feat(chat): add manual refresh for delivered agents
This commit is contained in:
1 parent
862477f9d4
commit
2b1943dd0f
13 files changed
+517
-59
No files matched your search
@@ -0,0 +1,56 @@
|
|||||||
|
# Task: Inspect delivered agent refresh behavior
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260928-agent-refresh-a6d109b3
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: codex/20260928-agent-refresh-a6d109b3-agent-refresh
|
||||||
|
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-agent-refresh-a6d109b3
|
||||||
|
- Base commit: b26e25c9ed26bf30f4d0bc03869c223f0fe34386
|
||||||
|
- Owner: codex
|
||||||
|
- Status: Ready for Integration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Diagnose delayed delivered-Agent updates, then implement the user-approved manual refresh button beside the topbar Agent list.
|
||||||
|
- Share refreshed catalog/configuration with the open consultation panel without replacing its topic, draft or published version.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- User first requested diagnosis and product-language evaluation with no code changes, then explicitly requested implementation.
|
||||||
|
- Refresh is read-only and independent of proactive check-in eligibility; no model question, paid call, current-topic upgrade, automatic Agent switch or draft clearing.
|
||||||
|
- Preserve the latest approved hover introductions and simplified consultation panel by fast-forwarding this owned checkout to committed dependency 862477f. Its source task record remains byte-for-byte unchanged. Do not alter peer checkouts or the running trial.
|
||||||
|
|
||||||
|
## Project Context Loaded
|
||||||
|
|
||||||
|
- Same task/worktree resumed with matching start/status ownership; feature mode and recorded branch/base verified. Prior positioning, integrated state, decision index, teacher ADR, architecture, domain and evidence context remain loaded. Updated hover-intro peer task record reviewed; all eight peer scopes assessed, no conflicting product decisions. Concurrent and Planning Gates Passed.
|
||||||
|
- Current rules: Main owns cloud access; published Agent identity/configuration comes from the catalog; old topics pin their version. Initial catalog reads, panel-open reads and existing automatic check-ins remain supported.
|
||||||
|
- Adapt the design skill's compact, accessible loading/error feedback to the existing light UI; no new fonts, theme, or navigation patterns.
|
||||||
|
- Plan: add shared read-only loader and manual action, preserve drafts/selection/pinned history, verify unit/typecheck/build/layout/Electron behavior and obtain independent review.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Added a compact Refresh control outside the scrolling Agent strip, with disabled/spinning loading state, three-second success feedback, visible failure feedback and retry. Keyboard labeling and polite status announcements are included.
|
||||||
|
- Manual refresh immediately reads configuration and catalog through the existing Main API, bypassing proactive five-minute/call-eligibility gates. Concurrent callers share the same request; failed reads retain the prior catalog; obsolete scope/unmounted responses are ignored.
|
||||||
|
- Topbar and open panel consume the same catalog. Same-ID updates change the version used by new topics without remounting the panel or changing existing topic definitions/versions. Opening or switching a panel still checks through the shared loader.
|
||||||
|
- Preserved current Agent selection, operation and consultation drafts, pinned history, unread messages, and pending sends. First delivery while an unassigned editor is open keeps that editor/draft stable until the user explicitly selects an Agent or a topic is bound.
|
||||||
|
- No Main/backend change, production installation, main merge, remote push, live model call, or running-trial restart.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Passed: 67 tests across teacher-companion and coding-teacher-ui, including seven new cases covering busy/composing refresh, duplicate clicks, shared latest versions, old-topic/history/draft/unread preservation, independent config/catalog errors and retry, first delivery, and obsolete project responses.
|
||||||
|
- Passed: 38 tests across coding-chat-panel and preview-clearance on final product code; 105 relevant unit tests in total.
|
||||||
|
- Passed: standard typecheck, changed-file ESLint, git diff whitespace check, production Renderer/Main/Preload/utility compilation using pinned pnpm 10.33.4.
|
||||||
|
- Passed: all 9 existing/extended Chrome layout scenarios, including 520px and 1000px scrolling strips with an always-reachable refresh control. Desktop and narrow screenshots visually reviewed.
|
||||||
|
- Passed: final-build Electron manual-refresh and proactive-presence scenarios. Manual refresh verifies held/loading state, new/renamed Agent, unchanged old topic/reply, operation and consultation draft preservation, failed refresh retention and retry. Captured API requests prove refresh performs no message/check-in/prompt POST or topic reload.
|
||||||
|
- Independent review found first-delivery draft remounting; fixed with an explicit unassigned selection and covered by a focused regression. Existing consultation E2E exposed delayed introduction reopening after clicking an Agent; explicit click suppression now preserves closure until genuine outside reentry or keyboard focus. Re-review found no additional actionable issue for either fix.
|
||||||
|
- Passed on final build: all 3 focused Electron cases (consultation switching/drafts/hover, manual refresh, proactive presence) plus 4 affected scrolling/hover layout cases, 7/7 in 16.9 seconds. No assertion was weakened. A prior unrelated sidebar drag assertion (193px instead of 256px) matched the source task’s documented native-input flake; the final full consultation scenario passed. The proactive fixture was corrected to create its new topic from the actual distributed default definition/version. Final screenshots are under ignored test-results/agent-refresh-verified/.
|
||||||
|
- check_project_docs passed. Task-aware drift checker reports the unchanged source task record inherited from 862477f as FOREIGN_DOC because it compares all commits to the original b26e25c base. Direct comparison to dependency 862477f is empty; Git blob and working-file hash both equal ffd72a12407ef3d963f49e42afa29549300f9883. No foreign/canonical document was edited, deleted, or adopted; record this checker limitation without weakening its rules.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- Current running trial still uses the hover-intro checkout; this implementation is built and verified in the isolated task checkout. Integrate/update that client separately, preserving any active unsent input.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- Target: canonical classroom interaction/current-state documentation. Proposal: topbar manual refresh immediately synchronizes catalog/configuration with open consultation, preserves drafts/history and old-topic version pinning, and issues only read requests. Evidence: explicit user request, source change, 105 unit tests, layout and Electron checks, independent review. Future impact: operational Agent publication can be checked immediately without waiting for proactive cadence. Semantic conflicts: none. Human confirmation: already supplied for this feature; canonical promotion remains integration-only.
|
||||||
@@ -160,6 +160,8 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
|||||||
|
|
||||||
### 下发智能体咨询
|
### 下发智能体咨询
|
||||||
|
|
||||||
|
顶部智能体名单旁的“刷新”按钮可立即获取最新下发名单、发布资料和新话题版本,独立于主动关心的检查条件。刷新中显示进度,成功提示“已更新”,失败保留原名单并可重试;顶部和已打开的咨询面板使用同一份结果。刷新不发起模型请求、不切换当前智能体、不清除草稿或历史,已有话题继续使用创建时绑定的版本。
|
||||||
|
|
||||||
老师 Agent 是某一领域杰出人物的能力转化而成的智能体,供平台学生使用,在真实项目、作品和问题中提供专业理解、判断与解决问题的能力支持。定义以人的领域能力及学生价值为中心;蒸馏属于实现方法,云端下发、上下文读取和聊天是能力交付方式。完整理念见[老师 Agent 产品定位](.project-docs/00-brief/project-positioning.md#老师-agent-的产品定位)。
|
老师 Agent 是某一领域杰出人物的能力转化而成的智能体,供平台学生使用,在真实项目、作品和问题中提供专业理解、判断与解决问题的能力支持。定义以人的领域能力及学生价值为中心;蒸馏属于实现方法,云端下发、上下文读取和聊天是能力交付方式。完整理念见[老师 Agent 产品定位](.project-docs/00-brief/project-positioning.md#老师-agent-的产品定位)。
|
||||||
|
|
||||||
- 新话题可选择运营下发的 Yuxi 智能体,默认使用指定智能体;提示词、Skills、知识库和模型配置由 Yuxi 管理,运营同步后生成新版本,已有话题固定原版本。Yuxi 智能体模型费用由当前学生的编程词元点数支付,个人 Agents 模块仍由创建者付费;不要求学生开通个人云智能体模块。停用的智能体仍可查看历史,不能借另一位智能体的可用状态继续提问。
|
- 新话题可选择运营下发的 Yuxi 智能体,默认使用指定智能体;提示词、Skills、知识库和模型配置由 Yuxi 管理,运营同步后生成新版本,已有话题固定原版本。Yuxi 智能体模型费用由当前学生的编程词元点数支付,个人 Agents 模块仍由创建者付费;不要求学生开通个人云智能体模块。停用的智能体仍可查看历史,不能借另一位智能体的可用状态继续提问。
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import type {
|
|||||||
TeacherTopic,
|
TeacherTopic,
|
||||||
TeacherTopicList,
|
TeacherTopicList,
|
||||||
} from '../../shared/coding-teacher';
|
} from '../../shared/coding-teacher';
|
||||||
|
export interface TeacherCatalogState {
|
||||||
|
config: TeacherAvailability & { definition: TeacherDefinition | null };
|
||||||
|
catalog: TeacherCatalog;
|
||||||
|
}
|
||||||
export function teacherTopicsPath(projectId: string) {
|
export function teacherTopicsPath(projectId: string) {
|
||||||
return projectId === 'preview'
|
return projectId === 'preview'
|
||||||
? '/api/coding/teacher-preview/topics'
|
? '/api/coding/teacher-preview/topics'
|
||||||
|
|||||||
@@ -972,6 +972,8 @@ export function CodingChatPanel({
|
|||||||
<TeacherChatPanel key={`${account}:${activeProject.id}:${teacherCompanion.selectedAgent?.teacher_id ?? 'history'}`}
|
<TeacherChatPanel key={`${account}:${activeProject.id}:${teacherCompanion.selectedAgent?.teacher_id ?? 'history'}`}
|
||||||
projectId={activeProject.id} projectName={activeProject.name} sourceId={targetConversationId ?? ''}
|
projectId={activeProject.id} projectName={activeProject.name} sourceId={targetConversationId ?? ''}
|
||||||
selectedAgent={teacherCompanion.selectedAgent}
|
selectedAgent={teacherCompanion.selectedAgent}
|
||||||
|
catalogState={teacherCompanion.catalogState}
|
||||||
|
reloadCatalog={teacherCompanion.reloadCatalog}
|
||||||
externalTopic={teacherCompanion.topic}
|
externalTopic={teacherCompanion.topic}
|
||||||
onTopicChange={teacherCompanion.noteTopic}
|
onTopicChange={teacherCompanion.noteTopic}
|
||||||
onComposingChange={setTeacherComposing}
|
onComposingChange={setTeacherComposing}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { ArrowUp, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
import { ArrowUp, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
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 {
|
import type {
|
||||||
TeacherDefinition,
|
TeacherDefinition,
|
||||||
TeacherCatalog,
|
TeacherCatalog,
|
||||||
@@ -26,6 +26,8 @@ export function TeacherChatPanel({
|
|||||||
onTopicChange,
|
onTopicChange,
|
||||||
onComposingChange,
|
onComposingChange,
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
|
catalogState,
|
||||||
|
reloadCatalog,
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
@@ -38,6 +40,8 @@ export function TeacherChatPanel({
|
|||||||
onTopicChange?(topic: TeacherTopic): void;
|
onTopicChange?(topic: TeacherTopic): void;
|
||||||
onComposingChange?(composing: boolean): void;
|
onComposingChange?(composing: boolean): void;
|
||||||
selectedAgent?: TeacherCatalog['items'][number];
|
selectedAgent?: TeacherCatalog['items'][number];
|
||||||
|
catalogState?: TeacherCatalogState;
|
||||||
|
reloadCatalog?(): Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const base = teacherTopicsPath(projectId);
|
const base = teacherTopicsPath(projectId);
|
||||||
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
|
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
|
||||||
@@ -60,7 +64,9 @@ export function TeacherChatPanel({
|
|||||||
const discussionEnabled = !draftRevision;
|
const discussionEnabled = !draftRevision;
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [focus, setFocus] = useState<{ topicId: string; id: string; label: string } | null>(null);
|
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);
|
const externalTopicRef = useRef(externalTopic);
|
||||||
externalTopicRef.current = externalTopic;
|
externalTopicRef.current = externalTopic;
|
||||||
const selectedAgentId = selectedAgent?.teacher_id;
|
const selectedAgentId = selectedAgent?.teacher_id;
|
||||||
@@ -80,10 +86,20 @@ export function TeacherChatPanel({
|
|||||||
draftRef.current = { text, references };
|
draftRef.current = { text, references };
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [busy, setBusy] = useState(true);
|
const [busy, setBusy] = useState(true);
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [localEnabled, setEnabled] = useState(false);
|
||||||
const [legacyEnabled, setLegacyEnabled] = useState(false);
|
const [localLegacyEnabled, setLegacyEnabled] = useState(false);
|
||||||
const [teachers, setTeachers] = useState<TeacherCatalog['items']>([]);
|
const [localTeachers, setTeachers] = useState<TeacherCatalog['items']>([]);
|
||||||
const [selectedTeacherVersion, setSelectedTeacherVersion] = useState<number>();
|
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 = topic?.definition ?? (catalogState
|
||||||
|
? selectedTeacher?.definition ?? selectedAgent?.definition ?? catalogState.config.definition : localDefinition);
|
||||||
const topicEnabled = legacyFriend ? false : !topic ? enabled
|
const topicEnabled = legacyFriend ? false : !topic ? enabled
|
||||||
: topic.definition.config_id
|
: topic.definition.config_id
|
||||||
? teachers.some((item) => item.teacher_id === topic.definition.config_id)
|
? teachers.some((item) => item.teacher_id === topic.definition.config_id)
|
||||||
@@ -140,25 +156,31 @@ export function TeacherChatPanel({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const config = draftRevision
|
if (!draftRevision && reloadCatalog) {
|
||||||
? { definition: (await teacherApi.preview(draftRevision)).payload, enabled: true }
|
// Opening the panel still checks for updates through the shared loader.
|
||||||
: await teacherApi.config();
|
// A failed background read leaves the last usable catalog in place.
|
||||||
if (alive) {
|
await reloadCatalog().catch(() => undefined);
|
||||||
setDefinition(config.definition);
|
} else if (draftRevision || !catalogStateRef.current) {
|
||||||
setEnabled(config.enabled);
|
const config = draftRevision
|
||||||
setLegacyEnabled(config.enabled);
|
? { definition: (await teacherApi.preview(draftRevision)).payload, enabled: true }
|
||||||
}
|
: await teacherApi.config();
|
||||||
if (!draftRevision) {
|
|
||||||
const catalog = await teacherApi.catalog();
|
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setTeachers(catalog.items);
|
setDefinition(config.definition);
|
||||||
const selected = selectedAgentId
|
setEnabled(config.enabled);
|
||||||
? catalog.items.find((item) => item.teacher_id === selectedAgentId)
|
setLegacyEnabled(config.enabled);
|
||||||
: catalog.items.find((item) => item.is_default) ?? catalog.items[0];
|
}
|
||||||
setSelectedTeacherVersion(selected?.version);
|
if (!draftRevision) {
|
||||||
setEnabled(selectedAgentId ? Boolean(selected) : catalog.items.length > 0 || (config.enabled && !config.definition?.config_id));
|
const catalog = await teacherApi.catalog();
|
||||||
const visibleAgent = selected ?? selectedAgentRef.current;
|
if (alive) {
|
||||||
if (visibleAgent) setDefinition(visibleAgent.definition);
|
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) {
|
} catch (e) {
|
||||||
@@ -193,7 +215,7 @@ export function TeacherChatPanel({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
generation.current++;
|
generation.current++;
|
||||||
};
|
};
|
||||||
}, [base, draftRevision, label, consume, selectedAgentId]);
|
}, [base, draftRevision, label, consume, selectedAgentId, reloadCatalog]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!topic?.id || legacyFriend) return;
|
if (!topic?.id || legacyFriend) return;
|
||||||
const streamTopicId = topic.id;
|
const streamTopicId = topic.id;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowUpRight, Loader2, X } from 'lucide-react';
|
import { ArrowUpRight, Loader2, RefreshCw, X } from 'lucide-react';
|
||||||
import { useState, type Ref } from 'react';
|
import { useState, type Ref } from 'react';
|
||||||
import { Provider as TooltipProvider } from '@radix-ui/react-tooltip';
|
import { Provider as TooltipProvider } from '@radix-ui/react-tooltip';
|
||||||
import { TeacherAvatar } from './TeacherAvatar';
|
import { TeacherAvatar } from './TeacherAvatar';
|
||||||
@@ -28,6 +28,9 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef,
|
|||||||
const status = loading ? '正在连接智能体…'
|
const status = loading ? '正在连接智能体…'
|
||||||
: checking ? '智能体在看看你最近的进展…'
|
: checking ? '智能体在看看你最近的进展…'
|
||||||
: error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说');
|
: error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说');
|
||||||
|
const refreshMessage = companion.refreshing ? '正在刷新…'
|
||||||
|
: companion.refreshStatus === 'error' ? '刷新失败,请重试'
|
||||||
|
: companion.refreshStatus === 'success' ? '已更新' : '';
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={180} skipDelayDuration={100}>
|
<TooltipProvider delayDuration={180} skipDelayDuration={100}>
|
||||||
<section className="teacher-companion no-drag" aria-label="智能体陪伴" data-testid="teacher-companion">
|
<section className="teacher-companion no-drag" aria-label="智能体陪伴" data-testid="teacher-companion">
|
||||||
@@ -50,6 +53,13 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef,
|
|||||||
{checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
|
{checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
|
||||||
</button></TeacherIntroduction>}
|
</button></TeacherIntroduction>}
|
||||||
</nav>
|
</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">
|
{(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" data-testid="teacher-invitation" data-bubble-kind={invitation ? 'check-in' : 'welcome'}>
|
||||||
<div className="teacher-companion-bubble-heading">
|
<div className="teacher-companion-bubble-heading">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||||
import { Sparkles } from 'lucide-react';
|
import { Sparkles } from 'lucide-react';
|
||||||
import type { ReactElement, Ref } from 'react';
|
import { useRef, type ReactElement, type Ref } from 'react';
|
||||||
import type { TeacherDefinition } from '../../../shared/coding-teacher';
|
import type { TeacherDefinition } from '../../../shared/coding-teacher';
|
||||||
import { TeacherAvatar } from './TeacherAvatar';
|
import { TeacherAvatar } from './TeacherAvatar';
|
||||||
|
|
||||||
@@ -13,11 +13,23 @@ export function TeacherIntroduction({ definition, children, contentRef, open, on
|
|||||||
onOpenChange(open: boolean): void;
|
onOpenChange(open: boolean): void;
|
||||||
}) {
|
}) {
|
||||||
const description = definition?.description?.trim();
|
const description = definition?.description?.trim();
|
||||||
return <Tooltip.Root open={Boolean(description) && open} onOpenChange={onOpenChange}>
|
const clicked = useRef(false);
|
||||||
|
return <Tooltip.Root open={Boolean(description) && open} onOpenChange={(next) => {
|
||||||
|
if (!next || !clicked.current) onOpenChange(next);
|
||||||
|
}}>
|
||||||
<Tooltip.Trigger asChild onPointerEnter={(event) => {
|
<Tooltip.Trigger asChild onPointerEnter={(event) => {
|
||||||
// Adjacent entries must switch immediately, even inside the previous
|
// Adjacent entries must switch immediately, even inside the previous
|
||||||
// tooltip's pointer grace area used when moving into its readable card.
|
// tooltip's pointer grace area used when moving into its readable card.
|
||||||
if (event.pointerType !== 'touch') onOpenChange(true);
|
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) => {
|
}} onPointerMove={(event) => {
|
||||||
// pointerEnter owns hover opening. Skip Radix's second opening path so
|
// pointerEnter owns hover opening. Skip Radix's second opening path so
|
||||||
// moving inside an entry after clicking it cannot reopen its introduction.
|
// moving inside an entry after clicking it cannot reopen its introduction.
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
|
|
||||||
/* The teacher speaks from the title bar without taking the student's focus. */
|
/* The teacher speaks from the title bar without taking the student's focus. */
|
||||||
.classroom-composer-view[hidden] { display: none; }
|
.classroom-composer-view[hidden] { display: none; }
|
||||||
.teacher-companion { position: relative; min-width: 0; max-width: 65%; }
|
.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 { 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-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-avatar { width: 48px; height: 48px; flex-shrink: 0; border: 2px solid #fff; border-radius: 15px; background: #e8f1fb; box-shadow: 0 3px 0 #d6e3ee; }
|
||||||
@@ -85,7 +85,10 @@
|
|||||||
.teacher-introduction-arrow { fill: #ead6af; }
|
.teacher-introduction-arrow { fill: #ead6af; }
|
||||||
@keyframes teacher-introduction-in { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes teacher-introduction-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .teacher-introduction { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .teacher-introduction { animation: none; } }
|
||||||
.consultation-agent-tabs { display: flex; align-items: center; gap: 4px; max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
|
.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; }
|
.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 { 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-person:hover, .teacher-companion-person[aria-expanded="true"] { background: #eef1e8; color: #42543a; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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 { useAuthStore } from '@/stores/auth';
|
||||||
import { AppError } from '@/lib/error-model';
|
import { AppError } from '@/lib/error-model';
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +11,12 @@ import {
|
|||||||
} from '../../../shared/coding-teacher';
|
} from '../../../shared/coding-teacher';
|
||||||
|
|
||||||
interface CompanionState {
|
interface CompanionState {
|
||||||
|
catalogState?: TeacherCatalogState;
|
||||||
|
refreshing: boolean;
|
||||||
|
refreshStatus: 'idle' | 'success' | 'error';
|
||||||
agents: TeacherCatalog['items'];
|
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;
|
definition: TeacherDefinition | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -47,7 +51,7 @@ export function useTeacherCompanion(options: Options) {
|
|||||||
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
|
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
|
||||||
const scope = `${account}:${options.projectId ?? ''}`;
|
const scope = `${account}:${options.projectId ?? ''}`;
|
||||||
const storageKey = `makelore-teacher-presence:${scope}`;
|
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 selectionEpoch = useRef(0);
|
const selectionEpoch = useRef(0);
|
||||||
const latest = useRef({ options, state });
|
const latest = useRef({ options, state });
|
||||||
latest.current = { options, state };
|
latest.current = { options, state };
|
||||||
@@ -55,6 +59,32 @@ export function useTeacherCompanion(options: Options) {
|
|||||||
scopeRef.current = scope;
|
scopeRef.current = scope;
|
||||||
const stateScope = useRef(scope);
|
const stateScope = useRef(scope);
|
||||||
const inFlight = useRef(false);
|
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) => {
|
const noteTopic = useCallback((topic: TeacherTopic) => {
|
||||||
if (scopeRef.current !== scope || topic.projectId !== options.projectId) return;
|
if (scopeRef.current !== scope || topic.projectId !== options.projectId) return;
|
||||||
setState((current) => current.topic?.id === topic.id && current.topic.revision >= topic.revision
|
setState((current) => current.topic?.id === topic.id && current.topic.revision >= topic.revision
|
||||||
@@ -64,7 +94,7 @@ export function useTeacherCompanion(options: Options) {
|
|||||||
}, [scope, options.projectId]);
|
}, [scope, options.projectId]);
|
||||||
const selectAgent = useCallback((id: string) => {
|
const selectAgent = useCallback((id: string) => {
|
||||||
const current = latest.current.state;
|
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);
|
const agent = current.agents.find((item) => item.teacher_id === id);
|
||||||
if (!agent) return;
|
if (!agent) return;
|
||||||
selectionEpoch.current++;
|
selectionEpoch.current++;
|
||||||
@@ -93,24 +123,38 @@ export function useTeacherCompanion(options: Options) {
|
|||||||
const welcomeDismissed = readLocal<boolean>(storageKey + ':welcome-dismissed', false) === true;
|
const welcomeDismissed = readLocal<boolean>(storageKey + ':welcome-dismissed', false) === true;
|
||||||
selectionEpoch.current++;
|
selectionEpoch.current++;
|
||||||
const initialSelection = selectionEpoch.current;
|
const initialSelection = selectionEpoch.current;
|
||||||
setState({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen, welcomeDismissed, error: '' });
|
setState({ agents: [], definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen, welcomeDismissed, error: '' });
|
||||||
const projectId = options.projectId;
|
const projectId = options.projectId;
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
setState((current) => ({ ...current, loading: false }));
|
setState((current) => ({ ...current, loading: false }));
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}
|
}
|
||||||
const base = teacherTopicsPath(projectId);
|
const base = teacherTopicsPath(projectId);
|
||||||
const loadConfig = async () => {
|
let configFlight: Promise<{ enabled: boolean }> | null = null;
|
||||||
const config = await teacherApi.config();
|
const loadConfig = () => {
|
||||||
const catalog = await teacherApi.catalog();
|
if (configFlight) return configFlight;
|
||||||
const selectedId = latest.current.state.selectedAgent?.teacher_id;
|
configFlight = (async () => {
|
||||||
const selected = catalog.items.find((item) => item.teacher_id === selectedId)
|
const [config, catalog] = await Promise.all([teacherApi.config(), teacherApi.catalog()]);
|
||||||
?? (!selectedId ? catalog.items.find((item) => item.is_default) ?? catalog.items[0] : undefined);
|
if (!alive || scopeRef.current !== scope) return { enabled: false };
|
||||||
const enabled = selected ? true : !selectedId && config.enabled;
|
const select = (current: CompanionState) => {
|
||||||
if (alive) setState((current) => ({ ...current, agents: catalog.items,
|
const id = current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id;
|
||||||
definition: selected?.definition ?? current.selectedAgent?.definition ?? current.topic?.definition ?? config.definition, enabled, loading: false }));
|
return catalog.items.find((item) => item.teacher_id === id)
|
||||||
return { enabled };
|
?? (!id ? catalog.items.find((item) => item.is_default) ?? catalog.items[0] : undefined);
|
||||||
|
};
|
||||||
|
const isEnabled = (current: CompanionState) => Boolean(select(current))
|
||||||
|
|| (!current.selectedAgent && !current.topic?.definition.config_id && config.enabled);
|
||||||
|
const enabled = isEnabled(latest.current.state);
|
||||||
|
setState((current) => ({ ...current, catalogState: { config, catalog }, agents: catalog.items,
|
||||||
|
selectedAgent: current.selectedAgent ? select(current) ?? current.selectedAgent
|
||||||
|
: (!current.agents.length && !current.topic?.definition.config_id && latest.current.options.teacherOpen)
|
||||||
|
? null : current.selectedAgent,
|
||||||
|
definition: select(current)?.definition ?? current.selectedAgent?.definition ?? current.topic?.definition ?? config.definition,
|
||||||
|
enabled: isEnabled(current), loading: false, error: '' }));
|
||||||
|
return { enabled };
|
||||||
|
})().finally(() => { configFlight = null; });
|
||||||
|
return configFlight;
|
||||||
};
|
};
|
||||||
|
configLoader.current = loadConfig;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await loadConfig();
|
await loadConfig();
|
||||||
@@ -183,6 +227,7 @@ export function useTeacherCompanion(options: Options) {
|
|||||||
document.addEventListener('visibilitychange', onVisible);
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
if (configLoader.current === loadConfig) configLoader.current = null;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
window.removeEventListener('focus', onVisible);
|
window.removeEventListener('focus', onVisible);
|
||||||
document.removeEventListener('visibilitychange', onVisible);
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
@@ -211,12 +256,12 @@ export function useTeacherCompanion(options: Options) {
|
|||||||
|
|
||||||
const last = topic?.requests.at(-1);
|
const last = topic?.requests.at(-1);
|
||||||
const invitation = unreadInvitation({ topic, seen: state.seen });
|
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.
|
// This is the published greeting, not a fabricated project assessment.
|
||||||
const selectedDefinition = topic?.definition ?? visibleState.definition;
|
const selectedDefinition = topic?.definition ?? visibleState.definition;
|
||||||
const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id;
|
const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id;
|
||||||
const selectedAgent = visibleState.agents.find((item) => item.teacher_id === selectedAgentId)
|
const selectedAgent = visibleState.selectedAgent === null ? undefined
|
||||||
?? visibleState.selectedAgent;
|
: visibleState.agents.find((item) => item.teacher_id === selectedAgentId) ?? visibleState.selectedAgent;
|
||||||
const welcome = topic?.role !== 'friend' && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed
|
const welcome = topic?.role !== 'friend' && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed
|
||||||
? selectedDefinition?.welcome_message.trim() || null : null;
|
? selectedDefinition?.welcome_message.trim() || null : null;
|
||||||
return {
|
return {
|
||||||
@@ -224,6 +269,8 @@ export function useTeacherCompanion(options: Options) {
|
|||||||
error: last?.intent === 'check-in' && ['failed', 'interrupted'].includes(last.status) ? '智能体这次没连上,可以点头像找智能体聊聊。' : visibleState.error,
|
error: last?.intent === 'check-in' && ['failed', 'interrupted'].includes(last.status) ? '智能体这次没连上,可以点头像找智能体聊聊。' : visibleState.error,
|
||||||
definition: selectedDefinition,
|
definition: selectedDefinition,
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
|
refresh,
|
||||||
|
reloadCatalog,
|
||||||
selectAgent,
|
selectAgent,
|
||||||
topic,
|
topic,
|
||||||
invitation,
|
invitation,
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export function Layout() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [width, setWidth] = useState(508);
|
const [width, setWidth] = useState(508);
|
||||||
const [long, setLong] = useState(true);
|
const [long, setLong] = useState(true);
|
||||||
const count = Number(new URLSearchParams(location.search).get('agents') ?? 0);
|
const [count, setCount] = useState(Number(new URLSearchParams(location.search).get('agents') ?? 0));
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [refreshStatus, setRefreshStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
const [selected, setSelected] = useState(0);
|
const [selected, setSelected] = useState(0);
|
||||||
const agents = Array.from({ length: count }, (_, index) => ({
|
const agents = Array.from({ length: count }, (_, index) => ({
|
||||||
teacher_id: `agent-${index}`, version: index + 1, is_default: index === 0,
|
teacher_id: `agent-${index}`, version: index + 1, is_default: index === 0,
|
||||||
@@ -27,6 +29,16 @@ export function Layout() {
|
|||||||
definition: agents[selected]?.definition ?? { name: '编程老师', avatar_id: 'avatar-06' },
|
definition: agents[selected]?.definition ?? { name: '编程老师', avatar_id: 'avatar-06' },
|
||||||
invitation: visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : null,
|
invitation: visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : null,
|
||||||
welcome: null, enabled: true, loading: false, checking: false, error: null,
|
welcome: null, enabled: true, loading: false, checking: false, error: null,
|
||||||
|
refreshing, refreshStatus,
|
||||||
|
refresh: async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setRefreshStatus('idle');
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 100));
|
||||||
|
setCount((current) => current + 1);
|
||||||
|
setRefreshStatus('success');
|
||||||
|
setRefreshing(false);
|
||||||
|
},
|
||||||
|
reloadCatalog: async () => {},
|
||||||
dismiss: () => setVisible(false),
|
dismiss: () => setVisible(false),
|
||||||
} as ComponentProps<typeof TeacherCompanion>['companion'];
|
} as ComponentProps<typeof TeacherCompanion>['companion'];
|
||||||
return <div className="classroom-workspace flex h-screen flex-col bg-white">
|
return <div className="classroom-workspace flex h-screen flex-col bg-white">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createServer } from 'node:http';
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import type { NativeAgentBrowserFixture } from './fixtures/native-agent-browser';
|
import type { NativeAgentBrowserFixture } from './fixtures/native-agent-browser';
|
||||||
|
import type { TeacherCatalog, TeacherDefinition } from '../../shared/coding-teacher';
|
||||||
import { expect, getStableWindow, test } from './fixtures/electron';
|
import { expect, getStableWindow, test } from './fixtures/electron';
|
||||||
|
|
||||||
type CapturedRequest = {
|
type CapturedRequest = {
|
||||||
@@ -20,6 +21,13 @@ type HostConnection = {
|
|||||||
token: string;
|
token: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TeacherCatalogFixture = {
|
||||||
|
teacherCatalog: TeacherCatalog;
|
||||||
|
teacherCatalogFailure: boolean;
|
||||||
|
holdTeacherCatalog: boolean;
|
||||||
|
releaseTeacherCatalog: (() => void) | null;
|
||||||
|
};
|
||||||
|
|
||||||
async function disableCodingEventSource(page: Page): Promise<void> {
|
async function disableCodingEventSource(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const sources = new Set<LocalEventSource>();
|
const sources = new Set<LocalEventSource>();
|
||||||
@@ -92,7 +100,7 @@ async function installCodingFirstChatHost(
|
|||||||
await electronApp.evaluate(async (_, payload) => {
|
await electronApp.evaluate(async (_, payload) => {
|
||||||
const { connection, featureComplete, managedCapabilities, removedModel, audioPreview } = payload;
|
const { connection, featureComplete, managedCapabilities, removedModel, audioPreview } = payload;
|
||||||
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
|
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
|
||||||
type MainState = {
|
type MainState = TeacherCatalogFixture & {
|
||||||
captured: CapturedRequest[];
|
captured: CapturedRequest[];
|
||||||
conversationCreated: boolean;
|
conversationCreated: boolean;
|
||||||
interactionAnswered: boolean;
|
interactionAnswered: boolean;
|
||||||
@@ -116,6 +124,10 @@ async function installCodingFirstChatHost(
|
|||||||
releaseSnapshot: null,
|
releaseSnapshot: null,
|
||||||
snapshotPending: false,
|
snapshotPending: false,
|
||||||
snapshotSettled: false,
|
snapshotSettled: false,
|
||||||
|
teacherCatalog: { items: [] },
|
||||||
|
teacherCatalogFailure: false,
|
||||||
|
holdTeacherCatalog: false,
|
||||||
|
releaseTeacherCatalog: null,
|
||||||
};
|
};
|
||||||
mainGlobal.__makelorePiFirstChatE2E = state;
|
mainGlobal.__makelorePiFirstChatE2E = state;
|
||||||
const now = '2026-08-24T00:00:00.000Z';
|
const now = '2026-08-24T00:00:00.000Z';
|
||||||
@@ -454,8 +466,8 @@ async function installCodingFirstChatHost(
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const teacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'一起发现代码里的小奥秘,把想法变成作品。',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
|
const teacherDefinition: TeacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'一起发现代码里的小奥秘,把想法变成作品。',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
|
||||||
const teachers = [
|
state.teacherCatalog.items = [
|
||||||
{ teacher_id: 'teacher-code', version: 1, is_default: true, definition: { ...teacherDefinition, config_id: 'teacher-code', runtime: 'yuxi', system_prompt: '', yuxi: { agent_slug: 'code', agent_version: 2 } } },
|
{ teacher_id: 'teacher-code', version: 1, is_default: true, definition: { ...teacherDefinition, config_id: 'teacher-code', runtime: 'yuxi', system_prompt: '', yuxi: { agent_slug: 'code', agent_version: 2 } } },
|
||||||
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', description: '陪你听听玩家的想法,让作品更有趣。', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
|
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', description: '陪你听听玩家的想法,让作品更有趣。', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
|
||||||
];
|
];
|
||||||
@@ -533,11 +545,20 @@ async function installCodingFirstChatHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (path.endsWith('/teacher-check-in') && method === 'POST') {
|
if (path.endsWith('/teacher-check-in') && method === 'POST') {
|
||||||
const current = consultationTopics.agent ?? {schemaVersion:1,revision:0,id:'agent-topic',accountId:'e2e',projectId:project.id,sourceConversationId:'project',definition:teacherDefinition,version:1,createdAt:now,updatedAt:now,requests:[]};
|
const published = state.teacherCatalog.items.find((item) => item.is_default) ?? state.teacherCatalog.items[0];
|
||||||
|
const current = consultationTopics.agent ?? {schemaVersion:1,revision:0,id:'agent-topic',accountId:'e2e',projectId:project.id,sourceConversationId:'project',definition:published?.definition ?? teacherDefinition,version:published?.version ?? 1,createdAt:now,updatedAt:now,requests:[]};
|
||||||
consultationTopics.agent = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,intent:'check-in',text:'',references:[],sourceConversationId:body!.sourceConversationId,createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,status:'completed',response:'你刚才想加排行榜,我们一起想想比什么更有意思?'}]};
|
consultationTopics.agent = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,intent:'check-in',text:'',references:[],sourceConversationId:body!.sourceConversationId,createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,status:'completed',response:'你刚才想加排行榜,我们一起想想比什么更有意思?'}]};
|
||||||
return respond({topic:consultationTopics.agent});
|
return respond({topic:consultationTopics.agent});
|
||||||
}
|
}
|
||||||
if (path === '/api/coding/teacher/teachers') return respond({ items: teachers });
|
if (path === '/api/coding/teacher/teachers') {
|
||||||
|
if (state.holdTeacherCatalog) {
|
||||||
|
await new Promise<void>((resolveCatalog) => { state.releaseTeacherCatalog = resolveCatalog; });
|
||||||
|
state.releaseTeacherCatalog = null;
|
||||||
|
state.holdTeacherCatalog = false;
|
||||||
|
}
|
||||||
|
if (state.teacherCatalogFailure) return respond({ error: 'Catalog unavailable' }, 503);
|
||||||
|
return respond(state.teacherCatalog);
|
||||||
|
}
|
||||||
if (path === '/api/coding/teacher/config') return respond({enabled:true,revision:1,published_version:1,definition:teacherDefinition});
|
if (path === '/api/coding/teacher/config') return respond({enabled:true,revision:1,published_version:1,definition:teacherDefinition});
|
||||||
const consultationPath = path.match(/\/(agent|teacher)-topics(?:\/|$)/)?.[1];
|
const consultationPath = path.match(/\/(agent|teacher)-topics(?:\/|$)/)?.[1];
|
||||||
if (consultationPath) {
|
if (consultationPath) {
|
||||||
@@ -546,7 +567,7 @@ async function installCodingFirstChatHost(
|
|||||||
const currentTopic = consultationTopics[consultationPath];
|
const currentTopic = consultationTopics[consultationPath];
|
||||||
if (path.endsWith(topicBase) && method === 'GET') return respond({items:currentTopic?[{id:currentTopic.id,title:'智能体话题',teacherId:(currentTopic.definition as {config_id?:string}).config_id}]:[],lastSelectedTopicId:currentTopic?.id??null});
|
if (path.endsWith(topicBase) && method === 'GET') return respond({items:currentTopic?[{id:currentTopic.id,title:'智能体话题',teacherId:(currentTopic.definition as {config_id?:string}).config_id}]:[],lastSelectedTopicId:currentTopic?.id??null});
|
||||||
if (path.endsWith(topicBase) && method === 'POST') {
|
if (path.endsWith(topicBase) && method === 'POST') {
|
||||||
const selected = teachers.find(item => item.version === body?.teacherVersion) ?? teachers[0];
|
const selected = state.teacherCatalog.items.find(item => item.version === body?.teacherVersion) ?? state.teacherCatalog.items[0];
|
||||||
consultationTopics[consultationPath]={schemaVersion:1,revision:0,id:topicId,accountId:'e2e',projectId:project.id,sourceConversationId:conversation.id,definition:selected.definition,version:selected.version,createdAt:now,updatedAt:now,requests:[]};
|
consultationTopics[consultationPath]={schemaVersion:1,revision:0,id:topicId,accountId:'e2e',projectId:project.id,sourceConversationId:conversation.id,definition:selected.definition,version:selected.version,createdAt:now,updatedAt:now,requests:[]};
|
||||||
return respond(consultationTopics[consultationPath],201);
|
return respond(consultationTopics[consultationPath],201);
|
||||||
}
|
}
|
||||||
@@ -1864,6 +1885,107 @@ test('project consultations preserve student drafts and switch between work and
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('manual agent refresh updates the catalog and retries failures without replacing the open topic or drafts', async ({ launchElectronApp }) => {
|
||||||
|
const electronApp = await launchElectronApp({ skipSetup: true });
|
||||||
|
let page = await getStableWindow(electronApp);
|
||||||
|
const connection = await page.evaluate(async () => ({
|
||||||
|
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
|
||||||
|
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
|
||||||
|
}));
|
||||||
|
await installCodingFirstChatHost(electronApp, connection, true);
|
||||||
|
await settleSnapshot(electronApp);
|
||||||
|
await disableCodingEventSource(page);
|
||||||
|
try {
|
||||||
|
await page.reload();
|
||||||
|
page = await getStableWindow(electronApp);
|
||||||
|
await page.getByTestId('ai-module-option-programming').click();
|
||||||
|
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||||
|
await expect(page.getByTestId('project-conversations')).toBeVisible();
|
||||||
|
const composer = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
|
||||||
|
await composer.fill('主会话还没发出的想法');
|
||||||
|
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
|
||||||
|
const teacher = page.getByTestId('teacher-chat-panel');
|
||||||
|
await teacher.getByRole('button', { name: '解释当前代码', exact: true }).click();
|
||||||
|
await teacher.getByRole('button', { name: '提问', exact: true }).click();
|
||||||
|
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
|
||||||
|
const draft = teacher.getByRole('textbox', { name: '向智能体提问' });
|
||||||
|
await draft.fill('智能体聊天里还没说完的话');
|
||||||
|
const before = (await readState(electronApp)).captured.length;
|
||||||
|
await electronApp.evaluate(() => {
|
||||||
|
const state = (globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture }).__makelorePiFirstChatE2E;
|
||||||
|
const original = state.teacherCatalog.items[0];
|
||||||
|
state.teacherCatalog.items = [
|
||||||
|
{ ...original, version: 2, definition: { ...original.definition, name: '新版代码智能体', description: '更新后的编程伙伴介绍。' } },
|
||||||
|
state.teacherCatalog.items[1],
|
||||||
|
{ teacher_id: 'teacher-science', version: 15, is_default: false,
|
||||||
|
definition: { ...original.definition, config_id: 'teacher-science', name: '科学智能体', welcome_message: '一起探索科学', description: '刚刚下发的新智能体。' } },
|
||||||
|
];
|
||||||
|
state.holdTeacherCatalog = true;
|
||||||
|
});
|
||||||
|
const refresh = page.getByRole('button', { name: '刷新智能体', exact: true });
|
||||||
|
await refresh.click();
|
||||||
|
await expect(refresh).toBeDisabled();
|
||||||
|
await expect(page.getByRole('status').filter({ hasText: '正在刷新' })).toBeVisible();
|
||||||
|
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
|
||||||
|
await expect.poll(() => electronApp.evaluate(() => Boolean(
|
||||||
|
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
|
||||||
|
.__makelorePiFirstChatE2E.releaseTeacherCatalog,
|
||||||
|
))).toBe(true);
|
||||||
|
await electronApp.evaluate(() => {
|
||||||
|
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
|
||||||
|
.__makelorePiFirstChatE2E.releaseTeacherCatalog?.();
|
||||||
|
});
|
||||||
|
await expect(refresh).toBeEnabled();
|
||||||
|
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '与科学智能体聊天', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '与新版代码智能体聊天', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.getByRole('button', { name: '与代码智能体聊天', exact: true })).toHaveCount(0);
|
||||||
|
// Existing discussions keep their original definition and all unsent text.
|
||||||
|
await expect(teacher).toHaveAccessibleName('代码智能体咨询');
|
||||||
|
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
|
||||||
|
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
|
||||||
|
await expect(composer).toHaveValue('主会话还没发出的想法');
|
||||||
|
const refreshedRequests = (await readState(electronApp)).captured.slice(before);
|
||||||
|
expect(refreshedRequests.filter((request) => request.path === '/api/coding/teacher/teachers')).toHaveLength(1);
|
||||||
|
expect(refreshedRequests.filter((request) => request.path === '/api/coding/teacher/config')).toHaveLength(1);
|
||||||
|
expect(refreshedRequests.filter((request) => /\/(agent-topics|messages|teacher-check-in|prompt)$/.test(request.path) && request.method === 'POST')).toHaveLength(0);
|
||||||
|
expect(refreshedRequests.filter((request) => /\/agent-topics(?:\/|$)/.test(request.path))).toHaveLength(0);
|
||||||
|
await page.screenshot({ path: test.info().outputPath('manual-agent-refresh.png') });
|
||||||
|
|
||||||
|
await electronApp.evaluate(() => {
|
||||||
|
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
|
||||||
|
.__makelorePiFirstChatE2E.teacherCatalogFailure = true;
|
||||||
|
});
|
||||||
|
await refresh.click();
|
||||||
|
await expect(page.getByRole('status').filter({ hasText: '刷新失败,请重试' })).toBeVisible();
|
||||||
|
await expect(refresh).toBeEnabled();
|
||||||
|
await expect(page.getByRole('button', { name: '与科学智能体聊天', exact: true })).toBeVisible();
|
||||||
|
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
|
||||||
|
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
|
||||||
|
await electronApp.evaluate(() => {
|
||||||
|
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
|
||||||
|
.__makelorePiFirstChatE2E.teacherCatalogFailure = false;
|
||||||
|
});
|
||||||
|
await refresh.click();
|
||||||
|
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '与科学智能体聊天', exact: true }).click();
|
||||||
|
await expect(teacher).toHaveAccessibleName('科学智能体咨询');
|
||||||
|
await expect(teacher.getByText('一起探索科学')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '与新版代码智能体聊天', exact: true }).click();
|
||||||
|
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
|
||||||
|
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
|
||||||
|
await expect(composer).toHaveValue('主会话还没发出的想法');
|
||||||
|
const finalRequests = (await readState(electronApp)).captured.slice(before);
|
||||||
|
expect(finalRequests.filter((request) => /\/(messages|teacher-check-in|prompt)$/.test(request.path) && request.method === 'POST')).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
await electronApp.evaluate(() => {
|
||||||
|
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E?: TeacherCatalogFixture })
|
||||||
|
.__makelorePiFirstChatE2E?.releaseTeacherCatalog?.();
|
||||||
|
}).catch(() => undefined);
|
||||||
|
await releaseSnapshot(electronApp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('settled prompt releases the composer without a client receipt and keeps the next draft', async ({ launchElectronApp }) => {
|
test('settled prompt releases the composer without a client receipt and keeps the next draft', async ({ launchElectronApp }) => {
|
||||||
const electronApp = await launchElectronApp({ skipSetup: true });
|
const electronApp = await launchElectronApp({ skipSetup: true });
|
||||||
let page = await getStableWindow(electronApp);
|
let page = await getStableWindow(electronApp);
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ for (const width of [520, 1000]) {
|
|||||||
await page.setViewportSize({ width, height: 800 });
|
await page.setViewportSize({ width, height: 800 });
|
||||||
await page.goto(baseUrl + '?agents=8');
|
await page.goto(baseUrl + '?agents=8');
|
||||||
const tabs = page.getByRole('navigation', { name: '已下发智能体' });
|
const tabs = page.getByRole('navigation', { name: '已下发智能体' });
|
||||||
|
const refresh = page.getByRole('button', { name: '刷新智能体', exact: true });
|
||||||
await expect(tabs.getByRole('button')).toHaveCount(8);
|
await expect(tabs.getByRole('button')).toHaveCount(8);
|
||||||
|
await expect(refresh).toBeVisible();
|
||||||
|
await expect(tabs.getByRole('button', { name: '刷新智能体', exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0);
|
await expect(page.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0);
|
||||||
const last = tabs.getByRole('button', { name: '与智能体8聊天' });
|
const last = tabs.getByRole('button', { name: '与智能体8聊天' });
|
||||||
await last.focus();
|
await last.focus();
|
||||||
@@ -48,13 +51,22 @@ for (const width of [520, 1000]) {
|
|||||||
await expect(last).toHaveAttribute('aria-pressed', 'true');
|
await expect(last).toHaveAttribute('aria-pressed', 'true');
|
||||||
const bounds = await page.evaluate(() => {
|
const bounds = await page.evaluate(() => {
|
||||||
const strip = document.querySelector('.consultation-agent-tabs')!;
|
const strip = document.querySelector('.consultation-agent-tabs')!;
|
||||||
|
const refresh = document.querySelector('button[aria-label="刷新智能体"]')!;
|
||||||
const controls = document.querySelector('[data-testid="window-controls"]')!;
|
const controls = document.querySelector('[data-testid="window-controls"]')!;
|
||||||
return { right: strip.getBoundingClientRect().right, controlsLeft: controls.getBoundingClientRect().left,
|
return { right: strip.getBoundingClientRect().right, controlsLeft: controls.getBoundingClientRect().left,
|
||||||
|
refreshLeft: refresh.getBoundingClientRect().left, refreshRight: refresh.getBoundingClientRect().right,
|
||||||
scroll: strip.scrollLeft, documentWidth: document.documentElement.scrollWidth };
|
scroll: strip.scrollLeft, documentWidth: document.documentElement.scrollWidth };
|
||||||
});
|
});
|
||||||
expect(bounds.right).toBeLessThanOrEqual(bounds.controlsLeft);
|
expect(bounds.right).toBeLessThanOrEqual(bounds.refreshLeft);
|
||||||
|
expect(bounds.refreshRight).toBeLessThanOrEqual(bounds.controlsLeft);
|
||||||
expect(bounds.documentWidth).toBeLessThanOrEqual(width);
|
expect(bounds.documentWidth).toBeLessThanOrEqual(width);
|
||||||
expect(bounds.scroll).toBeGreaterThan(0);
|
expect(bounds.scroll).toBeGreaterThan(0);
|
||||||
|
await refresh.click();
|
||||||
|
await expect(tabs.getByRole('button')).toHaveCount(9);
|
||||||
|
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
|
||||||
|
await expect(last).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(refresh).toBeInViewport();
|
||||||
|
await expect(refresh).toBeEnabled();
|
||||||
await page.screenshot({ path: test.info().outputPath('distributed-agents-titlebar.png') });
|
await page.screenshot({ path: test.info().outputPath('distributed-agents-titlebar.png') });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { useTeacherCompanion } from '@/pages/Chat/use-teacher-companion';
|
import { useTeacherCompanion } from '@/pages/Chat/use-teacher-companion';
|
||||||
import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion';
|
import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion';
|
||||||
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
|
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
|
||||||
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
|
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherCatalog, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn() }));
|
const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn() }));
|
||||||
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project }));
|
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project }));
|
||||||
@@ -25,7 +25,7 @@ function Harness({ projectId = 'project', sourceId = 'source', busy = false, arc
|
|||||||
if (open && (!id || id === companion.selectedAgent?.teacher_id)) setOpen(false);
|
if (open && (!id || id === companion.selectedAgent?.teacher_id)) setOpen(false);
|
||||||
else { if (id) companion.selectAgent(id); setOpen(true); }
|
else { if (id) companion.selectAgent(id); setOpen(true); }
|
||||||
}} />
|
}} />
|
||||||
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} />}</>;
|
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} catalogState={companion.catalogState} reloadCatalog={companion.reloadCatalog} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} />}</>;
|
||||||
}
|
}
|
||||||
async function mount(props: Parameters<typeof Harness>[0] = {}) {
|
async function mount(props: Parameters<typeof Harness>[0] = {}) {
|
||||||
let view!: ReturnType<typeof render>;
|
let view!: ReturnType<typeof render>;
|
||||||
@@ -51,6 +51,160 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
|
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
describe('manual agent refresh', () => {
|
||||||
|
it('refreshes while busy and composing, shares one request, and uses the new version for the first question', async () => {
|
||||||
|
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', welcome_message: '一起想想办法' } };
|
||||||
|
const updated = { ...original, version: 4, definition: { ...original.definition, name: '方法伙伴', welcome_message: '我们一起试试新办法' } };
|
||||||
|
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
||||||
|
api.catalog.mockResolvedValue({ items: [original] });
|
||||||
|
await mount({ busy: true });
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
|
||||||
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我正在写的问题' } });
|
||||||
|
const listCalls = api.list.mock.calls.length;
|
||||||
|
const catalogCalls = api.catalog.mock.calls.length;
|
||||||
|
let resolve!: (catalog: TeacherCatalog) => void;
|
||||||
|
api.catalog.mockImplementationOnce(() => new Promise<TeacherCatalog>((done) => { resolve = done; }));
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
||||||
|
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeDisabled();
|
||||||
|
expect(screen.getByText('正在刷新…', { selector: '[role="status"]' })).toBeVisible();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '刷新智能体' }));
|
||||||
|
expect(api.catalog).toHaveBeenCalledTimes(catalogCalls + 1);
|
||||||
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我正在写的问题');
|
||||||
|
await act(async () => resolve({ items: [updated, added] }));
|
||||||
|
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
|
||||||
|
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('complementary', { name: '方法伙伴咨询' })).toBeVisible();
|
||||||
|
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(updated.definition.welcome_message)).toBeVisible();
|
||||||
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我正在写的问题');
|
||||||
|
expect(api.list).toHaveBeenCalledTimes(listCalls);
|
||||||
|
expect(api.create).not.toHaveBeenCalled();
|
||||||
|
expect(api.send).not.toHaveBeenCalled();
|
||||||
|
expect(api.checkIn).not.toHaveBeenCalled();
|
||||||
|
const created = { ...checkInTopic(), id: 'new-topic', definition: updated.definition, version: updated.version, requests: [] };
|
||||||
|
api.create.mockResolvedValue(created);
|
||||||
|
api.send.mockResolvedValue(created);
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||||
|
expect(api.create).toHaveBeenCalledWith('project', undefined, undefined, updated.version);
|
||||||
|
expect(api.send).toHaveBeenCalledWith('project', created.id, expect.objectContaining({ text: '我正在写的问题' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the selected pinned topic, unread reply and draft until the user starts a new topic', async () => {
|
||||||
|
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
|
||||||
|
const updated = { ...original, version: 4, definition: { ...original.definition, name: '方法伙伴' } };
|
||||||
|
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
||||||
|
const pinned = { ...checkInTopic(), version: 1, definition: original.definition };
|
||||||
|
api.catalog.mockResolvedValue({ items: [original] });
|
||||||
|
api.list.mockResolvedValue({ items: [{ id: pinned.id, teacherId: original.teacher_id, title: '之前的讨论' }], lastSelectedTopicId: pinned.id });
|
||||||
|
api.read.mockResolvedValue(pinned);
|
||||||
|
await mount();
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
|
||||||
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '接着刚才说' } });
|
||||||
|
const readCalls = api.read.mock.calls.length;
|
||||||
|
const listCalls = api.list.mock.calls.length;
|
||||||
|
api.catalog.mockResolvedValue({ items: [updated, added] });
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
||||||
|
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByRole('complementary', { name: '方法老师咨询' })).toBeVisible();
|
||||||
|
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(pinned.requests[0].response)).toBeVisible();
|
||||||
|
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent(pinned.requests[0].response);
|
||||||
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('接着刚才说');
|
||||||
|
expect(api.read).toHaveBeenCalledTimes(readCalls);
|
||||||
|
expect(api.list).toHaveBeenCalledTimes(listCalls);
|
||||||
|
expect(api.create).not.toHaveBeenCalled();
|
||||||
|
expect(api.send).not.toHaveBeenCalled();
|
||||||
|
expect(api.checkIn).not.toHaveBeenCalled();
|
||||||
|
api.send.mockResolvedValue(pinned);
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||||
|
expect(api.send).toHaveBeenCalledWith('project', pinned.id, expect.objectContaining({ text: '接着刚才说' }));
|
||||||
|
expect(api.create).not.toHaveBeenCalled();
|
||||||
|
api.create.mockResolvedValue({ ...pinned, id: 'new-topic', version: updated.version, definition: updated.definition, requests: [] });
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '以往讨论' })));
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体新话题' })));
|
||||||
|
expect(api.create).toHaveBeenCalledWith('project', undefined, undefined, updated.version);
|
||||||
|
expect(screen.getByRole('complementary', { name: '方法伙伴咨询' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['catalog', 'config'] as const)('keeps the current catalog when %s fails and lets the user retry', async (failedEndpoint) => {
|
||||||
|
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
|
||||||
|
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
||||||
|
api.catalog.mockResolvedValue({ items: [original] });
|
||||||
|
await mount();
|
||||||
|
api.catalog.mockResolvedValue({ items: [original, added] });
|
||||||
|
api[failedEndpoint].mockRejectedValueOnce(new Error('offline'));
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
||||||
|
expect(screen.getByText('刷新失败,请重试')).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
|
||||||
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent('方法老师');
|
||||||
|
expect(screen.queryByRole('button', { name: '与体验朋友聊天' })).not.toBeInTheDocument();
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
||||||
|
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
|
||||||
|
expect(screen.queryByText('刷新失败,请重试')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
|
||||||
|
expect(api.create).not.toHaveBeenCalled();
|
||||||
|
expect(api.send).not.toHaveBeenCalled();
|
||||||
|
expect(api.checkIn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can fetch the first delivered agent while the previous catalog was unavailable', async () => {
|
||||||
|
api.config.mockResolvedValue({ enabled: false, definition: null });
|
||||||
|
await mount();
|
||||||
|
const added = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
||||||
|
api.catalog.mockResolvedValue({ items: [added] });
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
||||||
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent('体验朋友');
|
||||||
|
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
|
||||||
|
expect(api.checkIn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an open project draft in place when the first delivered agent appears', async () => {
|
||||||
|
await mount();
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
|
||||||
|
const input = screen.getByLabelText('向智能体提问');
|
||||||
|
fireEvent.change(input, { target: { value: '还没有选择智能体的问题' } });
|
||||||
|
const added = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
||||||
|
api.catalog.mockResolvedValue({ items: [added] });
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
||||||
|
expect(screen.getByLabelText('向智能体提问')).toBe(input);
|
||||||
|
expect(input).toHaveValue('还没有选择智能体的问题');
|
||||||
|
expect(screen.queryByText('查看之前的项目草稿')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(added.definition.name);
|
||||||
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
||||||
|
expect(screen.getByLabelText('向智能体提问')).toBe(input);
|
||||||
|
expect(input).toHaveValue('还没有选择智能体的问题');
|
||||||
|
expect(api.create).not.toHaveBeenCalled();
|
||||||
|
expect(api.send).not.toHaveBeenCalled();
|
||||||
|
expect(api.checkIn).not.toHaveBeenCalled();
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
|
||||||
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
|
||||||
|
expect(screen.getByText('查看之前的项目草稿')).toBeVisible();
|
||||||
|
expect(localStorage.getItem('makelore-consultation-draft:kid:project:teacher')).toContain('还没有选择智能体的问题');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a slow refresh from the previous project without changing the new project status', async () => {
|
||||||
|
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '旧项目智能体' } };
|
||||||
|
const current = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '当前项目智能体' } };
|
||||||
|
api.catalog.mockResolvedValue({ items: [original] });
|
||||||
|
const view = await mount();
|
||||||
|
let resolve!: (catalog: TeacherCatalog) => void;
|
||||||
|
api.catalog.mockImplementationOnce(() => new Promise<TeacherCatalog>((done) => { resolve = done; }));
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
||||||
|
api.catalog.mockResolvedValue({ items: [current] });
|
||||||
|
await act(async () => view.rerender(<Harness projectId="other" />));
|
||||||
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(current.definition.name);
|
||||||
|
await act(async () => resolve({ items: [original] }));
|
||||||
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(current.definition.name);
|
||||||
|
expect(screen.queryByText(original.definition.name)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('已更新', { selector: '[role="status"]' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('正在刷新…', { selector: '[role="status"]' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
|
||||||
|
expect(api.checkIn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('teacher presence and periodic guidance', () => {
|
describe('teacher presence and periodic guidance', () => {
|
||||||
it('shows the hovered agent introduction in a portal without selecting it or sending a request', async () => {
|
it('shows the hovered agent introduction in a portal without selecting it or sending a request', async () => {
|
||||||
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', description: '把大问题拆成小积木,一起试试看。' } };
|
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', description: '把大问题拆成小积木,一起试试看。' } };
|
||||||
|
|||||||
Reference in new issue
Block a user