feat: 专家配置保存后即时生效,无需重启网关

experts/mod.rs: config 改为 RwLock 支持 update_config 运行时更新; ExpertWithStatus 增加 body 字段供前端编辑模态框直接显示; 空 sources 时不再兜底返回 [User,Project],尊重用户关闭所有源的意图。

gateway/http.rs: save_config 同步调用 experts.update_config,返回消息从'需要重启'改为'已保存'。

ConfigPage.tsx: 编辑模态框宽度扩大到 max-w-3xl,textarea 高度增加到 360px,提示文案明确正文与名称/描述的注入关系。
This commit is contained in:
oudecheng 2026-07-07 14:13:09 +08:00
parent fdd22556a1
commit 7eecd0b6bb
3 changed files with 40 additions and 18 deletions

View File

@ -80,6 +80,9 @@ impl From<ExpertScope> for ExpertSource {
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.
@ -217,7 +220,7 @@ impl ExpertCatalog {
/// patterns, and adds per-session expert selection persisted to `expert-state.json`.
#[derive(Debug)]
pub struct ExpertRuntime {
config: ExpertsConfig,
config: RwLock<ExpertsConfig>,
catalog: RwLock<ExpertCatalog>,
disable_state: RwLock<ExpertDisableState>,
/// session_id -> selected expert name
@ -228,7 +231,7 @@ pub struct ExpertRuntime {
impl Default for ExpertRuntime {
fn default() -> Self {
Self {
config: ExpertsConfig::default(),
config: RwLock::new(ExpertsConfig::default()),
catalog: RwLock::new(ExpertCatalog::default()),
disable_state: RwLock::new(ExpertDisableState::default()),
session_experts: RwLock::new(HashMap::new()),
@ -254,7 +257,7 @@ impl ExpertRuntime {
let session_experts = load_project_session_experts(&cwd);
Self {
config,
config: RwLock::new(config),
catalog: RwLock::new(catalog),
disable_state: RwLock::new(disable_state),
session_experts: RwLock::new(session_experts),
@ -264,8 +267,9 @@ impl ExpertRuntime {
/// 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(
&self.config,
&config,
&self.cwd,
Some(&load_expert_disable_state(&self.cwd)),
);
@ -274,6 +278,17 @@ impl ExpertRuntime {
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
@ -285,7 +300,8 @@ impl ExpertRuntime {
/// List all discovered experts including disabled ones, with their disabled scopes.
pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> {
let catalog = ExpertCatalog::discover_without_state(&self.config, &self.cwd);
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
@ -296,6 +312,7 @@ impl ExpertRuntime {
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(),
@ -404,7 +421,8 @@ impl ExpertRuntime {
pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> {
validate_expert_name(name)?;
let catalog = ExpertCatalog::discover_without_state(&self.config, &self.cwd);
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())
}
@ -652,13 +670,12 @@ fn source_order(sources: &[String]) -> Vec<ExpertSource> {
}
}
// Default order: user then project (project overrides user)
if result.is_empty() {
vec![ExpertSource::User, ExpertSource::Project]
} else {
// 注意:空 sources 时不再兜底返回 [User, Project]。
// 用户在前端关闭所有源时sources 会变为空数组,此时应尊重用户意图,
// 不发现任何专家。配置文件缺失 sources 字段时default_experts_sources()
// 已经返回 ["user", "project"],不会走到这里。
result
}
}
fn source_root(source: &ExpertSource, cwd: &Path) -> Option<PathBuf> {
match source {

View File

@ -149,11 +149,16 @@ pub async fn save_config(
*cfg = new_config.clone();
}
// 同步更新 ExpertRuntime 的 config让 sources 等变更即时生效(无需重启)
if let Err(e) = state.experts.update_config(new_config.experts.clone()) {
tracing::warn!(error = %e, "Failed to sync experts config after save_config");
}
tracing::info!(path = %config_path.display(), "Config saved via API");
Ok(Json(SaveConfigResponse {
success: true,
message: "配置已保存,需要重启服务才能生效".to_string(),
message: "配置已保存".to_string(),
config_path: config_path.to_string_lossy().to_string(),
}))
}

View File

@ -1206,7 +1206,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl">
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-6 w-[90%] max-w-lg mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out]">
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-6 w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out]">
<div className="flex items-center gap-2 mb-4">
<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />
<h3 className="text-sm font-semibold text-[var(--text-primary)]">
@ -1214,7 +1214,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
</h3>
</div>
<div className="space-y-3">
<Field label="名称">
<Field label="名称" hint="创建后不可修改。仅当正文为空时,与描述一起生成兜底提示词;正文非空时不注入">
<input
value={editingExpert.nameField}
onChange={e => setEditingExpert(prev => prev ? { ...prev, nameField: e.target.value } : prev)}
@ -1224,7 +1224,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
autoFocus={!isEdit}
/>
</Field>
<Field label="描述" hint="必填,简要说明专家身份">
<Field label="描述" hint="必填。仅当正文为空时,与名称一起生成兜底提示词;正文非空时不注入">
<input
value={editingExpert.description}
onChange={e => setEditingExpert(prev => prev ? { ...prev, description: e.target.value } : prev)}
@ -1232,12 +1232,12 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
className={inputCls}
/>
</Field>
<Field label="专家提示词正文" hint="markdown 格式,将作为系统提示词注入">
<Field label="专家提示词正文" hint="markdown 格式。非空时仅注入正文(不注入名称和描述);为空时自动用“名称+描述”生成兜底提示词">
<textarea
value={editingExpert.body}
onChange={e => setEditingExpert(prev => prev ? { ...prev, body: e.target.value } : prev)}
placeholder="你是一名专业翻译..."
className={inputCls + ' min-h-[200px] resize-y font-mono text-xs'}
className={inputCls + ' min-h-[360px] resize-y font-mono text-xs'}
/>
</Field>
{editingExpertError && (