feat: 階段 5 工具三 wiki 知識庫 #10
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-anima",
|
"name": "jsc-anima",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
|
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"userConfig": {
|
"userConfig": {
|
||||||
@@ -187,6 +187,18 @@
|
|||||||
"title": "情緒外顯 (0-10,-1 沿用角色包)",
|
"title": "情緒外顯 (0-10,-1 沿用角色包)",
|
||||||
"description": "覆蓋目前角色的數值軸,只存本機",
|
"description": "覆蓋目前角色的數值軸,只存本機",
|
||||||
"default": -1
|
"default": -1
|
||||||
|
},
|
||||||
|
"wikiUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "wiki 位址",
|
||||||
|
"description": "知識庫的 Gitea wiki git 位址;留空則由目前專案的 origin 推得(認證優先讀 tea 登入,其次 GITEA_TOKEN)",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"wikiCacheDir": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "wiki 本機快取目錄",
|
||||||
|
"description": "10 分鐘 git 快取的位置;留空用 ~/.cache/jsc-anima/wiki",
|
||||||
|
"default": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -6,6 +6,15 @@
|
|||||||
|
|
||||||
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
|
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
|
||||||
|
|
||||||
|
## 知識庫(wiki)
|
||||||
|
|
||||||
|
啟動角色後可用:
|
||||||
|
|
||||||
|
- `/jsc-anima:anima-wiki <領域> <主題> [出處]`(下一行起為內容):寫入 Gitea wiki,每次一個中文 commit,並更新 `Home` 目錄與 `_Sidebar`。領域與主題只能用英數與底線。
|
||||||
|
- `/jsc-anima:<領域>-<主題>`:讀取知識,指令在啟動時依 wiki 目錄註冊。
|
||||||
|
|
||||||
|
讀寫都在 wiki,本機只做 10 分鐘快取,離線時沿用快取。認證優先讀 tea 登入,其次 `GITEA_TOKEN`,token 不寫入任何檔案。
|
||||||
|
|
||||||
## 安裝
|
## 安裝
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+115
-1
@@ -11,6 +11,8 @@ import type { Effort, ModelName, Route } from './router'
|
|||||||
import { REWRITE_SYSTEM, needsSonnet, protect, restore, withPrefix } from './rewrite'
|
import { REWRITE_SYSTEM, needsSonnet, protect, restore, withPrefix } from './rewrite'
|
||||||
import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
|
import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
|
||||||
import type { Segment } 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'
|
||||||
import { NEED_ACTIVE, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool'
|
import { NEED_ACTIVE, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool'
|
||||||
|
|
||||||
const ACTIVE_KEY = 'active'
|
const ACTIVE_KEY = 'active'
|
||||||
@@ -18,6 +20,8 @@ const OFF_COMMAND = 'anima-off'
|
|||||||
const AFFINITY_COMMAND = 'anima-affinity'
|
const AFFINITY_COMMAND = 'anima-affinity'
|
||||||
const RAW_LOG_KEY = 'rawlog'
|
const RAW_LOG_KEY = 'rawlog'
|
||||||
const RAW_LOG_MAX = 200
|
const RAW_LOG_MAX = 200
|
||||||
|
const WIKI_COMMAND = 'anima-wiki'
|
||||||
|
const SYNCED_KEY = 'wikiSyncedAt'
|
||||||
|
|
||||||
const AXIS_OPTION: Record<keyof Axes, string> = {
|
const AXIS_OPTION: Record<keyof Axes, string> = {
|
||||||
gentle: 'axisGentle',
|
gentle: 'axisGentle',
|
||||||
@@ -65,6 +69,106 @@ async function readSys($: Engine, holder: { sys?: Sys }, refreshMs: number): Pro
|
|||||||
return holder.sys
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
const affinityKey = (slug: string): string => `affinity:${slug}`
|
const affinityKey = (slug: string): string => `affinity:${slug}`
|
||||||
async function loadAffinity($: Engine, slug: string): Promise<Affinity> {
|
async function loadAffinity($: Engine, slug: string): Promise<Affinity> {
|
||||||
const v = (await $.store.get(affinityKey(slug))) as Affinity | undefined
|
const v = (await $.store.get(affinityKey(slug))) as Affinity | undefined
|
||||||
@@ -90,6 +194,8 @@ export const register: Register = (on, options) => {
|
|||||||
let route: Route | undefined
|
let route: Route | undefined
|
||||||
let prefixed = false
|
let prefixed = false
|
||||||
const holder: { sys?: Sys } = {}
|
const holder: { sys?: Sys } = {}
|
||||||
|
const wikiCommands = new Set<string>()
|
||||||
|
const wikiCfg = (): WikiCfg => ({ url: str('wikiUrl', ''), cacheDir: str('wikiCacheDir', '') })
|
||||||
|
|
||||||
const routingOn = () => flag('routing', true)
|
const routingOn = () => flag('routing', true)
|
||||||
const candidates = (): ModelName[] =>
|
const candidates = (): ModelName[] =>
|
||||||
@@ -122,6 +228,7 @@ export const register: Register = (on, options) => {
|
|||||||
toolRegistered = true
|
toolRegistered = true
|
||||||
await registerTools($)
|
await registerTools($)
|
||||||
}
|
}
|
||||||
|
if (active) await registerWikiTools($, wikiCfg(), wikiCommands)
|
||||||
}
|
}
|
||||||
return next(e)
|
return next(e)
|
||||||
})
|
})
|
||||||
@@ -152,7 +259,13 @@ export const register: Register = (on, options) => {
|
|||||||
return { text: `好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
|
return { text: `好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
|
||||||
}
|
}
|
||||||
const c = findCharacter(e.command)
|
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
|
active = c.slug
|
||||||
route = undefined
|
route = undefined
|
||||||
await $.store.set(ACTIVE_KEY, c.slug)
|
await $.store.set(ACTIVE_KEY, c.slug)
|
||||||
@@ -161,6 +274,7 @@ export const register: Register = (on, options) => {
|
|||||||
toolRegistered = true
|
toolRegistered = true
|
||||||
await registerTools($)
|
await registerTools($)
|
||||||
}
|
}
|
||||||
|
await registerWikiTools($, wikiCfg(), wikiCommands)
|
||||||
return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
|
return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { expect, mock, test } from 'claude-code/testing'
|
||||||
|
|
||||||
|
const OPTS = { options: { wikiCacheDir: '/cache/wiki' } }
|
||||||
|
|
||||||
|
// 記憶體內的假 wiki:以 process.run/fs.* 事件邊界替代 git 與檔案系統。
|
||||||
|
const setup = (on: any, opts: { registered?: string[]; online?: boolean; seed?: Record<string, string>; pushOk?: boolean } = {}) => {
|
||||||
|
const files = new Map<string, string>(Object.entries(opts.seed ?? {}))
|
||||||
|
const calls: string[][] = []
|
||||||
|
const state = { online: opts.online ?? true, pushOk: opts.pushOk ?? true, commits: [] as string[] }
|
||||||
|
const D = '/cache/wiki'
|
||||||
|
mock.store(on)
|
||||||
|
mock.env(on, { HOME: '/home/u', GITEA_TOKEN: 'secret-token' })
|
||||||
|
const clock = mock.clock(on, { now: 1_000_000 })
|
||||||
|
on('command.register', (_$: any, e: any) => {
|
||||||
|
opts.registered?.push(e.name)
|
||||||
|
return { value: { name: e.name } } as never
|
||||||
|
})
|
||||||
|
on('session.start', () => ({ cwd: '/proj' }) as never)
|
||||||
|
on('command.run', () => ({ text: '' }))
|
||||||
|
on('fs.exists', (_$: any, e: any) => ({ value: files.has(e.path.replace(`${D}/`, '')) || e.path === `${D}/.git` && files.has('.git') }) as never)
|
||||||
|
on('fs.read', (_$: any, e: any) => {
|
||||||
|
const k = e.path.replace(`${D}/`, '')
|
||||||
|
if (!files.has(k)) return { deny: 'ENOENT' } as never
|
||||||
|
return { value: files.get(k)! } as never
|
||||||
|
})
|
||||||
|
on('fs.write', (_$: any, e: any) => {
|
||||||
|
files.set(e.path.replace(`${D}/`, ''), e.text)
|
||||||
|
return { value: undefined } as never
|
||||||
|
})
|
||||||
|
on('process.run', (_$: any, e: any) => {
|
||||||
|
const argv = [...e.argv]
|
||||||
|
calls.push(argv)
|
||||||
|
const sub = argv.find(a => ['clone', 'pull', 'add', 'commit', 'push', 'reset', 'get-url'].includes(a))
|
||||||
|
const out = (exitCode = 0, stdout = '') => ({ value: { exitCode, stdout, stderr: '' } }) as never
|
||||||
|
if (sub === 'get-url') return out(0, 'https://gitea.example/o/r.git\n')
|
||||||
|
if (sub === 'clone') {
|
||||||
|
if (!state.online) return out(128)
|
||||||
|
files.set('.git', '1')
|
||||||
|
return out()
|
||||||
|
}
|
||||||
|
if (sub === 'pull') return state.online ? out() : out(128)
|
||||||
|
if (sub === 'commit') {
|
||||||
|
state.commits.push(argv[argv.indexOf('-m') + 1]!)
|
||||||
|
return out()
|
||||||
|
}
|
||||||
|
if (sub === 'push') return state.pushOk ? out() : out(1)
|
||||||
|
return out()
|
||||||
|
})
|
||||||
|
return { files, calls, state, clock }
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = async ($: any) => {
|
||||||
|
await $.session.start({ source: 'startup', cwd: '/proj' } as never)
|
||||||
|
}
|
||||||
|
const run = ($: any, command: string, args = '') => $.command.run({ command, args } as never)
|
||||||
|
|
||||||
|
test('未啟動角色時拒絕寫入與讀取', OPTS, async ($, on) => {
|
||||||
|
const w = setup(on, { seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n', 'tools-git.md': '內容' } })
|
||||||
|
await start($)
|
||||||
|
// 工具指令尚未註冊,也未啟動:直接當作一般指令放行(由 harness 底層回空字串)。
|
||||||
|
const res = await run($, 'anima-wiki', 'tools git\n內容')
|
||||||
|
expect(res.text).toBe('')
|
||||||
|
expect(w.state.commits.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('啟動後才註冊工具,並依 wiki 目錄動態註冊', OPTS, async ($, on) => {
|
||||||
|
const registered: string[] = []
|
||||||
|
setup(on, { registered, seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n' } })
|
||||||
|
await start($)
|
||||||
|
expect(registered).not.toContain('anima-wiki')
|
||||||
|
expect(registered).not.toContain('tools-git')
|
||||||
|
await run($, 'sao-asuna')
|
||||||
|
expect(registered).toContain('anima-wiki')
|
||||||
|
expect(registered).toContain('tools-git')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('寫入:一個中文 commit、更新 Home 與 _Sidebar、推送,並註冊新指令', OPTS, async ($, on) => {
|
||||||
|
const w = setup(on, { seed: { '.git': '1' } })
|
||||||
|
await start($)
|
||||||
|
await run($, 'sao-asuna')
|
||||||
|
const res = await run($, 'anima-wiki', 'tools git https://git-scm.com\n用 rebase 整理歷史。')
|
||||||
|
expect(res.text).toContain('已新增')
|
||||||
|
expect(w.state.commits).toEqual(['feat: 新增知識「tools/git」'])
|
||||||
|
expect(w.files.get('Home.md')).toContain('## tools')
|
||||||
|
expect(w.files.get('Home.md')).toContain('- [git](tools-git)')
|
||||||
|
expect(w.files.get('_Sidebar.md')).toContain('[git](tools-git)')
|
||||||
|
expect(w.files.get('tools-git.md')).toContain('出處:https://git-scm.com')
|
||||||
|
expect(w.calls.some(c => c.includes('push'))).toBe(true)
|
||||||
|
// 讀回
|
||||||
|
const read = await run($, 'tools-git')
|
||||||
|
expect(read.text).toContain('用 rebase 整理歷史。')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('覆蓋既有主題用「更新」訊息;來源預設為使用者提供', OPTS, async ($, on) => {
|
||||||
|
const w = setup(on, { seed: { '.git': '1' } })
|
||||||
|
await start($)
|
||||||
|
await run($, 'sao-asuna')
|
||||||
|
await run($, 'anima-wiki', 'tools git\n第一版')
|
||||||
|
await run($, 'anima-wiki', 'tools git\n第二版')
|
||||||
|
expect(w.state.commits).toEqual(['feat: 新增知識「tools/git」', 'feat: 更新知識「tools/git」'])
|
||||||
|
expect(w.files.get('tools-git.md')).toContain('出處:使用者提供')
|
||||||
|
expect(w.files.get('Home.md')!.match(/\[git\]/g)!.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('10 分鐘內讀取用快取,逾時才重新 pull;離線沿用快取', OPTS, async ($, on) => {
|
||||||
|
const w = setup(on, { seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n', 'tools-git.md': '舊內容' } })
|
||||||
|
await start($)
|
||||||
|
await run($, 'sao-asuna')
|
||||||
|
const pulls = () => w.calls.filter(c => c.includes('pull')).length
|
||||||
|
await run($, 'tools-git')
|
||||||
|
const base = pulls()
|
||||||
|
await w.clock.advance(5 * 60 * 1000)
|
||||||
|
await run($, 'tools-git')
|
||||||
|
expect(pulls()).toBe(base)
|
||||||
|
await w.clock.advance(6 * 60 * 1000)
|
||||||
|
w.state.online = false
|
||||||
|
const res = await run($, 'tools-git')
|
||||||
|
expect(pulls()).toBe(base + 1)
|
||||||
|
expect(res.text).toContain('舊內容')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('推送失敗時還原並回報,不註冊指令', OPTS, async ($, on) => {
|
||||||
|
const w = setup(on, { seed: { '.git': '1' }, pushOk: false })
|
||||||
|
await start($)
|
||||||
|
await run($, 'sao-asuna')
|
||||||
|
const res = await run($, 'anima-wiki', 'tools git\n內容')
|
||||||
|
expect(res.text).toContain('失敗')
|
||||||
|
expect(w.calls.some(c => c.includes('reset'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('token 只放在指令參數,不寫入任何檔案', OPTS, async ($, on) => {
|
||||||
|
const w = setup(on, { seed: { '.git': '1' } })
|
||||||
|
await start($)
|
||||||
|
await run($, 'sao-asuna')
|
||||||
|
await run($, 'anima-wiki', 'tools git\n內容')
|
||||||
|
const pushCall = w.calls.find(c => c.includes('push'))!
|
||||||
|
expect(pushCall.join(' ')).toContain('Authorization: token secret-token')
|
||||||
|
expect([...w.files.values()].some(v => v.includes('secret-token'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('離開角色後工具被拒絕並提示先啟動', OPTS, async ($, on) => {
|
||||||
|
const w = setup(on, { seed: { '.git': '1' } })
|
||||||
|
await start($)
|
||||||
|
await run($, 'sao-asuna')
|
||||||
|
await run($, 'anima-off')
|
||||||
|
const res = await run($, 'anima-wiki', 'tools git\n內容')
|
||||||
|
expect(res.text).toContain('請先啟動角色')
|
||||||
|
expect(w.state.commits.length).toBe(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// wiki 知識庫的純邏輯:目錄解析與渲染、頁名、驗證。讀寫與 git 在 register.tsx。
|
||||||
|
|
||||||
|
export type Entry = { domain: string; topic: string }
|
||||||
|
|
||||||
|
export const CACHE_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
|
// 指令名稱只能用英數與底線,領域與主題也沿用。
|
||||||
|
const NAME_RE = /^[A-Za-z0-9_]+$/
|
||||||
|
|
||||||
|
export const validName = (s: string): boolean => NAME_RE.test(s)
|
||||||
|
|
||||||
|
export const commandName = (e: Entry): string => `${e.domain}-${e.topic}`
|
||||||
|
|
||||||
|
export const pageName = (e: Entry): string => `${e.domain}-${e.topic}`
|
||||||
|
|
||||||
|
// Home 的格式:「## 領域」章、「- [主題](頁名)」節。
|
||||||
|
export const parseCatalog = (home: string): Entry[] => {
|
||||||
|
const out: Entry[] = []
|
||||||
|
let domain = ''
|
||||||
|
for (const line of home.split('\n')) {
|
||||||
|
const h = /^##\s+(.+?)\s*$/.exec(line)
|
||||||
|
if (h) {
|
||||||
|
domain = h[1]!
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const m = /^\s*[-*]\s+\[(.+?)\]\((.+?)\)\s*$/.exec(line)
|
||||||
|
if (m && domain) out.push({ domain, topic: m[1]! })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = (entries: Entry[]): Map<string, string[]> => {
|
||||||
|
const g = new Map<string, string[]>()
|
||||||
|
for (const e of entries) g.set(e.domain, [...(g.get(e.domain) ?? []), e.topic])
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addEntry = (entries: Entry[], e: Entry): Entry[] =>
|
||||||
|
entries.some(x => x.domain === e.domain && x.topic === e.topic) ? entries : [...entries, e]
|
||||||
|
|
||||||
|
export const renderHome = (entries: Entry[]): string => {
|
||||||
|
const lines = ['# 知識庫', '']
|
||||||
|
for (const [domain, topics] of group(entries)) {
|
||||||
|
lines.push(`## ${domain}`, '')
|
||||||
|
for (const t of topics) lines.push(`- [${t}](${pageName({ domain, topic: t })})`)
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderSidebar = (entries: Entry[]): string => {
|
||||||
|
const lines = ['**[知識庫](Home)**', '']
|
||||||
|
for (const [domain, topics] of group(entries)) {
|
||||||
|
lines.push(`**${domain}**`, '')
|
||||||
|
for (const t of topics) lines.push(`- [${t}](${pageName({ domain, topic: t })})`)
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderPage = (e: Entry, body: string, source: string): string =>
|
||||||
|
`# ${e.topic}\n\n> 領域:${e.domain}\n\n${body.trim()}\n\n---\n\n出處:${source}\n`
|
||||||
|
|
||||||
|
export const commitMessage = (e: Entry, existed: boolean): string =>
|
||||||
|
`feat: ${existed ? '更新' : '新增'}知識「${e.domain}/${e.topic}」`
|
||||||
|
|
||||||
|
// tea 設定檔(YAML)中,找出網址主機相符的登入的 token。
|
||||||
|
export const teaToken = (yaml: string, host: string): string | undefined => {
|
||||||
|
for (const block of yaml.split(/^\s*-\s+name:/m).slice(1)) {
|
||||||
|
const url = /^\s*url:\s*(\S+)/m.exec(block)?.[1]
|
||||||
|
const token = /^\s*token:\s*(\S+)/m.exec(block)?.[1]
|
||||||
|
if (!url || !token) continue
|
||||||
|
try {
|
||||||
|
if (new URL(url.replace(/^['"]|['"]$/g, '')).host === host) return token.replace(/^['"]|['"]$/g, '')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wikiUrlFromRemote = (remote: string): string | undefined => {
|
||||||
|
const base = remote.trim().replace(/\.git$/, '')
|
||||||
|
return base ? `${base}.wiki.git` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指令參數:第一行「領域 主題 [出處]」,其後為內容。
|
||||||
|
export const parseWriteArgs = (args: string): { entry: Entry; source: string; body: string } | undefined => {
|
||||||
|
const [first = '', ...rest] = args.trim().split('\n')
|
||||||
|
const [domain, topic, ...src] = first.trim().split(/\s+/)
|
||||||
|
const body = rest.join('\n').trim()
|
||||||
|
if (!domain || !topic || !body || !validName(domain) || !validName(topic)) return undefined
|
||||||
|
return { entry: { domain, topic }, source: src.join(' ') || '使用者提供', body }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user