mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.84.0 with full pending changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,8 @@ pub struct ProxyCastScheduler {
|
||||
inner: proxycast_agent::subagent_scheduler::ProxyCastScheduler,
|
||||
/// Tauri AppHandle
|
||||
app_handle: Option<AppHandle>,
|
||||
/// 调度事件归属的会话 ID
|
||||
event_session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ProxyCastScheduler {
|
||||
@@ -31,6 +33,7 @@ impl ProxyCastScheduler {
|
||||
Self {
|
||||
inner: proxycast_agent::subagent_scheduler::ProxyCastScheduler::new(db),
|
||||
app_handle: None,
|
||||
event_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +43,13 @@ impl ProxyCastScheduler {
|
||||
self
|
||||
}
|
||||
|
||||
/// 绑定调度事件的会话 ID
|
||||
pub fn with_event_session_id(mut self, session_id: impl Into<String>) -> Self {
|
||||
let normalized = session_id.into();
|
||||
self.event_session_id = (!normalized.trim().is_empty()).then_some(normalized);
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置默认角色
|
||||
pub fn with_default_role(mut self, role: SubAgentRole) -> Self {
|
||||
self.inner = self.inner.with_default_role(role);
|
||||
@@ -48,9 +58,11 @@ impl ProxyCastScheduler {
|
||||
|
||||
/// 初始化调度器
|
||||
pub async fn init(&self, config: Option<SchedulerConfig>) {
|
||||
let event_session_id = self.event_session_id.clone();
|
||||
let event_emitter = self.app_handle.clone().map(|handle| {
|
||||
Arc::new(move |event: &serde_json::Value| {
|
||||
if let Err(err) = handle.emit("subagent-scheduler-event", event) {
|
||||
let payload = enrich_scheduler_event_payload(event, event_session_id.as_deref());
|
||||
if let Err(err) = handle.emit("subagent-scheduler-event", payload) {
|
||||
tracing::warn!("发送 Tauri 事件失败: {}", err);
|
||||
}
|
||||
}) as SchedulerEventEmitter
|
||||
@@ -87,3 +99,58 @@ impl ProxyCastScheduler {
|
||||
self.inner.cancel().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_scheduler_event_payload(
|
||||
event: &serde_json::Value,
|
||||
session_id: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let Some(session_id) = session_id.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return event.clone();
|
||||
};
|
||||
|
||||
match event {
|
||||
serde_json::Value::Object(map) => {
|
||||
let mut next = map.clone();
|
||||
next.insert(
|
||||
"sessionId".to_string(),
|
||||
serde_json::Value::String(session_id.to_string()),
|
||||
);
|
||||
serde_json::Value::Object(next)
|
||||
}
|
||||
other => serde_json::json!({
|
||||
"type": "unknown",
|
||||
"payload": other,
|
||||
"sessionId": session_id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::enrich_scheduler_event_payload;
|
||||
|
||||
#[test]
|
||||
fn should_append_session_id_for_object_event() {
|
||||
let payload = serde_json::json!({
|
||||
"type": "started",
|
||||
"totalTasks": 1,
|
||||
});
|
||||
|
||||
let enriched = enrich_scheduler_event_payload(&payload, Some("session-a"));
|
||||
|
||||
assert_eq!(enriched["type"], serde_json::json!("started"));
|
||||
assert_eq!(enriched["sessionId"], serde_json::json!("session-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_keep_original_event_when_session_id_missing() {
|
||||
let payload = serde_json::json!({
|
||||
"type": "completed",
|
||||
"success": true,
|
||||
});
|
||||
|
||||
let enriched = enrich_scheduler_event_payload(&payload, None);
|
||||
|
||||
assert_eq!(enriched, payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ use crate::config::{
|
||||
observer::{ConfigChangeEvent, RoutingChangeEvent},
|
||||
ConfigChangeSource, GlobalConfigManagerState,
|
||||
};
|
||||
use crate::services::environment_service::{
|
||||
apply_configured_environment, build_environment_preview,
|
||||
};
|
||||
|
||||
/// 获取配置
|
||||
#[tauri::command]
|
||||
@@ -55,6 +58,7 @@ pub async fn save_config(
|
||||
let save_result = config_manager.0.save_config(&config).await;
|
||||
match save_result {
|
||||
Ok(()) => {
|
||||
apply_configured_environment(&config).await;
|
||||
tracing::info!("[CONFIG] 配置保存成功: host={}", config.server.host);
|
||||
Ok(())
|
||||
}
|
||||
@@ -65,6 +69,18 @@ pub async fn save_config(
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取统一环境变量预览
|
||||
#[tauri::command]
|
||||
pub async fn get_environment_preview(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<crate::services::environment_service::EnvironmentPreview, String> {
|
||||
let config = {
|
||||
let s = state.read().await;
|
||||
s.config.clone()
|
||||
};
|
||||
Ok(build_environment_preview(&config).await)
|
||||
}
|
||||
|
||||
/// 获取默认 Provider
|
||||
#[tauri::command]
|
||||
pub async fn get_default_provider(state: tauri::State<'_, AppState>) -> Result<String, String> {
|
||||
@@ -202,6 +218,8 @@ pub async fn set_endpoint_provider(
|
||||
/// 会更新 ~/.claude/settings.json 和 shell 配置文件中的环境变量
|
||||
#[tauri::command]
|
||||
pub async fn update_provider_env_vars(
|
||||
state: tauri::State<'_, AppState>,
|
||||
config_manager: tauri::State<'_, GlobalConfigManagerState>,
|
||||
logs: tauri::State<'_, LogState>,
|
||||
provider_type: String,
|
||||
api_host: String,
|
||||
@@ -267,10 +285,18 @@ pub async fn update_provider_env_vars(
|
||||
// 不中断流程
|
||||
}
|
||||
|
||||
let next_config = {
|
||||
let mut s = state.write().await;
|
||||
upsert_environment_overrides(&mut s.config, &env_vars);
|
||||
s.config.clone()
|
||||
};
|
||||
config_manager.0.save_config(&next_config).await?;
|
||||
apply_configured_environment(&next_config).await;
|
||||
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"已更新 {} 环境变量: {}",
|
||||
"已更新 {} 环境变量,并同步到统一环境配置: {}",
|
||||
provider_type,
|
||||
env_vars
|
||||
.iter()
|
||||
@@ -289,6 +315,37 @@ pub async fn update_provider_env_vars(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_environment_overrides(config: &mut config::Config, env_vars: &[(String, String)]) {
|
||||
for (key, value) in env_vars {
|
||||
let trimmed_key = key.trim();
|
||||
if trimmed_key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = config
|
||||
.environment
|
||||
.variables
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|entry| entry.key.trim().eq_ignore_ascii_case(trimmed_key))
|
||||
{
|
||||
existing.key = trimmed_key.to_string();
|
||||
existing.value = value.clone();
|
||||
existing.enabled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
config
|
||||
.environment
|
||||
.variables
|
||||
.push(proxycast_core::config::EnvironmentVariableOverride {
|
||||
key: trimmed_key.to_string(),
|
||||
value: value.clone(),
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn build_provider_env_vars(
|
||||
provider_type: &str,
|
||||
api_host: &str,
|
||||
@@ -391,7 +448,8 @@ fn build_provider_env_vars(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_provider_env_vars;
|
||||
use super::{build_provider_env_vars, upsert_environment_overrides};
|
||||
use proxycast_core::config::Config;
|
||||
|
||||
#[test]
|
||||
fn test_build_provider_env_vars_explicit_anthropic_compatible() {
|
||||
@@ -443,4 +501,26 @@ mod tests {
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_environment_overrides_updates_existing_key() {
|
||||
let mut config = Config::default();
|
||||
config
|
||||
.environment
|
||||
.variables
|
||||
.push(proxycast_core::config::EnvironmentVariableOverride {
|
||||
key: "OPENAI_BASE_URL".to_string(),
|
||||
value: "http://old".to_string(),
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
upsert_environment_overrides(
|
||||
&mut config,
|
||||
&[("OPENAI_BASE_URL".to_string(), "http://new".to_string())],
|
||||
);
|
||||
|
||||
assert_eq!(config.environment.variables.len(), 1);
|
||||
assert_eq!(config.environment.variables[0].value, "http://new");
|
||||
assert!(config.environment.variables[0].enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ pub fn run() {
|
||||
}
|
||||
};
|
||||
|
||||
tauri::async_runtime::block_on(
|
||||
crate::services::environment_service::apply_configured_environment(&config),
|
||||
);
|
||||
|
||||
// 初始化崩溃上报(保持 guard 生命周期直到应用退出)
|
||||
let _crash_reporting_guard = crate::crash_reporting::init_from_config(&config);
|
||||
|
||||
@@ -903,6 +907,7 @@ pub fn run() {
|
||||
// Config commands (from app::commands)
|
||||
app_commands::get_config,
|
||||
app_commands::save_config,
|
||||
app_commands::get_environment_preview,
|
||||
app_commands::get_default_provider,
|
||||
app_commands::set_default_provider,
|
||||
app_commands::get_endpoint_providers,
|
||||
@@ -1054,6 +1059,7 @@ pub fn run() {
|
||||
// Skill commands
|
||||
commands::skill_cmd::get_skills,
|
||||
commands::skill_cmd::get_skills_for_app,
|
||||
commands::skill_cmd::get_local_skills_for_app,
|
||||
commands::skill_cmd::install_skill,
|
||||
commands::skill_cmd::install_skill_for_app,
|
||||
commands::skill_cmd::uninstall_skill,
|
||||
@@ -1061,6 +1067,7 @@ pub fn run() {
|
||||
commands::skill_cmd::get_skill_repos,
|
||||
commands::skill_cmd::add_skill_repo,
|
||||
commands::skill_cmd::remove_skill_repo,
|
||||
commands::skill_cmd::refresh_skill_cache,
|
||||
commands::skill_cmd::get_installed_proxycast_skills,
|
||||
commands::skill_cmd::get_local_skill_content,
|
||||
// Skill Execution commands
|
||||
@@ -1459,7 +1466,7 @@ pub fn run() {
|
||||
commands::document_import_cmd::import_document,
|
||||
commands::document_import_cmd::import_document_to_session,
|
||||
commands::document_import_cmd::save_exported_document,
|
||||
// General Chat commands
|
||||
// General Chat commands(兼容旧链路,禁止新增依赖)
|
||||
commands::general_chat_cmd::general_chat_create_session,
|
||||
commands::general_chat_cmd::general_chat_list_sessions,
|
||||
commands::general_chat_cmd::general_chat_get_session,
|
||||
@@ -1470,7 +1477,7 @@ pub fn run() {
|
||||
commands::general_chat_cmd::general_chat_send_message,
|
||||
commands::general_chat_cmd::general_chat_stop_generation,
|
||||
commands::general_chat_cmd::general_chat_generate_title,
|
||||
// Unified Chat commands (统一对话 API)
|
||||
// Unified Chat commands(统一对话 API,后续治理收口入口)
|
||||
commands::unified_chat_cmd::chat_create_session,
|
||||
commands::unified_chat_cmd::chat_list_sessions,
|
||||
commands::unified_chat_cmd::chat_get_session,
|
||||
|
||||
@@ -8,7 +8,9 @@ use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered;
|
||||
use crate::config::GlobalConfigManagerState;
|
||||
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::memory_profile_prompt_service::{
|
||||
merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources,
|
||||
};
|
||||
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;
|
||||
@@ -231,7 +233,12 @@ pub async fn agent_create_session(
|
||||
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 prompt_with_memory = merge_system_prompt_with_memory_sources(
|
||||
merge_system_prompt_with_memory_profile(base_system_prompt, &config),
|
||||
&config,
|
||||
std::path::Path::new(&workspace_root),
|
||||
None,
|
||||
);
|
||||
let final_system_prompt = merge_system_prompt_with_web_search(prompt_with_memory, &config);
|
||||
|
||||
// 保存会话到数据库
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -150,6 +150,28 @@ fn get_skill_key(app_type: &AppType, directory: &str) -> String {
|
||||
format!("{}:{}", app_type.to_string().to_lowercase(), directory)
|
||||
}
|
||||
|
||||
/// 解析指定应用的技能列表(供 dispatcher 等非 Tauri command 场景调用)
|
||||
pub async fn resolve_skills_for_app(
|
||||
db: &DbConnection,
|
||||
skill_service: &Arc<SkillService>,
|
||||
app_type: &AppType,
|
||||
_refresh_remote: bool,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
let (repos, installed_states) = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let repos = SkillDao::get_skill_repos(&conn).map_err(|e| e.to_string())?;
|
||||
let installed_states = SkillDao::get_skills(&conn).map_err(|e| e.to_string())?;
|
||||
(repos, installed_states)
|
||||
};
|
||||
|
||||
let skills = skill_service
|
||||
.list_skills(app_type, &repos, &installed_states)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_skills(
|
||||
db: State<'_, DbConnection>,
|
||||
@@ -203,6 +225,25 @@ pub async fn get_skills_for_app(
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_local_skills_for_app(
|
||||
db: State<'_, DbConnection>,
|
||||
skill_service: State<'_, SkillServiceState>,
|
||||
app: String,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
let app_type: AppType = app.parse().map_err(|e: String| e)?;
|
||||
|
||||
let installed_states = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
SkillDao::get_skills(&conn).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
skill_service
|
||||
.0
|
||||
.list_local_skills(&app_type, &installed_states)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_skill(
|
||||
db: State<'_, DbConnection>,
|
||||
@@ -337,6 +378,12 @@ pub fn remove_skill_repo(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn refresh_skill_cache(skill_service: State<'_, SkillServiceState>) -> Result<bool, String> {
|
||||
skill_service.0.refresh_cache();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -506,6 +506,7 @@ fn emit_social_write_file_events(
|
||||
output: format!("写入社媒文稿: {file_path}"),
|
||||
error: None,
|
||||
images: None,
|
||||
metadata: None,
|
||||
},
|
||||
};
|
||||
if let Err(err) = app_handle.emit(&event_name, &tool_end) {
|
||||
|
||||
@@ -40,8 +40,12 @@ pub async fn init_subagent_scheduler(
|
||||
db: State<'_, DbConnection>,
|
||||
state: State<'_, SubAgentSchedulerState>,
|
||||
config: Option<SchedulerConfig>,
|
||||
session_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let scheduler = ProxyCastScheduler::new(db.inner().clone()).with_app_handle(app);
|
||||
let mut scheduler = ProxyCastScheduler::new(db.inner().clone()).with_app_handle(app);
|
||||
if let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) {
|
||||
scheduler = scheduler.with_event_session_id(session_id);
|
||||
}
|
||||
|
||||
scheduler.init(config).await;
|
||||
|
||||
@@ -60,17 +64,14 @@ pub async fn execute_subagent_tasks(
|
||||
tasks: Vec<SubAgentTask>,
|
||||
config: Option<SchedulerConfig>,
|
||||
role: Option<SubAgentRole>,
|
||||
session_id: Option<String>,
|
||||
) -> Result<SchedulerExecutionResult, String> {
|
||||
// 确保调度器已初始化
|
||||
let scheduler_guard = state.scheduler.read().await;
|
||||
|
||||
if scheduler_guard.is_none() {
|
||||
drop(scheduler_guard);
|
||||
// 自动初始化
|
||||
let scheduler = ProxyCastScheduler::new(db.inner().clone()).with_app_handle(app);
|
||||
scheduler.init(config.clone()).await;
|
||||
*state.scheduler.write().await = Some(scheduler);
|
||||
let mut scheduler = ProxyCastScheduler::new(db.inner().clone()).with_app_handle(app);
|
||||
if let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) {
|
||||
scheduler = scheduler.with_event_session_id(session_id);
|
||||
}
|
||||
scheduler.init(config.clone()).await;
|
||||
*state.scheduler.write().await = Some(scheduler);
|
||||
|
||||
let scheduler_guard = state.scheduler.read().await;
|
||||
let scheduler = scheduler_guard
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
use crate::agent::{AsterAgentState, AsterAgentWrapper};
|
||||
use crate::config::GlobalConfigManagerState;
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile;
|
||||
use crate::services::memory_profile_prompt_service::{
|
||||
merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources,
|
||||
};
|
||||
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;
|
||||
@@ -379,9 +381,15 @@ pub async fn aster_agent_theme_context_search(
|
||||
});
|
||||
|
||||
let request_tool_policy = resolve_request_tool_policy(Some(true), false);
|
||||
let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let system_prompt = proxycast_agent::merge_system_prompt_with_request_tool_policy(
|
||||
merge_system_prompt_with_web_search(
|
||||
merge_system_prompt_with_memory_profile(project_prompt, &runtime_config),
|
||||
merge_system_prompt_with_memory_sources(
|
||||
merge_system_prompt_with_memory_profile(project_prompt, &runtime_config),
|
||||
&runtime_config,
|
||||
&working_dir,
|
||||
None,
|
||||
),
|
||||
&runtime_config,
|
||||
),
|
||||
&request_tool_policy,
|
||||
|
||||
@@ -19,7 +19,9 @@ use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered;
|
||||
use crate::config::GlobalConfigManagerState;
|
||||
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::memory_profile_prompt_service::{
|
||||
merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources,
|
||||
};
|
||||
use crate::services::request_tool_policy_prompt_service::{
|
||||
execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy,
|
||||
resolve_request_tool_policy, RequestToolPolicy, WebSearchExecutionTracker,
|
||||
@@ -128,8 +130,14 @@ pub async fn chat_create_session(
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let config = config_manager.config();
|
||||
let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let merged_system_prompt = merge_system_prompt_with_web_search(
|
||||
merge_system_prompt_with_memory_profile(request.system_prompt.clone(), &config),
|
||||
merge_system_prompt_with_memory_sources(
|
||||
merge_system_prompt_with_memory_profile(request.system_prompt.clone(), &config),
|
||||
&config,
|
||||
&working_dir,
|
||||
None,
|
||||
),
|
||||
&config,
|
||||
);
|
||||
|
||||
@@ -365,8 +373,14 @@ pub async fn chat_send_message(
|
||||
// 根据模式处理
|
||||
let config = config_manager.config();
|
||||
apply_web_search_runtime_env(&config);
|
||||
let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let merged_system_prompt = merge_system_prompt_with_web_search(
|
||||
merge_system_prompt_with_memory_profile(session.system_prompt.clone(), &config),
|
||||
merge_system_prompt_with_memory_sources(
|
||||
merge_system_prompt_with_memory_profile(session.system_prompt.clone(), &config),
|
||||
&config,
|
||||
&working_dir,
|
||||
None,
|
||||
),
|
||||
&config,
|
||||
);
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ fn arb_config() -> impl Strategy<Value = Config> {
|
||||
content_creator: ContentCreatorConfig::default(),
|
||||
navigation: NavigationConfig::default(),
|
||||
chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(),
|
||||
environment: proxycast_core::config::EnvironmentConfig::default(),
|
||||
web_search: proxycast_core::config::WebSearchConfig::default(),
|
||||
memory: proxycast_core::config::MemoryConfig::default(),
|
||||
voice: proxycast_core::config::VoiceConfig::default(),
|
||||
@@ -452,6 +453,7 @@ fn arb_valid_config() -> impl Strategy<Value = Config> {
|
||||
content_creator: ContentCreatorConfig::default(),
|
||||
navigation: NavigationConfig::default(),
|
||||
chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(),
|
||||
environment: proxycast_core::config::EnvironmentConfig::default(),
|
||||
web_search: proxycast_core::config::WebSearchConfig::default(),
|
||||
memory: proxycast_core::config::MemoryConfig::default(),
|
||||
voice: proxycast_core::config::VoiceConfig::default(),
|
||||
@@ -519,6 +521,7 @@ fn arb_invalid_config() -> impl Strategy<Value = Config> {
|
||||
content_creator: ContentCreatorConfig::default(),
|
||||
navigation: NavigationConfig::default(),
|
||||
chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(),
|
||||
environment: proxycast_core::config::EnvironmentConfig::default(),
|
||||
web_search: proxycast_core::config::WebSearchConfig::default(),
|
||||
memory: proxycast_core::config::MemoryConfig::default(),
|
||||
voice: proxycast_core::config::VoiceConfig::default(),
|
||||
|
||||
@@ -306,9 +306,18 @@ pub async fn handle_command(
|
||||
// 保存配置到文件
|
||||
let config: proxycast_core::config::Config = serde_json::from_value(args.unwrap_or_default())?;
|
||||
proxycast_core::config::save_config(&config)?;
|
||||
crate::services::environment_service::apply_configured_environment(&config).await;
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
"get_environment_preview" => {
|
||||
let config_path = proxycast_core::config::ConfigManager::default_config_path();
|
||||
let manager = proxycast_core::config::ConfigManager::load(&config_path)?;
|
||||
let config = manager.config();
|
||||
let preview = crate::services::environment_service::build_environment_preview(&config).await;
|
||||
Ok(serde_json::to_value(preview)?)
|
||||
}
|
||||
|
||||
"get_default_provider" => {
|
||||
let default_provider_ref = { state.server.read().await.default_provider_ref.clone() };
|
||||
let provider = default_provider_ref.read().await.clone();
|
||||
@@ -517,21 +526,20 @@ pub async fn handle_command(
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("proxycast")
|
||||
.to_string();
|
||||
let refresh_remote = args
|
||||
.get("refresh_remote")
|
||||
.or_else(|| args.get("refreshRemote"))
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false);
|
||||
let app_type: crate::models::app_type::AppType = app.parse().map_err(|e: String| e)?;
|
||||
|
||||
if let Some(db) = &state.db {
|
||||
let (repos, installed_states) = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let repos = crate::database::dao::skills::SkillDao::get_skill_repos(&conn)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let installed_states = crate::database::dao::skills::SkillDao::get_skills(&conn)
|
||||
.map_err(|e| e.to_string())?;
|
||||
(repos, installed_states)
|
||||
};
|
||||
|
||||
let skills = state
|
||||
.skill_service
|
||||
.list_skills(&app_type, &repos, &installed_states)
|
||||
let skills = crate::commands::skill_cmd::resolve_skills_for_app(
|
||||
db,
|
||||
&state.skill_service,
|
||||
&app_type,
|
||||
refresh_remote,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -541,6 +549,31 @@ pub async fn handle_command(
|
||||
}
|
||||
}
|
||||
|
||||
"get_local_skills_for_app" => {
|
||||
let args = args.unwrap_or_default();
|
||||
let app = args
|
||||
.get("app")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("proxycast")
|
||||
.to_string();
|
||||
|
||||
if let Some(db) = &state.db {
|
||||
let app_type: crate::models::app_type::AppType = app.parse().map_err(|e: String| e)?;
|
||||
let installed_states = {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
crate::database::dao::skills::SkillDao::get_skills(&conn)
|
||||
.map_err(|e| format!("{e}"))?
|
||||
};
|
||||
let skills = state
|
||||
.skill_service
|
||||
.list_local_skills(&app_type, &installed_states)
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(serde_json::to_value(skills)?)
|
||||
} else {
|
||||
Ok(serde_json::json!([]))
|
||||
}
|
||||
}
|
||||
|
||||
"test_api" => {
|
||||
// 测试 API 连接
|
||||
// 从 args 获取 provider
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
use proxycast_core::config::{Config, EnvironmentVariableOverride, WebSearchProvider};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Instant;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
const CONFIGURED_NAMESPACE: &str = "configured_environment";
|
||||
const WEB_SEARCH_NAMESPACE: &str = "web_search_runtime";
|
||||
const MAX_SHELL_IMPORT_TIMEOUT_MS: u64 = 30_000;
|
||||
const DEFAULT_PREVIEW_KEYS: &[&str] = &[
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"SHELL",
|
||||
"COMSPEC",
|
||||
"TMPDIR",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"ALL_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
"all_proxy",
|
||||
];
|
||||
const DERIVED_PREVIEW_KEYS: &[&str] = &[
|
||||
"WEB_SEARCH_PROVIDER",
|
||||
"WEB_SEARCH_PROVIDER_PRIORITY",
|
||||
"TAVILY_API_KEY",
|
||||
"BING_SEARCH_API_KEY",
|
||||
"GOOGLE_SEARCH_API_KEY",
|
||||
"GOOGLE_SEARCH_ENGINE_ID",
|
||||
];
|
||||
|
||||
static APPLIED_ENV_REGISTRY: OnceLock<Mutex<HashMap<String, BTreeSet<String>>>> = OnceLock::new();
|
||||
static BASELINE_ENV_REGISTRY: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShellImportPreview {
|
||||
pub enabled: bool,
|
||||
pub status: String,
|
||||
pub message: String,
|
||||
pub imported_count: usize,
|
||||
pub duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnvironmentPreviewEntry {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub masked_value: String,
|
||||
pub source: String,
|
||||
pub source_label: String,
|
||||
pub sensitive: bool,
|
||||
#[serde(default)]
|
||||
pub overridden_sources: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnvironmentPreview {
|
||||
pub shell_import: ShellImportPreview,
|
||||
pub entries: Vec<EnvironmentPreviewEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ShellImportResult {
|
||||
env: BTreeMap<String, String>,
|
||||
preview: ShellImportPreview,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EffectiveEnvironmentResolution {
|
||||
env: BTreeMap<String, String>,
|
||||
shell_import: ShellImportPreview,
|
||||
sources: HashMap<String, String>,
|
||||
overridden_sources: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
fn managed_registry() -> &'static Mutex<HashMap<String, BTreeSet<String>>> {
|
||||
APPLIED_ENV_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn baseline_registry() -> &'static Mutex<HashMap<String, Option<String>>> {
|
||||
BASELINE_ENV_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn is_valid_env_key(key: &str) -> bool {
|
||||
let mut chars = key.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if !(first == '_' || first.is_ascii_alphabetic()) {
|
||||
return false;
|
||||
}
|
||||
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn is_sensitive_key(key: &str) -> bool {
|
||||
let upper = key.to_ascii_uppercase();
|
||||
upper.contains("KEY")
|
||||
|| upper.contains("TOKEN")
|
||||
|| upper.contains("SECRET")
|
||||
|| upper.contains("PASSWORD")
|
||||
|| upper.contains("AUTH")
|
||||
}
|
||||
|
||||
fn mask_value(value: &str) -> String {
|
||||
if value.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let chars: Vec<char> = value.chars().collect();
|
||||
if chars.len() <= 8 {
|
||||
return "••••••".to_string();
|
||||
}
|
||||
|
||||
let prefix: String = chars.iter().take(3).collect();
|
||||
let suffix: String = chars
|
||||
.iter()
|
||||
.skip(chars.len().saturating_sub(2))
|
||||
.copied()
|
||||
.collect();
|
||||
format!("{prefix}••••••{suffix}")
|
||||
}
|
||||
|
||||
fn normalize_override_entry(entry: &EnvironmentVariableOverride) -> Option<(String, String)> {
|
||||
if !entry.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let key = entry.key.trim();
|
||||
if !is_valid_env_key(key) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((key.to_string(), entry.value.clone()))
|
||||
}
|
||||
|
||||
pub fn collect_configured_override_env(config: &Config) -> BTreeMap<String, String> {
|
||||
let mut env = BTreeMap::new();
|
||||
for entry in &config.environment.variables {
|
||||
if let Some((key, value)) = normalize_override_entry(entry) {
|
||||
env.insert(key, value);
|
||||
}
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
pub fn build_web_search_runtime_env(config: &Config) -> BTreeMap<String, String> {
|
||||
let web_search = &config.web_search;
|
||||
let mut env = BTreeMap::new();
|
||||
|
||||
env.insert(
|
||||
"WEB_SEARCH_PROVIDER".to_string(),
|
||||
match web_search.provider {
|
||||
WebSearchProvider::Tavily => "tavily",
|
||||
WebSearchProvider::MultiSearchEngine => "multi_search_engine",
|
||||
WebSearchProvider::DuckduckgoInstant => "duckduckgo_instant",
|
||||
WebSearchProvider::BingSearchApi => "bing_search_api",
|
||||
WebSearchProvider::GoogleCustomSearch => "google_custom_search",
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let mut provider_priority = Vec::new();
|
||||
let mut push_unique = |value: &str| {
|
||||
if !provider_priority.iter().any(|current| current == value) {
|
||||
provider_priority.push(value.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
push_unique(env["WEB_SEARCH_PROVIDER"].as_str());
|
||||
for provider in &web_search.provider_priority {
|
||||
push_unique(match provider {
|
||||
WebSearchProvider::Tavily => "tavily",
|
||||
WebSearchProvider::MultiSearchEngine => "multi_search_engine",
|
||||
WebSearchProvider::DuckduckgoInstant => "duckduckgo_instant",
|
||||
WebSearchProvider::BingSearchApi => "bing_search_api",
|
||||
WebSearchProvider::GoogleCustomSearch => "google_custom_search",
|
||||
});
|
||||
}
|
||||
for provider in [
|
||||
"tavily",
|
||||
"multi_search_engine",
|
||||
"bing_search_api",
|
||||
"google_custom_search",
|
||||
"duckduckgo_instant",
|
||||
] {
|
||||
push_unique(provider);
|
||||
}
|
||||
env.insert(
|
||||
"WEB_SEARCH_PROVIDER_PRIORITY".to_string(),
|
||||
provider_priority.join(","),
|
||||
);
|
||||
|
||||
let insert_trimmed =
|
||||
|target: &mut BTreeMap<String, String>, key: &str, value: &Option<String>| {
|
||||
if let Some(trimmed) = value
|
||||
.as_ref()
|
||||
.map(|item| item.trim())
|
||||
.filter(|item| !item.is_empty())
|
||||
{
|
||||
target.insert(key.to_string(), trimmed.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
insert_trimmed(&mut env, "TAVILY_API_KEY", &web_search.tavily_api_key);
|
||||
insert_trimmed(
|
||||
&mut env,
|
||||
"BING_SEARCH_API_KEY",
|
||||
&web_search.bing_search_api_key,
|
||||
);
|
||||
insert_trimmed(
|
||||
&mut env,
|
||||
"GOOGLE_SEARCH_API_KEY",
|
||||
&web_search.google_search_api_key,
|
||||
);
|
||||
insert_trimmed(
|
||||
&mut env,
|
||||
"GOOGLE_SEARCH_ENGINE_ID",
|
||||
&web_search.google_search_engine_id,
|
||||
);
|
||||
|
||||
let engines = web_search
|
||||
.multi_search
|
||||
.engines
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
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,
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let valid_engine_names: std::collections::HashSet<String> = engines
|
||||
.iter()
|
||||
.filter_map(|engine| engine.get("name").and_then(|v| v.as_str()))
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let multi_search_priority = if web_search.multi_search.priority.is_empty() {
|
||||
valid_engine_names.iter().cloned().collect::<Vec<_>>()
|
||||
} else {
|
||||
web_search
|
||||
.multi_search
|
||||
.priority
|
||||
.iter()
|
||||
.map(|name| name.trim().to_string())
|
||||
.filter(|name| !name.is_empty() && valid_engine_names.contains(name))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let multi_search_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,
|
||||
});
|
||||
|
||||
if let Ok(raw) = serde_json::to_string(&multi_search_config) {
|
||||
env.insert("MULTI_SEARCH_ENGINE_CONFIG_JSON".to_string(), raw);
|
||||
}
|
||||
|
||||
env
|
||||
}
|
||||
|
||||
fn upsert_source(
|
||||
sources: &mut HashMap<String, String>,
|
||||
overridden_sources: &mut HashMap<String, Vec<String>>,
|
||||
key: &str,
|
||||
source: &str,
|
||||
) {
|
||||
if let Some(previous) = sources.insert(key.to_string(), source.to_string()) {
|
||||
overridden_sources
|
||||
.entry(key.to_string())
|
||||
.or_default()
|
||||
.push(previous);
|
||||
}
|
||||
}
|
||||
|
||||
async fn import_shell_environment(config: &Config) -> ShellImportResult {
|
||||
if !config.environment.shell_import.enabled {
|
||||
return ShellImportResult {
|
||||
env: BTreeMap::new(),
|
||||
preview: ShellImportPreview {
|
||||
enabled: false,
|
||||
status: "disabled".to_string(),
|
||||
message: "已关闭 Shell 环境导入,仅使用当前进程环境与显式覆盖。".to_string(),
|
||||
imported_count: 0,
|
||||
duration_ms: None,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let timeout_ms = config
|
||||
.environment
|
||||
.shell_import
|
||||
.timeout_ms
|
||||
.clamp(100, MAX_SHELL_IMPORT_TIMEOUT_MS);
|
||||
let started_at = Instant::now();
|
||||
|
||||
let output = timeout(
|
||||
Duration::from_millis(timeout_ms),
|
||||
read_shell_environment_output(),
|
||||
)
|
||||
.await;
|
||||
match output {
|
||||
Ok(Ok(raw)) => {
|
||||
let env = parse_environment_output(&raw);
|
||||
let duration_ms = started_at.elapsed().as_millis() as u64;
|
||||
ShellImportResult {
|
||||
preview: ShellImportPreview {
|
||||
enabled: true,
|
||||
status: "ok".to_string(),
|
||||
message: format!("已导入 Shell 环境,共 {} 个变量。", env.len()),
|
||||
imported_count: env.len(),
|
||||
duration_ms: Some(duration_ms),
|
||||
},
|
||||
env,
|
||||
}
|
||||
}
|
||||
Ok(Err(error)) => ShellImportResult {
|
||||
env: BTreeMap::new(),
|
||||
preview: ShellImportPreview {
|
||||
enabled: true,
|
||||
status: "error".to_string(),
|
||||
message: format!("Shell 环境导入失败:{error}"),
|
||||
imported_count: 0,
|
||||
duration_ms: Some(started_at.elapsed().as_millis() as u64),
|
||||
},
|
||||
},
|
||||
Err(_) => ShellImportResult {
|
||||
env: BTreeMap::new(),
|
||||
preview: ShellImportPreview {
|
||||
enabled: true,
|
||||
status: "timeout".to_string(),
|
||||
message: format!(
|
||||
"Shell 环境导入超时({} ms),已回退为仅使用显式覆盖。",
|
||||
timeout_ms
|
||||
),
|
||||
imported_count: 0,
|
||||
duration_ms: Some(timeout_ms),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_shell_environment_output() -> Result<Vec<u8>, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let script = r#"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-ChildItem Env: | ForEach-Object { "{0}={1}" -f $_.Name, $_.Value }"#;
|
||||
for shell in ["pwsh", "powershell"] {
|
||||
let mut command = Command::new(shell);
|
||||
let output = command
|
||||
.arg("-NoLogo")
|
||||
.arg("-Command")
|
||||
.arg(script)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(result) if result.status.success() => return Ok(result.stdout),
|
||||
Ok(_) => continue,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err("未找到可用的 PowerShell 解释器。".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let shell = std::env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "/bin/zsh".to_string());
|
||||
|
||||
for args in [vec!["-lic", "env -0"], vec!["-lc", "env -0"]] {
|
||||
let mut command = Command::new(&shell);
|
||||
let output = command.args(&args).output().await;
|
||||
match output {
|
||||
Ok(result) if result.status.success() => return Ok(result.stdout),
|
||||
Ok(_) => continue,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("无法使用 Shell `{shell}` 读取环境变量。"))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_environment_output(raw: &[u8]) -> BTreeMap<String, String> {
|
||||
let mut env = BTreeMap::new();
|
||||
let text = String::from_utf8_lossy(raw);
|
||||
let segments = if text.contains('\0') {
|
||||
text.split('\0').map(str::to_string).collect::<Vec<_>>()
|
||||
} else {
|
||||
text.lines().map(str::to_string).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for line in segments {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = trimmed.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if !is_valid_env_key(key.trim()) {
|
||||
continue;
|
||||
}
|
||||
env.insert(key.trim().to_string(), value.to_string());
|
||||
}
|
||||
|
||||
env
|
||||
}
|
||||
|
||||
async fn resolve_effective_environment(config: &Config) -> EffectiveEnvironmentResolution {
|
||||
let shell_import = import_shell_environment(config).await;
|
||||
let override_env = collect_configured_override_env(config);
|
||||
let derived_web_search_env = build_web_search_runtime_env(config);
|
||||
let mut env = BTreeMap::new();
|
||||
let mut sources = HashMap::new();
|
||||
let mut overridden_sources = HashMap::new();
|
||||
|
||||
for (key, value) in &shell_import.env {
|
||||
env.insert(key.clone(), value.clone());
|
||||
upsert_source(&mut sources, &mut overridden_sources, key, "shell_import");
|
||||
}
|
||||
|
||||
for (key, value) in &derived_web_search_env {
|
||||
if override_env.contains_key(key) {
|
||||
overridden_sources
|
||||
.entry(key.clone())
|
||||
.or_default()
|
||||
.push("web_search".to_string());
|
||||
continue;
|
||||
}
|
||||
env.insert(key.clone(), value.clone());
|
||||
upsert_source(&mut sources, &mut overridden_sources, key, "web_search");
|
||||
}
|
||||
|
||||
for (key, value) in &override_env {
|
||||
env.insert(key.clone(), value.clone());
|
||||
upsert_source(&mut sources, &mut overridden_sources, key, "override");
|
||||
}
|
||||
|
||||
EffectiveEnvironmentResolution {
|
||||
env,
|
||||
shell_import: shell_import.preview,
|
||||
sources,
|
||||
overridden_sources,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_environment_preview(config: &Config) -> EnvironmentPreview {
|
||||
let resolution = resolve_effective_environment(config).await;
|
||||
let configured_keys = collect_configured_override_env(config)
|
||||
.into_keys()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let derived_keys = build_web_search_runtime_env(config)
|
||||
.into_keys()
|
||||
.filter(|key| {
|
||||
DERIVED_PREVIEW_KEYS
|
||||
.iter()
|
||||
.any(|candidate| candidate == key)
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut preview_keys = BTreeSet::new();
|
||||
|
||||
preview_keys.extend(configured_keys);
|
||||
preview_keys.extend(derived_keys);
|
||||
preview_keys.extend(
|
||||
DEFAULT_PREVIEW_KEYS
|
||||
.iter()
|
||||
.filter(|key| resolution.env.contains_key(**key))
|
||||
.map(|key| key.to_string()),
|
||||
);
|
||||
|
||||
let entries = preview_keys
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let value = resolution.env.get(&key)?.to_string();
|
||||
let source = resolution
|
||||
.sources
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "process".to_string());
|
||||
let source_label = match source.as_str() {
|
||||
"override" => "环境变量覆盖",
|
||||
"shell_import" => "Shell 环境导入",
|
||||
"web_search" => "网络搜索配置",
|
||||
_ => "当前进程环境",
|
||||
}
|
||||
.to_string();
|
||||
let sensitive = is_sensitive_key(&key);
|
||||
Some(EnvironmentPreviewEntry {
|
||||
key: key.clone(),
|
||||
masked_value: if sensitive {
|
||||
mask_value(&value)
|
||||
} else {
|
||||
value.clone()
|
||||
},
|
||||
value,
|
||||
source,
|
||||
source_label,
|
||||
sensitive,
|
||||
overridden_sources: resolution
|
||||
.overridden_sources
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
EnvironmentPreview {
|
||||
shell_import: resolution.shell_import,
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_configured_environment(config: &Config) {
|
||||
let shell_import = import_shell_environment(config).await;
|
||||
let mut env = shell_import.env;
|
||||
for (key, value) in collect_configured_override_env(config) {
|
||||
env.insert(key, value);
|
||||
}
|
||||
apply_environment_namespace(CONFIGURED_NAMESPACE, &env);
|
||||
}
|
||||
|
||||
pub fn apply_web_search_environment(config: &Config) {
|
||||
let override_keys = collect_configured_override_env(config)
|
||||
.into_keys()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut env = build_web_search_runtime_env(config);
|
||||
env.retain(|key, _| !override_keys.contains(key));
|
||||
apply_environment_namespace(WEB_SEARCH_NAMESPACE, &env);
|
||||
}
|
||||
|
||||
pub fn apply_environment_namespace(namespace: &str, env: &BTreeMap<String, String>) {
|
||||
let registry = managed_registry();
|
||||
let mut registry = match registry.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let baseline_registry = baseline_registry();
|
||||
let mut baseline_registry = match baseline_registry.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
|
||||
let next_keys = env.keys().cloned().collect::<BTreeSet<_>>();
|
||||
let previous_keys = registry
|
||||
.insert(namespace.to_string(), next_keys.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
for key in previous_keys.difference(&next_keys) {
|
||||
if let Some(Some(original)) = baseline_registry.get(key) {
|
||||
std::env::set_var(key, original);
|
||||
} else {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in env {
|
||||
baseline_registry
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| std::env::var(key).ok());
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proxycast_core::config::{
|
||||
Config, MultiSearchEngineEntryConfig, SearchEngine, WebSearchConfig,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn environment_preview_prefers_explicit_override_over_web_search() {
|
||||
let mut config = Config::default();
|
||||
config.environment.variables = vec![EnvironmentVariableOverride {
|
||||
key: "TAVILY_API_KEY".to_string(),
|
||||
value: "override-key".to_string(),
|
||||
enabled: true,
|
||||
}];
|
||||
config.web_search = WebSearchConfig {
|
||||
engine: SearchEngine::Google,
|
||||
provider: WebSearchProvider::Tavily,
|
||||
provider_priority: vec![],
|
||||
tavily_api_key: Some("search-key".to_string()),
|
||||
bing_search_api_key: None,
|
||||
google_search_api_key: None,
|
||||
google_search_engine_id: None,
|
||||
multi_search: Default::default(),
|
||||
};
|
||||
|
||||
let preview = build_environment_preview(&config).await;
|
||||
let entry = preview
|
||||
.entries
|
||||
.iter()
|
||||
.find(|item| item.key == "TAVILY_API_KEY")
|
||||
.expect("should contain TAVILY_API_KEY");
|
||||
|
||||
assert_eq!(entry.value, "override-key");
|
||||
assert_eq!(entry.source, "override");
|
||||
assert!(entry
|
||||
.overridden_sources
|
||||
.iter()
|
||||
.any(|item| item == "web_search"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_web_search_runtime_env_contains_serialized_multi_search_config() {
|
||||
let mut config = Config::default();
|
||||
config.web_search.provider = WebSearchProvider::MultiSearchEngine;
|
||||
config.web_search.multi_search.engines = vec![MultiSearchEngineEntryConfig {
|
||||
name: "google".to_string(),
|
||||
url_template: "https://www.google.com/search?q={query}".to_string(),
|
||||
enabled: true,
|
||||
}];
|
||||
|
||||
let env = build_web_search_runtime_env(&config);
|
||||
assert_eq!(
|
||||
env.get("WEB_SEARCH_PROVIDER").map(String::as_str),
|
||||
Some("multi_search_engine")
|
||||
);
|
||||
assert!(env.contains_key("MULTI_SEARCH_ENGINE_CONFIG_JSON"));
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@
|
||||
//! 转换为可注入到系统提示词中的统一指令片段。
|
||||
|
||||
use proxycast_core::config::Config;
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::services::memory_source_resolver_service::build_memory_sources_prompt;
|
||||
|
||||
const MEMORY_PROFILE_PROMPT_MARKER: &str = "【用户记忆画像偏好】";
|
||||
const MEMORY_SOURCE_PROMPT_MARKER: &str = "【记忆来源补充指令】";
|
||||
|
||||
fn normalize_text(input: &str) -> Option<String> {
|
||||
let trimmed = input.trim();
|
||||
@@ -79,13 +80,6 @@ pub fn build_memory_profile_prompt(config: &Config) -> Option<String> {
|
||||
lines.push("2. 在保证正确性的前提下,控制解释粒度并匹配用户理解路径。".to_string());
|
||||
lines.push("3. 不要显式提及你看到了该画像配置。".to_string());
|
||||
|
||||
// 记忆来源补充(AGENTS、规则、自动记忆等)
|
||||
let working_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
if let Some(source_prompt) = build_memory_sources_prompt(config, &working_dir, None, 4000) {
|
||||
lines.push(String::new());
|
||||
lines.push(source_prompt);
|
||||
}
|
||||
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
@@ -115,10 +109,41 @@ pub fn merge_system_prompt_with_memory_profile(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_system_prompt_with_memory_sources(
|
||||
base_prompt: Option<String>,
|
||||
config: &Config,
|
||||
working_dir: &Path,
|
||||
active_relative_path: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if !config.memory.enabled {
|
||||
return base_prompt;
|
||||
}
|
||||
|
||||
let memory_sources_prompt =
|
||||
build_memory_sources_prompt(config, working_dir, active_relative_path, 4000);
|
||||
|
||||
match (base_prompt, memory_sources_prompt) {
|
||||
(Some(base), Some(source_prompt)) => {
|
||||
if base.contains(MEMORY_SOURCE_PROMPT_MARKER) {
|
||||
Some(base)
|
||||
} else if base.trim().is_empty() {
|
||||
Some(source_prompt)
|
||||
} else {
|
||||
Some(format!("{base}\n\n{source_prompt}"))
|
||||
}
|
||||
}
|
||||
(Some(base), None) => Some(base),
|
||||
(None, Some(source_prompt)) => Some(source_prompt),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proxycast_core::config::Config;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn memory_disabled_should_not_build_prompt() {
|
||||
@@ -170,4 +195,25 @@ mod tests {
|
||||
let merged = merge_system_prompt_with_memory_profile(base.clone(), &config);
|
||||
assert_eq!(merged, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_merge_memory_sources_without_profile_data() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "# 项目记忆\n- 偏好简洁输出")
|
||||
.expect("write memory file");
|
||||
|
||||
let mut config = Config::default();
|
||||
config.memory.enabled = true;
|
||||
config.memory.profile = Some(Default::default());
|
||||
config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
|
||||
config.memory.sources.user_memory_path = Some("missing-user.md".to_string());
|
||||
config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
|
||||
config.memory.sources.project_rule_dirs = Vec::new();
|
||||
|
||||
let merged = merge_system_prompt_with_memory_sources(None, &config, tmp.path(), None)
|
||||
.expect("should build sources prompt");
|
||||
|
||||
assert!(merged.contains("【记忆来源补充指令】"));
|
||||
assert!(merged.contains("偏好简洁输出"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,18 @@
|
||||
use crate::services::auto_memory_service::{get_auto_memory_index, resolve_auto_memory_root};
|
||||
use crate::services::memory_import_parser_service::{parse_memory_file, MemoryImportParseOptions};
|
||||
use crate::services::memory_rules_loader_service::load_rules;
|
||||
use proxycast_agent::{
|
||||
resolve_durable_memory_root, to_virtual_memory_path, DURABLE_MEMORY_VIRTUAL_ROOT,
|
||||
};
|
||||
use proxycast_core::config::{Config, MemoryConfig};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DURABLE_MEMORY_MAX_DEPTH: usize = 4;
|
||||
const DURABLE_MEMORY_MAX_FILES: usize = 64;
|
||||
|
||||
/// 单个来源解析结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EffectiveMemorySource {
|
||||
@@ -99,7 +106,16 @@ pub fn resolve_effective_sources(
|
||||
&mut prompt_segments,
|
||||
);
|
||||
|
||||
// 3. project hierarchy memory + rules
|
||||
// 3. cross-thread durable memory (`/memories/...`)
|
||||
resolve_durable_memory_sources(
|
||||
memory,
|
||||
&options,
|
||||
&mut seen,
|
||||
&mut sources,
|
||||
&mut prompt_segments,
|
||||
);
|
||||
|
||||
// 4. project hierarchy memory + rules
|
||||
let ancestors = collect_ancestor_dirs(working_dir);
|
||||
for ancestor in &ancestors {
|
||||
for rel in &memory.sources.project_memory_paths {
|
||||
@@ -153,7 +169,7 @@ pub fn resolve_effective_sources(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. additional directories
|
||||
// 5. additional directories
|
||||
if memory.resolve.load_additional_dirs_memory {
|
||||
for additional in &memory.resolve.additional_dirs {
|
||||
let additional_dir = expand_path(additional, Some(working_dir));
|
||||
@@ -189,7 +205,7 @@ pub fn resolve_effective_sources(
|
||||
}
|
||||
}
|
||||
|
||||
// 5. auto memory
|
||||
// 6. auto memory
|
||||
resolve_auto_memory_source(
|
||||
memory,
|
||||
working_dir,
|
||||
@@ -263,11 +279,36 @@ fn resolve_file_source(
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
output: &mut Vec<EffectiveMemorySource>,
|
||||
prompt_segments: &mut Vec<String>,
|
||||
) {
|
||||
resolve_file_source_with_display_path(
|
||||
kind,
|
||||
file_path,
|
||||
None,
|
||||
include_missing,
|
||||
options,
|
||||
seen,
|
||||
output,
|
||||
prompt_segments,
|
||||
);
|
||||
}
|
||||
|
||||
fn resolve_file_source_with_display_path(
|
||||
kind: &str,
|
||||
file_path: &Path,
|
||||
display_path: Option<&str>,
|
||||
include_missing: bool,
|
||||
options: &MemoryImportParseOptions,
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
output: &mut Vec<EffectiveMemorySource>,
|
||||
prompt_segments: &mut Vec<String>,
|
||||
) {
|
||||
let normalized = normalize_path(file_path);
|
||||
if !seen.insert(normalized.clone()) {
|
||||
return;
|
||||
}
|
||||
let display_path = display_path
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| normalized.to_string_lossy().to_string());
|
||||
|
||||
if !normalized.exists() || !normalized.is_file() {
|
||||
if !include_missing {
|
||||
@@ -275,7 +316,7 @@ fn resolve_file_source(
|
||||
}
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: kind.to_string(),
|
||||
path: normalized.to_string_lossy().to_string(),
|
||||
path: display_path,
|
||||
exists: false,
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
@@ -304,7 +345,7 @@ fn resolve_file_source(
|
||||
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: kind.to_string(),
|
||||
path: normalized.to_string_lossy().to_string(),
|
||||
path: display_path.clone(),
|
||||
exists: true,
|
||||
loaded,
|
||||
line_count,
|
||||
@@ -314,18 +355,13 @@ fn resolve_file_source(
|
||||
});
|
||||
|
||||
if loaded {
|
||||
prompt_segments.push(format!(
|
||||
"### {} ({})\n{}",
|
||||
kind,
|
||||
normalized.display(),
|
||||
content
|
||||
));
|
||||
prompt_segments.push(format!("### {} ({})\n{}", kind, display_path, content));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: kind.to_string(),
|
||||
path: normalized.to_string_lossy().to_string(),
|
||||
path: display_path,
|
||||
exists: true,
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
@@ -337,6 +373,88 @@ fn resolve_file_source(
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_durable_memory_sources(
|
||||
memory_config: &MemoryConfig,
|
||||
options: &MemoryImportParseOptions,
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
output: &mut Vec<EffectiveMemorySource>,
|
||||
prompt_segments: &mut Vec<String>,
|
||||
) {
|
||||
let root = match resolve_durable_memory_root() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: "durable_memory".to_string(),
|
||||
path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
|
||||
exists: false,
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
import_count: 0,
|
||||
warnings: vec![format!("解析 durable memory 根目录失败: {err}")],
|
||||
preview: None,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let files = match collect_durable_memory_files(
|
||||
&root,
|
||||
DURABLE_MEMORY_MAX_DEPTH,
|
||||
DURABLE_MEMORY_MAX_FILES,
|
||||
) {
|
||||
Ok(files) => files,
|
||||
Err(err) => {
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: "durable_memory".to_string(),
|
||||
path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
|
||||
exists: root.exists(),
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
import_count: 0,
|
||||
warnings: vec![format!("扫描 durable memory 文件失败: {err}")],
|
||||
preview: None,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if files.is_empty() {
|
||||
let warnings = if memory_config.enabled {
|
||||
vec!["尚未创建 durable memory 文件,可通过 `/memories/...` 路径写入".to_string()]
|
||||
} else {
|
||||
vec!["记忆功能已关闭".to_string()]
|
||||
};
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: "durable_memory".to_string(),
|
||||
path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
|
||||
exists: root.exists(),
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
import_count: 0,
|
||||
warnings,
|
||||
preview: None,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for file_path in files {
|
||||
let display_path = to_virtual_memory_path(&file_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| file_path.to_string_lossy().to_string());
|
||||
resolve_file_source_with_display_path(
|
||||
"durable_memory",
|
||||
&file_path,
|
||||
Some(&display_path),
|
||||
false,
|
||||
options,
|
||||
seen,
|
||||
output,
|
||||
prompt_segments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_rule_sources(
|
||||
rule_dir: &Path,
|
||||
active_relative_path: Option<&str>,
|
||||
@@ -495,6 +613,92 @@ fn resolve_auto_memory_source(
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_durable_memory_files(
|
||||
root: &Path,
|
||||
max_depth: usize,
|
||||
max_files: usize,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
let mut files = Vec::new();
|
||||
collect_durable_memory_files_recursive(root, 0, max_depth, max_files, &mut files)?;
|
||||
files.sort_by(|left, right| durable_memory_sort_key(left).cmp(&durable_memory_sort_key(right)));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn collect_durable_memory_files_recursive(
|
||||
dir: &Path,
|
||||
depth: usize,
|
||||
max_depth: usize,
|
||||
max_files: usize,
|
||||
output: &mut Vec<PathBuf>,
|
||||
) -> Result<(), String> {
|
||||
if depth > max_depth || output.len() >= max_files || !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut entries = fs::read_dir(dir)
|
||||
.map_err(|e| format!("读取目录失败 {}: {e}", dir.display()))?
|
||||
.filter_map(Result::ok)
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by(|left, right| left.path().cmp(&right.path()));
|
||||
|
||||
for entry in entries {
|
||||
if output.len() >= max_files {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if file_type.is_dir() {
|
||||
collect_durable_memory_files_recursive(&path, depth + 1, max_depth, max_files, output)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_file() && is_durable_memory_candidate_file(&path) {
|
||||
output.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_durable_memory_candidate_file(path: &Path) -> bool {
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.trim().to_ascii_lowercase());
|
||||
|
||||
matches!(
|
||||
extension.as_deref(),
|
||||
Some("md")
|
||||
| Some("markdown")
|
||||
| Some("mdx")
|
||||
| Some("txt")
|
||||
| Some("json")
|
||||
| Some("yaml")
|
||||
| Some("yml")
|
||||
| Some("toml")
|
||||
)
|
||||
}
|
||||
|
||||
fn durable_memory_sort_key(path: &Path) -> (u8, String) {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
let priority = match file_name.as_str() {
|
||||
"memory.md" | "memory.mdx" | "memory.txt" => 0,
|
||||
"preferences.md" | "preferences.json" | "preferences.toml" => 1,
|
||||
"project.md" | "project.json" | "project.toml" => 2,
|
||||
_ => 10,
|
||||
};
|
||||
|
||||
(priority, path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn collect_ancestor_dirs(start: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
let mut current = if start.is_file() {
|
||||
@@ -614,9 +818,38 @@ fn clip_text(text: &str, max_chars: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn durable_memory_env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
struct DurableMemoryEnvGuard {
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
|
||||
impl DurableMemoryEnvGuard {
|
||||
fn set(path: &Path) -> Self {
|
||||
let previous = std::env::var_os("PROXYCAST_DURABLE_MEMORY_DIR");
|
||||
std::env::set_var("PROXYCAST_DURABLE_MEMORY_DIR", path.as_os_str());
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DurableMemoryEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = &self.previous {
|
||||
std::env::set_var("PROXYCAST_DURABLE_MEMORY_DIR", value);
|
||||
} else {
|
||||
std::env::remove_var("PROXYCAST_DURABLE_MEMORY_DIR");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_resolve_project_memory_and_rules() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
@@ -661,4 +894,46 @@ mod tests {
|
||||
.any(|s| s.kind == "additional_memory" && s.loaded);
|
||||
assert!(has_additional_loaded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_resolve_durable_memory_sources_with_virtual_paths() {
|
||||
let _env_lock = durable_memory_env_lock().lock().expect("lock env");
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
fs::create_dir_all(tmp.path().join("team")).expect("create subdir");
|
||||
fs::write(tmp.path().join("MEMORY.md"), "# 长期记忆\n- 始终先给结论")
|
||||
.expect("write durable memory");
|
||||
fs::write(
|
||||
tmp.path().join("team/preferences.md"),
|
||||
"# 团队偏好\n- 保持 KISS",
|
||||
)
|
||||
.expect("write nested durable memory");
|
||||
let _env = DurableMemoryEnvGuard::set(tmp.path());
|
||||
|
||||
let mut cfg = Config::default();
|
||||
cfg.memory.enabled = true;
|
||||
cfg.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
|
||||
cfg.memory.sources.user_memory_path = Some("missing-user.md".to_string());
|
||||
cfg.memory.sources.project_memory_paths = Vec::new();
|
||||
cfg.memory.sources.project_rule_dirs = Vec::new();
|
||||
|
||||
let resolved = resolve_effective_sources(&cfg, Path::new("."), None);
|
||||
assert!(resolved
|
||||
.response
|
||||
.sources
|
||||
.iter()
|
||||
.any(|source| source.kind == "durable_memory"
|
||||
&& source.path == "/memories/MEMORY.md"
|
||||
&& source.loaded));
|
||||
assert!(resolved
|
||||
.response
|
||||
.sources
|
||||
.iter()
|
||||
.any(|source| source.kind == "durable_memory"
|
||||
&& source.path == "/memories/team/preferences.md"
|
||||
&& source.loaded));
|
||||
assert!(resolved
|
||||
.prompt_segments
|
||||
.iter()
|
||||
.any(|segment| segment.contains("/memories/MEMORY.md")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// 保留在主 crate 的 Tauri 相关服务
|
||||
pub mod auto_memory_service;
|
||||
pub mod conversation_statistics_service;
|
||||
pub mod environment_service;
|
||||
pub mod execution_tracker_service;
|
||||
pub mod file_browser_service;
|
||||
pub mod heartbeat_service;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,148 +2,17 @@
|
||||
//!
|
||||
//! 将设置页中的网络搜索配置同步为 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);
|
||||
}
|
||||
}
|
||||
use crate::services::environment_service::apply_web_search_environment;
|
||||
use proxycast_core::config::Config;
|
||||
|
||||
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(),
|
||||
);
|
||||
apply_web_search_environment(config);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::services::environment_service::build_web_search_runtime_env;
|
||||
use proxycast_core::config::{Config, WebSearchConfig, WebSearchProvider};
|
||||
use proxycast_core::config::{MultiSearchConfig, SearchEngine};
|
||||
|
||||
#[test]
|
||||
@@ -155,30 +24,46 @@ mod tests {
|
||||
WebSearchProvider::Tavily,
|
||||
];
|
||||
|
||||
let priority = resolve_provider_priority(&web_search);
|
||||
let config = Config {
|
||||
web_search,
|
||||
..Config::default()
|
||||
};
|
||||
let raw = build_web_search_runtime_env(&config)
|
||||
.get("WEB_SEARCH_PROVIDER_PRIORITY")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let priority = raw.split(',').map(str::to_string).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
priority.first(),
|
||||
Some(&WebSearchProvider::GoogleCustomSearch)
|
||||
priority.first().map(String::as_str),
|
||||
Some("google_custom_search")
|
||||
);
|
||||
assert!(priority.contains(&WebSearchProvider::DuckduckgoInstant));
|
||||
assert!(priority.contains(&WebSearchProvider::Tavily));
|
||||
assert!(priority.iter().any(|item| item == "duckduckgo_instant"));
|
||||
assert!(priority.iter().any(|item| item == "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,
|
||||
};
|
||||
let mut config = Config::default();
|
||||
config.web_search.provider = WebSearchProvider::MultiSearchEngine;
|
||||
config.web_search.multi_search.engines = vec![
|
||||
proxycast_core::config::MultiSearchEngineEntryConfig {
|
||||
name: "valid".to_string(),
|
||||
url_template: "https://example.com/search?q={query}".to_string(),
|
||||
enabled: true,
|
||||
},
|
||||
proxycast_core::config::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());
|
||||
let raw = build_web_search_runtime_env(&config)
|
||||
.get("MULTI_SEARCH_ENGINE_CONFIG_JSON")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(raw.contains("\"valid\""));
|
||||
assert!(!raw.contains("\"invalid\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -195,8 +80,10 @@ mod tests {
|
||||
multi_search: MultiSearchConfig::default(),
|
||||
};
|
||||
|
||||
apply_web_search_runtime_env(&config);
|
||||
let raw = std::env::var("MULTI_SEARCH_ENGINE_CONFIG_JSON").unwrap_or_default();
|
||||
let raw = build_web_search_runtime_env(&config)
|
||||
.get("MULTI_SEARCH_ENGINE_CONFIG_JSON")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(!raw.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,61 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const VIDEO_GENERATE_SKILL_NAME: &str = "video_generate";
|
||||
use proxycast_core::models::{
|
||||
BROADCAST_GENERATE_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY,
|
||||
IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY,
|
||||
RESEARCH_SKILL_DIRECTORY, SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY,
|
||||
URL_PARSE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_DIRECTORY,
|
||||
};
|
||||
|
||||
const VIDEO_GENERATE_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/video_generate/SKILL.md");
|
||||
|
||||
const BROADCAST_GENERATE_SKILL_NAME: &str = "broadcast_generate";
|
||||
const BROADCAST_GENERATE_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/broadcast_generate/SKILL.md");
|
||||
|
||||
const COVER_GENERATE_SKILL_NAME: &str = "cover_generate";
|
||||
const COVER_GENERATE_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/cover_generate/SKILL.md");
|
||||
|
||||
const MODAL_RESOURCE_SEARCH_SKILL_NAME: &str = "modal_resource_search";
|
||||
const MODAL_RESOURCE_SEARCH_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/modal_resource_search/SKILL.md");
|
||||
|
||||
const IMAGE_GENERATE_SKILL_NAME: &str = "image_generate";
|
||||
const IMAGE_GENERATE_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/image_generate/SKILL.md");
|
||||
|
||||
const LIBRARY_SKILL_NAME: &str = "library";
|
||||
const LIBRARY_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/library/SKILL.md");
|
||||
|
||||
const URL_PARSE_SKILL_NAME: &str = "url_parse";
|
||||
const URL_PARSE_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/url_parse/SKILL.md");
|
||||
|
||||
const RESEARCH_SKILL_NAME: &str = "research";
|
||||
const RESEARCH_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/research/SKILL.md");
|
||||
|
||||
const TYPESETTING_SKILL_NAME: &str = "typesetting";
|
||||
const TYPESETTING_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/typesetting/SKILL.md");
|
||||
|
||||
const SOCIAL_POST_WITH_COVER_SKILL_NAME: &str = "social_post_with_cover";
|
||||
const SOCIAL_POST_WITH_COVER_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/social_post_with_cover/SKILL.md");
|
||||
|
||||
fn default_skills() -> [(&'static str, &'static str); 10] {
|
||||
[
|
||||
(VIDEO_GENERATE_SKILL_NAME, VIDEO_GENERATE_SKILL_CONTENT),
|
||||
(VIDEO_GENERATE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_CONTENT),
|
||||
(
|
||||
BROADCAST_GENERATE_SKILL_NAME,
|
||||
BROADCAST_GENERATE_SKILL_DIRECTORY,
|
||||
BROADCAST_GENERATE_SKILL_CONTENT,
|
||||
),
|
||||
(COVER_GENERATE_SKILL_NAME, COVER_GENERATE_SKILL_CONTENT),
|
||||
(COVER_GENERATE_SKILL_DIRECTORY, COVER_GENERATE_SKILL_CONTENT),
|
||||
(
|
||||
MODAL_RESOURCE_SEARCH_SKILL_NAME,
|
||||
MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY,
|
||||
MODAL_RESOURCE_SEARCH_SKILL_CONTENT,
|
||||
),
|
||||
(IMAGE_GENERATE_SKILL_NAME, IMAGE_GENERATE_SKILL_CONTENT),
|
||||
(LIBRARY_SKILL_NAME, LIBRARY_SKILL_CONTENT),
|
||||
(URL_PARSE_SKILL_NAME, URL_PARSE_SKILL_CONTENT),
|
||||
(RESEARCH_SKILL_NAME, RESEARCH_SKILL_CONTENT),
|
||||
(TYPESETTING_SKILL_NAME, TYPESETTING_SKILL_CONTENT),
|
||||
(IMAGE_GENERATE_SKILL_DIRECTORY, IMAGE_GENERATE_SKILL_CONTENT),
|
||||
(LIBRARY_SKILL_DIRECTORY, LIBRARY_SKILL_CONTENT),
|
||||
(URL_PARSE_SKILL_DIRECTORY, URL_PARSE_SKILL_CONTENT),
|
||||
(RESEARCH_SKILL_DIRECTORY, RESEARCH_SKILL_CONTENT),
|
||||
(TYPESETTING_SKILL_DIRECTORY, TYPESETTING_SKILL_CONTENT),
|
||||
(
|
||||
SOCIAL_POST_WITH_COVER_SKILL_NAME,
|
||||
SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY,
|
||||
SOCIAL_POST_WITH_COVER_SKILL_CONTENT,
|
||||
),
|
||||
]
|
||||
@@ -135,13 +132,13 @@ mod tests {
|
||||
fn should_install_default_skill_when_missing() {
|
||||
let temp = tempfile::tempdir().expect("create temp dir");
|
||||
let installed = ensure_default_local_skills_in_home(temp.path()).expect("install");
|
||||
assert!(installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_NAME.to_string()));
|
||||
assert!(installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY.to_string()));
|
||||
|
||||
let skill_md_path = temp
|
||||
.path()
|
||||
.join(".proxycast")
|
||||
.join("skills")
|
||||
.join(SOCIAL_POST_WITH_COVER_SKILL_NAME)
|
||||
.join(SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY)
|
||||
.join("SKILL.md");
|
||||
assert!(skill_md_path.exists());
|
||||
}
|
||||
@@ -153,7 +150,7 @@ mod tests {
|
||||
.path()
|
||||
.join(".proxycast")
|
||||
.join("skills")
|
||||
.join(SOCIAL_POST_WITH_COVER_SKILL_NAME);
|
||||
.join(SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY);
|
||||
fs::create_dir_all(&skill_dir).expect("create skill dir");
|
||||
let skill_md_path = skill_dir.join("SKILL.md");
|
||||
// 无版本号的自定义内容不应被覆盖
|
||||
@@ -162,7 +159,7 @@ mod tests {
|
||||
|
||||
let installed = ensure_default_local_skills_in_home(temp.path()).expect("install");
|
||||
assert!(
|
||||
!installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_NAME.to_string()),
|
||||
!installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY.to_string()),
|
||||
"无版本信息的已存在 skill 不应被重新安装"
|
||||
);
|
||||
|
||||
@@ -177,7 +174,7 @@ mod tests {
|
||||
.path()
|
||||
.join(".proxycast")
|
||||
.join("skills")
|
||||
.join(SOCIAL_POST_WITH_COVER_SKILL_NAME);
|
||||
.join(SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY);
|
||||
fs::create_dir_all(&skill_dir).expect("create skill dir");
|
||||
let skill_md_path = skill_dir.join("SKILL.md");
|
||||
// 旧版本内容
|
||||
@@ -186,7 +183,7 @@ mod tests {
|
||||
|
||||
let installed = ensure_default_local_skills_in_home(temp.path()).expect("install");
|
||||
assert!(
|
||||
installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_NAME.to_string()),
|
||||
installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY.to_string()),
|
||||
"内置版本更新时应自动升级"
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user