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 // session selections are persisted in the project-scope state file
let session_experts = load_project_session_experts(&cwd); 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 { Self {
config: RwLock::new(config), config: RwLock::new(config),
catalog: RwLock::new(catalog), 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)> { 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 marker = "\n---\n";
let idx = rest.find(marker)?; let marker_crlf = "\n---\r\n";
if let Some(idx) = rest.find(marker) {
let frontmatter = &rest[..idx]; let frontmatter = &rest[..idx];
let body = &rest[idx + marker.len()..]; let body = &rest[idx + marker.len()..];
Some((frontmatter, body)) 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 ========== // ========== State file I/O ==========

View File

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

View File

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

View File

@ -20,9 +20,11 @@ interface ExpertSelectorProps {
sessionId: string | null sessionId: string | null
onManageExperts?: () => void onManageExperts?: () => void
onSelectionChange?: (expert: SelectedExpert | null) => 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 [selectedExpert, setSelectedExpert] = useState<SelectedExpert | null>(null)
const [expertList, setExpertList] = useState<ExpertItem[]>([]) const [expertList, setExpertList] = useState<ExpertItem[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -31,41 +33,46 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const listFetchedRef = useRef(false)
// Load current selection whenever sessionId changes // 刷新当前会话选中的专家(后端会对禁用专家返回 null
useEffect(() => { const refreshSelection = useCallback(() => {
if (!sessionId) { if (!sessionId) {
setSelectedExpert(null) setSelectedExpert(null)
onSelectionChange?.(null) onSelectionChange?.(null)
return return
} }
let cancelled = false
setLoading(true) setLoading(true)
setError(null) setError(null)
getSelectedExpert(sessionId) getSelectedExpert(sessionId)
.then(data => { .then(data => {
if (cancelled) return
if (data?.expert) { if (data?.expert) {
setSelectedExpert({ name: data.expert.name, description: data.expert.description }) setSelectedExpert({ name: data.expert.name, description: data.expert.description })
onSelectionChange?.({ name: data.expert.name, description: data.expert.description }) onSelectionChange?.({ name: data.expert.name, description: data.expert.description })
} else { } else {
// 已选专家被禁用/删除时,后端返回 null前端同步清除
setSelectedExpert(null) setSelectedExpert(null)
onSelectionChange?.(null) onSelectionChange?.(null)
} }
}) })
.catch(() => { .catch(() => {
if (cancelled) return
// Silent fail: default to no expert // Silent fail: default to no expert
setSelectedExpert(null) setSelectedExpert(null)
onSelectionChange?.(null) onSelectionChange?.(null)
}) })
.finally(() => { .finally(() => setLoading(false))
if (!cancelled) setLoading(false) }, [sessionId, onSelectionChange])
})
return () => { cancelled = true } // Load current selection whenever sessionId changes
useEffect(() => {
refreshSelection()
}, [refreshSelection])
// 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况)
useEffect(() => {
if (settingsClosedTick === undefined) return
refreshSelection()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId]) }, [settingsClosedTick])
// Click outside to close dropdown // Click outside to close dropdown
useEffect(() => { useEffect(() => {
@ -95,9 +102,10 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
const handleToggleOpen = () => { const handleToggleOpen = () => {
const next = !open const next = !open
setOpen(next) setOpen(next)
if (next && !listFetchedRef.current) { // 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映
listFetchedRef.current = true if (next) {
fetchExpertList() fetchExpertList()
refreshSelection()
} }
} }
@ -136,6 +144,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
return ( return (
<div ref={containerRef} className="relative shrink-0 px-4 pt-2 pb-0"> <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"> <div className="max-w-5xl mx-auto flex items-center gap-2">
<div className="relative">
<button <button
onClick={handleToggleOpen} onClick={handleToggleOpen}
disabled={loading} disabled={loading}
@ -165,15 +174,10 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`} className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
/> />
</button> </button>
{error && (
<span className="text-xs text-red-400 truncate">{error}</span>
)}
</div>
{open && ( {open && (
<div <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" 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"
style={{ left: 'max(1rem, calc(50% - 18rem))' }}
> >
{listLoading && expertList.length === 0 ? ( {listLoading && expertList.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]"> <div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
@ -192,7 +196,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" /> <Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
)} )}
</button> </button>
{expertList.length > 0 && ( {expertList.length > 0 ? (
<div className="border-t border-[var(--border-color)]"> <div className="border-t border-[var(--border-color)]">
{expertList.map(expert => { {expertList.map(expert => {
const isSelected = selectedExpert?.name === expert.name const isSelected = selectedExpert?.name === expert.name
@ -206,26 +210,31 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
className={`h-4 w-4 shrink-0 mt-0.5 ${isSelected ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`} 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="min-w-0 flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-start gap-1.5">
<span className="text-sm font-medium text-[var(--text-primary)] truncate"> <span className="text-sm font-medium text-[var(--text-primary)] break-all">
{expert.name} {expert.name}
</span> </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 && ( {isSelected && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0" /> <Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0 mt-0.5" />
)} )}
</div> </div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5"> <p className="text-xs text-[var(--text-muted)] mt-0.5">
{expert.description} {expert.description}
</p> </p>
{expert.path && (
<p
className="text-[10px] text-[var(--text-muted)]/60 truncate mt-1 font-mono"
title={expert.path}
>
{expert.path}
</p>
)}
</div> </div>
</button> </button>
) )
})} })}
</div> </div>
)} ) : (
<div className="border-t border-[var(--border-color)]"> <div className="border-t border-[var(--border-color)]">
<button <button
onClick={handleManage} onClick={handleManage}
@ -235,10 +244,16 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
<span>...</span> <span>...</span>
</button> </button>
</div> </div>
)}
</> </>
)} )}
</div> </div>
)} )}
</div> </div>
{error && (
<span className="text-xs text-red-400 truncate">{error}</span>
)}
</div>
</div>
) )
} }