Merge remote main and reconcile agent refresh with ongoing chats
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-09-28 15:02:09 +08:00
24 changed files with 1122 additions and 165 deletions

View File

@@ -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'

View File

@@ -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);

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
);
}

View 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>;
}

View File

@@ -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; }

View File

@@ -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]);
}

View File

@@ -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,