feat: 在顶栏并排展示下发智能体并直接切换

This commit is contained in:
2026-09-24 15:38:50 +08:00
parent 39b03a1628
commit a10cf00015
15 changed files with 291 additions and 59 deletions

View File

@@ -245,7 +245,8 @@ export function CodingChatPanel({
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
const teacherCompanion = useTeacherCompanion({ projectId: activeProject?.id ?? null, sourceId: targetConversationId, sourceBusy: running, sourceArchived: Boolean(selectedConversation?.archivedAt), teacherOpen: consultationOpen, teacherComposing });
const [teacherBubble, setTeacherBubble] = useState<HTMLDivElement | null>(null);
const openTeacher = () => {
const openTeacher = (agentId?: string) => {
if (agentId) teacherCompanion.selectAgent(agentId);
const selectedText = window.getSelection()?.toString().trim();
setTeacherQuote(selectedText ? { kind: 'code', text: selectedText.slice(0, 12000) } : undefined);
setTeacherQuoteSource(`${activeProject?.id}:${targetConversationId}`);
@@ -947,20 +948,21 @@ export function CodingChatPanel({
{consultationOpen && activeProject && (
<div id="coding-consultation-dock" className="classroom-consultation-dock relative min-h-0 shrink-0 border-l" style={{ width: consultationWidth }}
onKeyDown={(event) => { if (event.key === 'Escape') { setConsultationOpen(false); document.querySelector<HTMLButtonElement>(`[aria-label="智能体"]`)?.focus(); } }}>
onKeyDown={(event) => { if (event.key === 'Escape') { setConsultationOpen(false); document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); } }}>
<div role="separator" aria-label="调整咨询栏宽度" aria-orientation="vertical" aria-valuemin={320} aria-valuemax={640} aria-valuenow={consultationWidth} tabIndex={0} className="classroom-consultation-resizer"
onPointerDown={(event) => { if (event.button === 0) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); } }}
onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) setConsultationWidth(Math.max(320, Math.min(640, window.innerWidth - event.clientX))); }}
onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }}
onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); setConsultationWidth((width) => Math.max(320, Math.min(640, width + (event.key === 'ArrowLeft' ? 20 : -20)))); } }} />
<TeacherChatPanel key={`${account}:${activeProject.id}`}
<TeacherChatPanel key={`${account}:${activeProject.id}:${teacherCompanion.selectedAgent?.teacher_id ?? 'history'}`}
projectId={activeProject.id} projectName={activeProject.name} sourceId={targetConversationId ?? ''}
selectedAgent={teacherCompanion.selectedAgent}
externalTopic={teacherCompanion.topic}
onTopicChange={teacherCompanion.noteTopic}
onComposingChange={setTeacherComposing}
expanded={consultationWidth > 508} onExpand={() => setConsultationWidth((width) => width > 508 ? 508 : 640)}
quote={consultationOpen && teacherQuoteSource === `${activeProject.id}:${targetConversationId}` ? teacherQuote : undefined}
onClose={() => { setConsultationOpen(false); document.querySelector<HTMLButtonElement>(`[aria-label="智能体"]`)?.focus(); }} />
onClose={() => { setConsultationOpen(false); document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); }} />
</div>
)}
</section>

View File

