From cafebca05710b7bf1a6fa62ec51be4992a73a18c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Mon, 28 Sep 2026 18:14:12 +0800 Subject: [PATCH] fix(chat): reconcile reply recovery with continuous agent conversations --- .../20260928-latest-main-merge-b6a42f90.md | 19 +++++-- README.md | 6 +-- electron/coding-teacher/conversation-store.ts | 27 +++++++--- electron/coding-teacher/reply.ts | 3 +- electron/coding-teacher/store.ts | 6 ++- shared/coding-teacher.ts | 2 + src/pages/Chat/TeacherChatPanel.tsx | 20 ++++---- tests/e2e/fixtures/teacher-reply-api.ts | 13 ++++- tests/e2e/fixtures/teacher-reply-layout.tsx | 4 +- tests/e2e/teacher-reply-layout.spec.ts | 8 +-- tests/unit/teacher-companion.test.tsx | 4 +- .../unit/teacher-conversation-panel.test.tsx | 31 ++++++++++++ tests/unit/teacher-conversation.test.ts | 50 +++++++++++++++++++ 13 files changed, 159 insertions(+), 34 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20260928-latest-main-merge-b6a42f90.md b/.project-docs/30-worklog/tasks/20260928-latest-main-merge-b6a42f90.md index e50dfbb2..4386cd4a 100644 --- a/.project-docs/30-worklog/tasks/20260928-latest-main-merge-b6a42f90.md +++ b/.project-docs/30-worklog/tasks/20260928-latest-main-merge-b6a42f90.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-latest-main-merge-b6a42f90 - Base commit: a21a1f077cc11976213290a6b843bb74be97ab44 - Owner: codex -- Status: Planning +- Status: Ready for Integration ## Scope @@ -20,19 +20,28 @@ ## Outcome -- Not completed. +- Merged completed product sources with merge ancestry preserved: reply cleanup/recovery `d530abe` (including `40c247a`) via `d032425`; cloud-owned teaching policy and 0–3 new shortcuts `54a40d2` via `65a0c21`; welcome dismissal `c96bb45` via `44b16f8`. Base `a21a1f0` already contains continuous account/Agent conversations, manual refresh and hover UI. +- Adapted recovery to per-turn continuous storage, including older pages and read-only history tools; uncertain legacy index excerpts cannot leak partial answers. Display repair leaves completed turn files unchanged, including when another turn needs restart recovery. +- Incomplete replies retry only on deliberate click, retaining the original problem/references and both drafts. Continuous retries require the original project/source; each newly accepted turn still resolves the current published version. Legacy pinned-version behavior remains compatible. +- Persisted rejection of excessive new shortcuts so a later history repair cannot restore them; historical cards remain preserved. Removed retired discussion rendering/state updates while retaining opaque source archives. Welcome dismissal follows current account-wide scope. +- Refreshed README and combined unit/browser/native-E2E fixtures. Pending only normal main publication and remote identity confirmation; no app restart, model call, dependency change or worktree cleanup. ## Verification -- Not run. +- Passed: 405 focused unit tests in 14 files, covering parser, history, service, continuous conversations, UI, cloud/model/read contracts, refresh, welcome and preview. Command: `corepack pnpm exec vitest run tests/unit/teacher-reply.test.ts tests/unit/teacher-reply-history.test.ts tests/unit/coding-teacher-ui.test.tsx tests/unit/teacher-conversation.test.ts tests/unit/teacher-conversation-panel.test.tsx tests/unit/coding-teacher.test.ts tests/unit/teacher-companion.test.tsx tests/unit/coding-teacher-cloud.test.ts tests/unit/coding-teacher-model.test.ts tests/unit/coding-teacher-read-tools.test.ts tests/unit/teacher-guidance.test.ts tests/unit/teacher-retired-discussion-route.test.ts tests/unit/teacher-cloud-activity.test.ts tests/unit/agent-browser-panel.test.tsx --maxWorkers=2`. +- Passed: 28 headless browser cases via installed Chrome, including legacy/continuous quote recovery and retry at 319px/508px, draft retention, rich reply containment, archived component suppression and preview layout. Command: `MAKELORE_LAYOUT_BROWSER_CHANNEL=chrome corepack pnpm exec playwright test tests/e2e/teacher-reply-layout.spec.ts tests/e2e/teacher-preview-layout.spec.ts --max-failures=1`. +- Passed: `corepack pnpm run typecheck`, scoped ESLint for changed implementation/tests, `corepack pnpm run build:vite`, and `git diff --check`. Native Electron E2E additions parse/list successfully but were not executed because the fixture opens a foreground app window; the current client was left running unchanged. +- Passed: required project-document structure; every selected source is an ancestor of the merge candidate. Re-fetched remote main remains `a21a1f0`. +- Drift checker reports the four committed source-task documents as foreign relative to the task's original base. These are imported by the explicitly requested source merges, not edited by this task. Byte comparison against `d530abe`, `54a40d2`, `40c247a` and `c96bb45` confirms all four unchanged; only this task's own record is authored here. No canonical document changes, altered baseline or ownership override used to suppress this inherited checker finding. ## Follow-ups -- None recorded. +- Confirm normal push and local/remote main identity, retaining all source and integration worktrees. +- Full native Electron interaction execution and release packaging remain outside this merge validation; no claim of a rebuilt running client. ## Promotion Candidates -- None recorded. +- A later canonical-memory integration can reconcile retired discussion UI descriptions with the current README and continuous-chat/reply-recovery contracts; this task does not hold that integration lock or edit those canonical sources. ## Project Context Loaded diff --git a/README.md b/README.md index 390f9d18..3e033951 100644 --- a/README.md +++ b/README.md @@ -173,12 +173,12 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 鼠标悬停或键盘聚焦顶部智能体入口时,以奶油色圆角卡片显示该智能体的发布介绍;离开、Escape 或点击入口后收起,没有介绍则不显示空卡片。咨询栏不再显示重复的头像、名称标题条,也不显示介绍及新话题、展开、关闭三个图标;老师身份保留在顶部入口及消息署名中。智能体生成的追加问题以淡蓝、奶油黄、淡紫卡片依次承载,正文 16px、行高 1.8,保留整卡点击发送与忙碌时禁用。回复下方不再显示重复的输入引导或省略早期消息条数;输入框提示为“和老师聊聊”。再次点击当前入口或在咨询栏按 Escape 可收起,切换其他入口仍直接打开对应讨论;拖动边缘调宽保留,已下发智能体仅保留只读旧记录入口。悬浮介绍不会创建话题、调用模型或确认未读消息,作品原生预览同时避让介绍卡和真实主动气泡。 - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 - 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 -- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;点击智能体进入咨询时立即收起欢迎语,并按账号/项目记住已查看,关闭、重新打开或刷新后不重复弹出;主动收起也保留同样记录。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 +- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;点击智能体进入咨询时立即收起欢迎语,并按账号记住已查看,关闭、重新打开、切换项目或刷新后不重复弹出;主动收起也保留同样记录。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 - 智能体支持自由提问;输入框为空时,底部显示暖黄色小按钮“继续看看👀”,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,程序说明学生主动求助且尚未提出具体问题,智能体结合可用上下文按云端配置回应。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接;失败或中断后,可点暖黄色小按钮“继续看看👀”重试。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。 - 客户端只提供入口事实、可用上下文和界面协议。讨论切入点、教学方式、是否提问、正文长度、前缀和表达风格由所选智能体的云端配置决定;不在“继续看看👀”、引导开口或后台检查时附加教学策略。当前新回复允许 0–3 条快捷回复,没有时用空数组,不要求凑满。 - Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后展示正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。新输出超过 3 条快捷回复时保留完整正文、提示格式错误并保留原始回答,不展示超额卡片或静默截取;历史中已有的更多卡片在读取与后续保存时仍原样保留。独立资源上限只用于防护,不做旧式字数截断;格式错误的原文默认折叠、字面显示,不再次送进模型。 -- 回复中的引号或换行格式有误时,仅在正文边界明确、修复后整体合法的情况下恢复完整文字,不把半句话当成完整回答。附加建议损坏时仍展示完整正文;正文无法确认完整时显示“这次回复未能完整显示”,由学生点击“重新回答”,沿用原问题、引用、来源、话题和发布版本,保留当前草稿,连续点击不重复发送。打开旧话题会从保留的原文中在本地恢复可确认的正文,不调用模型,不因显示修复而改写磁盘记录;原始内容继续折叠保留。不完整的回答和建议不进入后续模型上下文。 +- 回复中的引号或换行格式有误时,仅在正文边界明确、修复后整体合法的情况下恢复完整文字,不把半句话当成完整回答。附加建议损坏时仍展示完整正文;正文无法确认完整时显示“这次回复未能完整显示”,由学生点击“重新回答”,沿用原问题、引用与来源;持续聊天须回到原项目和操作对话后重试,新一轮按当前发布版本执行,旧话题仍使用绑定版本,保留当前草稿,连续点击不重复发送。打开聊天或旧话题会从保留的原文中在本地恢复可确认的正文,不调用模型,不因显示修复而改写磁盘记录;原始内容继续折叠保留。不完整的回答和建议不进入后续模型上下文。 - 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。 - 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。 - 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。 @@ -187,7 +187,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 智能体输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。智能体指令、当前问题和明确引用不会被静默裁掉;固定配置或回复协议超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。 - `tests/fixtures/teacher-guidance-examples.json` 保留人工样例作为旧回复的读取兼容资料,仅验证正文和快捷回复投影,不加载到运行时,也不代表真实模型教学效果已验收。云端定义由 Yuxi 维护并由运营显式同步下发;本地代码修改不会自动改写云端配置或学生已有话题的固定发布版本。 - 下发智能体的图标随发布版本保存,顶栏、咨询面板、欢迎区和消息使用定义中的 `icon_url`;未设置或图片加载失败时使用内置头像。运营在 Yuxi 更换图标后需同步下发配置,联系人使用当前下发的名称和图标;每轮执行仍记录实际发布版本。相对图片地址由服务端使用 Yuxi 公网入口补全。 -- 咨询聊天和文字草稿按当前云端账号、已下发智能体保存;切换项目或操作对话仍延续同一个聊天,每轮发送冻结当时的项目、来源和发布版本并检查启停。引用保留原项目/来源,切换后必须移除失配引用再发送;旧项目卡片不能操作当前项目。历史按50轮分页,流式事件只传当前轮;本机每轮独立原子落盘,索引保存消息摘要,旧轮次可通过只读工具按ID读取。可确认归属的旧话题按来源ID幂等迁入,原文件保留;“旧记录”入口只读查看升级前项目/来源话题。项目、Pi来源或版本变化时切换内部云线程并带入有预算的近期交流,同一段由Yuxi原生历史压缩管理上下文。此版聊天历史在本机保存,不提供跨设备同步。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。云端断线按游标和持久状态恢复;停止或退出账号会取消原云端问题,下次提问先停止旧的未完成问题再绑定新上下文。只接收云端主线程回答,子智能体文本不混入正文。没有操作对话时也可咨询,但无操作文本上下文。 +- 咨询聊天和文字草稿按当前云端账号、已下发智能体保存;切换项目或操作对话仍延续同一个聊天,每轮发送冻结当时的项目、来源和发布版本并检查启停。引用保留原项目/来源,切换后必须移除失配引用再发送;旧组件仅保留原始归档,不恢复交互。历史按50轮分页,流式事件只传当前轮;本机每轮独立原子落盘,索引保存消息摘要,旧轮次可通过只读工具按ID读取。可确认归属的旧话题按来源ID幂等迁入,原文件保留;“旧记录”入口只读查看升级前项目/来源话题。项目、Pi来源或版本变化时切换内部云线程并带入有预算的近期交流,同一段由Yuxi原生历史压缩管理上下文。此版聊天历史在本机保存,不提供跨设备同步。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。云端断线按游标和持久状态恢复;停止或退出账号会取消原云端问题,下次提问先停止旧的未完成问题再绑定新上下文。只接收云端主线程回答,子智能体文本不混入正文。没有操作对话时也可咨询,但无操作文本上下文。 - 删除操作对话会停止正在使用该来源的回复,保留项目级咨询历史;归档来源需先恢复后才能引用。原有来源会话级智能体 API 与存储保持兼容,原路径历史不自动迁移到新项目级咨询栏。运营草稿试聊仍使用 `niancode://coding-teacher/preview?draft_revision=N` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。 - 旧版客户端生成的朋友话题在“以往讨论”中标记为仅查看,不再创建或继续调用,也不自动绑定某个云端智能体;可选择下发项进入唯一聊天。统一咨询保留原有项目草稿;旧版朋友未发送的文字和引用可从“查看旧版朋友草稿”只读查看,不自动提交。旧文件和原发布版本保留,不复制或改写历史。已有 `coding-teacher` 云端契约及历史目录名保留,避免无必要的数据迁移。 diff --git a/electron/coding-teacher/conversation-store.ts b/electron/coding-teacher/conversation-store.ts index 7bcd1847..7f0143d1 100644 --- a/electron/coding-teacher/conversation-store.ts +++ b/electron/coding-teacher/conversation-store.ts @@ -5,9 +5,9 @@ import type { TeacherDefinition, TeacherHistoryPage, TeacherRequest, TeacherTopi import { excerptTeacherText, teacherHistoryMessages } from './context'; import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json'; import { TeacherError } from './config-client'; -import { teacherTopicId } from './store'; +import { repairCompletedReply, teacherTopicId } from './store'; -interface TurnIndex { id: string; createdAt: string; origin?: string; messages: ReturnType } +interface TurnIndex { replyProjectionVersion?: 1; id: string; createdAt: string; origin?: string; messages: ReturnType } interface Manifest { topic: Omit; turns: TurnIndex[]; @@ -18,8 +18,13 @@ const isMissing = (error: unknown) => (error as NodeJS.ErrnoException)?.code === const originKey = (request: TeacherRequest) => request.origin ? [request.origin.projectId, request.origin.topicId, request.origin.requestId].join(':') : undefined; const order = (a: TurnIndex, b: TurnIndex) => a.createdAt.localeCompare(b.createdAt) || (a.origin ?? a.id).localeCompare(b.origin ?? b.id); -const indexTurn = (turn: TeacherRequest): TurnIndex => ({ id: turn.id, createdAt: turn.createdAt, origin: originKey(turn), - messages: teacherHistoryMessages([turn]).map(message => ({ ...message, text: excerptTeacherText(message.text, 180).replaceAll('\n', ' ') })) }); +const indexTurn = (turn: TeacherRequest): TurnIndex => { + const projection = structuredClone(turn); + repairCompletedReply(projection); + return { id: turn.id, createdAt: turn.createdAt, origin: originKey(turn), replyProjectionVersion: 1, + messages: teacherHistoryMessages([projection]).map(message => ({ ...message, + text: excerptTeacherText(message.text, 180).replaceAll('\n', ' ') })) }; +}; /** A small index and one atomic file per turn. Only the latest page is held live. */ export class TeacherConversationStore { @@ -64,17 +69,23 @@ export class TeacherConversationStore { turn.status = 'interrupted'; turn.error = '应用已重启,本次回复中断。'; await atomicWriteJson(this.turnPath(turn.id), turn); + const index = manifest.turns.findIndex(item => item.id === turn.id); + if (index >= 0) manifest.turns[index] = indexTurn(turn); recovered = true; } if (recovered) { this.live.revision++; - await this.save(this.live); + // Only restart metadata changes here; completed display repairs stay in memory. + await this.save(this.live, ''); } } private async turn(id: string) { const turn = await readJsonFile(this.turnPath(id)) as TeacherRequest; if (turn.id !== id || !Array.isArray(turn.references)) throw new TeacherError(409, 'teacher_history_invalid', '智能体消息无法读取,请保留本机记录。'); + repairCompletedReply(turn); + const index = this.manifest?.turns.findIndex(item => item.id === turn.id) ?? -1; + if (this.manifest && index >= 0) this.manifest.turns[index] = indexTurn(turn); return turn; } async exists() { await this.load(); return Boolean(this.manifest); } @@ -113,7 +124,11 @@ export class TeacherConversationStore { const entries = structuredClone(this.manifest?.turns ?? []); const owners = new Map(entries.flatMap(turn => turn.messages.map(message => [message.id, turn.id] as const))); return { - messages: entries.flatMap(turn => turn.messages), + // Older indexes may contain the unsafe quoted prefix. Keep them lazy: + // expose an ID for reading, not an unverified assistant excerpt. + messages: entries.flatMap(turn => turn.messages.map(message => + turn.replyProjectionVersion === 1 || message.role !== 'assistant' ? message + : { ...message, text: '(按消息 ID 读取完整回复)' })), read: async (id: string) => { const owner = owners.get(id); return owner ? teacherHistoryMessages([await this.turn(owner)]).find(message => message.id === id) : undefined; diff --git a/electron/coding-teacher/reply.ts b/electron/coding-teacher/reply.ts index 654fa4b7..323a99ff 100644 --- a/electron/coding-teacher/reply.ts +++ b/electron/coding-teacher/reply.ts @@ -13,9 +13,10 @@ export function applyTeacherReply(request: TeacherRequest, raw: string): void { // Apply the current UI contract only to new replies; archived replies keep // their original cards through the compatibility parser and topic store. if (parsed.quickReplies.length > MAX_CURRENT_QUICK_REPLIES) { + request.replyQuickRepliesRejected = true; parsed.quickReplies = []; parsed.parseError ??= '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。'; - } + } else delete request.replyQuickRepliesRejected; request.response = parsed.reply; request.suggestedQuestions = parsed.quickReplies; if (parsed.incomplete) request.replyIncomplete = true; diff --git a/electron/coding-teacher/store.ts b/electron/coding-teacher/store.ts index 50bd6b9a..c68ac768 100644 --- a/electron/coding-teacher/store.ts +++ b/electron/coding-teacher/store.ts @@ -6,12 +6,16 @@ import { isTeacherReplyEnvelope, parseTeacherReply } from '../../shared/teacher- import { TeacherError } from './config-client'; /** Repair only received, completed protocol replies; never resume a model request. */ -function repairCompletedReply(request: TeacherRequest): void { +export function repairCompletedReply(request: TeacherRequest): void { const raw = request.unparsedResponse; if (request.status !== 'completed' || typeof raw !== 'string' || !(request.discussionError || request.replyParseError || request.replyIncomplete) || !isTeacherReplyEnvelope(raw)) return; const parsed = parseTeacherReply(raw); + if (request.replyQuickRepliesRejected) { + parsed.quickReplies = []; + parsed.parseError ??= request.replyParseError; + } if (parsed.incomplete) { // Keep the previous text and diagnostics as evidence, but do not present a // legacy half-sentence as a complete reply. diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index d3d9dcc8..88dff210 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -82,6 +82,8 @@ export interface TeacherRequest { replyParseError?: string; /** No trustworthy complete reply could be recovered from the received text. */ replyIncomplete?: boolean; + /** New-output shortcut rejection survives later historical prose repair. */ + replyQuickRepliesRejected?: boolean; /** Original answer retained for failed or repaired parsing; never fed back as context. */ unparsedResponse?: string; toolActivity?: TeacherToolActivity[]; diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index 8d692eed..0210d8cd 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { ArrowUp, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; import { useAuthStore } from '@/stores/auth'; import { teacherApi, teacherTopicsPath, legacyTopicBase, type TeacherCatalogState } from '@/lib/coding-teacher'; @@ -101,9 +101,7 @@ export function TeacherChatPanel({ const focusedOnce = useRef(false); const [topics, setTopics] = useState({ items: [], lastSelectedTopicId: null }); const [storedTopic, setTopic] = useState(cachedView?.topic ?? null); - const topic = useMemo(() => storedTopic && continuous ? { ...storedTopic, - discussion: storedTopic.projectId === projectId ? storedTopic.discussion : storedTopic.conversation?.discussions[projectId] } - : storedTopic, [storedTopic, continuous, projectId]); + const topic = storedTopic; const legacyFriend = topic?.role === 'friend'; const legacyView = Boolean(continuous && topic && !topic.conversation); const viewingLegacy = useRef(false); @@ -383,15 +381,18 @@ export function TeacherChatPanel({ if (version === generation.current) setBusy(false); } }; + const retryScopeMatches = (request: TeacherRequest) => !continuous + || (request.projectId === projectId && (request.sourceConversationId ?? '') === sourceId); const send = async (action?: Pick, retry?: TeacherRequest) => { // A historical retry stays with its original topic and published Agent. - if (retry && (topicRef.current?.id !== topic?.id || topic?.projectId !== projectId + if (retry && (topicRef.current?.id !== topic?.id || !topic || !retryScopeMatches(retry) + || (!continuous && topic.projectId !== projectId) || (selectedAgentId && topic.definition.config_id !== selectedAgentId) || !topic.requests.some((request) => request === retry && request.status === 'completed' && request.replyIncomplete))) return; const question = action?.text ?? text; const questionReferences = retry?.references ?? (action ? [] : references); const questionSourceId = retry - ? retry.sourceConversationId ?? (topic?.sourceConversationId === 'project' ? undefined : topic?.sourceConversationId) + ? retry.sourceConversationId ?? (continuous || topic?.sourceConversationId === 'project' ? undefined : topic?.sourceConversationId) : sourceId; if (!question.trim() || busy || sendInFlight.current || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return; if (continuous && !retry && questionReferences.length && (referenceScope.projectId !== projectId || referenceScope.sourceId !== sourceId)) { @@ -423,8 +424,8 @@ export function TeacherChatPanel({ // Copy changes must not create another charge for an uncertain hint request. const retrySuggestions = !retry && action?.intent === 'suggestions' && previous?.intent === 'suggestions'; const input = - previous && (!continuous || previous.projectId === projectId) - && previous.sourceConversationId === (questionSourceId || undefined) && (retrySuggestions || ( + previous && (!continuous || (previous.projectId === projectId + && previous.sourceConversationId === (questionSourceId || undefined))) && (retrySuggestions || ( previous.text === question && (previous.intent ?? 'question') === (action?.intent ?? 'question') && JSON.stringify(previous.references) === JSON.stringify(questionReferences) && @@ -523,7 +524,8 @@ export function TeacherChatPanel({ } {request.status === 'completed' && request.replyIncomplete ?

