✨ 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

+10
View File
@@ -29,6 +29,16 @@ describe('跑 claude、逐行交出 stdout', () => {
expect(lines).toEqual(['{"a":1}', '{"b":2}', await fsp.realpath(dir), '-p|带 空格 的问题|'])
})
it('给了 input 就写进 stdin 再关上(stream-json 的消息);不给时 stdin 是空的,读 stdin 的也不会卡住', async () => {
const bin = await fakeBin('cat; echo end')
const withInput: string[] = []
await runClaudeLines({ bin, args: [], cwd: dir, input: '{"type":"user"}\n', onLine: item => withInput.push(item) })
expect(withInput).toEqual(['{"type":"user"}', 'end'])
const without: string[] = []
await runClaudeLines({ bin, args: [], cwd: dir, timeoutMs: 2000, onLine: item => without.push(item) })
expect(without).toEqual(['end'])
})
it('退出码非零:带上 stderr 的原话', async () => {
const bin = await fakeBin('echo "No conversation found with session ID: x" >&2; exit 1')
const error = await runClaudeLines({ bin, args: [], cwd: dir, onLine: () => {} }).catch((err: unknown) => err)
+8 -2
View File
@@ -222,7 +222,8 @@ function createProgressFeeder(onProgress?: ClaudeProgressListener): (chunk: stri
}).feed
}
function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string {
/** `--input-format stream-json` 的一条用户消息(一行 NDJSON):先是文字,后面跟着各张图片。 */
export function buildStreamJsonLine(prompt: string, images: readonly ClaudeImage[]): string {
const content: Array<Record<string, unknown>> = [
{ type: 'text', text: prompt },
]
@@ -566,6 +567,8 @@ function spawnClaudeStreamed(
export function runClaudeLines(opts: {
args: string[]
cwd: string
/** 写进 stdin 后关上(`--input-format stream-json` 的消息,见 buildStreamJsonLine);不给时 stdin 是空的 */
input?: string
onLine: (line: string) => void
timeoutMs?: number
signal?: AbortSignal
@@ -589,8 +592,11 @@ export function runClaudeLines(opts: {
const child = spawn(bin, opts.args, {
cwd: opts.cwd,
env: buildClaudeChildEnv(bin),
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['pipe', 'pipe', 'pipe'],
})
// 进程先退了(参数不对)时写不进去:真正的原因由退出码和 stderr 报
child.stdin.on('error', () => {})
child.stdin.end(opts.input ?? '')
const timer = setTimeout(() => {
child.kill('SIGKILL')
+6 -2
View File
@@ -2,6 +2,7 @@
* 代码阅读预览与扩展之间的消息,与 webview 端 `webview-ui/src/codeReader/types.ts` 同构,两边手动保持一致。
*/
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { FileAttribute } from '../attributes'
import type { BlockView } from '../blockView'
import type { ProfileChoice } from '../qa/askProfiles'
@@ -54,6 +55,8 @@ export interface QaEntryView {
createdAt: string
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
profile?: string
/** 问题带的图片,预览里能直接用的地址(webview.asWebviewUri);没带图片时没有 */
images?: string[]
}
/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示。 */
@@ -102,7 +105,8 @@ export type ExtensionToWebview
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览
@@ -116,7 +120,7 @@ export type WebviewToExtension
| { type: 'probeDefinition', id: number, version: number, offset: number }
| { type: 'goToDefinition', version: number, offset: number }
| { type: 'findOccurrences', id: number, version: number, offset: number }
| { type: 'ask', askId: string, anchor: string, question: string }
| { type: 'ask', askId: string, anchor: string, question: string, images: ClaudeImage[] }
| { type: 'stopAsk', askId: string }
| { type: 'deleteQa', anchor: string, entryId: string }
| { type: 'selectAskProfile', id: string }
@@ -1,4 +1,5 @@
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { BlockView } from '../blockView'
import type { RuleLocation, RuleReference } from '../contract'
import type { CodeQa } from '../qa/codeQa'
@@ -45,7 +46,9 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
) {}
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri)] }
// 问答带的图片(工作区文件夹的 .spx/code-qa/)也要能读,历史里才画得出缩略图
const imageRoot = this.qa.imageRoot(document.uri)
panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : [])] }
panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html')
const key = document.uri.toString()
const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, this.reveals.get(key))
@@ -219,7 +222,7 @@ class Preview {
private postQa(): void {
if (this.ready)
this.post(this.owner.qa.view(this.document.uri))
this.post(this.owner.qa.view(this.document.uri, file => this.panel.webview.asWebviewUri(file).toString()))
}
/** profile 列表每次现读(同看板),读完页面还在才发。 */
@@ -233,14 +236,14 @@ class Preview {
* 问 anchor 那块代码:按最近一次发出去的块树找到它,把完整问题和追问的话都交给 CodeQa,接不接着上次的会话由它定。
* 找不到这块(代码刚改过、还没重新发)或问不了时,直接回一条失败。
*/
private async ask(askId: string, anchor: string, question: string): Promise<void> {
private async ask(askId: string, anchor: string, question: string, images: ClaudeImage[]): Promise<void> {
const at = locate(this.document.uri)
const subject = at && this.sent && { path: at.key, languageId: this.document.languageId, ...this.sent, anchor }
const prompt = subject && askPrompt(subject, question)
const followUp = subject && followUpPrompt(subject, question)
const refused = prompt === undefined || followUp === undefined
? '这块代码刚改过,找不到了。等预览刷新后重新点「问 AI」再问。'
: await this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, followUp })
: await this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, followUp, images })
if (refused !== undefined) {
this.post({ type: 'answerEnd', askId, status: 'failed', error: refused })
return
@@ -264,7 +267,7 @@ class Preview {
await this.push()
return
case 'ask':
await this.ask(msg.askId, msg.anchor, msg.question)
await this.ask(msg.askId, msg.anchor, msg.question, msg.images)
return
case 'selectAskProfile':
await this.owner.qa.profiles.select(msg.id)
+6 -2
View File
@@ -1,7 +1,7 @@
import type { FileDeleteEvent, FileRenameEvent, WorkspaceFolder } from 'vscode'
import type { PathMap, PathMapFile } from './pathMap'
import { Disposable, EventEmitter, RelativePattern, Uri, window, workspace } from 'vscode'
import { changedKeys, dropEntries, entriesUnder, moveEntries, placeEntries, readPathMap, toPathKey, writePathMap } from './pathMap'
import { changedKeys, detachedFiles, dropEntries, entriesUnder, moveEntries, placeEntries, readPathMap, removeFiles, toPathKey, writePathMap } from './pathMap'
/** 一个文件 / 目录在哪个工作区文件夹里、键是什么。 */
export interface PathLocation {
@@ -50,7 +50,10 @@ export class FolderStore<T> implements Disposable {
return this.maps.get(folder.uri.toString())?.[key]
}
/** 读盘上最新的一份改好再写回;没变就不写,所以第一条存下之前不会生成文件。 */
/**
* 读盘上最新的一份改好再写回;没变就不写,所以第一条存下之前不会生成文件。
* 写好后删掉不再有条目带着的文件(见 PathMapFile.attachments):删一条、删源文件都跟着删。
*/
update(folder: WorkspaceFolder, change: (map: PathMap<T>) => PathMap<T>): Promise<void> {
return this.enqueue(async () => {
const before = await readPathMap(folder.uri.fsPath, this.file)
@@ -59,6 +62,7 @@ export class FolderStore<T> implements Disposable {
return
await writePathMap(folder.uri.fsPath, this.file, after)
this.apply(folder, after)
await removeFiles(folder.uri.fsPath, detachedFiles(this.file, before, after))
})
}
+36 -1
View File
@@ -1,5 +1,9 @@
import type { PathMapFile } from './pathMap'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { changedKeys, dropEntries, entriesUnder, moveEntries, normalizeKey, placeEntries, toPathKey } from './pathMap'
import { changedKeys, detachedFiles, dropEntries, entriesUnder, moveEntries, normalizeKey, placeEntries, removeFiles, toPathKey } from './pathMap'
describe('路径的键', () => {
it('相对工作区文件夹、用 / 分隔', () => {
@@ -92,3 +96,34 @@ describe('变了哪些行', () => {
expect(changedKeys({ a: '1' }, { a: '1' })).toEqual([])
})
})
describe('条目带着的文件', () => {
const file: PathMapFile<string[]> = { path: '.spx/x.json', label: 'x', parse: () => undefined, attachments: files => files }
it('改表后没有哪条再带着的才删:删掉的条目、改掉的引用;别的条目还带着同一个文件的留着', () => {
const before = { 'a.py': ['.spx/f/1.png', '.spx/f/2.png'], 'b.py': ['.spx/f/3.png', '.spx/f/2.png'] }
expect(detachedFiles(file, before, { 'b.py': ['.spx/f/3.png', '.spx/f/2.png'] })).toEqual(['.spx/f/1.png'])
expect(detachedFiles(file, before, { 'a.py': ['.spx/f/1.png'] }).sort()).toEqual(['.spx/f/2.png', '.spx/f/3.png'])
expect(detachedFiles(file, before, before)).toEqual([])
})
it('不带文件的表什么都不删', () => {
expect(detachedFiles({ ...file, attachments: undefined }, { a: ['x'] }, {})).toEqual([])
})
it('删文件:已经没了的不算错,跑到工作区文件夹外面的路径不碰', async () => {
const parent = await fsp.mkdtemp(path.join(os.tmpdir(), 'path-map-'))
try {
const root = path.join(parent, 'work')
await fsp.mkdir(path.join(root, '.spx', 'f'), { recursive: true })
await fsp.writeFile(path.join(root, '.spx', 'f', '1.png'), '')
await fsp.writeFile(path.join(parent, 'outside.png'), '')
await removeFiles(root, ['.spx/f/1.png', '.spx/f/missing.png', '../outside.png', path.join(parent, 'outside.png')])
await expect(fsp.access(path.join(root, '.spx', 'f', '1.png'))).rejects.toThrow()
await expect(fsp.access(path.join(parent, 'outside.png'))).resolves.toBeUndefined()
}
finally {
await fsp.rm(parent, { recursive: true, force: true })
}
})
})
+20 -1
View File
@@ -16,6 +16,8 @@ export interface PathMapFile<T> {
label: string
/** 认一条的值:不合法的返回 undefined,这条就跳过;合法的可以顺手规范成统一写法 */
parse: (value: unknown) => T | undefined
/** 一条值带着的文件(相对工作区文件夹,`/` 分隔,例如问答的图片):改表后不再有哪条带着的,跟着删掉 */
attachments?: (value: T) => string[]
}
/** 规范成表的键:去掉 `./`、重复和结尾的 `/`;工作区文件夹本身、文件夹外面的路径没有键。 */
@@ -62,7 +64,7 @@ export function changedKeys<T>(before: PathMap<T>, after: PathMap<T>): string[]
return [...keys].filter(key => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
}
function fileIn(workspaceRoot: string, file: PathMapFile<unknown>): string {
function fileIn<T>(workspaceRoot: string, file: PathMapFile<T>): string {
return path.join(workspaceRoot, ...file.path.split('/'))
}
@@ -100,3 +102,20 @@ export async function writePathMap<T>(workspaceRoot: string, file: PathMapFile<T
const sorted = Object.fromEntries(Object.entries(map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
await fsp.writeFile(target, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8')
}
/** 改表前带着、改表后没有哪条再带着的文件(见 PathMapFile.attachments)。 */
export function detachedFiles<T>(file: PathMapFile<T>, before: PathMap<T>, after: PathMap<T>): string[] {
const { attachments } = file
if (!attachments)
return []
const kept = new Set(Object.values(after).flatMap(attachments))
return [...new Set(Object.values(before).flatMap(attachments))].filter(attachment => !kept.has(attachment))
}
/** 删掉工作区文件夹里的这些文件(相对路径);已经没了的不算错,跑到文件夹外面的路径不碰。 */
export async function removeFiles(workspaceRoot: string, relativePaths: readonly string[]): Promise<void> {
await Promise.all(relativePaths.flatMap((relativePath) => {
const key = normalizeKey(relativePath)
return key === undefined ? [] : [fsp.rm(path.join(workspaceRoot, ...key.split('/')), { force: true })]
}))
}
+2 -2
View File
@@ -80,11 +80,11 @@ describe('记住最近选的 profile', () => {
const profiles = new AskProfiles(memento({ [ASK_PROFILE_KEY]: 'sonnet' }), source())
const chosen = await profiles.current()
expect(chosen).toEqual(profile('sonnet'))
expect(askArgs('问题', { profilePath: chosen?.path }).slice(0, 2)).toEqual(['--settings', `${DIR}/sonnet.json`])
expect(askArgs({ profilePath: chosen?.path }).slice(0, 2)).toEqual(['--settings', `${DIR}/sonnet.json`])
const none = await new AskProfiles(memento(), source([])).current()
expect(none).toBeUndefined()
expect(askArgs('问题', { profilePath: none?.path })).not.toContain('--settings')
expect(askArgs({ profilePath: none?.path })).not.toContain('--settings')
})
it('没选过:用插件的默认 profile(和以前问 AI 用的一样)', async () => {
+1 -1
View File
@@ -17,7 +17,7 @@ function fakeRunner(): { runner: (run: AskRun) => Promise<Answer>, runs: (AskRun
}
function start(askId: string, anchor = askId, owner = 'file:///a.py'): AskStart {
return { owner, askId, anchor, question: `问 ${askId}`, run: { prompt: `完整的 ${askId}`, cwd: '/work' } }
return { owner, askId, anchor, question: `问 ${askId}`, run: { prompt: `完整的 ${askId}`, images: [], cwd: '/work' } }
}
function listener(): { updates: RunningAsk[], ends: [RunningAsk, AskEnd][], ended: Promise<void>, update: (ask: RunningAsk) => void, end: (ask: RunningAsk, end: AskEnd) => void } {
+57 -10
View File
@@ -30,12 +30,26 @@ function fakeClaude(runs: { lines: string[], error?: Error }[]): { deps: AskDeps
function request(overrides: Partial<AskRun> = {}): AskRun & { events: unknown[] } {
const events: unknown[] = []
return { prompt: '完整的问题', cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides }
return { prompt: '完整的问题', images: [], cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides }
}
const PNG = { mediaType: 'image/png', base64: 'iVBORw0KGgo=' }
const JPEG = { mediaType: 'image/jpeg', base64: '/9j/4AAQ' }
/** 从 stdin 发出去的那条消息 */
function sent(call: RunLinesOptions): { text: string, images: unknown[] } {
expect(call.input?.endsWith('\n')).toBe(true)
const message = JSON.parse(call.input ?? '') as { type: string, message: { role: string, content: { type: string, text?: string, source?: unknown }[] } }
expect(message.type).toBe('user')
expect(message.message.role).toBe('user')
const [first, ...rest] = message.message.content
expect(first.type).toBe('text')
return { text: first.text ?? '', images: rest }
}
describe('命令行参数', () => {
it('只读:只给读、搜的工具,别的一律拒、不等人批,不加载 MCP;不带跳过权限;输出逐字的 stream-json', () => {
const args = askArgs('问题', { profilePath: '/p/offical.json' })
it('只读:只给读、搜的工具,别的一律拒、不等人批,不加载 MCP;不带跳过权限;问题从 stdin 按 stream-json 进,输出逐字的 stream-json', () => {
const args = askArgs({ profilePath: '/p/offical.json' })
expect(args).toEqual([
'--settings',
'/p/offical.json',
@@ -47,7 +61,8 @@ describe('命令行参数', () => {
'dontAsk',
'--strict-mcp-config',
'-p',
'问题',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
@@ -56,10 +71,41 @@ describe('命令行参数', () => {
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|Edit|Write|Bash/)
})
it('追问带 --resume,放在问题前面;没有 profile 就不带 --settings', () => {
const args = askArgs('追问', { resume: 'session-1' })
it('追问带 --resume,放在 -p 前面;没有 profile 就不带 --settings', () => {
const args = askArgs({ resume: 'session-1' })
expect(args).not.toContain('--settings')
expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 4)).toEqual(['--resume', 'session-1', '-p', '追问'])
expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 'session-1', '-p'])
})
})
describe('问题连同图片从 stdin 发', () => {
it('一条用户消息:先是问题的文字,后面按贴的先后跟着图片(base64 和格式原样)', async () => {
const { deps, calls } = fakeClaude([{ lines: [result('答案', 's1')] }])
await askClaude(request({ images: [PNG, JPEG] }), deps)
expect(sent(calls[0])).toEqual({
text: '完整的问题',
images: [
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } },
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: '/9j/4AAQ' } },
],
})
})
it('没贴图:只有问题的文字,照样走 stdin', async () => {
const { deps, calls } = fakeClaude([{ lines: [result('答案', 's1')] }])
await askClaude(request(), deps)
expect(sent(calls[0])).toEqual({ text: '完整的问题', images: [] })
expect(calls[0].args).not.toContain('完整的问题')
})
it('追问、会话找不到重新问:两次都带上图片', async () => {
const { deps, calls } = fakeClaude([
{ lines: [], error: new ClaudeError('Claude 退出码非零 (1): No conversation found with session ID: s0', 'No conversation found with session ID: s0') },
{ lines: [result('重新答', 's2')] },
])
await askClaude(request({ images: [PNG], followUp: { sessionId: 's0', prompt: '短问题' } }), deps)
expect(sent(calls[0])).toMatchObject({ text: '短问题', images: [{ source: { data: PNG.base64 } }] })
expect(sent(calls[1])).toMatchObject({ text: '完整的问题', images: [{ source: { data: PNG.base64 } }] })
})
})
@@ -72,7 +118,7 @@ describe('问一次', () => {
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({ cwd: '/work', signal: run.signal, timeoutMs: 300_000 })
expect(calls[0].args.slice(0, 2)).toEqual(['--settings', '/profiles/sonnet.json'])
expect(calls[0].args[calls[0].args.indexOf('-p') + 1]).toBe('完整的问题')
expect(sent(calls[0]).text).toBe('完整的问题')
})
it('选的是「默认」(没给 profile):不带 --settings', async () => {
@@ -85,7 +131,8 @@ describe('问一次', () => {
const { deps, calls } = fakeClaude([{ lines: [result('接着答', 's1')] }])
expect(await askClaude(request({ profilePath: '/profiles/opus.json', followUp: { sessionId: 's1', prompt: '短问题' } }), deps)).toEqual({ text: '接着答', sessionId: 's1' })
expect(calls[0].args.slice(0, 2)).toEqual(['--settings', '/profiles/opus.json'])
expect(calls[0].args.slice(calls[0].args.indexOf('--resume'), calls[0].args.indexOf('--resume') + 4)).toEqual(['--resume', 's1', '-p', '短问题'])
expect(calls[0].args.slice(calls[0].args.indexOf('--resume'), calls[0].args.indexOf('--resume') + 2)).toEqual(['--resume', 's1'])
expect(sent(calls[0]).text).toBe('短问题')
})
it('上次的会话找不到了:按完整问题重新开一次', async () => {
@@ -97,7 +144,7 @@ describe('问一次', () => {
expect(calls).toHaveLength(2)
expect(calls[1].args).not.toContain('--resume')
expect(calls[1].args.slice(0, 2)).toEqual(['--settings', '/profiles/opus.json'])
expect(calls[1].args[calls[1].args.indexOf('-p') + 1]).toBe('完整的问题')
expect(sent(calls[1]).text).toBe('完整的问题')
})
it('别的失败不重试,照样抛出来;Claude 报的失败、没给回答也算失败', async () => {
+10 -5
View File
@@ -3,8 +3,9 @@
* 用问的时候选的 profile(见 askProfiles);回答按 stream-json 逐字交出来,可以叫停。
*/
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { AnswerEvent } from './answerStream'
import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude'
import { buildStreamJsonLine, CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude'
import { createAnswerParser } from './answerStream'
/** 只给读、搜文件的工具。 */
@@ -13,11 +14,11 @@ const READ_ONLY_TOOLS = 'Read,Grep,Glob'
export const ASK_TIMEOUT_MS = 5 * 60_000
/**
* 一次提问的命令行参数。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`),
* 一次提问的命令行参数;问题连同贴的图片从 stdin 按 stream-json 发(见 askClaude),没贴图也一样。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`),
* 别的一律拒绝、不等人批(`--permission-mode dontAsk`,盖过 profile 里的 bypassPermissions);
* 不加载任何 MCP 服务(`--strict-mcp-config` 又不给 `--mcp-config`),免得 MCP 工具能改东西。
*/
export function askArgs(prompt: string, options: { profilePath?: string, resume?: string } = {}): string[] {
export function askArgs(options: { profilePath?: string, resume?: string } = {}): string[] {
return [
...(options.profilePath ? ['--settings', options.profilePath] : []),
'--tools',
@@ -29,7 +30,8 @@ export function askArgs(prompt: string, options: { profilePath?: string, resume?
'--strict-mcp-config',
...(options.resume ? ['--resume', options.resume] : []),
'-p',
prompt,
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
@@ -40,6 +42,8 @@ export function askArgs(prompt: string, options: { profilePath?: string, resume?
export interface AskRun {
/** 带着代码上下文的完整问题 */
prompt: string
/** 问题带的图片(贴进输入框的),追问、找不到会话重新问时一样带上 */
images: ClaudeImage[]
/** 用这个 profile 的设置(`--settings`);没有就不带,claude 用 ~/.claude 的登录。追问换了 profile 照样接着会话问 */
profilePath?: string
/** 追问:接着 sessionId 那次会话问 prompt;会话找不到了(过期、被清理)就按完整问题重新开一次 */
@@ -74,7 +78,8 @@ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Prom
let answer: Answer | undefined
let failure: string | undefined
await deps.runLines({
args: askArgs(prompt, { profilePath: run.profilePath, resume }),
args: askArgs({ profilePath: run.profilePath, resume }),
input: buildStreamJsonLine(prompt, run.images),
cwd: run.cwd,
signal: run.signal,
timeoutMs: ASK_TIMEOUT_MS,
+44 -8
View File
@@ -1,14 +1,16 @@
import type { Disposable, Memento, Uri } from 'vscode'
import type { Disposable, Memento } from 'vscode'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { QaMessage } from '../editor/messages'
import type { AskEnd, RunningAsk } from './asks'
import type { AskRunner } from './claudeAsk'
import type { QaEntry } from './qaMap'
import { randomUUID } from 'node:crypto'
import { EventEmitter } from 'vscode'
import { EventEmitter, Uri } from 'vscode'
import { FolderStore, locate } from '../folderStore'
import { AskProfiles } from './askProfiles'
import { QaAsks } from './asks'
import { askClaude } from './claudeAsk'
import { isQaImageType, QA_IMAGE_DIR, writeQaImages } from './qaImages'
import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap'
/** 正在答的问题有了进展(update),或答完了(end;答成了带存下的那条的 id)。owner 是问的那份文件的 uri。 */
@@ -23,6 +25,8 @@ export interface AskRequest {
question: string
prompt: string
followUp: string
/** 贴进输入框的图片,随问题一起发;答成了存进 `.spx/code-qa/` */
images: ClaudeImage[]
}
/**
@@ -44,16 +48,33 @@ export class CodeQa implements Disposable {
this.asks = new QaAsks(runner)
}
/** 发给预览的这份文件的问答;文件不在工作区文件夹里时没地方存,问 AI 不可用。 */
view(uri: Uri): QaMessage {
/** uri 这份文件的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);文件不在工作区文件夹里时没有。 */
imageRoot(uri: Uri): Uri | undefined {
const at = locate(uri)
return at && Uri.joinPath(at.folder.uri, ...QA_IMAGE_DIR.split('/'))
}
/**
* 发给预览的这份文件的问答;文件不在工作区文件夹里时没地方存,问 AI 不可用。
* 问题带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。
*/
view(uri: Uri, imageUrl: (file: Uri) => string): QaMessage {
const at = locate(uri)
const threads = at ? this.store.get(at.folder, at.key) ?? [] : []
const root = this.imageRoot(uri)
return {
type: 'qa',
available: at !== undefined,
threads: threads.map(({ anchor, entries }) => ({
anchor,
entries: entries.map(({ id, question, answer, createdAt, profile }) => ({ id, question, answer, createdAt, ...(profile && { profile }) })),
entries: entries.map(({ id, question, answer, createdAt, profile, images }) => ({
id,
question,
answer,
createdAt,
...(profile && { profile }),
...(root && images && { images: images.map(name => imageUrl(Uri.joinPath(root, name))) }),
})),
})),
running: this.asks.runningFor(uri.toString()),
}
@@ -63,17 +84,21 @@ export class CodeQa implements Disposable {
* 开始问;问不了时返回原因(给人看的话)。在工作区文件夹里跑 claude,它读得到整个项目。
* 这块问过的就接着最近一次的会话问(收起再展开、重启 VS Code 都一样),没问过就带上代码上下文开新会话。
* 用眼下选的 profile;和那次会话开头用的不一样也照样接着问(`--resume` 和 `--settings` 能一起用)。
* 带的图片随问题发,答成了才写进 `.spx/code-qa/`、记在这条上;没答成不留文件。
*/
async ask(uri: Uri, request: AskRequest): Promise<string | undefined> {
const at = locate(uri)
if (!at)
return '这个文件不在工作区文件夹里,问答没地方存。'
const { askId, anchor, question, prompt, followUp, images } = request
if (!images.every(image => isQaImageType(image.mediaType)))
return '只能带 PNG、JPEG、GIF、WebP 图片。'
const profile = await this.profiles.current()
const owner = uri.toString()
const { askId, anchor, question, prompt, followUp } = request
const sessionId = latestSession(this.store.get(at.folder, at.key), anchor)
const run = {
prompt,
images,
...(sessionId && { followUp: { sessionId, prompt: followUp } }),
...(profile && { profilePath: profile.path }),
cwd: at.folder.uri.fsPath,
@@ -85,13 +110,24 @@ export class CodeQa implements Disposable {
this.answered.fire({ owner, kind: 'end', ask, end })
return
}
const id = randomUUID()
let names: string[]
try {
names = await writeQaImages(at.folder.uri.fsPath, id, images)
}
catch (err) {
const error = `回答没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})`
this.answered.fire({ owner, kind: 'end', ask, end: { status: 'failed', error } })
return
}
const entry: QaEntry = {
id: randomUUID(),
id,
question,
answer: end.answer.text,
createdAt: new Date().toISOString(),
sessionId: end.answer.sessionId,
...(profile && { profile: profile.name }),
...(names.length > 0 && { images: names }),
}
await this.store.update(at.folder, map => withEntry(map, at.key, anchor, entry))
this.answered.fire({ owner, kind: 'end', ask, end, entryId: entry.id })
@@ -103,7 +139,7 @@ export class CodeQa implements Disposable {
this.asks.stop(askId)
}
/** 删掉一次问答,不用确认;块下面删光了这块就不挂了。 */
/** 删掉一次问答,不用确认;块下面删光了这块就不挂了,带的图片跟着删(见 QA_FILE.attachments)。 */
async delete(uri: Uri, anchor: string, entryId: string): Promise<void> {
const at = locate(uri)
if (at)
+49
View File
@@ -0,0 +1,49 @@
import { Buffer } from 'node:buffer'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { isQaImageName, isQaImageType, writeQaImages } from './qaImages'
let root: string
beforeEach(async () => {
root = await fsp.mkdtemp(path.join(os.tmpdir(), 'code-qa-images-'))
})
afterEach(async () => {
await fsp.rm(root, { recursive: true, force: true })
})
describe('问题带的图片', () => {
it('认 PNG、JPEG、GIF、WebP,别的不认', () => {
for (const type of ['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
expect(isQaImageType(type)).toBe(true)
for (const type of ['image/svg+xml', 'image/bmp', 'text/plain', '', 'toString'])
expect(isQaImageType(type)).toBe(false)
})
it('写进 .spx/code-qa/:<问答 id>-<第几张>.<扩展名>,内容是 base64 解出来的原样', async () => {
const png = Buffer.from([0x89, 0x50, 0x4E, 0x47])
const jpeg = Buffer.from([0xFF, 0xD8, 0xFF])
const names = await writeQaImages(root, 'e1', [
{ mediaType: 'image/png', base64: png.toString('base64') },
{ mediaType: 'image/jpeg', base64: jpeg.toString('base64') },
])
expect(names).toEqual(['e1-1.png', 'e1-2.jpg'])
expect(await fsp.readFile(path.join(root, '.spx', 'code-qa', 'e1-1.png'))).toEqual(png)
expect(await fsp.readFile(path.join(root, '.spx', 'code-qa', 'e1-2.jpg'))).toEqual(jpeg)
expect(names.every(isQaImageName)).toBe(true)
})
it('没带图片:什么都不建', async () => {
expect(await writeQaImages(root, 'e1', [])).toEqual([])
await expect(fsp.access(path.join(root, '.spx'))).rejects.toThrow()
})
it('问答里记的名字只认这边起的样子,跑到别处的路径不认', () => {
expect(isQaImageName('0b6c2f9e-3c1d-4a8e-9f00-1234567890ab-3.webp')).toBe(true)
for (const name of ['../x-1.png', 'a/b-1.png', 'e1-1.svg', 'e1.png', 'e1-1.png/..', ''])
expect(isQaImageName(name)).toBe(false)
})
})
+40
View File
@@ -0,0 +1,40 @@
/**
* 问题带的图片怎么存:答成了才写,每张一个文件,放在工作区文件夹的 `.spx/code-qa/` 里,
* 名字是 `<问答 id>-<第几张>.<扩展名>`,问答里只记名字(见 qaMap)。按问答 id 起名,文件改名、挪位置时不用动。
*/
import type { ClaudeImage } from '../../cc/spawnClaude'
import { Buffer } from 'node:buffer'
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'
/** 图片放在哪(相对工作区文件夹,`/` 分隔);第一张存下时才建 */
export const QA_IMAGE_DIR = '.spx/code-qa'
const EXTENSIONS: Readonly<Record<string, string>> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
}
/** 认的图片格式:PNG、JPEG、GIF、WebP(Claude 认的这几种) */
export function isQaImageType(mediaType: string): boolean {
return Object.hasOwn(EXTENSIONS, mediaType)
}
/** 问答里记的图片名是不是这边起的:手改 `code-qa.json` 写成别的路径(`../x`)的不认,删问答时也不会删到别处 */
export function isQaImageName(name: string): boolean {
return /^[\w-]+-\d+\.(?:png|jpg|gif|webp)$/.test(name)
}
/** 把 entryId 那条问答带的图片写进 `<rootPath>/.spx/code-qa/`,返回按先后的文件名;没有图片时什么都不建。 */
export async function writeQaImages(rootPath: string, entryId: string, images: readonly ClaudeImage[]): Promise<string[]> {
if (images.length === 0)
return []
const dir = path.join(rootPath, ...QA_IMAGE_DIR.split('/'))
await fsp.mkdir(dir, { recursive: true })
const names = images.map((image, index) => `${entryId}-${index + 1}.${EXTENSIONS[image.mediaType]}`)
await Promise.all(images.map((image, index) => fsp.writeFile(path.join(dir, names[index]), Buffer.from(image.base64, 'base64'))))
return names
}
+55 -1
View File
@@ -1,9 +1,11 @@
import type { QaEntry, QaMap } from './qaMap'
import { Buffer } from 'node:buffer'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { dropEntries, moveEntries, readPathMap, writePathMap } from '../pathMap'
import { detachedFiles, dropEntries, moveEntries, readPathMap, removeFiles, writePathMap } from '../pathMap'
import { writeQaImages } from './qaImages'
import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap'
function entry(id: string, createdAt: string, question = `问 ${id}`): QaEntry {
@@ -143,3 +145,55 @@ describe('读写 .spx/code-qa.json', () => {
expect(await fsp.readFile(qaFile(), 'utf8')).toBe('{}\n')
})
})
describe('问题带的图片', () => {
const imageFile = (name: string): string => path.join(root, '.spx', 'code-qa', name)
const exists = (file: string): Promise<boolean> => fsp.access(file).then(() => true, () => false)
const png = (id: string): { mediaType: string, base64: string } => ({ mediaType: 'image/png', base64: Buffer.from(id).toString('base64') })
/** 同 FolderStore.update:读盘上的一份改好写回,再删掉不再有条目带着的文件 */
async function update(change: (map: QaMap) => QaMap): Promise<void> {
const before = await readPathMap(root, QA_FILE)
const after = change(before)
await writePathMap(root, QA_FILE, after)
await removeFiles(root, detachedFiles(QA_FILE, before, after))
}
it('记在这条上(.spx/code-qa/ 里的文件名)、读得回来;认不出的名字(跑到别处的路径、不是字符串)跳过,一张都不剩就没有这一项', async () => {
const withImages: QaEntry = { ...entry('p', '2026-09-26T10:00:00.000Z'), images: ['p-1.png', 'p-2.webp'] }
await writePathMap(root, QA_FILE, withEntry({}, 'a.py', IF, withImages))
expect(await fsp.readFile(qaFile(), 'utf8')).toContain('"images": [')
expect(await readPathMap(root, QA_FILE)).toEqual({ 'a.py': [{ anchor: IF, entries: [withImages] }] })
await writeRaw({ 'a.py': [{ anchor: IF, entries: [
{ ...entry('x', '2026-09-25T10:00:00.000Z'), images: ['../../secret-1.png', 'x-1.png', 7, 'x-2.exe'] },
{ ...entry('y', '2026-09-26T10:00:00.000Z'), images: ['../y-1.png'] },
{ ...entry('z', '2026-09-26T11:00:00.000Z'), images: 'z-1.png' },
] }] })
expect(await readPathMap(root, QA_FILE)).toEqual({ 'a.py': [{ anchor: IF, entries: [
{ ...entry('x', '2026-09-25T10:00:00.000Z'), images: ['x-1.png'] },
entry('y', '2026-09-26T10:00:00.000Z'),
entry('z', '2026-09-26T11:00:00.000Z'),
] }] })
})
it('删一次问答,它的图片跟着删;别的问答的图片留着', async () => {
const a = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1'), png('a2')]) }
const b = { ...entry('b', '2026-09-26T10:00:00.000Z'), images: await writeQaImages(root, 'b', [png('b1')]) }
await update(map => withEntry(withEntry(map, 'a.py', IF, a), 'a.py', DISPLAY, b))
expect(await exists(imageFile('a-1.png'))).toBe(true)
await update(map => withoutEntry(map, 'a.py', IF, 'a'))
expect(await exists(imageFile('a-1.png'))).toBe(false)
expect(await exists(imageFile('a-2.png'))).toBe(false)
expect(await exists(imageFile('b-1.png'))).toBe(true)
})
it('源文件删了、问答跟着删时图片也删;改名、挪位置按问答 id 起的名字不用动', async () => {
const a = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1')]) }
await update(map => withEntry(map, 'src/a.py', IF, a))
await update(map => moveEntries(map, 'src', 'lib'))
expect(await readPathMap(root, QA_FILE)).toEqual({ 'lib/a.py': [{ anchor: IF, entries: [a] }] })
expect(await exists(imageFile('a-1.png'))).toBe(true)
await update(map => dropEntries(map, 'lib/a.py'))
expect(await exists(imageFile('a-1.png'))).toBe(false)
})
})
+16 -2
View File
@@ -1,10 +1,12 @@
/**
* 代码问答的存法:每个工作区文件夹一份 `<workspace>/.spx/code-qa.json`,读写的规矩同文件注释(见 pathMap)。
* 形状是 `{ "相对路径": [{ anchor, entries: [{ id, question, answer, createdAt, sessionId }] }] }`:
* 形状是 `{ "相对路径": [{ anchor, entries: [{ id, question, answer, createdAt, sessionId, profile?, images? }] }] }`:
* 一份文件里挂着问答的每一块一项(anchor 见 BlockAnchors),块下面问过的按时间排。
* 问题带的图片是 `.spx/code-qa/` 里的文件,条目删了(删一次问答、删源文件)文件跟着删(见 attachments)。
*/
import type { PathMap, PathMapFile } from '../pathMap'
import { isQaImageName, QA_IMAGE_DIR } from './qaImages'
/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、Claude Code 的会话 id。 */
export interface QaEntry {
@@ -15,6 +17,8 @@ export interface QaEntry {
sessionId: string
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
profile?: string
/** 问题带的图片:`.spx/code-qa/` 里的文件名(见 qaImages),按贴的先后;没带图片时没有 */
images?: string[]
}
/** 挂在一块上的问答。 */
@@ -39,7 +43,16 @@ function parseEntry(value: unknown): QaEntry | undefined {
const [id, question, answer, createdAt, profile] = [text(value.id), text(value.question), text(value.answer), text(value.createdAt), text(value.profile)]
if (!id || !question || !answer || !createdAt || Number.isNaN(Date.parse(createdAt)))
return undefined
return { id, question, answer, createdAt, sessionId: typeof value.sessionId === 'string' ? value.sessionId : '', ...(profile && { profile }) }
const images = Array.isArray(value.images) ? value.images.filter((name): name is string => typeof name === 'string' && isQaImageName(name)) : []
return {
id,
question,
answer,
createdAt,
sessionId: typeof value.sessionId === 'string' ? value.sessionId : '',
...(profile && { profile }),
...(images.length > 0 && { images }),
}
}
/** 同一块的并成一项;块按记号排,问答按时间排:写出来的顺序稳定,git diff 干净。 */
@@ -74,6 +87,7 @@ export const QA_FILE: PathMapFile<QaThread[]> = {
path: '.spx/code-qa.json',
label: '代码问答',
parse: parseThreads,
attachments: threads => threads.flatMap(thread => thread.entries.flatMap(entry => (entry.images ?? []).map(name => `${QA_IMAGE_DIR}/${name}`))),
}
/** 在 key 这份文件的 anchor 那块下面记一次问答。 */
@@ -92,4 +92,20 @@ describe('块下面的问答区', () => {
expect(text(html)).toContain('UserRole 还有哪些角色?昨天')
expect(text(html)).not.toContain('昨天 ·')
})
it('问题带的图片:历史和当前这一轮在问题下面一排缩略图,点开看大图,不能去掉;没带图片的不画这一排', () => {
const stored = ['https://file+.vscode-resource.vscode-cdn.net/work/.spx/code-qa/e1-1.png', 'https://file+.vscode-resource.vscode-cdn.net/work/.spx/code-qa/e1-2.jpg']
const html = render({ open: true, history: [{ ...HISTORY[0]!, images: stored }, HISTORY[1]!], asks: [ask({ images: ['data:image/png;base64,AAA'] })] })
const [first, second] = html.split('data-history=""').slice(1)
expect(first).toContain('<div class="cr-qa-thumbs">')
expect(first.indexOf('cr-qa-thumbs')).toBeGreaterThan(first.indexOf('cr-qa-head'))
expect(first.indexOf('cr-qa-thumbs')).toBeLessThan(first.indexOf('cr-qa-preview'))
expect(first).toContain(`<img src="${stored[0]}" alt=""/>`)
expect(first).toContain('aria-label="看第 2 张图片的大图"')
expect(second.slice(0, second.indexOf('data-current'))).not.toContain('cr-qa-thumbs')
const current = html.slice(html.indexOf('data-current'))
expect(current).toContain('<img src="data:image/png;base64,AAA" alt=""/>')
expect(html).not.toContain('cr-qa-thumb-remove')
expect(html).not.toContain('data-pending')
})
})
+152 -45
View File
@@ -1,14 +1,70 @@
import type { KeyboardEvent, ReactElement } from 'react'
import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'react'
import type { Ask } from './qa'
import type { PendingImage } from './qaImages'
import type { ProfileChoice, QaEntryView } from './types'
import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert } from 'lucide-react'
import { useState } from 'react'
import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { SelectMenu } from '@/components/ui/select-menu'
import { renderMarkdown, renderMarkdownOnce } from './markdown'
import { answerPreview, relativeTime } from './qa'
import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages'
/** 问过的一次,灰字:问题、时间、「已理解,删除」,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */
function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number, onDelete: () => void }): ReactElement {
/** 问题带的图片:一排小缩略图,点一张看大图;给了 onRemove 的(贴了还没发的)右上角有「去掉」。没有图片时什么都不画。 */
function Thumbs({ urls, pending, onOpen, onRemove }: { urls: string[], pending?: boolean, onOpen: (url: string) => void, onRemove?: (index: number) => void }): ReactElement | null {
if (urls.length === 0)
return null
return (
<div className="cr-qa-thumbs" data-pending={pending ? '' : undefined}>
{urls.map((url, index) => (
// 同一张图可能贴两次、地址一样;缩略图没有自己的状态,按位置认就行
<span key={index} className="cr-qa-thumb">
<button type="button" className="cr-qa-thumb-open" title="看大图" aria-label={`看第 ${index + 1} 张图片的大图`} onClick={() => onOpen(url)}>
<img src={url} alt="" />
</button>
{onRemove && (
<button type="button" className="cr-qa-thumb-remove" title="去掉这张图片" aria-label={`去掉第 ${index + 1} 张图片`} onClick={() => onRemove(index)}>
<X size={10} strokeWidth={2.4} aria-hidden />
</button>
)}
</span>
))}
</div>
)
}
/** 看大图:盖住整个预览,点哪儿或按 Esc 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。 */
function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): ReactElement {
const close = useRef<HTMLButtonElement>(null)
useEffect(() => {
const opener = document.activeElement
close.current?.focus()
return () => {
if (opener instanceof HTMLElement)
opener.focus()
}
}, [])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape') {
// 只关大图:不收起问答区,也不清名字高亮(那边认 defaultPrevented)
event.preventDefault()
event.stopPropagation()
onClose()
}
}
return createPortal(
<div className="cr-qa-lightbox" role="dialog" aria-modal="true" aria-label="图片大图" onClick={onClose} onKeyDown={onKeyDown}>
<img src={url} alt="" />
<button ref={close} type="button" className="cr-qa-lightbox-close" title="关闭(Esc)" aria-label="关闭大图">
<X size={16} strokeWidth={1.8} aria-hidden />
</button>
</div>,
document.body,
)
}
/** 问过的一次,灰字:问题、时间、「已理解,删除」,问题带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */
function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement {
const [expanded, setExpanded] = useState(false)
const toggle = (): void => setExpanded(value => !value)
return (
@@ -28,6 +84,7 @@ function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
</button>
</div>
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
{expanded
? <div className="cr-markdown cr-qa-answer" dangerouslySetInnerHTML={{ __html: renderMarkdown(entry.answer) }} />
: (
@@ -40,7 +97,7 @@ function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number
}
/** 当前这一轮问的一次,照常颜色:问题,回答逐字长出来(按 Markdown 排),在做的事、出错、停止写在下面 */
function CurrentItem({ ask }: { ask: Ask }): ReactElement {
function CurrentItem({ ask, onOpenImage }: { ask: Ask, onOpenImage: (url: string) => void }): ReactElement {
const running = ask.status === 'running'
return (
<div className="cr-qa-entry" data-current="">
@@ -48,6 +105,7 @@ function CurrentItem({ ask }: { ask: Ask }): ReactElement {
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<span className="cr-qa-question">{ask.question}</span>
</div>
<Thumbs urls={ask.images ?? []} onOpen={onOpenImage} />
{ask.text !== '' && (
<div
className="cr-markdown cr-qa-answer"
@@ -72,24 +130,41 @@ function CurrentItem({ ask }: { ask: Ask }): ReactElement {
)
}
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止 */
function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose }: {
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose, onOpenImage }: {
running: Ask | undefined
/** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */
first: boolean
profiles: ProfileChoice
onSelectProfile: (id: string) => void
onAsk: (question: string) => void
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onClose: () => void
onOpenImage: (url: string) => void
}): ReactElement {
const [value, setValue] = useState('')
const [images, setImages] = useState<PendingImage[]>([])
/** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */
const [notice, setNotice] = useState<string>()
const textarea = useRef<HTMLTextAreaElement>(null)
const attach = (files: File[]): void => {
if (files.length === 0)
return
const picked = pickImages(files, images.length)
setNotice(picked.notice)
Promise.all(picked.accepted.map(readImage)).then(
read => setImages(current => [...current, ...read].slice(0, MAX_IMAGES)),
() => setNotice('有图片读不出来,没加上。'),
)
}
const send = (): void => {
const question = value.trim()
if (question === '' || running)
return
onAsk(question)
onAsk(question, images)
setValue('')
setImages([])
setNotice(undefined)
}
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
// 输入法选字时的回车不算发送
@@ -102,38 +177,63 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on
onClose()
}
}
// 剪贴板里的图片加到上面的缩略图里;文字照常粘进输入框,不拦
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>): void => attach(transferFiles(event.clipboardData.items))
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
}
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
const files = transferFiles(event.dataTransfer.items)
if (files.length > 0) {
event.preventDefault()
attach(files)
}
}
const remove = (index: number): void => {
setImages(current => current.filter((_, i) => i !== index))
textarea.current?.focus()
}
return (
<div className="cr-qa-input">
<textarea
rows={1}
autoFocus
value={value}
placeholder={`${first ? '问这块代码…' : '继续问…'} Enter 发送,Esc 收起`}
aria-label="问 AI 的问题"
onChange={event => setValue(event.target.value)}
onKeyDown={onKeyDown}
/>
<SelectMenu
variant="ghost"
value={profiles.selected}
options={profiles.options.map(option => ({ value: option.id, label: option.label }))}
onChange={onSelectProfile}
placeholder="默认"
title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
className="cr-qa-profile"
/>
{running
? (
<button type="button" className="cr-tool" title="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
</button>
)
: (
<button type="button" className="cr-tool" title="发送(Enter)" aria-label="发送" disabled={value.trim() === ''} onClick={send}>
<SendHorizontal size={14} strokeWidth={1.8} aria-hidden />
</button>
)}
</div>
<>
<Thumbs urls={images.map(image => image.url)} pending onOpen={onOpenImage} onRemove={remove} />
{notice && <div className="cr-qa-notice" role="status">{notice}</div>}
<div className="cr-qa-input" onDragOver={onDragOver} onDrop={onDrop}>
<textarea
ref={textarea}
rows={1}
autoFocus
value={value}
placeholder={`${first ? '问这块代码…' : '继续问…'} Enter 发送,Esc 收起`}
aria-label="问 AI 的问题"
onChange={event => setValue(event.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
/>
<SelectMenu
variant="ghost"
value={profiles.selected}
options={profiles.options.map(option => ({ value: option.id, label: option.label }))}
onChange={onSelectProfile}
placeholder="默认"
title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
className="cr-qa-profile"
/>
{running
? (
<button type="button" className="cr-tool" title="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
</button>
)
: (
<button type="button" className="cr-tool" title="发送(Enter)" aria-label="发送" disabled={value.trim() === ''} onClick={send}>
<SendHorizontal size={14} strokeWidth={1.8} aria-hidden />
</button>
)}
</div>
</>
)
}
@@ -146,7 +246,8 @@ export interface AskThreadProps {
profiles: ProfileChoice
now?: number
onSelectProfile: (id: string) => void
onAsk: (question: string) => void
/** images 是贴进输入框的图片,随问题一起发 */
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (entryId: string) => void
onClose: () => void
@@ -158,6 +259,9 @@ export interface AskThreadProps {
*/
export function AskThread({ history, asks, open, profiles, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(false)
/** 正在看大图的那张 */
const [preview, setPreview] = useState<string>()
const lightbox = preview !== undefined && <ImagePreview url={preview} onClose={() => setPreview(undefined)} />
if (!open) {
if (history.length === 0)
return null
@@ -169,15 +273,16 @@ export function AskThread({ history, asks, open, profiles, now = Date.now(), onS
{`问过 ${history.length} 次`}
<Chevron size={12} strokeWidth={1.8} aria-hidden />
</button>
{showHistory && history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} />)}
{showHistory && history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)}
{lightbox}
</div>
)
}
return (
<section className="cr-qa" aria-label="问 AI">
{history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} />)}
{history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)}
{history.length > 0 && asks.length > 0 && <hr className="cr-qa-divider" />}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} />)}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} onOpenImage={setPreview} />)}
<AskInput
running={asks.find(ask => ask.status === 'running')}
first={history.length === 0 && asks.length === 0}
@@ -186,7 +291,9 @@ export function AskThread({ history, asks, open, profiles, now = Date.now(), onS
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
onOpenImage={setPreview}
/>
{lightbox}
</section>
)
}
@@ -1,5 +1,6 @@
import type { CSSProperties, ReactElement } from 'react'
import type { QaState } from './qa'
import type { PendingImage } from './qaImages'
import type { BlockView } from './types'
import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react'
import { Fragment } from 'react'
@@ -37,7 +38,8 @@ export interface QaTree {
state: QaState
onToggle: (anchor: string) => void
onClose: (anchor: string) => void
onAsk: (anchor: string, question: string) => void
/** images 是贴进输入框的图片,随问题一起发 */
onAsk: (anchor: string, question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (anchor: string, entryId: string) => void
/** 换「问 AI」用的 profile(所有块共用),id 同 ProfileOption.id */
@@ -101,7 +103,7 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
open={qa.state.open.includes(anchor)}
profiles={qa.state.profiles}
onSelectProfile={qa.onSelectProfile}
onAsk={question => qa.onAsk(anchor, question)}
onAsk={(question, images) => qa.onAsk(anchor, question, images)}
onStop={qa.onStop}
onDelete={entryId => qa.onDelete(anchor, entryId)}
onClose={() => qa.onClose(anchor)}
@@ -621,6 +621,107 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
border: 0;
border-top: 1px solid color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
}
/* 问题带的图片:一排 40px 的缩略图,和回答一样往右缩到问题下面;贴了还没发的在输入框上面,右上角有「去掉」 */
.cr-qa-thumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 2px 0 2px 18px;
}
.cr-qa-thumbs[data-pending] {
padding: 6px 0 0;
}
.cr-qa-thumb {
position: relative;
display: flex;
}
.cr-qa-thumb-open {
display: flex;
overflow: hidden;
width: 40px;
height: 40px;
border: 1px solid color-mix(in srgb, var(--vscode-foreground) 18%, transparent);
border-radius: 4px;
background: var(--vscode-editor-background);
cursor: zoom-in;
}
.cr-qa-thumb-open img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cr-qa-thumb-open:hover {
border-color: var(--vscode-focusBorder);
}
.cr-qa-entry[data-history] .cr-qa-thumb-open img {
opacity: 0.7;
}
.cr-qa-entry[data-history] .cr-qa-thumb-open:hover img {
opacity: 1;
}
.cr-qa-thumb-remove {
position: absolute;
top: -6px;
right: -6px;
display: flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
border: 1px solid var(--vscode-widget-border, color-mix(in srgb, var(--vscode-foreground) 25%, transparent));
border-radius: 50%;
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
color: var(--vscode-descriptionForeground);
}
.cr-qa-thumb-remove:hover {
color: var(--vscode-foreground);
}
.cr-qa-thumb-open:focus-visible,
.cr-qa-thumb-remove:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
.cr-qa-notice {
color: var(--vscode-editorWarning-foreground, var(--vscode-descriptionForeground));
font-size: 11.5px;
}
/* 看大图:盖住整个预览,图片按比例放到最大,右上角「关闭」 */
.cr-qa-lightbox {
position: fixed;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: rgb(0 0 0 / 65%);
cursor: zoom-out;
inset: 0;
}
.cr-qa-lightbox img {
max-width: 100%;
max-height: 100%;
border-radius: 4px;
box-shadow: 0 6px 28px rgb(0 0 0 / 45%);
object-fit: contain;
}
.cr-qa-lightbox-close {
position: absolute;
top: 10px;
right: 10px;
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 1px solid var(--vscode-widget-border, transparent);
border-radius: 4px;
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
color: var(--vscode-foreground);
}
.cr-qa-lightbox-close:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
.cr-qa-input {
display: flex;
align-items: flex-end;
+16 -6
View File
@@ -23,14 +23,14 @@ function run(...actions: QaAction[]): QaState {
describe('问 AI 的状态', () => {
it('问一次:展开这块,当前这一轮多一条在答的;回答长了跟着变;答完记下存成的那条,历史里不再重复', () => {
let state = run(qa([thread(IF, 'old')]), { type: 'ask', askId: 'a1', anchor: IF, question: '为什么?' })
let state = run(qa([thread(IF, 'old')]), { type: 'ask', askId: 'a1', anchor: IF, question: '为什么?', images: [] })
expect(state.open).toEqual([IF])
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '', status: 'running' }])
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '', status: 'running' }])
state = qaReducer(state, { type: 'answer', askId: 'a1', text: '因为', activity: '读取 a.py' })
expect(asksOf(state, IF)[0]).toMatchObject({ text: '因为', activity: '读取 a.py' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new' } })
state = qaReducer(state, qa([thread(IF, 'old', 'new')]))
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new' }])
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '因为要鉴权。', status: 'done', entryId: 'new' }])
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
})
@@ -45,9 +45,9 @@ describe('问 AI 的状态', () => {
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
let state = run(
qa([thread(IF, 'e1')]),
{ type: 'ask', askId: 'a1', anchor: IF, question: '1' },
{ type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] },
{ type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1' } },
{ type: 'ask', askId: 'a2', anchor: IF, question: '2' },
{ type: 'ask', askId: 'a2', anchor: IF, question: '2', images: [] },
{ type: 'close', anchor: IF },
)
expect(state.open).toEqual([])
@@ -67,12 +67,22 @@ describe('问 AI 的状态', () => {
})
it('停止、失败照记;不认识的问题的消息不理', () => {
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' }, { type: 'answer', askId: 'a1', text: '一半' })
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] }, { type: 'answer', askId: 'a1', text: '一半' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'stopped' } })
expect(asksOf(state, IF)[0]).toMatchObject({ status: 'stopped', text: '一半' })
expect(qaReducer(state, { type: 'answer', askId: 'a1', text: '晚到的' })).toEqual(state)
expect(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'nope', status: 'done' } })).toEqual(state)
})
it('问题带的图片跟着这一问:答完、失败、停止都还在;页面重新加载后接上的没有', () => {
const images = ['data:image/png;base64,AAA', 'data:image/jpeg;base64,BBB']
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '看图', images })
expect(asksOf(state, IF)[0].images).toEqual(images)
for (const status of ['done', 'failed', 'stopped'] as const)
expect(asksOf(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status } }), IF)[0].images).toEqual(images)
state = run(qa([], [{ askId: 'a2', anchor: IF, question: '1', text: '' }]))
expect(asksOf(state, IF)[0].images).toBeUndefined()
})
})
describe('问的时间', () => {
+5 -3
View File
@@ -10,6 +10,8 @@ export interface Ask {
askId: string
anchor: string
question: string
/** 问题带的图片(这边贴的 data: 地址);页面重新加载后接上的、扩展端还在答的没有 */
images?: string[]
/** 回答到现在为止的全文(Markdown) */
text: string
/** 正在做的事:「读取 routes.py」 */
@@ -40,7 +42,7 @@ export type QaAction
| { type: 'profiles', profiles: ProfileChoice }
| { type: 'answer', askId: string, text: string, activity?: string }
| { type: 'answerEnd', end: AnswerEnd }
| { type: 'ask', askId: string, anchor: string, question: string }
| { type: 'ask', askId: string, anchor: string, question: string, images: string[] }
| { type: 'open', anchor: string }
| { type: 'close', anchor: string }
@@ -76,14 +78,14 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
return {
...state,
asks: state.asks.map(ask => ask.askId === askId
? { askId, anchor: ask.anchor, question: ask.question, text: text ?? ask.text, status, error, entryId }
? { askId, anchor: ask.anchor, question: ask.question, images: ask.images, text: text ?? ask.text, status, error, entryId }
: ask),
}
}
case 'ask':
return {
...state,
asks: [...state.asks, { askId: action.askId, anchor: action.anchor, question: action.question, text: '', status: 'running' }],
asks: [...state.asks, { askId: action.askId, anchor: action.anchor, question: action.question, images: action.images, text: '', status: 'running' }],
open: withOpen(state.open, action.anchor),
}
case 'open':
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { MAX_IMAGE_BYTES, MAX_IMAGES, pickImages, toAskImage, transferFiles } from './qaImages'
interface FakeFile { name: string, type: string, size: number }
const file = (name: string, type = 'image/png', size = 1024): FakeFile => ({ name, type, size })
/** 剪贴板 / 拖进来的一项:文件,或文字、网页片段这种字符串 */
function item(value: FakeFile | string): { kind: string, getAsFile: () => FakeFile | null } {
return typeof value === 'string'
? { kind: 'string', getAsFile: () => null }
: { kind: 'file', getAsFile: () => value }
}
describe('剪贴板里的东西', () => {
it('只取文件,文字、网页片段不算(照常粘成文字);取不出文件的跳过', () => {
const shot = file('截图.png')
const copied = file('image.jpg', 'image/jpeg')
expect(transferFiles([item('<img src="x">'), item(shot), item('说明文字'), item(copied)])).toEqual([shot, copied])
expect(transferFiles([{ kind: 'file', getAsFile: () => null }])).toEqual([])
expect(transferFiles([item('只有文字')])).toEqual([])
})
})
describe('能带上哪些图片', () => {
it('认的四种格式(PNG、JPEG、GIF、WebP)都带上,按先后', () => {
const files = [file('a.png'), file('b.jpg', 'image/jpeg'), file('c.gif', 'image/gif'), file('d.webp', 'image/webp')]
expect(pickImages(files, 0)).toEqual({ accepted: files })
})
it('不是这几种格式的不带(SVG、PDF 等),说一句原因', () => {
const png = file('a.png')
expect(pickImages([file('x.svg', 'image/svg+xml'), png, file('y.pdf', 'application/pdf')], 0))
.toEqual({ accepted: [png], notice: '只能贴 PNG、JPEG、GIF、WebP 图片,有 2 个没加上。' })
})
it('每张不超过 5 MB:正好 5 MB 的带上,大一点的不带', () => {
const edge = file('edge.png', 'image/png', MAX_IMAGE_BYTES)
expect(pickImages([edge, file('big.png', 'image/png', MAX_IMAGE_BYTES + 1)], 0))
.toEqual({ accepted: [edge], notice: '每张图片不能超过 5 MB,有 1 个没加上。' })
})
it('连同已经贴的最多 6 张:多出来的不带;已经满了一张都不带', () => {
const files = [file('1.png'), file('2.png'), file('3.png')]
expect(pickImages(files, MAX_IMAGES - 2)).toEqual({ accepted: files.slice(0, 2), notice: '一个问题最多带 6 张图片,有 1 个没加上。' })
expect(pickImages(files, MAX_IMAGES)).toEqual({ accepted: [], notice: '一个问题最多带 6 张图片,有 3 个没加上。' })
})
it('几种原因一起说', () => {
const picked = pickImages([file('a.txt', 'text/plain'), file('big.png', 'image/png', MAX_IMAGE_BYTES * 2), ...Array.from({ length: 7 }, (_, i) => file(`${i}.png`))], 0)
expect(picked.accepted).toHaveLength(MAX_IMAGES)
expect(picked.notice).toBe('只能贴 PNG、JPEG、GIF、WebP 图片;每张图片不能超过 5 MB;一个问题最多带 6 张图片,有 3 个没加上。')
})
})
describe('发给扩展端的样子', () => {
it('data: 地址去掉前缀剩下 base64,格式照原样', () => {
expect(toAskImage({ id: 1, mediaType: 'image/png', url: 'data:image/png;base64,iVBORw0KGgo=' })).toEqual({ mediaType: 'image/png', base64: 'iVBORw0KGgo=' })
})
})
@@ -0,0 +1,61 @@
/**
* 「问 AI」输入框里贴图、拖图的规则(不碰 React):认哪些格式、每张多大、最多几张,
* 贴进来还没发的图片在页面上的样子,发给扩展端的样子。
*/
import type { AskImage } from './types'
/** 认的格式:Claude 认的这几种 */
export const IMAGE_TYPES: readonly string[] = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024
/** 一个问题最多带几张 */
export const MAX_IMAGES = 6
/** 贴进输入框、还没发的一张图片:url 是 data: 地址,缩略图、大图、发送都用它 */
export interface PendingImage {
id: number
mediaType: string
url: string
}
/** 剪贴板 / 拖进来的东西里的文件;文字、网页片段这些不算(照常粘成文字) */
export function transferFiles<F>(items: ArrayLike<{ kind: string, getAsFile: () => F | null }>): F[] {
return Array.from(items).flatMap(item => item.kind === 'file' ? item.getAsFile() ?? [] : [])
}
/**
* 挑出能带上的:只认 PNG、JPEG、GIF、WebP,每张不超过 MAX_IMAGE_BYTES,连同已经有的 have 张最多 MAX_IMAGES 张,按先后取;
* 有没加上的给一句原因。
*/
export function pickImages<F extends { type: string, size: number }>(files: readonly F[], have: number): { accepted: F[], notice?: string } {
const images = files.filter(file => IMAGE_TYPES.includes(file.type))
const small = images.filter(file => file.size <= MAX_IMAGE_BYTES)
const accepted = small.slice(0, Math.max(0, MAX_IMAGES - have))
const reasons = [
...(images.length < files.length ? ['只能贴 PNG、JPEG、GIF、WebP 图片'] : []),
...(small.length < images.length ? [`每张图片不能超过 ${MAX_IMAGE_BYTES / 1024 / 1024} MB`] : []),
...(accepted.length < small.length ? [`一个问题最多带 ${MAX_IMAGES} 张图片`] : []),
]
return reasons.length === 0
? { accepted }
: { accepted, notice: `${reasons.join(';')},有 ${files.length - accepted.length} 个没加上。` }
}
let imageCounter = 0
/** 读成 data: 地址(缩略图直接用,页面的安全策略允许 data: 图片) */
export function readImage(file: File): Promise<PendingImage> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve({ id: ++imageCounter, mediaType: file.type, url: String(reader.result) })
reader.onerror = () => reject(reader.error ?? new Error('读不出图片'))
reader.readAsDataURL(file)
})
}
/** 发给扩展端的样子:data: 地址去掉前缀剩下的 base64 */
export function toAskImage(image: PendingImage): AskImage {
return { mediaType: image.mediaType, base64: image.url.slice(image.url.indexOf(',') + 1) }
}
+11 -2
View File
@@ -164,6 +164,14 @@ export interface QaEntryView {
createdAt: string
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
profile?: string
/** 问题带的图片,能直接放进 img 的地址;没带图片时没有 */
images?: string[]
}
/** 问题带的一张图片,发给扩展端的样子:格式(`image/png` 等)和不带 `data:` 前缀的 base64 */
export interface AskImage {
mediaType: string
base64: string
}
/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示 */
@@ -246,7 +254,8 @@ export type ExtensionToWebview
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示
@@ -260,7 +269,7 @@ export type WebviewToExtension
| { type: 'probeDefinition', id: number, version: number, offset: number }
| { type: 'goToDefinition', version: number, offset: number }
| { type: 'findOccurrences', id: number, version: number, offset: number }
| { type: 'ask', askId: string, anchor: string, question: string }
| { type: 'ask', askId: string, anchor: string, question: string, images: AskImage[] }
| { type: 'stopAsk', askId: string }
| { type: 'deleteQa', anchor: string, entryId: string }
| { type: 'selectAskProfile', id: string }
@@ -7,7 +7,7 @@ import { onMessage, postMessage } from './vscode'
export const HighlightContext = createContext<Highlight | undefined>(undefined)
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动 */
const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], .cr-filter-bar, .cr-prop-label'
const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], [role="dialog"], .cr-filter-bar, .cr-prop-label'
function clickOf(event: MouseEvent): Click {
const target = event.target instanceof Element ? event.target : null
+4 -3
View File
@@ -2,6 +2,7 @@ import type { QaTree } from './BlockTree'
import type { QaState } from './qa'
import { useEffect, useMemo, useReducer, useRef } from 'react'
import { EMPTY_QA, newAskId, qaReducer } from './qa'
import { toAskImage } from './qaImages'
import { onMessage, postMessage } from './vscode'
/** 逐字长出来的回答最多隔多久画一次:一个字一画整棵块树跟着重画,大文件会卡 */
@@ -52,10 +53,10 @@ export function useQa(): QaTree {
state,
onToggle: anchor => dispatch({ type: current.current.open.includes(anchor) ? 'close' : 'open', anchor }),
onClose: anchor => dispatch({ type: 'close', anchor }),
onAsk: (anchor, question) => {
onAsk: (anchor, question, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question })
postMessage({ type: 'ask', askId, anchor, question })
dispatch({ type: 'ask', askId, anchor, question, images: images.map(image => image.url) })
postMessage({ type: 'ask', askId, anchor, question, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopAsk', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),