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 index 36e4daaa..a98e4de0 100644 --- 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 @@ -135,3 +135,13 @@ Main 接受时冻结项目、来源和版本,使用现有项目服务校验 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/30-worklog/tasks/20260928-agent-single-chat-27da516b.md b/.project-docs/30-worklog/tasks/20260928-agent-single-chat-27da516b.md index b8e5d28a..3393be73 100644 --- a/.project-docs/30-worklog/tasks/20260928-agent-single-chat-27da516b.md +++ b/.project-docs/30-worklog/tasks/20260928-agent-single-chat-27da516b.md @@ -12,45 +12,63 @@ ## Scope -- Source-grounded design for one ongoing conversation per user and delivered agent. -- Cover UI, identity, per-turn project/version context, history, persistence, cloud threads and observer integration. -- No product code changes, deployment, paid model calls, subagents, merge or cleanup. +- 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 -- Interpret user + agent as cross-project continuity; keep Pi conversations project-owned and tools scoped to each accepted question. -- Stable delivered config identity, account isolation and student payer remain; no fixed teacher/friend roles. -- Official isolated start succeeded after unowned dirty main correctly refused a claim. Existing foreign documents were untouched. -- Planning Gate passed. All 128 peer task records were inspected. Observer implementation overlaps semantically; consultation-scope peer remains template/unknown. No peer code inspected or changed. -- Design proposes revisions to accepted project-topic/version policy. No canonical promotion or implementation is claimed. +- 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 -- Completed [single-conversation proposal](../../10-decisions/proposals/20260928-agent-single-chat-27da516b__single-conversation.md). -- Confirmed topics currently bind project, version and cloud thread ID; proposed user-agent chat with separately frozen turn contexts and internal execution segments. -- Specified paged history, per-turn events, idempotent migration retaining original files, draft/reference provenance and observer delivery. -- Defined actual failure scenarios for implementation acceptance. -- Product implementation has not started; cross-device synchronization is outside the proposed first increment. +- 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 -- Inspected committed DTO, Main store/service/cloud-runner, Host API, Renderer panel and companion at recorded base. -- Read relevant positioning, teacher ADR, architecture/domain and evidence/reflection/commitment context. -- Yuxi server internals and installed/cloud behavior were not verified in this design-only task. -- No product tests/builds: product code unchanged. Task-aware check_doc_drift passed; working changes contain only this record and the task-owned proposal. +- 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 may approve implementation or adjust the proposed cross-project semantics. -- Before product edits, refresh observer/consultation-scope ownership and inspect actual Yuxi version, compaction and recovery contracts. -- Implement focused regressions and Electron scenarios from the proposal, preserving cancellation and billing semantics. -- Existing data-flow six-batch wording is stale relative to accepted protocol2 ADR; do not propagate it into new contracts. +- 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, README.md. -- Proposal: after implementation acceptance, use one account-agent conversation with per-turn project/version and unified observer messages. -- Evidence: user's screenshot/request and verified source seams listed in proposal. -- Future impact: history lifecycle, config refresh, unread/draft ownership, cloud context and project-bound actions. -- Semantic conflicts: changes accepted topic-level project/version binding; concurrent observer and unknown consultation-scope ownership require coordination before coding. -- Human confirmation: implementation/semantic acceptance required before promotion. Current result is a design proposal only. +- 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/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) &&