From f0a3ad80e1e0d74183924fe6b8133168c03bae55 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 21:25:22 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E9=97=AE=20AI=20?= =?UTF-8?q?=E6=94=B6=E8=B5=B7=E5=86=8D=E5=B1=95=E5=BC=80=E3=80=81=E9=87=8D?= =?UTF-8?q?=E5=90=AF=E5=90=8E=E6=8E=A5=E7=9D=80=E8=BF=99=E5=9D=97=E6=9C=80?= =?UTF-8?q?=E8=BF=91=E7=9A=84=E4=BC=9A=E8=AF=9D=E9=97=AE=EF=BC=9B=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=94=B9=E4=BA=86=E5=AF=B9=E4=B8=8D=E4=B8=8A=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E7=AD=94=E4=B8=8D=E5=86=8D=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 30 +++++++------- vscode/src/codeReader/editor/messages.ts | 12 +++--- vscode/src/codeReader/editor/previewEditor.ts | 14 +++---- vscode/src/codeReader/qa/anchor.test.ts | 11 +---- vscode/src/codeReader/qa/anchor.ts | 17 -------- vscode/src/codeReader/qa/codeQa.ts | 16 +++++--- vscode/src/codeReader/qa/qaMap.test.ts | 20 ++++++++- vscode/src/codeReader/qa/qaMap.ts | 5 +++ vscode/webview-ui/src/codeReader/App.tsx | 5 --- .../src/codeReader/AskThread.test.tsx | 18 +------- .../webview-ui/src/codeReader/AskThread.tsx | 30 +------------- .../webview-ui/src/codeReader/codeReader.css | 17 -------- vscode/webview-ui/src/codeReader/qa.test.ts | 41 +++---------------- vscode/webview-ui/src/codeReader/qa.ts | 34 ++------------- vscode/webview-ui/src/codeReader/types.ts | 16 ++------ vscode/webview-ui/src/codeReader/useQa.ts | 7 ++-- 16 files changed, 82 insertions(+), 211 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 004957a..b89d76c 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -28,7 +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」 | +| 问 AI | 块右上角「问 AI」,在块下面问 Claude Code(`claude -p`,只读,用插件默认的 profile)这块代码,回答逐字出来按 Markdown 显示、能追问(接着这块最近一次的会话,收起再展开、重启 VS Code 也接得上)、能停;问答存进各工作区文件夹的 `.spx/code-qa.json`,灰字挂在块下面免得重复问,理解了就删。问答按块的位置记号挂,记号只由和语言无关的块数据(种类、facts 里的名字、源码哈希)算出,文件别处改了照样认得;认不回来的不显示,留在文件里,代码改回来又挂回去。见「问 AI」 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | | 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | | 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 | @@ -216,10 +216,10 @@ - 当前这一轮(展开以来问的,和历史之间一条细分隔线):问题照常颜色、加粗,回答照常颜色、按 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 消息),只发问题和「还是第几行那块」;会话找不到了(过期、被清理)就按第一次那样重新问。收起再展开是新的一轮,重新开会话、带上代码上下文。 +- 收起就结束这一轮:这一轮答完的都回到灰字历史里(再问照样接着会话,见「追问」);还没答完的接着答,答完照样存。同一块同时只答一个问题(正在答时不能再发),不同块可以同时问,全插件同时最多 3 个,再多就在这一问下面说「同时最多问 3 个问题,等前面的答完再问」。 +- 追问:这块存过问答,再问就接着最近一次(按问的时间)存下的那条的会话(`--resume <会话 id>`,会话 id 取 stream-json 的 init 消息,和问答一起存在 `code-qa.json` 里),只发问题和「还是第几行那块」;收起再展开、预览重新加载、重启 VS Code 都一样接得上,前面问过的照旧灰字显示。那条没记会话 id、或会话找不到了(过期、被清理)就按第一次那样带上代码上下文重新开会话。删掉最近那条,就接着剩下里最近的;一块下面删光了就是没问过。 - 删除:点垃圾桶马上删,不用确认;一块下面删光了这块就不挂了,一份文件删光了文件里就没有这份文件。 -- 找不到原来位置的问答:记号(见下)对不上这份文件里任何一块的问答,放在预览顶上(筛选栏下面、代码块上面)一个虚线框里,标题「代码改动后找不到原来位置的问答 · N 条」(点标题收起 / 展开,默认展开),每组写「原来在:函数 get_user › 条件」(种类用翻译插件起的名字),下面同灰字历史,能看、能删。解析失败时一块都没有,不显示这一栏。 +- 找不到原来位置的问答:记号(见下)对不上这份文件里任何一块的问答不显示,也不动它,留在 `code-qa.json` 里;代码改回来、记号又对上了,就又挂回那块下面。 - 预览重新加载(标签页切走再切回来)后,扩展端还在答的问题接上(展开它那块、接着长),答完照样显示。同一份文件开在几处,问答、正在答的都一样。 怎么问(扩展端 `qa/`): @@ -232,8 +232,8 @@ 块的位置记号(`BlockView.anchor`,`qa/anchor.ts`): - 扩展端生成视图数据时按源码先后逐块给:有名字的块(`defines` 的来源:函数名、类名、枚举名、单个变量名,库规则卡片取规则给的定义范围)是外层各个有名字的块加自己的「种类 名字」,例如 `class ReferralRelation > function display`;没名字的块(条件、语句、返回、注释、导入、多变量声明……)是外层最近的有名字的块加「种类 @源码哈希」,哈希是这块编辑范围的源码去掉所有空白后的 SHA-1 前 8 位,例如 `function display > if @3fa1c2d0`。同一层里记号相同的(同名的两个函数、一模一样的两行语句)按源码先后在后面补 `[1]`、`[2]`。 -- 所以:上面插几行、改缩进空白、改别的函数的名字,记号都不变;有名字的块改了主体、签名还认得;没名字的块自己的源码改了,记号就变了,旧问答成了「找不到原来位置的」。种类用视图数据的种类(库规则的卡片是规则的种类 id)。 -- webview 只拿记号比相等,不拆开看;「原来在哪」由扩展端把记号拆回各层(`where: { kind, name? }[]`)发过去。 +- 所以:上面插几行、改缩进空白、改别的函数的名字,记号都不变;有名字的块改了主体、签名还认得;没名字的块自己的源码改了,记号就变了,旧问答成了「找不到原来位置的」(不显示)。种类用视图数据的种类(库规则的卡片是规则的种类 id)。 +- webview 只拿记号比相等,不拆开看。 存法: @@ -938,9 +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: 'qa', available, threads: { anchor, 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` 是被叫停、没存。 + - `{ type: 'answerEnd', askId, status: 'done' | 'failed' | 'stopped', text?, error?, entryId? }`:答完了。`done` 带最终回答和存下的那条的 id(历史里不再重复显示它),`failed` 带给人看的原因,`stopped` 是被叫停、没存。 - webview → 扩展: - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。这两条都只带下标:扩展端按发出去的文档认出规则标了引用的下标交给规则查(见「规则自己查定义」),其余问语言扩展。 @@ -949,7 +949,7 @@ 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: 'ask', askId, anchor, question }`:问 `anchor` 那块;`askId` 由 webview 起。扩展端按最近一次发出去的块树和源码找这块、拼问题,这块存过问答就接着最近那条的会话追问(见「问 AI」的追问);找不到(代码刚改过)或问不了(这块正在答、同时答的太多)时直接回 `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`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 @@ -1009,13 +1009,13 @@ vscode/src/codeReader/ notes/noteItem.ts 有注释的行怎么显示:描述、悬停提示、contextValue(纯函数) notes/fileNotes.ts FileNotes:注释的 FolderStore、给树画行 notes/commands.ts 「添加注释 / 编辑注释」输入框 - qa/anchor.ts 块的位置记号(BlockAnchors)、拆回各层(不碰 VS Code) - qa/qaMap.ts .spx/code-qa.json 的形状、每条怎么认、记一次 / 删一次(纯函数) + 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、开问、答完存、删,发给预览的问答 + qa/codeQa.ts CodeQa:问答的 FolderStore、开问(问过的块接着最近的会话)、答完存、删,发给预览的问答 (cc/spawnClaude.ts 的 runClaudeLines:跑 claude、stdout 逐行交出、超时、叫停,问 AI 用它) editor/previewEditor.ts CustomTextEditorProvider editor/messages.ts 消息类型 @@ -1058,14 +1058,14 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 复制出什么(`explorer/copyText.test.ts`):文件、目录的名字 / 相对路径 / 绝对路径;不带后缀只去最后一个扩展名(`a.test.ts`、`archive.tar.gz`),`.env` 原样、`.env.local` → `.env`、没有扩展名的原样;相对路径总用 `/`,绝对路径照本机写法(Windows 反斜杠)。 - 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、`title` 和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。 - 问 AI: - - 位置记号(`qa/anchor.test.ts`,真实的 Python 解析):有名字 / 没名字的块的记号、同一层重复的编号、两个函数里一样的条件互不影响;上面插几行、改空白记号不变;改别的函数的名字只动它和它里面的块;没名字的块自己的源码改了记号变、有名字的块改签名不变;拆回各层。 - - 存法(`qa/qaMap.test.ts`):记一次、删一次、删光不挂 / 不记;文件缺失、解析失败、不是对象当没有;认不出的块和问答跳过、没会话 id 的照认;手改的顺序、重复的块读进来整理好;写出来排好序、两格缩进、读得回来、删光留 `{}`;改名、挪目录、删除跟着走。 + - 位置记号(`qa/anchor.test.ts`,真实的 Python 解析):有名字 / 没名字的块的记号、同一层重复的编号、两个函数里一样的条件互不影响;上面插几行、改空白记号不变;改别的函数的名字只动它和它里面的块;没名字的块自己的源码改了记号变、有名字的块改签名不变。 + - 存法(`qa/qaMap.test.ts`):记一次、删一次、删光不挂 / 不记;文件缺失、解析失败、不是对象当没有;认不出的块和问答跳过、没会话 id 的照认;再问时接着这块最近那条的会话(删了最近的接着剩下里最近的,没问过、没记会话 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 次」、展开的历史 / 当前这一轮 / 输入框、出错和停止、找不到原来位置的那一栏。 + - 页面(`qa.test.ts`、`AskThread.test.tsx`):问一次、答完记下存成的那条、收起结束这一轮、重新加载接上还在答的、停止和失败、不认识的消息不理;问的时间、回答的前两行;问答区没问过不画、没展开只露「问过 N 次」、展开的历史 / 当前这一轮 / 输入框、出错和停止。 ## 收尾 diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index f7ae95f..d1d4daf 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -4,7 +4,6 @@ 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' @@ -54,10 +53,9 @@ export interface QaEntryView { createdAt: string } -/** 挂在一块上的问答。where 是记号拆回的各层(种类和名字),块找不到了时写它原来在哪。 */ +/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示。 */ export interface QaThreadView { anchor: string - where: AnchorStep[] entries: QaEntryView[] } @@ -79,7 +77,7 @@ export interface QaMessage { * filter:整个工作区共用的块筛选;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览 * qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 - * answerEnd:askId 那个问题答完了:done 带最终的回答、存下的那条的 id 和会话 id(追问接着这个会话),failed 带给人看的原因,stopped 是被叫停 + * answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停 */ export type ExtensionToWebview = | DocumentMessage @@ -89,7 +87,7 @@ export type ExtensionToWebview | { 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 } + | { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) @@ -99,7 +97,7 @@ export type ExtensionToWebview * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 * findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition * setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览 - * ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;resume 是追问时上一次的会话 id + * ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问 * stopAsk:叫停 askId 那个问题,这次不存 * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) */ @@ -112,6 +110,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: 'ask', askId: string, anchor: string, question: 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 a9b248a..6ff8f1e 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -157,7 +157,7 @@ class Preview { 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 }), + ...(end.status === 'done' && { text: end.answer.text, entryId }), }) this.postQa() }), @@ -219,17 +219,17 @@ class Preview { } /** - * 问 anchor 那块代码:按最近一次发出去的块树找到它、带上代码上下文交给 CodeQa;resume 是追问时上一次的会话 id。 + * 问 anchor 那块代码:按最近一次发出去的块树找到它,把完整问题和追问的话都交给 CodeQa,接不接着上次的会话由它定。 * 找不到这块(代码刚改过、还没重新发)或问不了时,直接回一条失败。 */ - private ask(askId: string, anchor: string, question: string, resume: string | undefined): void { + private ask(askId: string, anchor: string, question: string): 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 + const followUp = subject && followUpPrompt(subject, question) + const refused = prompt === undefined || followUp === undefined ? '这块代码刚改过,找不到了。等预览刷新后重新点「问 AI」再问。' - : this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, ...(resume && followUp && { followUp: { sessionId: resume, prompt: followUp } }) }) + : this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, followUp }) if (refused !== undefined) { this.post({ type: 'answerEnd', askId, status: 'failed', error: refused }) return @@ -252,7 +252,7 @@ class Preview { await this.push() return case 'ask': - this.ask(msg.askId, msg.anchor, msg.question, msg.resume) + this.ask(msg.askId, msg.anchor, msg.question) return case 'stopAsk': this.owner.qa.stop(msg.askId) diff --git a/vscode/src/codeReader/qa/anchor.test.ts b/vscode/src/codeReader/qa/anchor.test.ts index 5bae321..2e5e619 100644 --- a/vscode/src/codeReader/qa/anchor.test.ts +++ b/vscode/src/codeReader/qa/anchor.test.ts @@ -3,7 +3,7 @@ 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' +import { BlockAnchors } from './anchor' const py = createPythonAdapter(WASM_DIR) @@ -83,15 +83,6 @@ describe('块的位置记号', () => { 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 }') diff --git a/vscode/src/codeReader/qa/anchor.ts b/vscode/src/codeReader/qa/anchor.ts index 34cd4ad..a074b82 100644 --- a/vscode/src/codeReader/qa/anchor.ts +++ b/vscode/src/codeReader/qa/anchor.ts @@ -40,20 +40,3 @@ export class BlockAnchors { 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/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts index 1183130..23e2f4c 100644 --- a/vscode/src/codeReader/qa/codeQa.ts +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -6,22 +6,22 @@ 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' +import { latestSession, 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 } +/** prompt 是带代码上下文的完整问题;followUp 是接着这块上一次的会话问时发的话(只有问题和是哪块) */ export interface AskRequest { askId: string anchor: string question: string prompt: string - followUp?: { sessionId: string, prompt: string } + followUp: string } /** @@ -49,21 +49,25 @@ export class CodeQa implements Disposable { 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,它读得到整个项目。 */ + /** + * 开始问;问不了时返回原因(给人看的话)。在工作区文件夹里跑 claude,它读得到整个项目。 + * 这块问过的就接着最近一次的会话问(收起再展开、重启 VS Code 都一样),没问过就带上代码上下文开新会话。 + */ 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 } }, { + const sessionId = latestSession(this.store.get(at.folder, at.key), anchor) + const run = { prompt, ...(sessionId && { followUp: { sessionId, prompt: followUp } }), cwd: at.folder.uri.fsPath } + return this.asks.start({ owner, askId, anchor, question, run }, { update: ask => this.answered.fire({ owner, kind: 'update', ask }), end: async (ask, end) => { if (end.status !== 'done') { diff --git a/vscode/src/codeReader/qa/qaMap.test.ts b/vscode/src/codeReader/qa/qaMap.test.ts index 9a0c208..f176363 100644 --- a/vscode/src/codeReader/qa/qaMap.test.ts +++ b/vscode/src/codeReader/qa/qaMap.test.ts @@ -4,7 +4,7 @@ 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' +import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap' function entry(id: string, createdAt: string, question = `问 ${id}`): QaEntry { return { id, question, answer: `答 ${id}`, createdAt, sessionId: `s-${id}` } @@ -67,6 +67,24 @@ describe('记一次、删一次', () => { }) }) +describe('再问时接着哪个会话', () => { + it('接着这块最近一次问答的会话;别的块、没问过的块、没记会话 id 的都重新开', () => { + let map: QaMap = {} + map = withEntry(map, 'a.py', IF, entry('new', '2026-09-26T10:00:00.000Z')) + map = withEntry(map, 'a.py', IF, entry('old', '2026-09-25T10:00:00.000Z')) + map = withEntry(map, 'a.py', DISPLAY, { ...entry('bare', '2026-09-26T11:00:00.000Z'), sessionId: '' }) + expect(latestSession(map['a.py'], IF)).toBe('s-new') + expect(latestSession(map['a.py'], DISPLAY)).toBeUndefined() + expect(latestSession(map['a.py'], 'function gone')).toBeUndefined() + expect(latestSession(undefined, IF)).toBeUndefined() + }) + + it('删掉最近那次,就接着剩下里最近的', () => { + const map = withEntry(withEntry({}, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, entry('b', '2026-09-26T10:00:00.000Z')) + expect(latestSession(withoutEntry(map, 'a.py', IF, 'b')['a.py'], IF)).toBe('s-a') + }) +}) + describe('读写 .spx/code-qa.json', () => { it('文件不存在、解析失败、不是对象都当没有', async () => { expect(await readPathMap(root, QA_FILE)).toEqual({}) diff --git a/vscode/src/codeReader/qa/qaMap.ts b/vscode/src/codeReader/qa/qaMap.ts index db36460..6921816 100644 --- a/vscode/src/codeReader/qa/qaMap.ts +++ b/vscode/src/codeReader/qa/qaMap.ts @@ -88,3 +88,8 @@ export function withoutEntry(map: QaMap, key: string, anchor: string, entryId: s delete next[key] return threads.length > 0 ? { ...next, [key]: threads } : next } + +/** 再问 anchor 那块时接着的会话:这块最近一次问答的会话 id;没问过、或那次没记会话 id 时没有,重新开会话。 */ +export function latestSession(threads: readonly QaThread[] | undefined, anchor: string): string | undefined { + return threads?.find(thread => thread.anchor === anchor)?.entries.at(-1)?.sessionId || undefined +} diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index b8c58a5..4b191a8 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -4,12 +4,10 @@ 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' @@ -86,8 +84,6 @@ 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), @@ -137,7 +133,6 @@ 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 index 93bc83e..2f39427 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.test.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.test.tsx @@ -3,7 +3,7 @@ import type { Ask } from './qa' import type { QaEntryView } from './types' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' -import { AskThread, OrphanThreads } from './AskThread' +import { AskThread } from './AskThread' const NOW = new Date(2026, 8, 26, 15, 0).getTime() @@ -68,19 +68,3 @@ describe('块下面的问答区', () => { 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 index 2c9451b..849ebfe 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -1,10 +1,10 @@ import type { KeyboardEvent, ReactElement } from 'react' import type { Ask } from './qa' -import type { QaEntryView, QaThreadView } from './types' +import type { QaEntryView } 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' +import { answerPreview, relativeTime } from './qa' /** 问过的一次,灰字:问题、时间、「已理解,删除」,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */ function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number, onDelete: () => void }): ReactElement { @@ -170,29 +170,3 @@ export function AskThread({ history, asks, open, now = Date.now(), onAsk, onStop ) } - -/** 预览顶上的灰字一栏:代码改动后找不到原来位置的问答,写着原来在哪,照样能看、能删 */ -export function OrphanThreads({ threads, kindName, now = Date.now(), onDelete }: { - threads: QaThreadView[] - kindName: (kind: string) => string - now?: number - onDelete: (anchor: string, entryId: string) => void -}): ReactElement { - const [expanded, setExpanded] = useState(true) - const Chevron = expanded ? ChevronDown : ChevronRight - const count = threads.reduce((sum, thread) => sum + thread.entries.length, 0) - return ( -
- - {expanded && threads.map(thread => ( -
-
{`原来在:${whereLabel(thread.where, kindName)}`}
- {thread.entries.map(entry => onDelete(thread.anchor, entry.id)} />)} -
- ))} -
- ) -} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 8b30e19..1c66fe6 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -679,23 +679,6 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(- outline-offset: 1px; border-radius: 2px; } -/* 预览顶上:找不到原来位置的问答 */ -.cr-qa-orphans { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 12px; - padding: 6px 10px; - border: 1px dashed color-mix(in srgb, var(--vscode-descriptionForeground) 40%, transparent); - border-radius: 6px; -} -.cr-qa-orphans .cr-qa { - --qa-accent: var(--vscode-descriptionForeground); -} -.cr-qa-where { - color: var(--vscode-descriptionForeground); - font-size: 11.5px; -} @media (prefers-reduced-motion: reduce) { .cr-qa-spin, .cr-qa-answer[data-streaming] > :last-child::after { diff --git a/vscode/webview-ui/src/codeReader/qa.test.ts b/vscode/webview-ui/src/codeReader/qa.test.ts index 330cafe..c4d95fe 100644 --- a/vscode/webview-ui/src/codeReader/qa.test.ts +++ b/vscode/webview-ui/src/codeReader/qa.test.ts @@ -1,13 +1,12 @@ import type { QaAction, QaState } from './qa' -import type { BlockView, QaMessage, QaThreadView } from './types' +import type { QaMessage, QaThreadView } from './types' import { describe, expect, it } from 'vitest' -import { answerPreview, asksOf, EMPTY_QA, historyOf, orphanThreads, qaReducer, relativeTime, resumeFor, whereLabel } from './qa' +import { answerPreview, asksOf, EMPTY_QA, historyOf, qaReducer, relativeTime } from './qa' const IF = 'function get_user > if @3fa1c2d0' -const RETURN = 'function get_user > return @9b1e2c3d' function thread(anchor: string, ...ids: string[]): QaThreadView { - return { anchor, where: [], entries: ids.map(id => ({ id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-09-25T10:00:00.000Z' })) } + return { anchor, entries: ids.map(id => ({ id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-09-25T10:00:00.000Z' })) } } function message(threads: QaThreadView[], running: QaMessage['running'] = []): QaMessage { @@ -18,10 +17,6 @@ function qa(threads: QaThreadView[], running: QaMessage['running'] = []): QaActi return { type: 'qa', message: message(threads, running) } } -function block(anchor: string, children: BlockView[] = []): BlockView { - return { id: anchor, anchor, kind: 'statement', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: {}, children, translation: [] } -} - function run(...actions: QaAction[]): QaState { return actions.reduce(qaReducer, EMPTY_QA) } @@ -33,34 +28,23 @@ describe('问 AI 的状态', () => { expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '', status: 'running' }]) state = qaReducer(state, { type: 'answer', askId: 'a1', text: '因为', activity: '读取 a.py' }) expect(asksOf(state, IF)[0]).toMatchObject({ text: '因为', activity: '读取 a.py' }) - state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new', sessionId: 's1' } }) + state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new' } }) state = qaReducer(state, qa([thread(IF, 'old', 'new')])) - expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new', sessionId: 's1' }]) + expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new' }]) expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old']) }) - it('追问接着这一轮最近答成的会话;这一轮还没答成过就重新开', () => { - let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' }) - expect(resumeFor(state, IF)).toBeUndefined() - state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1', sessionId: 's1' } }) - state = qaReducer(state, { type: 'ask', askId: 'a2', anchor: IF, question: '2' }) - state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a2', status: 'failed', error: '网络断了' } }) - expect(resumeFor(state, IF)).toBe('s1') - expect(resumeFor(state, RETURN)).toBeUndefined() - }) - it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => { let state = run( qa([thread(IF, 'e1')]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' }, - { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1', sessionId: 's1' } }, + { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1' } }, { type: 'ask', askId: 'a2', anchor: IF, question: '2' }, { type: 'close', anchor: IF }, ) expect(state.open).toEqual([]) expect(asksOf(state, IF).map(ask => ask.askId)).toEqual(['a2']) expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['e1']) - expect(resumeFor(state, IF)).toBeUndefined() state = qaReducer(state, { type: 'open', anchor: IF }) expect(state.open).toEqual([IF]) }) @@ -83,19 +67,6 @@ describe('问 AI 的状态', () => { }) }) -describe('找不到原来位置的问答', () => { - it('记号对不上任何一层的块才算', () => { - const threads = [thread(IF, 'a'), thread(RETURN, 'b'), thread('function gone', 'c')] - expect(orphanThreads(threads, [block('function get_user', [block(IF)])]).map(item => item.anchor)).toEqual([RETURN, 'function gone']) - }) - - it('原来在哪:种类取翻译插件起的名字,没名字的块只写种类', () => { - const names: Record = { class: '类', function: '函数', if: '条件' } - expect(whereLabel([{ kind: 'class', name: 'ReferralRelation' }, { kind: 'function', name: 'display' }, { kind: 'if' }], kind => names[kind] ?? kind)) - .toBe('类 ReferralRelation › 函数 display › 条件') - }) -}) - describe('问的时间', () => { const now = new Date(2026, 8, 26, 15, 0).getTime() const at = (year: number, month: number, day: number, hour: number, minute: number, second = 0): string => diff --git a/vscode/webview-ui/src/codeReader/qa.ts b/vscode/webview-ui/src/codeReader/qa.ts index 03367ae..817fabb 100644 --- a/vscode/webview-ui/src/codeReader/qa.ts +++ b/vscode/webview-ui/src/codeReader/qa.ts @@ -1,9 +1,9 @@ /** * 「问 AI」在页面上的状态和规则(不碰 DOM):问答按块的记号(BlockView.anchor)挂到块下面; - * 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史;记号对不上任何块的问答是「找不到原来位置的」。 + * 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史;记号对不上任何块的问答不显示。 */ -import type { AnchorStep, AnswerEnd, BlockView, QaEntryView, QaMessage, QaThreadView } from './types' +import type { AnswerEnd, QaEntryView, QaMessage, QaThreadView } from './types' /** 这次打开以来问的一个问题 */ export interface Ask { @@ -19,8 +19,6 @@ export interface Ask { error?: string /** done 时存下的那条:历史里不再重复显示它 */ entryId?: string - /** done 时的会话 id:接着追问用 */ - sessionId?: string } export interface QaState { @@ -68,11 +66,11 @@ export function qaReducer(state: QaState, action: QaAction): QaState { : ask), } case 'answerEnd': { - const { askId, status, text, error, entryId, sessionId } = action.end + const { askId, status, text, error, entryId } = action.end return { ...state, asks: state.asks.map(ask => ask.askId === askId - ? { askId, anchor: ask.anchor, question: ask.question, text: text ?? ask.text, status, error, entryId, sessionId } + ? { askId, anchor: ask.anchor, question: ask.question, text: text ?? ask.text, status, error, entryId } : ask), } } @@ -105,30 +103,6 @@ export function historyOf(state: QaState, anchor: string): QaEntryView[] { return state.threads.find(thread => thread.anchor === anchor)?.entries.filter(entry => !current.has(entry.id)) ?? [] } -/** 追问接着哪个会话:当前这一轮最近答成的那次;这一轮还没答成过就重新开 */ -export function resumeFor(state: QaState, anchor: string): string | undefined { - return asksOf(state, anchor).filter(ask => ask.status === 'done' && ask.sessionId).at(-1)?.sessionId -} - -function anchorsIn(blocks: readonly BlockView[], into: Set): Set { - for (const block of blocks) { - into.add(block.anchor) - anchorsIn(block.children, into) - } - return into -} - -/** 记号对不上任何块的问答(代码改动后找不到原来的位置了),按文件里的先后 */ -export function orphanThreads(threads: readonly QaThreadView[], blocks: readonly BlockView[]): QaThreadView[] { - const anchors = anchorsIn(blocks, new Set()) - return threads.filter(thread => !anchors.has(thread.anchor)) -} - -/** 问答原来挂在哪:各层写成「种类 名字」,用 › 连起来,例如「类 ReferralRelation › 函数 display › 条件」 */ -export function whereLabel(where: readonly AnchorStep[], kindName: (kind: string) => string): string { - return where.map(step => step.name === undefined ? kindName(step.kind) : `${kindName(step.kind)} ${step.name}`).join(' › ') -} - const MINUTE = 60_000 const DAY = 24 * 60 * MINUTE diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index c5a1217..82d3e16 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -164,16 +164,9 @@ export interface QaEntryView { createdAt: string } -/** 块的位置记号里的一层:种类(同 BlockViewBase.kind)和名字,没名字的块只有种类 */ -export interface AnchorStep { - kind: string - name?: string -} - -/** 挂在一块上的问答;where 是它原来在哪(各层的种类和名字),块找不到了时显示 */ +/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示 */ export interface QaThreadView { anchor: string - where: AnchorStep[] entries: QaEntryView[] } @@ -198,7 +191,7 @@ export interface QaMessage { } /** - * 一个问题怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)、存下的那条的 id 和会话 id(追问接着这个会话), + * 一个问题怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)和存下的那条的 id, * failed 带给人看的原因,stopped 是被叫停、没存 */ export interface AnswerEnd { @@ -208,7 +201,6 @@ export interface AnswerEnd { text?: string error?: string entryId?: string - sessionId?: string } /** @@ -238,7 +230,7 @@ export type ExtensionToWebview * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 * findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition * setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示 - * ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;resume 是追问时上一次的会话 id + * ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问 * stopAsk:叫停 askId 那个问题,这次不存 * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) */ @@ -251,6 +243,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: 'ask', askId: string, anchor: string, question: string } | { type: 'stopAsk', askId: string } | { type: 'deleteQa', anchor: string, entryId: string } diff --git a/vscode/webview-ui/src/codeReader/useQa.ts b/vscode/webview-ui/src/codeReader/useQa.ts index e3cdd41..59ae1bc 100644 --- a/vscode/webview-ui/src/codeReader/useQa.ts +++ b/vscode/webview-ui/src/codeReader/useQa.ts @@ -1,7 +1,7 @@ import type { QaTree } from './BlockTree' import type { QaState } from './qa' import { useEffect, useMemo, useReducer, useRef } from 'react' -import { EMPTY_QA, newAskId, qaReducer, resumeFor } from './qa' +import { EMPTY_QA, newAskId, qaReducer } from './qa' import { onMessage, postMessage } from './vscode' /** 逐字长出来的回答最多隔多久画一次:一个字一画整棵块树跟着重画,大文件会卡 */ @@ -10,7 +10,7 @@ const ANSWER_FRAME_MS = 80 /** 问 AI:收扩展端的问答、正在答的进展,给块树用的状态和动作(问、停、删、展开 / 收起)。 */ export function useQa(): QaTree { const [state, dispatch] = useReducer(qaReducer, EMPTY_QA) - /** 动作里读眼下的状态(追问接着哪个会话) */ + /** 动作里读眼下的状态(这块展开着没有) */ const current = useRef(state) current.current = state @@ -51,9 +51,8 @@ export function useQa(): QaTree { onClose: anchor => dispatch({ type: 'close', anchor }), onAsk: (anchor, question) => { const askId = newAskId() - const resume = resumeFor(current.current, anchor) dispatch({ type: 'ask', askId, anchor, question }) - postMessage({ type: 'ask', askId, anchor, question, ...(resume && { resume }) }) + postMessage({ type: 'ask', askId, anchor, question }) }, onStop: askId => postMessage({ type: 'stopAsk', askId }), onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),