Compare commits

...
5 Commits
Author SHA1 Message Date
brother7 39b03a1628 docs: 同步智能体咨询渲染集成记录
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
2026-09-24 14:57:30 +08:00
brother7 98559e32a7 fix: 合入智能体咨询解析与渲染修复 2026-09-24 14:56:16 +08:00
brother7 97837cef90 fix: render consultation replies and typed tool activity 2026-09-24 14:36:20 +08:00
brother7 a628860144 docs: record missing evidence for discussion reply failure 2026-09-24 14:01:58 +08:00
brother7 3c68155ecd fix: avoid stray zero in consultation composer 2026-09-24 13:51:34 +08:00
23 changed files with 543 additions and 31 deletions

No files matched your search

@@ -18,8 +18,13 @@
- 当前咨询与草稿按账号、项目保存,每轮绑定当时的操作会话。旧客户端生成的朋友话题及草稿只读保留,不再继续调用,也不自动绑定云端智能体;旧来源会话级历史不自动迁移。现有云端 `coding-teacher` 契约与存储名保留为实现兼容,不构成角色分类。
- 输入预算在编译时计入完整执行请求。本地旧模型路径按约 2 UTF-8 字节估算 1 Token,工具定义、调用和读取预留纳入同一计量;估算不替代模型上限或实际 usage。云端以完整 JSON 转义后的 query 字节数裁剪来源节选,原文仍可按 ID 读取。固定配置/当前讨论超限与用户问题/引用超限分别提示,不静默修改发布预算、问题、明确引用或话题版本。
- 咨询回答以 Markdown 渲染标题、列表、表格、代码、HTTP(S) 链接/图片和公式,代码与表格在栏内滚动;普通 JSON、Markdown 链接与代码中的字面转义保持原义。discussion-v1 仅由顶层 reply/quickReplies/tool 或专用围栏识别,组件仍遵循既有生命周期。解析失败保留原讨论卡与可恢复正文,完整 unparsedResponse 单独持久保存并默认折叠,不再次注入后续会话上下文。
- 工具活动使用实际主线程事件及 Main 本地读取结果的名称/状态,按 run/call 关联与去重,单独折叠展示;参数、输出、错误原文与推理不进入活动 DTO 或回答。未观察到结果的调用明确显示结果缺失,不补写过期事件或旧话题。Yuxi chat_service 保证只有 AI 角色进入回答流,客户端不按内容外形猜测清洗。
## Evidence And Release Boundary
- 2026-09-24 渲染源 `97837cef90baf6bb02cfda0cef7b938081f3c5f7` 与 Yuxi 配套源 `02f27f33a8b816589036503f0720d446418c28aa` 已纳入本地主分支集成。客户端源 287 项单测、12 项真实浏览器布局用例及独立审查通过;Yuxi 82 项相关协议回归与真实 LangGraph 确定性模型测试通过。浏览器接口为替身,不代表安装版或线上模型已验收;旧版已覆盖的失败原文无法恢复。
- 2026-09-24 客户端源 `8a157be4f62d073744164318e02185aebeaadc09` 包含预算修复 `de72b1cd945df373d09402a1cf3a0e9b34832f03`。预算修复通过 289 项相关测试;统一智能体覆盖 323 项相关用例、类型/lint/生产构建、2 项 Electron 交互和 13 项布局测试。扩展 Main 类型检查仍有基线 66 项诊断,无新增;不代表真实收费模型、已安装客户端或生产整链验收。详见两份源任务记录。
- 配套源:Works Square 79b1da574364d6f412e4395c179b5d27cc7bbd7d;Yuxi c2792dc4938a68db8ce1e34056e84af4b06c739b;MakeLore d20c818fe79bc5833cef15d99974b699dde71c6d。
- 源验证包括 WS 66 passed / 1 skipped、Operations 125 项及构建/浏览器验证;Yuxi 2243 passed / 1 skipped、真实 HTTP/PostgreSQL/Redis/worker/SDK 续接验证;MakeLore 51 项相关测试、typecheck、lint、构建及已有 Electron 流程验证。模型使用明确替身,真实供应商扣款与完整生产拓扑未验收。
@@ -4,6 +4,8 @@ Code 咨询由 Works Square 运营下发的 Yuxi 智能体执行,客户端不
## Current Architecture
咨询正文由 TeacherReply 渲染 Markdown、GFM 与公式,Main 只按明确的 discussion-v1 字段解析讨论组件;普通 JSON/代码仍是正文。失败原文默认折叠持久保存且不重新注入上下文。云端主线程与本地读取活动投影为独立的工具名称/状态,参数、结果和推理不进入回答。Yuxi 在消息投影处筛选 AI 角色;详见[咨询决定](../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#知识文档处理与可用状态)。
知识库 embedding 独立于原有 one-api 模型网关:Yuxi 拥有目录、供应商地址/密钥与实际维度并直连供应商,平台承担费用,不计算用户用量、词元点数或智能体金额预算。访问资格与运行时限保留,聊天计费不变;见[已接受向量规则](../10-decisions/ADR-2026-09-11-personal-cloud-agents.md)。
@@ -4,6 +4,8 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
## Integrated Through
- 2026-09-24:在 `d2150ce5de5cec541f65eba9260d3cdc5172a66d` 上无冲突集成源 `97837cef90baf6bb02cfda0cef7b938081f3c5f7`,保留主线工作台样式。咨询支持 Markdown/表格/代码/公式/图片,普通 JSON 与代码不再被误判为讨论协议;格式失败原文独立折叠留存,工具名称/状态独立展示,修复多余数字 0。配套 Yuxi 源 `02f27f33a8b816589036503f0720d446418c28aa` 在主分支保证 AI 角色投影。合并树重新通过 287 项相关单测、12 项 319/508px 浏览器布局、Renderer typecheck、scoped lint 和 Vite 全目标构建。未推送、打包或部署,真实模型整链验收未执行。旧版已覆盖的原文不能恢复。详见[源任务](tasks/20260924-agent-rendering-a8c2d419.md)、[集成记录](tasks/20260924-merge-agent-rendering-ml-4efb196c.md)及[咨询决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)。用户批准释放旧集成登记,并以当前主线为基线重新登记;原三份未提交文档仍原样保留,不修改历史来源记录。
- 2026-09-24:将客户端源 `8a157be4f62d073744164318e02185aebeaadc09` 从 `eb3dc85a2f0ed8457b7f24684c2da3ab192c2e46` 无冲突快进合入本地 `main`,包括预算修复 `de72b1cd945df373d09402a1cf3a0e9b34832f03` 及其源记录 `248776d8550d67de7ce30802a9f827d432c42a99`。
咨询统一使用运营下发的智能体,移除固定老师/朋友角色、本地人设与按名称限制工具的逻辑;名称、头像、简介、欢迎语和推荐问题取自发布定义。旧合成朋友话题与草稿只读保留,不自动绑定或提交;项目级草稿、已发布版本、学生计费和现有讨论/主动跟进保持。
输入预算区分近似模型 Token 与完整 JSON query 字节,固定配置超限单独提示,修复短问题被错误拦截。源覆盖 323 项相关用例,类型/lint/生产构建、2 项 Electron 交互和 13 项布局测试通过;预算源另有 289 项相关测试。扩展 Main 仍为基线 66 项诊断,无新增。合并未改产品实现,沿用源验证;未推送、打包、安装或做真实付费模型验收。
@@ -0,0 +1,62 @@
# Task: Fix consultation response rendering
## Identity
- Task ID: 20260924-agent-rendering-a8c2d419
- Mode: Feature
- Branch: codex/20260924-agent-rendering-a8c2d419-agent-rendering
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260924-agent-rendering-a8c2d419
- Base commit: 0a8fce67a4ea6de291f82c09c17d3efe0ea7b92c
- Owner: codex
- Status: Ready for Integration
## Scope
- 修复用户截图中的咨询面板渲染:空旧草稿导致数字 0;定位序列化项目上下文进入智能体回复的问题。云端事件生产问题在独立 Yuxi 任务处理,客户端随后按用户要求完善回答解析、富文本和工具活动展示。
- 后续用户追加“这次回复没有整理完整 / 这次整理没有完成”的截图,继续同一回复渲染任务,核对结构化回复解析;先前产品修复提交 3c68155 保留且尚未合并部署。
- 2026-09-24 用户进一步要求参考 Yuxi 问答页面完善客户端数据适配、解析和渲染。继续本任务:区分正常 Markdown/JSON 示例与讨论协议,使用专门的正文渲染,保留解析失败的可检查原文;只投影工具身份与状态,不改变云端正文 string 合同,不以此声称找回两条已丢失回答。
## Intent And Constraints
- maintain-project-docs 与 diagnosing-bugs 已加载。官方 check/start/status、项目记忆与 peer scope 检查完成,Concurrent/Planning Gates Passed;Feature 模式仅写本人记录和必要产品/测试文件。
- 保留用户话题原文,不批量替换转义字符,不以 JSON 外形猜测并删除回复。线上事件探针被自动审批以 blocked by policy 拒绝,已停止该访问方式,改用本地数据和真实协议测试。用户随后批准创建 1 个全新只读 Reviewer,已委派两端补丁审查。
## Outcome
- 已从本地历史确认异常回复含序列化上下文;Yuxi 对非 AI 消息的错误投影在关联任务修复,客户端不添加猜测式内容清洗。
- 空旧草稿条件显式转换为 Boolean,避免 references.length 为 0 时作为 React 子节点显示。保留有内容的旧草稿入口,产品流程不变;README 无需改变。
- 两条历史格式错误的原始回答无法还原,仍不能声称找到其唯一根因。用户随后授权按 Yuxi 问答方式改进渲染,已建立受支持内容的独立回归:7种 Markdown/JSON/代码示例误判及1项纯文本渲染失败先红后绿。
- discussion-v1 的 parser 仅识别顶层 reply/quickReplies/tool 字段或专用围栏。Markdown 链接、普通 JSON 对象/数组、嵌套 reply 数据与 JS 示例保持正文;保留原有有效 envelope、完整 reply 恢复及组件生命周期校验。
- 新 TeacherReply 复用已有 ReactMarkdown、GFM、KaTeX 依赖,支持标题、列表、表格、代码、HTTP(S) 链接/图片与公式;代码/表格栏内滚动,图片失败显示替代说明,HTML不执行,代码中的字面转义不全局替换。链接复用现有 Main-owned 打开接口,未新增依赖。
- 解析失败时保留可恢复正文与旧讨论卡,同时将完整 unparsedResponse 持久保存为默认折叠的字面内容。Main 的后续会话文本只取 response,不重复注入诊断副本。旧版已经覆盖的原文不能恢复。
- 工具活动来自实际主线程 SSE typed tool events 或 Main 三项本地读取,按 run/call 合并与去重,独立折叠显示名称/状态;不复制 args、args_delta、output、error 或 reasoning。完成后尚无结果的调用显示“未收到执行结果”,不假称成功。只记录本客户端观察到的活动,不补写已过期云端事件/旧话题。completed output非string明确返回格式错误,不猜测拆包。
## Verification
- pnpm exec vitest run tests/unit/coding-teacher-ui.test.tsx -t 'does not render a zero' --maxWorkers=1:预期失败,复现截图数字 0。
- pnpm exec vitest run tests/unit/coding-teacher-ui.test.tsx --maxWorkers=1:30 passed。
- MAKELORE_LAYOUT_BROWSER_CHANNEL=msedge pnpm exec playwright test tests/e2e/teacher-discussion-layout.spec.ts --grep ordinary:319/508px 两个真实浏览器页面用例通过,使用现有隔离页面与模拟 API。
- pnpm run typecheck、三处改动文件的 eslint、pnpm run build:vite、git diff --check:通过。构建仅有既有 chunk 大小及动态/静态混用提示。
- 官方 check_doc_drift.py:Feature 文件所有权边界通过。未打包、安装、合并、推送或部署;未进行线上收费对话。
- 用户授权的全新只读 Reviewer agent_rendering_review 独立审查 PASS,无 findings;复跑客户端 30/30、Yuxi 55/55 通过。已目视核对508px页面截图,无数字0,回复/草稿/快捷提问显示正常。
- 追加截图诊断:官方 start/status 已恢复同一任务;复用未变项目记忆及113项既有scope,补读新增只读Reviewer记录,Concurrent/Planning Gates再次通过。精确匹配本地13:17的两条问题,均为discussion-v1/completed,只有固定INVALID_REPLY和INVALID_TOOL被落盘,未存模型原文;读取仅限用户报告的项目话题。
- 当前代码将 structuredText 交给 applyDiscussionReply 后覆盖 response,失败时不保存原始回答。截图两种提示并存,说明没有提取出有效reply且结构化解析/组件校验失败;单纯云端output非string或为空只会产生INVALID_REPLY,不会额外产生INVALID_TOOL。未掌握原始输出,不把格式错误、截断或解析误判中的某一种说成已确认根因。
- 现有Reviewer独立核对final-output合同:teacher request_view返回run_id,客户端随后读取run_view;get_agent_run_result.output来自同次output_message.content(Text string),checkpoint最终AI内容按字符串或text block抽取,无messages transport wrapper。structuredReply只传入完成态该字符串,字段/类型合同一致。故没有证据支持接口字段适配或猜测式parser补丁。
- 本轮用户授权的渲染改造:同一任务 check/start/status 与 Planning Gate 再次通过;114个owner身份集合未变,补读已有Reviewer最新记录,113个peer无语义冲突。复用现有只读reviewer调查Yuxi Web MessageProcessor/MarkdownPreview与老师SSE,没有创建新智能体。
- 红测命令:pnpm exec vitest run tests/unit/teacher-discussion.test.ts tests/unit/coding-teacher-ui.test.tsx -t 'preserves Markdown|renders assistant Markdown' --maxWorkers=1,8 failed,实际显示INVALID_REPLY/INVALID_TOOL并找不到Markdown heading。修复后全部通过。
- 最终相关单测:pnpm exec vitest run teacher-discussion teacher-cloud-activity coding-teacher --maxWorkers=1:8 files / 287 passed;覆盖正文格式、代码转义、公式/图片、无HTML执行、工具字段投影/子线程过滤/重放、真实Main工具续接、状态落盘重读、失败原文落盘与上下文排除。
- 最后复审发现 Yuxi 真实 tool-finished/tool-error 只带 tool_call_id,不重复名称;纠正初步合同核对中的工具名假设。3项真实shape测试先红,修复为按run/call关联既有名称并支持tool-error,再全相关287项通过。最终重新build通过,扩展Main tsc的66条输出与修复前逐字一致。此处源于实际生产者/audit测试,不是虚构兼容格式。
- 现有独立Reviewer最后复审PASS,无剩余阻断finding;独立复跑adapter/runner两文件33项通过,确认失败原文不会经teacherHistory/read_conversation重新投影、工具终态与子线程隔离正确。本人task-aware文档漂移门禁及diff-check通过;本任务可交付集成,源码验收与线上部署验收分开。
- Renderer typecheck、changed-file ESLint、build:vite通过;Main扩展检查 pnpm exec tsc --noEmit --composite false --incremental false -p tsconfig.node.json 仍为既有66条诊断,本次teacher模块无诊断,不宣称全量Main类型通过。构建仍有既有chunk及混合import提示。
- MAKELORE_LAYOUT_BROWSER_CHANNEL=msedge pnpm exec playwright test tests/e2e/teacher-discussion-layout.spec.ts:12 passed;覆盖319/508px富文本、已有四类讨论卡、草稿与固定输入框,目视检查319px截图通过。真实浏览器+模拟接口,不是已安装客户端/线上模型验收。
## Follow-ups
- 本轮实现、本地验证与最后只读复审完成,不需要再次索取旧原文来完成已授权的渲染改造。若用户补交原始回答,可另行核对原截图精确原因。
- 部署 Yuxi 并更新客户端后才能影响用户当前安装,部署后验收一次真实提问及只读工具续接。历史异常原文保持不变。当前仅交付任务分支修复,不含合并或部署。
- 不重试已拒绝的凭据/云端事件探针。新的 parser 变更依据本轮明确复现的普通内容误判;不将上轮角色过滤或本轮渲染修复表述为已验证解决两条已丢失原文的线上错误。
## Promotion Candidates
- Target: 10-decisions/ADR-2026-09-22-coding-teacher.md、20-architecture/system-overview.md。Proposal: 记录正文Markdown渲染、明确业务协议判定、解析失败原文默认折叠留存及工具元数据独立展示。Evidence: 用户本轮明确授权、287项测试与12项布局用例。Future impact: 后续新增多模态/工具渲染应有真实类型合同,不能按正文外形猜测;不改变学生计费、版本或文件权限。Semantic conflicts: README原“不展示模型原始JSON”改为正常正文不展示协议、失败可主动展开原文,现用户授权已覆盖。Human confirmation: 本次实现无需;canonical promotion待Integration Gate。
@@ -0,0 +1,44 @@
# Task: Integrate consultation rendering fixes into main
## Identity
- Task ID: 20260924-merge-agent-rendering-ml-4efb196c
- Mode: Integration
- Branch: main
- Worktree: D:\Datas\OthersProjects\makelore
- Base commit: d2150ce5de5cec541f65eba9260d3cdc5172a66d
- Owner: codex
- Status: Ready for Integration
## Scope
- Merge reviewed source 97837cef90baf6bb02cfda0cef7b938081f3c5f7 (20260924-agent-rendering-a8c2d419) into local main; promote accepted consultation rendering and protocol facts. Paired Yuxi source: 02f27f33a8b816589036503f0720d446418c28aa.
## Intent And Constraints
- Concurrent, Planning and Integration Gates Passed: official start/status match Identity and exclusive integration lock. Read entry/memory-index, own/source/reviewer records, positioning/current-state, teacher ADR, architecture, domain, evidence/reflection/commitments and registered peer scopes. Project goal remains server-distributed consultation agents, Main-owned read tools and student billing. No semantic conflict; preserve current workbench styling and guidance from main.
- User explicitly authorized official release --force of the prior integration owner and new --adopt-existing registration for three known foreign untracked task documents. They remain unchanged and excluded from commits. The new base avoids attributing old cross-machine imports to this integration. No registry fields were manually rewritten.
- Plan: merge exact sources, preserve main styling, rerun relevant client unit/type/build/layout checks, promote accepted facts, pass documentation gates and complete. No push, deployment, paid model request or new subagent. Reuse the completed independent review for unchanged source logic.
- Prior source verification: 287 focused unit tests and 12 browser layout cases passed. Full Main typecheck has 66 existing diagnostics; online production acceptance and recovery of previously overwritten malformed replies remain unverified.
## Outcome
- Source merge committed as 98559e32a75dfbada12a737d7ff0ffda55aa346a. Before the merge commit the drift checker could not recognize the staged foreign source record; exact source-record equality was confirmed, then the unchanged source import was committed and the official drift check passed. No foreign record was edited/adopted to bypass this gate.
- Merged source without conflicts; the only TeacherChatPanel difference from reviewed source is main's two header/avatar styling classes, and README keeps main's styling description. Source task record and implementation imported unchanged otherwise; cloud guidance already on main remains intact. Promoted Markdown/explicit protocol parsing, retained malformed originals and separate typed activity into teacher ADR and architecture.
## Verification
- Existing user-approved independent reviewer completed a bounded integration review: PASS, no findings. Of 19 source-changed files, 17 match source blobs exactly; README/header classes only preserve existing main styling. Confirmed canonical semantics and evidence boundaries. Official task-aware drift passes with all three adopted foreign documents unchanged; no unmerged files remain.
- Merged tree: pnpm exec vitest run teacher-discussion teacher-cloud-activity coding-teacher --maxWorkers=1: 8 files / 287 passed. MAKELORE_LAYOUT_BROWSER_CHANNEL=msedge pnpm exec playwright test tests/e2e/teacher-discussion-layout.spec.ts: 12 passed at 319/508px with fixture API. pnpm run typecheck, ESLint on changed TS/TSX, pnpm run build:vite and whitespace checks passed.
- Initial main checkout dependencies were incomplete: Vitest/tsc launchers missing, then css-calc/tslib resolution failed. Restored only ignored dependencies from the existing lockfile via pnpm install --frozen-lockfile --ignore-scripts --offline --force; no dependency manifest/lock change. All affected checks above subsequently passed.
- Source full Main typecheck remains known to have 66 pre-existing diagnostics; no new Main logic was changed during integration, so its recorded baseline evidence is reused. Source independent review passed. Yuxi paired backend matches its reviewed source exactly.
## Follow-ups
- No push, installer, app restart, deployment or real provider test. Install an updated client and deploy Yuxi API/worker before online acceptance. Existing malformed history is not rewritten. Source checkout cleanup requires separate user consent.
## Promotion Candidates
- Accepted source candidates promoted in integration mode to the teacher ADR, system overview and current-state. No product direction, billing, version or file-permission conflict remains.
+2 -1
View File
@@ -171,7 +171,8 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
- 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:学生点击后才结合当前操作对话与咨询历史生成 2–3 个可聊的问题。点击生成的问题会主动开始讨论;“我也说不清,你带我看看”让智能体从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。
- 智能体讨论采用“上方固定整理内容、下方独立滚动对话、底部原有输入框”的布局。普通回答可带直接发送的引导问题;想法板、结构图、流程/条件图和逐项对照由同一次模型回复提供结构化数据。先邀请学生“用这个一起想”,进入后程序锁定信息结构,智能体随讨论更新同一份内容;解释问题可仅回复文字。节点点击只选择讨论焦点,对照里的“聊聊这一点”直接发问,都保留输入草稿。
- 想法板区分已留下、智能体建议和暂放内容,可由学生采纳、暂放、选择先想哪项。“把想法理一理”明确转换为智能体归纳的结构图,并可“回去补充想法”。“只聊天”暂停整理,“先这些”暂时结束并保留未确定内容,“接着改”恢复原工具;这些操作只影响智能体讨论,不创建项目分支、不执行作品修改。状态、版本和历史快照按当前账号/项目/话题保存。
- Main 校验结构、引用和内容长度,拒绝未经学生选择的类型变更与旧版本更新。结构化回复在完整校验后一次应用;生成中显示简短处理状态,停止、失败或无效组件保留上一份内容,不展示模型原始 JSON。现有旧纯文本、主动关心、运营预览保持兼容。
- Main 校验结构、引用和内容长度,拒绝未经学生选择的类型变更与旧版本更新。结构化回复在完整校验后一次应用;生成中显示简短处理状态,停止、失败或无效组件保留上一份内容。普通 Markdown 链接、JSON 对象/数组和代码示例保持正文,仅讨论协议的顶层字段或专用围栏进入组件解析。解析失败仍保留完整的可读 reply,并将本次原始回答保存为默认折叠、字面显示的“查看收到的原始内容”,不把它再次加入模型上下文;既有丢失原文的历史不能恢复。
- 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按运行/调用身份合并名称和状态,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。
- 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。
- 每轮格式由 Main 的对应意图协议决定;`discussion.ts` 为支持组件的请求注入唯一 `{reply, quickReplies, tool}` 协议。运营教学补充不另写字段协议或要求始终纯文字。工具内讨论保留类型、稳定 ID、未修改内容和采纳状态;暂停/未进入时 `tool:null`,没有实质变化时也可保留原内容。结构图、流程和对照目前没有独立的采纳/来源字段,待定、建议与预测只能在展示文字中明确,不能据此推导已确认共识。
- 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、咨询历史和适用的当前组件,并支持上述只读原文恢复;没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。“先这些”仍仅结束本轮整理,保留各条目状态,不确认全部候选、不自动制作。提示词不宣称这些系统能力已接通,也不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。
+32
View File
@@ -0,0 +1,32 @@
import type { TeacherToolActivity } from '../../shared/coding-teacher';
export interface ToolActivityUpdate {
id: string;
name?: string;
status: TeacherToolActivity['status'];
}
function object(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
}
/** Project Yuxi's typed tool events; arguments, outputs and internal errors never enter the UI. */
export function cloudToolActivity(chunk: Record<string, unknown>, runId: string): ToolActivityUpdate | undefined {
const event = object(chunk.stream_event);
if (event?.type === 'tool_call' || event?.type === 'tool_call_delta') {
if (typeof event.tool_call_id !== 'string' || !event.tool_call_id || typeof event.name !== 'string' || !event.name) return;
return { id: runId + ':' + event.tool_call_id, name: event.name, status: 'running' };
}
const custom = object(chunk.event);
if (chunk.status !== 'stream_event' || custom?.method !== 'tools') return;
const data = object(custom.data);
if (!data || typeof data.tool_call_id !== 'string' || !data.tool_call_id) return;
if (data.event !== 'tool-started' && data.event !== 'tool-finished' && data.event !== 'tool-error') return;
const output = object(data.output);
return {
id: runId + ':' + data.tool_call_id,
...(typeof data.tool_name === 'string' && data.tool_name ? { name: data.tool_name } : {}),
status: data.event === 'tool-started' ? 'running'
: data.event === 'tool-error' || data.error || output?.status === 'error' || output?.status === 'failed' ? 'failed' : 'completed',
};
}
+24 -4
View File
@@ -1,6 +1,7 @@
import { setTimeout as delay } from 'node:timers/promises';
import type { PublicUsage } from '../../shared/coding-conversation-contracts';
import type { TeacherTopic } from '../../shared/coding-teacher';
import type { TeacherTopic, TeacherToolActivity } from '../../shared/coding-teacher';
import { cloudToolActivity, type ToolActivityUpdate } from './cloud-activity';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import {
assertTeacherAccount,
@@ -163,7 +164,8 @@ export function prepareCloudTeacher(
access: TeacherReadAccess,
onProgress: (text: string) => void,
saveRequestId: (id: string) => Promise<void>,
transport: TeacherCloudTransport = teacherCloudTransport(account)
transport: TeacherCloudTransport = teacherCloudTransport(account),
onToolActivity: (activity: TeacherToolActivity) => void = () => undefined,
) {
const tools = createTeacherReadTools(access);
return {
@@ -203,6 +205,17 @@ export function prepareCloudTeacher(
const bounded = AbortSignal.any([signal, AbortSignal.timeout(60 * 60 * 1000)]);
let questionId: string | undefined;
let completed = false;
const activities = new Map<string, TeacherToolActivity>();
const reportActivity = (activity: ToolActivityUpdate) => {
const previous = activities.get(activity.id);
// Replayed starts/deltas cannot put a finished tool back into a spinner.
if (previous && (previous.status !== 'running'
|| (previous.status === activity.status && (!activity.name || activity.name === previous.name)))) return;
// Finished/error events carry the call id but need not repeat its name.
const merged = { ...activity, name: activity.name ?? previous?.name ?? '' };
activities.set(activity.id, merged);
onToolActivity(merged);
};
try {
// 重启后本地快照已经丢失,先停止旧请求,再以新的问题建立读取作用域。
const previous = topic.requests.filter((item) => item.id !== requestId).at(-1);
@@ -291,10 +304,13 @@ export function prepareCloudTeacher(
const call = object(raw);
if (typeof call.tool_call_id !== 'string' || typeof call.name !== 'string')
throw new TeacherError(502, 'teacher_protocol_invalid', '智能体读取请求无效。');
reportActivity({ id: runId + ':' + call.tool_call_id, name: call.name, status: 'running' });
const result = await tools.executeResult(call.name, JSON.stringify(call.arguments), bounded);
results.push({
tool_call_id: call.tool_call_id,
...(await tools.executeResult(call.name, JSON.stringify(call.arguments), bounded)),
...result,
});
reportActivity({ id: runId + ':' + call.tool_call_id, name: call.name, status: result.status === 'success' ? 'completed' : 'failed' });
}
// POST 重试使用完全相同的结果,文件变化也不会导致重复续接或不同输入。
const resumed = await transport.json(
@@ -310,7 +326,9 @@ export function prepareCloudTeacher(
continue;
}
if (view.status === 'completed') {
const output = typeof view.output === 'string' ? view.output : '';
if (typeof view.output !== 'string')
throw new TeacherError(502, 'teacher_protocol_invalid', '智能体返回的正文格式不受支持,请重试。');
const output = view.output;
// Structured UI replies must contain only the final answer. A cloud
// run can stream a preamble or draft before reading and continuing.
if (structuredReply) onText(output);
@@ -349,6 +367,8 @@ export function prepareCloudTeacher(
? [payload.chunk]
: []) {
const chunk = object(item);
const activity = cloudToolActivity(chunk, runId);
if (activity) reportActivity(activity);
const event = chunk.stream_event ? object(chunk.stream_event) : {};
if (event.type === 'message_delta' && typeof event.content === 'string') {
if (typeof event.message_id === 'string' && event.message_id !== messageId) {
+5 -1
View File
@@ -58,7 +58,11 @@ export function applyDiscussionReply(topic: TeacherTopic, request: TeacherReques
const parsed = parseTeacherDiscussionReply(raw);
request.response = parsed.reply;
request.suggestedQuestions = parsed.quickReplies;
if (parsed.toolError) request.discussionError = parsed.toolError;
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) {
+8
View File
@@ -444,6 +444,14 @@ export class CodingTeacherService {
}, async cloudRequestId => {
topic.requests.at(-1)!.cloudRequestId = cloudRequestId;
await store.save(topic);
}, undefined, activity => {
const current = topic.requests.at(-1)!;
const activities = current.toolActivity ??= [];
const index = activities.findIndex(item => item.id === activity.id);
if (index < 0) activities.push(activity);
else activities[index] = activity;
topic.revision++;
this.events.emit(key, structuredClone(topic));
})
: await (this.options.prepareModel ?? prepareTeacherModel)(account, topic.definition,
access,
+8
View File
@@ -83,6 +83,9 @@ export interface TeacherRequest {
discussionContext?: TeacherDiscussionContext;
discussionSnapshot?: TeacherDiscussionContent;
discussionError?: string;
/** Original answer retained when discussion parsing fails; never fed back as context. */
unparsedResponse?: string;
toolActivity?: TeacherToolActivity[];
intent?: TeacherRequestIntent;
sourceConversationId?: string;
@@ -105,6 +108,11 @@ export interface TeacherRequest {
cloudRequestId?: string;
progress?: string;
}
export interface TeacherToolActivity {
id: string;
name: string;
status: 'running' | 'completed' | 'failed';
}
export interface TeacherTopic {
discussion?: TeacherDiscussion;
role?: LegacyConsultationRole;
+24 -19
View File
@@ -223,8 +223,8 @@ function stringToken(raw: string, start: number): { value: string; end: number }
return undefined;
}
/** Recover only a complete top-level JSON string field, never nested tool data or a partial string. */
function recoverReply(raw: string): string | 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++) {
@@ -232,16 +232,13 @@ function recoverReply(raw: string): string | undefined {
if (character === '"') {
const token = stringToken(raw, cursor);
if (!token) return undefined;
if (depth === 1 && expectingKey && token.value === 'reply') {
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++;
if (raw[valueStart] !== '"') return undefined;
const value = stringToken(raw, valueStart)?.value;
if (value?.trim() && value.length <= MAX_REPLY_LENGTH) return value.trim();
return undefined;
return valueStart;
}
if (depth === 1) expectingKey = false;
cursor = token.end - 1;
@@ -257,6 +254,14 @@ function recoverReply(raw: string): string | undefined {
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 };
}
@@ -264,18 +269,15 @@ function invalidReply(candidate: string): TeacherDiscussionReply {
/** Parse final model output. Callers keep the existing tool whenever toolError is present. */
export function parseTeacherDiscussionReply(raw: string): TeacherDiscussionReply {
const trimmed = raw.trim();
// JSON-labelled fences are transport, including truncated fences. Ordinary programming fences remain text.
const fence = /(?:^|\n)[ \t]*```(?:json|makelore-teacher(?:-discussion)?)[ \t]*(?:\r?\n|$)/i.exec(trimmed);
const bareFence = /^```[ \t]*\r?\n(?=\s*[{[])/.exec(trimmed);
const opening = fence ?? bareFence;
const unfencedEnvelope = /\{\s*"(?:reply|quickReplies|tool)"\s*:/.exec(trimmed);
const startsJson = /^[{[]/.test(trimmed);
const looksStructured = !!opening || !!unfencedEnvelope || startsJson || /^```(?:json|makelore-teacher)/i.test(trimmed);
if (!looksStructured) {
return { reply: trimmed.slice(0, MAX_REPLY_LENGTH) || INVALID_REPLY, quickReplies: [] };
}
let candidate = !opening && !startsJson && unfencedEnvelope ? trimmed.slice(unfencedEnvelope.index) : trimmed;
// 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);
@@ -283,6 +285,9 @@ export function parseTeacherDiscussionReply(raw: string): TeacherDiscussionReply
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 {
+18 -3
View File
@@ -13,6 +13,7 @@ import type {
import { TeacherDiscussionPanel, DiscussionFocus } from './TeacherDiscussionPanel';
import type { TeacherDiscussionAction } from '../../../shared/coding-teacher';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { TeacherReply } from './TeacherReply';
export function TeacherChatPanel({
projectId,
@@ -413,11 +414,21 @@ export function TeacherChatPanel({
: <div className="flex justify-end"><p className="consultation-user max-w-[92%] whitespace-pre-wrap break-words rounded-2xl rounded-br-sm px-4 py-3 text-[13px] leading-7">{request.text}</p></div>}
{request.references.length > 0 && <details className="text-xs text-muted-foreground"><summary>本轮引用 {request.references.length} 项</summary>{request.references.map((ref, index) => <pre className="whitespace-pre-wrap" key={index}>{ref.text}</pre>)}</details>}
<div className="flex items-center gap-2 text-[11px] text-muted-foreground"><img src={avatar} alt="" className="h-6 w-6 rounded-lg [image-rendering:pixelated]" />{definition?.name ?? label}</div>
{!!request.toolActivity?.length && <details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">使用了 {request.toolActivity.length} 项工具</summary>
<ul className="mt-2 space-y-2" aria-label="智能体工具活动">
{request.toolActivity.map(activity => <li key={activity.id} className="flex items-start justify-between gap-3">
<span>{activity.name === 'list_project_files' ? '查看项目文件列表' : activity.name === 'read_project_file' ? '读取项目文件' : activity.name === 'read_conversation' ? '读取会话原文' : '使用智能体工具'}</span>
<span className="shrink-0">{activity.status === 'completed' ? '已完成' : activity.status === 'failed' ? '未完成'
: ['running', 'preparing'].includes(request.status) ? '进行中' : '未收到执行结果'}</span>
</li>)}
</ul>
</details>}
{request.intent === 'suggestions' ? <>
{['running', 'preparing'].includes(request.status)
? <p role="status" className="flex items-center gap-2 text-[13px] leading-7 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />我看看你最近做到了哪里…</p>
: request.status === 'completed' && <>
<p className="whitespace-pre-wrap break-words text-[13px] leading-7">{request.response}</p>
<TeacherReply text={request.response} />
<div className="flex flex-col gap-2" aria-label="可以和智能体聊的问题">
{request.suggestedQuestions?.map((question) => <button key={question} type="button" disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-suggestion flex min-h-11 items-center justify-between gap-3 rounded-xl border bg-white px-3 py-3 text-left text-[13px] leading-6 transition-colors disabled:cursor-not-allowed disabled:opacity-50">{question}<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-60" /></button>)}
</div>
@@ -425,11 +436,15 @@ export function TeacherChatPanel({
<p className="text-[11px] leading-5 text-muted-foreground">也可以直接在下面说说你的想法。</p>
</>}
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-guided-help min-h-10 text-xs underline underline-offset-4 disabled:opacity-50">再请智能体看看</button>}
</> : <div className="whitespace-pre-wrap break-words text-[13px] leading-7">{request.response || (['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{request.discussionContext ? '正在梳理你的新想法…' : `${label}正在想…`}</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : '')}</div>}
</> : request.response ? <TeacherReply text={request.response} /> : <div className="text-[13px] leading-7">{['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{request.discussionContext ? '正在梳理你的新想法…' : `${label}正在想…`}</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
{discussionEnabled && request.intent !== 'suggestions' && request.status === 'completed' && topic.discussion?.status !== 'active' && topic.discussion?.status !== 'offered' && Boolean(request.suggestedQuestions?.length) && <div className="flex flex-col gap-2" aria-label="接着聊">
{request.suggestedQuestions?.map(question => <button type="button" key={question} disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-suggestion rounded-xl border px-3 py-2 text-left text-xs leading-6">{question}<ChevronRight className="ml-1 inline h-3 w-3" /></button>)}
</div>}
{request.discussionError && <p className="text-xs leading-6 text-muted-foreground">{request.discussionError}</p>}
{request.unparsedResponse && <details className="min-w-0 text-xs text-muted-foreground">
<summary className="cursor-pointer">查看收到的原始内容</summary>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border p-3">{request.unparsedResponse}</pre>
</details>}
{request.progress && ['running', 'preparing'].includes(request.status) && <p role="status" className="text-xs text-muted-foreground">{request.progress}</p>}
{(request.truncatedMessages ?? 0) > 0 && <p className="text-xs text-muted-foreground">{draftRevision ? '较长的上下文已节选。' : '较长的上下文已节选,智能体可按需读取原文。'}</p>}
{request.omittedMessages > 0 && <p className="text-[10px] text-muted-foreground">本轮参考了较近的讨论,省略了 {request.omittedMessages} 条较早内容。</p>}
@@ -443,7 +458,7 @@ export function TeacherChatPanel({
{topic?.unsaved && <div className="px-5 py-2 text-xs text-destructive">回复尚未保存,请复制或重试保存。<button className="ml-2 underline" onClick={() => void teacherApi.save(topicBase, topic.id).then(consume).catch(() => setError('保存失败,请先复制回复。'))}>重试保存</button></div>}
{!topicEnabled && !error && !busy && <p className="px-5 pb-2 text-xs text-muted-foreground">{legacyFriend ? '这是旧版内置朋友的话题,仅供查看。请选择智能体新建话题。' : `${label}暂未开放,历史仍可查看。`}</p>}
<form className="px-4 pb-4 pt-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
{(legacyDraft.text?.trim() || legacyDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
{Boolean(legacyDraft.text?.trim() || legacyDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
<summary>查看旧版朋友草稿</summary>
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">
{legacyDraft.text}
+46
View File
@@ -0,0 +1,46 @@
import { memo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { toast } from 'sonner';
import { openConversationExternalUrl, safeConversationExternalUrl } from '@/lib/conversation-links';
function ReplyImage({ src, alt }: { src?: string; alt?: string }) {
const [failed, setFailed] = useState(false);
const url = safeConversationExternalUrl(src);
if (!url || failed) return <span className="text-muted-foreground">[图片未能显示:{alt || '图片'}]</span>;
return <img src={url} alt={alt || '智能体提供的图片'} loading="lazy" referrerPolicy="no-referrer"
className="my-3 h-auto max-h-80 max-w-full rounded-lg object-contain" onError={() => setFailed(true)} />;
}
/** Render answer text only. Tool activity and discussion data have separate, typed UI. */
export const TeacherReply = memo(function TeacherReply({ text }: { text: string }) {
return <div className="teacher-reply min-w-0 max-w-full break-words text-[13px] leading-7 [overflow-wrap:anywhere] [&_.katex-display]:max-w-full [&_.katex-display]:overflow-x-auto" data-testid="teacher-reply">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} skipHtml components={{
h1: ({ children }) => <h1 className="mb-2 mt-4 text-lg font-semibold first:mt-0">{children}</h1>,
h2: ({ children }) => <h2 className="mb-2 mt-4 text-base font-semibold first:mt-0">{children}</h2>,
h3: ({ children }) => <h3 className="mb-2 mt-3 text-sm font-semibold first:mt-0">{children}</h3>,
h4: ({ children }) => <h4 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h4>,
p: ({ children }) => <p className="my-2 whitespace-pre-wrap first:mt-0 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="my-2 list-disc space-y-1 pl-5">{children}</ul>,
ol: ({ children, start }) => <ol start={start} className="my-2 list-decimal space-y-1 pl-5">{children}</ol>,
blockquote: ({ children }) => <blockquote className="my-3 border-l-2 border-border pl-3 text-muted-foreground">{children}</blockquote>,
a: ({ href, children }) => {
const url = safeConversationExternalUrl(href);
return url ? <a href={url} target="_blank" rel="noopener noreferrer" className="text-brand underline underline-offset-2"
onClick={event => {
event.preventDefault();
void openConversationExternalUrl(url).catch(() => toast.error('链接无法打开'));
}}>{children}</a> : <span>{children}</span>;
},
code: ({ children, className }) => <code className={className}>{children}</code>,
pre: ({ children }) => <pre className="my-3 max-w-full overflow-x-auto whitespace-pre rounded-lg border bg-surface-subtle p-3 font-mono text-xs leading-5">{children}</pre>,
table: ({ children }) => <div className="my-3 max-w-full overflow-x-auto rounded-lg border"><table className="w-full border-collapse text-left text-xs leading-6">{children}</table></div>,
thead: ({ children }) => <thead className="bg-surface-subtle">{children}</thead>,
th: ({ children, style }) => <th style={style} className="min-w-24 border-b px-3 py-2 align-top font-semibold">{children}</th>,
td: ({ children, style }) => <td style={style} className="min-w-24 border-b px-3 py-2 align-top">{children}</td>,
img: ({ src, alt }) => <ReplyImage key={typeof src === 'string' ? src : ''} src={typeof src === 'string' ? src : undefined} alt={alt} />,
}}>{text}</ReactMarkdown>
</div>;
});
@@ -11,6 +11,14 @@ const samples: Record<string, TeacherDiscussionContent> = {
const scenario = new URLSearchParams(location.search).get('kind') ?? 'ideas';
let current: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'discussion-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 0, seq: i }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: i === 7 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(scenario === 'ordinary' && i === 7 ? { suggestedQuestions: ['有什么好玩的想法?'] } : {}) })), ...(samples[scenario] ? { discussion: { id: 'tool', revision: 1, status: 'active', content: structuredClone(samples[scenario]), ...(scenario === 'structure' ? { previousIdeas: structuredClone(samples.ideas) as Extract<TeacherDiscussionContent, { kind: 'ideas' }> } : {}) } } : {}) };
const stream = Object.assign(new EventTarget(), { close() {}, onerror: null, onopen: null });
if (scenario === 'rich-text') {
current.requests = [{
...current.requests[0], text: '帮我看看项目和最近的讨论',
response: '## 项目建议\n\n**先核对已有设计**,再决定下一步。\n\n- 检查入口文件\n- 保留当前玩法\n\n| 文件 | 作用 | 需要确认 |\n| --- | --- | --- |\n| src/main.ts | 初始化项目 | 游戏循环与计时 |\n| public/assets | 图片资源 | 构建后路径是否可用 |\n\n```js\nconst message = "保留字符串里的\\n,不把它变成实际换行";\nconst resourcePath = "/assets/" + "very-long-file-name-".repeat(15);\n```\n\n运动距离:$s = vt$。\n\n![项目截图](https://example.com/teacher-preview.png)\n\n[项目说明](https://example.com/readme)\n\n> 先验证观察到的现象。',
discussionError: '这次整理没有完成,先保留原来的内容。',
unparsedResponse: '{"reply":"正文已保留","tool":{"kind":"structure",',
}];
}
let timer: ReturnType<typeof setTimeout> | undefined;
const snapshot = () => { current.revision++; stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })); };
export const teacherApi = {
@@ -2,6 +2,7 @@ import { createRoot } from 'react-dom/client';
import { TeacherChatPanel } from '../../../src/pages/Chat/TeacherChatPanel';
import '../../../src/styles/globals.css';
import '../../../src/pages/Chat/classroom-workspace.css';
import 'katex/dist/katex.min.css';
const width = Number(new URLSearchParams(location.search).get('width')) || 508;
createRoot(document.getElementById('root')!).render(<div className="classroom-workspace flex h-screen flex-col bg-white">
<header className="flex h-10 shrink-0 items-center border-b px-5 text-xs">智能体讨论交互测试 · 真实页面组件 / 模拟回复</header>
@@ -20,13 +20,46 @@ test.beforeAll(async () => {
await server.listen(); url = server.resolvedUrls!.local[0];
});
test.afterAll(async () => { await server?.close(); });
for (const width of [319, 508]) {
test(`rich replies keep code, tables and images inside the panel at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('https://example.com/teacher-preview.png', route => route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="900" height="250"><rect width="900" height="250" fill="#ebeedf"/><text x="30" y="130" font-size="32">Flybot project preview</text></svg>',
}));
await page.goto(`${url}?kind=rich-text&width=${width}`);
const body = page.getByTestId('teacher-reply');
await expect(body.getByRole('heading', { name: '项目建议' })).toHaveCount(1);
await expect(body.getByRole('table')).toHaveCount(1);
await expect(body.locator('.katex')).toHaveCount(1);
await expect(body.getByRole('img', { name: '项目截图' })).toBeVisible();
await expect.poll(() => body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await expect.poll(() => page.locator('.consultation-messages').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const raw = page.getByText('查看收到的原始内容');
await expect(raw.locator('..')).not.toHaveAttribute('open');
await raw.click();
await expect(raw.locator('..').locator('pre')).toContainText('"tool"');
const input = page.getByRole('textbox', { name: '向智能体提问' });
await expect(input).toBeVisible();
await page.locator('.consultation-messages').evaluate(el => { el.scrollTop = 0; });
await page.screenshot({ path: test.info().outputPath(`rich-reply-${width}.png`) });
expect(errors).toEqual([]);
});
}
for (const width of [319, 508]) for (const kind of ['ordinary', 'ideas', 'structure', 'flow', 'comparison']) {
test(`${kind} pins the current tool and composer at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 800 });
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
await page.addInitScript(() => localStorage.setItem(
'makelore-consultation-draft:fixture:fixture:friend', JSON.stringify({ text: '', references: [] }),
));
await page.goto(`${url}?kind=${kind}&width=${width}`);
const input = page.getByRole('textbox', { name: '向智能体提问' });
await expect(input).toBeEnabled();
await expect(page.locator('form')).not.toHaveText(/^0/);
await expect(page.getByText('查看旧版朋友草稿')).toHaveCount(0);
const tool = page.getByTestId('teacher-discussion');
if (kind === 'ordinary') {
await expect(tool).toHaveCount(0);
+44 -1
View File
@@ -113,6 +113,44 @@ it('submits only the published persona, source evidence and current question in
expect(body.local_context.tools).toEqual(['list_project_files', 'read_project_file', 'read_conversation']);
});
it.each([null, { content: 'not a supported output field' }, ['text block']])('reports unsupported final output instead of silently losing %j', async output => {
const f = await fixture();
const text = vi.fn();
const transport: TeacherCloudTransport = {
json: vi.fn(async url => url === '/questions' ? { request_id: requestId, run_id: 'run' } : { status: 'completed', output }),
events: vi.fn(),
};
await expect(prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport)
.run([{ role: 'user', content: '问题' }], new AbortController().signal, text))
.rejects.toMatchObject({ code: 'teacher_protocol_invalid' });
expect(text).not.toHaveBeenCalled();
});
it('merges typed tool activity across replay without adding tool data or child events to the answer', async () => {
const f = await fixture();
let reads = 0;
const activity = vi.fn(), text = vi.fn();
const transport: TeacherCloudTransport = {
json: vi.fn(async url => url === '/questions' ? { request_id: requestId, run_id: 'run' }
: ++reads === 1 ? { status: 'running', thread_id: 'main' } : { status: 'completed', output: '最终正文' }),
events: vi.fn(async (_url, _signal, accept) => {
const start = { stream_event: { type: 'tool_call', tool_call_id: 'call', name: 'search', args: { private: 'private-args' } } };
const finish = { status: 'stream_event', event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'call', output: { status: 'success', content: 'private-result' } } } };
accept('messages', { thread_id: 'child', payload: { items: [start] } }, '1-0');
accept('messages', { thread_id: 'main', payload: { items: [start] } }, '2-0');
accept('custom', { thread_id: 'main', payload: { chunk: finish } }, '3-0');
accept('messages', { thread_id: 'main', payload: { items: [start] } }, '4-0');
}),
};
await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity)
.run([{ role: 'user', content: '问题' }], new AbortController().signal, text);
expect(activity.mock.calls).toEqual([
[{ id: 'run:call', name: 'search', status: 'running' }],
[{ id: 'run:call', name: 'search', status: 'completed' }],
]);
expect(text.mock.calls).toEqual([['最终正文']]);
});
it('submits the active discussion protocol, current tool content and selected focus', async () => {
const f = await fixture();
f.topic.definition.limits.max_input_tokens = 16000;
@@ -448,6 +486,7 @@ it.each(['suggestions', 'discussion-v1'] as const)(
const finalOutput = JSON.stringify(result);
let firstReads = 0, resumedReads = 0;
const text = vi.fn();
const activity = vi.fn();
const transport: TeacherCloudTransport = {
json: vi.fn(async (url) => {
if (url === '/questions') return { request_id: requestId, run_id: 'before-read' };
@@ -481,9 +520,13 @@ it.each(['suggestions', 'discussion-v1'] as const)(
}),
};
await prepareCloudTeacher(
f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport
f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity
).run([{ role: 'user', content: '一起讨论' }], new AbortController().signal, text);
expect(text.mock.calls).toEqual([[finalOutput]]);
expect(activity.mock.calls).toEqual([
[{ id: 'before-read:file', name: 'read_project_file', status: 'running' }],
[{ id: 'before-read:file', name: 'read_project_file', status: 'completed' }],
]);
const response = text.mock.calls.map(([delta]) => delta).join('');
expect(format === 'suggestions'
? parseTeacherSuggestions(response)
+84
View File
@@ -96,6 +96,90 @@ async function ready() {
await waitFor(() => expect(streams.has('first')).toBe(true));
}
describe('teacher side chat', () => {
it('renders assistant Markdown, tables, code and images instead of literal markup', async () => {
const response = '## 项目建议\n\n**先验证跳跃**\n\n- 保留现有玩法\n\n| 文件 | 用途 |\n| --- | --- |\n| main.ts | 入口 |\n\n```json\n{"name":"flybot"}\n```\n\n[说明](https://example.com/readme)\n\n![项目截图](https://example.com/preview.png)';
api.read.mockResolvedValue({ ...first, requests: [request({ response })] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByRole('heading', { name: '项目建议' })).toBeVisible();
expect(screen.getByRole('table')).toHaveTextContent('main.ts');
expect(screen.getByText('保留现有玩法').closest('li')).not.toBeNull();
expect(screen.getByText('{"name":"flybot"}').closest('pre')).not.toBeNull();
expect(screen.getByRole('link', { name: '说明' })).toHaveAttribute('href', 'https://example.com/readme');
expect(screen.getByRole('img', { name: '项目截图' })).toHaveAttribute('src', 'https://example.com/preview.png');
});
it('does not render a zero for an empty legacy friend draft', async () => {
localStorage.setItem('makelore-consultation-draft:signed-out:p:friend', JSON.stringify({ text: '', references: [] }));
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const composer = screen.getByLabelText('向智能体提问').closest('form');
expect(composer).not.toHaveTextContent(/^0/);
expect(screen.queryByText('查看旧版朋友草稿')).not.toBeInTheDocument();
});
it('keeps malformed originals folded and literal while rendering the recovered answer', async () => {
const raw = '{"reply":"正文已保留","tool":<script>alert(1)</script>';
api.read.mockResolvedValue({ ...first, requests: [request({
response: '**正文已保留**', discussionError: '这次整理没有完成,先保留原来的内容。', unparsedResponse: raw,
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByText('正文已保留').tagName).toBe('STRONG');
const details = screen.getByText('查看收到的原始内容').closest('details');
expect(details).not.toHaveAttribute('open');
expect(details?.querySelector('pre')?.textContent).toBe(raw);
expect(details?.querySelector('script')).toBeNull();
});
it('renders formulas and preserves code escapes while rejecting executable links and HTML', async () => {
api.read.mockResolvedValue({ ...first, requests: [request({
response: '$E=mc^2$\n\n```js\nconst value = "\\n";\n```\n\n[危险](javascript:alert)\n\n<script>alert(1)</script>\n\n![本地图片](file:///C:/secret.png)\n\n![坏图片](https://example.com/missing.png)',
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const body = screen.getByTestId('teacher-reply');
expect(body.querySelector('.katex')).not.toBeNull();
expect(body.querySelector('pre')?.textContent).toContain('const value = "\\n";');
expect(body.querySelector('script')).toBeNull();
expect(screen.queryByRole('link', { name: '危险' })).not.toBeInTheDocument();
expect(screen.queryByRole('img', { name: '本地图片' })).not.toBeInTheDocument();
fireEvent.error(screen.getByRole('img', { name: '坏图片' }));
expect(screen.getByText('[图片未能显示:坏图片]')).toBeVisible();
});
it('updates streamed Markdown without duplicating the final answer', async () => {
const running = { ...first, requests: [request({ status: 'running', response: '## 正在回答\n\n**先检查' })] };
api.read.mockResolvedValue(running);
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByRole('heading', { name: '正在回答' })).toBeVisible();
const completed = { ...running, revision: 2, requests: [request({ response: '## 正在回答\n\n**先检查文件**' })] };
await act(async () => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(completed) })));
expect(screen.getAllByTestId('teacher-reply')).toHaveLength(1);
expect(screen.getByText('先检查文件').tagName).toBe('STRONG');
});
it('keeps tool activity separate from the answer and stops stale running indicators', async () => {
api.read.mockResolvedValue({ ...first, requests: [request({
response: '已经看过项目文件。',
toolActivity: [
{ id: 'one', name: 'read_project_file', status: 'completed' },
{ id: 'two', name: 'read_conversation', status: 'failed' },
{ id: 'three', name: 'cloud-search', status: 'running' },
],
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const details = screen.getByText('使用了 3 项工具').closest('details')!;
expect(details).not.toHaveAttribute('open');
expect(details).toHaveTextContent('读取项目文件已完成');
expect(details).toHaveTextContent('读取会话原文未完成');
expect(details).toHaveTextContent('未收到执行结果');
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('已经看过项目文件。');
expect(screen.getByTestId('teacher-reply')).not.toHaveTextContent('读取会话原文');
});
it('retains failed input and retries with the same request id, then appends a reply only on click', async () => {
const bringBack = vi.fn();
api.send
+22
View File
@@ -198,6 +198,9 @@ describe('cloud coding teacher', () => {
await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed'));
const saved = (await f.service.read(f.scope, topic.id)).requests[0];
expect(saved.response).toBe('计数器从 42 开始。');
expect(saved.toolActivity).toEqual([{ id: 'parent:read-counter', name: 'read_project_file', status: 'completed' }]);
const restarted = await f.restart();
expect((await restarted.read(f.scope, topic.id)).requests[0].toolActivity).toEqual(saved.toolActivity);
expect(saved.cloudRequestId).toBe('cloud-question');
expect(JSON.stringify(requests[0].body)).toContain('const title');
expect(saved.truncatedMessages).toBeGreaterThan(0);
@@ -1377,6 +1380,25 @@ describe('structured teacher service integration', () => {
describe('project consultations with selected cloud teachers', () => {
it('persists malformed discussion output without putting it into future model context', async () => {
const f = await fixture();
const scope = { ...f.scope, sourceId: 'project' };
const selected = await f.service.create(scope);
const raw = '{"reply":"这段正文完整。","tool":{"privateDiagnostic":"only-in-original"';
f.replyWith(raw);
await f.service.send(scope, selected.id, {
requestId: crypto.randomUUID(), text: '看看项目', presentation: 'discussion-v1', sourceConversationId: f.scope.sourceId,
});
f.finish();
await vi.waitFor(async () => expect((await f.service.read(scope, selected.id)).requests[0].status).toBe('completed'));
const restarted = await f.restart();
const saved = await restarted.read(scope, selected.id);
expect(saved.requests[0]).toMatchObject({ response: '这段正文完整。', unparsedResponse: raw });
const compiled = compileTeacherContext(saved.definition, context, saved.requests, '继续聊', []);
expect(JSON.stringify(compiled.messages)).not.toContain('only-in-original');
expect(JSON.stringify(compiled.messages)).toContain('这段正文完整。');
});
it.each(['local', 'yuxi'])('keeps the published identity and project tools on the %s runtime', async (runtime) => {
const f = await fixture({ cloudTeacher: runtime === 'yuxi', mockCloud: runtime === 'yuxi' });
const scope = { ...f.scope, sourceId: 'project' };
+38
View File
@@ -0,0 +1,38 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { cloudToolActivity } from '../../electron/coding-teacher/cloud-activity';
describe('teacher cloud tool activity projection', () => {
it.each(['tool_call', 'tool_call_delta'])('takes only identity and state from %s', type => {
expect(cloudToolActivity({ stream_event: {
type, tool_call_id: 'call', name: 'read_project_file', args: { path: 'private.ts' }, args_delta: '{"secret":',
} }, 'run')).toEqual({ id: 'run:call', name: 'read_project_file', status: 'running' });
});
it.each([
['tool-started', undefined, undefined, 'running'],
['tool-finished', { type: 'tool', content: '{"private":"value"}', status: 'success' }, undefined, 'completed'],
['tool-finished', 'result text', undefined, 'completed'],
['tool-finished', { status: 'error', content: 'internal error' }, undefined, 'failed'],
['tool-finished', undefined, 'internal stack trace', 'failed'],
])('projects %s without leaking tool output', (event, output, error, status) => {
expect(cloudToolActivity({ status: 'stream_event', event: { method: 'tools', data: {
event, tool_call_id: 'call', tool_name: 'read_conversation', output, error,
} } }, 'run')).toEqual({ id: 'run:call', name: 'read_conversation', status });
});
it('does not guess activity from answer text, reasoning or unidentified deltas', () => {
for (const chunk of [
{ stream_event: { type: 'message_delta', content: '{"tool_call_id":"call","name":"read_project_file"}' } },
{ stream_event: { type: 'message_delta', reasoning_content: 'internal reasoning' } },
{ stream_event: { type: 'tool_call_delta', index: 0, args_delta: '{}' } },
{ status: 'stream_event', event: { method: 'messages', data: { event: 'tool-started', tool_call_id: 'call', tool_name: 'test' } } },
]) expect(cloudToolActivity(chunk, 'run')).toBeUndefined();
});
it.each([
['tool-finished', { type: 'tool', content: 'tool result', status: 'success' }, undefined, 'completed'],
['tool-error', undefined, 'private error', 'failed'],
])('accepts real %s events that carry only the call id', (event, output, error, status) => {
expect(cloudToolActivity({ status: 'stream_event', event: { method: 'tools', data: {
event, tool_call_id: 'call', output, error,
} } }, 'run')).toEqual({ id: 'run:call', status });
});
});
@@ -304,6 +304,22 @@ describe('applying completed teacher replies', () => {
expect(turn.discussionSnapshot).toBeUndefined();
});
it('retains the original failed answer for inspection without changing the discussion', () => {
const owner = topic(flow, 'paused');
const before = structuredClone(owner.discussion);
const turn = request(owner);
const raw = '{"reply":"收到,先看看项目。","tool":{"kind":"flow",';
applyDiscussionReply(owner, turn, raw);
expect(turn.response).toBe('收到,先看看项目。');
expect(turn.unparsedResponse).toBe(raw);
expect(owner.discussion).toEqual(before);
const plainTurn = request(owner);
applyDiscussionReply(owner, plainTurn, '[项目说明](https://example.com)');
expect(plainTurn.discussionError).toBeUndefined();
expect(plainTurn.unparsedResponse).toBeUndefined();
expect(owner.discussion).toEqual(before);
});
it('keeps legacy text as a normal answer without creating a component', () => {
const owner = topic();
const turn = request(owner);
+15 -2
View File
@@ -176,6 +176,20 @@ describe('teacher discussion model envelope', () => {
.toEqual({ reply: '柯基、冒险和菜园都是好点子。', quickReplies: [] });
});
it.each([
'[项目说明](https://example.com/readme)\n\n先看已有设计。',
'[1, 2, 3]',
'[{"reply":"普通数组示例"}]',
'```json\nnot valid json\n```',
'{"scripts":{"dev":"vite"},"name":"flybot"}',
'项目配置可以这样写:\n\n```json\n{"name":"flybot","scripts":{"dev":"vite"}}\n```',
'```json\n[{"id":1,"label":"入口"}]\n```',
'{"example":{"reply":"这是数据字段,不是回答协议"}}',
'```js\nconst example = {"reply":"代码示例"};\n```',
])('preserves Markdown links and JSON examples as readable content: %s', raw => {
expect(parseTeacherDiscussionReply(raw)).toEqual({ reply: raw, quickReplies: [] });
});
it('distinguishes an absent tool from an explicit null and supports a tool-only envelope', () => {
expect(parseTeacherDiscussionReply('{"reply":"这轮只解释一下。"}'))
.toEqual({ reply: '这轮只解释一下。', quickReplies: [] });
@@ -213,8 +227,7 @@ describe('teacher discussion model envelope', () => {
'{"reply":"还没有说完',
'{"tool":{"reply":"不能拿嵌套结构充当正文"},',
'```json\n{"tool":{"secret":"raw-data"}}',
'```json\nnot valid json\n```',
'[{"reply":"不是合法 envelope"}]',
'```makelore-teacher-discussion\nnot valid json\n```',
'{"reply":13,"tool":{"kind":"html"}}',
'```json\n{"reply":"结束"}\n```\n```json\n{"reply":"第二个"}\n```',
])('never exposes malformed JSON as the student reply: %s', raw => {