mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.78.0 with full pending changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+67
-13
@@ -1,16 +1,70 @@
|
||||
## ProxyCast v0.77.0
|
||||
# ProxyCast v0.78.0 Release Notes
|
||||
|
||||
### ✨ 新功能
|
||||
- 添加可观测性面板,支持响应缓存配置和剪贴板权限指南 (ad61472a)
|
||||
- 增强崩溃诊断功能,新增调用错误缓冲区、前端崩溃缓冲区和应用版本信息 (ff915523)
|
||||
- 改进 Aster 运行时、Agent 命令、小说解析和日志检索功能 (68e16d4a)
|
||||
- API 服务器新增请求去重、响应缓存和能力路由指标 (0b2d3caa)
|
||||
- 使用崩溃边界包裹应用路由,添加启动时工作区检查,改进小说角色解析 (61270d6f)
|
||||
## 🎯 主要功能
|
||||
|
||||
### 🐛 修复
|
||||
- 为工作区健康检查添加自动重定位功能,使用修复标志进行遥测 (0f1be80b)
|
||||
- 构建 workspace 目录 4 层健康防护体系,彻底解决路径缺失问题 (2d00911c)
|
||||
### Tool Calling 2.0
|
||||
- 新增 Tool Calling 2.0 配置系统,支持统一控制编程式工具调用
|
||||
- 支持动态过滤功能,优先过滤网页抓取噪音
|
||||
- 支持原生 input_examples 透传
|
||||
- 在实验性设置中新增 Tool Calling 配置面板
|
||||
|
||||
### 🔧 优化与重构
|
||||
- 完善工作区健康监控和错误恢复机制
|
||||
- 提升应用稳定性和可观测性
|
||||
### 联网搜索增强
|
||||
- 新增多种联网搜索提供商支持:
|
||||
- Tavily Search API
|
||||
- Multi Search Engine v2.0.1(支持 12+ 搜索引擎)
|
||||
- DuckDuckGo Instant Answer API(无需 API Key,默认启用)
|
||||
- Bing Search API
|
||||
- Google Custom Search API
|
||||
- Multi Search Engine 支持自定义引擎优先级和启用/禁用控制
|
||||
- 新增 Web Search Runtime Service 用于运行时搜索能力
|
||||
|
||||
### MCP 工具增强
|
||||
- 改进 MCP 工具管理器,支持更灵活的工具转换
|
||||
- 新增 MCP 工具类型定义和转换逻辑
|
||||
- 优化 MCP 命令接口
|
||||
|
||||
### Provider 增强
|
||||
- Claude Custom Provider 支持更丰富的工具调用配置
|
||||
- OpenAI Custom Provider 增强工具调用能力
|
||||
- 改进 Provider Calls 处理逻辑
|
||||
|
||||
## 🔧 改进
|
||||
|
||||
### Agent 系统
|
||||
- 改进 Aster Agent 状态管理
|
||||
- 优化事件转换器逻辑
|
||||
- 增强 Agent 命令接口(新增 643 行代码)
|
||||
- 改进 Unified Chat 命令处理
|
||||
|
||||
### UI/UX
|
||||
- 优化 Agent Chat 界面
|
||||
- 改进空状态显示
|
||||
- 优化角色提及(Character Mention)组件
|
||||
- 改进输入栏交互
|
||||
- 优化流式渲染和工具调用显示
|
||||
- 改进实验性设置界面布局
|
||||
- 优化 Web Search 设置界面,支持多提供商配置
|
||||
|
||||
### 配置系统
|
||||
- 新增 `tool_calling` 配置项到核心配置
|
||||
- 新增 `WebSearchProvider` 枚举类型
|
||||
- 新增 `MultiSearchEngineEntryConfig` 和 `MultiSearchConfig` 配置类型
|
||||
- 改进配置测试覆盖
|
||||
|
||||
## 🐛 修复
|
||||
- 修复版本号测试用例(0.77.0 → 0.78.0)
|
||||
- 改进 Tauri Mock 核心逻辑
|
||||
- 优化 API Server 页面
|
||||
|
||||
## 📊 统计
|
||||
- 46 个文件修改
|
||||
- +3622 行新增代码
|
||||
- -323 行删除代码
|
||||
|
||||
## 🔗 依赖更新
|
||||
- 更新 Aster 依赖到 v0.16.0(通过 git tag)
|
||||
- 更新 Cargo.lock 依赖
|
||||
|
||||
---
|
||||
|
||||
**完整变更**: v0.77.0...v0.78.0
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.77.0",
|
||||
"version": "0.78.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+28
-28
@@ -369,7 +369,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "aster-core"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
@@ -461,7 +461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aster-models"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2399,7 +2399,7 @@ dependencies = [
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"matches",
|
||||
"phf 0.10.1",
|
||||
"phf 0.8.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"smallvec",
|
||||
@@ -2415,7 +2415,7 @@ dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.11.3",
|
||||
"phf 0.8.0",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -4324,7 +4324,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5679,7 +5679,7 @@ version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -6456,7 +6456,9 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
|
||||
dependencies = [
|
||||
"phf_macros 0.8.0",
|
||||
"phf_shared 0.8.0",
|
||||
"proc-macro-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6465,9 +6467,7 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
|
||||
dependencies = [
|
||||
"phf_macros 0.10.0",
|
||||
"phf_shared 0.10.0",
|
||||
"proc-macro-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6571,12 +6571,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.10.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
|
||||
checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
|
||||
dependencies = [
|
||||
"phf_generator 0.10.0",
|
||||
"phf_shared 0.10.0",
|
||||
"phf_generator 0.8.0",
|
||||
"phf_shared 0.8.0",
|
||||
"proc-macro-hack",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6988,7 +6988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"itertools 0.12.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -7097,7 +7097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-agent"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-trait",
|
||||
@@ -7121,7 +7121,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-config"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -7137,7 +7137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-core"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -7177,7 +7177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-credential"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -7212,7 +7212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-infra"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -7232,7 +7232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-mcp"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"glob",
|
||||
@@ -7263,7 +7263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-processor"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -7282,7 +7282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-providers"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -7334,7 +7334,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
@@ -7379,7 +7379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server-utils"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -7394,7 +7394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-services"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -7435,7 +7435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-skills"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7451,7 +7451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-terminal"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7478,7 +7478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-websocket"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -8957,7 +8957,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dirs 4.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
@@ -123,11 +123,11 @@ enigo = "0.3"
|
||||
# Aster Agent Framework
|
||||
# 开发时使用本地 aster-rust,CI/CD 使用远程 GitHub 仓库
|
||||
# 本地开发: path = "../../../astercloud/aster-rust/crates/aster" (相对 src-tauri/)
|
||||
# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.15.0"
|
||||
# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.16.0"
|
||||
# aster = { package = "aster-core", path = "../../../astercloud/aster-rust/crates/aster" }
|
||||
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.15.0" }
|
||||
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.16.0" }
|
||||
# 本地开发: aster-models = { path = "../../../astercloud/aster-rust/crates/aster-models" }
|
||||
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.15.0" }
|
||||
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.16.0" }
|
||||
|
||||
# MCP (Model Context Protocol)
|
||||
rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] }
|
||||
|
||||
@@ -585,6 +585,9 @@ impl AsterAgentState {
|
||||
timeout: None,
|
||||
bundled: Some(false),
|
||||
available_tools: Vec::new(),
|
||||
deferred_loading: false,
|
||||
always_expose_tools: Vec::new(),
|
||||
allowed_caller: None,
|
||||
};
|
||||
|
||||
// 注册到 ExtensionManager
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use aster::agents::AgentEvent;
|
||||
use aster::conversation::message::{ActionRequiredData, Message, MessageContent};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 从工具结果中提取文本内容
|
||||
@@ -74,12 +75,71 @@ fn extract_tool_result_text<T: serde::Serialize>(result: &T) -> String {
|
||||
collect_tool_result_text(&json, &mut parts);
|
||||
let deduped = dedupe_preserve_order(parts);
|
||||
if !deduped.is_empty() {
|
||||
return deduped.join("\n");
|
||||
return maybe_filter_web_content(&deduped.join("\n"));
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn dynamic_filtering_enabled() -> bool {
|
||||
proxycast_core::tool_calling::tool_calling_dynamic_filtering_enabled()
|
||||
}
|
||||
|
||||
fn maybe_filter_web_content(raw: &str) -> String {
|
||||
if !dynamic_filtering_enabled() {
|
||||
return raw.to_string();
|
||||
}
|
||||
|
||||
let lowered = raw.to_ascii_lowercase();
|
||||
let looks_like_html =
|
||||
(lowered.contains("<html") || lowered.contains("<body") || lowered.contains("</div>"))
|
||||
&& raw.len() > 4_000;
|
||||
if !looks_like_html {
|
||||
return raw.to_string();
|
||||
}
|
||||
|
||||
let script_re = Regex::new(r"(?is)<script[^>]*>.*?</script>").ok();
|
||||
let style_re = Regex::new(r"(?is)<style[^>]*>.*?</style>").ok();
|
||||
let tag_re = Regex::new(r"(?is)<[^>]+>").ok();
|
||||
let space_re = Regex::new(r"[ \t]{2,}").ok();
|
||||
let newline_re = Regex::new(r"\n{3,}").ok();
|
||||
|
||||
let mut cleaned = raw.to_string();
|
||||
if let Some(re) = script_re.as_ref() {
|
||||
cleaned = re.replace_all(&cleaned, " ").to_string();
|
||||
}
|
||||
if let Some(re) = style_re.as_ref() {
|
||||
cleaned = re.replace_all(&cleaned, " ").to_string();
|
||||
}
|
||||
if let Some(re) = tag_re.as_ref() {
|
||||
cleaned = re.replace_all(&cleaned, "\n").to_string();
|
||||
}
|
||||
if let Some(re) = space_re.as_ref() {
|
||||
cleaned = re.replace_all(&cleaned, " ").to_string();
|
||||
}
|
||||
if let Some(re) = newline_re.as_ref() {
|
||||
cleaned = re.replace_all(&cleaned, "\n\n").to_string();
|
||||
}
|
||||
cleaned = cleaned
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
const MAX_FILTERED_CHARS: usize = 8_000;
|
||||
if cleaned.chars().count() > MAX_FILTERED_CHARS {
|
||||
let shortened = cleaned.chars().take(MAX_FILTERED_CHARS).collect::<String>();
|
||||
return format!(
|
||||
"{}\n\n[dynamic_filtering] 内容已裁剪,原始长度 {} 字符",
|
||||
shortened,
|
||||
cleaned.chars().count()
|
||||
);
|
||||
}
|
||||
|
||||
cleaned
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ExtractedToolResult {
|
||||
output: String,
|
||||
@@ -801,4 +861,16 @@ mod tests {
|
||||
assert_eq!(extracted.images.len(), 1);
|
||||
assert_eq!(extracted.images[0].src, "data:image/png;base64,aGVsbG8=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_maybe_filter_web_content_should_strip_html_noise() {
|
||||
let html = format!(
|
||||
"<html><head><style>body{{color:red}}</style><script>alert(1)</script></head><body>{}</body></html>",
|
||||
"正文".repeat(2500)
|
||||
);
|
||||
let filtered = maybe_filter_web_content(&html);
|
||||
assert!(!filtered.to_ascii_lowercase().contains("<html"));
|
||||
assert!(!filtered.to_ascii_lowercase().contains("<script"));
|
||||
assert!(filtered.contains("正文"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ impl GlobalConfigManager {
|
||||
|
||||
/// 更新配置并通知观察者
|
||||
pub async fn update_config(&self, new_config: Config, source: ConfigChangeSource) {
|
||||
proxycast_core::tool_calling::apply_tool_calling_runtime_config(&new_config);
|
||||
{
|
||||
let hot_reload = self.hot_reload.read();
|
||||
hot_reload.update_config(new_config.clone());
|
||||
|
||||
@@ -279,6 +279,15 @@ pub struct FunctionDefinition {
|
||||
pub description: String,
|
||||
/// 参数 schema
|
||||
pub parameters: serde_json::Value,
|
||||
/// 工具输入示例(用于复杂参数提高准确率)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_examples: Option<Vec<serde_json::Value>>,
|
||||
/// 允许调用方(assistant/code_execution/tool_search)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_callers: Option<Vec<String>>,
|
||||
/// 是否延迟加载(默认不注入上下文)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deferred_loading: Option<bool>,
|
||||
}
|
||||
|
||||
/// Agent 配置
|
||||
|
||||
@@ -28,12 +28,13 @@ pub use types::{
|
||||
HeartbeatSecurityConfig, HeartbeatSettings, HintRouteSettingsEntry, HintRouterSettings,
|
||||
ImageGenConfig, InjectionRuleConfig, InjectionSettings, LoggingConfig, MemoryAutoConfig,
|
||||
MemoryConfig, MemoryProfileConfig, MemoryResolveConfig, MemorySourcesConfig, ModelInfo,
|
||||
ModelsConfig, NativeAgentConfig, NavigationConfig, OpenAIAsrConfig, PairingSettings,
|
||||
ProviderConfig, ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig, RateLimitSettings,
|
||||
RemoteManagementConfig, ResponseCacheSettings, RetrySettings, RoutingConfig,
|
||||
ScreenshotChatConfig, SearchEngine, ServerConfig, TaskSchedule, TlsConfig, UpdateCheckConfig,
|
||||
UserProfile, VertexApiKeyEntry, VertexModelAlias, VoiceConfig, VoiceInputConfig,
|
||||
VoiceInstruction, VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig,
|
||||
ModelsConfig, MultiSearchConfig, MultiSearchEngineEntryConfig, NativeAgentConfig,
|
||||
NavigationConfig, OpenAIAsrConfig, PairingSettings, ProviderConfig, ProviderModelsConfig,
|
||||
ProvidersConfig, QuotaExceededConfig, RateLimitSettings, RemoteManagementConfig,
|
||||
ResponseCacheSettings, RetrySettings, RoutingConfig, ScreenshotChatConfig, SearchEngine,
|
||||
ServerConfig, TaskSchedule, TlsConfig, ToolCallingConfig, UpdateCheckConfig, UserProfile,
|
||||
VertexApiKeyEntry, VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction,
|
||||
VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, WebSearchProvider,
|
||||
WhisperLocalConfig, WhisperModelSize, WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY,
|
||||
};
|
||||
pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService};
|
||||
|
||||
@@ -191,6 +191,7 @@ fn arb_config() -> impl Strategy<Value = Config> {
|
||||
agent: crate::config::NativeAgentConfig::default(),
|
||||
language: "zh".to_string(),
|
||||
experimental: crate::config::ExperimentalFeatures::default(),
|
||||
tool_calling: crate::config::ToolCallingConfig::default(),
|
||||
content_creator: ContentCreatorConfig::default(),
|
||||
navigation: NavigationConfig::default(),
|
||||
})
|
||||
@@ -432,6 +433,7 @@ fn arb_valid_config() -> impl Strategy<Value = Config> {
|
||||
agent: crate::config::NativeAgentConfig::default(),
|
||||
language: "zh".to_string(),
|
||||
experimental: crate::config::ExperimentalFeatures::default(),
|
||||
tool_calling: crate::config::ToolCallingConfig::default(),
|
||||
content_creator: ContentCreatorConfig::default(),
|
||||
navigation: NavigationConfig::default(),
|
||||
})
|
||||
@@ -483,6 +485,7 @@ fn arb_invalid_config() -> impl Strategy<Value = Config> {
|
||||
agent: crate::config::NativeAgentConfig::default(),
|
||||
language: "zh".to_string(),
|
||||
experimental: crate::config::ExperimentalFeatures::default(),
|
||||
tool_calling: crate::config::ToolCallingConfig::default(),
|
||||
content_creator: ContentCreatorConfig::default(),
|
||||
navigation: NavigationConfig::default(),
|
||||
};
|
||||
|
||||
@@ -399,6 +399,9 @@ pub struct Config {
|
||||
/// 实验室功能配置
|
||||
#[serde(default)]
|
||||
pub experimental: ExperimentalFeatures,
|
||||
/// Tool Calling 2.0 配置
|
||||
#[serde(default)]
|
||||
pub tool_calling: ToolCallingConfig,
|
||||
/// 内容创作配置
|
||||
#[serde(default)]
|
||||
pub content_creator: ContentCreatorConfig,
|
||||
@@ -745,6 +748,40 @@ pub struct ExperimentalFeatures {
|
||||
pub voice_input: VoiceInputConfig,
|
||||
}
|
||||
|
||||
/// Tool Calling 2.0 配置
|
||||
///
|
||||
/// 统一控制编程式工具调用、动态过滤与 input examples 透传行为。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ToolCallingConfig {
|
||||
/// 是否启用 Tool Calling 2.0 能力
|
||||
#[serde(default = "default_tool_calling_enabled")]
|
||||
pub enabled: bool,
|
||||
/// 是否启用动态过滤(优先过滤网页抓取噪音)
|
||||
#[serde(default = "default_tool_calling_dynamic_filtering_enabled")]
|
||||
pub dynamic_filtering: bool,
|
||||
/// 是否启用原生 input_examples 透传
|
||||
#[serde(default)]
|
||||
pub native_input_examples: bool,
|
||||
}
|
||||
|
||||
fn default_tool_calling_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_tool_calling_dynamic_filtering_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for ToolCallingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_tool_calling_enabled(),
|
||||
dynamic_filtering: default_tool_calling_dynamic_filtering_enabled(),
|
||||
native_input_examples: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 语音输入功能配置类型 ============
|
||||
|
||||
/// 语音输入功能配置
|
||||
@@ -1921,6 +1958,7 @@ impl Default for Config {
|
||||
models: ModelsConfig::default(),
|
||||
agent: NativeAgentConfig::default(),
|
||||
experimental: ExperimentalFeatures::default(),
|
||||
tool_calling: ToolCallingConfig::default(),
|
||||
content_creator: ContentCreatorConfig::default(),
|
||||
navigation: NavigationConfig::default(),
|
||||
chat_appearance: ChatAppearanceConfig::default(),
|
||||
@@ -1954,12 +1992,143 @@ pub enum SearchEngine {
|
||||
Xiaohongshu,
|
||||
}
|
||||
|
||||
/// 联网搜索提供商类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WebSearchProvider {
|
||||
/// Tavily Search API
|
||||
Tavily,
|
||||
/// Multi Search Engine v2.0.1
|
||||
MultiSearchEngine,
|
||||
/// DuckDuckGo Instant Answer API(无需 Key)
|
||||
#[default]
|
||||
DuckduckgoInstant,
|
||||
/// Bing Search API
|
||||
BingSearchApi,
|
||||
/// Google Custom Search API
|
||||
GoogleCustomSearch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MultiSearchEngineEntryConfig {
|
||||
/// 引擎标识名
|
||||
pub name: String,
|
||||
/// 搜索 URL 模板,必须包含 {query}
|
||||
pub url_template: String,
|
||||
/// 是否启用该引擎
|
||||
#[serde(default = "default_mse_engine_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MultiSearchConfig {
|
||||
/// 引擎优先级(按名称)
|
||||
#[serde(default)]
|
||||
pub priority: Vec<String>,
|
||||
/// 自定义/覆盖引擎列表
|
||||
#[serde(default = "default_multi_search_engines")]
|
||||
pub engines: Vec<MultiSearchEngineEntryConfig>,
|
||||
/// 每个引擎最大结果数
|
||||
#[serde(default = "default_mse_max_results_per_engine")]
|
||||
pub max_results_per_engine: usize,
|
||||
/// 最终聚合最大结果数
|
||||
#[serde(default = "default_mse_max_total_results")]
|
||||
pub max_total_results: usize,
|
||||
/// 每个引擎请求超时(毫秒)
|
||||
#[serde(default = "default_mse_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for MultiSearchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
priority: vec![],
|
||||
engines: default_multi_search_engines(),
|
||||
max_results_per_engine: default_mse_max_results_per_engine(),
|
||||
max_total_results: default_mse_max_total_results(),
|
||||
timeout_ms: default_mse_timeout_ms(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_mse_engine_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_mse_max_results_per_engine() -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_mse_max_total_results() -> usize {
|
||||
20
|
||||
}
|
||||
|
||||
fn default_mse_timeout_ms() -> u64 {
|
||||
4000
|
||||
}
|
||||
|
||||
fn default_multi_search_engines() -> Vec<MultiSearchEngineEntryConfig> {
|
||||
vec![
|
||||
("google", "https://www.google.com/search?q={query}"),
|
||||
("bing", "https://www.bing.com/search?q={query}"),
|
||||
("duckduckgo", "https://duckduckgo.com/?q={query}"),
|
||||
("yahoo", "https://search.yahoo.com/search?p={query}"),
|
||||
("baidu", "https://www.baidu.com/s?wd={query}"),
|
||||
("yandex", "https://yandex.com/search/?text={query}"),
|
||||
("ecosia", "https://www.ecosia.org/search?q={query}"),
|
||||
("brave", "https://search.brave.com/search?q={query}"),
|
||||
(
|
||||
"startpage",
|
||||
"https://www.startpage.com/do/search?query={query}",
|
||||
),
|
||||
("qwant", "https://www.qwant.com/?q={query}&t=web"),
|
||||
("sogou", "https://www.sogou.com/web?query={query}"),
|
||||
("so360", "https://www.so.com/s?q={query}"),
|
||||
("aol", "https://search.aol.com/aol/search?q={query}"),
|
||||
("ask", "https://www.ask.com/web?q={query}"),
|
||||
(
|
||||
"naver",
|
||||
"https://search.naver.com/search.naver?query={query}",
|
||||
),
|
||||
("seznam", "https://search.seznam.cz/?q={query}"),
|
||||
("dogpile", "https://www.dogpile.com/serp?q={query}"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, url_template)| MultiSearchEngineEntryConfig {
|
||||
name: name.to_string(),
|
||||
url_template: url_template.to_string(),
|
||||
enabled: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 网络搜索配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct WebSearchConfig {
|
||||
/// 默认搜索引擎偏好
|
||||
#[serde(default)]
|
||||
pub engine: SearchEngine,
|
||||
/// 联网搜索提供商
|
||||
#[serde(default)]
|
||||
pub provider: WebSearchProvider,
|
||||
/// 提供商回退优先级
|
||||
#[serde(default)]
|
||||
pub provider_priority: Vec<WebSearchProvider>,
|
||||
/// Tavily Search API Key
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tavily_api_key: Option<String>,
|
||||
/// Bing Search API Key
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bing_search_api_key: Option<String>,
|
||||
/// Google Search API Key
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub google_search_api_key: Option<String>,
|
||||
/// Google Search Engine ID (CSE CX)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub google_search_engine_id: Option<String>,
|
||||
/// Multi Search Engine 配置
|
||||
#[serde(default)]
|
||||
pub multi_search: MultiSearchConfig,
|
||||
}
|
||||
|
||||
/// 聊天外观配置
|
||||
@@ -2600,6 +2769,31 @@ mod unit_tests {
|
||||
assert_eq!(parsed, config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_calling_config_default() {
|
||||
let config = ToolCallingConfig::default();
|
||||
assert!(config.enabled);
|
||||
assert!(config.dynamic_filtering);
|
||||
assert!(!config.native_input_examples);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_calling_config_serialization() {
|
||||
let config = ToolCallingConfig {
|
||||
enabled: false,
|
||||
dynamic_filtering: false,
|
||||
native_input_examples: true,
|
||||
};
|
||||
|
||||
let yaml = serde_yaml::to_string(&config).unwrap();
|
||||
assert!(yaml.contains("enabled: false"));
|
||||
assert!(yaml.contains("dynamic_filtering: false"));
|
||||
assert!(yaml.contains("native_input_examples: true"));
|
||||
|
||||
let parsed: ToolCallingConfig = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(parsed, config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_experimental() {
|
||||
let config = Config::default();
|
||||
@@ -2614,6 +2808,9 @@ mod unit_tests {
|
||||
config.experimental.voice_input.shortcut,
|
||||
"CommandOrControl+Shift+V"
|
||||
);
|
||||
assert!(config.tool_calling.enabled);
|
||||
assert!(config.tool_calling.dynamic_filtering);
|
||||
assert!(!config.tool_calling.native_input_examples);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -36,6 +36,7 @@ pub mod orchestrator;
|
||||
pub mod plugin;
|
||||
pub mod session;
|
||||
pub mod session_files;
|
||||
pub mod tool_calling;
|
||||
|
||||
// 类型模块(纯数据类型,供 database 等模块使用)
|
||||
pub mod agent;
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Tool Calling 2.0 运行时配置
|
||||
//!
|
||||
//! 通过内存态开关提供跨 crate 的统一读取入口,并保留环境变量兜底覆盖。
|
||||
|
||||
use crate::config::{Config, ToolCallingConfig};
|
||||
use serde_json::{Map, Value};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
const ENV_TOOLCALL_V2_ENABLED: &str = "PROXYCAST_TOOLCALL_V2_ENABLED";
|
||||
const ENV_TOOLCALL_V2_DYNAMIC_FILTERING: &str = "PROXYCAST_TOOLCALL_V2_DYNAMIC_FILTERING";
|
||||
const ENV_TOOLCALL_V2_NATIVE_INPUT_EXAMPLES: &str = "PROXYCAST_TOOLCALL_V2_NATIVE_INPUT_EXAMPLES";
|
||||
|
||||
static TOOLCALL_RUNTIME_INITIALIZED: AtomicBool = AtomicBool::new(false);
|
||||
static TOOLCALL_V2_ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
static TOOLCALL_DYNAMIC_FILTERING_ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
static TOOLCALL_NATIVE_INPUT_EXAMPLES_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn parse_bool_env(name: &str) -> Option<bool> {
|
||||
let raw = std::env::var(name).ok()?;
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 将配置应用到进程内运行时开关。
|
||||
pub fn apply_tool_calling_runtime_config(config: &Config) {
|
||||
apply_tool_calling_runtime_config_with_flags(&config.tool_calling);
|
||||
}
|
||||
|
||||
/// 将 Tool Calling 配置应用到进程内运行时开关。
|
||||
pub fn apply_tool_calling_runtime_config_with_flags(flags: &ToolCallingConfig) {
|
||||
TOOLCALL_V2_ENABLED.store(flags.enabled, Ordering::Release);
|
||||
TOOLCALL_DYNAMIC_FILTERING_ENABLED.store(flags.dynamic_filtering, Ordering::Release);
|
||||
TOOLCALL_NATIVE_INPUT_EXAMPLES_ENABLED.store(flags.native_input_examples, Ordering::Release);
|
||||
TOOLCALL_RUNTIME_INITIALIZED.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Tool Calling 2.0 总开关。
|
||||
pub fn tool_calling_v2_enabled() -> bool {
|
||||
if let Some(value) = parse_bool_env(ENV_TOOLCALL_V2_ENABLED) {
|
||||
return value;
|
||||
}
|
||||
if TOOLCALL_RUNTIME_INITIALIZED.load(Ordering::Acquire) {
|
||||
return TOOLCALL_V2_ENABLED.load(Ordering::Acquire);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Tool Calling 动态过滤开关。
|
||||
pub fn tool_calling_dynamic_filtering_enabled() -> bool {
|
||||
if let Some(value) = parse_bool_env(ENV_TOOLCALL_V2_DYNAMIC_FILTERING) {
|
||||
return value;
|
||||
}
|
||||
if TOOLCALL_RUNTIME_INITIALIZED.load(Ordering::Acquire) {
|
||||
return TOOLCALL_DYNAMIC_FILTERING_ENABLED.load(Ordering::Acquire);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Tool Calling 原生 input examples 透传开关。
|
||||
pub fn tool_calling_native_input_examples_enabled() -> bool {
|
||||
if let Some(value) = parse_bool_env(ENV_TOOLCALL_V2_NATIVE_INPUT_EXAMPLES) {
|
||||
return value;
|
||||
}
|
||||
if TOOLCALL_RUNTIME_INITIALIZED.load(Ordering::Acquire) {
|
||||
return TOOLCALL_NATIVE_INPUT_EXAMPLES_ENABLED.load(Ordering::Acquire);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn schema_read_examples(schema: &Value) -> Vec<Value> {
|
||||
let extension = schema
|
||||
.get("x-proxycast")
|
||||
.or_else(|| schema.get("x_proxycast"))
|
||||
.unwrap_or(schema);
|
||||
|
||||
extension
|
||||
.get("input_examples")
|
||||
.or_else(|| extension.get("inputExamples"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter(|v| !v.is_null()).cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn pick_example_value(field_name: &str, schema: &Value, depth: usize) -> Value {
|
||||
if let Some(enum_values) = schema.get("enum").and_then(|v| v.as_array()) {
|
||||
if let Some(first) = enum_values.first() {
|
||||
return first.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(one_of) = schema
|
||||
.get("oneOf")
|
||||
.or_else(|| schema.get("anyOf"))
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
{
|
||||
return pick_example_value(field_name, one_of, depth + 1);
|
||||
}
|
||||
|
||||
let field_name_lc = field_name.to_ascii_lowercase();
|
||||
let field_type = schema
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("string");
|
||||
|
||||
match field_type {
|
||||
"boolean" => Value::Bool(true),
|
||||
"integer" => {
|
||||
if field_name_lc.contains("count")
|
||||
|| field_name_lc.contains("limit")
|
||||
|| field_name_lc.contains("top")
|
||||
{
|
||||
Value::Number(3.into())
|
||||
} else {
|
||||
Value::Number(1.into())
|
||||
}
|
||||
}
|
||||
"number" => Value::Number(serde_json::Number::from_f64(0.5).unwrap_or_else(|| 0.into())),
|
||||
"array" => {
|
||||
if depth >= 2 {
|
||||
return Value::Array(Vec::new());
|
||||
}
|
||||
let item_schema = schema.get("items").unwrap_or(&Value::Null);
|
||||
Value::Array(vec![pick_example_value(field_name, item_schema, depth + 1)])
|
||||
}
|
||||
"object" => {
|
||||
if depth >= 2 {
|
||||
return Value::Object(Map::new());
|
||||
}
|
||||
synthesize_example_from_schema(schema, depth + 1)
|
||||
.unwrap_or_else(|| Value::Object(Map::new()))
|
||||
}
|
||||
_ => {
|
||||
if field_name_lc.contains("url") || field_name_lc.contains("link") {
|
||||
Value::String("https://example.com".to_string())
|
||||
} else if field_name_lc.contains("query") || field_name_lc.contains("keyword") {
|
||||
Value::String("latest ai agent tool calling updates".to_string())
|
||||
} else if field_name_lc.contains("prompt")
|
||||
|| field_name_lc.contains("instruction")
|
||||
|| field_name_lc.contains("question")
|
||||
{
|
||||
Value::String("请提炼三条关键信息并给出结论".to_string())
|
||||
} else if field_name_lc.contains("id") {
|
||||
Value::String("example-id".to_string())
|
||||
} else if field_name_lc.contains("path") {
|
||||
Value::String("/tmp/example".to_string())
|
||||
} else {
|
||||
Value::String("example".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn synthesize_example_from_schema(schema: &Value, depth: usize) -> Option<Value> {
|
||||
let properties = schema.get("properties").and_then(|v| v.as_object())?;
|
||||
if properties.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let required = schema
|
||||
.get("required")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut keys = required.clone();
|
||||
let mut optional_keys = properties.keys().cloned().collect::<Vec<_>>();
|
||||
optional_keys.sort();
|
||||
for key in optional_keys {
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
let max_fields = if depth == 0 { 6 } else { 4 };
|
||||
let mut out = Map::new();
|
||||
for key in keys.into_iter().take(max_fields) {
|
||||
if let Some(field_schema) = properties.get(&key) {
|
||||
out.insert(key.clone(), pick_example_value(&key, field_schema, depth));
|
||||
}
|
||||
}
|
||||
|
||||
Some(Value::Object(out))
|
||||
}
|
||||
|
||||
/// 解析工具 schema 内配置的 input_examples。
|
||||
pub fn configured_tool_input_examples(schema: &Value) -> Vec<Value> {
|
||||
schema_read_examples(schema)
|
||||
}
|
||||
|
||||
/// 获取工具可用的 input_examples(优先配置,内置工具缺省时按 schema 生成)。
|
||||
pub fn resolve_tool_input_examples(tool_name: &str, schema: &Value) -> Vec<Value> {
|
||||
let configured = schema_read_examples(schema);
|
||||
if !configured.is_empty() {
|
||||
return configured;
|
||||
}
|
||||
|
||||
let normalized = tool_name.trim().to_ascii_lowercase();
|
||||
let built_in = matches!(
|
||||
normalized.as_str(),
|
||||
"websearch" | "webfetch" | "three_stage_workflow" | "tool_search"
|
||||
);
|
||||
if !built_in {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
synthesize_example_from_schema(schema, 0)
|
||||
.map(|v| vec![v])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
|
||||
}
|
||||
|
||||
fn clear_tool_calling_envs() {
|
||||
std::env::remove_var(ENV_TOOLCALL_V2_ENABLED);
|
||||
std::env::remove_var(ENV_TOOLCALL_V2_DYNAMIC_FILTERING);
|
||||
std::env::remove_var(ENV_TOOLCALL_V2_NATIVE_INPUT_EXAMPLES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_flags_apply_and_read() {
|
||||
let _guard = env_lock();
|
||||
clear_tool_calling_envs();
|
||||
apply_tool_calling_runtime_config_with_flags(&ToolCallingConfig {
|
||||
enabled: false,
|
||||
dynamic_filtering: false,
|
||||
native_input_examples: true,
|
||||
});
|
||||
|
||||
assert!(!tool_calling_v2_enabled());
|
||||
assert!(!tool_calling_dynamic_filtering_enabled());
|
||||
assert!(tool_calling_native_input_examples_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_overrides_runtime_flags() {
|
||||
let _guard = env_lock();
|
||||
clear_tool_calling_envs();
|
||||
|
||||
apply_tool_calling_runtime_config_with_flags(&ToolCallingConfig {
|
||||
enabled: false,
|
||||
dynamic_filtering: false,
|
||||
native_input_examples: false,
|
||||
});
|
||||
|
||||
std::env::set_var(ENV_TOOLCALL_V2_ENABLED, "true");
|
||||
std::env::set_var(ENV_TOOLCALL_V2_DYNAMIC_FILTERING, "1");
|
||||
std::env::set_var(ENV_TOOLCALL_V2_NATIVE_INPUT_EXAMPLES, "on");
|
||||
|
||||
assert!(tool_calling_v2_enabled());
|
||||
assert!(tool_calling_dynamic_filtering_enabled());
|
||||
assert!(tool_calling_native_input_examples_enabled());
|
||||
|
||||
clear_tool_calling_envs();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_tool_input_examples_prefers_configured_examples() {
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"x-proxycast": {
|
||||
"input_examples": [{"query": "rust async"}]
|
||||
}
|
||||
});
|
||||
let examples = resolve_tool_input_examples("WebSearch", &schema);
|
||||
assert_eq!(examples, vec![serde_json::json!({"query":"rust async"})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_tool_input_examples_generates_builtin_examples_from_schema() {
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type":"string"},
|
||||
"limit": {"type":"integer"}
|
||||
},
|
||||
"required": ["query"]
|
||||
});
|
||||
let examples = resolve_tool_input_examples("WebSearch", &schema);
|
||||
assert_eq!(examples.len(), 1);
|
||||
let example = examples[0].as_object().cloned().unwrap_or_default();
|
||||
assert!(example.contains_key("query"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_tool_input_examples_ignores_non_builtin_without_config() {
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type":"string"}
|
||||
}
|
||||
});
|
||||
let examples = resolve_tool_input_examples("docs_search", &schema);
|
||||
assert!(examples.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use proxycast_core::DynEmitter;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -42,6 +42,15 @@ use rmcp::ServiceExt;
|
||||
use crate::client::McpClientWrapper;
|
||||
use crate::types::*;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ToolMetadataExtraction {
|
||||
deferred_loading: Option<bool>,
|
||||
always_visible: Option<bool>,
|
||||
allowed_callers: Option<Vec<String>>,
|
||||
input_examples: Option<Vec<serde_json::Value>>,
|
||||
tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// MCP 客户端管理器
|
||||
///
|
||||
/// 负责管理所有 MCP 服务器的连接和生命周期。
|
||||
@@ -720,6 +729,8 @@ impl McpClientManager {
|
||||
"获取服务器工具列表成功"
|
||||
);
|
||||
for tool in tools {
|
||||
let input_schema = serde_json::Value::Object((*tool.input_schema).clone());
|
||||
let metadata = Self::extract_tool_metadata(&input_schema);
|
||||
all_tools.push(McpToolDefinition {
|
||||
name: tool.name.to_string(),
|
||||
description: tool
|
||||
@@ -727,8 +738,13 @@ impl McpClientManager {
|
||||
.clone()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default(),
|
||||
input_schema: serde_json::Value::Object((*tool.input_schema).clone()),
|
||||
input_schema,
|
||||
server_name: server_name.clone(),
|
||||
deferred_loading: metadata.deferred_loading,
|
||||
always_visible: metadata.always_visible,
|
||||
allowed_callers: metadata.allowed_callers,
|
||||
input_examples: metadata.input_examples,
|
||||
tags: metadata.tags,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -757,6 +773,179 @@ impl McpClientManager {
|
||||
Ok(resolved_tools)
|
||||
}
|
||||
|
||||
/// 根据上下文过滤工具列表
|
||||
///
|
||||
/// - `caller`: 调用方(assistant/code_execution/tool_search)
|
||||
/// - `include_deferred`: 是否包含延迟加载工具
|
||||
pub async fn list_tools_for_context(
|
||||
&self,
|
||||
caller: Option<&str>,
|
||||
include_deferred: bool,
|
||||
) -> Result<Vec<McpToolDefinition>, McpError> {
|
||||
let caller = caller
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
let tools = self.list_tools().await?;
|
||||
|
||||
let filtered = tools
|
||||
.into_iter()
|
||||
.filter(|tool| {
|
||||
// deferred_loading=true 且不是 always_visible 时,默认不注入上下文
|
||||
if !include_deferred
|
||||
&& tool.deferred_loading.unwrap_or(false)
|
||||
&& !tool.always_visible.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// caller 不在 allowed_callers 时,隐藏该工具
|
||||
if let (Some(caller), Some(allowed)) = (&caller, tool.allowed_callers.as_ref()) {
|
||||
let allowed_set: HashSet<String> = allowed
|
||||
.iter()
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect();
|
||||
if !allowed_set.is_empty() && !allowed_set.contains(caller) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(filtered)
|
||||
}
|
||||
|
||||
/// 搜索工具
|
||||
///
|
||||
/// 搜索默认包含 deferred 工具,便于模型通过 tool_search 检索按需加载。
|
||||
pub async fn search_tools(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
caller: Option<&str>,
|
||||
) -> Result<Vec<McpToolDefinition>, McpError> {
|
||||
let query = query.trim().to_ascii_lowercase();
|
||||
let limit = limit.clamp(1, 100);
|
||||
let mut tools = self.list_tools_for_context(caller, true).await?;
|
||||
|
||||
// 空查询:优先 always_visible,再按名称排序返回前 N
|
||||
if query.is_empty() {
|
||||
tools.sort_by(|a, b| {
|
||||
let a_visible = a.always_visible.unwrap_or(false);
|
||||
let b_visible = b.always_visible.unwrap_or(false);
|
||||
b_visible
|
||||
.cmp(&a_visible)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
tools.truncate(limit);
|
||||
return Ok(tools);
|
||||
}
|
||||
|
||||
let mut scored: Vec<(i32, McpToolDefinition)> = tools
|
||||
.into_iter()
|
||||
.filter_map(|tool| {
|
||||
let score = Self::score_tool_match(&tool, &query);
|
||||
(score > 0).then_some((score, tool))
|
||||
})
|
||||
.collect();
|
||||
|
||||
scored.sort_by(|(score_a, tool_a), (score_b, tool_b)| {
|
||||
score_b
|
||||
.cmp(score_a)
|
||||
.then_with(|| tool_a.name.to_lowercase().cmp(&tool_b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
let mut result = scored
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(_, tool)| tool)
|
||||
.collect::<Vec<_>>();
|
||||
result.truncate(limit);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn extract_tool_metadata(input_schema: &serde_json::Value) -> ToolMetadataExtraction {
|
||||
fn read_bool(root: &serde_json::Value, key: &str) -> Option<bool> {
|
||||
root.get(key).and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
fn read_string_vec(root: &serde_json::Value, key: &str) -> Option<Vec<String>> {
|
||||
let arr = root.get(key)?.as_array()?;
|
||||
let values = arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
(!values.is_empty()).then_some(values)
|
||||
}
|
||||
|
||||
fn read_examples(root: &serde_json::Value, key: &str) -> Option<Vec<serde_json::Value>> {
|
||||
let arr = root.get(key)?.as_array()?;
|
||||
let values = arr
|
||||
.iter()
|
||||
.filter(|v| !v.is_null())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
(!values.is_empty()).then_some(values)
|
||||
}
|
||||
|
||||
let extension = input_schema
|
||||
.get("x-proxycast")
|
||||
.or_else(|| input_schema.get("x_proxycast"))
|
||||
.unwrap_or(input_schema);
|
||||
|
||||
ToolMetadataExtraction {
|
||||
deferred_loading: read_bool(extension, "deferred_loading")
|
||||
.or_else(|| read_bool(extension, "deferredLoading")),
|
||||
always_visible: read_bool(extension, "always_visible")
|
||||
.or_else(|| read_bool(extension, "alwaysVisible")),
|
||||
allowed_callers: read_string_vec(extension, "allowed_callers")
|
||||
.or_else(|| read_string_vec(extension, "allowedCallers")),
|
||||
input_examples: read_examples(extension, "input_examples")
|
||||
.or_else(|| read_examples(extension, "inputExamples")),
|
||||
tags: read_string_vec(extension, "tags"),
|
||||
}
|
||||
}
|
||||
|
||||
fn score_tool_match(tool: &McpToolDefinition, query: &str) -> i32 {
|
||||
let name = tool.name.to_ascii_lowercase();
|
||||
let description = tool.description.to_ascii_lowercase();
|
||||
|
||||
let mut score = 0;
|
||||
if name == query {
|
||||
score += 120;
|
||||
} else if name.starts_with(query) {
|
||||
score += 90;
|
||||
} else if name.contains(query) {
|
||||
score += 70;
|
||||
}
|
||||
|
||||
if description.contains(query) {
|
||||
score += 40;
|
||||
}
|
||||
|
||||
if let Some(tags) = tool.tags.as_ref() {
|
||||
for tag in tags {
|
||||
let tag = tag.to_ascii_lowercase();
|
||||
if tag == query {
|
||||
score += 35;
|
||||
} else if tag.contains(query) {
|
||||
score += 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tool.always_visible.unwrap_or(false) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
/// 解决工具名称冲突
|
||||
///
|
||||
/// 当多个服务器提供同名工具时,为冲突的工具名称添加服务器前缀。
|
||||
@@ -769,8 +958,6 @@ impl McpClientManager {
|
||||
///
|
||||
/// 返回解决冲突后的工具列表。
|
||||
fn resolve_tool_name_conflicts(tools: Vec<McpToolDefinition>) -> Vec<McpToolDefinition> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// 统计每个工具名称出现的次数
|
||||
let mut name_counts: HashMap<String, usize> = HashMap::new();
|
||||
for tool in &tools {
|
||||
@@ -812,6 +999,46 @@ impl McpClientManager {
|
||||
///
|
||||
/// 返回工具调用结果。
|
||||
///
|
||||
/// # 实现步骤(Task 4.3)
|
||||
///
|
||||
/// 1. 解析工具名称,确定目标服务器
|
||||
/// 2. 路由到正确的客户端
|
||||
/// 3. 执行工具调用
|
||||
/// 4. 转换结果为 McpToolResult
|
||||
/// 5. 返回结果
|
||||
pub async fn call_tool_with_caller(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
arguments: serde_json::Value,
|
||||
caller: Option<&str>,
|
||||
) -> Result<McpToolResult, McpError> {
|
||||
let caller = caller
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
|
||||
if let Some(caller) = caller {
|
||||
let tools = self.list_tools().await?;
|
||||
if let Some(tool) = tools.iter().find(|t| t.name == tool_name) {
|
||||
if let Some(allowed) = tool.allowed_callers.as_ref() {
|
||||
let allowed_set: HashSet<String> = allowed
|
||||
.iter()
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect();
|
||||
if !allowed_set.is_empty() && !allowed_set.contains(&caller) {
|
||||
return Err(McpError::ToolCallFailed(format!(
|
||||
"调用方 '{}' 无权调用工具 '{}'",
|
||||
caller, tool_name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.call_tool(tool_name, arguments).await
|
||||
}
|
||||
|
||||
/// # 实现步骤(Task 4.3)
|
||||
///
|
||||
/// 1. 解析工具名称,确定目标服务器
|
||||
@@ -1523,6 +1750,20 @@ mod tests {
|
||||
McpClientWrapper::new(name.to_string(), create_test_config(), None)
|
||||
}
|
||||
|
||||
fn create_test_tool(name: &str, description: &str, server_name: &str) -> McpToolDefinition {
|
||||
McpToolDefinition {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: server_name.to_string(),
|
||||
deferred_loading: None,
|
||||
always_visible: None,
|
||||
allowed_callers: None,
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_creation() {
|
||||
let manager = McpClientManager::new(None);
|
||||
@@ -1672,18 +1913,8 @@ mod tests {
|
||||
|
||||
// 更新缓存
|
||||
let tools = vec![
|
||||
McpToolDefinition {
|
||||
name: "tool1".to_string(),
|
||||
description: "Test tool 1".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "server1".to_string(),
|
||||
},
|
||||
McpToolDefinition {
|
||||
name: "tool2".to_string(),
|
||||
description: "Test tool 2".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "server1".to_string(),
|
||||
},
|
||||
create_test_tool("tool1", "Test tool 1", "server1"),
|
||||
create_test_tool("tool2", "Test tool 2", "server1"),
|
||||
];
|
||||
manager.update_tool_cache(tools.clone()).await;
|
||||
|
||||
@@ -1731,6 +1962,33 @@ mod tests {
|
||||
assert!(Arc::strong_count(&state) >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_metadata_from_schema_extension() {
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"x-proxycast": {
|
||||
"deferred_loading": true,
|
||||
"always_visible": false,
|
||||
"allowed_callers": ["assistant", "code_execution"],
|
||||
"input_examples": [{"q": "rust"}],
|
||||
"tags": ["search", "docs"]
|
||||
}
|
||||
});
|
||||
let meta = McpClientManager::extract_tool_metadata(&schema);
|
||||
assert_eq!(meta.deferred_loading, Some(true));
|
||||
assert_eq!(meta.always_visible, Some(false));
|
||||
assert_eq!(
|
||||
meta.allowed_callers.unwrap_or_default(),
|
||||
vec!["assistant".to_string(), "code_execution".to_string()]
|
||||
);
|
||||
assert_eq!(meta.input_examples.unwrap_or_default().len(), 1);
|
||||
assert_eq!(
|
||||
meta.tags.unwrap_or_default(),
|
||||
vec!["search".to_string(), "docs".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 服务器生命周期测试
|
||||
// ========================================================================
|
||||
@@ -1827,12 +2085,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// 设置工具缓存
|
||||
let tools = vec![McpToolDefinition {
|
||||
name: "tool1".to_string(),
|
||||
description: "Test tool".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "test-server".to_string(),
|
||||
}];
|
||||
let tools = vec![create_test_tool("tool1", "Test tool", "test-server")];
|
||||
manager.update_tool_cache(tools).await;
|
||||
assert!(manager.is_tool_cache_valid().await);
|
||||
|
||||
@@ -1879,18 +2132,8 @@ mod tests {
|
||||
fn test_resolve_tool_name_conflicts_no_conflict() {
|
||||
// 没有冲突的情况
|
||||
let tools = vec![
|
||||
McpToolDefinition {
|
||||
name: "tool1".to_string(),
|
||||
description: "Tool 1".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "server1".to_string(),
|
||||
},
|
||||
McpToolDefinition {
|
||||
name: "tool2".to_string(),
|
||||
description: "Tool 2".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "server2".to_string(),
|
||||
},
|
||||
create_test_tool("tool1", "Tool 1", "server1"),
|
||||
create_test_tool("tool2", "Tool 2", "server2"),
|
||||
];
|
||||
|
||||
let resolved = McpClientManager::resolve_tool_name_conflicts(tools);
|
||||
@@ -1905,24 +2148,9 @@ mod tests {
|
||||
fn test_resolve_tool_name_conflicts_with_conflict() {
|
||||
// 有冲突的情况:两个服务器都提供 "read_file" 工具
|
||||
let tools = vec![
|
||||
McpToolDefinition {
|
||||
name: "read_file".to_string(),
|
||||
description: "Read file from server1".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "server1".to_string(),
|
||||
},
|
||||
McpToolDefinition {
|
||||
name: "read_file".to_string(),
|
||||
description: "Read file from server2".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "server2".to_string(),
|
||||
},
|
||||
McpToolDefinition {
|
||||
name: "unique_tool".to_string(),
|
||||
description: "Unique tool".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "server1".to_string(),
|
||||
},
|
||||
create_test_tool("read_file", "Read file from server1", "server1"),
|
||||
create_test_tool("read_file", "Read file from server2", "server2"),
|
||||
create_test_tool("unique_tool", "Unique tool", "server1"),
|
||||
];
|
||||
|
||||
let resolved = McpClientManager::resolve_tool_name_conflicts(tools);
|
||||
@@ -1939,24 +2167,9 @@ mod tests {
|
||||
fn test_resolve_tool_name_conflicts_multiple_conflicts() {
|
||||
// 多个冲突的情况
|
||||
let tools = vec![
|
||||
McpToolDefinition {
|
||||
name: "tool_a".to_string(),
|
||||
description: "Tool A from server1".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "server1".to_string(),
|
||||
},
|
||||
McpToolDefinition {
|
||||
name: "tool_a".to_string(),
|
||||
description: "Tool A from server2".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "server2".to_string(),
|
||||
},
|
||||
McpToolDefinition {
|
||||
name: "tool_a".to_string(),
|
||||
description: "Tool A from server3".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "server3".to_string(),
|
||||
},
|
||||
create_test_tool("tool_a", "Tool A from server1", "server1"),
|
||||
create_test_tool("tool_a", "Tool A from server2", "server2"),
|
||||
create_test_tool("tool_a", "Tool A from server3", "server3"),
|
||||
];
|
||||
|
||||
let resolved = McpClientManager::resolve_tool_name_conflicts(tools);
|
||||
@@ -1985,12 +2198,11 @@ mod tests {
|
||||
let manager = McpClientManager::new(None);
|
||||
|
||||
// 预先设置缓存
|
||||
let cached_tools = vec![McpToolDefinition {
|
||||
name: "cached_tool".to_string(),
|
||||
description: "Cached tool".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "cached_server".to_string(),
|
||||
}];
|
||||
let cached_tools = vec![create_test_tool(
|
||||
"cached_tool",
|
||||
"Cached tool",
|
||||
"cached_server",
|
||||
)];
|
||||
manager.update_tool_cache(cached_tools.clone()).await;
|
||||
|
||||
// 调用 list_tools 应该返回缓存的工具
|
||||
@@ -1999,6 +2211,119 @@ mod tests {
|
||||
assert_eq!(result[0].name, "cached_tool");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_tools_for_context_filters_deferred_and_caller() {
|
||||
let manager = McpClientManager::new(None);
|
||||
manager
|
||||
.update_tool_cache(vec![
|
||||
create_test_tool("always_tool", "always", "s1"),
|
||||
McpToolDefinition {
|
||||
name: "hidden_tool".to_string(),
|
||||
description: "hidden".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "s1".to_string(),
|
||||
deferred_loading: Some(true),
|
||||
always_visible: Some(false),
|
||||
allowed_callers: Some(vec!["code_execution".to_string()]),
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
},
|
||||
McpToolDefinition {
|
||||
name: "visible_deferred".to_string(),
|
||||
description: "visible deferred".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "s1".to_string(),
|
||||
deferred_loading: Some(true),
|
||||
always_visible: Some(true),
|
||||
allowed_callers: Some(vec!["assistant".to_string()]),
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
},
|
||||
])
|
||||
.await;
|
||||
|
||||
let assistant_tools = manager
|
||||
.list_tools_for_context(Some("assistant"), false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(assistant_tools.iter().any(|t| t.name == "always_tool"));
|
||||
assert!(assistant_tools.iter().any(|t| t.name == "visible_deferred"));
|
||||
assert!(!assistant_tools.iter().any(|t| t.name == "hidden_tool"));
|
||||
|
||||
let code_exec_tools = manager
|
||||
.list_tools_for_context(Some("code_execution"), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(code_exec_tools.iter().any(|t| t.name == "hidden_tool"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_tools_prioritizes_exact_match() {
|
||||
let manager = McpClientManager::new(None);
|
||||
manager
|
||||
.update_tool_cache(vec![
|
||||
McpToolDefinition {
|
||||
name: "weather".to_string(),
|
||||
description: "Get weather".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "s1".to_string(),
|
||||
deferred_loading: Some(true),
|
||||
always_visible: Some(false),
|
||||
allowed_callers: None,
|
||||
input_examples: None,
|
||||
tags: Some(vec!["forecast".to_string()]),
|
||||
},
|
||||
McpToolDefinition {
|
||||
name: "get_weather".to_string(),
|
||||
description: "weather by city".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "s1".to_string(),
|
||||
deferred_loading: Some(true),
|
||||
always_visible: Some(false),
|
||||
allowed_callers: None,
|
||||
input_examples: None,
|
||||
tags: Some(vec!["weather".to_string()]),
|
||||
},
|
||||
])
|
||||
.await;
|
||||
|
||||
let tools = manager
|
||||
.search_tools("weather", 5, Some("assistant"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tools.len(), 2);
|
||||
assert_eq!(tools[0].name, "weather");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_call_tool_with_caller_rejects_unauthorized_caller() {
|
||||
let manager = McpClientManager::new(None);
|
||||
manager
|
||||
.update_tool_cache(vec![McpToolDefinition {
|
||||
name: "restricted".to_string(),
|
||||
description: "Restricted tool".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "s1".to_string(),
|
||||
deferred_loading: None,
|
||||
always_visible: None,
|
||||
allowed_callers: Some(vec!["code_execution".to_string()]),
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
}])
|
||||
.await;
|
||||
|
||||
let result = manager
|
||||
.call_tool_with_caller("restricted", serde_json::json!({}), Some("assistant"))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(McpError::ToolCallFailed(message)) => {
|
||||
assert!(message.contains("无权调用"));
|
||||
}
|
||||
_ => panic!("Expected ToolCallFailed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_tools_returns_empty_when_no_servers() {
|
||||
let manager = McpClientManager::new(None);
|
||||
|
||||
@@ -29,6 +29,10 @@ pub struct OpenAIFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
/// OpenAI 兼容模型多数不原生支持 input_examples;
|
||||
/// 这里仅在上游支持时透传,默认使用 description 降级提示。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_examples: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// OpenAI 工具调用
|
||||
@@ -57,6 +61,10 @@ pub struct AnthropicTool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_examples: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_callers: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Anthropic 工具使用
|
||||
@@ -96,6 +104,34 @@ pub struct GeminiParameters {
|
||||
pub struct ToolConverter;
|
||||
|
||||
impl ToolConverter {
|
||||
fn build_openai_description(tool: &McpToolDefinition) -> String {
|
||||
let mut description = tool.description.clone();
|
||||
if let Some(examples) = tool.input_examples.as_ref() {
|
||||
if !examples.is_empty() {
|
||||
let rendered = examples
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ");
|
||||
description.push_str("\n\n[InputExamples] ");
|
||||
description.push_str(&rendered);
|
||||
}
|
||||
}
|
||||
if let Some(callers) = tool.allowed_callers.as_ref() {
|
||||
let normalized = callers
|
||||
.iter()
|
||||
.map(|v| v.trim())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if !normalized.is_empty() {
|
||||
description.push_str("\n\n[AllowedCallers] ");
|
||||
description.push_str(&normalized.join(", "));
|
||||
}
|
||||
}
|
||||
description
|
||||
}
|
||||
|
||||
/// 转换为 OpenAI function calling 格式
|
||||
pub fn to_openai(tools: &[McpToolDefinition]) -> Vec<OpenAITool> {
|
||||
tools
|
||||
@@ -104,8 +140,9 @@ impl ToolConverter {
|
||||
tool_type: "function".to_string(),
|
||||
function: OpenAIFunction {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
description: Self::build_openai_description(tool),
|
||||
parameters: tool.input_schema.clone(),
|
||||
input_examples: tool.input_examples.clone(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
@@ -119,6 +156,8 @@ impl ToolConverter {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
input_schema: tool.input_schema.clone(),
|
||||
input_examples: tool.input_examples.clone(),
|
||||
allowed_callers: tool.allowed_callers.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -178,3 +217,57 @@ impl ToolConverter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_tool() -> McpToolDefinition {
|
||||
McpToolDefinition {
|
||||
name: "search_docs".to_string(),
|
||||
description: "Search project docs".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": { "query": { "type": "string" } },
|
||||
"required": ["query"]
|
||||
}),
|
||||
server_name: "docs".to_string(),
|
||||
deferred_loading: Some(true),
|
||||
always_visible: Some(false),
|
||||
allowed_callers: Some(vec!["code_execution".to_string()]),
|
||||
input_examples: Some(vec![serde_json::json!({"query":"rust async"})]),
|
||||
tags: Some(vec!["docs".to_string(), "search".to_string()]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_openai_contains_fallback_description_and_examples() {
|
||||
let openai_tools = ToolConverter::to_openai(&[sample_tool()]);
|
||||
assert_eq!(openai_tools.len(), 1);
|
||||
let function = &openai_tools[0].function;
|
||||
assert!(function.description.contains("[InputExamples]"));
|
||||
assert!(function.description.contains("[AllowedCallers]"));
|
||||
assert_eq!(function.input_examples.as_ref().map(|v| v.len()), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_anthropic_passes_input_examples_and_allowed_callers() {
|
||||
let anthropic_tools = ToolConverter::to_anthropic(&[sample_tool()]);
|
||||
assert_eq!(anthropic_tools.len(), 1);
|
||||
assert_eq!(
|
||||
anthropic_tools[0]
|
||||
.input_examples
|
||||
.as_ref()
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(0),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
anthropic_tools[0]
|
||||
.allowed_callers
|
||||
.as_ref()
|
||||
.map(|v| v[0].as_str()),
|
||||
Some("code_execution")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,21 @@ pub struct McpToolDefinition {
|
||||
pub description: String,
|
||||
pub input_schema: serde_json::Value,
|
||||
pub server_name: String,
|
||||
/// 是否延迟加载(不默认注入上下文)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deferred_loading: Option<bool>,
|
||||
/// 是否始终可见(即使 deferred_loading=true 也可见)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub always_visible: Option<bool>,
|
||||
/// 允许调用方(如 assistant/code_execution/tool_search)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_callers: Option<Vec<String>>,
|
||||
/// 工具输入示例
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_examples: Option<Vec<serde_json::Value>>,
|
||||
/// 标签(用于工具搜索)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// MCP 工具调用请求
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 2dad3ab4b62243d81a62ce7df3d361f9a27633cc9ccf50cc78c370c66814f5bc # shrinks to expires_in_secs = 601
|
||||
@@ -131,6 +131,111 @@ impl ClaudeCustomProvider {
|
||||
None
|
||||
}
|
||||
|
||||
fn convert_openai_tool_to_anthropic(
|
||||
tool: &proxycast_core::models::openai::Tool,
|
||||
) -> Option<serde_json::Value> {
|
||||
match tool {
|
||||
proxycast_core::models::openai::Tool::Function { function } => {
|
||||
let input_schema = function
|
||||
.parameters
|
||||
.clone()
|
||||
.unwrap_or_else(|| serde_json::json!({"type":"object","properties":{}}));
|
||||
let extension = input_schema
|
||||
.get("x-proxycast")
|
||||
.or_else(|| input_schema.get("x_proxycast"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
let mut input_examples = extension
|
||||
.get("input_examples")
|
||||
.or_else(|| extension.get("inputExamples"))
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if input_examples.is_empty() {
|
||||
input_examples = proxycast_core::tool_calling::resolve_tool_input_examples(
|
||||
&function.name,
|
||||
&input_schema,
|
||||
);
|
||||
}
|
||||
let allowed_callers = extension
|
||||
.get("allowed_callers")
|
||||
.or_else(|| extension.get("allowedCallers"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut description = function.description.clone().unwrap_or_default();
|
||||
if !input_examples.is_empty() && !description.contains("[InputExamples]") {
|
||||
let rendered = input_examples
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ");
|
||||
description.push_str("\n\n[InputExamples] ");
|
||||
description.push_str(&rendered);
|
||||
}
|
||||
if !allowed_callers.is_empty() && !description.contains("[AllowedCallers]") {
|
||||
description.push_str("\n\n[AllowedCallers] ");
|
||||
description.push_str(&allowed_callers.join(", "));
|
||||
}
|
||||
|
||||
let mut anthropic_tool = serde_json::json!({
|
||||
"name": function.name,
|
||||
"description": description,
|
||||
"input_schema": input_schema
|
||||
});
|
||||
if !input_examples.is_empty() {
|
||||
anthropic_tool["input_examples"] = serde_json::Value::Array(input_examples);
|
||||
}
|
||||
if !allowed_callers.is_empty() {
|
||||
anthropic_tool["allowed_callers"] = serde_json::json!(allowed_callers);
|
||||
}
|
||||
Some(anthropic_tool)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_openai_tool_choice_to_anthropic(
|
||||
tool_choice: &Option<serde_json::Value>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let Some(tool_choice) = tool_choice else {
|
||||
return None;
|
||||
};
|
||||
match tool_choice {
|
||||
serde_json::Value::String(s) => match s.as_str() {
|
||||
"none" => Some(serde_json::json!({"type":"none"})),
|
||||
"auto" => Some(serde_json::json!({"type":"auto"})),
|
||||
"required" | "any" => Some(serde_json::json!({"type":"any"})),
|
||||
_ => None,
|
||||
},
|
||||
serde_json::Value::Object(obj) => {
|
||||
if let Some(func) = obj.get("function") {
|
||||
func.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|name| serde_json::json!({"type":"tool","name":name}))
|
||||
} else if let Some(t) = obj.get("type").and_then(|t| t.as_str()) {
|
||||
match t {
|
||||
"any" | "tool" => Some(serde_json::json!({"type":"any"})),
|
||||
"auto" => Some(serde_json::json!({"type":"auto"})),
|
||||
"none" => Some(serde_json::json!({"type":"none"})),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 调用 Anthropic API(原生格式)
|
||||
pub async fn call_api(
|
||||
&self,
|
||||
@@ -245,6 +350,19 @@ impl ClaudeCustomProvider {
|
||||
anthropic_body["system"] = serde_json::json!(sys);
|
||||
}
|
||||
|
||||
if let Some(ref tools) = request.tools {
|
||||
let anthropic_tools: Vec<serde_json::Value> = tools
|
||||
.iter()
|
||||
.filter_map(Self::convert_openai_tool_to_anthropic)
|
||||
.collect();
|
||||
if !anthropic_tools.is_empty() {
|
||||
anthropic_body["tools"] = serde_json::json!(anthropic_tools);
|
||||
}
|
||||
}
|
||||
if let Some(tc) = Self::convert_openai_tool_choice_to_anthropic(&request.tool_choice) {
|
||||
anthropic_body["tool_choice"] = tc;
|
||||
}
|
||||
|
||||
let api_key = self
|
||||
.config
|
||||
.api_key
|
||||
@@ -558,19 +676,7 @@ impl StreamingProvider for ClaudeCustomProvider {
|
||||
if let Some(ref tools) = request.tools {
|
||||
let anthropic_tools: Vec<serde_json::Value> = tools
|
||||
.iter()
|
||||
.filter_map(|tool| {
|
||||
match tool {
|
||||
proxycast_core::models::openai::Tool::Function { function } => {
|
||||
Some(serde_json::json!({
|
||||
"name": function.name,
|
||||
"description": function.description.clone().unwrap_or_default(),
|
||||
"input_schema": function.parameters.clone().unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}}))
|
||||
}))
|
||||
}
|
||||
// WebSearch 等其他工具类型暂不处理
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.filter_map(Self::convert_openai_tool_to_anthropic)
|
||||
.collect();
|
||||
|
||||
if !anthropic_tools.is_empty() {
|
||||
@@ -583,43 +689,12 @@ impl StreamingProvider for ClaudeCustomProvider {
|
||||
}
|
||||
|
||||
// 转换 tool_choice: OpenAI 格式 -> Anthropic 格式
|
||||
if let Some(ref tool_choice) = request.tool_choice {
|
||||
let anthropic_tool_choice = match tool_choice {
|
||||
serde_json::Value::String(s) => {
|
||||
match s.as_str() {
|
||||
"none" => Some(serde_json::json!({"type": "none"})),
|
||||
"auto" => Some(serde_json::json!({"type": "auto"})),
|
||||
"required" | "any" => Some(serde_json::json!({"type": "any"})),
|
||||
_ => None, // 未知值,不设置
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(obj) => {
|
||||
// 处理 {"type": "function", "function": {"name": "xxx"}} 格式
|
||||
if let Some(func) = obj.get("function") {
|
||||
func.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|name| serde_json::json!({"type": "tool", "name": name}))
|
||||
} else if let Some(t) = obj.get("type").and_then(|t| t.as_str()) {
|
||||
match t {
|
||||
"any" | "tool" => Some(serde_json::json!({"type": "any"})),
|
||||
"auto" => Some(serde_json::json!({"type": "auto"})),
|
||||
"none" => Some(serde_json::json!({"type": "none"})),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(tc) = anthropic_tool_choice {
|
||||
anthropic_body["tool_choice"] = tc;
|
||||
tracing::info!(
|
||||
"[CLAUDE_STREAM] 设置 tool_choice: {:?}",
|
||||
anthropic_body["tool_choice"]
|
||||
);
|
||||
}
|
||||
if let Some(tc) = Self::convert_openai_tool_choice_to_anthropic(&request.tool_choice) {
|
||||
anthropic_body["tool_choice"] = tc;
|
||||
tracing::info!(
|
||||
"[CLAUDE_STREAM] 设置 tool_choice: {:?}",
|
||||
anthropic_body["tool_choice"]
|
||||
);
|
||||
}
|
||||
|
||||
let url = self.build_url("messages");
|
||||
@@ -668,3 +743,104 @@ impl StreamingProvider for ClaudeCustomProvider {
|
||||
StreamFormat::AnthropicSse
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proxycast_core::models::openai::{FunctionDef, Tool};
|
||||
|
||||
#[test]
|
||||
fn test_convert_openai_tool_to_anthropic_keeps_metadata() {
|
||||
let tool = Tool::Function {
|
||||
function: FunctionDef {
|
||||
name: "create_ticket".to_string(),
|
||||
description: Some("Create support ticket".to_string()),
|
||||
parameters: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"}
|
||||
},
|
||||
"x-proxycast": {
|
||||
"input_examples": [{"title":"Billing issue"}],
|
||||
"allowed_callers": ["assistant", "code_execution"]
|
||||
}
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
let converted = ClaudeCustomProvider::convert_openai_tool_to_anthropic(&tool)
|
||||
.expect("tool should be converted");
|
||||
let description = converted
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
assert_eq!(converted["name"], serde_json::json!("create_ticket"));
|
||||
assert!(description.contains("[InputExamples]"));
|
||||
assert!(description.contains("[AllowedCallers]"));
|
||||
assert_eq!(
|
||||
converted["input_examples"],
|
||||
serde_json::json!([{"title":"Billing issue"}])
|
||||
);
|
||||
assert_eq!(
|
||||
converted["allowed_callers"],
|
||||
serde_json::json!(["assistant", "code_execution"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_openai_tool_choice_to_anthropic_variants() {
|
||||
assert_eq!(
|
||||
ClaudeCustomProvider::convert_openai_tool_choice_to_anthropic(&Some(
|
||||
serde_json::json!("required")
|
||||
)),
|
||||
Some(serde_json::json!({"type":"any"}))
|
||||
);
|
||||
assert_eq!(
|
||||
ClaudeCustomProvider::convert_openai_tool_choice_to_anthropic(&Some(
|
||||
serde_json::json!({"type":"function","function":{"name":"create_ticket"}})
|
||||
)),
|
||||
Some(serde_json::json!({"type":"tool","name":"create_ticket"}))
|
||||
);
|
||||
assert_eq!(
|
||||
ClaudeCustomProvider::convert_openai_tool_choice_to_anthropic(&Some(
|
||||
serde_json::json!({"type":"none"})
|
||||
)),
|
||||
Some(serde_json::json!({"type":"none"}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_openai_tool_to_anthropic_uses_builtin_input_examples_fallback() {
|
||||
let tool = Tool::Function {
|
||||
function: FunctionDef {
|
||||
name: "WebSearch".to_string(),
|
||||
description: Some("允许 Claude 搜索网络并使用结果来提供响应。".to_string()),
|
||||
parameters: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer"}
|
||||
},
|
||||
"required": ["query"]
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
let converted = ClaudeCustomProvider::convert_openai_tool_to_anthropic(&tool)
|
||||
.expect("tool should be converted");
|
||||
let description = converted
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
assert!(description.contains("[InputExamples]"));
|
||||
assert!(converted
|
||||
.get("input_examples")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| !arr.is_empty())
|
||||
.unwrap_or(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,121 @@ impl Default for OpenAICustomProvider {
|
||||
}
|
||||
|
||||
impl OpenAICustomProvider {
|
||||
fn tool_calling_v2_enabled() -> bool {
|
||||
proxycast_core::tool_calling::tool_calling_v2_enabled()
|
||||
}
|
||||
|
||||
fn native_input_examples_enabled() -> bool {
|
||||
proxycast_core::tool_calling::tool_calling_native_input_examples_enabled()
|
||||
}
|
||||
|
||||
fn normalize_openai_request_payload(&self, payload: &mut serde_json::Value) {
|
||||
if !Self::tool_calling_v2_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(tools) = payload.get_mut("tools").and_then(|v| v.as_array_mut()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for tool in tools.iter_mut() {
|
||||
let tool_type = tool
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
if tool_type != "function" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(function) = tool.get_mut("function").and_then(|v| v.as_object_mut()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let parameters = function
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
let extension = parameters
|
||||
.get("x-proxycast")
|
||||
.or_else(|| parameters.get("x_proxycast"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let mut input_examples = extension
|
||||
.get("input_examples")
|
||||
.or_else(|| extension.get("inputExamples"))
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if input_examples.is_empty() {
|
||||
let tool_name = function
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
input_examples = proxycast_core::tool_calling::resolve_tool_input_examples(
|
||||
tool_name,
|
||||
¶meters,
|
||||
);
|
||||
}
|
||||
let allowed_callers = extension
|
||||
.get("allowed_callers")
|
||||
.or_else(|| extension.get("allowedCallers"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let deferred_loading = extension
|
||||
.get("deferred_loading")
|
||||
.or_else(|| extension.get("deferredLoading"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let description = function
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let mut enhanced_description = description.clone();
|
||||
|
||||
if !input_examples.is_empty() && !enhanced_description.contains("[InputExamples]") {
|
||||
let rendered = input_examples
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ");
|
||||
enhanced_description.push_str("\n\n[InputExamples] ");
|
||||
enhanced_description.push_str(&rendered);
|
||||
}
|
||||
|
||||
if !allowed_callers.is_empty() && !enhanced_description.contains("[AllowedCallers]") {
|
||||
enhanced_description.push_str("\n\n[AllowedCallers] ");
|
||||
enhanced_description.push_str(&allowed_callers.join(", "));
|
||||
}
|
||||
|
||||
if deferred_loading && !enhanced_description.contains("[DeferredLoading]") {
|
||||
enhanced_description.push_str("\n\n[DeferredLoading] true");
|
||||
}
|
||||
|
||||
function.insert(
|
||||
"description".to_string(),
|
||||
serde_json::Value::String(enhanced_description),
|
||||
);
|
||||
|
||||
if !input_examples.is_empty() && Self::native_input_examples_enabled() {
|
||||
function.insert(
|
||||
"input_examples".to_string(),
|
||||
serde_json::Value::Array(input_examples.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_log_protocol_mismatch_hint(url: &str, status: StatusCode) {
|
||||
if (status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN)
|
||||
&& url.contains("/api/anthropic")
|
||||
@@ -221,6 +336,10 @@ impl OpenAICustomProvider {
|
||||
request.model
|
||||
);
|
||||
|
||||
let mut payload =
|
||||
serde_json::to_value(request).map_err(|e| format!("序列化 OpenAI 请求失败: {e}"))?;
|
||||
self.normalize_openai_request_payload(&mut payload);
|
||||
|
||||
for url in &urls {
|
||||
eprintln!("[OPENAI_CUSTOM] call_api trying URL: {url}");
|
||||
let resp = self
|
||||
@@ -228,7 +347,7 @@ impl OpenAICustomProvider {
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(request)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -261,12 +380,15 @@ impl OpenAICustomProvider {
|
||||
self.get_base_url()
|
||||
);
|
||||
|
||||
let mut payload = request.clone();
|
||||
self.normalize_openai_request_payload(&mut payload);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(request)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -280,7 +402,7 @@ impl OpenAICustomProvider {
|
||||
.post(&fallback_url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(request)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
Self::maybe_log_protocol_mismatch_hint(&fallback_url, resp2.status());
|
||||
@@ -368,6 +490,9 @@ impl StreamingProvider for OpenAICustomProvider {
|
||||
// 确保请求启用流式
|
||||
let mut stream_request = request.clone();
|
||||
stream_request.stream = true;
|
||||
let mut payload = serde_json::to_value(&stream_request)
|
||||
.map_err(|e| ProviderError::ConfigurationError(format!("序列化流式请求失败: {e}")))?;
|
||||
self.normalize_openai_request_payload(&mut payload);
|
||||
|
||||
let url = self.build_url("chat/completions");
|
||||
|
||||
@@ -383,7 +508,7 @@ impl StreamingProvider for OpenAICustomProvider {
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "text/event-stream")
|
||||
.json(&stream_request)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ProviderError::from_reqwest_error(&e))?;
|
||||
@@ -396,7 +521,7 @@ impl StreamingProvider for OpenAICustomProvider {
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "text/event-stream")
|
||||
.json(&stream_request)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ProviderError::from_reqwest_error(&e))?
|
||||
@@ -436,3 +561,262 @@ impl StreamingProvider for OpenAICustomProvider {
|
||||
StreamFormat::OpenAiSse
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{extract::State, http::header, response::IntoResponse, routing::post, Json, Router};
|
||||
use futures::StreamExt;
|
||||
use proxycast_core::models::openai::{ChatMessage, FunctionDef, MessageContent, Tool};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
async fn start_mock_openai_server(
|
||||
captured: Arc<Mutex<Vec<serde_json::Value>>>,
|
||||
) -> (String, tokio::task::JoinHandle<()>) {
|
||||
async fn handle_chat(
|
||||
State(captured): State<Arc<Mutex<Vec<serde_json::Value>>>>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
captured.lock().await.push(payload.clone());
|
||||
|
||||
if payload
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
(
|
||||
[(header::CONTENT_TYPE, "text/event-stream")],
|
||||
"data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"choices\":[]}\n\ndata: [DONE]\n\n",
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
Json(serde_json::json!({
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role":"assistant","content":"ok"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/v1/chat/completions", post(handle_chat))
|
||||
.with_state(captured);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind mock server");
|
||||
let addr = listener.local_addr().expect("read mock server local addr");
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("mock server should run");
|
||||
});
|
||||
(format!("http://{}", addr), server)
|
||||
}
|
||||
|
||||
fn build_tool_calling_request() -> ChatCompletionRequest {
|
||||
ChatCompletionRequest {
|
||||
model: "deepseek-chat".to_string(),
|
||||
messages: vec![ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(MessageContent::Text("hi".to_string())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
}],
|
||||
temperature: None,
|
||||
max_tokens: Some(128),
|
||||
top_p: None,
|
||||
stream: false,
|
||||
tools: Some(vec![Tool::Function {
|
||||
function: FunctionDef {
|
||||
name: "search_docs".to_string(),
|
||||
description: Some("Search docs".to_string()),
|
||||
parameters: Some(serde_json::json!({
|
||||
"type":"object",
|
||||
"properties":{"query":{"type":"string"}},
|
||||
"x-proxycast": {
|
||||
"input_examples":[{"query":"rust async"}],
|
||||
"allowed_callers":["assistant","code_execution"],
|
||||
"deferred_loading": true
|
||||
}
|
||||
})),
|
||||
},
|
||||
}]),
|
||||
tool_choice: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_openai_request_payload_injects_fallback_description() {
|
||||
let provider = OpenAICustomProvider::default();
|
||||
let mut payload = serde_json::json!({
|
||||
"model": "deepseek-chat",
|
||||
"messages": [{"role":"user","content":"hi"}],
|
||||
"tools": [{
|
||||
"type":"function",
|
||||
"function": {
|
||||
"name":"search_docs",
|
||||
"description":"Search docs",
|
||||
"parameters": {
|
||||
"type":"object",
|
||||
"properties":{"query":{"type":"string"}},
|
||||
"x-proxycast": {
|
||||
"input_examples":[{"query":"rust async"}],
|
||||
"allowed_callers":["assistant","code_execution"],
|
||||
"deferred_loading":true
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
provider.normalize_openai_request_payload(&mut payload);
|
||||
let description = payload["tools"][0]["function"]["description"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(description.contains("[InputExamples]"));
|
||||
assert!(description.contains("[AllowedCallers]"));
|
||||
assert!(description.contains("[DeferredLoading]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_openai_request_payload_supports_x_proxycast_alias() {
|
||||
let provider = OpenAICustomProvider::default();
|
||||
let mut payload = serde_json::json!({
|
||||
"model": "deepseek-chat",
|
||||
"messages": [{"role":"user","content":"hi"}],
|
||||
"tools": [{
|
||||
"type":"function",
|
||||
"function": {
|
||||
"name":"search_docs",
|
||||
"description":"Search docs",
|
||||
"parameters": {
|
||||
"type":"object",
|
||||
"properties":{"query":{"type":"string"}},
|
||||
"x_proxycast": {
|
||||
"inputExamples":[{"query":"tool search"}],
|
||||
"allowedCallers":["tool_search"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
provider.normalize_openai_request_payload(&mut payload);
|
||||
let description = payload["tools"][0]["function"]["description"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(description.contains("[InputExamples]"));
|
||||
assert!(description.contains("[AllowedCallers]"));
|
||||
assert!(description.contains("tool_search"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_openai_request_payload_ignores_non_function_tools() {
|
||||
let provider = OpenAICustomProvider::default();
|
||||
let mut payload = serde_json::json!({
|
||||
"model": "deepseek-chat",
|
||||
"messages": [{"role":"user","content":"hi"}],
|
||||
"tools": [{"type":"web_search_20250305"}]
|
||||
});
|
||||
|
||||
provider.normalize_openai_request_payload(&mut payload);
|
||||
|
||||
assert_eq!(
|
||||
payload["tools"][0],
|
||||
serde_json::json!({"type":"web_search_20250305"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_openai_request_payload_uses_builtin_input_examples_fallback() {
|
||||
let provider = OpenAICustomProvider::default();
|
||||
let mut payload = serde_json::json!({
|
||||
"model": "deepseek-chat",
|
||||
"messages": [{"role":"user","content":"hi"}],
|
||||
"tools": [{
|
||||
"type":"function",
|
||||
"function": {
|
||||
"name":"WebSearch",
|
||||
"description":"允许 Claude 搜索网络并使用结果来提供响应。",
|
||||
"parameters": {
|
||||
"type":"object",
|
||||
"properties":{"query":{"type":"string"},"limit":{"type":"integer"}},
|
||||
"required":["query"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
provider.normalize_openai_request_payload(&mut payload);
|
||||
let description = payload["tools"][0]["function"]["description"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(description.contains("[InputExamples]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_openai_compatible_non_stream_and_stream_both_normalized() {
|
||||
if !OpenAICustomProvider::tool_calling_v2_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let captured = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
|
||||
let (base_url, server_handle) = start_mock_openai_server(captured.clone()).await;
|
||||
let mut provider = OpenAICustomProvider::with_config("sk-test".to_string(), Some(base_url));
|
||||
provider.client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("build test client without proxy");
|
||||
let request = build_tool_calling_request();
|
||||
|
||||
let resp = provider
|
||||
.call_api(&request)
|
||||
.await
|
||||
.expect("non-stream call should succeed");
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
panic!("non-stream call failed: status={status}, body={body}");
|
||||
}
|
||||
|
||||
let mut stream = provider
|
||||
.call_api_stream(&request)
|
||||
.await
|
||||
.expect("stream call should succeed");
|
||||
let first_chunk = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("stream should return at least one chunk")
|
||||
.expect("first stream chunk should be ok");
|
||||
let chunk_text = String::from_utf8(first_chunk.to_vec()).expect("chunk should be utf8");
|
||||
assert!(chunk_text.contains("data:"));
|
||||
|
||||
let bodies = captured.lock().await;
|
||||
assert_eq!(bodies.len(), 2);
|
||||
assert_eq!(bodies[1]["stream"], serde_json::json!(true));
|
||||
|
||||
for body in bodies.iter() {
|
||||
let description = body["tools"][0]["function"]["description"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(description.contains("[InputExamples]"));
|
||||
assert!(description.contains("[AllowedCallers]"));
|
||||
assert!(description.contains("[DeferredLoading]"));
|
||||
}
|
||||
|
||||
server_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1818,8 +1818,67 @@ pub async fn call_provider_openai(
|
||||
custom_url,
|
||||
&credential.uuid[..8],
|
||||
request.stream
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if request.stream {
|
||||
state.logs.write().await.add(
|
||||
"info",
|
||||
"[OPENAI_COMPAT] 流式请求,走 OpenAICustomProvider.call_api_stream",
|
||||
);
|
||||
|
||||
match openai.call_api_stream(request).await {
|
||||
Ok(stream_response) => {
|
||||
if let Some(db) = &state.db {
|
||||
let _ = state.pool_service.mark_healthy(
|
||||
db,
|
||||
&credential.uuid,
|
||||
Some(&request.model),
|
||||
);
|
||||
let _ = state.pool_service.record_usage(db, &credential.uuid);
|
||||
}
|
||||
|
||||
let body_stream =
|
||||
stream_response.map(|result| -> Result<axum::body::Bytes, std::io::Error> {
|
||||
match result {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(e) => Ok(axum::body::Bytes::from(e.to_sse_error())),
|
||||
}
|
||||
});
|
||||
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/event-stream")
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
||||
.header("Connection", "keep-alive")
|
||||
.header("X-Accel-Buffering", "no")
|
||||
.header("Transfer-Encoding", "chunked")
|
||||
.body(Body::from_stream(body_stream))
|
||||
.unwrap_or_else(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": "Failed to build stream response"}})),
|
||||
)
|
||||
.into_response()
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(db) = &state.db {
|
||||
let _ = state.pool_service.mark_unhealthy(
|
||||
db,
|
||||
&credential.uuid,
|
||||
Some(&format!("Streaming API call failed: {e}")),
|
||||
);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": format!("OpenAI compatible streaming API call failed: {}", e)}})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match openai.call_api(request).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
@@ -1833,37 +1892,6 @@ pub async fn call_provider_openai(
|
||||
),
|
||||
);
|
||||
|
||||
if request.stream && status.is_success() {
|
||||
state.logs.write().await.add(
|
||||
"info",
|
||||
"[OPENAI_COMPAT] 流式请求,透传 SSE 响应",
|
||||
);
|
||||
if let Some(db) = &state.db {
|
||||
let _ = state.pool_service.mark_healthy(
|
||||
db,
|
||||
&credential.uuid,
|
||||
Some(&request.model),
|
||||
);
|
||||
let _ = state.pool_service.record_usage(db, &credential.uuid);
|
||||
}
|
||||
let stream = resp.bytes_stream();
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/event-stream")
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
||||
.header("Connection", "keep-alive")
|
||||
.header("X-Accel-Buffering", "no") // 禁用 nginx 等代理的缓冲
|
||||
.header("Transfer-Encoding", "chunked")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap_or_else(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": "Failed to build stream response"}})),
|
||||
)
|
||||
.into_response()
|
||||
});
|
||||
}
|
||||
|
||||
// 非流式响应
|
||||
if status.is_success() {
|
||||
if let Some(db) = &state.db {
|
||||
|
||||
@@ -92,6 +92,9 @@ type TelemetryInit = (
|
||||
|
||||
/// 初始化所有应用状态
|
||||
pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
// 将 Tool Calling 运行时开关与当前配置同步,避免依赖手工环境变量。
|
||||
proxycast_core::tool_calling::apply_tool_calling_runtime_config(config);
|
||||
|
||||
// 核心状态
|
||||
let state: AppState = Arc::new(RwLock::new(server::ServerState::new(config.clone())));
|
||||
let logs: LogState = Arc::new(RwLock::new(logger::create_log_store_from_config(
|
||||
|
||||
@@ -55,6 +55,8 @@ pub async fn save_config(
|
||||
let save_result = config::save_config(&config).map_err(|e| e.to_string());
|
||||
match save_result {
|
||||
Ok(()) => {
|
||||
proxycast_core::tool_calling::apply_tool_calling_runtime_config(&config);
|
||||
|
||||
let full_reload_event = ConfigChangeEvent::FullReload(FullReloadEvent {
|
||||
timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
|
||||
source: ConfigChangeSource::FrontendUI,
|
||||
|
||||
@@ -822,7 +822,10 @@ pub fn run() {
|
||||
commands::mcp_cmd::mcp_stop_server,
|
||||
// MCP 工具管理命令
|
||||
commands::mcp_cmd::mcp_list_tools,
|
||||
commands::mcp_cmd::mcp_list_tools_for_context,
|
||||
commands::mcp_cmd::mcp_search_tools,
|
||||
commands::mcp_cmd::mcp_call_tool,
|
||||
commands::mcp_cmd::mcp_call_tool_with_caller,
|
||||
// MCP 提示词管理命令
|
||||
commands::mcp_cmd::mcp_list_prompts,
|
||||
commands::mcp_cmd::mcp_get_prompt,
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::database::dao::agent::AgentDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile;
|
||||
use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
|
||||
use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
|
||||
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
|
||||
use crate::workspace::WorkspaceManager;
|
||||
use crate::AppState;
|
||||
@@ -229,6 +230,7 @@ pub async fn agent_create_session(
|
||||
// 构建包含 Skills 的 System Prompt,并附加记忆画像偏好
|
||||
let base_system_prompt = build_system_prompt_with_skills(system_prompt, skills.as_ref());
|
||||
let config = config_manager.config();
|
||||
apply_web_search_runtime_env(&config);
|
||||
let prompt_with_memory = merge_system_prompt_with_memory_profile(base_system_prompt, &config);
|
||||
let final_system_prompt = merge_system_prompt_with_web_search(prompt_with_memory, &config);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::services::execution_tracker_service::{ExecutionTracker, RunFinalizeOp
|
||||
use crate::services::heartbeat_service::HeartbeatServiceState;
|
||||
use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile;
|
||||
use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
|
||||
use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
|
||||
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
|
||||
use crate::workspace::WorkspaceManager;
|
||||
use crate::LogState;
|
||||
@@ -184,6 +185,7 @@ pub async fn aster_agent_init(
|
||||
|
||||
state.init_agent_with_db(&db).await?;
|
||||
ensure_browser_mcp_tools_registered(state.inner()).await?;
|
||||
ensure_tool_search_tool_registered(state.inner()).await?;
|
||||
|
||||
let provider_config = state.get_provider_config().await;
|
||||
|
||||
@@ -359,6 +361,10 @@ impl AsterExecutionStrategy {
|
||||
}
|
||||
|
||||
fn effective_for_message(self, message: &str) -> Self {
|
||||
if should_force_react_for_message(message) {
|
||||
return Self::React;
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Auto if should_use_code_orchestrated_for_message(message) => {
|
||||
Self::CodeOrchestrated
|
||||
@@ -375,6 +381,34 @@ struct ReplyAttemptError {
|
||||
emitted_any: bool,
|
||||
}
|
||||
|
||||
fn should_force_react_for_message(message: &str) -> bool {
|
||||
let lowered = message.to_lowercase();
|
||||
let default_hints = [
|
||||
"tool_search",
|
||||
"调用 tool_search",
|
||||
"调用tool_search",
|
||||
"use tool_search",
|
||||
"call tool_search",
|
||||
"websearch",
|
||||
"web search",
|
||||
"web_search",
|
||||
"webfetch",
|
||||
"web fetch",
|
||||
"web_fetch",
|
||||
"联网搜索",
|
||||
"网络搜索",
|
||||
"实时新闻",
|
||||
"最新新闻",
|
||||
"今日要闻",
|
||||
"时事新闻",
|
||||
"breaking news",
|
||||
"news today",
|
||||
];
|
||||
resolve_intent_hints("PROXYCAST_FORCE_REACT_HINTS", &default_hints)
|
||||
.iter()
|
||||
.any(|kw| lowered.contains(kw))
|
||||
}
|
||||
|
||||
fn extract_inline_agent_provider_error(message: &Message) -> Option<String> {
|
||||
let text = message.as_concat_text();
|
||||
if !text.contains("Ran into this error:") {
|
||||
@@ -400,11 +434,37 @@ fn extract_inline_agent_provider_error(message: &Message) -> Option<String> {
|
||||
|
||||
fn should_use_code_orchestrated_for_message(message: &str) -> bool {
|
||||
let lowered = message.to_lowercase();
|
||||
let keywords = [
|
||||
"搜索", "联网", "网页", "网站", "抓取", "爬取", "检索", "search", "browse", "crawl",
|
||||
"scrape", "url", "链接",
|
||||
];
|
||||
keywords.iter().any(|kw| lowered.contains(kw))
|
||||
// 默认不做消息关键词硬编码推断,Auto 模式优先走 ReAct。
|
||||
// 如需启用自动切换,可通过环境变量 PROXYCAST_CODE_ORCHESTRATED_HINTS 显式配置。
|
||||
resolve_intent_hints("PROXYCAST_CODE_ORCHESTRATED_HINTS", &[])
|
||||
.iter()
|
||||
.any(|kw| lowered.contains(kw))
|
||||
}
|
||||
|
||||
fn resolve_intent_hints(env_key: &str, defaults: &[&str]) -> Vec<String> {
|
||||
if let Ok(raw) = std::env::var(env_key) {
|
||||
let parsed = raw
|
||||
.split(',')
|
||||
.map(|item| item.trim().to_lowercase())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if !parsed.is_empty() {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
defaults.iter().map(|item| item.to_string()).collect()
|
||||
}
|
||||
|
||||
fn should_fallback_to_react_from_code_orchestrated(error: &ReplyAttemptError) -> bool {
|
||||
if !error.emitted_any {
|
||||
return true;
|
||||
}
|
||||
|
||||
let lowered = error.message.to_lowercase();
|
||||
let recoverable_hints = ["unknown subscript", "tool_search_analysis", "web_scraping"];
|
||||
|
||||
recoverable_hints.iter().any(|hint| lowered.contains(hint))
|
||||
}
|
||||
|
||||
async fn ensure_code_execution_extension_enabled(agent: &Agent) -> Result<bool, String> {
|
||||
@@ -421,6 +481,9 @@ async fn ensure_code_execution_extension_enabled(agent: &Agent) -> Result<bool,
|
||||
description: "Execute JavaScript code in a sandboxed environment".to_string(),
|
||||
bundled: Some(true),
|
||||
available_tools: vec![],
|
||||
deferred_loading: false,
|
||||
always_expose_tools: Vec::new(),
|
||||
allowed_caller: None,
|
||||
};
|
||||
|
||||
agent
|
||||
@@ -991,6 +1054,273 @@ impl Tool for ProxycastBrowserMcpTool {
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolSearchBridgeTool {
|
||||
registry: Arc<tokio::sync::RwLock<aster::tools::ToolRegistry>>,
|
||||
}
|
||||
|
||||
impl ToolSearchBridgeTool {
|
||||
fn new(registry: Arc<tokio::sync::RwLock<aster::tools::ToolRegistry>>) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
|
||||
fn with_input_examples_in_schema(
|
||||
schema: &serde_json::Value,
|
||||
input_examples: &[serde_json::Value],
|
||||
) -> serde_json::Value {
|
||||
if input_examples.is_empty() {
|
||||
return schema.clone();
|
||||
}
|
||||
|
||||
let mut enriched = schema.clone();
|
||||
let Some(root) = enriched.as_object_mut() else {
|
||||
return schema.clone();
|
||||
};
|
||||
let extension = root
|
||||
.entry("x-proxycast".to_string())
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
let Some(extension_obj) = extension.as_object_mut() else {
|
||||
return schema.clone();
|
||||
};
|
||||
if extension_obj.get("input_examples").is_none()
|
||||
&& extension_obj.get("inputExamples").is_none()
|
||||
{
|
||||
extension_obj.insert(
|
||||
"input_examples".to_string(),
|
||||
serde_json::Value::Array(input_examples.to_vec()),
|
||||
);
|
||||
}
|
||||
enriched
|
||||
}
|
||||
|
||||
fn parse_schema_metadata(
|
||||
tool_name: &str,
|
||||
schema: &serde_json::Value,
|
||||
) -> (
|
||||
bool, // deferred_loading
|
||||
bool, // always_visible
|
||||
Vec<String>, // allowed_callers
|
||||
Vec<String>, // tags
|
||||
Vec<serde_json::Value>, // input_examples
|
||||
) {
|
||||
let extension = schema
|
||||
.get("x-proxycast")
|
||||
.or_else(|| schema.get("x_proxycast"))
|
||||
.unwrap_or(schema);
|
||||
|
||||
let deferred_loading = extension
|
||||
.get("deferred_loading")
|
||||
.or_else(|| extension.get("deferredLoading"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let always_visible = extension
|
||||
.get("always_visible")
|
||||
.or_else(|| extension.get("alwaysVisible"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let allowed_callers = extension
|
||||
.get("allowed_callers")
|
||||
.or_else(|| extension.get("allowedCallers"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tags = extension
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let input_examples =
|
||||
proxycast_core::tool_calling::resolve_tool_input_examples(tool_name, schema);
|
||||
|
||||
(
|
||||
deferred_loading,
|
||||
always_visible,
|
||||
allowed_callers,
|
||||
tags,
|
||||
input_examples,
|
||||
)
|
||||
}
|
||||
|
||||
fn score_match(name: &str, description: &str, tags: &[String], query: &str) -> i32 {
|
||||
if query.is_empty() {
|
||||
return 1;
|
||||
}
|
||||
let name_lc = name.to_ascii_lowercase();
|
||||
let description_lc = description.to_ascii_lowercase();
|
||||
|
||||
let mut score = 0;
|
||||
if name_lc == query {
|
||||
score += 120;
|
||||
} else if name_lc.starts_with(query) {
|
||||
score += 90;
|
||||
} else if name_lc.contains(query) {
|
||||
score += 70;
|
||||
}
|
||||
if description_lc.contains(query) {
|
||||
score += 40;
|
||||
}
|
||||
for tag in tags {
|
||||
if tag == query {
|
||||
score += 35;
|
||||
} else if tag.contains(query) {
|
||||
score += 20;
|
||||
}
|
||||
}
|
||||
score
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ToolSearchBridgeTool {
|
||||
fn name(&self) -> &str {
|
||||
"tool_search"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"搜索当前会话可用工具;默认会过滤 deferred_loading 工具,并按调用方做 allowed_callers 约束。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "工具名称/描述关键词" },
|
||||
"caller": { "type": "string", "description": "调用方,例如 assistant/code_execution" },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 100 },
|
||||
"include_deferred": { "type": "boolean", "description": "是否包含延迟加载工具" },
|
||||
"include_schema": { "type": "boolean", "description": "是否返回完整输入 schema" }
|
||||
},
|
||||
"required": []
|
||||
})
|
||||
}
|
||||
|
||||
fn options(&self) -> ToolOptions {
|
||||
ToolOptions::new()
|
||||
.with_max_retries(1)
|
||||
.with_base_timeout(Duration::from_secs(15))
|
||||
.with_dynamic_timeout(false)
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let query = params
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let caller = params
|
||||
.get("caller")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("assistant")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let include_deferred = params
|
||||
.get("include_deferred")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let include_schema = params
|
||||
.get("include_schema")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v.clamp(1, 100) as usize)
|
||||
.unwrap_or(10);
|
||||
|
||||
let registry = self.registry.read().await;
|
||||
let definitions = registry.get_definitions();
|
||||
|
||||
let mut scored = definitions
|
||||
.into_iter()
|
||||
.filter(|d| d.name != self.name())
|
||||
.filter_map(|definition| {
|
||||
let (deferred_loading, always_visible, allowed_callers, tags, input_examples) =
|
||||
Self::parse_schema_metadata(&definition.name, &definition.input_schema);
|
||||
if deferred_loading && !always_visible && !include_deferred {
|
||||
return None;
|
||||
}
|
||||
if !allowed_callers.is_empty() && !allowed_callers.contains(&caller) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let score =
|
||||
Self::score_match(&definition.name, &definition.description, &tags, &query);
|
||||
if score <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let item = if include_schema {
|
||||
let enriched_schema = Self::with_input_examples_in_schema(
|
||||
&definition.input_schema,
|
||||
&input_examples,
|
||||
);
|
||||
serde_json::json!({
|
||||
"name": definition.name,
|
||||
"description": definition.description,
|
||||
"input_schema": enriched_schema,
|
||||
"deferred_loading": deferred_loading,
|
||||
"always_visible": always_visible,
|
||||
"allowed_callers": allowed_callers,
|
||||
"input_examples": input_examples,
|
||||
"tags": tags
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"name": definition.name,
|
||||
"description": definition.description,
|
||||
"deferred_loading": deferred_loading,
|
||||
"always_visible": always_visible,
|
||||
"allowed_callers": allowed_callers,
|
||||
"input_examples": input_examples,
|
||||
"tags": tags
|
||||
})
|
||||
};
|
||||
Some((score, item))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
scored.sort_by(|(a_score, a_item), (b_score, b_item)| {
|
||||
b_score.cmp(a_score).then_with(|| {
|
||||
a_item["name"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(b_item["name"].as_str().unwrap_or_default())
|
||||
})
|
||||
});
|
||||
|
||||
let result = scored
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(_, item)| item)
|
||||
.collect::<Vec<_>>();
|
||||
let text = serde_json::to_string_pretty(&serde_json::json!({
|
||||
"query": query,
|
||||
"caller": caller,
|
||||
"count": result.len(),
|
||||
"tools": result
|
||||
}))
|
||||
.map_err(|e| ToolError::execution_failed(format!("tool_search 序列化失败: {e}")))?;
|
||||
|
||||
Ok(ToolResult::success(text))
|
||||
}
|
||||
}
|
||||
|
||||
fn browser_mcp_tool_names() -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
for tool in get_chrome_mcp_tools() {
|
||||
@@ -1018,6 +1348,16 @@ fn register_browser_mcp_tools_to_registry(registry: &mut aster::tools::ToolRegis
|
||||
}
|
||||
}
|
||||
|
||||
fn register_tool_search_tool_to_registry(
|
||||
registry: &mut aster::tools::ToolRegistry,
|
||||
registry_arc: Arc<tokio::sync::RwLock<aster::tools::ToolRegistry>>,
|
||||
) {
|
||||
if registry.contains("tool_search") {
|
||||
return;
|
||||
}
|
||||
registry.register(Box::new(ToolSearchBridgeTool::new(registry_arc)));
|
||||
}
|
||||
|
||||
pub async fn ensure_browser_mcp_tools_registered(state: &AsterAgentState) -> Result<(), String> {
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
@@ -1029,6 +1369,21 @@ pub async fn ensure_browser_mcp_tools_registered(state: &AsterAgentState) -> Res
|
||||
|
||||
let mut registry = registry_arc.write().await;
|
||||
register_browser_mcp_tools_to_registry(&mut registry);
|
||||
register_tool_search_tool_to_registry(&mut registry, registry_arc.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ensure_tool_search_tool_registered(state: &AsterAgentState) -> Result<(), String> {
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Agent not initialized".to_string())?;
|
||||
let registry_arc = agent.tool_registry().clone();
|
||||
drop(guard);
|
||||
|
||||
let mut registry = registry_arc.write().await;
|
||||
register_tool_search_tool_to_registry(&mut registry, registry_arc.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1491,6 +1846,7 @@ async fn apply_workspace_sandbox_permissions(
|
||||
"ExitPlanMode",
|
||||
"WebSearch",
|
||||
"ask",
|
||||
"tool_search",
|
||||
"three_stage_workflow",
|
||||
"heartbeat",
|
||||
] {
|
||||
@@ -1665,6 +2021,9 @@ pub async fn aster_agent_chat_stream(
|
||||
}
|
||||
};
|
||||
let workspace_root = ensured.root_path.to_string_lossy().to_string();
|
||||
let runtime_config = config_manager.config();
|
||||
apply_web_search_runtime_env(&runtime_config);
|
||||
|
||||
if ensured.repaired {
|
||||
let warning_message = ensured.warning.unwrap_or_else(|| {
|
||||
format!(
|
||||
@@ -1779,10 +2138,9 @@ pub async fn aster_agent_chat_stream(
|
||||
}
|
||||
};
|
||||
|
||||
let config = config_manager.config();
|
||||
let merged_prompt = merge_system_prompt_with_web_search(
|
||||
merge_system_prompt_with_memory_profile(resolved_prompt, &config),
|
||||
&config,
|
||||
merge_system_prompt_with_memory_profile(resolved_prompt, &runtime_config),
|
||||
&runtime_config,
|
||||
);
|
||||
|
||||
(merged_prompt, persisted)
|
||||
@@ -1906,7 +2264,7 @@ pub async fn aster_agent_chat_stream(
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent not initialized")?;
|
||||
|
||||
let include_context_trace = config_manager.config().memory.enabled;
|
||||
let include_context_trace = runtime_config.memory.enabled;
|
||||
|
||||
let build_session_config = || {
|
||||
let mut session_config_builder = SessionConfigBuilder::new(session_id);
|
||||
@@ -1961,12 +2319,23 @@ pub async fn aster_agent_chat_stream(
|
||||
Ok(()) => Ok(()),
|
||||
Err(primary_error)
|
||||
if effective_strategy == AsterExecutionStrategy::CodeOrchestrated
|
||||
&& !primary_error.emitted_any =>
|
||||
&& should_fallback_to_react_from_code_orchestrated(&primary_error) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 编排模式执行失败,自动降级到 ReAct: {}",
|
||||
primary_error.message
|
||||
);
|
||||
if added_code_execution {
|
||||
if let Err(e) =
|
||||
agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 降级前移除 code_execution 扩展失败: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
added_code_execution = false;
|
||||
}
|
||||
stream_reply_once(
|
||||
agent,
|
||||
&app,
|
||||
@@ -2253,7 +2622,48 @@ pub async fn aster_agent_submit_elicitation_response(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct DummyTool {
|
||||
name: String,
|
||||
description: String,
|
||||
schema: serde_json::Value,
|
||||
}
|
||||
|
||||
impl DummyTool {
|
||||
fn new(name: &str, description: &str, schema: serde_json::Value) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
schema,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for DummyTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
&self.description
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
self.schema.clone()
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
Ok(ToolResult::success("ok"))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aster_chat_request_deserialize() {
|
||||
@@ -2313,6 +2723,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aster_execution_strategy_auto_prefers_react_when_tool_search_explicit() {
|
||||
let strategy =
|
||||
AsterExecutionStrategy::Auto.effective_for_message("请先调用 tool_search 再继续");
|
||||
assert_eq!(strategy, AsterExecutionStrategy::React);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aster_execution_strategy_auto_prefers_react_for_generic_web_search() {
|
||||
let strategy =
|
||||
AsterExecutionStrategy::Auto.effective_for_message("帮我联网搜索今天的 AI 新闻");
|
||||
assert_eq!(strategy, AsterExecutionStrategy::React);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aster_execution_strategy_auto_defaults_react_for_code_task() {
|
||||
let strategy = AsterExecutionStrategy::Auto
|
||||
.effective_for_message("请抓取这个仓库并修复 Rust 编译错误,然后给出补丁");
|
||||
assert_eq!(strategy, AsterExecutionStrategy::React);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aster_execution_strategy_code_orchestrated_still_prefers_react_for_web_search() {
|
||||
let strategy = AsterExecutionStrategy::CodeOrchestrated
|
||||
.effective_for_message("请联网搜索今天的 AI 新闻并给出来源");
|
||||
assert_eq!(strategy, AsterExecutionStrategy::React);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aster_execution_strategy_code_orchestrated_forces_react_for_websearch_instruction() {
|
||||
let strategy = AsterExecutionStrategy::CodeOrchestrated
|
||||
.effective_for_message("请必须使用 WebSearch 工具检索,不要用已有知识回答");
|
||||
assert_eq!(strategy, AsterExecutionStrategy::React);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_fallback_to_react_from_code_orchestrated_when_no_event_emitted() {
|
||||
let error = ReplyAttemptError {
|
||||
message: "Stream error: timeout".to_string(),
|
||||
emitted_any: false,
|
||||
};
|
||||
assert!(should_fallback_to_react_from_code_orchestrated(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_fallback_to_react_from_code_orchestrated_when_unknown_subscript() {
|
||||
let error = ReplyAttemptError {
|
||||
message: "Agent provider execution failed: Unknown subscript 'web_scraping'"
|
||||
.to_string(),
|
||||
emitted_any: true,
|
||||
};
|
||||
assert!(should_fallback_to_react_from_code_orchestrated(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_not_fallback_to_react_from_code_orchestrated_for_general_error() {
|
||||
let error = ReplyAttemptError {
|
||||
message: "Agent provider execution failed: quota exceeded".to_string(),
|
||||
emitted_any: true,
|
||||
};
|
||||
assert!(!should_fallback_to_react_from_code_orchestrated(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_elicitation_submission_rejects_empty_session_id() {
|
||||
let result = validate_elicitation_submission(" ", "req-1");
|
||||
@@ -2406,6 +2879,153 @@ mod tests {
|
||||
let second = shared_task_manager();
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_search_parse_schema_metadata() {
|
||||
let schema = serde_json::json!({
|
||||
"x-proxycast": {
|
||||
"deferred_loading": true,
|
||||
"always_visible": false,
|
||||
"allowed_callers": ["assistant", "code_execution"],
|
||||
"input_examples": [{"query":"rust"}],
|
||||
"tags": ["mcp", "filesystem"]
|
||||
}
|
||||
});
|
||||
let (deferred, always_visible, allowed_callers, tags, input_examples) =
|
||||
ToolSearchBridgeTool::parse_schema_metadata("docs_search", &schema);
|
||||
assert!(deferred);
|
||||
assert!(!always_visible);
|
||||
assert_eq!(
|
||||
allowed_callers,
|
||||
vec!["assistant".to_string(), "code_execution".to_string()]
|
||||
);
|
||||
assert_eq!(tags, vec!["mcp".to_string(), "filesystem".to_string()]);
|
||||
assert_eq!(input_examples, vec![serde_json::json!({"query":"rust"})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_search_parse_schema_metadata_infers_builtin_input_examples() {
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type":"string"}
|
||||
},
|
||||
"required": ["query"]
|
||||
});
|
||||
let (_, _, _, _, input_examples) =
|
||||
ToolSearchBridgeTool::parse_schema_metadata("WebSearch", &schema);
|
||||
assert!(!input_examples.is_empty());
|
||||
assert!(input_examples[0].get("query").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_search_score_match_prefers_exact_name() {
|
||||
let exact = ToolSearchBridgeTool::score_match(
|
||||
"web_fetch",
|
||||
"fetch webpage",
|
||||
&["web".to_string()],
|
||||
"web_fetch",
|
||||
);
|
||||
let partial = ToolSearchBridgeTool::score_match(
|
||||
"fetch_web",
|
||||
"web fetch helper",
|
||||
&["web".to_string()],
|
||||
"web_fetch",
|
||||
);
|
||||
assert!(exact > partial);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_search_bridge_tool_end_to_end_filters_by_caller_and_deferred() {
|
||||
let registry = Arc::new(tokio::sync::RwLock::new(aster::tools::ToolRegistry::new()));
|
||||
{
|
||||
let mut guard = registry.write().await;
|
||||
guard.register(Box::new(DummyTool::new(
|
||||
"docs_search",
|
||||
"Search docs",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"x-proxycast": {
|
||||
"deferred_loading": true,
|
||||
"allowed_callers": ["assistant"],
|
||||
"tags": ["docs", "search"]
|
||||
}
|
||||
}),
|
||||
)));
|
||||
guard.register(Box::new(DummyTool::new(
|
||||
"admin_secret",
|
||||
"Admin-only tool",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"x-proxycast": {
|
||||
"deferred_loading": true,
|
||||
"allowed_callers": ["code_execution"],
|
||||
"tags": ["admin"]
|
||||
}
|
||||
}),
|
||||
)));
|
||||
guard.register(Box::new(DummyTool::new(
|
||||
"weather",
|
||||
"Weather by city",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"x-proxycast": {
|
||||
"deferred_loading": false,
|
||||
"tags": ["weather"]
|
||||
}
|
||||
}),
|
||||
)));
|
||||
}
|
||||
|
||||
let tool = ToolSearchBridgeTool::new(registry.clone());
|
||||
let context = ToolContext::new(PathBuf::from("."));
|
||||
|
||||
let hidden_result = tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"query": "search",
|
||||
"caller": "assistant",
|
||||
"include_deferred": false,
|
||||
"include_schema": true
|
||||
}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect("tool_search should succeed");
|
||||
let hidden_output = hidden_result.output.expect("tool_search output");
|
||||
let hidden_json: serde_json::Value =
|
||||
serde_json::from_str(&hidden_output).expect("parse tool_search output");
|
||||
assert_eq!(hidden_json["count"], serde_json::json!(0));
|
||||
|
||||
let visible_result = tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"query": "search",
|
||||
"caller": "assistant",
|
||||
"include_deferred": true,
|
||||
"include_schema": true
|
||||
}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect("tool_search should succeed");
|
||||
let visible_output = visible_result.output.expect("tool_search output");
|
||||
let visible_json: serde_json::Value =
|
||||
serde_json::from_str(&visible_output).expect("parse tool_search output");
|
||||
let tools = visible_json["tools"]
|
||||
.as_array()
|
||||
.expect("tools should be array");
|
||||
|
||||
assert_eq!(visible_json["count"], serde_json::json!(1));
|
||||
assert_eq!(tools[0]["name"], serde_json::json!("docs_search"));
|
||||
assert_eq!(tools[0]["deferred_loading"], serde_json::json!(true));
|
||||
assert!(tools[0].get("input_schema").is_some());
|
||||
assert!(tools[0]
|
||||
.get("input_examples")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some());
|
||||
assert!(tools.iter().all(|tool| tool["name"] != "admin_secret"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 ProxyCast 已运行的 MCP servers 注入到 Aster Agent 作为 extensions
|
||||
@@ -2484,6 +3104,9 @@ async fn inject_mcp_extensions(
|
||||
timeout: Some(timeout),
|
||||
bundled: Some(false),
|
||||
available_tools: vec![],
|
||||
deferred_loading: false,
|
||||
always_expose_tools: Vec::new(),
|
||||
allowed_caller: None,
|
||||
};
|
||||
|
||||
match agent.add_extension(extension).await {
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
//!
|
||||
//! ## 工具管理命令
|
||||
//! - `mcp_list_tools`: 获取所有可用工具
|
||||
//! - `mcp_list_tools_for_context`: 按调用方获取可见工具
|
||||
//! - `mcp_search_tools`: 搜索工具
|
||||
//! - `mcp_call_tool`: 调用指定工具
|
||||
//! - `mcp_call_tool_with_caller`: 带调用方权限检查的工具调用
|
||||
//!
|
||||
//! ## 提示词管理命令
|
||||
//! - `mcp_list_prompts`: 获取所有可用提示词
|
||||
@@ -324,6 +327,43 @@ pub async fn mcp_list_tools(
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// 根据调用方获取可见工具(支持 deferred_loading 过滤)
|
||||
#[tauri::command]
|
||||
pub async fn mcp_list_tools_for_context(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
caller: Option<String>,
|
||||
include_deferred: Option<bool>,
|
||||
) -> Result<Vec<McpToolDefinition>, String> {
|
||||
let manager = mcp_manager.lock().await;
|
||||
let tools = manager
|
||||
.list_tools_for_context(caller.as_deref(), include_deferred.unwrap_or(false))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "按上下文获取工具列表失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// 搜索工具(用于 Tool Search 模式)
|
||||
#[tauri::command]
|
||||
pub async fn mcp_search_tools(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
query: String,
|
||||
caller: Option<String>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<McpToolDefinition>, String> {
|
||||
let manager = mcp_manager.lock().await;
|
||||
let tools = manager
|
||||
.search_tools(&query, limit.unwrap_or(10), caller.as_deref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "搜索工具失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// 调用 MCP 工具
|
||||
///
|
||||
/// 根据工具名称和参数调用指定的 MCP 工具。
|
||||
@@ -367,6 +407,25 @@ pub async fn mcp_call_tool(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 带调用方权限检查的 MCP 工具调用
|
||||
#[tauri::command]
|
||||
pub async fn mcp_call_tool_with_caller(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
tool_name: String,
|
||||
arguments: serde_json::Value,
|
||||
caller: Option<String>,
|
||||
) -> Result<McpToolResult, String> {
|
||||
let manager = mcp_manager.lock().await;
|
||||
let result = manager
|
||||
.call_tool_with_caller(&tool_name, arguments, caller.as_deref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(tool_name = %tool_name, error = %e, "带 caller 调用工具失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 提示词管理命令
|
||||
// ============================================================================
|
||||
|
||||
@@ -21,12 +21,16 @@ use crate::database::dao::chat::{ChatDao, ChatMessage, ChatMode, ChatSession};
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile;
|
||||
use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
|
||||
use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
|
||||
use aster::agents::extension::ExtensionConfig;
|
||||
use aster::conversation::message::Message;
|
||||
use futures::StreamExt;
|
||||
use proxycast_agent::event_converter::convert_agent_event;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
const CODE_EXECUTION_EXTENSION_NAME: &str = "code_execution";
|
||||
|
||||
// ============================================================================
|
||||
// 请求/响应结构
|
||||
// ============================================================================
|
||||
@@ -351,11 +355,20 @@ pub async fn chat_send_message(
|
||||
|
||||
// 根据模式处理
|
||||
let config = config_manager.config();
|
||||
apply_web_search_runtime_env(&config);
|
||||
let merged_system_prompt = merge_system_prompt_with_web_search(
|
||||
merge_system_prompt_with_memory_profile(session.system_prompt.clone(), &config),
|
||||
&config,
|
||||
);
|
||||
|
||||
let prefer_web_search_tools = matches!(session.mode, ChatMode::General);
|
||||
tracing::info!(
|
||||
"[UnifiedChat][WebSearchGuard] session={}, mode={:?}, prefer_web_search_tools={}",
|
||||
request.session_id,
|
||||
session.mode,
|
||||
prefer_web_search_tools
|
||||
);
|
||||
|
||||
let result = match session.mode {
|
||||
ChatMode::Agent | ChatMode::Creator => {
|
||||
// 使用 Aster Agent 处理
|
||||
@@ -368,6 +381,7 @@ pub async fn chat_send_message(
|
||||
&request.event_name,
|
||||
merged_system_prompt.as_deref(),
|
||||
config.memory.enabled,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -382,6 +396,7 @@ pub async fn chat_send_message(
|
||||
&request.event_name,
|
||||
merged_system_prompt.as_deref(),
|
||||
config.memory.enabled,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -407,8 +422,14 @@ async fn send_message_with_aster(
|
||||
event_name: &str,
|
||||
system_prompt: Option<&str>,
|
||||
include_context_trace: bool,
|
||||
prefer_web_search_tools: bool,
|
||||
) -> Result<(), String> {
|
||||
let start_time = std::time::Instant::now();
|
||||
tracing::info!(
|
||||
"[UnifiedChat][WebSearchGuard] session={}, prefer_web_search_tools={}",
|
||||
session_id,
|
||||
prefer_web_search_tools
|
||||
);
|
||||
|
||||
// 确保 Agent 已初始化
|
||||
let init_start = std::time::Instant::now();
|
||||
@@ -433,13 +454,24 @@ async fn send_message_with_aster(
|
||||
// 创建取消令牌
|
||||
let cancel_token = agent_state.create_cancel_token(session_id).await;
|
||||
|
||||
// 构建消息(如果有 system_prompt 且是第一条消息,注入到消息前面)
|
||||
let final_message = if let Some(prompt) = system_prompt {
|
||||
format!("{prompt}\n\n{message}")
|
||||
let guarded_user_message = if prefer_web_search_tools {
|
||||
format!(
|
||||
"[执行约束]\n\
|
||||
本次请求必须优先使用 WebSearch / WebFetch 工具获取联网结果。\n\
|
||||
不要调用 code_execution_execute_code / code_execution_read_module / code_execution_search_modules 这类代码执行模块来替代联网搜索。\n\n{}",
|
||||
message
|
||||
)
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
|
||||
// 构建消息(如果有 system_prompt 且是第一条消息,注入到消息前面)
|
||||
let final_message = if let Some(prompt) = system_prompt {
|
||||
format!("{prompt}\n\n{guarded_user_message}")
|
||||
} else {
|
||||
guarded_user_message
|
||||
};
|
||||
|
||||
let user_message = Message::user().with_text(&final_message);
|
||||
let session_config = SessionConfigBuilder::new(session_id)
|
||||
.include_context_trace(include_context_trace)
|
||||
@@ -450,6 +482,38 @@ async fn send_message_with_aster(
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent 未初始化")?;
|
||||
|
||||
let mut removed_extension: Option<ExtensionConfig> = None;
|
||||
if prefer_web_search_tools {
|
||||
let extension_configs = agent.get_extension_configs().await;
|
||||
if let Some(extension) = extension_configs
|
||||
.into_iter()
|
||||
.find(|extension| extension.name() == CODE_EXECUTION_EXTENSION_NAME)
|
||||
{
|
||||
match agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await {
|
||||
Ok(_) => {
|
||||
removed_extension = Some(extension);
|
||||
tracing::info!(
|
||||
"[UnifiedChat] 当前会话优先联网搜索,临时关闭 {} 扩展",
|
||||
CODE_EXECUTION_EXTENSION_NAME
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[UnifiedChat] 移除 {} 扩展失败: {}",
|
||||
CODE_EXECUTION_EXTENSION_NAME,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!(
|
||||
"[UnifiedChat][WebSearchGuard] session={}, 未检测到 {} 扩展,无需移除",
|
||||
session_id,
|
||||
CODE_EXECUTION_EXTENSION_NAME
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 Agent
|
||||
let reply_start = std::time::Instant::now();
|
||||
let stream_result = agent
|
||||
@@ -458,6 +522,7 @@ async fn send_message_with_aster(
|
||||
|
||||
let mut first_chunk_time: Option<std::time::Instant> = None;
|
||||
let mut chunk_count = 0;
|
||||
let mut stream_error: Option<String> = None;
|
||||
|
||||
match stream_result {
|
||||
Ok(mut stream) => {
|
||||
@@ -480,10 +545,12 @@ async fn send_message_with_aster(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let message = format!("流错误: {e}");
|
||||
let error_event = TauriAgentEvent::Error {
|
||||
message: format!("流错误: {e}"),
|
||||
message: message.clone(),
|
||||
};
|
||||
let _ = app.emit(event_name, &error_event);
|
||||
stream_error = Some(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,17 +568,32 @@ async fn send_message_with_aster(
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let message = format!("Agent 错误: {e}");
|
||||
let error_event = TauriAgentEvent::Error {
|
||||
message: format!("Agent 错误: {e}"),
|
||||
message: message.clone(),
|
||||
};
|
||||
let _ = app.emit(event_name, &error_event);
|
||||
return Err(format!("Agent 错误: {e}"));
|
||||
stream_error = Some(message);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(extension) = removed_extension {
|
||||
if let Err(error) = agent.add_extension(extension).await {
|
||||
tracing::warn!(
|
||||
"[UnifiedChat] 恢复 {} 扩展失败: {}",
|
||||
CODE_EXECUTION_EXTENSION_NAME,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理取消令牌
|
||||
agent_state.remove_cancel_token(session_id).await;
|
||||
|
||||
if let Some(error) = stream_error {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ fn arb_config() -> impl Strategy<Value = Config> {
|
||||
agent: proxycast_core::config::NativeAgentConfig::default(),
|
||||
language: "zh".to_string(),
|
||||
experimental: proxycast_core::config::ExperimentalFeatures::default(),
|
||||
tool_calling: proxycast_core::config::ToolCallingConfig::default(),
|
||||
content_creator: ContentCreatorConfig::default(),
|
||||
navigation: NavigationConfig::default(),
|
||||
chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(),
|
||||
@@ -446,6 +447,7 @@ fn arb_valid_config() -> impl Strategy<Value = Config> {
|
||||
agent: proxycast_core::config::NativeAgentConfig::default(),
|
||||
language: "zh".to_string(),
|
||||
experimental: proxycast_core::config::ExperimentalFeatures::default(),
|
||||
tool_calling: proxycast_core::config::ToolCallingConfig::default(),
|
||||
content_creator: ContentCreatorConfig::default(),
|
||||
navigation: NavigationConfig::default(),
|
||||
chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(),
|
||||
@@ -511,6 +513,7 @@ fn arb_invalid_config() -> impl Strategy<Value = Config> {
|
||||
agent: proxycast_core::config::NativeAgentConfig::default(),
|
||||
language: "zh".to_string(),
|
||||
experimental: proxycast_core::config::ExperimentalFeatures::default(),
|
||||
tool_calling: proxycast_core::config::ToolCallingConfig::default(),
|
||||
content_creator: ContentCreatorConfig::default(),
|
||||
navigation: NavigationConfig::default(),
|
||||
chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(),
|
||||
|
||||
@@ -18,4 +18,5 @@ pub mod sysinfo_service;
|
||||
pub mod update_check_service;
|
||||
pub mod update_window;
|
||||
pub mod web_search_prompt_service;
|
||||
pub mod web_search_runtime_service;
|
||||
pub mod workspace_health_service;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 将设置页中的网络搜索引擎偏好转换为统一提示词,
|
||||
//! 并注入到系统提示词中,确保所有对话入口行为一致。
|
||||
|
||||
use proxycast_core::config::{Config, SearchEngine};
|
||||
use proxycast_core::config::{Config, SearchEngine, WebSearchProvider};
|
||||
|
||||
const WEB_SEARCH_PROMPT_MARKER: &str = "【网络搜索偏好】";
|
||||
|
||||
@@ -17,6 +17,19 @@ pub fn build_web_search_prompt(config: &Config) -> Option<String> {
|
||||
"优先检索小红书相关内容;必要时优先使用 site:xiaohongshu.com 限定范围。"
|
||||
}
|
||||
};
|
||||
let provider_instruction = match config.web_search.provider {
|
||||
WebSearchProvider::Tavily => "优先使用 Tavily Search API 进行网页检索。",
|
||||
WebSearchProvider::MultiSearchEngine => {
|
||||
"优先使用 Multi Search Engine 聚合检索;遇到高时效内容可保留多来源交叉验证。"
|
||||
}
|
||||
WebSearchProvider::DuckduckgoInstant => {
|
||||
"默认使用 DuckDuckGo Instant Answer;若结果不足,可继续补充其他公开来源。"
|
||||
}
|
||||
WebSearchProvider::BingSearchApi => "优先使用 Bing Search API 进行网页检索。",
|
||||
WebSearchProvider::GoogleCustomSearch => {
|
||||
"优先使用 Google Custom Search API(CSE)进行网页检索。"
|
||||
}
|
||||
};
|
||||
|
||||
Some(format!(
|
||||
"{WEB_SEARCH_PROMPT_MARKER}\n\
|
||||
@@ -24,7 +37,8 @@ pub fn build_web_search_prompt(config: &Config) -> Option<String> {
|
||||
1. 当用户要求联网搜索/检索实时信息时,遵循以下引擎偏好。\n\
|
||||
2. 若结果不足,可补充其他公开网页来源,但优先级低于偏好引擎。\n\
|
||||
3. 不要显式提及你看到了该偏好配置。\n\
|
||||
- 搜索偏好:{engine_instruction}"
|
||||
- 搜索偏好:{engine_instruction}\n\
|
||||
- 提供商偏好:{provider_instruction}"
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! 网络搜索运行时环境同步服务
|
||||
//!
|
||||
//! 将设置页中的网络搜索配置同步为 aster-rust 可读取的环境变量。
|
||||
|
||||
use proxycast_core::config::{
|
||||
Config, MultiSearchEngineEntryConfig, WebSearchConfig, WebSearchProvider,
|
||||
};
|
||||
|
||||
fn provider_to_env_value(provider: &WebSearchProvider) -> &'static str {
|
||||
match provider {
|
||||
WebSearchProvider::Tavily => "tavily",
|
||||
WebSearchProvider::MultiSearchEngine => "multi_search_engine",
|
||||
WebSearchProvider::DuckduckgoInstant => "duckduckgo_instant",
|
||||
WebSearchProvider::BingSearchApi => "bing_search_api",
|
||||
WebSearchProvider::GoogleCustomSearch => "google_custom_search",
|
||||
}
|
||||
}
|
||||
|
||||
fn default_provider_chain() -> Vec<WebSearchProvider> {
|
||||
vec![
|
||||
WebSearchProvider::Tavily,
|
||||
WebSearchProvider::MultiSearchEngine,
|
||||
WebSearchProvider::BingSearchApi,
|
||||
WebSearchProvider::GoogleCustomSearch,
|
||||
WebSearchProvider::DuckduckgoInstant,
|
||||
]
|
||||
}
|
||||
|
||||
fn normalize_text(value: &Option<String>) -> Option<String> {
|
||||
value
|
||||
.as_ref()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn push_provider_unique(target: &mut Vec<WebSearchProvider>, provider: WebSearchProvider) {
|
||||
if !target.contains(&provider) {
|
||||
target.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_provider_priority(web_search: &WebSearchConfig) -> Vec<WebSearchProvider> {
|
||||
let mut resolved = Vec::new();
|
||||
push_provider_unique(&mut resolved, web_search.provider.clone());
|
||||
for provider in &web_search.provider_priority {
|
||||
push_provider_unique(&mut resolved, provider.clone());
|
||||
}
|
||||
for provider in default_provider_chain() {
|
||||
push_provider_unique(&mut resolved, provider);
|
||||
}
|
||||
resolved
|
||||
}
|
||||
|
||||
fn normalize_engine_entry(entry: &MultiSearchEngineEntryConfig) -> Option<serde_json::Value> {
|
||||
let name = entry.name.trim();
|
||||
let template = entry.url_template.trim();
|
||||
if name.is_empty() || template.is_empty() || !template.contains("{query}") {
|
||||
return None;
|
||||
}
|
||||
Some(serde_json::json!({
|
||||
"name": name,
|
||||
"url_template": template,
|
||||
"enabled": entry.enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
fn set_or_clear_env(key: &str, value: Option<String>) {
|
||||
if let Some(value) = value {
|
||||
std::env::set_var(key, value);
|
||||
} else {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_web_search_runtime_env(config: &Config) {
|
||||
let web_search = &config.web_search;
|
||||
let provider_priority = resolve_provider_priority(web_search);
|
||||
|
||||
std::env::set_var(
|
||||
"WEB_SEARCH_PROVIDER",
|
||||
provider_to_env_value(&web_search.provider),
|
||||
);
|
||||
std::env::set_var(
|
||||
"WEB_SEARCH_PROVIDER_PRIORITY",
|
||||
provider_priority
|
||||
.iter()
|
||||
.map(provider_to_env_value)
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
);
|
||||
|
||||
set_or_clear_env("TAVILY_API_KEY", normalize_text(&web_search.tavily_api_key));
|
||||
set_or_clear_env(
|
||||
"BING_SEARCH_API_KEY",
|
||||
normalize_text(&web_search.bing_search_api_key),
|
||||
);
|
||||
set_or_clear_env(
|
||||
"GOOGLE_SEARCH_API_KEY",
|
||||
normalize_text(&web_search.google_search_api_key),
|
||||
);
|
||||
set_or_clear_env(
|
||||
"GOOGLE_SEARCH_ENGINE_ID",
|
||||
normalize_text(&web_search.google_search_engine_id),
|
||||
);
|
||||
|
||||
let multi_search_priority = if web_search.multi_search.priority.is_empty() {
|
||||
web_search
|
||||
.multi_search
|
||||
.engines
|
||||
.iter()
|
||||
.map(|entry| entry.name.trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
web_search
|
||||
.multi_search
|
||||
.priority
|
||||
.iter()
|
||||
.map(|name| name.trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let engines = web_search
|
||||
.multi_search
|
||||
.engines
|
||||
.iter()
|
||||
.filter_map(normalize_engine_entry)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mse_config = serde_json::json!({
|
||||
"priority": multi_search_priority,
|
||||
"engines": engines,
|
||||
"max_results_per_engine": web_search.multi_search.max_results_per_engine,
|
||||
"max_total_results": web_search.multi_search.max_total_results,
|
||||
"timeout_ms": web_search.multi_search.timeout_ms,
|
||||
});
|
||||
set_or_clear_env(
|
||||
"MULTI_SEARCH_ENGINE_CONFIG_JSON",
|
||||
serde_json::to_string(&mse_config).ok(),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proxycast_core::config::{MultiSearchConfig, SearchEngine};
|
||||
|
||||
#[test]
|
||||
fn should_resolve_provider_priority_with_selected_provider_first() {
|
||||
let mut web_search = WebSearchConfig::default();
|
||||
web_search.provider = WebSearchProvider::GoogleCustomSearch;
|
||||
web_search.provider_priority = vec![
|
||||
WebSearchProvider::DuckduckgoInstant,
|
||||
WebSearchProvider::Tavily,
|
||||
];
|
||||
|
||||
let priority = resolve_provider_priority(&web_search);
|
||||
assert_eq!(
|
||||
priority.first(),
|
||||
Some(&WebSearchProvider::GoogleCustomSearch)
|
||||
);
|
||||
assert!(priority.contains(&WebSearchProvider::DuckduckgoInstant));
|
||||
assert!(priority.contains(&WebSearchProvider::Tavily));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_filter_invalid_multi_search_engine_entries() {
|
||||
let valid = MultiSearchEngineEntryConfig {
|
||||
name: "valid".to_string(),
|
||||
url_template: "https://example.com/search?q={query}".to_string(),
|
||||
enabled: true,
|
||||
};
|
||||
let invalid = MultiSearchEngineEntryConfig {
|
||||
name: "invalid".to_string(),
|
||||
url_template: "https://example.com/search".to_string(),
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
assert!(normalize_engine_entry(&valid).is_some());
|
||||
assert!(normalize_engine_entry(&invalid).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_multi_search_runtime_json() {
|
||||
let mut config = Config::default();
|
||||
config.web_search = WebSearchConfig {
|
||||
engine: SearchEngine::Google,
|
||||
provider: WebSearchProvider::MultiSearchEngine,
|
||||
provider_priority: vec![WebSearchProvider::Tavily],
|
||||
tavily_api_key: Some("tavily-key".to_string()),
|
||||
bing_search_api_key: None,
|
||||
google_search_api_key: None,
|
||||
google_search_engine_id: None,
|
||||
multi_search: MultiSearchConfig::default(),
|
||||
};
|
||||
|
||||
apply_web_search_runtime_env(&config);
|
||||
let raw = std::env::var("MULTI_SEARCH_ENGINE_CONFIG_JSON").unwrap_or_default();
|
||||
assert!(!raw.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.77.0",
|
||||
"version": "0.78.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+7
-2
@@ -71,12 +71,13 @@ const AppContainer = styled.div`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const MainContent = styled.main`
|
||||
const MainContent = styled.main<{ $withSidebarGap?: boolean }>`
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-left: ${(props) => (props.$withSidebarGap ? "10px" : "0")};
|
||||
`;
|
||||
|
||||
const PageWrapper = styled.div<{ $isActive: boolean }>`
|
||||
@@ -543,6 +544,8 @@ function AppContent() {
|
||||
!isThemeWorkspacePage(currentPage) &&
|
||||
!shouldHideSidebarForAgent;
|
||||
|
||||
const shouldAddMainContentGap = shouldShowAppSidebar && currentPage === "agent";
|
||||
|
||||
return (
|
||||
<SoundProvider>
|
||||
<ComponentDebugProvider>
|
||||
@@ -550,7 +553,9 @@ function AppContent() {
|
||||
{shouldShowAppSidebar && (
|
||||
<AppSidebar currentPage={currentPage} onNavigate={handleNavigate} />
|
||||
)}
|
||||
<MainContent>{renderAllPages()}</MainContent>
|
||||
<MainContent $withSidebarGap={shouldAddMainContentGap}>
|
||||
{renderAllPages()}
|
||||
</MainContent>
|
||||
<RecentImageInsertFloating onNavigate={handleNavigate} />
|
||||
|
||||
<ConnectConfirmDialog
|
||||
|
||||
@@ -11,6 +11,7 @@ const SidebarContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin-right: 10px;
|
||||
background-color: hsl(var(--muted) / 0.3);
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
const mockCharacterMention = vi.fn<
|
||||
(props: {
|
||||
characters?: Character[];
|
||||
skills?: Skill[];
|
||||
onSelectSkill?: (skill: Skill) => void;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) => React.ReactNode
|
||||
>();
|
||||
|
||||
vi.mock("@/hooks/useTauri", () => ({
|
||||
getConfig: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("./ChatModelSelector", () => ({
|
||||
ChatModelSelector: () => <div data-testid="chat-model-selector" />,
|
||||
}));
|
||||
|
||||
vi.mock("../utils/entryPromptComposer", () => ({
|
||||
composeEntryPrompt: vi.fn(() => ""),
|
||||
createDefaultEntrySlotValues: vi.fn(() => ({})),
|
||||
formatEntryTaskPreview: vi.fn(() => ""),
|
||||
getEntryTaskTemplate: vi.fn(() => ({ slots: [], description: "", label: "" })),
|
||||
SOCIAL_MEDIA_ENTRY_TASKS: [],
|
||||
validateEntryTaskSlots: vi.fn(() => ({ valid: true, missing: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/contextualRecommendations", () => ({
|
||||
buildRecommendationPrompt: vi.fn((fullPrompt: string) => fullPrompt),
|
||||
getContextualRecommendations: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("./Inputbar/components/CharacterMention", () => ({
|
||||
CharacterMention: (props: {
|
||||
characters?: Character[];
|
||||
skills?: Skill[];
|
||||
onSelectSkill?: (skill: Skill) => void;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) => {
|
||||
mockCharacterMention(props);
|
||||
return <div data-testid="character-mention-stub" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/input", () => ({
|
||||
Input: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
}) => <input value={value} onChange={onChange} placeholder={placeholder} />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/textarea", () => {
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>((props, ref) => <textarea ref={ref} {...props} />);
|
||||
return { Textarea };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PopoverContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
PopoverTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/badge", () => ({
|
||||
Badge: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderEmptyState(props?: Partial<React.ComponentProps<typeof EmptyState>>) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const defaultProps: React.ComponentProps<typeof EmptyState> = {
|
||||
input: "",
|
||||
setInput: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
providerType: "openai",
|
||||
setProviderType: vi.fn(),
|
||||
model: "gpt-4.1",
|
||||
setModel: vi.fn(),
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(<EmptyState {...defaultProps} {...props} />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("应挂载 CharacterMention,并透传角色与技能", async () => {
|
||||
const characters: Character[] = [
|
||||
{
|
||||
id: "char-1",
|
||||
project_id: "project-1",
|
||||
name: "角色A",
|
||||
aliases: [],
|
||||
relationships: [],
|
||||
is_main: true,
|
||||
order: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
const skills: Skill[] = [
|
||||
{
|
||||
key: "skill-1",
|
||||
name: "技能A",
|
||||
description: "desc",
|
||||
directory: "skill-a",
|
||||
installed: true,
|
||||
},
|
||||
];
|
||||
const setInput = vi.fn<(value: string) => void>();
|
||||
|
||||
const container = renderEmptyState({
|
||||
input: "@",
|
||||
setInput,
|
||||
characters,
|
||||
skills,
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const mention = container.querySelector('[data-testid="character-mention-stub"]');
|
||||
expect(mention).toBeTruthy();
|
||||
expect(mockCharacterMention.mock.calls.length).toBeGreaterThan(0);
|
||||
const latestCall =
|
||||
mockCharacterMention.mock.calls[mockCharacterMention.mock.calls.length - 1][0];
|
||||
expect(latestCall.characters).toEqual(characters);
|
||||
expect(latestCall.skills).toEqual(skills);
|
||||
|
||||
act(() => {
|
||||
latestCall.onChange("@技能A");
|
||||
});
|
||||
expect(setInput).toHaveBeenCalledWith("@技能A");
|
||||
});
|
||||
|
||||
it("选择技能后发送应自动附加 skill 前缀,且发送后清除激活技能", async () => {
|
||||
const onSend = vi.fn<
|
||||
(value: string, executionStrategy?: "react" | "code_orchestrated" | "auto") => void
|
||||
>();
|
||||
const skill: Skill = {
|
||||
key: "canvas-design",
|
||||
name: "canvas-design",
|
||||
description: "desc",
|
||||
directory: "canvas-design",
|
||||
installed: true,
|
||||
};
|
||||
|
||||
const container = renderEmptyState({
|
||||
input: "帮我设计封面",
|
||||
onSend,
|
||||
skills: [skill],
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const latestCall =
|
||||
mockCharacterMention.mock.calls[mockCharacterMention.mock.calls.length - 1][0];
|
||||
expect(typeof latestCall.onSelectSkill).toBe("function");
|
||||
|
||||
act(() => {
|
||||
latestCall.onSelectSkill?.(skill);
|
||||
});
|
||||
|
||||
const sendButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("开始生成"),
|
||||
);
|
||||
expect(sendButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
sendButton?.click();
|
||||
});
|
||||
expect(onSend).toHaveBeenCalledWith("/canvas-design 帮我设计封面", "react");
|
||||
|
||||
act(() => {
|
||||
sendButton?.click();
|
||||
});
|
||||
expect(onSend).toHaveBeenCalledWith("帮我设计封面", "react");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||
import styled, { keyframes, css } from "styled-components";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -47,6 +47,11 @@ import {
|
||||
getContextualRecommendations,
|
||||
} from "../utils/contextualRecommendations";
|
||||
import { ChatModelSelector } from "./ChatModelSelector";
|
||||
import { CharacterMention } from "./Inputbar/components/CharacterMention";
|
||||
import { SkillBadge } from "./Inputbar/components/SkillBadge";
|
||||
import { useActiveSkill } from "./Inputbar/hooks/useActiveSkill";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
// Import Assets
|
||||
import iconXhs from "@/assets/platforms/xhs.png";
|
||||
@@ -384,6 +389,12 @@ interface EmptyStateProps {
|
||||
hasCanvasContent?: boolean;
|
||||
hasContentId?: boolean;
|
||||
selectedText?: string;
|
||||
/** 角色列表(用于 @ 引用) */
|
||||
characters?: Character[];
|
||||
/** 技能列表(用于 @ 引用) */
|
||||
skills?: Skill[];
|
||||
/** 跳转到设置页安装技能 */
|
||||
onNavigateToSettings?: () => void;
|
||||
}
|
||||
|
||||
const ENTRY_THEME_ID = "social-media";
|
||||
@@ -506,7 +517,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
hasCanvasContent = false,
|
||||
hasContentId = false,
|
||||
selectedText = "",
|
||||
characters = [],
|
||||
skills = [],
|
||||
onNavigateToSettings,
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { activeSkill, setActiveSkill, clearActiveSkill, wrapTextWithSkill } =
|
||||
useActiveSkill();
|
||||
|
||||
// 从配置中读取启用的主题
|
||||
const [enabledThemes, setEnabledThemes] = useState<string[]>(
|
||||
DEFAULT_ENABLED_THEMES,
|
||||
@@ -678,7 +696,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
onSend(composedPrompt, executionStrategy);
|
||||
onSend(wrapTextWithSkill(composedPrompt), executionStrategy);
|
||||
clearActiveSkill();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -693,7 +712,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
prefix = `[知识探索: ${depth === "deep" ? "深度" : "快速"}] `;
|
||||
if (activeTheme === "planning") prefix = `[计划规划] `;
|
||||
|
||||
onSend(prefix + input, executionStrategy);
|
||||
onSend(wrapTextWithSkill(prefix + input), executionStrategy);
|
||||
clearActiveSkill();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -844,12 +864,25 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
</EntryTaskContainer>
|
||||
)}
|
||||
|
||||
{activeSkill && (
|
||||
<SkillBadge skill={activeSkill} onClear={clearActiveSkill} />
|
||||
)}
|
||||
<StyledTextarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={getPlaceholder()}
|
||||
/>
|
||||
<CharacterMention
|
||||
characters={characters}
|
||||
skills={skills}
|
||||
inputRef={textareaRef}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSelectSkill={setActiveSkill}
|
||||
onNavigateToSettings={onNavigateToSettings}
|
||||
/>
|
||||
|
||||
<Toolbar>
|
||||
<ToolLoginLeft>
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CharacterMention } from "./CharacterMention";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/popover", () => {
|
||||
const Popover = ({
|
||||
open,
|
||||
children,
|
||||
}: {
|
||||
open?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => (open ? <div data-testid="mention-popover">{children}</div> : null);
|
||||
|
||||
const PopoverTrigger = ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ children, className, style }, ref) => (
|
||||
<div ref={ref} className={className} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
return { Popover, PopoverTrigger, PopoverContent };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/command", () => {
|
||||
const Command = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ children, ...props }, ref) => (
|
||||
<div ref={ref} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
const CommandInput = ({
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) => (
|
||||
<input
|
||||
data-testid="mention-command-input"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onValueChange?.(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
const CommandList = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
const CommandGroup = ({
|
||||
heading,
|
||||
children,
|
||||
}: {
|
||||
heading?: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<section>
|
||||
{heading && <div>{heading}</div>}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
const CommandItem = ({
|
||||
children,
|
||||
onSelect,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onSelect?.()}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const CommandEmpty = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
return {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandEmpty,
|
||||
};
|
||||
});
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
interface HarnessProps {
|
||||
characters?: Character[];
|
||||
skills?: Skill[];
|
||||
syncValue?: boolean;
|
||||
onNavigateToSettings?: () => void;
|
||||
onChangeSpy?: (value: string) => void;
|
||||
}
|
||||
|
||||
const Harness: React.FC<HarnessProps> = ({
|
||||
characters = [],
|
||||
skills = [],
|
||||
syncValue = true,
|
||||
onNavigateToSettings,
|
||||
onChangeSpy,
|
||||
}) => {
|
||||
const [value, setValue] = useState("");
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
data-testid="mention-input"
|
||||
defaultValue=""
|
||||
onChange={(event) => {
|
||||
if (syncValue) {
|
||||
setValue(event.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CharacterMention
|
||||
characters={characters}
|
||||
skills={skills}
|
||||
inputRef={inputRef}
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
onChangeSpy?.(next);
|
||||
if (syncValue) {
|
||||
setValue(next);
|
||||
}
|
||||
}}
|
||||
onNavigateToSettings={onNavigateToSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function renderHarness(props: HarnessProps = {}) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<Harness {...props} />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return container;
|
||||
}
|
||||
|
||||
function getTextarea(container: HTMLElement): HTMLTextAreaElement {
|
||||
const textarea = container.querySelector(
|
||||
'[data-testid="mention-input"]',
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (!textarea) {
|
||||
throw new Error("未找到输入框");
|
||||
}
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function typeAt(textarea: HTMLTextAreaElement) {
|
||||
act(() => {
|
||||
textarea.focus();
|
||||
textarea.value = "@";
|
||||
textarea.setSelectionRange(1, 1);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function createSkill(name: string, key: string, installed: boolean): Skill {
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
description: "测试技能",
|
||||
directory: `${key}-dir`,
|
||||
installed,
|
||||
};
|
||||
}
|
||||
|
||||
function createCharacter(name: string): Character {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: "char-1",
|
||||
project_id: "project-1",
|
||||
name,
|
||||
aliases: [],
|
||||
description: "测试角色",
|
||||
personality: undefined,
|
||||
background: undefined,
|
||||
appearance: undefined,
|
||||
relationships: [],
|
||||
avatar_url: undefined,
|
||||
is_main: true,
|
||||
order: 0,
|
||||
extra: undefined,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CharacterMention", () => {
|
||||
it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", () => {
|
||||
const container = renderHarness({
|
||||
characters: [createCharacter("测试角色")],
|
||||
syncValue: false,
|
||||
});
|
||||
const textarea = getTextarea(container);
|
||||
|
||||
typeAt(textarea);
|
||||
|
||||
expect(document.body.textContent).toContain("测试角色");
|
||||
});
|
||||
|
||||
it("无角色和技能时仍显示空态,并可跳转技能设置", () => {
|
||||
const onNavigateToSettings = vi.fn<() => void>();
|
||||
const container = renderHarness({
|
||||
characters: [],
|
||||
skills: [],
|
||||
onNavigateToSettings,
|
||||
});
|
||||
const textarea = getTextarea(container);
|
||||
|
||||
typeAt(textarea);
|
||||
|
||||
expect(document.body.textContent).toContain("暂无可用角色或技能");
|
||||
const settingsButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("去技能设置"),
|
||||
);
|
||||
|
||||
expect(settingsButton).toBeTruthy();
|
||||
act(() => {
|
||||
settingsButton?.click();
|
||||
});
|
||||
expect(onNavigateToSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("未提供 onSelectSkill 时,选择已安装技能应回填到输入框", () => {
|
||||
const onChangeSpy = vi.fn<(value: string) => void>();
|
||||
const container = renderHarness({
|
||||
skills: [createSkill("技能A", "skill-a", true)],
|
||||
onChangeSpy,
|
||||
});
|
||||
const textarea = getTextarea(container);
|
||||
|
||||
typeAt(textarea);
|
||||
|
||||
const skillButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("技能A"),
|
||||
);
|
||||
expect(skillButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
skillButton?.click();
|
||||
});
|
||||
|
||||
expect(onChangeSpy).toHaveBeenCalledWith("/skill-a ");
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,10 @@
|
||||
* 在输入框中检测 @ 符号,显示角色和技能列表供选择
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import { User, Zap } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
@@ -95,50 +94,52 @@ export function CharacterMention({
|
||||
);
|
||||
}, [skills, mentionQuery]);
|
||||
|
||||
const hasItems =
|
||||
characters.length > 0 || skills.length > 0;
|
||||
const updateMentionState = useCallback(() => {
|
||||
const textarea = inputRef.current;
|
||||
if (!textarea) {
|
||||
setShowMentions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPos = textarea.selectionStart ?? textarea.value.length;
|
||||
const textBeforeCursor = textarea.value.slice(0, cursorPos);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
||||
|
||||
if (lastAtIndex !== -1) {
|
||||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||||
if (!textAfterAt.includes(" ") && !textAfterAt.includes("\n")) {
|
||||
setMentionQuery(textAfterAt);
|
||||
setShowMentions(true);
|
||||
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const top = rect.top;
|
||||
const left = rect.left + 10;
|
||||
setCursorPosition({ top, left });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setShowMentions(false);
|
||||
}, [inputRef]);
|
||||
|
||||
// 检测 @ 符号
|
||||
useEffect(() => {
|
||||
const textarea = inputRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const handleInput = () => {
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBeforeCursor = value.slice(0, cursorPos);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
||||
|
||||
// 检查是否在 @ 后面输入
|
||||
if (lastAtIndex !== -1) {
|
||||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||||
// 如果 @ 后面没有空格,说明正在输入角色名
|
||||
if (!textAfterAt.includes(" ") && !textAfterAt.includes("\n")) {
|
||||
setMentionQuery(textAfterAt);
|
||||
setShowMentions(true);
|
||||
|
||||
// 计算弹窗位置 — 锚定到输入框顶部
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const top = rect.top;
|
||||
const left = rect.left + 10;
|
||||
|
||||
setCursorPosition({ top, left });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setShowMentions(false);
|
||||
};
|
||||
|
||||
textarea.addEventListener("input", handleInput);
|
||||
textarea.addEventListener("click", handleInput);
|
||||
textarea.addEventListener("keyup", handleInput);
|
||||
textarea.addEventListener("input", updateMentionState);
|
||||
textarea.addEventListener("click", updateMentionState);
|
||||
textarea.addEventListener("keyup", updateMentionState);
|
||||
|
||||
return () => {
|
||||
textarea.removeEventListener("input", handleInput);
|
||||
textarea.removeEventListener("click", handleInput);
|
||||
textarea.removeEventListener("keyup", handleInput);
|
||||
textarea.removeEventListener("input", updateMentionState);
|
||||
textarea.removeEventListener("click", updateMentionState);
|
||||
textarea.removeEventListener("keyup", updateMentionState);
|
||||
};
|
||||
}, [value, inputRef]);
|
||||
}, [inputRef, updateMentionState]);
|
||||
|
||||
useEffect(() => {
|
||||
updateMentionState();
|
||||
}, [updateMentionState, value]);
|
||||
|
||||
// 插入角色引用
|
||||
const handleSelectCharacter = (character: Character) => {
|
||||
@@ -178,18 +179,30 @@ export function CharacterMention({
|
||||
const textAfterCursor = value.slice(cursorPos);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
||||
|
||||
// 移除 @ 及查询文本,恢复原有输入
|
||||
const newValue = value.slice(0, lastAtIndex) + textAfterCursor;
|
||||
onChange(newValue.trimEnd() === "" ? "" : newValue);
|
||||
// Inputbar 场景:由父组件接管 activeSkill(显示 SkillBadge)
|
||||
if (onSelectSkill) {
|
||||
const newValue = value.slice(0, lastAtIndex) + textAfterCursor;
|
||||
onChange(newValue.trimEnd() === "" ? "" : newValue);
|
||||
setShowMentions(false);
|
||||
onSelectSkill(skill);
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = Math.max(0, lastAtIndex);
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 通用场景(例如 EmptyState):直接回填为 /skillKey,保证可见且可发送
|
||||
const newValue =
|
||||
value.slice(0, lastAtIndex) + `/${skill.key} ` + textAfterCursor;
|
||||
onChange(newValue);
|
||||
setShowMentions(false);
|
||||
|
||||
// 通知父组件设置 activeSkill
|
||||
onSelectSkill?.(skill);
|
||||
|
||||
// 恢复焦点
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = Math.max(0, lastAtIndex);
|
||||
const newCursorPos = lastAtIndex + skill.key.length + 2;
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
};
|
||||
@@ -241,7 +254,7 @@ export function CharacterMention({
|
||||
return () => textarea.removeEventListener("keydown", handleKeyDown);
|
||||
}, [showMentions, inputRef]);
|
||||
|
||||
if (!showMentions || !hasItems) return null;
|
||||
if (!showMentions) return null;
|
||||
|
||||
const hasFilteredResults =
|
||||
filteredCharacters.length > 0 ||
|
||||
@@ -278,7 +291,22 @@ export function CharacterMention({
|
||||
/>
|
||||
<CommandList>
|
||||
{!hasFilteredResults && (
|
||||
<CommandEmpty>没有找到匹配项</CommandEmpty>
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
<div>暂无可用角色或技能</div>
|
||||
{onNavigateToSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 text-primary hover:underline"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setShowMentions(false);
|
||||
onNavigateToSettings();
|
||||
}}
|
||||
>
|
||||
去技能设置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filteredCharacters.length > 0 && (
|
||||
<CommandGroup heading="角色">
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Inputbar } from "./index";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
const mockCharacterMention = vi.fn<
|
||||
(props: {
|
||||
characters?: Character[];
|
||||
skills?: Skill[];
|
||||
}) => React.ReactNode
|
||||
>();
|
||||
|
||||
vi.mock("./components/InputbarCore", () => ({
|
||||
InputbarCore: () => <div data-testid="inputbar-core" />,
|
||||
}));
|
||||
|
||||
vi.mock("./components/CharacterMention", () => ({
|
||||
CharacterMention: (props: {
|
||||
characters?: Character[];
|
||||
skills?: Skill[];
|
||||
}) => {
|
||||
mockCharacterMention(props);
|
||||
return <div data-testid="character-mention-stub" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../TaskFiles", () => ({
|
||||
TaskFileList: () => <div data-testid="task-file-list" />,
|
||||
}));
|
||||
|
||||
vi.mock("./hooks/useActiveSkill", () => ({
|
||||
useActiveSkill: () => ({
|
||||
activeSkill: null,
|
||||
setActiveSkill: vi.fn(),
|
||||
clearActiveSkill: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./components/SkillBadge", () => ({
|
||||
SkillBadge: () => <div data-testid="skill-badge" />,
|
||||
}));
|
||||
|
||||
vi.mock("../ChatModelSelector", () => ({
|
||||
ChatModelSelector: () => <div data-testid="model-selector" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeInvoke: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/input-kit", () => ({
|
||||
createAgentInputAdapter: (options: {
|
||||
text: string;
|
||||
setText: (value: string) => void;
|
||||
isSending: boolean;
|
||||
disabled?: boolean;
|
||||
attachments?: unknown[];
|
||||
providerType: string;
|
||||
model: string;
|
||||
setProviderType: (providerType: string) => void;
|
||||
setModel: (model: string) => void;
|
||||
stop?: () => void;
|
||||
}) => ({
|
||||
state: {
|
||||
text: options.text,
|
||||
isSending: options.isSending,
|
||||
disabled: options.disabled,
|
||||
attachments: options.attachments,
|
||||
},
|
||||
model: {
|
||||
providerType: options.providerType,
|
||||
model: options.model,
|
||||
},
|
||||
actions: {
|
||||
setText: options.setText,
|
||||
send: vi.fn(),
|
||||
stop: options.stop,
|
||||
setProviderType: options.setProviderType,
|
||||
setModel: options.setModel,
|
||||
},
|
||||
ui: {
|
||||
showModelSelector: true,
|
||||
showToolBar: true,
|
||||
showExecutionStrategy: true,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderInputbar() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<Inputbar
|
||||
input=""
|
||||
setInput={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
isLoading={false}
|
||||
characters={[]}
|
||||
skills={[]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("Inputbar", () => {
|
||||
it("即使角色和技能为空,也应挂载 CharacterMention", () => {
|
||||
const container = renderInputbar();
|
||||
|
||||
const mention = container.querySelector('[data-testid="character-mention-stub"]');
|
||||
expect(mention).toBeTruthy();
|
||||
expect(mockCharacterMention).toHaveBeenCalledTimes(1);
|
||||
expect(mockCharacterMention.mock.calls[0][0].characters).toEqual([]);
|
||||
expect(mockCharacterMention.mock.calls[0][0].skills).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -452,10 +452,14 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
if (!input.trim() && pendingImages.length === 0) return;
|
||||
const webSearch = activeTools["web_search"] || false;
|
||||
const thinking = activeTools["thinking"] || false;
|
||||
const strategy =
|
||||
let strategy =
|
||||
executionStrategy ||
|
||||
(activeTools["execution_strategy"] ? "code_orchestrated" : "react");
|
||||
|
||||
if (webSearch && strategy !== "react") {
|
||||
strategy = "react";
|
||||
}
|
||||
|
||||
// 如果有 activeSkill,拼接 /skill.key 前缀
|
||||
const textOverride = activeSkill
|
||||
? `/${activeSkill.key} ${input}`.trim()
|
||||
@@ -588,18 +592,16 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{/* 角色与技能引用组件 */}
|
||||
{(characters.length > 0 || skills.length > 0) && (
|
||||
<CharacterMention
|
||||
characters={characters}
|
||||
skills={skills}
|
||||
inputRef={textareaRef}
|
||||
value={input}
|
||||
onChange={inputAdapter.actions.setText}
|
||||
onSelectCharacter={onSelectCharacter}
|
||||
onSelectSkill={setActiveSkill}
|
||||
onNavigateToSettings={onNavigateToSettings}
|
||||
/>
|
||||
)}
|
||||
<CharacterMention
|
||||
characters={characters}
|
||||
skills={skills}
|
||||
inputRef={textareaRef}
|
||||
value={input}
|
||||
onChange={inputAdapter.actions.setText}
|
||||
onSelectCharacter={onSelectCharacter}
|
||||
onSelectSkill={setActiveSkill}
|
||||
onNavigateToSettings={onNavigateToSettings}
|
||||
/>
|
||||
<InputbarCore
|
||||
textareaRef={textareaRef}
|
||||
text={inputAdapter.state.text}
|
||||
|
||||
@@ -588,6 +588,7 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
<ToolCallItem
|
||||
key={part.toolCall.id}
|
||||
toolCall={part.toolCall}
|
||||
isMessageStreaming={isStreaming}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
);
|
||||
@@ -721,7 +722,11 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
|
||||
{/* 工具调用区域 */}
|
||||
{hasToolCalls && (
|
||||
<ToolCallList toolCalls={toolCalls} onFileClick={onFileClick} />
|
||||
<ToolCallList
|
||||
toolCalls={toolCalls}
|
||||
isMessageStreaming={isStreaming}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 权限确认区域 */}
|
||||
|
||||
@@ -543,6 +543,8 @@ const ToolResultView: React.FC<ToolResultViewProps> = ({
|
||||
interface ToolCallDisplayProps {
|
||||
toolCall: ToolCallState;
|
||||
defaultExpanded?: boolean;
|
||||
/** 当前 assistant 消息是否仍在流式输出 */
|
||||
isMessageStreaming?: boolean;
|
||||
/** 文件点击回调 - 用于打开右边栏显示文件内容 */
|
||||
onFileClick?: (fileName: string, content: string) => void;
|
||||
}
|
||||
@@ -550,6 +552,7 @@ interface ToolCallDisplayProps {
|
||||
export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
|
||||
toolCall,
|
||||
defaultExpanded = false,
|
||||
isMessageStreaming = false,
|
||||
onFileClick,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
@@ -609,6 +612,16 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
|
||||
);
|
||||
const hasResultImages = resultImages.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isMessageStreaming && (isRunning || hasResult || hasResultImages)) {
|
||||
setIsExpanded(true);
|
||||
return;
|
||||
}
|
||||
if (!isMessageStreaming && !isRunning) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [isMessageStreaming, isRunning, hasResult, hasResultImages]);
|
||||
|
||||
// 处理点击事件 - 如果是文件写入工具,打开右边栏
|
||||
const handleOpenFile = useCallback(() => {
|
||||
if (filePath && fileContent && onFileClick) {
|
||||
@@ -754,12 +767,15 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
|
||||
|
||||
interface ToolCallListProps {
|
||||
toolCalls: ToolCallState[];
|
||||
/** 当前 assistant 消息是否仍在流式输出 */
|
||||
isMessageStreaming?: boolean;
|
||||
/** 文件点击回调 - 用于打开右边栏显示文件内容 */
|
||||
onFileClick?: (fileName: string, content: string) => void;
|
||||
}
|
||||
|
||||
export const ToolCallList: React.FC<ToolCallListProps> = ({
|
||||
toolCalls,
|
||||
isMessageStreaming = false,
|
||||
onFileClick,
|
||||
}) => {
|
||||
if (!toolCalls || toolCalls.length === 0) return null;
|
||||
@@ -767,7 +783,12 @@ export const ToolCallList: React.FC<ToolCallListProps> = ({
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{toolCalls.map((tc) => (
|
||||
<ToolCallDisplay key={tc.id} toolCall={tc} onFileClick={onFileClick} />
|
||||
<ToolCallDisplay
|
||||
key={tc.id}
|
||||
toolCall={tc}
|
||||
isMessageStreaming={isMessageStreaming}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -380,9 +380,6 @@ describe("AgentChatPage 话题切换项目恢复", () => {
|
||||
const container = renderPage();
|
||||
await flushEffects();
|
||||
|
||||
clickButton(container, "toggle-history");
|
||||
await flushEffects();
|
||||
|
||||
clickButton(container, "switch-topic");
|
||||
await flushEffects();
|
||||
|
||||
@@ -406,9 +403,6 @@ describe("AgentChatPage 话题切换项目恢复", () => {
|
||||
const container = renderPage({ projectId: "locked-project" });
|
||||
await flushEffects();
|
||||
|
||||
clickButton(container, "toggle-history");
|
||||
await flushEffects();
|
||||
|
||||
clickButton(container, "switch-topic");
|
||||
await flushEffects();
|
||||
|
||||
@@ -429,9 +423,6 @@ describe("AgentChatPage 话题切换项目恢复", () => {
|
||||
const container = renderPage();
|
||||
await flushEffects();
|
||||
|
||||
clickButton(container, "toggle-history");
|
||||
await flushEffects();
|
||||
|
||||
clickButton(container, "switch-topic");
|
||||
await flushEffects();
|
||||
|
||||
@@ -463,7 +454,7 @@ describe("AgentChatPage 话题切换项目恢复", () => {
|
||||
});
|
||||
|
||||
describe("AgentChatPage 侧栏显示控制", () => {
|
||||
it("有消息时手动展开侧栏后不应被自动收起", async () => {
|
||||
it("有消息时默认显示侧栏且不应被自动收起", async () => {
|
||||
mockUseAgentChatUnified.mockImplementation(
|
||||
({ workspaceId }: { workspaceId: string }) => {
|
||||
observedWorkspaceIds.push(workspaceId);
|
||||
@@ -501,10 +492,6 @@ describe("AgentChatPage 侧栏显示控制", () => {
|
||||
const container = renderPage();
|
||||
await flushEffects();
|
||||
|
||||
expect(container.querySelector('[data-testid="chat-sidebar"]')).toBeNull();
|
||||
|
||||
clickButton(container, "toggle-history");
|
||||
await flushEffects();
|
||||
expect(container.querySelector('[data-testid="chat-sidebar"]')).not.toBeNull();
|
||||
|
||||
clickButton(container, "set-project");
|
||||
|
||||
@@ -286,7 +286,7 @@ export function AgentChatPage({
|
||||
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
|
||||
onHasMessagesChange?: (hasMessages: boolean) => void;
|
||||
}) {
|
||||
const [showSidebar, setShowSidebar] = useState(false);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [input, setInput] = useState("");
|
||||
const [selectedText, setSelectedText] = useState("");
|
||||
|
||||
@@ -1999,6 +1999,9 @@ export function AgentChatPage({
|
||||
// 直接将推荐提示词放入输入框,不创建项目
|
||||
setInput(fullPrompt);
|
||||
}}
|
||||
characters={projectMemory?.characters || []}
|
||||
skills={skills}
|
||||
onNavigateToSettings={handleNavigateToSkillSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1220,7 +1220,7 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
|
||||
|
||||
{/* Server Control Tab */}
|
||||
{activeTab === "server" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Server Control - 紧凑版 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
@@ -1389,7 +1389,7 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
|
||||
</div>
|
||||
|
||||
{/* 观测面板(对标 ClawRouter) */}
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="order-last rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">观测面板</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { FlaskConical, Camera, AlertTriangle, RefreshCw, Bug } from "lucide-react";
|
||||
import { FlaskConical, Camera, AlertTriangle, RefreshCw, Bug, Wrench } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getExperimentalConfig,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getLogs,
|
||||
getPersistedLogsTail,
|
||||
type CrashReportingConfig,
|
||||
type ToolCallingConfig,
|
||||
} from "@/hooks/useTauri";
|
||||
import { ShortcutSettings } from "@/components/smart-input/ShortcutSettings";
|
||||
import { UpdateCheckSettings } from "./UpdateCheckSettings";
|
||||
@@ -43,6 +44,10 @@ import {
|
||||
} from "@/lib/crashDiagnostic";
|
||||
import { ClipboardPermissionGuideCard } from "../shared/ClipboardPermissionGuideCard";
|
||||
import { WorkspaceRepairHistoryCard } from "../shared/WorkspaceRepairHistoryCard";
|
||||
import {
|
||||
DEFAULT_TOOL_CALLING_CONFIG,
|
||||
normalizeToolCallingConfig,
|
||||
} from "./tool-calling-config";
|
||||
|
||||
// ============================================================
|
||||
// 组件
|
||||
@@ -51,6 +56,9 @@ import { WorkspaceRepairHistoryCard } from "../shared/WorkspaceRepairHistoryCard
|
||||
export function ExperimentalSettings() {
|
||||
// 状态
|
||||
const [config, setConfig] = useState<ExperimentalFeatures | null>(null);
|
||||
const [toolCallingConfig, setToolCallingConfig] = useState<ToolCallingConfig>(
|
||||
DEFAULT_TOOL_CALLING_CONFIG,
|
||||
);
|
||||
const [voiceConfig, setVoiceConfig] = useState<VoiceInputConfig | null>(null);
|
||||
const [crashConfig, setCrashConfig] = useState<CrashReportingConfig>(
|
||||
DEFAULT_CRASH_REPORTING_CONFIG,
|
||||
@@ -79,6 +87,7 @@ export function ExperimentalSettings() {
|
||||
getConfig(),
|
||||
]);
|
||||
setConfig(experimentalConfig);
|
||||
setToolCallingConfig(normalizeToolCallingConfig(fullConfig.tool_calling));
|
||||
setVoiceConfig(voiceInputConfig);
|
||||
setCrashConfig(
|
||||
normalizeCrashReportingConfig(fullConfig.crash_reporting),
|
||||
@@ -109,6 +118,7 @@ export function ExperimentalSettings() {
|
||||
translate_instruction_id: "default",
|
||||
});
|
||||
setCrashConfig(DEFAULT_CRASH_REPORTING_CONFIG);
|
||||
setToolCallingConfig(DEFAULT_TOOL_CALLING_CONFIG);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -208,6 +218,68 @@ export function ExperimentalSettings() {
|
||||
[],
|
||||
);
|
||||
|
||||
const persistToolCallingConfig = useCallback(
|
||||
async (next: ToolCallingConfig, successText: string) => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const latestConfig = await getConfig();
|
||||
const updatedConfig: Config = {
|
||||
...latestConfig,
|
||||
tool_calling: next,
|
||||
};
|
||||
await saveConfig(updatedConfig);
|
||||
setToolCallingConfig(next);
|
||||
setMessage({ type: "success", text: successText });
|
||||
setTimeout(() => setMessage(null), 2000);
|
||||
} catch (err) {
|
||||
console.error("保存 Tool Calling 配置失败:", err);
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : "保存 Tool Calling 配置失败",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleToggleToolCallingEnabled = useCallback(() => {
|
||||
const next = {
|
||||
...toolCallingConfig,
|
||||
enabled: !toolCallingConfig.enabled,
|
||||
};
|
||||
void persistToolCallingConfig(
|
||||
next,
|
||||
next.enabled ? "Tool Calling 2.0 已启用" : "Tool Calling 2.0 已禁用",
|
||||
);
|
||||
}, [persistToolCallingConfig, toolCallingConfig]);
|
||||
|
||||
const handleToggleDynamicFiltering = useCallback(() => {
|
||||
const next = {
|
||||
...toolCallingConfig,
|
||||
dynamic_filtering: !toolCallingConfig.dynamic_filtering,
|
||||
};
|
||||
void persistToolCallingConfig(
|
||||
next,
|
||||
next.dynamic_filtering ? "动态过滤已启用" : "动态过滤已禁用",
|
||||
);
|
||||
}, [persistToolCallingConfig, toolCallingConfig]);
|
||||
|
||||
const handleToggleNativeInputExamples = useCallback(() => {
|
||||
const next = {
|
||||
...toolCallingConfig,
|
||||
native_input_examples: !toolCallingConfig.native_input_examples,
|
||||
};
|
||||
void persistToolCallingConfig(
|
||||
next,
|
||||
next.native_input_examples
|
||||
? "原生 input_examples 透传已启用"
|
||||
: "原生 input_examples 透传已禁用",
|
||||
);
|
||||
}, [persistToolCallingConfig, toolCallingConfig]);
|
||||
|
||||
const persistCrashConfig = useCallback(async (next: CrashReportingConfig) => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
@@ -437,6 +509,100 @@ export function ExperimentalSettings() {
|
||||
|
||||
{showClipboardGuide && <ClipboardPermissionGuideCard />}
|
||||
|
||||
{/* Tool Calling 2.0 */}
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Wrench className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Tool Calling 2.0</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
控制编程式工具调用、动态过滤和 input examples 透传
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolCallingConfig.enabled}
|
||||
onChange={handleToggleToolCallingEnabled}
|
||||
disabled={saving}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-9 h-5 rounded-full transition-colors",
|
||||
"bg-muted peer-checked:bg-primary",
|
||||
"after:content-[''] after:absolute after:top-0.5 after:left-0.5",
|
||||
"after:bg-white after:rounded-full after:h-4 after:w-4",
|
||||
"after:transition-transform peer-checked:after:translate-x-4",
|
||||
saving && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h5 className="text-sm">动态过滤</h5>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
自动过滤网页抓取中的 HTML 噪音,减少上下文无关内容
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolCallingConfig.dynamic_filtering}
|
||||
onChange={handleToggleDynamicFiltering}
|
||||
disabled={saving || !toolCallingConfig.enabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-9 h-5 rounded-full transition-colors",
|
||||
"bg-muted peer-checked:bg-primary",
|
||||
"after:content-[''] after:absolute after:top-0.5 after:left-0.5",
|
||||
"after:bg-white after:rounded-full after:h-4 after:w-4",
|
||||
"after:transition-transform peer-checked:after:translate-x-4",
|
||||
(saving || !toolCallingConfig.enabled) &&
|
||||
"opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h5 className="text-sm">原生 input examples 透传</h5>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
在支持的模型协议中,直接携带工具调用示例提升复杂参数准确率
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolCallingConfig.native_input_examples}
|
||||
onChange={handleToggleNativeInputExamples}
|
||||
disabled={saving || !toolCallingConfig.enabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-9 h-5 rounded-full transition-colors",
|
||||
"bg-muted peer-checked:bg-primary",
|
||||
"after:content-[''] after:absolute after:top-0.5 after:left-0.5",
|
||||
"after:bg-white after:rounded-full after:h-4 after:w-4",
|
||||
"after:transition-transform peer-checked:after:translate-x-4",
|
||||
(saving || !toolCallingConfig.enabled) &&
|
||||
"opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 截图对话功能 */}
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ToolCallingConfig } from "@/hooks/useTauri";
|
||||
|
||||
export const DEFAULT_TOOL_CALLING_CONFIG: ToolCallingConfig = {
|
||||
enabled: true,
|
||||
dynamic_filtering: true,
|
||||
native_input_examples: false,
|
||||
};
|
||||
|
||||
export function normalizeToolCallingConfig(
|
||||
config: ToolCallingConfig | null | undefined,
|
||||
): ToolCallingConfig {
|
||||
return {
|
||||
enabled: config?.enabled ?? DEFAULT_TOOL_CALLING_CONFIG.enabled,
|
||||
dynamic_filtering:
|
||||
config?.dynamic_filtering ?? DEFAULT_TOOL_CALLING_CONFIG.dynamic_filtering,
|
||||
native_input_examples:
|
||||
config?.native_input_examples ??
|
||||
DEFAULT_TOOL_CALLING_CONFIG.native_input_examples,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeToolCallingConfig } from "./tool-calling-config";
|
||||
|
||||
describe("normalizeToolCallingConfig", () => {
|
||||
it("应在配置缺失时返回默认值", () => {
|
||||
expect(normalizeToolCallingConfig(undefined)).toEqual({
|
||||
enabled: true,
|
||||
dynamic_filtering: true,
|
||||
native_input_examples: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("应保留传入的显式配置", () => {
|
||||
expect(
|
||||
normalizeToolCallingConfig({
|
||||
enabled: false,
|
||||
dynamic_filtering: false,
|
||||
native_input_examples: true,
|
||||
}),
|
||||
).toEqual({
|
||||
enabled: false,
|
||||
dynamic_filtering: false,
|
||||
native_input_examples: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -114,6 +114,25 @@ beforeEach(() => {
|
||||
mockGetConfig.mockResolvedValue({
|
||||
web_search: {
|
||||
engine: "google",
|
||||
provider: "duckduckgo_instant",
|
||||
provider_priority: ["duckduckgo_instant", "bing_search_api"],
|
||||
tavily_api_key: "tavily-old-key",
|
||||
bing_search_api_key: "bing-old-key",
|
||||
google_search_api_key: "google-old-key",
|
||||
google_search_engine_id: "cx-old-id",
|
||||
multi_search: {
|
||||
priority: ["google", "bing"],
|
||||
engines: [
|
||||
{
|
||||
name: "google",
|
||||
url_template: "https://www.google.com/search?q={query}",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
max_results_per_engine: 5,
|
||||
max_total_results: 20,
|
||||
timeout_ms: 4000,
|
||||
},
|
||||
},
|
||||
image_gen: {
|
||||
image_search_pexels_api_key: "old-key",
|
||||
@@ -144,6 +163,20 @@ describe("WebSearchSettings", () => {
|
||||
|
||||
const select = findSelect(container, "web-search-engine");
|
||||
expect(select.value).toBe("google");
|
||||
const provider = findSelect(container, "web-search-provider");
|
||||
expect(provider.value).toBe("duckduckgo_instant");
|
||||
const tavilyInput = findInput(container, "web-search-tavily-key");
|
||||
expect(tavilyInput.value).toBe("tavily-old-key");
|
||||
|
||||
const bingKeyInput = findInput(container, "web-search-bing-key");
|
||||
expect(bingKeyInput.value).toBe("bing-old-key");
|
||||
const googleKeyInput = findInput(container, "web-search-google-key");
|
||||
expect(googleKeyInput.value).toBe("google-old-key");
|
||||
const googleEngineInput = findInput(
|
||||
container,
|
||||
"web-search-google-engine-id",
|
||||
);
|
||||
expect(googleEngineInput.value).toBe("cx-old-id");
|
||||
|
||||
const input = findInput(container, "web-search-pexels-key");
|
||||
expect(input.value).toBe("old-key");
|
||||
@@ -151,7 +184,7 @@ describe("WebSearchSettings", () => {
|
||||
expect(pixabayInput.value).toBe("old-pixabay-key");
|
||||
});
|
||||
|
||||
it("修改搜索引擎和图片 Key 后应统一保存", async () => {
|
||||
it("修改搜索提供商与图片 Key 后应统一保存", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
@@ -160,6 +193,38 @@ describe("WebSearchSettings", () => {
|
||||
findSelect(container, "web-search-engine"),
|
||||
"xiaohongshu",
|
||||
);
|
||||
await setSelectValue(
|
||||
findSelect(container, "web-search-provider"),
|
||||
"multi_search_engine",
|
||||
);
|
||||
await setInputValue(
|
||||
findInput(container, "web-search-provider-priority"),
|
||||
"multi_search_engine, tavily, bing_search_api",
|
||||
);
|
||||
await setInputValue(
|
||||
findInput(container, "web-search-tavily-key"),
|
||||
"tavily-new-key",
|
||||
);
|
||||
await setInputValue(
|
||||
findInput(container, "web-search-bing-key"),
|
||||
"bing-new-key",
|
||||
);
|
||||
await setInputValue(
|
||||
findInput(container, "web-search-google-key"),
|
||||
"google-new-key",
|
||||
);
|
||||
await setInputValue(
|
||||
findInput(container, "web-search-google-engine-id"),
|
||||
"cx-new-id",
|
||||
);
|
||||
await setInputValue(
|
||||
findInput(container, "web-search-mse-custom-engine-name"),
|
||||
"hn",
|
||||
);
|
||||
await setInputValue(
|
||||
findInput(container, "web-search-mse-custom-engine-template"),
|
||||
"https://hn.algolia.com/?q={query}",
|
||||
);
|
||||
await setInputValue(
|
||||
findInput(container, "web-search-pexels-key"),
|
||||
"new-key",
|
||||
@@ -177,9 +242,23 @@ describe("WebSearchSettings", () => {
|
||||
expect(mockSaveConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSaveConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
web_search: {
|
||||
web_search: expect.objectContaining({
|
||||
engine: "xiaohongshu",
|
||||
},
|
||||
provider: "multi_search_engine",
|
||||
provider_priority: [
|
||||
"multi_search_engine",
|
||||
"tavily",
|
||||
"bing_search_api",
|
||||
],
|
||||
tavily_api_key: "tavily-new-key",
|
||||
bing_search_api_key: "bing-new-key",
|
||||
google_search_api_key: "google-new-key",
|
||||
google_search_engine_id: "cx-new-id",
|
||||
multi_search: expect.objectContaining({
|
||||
priority: ["google", "bing"],
|
||||
timeout_ms: 4000,
|
||||
}),
|
||||
}),
|
||||
image_gen: expect.objectContaining({
|
||||
image_search_pexels_api_key: "new-key",
|
||||
image_search_pixabay_api_key: "new-pixabay-key",
|
||||
@@ -202,6 +281,19 @@ describe("WebSearchSettings", () => {
|
||||
expect(mockOpen).toHaveBeenCalledWith("https://www.pexels.com/api/new/");
|
||||
});
|
||||
|
||||
it("点击 Tavily 申请按钮应打开官方页面", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "申请 Tavily Key").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith("https://app.tavily.com/");
|
||||
});
|
||||
|
||||
it("插件打开失败时应回退到 window.open", async () => {
|
||||
mockOpen.mockRejectedValueOnce(new Error("plugin failed"));
|
||||
const fallbackSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
@@ -231,6 +323,53 @@ describe("WebSearchSettings", () => {
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith("https://pixabay.com/accounts/register/");
|
||||
expect(mockOpen).toHaveBeenCalledWith(
|
||||
"https://pixabay.com/accounts/register/",
|
||||
);
|
||||
});
|
||||
|
||||
it("点击 Bing 申请按钮应打开 Azure 页面", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "申请 Bing Key").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith(
|
||||
"https://portal.azure.com/#create/Microsoft.CognitiveServicesBingSearch-v7",
|
||||
);
|
||||
});
|
||||
|
||||
it("点击 Google 申请按钮应打开 Google Cloud API 页面", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "申请 Google Key").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith(
|
||||
"https://console.cloud.google.com/apis/library/customsearch.googleapis.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("点击创建 CSE 按钮应打开可编程搜索引擎页面", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "创建 CSE").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith(
|
||||
"https://programmablesearchengine.google.com/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,16 +4,186 @@ import { open } from "@tauri-apps/plugin-shell";
|
||||
import { getConfig, saveConfig, type Config } from "@/hooks/useTauri";
|
||||
|
||||
type SearchEngine = "google" | "xiaohongshu";
|
||||
type WebSearchProvider =
|
||||
| "tavily"
|
||||
| "multi_search_engine"
|
||||
| "duckduckgo_instant"
|
||||
| "bing_search_api"
|
||||
| "google_custom_search";
|
||||
|
||||
type MultiSearchEngineOption = {
|
||||
name: string;
|
||||
url_template: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const PEXELS_APPLY_URL = "https://www.pexels.com/api/new/";
|
||||
const PEXELS_DOC_URL = "https://www.pexels.com/api/";
|
||||
const PIXABAY_APPLY_URL = "https://pixabay.com/accounts/register/";
|
||||
const PIXABAY_DOC_URL = "https://pixabay.com/api/docs/";
|
||||
const TAVILY_APPLY_URL = "https://app.tavily.com/";
|
||||
const TAVILY_DOC_URL = "https://docs.tavily.com/";
|
||||
const MSE_DOC_URL =
|
||||
"https://openclaw.ai/blog/openclaw-multi-search-engine-enhanced";
|
||||
const BING_SEARCH_APPLY_URL =
|
||||
"https://portal.azure.com/#create/Microsoft.CognitiveServicesBingSearch-v7";
|
||||
const BING_SEARCH_DOC_URL =
|
||||
"https://learn.microsoft.com/zh-cn/bing/search-apis/bing-web-search/overview";
|
||||
const GOOGLE_SEARCH_API_APPLY_URL =
|
||||
"https://console.cloud.google.com/apis/library/customsearch.googleapis.com";
|
||||
const GOOGLE_SEARCH_DOC_URL =
|
||||
"https://developers.google.com/custom-search/v1/overview";
|
||||
const GOOGLE_SEARCH_CSE_URL = "https://programmablesearchengine.google.com/";
|
||||
|
||||
const DEFAULT_MSE_ENGINES: MultiSearchEngineOption[] = [
|
||||
{
|
||||
name: "google",
|
||||
url_template: "https://www.google.com/search?q={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "bing",
|
||||
url_template: "https://www.bing.com/search?q={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "duckduckgo",
|
||||
url_template: "https://duckduckgo.com/?q={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "yahoo",
|
||||
url_template: "https://search.yahoo.com/search?p={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "baidu",
|
||||
url_template: "https://www.baidu.com/s?wd={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "yandex",
|
||||
url_template: "https://yandex.com/search/?text={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "ecosia",
|
||||
url_template: "https://www.ecosia.org/search?q={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "brave",
|
||||
url_template: "https://search.brave.com/search?q={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "startpage",
|
||||
url_template: "https://www.startpage.com/do/search?query={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "qwant",
|
||||
url_template: "https://www.qwant.com/?q={query}&t=web",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "sogou",
|
||||
url_template: "https://www.sogou.com/web?query={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "so360",
|
||||
url_template: "https://www.so.com/s?q={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "aol",
|
||||
url_template: "https://search.aol.com/aol/search?q={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "ask",
|
||||
url_template: "https://www.ask.com/web?q={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "naver",
|
||||
url_template: "https://search.naver.com/search.naver?query={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "seznam",
|
||||
url_template: "https://search.seznam.cz/?q={query}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "dogpile",
|
||||
url_template: "https://www.dogpile.com/serp?q={query}",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_MSE_ENGINE_NAMES = new Set(
|
||||
DEFAULT_MSE_ENGINES.map((item) => item.name),
|
||||
);
|
||||
const ALL_PROVIDERS: WebSearchProvider[] = [
|
||||
"tavily",
|
||||
"multi_search_engine",
|
||||
"duckduckgo_instant",
|
||||
"bing_search_api",
|
||||
"google_custom_search",
|
||||
];
|
||||
|
||||
function parseCsv(input: string): string[] {
|
||||
return input
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isWebSearchProvider(value: string): value is WebSearchProvider {
|
||||
return ALL_PROVIDERS.includes(value as WebSearchProvider);
|
||||
}
|
||||
|
||||
function parseBoundedInt(
|
||||
value: string,
|
||||
min: number,
|
||||
max: number,
|
||||
fallback: number,
|
||||
): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
if (parsed < min) return min;
|
||||
if (parsed > max) return max;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function WebSearchSettings() {
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [draftEngine, setDraftEngine] = useState<SearchEngine>("google");
|
||||
const [draftProvider, setDraftProvider] =
|
||||
useState<WebSearchProvider>("duckduckgo_instant");
|
||||
const [draftProviderPriority, setDraftProviderPriority] = useState("");
|
||||
const [draftTavilyApiKey, setDraftTavilyApiKey] = useState("");
|
||||
const [draftBingSearchApiKey, setDraftBingSearchApiKey] = useState("");
|
||||
const [draftGoogleSearchApiKey, setDraftGoogleSearchApiKey] = useState("");
|
||||
const [draftGoogleSearchEngineId, setDraftGoogleSearchEngineId] =
|
||||
useState("");
|
||||
const [draftMsePriority, setDraftMsePriority] = useState("");
|
||||
const [draftMseMaxResultsPerEngine, setDraftMseMaxResultsPerEngine] =
|
||||
useState("5");
|
||||
const [draftMseMaxTotalResults, setDraftMseMaxTotalResults] = useState("20");
|
||||
const [draftMseTimeoutMs, setDraftMseTimeoutMs] = useState("4000");
|
||||
const [draftMseCustomEngineName, setDraftMseCustomEngineName] = useState("");
|
||||
const [draftMseCustomEngineTemplate, setDraftMseCustomEngineTemplate] =
|
||||
useState("");
|
||||
const [draftPexelsApiKey, setDraftPexelsApiKey] = useState("");
|
||||
const [draftPixabayApiKey, setDraftPixabayApiKey] = useState("");
|
||||
const [showTavilyApiKey, setShowTavilyApiKey] = useState(false);
|
||||
const [showBingSearchApiKey, setShowBingSearchApiKey] = useState(false);
|
||||
const [showGoogleSearchApiKey, setShowGoogleSearchApiKey] = useState(false);
|
||||
const [showPexelsApiKey, setShowPexelsApiKey] = useState(false);
|
||||
const [showPixabayApiKey, setShowPixabayApiKey] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -30,12 +200,46 @@ export function WebSearchSettings() {
|
||||
const nextConfig = await getConfig();
|
||||
const engine = (nextConfig.web_search?.engine ||
|
||||
"google") as SearchEngine;
|
||||
const provider = (nextConfig.web_search?.provider ||
|
||||
"duckduckgo_instant") as WebSearchProvider;
|
||||
const providerPriority = (
|
||||
nextConfig.web_search?.provider_priority || []
|
||||
).join(", ");
|
||||
const tavilyApiKey = nextConfig.web_search?.tavily_api_key || "";
|
||||
const bingSearchApiKey = nextConfig.web_search?.bing_search_api_key || "";
|
||||
const googleSearchApiKey =
|
||||
nextConfig.web_search?.google_search_api_key || "";
|
||||
const googleSearchEngineId =
|
||||
nextConfig.web_search?.google_search_engine_id || "";
|
||||
const multiSearch = nextConfig.web_search?.multi_search;
|
||||
const msePriority = (multiSearch?.priority || []).join(", ");
|
||||
const mseMaxResultsPerEngine = String(
|
||||
multiSearch?.max_results_per_engine || 5,
|
||||
);
|
||||
const mseMaxTotalResults = String(multiSearch?.max_total_results || 20);
|
||||
const mseTimeoutMs = String(multiSearch?.timeout_ms || 4000);
|
||||
const customEngine = (multiSearch?.engines || []).find(
|
||||
(engineItem) => !DEFAULT_MSE_ENGINE_NAMES.has(engineItem.name),
|
||||
);
|
||||
const pexelsApiKey =
|
||||
nextConfig.image_gen?.image_search_pexels_api_key || "";
|
||||
const pixabayApiKey =
|
||||
nextConfig.image_gen?.image_search_pixabay_api_key || "";
|
||||
|
||||
setConfig(nextConfig);
|
||||
setDraftEngine(engine);
|
||||
setDraftProvider(provider);
|
||||
setDraftProviderPriority(providerPriority);
|
||||
setDraftTavilyApiKey(tavilyApiKey);
|
||||
setDraftBingSearchApiKey(bingSearchApiKey);
|
||||
setDraftGoogleSearchApiKey(googleSearchApiKey);
|
||||
setDraftGoogleSearchEngineId(googleSearchEngineId);
|
||||
setDraftMsePriority(msePriority);
|
||||
setDraftMseMaxResultsPerEngine(mseMaxResultsPerEngine);
|
||||
setDraftMseMaxTotalResults(mseMaxTotalResults);
|
||||
setDraftMseTimeoutMs(mseTimeoutMs);
|
||||
setDraftMseCustomEngineName(customEngine?.name || "");
|
||||
setDraftMseCustomEngineTemplate(customEngine?.url_template || "");
|
||||
setDraftPexelsApiKey(pexelsApiKey);
|
||||
setDraftPixabayApiKey(pixabayApiKey);
|
||||
} catch (error) {
|
||||
@@ -57,6 +261,55 @@ export function WebSearchSettings() {
|
||||
() => (config?.web_search?.engine || "google") as SearchEngine,
|
||||
[config],
|
||||
);
|
||||
const currentProvider = useMemo(
|
||||
() =>
|
||||
(config?.web_search?.provider ||
|
||||
"duckduckgo_instant") as WebSearchProvider,
|
||||
[config],
|
||||
);
|
||||
const currentProviderPriority = useMemo(
|
||||
() => (config?.web_search?.provider_priority || []).join(", "),
|
||||
[config],
|
||||
);
|
||||
const currentTavilyApiKey = useMemo(
|
||||
() => config?.web_search?.tavily_api_key || "",
|
||||
[config],
|
||||
);
|
||||
const currentBingSearchApiKey = useMemo(
|
||||
() => config?.web_search?.bing_search_api_key || "",
|
||||
[config],
|
||||
);
|
||||
const currentGoogleSearchApiKey = useMemo(
|
||||
() => config?.web_search?.google_search_api_key || "",
|
||||
[config],
|
||||
);
|
||||
const currentGoogleSearchEngineId = useMemo(
|
||||
() => config?.web_search?.google_search_engine_id || "",
|
||||
[config],
|
||||
);
|
||||
const currentMsePriority = useMemo(
|
||||
() => (config?.web_search?.multi_search?.priority || []).join(", "),
|
||||
[config],
|
||||
);
|
||||
const currentMseMaxResultsPerEngine = useMemo(
|
||||
() => String(config?.web_search?.multi_search?.max_results_per_engine || 5),
|
||||
[config],
|
||||
);
|
||||
const currentMseMaxTotalResults = useMemo(
|
||||
() => String(config?.web_search?.multi_search?.max_total_results || 20),
|
||||
[config],
|
||||
);
|
||||
const currentMseTimeoutMs = useMemo(
|
||||
() => String(config?.web_search?.multi_search?.timeout_ms || 4000),
|
||||
[config],
|
||||
);
|
||||
const currentMseCustomEngine = useMemo(
|
||||
() =>
|
||||
(config?.web_search?.multi_search?.engines || []).find(
|
||||
(engineItem) => !DEFAULT_MSE_ENGINE_NAMES.has(engineItem.name),
|
||||
) || null,
|
||||
[config],
|
||||
);
|
||||
const currentPexelsApiKey = useMemo(
|
||||
() => config?.image_gen?.image_search_pexels_api_key || "",
|
||||
[config],
|
||||
@@ -68,27 +321,90 @@ export function WebSearchSettings() {
|
||||
|
||||
const hasUnsavedChanges =
|
||||
draftEngine !== currentEngine ||
|
||||
draftProvider !== currentProvider ||
|
||||
draftProviderPriority.trim() !== currentProviderPriority ||
|
||||
draftTavilyApiKey.trim() !== currentTavilyApiKey ||
|
||||
draftBingSearchApiKey.trim() !== currentBingSearchApiKey ||
|
||||
draftGoogleSearchApiKey.trim() !== currentGoogleSearchApiKey ||
|
||||
draftGoogleSearchEngineId.trim() !== currentGoogleSearchEngineId ||
|
||||
draftMsePriority.trim() !== currentMsePriority ||
|
||||
draftMseMaxResultsPerEngine.trim() !== currentMseMaxResultsPerEngine ||
|
||||
draftMseMaxTotalResults.trim() !== currentMseMaxTotalResults ||
|
||||
draftMseTimeoutMs.trim() !== currentMseTimeoutMs ||
|
||||
draftMseCustomEngineName.trim() !== (currentMseCustomEngine?.name || "") ||
|
||||
draftMseCustomEngineTemplate.trim() !==
|
||||
(currentMseCustomEngine?.url_template || "") ||
|
||||
draftPexelsApiKey.trim() !== currentPexelsApiKey ||
|
||||
draftPixabayApiKey.trim() !== currentPixabayApiKey;
|
||||
|
||||
const tavilyKeyConfigured = draftTavilyApiKey.trim().length > 0;
|
||||
const bingSearchKeyConfigured = draftBingSearchApiKey.trim().length > 0;
|
||||
const googleSearchKeyConfigured = draftGoogleSearchApiKey.trim().length > 0;
|
||||
const googleSearchEngineConfigured =
|
||||
draftGoogleSearchEngineId.trim().length > 0;
|
||||
const mseCustomEngineReady =
|
||||
draftMseCustomEngineName.trim().length > 0 &&
|
||||
draftMseCustomEngineTemplate.trim().includes("{query}");
|
||||
const pexelsKeyConfigured = draftPexelsApiKey.trim().length > 0;
|
||||
const pixabayKeyConfigured = draftPixabayApiKey.trim().length > 0;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config || !hasUnsavedChanges) return;
|
||||
|
||||
const providerPriority = parseCsv(draftProviderPriority).filter(
|
||||
isWebSearchProvider,
|
||||
);
|
||||
const msePriority = parseCsv(draftMsePriority);
|
||||
const customName = draftMseCustomEngineName.trim();
|
||||
const customTemplate = draftMseCustomEngineTemplate.trim();
|
||||
|
||||
const mseEngines: MultiSearchEngineOption[] = [...DEFAULT_MSE_ENGINES];
|
||||
if (customName && customTemplate.includes("{query}")) {
|
||||
mseEngines.push({
|
||||
name: customName,
|
||||
url_template: customTemplate,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
const nextConfig: Config = {
|
||||
...config,
|
||||
web_search: {
|
||||
engine: draftEngine,
|
||||
provider: draftProvider,
|
||||
provider_priority: providerPriority,
|
||||
tavily_api_key: draftTavilyApiKey.trim() || null,
|
||||
bing_search_api_key: draftBingSearchApiKey.trim() || null,
|
||||
google_search_api_key: draftGoogleSearchApiKey.trim() || null,
|
||||
google_search_engine_id: draftGoogleSearchEngineId.trim() || null,
|
||||
multi_search: {
|
||||
priority: msePriority,
|
||||
engines: mseEngines,
|
||||
max_results_per_engine: parseBoundedInt(
|
||||
draftMseMaxResultsPerEngine,
|
||||
1,
|
||||
20,
|
||||
5,
|
||||
),
|
||||
max_total_results: parseBoundedInt(
|
||||
draftMseMaxTotalResults,
|
||||
1,
|
||||
100,
|
||||
20,
|
||||
),
|
||||
timeout_ms: parseBoundedInt(draftMseTimeoutMs, 500, 15000, 4000),
|
||||
},
|
||||
},
|
||||
image_gen: {
|
||||
...(config.image_gen || {}),
|
||||
image_search_pexels_api_key: draftPexelsApiKey.trim(),
|
||||
image_search_pixabay_api_key: draftPixabayApiKey.trim(),
|
||||
},
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const nextConfig: Config = {
|
||||
...config,
|
||||
web_search: {
|
||||
engine: draftEngine,
|
||||
},
|
||||
image_gen: {
|
||||
...(config.image_gen || {}),
|
||||
image_search_pexels_api_key: draftPexelsApiKey.trim(),
|
||||
image_search_pixabay_api_key: draftPixabayApiKey.trim(),
|
||||
},
|
||||
};
|
||||
await saveConfig(nextConfig);
|
||||
setConfig(nextConfig);
|
||||
setMessage({ type: "success", text: "网络搜索设置已保存" });
|
||||
@@ -105,6 +421,18 @@ export function WebSearchSettings() {
|
||||
|
||||
const handleReset = () => {
|
||||
setDraftEngine(currentEngine);
|
||||
setDraftProvider(currentProvider);
|
||||
setDraftProviderPriority(currentProviderPriority);
|
||||
setDraftTavilyApiKey(currentTavilyApiKey);
|
||||
setDraftBingSearchApiKey(currentBingSearchApiKey);
|
||||
setDraftGoogleSearchApiKey(currentGoogleSearchApiKey);
|
||||
setDraftGoogleSearchEngineId(currentGoogleSearchEngineId);
|
||||
setDraftMsePriority(currentMsePriority);
|
||||
setDraftMseMaxResultsPerEngine(currentMseMaxResultsPerEngine);
|
||||
setDraftMseMaxTotalResults(currentMseMaxTotalResults);
|
||||
setDraftMseTimeoutMs(currentMseTimeoutMs);
|
||||
setDraftMseCustomEngineName(currentMseCustomEngine?.name || "");
|
||||
setDraftMseCustomEngineTemplate(currentMseCustomEngine?.url_template || "");
|
||||
setDraftPexelsApiKey(currentPexelsApiKey);
|
||||
setDraftPixabayApiKey(currentPixabayApiKey);
|
||||
setMessage(null);
|
||||
@@ -145,9 +473,10 @@ export function WebSearchSettings() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">搜索引擎</h3>
|
||||
<h3 className="text-sm font-medium">联网搜索配置</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择用于网络搜索的默认搜索引擎。
|
||||
使用策略化回退链路统一管理 Tavily / MSE / Bing / Google /
|
||||
DuckDuckGo。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,6 +498,314 @@ export function WebSearchSettings() {
|
||||
Google 适用于通用搜索,小红书适用于中文生活方式和购物内容。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="web-search-provider" className="text-sm font-medium">
|
||||
首选搜索提供商
|
||||
</label>
|
||||
<select
|
||||
id="web-search-provider"
|
||||
value={draftProvider}
|
||||
onChange={(e) =>
|
||||
setDraftProvider(e.target.value as WebSearchProvider)
|
||||
}
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
>
|
||||
<option value="tavily">Tavily Search API</option>
|
||||
<option value="multi_search_engine">
|
||||
Multi Search Engine v2.0.1
|
||||
</option>
|
||||
<option value="duckduckgo_instant">
|
||||
DuckDuckGo Instant Answer (免费)
|
||||
</option>
|
||||
<option value="bing_search_api">Bing Search API</option>
|
||||
<option value="google_custom_search">
|
||||
Google Custom Search API
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="web-search-provider-priority"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
提供商回退优先级(逗号分隔)
|
||||
</label>
|
||||
<input
|
||||
id="web-search-provider-priority"
|
||||
value={draftProviderPriority}
|
||||
onChange={(e) => setDraftProviderPriority(e.target.value)}
|
||||
placeholder="tavily, multi_search_engine, bing_search_api, google_custom_search, duckduckgo_instant"
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
未填写时会自动使用默认回退链;未知 provider 会被忽略。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="web-search-tavily-key"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Tavily API Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(TAVILY_APPLY_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
申请 Tavily Key
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(TAVILY_DOC_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
查看文档
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="web-search-tavily-key"
|
||||
type={showTavilyApiKey ? "text" : "password"}
|
||||
value={draftTavilyApiKey}
|
||||
onChange={(e) => setDraftTavilyApiKey(e.target.value)}
|
||||
placeholder="输入 TAVILY_API_KEY"
|
||||
className="w-full h-10 rounded-md border bg-background px-3 pr-20 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTavilyApiKey((prev) => !prev)}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md border px-2.5 py-1 text-xs"
|
||||
>
|
||||
{showTavilyApiKey ? "隐藏" : "显示"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
未填写时会回退环境变量 <code>TAVILY_API_KEY</code>。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="web-search-bing-key" className="text-sm font-medium">
|
||||
Bing Search API Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(BING_SEARCH_APPLY_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
申请 Bing Key
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(BING_SEARCH_DOC_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
查看文档
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="web-search-bing-key"
|
||||
type={showBingSearchApiKey ? "text" : "password"}
|
||||
value={draftBingSearchApiKey}
|
||||
onChange={(e) => setDraftBingSearchApiKey(e.target.value)}
|
||||
placeholder="输入 BING_SEARCH_API_KEY"
|
||||
className="w-full h-10 rounded-md border bg-background px-3 pr-20 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBingSearchApiKey((prev) => !prev)}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md border px-2.5 py-1 text-xs"
|
||||
>
|
||||
{showBingSearchApiKey ? "隐藏" : "显示"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
未填写时会回退环境变量 <code>BING_SEARCH_API_KEY</code>。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="web-search-google-key"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Google Search API Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(GOOGLE_SEARCH_API_APPLY_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
申请 Google Key
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(GOOGLE_SEARCH_DOC_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
查看文档
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="web-search-google-key"
|
||||
type={showGoogleSearchApiKey ? "text" : "password"}
|
||||
value={draftGoogleSearchApiKey}
|
||||
onChange={(e) => setDraftGoogleSearchApiKey(e.target.value)}
|
||||
placeholder="输入 GOOGLE_SEARCH_API_KEY"
|
||||
className="w-full h-10 rounded-md border bg-background px-3 pr-20 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGoogleSearchApiKey((prev) => !prev)}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md border px-2.5 py-1 text-xs"
|
||||
>
|
||||
{showGoogleSearchApiKey ? "隐藏" : "显示"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
未填写时会回退环境变量 <code>GOOGLE_SEARCH_API_KEY</code>。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="web-search-google-engine-id"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Google Search Engine ID (CSE CX)
|
||||
</label>
|
||||
<input
|
||||
id="web-search-google-engine-id"
|
||||
value={draftGoogleSearchEngineId}
|
||||
onChange={(e) => setDraftGoogleSearchEngineId(e.target.value)}
|
||||
placeholder="输入 GOOGLE_SEARCH_ENGINE_ID"
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(GOOGLE_SEARCH_CSE_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
创建 CSE
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
未填写时会回退环境变量 <code>GOOGLE_SEARCH_ENGINE_ID</code>。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="web-search-mse-priority"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Multi Search Engine 引擎优先级(逗号分隔)
|
||||
</label>
|
||||
<input
|
||||
id="web-search-mse-priority"
|
||||
value={draftMsePriority}
|
||||
onChange={(e) => setDraftMsePriority(e.target.value)}
|
||||
placeholder="google, bing, duckduckgo, brave"
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(MSE_DOC_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
查看 MSE 设计参考
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="web-search-mse-max-per-engine"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
每引擎结果上限
|
||||
</label>
|
||||
<input
|
||||
id="web-search-mse-max-per-engine"
|
||||
value={draftMseMaxResultsPerEngine}
|
||||
onChange={(e) => setDraftMseMaxResultsPerEngine(e.target.value)}
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="web-search-mse-max-total"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
聚合结果总上限
|
||||
</label>
|
||||
<input
|
||||
id="web-search-mse-max-total"
|
||||
value={draftMseMaxTotalResults}
|
||||
onChange={(e) => setDraftMseMaxTotalResults(e.target.value)}
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="web-search-mse-timeout"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
单引擎超时 (ms)
|
||||
</label>
|
||||
<input
|
||||
id="web-search-mse-timeout"
|
||||
value={draftMseTimeoutMs}
|
||||
onChange={(e) => setDraftMseTimeoutMs(e.target.value)}
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="web-search-mse-custom-engine-name"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
自定义引擎名称(可选)
|
||||
</label>
|
||||
<input
|
||||
id="web-search-mse-custom-engine-name"
|
||||
value={draftMseCustomEngineName}
|
||||
onChange={(e) => setDraftMseCustomEngineName(e.target.value)}
|
||||
placeholder="例如: hn"
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<label
|
||||
htmlFor="web-search-mse-custom-engine-template"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
自定义引擎 URL 模板(必须包含 {"{query}"})
|
||||
</label>
|
||||
<input
|
||||
id="web-search-mse-custom-engine-template"
|
||||
value={draftMseCustomEngineTemplate}
|
||||
onChange={(e) => setDraftMseCustomEngineTemplate(e.target.value)}
|
||||
placeholder="https://example.com/search?q={query}"
|
||||
className="w-full h-10 rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-5 space-y-4">
|
||||
@@ -304,6 +941,63 @@ export function WebSearchSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-sm font-medium">观测面板</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs ${
|
||||
tavilyKeyConfigured
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Tavily {tavilyKeyConfigured ? "已填写" : "未填写"}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs ${
|
||||
bingSearchKeyConfigured
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Bing {bingSearchKeyConfigured ? "已填写" : "未填写"}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs ${
|
||||
googleSearchKeyConfigured
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Google {googleSearchKeyConfigured ? "已填写" : "未填写"}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs ${
|
||||
googleSearchEngineConfigured
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
CSE {googleSearchEngineConfigured ? "已填写" : "未填写"}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs ${
|
||||
mseCustomEngineReady
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
MSE 自定义模板 {mseCustomEngineReady ? "可用" : "未配置"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当前 provider 回退链:
|
||||
{parseCsv(draftProviderPriority).length > 0
|
||||
? parseCsv(draftProviderPriority).join(" -> ")
|
||||
: "自动默认链"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border rounded-lg px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{hasUnsavedChanges ? "未保存的更改" : "所有更改已保存"}
|
||||
|
||||
+47
-3
@@ -224,6 +224,20 @@ export interface ApiKeyEntry {
|
||||
proxy_url: string | null;
|
||||
}
|
||||
|
||||
export interface MultiSearchEngineEntryConfig {
|
||||
name: string;
|
||||
url_template: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MultiSearchConfig {
|
||||
priority?: string[];
|
||||
engines?: MultiSearchEngineEntryConfig[];
|
||||
max_results_per_engine?: number;
|
||||
max_total_results?: number;
|
||||
timeout_ms?: number;
|
||||
}
|
||||
|
||||
// ============ 实验室功能配置 ============
|
||||
|
||||
/**
|
||||
@@ -244,6 +258,18 @@ export interface ExperimentalFeatures {
|
||||
screenshot_chat: SmartInputConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Calling 2.0 配置
|
||||
*/
|
||||
export interface ToolCallingConfig {
|
||||
/** 总开关 */
|
||||
enabled: boolean;
|
||||
/** 动态过滤(网页噪音过滤) */
|
||||
dynamic_filtering: boolean;
|
||||
/** 原生 input examples 透传 */
|
||||
native_input_examples: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容创作配置
|
||||
*/
|
||||
@@ -535,6 +561,8 @@ export interface Config {
|
||||
language: string;
|
||||
/** 实验室功能配置 */
|
||||
experimental?: ExperimentalFeatures;
|
||||
/** Tool Calling 2.0 配置 */
|
||||
tool_calling?: ToolCallingConfig;
|
||||
/** 内容创作配置 */
|
||||
content_creator?: ContentCreatorConfig;
|
||||
/** 导航栏配置 */
|
||||
@@ -544,6 +572,24 @@ export interface Config {
|
||||
/** 网络搜索配置 */
|
||||
web_search?: {
|
||||
engine: "google" | "xiaohongshu";
|
||||
provider?:
|
||||
| "tavily"
|
||||
| "multi_search_engine"
|
||||
| "duckduckgo_instant"
|
||||
| "bing_search_api"
|
||||
| "google_custom_search";
|
||||
provider_priority?: Array<
|
||||
| "tavily"
|
||||
| "multi_search_engine"
|
||||
| "duckduckgo_instant"
|
||||
| "bing_search_api"
|
||||
| "google_custom_search"
|
||||
>;
|
||||
tavily_api_key?: string | null;
|
||||
bing_search_api_key?: string | null;
|
||||
google_search_api_key?: string | null;
|
||||
google_search_engine_id?: string | null;
|
||||
multi_search?: MultiSearchConfig;
|
||||
};
|
||||
/** 记忆管理配置 */
|
||||
memory?: MemoryConfig;
|
||||
@@ -616,9 +662,7 @@ export async function workspaceEnsureReady(
|
||||
return safeInvoke("workspace_ensure_ready", { id });
|
||||
}
|
||||
|
||||
export async function workspaceEnsureDefaultReady(): Promise<
|
||||
WorkspaceEnsureResult | null
|
||||
> {
|
||||
export async function workspaceEnsureDefaultReady(): Promise<WorkspaceEnsureResult | null> {
|
||||
return safeInvoke("workspace_ensure_default_ready");
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@ export interface McpToolDefinition {
|
||||
description: string;
|
||||
input_schema: Record<string, unknown>;
|
||||
server_name: string;
|
||||
deferred_loading?: boolean;
|
||||
always_visible?: boolean;
|
||||
allowed_callers?: string[];
|
||||
input_examples?: unknown[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** MCP 内容类型 */
|
||||
@@ -177,6 +182,21 @@ export const mcpApi = {
|
||||
/** 获取所有可用工具 */
|
||||
listTools: (): Promise<McpToolDefinition[]> => safeInvoke("mcp_list_tools"),
|
||||
|
||||
/** 按调用上下文获取可见工具(支持 deferred_loading) */
|
||||
listToolsForContext: (
|
||||
caller?: string,
|
||||
includeDeferred = false,
|
||||
): Promise<McpToolDefinition[]> =>
|
||||
safeInvoke("mcp_list_tools_for_context", { caller, includeDeferred }),
|
||||
|
||||
/** 工具搜索(Tool Search) */
|
||||
searchTools: (
|
||||
query: string,
|
||||
caller?: string,
|
||||
limit = 10,
|
||||
): Promise<McpToolDefinition[]> =>
|
||||
safeInvoke("mcp_search_tools", { query, caller, limit }),
|
||||
|
||||
/** 调用工具 */
|
||||
callTool: (
|
||||
toolName: string,
|
||||
@@ -184,6 +204,14 @@ export const mcpApi = {
|
||||
): Promise<McpToolResult> =>
|
||||
safeInvoke("mcp_call_tool", { toolName, arguments: args }),
|
||||
|
||||
/** 带 caller 校验调用工具 */
|
||||
callToolWithCaller: (
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
caller?: string,
|
||||
): Promise<McpToolResult> =>
|
||||
safeInvoke("mcp_call_tool_with_caller", { toolName, arguments: args, caller }),
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 提示词管理 API
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -3,12 +3,12 @@ import { resolveAppVersion } from "./appVersion";
|
||||
|
||||
describe("appVersion", () => {
|
||||
it("优先返回首个有效版本号", () => {
|
||||
expect(resolveAppVersion("0.77.1", "0.77.0")).toBe("0.77.1");
|
||||
expect(resolveAppVersion("unknown", "0.77.0")).toBe("0.77.0");
|
||||
expect(resolveAppVersion("", " ", "0.77.0")).toBe("0.77.0");
|
||||
expect(resolveAppVersion("0.78.1", "0.78.0")).toBe("0.78.1");
|
||||
expect(resolveAppVersion("unknown", "0.78.0")).toBe("0.78.0");
|
||||
expect(resolveAppVersion("", " ", "0.78.0")).toBe("0.78.0");
|
||||
});
|
||||
|
||||
it("无候选值时回退 package.json 版本", () => {
|
||||
expect(resolveAppVersion(undefined, null, "unknown")).toBe("0.77.0");
|
||||
expect(resolveAppVersion(undefined, null, "unknown")).toBe("0.78.0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,8 +87,32 @@ const defaultMocks: Record<string, any> = {
|
||||
shortcut: "",
|
||||
},
|
||||
},
|
||||
tool_calling: {
|
||||
enabled: true,
|
||||
dynamic_filtering: true,
|
||||
native_input_examples: false,
|
||||
},
|
||||
web_search: {
|
||||
engine: "google",
|
||||
provider: "duckduckgo_instant",
|
||||
provider_priority: [
|
||||
"duckduckgo_instant",
|
||||
"tavily",
|
||||
"multi_search_engine",
|
||||
"bing_search_api",
|
||||
"google_custom_search",
|
||||
],
|
||||
tavily_api_key: "",
|
||||
bing_search_api_key: "",
|
||||
google_search_api_key: "",
|
||||
google_search_engine_id: "",
|
||||
multi_search: {
|
||||
priority: [],
|
||||
engines: [],
|
||||
max_results_per_engine: 5,
|
||||
max_total_results: 20,
|
||||
timeout_ms: 4000,
|
||||
},
|
||||
},
|
||||
image_gen: {
|
||||
default_service: "dall_e",
|
||||
@@ -690,6 +714,18 @@ const defaultMocks: Record<string, any> = {
|
||||
import_mcp_from_app: () => ({ success: true }),
|
||||
sync_all_mcp_to_live: () => ({ success: true }),
|
||||
sync_from_external_config: () => ({ success: true }),
|
||||
mcp_list_servers_with_status: () => [],
|
||||
mcp_start_server: () => ({ success: true }),
|
||||
mcp_stop_server: () => ({ success: true }),
|
||||
mcp_list_tools: () => [],
|
||||
mcp_list_tools_for_context: () => [],
|
||||
mcp_search_tools: () => [],
|
||||
mcp_call_tool: () => ({ content: [], is_error: false }),
|
||||
mcp_call_tool_with_caller: () => ({ content: [], is_error: false }),
|
||||
mcp_list_prompts: () => [],
|
||||
mcp_get_prompt: () => ({ description: "", messages: [] }),
|
||||
mcp_list_resources: () => [],
|
||||
mcp_read_resource: () => ({}),
|
||||
|
||||
// Switch Provider 相关
|
||||
get_switch_providers: () => [],
|
||||
@@ -1035,11 +1071,13 @@ const defaultMocks: Record<string, any> = {
|
||||
warning: null,
|
||||
}),
|
||||
workspace_get_projects_root: () => "/mock/workspace/projects",
|
||||
workspace_resolve_project_path: (args: any) => `/mock/workspace/projects/${args?.name ?? "untitled"}`,
|
||||
workspace_resolve_project_path: (args: any) =>
|
||||
`/mock/workspace/projects/${args?.name ?? "untitled"}`,
|
||||
workspace_create: (args: any) => ({
|
||||
id: `mock-project-${Date.now()}`,
|
||||
name: args?.request?.name ?? "Mock Project",
|
||||
rootPath: args?.request?.rootPath ?? "/mock/workspace/projects/mock-project",
|
||||
rootPath:
|
||||
args?.request?.rootPath ?? "/mock/workspace/projects/mock-project",
|
||||
workspaceType: args?.request?.workspaceType ?? "general",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
|
||||
@@ -20,6 +20,8 @@ const tauriMockDir = path.resolve(__dirname, "./src/lib/tauri-mock");
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 检查是否在 Tauri 环境中运行(通过环境变量判断)
|
||||
const isTauri = process.env.TAURI_ENV_PLATFORM !== undefined;
|
||||
// 避免 Tauri/非 Tauri 共享同一份 optimize deps 缓存导致 chunk 丢失
|
||||
const cacheDir = isTauri ? "node_modules/.vite-tauri" : "node_modules/.vite-web";
|
||||
|
||||
// 只在非 Tauri 环境(纯浏览器开发)下使用 mock
|
||||
const tauriAliases = isTauri ? {} : {
|
||||
@@ -35,6 +37,7 @@ export default defineConfig(({ mode }) => {
|
||||
};
|
||||
|
||||
return {
|
||||
cacheDir,
|
||||
define: {
|
||||
"import.meta.env.VITE_APP_VERSION": JSON.stringify(appVersion),
|
||||
},
|
||||
@@ -56,6 +59,8 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
// 强制每次启动时校验并重建依赖预构建,避免命中损坏缓存
|
||||
force: true,
|
||||
// 只在非 Tauri 环境下排除 Tauri 包的预构建
|
||||
exclude: isTauri ? [] : [
|
||||
"@tauri-apps/api",
|
||||
|
||||
Reference in New Issue
Block a user