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:
parent
46a1ca6853
commit
0a8d21fe40
@ -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")?;
|
||||
// 兼容 CRLF(Windows)和 LF(Unix)行尾符
|
||||
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 ==========
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user