feat: 在顶栏并排展示下发智能体并直接切换
This commit is contained in:
@@ -245,7 +245,8 @@ export function CodingChatPanel({
|
||||
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
|
||||
const teacherCompanion = useTeacherCompanion({ projectId: activeProject?.id ?? null, sourceId: targetConversationId, sourceBusy: running, sourceArchived: Boolean(selectedConversation?.archivedAt), teacherOpen: consultationOpen, teacherComposing });
|
||||
const [teacherBubble, setTeacherBubble] = useState<HTMLDivElement | null>(null);
|
||||
const openTeacher = () => {
|
||||
const openTeacher = (agentId?: string) => {
|
||||
if (agentId) teacherCompanion.selectAgent(agentId);
|
||||
const selectedText = window.getSelection()?.toString().trim();
|
||||
setTeacherQuote(selectedText ? { kind: 'code', text: selectedText.slice(0, 12000) } : undefined);
|
||||
setTeacherQuoteSource(`${activeProject?.id}:${targetConversationId}`);
|
||||
@@ -947,20 +948,21 @@ export function CodingChatPanel({
|
||||
|
||||
{consultationOpen && activeProject && (
|
||||
<div id="coding-consultation-dock" className="classroom-consultation-dock relative min-h-0 shrink-0 border-l" style={{ width: consultationWidth }}
|
||||
onKeyDown={(event) => { if (event.key === 'Escape') { setConsultationOpen(false); document.querySelector<HTMLButtonElement>(`[aria-label="智能体"]`)?.focus(); } }}>
|
||||
onKeyDown={(event) => { if (event.key === 'Escape') { setConsultationOpen(false); document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); } }}>
|
||||
<div role="separator" aria-label="调整咨询栏宽度" aria-orientation="vertical" aria-valuemin={320} aria-valuemax={640} aria-valuenow={consultationWidth} tabIndex={0} className="classroom-consultation-resizer"
|
||||
onPointerDown={(event) => { if (event.button === 0) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); } }}
|
||||
onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) setConsultationWidth(Math.max(320, Math.min(640, window.innerWidth - event.clientX))); }}
|
||||
onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }}
|
||||
onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); setConsultationWidth((width) => Math.max(320, Math.min(640, width + (event.key === 'ArrowLeft' ? 20 : -20)))); } }} />
|
||||
<TeacherChatPanel key={`${account}:${activeProject.id}`}
|
||||
<TeacherChatPanel key={`${account}:${activeProject.id}:${teacherCompanion.selectedAgent?.teacher_id ?? 'history'}`}
|
||||
projectId={activeProject.id} projectName={activeProject.name} sourceId={targetConversationId ?? ''}
|
||||
selectedAgent={teacherCompanion.selectedAgent}
|
||||
externalTopic={teacherCompanion.topic}
|
||||
onTopicChange={teacherCompanion.noteTopic}
|
||||
onComposingChange={setTeacherComposing}
|
||||
expanded={consultationWidth > 508} onExpand={() => setConsultationWidth((width) => width > 508 ? 508 : 640)}
|
||||
quote={consultationOpen && teacherQuoteSource === `${activeProject.id}:${targetConversationId}` ? teacherQuote : undefined}
|
||||
onClose={() => { setConsultationOpen(false); document.querySelector<HTMLButtonElement>(`[aria-label="智能体"]`)?.focus(); }} />
|
||||
onClose={() => { setConsultationOpen(false); document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); }} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -28,6 +28,7 @@ export function TeacherChatPanel({
|
||||
externalTopic,
|
||||
onTopicChange,
|
||||
onComposingChange,
|
||||
selectedAgent,
|
||||
}: {
|
||||
projectId: string;
|
||||
sourceId: string;
|
||||
@@ -42,17 +43,21 @@ export function TeacherChatPanel({
|
||||
externalTopic?: TeacherTopic | null;
|
||||
onTopicChange?(topic: TeacherTopic): void;
|
||||
onComposingChange?(composing: boolean): void;
|
||||
selectedAgent?: TeacherCatalog['items'][number];
|
||||
}) {
|
||||
const base = teacherTopicsPath(projectId);
|
||||
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
|
||||
// Retain the existing project draft key; its suffix is a storage identifier, not a role.
|
||||
const draftKey = !draftRevision ? `makelore-consultation-draft:${account}:${projectId}:teacher` : null;
|
||||
const projectDraftKey = `makelore-consultation-draft:${account}:${projectId}:teacher`;
|
||||
const draftKey = !draftRevision ? selectedAgent ? `makelore-consultation-draft:${account}:${projectId}:agent:${selectedAgent.teacher_id}` : projectDraftKey : null;
|
||||
const [saved] = useState<{ text?: string; references?: TeacherReference[]; pending?: TeacherSend }>(() => {
|
||||
try { return draftKey ? JSON.parse(localStorage.getItem(draftKey) ?? '{}') : {}; } catch { return {}; }
|
||||
});
|
||||
const [legacyDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => {
|
||||
try { return draftKey ? JSON.parse(localStorage.getItem(`makelore-consultation-draft:${account}:${projectId}:friend`) ?? '{}') : {}; } catch { return {}; }
|
||||
});
|
||||
const [unassignedDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => {
|
||||
try { return selectedAgent ? JSON.parse(localStorage.getItem(projectDraftKey) ?? '{}') : {}; } catch { return {}; }
|
||||
});
|
||||
const persistDraft = useCallback((text: string, references: TeacherReference[], pending?: TeacherSend | null) => {
|
||||
if (!draftKey) return;
|
||||
try { localStorage.setItem(draftKey, JSON.stringify({ text, references, pending })); } catch { /* Draft remains in the open panel. */ }
|
||||
@@ -65,6 +70,9 @@ export function TeacherChatPanel({
|
||||
const avatar = getAgentAvatarSrc(definition?.avatar_id);
|
||||
const externalTopicRef = useRef(externalTopic);
|
||||
externalTopicRef.current = externalTopic;
|
||||
const selectedAgentId = selectedAgent?.teacher_id;
|
||||
const selectedAgentRef = useRef(selectedAgent);
|
||||
selectedAgentRef.current = selectedAgent;
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const focusedOnce = useRef(false);
|
||||
const [topics, setTopics] = useState<TeacherTopicList>({ items: [], lastSelectedTopicId: null });
|
||||
@@ -118,11 +126,12 @@ export function TeacherChatPanel({
|
||||
[persistDraft]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (externalTopic && externalTopic.projectId === projectId) {
|
||||
if (externalTopic && externalTopic.projectId === projectId
|
||||
&& (!selectedAgentId || externalTopic.definition.config_id === selectedAgentId)) {
|
||||
consume(externalTopic);
|
||||
setDefinition(externalTopic.definition);
|
||||
}
|
||||
}, [externalTopic, projectId, consume]);
|
||||
}, [externalTopic, projectId, consume, selectedAgentId]);
|
||||
useEffect(() => { if (topic) onTopicChange?.(topic); }, [topic, onTopicChange]);
|
||||
useEffect(() => {
|
||||
if (!busy && !focusedOnce.current) {
|
||||
@@ -150,10 +159,13 @@ export function TeacherChatPanel({
|
||||
const catalog = await teacherApi.catalog();
|
||||
if (alive) {
|
||||
setTeachers(catalog.items);
|
||||
const selected = catalog.items.find((item) => item.is_default) ?? catalog.items[0];
|
||||
const selected = selectedAgentId
|
||||
? catalog.items.find((item) => item.teacher_id === selectedAgentId)
|
||||
: catalog.items.find((item) => item.is_default) ?? catalog.items[0];
|
||||
setSelectedTeacherVersion(selected?.version);
|
||||
setEnabled(catalog.items.length > 0 || (config.enabled && !config.definition?.config_id));
|
||||
if (selected) setDefinition(selected.definition);
|
||||
setEnabled(selectedAgentId ? Boolean(selected) : catalog.items.length > 0 || (config.enabled && !config.definition?.config_id));
|
||||
const visibleAgent = selected ?? selectedAgentRef.current;
|
||||
if (visibleAgent) setDefinition(visibleAgent.definition);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -163,11 +175,14 @@ export function TeacherChatPanel({
|
||||
const listed = await teacherApi.list(base);
|
||||
if (!alive) return;
|
||||
setTopics(listed);
|
||||
if (externalTopicRef.current) {
|
||||
const previous = listed.items.find((item) => item.id === listed.lastSelectedTopicId
|
||||
&& (!selectedAgentId || item.teacherId === selectedAgentId))
|
||||
?? (selectedAgentId ? listed.items.find((item) => item.teacherId === selectedAgentId) : undefined);
|
||||
if (externalTopicRef.current && (!selectedAgentId || externalTopicRef.current.definition.config_id === selectedAgentId)) {
|
||||
consume(externalTopicRef.current);
|
||||
setDefinition(externalTopicRef.current.definition);
|
||||
} else if (listed.lastSelectedTopicId) {
|
||||
const current = await teacherApi.read(base, listed.lastSelectedTopicId);
|
||||
} else if (previous) {
|
||||
const current = await teacherApi.read(base, previous.id);
|
||||
if (alive && (!draftRevision || current.draftRevision === draftRevision)) {
|
||||
consume(current);
|
||||
setDefinition(current.definition);
|
||||
@@ -185,7 +200,7 @@ export function TeacherChatPanel({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
generation.current++;
|
||||
};
|
||||
}, [base, draftRevision, label, consume]);
|
||||
}, [base, draftRevision, label, consume, selectedAgentId]);
|
||||
useEffect(() => {
|
||||
if (!topic?.id || legacyFriend) return;
|
||||
const streamTopicId = topic.id;
|
||||
@@ -375,18 +390,6 @@ export function TeacherChatPanel({
|
||||
{onExpand && <button className="consult-icon" aria-label={expanded ? '恢复咨询栏宽度' : '展开咨询栏'} onClick={onExpand}><Maximize2 className="h-4 w-4" /></button>}
|
||||
{onClose && <button className="consult-icon" aria-label={`关闭${label}`} onClick={onClose}><X className="h-4 w-4" /></button>}
|
||||
</header>
|
||||
{!draftRevision && teachers.length > 0 && (
|
||||
<label className="px-5 pb-2 text-[11px] text-muted-foreground">
|
||||
新话题使用的智能体
|
||||
<select aria-label="新话题使用的智能体" value={selectedTeacherVersion ?? ''} disabled={busy}
|
||||
onChange={(event) => { const selected = teachers.find(item => item.version === Number(event.target.value)); setSelectedTeacherVersion(selected?.version); if (!topic && selected) setDefinition(selected.definition); }}
|
||||
className="mt-1 w-full rounded-lg border bg-background p-2 text-xs text-foreground">
|
||||
{teachers.map((item) => <option key={item.teacher_id} value={item.version}>
|
||||
{item.definition.name}{item.is_default ? '(默认)' : ''}
|
||||
</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{(topics.items.length > 0 || draftRevision) && <div className="px-5 pt-2">
|
||||
<button className="text-[11px] text-muted-foreground" onClick={() => setHistoryOpen(!historyOpen)} aria-expanded={historyOpen}>以往讨论 <ChevronDown className="inline h-3 w-3" /></button>
|
||||
{(historyOpen || draftRevision) && <div className="mt-2 flex gap-2">
|
||||
@@ -458,6 +461,13 @@ export function TeacherChatPanel({
|
||||
{topic?.unsaved && <div className="px-5 py-2 text-xs text-destructive">回复尚未保存,请复制或重试保存。<button className="ml-2 underline" onClick={() => void teacherApi.save(topicBase, topic.id).then(consume).catch(() => setError('保存失败,请先复制回复。'))}>重试保存</button></div>}
|
||||
{!topicEnabled && !error && !busy && <p className="px-5 pb-2 text-xs text-muted-foreground">{legacyFriend ? '这是旧版内置朋友的话题,仅供查看。请选择智能体新建话题。' : `${label}暂未开放,历史仍可查看。`}</p>}
|
||||
<form className="px-4 pb-4 pt-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
|
||||
{Boolean(unassignedDraft.text?.trim() || unassignedDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
|
||||
<summary>查看之前的项目草稿</summary>
|
||||
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">
|
||||
{unassignedDraft.text}
|
||||
{unassignedDraft.references?.map((ref, index) => <pre className="mt-2 whitespace-pre-wrap" key={index}>{ref.text}</pre>)}
|
||||
</div>
|
||||
</details>}
|
||||
{Boolean(legacyDraft.text?.trim() || legacyDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
|
||||
<summary>查看旧版朋友草稿</summary>
|
||||
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">
|
||||
|
||||
@@ -7,23 +7,38 @@ type Companion = ReturnType<typeof useTeacherCompanion>;
|
||||
export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
|
||||
companion: Companion;
|
||||
open: boolean;
|
||||
onOpen(): void;
|
||||
onOpen(agentId?: string): void;
|
||||
bubbleRef?: Ref<HTMLDivElement>;
|
||||
}) {
|
||||
const { definition, invitation, welcome, loading, enabled, checking, error } = companion;
|
||||
const name = definition?.name || '智能体';
|
||||
const avatar = getAgentAvatarSrc(definition?.avatar_id);
|
||||
const activeId = companion.selectedAgent?.teacher_id;
|
||||
const agents = companion.selectedAgent && !companion.agents.some((item) => item.teacher_id === activeId)
|
||||
? [...companion.agents, companion.selectedAgent] : companion.agents;
|
||||
const viewConversation = () => { companion.dismiss(); onOpen(); };
|
||||
const status = loading ? '正在连接智能体…'
|
||||
: checking ? '智能体在看看你最近的进展…'
|
||||
: error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说');
|
||||
return (
|
||||
<section className="teacher-companion no-drag" aria-label="智能体陪伴" data-testid="teacher-companion">
|
||||
<button type="button" className="teacher-companion-person" title={status} aria-label="智能体" aria-expanded={open} aria-controls="coding-consultation-dock" onMouseDown={(event) => event.preventDefault()} onClick={onOpen}>
|
||||
<nav className="consultation-agent-tabs" aria-label="已下发智能体">
|
||||
{agents.map((agent) => <button key={agent.teacher_id} type="button" className="teacher-companion-person"
|
||||
title={agent.definition.description || agent.definition.name} aria-label={agents.length === 1 ? '智能体' : `与${agent.definition.name}聊天`}
|
||||
aria-expanded={open && activeId === agent.teacher_id} aria-pressed={open && activeId === agent.teacher_id}
|
||||
aria-controls="coding-consultation-dock" data-agent-id={agent.teacher_id}
|
||||
onMouseDown={(event) => event.preventDefault()} onClick={() => onOpen(agent.teacher_id)}>
|
||||
<span className="teacher-companion-avatar"><img src={getAgentAvatarSrc(agent.definition.avatar_id)} alt="" />
|
||||
{activeId === agent.teacher_id && invitation && <span className="teacher-companion-unread" aria-hidden="true" />}</span>
|
||||
<span className="teacher-companion-name">{agent.definition.name}</span>
|
||||
{activeId === agent.teacher_id && checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
|
||||
</button>)}
|
||||
{(!agents.length || (!activeId && companion.topic)) && <button type="button" className="teacher-companion-person" title={status} aria-label="智能体" aria-expanded={open} aria-controls="coding-consultation-dock" onMouseDown={(event) => event.preventDefault()} onClick={() => onOpen()}>
|
||||
<span className="teacher-companion-avatar"><img src={avatar} alt="" />{invitation && <span className="teacher-companion-unread" aria-hidden="true" />}</span>
|
||||
<span className="teacher-companion-name">{name}</span>
|
||||
{checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
|
||||
</button>
|
||||
</button>}
|
||||
</nav>
|
||||
{(invitation || welcome) && <div ref={bubbleRef} className="teacher-companion-presence" data-testid="teacher-presence">
|
||||
<div className="teacher-companion-bubble" data-testid="teacher-invitation" data-bubble-kind={invitation ? 'check-in' : 'welcome'}>
|
||||
<div className="teacher-companion-bubble-heading">
|
||||
|
||||
@@ -69,7 +69,9 @@
|
||||
|
||||
/* The teacher speaks from the title bar without taking the student's focus. */
|
||||
.classroom-composer-view[hidden] { display: none; }
|
||||
.teacher-companion { position: relative; flex-shrink: 0; }
|
||||
.teacher-companion { position: relative; min-width: 0; max-width: 65%; }
|
||||
.consultation-agent-tabs { display: flex; align-items: center; gap: 4px; max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
|
||||
.consultation-agent-tabs .teacher-companion-person { flex-shrink: 0; }
|
||||
.teacher-companion-person { display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 8px; border-radius: 8px; color: #747d6c; transition: background 150ms; }
|
||||
.teacher-companion-person:hover, .teacher-companion-person[aria-expanded="true"] { background: #eef1e8; color: #42543a; }
|
||||
.teacher-companion-avatar { position: relative; flex-shrink: 0; }
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
TEACHER_CHECK_IN_INTERVAL_MS,
|
||||
type TeacherCheckInInput,
|
||||
type TeacherDefinition,
|
||||
type TeacherCatalog,
|
||||
type TeacherTopic,
|
||||
} from '../../../shared/coding-teacher';
|
||||
|
||||
interface CompanionState {
|
||||
agents: TeacherCatalog['items'];
|
||||
selectedAgent?: TeacherCatalog['items'][number];
|
||||
definition: TeacherDefinition | null;
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
@@ -44,7 +47,8 @@ export function useTeacherCompanion(options: Options) {
|
||||
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
|
||||
const scope = `${account}:${options.projectId ?? ''}`;
|
||||
const storageKey = `makelore-teacher-presence:${scope}`;
|
||||
const [state, setState] = useState<CompanionState>({ definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' });
|
||||
const [state, setState] = useState<CompanionState>({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' });
|
||||
const selectionEpoch = useRef(0);
|
||||
const latest = useRef({ options, state });
|
||||
latest.current = { options, state };
|
||||
const scopeRef = useRef(scope);
|
||||
@@ -55,8 +59,18 @@ export function useTeacherCompanion(options: Options) {
|
||||
if (scopeRef.current !== scope || topic.projectId !== options.projectId) return;
|
||||
setState((current) => current.topic?.id === topic.id && current.topic.revision >= topic.revision
|
||||
? current
|
||||
: { ...current, topic, error: '' });
|
||||
: { ...current, topic, selectedAgent: current.agents.find((item) => item.teacher_id === topic.definition.config_id)
|
||||
?? (topic.definition.config_id ? { teacher_id: topic.definition.config_id, version: topic.version, definition: topic.definition, is_default: false } : undefined), error: '' });
|
||||
}, [scope, options.projectId]);
|
||||
const selectAgent = useCallback((id: string) => {
|
||||
const current = latest.current.state;
|
||||
if (id === (current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id ?? current.definition?.config_id)) return;
|
||||
const agent = current.agents.find((item) => item.teacher_id === id);
|
||||
if (!agent) return;
|
||||
selectionEpoch.current++;
|
||||
setState((value) => ({ ...value, selectedAgent: agent, definition: agent.definition, topic: null,
|
||||
enabled: true, welcomeDismissed: true, error: '' }));
|
||||
}, []);
|
||||
const dismiss = useCallback(() => {
|
||||
const request = unreadInvitation(latest.current.state);
|
||||
writeLocal(storageKey + ':welcome-dismissed', true);
|
||||
@@ -77,7 +91,9 @@ export function useTeacherCompanion(options: Options) {
|
||||
const storedSeen = readLocal<unknown>(storageKey + ':seen', []);
|
||||
const seen = Array.isArray(storedSeen) ? storedSeen.filter((id): id is string => typeof id === 'string') : [];
|
||||
const welcomeDismissed = readLocal<boolean>(storageKey + ':welcome-dismissed', false) === true;
|
||||
setState({ definition: null, enabled: false, loading: true, topic: null, seen, welcomeDismissed, error: '' });
|
||||
selectionEpoch.current++;
|
||||
const initialSelection = selectionEpoch.current;
|
||||
setState({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen, welcomeDismissed, error: '' });
|
||||
const projectId = options.projectId;
|
||||
if (!projectId) {
|
||||
setState((current) => ({ ...current, loading: false }));
|
||||
@@ -86,8 +102,14 @@ export function useTeacherCompanion(options: Options) {
|
||||
const base = teacherTopicsPath(projectId);
|
||||
const loadConfig = async () => {
|
||||
const config = await teacherApi.config();
|
||||
if (alive) setState((current) => ({ ...current, definition: config.definition, enabled: config.enabled, loading: false }));
|
||||
return config;
|
||||
const catalog = await teacherApi.catalog();
|
||||
const selectedId = latest.current.state.selectedAgent?.teacher_id;
|
||||
const selected = catalog.items.find((item) => item.teacher_id === selectedId)
|
||||
?? (!selectedId ? catalog.items.find((item) => item.is_default) ?? catalog.items[0] : undefined);
|
||||
const enabled = selected ? true : !selectedId && config.enabled;
|
||||
if (alive) setState((current) => ({ ...current, agents: catalog.items,
|
||||
definition: selected?.definition ?? current.selectedAgent?.definition ?? current.topic?.definition ?? config.definition, enabled, loading: false }));
|
||||
return { enabled };
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -95,8 +117,8 @@ export function useTeacherCompanion(options: Options) {
|
||||
if (!alive) return;
|
||||
const topics = await teacherApi.list(base);
|
||||
if (!alive || !topics.lastSelectedTopicId) return;
|
||||
const topic = await teacherApi.read(base, topics.lastSelectedTopicId);
|
||||
if (alive && (!latest.current.state.topic || latest.current.state.topic.id === topic.id)) noteTopic(topic);
|
||||
const topic = await teacherApi.read(base, topics.lastSelectedTopicId, false);
|
||||
if (alive && initialSelection === selectionEpoch.current && (!latest.current.state.topic || latest.current.state.topic.id === topic.id)) noteTopic(topic);
|
||||
} catch {
|
||||
if (alive) setState((current) => ({ ...current, loading: false, error: '智能体暂时没连上,稍后再来看看。' }));
|
||||
}
|
||||
@@ -111,6 +133,8 @@ export function useTeacherCompanion(options: Options) {
|
||||
const { options: current, state: currentState } = latest.current;
|
||||
if (currentState.topic?.role === 'friend') return;
|
||||
if (!alive || inFlight.current || document.visibilityState !== 'visible' || currentState.loading) return;
|
||||
// A newly selected agent has no Main selection until its history loads or the student sends.
|
||||
if (currentState.selectedAgent && !currentState.topic) return;
|
||||
// A dropped event stream must not leave a completed reply stuck as thinking.
|
||||
if (currentState.topic?.requests.some((request) => ['preparing', 'running'].includes(request.status))) {
|
||||
inFlight.current = true;
|
||||
@@ -127,6 +151,7 @@ export function useTeacherCompanion(options: Options) {
|
||||
lastAttempt = Date.now();
|
||||
writeLocal(storageKey + ':last-attempt', lastAttempt);
|
||||
inFlight.current = true;
|
||||
const checkingSelection = selectionEpoch.current;
|
||||
try {
|
||||
const config = await loadConfig();
|
||||
const dispatch = latest.current.options;
|
||||
@@ -143,7 +168,7 @@ export function useTeacherCompanion(options: Options) {
|
||||
writeLocal(storageKey + ':last-attempt', lastAttempt);
|
||||
}
|
||||
writeLocal(storageKey + ':pending', null);
|
||||
if (result.topic) noteTopic(result.topic);
|
||||
if (result.topic && checkingSelection === selectionEpoch.current) noteTopic(result.topic);
|
||||
} catch (error) {
|
||||
if (!alive) return;
|
||||
if (error instanceof AppError && typeof error.details?.status === 'number' && error.details.status >= 400 && error.details.status < 500 && error.details.status !== 408) writeLocal(storageKey + ':pending', null);
|
||||
@@ -171,12 +196,13 @@ export function useTeacherCompanion(options: Options) {
|
||||
if (!topicId || !topicProjectId || topic?.role === 'friend') return;
|
||||
let alive = true;
|
||||
let stream: EventSource | undefined;
|
||||
const streamSelection = selectionEpoch.current;
|
||||
const base = teacherTopicsPath(topicProjectId);
|
||||
void teacherApi.events(base, topicId).then((source) => {
|
||||
if (!alive) { source.close(); return; }
|
||||
stream = source;
|
||||
source.addEventListener('snapshot', (event) => {
|
||||
if (!alive) return;
|
||||
if (!alive || streamSelection !== selectionEpoch.current) return;
|
||||
try { noteTopic(JSON.parse((event as MessageEvent).data) as TeacherTopic); } catch { /* Wait for the next authoritative snapshot. */ }
|
||||
});
|
||||
}).catch(() => { /* The consultation can still be opened and reloaded. */ });
|
||||
@@ -185,15 +211,20 @@ export function useTeacherCompanion(options: Options) {
|
||||
|
||||
const last = topic?.requests.at(-1);
|
||||
const invitation = unreadInvitation({ topic, seen: state.seen });
|
||||
const visibleState = stateScope.current === scope ? state : { definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: true, error: '' };
|
||||
const visibleState = stateScope.current === scope ? state : { agents: [], selectedAgent: undefined, definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: true, error: '' };
|
||||
// This is the published greeting, not a fabricated project assessment.
|
||||
const selectedDefinition = topic?.definition ?? visibleState.definition;
|
||||
const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id;
|
||||
const selectedAgent = visibleState.agents.find((item) => item.teacher_id === selectedAgentId)
|
||||
?? visibleState.selectedAgent;
|
||||
const welcome = topic?.role !== 'friend' && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed
|
||||
? selectedDefinition?.welcome_message.trim() || null : null;
|
||||
return {
|
||||
...visibleState,
|
||||
error: last?.intent === 'check-in' && ['failed', 'interrupted'].includes(last.status) ? '智能体这次没连上,可以点头像找智能体聊聊。' : visibleState.error,
|
||||
definition: selectedDefinition,
|
||||
selectedAgent,
|
||||
selectAgent,
|
||||
topic,
|
||||
invitation,
|
||||
welcome,
|
||||
|
||||
Reference in New Issue
Block a user