feat: organize project conversations and add coding teacher side chat
This commit is contained in:
1 parent
2f82b9f79c
commit
9242a40d87
45 files changed
+2987
-1137
No files matched your search
@@ -0,0 +1,145 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
|
||||
import type { TeacherDefinition, TeacherTopic } from '../../shared/coding-teacher';
|
||||
const api = vi.hoisted(() => ({
|
||||
config: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
read: vi.fn(),
|
||||
send: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
save: vi.fn(),
|
||||
events: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/coding-teacher', () => ({
|
||||
teacherApi: api,
|
||||
teacherTopicsPath: (p: string, s: string) => p + '/' + s,
|
||||
}));
|
||||
const definition: TeacherDefinition = {
|
||||
schema_version: 1,
|
||||
teacher_id: 'coding-teacher',
|
||||
name: '老师',
|
||||
description: '',
|
||||
avatar_id: 'avatar-01',
|
||||
welcome_message: '欢迎',
|
||||
suggested_questions: [],
|
||||
system_prompt: '教学',
|
||||
skills: [],
|
||||
model: { model_id: 'qwen', reasoning_choice: { mode: 'default' } },
|
||||
limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
|
||||
};
|
||||
function topic(id: string): TeacherTopic {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
id,
|
||||
accountId: 'user',
|
||||
projectId: 'p',
|
||||
sourceConversationId: 'c',
|
||||
definition,
|
||||
version: 1,
|
||||
createdAt: '2026-09-22',
|
||||
updatedAt: '2026-09-22',
|
||||
requests: [],
|
||||
};
|
||||
}
|
||||
const first = topic('first'),
|
||||
second = topic('second');
|
||||
let streams: Map<string, EventTarget & { close: ReturnType<typeof vi.fn> }>;
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
streams = new Map();
|
||||
api.config.mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition });
|
||||
api.list.mockResolvedValue({
|
||||
items: [
|
||||
{ id: 'first', title: '第一个' },
|
||||
{ id: 'second', title: '第二个' },
|
||||
],
|
||||
lastSelectedTopicId: 'first',
|
||||
});
|
||||
api.read.mockImplementation(async (_base, id) =>
|
||||
id === 'first' ? structuredClone(first) : structuredClone(second)
|
||||
);
|
||||
api.create.mockResolvedValue(first);
|
||||
api.events.mockImplementation(async (_base, id) => {
|
||||
const stream = Object.assign(new EventTarget(), { close: vi.fn() });
|
||||
streams.set(id, stream);
|
||||
return stream;
|
||||
});
|
||||
});
|
||||
async function ready() {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('first')
|
||||
);
|
||||
await waitFor(() => expect(streams.has('first')).toBe(true));
|
||||
}
|
||||
describe('teacher side chat', () => {
|
||||
it('retains failed input and retries with the same request id, then appends a reply only on click', async () => {
|
||||
const bringBack = vi.fn();
|
||||
api.send
|
||||
.mockRejectedValueOnce(new Error('网络暂不可用'))
|
||||
.mockImplementationOnce(async (_base, _id, input) => ({
|
||||
...first,
|
||||
revision: 3,
|
||||
requests: [
|
||||
{
|
||||
id: input.requestId,
|
||||
text: input.text,
|
||||
references: [],
|
||||
createdAt: 'now',
|
||||
sourceCursor: { workerGeneration: 1, seq: 1 },
|
||||
sourceCapturedAt: 'now',
|
||||
includedSourceMessageIds: [],
|
||||
omittedMessages: 0,
|
||||
status: 'completed',
|
||||
response: '教学回答',
|
||||
},
|
||||
],
|
||||
}));
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" onBringBack={bringBack} />);
|
||||
await ready();
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '解释代码' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
await screen.findByText('网络暂不可用');
|
||||
expect(screen.getByLabelText('向老师提问')).toHaveValue('解释代码');
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
await screen.findByText('教学回答');
|
||||
expect(api.send.mock.calls[0][2].requestId).toBe(api.send.mock.calls[1][2].requestId);
|
||||
expect(bringBack).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '带回主会话草稿' }));
|
||||
expect(bringBack).toHaveBeenCalledWith('教学回答');
|
||||
});
|
||||
it('ignores an old topic stream after switching and does not cancel when closed', async () => {
|
||||
const closed = vi.fn();
|
||||
const view = render(<TeacherChatPanel projectId="p" sourceId="c" onClose={closed} />);
|
||||
await ready();
|
||||
const old = streams.get('first')!;
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '老师话题' }), {
|
||||
target: { value: 'second' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('second')
|
||||
);
|
||||
act(() =>
|
||||
old.dispatchEvent(
|
||||
new MessageEvent('snapshot', { data: JSON.stringify({ ...first, revision: 999 }) })
|
||||
)
|
||||
);
|
||||
expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('second');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭老师' }));
|
||||
expect(closed).toHaveBeenCalledOnce();
|
||||
view.unmount();
|
||||
expect(api.cancel).not.toHaveBeenCalled();
|
||||
expect(old.close).toHaveBeenCalled();
|
||||
});
|
||||
it('shows disabled history while blocking a new question', async () => {
|
||||
api.config.mockResolvedValue({ enabled: false, definition });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await ready();
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '问题' } });
|
||||
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
|
||||
expect(api.read).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user