feat: 階段 5 wiki 知識庫(讀寫、10 分鐘快取、動態註冊指令)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+116
-1
@@ -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} 離開。` }
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user