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:
coso
2026-03-03 23:32:52 +08:00
co-authored by Claude Opus 4.6
parent d2fadfbbf0
commit c3b77ef849
55 changed files with 5003 additions and 348 deletions
+67 -13
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.77.0",
"version": "0.78.0",
"type": "module",
"repository": {
"type": "git",
+28 -28
View File
@@ -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]]
+4 -4
View File
@@ -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
+73 -1
View File
@@ -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());
+9
View File
@@ -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 配置
+7 -6
View File
@@ -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(),
};
+197
View File
@@ -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]
+1
View File
@@ -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;
+312
View File
@@ -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());
}
}
+401 -76
View File
@@ -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);
+94 -1
View File
@@ -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")
);
}
}
+15
View File
@@ -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,
&parameters,
);
}
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 {
+3
View File
@@ -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(
+2
View File
@@ -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,
+3
View File
@@ -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,
+2
View File
@@ -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);
+633 -10
View File
@@ -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 {
+59
View File
@@ -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)
}
// ============================================================================
// 提示词管理命令
// ============================================================================
+88 -6
View File
@@ -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(())
}
+3
View File
@@ -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(),
+1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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>
);
+1 -14
View File
@@ -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");
+4 -1
View File
@@ -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}
/>
)}
+2 -2
View File
@@ -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
View File
@@ -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");
}
+28
View File
@@ -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
// --------------------------------------------------------------------------
+4 -4
View File
@@ -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");
});
});
+40 -2
View File
@@ -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(),
+5
View File
@@ -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",