Files
makelore/src/pages/Chat/TeacherChatPanel.tsx

487 lines
29 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 { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowUp, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/auth';
import { teacherApi, teacherTopicsPath, legacyTopicBase } from '@/lib/coding-teacher';
import type {
TeacherDefinition,
TeacherCatalog,
TeacherReference,
TeacherSend,
TeacherTopic,
TeacherTopicList,
} from '../../../shared/coding-teacher';
import { TeacherDiscussionPanel, DiscussionFocus } from './TeacherDiscussionPanel';
import type { TeacherDiscussionAction } from '../../../shared/coding-teacher';
import { TeacherAvatar } from './TeacherAvatar';
import { TeacherReply } from './TeacherReply';
export function TeacherChatPanel({
projectId,
sourceId,
onBringBack,
quote,
draftRevision,
sampleContext,
externalTopic,
onTopicChange,
onComposingChange,
selectedAgent,
}: {
projectId: string;
sourceId: string;
onBringBack?(text: string): void;
quote?: TeacherReference;
draftRevision?: number;
sampleContext?: string;
projectName?: string;
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');
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. */ }
}, [draftKey]);
const label = '智能体';
const discussionEnabled = !draftRevision;
const [historyOpen, setHistoryOpen] = useState(false);
const [focus, setFocus] = useState<{ topicId: string; id: string; label: string } | null>(null);
const [definition, setDefinition] = useState<TeacherDefinition | null>(null);
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 });
const [topic, setTopic] = useState<TeacherTopic | null>(null);
const legacyFriend = topic?.role === 'friend';
const topicBase = legacyTopicBase(base, legacyFriend);
const [text, setText] = useState(saved.text ?? '');
const [references, setReferences] = useState<TeacherReference[]>(quote ? [quote] : saved.references ?? []);
useEffect(() => { onComposingChange?.(Boolean(text.trim() || references.length)); }, [text, references.length, onComposingChange]);
useEffect(() => () => onComposingChange?.(false), [onComposingChange]);
const draftRef = useRef({ text, references });
draftRef.current = { text, references };
const [error, setError] = useState('');
const [busy, setBusy] = useState(true);
const [enabled, setEnabled] = useState(false);
const [legacyEnabled, setLegacyEnabled] = useState(false);
const [teachers, setTeachers] = useState<TeacherCatalog['items']>([]);
const [selectedTeacherVersion, setSelectedTeacherVersion] = useState<number>();
const topicEnabled = legacyFriend ? false : !topic ? enabled
: topic.definition.config_id
? teachers.some((item) => item.teacher_id === topic.definition.config_id)
: legacyEnabled;
const pending = useRef<TeacherSend | null>(saved.pending ?? null);
useEffect(() => {
persistDraft(text, references, pending.current);
}, [text, references, persistDraft]);
const generation = useRef(0);
const topicRef = useRef(topic);
topicRef.current = topic;
const scrollRef = useRef<HTMLDivElement>(null);
const content = topic?.discussion?.content;
const activeFocus = focus && focus.topicId === topic?.id && content && topic?.discussion?.status === 'active' && (
content.kind === 'ideas' ? content.items.some(item => item.id === focus.id)
: content.kind === 'comparison' ? content.rows.some(row => content.columns.some(column => `${row.id}:${column.id}` === focus.id))
: content.nodes.some(node => node.id === focus.id)
) ? focus : null;
useEffect(() => {
const pane = scrollRef.current;
if (pane) pane.scrollTop = pane.scrollHeight;
}, [topic?.id, topic?.revision]);
const consume = useCallback(
(next: TeacherTopic) => {
if (pending.current && next.requests.some((request) => request.id === pending.current?.requestId)) {
pending.current = null;
persistDraft(draftRef.current.text, draftRef.current.references);
}
setTopic((current) =>
current?.id === next.id && current.revision > next.revision ? current : next
);
},
[persistDraft]
);
useEffect(() => {
if (externalTopic && externalTopic.projectId === projectId
&& (!selectedAgentId || externalTopic.definition.config_id === selectedAgentId)) {
consume(externalTopic);
setDefinition(externalTopic.definition);
}
}, [externalTopic, projectId, consume, selectedAgentId]);
useEffect(() => { if (topic) onTopicChange?.(topic); }, [topic, onTopicChange]);
useEffect(() => {
if (!busy && !focusedOnce.current) {
focusedOnce.current = true;
inputRef.current?.focus();
}
}, [busy]);
useEffect(() => {
if (quote) setReferences([quote]);
}, [quote]);
useEffect(() => {
let alive = true;
setBusy(true);
void (async () => {
try {
const config = draftRevision
? { definition: (await teacherApi.preview(draftRevision)).payload, enabled: true }
: await teacherApi.config();
if (alive) {
setDefinition(config.definition);
setEnabled(config.enabled);
setLegacyEnabled(config.enabled);
}
if (!draftRevision) {
const catalog = await teacherApi.catalog();
if (alive) {
setTeachers(catalog.items);
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(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) {
if (alive) setError(e instanceof Error ? e.message : `${label}配置暂不可用`);
}
try {
const listed = await teacherApi.list(base);
if (!alive) return;
setTopics(listed);
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 (previous) {
const current = await teacherApi.read(base, previous.id);
if (alive && (!draftRevision || current.draftRevision === draftRevision)) {
consume(current);
setDefinition(current.definition);
}
}
} catch (e) {
if (alive) setError(e instanceof Error ? e.message : `${label}历史暂不可用`);
} finally {
if (alive) setBusy(false);
}
})();
return () => {
alive = false;
// This numeric epoch invalidates outstanding requests on unmount; it is not a DOM ref.
// eslint-disable-next-line react-hooks/exhaustive-deps
generation.current++;
};
}, [base, draftRevision, label, consume, selectedAgentId]);
useEffect(() => {
if (!topic?.id || legacyFriend) return;
const streamTopicId = topic.id;
let closed = false,
stream: EventSource | undefined;
void teacherApi
.events(topicBase, topic.id)
.then((source) => {
if (closed) {
source.close();
return;
}
stream = source;
source.addEventListener('snapshot', (event) => {
if (closed) return;
const next = JSON.parse((event as MessageEvent).data) as TeacherTopic;
setTopic((current) =>
current?.id === streamTopicId &&
next.id === streamTopicId &&
current.revision <= next.revision
? next
: current
);
});
source.onerror = () => {
if (!closed) setError(`连接暂时中断,正在恢复${label}回复。`);
};
source.onopen = () => {
if (!closed) setError('');
};
})
.catch((e) => {
if (!closed) setError(e instanceof Error ? e.message : `连接${label}失败`);
});
return () => {
closed = true;
stream?.close();
};
}, [topicBase, topic?.id, legacyFriend, consume, label]);
const choose = async (id: string) => {
const version = ++generation.current;
setBusy(true);
setError('');
try {
const next = await teacherApi.read(legacyTopicBase(base, topics.items.some(item => item.id === id && item.legacyRole === 'friend')), id);
if (version === generation.current) {
consume(next);
setDefinition(next.definition);
pending.current = null;
}
} catch (e) {
if (version === generation.current) setError(e instanceof Error ? e.message : '加载失败');
} finally {
if (version === generation.current) setBusy(false);
}
};
const newTopic = async () => {
const version = ++generation.current;
setBusy(true);
setError('');
try {
const next = await teacherApi.create(
base,
draftRevision,
sampleContext,
selectedTeacherVersion
);
if (version === generation.current) {
consume(next);
setDefinition(next.definition);
setTopics(await teacherApi.list(base));
pending.current = null;
}
} catch (e) {
if (version === generation.current)
setError(e instanceof Error ? e.message : `无法新建${label}话题`);
} finally {
if (version === generation.current) setBusy(false);
}
};
const send = async (action?: Pick<TeacherSend, 'text' | 'intent' | 'discussion'>) => {
const question = action?.text ?? text;
const questionReferences = action ? [] : references;
if (!question.trim() || busy || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
const version = generation.current;
setBusy(true);
setError('');
let current = topicRef.current;
try {
if (!current) {
current = await teacherApi.create(
base,
draftRevision,
sampleContext,
selectedTeacherVersion
);
if (version !== generation.current) return;
consume(current);
setDefinition(current.definition);
}
const presentation = discussionEnabled && (!action?.intent || action.intent === 'question' || action.intent === 'guided-help') ? 'discussion-v1' as const : undefined;
const discussion = presentation && current.discussion?.status === 'active'
? action?.discussion ?? { toolId: current.discussion.id, revision: current.discussion.revision,
...(activeFocus?.topicId === current.id ? { focusId: activeFocus.id } : {}) } : undefined;
const previous = pending.current && !current.requests.some((request) => request.id === pending.current?.requestId)
? pending.current
: null;
const input =
previous &&
previous.text === question &&
previous.presentation === presentation &&
JSON.stringify(previous.discussion) === JSON.stringify(discussion) &&
(previous.intent ?? 'question') === (action?.intent ?? 'question') &&
JSON.stringify(previous.references) === JSON.stringify(questionReferences)
? previous
: {
requestId: crypto.randomUUID(), text: question, references: questionReferences,
...(presentation ? { presentation } : {}), ...(discussion ? { discussion } : {}),
...(action?.intent ? { intent: action.intent } : {}),
...(sourceId ? { sourceConversationId: sourceId } : {}),
};
pending.current = input;
persistDraft(text, references, input);
const next = await teacherApi.send(topicBase, current.id, input);
if (version !== generation.current) return;
consume(next);
pending.current = null;
if (action) {
persistDraft(text, references);
} else {
setText('');
setReferences([]);
persistDraft('', []);
}
setTopics(await teacherApi.list(base));
} catch (e) {
if (version === generation.current)
setError(e instanceof Error ? e.message : '提问失败,请保留问题后重试');
} finally {
if (version === generation.current) setBusy(false);
}
};
const running = topic?.requests.find(
(request) => request.status === 'preparing' || request.status === 'running'
);
const helpUnavailable = busy || !topicEnabled || Boolean(running);
const updateDiscussion = async (action: TeacherDiscussionAction['action'], itemId?: string) => {
const current = topicRef.current;
if (!current?.discussion || helpUnavailable) return;
const version = generation.current;
setBusy(true);
setError('');
try {
const next = await teacherApi.updateDiscussion(topicBase, current.id, {
toolId: current.discussion.id, revision: current.discussion.revision, action, ...(itemId ? { itemId } : {}),
});
if (version === generation.current) { consume(next); setFocus(null); }
} catch (e) {
if (version === generation.current) setError(e instanceof Error ? e.message : '内容还没更新,请再试一次。');
} finally { if (version === generation.current) setBusy(false); }
};
const focusItem = (id: string, label: string) => {
if (!topic) return;
setFocus({ topicId: topic.id, id, label });
inputRef.current?.focus();
};
const structureIdeas = () => {
if (!topic?.discussion) return;
setFocus(null);
void send({ text: '把我留下的想法理一理,看看它们怎么联系。', discussion: {
toolId: topic.discussion.id, revision: topic.discussion.revision, transition: 'structure',
} });
};
const askForSuggestions = () => void send({ text: '帮我看看', intent: 'suggestions' });
const askForGuidance = () => void send({ text: '我也说不清,你带我看看', intent: 'guided-help' });
const changeText = (next: string) => { setText(next); persistDraft(next, references, pending.current); };
const removeReferences = () => { setReferences([]); persistDraft(text, [], pending.current); };
const questions = definition?.suggested_questions ?? [];
return (
<aside className="consultation-pane flex h-full min-h-0 w-full flex-col"
data-testid="teacher-chat-panel" aria-label={`${label}聊天`}>
<header className="consultation-header flex h-[68px] shrink-0 items-center gap-3 px-5">
<TeacherAvatar className="consultation-avatar h-9 w-9 rounded-xl" definition={definition} />
<div className="min-w-0 flex-1"><h2 className="truncate text-sm font-semibold">{definition?.name ?? '智能体'}</h2>
{draftRevision && <p className="mt-1 text-[11px] text-muted-foreground">运营草稿试聊</p>}</div>
</header>
{(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">
<select aria-label={`${label}话题`} value={topic?.id ?? ''} disabled={busy} onChange={(e) => void choose(e.target.value)} className="min-w-0 flex-1 rounded-lg border bg-background p-2 text-xs">
{!topic && <option value="">选择话题</option>}{topics.items.map((item) => <option key={item.id} value={item.id}>{item.title}{item.legacyRole ? '(旧版,仅查看)' : ''}</option>)}
</select>
<button type="button" aria-label={`${label}新话题`} disabled={busy || !enabled} onClick={() => void newTopic()}
className="shrink-0 rounded-lg px-2 text-xs text-muted-foreground hover:bg-white hover:text-foreground disabled:opacity-40">新话题</button>
</div>}
</div>}
{discussionEnabled && topic?.discussion && <TeacherDiscussionPanel key={topic.id} discussion={topic.discussion}
disabled={helpUnavailable} focusId={activeFocus?.id}
onAction={(action, itemId) => void updateDiscussion(action, itemId)} onFocus={focusItem} onStructure={structureIdeas}
onDiscuss={(id, question) => {
focusItem(id, question);
void send({ text: question, discussion: { toolId: topic.discussion!.id, revision: topic.discussion!.revision, focusId: id } });
}} />}
<div ref={scrollRef} className="consultation-messages min-h-0 flex-1 overflow-y-auto px-5 py-6">
{!topic?.requests.length && <div className="flex min-h-full flex-col items-center justify-center pb-5 text-center">
<TeacherAvatar className="mb-6 h-[68px] w-[68px] rounded-3xl" definition={definition} />
<h3 className="text-lg font-medium tracking-tight">{definition?.welcome_message || '开始对话'}</h3>
<div className="mt-6 flex w-full max-w-64 flex-col gap-2">{questions.map((question) => <button key={question} onClick={() => changeText(question)} className="consultation-suggestion flex items-center justify-between gap-2 rounded-lg border bg-white px-3 py-3 text-left text-xs">{question}<ChevronRight className="h-3 w-3 shrink-0 opacity-50" /></button>)}</div>
</div>}
{topic?.requests.map((request) => <div key={request.id} className="mb-7 space-y-4">
{request.intent === 'check-in'
? <p className="text-[11px] text-muted-foreground">智能体来看看你的进展</p>
: <div className="flex justify-end"><p className="consultation-user max-w-[92%] whitespace-pre-wrap break-words rounded-2xl rounded-br-sm px-4 py-3 text-[13px] leading-7">{request.text}</p></div>}
{request.references.length > 0 && <details className="text-xs text-muted-foreground"><summary>本轮引用 {request.references.length} 项</summary>{request.references.map((ref, index) => <pre className="whitespace-pre-wrap" key={index}>{ref.text}</pre>)}</details>}
<div className="flex items-center gap-2 text-[11px] text-muted-foreground"><TeacherAvatar definition={definition} className="h-6 w-6 rounded-lg" />{definition?.name ?? label}</div>
{!!request.toolActivity?.length && <details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">使用了 {request.toolActivity.length} 项工具</summary>
<ul className="mt-2 space-y-2" aria-label="智能体工具活动">
{request.toolActivity.map(activity => <li key={activity.id} className="flex items-start justify-between gap-3">
<span>{activity.name === 'list_project_files' ? '查看项目文件列表' : activity.name === 'read_project_file' ? '读取项目文件' : activity.name === 'read_conversation' ? '读取会话原文' : '使用智能体工具'}</span>
<span className="shrink-0">{activity.status === 'completed' ? '已完成' : activity.status === 'failed' ? '未完成'
: ['running', 'preparing'].includes(request.status) ? '进行中' : '未收到执行结果'}</span>
</li>)}
</ul>
</details>}
{request.intent === 'suggestions' ? <>
{['running', 'preparing'].includes(request.status)
? <p role="status" className="flex items-center gap-2 text-[13px] leading-7 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />我看看你最近做到了哪里…</p>
: request.status === 'completed' && <>
<TeacherReply text={request.response} />
<div className="flex flex-col gap-2" aria-label="可以和智能体聊的问题">
{request.suggestedQuestions?.map((question) => <button key={question} type="button" disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-suggestion flex min-h-11 items-center justify-between gap-3 rounded-xl border bg-white px-3 py-3 text-left text-[13px] leading-6 transition-colors disabled:cursor-not-allowed disabled:opacity-50">{question}<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-60" /></button>)}
</div>
<button type="button" disabled={helpUnavailable} onClick={askForGuidance} className="consultation-guided-help min-h-10 text-left text-xs leading-6 underline decoration-current/30 underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50">我也说不清,你带我看看</button>
<p className="text-[11px] leading-5 text-muted-foreground">也可以直接在下面说说你的想法。</p>
</>}
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-guided-help min-h-10 text-xs underline underline-offset-4 disabled:opacity-50">再请智能体看看</button>}
</> : request.response ? <TeacherReply text={request.response} /> : <div className="text-[13px] leading-7">{['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{request.discussionContext ? '正在梳理你的新想法…' : `${label}正在想…`}</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
{discussionEnabled && request.intent !== 'suggestions' && request.status === 'completed' && topic.discussion?.status !== 'active' && topic.discussion?.status !== 'offered' && Boolean(request.suggestedQuestions?.length) && <div className="flex flex-col gap-2" aria-label="接着聊">
{request.suggestedQuestions?.map(question => <button type="button" key={question} disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-suggestion rounded-xl border px-3 py-2 text-left text-xs leading-6">{question}<ChevronRight className="ml-1 inline h-3 w-3" /></button>)}
</div>}
{request.discussionError && <p className="text-xs leading-6 text-muted-foreground">{request.discussionError}</p>}
{request.unparsedResponse && <details className="min-w-0 text-xs text-muted-foreground">
<summary className="cursor-pointer">查看收到的原始内容</summary>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border p-3">{request.unparsedResponse}</pre>
</details>}
{request.progress && ['running', 'preparing'].includes(request.status) && <p role="status" className="text-xs text-muted-foreground">{request.progress}</p>}
{(request.truncatedMessages ?? 0) > 0 && <p className="text-xs text-muted-foreground">{draftRevision ? '较长的上下文已节选。' : '较长的上下文已节选,智能体可按需读取原文。'}</p>}
{request.omittedMessages > 0 && <p className="text-[10px] text-muted-foreground">本轮参考了较近的讨论,省略了 {request.omittedMessages} 条较早内容。</p>}
{request.error && <p className="text-xs text-destructive">{request.error}</p>}
{onBringBack && request.response && request.intent !== 'suggestions' && request.status === 'completed' && <div className="text-[11px] text-muted-foreground">
<button onClick={() => onBringBack(request.response)}>带回主会话草稿</button>
</div>}
</div>)}
</div>
{error && <p role="alert" className="px-5 py-2 text-xs leading-5 text-destructive">{error}</p>}
{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">
{legacyDraft.text}
{legacyDraft.references?.map((ref, index) => <pre className="mt-2 whitespace-pre-wrap" key={index}>{ref.text}</pre>)}
</div>
</details>}
{references.length > 0 && <div className="mb-2 rounded-lg bg-white p-3 text-xs"><p className="line-clamp-3 whitespace-pre-wrap">{references.map((ref) => ref.text).join('\n')}</p><button type="button" className="mt-1 underline" onClick={removeReferences}>移除引用</button></div>}
<div className="consultation-composer rounded-2xl border bg-white p-3 focus-within:ring-2 focus-within:ring-black/5">
{activeFocus && <DiscussionFocus label={activeFocus.label} onClear={() => setFocus(null)} />}
<textarea ref={inputRef} aria-label={`向${label}提问`} value={text} disabled={busy} maxLength={6000} onChange={(e) => changeText(e.target.value)} placeholder={`想和${label}聊些什么?`} rows={2}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void send(); } }}
className="min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" />
<div className="mt-2 flex items-center justify-between gap-2" data-testid="consultation-composer-actions">
{discussionEnabled ? <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-help-prompt flex min-h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50">帮我看看<ChevronRight className="h-3 w-3" aria-hidden="true" /></button>
: <span />}
{running ? <button type="button" className="rounded-lg border px-3 py-2 text-xs" onClick={() => void teacherApi.cancel(topicBase, topic!.id, running.id).then(consume).catch(() => setError('暂时无法停止,请稍后重试。'))}>停止回复</button>
: <button type="submit" aria-label="提问" title={busy ? '正在发送…' : '发送'} disabled={busy || !topicEnabled || !text.trim()} className="consultation-send flex h-8 w-8 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
</div>
</div>
</form>
</aside>
);
}