feat(teacher): show a subtle hint action in the empty composer

This commit is contained in:
鲨鱼辣椒
2026-09-28 15:17:18 +08:00
parent e00b3912bc
commit 73331e64f2
8 changed files with 184 additions and 62 deletions

View File

@@ -8,7 +8,7 @@
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore-teacher-reply-cleanup
- Base commit: ae0f72d8fa0089f87b41d272da220e1c1397aec8
- Owner: codex
- Status: Ready for Integration
- Status: In progress
## Scope
@@ -79,3 +79,17 @@
- Runtime source/build is 9adab45, Renderer index-Dn2S79es.js loaded from this checkout, document complete and React mounted. Startup guard blocked one show and retained a hidden, unfocused window until user activation. A 12-second NSWorkspace monitor recorded trialFocused=false and unchanged foreground PID 409. Installed production PID 23486 remains untouched.
- Preserved the same trial user-data directory. Updated only local trial launch metadata after backing up prior descriptors. No cloud configuration publication, deliberate model request, history seeding or UI activation. This checkout must remain available while the trial uses its build.
- Task-aware drift checker now flags the imported peer task record 20260928-agent-refresh-a6d109b3 relative to the original task base. It is a committed dependency imported by the authorized merge, verified byte-for-byte identical to 4f6bda4; no peer document was edited. Record this inherited-source diagnostic rather than claiming the checker passed after merge. Canonical project documents remain untouched.
## Empty Composer Hint Follow-up
- User approved the first proposed interaction: show a lightweight “给我一点提示” action only when the consultation input is empty, hide it when typing and remove the fixed “我也说不清,你带我看看” link below replies. Update the existing trial without desktop focus changes.
- Same-task ownership resumed and verified; rechecked task/entry and peer scopes. The protocol-boundary peer is complete but unmerged and not live in this trial; preserve its separate prompt-policy decisions for its designated integration task, do not change backend policy as part of this UI request. Existing manual refresh is already included.
- Plan: modify only the composer entry and fixed follow-up link, preserve true generated cards/free input/retries, add focused UI and browser checks, build and update trial Renderer while retaining Main and persisted data where possible. No cloud publication or deliberate model test request.
### Hint Implementation And Verification
- Replaced the filled “帮我看看” pill with a transparent “给我一点提示” text action in the empty composer. Typing hides it; clearing or whitespace restores it. A fixed minimum action-row height and left flex slot retain the send button’s position. Removed only the client-added “我也说不清,你带我看看” footer; identical text genuinely returned by the Agent still renders and sends as an ordinary generated reply.
- Preserved quick replies, free input, references/drafts, failure/cancel/interruption retries, and one-click request behavior. When an old unconfirmed suggestions payload still says “帮我看看”, retry now reuses the complete original payload and request ID; ordinary questions and already-confirmed requests cannot reuse it. Old guided-help intent remains readable for history compatibility; no backend/cloud policy changed.
- Verification passed: 59 UI unit tests (including five legacy pending-payload cases), 14 headless Chrome layout tests at 319/508px, typecheck, full lint followed by final changed-file lint, and production Renderer/Main/Preload/utility compile. Legacy regressions failed before the compatibility fix and passed afterward. Updated shared Electron E2E assertions but did not launch a native test application. No real model request was made for verification.
- Built with `corepack pnpm run build:vite --emptyOutDir false` so the live Renderer can keep loading its old hashed assets until the deliberate refresh. Saved previous index and bootstrap/launch descriptors locally outside Git; current Main is retained for the pending Renderer update.

View File

