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:
coso
2026-03-12 00:34:23 +08:00
co-authored by Claude Opus 4.6
parent bfacf764b7
commit 2aa83ee7fc
268 changed files with 21727 additions and 6006 deletions
+68 -1
View File
@@ -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);
}
}
+82 -2
View File
@@ -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);
}
}
+9 -2
View File
@@ -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,
+9 -2
View File
@@ -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
+47
View File
@@ -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::*;
+1
View File
@@ -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) {
+11 -10
View File
@@ -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
+10 -2
View File
@@ -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,
+17 -3
View File
@@ -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,
);
+3
View File
@@ -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(),
+45 -12
View File
@@ -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")));
}
}
+1
View File
@@ -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());
}
}
+23 -26
View File
@@ -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()),
"内置版本更新时应自动升级"
);