oudecheng 0a8d21fe40 fix: 修复专家发现与前端选择多项问题
后端:
- split_frontmatter 兼容 CRLF/LF 行尾符,修复 Windows 下 EXPERT.md 解析失败
- Config::load_from 移除 fallback 到 cwd/config.json 的逻辑,避免静默加载项目目录配置导致 experts 字段缺失
- ExpertRuntime 新增 update_config 方法,save_config 时同步更新 ExpertRuntime 内部 config,sources 变更即时生效
- source_order 空数组不再兜底返回 [User, Project],尊重用户关闭所有源的意图
- from_config_with_cwd 增加诊断日志,输出 enabled/sources/cwd/discovered

前端:
- ExpertSelector 弹窗改为按钮上方居中展开
- 专家卡片 name 完整显示(break-all),路径显示在卡片底部
- "管理专家"入口仅在无专家时显示
- 每次打开下拉都重新拉取列表和选中状态
- 监听设置弹窗关闭事件刷新已选专家(处理已选专家被禁用的情况)
2026-07-08 14:53:30 +08:00

1521 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::config::ExpertsConfig;
use crate::platform::{atomic_rename, home_dir as platform_home_dir};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
#[cfg(test)]
static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) fn acquire_expert_test_env_lock() -> std::sync::MutexGuard<'static, ()> {
EXPERT_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner())
}
/// A discovered expert definition.
#[derive(Debug, Clone)]
pub struct Expert {
pub name: String,
pub description: String,
pub body: String,
pub source: ExpertSource,
pub path: PathBuf,
}
/// Where an expert definition was discovered from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExpertSource {
User,
Project,
Custom(String),
}
impl ExpertSource {
pub fn as_str(&self) -> &str {
match self {
ExpertSource::User => "user",
ExpertSource::Project => "project",
ExpertSource::Custom(path) => path.as_str(),
}
}
}
/// The scope an expert is addressed by for enable/disable and CRUD operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExpertScope {
User,
Project,
}
impl ExpertScope {
pub fn parse(value: &str) -> Option<Self> {
match value {
"user" => Some(Self::User),
"project" => Some(Self::Project),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Project => "project",
}
}
}
impl From<ExpertScope> for ExpertSource {
fn from(value: ExpertScope) -> Self {
match value {
ExpertScope::User => ExpertSource::User,
ExpertScope::Project => ExpertSource::Project,
}
}
}
/// An expert entry with its disabled status across scopes (for the settings page).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExpertWithStatus {
pub name: String,
pub description: String,
/// 专家提示词正文。列表 API 返回它是为了让前端编辑模态框
/// 能直接显示已有正文,无需再发一次详情请求。
pub body: String,
pub source: String,
pub path: String,
/// Which scopes have this expert disabled. Empty means enabled.
pub disabled_in_scopes: Vec<String>,
}
/// Result of an enable/disable operation.
#[derive(Debug, Clone)]
pub struct ExpertAvailabilityChange {
pub name: String,
pub scope: ExpertScope,
pub changed: bool,
pub disabled_in_scopes: Vec<ExpertScope>,
pub available: bool,
}
/// The persisted expert state file (`expert-state.json`).
///
/// Holds both the per-session selected expert and the disabled experts list.
/// There is one file per scope (user/project).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExpertStateFile {
#[serde(default)]
pub session_experts: HashMap<String, String>,
#[serde(default)]
pub disabled_experts: Vec<String>,
}
/// Merged disable state across user + project scopes.
#[derive(Debug, Clone, Default)]
struct ExpertDisableState {
user_disabled: HashSet<String>,
project_disabled: HashSet<String>,
}
impl ExpertDisableState {
fn is_disabled(&self, name: &str) -> bool {
self.user_disabled.contains(name) || self.project_disabled.contains(name)
}
fn disabled_scopes_for(&self, name: &str) -> Vec<ExpertScope> {
let mut scopes = Vec::new();
if self.user_disabled.contains(name) {
scopes.push(ExpertScope::User);
}
if self.project_disabled.contains(name) {
scopes.push(ExpertScope::Project);
}
scopes
}
}
/// In-memory catalog of discovered experts.
#[derive(Debug, Clone, Default)]
pub struct ExpertCatalog {
experts: Vec<Expert>,
}
impl ExpertCatalog {
pub fn discover(config: &ExpertsConfig) -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let disable_state = load_expert_disable_state(&cwd);
Self::discover_with_state(config, &cwd, Some(&disable_state))
}
fn discover_without_state(config: &ExpertsConfig, cwd: &Path) -> Self {
Self::discover_with_state(config, cwd, None)
}
fn discover_with_state(
config: &ExpertsConfig,
cwd: &Path,
disable_state: Option<&ExpertDisableState>,
) -> Self {
if !config.enabled {
return Self::default();
}
let mut merged: HashMap<String, Expert> = HashMap::new();
let mut sources_seen = 0usize;
// Load from least specific to most specific so later sources win on conflicts.
for source in source_order(&config.sources) {
sources_seen += 1;
let root = source_root(&source, cwd);
let Some(root) = root else { continue };
for expert in load_experts_from_root(&root, source.clone()) {
if let Some(existing) = merged.get(&expert.name) {
tracing::warn!(
expert = %expert.name,
old_source = %existing.source.as_str(),
new_source = %expert.source.as_str(),
"Duplicate expert name found; overriding with later source"
);
}
merged.insert(expert.name.clone(), expert);
}
}
let mut experts: Vec<Expert> = merged.into_values().collect();
if let Some(disable_state) = disable_state {
experts.retain(|expert| !disable_state.is_disabled(&expert.name));
}
experts.sort_by(|a, b| a.name.cmp(&b.name));
tracing::info!(
sources_seen,
discovered = experts.len(),
"Experts discovery completed"
);
Self { experts }
}
pub fn is_empty(&self) -> bool {
self.experts.is_empty()
}
pub fn len(&self) -> usize {
self.experts.len()
}
pub fn find_expert(&self, name: &str) -> Option<&Expert> {
self.experts.iter().find(|e| e.name == name)
}
pub fn all(&self) -> Vec<Expert> {
self.experts.clone()
}
}
/// Expert runtime: manages discovery, disable state, and per-session selection.
///
/// Mirrors `SkillRuntime` (discovery + CRUD) and `SubagentRuntime` (disable state)
/// patterns, and adds per-session expert selection persisted to `expert-state.json`.
#[derive(Debug)]
pub struct ExpertRuntime {
config: RwLock<ExpertsConfig>,
catalog: RwLock<ExpertCatalog>,
disable_state: RwLock<ExpertDisableState>,
/// session_id -> selected expert name
session_experts: RwLock<HashMap<String, String>>,
cwd: PathBuf,
}
impl Default for ExpertRuntime {
fn default() -> Self {
Self {
config: RwLock::new(ExpertsConfig::default()),
catalog: RwLock::new(ExpertCatalog::default()),
disable_state: RwLock::new(ExpertDisableState::default()),
session_experts: RwLock::new(HashMap::new()),
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
}
}
}
impl ExpertRuntime {
pub fn from_config(config: ExpertsConfig) -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Self::from_config_with_cwd(config, cwd)
}
fn from_config_with_cwd(config: ExpertsConfig, cwd: PathBuf) -> Self {
let catalog = ExpertCatalog::discover_with_state(
&config,
&cwd,
Some(&load_expert_disable_state(&cwd)),
);
let disable_state = load_expert_disable_state(&cwd);
// session selections are persisted in the project-scope state file
let session_experts = load_project_session_experts(&cwd);
tracing::info!(
enabled = config.enabled,
sources = ?config.sources,
cwd = %cwd.display(),
discovered = catalog.len(),
"ExpertRuntime initialized"
);
Self {
config: RwLock::new(config),
catalog: RwLock::new(catalog),
disable_state: RwLock::new(disable_state),
session_experts: RwLock::new(session_experts),
cwd,
}
}
/// Re-discover experts from the filesystem.
pub fn reload(&self) -> Result<ExpertCatalog, String> {
let config = self.config.read().expect("experts config rwlock poisoned").clone();
let catalog = ExpertCatalog::discover_with_state(
&config,
&self.cwd,
Some(&load_expert_disable_state(&self.cwd)),
);
let mut guard = self.catalog.write().expect("experts catalog rwlock poisoned");
*guard = catalog.clone();
Ok(catalog)
}
/// 运行时更新 experts 配置sources 等),并立即重新发现专家。
/// 用于前端保存配置后即时生效,无需重启网关。
pub fn update_config(&self, new_config: ExpertsConfig) -> Result<(), String> {
{
let mut guard = self.config.write().expect("experts config rwlock poisoned");
*guard = new_config;
}
self.reload()?;
Ok(())
}
/// List enabled experts (disabled ones are filtered out).
pub fn list_experts(&self) -> Vec<Expert> {
self.catalog
.read()
.expect("experts catalog rwlock poisoned")
.experts
.clone()
}
/// List all discovered experts including disabled ones, with their disabled scopes.
pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> {
let config = self.config.read().expect("experts config rwlock poisoned").clone();
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
let disable_state = load_expert_disable_state(&self.cwd);
let mut items: Vec<ExpertWithStatus> = catalog
.experts
.iter()
.map(|expert| {
let scopes = disable_state.disabled_scopes_for(&expert.name);
ExpertWithStatus {
name: expert.name.clone(),
description: expert.description.clone(),
body: expert.body.clone(),
source: expert.source.as_str().to_string(),
path: expert.path.display().to_string(),
disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(),
}
})
.collect();
items.sort_by(|a, b| a.name.cmp(&b.name));
items
}
pub fn get_expert(&self, name: &str) -> Option<Expert> {
self.catalog
.read()
.expect("experts catalog rwlock poisoned")
.find_expert(name)
.cloned()
}
pub fn create_expert(
&self,
scope: ExpertScope,
name: &str,
description: &str,
body: &str,
reload: bool,
) -> Result<Expert, String> {
validate_expert_name(name)?;
let path = expert_file_path(scope, name, &self.cwd)?;
if path.exists() {
return Err(format!(
"expert '{}' already exists at {}",
name,
path.display()
));
}
write_expert_file(&path, name, description, body)?;
let expert = parse_expert_file(&path, scope.into())?;
if reload {
let _ = self.reload()?;
}
Ok(expert)
}
pub fn update_expert(
&self,
scope: ExpertScope,
name: &str,
description: Option<&str>,
body: Option<&str>,
reload: bool,
) -> Result<Expert, String> {
validate_expert_name(name)?;
let path = expert_file_path(scope, name, &self.cwd)?;
if !path.exists() {
return Err(format!("expert '{}' not found at {}", name, path.display()));
}
let existing = parse_expert_file(&path, scope.into())?;
let next_description = description.unwrap_or(&existing.description);
let next_body = body.unwrap_or(&existing.body);
write_expert_file(&path, name, next_description, next_body)?;
let expert = parse_expert_file(&path, scope.into())?;
if reload {
let _ = self.reload()?;
}
Ok(expert)
}
pub fn delete_expert(
&self,
scope: ExpertScope,
name: &str,
reload: bool,
) -> Result<PathBuf, String> {
validate_expert_name(name)?;
let dir = expert_dir_path(scope, name, &self.cwd)?;
if !dir.exists() {
return Err(format!("expert '{}' not found at {}", name, dir.display()));
}
fs::remove_dir_all(&dir)
.map_err(|err| format!("failed to delete expert directory: {}", err))?;
if reload {
let _ = self.reload()?;
}
Ok(dir)
}
pub fn disable_expert(
&self,
scope: ExpertScope,
name: &str,
) -> Result<ExpertAvailabilityChange, String> {
self.set_expert_enabled(scope, name, false)
}
pub fn enable_expert(
&self,
scope: ExpertScope,
name: &str,
) -> Result<ExpertAvailabilityChange, String> {
self.set_expert_enabled(scope, name, true)
}
pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> {
validate_expert_name(name)?;
let config = self.config.read().expect("experts config rwlock poisoned").clone();
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
Ok(catalog.find_expert(name).is_some())
}
fn set_expert_enabled(
&self,
scope: ExpertScope,
name: &str,
enabled: bool,
) -> Result<ExpertAvailabilityChange, String> {
validate_expert_name(name)?;
if !self.has_expert_definition(name)? {
return Err(format!("expert '{}' not found", name));
}
let state_path = expert_state_path(scope, &self.cwd);
let mut state_file = load_expert_state_file(&state_path)?;
let mut disabled: HashSet<String> = state_file.disabled_experts.into_iter().collect();
let changed = if enabled {
disabled.remove(name)
} else {
disabled.insert(name.to_string())
};
let mut disabled_list: Vec<String> = disabled.into_iter().collect();
disabled_list.sort();
state_file.disabled_experts = disabled_list;
save_expert_state_file(&state_path, &state_file)?;
// update in-memory disable_state
{
let mut state = self
.disable_state
.write()
.expect("experts disable_state rwlock poisoned");
match scope {
ExpertScope::User => {
if enabled {
state.user_disabled.remove(name);
} else {
state.user_disabled.insert(name.to_string());
}
}
ExpertScope::Project => {
if enabled {
state.project_disabled.remove(name);
} else {
state.project_disabled.insert(name.to_string());
}
}
}
}
// refresh catalog so list_experts / get_expert reflect the change
let _ = self.reload()?;
let state = self
.disable_state
.read()
.expect("experts disable_state rwlock poisoned");
let disabled_in_scopes = state.disabled_scopes_for(name);
Ok(ExpertAvailabilityChange {
name: name.to_string(),
scope,
changed,
available: disabled_in_scopes.is_empty(),
disabled_in_scopes,
})
}
/// Select an expert for a session. Persists to the project-scope state file.
pub fn select_expert(&self, session_id: &str, expert_name: &str) -> Result<(), String> {
if session_id.trim().is_empty() {
return Err("session_id cannot be empty".to_string());
}
// The expert must exist (and not be disabled) for selection to be meaningful.
if self.get_expert(expert_name).is_none() {
return Err(format!(
"expert '{}' not found or disabled",
expert_name
));
}
{
let mut sessions = self
.session_experts
.write()
.expect("experts session_experts rwlock poisoned");
sessions.insert(session_id.to_string(), expert_name.to_string());
}
persist_session_experts(&self.cwd, |state| {
state
.session_experts
.insert(session_id.to_string(), expert_name.to_string());
})
}
/// Clear the selected expert for a session.
pub fn clear_expert(&self, session_id: &str) -> Result<(), String> {
{
let mut sessions = self
.session_experts
.write()
.expect("experts session_experts rwlock poisoned");
sessions.remove(session_id);
}
persist_session_experts(&self.cwd, |state| {
state.session_experts.remove(session_id);
})
}
/// Returns the expert selected for a session, or None if none selected / disabled / not found.
pub fn selected_expert_for(&self, session_id: &str) -> Option<Expert> {
let name = {
let sessions = self
.session_experts
.read()
.expect("experts session_experts rwlock poisoned");
sessions.get(session_id).cloned()
}?;
// Filter out disabled experts.
let state = self
.disable_state
.read()
.expect("experts disable_state rwlock poisoned");
if state.is_disabled(&name) {
return None;
}
self.get_expert(&name)
}
/// Returns just the name of the selected expert (for API responses).
pub fn selected_expert_name_for(&self, session_id: &str) -> Option<String> {
self.selected_expert_for(session_id).map(|e| e.name)
}
}
// ========== ExpertPromptProvider ==========
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
/// Injects the currently-selected expert's body as a system prompt fragment.
///
/// Looks up the selection via the shared `ExpertRuntime` using
/// `context.session_id`. Returns `None` when no expert is selected or the
/// selected expert is disabled/missing.
pub struct ExpertPromptProvider {
runtime: Arc<ExpertRuntime>,
}
impl ExpertPromptProvider {
pub fn new(runtime: Arc<ExpertRuntime>) -> Self {
Self { runtime }
}
}
impl SystemPromptProvider for ExpertPromptProvider {
fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> {
let session_id = context.session_id.as_ref()?;
let expert = self.runtime.selected_expert_for(session_id)?;
let content = if expert.body.trim().is_empty() {
// Empty body is OK; inject a header so the LLM still knows the role.
format!(
"# 专家角色: {}\n\n{}",
expert.name, expert.description
)
} else {
expert.body.clone()
};
Some(SystemPrompt {
content,
context: Some("expert".to_string()),
})
}
}
// ========== Path helpers ==========
fn user_experts_root() -> Option<PathBuf> {
platform_home_dir().map(|p| p.join(".picobot").join("experts"))
}
fn project_experts_root(cwd: &Path) -> PathBuf {
cwd.join(".picobot").join("experts")
}
fn user_expert_state_path() -> Option<PathBuf> {
platform_home_dir().map(|p| p.join(".picobot").join("expert-state.json"))
}
fn project_expert_state_path(cwd: &Path) -> PathBuf {
cwd.join(".picobot").join("expert-state.json")
}
fn expert_state_path(scope: ExpertScope, cwd: &Path) -> PathBuf {
match scope {
ExpertScope::User => user_expert_state_path()
.unwrap_or_else(|| cwd.join(".picobot").join("expert-state.json")),
ExpertScope::Project => project_expert_state_path(cwd),
}
}
fn root_for_scope(scope: ExpertScope, cwd: &Path) -> Result<PathBuf, String> {
match scope {
ExpertScope::User => user_experts_root()
.ok_or_else(|| "failed to resolve home directory".to_string()),
ExpertScope::Project => Ok(project_experts_root(cwd)),
}
}
fn expert_dir_path(scope: ExpertScope, name: &str, cwd: &Path) -> Result<PathBuf, String> {
Ok(root_for_scope(scope, cwd)?.join(name))
}
fn expert_file_path(scope: ExpertScope, name: &str, cwd: &Path) -> Result<PathBuf, String> {
Ok(expert_dir_path(scope, name, cwd)?.join("EXPERT.md"))
}
// ========== Source ordering ==========
fn source_order(sources: &[String]) -> Vec<ExpertSource> {
let mut result = Vec::new();
for source in sources {
match source.as_str() {
"user" => {
if !result.contains(&ExpertSource::User) {
result.push(ExpertSource::User);
}
}
"project" => {
if !result.contains(&ExpertSource::Project) {
result.push(ExpertSource::Project);
}
}
unknown => {
let custom = ExpertSource::Custom(unknown.to_string());
if !result.contains(&custom) {
result.push(custom);
}
}
}
}
// 注意:空 sources 时不再兜底返回 [User, Project]。
// 用户在前端关闭所有源时sources 会变为空数组,此时应尊重用户意图,
// 不发现任何专家。配置文件缺失 sources 字段时default_experts_sources()
// 已经返回 ["user", "project"],不会走到这里。
result
}
fn source_root(source: &ExpertSource, cwd: &Path) -> Option<PathBuf> {
match source {
ExpertSource::User => user_experts_root(),
ExpertSource::Project => Some(project_experts_root(cwd)),
ExpertSource::Custom(path) => {
let p = PathBuf::from(path);
if p.is_absolute() {
Some(p)
} else {
tracing::warn!(path = %path, "Custom experts source must be an absolute path, skipping");
None
}
}
}
}
// ========== Validation ==========
fn validate_expert_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("expert name cannot be empty".to_string());
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err("expert name must not contain path separators or '..'".to_string());
}
Ok(())
}
// ========== File I/O ==========
#[derive(Debug, Deserialize)]
struct ExpertFrontmatter {
description: String,
#[serde(default)]
name: Option<String>,
}
fn render_expert_file(name: &str, description: &str, body: &str) -> Result<String, String> {
if description.trim().is_empty() {
return Err("description is required and cannot be empty".to_string());
}
#[derive(serde::Serialize)]
struct ExpertFrontmatterOwned {
name: String,
description: String,
}
let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned {
name: name.to_string(),
description: description.to_string(),
})
.map_err(|err| format!("failed to render expert frontmatter: {}", err))?;
let yaml = yaml.trim_start_matches("---\n");
let body = body.trim();
if body.is_empty() {
Ok(format!("---\n{}---\n", yaml))
} else {
Ok(format!("---\n{}---\n{}\n", yaml, body))
}
}
fn write_expert_file(path: &Path, name: &str, description: &str, body: &str) -> Result<(), String> {
let content = render_expert_file(name, description, body)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create expert directory: {}", err))?;
}
fs::write(path, content).map_err(|err| format!("failed to write expert file: {}", err))
}
fn load_experts_from_root(root: &Path, source: ExpertSource) -> Vec<Expert> {
let mut out = Vec::new();
if !root.exists() {
return out;
}
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(err) => {
tracing::warn!(path = %root.display(), error = %err, "Failed to read experts directory");
return out;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let expert_md = path.join("EXPERT.md");
if !expert_md.exists() {
continue;
}
match parse_expert_file(&expert_md, source.clone()) {
Ok(expert) => out.push(expert),
Err(err) => {
tracing::warn!(path = %expert_md.display(), error = %err, "Skipping invalid expert file");
}
}
}
out
}
fn parse_expert_file(path: &Path, source: ExpertSource) -> Result<Expert, String> {
let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?;
let (frontmatter_raw, body) =
split_frontmatter(&content).ok_or_else(|| "missing YAML frontmatter block".to_string())?;
let frontmatter: ExpertFrontmatter = serde_yaml::from_str(frontmatter_raw)
.map_err(|e| format!("invalid YAML frontmatter: {}", e))?;
let description = frontmatter.description.trim();
if description.is_empty() {
return Err("description is required and cannot be empty".to_string());
}
let dir_name = path
.parent()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown-expert".to_string());
let name = frontmatter.name.unwrap_or(dir_name).trim().to_string();
Ok(Expert {
name,
description: description.to_string(),
body: body.trim().to_string(),
source,
path: path.to_path_buf(),
})
}
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
// 兼容 CRLFWindows和 LFUnix行尾符
let rest = content
.strip_prefix("---\n")
.or_else(|| content.strip_prefix("---\r\n"))?;
let marker = "\n---\n";
let marker_crlf = "\n---\r\n";
if let Some(idx) = rest.find(marker) {
let frontmatter = &rest[..idx];
let body = &rest[idx + marker.len()..];
Some((frontmatter, body))
} else if let Some(idx) = rest.find(marker_crlf) {
let frontmatter = &rest[..idx];
let body = &rest[idx + marker_crlf.len()..];
Some((frontmatter, body))
} else {
None
}
}
// ========== State file I/O ==========
fn load_expert_disable_state(cwd: &Path) -> ExpertDisableState {
ExpertDisableState {
user_disabled: user_expert_state_path()
.map(|path| load_disabled_expert_names(&path))
.unwrap_or_default(),
project_disabled: load_disabled_expert_names(&project_expert_state_path(cwd)),
}
}
fn load_disabled_expert_names(path: &Path) -> HashSet<String> {
match load_expert_state_file(path) {
Ok(state) => state
.disabled_experts
.into_iter()
.filter_map(|name| normalize_expert_name(name, path))
.collect(),
Err(err) => {
tracing::warn!(path = %path.display(), error = %err, "Failed to load expert state file");
HashSet::new()
}
}
}
fn normalize_expert_name(name: String, path: &Path) -> Option<String> {
let trimmed = name.trim();
match validate_expert_name(trimmed) {
Ok(()) => Some(trimmed.to_string()),
Err(err) => {
tracing::warn!(path = %path.display(), expert = %name, error = %err, "Ignoring invalid disabled expert entry");
None
}
}
}
fn load_expert_state_file(path: &Path) -> Result<ExpertStateFile, String> {
if !path.exists() {
return Ok(ExpertStateFile::default());
}
let content = fs::read_to_string(path)
.map_err(|err| format!("failed to read expert state file: {}", err))?;
serde_json::from_str(&content)
.map_err(|err| format!("failed to parse expert state file: {}", err))
}
fn save_expert_state_file(path: &Path, state: &ExpertStateFile) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create expert state directory: {}", err))?;
}
let content = serde_json::to_string_pretty(state)
.map_err(|err| format!("failed to render expert state file: {}", err))?;
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, format!("{}\n", content))
.map_err(|err| format!("failed to write temporary expert state file: {}", err))?;
atomic_rename(&tmp_path, path)
.map_err(|err| format!("failed to persist expert state file: {}", err))
}
/// Load only the session_experts map from the project-scope state file (used at startup).
fn load_project_session_experts(cwd: &Path) -> HashMap<String, String> {
let path = project_expert_state_path(cwd);
match load_expert_state_file(&path) {
Ok(state) => state.session_experts,
Err(err) => {
tracing::warn!(path = %path.display(), error = %err, "Failed to load project expert state for session_experts");
HashMap::new()
}
}
}
/// Persist a mutation to the project-scope state file's session_experts while
/// preserving the existing disabled_experts field.
fn persist_session_experts<F: FnOnce(&mut ExpertStateFile)>(cwd: &Path, mutate: F) -> Result<(), String> {
let path = project_expert_state_path(cwd);
let mut state = load_expert_state_file(&path)?;
mutate(&mut state);
save_expert_state_file(&path, &state)
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
struct CurrentDirGuard {
previous: PathBuf,
}
struct HomeDirGuard {
previous: Option<OsString>,
previous_userprofile: Option<OsString>,
}
impl CurrentDirGuard {
fn enter(path: &Path) -> Self {
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(path).unwrap();
Self { previous }
}
}
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.previous);
}
}
impl HomeDirGuard {
fn enter(path: &Path) -> Self {
let home_backup = std::env::var_os("HOME");
let userprofile_backup = std::env::var_os("USERPROFILE");
unsafe {
std::env::set_var("HOME", path);
std::env::set_var("USERPROFILE", path);
}
Self {
previous: home_backup,
previous_userprofile: userprofile_backup,
}
}
}
impl Drop for HomeDirGuard {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match &self.previous_userprofile {
Some(value) => std::env::set_var("USERPROFILE", value),
None => std::env::remove_var("USERPROFILE"),
}
}
}
}
fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> {
acquire_expert_test_env_lock()
}
#[test]
fn test_split_frontmatter() {
let input = "---\ndescription: demo\n---\nhello";
let (fm, body) = split_frontmatter(input).unwrap();
assert!(fm.contains("description"));
assert_eq!(body, "hello");
}
#[test]
fn test_parse_expert_file_requires_description() {
let dir = tempfile::tempdir().unwrap();
let expert_dir = dir.path().join("demo");
fs::create_dir_all(&expert_dir).unwrap();
let expert_md = expert_dir.join("EXPERT.md");
fs::write(&expert_md, "---\nname: demo\n---\ncontent").unwrap();
let err = parse_expert_file(&expert_md, ExpertSource::Project).unwrap_err();
assert!(err.contains("description"));
}
#[test]
fn test_parse_expert_file_falls_back_to_dir_name() {
let dir = tempfile::tempdir().unwrap();
let expert_dir = dir.path().join("from-dir");
fs::create_dir_all(&expert_dir).unwrap();
let expert_md = expert_dir.join("EXPERT.md");
fs::write(
&expert_md,
"---\ndescription: demo expert\n---\nYou are a translator.",
)
.unwrap();
let expert = parse_expert_file(&expert_md, ExpertSource::Project).unwrap();
assert_eq!(expert.name, "from-dir");
assert_eq!(expert.description, "demo expert");
assert_eq!(expert.body, "You are a translator.");
}
#[test]
fn test_render_expert_file_requires_description() {
let err = render_expert_file("demo", " ", "body").unwrap_err();
assert!(err.contains("description"));
}
#[test]
fn test_discover_prefers_project_over_user() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
// user scope
let user_dir = home_dir.join(".picobot").join("experts").join("demo");
fs::create_dir_all(&user_dir).unwrap();
fs::write(
user_dir.join("EXPERT.md"),
"---\ndescription: user version\n---\nUser body",
)
.unwrap();
// project scope (overrides user)
let project_dir_expert = project_dir
.join(".picobot")
.join("experts")
.join("demo");
fs::create_dir_all(&project_dir_expert).unwrap();
fs::write(
project_dir_expert.join("EXPERT.md"),
"---\ndescription: project version\n---\nProject body",
)
.unwrap();
let catalog = ExpertCatalog::discover(&ExpertsConfig {
enabled: true,
sources: vec!["user".to_string(), "project".to_string()],
});
assert_eq!(catalog.len(), 1);
let expert = catalog.find_expert("demo").unwrap();
assert_eq!(expert.source, ExpertSource::Project);
assert_eq!(expert.description, "project version");
assert_eq!(expert.body, "Project body");
}
#[test]
fn test_create_update_delete_cycle() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let runtime = ExpertRuntime::from_config(ExpertsConfig {
enabled: true,
sources: vec!["project".to_string()],
});
assert_eq!(runtime.list_experts().len(), 0);
let created = runtime
.create_expert(
ExpertScope::Project,
"translator",
"翻译专家",
"你是一名专业翻译。",
true,
)
.unwrap();
assert_eq!(created.name, "translator");
assert_eq!(runtime.list_experts().len(), 1);
// Duplicate create -> error
let dup = runtime.create_expert(
ExpertScope::Project,
"translator",
"dup",
"body",
true,
);
assert!(dup.is_err());
let updated = runtime
.update_expert(
ExpertScope::Project,
"translator",
Some("更新翻译专家"),
Some("你是一名中文教师。"),
true,
)
.unwrap();
assert_eq!(updated.description, "更新翻译专家");
assert_eq!(updated.body, "你是一名中文教师。");
// update with None preserves fields
let updated_none = runtime
.update_expert(ExpertScope::Project, "translator", None, None, true)
.unwrap();
assert_eq!(updated_none.description, "更新翻译专家");
assert_eq!(updated_none.body, "你是一名中文教师。");
let deleted_path = runtime
.delete_expert(ExpertScope::Project, "translator", true)
.unwrap();
assert!(!deleted_path.exists());
assert_eq!(runtime.list_experts().len(), 0);
}
#[test]
fn test_select_and_selected_expert_for() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let runtime = ExpertRuntime::from_config(ExpertsConfig {
enabled: true,
sources: vec!["project".to_string()],
});
runtime
.create_expert(
ExpertScope::Project,
"coder",
"编程专家",
"你是一名编程专家。",
true,
)
.unwrap();
// No selection initially
assert!(runtime.selected_expert_for("sess-1").is_none());
assert!(runtime.selected_expert_name_for("sess-1").is_none());
runtime.select_expert("sess-1", "coder").unwrap();
let selected = runtime.selected_expert_for("sess-1").unwrap();
assert_eq!(selected.name, "coder");
assert_eq!(
runtime.selected_expert_name_for("sess-1"),
Some("coder".to_string())
);
// persistence: reload from disk
let runtime2 = ExpertRuntime::from_config(ExpertsConfig {
enabled: true,
sources: vec!["project".to_string()],
});
assert_eq!(
runtime2.selected_expert_name_for("sess-1"),
Some("coder".to_string())
);
}
#[test]
fn test_clear_expert_removes_selection() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let runtime = ExpertRuntime::from_config(ExpertsConfig {
enabled: true,
sources: vec!["project".to_string()],
});
runtime
.create_expert(
ExpertScope::Project,
"writer",
"写作专家",
"你是一名写作专家。",
true,
)
.unwrap();
runtime.select_expert("sess-clear", "writer").unwrap();
assert!(runtime.selected_expert_for("sess-clear").is_some());
runtime.clear_expert("sess-clear").unwrap();
assert!(runtime.selected_expert_for("sess-clear").is_none());
// clear persists across reloads
let runtime2 = ExpertRuntime::from_config(ExpertsConfig {
enabled: true,
sources: vec!["project".to_string()],
});
assert!(runtime2.selected_expert_for("sess-clear").is_none());
}
#[test]
fn test_disable_expert_makes_selected_return_none() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let runtime = ExpertRuntime::from_config(ExpertsConfig {
enabled: true,
sources: vec!["project".to_string()],
});
runtime
.create_expert(
ExpertScope::Project,
"reviewer",
"代码审查专家",
"你是一名代码审查专家。",
true,
)
.unwrap();
runtime.select_expert("sess-disable", "reviewer").unwrap();
assert!(runtime.selected_expert_for("sess-disable").is_some());
let change = runtime
.disable_expert(ExpertScope::Project, "reviewer")
.unwrap();
assert!(change.changed);
assert!(!change.available);
assert_eq!(change.disabled_in_scopes, vec![ExpertScope::Project]);
// After disabling, selection no longer resolves.
assert!(runtime.selected_expert_for("sess-disable").is_none());
// Re-enable restores availability
let change = runtime
.enable_expert(ExpertScope::Project, "reviewer")
.unwrap();
assert!(change.changed);
assert!(change.available);
assert!(change.disabled_in_scopes.is_empty());
assert!(runtime.selected_expert_for("sess-disable").is_some());
}
#[test]
fn test_list_experts_with_status_includes_disabled() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let runtime = ExpertRuntime::from_config(ExpertsConfig {
enabled: true,
sources: vec!["project".to_string()],
});
runtime
.create_expert(
ExpertScope::Project,
"planner",
"规划专家",
"你是一名规划专家。",
true,
)
.unwrap();
// initially enabled
let items = runtime.list_experts_with_status();
assert_eq!(items.len(), 1);
assert!(items[0].disabled_in_scopes.is_empty());
// disable in project scope
runtime
.disable_expert(ExpertScope::Project, "planner")
.unwrap();
let items = runtime.list_experts_with_status();
assert_eq!(items.len(), 1, "list_experts_with_status should include disabled experts");
assert_eq!(items[0].name, "planner");
assert_eq!(items[0].disabled_in_scopes, vec!["project".to_string()]);
// list_experts (filtered) should be empty
let active = runtime.list_experts();
assert_eq!(active.len(), 0, "list_experts should filter out disabled experts");
}
#[test]
fn test_state_file_roundtrip_preserves_both_fields() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("expert-state.json");
let mut state = ExpertStateFile {
session_experts: HashMap::new(),
disabled_experts: vec!["demo".to_string()],
};
state.session_experts.insert("sess-1".to_string(), "demo".to_string());
save_expert_state_file(&path, &state).unwrap();
let loaded = load_expert_state_file(&path).unwrap();
assert_eq!(loaded, state);
}
#[test]
fn test_persist_session_experts_preserves_disabled() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
// Pre-populate the project state file with a disabled expert.
let state_path = project_dir.join(".picobot").join("expert-state.json");
fs::create_dir_all(state_path.parent().unwrap()).unwrap();
save_expert_state_file(
&state_path,
&ExpertStateFile {
session_experts: HashMap::new(),
disabled_experts: vec!["locked".to_string()],
},
)
.unwrap();
// Now persist a session selection; the disabled_experts field should survive.
persist_session_experts(&project_dir, |state| {
state
.session_experts
.insert("sess-x".to_string(), "translator".to_string());
})
.unwrap();
let loaded = load_expert_state_file(&state_path).unwrap();
assert_eq!(loaded.disabled_experts, vec!["locked".to_string()]);
assert_eq!(
loaded.session_experts.get("sess-x"),
Some(&"translator".to_string())
);
}
#[test]
fn test_prompt_provider_returns_none_without_selection() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let runtime = Arc::new(ExpertRuntime::from_config(ExpertsConfig {
enabled: true,
sources: vec!["project".to_string()],
}));
let provider = ExpertPromptProvider::new(runtime.clone());
// No session_id
let ctx = SystemPromptContext {
session_id: None,
chat_id: "chat".to_string(),
user_message_count: 1,
};
assert!(provider.build(&ctx).is_none());
// session_id present but no selection
let ctx = SystemPromptContext {
session_id: Some("no-selection".to_string()),
chat_id: "chat".to_string(),
user_message_count: 1,
};
assert!(provider.build(&ctx).is_none());
}
#[test]
fn test_prompt_provider_returns_body_when_selected() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let runtime = Arc::new(ExpertRuntime::from_config(ExpertsConfig {
enabled: true,
sources: vec!["project".to_string()],
}));
runtime
.create_expert(
ExpertScope::Project,
"teacher",
"教师专家",
"你是一名中文教师,请用中文回答。",
true,
)
.unwrap();
runtime.select_expert("sess-p", "teacher").unwrap();
let provider = ExpertPromptProvider::new(runtime.clone());
let ctx = SystemPromptContext {
session_id: Some("sess-p".to_string()),
chat_id: "chat".to_string(),
user_message_count: 1,
};
let prompt = provider.build(&ctx).unwrap();
assert_eq!(prompt.context, Some("expert".to_string()));
assert!(prompt.content.contains("中文教师"));
}
#[test]
fn test_prompt_provider_injects_header_when_body_empty() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let runtime = Arc::new(ExpertRuntime::from_config(ExpertsConfig {
enabled: true,
sources: vec!["project".to_string()],
}));
runtime
.create_expert(
ExpertScope::Project,
"empty-body",
"无 body 的专家",
"", // body empty
true,
)
.unwrap();
runtime.select_expert("sess-empty", "empty-body").unwrap();
let provider = ExpertPromptProvider::new(runtime.clone());
let ctx = SystemPromptContext {
session_id: Some("sess-empty".to_string()),
chat_id: "chat".to_string(),
user_message_count: 1,
};
let prompt = provider.build(&ctx).unwrap();
assert!(prompt.content.contains("# 专家角色: empty-body"));
assert!(prompt.content.contains("无 body 的专家"));
}
#[test]
fn test_disabled_config_returns_empty_catalog() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let project_expert_dir = project_dir.join(".picobot").join("experts").join("ignored");
fs::create_dir_all(&project_expert_dir).unwrap();
fs::write(
project_expert_dir.join("EXPERT.md"),
"---\ndescription: should be ignored\n---\nbody",
)
.unwrap();
let catalog = ExpertCatalog::discover(&ExpertsConfig {
enabled: false,
sources: vec!["project".to_string()],
});
assert_eq!(catalog.len(), 0);
}
}