@@ -174,9 +174,9 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
- 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。
- 学生在当前项目创作时,前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。
- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。
- 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:学生点击后才结合当前操作对话与咨询历史推荐一个具体切入点,并生成围绕它的快捷回复。点击快捷回复会主动开始讨论;“我也说不清,你带我看看”让智能体从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。
- 智能体支持自由提问;输入框为空时,底部显示轻量文字入口“给我一点提示”,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,结合当前操作对话与咨询历史推荐一个具体切入点,并生成围绕它的快捷回复。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。
- 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。
- “帮我看看”和引导开口都由老师结合项目推荐一个具体切入点,快捷回复围绕这个切入点帮助学生接话;客户端不要求多个独立话题,也不固定正文长度、选项数量、前缀或表达风格。老师继续依据已配置职责帮助孩子整理想法、理解关系和承接已确认的共识,区分建议与已确认内容。
- “给我一点提示”由老师结合项目推荐一个具体切入点,快捷回复围绕这个切入点帮助学生接话;客户端不要求多个独立话题,也不固定正文长度、选项数量、前缀或表达风格。老师继续依据已配置职责帮助孩子整理想法、理解关系和承接已确认的共识,区分建议与已确认内容。
- Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后展示正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。资源上限只用于防护,不做旧式字数截断;格式错误保留可读正文及原始回答,原文默认折叠、字面显示,不再次送进模型。
- 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。
- 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。

View File

