✨ feat(vscode): 代码阅读加「问 AI」,块下面问 Claude Code,问答存进 .spx 挂在块下
This commit is contained in:
1 parent
badc722640
commit
fb53bb727c
34 files changed
+2726
-43
No files matched your search
@@ -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('文件里出现过的属性', () => {
|
||||
|
||||
@@ -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), ''))
|
||||
}
|
||||
|
||||
/** 解析一份源码并转成视图数据;语法树用完即释放。 */
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 调用已停止')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 从哪来?')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user