✨ 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

+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
}