From bb96941461a9c355cec28a29ad2125df3addb456 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 29 Sep 2026 17:04:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E8=80=81=E5=B8=88=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E9=85=8D=E7=BD=AE=E6=B8=B2=E6=9F=93=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + shared/coding-teacher.ts | 6 +++ src/pages/Chat/TeacherChatPanel.tsx | 10 ++++- tests/e2e/fixtures/teacher-reply-api.ts | 6 +++ tests/e2e/teacher-reply-layout.spec.ts | 29 ++++++++++++++ tests/unit/coding-teacher-ui.test.tsx | 50 +++++++++++++++++++++++++ 6 files changed, 100 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9ae8fe05..7c348439 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。 - 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。 - 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。 +- 输入框为空时显示当前老师已发布的快捷提示,按 Yuxi 中配置的顺序展示文案,点击直接发送该项实际提示词。Yuxi“编辑 → 快捷提示”支持最多 8 项,每项文案最多 40 字、提示词最多 6000 字;保存并发布后,目录更新即可生效,历史和未发送草稿保留。明确发布空列表隐藏全部按钮;缺少字段的历史版本保留“继续看看👀”。多个长文案在限定高度内滚动,运行中禁止重复发送。上线需先更新 WS 登记接口,再更新 Yuxi 和客户端。 - `reply.ts` 只声明正文与快捷回复的输出格式,语言、长度、前缀与表达风格由所选智能体的云端配置决定。客户端不再发送选择、生成、转换或维护展示组件的指令。旧话题内的组件状态、快照和解析诊断作为不透明历史数据原样保留,不校验删除、不恢复交互;旧组件修改路由已移除。 - 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、咨询正文和快捷回复,并支持上述只读原文恢复;旧组件状态、快照和未解析原文不加入模型上下文或咨询历史读取结果。没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。提示词不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。 - 智能体输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。智能体指令、当前问题和明确引用不会被静默裁掉;固定配置或回复协议超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。 diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index 46f6cde3..ffdd641a 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -6,6 +6,11 @@ export const TEACHER_CHECK_IN_INTERVAL_MS = 5 * 60 * 1000; export const TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS = 15 * 60 * 1000; export type TeacherRequestIntent = 'question' | 'suggestions' | 'guided-help' | 'check-in'; +export interface TeacherShortcut { + label: string; + prompt: string; +} + export interface TeacherDefinition { config_id?: string; runtime?: 'local' | 'yuxi'; @@ -18,6 +23,7 @@ export interface TeacherDefinition { icon_url?: string | null; welcome_message: string; suggested_questions: string[]; + shortcuts?: TeacherShortcut[] | null; system_prompt: string; skills: Array<{ id: string; diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index ea6301a3..cd989b21 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -611,8 +611,14 @@ export function TeacherChatPanel({ 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" />
-
- {!draftRevision && !text.trim() && } +
+ {!draftRevision && !text.trim() && (definition?.shortcuts == null + ? + : definition.shortcuts.map((shortcut, index) => ))}
= { 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'; +if (scenario === 'shortcuts') definition.shortcuts = [ + { label: '看看进展', prompt: '请结合当前项目,分析我最近完成的工作。' }, + { label: '解释代码', prompt: '请解释当前项目最近的代码改动,用一个具体例子帮助我理解。' }, + ...Array.from({ length: 6 }, (_, i) => ({ label: `入口${i + 3}:看看我的项目进展,帮我理解当前代码并提出下一步的编程建议`, prompt: `请讨论第 ${i + 3} 个主题。` })), +]; +if (scenario === 'shortcuts-empty') definition.shortcuts = []; export const fixtureAgent = { teacher_id: definition.teacher_id, version: 1, is_default: true, definition }; const continuous = new URLSearchParams(location.search).get('chat') !== 'legacy'; 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: [], 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]) } } : {}) }; diff --git a/tests/e2e/teacher-reply-layout.spec.ts b/tests/e2e/teacher-reply-layout.spec.ts index e6fbebe1..2668402d 100644 --- a/tests/e2e/teacher-reply-layout.spec.ts +++ b/tests/e2e/teacher-reply-layout.spec.ts @@ -47,6 +47,35 @@ test('retry stickers stay readable and preserve drafts in a 319px consultation', }); for (const width of [319, 508]) { + test(`published shortcuts send their prompts and keep eight long labels scrollable at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width: 1180, height: 800 }); + await page.goto(`${url}?kind=shortcuts&width=${width}`); + const panel = page.getByTestId('teacher-chat-panel'); + const shortcuts = page.locator('form .consultation-hint'); + await expect(shortcuts).toHaveCount(8); + await expect(shortcuts.first()).toHaveText('看看进展'); + await expect(shortcuts.nth(1)).toHaveText('解释代码'); + await expect(shortcuts.nth(1)).toBeEnabled(); + const form = (await panel.locator('form').boundingBox())!; + expect(form.y + form.height).toBeLessThanOrEqual(800); + expect(await panel.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); + await shortcuts.last().scrollIntoViewIfNeeded(); + await panel.screenshot({ path: test.info().outputPath(`shortcuts-${width}.png`) }); + await shortcuts.last().click(); + await expect(page.getByText('请讨论第 8 个主题。', { exact: true })).toBeVisible(); + await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible(); + await shortcuts.nth(1).click(); + await expect(page.getByText('请解释当前项目最近的代码改动,用一个具体例子帮助我理解。', { exact: true })).toBeVisible(); + const inputs = await page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs); + expect(inputs).toEqual([ + { requestId: expect.any(String), text: '请讨论第 8 个主题。', references: [], projectId: 'fixture', sourceConversationId: 'source' }, + { requestId: expect.any(String), text: '请解释当前项目最近的代码改动,用一个具体例子帮助我理解。', references: [], projectId: 'fixture', sourceConversationId: 'source' }, + ]); + await page.goto(`${url}?kind=shortcuts-empty&width=${width}`); + await expect(page.getByRole('textbox', { name: '向智能体提问' })).toBeEnabled(); + await expect(page.locator('form .consultation-hint')).toHaveCount(0); + }); + for (const chat of ['legacy', 'continuous']) test(`quoted ${chat} 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); diff --git a/tests/unit/coding-teacher-ui.test.tsx b/tests/unit/coding-teacher-ui.test.tsx index 86d6e345..1a233c5a 100644 --- a/tests/unit/coding-teacher-ui.test.tsx +++ b/tests/unit/coding-teacher-ui.test.tsx @@ -101,6 +101,56 @@ async function ready() { await waitFor(() => expect(streams.has('first')).toBe(true)); } describe('teacher side chat', () => { + it('renders published shortcut labels in order and sends each actual prompt with independent retry identity', async () => { + const shortcuts = [{ label: '看看进展', prompt: '请分析当前项目的进展。' }, { label: '解释代码', prompt: '请解释刚才的代码改动。' }]; + const agent = { teacher_id: 'shortcuts', version: 1, is_default: true, definition: { ...definition, config_id: 'shortcuts', shortcuts } }; + api.catalog.mockResolvedValue({ items: [agent] }); + api.sendConversation.mockRejectedValue(new Error('结果暂未确认')); + render(); + await waitFor(() => expect(screen.getByRole('button', { name: '看看进展' })).toBeEnabled()); + expect([...screen.getByTestId('consultation-composer-actions').querySelectorAll('.consultation-hint')].map(el => el.textContent)).toEqual(shortcuts.map(item => item.label)); + expect(screen.queryByRole('button', { name: '继续看看👀' })).not.toBeInTheDocument(); + expect(screen.queryByText(shortcuts[0].prompt)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '看看进展' })); + await screen.findByText('结果暂未确认'); + const original = api.sendConversation.mock.calls[0][2]; + expect(original).toMatchObject({ text: shortcuts[0].prompt, projectId: 'p', sourceConversationId: 'c', references: [] }); + expect(original.intent).toBeUndefined(); + fireEvent.click(screen.getByRole('button', { name: '看看进展' })); + await waitFor(() => expect(api.sendConversation).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByRole('button', { name: '解释代码' })).toBeEnabled()); + expect(api.sendConversation.mock.calls[1][2]).toEqual(original); + fireEvent.click(screen.getByRole('button', { name: '解释代码' })); + await waitFor(() => expect(api.sendConversation).toHaveBeenCalledTimes(3)); + expect(api.sendConversation.mock.calls[2][2].text).toBe(shortcuts[1].prompt); + expect(api.sendConversation.mock.calls[2][2].requestId).not.toBe(original.requestId); + }); + + it('updates published shortcuts without replacing chat history or a composed draft, and supports clearing the list', async () => { + const old = { teacher_id: 'shortcut-refresh', version: 1, is_default: true, definition: { ...definition, config_id: 'shortcut-refresh', shortcuts: [{ label: '旧入口', prompt: '旧提示词' }] } }; + const conversation = { ...topic('shortcut-chat'), definition: old.definition, + conversation: { agentId: old.teacher_id, segmentTurns: 1, discussions: {}, seenThrough: 'past' }, + requests: [request({ id: 'past', text: '之前的问题', response: '之前的回答' })] }; + api.conversation.mockResolvedValue({ topic: conversation, before: null }); + const state = (agent: typeof old) => ({ catalog: { revision: agent.version, items: [agent] }, config: { enabled: true, published_version: agent.version, revision: agent.version, definition: agent.definition } }); + const view = render(); + await screen.findByText('之前的回答'); + await waitFor(() => expect(screen.getByRole('button', { name: '旧入口' })).toBeEnabled()); + fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '尚未发出的草稿' } }); + const updated = { ...old, version: 2, definition: { ...old.definition, shortcuts: [{ label: '新入口', prompt: '新的实际提示词' }] } }; + view.rerender(); + expect(screen.getByLabelText('向智能体提问')).toHaveValue('尚未发出的草稿'); + expect(screen.queryByRole('button', { name: '新入口' })).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '' } }); + expect(screen.getByRole('button', { name: '新入口' })).toBeVisible(); + expect(screen.queryByRole('button', { name: '旧入口' })).not.toBeInTheDocument(); + const cleared = { ...updated, version: 3, definition: { ...updated.definition, shortcuts: [] } }; + view.rerender(); + expect(screen.getByTestId('consultation-composer-actions').querySelectorAll('.consultation-hint')).toHaveLength(0); + expect(screen.getByText('之前的回答')).toBeVisible(); + expect(api.sendConversation).not.toHaveBeenCalled(); + }); + it('opens directly into the consultation and puts new topics inside expanded history', async () => { render(); const history = await screen.findByRole('button', { name: '以往讨论' });