✨ feat(vscode): 问 AI 输入框能粘贴、拖进图片随问题发给 Claude Code,缩略图能点开看大图,图片存进 .spx/code-qa 跟着问答删
This commit is contained in:
1 parent
d9a5c3673e
commit
36d842e18e
28 files changed
+825
-121
No files matched your search
@@ -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 照旧收起。亮、暗两套颜色都看过。
|
||||
|
||||
## 收尾
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 })]
|
||||
}))
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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('问的时间', () => {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in new issue
Block a user