✨ 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

+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 () => {