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),路径显示在卡片底部
- "管理专家"入口仅在无专家时显示
- 每次打开下拉都重新拉取列表和选中状态
- 监听设置弹窗关闭事件刷新已选专家(处理已选专家被禁用的情况)
This commit is contained in:
oudecheng 2026-07-08 14:53:30 +08:00
parent 46a1ca6853
commit 0a8d21fe40
4 changed files with 161 additions and 117 deletions

View File

@ -256,6 +256,14 @@ impl ExpertRuntime {
// 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),
@ -816,12 +824,23 @@ fn parse_expert_file(path: &Path, source: ExpertSource) -> Result<Expert, String
}
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let rest = content.strip_prefix("---\n")?;
// 兼容 CRLFWindows和 LFUnix行尾符
let rest = content
.strip_prefix("---\n")
.or_else(|| content.strip_prefix("---\r\n"))?;
let marker = "\n---\n";
let idx = rest.find(marker)?;
let frontmatter = &rest[..idx];
let body = &rest[idx + marker.len()..];
Some((frontmatter, body))
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 ==========

View File

@ -167,6 +167,8 @@ function App() {
const [configPageOpen, setConfigPageOpen] = useState(false)
const [configInitialTab, setConfigInitialTab] = useState<'providers' | 'experts'>('providers')
// 设置弹窗关闭计数器:每次关闭时递增,用于通知 ExpertSelector 刷新已选专家状态
const [settingsClosedTick, setSettingsClosedTick] = useState(0)
const handleSaveConnection = useCallback((host: string, port: number) => {
setGatewaySettings({ host, port })
@ -806,6 +808,7 @@ function App() {
viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
sessionId={sessionId}
settingsClosedTick={settingsClosedTick}
onOpenSettings={() => {
setConfigInitialTab('experts')
setConfigPageOpen(true)
@ -899,7 +902,10 @@ function App() {
{/* 系统配置页面 */}
{configPageOpen && (
<ConfigPage
onClose={() => setConfigPageOpen(false)}
onClose={() => {
setConfigPageOpen(false)
setSettingsClosedTick(t => t + 1)
}}
onSaveConnection={handleSaveConnection}
initialTab={configInitialTab}
/>

View File

@ -21,6 +21,8 @@ interface ChatContainerProps {
sessionId?: string | null
/** 打开设置页(用于专家管理入口) */
onOpenSettings?: () => void
/** 设置弹窗关闭信号(每次关闭递增,用于触发 ExpertSelector 刷新) */
settingsClosedTick?: number
}
export function ChatContainer({
@ -36,6 +38,7 @@ export function ChatContainer({
highlightedMessageId,
sessionId,
onOpenSettings,
settingsClosedTick,
}: ChatContainerProps) {
const [selectedExpert, setSelectedExpert] = useState<{ name: string; description: string } | null>(null)
@ -48,6 +51,7 @@ export function ChatContainer({
sessionId={sessionId ?? null}
onManageExperts={onOpenSettings}
onSelectionChange={setSelectedExpert}
settingsClosedTick={settingsClosedTick}
/>
<MessageInput
onSend={onSendMessage}

View File

@ -20,9 +20,11 @@ interface ExpertSelectorProps {
sessionId: string | null
onManageExperts?: () => void
onSelectionChange?: (expert: SelectedExpert | null) => void
/** 设置弹窗关闭时触发的刷新信号(每次关闭时递增) */
settingsClosedTick?: number
}
export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }: ExpertSelectorProps) {
export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange, settingsClosedTick }: ExpertSelectorProps) {
const [selectedExpert, setSelectedExpert] = useState<SelectedExpert | null>(null)
const [expertList, setExpertList] = useState<ExpertItem[]>([])
const [open, setOpen] = useState(false)
@ -31,41 +33,46 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
const [error, setError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const listFetchedRef = useRef(false)
// Load current selection whenever sessionId changes
useEffect(() => {
// 刷新当前会话选中的专家(后端会对禁用专家返回 null
const refreshSelection = useCallback(() => {
if (!sessionId) {
setSelectedExpert(null)
onSelectionChange?.(null)
return
}
let cancelled = false
setLoading(true)
setError(null)
getSelectedExpert(sessionId)
.then(data => {
if (cancelled) return
if (data?.expert) {
setSelectedExpert({ name: data.expert.name, description: data.expert.description })
onSelectionChange?.({ name: data.expert.name, description: data.expert.description })
} else {
// 已选专家被禁用/删除时,后端返回 null前端同步清除
setSelectedExpert(null)
onSelectionChange?.(null)
}
})
.catch(() => {
if (cancelled) return
// Silent fail: default to no expert
setSelectedExpert(null)
onSelectionChange?.(null)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
.finally(() => setLoading(false))
}, [sessionId, onSelectionChange])
// Load current selection whenever sessionId changes
useEffect(() => {
refreshSelection()
}, [refreshSelection])
// 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况)
useEffect(() => {
if (settingsClosedTick === undefined) return
refreshSelection()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
}, [settingsClosedTick])
// Click outside to close dropdown
useEffect(() => {
@ -95,9 +102,10 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
const handleToggleOpen = () => {
const next = !open
setOpen(next)
if (next && !listFetchedRef.current) {
listFetchedRef.current = true
// 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映
if (next) {
fetchExpertList()
refreshSelection()
}
}
@ -136,109 +144,116 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
return (
<div ref={containerRef} className="relative shrink-0 px-4 pt-2 pb-0">
<div className="max-w-5xl mx-auto flex items-center gap-2">
<button
onClick={handleToggleOpen}
disabled={loading}
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
) : (
<UserCheck
className={`h-3.5 w-3.5 ${selectedExpert ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
<div className="relative">
<button
onClick={handleToggleOpen}
disabled={loading}
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
) : (
<UserCheck
className={`h-3.5 w-3.5 ${selectedExpert ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
)}
{selectedExpert ? (
<span className="flex items-center gap-1 min-w-0">
<span className="text-[var(--text-primary)] font-medium truncate max-w-[120px]">
{selectedExpert.name}
</span>
<span className="text-[var(--text-muted)] truncate max-w-[180px]">
{selectedExpert.description}
</span>
</span>
) : (
<span className="text-[var(--text-muted)]"></span>
)}
<ChevronDown
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden"
>
{listLoading && expertList.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> ...
</div>
) : (
<>
{/* 无专家 option */}
<button
onClick={() => handleSelect(null)}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck className="h-4 w-4 text-[var(--text-muted)] shrink-0" />
<span className="text-[var(--text-primary)]"></span>
{!selectedExpert && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
)}
</button>
{expertList.length > 0 ? (
<div className="border-t border-[var(--border-color)]">
{expertList.map(expert => {
const isSelected = selectedExpert?.name === expert.name
return (
<button
key={expert.name}
onClick={() => handleSelect({ name: expert.name, description: expert.description })}
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck
className={`h-4 w-4 shrink-0 mt-0.5 ${isSelected ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-1.5">
<span className="text-sm font-medium text-[var(--text-primary)] break-all">
{expert.name}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0 mt-0.5" />
)}
</div>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
{expert.description}
</p>
{expert.path && (
<p
className="text-[10px] text-[var(--text-muted)]/60 truncate mt-1 font-mono"
title={expert.path}
>
{expert.path}
</p>
)}
</div>
</button>
)
})}
</div>
) : (
<div className="border-t border-[var(--border-color)]">
<button
onClick={handleManage}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-secondary)] hover:text-[var(--accent-cyan)] hover:bg-[var(--bg-hover)] transition-colors"
>
<Settings className="h-4 w-4" />
<span>...</span>
</button>
</div>
)}
</>
)}
</div>
)}
{selectedExpert ? (
<span className="flex items-center gap-1 min-w-0">
<span className="text-[var(--text-primary)] font-medium truncate max-w-[120px]">
{selectedExpert.name}
</span>
<span className="text-[var(--text-muted)] truncate max-w-[180px]">
{selectedExpert.description}
</span>
</span>
) : (
<span className="text-[var(--text-muted)]"></span>
)}
<ChevronDown
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
</div>
{error && (
<span className="text-xs text-red-400 truncate">{error}</span>
)}
</div>
{open && (
<div
className="absolute z-30 mt-1 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden"
style={{ left: 'max(1rem, calc(50% - 18rem))' }}
>
{listLoading && expertList.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> ...
</div>
) : (
<>
{/* 无专家 option */}
<button
onClick={() => handleSelect(null)}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck className="h-4 w-4 text-[var(--text-muted)] shrink-0" />
<span className="text-[var(--text-primary)]"></span>
{!selectedExpert && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
)}
</button>
{expertList.length > 0 && (
<div className="border-t border-[var(--border-color)]">
{expertList.map(expert => {
const isSelected = selectedExpert?.name === expert.name
return (
<button
key={expert.name}
onClick={() => handleSelect({ name: expert.name, description: expert.description })}
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck
className={`h-4 w-4 shrink-0 mt-0.5 ${isSelected ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-[var(--text-primary)] truncate">
{expert.name}
</span>
<span className="text-[9px] px-1 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider shrink-0">
{expert.source}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0" />
)}
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">
{expert.description}
</p>
</div>
</button>
)
})}
</div>
)}
<div className="border-t border-[var(--border-color)]">
<button
onClick={handleManage}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-secondary)] hover:text-[var(--accent-cyan)] hover:bg-[var(--bg-hover)] transition-colors"
>
<Settings className="h-4 w-4" />
<span>...</span>
</button>
</div>
</>
)}
</div>
)}
</div>
)
}