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

654 lines
42 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, useLayoutEffect, useRef, useState } from 'react';
import { ArrowUp, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/auth';
import { teacherApi, teacherTopicsPath, teacherConversationsPath, legacyTopicBase, type TeacherCatalogState } from '@/lib/coding-teacher';
import type {
TeacherDefinition,
TeacherCatalog,
TeacherReference,
TeacherRequest,
TeacherSend,
TeacherTopic,
TeacherTopicList,
} from '../../../shared/coding-teacher';
import { TeacherAvatar } from './TeacherAvatar';
import { TeacherReply } from './TeacherReply';
import { MessageTimestamp } from './MessageTimestamp';
import { ComposerVoiceButton } from './ComposerVoiceButton';
import { useWorksVoiceInput } from '@/hooks/use-works-voice-input';
import { useTeacherModelChoice } from '@/hooks/use-teacher-model-choice';
import { Textarea } from '@/components/ui/textarea';
// Preserve loaded pages and the reading anchor while moving between contacts.
// Durable messages remain Main-owned; this is only the current window's view.
const conversationViews = new Map<string, { topic: TeacherTopic | null; before: string | null; top: number; follow: boolean }>();
const messageOrderKey = (request: TeacherTopic['requests'][number]) => request.origin
? [request.origin.projectId, request.origin.topicId, request.origin.requestId].join(':') : request.id;
function restorePendingSend(saved?: TeacherSend, legacyProjectId?: string): TeacherSend | null {
if (!saved) return null;
// Keep an interrupted send retryable without restoring retired UI protocol fields.
const { requestId, text, references, intent, sourceConversationId, projectId, modelId } = saved;
return { requestId, text, references, intent, sourceConversationId, ...(modelId ? { modelId } : {}), ...(projectId || legacyProjectId ? { projectId: projectId ?? legacyProjectId } : {}) };
}
export function TeacherChatPanel(props: Parameters<typeof ProjectTeacherChatPanel>[0]) {
const account = useAuthStore(state => state.user?.userId ?? state.user?.username ?? 'signed-out');
return <ProjectTeacherChatPanel key={`${account}:${props.projectId}:${props.selectedAgent?.teacher_id ?? 'legacy'}:${props.draftRevision ?? ''}`} {...props} />;
}
function ProjectTeacherChatPanel({
projectId,
projectName,
sourceId,
onBringBack,
quote,
onQuoteConsumed,
draftRevision,
sampleContext,
externalTopic,
onTopicChange,
onComposingChange,
selectedAgent,
catalogState,
reloadCatalog,
}: {
projectId: string;
sourceId: string;
onBringBack?(text: string): void;
quote?: TeacherReference;
onQuoteConsumed?(reference: TeacherReference): void;
draftRevision?: number;
sampleContext?: string;
projectName?: string;
externalTopic?: TeacherTopic | null;
onTopicChange?(topic: TeacherTopic): void;
onComposingChange?(composing: boolean): void;
selectedAgent?: TeacherCatalog['items'][number];
catalogState?: TeacherCatalogState;
reloadCatalog?(): Promise<void>;
}) {
const continuous = Boolean(selectedAgent && !draftRevision);
const base = continuous ? teacherConversationsPath(projectId) : 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}:project:${projectId}:agent:${selectedAgent.teacher_id}` : projectDraftKey : null;
const [cachedView] = useState(() => continuous && draftKey ? conversationViews.get(draftKey) : undefined);
const [saved] = useState<{ text?: string; references?: TeacherReference[]; pending?: TeacherSend; projectId?: string; sourceId?: string }>(() => {
try {
if (!draftKey) return {};
const current = localStorage.getItem(draftKey);
if (current) return JSON.parse(current);
const legacyKey = selectedAgent ? `makelore-consultation-draft:${account}:agent:${selectedAgent.teacher_id}` : projectDraftKey;
const old = JSON.parse(localStorage.getItem(legacyKey) ?? '{}');
if ((old.projectId ?? old.pending?.projectId) === projectId) return old;
// Pre-single-chat versions already had project drafts; retain them as a fallback.
return selectedAgent ? JSON.parse(localStorage.getItem(`makelore-consultation-draft:${account}:${projectId}:agent:${selectedAgent.teacher_id}`) ?? '{}') : {};
} catch { return {}; }
});
const [referenceScope, setReferenceScope] = useState(quote ? { projectId, sourceId } : { projectId: saved.projectId ?? projectId, sourceId: saved.sourceId ?? sourceId });
const referenceScopeRef = useRef(referenceScope);
referenceScopeRef.current = referenceScope;
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, ...referenceScopeRef.current })); } catch { /* Draft remains in the open panel. */ }
}, [draftKey]);
const label = '智能体';
const [historyOpen, setHistoryOpen] = useState(false);
const [before, setBefore] = useState<string | null>(cachedView?.before ?? null);
const [loadingOlder, setLoadingOlder] = useState(false);
const olderAnchor = useRef<{ height: number; top: number } | null>(null);
const followBottom = useRef(cachedView?.follow ?? true);
const restoreTop = useRef(cachedView?.top);
const scrollPosition = useRef(cachedView?.top ?? 0);
const [localDefinition, setDefinition] = useState<TeacherDefinition | null>(null);
const catalogStateRef = useRef(catalogState);
catalogStateRef.current = catalogState;
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>(cachedView?.topic ?? null);
const legacyFriend = topic?.role === 'friend';
const legacyView = Boolean(continuous && topic && !topic.conversation);
const viewingLegacy = useRef(false);
const topicBase = legacyTopicBase(base, legacyFriend);
const endpointId = continuous ? selectedAgent!.teacher_id : topic?.id ?? '';
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 [localEnabled, setEnabled] = useState(false);
const [localLegacyEnabled, setLegacyEnabled] = useState(false);
const [localTeachers, setTeachers] = useState<TeacherCatalog['items']>([]);
const [localTeacherVersion, setSelectedTeacherVersion] = useState<number>();
// Share the current catalog with the topbar without reloading the topic or its draft.
const teachers = catalogState?.catalog.items ?? localTeachers;
const selectedTeacher = selectedAgentId ? teachers.find((item) => item.teacher_id === selectedAgentId)
: teachers.find((item) => item.is_default) ?? teachers[0];
const enabled = catalogState ? (selectedAgentId ? Boolean(selectedTeacher)
: teachers.length > 0 || (catalogState.config.enabled && !catalogState.config.definition?.config_id)) : localEnabled;
const legacyEnabled = catalogState?.config.enabled ?? localLegacyEnabled;
const selectedTeacherVersion = catalogState ? selectedTeacher?.version : localTeacherVersion;
const definition = continuous && !legacyView
? selectedTeacher?.definition ?? selectedAgent?.definition ?? localDefinition
: topic?.definition ?? (catalogState
? selectedTeacher?.definition ?? selectedAgent?.definition ?? catalogState.config.definition : localDefinition);
const modelScope = `${account}:${selectedAgentId ?? definition?.config_id ?? definition?.teacher_id ?? 'teacher'}:${draftRevision ?? 'published'}`;
const modelChoice = useTeacherModelChoice(modelScope);
const topicEnabled = legacyFriend || legacyView ? false : !topic ? enabled
: topic.definition.config_id
? teachers.some((item) => item.teacher_id === topic.definition.config_id)
: legacyEnabled;
const pending = useRef<TeacherSend | null>(restorePendingSend(saved.pending, continuous ? saved.projectId ?? projectId : undefined));
const sendInFlight = useRef(false);
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 beforeRef = useRef(before);
beforeRef.current = before;
useEffect(() => () => {
if (continuous && draftKey && !viewingLegacy.current) conversationViews.set(draftKey, { topic: topicRef.current,
before: beforeRef.current, top: scrollPosition.current, follow: followBottom.current });
}, [continuous, draftKey]);
useLayoutEffect(() => {
const pane = scrollRef.current;
if (!pane) return;
if (restoreTop.current !== undefined) {
pane.scrollTop = restoreTop.current; restoreTop.current = undefined;
} else if (olderAnchor.current) {
pane.scrollTop = olderAnchor.current.top + pane.scrollHeight - olderAnchor.current.height;
olderAnchor.current = null;
} else if (followBottom.current) pane.scrollTop = pane.scrollHeight;
}, [topic?.id, topic?.revision, topic?.requests.length]);
const consume = useCallback(
(next: TeacherTopic) => {
if (next.projectId !== projectId) return;
if (pending.current && next.requests.some((request) => request.id === pending.current?.requestId)) {
pending.current = null;
persistDraft(draftRef.current.text, draftRef.current.references);
}
setTopic((current) => {
const olderSnapshot = current?.id === next.id && current.revision > next.revision;
if (olderSnapshot && !continuous) return current;
if (!continuous || current?.id !== next.id) return next;
const requests = new Map(current.requests.map(request => [request.id, request]));
next.requests.forEach(request => { if (!olderSnapshot || !requests.has(request.id)) requests.set(request.id, request); });
return { ...(olderSnapshot ? current : next), requests: [...requests.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt) || messageOrderKey(a).localeCompare(messageOrderKey(b))) };
});
},
[persistDraft, continuous, projectId]
);
useEffect(() => {
if (!viewingLegacy.current && externalTopic && externalTopic.projectId === projectId
&& (!selectedAgentId || externalTopic.definition.config_id === selectedAgentId)) {
consume(externalTopic);
setDefinition(continuous ? selectedAgentRef.current?.definition ?? externalTopic.definition : externalTopic.definition);
}
}, [externalTopic, projectId, consume, selectedAgentId, continuous]);
useEffect(() => { if (topic && !legacyView) onTopicChange?.(topic); }, [topic, onTopicChange, legacyView]);
useEffect(() => { if (continuous && selectedAgent) setDefinition(selectedAgent.definition); }, [continuous, selectedAgent]);
useEffect(() => {
if (!busy && !focusedOnce.current) {
focusedOnce.current = true;
inputRef.current?.focus();
}
}, [busy]);
useEffect(() => {
if (quote) {
setReferences([quote]);
setReferenceScope({ projectId, sourceId });
onQuoteConsumed?.(quote);
}
}, [quote, projectId, sourceId, onQuoteConsumed]);
useEffect(() => {
let alive = true;
setBusy(true);
void (async () => {
try {
if (!draftRevision && reloadCatalog) {
// Opening the panel still checks for updates through the shared loader.
// A failed background read leaves the last usable catalog in place.
await reloadCatalog().catch(() => undefined);
} else if (draftRevision || !catalogStateRef.current) {
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 {
if (continuous && selectedAgentId) {
const view = await teacherApi.conversation(projectId, selectedAgentId);
if (alive) { if (view.topic) consume(view.topic); if (!cachedView?.topic) setBefore(view.before); }
return;
}
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?.projectId === projectId && (!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, projectId, draftRevision, label, consume, selectedAgentId, continuous, cachedView, reloadCatalog]);
const seenPending = useRef<string | undefined>(undefined);
const markSeen = useCallback(() => {
const current = topicRef.current;
const last = current?.requests.at(-1);
if (!continuous || legacyView || !selectedAgentId || !current || !last || !followBottom.current
|| document.visibilityState === 'hidden' || ['preparing', 'running'].includes(last.status)
|| current.conversation?.seenThrough === last.id || seenPending.current === last.id) return;
seenPending.current = last.id;
void teacherApi.seenConversation(projectId, selectedAgentId, last.id).then(consume).catch(() => undefined)
.finally(() => { seenPending.current = undefined; });
}, [continuous, legacyView, projectId, selectedAgentId, consume]);
useEffect(() => { markSeen(); }, [topic?.revision, markSeen]);
useEffect(() => {
if (!topic?.id || legacyFriend || legacyView) return;
const streamTopicId = topic.id;
let closed = false,
stream: EventSource | undefined;
void teacherApi
.events(topicBase, endpointId)
.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;
if (next.id === streamTopicId) consume(next);
});
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, endpointId, legacyFriend, legacyView, consume, label]);
const loadOlder = async () => {
if (!before || loadingOlder || !selectedAgentId) return;
const epoch = generation.current;
setLoadingOlder(true);
try {
const page = await teacherApi.conversation(projectId, selectedAgentId, before);
if (epoch !== generation.current) return;
const pane = scrollRef.current;
if (pane) olderAnchor.current = { height: pane.scrollHeight, top: pane.scrollTop };
followBottom.current = false;
if (page.topic) consume(page.topic);
setBefore(page.before);
} catch (error) { if (epoch === generation.current) setError(error instanceof Error ? error.message : '较早消息加载失败,请重试。'); }
finally { if (epoch === generation.current) setLoadingOlder(false); }
};
const loadLegacy = async () => {
setHistoryOpen(value => !value);
try {
setTopics(await teacherApi.legacyHistory(projectId));
} catch { setError('旧记录暂时无法读取。'); }
};
const returnToConversation = async () => {
if (!selectedAgentId) return;
const view = await teacherApi.conversation(projectId, selectedAgentId);
viewingLegacy.current = false;
setTopic(view.topic); setBefore(view.before); setDefinition(selectedAgentRef.current?.definition ?? null);
setHistoryOpen(false);
};
const choose = async (id: string) => {
const version = ++generation.current;
setBusy(true);
setError('');
try {
const next = continuous ? await teacherApi.legacyRead(projectId, topics.items.find(item => item.id === id)!)
: await teacherApi.read(legacyTopicBase(base, topics.items.some(item => item.id === id && item.legacyRole === 'friend')), id);
if (version === generation.current) {
if (continuous) viewingLegacy.current = true;
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 retryScopeMatches = (request: TeacherRequest) => !continuous
|| (request.projectId === projectId && (request.sourceConversationId ?? '') === sourceId);
const send = async (action?: Pick<TeacherSend, 'text' | 'intent'>, retry?: TeacherRequest) => {
// A historical retry stays with its original topic and published Agent.
if (retry && (topicRef.current?.id !== topic?.id || !topic || !retryScopeMatches(retry)
|| (!continuous && topic.projectId !== projectId)
|| (selectedAgentId && topic.definition.config_id !== selectedAgentId)
|| !topic.requests.some((request) => request === retry && request.status === 'completed' && request.replyIncomplete))) return;
const question = action?.text ?? text;
const questionReferences = retry?.references ?? (action ? [] : references);
const questionSourceId = retry
? retry.sourceConversationId ?? (continuous || topic?.sourceConversationId === 'project' ? undefined : topic?.sourceConversationId)
: sourceId;
if (voiceInput.state !== 'idle' || !question.trim() || busy || sendInFlight.current || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
if (continuous && !retry && questionReferences.length && (referenceScope.projectId !== projectId || referenceScope.sourceId !== sourceId)) {
setError('引用来自另一个项目或操作对话,请切回来源,或移除引用后再提问。'); return;
}
if (continuous && pending.current && !topicRef.current?.requests.some(request => request.id === pending.current?.requestId)
&& (pending.current.text === question || (action?.intent === 'suggestions' && pending.current.intent === 'suggestions'))
&& pending.current.projectId && pending.current.projectId !== projectId) {
setError('上次提问仍绑定原项目,请切回原项目确认结果后重试。'); return;
}
if (continuous && !retry && action?.intent === 'suggestions' && pending.current?.intent === 'suggestions'
&& !topicRef.current?.requests.some(request => request.id === pending.current?.requestId)
&& pending.current.sourceConversationId !== (questionSourceId || undefined)) {
setError('上次提问仍绑定原操作对话,请切回原操作对话确认结果后重试。'); return;
}
const version = generation.current;
sendInFlight.current = true;
setBusy(true);
setError('');
let current = topicRef.current;
try {
if (!current && !continuous) {
current = await teacherApi.create(
base,
draftRevision,
sampleContext,
selectedTeacherVersion
);
if (version !== generation.current) return;
consume(current);
setDefinition(current.definition);
}
const previous = pending.current && !current?.requests.some((request) => request.id === pending.current?.requestId)
? pending.current
: null;
// Copy changes must not create another charge for an uncertain hint request.
const retrySuggestions = !retry && action?.intent === 'suggestions' && previous?.intent === 'suggestions';
const input =
previous && (!continuous || (previous.projectId === projectId
&& previous.sourceConversationId === (questionSourceId || undefined))) && (retrySuggestions || (
previous.text === question &&
(previous.intent ?? 'question') === (action?.intent ?? 'question') &&
JSON.stringify(previous.references) === JSON.stringify(questionReferences) &&
(!retry || previous.sourceConversationId === (questionSourceId || undefined))
))
? previous
: {
requestId: crypto.randomUUID(), text: question, references: questionReferences,
...((retry ? retry.modelId : modelChoice.modelId) ? { modelId: retry ? retry.modelId : modelChoice.modelId } : {}),
...(continuous ? { projectId } : {}),
...(action?.intent ? { intent: action.intent } : {}),
...(questionSourceId ? { sourceConversationId: questionSourceId } : {}),
};
pending.current = input;
persistDraft(text, references, input);
const next = continuous ? await teacherApi.sendConversation(projectId, selectedAgentId!, input)
: await teacherApi.send(topicBase, current!.id, input);
if (version !== generation.current) return;
consume(next);
pending.current = null;
if (action) {
persistDraft(draftRef.current.text, draftRef.current.references);
} else {
setText('');
setReferences([]);
persistDraft('', []);
}
if (!continuous) setTopics(await teacherApi.list(base));
} catch (e) {
if (version === generation.current) {
const code = (e as { details?: { backendCode?: string } } | null)?.details?.backendCode;
// These Main validation failures precede acceptance; allow correcting the selection.
// Uncertain transport failures retain the original request and model to avoid double charging.
if (code === 'teacher_model_unavailable' || code === 'teacher_model_invalid') {
pending.current = null;
persistDraft(draftRef.current.text, draftRef.current.references);
}
setError(e instanceof Error ? e.message : '提问失败,请保留问题后重试');
}
} finally {
sendInFlight.current = false;
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 askForSuggestions = () => void send({ text: '帮我整体看🧠', intent: 'suggestions' });
const changeText = (next: string) => { draftRef.current.text = next; setText(next); persistDraft(next, references, pending.current); };
const voiceInput = useWorksVoiceInput({
enabled: !busy && topicEnabled && !running,
scopeKey: `${modelScope}:${projectId}:${sourceId}:${topic?.id ?? 'new'}:${modelChoice.modelId ?? 'default'}`,
onTranscript: transcript => {
const current = draftRef.current.text;
const next = current.trim() ? `${current.trimEnd()}\n${transcript}` : transcript;
if (next.length > 6000) { setError('文字超过 6000 字,请缩短输入后重新录音。'); return; }
changeText(next);
},
});
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={`${definition?.name ?? label}咨询`}>
{Boolean(draftRevision) && <p className="px-5 pt-3 text-[11px] text-muted-foreground">运营草稿试聊</p>}
{continuous && <div className="flex items-center justify-between gap-2 px-5 pt-2 text-[11px] text-muted-foreground">
<span>本轮项目:{projectName || projectId}{sourceId ? ' · 当前操作对话' : ''}</span>
<button type="button" onClick={() => void loadLegacy()}>旧记录</button>
</div>}
{continuous && historyOpen && <div className="space-y-2 px-5 pt-2 text-xs">
<p className="text-muted-foreground">升级前的原始记录保留在这里,仅供查看。</p>
{topics.items.map(item => <button className="block" key={item.id} onClick={() => void choose(item.id)}>{item.title}(只读)</button>)}
{!topics.items.length && <p>没有待查看的旧记录。</p>}
</div>}
{legacyView && <button className="px-5 pt-2 text-left text-xs underline" onClick={() => void returnToConversation().catch(() => setError('暂时无法返回聊天。'))}>返回与智能体的聊天</button>}
{!continuous && (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>}
<div ref={scrollRef} className="consultation-messages min-h-0 flex-1 overflow-y-auto px-5 py-6"
onScroll={() => { const pane = scrollRef.current; if (!pane) return;
scrollPosition.current = pane.scrollTop;
followBottom.current = pane.scrollHeight - pane.scrollTop - pane.clientHeight < 80;
markSeen();
if (continuous && !legacyView && pane.scrollTop < 40) void loadOlder(); }}>
{continuous && !legacyView && before && <button type="button" disabled={loadingOlder} className="mb-4 w-full text-xs text-muted-foreground" onClick={() => void loadOlder()}>{loadingOlder ? '正在加载…' : '查看更早消息'}</button>}
{!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, index) => <div key={request.id} className="mb-7 space-y-4">
{continuous && request.projectId && (index === 0 || request.projectId !== topic.requests[index - 1].projectId) && <p className="text-center text-[10px] text-muted-foreground">{request.projectName || '之前的项目'}</p>}
{request.intent === 'check-in'
? <div className="space-y-1"><p className="text-[11px] text-muted-foreground">智能体来看看你的进展</p><MessageTimestamp value={request.createdAt} label="主动检查时间" /></div>
: <div className="flex flex-col items-end gap-1"><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><MessageTimestamp value={request.createdAt} label="发送时间" className="text-right" /></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.reasoning && <details open={['running', 'preparing'].includes(request.status)} className="min-w-0 text-xs text-muted-foreground" data-testid="teacher-reasoning">
<summary className="cursor-pointer">思考过程</summary>
<div className="mt-2 whitespace-pre-wrap break-words border-l-2 pl-3 leading-6 [overflow-wrap:anywhere]" aria-label="智能体思考过程">{request.reasoning}</div>
</details>}
{!!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>}
<div className="space-y-1.5">
{request.status === 'completed' && request.replyIncomplete ? <div className="space-y-3">
<p role="status" className="text-[13px] leading-7 text-muted-foreground">这次回复未能完整显示</p>
{request.intent !== 'check-in' && request.text.trim() && <button type="button" disabled={helpUnavailable || !retryScopeMatches(request)} onClick={() => void send({ text: request.text, intent: request.intent }, request)} className="consultation-retry">重新回答</button>}
{!retryScopeMatches(request) && <p className="text-xs text-muted-foreground">回到原项目和操作对话后,可以重新回答。</p>}
</div> : request.intent === 'suggestions' ? <>
{request.response && (request.status === 'completed' || request.presentation === 'reply-v1') && <TeacherReply text={request.response} />}
{['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>}
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'} onClick={askForSuggestions} className="consultation-retry">帮我整体看🧠</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" />{label}正在想…</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
<MessageTimestamp value={request.finishedAt} pending={['running', 'preparing'].includes(request.status)} />
</div>
{request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) && <div className="consultation-follow-ups" aria-label={request.intent === 'suggestions' ? '可以和智能体聊的问题' : '接着聊'}>
{request.suggestedQuestions?.map((question, index) => <button type="button" key={`${request.id}:${index}`} disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-follow-up"><span className="min-w-0 flex-1">{question}</span><ChevronRight className="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" /></button>)}
</div>}
{request.status === 'completed' && !request.replyIncomplete && request.replyParseError && <p role="status" className="text-xs text-muted-foreground">回答正文已保留,部分附加内容未能显示。</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.error && <p className="text-xs text-destructive">{request.error}</p>}
{onBringBack && request.response && !request.replyIncomplete && request.intent !== 'suggestions' && request.status === 'completed' && <div className="text-[11px] text-muted-foreground">
<button disabled={continuous && (request.projectId !== projectId || request.sourceConversationId !== sourceId)} onClick={() => onBringBack(request.response)}>
{continuous && (request.projectId !== projectId || request.sourceConversationId !== sourceId) ? '请打开来源项目和操作对话后带回' : '带回主会话草稿'}</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, endpointId).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="shrink-0 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>}
{!draftRevision && !text.trim() && (definition?.shortcuts == null || definition.shortcuts.length > 0) &&
<div className="consultation-shortcuts" role="group" aria-label="快捷提问" data-testid="consultation-shortcuts">
{definition?.shortcuts == null
? <button type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'} onClick={askForSuggestions} className="consultation-hint">帮我整体看🧠</button>
: definition.shortcuts.map((shortcut, index) => <button
key={index} type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'}
onClick={() => void send({ text: shortcut.prompt })}
className="consultation-hint"
>{shortcut.label}</button>)}
</div>}
<div className="consultation-composer rounded-2xl border bg-white p-3 focus-within:ring-2 focus-within:ring-black/5">
<Textarea ref={inputRef} aria-label={`向${label}提问`} value={text} disabled={busy} maxLength={6000} onChange={(e) => changeText(e.target.value)} placeholder="和老师聊聊" rows={Math.min(6, text.split('\n').length)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void send(); } }}
className="max-h-48 !min-h-[42px] resize-none border-0 bg-transparent px-0 py-1 text-[13px] leading-6 shadow-none placeholder:text-muted-foreground/65 focus-visible:ring-0 focus-visible:ring-offset-0 md:text-[13px] md:leading-5" />
<div className="flex min-h-8 items-center gap-1" data-testid="consultation-composer-actions">
<div className="ml-auto flex min-w-0 max-w-full items-center gap-1" data-testid="teacher-composer-runtime-controls">
<ComposerVoiceButton voiceInput={voiceInput} />
{running ? <button type="button" className="h-8 rounded-lg border px-3 text-xs" onClick={() => void teacherApi.cancel(topicBase, endpointId, running.id).then(consume).catch(() => setError('暂时无法停止,请稍后重试。'))}>停止回复</button>
: <button type="submit" aria-label="提问" title={busy ? '正在发送…' : '发送'} disabled={busy || !topicEnabled || !text.trim() || voiceInput.state !== 'idle'} className="consultation-send flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
</div>
</div>
</div>
</form>
</aside>
);
}