feat: 在顶栏并排展示下发智能体并直接切换

This commit is contained in:
brother7 committed 2026-09-24 15:38:50 +08:00
1 parent 39b03a1628
commit a10cf00015
15 files changed
+291 -59

No files matched your search

+33 -23
View File
@@ -28,6 +28,7 @@ export function TeacherChatPanel({
externalTopic,
onTopicChange,
onComposingChange,
selectedAgent,
}: {
projectId: string;
sourceId: string;
@@ -42,17 +43,21 @@ export function TeacherChatPanel({
externalTopic?: TeacherTopic | null;
onTopicChange?(topic: TeacherTopic): void;
onComposingChange?(composing: boolean): void;
selectedAgent?: TeacherCatalog['items'][number];
}) {
const base = teacherTopicsPath(projectId);
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
// Retain the existing project draft key; its suffix is a storage identifier, not a role.
const draftKey = !draftRevision ? `makelore-consultation-draft:${account}:${projectId}:teacher` : null;
const projectDraftKey = `makelore-consultation-draft:${account}:${projectId}:teacher`;
const draftKey = !draftRevision ? selectedAgent ? `makelore-consultation-draft:${account}:${projectId}:agent:${selectedAgent.teacher_id}` : projectDraftKey : null;
const [saved] = useState<{ text?: string; references?: TeacherReference[]; pending?: TeacherSend }>(() => {
try { return draftKey ? JSON.parse(localStorage.getItem(draftKey) ?? '{}') : {}; } catch { return {}; }
});
const [legacyDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => {
try { return draftKey ? JSON.parse(localStorage.getItem(`makelore-consultation-draft:${account}:${projectId}:friend`) ?? '{}') : {}; } catch { return {}; }
});
const [unassignedDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => {
try { return selectedAgent ? JSON.parse(localStorage.getItem(projectDraftKey) ?? '{}') : {}; } catch { return {}; }
});
const persistDraft = useCallback((text: string, references: TeacherReference[], pending?: TeacherSend | null) => {
if (!draftKey) return;
try { localStorage.setItem(draftKey, JSON.stringify({ text, references, pending })); } catch { /* Draft remains in the open panel. */ }
@@ -65,6 +70,9 @@ export function TeacherChatPanel({
const avatar = getAgentAvatarSrc(definition?.avatar_id);
const externalTopicRef = useRef(externalTopic);
externalTopicRef.current = externalTopic;
const selectedAgentId = selectedAgent?.teacher_id;
const selectedAgentRef = useRef(selectedAgent);
selectedAgentRef.current = selectedAgent;
const inputRef = useRef<HTMLTextAreaElement>(null);
const focusedOnce = useRef(false);
const [topics, setTopics] = useState<TeacherTopicList>({ items: [], lastSelectedTopicId: null });
@@ -118,11 +126,12 @@ export function TeacherChatPanel({
[persistDraft]
);
useEffect(() => {
if (externalTopic && externalTopic.projectId === projectId) {
if (externalTopic && externalTopic.projectId === projectId
&& (!selectedAgentId || externalTopic.definition.config_id === selectedAgentId)) {
consume(externalTopic);
setDefinition(externalTopic.definition);
}
}, [externalTopic, projectId, consume]);
}, [externalTopic, projectId, consume, selectedAgentId]);
useEffect(() => { if (topic) onTopicChange?.(topic); }, [topic, onTopicChange]);
useEffect(() => {
if (!busy && !focusedOnce.current) {
@@ -150,10 +159,13 @@ export function TeacherChatPanel({
const catalog = await teacherApi.catalog();
if (alive) {
setTeachers(catalog.items);
const selected = catalog.items.find((item) => item.is_default) ?? catalog.items[0];
const selected = selectedAgentId
? catalog.items.find((item) => item.teacher_id === selectedAgentId)
: catalog.items.find((item) => item.is_default) ?? catalog.items[0];
setSelectedTeacherVersion(selected?.version);
setEnabled(catalog.items.length > 0 || (config.enabled && !config.definition?.config_id));
if (selected) setDefinition(selected.definition);
setEnabled(selectedAgentId ? Boolean(selected) : catalog.items.length > 0 || (config.enabled && !config.definition?.config_id));
const visibleAgent = selected ?? selectedAgentRef.current;
if (visibleAgent) setDefinition(visibleAgent.definition);
}
}
} catch (e) {
@@ -163,11 +175,14 @@ export function TeacherChatPanel({
const listed = await teacherApi.list(base);
if (!alive) return;
setTopics(listed);
if (externalTopicRef.current) {
const previous = listed.items.find((item) => item.id === listed.lastSelectedTopicId
&& (!selectedAgentId || item.teacherId === selectedAgentId))
?? (selectedAgentId ? listed.items.find((item) => item.teacherId === selectedAgentId) : undefined);
if (externalTopicRef.current && (!selectedAgentId || externalTopicRef.current.definition.config_id === selectedAgentId)) {
consume(externalTopicRef.current);
setDefinition(externalTopicRef.current.definition);
} else if (listed.lastSelectedTopicId) {
const current = await teacherApi.read(base, listed.lastSelectedTopicId);
} else if (previous) {
const current = await teacherApi.read(base, previous.id);
if (alive && (!draftRevision || current.draftRevision === draftRevision)) {
consume(current);
setDefinition(current.definition);
@@ -185,7 +200,7 @@ export function TeacherChatPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps
generation.current++;
};
}, [base, draftRevision, label, consume]);
}, [base, draftRevision, label, consume, selectedAgentId]);
useEffect(() => {
if (!topic?.id || legacyFriend) return;
const streamTopicId = topic.id;
@@ -375,18 +390,6 @@ export function TeacherChatPanel({
{onExpand && <button className="consult-icon" aria-label={expanded ? '恢复咨询栏宽度' : '展开咨询栏'} onClick={onExpand}><Maximize2 className="h-4 w-4" /></button>}
{onClose && <button className="consult-icon" aria-label={`关闭${label}`} onClick={onClose}><X className="h-4 w-4" /></button>}
</header>
{!draftRevision && teachers.length > 0 && (
<label className="px-5 pb-2 text-[11px] text-muted-foreground">
新话题使用的智能体
<select aria-label="新话题使用的智能体" value={selectedTeacherVersion ?? ''} disabled={busy}
onChange={(event) => { const selected = teachers.find(item => item.version === Number(event.target.value)); setSelectedTeacherVersion(selected?.version); if (!topic && selected) setDefinition(selected.definition); }}
className="mt-1 w-full rounded-lg border bg-background p-2 text-xs text-foreground">
{teachers.map((item) => <option key={item.teacher_id} value={item.version}>
{item.definition.name}{item.is_default ? '(默认)' : ''}
</option>)}
</select>
</label>
)}
{(topics.items.length > 0 || draftRevision) && <div className="px-5 pt-2">
<button className="text-[11px] text-muted-foreground" onClick={() => setHistoryOpen(!historyOpen)} aria-expanded={historyOpen}>以往讨论 <ChevronDown className="inline h-3 w-3" /></button>
{(historyOpen || draftRevision) && <div className="mt-2 flex gap-2">
@@ -458,6 +461,13 @@ export function TeacherChatPanel({
{topic?.unsaved && <div className="px-5 py-2 text-xs text-destructive">回复尚未保存,请复制或重试保存。<button className="ml-2 underline" onClick={() => void teacherApi.save(topicBase, topic.id).then(consume).catch(() => setError('保存失败,请先复制回复。'))}>重试保存</button></div>}
{!topicEnabled && !error && !busy && <p className="px-5 pb-2 text-xs text-muted-foreground">{legacyFriend ? '这是旧版内置朋友的话题,仅供查看。请选择智能体新建话题。' : `${label}暂未开放,历史仍可查看。`}</p>}
<form className="px-4 pb-4 pt-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
{Boolean(unassignedDraft.text?.trim() || unassignedDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
<summary>查看之前的项目草稿</summary>
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">
{unassignedDraft.text}
{unassignedDraft.references?.map((ref, index) => <pre className="mt-2 whitespace-pre-wrap" key={index}>{ref.text}</pre>)}
</div>
</details>}
{Boolean(legacyDraft.text?.trim() || legacyDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
<summary>查看旧版朋友草稿</summary>
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">