PicoBot/web/src/components/Chat/ExpertSelector.tsx
oudecheng c724bbf864 chore(web): prettier 一次性格式化并在 CI 启用 format:check
对 47 个前端文件统一执行 npm run format,消除存量格式差异。
随后在 CI 与 Makefile check 中启用 format:check,确保后续提交强制遵守 prettier 风格。
2026-08-03 23:25:22 +08:00

263 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, useCallback } from 'react';
import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react';
import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts';
interface ExpertItem {
name: string;
description: string;
source: string;
path?: string;
body?: string;
disabled_in_scopes: string[];
}
interface SelectedExpert {
name: string;
description: string;
}
interface ExpertSelectorProps {
sessionId: string | null;
onManageExperts?: () => void;
onSelectionChange?: (expert: SelectedExpert | null) => void;
/** 设置弹窗关闭时触发的刷新信号(每次关闭时递增) */
settingsClosedTick?: number;
}
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);
const [loading, setLoading] = useState(false);
const [listLoading, setListLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 刷新当前会话选中的专家(后端会对禁用专家返回 null
const refreshSelection = useCallback(() => {
if (!sessionId) {
setSelectedExpert(null);
onSelectionChange?.(null);
return;
}
setLoading(true);
setError(null);
getSelectedExpert(sessionId)
.then((data) => {
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(() => {
// Silent fail: default to no expert
setSelectedExpert(null);
onSelectionChange?.(null);
})
.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
}, [settingsClosedTick]);
// Click outside to close dropdown
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const fetchExpertList = useCallback(async () => {
setListLoading(true);
const data = await listExperts();
if (data) {
// Only show enabled experts (disabled_in_scopes.length === 0)
const enabled = (data.experts ?? []).filter(
(e: ExpertItem) => e.disabled_in_scopes.length === 0,
);
setExpertList(enabled);
}
setListLoading(false);
}, []);
const handleToggleOpen = () => {
const next = !open;
setOpen(next);
// 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映
if (next) {
fetchExpertList();
refreshSelection();
}
};
const handleSelect = async (expert: SelectedExpert | null) => {
if (!sessionId) return;
// Optimistic update
const prev = selectedExpert;
setSelectedExpert(expert);
onSelectionChange?.(expert);
setOpen(false);
try {
const result = await selectExpert(sessionId, expert?.name ?? null);
if (!result.success) {
// Revert
setSelectedExpert(prev);
onSelectionChange?.(prev);
setError(result.error || '切换专家失败');
setTimeout(() => setError(null), 3000);
}
} catch {
setSelectedExpert(prev);
onSelectionChange?.(prev);
setError('网络错误,切换专家失败');
setTimeout(() => setError(null), 3000);
}
};
const handleManage = () => {
setOpen(false);
onManageExperts?.();
};
// If sessionId is null, render nothing
if (!sessionId) return null;
return (
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
<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>
)}
</div>
{error && <span className="text-xs text-red-400 truncate">{error}</span>}
</div>
);
}