Merge teacher composer and compact prompt UI into main

This commit is contained in:
鲨鱼辣椒 committed 2026-09-29 20:40:09 +08:00
commit 2b2d39449c
62 files changed
+2327 -158

No files matched your search

+211 -1
View File
@@ -4,12 +4,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useTeacherCompanion } from '@/pages/Chat/use-teacher-companion';
import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import { codingConversationStore } from '@/stores/coding-conversations';
import type { ConversationRunState } from '@/types/coding-conversation';
import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures';
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(), conversation: vi.fn(), sendConversation: vi.fn(), seenConversation: vi.fn() }));
const identity = vi.hoisted(() => ({ id: 'kid' }));
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project,
teacherConversationsPath: (project: string) => '/api/coding/projects/' + project + '/agent-conversations' }));
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid' } }) }));
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: identity.id } }) }));
const definition: TeacherDefinition = {
schema_version: 1, teacher_id: 'coding-teacher', name: '运营配置的智能体', description: '', avatar_id: 'avatar-06', welcome_message: '', suggested_questions: [], system_prompt: '指导', skills: [],
model: { model_id: 'model', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
@@ -38,6 +42,8 @@ async function advance(ms = TEACHER_CHECK_IN_INTERVAL_MS) {
}
beforeEach(() => {
vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear();
codingConversationStore.setState({ entriesByConversationId: {}, summariesByConversationId: {} });
identity.id = 'kid';
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
@@ -53,6 +59,210 @@ beforeEach(() => {
});
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
describe('individual teacher introductions', () => {
const agents = ['方法老师', '体验老师', '故事老师'].map((name, index) => ({
teacher_id: `intro-${index}`, version: 1, is_default: index === 0,
definition: { ...definition, config_id: `intro-${index}`, name, description: `${name}的自我介绍`, welcome_message: `${name}的聊天欢迎语` },
}));
async function operation(runId: string, overrides: Partial<ConversationRunState> = {}, projectId = 'project', sourceId = 'source') {
const snapshot = createProductSnapshot(sourceId);
snapshot.conversation.projectId = projectId;
snapshot.run = { status: 'idle', mode: 'prompt', runId, startedAt: Date.now(), settledAt: Date.now(), terminalReason: 'completed', ...overrides };
snapshot.cursor.seq = (codingConversationStore.getState().entriesByConversationId[sourceId]?.reducer.snapshot?.cursor.seq ?? 0) + 1;
await act(async () => codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot', conversationId: sourceId, workerGeneration: 1, seq: snapshot.cursor.seq, snapshot,
}));
}
it('leaves introductions until the third completed operation round and preserves hover and unread receipts', async () => {
api.catalog.mockResolvedValue({ items: agents });
localStorage.setItem('makelore-teacher-presence:kid:seen', JSON.stringify(['keep-seen']));
await mount();
await operation('one');
await operation('one'); // A duplicate/recovered completion is still the same round.
await operation('two');
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3);
await operation('three', { status: 'running', terminalReason: undefined });
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
fireEvent.pointerEnter(screen.getByRole('button', { name: '与方法老师聊天' }), { pointerType: 'mouse' });
expect(screen.getByRole('tooltip')).toHaveTextContent('方法老师的自我介绍');
expect(JSON.parse(localStorage.getItem('makelore-teacher-presence:kid:seen')!)).toEqual(['keep-seen']);
expect(api.sendConversation).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
expect(api.seenConversation).not.toHaveBeenCalled();
});
it('ignores historical, failed, aborted, tool/retry, compaction and other-project activity', async () => {
api.catalog.mockResolvedValue({ items: agents });
await mount();
for (let i = 0; i < 3; i++) {
await operation(`history-${i}`, { startedAt: Date.now() - 60_000 });
await operation(`failed-${i}`, { status: 'error', terminalReason: 'failed' });
await operation(`aborted-${i}`, { terminalReason: 'aborted' });
await operation(`retry-${i}`, { status: 'retrying', terminalReason: undefined });
await operation(`tools-${i}`, { status: 'running', terminalReason: undefined });
await operation(`compaction-${i}`, { mode: undefined });
await operation(`other-project-${i}`, {}, 'other');
}
await operation('one');
await operation('two');
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
});
it('continues the count across reload and operation-chat switches, including a completion during reload', async () => {
api.catalog.mockResolvedValue({ items: agents });
const first = await mount();
await operation('same-run-id');
await act(async () => first.rerender(<Harness sourceId="second-source" />));
await operation('same-run-id', {}, 'project', 'second-source');
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
first.unmount();
const second = await mount({ sourceId: 'second-source' });
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
second.unmount();
await operation('third-during-reload', {}, 'project', 'source');
const third = await mount();
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
third.unmount();
await mount();
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
});
it('keeps round windows independent for projects/accounts and newly delivered teachers', async () => {
api.catalog.mockResolvedValue({ items: agents });
const view = await mount();
await operation('one');
await operation('two');
await advance(1);
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('other-one', {}, 'other');
await act(async () => view.rerender(<Harness />));
await operation('three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('other-two', {}, 'other');
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('other-three', {}, 'other');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await advance(1);
identity.id = 'new-kid';
await act(async () => view.rerender(<Harness />));
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3);
identity.id = 'kid';
await act(async () => view.rerender(<Harness />));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
const added = { ...agents[0], teacher_id: 'new', definition: { ...agents[0].definition, config_id: 'new', name: '新老师' } };
api.catalog.mockResolvedValue({ items: [...agents, added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(1);
await operation('new-one');
await operation('new-two');
expect(screen.getByRole('button', { name: '收起新老师的介绍' })).toBeVisible();
await operation('new-three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
});
it('does not count teacher turns and still collapses the other teachers while consultation is open', async () => {
api.catalog.mockResolvedValue({ items: agents });
const topic = { ...checkInTopic(), definition: agents[0].definition, requests: [],
conversation: { agentId: agents[0].teacher_id, segmentTurns: 0, discussions: {} } };
api.sendConversation.mockResolvedValue(topic);
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '和方法老师聊聊' })));
for (let i = 0; i < 3; i++) {
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: `老师问题${i}` } });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
}
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(2);
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
await operation('one'); await operation('two'); await operation('three');
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
expect(api.sendConversation).toHaveBeenCalledTimes(3);
});
it('introduces every delivered teacher independently of the old shared welcome dismissal without sending requests', async () => {
localStorage.setItem('makelore-teacher-presence:kid:welcome-dismissed', 'true');
api.catalog.mockResolvedValue({ items: agents });
await mount();
const group = screen.getByTestId('teacher-welcomes');
expect(within(group).queryByText('来认识一下')).not.toBeInTheDocument();
expect(within(group).queryByRole('button', { name: '先自己试试' })).not.toBeInTheDocument();
for (const agent of agents) expect(within(group).getByText(agent.definition.description)).toBeVisible();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起体验老师的介绍' })));
expect(within(group).queryByText('体验老师的自我介绍')).not.toBeInTheDocument();
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
expect(within(group).getByText('方法老师的自我介绍')).toBeVisible();
expect(within(group).getByText('故事老师的自我介绍')).toBeVisible();
await advance();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.sendConversation).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
expect(api.seenConversation).not.toHaveBeenCalled();
});
it('keeps dismissal across reloads for this account/project while leaving other projects and accounts independent', async () => {
api.catalog.mockResolvedValue({ items: agents });
const first = await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起方法老师的介绍' })));
first.unmount();
const next = await mount();
expect(screen.queryByRole('button', { name: '收起方法老师的介绍' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '收起体验老师的介绍' })).toBeVisible();
await act(async () => next.rerender(<Harness projectId="other" />));
expect(screen.getByRole('button', { name: '收起方法老师的介绍' })).toBeVisible();
for (const agent of agents) await act(async () => fireEvent.click(screen.getByRole('button', { name: `收起${agent.definition.name}的介绍` })));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await act(async () => next.rerender(<Harness />));
expect(screen.queryByRole('button', { name: '收起方法老师的介绍' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '收起体验老师的介绍' })).toBeVisible();
identity.id = 'another-kid';
await act(async () => next.rerender(<Harness />));
expect(screen.getByRole('button', { name: '收起方法老师的介绍' })).toBeVisible();
});
it('opens the clicked teacher and consumes only their introduction, preserving the others for later', async () => {
api.catalog.mockResolvedValue({ items: agents });
await mount();
await act(async () => fireEvent.click(within(screen.getByTestId('teacher-welcomes')).getByText('故事老师的自我介绍')));
expect(screen.getByRole('button', { name: '与故事老师聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('teacher-chat-panel')).toHaveTextContent('故事老师的聊天欢迎语');
expect(screen.getByTestId('teacher-chat-panel')).not.toHaveTextContent('故事老师的自我介绍');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与故事老师聊天' })));
expect(screen.queryByRole('button', { name: '收起故事老师的介绍' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '收起方法老师的介绍' })).toBeVisible();
expect(screen.getByRole('button', { name: '收起体验老师的介绍' })).toBeVisible();
expect(api.create).not.toHaveBeenCalled();
expect(api.sendConversation).not.toHaveBeenCalled();
});
it('introduces only newly delivered teachers after individually dismissing every introduction, without resetting seen replies or existing receipts on refresh', async () => {
localStorage.setItem('makelore-teacher-presence:kid:seen', JSON.stringify(['keep-seen']));
api.catalog.mockResolvedValue({ items: agents });
await mount();
for (const agent of agents) await act(async () => fireEvent.click(screen.getByRole('button', { name: `收起${agent.definition.name}的介绍` })));
const added = { ...agents[0], teacher_id: 'new', definition: { ...agents[0].definition, config_id: 'new', name: '新老师', description: '', welcome_message: '新老师的云端欢迎语' } };
api.catalog.mockResolvedValue({ items: [...agents.map(agent => ({ ...agent, version: 2 })), added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByTestId('teacher-welcomes')).toHaveTextContent('新老师的云端欢迎语');
for (const agent of agents) expect(screen.queryByRole('button', { name: `收起${agent.definition.name}的介绍` })).not.toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('makelore-teacher-presence:kid:seen')!)).toEqual(['keep-seen']);
expect(api.sendConversation).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
});
describe('manual agent refresh', () => {
it('refreshes while busy and composing, shares one request, and sends through the same agent after publication updates', async () => {
const original = { teacher_id: 'refresh-first-method', version: 3, is_default: true, definition: { ...definition, config_id: 'refresh-first-method', name: '方法老师', welcome_message: '一起想想办法' } };