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(); 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[0]) { const account = useAuthStore(state => state.user?.userId ?? state.user?.username ?? 'signed-out'); return ; } 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; }) { 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(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(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(null); const focusedOnce = useRef(false); const [topics, setTopics] = useState({ items: [], lastSelectedTopicId: null }); const [topic, setTopic] = useState(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(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([]); const [localTeacherVersion, setSelectedTeacherVersion] = useState(); // 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(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(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(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, 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 (