✨ feat(vscode): 问 AI 输入框能粘贴、拖进图片随问题发给 Claude Code,缩略图能点开看大图,图片存进 .spx/code-qa 跟着问答删

This commit is contained in:
cruldra committed 2026-09-26 23:15:02 +08:00
1 parent d9a5c3673e
commit 36d842e18e
28 files changed
+825 -121

No files matched your search

+26 -17
View File
@@ -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 照旧收起。亮、暗两套颜色都看过。
## 收尾
+10
View File
@@ -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)
+8 -2
View File
@@ -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<Record<string, unknown>> = [
{ 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')
+6 -2
View File
@@ -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 }
@@ -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<void> {
private async ask(askId: string, anchor: string, question: string, images: ClaudeImage[]): Promise<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 && 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)
+6 -2
View File
@@ -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<T> implements Disposable {
return this.maps.get(folder.uri.toString())?.[key]
}
/** 读盘上最新的一份改好再写回;没变就不写,所以第一条存下之前不会生成文件。 */
/**
* 读盘上最新的一份改好再写回;没变就不写,所以第一条存下之前不会生成文件。
* 写好后删掉不再有条目带着的文件(见 PathMapFile.attachments):删一条、删源文件都跟着删。
*/
update(folder: WorkspaceFolder, change: (map: PathMap<T>) => PathMap<T>): Promise<void> {
return this.enqueue(async () => {
const before = await readPathMap(folder.uri.fsPath, this.file)
@@ -59,6 +62,7 @@ export class FolderStore<T> 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))
})
}
+36 -1
View File
@@ -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<string[]> = { 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 })
}
})
})
+20 -1
View File
@@ -16,6 +16,8 @@ export interface PathMapFile<T> {
label: string
/** 认一条的值:不合法的返回 undefined,这条就跳过;合法的可以顺手规范成统一写法 */
parse: (value: unknown) => T | undefined
/** 一条值带着的文件(相对工作区文件夹,`/` 分隔,例如问答的图片):改表后不再有哪条带着的,跟着删掉 */
attachments?: (value: T) => string[]
}
/** 规范成表的键:去掉 `./`、重复和结尾的 `/`;工作区文件夹本身、文件夹外面的路径没有键。 */
@@ -62,7 +64,7 @@ export function changedKeys<T>(before: PathMap<T>, after: PathMap<T>): string[]
return [...keys].filter(key => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
}
function fileIn(workspaceRoot: string, file: PathMapFile<unknown>): string {
function fileIn<T>(workspaceRoot: string, file: PathMapFile<T>): string {
return path.join(workspaceRoot, ...file.path.split('/'))
}
@@ -100,3 +102,20 @@ export async function writePathMap<T>(workspaceRoot: string, file: PathMapFile<T
const sorted = Object.fromEntries(Object.entries(map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
await fsp.writeFile(target, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8')
}
/** 改表前带着、改表后没有哪条再带着的文件(见 PathMapFile.attachments)。 */
export function detachedFiles<T>(file: PathMapFile<T>, before: PathMap<T>, after: PathMap<T>): 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<void> {
await Promise.all(relativePaths.flatMap((relativePath) => {
const key = normalizeKey(relativePath)
return key === undefined ? [] : [fsp.rm(path.join(workspaceRoot, ...key.split('/')), { force: true })]
}))
}
+2 -2
View File
@@ -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 () => {
+1 -1
View File
@@ -17,7 +17,7 @@ function fakeRunner(): { runner: (run: AskRun) => Promise<Answer>, 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<void>, update: (ask: RunningAsk) => void, end: (ask: RunningAsk, end: AskEnd) => void } {
+57 -10
View File
@@ -30,12 +30,26 @@ function fakeClaude(runs: { lines: string[], error?: Error }[]): { deps: AskDeps
function request(overrides: Partial<AskRun> = {}): 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 () => {
+10 -5
View File
@@ -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,
+44 -8
View File
@@ -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<string | undefined> {
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<void> {
const at = locate(uri)
if (at)
+49
View File
@@ -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)
})
})
+40
View File
@@ -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<Record<string, string>> = {
'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 那条问答带的图片写进 `<rootPath>/.spx/code-qa/`,返回按先后的文件名;没有图片时什么都不建。 */
export async function writeQaImages(rootPath: string, entryId: string, images: readonly ClaudeImage[]): Promise<string[]> {
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
}
+55 -1
View File
@@ -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<boolean> => 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<void> {
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)
})
})
+16 -2
View File
@@ -1,10 +1,12 @@
/**
* 代码问答的存法:每个工作区文件夹一份 `<workspace>/.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<QaThread[]> = {
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 那块下面记一次问答。 */
@@ -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('<div class="cr-qa-thumbs">')
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(`<img src="${stored[0]}" alt=""/>`)
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('<img src="data:image/png;base64,AAA" alt=""/>')
expect(html).not.toContain('cr-qa-thumb-remove')
expect(html).not.toContain('data-pending')
})
})
+152 -45
View File
@@ -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 (
<div className="cr-qa-thumbs" data-pending={pending ? '' : undefined}>
{urls.map((url, index) => (
// 同一张图可能贴两次、地址一样;缩略图没有自己的状态,按位置认就行
<span key={index} className="cr-qa-thumb">
<button type="button" className="cr-qa-thumb-open" title="看大图" aria-label={`看第 ${index + 1} 张图片的大图`} onClick={() => onOpen(url)}>
<img src={url} alt="" />
</button>
{onRemove && (
<button type="button" className="cr-qa-thumb-remove" title="去掉这张图片" aria-label={`去掉第 ${index + 1} 张图片`} onClick={() => onRemove(index)}>
<X size={10} strokeWidth={2.4} aria-hidden />
</button>
)}
</span>
))}
</div>
)
}
/** 看大图:盖住整个预览,点哪儿或按 Esc 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。 */
function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): ReactElement {
const close = useRef<HTMLButtonElement>(null)
useEffect(() => {
const opener = document.activeElement
close.current?.focus()
return () => {
if (opener instanceof HTMLElement)
opener.focus()
}
}, [])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape') {
// 只关大图:不收起问答区,也不清名字高亮(那边认 defaultPrevented)
event.preventDefault()
event.stopPropagation()
onClose()
}
}
return createPortal(
<div className="cr-qa-lightbox" role="dialog" aria-modal="true" aria-label="图片大图" onClick={onClose} onKeyDown={onKeyDown}>
<img src={url} alt="" />
<button ref={close} type="button" className="cr-qa-lightbox-close" title="关闭(Esc)" aria-label="关闭大图">
<X size={16} strokeWidth={1.8} aria-hidden />
</button>
</div>,
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
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
</button>
</div>
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
{expanded
? <div className="cr-markdown cr-qa-answer" dangerouslySetInnerHTML={{ __html: renderMarkdown(entry.answer) }} />
: (
@@ -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 (
<div className="cr-qa-entry" data-current="">
@@ -48,6 +105,7 @@ function CurrentItem({ ask }: { ask: Ask }): ReactElement {
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<span className="cr-qa-question">{ask.question}</span>
</div>
<Thumbs urls={ask.images ?? []} onOpen={onOpenImage} />
{ask.text !== '' && (
<div
className="cr-markdown cr-qa-answer"
@@ -72,24 +130,41 @@ function CurrentItem({ ask }: { ask: Ask }): ReactElement {
)
}
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止 */
function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose }: {
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose, onOpenImage }: {
running: Ask | undefined
/** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */
first: boolean
profiles: ProfileChoice
onSelectProfile: (id: string) => 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<PendingImage[]>([])
/** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */
const [notice, setNotice] = useState<string>()
const textarea = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>): void => {
// 输入法选字时的回车不算发送
@@ -102,38 +177,63 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on
onClose()
}
}
// 剪贴板里的图片加到上面的缩略图里;文字照常粘进输入框,不拦
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>): void => attach(transferFiles(event.clipboardData.items))
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
}
const onDrop = (event: DragEvent<HTMLDivElement>): 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 (
<div className="cr-qa-input">
<textarea
rows={1}
autoFocus
value={value}
placeholder={`${first ? '问这块代码…' : '继续问…'} Enter 发送,Esc 收起`}
aria-label="问 AI 的问题"
onChange={event => setValue(event.target.value)}
onKeyDown={onKeyDown}
/>
<SelectMenu
variant="ghost"
value={profiles.selected}
options={profiles.options.map(option => ({ value: option.id, label: option.label }))}
onChange={onSelectProfile}
placeholder="默认"
title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
className="cr-qa-profile"
/>
{running
? (
<button type="button" className="cr-tool" title="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
</button>
)
: (
<button type="button" className="cr-tool" title="发送(Enter)" aria-label="发送" disabled={value.trim() === ''} onClick={send}>
<SendHorizontal size={14} strokeWidth={1.8} aria-hidden />
</button>
)}
</div>
<>
<Thumbs urls={images.map(image => image.url)} pending onOpen={onOpenImage} onRemove={remove} />
{notice && <div className="cr-qa-notice" role="status">{notice}</div>}
<div className="cr-qa-input" onDragOver={onDragOver} onDrop={onDrop}>
<textarea
ref={textarea}
rows={1}
autoFocus
value={value}
placeholder={`${first ? '问这块代码…' : '继续问…'} Enter 发送,Esc 收起`}
aria-label="问 AI 的问题"
onChange={event => setValue(event.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
/>
<SelectMenu
variant="ghost"
value={profiles.selected}
options={profiles.options.map(option => ({ value: option.id, label: option.label }))}
onChange={onSelectProfile}
placeholder="默认"
title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
className="cr-qa-profile"
/>
{running
? (
<button type="button" className="cr-tool" title="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
</button>
)
: (
<button type="button" className="cr-tool" title="发送(Enter)" aria-label="发送" disabled={value.trim() === ''} onClick={send}>
<SendHorizontal size={14} strokeWidth={1.8} aria-hidden />
</button>
)}
</div>
</>
)
}
@@ -146,7 +246,8 @@ export interface AskThreadProps {
profiles: ProfileChoice
now?: number
onSelectProfile: (id: string) => void
onAsk: (question: string) => void
/** images 是贴进输入框的图片,随问题一起发 */
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (entryId: string) => void
onClose: () => void
@@ -158,6 +259,9 @@ export interface AskThreadProps {
*/
export function AskThread({ history, asks, open, profiles, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(false)
/** 正在看大图的那张 */
const [preview, setPreview] = useState<string>()
const lightbox = preview !== undefined && <ImagePreview url={preview} onClose={() => setPreview(undefined)} />
if (!open) {
if (history.length === 0)
return null
@@ -169,15 +273,16 @@ export function AskThread({ history, asks, open, profiles, now = Date.now(), onS
{`问过 ${history.length} 次`}
<Chevron size={12} strokeWidth={1.8} aria-hidden />
</button>
{showHistory && history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} />)}
{showHistory && history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)}
{lightbox}
</div>
)
}
return (
<section className="cr-qa" aria-label="问 AI">
{history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} />)}
{history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)}
{history.length > 0 && asks.length > 0 && <hr className="cr-qa-divider" />}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} />)}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} onOpenImage={setPreview} />)}
<AskInput
running={asks.find(ask => ask.status === 'running')}
first={history.length === 0 && asks.length === 0}
@@ -186,7 +291,9 @@ export function AskThread({ history, asks, open, profiles, now = Date.now(), onS
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
onOpenImage={setPreview}
/>
{lightbox}
</section>
)
}
@@ -1,5 +1,6 @@
import type { CSSProperties, ReactElement } from 'react'
import type { QaState } from './qa'
import type { PendingImage } from './qaImages'
import type { BlockView } from './types'
import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react'
import { Fragment } from 'react'
@@ -37,7 +38,8 @@ export interface QaTree {
state: QaState
onToggle: (anchor: string) => void
onClose: (anchor: string) => void
onAsk: (anchor: string, question: string) => void
/** images 是贴进输入框的图片,随问题一起发 */
onAsk: (anchor: string, question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (anchor: string, entryId: string) => void
/** 换「问 AI」用的 profile(所有块共用),id 同 ProfileOption.id */
@@ -101,7 +103,7 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
open={qa.state.open.includes(anchor)}
profiles={qa.state.profiles}
onSelectProfile={qa.onSelectProfile}
onAsk={question => qa.onAsk(anchor, question)}
onAsk={(question, images) => qa.onAsk(anchor, question, images)}
onStop={qa.onStop}
onDelete={entryId => qa.onDelete(anchor, entryId)}
onClose={() => qa.onClose(anchor)}
@@ -621,6 +621,107 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
border: 0;
border-top: 1px solid color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
}
/* 问题带的图片:一排 40px 的缩略图,和回答一样往右缩到问题下面;贴了还没发的在输入框上面,右上角有「去掉」 */
.cr-qa-thumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 2px 0 2px 18px;
}
.cr-qa-thumbs[data-pending] {
padding: 6px 0 0;
}
.cr-qa-thumb {
position: relative;
display: flex;
}
.cr-qa-thumb-open {
display: flex;
overflow: hidden;
width: 40px;
height: 40px;
border: 1px solid color-mix(in srgb, var(--vscode-foreground) 18%, transparent);
border-radius: 4px;
background: var(--vscode-editor-background);
cursor: zoom-in;
}
.cr-qa-thumb-open img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cr-qa-thumb-open:hover {
border-color: var(--vscode-focusBorder);
}
.cr-qa-entry[data-history] .cr-qa-thumb-open img {
opacity: 0.7;
}
.cr-qa-entry[data-history] .cr-qa-thumb-open:hover img {
opacity: 1;
}
.cr-qa-thumb-remove {
position: absolute;
top: -6px;
right: -6px;
display: flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
border: 1px solid var(--vscode-widget-border, color-mix(in srgb, var(--vscode-foreground) 25%, transparent));
border-radius: 50%;
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
color: var(--vscode-descriptionForeground);
}
.cr-qa-thumb-remove:hover {
color: var(--vscode-foreground);
}
.cr-qa-thumb-open:focus-visible,
.cr-qa-thumb-remove:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
.cr-qa-notice {
color: var(--vscode-editorWarning-foreground, var(--vscode-descriptionForeground));
font-size: 11.5px;
}
/* 看大图:盖住整个预览,图片按比例放到最大,右上角「关闭」 */
.cr-qa-lightbox {
position: fixed;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: rgb(0 0 0 / 65%);
cursor: zoom-out;
inset: 0;
}
.cr-qa-lightbox img {
max-width: 100%;
max-height: 100%;
border-radius: 4px;
box-shadow: 0 6px 28px rgb(0 0 0 / 45%);
object-fit: contain;
}
.cr-qa-lightbox-close {
position: absolute;
top: 10px;
right: 10px;
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 1px solid var(--vscode-widget-border, transparent);
border-radius: 4px;
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
color: var(--vscode-foreground);
}
.cr-qa-lightbox-close:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
.cr-qa-input {
display: flex;
align-items: flex-end;
+16 -6
View File
@@ -23,14 +23,14 @@ function run(...actions: QaAction[]): QaState {
describe('问 AI 的状态', () => {
it('问一次:展开这块,当前这一轮多一条在答的;回答长了跟着变;答完记下存成的那条,历史里不再重复', () => {
let state = run(qa([thread(IF, 'old')]), { type: 'ask', askId: 'a1', anchor: IF, question: '为什么?' })
let state = run(qa([thread(IF, 'old')]), { type: 'ask', askId: 'a1', anchor: IF, question: '为什么?', images: [] })
expect(state.open).toEqual([IF])
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '', status: 'running' }])
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '', status: 'running' }])
state = qaReducer(state, { type: 'answer', askId: 'a1', text: '因为', activity: '读取 a.py' })
expect(asksOf(state, IF)[0]).toMatchObject({ text: '因为', activity: '读取 a.py' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new' } })
state = qaReducer(state, qa([thread(IF, 'old', 'new')]))
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new' }])
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '因为要鉴权。', status: 'done', entryId: 'new' }])
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
})
@@ -45,9 +45,9 @@ describe('问 AI 的状态', () => {
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
let state = run(
qa([thread(IF, 'e1')]),
{ type: 'ask', askId: 'a1', anchor: IF, question: '1' },
{ type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] },
{ type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1' } },
{ type: 'ask', askId: 'a2', anchor: IF, question: '2' },
{ type: 'ask', askId: 'a2', anchor: IF, question: '2', images: [] },
{ type: 'close', anchor: IF },
)
expect(state.open).toEqual([])
@@ -67,12 +67,22 @@ describe('问 AI 的状态', () => {
})
it('停止、失败照记;不认识的问题的消息不理', () => {
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' }, { type: 'answer', askId: 'a1', text: '一半' })
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] }, { type: 'answer', askId: 'a1', text: '一半' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'stopped' } })
expect(asksOf(state, IF)[0]).toMatchObject({ status: 'stopped', text: '一半' })
expect(qaReducer(state, { type: 'answer', askId: 'a1', text: '晚到的' })).toEqual(state)
expect(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'nope', status: 'done' } })).toEqual(state)
})
it('问题带的图片跟着这一问:答完、失败、停止都还在;页面重新加载后接上的没有', () => {
const images = ['data:image/png;base64,AAA', 'data:image/jpeg;base64,BBB']
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '看图', images })
expect(asksOf(state, IF)[0].images).toEqual(images)
for (const status of ['done', 'failed', 'stopped'] as const)
expect(asksOf(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status } }), IF)[0].images).toEqual(images)
state = run(qa([], [{ askId: 'a2', anchor: IF, question: '1', text: '' }]))
expect(asksOf(state, IF)[0].images).toBeUndefined()
})
})
describe('问的时间', () => {
+5 -3
View File
@@ -10,6 +10,8 @@ export interface Ask {
askId: string
anchor: string
question: string
/** 问题带的图片(这边贴的 data: 地址);页面重新加载后接上的、扩展端还在答的没有 */
images?: string[]
/** 回答到现在为止的全文(Markdown) */
text: string
/** 正在做的事:「读取 routes.py」 */
@@ -40,7 +42,7 @@ export type QaAction
| { type: 'profiles', profiles: ProfileChoice }
| { type: 'answer', askId: string, text: string, activity?: string }
| { type: 'answerEnd', end: AnswerEnd }
| { type: 'ask', askId: string, anchor: string, question: string }
| { type: 'ask', askId: string, anchor: string, question: string, images: string[] }
| { type: 'open', anchor: string }
| { type: 'close', anchor: string }
@@ -76,14 +78,14 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
return {
...state,
asks: state.asks.map(ask => ask.askId === askId
? { askId, anchor: ask.anchor, question: ask.question, text: text ?? ask.text, status, error, entryId }
? { askId, anchor: ask.anchor, question: ask.question, images: ask.images, text: text ?? ask.text, status, error, entryId }
: ask),
}
}
case 'ask':
return {
...state,
asks: [...state.asks, { askId: action.askId, anchor: action.anchor, question: action.question, text: '', status: 'running' }],
asks: [...state.asks, { askId: action.askId, anchor: action.anchor, question: action.question, images: action.images, text: '', status: 'running' }],
open: withOpen(state.open, action.anchor),
}
case 'open':
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { MAX_IMAGE_BYTES, MAX_IMAGES, pickImages, toAskImage, transferFiles } from './qaImages'
interface FakeFile { name: string, type: string, size: number }
const file = (name: string, type = 'image/png', size = 1024): FakeFile => ({ name, type, size })
/** 剪贴板 / 拖进来的一项:文件,或文字、网页片段这种字符串 */
function item(value: FakeFile | string): { kind: string, getAsFile: () => FakeFile | null } {
return typeof value === 'string'
? { kind: 'string', getAsFile: () => null }
: { kind: 'file', getAsFile: () => value }
}
describe('剪贴板里的东西', () => {
it('只取文件,文字、网页片段不算(照常粘成文字);取不出文件的跳过', () => {
const shot = file('截图.png')
const copied = file('image.jpg', 'image/jpeg')
expect(transferFiles([item('<img src="x">'), item(shot), item('说明文字'), item(copied)])).toEqual([shot, copied])
expect(transferFiles([{ kind: 'file', getAsFile: () => null }])).toEqual([])
expect(transferFiles([item('只有文字')])).toEqual([])
})
})
describe('能带上哪些图片', () => {
it('认的四种格式(PNG、JPEG、GIF、WebP)都带上,按先后', () => {
const files = [file('a.png'), file('b.jpg', 'image/jpeg'), file('c.gif', 'image/gif'), file('d.webp', 'image/webp')]
expect(pickImages(files, 0)).toEqual({ accepted: files })
})
it('不是这几种格式的不带(SVG、PDF 等),说一句原因', () => {
const png = file('a.png')
expect(pickImages([file('x.svg', 'image/svg+xml'), png, file('y.pdf', 'application/pdf')], 0))
.toEqual({ accepted: [png], notice: '只能贴 PNG、JPEG、GIF、WebP 图片,有 2 个没加上。' })
})
it('每张不超过 5 MB:正好 5 MB 的带上,大一点的不带', () => {
const edge = file('edge.png', 'image/png', MAX_IMAGE_BYTES)
expect(pickImages([edge, file('big.png', 'image/png', MAX_IMAGE_BYTES + 1)], 0))
.toEqual({ accepted: [edge], notice: '每张图片不能超过 5 MB,有 1 个没加上。' })
})
it('连同已经贴的最多 6 张:多出来的不带;已经满了一张都不带', () => {
const files = [file('1.png'), file('2.png'), file('3.png')]
expect(pickImages(files, MAX_IMAGES - 2)).toEqual({ accepted: files.slice(0, 2), notice: '一个问题最多带 6 张图片,有 1 个没加上。' })
expect(pickImages(files, MAX_IMAGES)).toEqual({ accepted: [], notice: '一个问题最多带 6 张图片,有 3 个没加上。' })
})
it('几种原因一起说', () => {
const picked = pickImages([file('a.txt', 'text/plain'), file('big.png', 'image/png', MAX_IMAGE_BYTES * 2), ...Array.from({ length: 7 }, (_, i) => file(`${i}.png`))], 0)
expect(picked.accepted).toHaveLength(MAX_IMAGES)
expect(picked.notice).toBe('只能贴 PNG、JPEG、GIF、WebP 图片;每张图片不能超过 5 MB;一个问题最多带 6 张图片,有 3 个没加上。')
})
})
describe('发给扩展端的样子', () => {
it('data: 地址去掉前缀剩下 base64,格式照原样', () => {
expect(toAskImage({ id: 1, mediaType: 'image/png', url: 'data:image/png;base64,iVBORw0KGgo=' })).toEqual({ mediaType: 'image/png', base64: 'iVBORw0KGgo=' })
})
})
@@ -0,0 +1,61 @@
/**
* 「问 AI」输入框里贴图、拖图的规则(不碰 React):认哪些格式、每张多大、最多几张,
* 贴进来还没发的图片在页面上的样子,发给扩展端的样子。
*/
import type { AskImage } from './types'
/** 认的格式:Claude 认的这几种 */
export const IMAGE_TYPES: readonly string[] = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024
/** 一个问题最多带几张 */
export const MAX_IMAGES = 6
/** 贴进输入框、还没发的一张图片:url 是 data: 地址,缩略图、大图、发送都用它 */
export interface PendingImage {
id: number
mediaType: string
url: string
}
/** 剪贴板 / 拖进来的东西里的文件;文字、网页片段这些不算(照常粘成文字) */
export function transferFiles<F>(items: ArrayLike<{ kind: string, getAsFile: () => F | null }>): F[] {
return Array.from(items).flatMap(item => item.kind === 'file' ? item.getAsFile() ?? [] : [])
}
/**
* 挑出能带上的:只认 PNG、JPEG、GIF、WebP,每张不超过 MAX_IMAGE_BYTES,连同已经有的 have 张最多 MAX_IMAGES 张,按先后取;
* 有没加上的给一句原因。
*/
export function pickImages<F extends { type: string, size: number }>(files: readonly F[], have: number): { accepted: F[], notice?: string } {
const images = files.filter(file => IMAGE_TYPES.includes(file.type))
const small = images.filter(file => file.size <= MAX_IMAGE_BYTES)
const accepted = small.slice(0, Math.max(0, MAX_IMAGES - have))
const reasons = [
...(images.length < files.length ? ['只能贴 PNG、JPEG、GIF、WebP 图片'] : []),
...(small.length < images.length ? [`每张图片不能超过 ${MAX_IMAGE_BYTES / 1024 / 1024} MB`] : []),
...(accepted.length < small.length ? [`一个问题最多带 ${MAX_IMAGES} 张图片`] : []),
]
return reasons.length === 0
? { accepted }
: { accepted, notice: `${reasons.join(';')},有 ${files.length - accepted.length} 个没加上。` }
}
let imageCounter = 0
/** 读成 data: 地址(缩略图直接用,页面的安全策略允许 data: 图片) */
export function readImage(file: File): Promise<PendingImage> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve({ id: ++imageCounter, mediaType: file.type, url: String(reader.result) })
reader.onerror = () => reject(reader.error ?? new Error('读不出图片'))
reader.readAsDataURL(file)
})
}
/** 发给扩展端的样子:data: 地址去掉前缀剩下的 base64 */
export function toAskImage(image: PendingImage): AskImage {
return { mediaType: image.mediaType, base64: image.url.slice(image.url.indexOf(',') + 1) }
}
+11 -2
View File
@@ -164,6 +164,14 @@ export interface QaEntryView {
createdAt: string
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
profile?: string
/** 问题带的图片,能直接放进 img 的地址;没带图片时没有 */
images?: string[]
}
/** 问题带的一张图片,发给扩展端的样子:格式(`image/png` 等)和不带 `data:` 前缀的 base64 */
export interface AskImage {
mediaType: string
base64: string
}
/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示 */
@@ -246,7 +254,8 @@ export type ExtensionToWebview
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示
@@ -260,7 +269,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: AskImage[] }
| { type: 'stopAsk', askId: string }
| { type: 'deleteQa', anchor: string, entryId: string }
| { type: 'selectAskProfile', id: string }
@@ -7,7 +7,7 @@ import { onMessage, postMessage } from './vscode'
export const HighlightContext = createContext<Highlight | undefined>(undefined)
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动 */
const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], .cr-filter-bar, .cr-prop-label'
const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], [role="dialog"], .cr-filter-bar, .cr-prop-label'
function clickOf(event: MouseEvent): Click {
const target = event.target instanceof Element ? event.target : null
+4 -3
View File
@@ -2,6 +2,7 @@ import type { QaTree } from './BlockTree'
import type { QaState } from './qa'
import { useEffect, useMemo, useReducer, useRef } from 'react'
import { EMPTY_QA, newAskId, qaReducer } from './qa'
import { toAskImage } from './qaImages'
import { onMessage, postMessage } from './vscode'
/** 逐字长出来的回答最多隔多久画一次:一个字一画整棵块树跟着重画,大文件会卡 */
@@ -52,10 +53,10 @@ export function useQa(): QaTree {
state,
onToggle: anchor => dispatch({ type: current.current.open.includes(anchor) ? 'close' : 'open', anchor }),
onClose: anchor => dispatch({ type: 'close', anchor }),
onAsk: (anchor, question) => {
onAsk: (anchor, question, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question })
postMessage({ type: 'ask', askId, anchor, question })
dispatch({ type: 'ask', askId, anchor, question, images: images.map(image => image.url) })
postMessage({ type: 'ask', askId, anchor, question, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopAsk', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),