feat: 階段 5 wiki 知識庫(讀寫、10 分鐘快取、動態註冊指令)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 06:15:03 +00:00
co-authored by Claude Sonnet 5.5
parent c890059a50
commit 0c525d07b2
5 changed files with 379 additions and 3 deletions
+116 -1
View File
@@ -4,9 +4,14 @@ import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './cha
import type { Axes } from './characters'
import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
import type { Segment } from './statusline'
import { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog, parseWriteArgs, renderHome, renderPage, renderSidebar, teaToken, wikiUrlFromRemote } from './wiki'
import type { Entry } from './wiki'
const ACTIVE_KEY = 'active'
const OFF_COMMAND = 'anima-off'
const WIKI_COMMAND = 'anima-wiki'
const SYNCED_KEY = 'wikiSyncedAt'
const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
const AXIS_OPTION: Record<keyof Axes, string> = {
gentle: 'axisGentle',
@@ -54,6 +59,106 @@ async function readSys($: Engine, holder: { sys?: Sys }, refreshMs: number): Pro
return holder.sys
}
type WikiCfg = { url: string; cacheDir: string }
type Wiki = { dir: string; url: string; auth: string[] }
async function wikiCtx($: Engine, cfg: WikiCfg): Promise<Wiki | undefined> {
let url = cfg.url
if (!url) {
const cwd = await $.session.cwd().catch(() => '')
const r = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd: cwd || undefined, timeoutMs: 3000 }).catch(() => undefined)
url = (r && r.exitCode === 0 && wikiUrlFromRemote(String(r.stdout))) || ''
}
if (!url) return undefined
const home = (await $.env.get('HOME')) ?? ''
const dir = cfg.cacheDir || `${home}/.cache/jsc-anima/wiki`
// 認證:優先 tea 登入,其次 GITEA_TOKEN;token 只放在指令參數,不寫入任何檔案。
let token: string | undefined
try {
token = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), new URL(url).host)
} catch {}
token ??= (await $.env.get('GITEA_TOKEN')) || undefined
return { dir, url, auth: token ? ['-c', `http.extraHeader=Authorization: token ${token}`] : [] }
}
async function git($: Engine, w: Wiki, args: string[], inDir = true) {
const r = await $.process.run(['git', ...w.auth, ...(inDir ? ['-C', w.dir] : []), ...args], { timeoutMs: 60000 }).catch(() => undefined)
return r && r.exitCode === 0 ? r : undefined
}
// 本機只做 10 分鐘快取;離線時沿用已有的快取。
async function sync($: Engine, w: Wiki, force: boolean): Promise<'fresh' | 'cached' | 'none'> {
const cloned = await $.fs.exists(`${w.dir}/.git`).catch(() => false)
const now = await $.clock.now()
const last = Number((await $.store.get(SYNCED_KEY)) ?? 0)
if (cloned && !force && now - last < CACHE_MS) return 'fresh'
const ok = cloned ? await git($, w, ['pull', '--ff-only']) : await git($, w, ['clone', w.url, w.dir], false)
if (ok) {
await $.store.set(SYNCED_KEY, now)
return 'fresh'
}
return cloned ? 'cached' : 'none'
}
async function readCatalog($: Engine, w: Wiki): Promise<Entry[]> {
return parseCatalog(await $.fs.read(`${w.dir}/Home.md`).catch(() => ''))
}
async function registerEntry($: Engine, known: Set<string>, e: Entry) {
const name = commandName(e)
if (known.has(name)) return
known.add(name)
await $.command.register({ name, description: `讀取知識庫:${e.domain}/${e.topic}` })
}
// 工具三的指令只在啟動角色後註冊,依 wiki 目錄動態註冊。
async function registerWikiTools($: Engine, cfg: WikiCfg, known: Set<string>) {
if (!known.has(WIKI_COMMAND)) {
known.add(WIKI_COMMAND)
await $.command.register({ name: WIKI_COMMAND, description: '整理資料寫入 wiki 知識庫', argumentHint: '<領域> <主題> [出處](下一行起為內容)' })
}
try {
const w = await wikiCtx($, cfg)
if (!w) return
await sync($, w, false)
for (const e of await readCatalog($, w)) await registerEntry($, known, e)
} catch {}
}
async function writeWiki($: Engine, cfg: WikiCfg, known: Set<string>, args: string): Promise<string> {
const parsed = parseWriteArgs(args)
if (!parsed) return '用法:/jsc-anima:anima-wiki <領域> <主題> [出處],下一行起寫內容。領域與主題只能用英數與底線(會成為指令名稱)。'
const w = await wikiCtx($, cfg)
if (!w) return '找不到 wiki 位址:請在 /config 設定 wikiUrl,或在 Gitea 專案目錄中使用。'
if ((await sync($, w, true)) === 'none') return '無法連線到 wiki,且本機沒有快取,未寫入。'
const { entry, source, body } = parsed
const file = `${w.dir}/${pageName(entry)}.md`
const existed = await $.fs.exists(file).catch(() => false)
const catalog = addEntry(await readCatalog($, w), entry)
await $.fs.write(file, renderPage(entry, body, source))
await $.fs.write(`${w.dir}/Home.md`, renderHome(catalog))
await $.fs.write(`${w.dir}/_Sidebar.md`, renderSidebar(catalog))
if (!(await git($, w, ['add', '-A'])) || !(await git($, w, ['commit', '-m', commitMessage(entry, existed)]))) {
return '寫入失敗:無法建立 commit。'
}
if (!(await git($, w, ['push']))) {
await git($, w, ['reset', '--hard', 'HEAD~1'])
return '推送 wiki 失敗,已還原本機變更,請稍後再試。'
}
await $.store.set(SYNCED_KEY, await $.clock.now())
await registerEntry($, known, entry)
return `已${existed ? '更新' : '新增'}知識「${entry.domain}/${entry.topic}」,指令:/jsc-anima:${commandName(entry)}`
}
async function readWiki($: Engine, cfg: WikiCfg, name: string): Promise<string | undefined> {
const w = await wikiCtx($, cfg)
if (!w) return undefined
await sync($, w, false)
const e = (await readCatalog($, w)).find(x => commandName(x) === name)
if (!e) return undefined
return await $.fs.read(`${w.dir}/${pageName(e)}.md`).catch(() => undefined)
}
export const register: Register = (on, options) => {
const opt = options as Record<string, unknown>
const flag = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d)
@@ -63,6 +168,8 @@ export const register: Register = (on, options) => {
const isEnabled = flag('enabled', true)
let active: string | undefined
const holder: { sys?: Sys } = {}
const wikiCommands = new Set<string>()
const wikiCfg = (): WikiCfg => ({ url: str('wikiUrl', ''), cacheDir: str('wikiCacheDir', '') })
const overrides = (): Partial<Axes> => {
const out: Partial<Axes> = {}
@@ -81,6 +188,7 @@ export const register: Register = (on, options) => {
await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
const saved = await $.store.get(ACTIVE_KEY)
active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
if (active) await registerWikiTools($, wikiCfg(), wikiCommands)
}
return next(e)
})
@@ -93,9 +201,16 @@ export const register: Register = (on, options) => {
return { text: '已離開角色,回到一般模式。' }
}
const c = findCharacter(e.command)
if (!c) return next(e)
if (!c) {
if (!wikiCommands.has(e.command)) return next(e)
if (!findCharacter(active)) return { text: NEED_ACTIVE }
if (e.command === WIKI_COMMAND) return { text: await writeWiki($, wikiCfg(), wikiCommands, String(e.args ?? '')) }
const page = await readWiki($, wikiCfg(), e.command)
return { text: page ?? `找不到知識「${e.command}」。` }
}
active = c.slug
await $.store.set(ACTIVE_KEY, c.slug)
await registerWikiTools($, wikiCfg(), wikiCommands)
return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
})