diff --git a/.project-docs/10-decisions/ADR-2026-09-22-coding-teacher.md b/.project-docs/10-decisions/ADR-2026-09-22-coding-teacher.md index 1ad5f4c4..9640ff11 100644 --- a/.project-docs/10-decisions/ADR-2026-09-22-coding-teacher.md +++ b/.project-docs/10-decisions/ADR-2026-09-22-coding-teacher.md @@ -1,6 +1,6 @@ # ADR: 官方编程老师与项目会话导航 -- Status: Accepted / implemented, paged reads amended 2026-09-27 +- Status: Accepted / implemented, single ongoing chat amended 2026-09-28 - Date: 2026-09-22 - Approval: 用户确认项目默认 Agent、项目下会话、多老师列表与移除试聊;随后确认使用 Yuxi 原生智能体、客户端提供三个只读工具、沿用学生账号付费,并要求实施及合并。2026-09-24 明确取消客户端固定老师/朋友分类,统一使用后端下发智能体,并要求合并上下文预算修复。 @@ -10,17 +10,19 @@ ## Decision +- 2026-09-28 用户确认像微信联系人一样,每个账号与每个下发智能体只有一个持续聊天,并授权实施与合入主分支。以稳定 config_id 标识智能体,不按名称、默认标记、项目或版本创建第二个可见聊天;本次范围为本机持久化,不含跨设备同步。 - 新项目创建内部默认编程 Agent 并保存 defaultAgentId,会话直接列在项目下。打开项目不自动创建空会话,首次发送或显式新建才创建;历史 Agent ID、配置和 Pi 会话绑定保留。 - Yuxi 拥有老师提示词、模型和所选 Skills、知识库、MCP、子智能体。Works Square 运营菜单首先展示下发列表,添加时从 Yuxi 选择并发布当前配置,支持多位、默认选择与启停,不维护第二份提示词编辑器或试聊界面。 -- 运营显式同步 Yuxi 配置产生不可变下发版本。新话题可选择已下发老师,默认使用运营指定老师;已有话题固定原版本。配置与资源绑定固定,Skill 按问题建立运行快照,知识内容和远端工具仍由各自服务拥有;资源撤销可能阻止执行,版本不表示复制全部外部资源。 +- 运营显式同步 Yuxi 配置产生不可变下发版本。每次接受新问题使用当前已下发版本,运行中的问题保持原版本;版本变化不另建可见聊天。配置与资源绑定固定,Skill 按问题建立运行快照,知识内容和远端工具仍由各自服务拥有;资源撤销可能阻止执行,版本不表示复制全部外部资源。 - Yuxi 原生 Request/Run、PostgreSQL checkpoint 和 Redis 事件拥有云执行。Main 冻结本次账号、项目、来源 Pi 已完成分支的公开消息及老师话题,通过三个只读工具提供目录、UTF-8 文件行段和会话原文。文件按读取时内容提供,会话按本轮快照读取,不上传完整工程、Pi 原始日志或思考。 - 本地工具只交给老师主线程:云端持久中断完整批次,Main 主动回传配对结果,关联 Run 续接同一问题。用户于 2026-09-27 授权改造读取工具:声明 read_protocol=2 的客户端支持按层目录与连续原文分页,每问题最多 12 批、成功结果累计 64 KiB、单页 8 KiB,剩余不足 512 字节时收尾;旧客户端保持六批。最后结果消费后显式禁止继续选工具并要求基于已有证据回答。保留原付款人、版本、上下文和截止时间;读取限当前项目,允许 .makelore/project.json,排除其余内部记录与 Git 数据,不提供本地写入或命令。 - Works Square 验证学生编程资格,使用专用短期老师凭据;聊天模型费用记入学生 ai_programming / coding_teacher 账本。老师资源仍归 Yuxi 创建者,个人 Agents 模块继续创建者付费,老师问题不占创建者个人智能体金额上限。 -- 云端保存老师消息、执行和收到的片段;Main 按账号、项目和来源会话保存话题索引与展示历史。旧本地老师话题保留原路径。停用阻止后续调用,历史仍可查看;断线按游标和持久状态恢复,重启后中断旧问题,下次提问先停止旧问题再绑定新上下文。 -- Code 咨询统一使用服务端下发智能体,不内置老师/朋友角色、本地教学人设或按名称分配的工具权限。名称、头像、简介、欢迎语和推荐问题均来自发布定义;新话题使用用户选择或运营默认项,已有话题继续固定原定义和版本。所有下发项共享上述只读工具边界,通用讨论组件协议不定义智能体人格。 -- 右侧咨询保留已实现的结构化讨论和主动跟进:自动检查在隐藏窗口、操作会话运行、来源归档、智能体回复或咨询草稿编辑期间延后,Main 再校验来源、启停和冷却。跟进使用同一发布定义与学生计费,不伪造学生消息。正文、保存历史和带回草稿仅接收云端主线程文本;子线程事件仍推进续传游标。带回回答只追加草稿,不自动发送。Code/Pi 执行和项目 Plugin 生命周期保持各自边界。 -- 所有下发智能体以头像和名称在顶栏并排展示,点击直接切换,移除面板内的新话题智能体下拉框;数量多时在顶栏内横向滚动,不挤占窗口控件。切换恢复所选智能体的历史或空输入框,不创建话题、不发起模型调用;新话题使用所选项的当前发布版本,旧话题仍固定原版本。历史菜单保留,查看其他智能体的话题时同步顶栏选中项。 -- 当前咨询历史按账号、项目保存,每轮绑定当时的操作会话;未发送草稿按账号、项目及服务端智能体 ID 分别保存。旧项目级未归属草稿折叠保留供查看,不猜测归属或自动填入其他智能体。旧客户端生成的朋友话题及草稿只读保留,不再继续调用,也不自动绑定云端智能体;旧来源会话级历史不自动迁移。现有云端 `coding-teacher` 契约与存储名保留为实现兼容,不构成角色分类。 +- 云端保存执行线程、消息和收到的片段。Main 在 userData/agent-conversations// 保存轻量聊天索引和独立轮文件;每页50轮,流事件只更新当前轮。轮文件先于索引原子保存,恢复不重发模型。停用阻止后续调用,历史仍可查看;重启将未完成问题标为中断,下次提问先停止旧问题。 +- 每轮冻结项目、Pi 来源、发布版本及只读范围。相同项目/来源/版本沿用内部 Yuxi 线程,任一改变都开启内部执行段,携带有预算的公开近期交流;可见聊天保持连续。历史消息可按 ID 读取,读到另一项目的旧交流不授予其文件权限。Pi 与项目配置仍归项目所有。 +- Code 咨询统一使用服务端下发智能体,不内置老师/朋友角色、本地教学人设或按名称分配的工具权限。名称、头像、简介、欢迎语和推荐问题来自发布定义。所有下发项共享上述只读工具边界,通用讨论组件协议不定义智能体人格。 +- 结构化讨论在聊天内按项目保存,历史轮保留原卡片快照。正文、历史和带回草稿仅接收云端主线程文本;子线程事件仍推进续传游标。带回回答只追加原项目/Pi 来源的草稿,不自动发送或改写当前其他来源。已下发智能体的旧 Renderer 定时跟进停止派发;新的 Main 主动观察由独立任务实施,本次未接入建议投递,后续必须并入同一聊天,不能恢复项目话题入口。 +- 所有下发智能体以头像和名称在顶栏并排展示,溢出横向滚动。点击恢复唯一聊天或空态、独立草稿和未读,不调用模型;移除新话题加号和话题下拉框。旧原始记录保留单独只读入口。最近50轮先加载,历史分页与流事件按请求身份合并;切换智能体保留窗口内阅读位置,已读位置由 Main 持久保存。 +- 未发送文字草稿按账号/智能体保存;明确引用仍携带项目/Pi 来源,来源切换后须移除旧引用或恢复原上下文才能发送。只导入已登记项目中能证明当前账号与 config_id 归属的旧话题,以项目/话题/请求来源身份去重,原文件保留;重复正文不去重,失联目录稍后重试。无法证明归属的朋友/旧老师历史及旧草稿只读保留,不猜测身份。移除项目不删除账号级聊天;现有云端 coding-teacher 名称不构成角色分类。 - 输入预算在编译时计入完整执行请求。本地旧模型路径按约 2 UTF-8 字节估算 1 Token,工具定义、调用和读取预留纳入同一计量;估算不替代模型上限或实际 usage。云端以完整 JSON 转义后的 query 字节数裁剪来源节选,原文仍可按 ID 读取。固定配置/当前讨论超限与用户问题/引用超限分别提示,不静默修改发布预算、问题、明确引用或话题版本。 - 咨询回答以 Markdown 渲染标题、列表、表格、代码、HTTP(S) 链接/图片和公式,代码与表格在栏内滚动;普通 JSON、Markdown 链接与代码中的字面转义保持原义。discussion-v1 仅由顶层 reply/quickReplies/tool 或专用围栏识别,组件仍遵循既有生命周期。解析失败保留原讨论卡与可恢复正文,完整 unparsedResponse 单独持久保存并默认折叠,不再次注入后续会话上下文。 @@ -28,6 +30,7 @@ ## Evidence And Release Boundary +- 2026-09-28 单会话源 `7aa81da616449dead25557476cf4f1433edf84b5` 已纳入本地 main。源365项相关单测、Renderer typecheck、变更文件 lint、Vite构建和真实 Electron 夹具交互通过;扩展 Main 检查与基线同为66项既有诊断。合并保持产品/测试与源一致,不重跑相同验证。未推送、打包、部署或进行真实付费模型验收;主动观察组合验收另行完成。见[源任务](../30-worklog/tasks/20260928-agent-single-chat-27da516b.md)及[集成记录](../30-worklog/tasks/20260928-merge-agent-single-chat-9911c6df.md)。下方旧话题交互证据仅记录当时版本。 - 2026-09-24 用户明确要求多智能体在顶栏铺开并授权合并,客户端源 `a10cf000157a1a3edcfad43705e0824f19568a33` 已快进纳入本地 `main`。源相关单测、17 项浏览器布局和 1 项 Electron 夹具交互、类型/lint/构建通过,具体分批验证见[源记录](../30-worklog/tasks/20260924-consultation-topbar-22717e99.md)。合并未改变已测产品字节;不代表已更新安装版或真实供应商验收。 - 2026-09-24 渲染源 `97837cef90baf6bb02cfda0cef7b938081f3c5f7` 与 Yuxi 配套源 `02f27f33a8b816589036503f0720d446418c28aa` 已纳入本地主分支集成。客户端源 287 项单测、12 项真实浏览器布局用例及独立审查通过;Yuxi 82 项相关协议回归与真实 LangGraph 确定性模型测试通过。浏览器接口为替身,不代表安装版或线上模型已验收;旧版已覆盖的失败原文无法恢复。 diff --git a/.project-docs/10-decisions/decision-index.md b/.project-docs/10-decisions/decision-index.md index 5110af0d..8a91c4a3 100644 --- a/.project-docs/10-decisions/decision-index.md +++ b/.project-docs/10-decisions/decision-index.md @@ -2,7 +2,7 @@ - 2026-09-26 已确认[老师 Agent 产品定位](../00-brief/project-positioning.md#老师-agent-的产品定位):某一领域杰出人物的能力转化而成的智能体,供平台学生使用;蒸馏属于实现方法,以真实案例验证领域能力;补充下方既有咨询技术决定。 -- [下发智能体咨询与项目会话导航](ADR-2026-09-22-coding-teacher.md):2026-09-24 amended / implemented;取消固定老师/朋友分类,运营下发 Yuxi 智能体并指定默认,发布定义拥有人设与显示资料,Main 提供一致的项目/会话只读工具。旧合成朋友话题与草稿只读保留;学生计费、版本固定与主线程正文路由保持,输入预算区分近似 Token 与精确 JSON 字节。 +- [下发智能体咨询与项目会话导航](ADR-2026-09-22-coding-teacher.md):2026-09-28 amended / implemented;每个账号与下发智能体使用唯一持续聊天,跨项目保留历史,移除新话题和话题选择。项目/Pi 来源/发布版本在每轮接受时冻结,变化时只轮转内部线程。Main 持久化分页历史与已读,草稿按账号/智能体隔离;可证明归属的旧话题导入且原文件保留,其余只读。学生计费、统一只读工具和主线程正文保持;主动观察建议接入由独立任务继续。 ## Active Decisions diff --git a/.project-docs/10-decisions/proposals/20260928-agent-single-chat-27da516b__single-conversation.md b/.project-docs/10-decisions/proposals/20260928-agent-single-chat-27da516b__single-conversation.md new file mode 100644 index 00000000..a98e4de0 --- /dev/null +++ b/.project-docs/10-decisions/proposals/20260928-agent-single-chat-27da516b__single-conversation.md @@ -0,0 +1,147 @@ +# 用户与下发智能体的单一持续会话 + +- 日期:2026-09-28 +- 状态:待采纳设计;尚未修改产品代码或已接受 ADR。 +- 任务:20260928-agent-single-chat-27da516b +- 代码基线:b26e25c9ed26bf30f4d0bc03869c223f0fe34386 +- 用户要求:像微信联系人聊天,一个用户与一个智能体只有一个可见会话。 +- 范围解释:按用户+智能体理解为跨项目连续聊天;先保持现有本机历史持久化范围,不承诺跨设备同步。 + +## 1. 用户体验 + +顶栏继续显示所有已下发智能体。点头像直接恢复这个用户与该智能体的唯一聊天、草稿、未读和阅读位置。移除学生面板的“新话题”加号和“以往讨论/选择话题”下拉框。向上滚动加载更早消息。 + +切换项目或 Pi 会话不另建可见会话。输入区显示本轮上下文,例如“当前项目:天气 App · 操作对话:我要做个天气 App”;上下文变化时在下一轮提问旁显示来源。已开始的问题仍绑定原项目,切页不会改走它的文件读取路径。 + +不同智能体各有自己的聊天;昵称、头像、默认项、发布版本变化不会变成另一个联系人。保留已有 Markdown、工具活动和结构化讨论渲染,不顺带增加附件、搜索或聊天删除系统。 + +## 2. 已确认现状与改造位置 + +| 源码 | 已确认事实 | 影响 | +| --- | --- | --- | +| electron/coding-teacher/service.ts | scopedStore 在项目 .makelore/teacher-conversations/账号/范围下存历史;create 每次生成 UUID;topic 固定项目和版本 | 会话身份与本轮上下文分离 | +| shared/coding-teacher.ts | TeacherTopic 持有 projectId、version、definition;TeacherRequest 只有可选 sourceConversationId | 项目与发布版本下沉到请求 | +| electron/coding-teacher/cloud-runner.ts | thread_id = topic.id;teacher_version = topic.version;local_context.scope 使用 topic.projectId | 可见聊天 ID 和云执行线程 ID 解耦 | +| electron/coding-teacher/store.ts | topic 文件包含全部 requests;list 读取各 topic 文件 | 改为分页读取,避免永久聊天全量加载 | +| electron/api/routes/coding-teacher.ts | 项目级 agent-topics 路由;SSE 发送完整 topic | 按联系人读写,事件只更新目标轮次 | +| src/pages/Chat/TeacherChatPanel.tsx、use-teacher-companion.ts | 话题选择和显式新建;状态、草稿以 account/project/agent 组织 | 归属改为 account/agent,项目只作上下文 | + +这些结论来自本地源码和老师 ADR,不代表部署验收。本轮未检查 Yuxi 服务端线程绑定与压缩实现;实施前必须核实。 + +## 3. 数据结构 + +### AgentConversation:唯一可见聊天 + +- 唯一键:accountId + agentId。agentId 使用目录稳定 teacher_id,对应 definition.config_id,不使用名称、版本或默认标记。 +- 保存 conversationId、创建/更新时间、最后消息、未读游标、阅读位置及当前执行段。 +- 首次真正保存消息时由 Main 串行获取或创建;多窗口并发不能生成两个聊天。打开空面板不触发模型。 +- 运营同步同一个配置沿用身份;删除后重新创建得到新配置 ID,视作另一智能体,不按同名合并。 +- 当前展示资料使用已下发配置;历史保留执行版本及必要显示快照。 + +### ConversationTurn:每轮请求 + +在现有 TeacherRequest 上明确保存: +- requestId、conversationId、问题/引用/回答、状态、工具活动、用量。 +- projectId、项目名称快照、sourceConversationId、来源游标和采集时间。 +- teacherVersion、runtimeThreadId、云 request/run ID。 +- 用户提问或主动建议来源、结构化讨论引用。 + +Main 接受时冻结项目、来源和版本,使用现有项目服务校验 Pi 会话归属并解析路径。Renderer 不传任意本地路径。流式回调按 requestId 更新目标轮,不能继续假设数组最后一项就是当前请求,因为主动建议可能同时到达。 + +### RuntimeSegment:内部执行段 + +建议同项目、同发布版本连续提问复用当前 Yuxi 线程。下一次发送发现项目或版本改变时,创建内部线程,带入有预算的近期交流及相关状态;旧消息仍显示在同一时间线。A → B → A 可以产生新执行段,不需要后台线程池。只切换页面或头像不建线程、不收费。 + +历史过长优先使用已验证的 Yuxi 压缩能力;若该能力不满足合同,可使用相同执行段机制轮转,并带入有界上下文。实施前验证服务端能力,不假定已有压缩,也不把全部历史不断追加到 query。摘要若调用模型,必须沿用学生计费链并明确触发,不新增定时总结调用。 + +这可利用现有 thread_id 参数;初步判断客户端为主要改动。Yuxi 是否需要补充压缩或恢复接口,在实施时依据其源码与测试确定。Works 继续负责目录、版本和学生身份,无需为此新增运营会话管理。 + +## 4. 发送与恢复 + +建议 Host API(名称在实施时按项目风格收敛): +- GET /api/coding/agents/:agentId/conversation:读取唯一聊天或空态。 +- GET /api/coding/agent-conversations/:id/messages?before=...&limit=50:历史页。 +- POST /api/coding/agents/:agentId/messages:幂等创建聊天、冻结上下文并接受问题。 +- GET /api/coding/agent-conversations/:id/events:活动轮、未读和状态事件。 +- POST /api/coding/agent-conversations/:id/requests/:requestId/cancel:取消精确请求。 + +断网、切页、重开只查询/订阅原状态,不重放计费问题。重复提交沿用 requestId。同一用户与智能体一次接受一轮主动问答;运行时可编辑草稿、等待或停止,先不引入消息队列。不同智能体沿用现有总运行约束。 + +历史页与事件按消息序号/revision 合并,避免分页覆盖流式结果;先显示最近 50 轮,向上加载,保持阅读锚点,阅读较早消息时不强制滚到底部。 + +每轮只读工具仍使用冻结 projectPath、Pi 消息快照、账号与取消状态;现有分页、预算和最终回答规则保持。其他项目的历史聊天文本不授予读取那些项目文件的权限。 + +草稿文字归用户+智能体,代码/消息引用携带项目与 Pi 来源。切项目后若草稿有旧引用,发送前明确保留旧上下文或移除引用,不能静默解释成新项目资料。“带回操作对话”定位消息原目标,不写入碰巧打开的另一会话。无有效项目时保留历史与草稿;普通无项目聊天需核对云合同,第一版可继续要求选择项目,不伪造上下文。 + +## 5. 配置与结构化讨论 + +版本改为“每轮接受时固定”:下一轮使用当前已下发版本;已运行轮次保持原版本;必要时切内部执行段,聊天不重建。停用禁止新调用,历史可读;同 ID 重启下发继续原聊天。运行中停用、取消和结算遵守原服务端规则。 + +现有 topic.discussion 不能直接合并成一个跨项目对象。卡片保留独立 ID、projectId、revision 与历史快照,当前只激活对应项目的讨论对象。操作旧项目卡片应明确恢复其来源上下文,不能套用当前项目卡片。 + +## 6. 持久化与旧历史 + +建议使用现有 app userData 根下新增账号隔离的 agent-conversations 目录,不改变全局路径配置。项目源文件和 Pi 会话继续在项目目录。移除项目不会连带删除用户与智能体的全部聊天。 + +使用轻量聊天索引加每轮独立文件,复用现有原子写文件能力,不新增数据库依赖。仅写当前轮和必要元数据;验证轮文件/索引在退出中断时可恢复,不能重发模型问题。 + +旧记录接入: +1. 只扫描应用已登记项目的既有老师历史目录、当前账号;不扫描整块磁盘。 +2. 相同 definition.config_id 的旧话题归入同一聊天,按发生时间展示,保存 originProjectId/originTopicId/originRequestId;同时间用来源 ID 稳定排序。 +3. 使用来源 ID 判定是否已接入,不按正文去重;重复问同一句话仍是两条消息。 +4. 原文件保留,新结构写入成功才记录进度,重启可继续,不原地覆盖。 +5. 无法证明身份的旧合成朋友/旧老师记录继续只读,通过单独旧记录入口查看,不按名字猜归属。 +6. 失联项目稍后重新打开时再接入;不宣称已收集所有磁盘历史。 +7. 不合并云端 checkpoint;旧运行结束或取消后启用新发送。只带入必要公开交流,不重新执行旧问题或上传完整工程。 +8. 多份草稿只选择身份明确的一份恢复,其余保留供恢复,不用最后写入覆盖所有内容。 + +本方案先保持本机持久化。若需要同账号跨设备共享记录,必须增加服务端唯一会话与同步游标,文件读取仍绑定发起设备;各机器独立 UUID 无法保证跨设备唯一。 + +## 7. 并行主动观察任务 + +20260928-agent-observer-design-9c41a872 的记录显示正在实施项目选择观察者及轮数/空闲/冷却触发。其代码尚不属于本方案基线。建议由 Main 提供幂等的“向 accountId + agentId 聊天追加观察结果”操作,带 originatingProjectId 和 observationId。 + +观察调度仍按项目,无建议不制造空消息,有建议进入同一个聊天并产生对应头像未读。观察运行不能复用或挤占正在运行的主动问答线程。先前提到的按 requestId 更新可防止结果写错轮次。 + +实施前刷新该任务与 20260928-consultation-scope-83fa19c2 的记录;后者目前为模板、范围未知。明确 shared DTO、service、hook 和面板的文件所有权后再写产品代码。本轮仅写独立方案,不干扰并行实现。 + +## 8. 实施顺序与验收 + +1. 确认数据合同、云端线程/版本约束与并行接口,完成 Main 唯一存储和幂等发送。 +2. 接入旧历史,冻结每轮项目/版本,验证执行段与云端恢复。 +3. 替换话题 UI,接分页、上下文提示、草稿/阅读位置/未读。 +4. 主动建议通过统一消息入口接入,完成联调。 + +实施必须验证: +- 双窗口、重试和重启只产生一个聊天和一次模型调用。 +- 同智能体跨项目保留历史;A 请求运行中切 B,工具仍只读 A。 +- 不同账号、智能体的消息/草稿/未读隔离。 +- 下一轮采用新发布版本,运行中轮次不被切换。 +- 重复正文、同时间旧消息、中断接入及失联项目不丢记录、不复制调用。 +- 数千轮历史分页,流式结果不被翻页覆盖,阅读位置不跳动。 +- 断网/关闭/取消/退出恢复不重发,不留假运行状态。 +- 原项目或 Pi 来源删除后历史仍可读,旧引用/卡片不能误操作新项目。 +- 主动建议和问答同时返回,分别更新正确记录,未读只追加一次。 +- 顶栏打开即持续聊天,无学生新话题加号及话题下拉框。 + +设计阶段未运行产品测试、构建、真实收费模型或云端验收。实施时应做聚焦 Store/Service/API/Renderer 回归、Electron 切项目与分页流程、类型检查和生产构建。 + +## 9. 采纳与规范 + +待实施批准并验证后,在集成阶段更新老师 ADR、system-overview、data-flow、business-rules、README: +- 账号+项目+话题改为账号+智能体; +- 项目/Pi 来源改为每轮冻结上下文; +- 话题固定版本改为每轮接受时固定版本; +- 建议/讨论/问答共用联系人时间线,各自保留项目来源。 + +当前已接受决策保持不变。本提案不会把尚未实现的行为写成现状。 + +## 10. 实施结果(2026-09-28) + +用户已批准实施和既有主动观察任务协调。单会话客户端已实现;具体数据、路由、验证和集成边界以[任务记录](../../30-worklog/tasks/20260928-agent-single-chat-27da516b.md)为准。 + +实际采用 `/api/coding/agent-conversations/:agentId` 系列接口;Main 原子保存轻量索引和独立轮文件,每页50轮,流事件仅传当前轮。云端源码协调结果显示线程同时绑定项目和Pi来源,故项目、Pi来源或版本改变都会切换内部线程;同段沿用现有Yuxi压缩,新段只携带有预算的公开历史。本机长历史可由只读工具按消息ID取回。 + +正文草稿、已读位置和旧记录已处理;滚动位置和已加载页的缓存只维持当前窗口生命周期。未进行跨设备同步或生产付费模型验收。旧原始记录全部保留,可从只读入口查看。 + +主动观察实现仍在另一任务独立审查中,尚未进入本分支。其“继续讨论”仍创建项目话题,必须在两项集成时改为同一聊天的幂等建议投递/定位;不能把接口协调记为组合功能已实现。单会话方向已经用户批准,共享规范的正式更新留给集成任务。 diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index 54ca3420..ed36f058 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -4,11 +4,11 @@ 目录式创建 → Main 保存项目与 defaultAgentId → 项目直接列出会话 → 首次发送或显式新建创建 Conversation → 原 Pi 执行链。历史会话继续使用原 Agent 绑定;切换项目丢弃旧读取。 -统一智能体入口/选择已下发项 → Main 获取 WS 专用凭据,以所选或运营默认智能体创建项目级话题并固定版本 → 保存问题身份及当前账号/项目/本轮 Pi 公开消息快照 → Yuxi 原生 Request/Run。名称、人设和展示资料来自发布定义,不叠加本地老师/朋友人格。持久读取中断 → Main 校验绑定与取消状态 → 列目录/读取文件/读取会话原文 → 主动 POST 完整配对结果 → checkpoint 关联 Run 续接。所有下发项均遵守相同工具边界。最多六批读取,保留原问题时限与学生付款人;文件使用读取时内容,会话使用本轮快照。云端保存消息和收到的片段,完整工程与 Pi 原始日志保留本机。 +顶栏选择下发智能体 → Main 读取账号/智能体唯一聊天与最近50轮,打开本身不调用模型 → 学生发送时幂等获取聊天,冻结项目/Pi 来源/当前发布版本 → 获取WS专用凭据 → Yuxi原生Request/Run。同项目/Pi/版本沿用内部线程,改变时新建执行段并携带有预算的公开交流,不创建第二个可见聊天。名称、人设和资料来自发布定义。读取中断 → Main校验本轮绑定、取消与额度 → 列目录/读取文件/读取会话原文 → 主动回传配对结果 → 原问题关联Run续接。协议2为12批/64KiB累计成功结果/8KiB单页,旧协议六批;最后一批消费后要求最终回答。保留原问题时限与学生付款人;文件使用读取时内容,会话使用本轮快照。云端只保存消息和收到的片段,完整工程与Pi原始日志保留本机。 云端事件 → Main 先推进游标,再仅转发云端主线程文本 → Host SSE/本机历史 → 右侧面板。子智能体文本不混入正文或带回草稿;断线按游标与持久状态恢复。关闭面板不取消已接受问题,停止/退出请求取消原问题;重启后下次提问先停止旧问题。带回回答只追加草稿,不自动发送,旧本地老师话题保留原执行路径。见[老师决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)。 -编译完整请求 → 本地路径按近似 Token 预算、云端路径按实际 JSON query 字节预算裁剪来源节选 → 提交前按同一计量复核。问题、明确引用及固定讨论指令保持完整;真正的固定配置超限单独提示。旧合成朋友历史和草稿只读展示,不创建新调用或自动绑定;现有结构化讨论、主动跟进和项目草稿继续使用统一咨询身份。 +编译完整请求 → 本地路径按近似Token预算、云端路径按实际JSON query字节预算裁剪来源节选 → 提交前按同一计量复核。问题、明确引用及固定讨论指令保持完整;真正的固定配置超限单独提示。明确引用绑定旧项目/Pi时须移除引用或恢复原来源。聊天轮文件原子写入后更新轻量索引;恢复与旧记录导入按原始身份去重,不重发模型。历史页和SSE按请求ID合并,已读归Main,文字草稿按账号/智能体保存;讨论状态仍按项目隔离。旧不明归属历史/草稿仅查看。现代聊天停止旧定时跟进,独立主动观察后续接入同一聊天。 ## Permanent wallet and recharge diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index 685ea373..6135560d 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -4,7 +4,7 @@ | Path | Responsibility | Owner Notes | |---|---|---| -| `electron/coding-teacher/**`, `electron/api/routes/coding-teacher.ts`, `shared/coding-teacher.ts`, and `src/pages/Chat/Teacher*.tsx` | Main-owned 下发智能体目录与选择、云凭据、项目/Pi 快照、三个只读工具、Yuxi 续接、项目级咨询和讨论 | 服务端发布定义提供人设与显示资料,无本地老师/朋友分类或名称工具限制;旧合成朋友话题/草稿只读。`context.ts` 按执行路径测量完整请求,`model-runner.ts` 使用近似 Token 预算,`cloud-runner.ts` 使用精确 JSON 字节并仅路由云端主线程正文。账号/项目/本轮来源隔离,无本机写入或命令。见 [咨询 ADR](../10-decisions/ADR-2026-09-22-coding-teacher.md)。 | +| `electron/coding-teacher/**`, `electron/api/routes/coding-teacher.ts`, `shared/coding-teacher.ts`, and `src/pages/Chat/Teacher*.tsx` | Main-owned下发目录、账号/智能体唯一聊天、每轮项目/Pi/版本快照、三个只读工具及Yuxi续接 | `conversation-store.ts` 持有轻量索引、独立轮文件、历史分页、旧记录导入和已读;`service.ts` 按请求ID更新、冻结范围并轮转内部线程。Renderer只保存草稿/窗口阅读锚点,无新话题入口。服务端定义人设与资料;不明归属旧记录只读。context按执行路径计量,cloud-runner仅路由主线程正文,无本机写入或命令。主动观察后续接入同一聊天。见[咨询ADR](../10-decisions/ADR-2026-09-22-coding-teacher.md)。 | | `electron/coding-projects/project-config.ts`, `src/pages/Chat/CodingProjectConversations.tsx`, and `src/components/layout/Sidebar.tsx` | 内部默认 Agent 与项目下会话导航 | 旧绑定保留;首次发送或显式新建创建会话,打开项目不创建。 | | `electron/coding-plugins/manifest.ts`, `release-descriptor.ts`, `signature-verifier.ts`, and `trusted-keys.ts` | Closed Plugin package/descriptor parsing, fixed code-owned resource roots, compatibility checks, canonical archive validation, and Ed25519 trust | Data Service plus exact Game Resource, Game Audio and Project Scaffold roots are code-owned. Other schema-2 packages still pass descriptor/signature/archive validation; downloadable P0 Skill assets remain text/image-only. Hosted Web Search is not a current Plugin root. | | `electron/coding-plugins/account-plugin-cache.ts`, `marketplace-client.ts`, and `package-store.ts` | Main-owned Account Library cache, authenticated server client, and atomic immutable device installation store | Account acquisition and device installation are separate. Package bytes switch atomically only after descriptor, signature, archive, client-range, and provenance checks; failed updates preserve the prior current release. | diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 8a815c20..f81c6098 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -2,10 +2,12 @@ 云端咨询的 Main 声明 `read_protocol=2`:目录按层分页(默认深度 4、最多 6,跳过生成缓存),文件与捕获会话返回连续文本及准确的下一页行/Unicode 字符列。文件增量解码,不受 UI 预览大小限制。每问题最多 12 批、累计成功结果 64 KiB、单页 8 KiB;剩余不足 512 字节时停止读取。Yuxi 消费最后一批结果后通过 `tool_choice: none` 请求最终答案,保留原身份、版本、时限与幂等续接;未声明新协议的旧客户端保持六批。工具活动以问题与逻辑 tool_call_id 合并跨 Run 重放,原始 interrupt 工具错误不单独判为失败。Main 区分读取额度、上下文失效与协议错误;失败问题不自动重试。须先更新 Yuxi API/worker,再更新客户端;生产供应商与安装版验收仍待完成。 -智能体图标跟随发布定义:Yuxi 将原生 `icon` 固定到发布快照,Works Square 转为公开地址 `icon_url` 下发。Makelore 顶栏、邀请、面板标题、欢迎区和消息行统一读取该字段;缺失或加载失败时回退到既有 `avatar_id`。新话题取当前发布定义,旧话题保持原版本;自定义图标使用正常图像渲染,内置像素头像保持像素样式。 +智能体图标跟随发布定义:Yuxi 将原生 `icon` 固定到发布快照,Works Square 转为公开地址 `icon_url` 下发。Makelore 顶栏、邀请、面板标题、欢迎区和消息行统一读取该字段;缺失或加载失败时回退到既有 `avatar_id`。每个新问题取当前发布定义,已接受问题保持原版本;自定义图标使用正常图像渲染,内置像素头像保持像素样式。 详见[图标集成记录](../30-worklog/tasks/20260926-merge-agent-avatar-ml-92dc837e.md)。 -Code 咨询由 Works Square 运营下发的 Yuxi 智能体执行,客户端不内置老师/朋友角色;发布定义提供名称、头像、人设、欢迎语和推荐问题。Main 按账号/项目管理本机话题,Renderer 按账号/项目/服务端智能体 ID 保存未发送草稿。每轮绑定当前操作会话和已完成 Pi 公开消息,执行云端请求的三个只读工具后主动回传结果;Yuxi 保存云端消息和收到的片段。学生支付咨询模型费用,个人 Agents 保持创建者付费。全部下发智能体在顶栏以头像和名称直接选择,溢出横向滚动;面板不再提供智能体下拉框。选择恢复对应历史或空输入框,不创建话题或调用模型。新话题使用所选或默认项,旧话题固定版本;旧未归属项目草稿、合成朋友历史及草稿仅查看。结构化讨论和现有主动跟进协议保留。正文只接收云端主线程文本,子线程事件仍推进续传游标。Code 默认 Agent、项目下会话和 Pi 执行保持原边界。详见[已接受决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)。 +Code 咨询由 Works Square 运营下发的 Yuxi 智能体执行,不内置老师/朋友分类。Main 按账号/稳定智能体 ID 保存唯一持续聊天,Renderer 按同一身份保存文字草稿。顶栏直接选择智能体,恢复历史/空态与未读;没有新话题加号或话题下拉框。每次接受问题固定当前项目、Pi 公开消息及发布版本;项目、Pi 来源或版本变化时只轮转内部 Yuxi 线程,带入有预算的公开近期交流。历史聊天文本不能扩大本轮文件范围。Main 执行三个只读工具并回传结果;学生支付咨询模型费用,个人 Agents 保持创建者付费。正文只接收云端主线程文本,其他线程事件仍推进游标。Code 默认 Agent、项目下操作会话及 Pi 执行保持项目边界。 + +TeacherConversationStore 在 userData/agent-conversations// 保存轻量索引和独立轮文件,每页50轮;SSE只发送当前轮与元数据,Renderer 按请求身份合并分页和流。Main 持久保存已读,阅读锚点只缓存在当前窗口。已登记项目的旧话题仅在账号和config_id明确时按来源身份导入,保留原文件;不明身份记录单独只读。讨论卡按项目保存,引用与带回操作核对原项目/Pi 来源。新下发聊天停止旧Renderer定时跟进;独立主动观察任务尚未纳入,未来建议须幂等投递同一聊天。详见[已接受决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)。 ## Current Architecture diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 830a27cd..e0b62d28 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,8 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- 2026-09-28:无冲突合入单会话源 `7aa81da616449dead25557476cf4f1433edf84b5`(含方案 `e54f3d2`)。每个账号与下发智能体使用唯一持续聊天,移除新话题入口,跨项目分页历史、独立草稿与持久已读;项目/Pi/发布版本下沉到每轮冻结,变化时只轮转内部Yuxi线程。旧记录按可证明的账号/配置身份导入,原文件保留。已按用户批准更新咨询ADR、架构和领域规则,取代下方历史条目的项目/版本可见话题规则。产品/测试与已验证源一致,沿用365项相关单测、Renderer typecheck、scoped lint、Vite构建及1项Electron交互;扩展Main仍为66项基线诊断。三份外来文档原样保留;未推送、打包、部署或调用真实收费模型。主动观察仍在独立任务,建议投递必须在其后续合并时衔接唯一聊天。见[源记录](tasks/20260928-agent-single-chat-27da516b.md)和[集成记录](tasks/20260928-merge-agent-single-chat-9911c6df.md)。 + - 2026-09-28:无冲突快进合入诊断记录 `3779f33`,客户端产品及测试无变化。确认现有咨询按账号/项目保存,每个问题绑定当前 Pi 来源;配套 Yuxi 修复 `e96d8cf` 将话题准入与每问题上下文分开,允许同项目切换编程会话后继续原话题,旧历史无需删除。Yuxi 2260 项 unit(1 skipped)、10 项真实 HTTP/PostgreSQL/worker 集成及独立审查通过;本仓不为诊断文档重跑客户端构建。三份外来文档原样保留;未推送、打包、安装或部署,线上需更新 Yuxi API。见[源记录](tasks/20260928-consultation-scope-83fa19c2.md)和[集成](tasks/20260928-merge-consultation-scope-ml-7b56cbe4.md)。 - 2026-09-27:从 `0eed70fa637f70b22be5eda7af400a702ee4a7f2` 无冲突快进合入 Design 客户端源 `bbea6eba82a625f3d8b34ecb3d2747e7a19766e3`。未保存视频时长不再显示默认 6 秒,参数保存失败恢复已接受值;缺少内部分镜改为 AI 准备动作引导,保留其他阻断与确认边界。产品/测试与已验证源一致,沿用 71 项相关测试、3 项跨前后端离线流程、2 项 Electron 交互及 typecheck、lint、构建证据。三份外来文档原样保留;未推送、打包、安装或调用收费生成。本次无需数据库或环境变更,不包含独立服务端 Reasoner 修复。见[源任务](tasks/20260927-design-video-form-d2b96e41.md)和[集成记录](tasks/20260927-merge-design-video-form-9af51c63.md)。 diff --git a/.project-docs/30-worklog/tasks/20260928-agent-single-chat-27da516b.md b/.project-docs/30-worklog/tasks/20260928-agent-single-chat-27da516b.md new file mode 100644 index 00000000..3393be73 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-agent-single-chat-27da516b.md @@ -0,0 +1,74 @@ +# Task: Design one ongoing conversation per user and cloud agent + +## Identity + +- Task ID: 20260928-agent-single-chat-27da516b +- Mode: Feature +- Branch: codex/20260928-agent-single-chat-27da516b-agent-single-chat +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260928-agent-single-chat-27da516b +- Base commit: b26e25c9ed26bf30f4d0bc03869c223f0fe34386 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- User approved the proposed account + delivered-agent single ongoing chat and implementation. +- Client store, per-turn scope/version, Host API, paged UI, legacy history, drafts, read receipts and focused regressions. +- Coordination with the existing proactive-observer task was explicitly authorized. This task does not own its uncommitted implementation or its Reviewer. +- No main merge, push, deployment, real paid model call, new subagent or worktree cleanup. + +## Intent And Constraints + +- Concurrent Task Gate and Planning Gate Passed on initial design and implementation resume; official start/status confirmed this task's feature ownership. +- Refreshed 128 peer records, read only their task records; consultation-scope client task is read-only diagnosis. Observer code remains in a separately owned worktree. +- Stable delivered config ID, account isolation, student payer and three readonly tools remain. Pi/project files stay project-owned. +- Every accepted question freezes project, Pi source and released version. Switching UI never changes an accepted read scope. +- Existing main foreign documents and unrelated worktrees remained untouched. Only this task record/proposal are changed in project memory; canonical promotion remains integration work. + +## Outcome + +- Main stores one account-agent chat under userData/agent-conversations//. A small message index and separate atomic turn files avoid rewriting every historical answer. +- First question ensures the chat; repeated request IDs are idempotent, including older pages. One active manual question per account-agent. Snapshot progress/completion updates by request ID. +- Latest 50 turns initially; older history pages remain available. SSE sends the current turn and metadata. Renderer merges delayed pages without overwriting newer streamed replies, preserves reading position while switching agents, and keeps account-agent text drafts. +- Removed student new-topic plus and normal topic dropdown for delivered agents. Current project/source are shown. Cross-project/Pi references block sending until removed or the source is reopened; old reply actions cannot target another Pi session. +- Contacts retain distinct unread state; read position is persisted in Main. Last selected disabled agent can reopen readonly history. Loaded-page/scroll cache is window-local; durable messages and read receipts survive restart. +- Next question uses the current released definition. Same project/Pi/version reuses its internal thread; changing any starts an internal segment carrying bounded public history. Old imported cloud questions retain original cloudRequestId for cancellation. +- Readonly conversation tool can retrieve older completed chat messages by ID through the frozen index without loading all bodies each question; prior-project message text does not grant prior-project file access. +- Legacy topics are imported only on proven account/config identity, deduplicated by project/topic/request origin, and original files are retained. Body-before-index interruption recovers without duplicate import, including retry in the same process. Missing project directories can be retried later. Historical project and source topics, including unassigned/friend records, remain readonly via the old-records endpoint. +- Discussion state is per project inside the chat; imported old cards retain their turn snapshots and newest project card. Legacy originals remain separately viewable. +- README updated for the implemented client behavior. Accepted teacher ADR/domain/architecture changes are proposed, not promoted. + +## Verification + +- Relevant unit suite: 12 files, 365 tests passed. Includes Store/Service/actual HTTP + SSE/paging/legacy source archive/account isolation/concurrent duplicate acceptance/versions/Pi switching/frozen file roots/older message tool reads/import failure recovery/read receipts/UI delayed pages/old-record readonly/disabled history/independent agent drafts. +- pnpm run typecheck: passed. +- Focused ESLint over every changed TS/TSX file and new files: passed. +- pnpm run build:vite: passed; existing chunk-size/dynamic-import/Browserslist warnings retained. +- Real Electron test with deterministic Host fixtures: project consultations preserve student drafts and switch between work and chat without submitting advice, 1/1 passed. Screenshot manually inspected: topbar agents, one chat, no new-topic plus/select, project label, separate drafts, work tab preserved. Not a live cloud/model acceptance claim. +- Expanded Main tsc: 66 diagnostics. Temporarily replayed original committed source inside this owned worktree under try/finally restoration; baseline also has the same 66 diagnostics (line positions normalized). No new diagnostics, but global Main typecheck is not clean. +- Reproduced existing coding-teacher.test tool activity ID failure on original base: test expected parent run ID, existing implementation uses stable context/request ID. Corrected that assertion to the existing contract; the full related suite then passed. +- No dependency lock/package change. Build output, local reports, runtimes and temporary checks are ignored and not committed. + +## Coordination And Integration Dependency + +- Authorized messages sent to existing thread 01a0dc9a-9b4e-7fa3-bb19-aea80abaaac4, task 20260928-agent-observer-design-9c41a872. +- That task confirmed its Main suggestion contract in its own record: stable suggestion ID survives replacements; state.json is durably saved before advice delivery. Use projectId + suggestion.id for idempotent delivery; do not call sendConversation to invent a student message. +- Actual manual APIs: GET /api/coding/agent-conversations/:agentId?before=...; POST /messages; /seen; /discussion; /save; /requests/:id/cancel; GET /events. service.conversation(agentId,before?) and service.sendConversation(agentId,input) own the unique chat. +- Observer review was still in progress at this handoff; no observer code is in this branch. The combined integration MUST replace its discussObservation project-topic creation with inserting/opening this chat, plus delivery replay/read receipts. Its independent observation thread and Main project scheduler remain separate. Do not claim automatic advice has already been wired or combine branches without this adaptation and joint tests. +- Observer coordinator source inspection confirmed Yuxi scope binds both project and Pi source, and native YuxiSummarizationMiddleware exists. This justified Pi-aware segment rotation; neither cloud deployment nor compression quality was independently exercised here. + +## Follow-ups + +- User-requested main merge is not yet authorized for this implementation. +- During integration reconcile observer suggestions as above, and promote the approved account-agent/per-turn semantics; retain unrelated docs. +- Validate configured Yuxi deployment against current client; no real cloud/paid-model run occurred. Cross-device history synchronization is outside this increment. +- Retained worktree belongs to this completed feature; no cleanup requested. + +## Promotion Candidates + +- Targets: ADR-2026-09-22-coding-teacher.md, system-overview.md, data-flow.md, business-rules.md and current state. +- Proposal: replace visible project/version topic identity with account-agent chat; bind project/Pi/version to individual execution turns; account-agent drafts/read receipts, paged files/index, retained source archives. +- Evidence: approved user request, implementation, 365 related tests, Electron UI and cloud request contract tests. +- Future impact: history lifecycle, cloud scope rotation/summary, old-context actions, observation delivery. +- Semantic conflicts: accepted project-topic/version policy is intentionally revised by user approval; observer project-topic creation must be adapted at joint integration. +- Human confirmation: single-chat direction and implementation approved. Main merge/deployment require their own corresponding user request. diff --git a/.project-docs/30-worklog/tasks/20260928-merge-agent-single-chat-9911c6df.md b/.project-docs/30-worklog/tasks/20260928-merge-agent-single-chat-9911c6df.md new file mode 100644 index 00000000..2dc67625 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-merge-agent-single-chat-9911c6df.md @@ -0,0 +1,46 @@ +# Task: Integrate one ongoing conversation per delivered agent + +## Identity + +- Task ID: 20260928-merge-agent-single-chat-9911c6df +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: a1d52c60d841dc451545805af5fe91b0ca8839d3 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Merge source e54f3d2 and 7aa81da into local main; promote the approved account-agent ongoing conversation contract. + +## Intent And Constraints + +- Concurrent and Planning Gates Passed. Official start/status match Identity and exclusive integration ownership. Loaded required entry, own/source tasks, relevant accepted ADR, architecture/domain/evidence/reflection/commitments and peer task scopes. Historical placeholder scopes remain unknown but have no concrete dependency on this merge. +- User authorized main merge and explicitly approved releasing completed 20260928-merge-consultation-scope-ml-7b56cbe4 and adopting three known foreign task documents. Preserve them unchanged and exclude them from commits. Source task/proposal remain read-only. +- Registered the exact unchanged source task/proposal imported by the authorized merge before committing, following the documented source-record adoption procedure. Same-task official resume/status and refreshed Planning Gate passed with unchanged base and scope. +- Approved account-agent chat replaces visible project/version topics; each accepted execution still freezes project/Pi/version, student payer and readonly tool scope. No changes to Pi project ownership. +- Observer task 20260928-agent-observer-design-9c41a872 remains uncommitted and separate; authorized coordination sent. Its later merge must adapt durable suggestion delivery/discussObservation to this chat and jointly test idempotency, unread state, scope and focus. Do not import its unfinished code here. +- Plan: merge exact source without rewriting product files, reconcile accepted canonical rules, verify source product/history preservation and documentation boundaries, then commit/complete. Reuse source verification if product/test tree is identical; rerun only checks justified by integration changes. +- No new subagents, push, deployment, packaging, paid model calls or cleanup. + +## Outcome + +- Source 7aa81da (including design e54f3d2) merged conflict-free into local main. Product, tests, README and source records are byte-identical to the verified source; main's separate consultation-scope diagnosis remains preserved. +- Promoted approved account-agent conversation identity, per-turn project/Pi/version scope, paged persistence, read receipts, drafts and retained legacy archives into the teacher ADR, architecture, domain rules, decision/evidence indexes and Integrated Through. +- Explicitly recorded that legacy Renderer check-ins stop for delivered-agent chats and the independent observation implementation is not part of this merge. Its later integration must adapt durable advice delivery rather than creating project topics. +- Three adopted foreign documents remain unchanged and excluded. No push, packaging, installation, deployment or cleanup. + +## Verification + +- Source verification reused because integration changes only canonical/task documentation beyond the exact source: 365 related tests, Renderer typecheck, changed-file ESLint, Vite build and one real Electron interaction with deterministic Host fixture passed. Expanded Main has the same 66 pre-existing diagnostics; no live paid-model claim. +- Conflict-free merge, exact product/test/source-record preservation, git diff --check and task-aware documentation drift checks passed. The official gate also verifies the three adopted external records retain their original bytes/index state. Main's earlier diagnosis commits are retained as the first-parent history. + +## Follow-ups + +- Observer task 20260928-agent-observer-design-9c41a872 must consume this main and complete same-chat suggestion delivery/read receipts/idempotency/scope/focus tests before its own merge. Existing thread coordination is user-authorized. +- Production/installed-client acceptance and optional task worktree cleanup are separate. No cross-device history synchronization is implemented. + +## Promotion Candidates + +- Accepted and promoted the source account-agent/per-turn contract under the user's implementation and merge approval; no unresolved product-direction choice. Source task/proposal remain unchanged historical records. diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index c6b34c7c..23e53c73 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -4,11 +4,11 @@ - 每个新项目创建内部默认 Agent,普通界面无 Agent 创建步骤或分组列。defaultAgentId 选取已存默认、置顶或首个可用 Agent;历史 Agent 与会话绑定不重写,不重新启用停用/归档 Agent。 - 项目直接显示会话并保留归档、改名、未读、运行状态;打开项目不创建空会话。首次发送或显式新建才创建。项目设置编辑默认配置,历史会话仍能定位原绑定配置。 -- 老师使用 Yuxi 原生配置和云资源,新话题可选择运营下发的老师,旧话题固定版本。Main 只提供当前项目和本轮捕获的 Pi 公开消息/老师话题的三个只读工具,允许 `.makelore/project.json`,无本机写入或命令,不进入其他账号/来源记录;每次读取与续接检查绑定及取消状态。Yuxi 保存老师消息与收到的片段,完整本地工程不全量上传。 -- 同 requestId 相同输入读取已有请求结果,不同输入冲突;重启中的请求标记中断,账号退出中止,来源删除清理相关话题。带回回答追加草稿且不自动发送;已归档来源必须先恢复。 +- 每个账号与稳定下发智能体ID只有一个可见聊天,跨项目保留历史。每个问题使用接受时的当前发布版本,并冻结项目、Pi来源及公开消息;相同作用域复用内部线程,任一变化只开启内部执行段。Main三个只读工具仍限本轮项目,允许 `.makelore/project.json`,无本机写入或命令。可读同一聊天较早消息,但不能据此读取旧项目文件;每次读取与续接检查绑定及取消状态。完整工程不全量上传。 +- 同requestId相同输入读取已有结果(包括更早历史页),不同输入冲突;同一账号/智能体一次接受一轮人工问答。重启将未完成问题标为中断,账号退出中止;移除项目不删除账号级聊天。带回回答核对原项目/Pi来源,只追加草稿且不自动发送;已归档来源必须先恢复。 - 运营只管理下发、同步、默认与启停,无重复提示词编辑器或试聊。老师聊天模型由学生账号付费,个人 Agents 保持创建者付费。正文、历史及带回草稿只接收云端主线程文本;忽略子线程文本仍推进游标。 - 客户端不内置老师/朋友角色:名称、头像、简介、欢迎语、推荐问题及人格由服务端发布定义决定,通用讨论协议不定义人格。任意名称的下发智能体都遵守同一只读工具边界;不再按角色限制项目读取。 -- 当前咨询按账号、项目保存,每轮绑定所选操作会话;新话题选择下发项或运营默认项,旧话题固定原定义与版本。旧合成朋友话题及未发送草稿仅查看,不继续调用、不自动重绑定或提交;兼容路由/存储名不定义新角色。 +- 顶栏点击直接恢复唯一聊天,没有新话题加号和话题下拉框。历史分页,已读由Main持久保存;草稿按账号/智能体隔离,引用仍绑定原项目/Pi。只导入已登记项目中当前账号与config_id可证明一致的旧记录,按项目/话题/请求来源去重,原文件保留;不明身份记录及旧草稿单独只读,不按名称猜归属。讨论状态按项目保存。旧Renderer定时跟进不再向现代聊天派发;独立主动观察的建议接入尚待完成。 - 输入预算区分近似模型 Token 与精确云端 JSON 字节,编译和提交使用同一完整请求计量。保留当前问题、明确引用和固定指令,按需缩减来源节选;固定配置/讨论超限不误报为用户短问题过长,不静默提升预算或迁移旧话题。 依据:[老师决定](../10-decisions/ADR-2026-09-22-coding-teacher.md)及 [ADR-008](../10-decisions/adr-008-interactive-ai-app-scaffold.md)。 diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md index be177357..5c31d1cc 100644 --- a/.project-docs/50-evidence/evidence-index.md +++ b/.project-docs/50-evidence/evidence-index.md @@ -1,5 +1,7 @@ # Evidence Index +2026-09-28 [单会话源任务](../30-worklog/tasks/20260928-agent-single-chat-27da516b.md) `7aa81da` 已合入本地main:365项相关单测、Renderer typecheck、变更文件lint、Vite构建与1项真实Electron夹具交互通过,覆盖存储恢复、分页/事件合并、旧记录归属、每轮范围/版本、历史原文读取、草稿与已读。集成产品/测试与源一致,沿用其证据;扩展Main仍有相同66项基线诊断。未验证线上付费模型、跨设备同步或独立主动观察与单聊天的组合交付。 + Use this index for searchable, traceable evidence records. | Date | Topic | Status | Source | Detail | diff --git a/README.md b/README.md index 70ceb548..844f1780 100644 --- a/README.md +++ b/README.md @@ -167,13 +167,13 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 本地读取使用连续分页:目录按层展开,默认四层、最多六层,跳过 `.gradle` 等生成目录;条目随页面按需产出,深层目录可单独查询;文件和会话默认每页 120 行、最多 400 行,返回连续原文、起始行列与精确 next 游标,列按 Unicode 字符计数、换行统一为 LF。单个云端结果最多 8 KiB,长行也能继续读取;不受文件 UI 的 256 KiB 预览限制。会话索引保留消息 ID 和摘要,按 ID 读取完整公开原文。每题最多十二批、累计成功结果 64 KiB(包含结果元数据),不足 512 字节时收尾;不上传 Pi 原始日志或思考。云端保留智能体对话及收到的片段,完整工程仍在本机。本地旧版模型仍按自身上下文和六批额度缩小结果。 - Code 课堂左侧项目栏默认宽 171px,可拖动右边缘在 128–256px 间调整,双击恢复默认并在本机记住宽度;标题栏同步对齐。项目名与会话名超长省略并可悬停查看;右侧咨询栏默认宽 508px,可拖动至 640px,紧凑窗口仍使用覆盖式咨询。 -- 客户端没有内置老师/朋友角色。已下发智能体以头像和名称并排显示在标题栏,点击后打开对应智能体的讨论;数量较多时横向滚动,咨询面板不再提供智能体下拉框。名称、头像、简介、欢迎语和推荐问题均由服务端提供。首次使用默认选择运营指定项,各智能体的未发送草稿分别保存,已有话题保持绑定的版本;旧的未分配项目草稿可展开查看,不自动转给其他智能体。打开或切换只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。 +- 客户端没有内置老师/朋友角色。已下发智能体以头像和名称并排显示在标题栏,点击后打开对应智能体的讨论;数量较多时横向滚动,咨询面板不再提供智能体下拉框。名称、头像、简介、欢迎语和推荐问题均由服务端提供。首次使用默认选择运营指定项,各智能体的未发送草稿分别保存,同一账号与同一智能体只有一个持续聊天,跨项目保留消息和文字草稿;不再显示新话题按钮或话题选择。每轮接受时固定项目、操作对话与当前发布版本;旧的未分配项目草稿可展开查看,不自动转给其他智能体。打开或切换只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。 - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 -- 学生在当前项目创作时,前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成简短的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 +- 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成简短的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 - 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 - 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:学生点击后才结合当前操作对话与咨询历史生成 2–3 个可聊的问题。点击生成的问题会主动开始讨论;“我也说不清,你带我看看”让智能体从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 智能体讨论采用“上方固定整理内容、下方独立滚动对话、底部原有输入框”的布局。普通回答可带直接发送的引导问题;想法板、结构图、流程/条件图和逐项对照由同一次模型回复提供结构化数据。先邀请学生“用这个一起想”,进入后程序锁定信息结构,智能体随讨论更新同一份内容;解释问题可仅回复文字。节点点击只选择讨论焦点,对照里的“聊聊这一点”直接发问,都保留输入草稿。 -- 想法板区分已留下、智能体建议和暂放内容,可由学生采纳、暂放、选择先想哪项。“把想法理一理”明确转换为智能体归纳的结构图,并可“回去补充想法”。“只聊天”暂停整理,“先这些”暂时结束并保留未确定内容,“接着改”恢复原工具;这些操作只影响智能体讨论,不创建项目分支、不执行作品修改。状态、版本和历史快照按当前账号/项目/话题保存。 +- 想法板区分已留下、智能体建议和暂放内容,可由学生采纳、暂放、选择先想哪项。“把想法理一理”明确转换为智能体归纳的结构图,并可“回去补充想法”。“只聊天”暂停整理,“先这些”暂时结束并保留未确定内容,“接着改”恢复原工具;这些操作只影响智能体讨论,不创建项目分支、不执行作品修改。当前讨论卡片按聊天中的项目分别保存,历史轮次保留卡片快照。 - Main 校验结构、引用和内容长度,拒绝未经学生选择的类型变更与旧版本更新。结构化回复在完整校验后一次应用;生成中显示简短处理状态,停止、失败或无效组件保留上一份内容。普通 Markdown 链接、JSON 对象/数组和代码示例保持正文,仅讨论协议的顶层字段或专用围栏进入组件解析。解析失败仍保留完整的可读 reply,并将本次原始回答保存为默认折叠、字面显示的“查看收到的原始内容”,不把它再次加入模型上下文;既有丢失原文的历史不能恢复。 - 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。 - 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。 @@ -182,8 +182,8 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、咨询历史和适用的当前组件,并支持上述只读原文恢复;没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。“先这些”仍仅结束本轮整理,保留各条目状态,不确认全部候选、不自动制作。提示词不宣称这些系统能力已接通,也不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。 - 智能体输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。智能体指令、当前问题和明确引用不会被静默裁掉;固定配置或当前整理本身超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。 - `tests/fixtures/teacher-guidance-examples.json` 提供宠物、种植、射击、蛋糕的人工多轮输入输出样例,由真实解析器、上下文编译器与讨论状态机校验;样例不加载到运行时,也不代表真实模型教学效果已验收。云端定义由 Yuxi 维护并由运营显式同步下发;本地代码修改不会自动改写云端配置或学生已有话题的固定发布版本。 -- 下发智能体的图标随发布版本保存,顶栏、咨询面板、欢迎区和消息使用定义中的 `icon_url`;未设置或图片加载失败时使用内置头像。运营在 Yuxi 更换图标后需同步下发配置,新话题采用新版本,已有话题保持原图标。相对图片地址由服务端使用 Yuxi 公网入口补全。 -- 咨询话题及未发送草稿按当前云端账号、项目保存;切换操作对话仍延续该项目下的咨询,每轮发送时绑定当时的操作对话来源。创建话题时固定发布版本,每轮重新检查启停。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。云端断线按游标和持久状态恢复;停止或退出账号会取消原云端问题,下次提问先停止旧的未完成问题再绑定新上下文。只接收云端主线程回答,子智能体文本不混入正文。没有操作对话时也可咨询,但无操作文本上下文。 +- 下发智能体的图标随发布版本保存,顶栏、咨询面板、欢迎区和消息使用定义中的 `icon_url`;未设置或图片加载失败时使用内置头像。运营在 Yuxi 更换图标后需同步下发配置,联系人使用当前下发的名称和图标;每轮执行仍记录实际发布版本。相对图片地址由服务端使用 Yuxi 公网入口补全。 +- 咨询聊天和文字草稿按当前云端账号、已下发智能体保存;切换项目或操作对话仍延续同一个聊天,每轮发送冻结当时的项目、来源和发布版本并检查启停。引用保留原项目/来源,切换后必须移除失配引用再发送;旧项目卡片不能操作当前项目。历史按50轮分页,流式事件只传当前轮;本机每轮独立原子落盘,索引保存消息摘要,旧轮次可通过只读工具按ID读取。可确认归属的旧话题按来源ID幂等迁入,原文件保留;“旧记录”入口只读查看升级前项目/来源话题。项目、Pi来源或版本变化时切换内部云线程并带入有预算的近期交流,同一段由Yuxi原生历史压缩管理上下文。此版聊天历史在本机保存,不提供跨设备同步。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。云端断线按游标和持久状态恢复;停止或退出账号会取消原云端问题,下次提问先停止旧的未完成问题再绑定新上下文。只接收云端主线程回答,子智能体文本不混入正文。没有操作对话时也可咨询,但无操作文本上下文。 - 删除操作对话会停止正在使用该来源的回复,保留项目级咨询历史;归档来源需先恢复后才能引用。原有来源会话级智能体 API 与存储保持兼容,原路径历史不自动迁移到新项目级咨询栏。运营草稿试聊仍使用 `niancode://coding-teacher/preview?draft_revision=N` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。 - 旧版客户端生成的朋友话题在“以往讨论”中标记为仅查看,不再创建或继续调用,也不自动绑定某个云端智能体;可选择下发项新建话题。统一咨询保留原有项目草稿;旧版朋友未发送的文字和引用可从“查看旧版朋友草稿”只读查看,不自动提交。旧文件和原发布版本保留,不复制或改写历史。已有 `coding-teacher` 云端契约及历史目录名保留,避免无必要的数据迁移。 diff --git a/electron/api/routes/coding-teacher.ts b/electron/api/routes/coding-teacher.ts index 11e8e740..37edfb31 100644 --- a/electron/api/routes/coding-teacher.ts +++ b/electron/api/routes/coding-teacher.ts @@ -17,6 +17,8 @@ export async function handleCodingTeacherRoutes( url: URL, ctx: HostApiContext ): Promise { + const legacy = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/agent-history(?:\/([^/]+))?$/); + const conversation = url.pathname.match(/^\/api\/coding\/agent-conversations\/([^/]+)(?:\/(messages|events|save|seen|discussion|requests\/([^/]+)\/cancel))?$/); const source = url.pathname.match( /^\/api\/coding\/projects\/([^/]+)\/conversations\/([^/]+)\/teacher-topics(?:\/([^/]+))?(?:\/(messages|events|save|discussion|requests\/([^/]+)\/cancel))?$/ ); @@ -32,7 +34,7 @@ export async function handleCodingTeacherRoutes( const catalog = url.pathname === '/api/coding/teacher/teachers'; const draft = url.pathname === '/api/coding/teacher-preview'; const pending = url.pathname === '/api/coding/teacher-preview/pending-link'; - if (!source && !preview && !projectTopics && !checkIn && !config && !catalog && !draft && !pending) return false; + if (!legacy && !conversation && !source && !preview && !projectTopics && !checkIn && !config && !catalog && !draft && !pending) return false; if ((config || catalog || draft || pending) && req.method !== 'GET') { sendJson(res, 405, { error: '不支持此操作。' }); return true; @@ -47,6 +49,27 @@ export async function handleCodingTeacherRoutes( return true; } try { + if (legacy) { + if (req.method !== 'GET') sendJson(res, 405, { error: '旧记录仅供查看。' }); + else sendJson(res, 200, await service.legacyHistory(decodeURIComponent(legacy[1]), legacy[2] ? { + id: decodeURIComponent(legacy[2]), sourceId: url.searchParams.get('source') ?? 'project', friend: url.searchParams.get('friend') === 'true', + } : undefined)); + return true; + } + const agentId = conversation ? decodeURIComponent(conversation[1]) : undefined; + if (agentId && !conversation![2] && req.method === 'GET') { + sendJson(res, 200, await service.conversation(agentId, url.searchParams.get('before') ?? undefined)); + return true; + } + if (agentId && conversation![2] === 'messages' && req.method === 'POST') { + sendJson(res, 202, await service.sendConversation(agentId, await parseJsonBody(req))); + return true; + } + if (agentId && conversation![2] === 'seen' && req.method === 'POST') { + const input = await parseJsonBody<{ requestId: string }>(req); + sendJson(res, 200, await service.markConversationSeen(agentId, input.requestId)); + return true; + } if (checkIn) { if (req.method !== 'POST') sendJson(res, 405, { error: '不支持此操作。' }); else sendJson(res, 200, await service.checkIn( @@ -70,14 +93,16 @@ export async function handleCodingTeacherRoutes( sendJson(res, 200, await service.previewDefinition(revision)); return true; } - const scope: TeacherScope = source + const continuous = agentId ? await service.conversation(agentId) : undefined; + if (agentId && !continuous?.topic) throw new TeacherError(404, 'teacher_topic_not_found', '智能体聊天尚未开始。'); + const scope: TeacherScope = agentId ? { projectId: '', sourceId: 'project', agentId } : source ? { projectId: decodeURIComponent(source[1]), sourceId: decodeURIComponent(source[2]) } : projectTopics ? { projectId: decodeURIComponent(projectTopics[1]), sourceId: 'project', role } : { projectId: 'preview', sourceId: 'preview' }; - const id = source?.[3] ?? projectTopics?.[3] ?? preview?.[1], - action = source?.[4] ?? projectTopics?.[4] ?? preview?.[2], - requestId = source?.[5] ?? projectTopics?.[5] ?? preview?.[3]; + const id = continuous?.topic?.id ?? source?.[3] ?? projectTopics?.[3] ?? preview?.[1], + action = conversation?.[2] ?? source?.[4] ?? projectTopics?.[4] ?? preview?.[2], + requestId = conversation?.[3] ?? source?.[5] ?? projectTopics?.[5] ?? preview?.[3]; if (!id && req.method === 'GET') { sendJson(res, 200, await service.list(scope)); return true; @@ -112,6 +137,9 @@ export async function handleCodingTeacherRoutes( const buffered: unknown[] = []; let started = false; const close = await service.subscribe(scope, id, (topic) => { + // GET owns paged history. Stream only the current turn and metadata; + // Renderer merges by request ID/revision without replacing older pages. + if (conversation) topic = { ...topic, requests: topic.requests.slice(-1) }; if (!started) { buffered.push(topic); return; diff --git a/electron/coding-teacher/cloud-runner.ts b/electron/coding-teacher/cloud-runner.ts index 7332d4c6..26ac2b36 100644 --- a/electron/coding-teacher/cloud-runner.ts +++ b/electron/coding-teacher/cloud-runner.ts @@ -212,7 +212,7 @@ export function prepareCloudTeacher( id: requestId, read_protocol: TEACHER_READ_PROTOCOL, scope: { - project_id: topic.projectId, + project_id: currentRequest?.projectId ?? topic.projectId, source_session_id: currentRequest?.sourceConversationId ?? topic.sourceConversationId, }, tools: tools?.definitions.map((item) => item.function.name) ?? [], @@ -238,7 +238,7 @@ export function prepareCloudTeacher( if (previous && previous.status !== 'completed') { try { await transport.json( - '/questions/' + encodeURIComponent(previous.id) + '/cancel', + '/questions/' + encodeURIComponent(previous.cloudRequestId ?? previous.id) + '/cancel', {}, bounded ); @@ -249,8 +249,8 @@ export function prepareCloudTeacher( let queued = await transport.json( '/questions', { - teacher_version: topic.version, - thread_id: topic.id, + teacher_version: currentRequest?.teacherVersion ?? topic.version, + thread_id: currentRequest?.runtimeThreadId ?? topic.id, request_id: requestId, query, local_context: localContext, diff --git a/electron/coding-teacher/context.ts b/electron/coding-teacher/context.ts index ef552608..e029465c 100644 --- a/electron/coding-teacher/context.ts +++ b/electron/coding-teacher/context.ts @@ -60,7 +60,8 @@ export function teacherHistoryMessages(history: TeacherRequest[]): TeacherSource return history.filter(request => request.status === 'completed').flatMap((request): TeacherSourceMessage[] => [ ...(request.intent === 'check-in' ? [] : [{ id: 'teacher:' + request.id + ':user', role: 'user' as const, - text: [...request.references.map(ref => '明确引用:\n' + ref.text), request.text].join('\n\n'), + text: [...(request.projectId ? ['当时的项目:' + (request.projectName ?? request.projectId)] : []), + ...request.references.map(ref => '明确引用:\n' + ref.text), request.text].join('\n\n'), }]), { id: 'teacher:' + request.id + ':assistant', role: 'assistant' as const, diff --git a/electron/coding-teacher/conversation-store.ts b/electron/coding-teacher/conversation-store.ts new file mode 100644 index 00000000..7bcd1847 --- /dev/null +++ b/electron/coding-teacher/conversation-store.ts @@ -0,0 +1,235 @@ +import { randomUUID } from 'node:crypto'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import type { TeacherDefinition, TeacherHistoryPage, TeacherRequest, TeacherTopic } from '../../shared/coding-teacher'; +import { excerptTeacherText, teacherHistoryMessages } from './context'; +import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json'; +import { TeacherError } from './config-client'; +import { teacherTopicId } from './store'; + +interface TurnIndex { id: string; createdAt: string; origin?: string; messages: ReturnType } +interface Manifest { + topic: Omit; + turns: TurnIndex[]; + importedDiscussions?: Record; +} +const PAGE_SIZE = 50; +const isMissing = (error: unknown) => (error as NodeJS.ErrnoException)?.code === 'ENOENT'; +const originKey = (request: TeacherRequest) => request.origin + ? [request.origin.projectId, request.origin.topicId, request.origin.requestId].join(':') : undefined; +const order = (a: TurnIndex, b: TurnIndex) => a.createdAt.localeCompare(b.createdAt) || (a.origin ?? a.id).localeCompare(b.origin ?? b.id); +const indexTurn = (turn: TeacherRequest): TurnIndex => ({ id: turn.id, createdAt: turn.createdAt, origin: originKey(turn), + messages: teacherHistoryMessages([turn]).map(message => ({ ...message, text: excerptTeacherText(message.text, 180).replaceAll('\n', ' ') })) }); + +/** A small index and one atomic file per turn. Only the latest page is held live. */ +export class TeacherConversationStore { + private manifest?: Manifest; + private live?: TeacherTopic; + private loaded?: Promise; + private writes: Promise = Promise.resolve(); + private readonly importedProjects = new Set(); + constructor(readonly directory: string, readonly accountId: string, readonly agentId: string) {} + + private manifestPath() { return path.join(this.directory, 'conversation.json'); } + private turnPath(id: string) { return path.join(this.directory, 'turns', teacherTopicId(id) + '.json'); } + private async load() { + this.loaded ??= this.loadFiles().catch(error => { this.loaded = undefined; throw error; }); + return this.loaded; + } + private async loadFiles() { + try { this.manifest = await readJsonFile(this.manifestPath()) as Manifest; } + catch (error) { if (isMissing(error)) return; throw error; } + const manifest = this.manifest; + if (manifest.topic.accountId !== this.accountId || manifest.topic.conversation?.agentId !== this.agentId + || !Array.isArray(manifest.turns)) + throw new TeacherError(409, 'teacher_history_invalid', '智能体历史无法读取,请保留本机记录。'); + // A turn can finish writing just before the index write is interrupted. + // Recover only unindexed files, without reading every historical body. + let names: string[] = []; + try { names = await readdir(path.join(this.directory, 'turns')); } + catch (error) { if (!isMissing(error)) throw error; } + const indexed = new Set(manifest.turns.map(turn => turn.id)); + let recovered = false; + for (const name of names.filter(name => name.endsWith('.json'))) { + const id = name.slice(0, -5); + if (indexed.has(id)) continue; + const turn = await this.turn(id); + manifest.turns.push(indexTurn(turn)); + recovered = true; + } + manifest.turns.sort(order); + this.live = { ...manifest.topic, requests: (await this.page()).requests }; + for (const turn of this.live.requests) { + if (turn.status !== 'preparing' && turn.status !== 'running') continue; + turn.status = 'interrupted'; + turn.error = '应用已重启,本次回复中断。'; + await atomicWriteJson(this.turnPath(turn.id), turn); + recovered = true; + } + if (recovered) { + this.live.revision++; + await this.save(this.live); + } + } + private async turn(id: string) { + const turn = await readJsonFile(this.turnPath(id)) as TeacherRequest; + if (turn.id !== id || !Array.isArray(turn.references)) + throw new TeacherError(409, 'teacher_history_invalid', '智能体消息无法读取,请保留本机记录。'); + return turn; + } + async exists() { await this.load(); return Boolean(this.manifest); } + async ensure(definition: TeacherDefinition, version: number): Promise { + await this.load(); + if (this.live) return this.live; + const now = new Date().toISOString(); + const topic: TeacherTopic = { + schemaVersion: 1, revision: 0, id: randomUUID(), accountId: this.accountId, + projectId: '', sourceConversationId: 'project', definition: structuredClone(definition), version, + createdAt: now, updatedAt: now, requests: [], + conversation: { agentId: this.agentId, segmentTurns: 0, discussions: {} }, + }; + // The manifest is durable before any turn file, so orphan recovery knows its owner. + const manifest: Manifest = { topic: { ...topic }, turns: [] }; + delete (manifest.topic as Partial).requests; + await atomicWriteJson(this.manifestPath(), manifest); + this.manifest = manifest; + this.live = topic; + return topic; + } + async read(id?: string): Promise { + await this.load(); + if (!this.live || (id && id !== this.live.id)) + throw new TeacherError(404, 'teacher_topic_not_found', '智能体聊天尚未开始。'); + return this.live; + } + async findRequest(id: string): Promise { + await this.load(); + const current = this.live?.requests.find(turn => turn.id === id); + if (current) return current; + return this.manifest?.turns.some(turn => turn.id === id) ? this.turn(id) : undefined; + } + async history() { + await this.load(); + const entries = structuredClone(this.manifest?.turns ?? []); + const owners = new Map(entries.flatMap(turn => turn.messages.map(message => [message.id, turn.id] as const))); + return { + messages: entries.flatMap(turn => turn.messages), + read: async (id: string) => { + const owner = owners.get(id); + return owner ? teacherHistoryMessages([await this.turn(owner)]).find(message => message.id === id) : undefined; + }, + }; + } + async page(before?: string, limit = PAGE_SIZE): Promise { + // loadFiles calls page only after the manifest is available. + if (!this.manifest) { + if (!this.loaded) await this.load(); + if (!this.manifest) return { requests: [], before: null }; + } + const turns = this.manifest.turns; + const end = before ? turns.findIndex(turn => turn.id === before) : turns.length; + if (end < 0) throw new TeacherError(400, 'teacher_cursor_invalid', '历史位置已变化,请重新打开聊天。'); + const start = Math.max(0, end - Math.min(PAGE_SIZE, Math.max(1, limit))); + const requests = await Promise.all(turns.slice(start, end).map(turn => + this.live?.requests.find(live => live.id === turn.id) ?? this.turn(turn.id))); + return { requests: structuredClone(requests), before: start > 0 ? turns[start].id : null }; + } + async save(topic: TeacherTopic, requestId?: string) { + const next = this.writes.catch(() => undefined).then(() => this.persist(topic, requestId)); + this.writes = next; + await next; + } + private async persist(topic: TeacherTopic, requestId?: string) { + if (!this.manifest) throw new Error('Conversation not initialized'); + const requests = requestId !== undefined ? topic.requests.filter(turn => turn.id === requestId) : topic.requests; + const turns = [...this.manifest.turns]; + for (const turn of requests) { + await atomicWriteJson(this.turnPath(turn.id), turn); + const next = indexTurn(turn); + const index = turns.findIndex(item => item.id === turn.id); + if (index < 0) turns.push(next); else turns[index] = next; + } + turns.sort(order); + const { requests: _requests, unsaved: _unsaved, ...metadata } = topic; + const next = { topic: metadata, turns, importedDiscussions: this.manifest.importedDiscussions }; + try { await atomicWriteJson(this.manifestPath(), next); } + catch (error) { + // The body may already be durable. Reload and recover it before a retry, + // including its original legacy identity, instead of importing twice. + this.loaded = undefined; + throw error; + } + this.manifest = next; + topic.unsaved = false; + // Keep references used by an active run; trim only at the next read/accept boundary. + this.live = topic; + } + async recent() { + const topic = await this.read(); + topic.requests = (await this.page()).requests; + return topic; + } + async select(_id: string) { /* There is only one conversation per agent. */ } + async list() { + await this.load(); + const topic = this.live; + return { items: topic ? [{ id: topic.id, title: topic.definition.name, updatedAt: topic.updatedAt, + version: topic.version, teacherId: this.agentId }] : [], lastSelectedTopicId: topic?.id ?? null }; + } + async markSeen(requestId: string) { + const topic = await this.read(); + const turns = this.manifest!.turns; + const index = turns.findIndex(turn => turn.id === requestId); + if (index < 0 || index <= turns.findIndex(turn => turn.id === topic.conversation!.seenThrough)) return; + topic.conversation!.seenThrough = requestId; + topic.revision++; + await this.save(topic, ''); + } + /** Old files remain untouched. Only a proven account + config ID can be imported. */ + async importProject(project: { id: string; path: string; name: string }) { + if (this.importedProjects.has(project.id)) return; + await this.load(); + const root = path.join(project.path, '.makelore', 'teacher-conversations', this.accountId); + let scopes: string[]; + try { scopes = await readdir(root); } + catch (error) { if (isMissing(error)) return; throw error; } + for (const scope of scopes.sort()) { + let names: string[]; + try { names = await readdir(path.join(root, scope)); } + catch (error) { if (isMissing(error) || (error as NodeJS.ErrnoException).code === 'ENOTDIR') continue; throw error; } + for (const name of names.filter(name => /^[0-9a-f-]{36}\.json$/i.test(name)).sort()) { + const old = await readJsonFile(path.join(root, scope, name)) as TeacherTopic; + if (old.accountId !== this.accountId || old.projectId !== project.id + || old.definition?.config_id !== this.agentId || old.role === 'friend') continue; + const topic = await this.ensure(old.definition, old.version); + const origins = new Set(this.manifest!.turns.map(turn => turn.origin).filter(Boolean)); + for (const request of old.requests) { + const origin = { projectId: project.id, topicId: old.id, requestId: request.id }; + if (origins.has([origin.projectId, origin.topicId, origin.requestId].join(':'))) continue; + const turn: TeacherRequest = { + ...structuredClone(request), id: randomUUID(), origin, projectId: project.id, projectName: project.name, + sourceConversationId: request.sourceConversationId ?? (old.sourceConversationId !== 'project' ? old.sourceConversationId : undefined), + teacherVersion: old.version, runtimeThreadId: old.id, + ...(old.definition.runtime === 'yuxi' ? { cloudRequestId: request.cloudRequestId ?? request.id } : {}), + }; + if (turn.status === 'running' || turn.status === 'preparing') { + turn.status = 'interrupted'; turn.error = '旧话题中的回复已中断。'; + } + topic.requests.push(turn); + topic.updatedAt = [topic.updatedAt, old.updatedAt].sort().at(-1)!; + topic.revision++; + await this.save(topic, turn.id); + topic.requests = topic.requests.slice(-PAGE_SIZE); + } + if (old.discussion && (!topic.conversation!.discussions[project.id] + || old.updatedAt > (this.manifest!.importedDiscussions?.[project.id] ?? ''))) { + (this.manifest!.importedDiscussions ??= {})[project.id] = old.updatedAt; + topic.conversation!.discussions[project.id] = structuredClone(old.discussion); + await this.save(topic, ''); + } + } + } + if (this.manifest) await this.recent(); + this.importedProjects.add(project.id); + } +} diff --git a/electron/coding-teacher/read-tools.ts b/electron/coding-teacher/read-tools.ts index 92502eec..aad31295 100644 --- a/electron/coding-teacher/read-tools.ts +++ b/electron/coding-teacher/read-tools.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import type { TeacherRequest, TeacherSourceContext } from '../../shared/coding-teacher'; +import type { TeacherRequest, TeacherSourceContext, TeacherSourceMessage } from '../../shared/coding-teacher'; import { CodingProjectFileService } from '../coding-projects/project-files'; import { excerptTeacherText, teacherHistoryMessages } from './context'; import { readInteger, readPage, textChunks } from './read-page'; @@ -14,6 +14,7 @@ export interface TeacherReadAccess { projectPath: string; source: TeacherSourceContext; history?: TeacherRequest[]; + archive?: { messages: TeacherSourceMessage[]; read(id: string): Promise }; assertCurrent(): void; } @@ -32,7 +33,7 @@ export const teacherReadToolDefinitions = [ parameters: { type: 'object', properties: { path: { type: 'string' }, ...lineParameters }, required: ['path'], additionalProperties: false }, } }, { type: 'function', function: { - name: 'read_conversation', description: 'Read the captured active coding conversation and current teacher topic. Omit message_id to list messages; supply it to read continuous original text. Results give start_line/start_column and next; do not reread unchanged ranges.', + name: 'read_conversation', description: 'Read the captured active coding conversation and this agent chat history. Historical messages can belong to earlier projects and are labelled as such; files remain limited to the current project. Omit message_id to list messages; supply it to read continuous original text. Results give start_line/start_column and next; do not reread unchanged ranges.', parameters: { type: 'object', properties: { message_id: { type: 'string' }, ...lineParameters }, additionalProperties: false }, } }, ]; @@ -50,6 +51,7 @@ function projectPath(value: unknown): string { export function createTeacherReadTools(access: TeacherReadAccess) { const files = new CodingProjectFileService(); const messages = [...access.source.messages, ...teacherHistoryMessages(access.history ?? [])]; + const index = [...access.source.messages, ...(access.archive?.messages ?? teacherHistoryMessages(access.history ?? []))]; return { definitions: teacherReadToolDefinitions, async execute(name: string, rawArguments: string, signal: AbortSignal, maxBytes = TEACHER_READ_PAGE_BYTES): Promise { @@ -96,9 +98,10 @@ export function createTeacherReadTools(access: TeacherReadAccess) { break; } case 'read_conversation': { - const message = args.message_id === undefined ? undefined : messages.find(item => item.id === args.message_id); + const message = args.message_id === undefined ? undefined : messages.find(item => item.id === args.message_id) + ?? (typeof args.message_id === 'string' ? await access.archive?.read(args.message_id) : undefined); if (args.message_id !== undefined && !message) throw new Error('Message is not in the current conversation.'); - const text = message?.text ?? (messages.map(item => + const text = message?.text ?? (index.map(item => `${item.id} ${item.role}: ${excerptTeacherText(item.text, 180).replaceAll('\n', ' ')}`).join('\n') || '(no completed text messages in this conversation)'); ({ content: result, truncated } = await readPage(message ? message.id + ' ' + message.role : 'conversation message index (snippets)', diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index 6aebbc1f..53ff6e6d 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -11,9 +11,11 @@ import type { TeacherDefinition, TeacherDiscussionAction, TeacherReference, + TeacherRequest, TeacherSend, TeacherSourceContext, TeacherTopic, + TeacherTopicList, } from '../../shared/coding-teacher'; import { TEACHER_CHECK_IN_INTERVAL_MS, TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS } from '../../shared/coding-teacher'; import { @@ -27,6 +29,8 @@ import { type TeacherAccount, } from './config-client'; import { TeacherTopicStore, teacherTopicId } from './store'; +import { TeacherConversationStore } from './conversation-store'; +import { readJsonFile } from '../coding-projects/atomic-json'; import { compileTeacherContext } from './context'; import { prepareTeacherModel } from './model-runner'; import { prepareCloudTeacher } from './cloud-runner'; @@ -36,6 +40,7 @@ import { applyDiscussionReply, discussionInstructions, editDiscussion, validateD import { subscribeWorksSquareSession } from '../services/works-square-session'; export interface TeacherScope { + agentId?: string; projectId: string; sourceId: string; role?: LegacyConsultationRole; @@ -59,6 +64,7 @@ export interface TeacherServiceOptions { readSource?(scope: TeacherScope): Promise; } export class CodingTeacherService { + private readonly conversations = new Map(); private readonly stores = new Map(); private readonly tails = new Map>(); private readonly active = new Map< @@ -99,10 +105,103 @@ export class CodingTeacherService { async catalog() { return (this.options.catalog ?? teacherCatalog)(await this.account()); } + async legacyHistory(projectId: string, selection?: { id: string; sourceId: string; friend: boolean }) { + const account = await this.account(); + const project = await this.options.projects.getProject(projectId); + const directory = (friend: boolean) => path.join(project.path, '.makelore', friend ? 'friend-conversations' : 'teacher-conversations', account.id); + const read = async (friend: boolean, source: string, id: string) => { + const topic = await readJsonFile(path.join(directory(friend), source === 'project' ? source : teacherTopicId(source), teacherTopicId(id) + '.json')) as TeacherTopic; + if (topic.accountId !== account.id || topic.projectId !== projectId) + throw new TeacherError(404, 'teacher_topic_not_found', '旧记录不存在。'); + this.assertAccount(account); + return topic; + }; + if (selection) return read(selection.friend, selection.sourceId, selection.id); + const items: TeacherTopicList['items'] = []; + for (const friend of [false, true]) { + let scopes: string[]; + try { scopes = await readdir(directory(friend)); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; throw error; } + for (const source of scopes.filter(value => value === 'project' || /^[0-9a-f-]{36}$/i.test(value))) { + for (const name of await readdir(path.join(directory(friend), source))) { + if (!/^[0-9a-f-]{36}\.json$/i.test(name)) continue; + const topic = await read(friend, source, name.slice(0, -5)); + items.push({ id: topic.id, title: topic.requests.find(turn => turn.text)?.text.slice(0, 48) || topic.definition.name, + updatedAt: topic.updatedAt, version: topic.version, teacherId: topic.definition.config_id, + legacySourceId: source, ...(friend ? { legacyRole: 'friend' as const } : {}) }); + } + } + } + this.assertAccount(account); + return { items: items.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), lastSelectedTopicId: null }; + } + private conversationStore(account: TeacherAccount, agentId: string) { + if (!agentId || agentId.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(agentId)) + throw new TeacherError(400, 'teacher_agent_invalid', '智能体标识无效。'); + const key = account.id + ':' + agentId; + let store = this.conversations.get(key); + if (!store) { + store = new TeacherConversationStore(path.join(this.options.userDataDir, 'agent-conversations', account.id, agentId), account.id, agentId); + this.conversations.set(key, store); + } + return store; + } + private async importConversations(account: TeacherAccount, agentId: string) { + const store = this.conversationStore(account, agentId); + if (![...this.active.keys()].some(key => key.startsWith(account.id + ':agent:' + agentId + ':'))) { + for (const project of await this.options.projects.listProjects()) { + this.assertAccount(account); + await store.importProject(project); + } + } + return store; + } + async conversation(agentId: string, before?: string) { + const account = await this.account(); + return this.serialize(account.id + ':agent:' + agentId + ':acceptance', async () => { + const store = await this.importConversations(account, agentId); + if (!await store.exists()) return { topic: null, before: null }; + const topic = await store.read(); + const page = await store.page(before); + this.assertAccount(account); + return { topic: { ...structuredClone(topic), requests: page.requests }, before: page.before }; + }); + } + async sendConversation(agentId: string, input: TeacherSend) { + if (!input.projectId) throw new TeacherError(422, 'teacher_project_required', '请先选择本次提问的项目。'); + const account = await this.account(); + const scope: TeacherScope = { projectId: input.projectId, sourceId: 'project', agentId }; + return this.serialize(this.acceptanceKey(account, scope), async () => { + const store = await this.importConversations(account, agentId); + if (!await store.exists()) { + const catalog = await (this.options.catalog ?? teacherCatalog)(account); + const selected = catalog.items.find(item => item.teacher_id === agentId); + if (!selected) throw new TeacherError(409, 'teacher_disabled', '智能体已停用,历史仍可查看。'); + this.assertAccount(account); + await store.ensure(selected.definition, selected.version); + } + const topic = await store.read(); + if (!this.active.has(this.key(account, scope, topic.id))) await store.recent(); + return this.sendRequest(account, scope, topic.id, input); + }); + } + async markConversationSeen(agentId: string, requestId: string) { + const account = await this.account(); + const scope = { agentId, projectId: '', sourceId: 'project' }; + return this.serialize(this.acceptanceKey(account, scope), async () => { + const store = this.conversationStore(account, agentId); + this.assertAccount(account); + await store.markSeen(requestId); + const topic = await store.read(); + this.events.emit(this.key(account, scope, topic.id), structuredClone(topic)); + return structuredClone(topic); + }); + } private async scopedStore( account: TeacherAccount, scope: TeacherScope - ): Promise { + ): Promise { + if (scope.agentId) return this.conversationStore(account, scope.agentId); if (this.deletingSources.has(scope.projectId + ':' + scope.sourceId)) throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); let directory: string; @@ -128,9 +227,11 @@ export class CodingTeacherService { return store; } private key(account: TeacherAccount, scope: TeacherScope, id: string) { + if (scope.agentId) return account.id + ':agent:' + scope.agentId + ':' + id; return account.id + ':' + scope.projectId + ':' + scope.sourceId + ':' + (scope.role ?? 'teacher') + ':' + id; } private acceptanceKey(account: TeacherAccount, scope: TeacherScope) { + if (scope.agentId) return account.id + ':agent:' + scope.agentId + ':acceptance'; return account.id + ':' + scope.projectId + ':teacher-acceptance'; } private async serialize(key: string, operation: () => Promise): Promise { @@ -146,7 +247,7 @@ export class CodingTeacherService { async list(scope: TeacherScope) { const account = await this.account(); const current = await (await this.scopedStore(account, scope)).list(); - if (scope.sourceId !== 'project' || scope.role === 'friend') return current; + if (scope.agentId || scope.sourceId !== 'project' || scope.role === 'friend') return current; const legacy = await (await this.scopedStore(account, { ...scope, role: 'friend' })).list(); return { ...current, items: [...current.items, ...legacy.items.map(item => ({ ...item, legacyRole: 'friend' as const }))] .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) }; @@ -216,8 +317,8 @@ export class CodingTeacherService { const topic = (await store.read(id)) as PreviewTopic; if ( topic.accountId !== account.id || - topic.projectId !== scope.projectId || - topic.sourceConversationId !== scope.sourceId || + (scope.agentId ? topic.conversation?.agentId !== scope.agentId : + topic.projectId !== scope.projectId || topic.sourceConversationId !== scope.sourceId) || (topic.role ?? 'teacher') !== (scope.role ?? 'teacher') ) throw new TeacherError(404, 'teacher_topic_not_found', '智能体话题不存在。'); @@ -347,12 +448,17 @@ export class CodingTeacherService { throw new TeacherError(422, 'teacher_reference_invalid', '引用内容无效或超过 12000 字。'); const key = this.key(account, scope, id); return await this.serialize(key, async () => { - const { store, topic } = await this.readOwned(account, scope, id); + const { store, topic: stored } = await this.readOwned(account, scope, id); + if (this.active.has(key) && !stored.requests.some(request => ['preparing', 'running'].includes(request.status))) + await this.finishes.get(key); + const topic = scope.agentId ? structuredClone(stored) : stored; this.assertWritable(scope, topic); - const existing = topic.requests.find((request) => request.id === input.requestId); + const existing = store instanceof TeacherConversationStore ? await store.findRequest(input.requestId) + : topic.requests.find((request) => request.id === input.requestId); if (existing) { if ( existing.text !== input.text || + (scope.agentId && existing.projectId !== scope.projectId) || (existing.intent ?? 'question') !== intent || JSON.stringify(existing.references) !== JSON.stringify(refs) || (existing.sourceConversationId ?? undefined) !== (input.sourceConversationId ?? undefined) || @@ -369,6 +475,27 @@ export class CodingTeacherService { ) ) throw new TeacherError(409, 'teacher_topic_busy', '请等待当前回复完成,或先停止。'); + let newConversationSegment = false; + if (scope.agentId) { + const selected = (await (this.options.catalog ?? teacherCatalog)(account)).items.find(item => item.teacher_id === scope.agentId); + if (!selected) throw new TeacherError(409, 'teacher_disabled', '智能体已停用,历史仍可查看。'); + this.assertAccount(account); + topic.definition = structuredClone(selected.definition); + topic.version = selected.version; + topic.projectId = scope.projectId; + topic.sourceConversationId = 'project'; + topic.discussion = topic.conversation!.discussions[scope.projectId]; + const previous = topic.requests.at(-1); + const reuse = previous && !previous.origin && previous.projectId === scope.projectId + && previous.sourceConversationId === input.sourceConversationId + && previous.teacherVersion === selected.version && previous.runtimeThreadId; + newConversationSegment = !reuse; + // Yuxi binds both project and Pi source to a thread. Preserve its native + // summary middleware within a segment; explicitly carry bounded public + // history only when a new scope/release starts another segment. + topic.conversation!.runtimeThreadId = reuse || randomUUID(); + topic.conversation!.segmentTurns = reuse ? topic.conversation!.segmentTurns + 1 : 1; + } const discussionContext = validateDiscussionContext(topic, input.discussion); if (topic.draftRevision) { await (this.options.preview ?? teacherPreview)(account, topic.draftRevision); @@ -432,20 +559,21 @@ export class CodingTeacherService { projectPath: (await this.options.projects.getProject(scope.projectId)).path, source, history: topic.requests, + ...(store instanceof TeacherConversationStore ? { archive: await store.history() } : {}), assertCurrent: () => this.assertAccount(account), }; const model = isCloud && access ? (this.options.prepareCloud ?? prepareCloudTeacher)(account, topic, input.requestId, access, (progress) => { - const current = topic.requests.at(-1)!; + const current = topic.requests.find(request => request.id === input.requestId)!; if (current.progress === progress) return; current.progress = progress; topic.revision++; this.events.emit(key, structuredClone(topic)); }, async cloudRequestId => { - topic.requests.at(-1)!.cloudRequestId = cloudRequestId; - await store.save(topic); + topic.requests.find(request => request.id === input.requestId)!.cloudRequestId = cloudRequestId; + await store.save(topic, input.requestId); }, undefined, activity => { - const current = topic.requests.at(-1)!; + const current = topic.requests.find(request => request.id === input.requestId)!; const activities = current.toolActivity ??= []; const index = activities.findIndex(item => item.id === activity.id); if (index < 0) activities.push(activity); @@ -460,7 +588,7 @@ export class CodingTeacherService { const compiled = compileTeacherContext( topic.definition, source, - isCloud ? [] : topic.requests, + isCloud && (!scope.agentId || !newConversationSegment) ? [] : topic.requests, input.text, references, model.inputLimit, @@ -482,7 +610,10 @@ export class CodingTeacherService { throw new TeacherError(409, 'teacher_source_archived', '来源会话已归档。'); this.assertAccount(account); } - const request = { + const request: TeacherRequest = { + ...(scope.agentId ? { projectId: scope.projectId, + projectName: (await this.options.projects.getProject(scope.projectId)).name, + teacherVersion: topic.version, runtimeThreadId: topic.conversation!.runtimeThreadId } : {}), id: input.requestId, intent, ...(input.presentation ? { presentation: input.presentation } : {}), @@ -504,7 +635,7 @@ export class CodingTeacherService { topic.updatedAt = request.createdAt; topic.revision++; try { - await store.save(topic); + await store.save(topic, request.id); } catch (error) { topic.requests.pop(); throw error; @@ -517,7 +648,7 @@ export class CodingTeacherService { this.active.set(key, { account, controller, sourceId, projectId: scope.projectId }); const release = this.options.acquireLease?.(key) ?? (() => undefined); const finish = async () => { - const current = topic.requests.at(-1)!; + const current = topic.requests.find(request => request.id === input.requestId)!; try { this.assertAccount(account); if (controller.signal.aborted) throw controller.signal.reason; @@ -554,7 +685,8 @@ export class CodingTeacherService { topic.updatedAt = new Date().toISOString(); topic.revision++; try { - await store.save(topic); + if (topic.conversation && topic.discussion) topic.conversation.discussions[scope.projectId] = topic.discussion; + await store.save(topic, current.id); } catch { topic.unsaved = true; } @@ -578,11 +710,17 @@ export class CodingTeacherService { if (this.active.has(key) || topic.requests.some(request => ['preparing', 'running'].includes(request.status))) throw new TeacherError(409, 'teacher_topic_busy', '请等待智能体回复,或先停止。'); const next = structuredClone(topic); + if (next.conversation) { + if (!input.projectId) throw new TeacherError(422, 'teacher_project_required', '请选择讨论所属的项目。'); + await this.options.projects.getProject(input.projectId); + next.discussion = next.conversation.discussions[input.projectId]; + } editDiscussion(next, input); + if (next.conversation && next.discussion) next.conversation.discussions[input.projectId!] = next.discussion; next.revision++; next.updatedAt = new Date().toISOString(); this.assertAccount(account); - await store.save(next); + await store.save(next, scope.agentId ? '' : undefined); this.events.emit(key, structuredClone(next)); return structuredClone(next); }); @@ -590,7 +728,7 @@ export class CodingTeacherService { async cancel(scope: TeacherScope, id: string, requestId: string) { const account = await this.account(); const { topic } = await this.readOwned(account, scope, id); - if (topic.requests.at(-1)?.id === requestId) + if (topic.requests.some(request => request.id === requestId && ['preparing', 'running'].includes(request.status))) this.active.get(this.key(account, scope, id))?.controller.abort(); return structuredClone(topic); } diff --git a/electron/coding-teacher/store.ts b/electron/coding-teacher/store.ts index 7614837b..0af76b66 100644 --- a/electron/coding-teacher/store.ts +++ b/electron/coding-teacher/store.ts @@ -101,7 +101,7 @@ export class TeacherTopicStore { } return await pending; } - async save(topic: TeacherTopic) { + async save(topic: TeacherTopic, _requestId?: string) { await mkdir(this.directory, { recursive: true }); const saved = { ...topic }; delete saved.unsaved; diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index 4bc24280..b4f92c05 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -74,12 +74,19 @@ export interface TeacherDiscussionContext { transition?: 'structure'; } export interface TeacherDiscussionAction { + projectId?: string; toolId: string; revision: number; action: 'enter' | 'pause' | 'finish' | 'resume' | 'keep-idea' | 'aside-idea' | 'first-idea' | 'back-ideas'; itemId?: string; } export interface TeacherRequest { + /** Frozen when this turn is accepted; history can span projects and releases. */ + projectId?: string; + projectName?: string; + teacherVersion?: number; + runtimeThreadId?: string; + origin?: { projectId: string; topicId: string; requestId: string }; presentation?: 'discussion-v1'; discussionContext?: TeacherDiscussionContext; discussionSnapshot?: TeacherDiscussionContent; @@ -115,6 +122,14 @@ export interface TeacherToolActivity { status: 'running' | 'completed' | 'failed'; } export interface TeacherTopic { + /** Present only for the account-owned continuous conversation. */ + conversation?: { + agentId: string; + runtimeThreadId?: string; + segmentTurns: number; + discussions: Record; + seenThrough?: string; + }; discussion?: TeacherDiscussion; role?: LegacyConsultationRole; revision: number; @@ -132,10 +147,11 @@ export interface TeacherTopic { unsaved?: boolean; } export interface TeacherTopicList { - items: Array<{ id: string; title: string; updatedAt: string; version: number; teacherId?: string; legacyRole?: 'friend' }>; + items: Array<{ id: string; title: string; updatedAt: string; version: number; teacherId?: string; legacyRole?: 'friend'; legacySourceId?: string }>; lastSelectedTopicId: string | null; } export interface TeacherSend { + projectId?: string; presentation?: 'discussion-v1'; discussion?: TeacherDiscussionContext; intent?: TeacherRequestIntent; @@ -144,6 +160,14 @@ export interface TeacherSend { text: string; references?: TeacherReference[]; } +export interface TeacherHistoryPage { + requests: TeacherRequest[]; + before: string | null; +} +export interface TeacherConversationView { + topic: TeacherTopic | null; + before: string | null; +} export interface TeacherCheckInInput { requestId: string; sourceConversationId: string; diff --git a/src/lib/coding-teacher.ts b/src/lib/coding-teacher.ts index 6b445db9..961e9d21 100644 --- a/src/lib/coding-teacher.ts +++ b/src/lib/coding-teacher.ts @@ -9,6 +9,7 @@ import type { TeacherSend, TeacherTopic, TeacherTopicList, + TeacherConversationView, } from '../../shared/coding-teacher'; export function teacherTopicsPath(projectId: string) { return projectId === 'preview' @@ -20,6 +21,15 @@ export function legacyTopicBase(base: string, legacyFriend: boolean) { return legacyFriend ? base.replace(/agent-topics$/, 'friend-topics') : base; } export const teacherApi = { + legacyHistory: (projectId: string) => hostApiFetch(`/api/coding/projects/${encodeURIComponent(projectId)}/agent-history`), + legacyRead: (projectId: string, item: TeacherTopicList['items'][number]) => hostApiFetch( + `/api/coding/projects/${encodeURIComponent(projectId)}/agent-history/${encodeURIComponent(item.id)}?source=${encodeURIComponent(item.legacySourceId ?? 'project')}&friend=${item.legacyRole === 'friend'}`), + seenConversation: (agentId: string, requestId: string) => hostApiFetch( + '/api/coding/agent-conversations/' + encodeURIComponent(agentId) + '/seen', { method: 'POST', body: JSON.stringify({ requestId }) }), + conversation: (agentId: string, before?: string) => hostApiFetch( + '/api/coding/agent-conversations/' + encodeURIComponent(agentId) + (before ? '?before=' + encodeURIComponent(before) : '')), + sendConversation: (agentId: string, input: TeacherSend) => hostApiFetch( + '/api/coding/agent-conversations/' + encodeURIComponent(agentId) + '/messages', { method: 'POST', body: JSON.stringify(input) }), catalog: () => hostApiFetch('/api/coding/teacher/teachers'), checkIn: (projectId: string, input: TeacherCheckInInput) => hostApiFetch(`/api/coding/projects/${encodeURIComponent(projectId)}/teacher-check-in`, { diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index a33cb896..0e1e551d 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -954,7 +954,7 @@ export function CodingChatPanel({ onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) setConsultationWidth(Math.max(320, Math.min(640, window.innerWidth - event.clientX))); }} onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); setConsultationWidth((width) => Math.max(320, Math.min(640, width + (event.key === 'ArrowLeft' ? 20 : -20)))); } }} /> - (); +const messageOrderKey = (request: TeacherTopic['requests'][number]) => request.origin + ? [request.origin.projectId, request.origin.topicId, request.origin.requestId].join(':') : request.id; + export function TeacherChatPanel({ projectId, + projectName, sourceId, onClose, onBringBack, @@ -45,13 +52,21 @@ export function TeacherChatPanel({ onComposingChange?(composing: boolean): void; selectedAgent?: TeacherCatalog['items'][number]; }) { - const base = teacherTopicsPath(projectId); + const continuous = Boolean(selectedAgent && !draftRevision); + const base = continuous ? '/api/coding/agent-conversations' : teacherTopicsPath(projectId); const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); const projectDraftKey = `makelore-consultation-draft:${account}:${projectId}:teacher`; - const draftKey = !draftRevision ? selectedAgent ? `makelore-consultation-draft:${account}:${projectId}:agent:${selectedAgent.teacher_id}` : projectDraftKey : null; - const [saved] = useState<{ text?: string; references?: TeacherReference[]; pending?: TeacherSend }>(() => { - try { return draftKey ? JSON.parse(localStorage.getItem(draftKey) ?? '{}') : {}; } catch { return {}; } + const draftKey = !draftRevision ? selectedAgent ? `makelore-consultation-draft:${account}:agent:${selectedAgent.teacher_id}` : projectDraftKey : null; + const [cachedView] = useState(() => continuous && draftKey ? conversationViews.get(draftKey) : undefined); + const [saved] = useState<{ text?: string; references?: TeacherReference[]; pending?: TeacherSend; projectId?: string; sourceId?: string }>(() => { + try { + const legacyKey = selectedAgent ? `makelore-consultation-draft:${account}:${projectId}:agent:${selectedAgent.teacher_id}` : projectDraftKey; + return draftKey ? JSON.parse(localStorage.getItem(draftKey) ?? localStorage.getItem(legacyKey) ?? '{}') : {}; + } catch { return {}; } }); + const [referenceScope, setReferenceScope] = useState({ projectId: saved.projectId ?? projectId, sourceId: saved.sourceId ?? sourceId }); + const referenceScopeRef = useRef(referenceScope); + referenceScopeRef.current = referenceScope; const [legacyDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => { try { return draftKey ? JSON.parse(localStorage.getItem(`makelore-consultation-draft:${account}:${projectId}:friend`) ?? '{}') : {}; } catch { return {}; } }); @@ -60,11 +75,17 @@ export function TeacherChatPanel({ }); const persistDraft = useCallback((text: string, references: TeacherReference[], pending?: TeacherSend | null) => { if (!draftKey) return; - try { localStorage.setItem(draftKey, JSON.stringify({ text, references, pending })); } catch { /* Draft remains in the open panel. */ } + try { localStorage.setItem(draftKey, JSON.stringify({ text, references, pending, ...referenceScopeRef.current })); } catch { /* Draft remains in the open panel. */ } }, [draftKey]); const label = '智能体'; const discussionEnabled = !draftRevision; const [historyOpen, setHistoryOpen] = useState(false); + const [before, setBefore] = useState(cachedView?.before ?? null); + const [loadingOlder, setLoadingOlder] = useState(false); + const olderAnchor = useRef<{ height: number; top: number } | null>(null); + const followBottom = useRef(cachedView?.follow ?? true); + const restoreTop = useRef(cachedView?.top); + const scrollPosition = useRef(cachedView?.top ?? 0); const [focus, setFocus] = useState<{ topicId: string; id: string; label: string } | null>(null); const [definition, setDefinition] = useState(null); const externalTopicRef = useRef(externalTopic); @@ -75,9 +96,15 @@ export function TeacherChatPanel({ const inputRef = useRef(null); const focusedOnce = useRef(false); const [topics, setTopics] = useState({ items: [], lastSelectedTopicId: null }); - const [topic, setTopic] = useState(null); + const [storedTopic, setTopic] = useState(cachedView?.topic ?? null); + const topic = useMemo(() => storedTopic && continuous ? { ...storedTopic, + discussion: storedTopic.projectId === projectId ? storedTopic.discussion : storedTopic.conversation?.discussions[projectId] } + : storedTopic, [storedTopic, continuous, projectId]); const legacyFriend = topic?.role === 'friend'; + const legacyView = Boolean(continuous && topic && !topic.conversation); + const viewingLegacy = useRef(false); const topicBase = legacyTopicBase(base, legacyFriend); + const endpointId = continuous ? selectedAgent!.teacher_id : topic?.id ?? ''; const [text, setText] = useState(saved.text ?? ''); const [references, setReferences] = useState(quote ? [quote] : saved.references ?? []); useEffect(() => { onComposingChange?.(Boolean(text.trim() || references.length)); }, [text, references.length, onComposingChange]); @@ -90,7 +117,7 @@ export function TeacherChatPanel({ const [legacyEnabled, setLegacyEnabled] = useState(false); const [teachers, setTeachers] = useState([]); const [selectedTeacherVersion, setSelectedTeacherVersion] = useState(); - const topicEnabled = legacyFriend ? false : !topic ? enabled + const topicEnabled = legacyFriend || legacyView ? false : !topic ? enabled : topic.definition.config_id ? teachers.some((item) => item.teacher_id === topic.definition.config_id) : legacyEnabled; @@ -102,36 +129,54 @@ export function TeacherChatPanel({ const topicRef = useRef(topic); topicRef.current = topic; const scrollRef = useRef(null); + const beforeRef = useRef(before); + beforeRef.current = before; + useEffect(() => () => { + if (continuous && draftKey && !viewingLegacy.current) conversationViews.set(draftKey, { topic: topicRef.current, + before: beforeRef.current, top: scrollPosition.current, follow: followBottom.current }); + }, [continuous, draftKey]); 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(() => { + useLayoutEffect(() => { const pane = scrollRef.current; - if (pane) pane.scrollTop = pane.scrollHeight; - }, [topic?.id, topic?.revision]); + if (!pane) return; + if (restoreTop.current !== undefined) { + pane.scrollTop = restoreTop.current; restoreTop.current = undefined; + } else if (olderAnchor.current) { + pane.scrollTop = olderAnchor.current.top + pane.scrollHeight - olderAnchor.current.height; + olderAnchor.current = null; + } else if (followBottom.current) pane.scrollTop = pane.scrollHeight; + }, [topic?.id, topic?.revision, topic?.requests.length]); const consume = useCallback( (next: TeacherTopic) => { if (pending.current && next.requests.some((request) => request.id === pending.current?.requestId)) { pending.current = null; persistDraft(draftRef.current.text, draftRef.current.references); } - setTopic((current) => - current?.id === next.id && current.revision > next.revision ? current : next - ); + setTopic((current) => { + const olderSnapshot = current?.id === next.id && current.revision > next.revision; + if (olderSnapshot && !continuous) return current; + if (!continuous || current?.id !== next.id) return next; + const requests = new Map(current.requests.map(request => [request.id, request])); + next.requests.forEach(request => { if (!olderSnapshot || !requests.has(request.id)) requests.set(request.id, request); }); + return { ...(olderSnapshot ? current : next), requests: [...requests.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt) || messageOrderKey(a).localeCompare(messageOrderKey(b))) }; + }); }, - [persistDraft] + [persistDraft, continuous] ); useEffect(() => { - if (externalTopic && externalTopic.projectId === projectId + if (!viewingLegacy.current && externalTopic && (continuous || externalTopic.projectId === projectId) && (!selectedAgentId || externalTopic.definition.config_id === selectedAgentId)) { consume(externalTopic); - setDefinition(externalTopic.definition); + setDefinition(continuous ? selectedAgentRef.current?.definition ?? externalTopic.definition : externalTopic.definition); } - }, [externalTopic, projectId, consume, selectedAgentId]); - useEffect(() => { if (topic) onTopicChange?.(topic); }, [topic, onTopicChange]); + }, [externalTopic, projectId, consume, selectedAgentId, continuous]); + useEffect(() => { if (topic && !legacyView) onTopicChange?.(topic); }, [topic, onTopicChange, legacyView]); + useEffect(() => { if (continuous && selectedAgent) setDefinition(selectedAgent.definition); }, [continuous, selectedAgent]); useEffect(() => { if (!busy && !focusedOnce.current) { focusedOnce.current = true; @@ -139,8 +184,8 @@ export function TeacherChatPanel({ } }, [busy]); useEffect(() => { - if (quote) setReferences([quote]); - }, [quote]); + if (quote) { setReferences([quote]); setReferenceScope({ projectId, sourceId }); } + }, [quote, projectId, sourceId]); useEffect(() => { let alive = true; setBusy(true); @@ -171,6 +216,11 @@ export function TeacherChatPanel({ if (alive) setError(e instanceof Error ? e.message : `${label}配置暂不可用`); } try { + if (continuous && selectedAgentId) { + const view = await teacherApi.conversation(selectedAgentId); + if (alive) { if (view.topic) consume(view.topic); if (!cachedView?.topic) setBefore(view.before); } + return; + } const listed = await teacherApi.list(base); if (!alive) return; setTopics(listed); @@ -199,14 +249,26 @@ export function TeacherChatPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps generation.current++; }; - }, [base, draftRevision, label, consume, selectedAgentId]); + }, [base, draftRevision, label, consume, selectedAgentId, continuous, cachedView]); + const seenPending = useRef(undefined); + const markSeen = useCallback(() => { + const current = topicRef.current; + const last = current?.requests.at(-1); + if (!continuous || legacyView || !selectedAgentId || !current || !last || !followBottom.current + || document.visibilityState === 'hidden' || ['preparing', 'running'].includes(last.status) + || current.conversation?.seenThrough === last.id || seenPending.current === last.id) return; + seenPending.current = last.id; + void teacherApi.seenConversation(selectedAgentId, last.id).then(consume).catch(() => undefined) + .finally(() => { seenPending.current = undefined; }); + }, [continuous, legacyView, selectedAgentId, consume]); + useEffect(() => { markSeen(); }, [topic?.revision, markSeen]); useEffect(() => { - if (!topic?.id || legacyFriend) return; + if (!topic?.id || legacyFriend || legacyView) return; const streamTopicId = topic.id; let closed = false, stream: EventSource | undefined; void teacherApi - .events(topicBase, topic.id) + .events(topicBase, endpointId) .then((source) => { if (closed) { source.close(); @@ -216,13 +278,7 @@ export function TeacherChatPanel({ source.addEventListener('snapshot', (event) => { if (closed) return; const next = JSON.parse((event as MessageEvent).data) as TeacherTopic; - setTopic((current) => - current?.id === streamTopicId && - next.id === streamTopicId && - current.revision <= next.revision - ? next - : current - ); + if (next.id === streamTopicId) consume(next); }); source.onerror = () => { if (!closed) setError(`连接暂时中断,正在恢复${label}回复。`); @@ -238,14 +294,44 @@ export function TeacherChatPanel({ closed = true; stream?.close(); }; - }, [topicBase, topic?.id, legacyFriend, consume, label]); + }, [topicBase, topic?.id, endpointId, legacyFriend, legacyView, consume, label]); + const loadOlder = async () => { + if (!before || loadingOlder || !selectedAgentId) return; + const epoch = generation.current; + setLoadingOlder(true); + try { + const page = await teacherApi.conversation(selectedAgentId, before); + if (epoch !== generation.current) return; + const pane = scrollRef.current; + if (pane) olderAnchor.current = { height: pane.scrollHeight, top: pane.scrollTop }; + followBottom.current = false; + if (page.topic) consume(page.topic); + setBefore(page.before); + } catch (error) { if (epoch === generation.current) setError(error instanceof Error ? error.message : '较早消息加载失败,请重试。'); } + finally { if (epoch === generation.current) setLoadingOlder(false); } + }; + const loadLegacy = async () => { + setHistoryOpen(value => !value); + try { + setTopics(await teacherApi.legacyHistory(projectId)); + } catch { setError('旧记录暂时无法读取。'); } + }; + const returnToConversation = async () => { + if (!selectedAgentId) return; + const view = await teacherApi.conversation(selectedAgentId); + viewingLegacy.current = false; + setTopic(view.topic); setBefore(view.before); setDefinition(selectedAgentRef.current?.definition ?? null); + setHistoryOpen(false); + }; const choose = async (id: string) => { const version = ++generation.current; setBusy(true); setError(''); try { - const next = await teacherApi.read(legacyTopicBase(base, topics.items.some(item => item.id === id && item.legacyRole === 'friend')), id); + const next = continuous ? await teacherApi.legacyRead(projectId, topics.items.find(item => item.id === id)!) + : await teacherApi.read(legacyTopicBase(base, topics.items.some(item => item.id === id && item.legacyRole === 'friend')), id); if (version === generation.current) { + if (continuous) viewingLegacy.current = true; consume(next); setDefinition(next.definition); pending.current = null; @@ -284,12 +370,18 @@ export function TeacherChatPanel({ 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; + if (continuous && questionReferences.length && (referenceScope.projectId !== projectId || referenceScope.sourceId !== sourceId)) { + setError('引用来自另一个项目或操作对话,请切回来源,或移除引用后再提问。'); return; + } + if (continuous && pending.current?.text === question && pending.current.projectId && pending.current.projectId !== projectId) { + setError('上次提问仍绑定原项目,请切回原项目确认结果后重试。'); return; + } const version = generation.current; setBusy(true); setError(''); let current = topicRef.current; try { - if (!current) { + if (!current && !continuous) { current = await teacherApi.create( base, draftRevision, @@ -301,15 +393,17 @@ export function TeacherChatPanel({ 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' + 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) + const previous = pending.current && !current?.requests.some((request) => request.id === pending.current?.requestId) ? pending.current : null; const input = previous && previous.text === question && + (!continuous || previous.projectId === projectId) && + previous.sourceConversationId === (sourceId || undefined) && previous.presentation === presentation && JSON.stringify(previous.discussion) === JSON.stringify(discussion) && (previous.intent ?? 'question') === (action?.intent ?? 'question') && @@ -317,13 +411,15 @@ export function TeacherChatPanel({ ? previous : { requestId: crypto.randomUUID(), text: question, references: questionReferences, + ...(continuous ? { projectId } : {}), ...(presentation ? { presentation } : {}), ...(discussion ? { discussion } : {}), ...(action?.intent ? { intent: action.intent } : {}), ...(sourceId ? { sourceConversationId: sourceId } : {}), }; pending.current = input; persistDraft(text, references, input); - const next = await teacherApi.send(topicBase, current.id, input); + const next = continuous ? await teacherApi.sendConversation(selectedAgentId!, input) + : await teacherApi.send(topicBase, current!.id, input); if (version !== generation.current) return; consume(next); pending.current = null; @@ -334,7 +430,7 @@ export function TeacherChatPanel({ setReferences([]); persistDraft('', []); } - setTopics(await teacherApi.list(base)); + if (!continuous) setTopics(await teacherApi.list(base)); } catch (e) { if (version === generation.current) setError(e instanceof Error ? e.message : '提问失败,请保留问题后重试'); @@ -353,7 +449,8 @@ export function TeacherChatPanel({ setBusy(true); setError(''); try { - const next = await teacherApi.updateDiscussion(topicBase, current.id, { + const next = await teacherApi.updateDiscussion(topicBase, endpointId, { + ...(continuous ? { projectId } : {}), toolId: current.discussion.id, revision: current.discussion.revision, action, ...(itemId ? { itemId } : {}), }); if (version === generation.current) { consume(next); setFocus(null); } @@ -385,11 +482,21 @@ export function TeacherChatPanel({

{definition?.name ?? '智能体'}

{draftRevision ? '运营草稿试聊' : definition?.description}

- + {!continuous && } {onExpand && } {onClose && } - {(topics.items.length > 0 || draftRevision) &&
+ {continuous &&
+ 本轮项目:{projectName || projectId}{sourceId ? ' · 当前操作对话' : ''} + +
} + {continuous && historyOpen &&
+

升级前的原始记录保留在这里,仅供查看。

+ {topics.items.map(item => )} + {!topics.items.length &&

没有待查看的旧记录。

} +
} + {legacyView && } + {!continuous && (topics.items.length > 0 || draftRevision) &&
{(historyOpen || draftRevision) &&