feat(consultation): unify server-distributed agents without fixed roles

This commit is contained in:
2026-09-24 11:56:42 +08:00
parent 248776d855
commit 8a157be4f6
35 changed files with 625 additions and 668 deletions

View File

@@ -1,7 +1,7 @@
// 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';
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 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> = {
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: '收获后' }] },
@@ -9,7 +9,7 @@ const samples: Record<string, TeacherDiscussionContent> = {
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', role: 'teacher', 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 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(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' }> } : {}) } } : {}) };
const stream = Object.assign(new EventTarget(), { close() {}, onerror: null, onopen: null });
let timer: ReturnType<typeof setTimeout> | undefined;
const snapshot = () => { current.revision++; stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })); };
@@ -63,3 +63,5 @@ export const teacherApi = {
cancel: async () => { clearTimeout(timer); current.requests.at(-1)!.status = 'cancelled'; snapshot(); return structuredClone(current); },
};
export const teacherTopicsPath = () => '/fixture';
export const legacyTopicBase = (base: string) => base;

View File

@@ -4,9 +4,9 @@ import '../../../src/styles/globals.css';
import '../../../src/pages/Chat/classroom-workspace.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" role="teacher" /></div>
<div className="h-full shrink-0 border-l" style={{ width }}><TeacherChatPanel projectId="fixture" sourceId="source" /></div>
</div>
</div>);

View File