@@ -28,6 +28,7 @@ export function TeacherChatPanel({
externalTopic,
onTopicChange,
onComposingChange,
selectedAgent,
}: {
projectId: string;
sourceId: string;
@@ -42,17 +43,21 @@ export function TeacherChatPanel({
externalTopic?: TeacherTopic | null;
onTopicChange?(topic: TeacherTopic): void;
onComposingChange?(composing: boolean): void;
selectedAgent?: TeacherCatalog['items'][number];
}) {
const base = teacherTopicsPath(projectId);
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
// Retain the existing project draft key; its suffix is a storage identifier, not a role.
const draftKey = !draftRevision ? `makelore-consultation-draft:${account}:${projectId}:teacher` : null;
const projectDraftKey = `makelore-consultation-draft:${account}:${projectId}:teacher`;
const draftKey = !draftRevision ? selectedAgent ? `makelore-consultation-draft:${account}:${projectId}:agent:${selectedAgent.teacher_id}` : projectDraftKey : null;
const [saved] = useState<{ text?: string; references?: TeacherReference[]; pending?: TeacherSend }>(() => {
try { return draftKey ? JSON.parse(localStorage.getItem(draftKey) ?? '{}') : {}; } catch { return {}; }
});
const [legacyDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => {
try { return draftKey ? JSON.parse(localStorage.getItem(`makelore-consultation-draft:${account}:${projectId}:friend`) ?? '{}') : {}; } catch { return {}; }
});
const [unassignedDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => {
try { return selectedAgent ? JSON.parse(localStorage.getItem(projectDraftKey) ?? '{}') : {}; } catch { return {}; }
});
const persistDraft = useCallback((text: string, references: TeacherReference[], pending?: TeacherSend | null) => {
if (!draftKey) return;
try { localStorage.setItem(draftKey, JSON.stringify({ text, references, pending })); } catch { /* Draft remains in the open panel. */ }
@@ -65,6 +70,9 @@ export function TeacherChatPanel({
const avatar = getAgentAvatarSrc(definition?.avatar_id);
const externalTopicRef = useRef(externalTopic);
externalTopicRef.current = externalTopic;
const selectedAgentId = selectedAgent?.teacher_id;
const selectedAgentRef = useRef(selectedAgent);
selectedAgentRef.current = selectedAgent;
const inputRef = useRef<HTMLTextAreaElement>(null);
const focusedOnce = useRef(false);
const [topics, setTopics] = useState<TeacherTopicList>({ items: [], lastSelectedTopicId: null });
@@ -118,11 +126,12 @@ export function TeacherChatPanel({
[persistDraft]
);
useEffect(() => {
if (externalTopic && externalTopic.projectId === projectId) {
if (externalTopic && externalTopic.projectId === projectId
&& (!selectedAgentId || externalTopic.definition.config_id === selectedAgentId)) {
consume(externalTopic);
setDefinition(externalTopic.definition);
}
}, [externalTopic, projectId, consume]);
}, [externalTopic, projectId, consume, selectedAgentId]);
useEffect(() => { if (topic) onTopicChange?.(topic); }, [topic, onTopicChange]);
useEffect(() => {
if (!busy && !focusedOnce.current) {
@@ -150,10 +159,13 @@ export function TeacherChatPanel({
const catalog = await teacherApi.catalog();
if (alive) {
setTeachers(catalog.items);
const selected = catalog.items.find((item) => item.is_default) ?? catalog.items[0];
const selected = selectedAgentId
? catalog.items.find((item) => item.teacher_id === selectedAgentId)
: catalog.items.find((item) => item.is_default) ?? catalog.items[0];
setSelectedTeacherVersion(selected?.version);
setEnabled(catalog.items.length > 0 || (config.enabled && !config.definition?.config_id));
if (selected) setDefinition(selected.definition);
setEnabled(selectedAgentId ? Boolean(selected) : catalog.items.length > 0 || (config.enabled && !config.definition?.config_id));
const visibleAgent = selected ?? selectedAgentRef.current;
if (visibleAgent) setDefinition(visibleAgent.definition);
}
}
} catch (e) {
@@ -163,11 +175,14 @@ export function TeacherChatPanel({
const listed = await teacherApi.list(base);
if (!alive) return;
setTopics(listed);
if (externalTopicRef.current) {
const previous = listed.items.find((item) => item.id === listed.lastSelectedTopicId
&& (!selectedAgentId || item.teacherId === selectedAgentId))
?? (selectedAgentId ? listed.items.find((item) => item.teacherId === selectedAgentId) : undefined);
if (externalTopicRef.current && (!selectedAgentId || externalTopicRef.current.definition.config_id === selectedAgentId)) {
consume(externalTopicRef.current);
setDefinition(externalTopicRef.current.definition);
} else if (listed.lastSelectedTopicId) {
const current = await teacherApi.read(base, listed.lastSelectedTopicId);
} else if (previous) {
const current = await teacherApi.read(base, previous.id);
if (alive && (!draftRevision || current.draftRevision === draftRevision)) {
consume(current);
setDefinition(current.definition);
@@ -185,7 +200,7 @@ export function TeacherChatPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps
generation.current++;
};
}, [base, draftRevision, label, consume]);
}, [base, draftRevision, label, consume, selectedAgentId]);
useEffect(() => {
if (!topic?.id || legacyFriend) return;
const streamTopicId = topic.id;
@@ -375,18 +390,6 @@ export function TeacherChatPanel({
{onExpand && <button className="consult-icon" aria-label={expanded ? '恢复咨询栏宽度' : '展开咨询栏'} onClick={onExpand}><Maximize2 className="h-4 w-4" /></button>}
{onClose && <button className="consult-icon" aria-label={`关闭${label}`} onClick={onClose}><X className="h-4 w-4" /></button>}
</header>
{!draftRevision && teachers.length > 0 && (
<label className="px-5 pb-2 text-[11px] text-muted-foreground">
新话题使用的智能体
<select aria-label="新话题使用的智能体" value={selectedTeacherVersion ?? ''} disabled={busy}
onChange={(event) => { const selected = teachers.find(item => item.version === Number(event.target.value)); setSelectedTeacherVersion(selected?.version); if (!topic && selected) setDefinition(selected.definition); }}
className="mt-1 w-full rounded-lg border bg-background p-2 text-xs text-foreground">
{teachers.map((item) => <option key={item.teacher_id} value={item.version}>
{item.definition.name}{item.is_default ? '(默认)' : ''}
</option>)}
</select>
</label>
)}
{(topics.items.length > 0 || draftRevision) && <div className="px-5 pt-2">
<button className="text-[11px] text-muted-foreground" onClick={() => setHistoryOpen(!historyOpen)} aria-expanded={historyOpen}>以往讨论 <ChevronDown className="inline h-3 w-3" /></button>
{(historyOpen || draftRevision) && <div className="mt-2 flex gap-2">
@@ -458,6 +461,13 @@ export function TeacherChatPanel({
{topic?.unsaved && <div className="px-5 py-2 text-xs text-destructive">回复尚未保存,请复制或重试保存。<button className="ml-2 underline" onClick={() => void teacherApi.save(topicBase, topic.id).then(consume).catch(() => setError('保存失败,请先复制回复。'))}>重试保存</button></div>}
{!topicEnabled && !error && !busy && <p className="px-5 pb-2 text-xs text-muted-foreground">{legacyFriend ? '这是旧版内置朋友的话题,仅供查看。请选择智能体新建话题。' : `${label}暂未开放,历史仍可查看。`}</p>}
<form className="px-4 pb-4 pt-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
{Boolean(unassignedDraft.text?.trim() || unassignedDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
<summary>查看之前的项目草稿</summary>
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">
{unassignedDraft.text}
{unassignedDraft.references?.map((ref, index) => <pre className="mt-2 whitespace-pre-wrap" key={index}>{ref.text}</pre>)}
</div>
</details>}
{Boolean(legacyDraft.text?.trim() || legacyDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
<summary>查看旧版朋友草稿</summary>
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">

View File

@@ -7,23 +7,38 @@ type Companion = ReturnType<typeof useTeacherCompanion>;
export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
companion: Companion;
open: boolean;
onOpen(): void;
onOpen(agentId?: string): void;
bubbleRef?: Ref<HTMLDivElement>;
}) {
const { definition, invitation, welcome, loading, enabled, checking, error } = companion;
const name = definition?.name || '智能体';
const avatar = getAgentAvatarSrc(definition?.avatar_id);
const activeId = companion.selectedAgent?.teacher_id;
const agents = companion.selectedAgent && !companion.agents.some((item) => item.teacher_id === activeId)
? [...companion.agents, companion.selectedAgent] : companion.agents;
const viewConversation = () => { companion.dismiss(); onOpen(); };
const status = loading ? '正在连接智能体…'
: checking ? '智能体在看看你最近的进展…'
: error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说');
return (
<section className="teacher-companion no-drag" aria-label="智能体陪伴" data-testid="teacher-companion">
<button type="button" className="teacher-companion-person" title={status} aria-label="智能体" aria-expanded={open} aria-controls="coding-consultation-dock" onMouseDown={(event) => event.preventDefault()} onClick={onOpen}>
<nav className="consultation-agent-tabs" aria-label="已下发智能体">
{agents.map((agent) => <button key={agent.teacher_id} type="button" className="teacher-companion-person"
title={agent.definition.description || agent.definition.name} aria-label={agents.length === 1 ? '智能体' : `与${agent.definition.name}聊天`}
aria-expanded={open && activeId === agent.teacher_id} aria-pressed={open && activeId === agent.teacher_id}
aria-controls="coding-consultation-dock" data-agent-id={agent.teacher_id}
onMouseDown={(event) => event.preventDefault()} onClick={() => onOpen(agent.teacher_id)}>
<span className="teacher-companion-avatar"><img src={getAgentAvatarSrc(agent.definition.avatar_id)} alt="" />
{activeId === agent.teacher_id && invitation && <span className="teacher-companion-unread" aria-hidden="true" />}</span>
<span className="teacher-companion-name">{agent.definition.name}</span>
{activeId === agent.teacher_id && checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
</button>)}
{(!agents.length || (!activeId && companion.topic)) && <button type="button" className="teacher-companion-person" title={status} aria-label="智能体" aria-expanded={open} aria-controls="coding-consultation-dock" onMouseDown={(event) => event.preventDefault()} onClick={() => onOpen()}>
<span className="teacher-companion-avatar"><img src={avatar} alt="" />{invitation && <span className="teacher-companion-unread" aria-hidden="true" />}</span>
<span className="teacher-companion-name">{name}</span>
{checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
</button>
</button>}
</nav>
{(invitation || welcome) && <div ref={bubbleRef} className="teacher-companion-presence" data-testid="teacher-presence">
<div className="teacher-companion-bubble" data-testid="teacher-invitation" data-bubble-kind={invitation ? 'check-in' : 'welcome'}>
<div className="teacher-companion-bubble-heading">

View File

@@ -69,7 +69,9 @@
/* The teacher speaks from the title bar without taking the student's focus. */
.classroom-composer-view[hidden] { display: none; }
.teacher-companion { position: relative; flex-shrink: 0; }
.teacher-companion { position: relative; min-width: 0; max-width: 65%; }
.consultation-agent-tabs { display: flex; align-items: center; gap: 4px; max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
.consultation-agent-tabs .teacher-companion-person { flex-shrink: 0; }
.teacher-companion-person { display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 8px; border-radius: 8px; color: #747d6c; transition: background 150ms; }
.teacher-companion-person:hover, .teacher-companion-person[aria-expanded="true"] { background: #eef1e8; color: #42543a; }
.teacher-companion-avatar { position: relative; flex-shrink: 0; }

View File

@@ -6,10 +6,13 @@ import {
TEACHER_CHECK_IN_INTERVAL_MS,
type TeacherCheckInInput,
type TeacherDefinition,
type TeacherCatalog,
type TeacherTopic,
} from '../../../shared/coding-teacher';
interface CompanionState {
agents: TeacherCatalog['items'];
selectedAgent?: TeacherCatalog['items'][number];
definition: TeacherDefinition | null;
enabled: boolean;
loading: boolean;
@@ -44,7 +47,8 @@ export function useTeacherCompanion(options: Options) {
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
const scope = `${account}:${options.projectId ?? ''}`;
const storageKey = `makelore-teacher-presence:${scope}`;
const [state, setState] = useState<CompanionState>({ definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' });
const [state, setState] = useState<CompanionState>({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' });
const selectionEpoch = useRef(0);
const latest = useRef({ options, state });
latest.current = { options, state };
const scopeRef = useRef(scope);
@@ -55,8 +59,18 @@ export function useTeacherCompanion(options: Options) {
if (scopeRef.current !== scope || topic.projectId !== options.projectId) return;
setState((current) => current.topic?.id === topic.id && current.topic.revision >= topic.revision
? current
: { ...current, topic, error: '' });
: { ...current, topic, selectedAgent: current.agents.find((item) => item.teacher_id === topic.definition.config_id)
?? (topic.definition.config_id ? { teacher_id: topic.definition.config_id, version: topic.version, definition: topic.definition, is_default: false } : undefined), error: '' });
}, [scope, options.projectId]);
const selectAgent = useCallback((id: string) => {
const current = latest.current.state;
if (id === (current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id ?? current.definition?.config_id)) return;
const agent = current.agents.find((item) => item.teacher_id === id);
if (!agent) return;
selectionEpoch.current++;
setState((value) => ({ ...value, selectedAgent: agent, definition: agent.definition, topic: null,
enabled: true, welcomeDismissed: true, error: '' }));
}, []);
const dismiss = useCallback(() => {
const request = unreadInvitation(latest.current.state);
writeLocal(storageKey + ':welcome-dismissed', true);
@@ -77,7 +91,9 @@ export function useTeacherCompanion(options: Options) {
const storedSeen = readLocal<unknown>(storageKey + ':seen', []);
const seen = Array.isArray(storedSeen) ? storedSeen.filter((id): id is string => typeof id === 'string') : [];
const welcomeDismissed = readLocal<boolean>(storageKey + ':welcome-dismissed', false) === true;
setState({ definition: null, enabled: false, loading: true, topic: null, seen, welcomeDismissed, error: '' });
selectionEpoch.current++;
const initialSelection = selectionEpoch.current;
setState({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen, welcomeDismissed, error: '' });
const projectId = options.projectId;
if (!projectId) {
setState((current) => ({ ...current, loading: false }));
@@ -86,8 +102,14 @@ export function useTeacherCompanion(options: Options) {
const base = teacherTopicsPath(projectId);
const loadConfig = async () => {
const config = await teacherApi.config();
if (alive) setState((current) => ({ ...current, definition: config.definition, enabled: config.enabled, loading: false }));
return config;
const catalog = await teacherApi.catalog();
const selectedId = latest.current.state.selectedAgent?.teacher_id;
const selected = catalog.items.find((item) => item.teacher_id === selectedId)
?? (!selectedId ? catalog.items.find((item) => item.is_default) ?? catalog.items[0] : undefined);
const enabled = selected ? true : !selectedId && config.enabled;
if (alive) setState((current) => ({ ...current, agents: catalog.items,
definition: selected?.definition ?? current.selectedAgent?.definition ?? current.topic?.definition ?? config.definition, enabled, loading: false }));
return { enabled };
};
void (async () => {
try {
@@ -95,8 +117,8 @@ export function useTeacherCompanion(options: Options) {
if (!alive) return;
const topics = await teacherApi.list(base);
if (!alive || !topics.lastSelectedTopicId) return;
const topic = await teacherApi.read(base, topics.lastSelectedTopicId);
if (alive && (!latest.current.state.topic || latest.current.state.topic.id === topic.id)) noteTopic(topic);
const topic = await teacherApi.read(base, topics.lastSelectedTopicId, false);
if (alive && initialSelection === selectionEpoch.current && (!latest.current.state.topic || latest.current.state.topic.id === topic.id)) noteTopic(topic);
} catch {
if (alive) setState((current) => ({ ...current, loading: false, error: '智能体暂时没连上,稍后再来看看。' }));
}
@@ -111,6 +133,8 @@ export function useTeacherCompanion(options: Options) {
const { options: current, state: currentState } = latest.current;
if (currentState.topic?.role === 'friend') return;
if (!alive || inFlight.current || document.visibilityState !== 'visible' || currentState.loading) return;
// A newly selected agent has no Main selection until its history loads or the student sends.
if (currentState.selectedAgent && !currentState.topic) return;
// A dropped event stream must not leave a completed reply stuck as thinking.
if (currentState.topic?.requests.some((request) => ['preparing', 'running'].includes(request.status))) {
inFlight.current = true;
@@ -127,6 +151,7 @@ export function useTeacherCompanion(options: Options) {
lastAttempt = Date.now();
writeLocal(storageKey + ':last-attempt', lastAttempt);
inFlight.current = true;
const checkingSelection = selectionEpoch.current;
try {
const config = await loadConfig();
const dispatch = latest.current.options;
@@ -143,7 +168,7 @@ export function useTeacherCompanion(options: Options) {
writeLocal(storageKey + ':last-attempt', lastAttempt);
}
writeLocal(storageKey + ':pending', null);
if (result.topic) noteTopic(result.topic);
if (result.topic && checkingSelection === selectionEpoch.current) noteTopic(result.topic);
} catch (error) {
if (!alive) return;
if (error instanceof AppError && typeof error.details?.status === 'number' && error.details.status >= 400 && error.details.status < 500 && error.details.status !== 408) writeLocal(storageKey + ':pending', null);
@@ -171,12 +196,13 @@ export function useTeacherCompanion(options: Options) {
if (!topicId || !topicProjectId || topic?.role === 'friend') return;
let alive = true;
let stream: EventSource | undefined;
const streamSelection = selectionEpoch.current;
const base = teacherTopicsPath(topicProjectId);
void teacherApi.events(base, topicId).then((source) => {
if (!alive) { source.close(); return; }
stream = source;
source.addEventListener('snapshot', (event) => {
if (!alive) return;
if (!alive || streamSelection !== selectionEpoch.current) return;
try { noteTopic(JSON.parse((event as MessageEvent).data) as TeacherTopic); } catch { /* Wait for the next authoritative snapshot. */ }
});
}).catch(() => { /* The consultation can still be opened and reloaded. */ });
@@ -185,15 +211,20 @@ export function useTeacherCompanion(options: Options) {
const last = topic?.requests.at(-1);
const invitation = unreadInvitation({ topic, seen: state.seen });
const visibleState = stateScope.current === scope ? state : { definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: true, error: '' };
const visibleState = stateScope.current === scope ? state : { agents: [], selectedAgent: undefined, definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: true, error: '' };
// This is the published greeting, not a fabricated project assessment.
const selectedDefinition = topic?.definition ?? visibleState.definition;
const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id;
const selectedAgent = visibleState.agents.find((item) => item.teacher_id === selectedAgentId)
?? visibleState.selectedAgent;
const welcome = topic?.role !== 'friend' && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed
? selectedDefinition?.welcome_message.trim() || null : null;
return {
...visibleState,
error: last?.intent === 'check-in' && ['failed', 'interrupted'].includes(last.status) ? '智能体这次没连上,可以点头像找智能体聊聊。' : visibleState.error,
definition: selectedDefinition,
selectedAgent,
selectAgent,
topic,
invitation,
welcome,