98 lines
4.1 KiB
TypeScript
98 lines
4.1 KiB
TypeScript
/**
|
||
* 代码问答的存法:每个工作区文件夹一份 `<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
|
||
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
|
||
profile?: 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, profile] = [text(value.id), text(value.question), text(value.answer), text(value.createdAt), text(value.profile)]
|
||
if (!id || !question || !answer || !createdAt || Number.isNaN(Date.parse(createdAt)))
|
||
return undefined
|
||
return { id, question, answer, createdAt, sessionId: typeof value.sessionId === 'string' ? value.sessionId : '', ...(profile && { profile }) }
|
||
}
|
||
|
||
/** 同一块的并成一项;块按记号排,问答按时间排:写出来的顺序稳定,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
|
||
}
|
||
|
||
/** 再问 anchor 那块时接着的会话:这块最近一次问答的会话 id;没问过、或那次没记会话 id 时没有,重新开会话。 */
|
||
export function latestSession(threads: readonly QaThread[] | undefined, anchor: string): string | undefined {
|
||
return threads?.find(thread => thread.anchor === anchor)?.entries.at(-1)?.sessionId || undefined
|
||
}
|