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(null); 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(null); const legacyFriend = topic?.role === 'friend'; const topicBase = legacyTopicBase(base, legacyFriend); 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 [enabled, setEnabled] = useState(false); const [legacyEnabled, setLegacyEnabled] = useState(false); const [teachers, setTeachers] = useState([]); const [selectedTeacherVersion, setSelectedTeacherVersion] = useState(); const topicEnabled = legacyFriend ? false : !topic ? enabled : topic.definition.config_id ? teachers.some((item) => item.teacher_id === topic.definition.config_id) : legacyEnabled; const pending = useRef(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(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) => { 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 (