这次回复未能完整显示

- {request.intent !== 'check-in' && request.text.trim() && } + {request.intent !== 'check-in' && request.text.trim() && } + {!retryScopeMatches(request) &&

回到原项目和操作对话后,可以重新回答。

}
: request.intent === 'suggestions' ? <> {['running', 'preparing'].includes(request.status) ?

我看看你最近做到了哪里…

diff --git a/tests/e2e/fixtures/teacher-reply-api.ts b/tests/e2e/fixtures/teacher-reply-api.ts index e7c742e7..684fcd68 100644 --- a/tests/e2e/fixtures/teacher-reply-api.ts +++ b/tests/e2e/fixtures/teacher-reply-api.ts @@ -1,7 +1,7 @@ // Deterministic responses for layout/interaction tests only; never imported by the app. import type { TeacherDefinition, TeacherTopic, TeacherSend } from '../../../shared/coding-teacher'; import { parseTeacherReply } from '../../../shared/teacher-reply'; -const definition: TeacherDefinition = { schema_version: 1, teacher_id: 'coding-teacher', name: '麦洛智能体', description: '', avatar_id: 'avatar-01', welcome_message: '', suggested_questions: [], system_prompt: '测试', skills: [], model: { model_id: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } }; +const definition: TeacherDefinition = { config_id: 'coding-teacher', 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 = { 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,14 +9,17 @@ const samples: Record = { 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'; +export const fixtureAgent = { teacher_id: definition.teacher_id, version: 1, is_default: true, definition }; +const continuous = new URLSearchParams(location.search).get('chat') === 'continuous'; let current: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'discussion-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 0, seq: i }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: i === 7 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(i === 7 ? { suggestedQuestions: ['有什么好玩的想法?'] } : {}) })), ...(samples[scenario] ? { discussion: { id: 'tool', revision: 1, status: 'active', content: structuredClone(samples[scenario]) } } : {}) }; +if (continuous) current.conversation = { agentId: definition.teacher_id, segmentTurns: 1, discussions: {} }; const stream = Object.assign(new EventTarget(), { close() {}, onerror: null, onopen: null }); if (scenario === 'recovered' || scenario === 'incomplete') { const raw = '{"reply":"它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在的阶段是「能玩,正在调手感」。' + (scenario === 'recovered' ? '","quickReplies":[],"tool":{"kind":"structure","nodes":[]}}' : ''); const parsed = parseTeacherReply(raw); current.requests = [{ - ...current.requests[0], text: '现在项目是什么阶段?', sourceConversationId: 'original-source', + ...current.requests[0], projectId: 'fixture', text: '现在项目是什么阶段?', sourceConversationId: 'original-source', references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }], response: parsed.reply, replyIncomplete: parsed.incomplete, replyParseError: parsed.parseError, suggestedQuestions: parsed.quickReplies, unparsedResponse: raw, @@ -42,6 +45,12 @@ 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 = { + conversation: async () => ({ topic: structuredClone(current), before: null }), + seenConversation: async (_agentId: string, requestId: string) => { + if (current.conversation) current.conversation.seenThrough = requestId; + return structuredClone(current); + }, + sendConversation: async (_agentId: string, input: TeacherSend): Promise => teacherApi.send('/fixture', current.id, input), 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 }), diff --git a/tests/e2e/fixtures/teacher-reply-layout.tsx b/tests/e2e/fixtures/teacher-reply-layout.tsx index af95b51d..71e2c905 100644 --- a/tests/e2e/fixtures/teacher-reply-layout.tsx +++ b/tests/e2e/fixtures/teacher-reply-layout.tsx @@ -1,13 +1,15 @@ 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 continuous = new URLSearchParams(location.search).get('chat') === 'continuous'; const width = Number(new URLSearchParams(location.search).get('width')) || 508; createRoot(document.getElementById('root')!).render(
智能体回复交互测试 · 真实页面组件 / 模拟回复

我的小狗世界

作品区域

-
+
); diff --git a/tests/e2e/teacher-reply-layout.spec.ts b/tests/e2e/teacher-reply-layout.spec.ts index 0a5aaaff..4b9e4d63 100644 --- a/tests/e2e/teacher-reply-layout.spec.ts +++ b/tests/e2e/teacher-reply-layout.spec.ts @@ -47,10 +47,10 @@ test('retry stickers stay readable and preserve drafts in a 319px consultation', }); for (const width of [319, 508]) { - test(`quoted replies recover fully and incomplete replies retry without losing the draft at ${width}px`, async ({ page }) => { + 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); - await page.goto(`${url}?kind=recovered&width=${width}`); + await page.goto(`${url}?kind=recovered&width=${width}&chat=${chat}`); const panel = page.getByTestId('teacher-chat-panel'); const body = page.getByTestId('teacher-reply'); await expect(body).toContainText('它早就不是"刚搭好架子"那种阶段了'); @@ -61,7 +61,7 @@ for (const width of [319, 508]) { expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); await panel.screenshot({ path: test.info().outputPath(`recovered-${width}.png`) }); - await page.goto(`${url}?kind=incomplete&width=${width}`); + await page.goto(`${url}?kind=incomplete&width=${width}&chat=${chat}`); await expect(page.getByRole('status')).toHaveText('这次回复未能完整显示'); await expect(page.getByTestId('teacher-reply')).toHaveCount(0); expect(await sent()).toEqual([]); @@ -76,7 +76,7 @@ for (const width of [319, 508]) { await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible(); await expect(input).toHaveValue('我还在写的新想法'); expect(await sent()).toEqual([{ - requestId: expect.any(String), text: '现在项目是什么阶段?', + requestId: expect.any(String), text: '现在项目是什么阶段?', ...(chat === 'continuous' ? { projectId: 'fixture' } : {}), sourceConversationId: 'original-source', references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }], }]); }); diff --git a/tests/unit/teacher-companion.test.tsx b/tests/unit/teacher-companion.test.tsx index bcb6b1ea..31ec78b8 100644 --- a/tests/unit/teacher-companion.test.tsx +++ b/tests/unit/teacher-companion.test.tsx @@ -468,7 +468,7 @@ describe('teacher presence and periodic guidance', () => { expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); }); - it('consumes the static greeting when consultation opens through the avatar and remembers it for the project', async () => { + it('consumes the static greeting when consultation opens through the avatar and remembers it across projects', async () => { const greeting = '有什么编程问题想一起讨论?'; api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: greeting } }); const view = await mount(); @@ -486,7 +486,7 @@ describe('teacher presence and periodic guidance', () => { const next = await mount(); expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); await act(async () => next.rerender()); - expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'welcome'); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); expect(api.create).not.toHaveBeenCalled(); expect(api.send).not.toHaveBeenCalled(); expect(api.checkIn).not.toHaveBeenCalled(); diff --git a/tests/unit/teacher-conversation-panel.test.tsx b/tests/unit/teacher-conversation-panel.test.tsx index 0cf82b74..272cbf65 100644 --- a/tests/unit/teacher-conversation-panel.test.tsx +++ b/tests/unit/teacher-conversation-panel.test.tsx @@ -41,6 +41,37 @@ const props = { projectId: 'project-a', projectName: '天气', sourceId: 'source async function mount() { let view!: ReturnType; await act(async () => { view = render(); }); return view; } describe('continuous agent panel', () => { + it('retries incomplete prose only in its original project/source and keeps current drafts and one send identity', async () => { + const original = { ...request('broken', '现在项目是什么阶段?'), response: '它早就不是', replyIncomplete: true, + references: [{ kind: 'code' as const, text: '原项目代码' }] }; + api.conversation.mockResolvedValue({ topic: topic([original]), before: null }); + api.seenConversation.mockImplementation(async () => topic([original])); + let finish!: (value: TeacherTopic) => void; + api.sendConversation.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + const view = await mount(); + expect(screen.getByRole('status')).toHaveTextContent('这次回复未能完整显示'); + expect(screen.queryByTestId('teacher-reply')).toBeNull(); + expect(api.sendConversation).not.toHaveBeenCalled(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: '仍在编辑的草稿' } }); + view.rerender(); + expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled(); + view.rerender(); + expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled(); + view.rerender(); + const retry = screen.getByRole('button', { name: '重新回答' }); + fireEvent.click(retry); fireEvent.click(retry); + expect(api.sendConversation).toHaveBeenCalledTimes(1); + const sent = api.sendConversation.mock.calls[0][1]; + expect(sent).toEqual({ requestId: expect.any(String), projectId: 'project-a', sourceConversationId: 'source-a', + text: original.text, references: original.references }); + expect(sent.requestId).not.toBe(original.id); + fireEvent.change(screen.getByRole('textbox'), { target: { value: '等待时补充的草稿' } }); + await act(async () => finish({ ...topic([original, { ...request(sent.requestId), response: '完整回答' }]), revision: 2 })); + expect(screen.getByText('完整回答')).toBeVisible(); + expect(screen.getByRole('textbox')).toHaveValue('等待时补充的草稿'); + expect(JSON.parse(localStorage.getItem(`makelore-consultation-draft:kid-${identity.id}:agent:agent-a`)!)).toMatchObject({ text: '等待时补充的草稿' }); + }); + it('opens one chat without topic controls or creating a topic, and preserves it across project switches', async () => { const view = await mount(); expect(screen.queryByRole('button', { name: '智能体新话题' })).toBeNull(); diff --git a/tests/unit/teacher-conversation.test.ts b/tests/unit/teacher-conversation.test.ts index d4ba8f9b..cdf72fb1 100644 --- a/tests/unit/teacher-conversation.test.ts +++ b/tests/unit/teacher-conversation.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { TeacherConversationStore } from '../../electron/coding-teacher/conversation-store'; import { TeacherTopicStore } from '../../electron/coding-teacher/store'; +import { applyTeacherReply } from '../../electron/coding-teacher/reply'; import { CodingTeacherService } from '../../electron/coding-teacher/service'; import { CodingProjectService } from '../../electron/coding-projects/project-service'; import { createCodingProjectStore, createMemoryCodingProjectStorage } from '../../electron/coding-projects/project-store'; @@ -36,6 +37,55 @@ function turn(index: number): TeacherRequest { omittedMessages: 0, status: 'completed', response: '回答 ' + index }; } describe('continuous conversation persistence', () => { + it('repairs retained replies on every page without rewriting their files during restart recovery', async () => { + const dir = await root(); + const store = new TeacherConversationStore(dir, 'account', 'agent-a'); + const topic = await store.ensure(definition, 1); + const prose = '它早就不是"刚搭好架子"那种阶段了。\n完整的后半段。'; + const damaged = { ...turn(0), response: '它早就不是', discussionError: '旧解析失败', + unparsedResponse: '{"reply":"' + prose + '","quickReplies":[]}' }; + const incomplete = { ...turn(1), response: '它早就不是', discussionError: '旧解析失败', + unparsedResponse: '{"reply":"它早就不是"刚搭好架子' }; + const running = { ...turn(60), status: 'running' as const }; + topic.requests = [damaged, incomplete, ...Array.from({ length: 57 }, (_, i) => turn(i + 2)), running]; + await store.save(topic); + const file = (id: string) => path.join(dir, 'turns', id + '.json'); + const rawBefore = await readFile(file(damaged.id), 'utf8'); + // Simulate a pre-repair index, including an untrusted partial assistant excerpt. + const manifestFile = path.join(dir, 'conversation.json'); + const manifest = JSON.parse(await readFile(manifestFile, 'utf8')); + for (const item of manifest.turns) delete item.replyProjectionVersion; + manifest.turns[1].messages.push({ id: 'teacher:' + incomplete.id + ':assistant', role: 'assistant', text: '它早就不是' }); + await atomicWriteJson(manifestFile, manifest); + const resumed = new TeacherConversationStore(dir, 'account', 'agent-a'); + const latest = await resumed.page(); + expect(latest.requests.at(-1)?.status).toBe('interrupted'); + const archive = await resumed.history(); + expect(archive.messages.some(message => message.text === '它早就不是')).toBe(false); + expect(await archive.read('teacher:' + incomplete.id + ':assistant')).toBeUndefined(); + expect((await archive.read('teacher:' + damaged.id + ':assistant'))?.text).toBe(prose); + const older = await resumed.page(latest.before!); + expect(older.requests[0]).toMatchObject({ response: prose, unparsedResponse: damaged.unparsedResponse }); + expect(older.requests[1].replyIncomplete).toBe(true); + expect(await readFile(file(damaged.id), 'utf8')).toBe(rawBefore); + expect(JSON.parse(await readFile(file(incomplete.id), 'utf8'))).not.toHaveProperty('replyIncomplete'); + expect(JSON.parse(await readFile(file(running.id), 'utf8')).status).toBe('interrupted'); + }); + + it('keeps rejected new shortcuts hidden after reload while retaining historical cards', async () => { + const dir = await root(); + const store = new TeacherConversationStore(dir, 'account', 'agent-a'); + const topic = await store.ensure(definition, 1); + const current = turn(1); + const old = { ...turn(0), suggestedQuestions: ['一', '二', '三', '四'] }; + applyTeacherReply(current, JSON.stringify({ reply: '完整回答', quickReplies: old.suggestedQuestions })); + topic.requests = [old, current]; + await store.save(topic); + const loaded = await new TeacherConversationStore(dir, 'account', 'agent-a').read(); + expect(loaded.requests[0].suggestedQuestions).toEqual(old.suggestedQuestions); + expect(loaded.requests[1]).toMatchObject({ response: '完整回答', suggestedQuestions: [], replyQuickRepliesRejected: true, replyParseError: expect.any(String) }); + }); + it('pages thousands of turns, restores the latest page and preserves repeated questions', async () => { const dir = await root(); const store = new TeacherConversationStore(dir, 'account', 'agent-a');