✨ 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

+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 那块下面记一次问答。 */