@@ -34,8 +34,8 @@ export function Layout() {
</main>
{open && <aside id="coding-consultation-dock" className="classroom-consultation-dock consultation-pane shrink-0 border-l p-5" style={{ width }}>
<p className="mb-8 text-sm">老师对话(布局测试)</p>
<label className="block text-xs">老师栏宽度<input aria-label="老师栏宽度" type="range" min="320" max="640" value={width} onChange={(event) => setWidth(Number(event.target.value))} /></label>
<button className="mt-6 text-xs" onClick={() => setOpen(false)}>关闭老师栏</button>
<label className="block text-xs">智能体栏宽度<input aria-label="智能体栏宽度" type="range" min="320" max="640" value={width} onChange={(event) => setWidth(Number(event.target.value))} /></label>
<button className="mt-6 text-xs" onClick={() => setOpen(false)}>关闭智能体栏</button>
</aside>}
</div>
<footer className="flex shrink-0 gap-5 border-t p-2 text-xs">

View File

@@ -449,12 +449,11 @@ async function installCodingFirstChatHost(
...overrides,
});
const teacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码老师',description:'',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
const teacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
const teachers = [
{ teacher_id: 'teacher-code', version: 1, is_default: true, definition: { ...teacherDefinition, config_id: 'teacher-code', runtime: 'yuxi', system_prompt: '', yuxi: { agent_slug: 'code', agent_version: 2 } } },
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '算法老师', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
];
const friendDefinition = {...teacherDefinition,teacher_id:'coding-friend',name:'小麦',welcome_message:'一起看看你的作品',suggested_questions:['想听听你对作品的第一印象']};
const consultationTopics: Record<string, Record<string, unknown>> = {};
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (
@@ -529,29 +528,28 @@ async function installCodingFirstChatHost(
}
if (path.endsWith('/teacher-check-in') && method === 'POST') {
const current = consultationTopics.teacher ?? {schemaVersion:1,revision:0,id:'teacher-topic',accountId:'e2e',projectId:project.id,role:'teacher',sourceConversationId:'project',definition:teacherDefinition,version:1,createdAt:now,updatedAt:now,requests:[]};
consultationTopics.teacher = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,intent:'check-in',text:'',references:[],sourceConversationId:body!.sourceConversationId,createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,status:'completed',response:'你刚才想加排行榜,我们一起想想比什么更有意思?'}]};
return respond({topic:consultationTopics.teacher});
const current = consultationTopics.agent ?? {schemaVersion:1,revision:0,id:'agent-topic',accountId:'e2e',projectId:project.id,sourceConversationId:'project',definition:teacherDefinition,version:1,createdAt:now,updatedAt:now,requests:[]};
consultationTopics.agent = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,intent:'check-in',text:'',references:[],sourceConversationId:body!.sourceConversationId,createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,status:'completed',response:'你刚才想加排行榜,我们一起想想比什么更有意思?'}]};
return respond({topic:consultationTopics.agent});
}
if (path === '/api/coding/teacher/teachers') return respond({ items: teachers });
if (path === '/api/coding/teacher/config') return respond({enabled:true,revision:1,published_version:1,definition:teacherDefinition});
if (path === '/api/coding/friend/config') return respond({enabled:true,revision:1,published_version:1,definition:friendDefinition});
const consultationRole = path.match(/\/(teacher|friend)-topics(?:\/|$)/)?.[1];
if (consultationRole) {
const topicBase = `/${consultationRole}-topics`;
const topicId = `${consultationRole}-topic`;
const currentTopic = consultationTopics[consultationRole];
if (path.endsWith(topicBase) && method === 'GET') return respond({items:currentTopic?[{id:currentTopic.id,title:consultationRole==='friend'?'朋友话题':'老师话题'}]:[],lastSelectedTopicId:currentTopic?.id??null});
const consultationPath = path.match(/\/(agent|teacher)-topics(?:\/|$)/)?.[1];
if (consultationPath) {
const topicBase = `/${consultationPath}-topics`;
const topicId = `${consultationPath}-topic`;
const currentTopic = consultationTopics[consultationPath];
if (path.endsWith(topicBase) && method === 'GET') return respond({items:currentTopic?[{id:currentTopic.id,title:'智能体话题'}]:[],lastSelectedTopicId:currentTopic?.id??null});
if (path.endsWith(topicBase) && method === 'POST') {
const selected = teachers.find(item => item.version === body?.teacherVersion) ?? teachers[0];
consultationTopics[consultationRole]={schemaVersion:1,revision:0,id:topicId,accountId:'e2e',projectId:project.id,role:consultationRole,sourceConversationId:conversation.id,definition:consultationRole==='friend'?friendDefinition:selected.definition,version:consultationRole==='friend'?1:selected.version,createdAt:now,updatedAt:now,requests:[]};
return respond(consultationTopics[consultationRole],201);
consultationTopics[consultationPath]={schemaVersion:1,revision:0,id:topicId,accountId:'e2e',projectId:project.id,sourceConversationId:conversation.id,definition:selected.definition,version:selected.version,createdAt:now,updatedAt:now,requests:[]};
return respond(consultationTopics[consultationPath],201);
}
if (path.endsWith(`${topicBase}/${topicId}/messages`) && method === 'POST') {
const suggestionRequest = body!.intent === 'suggestions';
const teacherResponse = suggestionRequest ? '我们可以从你最近试过的地方聊起。' : body!.intent === 'guided-help' ? '你最近做的哪一步,让你停下来想了一会儿?' : '先理解状态如何随点击变化,再修改代码。';
consultationTopics[consultationRole]={...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:consultationRole==='friend'?'听你的描述,我有点好奇该从哪里开始玩。':teacherResponse,...(suggestionRequest?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
return respond(consultationTopics[consultationRole],202);
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:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
return respond(consultationTopics[consultationPath],202);
}
if (path.endsWith(`${topicBase}/${topicId}`)) return respond(currentTopic);
}
@@ -1780,67 +1778,57 @@ test('project consultations preserve student drafts and switch between work and
await expect(composer).toBeHidden();
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
await expect(composer).toHaveValue('保留我的草稿');
await page.getByRole('button',{name:'老师',exact:true}).click();
await page.getByRole('button',{name:'智能体',exact:true}).click();
const teacher=page.getByTestId('teacher-chat-panel');
await expect(page.getByRole('tab',{name:'作品',exact:true})).toHaveAttribute('aria-selected','true');
await expect(page.getByRole('tabpanel',{name:'作品',exact:true})).toBeVisible();
await expect(composer).toBeHidden();
await expect(composer).toHaveValue('保留我的草稿');
await expect(teacher.getByText('有问题,随时来找我')).toBeVisible();
await teacher.getByRole('combobox', { name: '新话题使用的老师' }).selectOption('9');
await expect(teacher.getByText('一起理解代码')).toBeVisible();
await teacher.getByRole('combobox', { name: '新话题使用的智能体' }).selectOption('9');
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.getByTestId('consultation-composer-actions').getByRole('button', { name: '帮我看看', exact: true })).toBeVisible();
await expect(teacher.getByText('把你的困惑说出来就好')).toHaveCount(0);
await expect(teacher.getByText('当前项目',{exact:true})).toHaveCount(0);
await expect(teacher.getByRole('button',{name:'解释当前代码',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 teacher.getByRole('textbox',{name:'向智能体提问'}).fill('还没说完的困惑');
await teacher.getByRole('button',{name:'帮我看看',exact:true}).click();
await expect(teacher.getByText('我们可以从你最近试过的地方聊起。')).toBeVisible();
await expect(teacher.getByRole('textbox',{name:'向老师提问'})).toHaveValue('还没说完的困惑');
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();
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(teacher.getByRole('textbox',{name:'向老师提问'})).toHaveValue('还没说完的困惑');
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
await expect(teacher.getByRole('button',{name:'带回主会话草稿'})).toHaveCount(0);
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('textbox',{name:'向老师提问'})).toHaveValue('还没说完的困惑');
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
await expect(composer).toBeVisible();
await page.getByRole('button',{name:'朋友',exact:true}).click();
await expect(page.getByRole('tab',{name:'操作对话',exact:true})).toHaveAttribute('aria-selected','true');
await expect(teacher).toHaveAttribute('data-role','friend');
await expect(teacher.getByText('一起看看你的作品')).toBeVisible();
await expect(teacher.getByRole('textbox',{name:'向朋友提问'})).toHaveValue('');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toHaveCount(0);
await teacher.getByRole('textbox',{name:'向朋友提问'}).fill('你觉得哪里有意思?');
await teacher.getByRole('button',{name:'关闭朋友'}).click();
await expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'朋友',exact:true}).click();
await expect(teacher.getByRole('textbox',{name:'向朋友提问'})).toHaveValue('你觉得哪里有意思?');
await page.getByRole('button',{name:'老师',exact:true}).click();
await expect(teacher.getByRole('textbox',{name:'向老师提问'})).toHaveValue('还没说完的困惑');
await expect(page.getByRole('tab',{name:'作品',exact:true})).toHaveAttribute('aria-selected','true');
await teacher.getByRole('button',{name:'关闭智能体'}).click();
await expect(page.getByRole('button',{name:'朋友',exact:true})).toHaveCount(0);
await page.getByRole('button',{name:'智能体',exact:true}).click();
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
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(requests.find(item => item.path.endsWith('/teacher-topics') && item.method === 'POST')?.body?.teacherVersion).toBe(9);
await expect(teacher.getByRole('heading', { name: '算法老师', exact: true })).toBeVisible();
expect(consultationRequests[0].body).toMatchObject({intent:'suggestions',text:'老师帮我看看'});
expect(requests.find(item => item.path.endsWith('/agent-topics') && item.method === 'POST')?.body?.teacherVersion).toBe(9);
await expect(teacher.getByRole('heading', { name: '朋友', exact: true })).toBeVisible();
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 teacher.getByRole('button',{name:'关闭老师'}).click();await expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'老师',exact:true}).click();
await teacher.getByRole('button',{name:'关闭智能体'}).click();await expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'智能体',exact:true}).click();
await expect(page.getByText('先理解状态如何随点击变化,再修改代码。',{exact:true})).toBeVisible();
await expect(teacher.getByRole('textbox',{name:'向老师提问'})).toHaveValue('还没说完的困惑');
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
} finally {await releaseSnapshot(electronApp);}
});
@@ -1901,11 +1889,11 @@ test('teacher stays in the top-right header and proactively checks in without ta
await page.evaluate(() => { window.location.hash = '/chat'; });
const header = page.getByTestId('coding-conversation-header');
const companion = header.getByTestId('teacher-companion');
const teacherButton = companion.getByRole('button', { name: '老师', exact: true });
await expect(teacherButton).toHaveText('代码老师');
const teacherButton = companion.getByRole('button', { name: '智能体', exact: true });
await expect(teacherButton).toHaveText('代码智能体');
await expect(teacherButton).toBeVisible();
await expect(teacherButton.locator('img')).toHaveAttribute('src', /teacher-pixel/);
await expect(page.getByRole('checkbox', { name: '老师偶尔来看看' })).toHaveCount(0);
await expect(teacherButton.locator('img')).toHaveAttribute('src', /avatar-01/);
await expect(page.getByRole('checkbox', { name: '智能体偶尔来看看' })).toHaveCount(0);
await expect(page.getByTestId('teacher-companion')).toHaveCount(1);
const invitation = companion.getByTestId('teacher-invitation');
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
@@ -1932,10 +1920,10 @@ test('teacher stays in the top-right header and proactively checks in without ta
await expect(invitation).toHaveAttribute('data-bubble-kind', 'check-in');
await expect(invitation).toBeInViewport({ ratio: 1 });
const presence = companion.getByTestId('teacher-presence');
await expect(presence.getByRole('button', { name: '打开这条老师消息' })).toBeVisible();
await expect(presence.getByRole('button', { name: '打开这条老师消息' }).locator('img')).toHaveAttribute('src', await teacherButton.locator('img').getAttribute('src') as string);
await expect(presence.getByRole('button', { name: '打开这条智能体消息' })).toBeVisible();
await expect(presence.getByRole('button', { name: '打开这条智能体消息' }).locator('img')).toHaveAttribute('src', await teacherButton.locator('img').getAttribute('src') as string);
const [invitationBounds, companionBounds, avatarBounds, presenceBounds, speakerBounds] = await Promise.all([
invitation.boundingBox(), companion.boundingBox(), teacherButton.locator('img').boundingBox(), presence.boundingBox(), presence.getByRole('button', { name: '打开这条老师消息' }).boundingBox(),
invitation.boundingBox(), companion.boundingBox(), teacherButton.locator('img').boundingBox(), presence.boundingBox(), presence.getByRole('button', { name: '打开这条智能体消息' }).boundingBox(),
]);
expect(invitationBounds).not.toBeNull();
expect(companionBounds).not.toBeNull();
@@ -1964,7 +1952,7 @@ test('teacher stays in the top-right header and proactively checks in without ta
await expect(composer).toHaveValue('我还在写自己的想法');
await page.clock.fastForward(300_000);
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(1);
await invitation.getByRole('button', { name: '和老师聊聊' }).click();
await invitation.getByRole('button', { name: '和智能体聊聊' }).click();
await expect(invitation).toHaveCount(0);
await expect(presence).toHaveCount(0);
await expect(teacherButton).toBeVisible();
@@ -1973,12 +1961,12 @@ test('teacher stays in the top-right header and proactively checks in without ta
const teacher = page.getByTestId('teacher-chat-panel');
await expect(teacher.getByText('你刚才想加排行榜,我们一起想想比什么更有意思?')).toBeVisible();
await expect(teacher.locator('.consultation-user')).toHaveCount(0);
const teacherInput = teacher.getByRole('textbox', { name: '向老师提问' });
const teacherInput = teacher.getByRole('textbox', { name: '向智能体提问' });
await expect(teacherInput).toBeFocused();
await teacherInput.fill('我想比谁更了解自己的宠物');
await teacher.getByRole('button', { name: '关闭老师' }).click();
await expect(page.getByRole('button', { name: '老师', exact: true })).toBeFocused();
await page.getByRole('button', { name: '老师', exact: true }).click();
await teacher.getByRole('button', { name: '关闭智能体' }).click();
await expect(page.getByRole('button', { name: '智能体', exact: true })).toBeFocused();
await page.getByRole('button', { name: '智能体', exact: true }).click();
await expect(teacherInput).toHaveValue('我想比谁更了解自己的宠物');
await teacherInput.fill('');
// Let the cleared draft propagate through the parent before advancing the check-in timer.
@@ -1987,7 +1975,7 @@ test('teacher stays in the top-right header and proactively checks in without ta
await page.clock.runFor(15_000);
await expect(invitation).toBeVisible();
await expect(teacher).toBeVisible();
await expect(invitation.getByRole('button', { name: '看看老师说的' })).toBeVisible();
await expect(invitation.getByRole('button', { name: '看看智能体说的' })).toBeVisible();
// The message sits over the separate teacher pane and must not push Work down.
await expect.poll(async () => (await workPanel.boundingBox())!.y - (await workPane.boundingBox())!.y).toBe(20);
await page.screenshot({ path: test.info().outputPath('teacher-open-proactive-invitation.png') });

View File

@@ -8,7 +8,7 @@ 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 } from '/tests/e2e/fixtures/teacher-discussion-api.ts';";
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) => {
@@ -25,7 +25,7 @@ for (const width of [319, 508]) for (const kind of ['ordinary', 'ideas', 'struct
await page.setViewportSize({ width: 1180, height: 800 });
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
await page.goto(`${url}?kind=${kind}&width=${width}`);
const input = page.getByRole('textbox', { name: '向老师提问' });
const input = page.getByRole('textbox', { name: '向智能体提问' });
await expect(input).toBeEnabled();
const tool = page.getByTestId('teacher-discussion');
if (kind === 'ordinary') {

View File

@@ -55,17 +55,17 @@ for (const size of [{ width: 1440, height: 900 }, { width: 1180, height: 720 },
await expect(page.getByTestId('teacher-invitation')).toBeVisible();
await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8);
expect((await geometry(page)).gap).toBeLessThan(65);
await page.getByRole('button', { name: '老师', exact: true }).click();
await page.getByRole('button', { name: '智能体', exact: true }).click();
await expect.poll(async () => (await geometry(page)).sideBySide).toBe(true);
await expect.poll(async () => (await geometry(page)).gap).toBe(20);
// A narrow dock can overlap again; increasing its width must give the
// preview all its space back without hiding the unread message.
await page.getByRole('slider', { name: '老师栏宽度' }).press('Home');
await page.getByRole('slider', { name: '智能体栏宽度' }).press('Home');
await expect.poll(async () => { const g = await geometry(page); return g.sideBySide || g.nativeGap >= 8; }).toBe(true);
await page.getByRole('slider', { name: '老师栏宽度' }).press('End');
await page.getByRole('slider', { name: '智能体栏宽度' }).press('End');
await expect.poll(async () => (await geometry(page)).gap).toBe(20);
await page.screenshot({ path: test.info().outputPath('work-with-teacher-no-gap.png') });
await page.getByRole('button', { name: '关闭老师栏' }).click();
await page.getByRole('button', { name: '关闭智能体栏' }).click();
await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8);
await page.getByRole('button', { name: '切换消息长度' }).click();
await expect.poll(async () => (await geometry(page)).gap).toBe(20);