Merge reply recovery and simplified consultation UI into ongoing chats

This commit is contained in:
鲨鱼辣椒 committed 2026-09-28 18:05:39 +08:00
commit d032425521
37 files changed
+2089 -1940

No files matched your search

@@ -1,16 +1,34 @@
// Deterministic responses for layout/interaction tests only; never imported by the app.
import type { TeacherDefinition, TeacherTopic, TeacherSend, TeacherDiscussionAction } from '../../../shared/coding-teacher';
import type { TeacherDiscussionContent } from '../../../shared/teacher-discussion';
import type { TeacherDefinition, TeacherTopic, TeacherSend } from '../../../shared/coding-teacher';
import { parseTeacherReply } from '../../../shared/teacher-reply';
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: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } };
const samples: Record<string, TeacherDiscussionContent> = {
const samples: Record<string, unknown> = {
ideas: { kind: 'ideas', title: '我的小狗世界', items: [{ id: 'dog', text: '养一只自己的小狗', state: 'kept' }, { id: 'breed', parentId: 'dog', text: '是一只柯基', state: 'kept' }, { id: 'garden', text: '有一片可以种东西的花园', state: 'kept' }, { id: 'food', parentId: 'garden', text: '种出来的东西可以喂小狗', state: 'suggested' }] },
structure: { kind: 'structure', title: '小狗和花园怎么连起来?', nodes: [{ id: 'world', label: '我的小狗世界' }, { id: 'dog', label: '照顾小狗', parentId: 'world' }, { id: 'breed', label: '一只柯基', parentId: 'dog', relation: '它的样子' }, { id: 'garden', label: '种植花园', parentId: 'world' }, { id: 'food', label: '收获食物,喂给小狗', parentId: 'garden', relation: '收获后' }] },
flow: { kind: 'flow', title: '我回家时,小狗会做什么?', nodes: [{ id: 'home', kind: 'event', label: '我回到家' }, { id: 'awake', kind: 'condition', label: '小狗醒着吗?' }, { id: 'welcome', kind: 'outcome', label: '跑过来欢迎我' }, { id: 'sleep', kind: 'outcome', label: '先睡觉,醒来再过来' }], edges: [{ id: 'one', from: 'home', to: 'awake' }, { id: 'yes', from: 'awake', to: 'welcome', label: '醒着' }, { id: 'no', from: 'awake', to: 'sleep', label: '睡着了' }] },
comparison: { kind: 'comparison', title: '哪一种更像你想要的小狗?', columns: [{ id: 'click', label: '点了才回应' }, { id: 'active', label: '会主动找我' }], rows: [{ id: 'home', label: '刚回到家的时候', cells: [{ columnId: 'click', text: '等我点一下,才跳起来。' }, { columnId: 'active', text: '看见我就跑过来,还带着玩具。' }] }, { id: 'busy', label: '我忙着种东西的时候', cells: [{ columnId: 'click', text: '安静等着,不打扰我。' }, { columnId: 'active', text: '有时来蹭一蹭,可能也会打断我。' }] }] },
};
const scenario = new URLSearchParams(location.search).get('kind') ?? 'ideas';
let current: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'discussion-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 0, seq: i }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: i === 7 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(scenario === 'ordinary' && i === 7 ? { suggestedQuestions: ['有什么好玩的想法?'] } : {}) })), ...(samples[scenario] ? { discussion: { id: 'tool', revision: 1, status: 'active', content: structuredClone(samples[scenario]), ...(scenario === 'structure' ? { previousIdeas: structuredClone(samples.ideas) as Extract<TeacherDiscussionContent, { kind: 'ideas' }> } : {}) } } : {}) };
let current: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'discussion-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 0, seq: i }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: i === 7 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(i === 7 ? { suggestedQuestions: ['有什么好玩的想法?'] } : {}) })), ...(samples[scenario] ? { discussion: { id: 'tool', revision: 1, status: 'active', content: structuredClone(samples[scenario]) } } : {}) };
const stream = Object.assign(new EventTarget(), { close() {}, onerror: null, onopen: null });
if (scenario === 'recovered' || scenario === 'incomplete') {
const raw = '{"reply":"它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在的阶段是「能玩,正在调手感」。'
+ (scenario === 'recovered' ? '","quickReplies":[],"tool":{"kind":"structure","nodes":[]}}' : '');
const parsed = parseTeacherReply(raw);
current.requests = [{
...current.requests[0], text: '现在项目是什么阶段?', sourceConversationId: 'original-source',
references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }],
response: parsed.reply, replyIncomplete: parsed.incomplete, replyParseError: parsed.parseError,
suggestedQuestions: parsed.quickReplies, unparsedResponse: raw,
}];
}
if (scenario === 'retry') {
const status = new URLSearchParams(location.search).get('status') === 'cancelled' ? 'cancelled' : 'failed';
current.requests = [{
...current.requests[0], text: '给我一点提示', intent: 'suggestions', status, response: '',
...(status === 'failed' ? { error: '这次没连上,我们可以再试一次。' } : {}),
}];
}
if (scenario === 'rich-text') {
current.requests = [{
...current.requests[0], text: '帮我看看项目和最近的讨论',
@@ -20,6 +38,8 @@ if (scenario === 'rich-text') {
}];
}
let timer: ReturnType<typeof setTimeout> | undefined;
const sentInputs: TeacherSend[] = [];
Object.defineProperty(window, '__teacherReplyInputs', { value: sentInputs });
const snapshot = () => { current.revision++; stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })); };
export const teacherApi = {
catalog: async () => ({ items: [{ teacher_id: definition.teacher_id, version: 1, is_default: true, definition }] }),
@@ -28,42 +48,16 @@ export const teacherApi = {
read: async () => structuredClone(current),
create: async () => structuredClone(current),
events: async () => stream,
updateDiscussion: async (_base: string, _id: string, input: TeacherDiscussionAction) => {
const tool = current.discussion!;
if (input.action === 'pause' || input.action === 'finish') tool.status = input.action === 'pause' ? 'paused' : 'finished';
else tool.status = 'active';
if (input.action === 'back-ideas' && tool.previousIdeas) {
if (tool.content.kind === 'structure') tool.previousStructure = structuredClone(tool.content);
tool.content = structuredClone(tool.previousIdeas);
}
if (tool.content.kind === 'ideas') {
const item = tool.content.items.find(item => item.id === input.itemId);
if (item && input.action === 'keep-idea') item.state = 'kept';
if (item && input.action === 'aside-idea') item.state = 'aside';
if (item && input.action === 'first-idea') tool.content.firstItemId = item.id;
}
tool.revision++;
snapshot();
return structuredClone(current);
},
send: async (_base: string, _id: string, input: TeacherSend) => {
current.requests.push({ id: input.requestId, text: input.text, references: [], createdAt: 'now', status: 'running', sourceCursor: { workerGeneration: 0, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, response: '', discussionContext: input.discussion });
if ('presentation' in input || 'discussion' in input) throw new Error('Retired component fields must not be sent');
sentInputs.push(structuredClone(input));
current.requests.push({ id: input.requestId, text: input.text, intent: input.intent, references: [], createdAt: 'now', status: 'running', sourceCursor: { workerGeneration: 0, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, response: '', presentation: 'reply-v1' });
snapshot();
timer = setTimeout(() => {
const request = current.requests.at(-1)!;
request.status = 'completed';
request.response = '好,已经把你的补充放在上面了。我们可以接着想。';
const tool = current.discussion;
if (tool?.status === 'active' && input.discussion) {
if (input.discussion.transition === 'structure' && tool.content.kind === 'ideas') {
tool.previousIdeas = structuredClone(tool.content);
tool.content = structuredClone(samples.structure);
} else if (tool.content.kind === 'ideas') tool.content.items.push({ id: `new-${tool.revision}`, text: input.text, state: 'kept' });
else if (tool.content.kind === 'structure') tool.content.nodes.push({ id: `new-${tool.revision}`, label: input.text, parentId: 'world' });
else if (tool.content.kind === 'flow') tool.content.nodes.push({ id: `new-${tool.revision}`, label: input.text, kind: 'outcome' });
else tool.content.rows.push({ id: `new-${tool.revision}`, label: '再想想这一点', cells: tool.content.columns.map(col => ({ columnId: col.id, text: '先观察实际玩的时候有什么不同。' })) });
tool.revision++;
}
request.response = input.intent === 'suggestions' ? '我们可以先从一个小地方开始。' : '好,我们就从这一个问题聊起。';
if (input.intent === 'suggestions') request.suggestedQuestions = ['可以从哪个小地方开始?'];
snapshot();
}, 350);
return structuredClone(current);
@@ -5,7 +5,7 @@ import '../../../src/pages/Chat/classroom-workspace.css';
import 'katex/dist/katex.min.css';
const width = Number(new URLSearchParams(location.search).get('width')) || 508;
createRoot(document.getElementById('root')!).render(<div className="classroom-workspace flex h-screen flex-col bg-white">
<header className="flex h-10 shrink-0 items-center border-b px-5 text-xs">智能体讨论交互测试 · 真实页面组件 / 模拟回复</header>
<header className="flex h-10 shrink-0 items-center border-b px-5 text-xs">智能体回复交互测试 · 真实页面组件 / 模拟回复</header>
<div className="flex min-h-0 flex-1 justify-end bg-[#f5f5f1]">
<main className="min-w-0 flex-1 p-8"><h1 className="text-lg">我的小狗世界</h1><p className="mt-4 text-sm text-muted-foreground">作品区域</p></main>
<div className="h-full shrink-0 border-l" style={{ width }}><TeacherChatPanel projectId="fixture" sourceId="source" /></div>
+72 -8
View File
@@ -22,6 +22,7 @@ type HostConnection = {
};
type TeacherCatalogFixture = {
nextTeacherReplyIncomplete?: boolean;
teacherCatalog: TeacherCatalog;
teacherCatalogFailure: boolean;
holdTeacherCatalog: boolean;
@@ -590,8 +591,14 @@ async function installCodingFirstChatHost(
}
if (path.endsWith(`${topicBase}/${topicId}/messages`) && method === 'POST') {
const suggestionRequest = body!.intent === 'suggestions';
const teacherResponse = suggestionRequest ? '我们可以从你最近试过的地方聊起。' : body!.intent === 'guided-help' ? '你最近做的哪一步,让你停下来想了一会儿?' : '先理解状态如何随点击变化,再修改代码。';
const teacherResponse = suggestionRequest ? '我们可以从你最近试过的地方聊起。' : '先理解状态如何随点击变化,再修改代码。';
consultationTopics[consultationPath]={...currentTopic,revision:Number(currentTopic?.revision??0)+1,requests:[...(currentTopic?.requests as unknown[]??[]),{id:body!.requestId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response:teacherResponse,...(suggestionRequest?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
if (state.nextTeacherReplyIncomplete) {
state.nextTeacherReplyIncomplete = false;
const reply = (consultationTopics[consultationPath].requests as Record<string, unknown>[]).at(-1)!;
Object.assign(reply, { response: '它早就不是', replyIncomplete: true, replyParseError: '格式不完整',
sourceConversationId: body!.sourceConversationId, unparsedResponse: '{"reply":"它早就不是"刚搭好架子' });
}
return respond(consultationTopics[consultationPath],202);
}
if (path.endsWith(`${topicBase}/${topicId}`)) return respond(currentTopic);
@@ -1846,14 +1853,21 @@ test('project consultations preserve student drafts and switch between work and
await expect(page.getByRole('button', { name: '与代码智能体聊天' })).toBeVisible();
await expect(page.getByRole('button', { name: '与朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#coding-consultation-dock')).toHaveCSS('width', '508px');
await expect(teacher.getByTestId('consultation-composer-actions').getByRole('button', { name: '帮我看看', exact: true })).toBeVisible();
await expect(teacher.locator('form .consultation-hint')).toBeVisible();
await expect(teacher.locator('form .consultation-hint')).toHaveText('继续看看👀');
await expect(teacher.getByText('把你的困惑说出来就好')).toHaveCount(0);
await expect(teacher.getByText('当前项目',{exact:true})).toHaveCount(0);
await expect(teacher.getByRole('button',{name:'解释当前代码',exact:true})).toBeVisible();
expect((await readState(electronApp)).captured.filter(item=>item.path.endsWith('/messages')&&item.method==='POST')).toHaveLength(0);
await teacher.getByRole('textbox',{name:'向智能体提问'}).fill('还没说完的困惑');
await teacher.getByRole('button',{name:'帮我看看',exact:true}).click();
await expect(teacher.locator('form .consultation-hint')).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(item=>item.path.endsWith('/messages')&&item.method==='POST')).toHaveLength(0);
await teacher.getByRole('textbox',{name:'向智能体提问'}).fill('');
await teacher.locator('form .consultation-hint').click();
await expect(teacher.getByText('我们可以从你最近试过的地方聊起。')).toBeVisible();
await expect(teacher.getByTestId('teacher-discussion')).toHaveCount(0);
await teacher.getByRole('textbox',{name:'向智能体提问'}).fill('还没说完的困惑');
await expect(teacher.locator('form .consultation-hint')).toHaveCount(0);
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
expect((await readState(electronApp)).captured.filter(item=>item.path.endsWith('/messages')&&item.method==='POST')).toHaveLength(1);
await teacher.getByRole('button',{name:'怎样观察别人玩游戏?',exact:true}).click();
@@ -1863,8 +1877,7 @@ test('project consultations preserve student drafts and switch between work and
await expect(teacher.getByRole('button',{name:'我去试一试'})).toHaveCount(0);
await expect(teacher.getByRole('button',{name:'复制',exact:true})).toHaveCount(0);
await expect(composer).toHaveValue('保留我的草稿');
await teacher.getByRole('button',{name:'我也说不清,你带我看看',exact:true}).click();
await expect(teacher.getByText('你最近做的哪一步,让你停下来想了一会儿?')).toBeVisible();
await expect(teacher.getByRole('button',{name:'我也说不清,你带我看看',exact:true})).toHaveCount(0);
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
await expect(composer).toBeVisible();
@@ -1886,16 +1899,19 @@ test('project consultations preserve student drafts and switch between work and
const requests=(await readState(electronApp)).captured;
expect(requests.filter(item=>item.path.endsWith('/prompt')&&item.method==='POST')).toHaveLength(0);
const consultationRequests=requests.filter(item=>item.path.endsWith('/messages')&&item.method==='POST');
expect(consultationRequests).toHaveLength(3);
expect(consultationRequests).toHaveLength(2);
expect(requests.filter(item => item.path.endsWith('/agent-topics') && item.method === 'POST')).toHaveLength(0);
expect(consultationRequests.every(item => item.path === '/api/coding/agent-conversations/teacher-algorithm/messages')).toBe(true);
await expect(teacher.getByRole('button', { name: '智能体新话题' })).toHaveCount(0);
await expect(teacher.getByRole('combobox')).toHaveCount(0);
for (const request of consultationRequests) {
expect(request.body).not.toHaveProperty('presentation');
expect(request.body).not.toHaveProperty('discussion');
}
await expect(teacher).toHaveAccessibleName('朋友咨询');
expect(consultationRequests[0].body).toMatchObject({intent:'suggestions',text:'帮我看看'});
expect(consultationRequests[0].body).toMatchObject({intent:'suggestions',text:'继续看看👀'});
expect(consultationRequests[1].body).toMatchObject({text:'怎样观察别人玩游戏?'});
expect(consultationRequests[1].body?.intent).toBeUndefined();
expect(consultationRequests[2].body).toMatchObject({intent:'guided-help',text:'我也说不清,你带我看看'});
await page.screenshot({path:test.info().outputPath('project-teacher-side-chat.png')});
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();await expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
@@ -1905,6 +1921,50 @@ test('project consultations preserve student drafts and switch between work and
});
test('incomplete consultation replies retry only on click and preserve both drafts', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(app);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(app, connection, true);
await settleSnapshot(app);
await disableCodingEventSource(page);
await app.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.nextTeacherReplyIncomplete = true;
});
try {
await page.reload();
page = await getStableWindow(app);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const mainDraft = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
await mainDraft.fill('主对话草稿');
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
const teacher = page.getByTestId('teacher-chat-panel');
const draft = teacher.getByRole('textbox', { name: '向智能体提问' });
await draft.fill('现在项目是什么阶段?');
await teacher.getByRole('button', { name: '提问', exact: true }).click();
await expect(teacher.getByRole('status')).toHaveText('这次回复未能完整显示');
await expect(teacher.getByTestId('teacher-reply')).toHaveCount(0);
await expect(teacher.getByText('查看收到的原始内容').locator('..')).not.toHaveAttribute('open');
const sends = async () => (await readState(app)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST');
expect(await sends()).toHaveLength(1);
await draft.fill('咨询里还没写完的草稿');
await teacher.getByRole('button', { name: '重新回答' }).click();
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(draft).toHaveValue('咨询里还没写完的草稿');
await expect(mainDraft).toHaveValue('主对话草稿');
const inputs = await sends();
expect(inputs).toHaveLength(2);
expect(inputs[1].path).toBe(inputs[0].path);
expect(inputs[1].body).toEqual({ ...inputs[0].body, requestId: expect.any(String) });
expect(inputs[1].body!.requestId).not.toBe(inputs[0].body!.requestId);
} finally { await releaseSnapshot(app); }
});
test('manual agent refresh updates the catalog and retries failures without replacing the ongoing chat or drafts', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
@@ -2199,6 +2259,10 @@ test('work tab prepares the page automatically and keeps the student draft throu
await expect(page.getByText('正在打开你的作品…', { exact: true })).toBeVisible();
await electronApp.evaluate(() => { (globalThis as unknown as { __makelorePiFirstChatE2E: { workReady: boolean } }).__makelorePiFirstChatE2E.workReady = true; });
await expect(page.getByRole('textbox', { name: '网页地址' })).toHaveValue('http://127.0.0.1:4173/');
await expect(page.getByTestId('agent-browser-panel')).toHaveAccessibleName('作品预览');
await expect(page.getByRole('heading', { name: '作品预览', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '收起作品', exact: true })).toHaveCount(0);
await expect(page.getByTestId('agent-browser-viewport')).toBeVisible();
await expect(page.getByText('正在打开你的作品…', { exact: true })).toHaveCount(0);
// A manual address open must not permanently disable recovery checks.
await page.getByRole('textbox', { name: '网页地址' }).fill('http://127.0.0.1:4173/');
-103
View File
@@ -1,103 +0,0 @@
import { test, expect } from '@playwright/test';
import { createServer, type ViteDevServer } from 'vite';
import { resolve } from 'node:path';
let server: ViteDevServer;
let url: string;
test.use({ channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
test.beforeAll(async () => {
server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-teacher-discussion', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0 }, plugins: [{
name: 'isolated-discussion-test', enforce: 'pre',
load(id) {
if (id.endsWith('/src/lib/coding-teacher.ts')) return "export { teacherApi, teacherTopicsPath, legacyTopicBase } from '/tests/e2e/fixtures/teacher-discussion-api.ts';";
if (id.endsWith('/src/stores/auth.ts')) return 'export const useAuthStore = selector => selector({user:{userId:"fixture"}});';
},
configureServer(vite) { vite.middlewares.use((req, res, next) => {
if (!req.url?.startsWith('/?') && req.url !== '/') return next();
res.setHeader('Content-Type', 'text/html');
res.end('<html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/e2e/fixtures/teacher-discussion-layout.tsx"></script></body></html>');
}); },
}] });
await server.listen(); url = server.resolvedUrls!.local[0];
});
test.afterAll(async () => { await server?.close(); });
for (const width of [319, 508]) {
test(`rich replies keep code, tables and images inside the panel at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('https://example.com/teacher-preview.png', route => route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="900" height="250"><rect width="900" height="250" fill="#ebeedf"/><text x="30" y="130" font-size="32">Flybot project preview</text></svg>',
}));
await page.goto(`${url}?kind=rich-text&width=${width}`);
const body = page.getByTestId('teacher-reply');
await expect(body.getByRole('heading', { name: '项目建议' })).toHaveCount(1);
await expect(body.getByRole('table')).toHaveCount(1);
await expect(body.locator('.katex')).toHaveCount(1);
await expect(body.getByRole('img', { name: '项目截图' })).toBeVisible();
await expect.poll(() => body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await expect.poll(() => page.locator('.consultation-messages').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const raw = page.getByText('查看收到的原始内容');
await expect(raw.locator('..')).not.toHaveAttribute('open');
await raw.click();
await expect(raw.locator('..').locator('pre')).toContainText('"tool"');
const input = page.getByRole('textbox', { name: '向智能体提问' });
await expect(input).toBeVisible();
await page.locator('.consultation-messages').evaluate(el => { el.scrollTop = 0; });
await page.screenshot({ path: test.info().outputPath(`rich-reply-${width}.png`) });
expect(errors).toEqual([]);
});
}
for (const width of [319, 508]) for (const kind of ['ordinary', 'ideas', 'structure', 'flow', 'comparison']) {
test(`${kind} pins the current tool and composer at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
await page.addInitScript(() => localStorage.setItem(
'makelore-consultation-draft:fixture:fixture:friend', JSON.stringify({ text: '', references: [] }),
));
await page.goto(`${url}?kind=${kind}&width=${width}`);
const input = page.getByRole('textbox', { name: '向智能体提问' });
await expect(input).toBeEnabled();
await expect(page.locator('form')).not.toHaveText(/^0/);
await expect(page.getByText('查看旧版朋友草稿')).toHaveCount(0);
const tool = page.getByTestId('teacher-discussion');
if (kind === 'ordinary') {
await expect(tool).toHaveCount(0);
await input.fill('保留我的草稿');
await page.getByRole('button', { name: '有什么好玩的想法?' }).click();
await expect(page.getByText('好,已经把你的补充放在上面了。我们可以接着想。')).toBeVisible();
await expect(input).toHaveValue('保留我的草稿');
} else {
await expect(tool).toHaveAttribute('data-kind', kind);
const before = await tool.boundingBox();
const composer = await input.boundingBox();
await page.locator('.consultation-messages').hover();
await page.mouse.wheel(0, -1500);
await expect.poll(async () => (await tool.boundingBox())?.y).toBe(before!.y);
expect((await input.boundingBox())?.y).toBe(composer!.y);
expect(await tool.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
expect(composer!.y + composer!.height).toBeLessThan(800);
await input.fill('它还喜欢和我踢球');
await page.getByRole('button', { name: '提问', exact: true }).click();
await expect(page.getByRole('status')).toContainText('正在梳理');
await expect(page.getByText('好,已经把你的补充放在上面了。我们可以接着想。')).toBeVisible();
await expect(tool).toHaveCount(1);
await expect(tool).toHaveAttribute('data-kind', kind);
if (kind === 'ideas') {
await page.getByRole('button', { name: '把想法理一理' }).click();
await expect(tool).toHaveAttribute('data-kind', 'structure');
await page.getByRole('button', { name: '回去补充想法' }).click();
await expect(tool).toHaveAttribute('data-kind', 'ideas');
await expect(tool.getByText('它还喜欢和我踢球', { exact: true })).toHaveCount(1);
}
await input.fill('先留下下一句');
await page.getByRole('button', { name: '先这些', exact: true }).click();
await expect(tool).toHaveAttribute('data-collapsed', 'true');
await expect(input).toHaveValue('先留下下一句');
await page.getByRole('button', { name: '接着改', exact: true }).click();
await expect(tool).toHaveAttribute('data-collapsed', 'false');
}
await page.screenshot({ path: test.info().outputPath(`${kind}-${width}.png`) });
expect(errors).toEqual([]);
});
}
+188
View File
@@ -0,0 +1,188 @@
import { test, expect } from '@playwright/test';
import { createServer, type ViteDevServer } from 'vite';
import { realpathSync } from 'node:fs';
import { resolve } from 'node:path';
let server: ViteDevServer;
let url: string;
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
test.beforeAll(async () => {
server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-teacher-reply', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0, fs: { allow: [resolve('.'), realpathSync(resolve('node_modules'))] } }, plugins: [{
name: 'isolated-reply-test', enforce: 'pre',
load(id) {
if (id.endsWith('/src/lib/coding-teacher.ts')) return "export { teacherApi, teacherTopicsPath, legacyTopicBase } from '/tests/e2e/fixtures/teacher-reply-api.ts';";
if (id.endsWith('/src/stores/auth.ts')) return 'export const useAuthStore = selector => selector({user:{userId:"fixture"}});';
},
configureServer(vite) { vite.middlewares.use((req, res, next) => {
if (!req.url?.startsWith('/?') && req.url !== '/') return next();
res.setHeader('Content-Type', 'text/html');
res.end('<html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/e2e/fixtures/teacher-reply-layout.tsx"></script></body></html>');
}); },
}] });
await server.listen(); url = server.resolvedUrls!.local[0];
});
test.afterAll(async () => { await server?.close(); });
test('retry stickers stay readable and preserve drafts in a 319px consultation', async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
await page.addInitScript(() => localStorage.clear());
for (const status of ['failed', 'cancelled']) {
await page.goto(`${url}?kind=retry&status=${status}&width=319`);
const panel = page.getByTestId('teacher-chat-panel');
const retry = panel.locator('.consultation-retry');
await expect(retry).toBeEnabled();
await expect(retry).toHaveText('继续看看👀');
expect(await retry.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const [panelBounds, retryBounds] = await Promise.all([panel.boundingBox(), retry.boundingBox()]);
expect(retryBounds!.x).toBeGreaterThanOrEqual(panelBounds!.x);
expect(retryBounds!.x + retryBounds!.width).toBeLessThanOrEqual(panelBounds!.x + panelBounds!.width);
await panel.screenshot({ path: test.info().outputPath(`retry-${status}-319.png`) });
const input = page.getByRole('textbox', { name: '向智能体提问' });
await input.fill('我还有一点自己的想法');
await retry.click();
await expect(page.getByText('我们可以先从一个小地方开始。')).toBeVisible();
await expect(input).toHaveValue('我还有一点自己的想法');
expect(await page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs)).toEqual([
{ requestId: expect.any(String), text: '继续看看👀', intent: 'suggestions', references: [], sourceConversationId: 'source' },
]);
}
});
for (const width of [319, 508]) {
test(`quoted replies recover fully and incomplete replies retry without losing the draft at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
const sent = () => page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs);
await page.goto(`${url}?kind=recovered&width=${width}`);
const panel = page.getByTestId('teacher-chat-panel');
const body = page.getByTestId('teacher-reply');
await expect(body).toContainText('它早就不是"刚搭好架子"那种阶段了');
await expect(body).toContainText('现在的阶段是「能玩,正在调手感」。');
await expect(page.getByText('查看收到的原始内容').locator('..')).not.toHaveAttribute('open');
await expect(page.getByRole('button', { name: '重新回答' })).toHaveCount(0);
expect(await sent()).toEqual([]);
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await panel.screenshot({ path: test.info().outputPath(`recovered-${width}.png`) });
await page.goto(`${url}?kind=incomplete&width=${width}`);
await expect(page.getByRole('status')).toHaveText('这次回复未能完整显示');
await expect(page.getByTestId('teacher-reply')).toHaveCount(0);
expect(await sent()).toEqual([]);
const input = page.getByRole('textbox', { name: '向智能体提问' });
await input.fill('我还在写的新想法');
const retry = page.getByRole('button', { name: '重新回答' });
const bounds = (await retry.boundingBox())!;
expect(bounds.height).toBeGreaterThanOrEqual(44);
expect(await panel.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await panel.screenshot({ path: test.info().outputPath(`incomplete-${width}.png`) });
await retry.click();
await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible();
await expect(input).toHaveValue('我还在写的新想法');
expect(await sent()).toEqual([{
requestId: expect.any(String), text: '现在项目是什么阶段?',
sourceConversationId: 'original-source', references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }],
}]);
});
test(`empty-composer hint stays readable and keeps the send position stable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
await page.goto(`${url}?kind=ordinary&width=${width}`);
const input = page.getByRole('textbox', { name: '向智能体提问' });
const hint = page.locator('form .consultation-hint');
const send = page.getByRole('button', { name: '提问', exact: true });
const sent = () => page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs);
await expect(hint).toBeEnabled();
await expect(hint).toHaveText('继续看看👀');
await expect(hint).toHaveCSS('background-color', 'rgb(255, 242, 201)');
const initialSend = (await send.boundingBox())!;
const hintBounds = (await hint.boundingBox())!;
expect(hintBounds.x).toBeLessThan(initialSend.x);
expect(hintBounds.height).toBeGreaterThanOrEqual(44);
expect(hintBounds.x + hintBounds.width).toBeLessThanOrEqual(initialSend.x);
await page.getByTestId('teacher-chat-panel').screenshot({ path: test.info().outputPath(`composer-hint-${width}.png`) });
expect(await sent()).toEqual([]);
await input.fill('我想先自己说说');
await expect(hint).toHaveCount(0);
expect(await send.boundingBox()).toEqual(initialSend);
await input.fill(' ');
await expect(hint).toBeVisible();
await input.fill('');
await expect(hint).toBeVisible();
expect(await send.boundingBox()).toEqual(initialSend);
expect(await sent()).toEqual([]);
await hint.click();
await expect(page.getByText('我们可以先从一个小地方开始。')).toBeVisible();
expect(await sent()).toEqual([{ requestId: expect.any(String), text: '继续看看👀', intent: 'suggestions', references: [], sourceConversationId: 'source' }]);
await expect(page.getByRole('button', { name: '我也说不清,你带我看看', exact: true })).toHaveCount(0);
await expect(page.locator('.consultation-messages').getByRole('button', { name: '继续看看👀', exact: true })).toHaveCount(0);
const card = page.getByRole('button', { name: '可以从哪个小地方开始?' });
await expect(card).toBeVisible();
await input.fill('留着我自己的草稿');
await expect(hint).toHaveCount(0);
await card.click();
await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible();
await expect(input).toHaveValue('留着我自己的草稿');
expect(await sent()).toHaveLength(2);
await page.screenshot({ path: test.info().outputPath(`composer-draft-${width}.png`) });
});
test(`rich replies keep code, tables and images inside the panel at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('https://example.com/teacher-preview.png', route => route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="900" height="250"><rect width="900" height="250" fill="#ebeedf"/><text x="30" y="130" font-size="32">Flybot project preview</text></svg>',
}));
await page.goto(`${url}?kind=rich-text&width=${width}`);
const body = page.getByTestId('teacher-reply');
await expect(body.getByRole('heading', { name: '项目建议' })).toHaveCount(1);
await expect(body.getByRole('table')).toHaveCount(1);
await expect(body.locator('.katex')).toHaveCount(1);
await expect(body.getByRole('img', { name: '项目截图' })).toBeVisible();
await expect.poll(() => body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await expect.poll(() => page.locator('.consultation-messages').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const raw = page.getByText('查看收到的原始内容');
await expect(raw.locator('..')).not.toHaveAttribute('open');
await raw.click();
await expect(raw.locator('..').locator('pre')).toContainText('"tool"');
const input = page.getByRole('textbox', { name: '向智能体提问' });
await expect(input).toBeVisible();
await page.locator('.consultation-messages').evaluate(el => { el.scrollTop = 0; });
await page.screenshot({ path: test.info().outputPath(`rich-reply-${width}.png`) });
expect(errors).toEqual([]);
});
}
for (const width of [319, 508]) for (const kind of ['ordinary', 'ideas', 'structure', 'flow', 'comparison']) {
test(`${kind} history keeps ordinary replies, quick cards and the composer usable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
await page.addInitScript(() => localStorage.setItem(
'makelore-consultation-draft:fixture:fixture:friend', JSON.stringify({ text: '', references: [] }),
));
await page.goto(`${url}?kind=${kind}&width=${width}`);
const input = page.getByRole('textbox', { name: '向智能体提问' });
await expect(input).toBeEnabled();
await expect(page.getByRole('complementary', { name: '麦洛智能体咨询' })).toBeVisible();
await expect(page.getByTestId('teacher-chat-panel').locator('header')).toHaveCount(0);
await expect(page.locator('form')).not.toHaveText(/^0/);
await expect(page.getByText('查看旧版朋友草稿')).toHaveCount(0);
await expect(page.getByTestId('teacher-discussion')).toHaveCount(0);
await expect(page.getByRole('button', { name: /用这个一起想|先这些|接着改|把想法理一理|聊聊这一点/ })).toHaveCount(0);
await input.fill('保留我的草稿');
const card = page.getByRole('button', { name: '有什么好玩的想法?' });
await expect(card).toBeVisible();
expect(await card.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await card.click();
await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible();
await expect(input).toHaveValue('保留我的草稿');
await input.fill('它还喜欢和我踢球');
await page.getByRole('button', { name: '提问', exact: true }).click();
await expect(page.getByText('它还喜欢和我踢球', { exact: true })).toBeVisible();
await expect(page.getByText('好,我们就从这一个问题聊起。')).toHaveCount(2);
await expect(input).toHaveValue('');
await expect(page.getByTestId('teacher-discussion')).toHaveCount(0);
expect(await page.locator('.consultation-messages').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const composer = await input.boundingBox();
expect(composer!.y + composer!.height).toBeLessThan(800);
await page.screenshot({ path: test.info().outputPath(`${kind}-${width}.png`) });
expect(errors).toEqual([]);
});
}