RWork 需求与功能状态
本文回答两个问题:已经要求并实现的是什么(含验收方式),以及还没做的、以及为什么。
功能与界面示意图见 FEATURES.md;架构与取舍见 README.md;开发约定见 AGENTS.md。
状态用三种标记:
- [已完成] 已实现并有测试或实测支撑。
- [部分] 主路径可用,但有明确的边界或未验证的部分(会写清是哪一处)。
- [未做] 尚未实现,附理由或前置条件。
一、需求来源
按用户提出的批次整理。每一批的原始诉求保留,因为「当时要解决的问题」决定了取舍。
| 批次 | 主题 | 诉求要点 |
|---|---|---|
| 1 | 界面与保存缺陷 | 模型提供商默认模型无法刷新、看不到模型名字与来源、定价区无滚动条导致无法保存、新建智能体无法保存、工作空间设置太远、对话框不够细腻 |
| 2 | 工具权限重设计 | 智能化审批(自有内容可改、尽量不删、文档类新增版本、代码类按关系图扫描 + 确认)、数据权限按系统与用户权限动态决定、可用小模型从数据库在线学习 |
| 3 | 复制功能 | 发送的消息、助手的思考与回复都要能复制 |
| 4 | MCP 与 Skill | 设置里缺 MCP 与 Skill 入口;skill 走导入 .zip/.skill 或目录扫描并注入;侧栏名字重复;路径要能选择文件夹而不是手输 |
| 5 | Office 与 OCR | Word/Excel/PPT 等都要实现;MCP 要实现;PDF 图片识别调 OCR 模型;模型文件放到和数据库一样的地方 |
| 6 | 推翻第 5 批 | 不内置解析方法,按模型意图自行完善命令、自动安装 node/Python 环境、优先国内镜像 |
| 7 | 核心行为 | 定时任务(项目/全局维度)、长期记忆(关键词触发 + 手动整合 + 作用域)、工作台布局(重复、拖动、图标、去掉绝对路径)、多问题输入的优先级与依赖关系综合评定 |
| 8 | 输入区设计 | 输入窗口没填满或占位过多 |
| 9 | 复制到文档 | 助手回复要有两个复制按钮:markdown 原文,以及能粘进 Word 的带样式版本 |
| 10 | 定时任务纠正 | 应该像 cron 那样设;要落库;启动与设置时检查漏跑并让用户决定补不补;要有执行历史,查最近两次;与窗口能力一致 |
| 11 | 多智能体 | 智能体有设置没应用的地方;新建对话时可选、可多选;多智能体需要策略 |
| 12 | 内置 subagent | 基于 omp/pi/opencode 等编程工具,以及文件分析、数据分析类,做一批内置 subagent |
| 13 | 对话可用性缺陷 | 工具调用的权限浮层被对话内容挡住、不在最上层;工具调用之后程序像停住了;大模型最终回复的内容复制不了;产出物应当以「文件图标 + 文件名」的卡片展示,点一下用系统默认程序打开 |
| 14 | 卡住与自愈 | 有没有大模型交互的日志(调试时看不到任何记录);程序卡住了(要查清卡在哪);命令执行出错时不能就此停下,要分析问题、修改、重新执行 |
| 16 | 工具块的形态与实时性 | 工具块要分上下两块(输入 / 输出)、外面一个大框圈起来;默认折叠,只有执行时才像思考块那样展开;文字按默认字号渲染;会话要能删除(右键菜单);运行中要能实时看到命令行返回值 |
| 24 | 右侧三入口的面板、统计口径与拆解触发 | 右上角那三个按钮不符合 UX:最上面这个(我的输入)「样式对了,但位置不对,应该在最右侧」;中间这个(统计)「结果样式不对,而且 token 速率有问题吧,那么快哪可能才 6.2」;最下面这个(执行计划)「本来任务是有执行计划的,但是卡片说没有执行计划,而且样式不对」;三者共有的问题是都没有关闭按钮,得加上。另附一张截图:被标红的那几处正文出现字体堆叠 |
| 23 | 任务持久化与链路 | 用户点名的「整个任务队列链路过程展示」:任务状态落库,界面只订阅与渲染;进程被杀之后仍能知道「上次跑到哪」 |
| 22 | 会话压缩等七件 | 会话内容过多怎么压缩;缺「意图倾向/人工介入」的选项;记忆系统要重新设计(用户说「我得想想」);输入框里的文件/附件/图片如何呈现,以及内容过大时的「内容意图分离 + 多意图识别」;subagent 的设计;任务队列链路展示;切换窗口后任务异常停止,要求后台执行、UI 与任务分离 |
| 21 | 交付物契约与派活 | 问「交付物契约的语义是什么」;另两项(记忆/设置/MCP 是否回合内重读、试错交给 subagent)按我建议的做 |
| 20 | 五条界面修正 | 轮次设置位置不对(应独立成块、放在模型配置下面,叫「会话交互轮次」);工具调用时输入必须先出来,不要等跑完才显示输入和输出;已交付的文件要用文件卡片显示;长对话里找某次输入要滚很久,要一个固定位置的输入概要面板,点概要跳到那一条;以及「信息输入面板能否应对十万字的长对话历史」 |
| 26 | 交接待办里的四件零碎(本轮) | 用户说「按照待处理内容继续工作」,于是按 HANDOFF.md 第三节的表接着做:执行计划的两个上限进设置(此前写死在 Policy::default)、孤儿向量的界面(原本只能靠代码顺手清)、核心行为页按作用域列历次拆解、拆解的触发认更多分项写法 |
| 19 | 轮次能不能调、跳跃按钮、动态加载 | 轮次限制「能不能有什么说法?能否设置成设置内的选项?」;右上角那个跳转不是想要的,想要的是像 DeepSeek 那样右下角的「回到最新」;以及「在与大模型沟通的过程中动态安装了环境、mcp、skill 的时候,RWork 如何能够修改这块内容并动态加载」,要不要重启 |
| 18 | 任务没做完就结束了 | 用「请为我编制一个 word 文档,里面是一个简单的 Java+react 的架构设计,有图片的那种」实测,生成还没完成,回合就结束了;问「harness agent(opencode / pi / omp)是怎么保证交付物真的产出的」,并要把 06:19 那次查清楚到底卡在哪 |
| 17 | 对话区的排版与收尾 | 流式输出时正文的中文句子被拆到别处、间距异常;工具块折叠后应当只占一行;任务做完要明确告诉用户「完成了没有、交付物/文档是什么」,并且用户原话(真实意图)要和 agent 的理解一起交给模型(例如交付前先把文件里的占位符换成真内容);对话区右侧的空白加两个入口(回到我发的那条消息、看本轮统计);删除不正常工作,要查清并修 |
| 15 | 块化展示 | 对话的每个单元都是一块(思考/工具/正文/产出物),各块默认表现不同:文件是「图标 + 文件名」的小卡片;思考可展开、收起时显示最新数据的滚动输出;工具的输入与输出在同一个块里;工具块与思考块同宽、正文块最宽且可复制;执行中要有明确的转圈图标;要有流式介入命令行返回值的能力(「命令运行你总有对应的日志」) |
二、已实现(按领域)
2.1 对话与消息
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| 流式输出 | 已完成 | 逐字上屏;tests/end_to_end.rs 断言分片文本与入库文本一致 |
| 停止生成 | 已完成 | 已生成内容保留并入库 |
| 思考通道单列 | 已完成 | 带工具的回合也曾整段丢弃推理,已修(提出 render_thinking 并在提前返回前渲染) |
| 复制(四类文本) | 已完成 | 用户正文 / 助手正文 / 思考块 / 工具调用与结果各一个按钮;思考收起时也能复制全文 |
| 复制为富文本 | 已完成 | 转 HTML 走 CF_HTML;同时写纯文本回退(否则记事本/终端粘出空白)。对着真实系统剪贴板测过:写完按格式名读回,偏移量逐字节核对 |
选中文字后 Ctrl+C 一定能复制 |
已完成(第 13 批) | 复制是绑在焦点上的动作,选区却是窗口级的事实:焦点停在按钮/别处时,选中正文再按 Ctrl+C 不会走任何处理(实测剪贴板纹丝不动)。修法是在外壳(无上下文,优先级最低的一档)兜一次底,读窗口当前选区。实测:焦点在复制按钮上、在输入框里两种情况都复制成功,而输入框自身的 Ctrl+C 仍然归它自己 |
| 复制按钮始终在最右 | 已完成(第 13 批) | 助手行的元信息(模型/Token/耗时/时间戳)此前与按钮同排,窄窗口下会把按钮挤出可见范围;现在元信息单独成组、可收缩,按钮固定在最右 |
| 上下文裁剪 | 已完成 | 超窗旧消息按条裁剪,并在对话里说明省略条数 |
| 多问题优先级与依赖 | 已完成 | 输入像多件事时先让模型给依赖图、由我们算顺序。联网测试用倒序输入验证过 3 种表述 |
| 执行计划(带条件的 DAG) | 已完成 | 拆解时要求每一步给出可判定的完成条件;按依赖顺序执行,每一步做完按条件审计,上游失效就回到最早那一步(受牵连的子孙一起重做),有重试/回滚上限,有副作用的步骤重跑前先问用户。端到端实测:mock 场景里第 2 步把第 1 步的产出清空 → 日志 退回最早失效的那一步 target=draft invalidated=["draft","chart"] reason=文件 report.md 是空的,随后两步依次通过、全部步骤通过条件核对 |
| 输入区布局 | 已完成 | 高度从 76px → 32px,去掉与上方重复的 placeholder,改显示当前模型 |
2.2 工具与权限
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| 派活可以指定智能体 | 已完成(第 22 批) | delegate 此前只能「交给同一个智能体做」,而用户要的是按活找人(评审的活该给评审位)。现在多一个可选参数 agent:填预设的名字或 id 都认(模型记得住名字,配置里存的是 id);不填就是当前这位。填了就用它自己的系统提示词与工具范围;预设自带模型就用它的,没带就跟随全局——与对话里选智能体的规则一致。找不到时报错并列出可选项,不猜:猜错会把活派给一个不该碰文件的角色。实测:父代理派给「代码评审」,子代理请求里的 system 从 505 字变成 941 字,带着「你是独立的评审者…你本身不改代码」那段 |
| 输入内容要有「组装说明书」 | 已完成(第 22 批,提示词这一半) | 用户的原话是「图片也好附件也好,本质上是为了提供给当时的上下文来处理的,所以必须让大模型理解明白信息组装情况」。此前:文本附件有各自的段落,而图片一个字都不提(它按图发送,正文里没有文字),被摘要过的附件也不说——模型会把「没给它」当成「不存在」,拿着摘要当全文回答。现在每条消息前加一份清单:(本轮消息的组成:1 段用户文字 + 2 个文本附件 + 1 张图片),逐条写明名称、大小、是否只有摘要、文档里还有几张内嵌图片没发。3 条单测(无附件不加、图片与截断都要点名、内嵌图要计数)+ 1 条组装测试。界面侧(附件在输入区显示成可点开的卡片)还没做 |
| 附件是正文里的「一块」,清单在输入框上方 | 已完成(第 22 批,第二次澄清后重做) | 用户的原话:「是和文本放在一起的,比如 xxxx[图片1]xxxx[图片2] 这样的内容,可以在删除的时候认作是一块(有点像表情的处理机制),文件在对话框上面列表上展示」。所以最终形态是两件事:正文里插单码位占位符(① ② ③…,插在光标处),文件清单排在输入框上方(① paste-….bmp · 18.3 KB,点一下移除)。为什么用单码位而不是 [图片1]:输入框的删除按字符走,而退格键拦不到(动作会拦截传播,与 Ctrl+V 同一个机制,实测过)——只有单码位才能天然做到「删一次是一块」,多码位记号会被一个字一个字地啃掉。正文是唯一依据:发送时按正文里还留着的占位符决定发哪几个附件(一个都没留时保留全部,免得静默不发)。5 条单测(占位符单码位、按正文顺序识别、删掉即不发、无占位符不过滤、编号越界忽略)。实测:粘贴一张图后正文变成 ①②③(库里存的就是这三个字符)、消息带 3 个附件、清单显示编号 + 文件名 + 大小 |
| 附件卡片与输入同排 | 已完成(第 22 批,按用户澄清重做) | 用户澄清的原话:「我说的是在输入框里面形成一个类似于图标 + 文件名的形式,而不是在输入框上面」。原先卡片虽然落在同一个圆角框里,但排在文字行上方,而输入框那一行的兄弟关系是垂直的——所以看起来就是「框里一叠东西」。现在卡片与 Textarea 包进同一个 h_flex:卡片在左、输入在右,同一水平线;没有附件时这段自然为空。顺带把状态类内容(被打断的提示、附件提示)移到输入框外面:它们是状态,不是这条消息的内容,塞在框里会让「输入框」看起来不像输入框。实测(隔离实例):粘贴一张图后,卡片出现在「输入消息…」占位文字左侧同一行,视觉模型复核一致 |
| 复制了图片可以直接用 | 已完成(第 22 批) | 用户说的是「输入框中的内容展示,比如插入了文件、上传了附件,复制了图片」。查下来:附件卡片本来就有(图标 + 名字 + 大小,点击移除),缺的是从剪贴板添加——复制一张图进来按 Ctrl+V 什么都不会发生。现在输入区多一个「从剪贴板添加」按钮:图片按它自带的编码格式落成文件(.rwork/attachments/paste-<时间戳>.<扩展名>,gpui 的 Image 带的就是编码好的字节,不需要转码),文件直接用路径,纯文本不归它管。为什么是按钮而不是接管 Ctrl+V:输入框的粘贴是它自己的动作,而动作处理默认阻止传播,外面收不到(实测加在容器上的 on_key_down 一次都没触发);要抢就得把 Ctrl+V 全局改绑,那会连纯文本粘贴一起弄坏。3 条单测(图片落盘且字节原样、文件用原路径、纯文本不接管);实测:往剪贴板放一张 PNG → 点按钮 → 落成 paste-*.bmp(剪贴板给的是 DIB)+ 输入区出现 8 张带文件名的卡片 |
| 派活的步骤收进一个块(不再伪装成用户消息) | 已完成(第 23 批) | 子代理的消息在库里就是普通消息,其中一条是 role=user 的任务说明——照原样渲染,界面上会变成「用户说过这句话」,而用户从没说过。现在 blocks_from_messages 见到 delegate 调用就进入收集状态,直到它自己的工具结果回来:中间那些消息只作为「步骤」进一个 Block::Delegation,不再单独露出来。块里是任务说明 + 编号步骤 + 结论。实测:界面上出现「交给子代理(这一段是它的过程)」方框(任务 + 3 条编号步骤 + 结论),且子任务不再以用户消息出现 |
| 子代理预算与主循环的关系 | 已完成(第 23 批) | 子代理预算保持独立(那正是它存在的理由),但主循环剩不到 2 轮就拒绝派活:派出去要花一轮、回来还要至少一轮读结论,只剩一轮时派活等于把成果扔在门口。拒绝时给一句人话(自己去收尾)。1 条单测 |
过长的正文按 omp 的思路变成 local:// 引用 |
已完成(第 24 批) | 用户粘一段几万字的材料进来,塞进上下文会吃掉整个窗口,而真正用到的往往只有几段。现在超过 2000 字的正文在组装请求时存成 .rwork/pastes/paste-<时间戳>.md,正文只留「已存为本地文件 + local://路径 + 开头 300 字预览」,并告诉模型需要全文就 read 它。库里存的仍是用户原文(转换只作用在请求副本上),所以对话记录里看到的还是他自己写的东西。写文件失败会退回原文——宁可多花 token,也不能把用户写的东西弄丢。提示词里也补了这个引用的含义,否则模型会当 URL 去猜 |
| 设置页改成分区,不再是一根长滚动 | 已完成(第 24 批) | 设置项已经多到一屏放不下,全堆在一个长滚动里找东西很费劲(用户原话:「不要一直往下滚动,对页面操作很差劲」)。现在分成「外观 / 智能体 / 模型服务 / 工具与扩展 / 数据」五区,页头下面一排常驻标签,一次只渲染当前分区的卡片。顺带修掉每帧把 9 张卡片全建一遍的浪费——模型服务那张卡每帧都要为每个模型建行,切到别的分区时那些活白干 |
| 保存要有回执 | 已完成(第 24 批) | 原来保存成功是静默的(只在失败时弹提示),改完根本不知道存没存进去。现在页头下面常驻一条状态:没保存过显示「改动会自动保存」,保存成功变绿显示「已保存 · 21:42:00」,失败变红并写明原因。做成常驻状态条而不是弹窗,是因为自动保存的项很多、弹窗会刷屏,而「刚才那次到底成没成」始终需要一个答案 |
| 运行记录:一次回合 = 一条可查的记录 | 已完成(第 23 批) | 新增 run / run_step 两张表(迁移 20260923000100_run_registry)与 7 个 store 方法(start_run / append_run_step / finish_run / recent_runs / run_steps / mark_stale_runs / last_run_of)。回合开始时写 running、每个工具出口(成功 / 工具报错 / 被拒 / 参数不合法)记一条链路节点、结束时写终态(轮数、调用数、错误、产出文件)。命名冲突处理:既有的定时任务 finish_run 改名 finish_task_run(同一个 Store 上不能有两个同名 inherent 方法,E0592)。启动时 mark_stale_runs 把还标着 running 的改成 interrupted——那才是「上次跑到哪」唯一可信的来源(从消息序列只能反推出「少了收尾」)。实测:跑到一半杀掉进程 → 记录停在 running → 下次启动日志 上次有运行没跑完,已标记为中断 count=1、库里变成 [interrupted] 并带上收尾时间 |
| 运行记录界面(队列 + 链路) | 已完成(第 23 批) | 「核心行为」页多一块「运行记录」:最近 30 条运行,每条显示时间、状态(进行中/已完成/失败/被中断,按严重程度配色)、标题(用户那句话的开头)、N 轮 · N 次调用 · N 个文件、产出文件清单;点一条展开它的链路步骤(1. bash · $ echo … 退出码 0),再点收起。实测:列表 3 条含一条红色的「被中断」;点 12 轮那条展开了它的工具步骤 |
| 老库升级路径 | 已验(第 23 批) | 迁移按 id 增量执行,既有数据库重新打开会补齐新表;子代理另用临时脚本验证过「删掉新表后重开仍能补上并用起来」,脚本已删 |
| 被打断的回合要如实说出来 | 已完成(第 22 批) | 进程被关掉或杀掉之后,对话就停在半路,而用户看不出来——他只会以为「它自己停了」(他的原话是「切换窗口后任务异常停止了」,我上轮已查明多半是我按进程名杀进程造成的)。事实其实摆在库里:正常结束的会话,最后一条一定是助手的收尾正文;停在用户提问 / 带工具调用的助手 / 工具结果上,就是没跑完。现在打开会话时判定并在输入框上方给一条提示:说清停在哪一步、多半是被关掉或杀掉,并给一个「继续」按钮(走正常发送路径,不另造机制)。4 条单测。实测:造一个「用户 → 助手请求工具 → 工具结果」的会话,打开后提示出现、日志有 打开会话:发现上一轮被打断 |
| 验证不得影响用户正在跑的实例 | 已完成(第 22 批) | 用户的原话是「做测试的时候不要影响我正在运行的程序」。查下来根因两条,都在我这边:①按进程名杀进程(Get-Process rwork-app | Stop-Process -Force)会连用户开着的实例一起杀掉——他正在跑的任务就停在那里,而看起来像「程序又卡住了」;②验证实例的日志写进了用户的 rwork.log(实测混进 28 行验证库的路径),他去查自己的问题时会被误导。现在:日志路径支持 RWORK_LOG_DIR 覆盖,并且只设了 RWORK_DB 时日志自动落到该库旁边;测试实例改用改名后的副本启动(按名字杀也碰不到用户那个)。实测:验证实例的日志落在 .verify/gui/logs/,用户的日志大小与 mtime 一个字节没动。3 条单测钉住路径规则 |
| 会话压缩:只压过程,不压用户原话 | 已完成(第 22 批) | 超长会话此前只有「按预算丢掉最老的」这一条路,丢掉的是整条消息(工具输出与推理是大头)。现在多一层结构化压缩(rwork_core::context::compact_process):用户消息一个字不动(提示词里写着「用户原话才是依据」,压缩动它就等于篡改意图);助手正文超过 400 字截断并标明;推理内容整段丢掉;工具结果换成一行的摘要(首行 + 末尾的状态/路径)。不调用模型——确定性、免费、可测,因此也不会在压缩时又撑爆一次窗口。触发线是预算的 70%,保留最近 4 轮原文;压缩说明会作为一条 system 消息进请求(模型得知道细节被压过,否则会拿摘要当真凭实据)。实测:30 轮 / 5.1 万字的会话,compacted=52 saved_tokens=34311,26 条工具结果变成摘要,31 条用户消息逐字未动。5 条单测钉住:低于阈值不动、用户原话不动、长输出压成一行、最近几轮不动、老推理被丢掉 |
| 压缩可读回 + 可看见(含两个既有缺陷) | 已完成(第 30 批) | 上一批的压缩是不可逆的:超长工具输出换成一行摘要之后,模型后来需要那份原始输出时只能重跑一次工具——而重跑常常已经查不出当时的问题。现在超过 1200 字的工具输出把全文存成 .rwork/<会话>/context/tool-<第几条>-<内容哈希>.md,摘要里只留开头加一个引用;身份是内容哈希、序号只是让目录好看(回合读的是最近 400 条,窗口滑动会让序号变,拿它当身份就会把同一段内容重复存),所以每一轮重压同一段历史不会多出文件、也不会换引用(有测试钉住「压两次只有一个文件」,以及「窗口滑动之后仍是同一个文件」),存不下时退回只压不存,绝不给一个读不到的引用。压缩与裁剪改为写进运行链路(run_step 的 context 步:压了几条 / 丢了几条 / 省多少 token / 存几个文件),于是「它怎么忘了刚才说的」在「核心行为」页的运行记录里看得到,不必再翻日志。顺手修掉两个查出来的既有缺陷:①引用根本读不回来——系统提示一直教模型「 local:// 表示工作区里的文件,需要就用 read 读它」(chat.system.local_ref),但 spec::resolve_path 不认这个前缀,于是模型照提示去读只会得到「文件不存在」,而用户看到的是它莫名其妙地重做一遍;现在前缀在路径解析里落地(前导斜杠会被去掉,因此它只能指向工作区内),read 上有一条回归测试。②运行链路的步会被覆盖——run_step 的主键是 (run_id, seq) 且写入是覆盖,而 step_seq 每轮从 0 重数,因此第二轮的工具步把第一轮的盖掉了,链路上只剩最后一轮;现在步序号由 TurnState 持有、一次运行单调递增。验证:core 侧 3 条(落盘 + 引用、存不下退回只压不存、短输出不落盘)、app 侧 6 条(走真实 read 工具读回引用、重复压缩引用不变、历史窗口滑动之后仍复用同一个文件、没勾 read 的智能体不落盘也不给引用、没超预算不记步、标签只说发生过的事)、路径侧 3 条(local:// 解析、前导斜杠不构成绝对路径、read 真的读回存下的文件)、步序号 1 条(跨轮次不重数) |
| 决策:记忆 / 设置 / MCP 不在回合内重读 | 已定(第 21 批,不实现) | 保持第 19 批之后的现状:跨回合一律重新读(每条消息重读设置、重扫 skill、重连 MCP、现场解析解释器),回合内沿用回合开始时的快照。理由:回合内一致性本身有用(同一个回合里工具表与审批策略忽变会让模型困惑),而 MCP 重连要杀子进程并重新握手,代价不低。skill 是唯一的例外(纯目录读、便宜,见第 19 批) |
| 定时任务与对话走同一套(上下文 + 账本) | 已完成(第 31 批) | 定时任务那条路曾经自己拼请求:只发预设提示词(基座系统提示一条都没有——「用户原话才是依据」这类基座它拿不到)、没有任何上下文预算(compact_process / fit_history 一次都没调),而它恰恰是最容易攒出长历史的运行:无人值守、没人打断、十几轮工具往返。现在两条路共用 turn::build_request——基座提示、记忆、skill 索引、结构化压缩、按条裁剪、超长工具输出落盘成可读回的引用,全都在那里面;历史也从 wire::ChatMessage 换成与对话同样的 core::Message(压缩函数只认后者,这正是当初压不动的原因)。顺手修掉两处同源的分叉:①压缩对「整段历史只有一条用户消息」的运行完全无效——保护区间按「最近 4 轮」算,而定时任务只有一句指令、连一轮都不到,于是它只能等裁剪把早年那几条整条丢掉(正是上一批刚给对话修好的那种不可逆);现在轮数不够时按消息数保护最近 8 条( KEEP_RECENT_MESSAGES),轮数够时走的还是原来那条分支,因此对话侧行为不变。②定时任务给 skill 工具的列表是空的,那条路上的 skill 工具永远查不到任何 skill;现在扫一次目录、按同一份索引给。③裁剪从最老的那条开始丢,而定时任务的指令正好是最老的——压不动时它第一个被丢,模型不会报错、只会接着做别的;现在「整段历史只有一条用户消息」时那一条不裁(新会话第一轮同样受益,因为那里也是同一个形状)。④账本也一次都没写:那条路的模型调用(用量、费用、耗时、首字延迟、成败)与工具调用(工具名、目标、裁决、耗时)都不进 audit_record,于是「这个月花了多少」「它实际动了什么」在不跑对话的那几天是假的——而界面上完全看不出来。现在两边共用 audit_model_call / audit_tool_call(对话侧的三个写入函数改为调它们,不再各写一遍)。仍然有意不同的只有三处(都写在 scheduler.rs 的注释里):无人值守时「需要确认」等于不执行、轮次用尽没有收尾轮、不接执行计划与附件(因此上下文记录进日志,不进运行链路——定时任务的台账是 task_run,它没有步骤列表)。验证:app 侧 3 条(上下文:断言基座提示在、任务指令一字不动、超长输出落盘并被换成引用;账本:模型调用与工具调用各落一行,且「工具那条不把模型那条的汇总数字加上去」;端到端: scheduler::end_to_end 起真实 HTTP 服务跑完 run_scheduled,断言六轮请求、工具真的执行、被压掉的输出换成可读回的引用且落盘文件是全文、账本各落一行、task_run 收成成功——这一条补上了 §3.1 里记着的那个缺口)、core 侧 3 条(只有一条用户消息的历史也能压且最近 8 条不动、唯一的用户消息不被裁、多条用户消息时仍按最老优先裁) |
delegate:把试错交给子代理 |
已完成(第 21 批) | 那条失败的真形状是「12 轮里 5 轮都在反复改同一个画图脚本,交付物一步没开始」。现在有一个 delegate 工具:模型把「要反复试错」的子任务交出去,子代理有自己的轮次预算(12 轮)与自己的历史(只有任务说明,不继承父代理的整段对话),做完把结论 + 写出的文件清单带回来。它不在工具注册表里——执行要开另一段回合循环,由 run_calls 特判;但其余环节一模一样:照样走权限判定(按「写」这一档)、照样可被拒绝、照样记账。子代理不继承用户的授权意图(Intent 只能来自用户原话,派活是模型发起的,继承等于让模型自己扩权);只允许一层。实测:子代理在 depth=1 跑了自己 2 轮,父代理的轮次计数不受影响,父代理随后收到了「子代理的收尾交代:图已重画…」 |
| 输入框不再跟页面抢滚轮、数字框放大 | 已完成(第 20 批) | 用户报:鼠标停在「轮次上限」输入框上滚动时,输入框和页面同时滚;而且数字框太小、几乎没法用。根因两条:①组件库的输入框在滚轮处理里只在自身偏移真的变了才 stop_propagation(单行数字框滚不动,于是不拦),事件继续冒泡给外层滚动容器;②我第一版加的包装层漏了 .id()——没有 id 的 div 不生成命中盒,回调根本不会被调用。现在 wheel_contained(id, el) 无条件拦下,实测:鼠标在输入框上滚 6 格页面变化 0 行,移到空白处滚同样格数页面正常滚(569 行)。数字框从 96px + small() 改成 160px + 默认字号,标签挪到左侧、说明另起一行。 |
| 轮次设置独立成块 | 已完成(第 20 批) | 上一批把它塞进了「智能体」卡片,用户指出位置不对——现在是一张独立卡片**「会话交互轮次」**,紧跟在「模型服务」下面(用户的原话:「在模型配置下面来设置这个会话交互轮次」),说明里写清它和模型一样决定「一次回合能走多远」 |
| 工具的输入随流式到达就显示 | 已完成(第 20 批) | 此前 tool.args 只在调用真正开始执行时才赋值:模型把参数一个字一个字发过来,界面什么都不显示,等整条回答结束才一次性出现,用户白等了同一段时间。现在 StreamEvent::ToolArgs 把「此刻攒到的参数原文」逐帧送给界面(ToolCallAccumulator::progress),工具块在调用还没开始时就展开了输入框,JSON 还没闭合就按原文显示。实测日志:参数分两片到达(42 字节 → 70 字节),3 秒之后工具才开始运行 |
| 交付物块(含脚本产出的文件) | 已完成(第 20 批) | 用户那份带图的 Word 是脚本写出来的(python-docx / matplotlib),而产出物卡片只认 write / write_version 调用,于是「图生成了、Word 生成了」界面上却一张卡片都没有。现在每回合末尾多一块**「交付物(这一回合写出的文件)」:既有工具写出的(原有判据),也有模型在回答里点名、并且磁盘上确实存在**的路径。踩到的坑:中文行文里路径是用顿号/反引号隔开的、没有空格,只按空白切会把 docs/a.png、docs/b.py 粘成一个 token,两个都认不出来——改成按分隔符集切。新增 2 条单测(中文行文、工作区之外的路径不算)。实测:mock 的收尾回答点名 3 个文件 → 界面上出现 3 张卡片 |
| 「我的输入」概览面板 | 已完成(第 20 批;第 24 批挪到右侧) | 用户的原话是「长对话里想找某一次输入,要滚动很久」。原先那个「回到我发的话」不是他要的,换成概览面板(标题「我的输入」、副标题「点击跳到那一条」):每条是我发过的话的第一行 + 时间,点一条就用逐帧记录的 user_offsets 滚过去;面板自己可滚动,几百条也放得下。实测:点第一条 → 视图精确停在「生成文档和图」那条用户消息上。第 24 批按截图把位置改到右侧那排图标下面(与统计、计划同一个栈):此前它被钉在左上角,点是右边的图标、浮层却出现在左边 |
| 十万字长对话的现状 | 已完成(第 20 批,含实测数据) | 实测 400 条消息 / 6.2 万字的会话:建块 8 ms,从「数据库已就绪」到界面可用约 0.6 秒;滚轮连续 20 格画面跟得上;负载下「回到最新」按钮照常出现。结论:决定成本的是消息条数而不是字数——界面固定只加载最近 400 条(list_messages(conv, 400),取的是末尾),所以十万字只要不超过 400 条就在这套成本内。超过 400 条的更早历史目前不显示(store 已有 page_messages 分页接口,界面还没接)。顺带把「展开一个长会话花了多久」写进日志(长会话已展开成块 messages=… blocks=… build_ms=…) |
| 轮次上限进了设置页 | 已完成(第 19 批) | 这个值此前只存在于数据库里(AppSettings::max_tool_rounds,默认 12、范围 1–50),界面没有任何地方能调——用户遇到「没做完就结束了」时无从下手。现在「智能体」卡片里有一行:输入框 + 保存 + 说明(1–50、用尽时会摘掉工具并要收尾汇报、复杂活可以调到 20–30),存进去前按同一区间夹一次,免得显示一个存了就被夹掉的数 |
| 右下角「回到最新」 | 已完成(第 19 批) | 用户对照 DeepSeek 提的:滚动离开底部后右下角出现一个圆形向下箭头,点一下回到最新(原来的「回到我发的话」是跳到本轮用户消息,两个动作不一样,都留着)。实现里踩了三个坑,都写进注释了:①滚动不会让页面重渲染,所以靠每帧都跑的 on_children_prepainted 比较「贴底状态」,变了才 notify(无条件 notify 就是渲染死循环);②这版引擎里 offset().y 越往下越负(-3302),而 max_offset().y 是正的量级(+3302),两者不同号;③引擎自己滚到底会略越过量级(实测 -3374),所以「离底距离」用 offset + max 算,负数也算贴底。实测:向上滚 14 格出现按钮(pinned=false),点击后 offset=-3302 回到最新、按钮消失 |
| 动态加载:skill 每轮重扫 | 已完成(第 19 批) | 用户问「模型在对话中动态装了环境 / 加了 mcp / 写了 skill,怎么不重启就生效」。查下来的结论是:跨回合本来就是重新读的(每条消息都重新读设置、扫 skill、重连 MCP、现场解析解释器)。真正的缺口在同一条消息的后续轮次——TurnContext 是回合开始时的快照。现在 skill 改成每轮重扫(skill::for_settings,发送路径与每轮共用同一个口径),skill 工具拿到的也是这一份。实测:让 mock 在第一个请求时把 SKILL.md 写到盘上,日志出现「skill 索引在回合进行中变了 before=0 after=1」,第 2–4 个请求的 system 里都带上了 write-docx 的索引,第 1 个请求没有 |
| 动态加载:环境面板自动重测 | 已完成(第 19 批) | 「脚本环境」卡片原来只在打开窗口 / 装完运行时 / 手点「重新检测」时更新,模型在对话里 pip 装完东西回去看还是旧的——用户就会以为「要重启才认」。现在每次进设置页都重新探测(后台线程,页面先显示检测中)。顺带把 MCP 的说明改准:「启动应用时连接」是错的,实际是每条消息开始时连一次、用完关掉,改完配置下一条消息生效 |
| 轮次用尽要交代清楚,不能静默结束 | 已完成(第 18 批) | 查 06:19 那次的日志,最后一行是 回合结束:达到轮次上限 rounds=12 calls=16:12 轮里有 5 轮(第 8–12 轮)在反复 edit 同一个 docs/build/gen_diagrams.py 调图,8 张图都生成了(docs/images/fig1..8.png)、22 KB 的脚本也在,而那份 Word 一个字都还没写——然后循环直接 break,只弹了一句「已达轮次上限」,模型连一句话都没机会说,用户看到的就是「生成没完成,任务就结束了」。现在轮次用尽不再是结束条件,而是「摘掉全部工具再要一份收尾汇报」的开关:make_closing 清空 tools 并追加一条指令,要求它说清「做完了什么(带路径)/ 交付物能不能直接用 / 还差什么 / 继续的第一步」。实测(mock 每次请求都回工具调用、预算设 3):日志出现 回合开始 … closing=true,第 4 个请求的工具数是 0,收尾消息落到库里(role=assistant)并且不用手动滚动就停在视口里 |
| 预算要对模型可见 | 已完成(第 18 批) | 上面那次的真实病因有一半在这里:模型不知道只剩几轮,于是把预算全花在打磨图上,直到被切断。现在从第 2 轮起,每轮请求末尾追加一条「(回合预算)本轮工具轮次共 N 轮,已用 X 轮,剩余 Y 轮;剩余不多时先把交付物落地成文件」——第一轮不注入(纯问答看到的是噪声)。实测三个请求体的正文里分别是「已用 2 剩余 1」「已用 3 剩余 0」 |
| 交付物优先(提示词) | 已完成(第 18 批) | 收尾要求里补上一条纪律:需要多步才能做好的事,先把交付物本身做成能打开能用的最小版本,再回头打磨细节——把它留到最后一步、前面把预算花在别处,它就会根本做不出来。这正是那次失败的形状(先花 5 轮把图修到完美,Word 没开始) |
| 流式内容跟随滚动 | 已完成(第 18 批) | 修完「会用收尾」之后发现同一条链上的第二个缺陷:流式正文每来一段并不跟随滚动(只有工具输出与建块时才滚),于是那段收尾交代落在视口之外——用户不往下滚就看不到,看起来仍然是「任务结束了但什么都没说」。现在正文与思考在用户本来就贴着底部时才跟随(pinned_to_bottom,容差 24px),往回翻着读的人不会被拽走。实测:同一条收尾消息,改前要手动滚 12 格才看得到,改后不滚动就在视口里 |
| 流式正文不再错行 | 已完成(第 17 批) | 根因是正文块的元素 id 只用了块的 key,而流式阶段同一个块每来一小片文字就重渲染一次:id 不变 → GPUI 认为还是同一个元素 → 复用上一次的排版结果,于是新来的字被塞进旧的行框里(用户截图里「3.14」跑到别处的样子)。id 改成 key-文字长度,每长一次就重排一次。实测:mock 慢速流式(2 字/帧、每帧 220ms,共 11 秒)抓 6 帧,多行中文逐行左对齐、行距均匀,3.14 / 19.625 都嵌在句子里 |
| 折叠的工具块只占一行 | 已完成(第 17 批) | 折叠态此前会把命令整段换行显示(实测占了 5 行,把对话挤得看不清)。改成 whitespace_nowrap + overflow_hidden + text_ellipsis:折叠只留一行「工具名 + 参数摘要」,超出部分省略号截断;要完整内容就展开。实测截图:折叠后的 bash 块就是一行,末尾 ping -n 8… 被截断 |
| 收尾必须汇报完成度 | 已完成(第 17 批) | 系统提示词里单列一条收尾要求(chat.system.closing):每轮结束说明任务完成没有;完成就给出产出文件的相对路径加一句内容说明;没完成就说清还差什么、卡在哪;不许用「已完成」代替清单;交付前检查占位符 / TODO / 示例数据并换成真内容。另外把本会话已写出的文件清单(produced_files,判据与界面上的产出物卡片同一套块模型)随提示词下发,好让下一轮直接接着改它们。提示词里还写明「动手之前先用自己的话复述用户要的东西,用户原话才是依据,别拿自己觉得更合理的那版替换它」——用户提这条的原话是「用户的原话(真实意图)需要连同 agent 的理解一起发给模型」 |
| 对话区右侧留白三个入口 | 已完成(第 17 批;第 24 批重做外观与布局) | 正文列有上限(字号 × 61 字),窗口再宽右侧也空着。那里放三个按钮:「回到我发的话」(滚到本轮用户消息;位置由 on_children_prepainted 逐帧记录,注意回调给的是视口坐标,要减去当前滚动量才是内容坐标)、「我的输入」与「这一轮的统计」(助手回复数、工具调用数、输入/输出 token、用时、模型用时、输出速度、产出文件)。第 24 批按用户的截图改了三处:三个浮层共用一个右侧栈(此前「我的输入」被钉在左边,用户的原话是「样式对了,但位置不对,应该在最右侧」);共用一层壳(标题 + 关闭按钮 + popover 底 + 边框 + 阴影——统计与计划此前拿 muted_foreground 当底色,画出来是灰底灰边的方块);输出速度按模型用时算(此前用墙钟跨度,把工具执行也算进分母:那一轮墙钟 126 秒、模型只生成 5.2 秒,算出来 6.2 tok/s 与用户感受完全不符) |
| 删除路径复核 | 已完成(第 17 批) | 两条路都实测走通:行内垃圾桶按钮(悬停提示「删除」→ 确认框「删除这段对话?对话及其全部消息会从本地数据库中永久删除,无法恢复。」→ 确定)与会话行右键菜单里的「删除」。都落到同一处 confirm_delete → delete_conversation,日志留了 删除结果 result=Ok(true)。实测:4 段会话删掉其中一段 → 库里 3 段,界面同步;把正开着的那一段删掉 → 对话页清空(草稿保留);全删光 → 0 段。顺带把垃圾桶改成 danger 变体(原来是默认灰,和会话标题几乎同色,行里不好找)。踩到的坑记一下:写在 Icon 上的 text_color 不起作用,按钮按变体给图标上色,得用 .danger() 变体 |
| 工具块:上下两块 + 默认折叠 | 已完成(第 16 批) | 外面一个框,里面「输入」(参数)与「输出」(结果)两个小框;默认折叠成一行,执行中(已排上 / 等确认 / 运行中)自动展开并转圈,跑完自己收回;点标题行可手动展开,与思考块同一套交互。内容按默认字号渲染(此前是 text_xs,把最该看清的东西缩成了小字)。实测截图:运行中的块展开、输出框里逐行刷出「第一段 / 第二段」 |
| 会话可删除(右键) | 已完成(第 16 批) | 侧栏会话行的右键菜单里有「删除」,确认框(标题 + 「永久删除,无法恢复」)之后才动手;删掉的若是正开着的那一段,对话页跟着清空(保留输入框草稿)。实测:3 段会话 → 右键第二段 → 删除 → 确定 → 库里只剩 2 段,界面同步 |
| 对话是一串块 | 已完成(第 15 批) | 新增 crate::blocks:把「消息 → 块」做成纯函数(blocks_from_messages,5 条单测),落库回放与流式进行中共用同一套块类型,因此「流式时看到的」与「跑完之后回放的」形态一致。渲染按块分派:正文最宽且可复制、思考与工具同宽(760px,正文 920px)、产出物只包住文字。实测截图:正文块约占 98% 宽、思考与工具块约 82% |
| 实时命令输出 | 已完成(第 15 批) | ToolContext.progress 通道 + 每根管道一个读取线程(rwork-tools/src/stream.rs):stdout/stderr 每产生一行就送到界面,逐行长进那个工具块;stderr 带 [stderr] 前缀(前缀只存在于进度通道,不进 ToolOutcome)。装包也接了同一条通道,并给 Python 设 PYTHONUNBUFFERED=1——否则 pip 的输出会攒到装完才出现,而那正是「装包时界面上什么都看不到」的直接原因。实测:一条分三段输出的命令,2 秒时块里已有前两行、5 秒时三段齐 |
| 执行中的转圈 | 已完成(第 15 批) | 工具块标题在「已排上 / 等确认 / 运行中 / 已完成」四态之间切换,前两态与运行中显示转圈(Spinner);被拒绝与被研判拦下的调用也会落到「已结束」,不会一直转 |
| 尺寸不写死像素 | 已完成(第 15 批) | 宽度全部由字号与窗口/屏幕算出来(crates/rwork-app/src/layout.rs,4 条单测):正文列 = 字号 × 61 字、过程块 = 正文列 × 0.82、页内对话框 = 窗口宽 × 0.42(夹在 420–720)、首次窗口 = 显示器可用区域 × 0.78(上限 1600×1000)。实测:13/15/18 字号 → 793/915/1098 px(日志里逐条可查);窗口从 1584 缩到 1040 时正文列仍是 915(它是阅读上限),由父容器自然压窄,不溢出也不重叠。顺带修好:侧栏拖动宽度被写死成 264 而忽略设置(拖了没用),启动条的位置现在也对齐侧栏 |
| 大模型交互日志 | 已完成(第 14 批) | 此前一处日志都没有:rwork-llm 里没有任何 tracing 调用,用户卡住时无从判断。现在请求(服务/模型/接口地址/消息数/工具数)、回答(结束原因、五个 token 计数、耗时、首字延迟、正文与工具调用数)、每次工具调用(工具名、目标、裁决、耗时、成败)、命令与脚本的每个阶段都进日志,并写到 %APPDATA%\RWork\logs\rwork.log(控制台同样一份,超过 8 MB 轮转一次)。实测:一轮「失败→修正→成功」的完整时间线在日志里逐行可读 |
| 工具失败不再中止回合 | 已完成(第 14 批) | 此前「一轮里所有调用都失败」会立刻 return,把回合连同流水线一起停掉——模型明明已经拿到错误,却没有下一次机会。现在失败结果照常回填历史,由模型分析、改、重跑;只有整位跑完一次都没成功才作为流水线停下的依据。实测:bash exit 1 失败 → 模型改成 echo fixed → 成功 → 给出结论,三个阶段都在同一个回合里走完 |
| 装包有上限 | 已完成(第 14 批) | 根因:install_packages 用 Command::output(),没有任何上限。实测卡死现场:用户要一份带图的 Word 文档 → 模型调 run_script 并要求装 python-docx / matplotlib / pillow → pip 进程 5 分钟只用掉 1.8s CPU(在等不响应的镜像)→ 回合从此不动。现在装包与跑脚本共用同一个「等子进程 + 到点就杀」的机制(180 秒),超时如实告诉模型「依赖可能没装上」;单测用一条必然超时的命令钉住这个行为 |
| 状态显示正在发生的事 | 已完成(第 14 批) | 工具在跑时输入区与流式卡片显示「正在运行 <工具>…」。此前那里写的是「正在生成回复…」,而模型那边什么都没在生成——用户看到的「卡住了」有一半来自这句话。顺带修好 ChatPage.running:它此前只被清空、从没被填过,因此「停止」时给被中止工具记的一票否决从未生效 |
| 中断留下的缺口能接上 | 已完成(第 14 批) | 进程被杀/点了停止会让「带 tool_calls 的助手消息」后面没有结果,而提供方会整条拒绝这种历史——表现是「这个对话再也发不出消息」。build_request 现在会补一条说明性的结果(「这次调用没有留下结果:上一轮被中断了」),而不是丢掉那次调用(丢掉会让模型重新请求一次)。实测:把用户卡住的那段真实会话(末尾那次 run_script 没有结果)搬进验证库,下一个请求里补上了占位结果,历史其余部分一字未改 |
| 内置工具 | 已完成 | read / write / edit / glob / grep / refs / run_script / skill / write_version / bash |
| 工具结果入库 | 已完成(第 13 批) | 此前只存了调用请求,重开对话看不到任何工具输出,而更严重的是下一次提问会带着一个「有 tool_calls、却没有对应工具结果」的历史发给服务端——那是会被整条拒绝的(表现就是「用过一次工具之后这个会话再也发不出消息」)。改为每一轮把结果与请求一起落库。实测:第二次请求体里已带上配对的工具结果(tool_call_id 与调用一致) |
| 产出物卡片 | 已完成(第 13 批) | 结果下面给一张「文件图标 + 文件名」的卡片,点一下交给系统默认程序打开。判据是调用参数 + 结果确实成功,不看模型在回答里提到的路径;只认 write / write_version 这类整文件写出的调用。实测:点击后系统关联程序(本机是 MilkUp)打开 report.md |
| 审批浮层在最上层 | 已完成(第 13 批) | 根因是浮层声明在页面第一个子元素,而 GPUI 按声明顺序绘制 —— 它被消息列表和输入区整段盖住,用户看不到也点不到,界面表现为「工具调用后程序停了」。改为最后声明 |
| 分层 + 按参数变化 | 已完成 | read/write/exec/destructive 四档;bash 遇 rm -rf 升级为强制确认、遇 rm -rf / 拒绝 |
| 删除比写入更难放行 | 已完成 | destructive 档任何模式都不自动放行,推断授权也不覆盖 |
| 内容研判 | 已完成 | 容器格式(office/PDF/压缩包/图片)拒绝按文本改写;版本库目录拒绝;密钥文件、工作区外、符号链接、只读、大文件、整文件覆盖升级为确认 |
| 授权作用域分层 | 已完成 | 会话 < 工具 < 工具组 < 目录 < 工具+目录 < 单文件;更具体优先且拒绝优先于允许 |
| 用户意图预授权 | 已完成 | 只有用户的话能预授权;绝不从模型输出推断权限 |
| 历史授权学习 | 已完成 | Beta-二项后验;只从用户真正回答过的问题取数(allow 与 approved 分开记) |
| 自有内容判定 | 已完成 | file_provenance 表 + mtime 比对,不靠模型自称;被外部改过就不再算自有 |
| 路径遍历修复 | 已完成 | is_under 不解析 ..,<工作空间>/../机密.txt 曾被判为内部;收敛到 workspace::fold_path 单一实现 |
2.3 模型服务
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| 三种方言 | 已完成 | OpenAI 兼容 / Anthropic / Ollama;工具调用分帧各有测试(分片跨帧、按 index 分组、缺 id 补号、缺名字丢弃) |
| 默认模型可刷新 | 已完成 | 填完接口地址与密钥即可刷新;异步结果由 render 建行(window 不能跨到 spawn 闭包) |
| 看到模型名字与来源 | 已完成 | 模型摘要优先显示行内值(那才是计费真正用的值)而不是目录值 |
| 定价区可滚动 | 已完成 | 根因是 overflow_y_scrollbar() 固定用 Scrolling 模式(空闲时完全画不出),改用 ScrollbarMode::Always |
| 保存不上 | 已完成 | 根因:set_catalog 会重建表单抹掉输入;改为不重建 + dirty 标志 + 先落库后改内存 |
| 密钥走环境变量 | 已完成 | 变量名填了却取不到值明确报错,不静默发空密钥 |
| 模型目录(models.dev) | 已完成 | 213 服务 / 7700+ 模型;按缺口填充不覆盖手填;匹配要求命中接口地址或服务名 |
| 未标注价格不显示 0 | 已完成 | 费用卡说明「未标注价格,费用无法估算」 |
| 执行计划的两个上限可调 | 已完成(第 26 批) | 单步重试(1–10)与总回滚(0–20)此前只存在于 plan::Policy::default() 里,界面上没有地方能改。现在进 AppSettings(plan_max_attempts_per_node / plan_max_rollbacks),在「模型服务」分区里紧随「会话交互轮次」多一张「执行计划」卡片;AppSettings::plan_policy() 把它交给执行器,sanitize 与输入框用同一区间。真机:把输入框改成 35 保存 → 库里 plan_max_attempts_per_node=10(夹到上限)、页面上回写成 10 |
| 按作用域列历次拆解 | 已完成(第 26 批) | 核心行为页新增「执行计划」区块(在任务与运行记录之间):每行是一句话目标 + 状态 + 进度 N/M 步 + 回滚次数(为 0 不显示)+ 作用范围 + 最近更新时间。数据走 Store::plans_visible_in(scope, 20)——一条 LEFT JOIN plan_node 把进度数出来,可见性规则与记忆、任务共用同一个 visible_keys。真机:库里两条不同作用域的计划 → 项目上下文只列出项目那条与全局那条 |
| 调用明细的查询与分页 | 已完成(第 28 批) | 分页本来就有(keyset + 定高虚拟列表),但界面上没交代,用户看到的就是「只显示这么点」。补了三件:关键字搜索(AuditFilter::keyword,在 model / provider_name / provider_id / error_kind / error_message 上子串匹配,% 与 _ 按字面转义)、状态筛选入口(SQL 早就有,缺的是入口)、底部「已加载 N 条 / 加载更多 / 已经到底了」。顺带修一处会静默丢数据的翻页错误:排序是 (created_at_ms DESC, id DESC),断页却只比时间戳,同一毫秒里的多行会被整批跳过(测试用旧谓词时 5 行只翻出 2 行) |
2.4 附件与文件处理
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| 图片多模态 | 已完成 | 协议按图像 token 计费(不按 base64 长度估);非多模态模型在发请求前就拒绝 |
| 图片走本机 OCR | 已完成(第 25 批) | 当前模型看不了图时,图片先用本机 OCR 识别成文字再发(rwork-ocr + rwork-app/src/ocr.rs)。模型与运行时(百度 PP-OCRv6 tiny 的 det/rec + onnxruntime)放在程序目录旁的 .models/ocr/ 下(与嵌入模型同一处,第 42 批把分居两处的模型目录统一了),用到时按需下载、设置页也能下。真机验证:文本模型 + 一张 900×320 的图文截图 → 请求体里是清单行「图片:① sample.png(…,用本机 OCR 识别成文字…)」加五行识别结果(RWork 本地 OCR 验证 / 订单编号:A20260916 / …),33ms;清空模型目录后点发送会先下载 5 个文件(哈希逐个核对一致)再提示「再发一次」 |
| 文本内联 | 已完成 | ≤ 24000 字节直接内联;40 余种源码扩展名 |
| Office / PDF / 表格 | 已完成(第 6 批方向) | 不内置解析器:给路径让模型自己写脚本。联网实测:模型自己选 openpyxl、自己拼路径、读出 1450 |
| 附件失败即中止 | 已完成 | 不静默丢文件(用户会以为模型看过了) |
| 附件落库内容 | 已完成 | 存当初真正发出去的内容,重开会话原样回放 |
2.5 脚本运行环境
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| 环境检测 | 已完成 | 跑 --version 取版本;存在但跑不起来不算可用(Windows 商店占位别名) |
| 一键安装 | 已完成 | node 走 npmmirror、python 走华为云;解压到用户目录,不装系统、不提权 |
| 国内镜像 | 已完成 | pip/npm 候选列表,以环境变量传给子进程,不写 pip.conf/.npmrc |
| 默认 pip 源 | 已完成 | 用阿里云而非清华:实测清华对所有包返回 403 |
run_script 工具 |
已完成 | 脚本落文件再跑(绕开转义)、装包走镜像、缺解释器在写脚本前拒绝;16 个单元测试 |
2.6 Skill 与 MCP
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| Skill 形式与导入 | 已完成 | YAML frontmatter + markdown;接受 <名>/SKILL.md 与平铺 .md;.zip/.skill 或目录扫描 |
| Skill 注入策略 | 已完成 | 系统提示只放索引,正文由 skill 工具按需读(成本考虑) |
| 目录存目录不存结果 | 已完成 | 你在应用外改了 skill 文件,重启即生效 |
| MCP 配置形状 | 已完成 | 照抄生态 {"mcpServers":{...}},从别处复制的配置能直接粘 |
| MCP 连接与调用 | 已完成 | 客户端与桥接有 8 项对着真实 MCP 服务器进程的测试;本轮补上接线——此前 mcp_bridge 的调用点数量是 0,配置了但从不连接 |
| 远程传输 | 已完成(如实拒绝) | HTTP/SSE 明说不支持,不假装连上 |
| 只提供 prompts 的服务器 | 已完成 | tools/list 回 -32601 识别为「连接成功,只是没有工具」 |
2.7 长期记忆
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| 关键词触发 | 已完成 | 纯函数 memory::detect,不调模型。修正过两处:「下次不要用 emoji」漏识别、「…了吗」被误判 |
| 手动整合 | 已完成 | 「整合长期记忆」按钮:取最近一次会话的最近 40 条 + 已有条目交给模型(不带上已有的会每点一次多一批重复) |
| 作用域分项目/全局 | 已完成 | 项目内容在全局上下文不可见(规则单一实现,visible_keys 返回定长数组修过越界) |
| 只提议不直接写 | 已完成 | 误报会安静地一直影响后续输出,因此交给用户确认 |
| 自动整合 | 已完成(第 25 批) | 换话题 + 这一段里出现过「该记住」的信号 + 开关打开,三条同时成立才跑(memory::auto_consolidate_due,纯函数)。素材只取上次之后的消息(会话表 consolidated_seq 水印)。默认关闭,跑之前明说「这一次会花一次模型调用」。真机验证:开开关 → 第一轮交代「以后提交信息都用中文」→ 第二轮显式换话题 → 第三个请求就是整合调用(素材只有那两条用户消息、不带工具、max_tokens 4096)→ 记忆入库、水印推到 10;再来一轮「换话题但没有交代」→ 请求数不变、记忆与水印都没动 |
| 区分「没有可记」与「格式错」 | 已完成 | 后者要提示,否则用户以为真的没什么可记 |
| 孤儿向量的入口 | 已完成(第 26 批) | 记忆被删之后向量行不会跟着走(delete_memory 只删 memory),此前只有代码每轮顺手清 500 条。现在核心行为页的记忆区多一行「向量库里有 N 条记录找不到对应的记忆……」+ 一个「清理孤儿向量」按钮(一条都没有时按钮禁用)。后端 count_orphan_memory_vectors / prune_all_memory_vectors(按批循环,跨批也清得干净)。真机:造出一条孤儿 → 那一行显示 1、按钮亮色 → 点击 → 日志 已清理孤儿向量 removed=1、页面重读后显示 0、按钮变灰 |
| 整合不再被服务端拒收 | 已完成(第 28 批) | 用户点「整合长期记忆」报 400:Messages with role 'tool' must be a response to a preceding message with 'tool_calls'。取素材的 SQL 把整段会话都发了出去,其中包含 tool 角色的消息,而我们发出去的助手消息不带 tool_calls(库里只存正文,tool_calls 另存一列)——真实库里 32 条 tool 消息、23 条助手消息里 18 条正文为空。函数自己的文档注释早就写了「工具调用不带过来」,只是代码没照做;现在按注释来:滤掉 tool 消息与空正文消息,limit 作用在过滤之后。验证:rwork-store 的 consolidation_material_leaves_out_tool_traffic(造一段跑过工具的会话,去掉过滤就红),以及 live_provider 里一条真机测试——去掉过滤时它复现出用户那条 400 一字不差 |
| 换嵌入模型后重建向量索引 | 已完成(第 39 批) | memory_vec 是按当时的模型维度建的,而 ensure_memory_index 用的是 CREATE VIRTUAL TABLE IF NOT EXISTS——换成别的模型之后那句是静默的 no-op:老表在、memory_vector 里的老向量也在,于是精排把两个模型算出来的向量混着算。表现不是报错,只是「召回越来越不准」。现在 meta 里记着索引的身份(模型标识 + 维度,memory_embedding),与当前模型不一致就丢掉重建两张向量表(reset_memory_vectors),老向量由后台每轮补一批重嵌;没装模型时什么都不做(没有查询向量,等模型回来再判——那时 stored != current,自然会重建)。验证:rwork-store 的 switching_the_embedding_model_rebuilds_the_vector_tables(8 → 16 维:老向量没了、新维度写得进去、记忆本体没动;去掉重建就红)、rwork-core 里 needs_rebuild 的四种情形、rwork-app 的 sync_embedding_index_leaves_the_stored_identity_matching_the_model(有模型 → 身份改记且老向量清掉;没模型 → 库原样不动,因此本机装没装模型这条都成立) |
| 整合出的类别与出处入库 | 已完成(第 39 批) | 迁移 20261005000100_memory_kind:memory.kind(只有决定/待办落库,事实是默认档、写空串)+ memory.source_ids。老数据按正文前缀回填(决定: / 待办: → 对应类别,认不出来的一律当普通记忆):类别原先只存在于正文前缀里,不回填的话用户升级后那些「已经定下来的方案」在提示词里就跟随口一提的事毫无区别。出处不是让模型逐条标引用(它会写错、会漏写,而写错的那种看起来像有了出处,比没有出处更坏),记的是这次提炼实际读过的那段消息序号区间(consolidation_material 顺带给出 from_seq)。验证:rwork-store 的 the_kind_of_older_memories_is_backfilled_from_the_text_prefix(造一个停在迁移之前的库再走正常打开流程)、a_memory_keeps_its_kind_and_source_range(往返)、rwork-core 的 entries_become_memories_with_the_given_scope_and_origin |
| 意图视图:决定与待办不看相关度 | 已完成(第 39 批,第 40 批补齐事实与缓存) | 召回按相似度算,而「三个月前定下的方案」跟今天的问题常常一个词都不重合——它丢出提示词的表现就是模型把已经定下来的方案当成还没定的选项,重新问一遍「你想用 A 还是 B」。现在这一节按类别 + 当前意图组装:决定与待办全收(新的在前、上限 12 条),事实只收强档那几条(按相关度,上限 5 条);正文里的 决定: 前缀去掉、被截掉的条数写明;视图用掉的那几条不再在记忆那一节里重复。按会话缓存(intent_view 表,指纹 = 可见记忆集合的摘要):换话题或记忆集合变了才重建,其余时候逐字稳定——每轮按当轮召回重排会让同一件事在提示词里进进出出。界面同步显示类别标签与「从第 12–18 条消息总结」。验证:rwork-core 的 intent_view 五条(决定/待办不看相似度、事实只收强档、报出用掉哪几条、截断写明、没有就不出现)、rwork-app 的 the_intent_view_is_cached_until_intent_or_memory_changes(第一次建 → 命中缓存逐字相同 → 换话题重建 → 多一条决定重建)、decisions_and_todos_reach_recall_without_any_similarity、the_request_carries_the_intent_view(从组装出来的请求对象上读系统提示,并断言那一条只出现一次)、memory_row_text_... 与 a_classified_memory_row_shows_its_kind_and_source(真渲染一帧)。没做的:真机点一遍界面(.verify/gui 那套工具已不在工作区) |
| 记忆的取用反馈:被取用几次 | 已完成(第 48 批) | 此前 memory.last_used_at 是一个没人写的字段:全仓除了它自己的单测,没有任何生产调用者(touch_memory 一次也没被调过)。于是召回里那句 ORDER BY last_used_at DESC 实际等价于按创建时间排序,而界面上那个「最近使用」显示的是它的写入时刻——一个看不出错的谎(不报错,只是排序永远不对)。现在每次召回都落一行 memory_use(迁移 20261012000100,一行一次取用,只追加),并把 last_used_at 推到此刻;核心行为页每条记忆多一行「被取用 N 次」,一次都没取用过时说「还从未被取用」(这两种情形必须分得开:前者可能该删,后者说明召回时总是多条一起命中)。验证:rwork-store 的 recording_a_use_moves_last_used_at_and_shows_up_in_the_stats、rwork-app 的 decisions_and_todos_reach_recall_without_any_similarity(从真召回路径断言 uses == 1)、memory_row_text_comes_from_the_kind_and_the_source_range(三档文案)、a_classified_memory_row_shows_its_kind_and_source(真渲染一帧)。反向验证:去掉 record_memory_uses 里那句 UPDATE memory SET last_used_at,第一条立刻红。并修掉它带出来的一处耦合:意图视图的重建指纹原本含 last_used_at,字段一开写就每轮判成「记忆变了」——缓存永不命中、视图每轮重建(the_intent_view_is_cached_until_intent_or_memory_changes 抓住的;这个字段此前从没被写过,所以带上它也看不出问题)。 |
| 记忆的成败只算「有独立结论」的那一次 | 已完成(第 48 批) | 一次回合召回五条、任务成功,给五条各记一次「赢」等于把噪声当信号——而它会直接改召回排序。因此只有恰好命中一条时才把成败算到它头上,命中多条只标 coexposed(谁也不记功);上一次没跑完留下的待裁决记录,在下一轮记为取用时先标成 interrupted,否则它们会被下一轮的裁决一起收走,等于把这一轮的成败记到一个中途死掉的回合头上。裁决只由用户自己那一轮给(delegation_depth == 0 且没有棒号):派活与流水线那几位跑完只说明这一棒结束——流水线那几位也是顶层上下文,只有棒号能把它们与用户那一轮分开。代价是团队那一轮拿不到结论,而这是诚实的(谁起的作用本来就要 A/B 才说得清)。这条规矩来自 momo-code 的 experience/closure.ts(那里的注释是「个体因果需要对照实验」)。验证:rwork-store 的 a_verdict_is_only_attributed_when_a_single_memory_was_used、a_turn_that_never_finished_is_not_given_the_next_turns_verdict;反向验证:把判据改成「一律记赢」、或去掉标 interrupted 那句,两条分别立刻红。 |
| 一句话里几件事时按分项切段召回 | 已完成(第 49 批) | 整句只算一个向量时它是几件事的平均:跟哪一件都不够像 → 分档整体滑向中/弱,而事实那一节只收强档 ⇒ 它空掉;关键词那条路更直白(整句是当短语查的,长消息几乎必然查不到)。现在按分项边界切开(编号、带圈数字、项目符号、行首的并列/递进词、分号——不把普通换行当边界),逐段各算一次嵌入、各查一次三层召回,merge_hits 合并:同一条被多段命中保留分数最高那次(取平均会压掉「两段都沾一点」的那种,而它最该留下);段数上限 5、超出的并进最后一段;合并后条数收在单段预算内。普通消息切不出第二段 ⇒ 行为与从前逐字一致。切分与「像不像好几件事」的判据收在 rwork_core::segments 一处(后者第 49 批从 rwork-app/src/turn.rs 搬来)。验证:segments 14 条(切分/上限/短片段合并/普通消息不切/四类边界)、merging_segment_hits_keeps_the_best_score_per_memory、接线 a_second_item_in_the_same_message_is_recalled_too(反向验证:让 for_recall 原样返回整句立刻红)。 |
| 换话题的判据是「有几段不像」 | 已完成(第 49 批) | 以前整句一个向量:几段平均之后与重心「有点像」,混话题的消息永远判不出换话题,而事实那一节会一直停在旧话题上。现在每段各自与重心算余弦,off = 明显偏离(< 0.62)的段数:off >= 2 直接算换(一处是口误,两处就是换了话题);off == 1 先记待定、下一轮再偏才算(单段消息因此与从前逐条一致);显式说法落在哪一段就把重心置成那一段(不是整句均值——均值会把新旧话题焊在一起)。验证:intent_drift 的四条新用例 + 既有五条(全部仍绿);反向验证:把判据改回「只有连续两轮」立刻红。 |
2.8 任务与定时任务
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| cron 支持 | 已完成 | 引 cron 库;实测它只认 6–7 段、标准 5 段被拒,因此做归一化 |
| 图形化编辑器 | 已完成 | 选频率 + 选时刻,不需要手写;有中文说明、表达式与下次执行预览 |
| 星期编号差异 | 已完成(如实记录) | 该库是 1=周日、2=周一且拒绝 0,与标准不同。不替你翻译数字(会引入第二义),但用测试钉住并在界面给不受影响的示例 |
| 时区 | 已完成 | cron 按本地解释,存库仍是 UTC。修过两处:原按 UTC 算导致「写 9 点跑下午 5 点」;补做按钮另按 UTC 显示 |
| 落库 | 已完成 | task.schedule / task_run 历史表 |
| 错过执行检测 | 已完成 | 算出上次执行到现在的漏跑点;总数照实说、只列最近两次 |
| 补跑决策交给用户 | 已完成 | 不自动补做(「每天 9 点备份」在开机时自动补会产生没预期的写入) |
| 间隔型任务不报错过 | 已完成 | 它的语义是「距上次多久」,开机晚一点本就该顺延 |
| 补做时告知时点 | 已完成 | 「生成昨天的日报」不说清时点,模型会按现在写 |
| 执行历史 | 已完成 | 记计划触发的时刻(不是点按钮的时刻)、结局、模型、工具调用与结果 |
| 崩溃残留处理 | 已完成 | 启动时把 running 标成失败,否则那一次会被永远跳过 |
due_tasks 状态过滤 |
已完成 | 原只选 ready/blocked,导致任务一旦失败就永远不再跑;改为排除 running/done/cancelled、保留 failed |
| 与窗口能力一致 | 已完成 | 继承 agent 或全局配置:工具、审批模式、系统提示词、授权规则全部来自同一个 resolve,与对话页同一套解析。曾做过独立的 granted_tools 白名单,已删除(两处配置必然分叉) |
| 与 MCP 的关系 | 已完成 | 间接:MCP 工具由连接层注册进注册表,白名单决定它能不能用,两条路径同一条规则 |
| 对话式创建 | 已完成 | 检测「时间性 + 请求性」同时出现 → 并行跑一次翻译 → 确认条 → 用户点才建 |
| 任务列表布局 | 已完成 | 列表在前、新建折叠在后(原表单常驻让页面像「正在编辑一条」) |
2.9 多智能体
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| 新建对话时可选 | 已完成 | 输入框上方一行;已开始的对话不再显示(中途换人会让前后半段约定不一致) |
| 多选 = 流水线 | 已完成 | 按点击顺序接力,后一位看到前一位全部产出(每步已落库,不需额外交接结构) |
| 协作策略 | 已完成 | 串联流水线;调研后明确不做并行扇出/主从/辩论/LLM 路由,理由见 local://multi-agent-research.md |
| 每位保留自己的配置 | 已完成 | 模型、提示词、工具白名单、审批模式各用各的(不合并)——「便宜模型规划、强模型执行」的前提 |
| 合并规则(备用) | 已完成 | 工具取交集(空交集报错不静默降级)、审批取更严格者、提示词按序带名拼接、模型取首个明确选择 |
| 权限严格度 | 已完成 | 新增显式 strictness/stricter 而非 derive(Ord):靠声明顺序排序的话,将来插一个变体会静默改变权限语义 |
| 交接说明 | 已完成 | 从第二位起插一条 Role::User 说明(某些提供方不接受对话中途的 system) |
| 轮次预算 | 已完成 | 每位各一份;共用会让第一位跑满把后面掐死 |
| 失败不连锁 | 已完成 | 某位失败就停在那里,已完成产出如实保留 |
| 评审位驱动退回 | 已完成 | parse_verdict 读首行结论;退回时交给上一位重做最多一轮 |
| 评审解析的误判防护 | 已完成 | 只认开头几行(「不通过」含「通过」、正文里的词都会被误判成结论);读不出不当作通过 |
| 会话记住所选 | 已完成 | 团队记在 Conversation.team,不记全局 |
| 内置 10 个角色 | 已完成 | 编程 6 + 文件/数据 4,每个写明「不该做什么」 |
| 白名单是硬约束 | 已完成 | specs_for 按名字过滤,只读角色改不了文件是机制保证 |
| 内置角色可改/可删/可升级 | 已完成 | 改动存同 id 影子;删除记 disabled_agents;新角色默认可用 |
| 不占用户额度 | 已完成 | 现算展开,从不写进 agents[];有测试钉住 |
| 角色的审批档位真的生效 | 已完成(第 32 批) | policy_from 此前读的是全局 settings.approval_mode,ResolvedAgent::approval_mode 在 rwork-app 里没有任何消费者——于是「选一个只读角色」只是提示词里的一句话,引擎照旧按全局档位放行,而 agent_preset 那条「每位成员保留自己的审批模式」的测试只断言了解析结果。现在策略取自那一位(对话、流水线、定时任务三处同源)。回归测试 turn::context_notes::a_role_keeps_its_own_approval_mode_under_a_permissive_global:全局 Yolo + 内置「代码探查」(只读、每次都问)→ 一次写入判成 Ask,而跟随全局的那一位判成 Run |
| 内置角色能当「当前智能体」 | 已完成(第 32 批) | 设置页那个选择器一直列着 available_agents(含内置),而 active_preset() 只查 settings.agents——内置角色从不写进那里,于是选中「编程」得到的是「什么都没选」,而按钮还高亮着;AppSettings::sanitize 的「悬空 id」判据同样只认后者,于是每次载入设置都会把选择清掉(表现为「重启之后变回跟随全局,我什么都没改」)。两处都改查可选清单。测试 a_builtin_role_can_be_the_active_agent、sanitize_keeps_a_builtin_active_agent |
| 单人对话的编程档位 | 已完成(第 32 批) | 新增内置角色「编程」:与「代码实现」同一套工具与档位,提示词换成单人对话的姿态(先看懂现状、自己定步骤、改完自验证)。两者并存不是重复——后者的提示词写着「按上一位给出的计划改代码」,而单人对话里没有上一位,那段话只会让模型去等一个不会来的计划 |
| 工具表计入上下文预算 | 已完成(第 32 批) | 工具表不在消息里,而压缩与裁剪只看消息(fit_history / compact_process)——每加一个工具都在预算之外占窗口,且没有任何地方反映出来,表现是「它怎么什么都记不住」。现在 context::estimate_tool_table 在组装请求时先扣掉再裁,占掉一半以上额度时记一行 warn。实测(扫 i18n 表):10 个内置工具的描述 + 参数说明,中文 3 616 字符 / 英文 8 161 字符 |
| 内置角色能进流水线 | 已完成(第 26 批) | resolve_team 改查 available_agents(内置 + 用户预设)。此前只查 settings.agents,而内置角色从不写进去——从列表里选了「代码实现」发出去得到的是「找不到这个智能体」 |
| 会话记着的团队真的会跑 | 已完成(第 26 批) | send_now / send_model 此前只看界面上这一轮选的团队,于是「重开应用后继续一段流水线对话」静默退化成单人。现在判据是 effective_team(界面选择优先,其次会话记着的),并在打开会话时把它铺回选择器——界面说的与实际跑的从此是同一份 |
| 用户消息只插一次 | 已完成(第 26 批) | 此前每位都插一次用户消息,撞 message.id 唯一索引:第二位永远开不了跑,退回后重入第一位时第一位自己把自己撞停 |
| 执行计划在流水线里接力 | 已完成(第 26 批) | 一份计划整条流水线共用;审计留在每一棒的回合循环里;退回按节点上记的棒号把那一棒叫回来(含被牵连的子孙),见 §3.13 |
2.10 外部智能体(omp)
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| 把对话交给本机的 coding agent 跑 | 已完成(第 33 批) | 设置里一个开关(默认关)。打开后这一轮由 omp --mode rpc 执行,过程镜像进同一个对话:正文逐字上屏、思考单独通道、工具调用与结果都进历史。传输在 rwork_core::omp_client(子进程 + JSONL + 读线程,与 mcp_client 同形状),镜像在 rwork_app::external |
| 不复刻成「两次执行」 | 已完成(第 33 批) | 外部代理的工具调用是已经跑完的事实,因此它走 Signal::ToolFinished 而不是 StreamEvent::ToolCall:混在一起会让每一次写入被本地再执行一遍。外部执行过的结果在库里标成 external |
| 账本分得清「谁花的」 | 已完成(第 33 批) | 用量与费用取对方 turn_end 里报的那一份(含 cache read/write 与它按自己价格表算出的 cost),provider_id 记成 omp。不按我们的价格表重算:两个口径混在一组里,两个数都不对 |
| 进程按对话复用 | 已完成(第 33 批) | 一个对话一个进程(对方的上下文才接得上);懒启动(第一句话才起),关 stdin 让它自己收尾,超时才 kill。每句话起一个新进程会让「库里连续、它那边从零」 |
| 权限边界说清楚 | 已完成(第 34 批,已闭合) | 写入类工具交回 RWork:启动时用 --tools read,grep,glob 把它的内置工具收成只读,再用 set_host_tools 把 rwork_write/rwork_edit/rwork_bash 交给它。它请求执行的每一次调用都走 prepare → decide → (同一个审批框)→ invoke,并记审计与文件出处;库里用 host(我们执行的)与 external(它自己跑的)两个标记分开 |
| 失去管辖要说出来 | 已完成(第 35 批) | 上面那条只在默认配置下成立:把 read_only_tools 清空(我们就不追加 --tools,按对方的约定等于它的内置工具全开),或者在参数里自己写了 --tools 且里面有 write/edit/bash——两种情况下它都能绕过我们的工具直接写盘。判据只能是生效的那一份清单(自写 --tools 时 read_only_tools 已经失效,拿字段判断会给出安慰性的答案),因此做成纯函数 ExternalAgent::host_jurisdiction_over_writes(),设置页在失去管辖时直接给出警告。同时「它自己执行的写」在库里单独标成 external-write,并在对话里渲染在工具块名字旁边(未经 RWork 的写入)——此前它只记 external,界面无从分辨「它读了一个文件」与「它写了一个文件」 |
| 外部执行的调用不再画成失败 | 已完成(第 35 批) | 界面的失败判据此前写成「裁决不等于 allow」,而外部那一轮的裁决是 host / external——于是重新打开那段对话时,它跑过的每一次调用都画成红色失败(不报任何错)。判据收进 rwork_core::model::decision_is_failure(只认 denied),并把同一个含义的两个拼写(deny / denied)并成一个。回归测试:把判据改回旧写法,an_externally_run_tool_call_is_not_rendered_as_failed 会红在 failed: true, decision: Some("external") |
| 它真的会用我们的工具 | 已完成(第 34 批) | 真机测试 the_external_agent_asks_us_to_write_and_we_gate_it:它请求 rwork_write {"path":"hello.txt","content":"hi"} → 在 AlwaysAsk 档位下被判成「先问」(asked=1)→ 我们执行 → 文件内容真的是 hi → 它收到结果后答 done。实测还抓到一条协议事实:宿主工具不能与它的内置工具重名(即使那一个已被 --tools 关掉),set_host_tools 会回 conflicts with an existing tool,因此名字带 rwork_ 前缀 |
| 真实上游验证 | 已完成(第 33 批) | crates/rwork-integration-tests/tests/omp_session.rs(传输:ready 帧、get_state、事件流收完、用量与费用非零)与 rwork-app 的 external::tests::a_real_omp_turn_becomes_messages_and_a_ledger_entry(镜像:真 omp → 真 SQLite,断言库里那几条消息与那条账本)。两条都 #[ignore],手动跑 |
| 宿主工具回路不依赖真机 | 已完成(第 37 批) | 这条回路此前只在 #[ignore] 的真机用例里验过(要装 omp、要登录、花额度),常规运行里等于没有覆盖。现在有一个假 omp 服务器(crates/rwork-integration-tests/src/bin/mock_omp.rs,同 crate 的二进制、靠 CARGO_BIN_EXE_mock_omp 起)把「它请求 → 我们判定 → 我们执行 → 我们回执」整条链在 cargo test 里跑一遍:0.07 秒、零外部依赖。夹具刻意用 Rust 而不是 python——MCP 那两个 python 夹具在没有 python 的机器上会静默跳过(这台开发机就是),而这条回路要钉的恰好是「回执送错的 id 会让整轮卡死」这类只在真机才暴露的行为。真机用例仍然保留:它回答的是另一个问题(上游实际发出来的帧是不是这些) |
| 外部智能体也拿到记忆与意图视图 | 已完成(第 40 批) | 那条路以前一个 memories 都不读:提示词由对方自己拼,我们插不进它的系统提示,于是记忆召回、意图视图、换话题说明都到不了它那里(设计文档 §9 记过这一条)。现在 run_turn 在新开一段对方会话或换了话题时,把「意图视图 + 记忆那一节 + 换话题说明」作为一段话带在用户消息前面(external_preamble / needs_preamble),视图用掉的那几条不在记忆那一节里重复;其余时候不带——它自己会一直留着历史,每轮再塞一遍只会在它的上下文里堆 N 份。会话是不是新开的由 session_for 一并返回(接回旧会话时 false,--resume 失败重开时 true)。验证:the_preamble_carries_decisions_and_skips_what_the_view_already_used(决定进、强档事实进视图、中档事实留在记忆节、同一句只说一遍、换话题有说明、没有就不带)、the_preamble_goes_out_only_for_a_fresh_session_or_a_topic_switch。没做的:注入那一行的接线只有单测,没跑真机 omp(那要花额度) |
| 重开应用后接着对方那段会话 | 已完成(第 38 批) | 外部智能体用 --resume <id> 接着上次继续,而我们此前不记那个 id:进程一没(退出、崩溃、切走),下次就重起一段新的对方会话——它那边从零开始,库里那段历史看起来却是连续的。现在 conversation.external_session 记着它(迁移 20261004000100,定向更新 set_conversation_external_session):session_for 起进程时带上 --resume,握手后从 get_state 的 sessionId 写回。接不上时(对方那段已被清理、--resume 起不来)去掉参数重开一段并发通知 omp.session_resume_failed——绝不静默,否则用户只会看到「它怎么忘了刚才说的」。实测(真机 omp 18.2.6,--mode rpc,不需要模型调用):get_state 的 data.sessionId 就是标识;--resume <id>(前缀或文件路径)接回后 sessionId 不变、messageCount 仍是之前那两条;另有一条事实——没有内容的会话在盘上不存在,所以拿「刚握手还没跑过任何一轮」的 id 去 resume 会得到 Session not found,那正是上面那条降级分支存在的理由 |
| 读与搜索留在它那边(已定案) | 已完成(第 45 批) | 它的 read/grep/glob 至今是它自己的工具,我们只看得见结果。这条不是遗漏:定案前核实过「读的风险由什么兜住」,结论是读这一层没有可判的东西——RWork 自己的读在三种档位下同样自动放行、同样不做工作区包含检查,把它搬成宿主工具只是给同一次读取多加一次进程往返。兜住的是三件事(默认关闭 / 写类动作强制回到我们的权限链 / 每一次调用都标出来),残余风险与前提写在架构文档 §8 第 3 条 |
| 「这一段是转述」当场可见 | 已完成(第 45 批) | 它自己执行的读与搜索(以及我们认不出名字的调用)在工具块名字旁边标成「由它自己执行(转述)」(陈述语气),与写那一路的「未经 RWork 的写入」(警告色)互斥、都不与「失败」混。此前只有设置页与文档说过「转述」,对话里其实只有「写」有标注——docs/02 §7 的说法与实现不一致,本批补齐 |
| 审批弹窗端到端自动化 | 已完成(第 45 批) | 「弹窗 → 允许 → 执行 → 回执」此前只有真机上的一只手。现在 turn::approval_dialog::clicking_allow_once_runs_the_tool_and_leaves_a_receipt 在真实窗口里用 TestWindowExt::click 合成平台鼠标事件走完整条链(文件落盘、交互行 AllowOnce、账本 approved 且指回提问、弹窗收起)。顺带更正:§3.0/§3.2 里「合成鼠标到不了 GPUI」只对从外部往真实窗口注入成立,进程内的测试窗口可以 |
2.11 界面与工作空间
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| 后台不再空转(光标闪烁) | 已完成(第 43 批;上游缺陷 + 本地第二处 [patch]) |
用户报「打开之后总是定期占用 2-3% CPU」,问是不是有自动执行的代码。没有:全仓只有一处常驻周期代码(调度线程 30 秒一次 due_tasks 查询,微秒级)与两处 5 分钟空闲卸载(记忆 / OCR 工作线程)。真正在烧的是光标闪烁:gpui-base 的 BlinkCursor 每 500 毫秒翻转一次并 cx.notify(),而 GPUI 的 Window::mark_view_dirty 会把这条视图链的所有祖先都标脏(gpui-pre-0.3.4/src/window.rs:2102),引擎又不复用未变子树的预排结果,于是每一次翻转都是一次整窗重绘;上游那句注释写的是「Blink the cursor when the window is active, pause when it's not.」,但只实现了前半句(激活观察没有 else 分支,on_focus 也无条件启动),而光标只在窗口是前台时才画(InputState::show_cursor)。实测(隔离库 + 改名副本 + 帧探针 + typeperf,窗口全程不是前台):无输入框有焦点 0 帧/秒;聊天输入框有焦点 2 帧/秒、2.44% 单核(主线程 2.30%);空会话 1.22%;审计页 + 侧栏搜索框 4 帧/秒、2.24%。每次重绘 4.4–11.5 ms(空会话 4.4 / 38 条消息 11.5 / 审计页 9.8)。修法:本地副本加第二处补丁(blink_cursor::suspend + 激活观察的 else 分支 + on_focus 判 is_window_active)。修后按同一条生产路径验证(投递 WM_ACTIVATE(WA_INACTIVE),与用户切走窗口时 Windows 发的是同一条消息):0.40% 单核(主线程 2.30% → 0.20%),投回 WA_ACTIVE 后 2.80%(光标照常闪)。门禁:vendor_patch.rs 第二条检查 + 副本自带用例 a_suspended_cursor_stops_toggling。剩下的成本记在 docs/09 的 E5/E6 |
| 侧栏名字重复 | 已完成 | 去掉与「工作空间」重复的文案;折叠按钮改方向性 Chevron |
| 折叠按钮 | 已完成 | 收起 ›、展开 ‹(资源包里没有面板图标) |
| 栏位可拖动 | 已完成(逻辑验证) | 右边缘 6px 拖动改宽(200–520px)。只做了逻辑验证:合成鼠标消息到不了 GPUI 命中测试(滚轮能到,点击/拖动不能) |
| 工作空间用图标 + 提示 | 已完成 | 不再在按钮上显示目录名(那是与标题重复的来源) |
| 去掉绝对路径输入 | 已完成 | 只留「选文件夹」 |
| i18n | 已完成 | 全部文案中英双语;有测试要求每个键两种语言都有值 |
| 中文字体 | 已完成 | 内置 Maple Mono NF CN,首帧前注册,不依赖系统安装 |
| 正文「字体堆叠」的根因与补丁 | 已完成(第 24 批;上游缺陷 + 本地 [patch]) |
用户截图标红的正文块出现字体堆叠。逐像素 + 程序内日志两头量下来:不是样式/字号/字族配置问题,而是 gpui-base 的行内排版测量与绘制用了不同的字体——InlineFlow 的 measured-layout 闭包在布局阶段才被回调,那时元素(与父元素)压进样式栈的字族已出栈,window.text_style() 退回默认字体(比例字体:汉字 1.0em=15px、空格 4.28px),而同一次绘制的 Inline 在元素树里拿到的是宿主设的 Maple Mono NF CN(汉字 1.2em=18px、空格 9px)。于是每个含汉字的片段被少算 ~18% 的宽度,下一个片段压在上一个片段的尾部——正是用户看到的叠字;纯 ASCII 的行内代码片段不受影响(run 里写死了字族,两侧同源)。全库扫描 1834 个片段:含汉字的宽度比值中位数 0.822,纯 ASCII 全是 1.000。修法是把 request_layout 那一刻的组合文字样式抓下来交给闭包(3 行);已以本地副本 vendor/gpui-base + [patch.crates-io] 接进构建,并带一条打补丁前会失败的回归测试;同一段会话重新扫描后含汉字段落的比值中位数回到 1.000。给上游的报告与最小复现见 UPSTREAM_REPORT.md |
| 渐进刷新 | 已完成 | 窗口先出现,四步启动管线 + 每卡片独立 Loading/Ready/Failed |
| 滚动条跟着内容跑 | 已完成(第 28 批;结构改动,见 §3 的验证说明) | 用户报「对话面板、智能体设置、模型服务、工具扩展」的滚动条有问题,且「只要内部空间是动态大小的就会出现」。四个页面(连审计页共七处)共用 ui::static_scroll,它把 Scrollbar 浮层做成滚动容器自己的子节点,而 gpui 会把滚动容器的每一个子节点都平移 scroll_offset(gpui-pre 的 elements/div.rs 里 with_element_offset(scroll_offset, …) 不区分普通子节点与绝对定位浮层)——于是轨道、滑块、命中区跟着内容跑:没滚动时正常,一滚就滑出裁剪框。内容越长(越需要滚动条)偏得越远。改成与 gpui-component 的 List 相同的结构:定位外壳(不滚动)→ 滚动区 + 滚动条兄弟节点。另给浮动面板正文加 flex_1().min_h(0.)(外壳是 max_h,正文不可收缩就会被直接裁掉) |
| 工作空间跟着会话走 | 已完成(第 32 批) | 此前工作空间只有全局一个值,于是「为 B 项目切过去」会把还开着的 A 项目那段对话一起挪走——相对路径指到别的仓库、目录级授权换了对象、历史里 local:// 引用的全文读到另一个项目的同名文件、项目范围记忆换了作用域,而侧栏还显示着旧目录。现在 conversation.workspace(迁移 20261003000100,空串 = 跟随全局,因此升级前的会话行为完全不变),优先级由 turn::pinned_workspace 一处决定(会话 > 全局),侧栏显示的与工具实际用的是同一个;切换器同时改全局默认与当前会话。测试:a_conversation_remembers_its_workspace(store 往返、定向改、整条写回不把它抹掉)、a_conversation_workspace_wins_over_the_global_default。界面部分只做了逻辑验证(没有肉眼确认切换器切换后的画面) |
| 等待后台结果期间窗口照常能用 | 已完成(第 44 批,修既有缺陷) | 四处「等后台线程结果」的轮询原本写成 std::thread::sleep(25ms),而 cx.spawn 的 future 是在界面线程上被 poll 的——等到结果回来为止,窗口点不动(装本机 OCR 的上限 12000×25ms、手动整合 4800×25ms)。现在统一走 crate::wait::for_result(执行器自己的定时器等),并在等不到结果时把「正在下载」那类状态收掉、如实报出(此前三处什么都不做)。A/B 实测(同一份二进制,mock 把整合的回答拖慢 4 秒,点完之后 1 秒去点侧栏「设置」):旧写法切页发生在阻塞轮询结束之后(点击被压 2.4 秒),新写法 0.2 秒内就切过去了,而那次等待还在继续。测试 wait::tests::the_wait_gives_the_ui_thread_back(换回 std::thread::sleep 立刻红) |
| 长会话的重绘成本与块数脱钩 | 已完成(第 44 批) | ChatPage::render_transcript 此前为全部块建元素。现在每帧量出各块的位置与高度(on_children_prepainted),视口外(±600px)的块换成等高占位,没量过高度的块一律真建(第一次打开长会话仍全建一帧)。A/B 实测(同一份二进制 + RWORK_NO_VIRTUALIZE=1):380 条消息(190 块)的会话每次重绘 97.72 ms → 18.59 ms 进程 CPU(19.21% → 3.59% 单核),5.3 倍;38 条消息那次开关都是 ~18 ms。占位高度必须与真块一致(测试断言两种状态下内容总高度逐值相等)。没达成原来的目标(38 条消息 <1.5% 单核):剩下的 ~18 ms 与对话长度无关,瓶颈已转移到外壳整窗重绘本身,见 §3.14 |
| 产出物按会话区分 | 已完成(第 28 批) | 会话之间共享工作区,用户要求「用会话 id 做文件夹区分,在 .rwork 里做信息的区分,跨会话互通只在要求时做」。两处落地:①workspace::session_dir = <工作区>/.rwork/<会话 id>/,run_script 的脚本改放这里(ToolContext 新增 scratch,对话按会话、定时任务按这次运行分开;脚本里的相对路径仍以工作区为基准,文档照旧落在工作区里);②交付物清单按 file_provenance(谁先创建了这个文件——此前只有写入、没人读)分两组:「交付物」只列本会话写出的,另一组列出工作空间里已有的并标明来自哪段会话。默认不继承别的会话的产出(模型显式去读工作区即可) |
2.12 人机交互的过程(问了什么、怎么答的)
交互以前只留下结果:账本里的 approved / denied、「总是允许」进 permission_grant、
确认过的记忆进 memory。而「问了什么」与「用户点了不记 / 不建 / 没答」没有落点——
于是「它为什么老是问同一件事」「他每次都把这类候选点掉」事后都答不出来。
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| 交互落成一层数据 | 已完成(第 40 批) | 迁移 20261006000100 加 interaction 表:kind(permission / memory / schedule)、subject(工具调用 id / 记忆 id / 任务 id)、question(当时摆给用户看的那几行,不是我们重新概括的)、answer、at_ms / answered_ms。提问时先写一行、回答时补上——两步是有意的。验证:rwork-store 的 a_question_is_written_first_and_the_answer_fills_it_in(提问先落、回答补上、提问正文不被后来的写入改写) |
| 「问了没答」与「拒绝」分开 | 已完成(第 40 批) | 进程在等回答时被杀、界面被关,库里留下的是「问了、没答」(answer 空),不是拒绝——后者是用户明确说了不。界面文案也分开(interaction.answer.unanswered = 「问了,没等到回答」)。验证:an_unanswered_question_never_becomes_a_denial |
| 三处记录挂同一个 id | 已完成(第 40 批) | 运行链路那一步与账本那一行都记下这次交互(run_step.interaction_id / audit_record.interaction_id),interaction.subject 是那条工具结果消息的 tool_call_id,因此「第 3 轮为什么问我」不再只能靠 conversation_id + 时间戳拼。验证:rwork-app 的 a_permission_question_and_its_answer_are_recorded_and_linked(提问 → 回答 → 运行步与账本都指得回来;LEFT JOIN 那次列名歧义就是被这条抓出来的) |
| 候选的否定回答也落行 | 已完成(第 40 批) | 「不记」「不建」以前只清界面上的候选(内存字段),什么都不留。现在两种回答都落一行;候选卡上那行「这类提议你已经跳过 {count} 次」读的就是它(Store::count_dismissed,按类别与会话分开算)。验证:dismissed_candidates_are_counted_per_kind_and_conversation(确认过的不算「跳过」、别的会话不掺进来) |
| 界面读回 | 已完成(第 40 批) | 核心行为页的运行链路里,问过的那一步后面接「问过:总是允许」;没等到回答的接「问过:问了,没等到回答」。验证:a_step_says_when_the_user_was_asked(三态文案是纯函数)。渲染那一行没有单独的真窗口测试——它没有可交互的控件,与记忆行那两条(点了才看得见结果)不同 |
外部智能体那条路的审批同样落行(它走的是同一个弹窗,只是回合循环在 external.rs)。
2.13 无人值守(后台常驻、托盘、开机自启、被杀后接着做)
| 需求 | 状态 | 实现与验证 |
|---|---|---|
| 关掉窗口不等于退出 | 已完成(第 46 批) | QuitMode::Explicit + on_window_should_close:run_in_background(默认开,设置页可关)且托盘装上了就 ShowWindow(SW_HIDE) + 返回 false,窗口不死、调度线程照跑;托盘没装起来或用户关掉那条时,关窗口就是退出(绝不藏起一个回不来的窗口)。--silent 让窗口以 show: false 建出来 |
| 托盘图标 | 已完成(第 46 批) | Shell_NotifyIconW 挂在自己的消息窗口上(独立线程 + 自己的消息循环:gpui 的窗口过程拿不到 WM_APP);右键菜单「打开主界面 / 退出」,双击也打开。显示走 ShowWindow(SW_SHOW) + SetForegroundWindow——activate_window()(SW_RESTORE)拉不回被 SW_HIDE 藏起来的窗口,这是真机实测踩到的 |
| 单实例 | 已完成(第 46 批) | CreateMutexW(Local\RWork.SingleInstance),且在打开数据库之前取得锁(否则第二个实例会把第一个实例正在跑的记录标成中断);第二个实例投一条消息让老窗口回来,然后自己退出 |
| 开机自启 | 已完成(第 46 批) | 设置页「后台与启动」里一个开关,写 HKCU\...\Run 的 RWork = "<exe>" --silent;关掉即删除。事实唯一来源是注册表(AppSettings 不存副本);写失败不显示为已开启 |
| 被杀之后接着做 | 已完成(第 46 批) | 每轮任务在 task_run 之外再写一份 run(kind='schedule',同 id)+ 每步一条 run_step;启动时 mark_stale_runs 把残留标成 interrupted,resume_interrupted 捞最新那条接着做(复用原会话、提示词写明停在哪一步) |
| 真机验证 | 已完成(第 46 批) | docs/08-端到端测试结果/端到端测试结果报告-files/verify-tray-and-instance.ps1 22 项 PASS(托盘/单实例/关窗进托盘/从托盘回来/静默启动/不碰用户日志);docs/08-端到端测试结果/端到端测试结果报告-files/verify-unattended.ps1:预置一个已到点的任务后以 --silent 启动,日志出现 定时任务到点,开始执行 且库里落一条 task_run。没做:托盘菜单的鼠标点击(代码路径用 WM_COMMAND 走通了,真人点那一下没做)、重启一次看自启(写了真注册表项并删干净,重启那一步没等) |
三、[未做] 或 [部分]
按重要性排列。这一节是本文的重点——没说清的完成度等于没有完成度。
3.0 滚动条那一处的「已滚动状态」没有肉眼验证 [部分]
现状:成因与修法在代码层面是确定的——gpui 把滚动容器的每个子节点都平移
scroll_offset(gpui-pre 的 elements/div.rs,不区分绝对定位浮层),因此把
浮层放在滚动容器里必然跟着内容跑;修完的结构与 gpui-component 的 List
一致。程序能起、页面能画、滚动条画在内容区右缘(截图确认),七处调用点同源。
没验证的是:滚动之后滚动条是否仍留在原位。要看到这一幕得让窗口滚起来,
而本轮从外部注入的鼠标事件(SetCursorPos + mouse_event 滚轮,含先制造 hover
的位移)在 GPUI 窗口上没有生效——前后两张截图的像素完全一致。这与
「栏位可拖动」那条记录的困难是同一个(合成鼠标输入进不去)。
第 45 批更正一处措辞:进不去的是从外部往真实窗口注入。进程内的测试窗口可以——
gpui-kit的test-support里TestWindowExt::click会按元素 bounds 合成平台鼠标事件 (第 45 批的审批弹窗点击测试就走这条路,见docs/06§3.5)。因此「鼠标注入做不到」 只用来解释真机肉眼验证这一侧的困难,不再用于解释「测试里点不到按钮」。
验证方式(建议):打开任意长页面(对话或用量审计)滚一下:修好后滚动条应 始终贴在内容区右缘、滑块随滚动下移;修好前它会跟着内容一起往上跑、最后被裁掉。
3.1 定时任务触发侧(线程 / 到点查询 / 漏跑判定)[已完成](第 46 批)
现状:调度循环(到点查询 → 组装请求 → 工具循环 → 落库)代码完整,
但它没有集成测试(crates/rwork-integration-tests/tests/ 里没有任何 scheduler 用例),
我也没有真实等到一个任务到点执行过。
第 31 批的进展:执行侧已经端到端了。scheduler::end_to_end 里的用例真起
一个脚本化的 OpenAI 兼容服务,跑真实的 run_scheduled → execute_inner,断言:
六轮请求(五轮工具 + 一个结论)、工具真的被执行、被压掉的工具输出换成 local://
引用且那条引用指向的文件真的躺在 .rwork/<run>/context/ 下、账本各落一行、
task_run 收成成功。仍然没有覆盖的是触发侧:调度线程本身(30 秒轮询、
due_tasks 查询、auto_run_is_due 的迟到判定、skip_missed 的「错过不自动补跑」)
——那一段只有 auto_run_is_due 的单元测试。
风险:触发侧的失败不会以报错的形式出现,只表现为「任务该跑却没跑」或者 「刚打开应用就自己跑了一轮」,而前者的现场只有日志里那一行。
第 46 批做完了,按上面那条建议的形状:轮询间隔与迟到宽限收进 Cadence
(生产 Cadence::production() = 30 秒一轮 + 一个周期的宽限,测试传几十毫秒并跑几轮就收工),
auto_run_is_due 收下宽限参数,spawn_with 把线程句柄交回来。两条用例都起真轮询线程、
真 HTTP、真 SQLite:
the_trigger_loop_fires_a_task_that_is_due:间隔型任务、next_run_at在 5 秒前 → 库里多一条task_run(ok)、模型真被调用、next_run_at推到将来;the_trigger_loop_skips_a_long_overdue_cron_task:cron 型迟到 6 小时 → 零执行记录、 零模型调用、last_run_at没动,并用界面那个纯函数Schedule::missed_runs确认 这一次算得出「错过」。- 反向验证两个方向都做过:判定改成恒真 ⇒ 第二条红;改成恒假 ⇒ 第一条红。
3.2 侧栏拖动改宽未做交互验证 [部分]
现状:拖动逻辑(按下点 + 当前位置,不用累加以免丢帧漂移)与钳制都有单元测试, 6px 把手的位置也对,但没有用真实鼠标拖过。
原因:合成鼠标消息到不了 GPUI 的命中测试(WM_MOUSEWHEEL 能送达,点击与拖动不能)。
这是环境限制,不是实现缺陷。
第 45 批更正:这条只对从外部往真实窗口注入成立。进程内的测试窗口可以用
TestWindowExt::click合成平台鼠标事件(第 45 批的审批弹窗点击测试即是)。要闭掉本节, 差的是「真实窗口 + 真鼠标」这一侧的验证手段,而不是「按钮点不到」。
3.3 OCR 模型 [已完成]
第 5 批要求「PDF 图片识别调用 OCR 模型;模型文件放到和数据库一样的地方」, 第 6 批推翻了「内置格式解析」这个方向(Office/PDF 改为给路径让模型自己写脚本), OCR 随之一起被删掉。本轮按用户的澄清重新做回来,用的是百度那个模型:
- 模型:百度 PP-OCRv6 tiny 的 det(1.8MB)与 rec(4.5MB),官方直链给的就是
ONNX 格式,不需要自己转;字符表(6904 项)与后处理阈值都从包里的
inference.yml读,不把数字写死在代码里(换了模型版本这些都会变)。 - 运行时:onnxruntime 1.20.1 的 dll。
ort用load-dynamic:构建期不下载、 不链接(它的默认 feature 在本机编不过),缺 dll 时给的是可读的加载错误。 dll 从 PyPI 的onnxruntime轮子里取(11MB),而不是微软 NuGet 的 108MB 全平台包;主源是国内镜像,备源是 PyPI 官方,两个文件的哈希都钉住。 - 拿法(用户要求「和内置向量模型类似」):用到时按需下载(点发送时发现 没装就先下,下完提示再发一次)与设置页手动下载,都会显示进度与结果; 装完逐个文件校验哈希,缺文件与「文件不对」分开报告。
- 用法(用户要求「按模型是否支持多模态来调用」):当前模型支持多模态就按
图片块发;看不了图时图片先过本机 OCR,发出去的是识别出的文字。
这件事在清单里写明(
attach.manifest.ocr),否则模型会以为它看到了原图。 - 已验证:
crates/rwork-ocr20 个单元测试(配置解析、BGR/尺寸、DB 后处理、 CTC 解码、清单校验);真图 900×320 五行中英混排 33ms 全对;真机走完整链路 (文本模型 + 剪贴板图片 → 请求体里是识别出的五行文字);清空模型目录后 点发送会先下载再提示。
已知限制:运行时只接 Windows x64;识别的是图片文件(PDF 里的扫描页
要先用脚本渲染成图,那条路走 run_script,符合第 6 批的分工)。
3.4 数据权限按系统与用户权限动态决定 [未做]
第 2 批的诉求:「对于数据权限来说,根据系统权限和用户权限来动态处理范围」。
现状:已有 Capability(系统许可与用户策略正交)与工作空间分层授权,
但没有接操作系统的实际权限(如 Windows ACL、只读文件属性、管理员身份)。
只做了「只读文件 → 升级为确认」这一条。
要做的工作:读取目标文件的 ACL / 属性,据此决定范围。这需要一个 Windows 专有的探测层,且要处理「同一文件在不同用户下权限不同」的情况。
3.5 小模型在线学习 [已完成]
第 2 批的诉求:「可以设计一个小模型或算法模型来通过在线强化学习来在数据库中获取数据来训练和处理」。 用户后来给出了具体的奖励信号定义:
奖励信号通过对话过程或者是通过无监督分类进行分类,若相似度超过 60% 就算做一样的情况 可以通过,除非通过对话要求拒绝,或者是强制终止。
实现(rwork-core/src/learning.rs,纯函数,17 个测试):
- 奖励信号来自对话过程:点同意 / 点拒绝 / 对话里明确说不要 / 强制终止。
- 无监督分类:相似度 ≥ 60% 归为同一类(
similarity显式给出分数与依据)。 - 拒绝与终止是一票否决:不给计数,直接标记,统计上的多数压不过它。
- 没有历史时的拒绝也留下(「这个目录绝对不要动」常常是第一句话)。
没做梯度更新,这不是遗漏:奖励信号是离散的计数而决策是单步的, 计数统计与策略更新在这里等价,而前者可解释——界面上能说清 「为什么这条被自动放行了」,不必去查一组权重。
3.6 代码关系图扫描 [未做]
第 2 批的诉求:「对于代码来说,可以根据代码关系图视图扫描和用户确认来处理」。
现状:[部分] 有 refs 工具(引用扫描)与 intent::named_paths(从你的话里解析
点名的文件),能回答「谁引用了这个符号」。但没有构建可视化的关系图,
也没有「按关系图决定影响范围」的自动授权。
要做的工作:一个轻量的符号索引(解析 use/mod/函数签名),
据此算出改动的影响面。这是一个独立的子系统,规模不小。
3.7 内置角色不进设置页的「内置」分区 [部分]
现状:内置角色与用户自建的混在同一个列表里,用户看不出哪些是内置、哪些是自己建的。 「恢复内置」按钮也还没做(现在要恢复官方版本得手动删掉同 id 的影子副本)。
要做的工作:列表里把两者分开计量/分组,影子存在时标注「已改(内置已更新)」, 并加一个「恢复内置」按钮。
3.8 任务编辑 [已完成]
本轮补上:任务行有「编辑」按钮,载入表单后保存会更新同一条 (沿用原 id 与「上次执行时间」——换 id 会丢执行历史,清掉 last_run_at 会让补跑判定以为从没跑过)。cron 会反解回图形化控件。
3.9 执行历史展示 [已完成]
本轮补上:任务行有「历史」按钮,展开显示最近 20 次执行——计划时刻、 结局(成功绿 / 失败红 / 进行中黄)、耗时、模型、结果开头一段。
3.11 记忆系统与本地小模型 [进行中]
用户给了一份记忆系统设计草案(分层 L0–L7 + 全量原始事件 + 临时空间 + 意图变化时二次提纯生成意图视图 + 小模型选择压缩、大模型推理兜底), 要求 RWork 按它落地。草案是 Postgres + FastAPI + Python 的形态。
它现在是一份独立文档:docs/10-实验性质功能/记忆系统与本地小模型.md
——方案、已经核实的事实、以及逐层的「设计 vs 实现」盘点都在那里。
之所以放进「实验性质功能」是用户明确要求先不推进:其中的实验性质内容
还要再想,特别是与 local:// 落盘引用的集成方式(那份文档的 §10)。
本节与 §3.12 只留状态与指针,不再复制内容——复制出来的第二份一定会先腐烂。
- 已经在跑的(需求层面见 §2.7,界面见
FEATURES.md):分层存储 (memory+memory_fts+memory_vector+memory_vec)、candle 本地嵌入、 二值量化粗排 + 完整向量精排、强/中/弱三档注入、没模型退关键词、 手动与自动整合、孤儿向量的计数与清理。 - 已经落地的 L3 那一半:类别(fact / decision / todo)与出处 (这次提炼实际读过的消息区间)入库,请求时按类别组装成 「已经定下的 / 接下来要做」一节——不看相似度,因为「三个月前定下的方案」 跟今天的问题常常一个词都不重合,而它丢出提示词的表现就是模型把已定的方案 当选项重新问一遍。明细与验证见 §2.7。
- 还只是方案:意图变化时的二次提纯、按置信度路由大小模型、 显式的预算分配、抽取式压缩、大模型兜底、多意图并存与回切、评估指标。
- 前置条件:意图链路的端到端没通(算法够了,是接线问题)—— 一个自己都不工作的模块不该成为别人的地基。
3.12 记忆系统:设计 vs 实现(现状盘点)
已移出:逐层对照表与分期规划在
docs/10-实验性质功能/记忆系统与本地小模型.md §9。
留在本节只会变成与那份文档互相漂移的第二份盘点。
3.13 执行计划:带条件的 DAG [已完成]
用户提的机制:拆出步骤清单 → 依赖构成有向无环图 → 按图执行 → 下一步之前 审计上一步的结果(判据就是分解时写下的输入/输出条件)→ 上游有问题就 回滚到最早出问题的那一步重做 → 做完为止。
存哪:plan(实例)+ plan_node(节点:声明顺序、依赖、输出条件、产出、
副作用标记、状态)+ plan_attempt(每次尝试一行,只追加)。
不塞进 run / run_step:那两个是「一次回合跑了什么」的审计流水,
而这是执行计划(状态会被改写,还要回答「现在轮到谁」)。
条件必须可判定,只用四种写法(都在 rwork_core::plan::Condition 里,
读文件一族):
| 写作 | 判据 |
|---|---|
{"kind":"file_exists","path":"…"} |
文件存在 |
{"kind":"file_non_empty","path":"…"} |
存在且有内容(file_exists 看不出的那种失败) |
{"kind":"file_contains","path":"…","text":"…"} |
里面有这段文字 |
{"kind":"json_has_field","path":"…","field":"a.b"} |
是 JSON 且有这个字段 |
刻意没有「跑一条命令看退出码」:那等于让模型决定执行什么程序,而计划里
已经有一整套工具与审批链在管这件事。路径必须落在工作区内——审计是绕过审批链
的一次静默读取,限制在这里不误伤正常用法(产出物本来就该落在工作区)。
拆不出可判定条件的步骤,计划直接不成立(PlanError::Uncheckable),
如实说明并退回「按顺序做」的清单——比一个判不了的计划好。
审计只看条件的真假,不问模型「你觉得做完了吗」——后者会让回滚变成随机行为。
决策(下一个动作是什么)做成纯函数 plan::next_action(plan, policy, audit):
审计结果由调用方喂进去,因此「回滚到哪一步」「什么时候该放弃」这两件最难在
真实环境里复现的事,可以在单元测试里把各种时序都走一遍(13 条测试,覆盖顺序
推进、最早失效节点、回滚留痕、重试上限、震荡上限、副作用先问)。
回滚语义(最容易写错的地方):
- 目标 = 最早那个「已完成但输出条件不再成立」的节点,不是最近的;
- 受牵连的子孙(直接或间接依赖它的)一起退回重做——下游的产出建立在那份 失效的产出上,只重做目标等于让下游拿着错误的前提继续;
- 不抹历史:重试与回滚都是新增一行(
plan_attempt), 回滚那一行带着原因,事后能回答「它为什么又跑了一遍」; - 两个上限:单节点尝试次数(默认 3)、总回滚次数(默认 4)。缺一个都会 转不完——前者挡「这一步就是做不成」,后者挡「两个节点互相改坏对方的产出」。 到顶如实报告(哪一步、哪条条件、试了几次),不假装完成;
- 有副作用的节点(推送、发布、覆盖文件)在「已经不是第一次跑」时先问用户:
计划停下(
NeedsDecision),用户的下一句话按关键词判「继续/不继续」 (纯函数,认不出来就作废并说明)。这里不能靠模型猜——误跑一次推送收不回来。
接进回合循环:拆解仍是模型的一次结构化输出(多一次请求,只在输入看起来像
多件事时发生,见 looks_multi_issue);执行交给现有的回合循环——每一步就是
「一次工具调用 + 一次审计」。计划的当前进度渲染进系统提示(哪一步、要满足什么、
不要跳步),审计插在工具结果回填之后、下一次请求之前。
端到端验证(真实实例 + 本地 mock,.verify/gui/scenario-plan.json):
第 1 步写出 report.md → 条件成立、进入第 2 步;第 2 步的工具把 report.md
清空 → 审计发现上游的产出不成立,回滚到第 1 步并连带第 2 步;重做后两步
依次通过。库里的尝试历史正是那五次事件(passed / unsatisfied /
rolled_back / passed / passed),plan.rollbacks = 1。
有副作用的步骤走通了「先问用户」(真实实例 + mock,scenario-plan-ask.json):
第 1 步「推送到远端」(side_effects: true)通过后,第 2 步把它的产出清空 →
退回最早失效的那一步 target=push invalidated=["push","release"] →
计划:需要用户先拿主意 node=push(这一步已经跑过、重跑有副作用)→ 计划停在
needs_decision。用户回一句「继续」→ 用户同意继续,计划重新推进 → 重做第 1 步 →
两步依次通过。库里仍是五行历史、rollbacks=1。
界面:对话右侧留白里加了「执行计划」入口(与「回到我发的话」「这一轮的统计」
同一组),面板显示目标、整体状态、每一步的状态与完成条件、每次尝试(含退回原因)
与回滚次数。数据从库里读(Store::latest_plan_of),因此重开应用后仍能看到上一轮
停在哪一步。实测:面板上逐行显示 ✓ 1. 写出报告草稿 / 文件 report.md 非空 / 第 1 次:通过 / 退回重做:文件 report.md 是空的 / 第 2 次:通过 与第 2 步的两条
尝试,右上角 已完成 · 共 5 次尝试 · 回滚 1 次。
两个上限进设置,历次拆解进核心行为页(第 26 批):
- 上限此前只存在于
plan::Policy::default()(单步 3 次、总回滚 4 次), 界面上没有地方能改。现在它们是AppSettings里的两个字段,设置页 「模型服务」分区多一张「执行计划」卡片;AppSettings::plan_policy()把它 交给执行器(advance_plan里不再写Policy::default())。 - 核心行为页新增「执行计划」区块:按作用域列历次拆解(
Store::plans_visible_in), 每行是目标、状态、进度、回滚次数、作用范围与最近更新时间。它与对话右侧那个 面板分工明确——那个只看当前这次(Store::latest_plan_of)的每一步与 每次尝试,这个回答「上次让它拆的那件事做到哪了」。
流水线里也有执行计划(第 26 批,取代了原来的「已知限制」):
- 一份计划,整条流水线共用:计划在内存里被「这一棒跑完交出来、下一棒接着拿」,
落库一直是同一行
plan。 - 审计仍在每一棒自己的回合循环里(同一段
advance_plan,同一套只看条件 真假的判据),因此「谁审计」这个问题不用另立机制。 - 退回按棒号找人:每个节点记着上一次是谁做的(
PlanNode.owner,落库一列 JSON)。退回到别的棒做的产出上时,本棒立刻结束回合并把序号拨回那一棒—— 判据是「上一次是谁做的」这个可观测事实,不问模型「该谁重做」。 退回之后要重做的后续步骤(被牵连的子孙)同样交回给当初做它的那一棒, 于是整条链路按棒号回流,而不是让某一棒替别人写一半。 - 来回交棒不会无限:每次交棒都由一次回滚触发,而回滚总数受设置里的 「总回滚上限」约束。
- 停下的计划也能接着跑:计划停在「等你确认」上时,用户的「继续」在第一位 那里生效,并按节点上的棒号直接从那一棒接着做,不从第一位重跑。
真机验证(真实应用 + mock,.verify/gui/scenario-team-plan.json,两位内置角色
「代码实现」「报告成文」):第 1 棒写草稿 → 过了 → 第 2 棒作图时把草稿清空 →
审计发现上游失效 → 日志 退回给做这一步的那一棒 member=1 name=代码实现、
本棒交棒,结束这一棒的回合 member=2 → 流水线 拨回序号重做 from=2 to=1 →
第 1 棒重写草稿(从停下的那一棒接着做 member=1)→ 图表那一步是第 2 棒做的,
于是又交回给第 2 棒 → 第 2 棒重做图表 → 全部步骤通过条件核对。
库里 plan.state=done、rollbacks=1,两个节点的 owner 分别是第 1 棒与第 2 棒,
尝试历史正是那五次事件(passed / passed / rolled_back「文件 report.md 是空的」
/ passed / passed)。
顺带修掉两个让流水线跑不起来的缺陷(同一批里发现):
resolve_team只查settings.agents,而内置角色从不写进那里——从列表里选了 「代码实现」发出去会得到「找不到这个智能体」。现在查available_agents(内置 + 用户预设)。ensure_conversation给每一位都插一次用户消息(撞message.id唯一索引), 于是第二位永远开不了跑;而退回后重入第一位时,连第一位自己也会撞停自己。 现在整条流水线只插一次。
已知限制(写下来免得被当成漏了):
- 拆解仍由
looks_multi_issue这个启发式触发:没有任何分项/并列/递进信号的 一句话多步任务不会拆(代价是多一次模型往返)。这是有意的取舍,不是遗漏。 第 24 批放宽了一处:带圈数字(①…⑩)不再要求出现在行首——用户那句 「请帮我分析 ① 这个文件,…请帮我编制一个设计文档,这个文档 使用 word 来编写。」 就是这种形态,此前被判成「一件事」,整轮没有计划。第 26 批又认了几种中文写法 的分项编号(1、(1)一、一是…二是)与几个递进词(首先/其次/再次/接下来), 判据仍是「只在行首或分句开头认编号」,因为句子中间的数字与小数 (「把 3.14 改成 2.72」)不是分项。
3.14 无人值守 / 自我验证 / 感知与操作 [未做]
第 44 批回答「离桌面端通用 AGI 工具还有多远」时,把现有的能力轴逐条过了一遍 (对话闭环、工具面、计划自纠、记忆、权限审计都已有实测支撑)。缺口集中在四条, 都属于「无人在场也能把活干完」这个具体目标,与模型能力无关的那两条是这个仓库 自己能缩短的部分:
无人值守已完成(第 46 批):关窗口只是把界面收进托盘、进程继续跑 (托盘图标提供「打开主界面 / 退出」,--silent供开机自启静默启动,单实例避免两个 进程各跑一遍任务);每一轮任务写一份run(与task_run同 id)+ 步级链路,进程被杀时 下次启动会接着做一次(同一段会话,提示词里写清停在哪一步)。验收两条都有证据: 真机--silent启动(没有任何窗口)下一个已到点的任务真的被触发(日志定时任务到点,开始执行+ 库里一条task_run);an_interrupted_run_is_picked_up_and_finished跑通「被中断 → 接着做完」。 没做:docs/10设想的job表仍然不存在(没有通用的任务队列,只有「定时任务这一轮」的续跑)。- 自我验证:交付物收尾前只有「计划里声明的文件条件」(存在/非空)在管, 没有「把产物真的跑一遍/解析一遍」的独立校验环。验收:交付代码或文档时收尾前有 一次可执行的校验,失败就退回计划节点。
- 感知与操作别的应用:没有截图工具、没有键鼠控制、没有浏览器驱动,只能动文件
与 shell。它牵动权限模型(「能操作桌面」要重新划边界),因此第一步是**只读的截图
- 视觉解读**。
- 并行(已在 §3.13 与 ADR-06 记过):worktree 隔离是多智能体并行的前提。
3.10 其余
原本列在这里的「定时任务不接 MCP」已在本轮完成(MCP 接进了两条路径)。
四、验收标准
| 层面 | 方式 |
|---|---|
| 单元测试 | cargo test --workspace,1020 项通过 / 2 项按设计忽略(第 46 批重跑;不含第三方副本 gpui-base 自带的 898 项) |
| 静态检查 | cargo clippy --workspace --all-targets,要求 0 警告 |
| 中文注释 | cargo test -p rwork-integration-tests --test convention,要求 0 违规 |
| 真实协议 | tests/end_to_end.rs:真实 HTTP + 真实 SQLite |
| 真实 MCP | tests/mcp.rs:对着真实 MCP 服务器进程 |
| 真实运行时上下文 | tests/runtime_context.rs:用非 tokio 执行器 poll,断言无上下文时 panic、enter() 后成功 |
| 真实模型 | tests/live_provider.rs(默认 #[ignore],需联网与密钥):11 项,含读图、读 skill、让模型自己写脚本读表格、多问题依赖排序 |
| 界面 | 截图确认(PrintWindow + 定时改动尺寸规避首帧空白竞态) |
测试的取舍:只写在能防住真实缺陷的地方。因此有一条有意例外——
convention 断言源码文本而非行为。一般不该这么做,这里是有意为之:
一条只能靠人肉遵守的要求等于没有要求。
五、已知的工程约束(会影响后续设计)
- GPUI 的异步执行器不是 tokio,而
cx.spawn的 future 在主线程上被 poll。 因此主线程必须始终处在 tokio 上下文里(runtime.enter()),否则任何reqwest/tokio::time调用都会 panic 而不是返回错误。 集成测试抓不到这类问题(它们跑在#[tokio::test]里)。 - 窗口输入要用真实的系统注入(
SetCursorPos+mouse_event):PostMessage造的合成消息到不了 GPUI 的命中测试(滚轮能到),但真实注入可以 ——点击与拖动都能自动化,第 13 批的权限浮层、Ctrl+C兜底、产出物卡片都是这样验的。 两个要点:窗口矩形含 8px 边框与约 31px 标题栏,而截图取的就是整窗,因此截图坐标加上 窗口左上角即是屏幕坐标;SetForegroundWindow常常第一次就失败,要用一次 ALT 敲击解锁并重试, 否则按键会打进别的窗口。 - 同一帧里的元素 id 必须唯一,而
tool_call_id不保证唯一。把块的元素 id 直接写成tool-{call_id}时,一旦两块会话复用同一个调用 id(提供方可以这样做,我们自己的模拟服务就每轮都用同一个),后一块会整个渲染不出来——用户看到的是「工具块不见了 / 一直停在准备执行」。元素 id 里要带它在列表中的下标;同理,回放历史时「谁的结果」必须配给最近那个同 id 的调用(从后往前找),否则第二轮的结果会贴到第一轮的块上。 - 管道的读取必须与等待并行:
wait_with_output会在进程退出后继续读管道,而管道写满会阻塞子进程——两件事凑在一起就是死锁。因此 stdout/stderr 各一个读取线程(rwork-tools/src/stream.rs),主线程只负责轮询与超时 kill。超时那一支只 kill、不 join:Windows 上孙子进程继承着管道写端时,join 会一直等下去,那就把「超时」变成了第二次卡死——而超时的全部意义就是立刻返回。 - 不要在界面线程上调
ShellExecuteW:它会替被打开的程序跑一段消息循环(等窗口、DDE 协商),于是 GPUI 重入自己——实测点一下产出物卡片,日志里每帧一条RefCell already borrowed,几百毫秒后整个应用 panic 退出。放到background_executor上执行,结果再回主线程报。 同类危险的是所有「会替别人跑消息循环」的 shell API。 - 权限引擎必须是纯函数:只根据可观测输入决策,不做 IO、不调用模型。 任何「让模型判断是否安全」的写法都不接受。
- 界面线程不做 IO:查询走
spawn_blocking,HTTP 走 tokio 任务。 - 注释与说明一律中文(硬性要求,有自动检查)。
六、如果要继续,建议的顺序
按「用户可感知的完整度」排:
- 定时任务的端到端测试(3.1)——把运行时链路钉住,它是唯一一块没有测试覆盖的执行路径。
- 内置角色的「内置」分区与恢复(3.7)——改了内置角色后目前没有回到官方版本的路径。
- 数据权限接系统权限(3.4)——见文末需要确认方向的第二条。
需要你先确认方向的:
- 数据权限接系统权限(3.4):要不要读 Windows ACL?这会让权限决策依赖运行环境, 而当前引擎是纯函数——接进来需要重新定义「纯」的边界。
- 小模型在线强化学习(3.5):需要先定义奖励信号,否则统计学习已经覆盖了「从数据库学」。