diff --git a/.project-docs/10-decisions/ADR-2026-09-22-coding-teacher.md b/.project-docs/10-decisions/ADR-2026-09-22-coding-teacher.md index 667249ff..91e62c1c 100644 --- a/.project-docs/10-decisions/ADR-2026-09-22-coding-teacher.md +++ b/.project-docs/10-decisions/ADR-2026-09-22-coding-teacher.md @@ -10,7 +10,7 @@ ## Decision -- 2026-09-28 用户要求移除老师对话栏的结构化组件并确认合并:不再渲染想法板、结构图、流程/条件图和逐项对照,以及邀请、焦点和操作按钮。新提问与引导求助不再携带 discussion-v1 或 discussion;普通正文、“帮我看看”与快捷追问保留。旧结构化数据与快照不删除,结果不确定的旧请求重试保持原 ID 和完整输入,避免重复调用。 +- 2026-09-28 用户要求移除老师对话栏的结构化组件并确认合并;远程回复清理和恢复工作一并集成。新咨询由 Main 统一声明 `{reply, quickReplies}`,正文和 0–3 条可选快捷回复按所选智能体云端配置生成,客户端只提供入口事实、上下文与输出格式。超额新卡片保留正文及原文诊断,不截断或展示;历史卡片数量不改写。旧组件状态和快照作为不透明档案保留,不恢复交互、主动导入或进入模型上下文,旧组件修改路由已移除。 结果不确定的旧请求保留请求 ID、问题、引用与来源,客户端重试移除退役组件字段,Main 按已有请求身份返回结果,避免重复执行。 - 2026-09-28 用户确认像微信联系人一样持续聊天,随后明确纠正:不同项目必须有不同会话,并授权修复及合并。唯一可见聊天身份为账号+项目+稳定 config_id;同一项目不按名称、默认标记、Pi 会话或发布版本创建第二个可见聊天。项目隔离历史、草稿、已读和模型上下文;本次范围为本机持久化,不含跨设备同步。 - 新项目创建内部默认编程 Agent 并保存 defaultAgentId,会话直接列在项目下。打开项目不自动创建空会话,首次发送或显式新建才创建;历史 Agent ID、配置和 Pi 会话绑定保留。 - Yuxi 拥有老师提示词、模型和所选 Skills、知识库、MCP、子智能体。Works Square 运营菜单首先展示下发列表,添加时从 Yuxi 选择并发布当前配置,支持多位、默认选择与启停,不维护第二份提示词编辑器或试聊界面。 @@ -18,19 +18,21 @@ - Yuxi 原生 Request/Run、PostgreSQL checkpoint 和 Redis 事件拥有云执行。Main 冻结本次账号、项目、来源 Pi 已完成分支的公开消息及老师话题,通过三个只读工具提供目录、UTF-8 文件行段和会话原文。文件按读取时内容提供,会话按本轮快照读取,不上传完整工程、Pi 原始日志或思考。 - 本地工具只交给老师主线程:云端持久中断完整批次,Main 主动回传配对结果,关联 Run 续接同一问题。用户于 2026-09-27 授权改造读取工具:声明 read_protocol=2 的客户端支持按层目录与连续原文分页,每问题最多 12 批、成功结果累计 64 KiB、单页 8 KiB,剩余不足 512 字节时收尾;旧客户端保持六批。最后结果消费后显式禁止继续选工具并要求基于已有证据回答。保留原付款人、版本、上下文和截止时间;读取限当前项目,允许 .makelore/project.json,排除其余内部记录与 Git 数据,不提供本地写入或命令。 - Works Square 验证学生编程资格,使用专用短期老师凭据;聊天模型费用记入学生 ai_programming / coding_teacher 账本。老师资源仍归 Yuxi 创建者,个人 Agents 模块继续创建者付费,老师问题不占创建者个人智能体金额上限。 -- 云端保存执行线程、消息和收到的片段。Main 在 userData/agent-conversations///projects/ 保存轻量聊天索引和独立轮文件;每页50轮,流事件只更新当前轮。Host API 统一使用 /api/coding/projects/:projectId/agent-conversations/:agentId,历史、发送、SSE、停止、讨论、保存和已读均绑定该项目。轮文件先于索引原子保存,恢复不重发模型。停用阻止后续调用,历史仍可查看;重启将未完成问题标为中断,下次提问先停止旧问题。 -- 每轮冻结项目、Pi 来源、发布版本及只读范围。同一项目相同来源/版本沿用内部 Yuxi 线程,来源或版本改变时开启内部执行段,只携带该项目有预算的公开近期交流;可见聊天保持连续。切换项目恢复另一个独立聊天,不能复用跨项目云端 checkpoint。历史消息按当前项目聊天内的 ID 读取,Pi 与项目配置仍归项目所有。 -- Code 咨询统一使用服务端下发智能体,不内置老师/朋友角色、本地教学人设或按名称分配的工具权限。名称、头像、简介、欢迎语和推荐问题来自发布定义。所有下发项共享上述只读工具边界,通用讨论组件协议不定义智能体人格。 -- 结构化讨论在聊天内按项目保存,历史轮保留原卡片快照。正文、历史和带回草稿仅接收云端主线程文本;子线程事件仍推进续传游标。带回回答只追加原项目/Pi 来源的草稿,不自动发送或改写当前其他来源。已下发智能体的旧 Renderer 定时跟进停止派发;新的 Main 主动观察由独立任务实施,本次未接入建议投递,后续必须并入同一聊天,不能恢复项目话题入口。 +- 云端保存执行线程、消息和收到的片段。Main 在 userData/agent-conversations///projects/ 保存轻量聊天索引和独立轮文件;每页50轮,流事件只更新当前轮。Host API 统一使用 /api/coding/projects/:projectId/agent-conversations/:agentId,历史、发送、SSE、停止、保存和已读均绑定该项目。轮文件先于索引原子保存,恢复不重发模型。停用阻止后续调用,历史仍可查看;重启将未完成问题标为中断,下次提问先停止旧问题。 +- 每轮冻结项目、Pi 来源、发布版本及只读范围。同一项目相同来源/版本沿用内部 Yuxi 线程,来源或版本改变、或首轮从旧讨论协议升级到 reply-v1 时开启内部执行段,只携带该项目有预算的公开近期交流;可见聊天保持连续。切换项目恢复另一个独立聊天,不能复用跨项目云端 checkpoint。历史消息按当前项目聊天内的 ID 读取,Pi 与项目配置仍归项目所有。 +- Code 咨询统一使用服务端下发智能体,不内置老师/朋友角色、本地教学人设或按名称分配的工具权限。名称、头像、简介、欢迎语和推荐问题来自发布定义。所有下发项共享上述只读工具边界,回复格式协议不定义智能体人格。 +- 历史结构化讨论和快照保留在其原档案中,不参与新回复或项目聊天导入。正文、历史和带回草稿仅接收云端主线程文本;子线程事件仍推进续传游标。带回回答只追加原项目/Pi 来源的草稿,不自动发送或改写其他来源。已下发智能体的旧 Renderer 定时跟进停止派发;独立 Main 主动观察仍未合入,后续必须并入对应项目的同一聊天。 - 所有下发智能体以头像和名称在顶栏并排展示,溢出横向滚动。点击恢复当前项目中的唯一聊天或空态、独立草稿和未读,不调用模型;移除新话题加号和话题下拉框。最近50轮先加载,历史分页与流事件按请求身份合并;切换项目或智能体保留各自窗口内阅读位置,已读位置由 Main 持久保存,迟到的读取和事件不得填入另一项目。 - 2026-09-28 远程刷新与界面改动合并:顶栏手动刷新只读共享下发目录和配置,更新联系人资料,不重建聊天、不切换选中智能体、不清空历史或草稿;已接受问题保留原版本,下一轮由 Main 固定当前发布版本。停用后保留原聊天但禁止发送。发布介绍在顶栏悬停/聚焦显示,点击当前入口或 Escape 收起咨询栏,边缘调宽保留;不再显示重复面板标题栏或下发智能体新话题入口。追加问题使用三色卡片。 -- 未发送文字草稿按账号/项目/智能体保存;明确引用仍携带项目/Pi 来源,来源切换后须移除旧引用或恢复原上下文才能发送。旧全局草稿仅由其记录的项目接纳,优先于更早的项目话题草稿;原记录保留。旧全局聊天依据每轮 request.projectId 或 origin.projectId 分入对应项目,保留原请求身份、映射项目已读位置、复制该项目讨论,并开启新的云端执行段。导入可在中断后幂等续接,原始索引和轮文件不修改,无法证明项目的轮仅留在原档案中,不猜测归属。 +- 未发送文字草稿按账号/项目/智能体保存;明确引用仍携带项目/Pi 来源,来源切换后须移除旧引用或恢复原上下文才能发送。旧全局草稿仅由其记录的项目接纳,优先于更早的项目话题草稿;原记录保留。旧全局聊天依据每轮 request.projectId 或 origin.projectId 分入对应项目,保留原请求身份、映射项目已读位置,并开启新的云端执行段;原讨论状态仅留在原档案。导入可在中断后幂等续接,原始索引和轮文件不修改,无法证明项目的轮仅留在原档案中,不猜测归属。 - 更早的话题仅在已登记项目、账号与 config_id 归属均明确时导入,以项目/话题/请求来源去重,重复正文不去重;失联目录稍后重试。无法证明身份的朋友/旧老师历史及旧草稿只读保留。现有云端 coding-teacher 名称不构成角色分类。独立主动观察后续也必须向相应项目的唯一智能体聊天投递建议。 - 输入预算在编译时计入完整执行请求。本地旧模型路径按约 2 UTF-8 字节估算 1 Token,工具定义、调用和读取预留纳入同一计量;估算不替代模型上限或实际 usage。云端以完整 JSON 转义后的 query 字节数裁剪来源节选,原文仍可按 ID 读取。固定配置/当前讨论超限与用户问题/引用超限分别提示,不静默修改发布预算、问题、明确引用或话题版本。 -- 咨询回答以 Markdown 渲染标题、列表、表格、代码、HTTP(S) 链接/图片和公式,代码与表格在栏内滚动;普通 JSON、Markdown 链接与代码中的字面转义保持原义。历史 discussion-v1 请求仍由顶层 reply/quickReplies/tool 或专用围栏识别,保留数据解析但不再显示组件。解析失败保留原讨论数据与可恢复正文,完整 unparsedResponse 单独持久保存并默认折叠,不再次注入后续会话上下文。 +- 咨询回答以 Markdown 渲染标题、列表、表格、代码、HTTP(S) 链接/图片和公式,长内容在栏内滚动;普通 JSON 和代码保持正文。兼容旧 `{intro, questions}` 和含 `tool` 的输出,只提取正文和快捷回复。完整正文可从保留原文中在本地恢复;无法确认完整时显示缺失提示,由用户明确点击重新回答,保持原问题、引用和项目/Pi 来源,保留现有草稿,采用当前发布版本。被动恢复不调用模型或改写已完成原始轮文件;不完整回答和未解析原文不进入后续模型上下文。 原文默认折叠、按字面展示。 - 工具活动使用实际主线程事件及 Main 本地读取结果的名称/状态,按 run/call 关联与去重,单独折叠展示;参数、输出、错误原文与推理不进入活动 DTO 或回答。未观察到结果的调用明确显示结果缺失,不补写过期事件或旧话题。Yuxi chat_service 保证只有 AI 角色进入回答流,客户端不按内容外形猜测清洗。 +- 输入为空时显示暖黄色“继续看看👀”,输入后隐藏;点击才发起求助。进入咨询立即收起静态欢迎语并按账号/项目记住,真实未读建议保持显式查看/收起边界。 + ## Evidence And Release Boundary - 2026-09-28 组件移除源 317def79129503062d15076585881ceae753055c 无冲突快进合入本地 main。47 项相关单测、12 项浏览器布局、1 项 Electron 咨询场景及类型/lint/构建通过;产品与测试保持源字节,沿用其验证。没有服务端或历史数据迁移;未推送、打包或更新安装版。见[源任务](../30-worklog/tasks/20260928-remove-teacher-cards-ef1cf59c.md)和[集成记录](../30-worklog/tasks/20260928-merge-teacher-cards-a09c07f3.md)。 diff --git a/.project-docs/10-decisions/decision-index.md b/.project-docs/10-decisions/decision-index.md index db42e50a..06ec0f91 100644 --- a/.project-docs/10-decisions/decision-index.md +++ b/.project-docs/10-decisions/decision-index.md @@ -2,7 +2,7 @@ - 2026-09-26 已确认[老师 Agent 产品定位](../00-brief/project-positioning.md#老师-agent-的产品定位):某一领域杰出人物的能力转化而成的智能体,供平台学生使用;蒸馏属于实现方法,以真实案例验证领域能力;补充下方既有咨询技术决定。 -- [下发智能体咨询与项目会话导航](ADR-2026-09-22-coding-teacher.md):2026-09-28 amended / implemented;按用户纠正,唯一持续聊天身份为账号+项目+下发智能体,不同项目隔离历史、草稿、已读与模型上下文,保留同项目无新话题入口的交互。Pi 来源/发布版本在每轮接受时冻结,同项目变化只轮转内部线程。旧全局聊天按每轮可证明的项目分入对应会话,保留原文件并开启新云端执行段;不明归属仅保留原档案。学生计费、统一只读工具和主线程正文保持;老师对话栏已移除结构化组件及新请求的组件格式要求,历史数据保留;主动观察建议接入由独立任务继续并绑定对应项目。 +- [下发智能体咨询与项目会话导航](ADR-2026-09-22-coding-teacher.md):2026-09-28 amended / implemented;按用户纠正,唯一持续聊天身份为账号+项目+下发智能体,不同项目隔离历史、草稿、已读与模型上下文,保留同项目无新话题入口的交互。Pi 来源/发布版本在每轮接受时冻结,同项目变化只轮转内部线程。旧全局聊天按每轮可证明的项目分入对应会话,保留原文件并开启新云端执行段;不明归属仅保留原档案。学生计费、统一只读工具和主线程正文保持;老师对话使用 reply-v1 正文与 0–3 条可选快捷回复,退役组件仅留原档案;本地恢复完整旧正文,无法完整恢复时显式重试且保留草稿;主动观察建议接入由独立任务继续并绑定对应项目。 ## Active Decisions diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index ea0f2875..dbe37275 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -6,11 +6,11 @@ 当前项目中顶栏选择下发智能体 → Main 通过 /api/coding/projects/:projectId/agent-conversations/:agentId 读取账号/项目/智能体唯一聊天与最近50轮,打开本身不调用模型 → 学生发送时幂等获取该项目聊天,冻结项目/Pi 来源/当前发布版本 → 获取WS专用凭据 → Yuxi原生Request/Run。同项目/Pi/版本沿用内部线程;同项目来源或版本改变时新建执行段并仅携带该项目有预算的公开交流,不创建第二个可见聊天。切换项目恢复独立聊天,不复用前一项目历史或云端checkpoint。名称、人设和资料来自发布定义。读取中断 → Main校验本轮绑定、取消与额度 → 列目录/读取文件/读取会话原文 → 主动回传配对结果 → 原问题关联Run续接。协议2为12批/64KiB累计成功结果/8KiB单页,旧协议六批;最后一批消费后要求最终回答。保留原问题时限与学生付款人;文件使用读取时内容,会话使用本轮快照。云端只保存消息和收到的片段,完整工程与Pi原始日志保留本机。 -Renderer 新提问/引导求助省略组件格式与讨论上下文 → Main 既有普通回答路径;“帮我看看”仍使用问题建议协议。历史组件状态不再进入可见卡片或焦点控件;不确定的旧请求重试沿用原 ID 与完整输入。存储及旧请求协议不迁移。 +Renderer 自由输入/空输入框“继续看看👀” → Main 统一 reply-v1 → 完整正文及 0–3 条可选快捷回复。完整正文可从保留原文中在本地恢复;无法确认完整时显示缺失提示,由用户明确点击重新回答,保持原问题、引用和项目/Pi 来源,保留现有草稿,采用当前发布版本。被动恢复不调用模型或改写已完成原始轮文件;不完整回答和未解析原文不进入后续模型上下文。 不确定旧请求仍复用原身份与输入来源,移除退役组件字段;旧组件只留原档案,修改路由退役。 云端事件 → Main 先推进游标,再仅转发云端主线程文本 → Host SSE/本机历史 → 右侧面板。子智能体文本不混入正文或带回草稿;断线按游标与持久状态恢复。关闭面板不取消已接受问题,停止/退出请求取消原问题;重启后下次提问先停止旧问题。带回回答只追加草稿,不自动发送,旧本地老师话题保留原执行路径。见[老师决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)。 -编译完整请求 → 本地路径按近似Token预算、云端路径按实际JSON query字节预算裁剪来源节选 → 提交前按同一计量复核。问题、明确引用及固定讨论指令保持完整;真正的固定配置超限单独提示。明确引用绑定旧项目/Pi时须移除引用或恢复原来源。聊天轮文件原子写入后更新轻量索引;恢复与旧记录导入按原始身份去重,不重发模型。旧全局聊天依据每轮可证明的项目分入对应存储,映射已读、复制相应讨论并开启新云端执行段;原文件不修改,无项目归属的轮保留原档案。历史页和SSE按请求ID合并且排除跨项目迟到响应,已读归Main,文字草稿和阅读缓存按账号/项目/智能体保存。旧全局草稿只导入其记录的项目,保留原值。现代聊天停止旧定时跟进,独立主动观察后续接入相应项目的同一聊天。 +编译完整请求 → 本地路径按近似Token预算、云端路径按实际JSON query字节预算裁剪来源节选 → 提交前按同一计量复核。问题、明确引用及固定回复协议保持完整;真正的固定配置超限单独提示。明确引用绑定旧项目/Pi时须移除引用或恢复原来源。聊天轮文件原子写入后更新轻量索引;恢复与旧记录导入按原始身份去重,不重发模型。旧全局聊天依据每轮可证明的项目分入对应存储,映射已读并开启新云端执行段,旧讨论只留原档案;原文件不修改,无项目归属的轮保留原档案。历史页和SSE按请求ID合并且排除跨项目迟到响应,已读归Main,文字草稿和阅读缓存按账号/项目/智能体保存。旧全局草稿只导入其记录的项目,保留原值。现代聊天停止旧定时跟进,独立主动观察后续接入相应项目的同一聊天。 ## Permanent wallet and recharge diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 73fa0f27..6636dd9e 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -13,7 +13,7 @@ TeacherConversationStore 在 userData/agent-conversations///proj ## Current Architecture -咨询正文由 TeacherReply 渲染 Markdown、GFM 与公式;TeacherDiscussionPanel、焦点与操作逻辑及专用样式已移除。新提问与引导求助省略 presentation/discussion,使用既有普通回答路径;“帮我看看”及快捷追问保留。Main 保留历史 discussion-v1 解析与存储,旧未确认请求仍以原身份和完整输入重试,Renderer 不再显示其组件。普通 JSON/代码仍是正文。失败原文默认折叠持久保存且不重新注入上下文。云端主线程与本地读取活动投影为独立的工具名称/状态,参数、结果和推理不进入回答。Yuxi 在消息投影处筛选 AI 角色;详见[咨询决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)。 +咨询正文由 TeacherReply 渲染 Markdown、GFM 与公式。新咨询由 Main 统一声明 `{reply, quickReplies}`,正文和 0–3 条可选快捷回复按所选智能体云端配置生成,客户端只提供入口事实、上下文与输出格式。超额新卡片保留正文及原文诊断,不截断或展示;历史卡片数量不改写。旧组件状态和快照作为不透明档案保留,不恢复交互、主动导入或进入模型上下文,旧组件修改路由已移除。 完整正文可从保留原文中在本地恢复;无法确认完整时显示缺失提示,由用户明确点击重新回答,保持原问题、引用和项目/Pi 来源,保留现有草稿,采用当前发布版本。被动恢复不调用模型或改写已完成原始轮文件;不完整回答和未解析原文不进入后续模型上下文。 首次协议升级轮转内部云线程,保留项目可见聊天;工具活动继续独立展示名称/状态。详见[咨询决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)。 知识文档上传、附件导入与替换后由客户端自动衔接既有解析索引接口,Main 保留处理 Task、可用性与替换关系。Renderer 每 5 秒串行刷新已加载页面,以服务端持久 Task 与实际分块显示阶段、失败原因及可用于回答的文档;请求未确认时保留同一操作意图,观察到新 Task 后才解除本地不确定状态。替换失败及清理重试保留旧可用内容,零分块不显示可用。见[知识处理交互规则](../10-decisions/ADR-2026-09-11-personal-cloud-agents.md#知识文档处理与可用状态)。 diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 8edb92a1..1a1abec4 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,8 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- 2026-09-28:续接获授权的组件移除集成任务,合并远程 `b645cfa312ca23e9a44758211a43f4934a44a70f` 与本地 `d37bc13`。保留账号/项目/智能体独立聊天,接入 reply-v1、0–3 条新快捷回复、完整正文/历史恢复、显式重试、欢迎语消费及旧组件接口退役;项目边界适配到路由、存储与测试。补齐首次导入全局历史时的内存回复恢复,原始存档保留。472 项相关单测、28 项浏览器布局、6 项 Electron 场景、标准类型检查、变更文件 lint 和最终生产构建通过。旧组件仅留原档案,不进入新上下文;本条取代下方历史记录中组件协议与跨项目假设。三份未跟踪文档及六份远程来源记录原样保留,未推送、打包、部署或更新安装版;独立主动观察仍未合入。见[集成记录](tasks/20260928-merge-teacher-cards-a09c07f3.md#remote-main-merge-resume)。 + - 2026-09-28:用户确认主目录交接后,将组件移除源 317def79129503062d15076585881ceae753055c 从 592920d 无冲突快进合入本地 main。老师对话栏移除想法板、结构图、流程图、对照及其专用交互,新问题不再请求组件输出;普通正文、快捷求助、历史数据及未确认旧请求的原身份恢复保留。产品/测试与源一致,沿用47项单测、12项布局、1项Electron以及类型/lint/构建验证。三份外来文档原样保留且未提交;未推送、打包或更新安装版。见[源记录](tasks/20260928-remove-teacher-cards-ef1cf59c.md)和[集成记录](tasks/20260928-merge-teacher-cards-a09c07f3.md)。本条取代下方历史记录中的可见讨论组件描述。 - 2026-09-28:按用户“不同项目应有不同会话”的明确纠正,无冲突合入源 `4b41c23a3a5e9da1a329b1b69930787a670163de`(基于 `a21a1f0`)。当前唯一聊天身份为账号+项目+下发智能体,历史、草稿、已读、SSE、接受锁和模型上下文按项目隔离;同项目保持持续聊天,无新话题入口。旧全局轮按已知项目归属导入,保留原文件与请求身份,使用新云端执行段;不明归属保留原档案。本文下方跨项目单聊天条目保留为历史,本条及修订咨询ADR为当前规则。产品/测试与已验证源一致,沿用382项相关测试、标准typecheck、scoped lint、Vite构建和3项Electron场景;扩展Main仍有66项既有诊断。三份外来文档未改动、未提交;未推送、打包、部署或调用收费模型。独立主动观察未合入,后续须适配项目聊天。见[修复源](tasks/20260928-agent-project-chat-6644c06d.md)与[本次集成](tasks/20260928-merge-agent-single-chat-9911c6df.md#project-isolation-correction-resume)。 diff --git a/.project-docs/30-worklog/tasks/20260928-agent-reply-truncation-c812b59f.md b/.project-docs/30-worklog/tasks/20260928-agent-reply-truncation-c812b59f.md new file mode 100644 index 00000000..382396c0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-agent-reply-truncation-c812b59f.md @@ -0,0 +1,87 @@ +# Task: Diagnose truncated consultation reply and raw-content fallback + +## Identity + +- Task ID: 20260928-agent-reply-truncation-c812b59f +- Mode: Feature +- Branch: codex/20260928-agent-reply-truncation-c812b59f-agent-reply-truncation +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-agent-reply-truncation-c812b59f +- Base commit: 4495345fb0a9ea1abef8e603629ce9b51d73eb05 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose and repair the screenshot showing only a short prose fragment followed by a legacy parse-failure notice and raw structured output. +- After diagnosis, user authorized the three repair paths: conservative full-prose recovery, independent auxiliary-field fallback plus explicit manual retry, and passive recovery of retained history. + +## Intent And Constraints + +- Initial investigation was read-only. The later implementation is explicitly authorized; keep local raw evidence and avoid automatic model replay, live app restart, cloud edits, or deployment. The earlier main merge/push request covered the completed Agent refresh feature. +- Latest user constraint: no subagents. Remaining code review, changes and verification are performed by the primary agent; earlier partial subagent work was reviewed locally. +- Ownership and planning gates passed in this task checkout. Reused unchanged canonical context from the prior turn; read all current peer task scopes. Current trial Main 9adab45 / Renderer cb48f60 belongs to the separate reply-cleanup task and is distinct from main 4495345. Inspected committed code only; peer working files remain untouched. + +## Outcome + +- The three approved repair paths are implemented and locally verified: conservative full-prose recovery, explicit incomplete-reply handling/manual retry, and passive retained-history recovery. The running client remains unchanged. +- Implementation and final validation are recorded below; the following investigation notes preserve how the original diagnosis was established. + +### Initial Diagnosis + +- The screenshot notice maps to the legacy structured-response parse fallback. The UI retains unparsedResponse separately from the recovered prose, so content in the raw box was received even though normal presentation failed. +- Independently reproduced an exact-shape failure: an unescaped ASCII quote after the opening prose causes JSON parsing to fail, while stringToken/recoverReply accepts only the prefix as a complete reply. The old parser returns the same short-prefix + screenshot notice combination. The newer shared/teacher-reply fields recovery in committed 9adab45 still accepts the same premature prefix. +- This is a demonstrated mechanism, not a confirmed trace of the screenshot request. The exact question/response was not found in the known trial/installed-app project teacher histories. The screenshot shows only the tail of the raw output, so its precise triggering bytes and provider termination reason remain unknown. +- Cloud structured replies use completed run.output; missing intermediate stream deltas do not explain a complete raw body being cut by this recovery path. Do not claim all provider truncation/format causes are excluded. + +## Verification + +- Final implementation checks: 329 unit checks, 17 headless UI cases, typecheck, scoped ESLint and production compilation passed. Native Electron case added and enumerated but not executed. The inherited dependency-document drift finding remains disclosed below. + +### Initial Investigation Verification + +- Read old shared/teacher-discussion.ts stringToken/recoverReply/parser fallback, UI diagnostics rendering, service/discussion persistence and cloud-runner final-output handling. +- Independent agent performed an in-memory execution of both committed parsers with malformed quoted prose and reproduced the truncated prefix. No tests or product files added/modified. +- Inspected only relevant local app runtime descriptors/project catalogs and teacher conversation records to seek the screenshot request; no matching request found. No credentials read or emitted. +- Project-document structure, task ownership drift and whitespace checks passed. + +## Follow-ups + +- The requested repair is complete; integrate and update the running client as a separate next step. Provider byte-level correlation remains unavailable; retain the screenshot-level evidence boundary. + +## Promotion Candidates + +- See Repair Follow-ups And Promotion Candidates below. Initial diagnosis introduced no accepted behavior change; the later repair was approved by the user. + +## Agent Log Screenshot Follow-up + +- Same-task start/status matched; reused unchanged project context and reviewed the new welcome-diagnosis/latest cleanup peer scope. Read-only gate passed; no product or live state edits. +- User supplied an Agent log screenshot containing the visible prefix `{"reply":"它早就不是"刚搭好架子"那种阶段了——` followed by a substantial prose answer and legacy structure payload. The earlier client screenshot shows exactly the prose prefix 它早就不是, the legacy fallback notice, and the later tool payload in the raw-content box. +- Independent reviewer replayed the visible prefix in memory against the old discussion parser and committed 9adab45 reply parser. Both cut at precisely the same quote. Together the screenshots locate the observed short-body symptom to malformed-format handling/recovery, strongly contradicting a stream that delivered only the first few characters. +- Distinguish upstream malformed structured output from downstream unsafe recovery: model-produced internal quotes need valid encoding, and the client must not accept an ambiguous prefix as complete prose. This is parsing before rendering, rather than visual clipping. +- Evidence remains screenshot-level, not a request-correlated byte capture. A log renderer could alter escape display, so do not claim the full transport was byte-for-byte audited or all possible transport issues were excluded. No corrective code had been applied at this diagnostic stage; the approved implementation is recorded below. + +## Approved Repair Implementation + +- Same-task ownership and planning gates passed on resume; registry remains this task / feature / owned worktree and branch. Merged the committed current reply-only dependency `40c247a` through `dda2f0a` so the fix applies to the current consultation implementation. No peer working files or canonical documents were edited; protocol-boundary and welcome/child-language changes were not imported. +- Prose extraction now requires a credible field boundary. Internal unescaped quotes and literal control characters are repaired only inside the prose field and only when the entire repaired envelope parses. Ambiguous boundaries, duplicate bodies and missing body endings fail visibly; missing content is never guessed. Ordinary Markdown, escaped code and Unicode remain intact. +- Complete prose survives malformed or truncated auxiliary data. Replies without trustworthy complete prose get `replyIncomplete`; UI shows an explicit incomplete state and a manual retry, never the half-sentence as a finished answer. Raw diagnostics stay folded and inert, including when escaping was successfully repaired. Technical output instructions require proper JSON string escaping without changing the published persona. +- Manual retry reuses original text, intent, references, source, topic and published version; an accepted completed turn gets a new request identity, uncertain resends reuse their identity. Click guarding prevents duplicate sends. Both current text/references and edits made while awaiting the send response survive. Project-only history does not substitute a different source or send the `project` sentinel as a conversation id. Disabled/legacy Agents and check-ins cannot be retried as ordinary questions. +- Store reads repair eligible completed history from retained original content locally. Valid unrelated prose is not replaced by a different recovered answer. Failed/cancelled/interrupted/running replies are never promoted to success. The projection preserves raw data, opaque legacy fields, accounting, request status and topic metadata; no model call or repair-only disk write occurs. In mixed history, restart recovery is persisted before applying the display projection. Incomplete assistant content and suggestions are excluded from both compiled model context and read_conversation while the user's original question remains available. + +## Implementation Verification + +- Focused parser, history and Renderer unit suites: 153/153 passed. +- Related service/cloud/model/read-tools/guidance/retired-route/activity suites: 176/176 passed (329 unit checks total). +- Typecheck and scoped ESLint passed. Production Renderer/Main/Preload/worker compilation passed (`build:vite`); build artifacts remain ignored. +- Headless Chrome layout/interaction suite: 17/17 passed, covering 319px and 508px widths, complete screenshot-shaped prose, incomplete manual retry, original input retention, no automatic send, draft retention and legacy-history behavior. Personally inspected narrow-panel recovered/incomplete screenshots. Initial browser run could not launch because the Playwright bundled binary was absent; reran successfully using the already-installed Chrome channel. +- Added an Electron integration case for click-only retry and preservation of both main and consultation drafts. Native Electron case was not run because the existing fixture shows/focuses an app window; keep the user's no-focus workflow intact. Browser interaction and backend history tests passed independently; do not claim a native smoke or a real-provider end-to-end run. +- Current live client was not replaced/restarted; code is ready for integration and a subsequent app update. Tests use deterministic local fixtures and do not consume paid model calls or modify real conversation history. + +## Documentation Boundary Note + +- Project-document structure check passed. The task-aware drift checker reports the already-committed cleanup task record as foreign relative to the original `4495345` base because this task merged the required reply-only dependency. That record is byte-identical to dependency `40c247a`; no owned-work edits touch it. Do not rewrite or delete the peer record, falsify the recorded base, or claim this inherited checker finding passed. All implementation-era document edits are limited to this task record and product README. + +## Repair Follow-ups And Promotion Candidates + +- Integrate the tested repair branch, then update the user's running client when requested. Exact historical screenshot request was not located locally; fixture tests reproduce the visible triggering bytes, not a provider trace. +- Candidate for the canonical current-state/consultation architecture at integration: preserve trustworthy full prose independently of auxiliary data; flag ambiguous incomplete prose explicitly; user-triggered retries preserve original scope and draft; retained raw data permits local history repair without replay. Evidence: focused unit and headless UI checks above. Future impact: any new reply format/history migration must retain the same safety properties. No change to published persona, model billing boundary or retired component policy; no additional product decision required beyond the user's approved repair. 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 new file mode 100644 index 00000000..b83f5ee7 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-latest-main-merge-b6a42f90.md @@ -0,0 +1,60 @@ +# Task: Merge current completed Makelore changes into main and push + +## Identity + +- Task ID: 20260928-latest-main-merge-b6a42f90 +- Mode: Feature +- Branch: codex/20260928-latest-main-merge-b6a42f90-latest-main-merge +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-latest-main-merge-b6a42f90 +- Base commit: a21a1f077cc11976213290a6b843bb74be97ab44 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Integrate current completed product work into origin/main, adapt overlaps, verify, fast-forward local main and push without force. + +## Intent And Constraints + +- User explicitly requested all latest project code merged and pushed. No subagents, paid model calls, app restart or cleanup. Preserve foreign working trees and original source records. Canonical memory reconciliation is not included; its integration lock is held by another task. + +## Outcome + +- 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. Product merge `cafebca05710b7bf1a6fa62ec51be4992a73a18c` was fast-forwarded into local main and pushed normally; `git ls-remote origin refs/heads/main`, origin/main and local main matched with a clean checkout. No app restart, model call, dependency change or worktree cleanup. + +## Verification + +- 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 + +- Product publication complete; source and integration worktrees retained. +- Full native Electron interaction execution and release packaging remain outside this merge validation; no claim of a rebuilt running client. + +## Promotion Candidates + +- 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 + +- Task ID/mode/branch/worktree/base: verified against official start/status; feature `20260928-latest-main-merge-b6a42f90`, branch `codex/20260928-latest-main-merge-b6a42f90-latest-main-merge`, this absolute worktree, fetched base `a21a1f077cc11976213290a6b843bb74be97ab44`. +- Managed app worktree creation was unavailable because the chat root is the repository parent; official skill start created and registered isolation from the explicit remote base. +- Read: entry order, memory-index in 05-agent-entry, own record, positioning, latest current-state/teacher ADR/decision index, architecture, domain/success criteria, relevant evidence/reflection/commitment/stale indexes, and all registered peer Scope/Intent/Promotion sections. +- Project goal: cloud-configured domain Agents support student projects; Main owns calls, source scope and persistence. Current remote main already implements account/Agent continuous chats, per-turn project/source/version, shared manual refresh and hover UI. No fixed local persona, deleted component UI or old topic controls should reappear. +- Local current product sources: reply cleanup and truncation fix `d530abe` (includes `40c247a`); technical prompt boundary `54a40d2` supersedes older Alice audit `89c913b`; welcome-entry fix `c96bb45`. Other feature branches are ancestors of main, diagnostic-only, or superseded experiments. Old dirty teacher-components and trial launcher are known historical work and remain excluded/read-only; child-language task has only an unfinished task record and no product code. +- Overlap: remote continuous-chat storage/send/history needs the same repair, retry and retired-component behavior as legacy topics. Resolve mechanically while preserving the accepted single-chat/per-turn rules. The old retry's pinned-topic assumption must not reverse the newer per-turn current-version contract. +- Canonical docs contain historical component descriptions; README will describe the combined implementation. Existing canonical integration lock and source task records remain untouched. No new semantic decision needed; gate Passed. + +## Plan + +1. Merge the committed reply, prompt-boundary and welcome changes into fetched remote main; resolve overlapping code and adapt continuous-chat paths. +2. Add focused regressions for the combined storage, context and UI paths; run relevant tests, typecheck, lint and build plus headless UI where available. +3. Verify committed source coverage, record outcomes, fast-forward clean local main, push normally and compare remote identity. Keep all worktrees. diff --git a/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md b/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md new file mode 100644 index 00000000..eb041382 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md @@ -0,0 +1,74 @@ +# Task: Merge teacher reply cleanup and approved UI changes into main + +## Identity + +- Task ID: 20260928-merge-reply-cleanup-eb8d39b3 +- Mode: Feature +- Branch: codex/20260928-merge-reply-cleanup-eb8d39b3-merge-reply-cleanup +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-merge-reply-cleanup-eb8d39b3 +- Base commit: a21a1f077cc11976213290a6b843bb74be97ab44 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Merge committed teacher/UI cleanup source 40c247a into latest origin/main and push normally; preserve existing continuous-chat and per-turn project/version behavior. + +## Intent And Constraints + +- User explicitly authorizes main integration and repository push; packaging belongs to their colleague. No packaging, native app restart, cloud publication, real model calls or focus changes. Preserve all foreign worktrees and source records. + +## Outcome + +- Source cleanup `40c247a` merged with the current account/Agent continuous-chat behavior in `581ebc8`, then incorporated complete remote main `077184a` before publication. Preserve its reply recovery, cloud-owned teaching policy, 0–3 new shortcuts, welcome dismissal, pagination and per-turn source/version behavior. +- Additional merge safeguards: old component mutation route removed from the continuous API; no new per-project component state is created or imported; existing malformed legacy component archives are retained verbatim. Old component instructions cannot persist through a reused pre-upgrade cloud checkpoint: first reply-protocol turn rotates the internal thread and carries bounded public reply history, without changing the visible chat. +- Unconfirmed legacy hint requests retain their old text, ID and original project/source. Migrated per-project drafts keep that original scope; cross-project or cross-source hint retry waits for the original scope instead of issuing another charge. Existing send-in-flight and incomplete-reply retry safeguards remain intact. +- No package, app restart/update, cloud configuration publication, paid model call or desktop focus change. Source/live-trial and all foreign worktrees remain intact. Product merge `f6944c46ebdeb05a940043ba629aee6716b5e44a` was pushed normally from `077184a` to remote main; `git ls-remote` matched the merge HEAD. Primary checkout is owned by another task and was left untouched; packaging can fetch remote main. + +## Verification + +- Final `corepack pnpm run typecheck`, `corepack pnpm run lint:check` and `corepack pnpm run build:vite` passed. Lint has 8 existing unrelated warnings; build has existing chunk-size/dynamic-import warnings. No dependencies or build outputs committed. +- Final backend focused validation: 7 suites / 261 tests passed; UI/companion/chat: 4 suites / 144 tests passed; continuous storage/retired routes: 2 suites / 17 tests passed. These runs overlap and are not an additive unique-test count. +- Headless Chrome: all 28 teacher reply/preview layout cases verified. First run passed 27; one initial Vite scan observed an in-progress shared-type merge marker, then passed when rerun after resolution. Native Pi Electron E2E fixture parses and lists 19 tests; not launched because it opens a foreground app. +- Full `corepack pnpm test`: 261 files / 2639 tests passed, 3 tests skipped; 3 failures in 2 unrelated Pi files. Independently reproduced exactly those same 3 failures on a disposable archive of original remote `077184a` (12 passed / 3 failed in the selected two files): two event-projector old error-message assertions and the managed-worker opener runtime-v6/v7 assertion. Baseline log: `/tmp/makelore-baseline-077184a-21n6od02/baseline-tests.log`. The script's separately gated pressure suite was then run manually and passed 1/1. +- `git diff --check`, required project-document structure and semantic comparison against latest remote passed. Task drift checker reports 5 imported source-task documents as foreign relative to the original base; byte comparison against `077184a` confirms every imported document unchanged. Only this task's own record is authored here; no canonical writes, ownership override, baseline alteration or source-document edits were used to suppress the inherited diagnostic. + +## Follow-ups + +- Remote main publication complete at product merge `f6944c46ebdeb05a940043ba629aee6716b5e44a`. Packaging and installation belong to the user's colleague; this final record commit carries no product changes. Source/live-trial worktree retained. +- The 3 pre-existing Pi test assertions were subsequently repaired at the user's request; see the follow-up below. Native Electron foreground interactions and signed package verification are not claimed. + +## Promotion Candidates + +- A later canonical-memory integration should reflect current text/quick-reply-only teacher behavior and first-upgrade cloud-thread rotation. Evidence is the merged implementation, focused regressions and baseline comparison; existing cloud-defined policy and continuous-chat decisions remain unchanged. This feature task does not own the canonical integration lock. + +## Project Context Loaded + +- Identity verified by official start/status: feature task `20260928-merge-reply-cleanup-eb8d39b3`, this managed worktree/branch, base `a21a1f077cc11976213290a6b843bb74be97ab44`. Source checkout is owned by the completed cleanup task and still hosts the temporary app, so it remains read-only. +- Read entry/memory index, own record, project positioning, latest integrated state, decisions, architecture, success criteria and all registered peer scope/intent/promotion sections. Reuse previously loaded teacher domain/evidence/reflection/commitment context; changed continuous-chat rules checked against current main/source task records. +- Goal: student-owned creation with cloud-defined teachers, Main-owned request/history/version boundaries, prose and quick replies only. Preserve main's unique account/Agent chat and per-turn project/source/version; remove retired component UI/protocol/state and retain approved empty-composer hint/retry/preview-header styling. +- Other tasks: a broader latest-main integration is also registered planning; prepare this requested source in isolation, check remote again before push and never overwrite a concurrent remote advance. Prompt-boundary, truncation repair and welcome changes are separately owned and not added speculatively. Child-language task is unfinished. Canonical integration lock is held elsewhere; keep all canonical/source records read-only. +- New integrated single-chat behavior is compatible with component cleanup; overlaps require combined code/tests rather than choosing an entire side. No new product decision needed. Gate Passed. + +## Plan + +1. Merge source 40c247a into fetched main in this isolated branch; preserve source history and resolve overlaps with the current continuous-chat contract. +2. Verify backend/context/parser/history and UI/preview/refresh paths, typecheck, lint, production compile and headless layout; independently review the merge. +3. Recheck origin/main, integrate any concurrent advance, push normally to main and verify remote ancestry/hash. Keep the live trial checkout; packaging and app updates are outside this request. + +## Follow-up: Three Baseline Test Failures + +- User asks what the reported 3 failures mean, whether they affect the product and requests their repair. Resume the same integration-validation task in its existing owned checkout at `405846c`; official start/status verify the same task ID/mode/branch/worktree. No subagents unless separately approved; this follow-up is performed independently. +- Context gate passed: entry/memory/task/peer scopes rechecked; unchanged positioning and prior teacher context retained; read Pi ADR-006, Main architecture, provider/domain success criteria and evidence/reflection/commitment/stale indexes. Main owns credential isolation, public error classification and managed runtime resources. No change to the accepted Pi 0.84.2/runtime-v7 behavior or login recovery flow. +- Peers still own application installation/testing and an unfinished child-language task; their files stay read-only. This work repairs the specifically reported test debt in two Pi test files and the matching release-proof filename metadata. No semantic conflict, new product direction, paid call, packaging or app restart is involved. +- Evidence: commit `7951cca` deliberately changed the public Provider-auth message and expanded expired-session recognition; `26cbb29` deliberately advanced the managed extension from v6 to v7. Session-projector already expects the new public message. Runtime opener uses the real materialized extension path. The three failed assertions are stale; release proof also retains a stale v6 report label. +- Plan: align exact public-message assertions without weakening status/error-code/redaction checks; use the canonical extension filename and verify the concrete extension argument points to a readable file; remove the stale release-proof label; run focused tests, typecheck/lint/build and then the full suite. Preserve histories and production behavior. + +### Follow-up Outcome And Verification + +- Repaired both exact Provider-auth message expectations to the accepted public wording; error code, terminal state, recoverability and secret-redaction checks remain intact. +- Worker-opener regression now uses the authoritative versioned extension filename, verifies its exact `--extension` argument path and reads the generated module. The release-proof report uses that same filename instead of an obsolete v6 label. No authentication, runtime version, model behavior or billing change. +- Focused event/session projector, managed opener and actual extension bundle: 4 files / 37 tests passed. Full `corepack pnpm test` now passes: 263 files / 2642 tests passed, 3 unchanged skipped tests, followed by the separately executed pressure stage 1 file / 1 test passed. No remaining failed tests. +- `typecheck`, full `lint:check` (0 errors / 8 prior warnings), `build:vite`, `git diff --check` and required documentation structure passed. Task drift retains the same 5 unchanged imported source-document diagnostics from the previous integration; current follow-up modifies only this owned record plus the 3 listed test/metadata files relative to `405846c`. +- Impact: stale expectations made the full validation command fail although the intended auth projection and v7 extension were operating correctly; stale release-proof metadata could mislabel an artifact report. Repair restores meaningful release validation without downgrading runtime behavior. Formal package verification remains the packaging owner's responsibility. +- Continue the authorized repository handoff with a normal fast-forward push to main after commit; no app restart, package, cloud publication, real model request or subagent creation. diff --git a/.project-docs/30-worklog/tasks/20260928-merge-teacher-cards-a09c07f3.md b/.project-docs/30-worklog/tasks/20260928-merge-teacher-cards-a09c07f3.md index 21560dd7..ba6de196 100644 --- a/.project-docs/30-worklog/tasks/20260928-merge-teacher-cards-a09c07f3.md +++ b/.project-docs/30-worklog/tasks/20260928-merge-teacher-cards-a09c07f3.md @@ -12,7 +12,7 @@ ## Scope -- Integrate verified MakeLore source 317def7 into local main and promote its approved component-free teacher conversation contract. +- Integrate verified MakeLore source 317def7 into local main and promote its approved component-free teacher conversation contract. Authorized follow-up also reconciles remote main b645cfa with local project-scoped chats. ## Intent And Constraints @@ -44,3 +44,24 @@ - Accepted source candidates promoted under user removal and merge instructions. Source task records remain read-only; no unresolved product-direction choice. + +## Remote Main Merge Resume + +- User explicitly authorized resuming this integration owner and completing the already-started merge of remote b645cfa312ca23e9a44758211a43f4934a44a70f into local d37bc13. Official check/start/status passed with the same task, integration lock, main worktree and recorded base. Adopted six incoming source records and preserved three existing untracked records unchanged. +- Planning Gate Passed: refreshed entry, own/source records, positioning, integrated snapshot, teacher ADR/index, architecture/data-flow/domain and relevant evidence/reflection/commitments. Read 133 registered peer records; historical template scopes remain unknown without a concrete dependency. Independent observer and macOS diagnosis remain outside this merge. +- Preserve the explicitly corrected account/project/agent chat identity, drafts, receipts and cloud-context isolation. Combine it with the remotely approved reply-only protocol, bounded new shortcuts, complete-text/history recovery, welcome dismissal and retired component routes. No new product direction, subagents, push, packaging or paid model calls. +- Plan: reconcile conflicting implementations and fixtures; test project-specific histories and uncertain/incomplete reply retries together; run typecheck, relevant units, scoped lint, production build and focused UI/Electron coverage; promote accepted current behavior, pass documentation drift and commit the merge. + +### Remote Merge Outcome And Verification + +- Resolved all 11 conflicted files. Retained local account/project/agent identity, original-source checks, per-project storage/imports, drafts, read positions and SSE. Integrated the remote text/shortcut-only protocol, 0–3 new-card limit, reply repair/manual retry, inert legacy archives, first-upgrade cloud-thread rotation, empty-composer hint, welcome consumption, work-preview header cleanup and Pi runtime test alignment. +- Adapted imported test fixtures to project-scoped routes and storage. Combined both sides' coverage rather than preserving obsolete cross-project assertions; removed the retired component-layout spec in favor of the richer reply-layout coverage. The route fixture now returns 404 for removed handlers instead of hanging. +- A new combined upgrade regression reproduced a real issue: imported global turns already in the live cache bypassed completed-reply repair on their first page. Page projection now repairs copied results too, preserving the original archive and completed on-disk turn bytes. The focused regression failed before the fix and passed after it. +- Verification: standard typecheck passed; 472 relevant unit tests across 19 files passed across the focused runs, with affected persistence/service/history tests rerun after the final fix (121/121). All 28 browser layout cases and six native Electron scenarios passed, including project history/draft switching and reload, manual refresh, incomplete-reply retry and format-error presentation. Changed-file ESLint passed, followed by lint for the final changed Main/test files; Renderer/Main/Preload/utility production build passed again after the fix. +- Refetched origin before completion: MERGE_HEAD and origin/main still match b645cfa312ca23e9a44758211a43f4934a44a70f. Canonical teacher ADR/index, architecture/data-flow and business rules now describe the merged behavior and keep project isolation authoritative. Source task records remain unchanged; three original untracked task records remain uncommitted. +- No remote push, installed-app update, package, deployment or real paid model request. Verification uses local deterministic fixtures, not live published-model quality. Existing build advisory warnings do not prevent compilation. Independent observer integration remains separate. +- Task-aware documentation drift and whitespace checks passed; all conflict index entries are resolved. The durable merge records both source parents and the verified task outcome; final Git state and registry completion are checked after committing. Retained source worktree cleanup remains subject to the earlier pending decision; this primary worktree is unmanaged and is not a cleanup target. + +### Remote Merge Promotion Candidates + +- Accepted reply cleanup/protocol/recovery/welcome candidates are promoted to the teacher ADR, architecture, domain and current integrated snapshot. Their account-wide assumptions are reconciled with the explicitly corrected project-chat contract; no conflicting product decision remains. No source document was edited. diff --git a/.project-docs/30-worklog/tasks/20260928-teacher-protocol-boundary-f2c8a601.md b/.project-docs/30-worklog/tasks/20260928-teacher-protocol-boundary-f2c8a601.md new file mode 100644 index 00000000..601f1778 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-teacher-protocol-boundary-f2c8a601.md @@ -0,0 +1,62 @@ +# Task: Keep teacher runtime instructions scoped to entry facts and UI protocol + +## Identity + +- Task ID: 20260928-teacher-protocol-boundary-f2c8a601 +- Mode: Feature +- Branch: codex/20260928-teacher-protocol-boundary-f2c8a601-teacher-protocol-boundary +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-teacher-protocol-boundary-f2c8a601 +- Base commit: 998796d77cba146ae2a007cbebfdaa1255a3413e +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Continue the accepted teacher/client boundary on top of completed legacy-component cleanup commit `998796d77cba146ae2a007cbebfdaa1255a3413e`. +- Keep entry markers factual and the reply protocol technical. Remove locally appended teaching strategy from active help, guided help, background checks, and reply-format instructions. +- Enforce the current 0–3 shortcut capacity for newly generated answers while preserving archived replies and exact diagnostics. +- Update focused tests and README; preserve card styling and existing interaction/scheduling machinery. + +## Intent And Constraints + +- User assigned main merge and push to another conversation. This task stays in its isolated feature branch and prepares a tested commit for integration. +- Cloud configuration owns Alice's teaching purpose, focus, tone, length, and shortcut prefixes; the host owns invocation facts, available context/tools, and interface protocol. +- Keep old component cleanup intact. Do not merge the older `89c913b` patch wholesale because it modifies a superseded protocol. +- No cloud configuration publishing, live app restart, scheduler redesign, or teacher-to-operator consensus handoff in this task. +- Gate passed before edits: task ownership/status verified; entry, task-relevant memory, and peer scopes reviewed. Relevant canonical documents are unchanged between the previously read baseline and `998796d`; shared memory remains read-only. + +## Outcome + +- Active-help and guided-help messages now identify the student's entry intent; background checks identify a program-triggered check without a fabricated student question. They no longer mandate a teaching strategy. +- Reply instructions now declare only the two fields, Markdown body support, optional 0–3 nonempty shortcut strings, and delegation of content/style to published configuration. +- Main rejects excess newly generated shortcuts without silently truncating or rewriting them: prose is retained, cards are withheld, and the exact raw answer and format diagnostic are saved. 0/1/3-card replies preserve prefixes and duplicates. The shared compatibility parser and historical store remain unchanged. +- The UI now shows recorded reply-format errors beside the preserved answer; raw content stays collapsed and inert. Existing colors, layout, click handling and drafts are unchanged. +- Regression coverage includes local/cloud service persistence, history read/list/save, all entry intents, current/legacy envelopes, and UI behavior. README reflects the responsibility boundary and current capacity. +- No merge, push, running-app restart, cloud config change, or canonical memory edit performed. + +## Verification + +- Dependency installation: pinned pnpm 10.33.4, frozen lockfile, offline cache; no tracked dependency change. +- Focused final suite: 269/269 passed across teacher-guidance, teacher-reply, teacher-reply-history, coding-teacher, coding-teacher-cloud, coding-teacher-model and coding-teacher-ui. +- `corepack pnpm run typecheck`: passed. +- ESLint for all changed TypeScript/TSX files, including the Electron E2E spec: passed. +- `corepack pnpm run build:vite`: passed after the final UI change. +- `git diff --check` and task-aware project-doc drift check: passed. +- Independent read-only review found no remaining teaching-policy directives in the scoped runtime files and confirmed lossless historical handling. +- Added a focused Electron E2E case for visible format errors, absent rejected shortcut buttons, collapsed exact raw text, and preserved drafts. Playwright test collection passed using an existing Electron distribution override; the native test was not executed to avoid launching another app window during concurrent work. Component/service assertions covering these behaviors passed. +- Real published-model responses were not tested. Existing topics retain their pinned configuration versions; code tests do not establish that an updated cloud prompt is active. + +## Follow-ups + +- Integrate this commit after the legacy-component cleanup; merging/pushing belongs to the user's other conversation. +- Validate actual Alice output with a new topic using the published cloud version. Local tests cannot prove model behavior or that existing pinned topics picked up a new prompt. +- Three-operation-round checks and shared confirmed context remain separate work. + +## Promotion Candidates + +- Target: canonical teacher decision/current-state documents during integration. + - Proposal: runtime help/check-in markers convey entry facts only; teaching strategy is entirely in the selected published agent configuration. Current newly generated replies accept 0–3 shortcuts; historical card counts remain lossless. + - Evidence: context/reply code and focused tests in this task; user's accepted client/agent boundary in this conversation. + - Future impact: future personas can use the same interface without inheriting Alice's teaching policy; prompt changes are evaluated against the actual published topic version. + - Semantic conflicts: supersedes cleanup task's remaining locally mandated single discussion focus and unrestricted new-card count; retains historical compatibility and UI cleanup. + - Human confirmation: not additionally required for these already accepted boundaries; no canonical edits in this feature task. diff --git a/.project-docs/30-worklog/tasks/20260928-teacher-reply-cleanup-c8e4a219.md b/.project-docs/30-worklog/tasks/20260928-teacher-reply-cleanup-c8e4a219.md new file mode 100644 index 00000000..be20c97c --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-teacher-reply-cleanup-c8e4a219.md @@ -0,0 +1,142 @@ +# Task: Retire teacher discussion components and preserve text replies and quick replies + +## Identity + +- Task ID: 20260928-teacher-reply-cleanup-c8e4a219 +- Mode: Feature +- Branch: codex/20260928-teacher-reply-cleanup-c8e4a219-teacher-reply-cleanup +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore-teacher-reply-cleanup +- Base commit: ae0f72d8fa0089f87b41d272da220e1c1397aec8 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Remove retired visual discussion components from UI, state, context, parser, service and routes; keep teacher prose, clickable quick replies and free input. +- Preserve opaque historical data, agent/version selection, read tools, proactive check-ins, billing and operational Agent. + +## Intent And Constraints + +- User explicitly authorizes this product change, superseding old component-based teaching plans. Do not publish cloud configuration, restart applications or steal desktop focus. +- Gates passed: read AGENTS, project entry/read-before-planning, memory and indexes, relevant positioning/teacher decision/domain/architecture/current-state/evidence/commitment/stale records, success criteria and peer task scopes. Managed feature worktree identity verified against registry. +- Isolated from the live trial checkout. Frontend and reply protocol implementation delegated; backend/context/store and integration verification owned here. +- Follow cloud agent configuration for persona, length, prefixes and style; keep only output format and single-angle guided-help requirements locally. +- Plan: remove active component plumbing; retain passive legacy fields; add UI/parser/history/service/cloud regressions; run typecheck, targeted/full unit checks, build and guarded UI verification; document and commit. + +## Outcome + +- Removed all four interactive discussion views, their dedicated CSS, state/focus/conversion/lifecycle actions, client mutation API, Host API mutation routes and backend state machine. Pending drafts restore only current send fields while retaining request identity for safe retry. +- Ordinary questions and guided-help/suggestions use one text + quickReplies envelope. The local runner and cloud final-output path preserve project reads while keeping tool preambles out of the answer. Check-ins keep their existing plain-text path and scheduling/dedup behavior. +- Removed old component schemas/instructions, hard-coded suggestion counts/length/student-voice policy and check-in/guided-help brevity additions. “帮我看看” recommends one concrete starting point with replies around it. Published persona, skills, language, length, prefixes and style remain authoritative; no Alice-specific runtime content. +- Legacy discussion/context/snapshot/error data stays opaque and unmodified on read/list/save/recovery. No component is restored or updated. The parser supports old intro/questions, reply envelopes with inert tool data and damaged historical envelopes; only prose and suggestions survive. Raw parse failures or received cancelled output remain inert diagnostics, excluded from context/read_conversation. +- Kept teacher selection/version binding, readonly project/source tools, native/cloud billing and request identities, cancellation, source/account guards, active check-ins and operational Agent unchanged. +- README now describes the supported product behavior. No cloud publication, real application restart, live trial update or desktop focus change. Work is retained on this isolated feature branch. + +## Verification + +- `corepack pnpm run typecheck`: passed after the combined changes. +- `corepack pnpm run lint:check`: passed; scoped ESLint rerun after final parser/service changes passed. +- Final duplicate-card regression: both ordinary and guided suggestion cards use request ID + index keys; two repeated-text click/draft cases fail before the fix and pass after it. UI suite 52/52 and scoped ESLint passed; typecheck/build rerun after this final code edit. +- Targeted teacher backend/cloud/model/read-tools/UI/parser/history/route/guidance run: 9 files, 281 tests passed. Frontend agent also verified teacher companion and chat panel suites: 103 tests passed across its initial 3-file run. +- `corepack pnpm test`: first stage ran 261 files, 259 passed; 2551 tests passed, 3 skipped, 3 failed. The 3 failures are pre-existing Pi event-projector message expectations (2) and managed-worker-opener runtime-v6 versus v7 expectation (1). Isolated `git archive HEAD` base snapshot ae0f72d reproduces the exact same failures (3 failed / 12 passed); these tests and production coding-runtime/contracts/reducer paths have no task diff. Evidence: /tmp/makelore-teacher-reply-full-tests.log and /tmp/makelore-teacher-reply-baseline-tests.log. +- The test script’s pressure stage was skipped by its `&&` after baseline failures, so ran `coding-chat-pressure.test.tsx --maxWorkers=1` separately: 1 passed. +- `corepack pnpm run build:vite`: passed for Renderer, Main, Preload and utility worker. Generated output stayed ignored in this isolated checkout. +- Playwright teacher-reply-layout fixture: 12 headless Chrome checks passed at 319px/508px; rich Markdown/font checks rerun after shared dependency allow-list fix: 2 passed. No Electron/native app launch. Shared Electron first-chat spec updated to assert absence of retired send fields, but native suite deliberately not launched under the no-restart/no-focus constraint. +- Independent review found and resolved ordinary JSON questions/tool examples being mistaken for protocol and an aborted runner resolving normally losing received diagnostic bytes. Regressions cover both. +- Production search confirms old component words/schemas remain only as passive archive types, historical transport/fence recognition, and test data. No active lifecycle/prompt/action code remains. Model-bound history and read tools use only prose + quick replies; no legacy archived fields or raw failures enter the model. +- `check_project_docs.py`, task-aware drift check and `git diff --check`: passed before handoff. + +## Follow-ups + +- Integration must preserve the independent manual agent-refresh work while dropping its obsolete component state assumptions; this branch intentionally does not import another task’s unmerged feature. +- Track the three existing unrelated Pi test expectation failures separately. No production Pi changes are included here. +- Cloud agent configuration and cloud-side stored conversation history were not inspected or published; verification establishes what this client now sends, not an audit of remote prompt contents. Existing pinned versions are intentionally preserved. +- Task was not merged, pushed or deployed. No post-merge worktree cleanup applies. + +## Promotion Candidates + +- Targets: `30-worklog/current-state.md`, `10-decisions/ADR-2026-09-22-coding-teacher.md`, `20-architecture/data-flow.md`, `40-domain/business-rules.md`. +- Proposal: current teacher UX is prose plus clickable quick replies/free input; retire prior ideas/structure/flow/comparison component plans and model schema/state instructions. Preserve opaque historical data and the teacher responsibility of organizing ideas, understanding relationships and carrying confirmed consensus; published configuration owns expression policy. +- Evidence: explicit user direction in this task, deleted UI/API/state/schema paths, parser/history/model-payload regressions, headless interactive verification. +- Impact/conflict: supersedes prior active component lifecycle and multiple-topic suggestion assumptions; no automatic migration or history deletion, no change to source/version/billing/operational Agent boundaries. Prior feature-task records remain historical evidence. +- Human confirmation: already supplied by this task’s explicit product requirements; canonical promotion must still happen in a serialized integration task. + + +## Authorized Background Restart Follow-up + +- User explicitly requests restarting the application using the latest project state, superseding the earlier no-restart boundary. Keep no cloud publication and no desktop-focus takeover. +- Context gate passed on the same task/worktree/branch. Reread own task, entry/memory, positioning, current-state, teacher ADR/architecture, evidence/reflection/commitment references and all peer task scopes. The accepted product simplification supersedes stale active-component documentation; no canonical edits here. +- Current trial PID 27713 still uses the hover checkout’s Main but its Renderer was independently updated to committed manual-refresh source 4f6bda4. Preserve this existing trial feature by merging that commit into this task; resolve the one consultation-state conflict with shared catalog state and no retired focus state. Do not import the unrelated in-progress Alice policy branch or change main. +- Plan: validate the combined teacher/refresh paths, build the combined source, create an ignored background-launch wrapper, gracefully stop only the exact temporary-app PID and relaunch the same bundle/profile/port; verify loaded build and startup without focus. Installed production application is outside scope. +- Same user-data retains persisted project/history and consultation drafts. Unpackaged authentication and operation drafts are memory-owned; do not claim a restart guarantees persistence of data the product does not save. + + +### Restart Outcome + +- Merged committed manual-refresh source 4f6bda4 into this feature branch as 9adab45, retaining cleanup source 998796d. Independent review confirms catalog refresh/current topic version/drafts and reply-only UI all remain; the sole conflict was resolved without restoring retired focus state. +- Combined validation passed: typecheck, 112 teacher UI/companion/chat tests, changed-file ESLint, Renderer/Main/Preload/utility build and whitespace checks. No new backend logic changed after the previously recorded full regression run. +- Sent SIGTERM only to verified trial PID 27713; its normal app.quit cleanup completed and port 54599 was released. Relaunched the exact existing trial bundle with open -g -j and an ignored wrapper for this checkout. New PID 36109 serves 54599; OPTIONS /api/app/runtime-info returned 204. +- 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. + +### Hint Trial Refresh Outcome + +- Implemented UI is committed as 73331e6; refreshed only the existing trial Renderer to `index-IW_wuxZ1.js`. Main PID 36109 and Host API 54599 remain running from the earlier 9adab45 launch. No native app restart, production application change, cloud publication, merge or push. Local launch metadata now records both running Main and Renderer versions, and the next-launch wrapper points to the current build. +- Preserved state only in Main memory during refresh, with busy request/attachment/flight gates and temporary show/focus guards. Did not read or copy auth tokens or all localStorage. After refresh, the current authenticated account matched the pre-refresh account, and project/conversation/route, operation/provisional drafts, composer contents and persisted consultation drafts matched. Both visible composers were empty; no claim of a live nonempty draft scenario is needed beyond the automated regressions. +- Verified actual DOM has the new transparent 13px hint, no old fixed footer element, mounted app and current script hash. Read-only composer screenshot inspected at the local trial `hint-preview.png`; it matches the intended light text interaction. 12-second foreground monitor recorded trialFocused=false with unchanged foreground PID 19791. Main focus calls were guarded and none occurred. +- Temporary inspector and Main-memory backup removed after verification. Runtime metadata/verification/screenshots remain local and ignored/outside Git. The managed checkout must remain available while this temporary app uses its compiled files. +- Project-doc structure and whitespace checks pass. Task-aware drift still reports only the unchanged imported manual-refresh task record from the authorized earlier merge; reconfirmed no difference against 4f6bda4. No canonical or peer task document was edited by this UI follow-up. + +## Playful Retry Follow-up + +- User requests the retry button label “继续看看👀” and a more playful appearance; continue the same temporary-app preview without taking desktop focus. +- Project Context Loaded: same feature task/branch/worktree/base identity above, registry ownership resumed and verified. Reread entry, active task and all peer Scope/Intent/Promotion sections; reuse already-read unchanged positioning, integrated snapshot, teacher decisions, architecture/domain, evidence/reflection and commitment context. Makelore remains a single light creation workspace; scope is only the existing failed/cancelled/interrupted suggestion retry affordance. Separate protocol-policy work stays isolated; old component documents are historical and superseded by this task’s accepted removal. No new semantic conflict. Gate Passed. +- Plan: change label and scoped button CSS to a warm rounded sticker with a small pressed shadow, retaining keyboard/disabled/reduced-motion support; update existing retry tests, inspect a headless screenshot, build and refresh only the trial Renderer with the existing state-preservation method. User-requested emoji overrides the design skill’s generic preference against emoji icons. + +- Implemented exact label “继续看看👀” with a cream-yellow 44px minimum-height sticker button, asymmetrical rounded corners, warm outline and shallow bottom shadow. Hover warms the fill; press lowers the shadow. Disabled and keyboard focus behavior remain, and reduced-motion disables transform/transition. No request or retry behavior changed. +- Passed: 59 existing UI tests updated for the new label, 15 headless layout checks including failed/cancelled retry at 319px with draft preservation, typecheck, full lint, production build and whitespace checks. Inspected the failed-state screenshot; the button fits the narrow panel without clipping. Tests use fixtures only. + +- Trial updated from committed cb48f60 to Renderer `index-D5o0Sba7.js`; running Main PID 36109/54599 and its 9adab45 code remain. Initial snapshot correctly refused while a teacher request was running; waited for completion before the state-preserving Renderer refresh. Existing successful reply is retained, without fabricating a failed live request to demonstrate retry. The new retry CSS is loaded; actual failed/cancelled appearance was verified in the headless fixture. +- Verified same login/account/project/conversation/route, operation drafts, composer values and all persisted consultation draft text/references/pending payloads. One persisted absent pending field normalizes to null on mount; semantic comparison confirms no draft content or request identity was lost. Runtime verification contains only boolean/style/build metadata, no draft bodies or credentials. Temporary Main-memory backup and inspector removed. +- Foreground monitor: trialFocused=false, unchanged foreground PID 88503. No show/focus calls were needed. Local trial metadata and next-launch bootstrap updated; production app untouched. No cloud publication, deliberate model test request, main merge or push. +- Documentation structure and whitespace checks pass; the task-aware drift diagnostic remains limited to the unchanged imported manual-refresh task record described above, reconfirmed identical to 4f6bda4. + +## Visible Hint Clarification + +- User could not see the prior rename. Read-only runtime inspection confirmed the new build was loaded but no failed/cancelled request was currently displayed; the visible empty-composer entry still said “给我一点提示”. Explained the distinction and user explicitly confirmed to rename/style the composer entry too. +- Same task identity/ownership resumed and verified; reuse this follow-up's loaded project/peer context with no new conflict. User clarification supersedes the earlier preference for a transparent text hint. Plan: share the warm sticker styling between hint and retry, send the matching new label through the existing suggestions intent, preserve empty/type/clear behavior and legacy pending-payload reuse. Increase the reserved action-row height to 44px so the send control remains still. Update existing tests and preview with a background Renderer refresh. No backend or cloud-policy change. + +- Implemented shared hint/retry sticker styling and exact “继续看看👀” composer label/payload. The hint still hides on nonempty input; reserved 44px action height keeps the send button fixed. Existing request identity reuse covers both former labels (“帮我看看” and “给我一点提示”), without changing saved history. +- Verification passed: typecheck, full lint plus final test-file lint, production build, 59 UI tests and 15 headless browser checks. Existing tests distinguish the two same-label controls by composer/reply location. Screenshots at 319/508px show the visible empty-composer button and confirm no clipping or send-button movement. Shared native E2E assertions updated without launching a native test app. + +- Refreshed existing trial Renderer from committed 784fa8c to `index-DLBsnWbW.js`. Main PID 36109 and profile retained. Final DOM confirms visible composer hint text “继续看看👀” with `rgb(255, 242, 201)` background; captured and inspected the actual empty-composer screenshot `visible-hint-preview.png` in the local trial root. Login/account/project/draft/input/route checks passed; no generated conversation content or requests were inserted for demonstration. +- Window/panel/selection state changed during the live verification: the consultation panel was closed at snapshot and open at final capture, and the selection equality check was false. Preserved current interaction without a second forced selection restore; do not claim exact conversation/window state remained unchanged. The foreground monitor already observed the trial as its sole foreground application; refresh invoked no show/focus calls. Final capture was taken with the window unfocused. +- Temporary backup memory and inspector removed; local launch/build metadata updated. No cloud publication, Main restart, main merge or push. Final documentation drift has only the previously recorded unchanged imported task document diagnostic. + +## Embedded Work Preview Header Follow-up + +- User requests removal of the pictured horizontal “作品预览” title/icon/close row. Continue the current temporary workspace preview without taking desktop focus. +- Same feature ownership resumed and verified. Reused unchanged previously loaded project context; reread own task/entry and newly active welcome/truncation/role-review task records. Existing peer scopes remain unchanged; no work-preview overlap or unresolved product decision. Gate Passed. This scoped presentation change preserves the shared browser's lifecycle, navigation, recovery, native viewport and separate developer-browser UI. +- Plan: hide the redundant header only in embedded work preview, retain an accessible aside name and the existing top-level operation/work tabs; keep address navigation and standalone browser close controls. Update existing unit/native E2E assertions, compile, and refresh only the temporary Renderer. Main/remote branches and installed production application remain unchanged. + +- Implemented conditional removal of the entire embedded title row (globe icon, “作品预览”, close button); the work-preview aside remains accessibly named. Address/back/forward/reload controls and the native viewport remain, while the standalone developer-browser header and close action are unchanged. +- Passed: 44 AgentBrowserPanel/CodingChatPanel unit tests, typecheck, full lint plus scoped test lint, full production compile and whitespace check. Updated shared Electron work-tab assertions without launching a native test instance. + +- Code committed as 064096e and new build prepared. The safe refresh snapshot detected an operation run in progress and refused to reload. Applied a narrowly scoped live stylesheet to the existing Renderer instead: only the embedded preview's old first div/title row is hidden, including after it mounts again. Current work tab was closed, so no live header screenshot or mounted-viewport assertion is claimed. The new compiled component permanently omits the row when next loaded. No work-tab switch, browser startup, model request, page reload or application restart was performed. +- Main PID 36109 and running Renderer 784fa8c remain; local descriptors explicitly distinguish the 064096e visual overlay/next build from the currently loaded script. Main focus/visibility stayed unchanged and no focus action was called. Temporary inspector closed; no draft, request, account, history or operation state was copied or reset. Installed production app and remote main remain unchanged. +- Final documentation structure/whitespace checks pass; task-aware drift retains only the previously documented unchanged imported manual-refresh task record diagnostic. diff --git a/.project-docs/30-worklog/tasks/20260928-teacher-welcome-diagnosis-4fb809c2.md b/.project-docs/30-worklog/tasks/20260928-teacher-welcome-diagnosis-4fb809c2.md new file mode 100644 index 00000000..0f92c2d0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-teacher-welcome-diagnosis-4fb809c2.md @@ -0,0 +1,50 @@ +# Task: Explain the first-click teacher welcome bubble + +## Identity + +- Task ID: 20260928-teacher-welcome-diagnosis-4fb809c2 +- Mode: Feature +- Branch: codex/20260928-teacher-welcome-diagnosis-4fb809c2-teacher-welcome-diagnosis +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-teacher-welcome-diagnosis-4fb809c2 +- Base commit: 4495345fb0a9ea1abef8e603629ce9b51d73eb05 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose and fix the first-click teacher welcome bubble at baseline 4495345. Entering consultation consumes the static welcome and preserves true unread proactive messages. + +## Intent And Constraints + +- User first asked why the screenshot bubble appears, then explicitly requested the proposed fix. +- Concurrent and Planning Gates passed in this owned checkout; task identity, branch, absolute path and base verified on resume. Relevant project memory and peer scopes reviewed. Adjacent cleanup and truncation tasks remain isolated. +- Preserve published greeting content inside the consultation panel, account/project scope, real unread check-ins, cloud configuration, model requests and conversation state. +- No main merge, live trial restart, cloud publication or paid model request in this task. + +## Outcome + +- Cause: welcome_message displayed after config load until explicit dismissal; avatar entry only opened the panel, leaving the welcome bubble visible and changing its CTA to 看看智能体说的. +- The hook now hides static welcome immediately when consultation is open, then records welcome-dismissed for the current account/project. This also handles entering before async config completes, closing before config completes, same-agent selection and reload. +- The effect runs after scope initialization and changes only welcomeDismissed. Real invitations and seen receipts are untouched; a real check-in still requires explicit viewing/dismissal. +- Updated current README and focused unit/Electron coverage. Independent review found no actionable issue. + +## Verification + +- New unit tests first reproduced three failures on the prior source; unread check-in preservation already passed. +- corepack pnpm 10.33.4: teacher-companion.test.tsx, 33 passed. +- pnpm run typecheck, scoped ESLint, pnpm run build:vite and git diff --check passed. +- Electron welcome test passed: avatar entry, close/reopen, renderer reload, panel greeting retained, no model/topic POST. +- Electron proactive test passed: entry/close does not consume unread message; explicit CTA consumes it, original focus/draft/layout/timer assertions retained. Initial modified test had an invalid remount-focus expectation when opening an already-open panel; restored its original close-then-CTA sequence and reran successfully. Final total: 2 relevant Electron scenarios passed. +- Verification uses the existing mocked Host fixture with isolated test profiles; no real provider or trial application updated. + +## Follow-ups + +- Integrate this small source change with the current main/trial version through its owning integration task. Build outputs, test profiles and dependency symlink are not committed. + +## Promotion Candidates + +- Target: canonical classroom interaction/current-state docs during an integration task. +- Proposal: entering consultation consumes and persists the static welcome per account/project; actual unread proactive messages remain until explicitly viewed/dismissed. +- Evidence: explicit user request, hook change, 33 unit tests and two Electron scenarios. +- Future impact: prevents redundant first-entry invitations without hiding real messages. +- Semantic conflicts: supersedes the older welcome-until-manual-dismissal behavior only. No further human confirmation needed for this accepted behavior; canonical promotion remains integration-only. diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index 84009db9..ee27ab2a 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -5,12 +5,16 @@ - 每个新项目创建内部默认 Agent,普通界面无 Agent 创建步骤或分组列。defaultAgentId 选取已存默认、置顶或首个可用 Agent;历史 Agent 与会话绑定不重写,不重新启用停用/归档 Agent。 - 项目直接显示会话并保留归档、改名、未读、运行状态;打开项目不创建空会话。首次发送或显式新建才创建。项目设置编辑默认配置,历史会话仍能定位原绑定配置。 - 每个账号/项目/稳定下发智能体ID只有一个可见聊天,不同项目隔离历史、草稿、已读及模型上下文。每个问题使用接受时的当前发布版本,并冻结项目、Pi来源及公开消息;同项目相同作用域复用内部线程,来源或版本变化只开启内部执行段。切换项目恢复独立聊天,不复用其他项目checkpoint或历史。Main三个只读工具仍限本轮项目,允许 `.makelore/project.json`,无本机写入或命令;每次读取与续接检查绑定及取消状态。完整工程不全量上传。 -- 同requestId相同输入读取当前项目聊天的已有结果(包括更早历史页),不同输入冲突;同一账号/项目/智能体一次接受一轮人工问答,各项目接受锁独立。SSE、取消、保存、讨论和已读均绑定项目,其他项目不能取消本轮。重启将未完成问题标为中断,账号退出中止。带回回答核对原项目/Pi来源,只追加草稿且不自动发送;已归档来源必须先恢复。 +- 同requestId相同输入读取当前项目聊天的已有结果(包括更早历史页),不同输入冲突;同一账号/项目/智能体一次接受一轮人工问答,各项目接受锁独立。SSE、取消、保存和已读均绑定项目,其他项目不能取消本轮。重启将未完成问题标为中断,账号退出中止。带回回答核对原项目/Pi来源,只追加草稿且不自动发送;已归档来源必须先恢复。 - 运营只管理下发、同步、默认与启停,无重复提示词编辑器或试聊。老师聊天模型由学生账号付费,个人 Agents 保持创建者付费。正文、历史及带回草稿只接收云端主线程文本;忽略子线程文本仍推进游标。 -- 客户端不内置老师/朋友角色:名称、头像、简介、欢迎语、推荐问题及人格由服务端发布定义决定,通用讨论协议不定义人格。任意名称的下发智能体都遵守同一只读工具边界;不再按角色限制项目读取。 -- 顶栏点击直接恢复当前项目的唯一聊天,没有新话题加号和话题下拉框。历史分页,已读由Main持久保存;草稿与阅读缓存按账号/项目/智能体隔离,引用仍绑定原项目/Pi,迟到响应不得进入另一项目。旧全局聊天依据每轮可证明的项目归属分入对应聊天,映射已读并开启新云端执行段;保留原文件与请求身份,不明归属仅留原档案。旧全局草稿只导入记录的项目,优先于更早话题草稿且保留原值。更早项目话题仍按当前账号、config_id与项目/话题/请求来源去重导入,不按名称猜归属。历史讨论状态仍按项目保存,但对话栏不再渲染讨论组件、邀请、焦点或操作按钮;新问题与引导求助不再请求组件输出,旧未确认请求重试保持原身份及完整输入。旧Renderer定时跟进不再向现代聊天派发;独立主动观察后续也须投递到对应项目的同一聊天。 +- 客户端不内置老师/朋友角色:名称、头像、简介、欢迎语、推荐问题及人格由服务端发布定义决定,回复格式协议不定义人格。任意名称的下发智能体都遵守同一只读工具边界;不再按角色限制项目读取。 +- 顶栏点击直接恢复当前项目的唯一聊天,没有新话题加号和话题下拉框。历史分页,已读由Main持久保存;草稿与阅读缓存按账号/项目/智能体隔离,引用仍绑定原项目/Pi,迟到响应不得进入另一项目。旧全局聊天依据每轮可证明的项目归属分入对应聊天,映射已读并开启新云端执行段;保留原文件与请求身份,不明归属仅留原档案。旧全局草稿只导入记录的项目,优先于更早话题草稿且保留原值。更早项目话题仍按当前账号、config_id与项目/话题/请求来源去重导入,不按名称猜归属。历史组件状态仅保留在原档案,既不渲染,也不主动导入或发送给模型。旧未确认请求重试保持原请求身份、问题、引用及来源,去除退役组件字段。旧Renderer定时跟进不再向现代聊天派发;独立主动观察后续也须投递到对应项目的同一聊天。 - 输入预算区分近似模型 Token 与精确云端 JSON 字节,编译和提交使用同一完整请求计量。保留当前问题、明确引用和固定指令,按需缩减来源节选;固定配置/讨论超限不误报为用户短问题过长,不静默提升预算或迁移旧话题。 +- 新咨询由 Main 统一声明 `{reply, quickReplies}`,正文和 0–3 条可选快捷回复按所选智能体云端配置生成,客户端只提供入口事实、上下文与输出格式。超额新卡片保留正文及原文诊断,不截断或展示;历史卡片数量不改写。旧组件状态和快照作为不透明档案保留,不恢复交互、主动导入或进入模型上下文,旧组件修改路由已移除。 +- 完整正文可从保留原文中在本地恢复;无法确认完整时显示缺失提示,由用户明确点击重新回答,保持原问题、引用和项目/Pi 来源,保留现有草稿,采用当前发布版本。被动恢复不调用模型或改写已完成原始轮文件;不完整回答和未解析原文不进入后续模型上下文。 +- 进入咨询消费静态欢迎语并按账号/项目保存,真实未读消息不随打开自动已读;空输入框的“继续看看👀”仅在点击时调用模型。 + 依据:[老师决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)及 [ADR-008](../10-decisions/adr-008-interactive-ai-app-scaffold.md)。 ## Permanent Token Points diff --git a/README.md b/README.md index 07b75a11..817f7e94 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。 - 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列正常在 `agent_settled` 后释放,用户可中止当前运行。若 Pi 已终结但该握手丢失,Main 会在 30 秒宽限后仅探测目标线程:权威 idle 状态会补齐持久化最终回复并 exactly-once 释放 ownership,矛盾状态则显式失败该目标,且不会重放已接受请求。select/confirm/input/editor 交互在输入区上方回答,select 除预设选项外支持自由输入;交互提交或取消后立即从输入区移除,失效请求会就地提示并允许重试或取消。 - 隐藏 Conversation 的红色新消息标记只在出现新的待回答交互,或当前任务进入 completed、failed、aborted 终态时设置;助手流式文字、思考、工具过程和单个工具失败不会提前触发红点。 -- 核心聊天页左侧显示品牌、项目及其对话;标题栏显示项目与当前 Conversation,并在右上角提供统一智能体入口和必要的中止/恢复按钮。中央“操作对话/作品”切换真实对话与项目浏览器预览,切换时保留操作草稿。点击智能体头像或主动气泡中的聊天入口,会同时打开智能体咨询并默认切到“作品”;仍可手动切回操作对话,单纯收到智能体气泡不会切换页面。点击“作品”会由 Main 检查已知本地页面:可访问则直接复用,否则向编程 Agent 发送固定的内部打开作品指令,检查现有服务、按项目实际脚本启动并调用 `agent_browser` 展示。当前项目正在制作时先等待;同一启动过程防止重复派发。失败时提供明确恢复入口:登录失效可“登录并继续”,返回相同账号和项目后接着打开;作品未打开可“帮我检查并打开”;普通操作中断可“帮我继续”,先核对已有进度再继续,保留当前输入草稿。仅对连接检查做两次有上限的自动重连并复用请求身份,不自动重放结果不确定的操作。最新失败提供一个恢复入口,空错误不再显示“本次处理失败。”;作品成功打开后收起对应失败提示。后台休眠释放预览窗口后,Main 在本次应用会话内保留项目对应的已验证地址,包括从地址栏或 Agent 成功打开的页面;回到前台或收到关闭状态时自动检查并恢复仍可访问的作品,不重复调用 Agent,也不依赖模型登录。手动打开成功会覆盖旧的启动请求结果,但不关闭后续恢复检查。窗口显示时发生关闭竞态会自动重试两次;仍无法显示则提供“重新打开作品”,不向学生暴露“开发浏览器已关闭”等内部错误,也不无限停留在打开中。内部指令不消耗学生草稿,也不参与对话自动命名,时间线隐藏这条内部指令及“已点击打开作品”操作记录,保留真实的处理结果和错误。学生作品区不展示 Console/Network 调试栏,也不订阅这两个面板的调试事件;Agent 的后台调试能力保留。项目设置、归档和会话管理保留在左侧。从已持久化的 user 消息仍可“从这里创建新对话分支”,assistant 消息和未持久化消息不提供该动作;分支只创建新的 Conversation 历史,不表示文件回滚。Pi 的工具调用与浏览器附件继续在对应的对话过程节点中按需展开;分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 +- 核心聊天页左侧显示品牌、项目及其对话;标题栏显示项目与当前 Conversation,并在右上角提供统一智能体入口和必要的中止/恢复按钮。中央“操作对话/作品”切换真实对话与项目浏览器预览,切换时保留操作草稿。作品区直接显示地址栏和页面,不再重复显示“作品预览”标题、图标及关闭横栏;通过上方“操作对话”标签切回对话。点击智能体头像或主动气泡中的聊天入口,会同时打开智能体咨询并默认切到“作品”;仍可手动切回操作对话,单纯收到智能体气泡不会切换页面。点击“作品”会由 Main 检查已知本地页面:可访问则直接复用,否则向编程 Agent 发送固定的内部打开作品指令,检查现有服务、按项目实际脚本启动并调用 `agent_browser` 展示。当前项目正在制作时先等待;同一启动过程防止重复派发。失败时提供明确恢复入口:登录失效可“登录并继续”,返回相同账号和项目后接着打开;作品未打开可“帮我检查并打开”;普通操作中断可“帮我继续”,先核对已有进度再继续,保留当前输入草稿。仅对连接检查做两次有上限的自动重连并复用请求身份,不自动重放结果不确定的操作。最新失败提供一个恢复入口,空错误不再显示“本次处理失败。”;作品成功打开后收起对应失败提示。后台休眠释放预览窗口后,Main 在本次应用会话内保留项目对应的已验证地址,包括从地址栏或 Agent 成功打开的页面;回到前台或收到关闭状态时自动检查并恢复仍可访问的作品,不重复调用 Agent,也不依赖模型登录。手动打开成功会覆盖旧的启动请求结果,但不关闭后续恢复检查。窗口显示时发生关闭竞态会自动重试两次;仍无法显示则提供“重新打开作品”,不向学生暴露“开发浏览器已关闭”等内部错误,也不无限停留在打开中。内部指令不消耗学生草稿,也不参与对话自动命名,时间线隐藏这条内部指令及“已点击打开作品”操作记录,保留真实的处理结果和错误。学生作品区不展示 Console/Network 调试栏,也不订阅这两个面板的调试事件;Agent 的后台调试能力保留。项目设置、归档和会话管理保留在左侧。从已持久化的 user 消息仍可“从这里创建新对话分支”,assistant 消息和未持久化消息不提供该动作;分支只创建新的 Conversation 历史,不表示文件回滚。Pi 的工具调用与浏览器附件继续在对应的对话过程节点中按需展开;分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 - 普通消息接收成功不显示额外的技术提示;准备、错误和排队状态仍正常反馈。发送状态按请求对应的运行结束收敛,保留期间输入的新草稿;恢复历史时优先以持久消息身份关联,避免相同文本串联到其他请求。 @@ -172,20 +172,22 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 客户端没有内置老师/朋友角色。已下发智能体以头像和名称并排显示在标题栏,点击后打开对应智能体的讨论;数量较多时横向滚动,咨询面板不再提供智能体下拉框。名称、头像、简介、欢迎语和推荐问题均由服务端提供。首次使用默认选择运营指定项。同一账号在同一项目内与同一智能体只有一个持续聊天;不同项目的历史、未发送草稿、未读状态与阅读位置分别保存,切回项目后恢复,不再显示新话题按钮或话题选择。旧版跨项目聊天按每轮保存的项目来源拆入对应聊天,原始文件保留;归属不明的旧消息留在原文件,不分配给任一项目。每轮接受时固定项目、操作对话与当前发布版本;旧的未分配项目草稿可展开查看,不自动转给其他智能体。打开或切换只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。 - 鼠标悬停或键盘聚焦顶部智能体入口时,以奶油色圆角卡片显示该智能体的发布介绍;离开、Escape 或点击入口后收起,没有介绍则不显示空卡片。咨询栏不再显示重复的头像、名称标题条,也不显示介绍及新话题、展开、关闭三个图标;老师身份保留在顶部入口及消息署名中。智能体生成的追加问题以淡蓝、奶油黄、淡紫卡片依次承载,正文 16px、行高 1.8,保留整卡点击发送与忙碌时禁用。回复下方不再显示重复的输入引导或省略早期消息条数;输入框提示为“和老师聊聊”。再次点击当前入口或在咨询栏按 Escape 可收起,切换其他入口仍直接打开对应讨论;拖动边缘调宽保留,已下发智能体仅保留只读旧记录入口。悬浮介绍不会创建话题、调用模型或确认未读消息,作品原生预览同时避让介绍卡和真实主动气泡。 - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 -- 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成简短的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 -- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 -- 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:学生点击后才结合当前操作对话与咨询历史生成 2–3 个可聊的问题。点击生成的问题会主动开始讨论;“我也说不清,你带我看看”让智能体从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 -- 智能体咨询栏保留普通消息与原有输入框,不再渲染想法板、结构图、流程/条件图、逐项对照或其邀请、讨论焦点和操作按钮。新提问与引导求助不再请求 `discussion-v1` 组件输出;“帮我看看”及生成的追问仍可使用,保留输入草稿。 -- 历史讨论数据及快照保留在 Main 的原存储中,旧回复正文、工具活动和解析失败时保存的原文仍可查看。升级前结果不确定的请求重试保持原请求身份与内容,避免重复执行。 +- 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 +- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;点击智能体进入咨询时立即收起欢迎语,并按账号/项目记住已查看,在同一项目关闭、重新打开或刷新后不重复弹出;主动收起也保留同样记录。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 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,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。 -- 每轮格式由 Main 的对应意图协议决定;当前咨询界面的新问题使用普通回答,“帮我看看”使用既有问题建议协议。旧请求的讨论协议解析与数据读取仍保留,客户端不再提供组件交互入口。 -- 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用和咨询历史,并支持上述只读原文恢复;没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。提示词不宣称这些系统能力已接通,也不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。 -- 智能体输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。智能体指令、当前问题和明确引用不会被静默裁掉;固定配置或当前整理本身超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。 -- `tests/fixtures/teacher-guidance-examples.json` 提供宠物、种植、射击、蛋糕的人工多轮输入输出样例,由真实解析器、上下文编译器与讨论状态机校验;样例不加载到运行时,也不代表真实模型教学效果已验收。云端定义由 Yuxi 维护并由运营显式同步下发;本地代码修改不会自动改写云端配置或学生已接受问题的固定发布版本。 +- `reply.ts` 只声明正文与快捷回复的输出格式,语言、长度、前缀与表达风格由所选智能体的云端配置决定。客户端不再发送选择、生成、转换或维护展示组件的指令。旧话题内的组件状态、快照和解析诊断作为不透明历史数据原样保留,不校验删除、不恢复交互;旧组件修改路由已移除。 +- 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、咨询正文和快捷回复,并支持上述只读原文恢复;旧组件状态、快照和未解析原文不加入模型上下文或咨询历史读取结果。没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。提示词不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。 +- 智能体输入裁剪区分两种计量:本地模型以约每 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/api/routes/coding-teacher.ts b/electron/api/routes/coding-teacher.ts index 0c8125b0..2b7e8bbf 100644 --- a/electron/api/routes/coding-teacher.ts +++ b/electron/api/routes/coding-teacher.ts @@ -8,7 +8,7 @@ import { } from '../route-utils'; import { TeacherError } from '../../coding-teacher/config-client'; import type { TeacherScope } from '../../coding-teacher/service'; -import type { TeacherCheckInInput, TeacherDiscussionAction, TeacherSend } from '../../../shared/coding-teacher'; +import type { TeacherCheckInInput, TeacherSend } from '../../../shared/coding-teacher'; import { takeTeacherPreviewRevision } from '../../main/app-deep-link'; export async function handleCodingTeacherRoutes( @@ -18,15 +18,15 @@ export async function handleCodingTeacherRoutes( ctx: HostApiContext ): Promise { const legacy = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/agent-history(?:\/([^/]+))?$/); - const conversation = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/agent-conversations\/([^/]+)(?:\/(messages|events|save|seen|discussion|requests\/([^/]+)\/cancel))?$/); + const conversation = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/agent-conversations\/([^/]+)(?:\/(messages|events|save|seen|requests\/([^/]+)\/cancel))?$/); const source = url.pathname.match( - /^\/api\/coding\/projects\/([^/]+)\/conversations\/([^/]+)\/teacher-topics(?:\/([^/]+))?(?:\/(messages|events|save|discussion|requests\/([^/]+)\/cancel))?$/ + /^\/api\/coding\/projects\/([^/]+)\/conversations\/([^/]+)\/teacher-topics(?:\/([^/]+))?(?:\/(messages|events|save|requests\/([^/]+)\/cancel))?$/ ); const preview = url.pathname.match( - /^\/api\/coding\/teacher-preview\/topics(?:\/([^/]+))?(?:\/(messages|events|save|discussion|requests\/([^/]+)\/cancel))?$/ + /^\/api\/coding\/teacher-preview\/topics(?:\/([^/]+))?(?:\/(messages|events|save|requests\/([^/]+)\/cancel))?$/ ); const projectTopics = url.pathname.match( - /^\/api\/coding\/projects\/([^/]+)\/(agent|teacher|friend)-topics(?:\/([^/]+))?(?:\/(messages|events|save|discussion|requests\/([^/]+)\/cancel))?$/ + /^\/api\/coding\/projects\/([^/]+)\/(agent|teacher|friend)-topics(?:\/([^/]+))?(?:\/(messages|events|save|requests\/([^/]+)\/cancel))?$/ ); const checkIn = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/teacher-check-in$/); const role = projectTopics?.[2] === 'friend' ? 'friend' : undefined; @@ -124,10 +124,6 @@ export async function handleCodingTeacherRoutes( sendJson(res, 202, await service.send(scope, id, await parseJsonBody(req))); return true; } - if (id && action === 'discussion' && req.method === 'POST') { - sendJson(res, 200, await service.updateDiscussion(scope, id, await parseJsonBody(req))); - return true; - } if (id && action === 'save' && req.method === 'POST') { sendJson(res, 200, await service.save(scope, id)); return true; diff --git a/electron/coding-runtime/pi/release-proof.ts b/electron/coding-runtime/pi/release-proof.ts index 0784662d..0843ca7c 100644 --- a/electron/coding-runtime/pi/release-proof.ts +++ b/electron/coding-runtime/pi/release-proof.ts @@ -25,6 +25,7 @@ import { import type { ConversationPatchEnvelope, PrepareConversationInput } from '../contracts'; import { PiAgentServerProcess } from './agent-server-process'; import { PiManagedExtensionHost } from './extension-host'; +import { MAKELORE_PI_EXTENSION_FILENAME } from './extensions/makelore-runtime'; import { PiManagedInputRevisionCoordinator } from './managed-input-revision'; import { runPiReleasePressureCleanup } from './release-proof-cleanup'; import { ensurePiManagedPaths } from './resource-loader'; @@ -1416,7 +1417,7 @@ export async function runFinalAsarExtensionProof(): Promise item.id === requestId); const structuredReply = currentRequest?.intent === 'suggestions' - || currentRequest?.presentation === 'discussion-v1'; + || currentRequest?.presentation === 'reply-v1' + || currentRequest?.presentation === 'discussion-v1'; // Historical transport marker only. const localContext = { id: requestId, read_protocol: TEACHER_READ_PROTOCOL, diff --git a/electron/coding-teacher/context.ts b/electron/coding-teacher/context.ts index e029465c..6f0e2e98 100644 --- a/electron/coding-teacher/context.ts +++ b/electron/coding-teacher/context.ts @@ -63,12 +63,12 @@ export function teacherHistoryMessages(history: TeacherRequest[]): TeacherSource text: [...(request.projectId ? ['当时的项目:' + (request.projectName ?? request.projectId)] : []), ...request.references.map(ref => '明确引用:\n' + ref.text), request.text].join('\n\n'), }]), - { + ...(request.replyIncomplete ? [] : [{ id: 'teacher:' + request.id + ':assistant', role: 'assistant' as const, text: [request.response, ...(request.suggestedQuestions?.length ? ['可以接着聊的问题:\n' + request.suggestedQuestions.map(text => '- ' + text).join('\n')] : [])].join('\n\n'), - }, + }]), ]); } // A bounded text estimate for the native model path, not an exact tokenizer or @@ -113,7 +113,7 @@ export function compileTeacherContext( const current: TeacherModelMessage = { role: intent === 'check-in' ? 'system' : 'user', content: intent === 'check-in' - ? '本轮是一次项目进展提醒,不是用户提问。按照已配置的人设和职责,结合来源操作对话中已完成的文字和咨询历史回应,保持简短,不要求用户立即回答。仅依据已有证据,不重复上次提醒,不声称实际运行或试玩过作品。' + ? '本轮入口:程序触发的项目进展检查,学生没有在本轮主动提问。回应方式遵循当前智能体的云端配置。' : [ ...references.map( (ref) => @@ -124,14 +124,10 @@ export function compileTeacherContext( ), '当前问题:\n' + question, ...(intent === 'suggestions' - ? [ - '本轮交互要求(仅本轮):依据当前来源操作对话和本咨询历史,邀请学生选择一个可以一起讨论的问题。只返回 JSON 对象 {"intro":string,"questions":string[]},不要附加其他文字。intro 是简短、自然的邀请,不超过 400 字;questions 必须有 2–3 个互不重复、具体贴近当前进展的问题,每个不超过 120 字,用学生自己的口吻表达。问题应符合已配置智能体的职责和当前项目上下文。没有可用上下文时,坦诚说明目前还不了解项目,从学生想做什么、希望谁来用等构思切入;不要编造学生已经完成的功能、作品表现或项目进展,不要生成待办。', - ] + ? ['本轮入口:学生通过“帮我看看”主动请求帮助,尚未提出具体问题。'] : intent === 'guided-help' - ? [ - '本轮交互要求(仅本轮):学生暂时说不清想问什么。依据当前来源操作对话和本咨询历史,只发起一个具体、容易回答的交流起点,帮助学生开口。' + (presentationInstructions ? '按本轮界面协议返回,reply使用简短中文,' : '用正常、简短的中文文字回答,不返回 JSON,') + '不列出多个问题或一串任务。没有可用上下文时,坦诚从构思切入,不假定学生已经完成了任何功能。', - ] - : []), + ? ['本轮入口:学生主动求助,表示暂时说不清想问什么。'] + : []), ].join('\n\n'), }; const sourceMessages = source.messages.map(message => ({ ...message })); @@ -202,7 +198,7 @@ export function compileTeacherContext( throw new TeacherError( 422, 'teacher_configuration_too_long', - '智能体配置或当前整理内容超过上下文预算,请联系运营调整智能体配置或预算。' + '智能体配置或回复协议超过上下文预算,请联系运营调整智能体配置或预算。' ); throw new TeacherError( 422, diff --git a/electron/coding-teacher/conversation-store.ts b/electron/coding-teacher/conversation-store.ts index 6a5d22be..273bb31f 100644 --- a/electron/coding-teacher/conversation-store.ts +++ b/electron/coding-teacher/conversation-store.ts @@ -5,13 +5,14 @@ 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[]; - importedDiscussions?: Record; + /** Retained verbatim when present in older manifests; no longer imported or interpreted. */ + importedDiscussions?: unknown; importedGlobal?: boolean; } const PAGE_SIZE = 50; @@ -19,8 +20,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 { @@ -66,17 +72,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); } @@ -88,7 +100,7 @@ export class TeacherConversationStore { schemaVersion: 1, revision: 0, id: randomUUID(), accountId: this.accountId, projectId: this.projectId, sourceConversationId: 'project', definition: structuredClone(definition), version, createdAt: now, updatedAt: now, requests: [], - conversation: { agentId: this.agentId, segmentTurns: 0, discussions: {} }, + conversation: { agentId: this.agentId, segmentTurns: 0 }, }; // The manifest is durable before any turn file, so orphan recovery knows its owner. const manifest: Manifest = { topic: { ...topic }, turns: [] }; @@ -115,7 +127,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; @@ -134,7 +150,11 @@ export class TeacherConversationStore { const start = Math.max(0, end - Math.min(PAGE_SIZE, Math.max(1, limit))); const requests = await Promise.all(turns.slice(start, end).map(turn => this.live?.requests.find(live => live.id === turn.id) ?? this.turn(turn.id))); - return { requests: structuredClone(requests), before: start > 0 ? turns[start].id : null }; + // Newly imported turns can still be live before they have passed through turn(). + // Repair the returned view too, keeping archived bytes unchanged. + const projection = structuredClone(requests); + projection.forEach(repairCompletedReply); + return { requests: projection, before: start > 0 ? turns[start].id : null }; } async save(topic: TeacherTopic, requestId?: string) { const next = this.writes.catch(() => undefined).then(() => this.persist(topic, requestId)); @@ -226,15 +246,6 @@ export class TeacherConversationStore { await this.save(topic, turn.id); topic.requests = topic.requests.slice(-PAGE_SIZE); } - // Retain an empty project's discussion too, without assigning unowned turns to it. - const discussion = old.topic.conversation.discussions[this.projectId]; - if (discussion) { - const topic = await this.ensure(old.topic.definition, old.topic.version); - if (!topic.conversation!.discussions[this.projectId]) { - topic.conversation!.discussions[this.projectId] = structuredClone(discussion); - (this.manifest!.importedDiscussions ??= {})[this.projectId] = old.topic.updatedAt; - } - } if (this.manifest) { if (seen) await this.markSeen(seen); this.manifest.importedGlobal = true; @@ -280,12 +291,8 @@ export class TeacherConversationStore { await this.save(topic, turn.id); topic.requests = topic.requests.slice(-PAGE_SIZE); } - if (old.discussion && (!topic.conversation!.discussions[project.id] - || old.updatedAt > (this.manifest!.importedDiscussions?.[project.id] ?? ''))) { - (this.manifest!.importedDiscussions ??= {})[project.id] = old.updatedAt; - topic.conversation!.discussions[project.id] = structuredClone(old.discussion); - await this.save(topic, ''); - } + // Retired component state stays in the untouched legacy topic. Import + // its conversation turns without creating active per-project components. } } if (this.manifest) await this.recent(); diff --git a/electron/coding-teacher/discussion.ts b/electron/coding-teacher/discussion.ts deleted file mode 100644 index 94b08f4e..00000000 --- a/electron/coding-teacher/discussion.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import type { TeacherDiscussionAction, TeacherDiscussionContext, TeacherRequest, TeacherTopic } from '../../shared/coding-teacher'; -import { parseTeacherDiscussionContent, parseTeacherDiscussionReply } from '../../shared/teacher-discussion'; -import { TeacherError } from './config-client'; - -const conflict = () => new TeacherError(409, 'teacher_discussion_changed', '这份整理已经更新,请看一下最新内容后再试。'); -export function validateDiscussionContext(topic: TeacherTopic, value?: TeacherDiscussionContext): TeacherDiscussionContext | undefined { - if (value === undefined) return undefined; - if (!value || typeof value !== 'object' || typeof value.toolId !== 'string' || !Number.isSafeInteger(value.revision) - || (value.focusId !== undefined && (typeof value.focusId !== 'string' || value.focusId.length > 129)) - || (value.transition !== undefined && value.transition !== 'structure')) - throw new TeacherError(422, 'teacher_discussion_invalid', '讨论内容无效,请重新选择。'); - const tool = topic.discussion; - if (!tool || tool.id !== value.toolId || tool.revision !== value.revision || tool.status !== 'active') throw conflict(); - if (value.transition && tool.content.kind !== 'ideas') throw conflict(); - if (value.focusId) { - const content = tool.content; - const ids = content.kind === 'ideas' ? content.items.map(item => item.id) - : content.kind === 'comparison' ? content.rows.flatMap(row => content.columns.map(col => `${row.id}:${col.id}`)) - : content.nodes.map(node => node.id); - if (!ids.includes(value.focusId)) throw conflict(); - } - return { toolId: value.toolId, revision: value.revision, - ...(value.focusId ? { focusId: value.focusId } : {}), ...(value.transition ? { transition: value.transition } : {}) }; -} - -export function editDiscussion(topic: TeacherTopic, input: TeacherDiscussionAction): void { - const tool = topic.discussion; - if (!tool || !input || tool.id !== input.toolId || tool.revision !== input.revision) throw conflict(); - const phases = { enter: 'active', resume: 'active', pause: 'paused', finish: 'finished' } as const; - if (Object.hasOwn(phases, input.action)) tool.status = phases[input.action as keyof typeof phases]; - else if (input.action === 'back-ideas') { - if (tool.content.kind !== 'structure' || !tool.previousIdeas) throw conflict(); - tool.previousStructure = structuredClone(tool.content); - tool.content = structuredClone(tool.previousIdeas); - tool.status = 'active'; - } - else { - if (tool.content.kind !== 'ideas' || !['keep-idea', 'aside-idea', 'first-idea'].includes(input.action)) - throw new TeacherError(422, 'teacher_discussion_action_invalid', '这个操作暂不可用。'); - const item = tool.content.items.find(candidate => candidate.id === input.itemId); - if (!item) throw conflict(); - if (input.action === 'first-idea') { - if (item.state !== 'kept') throw conflict(); - tool.content.firstItemId = item.id; - } else { - item.state = input.action === 'keep-idea' ? 'kept' : 'aside'; - if (item.state === 'aside' && tool.content.firstItemId === item.id) delete tool.content.firstItemId; - } - tool.status = 'active'; - tool.content = parseTeacherDiscussionContent(tool.content); - } - tool.revision++; -} - -/** The program controls tool identity/lifecycle; the model only supplies its content. */ -export function applyDiscussionReply(topic: TeacherTopic, request: TeacherRequest, raw: string): void { - const parsed = parseTeacherDiscussionReply(raw); - request.response = parsed.reply; - request.suggestedQuestions = parsed.quickReplies; - if (parsed.toolError) { - request.discussionError = parsed.toolError; - // Keep inspectable evidence instead of replacing the only copy with an error. - request.unparsedResponse = raw; - } - if (!parsed.tool) return; - const current = topic.discussion; - if (!current) { - topic.discussion = { id: randomUUID(), revision: 1, status: 'offered', content: parsed.tool }; - request.discussionSnapshot = structuredClone(parsed.tool); - return; - } - const context = request.discussionContext; - const expectedKind = context?.transition === 'structure' ? 'structure' : current.content.kind; - if (!context || current.status !== 'active' || context.toolId !== current.id || context.revision !== current.revision) return; - if (parsed.tool.kind !== expectedKind) { - request.discussionError = '这次的整理还没放好,原来的内容保留着,可以接着聊。'; - return; - } - if (JSON.stringify(current.content) !== JSON.stringify(parsed.tool)) { - if (context.transition === 'structure' && current.content.kind === 'ideas') { - current.previousIdeas = structuredClone(current.content); - } - current.content = parsed.tool; - current.revision++; - request.discussionSnapshot = structuredClone(parsed.tool); - } -} - -export function discussionInstructions(topic: TeacherTopic, context?: TeacherDiscussionContext): string { - const current = topic.discussion; - const currentInfo = current ? JSON.stringify({ status: current.status, content: current.content, focusId: context?.focusId, - ...(context?.transition && current.previousStructure ? { previousStructure: current.previousStructure } : {}) }) : '目前没有共同整理的内容'; - return [ - '本轮界面协议:只输出一个JSON对象 {"reply":string,"quickReplies":string[],"tool":object|null},不用代码围栏。reply是自然的中文回应,不复述整个组件,也不提JSON或组件类型名。quickReplies最多3句学生可以直接点选发问的话,使用组件时一般为空。', - '正文遵循已配置智能体的人设、职责和本轮问题。quickReplies应围绕当前项目尚需理清的关系、取舍或验证,用学生口吻发问;已有组件内讨论入口时留空。不重复已知信息,不默认增加功能或安排制作。普通解释可在reply中回答,tool填null。tool是展示数据,不是执行工具调用。', - 'tool按信息结构选择:ideas {kind:"ideas",title,items:[{id,text,parentId?,state:"kept"|"suggested"|"aside"}],firstItemId?};structure {kind:"structure",title,nodes:[{id,label,relation?,parentId?}]};flow {kind:"flow",title,nodes:[{id,label,kind:"event"|"condition"|"outcome"}],edges:[{id,from,to,label?}]};comparison {kind:"comparison",title,columns:[{id,label}],rows:[{id,label,cells:[{columnId,text}]}]}。', - 'id使用1–64位ASCII字母、数字、下划线或连字符。title和列label最多120字;节点label、想法text、对照cell.text最多600字,relation最多120字,边label和行label最多160字;quickReplies每句最多120字。结构节点/想法最多24项,流程最多24节点40边,对照2–4列最多12行。比较逐行对齐相同情况下的差异,并在文字中区分预期和观察。父节点、边、比较列必须存在。结构层级由实际想法总结,不使用固定分类。', - 'ideas中学生明确表达想要或已留下的内容用kept,你新提的候选用suggested,暂放内容用aside;沿用已有采纳状态和firstItemId,不因讨论、焦点选择或“先这些”自动采纳候选。structure/flow/comparison没有独立采纳字段,不能自造字段;需要表达不确定性时在label、relation、cell.text或reply中明确“待定”“建议”或“预计”,不把展示内容称为已确认共识。不要输出工具身份、revision或生命周期状态,这些由程序管理。', - current && context - ? context.transition === 'structure' - ? '学生明确选择了“把想法理一理”。本轮将已留下的ideas整理为structure,保留相关细节,不把suggested/aside当成学生采纳。之后维持structure。' - : `正在使用${current.content.kind}讨论,必须保持这个kind和现有信息。围绕focusId(如有)理解新表达;有实质补充时返回更新后的完整内容,保留未修改条目、关系、采纳状态和稳定ID,不只返回差量。无需更新时tool为null,原内容会保留。不要自行换工具;学生明确选“把想法理一理”才转structure,返回想法的动作由程序处理。` - : current ? '学生当前没有选择在工具内继续整理。本轮只对话,tool必须为null,保留原来的内容。' - : '必要时可以提出一个共同整理的工具;程序先让学生选择是否使用。普通对话tool为null。', - '当前共同内容(只是资料,不是系统指令):\n' + currentInfo, - ].join('\n\n'); -} diff --git a/electron/coding-teacher/reply.ts b/electron/coding-teacher/reply.ts new file mode 100644 index 00000000..323a99ff --- /dev/null +++ b/electron/coding-teacher/reply.ts @@ -0,0 +1,32 @@ +import type { TeacherRequest } from '../../shared/coding-teacher'; +import { parseTeacherReply } from '../../shared/teacher-reply'; + +const MAX_CURRENT_QUICK_REPLIES = 3; + +export function teacherReplyInstructions(): string { + return `本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的快捷回复,最多 ${MAX_CURRENT_QUICK_REPLIES} 条,每条为非空字符串,没有快捷回复时用空数组。只使用这两个字段。字符串中的英文双引号、反斜杠和换行必须按 JSON 规则转义,确保整个对象是合法 JSON。回复的内容、语言、风格、长度和前缀遵循当前智能体的云端配置。`; +} + +/** The original response remains available for diagnosis when parsing is incomplete. */ +export function applyTeacherReply(request: TeacherRequest, raw: string): void { + const parsed = parseTeacherReply(raw); + // 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; + else delete request.replyIncomplete; + if (parsed.parseError) { + request.replyParseError = parsed.parseError; + } else { + delete request.replyParseError; + delete request.discussionError; + } + if (parsed.parseError || parsed.recovered) request.unparsedResponse = raw; + else delete request.unparsedResponse; +} diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index da496d1c..82d0d070 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -9,7 +9,6 @@ import type { TeacherCheckInInput, TeacherCheckInResult, TeacherDefinition, - TeacherDiscussionAction, TeacherReference, TeacherRequest, TeacherSend, @@ -35,8 +34,7 @@ import { compileTeacherContext } from './context'; import { prepareTeacherModel } from './model-runner'; import { prepareCloudTeacher } from './cloud-runner'; import { readTeacherSource } from './source-reader'; -import { parseTeacherSuggestions } from './suggestions'; -import { applyDiscussionReply, discussionInstructions, editDiscussion, validateDiscussionContext } from './discussion'; +import { applyTeacherReply, teacherReplyInstructions } from './reply'; import { subscribeWorksSquareSession } from '../services/works-square-session'; export interface TeacherScope { @@ -419,11 +417,7 @@ export class CodingTeacherService { ): Promise { teacherTopicId(input.requestId); const intent = input.intent === undefined ? 'question' : input.intent; - if (input.presentation !== undefined && input.presentation !== 'discussion-v1') - throw new TeacherError(422, 'teacher_presentation_invalid', '智能体的显示方式无效。'); - if (input.discussion !== undefined && input.presentation !== 'discussion-v1') - throw new TeacherError(422, 'teacher_discussion_invalid', '讨论方式无效。'); - const structuredReply = input.presentation === 'discussion-v1' && (intent === 'question' || intent === 'guided-help'); + const structuredReply = intent !== 'check-in'; if (!['question', 'suggestions', 'guided-help', 'check-in'].includes(intent) || (intent === 'check-in' && !checkIn)) throw new TeacherError(422, 'teacher_intent_invalid', '提问方式无效,请重新打开智能体后再试。'); if (intent !== 'question' && ( @@ -462,9 +456,7 @@ export class CodingTeacherService { (scope.agentId && existing.projectId !== scope.projectId) || (existing.intent ?? 'question') !== intent || JSON.stringify(existing.references) !== JSON.stringify(refs) || - (existing.sourceConversationId ?? undefined) !== (input.sourceConversationId ?? undefined) || - existing.presentation !== input.presentation || - JSON.stringify(existing.discussionContext) !== JSON.stringify(input.discussion) + (existing.sourceConversationId ?? undefined) !== (input.sourceConversationId ?? undefined) ) throw new TeacherError(409, 'teacher_request_conflict', '同一请求标识不能用于不同问题。'); return structuredClone(topic); @@ -485,19 +477,19 @@ export class CodingTeacherService { topic.version = selected.version; topic.projectId = scope.projectId; topic.sourceConversationId = 'project'; - topic.discussion = topic.conversation!.discussions[scope.projectId]; const previous = topic.requests.at(-1); const reuse = previous && !previous.origin && previous.projectId === scope.projectId && previous.sourceConversationId === input.sourceConversationId + && previous.presentation === 'reply-v1' && previous.teacherVersion === selected.version && previous.runtimeThreadId; newConversationSegment = !reuse; // Yuxi binds both project and Pi source to a thread. Preserve its native // summary middleware within a segment; explicitly carry bounded public - // history only when a new scope/release starts another segment. + // history only when a new scope, release, or reply protocol starts another + // segment. Older checkpoints may still contain retired component instructions. topic.conversation!.runtimeThreadId = reuse || randomUUID(); topic.conversation!.segmentTurns = reuse ? topic.conversation!.segmentTurns + 1 : 1; } - const discussionContext = validateDiscussionContext(topic, input.discussion); if (topic.draftRevision) { await (this.options.preview ?? teacherPreview)(account, topic.draftRevision); } else if (topic.definition.runtime !== 'yuxi' && topic.definition.config_id) { @@ -584,7 +576,7 @@ export class CodingTeacherService { }) : await (this.options.prepareModel ?? prepareTeacherModel)(account, topic.definition, access, - { finalOnly: structuredReply || intent === 'suggestions' }); + { finalOnly: structuredReply }); if (checkIn) this.assertAccount(account); const compiled = compileTeacherContext( topic.definition, @@ -594,7 +586,7 @@ export class CodingTeacherService { references, model.inputLimit, intent, - structuredReply ? discussionInstructions(topic, discussionContext) : undefined, + structuredReply ? teacherReplyInstructions() : undefined, scope.projectId !== 'preview', model.measureInput ); @@ -617,8 +609,7 @@ export class CodingTeacherService { teacherVersion: topic.version, runtimeThreadId: topic.conversation!.runtimeThreadId } : {}), id: input.requestId, intent, - ...(input.presentation ? { presentation: input.presentation } : {}), - ...(discussionContext ? { discussionContext } : {}), + ...(structuredReply ? { presentation: 'reply-v1' as const } : {}), ...(input.sourceConversationId ? { sourceConversationId: input.sourceConversationId } : {}), ...(checkIn ? { checkInSourceFingerprint: checkIn.fingerprint } : {}), text: input.text, @@ -650,13 +641,13 @@ export class CodingTeacherService { const release = this.options.acquireLease?.(key) ?? (() => undefined); const finish = async () => { const current = topic.requests.find(request => request.id === input.requestId)!; + let structuredText = ''; try { this.assertAccount(account); if (controller.signal.aborted) throw controller.signal.reason; current.status = 'running'; topic.revision++; this.events.emit(key, structuredClone(topic)); - let structuredText = ''; current.usage = await model.run(compiled.messages, controller.signal, (delta) => { if (structuredReply) { structuredText += delta; return; } current.response += delta; @@ -664,12 +655,7 @@ export class CodingTeacherService { this.events.emit(key, structuredClone(topic)); }); this.assertAccount(account); - if (!controller.signal.aborted && structuredReply) applyDiscussionReply(topic, current, structuredText); - if (!controller.signal.aborted && intent === 'suggestions') { - const suggestions = parseTeacherSuggestions(current.response); - current.response = suggestions.intro; - current.suggestedQuestions = suggestions.questions; - } + if (!controller.signal.aborted && structuredReply) applyTeacherReply(current, structuredText); current.status = controller.signal.aborted ? 'cancelled' : 'completed'; } catch (error) { current.status = controller.signal.aborted ? 'cancelled' : 'failed'; @@ -679,6 +665,9 @@ export class CodingTeacherService { ? error.message : '智能体回复失败,已保留本次问题与收到的内容。'; } finally { + // Keep received bytes inert even when an aborted runner resolves normally. + if (structuredReply && current.status !== 'completed' && structuredText) + current.unparsedResponse = structuredText; if (intent === 'suggestions' && current.status !== 'completed') { current.response = ''; delete current.suggestedQuestions; @@ -686,7 +675,6 @@ export class CodingTeacherService { topic.updatedAt = new Date().toISOString(); topic.revision++; try { - if (topic.conversation && topic.discussion) topic.conversation.discussions[scope.projectId] = topic.discussion; await store.save(topic, current.id); } catch { topic.unsaved = true; @@ -702,31 +690,6 @@ export class CodingTeacherService { return structuredClone(topic); }); } - async updateDiscussion(scope: TeacherScope, id: string, input: TeacherDiscussionAction) { - const account = await this.account(); - this.assertWritable(scope); - const key = this.key(account, scope, id); - return await this.serialize(key, async () => { - const { store, topic } = await this.readOwned(account, scope, id); - if (this.active.has(key) || topic.requests.some(request => ['preparing', 'running'].includes(request.status))) - throw new TeacherError(409, 'teacher_topic_busy', '请等待智能体回复,或先停止。'); - const next = structuredClone(topic); - if (next.conversation) { - if (!input.projectId) throw new TeacherError(422, 'teacher_project_required', '请选择讨论所属的项目。'); - if (input.projectId !== scope.projectId) throw new TeacherError(422, 'teacher_project_mismatch', '讨论不属于当前项目。'); - await this.options.projects.getProject(input.projectId); - next.discussion = next.conversation.discussions[input.projectId]; - } - editDiscussion(next, input); - if (next.conversation && next.discussion) next.conversation.discussions[input.projectId!] = next.discussion; - next.revision++; - next.updatedAt = new Date().toISOString(); - this.assertAccount(account); - await store.save(next, scope.agentId ? '' : undefined); - this.events.emit(key, structuredClone(next)); - return structuredClone(next); - }); - } async cancel(scope: TeacherScope, id: string, requestId: string) { const account = await this.account(); const { topic } = await this.readOwned(account, scope, id); diff --git a/electron/coding-teacher/store.ts b/electron/coding-teacher/store.ts index 0af76b66..c68ac768 100644 --- a/electron/coding-teacher/store.ts +++ b/electron/coding-teacher/store.ts @@ -1,9 +1,43 @@ import { mkdir, readdir } from 'node:fs/promises'; import path from 'node:path'; import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json'; -import type { TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher'; +import type { TeacherRequest, TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher'; +import { isTeacherReplyEnvelope, parseTeacherReply } from '../../shared/teacher-reply'; import { TeacherError } from './config-client'; -import { parseTeacherDiscussionContent } from '../../shared/teacher-discussion'; + +/** Repair only received, completed protocol replies; never resume a model request. */ +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. + request.replyIncomplete = true; + return; + } + const previous = request.response.trim(); + // The retained raw data must extend or confirm the existing prose. A stale or + // unrelated archive must not replace a useful answer with different content. + if (previous && !parsed.reply.trim().startsWith(previous) + && previous !== '这次回复没有完整解析,请重试。' + && previous !== raw.trim() && !request.replyIncomplete) return; + request.response = parsed.reply; + request.suggestedQuestions = parsed.quickReplies; + delete request.replyIncomplete; + if (parsed.parseError) request.replyParseError = parsed.parseError; + else { + delete request.replyParseError; + delete request.discussionError; + } + // Unlike applying a new reply, reading history never discards the original. +} export function teacherTopicId(id: string): string { if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id)) @@ -60,40 +94,21 @@ export class TeacherTopicStore { if (topic.id !== id || topic.schemaVersion !== 1 || !Array.isArray(topic.requests)) throw new TeacherError(409, 'teacher_topic_invalid', '智能体历史无法读取。'); let recovered = false; - if (topic.discussion) { - try { - const discussion = topic.discussion; - teacherTopicId(discussion.id); - if (!Number.isSafeInteger(discussion.revision) || discussion.revision < 1 - || !['offered', 'active', 'paused', 'finished'].includes(discussion.status)) throw new Error('Invalid discussion'); - discussion.content = parseTeacherDiscussionContent(discussion.content); - for (const key of ['previousIdeas', 'previousStructure'] as const) { - if (!discussion[key]) continue; - try { - const previous = parseTeacherDiscussionContent(discussion[key]); - if (key === 'previousIdeas' && previous.kind === 'ideas') discussion.previousIdeas = previous; - else if (key === 'previousStructure' && previous.kind === 'structure') discussion.previousStructure = previous; - else throw new Error('Invalid history'); - } catch { delete discussion[key]; recovered = true; } - } - } catch { delete topic.discussion; recovered = true; } - } + // Legacy component fields are opaque archives: never validate, revive or delete them. for (const request of topic.requests) { - if (request.discussionSnapshot) { - try { request.discussionSnapshot = parseTeacherDiscussionContent(request.discussionSnapshot); } - catch { delete request.discussionSnapshot; recovered = true; } - } - } - for (const request of topic.requests) if (request.status === 'preparing' || request.status === 'running') { request.status = 'interrupted'; request.error = '应用已重启,本次回复中断。'; recovered = true; } + } if (recovered) { topic.revision++; await this.save(topic); } + // Apply display repair after restart recovery is saved, so reading a + // mixed topic does not incidentally persist completed-reply repairs. + for (const request of topic.requests) repairCompletedReply(request); return topic; })(); this.cache.set(id, pending); diff --git a/electron/coding-teacher/suggestions.ts b/electron/coding-teacher/suggestions.ts deleted file mode 100644 index 62f1749e..00000000 --- a/electron/coding-teacher/suggestions.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { TeacherError } from './config-client'; - -export interface TeacherSuggestions { - intro: string; - questions: string[]; -} - -/** Validate model output before it can become interactive student questions. */ -export function parseTeacherSuggestions(response: string): TeacherSuggestions { - const invalid = () => new TeacherError( - 502, - 'teacher_suggestions_invalid', - '智能体这次没能整理好可以讨论的问题,请再试一次,或直接告诉智能体你的想法。' - ); - const text = response.trim(); - const fenced = text.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i); - let parsed: unknown; - try { - parsed = JSON.parse(fenced ? fenced[1] : text); - } catch { - throw invalid(); - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw invalid(); - const { intro, questions } = parsed as Record; - if ( - typeof intro !== 'string' || !intro.trim() || intro.trim().length > 400 || - !Array.isArray(questions) || questions.length < 2 || questions.length > 3 || - questions.some((question) => typeof question !== 'string' || !question.trim() || question.trim().length > 120) - ) throw invalid(); - const uniqueQuestions = [...new Set(questions.map((question: string) => question.trim()))]; - if (uniqueQuestions.length < 2) throw invalid(); - return { intro: intro.trim(), questions: uniqueQuestions }; -} diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index c82a1ac2..a23dcf34 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -1,4 +1,3 @@ -import type { TeacherDiscussion, TeacherDiscussionContent } from './teacher-discussion'; import type { PublicUsage } from './coding-conversation-contracts'; /** Only retained for locating conversations created by older clients. */ @@ -67,19 +66,6 @@ export type TeacherRequestStatus = | 'failed' | 'cancelled' | 'interrupted'; -export interface TeacherDiscussionContext { - toolId: string; - revision: number; - focusId?: string; - transition?: 'structure'; -} -export interface TeacherDiscussionAction { - projectId?: string; - toolId: string; - revision: number; - action: 'enter' | 'pause' | 'finish' | 'resume' | 'keep-idea' | 'aside-idea' | 'first-idea' | 'back-ideas'; - itemId?: string; -} export interface TeacherRequest { /** Frozen when this turn is accepted; history can span projects and releases. */ projectId?: string; @@ -87,11 +73,18 @@ export interface TeacherRequest { teacherVersion?: number; runtimeThreadId?: string; origin?: { projectId: string; topicId: string; requestId: string }; - presentation?: 'discussion-v1'; - discussionContext?: TeacherDiscussionContext; - discussionSnapshot?: TeacherDiscussionContent; + /** discussion-v1 is retained only to identify historical replies. */ + presentation?: 'reply-v1' | 'discussion-v1'; + /** Archived legacy data; never parsed into interactive component state. */ + discussionContext?: unknown; + discussionSnapshot?: unknown; discussionError?: string; - /** Original answer retained when discussion parsing fails; never fed back as context. */ + 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[]; @@ -127,10 +120,12 @@ export interface TeacherTopic { agentId: string; runtimeThreadId?: string; segmentTurns: number; - discussions: Record; + /** Archived per-project component data; never selected or updated by new turns. */ + discussions?: Record; seenThrough?: string; }; - discussion?: TeacherDiscussion; + /** Archived legacy data; new requests do not create or update it. */ + discussion?: unknown; role?: LegacyConsultationRole; revision: number; schemaVersion: 1; @@ -152,8 +147,6 @@ export interface TeacherTopicList { } export interface TeacherSend { projectId?: string; - presentation?: 'discussion-v1'; - discussion?: TeacherDiscussionContext; intent?: TeacherRequestIntent; sourceConversationId?: string; requestId: string; diff --git a/shared/teacher-discussion.ts b/shared/teacher-discussion.ts deleted file mode 100644 index 4e01f4c9..00000000 --- a/shared/teacher-discussion.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** Content comes from the teacher; rendering, lifecycle, and type locking belong to the app. */ -export interface TeacherIdeaItem { - id: string; - text: string; - parentId?: string; - state: 'kept' | 'suggested' | 'aside'; -} - -export interface TeacherStructureNode { - id: string; - label: string; - relation?: string; - parentId?: string; -} - -export interface TeacherFlowNode { - id: string; - label: string; - kind: 'event' | 'condition' | 'outcome'; -} - -export interface TeacherFlowEdge { - id: string; - from: string; - to: string; - label?: string; -} - -export interface TeacherComparisonColumn { - id: string; - label: string; -} - -export interface TeacherComparisonRow { - id: string; - label: string; - cells: Array<{ columnId: string; text: string }>; -} - -export type TeacherDiscussionContent = - | { kind: 'ideas'; title: string; items: TeacherIdeaItem[]; firstItemId?: string } - | { kind: 'structure'; title: string; nodes: TeacherStructureNode[] } - | { kind: 'flow'; title: string; nodes: TeacherFlowNode[]; edges: TeacherFlowEdge[] } - | { kind: 'comparison'; title: string; columns: TeacherComparisonColumn[]; rows: TeacherComparisonRow[] }; - -export interface TeacherDiscussion { - id: string; - revision: number; - status: 'offered' | 'active' | 'paused' | 'finished'; - content: TeacherDiscussionContent; - previousIdeas?: Extract; - previousStructure?: Extract; -} - -export interface TeacherDiscussionReply { - reply: string; - quickReplies: string[]; - tool?: TeacherDiscussionContent | null; - toolError?: string; -} - -const MAX_REPLY_LENGTH = 12000; -const MAX_INPUT_BYTES = 64000; -const INVALID_REPLY = '这次回复没有整理完整,请再试一次。'; -const INVALID_TOOL = '这次整理没有完成,先保留原来的内容。'; -const dangerousKeys = new Set(['__proto__', 'prototype', 'constructor']); - -function invalid(): never { - // Validation errors contain no untrusted model content. - throw new Error('老师组件内容格式无效或超过长度限制。'); -} - -function record(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid(); - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) return invalid(); - for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) { - if (dangerousKeys.has(key) || !('value' in descriptor)) return invalid(); - } - return value as Record; -} - -function text(value: unknown, max: number, allowEmpty = false): string { - if (typeof value !== 'string' || value.length > max || (!allowEmpty && !value.trim())) return invalid(); - return value.trim(); -} - -function id(value: unknown): string { - if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(value) || dangerousKeys.has(value)) return invalid(); - return value; -} - -function array(value: unknown, min: number, max: number): unknown[] { - if (!Array.isArray(value) || value.length < min || value.length > max) return invalid(); - // Do not invoke accessor elements, overridden map methods, or custom iterators. - const projected: unknown[] = []; - for (let index = 0; index < value.length; index++) { - const descriptor = Object.getOwnPropertyDescriptor(value, index); - if (!descriptor || !('value' in descriptor)) return invalid(); - projected.push(descriptor.value); - } - return projected; -} - -function uniqueIds(values: T[]): Set { - const ids = new Set(values.map(value => value.id)); - if (ids.size !== values.length) return invalid(); - return ids; -} - -function optionalParent(value: Record): { parentId?: string } { - return value.parentId === undefined ? {} : { parentId: id(value.parentId) }; -} - -function validateParents(values: Array<{ id: string; parentId?: string }>): void { - const ids = uniqueIds(values); - const parents = new Map(values.map(value => [value.id, value.parentId])); - for (const value of values) { - const path = new Set([value.id]); - let parent = value.parentId; - while (parent !== undefined) { - if (!ids.has(parent) || path.has(parent)) return invalid(); - path.add(parent); - parent = parents.get(parent); - } - } -} - -/** Validate and project only the supported data fields. No HTML, style, or executable actions are copied. */ -export function parseTeacherDiscussionContent(value: unknown): TeacherDiscussionContent { - const content = record(value); - const title = text(content.title, 120); - switch (content.kind) { - case 'ideas': { - const items: TeacherIdeaItem[] = array(content.items, 1, 24).map(raw => { - const item = record(raw); - if (item.state !== 'kept' && item.state !== 'suggested' && item.state !== 'aside') return invalid(); - return { id: id(item.id), text: text(item.text, 600), ...optionalParent(item), state: item.state }; - }); - validateParents(items); - if (content.firstItemId !== undefined) { - const firstItemId = id(content.firstItemId); - if (!items.some(item => item.id === firstItemId && item.state === 'kept')) return invalid(); - return { kind: 'ideas', title, items, firstItemId }; - } - return { kind: 'ideas', title, items }; - } - case 'structure': { - const nodes: TeacherStructureNode[] = array(content.nodes, 1, 24).map(raw => { - const node = record(raw); - return { - id: id(node.id), label: text(node.label, 600), ...optionalParent(node), - ...(node.relation === undefined ? {} : { relation: text(node.relation, 120) }), - }; - }); - validateParents(nodes); - return { kind: 'structure', title, nodes }; - } - case 'flow': { - const nodes: TeacherFlowNode[] = array(content.nodes, 1, 24).map(raw => { - const node = record(raw); - if (node.kind !== 'event' && node.kind !== 'condition' && node.kind !== 'outcome') return invalid(); - return { id: id(node.id), label: text(node.label, 600), kind: node.kind }; - }); - const nodeIds = uniqueIds(nodes); - const edges: TeacherFlowEdge[] = array(content.edges, 0, 40).map(raw => { - const edge = record(raw); - const from = id(edge.from), to = id(edge.to); - if (!nodeIds.has(from) || !nodeIds.has(to)) return invalid(); - return { - id: id(edge.id), from, to, - ...(edge.label === undefined ? {} : { label: text(edge.label, 160) }), - }; - }); - uniqueIds(edges); - // Explicit cycles are valid: a game loop or failed attempt may return to an earlier event. - return { kind: 'flow', title, nodes, edges }; - } - case 'comparison': { - const columns: TeacherComparisonColumn[] = array(content.columns, 2, 4).map(raw => { - const column = record(raw); - return { id: id(column.id), label: text(column.label, 120) }; - }); - const columnIds = uniqueIds(columns); - const rows: TeacherComparisonRow[] = array(content.rows, 1, 12).map(raw => { - const row = record(raw); - const cells = array(row.cells, columns.length, columns.length).map(rawCell => { - const cell = record(rawCell); - const columnId = id(cell.columnId); - if (!columnIds.has(columnId)) return invalid(); - return { columnId, text: text(cell.text, 600) }; - }); - if (new Set(cells.map(cell => cell.columnId)).size !== columns.length) return invalid(); - const byColumn = new Map(cells.map(cell => [cell.columnId, cell])); - return { - id: id(row.id), label: text(row.label, 160), - cells: columns.map(column => byColumn.get(column.id)!), - }; - }); - uniqueIds(rows); - return { kind: 'comparison', title, columns, rows }; - } - default: return invalid(); - } -} - -function quickReplies(value: unknown): string[] { - if (value === undefined) return []; - try { - return [...new Set(array(value, 0, 3).map(reply => text(reply, 120)))]; - } catch { return []; } -} - -function stringToken(raw: string, start: number): { value: string; end: number } | undefined { - for (let cursor = start + 1; cursor < raw.length; cursor++) { - if (raw[cursor] === '\\') { cursor++; continue; } - if (raw[cursor] !== '"') continue; - try { - const value: unknown = JSON.parse(raw.slice(start, cursor + 1)); - if (typeof value === 'string') return { value, end: cursor + 1 }; - } catch { return undefined; } - } - return undefined; -} - -/** Inspect only top-level fields, including when a later value was truncated. */ -function findTopLevelField(raw: string, names: readonly string[]): number | undefined { - if (!raw.trimStart().startsWith('{')) return undefined; - let depth = 0, expectingKey = false; - for (let cursor = 0; cursor < raw.length; cursor++) { - const character = raw[cursor]; - if (character === '"') { - const token = stringToken(raw, cursor); - if (!token) return undefined; - if (depth === 1 && expectingKey && names.includes(token.value)) { - let valueStart = token.end; - while (/\s/.test(raw[valueStart] ?? '') && valueStart < raw.length) valueStart++; - if (raw[valueStart] !== ':') return undefined; - valueStart++; - while (/\s/.test(raw[valueStart] ?? '') && valueStart < raw.length) valueStart++; - return valueStart; - } - if (depth === 1) expectingKey = false; - cursor = token.end - 1; - } else if (character === '{' || character === '[') { - depth++; - if (depth === 1) expectingKey = character === '{'; - } else if (character === '}' || character === ']') { - depth--; - } else if (character === ',' && depth === 1) { - expectingKey = true; - } - } - return undefined; -} - -/** Recover a complete reply string, never nested tool data or a partial string. */ -function recoverReply(raw: string): string | undefined { - const start = findTopLevelField(raw, ['reply']); - if (start === undefined || raw[start] !== '"') return undefined; - const value = stringToken(raw, start)?.value; - return value?.trim() && value.length <= MAX_REPLY_LENGTH ? value.trim() : undefined; -} - -function invalidReply(candidate: string): TeacherDiscussionReply { - return { reply: recoverReply(candidate) ?? INVALID_REPLY, quickReplies: [], toolError: INVALID_TOOL }; -} - -/** Parse final model output. Callers keep the existing tool whenever toolError is present. */ -export function parseTeacherDiscussionReply(raw: string): TeacherDiscussionReply { - const trimmed = raw.trim(); - // A Markdown link, JSON example or code fence is not a discussion envelope. - // Only our explicit fence or top-level protocol fields select this parser. - const firstFence = /(?:^|\n)[ \t]*```([^\r\n]*)[ \t]*(?:\r?\n|$)/.exec(trimmed); - const language = firstFence?.[1].trim().toLowerCase(); - const explicitFence = language === 'makelore-teacher' || language === 'makelore-teacher-discussion'; - const opening = firstFence && (explicitFence || language === 'json' || language === '') ? firstFence : null; - const unfencedEnvelope = !firstFence && !trimmed.startsWith('{') - ? /(?:^|\n)[ \t]*(\{\s*"(?:reply|quickReplies|tool)"\s*:)/.exec(trimmed) : null; - let candidate = unfencedEnvelope ? trimmed.slice(unfencedEnvelope.index).trimStart() : trimmed; - let validFence = true; - if (opening) { - const remainder = trimmed.slice(opening.index + opening[0].length); - const closing = /\r?\n```[ \t]*$/.exec(remainder); - candidate = closing ? remainder.slice(0, closing.index) : remainder; - validFence = !!closing && !/(?:^|\n)[ \t]*```/.test(candidate); - } - if (!explicitFence && findTopLevelField(candidate, ['reply', 'quickReplies', 'tool']) === undefined) { - return { reply: trimmed.slice(0, MAX_REPLY_LENGTH) || INVALID_REPLY, quickReplies: [] }; - } - if (new TextEncoder().encode(trimmed).length > MAX_INPUT_BYTES) return invalidReply(candidate.slice(0, MAX_INPUT_BYTES)); - if (!validFence) return invalidReply(candidate); - try { - const envelope = record(JSON.parse(candidate)); - const reply = text(envelope.reply, MAX_REPLY_LENGTH, true); - const result: TeacherDiscussionReply = { reply, quickReplies: quickReplies(envelope.quickReplies) }; - if (envelope.tool === null) result.tool = null; - else if (envelope.tool !== undefined) { - try { result.tool = parseTeacherDiscussionContent(envelope.tool); } - catch { result.toolError = INVALID_TOOL; } - } - if (!reply && !result.tool) result.reply = INVALID_REPLY; - return result; - } catch { return invalidReply(candidate); } -} diff --git a/shared/teacher-reply.ts b/shared/teacher-reply.ts new file mode 100644 index 00000000..b78fe92a --- /dev/null +++ b/shared/teacher-reply.ts @@ -0,0 +1,286 @@ +export interface TeacherReply { + reply: string; + quickReplies: string[]; + parseError?: string; + /** No unambiguous, complete prose field could be recovered. */ + incomplete?: boolean; + /** Prose escaping was repaired; retain the original bytes for diagnosis. */ + recovered?: boolean; +} + +// Resource limits, not writing instructions. Ordinary answers below the transport +// limit are preserved in full, including Markdown and long suggested questions. +export const MAX_TEACHER_REPLY_BYTES = 1024 * 1024; +export const MAX_TEACHER_QUICK_REPLIES = 128; +export const MAX_TEACHER_QUICK_REPLY_BYTES = 64 * 1024; +const INVALID_REPLY = '这次回复没有完整解析,请重试。'; +const INVALID_FORMAT = '回复格式不完整或无效。'; +const RESOURCE_LIMIT = '回复超过可处理的数据大小。'; +const protocolKeys = new Set(['reply', 'quickReplies', 'intro', 'questions', 'tool']); +const own = (value: object, key: string) => Object.prototype.hasOwnProperty.call(value, key); + +function withinBytes(value: string, max: number): boolean { + return value.length <= max && new TextEncoder().encode(value).length <= max; +} + +function stringEnd(raw: string, start: number): number | undefined { + for (let index = start + 1; index < raw.length; index++) { + if (raw[index] === '\\') { index++; continue; } + if (raw[index] === '"') return index + 1; + } + return undefined; +} + +/** A quote alone is not a field boundary: it may be punctuation in the prose. */ +function fieldBoundary(raw: string, end: number): boolean { + let index = end; + while (/\s/.test(raw[index] ?? '') && index < raw.length) index++; + if (raw[index] === '}') { + while (index < raw.length && (raw[index] === '}' || /\s/.test(raw[index]))) index++; + return index === raw.length; + } + if (raw[index++] !== ',') return false; + while (/\s/.test(raw[index] ?? '') && index < raw.length) index++; + if (index === raw.length) return true; // Complete value, missing later fields. + if (raw[index] !== '"') return false; + const keyEnd = stringEnd(raw, index); + if (keyEnd === undefined) return false; + try { JSON.parse(raw.slice(index, keyEnd)); } catch { return false; } + index = keyEnd; + while (/\s/.test(raw[index] ?? '') && index < raw.length) index++; + return raw[index] === ':'; +} + +/** Find one complete value without interpreting nested legacy tool data. */ +function valueEnd(raw: string, start: number): number | undefined { + if (raw[start] === '"') return stringEnd(raw, start); + if (raw[start] !== '{' && raw[start] !== '[') { + let end = start; + while (end < raw.length && !/[,}\]\s]/.test(raw[end])) end++; + return end > start ? end : undefined; + } + const stack = [raw[start]]; + for (let index = start + 1; index < raw.length; index++) { + const character = raw[index]; + if (character === '"') { + const end = stringEnd(raw, index); + if (end === undefined) return undefined; + index = end - 1; + } else if (character === '{' || character === '[') stack.push(character); + else if (character === '}' || character === ']') { + if (stack.pop() !== (character === '}' ? '{' : '[')) return undefined; + if (!stack.length) return index + 1; + } + } + return undefined; +} + +/** Recover only complete top-level fields; nested reply-looking data stays inert. */ +function fields(raw: string): { values: Map; keys: Set; duplicate: boolean; toolStart?: number; proseStart?: number } { + const values = new Map(), keys = new Set(); + let duplicate = false, index = raw.indexOf('{') + 1; + let toolStart: number | undefined; + let proseStart: number | undefined; + const whitespace = () => { while (index < raw.length && /\s/.test(raw[index])) index++; }; + while (index > 0 && index < raw.length) { + whitespace(); + if (raw[index] !== '"') break; + const end = stringEnd(raw, index); + if (end === undefined) break; + let key: string; + try { key = JSON.parse(raw.slice(index, end)); } catch { break; } + index = end; + whitespace(); + if (raw[index++] !== ':') break; + duplicate ||= keys.has(key); + keys.add(key); + whitespace(); + if (key === 'tool') toolStart = index; + if ((key === 'reply' || key === 'intro') && proseStart === undefined) proseStart = index; + const next = valueEnd(raw, index); + if (next === undefined || !fieldBoundary(raw, next)) break; + if ((protocolKeys.has(key) || key === 'kind') && key !== 'tool' && !values.has(key)) { + try { values.set(key, JSON.parse(raw.slice(index, next))); } catch { break; } + } + index = next; + whitespace(); + if (raw[index++] !== ',') break; + } + return { values, keys, duplicate, toolStart, proseStart }; +} + +/** Repair only prose escaping, never fabricate missing text or consume other fields. */ +function repairProse(raw: string, start: number | undefined): string | undefined { + if (start === undefined || raw[start] !== '"') return undefined; + for (let index = start + 1; index < raw.length; index++) { + if (raw[index] === '\\') { index++; continue; } + if (raw[index] !== '"' || !fieldBoundary(raw, index + 1)) continue; + // Stop at the first field boundary even if later auxiliary data is broken. + // Searching past it could swallow tool data into a fabricated prose field. + let encoded = '"'; + for (let cursor = start + 1; cursor < index; cursor++) { + const character = raw[cursor]; + if (character === '\\') { encoded += character + raw[++cursor]; continue; } + encoded += character === '"' || character.charCodeAt(0) < 32 + ? JSON.stringify(character).slice(1, -1) : character; + } + encoded += '"'; + if (encoded === raw.slice(start, index + 1)) return undefined; + const repaired = raw.slice(0, start) + encoded + raw.slice(index + 1); + try { + JSON.parse(repaired); + return repaired; + } catch { return undefined; } + } + return undefined; +} + +interface Candidate { start: number; end: number; content: string; incomplete?: boolean } +function isEnvelope(raw: string): boolean { + const scanned = fields(raw); + if (scanned.keys.has('reply') || scanned.keys.has('quickReplies') + || (scanned.keys.has('intro') && scanned.keys.has('questions'))) return true; + if (scanned.keys.has('intro')) { + const repaired = repairProse(raw, scanned.proseStart); + if (repaired) { + const envelope = JSON.parse(repaired) as Record; + if (own(envelope, 'intro') && own(envelope, 'questions')) return true; + } + } + // "questions" and "tool" also occur in ordinary API/code examples. A tool + // alone identifies the retired reply protocol only through its legacy kind. + if (scanned.toolStart !== undefined && raw[scanned.toolStart] === '{') { + const kind = fields(raw.slice(scanned.toolStart)).values.get('kind'); + if (typeof kind === 'string' && ['ideas', 'structure', 'flow', 'comparison'].includes(kind)) return true; + } + return /^\{\s*(?:reply|quickReplies)\s*:/.test(raw); +} + +/** Markdown examples stay prose unless a top-level reply envelope identifies them. */ +function candidates(raw: string): Candidate[] { + if (raw.startsWith('{') && isEnvelope(raw)) { + const end = valueEnd(raw, 0); + try { + if (end === undefined) throw new Error(); + JSON.parse(raw.slice(0, end)); + } catch { + // Literal newlines/code fences within damaged prose belong to this envelope. + return [{ start: 0, end: raw.length, content: raw }]; + } + } + const result: Candidate[] = []; + const inspectText = (start: number, end: number) => { + for (let index = start; index < end; index++) { + if (raw[index] === '`') { + let fenceEnd = index + 1; + while (raw[fenceEnd] === '`') fenceEnd++; + const close = raw.indexOf(raw.slice(index, fenceEnd), fenceEnd); + index = close >= 0 && close < end ? close + fenceEnd - index - 1 : end; + } else if (raw[index] === '[') { + const endOfArray = valueEnd(raw, index); + if (endOfArray !== undefined && endOfArray <= end) { + try { + if (Array.isArray(JSON.parse(raw.slice(index, endOfArray)))) index = endOfArray - 1; + } catch { /* A Markdown link is not a JSON array. */ } + } + } else if (raw[index] === '{') { + const objectEnd = valueEnd(raw, index); + let stop = objectEnd !== undefined && objectEnd <= end ? objectEnd : end; + let content = raw.slice(index, stop); + if (isEnvelope(content)) { + // A stray quote can also fool bracket scanning. Keep the received tail + // with the malformed envelope instead of appending it as normal prose. + try { JSON.parse(content); } catch { stop = end; content = raw.slice(index, stop); } + result.push({ start: index, end: stop, content }); + } + index = stop - 1; + } + } + }; + const opening = /(?:^|\n)[ \t]*(`{3,}|~{3,})([^\r\n]*)\r?\n/g; + let processed = 0, match: RegExpExecArray | null; + while ((match = opening.exec(raw))) { + inspectText(processed, match.index); + const bodyStart = opening.lastIndex; + const marker = match[1][0], size = match[1].length; + const closing = new RegExp('(?:^|\\n)[ \\t]*' + marker + '{' + size + ',}[ \\t]*(?=\\r?\\n|$)', 'g'); + closing.lastIndex = bodyStart; + const close = closing.exec(raw); + const body = raw.slice(bodyStart, close?.index ?? raw.length).trim(); + const language = match[2].trim().toLowerCase(); + const explicit = ['makelore-teacher', 'makelore-teacher-reply', 'makelore-teacher-discussion'].includes(language); + if (explicit || ((language === 'json' || language === '') && body.startsWith('{') && isEnvelope(body))) { + result.push({ start: match.index, end: close ? closing.lastIndex : raw.length, content: body, incomplete: !close }); + } + processed = close ? closing.lastIndex : raw.length; + opening.lastIndex = processed; + } + inspectText(processed, raw.length); + return result; +} + +/** Historical diagnostics must be identifiable as our envelope, not arbitrary raw text. */ +export function isTeacherReplyEnvelope(raw: string): boolean { + return withinBytes(raw, MAX_TEACHER_REPLY_BYTES) && candidates(raw.trim()).length > 0; +} + +function projectReply(envelope: Record): TeacherReply { + const reply = own(envelope, 'reply') ? envelope.reply : envelope.intro; + const suggestions = own(envelope, 'quickReplies') ? envelope.quickReplies : envelope.questions; + let parseError: string | undefined; + let quickReplies: string[] = []; + if (suggestions !== undefined) { + if (!Array.isArray(suggestions) || suggestions.some(item => typeof item !== 'string' || !item.trim())) { + parseError = INVALID_FORMAT; + } else if (suggestions.length > MAX_TEACHER_QUICK_REPLIES + || suggestions.some(item => !withinBytes(item, MAX_TEACHER_QUICK_REPLY_BYTES))) { + parseError = RESOURCE_LIMIT; + } else quickReplies = suggestions; + } + if (typeof reply !== 'string' || (!reply.trim() && !quickReplies.length)) { + return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true }; + } + return { reply, quickReplies, ...(parseError ? { parseError } : {}) }; +} + +/** Only text and suggested replies survive; legacy tool payloads are never restored. */ +export function parseTeacherReply(raw: string): TeacherReply { + if (!withinBytes(raw, MAX_TEACHER_REPLY_BYTES)) { + return { reply: INVALID_REPLY, quickReplies: [], parseError: RESOURCE_LIMIT, incomplete: true }; + } + const text = raw.trim(); + if (!text) return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true }; + const found = candidates(text); + if (!found.length) return { reply: text, quickReplies: [] }; + const candidate = found[0]; + let result: TeacherReply; + let recovered = fields(candidate.content); + try { + const envelope: unknown = JSON.parse(candidate.content); + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new Error(); + result = projectReply(envelope as Record); + } catch { + const repaired = repairProse(candidate.content, recovered.proseStart); + if (repaired) { + recovered = fields(repaired); + result = { ...projectReply(JSON.parse(repaired)), recovered: true }; + } else { + result = projectReply(Object.fromEntries(recovered.values)); + result.parseError = INVALID_FORMAT; + } + } + if (candidate.incomplete) result.parseError = INVALID_FORMAT; + if (recovered.duplicate || found.length > 1) { + result = { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true }; + } + // Multiple envelopes are ambiguous; never append their JSON as visible prose. + if (found.length === 1) { + const suffix = text.slice(candidate.end).trim(); + const brokenTail = /^[,\s]*"(?:reply|quickReplies|intro|questions|tool)"\s*:/.test(suffix) + || (suffix.length > 0 && /^[}\],\s]+$/.test(suffix)); + if (brokenTail) result.parseError = INVALID_FORMAT; + if (!result.incomplete) result.reply = [text.slice(0, candidate.start).trim(), result.reply, brokenTail ? '' : suffix] + .filter(part => part.length > 0).join('\n\n'); + } + return result; +} diff --git a/src/lib/coding-teacher.ts b/src/lib/coding-teacher.ts index cb123c28..293f1bc7 100644 --- a/src/lib/coding-teacher.ts +++ b/src/lib/coding-teacher.ts @@ -5,7 +5,6 @@ import type { TeacherCheckInResult, TeacherCatalog, TeacherDefinition, - TeacherDiscussionAction, TeacherSend, TeacherTopic, TeacherTopicList, @@ -64,10 +63,6 @@ export const teacherApi = { method: 'POST', body: JSON.stringify(input), }), - updateDiscussion: (base: string, id: string, input: TeacherDiscussionAction) => - hostApiFetch(base + '/' + encodeURIComponent(id) + '/discussion', { - method: 'POST', body: JSON.stringify(input), - }), cancel: (base: string, id: string, requestId: string) => hostApiFetch( base + diff --git a/src/pages/Chat/AgentBrowserPanel.tsx b/src/pages/Chat/AgentBrowserPanel.tsx index bc8e0220..25e1feb3 100644 --- a/src/pages/Chat/AgentBrowserPanel.tsx +++ b/src/pages/Chat/AgentBrowserPanel.tsx @@ -480,23 +480,24 @@ export function AgentBrowserPanel({ ref={panelRef} className={cn(embedded ? 'relative flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden bg-background' : 'relative flex w-[400px] min-w-[300px] max-w-[48vw] shrink-0 flex-col overflow-hidden border-l border-border/80 bg-background shadow-[-16px_0_32px_hsl(220_20%_16%_/_0.06)] max-[900px]:fixed max-[900px]:inset-y-10 max-[900px]:right-0 max-[900px]:z-40 max-[900px]:max-w-[min(92vw,420px)]')} data-testid="agent-browser-panel" + aria-label={embedded ? '作品预览' : '开发浏览器'} > -
+ {!embedded &&
-

{embedded ? '作品预览' : '开发浏览器'}

+

开发浏览器

-
+
}
} + {!retryScopeMatches(request) &&

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

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

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

: request.status === 'completed' && <>
- {request.suggestedQuestions?.map((question) => )} + {request.suggestedQuestions?.map((question, index) => )}
- } - {['failed', 'cancelled', 'interrupted'].includes(request.status) && } - : request.response ? :
{['running', 'preparing'].includes(request.status) ? {`${label}正在想…`} : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
} - {helpEnabled && request.intent !== 'suggestions' && request.status === 'completed' && Boolean(request.suggestedQuestions?.length) &&
- {request.suggestedQuestions?.map(question => )} + {['failed', 'cancelled', 'interrupted'].includes(request.status) && } + : request.response ? :
{['running', 'preparing'].includes(request.status) ? {label}正在想… : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
} + {request.intent !== 'suggestions' && request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) &&
+ {request.suggestedQuestions?.map((question, index) => )}
} - {request.discussionError &&

{request.discussionError}

} + {request.status === 'completed' && !request.replyIncomplete && request.replyParseError &&

回答正文已保留,部分附加内容未能显示。

} {request.unparsedResponse &&
查看收到的原始内容
{request.unparsedResponse}
@@ -535,7 +566,7 @@ function ProjectTeacherChatPanel({ {request.progress && ['running', 'preparing'].includes(request.status) &&

{request.progress}

} {(request.truncatedMessages ?? 0) > 0 &&

{draftRevision ? '较长的上下文已节选。' : '较长的上下文已节选,智能体可按需读取原文。'}

} {request.error &&

{request.error}

} - {onBringBack && request.response && request.intent !== 'suggestions' && request.status === 'completed' &&
+ {onBringBack && request.response && !request.replyIncomplete && request.intent !== 'suggestions' && request.status === 'completed' &&
} @@ -564,9 +595,10 @@ function ProjectTeacherChatPanel({