From 36d842e18e13f510720b9c8688e99180adb19f3f Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 23:15:02 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E9=97=AE=20AI=20?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E8=83=BD=E7=B2=98=E8=B4=B4=E3=80=81?= =?UTF-8?q?=E6=8B=96=E8=BF=9B=E5=9B=BE=E7=89=87=E9=9A=8F=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E5=8F=91=E7=BB=99=20Claude=20Code=EF=BC=8C=E7=BC=A9=E7=95=A5?= =?UTF-8?q?=E5=9B=BE=E8=83=BD=E7=82=B9=E5=BC=80=E7=9C=8B=E5=A4=A7=E5=9B=BE?= =?UTF-8?q?=EF=BC=8C=E5=9B=BE=E7=89=87=E5=AD=98=E8=BF=9B=20.spx/code-qa=20?= =?UTF-8?q?=E8=B7=9F=E7=9D=80=E9=97=AE=E7=AD=94=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 43 ++-- vscode/src/cc/spawnClaude.test.ts | 10 + vscode/src/cc/spawnClaude.ts | 10 +- vscode/src/codeReader/editor/messages.ts | 8 +- vscode/src/codeReader/editor/previewEditor.ts | 13 +- vscode/src/codeReader/folderStore.ts | 8 +- vscode/src/codeReader/pathMap.test.ts | 37 +++- vscode/src/codeReader/pathMap.ts | 21 +- vscode/src/codeReader/qa/askProfiles.test.ts | 4 +- vscode/src/codeReader/qa/asks.test.ts | 2 +- vscode/src/codeReader/qa/claudeAsk.test.ts | 67 +++++- vscode/src/codeReader/qa/claudeAsk.ts | 15 +- vscode/src/codeReader/qa/codeQa.ts | 52 ++++- vscode/src/codeReader/qa/qaImages.test.ts | 49 +++++ vscode/src/codeReader/qa/qaImages.ts | 40 ++++ vscode/src/codeReader/qa/qaMap.test.ts | 56 ++++- vscode/src/codeReader/qa/qaMap.ts | 18 +- .../src/codeReader/AskThread.test.tsx | 16 ++ .../webview-ui/src/codeReader/AskThread.tsx | 197 ++++++++++++++---- .../webview-ui/src/codeReader/BlockTree.tsx | 6 +- .../webview-ui/src/codeReader/codeReader.css | 101 +++++++++ vscode/webview-ui/src/codeReader/qa.test.ts | 22 +- vscode/webview-ui/src/codeReader/qa.ts | 8 +- .../src/codeReader/qaImages.test.ts | 60 ++++++ vscode/webview-ui/src/codeReader/qaImages.ts | 61 ++++++ vscode/webview-ui/src/codeReader/types.ts | 13 +- .../src/codeReader/useOccurrences.ts | 2 +- vscode/webview-ui/src/codeReader/useQa.ts | 7 +- 28 files changed, 825 insertions(+), 121 deletions(-) create mode 100644 vscode/src/codeReader/qa/qaImages.test.ts create mode 100644 vscode/src/codeReader/qa/qaImages.ts create mode 100644 vscode/webview-ui/src/codeReader/qaImages.test.ts create mode 100644 vscode/webview-ui/src/codeReader/qaImages.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 4d5c3a6..f9d1e61 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 显示、能追问(接着这块最近一次的会话,收起再展开、重启 VS Code 也接得上)、能停;问答存进各工作区文件夹的 `.spx/code-qa.json`,灰字挂在块下面免得重复问,理解了就删。问答按块的位置记号挂,记号只由和语言无关的块数据(种类、facts 里的名字、源码哈希)算出,文件别处改了照样认得;认不回来的不显示,留在文件里,代码改回来又挂回去。见「问 AI」 | +| 问 AI | 块右上角「问 AI」,在块下面问 Claude Code(`claude -p`,只读,用输入框旁边选的 profile,记住上次选的,没选过用插件默认的)这块代码,回答逐字出来按 Markdown 显示、能追问(接着这块最近一次的会话,收起再展开、重启 VS Code 也接得上)、能停;输入框里能粘贴 / 拖进图片随问题发(存在 `.spx/code-qa/`,跟着问答删);问答存进各工作区文件夹的 `.spx/code-qa.json`,灰字挂在块下面免得重复问,理解了就删。问答按块的位置记号挂,记号只由和语言无关的块数据(种类、facts 里的名字、源码哈希)算出,文件别处改了照样认得;认不回来的不显示,留在文件里,代码改回来又挂回去。见「问 AI」 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | | 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | | 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 | @@ -212,16 +212,18 @@ - 入口:块右上角那排图标按钮里的「问 AI」(每种块都有:普通块、属性表块、库规则的卡片)。点一下在块下面展开问答区,再点(或在输入框里按 Esc)收起。文件不在任何工作区文件夹里时问答没地方存,不给这个按钮。 - 问答区紧跟在块下面、子块前面,和块同宽,左边一条 2px 的链接色竖线(`--vscode-textLink-foreground` 50%)。从上到下: - - 灰字的历史:这块以前问过的,每条一行问题(`descriptionForeground`,一行放不下截断)、右边时间(「刚刚」「5 分钟前」「3 小时前」「昨天」「3 天前」,一个月以前写日期,悬停看完整时间)跟着用哪个 profile 答的(「3 天前 · sonnet」;选「默认」答的、这功能以前存的只写时间)和垃圾桶「已理解,删除」;下面是回答的前两行(去掉 Markdown 记号的纯文字,更淡)。点问题或这两行展开整段回答,照样灰字,按 Markdown 排(同注释的 Markdown 渲染),再点收起。 - - 当前这一轮(展开以来问的,和历史之间一条细分隔线):问题照常颜色、加粗,回答照常颜色、按 Markdown 排,逐字长出来,末尾一个闪的光标;下面一行灰字写正在做的事(「读取 exceptions.py」「搜索 get_user」,还没开口时「正在想…」)。出错时这一问下面一个红框写原因(找不到 claude、退出码非零带它的原话、没登录时提示在终端里运行 claude 登录、超时);叫停的写「已停止,这次没存。」 + - 灰字的历史:这块以前问过的,每条一行问题(`descriptionForeground`,一行放不下截断)、右边时间(「刚刚」「5 分钟前」「3 小时前」「昨天」「3 天前」,一个月以前写日期,悬停看完整时间)跟着用哪个 profile 答的(「3 天前 · sonnet」;选「默认」答的、这功能以前存的只写时间)和垃圾桶「已理解,删除」;问的时候贴了图的,问题下面一排缩略图(见「贴图」,灰字历史里淡一点、悬停变实);下面是回答的前两行(去掉 Markdown 记号的纯文字,更淡)。点问题或这两行展开整段回答,照样灰字,按 Markdown 排(同注释的 Markdown 渲染),再点收起。 + - 当前这一轮(展开以来问的,和历史之间一条细分隔线):问题照常颜色、加粗,贴了图的问题下面一排缩略图,回答照常颜色、按 Markdown 排,逐字长出来,末尾一个闪的光标;下面一行灰字写正在做的事(「读取 exceptions.py」「搜索 get_user」,还没开口时「正在想…」)。出错时这一问下面一个红框写原因(找不到 claude、退出码非零带它的原话、没登录时提示在终端里运行 claude 登录、超时);叫停的写「已停止,这次没存。」 - 输入框:没问过时提示「问这块代码… Enter 发送,Esc 收起」,问过了「继续问… Enter 发送,Esc 收起」;Enter 发送,Shift+Enter 换行,输入法选字时的 Enter 不算;Esc 收起(不清掉名字高亮);展开时光标就在输入框里。右边先是 profile 下拉,再是发送按钮(没字时不能点),正在答时换成「停止」。 + - 贴图:在输入框里粘贴(⌘V / Ctrl+V)剪贴板里的图片(截图、复制的图片),图片加到输入框上面一排缩略图里,随下一个问题一起发;剪贴板里同时有的文字照常粘进输入框。图片文件拖到输入框上也一样(VS Code 里要按住 Shift 拖,不然它自己接走打开文件)。认 PNG、JPEG、GIF、WebP,每张最多 5 MB,一个问题最多 6 张;不认的、太大的、多出来的不加,缩略图下面一行提示原因(「只能贴 PNG、JPEG、GIF、WebP 图片;每张图片不能超过 5 MB;一个问题最多带 6 张图片,有 N 个没加上。」,警告色 11.5px),下次贴图或发送时清掉。问题还是要有字才能发(只贴图不写字发不出去);发出去后缩略图清空。 + - 缩略图:40px 见方、圆角 4px、1px 边框,图按比例裁满;历史和当前这一轮的和回答一样往右缩到问题下面,贴了还没发的和输入框左边对齐、每张右上角一个小圆「✕」(「去掉这张图片」,去掉后光标回到输入框)。点缩略图看大图:盖住整个预览的深色底,图按比例放到放得下为止,右上角「关闭」;打开时焦点在「关闭」上,点哪儿或按 Esc 关上(只关大图,不收起问答区、不清名字高亮),焦点回到点开它的缩略图。缩略图用 `data:` 地址(还没发的、当前这一轮的)或 `webview.asWebviewUri` 给的地址(存下的),页面安全策略本来就允许这两种图片,不用改。 - profile 下拉(同筛选栏的下拉 `SelectMenu`,`ghost` 样子):只写选中的 profile 名和一个小箭头,灰字 12px、高 20px,最宽 140px,长名字截断,输入框还是主角;悬停 `--vscode-toolbar-hoverBackground`,打开时 `--vscode-toolbar-activeBackground`,键盘聚焦描 1px `--vscode-focusBorder`。点开(或聚焦后按 ↓ / ↑ / Enter / 空格)列出能用的 profile,↑ ↓ 挑、Enter 选、Esc 关;profile 多了弹层顶上有搜索框。悬停提示「用哪个 Claude Code 配置(profile)回答,下一个问题起用它」。 - 选项是插件的 profile 目录(同看板、会话:`cc/profiles` 的 `listClaudeProfiles`,名字是文件名去掉 `.json`)。插件的默认 profile(`getDefaultProfilePath`,`offical.json`)在里面时,没选过就选它;不在(一个 profile 都没有,或没有 `offical.json`)时最前面多一项「默认」:不带 `--settings`,claude 用 `~/.claude` 的登录,和以前一样照问,没选过就选它。 - 选了从下一个问题起用它(正在答的不受影响)。所有预览、所有块共用一份,记在扩展的 `globalState`(`superpowers.codeReader.askProfile`,记 profile 名,「默认」记 `''`)里:换个预览、重启 VS Code 都还是它;记着的那个没了(删了、目录换了)就回到默认那项。 - 没展开、但问过的块:块下面只露一行灰字「问过 N 次」,读的时候一眼知道这里问过;点开看灰字历史(能展开、能删),没有输入框。 - 收起就结束这一轮:这一轮答完的都回到灰字历史里(再问照样接着会话,见「追问」);还没答完的接着答,答完照样存。同一块同时只答一个问题(正在答时不能再发),不同块可以同时问,全插件同时最多 3 个,再多就在这一问下面说「同时最多问 3 个问题,等前面的答完再问」。 - 追问:这块存过问答,再问就接着最近一次(按问的时间)存下的那条的会话(`--resume <会话 id>`,会话 id 取 stream-json 的 init 消息,和问答一起存在 `code-qa.json` 里),只发问题和「还是第几行那块」;收起再展开、预览重新加载、重启 VS Code 都一样接得上,前面问过的照旧灰字显示。换了 profile 也照样接着那次会话问(`--resume` 和这次选的 `--settings` 一起带,本机 claude 2.1 试过:换 profile 后会话 id 不变、记得前面说过的),线程里不另外提示。那条没记会话 id、或会话找不到了(过期、被清理)就按第一次那样带上代码上下文重新开会话。删掉最近那条,就接着剩下里最近的;一块下面删光了就是没问过。 -- 删除:点垃圾桶马上删,不用确认;一块下面删光了这块就不挂了,一份文件删光了文件里就没有这份文件。 +- 删除:点垃圾桶马上删,不用确认;一块下面删光了这块就不挂了,一份文件删光了文件里就没有这份文件。那条带的图片文件一起删。 - 找不到原来位置的问答:记号(见下)对不上这份文件里任何一块的问答不显示,也不动它,留在 `code-qa.json` 里;代码改回来、记号又对上了,就又挂回那块下面。 - 预览重新加载(标签页切走再切回来)后,扩展端还在答的问题接上(展开它那块、接着长),答完照样显示。同一份文件开在几处,问答、正在答的都一样。 @@ -229,6 +231,7 @@ - 用 Claude Code 命令行:和插件别处的 `claude -p` 一样找 claude(`resolveClaudeBin`)、清环境变量(`ANTHROPIC_*`、嵌套会话标记)、带问的时候选的 profile(`--settings <它的路径>`,选「默认」不带;每次问都现读 profile 目录,见上面的 profile 下拉),在这份文件所在的工作区文件夹里跑,它能自己读项目里别的文件。 - 只读,靠三层:`--tools Read,Grep,Glob`(可用的内置工具只剩读、搜,编辑、写文件、跑命令的工具根本没有),`--allowedTools Read,Grep,Glob`(这几个不用再批),`--permission-mode dontAsk`(别的一律拒、不等人批,盖过 profile 里的 bypassPermissions),外加 `--strict-mcp-config` 且不给 `--mcp-config`(不加载任何 MCP 服务,免得 MCP 工具能改东西)。不带 `--dangerously-skip-permissions`。 +- 问题从 stdin 发:`-p --input-format stream-json`,写一条用户消息(一行 JSON,同 `cc/spawnClaude.ts` 带图时的 `buildStreamJsonLine`:先是问题的文字,后面按贴的先后跟着各张图片,`{ type: 'image', source: { type: 'base64', media_type, data } }`)后关上 stdin。没贴图也走这一条路(只有文字),问题不上命令行。追问(`--resume`)和会话找不到时重新问都带上这一问的图片。本机 claude 2.1 试过:带图第一次问、接着会话再带一张图追问,都认得出图、记得上一张;约 6 MB 的 base64 图片照样能答。 - 输出 `--output-format stream-json --verbose --include-partial-messages`:init 消息给会话 id;逐字的 `text_delta` 拼成回答;工具调用换成「读取 X」「搜索 Y」;新的一条回复(读完文件后开口)一开口就换掉前面那句(「我先看看 X」);答完以 result 行的最终回答为准,`is_error` 当失败。超时 5 分钟;「停止」给进程发 SIGTERM,这次不存。 - 第一次问的话:要求用中文、大白话、简洁,只读,提到文件写相对项目根目录的路径(带行号);带上文件的相对路径和语言、外面套着的各层块的开头(每层最多 6 行,带行号,例如类和它里面的函数)、这块代码(从块的第一行到它最后一个子孙的最后一行,带行号,超过 120 行只贴开头并说剩下的在哪几行)、问题。追问只发问题和「还是 路径 第 a–b 行那块代码,代码可能刚改过,需要时重新读文件」。这些都只用视图数据的行号、记号和树形,不认识任何语言。 @@ -240,7 +243,10 @@ 存法: -- 每个工作区文件夹一份 `<工作区文件夹>/.spx/code-qa.json`,和 `.spx/code-notes.json` 同一套写法(`pathMap.ts`、`folderStore.ts` 两者共用):`{ "相对路径": [{ "anchor": 记号, "entries": [{ "id", "question", "answer", "createdAt", "sessionId", "profile"? }] }] }`,回答是 Markdown 原文,`createdAt` 是 ISO 时间,`profile` 是用哪个 profile 答的(profile 名;选「默认」答的没有这一项);文件缺失或解析失败当作没有,认不出的块、问答跳过;写时按需建 `.spx`,两格缩进、文件按路径排、块按记号排、问答按时间排、末尾换行;存第一条前不生成文件,删光后留 `{}`。 +- 每个工作区文件夹一份 `<工作区文件夹>/.spx/code-qa.json`,和 `.spx/code-notes.json` 同一套写法(`pathMap.ts`、`folderStore.ts` 两者共用):`{ "相对路径": [{ "anchor": 记号, "entries": [{ "id", "question", "answer", "createdAt", "sessionId", "profile"?, "images"? }] }] }`,回答是 Markdown 原文,`createdAt` 是 ISO 时间,`profile` 是用哪个 profile 答的(profile 名;选「默认」答的没有这一项),`images` 是问题带的图片的文件名(见下;没带图的没有这一项);文件缺失或解析失败当作没有,认不出的块、问答跳过;写时按需建 `.spx`,两格缩进、文件按路径排、块按记号排、问答按时间排、末尾换行;存第一条前不生成文件,删光后留 `{}`。 +- 问题带的图片(`qa/qaImages.ts`):答成了才写,每张一个文件,放在同一个工作区文件夹的 `.spx/code-qa/` 里,名字是 `<问答 id>-<第几张>.<扩展名>`(`png` / `jpg` / `gif` / `webp`),内容是原图;这个目录第一张图存下时才建。没答成(失败、叫停)不留文件;图片写不进去时这一问算失败(「回答没存上:图片写不进 .spx/code-qa(原因)」),不存这条。`images` 只认这个样子的名字,手改成别的路径(`../x`)的跳过,删的时候也碰不到别处。 +- 图片跟着条目走(`PathMapFile.attachments`,`folderStore.ts` 每次改表写好后删掉不再有条目带着的文件):删一次问答、删源文件(问答跟着删)时图片一起删;改名、挪位置按问答 id 起名,文件不用动;另一条还带着的不删;外部改了 `code-qa.json`(git pull、手改)不删文件。挪到另一个工作区文件夹时问答跟过去,图片留在原来那个文件夹、跟着删掉,过去的那条缩略图就没了。 +- 预览要能读到 `.spx/code-qa/`:打开预览时把这份文件所在工作区文件夹的这个目录加进 webview 的 `localResourceRoots`,发给预览的问答里图片换成 `webview.asWebviewUri` 的地址。 - 在 VS Code 里改名、挪位置、删除文件 / 目录时问答跟着走(同文件注释);外部改了 `code-qa.json`(git pull、手改)文件监听重读,开着的预览跟着变。 ## 通用契约 @@ -941,7 +947,7 @@ 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, entries: { id, question, answer, createdAt, profile? }[] }[], running: { askId, anchor, question, text, activity? }[] }`:这份文件的问答(见「问 AI」)。`ready` 后发一次,存的问答、正在答的问题变了再发给开着这份文件的所有预览;`available` 为假时文件不在工作区文件夹里,不给「问 AI」;`running` 让重新加载的页面接上还在答的问题。 + - `{ type: 'qa', available, threads: { anchor, entries: { id, question, answer, createdAt, profile?, images? }[] }[], running: { askId, anchor, question, text, activity? }[] }`:这份文件的问答(见「问 AI」)。`ready` 后发一次,存的问答、正在答的问题变了再发给开着这份文件的所有预览;`available` 为假时文件不在工作区文件夹里,不给「问 AI」;`images` 是问题带的图片在预览里能直接用的地址(`webview.asWebviewUri`);`running` 让重新加载的页面接上还在答的问题(不带图片,接上的这一问不显示缩略图,答完回到历史里照常有)。 - `{ type: 'askProfiles', options: { id, label }[], selected }`:「问 AI」的 profile 下拉(`id` 是 profile 名,「默认」的 `id` 是 `''`、`label` 是「默认」)。`ready` 后发一次(每次现读 profile 目录),任何一个预览选了都发给所有开着的预览(包括选的那个)。 - `{ type: 'answer', askId, text, activity? }`:`askId` 那个问题的回答长了(`text` 是到现在为止的全文)或换了在做的事。webview 攒着、最多每 80ms 画一次。 - `{ type: 'answerEnd', askId, status: 'done' | 'failed' | 'stopped', text?, error?, entryId? }`:答完了。`done` 带最终回答和存下的那条的 id(历史里不再重复显示它),`failed` 带给人看的原因,`stopped` 是被叫停、没存。 @@ -953,7 +959,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 }`:问 `anchor` 那块;`askId` 由 webview 起。扩展端按最近一次发出去的块树和源码找这块、拼问题,这块存过问答就接着最近那条的会话追问(见「问 AI」的追问),用扩展端记着的 profile;找不到(代码刚改过)或问不了(这块正在答、同时答的太多)时直接回 `answerEnd` failed。 + - `{ type: 'ask', askId, anchor, question, images: { mediaType, base64 }[] }`:问 `anchor` 那块;`askId` 由 webview 起;`images` 是贴进输入框的图片(没贴是空的,`base64` 不带 `data:` 前缀)。扩展端按最近一次发出去的块树和源码找这块、拼问题,这块存过问答就接着最近那条的会话追问(见「问 AI」的追问),用扩展端记着的 profile;找不到(代码刚改过)、图片格式不认或问不了(这块正在答、同时答的太多)时直接回 `answerEnd` failed。 - `{ type: 'stopAsk', askId }`:叫停,结束 claude 进程,这次不存。 - `{ type: 'deleteQa', anchor, entryId }`:删掉一次问答。 - `{ type: 'selectAskProfile', id }`:「问 AI」换 profile;扩展端记进 `globalState` 后发 `askProfiles` 给所有预览。webview 不在本地先改,只按扩展端发来的显示。 @@ -1007,8 +1013,8 @@ vscode/src/codeReader/ explorer/fileTree.ts 侧栏文件树(FileTreeDecorator:给行添信息的接口) explorer/copyText.ts 复制菜单的命令和复制出什么(纯函数) explorer/copyCommands.ts 复制命令:写剪贴板、状态栏提示 - pathMap.ts 按文件 / 目录记东西的表(文件注释、问答共用):路径的键、改名 / 挪位置 / 删除时怎么改、.spx 里 JSON 的读写(不碰 VS Code) - folderStore.ts FolderStore:每个工作区文件夹一份表,内存里的一份、文件监听、跟着改名 / 删除走、读写排队 + pathMap.ts 按文件 / 目录记东西的表(文件注释、问答共用):路径的键、改名 / 挪位置 / 删除时怎么改、.spx 里 JSON 的读写、条目带着的文件哪些不再有人带、删文件(不碰 VS Code) + folderStore.ts FolderStore:每个工作区文件夹一份表,内存里的一份、文件监听、跟着改名 / 删除走、读写排队、改表后删掉不再有条目带着的文件 notes/noteMap.ts 保存一条注释(纯函数) notes/store.ts .spx/code-notes.json 叫什么、每条怎么认 notes/noteItem.ts 有注释的行怎么显示:描述、悬停提示、contextValue(纯函数) @@ -1018,11 +1024,12 @@ vscode/src/codeReader/ qa/qaMap.ts .spx/code-qa.json 的形状、每条怎么认、记一次 / 删一次、再问时接着哪个会话(纯函数) qa/prompt.ts 问 AI 的话:找到那块、外层块的开头、带行号的代码、追问 qa/answerStream.ts stream-json 的每行 → 会话 id、逐字的回答、在做的事、最终回答 / 失败 - qa/claudeAsk.ts 命令行参数(只读、选的 profile)、问一次(追问找不到会话时重新问)、失败给人看的话 + qa/claudeAsk.ts 命令行参数(只读、选的 profile)、问一次(问题连同图片从 stdin 发,追问找不到会话时重新问)、失败给人看的话 + qa/qaImages.ts 问题带的图片:认哪些格式、存到 .spx/code-qa/ 叫什么名字、写文件(不碰 VS Code) qa/askProfiles.ts AskProfiles:问 AI 用哪个 profile,下拉的选项、记在 globalState、通知所有预览(不碰 VS Code) qa/asks.ts QaAsks:正在答的问题,一块一个、最多 3 个、能叫停(不碰 VS Code) - qa/codeQa.ts CodeQa:问答的 FolderStore、开问(问过的块接着最近的会话,用选的 profile)、答完存(带 profile 名)、删,发给预览的问答 - (cc/spawnClaude.ts 的 runClaudeLines:跑 claude、stdout 逐行交出、超时、叫停,问 AI 用它) + qa/codeQa.ts CodeQa:问答的 FolderStore、开问(问过的块接着最近的会话,用选的 profile,带贴的图)、答完存(带 profile 名、图片)、删,发给预览的问答、图片目录 + (cc/spawnClaude.ts 的 runClaudeLines:跑 claude、给了 input 就写进 stdin、stdout 逐行交出、超时、叫停,问 AI 用它;buildStreamJsonLine 拼 stream-json 的一条用户消息) editor/previewEditor.ts CustomTextEditorProvider editor/messages.ts 消息类型 editor/blockFilter.ts 块筛选:workspaceState 读写、通知所有预览 @@ -1031,7 +1038,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,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、收发在 useQa.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、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts) ``` ## 测试 @@ -1065,14 +1072,16 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、`title` 和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。 - 问 AI: - 位置记号(`qa/anchor.test.ts`,真实的 Python 解析):有名字 / 没名字的块的记号、同一层重复的编号、两个函数里一样的条件互不影响;上面插几行、改空白记号不变;改别的函数的名字只动它和它里面的块;没名字的块自己的源码改了记号变、有名字的块改签名不变。 - - 存法(`qa/qaMap.test.ts`):记一次、删一次、删光不挂 / 不记;文件缺失、解析失败、不是对象当没有;认不出的块和问答跳过、没会话 id 的照认;再问时接着这块最近那条的会话(删了最近的接着剩下里最近的,没问过、没记会话 id 的重新开);用哪个 profile 答的跟着这条存、读得回来,没有的、空的、不是字符串的当没有;手改的顺序、重复的块读进来整理好;写出来排好序、两格缩进、读得回来、删光留 `{}`;改名、挪目录、删除跟着走。 + - 存法(`qa/qaMap.test.ts`):记一次、删一次、删光不挂 / 不记;文件缺失、解析失败、不是对象当没有;认不出的块和问答跳过、没会话 id 的照认;再问时接着这块最近那条的会话(删了最近的接着剩下里最近的,没问过、没记会话 id 的重新开);用哪个 profile 答的跟着这条存、读得回来,没有的、空的、不是字符串的当没有;手改的顺序、重复的块读进来整理好;写出来排好序、两格缩进、读得回来、删光留 `{}`;改名、挪目录、删除跟着走。图片:文件名跟着这条存、读得回来,跑到别处的路径、不是字符串的名字跳过;删一次问答它的图片跟着删、别的留着;源文件删了图片跟着删,改名、挪目录图片不动。 + - 图片文件(`qa/qaImages.test.ts`):认的四种格式;写进 `.spx/code-qa/` 的名字和内容;没带图片不建目录;只认这边起的名字。条目带着的文件(`pathMap.test.ts`):改表后没有哪条再带着的才删、另一条还带着的留着;删文件时已经没了的不算错、跑到工作区文件夹外面的不碰。 - 问的话(`qa/prompt.test.ts`):完整问题逐字(要求、文件、外层块的开头、带行号的代码、问题),顶层块没有外层、函数连主体一起贴,很长的块只贴开头、外层块每层最多几行,找不到这块没有,追问的话。 - - 命令行(`qa/claudeAsk.test.ts`,假的 claude):只读的参数、没有跳过权限、追问带 `--resume`;问一次的过程和结果、在工作区文件夹里跑、带超时和叫停、带选的 profile 的 `--settings`(「默认」不带);追问接着会话(换了 profile 也带 `--resume` 和新的 `--settings`)、会话找不到时按完整问题重问(照样带选的 profile)、别的失败不重试;失败给人看的话。 + - 命令行(`qa/claudeAsk.test.ts`,假的 claude):只读的参数、没有跳过权限、问题从 stdin 按 stream-json 进、追问带 `--resume`;stdin 的那条消息是文字加按先后的图片,没贴图只有文字、问题不上命令行,追问和会话找不到重问都带图;问一次的过程和结果、在工作区文件夹里跑、带超时和叫停、带选的 profile 的 `--settings`(「默认」不带);追问接着会话(换了 profile 也带 `--resume` 和新的 `--settings`)、会话找不到时按完整问题重问(照样带选的 profile)、别的失败不重试;失败给人看的话。 - profile 下拉(`qa/askProfiles.test.ts`):profile 列表 → 选项(默认 profile 在不在里面、一个都没有只剩「默认」)、没选过选哪个、选过的不在了回到默认;选了记进 `globalState`、新的实例读得回来、所有订阅的预览收到;选的那个的路径进命令行的 `--settings`,「默认」不带。 - 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 次」、展开的历史 / 当前这一轮 / 输入框、出错和停止;profile 下拉在输入框和发送按钮中间、写选中的名字、是能用键盘打开的弹出列表按钮、没收到选项时写「默认」,扩展端发来的下拉问答刷新不丢;历史每条的灰字写「时间 · profile 名」,没有 profile 的只写时间。(webview 的单元测试认 `@/`,靠 `vscode/vitest.config.mts` 里同 Vite 的别名。) + - 跑 claude(`cc/spawnClaude.test.ts`,假的 claude 脚本):逐行交出、最后没换行的那段、参数和 cwd、给了 input 写进 stdin 再关上 / 不给时 stdin 是空的不卡住、退出码非零带原话、叫停马上结束进程、超时。 + - 页面(`qa.test.ts`、`AskThread.test.tsx`):问一次、答完记下存成的那条、收起结束这一轮、重新加载接上还在答的、停止和失败、不认识的消息不理;问的时间、回答的前两行;问答区没问过不画、没展开只露「问过 N 次」、展开的历史 / 当前这一轮 / 输入框、出错和停止;profile 下拉在输入框和发送按钮中间、写选中的名字、是能用键盘打开的弹出列表按钮、没收到选项时写「默认」,扩展端发来的下拉问答刷新不丢;历史每条的灰字写「时间 · profile 名」,没有 profile 的只写时间;带图的历史和当前这一轮在问题下面、回答前面一排缩略图(能点开看大图、不能去掉),没带图的不画;这一问带的图片答完、失败、停止都还在,重新加载接上的没有。贴图拖图的规则(`qaImages.test.ts`):剪贴板里只取文件、文字不算;四种格式、5 MB(正好 5 MB 的算)、连同已有最多 6 张,没加上的按原因合成一句提示;发出去的 base64 去掉 `data:` 前缀。(webview 的单元测试认 `@/`,靠 `vscode/vitest.config.mts` 里同 Vite 的别名。) + - 页面上贴图(无头 Chrome,模拟粘贴、拖放、按键):粘贴图片加缩略图且不拦文字、只粘文字不加图;太大、不是图片、超过张数不加并提示;✕ 去掉后光标回到输入框;点缩略图出大图、焦点在「关闭」、Esc 只关大图不收起问答区、焦点回到缩略图,点一下也关;拖进来的图加上;Shift+Enter 不发送、Enter 发送带上图片、发送后缩略图清空、这一问下面显示它带的图;输入框里 Esc 照旧收起。亮、暗两套颜色都看过。 ## 收尾 diff --git a/vscode/src/cc/spawnClaude.test.ts b/vscode/src/cc/spawnClaude.test.ts index 262405d..56b7aa1 100644 --- a/vscode/src/cc/spawnClaude.test.ts +++ b/vscode/src/cc/spawnClaude.test.ts @@ -29,6 +29,16 @@ describe('跑 claude、逐行交出 stdout', () => { expect(lines).toEqual(['{"a":1}', '{"b":2}', await fsp.realpath(dir), '-p|带 空格 的问题|']) }) + it('给了 input 就写进 stdin 再关上(stream-json 的消息);不给时 stdin 是空的,读 stdin 的也不会卡住', async () => { + const bin = await fakeBin('cat; echo end') + const withInput: string[] = [] + await runClaudeLines({ bin, args: [], cwd: dir, input: '{"type":"user"}\n', onLine: item => withInput.push(item) }) + expect(withInput).toEqual(['{"type":"user"}', 'end']) + const without: string[] = [] + await runClaudeLines({ bin, args: [], cwd: dir, timeoutMs: 2000, onLine: item => without.push(item) }) + expect(without).toEqual(['end']) + }) + 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) diff --git a/vscode/src/cc/spawnClaude.ts b/vscode/src/cc/spawnClaude.ts index ce9ddf6..2295cf3 100644 --- a/vscode/src/cc/spawnClaude.ts +++ b/vscode/src/cc/spawnClaude.ts @@ -222,7 +222,8 @@ function createProgressFeeder(onProgress?: ClaudeProgressListener): (chunk: stri }).feed } -function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string { +/** `--input-format stream-json` 的一条用户消息(一行 NDJSON):先是文字,后面跟着各张图片。 */ +export function buildStreamJsonLine(prompt: string, images: readonly ClaudeImage[]): string { const content: Array> = [ { type: 'text', text: prompt }, ] @@ -566,6 +567,8 @@ function spawnClaudeStreamed( export function runClaudeLines(opts: { args: string[] cwd: string + /** 写进 stdin 后关上(`--input-format stream-json` 的消息,见 buildStreamJsonLine);不给时 stdin 是空的 */ + input?: string onLine: (line: string) => void timeoutMs?: number signal?: AbortSignal @@ -589,8 +592,11 @@ export function runClaudeLines(opts: { const child = spawn(bin, opts.args, { cwd: opts.cwd, env: buildClaudeChildEnv(bin), - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['pipe', 'pipe', 'pipe'], }) + // 进程先退了(参数不对)时写不进去:真正的原因由退出码和 stderr 报 + child.stdin.on('error', () => {}) + child.stdin.end(opts.input ?? '') const timer = setTimeout(() => { child.kill('SIGKILL') diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index df4b53f..434304d 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -2,6 +2,7 @@ * 代码阅读预览与扩展之间的消息,与 webview 端 `webview-ui/src/codeReader/types.ts` 同构,两边手动保持一致。 */ +import type { ClaudeImage } from '../../cc/spawnClaude' import type { FileAttribute } from '../attributes' import type { BlockView } from '../blockView' import type { ProfileChoice } from '../qa/askProfiles' @@ -54,6 +55,8 @@ export interface QaEntryView { createdAt: string /** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */ profile?: string + /** 问题带的图片,预览里能直接用的地址(webview.asWebviewUri);没带图片时没有 */ + images?: string[] } /** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示。 */ @@ -102,7 +105,8 @@ export type ExtensionToWebview * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 * findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition * setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览 - * ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile + * ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile; + * images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存 * stopAsk:叫停 askId 那个问题,这次不存 * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) * selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览 @@ -116,7 +120,7 @@ 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 } + | { type: 'ask', askId: string, anchor: string, question: string, images: ClaudeImage[] } | { type: 'stopAsk', askId: string } | { type: 'deleteQa', anchor: string, entryId: string } | { type: 'selectAskProfile', id: string } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 7aea27a..135e483 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,4 +1,5 @@ import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode' +import type { ClaudeImage } from '../../cc/spawnClaude' import type { BlockView } from '../blockView' import type { RuleLocation, RuleReference } from '../contract' import type { CodeQa } from '../qa/codeQa' @@ -45,7 +46,9 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { ) {} resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { - panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri)] } + // 问答带的图片(工作区文件夹的 .spx/code-qa/)也要能读,历史里才画得出缩略图 + const imageRoot = this.qa.imageRoot(document.uri) + panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : [])] } panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html') const key = document.uri.toString() const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, this.reveals.get(key)) @@ -219,7 +222,7 @@ class Preview { private postQa(): void { if (this.ready) - this.post(this.owner.qa.view(this.document.uri)) + this.post(this.owner.qa.view(this.document.uri, file => this.panel.webview.asWebviewUri(file).toString())) } /** profile 列表每次现读(同看板),读完页面还在才发。 */ @@ -233,14 +236,14 @@ class Preview { * 问 anchor 那块代码:按最近一次发出去的块树找到它,把完整问题和追问的话都交给 CodeQa,接不接着上次的会话由它定。 * 找不到这块(代码刚改过、还没重新发)或问不了时,直接回一条失败。 */ - private async ask(askId: string, anchor: string, question: string): Promise { + private async ask(askId: string, anchor: string, question: string, images: ClaudeImage[]): Promise { 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 && followUpPrompt(subject, question) const refused = prompt === undefined || followUp === undefined ? '这块代码刚改过,找不到了。等预览刷新后重新点「问 AI」再问。' - : await this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, followUp }) + : await this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, followUp, images }) if (refused !== undefined) { this.post({ type: 'answerEnd', askId, status: 'failed', error: refused }) return @@ -264,7 +267,7 @@ class Preview { await this.push() return case 'ask': - await this.ask(msg.askId, msg.anchor, msg.question) + await this.ask(msg.askId, msg.anchor, msg.question, msg.images) return case 'selectAskProfile': await this.owner.qa.profiles.select(msg.id) diff --git a/vscode/src/codeReader/folderStore.ts b/vscode/src/codeReader/folderStore.ts index ccbdef0..5c8b947 100644 --- a/vscode/src/codeReader/folderStore.ts +++ b/vscode/src/codeReader/folderStore.ts @@ -1,7 +1,7 @@ import type { FileDeleteEvent, FileRenameEvent, WorkspaceFolder } from 'vscode' import type { PathMap, PathMapFile } from './pathMap' import { Disposable, EventEmitter, RelativePattern, Uri, window, workspace } from 'vscode' -import { changedKeys, dropEntries, entriesUnder, moveEntries, placeEntries, readPathMap, toPathKey, writePathMap } from './pathMap' +import { changedKeys, detachedFiles, dropEntries, entriesUnder, moveEntries, placeEntries, readPathMap, removeFiles, toPathKey, writePathMap } from './pathMap' /** 一个文件 / 目录在哪个工作区文件夹里、键是什么。 */ export interface PathLocation { @@ -50,7 +50,10 @@ export class FolderStore implements Disposable { return this.maps.get(folder.uri.toString())?.[key] } - /** 读盘上最新的一份改好再写回;没变就不写,所以第一条存下之前不会生成文件。 */ + /** + * 读盘上最新的一份改好再写回;没变就不写,所以第一条存下之前不会生成文件。 + * 写好后删掉不再有条目带着的文件(见 PathMapFile.attachments):删一条、删源文件都跟着删。 + */ update(folder: WorkspaceFolder, change: (map: PathMap) => PathMap): Promise { return this.enqueue(async () => { const before = await readPathMap(folder.uri.fsPath, this.file) @@ -59,6 +62,7 @@ export class FolderStore implements Disposable { return await writePathMap(folder.uri.fsPath, this.file, after) this.apply(folder, after) + await removeFiles(folder.uri.fsPath, detachedFiles(this.file, before, after)) }) } diff --git a/vscode/src/codeReader/pathMap.test.ts b/vscode/src/codeReader/pathMap.test.ts index 0de80e3..fa29a20 100644 --- a/vscode/src/codeReader/pathMap.test.ts +++ b/vscode/src/codeReader/pathMap.test.ts @@ -1,5 +1,9 @@ +import type { PathMapFile } from './pathMap' +import { promises as fsp } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' import { describe, expect, it } from 'vitest' -import { changedKeys, dropEntries, entriesUnder, moveEntries, normalizeKey, placeEntries, toPathKey } from './pathMap' +import { changedKeys, detachedFiles, dropEntries, entriesUnder, moveEntries, normalizeKey, placeEntries, removeFiles, toPathKey } from './pathMap' describe('路径的键', () => { it('相对工作区文件夹、用 / 分隔', () => { @@ -92,3 +96,34 @@ describe('变了哪些行', () => { expect(changedKeys({ a: '1' }, { a: '1' })).toEqual([]) }) }) + +describe('条目带着的文件', () => { + const file: PathMapFile = { path: '.spx/x.json', label: 'x', parse: () => undefined, attachments: files => files } + + it('改表后没有哪条再带着的才删:删掉的条目、改掉的引用;别的条目还带着同一个文件的留着', () => { + const before = { 'a.py': ['.spx/f/1.png', '.spx/f/2.png'], 'b.py': ['.spx/f/3.png', '.spx/f/2.png'] } + expect(detachedFiles(file, before, { 'b.py': ['.spx/f/3.png', '.spx/f/2.png'] })).toEqual(['.spx/f/1.png']) + expect(detachedFiles(file, before, { 'a.py': ['.spx/f/1.png'] }).sort()).toEqual(['.spx/f/2.png', '.spx/f/3.png']) + expect(detachedFiles(file, before, before)).toEqual([]) + }) + + it('不带文件的表什么都不删', () => { + expect(detachedFiles({ ...file, attachments: undefined }, { a: ['x'] }, {})).toEqual([]) + }) + + it('删文件:已经没了的不算错,跑到工作区文件夹外面的路径不碰', async () => { + const parent = await fsp.mkdtemp(path.join(os.tmpdir(), 'path-map-')) + try { + const root = path.join(parent, 'work') + await fsp.mkdir(path.join(root, '.spx', 'f'), { recursive: true }) + await fsp.writeFile(path.join(root, '.spx', 'f', '1.png'), '') + await fsp.writeFile(path.join(parent, 'outside.png'), '') + await removeFiles(root, ['.spx/f/1.png', '.spx/f/missing.png', '../outside.png', path.join(parent, 'outside.png')]) + await expect(fsp.access(path.join(root, '.spx', 'f', '1.png'))).rejects.toThrow() + await expect(fsp.access(path.join(parent, 'outside.png'))).resolves.toBeUndefined() + } + finally { + await fsp.rm(parent, { recursive: true, force: true }) + } + }) +}) diff --git a/vscode/src/codeReader/pathMap.ts b/vscode/src/codeReader/pathMap.ts index 79bfdc5..cfbbc98 100644 --- a/vscode/src/codeReader/pathMap.ts +++ b/vscode/src/codeReader/pathMap.ts @@ -16,6 +16,8 @@ export interface PathMapFile { label: string /** 认一条的值:不合法的返回 undefined,这条就跳过;合法的可以顺手规范成统一写法 */ parse: (value: unknown) => T | undefined + /** 一条值带着的文件(相对工作区文件夹,`/` 分隔,例如问答的图片):改表后不再有哪条带着的,跟着删掉 */ + attachments?: (value: T) => string[] } /** 规范成表的键:去掉 `./`、重复和结尾的 `/`;工作区文件夹本身、文件夹外面的路径没有键。 */ @@ -62,7 +64,7 @@ export function changedKeys(before: PathMap, after: PathMap): string[] return [...keys].filter(key => JSON.stringify(before[key]) !== JSON.stringify(after[key])) } -function fileIn(workspaceRoot: string, file: PathMapFile): string { +function fileIn(workspaceRoot: string, file: PathMapFile): string { return path.join(workspaceRoot, ...file.path.split('/')) } @@ -100,3 +102,20 @@ export async function writePathMap(workspaceRoot: string, file: PathMapFile a < b ? -1 : a > b ? 1 : 0)) await fsp.writeFile(target, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8') } + +/** 改表前带着、改表后没有哪条再带着的文件(见 PathMapFile.attachments)。 */ +export function detachedFiles(file: PathMapFile, before: PathMap, after: PathMap): string[] { + const { attachments } = file + if (!attachments) + return [] + const kept = new Set(Object.values(after).flatMap(attachments)) + return [...new Set(Object.values(before).flatMap(attachments))].filter(attachment => !kept.has(attachment)) +} + +/** 删掉工作区文件夹里的这些文件(相对路径);已经没了的不算错,跑到文件夹外面的路径不碰。 */ +export async function removeFiles(workspaceRoot: string, relativePaths: readonly string[]): Promise { + await Promise.all(relativePaths.flatMap((relativePath) => { + const key = normalizeKey(relativePath) + return key === undefined ? [] : [fsp.rm(path.join(workspaceRoot, ...key.split('/')), { force: true })] + })) +} diff --git a/vscode/src/codeReader/qa/askProfiles.test.ts b/vscode/src/codeReader/qa/askProfiles.test.ts index 4064dd0..3689874 100644 --- a/vscode/src/codeReader/qa/askProfiles.test.ts +++ b/vscode/src/codeReader/qa/askProfiles.test.ts @@ -80,11 +80,11 @@ describe('记住最近选的 profile', () => { const profiles = new AskProfiles(memento({ [ASK_PROFILE_KEY]: 'sonnet' }), source()) const chosen = await profiles.current() expect(chosen).toEqual(profile('sonnet')) - expect(askArgs('问题', { profilePath: chosen?.path }).slice(0, 2)).toEqual(['--settings', `${DIR}/sonnet.json`]) + expect(askArgs({ profilePath: chosen?.path }).slice(0, 2)).toEqual(['--settings', `${DIR}/sonnet.json`]) const none = await new AskProfiles(memento(), source([])).current() expect(none).toBeUndefined() - expect(askArgs('问题', { profilePath: none?.path })).not.toContain('--settings') + expect(askArgs({ profilePath: none?.path })).not.toContain('--settings') }) it('没选过:用插件的默认 profile(和以前问 AI 用的一样)', async () => { diff --git a/vscode/src/codeReader/qa/asks.test.ts b/vscode/src/codeReader/qa/asks.test.ts index 9063510..9459648 100644 --- a/vscode/src/codeReader/qa/asks.test.ts +++ b/vscode/src/codeReader/qa/asks.test.ts @@ -17,7 +17,7 @@ function fakeRunner(): { runner: (run: AskRun) => Promise, runs: (AskRun } function start(askId: string, anchor = askId, owner = 'file:///a.py'): AskStart { - return { owner, askId, anchor, question: `问 ${askId}`, run: { prompt: `完整的 ${askId}`, cwd: '/work' } } + return { owner, askId, anchor, question: `问 ${askId}`, run: { prompt: `完整的 ${askId}`, images: [], cwd: '/work' } } } function listener(): { updates: RunningAsk[], ends: [RunningAsk, AskEnd][], ended: Promise, update: (ask: RunningAsk) => void, end: (ask: RunningAsk, end: AskEnd) => void } { diff --git a/vscode/src/codeReader/qa/claudeAsk.test.ts b/vscode/src/codeReader/qa/claudeAsk.test.ts index 36f84b4..67b07f2 100644 --- a/vscode/src/codeReader/qa/claudeAsk.test.ts +++ b/vscode/src/codeReader/qa/claudeAsk.test.ts @@ -30,12 +30,26 @@ function fakeClaude(runs: { lines: string[], error?: Error }[]): { deps: AskDeps 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 } + return { prompt: '完整的问题', images: [], cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides } +} + +const PNG = { mediaType: 'image/png', base64: 'iVBORw0KGgo=' } +const JPEG = { mediaType: 'image/jpeg', base64: '/9j/4AAQ' } + +/** 从 stdin 发出去的那条消息 */ +function sent(call: RunLinesOptions): { text: string, images: unknown[] } { + expect(call.input?.endsWith('\n')).toBe(true) + const message = JSON.parse(call.input ?? '') as { type: string, message: { role: string, content: { type: string, text?: string, source?: unknown }[] } } + expect(message.type).toBe('user') + expect(message.message.role).toBe('user') + const [first, ...rest] = message.message.content + expect(first.type).toBe('text') + return { text: first.text ?? '', images: rest } } describe('命令行参数', () => { - it('只读:只给读、搜的工具,别的一律拒、不等人批,不加载 MCP;不带跳过权限;输出逐字的 stream-json', () => { - const args = askArgs('问题', { profilePath: '/p/offical.json' }) + it('只读:只给读、搜的工具,别的一律拒、不等人批,不加载 MCP;不带跳过权限;问题从 stdin 按 stream-json 进,输出逐字的 stream-json', () => { + const args = askArgs({ profilePath: '/p/offical.json' }) expect(args).toEqual([ '--settings', '/p/offical.json', @@ -47,7 +61,8 @@ describe('命令行参数', () => { 'dontAsk', '--strict-mcp-config', '-p', - '问题', + '--input-format', + 'stream-json', '--output-format', 'stream-json', '--verbose', @@ -56,10 +71,41 @@ describe('命令行参数', () => { expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|Edit|Write|Bash/) }) - it('追问带 --resume,放在问题前面;没有 profile 就不带 --settings', () => { - const args = askArgs('追问', { resume: 'session-1' }) + it('追问带 --resume,放在 -p 前面;没有 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', '追问']) + expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 'session-1', '-p']) + }) +}) + +describe('问题连同图片从 stdin 发', () => { + it('一条用户消息:先是问题的文字,后面按贴的先后跟着图片(base64 和格式原样)', async () => { + const { deps, calls } = fakeClaude([{ lines: [result('答案', 's1')] }]) + await askClaude(request({ images: [PNG, JPEG] }), deps) + expect(sent(calls[0])).toEqual({ + text: '完整的问题', + images: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } }, + { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: '/9j/4AAQ' } }, + ], + }) + }) + + it('没贴图:只有问题的文字,照样走 stdin', async () => { + const { deps, calls } = fakeClaude([{ lines: [result('答案', 's1')] }]) + await askClaude(request(), deps) + expect(sent(calls[0])).toEqual({ text: '完整的问题', images: [] }) + expect(calls[0].args).not.toContain('完整的问题') + }) + + 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: [result('重新答', 's2')] }, + ]) + await askClaude(request({ images: [PNG], followUp: { sessionId: 's0', prompt: '短问题' } }), deps) + expect(sent(calls[0])).toMatchObject({ text: '短问题', images: [{ source: { data: PNG.base64 } }] }) + expect(sent(calls[1])).toMatchObject({ text: '完整的问题', images: [{ source: { data: PNG.base64 } }] }) }) }) @@ -72,7 +118,7 @@ describe('问一次', () => { expect(calls).toHaveLength(1) expect(calls[0]).toMatchObject({ cwd: '/work', signal: run.signal, timeoutMs: 300_000 }) expect(calls[0].args.slice(0, 2)).toEqual(['--settings', '/profiles/sonnet.json']) - expect(calls[0].args[calls[0].args.indexOf('-p') + 1]).toBe('完整的问题') + expect(sent(calls[0]).text).toBe('完整的问题') }) it('选的是「默认」(没给 profile):不带 --settings', async () => { @@ -85,7 +131,8 @@ describe('问一次', () => { const { deps, calls } = fakeClaude([{ lines: [result('接着答', 's1')] }]) expect(await askClaude(request({ profilePath: '/profiles/opus.json', followUp: { sessionId: 's1', prompt: '短问题' } }), deps)).toEqual({ text: '接着答', sessionId: 's1' }) expect(calls[0].args.slice(0, 2)).toEqual(['--settings', '/profiles/opus.json']) - expect(calls[0].args.slice(calls[0].args.indexOf('--resume'), calls[0].args.indexOf('--resume') + 4)).toEqual(['--resume', 's1', '-p', '短问题']) + expect(calls[0].args.slice(calls[0].args.indexOf('--resume'), calls[0].args.indexOf('--resume') + 2)).toEqual(['--resume', 's1']) + expect(sent(calls[0]).text).toBe('短问题') }) it('上次的会话找不到了:按完整问题重新开一次', async () => { @@ -97,7 +144,7 @@ describe('问一次', () => { expect(calls).toHaveLength(2) expect(calls[1].args).not.toContain('--resume') expect(calls[1].args.slice(0, 2)).toEqual(['--settings', '/profiles/opus.json']) - expect(calls[1].args[calls[1].args.indexOf('-p') + 1]).toBe('完整的问题') + expect(sent(calls[1]).text).toBe('完整的问题') }) it('别的失败不重试,照样抛出来;Claude 报的失败、没给回答也算失败', async () => { diff --git a/vscode/src/codeReader/qa/claudeAsk.ts b/vscode/src/codeReader/qa/claudeAsk.ts index 57da6c2..eae71ac 100644 --- a/vscode/src/codeReader/qa/claudeAsk.ts +++ b/vscode/src/codeReader/qa/claudeAsk.ts @@ -3,8 +3,9 @@ * 用问的时候选的 profile(见 askProfiles);回答按 stream-json 逐字交出来,可以叫停。 */ +import type { ClaudeImage } from '../../cc/spawnClaude' import type { AnswerEvent } from './answerStream' -import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude' +import { buildStreamJsonLine, CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude' import { createAnswerParser } from './answerStream' /** 只给读、搜文件的工具。 */ @@ -13,11 +14,11 @@ const READ_ONLY_TOOLS = 'Read,Grep,Glob' export const ASK_TIMEOUT_MS = 5 * 60_000 /** - * 一次提问的命令行参数。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`), + * 一次提问的命令行参数;问题连同贴的图片从 stdin 按 stream-json 发(见 askClaude),没贴图也一样。只读靠三层:可用的内置工具只剩读、搜(`--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[] { +export function askArgs(options: { profilePath?: string, resume?: string } = {}): string[] { return [ ...(options.profilePath ? ['--settings', options.profilePath] : []), '--tools', @@ -29,7 +30,8 @@ export function askArgs(prompt: string, options: { profilePath?: string, resume? '--strict-mcp-config', ...(options.resume ? ['--resume', options.resume] : []), '-p', - prompt, + '--input-format', + 'stream-json', '--output-format', 'stream-json', '--verbose', @@ -40,6 +42,8 @@ export function askArgs(prompt: string, options: { profilePath?: string, resume? export interface AskRun { /** 带着代码上下文的完整问题 */ prompt: string + /** 问题带的图片(贴进输入框的),追问、找不到会话重新问时一样带上 */ + images: ClaudeImage[] /** 用这个 profile 的设置(`--settings`);没有就不带,claude 用 ~/.claude 的登录。追问换了 profile 照样接着会话问 */ profilePath?: string /** 追问:接着 sessionId 那次会话问 prompt;会话找不到了(过期、被清理)就按完整问题重新开一次 */ @@ -74,7 +78,8 @@ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Prom let answer: Answer | undefined let failure: string | undefined await deps.runLines({ - args: askArgs(prompt, { profilePath: run.profilePath, resume }), + args: askArgs({ profilePath: run.profilePath, resume }), + input: buildStreamJsonLine(prompt, run.images), cwd: run.cwd, signal: run.signal, timeoutMs: ASK_TIMEOUT_MS, diff --git a/vscode/src/codeReader/qa/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts index 4c62ecb..df9f818 100644 --- a/vscode/src/codeReader/qa/codeQa.ts +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -1,14 +1,16 @@ -import type { Disposable, Memento, Uri } from 'vscode' +import type { Disposable, Memento } from 'vscode' +import type { ClaudeImage } from '../../cc/spawnClaude' 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 { EventEmitter, Uri } from 'vscode' import { FolderStore, locate } from '../folderStore' import { AskProfiles } from './askProfiles' import { QaAsks } from './asks' import { askClaude } from './claudeAsk' +import { isQaImageType, QA_IMAGE_DIR, writeQaImages } from './qaImages' import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap' /** 正在答的问题有了进展(update),或答完了(end;答成了带存下的那条的 id)。owner 是问的那份文件的 uri。 */ @@ -23,6 +25,8 @@ export interface AskRequest { question: string prompt: string followUp: string + /** 贴进输入框的图片,随问题一起发;答成了存进 `.spx/code-qa/` */ + images: ClaudeImage[] } /** @@ -44,16 +48,33 @@ export class CodeQa implements Disposable { this.asks = new QaAsks(runner) } - /** 发给预览的这份文件的问答;文件不在工作区文件夹里时没地方存,问 AI 不可用。 */ - view(uri: Uri): QaMessage { + /** uri 这份文件的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);文件不在工作区文件夹里时没有。 */ + imageRoot(uri: Uri): Uri | undefined { + const at = locate(uri) + return at && Uri.joinPath(at.folder.uri, ...QA_IMAGE_DIR.split('/')) + } + + /** + * 发给预览的这份文件的问答;文件不在工作区文件夹里时没地方存,问 AI 不可用。 + * 问题带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。 + */ + view(uri: Uri, imageUrl: (file: Uri) => string): QaMessage { const at = locate(uri) const threads = at ? this.store.get(at.folder, at.key) ?? [] : [] + const root = this.imageRoot(uri) return { type: 'qa', available: at !== undefined, threads: threads.map(({ anchor, entries }) => ({ anchor, - entries: entries.map(({ id, question, answer, createdAt, profile }) => ({ id, question, answer, createdAt, ...(profile && { profile }) })), + entries: entries.map(({ id, question, answer, createdAt, profile, images }) => ({ + id, + question, + answer, + createdAt, + ...(profile && { profile }), + ...(root && images && { images: images.map(name => imageUrl(Uri.joinPath(root, name))) }), + })), })), running: this.asks.runningFor(uri.toString()), } @@ -63,17 +84,21 @@ export class CodeQa implements Disposable { * 开始问;问不了时返回原因(给人看的话)。在工作区文件夹里跑 claude,它读得到整个项目。 * 这块问过的就接着最近一次的会话问(收起再展开、重启 VS Code 都一样),没问过就带上代码上下文开新会话。 * 用眼下选的 profile;和那次会话开头用的不一样也照样接着问(`--resume` 和 `--settings` 能一起用)。 + * 带的图片随问题发,答成了才写进 `.spx/code-qa/`、记在这条上;没答成不留文件。 */ async ask(uri: Uri, request: AskRequest): Promise { const at = locate(uri) if (!at) return '这个文件不在工作区文件夹里,问答没地方存。' + const { askId, anchor, question, prompt, followUp, images } = request + if (!images.every(image => isQaImageType(image.mediaType))) + return '只能带 PNG、JPEG、GIF、WebP 图片。' const profile = await this.profiles.current() const owner = uri.toString() - const { askId, anchor, question, prompt, followUp } = request const sessionId = latestSession(this.store.get(at.folder, at.key), anchor) const run = { prompt, + images, ...(sessionId && { followUp: { sessionId, prompt: followUp } }), ...(profile && { profilePath: profile.path }), cwd: at.folder.uri.fsPath, @@ -85,13 +110,24 @@ export class CodeQa implements Disposable { this.answered.fire({ owner, kind: 'end', ask, end }) return } + const id = randomUUID() + let names: string[] + try { + names = await writeQaImages(at.folder.uri.fsPath, id, images) + } + catch (err) { + const error = `回答没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})` + this.answered.fire({ owner, kind: 'end', ask, end: { status: 'failed', error } }) + return + } const entry: QaEntry = { - id: randomUUID(), + id, question, answer: end.answer.text, createdAt: new Date().toISOString(), sessionId: end.answer.sessionId, ...(profile && { profile: profile.name }), + ...(names.length > 0 && { images: names }), } await this.store.update(at.folder, map => withEntry(map, at.key, anchor, entry)) this.answered.fire({ owner, kind: 'end', ask, end, entryId: entry.id }) @@ -103,7 +139,7 @@ export class CodeQa implements Disposable { this.asks.stop(askId) } - /** 删掉一次问答,不用确认;块下面删光了这块就不挂了。 */ + /** 删掉一次问答,不用确认;块下面删光了这块就不挂了,带的图片跟着删(见 QA_FILE.attachments)。 */ async delete(uri: Uri, anchor: string, entryId: string): Promise { const at = locate(uri) if (at) diff --git a/vscode/src/codeReader/qa/qaImages.test.ts b/vscode/src/codeReader/qa/qaImages.test.ts new file mode 100644 index 0000000..ff82cac --- /dev/null +++ b/vscode/src/codeReader/qa/qaImages.test.ts @@ -0,0 +1,49 @@ +import { Buffer } from 'node:buffer' +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 { isQaImageName, isQaImageType, writeQaImages } from './qaImages' + +let root: string + +beforeEach(async () => { + root = await fsp.mkdtemp(path.join(os.tmpdir(), 'code-qa-images-')) +}) + +afterEach(async () => { + await fsp.rm(root, { recursive: true, force: true }) +}) + +describe('问题带的图片', () => { + it('认 PNG、JPEG、GIF、WebP,别的不认', () => { + for (const type of ['image/png', 'image/jpeg', 'image/gif', 'image/webp']) + expect(isQaImageType(type)).toBe(true) + for (const type of ['image/svg+xml', 'image/bmp', 'text/plain', '', 'toString']) + expect(isQaImageType(type)).toBe(false) + }) + + it('写进 .spx/code-qa/:<问答 id>-<第几张>.<扩展名>,内容是 base64 解出来的原样', async () => { + const png = Buffer.from([0x89, 0x50, 0x4E, 0x47]) + const jpeg = Buffer.from([0xFF, 0xD8, 0xFF]) + const names = await writeQaImages(root, 'e1', [ + { mediaType: 'image/png', base64: png.toString('base64') }, + { mediaType: 'image/jpeg', base64: jpeg.toString('base64') }, + ]) + expect(names).toEqual(['e1-1.png', 'e1-2.jpg']) + expect(await fsp.readFile(path.join(root, '.spx', 'code-qa', 'e1-1.png'))).toEqual(png) + expect(await fsp.readFile(path.join(root, '.spx', 'code-qa', 'e1-2.jpg'))).toEqual(jpeg) + expect(names.every(isQaImageName)).toBe(true) + }) + + it('没带图片:什么都不建', async () => { + expect(await writeQaImages(root, 'e1', [])).toEqual([]) + await expect(fsp.access(path.join(root, '.spx'))).rejects.toThrow() + }) + + it('问答里记的名字只认这边起的样子,跑到别处的路径不认', () => { + expect(isQaImageName('0b6c2f9e-3c1d-4a8e-9f00-1234567890ab-3.webp')).toBe(true) + for (const name of ['../x-1.png', 'a/b-1.png', 'e1-1.svg', 'e1.png', 'e1-1.png/..', '']) + expect(isQaImageName(name)).toBe(false) + }) +}) diff --git a/vscode/src/codeReader/qa/qaImages.ts b/vscode/src/codeReader/qa/qaImages.ts new file mode 100644 index 0000000..9bc1dd0 --- /dev/null +++ b/vscode/src/codeReader/qa/qaImages.ts @@ -0,0 +1,40 @@ +/** + * 问题带的图片怎么存:答成了才写,每张一个文件,放在工作区文件夹的 `.spx/code-qa/` 里, + * 名字是 `<问答 id>-<第几张>.<扩展名>`,问答里只记名字(见 qaMap)。按问答 id 起名,文件改名、挪位置时不用动。 + */ + +import type { ClaudeImage } from '../../cc/spawnClaude' +import { Buffer } from 'node:buffer' +import { promises as fsp } from 'node:fs' +import * as path from 'node:path' + +/** 图片放在哪(相对工作区文件夹,`/` 分隔);第一张存下时才建 */ +export const QA_IMAGE_DIR = '.spx/code-qa' + +const EXTENSIONS: Readonly> = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', +} + +/** 认的图片格式:PNG、JPEG、GIF、WebP(Claude 认的这几种) */ +export function isQaImageType(mediaType: string): boolean { + return Object.hasOwn(EXTENSIONS, mediaType) +} + +/** 问答里记的图片名是不是这边起的:手改 `code-qa.json` 写成别的路径(`../x`)的不认,删问答时也不会删到别处 */ +export function isQaImageName(name: string): boolean { + return /^[\w-]+-\d+\.(?:png|jpg|gif|webp)$/.test(name) +} + +/** 把 entryId 那条问答带的图片写进 `/.spx/code-qa/`,返回按先后的文件名;没有图片时什么都不建。 */ +export async function writeQaImages(rootPath: string, entryId: string, images: readonly ClaudeImage[]): Promise { + if (images.length === 0) + return [] + const dir = path.join(rootPath, ...QA_IMAGE_DIR.split('/')) + await fsp.mkdir(dir, { recursive: true }) + const names = images.map((image, index) => `${entryId}-${index + 1}.${EXTENSIONS[image.mediaType]}`) + await Promise.all(images.map((image, index) => fsp.writeFile(path.join(dir, names[index]), Buffer.from(image.base64, 'base64')))) + return names +} diff --git a/vscode/src/codeReader/qa/qaMap.test.ts b/vscode/src/codeReader/qa/qaMap.test.ts index ab550f2..41427bc 100644 --- a/vscode/src/codeReader/qa/qaMap.test.ts +++ b/vscode/src/codeReader/qa/qaMap.test.ts @@ -1,9 +1,11 @@ import type { QaEntry, QaMap } from './qaMap' +import { Buffer } from 'node:buffer' 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 { detachedFiles, dropEntries, moveEntries, readPathMap, removeFiles, writePathMap } from '../pathMap' +import { writeQaImages } from './qaImages' import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap' function entry(id: string, createdAt: string, question = `问 ${id}`): QaEntry { @@ -143,3 +145,55 @@ describe('读写 .spx/code-qa.json', () => { expect(await fsp.readFile(qaFile(), 'utf8')).toBe('{}\n') }) }) + +describe('问题带的图片', () => { + const imageFile = (name: string): string => path.join(root, '.spx', 'code-qa', name) + const exists = (file: string): Promise => fsp.access(file).then(() => true, () => false) + const png = (id: string): { mediaType: string, base64: string } => ({ mediaType: 'image/png', base64: Buffer.from(id).toString('base64') }) + + /** 同 FolderStore.update:读盘上的一份改好写回,再删掉不再有条目带着的文件 */ + async function update(change: (map: QaMap) => QaMap): Promise { + const before = await readPathMap(root, QA_FILE) + const after = change(before) + await writePathMap(root, QA_FILE, after) + await removeFiles(root, detachedFiles(QA_FILE, before, after)) + } + + it('记在这条上(.spx/code-qa/ 里的文件名)、读得回来;认不出的名字(跑到别处的路径、不是字符串)跳过,一张都不剩就没有这一项', async () => { + const withImages: QaEntry = { ...entry('p', '2026-09-26T10:00:00.000Z'), images: ['p-1.png', 'p-2.webp'] } + await writePathMap(root, QA_FILE, withEntry({}, 'a.py', IF, withImages)) + expect(await fsp.readFile(qaFile(), 'utf8')).toContain('"images": [') + expect(await readPathMap(root, QA_FILE)).toEqual({ 'a.py': [{ anchor: IF, entries: [withImages] }] }) + await writeRaw({ 'a.py': [{ anchor: IF, entries: [ + { ...entry('x', '2026-09-25T10:00:00.000Z'), images: ['../../secret-1.png', 'x-1.png', 7, 'x-2.exe'] }, + { ...entry('y', '2026-09-26T10:00:00.000Z'), images: ['../y-1.png'] }, + { ...entry('z', '2026-09-26T11:00:00.000Z'), images: 'z-1.png' }, + ] }] }) + expect(await readPathMap(root, QA_FILE)).toEqual({ 'a.py': [{ anchor: IF, entries: [ + { ...entry('x', '2026-09-25T10:00:00.000Z'), images: ['x-1.png'] }, + entry('y', '2026-09-26T10:00:00.000Z'), + entry('z', '2026-09-26T11:00:00.000Z'), + ] }] }) + }) + + it('删一次问答,它的图片跟着删;别的问答的图片留着', async () => { + const a = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1'), png('a2')]) } + const b = { ...entry('b', '2026-09-26T10:00:00.000Z'), images: await writeQaImages(root, 'b', [png('b1')]) } + await update(map => withEntry(withEntry(map, 'a.py', IF, a), 'a.py', DISPLAY, b)) + expect(await exists(imageFile('a-1.png'))).toBe(true) + await update(map => withoutEntry(map, 'a.py', IF, 'a')) + expect(await exists(imageFile('a-1.png'))).toBe(false) + expect(await exists(imageFile('a-2.png'))).toBe(false) + expect(await exists(imageFile('b-1.png'))).toBe(true) + }) + + it('源文件删了、问答跟着删时图片也删;改名、挪位置按问答 id 起的名字不用动', async () => { + const a = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1')]) } + await update(map => withEntry(map, 'src/a.py', IF, a)) + await update(map => moveEntries(map, 'src', 'lib')) + expect(await readPathMap(root, QA_FILE)).toEqual({ 'lib/a.py': [{ anchor: IF, entries: [a] }] }) + expect(await exists(imageFile('a-1.png'))).toBe(true) + await update(map => dropEntries(map, 'lib/a.py')) + expect(await exists(imageFile('a-1.png'))).toBe(false) + }) +}) diff --git a/vscode/src/codeReader/qa/qaMap.ts b/vscode/src/codeReader/qa/qaMap.ts index a9e93c2..24ffbad 100644 --- a/vscode/src/codeReader/qa/qaMap.ts +++ b/vscode/src/codeReader/qa/qaMap.ts @@ -1,10 +1,12 @@ /** * 代码问答的存法:每个工作区文件夹一份 `/.spx/code-qa.json`,读写的规矩同文件注释(见 pathMap)。 - * 形状是 `{ "相对路径": [{ anchor, entries: [{ id, question, answer, createdAt, sessionId }] }] }`: + * 形状是 `{ "相对路径": [{ anchor, entries: [{ id, question, answer, createdAt, sessionId, profile?, images? }] }] }`: * 一份文件里挂着问答的每一块一项(anchor 见 BlockAnchors),块下面问过的按时间排。 + * 问题带的图片是 `.spx/code-qa/` 里的文件,条目删了(删一次问答、删源文件)文件跟着删(见 attachments)。 */ import type { PathMap, PathMapFile } from '../pathMap' +import { isQaImageName, QA_IMAGE_DIR } from './qaImages' /** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、Claude Code 的会话 id。 */ export interface QaEntry { @@ -15,6 +17,8 @@ export interface QaEntry { sessionId: string /** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */ profile?: string + /** 问题带的图片:`.spx/code-qa/` 里的文件名(见 qaImages),按贴的先后;没带图片时没有 */ + images?: string[] } /** 挂在一块上的问答。 */ @@ -39,7 +43,16 @@ function parseEntry(value: unknown): QaEntry | undefined { const [id, question, answer, createdAt, profile] = [text(value.id), text(value.question), text(value.answer), text(value.createdAt), text(value.profile)] if (!id || !question || !answer || !createdAt || Number.isNaN(Date.parse(createdAt))) return undefined - return { id, question, answer, createdAt, sessionId: typeof value.sessionId === 'string' ? value.sessionId : '', ...(profile && { profile }) } + const images = Array.isArray(value.images) ? value.images.filter((name): name is string => typeof name === 'string' && isQaImageName(name)) : [] + return { + id, + question, + answer, + createdAt, + sessionId: typeof value.sessionId === 'string' ? value.sessionId : '', + ...(profile && { profile }), + ...(images.length > 0 && { images }), + } } /** 同一块的并成一项;块按记号排,问答按时间排:写出来的顺序稳定,git diff 干净。 */ @@ -74,6 +87,7 @@ export const QA_FILE: PathMapFile = { path: '.spx/code-qa.json', label: '代码问答', parse: parseThreads, + attachments: threads => threads.flatMap(thread => thread.entries.flatMap(entry => (entry.images ?? []).map(name => `${QA_IMAGE_DIR}/${name}`))), } /** 在 key 这份文件的 anchor 那块下面记一次问答。 */ diff --git a/vscode/webview-ui/src/codeReader/AskThread.test.tsx b/vscode/webview-ui/src/codeReader/AskThread.test.tsx index de4fa6a..5f189cf 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.test.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.test.tsx @@ -92,4 +92,20 @@ describe('块下面的问答区', () => { expect(text(html)).toContain('UserRole 还有哪些角色?昨天') expect(text(html)).not.toContain('昨天 ·') }) + + it('问题带的图片:历史和当前这一轮在问题下面一排缩略图,点开看大图,不能去掉;没带图片的不画这一排', () => { + const stored = ['https://file+.vscode-resource.vscode-cdn.net/work/.spx/code-qa/e1-1.png', 'https://file+.vscode-resource.vscode-cdn.net/work/.spx/code-qa/e1-2.jpg'] + const html = render({ open: true, history: [{ ...HISTORY[0]!, images: stored }, HISTORY[1]!], asks: [ask({ images: ['data:image/png;base64,AAA'] })] }) + const [first, second] = html.split('data-history=""').slice(1) + expect(first).toContain('
') + expect(first.indexOf('cr-qa-thumbs')).toBeGreaterThan(first.indexOf('cr-qa-head')) + expect(first.indexOf('cr-qa-thumbs')).toBeLessThan(first.indexOf('cr-qa-preview')) + expect(first).toContain(``) + expect(first).toContain('aria-label="看第 2 张图片的大图"') + expect(second.slice(0, second.indexOf('data-current'))).not.toContain('cr-qa-thumbs') + const current = html.slice(html.indexOf('data-current')) + expect(current).toContain('') + expect(html).not.toContain('cr-qa-thumb-remove') + expect(html).not.toContain('data-pending') + }) }) diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx index 3643d1b..7cc63b7 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -1,14 +1,70 @@ -import type { KeyboardEvent, ReactElement } from 'react' +import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'react' import type { Ask } from './qa' +import type { PendingImage } from './qaImages' import type { ProfileChoice, QaEntryView } from './types' -import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert } from 'lucide-react' -import { useState } from 'react' +import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { SelectMenu } from '@/components/ui/select-menu' import { renderMarkdown, renderMarkdownOnce } from './markdown' import { answerPreview, relativeTime } from './qa' +import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages' -/** 问过的一次,灰字:问题、时间、「已理解,删除」,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */ -function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number, onDelete: () => void }): ReactElement { +/** 问题带的图片:一排小缩略图,点一张看大图;给了 onRemove 的(贴了还没发的)右上角有「去掉」。没有图片时什么都不画。 */ +function Thumbs({ urls, pending, onOpen, onRemove }: { urls: string[], pending?: boolean, onOpen: (url: string) => void, onRemove?: (index: number) => void }): ReactElement | null { + if (urls.length === 0) + return null + return ( +
+ {urls.map((url, index) => ( + // 同一张图可能贴两次、地址一样;缩略图没有自己的状态,按位置认就行 + + + {onRemove && ( + + )} + + ))} +
+ ) +} + +/** 看大图:盖住整个预览,点哪儿或按 Esc 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。 */ +function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): ReactElement { + const close = useRef(null) + useEffect(() => { + const opener = document.activeElement + close.current?.focus() + return () => { + if (opener instanceof HTMLElement) + opener.focus() + } + }, []) + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + // 只关大图:不收起问答区,也不清名字高亮(那边认 defaultPrevented) + event.preventDefault() + event.stopPropagation() + onClose() + } + } + return createPortal( +
+ + +
, + document.body, + ) +} + +/** 问过的一次,灰字:问题、时间、「已理解,删除」,问题带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */ +function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement { const [expanded, setExpanded] = useState(false) const toggle = (): void => setExpanded(value => !value) return ( @@ -28,6 +84,7 @@ function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number
+ {expanded ?
: ( @@ -40,7 +97,7 @@ function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number } /** 当前这一轮问的一次,照常颜色:问题,回答逐字长出来(按 Markdown 排),在做的事、出错、停止写在下面 */ -function CurrentItem({ ask }: { ask: Ask }): ReactElement { +function CurrentItem({ ask, onOpenImage }: { ask: Ask, onOpenImage: (url: string) => void }): ReactElement { const running = ask.status === 'running' return (
@@ -48,6 +105,7 @@ function CurrentItem({ ask }: { ask: Ask }): ReactElement { {ask.question}
+ {ask.text !== '' && (
void - onAsk: (question: string) => void + onAsk: (question: string, images: PendingImage[]) => void onStop: (askId: string) => void onClose: () => void + onOpenImage: (url: string) => void }): ReactElement { const [value, setValue] = useState('') + const [images, setImages] = useState([]) + /** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */ + const [notice, setNotice] = useState() + const textarea = useRef(null) + const attach = (files: File[]): void => { + if (files.length === 0) + return + const picked = pickImages(files, images.length) + setNotice(picked.notice) + Promise.all(picked.accepted.map(readImage)).then( + read => setImages(current => [...current, ...read].slice(0, MAX_IMAGES)), + () => setNotice('有图片读不出来,没加上。'), + ) + } const send = (): void => { const question = value.trim() if (question === '' || running) return - onAsk(question) + onAsk(question, images) setValue('') + setImages([]) + setNotice(undefined) } const onKeyDown = (event: KeyboardEvent): void => { // 输入法选字时的回车不算发送 @@ -102,38 +177,63 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on onClose() } } + // 剪贴板里的图片加到上面的缩略图里;文字照常粘进输入框,不拦 + const onPaste = (event: ClipboardEvent): void => attach(transferFiles(event.clipboardData.items)) + const onDragOver = (event: DragEvent): void => { + if (event.dataTransfer.types.includes('Files')) { + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + } + const onDrop = (event: DragEvent): void => { + const files = transferFiles(event.dataTransfer.items) + if (files.length > 0) { + event.preventDefault() + attach(files) + } + } + const remove = (index: number): void => { + setImages(current => current.filter((_, i) => i !== index)) + textarea.current?.focus() + } return ( -
-