From 998796d77cba146ae2a007cbebfdaa1255a3413e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Mon, 28 Sep 2026 13:54:55 +0800 Subject: [PATCH 01/10] refactor(teacher): retire discussion components and keep text replies --- ...20260928-teacher-reply-cleanup-c8e4a219.md | 62 ++++ README.md | 18 +- electron/api/routes/coding-teacher.ts | 12 +- electron/coding-teacher/cloud-runner.ts | 3 +- electron/coding-teacher/context.ts | 14 +- electron/coding-teacher/discussion.ts | 108 ------ electron/coding-teacher/reply.ts | 20 ++ electron/coding-teacher/service.ts | 52 +-- electron/coding-teacher/store.ts | 26 +- electron/coding-teacher/suggestions.ts | 33 -- shared/coding-teacher.ts | 29 +- shared/teacher-discussion.ts | 305 ---------------- shared/teacher-reply.ts | 198 +++++++++++ src/lib/coding-teacher.ts | 5 - src/pages/Chat/TeacherChatPanel.tsx | 74 +--- src/pages/Chat/TeacherDiscussionPanel.tsx | 94 ----- src/pages/Chat/teacher-discussion.css | 68 ---- ...discussion-api.ts => teacher-reply-api.ts} | 41 +-- ...on-layout.tsx => teacher-reply-layout.tsx} | 2 +- tests/e2e/pi-coding-first-chat.spec.ts | 5 + ...t.spec.ts => teacher-reply-layout.spec.ts} | 70 ++-- tests/fixtures/teacher-guidance-examples.json | 2 +- tests/unit/coding-teacher-cloud.test.ts | 38 +- tests/unit/coding-teacher-model.test.ts | 10 +- tests/unit/coding-teacher-ui.test.tsx | 182 +++++----- tests/unit/coding-teacher.test.ts | 167 ++++----- tests/unit/teacher-discussion-state.test.ts | 331 ------------------ tests/unit/teacher-discussion.test.ts | 281 --------------- tests/unit/teacher-guidance.test.ts | 164 +++------ tests/unit/teacher-reply-history.test.ts | 208 +++++++++++ tests/unit/teacher-reply.test.ts | 204 +++++++++++ .../teacher-retired-discussion-route.test.ts | 28 ++ 32 files changed, 1060 insertions(+), 1794 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260928-teacher-reply-cleanup-c8e4a219.md delete mode 100644 electron/coding-teacher/discussion.ts create mode 100644 electron/coding-teacher/reply.ts delete mode 100644 electron/coding-teacher/suggestions.ts delete mode 100644 shared/teacher-discussion.ts create mode 100644 shared/teacher-reply.ts delete mode 100644 src/pages/Chat/TeacherDiscussionPanel.tsx delete mode 100644 src/pages/Chat/teacher-discussion.css rename tests/e2e/fixtures/{teacher-discussion-api.ts => teacher-reply-api.ts} (66%) rename tests/e2e/fixtures/{teacher-discussion-layout.tsx => teacher-reply-layout.tsx} (93%) rename tests/e2e/{teacher-discussion-layout.spec.ts => teacher-reply-layout.spec.ts} (57%) delete mode 100644 tests/unit/teacher-discussion-state.test.ts delete mode 100644 tests/unit/teacher-discussion.test.ts create mode 100644 tests/unit/teacher-reply-history.test.ts create mode 100644 tests/unit/teacher-reply.test.ts create mode 100644 tests/unit/teacher-retired-discussion-route.test.ts 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..b563a098 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-teacher-reply-cleanup-c8e4a219.md @@ -0,0 +1,62 @@ +# 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. diff --git a/README.md b/README.md index 9054b049..4aa2b4f1 100644 --- a/README.md +++ b/README.md @@ -170,19 +170,19 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 客户端没有内置老师/朋友角色。已下发智能体以头像和名称并排显示在标题栏,点击后打开对应智能体的讨论;数量较多时横向滚动,咨询面板不再提供智能体下拉框。名称、头像、简介、欢迎语和推荐问题均由服务端提供。首次使用默认选择运营指定项,各智能体的未发送草稿分别保存,已有话题保持绑定的版本;旧的未分配项目草稿可展开查看,不自动转给其他智能体。打开或切换只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。 - 鼠标悬停或键盘聚焦顶部智能体入口时,以奶油色圆角卡片显示该智能体的发布介绍;离开、Escape 或点击入口后收起,没有介绍则不显示空卡片。咨询栏不再显示重复的头像、名称标题条,也不显示介绍及新话题、展开、关闭三个图标;老师身份保留在顶部入口及消息署名中。智能体生成的追加问题以淡蓝、奶油黄、淡紫卡片依次承载,正文 16px、行高 1.8,保留整卡点击发送与忙碌时禁用。回复下方不再显示重复的输入引导或省略早期消息条数;输入框提示为“和老师聊聊”。再次点击当前入口或在咨询栏按 Escape 可收起,切换其他入口仍直接打开对应讨论;拖动边缘调宽保留,新话题文字入口收在展开的“以往讨论”中。悬浮介绍不会创建话题、调用模型或确认未读消息,作品原生预览同时避让介绍卡和真实主动气泡。 - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 -- 学生在当前项目创作时,前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成简短的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 +- 学生在当前项目创作时,前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 - 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 -- 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:学生点击后才结合当前操作对话与咨询历史生成 2–3 个可聊的问题。点击生成的问题会主动开始讨论;“我也说不清,你带我看看”让智能体从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 -- 智能体讨论采用“上方固定整理内容、下方独立滚动对话、底部原有输入框”的布局。普通回答可带直接发送的引导问题;想法板、结构图、流程/条件图和逐项对照由同一次模型回复提供结构化数据。先邀请学生“用这个一起想”,进入后程序锁定信息结构,智能体随讨论更新同一份内容;解释问题可仅回复文字。节点点击只选择讨论焦点,对照里的“聊聊这一点”直接发问,都保留输入草稿。 -- 想法板区分已留下、智能体建议和暂放内容,可由学生采纳、暂放、选择先想哪项。“把想法理一理”明确转换为智能体归纳的结构图,并可“回去补充想法”。“只聊天”暂停整理,“先这些”暂时结束并保留未确定内容,“接着改”恢复原工具;这些操作只影响智能体讨论,不创建项目分支、不执行作品修改。状态、版本和历史快照按当前账号/项目/话题保存。 -- Main 校验结构、引用和内容长度,拒绝未经学生选择的类型变更与旧版本更新。结构化回复在完整校验后一次应用;生成中显示简短处理状态,停止、失败或无效组件保留上一份内容。普通 Markdown 链接、JSON 对象/数组和代码示例保持正文,仅讨论协议的顶层字段或专用围栏进入组件解析。解析失败仍保留完整的可读 reply,并将本次原始回答保存为默认折叠、字面显示的“查看收到的原始内容”,不把它再次加入模型上下文;既有丢失原文的历史不能恢复。 +- 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:学生点击后才结合当前操作对话与咨询历史推荐一个具体切入点,并生成围绕它的快捷回复。点击快捷回复会主动开始讨论;“我也说不清,你带我看看”让智能体从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 +- 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。 +- “帮我看看”和引导开口都由老师结合项目推荐一个具体切入点,快捷回复围绕这个切入点帮助学生接话;客户端不要求多个独立话题,也不固定正文长度、选项数量、前缀或表达风格。老师继续依据已配置职责帮助孩子整理想法、理解关系和承接已确认的共识,区分建议与已确认内容。 +- Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后展示正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。资源上限只用于防护,不做旧式字数截断;格式错误保留可读正文及原始回答,原文默认折叠、字面显示,不再次送进模型。 - 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。 - 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。 - 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。 -- 每轮格式由 Main 的对应意图协议决定;`discussion.ts` 为支持组件的请求注入唯一 `{reply, quickReplies, tool}` 协议。运营教学补充不另写字段协议或要求始终纯文字。工具内讨论保留类型、稳定 ID、未修改内容和采纳状态;暂停/未进入时 `tool:null`,没有实质变化时也可保留原内容。结构图、流程和对照目前没有独立的采纳/来源字段,待定、建议与预测只能在展示文字中明确,不能据此推导已确认共识。 -- 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、咨询历史和适用的当前组件,并支持上述只读原文恢复;没有跨全部操作会话自动读取,也未向操作 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 公网入口补全。 - 咨询话题及未发送草稿按当前云端账号、项目保存;切换操作对话仍延续该项目下的咨询,每轮发送时绑定当时的操作对话来源。创建话题时固定发布版本,每轮重新检查启停。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。云端断线按游标和持久状态恢复;停止或退出账号会取消原云端问题,下次提问先停止旧的未完成问题再绑定新上下文。只接收云端主线程回答,子智能体文本不混入正文。没有操作对话时也可咨询,但无操作文本上下文。 - 删除操作对话会停止正在使用该来源的回复,保留项目级咨询历史;归档来源需先恢复后才能引用。原有来源会话级智能体 API 与存储保持兼容,原路径历史不自动迁移到新项目级咨询栏。运营草稿试聊仍使用 `niancode://coding-teacher/preview?draft_revision=N` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。 diff --git a/electron/api/routes/coding-teacher.ts b/electron/api/routes/coding-teacher.ts index 11e8e740..302597f2 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,13 +18,13 @@ export async function handleCodingTeacherRoutes( ctx: HostApiContext ): Promise { 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; @@ -95,10 +95,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-teacher/cloud-runner.ts b/electron/coding-teacher/cloud-runner.ts index 7332d4c6..ae47f150 100644 --- a/electron/coding-teacher/cloud-runner.ts +++ b/electron/coding-teacher/cloud-runner.ts @@ -207,7 +207,8 @@ export function prepareCloudTeacher( } const currentRequest = topic.requests.find((item) => 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 ef552608..bc90a65c 100644 --- a/electron/coding-teacher/context.ts +++ b/electron/coding-teacher/context.ts @@ -112,7 +112,7 @@ export function compileTeacherContext( const current: TeacherModelMessage = { role: intent === 'check-in' ? 'system' : 'user', content: intent === 'check-in' - ? '本轮是一次项目进展提醒,不是用户提问。按照已配置的人设和职责,结合来源操作对话中已完成的文字和咨询历史回应,保持简短,不要求用户立即回答。仅依据已有证据,不重复上次提醒,不声称实际运行或试玩过作品。' + ? '本轮是一次项目进展提醒,不是用户提问。按照已配置的人设和职责,结合来源操作对话中已完成的文字和咨询历史回应,表达方式遵循已配置的要求,不要求用户立即回答。仅依据已有证据,不重复上次提醒,不声称实际运行或试玩过作品。' : [ ...references.map( (ref) => @@ -122,15 +122,11 @@ export function compileTeacherContext( ref.text ), '当前问题:\n' + question, - ...(intent === 'suggestions' + ...(intent === 'suggestions' || intent === 'guided-help' ? [ - '本轮交互要求(仅本轮):依据当前来源操作对话和本咨询历史,邀请学生选择一个可以一起讨论的问题。只返回 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 })); @@ -201,7 +197,7 @@ export function compileTeacherContext( throw new TeacherError( 422, 'teacher_configuration_too_long', - '智能体配置或当前整理内容超过上下文预算,请联系运营调整智能体配置或预算。' + '智能体配置或回复协议超过上下文预算,请联系运营调整智能体配置或预算。' ); throw new TeacherError( 422, 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..0356e4f8 --- /dev/null +++ b/electron/coding-teacher/reply.ts @@ -0,0 +1,20 @@ +import type { TeacherRequest } from '../../shared/coding-teacher'; +import { parseTeacherReply } from '../../shared/teacher-reply'; + +export function teacherReplyInstructions(): string { + return '本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的后续回复建议,没有建议时用空数组。只使用这两个字段。依据已配置职责帮助学生整理想法、理解关系、承接已确认的共识;区分学生已确认内容与智能体建议、待定想法。回复的语言、风格、长度、前缀和建议内容遵循当前智能体的云端配置。'; +} + +/** The original response remains available for diagnosis when parsing is incomplete. */ +export function applyTeacherReply(request: TeacherRequest, raw: string): void { + const parsed = parseTeacherReply(raw); + request.response = parsed.reply; + request.suggestedQuestions = parsed.quickReplies; + if (parsed.parseError) { + request.replyParseError = parsed.parseError; + request.unparsedResponse = raw; + } else { + delete request.replyParseError; + delete request.unparsedResponse; + } +} diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index 6aebbc1f..acf2126b 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -9,7 +9,6 @@ import type { TeacherCheckInInput, TeacherCheckInResult, TeacherDefinition, - TeacherDiscussionAction, TeacherReference, TeacherSend, TeacherSourceContext, @@ -31,8 +30,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 { @@ -317,11 +315,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' && ( @@ -355,9 +349,7 @@ export class CodingTeacherService { existing.text !== input.text || (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); @@ -369,7 +361,6 @@ export class CodingTeacherService { ) ) throw new TeacherError(409, 'teacher_topic_busy', '请等待当前回复完成,或先停止。'); - 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) { @@ -455,7 +446,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, @@ -465,7 +456,7 @@ export class CodingTeacherService { references, model.inputLimit, intent, - structuredReply ? discussionInstructions(topic, discussionContext) : undefined, + structuredReply ? teacherReplyInstructions() : undefined, scope.projectId !== 'preview', model.measureInput ); @@ -485,8 +476,7 @@ export class CodingTeacherService { const request = { 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, @@ -518,13 +508,13 @@ export class CodingTeacherService { const release = this.options.acquireLease?.(key) ?? (() => undefined); const finish = async () => { const current = topic.requests.at(-1)!; + 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; @@ -532,12 +522,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'; @@ -547,6 +532,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; @@ -569,24 +557,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); - editDiscussion(next, input); - next.revision++; - next.updatedAt = new Date().toISOString(); - this.assertAccount(account); - await store.save(next); - 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 7614837b..e572890b 100644 --- a/electron/coding-teacher/store.ts +++ b/electron/coding-teacher/store.ts @@ -3,7 +3,6 @@ import path from 'node:path'; import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json'; import type { TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher'; import { TeacherError } from './config-client'; -import { parseTeacherDiscussionContent } from '../../shared/teacher-discussion'; export function teacherTopicId(id: string): string { if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id)) @@ -60,30 +59,7 @@ 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; } - } - for (const request of topic.requests) { - if (request.discussionSnapshot) { - try { request.discussionSnapshot = parseTeacherDiscussionContent(request.discussionSnapshot); } - catch { delete request.discussionSnapshot; recovered = true; } - } - } + // Legacy component fields are opaque archives: never validate, revive or delete them. for (const request of topic.requests) if (request.status === 'preparing' || request.status === 'running') { request.status = 'interrupted'; 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 4bc24280..c4fdeb36 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,24 +66,15 @@ export type TeacherRequestStatus = | 'failed' | 'cancelled' | 'interrupted'; -export interface TeacherDiscussionContext { - toolId: string; - revision: number; - focusId?: string; - transition?: 'structure'; -} -export interface TeacherDiscussionAction { - toolId: string; - revision: number; - action: 'enter' | 'pause' | 'finish' | 'resume' | 'keep-idea' | 'aside-idea' | 'first-idea' | 'back-ideas'; - itemId?: string; -} export interface TeacherRequest { - 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; + /** Original answer retained when reply parsing fails; never fed back as context. */ unparsedResponse?: string; toolActivity?: TeacherToolActivity[]; @@ -115,7 +105,8 @@ export interface TeacherToolActivity { status: 'running' | 'completed' | 'failed'; } export interface TeacherTopic { - discussion?: TeacherDiscussion; + /** Archived legacy data; new requests do not create or update it. */ + discussion?: unknown; role?: LegacyConsultationRole; revision: number; schemaVersion: 1; @@ -136,8 +127,6 @@ export interface TeacherTopicList { lastSelectedTopicId: string | null; } export interface TeacherSend { - 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..62eec581 --- /dev/null +++ b/shared/teacher-reply.ts @@ -0,0 +1,198 @@ +export interface TeacherReply { + reply: string; + quickReplies: string[]; + parseError?: string; +} + +// 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; +} + +/** 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 } { + const values = new Map(), keys = new Set(); + let duplicate = false, index = raw.indexOf('{') + 1; + let toolStart: 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; + const next = valueEnd(raw, index); + if (next === undefined) 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 }; +} + +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; + // "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[] { + 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); + const stop = objectEnd !== undefined && objectEnd <= end ? objectEnd : end; + const content = raw.slice(index, stop); + if (isEnvelope(content)) 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; +} + +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 }; + } + 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 }; + } + const text = raw.trim(); + if (!text) return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT }; + const found = candidates(text); + if (!found.length) return { reply: text, quickReplies: [] }; + const candidate = found[0]; + let result: TeacherReply; + const 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 { + result = projectReply(Object.fromEntries(recovered.values)); + result.parseError = INVALID_FORMAT; + } + if (candidate.incomplete || recovered.duplicate || found.length > 1) result.parseError = INVALID_FORMAT; + // 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; + 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 6b445db9..59a874e5 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, @@ -47,10 +46,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/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index 2f1801a0..2b4b5678 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -10,11 +10,16 @@ import type { TeacherTopic, TeacherTopicList, } from '../../../shared/coding-teacher'; -import { TeacherDiscussionPanel, DiscussionFocus } from './TeacherDiscussionPanel'; -import type { TeacherDiscussionAction } from '../../../shared/coding-teacher'; import { TeacherAvatar } from './TeacherAvatar'; import { TeacherReply } from './TeacherReply'; +function restorePendingSend(saved?: TeacherSend): TeacherSend | null { + if (!saved) return null; + // Keep an interrupted send retryable without restoring retired UI protocol fields. + const { requestId, text, references, intent, sourceConversationId } = saved; + return { requestId, text, references, intent, sourceConversationId }; +} + export function TeacherChatPanel({ projectId, sourceId, @@ -57,9 +62,7 @@ export function TeacherChatPanel({ try { localStorage.setItem(draftKey, JSON.stringify({ text, references, pending })); } catch { /* Draft remains in the open panel. */ } }, [draftKey]); const label = '智能体'; - const discussionEnabled = !draftRevision; const [historyOpen, setHistoryOpen] = useState(false); - const [focus, setFocus] = useState<{ topicId: string; id: string; label: string } | null>(null); const [definition, setDefinition] = useState(null); const externalTopicRef = useRef(externalTopic); externalTopicRef.current = externalTopic; @@ -88,7 +91,7 @@ export function TeacherChatPanel({ : topic.definition.config_id ? teachers.some((item) => item.teacher_id === topic.definition.config_id) : legacyEnabled; - const pending = useRef(saved.pending ?? null); + const pending = useRef(restorePendingSend(saved.pending)); useEffect(() => { persistDraft(text, references, pending.current); }, [text, references, persistDraft]); @@ -96,12 +99,6 @@ export function TeacherChatPanel({ const topicRef = useRef(topic); topicRef.current = topic; const scrollRef = useRef(null); - const content = topic?.discussion?.content; - const activeFocus = focus && focus.topicId === topic?.id && content && topic?.discussion?.status === 'active' && ( - content.kind === 'ideas' ? content.items.some(item => item.id === focus.id) - : content.kind === 'comparison' ? content.rows.some(row => content.columns.some(column => `${row.id}:${column.id}` === focus.id)) - : content.nodes.some(node => node.id === focus.id) - ) ? focus : null; useEffect(() => { const pane = scrollRef.current; if (pane) pane.scrollTop = pane.scrollHeight; @@ -274,7 +271,7 @@ export function TeacherChatPanel({ if (version === generation.current) setBusy(false); } }; - const send = async (action?: Pick) => { + const send = async (action?: Pick) => { const question = action?.text ?? text; const questionReferences = action ? [] : references; if (!question.trim() || busy || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return; @@ -294,24 +291,17 @@ export function TeacherChatPanel({ consume(current); setDefinition(current.definition); } - const presentation = discussionEnabled && (!action?.intent || action.intent === 'question' || action.intent === 'guided-help') ? 'discussion-v1' as const : undefined; - const discussion = presentation && current.discussion?.status === 'active' - ? action?.discussion ?? { toolId: current.discussion.id, revision: current.discussion.revision, - ...(activeFocus?.topicId === current.id ? { focusId: activeFocus.id } : {}) } : undefined; const previous = pending.current && !current.requests.some((request) => request.id === pending.current?.requestId) ? pending.current : null; const input = previous && previous.text === question && - previous.presentation === presentation && - JSON.stringify(previous.discussion) === JSON.stringify(discussion) && (previous.intent ?? 'question') === (action?.intent ?? 'question') && JSON.stringify(previous.references) === JSON.stringify(questionReferences) ? previous : { requestId: crypto.randomUUID(), text: question, references: questionReferences, - ...(presentation ? { presentation } : {}), ...(discussion ? { discussion } : {}), ...(action?.intent ? { intent: action.intent } : {}), ...(sourceId ? { sourceConversationId: sourceId } : {}), }; @@ -340,33 +330,6 @@ export function TeacherChatPanel({ (request) => request.status === 'preparing' || request.status === 'running' ); const helpUnavailable = busy || !topicEnabled || Boolean(running); - const updateDiscussion = async (action: TeacherDiscussionAction['action'], itemId?: string) => { - const current = topicRef.current; - if (!current?.discussion || helpUnavailable) return; - const version = generation.current; - setBusy(true); - setError(''); - try { - const next = await teacherApi.updateDiscussion(topicBase, current.id, { - toolId: current.discussion.id, revision: current.discussion.revision, action, ...(itemId ? { itemId } : {}), - }); - if (version === generation.current) { consume(next); setFocus(null); } - } catch (e) { - if (version === generation.current) setError(e instanceof Error ? e.message : '内容还没更新,请再试一次。'); - } finally { if (version === generation.current) setBusy(false); } - }; - const focusItem = (id: string, label: string) => { - if (!topic) return; - setFocus({ topicId: topic.id, id, label }); - inputRef.current?.focus(); - }; - const structureIdeas = () => { - if (!topic?.discussion) return; - setFocus(null); - void send({ text: '把我留下的想法理一理,看看它们怎么联系。', discussion: { - toolId: topic.discussion.id, revision: topic.discussion.revision, transition: 'structure', - } }); - }; const askForSuggestions = () => void send({ text: '帮我看看', intent: 'suggestions' }); const askForGuidance = () => void send({ text: '我也说不清,你带我看看', intent: 'guided-help' }); const changeText = (next: string) => { setText(next); persistDraft(next, references, pending.current); }; @@ -386,13 +349,6 @@ export function TeacherChatPanel({ className="shrink-0 rounded-lg px-2 text-xs text-muted-foreground hover:bg-white hover:text-foreground disabled:opacity-40">新话题 } } - {discussionEnabled && topic?.discussion && void updateDiscussion(action, itemId)} onFocus={focusItem} onStructure={structureIdeas} - onDiscuss={(id, question) => { - focusItem(id, question); - void send({ text: question, discussion: { toolId: topic.discussion!.id, revision: topic.discussion!.revision, focusId: id } }); - }} />}
{!topic?.requests.length &&
@@ -421,16 +377,15 @@ export function TeacherChatPanel({ : 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) ? {request.discussionContext ? '正在梳理你的新想法…' : `${label}正在想…`} : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
} - {discussionEnabled && request.intent !== 'suggestions' && request.status === 'completed' && topic.discussion?.status !== 'active' && topic.discussion?.status !== 'offered' && Boolean(request.suggestedQuestions?.length) &&
- {request.suggestedQuestions?.map(question => )} + : request.response ? :
{['running', 'preparing'].includes(request.status) ? {label}正在想… : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
} + {request.intent !== 'suggestions' && request.status === 'completed' && Boolean(request.suggestedQuestions?.length) &&
+ {request.suggestedQuestions?.map((question, index) => )}
} - {request.discussionError &&

{request.discussionError}

} {request.unparsedResponse &&
查看收到的原始内容
{request.unparsedResponse}
@@ -463,12 +418,11 @@ export function TeacherChatPanel({
} {references.length > 0 &&

{references.map((ref) => ref.text).join('\n')}

}
- {activeFocus && setFocus(null)} />}