@@ -316,11 +316,14 @@ export function TeacherChatPanel({
const previous = pending.current && !current.requests.some((request) => request.id === pending.current?.requestId)
? pending.current
: null;
// Copy changes must not create another charge for an uncertain hint request.
const retrySuggestions = action?.intent === 'suggestions' && previous?.intent === 'suggestions';
const input =
previous &&
previous.text === question &&
(previous.intent ?? 'question') === (action?.intent ?? 'question') &&
JSON.stringify(previous.references) === JSON.stringify(questionReferences)
previous && (retrySuggestions || (
previous.text === question &&
(previous.intent ?? 'question') === (action?.intent ?? 'question') &&
JSON.stringify(previous.references) === JSON.stringify(questionReferences)
))
? previous
: {
requestId: crypto.randomUUID(), text: question, references: questionReferences,
@@ -352,8 +355,7 @@ export function TeacherChatPanel({
(request) => request.status === 'preparing' || request.status === 'running'
);
const helpUnavailable = busy || !topicEnabled || Boolean(running);
const askForSuggestions = () => void send({ text: '帮我看看', intent: 'suggestions' });
const askForGuidance = () => void send({ text: '我也说不清,你带我看看', intent: 'guided-help' });
const askForSuggestions = () => void send({ text: '给我一点提示', intent: 'suggestions' });
const changeText = (next: string) => { setText(next); persistDraft(next, references, pending.current); };
const removeReferences = () => { setReferences([]); persistDraft(text, [], pending.current); };
const questions = definition?.suggested_questions ?? [];
@@ -401,9 +403,8 @@ export function TeacherChatPanel({
<div className="consultation-follow-ups" aria-label="可以和智能体聊的问题">
{request.suggestedQuestions?.map((question, index) => <button key={`${request.id}:${index}`} type="button" disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-follow-up"><span className="min-w-0 flex-1">{question}</span><ChevronRight className="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" /></button>)}
</div>
<button type="button" disabled={helpUnavailable} onClick={askForGuidance} className="consultation-guided-help min-h-10 text-left text-xs leading-6 underline decoration-current/30 underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50">我也说不清,你带我看看</button>
</>}
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-guided-help min-h-10 text-xs underline underline-offset-4 disabled:opacity-50">再请智能体看看</button>}
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-retry min-h-10 text-xs underline underline-offset-4 disabled:opacity-50">再请智能体看看</button>}
</> : request.response ? <TeacherReply text={request.response} /> : <div className="text-[13px] leading-7">{['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{label}正在想…</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
{request.intent !== 'suggestions' && request.status === 'completed' && Boolean(request.suggestedQuestions?.length) && <div className="consultation-follow-ups" aria-label="接着聊">
{request.suggestedQuestions?.map((question, index) => <button type="button" key={`${request.id}:${index}`} disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-follow-up"><span className="min-w-0 flex-1">{question}</span><ChevronRight className="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" /></button>)}
@@ -443,9 +444,10 @@ export function TeacherChatPanel({
<textarea ref={inputRef} aria-label={`向${label}提问`} value={text} disabled={busy} maxLength={6000} onChange={(e) => changeText(e.target.value)} placeholder="和老师聊聊" rows={2}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void send(); } }}
className="min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" />
<div className="mt-2 flex items-center justify-between gap-2" data-testid="consultation-composer-actions">
{!draftRevision ? <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-help-prompt flex min-h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50">帮我看看<ChevronRight className="h-3 w-3" aria-hidden="true" /></button>
: <span />}
<div className="mt-2 flex min-h-9 items-center justify-between gap-2" data-testid="consultation-composer-actions">
<div className="min-w-0 flex-1">
{!draftRevision && !text.trim() && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-hint flex min-h-9 cursor-pointer items-center gap-1 rounded-md px-1 text-[13px] transition-colors disabled:cursor-not-allowed disabled:opacity-50">给我一点提示<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" /></button>}
</div>
{running ? <button type="button" className="rounded-lg border px-3 py-2 text-xs" onClick={() => void teacherApi.cancel(topicBase, topic!.id, running.id).then(consume).catch(() => setError('暂时无法停止,请稍后重试。'))}>停止回复</button>
: <button type="submit" aria-label="提问" title={busy ? '正在发送…' : '发送'} disabled={busy || !topicEnabled || !text.trim()} className="consultation-send flex h-8 w-8 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
</div>

View File

@@ -49,9 +49,9 @@
.classroom-consultation-resizer { position: absolute; inset: 0 auto 0 -4px; width: 8px; z-index: 2; cursor: col-resize; }
.classroom-consultation-resizer:hover, .classroom-consultation-resizer:focus-visible { background: #7994b533; }
.consultation-pane { background: #fffdf8; color: #30465f; }
.consultation-help-prompt, .consultation-guided-help { color: #536747; }
.consultation-help-prompt { background: #f1f5ea; border: 1px solid #dfe7d5; }
.consultation-help-prompt:hover:not(:disabled) { background: #e6eedc; border-color: #bacba8; }
.consultation-hint { color: #5c7087; background: transparent; border: 0; text-underline-offset: 4px; text-decoration: underline transparent; }
.consultation-hint:hover:not(:disabled) { color: #30465f; text-decoration-color: #a9bbce; }
.consultation-retry { color: #536747; }
.consultation-pane button:focus-visible { outline: 2px solid #71845f; outline-offset: 3px; }
.consultation-suggestion { border-color: #e0e6d6; color: #718163; }
.consultation-suggestion:hover { border-color: #bac9aa; background: #f4f7ee; }

View File

@@ -19,6 +19,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 }] }),
@@ -29,12 +31,14 @@ export const teacherApi = {
events: async () => stream,
send: async (_base: string, _id: string, input: TeacherSend) => {
if ('presentation' in input || 'discussion' in input) throw new Error('Retired component fields must not be sent');
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: '', presentation: 'reply-v1' });
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 = '好,我们就从这一个问题聊起。';
request.response = input.intent === 'suggestions' ? '我们可以先从一个小地方开始。' : '好,我们就从这一个问题聊起。';
if (input.intent === 'suggestions') request.suggestedQuestions = ['可以从哪个小地方开始?'];
snapshot();
}, 350);
return structuredClone(current);

View File

@@ -573,7 +573,7 @@ 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:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
return respond(consultationTopics[consultationPath],202);
}
@@ -1829,15 +1829,20 @@ 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.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})).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.getByRole('button',{name:'给我一点提示',exact:true})).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.getByRole('button',{name:'给我一点提示',exact:true}).click();
await expect(teacher.getByText('我们可以从你最近试过的地方聊起。')).toBeVisible();
await expect(teacher.getByTestId('teacher-discussion')).toHaveCount(0);
await teacher.getByRole('textbox',{name:'向智能体提问'}).fill('还没说完的困惑');
await expect(teacher.getByRole('button',{name:'给我一点提示',exact:true})).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();
@@ -1847,8 +1852,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();
@@ -1870,17 +1874,16 @@ 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);
for (const request of consultationRequests) {
expect(request.body).not.toHaveProperty('presentation');
expect(request.body).not.toHaveProperty('discussion');
}
expect(requests.find(item => item.path.endsWith('/agent-topics') && item.method === 'POST')?.body?.teacherVersion).toBe(9);
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();

View File

@@ -22,6 +22,44 @@ test.beforeAll(async () => {
});
test.afterAll(async () => { await server?.close(); });
for (const width of [319, 508]) {
test(`empty-composer hint stays lightweight 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.getByRole('button', { name: '给我一点提示', exact: true });
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).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
const initialSend = (await send.boundingBox())!;
const hintBounds = (await hint.boundingBox())!;
expect(hintBounds.x).toBeLessThan(initialSend.x);
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-hint-${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[] = [];

View File

@@ -51,7 +51,7 @@ const first = topic('first'),
second = topic('second');
function request(overrides: Partial<TeacherRequest> = {}): TeacherRequest {
return {
id: 'request-1', text: '帮我看看', references: [], createdAt: 'now',
id: 'request-1', text: '给我一点提示', references: [], createdAt: 'now',
sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now',
includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '',
...overrides,
@@ -346,25 +346,38 @@ describe('teacher side chat', () => {
}
});
it('requests contextual suggestions explicitly, hides streaming JSON and preserves the freeform draft', async () => {
it('only requests suggestions from the empty composer and preserves a later draft while streaming', async () => {
const streamingJson = '{"intro":"还没输出完","questions":["尚未确认的问题';
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 2, requests: [suggestionsRequest({ id: input.requestId, status: 'running', response: streamingJson, suggestedQuestions: undefined })],
}));
render(<TeacherChatPanel projectId="p" sourceId="c" />);
const help = screen.getByRole('button', { name: '帮我看看', exact: true });
await waitFor(() => expect(help).toBeEnabled());
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我自己还想问的另一件事' } });
fireEvent.click(help);
const input = screen.getByLabelText('向智能体提问');
const help = () => screen.getByRole('button', { name: '给我一点提示', exact: true });
await waitFor(() => expect(help()).toBeEnabled());
expect(screen.getByTestId('consultation-composer-actions')).toContainElement(help());
expect(api.send).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: '我先自己想想' } });
expect(screen.queryByRole('button', { name: '给我一点提示', exact: true })).not.toBeInTheDocument();
expect(api.send).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: ' ' } });
expect(help()).toBeEnabled();
fireEvent.change(input, { target: { value: '' } });
expect(help()).toBeEnabled();
expect(api.send).not.toHaveBeenCalled();
fireEvent.click(help());
fireEvent.click(help());
await screen.findByText('我看看你最近做到了哪里…');
expect(screen.queryByText(streamingJson)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '尚未确认的问题' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
expect(api.send).toHaveBeenCalledWith('p/agent-topics', 'first', expect.objectContaining({
intent: 'suggestions', text: '帮我看看', sourceConversationId: 'c',
intent: 'suggestions', text: '给我一点提示', sourceConversationId: 'c',
}));
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我自己还想问的另一件事');
expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeDisabled();
expect(help()).toBeDisabled();
fireEvent.change(input, { target: { value: '我自己还想问的另一件事' } });
expect(screen.queryByRole('button', { name: '给我一点提示', exact: true })).not.toBeInTheDocument();
await waitFor(() => expect(streams.has('first')).toBe(true));
const sent = api.send.mock.calls[0][2] as TeacherSend;
act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({
@@ -374,23 +387,21 @@ describe('teacher side chat', () => {
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
expect(screen.queryByText('我看看你最近做到了哪里…')).not.toBeInTheDocument();
for (const question of suggestedQuestions) expect(screen.getByRole('button', { name: question })).toBeEnabled();
expect(screen.getByRole('button', { name: '我也说不清,你带我看看', exact: true })).toBeEnabled();
expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeEnabled();
expect(screen.queryByRole('button', { name: '我也说不清,你带我看看', exact: true })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '给我一点提示', exact: true })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我自己还想问的另一件事');
expect(input).toHaveValue('我自己还想问的另一件事');
});
it.each([
[suggestedQuestions[0], undefined],
['我也说不清,你带我看看', 'guided-help'],
] as const)('sends the selected help action "%s" without replacing the student draft', async (text, intent) => {
const previous = suggestionsRequest();
it.each([suggestedQuestions[0], '我也说不清,你带我看看'])('sends the Agent-generated card "%s" as an ordinary question without replacing the draft', async text => {
const previous = suggestionsRequest({ suggestedQuestions: [text] });
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, intent: input.intent, response: '我们先从你刚才做的那一步看起。' })],
...first, revision: 2, requests: [previous, request({ id: input.requestId, text: input.text, response: '我们先从你刚才做的那一步看起。' })],
}));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" quote={{ kind: 'code', text: '学生正在引用的一段代码' }} />);
await waitFor(() => expect(screen.getByRole('button', { name: text, exact: true })).toBeEnabled());
expect(screen.getAllByRole('button', { name: text, exact: true })).toHaveLength(1);
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '还在整理的自由提问' } });
expect(api.send).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: text, exact: true }));
@@ -399,7 +410,7 @@ describe('teacher side chat', () => {
const sent = api.send.mock.calls[0][2] as TeacherSend;
expect(sent).toMatchObject({ text, sourceConversationId: 'c' });
expect(sent.references).toEqual([]);
expect(sent.intent).toBe(intent);
expect(sent.intent).toBeUndefined();
expect(screen.getByLabelText('向智能体提问')).toHaveValue('还在整理的自由提问');
expect(screen.getByText('学生正在引用的一段代码')).toBeInTheDocument();
view.unmount();
@@ -409,30 +420,79 @@ describe('teacher side chat', () => {
expect(api.send).toHaveBeenCalledTimes(1);
});
it('retries an uncertain suggestions request after reopening without losing the student draft', async () => {
it('keeps an uncertain suggestions request identity through reopening and draft edits', async () => {
api.send.mockRejectedValue(new Error('网络暂不可用'));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeEnabled());
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还没写完的问题' } });
fireEvent.click(screen.getByRole('button', { name: '帮我看看', exact: true }));
await waitFor(() => expect(screen.getByRole('button', { name: '给我一点提示', exact: true })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '给我一点提示', exact: true }));
await screen.findByText('网络暂不可用');
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还没写完的问题' } });
const originalRequest = api.send.mock.calls[0][2] as TeacherSend;
expect(originalRequest.intent).toBe('suggestions');
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeEnabled());
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我还没写完的问题');
expect(screen.queryByRole('button', { name: '给我一点提示', exact: true })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我又补了一点自己的想法' } });
fireEvent.click(screen.getByRole('button', { name: '帮我看看', exact: true }));
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: '给我一点提示', exact: true }));
await screen.findByText('网络暂不可用');
expect(api.send.mock.calls[1][2]).toEqual(originalRequest);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我又补了一点自己的想法');
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
});
it('shows a failed suggestion request without leaking raw JSON and starts a fresh request on another try', async () => {
const failed = suggestionsRequest({ status: 'failed', response: '{"intro":"未完成', suggestedQuestions: undefined, error: '这次没有看清楚,请再试一次。' });
it.each([{ references: undefined }, { references: [{ kind: 'code' as const, text: '旧请求的引用' }] }])('retries an uncertain legacy hint payload unchanged with references $references', async ({ references }) => {
const original: TeacherSend = { requestId: 'legacy-hint-request', text: '帮我看看', intent: 'suggestions', sourceConversationId: 'previous-source', ...(references ? { references } : {}) };
localStorage.setItem('makelore-consultation-draft:signed-out:p:teacher', JSON.stringify({ text: '', references: [], pending: original }));
api.send.mockRejectedValue(new Error('网络暂不可用'));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByRole('button', { name: '给我一点提示', exact: true })).toBeEnabled());
expect(api.send).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '给我一点提示', exact: true }));
await screen.findByText('网络暂不可用');
expect(JSON.parse(JSON.stringify(api.send.mock.calls[0][2]))).toEqual(original);
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByRole('button', { name: '给我一点提示', exact: true })).toBeEnabled());
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '给我一点提示', exact: true }));
await screen.findByText('网络暂不可用');
expect(JSON.parse(JSON.stringify(api.send.mock.calls[1][2]))).toEqual(original);
});
it('does not reuse an uncertain legacy hint request for an ordinary typed question with the same text', async () => {
const original: TeacherSend = { requestId: 'legacy-hint-request', text: '帮我看看', intent: 'suggestions', references: [] };
localStorage.setItem('makelore-consultation-draft:signed-out:p:teacher', JSON.stringify({ text: original.text, references: [], pending: original }));
api.send.mockResolvedValue({ ...first, revision: 2 });
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 waitFor(() => expect(api.send).toHaveBeenCalledTimes(1));
const sent = api.send.mock.calls[0][2] as TeacherSend;
expect(sent.requestId).not.toBe(original.requestId);
expect(sent.text).toBe(original.text);
expect(sent.intent).toBeUndefined();
});
it.each(['completed', 'failed'] as const)('starts a fresh hint when the old hint request is already confirmed %s', async status => {
const original: TeacherSend = { requestId: 'legacy-hint-request', text: '帮我看看', intent: 'suggestions' };
localStorage.setItem('makelore-consultation-draft:signed-out:p:teacher', JSON.stringify({ text: '', references: [], pending: original }));
api.read.mockResolvedValue({ ...first, requests: [suggestionsRequest({ id: original.requestId, text: original.text, status })] });
api.send.mockResolvedValue({ ...first, revision: 2 });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByRole('button', { name: '给我一点提示', exact: true })).toBeEnabled());
expect(api.send).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '给我一点提示', exact: true }));
await waitFor(() => expect(api.send).toHaveBeenCalledTimes(1));
const sent = api.send.mock.calls[0][2] as TeacherSend;
expect(sent.requestId).not.toBe(original.requestId);
expect(sent).toMatchObject({ text: '给我一点提示', intent: 'suggestions', references: [], sourceConversationId: 'c' });
});
it.each(['failed', 'cancelled', 'interrupted'] as const)('retries a %s suggestion request without leaking raw JSON or reusing its accepted request id', async status => {
const failed = suggestionsRequest({ status, response: '{"intro":"未完成', suggestedQuestions: undefined, error: '这次没有看清楚,请再试一次。' });
api.read.mockResolvedValue({ ...first, requests: [failed] });
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 2, requests: [failed, suggestionsRequest({ id: input.requestId })],
@@ -453,11 +513,11 @@ describe('teacher side chat', () => {
...first, revision: 3, requests: [accepted, suggestionsRequest({ id: input.requestId })],
}));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeEnabled());
await waitFor(() => expect(screen.getByRole('button', { name: '给我一点提示', exact: true })).toBeEnabled());
await waitFor(() => expect(streams.has('first')).toBe(true));
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我的自由提问继续留着' } });
fireEvent.click(screen.getByRole('button', { name: '帮我看看', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '给我一点提示', exact: true }));
await screen.findByText('响应中途断开');
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我的自由提问继续留着' } });
const originalRequest = api.send.mock.calls[0][2] as TeacherSend;
accepted = suggestionsRequest({ id: originalRequest.requestId, status: 'failed', response: '', suggestedQuestions: undefined, error: '智能体已接到,但这次回复失败了' });
const recovered = { ...first, revision: 2, requests: [accepted] };
@@ -495,7 +555,7 @@ describe('teacher side chat', () => {
expect(api.send.mock.calls[0][2].intent).toBeUndefined();
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeEnabled();
expect(screen.getByRole('button', { name: '给我一点提示', exact: true })).toBeEnabled();
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" />);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
@@ -583,7 +643,7 @@ describe('teacher side chat', () => {
fireEvent.click(screen.getByRole('button', { name: '运营配置的预览问题' }));
expect(screen.getByLabelText('向智能体提问')).toHaveValue('运营配置的预览问题');
expect(api.send).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '帮我看看', exact: true })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '给我一点提示', exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '智能体新话题' }));
await waitFor(() => expect(api.create).toHaveBeenCalledWith('preview/sample', 7, '学生的练习代码', undefined));
});
@@ -718,9 +778,10 @@ describe('merged cloud teacher classroom', () => {
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByText('智能体暂未开放,历史仍可查看。')).toBeVisible());
const input = screen.getByLabelText('向智能体提问');
expect(screen.getByRole('button', { name: '给我一点提示', exact: true })).toBeDisabled();
fireEvent.change(input, { target: { value: '继续讨论' } });
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeDisabled();
expect(screen.queryByRole('button', { name: '给我一点提示', exact: true })).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: 'Enter' });
expect(api.send).not.toHaveBeenCalled();
});