✨ 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

+120
View File
@@ -0,0 +1,120 @@
import type { QaAction, QaState } from './qa'
import type { BlockView, QaMessage, QaThreadView } from './types'
import { describe, expect, it } from 'vitest'
import { answerPreview, asksOf, EMPTY_QA, historyOf, orphanThreads, qaReducer, relativeTime, resumeFor, whereLabel } from './qa'
const IF = 'function get_user > if @3fa1c2d0'
const RETURN = 'function get_user > return @9b1e2c3d'
function thread(anchor: string, ...ids: string[]): QaThreadView {
return { anchor, where: [], entries: ids.map(id => ({ id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-09-25T10:00:00.000Z' })) }
}
function message(threads: QaThreadView[], running: QaMessage['running'] = []): QaMessage {
return { type: 'qa', available: true, threads, running }
}
function qa(threads: QaThreadView[], running: QaMessage['running'] = []): QaAction {
return { type: 'qa', message: message(threads, running) }
}
function block(anchor: string, children: BlockView[] = []): BlockView {
return { id: anchor, anchor, kind: 'statement', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: {}, children, translation: [] }
}
function run(...actions: QaAction[]): QaState {
return actions.reduce(qaReducer, EMPTY_QA)
}
describe('问 AI 的状态', () => {
it('问一次:展开这块,当前这一轮多一条在答的;回答长了跟着变;答完记下存成的那条,历史里不再重复', () => {
let state = run(qa([thread(IF, 'old')]), { type: 'ask', askId: 'a1', anchor: IF, question: '为什么?' })
expect(state.open).toEqual([IF])
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '', status: 'running' }])
state = qaReducer(state, { type: 'answer', askId: 'a1', text: '因为', activity: '读取 a.py' })
expect(asksOf(state, IF)[0]).toMatchObject({ text: '因为', activity: '读取 a.py' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new', sessionId: 's1' } })
state = qaReducer(state, qa([thread(IF, 'old', 'new')]))
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new', sessionId: 's1' }])
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
})
it('追问接着这一轮最近答成的会话;这一轮还没答成过就重新开', () => {
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' })
expect(resumeFor(state, IF)).toBeUndefined()
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1', sessionId: 's1' } })
state = qaReducer(state, { type: 'ask', askId: 'a2', anchor: IF, question: '2' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a2', status: 'failed', error: '网络断了' } })
expect(resumeFor(state, IF)).toBe('s1')
expect(resumeFor(state, RETURN)).toBeUndefined()
})
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
let state = run(
qa([thread(IF, 'e1')]),
{ type: 'ask', askId: 'a1', anchor: IF, question: '1' },
{ type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1', sessionId: 's1' } },
{ type: 'ask', askId: 'a2', anchor: IF, question: '2' },
{ type: 'close', anchor: IF },
)
expect(state.open).toEqual([])
expect(asksOf(state, IF).map(ask => ask.askId)).toEqual(['a2'])
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['e1'])
expect(resumeFor(state, IF)).toBeUndefined()
state = qaReducer(state, { type: 'open', anchor: IF })
expect(state.open).toEqual([IF])
})
it('页面重新加载后:扩展端还在答的接上、展开它那块;已知道的不重复', () => {
const running = [{ askId: 'a1', anchor: IF, question: '1', text: '答了一半' }]
let state = run(qa([], running))
expect(state.open).toEqual([IF])
expect(asksOf(state, IF)).toEqual([{ ...running[0], status: 'running' }])
state = qaReducer(state, qa([], running))
expect(state.asks).toHaveLength(1)
})
it('停止、失败照记;不认识的问题的消息不理', () => {
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' }, { type: 'answer', askId: 'a1', text: '一半' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'stopped' } })
expect(asksOf(state, IF)[0]).toMatchObject({ status: 'stopped', text: '一半' })
expect(qaReducer(state, { type: 'answer', askId: 'a1', text: '晚到的' })).toEqual(state)
expect(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'nope', status: 'done' } })).toEqual(state)
})
})
describe('找不到原来位置的问答', () => {
it('记号对不上任何一层的块才算', () => {
const threads = [thread(IF, 'a'), thread(RETURN, 'b'), thread('function gone', 'c')]
expect(orphanThreads(threads, [block('function get_user', [block(IF)])]).map(item => item.anchor)).toEqual([RETURN, 'function gone'])
})
it('原来在哪:种类取翻译插件起的名字,没名字的块只写种类', () => {
const names: Record<string, string> = { class: '类', function: '函数', if: '条件' }
expect(whereLabel([{ kind: 'class', name: 'ReferralRelation' }, { kind: 'function', name: 'display' }, { kind: 'if' }], kind => names[kind] ?? kind))
.toBe('类 ReferralRelation › 函数 display › 条件')
})
})
describe('问的时间', () => {
const now = new Date(2026, 8, 26, 15, 0).getTime()
const at = (year: number, month: number, day: number, hour: number, minute: number, second = 0): string =>
new Date(year, month, day, hour, minute, second).toISOString()
it('刚刚、几分钟前、几小时前(同一天)、昨天、几天前、一个月以前写日期', () => {
expect(relativeTime(at(2026, 8, 26, 14, 59, 30), now)).toBe('刚刚')
expect(relativeTime(at(2026, 8, 26, 14, 55), now)).toBe('5 分钟前')
expect(relativeTime(at(2026, 8, 26, 9, 30), now)).toBe('5 小时前')
expect(relativeTime(at(2026, 8, 25, 23, 0), now)).toBe('昨天')
expect(relativeTime(at(2026, 8, 23, 10, 0), now)).toBe('3 天前')
expect(relativeTime(at(2026, 7, 1, 10, 0), now)).toBe('2026-08-01')
expect(relativeTime('坏的', now)).toBe('')
})
})
describe('回答的前两行预览', () => {
it('去掉 Markdown 记号连成一段', () => {
expect(answerPreview('## 结论\n\n`Forbidden` 定义在 **core/exceptions.py**,见 [文档](https://x.y)。\n\n- 第一条\n1. 第二条\n\n```py\nraise Forbidden()\n```'))
.toBe('结论 Forbidden 定义在 core/exceptions.py,见 文档。 第一条 第二条 raise Forbidden()')
})
})