Merge latest main and preserve teacher upgrade safeguards
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
鲨鱼辣椒
2026-09-28 18:42:04 +08:00
28 changed files with 1435 additions and 93 deletions

View File

@@ -162,6 +162,145 @@ describe('teacher side chat', () => {
expect(details?.querySelector('script')).toBeNull();
});
it('marks an incomplete completed reply instead of displaying a half sentence as a finished answer', async () => {
const raw = '{"reply":"它早就不是"刚搭好架子"那种阶段了——还有后面的回答';
const bringBack = vi.fn();
api.read.mockResolvedValue({ ...first, requests: [request({
text: '现在项目是什么阶段?', response: '它早就不是', replyIncomplete: true,
replyParseError: 'format error', unparsedResponse: raw, suggestedQuestions: ['尚未确认的建议'],
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" onBringBack={bringBack} />);
await ready();
expect(screen.getByRole('status')).toHaveTextContent('这次回复未能完整显示');
expect(screen.queryByTestId('teacher-reply')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '尚未确认的建议' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '带回主会话草稿' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '重新回答' })).toBeEnabled();
const details = screen.getByText('查看收到的原始内容').closest('details');
expect(details).not.toHaveAttribute('open');
expect(details?.querySelector('pre')?.textContent).toBe(raw);
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(bringBack).not.toHaveBeenCalled();
});
it('keeps complete Markdown visible when only auxiliary fields could not be recovered', async () => {
api.read.mockResolvedValue({ ...first, requests: [request({
response: '**它早就不是"刚搭好架子"那种阶段了。** 后面的回答也已经收到。',
replyParseError: 'invalid quickReplies', unparsedResponse: '{"reply":"完整回答","quickReplies":',
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('它早就不是"刚搭好架子"那种阶段了。 后面的回答也已经收到。');
expect(screen.getByText('回答正文已保留,部分附加内容未能显示。')).toBeVisible();
expect(screen.queryByText('这次回复未能完整显示')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '重新回答' })).not.toBeInTheDocument();
expect(api.send).not.toHaveBeenCalled();
});
it.each(['question', 'suggestions', 'guided-help'] as const)('retries an incomplete %s only on click with its original input while preserving the current draft', async intent => {
const originalReferences = [{ messageId: 'original-message', text: '原来的引用', role: 'assistant' as const }];
const currentQuote = { messageId: 'current-message', text: '草稿里的新引用', role: 'assistant' as const };
const incomplete = request({
intent, text: '现在项目是什么阶段?', sourceConversationId: 'original-source', references: originalReferences,
response: '它早就不是', replyIncomplete: true,
});
const before = { ...first, requests: [incomplete] };
api.read.mockResolvedValue(before);
let finish!: (topic: TeacherTopic) => void;
api.send.mockImplementation(() => new Promise<TeacherTopic>(resolve => { finish = resolve; }));
render(<TeacherChatPanel projectId="p" sourceId="different-source" quote={currentQuote} />);
await ready();
const input = screen.getByLabelText('向智能体提问');
fireEvent.change(input, { target: { value: '还没说完的新想法' } });
expect(api.send).not.toHaveBeenCalled();
const retry = screen.getByRole('button', { name: '重新回答' });
fireEvent.click(retry);
fireEvent.click(retry);
expect(api.send).toHaveBeenCalledTimes(1);
const sent = api.send.mock.calls[0][2] as TeacherSend;
expect(sent).toEqual({ requestId: expect.any(String), text: incomplete.text, intent, references: originalReferences, sourceConversationId: 'original-source' });
expect(sent.requestId).not.toBe(incomplete.id);
expect(retry).toBeDisabled();
fireEvent.change(input, { target: { value: '等待时又补充了想法' } });
await act(async () => finish({ ...before, revision: 2, requests: [...before.requests, request({ id: sent.requestId, text: sent.text, response: '这次的完整回答。' })] }));
expect(screen.getByText('这次的完整回答。')).toBeVisible();
expect(input).toHaveValue('等待时又补充了想法');
expect(screen.getByText('草稿里的新引用')).toBeVisible();
expect(JSON.parse(localStorage.getItem('makelore-consultation-draft:signed-out:p:teacher')!)).toMatchObject({ text: '等待时又补充了想法', references: [currentQuote] });
});
it('keeps the retry id and draft after an uncertain send failure and uses the original topic source for older replies', async () => {
const incomplete = request({ text: '原来的问题', response: '', replyIncomplete: true });
const before = { ...first, sourceConversationId: 'older-source', requests: [incomplete] };
api.read.mockResolvedValue(before);
api.send.mockRejectedValueOnce(new Error('暂时没连上')).mockImplementation(async (_base, _id, sent: TeacherSend) => ({
...before, revision: 2, requests: [incomplete, request({ id: sent.requestId, text: sent.text, response: '完整回答' })],
}));
render(<TeacherChatPanel projectId="p" sourceId="new-source" />);
await ready();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '草稿不能丢' } });
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
await screen.findByRole('alert');
expect(screen.getByLabelText('向智能体提问')).toHaveValue('草稿不能丢');
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
await screen.findByText('完整回答');
expect(api.send).toHaveBeenCalledTimes(2);
expect(api.send.mock.calls[0][2]).toEqual(api.send.mock.calls[1][2]);
expect(api.send.mock.calls[1][2]).toMatchObject({ text: '原来的问题', sourceConversationId: 'older-source', references: [] });
expect(screen.getByLabelText('向智能体提问')).toHaveValue('草稿不能丢');
});
it('retries a project-only historical question without attaching another conversation or the project sentinel', async () => {
const incomplete = request({ text: '原来的问题', replyIncomplete: true });
const before = { ...first, sourceConversationId: 'project', requests: [incomplete] };
api.read.mockResolvedValue(before);
api.send.mockResolvedValue(before);
render(<TeacherChatPanel projectId="p" sourceId="different-source" />);
await ready();
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
await waitFor(() => expect(api.send).toHaveBeenCalledTimes(1));
expect(api.send.mock.calls[0][2]).not.toHaveProperty('sourceConversationId');
});
it('does not retry an incomplete reply from a disabled Agent or turn a check-in into a normal question', async () => {
const archived = { ...first, definition: { ...definition, config_id: 'unavailable-agent' }, requests: [
request({ response: '', replyIncomplete: true }),
request({ id: 'check-in', intent: 'check-in', text: '', response: '', replyIncomplete: true }),
] };
api.read.mockResolvedValue(archived);
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getAllByText('这次回复未能完整显示')).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '重新回答' })).toHaveLength(1);
expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
expect(api.send).not.toHaveBeenCalled();
});
it('shows the format error alongside the preserved reply while keeping excess shortcuts noninteractive', async () => {
const response = '先看看摸头会带来什么变化。';
const quickReplies = ['看懂规则|摸头会改变什么?', '想想作用|我们聊聊摸头。', '试试结果|怎样看出变化?', '换个话题|我想聊别的地方。'];
const replyParseError = '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。';
const raw = '\n' + JSON.stringify({ reply: response, quickReplies }, null, 2) + '\n';
api.read.mockResolvedValue({ ...first, requests: [suggestionsRequest({
presentation: 'reply-v1', response, suggestedQuestions: [], replyParseError, unparsedResponse: raw,
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByText(response)).toBeVisible();
expect(screen.getByText('回答正文已保留,部分附加内容未能显示。')).toBeVisible();
for (const shortcut of quickReplies) {
expect(screen.queryByRole('button', { name: shortcut, exact: true })).not.toBeInTheDocument();
}
const details = screen.getByText('查看收到的原始内容').closest('details');
expect(details).not.toHaveAttribute('open');
expect(details?.querySelector('pre')?.textContent).toBe(raw);
expect(screen.getByLabelText('向智能体提问')).toBeEnabled();
expect(api.send).not.toHaveBeenCalled();
});
it('renders formulas and preserves code escapes while rejecting executable links and HTML', async () => {
api.read.mockResolvedValue({ ...first, requests: [request({
response: '$E=mc^2$\n\n```js\nconst value = "\\n";\n```\n\n[危险](javascript:alert)\n\n<script>alert(1)</script>\n\n![本地图片](file:///C:/secret.png)\n\n![坏图片](https://example.com/missing.png)',
@@ -656,6 +795,22 @@ describe('teacher side chat', () => {
describe('legacy discussion history compatibility', () => {
it('shows every historical shortcut even when the old reply has more than three', async () => {
const shortcuts = ['看懂规则|摸头会改变什么?', '想想作用|我们聊聊摸头。', '试试结果|怎样看出变化?', '换个话题|我想聊别的地方。'];
api.read.mockResolvedValue({ ...first, requests: [request({
presentation: 'discussion-v1', response: '这些是之前留下的讨论入口。', suggestedQuestions: shortcuts,
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByText('这些是之前留下的讨论入口。')).toBeVisible();
for (const shortcut of shortcuts) {
expect(screen.getByRole('button', { name: shortcut, exact: true })).toBeEnabled();
}
expect(screen.queryByText('查看收到的原始内容')).not.toBeInTheDocument();
expect(api.send).not.toHaveBeenCalled();
});
const contents = [
{ kind: 'ideas', title: '旧想法组件', items: [{ id: 'dog', text: '养只小狗', state: 'kept' }] },
{ kind: 'structure', title: '旧结构组件', nodes: [{ id: 'dog', label: '照顾小狗' }] },
@@ -754,7 +909,7 @@ describe('merged cloud teacher classroom', () => {
} }));
api.catalog.mockResolvedValue({ items: [agent] });
api.sendConversation.mockRejectedValue(new Error('结果暂未确认'));
const props = { projectId: 'p', sourceId: 'current-source', selectedAgent: agent };
const props = { projectId: 'p', sourceId: 'original-source', selectedAgent: agent };
const view = render(<TeacherChatPanel {...props} />);
const hint = () => screen.getByText('继续看看👀', { selector: 'form .consultation-hint' });
await waitFor(() => expect(hint()).toBeEnabled());
@@ -766,6 +921,10 @@ describe('merged cloud teacher classroom', () => {
fireEvent.click(hint());
await screen.findByText('上次提问仍绑定原项目,请切回原项目确认结果后重试。');
expect(api.sendConversation).toHaveBeenCalledTimes(1);
view.rerender(<TeacherChatPanel {...props} sourceId="another-source" />);
fireEvent.click(hint());
await screen.findByText('上次提问仍绑定原操作对话,请切回原操作对话确认结果后重试。');
expect(api.sendConversation).toHaveBeenCalledTimes(1);
view.rerender(<TeacherChatPanel {...props} />);
fireEvent.click(hint());
await screen.findByText('结果暂未确认');