feat: 在顶栏并排展示下发智能体并直接切换
This commit is contained in:
@@ -642,6 +642,22 @@ it('comparison focuses and directly sends the selected difference while preservi
|
||||
|
||||
|
||||
describe('merged cloud teacher classroom', () => {
|
||||
it('keeps an old unassigned project draft readable without copying it to the selected agent', async () => {
|
||||
const published = { ...definition, config_id: 'one' };
|
||||
const original = JSON.stringify({ text: '旧项目草稿', references: [{ kind: 'code', text: '旧引用' }] });
|
||||
const key = 'makelore-consultation-draft:signed-out:p:teacher';
|
||||
localStorage.setItem(key, original);
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={{ teacher_id: 'one', version: 1, definition: published, is_default: true }} />);
|
||||
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
|
||||
fireEvent.click(screen.getByText('查看之前的项目草稿'));
|
||||
expect(screen.getByText('旧项目草稿')).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '新的独立草稿' } });
|
||||
expect(localStorage.getItem(key)).toBe(original);
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects a cloud teacher for the first project discussion without sending the operation draft', async () => {
|
||||
const cloudDefinition = { ...definition, runtime: 'yuxi' as const, config_id: 'algorithm', name: '算法智能体', yuxi: { agent_slug: 'algorithm', agent_version: 2 } };
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
@@ -652,10 +668,10 @@ describe('merged cloud teacher classroom', () => {
|
||||
const selected = { ...topic('selected'), sourceConversationId: 'project', role: 'teacher' as const, definition: cloudDefinition, version: 9 };
|
||||
api.create.mockResolvedValue(selected);
|
||||
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...selected, revision: 2, requests: [request({ id: input.requestId, text: input.text, response: '先看看玩家会做什么。' })] }));
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await waitFor(() => expect(screen.getByLabelText('新话题使用的智能体')).toBeEnabled());
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={{ teacher_id: 'algorithm', version: 9, definition: cloudDefinition, is_default: false }} />);
|
||||
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
|
||||
expect(screen.queryByLabelText('新话题使用的智能体')).not.toBeInTheDocument();
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText('新话题使用的智能体'), { target: { value: '9' } });
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我想做一个小游戏' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
await screen.findByText('先看看玩家会做什么。');
|
||||
|
||||
@@ -1405,6 +1405,8 @@ describe('project consultations with selected cloud teachers', () => {
|
||||
const topic = await f.service.create(scope, undefined, undefined, runtime === 'yuxi' ? 9 : undefined);
|
||||
expect(topic.role).toBeUndefined();
|
||||
expect(topic.definition.teacher_id).toBe('coding-teacher');
|
||||
expect((await f.service.list(scope)).items.find((item) => item.id === topic.id)?.teacherId)
|
||||
.toBe(runtime === 'yuxi' ? 'cloud-teacher' : undefined);
|
||||
await f.service.send(scope, topic.id, { requestId: crypto.randomUUID(), text: '你觉得怎么样?', sourceConversationId: f.scope.sourceId });
|
||||
const messages = f.run.mock.calls[0][0];
|
||||
expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion';
|
||||
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
|
||||
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
|
||||
|
||||
const api = vi.hoisted(() => ({ 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('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid' } }) }));
|
||||
const definition: TeacherDefinition = {
|
||||
@@ -20,8 +20,8 @@ function Harness({ projectId = 'project', sourceId = 'source', busy = false, arc
|
||||
const [open, setOpen] = useState(false);
|
||||
const [teacherComposing, setTeacherComposing] = useState(false);
|
||||
const companion = useTeacherCompanion({ projectId, sourceId, sourceBusy: busy, sourceArchived: archived, teacherOpen: open, teacherComposing });
|
||||
return <><TeacherCompanion companion={companion} open={open} onOpen={() => setOpen(true)} />
|
||||
{open && <TeacherChatPanel key={projectId} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
|
||||
return <><TeacherCompanion companion={companion} open={open} onOpen={(id) => { 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} onClose={() => setOpen(false)} />}</>;
|
||||
}
|
||||
async function mount(props: Parameters<typeof Harness>[0] = {}) {
|
||||
let view!: ReturnType<typeof render>;
|
||||
@@ -35,6 +35,7 @@ beforeEach(() => {
|
||||
vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear();
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
api.config.mockResolvedValue({ enabled: true, definition });
|
||||
api.catalog.mockResolvedValue({ items: [] });
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
api.events.mockImplementation(async () => Object.assign(new EventTarget(), { close: vi.fn() }));
|
||||
api.checkIn.mockImplementation(async (projectId: string) => ({ topic: checkInTopic(projectId) }));
|
||||
@@ -42,6 +43,76 @@ beforeEach(() => {
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
describe('teacher presence and periodic guidance', () => {
|
||||
it('shows every distributed agent in the header and preserves separate drafts without dispatching on switch', async () => {
|
||||
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
|
||||
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友', avatar_id: 'avatar-09' } };
|
||||
api.catalog.mockResolvedValue({ items: [a, b] });
|
||||
api.create.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
|
||||
api.send.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
|
||||
await mount();
|
||||
expect(screen.getByRole('button', { name: '与方法老师聊天' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
expect(screen.queryByLabelText('新话题使用的智能体')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '体验的草稿' } });
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '方法的草稿' } });
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('体验的草稿');
|
||||
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
await advance();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||
expect(api.create).toHaveBeenCalledWith('project', undefined, undefined, 9);
|
||||
expect(api.send.mock.calls[0][2].text).toBe('体验的草稿');
|
||||
});
|
||||
|
||||
it('restores the selected agent history at its pinned version instead of the globally last topic', async () => {
|
||||
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
|
||||
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
||||
const oldA = { ...checkInTopic(), id: 'a-topic', version: 1, definition: a.definition, requests: [] };
|
||||
const oldB = { ...checkInTopic(), id: 'b-topic', version: 8, definition: b.definition, requests: [] };
|
||||
api.catalog.mockResolvedValue({ items: [a, b] });
|
||||
api.list.mockResolvedValue({ items: [
|
||||
{ id: oldB.id, teacherId: b.teacher_id, title: '体验历史' },
|
||||
{ id: oldA.id, teacherId: a.teacher_id, title: '方法历史' },
|
||||
], lastSelectedTopicId: oldB.id });
|
||||
api.read.mockImplementation(async (_base, id) => id === oldA.id ? oldA : oldB);
|
||||
api.send.mockResolvedValue({ ...oldA, revision: 3 });
|
||||
await mount();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
|
||||
expect(api.read).toHaveBeenCalledWith('project', oldA.id);
|
||||
expect(screen.getByRole('heading', { name: '方法老师' })).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '继续原来的讨论' } });
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||
expect(api.send.mock.calls[0][1]).toBe(oldA.id);
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
||||
expect(api.read).toHaveBeenCalledWith('project', oldB.id);
|
||||
});
|
||||
|
||||
it('ignores a late initial history read after the student selects a different agent', async () => {
|
||||
const a = { teacher_id: 'a', version: 1, is_default: true, definition: { ...definition, config_id: 'a', name: '方法老师' } };
|
||||
const b = { teacher_id: 'b', version: 2, is_default: false, definition: { ...definition, config_id: 'b', name: '体验朋友' } };
|
||||
const old = { ...checkInTopic(), definition: a.definition };
|
||||
api.catalog.mockResolvedValue({ items: [a, b] });
|
||||
api.list.mockResolvedValue({ items: [{ id: old.id, teacherId: 'a' }], lastSelectedTopicId: old.id });
|
||||
let resolve!: (topic: TeacherTopic) => void;
|
||||
api.read.mockReturnValue(new Promise<TeacherTopic>((done) => { resolve = done; }));
|
||||
await mount();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
await act(async () => resolve(old));
|
||||
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(api.read).toHaveBeenCalledWith('project', old.id, false);
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the selected agent greeting instead of borrowing the default agent greeting', async () => {
|
||||
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '默认智能体欢迎语' } });
|
||||
const selected = { ...checkInTopic(), requests: [], definition: { ...definition, name: '体验朋友', avatar_id: 'avatar-09', welcome_message: '我们聊聊你的体验' } };
|
||||
|
||||
Reference in New Issue
Block a user