From fb53bb727c74a6e1cc6794b21d28ba56f1846d7b Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 21:12:47 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E5=8A=A0=E3=80=8C=E9=97=AE=20AI=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E5=9D=97=E4=B8=8B=E9=9D=A2=E9=97=AE=20Claude=20Code?= =?UTF-8?q?=EF=BC=8C=E9=97=AE=E7=AD=94=E5=AD=98=E8=BF=9B=20.spx=20?= =?UTF-8?q?=E6=8C=82=E5=9C=A8=E5=9D=97=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 73 +++++- vscode/src/cc/claudeProgress.ts | 3 +- vscode/src/cc/spawnClaude.test.ts | 56 +++++ vscode/src/cc/spawnClaude.ts | 133 ++++++++++- vscode/src/codeReader/attributes.test.ts | 2 +- vscode/src/codeReader/blockView.ts | 31 ++- vscode/src/codeReader/editor/messages.ts | 40 ++++ vscode/src/codeReader/editor/previewEditor.ts | 69 +++++- vscode/src/codeReader/index.ts | 7 +- vscode/src/codeReader/qa/anchor.test.ts | 102 ++++++++ vscode/src/codeReader/qa/anchor.ts | 59 +++++ vscode/src/codeReader/qa/answerStream.test.ts | 98 ++++++++ vscode/src/codeReader/qa/answerStream.ts | 119 ++++++++++ vscode/src/codeReader/qa/asks.test.ts | 114 +++++++++ vscode/src/codeReader/qa/asks.ts | 107 +++++++++ vscode/src/codeReader/qa/claudeAsk.test.ts | 116 +++++++++ vscode/src/codeReader/qa/claudeAsk.ts | 136 +++++++++++ vscode/src/codeReader/qa/codeQa.ts | 102 ++++++++ vscode/src/codeReader/qa/prompt.test.ts | 74 ++++++ vscode/src/codeReader/qa/prompt.ts | 86 +++++++ vscode/src/codeReader/qa/qaMap.test.ts | 117 ++++++++++ vscode/src/codeReader/qa/qaMap.ts | 90 +++++++ vscode/webview-ui/src/codeReader/App.tsx | 14 +- .../src/codeReader/AskThread.test.tsx | 86 +++++++ .../webview-ui/src/codeReader/AskThread.tsx | 198 ++++++++++++++++ .../src/codeReader/BlockTools.test.tsx | 15 +- .../webview-ui/src/codeReader/BlockTools.tsx | 27 ++- .../webview-ui/src/codeReader/BlockTree.tsx | 38 ++- .../webview-ui/src/codeReader/codeReader.css | 221 +++++++++++++++++- vscode/webview-ui/src/codeReader/markdown.ts | 7 +- vscode/webview-ui/src/codeReader/qa.test.ts | 120 ++++++++++ vscode/webview-ui/src/codeReader/qa.ts | 179 ++++++++++++++ vscode/webview-ui/src/codeReader/types.ts | 69 ++++++ vscode/webview-ui/src/codeReader/useQa.ts | 61 +++++ 34 files changed, 2726 insertions(+), 43 deletions(-) create mode 100644 vscode/src/cc/spawnClaude.test.ts create mode 100644 vscode/src/codeReader/qa/anchor.test.ts create mode 100644 vscode/src/codeReader/qa/anchor.ts create mode 100644 vscode/src/codeReader/qa/answerStream.test.ts create mode 100644 vscode/src/codeReader/qa/answerStream.ts create mode 100644 vscode/src/codeReader/qa/asks.test.ts create mode 100644 vscode/src/codeReader/qa/asks.ts create mode 100644 vscode/src/codeReader/qa/claudeAsk.test.ts create mode 100644 vscode/src/codeReader/qa/claudeAsk.ts create mode 100644 vscode/src/codeReader/qa/codeQa.ts create mode 100644 vscode/src/codeReader/qa/prompt.test.ts create mode 100644 vscode/src/codeReader/qa/prompt.ts create mode 100644 vscode/src/codeReader/qa/qaMap.test.ts create mode 100644 vscode/src/codeReader/qa/qaMap.ts create mode 100644 vscode/webview-ui/src/codeReader/AskThread.test.tsx create mode 100644 vscode/webview-ui/src/codeReader/AskThread.tsx create mode 100644 vscode/webview-ui/src/codeReader/qa.test.ts create mode 100644 vscode/webview-ui/src/codeReader/qa.ts create mode 100644 vscode/webview-ui/src/codeReader/useQa.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 5f7a794..004957a 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -28,6 +28,7 @@ | 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色;看不见的(折叠、筛掉的)不自动展开,筛选栏上写「名字 · N 处」 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释;不做新建 / 重命名 / 删除 | | 文件注释 | 读代码时给目录 / 文件随手记一句:存在各工作区文件夹的 `.spx/code-notes.json`,跟着仓库走;在 VS Code 里改名、挪位置、删除时注释跟着变 | +| 问 AI | 块右上角「问 AI」,在块下面问 Claude Code(`claude -p`,只读,用插件默认的 profile)这块代码,回答逐字出来按 Markdown 显示、能追问(接着同一个会话)、能停;问答存进各工作区文件夹的 `.spx/code-qa.json`,灰字挂在块下面免得重复问,理解了就删。问答按块的位置记号挂,记号只由和语言无关的块数据(种类、facts 里的名字、源码哈希)算出,文件别处改了照样认得;认不回来的放在预览顶上。见「问 AI」 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | | 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | | 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 | @@ -85,7 +86,7 @@ - 每一句是一块:导入、函数、类、枚举、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。 - 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。 - 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签(字取翻译插件给 `kind` 值起的名字,见「筛选」)、内容、右上角一排图标按钮。 -- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示(`title`)和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`),以后的按钮(问 AI)加在这一排里。眼下只有「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类标签同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。 +- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示(`title`)和 `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 能走到,回车 / 空格切换。 - 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 - 内容两种状态,每块单独切: - 预览:函数、类、枚举、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。 @@ -205,6 +206,40 @@ - 清掉(连同筛选栏上那句):点空白处或不是名字的字、按 Esc(输入框、下拉列表自己用掉的 Esc 不算)、文档换了版本、换了翻译。 - 不抢别的点击:⌘ / Ctrl(以及 Alt、Shift)+ 点击、双击的第二下、拖着选字都不动高亮(双击的第一下照样算一次单击,和编辑器一样);点在按钮、链接、输入框、下拉列表、筛选栏、属性名上照旧,高亮不动——折叠箭头、「编辑」、Markdown 里的链接都照旧;名字本身在按钮里(带下一层的子行的键)时照旧折叠,同时高亮。 +### 问 AI + +原型画板「问 AI」、组件 `Reader / AskPanel`(`State=history | asking`)。读代码时对一块有疑问,直接在块下面问 Claude Code,答完存起来,下次打开还在,理解了就删。 + +- 入口:块右上角那排图标按钮里的「问 AI」(每种块都有:普通块、属性表块、库规则的卡片)。点一下在块下面展开问答区,再点(或在输入框里按 Esc)收起。文件不在任何工作区文件夹里时问答没地方存,不给这个按钮。 +- 问答区紧跟在块下面、子块前面,和块同宽,左边一条 2px 的链接色竖线(`--vscode-textLink-foreground` 50%)。从上到下: + - 灰字的历史:这块以前问过的,每条一行问题(`descriptionForeground`,一行放不下截断)、右边时间(「刚刚」「5 分钟前」「3 小时前」「昨天」「3 天前」,一个月以前写日期,悬停看完整时间)和垃圾桶「已理解,删除」;下面是回答的前两行(去掉 Markdown 记号的纯文字,更淡)。点问题或这两行展开整段回答,照样灰字,按 Markdown 排(同注释的 Markdown 渲染),再点收起。 + - 当前这一轮(展开以来问的,和历史之间一条细分隔线):问题照常颜色、加粗,回答照常颜色、按 Markdown 排,逐字长出来,末尾一个闪的光标;下面一行灰字写正在做的事(「读取 exceptions.py」「搜索 get_user」,还没开口时「正在想…」)。出错时这一问下面一个红框写原因(找不到 claude、退出码非零带它的原话、没登录时提示在终端里运行 claude 登录、超时);叫停的写「已停止,这次没存。」 + - 输入框:没问过时提示「问这块代码… Enter 发送,Esc 收起」,问过了「继续问… Enter 发送,Esc 收起」;Enter 发送,Shift+Enter 换行,输入法选字时的 Enter 不算;Esc 收起(不清掉名字高亮);展开时光标就在输入框里。右边是发送按钮(没字时不能点),正在答时换成「停止」。 +- 没展开、但问过的块:块下面只露一行灰字「问过 N 次」,读的时候一眼知道这里问过;点开看灰字历史(能展开、能删),没有输入框。 +- 收起就结束这一轮:这一轮答完的都回到灰字历史里;还没答完的接着答,答完照样存。同一块同时只答一个问题(正在答时不能再发),不同块可以同时问,全插件同时最多 3 个,再多就在这一问下面说「同时最多问 3 个问题,等前面的答完再问」。 +- 追问:这一轮里已经答成过,再问就接着那次的会话(`--resume <会话 id>`,会话 id 取 stream-json 的 init 消息),只发问题和「还是第几行那块」;会话找不到了(过期、被清理)就按第一次那样重新问。收起再展开是新的一轮,重新开会话、带上代码上下文。 +- 删除:点垃圾桶马上删,不用确认;一块下面删光了这块就不挂了,一份文件删光了文件里就没有这份文件。 +- 找不到原来位置的问答:记号(见下)对不上这份文件里任何一块的问答,放在预览顶上(筛选栏下面、代码块上面)一个虚线框里,标题「代码改动后找不到原来位置的问答 · N 条」(点标题收起 / 展开,默认展开),每组写「原来在:函数 get_user › 条件」(种类用翻译插件起的名字),下面同灰字历史,能看、能删。解析失败时一块都没有,不显示这一栏。 +- 预览重新加载(标签页切走再切回来)后,扩展端还在答的问题接上(展开它那块、接着长),答完照样显示。同一份文件开在几处,问答、正在答的都一样。 + +怎么问(扩展端 `qa/`): + +- 用 Claude Code 命令行:和插件别处的 `claude -p` 一样找 claude(`resolveClaudeBin`)、清环境变量(`ANTHROPIC_*`、嵌套会话标记)、带默认 profile(`getDefaultProfilePath`,读不到就不带 `--settings`),在这份文件所在的工作区文件夹里跑,它能自己读项目里别的文件。 +- 只读,靠三层:`--tools Read,Grep,Glob`(可用的内置工具只剩读、搜,编辑、写文件、跑命令的工具根本没有),`--allowedTools Read,Grep,Glob`(这几个不用再批),`--permission-mode dontAsk`(别的一律拒、不等人批,盖过 profile 里的 bypassPermissions),外加 `--strict-mcp-config` 且不给 `--mcp-config`(不加载任何 MCP 服务,免得 MCP 工具能改东西)。不带 `--dangerously-skip-permissions`。 +- 输出 `--output-format stream-json --verbose --include-partial-messages`:init 消息给会话 id;逐字的 `text_delta` 拼成回答;工具调用换成「读取 X」「搜索 Y」;新的一条回复(读完文件后开口)一开口就换掉前面那句(「我先看看 X」);答完以 result 行的最终回答为准,`is_error` 当失败。超时 5 分钟;「停止」给进程发 SIGTERM,这次不存。 +- 第一次问的话:要求用中文、大白话、简洁,只读,提到文件写相对项目根目录的路径(带行号);带上文件的相对路径和语言、外面套着的各层块的开头(每层最多 6 行,带行号,例如类和它里面的函数)、这块代码(从块的第一行到它最后一个子孙的最后一行,带行号,超过 120 行只贴开头并说剩下的在哪几行)、问题。追问只发问题和「还是 路径 第 a–b 行那块代码,代码可能刚改过,需要时重新读文件」。这些都只用视图数据的行号、记号和树形,不认识任何语言。 + +块的位置记号(`BlockView.anchor`,`qa/anchor.ts`): + +- 扩展端生成视图数据时按源码先后逐块给:有名字的块(`defines` 的来源:函数名、类名、枚举名、单个变量名,库规则卡片取规则给的定义范围)是外层各个有名字的块加自己的「种类 名字」,例如 `class ReferralRelation > function display`;没名字的块(条件、语句、返回、注释、导入、多变量声明……)是外层最近的有名字的块加「种类 @源码哈希」,哈希是这块编辑范围的源码去掉所有空白后的 SHA-1 前 8 位,例如 `function display > if @3fa1c2d0`。同一层里记号相同的(同名的两个函数、一模一样的两行语句)按源码先后在后面补 `[1]`、`[2]`。 +- 所以:上面插几行、改缩进空白、改别的函数的名字,记号都不变;有名字的块改了主体、签名还认得;没名字的块自己的源码改了,记号就变了,旧问答成了「找不到原来位置的」。种类用视图数据的种类(库规则的卡片是规则的种类 id)。 +- webview 只拿记号比相等,不拆开看;「原来在哪」由扩展端把记号拆回各层(`where: { kind, name? }[]`)发过去。 + +存法: + +- 每个工作区文件夹一份 `<工作区文件夹>/.spx/code-qa.json`,和 `.spx/code-notes.json` 同一套写法(`pathMap.ts`、`folderStore.ts` 两者共用):`{ "相对路径": [{ "anchor": 记号, "entries": [{ "id", "question", "answer", "createdAt", "sessionId" }] }] }`,回答是 Markdown 原文,`createdAt` 是 ISO 时间;文件缺失或解析失败当作没有,认不出的块、问答跳过;写时按需建 `.spx`,两格缩进、文件按路径排、块按记号排、问答按时间排、末尾换行;存第一条前不生成文件,删光后留 `{}`。 +- 在 VS Code 里改名、挪位置、删除文件 / 目录时问答跟着走(同文件注释);外部改了 `code-qa.json`(git pull、手改)文件监听重读,开着的预览跟着变。 + ## 通用契约 放在 `vscode/src/codeReader/contract.ts`,语法树类型用 `@vscode/tree-sitter-wasm` 自带的 web-tree-sitter 类型。 @@ -878,6 +913,7 @@ Python 内置类型(仅注解里):str 字符串、int 整数、float 浮 ```ts interface BlockViewBase { id: string // 在树里的位置,例如 '6.1.0' + anchor: string // 块的位置记号(见「问 AI」):问答挂在它上面,webview 只比相等 kind: string // BlockKind(库规则认成的现成块也是,如 enum),或库规则卡片的种类 id(sqlalchemy.table) startLine: number // 编辑范围的起止行,0 起:一般是 header,库规则认领的块是原块整条语句(盖住它留下的子块) endLine: number @@ -902,6 +938,9 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | - `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。 - `{ type: 'occurrencesFound', id, occurrences: { start, end, kind: 'text' | 'read' | 'write' }[] }`:回答第 `id` 次 `findOccurrences`,`[start, end)` 是源码下标,`kind` 同 `DocumentHighlightKind`;语言扩展没答就是空数组。 - `{ type: 'filter', excluded: Record<属性 id, 值 id[]> }`:整个工作区共用的块筛选。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。 + - `{ type: 'qa', available, threads: { anchor, where: { kind, name? }[], entries: { id, question, answer, createdAt }[] }[], running: { askId, anchor, question, text, activity? }[] }`:这份文件的问答(见「问 AI」)。`ready` 后发一次,存的问答、正在答的问题变了再发给开着这份文件的所有预览;`available` 为假时文件不在工作区文件夹里,不给「问 AI」;`running` 让重新加载的页面接上还在答的问题。 + - `{ type: 'answer', askId, text, activity? }`:`askId` 那个问题的回答长了(`text` 是到现在为止的全文)或换了在做的事。webview 攒着、最多每 80ms 画一次。 + - `{ type: 'answerEnd', askId, status: 'done' | 'failed' | 'stopped', text?, error?, entryId?, sessionId? }`:答完了。`done` 带最终回答、存下的那条的 id(历史里不再重复显示它)和会话 id(追问接着它),`failed` 带给人看的原因,`stopped` 是被叫停、没存。 - webview → 扩展: - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。这两条都只带下标:扩展端按发出去的文档认出规则标了引用的下标交给规则查(见「规则自己查定义」),其余问语言扩展。 @@ -910,6 +949,9 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 - `{ type: 'setFilter', excluded }`:改块筛选(整份替换);扩展端逐项核对后记进 `workspaceState`,再发 `filter` 给所有预览。webview 不在本地先改,只按扩展端发来的显示。 + - `{ type: 'ask', askId, anchor, question, resume? }`:问 `anchor` 那块;`askId` 由 webview 起,`resume` 是追问时这一轮最近答成的会话 id。扩展端按最近一次发出去的块树和源码找这块、拼问题;找不到(代码刚改过)或问不了(这块正在答、同时答的太多)时直接回 `answerEnd` failed。 + - `{ type: 'stopAsk', askId }`:叫停,结束 claude 进程,这次不存。 + - `{ type: 'deleteQa', anchor, entryId }`:删掉一次问答。 - 块的编辑状态和用户的折叠选择在 webview 里按块 id 记(`vscode.setState`):`{ editing: string[], folds: Record, scroll? }`。`folds` 只记用户点过的(id → 折叠着没有),属性行和带下一层的子行也记在里面:属性行 id 是 `<块 id>/p<行序号>`(例如 `6.1.0/p3`),子行 id 是上一层(行或子行)的 id 加 `/<子行序号>`,一层层往后接(例如 `6.1.0/p3/1` 是第 3 行的第 1 个子行,`6.1.0/p3/1/0` 是它下面的第 0 个);块 id 只有数字和点,只有行那一节带 `p`、其余各节是纯数字,所以块、行、各层子行的 id 互不相撞。子行 id 也是跳到定义时在页面上找要闪的那一项用的。没记的按默认:函数、类、枚举块和库规则的卡片折叠,其余块、属性行和子行展开。眼下折叠着哪些由「默认 + folds」算出来,界面、跳到定义的展开都只看这一份。跳到定义展开祖先块、属性行时也记进 `folds`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 ## 颜色 @@ -960,11 +1002,21 @@ vscode/src/codeReader/ explorer/fileTree.ts 侧栏文件树(FileTreeDecorator:给行添信息的接口) explorer/copyText.ts 复制菜单的命令和复制出什么(纯函数) explorer/copyCommands.ts 复制命令:写剪贴板、状态栏提示 - notes/noteMap.ts 注释的键、保存 / 改名 / 删除时怎么改(纯函数) - notes/store.ts .spx/code-notes.json 读写 + pathMap.ts 按文件 / 目录记东西的表(文件注释、问答共用):路径的键、改名 / 挪位置 / 删除时怎么改、.spx 里 JSON 的读写(不碰 VS Code) + folderStore.ts FolderStore:每个工作区文件夹一份表,内存里的一份、文件监听、跟着改名 / 删除走、读写排队 + notes/noteMap.ts 保存一条注释(纯函数) + notes/store.ts .spx/code-notes.json 叫什么、每条怎么认 notes/noteItem.ts 有注释的行怎么显示:描述、悬停提示、contextValue(纯函数) - notes/fileNotes.ts FileNotes:内存里的注释、文件监听、跟着改名 / 删除走、给树画行 + notes/fileNotes.ts FileNotes:注释的 FolderStore、给树画行 notes/commands.ts 「添加注释 / 编辑注释」输入框 + qa/anchor.ts 块的位置记号(BlockAnchors)、拆回各层(不碰 VS Code) + qa/qaMap.ts .spx/code-qa.json 的形状、每条怎么认、记一次 / 删一次(纯函数) + qa/prompt.ts 问 AI 的话:找到那块、外层块的开头、带行号的代码、追问 + qa/answerStream.ts stream-json 的每行 → 会话 id、逐字的回答、在做的事、最终回答 / 失败 + qa/claudeAsk.ts 命令行参数(只读)、问一次(追问找不到会话时重新问)、失败给人看的话 + qa/asks.ts QaAsks:正在答的问题,一块一个、最多 3 个、能叫停(不碰 VS Code) + qa/codeQa.ts CodeQa:问答的 FolderStore、开问、答完存、删,发给预览的问答 + (cc/spawnClaude.ts 的 runClaudeLines:跑 claude、stdout 逐行交出、超时、叫停,问 AI 用它) editor/previewEditor.ts CustomTextEditorProvider editor/messages.ts 消息类型 editor/blockFilter.ts 块筛选:workspaceState 读写、通知所有预览 @@ -973,7 +1025,7 @@ vscode/src/codeReader/ editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数) editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights) index.ts registerCodeReader(context): CodeReaderApi -vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、收发在 useQa.ts) ``` ## 测试 @@ -1004,7 +1056,16 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 - 侧栏行上的菜单(`explorer/rowMenus.test.ts`,读 `package.json`):行尾按钮先「复制」子菜单再注释,有没有注释换「添加 / 编辑注释」,工作区根都没有;「复制」子菜单带 `$(copy)`,注释图标 `$(note)` / `$(edit)`;子菜单和右键菜单里文件四项、目录三项(有没有注释一样)、菜单上的字;复制命令都登记了、和代码里的命令表一致、命令面板里藏起来。 - 复制出什么(`explorer/copyText.test.ts`):文件、目录的名字 / 相对路径 / 绝对路径;不带后缀只去最后一个扩展名(`a.test.ts`、`archive.tar.gz`),`.env` 原样、`.env.local` → `.env`、没有扩展名的原样;相对路径总用 `/`,绝对路径照本机写法(Windows 反斜杠)。 -- 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、`title` 和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记。 +- 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、`title` 和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。 +- 问 AI: + - 位置记号(`qa/anchor.test.ts`,真实的 Python 解析):有名字 / 没名字的块的记号、同一层重复的编号、两个函数里一样的条件互不影响;上面插几行、改空白记号不变;改别的函数的名字只动它和它里面的块;没名字的块自己的源码改了记号变、有名字的块改签名不变;拆回各层。 + - 存法(`qa/qaMap.test.ts`):记一次、删一次、删光不挂 / 不记;文件缺失、解析失败、不是对象当没有;认不出的块和问答跳过、没会话 id 的照认;手改的顺序、重复的块读进来整理好;写出来排好序、两格缩进、读得回来、删光留 `{}`;改名、挪目录、删除跟着走。 + - 问的话(`qa/prompt.test.ts`):完整问题逐字(要求、文件、外层块的开头、带行号的代码、问题),顶层块没有外层、函数连主体一起贴,很长的块只贴开头、外层块每层最多几行,找不到这块没有,追问的话。 + - 命令行(`qa/claudeAsk.test.ts`,假的 claude):只读的参数、没有跳过权限、追问带 `--resume`;问一次的过程和结果、在工作区文件夹里跑、带超时和叫停;追问接着会话、会话找不到时按完整问题重问、别的失败不重试;失败给人看的话。 + - stream-json(`qa/answerStream.test.ts`,照本机 claude 2.1 的输出截的):会话 id、逐字的回答、读文件、新回复换掉前面那句、没开逐字片段时一样、一条回复里两段空一行、result 没带回答时用眼下的、失败的原因、坏行和子代理的消息不算。 + - 正在答的问题(`qa/asks.test.ts`):进展、答完、叫停(结束进程、报 stopped、这块能再问)、出错、同一块只答一个、最多几个、全部叫停。 + - 跑 claude(`cc/spawnClaude.test.ts`,假的 claude 脚本):逐行交出、最后没换行的那段、参数和 cwd、退出码非零带原话、叫停马上结束进程、超时。 + - 页面(`qa.test.ts`、`AskThread.test.tsx`):问一次、答完记下存成的那条、追问接着哪个会话、收起结束这一轮、重新加载接上还在答的、停止和失败、不认识的消息不理;找不到原来位置的问答、原来在哪;问的时间、回答的前两行;问答区没问过不画、没展开只露「问过 N 次」、展开的历史 / 当前这一轮 / 输入框、出错和停止、找不到原来位置的那一栏。 ## 收尾 diff --git a/vscode/src/cc/claudeProgress.ts b/vscode/src/cc/claudeProgress.ts index d9556d0..802fd1f 100644 --- a/vscode/src/cc/claudeProgress.ts +++ b/vscode/src/cc/claudeProgress.ts @@ -27,7 +27,8 @@ function asString(value: unknown): string { return typeof value === 'string' ? value : '' } -function describeToolUse(name: string, input: Record): string { +/** 一次工具调用写成人看得懂的短语:「读取 routes.py」「搜索 get_user」。 */ +export function describeToolUse(name: string, input: Record): string { switch (name) { case 'Bash': return `执行命令:${truncate(asString(input.description) || asString(input.command))}` diff --git a/vscode/src/cc/spawnClaude.test.ts b/vscode/src/cc/spawnClaude.test.ts new file mode 100644 index 0000000..262405d --- /dev/null +++ b/vscode/src/cc/spawnClaude.test.ts @@ -0,0 +1,56 @@ +import { promises as fsp } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from './spawnClaude' + +let dir: string + +beforeEach(async () => { + dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'fake-claude-')) +}) + +afterEach(async () => { + await fsp.rm(dir, { recursive: true, force: true }) +}) + +/** 假的 claude:一段 sh 脚本 */ +async function fakeBin(script: string): Promise { + const bin = path.join(dir, 'claude') + await fsp.writeFile(bin, `#!/bin/sh\n${script}\n`, { mode: 0o755 }) + return bin +} + +describe('跑 claude、逐行交出 stdout', () => { + it('每出来完整的一行交一行,最后没带换行的那段结束时交出;参数原样传、在 cwd 里跑', async () => { + const bin = await fakeBin('printf \'{"a":1}\\n{"b":\'; sleep 0.1; printf \'2}\\n\'; pwd; printf "%s|" "$@"') + const lines: string[] = [] + await runClaudeLines({ bin, args: ['-p', '带 空格 的问题'], cwd: dir, onLine: item => lines.push(item) }) + expect(lines).toEqual(['{"a":1}', '{"b":2}', await fsp.realpath(dir), '-p|带 空格 的问题|']) + }) + + it('退出码非零:带上 stderr 的原话', async () => { + const bin = await fakeBin('echo "No conversation found with session ID: x" >&2; exit 1') + const error = await runClaudeLines({ bin, args: [], cwd: dir, onLine: () => {} }).catch((err: unknown) => err) + expect(error).toBeInstanceOf(ClaudeError) + expect((error as ClaudeError).message).toBe('Claude 退出码非零 (1): No conversation found with session ID: x') + }) + + it('叫停:马上结束进程,报 ClaudeCancelledError;已经叫停的不再启动', async () => { + const marker = path.join(dir, 'still-running') + const bin = await fakeBin(`echo started; sleep 2; touch "${marker}"`) + const controller = new AbortController() + const started = Date.now() + const run = runClaudeLines({ bin, args: [], cwd: dir, signal: controller.signal, onLine: () => controller.abort() }) + await expect(run).rejects.toBeInstanceOf(ClaudeCancelledError) + expect(Date.now() - started).toBeLessThan(1500) + await new Promise(resolve => setTimeout(resolve, 2500)) + await expect(fsp.access(marker)).rejects.toThrow() + await expect(runClaudeLines({ bin, args: [], cwd: dir, signal: controller.signal, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeCancelledError) + }, 10_000) + + it('超时:结束进程,报 ClaudeTimeoutError', async () => { + const bin = await fakeBin('sleep 5') + await expect(runClaudeLines({ bin, args: [], cwd: dir, timeoutMs: 200, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeTimeoutError) + }) +}) diff --git a/vscode/src/cc/spawnClaude.ts b/vscode/src/cc/spawnClaude.ts index cfc4252..ce9ddf6 100644 --- a/vscode/src/cc/spawnClaude.ts +++ b/vscode/src/cc/spawnClaude.ts @@ -58,6 +58,14 @@ export class ClaudeTimeoutError extends ClaudeError { } } +/** 调用方叫停(AbortSignal)时进程已被结束。 */ +export class ClaudeCancelledError extends ClaudeError { + constructor(stderr: string) { + super('Claude 调用已停止', stderr) + this.name = 'ClaudeCancelledError' + } +} + interface ParsedClaudeJson { session_id: string result: string @@ -185,21 +193,33 @@ export type ClaudeProgressListener = (label: string) => void /** * 按行切 stdout:stream-json 每行一个对象,但 data 事件的 chunk 边界和行边界 - * 无关,一行常被拆进两个 chunk,所以末尾那段不完整的残行要留到下次拼上。 + * 无关,一行常被拆进两个 chunk,所以末尾那段不完整的残行要留到下次拼上; + * 进程结束时 flush 交出最后那段没带换行的。 */ +function createLineFeeder(onLine: (line: string) => void): { feed: (chunk: string) => void, flush: () => void } { + let pending = '' + return { + feed: (chunk) => { + pending += chunk + const lines = pending.split('\n') + pending = lines.pop() ?? '' + lines.forEach(onLine) + }, + flush: () => { + if (pending !== '') + onLine(pending) + pending = '' + }, + } +} + function createProgressFeeder(onProgress?: ClaudeProgressListener): (chunk: string) => void { if (!onProgress) return () => {} - let pending = '' - return (chunk) => { - pending += chunk - const lines = pending.split('\n') - pending = lines.pop() ?? '' - for (const line of lines) { - for (const label of summarizeClaudeStreamLine(line)) - onProgress(label) - } - } + return createLineFeeder((line) => { + for (const label of summarizeClaudeStreamLine(line)) + onProgress(label) + }).feed } function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string { @@ -233,7 +253,7 @@ function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string { * - CLAUDECODE / CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_SESSION: * 从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出、零输出。 */ -const CLAUDE_MISSING_MSG = '未检测到 claude CLI,请确认已安装并在 PATH 中(常见位置 ~/.local/bin)' +export const CLAUDE_MISSING_MSG = '未检测到 claude CLI,请确认已安装并在 PATH 中(常见位置 ~/.local/bin)' function buildClaudeChildEnv(bin?: string): NodeJS.ProcessEnv { // 剔除所有 ANTHROPIC_* 是为了让 `--settings` profile 成为 provider/鉴权的唯一来源 @@ -537,3 +557,92 @@ function spawnClaudeStreamed( } }) } + +/** + * 跑一次 claude(参数由调用方拼好,一般带 `--output-format stream-json`),stdout 每出来完整的一行就交给 onLine。 + * 正常退出时 resolve;找不到 claude、退出码非零、输出过大时 reject ClaudeError,超时 ClaudeTimeoutError, + * signal 叫停时结束进程并 reject ClaudeCancelledError。 + */ +export function runClaudeLines(opts: { + args: string[] + cwd: string + onLine: (line: string) => void + timeoutMs?: number + signal?: AbortSignal + /** 默认按 resolveClaudeBin 找 */ + bin?: string +}): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS + const bin = opts.bin ?? resolveClaudeBin() + if (!bin) + return Promise.reject(new ClaudeError(CLAUDE_MISSING_MSG, '')) + if (opts.signal?.aborted) + return Promise.reject(new ClaudeCancelledError('')) + + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let stdoutBytes = 0 + let settled = false + const lines = createLineFeeder(opts.onLine) + + const child = spawn(bin, opts.args, { + cwd: opts.cwd, + env: buildClaudeChildEnv(bin), + stdio: ['ignore', 'pipe', 'pipe'], + }) + + const timer = setTimeout(() => { + child.kill('SIGKILL') + settle(new ClaudeTimeoutError(`Claude 调用超时(${Math.round(timeoutMs / 1000)}s)`, stderr)) + }, timeoutMs) + function onAbort(): void { + child.kill('SIGTERM') + settle(new ClaudeCancelledError(stderr)) + } + function settle(error?: ClaudeError): void { + if (settled) + return + settled = true + clearTimeout(timer) + opts.signal?.removeEventListener('abort', onAbort) + if (error) + reject(error) + else + resolve() + } + opts.signal?.addEventListener('abort', onAbort, { once: true }) + + child.on('error', (err) => { + const errCode = (err as NodeJS.ErrnoException).code + settle(new ClaudeError(errCode === 'ENOENT' ? CLAUDE_MISSING_MSG : `Claude 调用失败: ${err.message}`, stderr)) + }) + + child.stdout.on('data', (chunk: Buffer) => { + if (settled) + return + stdoutBytes += chunk.length + if (stdoutBytes > MAX_BUFFER_BYTES) { + child.kill('SIGKILL') + settle(new ClaudeError(`Claude 输出超过 ${MAX_BUFFER_BYTES} bytes`, stderr)) + return + } + const text = chunk.toString('utf8') + stdout += text + lines.feed(text) + }) + + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8') + }) + + child.on('close', (code) => { + if (settled) + return + lines.flush() + settle(code === 0 + ? undefined + : new ClaudeError(`Claude 退出码非零 (${code}): ${buildClaudeExitDiagnostic(stderr, stdout)}`, stderr)) + }) + }) +} diff --git a/vscode/src/codeReader/attributes.test.ts b/vscode/src/codeReader/attributes.test.ts index e9fe976..06480d9 100644 --- a/vscode/src/codeReader/attributes.test.ts +++ b/vscode/src/codeReader/attributes.test.ts @@ -108,7 +108,7 @@ describe('块的筛选属性:宿主从 kind 和 facts 推出通用属性', () }) function view(id: string, attributes: Record, children: BlockView[] = []): BlockView { - return { id, kind: 'statement', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes, children, translation: [] } + return { id, anchor: id, kind: 'statement', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes, children, translation: [] } } describe('文件里出现过的属性', () => { diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index babf9cb..6fefbe6 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -3,10 +3,13 @@ import type { TreeSitterRuntime } from './runtime' import { blockAttributes } from './attributes' import { commonIndent, splitLines } from './editText' import { activeRules, claimBlock, claimedShape, importsIn } from './libraryRules' +import { BlockAnchors } from './qa/anchor' interface BlockViewBase { /** 在树里的位置,例如 '6.1.0'。 */ id: string + /** 块的位置记号(见 BlockAnchors):问答挂在它上面,文件别处改了照样认得回来。 */ + anchor: string /** BlockKind,或库规则卡片的种类 id(RuleCard.kind,带点,例如 sqlalchemy.table)。 */ kind: string /** @@ -155,41 +158,49 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu const doc = block.doc && translator?.doc(block.doc, document) return { translation: translator ? translator.translate(block, document) : [], ...(doc && { doc }) } } - /** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):头部是编辑范围,子块照常交给各规则看。 */ - function plain(block: CodeBlock, id: string): BlockView { + /** 按源码先后逐块给位置记号:先序遍历,块的记号在它的子块之前要 */ + const anchors = new BlockAnchors() + /** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):头部是编辑范围,子块照常交给各规则看。scope 见 BlockAnchors.place */ + function plain(block: CodeBlock, id: string, scope: string): BlockView { const name = ownName(block) + const rows = sourceRows(block.node.tree.rootNode, block.header.startRow, block.header.endRow) + const { anchor, inner } = anchors.place(scope, block.kind, name?.text, rows.code) return { id, + anchor, kind: block.kind, - ...sourceRows(block.node.tree.rootNode, block.header.startRow, block.header.endRow), + ...rows, ...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }), attributes: blockAttributes(block), - children: block.children.map((child, i) => build(child, `${id}.${i}`)), + children: block.children.map((child, i) => build(child, `${id}.${i}`, inner)), ...('facts' in block ? { properties: translator ? translator.describe(block, document) : [] } : textView(block)), } } - function build(block: CodeBlock, id: string): BlockView { + function build(block: CodeBlock, id: string, scope: string): BlockView { const claim = active.length > 0 ? claimBlock(block, active, context, rules.report) : undefined if (!claim) - return plain(block, id) + return plain(block, id, scope) const { span, kept } = claimedShape(block, claim.block.absorbs) if ('as' in claim.block) { const { attributes } = block - return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, id) + return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, id, scope) } const { kind, badge, rows, defines, attributes } = claim.block + const lines = sourceRows(block.node.tree.rootNode, span.startRow, span.endRow) + const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), lines.code) return { id, + anchor, kind, - ...sourceRows(block.node.tree.rootNode, span.startRow, span.endRow), + ...lines, ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), attributes: { ...blockAttributes(block), ...attributes, kind }, badge, - children: kept.map((child, i) => build(child, `${id}.${i}`)), + children: kept.map((child, i) => build(child, `${id}.${i}`, inner)), properties: rows, } } - return blocks.map((block, i) => build(block, String(i))) + return blocks.map((block, i) => build(block, String(i), '')) } /** 解析一份源码并转成视图数据;语法树用完即释放。 */ diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 0b58813..f7ae95f 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -4,6 +4,8 @@ import type { FileAttribute } from '../attributes' import type { BlockView } from '../blockView' +import type { AnchorStep } from '../qa/anchor' +import type { RunningAsk } from '../qa/asks' import type { Excluded } from './blockFilter' export type { Excluded } @@ -44,11 +46,40 @@ export interface DocumentMessage { reveal?: Reveal } +/** 问过的一次(给预览看的):问题、回答(Markdown)、什么时候问的(ISO 时间)。 */ +export interface QaEntryView { + id: string + question: string + answer: string + createdAt: string +} + +/** 挂在一块上的问答。where 是记号拆回的各层(种类和名字),块找不到了时写它原来在哪。 */ +export interface QaThreadView { + anchor: string + where: AnchorStep[] + entries: QaEntryView[] +} + +/** + * 这份文件的问答:available 为假时文件不在工作区文件夹里、问答没地方存,不给「问 AI」; + * running 是正在答的问题(页面重新加载后靠它接上)。 + */ +export interface QaMessage { + type: 'qa' + available: boolean + threads: QaThreadView[] + running: RunningAsk[] +} + /** * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) * definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义 * occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的 * filter:整个工作区共用的块筛选;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览 + * qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发 + * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 + * answerEnd:askId 那个问题答完了:done 带最终的回答、存下的那条的 id 和会话 id(追问接着这个会话),failed 带给人看的原因,stopped 是被叫停 */ export type ExtensionToWebview = | DocumentMessage @@ -56,6 +87,9 @@ export type ExtensionToWebview | { type: 'definitionProbed', id: number, found: boolean } | { type: 'occurrencesFound', id: number, occurrences: Occurrence[] } | { type: 'filter', excluded: Excluded } + | QaMessage + | { type: 'answer', askId: string, text: string, activity?: string } + | { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string, sessionId?: string } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) @@ -65,6 +99,9 @@ export type ExtensionToWebview * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 * findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition * setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览 + * ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;resume 是追问时上一次的会话 id + * stopAsk:叫停 askId 那个问题,这次不存 + * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) */ export type WebviewToExtension = | { type: 'ready' } @@ -75,3 +112,6 @@ export type WebviewToExtension | { type: 'probeDefinition', id: number, version: number, offset: number } | { type: 'goToDefinition', version: number, offset: number } | { type: 'findOccurrences', id: number, version: number, offset: number } + | { type: 'ask', askId: string, anchor: string, question: string, resume?: string } + | { type: 'stopAsk', askId: string } + | { type: 'deleteQa', anchor: string, entryId: string } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 4fbce1b..a9b248a 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,5 +1,7 @@ import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode' +import type { BlockView } from '../blockView' import type { RuleLocation, RuleReference } from '../contract' +import type { CodeQa } from '../qa/codeQa' import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' import type { BlockFilter } from './blockFilter' @@ -9,6 +11,8 @@ import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode' import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' import { blockReplacement } from '../editText' +import { locate } from '../folderStore' +import { askPrompt, followUpPrompt } from '../qa/prompt' import { definitionAction } from './definitionAction' import { findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions' import { DefinitionSources } from './definitionSources' @@ -36,6 +40,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { private readonly filter: BlockFilter, /** 库规则标的引用去哪儿查定义(交给标它的规则) */ readonly resolveReference: (reference: RuleReference) => Promise, + /** 问 AI:各预览共用,同一份文件开在几处时问答、正在答的问题都一样 */ + readonly qa: CodeQa, ) {} resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { @@ -98,6 +104,8 @@ class Preview { private readonly definitions: DefinitionSources /** webview 报来的视口顶部那块的起始行 */ visibleLine: number | undefined + /** 最近一次发出去的块树和它的源码:问 AI 时按块的记号在里面找那块代码 */ + private sent: { blocks: BlockView[], source: string } | undefined constructor( private readonly document: TextDocument, @@ -131,6 +139,28 @@ class Preview { if (this.ready) this.post({ type: 'filter', excluded }) }), + owner.qa.onDidChange((uris) => { + if (uris.some(uri => uri.toString() === document.uri.toString())) + this.postQa() + }), + owner.qa.onDidAnswer((event) => { + if (event.owner !== document.uri.toString() || !this.ready) + return + if (event.kind === 'update') { + const { askId, text, activity } = event.ask + this.post({ type: 'answer', askId, text, ...(activity !== undefined && { activity }) }) + return + } + const { end, entryId } = event + this.post({ + type: 'answerEnd', + askId: event.ask.askId, + status: end.status, + ...(end.status === 'failed' && { error: end.error }), + ...(end.status === 'done' && { text: end.answer.text, entryId, sessionId: end.answer.sessionId }), + }) + this.postQa() + }), ] } @@ -151,12 +181,15 @@ class Preview { if (!this.ready) return const request = ++this.latest - const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime, (text) => { + const { languageId, version } = this.document + const source = this.document.getText() + const message = await documentMessage({ languageId, version, getText: () => source }, this.registry, this.runtime, (text) => { logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` }) }) if (request !== this.latest || !this.ready) return this.definitions.remember(message.version, message.blocks) + this.sent = { blocks: message.blocks, source } const reveal = this.pendingReveal this.pendingReveal = undefined this.post(reveal === undefined ? message : { ...message, reveal }) @@ -180,6 +213,30 @@ class Preview { void this.panel.webview.postMessage(message) } + private postQa(): void { + if (this.ready) + this.post(this.owner.qa.view(this.document.uri)) + } + + /** + * 问 anchor 那块代码:按最近一次发出去的块树找到它、带上代码上下文交给 CodeQa;resume 是追问时上一次的会话 id。 + * 找不到这块(代码刚改过、还没重新发)或问不了时,直接回一条失败。 + */ + private ask(askId: string, anchor: string, question: string, resume: string | undefined): void { + const at = locate(this.document.uri) + const subject = at && this.sent && { path: at.key, languageId: this.document.languageId, ...this.sent, anchor } + const prompt = subject && askPrompt(subject, question) + const followUp = subject && resume ? followUpPrompt(subject, question) : undefined + const refused = prompt === undefined + ? '这块代码刚改过,找不到了。等预览刷新后重新点「问 AI」再问。' + : this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, ...(resume && followUp && { followUp: { sessionId: resume, prompt: followUp } }) }) + if (refused !== undefined) { + this.post({ type: 'answerEnd', askId, status: 'failed', error: refused }) + return + } + this.postQa() + } + private schedulePush(): void { clearTimeout(this.timer) this.timer = setTimeout(() => void this.push(), REFRESH_DELAY_MS) @@ -191,8 +248,18 @@ class Preview { this.ready = true // 先于文档发:第一份文档按筛选画,不会先整份露出来再藏 this.post({ type: 'filter', excluded: this.filter.excluded }) + this.postQa() await this.push() return + case 'ask': + this.ask(msg.askId, msg.anchor, msg.question, msg.resume) + return + case 'stopAsk': + this.owner.qa.stop(msg.askId) + return + case 'deleteQa': + await this.owner.qa.delete(this.document.uri, msg.anchor, msg.entryId) + return case 'setFilter': await this.filter.set(msg.excluded) return diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 1354b74..3dfacfa 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -12,6 +12,7 @@ import { createTypeScriptAdapter } from './languages/typescript' import { resolveReference } from './libraryRules' import { registerNoteCommands } from './notes/commands' import { FileNotes } from './notes/fileNotes' +import { CodeQa } from './qa/codeQa' import { CodeReaderRegistry } from './registry' import { celeryRule } from './rules/celery' import { enumRule } from './rules/enum' @@ -48,7 +49,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise { } /** - * 接入代码阅读:注册表、侧栏文件树(复制、文件注释)、预览编辑器、编辑器右上角的「预览 / 编辑」切换。 + * 接入代码阅读:注册表、侧栏文件树(复制、文件注释)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。 * 内置的语言插件、翻译插件和库规则与其他扩展一样通过返回的 API 注册。 */ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { @@ -62,11 +63,13 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { return runtime.parse(adapter, languageId, source) }, report)) const resolve = (reference: RuleReference): Promise => resolveReference(reference, registry.libraryRules(), indexes, report) - const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve) + const qa = new CodeQa() + const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa) const notes = new FileNotes() const files = new FileTreeProvider(OPEN_COMMAND, notes) context.subscriptions.push( + qa, notes, indexes, registerNoteCommands(notes), diff --git a/vscode/src/codeReader/qa/anchor.test.ts b/vscode/src/codeReader/qa/anchor.test.ts new file mode 100644 index 0000000..5bae321 --- /dev/null +++ b/vscode/src/codeReader/qa/anchor.test.ts @@ -0,0 +1,102 @@ +import type { BlockView } from '../blockView' +import { describe, expect, it } from 'vitest' +import { toBlockViews } from '../blockView' +import { createPythonAdapter } from '../languages/python' +import { WASM_DIR, withBlocks } from '../testSupport' +import { anchorSteps, BlockAnchors } from './anchor' + +const py = createPythonAdapter(WASM_DIR) + +function views(source: string): Promise { + return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, undefined)) +} + +function flatten(list: BlockView[]): BlockView[] { + return list.flatMap(view => [view, ...flatten(view.children)]) +} + +/** 各块的记号,按源码先后,连同它这块的第一行源码 */ +async function anchors(source: string): Promise<[string, string][]> { + return flatten(await views(source)).map(view => [view.anchor, view.code.split('\n')[0]]) +} + +const SOURCE = `class ReferralRelation(Base): + def display(self): + if self.active: + return self.name + return "none" + + def other(self): + if self.active: + return 1 + + +def helper(x): + print(x) + print(x) +` + +describe('块的位置记号', () => { + it('有名字的块是外层各个有名字的块加自己的种类和名字;没名字的块是外层最近的有名字的块加种类和源码哈希;同一层重复的依次编号', async () => { + const list = await anchors(SOURCE) + const hashed = (anchor: string): string => anchor.replace(/@[0-9a-f]{8}/g, '@#') + expect(list.map(([anchor, code]) => [hashed(anchor), code])).toEqual([ + ['class ReferralRelation', 'class ReferralRelation(Base):'], + ['class ReferralRelation > function display', 'def display(self):'], + ['class ReferralRelation > function display > if @#', 'if self.active:'], + ['class ReferralRelation > function display > return @#', 'return self.name'], + ['class ReferralRelation > function display > return @#', 'return "none"'], + ['class ReferralRelation > function other', 'def other(self):'], + ['class ReferralRelation > function other > if @#', 'if self.active:'], + ['class ReferralRelation > function other > return @#', 'return 1'], + ['function helper', 'def helper(x):'], + ['function helper > statement @#', 'print(x)'], + ['function helper > statement @#[1]', 'print(x)'], + ]) + // 两个函数里一模一样的条件各在各的函数下面,互不影响;源码不同的两个返回哈希不同 + expect(list[2][0].split(' > ').at(-1)).toBe(list[6][0].split(' > ').at(-1)) + expect(list[3][0]).not.toBe(list[4][0]) + expect(new Set(list.map(([anchor]) => anchor)).size).toBe(list.length) + }) + + it('上面插几行、改缩进和空白:记号都不变', async () => { + const before = await anchors(SOURCE) + const after = await anchors(`import os\n\n# 新加的注释\n${SOURCE.replace('print(x)\n print(x)', 'print( x )\n print(x)')}`) + const shifted = after.filter(([anchor]) => before.some(([old]) => old === anchor)) + expect(shifted.map(([anchor]) => anchor)).toEqual(before.map(([anchor]) => anchor)) + }) + + it('改别的函数的名字:别处的记号不变,改名的函数和它里面的块换了记号', async () => { + const before = new Set((await anchors(SOURCE)).map(([anchor]) => anchor)) + const after = (await anchors(SOURCE.replace('def other(self)', 'def renamed(self)'))).map(([anchor]) => anchor) + const kept = after.filter(anchor => before.has(anchor)) + expect(kept).toEqual([...before].filter(anchor => !anchor.includes('function other'))) + expect(after.filter(anchor => !before.has(anchor)).every(anchor => anchor.includes('function renamed'))).toBe(true) + }) + + it('没名字的块自己的源码改了:记号变了,旧问答认不回来;有名字的块改了主体、签名照样认得', async () => { + const before = await anchors(SOURCE) + const after = await anchors(SOURCE.replace('if self.active:\n return self.name', 'if self.visible:\n return self.name').replace('def helper(x):', 'def helper(x, y=1):')) + const anchorOf = (list: [string, string][], code: string): string | undefined => list.find(([, line]) => line === code)?.[0] + expect(after.map(([anchor]) => anchor)).not.toContain(anchorOf(before, 'if self.active:')) + expect(anchorOf(after, 'return self.name')).toBe(anchorOf(before, 'return self.name')) + expect(anchorOf(after, 'def helper(x, y=1):')).toBe(anchorOf(before, 'def helper(x):')) + }) + + it('拆回各层:没名字的一层只有种类,编号去掉', () => { + expect(anchorSteps('class ReferralRelation > function display[1] > if @3fa1c2d0[2]')).toEqual([ + { kind: 'class', name: 'ReferralRelation' }, + { kind: 'function', name: 'display' }, + { kind: 'if' }, + ]) + expect(anchorSteps('variable { a, b }')).toEqual([{ kind: 'variable', name: '{ a, b }' }]) + }) + + it('名字里的空白压成一个空格,空名字按没名字算', () => { + const anchors = new BlockAnchors() + expect(anchors.place('', 'variable', '{\n a,\n b\n}', '').anchor).toBe('variable { a, b }') + const unnamed = anchors.place('scope', 'statement', ' ', 'x = 1') + expect(unnamed.anchor).toMatch(/^scope > statement @[0-9a-f]{8}$/) + expect(unnamed.inner).toBe('scope') + }) +}) diff --git a/vscode/src/codeReader/qa/anchor.ts b/vscode/src/codeReader/qa/anchor.ts new file mode 100644 index 0000000..34cd4ad --- /dev/null +++ b/vscode/src/codeReader/qa/anchor.ts @@ -0,0 +1,59 @@ +import { createHash } from 'node:crypto' + +/** + * 块的位置记号(anchor):问答挂在哪一块上,文件别处改了也认得回来。只用和语言无关的块数据: + * - 有名字的块(函数、类、单个变量……,名字取自 facts / 规则给的定义范围):外层各个有名字的块 + 自己的种类和名字, + * 例如 `class ReferralRelation > function display`; + * - 没名字的块(条件、语句、注释……):外层最近的有名字的块 + 种类 + 源码去掉空白后的哈希,例如 `function display > if @3fa1c2d0`。 + * 同一层里记号相同的(同名的两个函数、一模一样的两行语句)按源码先后在后面补 `[1]`、`[2]`。 + * 所以在上面插几行、改别的函数的名字都不影响;没名字的块自己的源码一改,记号就变了,旧问答认不回来。 + */ + +const SEPARATOR = ' > ' +const HASH_MARK = '@' + +/** 连着的空白压成一个空格、去掉首尾空白(名字写进记号时)。 */ +function squeeze(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +/** 源码去掉所有空白再算:只改缩进、换行、空格(格式化工具重排)不算改了代码。 */ +function codeHash(code: string): string { + return createHash('sha1').update(code.replace(/\s+/g, '')).digest('hex').slice(0, 8) +} + +/** 一份文件里各块的记号:按源码先后(树的先序)逐块要,同一层里重复的记号依次编号。 */ +export class BlockAnchors { + private readonly seen = new Map() + + /** + * scope 是外层最近的有名字的块的记号(顶层是 `''`);name 是块自己的名字,没有就按 code 的哈希认。 + * 返回这一块的记号(anchor)和它的子块的 scope(inner):有名字的块自己就是一层,没名字的块沿用外层的。 + */ + place(scope: string, kind: string, name: string | undefined, code: string): { anchor: string, inner: string } { + const label = name === undefined ? '' : squeeze(name) + const segment = label === '' ? `${kind} ${HASH_MARK}${codeHash(code)}` : `${kind} ${label}` + const base = scope === '' ? segment : `${scope}${SEPARATOR}${segment}` + const count = this.seen.get(base) ?? 0 + this.seen.set(base, count + 1) + const anchor = count === 0 ? base : `${base}[${count}]` + return { anchor, inner: label === '' ? scope : anchor } + } +} + +/** 记号里的一层:种类和名字(没名字的块只有种类),给「找不到原来位置的问答」写它原来在哪。 */ +export interface AnchorStep { + kind: string + name?: string +} + +/** 把记号拆回各层;拆不出种类的一层丢掉。 */ +export function anchorSteps(anchor: string): AnchorStep[] { + return anchor.split(SEPARATOR).flatMap((segment) => { + const match = /^(\S+) (.*?)(?:\[\d+\])?$/.exec(segment) + if (!match) + return [] + const [, kind, rest] = match + return rest.startsWith(HASH_MARK) || rest === '' ? [{ kind }] : [{ kind, name: rest }] + }) +} diff --git a/vscode/src/codeReader/qa/answerStream.test.ts b/vscode/src/codeReader/qa/answerStream.test.ts new file mode 100644 index 0000000..1340427 --- /dev/null +++ b/vscode/src/codeReader/qa/answerStream.test.ts @@ -0,0 +1,98 @@ +import type { AnswerEvent } from './answerStream' +import { describe, expect, it } from 'vitest' +import { createAnswerParser } from './answerStream' + +const SESSION = '1763f93a-2792-4120-91eb-2e220fe01389' + +const line = (value: unknown): string => JSON.stringify(value) +const stream = (event: unknown): string => line({ type: 'stream_event', event, session_id: SESSION, parent_tool_use_id: null }) +const delta = (text: string): string => stream({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }) + +/** 照本机 claude 2.1 的真实输出截的:hook 行、init、先读文件再回答、逐字的片段、整条消息、result */ +const TRANSCRIPT = [ + line({ type: 'system', subtype: 'hook_started', hook_name: 'SessionStart:startup', session_id: SESSION }), + line({ type: 'system', subtype: 'init', cwd: '/work', session_id: SESSION, tools: ['Glob', 'Grep', 'Read'], permissionMode: 'dontAsk' }), + stream({ type: 'message_start', message: { id: 'msg_1', role: 'assistant', content: [] } }), + stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }), + delta('我先看看'), + delta(' m.py。'), + line({ type: 'assistant', message: { id: 'msg_1', content: [{ type: 'text', text: '我先看看 m.py。' }] }, parent_tool_use_id: null, session_id: SESSION }), + stream({ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_1', name: 'Read', input: {} } }), + stream({ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"file_path": "/work/m.py"}' } }), + line({ type: 'assistant', message: { id: 'msg_1', content: [{ type: 'tool_use', id: 'toolu_1', name: 'Read', input: { file_path: '/work/m.py' } }] }, parent_tool_use_id: null, session_id: SESSION }), + line({ type: 'user', message: { role: 'user', content: [{ tool_use_id: 'toolu_1', type: 'tool_result', content: '1\tdef add(a, b):' }] }, parent_tool_use_id: null }), + stream({ type: 'message_start', message: { id: 'msg_2', role: 'assistant', content: [] } }), + stream({ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } }), + stream({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '想一想' } }), + stream({ type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } }), + delta('`add` 返回'), + delta(' `a + b`(`m.py:1`)。'), + line({ type: 'assistant', message: { id: 'msg_2', content: [{ type: 'text', text: '`add` 返回 `a + b`(`m.py:1`)。' }] }, parent_tool_use_id: null, session_id: SESSION }), + line({ type: 'result', subtype: 'success', is_error: false, result: '`add` 返回 `a + b`(`m.py:1`)。', session_id: SESSION }), +] + +function run(lines: string[]): AnswerEvent[] { + const parse = createAnswerParser() + return lines.flatMap(parse) +} + +describe('stream-json → 问答的进展', () => { + it('会话 id、逐字长出来的回答、读文件、最终回答;新的一条回复一开口就换掉前面那句', () => { + expect(run(TRANSCRIPT)).toEqual([ + { kind: 'session', sessionId: SESSION }, + { kind: 'text', text: '我先看看' }, + { kind: 'text', text: '我先看看 m.py。' }, + { kind: 'activity', label: '读取 m.py' }, + { kind: 'text', text: '`add` 返回' }, + { kind: 'text', text: '`add` 返回 `a + b`(`m.py:1`)。' }, + { kind: 'result', text: '`add` 返回 `a + b`(`m.py:1`)。', sessionId: SESSION }, + ]) + }) + + it('没开逐字片段时按整条 assistant 消息更新,结果一样', () => { + const whole = TRANSCRIPT.filter(item => !item.includes('"stream_event"')) + expect(run(whole)).toEqual([ + { kind: 'session', sessionId: SESSION }, + { kind: 'text', text: '我先看看 m.py。' }, + { kind: 'activity', label: '读取 m.py' }, + { kind: 'text', text: '`add` 返回 `a + b`(`m.py:1`)。' }, + { kind: 'result', text: '`add` 返回 `a + b`(`m.py:1`)。', sessionId: SESSION }, + ]) + }) + + it('一条回复里两段文字中间空一行', () => { + const events = run([ + stream({ type: 'message_start', message: { id: 'm' } }), + stream({ type: 'content_block_start', index: 0, content_block: { type: 'text' } }), + delta('第一段'), + stream({ type: 'content_block_start', index: 1, content_block: { type: 'text' } }), + delta('第二段'), + ]) + expect(events.at(-1)).toEqual({ kind: 'text', text: '第一段\n\n第二段' }) + }) + + it('result 行没带回答时用眼下这条回复', () => { + expect(run([delta('答案'), line({ type: 'result', subtype: 'success', is_error: false, result: '', session_id: SESSION })]).at(-1)) + .toEqual({ kind: 'result', text: '答案', sessionId: SESSION }) + }) + + it('claude 自己报的失败:取它说的原因', () => { + expect(run([line({ type: 'result', subtype: 'success', is_error: true, result: 'Invalid API key · Please run /login' })])) + .toEqual([{ kind: 'error', message: 'Invalid API key · Please run /login' }]) + expect(run([line({ type: 'result', subtype: 'error_during_execution', is_error: true, errors: ['No conversation found with session ID: x'] })])) + .toEqual([{ kind: 'error', message: 'No conversation found with session ID: x' }]) + expect(run([line({ type: 'result', subtype: 'error_max_turns', is_error: true })])) + .toEqual([{ kind: 'error', message: 'error_max_turns' }]) + }) + + it('坏行、别的类型、子代理的消息都不算', () => { + expect(run([ + 'not json', + '', + line(null), + line({ type: 'rate_limit_event' }), + line({ type: 'assistant', parent_tool_use_id: 'toolu_9', message: { id: 'sub', content: [{ type: 'text', text: '子代理说的话' }] } }), + line({ type: 'stream_event', parent_tool_use_id: 'toolu_9', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: '子代理' } } }), + ])).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/qa/answerStream.ts b/vscode/src/codeReader/qa/answerStream.ts new file mode 100644 index 0000000..33a5a4d --- /dev/null +++ b/vscode/src/codeReader/qa/answerStream.ts @@ -0,0 +1,119 @@ +/** + * 把 `claude -p --output-format stream-json --verbose --include-partial-messages` 吐出的每行 NDJSON + * 换成问答要的几件事:会话 id、回答逐字长出来、正在读什么文件、答完 / 出错。 + * + * 回答显示的是眼下这条回复:Claude 先说一句「我去看看 X」、读完文件再回答时,新回复一开口就换掉前面那句, + * 答完以 result 行里的最终回复为准(就是最后那条回复)。没开 partial messages 时按整条 assistant 消息更新,结果一样。 + */ + +import { describeToolUse } from '../../cc/claudeProgress' + +export type AnswerEvent + = | { kind: 'session', sessionId: string } + /** 眼下这条回复到现在为止的全文(Markdown) */ + | { kind: 'text', text: string } + /** 正在做的事:「读取 routes.py」「搜索 get_user」 */ + | { kind: 'activity', label: string } + | { kind: 'result', text: string, sessionId: string } + /** Claude Code 自己报的失败(result 行 is_error),进程多半也会以非零退出 */ + | { kind: 'error', message: string } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function str(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +/** 一个问题一个解析器:它记着眼下这条回复写到哪了。 */ +export function createAnswerParser(): (line: string) => AnswerEvent[] { + let text = '' + let sessionId = '' + /** 新的一条回复开始了,下一个字换掉前面那条 */ + let fresh = false + /** 见过逐字的片段:整条 assistant 消息里的字就不再算,免得重复 */ + let partial = false + let lastMessage: unknown + + const write = (piece: string): AnswerEvent[] => { + if (fresh) { + text = '' + fresh = false + } + text += piece + return [{ kind: 'text', text }] + } + + const streamEvent = (event: Record): AnswerEvent[] => { + if (event.type === 'message_start') { + fresh = true + return [] + } + if (event.type === 'content_block_start' && isRecord(event.content_block) && event.content_block.type === 'text' && !fresh && text !== '') { + text += '\n\n' + return [] + } + if (event.type === 'content_block_delta' && isRecord(event.delta) && event.delta.type === 'text_delta') { + partial = true + return write(str(event.delta.text)) + } + return [] + } + + const assistant = (message: Record): AnswerEvent[] => { + if (!Array.isArray(message.content)) + return [] + const events: AnswerEvent[] = [] + if (!partial && message.id !== lastMessage) { + fresh = true + lastMessage = message.id + } + for (const block of message.content) { + if (!isRecord(block)) + continue + if (block.type === 'tool_use' && typeof block.name === 'string') + events.push({ kind: 'activity', label: describeToolUse(block.name, isRecord(block.input) ? block.input : {}) }) + else if (block.type === 'text' && !partial) + events.push(...write(str(block.text))) + } + return events + } + + const result = (line: Record): AnswerEvent[] => { + if (line.is_error === true) { + const errors = Array.isArray(line.errors) ? line.errors.filter(item => typeof item === 'string').join(';') : '' + return [{ kind: 'error', message: str(line.result).trim() || errors || str(line.subtype) || '没说原因' }] + } + return [{ kind: 'result', text: str(line.result).trim() || text, sessionId: str(line.session_id) || sessionId }] + } + + return (raw) => { + let line: unknown + try { + line = JSON.parse(raw) + } + catch { + return [] + } + // 子代理的消息(parent_tool_use_id 不空)不是给用户的回答 + if (!isRecord(line) || (line.parent_tool_use_id !== undefined && line.parent_tool_use_id !== null)) + return [] + switch (line.type) { + case 'system': + if (line.subtype === 'init' && typeof line.session_id === 'string') { + sessionId = line.session_id + return [{ kind: 'session', sessionId }] + } + return [] + case 'stream_event': + return isRecord(line.event) ? streamEvent(line.event) : [] + case 'assistant': + return isRecord(line.message) ? assistant(line.message) : [] + case 'result': + return result(line) + default: + return [] + } + } +} diff --git a/vscode/src/codeReader/qa/asks.test.ts b/vscode/src/codeReader/qa/asks.test.ts new file mode 100644 index 0000000..9063510 --- /dev/null +++ b/vscode/src/codeReader/qa/asks.test.ts @@ -0,0 +1,114 @@ +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' + +/** 假的回答:每次开问记下来,由测试决定什么时候答完 / 出错;叫停时像真的一样抛 ClaudeCancelledError */ +function fakeRunner(): { runner: (run: AskRun) => Promise, runs: (AskRun & { answer: (answer: Answer) => void, fail: (err: Error) => void })[] } { + const runs: (AskRun & { answer: (answer: Answer) => void, fail: (err: Error) => void })[] = [] + return { + runs, + runner: run => new Promise((resolve, reject) => { + run.signal.addEventListener('abort', () => reject(new ClaudeCancelledError(''))) + runs.push({ ...run, answer: resolve, fail: reject }) + }), + } +} + +function start(askId: string, anchor = askId, owner = 'file:///a.py'): AskStart { + return { owner, askId, anchor, question: `问 ${askId}`, run: { prompt: `完整的 ${askId}`, cwd: '/work' } } +} + +function listener(): { updates: RunningAsk[], ends: [RunningAsk, AskEnd][], ended: Promise, update: (ask: RunningAsk) => void, end: (ask: RunningAsk, end: AskEnd) => void } { + let done: () => void = () => {} + const record = { + updates: [] as RunningAsk[], + ends: [] as [RunningAsk, AskEnd][], + ended: new Promise((resolve) => { + done = resolve + }), + update: (ask: RunningAsk) => record.updates.push(ask), + end: (ask: RunningAsk, end: AskEnd) => { + record.ends.push([ask, end]) + done() + }, + } + return record +} + +describe('正在答的问题', () => { + it('回答长出来、换了在做的事都报给界面;答完报 done,不再算在答', async () => { + const { runner, runs } = fakeRunner() + const asks = new QaAsks(runner) + const events = listener() + expect(asks.start(start('1'), events)).toBeUndefined() + expect(runs[0]).toMatchObject({ prompt: '完整的 1', cwd: '/work' }) + runs[0].onEvent({ kind: 'session', sessionId: 's' }) + runs[0].onEvent({ kind: 'activity', label: '读取 m.py' }) + runs[0].onEvent({ kind: 'text', text: '答' }) + expect(events.updates).toEqual([ + { 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: '答' }]) + 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([]) + }) + + it('叫停:结束 claude,报 stopped,这块可以再问', async () => { + const { runner, runs } = fakeRunner() + const asks = new QaAsks(runner) + const events = listener() + asks.start(start('1'), events) + asks.stop('1') + await events.ended + expect(runs[0].signal.aborted).toBe(true) + expect(events.ends[0][1]).toEqual({ status: 'stopped' }) + expect(asks.start(start('2', '1'), listener())).toBeUndefined() + }) + + it('出错报 failed,带给人看的原因', async () => { + const { runner, runs } = fakeRunner() + const asks = new QaAsks(runner) + const events = listener() + asks.start(start('1'), events) + runs[0].fail(new ClaudeError('Claude 退出码非零 (1): 网络断了', '')) + await events.ended + expect(events.ends[0][1]).toEqual({ status: 'failed', error: 'Claude Code 没答成:Claude 退出码非零 (1): 网络断了' }) + }) + + it('同一块同时只答一个;别的文件里同样记号的块不算同一块', () => { + const { runner } = fakeRunner() + const asks = new QaAsks(runner) + asks.start(start('1', 'function f'), listener()) + expect(asks.start(start('2', 'function f'), listener())).toBe('这块已经在问了,等它答完或者先停下再问。') + expect(asks.start(start('3', 'function f', 'file:///b.py'), listener())).toBeUndefined() + expect(asks.start(start('1', 'function g'), listener())).toBe('这个问题已经在问了。') + }) + + it('不同块可以同时答,最多几个;答完一个又能问', async () => { + const { runner, runs } = fakeRunner() + const asks = new QaAsks(runner, 2) + const first = listener() + asks.start(start('1'), first) + asks.start(start('2'), listener()) + expect(asks.start(start('3'), listener())).toBe('同时最多问 2 个问题,等前面的答完再问。') + runs[0].answer({ text: '答', sessionId: 's' }) + await first.ended + expect(asks.start(start('3'), listener())).toBeUndefined() + }) + + it('全部叫停', async () => { + const { runner, runs } = fakeRunner() + const asks = new QaAsks(runner) + const [a, b] = [listener(), listener()] + asks.start(start('1'), a) + asks.start(start('2'), b) + asks.stopAll() + await Promise.all([a.ended, b.ended]) + expect(runs.every(run => run.signal.aborted)).toBe(true) + }) +}) diff --git a/vscode/src/codeReader/qa/asks.ts b/vscode/src/codeReader/qa/asks.ts new file mode 100644 index 0000000..dc76639 --- /dev/null +++ b/vscode/src/codeReader/qa/asks.ts @@ -0,0 +1,107 @@ +/** + * 正在答的问题:一块同时只答一个,不同块可以同时答(最多 MAX_RUNNING 个),能叫停。 + * 不碰 VS Code:答完怎么存、怎么告诉界面交给 AskListener。 + */ + +import type { Answer, AskRun, AskRunner } from './claudeAsk' +import { ClaudeCancelledError } from '../../cc/spawnClaude' +import { askFailure } from './claudeAsk' + +/** 同时答的问题最多几个:每个都是一个 claude 进程 */ +export const MAX_RUNNING = 3 + +/** 正在答的一个问题,界面照它显示。 */ +export interface RunningAsk { + askId: string + anchor: string + question: string + /** 回答到现在为止的全文(Markdown) */ + text: string + /** 正在做的事:「读取 routes.py」 */ + activity?: string +} + +export type AskEnd + = | { status: 'done', answer: Answer } + | { status: 'failed', error: string } + | { status: 'stopped' } + +export interface AskStart { + /** 哪份文件问的(文档 uri):同一块只看同一份文件里的 */ + owner: string + askId: string + anchor: string + question: string + run: Omit +} + +export interface AskListener { + update: (ask: RunningAsk) => void + end: (ask: RunningAsk, end: AskEnd) => void | Promise +} + +interface Running { + owner: string + ask: RunningAsk + controller: AbortController +} + +export class QaAsks { + private readonly running = new Map() + + constructor(private readonly runner: AskRunner, private readonly limit = MAX_RUNNING) {} + + /** 开始答;这块已经在答、同时答的太多时不开始,返回原因(给人看的话)。 */ + start(start: AskStart, listener: AskListener): string | undefined { + const { owner, askId, anchor, question } = start + if (this.running.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 controller = new AbortController() + const ask: RunningAsk = { askId, anchor, question, text: '' } + this.running.set(askId, { owner, ask, controller }) + void this.runner({ + ...start.run, + signal: controller.signal, + onEvent: (event) => { + if (event.kind === 'text') { + ask.text = event.text + delete ask.activity + } + else if (event.kind === 'activity') { + ask.activity = event.label + } + else { + return + } + listener.update({ ...ask }) + }, + }).then( + (answer): AskEnd => ({ status: 'done', answer }), + (err: unknown): AskEnd => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: askFailure(err) }, + ).then(async (end) => { + this.running.delete(askId) + await listener.end({ ...ask }, end) + }) + return undefined + } + + /** 叫停:结束 claude 进程,这次不存。 */ + stop(askId: string): void { + this.running.get(askId)?.controller.abort() + } + + /** owner 这份文件正在答的问题,按开始的先后。 */ + runningFor(owner: string): RunningAsk[] { + return [...this.running.values()].filter(item => item.owner === owner).map(item => ({ ...item.ask })) + } + + /** 全部叫停(插件停用时)。 */ + stopAll(): void { + for (const item of this.running.values()) + item.controller.abort() + } +} diff --git a/vscode/src/codeReader/qa/claudeAsk.test.ts b/vscode/src/codeReader/qa/claudeAsk.test.ts new file mode 100644 index 0000000..53aecc9 --- /dev/null +++ b/vscode/src/codeReader/qa/claudeAsk.test.ts @@ -0,0 +1,116 @@ +import type { runClaudeLines } from '../../cc/spawnClaude' +import type { AskDeps, AskRun } from './claudeAsk' +import { describe, expect, it } from 'vitest' +import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude' +import { askArgs, askClaude, askFailure } from './claudeAsk' + +type RunLinesOptions = Parameters[0] + +const line = (value: unknown): string => JSON.stringify(value) +const init = (session: string): string => line({ type: 'system', subtype: 'init', session_id: session }) +const delta = (text: string): string => line({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text } }, parent_tool_use_id: null }) +const result = (text: string, session: string): string => line({ type: 'result', subtype: 'success', is_error: false, result: text, session_id: session }) + +/** 假的 claude:记下每次的参数,按次序吐出事先给的行;给了 error 就在吐完后抛它 */ +function fakeClaude(runs: { lines: string[], error?: Error }[]): { deps: AskDeps, calls: RunLinesOptions[] } { + const calls: RunLinesOptions[] = [] + return { + calls, + deps: { + profilePath: () => '/profiles/offical.json', + runLines: async (options) => { + const run = runs[calls.length] + calls.push(options) + run.lines.forEach(options.onLine) + if (run.error) + throw run.error + }, + }, + } +} + +function request(overrides: Partial = {}): AskRun & { events: unknown[] } { + const events: unknown[] = [] + return { prompt: '完整的问题', cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides } +} + +describe('命令行参数', () => { + it('只读:只给读、搜的工具,别的一律拒、不等人批,不加载 MCP;不带跳过权限;输出逐字的 stream-json', () => { + const args = askArgs('问题', { profilePath: '/p/offical.json' }) + expect(args).toEqual([ + '--settings', + '/p/offical.json', + '--tools', + 'Read,Grep,Glob', + '--allowedTools', + 'Read,Grep,Glob', + '--permission-mode', + 'dontAsk', + '--strict-mcp-config', + '-p', + '问题', + '--output-format', + 'stream-json', + '--verbose', + '--include-partial-messages', + ]) + expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|Edit|Write|Bash/) + }) + + it('追问带 --resume,放在问题前面;没有 profile 就不带 --settings', () => { + const args = askArgs('追问', { resume: 'session-1' }) + expect(args).not.toContain('--settings') + expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 4)).toEqual(['--resume', 'session-1', '-p', '追问']) + }) +}) + +describe('问一次', () => { + it('在工作区文件夹里跑、带超时和叫停;过程交给 onEvent,答完返回最终回答和会话 id', async () => { + const { deps, calls } = fakeClaude([{ lines: [init('s1'), delta('答'), delta('案'), result('答案', 's1')] }]) + const run = request() + expect(await askClaude(run, deps)).toEqual({ text: '答案', sessionId: 's1' }) + expect(run.events).toEqual([{ kind: 'session', sessionId: 's1' }, { kind: 'text', text: '答' }, { kind: 'text', text: '答案' }]) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ cwd: '/work', signal: run.signal, timeoutMs: 300_000 }) + expect(calls[0].args).toContain('/profiles/offical.json') + expect(calls[0].args[calls[0].args.indexOf('-p') + 1]).toBe('完整的问题') + }) + + it('追问接着上次的会话问短问题', async () => { + const { deps, calls } = fakeClaude([{ lines: [result('接着答', 's1')] }]) + expect(await askClaude(request({ followUp: { sessionId: 's1', prompt: '短问题' } }), deps)).toEqual({ text: '接着答', sessionId: 's1' }) + expect(calls[0].args.slice(calls[0].args.indexOf('--resume'), calls[0].args.indexOf('--resume') + 4)).toEqual(['--resume', 's1', '-p', '短问题']) + }) + + it('上次的会话找不到了:按完整问题重新开一次', async () => { + const { deps, calls } = fakeClaude([ + { lines: [], error: new ClaudeError('Claude 退出码非零 (1): No conversation found with session ID: s0', 'No conversation found with session ID: s0') }, + { lines: [init('s2'), result('重新答', 's2')] }, + ]) + expect(await askClaude(request({ followUp: { sessionId: 's0', prompt: '短问题' } }), deps)).toEqual({ text: '重新答', sessionId: 's2' }) + expect(calls).toHaveLength(2) + expect(calls[1].args).not.toContain('--resume') + expect(calls[1].args[calls[1].args.indexOf('-p') + 1]).toBe('完整的问题') + }) + + it('别的失败不重试,照样抛出来;Claude 报的失败、没给回答也算失败', async () => { + const timeout = new ClaudeTimeoutError('超时', '') + const failing = fakeClaude([{ lines: [], error: timeout }, { lines: [result('不该到这', 's')] }]) + await expect(askClaude(request({ followUp: { sessionId: 's0', prompt: '短问题' } }), failing.deps)).rejects.toBe(timeout) + expect(failing.calls).toHaveLength(1) + await expect(askClaude(request(), fakeClaude([{ lines: [line({ type: 'result', is_error: true, result: 'Invalid API key · Please run /login' })] }]).deps)) + .rejects + .toThrow('Invalid API key') + await expect(askClaude(request(), fakeClaude([{ lines: [init('s')] }]).deps)).rejects.toThrow('没给出回答') + }) +}) + +describe('失败了给人看的话', () => { + it('超时、没装、没登录、别的', () => { + expect(askFailure(new ClaudeTimeoutError('超时', ''))).toBe('等了 5 分钟还没答完,已经停掉。可以把问题问得具体一点再试。') + expect(askFailure(new ClaudeError(CLAUDE_MISSING_MSG, ''))).toContain('没找到 Claude Code') + expect(askFailure(new ClaudeError('Claude 退出码非零 (1): Invalid API key · Please run /login', ''))).toMatch(/^Claude Code 没登录或登录过期了:在终端里运行 claude 登录一下再问。/) + expect(askFailure(new ClaudeError('Claude 退出码非零 (1): 网络断了', ''))).toBe('Claude Code 没答成:Claude 退出码非零 (1): 网络断了') + expect(askFailure(new ClaudeCancelledError(''))).toBe('Claude Code 没答成:Claude 调用已停止') + }) +}) diff --git a/vscode/src/codeReader/qa/claudeAsk.ts b/vscode/src/codeReader/qa/claudeAsk.ts new file mode 100644 index 0000000..eebeda6 --- /dev/null +++ b/vscode/src/codeReader/qa/claudeAsk.ts @@ -0,0 +1,136 @@ +/** + * 用 Claude Code(`claude -p`,只读)回答代码问答:和插件别处的 `claude -p` 一样找 claude、清环境变量、 + * 用默认 profile(见 cc/profiles);回答按 stream-json 逐字交出来,可以叫停。 + */ + +import type { AnswerEvent } from './answerStream' +import { accessSync, constants } from 'node:fs' +import { getDefaultProfilePath } from '../../cc/profiles' +import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude' +import { createAnswerParser } from './answerStream' + +/** 只给读、搜文件的工具。 */ +const READ_ONLY_TOOLS = 'Read,Grep,Glob' + +export const ASK_TIMEOUT_MS = 5 * 60_000 + +/** + * 一次提问的命令行参数。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`), + * 别的一律拒绝、不等人批(`--permission-mode dontAsk`,盖过 profile 里的 bypassPermissions); + * 不加载任何 MCP 服务(`--strict-mcp-config` 又不给 `--mcp-config`),免得 MCP 工具能改东西。 + */ +export function askArgs(prompt: string, options: { profilePath?: string, resume?: string } = {}): string[] { + return [ + ...(options.profilePath ? ['--settings', options.profilePath] : []), + '--tools', + READ_ONLY_TOOLS, + '--allowedTools', + READ_ONLY_TOOLS, + '--permission-mode', + 'dontAsk', + '--strict-mcp-config', + ...(options.resume ? ['--resume', options.resume] : []), + '-p', + prompt, + '--output-format', + 'stream-json', + '--verbose', + '--include-partial-messages', + ] +} + +/** 默认 profile 读得到就用它,读不到就不带 `--settings`(claude 回落到 ~/.claude 的登录)。 */ +function defaultProfile(): string | undefined { + const profile = getDefaultProfilePath() + try { + accessSync(profile, constants.R_OK) + return profile + } + catch { + return undefined + } +} + +export interface AskRun { + /** 带着代码上下文的完整问题 */ + prompt: string + /** 追问:接着 sessionId 那次会话问 prompt;会话找不到了(过期、被清理)就按完整问题重新开一次 */ + followUp?: { sessionId: string, prompt: string } + cwd: string + signal: AbortSignal + /** 答的过程中:会话 id、回答长出来的字、正在读什么 */ + onEvent: (event: Exclude) => void +} + +export interface Answer { + text: string + sessionId: string +} + +export type AskRunner = (run: AskRun) => Promise + +export interface AskDeps { + runLines: typeof runClaudeLines + profilePath: () => string | undefined +} + +const DEFAULT_DEPS: AskDeps = { runLines: runClaudeLines, profilePath: defaultProfile } + +function missingSession(err: unknown): boolean { + return err instanceof ClaudeError && !(err instanceof ClaudeCancelledError) && /no conversation found/i.test(`${err.message}\n${err.stderr}`) +} + +/** 问一次,答完返回最终回答;失败、超时、被叫停时抛 ClaudeError(及其子类),用 askFailure 换成给人看的话。 */ +export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Promise { + const profilePath = deps.profilePath() + const once = async (prompt: string, resume?: string): Promise => { + const parse = createAnswerParser() + let answer: Answer | undefined + let failure: string | undefined + await deps.runLines({ + args: askArgs(prompt, { profilePath, resume }), + cwd: run.cwd, + signal: run.signal, + timeoutMs: ASK_TIMEOUT_MS, + onLine: (line) => { + for (const event of parse(line)) { + if (event.kind === 'result') + answer = { text: event.text, sessionId: event.sessionId } + else if (event.kind === 'error') + failure = event.message + else + run.onEvent(event) + } + }, + }) + if (failure !== undefined) + throw new ClaudeError(failure, '') + if (!answer?.text) + throw new ClaudeError('Claude Code 结束了,但没给出回答', '') + return answer + } + if (run.followUp) { + try { + return await once(run.followUp.prompt, run.followUp.sessionId) + } + catch (err) { + if (!missingSession(err)) + throw err + } + } + return once(run.prompt) +} + +const AUTH_HINT = /\/login|log ?in|auth|401|403|api key|unauthori[sz]ed|credential|oauth/i + +/** 问失败了,给人看的一句话。 */ +export function askFailure(err: unknown): string { + if (err instanceof ClaudeTimeoutError) + return `等了 ${Math.round(ASK_TIMEOUT_MS / 60_000)} 分钟还没答完,已经停掉。可以把问题问得具体一点再试。` + if (err instanceof ClaudeError && err.message === CLAUDE_MISSING_MSG) + return '没找到 Claude Code(claude 命令)。装好、能在终端里运行 claude 之后再问。' + const message = err instanceof Error ? err.message : String(err) + if (err instanceof ClaudeError && AUTH_HINT.test(`${message}\n${err.stderr}`)) + return `Claude Code 没登录或登录过期了:在终端里运行 claude 登录一下再问。(原话:${message})` + return `Claude Code 没答成:${message}` +} diff --git a/vscode/src/codeReader/qa/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts new file mode 100644 index 0000000..1183130 --- /dev/null +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -0,0 +1,102 @@ +import type { Disposable, Uri } from 'vscode' +import type { QaMessage } from '../editor/messages' +import type { AskEnd, RunningAsk } from './asks' +import type { AskRunner } from './claudeAsk' +import type { QaEntry } from './qaMap' +import { randomUUID } from 'node:crypto' +import { EventEmitter } from 'vscode' +import { FolderStore, locate } from '../folderStore' +import { anchorSteps } from './anchor' +import { QaAsks } from './asks' +import { askClaude } from './claudeAsk' +import { QA_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 } + +export interface AskRequest { + askId: string + anchor: string + question: string + prompt: string + followUp?: { sessionId: string, prompt: string } +} + +/** + * 代码问答:问 Claude Code、答完存进各工作区文件夹的 `.spx/code-qa.json`(改名、删文件、外部改动的跟随同文件注释,见 FolderStore)。 + * 问答挂在块的位置记号上(见 BlockAnchors),按文件取给预览。 + */ +export class CodeQa implements Disposable { + private readonly store = new FolderStore(QA_FILE) + private readonly asks: QaAsks + private readonly answered = new EventEmitter() + /** 问答变了的那些文件 */ + readonly onDidChange = this.store.onDidChange + readonly onDidAnswer = this.answered.event + + constructor(runner: AskRunner = askClaude) { + this.asks = new QaAsks(runner) + } + + /** 发给预览的这份文件的问答;文件不在工作区文件夹里时没地方存,问 AI 不可用。 */ + view(uri: Uri): QaMessage { + const at = locate(uri) + const threads = at ? this.store.get(at.folder, at.key) ?? [] : [] + return { + type: 'qa', + available: at !== undefined, + threads: threads.map(({ anchor, entries }) => ({ + anchor, + where: anchorSteps(anchor), + entries: entries.map(({ id, question, answer, createdAt }) => ({ id, question, answer, createdAt })), + })), + running: this.asks.runningFor(uri.toString()), + } + } + + /** 开始问;问不了时返回原因(给人看的话)。在工作区文件夹里跑 claude,它读得到整个项目。 */ + ask(uri: Uri, request: AskRequest): string | undefined { + const at = locate(uri) + if (!at) + return '这个文件不在工作区文件夹里,问答没地方存。' + const owner = uri.toString() + const { askId, anchor, question, prompt, followUp } = request + return this.asks.start({ owner, askId, anchor, question, run: { prompt, followUp, cwd: at.folder.uri.fsPath } }, { + update: ask => this.answered.fire({ owner, kind: 'update', ask }), + end: async (ask, end) => { + if (end.status !== 'done') { + this.answered.fire({ owner, kind: 'end', ask, end }) + return + } + const entry: QaEntry = { + id: randomUUID(), + question, + answer: end.answer.text, + createdAt: new Date().toISOString(), + sessionId: end.answer.sessionId, + } + await this.store.update(at.folder, map => withEntry(map, at.key, anchor, entry)) + this.answered.fire({ owner, kind: 'end', ask, end, entryId: entry.id }) + }, + }) + } + + stop(askId: string): void { + this.asks.stop(askId) + } + + /** 删掉一次问答,不用确认;块下面删光了这块就不挂了。 */ + async delete(uri: Uri, anchor: string, entryId: string): Promise { + const at = locate(uri) + if (at) + await this.store.update(at.folder, map => withoutEntry(map, at.key, anchor, entryId)) + } + + dispose(): void { + this.asks.stopAll() + this.answered.dispose() + this.store.dispose() + } +} diff --git a/vscode/src/codeReader/qa/prompt.test.ts b/vscode/src/codeReader/qa/prompt.test.ts new file mode 100644 index 0000000..4032072 --- /dev/null +++ b/vscode/src/codeReader/qa/prompt.test.ts @@ -0,0 +1,74 @@ +import type { BlockView } from '../blockView' +import type { AskSubject } from './prompt' +import { describe, expect, it } from 'vitest' +import { toBlockViews } from '../blockView' +import { createPythonAdapter } from '../languages/python' +import { WASM_DIR, withBlocks } from '../testSupport' +import { askPrompt, findBlock, followUpPrompt } from './prompt' + +const py = createPythonAdapter(WASM_DIR) + +const SOURCE = `import os + + +class ReferralRelation(Base): + """推荐关系。""" + + def display(self): + if self.active: + return self.name + return "none" +` + +async function subject(source: string, pick: (views: BlockView[]) => BlockView): Promise { + const blocks = await withBlocks(py, 'python', source, list => toBlockViews(list, source, py, undefined)) + return { path: 'app/models.py', languageId: 'python', source, blocks, anchor: pick(blocks).anchor } +} + +describe('问 AI 的话', () => { + it('第一次问:要求中文大白话、只读、写相对路径;带文件、语言、外层块的开头和这块代码(带行号)、问题', async () => { + const asked = await subject(SOURCE, blocks => blocks[1].children[0].children[0]) + expect(asked.anchor).toMatch(/^class ReferralRelation > function display > if @/) + expect(askPrompt(asked, '为什么要判断 active?')).toBe([ + '我在读代码,想问一块代码。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的其他文件,但只读,不要改任何东西。提到文件时写相对项目根目录的路径,最好带行号。', + '文件:app/models.py(python)', + '这块代码外面套着(各层外层块的开头):\n4 | class ReferralRelation(Base):\n5 | """推荐关系。"""\n7 | def display(self):', + '我问的是这一块(第 8–9 行):\n8 | if self.active:\n9 | return self.name', + '问题:为什么要判断 active?', + ].join('\n\n')) + }) + + it('顶层的块没有外层;问函数时连主体一起贴', async () => { + const top = await subject(SOURCE, blocks => blocks[0]) + const prompt = askPrompt(top, '导入了什么?')! + expect(prompt).not.toContain('外面套着') + expect(prompt).toContain('我问的是这一块(第 1–1 行):\n1 | import os') + const fn = askPrompt(await subject(SOURCE, blocks => blocks[1].children[0]), '做什么?')! + expect(fn).toContain('我问的是这一块(第 7–10 行):\n 7 | def display(self):\n 8 | if self.active:\n 9 | return self.name\n10 | return "none"') + }) + + it('很长的块只贴开头,告诉它剩下的在哪几行;外层块的开头每层最多几行', async () => { + const body = Array.from({ length: 150 }, (_, i) => ` x${i} = ${i}`).join('\n') + const doc = Array.from({ length: 10 }, (_, i) => ` 第 ${i} 行说明`).join('\n') + const source = `def big():\n """\n${doc}\n """\n${body}\n` + const asked = await subject(source, blocks => blocks[0]) + const prompt = askPrompt(asked, '?')! + expect(prompt).toContain('(这块一共 163 行,上面只贴了开头 120 行,剩下的第 121–163 行需要时自己读文件)') + expect(prompt).toContain('120 | x106 = 106') + expect(prompt).not.toContain('x107 = 107') + const inner = askPrompt(await subject(source, blocks => blocks[0].children[3]), '?')! + expect(inner).toContain(' 6 | 第 3 行说明\n | …\n') + }) + + it('找不到这块(代码刚改过)时没有', async () => { + const asked = await subject(SOURCE, blocks => blocks[0]) + expect(askPrompt({ ...asked, anchor: 'function gone' }, '?')).toBeUndefined() + expect(followUpPrompt({ ...asked, anchor: 'function gone' }, '?')).toBeUndefined() + expect(findBlock(asked.blocks, 'function gone')).toBeUndefined() + }) + + it('追问只提醒是哪块', async () => { + const asked = await subject(SOURCE, blocks => blocks[1].children[0]) + expect(followUpPrompt(asked, '那 name 从哪来?')).toBe('接着问(还是 app/models.py 第 7–10 行那块代码,代码可能刚改过,需要时重新读文件):那 name 从哪来?') + }) +}) diff --git a/vscode/src/codeReader/qa/prompt.ts b/vscode/src/codeReader/qa/prompt.ts new file mode 100644 index 0000000..db044bf --- /dev/null +++ b/vscode/src/codeReader/qa/prompt.ts @@ -0,0 +1,86 @@ +/** + * 问 AI 时发给 Claude Code 的话:文件、语言、这块代码(带行号)、外面套着的各层块的开头、问题。 + * 只用和语言无关的块数据(BlockView 的行号、记号、树形),不认识任何语言。 + */ + +import type { BlockView } from '../blockView' +import { splitLines } from '../editText' + +/** 这块代码最多贴多少行,再长的只贴开头、告诉它剩下的在哪几行 */ +const BLOCK_LINES = 120 +/** 外层块的开头每层最多贴几行(函数的说明可能很长) */ +const OUTER_LINES = 6 + +export interface AskSubject { + /** 相对工作区文件夹的路径,`/` 分隔 */ + path: string + languageId: string + source: string + blocks: BlockView[] + anchor: string +} + +/** anchor 那块,和从最外层到它的各层外层块;树里没有这块时没有。 */ +export function findBlock(blocks: BlockView[], anchor: string): { block: BlockView, outer: BlockView[] } | undefined { + for (const block of blocks) { + if (block.anchor === anchor) + return { block, outer: [] } + const inner = findBlock(block.children, anchor) + if (inner) + return { block: inner.block, outer: [block, ...inner.outer] } + } + return undefined +} + +/** 块连同子孙管到的最后一行(0 起):复合语句的头部之外还有主体。 */ +function lastLine(block: BlockView): number { + return Math.max(block.endLine, ...block.children.map(lastLine)) +} + +/** 这块代码占的行(1 起,给人看的写法)。 */ +export function blockLines(block: BlockView): { from: number, to: number } { + return { from: block.startLine + 1, to: lastLine(block) + 1 } +} + +function numbered(lines: string[], from: number, to: number, width: number): string[] { + return lines.slice(from, to + 1).map((text, i) => `${String(from + i + 1).padStart(width)} | ${text}`) +} + +/** 第一次问这块:带上代码上下文的完整问题;树里找不到这块(刚改过代码)时没有。 */ +export function askPrompt(subject: AskSubject, question: string): string | undefined { + const found = findBlock(subject.blocks, subject.anchor) + if (!found) + return undefined + const { block, outer } = found + const lines = splitLines(subject.source) + const last = Math.min(lastLine(block), lines.length - 1) + const width = String(last + 1).length + const shownLast = Math.min(last, block.startLine + BLOCK_LINES - 1) + const { from, to } = blockLines(block) + const parts = [ + '我在读代码,想问一块代码。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的其他文件,但只读,不要改任何东西。' + + '提到文件时写相对项目根目录的路径,最好带行号。', + `文件:${subject.path}(${subject.languageId})`, + ] + if (outer.length > 0) { + const heads = outer.flatMap((item) => { + const end = Math.min(item.endLine, item.startLine + OUTER_LINES - 1) + return [...numbered(lines, item.startLine, end, width), ...(end < item.endLine ? [`${' '.repeat(width)} | …`] : [])] + }) + parts.push(`这块代码外面套着(各层外层块的开头):\n${heads.join('\n')}`) + } + const body = numbered(lines, block.startLine, shownLast, width) + const rest = shownLast < last ? `\n(这块一共 ${last - block.startLine + 1} 行,上面只贴了开头 ${BLOCK_LINES} 行,剩下的第 ${shownLast + 2}–${to} 行需要时自己读文件)` : '' + parts.push(`我问的是这一块(第 ${from}–${to} 行):\n${body.join('\n')}${rest}`) + parts.push(`问题:${question}`) + return parts.join('\n\n') +} + +/** 接着上一次的会话追问:代码上下文会话里已经有了,只提醒是哪块。 */ +export function followUpPrompt(subject: AskSubject, question: string): string | undefined { + const found = findBlock(subject.blocks, subject.anchor) + if (!found) + return undefined + const { from, to } = blockLines(found.block) + return `接着问(还是 ${subject.path} 第 ${from}–${to} 行那块代码,代码可能刚改过,需要时重新读文件):${question}` +} diff --git a/vscode/src/codeReader/qa/qaMap.test.ts b/vscode/src/codeReader/qa/qaMap.test.ts new file mode 100644 index 0000000..9a0c208 --- /dev/null +++ b/vscode/src/codeReader/qa/qaMap.test.ts @@ -0,0 +1,117 @@ +import type { QaEntry, QaMap } from './qaMap' +import { promises as fsp } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { dropEntries, moveEntries, readPathMap, writePathMap } from '../pathMap' +import { QA_FILE, withEntry, withoutEntry } from './qaMap' + +function entry(id: string, createdAt: string, question = `问 ${id}`): QaEntry { + return { id, question, answer: `答 ${id}`, createdAt, sessionId: `s-${id}` } +} + +const DISPLAY = 'class ReferralRelation > function display' +const IF = `${DISPLAY} > if @3fa1c2d0` + +let root: string +const qaFile = (): string => path.join(root, '.spx', 'code-qa.json') + +beforeEach(async () => { + root = await fsp.mkdtemp(path.join(os.tmpdir(), 'code-qa-')) +}) + +afterEach(async () => { + await fsp.rm(root, { recursive: true, force: true }) +}) + +async function writeRaw(content: unknown): Promise { + await fsp.mkdir(path.dirname(qaFile()), { recursive: true }) + await fsp.writeFile(qaFile(), typeof content === 'string' ? content : JSON.stringify(content), 'utf8') +} + +describe('记一次、删一次', () => { + it('记在文件的那块下面:块按记号排,问答按时间排', () => { + let map: QaMap = {} + map = withEntry(map, 'app/models.py', IF, entry('b', '2026-09-26T10:00:00.000Z')) + map = withEntry(map, 'app/models.py', DISPLAY, entry('c', '2026-09-26T11:00:00.000Z')) + map = withEntry(map, 'app/models.py', IF, entry('a', '2026-09-25T10:00:00.000Z')) + expect(map).toEqual({ + 'app/models.py': [ + { anchor: DISPLAY, entries: [entry('c', '2026-09-26T11:00:00.000Z')] }, + { anchor: IF, entries: [entry('a', '2026-09-25T10:00:00.000Z'), entry('b', '2026-09-26T10:00:00.000Z')] }, + ], + }) + }) + + it('删一次只删它;块下面删光了这块不挂了,文件里删光了这份文件不记了;不改传进来的那份', () => { + const map = withEntry(withEntry(withEntry({}, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, entry('b', '2026-09-26T10:00:00.000Z')), 'a.py', DISPLAY, entry('c', '2026-09-26T11:00:00.000Z')) + const once = withoutEntry(map, 'a.py', IF, 'a') + expect(once['a.py'].map(thread => [thread.anchor, thread.entries.map(item => item.id)])).toEqual([[DISPLAY, ['c']], [IF, ['b']]]) + const twice = withoutEntry(once, 'a.py', IF, 'b') + expect(twice['a.py'].map(thread => thread.anchor)).toEqual([DISPLAY]) + expect(withoutEntry(twice, 'a.py', DISPLAY, 'c')).toEqual({}) + expect(map['a.py'].flatMap(thread => thread.entries)).toHaveLength(3) + }) + + it('删不存在的那条不动', () => { + const map = withEntry({}, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z')) + expect(withoutEntry(map, 'a.py', IF, 'nope')).toEqual(map) + expect(withoutEntry(map, 'b.py', IF, 'a')).toEqual(map) + }) + + it('文件改名、挪目录、删除时问答跟着走(同文件注释的规矩)', () => { + const map = withEntry(withEntry({}, 'app/models.py', IF, entry('a', '2026-09-25T10:00:00.000Z')), 'app/views.py', DISPLAY, entry('b', '2026-09-25T10:00:00.000Z')) + expect(Object.keys(moveEntries(map, 'app/models.py', 'app/orm.py'))).toEqual(['app/views.py', 'app/orm.py']) + expect(Object.keys(moveEntries(map, 'app', 'backend/app')).sort()).toEqual(['backend/app/models.py', 'backend/app/views.py']) + expect(Object.keys(dropEntries(map, 'app/models.py'))).toEqual(['app/views.py']) + }) +}) + +describe('读写 .spx/code-qa.json', () => { + it('文件不存在、解析失败、不是对象都当没有', async () => { + expect(await readPathMap(root, QA_FILE)).toEqual({}) + await writeRaw('{ "a.py": [') + expect(await readPathMap(root, QA_FILE)).toEqual({}) + await writeRaw('[]') + expect(await readPathMap(root, QA_FILE)).toEqual({}) + }) + + it('跳过认不出的块和问答:没有问题 / 回答、时间不对、没有记号、没有问答的块、不是数组的文件', async () => { + await writeRaw({ + 'a.py': [ + { anchor: IF, entries: [entry('ok', '2026-09-25T10:00:00.000Z'), { id: 'x', question: '', answer: 'A', createdAt: '2026-09-25T10:00:00.000Z' }, { ...entry('bad', 'not a date') }] }, + { anchor: '', entries: [entry('no-anchor', '2026-09-25T10:00:00.000Z')] }, + { anchor: DISPLAY, entries: [] }, + 'junk', + ], + 'b.py': { anchor: IF }, + 'c.py': [{ anchor: IF, entries: [{ id: 'y', question: 'Q', answer: 'A', createdAt: '2026-09-25T10:00:00.000Z' }] }], + '../outside.py': [{ anchor: IF, entries: [entry('z', '2026-09-25T10:00:00.000Z')] }], + }) + expect(await readPathMap(root, QA_FILE)).toEqual({ + 'a.py': [{ anchor: IF, entries: [entry('ok', '2026-09-25T10:00:00.000Z')] }], + // 没有会话 id 的照样认,追问时重新开会话 + 'c.py': [{ anchor: IF, entries: [{ id: 'y', question: 'Q', answer: 'A', createdAt: '2026-09-25T10:00:00.000Z', sessionId: '' }] }], + }) + }) + + it('手改过的顺序、重复的块读进来整理好', async () => { + await writeRaw({ 'a.py': [ + { anchor: IF, entries: [entry('b', '2026-09-26T10:00:00.000Z')] }, + { anchor: DISPLAY, entries: [entry('c', '2026-09-26T11:00:00.000Z')] }, + { anchor: IF, entries: [entry('a', '2026-09-25T10:00:00.000Z')] }, + ] }) + expect(await readPathMap(root, QA_FILE)).toEqual(withEntry(withEntry(withEntry({}, 'a.py', IF, entry('b', '2026-09-26T10:00:00.000Z')), 'a.py', DISPLAY, entry('c', '2026-09-26T11:00:00.000Z')), 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z'))) + }) + + it('写:建 .spx、文件按路径排、两格缩进、末尾换行,读得回来;删光留 {}', async () => { + const map = withEntry(withEntry({}, 'z.py', IF, entry('a', '2026-09-25T10:00:00.000Z', '含 "引号" 和 `代码`')), 'a.py', DISPLAY, entry('b', '2026-09-25T10:00:00.000Z')) + await writePathMap(root, QA_FILE, map) + const raw = await fsp.readFile(qaFile(), 'utf8') + expect(raw).toBe(`${JSON.stringify({ 'a.py': map['a.py'], 'z.py': map['z.py'] }, null, 2)}\n`) + expect(raw.indexOf('"id"')).toBeLessThan(raw.indexOf('"question"')) + expect(await readPathMap(root, QA_FILE)).toEqual(map) + await writePathMap(root, QA_FILE, {}) + expect(await fsp.readFile(qaFile(), 'utf8')).toBe('{}\n') + }) +}) diff --git a/vscode/src/codeReader/qa/qaMap.ts b/vscode/src/codeReader/qa/qaMap.ts new file mode 100644 index 0000000..db36460 --- /dev/null +++ b/vscode/src/codeReader/qa/qaMap.ts @@ -0,0 +1,90 @@ +/** + * 代码问答的存法:每个工作区文件夹一份 `/.spx/code-qa.json`,读写的规矩同文件注释(见 pathMap)。 + * 形状是 `{ "相对路径": [{ anchor, entries: [{ id, question, answer, createdAt, sessionId }] }] }`: + * 一份文件里挂着问答的每一块一项(anchor 见 BlockAnchors),块下面问过的按时间排。 + */ + +import type { PathMap, PathMapFile } from '../pathMap' + +/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、Claude Code 的会话 id。 */ +export interface QaEntry { + id: string + question: string + answer: string + createdAt: string + sessionId: string +} + +/** 挂在一块上的问答。 */ +export interface QaThread { + anchor: string + entries: QaEntry[] +} + +export type QaMap = PathMap + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +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] = [text(value.id), text(value.question), text(value.answer), text(value.createdAt)] + if (!id || !question || !answer || !createdAt || Number.isNaN(Date.parse(createdAt))) + return undefined + return { id, question, answer, createdAt, sessionId: typeof value.sessionId === 'string' ? value.sessionId : '' } +} + +/** 同一块的并成一项;块按记号排,问答按时间排:写出来的顺序稳定,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] + .filter(([, entries]) => entries.length > 0) + .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) + .map(([anchor, entries]) => ({ + anchor, + entries: [...entries].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)), + })) +} + +/** 一份文件的问答:不是数组、一项都认不出的当没有;认不出的块、问答跳过。 */ +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 +} + +export const QA_FILE: PathMapFile = { + path: '.spx/code-qa.json', + label: '代码问答', + parse: parseThreads, +} + +/** 在 key 这份文件的 anchor 那块下面记一次问答。 */ +export function withEntry(map: QaMap, key: string, anchor: string, entry: QaEntry): QaMap { + return { ...map, [key]: tidy([...map[key] ?? [], { anchor, entries: [entry] }]) } +} + +/** 删掉一次问答;块下面删光了这块就不挂了,文件里删光了这份文件就不记了。 */ +export function withoutEntry(map: QaMap, key: string, anchor: string, entryId: string): QaMap { + const threads = tidy((map[key] ?? []).map(thread => thread.anchor === anchor + ? { anchor, entries: thread.entries.filter(entry => entry.id !== entryId) } + : thread)) + const next = { ...map } + delete next[key] + return threads.length > 0 ? { ...next, [key]: threads } : next +} diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 2ff7401..b8c58a5 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -4,12 +4,15 @@ import type { DocumentMessage, Excluded, Reveal } from './types' import type { ViewState } from './viewState' import { TriangleAlert } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { OrphanThreads } from './AskThread' import { BlockLevel } from './BlockTree' import { coveredByEditing, startEditing } from './editing' import { applyFilter, keptWhileEditing } from './filter' import { FilterBar } from './FilterBar' +import { orphanThreads } from './qa' import { useDefinitionLinks } from './useDefinitionLinks' import { HighlightContext, useOccurrences } from './useOccurrences' +import { useQa } from './useQa' import { useReveal } from './useReveal' import { useScrollPosition } from './useScrollPosition' import { collapsedIds } from './viewState' @@ -25,6 +28,8 @@ function savedBlockModes(): BlockModes { } export function App(): ReactElement | null { + // 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答 + const qa = useQa() const [doc, setDoc] = useState(null) const [view, setView] = useState(savedBlockModes) const [focusId, setFocusId] = useState(null) @@ -80,6 +85,9 @@ export function App(): ReactElement | null { const onFocused = useCallback(() => setFocusId(null), []) const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc]) + const kindName = useCallback((kind: string) => kindNames.get(kind) ?? kind, [kindNames]) + /** 找不到原来位置的问答;解析失败时一块都没有,不算 */ + const orphans = useMemo(() => doc && doc.error === undefined && qa.state.available ? orphanThreads(qa.state.threads, doc.blocks) : [], [doc, qa.state]) const tree = useMemo((): BlockTreeState | null => doc && { version: doc.version, editing: new Set(view.editing), @@ -87,7 +95,7 @@ export function App(): ReactElement | null { hidden: filter.hidden, temporary: filter.temporary, covered, - kindName: kind => kindNames.get(kind) ?? kind, + kindName, focusId, onFocused, onToggleEdit: (id) => { @@ -98,7 +106,8 @@ export function App(): ReactElement | null { })) }, onToggleFold: id => setFolded([id], !collapsed.has(id)), - }, [doc, view, collapsed, filter, covered, kindNames, focusId, onFocused, setFolded]) + ...(qa.state.available && { qa }), + }, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, qa]) if (!doc || !tree) return null @@ -128,6 +137,7 @@ export function App(): ReactElement | null { )} + {orphans.length > 0 && } {doc.blocks.length === 0 ? doc.error === undefined &&
这个文件里没有代码。
: doc.blocks.some(block => !filter.hidden.has(block.id)) diff --git a/vscode/webview-ui/src/codeReader/AskThread.test.tsx b/vscode/webview-ui/src/codeReader/AskThread.test.tsx new file mode 100644 index 0000000..93bc83e --- /dev/null +++ b/vscode/webview-ui/src/codeReader/AskThread.test.tsx @@ -0,0 +1,86 @@ +import type { AskThreadProps } from './AskThread' +import type { Ask } from './qa' +import type { QaEntryView } from './types' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { AskThread, OrphanThreads } from './AskThread' + +const NOW = new Date(2026, 8, 26, 15, 0).getTime() + +const HISTORY: QaEntryView[] = [ + { id: 'e1', question: '为什么普通用户不能看别人的资料?', answer: '这是**权限控制**:不是管理员、又不是查自己时抛 `Forbidden`。', createdAt: new Date(2026, 8, 23, 10, 0).toISOString() }, + { id: 'e2', question: 'UserRole 还有哪些角色?', answer: '只有 ADMIN 和 USER。', createdAt: new Date(2026, 8, 25, 10, 0).toISOString() }, +] + +function render(props: Partial): string { + return renderToStaticMarkup( {}} onStop={() => {}} onDelete={() => {}} onClose={() => {}} {...props} />) +} + +const text = (html: string): string => html.replace(/<[^>]*>/g, '') + +function ask(overrides: Partial): Ask { + return { askId: 'a1', anchor: 'x', question: 'Forbidden 抛出去以后前端收到什么?', text: '', status: 'running', ...overrides } +} + +describe('块下面的问答区', () => { + it('没问过、没展开:什么都不画', () => { + expect(render({})).toBe('') + }) + + it('问过、没展开:只露一行「问过 N 次」,点开才看历史', () => { + const html = render({ history: HISTORY }) + expect(html).toContain('data-collapsed') + expect(text(html)).toBe('问过 2 次') + expect(html).toContain('aria-expanded="false"') + expect(html).not.toContain(' { + const html = render({ open: true, history: HISTORY, asks: [ask({ text: '`Forbidden` 定义在 **core**', activity: '读取 exceptions.py' })] }) + expect(html.match(/data-history=""/g)).toHaveLength(2) + expect(text(html)).toContain('为什么普通用户不能看别人的资料?3 天前') + expect(text(html)).toContain('UserRole 还有哪些角色?昨天') + expect(html.match(/title="已理解,删除" aria-label="已理解,删除"/g)).toHaveLength(2) + expect(text(html)).toContain('这是权限控制:不是管理员、又不是查自己时抛 Forbidden。') + expect(html).toContain('
') + expect(html).toContain('data-current=""') + expect(html).toContain('data-streaming="">

Forbidden 定义在 core

') + expect(text(html)).toContain('读取 exceptions.py') + expect(html).toContain('placeholder="继续问… Enter 发送,Esc 收起"') + expect(html).toContain('title="停止" aria-label="停止"') + expect(html).not.toContain('aria-label="发送"') + }) + + it('还没问过:输入框提示问这块代码,发送按钮在没字时不能点;答完的回答按 Markdown 排、不再闪光标', () => { + expect(render({ open: true })).toContain('placeholder="问这块代码… Enter 发送,Esc 收起"') + expect(render({ open: true })).toMatch(/aria-label="发送" disabled=""/) + const done = render({ open: true, asks: [ask({ status: 'done', text: '答完了' })] }) + expect(done).toContain('

答完了

') + expect(done).not.toContain('data-streaming') + expect(done).not.toContain('role="status"') + }) + + it('出错、停止写在这一问下面', () => { + const failed = render({ open: true, asks: [ask({ status: 'failed', error: 'Claude Code 没登录或登录过期了' })] }) + expect(failed).toContain('role="alert"') + expect(text(failed)).toContain('Claude Code 没登录或登录过期了') + expect(text(render({ open: true, asks: [ask({ status: 'stopped', text: '一半' })] }))).toContain('已停止,这次没存。') + expect(text(render({ open: true, asks: [ask({})] }))).toContain('正在想…') + }) +}) + +describe('找不到原来位置的问答', () => { + it('灰字一栏:写着原来在哪,问答照样能删', () => { + const html = renderToStaticMarkup( + if @1', where: [{ kind: 'function', name: 'display' }, { kind: 'if' }], entries: HISTORY }]} + kindName={kind => ({ function: '函数', if: '条件' })[kind] ?? kind} + now={NOW} + onDelete={() => {}} + />, + ) + expect(text(html)).toContain('代码改动后找不到原来位置的问答 · 2 条') + expect(text(html)).toContain('原来在:函数 display › 条件') + expect(html.match(/aria-label="已理解,删除"/g)).toHaveLength(2) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx new file mode 100644 index 0000000..2c9451b --- /dev/null +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -0,0 +1,198 @@ +import type { KeyboardEvent, ReactElement } from 'react' +import type { Ask } from './qa' +import type { QaEntryView, QaThreadView } from './types' +import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert } from 'lucide-react' +import { useState } from 'react' +import { renderMarkdown, renderMarkdownOnce } from './markdown' +import { answerPreview, relativeTime, whereLabel } from './qa' + +/** 问过的一次,灰字:问题、时间、「已理解,删除」,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */ +function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number, onDelete: () => void }): ReactElement { + const [expanded, setExpanded] = useState(false) + const toggle = (): void => setExpanded(value => !value) + return ( +
+
+ + + + +
+ {expanded + ?
+ : ( + + )} +
+ ) +} + +/** 当前这一轮问的一次,照常颜色:问题,回答逐字长出来(按 Markdown 排),在做的事、出错、停止写在下面 */ +function CurrentItem({ ask }: { ask: Ask }): ReactElement { + const running = ask.status === 'running' + return ( +
+
+ + {ask.question} +
+ {ask.text !== '' && ( +
+ )} + {running && ( +
+ + {ask.activity ?? (ask.text === '' ? '正在想…' : '正在回答…')} +
+ )} + {ask.status === 'failed' && ( +
+ + {ask.error} +
+ )} + {ask.status === 'stopped' &&
已停止,这次没存。
} +
+ ) +} + +/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止 */ +function AskInput({ running, first, onAsk, onStop, onClose }: { + running: Ask | undefined + /** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */ + first: boolean + onAsk: (question: string) => void + onStop: (askId: string) => void + onClose: () => void +}): ReactElement { + const [value, setValue] = useState('') + const send = (): void => { + const question = value.trim() + if (question === '' || running) + return + onAsk(question) + setValue('') + } + const onKeyDown = (event: KeyboardEvent): void => { + // 输入法选字时的回车不算发送 + if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault() + send() + } + else if (event.key === 'Escape') { + event.preventDefault() + onClose() + } + } + return ( +
+