diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index ab0699e..f5d533b 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -184,7 +184,7 @@ 库规则的种类用规则给的图标(`KindBadge.icon`),跟在语言的种类图标后面:SQLAlchemy 数据表 `database`、数据类 `package`、Pydantic 数据模型 `braces`(和类型块同一个图标,颜色不同)、Celery 应用 `server-cog`、定时任务 `calendar-clock`、FastAPI 接口 `globe`、FastAPI 路由器 `network`、React 组件 `atom`、状态 `toggle-right`、引用 `anchor`、数据源 `download`、派生值 `sigma`、副作用 `zap`、事件处理函数 `mouse-pointer-click`、渲染输出 `layout-template`、serde 可序列化 `braces`、thiserror 错误 `triangle-alert`、axum 接口 `globe`、sqlx 查询 `database`、tauri 桌面命令 `square-function`。数据类写了 `frozen=True` 再加一把锁(`lock`,悬停「不可修改」)。实现块再由规则 `rust.impl` 换成卡片,左边因此有两个「实现」图标。 这组图标由插件自己定义,是一个封闭的集合:契约里的 `KIND_ICON_NAMES`(上表 26 个——命令、管道、执行共用一个,解释器、解析指令共用一个,选项、设置、编译属性共用一个,字段、标签共用一个——加库规则用的 14 个(React 的组件、状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出的 8 个也在里面),lucide 的 kebab-case 名字),类型 `KindIconName` 是它们的联合,概念的 `icon` 只能写其中一个,写别的是类型错误。内置种类 → 图标名在 `contract.ts` 的 `KIND_ICONS`(宿主写进概念列表)。webview 给每个名字配一个逐个 import 的 lucide 图标组件(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`,打包只带这些,预览脚本约 157 kB);webview 的 `KindIconName` 照契约手抄,`kindIconNames.test.ts` 核对两边一样。规则要新图标就把名字加进这组、在 webview 配上图标,没有按任意名字找图标的路。 -- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`)。最前面是「按注释规范重写」(注释图标,块还没有注释时叫「按注释规范写注释」;不能写注释的块没有,见「重写注释」),然后是「问 AI」(sparkles 图标,点开 / 收起块下面的问答区,展开时带按下的底色、`aria-expanded`;文件不在工作区文件夹里时没有,见「问 AI」),再是「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类图标同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。 +- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`)。最前面是「按注释规范重写」(注释图标,块还没有注释时叫「按注释规范写注释」,重写过、记录收着时叫「展开重写记录」;不能写注释的块没有,见「重写注释」),然后是「问 AI」(sparkles 图标,点开 / 收起块下面的问答区,展开时带按下的底色、`aria-expanded`;文件不在工作区文件夹里时没有,见「问 AI」),再是「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类图标同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。 - 悬停提示(整个插件的网页只走这一条路,预览和看板共用同一个组件):带提示的元素只写 `data-tooltip="提示"`(名字照旧写在 `aria-label` 里,只有图标的按钮必须有 `aria-label`),页面根上挂一个 `TooltipHost`(`webview-ui/src/components/ui/tooltip.tsx`)统一弹,不用原生 `title`;提示里的换行照样换行(看板摘修复弹框里「迁移」列出的几个文件一行一个);注释、回答里 Markdown 链接的标题(`[文字](网址 "标题")`)也写成 `data-tooltip`。网页的 TSX 里写原生 `title` 属性是 lint 错误(`vscode/eslint.config.mjs`),只有 `iframe` 这种必须带标题的元素例外,组件自己叫 `title` 的属性不算。原因:macOS 上 VS Code 带的 Electron(1.139 是 Electron 43)只在鼠标压着带 `title` 的元素自身边缘时才弹原生提示,停在它里面的图标、文字上不弹(electron/electron#49843,Electron 38 起),种类图标 20px 的框几乎被 16px 的图形占满,基本弹不出来;样子也是系统的。样子照 VS Code 的悬停框:`--vscode-editorHoverWidget-background` 底、`--vscode-editorHoverWidget-border` 边框、`--vscode-editorHoverWidget-foreground` 字、`0 2px 8px --vscode-widget-shadow` 阴影,11px 字,圆角 4px,最宽 22rem;优先在元素上方,上方放不下翻到下方,水平对着元素中间,贴边留 8px,不接鼠标。鼠标停 500ms 才弹,在同一个元素里移动不重新计时,已经弹着时移到另一个带提示的元素上立刻换;移开、移出页面、按下鼠标、按任意键(含 Esc)、滚动就收,收了之后鼠标不离开这个元素不再弹。键盘把焦点移到带提示的元素上(`:focus-visible`)立刻弹,焦点离开就收;提示不是元素自己的名字(`aria-label` 或它的字)时用 `aria-describedby` 挂到元素上,读屏当描述念。 - 种类图标和底色按种类区分(颜色见「颜色」);文件开头的说明(种类「注释」)虚线框。 - 「注释」一行:讲这块的普通注释不单独成块,是这张卡片上的一行(见「注释挂到它讲的块上」)。属性表卡片(语言的、翻译插件排的、库规则的卡片都一样,宿主统一插,规则、翻译插件不用管)插在第一行(名称这类讲它是谁的一行)后面,有说明(值是 Markdown 的那一行)时插在说明后面,所以是「名称、说明、注释、别的」;一行译文的卡片(条件、循环、语句、调用、返回……)排在译文、摊开实参的表、说明的下面,同属性表的一行(左边「注释」、右边 Markdown)。名字是翻译插件给种类 comment 起的名字(中文直译「注释」)。几段注释(中间隔着空行、或前后两处)各是一段 Markdown,照源码顺序,空行隔开。按「注释的 Markdown 排版」显示(仓库路径链接、不带路径的引用、仓库图片都照常能点),标差异时同属性表的行逐词标。这一行上不放按钮,「按注释规范重写」排在卡片右上角那排按钮里(见「重写注释」)。 @@ -605,11 +605,11 @@ - 选项是插件的 profile 目录(同看板、会话:`cc/profiles` 的 `listClaudeProfiles`,名字是文件名去掉 `.json`)。插件的默认 profile(`getDefaultProfilePath`,`offical.json`)在里面时,没选过就选它;不在(一个 profile 都没有,或没有 `offical.json`)时最前面多一项「默认」:不带 `--settings`,claude 用 `~/.claude` 的登录,和以前一样照问,没选过就选它。 - 选了从下一个问题起用它(正在答的不受影响)。所有预览、所有块共用一份,记在扩展的 `globalState`(`superpowers.codeReader.askProfile`,记 profile 名,「默认」记 `''`)里:换个预览、重启 VS Code 都还是它;记着的那个没了(删了、目录换了)就回到默认那项。 - 没展开、但问过的块:块下面一行灰字「问过 N 次」,下面默认摊开灰字历史(能展开、能删),读的时候一眼看到这里问过什么;点这一行收起历史、再点摊开,箭头跟着换;没有输入框。 -- 收起就结束这一轮:这一轮答完的都回到灰字历史里(再问照样接着会话,见「追问」);还没答完的接着答,答完照样存。同一块同时只答一个问题(正在答时不能再发),不同块可以同时问,全插件同时最多 3 个,再多就在这一问下面说「同时最多问 3 个问题,等前面的答完再问」。 +- 收起就结束这一轮:这一轮答完的都回到灰字历史里(再问照样接着会话,见「追问」);还没答完的接着答,答完照样存;出错、被叫停没存的那一轮扩展端也不再留着。同一块同时只答一个问题(正在答时不能再发),不同块可以同时问,全插件同时最多 3 个,再多就在这一问下面说「同时最多问 3 个问题,等前面的答完再问」。 - 追问:这块存过问答,再问就接着最近一次(按问的时间)存下的那条的会话(`--resume <会话 id>`,会话 id 取 stream-json 的 init 消息,和问答一起存在 `code-qa.json` 里),只发问题和「还是第几行那块」;收起再展开、预览重新加载、重启 VS Code 都一样接得上,前面问过的照旧灰字显示。换了 profile 也照样接着那次会话问(`--resume` 和这次选的 `--settings` 一起带,本机 claude 2.1 试过:换 profile 后会话 id 不变、记得前面说过的),线程里不另外提示。那条没记会话 id、或会话找不到了(过期、被清理)就按第一次那样带上代码上下文重新开会话。删掉最近那条,就接着剩下里最近的;一块下面删光了就是没问过。 - 删除:点垃圾桶马上删,不用确认;一块下面删光了这块就不挂了,一份文件删光了文件里就没有这份文件。那条带的图片文件一起删。 - 找不到原来位置的问答:记号(见下)对不上这份文件里任何一块的问答不显示,也不动它,留在 `code-qa.json` 里;代码改回来、记号又对上了,就又挂回那块下面。 -- 预览重新加载(标签页切走再切回来)后,扩展端还在答的问题接上(展开它那块、接着长),答完照样显示。同一份文件开在几处,问答、正在答的都一样。 +- 预览重新加载(标签页切走再切回来)后,扩展端记着的当前一轮接上(展开它那块):还在答的接着长、答完照样显示,出错、被叫停留着的照原样画出来(收起这块前一直留着)。同一份文件开在几处,问答、正在答的都一样。 怎么问(扩展端 `qa/`): @@ -635,14 +635,19 @@ ### 重写注释 -按写注释的技能(`/ecc:writing-code-comments`)给一块代码重写它的说明。没有说明、但块前面有普通注释时换掉那段;都没有就按这门语言的说明新写一段。 +按写注释的技能(`/ecc:writing-code-comments`)给一块代码重写它的注释。没有说明、但块前面有普通注释时换掉那段;都没有就按这门语言的说明新写一段。像问 AI 一样留会话、能接着说:块下面记着重写过的每一轮,在输入框里说修改意见,模型接着那次会话改,交回新正文就写回文件。 -- 按钮(`MessageSquareText` 图标,只有图标,悬停 / 读屏「按注释规范重写」,块还没有说明、也没有前面的普通注释时是「按注释规范写注释」):每块代码都有,不管卡片有没有「注释」一行都排在右上角那排按钮的最前面,依次是它、「问 AI」、「编辑」;文件开头的说明卡片也一样。正在重写时不能再点。定义卡片浮窗里、删掉的块没有;嵌着的代码里的块(Dockerfile RUN 里的 shell)、语言既没有说明的写法(`LanguageAdapter.freshDoc`)也没给行注释记号(`LanguageAdapter.lineComment`)、又还没有可换的注释的块没有(`BlockView.commentTarget` 没有)。 -- 改哪儿(`BlockView.commentTarget`,扩展端 `blockView.ts` 算):块已经有说明(Python 函数 / 类主体里第一条语句的 docstring、紧贴声明的 JSDoc、Rust 的 `///` / `//!`,以及别的语言记在块上的说明)时换掉这段说明的那几行,正文填回原来的定界符(`"""`、`/** */`、`///`),不在声明上面再插一行行注释;说明和块前面的普通注释都有时只换说明,普通注释留着。没有说明、块前面挂着普通注释时换掉它们(从第一段开头到最后一段结尾,几段中间隔着的空行一起换掉,写成一段);文件开头的说明卡片换掉它自己。都没有时新写:Python 的函数或类在主体里插一段 docstring(缩进同主体,作为第一条语句;声明和主体写在同一行时从主体那一列拆开);JavaScript / TypeScript 在声明紧上面插 `/** */`;Rust 在项紧上面插 `///`;只有行注释的语言(shell、Dockerfile)仍在块的第一行上面插一行行注释。挂在块后面、子块交上来的注释不动。 -- 问模型:同「问 AI」用 Claude Code 命令行、在这份文件所在的工作区文件夹里跑、用选的 profile、只读(`--tools Read,Grep,Glob`、`--allowedTools Read,Grep,Glob`、`--permission-mode dontAsk`、`--strict-mcp-config`:它能读那个文件,改不了,写回归扩展),从 stdin 发的只有一句:`/ecc:writing-code-comments <路径>:<起>-<止>`,路径相对 claude 的工作目录、用 `/` 分隔,行号 1 起,是这块代码的完整范围(连同挂在它上面的注释;文件开头的说明卡片是它自己那几行),别的不说(`commentRewrite.ts` 的 `commentRewritePrompt`)。文件有没保存的修改时不问(模型读的是磁盘上的文件),直接失败「先保存再重写注释」。 -- 结果按 JSON Schema 交(`--json-schema '{"type":"object","properties":{"body":{"type":"string"}},"required":["body"],"additionalProperties":false}'`):模型调 Claude Code 的 `StructuredOutput` 工具交结果,result 行的 `structured_output.body` 是注释正文(不带注释符号)。不读模型的自由文字:只回了一段话、`structured_output` 没有或 `body` 是空的,都算失败,文件不动。 -- 过程(`rewriteProgress`):问的那一句、正文逐字长出来(`--include-partial-messages` 时 `StructuredOutput` 的输入逐段来,按写到一半的 JSON 读出已经写出来的正文)、正在读什么(「读取 a.py」);块下面同问答区的样子显示(`RewriteThread`),能叫停。 -- 写回(扩展端,`WorkspaceEdit`,能撤销):换掉时套回原来那段的定界符和缩进(Python 三引号、`#` / `//` / `///` / `//!` 行注释、`/* */` 块注释,`refillComment`;几段隔着空行的照第一段的写法);新写时用这块说明的壳(docstring、`/** */`、`///`,或只有行注释的语言用 `#` / `//`)一行一行写,缩进同插入的那一行,空行写成只有记号的一行(docstring 的空行仍是空行)。写之前核对这块代码的那几行和问的时候一样,变了就不写(「这块代码在重写期间变了」);定界符认不出、编辑器没接受也不写。结束发 `rewriteEnd`(done / failed 带原因 / stopped),失败另弹一条错误提示。 +- 按钮(`MessageSquareText` 图标,只有图标,悬停 / 读屏「按注释规范重写」,块还没有说明、也没有前面的普通注释时是「按注释规范写注释」):每块代码都有,不管卡片有没有「注释」一行都排在右上角那排按钮的最前面,依次是它、「问 AI」、「编辑」;文件开头的说明卡片也一样。定义卡片浮窗里、删掉的块没有;嵌着的代码里的块(Dockerfile RUN 里的 shell)、语言既没有说明的写法(`LanguageAdapter.freshDoc`)也没给行注释记号(`LanguageAdapter.lineComment`)、又还没有可换的注释的块没有(`BlockView.commentTarget` 没有);文件不在工作区文件夹里时重写记录没地方存,也没有。点了按技能命令重写(开新会话)、展开这块的重写记录;展开着又正在写时不能再点。重写过、记录收着时它叫「展开重写记录」,点了只展开(好接着说),展开着再点才重写。 +- 改哪儿(`BlockView.commentTarget`,扩展端 `blockView.ts` 算):块已经有说明(Python 函数 / 类主体里第一条语句的 docstring、紧贴声明的 JSDoc、Rust 的 `///` / `//!`,以及别的语言记在块上的说明)时换掉这段说明的那几行,正文填回原来的定界符(`"""`、`/** */`、`///`),不在声明上面再插一行行注释;说明和块前面的普通注释都有时只换说明,普通注释留着。没有说明、块前面挂着普通注释时换掉它们(从第一段开头到最后一段结尾,几段中间隔着的空行一起换掉,写成一段);文件开头的说明卡片换掉它自己。都没有时新写:Python 的函数或类在主体里插一段 docstring(缩进同主体,作为第一条语句;声明和主体写在同一行时从主体那一列拆开);JavaScript / TypeScript 的函数、类、枚举、常量、变量在声明紧上面插 `/** */`;Rust 的项在紧上面插 `///`;别的(语句、Python 的变量,只有行注释的 shell、Dockerfile)在块的第一行上面插一行行注释。挂在块后面、子块交上来的注释不动。技能照这条规矩从范围本身判断写哪种:写说明时带 `@param` 等标签,前端组件的说明画原型图、嵌进正文。 +- 问模型:同「问 AI」用 Claude Code 命令行、在这份文件所在的工作区文件夹里跑、用输入框旁边选的 profile,按 JSON Schema 交结果(`--json-schema '{"type":"object","properties":{"body":{"type":"string"}},"required":["body"],"additionalProperties":false}'`:模型调 `StructuredOutput` 交,result 行的 `structured_output.body` 是不带注释符号的正文;只回了一段话、`body` 是空的都算没交回)。文件有没保存的修改时不问(模型读的是磁盘上的文件),直接失败「先保存再重写注释」。 + - 第一轮(点按钮)从 stdin 发的只有一句 `/ecc:writing-code-comments <路径>:<起>-<止>`:路径相对 claude 的工作目录、用 `/` 分隔,行号 1 起,是这块代码的完整范围(连同挂在它上面的注释;文件开头的说明卡片是它自己那几行),开新会话(`--session-id` 由扩展起)。 + - 输入框里说的话:`--resume` 这块最近一次重写的会话(和问答的会话分开,各接各的),发「块现在在哪(`<路径>:<起>-<止>`,写回注释后行号会变,按记号从最新的块树里找)、空一行、用户的话」,带上贴的图。会话找不到了(过期、被清理)就开新会话,发技能命令后面空一行跟着这句话。 + - 能做的事(`claudeAsk.ts` 的 `rewriteArgs`):`--restricted` 不读用户、项目、本地的设置文件(那里放行的命令进不来,比如用户设置里的 `git -C`),文件工具只认工作目录;写注释的插件单独加载(`--plugin-dir`,目录按 `claude plugin list --json` 里启用着的 ecc 找),它的目录和这个会话的草稿目录(系统临时目录下的 `spx-comment-<会话 id>`)加进工作目录(`--add-dir`)。工具只给读、搜、Bash、写文件(`--tools Read,Grep,Glob,Bash,Edit,Write`),不用再批的只有读搜、OpenPencil 的 `op`(`Bash(/Applications/OpenPencil.app/Contents/MacOS/op *)`)、建组件旁边的 `prototypes/` 目录(`Bash(mkdir -p */prototypes)`)、改草稿目录(`Edit(//<草稿目录>/**)`),别的一律拒绝、不等人批(`--permission-mode dontAsk`),不加载 MCP(`--strict-mcp-config`):改不了源码、跑不了别的命令,写回归扩展。本机 claude 2.1.296 试过:op 能跑、PNG 写进 `prototypes/`,改源码、`rm`、`git -C`、`node -e`、重定向写文件都被拒。 + - 画图比读文件慢,超时 15 分钟(问 AI 仍是 5 分钟)。 +- 过程:说的话(第一轮是那句技能命令,开始后扩展端报上来)、正文逐字长出来(`--include-partial-messages` 时 `StructuredOutput` 的输入逐段来,按写到一半的 JSON 读出已经写出来的正文)、正在做什么(「读取 a.py」)。块下面同问答区的样子(`AskThread` 换成重写的称呼):灰字历史、当前这一轮、输入框(提示「说说这段注释要怎么改…」,旁边 profile 下拉,能贴图),正在写时发送换成停止。同一块同时只重写一轮,全插件同时最多 3 段。 +- 写回(扩展端,`WorkspaceEdit`,能撤销):换掉时套回原来那段的定界符和缩进(Python 三引号、`#` / `//` / `///` / `//!` 行注释、`/* */` 块注释,`refillComment`;几段隔着空行的照第一段的写法);新写时用这块说明的壳(docstring、`/** */`、`///`,或行注释 `#` / `//`)一行一行写,缩进同插入的那一行,空行写成只有记号的一行(docstring 的空行仍是空行)。写之前核对这块代码的那几行和开始时一样,变了就不写(「这块代码在重写期间变了」);没交回正文、定界符认不出、编辑器没接受也不写。 +- 记录:每个工作区文件夹一份 `.spx/comment-rewrites.json`,和 `code-qa.json` 同一套写法(同一份 `qaMap.ts`,改名、删文件跟着走),条目 `{ "id", "question", "body"?, "error"?, "createdAt", "sessionId", "profile"?, "images"? }`:说的话(第一轮是技能命令)、交回的正文、没写进文件的原因(写进去了没有)。模型答完就记,写回失败也记下原因,好接着让它改;Claude Code 自己报错、被叫停的不记,文件不动。带的图片和问答的放在同一个 `.spx/code-qa/` 里,删一条跟着删。没展开时块下面一行「重写过 N 次」带灰字历史(没写进文件的那条下面写着原因),能收起、能删(「删除这条记录」)。 +- 预览重新加载(标签页切走再切回来)后照样接上:进行中的、出错被叫停留着的那一轮记在扩展端(同问 AI,`QaAsks`),随 `qa` 消息的 `rewriteCurrent` 回来,展开它那块、进展接着长、停止能用;收起这块时留着的那一轮去掉。切走期间扩展端照样写完、写回、记下。 ## 通用契约 diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index a4dfd88..5a0f873 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -402,7 +402,6 @@ export function toBlockViews( const row = commentRow(block) return { properties: row ? withCommentRow(rows, row) : rows } } - /** 「重写注释」写到哪(见 CommentTarget):块前面的普通注释换掉,没有就新写在块上面。 */ /** 「重写注释」写到哪:有说明就换说明,否则换块前面的普通注释,都没有就按这门语言的说明新写。 */ function commentTarget(block: CodeBlock): Pick { if (!writable) diff --git a/vscode/src/codeReader/commentRewrite.test.ts b/vscode/src/codeReader/commentRewrite.test.ts index 6e5d1be..5297254 100644 --- a/vscode/src/codeReader/commentRewrite.test.ts +++ b/vscode/src/codeReader/commentRewrite.test.ts @@ -1,8 +1,8 @@ import type { BlockView, CommentTarget } from './blockView' -import type { CommentEdit, CommentRewriteDeps, CommentRewriteJob, WriteResult } from './commentRewrite' +import type { CommentEdit, CommentJob, CommentWriteDeps, WriteResult } from './commentRewrite' import { describe, expect, it } from 'vitest' import { toBlockViews } from './blockView' -import { COMMENT_SKILL, commentEdit, commentRewritePrompt, commentSource, createCommentParser, findBlock, refillComment, rewriteBlockComment } from './commentRewrite' +import { COMMENT_SKILL, commentEdit, commentFollowUp, commentJob, commentRewritePrompt, commentSource, createCommentParser, findBlock, refillComment, writeCommentBody } from './commentRewrite' import { commonIndent, replaceLines, splitLines } from './editText' import { createPythonAdapter } from './languages/python' import { createRustAdapter } from './languages/rust' @@ -135,10 +135,15 @@ function structuredLines(body: string): string[] { const resultLine = (fields: Record): string => line({ type: 'result', subtype: 'success', is_error: false, ...fields }) describe('重写注释的提示', () => { - it('只有技能命令和代码块在哪:路径:起-止,行号 1 起', () => { + it('点按钮时只有技能命令和代码块在哪:路径:起-止,行号 1 起', () => { expect(commentRewritePrompt('src/acquisition/repository.py', 12, 20)).toBe(`${COMMENT_SKILL} src/acquisition/repository.py:13-21`) expect(commentRewritePrompt('a.ts', 0, 0)).toBe('/ecc:writing-code-comments a.ts:1-1') }) + + it('用户说了话:开新会话时跟在技能命令后面;接着会话说时前面是块现在在哪(写回注释后行号会变)', () => { + expect(commentRewritePrompt('a.ts', 2, 9, '加上原型图')).toBe('/ecc:writing-code-comments a.ts:3-10\n\n加上原型图') + expect(commentFollowUp('a.ts', 2, 9, '加上原型图')).toBe('a.ts:3-10\n\n加上原型图') + }) }) describe('读模型交回的结构化结果', () => { @@ -147,33 +152,37 @@ describe('读模型交回的结构化结果', () => { const events = [ line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/w/src/a.py' } }] } }), ...structuredLines('把 `a` 和 `b` 相加。'), - resultLine({ result: '{"body":"把 `a` 和 `b` 相加。"}', structured_output: { body: '把 `a` 和 `b` 相加。' } }), + resultLine({ result: '{"body":"把 `a` 和 `b` 相加。"}', structured_output: { body: '把 `a` 和 `b` 相加。' }, session_id: 's1' }), ].flatMap(parse) expect(events[0]).toEqual({ kind: 'activity', label: '读取 a.py' }) expect(events.filter(event => event.kind === 'text').map(event => 'text' in event && event.text)).toEqual(['把 `', '把 `a` 和 `b` 相加。']) - expect(events.at(-1)).toEqual({ kind: 'result', body: '把 `a` 和 `b` 相加。' }) + expect(events.at(-1)).toEqual({ kind: 'result', body: '把 `a` 和 `b` 相加。', sessionId: 's1' }) + }) + + it('会话 id 以 result 行的为准,没有时用开头 init 行的', () => { + const parse = createCommentParser() + expect(parse(line({ type: 'system', subtype: 'init', session_id: 's0' }))).toEqual([]) + expect(parse(resultLine({ structured_output: { body: '正文' } }))).toEqual([{ kind: 'result', body: '正文', sessionId: 's0' }]) }) it('模型只回了一段话、structured_output 是空的:结果里没有正文;Claude Code 报错时是失败', () => { - expect(createCommentParser()(resultLine({ result: '我改好了:把 a 和 b 相加。' }))).toEqual([{ kind: 'result' }]) - expect(createCommentParser()(resultLine({ result: '', structured_output: { body: ' ' } }))).toEqual([{ kind: 'result' }]) + expect(createCommentParser()(resultLine({ result: '我改好了:把 a 和 b 相加。', session_id: 's1' }))).toEqual([{ kind: 'result', sessionId: 's1' }]) + expect(createCommentParser()(resultLine({ result: '', structured_output: { body: ' ' }, session_id: 's1' }))).toEqual([{ kind: 'result', sessionId: 's1' }]) expect(createCommentParser()(line({ type: 'result', is_error: true, result: '额度用完了' }))).toEqual([{ kind: 'error', message: '额度用完了' }]) }) }) /** 内存里的一份文件:按行读,写回时同预览一样换掉几行(补回缩进)或在某行上面插几行。 */ -function memoryFile(text: string): { deps: (ask: CommentRewriteDeps['ask']) => CommentRewriteDeps, text: () => string, writes: CommentEdit[] } { +function memoryFile(text: string): { deps: CommentWriteDeps, text: () => string, writes: CommentEdit[] } { let current = text const writes: CommentEdit[] = [] const lines = (): string[] => splitLines(current) return { text: () => current, writes, - deps: ask => ({ + deps: { lineAt: index => lines()[index], lineCount: () => lines().length, - ask, - failure: err => `没答成:${err instanceof Error ? err.message : String(err)}`, write: async (edit): Promise => { writes.push(edit) if (edit.kind === 'replace') { @@ -185,8 +194,7 @@ function memoryFile(text: string): { deps: (ask: CommentRewriteDeps['ask']) => C } return 'written' }, - signal: new AbortController().signal, - }), + }, } } @@ -201,10 +209,15 @@ async function pythonBlock(source: string, line: number): Promise { return found } -function job(block: BlockView, path = 'src/a.py'): CommentRewriteJob { +/** 开始重写时记下这块:写到哪、范围、这几行当时的原文。 */ +function job(block: BlockView, source: string): CommentJob { if (!block.commentTarget) throw new Error('这块不能写注释') - return { target: block.commentTarget, startLine: block.startLine, endLine: block.endLine, path } + const lines = splitLines(source) + const started = commentJob(block.commentTarget, block.startLine, block.endLine, index => lines[index], () => lines.length) + if (!started) + throw new Error('块不在文件里') + return started } const SOURCE = [ @@ -219,18 +232,12 @@ const SOURCE = [ '', ].join('\n') -describe('重写一块代码的注释', () => { - it('块前面的注释(几段隔着空行的也算)换成模型交回的正文,套回 # 和缩进;提示只有技能命令和块的范围', async () => { +describe('把交回的正文写回这块代码', () => { + it('块前面的注释(几段隔着空行的也算)换成交回的正文,套回 # 和缩进', async () => { const repo = await pythonBlock(SOURCE, 3) expect(repo.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 4 }) const file = memoryFile(SOURCE) - const prompts: string[] = [] - const outcome = await rewriteBlockComment(job(repo), file.deps(async (prompt) => { - prompts.push(prompt) - return '仓库:按名字找文件。' - })) - expect(outcome).toEqual({ status: 'done' }) - expect(prompts).toEqual(['/ecc:writing-code-comments src/a.py:3-8']) + expect(await writeCommentBody(job(repo, SOURCE), '仓库:按名字找文件。', file.deps)).toBeUndefined() expect(file.text()).toBe(SOURCE.replace('# 旧的说明\n\n# 第二段', '# 仓库:按名字找文件。')) }) @@ -239,12 +246,7 @@ describe('重写一块代码的注释', () => { const find = await pythonBlock(source, 2) expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 2 }) const file = memoryFile(source) - const prompts: string[] = [] - expect(await rewriteBlockComment(job(find), file.deps(async (prompt) => { - prompts.push(prompt) - return '按名字找\n\n找不到时返回 1' - }))).toEqual({ status: 'done' }) - expect(prompts).toEqual(['/ecc:writing-code-comments src/a.py:2-4']) + expect(await writeCommentBody(job(find, source), '按名字找\n\n找不到时返回 1', file.deps)).toBeUndefined() expect(file.text()).toBe('class Repo:\n def find(self):\n """按名字找\n\n 找不到时返回 1\n """\n return 1\n') }) @@ -253,7 +255,7 @@ describe('重写一块代码的注释', () => { const find = await pythonBlock(source, 1) expect(find.commentTarget).toEqual({ kind: 'insert', line: 1, shell: '"""_"""' }) const file = memoryFile(source) - expect(await rewriteBlockComment(job(find), file.deps(async () => '按名字找。'))).toEqual({ status: 'done' }) + expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined() expect(file.text()).toBe('def find(self):\n """按名字找。"""\n return 1\n') }) @@ -262,7 +264,7 @@ describe('重写一块代码的注释', () => { const find = await pythonBlock(source, 1) expect(find.commentTarget).toMatchObject({ kind: 'insert', line: 0, shell: '"""_"""' }) const file = memoryFile(source) - expect(await rewriteBlockComment(job(find), file.deps(async () => '按名字找。'))).toEqual({ status: 'done' }) + expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined() expect(file.text()).toBe('def find():\n """按名字找。"""\n return 1\n') }) @@ -271,7 +273,7 @@ describe('重写一块代码的注释', () => { const find = await pythonBlock(source, 1) expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 0, endLine: 0 }) const file = memoryFile(source) - expect(await rewriteBlockComment(job(find), file.deps(async () => '按名字找。'))).toEqual({ status: 'done' }) + expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined() expect(file.text()).toBe('# 按名字找。\ndef find():\n return 1\n') }) @@ -280,7 +282,7 @@ describe('重写一块代码的注释', () => { const find = await pythonBlock(source, 1) expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 2 }) const file = memoryFile(source) - expect(await rewriteBlockComment(job(find), file.deps(async () => '新的说明。'))).toEqual({ status: 'done' }) + expect(await writeCommentBody(job(find, source), '新的说明。', file.deps)).toBeUndefined() expect(file.text()).toBe('# 旧的说明\ndef find():\n """新的说明。"""\n return 1\n') }) @@ -289,47 +291,32 @@ describe('重写一块代码的注释', () => { const header = await pythonBlock(source, 1) expect(header).toMatchObject({ kind: 'comment', commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } }) const file = memoryFile(source) - const prompts: string[] = [] - await rewriteBlockComment(job(header), file.deps(async (prompt) => { - prompts.push(prompt) - return '新的模块说明。' - })) - expect(prompts).toEqual(['/ecc:writing-code-comments src/a.py:1-1']) + expect(await writeCommentBody(job(header, source), '新的模块说明。', file.deps)).toBeUndefined() expect(file.text()).toBe('"""新的模块说明。"""\n\nimport os\n') }) - it('没交回结构化的正文、模型出错:失败,文件不动', async () => { + it('没交回结构化的正文:不写,说原因', async () => { const repo = await pythonBlock(SOURCE, 3) const silent = memoryFile(SOURCE) - expect(await rewriteBlockComment(job(repo), silent.deps(async () => undefined))).toEqual({ status: 'failed', error: '模型没有按要求交回注释正文,没有改文件。' }) + expect(await writeCommentBody(job(repo, SOURCE), undefined, silent.deps)).toBe('模型没有按要求交回注释正文,没有改文件。') expect(silent.writes).toEqual([]) - const broken = memoryFile(SOURCE) - expect(await rewriteBlockComment(job(repo), broken.deps(async () => { - throw new Error('额度用完了') - }))).toEqual({ status: 'failed', error: '没答成:额度用完了' }) - expect(broken.writes).toEqual([]) }) - it('重写期间这块代码变了:失败,不写;被叫停:stopped,不写', async () => { + it('重写期间这块代码变了、行号已经不在文件里:不写;编辑器没接受:说原因', async () => { const repo = await pythonBlock(SOURCE, 3) const file = memoryFile(SOURCE) - let edited = SOURCE - const deps = file.deps(async () => { - edited = SOURCE.replace('return os.sep', 'return "/"') - return '新说明' - }) - const changing: CommentRewriteDeps = { ...deps, lineAt: index => splitLines(edited)[index] } - expect(await rewriteBlockComment(job(repo), changing)).toEqual({ status: 'failed', error: '这块代码在重写期间变了,没有写回。' }) + const edited = splitLines(SOURCE.replace('return os.sep', 'return "/"')) + expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, lineAt: index => edited[index] })).toBe('这块代码在重写期间变了,没有写回。') + expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, lineCount: () => 3 })).toBe('这块代码在重写期间变了,没有写回。') expect(file.writes).toEqual([]) + expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, write: async () => 'rejected' })).toBe('编辑器没接受这次修改,没有写回。') + expect(commentJob({ kind: 'replace', startLine: 0, endLine: 0 }, 5, 9, () => '', () => 3)).toBeUndefined() + }) - const stop = new AbortController() - const stopped = memoryFile(SOURCE) - const result = await rewriteBlockComment(job(repo), { ...stopped.deps(async () => { - stop.abort() - return '新说明' - }), signal: stop.signal }) - expect(result).toEqual({ status: 'stopped' }) - expect(stopped.writes).toEqual([]) + it('认不出定界符:不写,说原因', async () => { + const file = memoryFile('x = 1\n') + const started = commentJob({ kind: 'replace', startLine: 0, endLine: 0 }, 0, 0, () => 'x = 1', () => 2) + expect(started && await writeCommentBody(started, '说明', file.deps)).toBe('这段注释的定界符认不出来,没有改文件。') }) it('typescript 已经有 JSDoc:换掉这段说明,不在声明上面再写 //', async () => { diff --git a/vscode/src/codeReader/commentRewrite.ts b/vscode/src/codeReader/commentRewrite.ts index b2f69b0..9583def 100644 --- a/vscode/src/codeReader/commentRewrite.ts +++ b/vscode/src/codeReader/commentRewrite.ts @@ -1,6 +1,6 @@ /** - * 「重写注释」:用写注释的技能给一块代码重写(没有就新写)讲它的普通注释。模型只读文件、按 JSON Schema 交回注释正文, - * 这里套回原来的定界符和缩进(没有注释时用语言的行注释记号),由扩展写回文件。 + * 「重写注释」:用写注释的技能给一块代码重写它的注释(没有就新写一段)。模型按 JSON Schema 交回注释正文, + * 这里套回原来的定界符和缩进(没有注释时用语言给的空壳),由扩展写回文件。 * 认 Python 的三引号、井号行注释,以及块注释和双斜杠行注释。认不出就不改。 */ @@ -8,8 +8,11 @@ import type { CommentTarget } from './blockView' import { describeToolUse } from '../cc/claudeProgress' import { commonIndent } from './editText' +/** 写注释的技能在哪个 Claude Code 插件里 */ +export const COMMENT_PLUGIN = 'ecc' + /** 跟在对话框里输入这个命令一样,由 Claude Code 自己加载写注释的技能。 */ -export const COMMENT_SKILL = '/ecc:writing-code-comments' +export const COMMENT_SKILL = `/${COMMENT_PLUGIN}:writing-code-comments` /** 模型交回的结构(`--json-schema`):body 是注释正文,不带注释符号。 */ export const COMMENT_SCHEMA = JSON.stringify({ @@ -224,19 +227,30 @@ export function refillComment(comment: string, body: string): string | undefined return refillDoc(comment, text) ?? refillLine(comment, text) ?? refillBlock(comment, text) } -/** 发给模型的整句话:技能命令后面跟代码块在哪,`路径:起-止`(路径相对 claude 的工作目录,行号 1 起,连同讲它的注释),别的不说。startLine、endLine 0 起。 */ -export function commentRewritePrompt(path: string, startLine: number, endLine: number): string { - return `${COMMENT_SKILL} ${path}:${startLine + 1}-${endLine + 1}` +/** 代码块在哪:`路径:起-止`(路径相对 claude 的工作目录,行号 1 起,连同讲它的注释)。startLine、endLine 0 起。 */ +function commentRange(path: string, startLine: number, endLine: number): string { + return `${path}:${startLine + 1}-${endLine + 1}` +} + +/** 开新会话时发给模型的:技能命令后面跟代码块在哪;用户说了话(request)就空一行跟在后面。 */ +export function commentRewritePrompt(path: string, startLine: number, endLine: number, request?: string): string { + const command = `${COMMENT_SKILL} ${commentRange(path, startLine, endLine)}` + return request ? `${command}\n\n${request}` : command +} + +/** 接着这块的重写会话说话时发的:块现在在哪(写回注释后行号会变),空一行跟着用户的话。 */ +export function commentFollowUp(path: string, startLine: number, endLine: number, request: string): string { + return `${commentRange(path, startLine, endLine)}\n\n${request}` } /** * 从 claude 的 stream-json 输出里读出来的:text 是注释正文到现在为止写出来的(结构化结果逐字长出来),activity 是正在做的事(「读取 routes.py」), - * result 是答完了(body 是结构化结果里的正文,没按 COMMENT_SCHEMA 交回、正文是空的时没有),error 是 Claude Code 自己报的失败。 + * result 是答完了(body 是结构化结果里的正文,没按 COMMENT_SCHEMA 交回、正文是空的时没有;sessionId 是这次的会话),error 是 Claude Code 自己报的失败。 */ export type CommentEvent = | { kind: 'text', text: string } | { kind: 'activity', label: string } - | { kind: 'result', body?: string } + | { kind: 'result', body?: string, sessionId: string } | { kind: 'error', message: string } function isRecord(value: unknown): value is Record { @@ -276,6 +290,8 @@ export function createCommentParser(): (line: string) => CommentEvent[] { /** 正在写的结构化结果(工具输入的 JSON 原文);不在写时没有 */ let json: string | undefined let text = '' + /** 开头 init 行报的会话 id;result 行没带时用它 */ + let sessionId = '' const grow = (body: string | undefined): CommentEvent[] => { if (body === undefined || body === text) return [] @@ -312,6 +328,10 @@ export function createCommentParser(): (line: string) => CommentEvent[] { // 子代理的消息(parent_tool_use_id 不空)不是这次的结果 if (!isRecord(line) || (line.parent_tool_use_id !== undefined && line.parent_tool_use_id !== null)) return [] + if (line.type === 'system' && line.subtype === 'init') { + sessionId = str(line.session_id) + return [] + } if (line.type === 'stream_event') return isRecord(line.event) ? streamEvent(line.event) : [] if (line.type === 'assistant') @@ -323,7 +343,7 @@ export function createCommentParser(): (line: string) => CommentEvent[] { return [{ kind: 'error', message: str(line.result).trim() || errors || str(line.subtype) || '没说原因' }] } const body = bodyOf(line.structured_output) - return [{ kind: 'result', ...(body !== undefined && { body }) }] + return [{ kind: 'result', ...(body !== undefined && { body }), sessionId: str(line.session_id) || sessionId }] } } @@ -372,62 +392,49 @@ function splitSameLine(line: string, atLine: number, breakAt: number, doc: strin /** 写回文件的结果:written 写进去了,unchanged 和文件里一样不用写,missing 行号已经不在,rejected 编辑器没接受。 */ export type WriteResult = 'written' | 'unchanged' | 'missing' | 'rejected' -/** 一次重写的结果:done 已经写进文件,failed 没写并带给人看的原因,stopped 是被叫停、没写。 */ -export type RewriteOutcome - = | { status: 'done' } - | { status: 'stopped' } - | { status: 'failed', error: string } - -/** 给哪块代码重写注释:「重写注释」写到哪,块的起止行(0 起,连同讲它的注释),文件相对 claude 工作目录的路径(`/` 分隔),语言的行注释记号。 */ -export interface CommentRewriteJob { +/** 开始重写时记下的这块:「重写注释」写到哪,块的起止行(0 起,连同讲它的注释),这几行当时的原文(写回前比对,变了就不写)。 */ +export interface CommentJob { target: CommentTarget startLine: number endLine: number - path: string + snapshot: string } -/** 重写时要用的:读文件现在的行,问模型(拿到结构化结果里的正文,没有正文时 undefined;失败时抛错),出错时给人看的话,写回文件。 */ -export interface CommentRewriteDeps { +/** 读这几行当时的原文,记成一次重写;行号已经不在文件里时没有。 */ +export function commentJob(target: CommentTarget, startLine: number, endLine: number, lineAt: (line: number) => string, lineCount: () => number): CommentJob | undefined { + const snapshot = rangeText(startLine, endLine, lineAt, lineCount) + return snapshot === undefined ? undefined : { target, startLine, endLine, snapshot } +} + +function rangeText(startLine: number, endLine: number, lineAt: (line: number) => string, lineCount: () => number): string | undefined { + if (startLine < 0 || endLine < startLine || endLine >= lineCount()) + return undefined + return Array.from({ length: endLine - startLine + 1 }, (_, i) => lineAt(startLine + i)).join('\n') +} + +/** 写回时要用的:读文件现在的行,写回文件。 */ +export interface CommentWriteDeps { lineAt: (line: number) => string lineCount: () => number - ask: (prompt: string) => Promise - failure: (err: unknown) => string write: (edit: CommentEdit) => Promise - signal: AbortSignal } /** - * 重写一块代码的注释:问模型(提示只有技能命令和代码块在哪,见 commentRewritePrompt),拿结构化结果里的正文套回去写进文件。 - * 没交回结构化的正文、这块代码在重写期间变了、写不回去都算失败,文件不动。 + * 把模型交回的正文套回这块的注释写进文件:写进去了(或和文件里一样)返回 undefined;没写成返回给人看的原因,文件不动。 + * 没交回正文、这块代码在重写期间变了、定界符认不出、编辑器没接受,都不写。 */ -export async function rewriteBlockComment(job: CommentRewriteJob, deps: CommentRewriteDeps): Promise { - const failed = (error: string): RewriteOutcome => ({ status: 'failed', error }) - const rangeText = (): string | undefined => job.endLine < deps.lineCount() - ? Array.from({ length: job.endLine - job.startLine + 1 }, (_, i) => deps.lineAt(job.startLine + i)).join('\n') - : undefined - const snapshot = job.startLine >= 0 && job.endLine >= job.startLine ? rangeText() : undefined - if (snapshot === undefined) - return failed('这块代码的位置已经变了,没有写回。') - let body: string | undefined - try { - body = await deps.ask(commentRewritePrompt(job.path, job.startLine, job.endLine)) - } - catch (err) { - return deps.signal.aborted ? { status: 'stopped' } : failed(deps.failure(err)) - } - if (deps.signal.aborted) - return { status: 'stopped' } +export async function writeCommentBody(job: CommentJob, body: string | undefined, deps: CommentWriteDeps): Promise { if (body === undefined) - return failed('模型没有按要求交回注释正文,没有改文件。') - if (rangeText() !== snapshot) - return failed('这块代码在重写期间变了,没有写回。') + return '模型没有按要求交回注释正文,没有改文件。' + if (rangeText(job.startLine, job.endLine, deps.lineAt, deps.lineCount) !== job.snapshot) + return '这块代码在重写期间变了,没有写回。' const edit = commentEdit(job.target, deps.lineAt, body) if (!edit) - return failed('这段注释的定界符认不出来,没有改文件。') + return '这段注释的定界符认不出来,没有改文件。' const wrote = await deps.write(edit) if (wrote === 'written' || wrote === 'unchanged') - return { status: 'done' } - return failed(wrote === 'rejected' ? '编辑器没接受这次修改,没有写回。' : '这块代码在重写期间变了,没有写回。') + return undefined + return wrote === 'rejected' ? '编辑器没接受这次修改,没有写回。' : '这块代码在重写期间变了,没有写回。' } /** 按记号在块树里找到这块。 */ diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 87496b5..ba0f57b 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -10,7 +10,7 @@ import type { CallTree } from '../callTree/model' import type { DocumentDiff } from '../diff/previewDiff' import type { ShownName } from '../nameResolution' import type { ProfileChoice } from '../qa/askProfiles' -import type { RunningAsk } from '../qa/asks' +import type { AskRound } from '../qa/asks' import type { Excluded, FilterState } from './blockFilter' export type { Excluded, ShownName } @@ -64,7 +64,9 @@ export interface DocumentMessage { names: ShownName[] } -/** 问过的一次(给预览看的):问题、回答(Markdown)、什么时候问的(ISO 时间)、用哪个 profile 答的。 */ +/** + * 问过、重写过的一次(给预览看的):说的话、回答(Markdown;重写记录是交回的注释正文,没交回时是空的)、什么时候(ISO 时间)、用哪个 profile 答的。 + */ export interface QaEntryView { id: string question: string @@ -72,23 +74,25 @@ export interface QaEntryView { createdAt: string /** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */ profile?: string - /** 问题带的图片,预览里能直接用的地址(webview.asWebviewUri);没带图片时没有 */ + /** 带的图片,预览里能直接用的地址(webview.asWebviewUri);没带图片时没有 */ images?: string[] + /** 重写记录才有:没写进文件的原因 */ + error?: string } -/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示。 */ +/** 挂在一块上的问答或重写记录;记号对不上这份文件里任何一块的不显示。 */ export interface QaThreadView { anchor: string entries: QaEntryView[] } /** - * 这份文件的问答:available 为假时文件不在工作区文件夹里、问答没地方存,不给「问 AI」; - * running 是正在答的问题(页面重新加载后靠它接上)。 + * 这份文件的问答和重写记录:available 为假时文件不在工作区文件夹里、没地方存,不给「问 AI」和「重写注释」; + * current、rewriteCurrent 是当前这一轮:正在答的问题、正在重写的注释,和出错、被叫停没记下留着的(页面重新加载后靠它们接上)。 */ export interface QaMessage { type: 'qa' - /** 不在工作区文件夹里时没地方存,问 AI 不可用 */ + /** 不在工作区文件夹里时没地方存,问 AI、重写注释不可用 */ available: boolean /** 块下面的问答,按块的记号 */ threads: QaThreadView[] @@ -96,7 +100,10 @@ export interface QaMessage { path: QaEntryView[] /** 顶部问的是这个文件还是这个文件夹 */ subject: 'file' | 'folder' - running: RunningAsk[] + current: AskRound[] + /** 块下面的重写记录,按块的记号 */ + rewrites: QaThreadView[] + rewriteCurrent: AskRound[] } /** @@ -121,8 +128,9 @@ export interface StickyScrollSettings { * askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停 - * rewriteProgress:anchor 那块的注释正在按规范重写。question 是发给模型的那一句(技能命令和块在哪),text 是注释正文到现在为止写出来的,activity 是正在读什么 - * rewriteEnd:这段重写结束了。done 是正文已经写进文件;failed 带给人看的原因,文件没改;stopped 是被叫停,文件没改 + * rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令) + * rewriteEnd:askId 那一轮重写结束了:done 是模型答完、这一轮记下了,带交回的正文(text)、存下的那条的 id,没写进文件时带原因(error); + * failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览 * displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发, @@ -150,8 +158,8 @@ export type ExtensionToWebview | ({ type: 'askProfiles' } & ProfileChoice) | { type: 'answer', askId: string, text: string, activity?: string } | { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string } - | { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string } - | { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string } + | { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string } + | { type: 'rewriteEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string } | { type: 'diff', diff: DocumentDiff | null } | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] } | { type: 'repoLinkBroken', path?: string, symbol?: string } @@ -174,8 +182,11 @@ export type ExtensionToWebview * setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览 * ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile; * images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存 - * rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段)。过程用 rewriteProgress / rewriteEnd 发回页面,不写入问答 - * stopRewrite:叫停 anchor 这段正在进行的重写,不写文件 + * rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段);askId 由 webview 起,过程用 rewriteProgress / rewriteEnd 带回去。 + * 没有 request 是点了按钮:发技能命令,开新会话;有 request 是在输入框里说的修改意见,接着这块最近一次重写的会话说,images 是贴的图片 + * stopRewrite:叫停 askId 那一轮重写,不写文件、不记 + * forgetRound:页面上收起了 anchor 那块的问答(ask)或重写(rewrite),扩展端去掉这块留着的那一轮(出错、被叫停的) + * deleteRewrite:删掉 anchor 那块下面的一条重写记录 * stopAsk:叫停 askId 那个问题,这次不存 * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) * selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览 @@ -193,8 +204,10 @@ export type WebviewToExtension | { type: 'setFilter', excluded: Excluded } | { type: 'setOnlyChanges', on: boolean } | { type: 'edit', startLine: number, endLine: number, text: string } - | { type: 'rewriteComment', anchor: string } - | { type: 'stopRewrite', anchor: string } + | { type: 'rewriteComment', askId: string, anchor: string, request?: string, images: ClaudeImage[] } + | { type: 'stopRewrite', askId: string } + | { type: 'deleteRewrite', anchor: string, entryId: string } + | { type: 'forgetRound', kind: 'ask' | 'rewrite', anchor: string } | { type: 'selectTranslator', id: string } | { type: 'visibleLine', line: number } | { type: 'probeDefinition', id: number, version: number, offset: number } diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index ddf2418..687a074 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -1,7 +1,7 @@ +import type { RewriteRequest } from '../qa/codeQa' import type { ExtensionToWebview, WebviewToExtension } from './messages' import { describe, expect, it, vi } from 'vitest' -import { window } from 'vscode' -import { askCommentBody } from '../qa/claudeAsk' +import { workspace } from 'vscode' import { PreviewEditorProvider } from './previewEditor' vi.mock('vscode', () => ({ @@ -10,27 +10,42 @@ vi.mock('vscode', () => ({ workspace: { onDidChangeTextDocument: () => ({ dispose: () => {} }), onDidChangeConfiguration: () => ({ dispose: () => {} }), + openTextDocument: vi.fn(), + applyEdit: vi.fn(async () => true), }, + /** 记下换了哪儿、换成什么 */ + WorkspaceEdit: class { + readonly replaced: { range: unknown, text: string }[] = [] + replace(_uri: unknown, range: unknown, text: string) { this.replaced.push({ range, text }) } + }, + Range: class { + constructor(readonly startLine: number, readonly startCharacter: number, readonly endLine: number, readonly endCharacter: number) {} + }, + EndOfLine: { LF: 1, CRLF: 2 }, })) vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } })) vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: () => ({}) })) -vi.mock('../folderStore', () => ({ locate: () => undefined })) +vi.mock('../folderStore', () => ({ locate: () => ({ folder: { uri: { fsPath: '/workspace' } }, key: 'web/a.ts' }) })) vi.mock('./pathPreview', () => ({ pathPreviewOf: () => undefined })) vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() })) -vi.mock('../qa/claudeAsk', () => ({ askCommentBody: vi.fn(), askFailure: (error: Error) => error.message })) -function preview(current: () => Promise) { +const SOURCE = ['// 旧说明', 'export function load() {}'] + +function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise, dirty = false) { const subscribe = () => ({ dispose: () => {} }) const receive = vi.fn<(listener: (message: WebviewToExtension) => Promise) => { dispose: () => void }>(subscribe) const post = vi.fn<(message: ExtensionToWebview) => void>() - const uri = { fsPath: '/workspace/a.ts', scheme: 'file', toString: () => 'file:///workspace/a.ts' } + const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' } + const document = { uri, isDirty: dirty, lineAt: (line: number) => ({ text: SOURCE[line] }), lineCount: SOURCE.length, eol: 1 } + vi.mocked(workspace.openTextDocument).mockResolvedValue(document as never) + const qa = { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { subscribe }, rewrite: vi.fn(rewrite) } const provider = new PreviewEditorProvider(...[ {}, {}, {}, { subscribe }, async () => [], - { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, profiles: { subscribe, current } }, + qa, async () => [], async () => [], { onDidChangeDiffBase: subscribe }, @@ -38,30 +53,74 @@ function preview(current: () => Promise) { async () => false, ] as unknown as ConstructorParameters) provider.resolveCustomTextEditor(...[ - { uri, isDirty: false }, + document, { webview: { onDidReceiveMessage: receive, postMessage: post }, onDidChangeViewState: subscribe, onDidDispose: subscribe }, ] as unknown as Parameters) const opened = (provider as unknown as { previews: Set<{ sent: unknown, dispose: () => void }> }).previews.values().next().value! - opened.sent = { blocks: [{ anchor: 'block', children: [], commentTarget: {}, startLine: 0, endLine: 1 }] } - return { send: receive.mock.calls[0]![0], post, dispose: () => opened.dispose() } + opened.sent = { blocks: [{ anchor: 'block', children: [], commentTarget: { kind: 'replace', startLine: 0, endLine: 0 }, startLine: 0, endLine: 1 }] } + return { send: receive.mock.calls[0]![0], post, qa, dispose: () => opened.dispose() } } -describe('重写注释的模型选择失败', () => { - it('模型删除后仍发送失败结束消息和错误提示,不调用模型且允许再次尝试', async () => { +describe('预览里重写注释', () => { + it('点按钮:发技能命令、开新会话,记在这一轮上的就是这句命令;开不了时回一条带 askId 的失败,能再试', async () => { const error = '文件问答模型不可用:official。请重新选择。' - const current = vi.fn<() => Promise>().mockRejectedValue(new Error(error)) - const editor = preview(current) + const editor = preview(async () => error) try { - await expect(editor.send({ type: 'rewriteComment', anchor: 'block' })).resolves.toBeUndefined() - expect(editor.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', anchor: 'block', status: 'failed', error }) - expect(window.showErrorMessage).toHaveBeenCalledWith(`代码阅读:${error}`) - expect(askCommentBody).not.toHaveBeenCalled() - await editor.send({ type: 'rewriteComment', anchor: 'block' }) - expect(current).toHaveBeenCalledTimes(2) - expect(editor.post.mock.calls.filter(([message]) => message.type === 'rewriteEnd')).toHaveLength(2) + await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] }) + expect(editor.qa.rewrite).toHaveBeenCalledWith(expect.objectContaining({ fsPath: '/workspace/web/a.ts' }), expect.objectContaining({ + askId: 'r1', + anchor: 'block', + question: '/ecc:writing-code-comments web/a.ts:1-2', + prompt: '/ecc:writing-code-comments web/a.ts:1-2', + images: [], + })) + expect(editor.qa.rewrite.mock.calls[0][1]).not.toHaveProperty('followUp') + expect(editor.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', askId: 'r1', status: 'failed', error }) + await editor.send({ type: 'rewriteComment', askId: 'r2', anchor: 'block', images: [] }) + expect(editor.qa.rewrite).toHaveBeenCalledTimes(2) } finally { editor.dispose() } }) + + it('输入框里说的话:接着这块的重写会话说,带上块现在在哪;会话找不到时开新会话用的是技能命令加这句话', async () => { + const editor = preview(async () => undefined) + try { + const image = { mediaType: 'image/png', base64: 'iVBORw0KGgo=' } + await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', request: '加上原型图', images: [image] }) + expect(editor.qa.rewrite.mock.calls[0][1]).toMatchObject({ + question: '加上原型图', + prompt: '/ecc:writing-code-comments web/a.ts:1-2\n\n加上原型图', + followUp: 'web/a.ts:1-2\n\n加上原型图', + images: [image], + }) + } + finally { + editor.dispose() + } + }) + + it('模型答完写回:套回原来的 // 换掉那几行;文件还有没保存的修改时不开始', async () => { + const editor = preview(async () => undefined) + try { + await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] }) + expect(await editor.qa.rewrite.mock.calls[0][1].writeBack('新说明')).toBeUndefined() + const [edit] = vi.mocked(workspace.applyEdit).mock.calls.at(-1)! as unknown as [{ replaced: { text: string }[] }] + expect(edit.replaced.map(item => item.text)).toEqual(['// 新说明']) + expect(await editor.qa.rewrite.mock.calls[0][1].writeBack(undefined)).toBe('模型没有按要求交回注释正文,没有改文件。') + } + finally { + editor.dispose() + } + const dirty = preview(async () => undefined, true) + try { + await dirty.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] }) + expect(dirty.qa.rewrite).not.toHaveBeenCalled() + expect(dirty.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', askId: 'r1', status: 'failed', error: '文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。' }) + } + finally { + dirty.dispose() + } + }) }) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 05726fd..2179ea6 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,8 +1,7 @@ import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode' -import type { ClaudeProfile } from '../../cc/profiles' import type { ClaudeImage } from '../../cc/spawnClaude' import type { IndexFind } from '../callTree/endpoint' -import type { CommentEdit, RewriteOutcome, WriteResult } from '../commentRewrite' +import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite' import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange } from '../contract' import type { DiffBase } from '../diff/diffBase' import type { OldDocument, SentDocument } from '../diff/previewDiff' @@ -18,20 +17,18 @@ import type { FoundLocation } from './definitions' import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages' import type { TargetSpot } from './openTarget' import { realpath } from 'node:fs/promises' -import { dirname, relative, sep } from 'node:path' import { commands, EndOfLine, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode' import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' import { DocumentCallees } from '../calleeKinds' import { loadBlockCalls } from '../callTree/query' import { CallTreeSession } from '../callTree/session' -import { findBlock, rewriteBlockComment } from '../commentRewrite' +import { commentFollowUp, commentJob, commentRewritePrompt, findBlock, writeCommentBody } from '../commentRewrite' import { PreviewDiff } from '../diff/previewDiff' import { blockReplacement } from '../editText' import { locate } from '../folderStore' import { definitionFor, symbolAt, symbolOccurrences } from '../localSymbols' import { DocumentNames, resolveNames } from '../nameResolution' -import { askCommentBody, askFailure } from '../qa/claudeAsk' import { PATH_ASK_ANCHOR } from '../qa/pathQa' import { askPrompt, followUpPrompt } from '../qa/prompt' import { DocumentLookups } from '../ruleLookups' @@ -181,12 +178,6 @@ class Preview { private readonly definitions: DefinitionSources /** webview 报来的视口顶部那块的起始行 */ visibleLine: number | undefined - /** 正在重写的注释(块的记号)。同一块没写完不再开一次 */ - private readonly rewriting = new Set() - /** 预览关掉时停掉还在跑的重写 */ - private readonly rewriteAbort = new AbortController() - /** 每段正在重写的注释各自的停止开关;预览关掉时 [rewriteAbort] 会把它们都停掉 */ - private readonly rewriteStops = new Map() /** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */ private sent: SentDocument | undefined /** 能看调用树的块(函数、类、接口卡片)落在几棵调用树里。文档发出去之后再数,不挡住第一次预览 */ @@ -314,6 +305,25 @@ class Preview { }) this.postQa() }), + owner.qa.onDidRewrite((event) => { + if (event.owner !== this.subject().uri.toString() || !this.ready) + return + if (event.kind === 'update') { + const { askId, question, text, activity } = event.ask + this.post({ type: 'rewriteProgress', askId, question, text, ...(activity !== undefined && { activity }) }) + return + } + const { end, entryId } = event + this.post({ + type: 'rewriteEnd', + askId: event.ask.askId, + status: end.status, + ...(end.status !== 'stopped' && end.error !== undefined && { error: end.error }), + ...(end.status === 'done' && end.body !== undefined && { text: end.body }), + ...(entryId !== undefined && { entryId }), + }) + this.postQa() + }), ] } @@ -474,7 +484,6 @@ class Preview { dispose(): void { this.callTrees.dispose() - this.rewriteAbort.abort() clearTimeout(this.timer) this.names.dispose() this.lookups.dispose() @@ -588,13 +597,22 @@ class Preview { await this.filter.setOnlyChanges(msg.on) return case 'edit': - await this.applyEdit(msg.startLine, msg.endLine, msg.text) + await replaceDocumentLines(this.document, msg.startLine, msg.endLine, msg.text) return case 'rewriteComment': - await this.rewriteComment(msg.anchor) + await this.rewrite(msg.askId, msg.anchor, msg.request, msg.images) return case 'stopRewrite': - this.rewriteStops.get(msg.anchor)?.abort() + this.owner.qa.stopRewrite(msg.askId) + return + case 'deleteRewrite': + await this.owner.qa.deleteRewrite(this.subject().uri, msg.anchor, msg.entryId) + return + case 'forgetRound': + if (msg.kind === 'ask') + this.owner.qa.forgetAsk(this.subject().uri, msg.anchor) + else + this.owner.qa.forgetRewrite(this.subject().uri, msg.anchor) return case 'selectTranslator': await this.registry.selectTranslator(this.languageId, msg.id) @@ -970,132 +988,90 @@ class Preview { this.post({ type: 'repoLinkOpened', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }) }) } - /** 用写注释的技能重写这一段,只把正文套回原来的定界符,再走块编辑那条写回。 */ - private async rewriteComment(anchor: string): Promise { - if (this.rewriting.has(anchor) || this.rewriteAbort.signal.aborted) - return - this.rewriting.add(anchor) - const stop = new AbortController() - const onDispose = (): void => stop.abort() - this.rewriteAbort.signal.addEventListener('abort', onDispose) - this.rewriteStops.set(anchor, stop) - try { - const outcome = await this.rewriteCommentNow(anchor, stop.signal) - if (!this.rewriteAbort.signal.aborted) - this.post({ type: 'rewriteEnd', anchor, status: outcome.status, ...(outcome.status === 'failed' && { error: outcome.error }) }) - } - finally { - this.rewriteAbort.signal.removeEventListener('abort', onDispose) - this.rewriteStops.delete(anchor) - this.rewriting.delete(anchor) - } - } - - private failRewrite(message: string): void { - void window.showErrorMessage(`代码阅读:${message}`) - } - - private async rewriteCommentNow(anchor: string, signal: AbortSignal): Promise { - const failed = (message: string): RewriteOutcome => { - this.failRewrite(message) - return { status: 'failed', error: message } - } - const block = this.sent && findBlock(this.sent.blocks, anchor) - if (!block) - return failed('这块代码刚改过,找不到了。等预览刷新后再重写。') - const target = block.commentTarget - if (!target) - return failed('这块不能写注释。') + /** + * 重写 anchor 那块的注释:按最近一次发出去的块树找到它,记下它现在的样子(写回前比对),把发给模型的话和写回的办法交给 CodeQa。 + * 点按钮(没有 request)发技能命令、开新会话;输入框里说的话接着这块最近一次重写的会话说,带上块现在在哪。 + * 找不到这块、文件有没保存的修改(模型读的是磁盘上的文件)、开不了时,直接回一条失败。 + */ + private async rewrite(askId: string, anchor: string, request: string | undefined, images: ClaudeImage[]): Promise { + const fail = (error: string): void => this.post({ type: 'rewriteEnd', askId, status: 'failed', error }) const { document } = this - if (document.isDirty) - return failed('文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。') + const block = this.sent && findBlock(this.sent.blocks, anchor) + const job = block?.commentTarget && commentJob(block.commentTarget, block.startLine, block.endLine, line => document.lineAt(line).text, () => document.lineCount) + if (!block || !job) { + fail(block && !block.commentTarget ? '这块不能写注释。' : '这块代码刚改过,找不到了。等预览刷新后再重写。') + return + } const at = locate(document.uri) - const cwd = at?.folder.uri.fsPath ?? dirname(document.uri.fsPath) - let profile: ClaudeProfile | undefined - try { - profile = await this.owner.qa.profiles.current() + if (!at) { + fail('这个文件不在工作区文件夹里,重写记录没地方存。') + return } - catch (err) { - return failed(err instanceof Error ? err.message : String(err)) + if (document.isDirty) { + fail('文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。') + return } - if (signal.aborted) - return { status: 'stopped' } - let question = '' - let text = '' - let activity: string | undefined - const publish = (): void => { - this.post({ type: 'rewriteProgress', anchor, question, text, ...(activity !== undefined && { activity }) }) + const prompt = commentRewritePrompt(at.key, block.startLine, block.endLine, request) + const question = request ?? prompt + const refused = await this.owner.qa.rewrite(document.uri, { + askId, + anchor, + question, + prompt, + ...(request !== undefined && { followUp: commentFollowUp(at.key, block.startLine, block.endLine, request) }), + images, + writeBack: body => this.writeBack(job, body), + }) + if (refused !== undefined) { + fail(refused) + return } - const outcome = await rewriteBlockComment({ - target, - startLine: block.startLine, - endLine: block.endLine, - path: relative(cwd, document.uri.fsPath).split(sep).join('/'), - }, { + // 页面点按钮时还不知道那句技能命令,先报上去 + this.post({ type: 'rewriteProgress', askId, question, text: '' }) + this.postQa() + } + + /** 把交回的正文写回这份文件;预览关了也照样按 uri 打开文档来写。 */ + private async writeBack(job: CommentJob, body: string | undefined): Promise { + const document = await workspace.openTextDocument(this.document.uri) + return writeCommentBody(job, body, { lineAt: line => document.lineAt(line).text, lineCount: () => document.lineCount, - ask: (prompt) => { - question = prompt - publish() - return askCommentBody({ - prompt, - ...(profile && { profilePath: profile.path }), - cwd, - signal, - onEvent: (event) => { - if (event.kind === 'text') { - text = event.text - activity = undefined - } - else { - activity = event.label - } - publish() - }, - }) - }, - failure: askFailure, - write: edit => this.applyCommentEdit(edit), - signal, + write: edit => writeCommentEdit(document, edit), }) - if (outcome.status === 'failed') - this.failRewrite(outcome.error) - return outcome - } - - /** 写回重写好的注释:换掉原来那几行(同块编辑),或在块上面插进新写的几行。 */ - private async applyCommentEdit(edit: CommentEdit): Promise { - if (edit.kind === 'replace') - return this.applyEdit(edit.startLine, edit.endLine, edit.text) - const { document } = this - if (edit.line >= document.lineCount) - return 'missing' - const eol = document.eol === EndOfLine.CRLF ? '\r\n' : '\n' - const change = new WorkspaceEdit() - change.insert(document.uri, new Position(edit.line, 0), edit.text.split('\n').join(eol) + eol) - if (!await workspace.applyEdit(change)) { - void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`) - return 'rejected' - } - return 'written' - } - - /** 把这几行换成 [text](见 WriteResult)。 */ - private async applyEdit(startLine: number, endLine: number, text: string): Promise { - const { document } = this - const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text) - if (!replacement) { - logger.add({ level: 'warn', source: 'codeReader', message: `块内修改的行号 ${startLine}-${endLine} 已不在 ${document.uri.fsPath} 里,没有写回` }) - return 'missing' - } - if (replacement.unchanged) - return 'unchanged' - const edit = new WorkspaceEdit() - edit.replace(document.uri, new Range(replacement.startLine, 0, replacement.endLine, replacement.endCharacter), replacement.text) - if (!await workspace.applyEdit(edit)) { - void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`) - return 'rejected' - } - return 'written' } } + +/** 写回重写好的注释:换掉原来那几行(同块编辑),或在块上面插进新写的几行。 */ +async function writeCommentEdit(document: TextDocument, edit: CommentEdit): Promise { + if (edit.kind === 'replace') + return replaceDocumentLines(document, edit.startLine, edit.endLine, edit.text) + if (edit.line >= document.lineCount) + return 'missing' + const eol = document.eol === EndOfLine.CRLF ? '\r\n' : '\n' + const change = new WorkspaceEdit() + change.insert(document.uri, new Position(edit.line, 0), edit.text.split('\n').join(eol) + eol) + if (!await workspace.applyEdit(change)) { + void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`) + return 'rejected' + } + return 'written' +} + +/** 把这几行换成 [text](见 WriteResult):块编辑和重写注释都走这条。 */ +async function replaceDocumentLines(document: TextDocument, startLine: number, endLine: number, text: string): Promise { + const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text) + if (!replacement) { + logger.add({ level: 'warn', source: 'codeReader', message: `块内修改的行号 ${startLine}-${endLine} 已不在 ${document.uri.fsPath} 里,没有写回` }) + return 'missing' + } + if (replacement.unchanged) + return 'unchanged' + const edit = new WorkspaceEdit() + edit.replace(document.uri, new Range(replacement.startLine, 0, replacement.endLine, replacement.endCharacter), replacement.text) + if (!await workspace.applyEdit(edit)) { + void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`) + return 'rejected' + } + return 'written' +} diff --git a/vscode/src/codeReader/qa/asks.test.ts b/vscode/src/codeReader/qa/asks.test.ts index 1e70cc2..8297dba 100644 --- a/vscode/src/codeReader/qa/asks.test.ts +++ b/vscode/src/codeReader/qa/asks.test.ts @@ -1,8 +1,8 @@ import type { AskEnd, AskStart, RunningAsk } from './asks' import type { Answer, AskRun } from './claudeAsk' import { describe, expect, it } from 'vitest' -import { ClaudeCancelledError, ClaudeError } from '../../cc/spawnClaude' -import { QaAsks } from './asks' +import { ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude' +import { ASK_WORDING, QaAsks, REWRITE_WORDING } from './asks' /** 假的回答:每次开问记下来,由测试决定什么时候答完 / 出错;叫停时像真的一样抛 ClaudeCancelledError */ function fakeRunner(): { runner: (run: AskRun) => Promise, runs: (AskRun & { answer: (answer: Answer) => void, fail: (err: Error) => void })[] } { @@ -51,11 +51,11 @@ describe('正在答的问题', () => { { askId: '1', anchor: '1', question: '问 1', text: '', activity: '读取 m.py' }, { askId: '1', anchor: '1', question: '问 1', text: '答' }, ]) - expect(asks.runningFor('file:///a.py')).toEqual([{ askId: '1', anchor: '1', question: '问 1', text: '答' }]) + expect(asks.currentFor('file:///a.py')).toEqual([{ askId: '1', anchor: '1', question: '问 1', text: '答', status: 'running' }]) runs[0].answer({ text: '答案', sessionId: 's' }) await events.ended expect(events.ends).toEqual([[{ askId: '1', anchor: '1', question: '问 1', text: '答' }, { status: 'done', answer: { text: '答案', sessionId: 's' } }]]) - expect(asks.runningFor('file:///a.py')).toEqual([]) + expect(asks.currentFor('file:///a.py')).toEqual([]) }) it('叫停:结束 claude,报 stopped,这块可以再问', async () => { @@ -91,7 +91,7 @@ describe('正在答的问题', () => { it('不同块可以同时答,最多几个;答完一个又能问', async () => { const { runner, runs } = fakeRunner() - const asks = new QaAsks(runner, 2) + const asks = new QaAsks(runner, ASK_WORDING, 2) const first = listener() asks.start(start('1'), first) asks.start(start('2'), listener()) @@ -101,6 +101,52 @@ describe('正在答的问题', () => { expect(asks.start(start('3'), listener())).toBeUndefined() }) + it('重写注释用自己的说法:这段已经在重写、同时重写的太多、等太久停掉', async () => { + const { runner, runs } = fakeRunner() + const rewrites = new QaAsks(runner, REWRITE_WORDING, 2) + const first = listener() + rewrites.start(start('1', 'function f'), first) + expect(rewrites.start(start('2', 'function f'), listener())).toBe('这段注释已经在重写了,等它写完或者先停下再说。') + rewrites.start(start('3', 'function g'), listener()) + expect(rewrites.start(start('4', 'function h'), listener())).toBe('同时最多重写 2 段注释,等前面的写完再来。') + runs[0].fail(new ClaudeTimeoutError('Claude 调用超时(900s)', '')) + await first.ended + expect(first.ends[0][1]).toEqual({ status: 'failed', error: '等了 15 分钟还没写完,已经停掉。' }) + }) + + it('出错、叫停的那一轮没记下,留在这里给重新加载的页面再画出来;这块再开一轮、收起这块时去掉,别的文件不算', async () => { + const { runner, runs } = fakeRunner() + const asks = new QaAsks(runner) + const failing = listener() + asks.start(start('1', 'function f'), failing) + runs[0].fail(new ClaudeError('网络断了', '')) + await failing.ended + expect(asks.currentFor('file:///a.py')).toEqual([{ askId: '1', anchor: 'function f', question: '问 1', text: '', status: 'failed', error: 'Claude Code 没答成:网络断了' }]) + expect(asks.currentFor('file:///b.py')).toEqual([]) + const stopping = listener() + expect(asks.start(start('2', 'function f'), stopping)).toBeUndefined() + expect(asks.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['2', 'running']]) + asks.stop('2') + await stopping.ended + expect(asks.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['2', 'stopped']]) + asks.forget('file:///b.py', 'function f') + expect(asks.currentFor('file:///a.py')).toHaveLength(1) + asks.forget('file:///a.py', 'function f') + expect(asks.currentFor('file:///a.py')).toEqual([]) + }) + + it('留着的那一轮不占名额,收起时还在答的不去掉', async () => { + const { runner, runs } = fakeRunner() + const asks = new QaAsks(runner, ASK_WORDING, 1) + const failing = listener() + asks.start(start('1', 'function f'), failing) + runs[0].fail(new ClaudeError('网络断了', '')) + await failing.ended + expect(asks.start(start('2', 'function g'), listener())).toBeUndefined() + asks.forget('file:///a.py', 'function g') + expect(asks.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['1', 'failed'], ['2', 'running']]) + }) + it('全部叫停', async () => { const { runner, runs } = fakeRunner() const asks = new QaAsks(runner) diff --git a/vscode/src/codeReader/qa/asks.ts b/vscode/src/codeReader/qa/asks.ts index 8a57491..7fc64c8 100644 --- a/vscode/src/codeReader/qa/asks.ts +++ b/vscode/src/codeReader/qa/asks.ts @@ -1,16 +1,16 @@ /** - * 正在答的问题:一块同时只答一个,不同块可以同时答(最多 MAX_RUNNING 个),能叫停。 - * 不碰 VS Code:答完怎么存、怎么告诉界面交给 AskListener。 + * 块下面正在进行的对话(问 AI、重写注释各一份):一块同时只进行一轮,不同块可以同时进行(最多 MAX_RUNNING 个),能叫停。 + * 不碰 VS Code:结束后怎么存、怎么告诉界面交给 AskListener。 */ -import type { Answer, AskRun, AskRunner } from './claudeAsk' +import type { Answer, AskRun } from './claudeAsk' import { ClaudeCancelledError } from '../../cc/spawnClaude' -import { askFailure } from './claudeAsk' +import { askFailure, rewriteFailure } from './claudeAsk' -/** 同时答的问题最多几个:每个都是一个 claude 进程 */ +/** 同时进行的最多几个:每个都是一个 claude 进程 */ export const MAX_RUNNING = 3 -/** 正在答的一个问题,界面照它显示。 */ +/** 正在进行的一轮,界面照它显示。 */ export interface RunningAsk { askId: string anchor: string @@ -21,50 +21,101 @@ export interface RunningAsk { activity?: string } -export type AskEnd - = | { status: 'done', answer: Answer } +/** 进行中报上来的:会话 id、回答长出来的字、正在做的事。 */ +export type RunEvent + = | { kind: 'session', sessionId: string } + | { kind: 'text', text: string } + | { kind: 'activity', label: string } + +/** 跑一轮要的:叫停的开关,进行中的消息交给谁。 */ +export interface RunControl { + signal: AbortSignal + onEvent: (event: RunEvent) => void +} + +export type AskEnd + = | { status: 'done', answer: Result } | { status: 'failed', error: string } | { status: 'stopped' } -export interface AskStart { +export interface AskStart> { /** 哪份文件问的(文档 uri):同一块只看同一份文件里的 */ owner: string askId: string anchor: string question: string - run: Omit + run: Run } -export interface AskListener { +export interface AskListener { update: (ask: RunningAsk) => void - end: (ask: RunningAsk, end: AskEnd) => void | Promise + end: (ask: RunningAsk, end: AskEnd) => void | Promise } -interface Running { +/** 给人看的话:这块已经在进行、同时进行的太多、没成的原因。 */ +export interface RunWording { + busy: string + full: (limit: number) => string + failure: (err: unknown) => string +} + +export const ASK_WORDING: RunWording = { + busy: '这里已经在问了,等它答完或者先停下再问。', + full: limit => `同时最多问 ${limit} 个问题,等前面的答完再问。`, + failure: askFailure, +} + +export const REWRITE_WORDING: RunWording = { + busy: '这段注释已经在重写了,等它写完或者先停下再说。', + full: limit => `同时最多重写 ${limit} 段注释,等前面的写完再来。`, + failure: rewriteFailure, +} + +/** 这一轮眼下怎样:还在进行,或出错、被叫停(没记下,留着给人看)。 */ +export interface AskRound extends RunningAsk { + status: 'running' | 'failed' | 'stopped' + /** failed 时给人看的原因 */ + error?: string +} + +interface Round { owner: string ask: RunningAsk controller: AbortController + /** 出错、被叫停了;还在进行时没有 */ + end?: Exclude } -export class QaAsks { - private readonly running = new Map() +/** + * Run 是跑一轮要的全部,叫停的开关和进行中的消息由这里给。 + * 答成了的那一轮交给 listener 记下,这里就不管了;出错、被叫停的没记下,留在这里(页面重新加载后靠 currentFor 再画出来), + * 这块再开一轮、或页面上收起这块时去掉(forget)。 + */ +export class QaAsks { + private readonly rounds = new Map() - constructor(private readonly runner: AskRunner, private readonly limit = MAX_RUNNING) {} + constructor( + private readonly runner: (run: Run) => Promise, + private readonly wording: RunWording = ASK_WORDING, + private readonly limit = MAX_RUNNING, + ) {} - /** 开始答;这块已经在答、同时答的太多时不开始,返回原因(给人看的话)。 */ - start(start: AskStart, listener: AskListener): string | undefined { + /** 开始一轮;这块已经在进行、同时进行的太多时不开始,返回原因(给人看的话)。这块留着的上一轮去掉。 */ + start(start: AskStart>, listener: AskListener): string | undefined { const { owner, askId, anchor, question } = start - if (this.running.has(askId)) + if (this.rounds.has(askId)) return '这个问题已经在问了。' - if ([...this.running.values()].some(item => item.owner === owner && item.ask.anchor === anchor)) - return '这里已经在问了,等它答完或者先停下再问。' - if (this.running.size >= this.limit) - return `同时最多问 ${this.limit} 个问题,等前面的答完再问。` + const running = [...this.rounds.values()].filter(round => !round.end) + if (running.some(round => round.owner === owner && round.ask.anchor === anchor)) + return this.wording.busy + if (running.length >= this.limit) + return this.wording.full(this.limit) + this.forget(owner, anchor) const controller = new AbortController() const ask: RunningAsk = { askId, anchor, question, text: '' } - this.running.set(askId, { owner, ask, controller }) - void this.runner({ - ...start.run, + const round: Round = { owner, ask, controller } + this.rounds.set(askId, round) + const control: RunControl = { signal: controller.signal, onEvent: (event) => { if (event.kind === 'text') { @@ -79,11 +130,16 @@ export class QaAsks { } listener.update({ ...ask }) }, - }).then( - (answer): AskEnd => ({ status: 'done', answer }), - (err: unknown): AskEnd => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: askFailure(err) }, + } + // Run 里除了这两项就是 start.run,拼回去还是 Run + void this.runner({ ...start.run, ...control } as unknown as Run).then( + (answer): AskEnd => ({ status: 'done', answer }), + (err: unknown): AskEnd => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: this.wording.failure(err) }, ).then(async (end) => { - this.running.delete(askId) + if (end.status === 'done') + this.rounds.delete(askId) + else + round.end = end await listener.end({ ...ask }, end) }) return undefined @@ -91,17 +147,33 @@ export class QaAsks { /** 叫停:结束 claude 进程,这次不存。 */ stop(askId: string): void { - this.running.get(askId)?.controller.abort() + const round = this.rounds.get(askId) + if (round && !round.end) + round.controller.abort() } - /** owner 这份文件正在答的问题,按开始的先后。 */ - runningFor(owner: string): RunningAsk[] { - return [...this.running.values()].filter(item => item.owner === owner).map(item => ({ ...item.ask })) + /** owner 这份文件当前的各轮:还在进行的,和出错、被叫停留着的,按开始的先后。 */ + currentFor(owner: string): AskRound[] { + return [...this.rounds.values()].filter(round => round.owner === owner).map(({ ask, end }) => ({ + ...ask, + status: end?.status ?? 'running', + ...(end?.status === 'failed' && { error: end.error }), + })) + } + + /** 去掉 owner 这份文件 anchor 那块留着的那一轮(页面上收起了这块);还在进行的不动。 */ + forget(owner: string, anchor: string): void { + for (const [askId, round] of this.rounds) { + if (round.end && round.owner === owner && round.ask.anchor === anchor) + this.rounds.delete(askId) + } } /** 全部叫停(插件停用时)。 */ stopAll(): void { - for (const item of this.running.values()) - item.controller.abort() + for (const round of this.rounds.values()) { + if (!round.end) + round.controller.abort() + } } } diff --git a/vscode/src/codeReader/qa/claudeAsk.test.ts b/vscode/src/codeReader/qa/claudeAsk.test.ts index 5b72e05..217fa82 100644 --- a/vscode/src/codeReader/qa/claudeAsk.test.ts +++ b/vscode/src/codeReader/qa/claudeAsk.test.ts @@ -1,9 +1,9 @@ import type { runClaudeLines } from '../../cc/spawnClaude' -import type { AskDeps, AskRun } from './claudeAsk' +import type { AskDeps, AskRun, CommentAsk, CommentAskDeps } from './claudeAsk' import { describe, expect, it } from 'vitest' import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude' import { COMMENT_SCHEMA } from '../commentRewrite' -import { askArgs, askClaude, askCommentBody, askFailure } from './claudeAsk' +import { askArgs, askClaude, askCommentBody, askFailure, commentPluginRoot, pluginRootIn, REWRITE_TIMEOUT_MS, rewriteArgs, rewriteFailure } from './claudeAsk' type RunLinesOptions = Parameters[0] @@ -170,27 +170,150 @@ describe('失败了给人看的话', () => { }) }) -describe('重写注释问一次', () => { - const structured = (body: string): string => line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify({ body }), structured_output: { body }, session_id: 's1' }) +describe('重写注释的命令行参数', () => { + const SCRATCH = '/private/var/folders/T/spx-comment-s1' + const allowed = 'Read,Grep,Glob,Bash(/Applications/OpenPencil.app/Contents/MacOS/op *),Bash(mkdir -p */prototypes),Edit(//private/var/folders/T/spx-comment-s1/**)' - it('只读、按 JSON Schema 交结构化结果:参数在只读那套后面加 --json-schema,stdin 里只有那一句提示;返回结构化结果里的正文', async () => { - const { deps, calls } = fakeClaude([{ lines: [structured('按名字找文件。')] }]) - const events: unknown[] = [] - const body = await askCommentBody({ prompt: '/ecc:writing-code-comments src/a.py:3-8', profilePath: '/p/sonnet.json', cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event) }, deps) - expect(body).toBe('按名字找文件。') - expect(calls[0].args).toEqual([...askArgs({ profilePath: '/p/sonnet.json' }), '--json-schema', COMMENT_SCHEMA]) - expect(calls[0].args).toEqual(expect.arrayContaining(['--tools', 'Read,Grep,Glob', '--permission-mode', 'dontAsk'])) + it('不读用户、项目的设置(--restricted),写注释的插件单独加载、能读;只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,别的一律拒、不加载 MCP;按 JSON Schema 交结果', () => { + const args = rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: SCRATCH, profilePath: '/p/sonnet.json', session: { start: 's1' } }) + expect(args).toEqual([ + '--settings', + '/p/sonnet.json', + '--restricted', + '--plugin-dir', + '/plugins/ecc', + '--add-dir', + '/plugins/ecc', + '--add-dir', + SCRATCH, + '--tools', + 'Read,Grep,Glob,Bash,Edit,Write', + '--allowedTools', + allowed, + '--permission-mode', + 'dontAsk', + '--strict-mcp-config', + '--session-id', + 's1', + '-p', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--verbose', + '--include-partial-messages', + '--json-schema', + COMMENT_SCHEMA, + ]) + expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions/) expect(JSON.parse(COMMENT_SCHEMA)).toEqual({ type: 'object', properties: { body: { type: 'string' } }, required: ['body'], additionalProperties: false }) - expect(sent(calls[0])).toEqual({ text: '/ecc:writing-code-comments src/a.py:3-8', images: [] }) - expect(calls[0].cwd).toBe('/work') }) - it('只回了一段话、没交结构化结果:没有正文;Claude Code 报错、没给结果:抛错', async () => { - const free = fakeClaude([{ lines: [result('我改好了。', 's1')] }]) - expect(await askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, free.deps)).toBeUndefined() - const failing = fakeClaude([{ lines: [line({ type: 'result', is_error: true, result: '额度用完了' })] }]) - await expect(askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, failing.deps)).rejects.toThrow('额度用完了') - const empty = fakeClaude([{ lines: [] }]) - await expect(askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, empty.deps)).rejects.toThrow('没给出结果') + it('接着说时 --resume 那次会话、不带 --session-id;没有 profile 就不带 --settings', () => { + const args = rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: SCRATCH, session: { resume: 's1' } }) + expect(args[0]).toBe('--restricted') + expect(args).not.toContain('--session-id') + expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 's1', '-p']) + }) +}) + +describe('写注释的插件在哪', () => { + const list = JSON.stringify([ + { id: 'claude-mem@thedotmack', enabled: true, installPath: '/plugins/mem' }, + { id: 'ecc@ecc', version: '2.2.52', enabled: true, installPath: '/plugins/ecc/2.2.52' }, + ]) + + it('claude plugin list --json 里启用着的 ecc 的目录;没装、没启用、读不出来都没有', () => { + expect(pluginRootIn(list, 'ecc')).toBe('/plugins/ecc/2.2.52') + expect(pluginRootIn(JSON.stringify([{ id: 'ecc@ecc', enabled: false, installPath: '/plugins/ecc' }]), 'ecc')).toBeUndefined() + expect(pluginRootIn(JSON.stringify([{ id: 'ecc-tools@x', enabled: true, installPath: '/plugins/tools' }]), 'ecc')).toBeUndefined() + expect(pluginRootIn('not json', 'ecc')).toBeUndefined() + }) + + it('在这次的目录里问 claude(项目里装的插件也算);找不到时说清楚要先装上', async () => { + const calls: RunLinesOptions[] = [] + const found = await commentPluginRoot('/work', async (options) => { + calls.push(options) + JSON.stringify(JSON.parse(list), null, 2).split('\n').forEach(options.onLine) + }) + expect(found).toBe('/plugins/ecc/2.2.52') + expect(calls[0]).toMatchObject({ args: ['plugin', 'list', '--json'], cwd: '/work' }) + await expect(commentPluginRoot('/work', async options => options.onLine('[]'))).rejects.toThrow('没找到启用着的 ecc 插件') + }) +}) + +describe('重写注释问一次', () => { + const structured = (body: string, session = 's1'): string => line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify({ body }), structured_output: { body }, session_id: session }) + + /** 假的 claude 再加上找插件、建草稿目录、起会话 id:都记下来 */ + function commentDeps(runs: { lines: string[], error?: Error }[]) { + const { deps, calls } = fakeClaude(runs) + const scratches: string[] = [] + const lookups: string[] = [] + let next = 0 + const commentDeps: CommentAskDeps = { + ...deps, + pluginRoot: async (cwd) => { + lookups.push(cwd) + return '/plugins/ecc' + }, + scratch: async (sessionId) => { + scratches.push(sessionId) + return `/tmp/spx-comment-${sessionId}` + }, + newSessionId: () => `new-${++next}`, + } + return { deps: commentDeps, calls, scratches, lookups } + } + + function comment(overrides: Partial = {}): CommentAsk & { events: unknown[] } { + const events: unknown[] = [] + return { prompt: '/ecc:writing-code-comments src/a.py:3-8', images: [], cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides } + } + + it('开新会话:自己起会话 id、按它建草稿目录,带上插件目录和超时;stdin 里是那句提示和贴的图;返回正文和会话 id', async () => { + const { deps, calls, scratches, lookups } = commentDeps([{ lines: [structured('按名字找文件。', 'new-1')] }]) + const ask = comment({ profilePath: '/p/sonnet.json', images: [PNG] }) + expect(await askCommentBody(ask, deps)).toEqual({ body: '按名字找文件。', sessionId: 'new-1' }) + expect(lookups).toEqual(['/work']) + expect(scratches).toEqual(['new-1']) + expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: '/tmp/spx-comment-new-1', profilePath: '/p/sonnet.json', session: { start: 'new-1' } })) + expect(calls[0]).toMatchObject({ cwd: '/work', signal: ask.signal, timeoutMs: REWRITE_TIMEOUT_MS }) + expect(sent(calls[0])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-8', images: [{ source: { data: PNG.base64 } }] }) + }) + + it('接着说:--resume 这块最近一次重写的会话,用那个会话的草稿目录,发块现在在哪和用户的话', async () => { + const { deps, calls, scratches } = commentDeps([{ lines: [structured('短一点。', 's0')] }]) + expect(await askCommentBody(comment({ followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } }), deps)).toEqual({ body: '短一点。', sessionId: 's0' }) + expect(scratches).toEqual(['s0']) + expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: '/tmp/spx-comment-s0', session: { resume: 's0' } })) + expect(sent(calls[0]).text).toBe('src/a.py:3-10\n\n短一点') + }) + + it('那次会话找不到了:开新会话按技能命令重新来,图片照样带上', async () => { + const { deps, calls, scratches } = commentDeps([ + { lines: [line({ type: 'result', subtype: 'error_during_execution', is_error: true, errors: ['No conversation found with session ID: s0'], session_id: 's0' })] }, + { lines: [structured('重新写的。', 'new-1')] }, + ]) + const ask = comment({ prompt: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [PNG], followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } }) + expect(await askCommentBody(ask, deps)).toEqual({ body: '重新写的。', sessionId: 'new-1' }) + expect(scratches).toEqual(['s0', 'new-1']) + expect(calls[1].args).toContain('--session-id') + expect(calls[1].args).not.toContain('--resume') + expect(sent(calls[1])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [{ source: { data: PNG.base64 } }] }) + }) + + it('只回了一段话、没交结构化结果:只有会话 id 没有正文;Claude Code 报错、没给结果:抛错,别的失败不重开', async () => { + expect(await askCommentBody(comment(), commentDeps([{ lines: [result('我改好了。', 'new-1')] }]).deps)).toEqual({ sessionId: 'new-1' }) + await expect(askCommentBody(comment(), commentDeps([{ lines: [line({ type: 'result', is_error: true, result: '额度用完了' })] }]).deps)).rejects.toThrow('额度用完了') + await expect(askCommentBody(comment(), commentDeps([{ lines: [] }]).deps)).rejects.toThrow('没给出结果') + const timeout = commentDeps([{ lines: [], error: new ClaudeTimeoutError('超时', '') }, { lines: [structured('不该到这')] }]) + await expect(askCommentBody(comment({ followUp: { sessionId: 's0', prompt: 'p' } }), timeout.deps)).rejects.toBeInstanceOf(ClaudeTimeoutError) + expect(timeout.calls).toHaveLength(1) + }) + + it('等太久停掉时说的是没写完', () => { + expect(rewriteFailure(new ClaudeTimeoutError('超时', ''))).toBe(`等了 ${REWRITE_TIMEOUT_MS / 60_000} 分钟还没写完,已经停掉。`) + expect(rewriteFailure(new ClaudeError('Claude 退出码非零 (1): 网络断了', ''))).toBe('Claude Code 没答成:Claude 退出码非零 (1): 网络断了') }) }) diff --git a/vscode/src/codeReader/qa/claudeAsk.ts b/vscode/src/codeReader/qa/claudeAsk.ts index 2d08f16..17b4fc7 100644 --- a/vscode/src/codeReader/qa/claudeAsk.ts +++ b/vscode/src/codeReader/qa/claudeAsk.ts @@ -1,13 +1,17 @@ /** - * 用 Claude Code(`claude -p`,只读)回答代码问答:和插件别处的 `claude -p` 一样找 claude、清环境变量、 - * 用问的时候选的 profile(见 askProfiles);回答按 stream-json 逐字交出来,可以叫停。 + * 用 Claude Code(`claude -p`)回答代码问答、重写注释:和插件别处的 `claude -p` 一样找 claude、清环境变量、 + * 用当时选的 profile(见 askProfiles);过程按 stream-json 逐字交出来,可以叫停。问答只读;重写注释另外只能画原型图。 */ import type { ClaudeImage } from '../../cc/spawnClaude' import type { CommentEvent } from '../commentRewrite' import type { AnswerEvent } from './answerStream' +import { randomUUID } from 'node:crypto' +import { mkdir, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' import { buildStreamJsonLine, CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude' -import { COMMENT_SCHEMA, createCommentParser } from '../commentRewrite' +import { COMMENT_PLUGIN, COMMENT_SCHEMA, createCommentParser } from '../commentRewrite' import { createAnswerParser } from './answerStream' /** 只给读、搜文件的工具。 */ @@ -15,13 +19,18 @@ const READ_ONLY_TOOLS = 'Read,Grep,Glob' export const ASK_TIMEOUT_MS = 5 * 60_000 +/** 重写注释要读代码、画原型图、导出图片,比问答慢 */ +export const REWRITE_TIMEOUT_MS = 15 * 60_000 + +/** OpenPencil 装在应用里的命令行(写注释的技能用它画前端组件的原型图) */ +export const OPENPENCIL_CLI = '/Applications/OpenPencil.app/Contents/MacOS/op' + /** * 一次提问的命令行参数;问题连同贴的图片从 stdin 按 stream-json 发(见 askClaude),没贴图也一样。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`), * 别的一律拒绝、不等人批(`--permission-mode dontAsk`,盖过 profile 里的 bypassPermissions); * 不加载任何 MCP 服务(`--strict-mcp-config` 又不给 `--mcp-config`),免得 MCP 工具能改东西。 - * jsonSchema:要模型按这个 JSON Schema 交结构化结果(`--json-schema`,结果在 result 行的 structured_output 里)。 */ -export function askArgs(options: { profilePath?: string, resume?: string, jsonSchema?: string } = {}): string[] { +export function askArgs(options: { profilePath?: string, resume?: string } = {}): string[] { return [ ...(options.profilePath ? ['--settings', options.profilePath] : []), '--tools', @@ -39,7 +48,45 @@ export function askArgs(options: { profilePath?: string, resume?: string, jsonSc 'stream-json', '--verbose', '--include-partial-messages', - ...(options.jsonSchema ? ['--json-schema', options.jsonSchema] : []), + ] +} + +/** + * 一次重写注释的命令行参数,按 COMMENT_SCHEMA 交结构化结果(`--json-schema`)。除了读、搜,只放开写注释的技能画原型图要的: + * 运行 OpenPencil 的 op、建组件旁边的 `prototypes/` 目录、在草稿目录里写 build.js(`--allowedTools` 里的规则),别的一律拒绝、不等人批。 + * `--restricted` 不读用户、项目的设置文件,那里放行的命令(比如 `git -C`)进不来,文件工具也只认工作目录; + * 写注释的插件因此要单独加载(`--plugin-dir`),它的目录和草稿目录加进工作目录(`--add-dir`),技能才读得到它的参考文档、写得了草稿。 + * session:新会话用 start 当会话 id(草稿目录按它起名),接着说时 resume 那次会话。 + */ +export function rewriteArgs(options: { pluginRoot: string, scratch: string, profilePath?: string, session: { start: string } | { resume: string } }): string[] { + const { pluginRoot, scratch, profilePath, session } = options + const allowed = [READ_ONLY_TOOLS, `Bash(${OPENPENCIL_CLI} *)`, 'Bash(mkdir -p */prototypes)', `Edit(/${scratch}/**)`].join(',') + return [ + ...(profilePath ? ['--settings', profilePath] : []), + '--restricted', + '--plugin-dir', + pluginRoot, + '--add-dir', + pluginRoot, + '--add-dir', + scratch, + '--tools', + `${READ_ONLY_TOOLS},Bash,Edit,Write`, + '--allowedTools', + allowed, + '--permission-mode', + 'dontAsk', + '--strict-mcp-config', + ...('resume' in session ? ['--resume', session.resume] : ['--session-id', session.start]), + '-p', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--verbose', + '--include-partial-messages', + '--json-schema', + COMMENT_SCHEMA, ] } @@ -117,45 +164,121 @@ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Prom } export interface CommentAsk { - /** 只有技能命令和代码块在哪的一句话(commentRewritePrompt) */ + /** 开新会话时发的:技能命令和代码块在哪(commentRewritePrompt),用户说了话就跟在后面 */ prompt: string + /** 用户贴的图片,接着说、找不到会话重新开时一样带上 */ + images: ClaudeImage[] profilePath?: string + /** 接着 sessionId 那次重写的会话发 prompt(块现在在哪和用户的话);会话找不到了就按上面的 prompt 开新会话 */ + followUp?: { sessionId: string, prompt: string } cwd: string signal: AbortSignal - /** 重写的过程中:正文写出来的字、正在读什么 */ + /** 重写的过程中:正文写出来的字、正在做什么 */ onEvent: (event: Extract) => void } +/** 重写一次的结果:结构化结果里的注释正文(只回了一段话时没有),这次的会话 id。 */ +export interface CommentAnswer { + body?: string + sessionId: string +} + +export interface CommentAskDeps extends AskDeps { + /** 写注释的技能所在插件的目录,按 cwd 问(项目里装的插件也算) */ + pluginRoot: (cwd: string) => Promise + /** 这个会话画原型用的草稿目录,建好了返回 */ + scratch: (sessionId: string) => Promise + /** 新会话的 id */ + newSessionId: () => string +} + +/** 这个会话画原型的草稿目录:系统临时目录下的 `spx-comment-<会话 id>`,同一会话每轮都是它。用真实路径,权限规则按它比对。不删,留给系统清。 */ +async function commentScratch(sessionId: string): Promise { + const dir = path.join(await realpath(tmpdir()), `spx-comment-${sessionId}`) + await mkdir(dir, { recursive: true }) + return dir +} + +const COMMENT_DEPS: CommentAskDeps = { + runLines: runClaudeLines, + pluginRoot: cwd => commentPluginRoot(cwd), + scratch: commentScratch, + newSessionId: randomUUID, +} + /** - * 「重写注释」问一次:同问 AI 一样只读(模型能读那个文件,改不了,写回归扩展),按 COMMENT_SCHEMA 交结构化结果。 - * 返回结果里的注释正文;只回了一段话、没交结构化结果时是 undefined。失败、超时、被叫停时抛 ClaudeError(及其子类),用 askFailure 换成给人看的话。 + * 「重写注释」问一次:参数见 rewriteArgs,按 COMMENT_SCHEMA 交结构化结果,写回归扩展。有 followUp 就接着那次会话说,会话找不到了按 prompt 开新会话。 + * 返回结果里的注释正文和会话 id;只回了一段话、没交结构化结果时没有正文。失败、超时、被叫停时抛 ClaudeError(及其子类),用 rewriteFailure 换成给人看的话。 */ -export async function askCommentBody(ask: CommentAsk, deps: AskDeps = DEFAULT_DEPS): Promise { - const parse = createCommentParser() - let result: { body?: string } | undefined - let failure: string | undefined - await deps.runLines({ - args: askArgs({ profilePath: ask.profilePath, jsonSchema: COMMENT_SCHEMA }), - input: buildStreamJsonLine(ask.prompt, []), - cwd: ask.cwd, - signal: ask.signal, - timeoutMs: ASK_TIMEOUT_MS, - onLine: (line) => { - for (const event of parse(line)) { - if (event.kind === 'result') - result = event - else if (event.kind === 'error') - failure = event.message - else - ask.onEvent(event) - } - }, - }) - if (failure !== undefined) - throw new ClaudeError(failure, '') - if (!result) - throw new ClaudeError('Claude Code 结束了,但没给出结果', '') - return result.body +export async function askCommentBody(ask: CommentAsk, deps: CommentAskDeps = COMMENT_DEPS): Promise { + const pluginRoot = await deps.pluginRoot(ask.cwd) + const once = async (prompt: string, session: { start: string } | { resume: string }): Promise => { + const scratch = await deps.scratch('resume' in session ? session.resume : session.start) + const parse = createCommentParser() + let result: CommentAnswer | undefined + let failure: string | undefined + await deps.runLines({ + args: rewriteArgs({ pluginRoot, scratch, profilePath: ask.profilePath, session }), + input: buildStreamJsonLine(prompt, ask.images), + cwd: ask.cwd, + signal: ask.signal, + timeoutMs: REWRITE_TIMEOUT_MS, + onLine: (line) => { + for (const event of parse(line)) { + if (event.kind === 'result') + result = { ...(event.body !== undefined && { body: event.body }), sessionId: event.sessionId } + else if (event.kind === 'error') + failure = event.message + else + ask.onEvent(event) + } + }, + }) + if (failure !== undefined) + throw new ClaudeError(failure, '') + if (!result) + throw new ClaudeError('Claude Code 结束了,但没给出结果', '') + return result + } + if (ask.followUp) { + try { + return await once(ask.followUp.prompt, { resume: ask.followUp.sessionId }) + } + catch (err) { + if (!missingSession(err)) + throw err + } + } + return once(ask.prompt, { start: deps.newSessionId() }) +} + +/** `claude plugin list --json` 里启用着的 name 插件的目录;没装、没启用、读不出来时没有。 */ +export function pluginRootIn(listJson: string, name: string): string | undefined { + let list: unknown + try { + list = JSON.parse(listJson) + } + catch { + return undefined + } + if (!Array.isArray(list)) + return undefined + const found = list.find((item: unknown) => isRecord(item) && typeof item.id === 'string' && item.id.startsWith(`${name}@`) && item.enabled === true && typeof item.installPath === 'string') + return isRecord(found) && typeof found.installPath === 'string' ? found.installPath : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** 写注释的技能所在插件的目录:在 cwd 里问 Claude Code 装了哪些插件。没找到时抛错,说清楚要先装上。 */ +export async function commentPluginRoot(cwd: string, runLines: typeof runClaudeLines = runClaudeLines): Promise { + const lines: string[] = [] + await runLines({ args: ['plugin', 'list', '--json'], cwd, onLine: line => lines.push(line), timeoutMs: 30_000 }) + const root = pluginRootIn(lines.join('\n'), COMMENT_PLUGIN) + if (!root) + throw new ClaudeError(`没找到启用着的 ${COMMENT_PLUGIN} 插件,写注释的技能在它里面:在 Claude Code 里装上并启用后再重写。`, '') + return root } const AUTH_HINT = /\/login|log ?in|auth|401|403|api key|unauthori[sz]ed|credential|oauth/i @@ -171,3 +294,10 @@ export function askFailure(err: unknown): string { return `Claude Code 没登录或登录过期了:在终端里运行 claude 登录一下再问。(原话:${message})` return `Claude Code 没答成:${message}` } + +/** 重写注释失败了,给人看的一句话:超时说的是没写完,别的同 askFailure。 */ +export function rewriteFailure(err: unknown): string { + if (err instanceof ClaudeTimeoutError) + return `等了 ${Math.round(REWRITE_TIMEOUT_MS / 60_000)} 分钟还没写完,已经停掉。` + return askFailure(err) +} diff --git a/vscode/src/codeReader/qa/codeQa.test.ts b/vscode/src/codeReader/qa/codeQa.test.ts index f9440ed..d7fc981 100644 --- a/vscode/src/codeReader/qa/codeQa.test.ts +++ b/vscode/src/codeReader/qa/codeQa.test.ts @@ -1,10 +1,12 @@ import type { ExtensionContext, Memento, Uri } from 'vscode' -import type { AskRunner } from './claudeAsk' +import type { AskRunner, CommentAnswer, CommentAsk } from './claudeAsk' +import type { RewriteEvent, RewriteRequest } from './codeQa' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { listClaudeProfiles } from '../../cc/profiles' +import { ClaudeCancelledError } from '../../cc/spawnClaude' import { getSettings, saveSettings } from '../../settings/store' import { CodeQa } from './codeQa' @@ -22,14 +24,21 @@ vi.mock('vscode', async () => { dispose() { this.emitter.removeAllListeners() } }, Disposable: { from: () => ({ dispose: () => {} }) }, - Uri: {}, + Uri: { joinPath: (base: { fsPath: string }, ...parts: string[]) => ({ fsPath: [base.fsPath, ...parts].join('/') }) }, window: { showErrorMessage: vi.fn() }, } }) vi.mock('../folderStore', () => ({ + /** 内存里的一份表:按键取、改了就换 */ FolderStore: class { - get() { return {} } + private map: Record = {} + readonly onDidChange = () => ({ dispose: () => {} }) + get(_folder: unknown, key: string) { return this.map[key] } + async update(_folder: unknown, change: (map: Record) => Record) { + this.map = change(this.map) + } + dispose() {} }, locate: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }), @@ -60,13 +69,13 @@ function context(): ExtensionContext { } as unknown as ExtensionContext } -function service(ctx: ExtensionContext) { +function service(ctx: ExtensionContext, rewriter = vi.fn<(ask: CommentAsk) => Promise>()) { const runner = vi.fn(run => new Promise((_resolve, reject) => { run.signal.addEventListener('abort', () => reject(new Error('停止'))) })) - const qa = new CodeQa(ctx, ctx.workspaceState as Memento, runner) + const qa = new CodeQa(ctx, ctx.workspaceState as Memento, runner, rewriter) services.push(qa) - return { qa, runner } + return { qa, runner, rewriter } } beforeEach(() => vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE)) @@ -134,3 +143,88 @@ describe('问这个文件实际使用设置的默认模型', () => { expect(runner).not.toHaveBeenCalled() }) }) + +describe('重写注释的记录', () => { + const SKILL = '/ecc:writing-code-comments a.ts:1-3' + + function request(askId: string, overrides: Partial = {}): RewriteRequest { + return { askId, anchor: 'function f', question: SKILL, prompt: SKILL, images: [], writeBack: async () => undefined, ...overrides } + } + + /** 等这一轮结束的事件 */ + function ended(qa: CodeQa, askId: string): Promise> { + return new Promise((resolve) => { + qa.onDidRewrite((event) => { + if (event.kind === 'end' && event.ask.askId === askId) + resolve(event) + }) + }) + } + + const rewritesOf = (qa: CodeQa) => qa.view(uri, file => file.fsPath, 'file').rewrites + + it('答完就记在重写记录里、不进问答:写进文件的记正文;接着说时接这块最近一次重写的会话,写回失败也记下原因', async () => { + const ctx = context() + const { qa, runner, rewriter } = service(ctx) + rewriter.mockResolvedValueOnce({ body: '按名字找。', sessionId: 'r1' }) + const first = ended(qa, '1') + expect(await qa.rewrite(uri, request('1'))).toBeUndefined() + expect(rewriter.mock.calls[0][0]).toMatchObject({ prompt: SKILL, cwd: '/workspace', images: [] }) + expect(rewriter.mock.calls[0][0]).not.toHaveProperty('followUp') + expect((await first).end).toEqual({ status: 'done', body: '按名字找。' }) + expect(rewritesOf(qa)).toEqual([{ anchor: 'function f', entries: [expect.objectContaining({ question: SKILL, answer: '按名字找。', id: (await first).entryId })] }]) + expect(qa.view(uri, file => file.fsPath, 'file').threads).toEqual([]) + + rewriter.mockResolvedValueOnce({ body: '短的。', sessionId: 'r1' }) + const writeBack = vi.fn(async () => '编辑器没接受这次修改,没有写回。') + const second = ended(qa, '2') + await qa.rewrite(uri, request('2', { question: '短一点', prompt: `${SKILL}\n\n短一点`, followUp: 'a.ts:1-5\n\n短一点', writeBack })) + expect(rewriter.mock.calls[1][0].followUp).toEqual({ sessionId: 'r1', prompt: 'a.ts:1-5\n\n短一点' }) + expect((await second).end).toEqual({ status: 'done', body: '短的。', error: '编辑器没接受这次修改,没有写回。' }) + expect(writeBack).toHaveBeenCalledWith('短的。') + expect(rewritesOf(qa)[0].entries.map(entry => [entry.question, entry.answer, entry.error])).toEqual([[SKILL, '按名字找。', undefined], ['短一点', '短的。', '编辑器没接受这次修改,没有写回。']]) + + await qa.ask(uri, { askId: '3', anchor: 'function f', question: '为什么', prompt: '完整的', followUp: '短的', images: [] }) + expect(runner.mock.calls[0][0]).not.toHaveProperty('followUp') + }) + + it('点按钮从头重写(不带 followUp)开新会话;模型没交回正文也记下原因', async () => { + const ctx = context() + const { qa, rewriter } = service(ctx) + rewriter.mockResolvedValueOnce({ body: '旧的。', sessionId: 'r1' }) + const first = ended(qa, '1') + await qa.rewrite(uri, request('1')) + await first + rewriter.mockResolvedValueOnce({ sessionId: 'r2' }) + const second = ended(qa, '2') + await qa.rewrite(uri, request('2', { writeBack: async body => body === undefined ? '模型没有按要求交回注释正文,没有改文件。' : undefined })) + expect(rewriter.mock.calls[1][0]).not.toHaveProperty('followUp') + expect((await second).end).toEqual({ status: 'done', error: '模型没有按要求交回注释正文,没有改文件。' }) + expect(rewritesOf(qa)[0].entries.at(-1)).toMatchObject({ answer: '', error: '模型没有按要求交回注释正文,没有改文件。' }) + }) + + it('出错、被叫停的不记,也不写文件;进行中的、没记下留着的那一轮在 rewriteCurrent 里,页面重新加载能再画出来,收起这块时去掉', async () => { + const ctx = context() + const { qa, rewriter } = service(ctx) + rewriter.mockRejectedValueOnce(new Error('额度用完了')) + const writeBack = vi.fn(async () => undefined) + const failed = ended(qa, '1') + await qa.rewrite(uri, request('1', { writeBack })) + expect((await failed).end).toEqual({ status: 'failed', error: 'Claude Code 没答成:额度用完了' }) + expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toMatchObject([{ askId: '1', status: 'failed', error: 'Claude Code 没答成:额度用完了' }]) + rewriter.mockImplementationOnce(ask => new Promise((_resolve, reject) => { + ask.signal.addEventListener('abort', () => reject(new ClaudeCancelledError(''))) + })) + const stopped = ended(qa, '2') + await qa.rewrite(uri, request('2', { writeBack })) + expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toEqual([{ askId: '2', anchor: 'function f', question: SKILL, text: '', status: 'running' }]) + qa.stopRewrite('2') + expect((await stopped).end).toEqual({ status: 'stopped' }) + expect(writeBack).not.toHaveBeenCalled() + expect(rewritesOf(qa)).toEqual([]) + expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toEqual([{ askId: '2', anchor: 'function f', question: SKILL, text: '', status: 'stopped' }]) + expect(qa.view(uri, file => file.fsPath, 'file').current).toEqual([]) + qa.forgetRewrite(uri, 'function f') + expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/qa/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts index 5698cff..f99761c 100644 --- a/vscode/src/codeReader/qa/codeQa.ts +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -1,30 +1,42 @@ import type { Event, ExtensionContext, Memento } from 'vscode' import type { ClaudeProfile } from '../../cc/profiles' import type { ClaudeImage } from '../../cc/spawnClaude' -import type { QaMessage } from '../editor/messages' +import type { QaEntryView, QaMessage } from '../editor/messages' import type { PathSubject } from '../editor/pathPreview' +import type { PathLocation } from '../folderStore' import type { AskEnd, RunningAsk } from './asks' -import type { AskRunner } from './claudeAsk' -import type { QaEntry } from './qaMap' +import type { AskRunner, CommentAnswer, CommentAsk } from './claudeAsk' +import type { ChatEntry, QaEntry, RewriteEntry } from './qaMap' import { randomUUID } from 'node:crypto' import { Disposable, EventEmitter, Uri, window } from 'vscode' import { getSettings, onDidChangeSettings } from '../../settings/store' import { pathQaLocation } from '../editor/pathPreview' import { FolderStore, locate } from '../folderStore' import { AskProfiles } from './askProfiles' -import { QaAsks } from './asks' -import { askClaude } from './claudeAsk' +import { QaAsks, REWRITE_WORDING } from './asks' +import { askClaude, askCommentBody } from './claudeAsk' import { describePath } from './pathAskDescribe' import { pathAskFollowUp, pathAskPrompt } from './pathAskPrompt' import { parsePathQa, PATH_ASK_ANCHOR, PATH_QA_STATE_KEY, pathQaEntries, pathQaKey, withoutPathQa, withPathQa } from './pathQa' import { isQaImageType, QA_IMAGE_DIR, removeQaImages, writeQaImages } from './qaImages' -import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap' +import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry } from './qaMap' /** 正在答的问题有了进展(update),或答完了(end;答成了带存下的那条的 id)。owner 是问的那份文件的 uri。 */ export type AskEvent = | { owner: string, kind: 'update', ask: RunningAsk } | { owner: string, kind: 'end', ask: RunningAsk, end: AskEnd, entryId?: string } +/** 一次重写怎么结束的:done 是模型答完、这一轮记下了(body 是交回的正文,没交回时没有;error 是没写进文件的原因);failed、stopped 没记,文件没动。 */ +export type RewriteEnd + = | { status: 'done', body?: string, error?: string } + | { status: 'failed', error: string } + | { status: 'stopped' } + +/** 正在重写的注释有了进展(update),或结束了(end;记下了的带那条的 id)。owner 是那份文件的 uri。 */ +export type RewriteEvent + = | { owner: string, kind: 'update', ask: RunningAsk } + | { owner: string, kind: 'end', ask: RunningAsk, end: RewriteEnd, entryId?: string } + /** prompt 是带代码上下文的完整问题;followUp 是接着这块上一次的会话问时发的话(只有问题和是哪块) */ export interface AskRequest { askId: string @@ -36,15 +48,59 @@ export interface AskRequest { images: ClaudeImage[] } +/** 重写一段注释:question 是记在这一轮上的话(点按钮是技能命令,之后是用户在输入框里说的)。 */ +export interface RewriteRequest { + askId: string + anchor: string + question: string + /** 开新会话时发的:技能命令和块在哪,用户说了话就跟在后面 */ + prompt: string + /** 接着这块最近一次重写的会话说时发的:块现在在哪和用户的话。点按钮从头重写时没有,开新会话 */ + followUp?: string + /** 贴进输入框的图片,随这句话发;记下时存进 `.spx/code-qa/` */ + images: ClaudeImage[] + /** 模型答完后把交回的正文写回文件:写进去了返回 undefined,没写成返回原因 */ + writeBack: (body: string | undefined) => Promise +} + +/** 记在这条上的图片换成预览里能用的地址;不在工作区文件夹里(root 没有)、没带图片时没有 */ +function viewEntry(root: Uri | undefined, imageUrl: (file: Uri) => string, entry: ChatEntry): Omit { + const { id, question, createdAt, profile, images } = entry + return { + id, + question, + createdAt, + ...(profile && { profile }), + ...(root && images && { images: images.map(name => imageUrl(Uri.joinPath(root, name))) }), + } +} + +/** 答完的一轮共有的几项:起 id、把带的图片写进 `/.spx/code-qa/`。图片写不进时抛错。 */ +async function chatEntry(rootPath: string, question: string, sessionId: string, profile: ClaudeProfile | undefined, images: readonly ClaudeImage[]): Promise { + const id = randomUUID() + const names = await writeQaImages(rootPath, id, images) + return { + id, + question, + createdAt: new Date().toISOString(), + sessionId, + ...(profile && { profile: profile.name }), + ...(names.length > 0 && { images: names }), + } +} + /** - * 代码问答:块下面的问 Claude Code、答完存进各工作区文件夹的 `.spx/code-qa.json` - * (改名、删文件、外部改动的跟随同文件注释,见 FolderStore),挂在块的位置记号上。 - * 预览顶部问的是整个文件或文件夹,按路径记在这个窗口的 workspaceState 里,不进那份文件。 + * 块下面的对话:问 AI 和重写注释,各自记在各工作区文件夹的一份文件里(`.spx/code-qa.json`、`.spx/comment-rewrites.json`; + * 改名、删文件、外部改动的跟随同文件注释,见 FolderStore),挂在块的位置记号上,各自接着自己最近一次的会话。 + * 预览顶部问的是整个文件或文件夹,按路径记在这个窗口的 workspaceState 里,不进那两份文件。 */ export class CodeQa implements Disposable { private readonly store = new FolderStore(QA_FILE) + private readonly rewriteStore = new FolderStore(REWRITE_FILE) private readonly asks: QaAsks + private readonly rewrites: QaAsks private readonly answered = new EventEmitter() + private readonly rewritten = new EventEmitter() /** 顶部问答变了的那些文件或文件夹 */ private readonly pathChanged = new EventEmitter() /** 顶部问答的读写排成一队,两次同时答完不会互相盖掉 */ @@ -52,10 +108,11 @@ export class CodeQa implements Disposable { private readonly settingsSubscription: Disposable /** 所有预览共用当前模型,临时切换不会写入默认设置。 */ readonly profiles: AskProfiles - /** 块下面或顶部的问答变了的那些文件 / 文件夹 */ + /** 块下面的问答、重写记录或顶部的问答变了的那些文件 / 文件夹 */ readonly onDidChange: Event = (listener, thisArgs, disposables) => { const subscription = Disposable.from( this.store.onDidChange(listener, thisArgs), + this.rewriteStore.onDidChange(listener, thisArgs), this.pathChanged.event(listener, thisArgs), ) disposables?.push(subscription) @@ -63,11 +120,13 @@ export class CodeQa implements Disposable { } readonly onDidAnswer = this.answered.event + readonly onDidRewrite = this.rewritten.event constructor( context: ExtensionContext, private readonly paths: Pick, runner: AskRunner = askClaude, + rewriter: (ask: CommentAsk) => Promise = askCommentBody, ) { this.profiles = new AskProfiles(() => getSettings(context).defaultAskProfilePath) this.settingsSubscription = onDidChangeSettings(context, () => { @@ -76,6 +135,7 @@ export class CodeQa implements Disposable { }) }) this.asks = new QaAsks(runner) + this.rewrites = new QaAsks(rewriter, REWRITE_WORDING) } /** uri 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */ @@ -85,31 +145,28 @@ export class CodeQa implements Disposable { } /** - * 发给预览的问答。块下面的按块的记号;顶部的按这条路径,subject 说是文件还是文件夹。 - * 不在工作区文件夹里时没地方存,问 AI 不可用。 - * 问题带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。 + * 发给预览的问答和重写记录。块下面的按块的记号;顶部的按这条路径,subject 说是文件还是文件夹。 + * 不在工作区文件夹里时没地方存,问 AI、重写注释都不可用。 + * 带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。重写记录的回答是交回的正文,没交回时是空的。 */ view(uri: Uri, imageUrl: (file: Uri) => string, subject: PathSubject): QaMessage { const at = locate(uri) const pathAt = pathQaLocation(uri) const threads = at ? this.store.get(at.folder, at.key) ?? [] : [] + const rewrites = at ? this.rewriteStore.get(at.folder, at.key) ?? [] : [] const pathEntries = pathAt ? pathQaEntries(this.pathMap(), pathQaKey(pathAt.folder.uri.toString(), pathAt.key)) : [] const root = this.imageRoot(uri) - const show = ({ id, question, answer, createdAt, profile, images }: QaEntry) => ({ - id, - question, - answer, - createdAt, - ...(profile && { profile }), - ...(root && images && { images: images.map(name => imageUrl(Uri.joinPath(root, name))) }), - }) + const show = (entry: QaEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.answer }) + const showRewrite = (entry: RewriteEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.body ?? '', ...(entry.error && { error: entry.error }) }) return { type: 'qa', available: pathAt !== undefined, subject, threads: threads.map(({ anchor, entries }) => ({ anchor, entries: entries.map(show) })), path: pathEntries.map(show), - running: this.asks.runningFor(uri.toString()), + current: this.asks.currentFor(uri.toString()), + rewrites: rewrites.map(({ anchor, entries }) => ({ anchor, entries: entries.map(showRewrite) })), + rewriteCurrent: this.rewrites.currentFor(uri.toString()), } } @@ -120,19 +177,11 @@ export class CodeQa implements Disposable { * 带的图片随问题发,答成了才写进 `.spx/code-qa/`、记在这条上;没答成不留文件。 */ async ask(uri: Uri, request: AskRequest): Promise { - const at = locate(uri) - if (!at) - return '这个文件不在工作区文件夹里,问答没地方存。' + const ready = await this.prepare(uri, request.images, '问答') + if (typeof ready === 'string') + return ready + const { at, profile } = ready const { askId, anchor, question, prompt, followUp, images } = request - if (!images.every(image => isQaImageType(image.mediaType))) - return '只能带 PNG、JPEG、GIF、WebP 图片。' - let profile: ClaudeProfile | undefined - try { - profile = await this.profiles.current() - } - catch (err) { - return err instanceof Error ? err.message : String(err) - } const owner = uri.toString() const sessionId = latestSession(this.store.get(at.folder, at.key), anchor) const run = { @@ -149,31 +198,104 @@ export class CodeQa implements Disposable { this.answered.fire({ owner, kind: 'end', ask, end }) return } - const id = randomUUID() - let names: string[] + let entry: QaEntry try { - names = await writeQaImages(at.folder.uri.fsPath, id, images) + const { id, question: asked, ...rest } = await chatEntry(at.folder.uri.fsPath, question, end.answer.sessionId, profile, images) + entry = { id, question: asked, answer: end.answer.text, ...rest } } catch (err) { const error = `回答没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})` this.answered.fire({ owner, kind: 'end', ask, end: { status: 'failed', error } }) return } - const entry: QaEntry = { - id, - question, - answer: end.answer.text, - createdAt: new Date().toISOString(), - sessionId: end.answer.sessionId, - ...(profile && { profile: profile.name }), - ...(names.length > 0 && { images: names }), - } await this.store.update(at.folder, map => withEntry(map, at.key, anchor, entry)) this.answered.fire({ owner, kind: 'end', ask, end, entryId: entry.id }) }, }) } + /** + * 重写一段注释;开不了时返回原因(给人看的话)。在工作区文件夹里跑 claude(见 askCommentBody),用眼下选的 profile。 + * 带 followUp 时接着这块最近一次重写的会话说(和问答的会话分开),没重写过就开新会话;不带时(点按钮)总是开新会话。 + * 模型答完先写回文件,再把这一轮记下:用户的话、交回的正文、没写进文件的原因、会话 id、profile、图片。Claude Code 报错、被叫停的不记,文件不动。 + */ + async rewrite(uri: Uri, request: RewriteRequest): Promise { + const ready = await this.prepare(uri, request.images, '重写记录') + if (typeof ready === 'string') + return ready + const { at, profile } = ready + const { askId, anchor, question, prompt, followUp, images, writeBack } = request + const owner = uri.toString() + const sessionId = followUp === undefined ? undefined : latestSession(this.rewriteStore.get(at.folder, at.key), anchor) + const run = { + prompt, + images, + ...(sessionId && followUp !== undefined && { followUp: { sessionId, prompt: followUp } }), + ...(profile && { profilePath: profile.path }), + cwd: at.folder.uri.fsPath, + } + return this.rewrites.start({ owner, askId, anchor, question, run }, { + update: ask => this.rewritten.fire({ owner, kind: 'update', ask }), + end: async (ask, end) => { + if (end.status !== 'done') { + this.rewritten.fire({ owner, kind: 'end', ask, end }) + return + } + const { body } = end.answer + const error = await writeBack(body) + let entry: RewriteEntry + try { + const { id, question: said, ...rest } = await chatEntry(at.folder.uri.fsPath, question, end.answer.sessionId, profile, images) + entry = { id, question: said, ...(body !== undefined && { body }), ...(error !== undefined && { error }), ...rest } + } + catch (err) { + const reason = `重写记录没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})` + this.rewritten.fire({ owner, kind: 'end', ask, end: { status: 'failed', error: reason } }) + return + } + await this.rewriteStore.update(at.folder, map => withEntry(map, at.key, anchor, entry)) + this.rewritten.fire({ owner, kind: 'end', ask, end: { status: 'done', ...(body !== undefined && { body }), ...(error !== undefined && { error }) }, entryId: entry.id }) + }, + }) + } + + stopRewrite(askId: string): void { + this.rewrites.stop(askId) + } + + /** 页面上收起了 anchor 那块的问答:去掉留着的那一轮(出错、被叫停的),重新加载后不再画出来。 */ + forgetAsk(uri: Uri, anchor: string): void { + this.asks.forget(uri.toString(), anchor) + } + + /** 页面上收起了 anchor 那块的重写:去掉留着的那一轮。 */ + forgetRewrite(uri: Uri, anchor: string): void { + this.rewrites.forget(uri.toString(), anchor) + } + + /** 删掉一条重写记录,不用确认;带的图片跟着删。 */ + async deleteRewrite(uri: Uri, anchor: string, entryId: string): Promise { + const at = locate(uri) + if (at) + await this.rewriteStore.update(at.folder, map => withoutEntry(map, at.key, anchor, entryId)) + } + + /** 块下面开一轮前都要的:文件在工作区文件夹里(记录才有地方存)、图片格式认得、眼下选的 profile 还在;不行时返回原因。 */ + private async prepare(uri: Uri, images: readonly ClaudeImage[], record: string): Promise<{ at: PathLocation, profile?: ClaudeProfile } | string> { + const at = locate(uri) + if (!at) + return `这个文件不在工作区文件夹里,${record}没地方存。` + if (!images.every(image => isQaImageType(image.mediaType))) + return '只能带 PNG、JPEG、GIF、WebP 图片。' + try { + const profile = await this.profiles.current() + return { at, ...(profile && { profile }) } + } + catch (err) { + return err instanceof Error ? err.message : String(err) + } + } + /** * 问这一整个文件或文件夹。材料是路径(文件再加开头若干行,文件夹再加这一层的名字), * 答成了记在 workspaceState 里,不进按块存的那份文件。没答成不记。 @@ -218,25 +340,16 @@ export class CodeQa implements Disposable { this.answered.fire({ owner, kind: 'end', ask, end }) return } - const id = randomUUID() - let names: string[] + let entry: QaEntry try { - names = await writeQaImages(at.folder.uri.fsPath, id, images) + const { id, question: asked, ...rest } = await chatEntry(at.folder.uri.fsPath, question, end.answer.sessionId, profile, images) + entry = { id, question: asked, answer: end.answer.text, ...rest } } catch (err) { const error = `回答没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})` this.answered.fire({ owner, kind: 'end', ask, end: { status: 'failed', error } }) return } - const entry: QaEntry = { - id, - question, - answer: end.answer.text, - createdAt: new Date().toISOString(), - sessionId: end.answer.sessionId, - ...(profile && { profile: profile.name }), - ...(names.length > 0 && { images: names }), - } await this.enqueuePath(async () => { await this.paths.update(PATH_QA_STATE_KEY, withPathQa(this.pathMap(), key, entry)) }) @@ -264,9 +377,12 @@ export class CodeQa implements Disposable { dispose(): void { this.settingsSubscription.dispose() this.asks.stopAll() + this.rewrites.stopAll() this.answered.dispose() + this.rewritten.dispose() this.pathChanged.dispose() this.store.dispose() + this.rewriteStore.dispose() } private pathMap() { diff --git a/vscode/src/codeReader/qa/qaMap.test.ts b/vscode/src/codeReader/qa/qaMap.test.ts index 41427bc..d023e73 100644 --- a/vscode/src/codeReader/qa/qaMap.test.ts +++ b/vscode/src/codeReader/qa/qaMap.test.ts @@ -1,4 +1,4 @@ -import type { QaEntry, QaMap } from './qaMap' +import type { QaEntry, QaMap, RewriteEntry, RewriteMap } from './qaMap' import { Buffer } from 'node:buffer' import { promises as fsp } from 'node:fs' import * as os from 'node:os' @@ -6,7 +6,7 @@ import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { detachedFiles, dropEntries, moveEntries, readPathMap, removeFiles, writePathMap } from '../pathMap' import { writeQaImages } from './qaImages' -import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap' +import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry } from './qaMap' function entry(id: string, createdAt: string, question = `问 ${id}`): QaEntry { return { id, question, answer: `答 ${id}`, createdAt, sessionId: `s-${id}` } @@ -177,8 +177,8 @@ describe('问题带的图片', () => { }) it('删一次问答,它的图片跟着删;别的问答的图片留着', async () => { - const a = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1'), png('a2')]) } - const b = { ...entry('b', '2026-09-26T10:00:00.000Z'), images: await writeQaImages(root, 'b', [png('b1')]) } + const a: QaEntry = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1'), png('a2')]) } + const b: QaEntry = { ...entry('b', '2026-09-26T10:00:00.000Z'), images: await writeQaImages(root, 'b', [png('b1')]) } await update(map => withEntry(withEntry(map, 'a.py', IF, a), 'a.py', DISPLAY, b)) expect(await exists(imageFile('a-1.png'))).toBe(true) await update(map => withoutEntry(map, 'a.py', IF, 'a')) @@ -188,7 +188,7 @@ describe('问题带的图片', () => { }) it('源文件删了、问答跟着删时图片也删;改名、挪位置按问答 id 起的名字不用动', async () => { - const a = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1')]) } + const a: QaEntry = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1')]) } await update(map => withEntry(map, 'src/a.py', IF, a)) await update(map => moveEntries(map, 'src', 'lib')) expect(await readPathMap(root, QA_FILE)).toEqual({ 'lib/a.py': [{ anchor: IF, entries: [a] }] }) @@ -197,3 +197,47 @@ describe('问题带的图片', () => { expect(await exists(imageFile('a-1.png'))).toBe(false) }) }) + +describe('重写注释的记录:另存一份,和问答分开', () => { + const rewriteFile = (): string => path.join(root, '.spx', 'comment-rewrites.json') + const exists = (file: string): Promise => fsp.access(file).then(() => true, () => false) + + function rewrite(id: string, createdAt: string, extra: Partial = {}): RewriteEntry { + return { id, question: '/ecc:writing-code-comments a.py:3-8', body: `正文 ${id}`, createdAt, sessionId: `r-${id}`, ...extra } + } + + it('存在 .spx/comment-rewrites.json,不进问答那份;正文、没写进文件的原因跟着这条读得回来,接着最近一次重写的会话', async () => { + const map: RewriteMap = withEntry(withEntry({}, 'a.py', IF, rewrite('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, rewrite('b', '2026-09-26T10:00:00.000Z', { error: '这段注释的定界符认不出来,没有改文件。' })) + await writePathMap(root, REWRITE_FILE, map) + expect(await exists(rewriteFile())).toBe(true) + expect(await exists(qaFile())).toBe(false) + expect(await readPathMap(root, REWRITE_FILE)).toEqual(map) + expect(await readPathMap(root, QA_FILE)).toEqual({}) + expect(latestSession(map['a.py'], IF)).toBe('r-b') + }) + + it('模型没交回正文的那次只记原因;正文和原因都没有、正文是空的不认', async () => { + await fsp.mkdir(path.dirname(rewriteFile()), { recursive: true }) + const silent = rewrite('s', '2026-09-25T10:00:00.000Z', { error: '模型没有按要求交回注释正文,没有改文件。' }) + delete silent.body + await fsp.writeFile(rewriteFile(), JSON.stringify({ 'a.py': [{ anchor: IF, entries: [ + silent, + { ...rewrite('bare', '2026-09-25T11:00:00.000Z'), body: undefined }, + rewrite('blank', '2026-09-25T12:00:00.000Z', { body: ' ' }), + ] }] }), 'utf8') + expect(await readPathMap(root, REWRITE_FILE)).toEqual({ 'a.py': [{ anchor: IF, entries: [silent] }] }) + }) + + it('带的图片和问答的放在同一个目录,删一条重写记录只删它自己的', async () => { + const png = { mediaType: 'image/png', base64: Buffer.from('r').toString('base64') } + const image = path.join(root, '.spx', 'code-qa', 'r-1.png') + const withImage = rewrite('r', '2026-09-25T10:00:00.000Z', { images: await writeQaImages(root, 'r', [png]) }) + const before = withEntry({}, 'a.py', IF, withImage) + await writePathMap(root, REWRITE_FILE, before) + expect(await readPathMap(root, REWRITE_FILE)).toEqual(before) + const after = withoutEntry(before, 'a.py', IF, 'r') + await writePathMap(root, REWRITE_FILE, after) + await removeFiles(root, detachedFiles(REWRITE_FILE, before, after)) + expect(await exists(image)).toBe(false) + }) +}) diff --git a/vscode/src/codeReader/qa/qaMap.ts b/vscode/src/codeReader/qa/qaMap.ts index 24ffbad..c3fac76 100644 --- a/vscode/src/codeReader/qa/qaMap.ts +++ b/vscode/src/codeReader/qa/qaMap.ts @@ -1,33 +1,44 @@ /** - * 代码问答的存法:每个工作区文件夹一份 `/.spx/code-qa.json`,读写的规矩同文件注释(见 pathMap)。 - * 形状是 `{ "相对路径": [{ anchor, entries: [{ id, question, answer, createdAt, sessionId, profile?, images? }] }] }`: - * 一份文件里挂着问答的每一块一项(anchor 见 BlockAnchors),块下面问过的按时间排。 - * 问题带的图片是 `.spx/code-qa/` 里的文件,条目删了(删一次问答、删源文件)文件跟着删(见 attachments)。 + * 块下面的对话怎么存:每个工作区文件夹里每种对话一份文件,读写的规矩同文件注释(见 pathMap)。 + * 问 AI 记在 `.spx/code-qa.json`,重写注释记在 `.spx/comment-rewrites.json`:两份分开,各自接着自己最近一次的会话。 + * 形状是 `{ "相对路径": [{ anchor, entries: [...] }] }`:一份文件里有记录的每一块一项(anchor 见 BlockAnchors),块下面的按时间排。 + * 带的图片是 `.spx/code-qa/` 里的文件,条目删了(删一条、删源文件)文件跟着删(见 attachments)。 */ import type { PathMap, PathMapFile } from '../pathMap' import { isQaImageName, QA_IMAGE_DIR } from './qaImages' -/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、Claude Code 的会话 id。 */ -export interface QaEntry { +/** 块下面的一轮:说的话、什么时候(ISO 时间)、Claude Code 的会话 id。 */ +export interface ChatEntry { id: string question: string - answer: string createdAt: string sessionId: string /** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */ profile?: string - /** 问题带的图片:`.spx/code-qa/` 里的文件名(见 qaImages),按贴的先后;没带图片时没有 */ + /** 带的图片:`.spx/code-qa/` 里的文件名(见 qaImages),按贴的先后;没带图片时没有 */ images?: string[] } -/** 挂在一块上的问答。 */ -export interface QaThread { +/** 问过的一次,带着回答(Markdown)。 */ +export interface QaEntry extends ChatEntry { + answer: string +} + +/** 重写过的一次:question 是用户说的话,第一轮是技能命令。body 是模型交回的注释正文,没交回时没有;error 是没写进文件的原因,写进去了没有。 */ +export interface RewriteEntry extends ChatEntry { + body?: string + error?: string +} + +/** 挂在一块上的记录。 */ +export interface QaThread { anchor: string - entries: QaEntry[] + entries: E[] } export type QaMap = PathMap +export type RewriteMap = PathMap[]> function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -37,17 +48,15 @@ function text(value: unknown): string | undefined { return typeof value === 'string' && value.trim() !== '' ? value : undefined } -function parseEntry(value: unknown): QaEntry | undefined { - if (!isRecord(value)) - return undefined - const [id, question, answer, createdAt, profile] = [text(value.id), text(value.question), text(value.answer), text(value.createdAt), text(value.profile)] - if (!id || !question || !answer || !createdAt || Number.isNaN(Date.parse(createdAt))) +/** 一轮共有的几项:没有 id、说的话,时间不对的不认;没有会话 id 的照样认,再说话时重新开会话。 */ +function parseChat(value: Record): ChatEntry | undefined { + const [id, question, createdAt, profile] = [text(value.id), text(value.question), text(value.createdAt), text(value.profile)] + if (!id || !question || !createdAt || Number.isNaN(Date.parse(createdAt))) return undefined const images = Array.isArray(value.images) ? value.images.filter((name): name is string => typeof name === 'string' && isQaImageName(name)) : [] return { id, question, - answer, createdAt, sessionId: typeof value.sessionId === 'string' ? value.sessionId : '', ...(profile && { profile }), @@ -55,9 +64,32 @@ function parseEntry(value: unknown): QaEntry | undefined { } } -/** 同一块的并成一项;块按记号排,问答按时间排:写出来的顺序稳定,git diff 干净。 */ -function tidy(threads: QaThread[]): QaThread[] { - const byAnchor = new Map() +function parseQaEntry(value: unknown): QaEntry | undefined { + if (!isRecord(value)) + return undefined + const chat = parseChat(value) + const answer = text(value.answer) + if (!chat || !answer) + return undefined + const { id, question, ...rest } = chat + return { id, question, answer, ...rest } +} + +/** 正文和没写成的原因至少有一样:两样都没有的不认。 */ +function parseRewriteEntry(value: unknown): RewriteEntry | undefined { + if (!isRecord(value)) + return undefined + const chat = parseChat(value) + const [body, error] = [text(value.body), text(value.error)] + if (!chat || (!body && !error)) + return undefined + const { id, question, ...rest } = chat + return { id, question, ...(body && { body }), ...(error && { error }), ...rest } +} + +/** 同一块的并成一项;块按记号排,一块下面的按时间排:写出来的顺序稳定,git diff 干净。 */ +function tidy(threads: QaThread[]): QaThread[] { + const byAnchor = new Map() for (const thread of threads) byAnchor.set(thread.anchor, [...byAnchor.get(thread.anchor) ?? [], ...thread.entries]) return [...byAnchor] @@ -69,34 +101,42 @@ function tidy(threads: QaThread[]): QaThread[] { })) } -/** 一份文件的问答:不是数组、一项都认不出的当没有;认不出的块、问答跳过。 */ -function parseThreads(value: unknown): QaThread[] | undefined { - if (!Array.isArray(value)) - return undefined - const threads = value.flatMap((item): QaThread[] => { - const anchor = isRecord(item) ? text(item.anchor) : undefined - if (!anchor || !isRecord(item) || !Array.isArray(item.entries)) - return [] - return [{ anchor, entries: item.entries.flatMap(entry => parseEntry(entry) ?? []) }] - }) - const tidied = tidy(threads) - return tidied.length > 0 ? tidied : undefined +/** 一份文件的记录:不是数组、一项都认不出的当没有;认不出的块、条目跳过。 */ +function threadsParser(parseEntry: (value: unknown) => E | undefined): (value: unknown) => QaThread[] | undefined { + return (value) => { + if (!Array.isArray(value)) + return undefined + const threads = value.flatMap((item): QaThread[] => { + const anchor = isRecord(item) ? text(item.anchor) : undefined + if (!anchor || !isRecord(item) || !Array.isArray(item.entries)) + return [] + return [{ anchor, entries: item.entries.flatMap(entry => parseEntry(entry) ?? []) }] + }) + const tidied = tidy(threads) + return tidied.length > 0 ? tidied : undefined + } } -export const QA_FILE: PathMapFile = { - path: '.spx/code-qa.json', - label: '代码问答', - parse: parseThreads, - attachments: threads => threads.flatMap(thread => thread.entries.flatMap(entry => (entry.images ?? []).map(name => `${QA_IMAGE_DIR}/${name}`))), +function threadsFile(path: string, label: string, parseEntry: (value: unknown) => E | undefined): PathMapFile[]> { + return { + path, + label, + parse: threadsParser(parseEntry), + attachments: threads => threads.flatMap(thread => thread.entries.flatMap(entry => (entry.images ?? []).map(name => `${QA_IMAGE_DIR}/${name}`))), + } } -/** 在 key 这份文件的 anchor 那块下面记一次问答。 */ -export function withEntry(map: QaMap, key: string, anchor: string, entry: QaEntry): QaMap { +export const QA_FILE = threadsFile('.spx/code-qa.json', '代码问答', parseQaEntry) + +export const REWRITE_FILE = threadsFile('.spx/comment-rewrites.json', '重写注释记录', parseRewriteEntry) + +/** 在 key 这份文件的 anchor 那块下面记一条。 */ +export function withEntry(map: PathMap[]>, key: string, anchor: string, entry: E): PathMap[]> { return { ...map, [key]: tidy([...map[key] ?? [], { anchor, entries: [entry] }]) } } -/** 删掉一次问答;块下面删光了这块就不挂了,文件里删光了这份文件就不记了。 */ -export function withoutEntry(map: QaMap, key: string, anchor: string, entryId: string): QaMap { +/** 删掉一条;块下面删光了这块就不挂了,文件里删光了这份文件就不记了。 */ +export function withoutEntry(map: PathMap[]>, key: string, anchor: string, entryId: string): PathMap[]> { const threads = tidy((map[key] ?? []).map(thread => thread.anchor === anchor ? { anchor, entries: thread.entries.filter(entry => entry.id !== entryId) } : thread)) @@ -105,7 +145,7 @@ export function withoutEntry(map: QaMap, key: string, anchor: string, entryId: s return threads.length > 0 ? { ...next, [key]: threads } : next } -/** 再问 anchor 那块时接着的会话:这块最近一次问答的会话 id;没问过、或那次没记会话 id 时没有,重新开会话。 */ -export function latestSession(threads: readonly QaThread[] | undefined, anchor: string): string | undefined { +/** 在 anchor 那块接着说时接着的会话:这块最近一条的会话 id;没有记录、或那条没记会话 id 时没有,重新开会话。 */ +export function latestSession(threads: readonly QaThread[] | undefined, anchor: string): string | undefined { return threads?.find(thread => thread.anchor === anchor)?.entries.at(-1)?.sessionId || undefined } diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 3fe70d8..f2c6756 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -59,8 +59,8 @@ export function App(): ReactElement | null { const qa = useQa() /** 侧栏「问 AI」要聚焦顶部输入框时加一 */ const [pathFocus, setPathFocus] = useState(0) - /** 按注释规范重写的过程,只在这一页上 */ - const rewrites = useRewrite() + /** 按注释规范重写:和问 AI 共用 profile 下拉 */ + const rewrite = useRewrite(qa.state.profiles, qa.onSelectProfile) const [doc, setDoc] = useState(null) const [view, setView] = useState(savedBlockModes) const [focusId, setFocusId] = useState(null) @@ -213,9 +213,6 @@ export function App(): ReactElement | null { editing: prev.editing.includes(id) ? prev.editing.filter(other => other !== id) : startEditing(doc.blocks, prev.editing, id), })) }, - rewrites: rewrites.runs, - onRewriteComment: rewrites.onRewrite, - onStopRewrite: rewrites.onStop, }, collapsed, hidden: filter.hidden, @@ -225,6 +222,7 @@ export function App(): ReactElement | null { onToggleFold: id => setFolded([id], !collapsed.has(id)), onSetFolds: folds => setView(prev => ({ ...prev, folds: { ...prev.folds, ...folds } })), ...(qa.state.available && { qa }), + ...(rewrite.state.available && { rewrite }), ...(changes && { diff: changes }), ...(folded && { folded }), onOpenRun: id => setOpenedRuns(prev => new Set([...prev, id])), @@ -233,7 +231,7 @@ export function App(): ReactElement | null { ...(openCall?.version === doc.version && { openAnchor: openCall.anchor }), onOpen: onOpenCall, }, - }, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrites, qa, changes, folded, callCounts, openCall, onOpenCall]) + }, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrite, qa, changes, folded, callCounts, openCall, onOpenCall]) if (!doc || !tree) return null diff --git a/vscode/webview-ui/src/codeReader/AskThread.test.tsx b/vscode/webview-ui/src/codeReader/AskThread.test.tsx index 897e56f..07e4dd4 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.test.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.test.tsx @@ -3,7 +3,7 @@ import type { Ask } from './qa' import type { ProfileChoice, QaEntryView } from './types' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' -import { AskThread } from './AskThread' +import { AskThread, REWRITE_THREAD } from './AskThread' const NOW = new Date(2026, 8, 26, 15, 0).getTime() @@ -112,3 +112,41 @@ describe('块下面的问答区', () => { expect(html).not.toContain('data-pending') }) }) + +describe('块下面的重写记录(样子和问答一样)', () => { + const REWRITES: QaEntryView[] = [ + { id: 'r1', question: '/ecc:writing-code-comments web/a.tsx:3-40', answer: '我的智能体页:用卡片网格列出自己的智能体。', createdAt: new Date(2026, 8, 25, 10, 0).toISOString() }, + { id: 'r2', question: '加上原型图', answer: '我的智能体页。\n\n![原型](web/prototypes/A.png)', createdAt: new Date(2026, 8, 26, 14, 0).toISOString(), error: '编辑器没接受这次修改,没有写回。' }, + ] + + it('没展开:一行「重写过 N 次」,下面灰字历史,没写进文件的那条带着原因、能删;没有输入框', () => { + const html = render({ wording: REWRITE_THREAD, history: REWRITES }) + expect(text(html)).toMatch(/^重写过 2 次/) + expect(text(html)).toContain('/ecc:writing-code-comments web/a.tsx:3-40') + expect(text(html)).toContain('没写进文件:编辑器没接受这次修改,没有写回。') + expect(html.match(/data-tooltip="删除这条记录" aria-label="删除这条记录"/g)).toHaveLength(2) + expect(html).not.toContain(' { + const html = render({ wording: REWRITE_THREAD, open: true, history: REWRITES, asks: [ask({ question: '/ecc:writing-code-comments web/a.tsx:3-40', text: '新的**注释**', activity: '运行 op export' })] }) + expect(html).toContain('aria-label="按注释规范重写"') + expect(html).toContain('data-streaming=""') + expect(text(html)).toContain('运行 op export') + expect(html).toContain('placeholder="说说这段注释要怎么改… Enter 发送,Esc 收起"') + expect(html).toContain('aria-label="对这段注释的修改意见"') + expect(html).toContain('cr-qa-profile') + expect(html).toContain('data-tooltip="停止" aria-label="停止"') + }) + + it('答完了但没写进文件:照常显示交回的正文,下面写着原因;出错、停止同问答', () => { + const done = render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'done', text: '正文', error: '这块代码在重写期间变了,没有写回。', entryId: 'r3' })] }) + expect(done).toContain('role="alert"') + expect(text(done)).toContain('正文') + expect(text(done)).toContain('这块代码在重写期间变了,没有写回。') + const written = render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'done', text: '正文', entryId: 'r3' })] }) + expect(written).not.toContain('role="alert"') + expect(text(render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'stopped' })] }))).toContain('已停止,这次没存。') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx index 06f890c..3aa2f1e 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -1,9 +1,9 @@ +import type { LucideIcon } from 'lucide-react' import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'react' import type { Ask } from './qa' import type { PendingImage } from './qaImages' -import type { RewriteRun } from './rewriteRun' import type { ProfileChoice, QaEntryView } from './types' -import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react' +import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageSquareText, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { SelectMenu } from '@/components/ui/select-menu' @@ -11,6 +11,39 @@ import { renderMarkdown, renderMarkdownOnce } from './markdown' import { answerPreview, relativeTime } from './qa' import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages' +/** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 N 次」、删一条、输入框的提示和名字、回答长出来时的状态 */ +export interface ThreadWording { + label: string + icon: LucideIcon + summary: (count: number) => string + deleteLabel: string + /** first:还没说过;hint:「问」后面的对象 */ + placeholder: (first: boolean, hint: string) => string + inputLabel: string + answering: string +} + +export const ASK_THREAD: ThreadWording = { + label: '问 AI', + icon: Sparkles, + summary: count => `问过 ${count} 次`, + deleteLabel: '已理解,删除', + placeholder: (first, hint) => first ? `问${hint}…` : '继续问…', + inputLabel: '问 AI 的问题', + answering: '正在回答…', +} + +/** 重写注释:第一轮由块右上角的按钮发技能命令,输入框里说的是对这段注释的修改意见 */ +export const REWRITE_THREAD: ThreadWording = { + label: '按注释规范重写', + icon: MessageSquareText, + summary: count => `重写过 ${count} 次`, + deleteLabel: '删除这条记录', + placeholder: () => '说说这段注释要怎么改…', + inputLabel: '对这段注释的修改意见', + answering: '正在写…', +} + /** 问题带的图片:一排小缩略图,点一张看大图;给了 onRemove 的(贴了还没发的)右上角有「去掉」。没有图片时什么都不画。 */ function Thumbs({ urls, pending, onOpen, onRemove }: { urls: string[], pending?: boolean, onOpen: (url: string) => void, onRemove?: (index: number) => void }): ReactElement | null { if (urls.length === 0) @@ -64,14 +97,18 @@ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): R ) } -/** 问过的一次,灰字:问题、时间、「已理解,删除」,问题带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */ -function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement { +/** + * 说过的一次,灰字:说的话、时间、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排)。 + * 重写记录没写进文件时,最下面写着原因 + */ +function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: QaEntryView, wording: ThreadWording, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement { const [expanded, setExpanded] = useState(false) const toggle = (): void => setExpanded(value => !value) + const Icon = wording.icon return (
- + @@ -81,7 +118,7 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView {entry.profile && {` · ${entry.profile}`}} -
@@ -93,18 +130,25 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView {answerPreview(entry.answer)} )} + {entry.error && ( +
+ + {`没写进文件:${entry.error}`} +
+ )}
) } -/** 当前这一轮问的一次,照常颜色:问题,回答逐字长出来(按 Markdown 排),在做的事、出错、停止写在下面 */ -function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url: string) => void, onStop?: () => void }): ReactElement { +/** 当前这一轮说的一次,照常颜色:说的话,回答逐字长出来(按 Markdown 排),在做的事、出错(重写注释没写进文件也算)、停止写在下面 */ +function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadWording, onOpenImage: (url: string) => void }): ReactElement { const running = ask.status === 'running' - const label = ask.activity ?? (ask.text === '' ? '正在想…' : '正在回答…') + const label = ask.activity ?? (ask.text === '' ? '正在想…' : wording.answering) + const Icon = wording.icon return (
- + {ask.question}
@@ -118,15 +162,10 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url {running && (
- {onStop ? {label} : label} - {onStop && ( - - )} + {label}
)} - {ask.status === 'failed' && ( + {ask.error !== undefined && ask.status !== 'running' && (
{ask.error} @@ -138,12 +177,12 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url } /** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */ -function AskInput({ running, first, hint, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: { +function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: { running: Ask | undefined - /** 还没问过:提示「问…」,问过了是「继续问…」 */ - first: boolean - /** 「问」后面的对象:块下面是「这块代码」,顶部是「这个文件」或「这个文件夹」 */ - hint: string + /** 输入框里的提示(后面接着怎么发送、收起) */ + placeholder: string + /** 输入框读屏用的名字 */ + label: string profiles: ProfileChoice onSelectProfile: (id: string) => void onAsk: (question: string, images: PendingImage[]) => void @@ -228,8 +267,8 @@ function AskInput({ running, first, hint, profiles, onSelectProfile, onAsk, onSt ref={textarea} rows={1} value={value} - placeholder={`${first ? `问${hint}…` : '继续问…'} Enter 发送${closable ? ',Esc 收起' : ''}`} - aria-label="问 AI 的问题" + placeholder={`${placeholder} Enter 发送${closable ? ',Esc 收起' : ''}`} + aria-label={label} onChange={event => setValue(event.target.value)} onKeyDown={onKeyDown} onPaste={onPaste} @@ -268,6 +307,8 @@ export interface AskThreadProps { profiles: ProfileChoice /** 「问」后面的对象,默认「这块代码」 */ hint?: string + /** 哪种对话,默认问 AI */ + wording?: ThreadWording /** 展开时聚焦输入框,默认聚焦。一直摊开的顶部聊天不聚焦 */ autoFocus?: boolean /** 变大时把光标放进输入框 */ @@ -276,7 +317,7 @@ export interface AskThreadProps { closable?: boolean now?: number onSelectProfile: (id: string) => void - /** images 是贴进输入框的图片,随问题一起发 */ + /** images 是贴进输入框的图片,随这句话一起发 */ onAsk: (question: string, images: PendingImage[]) => void onStop: (askId: string) => void onDelete: (entryId: string) => void @@ -284,14 +325,16 @@ export interface AskThreadProps { } /** - * 块下面的问答区(左边一条竖线):展开时是灰字的历史、当前这一轮、输入框; - * 没展开但问过的块是一行「问过 N 次」、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里问过什么。 + * 块下面的对话区(问 AI、重写注释,左边一条竖线):展开时是灰字的历史、当前这一轮、输入框; + * 没展开但说过的块是一行「问过 N 次」(重写注释是「重写过 N 次」)、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里说过什么。 */ -export function AskThread({ history, asks, open, profiles, hint = '这块代码', autoFocus = true, focusToken = 0, closable = true, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null { +export function AskThread({ history, asks, open, profiles, hint = '这块代码', wording = ASK_THREAD, autoFocus = true, focusToken = 0, closable = true, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null { const [showHistory, setShowHistory] = useState(true) /** 正在看大图的那张 */ const [preview, setPreview] = useState() const lightbox = preview !== undefined && setPreview(undefined)} /> + const Icon = wording.icon + const historyItems = history.map(entry => onDelete(entry.id)} onOpenImage={setPreview} />) if (!open) { if (history.length === 0) return null @@ -299,26 +342,26 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码' return (
- {showHistory && history.map(entry => onDelete(entry.id)} onOpenImage={setPreview} />)} + {showHistory && historyItems} {lightbox}
) } return ( -
- {history.map(entry => onDelete(entry.id)} onOpenImage={setPreview} />)} +
+ {historyItems} {history.length > 0 && asks.length > 0 &&
} - {asks.map(ask => )} + {asks.map(ask => )} ask.status === 'running')} - first={history.length === 0 && asks.length === 0} + placeholder={wording.placeholder(history.length === 0 && asks.length === 0, hint)} + label={wording.inputLabel} profiles={profiles} onSelectProfile={onSelectProfile} - hint={hint} onAsk={onAsk} onStop={onStop} onClose={onClose} @@ -331,25 +374,3 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
) } - -/** - * 注释块下面的重写过程,样子和展开的「问 AI」一样:左边竖线、问题、流出来的回答、正在读哪个文件。 - * - * 问题是发给模型的那句固定要求。没有输入框,不记历史。写进文件后由调用方拿掉;失败或停下就留在这里。 - */ -export function RewriteThread({ run, onStop }: { run: RewriteRun, onStop: () => void }): ReactElement { - const ask: Ask = { - askId: 'rewrite', - anchor: '', - question: run.question, - text: run.text, - ...(run.activity !== undefined && { activity: run.activity }), - status: run.status, - ...(run.error !== undefined && { error: run.error }), - } - return ( -
- {}} onStop={run.status === 'running' ? onStop : undefined} /> -
- ) -} diff --git a/vscode/webview-ui/src/codeReader/BlockTools.test.tsx b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx index 0dd7666..4f0f618 100644 --- a/vscode/webview-ui/src/codeReader/BlockTools.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx @@ -2,13 +2,13 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import { BlockTools } from './BlockTools' -function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean, fresh?: boolean }, callTree?: { count: number }): string { +function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean, fresh?: boolean, reopen?: boolean }, callTree?: { count: number }): string { return renderToStaticMarkup( {}} {...(ask && { asking: ask.asking, onToggleAsk: () => {} })} - {...(rewrite && { rewrite: { rewriting: rewrite.rewriting, fresh: rewrite.fresh ?? false, onRewrite: () => {} } })} + {...(rewrite && { rewrite: { rewriting: rewrite.rewriting, fresh: rewrite.fresh ?? false, reopen: rewrite.reopen ?? false, onRewrite: () => {} } })} {...(callTree && { callTree: { anchor: 'fn', count: callTree.count, open: false, onOpen: () => {} } })} />, ) @@ -66,6 +66,13 @@ describe('块右上角的图标按钮', () => { expect(buttons[2]).toContain('data-tooltip="编辑"') }) + it('重写过、下面的重写记录收着时,同一个按钮先展开记录(接着说),叫「展开重写记录」', () => { + const reopen = render(false, { asking: false }, { rewriting: false, reopen: true }) + const buttons = [...reopen.matchAll(/]*>/g)].map(match => match[0]) + expect(buttons[0]).toContain('data-tooltip="展开重写记录" aria-label="展开重写记录"') + expect(buttons[0]).not.toContain('disabled') + }) + it('调用树排在编辑后面;棵数大于 0 时按钮上带这个数字,是 0 时只有图标', () => { const html = render(false, { asking: false }, { rewriting: false }, { count: 3 }) const buttons = [...html.matchAll(/]*>/g)].map(match => match[0]) diff --git a/vscode/webview-ui/src/codeReader/BlockTools.tsx b/vscode/webview-ui/src/codeReader/BlockTools.tsx index 8ab88f2..8c66b7c 100644 --- a/vscode/webview-ui/src/codeReader/BlockTools.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTools.tsx @@ -2,16 +2,21 @@ import type { ReactElement } from 'react' import { Eye, GitBranch, MessageSquareText, Pencil, Sparkles } from 'lucide-react' import { cn } from '@/lib/utils' -/** 「重写注释」按钮的样子和动作:fresh 是块还没有注释、点了新写一段;正在重写时不能再点。 */ +/** + * 「重写注释」按钮的样子和动作:fresh 是块还没有注释、点了新写一段;正在重写时不能再点; + * reopen 是重写过、下面的重写记录收着,点了先展开它(好接着说),不重写。 + */ export interface RewriteTool { fresh: boolean rewriting: boolean + reopen: boolean onRewrite: () => void } /** * 块右上角的一排图标按钮(普通块、属性表、库规则的卡片共用):只放图标,名字写在悬停提示(data-tooltip)和读屏用的 aria-label 里。 - * 「按注释规范重写」:用写注释的技能重写这块代码的注释并写回文件,块还没有注释时叫「按注释规范写注释」、新写一段;正在重写时不能再点。图标是注释。 + * 「按注释规范重写」:用写注释的技能重写这块代码的注释并写回文件,块还没有注释时叫「按注释规范写注释」、新写一段;正在重写时不能再点。 + * 重写过、记录收着时叫「展开重写记录」,点了只展开。图标是注释。 * 「问 AI」:点开 / 收起块下面的问答区,展开时带按下的底色。 * 「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。 */ @@ -29,7 +34,7 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite }): ReactElement { const label = editing ? '预览' : '编辑' const Icon = editing ? Eye : Pencil - const rewriteLabel = rewrite?.fresh ? '按注释规范写注释' : '按注释规范重写' + const rewriteLabel = rewrite?.reopen ? '展开重写记录' : rewrite?.fresh ? '按注释规范写注释' : '按注释规范重写' return (
{rewrite && ( diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index a4a28cc..914c135 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -1,4 +1,5 @@ -import type { BlockTreeState } from './BlockTree' +import type { BlockTreeState, RewriteTree } from './BlockTree' +import type { ChatState } from './qa' import type { BlockView, KindBadge } from './types' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' @@ -11,6 +12,7 @@ import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh import { BlockLevel } from './BlockTree' import { applyFilter } from './filter' import { FilterBar } from './FilterBar' +import { EMPTY_CHAT } from './qa' import { collapsedIds } from './viewState' // 高亮的上下文所在的模块、编辑框都连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着 @@ -58,7 +60,12 @@ const CARD: BlockView = { ], } -function state(collapsed: ReadonlySet): BlockTreeState { +/** 重写注释的状态和动作:动作什么都不做 */ +function rewriteTree(state: ChatState): RewriteTree { + return { state, profiles: { options: [], selected: '' }, onRewrite: () => {}, onAsk: () => {}, onStop: () => {}, onDelete: () => {}, onOpen: () => {}, onClose: () => {}, onSelectProfile: () => {} } +} + +function state(collapsed: ReadonlySet, rewrite: ChatState = { ...EMPTY_CHAT, available: true }): BlockTreeState { return { edit: { version: 1, @@ -66,10 +73,8 @@ function state(collapsed: ReadonlySet): BlockTreeState { focusId: null, onFocused: () => {}, onToggleEdit: () => {}, - rewrites: new Map(), - onRewriteComment: () => {}, - onStopRewrite: () => {}, }, + rewrite: rewriteTree(rewrite), collapsed, hidden: new Set(), temporary: new Set(), @@ -197,6 +202,19 @@ describe('「注释」一行和「重写注释」按钮', () => { inToolbar(markup, '按注释规范重写') }) + it('重写过的块:下面一行「重写过 N 次」带灰字历史;记录收着时按钮先展开它,展开着时再点才重写', () => { + const block = view('5', 'statement', 'save()', { commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } }) + const entry = { id: 'r1', question: '/ecc:writing-code-comments a.py:1-1', answer: '保存。', createdAt: '2026-09-25T10:00:00.000Z' } + const closed = { ...EMPTY_CHAT, available: true, threads: [{ anchor: block.anchor, entries: [entry] }] } + const collapsedMarkup = renderToStaticMarkup() + expect(collapsedMarkup.replace(/<[^>]*>/g, '')).toContain('重写过 1 次') + inToolbar(collapsedMarkup, '展开重写记录') + const opened = renderToStaticMarkup() + expect(opened).toMatch(/role="group" aria-label="块操作">]*aria-label="按注释规范重写"/) + expect(opened).toContain('
') + expect(opened).toContain('aria-label="对这段注释的修改意见"') + }) + it('还没有注释的块:按钮同样在右上角那排,叫「按注释规范写注释」;不能写注释的块没有按钮', () => { inToolbar(html(view('3', 'statement', 'save()', { commentTarget: { kind: 'insert', line: 0 } })), '按注释规范写注释') expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范') diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 2f827ad..8163ca8 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -2,13 +2,12 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react' import type { RewriteTool } from './BlockTools' import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView' import type { GridSlots } from './PropertyGrid' -import type { QaState } from './qa' +import type { ChatState, QaState } from './qa' import type { PendingImage } from './qaImages' -import type { RewriteRun } from './rewriteRun' -import type { BlockMarks, BlockView } from './types' +import type { BlockMarks, BlockView, ProfileChoice } from './types' import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react' import { Fragment, useState } from 'react' -import { AskThread, RewriteThread } from './AskThread' +import { AskThread, REWRITE_THREAD } from './AskThread' import { BlockTools } from './BlockTools' import { CodeEditor } from './CodeEditor' import { DiffText } from './DiffText' @@ -20,7 +19,7 @@ import { TextPreview } from './TextPreview' import { tableId } from './viewState' /** - * 块树里改代码的那部分:哪些块在编辑、点「编辑」、按注释规范重写。页面上的块树才有;定义卡片浮窗里的块树没有, + * 块树里改代码的那部分:哪些块在编辑、点「编辑」。页面上的块树才有;定义卡片浮窗里的块树没有, * 卡片只读,不画右上角的按钮 */ export interface EditTree { @@ -30,11 +29,6 @@ export interface EditTree { focusId: string | null onFocused: () => void onToggleEdit: (id: string) => void - /** 按注释规范重写的过程,按块的记号。只在这一页上 */ - rewrites: ReadonlyMap - onRewriteComment: (anchor: string) => void - /** 叫停这块正在进行的重写,不写文件 */ - onStopRewrite: (anchor: string) => void } /** 函数、类落在几棵调用树里。定义浮窗不传,跟问 AI 一样不画这排按钮。 */ @@ -64,6 +58,8 @@ export interface BlockTreeState { onSetFolds: (folds: Record) => void /** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */ qa?: QaTree + /** 按注释规范重写;同问 AI,文件不在工作区文件夹里时没有 */ + rewrite?: RewriteTree /** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */ diff?: DiffTree /** 「只看改动」开着时折起来的连着没变的块(见 foldUnchanged);onOpenRun 点开一段(按第一块的 id) */ @@ -71,6 +67,21 @@ export interface BlockTreeState { onOpenRun: (id: string) => void } +/** 重写注释的状态和动作,块按自己的记号(anchor)取;profile 下拉和问 AI 共用 */ +export interface RewriteTree { + state: ChatState + profiles: ProfileChoice + /** 点了块右上角的按钮:按技能命令重写这块(开新会话),展开它的重写记录 */ + onRewrite: (anchor: string) => void + /** 在输入框里说的修改意见,接着这块最近一次重写的会话说;images 是贴的图片 */ + onAsk: (anchor: string, request: string, images: PendingImage[]) => void + onStop: (askId: string) => void + onDelete: (anchor: string, entryId: string) => void + onOpen: (anchor: string) => void + onClose: (anchor: string) => void + onSelectProfile: (id: string) => void +} + /** 问 AI 的状态和动作,块按自己的记号(anchor)取 */ export interface QaTree { state: QaState @@ -171,7 +182,7 @@ export function BlockLevel({ blocks, owner, tree, slotted = false }: { blocks: B {tree.qa && } - {tree.edit && } + {tree.rewrite && } {hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && ( )} @@ -203,11 +214,22 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement { ) } -function BlockRewrite({ anchor, edit }: { anchor: string, edit: EditTree }): ReactElement | null { - const run = edit.rewrites.get(anchor) - if (!run) - return null - return edit.onStopRewrite(anchor)} /> +/** 块下面的重写记录,紧跟在问答区后面;样子和问答区一样,输入框里说的是对这段注释的修改意见 */ +function BlockRewrite({ anchor, rewrite }: { anchor: string, rewrite: RewriteTree }): ReactElement | null { + return ( + rewrite.onAsk(anchor, request, images)} + onStop={rewrite.onStop} + onDelete={entryId => rewrite.onDelete(anchor, entryId)} + onClose={() => rewrite.onClose(anchor)} + /> + ) } /** @@ -248,13 +270,23 @@ function callTreeTool(block: BlockView, tree: BlockTreeState) { } } -function rewriteTool(block: BlockView, edit: EditTree | undefined): RewriteTool | undefined { - if (!edit || !block.commentTarget) +/** + * 这块的「重写注释」按钮:能写注释的块才有。重写过、记录收着时点了先展开记录(好接着说);展开着、或还没重写过时点了按技能命令重写。 + * 展开着又正在写时不能再点。 + */ +function rewriteTool(block: BlockView, tree: BlockTreeState): RewriteTool | undefined { + const { edit, rewrite } = tree + if (!edit || !rewrite || !block.commentTarget) return undefined + const { anchor } = block + const open = rewrite.state.open.includes(anchor) + const asks = asksOf(rewrite.state, anchor) + const reopen = !open && (asks.length > 0 || historyOf(rewrite.state, anchor).length > 0) return { fresh: block.commentTarget.kind === 'insert', - rewriting: edit.rewrites.get(block.anchor)?.status === 'running', - onRewrite: () => edit.onRewriteComment(block.anchor), + rewriting: open && asks.some(ask => ask.status === 'running'), + reopen, + onRewrite: () => reopen ? rewrite.onOpen(anchor) : rewrite.onRewrite(anchor), } } @@ -339,7 +371,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): onToggleEdit={() => edit.onToggleEdit(block.id)} asking={tree.qa?.state.open.includes(block.anchor)} onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))} - rewrite={rewriteTool(block, edit)} + rewrite={rewriteTool(block, tree)} callTree={callTreeTool(block, tree)} /> )} diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx index 2672b0a..dda6450 100644 --- a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx +++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx @@ -62,9 +62,6 @@ function tree(callTree?: BlockTreeState['callTree'], edit = true): BlockTreeStat focusId: null, onFocused: () => {}, onToggleEdit: () => {}, - rewrites: new Map(), - onRewriteComment: () => {}, - onStopRewrite: () => {}, }, }), collapsed: new Set(), diff --git a/vscode/webview-ui/src/codeReader/RewriteThread.test.tsx b/vscode/webview-ui/src/codeReader/RewriteThread.test.tsx deleted file mode 100644 index fe320d1..0000000 --- a/vscode/webview-ui/src/codeReader/RewriteThread.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { RewriteRun } from './rewriteRun' -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import { RewriteThread } from './AskThread' - -const QUESTION = '/ecc:writing-code-comments\n\n只按这个技能重写这一段注释。' - -function render(run: RewriteRun): string { - return renderToStaticMarkup( {}} />) -} - -const text = (html: string): string => html.replace(/<[^>]*>/g, '') - -describe('按注释规范重写的过程', () => { - const running: RewriteRun = { question: QUESTION, text: '新的**注释**', activity: '读取 routes.py', status: 'running' } - - it('进行中画出固定的要求、流出来的回答和正在读的文件,能停;没有输入框、没有「问过 N 次」', () => { - const html = render(running) - expect(html).toContain('aria-label="按注释规范重写"') - expect(html).toContain('class="cr-qa"') - expect(text(html)).toContain('/ecc:writing-code-comments') - expect(html).toContain('data-streaming=""') - expect(text(html)).toContain('新的注释') - expect(text(html)).toContain('读取 routes.py') - expect(html).toContain('aria-label="停止"') - expect(html).not.toContain(' { - const html = render({ ...running, text: '一半', status: 'failed', error: '模型没有给出注释正文,没有改文件。' }) - expect(html).toContain('role="alert"') - expect(text(html)).toContain('模型没有给出注释正文,没有改文件。') - expect(text(html)).toContain('/ecc:writing-code-comments') - expect(html).not.toContain('问过') - expect(html).not.toContain(' ({ id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-09-25T10:00:00.000Z' })) } } -function message(threads: QaThreadView[], running: QaMessage['running'] = []): QaMessage { - return { type: 'qa', available: true, threads, path: [], subject: 'file', running } +function message(threads: QaThreadView[], current: QaMessage['current'] = []): QaMessage { + return { type: 'qa', available: true, threads, path: [], subject: 'file', current, rewrites: [], rewriteCurrent: [] } } -function qa(threads: QaThreadView[], running: QaMessage['running'] = []): QaAction { - return { type: 'qa', message: message(threads, running) } +function qa(threads: QaThreadView[], current: QaMessage['current'] = []): QaAction { + return { type: 'qa', message: message(threads, current) } } function run(...actions: QaAction[]): QaState { @@ -28,7 +28,7 @@ describe('问 AI 的状态', () => { expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '', status: 'running' }]) state = qaReducer(state, { type: 'answer', askId: 'a1', text: '因为', activity: '读取 a.py' }) expect(asksOf(state, IF)[0]).toMatchObject({ text: '因为', activity: '读取 a.py' }) - state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new' } }) + state = qaReducer(state, { type: 'answerEnd', end: { askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new' } }) state = qaReducer(state, qa([thread(IF, 'old', 'new')])) expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '因为要鉴权。', status: 'done', entryId: 'new' }]) expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old']) @@ -51,7 +51,9 @@ describe('问 AI 的状态', () => { threads: [], path: [{ id: 'p', question: '这文件夹干什么', answer: '放源码', createdAt: '2026-09-25T10:00:00.000Z' }], subject: 'folder', - running: [], + current: [], + rewrites: [], + rewriteCurrent: [], }, }) expect(state.path.map(entry => entry.id)).toEqual(['p']) @@ -64,7 +66,7 @@ describe('问 AI 的状态', () => { let state = run( qa([thread(IF, 'e1')]), { type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] }, - { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1' } }, + { type: 'answerEnd', end: { askId: 'a1', status: 'done', entryId: 'e1' } }, { type: 'ask', askId: 'a2', anchor: IF, question: '2', images: [] }, { type: 'close', anchor: IF }, ) @@ -76,20 +78,20 @@ describe('问 AI 的状态', () => { }) it('页面重新加载后:扩展端还在答的接上、展开它那块;已知道的不重复', () => { - const running = [{ askId: 'a1', anchor: IF, question: '1', text: '答了一半' }] + const running = [{ askId: 'a1', anchor: IF, question: '1', text: '答了一半', status: 'running' as const }] let state = run(qa([], running)) expect(state.open).toEqual([IF]) - expect(asksOf(state, IF)).toEqual([{ ...running[0], status: 'running' }]) + expect(asksOf(state, IF)).toEqual([running[0]]) state = qaReducer(state, qa([], running)) expect(state.asks).toHaveLength(1) }) it('停止、失败照记;不认识的问题的消息不理', () => { let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] }, { type: 'answer', askId: 'a1', text: '一半' }) - state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'stopped' } }) + state = qaReducer(state, { type: 'answerEnd', end: { askId: 'a1', status: 'stopped' } }) expect(asksOf(state, IF)[0]).toMatchObject({ status: 'stopped', text: '一半' }) expect(qaReducer(state, { type: 'answer', askId: 'a1', text: '晚到的' })).toEqual(state) - expect(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'nope', status: 'done' } })).toEqual(state) + expect(qaReducer(state, { type: 'answerEnd', end: { askId: 'nope', status: 'done' } })).toEqual(state) }) it('问题带的图片跟着这一问:答完、失败、停止都还在;页面重新加载后接上的没有', () => { @@ -97,12 +99,39 @@ describe('问 AI 的状态', () => { let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '看图', images }) expect(asksOf(state, IF)[0].images).toEqual(images) for (const status of ['done', 'failed', 'stopped'] as const) - expect(asksOf(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status } }), IF)[0].images).toEqual(images) - state = run(qa([], [{ askId: 'a2', anchor: IF, question: '1', text: '' }])) + expect(asksOf(qaReducer(state, { type: 'answerEnd', end: { askId: 'a1', status } }), IF)[0].images).toEqual(images) + state = run(qa([], [{ askId: 'a2', anchor: IF, question: '1', text: '', status: 'running' }])) expect(asksOf(state, IF)[0].images).toBeUndefined() }) }) +describe('重写注释的状态:和问 AI 同一套', () => { + const SKILL = '/ecc:writing-code-comments a.py:3-8' + + it('页面重新加载后:扩展端还在写的那一轮接上、展开它那块,后面的进展接得上;出错、被叫停留着的照原样画出来', () => { + let state = chatReducer(EMPTY_CHAT, { type: 'sync', available: true, threads: [], current: [ + { askId: 'r1', anchor: IF, question: SKILL, text: '一半', status: 'running' }, + { askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' }, + ] }) + expect(state.open).toEqual([IF, 'class A']) + expect(asksOf(state, IF)).toEqual([{ askId: 'r1', anchor: IF, question: SKILL, text: '一半', status: 'running' }]) + expect(asksOf(state, 'class A')).toEqual([{ askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' }]) + state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '写完了大半', activity: '运行 op export' }) + expect(asksOf(state, IF)[0]).toMatchObject({ text: '写完了大半', activity: '运行 op export', status: 'running' }) + state = chatReducer(state, { type: 'answerEnd', end: { askId: 'r1', status: 'done', text: '正文', error: '编辑器没接受这次修改,没有写回。', entryId: 'e1' } }) + expect(asksOf(state, IF)[0]).toMatchObject({ status: 'done', text: '正文', error: '编辑器没接受这次修改,没有写回。', entryId: 'e1' }) + state = chatReducer(state, { type: 'sync', available: true, threads: [], current: [{ askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' }] }) + expect(state.asks).toHaveLength(2) + }) + + it('点按钮那一轮开始时还不知道那句技能命令,扩展端开始后报上来就换上', () => { + let state = chatReducer(EMPTY_CHAT, { type: 'ask', askId: 'r1', anchor: IF, question: '', images: [] }) + expect(state.open).toEqual([IF]) + state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '' }) + expect(asksOf(state, IF)[0]).toMatchObject({ question: SKILL, status: 'running' }) + }) +}) + describe('问的时间', () => { const now = new Date(2026, 8, 26, 15, 0).getTime() const at = (year: number, month: number, day: number, hour: number, minute: number, second = 0): string => diff --git a/vscode/webview-ui/src/codeReader/qa.ts b/vscode/webview-ui/src/codeReader/qa.ts index e871bfa..783b496 100644 --- a/vscode/webview-ui/src/codeReader/qa.ts +++ b/vscode/webview-ui/src/codeReader/qa.ts @@ -1,24 +1,24 @@ /** - * 「问 AI」在页面上的状态和规则(不碰 DOM):块下面的问答按块的记号挂; - * 顶部的问答按路径记在 path 上,不挂到任何一块。 - * 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史。 + * 块下面的对话(问 AI、重写注释)在页面上的状态和规则(不碰 DOM):两种各一份 ChatState,按块的记号挂; + * 问 AI 另有顶部的问答,按路径记在 path 上,不挂到任何一块。 + * 这次打开以来说的是「当前这一轮」,照常颜色显示,别的都是灰字的历史。 */ -import type { AnswerEnd, ProfileChoice, QaEntryView, QaMessage, QaThreadView } from './types' +import type { AskRound, ProfileChoice, QaEntryView, QaMessage, QaThreadView, RoundEnd } from './types' -/** 这次打开以来问的一个问题 */ +/** 这次打开以来的一轮 */ export interface Ask { askId: string anchor: string question: string - /** 问题带的图片(这边贴的 data: 地址);页面重新加载后接上的、扩展端还在答的没有 */ + /** 带的图片(这边贴的 data: 地址);页面重新加载后接上的、扩展端还在进行的没有 */ images?: string[] /** 回答到现在为止的全文(Markdown) */ text: string /** 正在做的事:「读取 routes.py」 */ activity?: string status: 'running' | 'done' | 'failed' | 'stopped' - /** failed 时给人看的原因 */ + /** failed 时给人看的原因;重写注释 done 了但没写进文件时也有 */ error?: string /** done 时存下的那条:历史里不再重复显示它 */ entryId?: string @@ -27,68 +27,79 @@ export interface Ask { /** 顶部问答的记号。跟扩展端 pathQa 里的是同一个,块的记号不会是这个。 */ export const PATH_ASK_ANCHOR = '\0path' -export interface QaState { - /** 文件不在工作区文件夹里时问答没地方存,不给「问 AI」 */ +/** 一种块下面的对话的状态 */ +export interface ChatState { + /** 文件不在工作区文件夹里时记录没地方存,不给这种对话 */ available: boolean threads: QaThreadView[] + /** 当前这一轮的,按先后 */ + asks: Ask[] + /** 展开着对话区(历史、当前这一轮、输入框)的块 */ + open: string[] +} + +export interface QaState extends ChatState { /** 这一页顶部按路径记的问答 */ path: QaEntryView[] /** 顶部问的是这个文件还是这个文件夹 */ subject: 'file' | 'folder' - /** 当前这一轮问的,按先后 */ - asks: Ask[] - /** 展开着对话区(历史、当前这一轮、输入框)的块 */ - open: string[] /** 输入框旁边的 profile 下拉,所有块共用 */ profiles: ProfileChoice } -export const EMPTY_QA: QaState = { +export const EMPTY_CHAT: ChatState = { available: false, threads: [], - path: [], - subject: 'file', asks: [], open: [], +} + +export const EMPTY_QA: QaState = { + ...EMPTY_CHAT, + path: [], + subject: 'file', profiles: { options: [], selected: '' }, } -export type QaAction - = | { type: 'qa', message: QaMessage } - | { type: 'profiles', profiles: ProfileChoice } - | { type: 'answer', askId: string, text: string, activity?: string } - | { type: 'answerEnd', end: AnswerEnd } +/** + * sync:扩展端发来的记录和它记着的当前一轮;answer:askId 那一轮的回答长了(question 是扩展端报上来的这一轮的话,点按钮那一轮开始时页面还不知道) + */ +export type ChatAction + = | { type: 'sync', available: boolean, threads: QaThreadView[], current: AskRound[] } + | { type: 'answer', askId: string, text: string, activity?: string, question?: string } + | { type: 'answerEnd', end: RoundEnd } | { type: 'ask', askId: string, anchor: string, question: string, images: string[] } | { type: 'open', anchor: string } | { type: 'close', anchor: string } +export type QaAction + = | ChatAction + | { type: 'qa', message: QaMessage } + | { type: 'profiles', profiles: ProfileChoice } + function withOpen(open: string[], anchor: string): string[] { return open.includes(anchor) ? open : [...open, anchor] } -export function qaReducer(state: QaState, action: QaAction): QaState { +export function chatReducer(state: S, action: ChatAction): S { switch (action.type) { - case 'qa': { - // 页面重新加载、或同一文件在别处开着的预览问的:扩展端还在答的接上,展开它那块 + case 'sync': { + // 页面重新加载、或同一文件在别处开着的预览开的:扩展端记着的当前一轮接上(还在进行的、出错被叫停留着的),展开它那块 const known = new Set(state.asks.map(ask => ask.askId)) - const adopted = action.message.running.filter(ask => !known.has(ask.askId)) + const adopted = action.current.filter(round => !known.has(round.askId)) return { ...state, - available: action.message.available, - threads: action.message.threads, - path: action.message.path, - subject: action.message.subject, - asks: [...state.asks, ...adopted.map((ask): Ask => ({ ...ask, status: 'running' }))], - open: adopted.reduce((open, ask) => withOpen(open, ask.anchor), state.open), + available: action.available, + threads: action.threads, + asks: [...state.asks, ...adopted], + open: adopted.reduce((open, round) => withOpen(open, round.anchor), state.open), } } - case 'profiles': - return { ...state, profiles: action.profiles } case 'answer': return { ...state, asks: state.asks.map(ask => ask.askId === action.askId && ask.status === 'running' - ? { ...ask, text: action.text, activity: action.activity } + ? { ...ask, text: action.text, activity: action.activity, ...(action.question && { question: action.question }) } : ask), } case 'answerEnd': { @@ -109,7 +120,7 @@ export function qaReducer(state: QaState, action: QaAction): QaState { case 'open': return { ...state, open: withOpen(state.open, action.anchor) } case 'close': - // 收起就结束这一轮:答完的都回到灰字历史里;还在答的留着,答完照样存 + // 收起就结束这一轮:答完的都回到灰字历史里;还在进行的留着,答完照样存 return { ...state, asks: state.asks.filter(ask => ask.anchor !== action.anchor || ask.status === 'running'), @@ -118,16 +129,29 @@ export function qaReducer(state: QaState, action: QaAction): QaState { } } -/** 当前这一轮在 anchor 那块问的 */ -export function asksOf(state: QaState, anchor: string): Ask[] { +export function qaReducer(state: QaState, action: QaAction): QaState { + switch (action.type) { + case 'qa': { + const { available, threads, current, path, subject } = action.message + return { ...chatReducer(state, { type: 'sync', available, threads, current }), path, subject } + } + case 'profiles': + return { ...state, profiles: action.profiles } + default: + return chatReducer(state, action) + } +} + +/** 当前这一轮在 anchor 那块说的 */ +export function asksOf(state: ChatState, anchor: string): Ask[] { return state.asks.filter(ask => ask.anchor === anchor) } -/** anchor 那块的灰字历史:存下的问答,当前这一轮问的那几条除外 */ -export function historyOf(state: QaState, anchor: string): QaEntryView[] { +/** anchor 那块的灰字历史:存下的记录,当前这一轮的那几条除外;顶部问答的在 path 上 */ +export function historyOf(state: ChatState & { path?: QaEntryView[] }, anchor: string): QaEntryView[] { const current = new Set(asksOf(state, anchor).map(ask => ask.entryId)) const entries = anchor === PATH_ASK_ANCHOR - ? state.path + ? state.path ?? [] : state.threads.find(thread => thread.anchor === anchor)?.entries ?? [] return entries.filter(entry => !current.has(entry.id)) } diff --git a/vscode/webview-ui/src/codeReader/rewriteRun.test.ts b/vscode/webview-ui/src/codeReader/rewriteRun.test.ts deleted file mode 100644 index 1b55834..0000000 --- a/vscode/webview-ui/src/codeReader/rewriteRun.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { RewriteRuns } from './rewriteRun' -import { describe, expect, it } from 'vitest' -import { rewriteEnd, rewriteProgress, startRewrite } from './rewriteRun' - -const ANCHOR = 'c1' -const QUESTION = '/ecc:writing-code-comments\n\n只按这个技能重写这一段注释。' - -describe('重写过程只留在页面上', () => { - it('进行中换上全文和正在读的文件;新的一段正文来了就不再标着上一个文件', () => { - const started = startRewrite(new Map(), ANCHOR) - const reading = rewriteProgress(started, ANCHOR, QUESTION, '', '读取 routes.py') - expect(reading.get(ANCHOR)).toEqual({ question: QUESTION, text: '', status: 'running', activity: '读取 routes.py' }) - const writing = rewriteProgress(reading, ANCHOR, QUESTION, '新的注释', undefined) - expect(writing.get(ANCHOR)).toEqual({ question: QUESTION, text: '新的注释', status: 'running' }) - }) - - it('写进文件就拿掉;没写成把原因留在这块上;叫停也留着,不再进行', () => { - const running = rewriteProgress(startRewrite(new Map(), ANCHOR), ANCHOR, QUESTION, '一半', '读取 a.py') - expect(rewriteEnd(running, ANCHOR, 'done').has(ANCHOR)).toBe(false) - const failed = rewriteEnd(running, ANCHOR, 'failed', '模型没有给出注释正文,没有改文件。') - expect(failed.get(ANCHOR)).toMatchObject({ question: QUESTION, text: '一半', status: 'failed', error: '模型没有给出注释正文,没有改文件。' }) - expect(rewriteEnd(running, ANCHOR, 'stopped').get(ANCHOR)?.status).toBe('stopped') - }) - - it('已经结束的,迟到的字不再把过程画出来;正在进行时再点一次也不重开', () => { - const running = startRewrite(new Map(), ANCHOR) - const done = rewriteEnd(running, ANCHOR, 'done') - expect(rewriteProgress(done, ANCHOR, QUESTION, '迟到了').has(ANCHOR)).toBe(false) - expect(startRewrite(running, ANCHOR)).toBe(running) - const failed = rewriteEnd(running, ANCHOR, 'failed', '没写成') - expect(startRewrite(failed, ANCHOR).get(ANCHOR)?.status).toBe('running') - }) - - it('这一页上没有的结束消息不动原来的', () => { - const runs: RewriteRuns = new Map() - expect(rewriteEnd(runs, ANCHOR, 'failed', '没有')).toBe(runs) - }) -}) diff --git a/vscode/webview-ui/src/codeReader/rewriteRun.ts b/vscode/webview-ui/src/codeReader/rewriteRun.ts deleted file mode 100644 index 07daff6..0000000 --- a/vscode/webview-ui/src/codeReader/rewriteRun.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** 一段注释正在按规范重写、或没写成时留在页面上的过程。只在这一页上,刷新或关掉预览就没了,不写入问答。 */ -export interface RewriteRun { - /** 发给模型的要求,以 `/ecc:writing-code-comments` 开头;还没从扩展端来时是空的 */ - question: string - /** 回答到现在为止的全文(Markdown) */ - text: string - /** 正在做的事,例如「读取 routes.py」 */ - activity?: string - status: 'running' | 'failed' | 'stopped' - /** [status] 是 `failed` 时给人看的原因 */ - error?: string -} - -/** 各段注释的重写过程,按块的记号 */ -export type RewriteRuns = ReadonlyMap - -/** 点了重写:这块标成正在进行。已经在进行就不动。 */ -export function startRewrite(runs: RewriteRuns, anchor: string): RewriteRuns { - if (runs.get(anchor)?.status === 'running') - return runs - const next = new Map(runs) - next.set(anchor, { question: '', text: '', status: 'running' }) - return next -} - -/** - * 模型又吐出一段,或换了正在读的文件。 - * - * 这块不在进行中(已经结束,或这一页上没有)就丢掉,避免写完之后迟到的字再把过程画出来。 - */ -export function rewriteProgress(runs: RewriteRuns, anchor: string, question: string, text: string, activity?: string): RewriteRuns { - if (runs.get(anchor)?.status !== 'running') - return runs - const next = new Map(runs) - next.set(anchor, { question, text, status: 'running', ...(activity !== undefined && { activity }) }) - return next -} - -/** 结束:正文写进文件了就拿掉这段过程;没写成或被叫停就留着,失败的原因写在上面。 */ -export function rewriteEnd(runs: RewriteRuns, anchor: string, status: 'done' | 'failed' | 'stopped', error?: string): RewriteRuns { - const current = runs.get(anchor) - if (current === undefined) - return runs - if (status === 'done') { - const next = new Map(runs) - next.delete(anchor) - return next - } - const next = new Map(runs) - next.set(anchor, { - question: current.question, - text: current.text, - status, - ...(current.activity !== undefined && { activity: current.activity }), - ...(error !== undefined && { error }), - }) - return next -} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 8c6a9f0..a0cd1de 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -258,7 +258,7 @@ export interface ShownName { definition?: true } -/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、用哪个 profile 答的 */ +/** 问过、重写过的一次:说的话、回答(Markdown;重写记录是交回的注释正文,没交回时是空的)、什么时候(ISO 时间)、用哪个 profile 答的 */ export interface QaEntryView { id: string question: string @@ -266,8 +266,10 @@ export interface QaEntryView { createdAt: string /** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */ profile?: string - /** 问题带的图片,能直接放进 img 的地址;没带图片时没有 */ + /** 带的图片,能直接放进 img 的地址;没带图片时没有 */ images?: string[] + /** 重写记录才有:没写进文件的原因 */ + error?: string } /** 问题带的一张图片,发给扩展端的样子:格式(`image/png` 等)和不带 `data:` 前缀的 base64 */ @@ -282,18 +284,23 @@ export interface QaThreadView { entries: QaEntryView[] } -/** 扩展端正在答的一个问题:text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」) */ -export interface RunningAsk { +/** + * 扩展端记着的当前一轮:还在进行的,或出错、被叫停没记下留着的。text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」), + * error 是 failed 时给人看的原因 + */ +export interface AskRound { askId: string anchor: string question: string text: string activity?: string + status: 'running' | 'failed' | 'stopped' + error?: string } /** - * 这份文件的问答:available 为假时文件不在工作区文件夹里、问答没地方存,不给「问 AI」; - * running 是扩展端正在答的问题(页面重新加载后靠它接上) + * 这份文件的问答和重写记录:available 为假时文件不在工作区文件夹里、没地方存,不给「问 AI」和「重写注释」; + * current、rewriteCurrent 是扩展端记着的当前一轮(页面重新加载后靠它们接上) */ export interface QaMessage { type: 'qa' @@ -303,7 +310,10 @@ export interface QaMessage { path: QaEntryView[] /** 顶部问的是这个文件还是这个文件夹 */ subject: 'file' | 'folder' - running: RunningAsk[] + current: AskRound[] + /** 块下面的重写记录,按块的记号 */ + rewrites: QaThreadView[] + rewriteCurrent: AskRound[] } /** 「问 AI」用的 Claude Code profile 的下拉一项:id 是 profile 名;「默认」(不带 `--settings`)的 id 是 '' */ @@ -319,11 +329,10 @@ export interface ProfileChoice { } /** - * 一个问题怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)和存下的那条的 id, + * 一轮怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)和存下的那条的 id,重写注释没写进文件时还带原因(error); * failed 带给人看的原因,stopped 是被叫停、没存 */ -export interface AnswerEnd { - type: 'answerEnd' +export interface RoundEnd { askId: string status: 'done' | 'failed' | 'stopped' text?: string @@ -331,6 +340,14 @@ export interface AnswerEnd { entryId?: string } +export interface AnswerEnd extends RoundEnd { + type: 'answerEnd' +} + +export interface RewriteEnd extends RoundEnd { + type: 'rewriteEnd' +} + /** 一截是新加的还是删掉的;没变的不写 */ export type PartChange = 'add' | 'del' @@ -443,8 +460,8 @@ export interface StickyScrollSettings { * askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了,见 AnswerEnd - * rewriteProgress:anchor 那块的注释正在按规范重写。question 是发给模型的那一句(技能命令和块在哪),text 是注释正文到现在为止写出来的,activity 是正在读什么 - * rewriteEnd:这段重写结束了。done 是正文已经写进文件;failed 带给人看的原因,文件没改;stopped 是被叫停,文件没改 + * rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令) + * rewriteEnd:askId 那一轮重写结束了,见 RewriteEnd:done 是模型答完、记下了(没写进文件时带原因);failed、stopped 没记,文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了 @@ -510,8 +527,8 @@ export type ExtensionToWebview | ({ type: 'askProfiles' } & ProfileChoice) | { type: 'answer', askId: string, text: string, activity?: string } | AnswerEnd - | { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string } - | { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string } + | { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string } + | RewriteEnd | { type: 'diff', diff: DocumentDiff | null } | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] } | { type: 'repoLinkBroken', path?: string, symbol?: string } @@ -534,8 +551,11 @@ export type ExtensionToWebview * setOnlyChanges:打开 / 关掉「只看改动」;同样等扩展端发回 filter 再按它显示 * ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile; * images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存 - * rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段)。过程用 rewriteProgress / rewriteEnd 回来,不写入问答 - * stopRewrite:叫停 anchor 这段正在进行的重写,不写文件 + * rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段);askId 由这边起,过程用 rewriteProgress / rewriteEnd 回来。 + * 没有 request 是点了按钮:发技能命令、开新会话;有 request 是在输入框里说的修改意见,接着这块最近一次重写的会话说,images 是贴的图片 + * stopRewrite:叫停 askId 那一轮重写,不写文件、不记 + * deleteRewrite:删掉 anchor 那块下面的一条重写记录 + * forgetRound:收起了 anchor 那块的问答(ask)或重写(rewrite),扩展端去掉这块留着的那一轮(出错、被叫停的),重新加载后不再画出来 * stopAsk:叫停 askId 那个问题,这次不存 * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) * selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示 @@ -552,8 +572,10 @@ export type WebviewToExtension | { type: 'setFilter', excluded: Excluded } | { type: 'setOnlyChanges', on: boolean } | { type: 'edit', startLine: number, endLine: number, text: string } - | { type: 'rewriteComment', anchor: string } - | { type: 'stopRewrite', anchor: string } + | { type: 'rewriteComment', askId: string, anchor: string, request?: string, images: AskImage[] } + | { type: 'stopRewrite', askId: string } + | { type: 'deleteRewrite', anchor: string, entryId: string } + | { type: 'forgetRound', kind: 'ask' | 'rewrite', anchor: string } | { type: 'selectTranslator', id: string } | { type: 'visibleLine', line: number } | { type: 'probeDefinition', id: number, version: number, offset: number } diff --git a/vscode/webview-ui/src/codeReader/useQa.ts b/vscode/webview-ui/src/codeReader/useQa.ts index 4488c28..a19b0c6 100644 --- a/vscode/webview-ui/src/codeReader/useQa.ts +++ b/vscode/webview-ui/src/codeReader/useQa.ts @@ -49,17 +49,24 @@ export function useQa(): QaTree { } }, []) - return useMemo((): QaTree => ({ - state, - onToggle: anchor => dispatch({ type: current.current.open.includes(anchor) ? 'close' : 'open', anchor }), - onClose: anchor => dispatch({ type: 'close', anchor }), - onAsk: (anchor, question, images) => { - const askId = newAskId() - dispatch({ type: 'ask', askId, anchor, question, images: images.map(image => image.url) }) - postMessage({ type: 'ask', askId, anchor, question, images: images.map(toAskImage) }) - }, - onStop: askId => postMessage({ type: 'stopAsk', askId }), - onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }), - onSelectProfile: id => postMessage({ type: 'selectAskProfile', id }), - }), [state]) + return useMemo((): QaTree => { + /** 收起就结束这一轮,扩展端留着的那一轮(出错、被叫停的)也去掉 */ + const close = (anchor: string): void => { + dispatch({ type: 'close', anchor }) + postMessage({ type: 'forgetRound', kind: 'ask', anchor }) + } + return { + state, + onToggle: anchor => current.current.open.includes(anchor) ? close(anchor) : dispatch({ type: 'open', anchor }), + onClose: close, + onAsk: (anchor, question, images) => { + const askId = newAskId() + dispatch({ type: 'ask', askId, anchor, question, images: images.map(image => image.url) }) + postMessage({ type: 'ask', askId, anchor, question, images: images.map(toAskImage) }) + }, + onStop: askId => postMessage({ type: 'stopAsk', askId }), + onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }), + onSelectProfile: id => postMessage({ type: 'selectAskProfile', id }), + } + }, [state]) } diff --git a/vscode/webview-ui/src/codeReader/useRewrite.ts b/vscode/webview-ui/src/codeReader/useRewrite.ts index f72848b..dba56f8 100644 --- a/vscode/webview-ui/src/codeReader/useRewrite.ts +++ b/vscode/webview-ui/src/codeReader/useRewrite.ts @@ -1,43 +1,42 @@ -import type { RewriteRuns } from './rewriteRun' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { rewriteEnd, rewriteProgress, startRewrite } from './rewriteRun' +import type { RewriteTree } from './BlockTree' +import type { ChatState } from './qa' +import type { ProfileChoice } from './types' +import { useEffect, useMemo, useReducer, useRef } from 'react' +import { chatReducer, EMPTY_CHAT, newAskId } from './qa' +import { toAskImage } from './qaImages' import { ANSWER_FRAME_MS } from './useQa' import { onMessage, postMessage } from './vscode' -/** 按注释规范重写:过程只在这一页上,按块的记号记。不写入问答,刷新或关掉预览就没了。 */ -export function useRewrite(): { runs: RewriteRuns, onRewrite: (anchor: string) => void, onStop: (anchor: string) => void } { - const [runs, setRuns] = useState(() => new Map()) - /** 动作里读眼下的过程(这块是不是已经在重写) */ - const current = useRef(runs) - current.current = runs +/** + * 按注释规范重写:收扩展端的重写记录、当前一轮的进展,给块树用的状态和动作。和问 AI 同一套: + * 进行中的、出错被叫停留着的那一轮记在扩展端,页面重新加载后随 qa 消息的 rewriteCurrent 接上。profile 下拉和问 AI 共用。 + */ +export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string) => void): RewriteTree { + const [state, dispatch] = useReducer(chatReducer, EMPTY_CHAT) useEffect(() => { - /** 攒着没画的进展:anchor → 最新的一份 */ + /** 攒着没画的进展:askId → 最新的一份 */ const pending = new Map() let timer: ReturnType | undefined const flush = (): void => { timer = undefined - setRuns((prev) => { - let next = prev - for (const [anchor, progress] of pending) - next = rewriteProgress(next, anchor, progress.question, progress.text, progress.activity) - return next - }) + for (const [askId, progress] of pending) + dispatch({ type: 'answer', askId, ...progress }) pending.clear() } const off = onMessage((msg) => { - if (msg.type === 'rewriteProgress') { - pending.set(msg.anchor, { question: msg.question, text: msg.text, ...(msg.activity !== undefined && { activity: msg.activity }) }) - timer ??= setTimeout(flush, ANSWER_FRAME_MS) - return - } - if (msg.type === 'rewriteEnd') { - pending.delete(msg.anchor) - if (pending.size === 0) { + switch (msg.type) { + case 'qa': + dispatch({ type: 'sync', available: msg.available, threads: msg.rewrites, current: msg.rewriteCurrent }) + return + case 'rewriteProgress': + pending.set(msg.askId, { question: msg.question, text: msg.text, ...(msg.activity !== undefined && { activity: msg.activity }) }) + timer ??= setTimeout(flush, ANSWER_FRAME_MS) + return + case 'rewriteEnd': clearTimeout(timer) - timer = undefined - } - setRuns(prev => rewriteEnd(prev, msg.anchor, msg.status, msg.error)) + flush() + dispatch({ type: 'answerEnd', end: msg }) } }) return () => { @@ -46,14 +45,33 @@ export function useRewrite(): { runs: RewriteRuns, onRewrite: (anchor: string) = } }, []) - const onRewrite = useCallback((anchor: string) => { - if (current.current.get(anchor)?.status === 'running') - return - setRuns(prev => startRewrite(prev, anchor)) - postMessage({ type: 'rewriteComment', anchor }) - }, []) - const onStop = useCallback((anchor: string) => { - postMessage({ type: 'stopRewrite', anchor }) - }, []) - return useMemo(() => ({ runs, onRewrite, onStop }), [runs, onRewrite, onStop]) + /** 动作里读眼下的状态(这块是不是已经在写) */ + const current = useRef(state) + current.current = state + + return useMemo((): RewriteTree => ({ + state, + profiles, + onSelectProfile, + onRewrite: (anchor) => { + if (current.current.asks.some(ask => ask.anchor === anchor && ask.status === 'running')) + return + const askId = newAskId() + // 那句技能命令由扩展端拼好,开始后随 rewriteProgress 报上来 + dispatch({ type: 'ask', askId, anchor, question: '', images: [] }) + postMessage({ type: 'rewriteComment', askId, anchor, images: [] }) + }, + onAsk: (anchor, request, images) => { + const askId = newAskId() + dispatch({ type: 'ask', askId, anchor, question: request, images: images.map(image => image.url) }) + postMessage({ type: 'rewriteComment', askId, anchor, request, images: images.map(toAskImage) }) + }, + onStop: askId => postMessage({ type: 'stopRewrite', askId }), + onDelete: (anchor, entryId) => postMessage({ type: 'deleteRewrite', anchor, entryId }), + onOpen: anchor => dispatch({ type: 'open', anchor }), + onClose: (anchor) => { + dispatch({ type: 'close', anchor }) + postMessage({ type: 'forgetRound', kind: 'rewrite', anchor }) + }, + }), [state, profiles, onSelectProfile]) }