✨ 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

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