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:
parent
fdd22556a1
commit
7eecd0b6bb
@ -80,6 +80,9 @@ impl From<ExpertScope> for ExpertSource {
|
|||||||
pub struct ExpertWithStatus {
|
pub struct ExpertWithStatus {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
/// 专家提示词正文。列表 API 返回它是为了让前端编辑模态框
|
||||||
|
/// 能直接显示已有正文,无需再发一次详情请求。
|
||||||
|
pub body: String,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
/// Which scopes have this expert disabled. Empty means enabled.
|
/// 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`.
|
/// patterns, and adds per-session expert selection persisted to `expert-state.json`.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ExpertRuntime {
|
pub struct ExpertRuntime {
|
||||||
config: ExpertsConfig,
|
config: RwLock<ExpertsConfig>,
|
||||||
catalog: RwLock<ExpertCatalog>,
|
catalog: RwLock<ExpertCatalog>,
|
||||||
disable_state: RwLock<ExpertDisableState>,
|
disable_state: RwLock<ExpertDisableState>,
|
||||||
/// session_id -> selected expert name
|
/// session_id -> selected expert name
|
||||||
@ -228,7 +231,7 @@ pub struct ExpertRuntime {
|
|||||||
impl Default for ExpertRuntime {
|
impl Default for ExpertRuntime {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
config: ExpertsConfig::default(),
|
config: RwLock::new(ExpertsConfig::default()),
|
||||||
catalog: RwLock::new(ExpertCatalog::default()),
|
catalog: RwLock::new(ExpertCatalog::default()),
|
||||||
disable_state: RwLock::new(ExpertDisableState::default()),
|
disable_state: RwLock::new(ExpertDisableState::default()),
|
||||||
session_experts: RwLock::new(HashMap::new()),
|
session_experts: RwLock::new(HashMap::new()),
|
||||||
@ -254,7 +257,7 @@ impl ExpertRuntime {
|
|||||||
let session_experts = load_project_session_experts(&cwd);
|
let session_experts = load_project_session_experts(&cwd);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config: RwLock::new(config),
|
||||||
catalog: RwLock::new(catalog),
|
catalog: RwLock::new(catalog),
|
||||||
disable_state: RwLock::new(disable_state),
|
disable_state: RwLock::new(disable_state),
|
||||||
session_experts: RwLock::new(session_experts),
|
session_experts: RwLock::new(session_experts),
|
||||||
@ -264,8 +267,9 @@ impl ExpertRuntime {
|
|||||||
|
|
||||||
/// Re-discover experts from the filesystem.
|
/// Re-discover experts from the filesystem.
|
||||||
pub fn reload(&self) -> Result<ExpertCatalog, String> {
|
pub fn reload(&self) -> Result<ExpertCatalog, String> {
|
||||||
|
let config = self.config.read().expect("experts config rwlock poisoned").clone();
|
||||||
let catalog = ExpertCatalog::discover_with_state(
|
let catalog = ExpertCatalog::discover_with_state(
|
||||||
&self.config,
|
&config,
|
||||||
&self.cwd,
|
&self.cwd,
|
||||||
Some(&load_expert_disable_state(&self.cwd)),
|
Some(&load_expert_disable_state(&self.cwd)),
|
||||||
);
|
);
|
||||||
@ -274,6 +278,17 @@ impl ExpertRuntime {
|
|||||||
Ok(catalog)
|
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).
|
/// List enabled experts (disabled ones are filtered out).
|
||||||
pub fn list_experts(&self) -> Vec<Expert> {
|
pub fn list_experts(&self) -> Vec<Expert> {
|
||||||
self.catalog
|
self.catalog
|
||||||
@ -285,7 +300,8 @@ impl ExpertRuntime {
|
|||||||
|
|
||||||
/// List all discovered experts including disabled ones, with their disabled scopes.
|
/// List all discovered experts including disabled ones, with their disabled scopes.
|
||||||
pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> {
|
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 disable_state = load_expert_disable_state(&self.cwd);
|
||||||
|
|
||||||
let mut items: Vec<ExpertWithStatus> = catalog
|
let mut items: Vec<ExpertWithStatus> = catalog
|
||||||
@ -296,6 +312,7 @@ impl ExpertRuntime {
|
|||||||
ExpertWithStatus {
|
ExpertWithStatus {
|
||||||
name: expert.name.clone(),
|
name: expert.name.clone(),
|
||||||
description: expert.description.clone(),
|
description: expert.description.clone(),
|
||||||
|
body: expert.body.clone(),
|
||||||
source: expert.source.as_str().to_string(),
|
source: expert.source.as_str().to_string(),
|
||||||
path: expert.path.display().to_string(),
|
path: expert.path.display().to_string(),
|
||||||
disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
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> {
|
pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> {
|
||||||
validate_expert_name(name)?;
|
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())
|
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)
|
// 注意:空 sources 时不再兜底返回 [User, Project]。
|
||||||
if result.is_empty() {
|
// 用户在前端关闭所有源时,sources 会变为空数组,此时应尊重用户意图,
|
||||||
vec![ExpertSource::User, ExpertSource::Project]
|
// 不发现任何专家。配置文件缺失 sources 字段时,default_experts_sources()
|
||||||
} else {
|
// 已经返回 ["user", "project"],不会走到这里。
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn source_root(source: &ExpertSource, cwd: &Path) -> Option<PathBuf> {
|
fn source_root(source: &ExpertSource, cwd: &Path) -> Option<PathBuf> {
|
||||||
match source {
|
match source {
|
||||||
|
|||||||
@ -149,11 +149,16 @@ pub async fn save_config(
|
|||||||
*cfg = new_config.clone();
|
*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");
|
tracing::info!(path = %config_path.display(), "Config saved via API");
|
||||||
|
|
||||||
Ok(Json(SaveConfigResponse {
|
Ok(Json(SaveConfigResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "配置已保存,需要重启服务才能生效".to_string(),
|
message: "配置已保存".to_string(),
|
||||||
config_path: config_path.to_string_lossy().to_string(),
|
config_path: config_path.to_string_lossy().to_string(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1206,7 +1206,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl">
|
<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">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />
|
<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />
|
||||||
<h3 className="text-sm font-semibold text-[var(--text-primary)]">
|
<h3 className="text-sm font-semibold text-[var(--text-primary)]">
|
||||||
@ -1214,7 +1214,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Field label="名称">
|
<Field label="名称" hint="创建后不可修改。仅当正文为空时,与描述一起生成兜底提示词;正文非空时不注入">
|
||||||
<input
|
<input
|
||||||
value={editingExpert.nameField}
|
value={editingExpert.nameField}
|
||||||
onChange={e => setEditingExpert(prev => prev ? { ...prev, nameField: e.target.value } : prev)}
|
onChange={e => setEditingExpert(prev => prev ? { ...prev, nameField: e.target.value } : prev)}
|
||||||
@ -1224,7 +1224,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
autoFocus={!isEdit}
|
autoFocus={!isEdit}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="描述" hint="必填,简要说明专家身份">
|
<Field label="描述" hint="必填。仅当正文为空时,与名称一起生成兜底提示词;正文非空时不注入">
|
||||||
<input
|
<input
|
||||||
value={editingExpert.description}
|
value={editingExpert.description}
|
||||||
onChange={e => setEditingExpert(prev => prev ? { ...prev, description: e.target.value } : prev)}
|
onChange={e => setEditingExpert(prev => prev ? { ...prev, description: e.target.value } : prev)}
|
||||||
@ -1232,12 +1232,12 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="专家提示词正文" hint="markdown 格式,将作为系统提示词注入">
|
<Field label="专家提示词正文" hint="markdown 格式。非空时仅注入正文(不注入名称和描述);为空时自动用“名称+描述”生成兜底提示词">
|
||||||
<textarea
|
<textarea
|
||||||
value={editingExpert.body}
|
value={editingExpert.body}
|
||||||
onChange={e => setEditingExpert(prev => prev ? { ...prev, body: e.target.value } : prev)}
|
onChange={e => setEditingExpert(prev => prev ? { ...prev, body: e.target.value } : prev)}
|
||||||
placeholder="你是一名专业翻译..."
|
placeholder="你是一名专业翻译..."
|
||||||
className={inputCls + ' min-h-[200px] resize-y font-mono text-xs'}
|
className={inputCls + ' min-h-[360px] resize-y font-mono text-xs'}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{editingExpertError && (
|
{editingExpertError && (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user