✨ feat(vscode): 代码阅读加「问 AI」,块下面问 Claude Code,问答存进 .spx 挂在块下

This commit is contained in:
cruldra committed 2026-09-26 21:12:47 +08:00
1 parent badc722640
commit fb53bb727c
34 files changed
+2726 -43

No files matched your search

+2 -1
View File
@@ -27,7 +27,8 @@ function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function describeToolUse(name: string, input: Record<string, unknown>): string {
/** 一次工具调用写成人看得懂的短语:「读取 routes.py」「搜索 get_user」。 */
export function describeToolUse(name: string, input: Record<string, unknown>): string {
switch (name) {
case 'Bash':
return `执行命令:${truncate(asString(input.description) || asString(input.command))}`
+56
View File
@@ -0,0 +1,56 @@
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 { ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from './spawnClaude'
let dir: string
beforeEach(async () => {
dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'fake-claude-'))
})
afterEach(async () => {
await fsp.rm(dir, { recursive: true, force: true })
})
/** 假的 claude:一段 sh 脚本 */
async function fakeBin(script: string): Promise<string> {
const bin = path.join(dir, 'claude')
await fsp.writeFile(bin, `#!/bin/sh\n${script}\n`, { mode: 0o755 })
return bin
}
describe('跑 claude、逐行交出 stdout', () => {
it('每出来完整的一行交一行,最后没带换行的那段结束时交出;参数原样传、在 cwd 里跑', async () => {
const bin = await fakeBin('printf \'{"a":1}\\n{"b":\'; sleep 0.1; printf \'2}\\n\'; pwd; printf "%s|" "$@"')
const lines: string[] = []
await runClaudeLines({ bin, args: ['-p', '带 空格 的问题'], cwd: dir, onLine: item => lines.push(item) })
expect(lines).toEqual(['{"a":1}', '{"b":2}', await fsp.realpath(dir), '-p|带 空格 的问题|'])
})
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)
expect(error).toBeInstanceOf(ClaudeError)
expect((error as ClaudeError).message).toBe('Claude 退出码非零 (1): No conversation found with session ID: x')
})
it('叫停:马上结束进程,报 ClaudeCancelledError;已经叫停的不再启动', async () => {
const marker = path.join(dir, 'still-running')
const bin = await fakeBin(`echo started; sleep 2; touch "${marker}"`)
const controller = new AbortController()
const started = Date.now()
const run = runClaudeLines({ bin, args: [], cwd: dir, signal: controller.signal, onLine: () => controller.abort() })
await expect(run).rejects.toBeInstanceOf(ClaudeCancelledError)
expect(Date.now() - started).toBeLessThan(1500)
await new Promise(resolve => setTimeout(resolve, 2500))
await expect(fsp.access(marker)).rejects.toThrow()
await expect(runClaudeLines({ bin, args: [], cwd: dir, signal: controller.signal, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeCancelledError)
}, 10_000)
it('超时:结束进程,报 ClaudeTimeoutError', async () => {
const bin = await fakeBin('sleep 5')
await expect(runClaudeLines({ bin, args: [], cwd: dir, timeoutMs: 200, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeTimeoutError)
})
})
+121 -12
View File
@@ -58,6 +58,14 @@ export class ClaudeTimeoutError extends ClaudeError {
}
}
/** 调用方叫停(AbortSignal)时进程已被结束。 */
export class ClaudeCancelledError extends ClaudeError {
constructor(stderr: string) {
super('Claude 调用已停止', stderr)
this.name = 'ClaudeCancelledError'
}
}
interface ParsedClaudeJson {
session_id: string
result: string
@@ -185,21 +193,33 @@ export type ClaudeProgressListener = (label: string) => void
/**
* 按行切 stdout:stream-json 每行一个对象,但 data 事件的 chunk 边界和行边界
* 无关,一行常被拆进两个 chunk,所以末尾那段不完整的残行要留到下次拼上。
* 无关,一行常被拆进两个 chunk,所以末尾那段不完整的残行要留到下次拼上;
* 进程结束时 flush 交出最后那段没带换行的。
*/
function createLineFeeder(onLine: (line: string) => void): { feed: (chunk: string) => void, flush: () => void } {
let pending = ''
return {
feed: (chunk) => {
pending += chunk
const lines = pending.split('\n')
pending = lines.pop() ?? ''
lines.forEach(onLine)
},
flush: () => {
if (pending !== '')
onLine(pending)
pending = ''
},
}
}
function createProgressFeeder(onProgress?: ClaudeProgressListener): (chunk: string) => void {
if (!onProgress)
return () => {}
let pending = ''
return (chunk) => {
pending += chunk
const lines = pending.split('\n')
pending = lines.pop() ?? ''
for (const line of lines) {
for (const label of summarizeClaudeStreamLine(line))
onProgress(label)
}
}
return createLineFeeder((line) => {
for (const label of summarizeClaudeStreamLine(line))
onProgress(label)
}).feed
}
function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string {
@@ -233,7 +253,7 @@ function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string {
* - CLAUDECODE / CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_SESSION:
* 从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出、零输出。
*/
const CLAUDE_MISSING_MSG = '未检测到 claude CLI,请确认已安装并在 PATH 中(常见位置 ~/.local/bin)'
export const CLAUDE_MISSING_MSG = '未检测到 claude CLI,请确认已安装并在 PATH 中(常见位置 ~/.local/bin)'
function buildClaudeChildEnv(bin?: string): NodeJS.ProcessEnv {
// 剔除所有 ANTHROPIC_* 是为了让 `--settings` profile 成为 provider/鉴权的唯一来源
@@ -537,3 +557,92 @@ function spawnClaudeStreamed(
}
})
}
/**
* 跑一次 claude(参数由调用方拼好,一般带 `--output-format stream-json`),stdout 每出来完整的一行就交给 onLine。
* 正常退出时 resolve;找不到 claude、退出码非零、输出过大时 reject ClaudeError,超时 ClaudeTimeoutError,
* signal 叫停时结束进程并 reject ClaudeCancelledError。
*/
export function runClaudeLines(opts: {
args: string[]
cwd: string
onLine: (line: string) => void
timeoutMs?: number
signal?: AbortSignal
/** 默认按 resolveClaudeBin 找 */
bin?: string
}): Promise<void> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
const bin = opts.bin ?? resolveClaudeBin()
if (!bin)
return Promise.reject(new ClaudeError(CLAUDE_MISSING_MSG, ''))
if (opts.signal?.aborted)
return Promise.reject(new ClaudeCancelledError(''))
return new Promise<void>((resolve, reject) => {
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let settled = false
const lines = createLineFeeder(opts.onLine)
const child = spawn(bin, opts.args, {
cwd: opts.cwd,
env: buildClaudeChildEnv(bin),
stdio: ['ignore', 'pipe', 'pipe'],
})
const timer = setTimeout(() => {
child.kill('SIGKILL')
settle(new ClaudeTimeoutError(`Claude 调用超时(${Math.round(timeoutMs / 1000)}s)`, stderr))
}, timeoutMs)
function onAbort(): void {
child.kill('SIGTERM')
settle(new ClaudeCancelledError(stderr))
}
function settle(error?: ClaudeError): void {
if (settled)
return
settled = true
clearTimeout(timer)
opts.signal?.removeEventListener('abort', onAbort)
if (error)
reject(error)
else
resolve()
}
opts.signal?.addEventListener('abort', onAbort, { once: true })
child.on('error', (err) => {
const errCode = (err as NodeJS.ErrnoException).code
settle(new ClaudeError(errCode === 'ENOENT' ? CLAUDE_MISSING_MSG : `Claude 调用失败: ${err.message}`, stderr))
})
child.stdout.on('data', (chunk: Buffer) => {
if (settled)
return
stdoutBytes += chunk.length
if (stdoutBytes > MAX_BUFFER_BYTES) {
child.kill('SIGKILL')
settle(new ClaudeError(`Claude 输出超过 ${MAX_BUFFER_BYTES} bytes`, stderr))
return
}
const text = chunk.toString('utf8')
stdout += text
lines.feed(text)
})
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8')
})
child.on('close', (code) => {
if (settled)
return
lines.flush()
settle(code === 0
? undefined
: new ClaudeError(`Claude 退出码非零 (${code}): ${buildClaudeExitDiagnostic(stderr, stdout)}`, stderr))
})
})
}
+1 -1
View File
@@ -108,7 +108,7 @@ describe('块的筛选属性:宿主从 kind 和 facts 推出通用属性', ()
})
function view(id: string, attributes: Record<string, string>, children: BlockView[] = []): BlockView {
return { id, kind: 'statement', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes, children, translation: [] }
return { id, anchor: id, kind: 'statement', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes, children, translation: [] }
}
describe('文件里出现过的属性', () => {
+21 -10
View File
@@ -3,10 +3,13 @@ import type { TreeSitterRuntime } from './runtime'
import { blockAttributes } from './attributes'
import { commonIndent, splitLines } from './editText'
import { activeRules, claimBlock, claimedShape, importsIn } from './libraryRules'
import { BlockAnchors } from './qa/anchor'
interface BlockViewBase {
/** 在树里的位置,例如 '6.1.0'。 */
id: string
/** 块的位置记号(见 BlockAnchors):问答挂在它上面,文件别处改了照样认得回来。 */
anchor: string
/** BlockKind,或库规则卡片的种类 id(RuleCard.kind,带点,例如 sqlalchemy.table)。 */
kind: string
/**
@@ -155,41 +158,49 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu
const doc = block.doc && translator?.doc(block.doc, document)
return { translation: translator ? translator.translate(block, document) : [], ...(doc && { doc }) }
}
/** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):头部是编辑范围,子块照常交给各规则看。 */
function plain(block: CodeBlock, id: string): BlockView {
/** 按源码先后逐块给位置记号:先序遍历,块的记号在它的子块之前要 */
const anchors = new BlockAnchors()
/** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):头部是编辑范围,子块照常交给各规则看。scope 见 BlockAnchors.place */
function plain(block: CodeBlock, id: string, scope: string): BlockView {
const name = ownName(block)
const rows = sourceRows(block.node.tree.rootNode, block.header.startRow, block.header.endRow)
const { anchor, inner } = anchors.place(scope, block.kind, name?.text, rows.code)
return {
id,
anchor,
kind: block.kind,
...sourceRows(block.node.tree.rootNode, block.header.startRow, block.header.endRow),
...rows,
...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }),
attributes: blockAttributes(block),
children: block.children.map((child, i) => build(child, `${id}.${i}`)),
children: block.children.map((child, i) => build(child, `${id}.${i}`, inner)),
...('facts' in block ? { properties: translator ? translator.describe(block, document) : [] } : textView(block)),
}
}
function build(block: CodeBlock, id: string): BlockView {
function build(block: CodeBlock, id: string, scope: string): BlockView {
const claim = active.length > 0 ? claimBlock(block, active, context, rules.report) : undefined
if (!claim)
return plain(block, id)
return plain(block, id, scope)
const { span, kept } = claimedShape(block, claim.block.absorbs)
if ('as' in claim.block) {
const { attributes } = block
return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, id)
return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, id, scope)
}
const { kind, badge, rows, defines, attributes } = claim.block
const lines = sourceRows(block.node.tree.rootNode, span.startRow, span.endRow)
const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), lines.code)
return {
id,
anchor,
kind,
...sourceRows(block.node.tree.rootNode, span.startRow, span.endRow),
...lines,
...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }),
attributes: { ...blockAttributes(block), ...attributes, kind },
badge,
children: kept.map((child, i) => build(child, `${id}.${i}`)),
children: kept.map((child, i) => build(child, `${id}.${i}`, inner)),
properties: rows,
}
}
return blocks.map((block, i) => build(block, String(i)))
return blocks.map((block, i) => build(block, String(i), ''))
}
/** 解析一份源码并转成视图数据;语法树用完即释放。 */
+40
View File
@@ -4,6 +4,8 @@
import type { FileAttribute } from '../attributes'
import type { BlockView } from '../blockView'
import type { AnchorStep } from '../qa/anchor'
import type { RunningAsk } from '../qa/asks'
import type { Excluded } from './blockFilter'
export type { Excluded }
@@ -44,11 +46,40 @@ export interface DocumentMessage {
reveal?: Reveal
}
/** 问过的一次(给预览看的):问题、回答(Markdown)、什么时候问的(ISO 时间)。 */
export interface QaEntryView {
id: string
question: string
answer: string
createdAt: string
}
/** 挂在一块上的问答。where 是记号拆回的各层(种类和名字),块找不到了时写它原来在哪。 */
export interface QaThreadView {
anchor: string
where: AnchorStep[]
entries: QaEntryView[]
}
/**
* 这份文件的问答:available 为假时文件不在工作区文件夹里、问答没地方存,不给「问 AI」;
* running 是正在答的问题(页面重新加载后靠它接上)。
*/
export interface QaMessage {
type: 'qa'
available: boolean
threads: QaThreadView[]
running: RunningAsk[]
}
/**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
* filter:整个工作区共用的块筛选;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
* qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了:done 带最终的回答、存下的那条的 id 和会话 id(追问接着这个会话),failed 带给人看的原因,stopped 是被叫停
*/
export type ExtensionToWebview
= | DocumentMessage
@@ -56,6 +87,9 @@ export type ExtensionToWebview
| { type: 'definitionProbed', id: number, found: boolean }
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'filter', excluded: Excluded }
| QaMessage
| { type: 'answer', askId: string, text: string, activity?: string }
| { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string, sessionId?: string }
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
@@ -65,6 +99,9 @@ export type ExtensionToWebview
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;resume 是追问时上一次的会话 id
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
*/
export type WebviewToExtension
= | { type: 'ready' }
@@ -75,3 +112,6 @@ 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, resume?: string }
| { type: 'stopAsk', askId: string }
| { type: 'deleteQa', anchor: string, entryId: string }
+68 -1
View File
@@ -1,5 +1,7 @@
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode'
import type { BlockView } from '../blockView'
import type { RuleLocation, RuleReference } from '../contract'
import type { CodeQa } from '../qa/codeQa'
import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime'
import type { BlockFilter } from './blockFilter'
@@ -9,6 +11,8 @@ import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
import { webviewHtml, webviewUiRoot } from '../../webview/html'
import { blockReplacement } from '../editText'
import { locate } from '../folderStore'
import { askPrompt, followUpPrompt } from '../qa/prompt'
import { definitionAction } from './definitionAction'
import { findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { DefinitionSources } from './definitionSources'
@@ -36,6 +40,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
private readonly filter: BlockFilter,
/** 库规则标的引用去哪儿查定义(交给标它的规则) */
readonly resolveReference: (reference: RuleReference) => Promise<RuleLocation[]>,
/** 问 AI:各预览共用,同一份文件开在几处时问答、正在答的问题都一样 */
readonly qa: CodeQa,
) {}
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
@@ -98,6 +104,8 @@ class Preview {
private readonly definitions: DefinitionSources<FoundLocation>
/** webview 报来的视口顶部那块的起始行 */
visibleLine: number | undefined
/** 最近一次发出去的块树和它的源码:问 AI 时按块的记号在里面找那块代码 */
private sent: { blocks: BlockView[], source: string } | undefined
constructor(
private readonly document: TextDocument,
@@ -131,6 +139,28 @@ class Preview {
if (this.ready)
this.post({ type: 'filter', excluded })
}),
owner.qa.onDidChange((uris) => {
if (uris.some(uri => uri.toString() === document.uri.toString()))
this.postQa()
}),
owner.qa.onDidAnswer((event) => {
if (event.owner !== document.uri.toString() || !this.ready)
return
if (event.kind === 'update') {
const { askId, text, activity } = event.ask
this.post({ type: 'answer', askId, text, ...(activity !== undefined && { activity }) })
return
}
const { end, entryId } = event
this.post({
type: 'answerEnd',
askId: event.ask.askId,
status: end.status,
...(end.status === 'failed' && { error: end.error }),
...(end.status === 'done' && { text: end.answer.text, entryId, sessionId: end.answer.sessionId }),
})
this.postQa()
}),
]
}
@@ -151,12 +181,15 @@ class Preview {
if (!this.ready)
return
const request = ++this.latest
const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime, (text) => {
const { languageId, version } = this.document
const source = this.document.getText()
const message = await documentMessage({ languageId, version, getText: () => source }, this.registry, this.runtime, (text) => {
logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` })
})
if (request !== this.latest || !this.ready)
return
this.definitions.remember(message.version, message.blocks)
this.sent = { blocks: message.blocks, source }
const reveal = this.pendingReveal
this.pendingReveal = undefined
this.post(reveal === undefined ? message : { ...message, reveal })
@@ -180,6 +213,30 @@ class Preview {
void this.panel.webview.postMessage(message)
}
private postQa(): void {
if (this.ready)
this.post(this.owner.qa.view(this.document.uri))
}
/**
* 问 anchor 那块代码:按最近一次发出去的块树找到它、带上代码上下文交给 CodeQa;resume 是追问时上一次的会话 id。
* 找不到这块(代码刚改过、还没重新发)或问不了时,直接回一条失败。
*/
private ask(askId: string, anchor: string, question: string, resume: string | undefined): 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 && resume ? followUpPrompt(subject, question) : undefined
const refused = prompt === undefined
? '这块代码刚改过,找不到了。等预览刷新后重新点「问 AI」再问。'
: this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, ...(resume && followUp && { followUp: { sessionId: resume, prompt: followUp } }) })
if (refused !== undefined) {
this.post({ type: 'answerEnd', askId, status: 'failed', error: refused })
return
}
this.postQa()
}
private schedulePush(): void {
clearTimeout(this.timer)
this.timer = setTimeout(() => void this.push(), REFRESH_DELAY_MS)
@@ -191,8 +248,18 @@ class Preview {
this.ready = true
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
this.post({ type: 'filter', excluded: this.filter.excluded })
this.postQa()
await this.push()
return
case 'ask':
this.ask(msg.askId, msg.anchor, msg.question, msg.resume)
return
case 'stopAsk':
this.owner.qa.stop(msg.askId)
return
case 'deleteQa':
await this.owner.qa.delete(this.document.uri, msg.anchor, msg.entryId)
return
case 'setFilter':
await this.filter.set(msg.excluded)
return
+5 -2
View File
@@ -12,6 +12,7 @@ import { createTypeScriptAdapter } from './languages/typescript'
import { resolveReference } from './libraryRules'
import { registerNoteCommands } from './notes/commands'
import { FileNotes } from './notes/fileNotes'
import { CodeQa } from './qa/codeQa'
import { CodeReaderRegistry } from './registry'
import { celeryRule } from './rules/celery'
import { enumRule } from './rules/enum'
@@ -48,7 +49,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
}
/**
* 接入代码阅读:注册表、侧栏文件树(复制、文件注释)、预览编辑器、编辑器右上角的「预览 / 编辑」切换。
* 接入代码阅读:注册表、侧栏文件树(复制、文件注释)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。
* 内置的语言插件、翻译插件和库规则与其他扩展一样通过返回的 API 注册。
*/
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
@@ -62,11 +63,13 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
return runtime.parse(adapter, languageId, source)
}, report))
const resolve = (reference: RuleReference): Promise<RuleLocation[]> => resolveReference(reference, registry.libraryRules(), indexes, report)
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve)
const qa = new CodeQa()
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa)
const notes = new FileNotes()
const files = new FileTreeProvider(OPEN_COMMAND, notes)
context.subscriptions.push(
qa,
notes,
indexes,
registerNoteCommands(notes),
+102
View File
@@ -0,0 +1,102 @@
import type { BlockView } from '../blockView'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { createPythonAdapter } from '../languages/python'
import { WASM_DIR, withBlocks } from '../testSupport'
import { anchorSteps, BlockAnchors } from './anchor'
const py = createPythonAdapter(WASM_DIR)
function views(source: string): Promise<BlockView[]> {
return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, undefined))
}
function flatten(list: BlockView[]): BlockView[] {
return list.flatMap(view => [view, ...flatten(view.children)])
}
/** 各块的记号,按源码先后,连同它这块的第一行源码 */
async function anchors(source: string): Promise<[string, string][]> {
return flatten(await views(source)).map(view => [view.anchor, view.code.split('\n')[0]])
}
const SOURCE = `class ReferralRelation(Base):
def display(self):
if self.active:
return self.name
return "none"
def other(self):
if self.active:
return 1
def helper(x):
print(x)
print(x)
`
describe('块的位置记号', () => {
it('有名字的块是外层各个有名字的块加自己的种类和名字;没名字的块是外层最近的有名字的块加种类和源码哈希;同一层重复的依次编号', async () => {
const list = await anchors(SOURCE)
const hashed = (anchor: string): string => anchor.replace(/@[0-9a-f]{8}/g, '@#')
expect(list.map(([anchor, code]) => [hashed(anchor), code])).toEqual([
['class ReferralRelation', 'class ReferralRelation(Base):'],
['class ReferralRelation > function display', 'def display(self):'],
['class ReferralRelation > function display > if @#', 'if self.active:'],
['class ReferralRelation > function display > return @#', 'return self.name'],
['class ReferralRelation > function display > return @#', 'return "none"'],
['class ReferralRelation > function other', 'def other(self):'],
['class ReferralRelation > function other > if @#', 'if self.active:'],
['class ReferralRelation > function other > return @#', 'return 1'],
['function helper', 'def helper(x):'],
['function helper > statement @#', 'print(x)'],
['function helper > statement @#[1]', 'print(x)'],
])
// 两个函数里一模一样的条件各在各的函数下面,互不影响;源码不同的两个返回哈希不同
expect(list[2][0].split(' > ').at(-1)).toBe(list[6][0].split(' > ').at(-1))
expect(list[3][0]).not.toBe(list[4][0])
expect(new Set(list.map(([anchor]) => anchor)).size).toBe(list.length)
})
it('上面插几行、改缩进和空白:记号都不变', async () => {
const before = await anchors(SOURCE)
const after = await anchors(`import os\n\n# 新加的注释\n${SOURCE.replace('print(x)\n print(x)', 'print( x )\n print(x)')}`)
const shifted = after.filter(([anchor]) => before.some(([old]) => old === anchor))
expect(shifted.map(([anchor]) => anchor)).toEqual(before.map(([anchor]) => anchor))
})
it('改别的函数的名字:别处的记号不变,改名的函数和它里面的块换了记号', async () => {
const before = new Set((await anchors(SOURCE)).map(([anchor]) => anchor))
const after = (await anchors(SOURCE.replace('def other(self)', 'def renamed(self)'))).map(([anchor]) => anchor)
const kept = after.filter(anchor => before.has(anchor))
expect(kept).toEqual([...before].filter(anchor => !anchor.includes('function other')))
expect(after.filter(anchor => !before.has(anchor)).every(anchor => anchor.includes('function renamed'))).toBe(true)
})
it('没名字的块自己的源码改了:记号变了,旧问答认不回来;有名字的块改了主体、签名照样认得', async () => {
const before = await anchors(SOURCE)
const after = await anchors(SOURCE.replace('if self.active:\n return self.name', 'if self.visible:\n return self.name').replace('def helper(x):', 'def helper(x, y=1):'))
const anchorOf = (list: [string, string][], code: string): string | undefined => list.find(([, line]) => line === code)?.[0]
expect(after.map(([anchor]) => anchor)).not.toContain(anchorOf(before, 'if self.active:'))
expect(anchorOf(after, 'return self.name')).toBe(anchorOf(before, 'return self.name'))
expect(anchorOf(after, 'def helper(x, y=1):')).toBe(anchorOf(before, 'def helper(x):'))
})
it('拆回各层:没名字的一层只有种类,编号去掉', () => {
expect(anchorSteps('class ReferralRelation > function display[1] > if @3fa1c2d0[2]')).toEqual([
{ kind: 'class', name: 'ReferralRelation' },
{ kind: 'function', name: 'display' },
{ kind: 'if' },
])
expect(anchorSteps('variable { a, b }')).toEqual([{ kind: 'variable', name: '{ a, b }' }])
})
it('名字里的空白压成一个空格,空名字按没名字算', () => {
const anchors = new BlockAnchors()
expect(anchors.place('', 'variable', '{\n a,\n b\n}', '').anchor).toBe('variable { a, b }')
const unnamed = anchors.place('scope', 'statement', ' ', 'x = 1')
expect(unnamed.anchor).toMatch(/^scope > statement @[0-9a-f]{8}$/)
expect(unnamed.inner).toBe('scope')
})
})
+59
View File
@@ -0,0 +1,59 @@
import { createHash } from 'node:crypto'
/**
* 块的位置记号(anchor):问答挂在哪一块上,文件别处改了也认得回来。只用和语言无关的块数据:
* - 有名字的块(函数、类、单个变量……,名字取自 facts / 规则给的定义范围):外层各个有名字的块 + 自己的种类和名字,
* 例如 `class ReferralRelation > function display`;
* - 没名字的块(条件、语句、注释……):外层最近的有名字的块 + 种类 + 源码去掉空白后的哈希,例如 `function display > if @3fa1c2d0`。
* 同一层里记号相同的(同名的两个函数、一模一样的两行语句)按源码先后在后面补 `[1]`、`[2]`。
* 所以在上面插几行、改别的函数的名字都不影响;没名字的块自己的源码一改,记号就变了,旧问答认不回来。
*/
const SEPARATOR = ' > '
const HASH_MARK = '@'
/** 连着的空白压成一个空格、去掉首尾空白(名字写进记号时)。 */
function squeeze(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
/** 源码去掉所有空白再算:只改缩进、换行、空格(格式化工具重排)不算改了代码。 */
function codeHash(code: string): string {
return createHash('sha1').update(code.replace(/\s+/g, '')).digest('hex').slice(0, 8)
}
/** 一份文件里各块的记号:按源码先后(树的先序)逐块要,同一层里重复的记号依次编号。 */
export class BlockAnchors {
private readonly seen = new Map<string, number>()
/**
* scope 是外层最近的有名字的块的记号(顶层是 `''`);name 是块自己的名字,没有就按 code 的哈希认。
* 返回这一块的记号(anchor)和它的子块的 scope(inner):有名字的块自己就是一层,没名字的块沿用外层的。
*/
place(scope: string, kind: string, name: string | undefined, code: string): { anchor: string, inner: string } {
const label = name === undefined ? '' : squeeze(name)
const segment = label === '' ? `${kind} ${HASH_MARK}${codeHash(code)}` : `${kind} ${label}`
const base = scope === '' ? segment : `${scope}${SEPARATOR}${segment}`
const count = this.seen.get(base) ?? 0
this.seen.set(base, count + 1)
const anchor = count === 0 ? base : `${base}[${count}]`
return { anchor, inner: label === '' ? scope : anchor }
}
}
/** 记号里的一层:种类和名字(没名字的块只有种类),给「找不到原来位置的问答」写它原来在哪。 */
export interface AnchorStep {
kind: string
name?: string
}
/** 把记号拆回各层;拆不出种类的一层丢掉。 */
export function anchorSteps(anchor: string): AnchorStep[] {
return anchor.split(SEPARATOR).flatMap((segment) => {
const match = /^(\S+) (.*?)(?:\[\d+\])?$/.exec(segment)
if (!match)
return []
const [, kind, rest] = match
return rest.startsWith(HASH_MARK) || rest === '' ? [{ kind }] : [{ kind, name: rest }]
})
}
@@ -0,0 +1,98 @@
import type { AnswerEvent } from './answerStream'
import { describe, expect, it } from 'vitest'
import { createAnswerParser } from './answerStream'
const SESSION = '1763f93a-2792-4120-91eb-2e220fe01389'
const line = (value: unknown): string => JSON.stringify(value)
const stream = (event: unknown): string => line({ type: 'stream_event', event, session_id: SESSION, parent_tool_use_id: null })
const delta = (text: string): string => stream({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } })
/** 照本机 claude 2.1 的真实输出截的:hook 行、init、先读文件再回答、逐字的片段、整条消息、result */
const TRANSCRIPT = [
line({ type: 'system', subtype: 'hook_started', hook_name: 'SessionStart:startup', session_id: SESSION }),
line({ type: 'system', subtype: 'init', cwd: '/work', session_id: SESSION, tools: ['Glob', 'Grep', 'Read'], permissionMode: 'dontAsk' }),
stream({ type: 'message_start', message: { id: 'msg_1', role: 'assistant', content: [] } }),
stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }),
delta('我先看看'),
delta(' m.py。'),
line({ type: 'assistant', message: { id: 'msg_1', content: [{ type: 'text', text: '我先看看 m.py。' }] }, parent_tool_use_id: null, session_id: SESSION }),
stream({ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_1', name: 'Read', input: {} } }),
stream({ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"file_path": "/work/m.py"}' } }),
line({ type: 'assistant', message: { id: 'msg_1', content: [{ type: 'tool_use', id: 'toolu_1', name: 'Read', input: { file_path: '/work/m.py' } }] }, parent_tool_use_id: null, session_id: SESSION }),
line({ type: 'user', message: { role: 'user', content: [{ tool_use_id: 'toolu_1', type: 'tool_result', content: '1\tdef add(a, b):' }] }, parent_tool_use_id: null }),
stream({ type: 'message_start', message: { id: 'msg_2', role: 'assistant', content: [] } }),
stream({ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } }),
stream({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '想一想' } }),
stream({ type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } }),
delta('`add` 返回'),
delta(' `a + b`(`m.py:1`)。'),
line({ type: 'assistant', message: { id: 'msg_2', content: [{ type: 'text', text: '`add` 返回 `a + b`(`m.py:1`)。' }] }, parent_tool_use_id: null, session_id: SESSION }),
line({ type: 'result', subtype: 'success', is_error: false, result: '`add` 返回 `a + b`(`m.py:1`)。', session_id: SESSION }),
]
function run(lines: string[]): AnswerEvent[] {
const parse = createAnswerParser()
return lines.flatMap(parse)
}
describe('stream-json → 问答的进展', () => {
it('会话 id、逐字长出来的回答、读文件、最终回答;新的一条回复一开口就换掉前面那句', () => {
expect(run(TRANSCRIPT)).toEqual([
{ kind: 'session', sessionId: SESSION },
{ kind: 'text', text: '我先看看' },
{ kind: 'text', text: '我先看看 m.py。' },
{ kind: 'activity', label: '读取 m.py' },
{ kind: 'text', text: '`add` 返回' },
{ kind: 'text', text: '`add` 返回 `a + b`(`m.py:1`)。' },
{ kind: 'result', text: '`add` 返回 `a + b`(`m.py:1`)。', sessionId: SESSION },
])
})
it('没开逐字片段时按整条 assistant 消息更新,结果一样', () => {
const whole = TRANSCRIPT.filter(item => !item.includes('"stream_event"'))
expect(run(whole)).toEqual([
{ kind: 'session', sessionId: SESSION },
{ kind: 'text', text: '我先看看 m.py。' },
{ kind: 'activity', label: '读取 m.py' },
{ kind: 'text', text: '`add` 返回 `a + b`(`m.py:1`)。' },
{ kind: 'result', text: '`add` 返回 `a + b`(`m.py:1`)。', sessionId: SESSION },
])
})
it('一条回复里两段文字中间空一行', () => {
const events = run([
stream({ type: 'message_start', message: { id: 'm' } }),
stream({ type: 'content_block_start', index: 0, content_block: { type: 'text' } }),
delta('第一段'),
stream({ type: 'content_block_start', index: 1, content_block: { type: 'text' } }),
delta('第二段'),
])
expect(events.at(-1)).toEqual({ kind: 'text', text: '第一段\n\n第二段' })
})
it('result 行没带回答时用眼下这条回复', () => {
expect(run([delta('答案'), line({ type: 'result', subtype: 'success', is_error: false, result: '', session_id: SESSION })]).at(-1))
.toEqual({ kind: 'result', text: '答案', sessionId: SESSION })
})
it('claude 自己报的失败:取它说的原因', () => {
expect(run([line({ type: 'result', subtype: 'success', is_error: true, result: 'Invalid API key · Please run /login' })]))
.toEqual([{ kind: 'error', message: 'Invalid API key · Please run /login' }])
expect(run([line({ type: 'result', subtype: 'error_during_execution', is_error: true, errors: ['No conversation found with session ID: x'] })]))
.toEqual([{ kind: 'error', message: 'No conversation found with session ID: x' }])
expect(run([line({ type: 'result', subtype: 'error_max_turns', is_error: true })]))
.toEqual([{ kind: 'error', message: 'error_max_turns' }])
})
it('坏行、别的类型、子代理的消息都不算', () => {
expect(run([
'not json',
'',
line(null),
line({ type: 'rate_limit_event' }),
line({ type: 'assistant', parent_tool_use_id: 'toolu_9', message: { id: 'sub', content: [{ type: 'text', text: '子代理说的话' }] } }),
line({ type: 'stream_event', parent_tool_use_id: 'toolu_9', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: '子代理' } } }),
])).toEqual([])
})
})
+119
View File
@@ -0,0 +1,119 @@
/**
* 把 `claude -p --output-format stream-json --verbose --include-partial-messages` 吐出的每行 NDJSON
* 换成问答要的几件事:会话 id、回答逐字长出来、正在读什么文件、答完 / 出错。
*
* 回答显示的是眼下这条回复:Claude 先说一句「我去看看 X」、读完文件再回答时,新回复一开口就换掉前面那句,
* 答完以 result 行里的最终回复为准(就是最后那条回复)。没开 partial messages 时按整条 assistant 消息更新,结果一样。
*/
import { describeToolUse } from '../../cc/claudeProgress'
export type AnswerEvent
= | { kind: 'session', sessionId: string }
/** 眼下这条回复到现在为止的全文(Markdown) */
| { kind: 'text', text: string }
/** 正在做的事:「读取 routes.py」「搜索 get_user」 */
| { kind: 'activity', label: string }
| { kind: 'result', text: string, sessionId: string }
/** Claude Code 自己报的失败(result 行 is_error),进程多半也会以非零退出 */
| { kind: 'error', message: string }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function str(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/** 一个问题一个解析器:它记着眼下这条回复写到哪了。 */
export function createAnswerParser(): (line: string) => AnswerEvent[] {
let text = ''
let sessionId = ''
/** 新的一条回复开始了,下一个字换掉前面那条 */
let fresh = false
/** 见过逐字的片段:整条 assistant 消息里的字就不再算,免得重复 */
let partial = false
let lastMessage: unknown
const write = (piece: string): AnswerEvent[] => {
if (fresh) {
text = ''
fresh = false
}
text += piece
return [{ kind: 'text', text }]
}
const streamEvent = (event: Record<string, unknown>): AnswerEvent[] => {
if (event.type === 'message_start') {
fresh = true
return []
}
if (event.type === 'content_block_start' && isRecord(event.content_block) && event.content_block.type === 'text' && !fresh && text !== '') {
text += '\n\n'
return []
}
if (event.type === 'content_block_delta' && isRecord(event.delta) && event.delta.type === 'text_delta') {
partial = true
return write(str(event.delta.text))
}
return []
}
const assistant = (message: Record<string, unknown>): AnswerEvent[] => {
if (!Array.isArray(message.content))
return []
const events: AnswerEvent[] = []
if (!partial && message.id !== lastMessage) {
fresh = true
lastMessage = message.id
}
for (const block of message.content) {
if (!isRecord(block))
continue
if (block.type === 'tool_use' && typeof block.name === 'string')
events.push({ kind: 'activity', label: describeToolUse(block.name, isRecord(block.input) ? block.input : {}) })
else if (block.type === 'text' && !partial)
events.push(...write(str(block.text)))
}
return events
}
const result = (line: Record<string, unknown>): AnswerEvent[] => {
if (line.is_error === true) {
const errors = Array.isArray(line.errors) ? line.errors.filter(item => typeof item === 'string').join(';') : ''
return [{ kind: 'error', message: str(line.result).trim() || errors || str(line.subtype) || '没说原因' }]
}
return [{ kind: 'result', text: str(line.result).trim() || text, sessionId: str(line.session_id) || sessionId }]
}
return (raw) => {
let line: unknown
try {
line = JSON.parse(raw)
}
catch {
return []
}
// 子代理的消息(parent_tool_use_id 不空)不是给用户的回答
if (!isRecord(line) || (line.parent_tool_use_id !== undefined && line.parent_tool_use_id !== null))
return []
switch (line.type) {
case 'system':
if (line.subtype === 'init' && typeof line.session_id === 'string') {
sessionId = line.session_id
return [{ kind: 'session', sessionId }]
}
return []
case 'stream_event':
return isRecord(line.event) ? streamEvent(line.event) : []
case 'assistant':
return isRecord(line.message) ? assistant(line.message) : []
case 'result':
return result(line)
default:
return []
}
}
}
+114
View File
@@ -0,0 +1,114 @@
import type { AskEnd, AskStart, RunningAsk } from './asks'
import type { Answer, AskRun } from './claudeAsk'
import { describe, expect, it } from 'vitest'
import { ClaudeCancelledError, ClaudeError } from '../../cc/spawnClaude'
import { QaAsks } from './asks'
/** 假的回答:每次开问记下来,由测试决定什么时候答完 / 出错;叫停时像真的一样抛 ClaudeCancelledError */
function fakeRunner(): { runner: (run: AskRun) => Promise<Answer>, runs: (AskRun & { answer: (answer: Answer) => void, fail: (err: Error) => void })[] } {
const runs: (AskRun & { answer: (answer: Answer) => void, fail: (err: Error) => void })[] = []
return {
runs,
runner: run => new Promise<Answer>((resolve, reject) => {
run.signal.addEventListener('abort', () => reject(new ClaudeCancelledError('')))
runs.push({ ...run, answer: resolve, fail: reject })
}),
}
}
function start(askId: string, anchor = askId, owner = 'file:///a.py'): AskStart {
return { owner, askId, anchor, question: `问 ${askId}`, run: { prompt: `完整的 ${askId}`, cwd: '/work' } }
}
function listener(): { updates: RunningAsk[], ends: [RunningAsk, AskEnd][], ended: Promise<void>, update: (ask: RunningAsk) => void, end: (ask: RunningAsk, end: AskEnd) => void } {
let done: () => void = () => {}
const record = {
updates: [] as RunningAsk[],
ends: [] as [RunningAsk, AskEnd][],
ended: new Promise<void>((resolve) => {
done = resolve
}),
update: (ask: RunningAsk) => record.updates.push(ask),
end: (ask: RunningAsk, end: AskEnd) => {
record.ends.push([ask, end])
done()
},
}
return record
}
describe('正在答的问题', () => {
it('回答长出来、换了在做的事都报给界面;答完报 done,不再算在答', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner)
const events = listener()
expect(asks.start(start('1'), events)).toBeUndefined()
expect(runs[0]).toMatchObject({ prompt: '完整的 1', cwd: '/work' })
runs[0].onEvent({ kind: 'session', sessionId: 's' })
runs[0].onEvent({ kind: 'activity', label: '读取 m.py' })
runs[0].onEvent({ kind: 'text', text: '答' })
expect(events.updates).toEqual([
{ askId: '1', anchor: '1', question: '问 1', text: '', activity: '读取 m.py' },
{ askId: '1', anchor: '1', question: '问 1', text: '答' },
])
expect(asks.runningFor('file:///a.py')).toEqual([{ askId: '1', anchor: '1', question: '问 1', text: '答' }])
runs[0].answer({ text: '答案', sessionId: 's' })
await events.ended
expect(events.ends).toEqual([[{ askId: '1', anchor: '1', question: '问 1', text: '答' }, { status: 'done', answer: { text: '答案', sessionId: 's' } }]])
expect(asks.runningFor('file:///a.py')).toEqual([])
})
it('叫停:结束 claude,报 stopped,这块可以再问', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner)
const events = listener()
asks.start(start('1'), events)
asks.stop('1')
await events.ended
expect(runs[0].signal.aborted).toBe(true)
expect(events.ends[0][1]).toEqual({ status: 'stopped' })
expect(asks.start(start('2', '1'), listener())).toBeUndefined()
})
it('出错报 failed,带给人看的原因', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner)
const events = listener()
asks.start(start('1'), events)
runs[0].fail(new ClaudeError('Claude 退出码非零 (1): 网络断了', ''))
await events.ended
expect(events.ends[0][1]).toEqual({ status: 'failed', error: 'Claude Code 没答成:Claude 退出码非零 (1): 网络断了' })
})
it('同一块同时只答一个;别的文件里同样记号的块不算同一块', () => {
const { runner } = fakeRunner()
const asks = new QaAsks(runner)
asks.start(start('1', 'function f'), listener())
expect(asks.start(start('2', 'function f'), listener())).toBe('这块已经在问了,等它答完或者先停下再问。')
expect(asks.start(start('3', 'function f', 'file:///b.py'), listener())).toBeUndefined()
expect(asks.start(start('1', 'function g'), listener())).toBe('这个问题已经在问了。')
})
it('不同块可以同时答,最多几个;答完一个又能问', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner, 2)
const first = listener()
asks.start(start('1'), first)
asks.start(start('2'), listener())
expect(asks.start(start('3'), listener())).toBe('同时最多问 2 个问题,等前面的答完再问。')
runs[0].answer({ text: '答', sessionId: 's' })
await first.ended
expect(asks.start(start('3'), listener())).toBeUndefined()
})
it('全部叫停', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner)
const [a, b] = [listener(), listener()]
asks.start(start('1'), a)
asks.start(start('2'), b)
asks.stopAll()
await Promise.all([a.ended, b.ended])
expect(runs.every(run => run.signal.aborted)).toBe(true)
})
})
+107
View File
@@ -0,0 +1,107 @@
/**
* 正在答的问题:一块同时只答一个,不同块可以同时答(最多 MAX_RUNNING 个),能叫停。
* 不碰 VS Code:答完怎么存、怎么告诉界面交给 AskListener。
*/
import type { Answer, AskRun, AskRunner } from './claudeAsk'
import { ClaudeCancelledError } from '../../cc/spawnClaude'
import { askFailure } from './claudeAsk'
/** 同时答的问题最多几个:每个都是一个 claude 进程 */
export const MAX_RUNNING = 3
/** 正在答的一个问题,界面照它显示。 */
export interface RunningAsk {
askId: string
anchor: string
question: string
/** 回答到现在为止的全文(Markdown) */
text: string
/** 正在做的事:「读取 routes.py」 */
activity?: string
}
export type AskEnd
= | { status: 'done', answer: Answer }
| { status: 'failed', error: string }
| { status: 'stopped' }
export interface AskStart {
/** 哪份文件问的(文档 uri):同一块只看同一份文件里的 */
owner: string
askId: string
anchor: string
question: string
run: Omit<AskRun, 'signal' | 'onEvent'>
}
export interface AskListener {
update: (ask: RunningAsk) => void
end: (ask: RunningAsk, end: AskEnd) => void | Promise<void>
}
interface Running {
owner: string
ask: RunningAsk
controller: AbortController
}
export class QaAsks {
private readonly running = new Map<string, Running>()
constructor(private readonly runner: AskRunner, private readonly limit = MAX_RUNNING) {}
/** 开始答;这块已经在答、同时答的太多时不开始,返回原因(给人看的话)。 */
start(start: AskStart, listener: AskListener): string | undefined {
const { owner, askId, anchor, question } = start
if (this.running.has(askId))
return '这个问题已经在问了。'
if ([...this.running.values()].some(item => item.owner === owner && item.ask.anchor === anchor))
return '这块已经在问了,等它答完或者先停下再问。'
if (this.running.size >= this.limit)
return `同时最多问 ${this.limit} 个问题,等前面的答完再问。`
const controller = new AbortController()
const ask: RunningAsk = { askId, anchor, question, text: '' }
this.running.set(askId, { owner, ask, controller })
void this.runner({
...start.run,
signal: controller.signal,
onEvent: (event) => {
if (event.kind === 'text') {
ask.text = event.text
delete ask.activity
}
else if (event.kind === 'activity') {
ask.activity = event.label
}
else {
return
}
listener.update({ ...ask })
},
}).then(
(answer): AskEnd => ({ status: 'done', answer }),
(err: unknown): AskEnd => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: askFailure(err) },
).then(async (end) => {
this.running.delete(askId)
await listener.end({ ...ask }, end)
})
return undefined
}
/** 叫停:结束 claude 进程,这次不存。 */
stop(askId: string): void {
this.running.get(askId)?.controller.abort()
}
/** owner 这份文件正在答的问题,按开始的先后。 */
runningFor(owner: string): RunningAsk[] {
return [...this.running.values()].filter(item => item.owner === owner).map(item => ({ ...item.ask }))
}
/** 全部叫停(插件停用时)。 */
stopAll(): void {
for (const item of this.running.values())
item.controller.abort()
}
}
+116
View File
@@ -0,0 +1,116 @@
import type { runClaudeLines } from '../../cc/spawnClaude'
import type { AskDeps, AskRun } from './claudeAsk'
import { describe, expect, it } from 'vitest'
import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude'
import { askArgs, askClaude, askFailure } from './claudeAsk'
type RunLinesOptions = Parameters<typeof runClaudeLines>[0]
const line = (value: unknown): string => JSON.stringify(value)
const init = (session: string): string => line({ type: 'system', subtype: 'init', session_id: session })
const delta = (text: string): string => line({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text } }, parent_tool_use_id: null })
const result = (text: string, session: string): string => line({ type: 'result', subtype: 'success', is_error: false, result: text, session_id: session })
/** 假的 claude:记下每次的参数,按次序吐出事先给的行;给了 error 就在吐完后抛它 */
function fakeClaude(runs: { lines: string[], error?: Error }[]): { deps: AskDeps, calls: RunLinesOptions[] } {
const calls: RunLinesOptions[] = []
return {
calls,
deps: {
profilePath: () => '/profiles/offical.json',
runLines: async (options) => {
const run = runs[calls.length]
calls.push(options)
run.lines.forEach(options.onLine)
if (run.error)
throw run.error
},
},
}
}
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 }
}
describe('命令行参数', () => {
it('只读:只给读、搜的工具,别的一律拒、不等人批,不加载 MCP;不带跳过权限;输出逐字的 stream-json', () => {
const args = askArgs('问题', { profilePath: '/p/offical.json' })
expect(args).toEqual([
'--settings',
'/p/offical.json',
'--tools',
'Read,Grep,Glob',
'--allowedTools',
'Read,Grep,Glob',
'--permission-mode',
'dontAsk',
'--strict-mcp-config',
'-p',
'问题',
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
])
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|Edit|Write|Bash/)
})
it('追问带 --resume,放在问题前面;没有 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', '追问'])
})
})
describe('问一次', () => {
it('在工作区文件夹里跑、带超时和叫停;过程交给 onEvent,答完返回最终回答和会话 id', async () => {
const { deps, calls } = fakeClaude([{ lines: [init('s1'), delta('答'), delta('案'), result('答案', 's1')] }])
const run = request()
expect(await askClaude(run, deps)).toEqual({ text: '答案', sessionId: 's1' })
expect(run.events).toEqual([{ kind: 'session', sessionId: 's1' }, { kind: 'text', text: '答' }, { kind: 'text', text: '答案' }])
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({ cwd: '/work', signal: run.signal, timeoutMs: 300_000 })
expect(calls[0].args).toContain('/profiles/offical.json')
expect(calls[0].args[calls[0].args.indexOf('-p') + 1]).toBe('完整的问题')
})
it('追问接着上次的会话问短问题', async () => {
const { deps, calls } = fakeClaude([{ lines: [result('接着答', 's1')] }])
expect(await askClaude(request({ followUp: { sessionId: 's1', prompt: '短问题' } }), deps)).toEqual({ text: '接着答', sessionId: 's1' })
expect(calls[0].args.slice(calls[0].args.indexOf('--resume'), calls[0].args.indexOf('--resume') + 4)).toEqual(['--resume', 's1', '-p', '短问题'])
})
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: [init('s2'), result('重新答', 's2')] },
])
expect(await askClaude(request({ followUp: { sessionId: 's0', prompt: '短问题' } }), deps)).toEqual({ text: '重新答', sessionId: 's2' })
expect(calls).toHaveLength(2)
expect(calls[1].args).not.toContain('--resume')
expect(calls[1].args[calls[1].args.indexOf('-p') + 1]).toBe('完整的问题')
})
it('别的失败不重试,照样抛出来;Claude 报的失败、没给回答也算失败', async () => {
const timeout = new ClaudeTimeoutError('超时', '')
const failing = fakeClaude([{ lines: [], error: timeout }, { lines: [result('不该到这', 's')] }])
await expect(askClaude(request({ followUp: { sessionId: 's0', prompt: '短问题' } }), failing.deps)).rejects.toBe(timeout)
expect(failing.calls).toHaveLength(1)
await expect(askClaude(request(), fakeClaude([{ lines: [line({ type: 'result', is_error: true, result: 'Invalid API key · Please run /login' })] }]).deps))
.rejects
.toThrow('Invalid API key')
await expect(askClaude(request(), fakeClaude([{ lines: [init('s')] }]).deps)).rejects.toThrow('没给出回答')
})
})
describe('失败了给人看的话', () => {
it('超时、没装、没登录、别的', () => {
expect(askFailure(new ClaudeTimeoutError('超时', ''))).toBe('等了 5 分钟还没答完,已经停掉。可以把问题问得具体一点再试。')
expect(askFailure(new ClaudeError(CLAUDE_MISSING_MSG, ''))).toContain('没找到 Claude Code')
expect(askFailure(new ClaudeError('Claude 退出码非零 (1): Invalid API key · Please run /login', ''))).toMatch(/^Claude Code 没登录或登录过期了:在终端里运行 claude 登录一下再问。/)
expect(askFailure(new ClaudeError('Claude 退出码非零 (1): 网络断了', ''))).toBe('Claude Code 没答成:Claude 退出码非零 (1): 网络断了')
expect(askFailure(new ClaudeCancelledError(''))).toBe('Claude Code 没答成:Claude 调用已停止')
})
})
+136
View File
@@ -0,0 +1,136 @@
/**
* 用 Claude Code(`claude -p`,只读)回答代码问答:和插件别处的 `claude -p` 一样找 claude、清环境变量、
* 用默认 profile(见 cc/profiles);回答按 stream-json 逐字交出来,可以叫停。
*/
import type { AnswerEvent } from './answerStream'
import { accessSync, constants } from 'node:fs'
import { getDefaultProfilePath } from '../../cc/profiles'
import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude'
import { createAnswerParser } from './answerStream'
/** 只给读、搜文件的工具。 */
const READ_ONLY_TOOLS = 'Read,Grep,Glob'
export const ASK_TIMEOUT_MS = 5 * 60_000
/**
* 一次提问的命令行参数。只读靠三层:可用的内置工具只剩读、搜(`--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[] {
return [
...(options.profilePath ? ['--settings', options.profilePath] : []),
'--tools',
READ_ONLY_TOOLS,
'--allowedTools',
READ_ONLY_TOOLS,
'--permission-mode',
'dontAsk',
'--strict-mcp-config',
...(options.resume ? ['--resume', options.resume] : []),
'-p',
prompt,
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
]
}
/** 默认 profile 读得到就用它,读不到就不带 `--settings`(claude 回落到 ~/.claude 的登录)。 */
function defaultProfile(): string | undefined {
const profile = getDefaultProfilePath()
try {
accessSync(profile, constants.R_OK)
return profile
}
catch {
return undefined
}
}
export interface AskRun {
/** 带着代码上下文的完整问题 */
prompt: string
/** 追问:接着 sessionId 那次会话问 prompt;会话找不到了(过期、被清理)就按完整问题重新开一次 */
followUp?: { sessionId: string, prompt: string }
cwd: string
signal: AbortSignal
/** 答的过程中:会话 id、回答长出来的字、正在读什么 */
onEvent: (event: Exclude<AnswerEvent, { kind: 'result' | 'error' }>) => void
}
export interface Answer {
text: string
sessionId: string
}
export type AskRunner = (run: AskRun) => Promise<Answer>
export interface AskDeps {
runLines: typeof runClaudeLines
profilePath: () => string | undefined
}
const DEFAULT_DEPS: AskDeps = { runLines: runClaudeLines, profilePath: defaultProfile }
function missingSession(err: unknown): boolean {
return err instanceof ClaudeError && !(err instanceof ClaudeCancelledError) && /no conversation found/i.test(`${err.message}\n${err.stderr}`)
}
/** 问一次,答完返回最终回答;失败、超时、被叫停时抛 ClaudeError(及其子类),用 askFailure 换成给人看的话。 */
export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Promise<Answer> {
const profilePath = deps.profilePath()
const once = async (prompt: string, resume?: string): Promise<Answer> => {
const parse = createAnswerParser()
let answer: Answer | undefined
let failure: string | undefined
await deps.runLines({
args: askArgs(prompt, { profilePath, resume }),
cwd: run.cwd,
signal: run.signal,
timeoutMs: ASK_TIMEOUT_MS,
onLine: (line) => {
for (const event of parse(line)) {
if (event.kind === 'result')
answer = { text: event.text, sessionId: event.sessionId }
else if (event.kind === 'error')
failure = event.message
else
run.onEvent(event)
}
},
})
if (failure !== undefined)
throw new ClaudeError(failure, '')
if (!answer?.text)
throw new ClaudeError('Claude Code 结束了,但没给出回答', '')
return answer
}
if (run.followUp) {
try {
return await once(run.followUp.prompt, run.followUp.sessionId)
}
catch (err) {
if (!missingSession(err))
throw err
}
}
return once(run.prompt)
}
const AUTH_HINT = /\/login|log ?in|auth|401|403|api key|unauthori[sz]ed|credential|oauth/i
/** 问失败了,给人看的一句话。 */
export function askFailure(err: unknown): string {
if (err instanceof ClaudeTimeoutError)
return `等了 ${Math.round(ASK_TIMEOUT_MS / 60_000)} 分钟还没答完,已经停掉。可以把问题问得具体一点再试。`
if (err instanceof ClaudeError && err.message === CLAUDE_MISSING_MSG)
return '没找到 Claude Code(claude 命令)。装好、能在终端里运行 claude 之后再问。'
const message = err instanceof Error ? err.message : String(err)
if (err instanceof ClaudeError && AUTH_HINT.test(`${message}\n${err.stderr}`))
return `Claude Code 没登录或登录过期了:在终端里运行 claude 登录一下再问。(原话:${message})`
return `Claude Code 没答成:${message}`
}
+102
View File
@@ -0,0 +1,102 @@
import type { Disposable, Uri } from 'vscode'
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 { FolderStore, locate } from '../folderStore'
import { anchorSteps } from './anchor'
import { QaAsks } from './asks'
import { askClaude } from './claudeAsk'
import { QA_FILE, withEntry, withoutEntry } from './qaMap'
/** 正在答的问题有了进展(update),或答完了(end;答成了带存下的那条的 id)。owner 是问的那份文件的 uri。 */
export type AskEvent
= | { owner: string, kind: 'update', ask: RunningAsk }
| { owner: string, kind: 'end', ask: RunningAsk, end: AskEnd, entryId?: string }
export interface AskRequest {
askId: string
anchor: string
question: string
prompt: string
followUp?: { sessionId: string, prompt: string }
}
/**
* 代码问答:问 Claude Code、答完存进各工作区文件夹的 `.spx/code-qa.json`(改名、删文件、外部改动的跟随同文件注释,见 FolderStore)。
* 问答挂在块的位置记号上(见 BlockAnchors),按文件取给预览。
*/
export class CodeQa implements Disposable {
private readonly store = new FolderStore(QA_FILE)
private readonly asks: QaAsks
private readonly answered = new EventEmitter<AskEvent>()
/** 问答变了的那些文件 */
readonly onDidChange = this.store.onDidChange
readonly onDidAnswer = this.answered.event
constructor(runner: AskRunner = askClaude) {
this.asks = new QaAsks(runner)
}
/** 发给预览的这份文件的问答;文件不在工作区文件夹里时没地方存,问 AI 不可用。 */
view(uri: Uri): QaMessage {
const at = locate(uri)
const threads = at ? this.store.get(at.folder, at.key) ?? [] : []
return {
type: 'qa',
available: at !== undefined,
threads: threads.map(({ anchor, entries }) => ({
anchor,
where: anchorSteps(anchor),
entries: entries.map(({ id, question, answer, createdAt }) => ({ id, question, answer, createdAt })),
})),
running: this.asks.runningFor(uri.toString()),
}
}
/** 开始问;问不了时返回原因(给人看的话)。在工作区文件夹里跑 claude,它读得到整个项目。 */
ask(uri: Uri, request: AskRequest): string | undefined {
const at = locate(uri)
if (!at)
return '这个文件不在工作区文件夹里,问答没地方存。'
const owner = uri.toString()
const { askId, anchor, question, prompt, followUp } = request
return this.asks.start({ owner, askId, anchor, question, run: { prompt, followUp, cwd: at.folder.uri.fsPath } }, {
update: ask => this.answered.fire({ owner, kind: 'update', ask }),
end: async (ask, end) => {
if (end.status !== 'done') {
this.answered.fire({ owner, kind: 'end', ask, end })
return
}
const entry: QaEntry = {
id: randomUUID(),
question,
answer: end.answer.text,
createdAt: new Date().toISOString(),
sessionId: end.answer.sessionId,
}
await this.store.update(at.folder, map => withEntry(map, at.key, anchor, entry))
this.answered.fire({ owner, kind: 'end', ask, end, entryId: entry.id })
},
})
}
stop(askId: string): void {
this.asks.stop(askId)
}
/** 删掉一次问答,不用确认;块下面删光了这块就不挂了。 */
async delete(uri: Uri, anchor: string, entryId: string): Promise<void> {
const at = locate(uri)
if (at)
await this.store.update(at.folder, map => withoutEntry(map, at.key, anchor, entryId))
}
dispose(): void {
this.asks.stopAll()
this.answered.dispose()
this.store.dispose()
}
}
+74
View File
@@ -0,0 +1,74 @@
import type { BlockView } from '../blockView'
import type { AskSubject } from './prompt'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { createPythonAdapter } from '../languages/python'
import { WASM_DIR, withBlocks } from '../testSupport'
import { askPrompt, findBlock, followUpPrompt } from './prompt'
const py = createPythonAdapter(WASM_DIR)
const SOURCE = `import os
class ReferralRelation(Base):
"""推荐关系。"""
def display(self):
if self.active:
return self.name
return "none"
`
async function subject(source: string, pick: (views: BlockView[]) => BlockView): Promise<AskSubject> {
const blocks = await withBlocks(py, 'python', source, list => toBlockViews(list, source, py, undefined))
return { path: 'app/models.py', languageId: 'python', source, blocks, anchor: pick(blocks).anchor }
}
describe('问 AI 的话', () => {
it('第一次问:要求中文大白话、只读、写相对路径;带文件、语言、外层块的开头和这块代码(带行号)、问题', async () => {
const asked = await subject(SOURCE, blocks => blocks[1].children[0].children[0])
expect(asked.anchor).toMatch(/^class ReferralRelation > function display > if @/)
expect(askPrompt(asked, '为什么要判断 active?')).toBe([
'我在读代码,想问一块代码。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的其他文件,但只读,不要改任何东西。提到文件时写相对项目根目录的路径,最好带行号。',
'文件:app/models.py(python)',
'这块代码外面套着(各层外层块的开头):\n4 | class ReferralRelation(Base):\n5 | """推荐关系。"""\n7 | def display(self):',
'我问的是这一块(第 8–9 行):\n8 | if self.active:\n9 | return self.name',
'问题:为什么要判断 active?',
].join('\n\n'))
})
it('顶层的块没有外层;问函数时连主体一起贴', async () => {
const top = await subject(SOURCE, blocks => blocks[0])
const prompt = askPrompt(top, '导入了什么?')!
expect(prompt).not.toContain('外面套着')
expect(prompt).toContain('我问的是这一块(第 1–1 行):\n1 | import os')
const fn = askPrompt(await subject(SOURCE, blocks => blocks[1].children[0]), '做什么?')!
expect(fn).toContain('我问的是这一块(第 7–10 行):\n 7 | def display(self):\n 8 | if self.active:\n 9 | return self.name\n10 | return "none"')
})
it('很长的块只贴开头,告诉它剩下的在哪几行;外层块的开头每层最多几行', async () => {
const body = Array.from({ length: 150 }, (_, i) => ` x${i} = ${i}`).join('\n')
const doc = Array.from({ length: 10 }, (_, i) => ` 第 ${i} 行说明`).join('\n')
const source = `def big():\n """\n${doc}\n """\n${body}\n`
const asked = await subject(source, blocks => blocks[0])
const prompt = askPrompt(asked, '?')!
expect(prompt).toContain('(这块一共 163 行,上面只贴了开头 120 行,剩下的第 121–163 行需要时自己读文件)')
expect(prompt).toContain('120 | x106 = 106')
expect(prompt).not.toContain('x107 = 107')
const inner = askPrompt(await subject(source, blocks => blocks[0].children[3]), '?')!
expect(inner).toContain(' 6 | 第 3 行说明\n | …\n')
})
it('找不到这块(代码刚改过)时没有', async () => {
const asked = await subject(SOURCE, blocks => blocks[0])
expect(askPrompt({ ...asked, anchor: 'function gone' }, '?')).toBeUndefined()
expect(followUpPrompt({ ...asked, anchor: 'function gone' }, '?')).toBeUndefined()
expect(findBlock(asked.blocks, 'function gone')).toBeUndefined()
})
it('追问只提醒是哪块', async () => {
const asked = await subject(SOURCE, blocks => blocks[1].children[0])
expect(followUpPrompt(asked, '那 name 从哪来?')).toBe('接着问(还是 app/models.py 第 7–10 行那块代码,代码可能刚改过,需要时重新读文件):那 name 从哪来?')
})
})
+86
View File
@@ -0,0 +1,86 @@
/**
* 问 AI 时发给 Claude Code 的话:文件、语言、这块代码(带行号)、外面套着的各层块的开头、问题。
* 只用和语言无关的块数据(BlockView 的行号、记号、树形),不认识任何语言。
*/
import type { BlockView } from '../blockView'
import { splitLines } from '../editText'
/** 这块代码最多贴多少行,再长的只贴开头、告诉它剩下的在哪几行 */
const BLOCK_LINES = 120
/** 外层块的开头每层最多贴几行(函数的说明可能很长) */
const OUTER_LINES = 6
export interface AskSubject {
/** 相对工作区文件夹的路径,`/` 分隔 */
path: string
languageId: string
source: string
blocks: BlockView[]
anchor: string
}
/** anchor 那块,和从最外层到它的各层外层块;树里没有这块时没有。 */
export function findBlock(blocks: BlockView[], anchor: string): { block: BlockView, outer: BlockView[] } | undefined {
for (const block of blocks) {
if (block.anchor === anchor)
return { block, outer: [] }
const inner = findBlock(block.children, anchor)
if (inner)
return { block: inner.block, outer: [block, ...inner.outer] }
}
return undefined
}
/** 块连同子孙管到的最后一行(0 起):复合语句的头部之外还有主体。 */
function lastLine(block: BlockView): number {
return Math.max(block.endLine, ...block.children.map(lastLine))
}
/** 这块代码占的行(1 起,给人看的写法)。 */
export function blockLines(block: BlockView): { from: number, to: number } {
return { from: block.startLine + 1, to: lastLine(block) + 1 }
}
function numbered(lines: string[], from: number, to: number, width: number): string[] {
return lines.slice(from, to + 1).map((text, i) => `${String(from + i + 1).padStart(width)} | ${text}`)
}
/** 第一次问这块:带上代码上下文的完整问题;树里找不到这块(刚改过代码)时没有。 */
export function askPrompt(subject: AskSubject, question: string): string | undefined {
const found = findBlock(subject.blocks, subject.anchor)
if (!found)
return undefined
const { block, outer } = found
const lines = splitLines(subject.source)
const last = Math.min(lastLine(block), lines.length - 1)
const width = String(last + 1).length
const shownLast = Math.min(last, block.startLine + BLOCK_LINES - 1)
const { from, to } = blockLines(block)
const parts = [
'我在读代码,想问一块代码。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的其他文件,但只读,不要改任何东西。'
+ '提到文件时写相对项目根目录的路径,最好带行号。',
`文件:${subject.path}(${subject.languageId})`,
]
if (outer.length > 0) {
const heads = outer.flatMap((item) => {
const end = Math.min(item.endLine, item.startLine + OUTER_LINES - 1)
return [...numbered(lines, item.startLine, end, width), ...(end < item.endLine ? [`${' '.repeat(width)} | …`] : [])]
})
parts.push(`这块代码外面套着(各层外层块的开头):\n${heads.join('\n')}`)
}
const body = numbered(lines, block.startLine, shownLast, width)
const rest = shownLast < last ? `\n(这块一共 ${last - block.startLine + 1} 行,上面只贴了开头 ${BLOCK_LINES} 行,剩下的第 ${shownLast + 2}–${to} 行需要时自己读文件)` : ''
parts.push(`我问的是这一块(第 ${from}–${to} 行):\n${body.join('\n')}${rest}`)
parts.push(`问题:${question}`)
return parts.join('\n\n')
}
/** 接着上一次的会话追问:代码上下文会话里已经有了,只提醒是哪块。 */
export function followUpPrompt(subject: AskSubject, question: string): string | undefined {
const found = findBlock(subject.blocks, subject.anchor)
if (!found)
return undefined
const { from, to } = blockLines(found.block)
return `接着问(还是 ${subject.path} 第 ${from}–${to} 行那块代码,代码可能刚改过,需要时重新读文件):${question}`
}
+117
View File
@@ -0,0 +1,117 @@
import type { QaEntry, QaMap } from './qaMap'
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 { QA_FILE, withEntry, withoutEntry } from './qaMap'
function entry(id: string, createdAt: string, question = `问 ${id}`): QaEntry {
return { id, question, answer: `答 ${id}`, createdAt, sessionId: `s-${id}` }
}
const DISPLAY = 'class ReferralRelation > function display'
const IF = `${DISPLAY} > if @3fa1c2d0`
let root: string
const qaFile = (): string => path.join(root, '.spx', 'code-qa.json')
beforeEach(async () => {
root = await fsp.mkdtemp(path.join(os.tmpdir(), 'code-qa-'))
})
afterEach(async () => {
await fsp.rm(root, { recursive: true, force: true })
})
async function writeRaw(content: unknown): Promise<void> {
await fsp.mkdir(path.dirname(qaFile()), { recursive: true })
await fsp.writeFile(qaFile(), typeof content === 'string' ? content : JSON.stringify(content), 'utf8')
}
describe('记一次、删一次', () => {
it('记在文件的那块下面:块按记号排,问答按时间排', () => {
let map: QaMap = {}
map = withEntry(map, 'app/models.py', IF, entry('b', '2026-09-26T10:00:00.000Z'))
map = withEntry(map, 'app/models.py', DISPLAY, entry('c', '2026-09-26T11:00:00.000Z'))
map = withEntry(map, 'app/models.py', IF, entry('a', '2026-09-25T10:00:00.000Z'))
expect(map).toEqual({
'app/models.py': [
{ anchor: DISPLAY, entries: [entry('c', '2026-09-26T11:00:00.000Z')] },
{ anchor: IF, entries: [entry('a', '2026-09-25T10:00:00.000Z'), entry('b', '2026-09-26T10:00:00.000Z')] },
],
})
})
it('删一次只删它;块下面删光了这块不挂了,文件里删光了这份文件不记了;不改传进来的那份', () => {
const map = withEntry(withEntry(withEntry({}, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, entry('b', '2026-09-26T10:00:00.000Z')), 'a.py', DISPLAY, entry('c', '2026-09-26T11:00:00.000Z'))
const once = withoutEntry(map, 'a.py', IF, 'a')
expect(once['a.py'].map(thread => [thread.anchor, thread.entries.map(item => item.id)])).toEqual([[DISPLAY, ['c']], [IF, ['b']]])
const twice = withoutEntry(once, 'a.py', IF, 'b')
expect(twice['a.py'].map(thread => thread.anchor)).toEqual([DISPLAY])
expect(withoutEntry(twice, 'a.py', DISPLAY, 'c')).toEqual({})
expect(map['a.py'].flatMap(thread => thread.entries)).toHaveLength(3)
})
it('删不存在的那条不动', () => {
const map = withEntry({}, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z'))
expect(withoutEntry(map, 'a.py', IF, 'nope')).toEqual(map)
expect(withoutEntry(map, 'b.py', IF, 'a')).toEqual(map)
})
it('文件改名、挪目录、删除时问答跟着走(同文件注释的规矩)', () => {
const map = withEntry(withEntry({}, 'app/models.py', IF, entry('a', '2026-09-25T10:00:00.000Z')), 'app/views.py', DISPLAY, entry('b', '2026-09-25T10:00:00.000Z'))
expect(Object.keys(moveEntries(map, 'app/models.py', 'app/orm.py'))).toEqual(['app/views.py', 'app/orm.py'])
expect(Object.keys(moveEntries(map, 'app', 'backend/app')).sort()).toEqual(['backend/app/models.py', 'backend/app/views.py'])
expect(Object.keys(dropEntries(map, 'app/models.py'))).toEqual(['app/views.py'])
})
})
describe('读写 .spx/code-qa.json', () => {
it('文件不存在、解析失败、不是对象都当没有', async () => {
expect(await readPathMap(root, QA_FILE)).toEqual({})
await writeRaw('{ "a.py": [')
expect(await readPathMap(root, QA_FILE)).toEqual({})
await writeRaw('[]')
expect(await readPathMap(root, QA_FILE)).toEqual({})
})
it('跳过认不出的块和问答:没有问题 / 回答、时间不对、没有记号、没有问答的块、不是数组的文件', async () => {
await writeRaw({
'a.py': [
{ anchor: IF, entries: [entry('ok', '2026-09-25T10:00:00.000Z'), { id: 'x', question: '', answer: 'A', createdAt: '2026-09-25T10:00:00.000Z' }, { ...entry('bad', 'not a date') }] },
{ anchor: '', entries: [entry('no-anchor', '2026-09-25T10:00:00.000Z')] },
{ anchor: DISPLAY, entries: [] },
'junk',
],
'b.py': { anchor: IF },
'c.py': [{ anchor: IF, entries: [{ id: 'y', question: 'Q', answer: 'A', createdAt: '2026-09-25T10:00:00.000Z' }] }],
'../outside.py': [{ anchor: IF, entries: [entry('z', '2026-09-25T10:00:00.000Z')] }],
})
expect(await readPathMap(root, QA_FILE)).toEqual({
'a.py': [{ anchor: IF, entries: [entry('ok', '2026-09-25T10:00:00.000Z')] }],
// 没有会话 id 的照样认,追问时重新开会话
'c.py': [{ anchor: IF, entries: [{ id: 'y', question: 'Q', answer: 'A', createdAt: '2026-09-25T10:00:00.000Z', sessionId: '' }] }],
})
})
it('手改过的顺序、重复的块读进来整理好', async () => {
await writeRaw({ 'a.py': [
{ anchor: IF, entries: [entry('b', '2026-09-26T10:00:00.000Z')] },
{ anchor: DISPLAY, entries: [entry('c', '2026-09-26T11:00:00.000Z')] },
{ anchor: IF, entries: [entry('a', '2026-09-25T10:00:00.000Z')] },
] })
expect(await readPathMap(root, QA_FILE)).toEqual(withEntry(withEntry(withEntry({}, 'a.py', IF, entry('b', '2026-09-26T10:00:00.000Z')), 'a.py', DISPLAY, entry('c', '2026-09-26T11:00:00.000Z')), 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z')))
})
it('写:建 .spx、文件按路径排、两格缩进、末尾换行,读得回来;删光留 {}', async () => {
const map = withEntry(withEntry({}, 'z.py', IF, entry('a', '2026-09-25T10:00:00.000Z', '含 "引号" 和 `代码`')), 'a.py', DISPLAY, entry('b', '2026-09-25T10:00:00.000Z'))
await writePathMap(root, QA_FILE, map)
const raw = await fsp.readFile(qaFile(), 'utf8')
expect(raw).toBe(`${JSON.stringify({ 'a.py': map['a.py'], 'z.py': map['z.py'] }, null, 2)}\n`)
expect(raw.indexOf('"id"')).toBeLessThan(raw.indexOf('"question"'))
expect(await readPathMap(root, QA_FILE)).toEqual(map)
await writePathMap(root, QA_FILE, {})
expect(await fsp.readFile(qaFile(), 'utf8')).toBe('{}\n')
})
})
+90
View File
@@ -0,0 +1,90 @@
/**
* 代码问答的存法:每个工作区文件夹一份 `<workspace>/.spx/code-qa.json`,读写的规矩同文件注释(见 pathMap)。
* 形状是 `{ "相对路径": [{ anchor, entries: [{ id, question, answer, createdAt, sessionId }] }] }`:
* 一份文件里挂着问答的每一块一项(anchor 见 BlockAnchors),块下面问过的按时间排。
*/
import type { PathMap, PathMapFile } from '../pathMap'
/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、Claude Code 的会话 id。 */
export interface QaEntry {
id: string
question: string
answer: string
createdAt: string
sessionId: string
}
/** 挂在一块上的问答。 */
export interface QaThread {
anchor: string
entries: QaEntry[]
}
export type QaMap = PathMap<QaThread[]>
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function text(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() !== '' ? value : undefined
}
function parseEntry(value: unknown): QaEntry | undefined {
if (!isRecord(value))
return undefined
const [id, question, answer, createdAt] = [text(value.id), text(value.question), text(value.answer), text(value.createdAt)]
if (!id || !question || !answer || !createdAt || Number.isNaN(Date.parse(createdAt)))
return undefined
return { id, question, answer, createdAt, sessionId: typeof value.sessionId === 'string' ? value.sessionId : '' }
}
/** 同一块的并成一项;块按记号排,问答按时间排:写出来的顺序稳定,git diff 干净。 */
function tidy(threads: QaThread[]): QaThread[] {
const byAnchor = new Map<string, QaEntry[]>()
for (const thread of threads)
byAnchor.set(thread.anchor, [...byAnchor.get(thread.anchor) ?? [], ...thread.entries])
return [...byAnchor]
.filter(([, entries]) => entries.length > 0)
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
.map(([anchor, entries]) => ({
anchor,
entries: [...entries].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
}))
}
/** 一份文件的问答:不是数组、一项都认不出的当没有;认不出的块、问答跳过。 */
function parseThreads(value: unknown): QaThread[] | undefined {
if (!Array.isArray(value))
return undefined
const threads = value.flatMap((item): QaThread[] => {
const anchor = isRecord(item) ? text(item.anchor) : undefined
if (!anchor || !isRecord(item) || !Array.isArray(item.entries))
return []
return [{ anchor, entries: item.entries.flatMap(entry => parseEntry(entry) ?? []) }]
})
const tidied = tidy(threads)
return tidied.length > 0 ? tidied : undefined
}
export const QA_FILE: PathMapFile<QaThread[]> = {
path: '.spx/code-qa.json',
label: '代码问答',
parse: parseThreads,
}
/** 在 key 这份文件的 anchor 那块下面记一次问答。 */
export function withEntry(map: QaMap, key: string, anchor: string, entry: QaEntry): QaMap {
return { ...map, [key]: tidy([...map[key] ?? [], { anchor, entries: [entry] }]) }
}
/** 删掉一次问答;块下面删光了这块就不挂了,文件里删光了这份文件就不记了。 */
export function withoutEntry(map: QaMap, key: string, anchor: string, entryId: string): QaMap {
const threads = tidy((map[key] ?? []).map(thread => thread.anchor === anchor
? { anchor, entries: thread.entries.filter(entry => entry.id !== entryId) }
: thread))
const next = { ...map }
delete next[key]
return threads.length > 0 ? { ...next, [key]: threads } : next
}
+12 -2
View File
@@ -4,12 +4,15 @@ import type { DocumentMessage, Excluded, Reveal } from './types'
import type { ViewState } from './viewState'
import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { OrphanThreads } from './AskThread'
import { BlockLevel } from './BlockTree'
import { coveredByEditing, startEditing } from './editing'
import { applyFilter, keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar'
import { orphanThreads } from './qa'
import { useDefinitionLinks } from './useDefinitionLinks'
import { HighlightContext, useOccurrences } from './useOccurrences'
import { useQa } from './useQa'
import { useReveal } from './useReveal'
import { useScrollPosition } from './useScrollPosition'
import { collapsedIds } from './viewState'
@@ -25,6 +28,8 @@ function savedBlockModes(): BlockModes {
}
export function App(): ReactElement | null {
// 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答
const qa = useQa()
const [doc, setDoc] = useState<DocumentMessage | null>(null)
const [view, setView] = useState<BlockModes>(savedBlockModes)
const [focusId, setFocusId] = useState<string | null>(null)
@@ -80,6 +85,9 @@ export function App(): ReactElement | null {
const onFocused = useCallback(() => setFocusId(null), [])
const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc])
const kindName = useCallback((kind: string) => kindNames.get(kind) ?? kind, [kindNames])
/** 找不到原来位置的问答;解析失败时一块都没有,不算 */
const orphans = useMemo(() => doc && doc.error === undefined && qa.state.available ? orphanThreads(qa.state.threads, doc.blocks) : [], [doc, qa.state])
const tree = useMemo((): BlockTreeState | null => doc && {
version: doc.version,
editing: new Set(view.editing),
@@ -87,7 +95,7 @@ export function App(): ReactElement | null {
hidden: filter.hidden,
temporary: filter.temporary,
covered,
kindName: kind => kindNames.get(kind) ?? kind,
kindName,
focusId,
onFocused,
onToggleEdit: (id) => {
@@ -98,7 +106,8 @@ export function App(): ReactElement | null {
}))
},
onToggleFold: id => setFolded([id], !collapsed.has(id)),
}, [doc, view, collapsed, filter, covered, kindNames, focusId, onFocused, setFolded])
...(qa.state.available && { qa }),
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, qa])
if (!doc || !tree)
return null
@@ -128,6 +137,7 @@ export function App(): ReactElement | null {
</div>
</div>
)}
{orphans.length > 0 && <OrphanThreads threads={orphans} kindName={kindName} onDelete={qa.onDelete} />}
{doc.blocks.length === 0
? doc.error === undefined && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>
: doc.blocks.some(block => !filter.hidden.has(block.id))
@@ -0,0 +1,86 @@
import type { AskThreadProps } from './AskThread'
import type { Ask } from './qa'
import type { QaEntryView } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { AskThread, OrphanThreads } from './AskThread'
const NOW = new Date(2026, 8, 26, 15, 0).getTime()
const HISTORY: QaEntryView[] = [
{ id: 'e1', question: '为什么普通用户不能看别人的资料?', answer: '这是**权限控制**:不是管理员、又不是查自己时抛 `Forbidden`。', createdAt: new Date(2026, 8, 23, 10, 0).toISOString() },
{ id: 'e2', question: 'UserRole 还有哪些角色?', answer: '只有 ADMIN 和 USER。', createdAt: new Date(2026, 8, 25, 10, 0).toISOString() },
]
function render(props: Partial<AskThreadProps>): string {
return renderToStaticMarkup(<AskThread history={[]} asks={[]} open={false} now={NOW} onAsk={() => {}} onStop={() => {}} onDelete={() => {}} onClose={() => {}} {...props} />)
}
const text = (html: string): string => html.replace(/<[^>]*>/g, '')
function ask(overrides: Partial<Ask>): Ask {
return { askId: 'a1', anchor: 'x', question: 'Forbidden 抛出去以后前端收到什么?', text: '', status: 'running', ...overrides }
}
describe('块下面的问答区', () => {
it('没问过、没展开:什么都不画', () => {
expect(render({})).toBe('')
})
it('问过、没展开:只露一行「问过 N 次」,点开才看历史', () => {
const html = render({ history: HISTORY })
expect(html).toContain('data-collapsed')
expect(text(html)).toBe('问过 2 次')
expect(html).toContain('aria-expanded="false"')
expect(html).not.toContain('<textarea')
})
it('展开:灰字的历史(问题、时间、「已理解,删除」、回答前两行),当前这一轮照常颜色,底下输入框', () => {
const html = render({ open: true, history: HISTORY, asks: [ask({ text: '`Forbidden` 定义在 **core**', activity: '读取 exceptions.py' })] })
expect(html.match(/data-history=""/g)).toHaveLength(2)
expect(text(html)).toContain('为什么普通用户不能看别人的资料?3 天前')
expect(text(html)).toContain('UserRole 还有哪些角色?昨天')
expect(html.match(/title="已理解,删除" aria-label="已理解,删除"/g)).toHaveLength(2)
expect(text(html)).toContain('这是权限控制:不是管理员、又不是查自己时抛 Forbidden。')
expect(html).toContain('<hr class="cr-qa-divider"/>')
expect(html).toContain('data-current=""')
expect(html).toContain('data-streaming=""><p><code>Forbidden</code> 定义在 <strong>core</strong></p>')
expect(text(html)).toContain('读取 exceptions.py')
expect(html).toContain('placeholder="继续问… Enter 发送,Esc 收起"')
expect(html).toContain('title="停止" aria-label="停止"')
expect(html).not.toContain('aria-label="发送"')
})
it('还没问过:输入框提示问这块代码,发送按钮在没字时不能点;答完的回答按 Markdown 排、不再闪光标', () => {
expect(render({ open: true })).toContain('placeholder="问这块代码… Enter 发送,Esc 收起"')
expect(render({ open: true })).toMatch(/aria-label="发送" disabled=""/)
const done = render({ open: true, asks: [ask({ status: 'done', text: '答完了' })] })
expect(done).toContain('<div class="cr-markdown cr-qa-answer"><p>答完了</p>')
expect(done).not.toContain('data-streaming')
expect(done).not.toContain('role="status"')
})
it('出错、停止写在这一问下面', () => {
const failed = render({ open: true, asks: [ask({ status: 'failed', error: 'Claude Code 没登录或登录过期了' })] })
expect(failed).toContain('role="alert"')
expect(text(failed)).toContain('Claude Code 没登录或登录过期了')
expect(text(render({ open: true, asks: [ask({ status: 'stopped', text: '一半' })] }))).toContain('已停止,这次没存。')
expect(text(render({ open: true, asks: [ask({})] }))).toContain('正在想…')
})
})
describe('找不到原来位置的问答', () => {
it('灰字一栏:写着原来在哪,问答照样能删', () => {
const html = renderToStaticMarkup(
<OrphanThreads
threads={[{ anchor: 'function display > if @1', where: [{ kind: 'function', name: 'display' }, { kind: 'if' }], entries: HISTORY }]}
kindName={kind => ({ function: '函数', if: '条件' })[kind] ?? kind}
now={NOW}
onDelete={() => {}}
/>,
)
expect(text(html)).toContain('代码改动后找不到原来位置的问答 · 2 条')
expect(text(html)).toContain('原来在:函数 display › 条件')
expect(html.match(/aria-label="已理解,删除"/g)).toHaveLength(2)
})
})
@@ -0,0 +1,198 @@
import type { KeyboardEvent, ReactElement } from 'react'
import type { Ask } from './qa'
import type { QaEntryView, QaThreadView } from './types'
import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert } from 'lucide-react'
import { useState } from 'react'
import { renderMarkdown, renderMarkdownOnce } from './markdown'
import { answerPreview, relativeTime, whereLabel } from './qa'
/** 问过的一次,灰字:问题、时间、「已理解,删除」,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */
function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number, onDelete: () => void }): ReactElement {
const [expanded, setExpanded] = useState(false)
const toggle = (): void => setExpanded(value => !value)
return (
<div className="cr-qa-entry" data-history="">
<div className="cr-qa-head">
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<button type="button" className="cr-qa-question" aria-expanded={expanded} title={expanded ? '收起回答' : '展开整段回答'} onClick={toggle}>
{entry.question}
</button>
<time className="cr-qa-when" dateTime={entry.createdAt} title={new Date(entry.createdAt).toLocaleString()}>
{relativeTime(entry.createdAt, now)}
</time>
<button type="button" className="cr-tool cr-qa-delete" title="已理解,删除" aria-label="已理解,删除" onClick={onDelete}>
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
</button>
</div>
{expanded
? <div className="cr-markdown cr-qa-answer" dangerouslySetInnerHTML={{ __html: renderMarkdown(entry.answer) }} />
: (
<button type="button" className="cr-qa-preview" onClick={toggle}>
{answerPreview(entry.answer)}
</button>
)}
</div>
)
}
/** 当前这一轮问的一次,照常颜色:问题,回答逐字长出来(按 Markdown 排),在做的事、出错、停止写在下面 */
function CurrentItem({ ask }: { ask: Ask }): ReactElement {
const running = ask.status === 'running'
return (
<div className="cr-qa-entry" data-current="">
<div className="cr-qa-head">
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<span className="cr-qa-question">{ask.question}</span>
</div>
{ask.text !== '' && (
<div
className="cr-markdown cr-qa-answer"
data-streaming={running ? '' : undefined}
dangerouslySetInnerHTML={{ __html: running ? renderMarkdownOnce(ask.text) : renderMarkdown(ask.text) }}
/>
)}
{running && (
<div className="cr-qa-status" role="status">
<LoaderCircle size={12} strokeWidth={1.8} className="cr-qa-spin" aria-hidden />
{ask.activity ?? (ask.text === '' ? '正在想…' : '正在回答…')}
</div>
)}
{ask.status === 'failed' && (
<div className="cr-qa-error" role="alert">
<TriangleAlert size={13} strokeWidth={1.8} className="mt-[3px] shrink-0" aria-hidden />
<span>{ask.error}</span>
</div>
)}
{ask.status === 'stopped' && <div className="cr-qa-status">已停止,这次没存。</div>}
</div>
)
}
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止 */
function AskInput({ running, first, onAsk, onStop, onClose }: {
running: Ask | undefined
/** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */
first: boolean
onAsk: (question: string) => void
onStop: (askId: string) => void
onClose: () => void
}): ReactElement {
const [value, setValue] = useState('')
const send = (): void => {
const question = value.trim()
if (question === '' || running)
return
onAsk(question)
setValue('')
}
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
// 输入法选字时的回车不算发送
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
send()
}
else if (event.key === 'Escape') {
event.preventDefault()
onClose()
}
}
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}
/>
{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>
)
}
export interface AskThreadProps {
history: QaEntryView[]
asks: Ask[]
/** 点了「问 AI」:展开全部历史、当前这一轮和输入框 */
open: boolean
now?: number
onAsk: (question: string) => void
onStop: (askId: string) => void
onDelete: (entryId: string) => void
onClose: () => void
}
/**
* 块下面的问答区(左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
* 没展开但问过的块只露一行「问过 N 次」,点开看灰字历史,读代码时一眼知道这里问过什么。
*/
export function AskThread({ history, asks, open, now = Date.now(), onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(false)
if (!open) {
if (history.length === 0)
return null
const Chevron = showHistory ? ChevronDown : ChevronRight
return (
<div className="cr-qa" data-collapsed="">
<button type="button" className="cr-qa-summary" aria-expanded={showHistory} onClick={() => setShowHistory(value => !value)}>
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
{`问过 ${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)} />)}
</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.length > 0 && asks.length > 0 && <hr className="cr-qa-divider" />}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} />)}
<AskInput
running={asks.find(ask => ask.status === 'running')}
first={history.length === 0 && asks.length === 0}
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
/>
</section>
)
}
/** 预览顶上的灰字一栏:代码改动后找不到原来位置的问答,写着原来在哪,照样能看、能删 */
export function OrphanThreads({ threads, kindName, now = Date.now(), onDelete }: {
threads: QaThreadView[]
kindName: (kind: string) => string
now?: number
onDelete: (anchor: string, entryId: string) => void
}): ReactElement {
const [expanded, setExpanded] = useState(true)
const Chevron = expanded ? ChevronDown : ChevronRight
const count = threads.reduce((sum, thread) => sum + thread.entries.length, 0)
return (
<section className="cr-qa-orphans" aria-label="代码改动后找不到原来位置的问答">
<button type="button" className="cr-qa-summary" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>
<Chevron size={12} strokeWidth={1.8} aria-hidden />
{`代码改动后找不到原来位置的问答 · ${count} 条`}
</button>
{expanded && threads.map(thread => (
<div key={thread.anchor} className="cr-qa">
<div className="cr-qa-where">{`原来在:${whereLabel(thread.where, kindName)}`}</div>
{thread.entries.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(thread.anchor, entry.id)} />)}
</div>
))}
</section>
)
}
@@ -2,7 +2,9 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { BlockTools } from './BlockTools'
const render = (editing: boolean): string => renderToStaticMarkup(<BlockTools editing={editing} onToggleEdit={() => {}} />)
function render(editing: boolean, ask?: { asking: boolean }): string {
return renderToStaticMarkup(<BlockTools editing={editing} onToggleEdit={() => {}} {...(ask && { asking: ask.asking, onToggleAsk: () => {} })} />)
}
/** 标签之间的字:图标(svg)没有字,按钮上有字就会出现在这里 */
const visibleText = (html: string): string => html.replace(/<[^>]*>/g, '').trim()
@@ -17,4 +19,15 @@ describe('块右上角的图标按钮', () => {
expect(html).toMatch(/<button[^>]*><svg/)
expect(html.includes('data-active')).toBe(editing)
})
it.each([false, true])('「问 AI」排在编辑前面,只有图标;问答区展开着 = %s 时带按下标记', (asking) => {
const html = render(false, { asking })
expect(visibleText(html)).toBe('')
const buttons = [...html.matchAll(/<button[^>]*>/g)].map(match => match[0])
expect(buttons).toHaveLength(2)
expect(buttons[0]).toContain('title="问 AI" aria-label="问 AI"')
expect(buttons[0]).toContain(`aria-expanded="${asking}"`)
expect(buttons[0].includes('data-active')).toBe(asking)
expect(buttons[1]).toContain('title="编辑"')
})
})
@@ -1,15 +1,36 @@
import type { ReactElement } from 'react'
import { Eye, Pencil } from 'lucide-react'
import { Eye, Pencil, Sparkles } from 'lucide-react'
/**
* 块右上角的一排图标按钮(普通块、属性表、库规则的卡片共用):只放图标,名字写在悬停提示(title)和读屏用的 aria-label 里。
* 眼下只有「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
* 「问 AI」:点开 / 收起块下面的问答区,展开时带按下的底色。
* 「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
*/
export function BlockTools({ editing, onToggleEdit }: { editing: boolean, onToggleEdit: () => void }): ReactElement {
export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk }: {
editing: boolean
onToggleEdit: () => void
/** 块下面的问答区展开着 */
asking?: boolean
/** 没给就不显示「问 AI」(文件不在工作区文件夹里,问答没地方存) */
onToggleAsk?: () => void
}): ReactElement {
const label = editing ? '预览' : '编辑'
const Icon = editing ? Eye : Pencil
return (
<div className="flex h-5 shrink-0 items-center gap-0.5" role="group" aria-label="块操作">
{onToggleAsk && (
<button
type="button"
className="cr-tool flex h-5 w-6 items-center justify-center rounded"
data-active={asking ? '' : undefined}
title="问 AI"
aria-label="问 AI"
aria-expanded={asking === true}
onClick={onToggleAsk}
>
<Sparkles size={14} strokeWidth={1.8} aria-hidden />
</button>
)}
<button
type="button"
className="cr-tool flex h-5 w-6 items-center justify-center rounded"
+37 -1
View File
@@ -1,10 +1,13 @@
import type { CSSProperties, ReactElement } from 'react'
import type { QaState } from './qa'
import type { BlockView } from './types'
import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react'
import { Fragment } from 'react'
import { AskThread } from './AskThread'
import { BlockTools } from './BlockTools'
import { CodeEditor } from './CodeEditor'
import { PropertyGrid } from './PropertyGrid'
import { asksOf, historyOf } from './qa'
import { TextPreview } from './TextPreview'
export interface BlockTreeState {
@@ -25,6 +28,18 @@ export interface BlockTreeState {
onFocused: () => void
onToggleEdit: (id: string) => void
onToggleFold: (id: string) => void
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
qa?: QaTree
}
/** 问 AI 的状态和动作,块按自己的记号(anchor)取 */
export interface QaTree {
state: QaState
onToggle: (anchor: string) => void
onClose: (anchor: string) => void
onAsk: (anchor: string, question: string) => void
onStop: (askId: string) => void
onDelete: (anchor: string, entryId: string) => void
}
/** 块下面有没有画出来的子块:子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */
@@ -64,6 +79,7 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner
{blocks.filter(block => shown(block, tree)).map(block => (
<Fragment key={block.id}>
<BlockCard block={block} tree={tree} />
{tree.qa && <BlockQa key={block.anchor} anchor={block.anchor} qa={tree.qa} />}
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
<BlockLevel blocks={block.children} owner={block} tree={tree} />
)}
@@ -74,6 +90,21 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner
)
}
/** 块下面的问答区,紧跟在块后面、子块前面;输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */
function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
return (
<AskThread
history={historyOf(qa.state, anchor)}
asks={asksOf(qa.state, anchor)}
open={qa.state.open.includes(anchor)}
onAsk={question => qa.onAsk(anchor, question)}
onStop={qa.onStop}
onDelete={entryId => qa.onDelete(anchor, entryId)}
onClose={() => qa.onClose(anchor)}
/>
)
}
/** 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文,带说明的译文下面接着说明。 */
function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
if ('properties' in block)
@@ -135,7 +166,12 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
已被筛选隐藏,临时显示
</span>
)}
<BlockTools editing={editing} onToggleEdit={() => tree.onToggleEdit(block.id)} />
<BlockTools
editing={editing}
onToggleEdit={() => tree.onToggleEdit(block.id)}
asking={tree.qa?.state.open.includes(block.anchor)}
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
/>
</div>
)
}
+219 -2
View File
@@ -106,12 +106,13 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
outline-offset: -1px;
}
/* 层级竖线:悬停一个块时,它子层的竖线换成它的种类色,标出这个块管到哪里为止 */
/* 层级竖线:悬停一个块时,它子层的竖线换成它的种类色,标出这个块管到哪里为止(块和子层中间隔着问答区也算) */
.cr-guide {
background: var(--vscode-editorIndentGuide-background1);
transition: background-color 100ms ease-out;
}
.cr-block:hover + .cr-level > * > .cr-guide {
.cr-block:hover + .cr-level > * > .cr-guide,
.cr-block:hover + .cr-qa + .cr-level > * > .cr-guide {
background: var(--kind);
}
@@ -485,3 +486,219 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
color: var(--vscode-descriptionForeground);
background: color-mix(in srgb, var(--vscode-focusBorder) 15%, transparent);
}
/* 问 AI:块下面的问答区,左边一条链接色竖线;问过的灰字,当前这一轮照常颜色 */
.cr-qa {
--qa-accent: var(--vscode-textLink-foreground);
--qa-dim: color-mix(in srgb, var(--vscode-descriptionForeground) 75%, transparent);
display: flex;
flex-direction: column;
gap: 8px;
padding: 6px 10px 6px 12px;
border-left: 2px solid color-mix(in srgb, var(--qa-accent) 50%, transparent);
font-family: var(--vscode-font-family, system-ui, sans-serif), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
font-size: 12.5px;
line-height: 18px;
}
.cr-qa[data-collapsed] {
gap: 6px;
padding-top: 2px;
padding-bottom: 2px;
}
.cr-qa-entry {
display: flex;
flex-direction: column;
gap: 2px;
}
.cr-qa-head {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.cr-qa-icon {
flex-shrink: 0;
color: var(--qa-dim);
}
.cr-qa-entry[data-current] .cr-qa-icon {
color: var(--qa-accent);
}
.cr-qa-question {
min-width: 0;
flex: 1;
text-align: left;
}
.cr-qa-entry[data-history] .cr-qa-question {
overflow: hidden;
color: var(--vscode-descriptionForeground);
text-overflow: ellipsis;
white-space: nowrap;
}
.cr-qa-entry[data-current] .cr-qa-question {
color: var(--vscode-foreground);
font-weight: 500;
}
.cr-qa-when {
flex-shrink: 0;
color: var(--qa-dim);
font-size: 11px;
line-height: 16px;
}
.cr-qa-delete {
display: flex;
width: 22px;
height: 18px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 4px;
color: var(--qa-dim);
}
.cr-qa-delete:hover {
color: var(--vscode-foreground);
}
/* 回答的前两行,和回答正文一样往右缩到问题下面 */
.cr-qa-preview {
display: -webkit-box;
overflow: hidden;
padding-left: 18px;
color: var(--qa-dim);
font-size: 12px;
line-height: 18px;
text-align: left;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.cr-qa-answer {
padding-left: 18px;
font-size: 12.5px;
}
.cr-qa .cr-markdown.cr-qa-answer {
color: var(--vscode-foreground);
}
.cr-qa .cr-qa-entry[data-history] .cr-markdown.cr-qa-answer {
color: var(--vscode-descriptionForeground);
}
/* 回答还在长:最后一段末尾一个闪的光标 */
.cr-qa-answer[data-streaming] > :last-child::after {
content: "▍";
margin-left: 1px;
color: var(--qa-accent);
animation: cr-qa-blink 1s steps(1) infinite;
}
@keyframes cr-qa-blink {
50% { opacity: 0; }
}
.cr-qa-status {
display: flex;
align-items: center;
gap: 6px;
padding-left: 18px;
color: var(--vscode-descriptionForeground);
font-size: 11.5px;
}
.cr-qa-spin {
animation: cr-qa-spin 900ms linear infinite;
}
@keyframes cr-qa-spin {
to { transform: rotate(360deg); }
}
.cr-qa-error {
display: flex;
gap: 6px;
margin-left: 18px;
padding: 4px 8px;
border: 1px solid var(--vscode-inputValidation-errorBorder);
border-radius: 4px;
background: var(--vscode-inputValidation-errorBackground);
color: var(--vscode-foreground);
font-size: 12px;
}
.cr-qa-error > svg {
color: var(--vscode-errorForeground);
}
.cr-qa-divider {
border: 0;
border-top: 1px solid color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
}
.cr-qa-input {
display: flex;
align-items: flex-end;
gap: 6px;
padding: 4px 4px 4px 8px;
border: 1px solid var(--vscode-input-border, color-mix(in srgb, var(--vscode-foreground) 20%, transparent));
border-radius: 4px;
background: var(--vscode-input-background);
}
.cr-qa-input:focus-within {
border-color: var(--vscode-focusBorder);
}
.cr-qa-input textarea {
min-width: 0;
max-height: 160px;
flex: 1;
padding: 1px 0;
border: 0;
outline: none;
background: transparent;
color: var(--vscode-input-foreground);
font: inherit;
resize: none;
field-sizing: content;
}
.cr-qa-input textarea::placeholder {
color: var(--vscode-input-placeholderForeground);
}
.cr-qa-input .cr-tool {
display: flex;
width: 24px;
height: 20px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.cr-qa-input .cr-tool:disabled {
opacity: 0.4;
}
.cr-qa-summary {
display: inline-flex;
width: fit-content;
align-items: center;
gap: 4px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.cr-qa-summary:hover {
color: var(--vscode-foreground);
}
.cr-qa-summary:focus-visible,
.cr-qa-question:focus-visible,
.cr-qa-preview:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
border-radius: 2px;
}
/* 预览顶上:找不到原来位置的问答 */
.cr-qa-orphans {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
padding: 6px 10px;
border: 1px dashed color-mix(in srgb, var(--vscode-descriptionForeground) 40%, transparent);
border-radius: 6px;
}
.cr-qa-orphans .cr-qa {
--qa-accent: var(--vscode-descriptionForeground);
}
.cr-qa-where {
color: var(--vscode-descriptionForeground);
font-size: 11.5px;
}
@media (prefers-reduced-motion: reduce) {
.cr-qa-spin,
.cr-qa-answer[data-streaming] > :last-child::after {
animation: none;
}
}
+6 -1
View File
@@ -1,7 +1,7 @@
import MarkdownIt from 'markdown-it'
/**
* 注释、函数和类说明的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
* 注释、函数和类说明、问 AI 的回答的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
* 源码里的 HTML 当文字显示;链接只认 http / https / mailto,别的写法照原文显示——
* webview 会拦下 <a> 的点击交给 VS Code 在外部打开。图片不渲染:webview 的 CSP 不放行外部图片。
*/
@@ -12,6 +12,11 @@ md.validateLink = url => /^(?:https?|mailto):/i.test(url)
const CACHE_SIZE = 500
const cache = new Map<string, string>()
/** 还在变的文字(逐字长出来的回答)→ HTML:每次都现渲染,不进缓存,免得半截的文字把缓存挤满。 */
export function renderMarkdownOnce(text: string): string {
return md.render(text)
}
/** Markdown 原文 → HTML;同一段文字只渲染一次。 */
export function renderMarkdown(text: string): string {
const html = cache.get(text) ?? md.render(text)
+120
View File
@@ -0,0 +1,120 @@
import type { QaAction, QaState } from './qa'
import type { BlockView, QaMessage, QaThreadView } from './types'
import { describe, expect, it } from 'vitest'
import { answerPreview, asksOf, EMPTY_QA, historyOf, orphanThreads, qaReducer, relativeTime, resumeFor, whereLabel } from './qa'
const IF = 'function get_user > if @3fa1c2d0'
const RETURN = 'function get_user > return @9b1e2c3d'
function thread(anchor: string, ...ids: string[]): QaThreadView {
return { anchor, where: [], entries: ids.map(id => ({ id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-09-25T10:00:00.000Z' })) }
}
function message(threads: QaThreadView[], running: QaMessage['running'] = []): QaMessage {
return { type: 'qa', available: true, threads, running }
}
function qa(threads: QaThreadView[], running: QaMessage['running'] = []): QaAction {
return { type: 'qa', message: message(threads, running) }
}
function block(anchor: string, children: BlockView[] = []): BlockView {
return { id: anchor, anchor, kind: 'statement', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: {}, children, translation: [] }
}
function run(...actions: QaAction[]): QaState {
return actions.reduce(qaReducer, EMPTY_QA)
}
describe('问 AI 的状态', () => {
it('问一次:展开这块,当前这一轮多一条在答的;回答长了跟着变;答完记下存成的那条,历史里不再重复', () => {
let state = run(qa([thread(IF, 'old')]), { type: 'ask', askId: 'a1', anchor: IF, question: '为什么?' })
expect(state.open).toEqual([IF])
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', 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', sessionId: 's1' } })
state = qaReducer(state, qa([thread(IF, 'old', 'new')]))
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new', sessionId: 's1' }])
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
})
it('追问接着这一轮最近答成的会话;这一轮还没答成过就重新开', () => {
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' })
expect(resumeFor(state, IF)).toBeUndefined()
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1', sessionId: 's1' } })
state = qaReducer(state, { type: 'ask', askId: 'a2', anchor: IF, question: '2' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a2', status: 'failed', error: '网络断了' } })
expect(resumeFor(state, IF)).toBe('s1')
expect(resumeFor(state, RETURN)).toBeUndefined()
})
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
let state = run(
qa([thread(IF, 'e1')]),
{ type: 'ask', askId: 'a1', anchor: IF, question: '1' },
{ type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1', sessionId: 's1' } },
{ type: 'ask', askId: 'a2', anchor: IF, question: '2' },
{ type: 'close', anchor: IF },
)
expect(state.open).toEqual([])
expect(asksOf(state, IF).map(ask => ask.askId)).toEqual(['a2'])
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['e1'])
expect(resumeFor(state, IF)).toBeUndefined()
state = qaReducer(state, { type: 'open', anchor: IF })
expect(state.open).toEqual([IF])
})
it('页面重新加载后:扩展端还在答的接上、展开它那块;已知道的不重复', () => {
const running = [{ askId: 'a1', anchor: IF, question: '1', text: '答了一半' }]
let state = run(qa([], running))
expect(state.open).toEqual([IF])
expect(asksOf(state, IF)).toEqual([{ ...running[0], status: 'running' }])
state = qaReducer(state, qa([], running))
expect(state.asks).toHaveLength(1)
})
it('停止、失败照记;不认识的问题的消息不理', () => {
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' }, { 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)
})
})
describe('找不到原来位置的问答', () => {
it('记号对不上任何一层的块才算', () => {
const threads = [thread(IF, 'a'), thread(RETURN, 'b'), thread('function gone', 'c')]
expect(orphanThreads(threads, [block('function get_user', [block(IF)])]).map(item => item.anchor)).toEqual([RETURN, 'function gone'])
})
it('原来在哪:种类取翻译插件起的名字,没名字的块只写种类', () => {
const names: Record<string, string> = { class: '类', function: '函数', if: '条件' }
expect(whereLabel([{ kind: 'class', name: 'ReferralRelation' }, { kind: 'function', name: 'display' }, { kind: 'if' }], kind => names[kind] ?? kind))
.toBe('类 ReferralRelation › 函数 display › 条件')
})
})
describe('问的时间', () => {
const now = new Date(2026, 8, 26, 15, 0).getTime()
const at = (year: number, month: number, day: number, hour: number, minute: number, second = 0): string =>
new Date(year, month, day, hour, minute, second).toISOString()
it('刚刚、几分钟前、几小时前(同一天)、昨天、几天前、一个月以前写日期', () => {
expect(relativeTime(at(2026, 8, 26, 14, 59, 30), now)).toBe('刚刚')
expect(relativeTime(at(2026, 8, 26, 14, 55), now)).toBe('5 分钟前')
expect(relativeTime(at(2026, 8, 26, 9, 30), now)).toBe('5 小时前')
expect(relativeTime(at(2026, 8, 25, 23, 0), now)).toBe('昨天')
expect(relativeTime(at(2026, 8, 23, 10, 0), now)).toBe('3 天前')
expect(relativeTime(at(2026, 7, 1, 10, 0), now)).toBe('2026-08-01')
expect(relativeTime('坏的', now)).toBe('')
})
})
describe('回答的前两行预览', () => {
it('去掉 Markdown 记号连成一段', () => {
expect(answerPreview('## 结论\n\n`Forbidden` 定义在 **core/exceptions.py**,见 [文档](https://x.y)。\n\n- 第一条\n1. 第二条\n\n```py\nraise Forbidden()\n```'))
.toBe('结论 Forbidden 定义在 core/exceptions.py,见 文档。 第一条 第二条 raise Forbidden()')
})
})
+179
View File
@@ -0,0 +1,179 @@
/**
* 「问 AI」在页面上的状态和规则(不碰 DOM):问答按块的记号(BlockView.anchor)挂到块下面;
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史;记号对不上任何块的问答是「找不到原来位置的」。
*/
import type { AnchorStep, AnswerEnd, BlockView, QaEntryView, QaMessage, QaThreadView } from './types'
/** 这次打开以来问的一个问题 */
export interface Ask {
askId: string
anchor: string
question: string
/** 回答到现在为止的全文(Markdown) */
text: string
/** 正在做的事:「读取 routes.py」 */
activity?: string
status: 'running' | 'done' | 'failed' | 'stopped'
/** failed 时给人看的原因 */
error?: string
/** done 时存下的那条:历史里不再重复显示它 */
entryId?: string
/** done 时的会话 id:接着追问用 */
sessionId?: string
}
export interface QaState {
/** 文件不在工作区文件夹里时问答没地方存,不给「问 AI」 */
available: boolean
threads: QaThreadView[]
/** 当前这一轮问的,按先后 */
asks: Ask[]
/** 展开着对话区(历史、当前这一轮、输入框)的块 */
open: string[]
}
export const EMPTY_QA: QaState = { available: false, threads: [], asks: [], open: [] }
export type QaAction
= | { type: 'qa', message: QaMessage }
| { type: 'answer', askId: string, text: string, activity?: string }
| { type: 'answerEnd', end: AnswerEnd }
| { type: 'ask', askId: string, anchor: string, question: string }
| { type: 'open', anchor: string }
| { type: 'close', anchor: string }
function withOpen(open: string[], anchor: string): string[] {
return open.includes(anchor) ? open : [...open, anchor]
}
export function qaReducer(state: QaState, action: QaAction): QaState {
switch (action.type) {
case 'qa': {
// 页面重新加载、或同一文件在别处开着的预览问的:扩展端还在答的接上,展开它那块
const known = new Set(state.asks.map(ask => ask.askId))
const adopted = action.message.running.filter(ask => !known.has(ask.askId))
return {
available: action.message.available,
threads: action.message.threads,
asks: [...state.asks, ...adopted.map((ask): Ask => ({ ...ask, status: 'running' }))],
open: adopted.reduce((open, ask) => withOpen(open, ask.anchor), state.open),
}
}
case 'answer':
return {
...state,
asks: state.asks.map(ask => ask.askId === action.askId && ask.status === 'running'
? { ...ask, text: action.text, activity: action.activity }
: ask),
}
case 'answerEnd': {
const { askId, status, text, error, entryId, sessionId } = action.end
return {
...state,
asks: state.asks.map(ask => ask.askId === askId
? { askId, anchor: ask.anchor, question: ask.question, text: text ?? ask.text, status, error, entryId, sessionId }
: ask),
}
}
case 'ask':
return {
...state,
asks: [...state.asks, { askId: action.askId, anchor: action.anchor, question: action.question, text: '', status: 'running' }],
open: withOpen(state.open, action.anchor),
}
case 'open':
return { ...state, open: withOpen(state.open, action.anchor) }
case 'close':
// 收起就结束这一轮:答完的都回到灰字历史里;还在答的留着,答完照样存
return {
...state,
asks: state.asks.filter(ask => ask.anchor !== action.anchor || ask.status === 'running'),
open: state.open.filter(anchor => anchor !== action.anchor),
}
}
}
/** 当前这一轮在 anchor 那块问的 */
export function asksOf(state: QaState, anchor: string): Ask[] {
return state.asks.filter(ask => ask.anchor === anchor)
}
/** anchor 那块的灰字历史:存下的问答,当前这一轮问的那几条除外 */
export function historyOf(state: QaState, anchor: string): QaEntryView[] {
const current = new Set(asksOf(state, anchor).map(ask => ask.entryId))
return state.threads.find(thread => thread.anchor === anchor)?.entries.filter(entry => !current.has(entry.id)) ?? []
}
/** 追问接着哪个会话:当前这一轮最近答成的那次;这一轮还没答成过就重新开 */
export function resumeFor(state: QaState, anchor: string): string | undefined {
return asksOf(state, anchor).filter(ask => ask.status === 'done' && ask.sessionId).at(-1)?.sessionId
}
function anchorsIn(blocks: readonly BlockView[], into: Set<string>): Set<string> {
for (const block of blocks) {
into.add(block.anchor)
anchorsIn(block.children, into)
}
return into
}
/** 记号对不上任何块的问答(代码改动后找不到原来的位置了),按文件里的先后 */
export function orphanThreads(threads: readonly QaThreadView[], blocks: readonly BlockView[]): QaThreadView[] {
const anchors = anchorsIn(blocks, new Set())
return threads.filter(thread => !anchors.has(thread.anchor))
}
/** 问答原来挂在哪:各层写成「种类 名字」,用 › 连起来,例如「类 ReferralRelation › 函数 display › 条件」 */
export function whereLabel(where: readonly AnchorStep[], kindName: (kind: string) => string): string {
return where.map(step => step.name === undefined ? kindName(step.kind) : `${kindName(step.kind)} ${step.name}`).join(' › ')
}
const MINUTE = 60_000
const DAY = 24 * 60 * MINUTE
function startOfDay(time: number): number {
const day = new Date(time)
day.setHours(0, 0, 0, 0)
return day.getTime()
}
/** 问的时间写成「刚刚」「5 分钟前」「3 小时前」「昨天」「3 天前」,一个月以前的写日期 */
export function relativeTime(iso: string, now: number): string {
const time = Date.parse(iso)
if (Number.isNaN(time))
return ''
const elapsed = now - time
if (elapsed < MINUTE)
return '刚刚'
if (elapsed < 60 * MINUTE)
return `${Math.floor(elapsed / MINUTE)} 分钟前`
const days = Math.round((startOfDay(now) - startOfDay(time)) / DAY)
if (days <= 0)
return `${Math.floor(elapsed / (60 * MINUTE))} 小时前`
if (days === 1)
return '昨天'
if (days < 30)
return `${days} 天前`
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
/** 回答的前两行预览:Markdown 去掉记号(代码块、反引号、粗体、标题、列表、链接)连成一段纯文字,截两行交给样式 */
export function answerPreview(markdown: string): string {
return markdown
.replace(/^```.*$/gm, '')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/gm, '')
.replace(/(\*\*|__|~~|`)/g, '')
.replace(/\s+/g, ' ')
.trim()
}
let askCounter = 0
/** 这一页里不重样的问题 id */
export function newAskId(): string {
askCounter += 1
return `${Date.now().toString(36)}-${askCounter}`
}
+69
View File
@@ -78,6 +78,8 @@ export interface KindBadge { label: string, color: ThemeColor }
interface BlockViewBase {
/** 在树里的位置,例如 '6.1.0' */
id: string
/** 块的位置记号:问答挂在它上面(QaThreadView.anchor),文件别处改了照样对得上;webview 只拿它比相等,不拆开看 */
anchor: string
/** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table);webview 不认识后者,只按 badge 画 */
kind: string
/** 编辑范围的起止行,0 起:一般是 header,库规则认领的块是原块整条语句(盖住它留下的子块,见 coveredByEditing) */
@@ -154,11 +156,69 @@ export interface DocumentMessage {
reveal?: Reveal
}
/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间) */
export interface QaEntryView {
id: string
question: string
answer: string
createdAt: string
}
/** 块的位置记号里的一层:种类(同 BlockViewBase.kind)和名字,没名字的块只有种类 */
export interface AnchorStep {
kind: string
name?: string
}
/** 挂在一块上的问答;where 是它原来在哪(各层的种类和名字),块找不到了时显示 */
export interface QaThreadView {
anchor: string
where: AnchorStep[]
entries: QaEntryView[]
}
/** 扩展端正在答的一个问题:text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」) */
export interface RunningAsk {
askId: string
anchor: string
question: string
text: string
activity?: string
}
/**
* 这份文件的问答:available 为假时文件不在工作区文件夹里、问答没地方存,不给「问 AI」;
* running 是扩展端正在答的问题(页面重新加载后靠它接上)
*/
export interface QaMessage {
type: 'qa'
available: boolean
threads: QaThreadView[]
running: RunningAsk[]
}
/**
* 一个问题怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)、存下的那条的 id 和会话 id(追问接着这个会话),
* failed 带给人看的原因,stopped 是被叫停、没存
*/
export interface AnswerEnd {
type: 'answerEnd'
askId: string
status: 'done' | 'failed' | 'stopped'
text?: string
error?: string
entryId?: string
sessionId?: string
}
/**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
* filter:整个工作区共用的块筛选;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
* qa:这份文件的问答;ready 后来一次,问答、正在答的问题变了再来
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了,见 AnswerEnd
*/
export type ExtensionToWebview
= | DocumentMessage
@@ -166,6 +226,9 @@ export type ExtensionToWebview
| { type: 'definitionProbed', id: number, found: boolean }
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'filter', excluded: Excluded }
| QaMessage
| { type: 'answer', askId: string, text: string, activity?: string }
| AnswerEnd
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
@@ -175,6 +238,9 @@ export type ExtensionToWebview
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;resume 是追问时上一次的会话 id
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
*/
export type WebviewToExtension
= | { type: 'ready' }
@@ -185,3 +251,6 @@ 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, resume?: string }
| { type: 'stopAsk', askId: string }
| { type: 'deleteQa', anchor: string, entryId: string }
+61
View File
@@ -0,0 +1,61 @@
import type { QaTree } from './BlockTree'
import type { QaState } from './qa'
import { useEffect, useMemo, useReducer, useRef } from 'react'
import { EMPTY_QA, newAskId, qaReducer, resumeFor } from './qa'
import { onMessage, postMessage } from './vscode'
/** 逐字长出来的回答最多隔多久画一次:一个字一画整棵块树跟着重画,大文件会卡 */
const ANSWER_FRAME_MS = 80
/** 问 AI:收扩展端的问答、正在答的进展,给块树用的状态和动作(问、停、删、展开 / 收起)。 */
export function useQa(): QaTree {
const [state, dispatch] = useReducer(qaReducer, EMPTY_QA)
/** 动作里读眼下的状态(追问接着哪个会话) */
const current = useRef<QaState>(state)
current.current = state
useEffect(() => {
/** 攒着没画的回答:askId → 最新的一份 */
const pending = new Map<string, { text: string, activity?: string }>()
let timer: ReturnType<typeof setTimeout> | undefined
const flush = (): void => {
timer = undefined
for (const [askId, answer] of pending)
dispatch({ type: 'answer', askId, ...answer })
pending.clear()
}
const off = onMessage((msg) => {
switch (msg.type) {
case 'qa':
dispatch({ type: 'qa', message: msg })
return
case 'answer':
pending.set(msg.askId, { text: msg.text, activity: msg.activity })
timer ??= setTimeout(flush, ANSWER_FRAME_MS)
return
case 'answerEnd':
clearTimeout(timer)
flush()
dispatch({ type: 'answerEnd', end: msg })
}
})
return () => {
clearTimeout(timer)
off()
}
}, [])
return useMemo((): QaTree => ({
state,
onToggle: anchor => dispatch({ type: current.current.open.includes(anchor) ? 'close' : 'open', anchor }),
onClose: anchor => dispatch({ type: 'close', anchor }),
onAsk: (anchor, question) => {
const askId = newAskId()
const resume = resumeFor(current.current, anchor)
dispatch({ type: 'ask', askId, anchor, question })
postMessage({ type: 'ask', askId, anchor, question, ...(resume && { resume }) })
},
onStop: askId => postMessage({ type: 'stopAsk', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
}), [state])
}