Merge teacher reply cleanup with continuous conversations
This commit is contained in:
@@ -1,75 +0,0 @@
|
||||
// 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 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: '收获后' }] },
|
||||
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' }> } : {}) } } : {}) };
|
||||
const stream = Object.assign(new EventTarget(), { close() {}, onerror: null, onopen: null });
|
||||
if (scenario === 'rich-text') {
|
||||
current.requests = [{
|
||||
...current.requests[0], text: '帮我看看项目和最近的讨论',
|
||||
response: '## 项目建议\n\n**先核对已有设计**,再决定下一步。\n\n- 检查入口文件\n- 保留当前玩法\n\n| 文件 | 作用 | 需要确认 |\n| --- | --- | --- |\n| src/main.ts | 初始化项目 | 游戏循环与计时 |\n| public/assets | 图片资源 | 构建后路径是否可用 |\n\n```js\nconst message = "保留字符串里的\\n,不把它变成实际换行";\nconst resourcePath = "/assets/" + "very-long-file-name-".repeat(15);\n```\n\n运动距离:$s = vt$。\n\n\n\n[项目说明](https://example.com/readme)\n\n> 先验证观察到的现象。',
|
||||
discussionError: '这次整理没有完成,先保留原来的内容。',
|
||||
unparsedResponse: '{"reply":"正文已保留","tool":{"kind":"structure",',
|
||||
}];
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
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 }] }),
|
||||
config: async () => ({ enabled: true, definition }),
|
||||
list: async () => ({ items: [{ id: current.id, title: '我的小狗世界' }], lastSelectedTopicId: current.id }),
|
||||
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 });
|
||||
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++;
|
||||
}
|
||||
snapshot();
|
||||
}, 350);
|
||||
return structuredClone(current);
|
||||
},
|
||||
cancel: async () => { clearTimeout(timer); current.requests.at(-1)!.status = 'cancelled'; snapshot(); return structuredClone(current); },
|
||||
};
|
||||
export const teacherTopicsPath = () => '/fixture';
|
||||
|
||||
export const legacyTopicBase = (base: string) => base;
|
||||
62
tests/e2e/fixtures/teacher-reply-api.ts
Normal file
62
tests/e2e/fixtures/teacher-reply-api.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Deterministic responses for layout/interaction tests only; never imported by the app.
|
||||
import type { TeacherDefinition, TeacherTopic, TeacherSend } from '../../../shared/coding-teacher';
|
||||
const definition: TeacherDefinition = { schema_version: 1, config_id: 'coding-teacher', 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 } };
|
||||
export const fixtureAgent = { teacher_id: definition.config_id!, version: 1, is_default: true, definition };
|
||||
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, conversation: { agentId: fixtureAgent.teacher_id, segmentTurns: 1 }, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], projectId: 'fixture', sourceConversationId: 'source', teacherVersion: 1, createdAt: `2026-09-28T10:00:${String(i).padStart(2, '0')}Z`, 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 === '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: '帮我看看项目和最近的讨论',
|
||||
response: '## 项目建议\n\n**先核对已有设计**,再决定下一步。\n\n- 检查入口文件\n- 保留当前玩法\n\n| 文件 | 作用 | 需要确认 |\n| --- | --- | --- |\n| src/main.ts | 初始化项目 | 游戏循环与计时 |\n| public/assets | 图片资源 | 构建后路径是否可用 |\n\n```js\nconst message = "保留字符串里的\\n,不把它变成实际换行";\nconst resourcePath = "/assets/" + "very-long-file-name-".repeat(15);\n```\n\n运动距离:$s = vt$。\n\n\n\n[项目说明](https://example.com/readme)\n\n> 先验证观察到的现象。',
|
||||
discussionError: '这次整理没有完成,先保留原来的内容。',
|
||||
unparsedResponse: '{"reply":"正文已保留","tool":{"kind":"structure",',
|
||||
}];
|
||||
}
|
||||
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: [fixtureAgent] }),
|
||||
config: async () => ({ enabled: true, definition }),
|
||||
conversation: async () => ({ topic: structuredClone(current), before: null }),
|
||||
seenConversation: async (_agent: string, requestId: string) => {
|
||||
current.conversation!.seenThrough = requestId;
|
||||
current.revision++;
|
||||
return structuredClone(current);
|
||||
},
|
||||
legacyHistory: async () => ({ items: [], lastSelectedTopicId: null }),
|
||||
events: async () => stream,
|
||||
sendConversation: async (_agent: string, input: TeacherSend) => {
|
||||
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: [], projectId: input.projectId, sourceConversationId: input.sourceConversationId, teacherVersion: 1, createdAt: `2026-09-28T10:01:${String(sentInputs.length).padStart(2, '0')}Z`, 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 = input.intent === 'suggestions' ? '我们可以先从一个小地方开始。' : '好,我们就从这一个问题聊起。';
|
||||
if (input.intent === 'suggestions') request.suggestedQuestions = ['可以从哪个小地方开始?'];
|
||||
snapshot();
|
||||
}, 350);
|
||||
return structuredClone(current);
|
||||
},
|
||||
cancel: async () => { clearTimeout(timer); current.requests.at(-1)!.status = 'cancelled'; snapshot(); return structuredClone(current); },
|
||||
};
|
||||
export const teacherTopicsPath = () => '/fixture';
|
||||
|
||||
export const legacyTopicBase = (base: string) => base;
|
||||
@@ -1,13 +1,14 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { fixtureAgent } from './teacher-reply-api';
|
||||
import { TeacherChatPanel } from '../../../src/pages/Chat/TeacherChatPanel';
|
||||
import '../../../src/styles/globals.css';
|
||||
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>
|
||||
<div className="h-full shrink-0 border-l" style={{ width }}><TeacherChatPanel projectId="fixture" sourceId="source" selectedAgent={fixtureAgent} /></div>
|
||||
</div>
|
||||
</div>);
|
||||
Reference in New Issue
Block a user