diff --git a/docs/features/conversation-supervision/ui-design.md b/docs/features/conversation-supervision/ui-design.md index 8100d795..f66811b7 100644 --- a/docs/features/conversation-supervision/ui-design.md +++ b/docs/features/conversation-supervision/ui-design.md @@ -6,7 +6,7 @@ 应用中心中的智能心跳入口升级为“监督者”,沿用既有启用、常驻与排序能力。未来应用市场使用同一应用身份。应用已启用时,停用单条自动心跳计划仍可手动回顾;关闭应用后,已打开页面显示关闭状态并禁用工作区操作。默认值及运行边界见[应用启停与执行](./logic-design.md#应用启停与执行)。 -当前应用页的顶层导航只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”(英文活动标签为 Activity records)。智能心跳独立管理心跳计划;设置只显示模型超时与并发、回顾算法,不含计划、报告、审计或内部页签,也不显示整页全局徽标或独立刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。故事线图谱面板内另有分类页签,见[故事线图谱](#故事线图谱)。 +当前应用页的顶层导航只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”(英文活动标签为 Activity records)。智能心跳独立管理心跳计划;设置不含计划、报告或审计,也不显示整页全局徽标或独立刷新行。设置内用一排分段页签按用途分为“模型 / 回顾整理 / 故事与经验 / 建议”,每次只显示一个表单:模型含整理超时与并发;回顾整理含读取条数、每批文本量与消息数、单次响应容量;故事与经验含子线索与经验阈值及跨项目关联开关;建议含暂无进展天数。各分类单独保存,只提交本分类字段并沿用其他字段的已存值;切换分类保留未保存的草稿。故事线图谱没有数据时,空状态占满可见高度,标题、说明与“工作回顾 / 刷新”按钮水平、垂直居中,不再在上方单独放刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。故事线图谱面板内另有分类页签,见[故事线图谱](#故事线图谱)。 工作回顾只读取统一监督结果。顶部紧凑排列范围、时间、发起与刷新控件,不重复显示“新回顾”标题和来源说明,不新增 Modal。历史结果选择与图谱入口位于正文之前,即使只有一条结果也显示选择器。新回顾工具栏、状态、历史选择和结果面板铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设阅读宽度上限;结果内部按面板内边距排版,不再单独居中窄正文。正文使用“监督结果”标题,常显生成日期、真实范围和覆盖区间,历史结果不标为最新。工作总结按原有空行分段并保留换行;本次变化和未解决事项直接使用已有字段,不补写结论或截断正文。390px 下新请求字段与操作各占两列,结果元数据可换行。 diff --git a/docs/pr-articles/2026-09-26-unreleased/article.md b/docs/pr-articles/2026-09-26-unreleased/article.md index b83a8150..e6748d0f 100644 --- a/docs/pr-articles/2026-09-26-unreleased/article.md +++ b/docs/pr-articles/2026-09-26-unreleased/article.md @@ -1,20 +1,20 @@ # GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 -> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.7,截至 `b02ce31` 的已提交变化,更新于 2026-10-02;保留 2026-10-01 补入的“下一版故事线”预览及配图。除该未来方向预览外,所述功能与修复均已开发完成并提交。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。 +> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.9,截至 `9527402` 的已提交变化,更新于 2026-10-03。所述功能与修复均已开发完成并提交;原故事线概念图仅作示意,待换为生产界面截图。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。 -GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者按项目回顾进展,并通过图谱保留结论依据;新加入的笔记连接支持保存聊天正文、返回原会话,以及在聊天中读写本机 Obsidian 笔记。本地项目还可以从输入区打开 Continue、OpenCode 原生终端或 DS Web 客户端,沿用当前项目与模型配置。 +GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者把分散的事件归入持续发展的故事,从中整理带有来源和适用条件的经验,供人和助手在后续工作中查阅。笔记连接支持保存聊天正文、返回原会话,以及在聊天中读写本机 Obsidian 笔记。本地项目还可以从输入区打开 Continue、OpenCode 原生终端或 DS Web 客户端,沿用当前项目与模型配置。 -本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。这次还加入了智能心跳的增量回顾与建议、笔记标签,以及按会话、项目和全局分别选择 Runtime 与模型的方式。远程使用建议配套 Desktop 0.15.7 和 Agent 0.15.1,请先升级桌面端,再更新 Host 环境。 +本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。这次还加入了智能心跳的增量回顾与建议、笔记标签、会话图片保存,以及按会话、项目和全局分别选择 Runtime 与模型的方式,并继续改善长会话的浏览与输入。远程使用建议配套 Desktop 0.15.9 和 Agent 0.15.3,请先升级桌面端,再更新 Host 环境。 -> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag 为 `v0.15.7`,指向 `b02ce31`;Agent tag `agent-v0.15.1` 指向 `a662b16`。本轮检查 `86347af..b02ce31`,共 36 个提交。对外发布前核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。 +> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag `v0.15.9` 与 Agent tag `agent-v0.15.3` 均指向 `9527402`。本轮检查 `b02ce31..9527402`,共 45 个提交;图片保存、笔记取消及默认开关调整已包含在桌面 0.15.8 中。对外发布前核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。 -**升级前,请先关闭应用并备份完整用户数据。** 数据库和相邻的 `notes/` 目录需要成套保留。当前数据库格式为 52,包含笔记来源、Runtime 分层选择、心跳建议、笔记标签和统计索引等更新;升级后旧客户端不能直接打开该数据库,回退需要升级前的备份。 +**升级前,请先关闭应用并备份完整用户数据。** 数据库和相邻的 `notes/` 目录需要成套保留;如有 WAL 文件,也应一并备份。当前助理数据库格式为 56,包含共享时间线、故事归属、经验及建议等更新;升级后旧客户端不能直接打开该数据库,回退需要升级前的备份。 ## 新增功能 ### 监督者:从工作记录中整理进展,查看每条结论的依据 -项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,以及最近 1 天、7 天或 30 天的范围。回顾会整理工作总结、本次变化和未解决事项;历史结果保留各自的范围和生成时间,方便回看。 +项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,以及最近 1 天、7 天或 30 天的范围。回顾会整理工作总结、本次变化和未解决事项,并按故事列出这段时间的推进、新出现与结束的事项,以及形成或用上的经验。点击故事或经验,可以进入图谱查看依据;历史结果保留各自的范围和生成时间,方便回看。 监督者默认关闭,在应用中心启用后即可手动回顾,无需先建立自动计划。当前分析来源包括范围内的用户与助手消息、任务标题与当前状态,以及消息中已有的知识引用片段。它不会自动遍历全部笔记、整个知识库或附件原件。 @@ -24,38 +24,52 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者 回顾中的事件、实体与关系可以在“故事线图谱”中查看。选中一个对象,就能检查它的说明、关联和保存的来源正文,再判断模型的归纳是否准确。实体支持确认、改名或撤销,关系可以确认或移除。 -这适合检查“某个结论来自哪段讨论”“一个事项与哪些事件有关”。图谱按事件顺序提供浏览,但还不能逐时刻还原当时的全部实体状态;图上的间距也不表示实际时间间隔。 +这适合检查“某个结论来自哪段讨论”“一个事项与哪些事件有关”。图谱提供平铺和时间螺旋两种浏览方式,时间螺旋按事件时间排列;平铺视图中的节点间距不表示实际时长。历史回顾中的故事分组使用当前保存的故事结构,尚不提供任意时刻的完整状态还原。 > **【待补图 02|必补|图谱与来源】** 使用图 01 同一份真实回顾结果,选中事件或实体,展示对应的来源正文。画面应能辨认对象与依据的关联;不使用设计原型代替生产结果。计划文件:`assets/02-story-sources.png`。 > > 建议图注:从回顾图谱查看关联对象,并打开保存的讨论依据。 -### 下一版故事线:把经历串成故事,让经验有来路 +### 故事与时间螺旋:追踪一件事如何持续发展 -下一版故事线将用一条连续的时间螺旋呈现交错发生的经历。同一圈里可以发生不同的事,同一件事也可以隔一段时间再继续。时间均匀推进,螺旋半径的起伏表达注意力密度,不代表效率或成果。 +一项功能往往经过多次讨论、试验和修订。回顾后,相关事件可以归入同一个故事,按项目、功能和子线索逐层浏览;后续有新进展时继续积累,不必每次从零查找。用户可以调整名称、合并重复故事、移动归错的事件,也能撤销调整;移除故事会保留其中的事件。 -外侧长短不一的“木片”把属于同一件事的事件串起来:一次旅行可能很短,一个产品可能陆续做了几年。沿时间线看,可以了解某个阶段经历了什么;沿一片木片看,可以追踪一件事如何发展,中间的停顿也保留下来。 +时间螺旋把这些故事放到连续时间上,便于查看不同工作如何交错推进。同一件事可以隔一段时间再继续,中间的停顿保留下来。螺旋高度对应时间,半径随消息活动量变化,不代表实际工时、效率或成果价值。 -![下一版故事线:连续时间螺旋、跨阶段故事,以及经验的形成依据与后续应用](assets/09-next-storyline-wisdom.png) +视图支持斜俯视、侧视、俯视和自由视角,可以逐层进入项目、功能与子线索,选中故事或事件查看详情。当前层可显示部分经验及其跨故事应用连线,完整经验列表仍在“经验”页签中查阅。设备无法使用 3D 时,可以切回平铺视图。 -*下一版故事线预览:选中 W 经验,查看它从哪次经历中形成、适用于什么条件,以及后来在哪件事中使用。图中为示例数据。* +![故事线早期概念示意:时间螺旋、跨阶段故事,以及经验的形成依据与后续应用](assets/09-next-storyline-wisdom.png) -W 表达的是从经历中归纳出的经验。例如,产品试验形成了“先验证关键假设,再扩大投入”的认识,后来用于研究项目。它会作为可查看的内容呈现,保留形成依据、适用条件和后续应用;新的实践结果还可以支持、修订或否定这条经验。这个方向希望让故事线既能回答“发生了什么”,也能帮助理解“学到了什么,下一次能怎么用”。 +*早期概念示意,使用示例数据;不是当前生产界面的截图,实际布局与操作以应用为准。* + +> **【待补图 09|必补|故事与经验的生产视图】** 原图保留作概念参考。发布前用目标版本的真实回顾结果替换,展示同一故事的事件、经验形成依据与后续应用,并核对平铺和时间螺旋中的选择联动。计划文件:`assets/09-story-experience.png`。 +> +> 建议图注:沿故事查看工作进展,从经验节点核对形成依据与后续应用记录。 + +### 从故事中整理经验,保留适用条件和依据 + +故事积累到一定规模,并出现新的决定、变化或里程碑后,监督者可以从事件摘要中归纳经验,保存适用条件、边界和形成依据。自动归纳的内容有明确标记,用户可以编辑、合并或删除,再回到原讨论核对。 + +后续回顾还可以记录某条经验在哪些事件中得到应用。应用记录会核对事件是否晚于最早形成事件的起始时间,并与至少一个形成事件涉及共同实体;这些记录仍是模型对历史的归纳,需要结合来源判断,不能据此断定经验已经产生效果。助手可以查阅这些经验,为后续工作提供参考。 ### 助手可以查阅故事图谱,接着已有决定继续工作 -继续一个项目时,可以请助手“查一下此前选了哪个方案,依据是什么”,或“找出这项决定后来的修订”。助手可搜索监督者已经保存的事件、实体、关系和回顾结果,读取相关上下文,再查看来源片段,作为后续讨论的依据。 +继续一个项目时,可以请助手“查一下此前选了哪个方案,依据是什么”,或“找出这项决定后来的修订”。助手可搜索监督者已经保存的故事、经验、事件、实体、关系和回顾结果,读取相关上下文,再查看来源片段,作为后续讨论的依据。故事上下文带出相关事件,经验上下文带出形成和应用记录。 + +工具说明也会引导助手在继续旧工作、追问决定原因或遇到可能已有约定的任务时主动查阅,优先找故事与经验。是否调用仍由当前模型判断;普通闲聊和独立的通用问题不要求查询,也不会在每次对话中自动塞入整张图谱。 在应用中心启用监督者,并在 MCP 设置中保持 Story Graph 开启、分配给所用 Runtime 后即可使用。支持直连模型、DeepSeek Harness,以及本地和远程 OpenCode、Continue;Ask 与 Execute 均可只读查询。本地 Continue、OpenCode 原生终端也可读取。 -查询默认使用当前项目,也可明确指定全局或项目集合;各范围读取各自保存的结果。工具只读取已有记录,不会为一次查询重新运行回顾或修改图谱;助手根据结果生成回答仍使用当前模型。保存的来源片段可以分页读取,但完整历史状态重建和跨范围事实自动复用不在本次功能范围内。 +查询默认使用当前项目,也可明确指定全局或项目集合。事件可从共享时间线中按项目查阅,回顾结果仍保留各自的范围。工具只读取已有记录,不会为一次查询重新运行回顾或修改图谱;助手根据结果生成回答仍使用当前模型。保存的来源片段可以分页读取,尚不支持任意时刻的完整历史状态重建。 ### 智能心跳:按新增工作回顾,并提出可处理的建议 -为项目设置每日或每周计划后,智能心跳会整理回顾窗口内新增、修改或尚待处理的记录,把变化保存到故事图谱。计划默认生成建议,也可以选择“仅更新记忆”;没有待处理变化时,不调用模型。 +为项目设置每日或每周计划后,智能心跳会整理回顾窗口内新增、修改或尚待处理的记录,把变化保存到故事图谱。计划默认生成建议,也可以选择“仅更新记忆”;没有新来源时跳过事实提取,仍可补做尚未完成的故事与经验整理。没有待处理工作时,不调用模型。 建议围绕本次回顾发现的未决事项、分歧、修订和候选约定展开,并保留来源供核对。接受未决事项后,会创建一项暂停任务,方便先检查再开始;候选约定经确认后才进入长期记忆。建议生成失败可以单独重试,已经保存的图谱和回顾进度仍然保留。 +心跳完成回顾后,还可以提示达到设定天数仍没有新进展的故事,或指出某条已有经验可能适用于当前故事。建议可以直接打开图谱;接受暂无进展建议会建立暂停任务,接受经验建议只标记已参考,不会据此写入经验应用记录。暂无进展默认按 14 天筛选;仅时间流逝到期、但本次回顾没有变化时,不会单独触发提醒。 + 计划需要应用保持运行,并按所选全局或项目范围处理。心跳不会在每条聊天消息后实时介入;有候选建议时会额外调用模型整理措辞,回顾本身也可能分批调用模型。 ### 在任务中心跟踪当前会话或任务 @@ -90,6 +104,8 @@ W 表达的是从经历中归纳出的经验。例如,产品试验形成了“ 宽窗口打开笔记时,左侧保留列表,右侧阅读和编辑正文,两栏宽度可以拖动调整。切换笔记时保留当前工作区内的筛选与阅读位置,有未保存内容时会提示。窄窗口改为列表、详情切换;记录索引默认收起,“新建笔记”固定在页头。 +正文使用当前栏的可用宽度,宽窗口下阅读画布和长内容有更多空间。新建文字或画布记录都可以取消,有草稿时先确认是否放弃;取消编辑、切换记录类型或返回聊天后,焦点回到对应的按钮或输入位置。 + 标签也可由支持魔法笔记工具的助手按你的要求添加或修改。快速采集面板继续用于保存聊天内容,标签管理在完整笔记工作区中进行。 ### 在聊天中查找和整理 Obsidian 笔记 @@ -140,6 +156,12 @@ W 表达的是从经历中归纳出的经验。例如,产品试验形成了“ > > 建议图注:从 Mermaid 大图窗口导出完整 PNG,保留图中的边缘节点。 +### 让助手把会话图片保存到指定文件 + +在 Execute 模式下,可以让助手把当前会话中已经上传或生成的图片保存到指定绝对路径,缺少的父目录会自动创建。远程项目保存到对应 Host,方便后续在项目文件中使用;已有同名文件默认保留,明确允许覆盖后才替换。 + +保存支持 PNG、JPEG 和原格式 WebP,可按扩展名转换为 PNG 或 JPEG,不提供转为 WebP。工具使用当前会话已有图片,不负责下载任意图片网址;Ask 模式不提供写入操作。远程保存自 Agent 0.15.2 起提供,本轮建议使用上述最新配套版本。 + ### 桌面通知可以单独关闭 “设置 → 平台功能 → 通用设置”新增“桌面通知”开关,默认开启。关闭后,应用处于后台时的任务完成、失败或取消等系统提醒停止弹出,应用内通知仍然保留。设置保存后立即生效,重启后也会记住选择。 @@ -168,15 +190,15 @@ W 表达的是从经历中归纳出的经验。例如,产品试验形成了“ 任务历史改为按批次展示,可以展开处理步骤,查看进行中、失败与历史记录。筛选与计数跟随当前文档或来源,便于检查一次导入的处理情况。 -### 图谱按事件、实体和关系切换 +### 图谱列表与画布各自浏览 -故事线图谱侧栏将原来上下堆叠的列表改为“事件、实体、关系”页签,各自显示数量并独立滚动。切换页签保留已有选中对象,在画布选中对象时,侧栏会切到对应类别,查看关联信息时少一些来回滚动。 +故事线图谱侧栏可以按故事、经验、事件、实体和关系查看,列表与详情各自滚动。切换页签保留已有选中对象,在画布选中对象时,侧栏会切到对应类别。平铺与时间螺旋都使用中间画布的可用空间,视角等控件收进紧凑操作区,减少窗口较矮时内容被挤出屏幕的问题。 ### 手动回顾与智能心跳共用增量进度 原有的每日、每周计划现在集中到“自动监督”。新建或编辑计划时,可以选择全局或多个项目,设置时间、时区、回顾窗口和历史保留期限;暂停的计划在编辑保存后仍保持暂停。 -手动“回顾”和心跳计划现在共用增量处理方式,默认只分析所选范围内尚待处理的变化,无变化时跳过模型调用。全局与各项目分别记录进度。需要重看完整区间时,可从“更多 → 重新整理”确认发起;重新整理不会推进或重置增量进度。 +手动“回顾”和心跳计划现在共用增量处理方式,默认只分析所选范围内尚待处理的变化。项目与全局共用来源处理进度:同一版本的来源已成功发布后,换一个范围回顾可以复用,减少重复提取。没有新来源时仍可补做积压的故事与经验整理,没有待处理工作才跳过模型调用。需要重看完整区间时,可从“更多 → 重新整理”确认发起;重新整理不会推进或重置增量进度。 启用监督者不会自动创建计划。定时工作需要桌面应用保持运行,关闭监督者会停止发起新的回顾,并保留计划和历史;从导航取消常驻只改变入口显示。尚无活动时,自动监督页会隐藏空的统计、建议和历史区域,先展示计划入口。 @@ -220,13 +242,19 @@ W 表达的是从经历中归纳出的经验。例如,产品试验形成了“ 长回答逐步生成时,已完成的 Markdown 区块可以复用,减少每次新增文字都重新处理整篇内容。切回近期看过的会话时也会复用缓存结果;会话侧栏与运行记录减少无关刷新,降低回复期间继续输入和切换会话的开销。 +较长会话和大量会话的侧栏主要渲染屏幕附近的内容,历史记录仍然保留,可以继续加载和定位。切回缓存会话时恢复阅读位置;Markdown 解析结果也可复用,包含公式的长回复减少重复解析。 + +知识检索、故事图谱查询、会话搜索和会话列表等常用读取移到后台线程,减少查询期间对桌面主线程的占用。远程运行的连续事件按批保存,减少逐条写入的开销。改善幅度取决于设备、数据规模和内容复杂度,不作统一提速比例承诺。 + ### 设置页减少来回查找 长设置表单的分类标题与相关页签固定在顶部,请求 Header、Body 等低频选项收进高级设置。默认工作目录移到“平台功能 → 通用设置”,Skills 和 MCP 页面移除重复标题,保留导入与添加操作。 设置导航按用途分组,开关统一放在右侧。通用设置集中放置会话与通知、工作目录等选项;本地语音和嵌入模型页面提供“模型下载源”快捷入口,可打开统一弹窗,在 ModelScope 与 Hugging Face 之间切换。选择影响后续托管模型下载,已安装模型、ZIP 导入、Ollama 和应用更新不受影响。 -外观设置新增默认关闭的透明磨砂效果。开启后,标题栏和 Runtime 检查清单使用半透明背景,可以看到其后的应用内容;设置会保存,下次启动继续沿用。 +外观设置新增透明磨砂效果,当前默认开启;标题栏和相关卡片使用半透明背景,可以看到其后的应用内容。可以关闭恢复实色显示,已有的外观选择继续保留。 + +内置浏览器 MCP 也改为默认启用,仍可在设置中关闭;之前明确保存的关闭状态会保留,不会修改系统默认浏览器。在 Execute 模式中,请助手通过配置工具应用本次请求的变更计划时,取消了额外一次原生确认;Ask 仍保持只读。 HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等内容,并恢复默认过滤方式。版面与生成参数的分组和间距也重新整理;具体选项效果仍取决于所连接的 OCR 服务。 @@ -258,19 +286,21 @@ HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等 最后一个活动任务结束后,远程空闲 Runtime 会被释放,下一轮对话再恢复历史。仍有任务或待答问题的共享进程会保留;更新后被替换的旧 Agent 会等待已有工作结束再退出,当前 Agent 保持常驻。 -Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮建议配套 Agent 0.15.1,仅升级桌面不会直接替换 Host 上已经运行的旧进程。 +Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮建议配套 Agent 0.15.3,仅升级桌面不会直接替换 Host 上已经运行的旧进程。 ### 启动、搜索与回顾结果 - 修复正常聊天或保存笔记后,重启反复显示历史迁移的问题。普通数据库结构升级也不再因正常空闲页额外整理整库;确需转换的旧数据仍会处理,启动页区分实际操作。 - 修复本机直连模型文件搜索参数受限和大结果截断的问题。模型可使用 ripgrep 原生参数,保留原生结果与退出码,并分页续读长输出。Ask 保持工作区只读,Execute 使用当前账号权限;这项变更不属于远程 Agent 或其他 Runtime 的新增工具。 - 修复早期监督回顾只取部分会话、消息和正文的问题,改为对入选来源分页、分批处理。事实与来源独立保存,只有必要阶段完成并成功保存结果后才标记完成。 -- 修复监督回顾中的旧实体引用、摘要合并校验及长摘要报错。非法格式、未知引用和超出响应容量的结果仍会报错,成功保存的批次可以保留供后续处理。 +- 修复监督回顾中的旧实体引用、摘要合并校验及长摘要报错。遇到无效 JSON、结构或引用时,会尝试缩小当前批次后重试,剩余正文留待后续处理;成功保存的批次保留。网络错误、超时、取消和超出响应容量不走这套重试。 +- 故事或经验整理失败时,已保存的回顾结果继续可用,可以单独重试整理。故事归类减少把各功能的提交与测试统归到“发布”的情况;经验应用记录也增加时间顺序与共同实体核对,归纳内容仍需结合来源判断。 +- 时间螺旋退出时释放图形资源;3D 初始化失败或图形上下文丢失时,在视图内提示并提供重试,也可切回平铺,避免整个监督页面无法使用。 - 修复历史回顾入口跳到其他结果、切换后旧响应覆盖当前选择的问题。侧栏、活动记录和图谱按对应结果定位,人工确认与修订的内容保留。 ### 日常界面操作 -- 多个会话同时回复时,侧栏列表不再随流式文本或工具进度反复换位。会话按最新消息的创建时间排序,新消息仍会把会话向前移动,置顶会话继续优先显示。 +- 多个会话同时回复时,侧栏列表不再随流式文本或工具进度反复换位。会话按最新消息的创建时间排序,每五秒检查一次;悬停、滚动后的短暂停留、菜单展开或键盘焦点位于列表时暂缓重排,方便点选。置顶会话继续优先显示。 - 输入框的附件、语音与选项按钮统一尺寸、间距和悬停反馈,录音中和选项展开时仍保留对应状态提示。 - 草稿和排队消息中的图片可以直接打开预览,发送前更容易核对附件。文本输入框移除重复问候,只保留快捷键提示。 - 展开 Runtime 检查清单不再挤动聊天内容;文件列表和差异页的操作栏在滚动时保持可见。 diff --git a/docs/pr-articles/2026-09-26-unreleased/review.md b/docs/pr-articles/2026-09-26-unreleased/review.md index 867d581c..b6d06253 100644 --- a/docs/pr-articles/2026-09-26-unreleased/review.md +++ b/docs/pr-articles/2026-09-26-unreleased/review.md @@ -1,5 +1,38 @@ # 0.13.8 起综合宣传稿核查记录 +## 2026-10-03 截至 9527402 的增量核查 + +本轮固定范围 `b02ce31..9527402`,共 45 个提交;全文范围为 `v0.13.7..9527402`。桌面 `v0.15.9` 与 Agent `agent-v0.15.3` 同指向 `9527402`。沿用用户确认的标题和综合稿结构,直接更新已授权草稿;未在线核验公开安装包和下载信息。以下记录取代旧节中的当前状态描述,旧节保留为历史依据。 + +| 提交 | 入文内容 | 核查依据与边界 | +| --- | --- | --- | +| `5dc6545`、`c3fa262` | 故事归类、共享时间线与时间螺旋,替换原未来方向节 | `supervision-stories.ts`、`supervision-timeline.ts`、`supervision-review-store.ts`、`story-graph-3d-model.ts`、`StoryGraph3D.tsx`;项目/功能/子线索,命名、合并、移动事件、移除和撤销。共享已成功发布来源版本的增量进度,时间与项目取自来源;螺旋半径为消息量,不是工时或成果。未归类事件不进入当前 3D 树;不宣传手动拆分、任意层级调整或完整时点回放 | +| `983cdd4`、`4d1ce38`、`ee4eb73` | 经验归纳、形成与应用依据、螺旋外侧经验节点 | `supervision-experiences.ts`、`SupervisionStories.tsx`、3D 数据模型;自动归纳可读且有标记,可编辑、合并、删除。应用事件必须晚于最早形成事件的起始时间,并与至少一个形成事件共享实体,仍是回顾归纳,不是实时执行遥测。3D 每层最多展示 6 条满足跨故事应用条件的经验,完整内容在经验页签 | +| `1f420cd` | 工作回顾按故事展示进展与经验,可点击查看 | `supervision-story-digest.ts`、`SupervisionStoryDigest.tsx`、`SupervisorWorkspace.tsx`;按所选结果的范围和时间筛选当前故事结构,不保存当时分类快照,不额外调用模型 | +| `43be465`、`e97263c` | 心跳新增暂无进展与可用经验建议,助手按需搜索故事和经验 | `supervision-suggestions.ts`、`heartbeat-service.ts`、`story-graph-tools.ts`、`story-graph-reader.ts`;停滞默认 14 天,但 no_change 不发建议。接受停滞建议建暂停任务,经验建议仅标记参考。主动检索由模型判断,搜索为分词匹配,不能写成语义检索或每轮自动注入;无答案时模型仍可能多次检索 | +| `0fd2f7e` | 无效模型输出缩小批次后重试,保留成功结果 | `supervisor-service.ts`;无效 JSON/结构/引用的叶子批次最多额外重试两次,减小文本预算,余文后续继续;导航合并同输入再问一次。网络/提供方错误、超时、取消、响应超容量不纳入。故事/经验整理在回顾发布后执行,失败不撤回回顾 | +| `9d8477b`、`a1a1d24`、`50481de` | 图谱占满画布列、独立滚动、紧凑控件、3D 局部恢复 | `SupervisorWorkspace.tsx`、`StoryGraph3D.tsx`、监督样式;退出释放 WebGL,失败不扩大到整个页面,保留平铺选择 | +| `dd29f30` | 保存当前会话上传或生成的图片到本机/Host 文件 | `image-generation-service.ts`、图片契约、`image-tool-mcp.ts`、MCP 设置;Execute、绝对路径、默认不覆盖;PNG/JPEG 转换,WebP 仅原格式保存。远程从 Agent 0.15.2 提供;不是任意 URL 下载,不宣传所有格式无损转换 | +| `f4d8fe7` | 笔记新记录取消、焦点恢复、正文铺满栏宽 | 提交 diff 及已提交 `MagicNotesWorkspace.tsx`、App 与样式;不包含工作区未提交的笔记列表窗口化 | +| `2e5c348`、`b1129ce`、`02a6f5c` | 浏览器与磨砂默认开启,Execute 配置变更减少重复确认 | capability/application settings、`goodbuddy-config-service.ts` 与网关;保留已有关闭选择,不改系统默认浏览器,不透出系统桌面。仅配置计划应用移除重复原生确认,Ask、请求绑定、有效期、单次应用与失败报告仍保留 | +| `3d4e957`、`90ea33e`、`bf6c4a9`、`fdf7d83`、`663aa85`、`b6c7de5`、`775763e`,以及 `43be465`、`ee4eb73` 的聊天改动 | 长会话与侧栏可见区域渲染、Markdown 缓存、草稿/流式/会话更新分离、阅读位置恢复、交互时暂缓排序 | 最终 `ChatHistoryPane.tsx`、`ConversationSidebar.tsx`、会话/草稿/live store 与窗口化组件;单条超长消息仍整行处理,历史未删除。不把缓存与窗口化宣传成全程无卡顿。排序每五秒检查并在交互时暂停 | +| `f571301`、`3c31f0b`、`d859cbf` | 常用数据库读取后台化、活动记录差异处理、远程事件批量保存 | 只读 worker、活动记录差异计划、remote event batcher 与 IPC;向量查询仍可能全表扫描,worker 失败/繁忙可同步回退。Renderer 活动记录仍发送完整快照,不宣称全链路增量 IPC | +| `09c415b`、`855b941` | 过渡性能实现,不叠加宣传 | 新打开会话先显示少量消息再补齐的方案已被最终窗口化替代 | +| `80579ee`、`1e10935`、`dc635d1`、`7aad787`、`169941c`、`82107ad`、`baa6def`、`53b9ecb`、`6ced760`、`0ce9606` | 性能基准、归因更正、架构/测试维护,仅内部依据 | 受控设备与假模型测量不能推为普遍收益;部分测量含同期改动,收尾波动也不作为显著提速。不把本地阈值检查写成性能 CI 已全面接通 | +| `5f1ff24`、`d773f5e`、`5368e0e`、`8280158`、`20317c2`、`9527402` | 小样式、运行环境、测试与发布维护 | 滚动按钮边框不单列卖点;Electron 44.5.1 与剪贴板适配不写成新增功能。Desktop 0.15.8/Agent 0.15.2 与本轮版本分开,助理 schema 52→56,知识库 schema 仍为 13 | + +新增主线按使用过程展开:事件归为故事,故事形成经验,人通过回顾和图谱查看,助手按需查阅,心跳提出待处理建议。原稿“下一版”改为已实现能力,但原 `assets/09-next-storyline-wisdom.png` 仍是早期模拟概念图,已改图注并增加生产替换标注 09,计划文件 `assets/09-story-experience.png`。不将概念稿中的切面、展开、动画等全部写成生产功能。旧图 01、02、04 也需采用最新故事/经验布局;本轮没有新增截图或空图片链接。 + +同步修正旧稿中的按范围独立进度、无新来源必然零模型调用、磨砂默认关闭、即时会话前移及 Agent 0.15.1 配套等描述。升级提示更新为助理数据库 56,备份数据库、WAL 与相邻 `notes/`;旧客户端不能直接打开升级数据库。 + +只读核查发现 `supervision-production.ts` 的 `existingGraph` 回调未转发 `batch`,因此不能承诺关闭跨项目关联后,全局回顾的实体候选已按批次项目隔离;正文未作该承诺,本轮不修改产品代码。故事归属阶段自身的跨项目开关与此不同。 + +工作区另有文档解析 worker、终端输出合批、笔记/文件/知识列表窗口化、启动测量及架构文档改动,均未纳入已提交增量,也未编辑。故事图谱历史检索未找到相关宣传记录,本轮依据当前文档与固定 Git 区间核查。 + +本轮只执行静态核查和文档检查,产品测试、构建、真实模型请求与新截图均为 0。历史性能和 Host 验证仅作参考,不宣称本轮完成跨平台实机或正式包验收;公开下载信息继续待核验。打包、构建及测试维护未写入宣传正文。 + +独立事实复核修正两处:移除旧稿笼统的“跨范围事实自动复用不支持”,区分共享事件时间线与按范围保存的回顾结果;经验应用的时间条件明确为晚于最早形成事件的起始时间,不扩大为晚于全部形成依据。文字扫描无阻断项,人工复核保留版本、素材、升级和能力范围的必要限定;唯一现有图片相对链接目标存在,待补图 09 只登记计划路径。文档差异检查通过,未提交、推送或对外发布。 + ## 2026-10-02 截至 b02ce31 的增量核查 当前依据为 `86347af..b02ce31`,共 36 个提交;全文范围为 `v0.13.7..b02ce31`。桌面 `v0.15.7` 指向 `b02ce31`,Agent `agent-v0.15.1` 指向 `a662b16`。本轮未在线核验公开安装包。沿用用户确认的标题和综合稿结构,继续更新已授权的草稿。 diff --git a/docs/pr-articles/README.md b/docs/pr-articles/README.md index e54af75f..d776e2a5 100644 --- a/docs/pr-articles/README.md +++ b/docs/pr-articles/README.md @@ -386,4 +386,4 @@ Agent 专题使用对应的 `agent-v…` tag。 | 日期 | 目标 tag | 标题 | 状态 | 正文 | 发布链接 | | --- | --- | --- | --- | --- | --- | | 2026-09-19 | 未确定(unreleased) | GoodBuddy 更新预览:画布笔记、文档处理与应用管理 | 草稿,截图待补 | [综合更新稿](./2026-09-19-unreleased/article.md) | 未发布 | -| 2026-09-26(10-02 更新) | v0.15.7(b02ce31);汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补智能心跳、Runtime 分层选择、笔记标签与双栏、设备共享目录及性能改进;保留下一版故事线预览,生产配图与公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 | +| 2026-09-26(10-03 更新) | v0.15.9(9527402);汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补故事归类、时间螺旋、经验与心跳建议、助手按需检索、图片保存和长会话性能;原概念图待换为生产截图,公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 | diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f7a4a4e6..0288cfac 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -325,8 +325,8 @@ const conversationPersistenceIntervalMs = 500; const liveMessageFlushIntervalMs = 250; const keepAliveExpirationMs = 60 * 60 * 1_000; const keepAliveSweepIntervalMs = 5 * 60 * 1_000; -const maximumCachedConversations = 12; -const recentCachedConversations = 5; +const maximumCachedConversations = 6; +const recentCachedConversations = 2; // Chat is the primary route and stays resident outside the capacity below, // together with any route that reports unsaved edits. const pinnedWorkspaceViews = ["chat"] as const; @@ -1842,6 +1842,13 @@ function App(): React.JSX.Element { }, [], ); + // Busy conversations (sidebar activity indicators and queued follow-ups) keep their cached view. + const busyConversationIdsRef = useRef>(new Set()); + const activeConversationViewIds = useCallback((): Set => new Set([ + ...[...activeRuns.current.values()].map((run) => run.conversationId), + ...preparingConversations.current, + ...busyConversationIdsRef.current, + ]), []); const setActiveId = useCallback((update: SetStateAction): void => { const next = typeof update === "function" @@ -1850,23 +1857,17 @@ function App(): React.JSX.Element { activeConversationIdRef.current = next; if (next) { const now = Date.now(); - const runningConversationIds = new Set( - [...activeRuns.current.values()].map((run) => run.conversationId), - ); - preparingConversations.current.forEach((conversationId) => - runningConversationIds.add(conversationId), - ); setCachedConversationViews((current) => touchAndPruneKeepAliveEntries(current, next, now, { expiresAfterMs: keepAliveExpirationMs, maximumEntries: maximumCachedConversations, - protectedKeys: runningConversationIds, + protectedKeys: activeConversationViewIds(), recentEntries: recentCachedConversations, }), ); } setActiveIdState(next); - }, []); + }, [activeConversationViewIds]); const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false); const [ conversationHtmlRenderingEnabled, @@ -2387,7 +2388,7 @@ function App(): React.JSX.Element { expiresAfterMs: keepAliveExpirationMs, maximumEntries: maximumCachedConversations, now, - protectedKeys: runningConversationIds, + protectedKeys: activeConversationViewIds(), recentEntries: recentCachedConversations, }, ), @@ -2407,7 +2408,7 @@ function App(): React.JSX.Element { }; const interval = window.setInterval(sweep, keepAliveSweepIntervalMs); return () => window.clearInterval(interval); - }, [activeId, assistantTasks, getPinnedWorkspaceViews, knowledgeOperationCount, view, conversationStore]); + }, [activeConversationViewIds, activeId, assistantTasks, getPinnedWorkspaceViews, knowledgeOperationCount, view, conversationStore]); useEffect(() => { livePrimarySidebarWidthRef.current = primarySidebarWidth; @@ -3335,6 +3336,9 @@ function App(): React.JSX.Element { ), [projectActivity], ); + useLayoutEffect(() => { + busyConversationIdsRef.current = new Set([...activityByConversationId.keys(), ...queuedConversationIds]); + }, [activityByConversationId, queuedConversationIds]); const pendingSidebarApprovals = usePendingSidebarApprovals(conversationStore); const sidebarArtifacts = useMemo( () => diff --git a/src/renderer/src/HeartbeatCenter.test.tsx b/src/renderer/src/HeartbeatCenter.test.tsx index 02cd46d9..f2cf619b 100644 --- a/src/renderer/src/HeartbeatCenter.test.tsx +++ b/src/renderer/src/HeartbeatCenter.test.tsx @@ -219,7 +219,12 @@ describe('HeartbeatCenter', () => { expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) expect(within(panel).queryByRole('heading', { name: t(key) })).not.toBeInTheDocument() expect(within(panel).queryByRole('tablist', { name: t('supervisor.navigation') })).not.toBeInTheDocument() - if (key !== 'supervisor.graph') { + if (key === 'supervisor.settings') { + const settingsNavigation = within(panel).getByRole('tablist', { name: t('settingsTabs.label') }) + expect(screen.getAllByRole('tablist')).toEqual([navigation, settingsNavigation]) + expect(within(settingsNavigation).getAllByRole('tab').map((tab) => tab.textContent)) + .toEqual(['model', 'review', 'stories', 'suggestions'].map((id) => t(`settingsTabs.${id}`))) + } else if (key !== 'supervisor.graph') { expect(screen.getAllByRole('tablist')).toEqual([navigation]) expect(within(panel).queryByRole('tablist')).not.toBeInTheDocument() } @@ -539,7 +544,14 @@ describe('HeartbeatCenter', () => { expect(screen.queryByRole('heading', { name })).not.toBeInTheDocument() } fireEvent.click(screen.getByRole('tab', { name: '设置' })) + expect(screen.getByRole('heading', { level: 2, name: '模型超时与并发' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('tab', { name: '回顾整理' })) expect(screen.getByRole('heading', { level: 2, name: '回顾算法' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: '模型超时与并发' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('tab', { name: '故事与经验' })) + expect(screen.getByRole('switch', { name: '跨项目关联' })).toBeVisible() + fireEvent.click(screen.getByRole('tab', { name: '建议' })) + expect(screen.getByLabelText(/故事多少天没有新进展/)).toBeVisible() expect(screen.queryByRole('button', { name: '新建计划' })).not.toBeInTheDocument() }) @@ -644,7 +656,17 @@ describe('HeartbeatCenter', () => { expect(screen.queryByRole('region', { name: '心跳计划' })).not.toBeInTheDocument() fireEvent.click(screen.getByRole('tab', { name: '设置' })) expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) - expect(screen.getByRole('heading', { name: '回顾算法' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '模型超时与并发' })).toBeInTheDocument() + }) + + it('keeps an unsaved draft when switching settings tabs', () => { + renderComponent() + fireEvent.click(screen.getByRole('tab', { name: '设置' })) + fireEvent.click(screen.getByRole('tab', { name: '回顾整理' })) + fireEvent.change(screen.getByLabelText(/每批消息数/), { target: { value: '33' } }) + fireEvent.click(screen.getByRole('tab', { name: '建议' })) + fireEvent.click(screen.getByRole('tab', { name: '回顾整理' })) + expect(screen.getByLabelText(/每批消息数/)).toHaveValue(33) }) it('passes manual project and time selections without requiring an automatic plan', async () => { diff --git a/src/renderer/src/HeartbeatCenter.tsx b/src/renderer/src/HeartbeatCenter.tsx index e2f10cd7..e5a14f5f 100644 --- a/src/renderer/src/HeartbeatCenter.tsx +++ b/src/renderer/src/HeartbeatCenter.tsx @@ -19,7 +19,7 @@ import type { } from '../../shared/assistant-contracts' import { HeartbeatSettings } from './HeartbeatSettings' import { defaultSupervisionTimeoutSeconds, supervisionTimeoutSecondsSchema, defaultSupervisorModelConcurrency, supervisorModelConcurrencySchema, type ApplicationSettings, type ApplicationSettingsUpdate } from '../../shared/application-settings-contracts' -import { SupervisionReviewSettings } from './SupervisionReviewSettings' +import { SupervisionReviewSettings, type SupervisionSettingsSection } from './SupervisionReviewSettings' import { SupervisorWorkspace, type SupervisionGraphNavigation } from './SupervisorWorkspace' import { SupervisorActivity } from './SupervisorActivity' import './supervisor-workspace.css' @@ -165,17 +165,37 @@ function UnifiedSupervisorCenter(props: HeartbeatCenterProps): React.JSX.Element setPageTab('activity') window.requestAnimationFrame(() => centerRef.current?.querySelector('#supervisor-tab-activity')?.focus()) }} /> - {pageTab === 'settings' && <> - - - } + {pageTab === 'settings' && } ) } +type SettingsTab = 'model' | SupervisionSettingsSection + +/** Supervisor settings grouped by purpose. Every section stays mounted, so switching tabs keeps unsaved drafts. */ +function SupervisorSettingsTabs(props: HeartbeatCenterProps): React.JSX.Element { + const { t } = useTranslation('heartbeat') + const [tab, setTab] = useState('model') + const reviewProps = { settings: props.applicationSettings, + disabled: props.applicationSettingsPending || props.applicationSettingsLocked, + onSave: props.onUpdateApplicationSettings } + const tabs: { id: SettingsTab; label: string }[] = [ + { id: 'model', label: t('settingsTabs.model') }, + { id: 'review', label: t('settingsTabs.review') }, + { id: 'stories', label: t('settingsTabs.stories') }, + { id: 'suggestions', label: t('settingsTabs.suggestions') } + ] + return
+ + {tabs.map(({ id }) => )} +
+} + function SupervisionModelSettings(props: HeartbeatCenterProps): React.JSX.Element { const { t } = useTranslation('heartbeat') const supervisor = props.applicationSettings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds diff --git a/src/renderer/src/SupervisionReviewSettings.test.tsx b/src/renderer/src/SupervisionReviewSettings.test.tsx index 0eac97c7..c901fc76 100644 --- a/src/renderer/src/SupervisionReviewSettings.test.tsx +++ b/src/renderer/src/SupervisionReviewSettings.test.tsx @@ -28,8 +28,7 @@ it('saves validated algorithm controls and retains the draft until saved setting fireEvent.change(screen.getByLabelText(/每次读取来源条数/), { target: { value: '17' } }) fireEvent.change(screen.getByLabelText(/每批消息数/), { target: { value: '30' } }) fireEvent.change(screen.getByLabelText(/单次响应容量/), { target: { value: '2048' } }) - const cross = screen.getByRole('switch', { name: '跨项目关联' }) - expect(cross).not.toBeChecked() + expect(screen.queryByRole('switch', { name: '跨项目关联' })).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '保存回顾算法' })) expect(save).toHaveBeenCalledWith({ supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 2048, crossProject: false, storyThreadEvents: 20, experienceMinEvents: 5, stalledDays: 14 } }) expect(screen.getByLabelText(/每次读取来源条数/)).toHaveValue(17) @@ -38,9 +37,23 @@ it('saves validated algorithm controls and retains the draft until saved setting it('enables cross-project linking only when the switch is turned on', () => { const save = vi.fn() const settings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false }) - render() - expect(screen.getByRole('button', { name: '保存回顾算法' })).toBeDisabled() + render() + expect(screen.getByRole('button', { name: '保存故事与经验设置' })).toBeDisabled() fireEvent.click(screen.getByRole('switch', { name: '跨项目关联' })) - fireEvent.click(screen.getByRole('button', { name: '保存回顾算法' })) + fireEvent.click(screen.getByRole('button', { name: '保存故事与经验设置' })) expect(save).toHaveBeenCalledWith({ supervisionReview: expect.objectContaining({ crossProject: true }) }) }) + +it('saves one section over stored values without carrying another section\'s draft', () => { + const save = vi.fn() + const settings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false }) + render(<> + + + ) + expect(screen.queryByLabelText(/故事多少天没有新进展/)).toBeInTheDocument() + fireEvent.change(screen.getByLabelText(/每批消息数/), { target: { value: '30' } }) + fireEvent.change(screen.getByLabelText(/故事多少天没有新进展/), { target: { value: '7' } }) + fireEvent.click(screen.getByRole('button', { name: '保存建议设置' })) + expect(save).toHaveBeenCalledWith({ supervisionReview: expect.objectContaining({ stalledDays: 7, batchMessages: 20 }) }) +}) diff --git a/src/renderer/src/SupervisionReviewSettings.tsx b/src/renderer/src/SupervisionReviewSettings.tsx index 91cb6841..a965487f 100644 --- a/src/renderer/src/SupervisionReviewSettings.tsx +++ b/src/renderer/src/SupervisionReviewSettings.tsx @@ -2,47 +2,71 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import type { ApplicationSettings, ApplicationSettingsUpdate } from '../../shared/application-settings-contracts' import { defaultExperienceMinEvents, defaultStalledDays, defaultStoryThreadEvents, defaultSupervisionReviewSettings, supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts' +import { useWorkspaceUnsavedChanges } from './workspace-unsaved-changes' -const fields = [ - ['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['responseKiB', 100, 16384], ['storyThreadEvents', 4, 500], ['experienceMinEvents', 2, 200], ['stalledDays', 1, 365] -] as const +export type SupervisionSettingsSection = 'review' | 'stories' | 'suggestions' -export function SupervisionReviewSettings({ settings, disabled, onSave }: { +type NumericKey = 'pageSize' | 'batchCharacters' | 'batchMessages' | 'responseKiB' | 'storyThreadEvents' | 'experienceMinEvents' | 'stalledDays' + +const sections: Record = { + review: { fields: [['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['responseKiB', 100, 16384]] }, + stories: { fields: [['storyThreadEvents', 4, 500], ['experienceMinEvents', 2, 200]], crossProject: true }, + suggestions: { fields: [['stalledDays', 1, 365]] } +} + +const titles = { review: 'reviewSettings.title', stories: 'reviewSettings.storiesTitle', suggestions: 'reviewSettings.suggestionsTitle' } as const +const saves = { review: 'reviewSettings.save', stories: 'reviewSettings.saveStories', suggestions: 'reviewSettings.saveSuggestions' } as const + +/** + * One settings section of the supervisor's review configuration. Each section edits only its own + * fields and saves them over the current stored values, so another section's unsaved draft is not + * overwritten or reset when this one saves. + */ +export function SupervisionReviewSettings({ settings, disabled, onSave, section = 'review' }: { settings?: ApplicationSettings disabled?: boolean onSave?: (value: ApplicationSettingsUpdate) => void | Promise + section?: SupervisionSettingsSection }) { const { t } = useTranslation('heartbeat') + const { fields, crossProject } = sections[section] const current = { ...defaultSupervisionReviewSettings, ...settings?.supervisionReview, responseKiB: settings?.supervisionReview?.responseKiB ?? 1024, crossProject: settings?.supervisionReview?.crossProject ?? false, storyThreadEvents: settings?.supervisionReview?.storyThreadEvents ?? defaultStoryThreadEvents, experienceMinEvents: settings?.supervisionReview?.experienceMinEvents ?? defaultExperienceMinEvents, stalledDays: settings?.supervisionReview?.stalledDays ?? defaultStalledDays } + const same = (left: typeof current, right: typeof current) => + fields.every(([key]) => left[key] === right[key]) && (!crossProject || left.crossProject === right.crossProject) const [saved, setSaved] = useState(current) const [draft, setDraft] = useState(current) - if (fields.some(([key]) => saved[key] !== current[key]) || saved.crossProject !== current.crossProject) { setSaved(current); setDraft(current) } - const unchanged = fields.every(([key]) => draft[key] === current[key]) && draft.crossProject === current.crossProject - const valid = supervisionReviewSettingsSchema.safeParse(draft).success + if (!same(saved, current)) { setSaved(current); setDraft(current) } + const unchanged = same(draft, current) + useWorkspaceUnsavedChanges(!unchanged) + const next = { ...current, ...Object.fromEntries(fields.map(([key]) => [key, draft[key]])), ...(crossProject ? { crossProject: draft.crossProject } : {}) } + const valid = supervisionReviewSettingsSchema.safeParse(next).success const locked = disabled || !settings || !onSave return
{ event.preventDefault() - if (valid && !locked) void onSave?.({ supervisionReview: draft }) + if (valid && !locked) void onSave?.({ supervisionReview: next }) }}> -

{t('reviewSettings.title')}

-

{t('reviewSettings.help')}

+

{t(titles[section])}

+ {section === 'review' &&

{t('reviewSettings.help')}

} + {section === 'suggestions' &&

{t('reviewSettings.suggestionsHelp')}

} {fields.map(([key, min, max]) => )} - -

{t('reviewSettings.crossProjectHelp')}

-

{t('reviewSettings.pauseHelp')}

+ {crossProject && <> + +

{t('reviewSettings.crossProjectHelp')}

+ } + {section === 'review' &&

{t('reviewSettings.pauseHelp')}

} {!valid &&

{t('reviewSettings.invalid')}

} - +
} diff --git a/src/renderer/src/SupervisorWorkspace.tsx b/src/renderer/src/SupervisorWorkspace.tsx index 304c90c0..ac19cdd6 100644 --- a/src/renderer/src/SupervisorWorkspace.tsx +++ b/src/renderer/src/SupervisorWorkspace.tsx @@ -498,6 +498,9 @@ export function SupervisorWorkspace({ // The graph column does not scroll: the graph scales to the height left in the window. Below 660px the flat // graph's labels would drop under 11px, so very short windows keep that height and scroll the page instead. useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0, 24, GRAPH_MIN_HEIGHT) + const graphEmptyRef = useRef(null) + // Without a graph, the empty state takes the visible height so its message sits in the middle of the page. + useFillHeight(graphEmptyRef, tab === 'graph' && !loading && !loadError && graph.events.length === 0, 24, 320) // The work review reads the selected result's own scope, so it matches the period shown. const recapStories = useSupervisionStories(latest?.scope, tab === 'overview', latest?.id) const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined @@ -700,31 +703,32 @@ export function SupervisorWorkspace({ )} {tab === 'graph' && ( <> - {/* With a graph, scope and refresh live in the canvas heading; without one, keep refresh reachable here. */} - {!graph.events.length &&
- - -
} + {/* With a graph, scope and refresh live in the canvas heading; without one, they sit in the centred empty state. */} {!loading && !loadError && !graph.events.length && ( - } - title={t('supervisor.graphEmpty')} - description={t('supervisor.emptyHint')} - action={ - - } - /> +
+ } + title={t('supervisor.graphEmpty')} + description={t('supervisor.emptyHint')} + action={ +
+ + +
+ } + /> +
)} {graph.events.length > 0 && (
diff --git a/src/renderer/src/i18n/locales/en-US/heartbeat.ts b/src/renderer/src/i18n/locales/en-US/heartbeat.ts index 401a39e4..0fd9c7f6 100644 --- a/src/renderer/src/i18n/locales/en-US/heartbeat.ts +++ b/src/renderer/src/i18n/locales/en-US/heartbeat.ts @@ -9,6 +9,9 @@ export const heartbeat = { experienceMinEvents: 'Minimum events before a story is considered for experiences (default 5)', stalledDays: 'Days without new events before a story is flagged (default 14)', save: 'Save review algorithm', + storiesTitle: 'Stories and experiences', saveStories: 'Save story and experience settings', + suggestionsTitle: 'Supervisor suggestions', saveSuggestions: 'Save suggestion settings', + suggestionsHelp: 'Used when a heartbeat plan is set to suggest. Suggestions only remind you; they never change a story\'s status.', help: 'Page size controls database reads, not total coverage. Batch limits split work without dropping remaining text. These settings and the organize timeout are frozen for each new review; continuing uses its saved configuration. Response capacity uses the current setting for each request; increase it before continuing a failed review.', pageSize: 'Sources read at a time (default 50)', batchCharacters: 'Text size per batch (default 8000)', batchMessages: 'Messages per batch (default 20)', executionSeconds: 'Time before pausing, seconds (default 300)', @@ -31,6 +34,7 @@ export const heartbeat = { runError: 'This run failed. Review the error details before continuing; saved batches remain available.', diagnostics: 'Error details' }, + settingsTabs: { label: 'Supervisor settings sections', model: 'Model', review: 'Review processing', stories: 'Stories and experiences', suggestions: 'Suggestions' }, suggestions: { title: 'Supervisor suggestions', kind: { open_item: 'Open item', conflict: 'Disagreement', convention: 'Possible convention', revision: 'Important revision', stalled: 'No recent progress', experience: 'Relevant experience' }, diff --git a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts index 6dcfc4fe..c05f4c11 100644 --- a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts +++ b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts @@ -6,6 +6,9 @@ export const heartbeat = { experienceMinEvents: '故事参与经验整理的最少事件数(默认 5)', stalledDays: '故事多少天没有新进展时提醒(默认 14)', save: '保存回顾算法', + storiesTitle: '故事与经验', saveStories: '保存故事与经验设置', + suggestionsTitle: '监督建议', saveSuggestions: '保存建议设置', + suggestionsHelp: '心跳计划选择“生成建议”时使用。只提醒,不会改变故事的状态。', help: '每页条数只控制数据库读取量,不限制回顾总量。分批上限用于拆分工作,剩余正文会继续处理。这些设置及整理超时在新回顾开始时固定;继续回顾沿用已保存的配置。响应容量按每次请求的当前设置生效,调高后可继续失败的回顾。', pageSize: '每次读取来源条数(默认 50)', batchCharacters: '每批文本量(默认 8000)', batchMessages: '每批消息数(默认 20)', executionSeconds: '单次整理时长,秒(默认 300)', @@ -28,6 +31,7 @@ export const heartbeat = { runError: '本次运行失败,请查看错误详情后继续。已保存的批次仍保留。', diagnostics: '错误详情' }, + settingsTabs: { label: '监督者设置分类', model: '模型', review: '回顾整理', stories: '故事与经验', suggestions: '建议' }, suggestions: { title: '监督建议', kind: { open_item: '未决事项', conflict: '分歧', convention: '候选约定', revision: '重要修订', stalled: '暂无进展', experience: '可用经验' }, diff --git a/src/renderer/src/supervisor-workspace.css b/src/renderer/src/supervisor-workspace.css index 2951b534..9ac305d1 100644 --- a/src/renderer/src/supervisor-workspace.css +++ b/src/renderer/src/supervisor-workspace.css @@ -14,6 +14,20 @@ box-sizing: border-box; align-self: stretch; } +.supervisor-workspace__graph-empty { + display: grid; + min-height: var(--fill-height, 320px); + box-sizing: border-box; +} +.supervisor-workspace__graph-empty > .empty-state { + min-height: 0; +} +.supervisor-workspace__empty-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-3); +} .supervisor-workspace__run-status { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; diff --git a/tests/support/supervisor-layout-driver.mjs b/tests/support/supervisor-layout-driver.mjs index 267f1b4b..6b48b2e6 100644 --- a/tests/support/supervisor-layout-driver.mjs +++ b/tests/support/supervisor-layout-driver.mjs @@ -279,6 +279,35 @@ app } } } + if (scenario === 'empty') { + // Without a graph, the empty state fills the visible height and its message sits in the middle. + for (const [width, height] of [[1440, 900], [390, 800]]) { + win.setContentSize(width, height) + await js(`document.querySelector('#supervisor-tab-graph').click(); document.querySelector('.page-shell').scrollTop = 0`) + await wait('!!document.querySelector(".supervisor-workspace__graph-empty .empty-state strong")') + await settle() + const empty = await js(`(() => { + const box = document.querySelector('.supervisor-workspace__graph-empty'); + const shell = document.querySelector('.page-shell'); + const title = box.querySelector('.empty-state strong').getBoundingClientRect(); + const r = box.getBoundingClientRect(); const s = shell.getBoundingClientRect(); + return { boxCenterX: r.left + r.width / 2, titleCenterX: title.left + title.width / 2, + boxTop: r.top, boxBottom: r.bottom, titleTop: title.top, shellBottom: s.bottom, + pageScroll: shell.scrollHeight > shell.clientHeight + 1, + buttons: [...box.querySelectorAll('button')].map(b => b.textContent), + outsideRefresh: [...document.querySelectorAll('#supervisor-panel-graph button')].filter(b => !box.contains(b) && b.textContent.includes('刷新')).length }; + })()`) + assert(Math.abs(empty.boxCenterX - empty.titleCenterX) <= 2, 'Graph empty state is horizontally centred') + const middle = (empty.boxTop + empty.boxBottom) / 2 + assert(empty.titleTop > empty.boxTop + 60 && Math.abs(empty.titleTop - middle) < (empty.boxBottom - empty.boxTop) / 4, 'Graph empty state is vertically centred') + assert(empty.shellBottom - empty.boxBottom <= 48, 'Graph empty state reaches the bottom margin') + assert.equal(empty.pageScroll, false, 'Graph empty state does not scroll the page') + assert.deepEqual(empty.buttons, ['工作回顾', '刷新']) + assert.equal(empty.outsideRefresh, 0) + reports.push({ scenario: 'graph-empty', width, height, ...empty }) + await writeFile(join(artifacts, `graph-empty-${width}x${height}.png`), (await win.webContents.capturePage()).toPNG()) + } + } if (scenario === 'populated') { await js('document.querySelector("#supervisor-tab-overview").click()') // Toolbar: incremental review button, refresh, and the more menu holding re-analysis. @@ -949,26 +978,35 @@ app join(artifacts, 'settings.png'), (await win.webContents.capturePage()).toPNG() ) - assert.equal(await js('document.querySelectorAll("[role=tablist]").length'), 1, 'No nested settings menu') - for (const width of [1440, 1024, 390]) { - win.setContentSize(width, 1100) - await js('document.querySelector(".page-shell").scrollTop = 0') - await settle() - const sizes = await js(`(() => { - const shell = document.querySelector('.page-shell'); - return { viewport: innerWidth, page: document.documentElement.scrollWidth, - clientWidth: shell.clientWidth, scrollWidth: shell.scrollWidth, headings: document.querySelectorAll('h1').length }; - })()`) - assert( - sizes.page <= width && sizes.scrollWidth <= sizes.clientWidth, - 'Settings overflow' - ) - assert.equal(sizes.headings, 1) - reports.push({ settings: sizes }) - await writeFile( - join(artifacts, `settings-plans-${width}.png`), - (await win.webContents.capturePage()).toPNG() - ) + // Settings are grouped into one row of section tabs, with one visible form at a time. + assert.deepEqual(await js('[...document.querySelectorAll("#supervisor-panel-settings [role=tablist] [role=tab]")].map(t => t.textContent)'), + ['模型', '回顾整理', '故事与经验', '建议']) + assert.equal(await js('document.querySelectorAll("[role=tablist]").length'), 2, 'Page tabs plus one settings section row') + for (const section of ['model', 'review', 'stories', 'suggestions']) { + await js(`document.querySelector('#supervisor-settings-tab-${section}').click()`) + for (const width of [1440, 1024, 390]) { + win.setContentSize(width, 1100) + await js('document.querySelector(".page-shell").scrollTop = 0') + await settle() + const sizes = await js(`(() => { + const shell = document.querySelector('.page-shell'); + const forms = [...document.querySelectorAll('#supervisor-panel-settings form')].filter(f => f.getClientRects().length); + return { viewport: innerWidth, page: document.documentElement.scrollWidth, + clientWidth: shell.clientWidth, scrollWidth: shell.scrollWidth, headings: document.querySelectorAll('h1').length, + visibleForms: forms.length, title: forms[0]?.querySelector('h2')?.textContent }; + })()`) + assert( + sizes.page <= width && sizes.scrollWidth <= sizes.clientWidth, + `Settings overflow: ${section}/${width}` + ) + assert.equal(sizes.headings, 1) + assert.equal(sizes.visibleForms, 1, `One settings form per section: ${section}`) + reports.push({ settings: section, ...sizes }) + await writeFile( + join(artifacts, `settings-${section}-${width}.png`), + (await win.webContents.capturePage()).toPNG() + ) + } } for (const query of [ 'story=1', @@ -1038,7 +1076,8 @@ app ) await js('document.querySelector("#supervisor-tab-settings").click()') await wait('!!document.querySelector(".heartbeat-settings")') - assert(await js('!document.querySelector("#supervisor-panel-settings [role=tablist], #supervisor-panel-settings .scope-badge")')) + assert(await js('!document.querySelector("#supervisor-panel-settings .scope-badge")')) + assert.equal(await js('document.querySelectorAll("#supervisor-panel-settings [role=tablist]").length'), 1, 'Only the settings section tabs') assert(await js('!document.querySelector("#heartbeat-panel-plans")'), 'Plans must not live in settings') await js('document.querySelector("#supervisor-tab-plans").click()') await js('document.querySelector(".heartbeat-settings__intro button").focus(); document.querySelector(".heartbeat-settings__intro button").click()') @@ -1138,7 +1177,7 @@ app refresh: [...panel.querySelectorAll('button')].some(e => e.textContent.includes('刷新')) }; })()`) assert.deepEqual(menuLayout.tabs, ['工作回顾', '故事线图谱', '智能心跳', '活动记录', '设置']) - assert.equal(menuLayout.nestedMenus, tab === 'graph' ? 1 : 0) + assert.equal(menuLayout.nestedMenus, tab === 'graph' || tab === 'settings' ? 1 : 0) assert(menuLayout.pageWidth <= width && menuLayout.scrollWidth <= menuLayout.panelWidth, 'Menu panel overflow') assert.equal(menuLayout.reports, tab === 'plans') if (tab === 'plans') assert(Math.abs(menuLayout.reportWidth - menuLayout.panelWidth) <= 1, 'Reports must use full reading width')