Merge remote main and reconcile agent refresh with ongoing chats
This commit is contained in:
@@ -11,6 +11,10 @@ import type {
|
||||
TeacherTopicList,
|
||||
TeacherConversationView,
|
||||
} from '../../shared/coding-teacher';
|
||||
export interface TeacherCatalogState {
|
||||
config: TeacherAvailability & { definition: TeacherDefinition | null };
|
||||
catalog: TeacherCatalog;
|
||||
}
|
||||
export function teacherTopicsPath(projectId: string) {
|
||||
return projectId === 'preview'
|
||||
? '/api/coding/teacher-preview/topics'
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface AgentBrowserPanelProps {
|
||||
onOpenChange(open: boolean): void;
|
||||
embedded?: boolean;
|
||||
teacherBubble?: HTMLElement | null;
|
||||
teacherIntroduction?: HTMLElement | null;
|
||||
conversationId?: string | null;
|
||||
onWorkConversation?(conversation: CodingConversationMetadata): void;
|
||||
workRevision?: number;
|
||||
@@ -133,6 +134,7 @@ export function AgentBrowserPanel({
|
||||
onOpenChange,
|
||||
embedded = false,
|
||||
teacherBubble,
|
||||
teacherIntroduction,
|
||||
conversationId,
|
||||
onWorkConversation,
|
||||
workRevision,
|
||||
@@ -149,7 +151,7 @@ export function AgentBrowserPanel({
|
||||
const [events, setEvents] = useState<AgentBrowserCdpEvent[]>([]);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const panelRef = useRef<HTMLElement | null>(null);
|
||||
usePreviewClearance(panelRef, viewportRef, teacherBubble, embedded && open);
|
||||
usePreviewClearance(panelRef, viewportRef, teacherBubble, embedded && open, teacherIntroduction);
|
||||
const addressInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const eventCursorRef = useRef(0);
|
||||
const snapshotGenerationRef = useRef(0);
|
||||
|
||||
@@ -245,6 +245,7 @@ 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 [teacherIntroduction, setTeacherIntroduction] = useState<HTMLDivElement | null>(null);
|
||||
const openTeacher = (agentId?: string) => {
|
||||
if (agentId) teacherCompanion.selectAgent(agentId);
|
||||
const selectedText = window.getSelection()?.toString().trim();
|
||||
@@ -253,6 +254,19 @@ export function CodingChatPanel({
|
||||
setConsultationOpen(true);
|
||||
setAgentBrowserOpen(true);
|
||||
};
|
||||
const closeTeacher = () => {
|
||||
setConsultationOpen(false);
|
||||
const entry = document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"]')
|
||||
?? document.querySelector<HTMLButtonElement>('.consultation-agent-tabs button');
|
||||
entry?.focus();
|
||||
};
|
||||
const toggleTeacher = (agentId?: string) => {
|
||||
if (consultationOpen && (!agentId || agentId === teacherCompanion.selectedAgent?.teacher_id)) {
|
||||
closeTeacher();
|
||||
return;
|
||||
}
|
||||
openTeacher(agentId);
|
||||
};
|
||||
const promptMode = running && draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt';
|
||||
const entryLoadState = useCodingConversationStore((state) => (
|
||||
targetConversationId
|
||||
@@ -761,7 +775,7 @@ export function CodingChatPanel({
|
||||
onToggleBrowser={() => setAgentBrowserOpen((current) => !current)}
|
||||
projectName={activeProject?.name}
|
||||
consultationOpen={consultationOpen}
|
||||
teacherEntry={activeProject ? <TeacherCompanion companion={teacherCompanion} open={consultationOpen} onOpen={openTeacher} bubbleRef={setTeacherBubble} /> : undefined}
|
||||
teacherEntry={activeProject ? <TeacherCompanion companion={teacherCompanion} open={consultationOpen} onOpen={openTeacher} onToggle={toggleTeacher} bubbleRef={setTeacherBubble} introductionRef={setTeacherIntroduction} /> : undefined}
|
||||
/>
|
||||
|
||||
<div className="classroom-tabs" role="tablist" aria-label="当前工作">
|
||||
@@ -771,6 +785,7 @@ export function CodingChatPanel({
|
||||
<div id="classroom-work-view" role="tabpanel" aria-labelledby="classroom-work-tab" hidden={!agentBrowserOpen} className="classroom-work-view min-h-0 flex-1 flex-col p-5">
|
||||
<AgentBrowserPanel projectId={activeProject?.id ?? null} open={agentBrowserOpen} onOpenChange={setAgentBrowserOpen} embedded
|
||||
teacherBubble={teacherBubble}
|
||||
teacherIntroduction={teacherIntroduction}
|
||||
workRevision={workRevision}
|
||||
onRecovery={handleRecovery}
|
||||
onWorkResult={(result) => setReadyWorkProjectId(result.status === 'ready' ? activeProject?.id ?? null : null)}
|
||||
@@ -948,7 +963,7 @@ 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>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); } }}>
|
||||
onKeyDown={(event) => { if (event.key === 'Escape') closeTeacher(); }}>
|
||||
<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))); }}
|
||||
@@ -957,12 +972,12 @@ export function CodingChatPanel({
|
||||
<TeacherChatPanel key={`${account}:${teacherCompanion.selectedAgent?.teacher_id ?? activeProject.id}`}
|
||||
projectId={activeProject.id} projectName={activeProject.name} sourceId={targetConversationId ?? ''}
|
||||
selectedAgent={teacherCompanion.selectedAgent}
|
||||
catalogState={teacherCompanion.catalogState}
|
||||
reloadCatalog={teacherCompanion.reloadCatalog}
|
||||
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>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); }} />
|
||||
quote={consultationOpen && teacherQuoteSource === `${activeProject.id}:${targetConversationId}` ? teacherQuote : undefined} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowUp, ChevronDown, ChevronRight, Loader2, Maximize2, Plus, X } from 'lucide-react';
|
||||
import { ArrowUp, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { teacherApi, teacherTopicsPath, legacyTopicBase } from '@/lib/coding-teacher';
|
||||
import { teacherApi, teacherTopicsPath, legacyTopicBase, type TeacherCatalogState } from '@/lib/coding-teacher';
|
||||
import type {
|
||||
TeacherDefinition,
|
||||
TeacherCatalog,
|
||||
@@ -25,32 +25,30 @@ export function TeacherChatPanel({
|
||||
projectId,
|
||||
projectName,
|
||||
sourceId,
|
||||
onClose,
|
||||
onBringBack,
|
||||
quote,
|
||||
draftRevision,
|
||||
sampleContext,
|
||||
expanded,
|
||||
onExpand,
|
||||
externalTopic,
|
||||
onTopicChange,
|
||||
onComposingChange,
|
||||
selectedAgent,
|
||||
catalogState,
|
||||
reloadCatalog,
|
||||
}: {
|
||||
projectId: string;
|
||||
sourceId: string;
|
||||
onClose?(): void;
|
||||
onBringBack?(text: string): void;
|
||||
quote?: TeacherReference;
|
||||
draftRevision?: number;
|
||||
sampleContext?: string;
|
||||
projectName?: string;
|
||||
expanded?: boolean;
|
||||
onExpand?(): void;
|
||||
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 ? '/api/coding/agent-conversations' : teacherTopicsPath(projectId);
|
||||
@@ -87,7 +85,9 @@ export function TeacherChatPanel({
|
||||
const restoreTop = useRef(cachedView?.top);
|
||||
const scrollPosition = useRef(cachedView?.top ?? 0);
|
||||
const [focus, setFocus] = useState<{ topicId: string; id: string; label: string } | null>(null);
|
||||
const [definition, setDefinition] = useState<TeacherDefinition | null>(null);
|
||||
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;
|
||||
@@ -113,10 +113,22 @@ export function TeacherChatPanel({
|
||||
draftRef.current = { text, references };
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [legacyEnabled, setLegacyEnabled] = useState(false);
|
||||
const [teachers, setTeachers] = useState<TeacherCatalog['items']>([]);
|
||||
const [selectedTeacherVersion, setSelectedTeacherVersion] = useState<number>();
|
||||
const [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 topicEnabled = legacyFriend || legacyView ? false : !topic ? enabled
|
||||
: topic.definition.config_id
|
||||
? teachers.some((item) => item.teacher_id === topic.definition.config_id)
|
||||
@@ -191,25 +203,31 @@ export function TeacherChatPanel({
|
||||
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 (!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) {
|
||||
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);
|
||||
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) {
|
||||
@@ -249,7 +267,7 @@ export function TeacherChatPanel({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
generation.current++;
|
||||
};
|
||||
}, [base, draftRevision, label, consume, selectedAgentId, continuous, cachedView]);
|
||||
}, [base, draftRevision, label, consume, selectedAgentId, continuous, cachedView, reloadCatalog]);
|
||||
const seenPending = useRef<string | undefined>(undefined);
|
||||
const markSeen = useCallback(() => {
|
||||
const current = topicRef.current;
|
||||
@@ -477,15 +495,8 @@ export function TeacherChatPanel({
|
||||
const questions = definition?.suggested_questions ?? [];
|
||||
return (
|
||||
<aside className="consultation-pane flex h-full min-h-0 w-full flex-col"
|
||||
data-testid="teacher-chat-panel" aria-label={`${label}聊天`}>
|
||||
<header className="consultation-header flex h-[68px] shrink-0 items-center gap-3 px-5">
|
||||
<TeacherAvatar className="consultation-avatar h-9 w-9 rounded-xl" definition={definition} />
|
||||
<div className="min-w-0 flex-1"><h2 className="truncate text-sm font-semibold">{definition?.name ?? '智能体'}</h2>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{draftRevision ? '运营草稿试聊' : definition?.description}</p></div>
|
||||
{!continuous && <button aria-label={`${label}新话题`} title="新话题" disabled={busy || !enabled} onClick={() => void newTopic()} className="consult-icon"><Plus className="h-4 w-4" /></button>}
|
||||
{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>
|
||||
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>
|
||||
@@ -502,6 +513,8 @@ export function TeacherChatPanel({
|
||||
<select aria-label={`${label}话题`} value={topic?.id ?? ''} disabled={busy} onChange={(e) => void choose(e.target.value)} className="min-w-0 flex-1 rounded-lg border bg-background p-2 text-xs">
|
||||
{!topic && <option value="">选择话题</option>}{topics.items.map((item) => <option key={item.id} value={item.id}>{item.title}{item.legacyRole ? '(旧版,仅查看)' : ''}</option>)}
|
||||
</select>
|
||||
<button type="button" aria-label={`${label}新话题`} disabled={busy || !enabled} onClick={() => void newTopic()}
|
||||
className="shrink-0 rounded-lg px-2 text-xs text-muted-foreground hover:bg-white hover:text-foreground disabled:opacity-40">新话题</button>
|
||||
</div>}
|
||||
</div>}
|
||||
{discussionEnabled && topic?.discussion && <TeacherDiscussionPanel key={topic.id} discussion={topic.discussion}
|
||||
@@ -545,16 +558,15 @@ export function TeacherChatPanel({
|
||||
? <p role="status" className="flex items-center gap-2 text-[13px] leading-7 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />我看看你最近做到了哪里…</p>
|
||||
: request.status === 'completed' && <>
|
||||
<TeacherReply text={request.response} />
|
||||
<div className="flex flex-col gap-2" aria-label="可以和智能体聊的问题">
|
||||
{request.suggestedQuestions?.map((question) => <button key={question} type="button" disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-suggestion flex min-h-11 items-center justify-between gap-3 rounded-xl border bg-white px-3 py-3 text-left text-[13px] leading-6 transition-colors disabled:cursor-not-allowed disabled:opacity-50">{question}<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-60" /></button>)}
|
||||
<div className="consultation-follow-ups" aria-label="可以和智能体聊的问题">
|
||||
{request.suggestedQuestions?.map((question) => <button key={question} type="button" 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>
|
||||
<button type="button" disabled={helpUnavailable} onClick={askForGuidance} className="consultation-guided-help min-h-10 text-left text-xs leading-6 underline decoration-current/30 underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50">我也说不清,你带我看看</button>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">也可以直接在下面说说你的想法。</p>
|
||||
</>}
|
||||
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-guided-help min-h-10 text-xs underline underline-offset-4 disabled:opacity-50">再请智能体看看</button>}
|
||||
</> : request.response ? <TeacherReply text={request.response} /> : <div className="text-[13px] leading-7">{['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{request.discussionContext ? '正在梳理你的新想法…' : `${label}正在想…`}</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
|
||||
{discussionEnabled && request.intent !== 'suggestions' && request.status === 'completed' && topic.discussion?.status !== 'active' && topic.discussion?.status !== 'offered' && Boolean(request.suggestedQuestions?.length) && <div className="flex flex-col gap-2" aria-label="接着聊">
|
||||
{request.suggestedQuestions?.map(question => <button type="button" key={question} disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-suggestion rounded-xl border px-3 py-2 text-left text-xs leading-6">{question}<ChevronRight className="ml-1 inline h-3 w-3" /></button>)}
|
||||
{discussionEnabled && request.intent !== 'suggestions' && request.status === 'completed' && topic.discussion?.status !== 'active' && topic.discussion?.status !== 'offered' && Boolean(request.suggestedQuestions?.length) && <div className="consultation-follow-ups" aria-label="接着聊">
|
||||
{request.suggestedQuestions?.map(question => <button type="button" key={question} 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.discussionError && <p className="text-xs leading-6 text-muted-foreground">{request.discussionError}</p>}
|
||||
{request.unparsedResponse && <details className="min-w-0 text-xs text-muted-foreground">
|
||||
@@ -563,7 +575,6 @@ export function TeacherChatPanel({
|
||||
</details>}
|
||||
{request.progress && ['running', 'preparing'].includes(request.status) && <p role="status" className="text-xs text-muted-foreground">{request.progress}</p>}
|
||||
{(request.truncatedMessages ?? 0) > 0 && <p className="text-xs text-muted-foreground">{draftRevision ? '较长的上下文已节选。' : '较长的上下文已节选,智能体可按需读取原文。'}</p>}
|
||||
{request.omittedMessages > 0 && <p className="text-[10px] text-muted-foreground">本轮参考了较近的讨论,省略了 {request.omittedMessages} 条较早内容。</p>}
|
||||
{request.error && <p className="text-xs text-destructive">{request.error}</p>}
|
||||
{onBringBack && request.response && request.intent !== 'suggestions' && request.status === 'completed' && <div className="text-[11px] text-muted-foreground">
|
||||
<button disabled={continuous && (request.projectId !== projectId || request.sourceConversationId !== sourceId)} onClick={() => onBringBack(request.response)}>
|
||||
@@ -592,7 +603,7 @@ export function TeacherChatPanel({
|
||||
{references.length > 0 && <div className="mb-2 rounded-lg bg-white p-3 text-xs"><p className="line-clamp-3 whitespace-pre-wrap">{references.map((ref) => ref.text).join('\n')}</p><button type="button" className="mt-1 underline" onClick={removeReferences}>移除引用</button></div>}
|
||||
<div className="consultation-composer rounded-2xl border bg-white p-3 focus-within:ring-2 focus-within:ring-black/5">
|
||||
{activeFocus && <DiscussionFocus label={activeFocus.label} onClear={() => setFocus(null)} />}
|
||||
<textarea ref={inputRef} aria-label={`向${label}提问`} value={text} disabled={busy} maxLength={6000} onChange={(e) => changeText(e.target.value)} placeholder={`想和${label}聊些什么?`} rows={2}
|
||||
<textarea ref={inputRef} aria-label={`向${label}提问`} value={text} disabled={busy} maxLength={6000} onChange={(e) => changeText(e.target.value)} placeholder="和老师聊聊" rows={2}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void send(); } }}
|
||||
className="min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" />
|
||||
<div className="mt-2 flex items-center justify-between gap-2" data-testid="consultation-composer-actions">
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { ArrowUpRight, Loader2, X } from 'lucide-react';
|
||||
import type { Ref } from 'react';
|
||||
import { ArrowUpRight, Loader2, RefreshCw, X } from 'lucide-react';
|
||||
import { useState, type Ref } from 'react';
|
||||
import { Provider as TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import { TeacherAvatar } from './TeacherAvatar';
|
||||
import { TeacherIntroduction } from './TeacherIntroduction';
|
||||
import type { useTeacherCompanion } from './use-teacher-companion';
|
||||
|
||||
type Companion = ReturnType<typeof useTeacherCompanion>;
|
||||
export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
|
||||
export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef, introductionRef }: {
|
||||
companion: Companion;
|
||||
open: boolean;
|
||||
onOpen(agentId?: string): void;
|
||||
onToggle?(agentId?: string): void;
|
||||
bubbleRef?: Ref<HTMLDivElement>;
|
||||
introductionRef?: Ref<HTMLDivElement>;
|
||||
}) {
|
||||
const { definition, invitation, welcome, loading, enabled, checking, error } = companion;
|
||||
const [introductionId, setIntroductionId] = useState<string | null>(null);
|
||||
const introductionProps = (id: string) => ({
|
||||
open: introductionId === id,
|
||||
onOpenChange: (next: boolean) => setIntroductionId((current) => next ? id : current === id ? null : current),
|
||||
});
|
||||
const name = definition?.name || '智能体';
|
||||
const activeId = companion.selectedAgent?.teacher_id;
|
||||
const agents = companion.selectedAgent && !companion.agents.some((item) => item.teacher_id === activeId)
|
||||
@@ -19,25 +28,38 @@ export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
|
||||
const status = loading ? '正在连接智能体…'
|
||||
: checking ? '智能体在看看你最近的进展…'
|
||||
: error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说');
|
||||
const refreshMessage = companion.refreshing ? '正在刷新…'
|
||||
: companion.refreshStatus === 'error' ? '刷新失败,请重试'
|
||||
: companion.refreshStatus === 'success' ? '已更新' : '';
|
||||
return (
|
||||
<TooltipProvider delayDuration={180} skipDelayDuration={100}>
|
||||
<section className="teacher-companion no-drag" aria-label="智能体陪伴" data-testid="teacher-companion">
|
||||
<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}聊天`}
|
||||
{agents.map((agent) => <TeacherIntroduction key={agent.teacher_id} definition={agent.definition} contentRef={introductionRef} {...introductionProps(`agent:${agent.teacher_id}`)}>
|
||||
<button type="button" className="teacher-companion-person"
|
||||
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)}>
|
||||
onMouseDown={(event) => event.preventDefault()} onClick={() => (onToggle ?? onOpen)(agent.teacher_id)}>
|
||||
<span className="teacher-companion-avatar"><TeacherAvatar definition={agent.definition} />
|
||||
{(companion.unreadAgentIds.includes(agent.teacher_id) || (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()}>
|
||||
</button></TeacherIntroduction>)}
|
||||
{(!agents.length || (!activeId && companion.topic)) && <TeacherIntroduction definition={definition} contentRef={introductionRef} {...introductionProps('history')}>
|
||||
<button type="button" className="teacher-companion-person" aria-description={status} aria-label="智能体" aria-expanded={open} aria-controls="coding-consultation-dock" onMouseDown={(event) => event.preventDefault()} onClick={() => (onToggle ?? onOpen)()}>
|
||||
<span className="teacher-companion-avatar"><TeacherAvatar definition={definition} />{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></TeacherIntroduction>}
|
||||
</nav>
|
||||
<button type="button" className="consultation-agents-refresh" aria-label="刷新智能体"
|
||||
title={refreshMessage || '刷新智能体'} disabled={loading || companion.refreshing} aria-busy={companion.refreshing}
|
||||
onMouseDown={(event) => event.preventDefault()} onClick={() => { setIntroductionId(null); void companion.refresh(); }}>
|
||||
<RefreshCw className={`h-3.5 w-3.5 shrink-0 ${companion.refreshing ? 'animate-spin motion-reduce:animate-none' : ''}`} aria-hidden="true" />
|
||||
<span>{companion.refreshing ? '刷新中' : companion.refreshStatus === 'error' ? '刷新失败' : companion.refreshStatus === 'success' ? '已更新' : '刷新'}</span>
|
||||
</button>
|
||||
<span className="sr-only" role="status" aria-live="polite">{refreshMessage}</span>
|
||||
{(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">
|
||||
@@ -55,5 +77,6 @@ export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
|
||||
</button>
|
||||
</div>}
|
||||
</section>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
55
src/pages/Chat/TeacherIntroduction.tsx
Normal file
55
src/pages/Chat/TeacherIntroduction.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useRef, type ReactElement, type Ref } from 'react';
|
||||
import type { TeacherDefinition } from '../../../shared/coding-teacher';
|
||||
import { TeacherAvatar } from './TeacherAvatar';
|
||||
|
||||
/** A temporary introduction, separate from the agent's unread messages. */
|
||||
export function TeacherIntroduction({ definition, children, contentRef, open, onOpenChange }: {
|
||||
definition: TeacherDefinition | null;
|
||||
children: ReactElement;
|
||||
contentRef?: Ref<HTMLDivElement>;
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
}) {
|
||||
const description = definition?.description?.trim();
|
||||
const clicked = useRef(false);
|
||||
return <Tooltip.Root open={Boolean(description) && open} onOpenChange={(next) => {
|
||||
if (!next || !clicked.current) onOpenChange(next);
|
||||
}}>
|
||||
<Tooltip.Trigger asChild onPointerEnter={(event) => {
|
||||
// Adjacent entries must switch immediately, even inside the previous
|
||||
// tooltip's pointer grace area used when moving into its readable card.
|
||||
if (event.pointerType === 'touch') return;
|
||||
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
|
||||
clicked.current = false;
|
||||
onOpenChange(true);
|
||||
}} onClick={() => {
|
||||
// A delayed Radix hover callback must not reopen the card after selection.
|
||||
clicked.current = true;
|
||||
onOpenChange(false);
|
||||
}} onFocus={() => {
|
||||
clicked.current = false;
|
||||
}} onPointerMove={(event) => {
|
||||
// pointerEnter owns hover opening. Skip Radix's second opening path so
|
||||
// moving inside an entry after clicking it cannot reopen its introduction.
|
||||
if (event.pointerType !== 'touch') event.preventDefault();
|
||||
}}>{children}</Tooltip.Trigger>
|
||||
{definition && description && <Tooltip.Portal>
|
||||
<Tooltip.Content ref={contentRef} side="bottom" align="end" sideOffset={10} collisionPadding={12}
|
||||
className="teacher-introduction no-drag" data-testid="teacher-introduction" aria-label={description}>
|
||||
<div className="teacher-introduction-heading">
|
||||
<TeacherAvatar definition={definition} className="teacher-introduction-avatar" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="teacher-introduction-eyebrow">认识一下</span>
|
||||
<p className="teacher-introduction-name">{definition.name}</p>
|
||||
</div>
|
||||
<Sparkles className="teacher-introduction-sparkles" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="teacher-introduction-description">{description}</p>
|
||||
<p className="teacher-introduction-hint">点点头像,一起聊聊</p>
|
||||
<Tooltip.Arrow className="teacher-introduction-arrow" width={18} height={9} />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>}
|
||||
</Tooltip.Root>;
|
||||
}
|
||||
@@ -49,28 +49,46 @@
|
||||
.classroom-consultation-resizer { position: absolute; inset: 0 auto 0 -4px; width: 8px; z-index: 2; cursor: col-resize; }
|
||||
.classroom-consultation-resizer:hover, .classroom-consultation-resizer:focus-visible { background: #7994b533; }
|
||||
.consultation-pane { background: #fffdf8; color: #30465f; }
|
||||
.classroom-workspace .consultation-header { height: 78px; padding: 12px 15px; gap: 10px; border-bottom: 1px solid #eddbb2; background: #fff4d9; }
|
||||
.classroom-workspace .consultation-avatar { width: 44px; height: 44px; flex-shrink: 0; border: 2px solid white; border-radius: 11px; box-shadow: 0 3px 0 #e3cea2; }
|
||||
.consultation-help-prompt, .consultation-guided-help { color: #536747; }
|
||||
.consultation-help-prompt { background: #f1f5ea; border: 1px solid #dfe7d5; }
|
||||
.consultation-help-prompt:hover:not(:disabled) { background: #e6eedc; border-color: #bacba8; }
|
||||
.consultation-pane button:focus-visible { outline: 2px solid #71845f; outline-offset: 3px; }
|
||||
.consultation-suggestion { border-color: #e0e6d6; color: #718163; }
|
||||
.consultation-suggestion:hover { border-color: #bac9aa; background: #f4f7ee; }
|
||||
.consultation-follow-ups { display: flex; flex-direction: column; gap: 10px; }
|
||||
.consultation-follow-up { --card-background: #eef5ff; --card-hover: #e2edfc; --card-border: #d3e3f7; display: flex; width: 100%; min-height: 48px; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border: 1px solid var(--card-border); border-radius: 16px; background: var(--card-background); color: #344963; font-size: 16px; line-height: 1.8; text-align: left; overflow-wrap: anywhere; cursor: pointer; transition: background-color 150ms ease, border-color 150ms ease; }
|
||||
.consultation-follow-up:nth-child(3n + 2) { --card-background: #fff4dc; --card-hover: #ffedc5; --card-border: #eeddb5; }
|
||||
.consultation-follow-up:nth-child(3n + 3) { --card-background: #f3edfc; --card-hover: #eae0f8; --card-border: #e2d7f2; }
|
||||
.consultation-follow-up:hover:not(:disabled) { background: var(--card-hover); }
|
||||
.consultation-follow-up:disabled { cursor: not-allowed; opacity: 0.5; }
|
||||
.consultation-pane .consultation-follow-up:focus-visible { outline-color: #6683a5; }
|
||||
.consultation-user { background: #eaf0e1; }
|
||||
.consultation-composer { border-color: #dce3d3; }
|
||||
.consultation-send { background: #536947; }
|
||||
.consult-icon { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 6px; color: #8c9781; }
|
||||
.consult-icon:hover { background: #eaf0e1; color: #45563b; }
|
||||
@media (max-width: 1100px) { .classroom-tabs { padding-left: 18px; padding-right: 18px; } }
|
||||
@media (max-width: 900px) { .classroom-consultation-dock { position: absolute; inset: 0 0 0 auto; z-index: 45; max-width: 100%; box-shadow: -12px 0 36px #24311b0d; } .classroom-consultation-resizer { display: none; } }
|
||||
@media (max-width: 600px) { .classroom-consultation-dock { width: 100%!important; } .classroom-workspace [data-testid="coding-message-composer"] { width: calc(100% - 24px); } }
|
||||
@media (prefers-reduced-motion: reduce) { .classroom-workspace *, .consultation-pane * { scroll-behavior: auto!important; } }
|
||||
@media (prefers-reduced-motion: reduce) { .consultation-follow-up { transition: none; } }
|
||||
|
||||
/* The teacher speaks from the title bar without taking the student's focus. */
|
||||
.classroom-composer-view[hidden] { display: none; }
|
||||
.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; }
|
||||
.teacher-companion { position: relative; display: flex; align-items: center; gap: 4px; min-width: 0; max-width: 65%; }
|
||||
.teacher-introduction { z-index: 200; width: min(304px, calc(100vw - 24px)); max-height: var(--radix-tooltip-content-available-height); padding: 18px; border: 2px solid #ead6af; border-radius: 22px; background: #fff9ed; color: #30465f; box-shadow: 0 5px 0 #ecdcc1, 0 12px 30px #354d6c18; transform-origin: var(--radix-tooltip-content-transform-origin); animation: teacher-introduction-in 150ms ease-out; }
|
||||
.teacher-introduction-heading { display: flex; align-items: center; gap: 11px; }
|
||||
.teacher-introduction-avatar { width: 48px; height: 48px; flex-shrink: 0; border: 2px solid #fff; border-radius: 15px; background: #e8f1fb; box-shadow: 0 3px 0 #d6e3ee; }
|
||||
.teacher-introduction-eyebrow { display: block; margin-bottom: 3px; color: #856637; font-size: 11px; font-weight: 500; }
|
||||
.teacher-introduction-name { overflow-wrap: anywhere; font-size: 15px; line-height: 1.5; font-weight: 600; }
|
||||
.teacher-introduction-sparkles { align-self: flex-start; width: 19px; height: 19px; flex-shrink: 0; color: #b9842f; }
|
||||
.teacher-introduction-description { max-height: min(240px, calc(var(--radix-tooltip-content-available-height) - 152px)); overflow-y: auto; overflow-wrap: anywhere; white-space: pre-wrap; margin-top: 16px; padding: 12px 13px; border: 1px solid #eee1c9; border-radius: 14px; background: #fffdf8; color: #48596e; font-size: 13px; line-height: 1.8; }
|
||||
.teacher-introduction-hint { margin-top: 12px; color: #776649; font-size: 11px; text-align: center; }
|
||||
.teacher-introduction-arrow { fill: #ead6af; }
|
||||
@keyframes teacher-introduction-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .teacher-introduction { animation: none; } }
|
||||
.consultation-agent-tabs { display: flex; align-items: center; gap: 4px; min-width: 0; max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
|
||||
.consultation-agents-refresh { display: flex; align-items: center; justify-content: center; gap: 5px; flex-shrink: 0; width: 76px; height: 32px; border: 1px solid #dce4d5; border-radius: 8px; color: #526348; background: #f7f9f3; font-size: 12px; white-space: nowrap; cursor: pointer; transition: background 150ms; }
|
||||
.consultation-agents-refresh:hover:not(:disabled) { background: #eaf0e1; }
|
||||
.consultation-agents-refresh:disabled { cursor: default; opacity: .6; }
|
||||
.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; }
|
||||
|
||||
@@ -6,24 +6,27 @@ export function usePreviewClearance(
|
||||
viewportRef: RefObject<HTMLElement | null>,
|
||||
bubble: HTMLElement | null | undefined,
|
||||
enabled: boolean,
|
||||
introduction?: HTMLElement | null,
|
||||
) {
|
||||
useLayoutEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
const viewport = viewportRef.current;
|
||||
if (!enabled || !panel || !viewport || !bubble) return;
|
||||
const overlays = [bubble, introduction].filter((item): item is HTMLElement => Boolean(item));
|
||||
if (!enabled || !panel || !viewport || !overlays.length) return;
|
||||
|
||||
let applied = 0;
|
||||
const update = () => {
|
||||
const message = bubble.getBoundingClientRect();
|
||||
const page = viewport.getBoundingClientRect();
|
||||
// Subtract our own previous offset so ResizeObserver cannot create a
|
||||
// feedback loop that alternately adds and removes the same clearance.
|
||||
const naturalTop = page.top - applied;
|
||||
const overlapsHorizontally = message.left < page.right && message.right > page.left;
|
||||
const visible = message.width > 0 && message.height > 0 && page.width > 0;
|
||||
const next = visible && overlapsHorizontally && message.top < page.bottom
|
||||
? Math.max(0, Math.ceil(message.bottom + 8 - naturalTop))
|
||||
: 0;
|
||||
const next = Math.max(0, ...overlays.map((overlay) => {
|
||||
const message = overlay.getBoundingClientRect();
|
||||
const overlapsHorizontally = message.left < page.right && message.right > page.left;
|
||||
const visible = message.width > 0 && message.height > 0 && page.width > 0;
|
||||
return visible && overlapsHorizontally && message.top < page.bottom
|
||||
? Math.ceil(message.bottom + 8 - naturalTop) : 0;
|
||||
}));
|
||||
if (next === applied) return;
|
||||
applied = next;
|
||||
panel.style.marginTop = next ? `${next}px` : '';
|
||||
@@ -31,7 +34,7 @@ export function usePreviewClearance(
|
||||
|
||||
update();
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
|
||||
observer?.observe(bubble);
|
||||
overlays.forEach((overlay) => observer?.observe(overlay));
|
||||
observer?.observe(viewport);
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
@@ -39,5 +42,5 @@ export function usePreviewClearance(
|
||||
window.removeEventListener('resize', update);
|
||||
panel.style.marginTop = '';
|
||||
};
|
||||
}, [bubble, enabled, panelRef, viewportRef]);
|
||||
}, [bubble, introduction, enabled, panelRef, viewportRef]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { teacherApi, teacherTopicsPath } from '@/lib/coding-teacher';
|
||||
import { teacherApi, teacherTopicsPath, type TeacherCatalogState } from '@/lib/coding-teacher';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import {
|
||||
@@ -11,8 +11,12 @@ import {
|
||||
} from '../../../shared/coding-teacher';
|
||||
|
||||
interface CompanionState {
|
||||
catalogState?: TeacherCatalogState;
|
||||
refreshing: boolean;
|
||||
refreshStatus: 'idle' | 'success' | 'error';
|
||||
agents: TeacherCatalog['items'];
|
||||
selectedAgent?: TeacherCatalog['items'][number];
|
||||
// null keeps an already-open, unassigned editor stable when the first Agent arrives.
|
||||
selectedAgent?: TeacherCatalog['items'][number] | null;
|
||||
definition: TeacherDefinition | null;
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
@@ -48,7 +52,7 @@ export function useTeacherCompanion(options: Options) {
|
||||
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
|
||||
const scope = account;
|
||||
const storageKey = `makelore-teacher-presence:${scope}`;
|
||||
const [state, setState] = useState<CompanionState>({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' });
|
||||
const [state, setState] = useState<CompanionState>({ agents: [], definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen: [], welcomeDismissed: false, error: '' });
|
||||
const [unreadAgentIds, setUnreadAgentIds] = useState<string[]>([]);
|
||||
const selectionEpoch = useRef(0);
|
||||
const latest = useRef({ options, state });
|
||||
@@ -57,6 +61,32 @@ export function useTeacherCompanion(options: Options) {
|
||||
scopeRef.current = scope;
|
||||
const stateScope = useRef(scope);
|
||||
const inFlight = useRef(false);
|
||||
const configLoader = useRef<(() => Promise<{ enabled: boolean }>) | null>(null);
|
||||
const reloadCatalog = useCallback(async () => { await configLoader.current?.(); }, []);
|
||||
const refreshFlight = useRef<{ load: NonNullable<typeof configLoader.current>; promise: Promise<void> } | null>(null);
|
||||
const refresh = useCallback((): Promise<void> => {
|
||||
const load = configLoader.current;
|
||||
if (!load) return Promise.resolve();
|
||||
const refreshingScope = scopeRef.current;
|
||||
const isCurrent = () => configLoader.current === load && scopeRef.current === refreshingScope;
|
||||
if (refreshFlight.current?.load === load) return refreshFlight.current.promise;
|
||||
setState((current) => ({ ...current, refreshing: true, refreshStatus: 'idle' }));
|
||||
const promise = load().then(() => {
|
||||
if (isCurrent()) setState((current) => ({ ...current, refreshStatus: 'success' }));
|
||||
}).catch(() => {
|
||||
if (isCurrent()) setState((current) => ({ ...current, refreshStatus: 'error' }));
|
||||
}).finally(() => {
|
||||
if (isCurrent()) setState((current) => ({ ...current, refreshing: false }));
|
||||
if (refreshFlight.current?.promise === promise) refreshFlight.current = null;
|
||||
});
|
||||
refreshFlight.current = { load, promise };
|
||||
return promise;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (state.refreshStatus !== 'success') return;
|
||||
const timer = window.setTimeout(() => setState((current) => ({ ...current, refreshStatus: 'idle' })), 3000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [state.refreshStatus]);
|
||||
const noteTopic = useCallback((topic: TeacherTopic) => {
|
||||
if (scopeRef.current !== scope || (!topic.conversation && topic.projectId !== latest.current.options.projectId)) return;
|
||||
const selectedId = latest.current.state.selectedAgent?.teacher_id;
|
||||
@@ -74,7 +104,7 @@ export function useTeacherCompanion(options: Options) {
|
||||
}, [scope]);
|
||||
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;
|
||||
if (id === (current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id)) return;
|
||||
const agent = current.agents.find((item) => item.teacher_id === id);
|
||||
if (!agent) return;
|
||||
selectionEpoch.current++;
|
||||
@@ -105,21 +135,41 @@ export function useTeacherCompanion(options: Options) {
|
||||
const welcomeDismissed = readLocal<boolean>(storageKey + ':welcome-dismissed', false) === true;
|
||||
if (!preserveChat) selectionEpoch.current++;
|
||||
const initialSelection = selectionEpoch.current;
|
||||
if (!preserveChat) setState({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen, welcomeDismissed, error: '' });
|
||||
if (!preserveChat) setState({ agents: [], definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen, welcomeDismissed, error: '' });
|
||||
else setState((current) => ({ ...current, refreshing: false, refreshStatus: 'idle' }));
|
||||
const projectId = options.projectId;
|
||||
const base = projectId ? teacherTopicsPath(projectId) : '';
|
||||
const loadConfig = async () => {
|
||||
const config = await teacherApi.config();
|
||||
const catalog = await teacherApi.catalog();
|
||||
const selectedId = latest.current.state.selectedAgent?.teacher_id ?? readLocal<string | undefined>(storageKey + ':selected-agent', undefined);
|
||||
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,
|
||||
selectedAgent: selected ?? current.selectedAgent,
|
||||
definition: selected?.definition ?? current.selectedAgent?.definition ?? current.topic?.definition ?? config.definition, enabled, loading: false }));
|
||||
return { enabled, selected, selectedId };
|
||||
type LoadedCatalog = { enabled: boolean; selected?: TeacherCatalog['items'][number]; selectedId?: string };
|
||||
let configFlight: Promise<LoadedCatalog> | null = null;
|
||||
const loadConfig = (): Promise<LoadedCatalog> => {
|
||||
if (configFlight) return configFlight;
|
||||
configFlight = (async () => {
|
||||
const [config, catalog] = await Promise.all([teacherApi.config(), teacherApi.catalog()]);
|
||||
if (!alive || scopeRef.current !== scope) return { enabled: false };
|
||||
const selection = (current: CompanionState) => {
|
||||
const selectedId = current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id
|
||||
?? readLocal<string | undefined>(storageKey + ':selected-agent', undefined);
|
||||
const selected = catalog.items.find((item) => item.teacher_id === selectedId)
|
||||
?? (!selectedId ? catalog.items.find((item) => item.is_default) ?? catalog.items[0] : undefined);
|
||||
const unassigned = current.selectedAgent === null
|
||||
|| (!current.agents.length && !selectedId && latest.current.options.teacherOpen);
|
||||
return { selectedId, selected, unassigned,
|
||||
enabled: Boolean(selected) || (!selectedId && config.enabled) };
|
||||
};
|
||||
const loaded = selection(latest.current.state);
|
||||
setState((current) => {
|
||||
const next = selection(current);
|
||||
return { ...current, catalogState: { config, catalog }, agents: catalog.items,
|
||||
selectedAgent: next.unassigned ? null : next.selected ?? current.selectedAgent,
|
||||
definition: next.selected?.definition ?? current.selectedAgent?.definition ?? current.topic?.definition ?? config.definition,
|
||||
enabled: next.enabled, loading: false, error: '' };
|
||||
});
|
||||
return { enabled: loaded.enabled, selected: loaded.unassigned ? undefined : loaded.selected,
|
||||
selectedId: loaded.unassigned ? undefined : loaded.selectedId };
|
||||
})().finally(() => { configFlight = null; });
|
||||
return configFlight;
|
||||
};
|
||||
configLoader.current = loadConfig;
|
||||
void (async () => {
|
||||
try {
|
||||
const loaded = await loadConfig();
|
||||
@@ -207,6 +257,7 @@ export function useTeacherCompanion(options: Options) {
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => {
|
||||
alive = false;
|
||||
if (configLoader.current === loadConfig) configLoader.current = null;
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener('focus', onVisible);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
@@ -258,12 +309,12 @@ export function useTeacherCompanion(options: Options) {
|
||||
|
||||
const last = topic?.requests.at(-1);
|
||||
const invitation = unreadInvitation({ topic, seen: state.seen });
|
||||
const visibleState = stateScope.current === scope ? state : { agents: [], selectedAgent: undefined, definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: true, error: '' };
|
||||
const visibleState: CompanionState = stateScope.current === scope ? state : { agents: [], selectedAgent: undefined, definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen: [], welcomeDismissed: true, error: '' };
|
||||
// This is the published greeting, not a fabricated project assessment.
|
||||
const selectedDefinition = visibleState.selectedAgent?.definition ?? 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 selectedAgent = visibleState.selectedAgent === null ? undefined
|
||||
: 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 {
|
||||
@@ -272,6 +323,8 @@ export function useTeacherCompanion(options: Options) {
|
||||
error: last?.intent === 'check-in' && ['failed', 'interrupted'].includes(last.status) ? '智能体这次没连上,可以点头像找智能体聊聊。' : visibleState.error,
|
||||
definition: selectedDefinition,
|
||||
selectedAgent,
|
||||
refresh,
|
||||
reloadCatalog,
|
||||
selectAgent,
|
||||
topic,
|
||||
invitation,
|
||||
|
||||
Reference in New Issue
Block a user