refactor(teacher): retire discussion components and keep text replies

This commit is contained in:
鲨鱼辣椒
2026-09-28 13:54:55 +08:00
parent ae0f72d8fa
commit 998796d77c
32 changed files with 1060 additions and 1794 deletions

View File

@@ -10,7 +10,6 @@ const api = vi.hoisted(() => ({
create: vi.fn(),
read: vi.fn(),
send: vi.fn(),
updateDiscussion: vi.fn(),
cancel: vi.fn(),
save: vi.fn(),
events: vi.fn(),
@@ -320,6 +319,33 @@ describe('teacher side chat', () => {
expect(api.send).not.toHaveBeenCalled();
});
it.each(['question', 'suggestions'] as const)('keeps duplicate %s quick cards distinct and clickable without replacing the draft', async intent => {
const question = '我可以先试哪一步?';
const previous = request({ intent, response: '先从你想尝试的地方聊起。', suggestedQuestions: [question, question] });
api.read.mockResolvedValue({ ...first, requests: [previous] });
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 2, requests: [previous, request({ id: input.requestId, text: input.text, response: '我们就聊这一点。' })],
}));
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
render(<TeacherChatPanel projectId="p" sourceId="c" />);
const cards = await screen.findAllByRole('button', { name: question, exact: true });
expect(cards).toHaveLength(2);
expect(cards[0]).toBeEnabled();
expect(cards[1]).toBeEnabled();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '还没说完的想法' } });
fireEvent.click(cards[1]);
await screen.findByText('我们就聊这一点。');
expect(api.send).toHaveBeenCalledTimes(1);
expect(api.send.mock.calls[0][2]).toEqual({ requestId: expect.any(String), text: question, references: [], sourceConversationId: 'c' });
expect(screen.getByLabelText('向智能体提问')).toHaveValue('还没说完的想法');
expect(screen.getAllByRole('button', { name: question, exact: true })).toHaveLength(2);
expect(errors.mock.calls.filter(args => /same key|unique.*key/i.test(args.join(' ')))).toEqual([]);
} finally {
errors.mockRestore();
}
});
it('requests contextual suggestions explicitly, hides streaming JSON and preserves the freeform draft', async () => {
const streamingJson = '{"intro":"还没输出完","questions":["尚未确认的问题';
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
@@ -564,105 +590,85 @@ describe('teacher side chat', () => {
});
describe('pinned discussion workspace', () => {
const ideas = { id: 'tool', revision: 1, status: 'active' as const, content: {
kind: 'ideas' as const, title: '一起想我的宠物游戏', items: [{ id: 'dog', text: '养只小狗', state: 'kept' as const }],
} };
async function openDiscussion(status: 'active' | 'offered' = 'active') {
const current = { ...structuredClone(first), discussion: { ...structuredClone(ideas), status } };
describe('legacy discussion history compatibility', () => {
const contents = [
{ kind: 'ideas', title: '旧想法组件', items: [{ id: 'dog', text: '养只小狗', state: 'kept' }] },
{ kind: 'structure', title: '旧结构组件', nodes: [{ id: 'dog', label: '照顾小狗' }] },
{ kind: 'flow', title: '旧流程组件', nodes: [{ id: 'home', kind: 'event', label: '我回到家' }], edges: [] },
{ kind: 'comparison', title: '旧对比组件', columns: [{ id: 'a', label: '主动回应' }], rows: [{ id: 'home', label: '回到家时', cells: [{ columnId: 'a', text: '跑来欢迎我' }] }] },
];
const histories = contents.flatMap(content => ['active', 'offered', 'paused', 'finished'].map(status => ({ kind: content.kind, status, content })));
it.each(histories)('reads $kind / $status history as ordinary replies with working quick cards and free input', async ({ status, content }) => {
const current = { ...structuredClone(first), discussion: { id: 'legacy-tool', revision: 3, status, content }, requests: [request({
presentation: 'discussion-v1', discussionSnapshot: content, discussionContext: { toolId: 'legacy-tool', revision: 3 },
response: '先听听你的想法。', suggestedQuestions: ['怎样知道小狗喜欢我?'],
})] } as TeacherTopic;
api.read.mockResolvedValue(current);
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...current, revision: current.revision + 1, requests: [...current.requests, request({ id: input.requestId, text: input.text, response: '我们就从这个问题聊起。' })] }));
render(<TeacherChatPanel projectId="p" sourceId="c" />);
const card = await screen.findByRole('button', { name: '怎样知道小狗喜欢我?' });
expect(screen.getByText('先听听你的想法。')).toBeVisible();
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
expect(screen.queryByText(content.title)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /用这个一起想|先这些|接着改|把想法理一理|聊聊这一点|取消讨论焦点/ })).not.toBeInTheDocument();
const input = screen.getByLabelText('向智能体提问');
fireEvent.change(input, { target: { value: '自己的草稿' } });
fireEvent.click(card);
await screen.findByText('我们就从这个问题聊起。');
expect(api.send.mock.calls[0][2]).toEqual({ requestId: expect.any(String), text: '怎样知道小狗喜欢我?', references: [], sourceConversationId: 'c' });
expect(input).toHaveValue('自己的草稿');
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await waitFor(() => expect(api.send).toHaveBeenCalledTimes(2));
expect(api.send.mock.calls[1][2]).toEqual({ requestId: expect.any(String), text: '自己的草稿', references: [], sourceConversationId: 'c' });
await waitFor(() => expect(input).toHaveValue(''));
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
});
it('keeps live text and cancellation working when snapshots still contain legacy component fields', async () => {
const current = { ...structuredClone(first), discussion: { id: 'tool', revision: 1, status: 'active', content: contents[0] } } as TeacherTopic;
api.read.mockResolvedValue(current);
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await screen.findByText('一起想我的宠物游戏');
await waitFor(() => expect(streams.has('first')).toBe(true));
return current;
}
it('quotes a focused item into the existing composer without sending, then sends the locked version', async () => {
const current = await openDiscussion();
api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 2, requests: [request({ text: input.text, response: '先聊聊小狗。' })] }));
fireEvent.click(screen.getByRole('button', { name: '养只小狗', exact: true }));
expect(api.send).not.toHaveBeenCalled();
expect(screen.getByText('接着聊:养只小狗')).toBeVisible();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我希望是柯基' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await waitFor(() => expect(api.send).toHaveBeenCalledWith('p/agent-topics', 'first', expect.objectContaining({ presentation: 'discussion-v1', discussion: { toolId: 'tool', revision: 1, focusId: 'dog' }, text: '我希望是柯基' })));
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
});
it('finishes provisionally, keeps the draft, chats without changing the tool, and resumes explicitly', async () => {
const current = await openDiscussion();
api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 2, discussion: { ...ideas, status: 'finished', revision: 2 } });
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '还在想的下一句' } });
fireEvent.click(screen.getByRole('button', { name: '先这些', exact: true }));
await screen.findByText('先留在这里');
expect(screen.getByLabelText('向智能体提问')).toHaveValue('还在想的下一句');
expect(screen.getByTestId('teacher-discussion')).toHaveAttribute('data-collapsed', 'true');
api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 3, discussion: { ...ideas, status: 'finished', revision: 2 }, requests: [request({ text: input.text, response: '我们慢慢聊。' })] }));
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('我们慢慢聊。');
expect(api.send.mock.calls[0][2].discussion).toBeUndefined();
api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 4, discussion: { ...ideas, revision: 3 } });
fireEvent.click(screen.getByRole('button', { name: '接着改', exact: true }));
await screen.findByRole('button', { name: '养只小狗', exact: true });
expect(api.updateDiscussion.mock.calls[1][2]).toEqual({ toolId: 'tool', revision: 2, action: 'resume' });
});
it('offers a tool before activating, and preserves a draft during explicit structure conversion', async () => {
const current = await openDiscussion('offered');
api.updateDiscussion.mockResolvedValue({ ...current, revision: 2, discussion: { ...ideas, revision: 2 } });
expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '用这个一起想', exact: true }));
await screen.findByRole('button', { name: '把想法理一理', exact: true });
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '不要丢掉的草稿' } });
api.send.mockResolvedValue({ ...current, revision: 3, discussion: { ...ideas, revision: 2 }, requests: [] });
fireEvent.click(screen.getByRole('button', { name: '把想法理一理', exact: true }));
await waitFor(() => expect(api.send).toHaveBeenCalled());
expect(api.send.mock.calls[0][2].discussion).toEqual({ toolId: 'tool', revision: 2, transition: 'structure' });
expect(screen.getByLabelText('向智能体提问')).toHaveValue('不要丢掉的草稿');
});
it('uses the same pinned component for SSE updates and leaves it untouched while thinking or stopping', async () => {
const current = await openDiscussion();
await ready();
const stream = streams.get('first')!;
const updating = { ...current, revision: 2, requests: [request({ status: 'running', response: '', discussionContext: { toolId: 'tool', revision: 1 } })] };
act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(updating) })));
expect(screen.getByRole('status')).toHaveTextContent('正在梳理');
expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeVisible();
expect(screen.getByRole('status')).toHaveTextContent('智能体正在想');
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '下一轮草稿' } });
api.cancel.mockResolvedValue({ ...updating, revision: 3, requests: [request({ status: 'cancelled' })] });
fireEvent.click(screen.getByRole('button', { name: '停止回复' }));
await screen.findByText('这次先停在这里。想继续时可以再说说。');
expect(screen.getByLabelText('向智能体提问')).toHaveValue('下一轮草稿');
act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...current, revision: 4, discussion: { ...ideas, revision: 2, content: { ...ideas.content, title: '一起想小狗和花园' } } }) })));
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
expect(screen.getByText('一起想小狗和花园')).toBeVisible();
});
it('sends a normal suggested question directly without changing the composer draft', async () => {
const current = { ...first, requests: [request({ response: '先听听你的想法。', suggestedQuestions: ['怎样知道小狗喜欢我?'] })] };
api.read.mockResolvedValue(current);
api.send.mockResolvedValue({ ...current, revision: 2 });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await screen.findByRole('button', { name: '怎样知道小狗喜欢我?' });
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '自己的草稿' } });
fireEvent.click(screen.getByRole('button', { name: '怎样知道小狗喜欢我?' }));
await waitFor(() => expect(api.send).toHaveBeenCalled());
expect(api.send.mock.calls[0][2].text).toBe('怎样知道小狗喜欢我?');
expect(screen.getByLabelText('向智能体提问')).toHaveValue('自己的草稿');
act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...current, revision: 4, discussion: { unknownLegacyShape: true }, requests: [request({ response: '旧历史继续显示普通回复。', suggestedQuestions: ['接着问一个问题'] })] }) })));
expect(screen.getByText('旧历史继续显示普通回复。')).toBeVisible();
expect(screen.getByRole('button', { name: '接着问一个问题' })).toBeEnabled();
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
});
});
it('comparison focuses and directly sends the selected difference while preserving the existing draft', async () => {
const current = { ...first, discussion: { id: 'compare', revision: 3, status: 'active' as const, content: {
kind: 'comparison' as const, title: '它什么时候回应我?', columns: [{ id: 'a', label: '点击才回应' }, { id: 'b', label: '主动回应' }], rows: [{ id: 'home', label: '回到家时', cells: [{ columnId: 'a', text: '等我点击' }, { columnId: 'b', text: '跑来欢迎我' }] }],
} } };
api.read.mockResolvedValue(current);
api.send.mockResolvedValue({ ...current, revision: 2 });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await screen.findByText('回到家时');
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '自己还没说完的话' } });
fireEvent.click(screen.getAllByRole('button', { name: '聊聊这一点' })[1]);
await waitFor(() => expect(api.send).toHaveBeenCalled());
expect(api.send.mock.calls[0][2]).toMatchObject({ text: '聊聊「回到家时」时,「主动回应」这一点:跑来欢迎我', discussion: { toolId: 'compare', revision: 3, focusId: 'home:b' } });
expect(screen.getByLabelText('向智能体提问')).toHaveValue('自己还没说完的话');
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
it('removes retired fields from a persisted pending send without changing its draft, references or retry id', async () => {
const key = 'makelore-consultation-draft:signed-out:p:teacher';
const references = [{ kind: 'code', text: 'const pet = "dog";' }];
const original = { requestId: 'pending-before-component-removal', text: '继续聊这件事', references, intent: 'question', sourceConversationId: 'previous-source' };
localStorage.setItem(key, JSON.stringify({ text: original.text, references, pending: { ...original, presentation: 'discussion-v1', discussion: { toolId: 'tool', revision: 2, focusId: 'dog', transition: 'structure' } } }));
api.send.mockRejectedValueOnce(new Error('网络暂不可用'));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByRole('button', { name: '提问', exact: true })).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue(original.text);
expect(screen.getByText(references[0].text)).toBeVisible();
expect(JSON.parse(localStorage.getItem(key)!).pending).toEqual(original);
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('网络暂不可用');
expect(api.send.mock.calls[0][2]).toEqual(original);
view.unmount();
api.send.mockResolvedValue({ ...first, revision: 2, requests: [request({ id: original.requestId, text: original.text, response: '可以,我们继续。' })] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByRole('button', { name: '提问', exact: true })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('可以,我们继续。');
expect(api.send.mock.calls[1][2]).toEqual(original);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
expect(screen.queryByText(references[0].text)).not.toBeInTheDocument();
});
});
@@ -703,7 +709,7 @@ describe('merged cloud teacher classroom', () => {
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('先看看玩家会做什么。');
expect(api.create).toHaveBeenCalledWith('p/agent-topics', undefined, undefined, 9);
expect(api.send.mock.calls[0][2]).toMatchObject({ sourceConversationId: 'c', presentation: 'discussion-v1' });
expect(api.send.mock.calls[0][2]).toEqual({ requestId: expect.any(String), text: '我想做一个小游戏', references: [], sourceConversationId: 'c' });
expect(screen.getByRole('complementary', { name: '算法智能体咨询' })).toBeVisible();
});