Files
jsc-anima/hooks/extractCommand.ts

308 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Engine } from 'claude-code'
import { teaToken } from './wiki'
import { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, parseWikiPages, parseWikiSource, slugify, validateCharacter } from './extract'
import type { CharacterPack, Conflict, Extracted, WorkPack } from './extract'
export const EXTRACT_COMMAND = 'anima-extract'
// PR 永遠開在 jsc-anima repo,不論 session 在哪個專案:改用專屬的快取 clone 讀寫角色包。
export const ANIMA_REPO_URL = 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git'
const PENDING_KEY = 'extract:pending'
const QUEUE_KEY = 'extract:queue'
const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑或 wiki 網址>(網址為 Gitea wiki 頁面清單時依序逐章處理,前後可加說明文字;或 paste <章節名稱> 並於下一行起貼上內容),之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。`
type Plan = {
files: { path: string; json: unknown }[]
lines: string[]
conflicts: Conflict[]
workSlug: string
workName: string
}
type Queue = { base: string; pages: string[] }
type Pending = {
phase: 'slug' | 'summary'
chapter: string
root: string
extracted: Extracted
workSlug: string
slugs: Record<string, string>
plan?: Plan
}
const dirOf = (root: string, work: string) => `${root}/characters/${work}`
async function readJson<T>($: Engine, path: string): Promise<T | undefined> {
if (!(await $.fs.exists(path).catch(() => false))) return undefined
try {
return JSON.parse(String(await $.fs.read(path))) as T
} catch {
return undefined
}
}
const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name
type Repo = { dir: string; base: string; auth: string[] }
async function gitIn($: Engine, auth: string[], dir: string, a: string[]) {
const r = await $.process.run(['git', ...auth, ...a], { cwd: dir, timeoutMs: 120000 })
if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`)
return String(r.stdout).trim()
}
// 取得 jsc-anima 的快取 clone 並對齊 origin 預設分支(認證優先 tea 登入,其次 GITEA_TOKEN;token 只放在指令參數)。
async function syncRepo($: Engine): Promise<Repo | string> {
const home = (await $.env.get('HOME')) ?? ''
const dir = `${home}/.cache/jsc-anima/repo`
let token: string | undefined
try {
token = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), new URL(ANIMA_REPO_URL).host)
} catch {}
token ??= (await $.env.get('GITEA_TOKEN')) || undefined
const auth = token ? ['-c', `http.extraHeader=Authorization: token ${token}`] : []
try {
if (!(await $.fs.exists(`${dir}/.git`).catch(() => false))) {
await $.process.run(['mkdir', '-p', dir.replace(/\/[^/]+$/, '')], { timeoutMs: 5000 })
await gitIn($, auth, home || '/', ['clone', ANIMA_REPO_URL, dir])
}
await gitIn($, auth, dir, ['fetch', 'origin', '--prune'])
await gitIn($, auth, dir, ['remote', 'set-head', 'origin', '--auto'])
const base = (await gitIn($, auth, dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])).replace(/^origin\//, '')
await gitIn($, auth, dir, ['checkout', '--detach', '--force', `origin/${base}`])
await gitIn($, auth, dir, ['clean', '-fd', 'characters'])
return { dir, base, auth }
} catch (err) {
return `無法取得 jsc-anima repo(${ANIMA_REPO_URL}):${(err as Error).message}`
}
}
async function giteaToken($: Engine, host: string): Promise<string | undefined> {
const home = (await $.env.get('HOME')) ?? ''
try {
const t = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), host)
if (t) return t
} catch {}
return (await $.env.get('GITEA_TOKEN')) || undefined
}
async function curl($: Engine, url: string): Promise<string | undefined> {
const token = await giteaToken($, new URL(url).host)
const r = await $.process.run(['curl', '-fsSL', ...(token ? ['-H', `Authorization: token ${token}`] : []), url], { timeoutMs: 60000 }).catch(() => undefined)
return r && r.exitCode === 0 ? String(r.stdout) : undefined
}
const rawUrl = (base: string, page: string) => `${base}/raw/${encodeURIComponent(page)}`
async function stageWiki($: Engine, base: string, page: string, rest: string[]): Promise<string> {
const text = await curl($, rawUrl(base, page))
if (!text?.trim()) return `無法讀取 wiki 章節「${page}」(${rawUrl(base, page)})。${rest.length ? `剩餘 ${rest.length} 章未處理。` : ''}`
await $.store.set(QUEUE_KEY, { base, pages: rest } satisfies Queue)
return stage($, page, text)
}
async function stageSource($: Engine, args: string, src: NonNullable<ReturnType<typeof parseWikiSource>>): Promise<string> {
if (src.kind === 'page') {
if (src.page) return stageWiki($, src.base, src.page, [])
const text = await curl($, src.url)
if (!text?.trim()) return `無法讀取網址:${src.url}`
return stage($, decodeURIComponent(src.url.split('/').pop() || 'chapter'), text)
}
const html = await curl($, `${src.base}/?action=_pages`)
if (!html) return `無法讀取 wiki 頁面清單:${src.base}/?action=_pages`
const pages = parseWikiPages(html, src.base)
if (!pages.length) return `wiki 頁面清單中找不到章節:${src.url}`
const [first, ...rest] = pages
const out = await stageWiki($, src.base, first!, rest)
return `依序逐章處理,共 ${pages.length} 章:${pages.join('、')}\n\n${out}`
}
// 目前這章處理完(PR 已建立)後,接著萃取佇列中的下一章。
async function nextChapter($: Engine, done: string): Promise<string> {
const q = (await $.store.get(QUEUE_KEY)) as Queue | undefined
if (!q?.pages.length) {
await $.store.delete(QUEUE_KEY)
return done
}
const [next, ...rest] = q.pages
return `${done}\n\n接著處理下一章(剩餘 ${rest.length} 章):\n${await stageWiki($, q.base, next!, rest)}`
}
async function stageFile($: Engine, path: string): Promise<string> {
const cwd = await $.session.cwd().catch(() => '')
const full = path.startsWith('/') ? path : `${cwd}/${path}`
if (!(await $.fs.exists(full).catch(() => false))) return `找不到檔案:${path}`
const text = String(await $.fs.read(full))
if (!text.trim()) return `檔案是空的:${path}`
return stage($, (path.split('/').pop() ?? path).replace(/\.[^.]+$/, ''), text)
}
async function stage($: Engine, chapter: string, text: string): Promise<string> {
const chunks = chunkText(text)
const parts: Extracted[] = []
for (const [i, chunk] of chunks.entries()) {
const r = await $.model.complete({ model: 'sonnet', prompt: extractPrompt(chunk, i, chunks.length), timeoutMs: 180000 })
if (!r.isAnswered) return `第 ${i + 1}/${chunks.length} 段萃取失敗,尚未寫入任何檔案。`
const parsed = parseExtracted(r.text)
if (!parsed) return `第 ${i + 1}/${chunks.length} 段的回覆不是有效的 JSON,尚未寫入任何檔案。`
parts.push(parsed)
}
const repo = await syncRepo($)
if (typeof repo === 'string') return repo
const cwd = repo.dir
const extracted = mergeChunks(parts)
const workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name)
if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。'
const slugs: Record<string, string> = {}
for (const c of extracted.characters) slugs[slugKey(c)] = slugKey(c)
await $.store.set(PENDING_KEY, { phase: 'slug', chapter, root: cwd, extracted, workSlug, slugs } satisfies Pending)
const existsWork = await $.fs.exists(`${dirOf(cwd, workSlug)}/work.json`).catch(() => false)
const lines = [
`已萃取《${extracted.work.name}》章節「${chapter}」(${chunks.length} 段),請確認代號:`,
`- 作品代號:${workSlug}(${existsWork ? '已建立,將更新' : '尚未建立,將新增'})`,
]
for (const c of extracted.characters) {
const s = slugs[slugKey(c)]!
const ex = await $.fs.exists(`${dirOf(cwd, workSlug)}/${s}.json`).catch(() => false)
lines.push(`- ${c.name}:${s}(${ex ? '已建立,將更新' : '尚未建立,將新增'})→ 啟動指令 /jsc-anima:${workSlug}-${s}`)
}
lines.push('', `代號沒問題請輸入 /jsc-anima:${EXTRACT_COMMAND} confirm;要修改請加 work=新代號 或 <原角色代號>=新代號,例如 confirm work=sao ${slugs[Object.keys(slugs)[0] ?? ''] ?? 'asuna'}=asu。`)
return lines.join('\n')
}
async function buildPlan($: Engine, p: Pending): Promise<Plan | string> {
const dir = dirOf(p.root, p.workSlug)
const files: Plan['files'] = []
const lines: string[] = []
const conflicts: Conflict[] = []
const errors: string[] = []
const work = mergeWork(p.extracted.work, p.workSlug, p.chapter, await readJson<WorkPack>($, `${dir}/work.json`))
files.push({ path: `${dir}/work.json`, json: work.pack })
lines.push(`- 作品 ${p.workSlug}:${work.action},${work.detail}`)
for (const c of p.extracted.characters) {
const slug = p.slugs[slugKey(c)]!
const m = mergeCharacter(c, p.workSlug, slug, p.chapter, await readJson<CharacterPack>($, `${dir}/${slug}.json`))
errors.push(...validateCharacter(m.pack))
files.push({ path: `${dir}/${slug}.json`, json: m.pack })
lines.push(`- 角色 ${p.workSlug}-${slug}(${c.name}):${m.action},${m.detail}${m.conflicts.length ? `,${m.conflicts.length} 項衝突待確認` : ''}`)
conflicts.push(...m.conflicts)
}
// 索引讓啟動時能找到角色包(外掛更新後 characters/ 在外掛目錄內)。
const indexPath = `${p.root}/characters/index.json`
const index = (await readJson<{ packs: { work: string; workName: string; slug: string }[] }>($, indexPath)) ?? { packs: [] }
for (const c of p.extracted.characters) {
const slug = p.slugs[slugKey(c)]!
if (!index.packs.some(x => x.work === p.workSlug && x.slug === slug)) index.packs.push({ work: p.workSlug, workName: p.extracted.work.name, slug })
}
files.push({ path: indexPath, json: index })
if (errors.length) return `角色包 schema 驗證失敗,尚未寫入任何檔案:\n${errors.map(e => `- ${e}`).join('\n')}`
return { files, lines, conflicts, workSlug: p.workSlug, workName: p.extracted.work.name }
}
function run($: Engine, argv: string[], cwd: string) {
return $.process.run(argv, { cwd, timeoutMs: 60000 })
}
async function openPr($: Engine, p: Pending, plan: Plan): Promise<string> {
const repo = await syncRepo($)
if (typeof repo === 'string') return repo
const root = repo.dir
const git = (...a: string[]) => gitIn($, repo.auth, root, a)
try {
const base = repo.base
const branch = `feat/extract-${plan.workSlug}-${slugify(p.chapter) || 'chapter'}`
await git('checkout', '-B', branch, '--no-track', `origin/${base}`)
for (const f of plan.files) await $.fs.write(f.path, `${JSON.stringify(f.json, null, 2)}\n`)
await git('add', 'characters')
await git('commit', '-m', `feat: 萃取《${plan.workName}》${p.chapter} 的作品與角色包`)
await git('push', '-u', '--force-with-lease', 'origin', branch)
await git('checkout', '--detach', `origin/${base}`)
const title = `feat: 萃取《${plan.workName}》${p.chapter} 的角色包`
const body = [
'由 /jsc-anima:anima-extract 產生。',
'',
...plan.lines,
...(plan.conflicts.length ? ['', '## 待確認的衝突', ...plan.conflicts.map(c => `- ${c.field}:既有「${c.existing}」,新章節提議「${c.proposed}」`)] : []),
'',
'角色包只含自行撰寫的人設與規則,不含原作台詞。',
].join('\n')
return await createPr($, root, base, branch, title, body)
} catch (err) {
return `寫檔或建立 PR 失敗:${(err as Error).message}`
}
}
// 認證優先讀 tea 登入,其次 GITEA_TOKEN;token 只放在子行程的環境變數,不寫進任何檔案。
async function createPr($: Engine, root: string, base: string, branch: string, title: string, body: string): Promise<string> {
const remote = parseRemote(String((await run($, ['git', 'remote', 'get-url', 'origin'], root)).stdout))
if (!remote) return '已推送分支,但無法解析 origin,請手動建立 PR。'
const logins = await run($, ['tea', 'logins', 'list', '--output', 'simple'], root).catch(() => undefined)
const login = logins?.exitCode === 0 ? String(logins.stdout).split('\n').map(l => l.trim().split(/\s+/)).find(f => f[1]?.includes(remote.host))?.[0] : undefined
if (login) {
const r = await run($, ['tea', 'pulls', 'create', '--login', login, '--repo', remote.repo, '--base', base, '--head', branch, '--title', title, '--description', body], root)
return r.exitCode === 0 ? `已建立 PR(未自動合併):\n${String(r.stdout).trim()}` : `已推送分支 ${branch},但 tea 建立 PR 失敗:${String(r.stderr).trim()}`
}
const token = await $.env.get('GITEA_TOKEN')
if (!token) return `已推送分支 ${branch},但找不到 tea 登入或 GITEA_TOKEN,請手動建立 PR。`
const r = await $.process.run(
['sh', '-c', `curl -sf -X POST "https://${remote.host}/api/v1/repos/${remote.repo}/pulls" -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" -d @-`],
{ cwd: root, env: { GITEA_TOKEN: token }, stdin: JSON.stringify({ title, body, base, head: branch }), timeoutMs: 30000 },
)
return r.exitCode === 0 ? `已建立 PR(未自動合併):${String(r.stdout).match(/"html_url":"([^"]+)"/)?.[1] ?? branch}` : `已推送分支 ${branch},但 API 建立 PR 失敗。`
}
export async function runExtract($: Engine, args: string): Promise<string> {
const [head = '', ...rest] = args.trim().split(/\s+/)
if (!head) return USAGE
if (head === 'cancel') {
await $.store.delete(PENDING_KEY)
await $.store.delete(QUEUE_KEY)
return '已取消,沒有寫入任何檔案。'
}
if (head === 'paste') {
// 貼上:第一行 paste <章節名稱>,下一行起為章節內容。
const [first = '', ...body] = args.trim().replace(/\r\n?/g, '\n').split('\n')
const name = first.replace(/^paste\s*/, '').trim()
const text = body.join('\n')
if (!name || !text.trim()) return `貼上章節的用法:/jsc-anima:${EXTRACT_COMMAND} paste <章節名稱>,下一行起貼上章節內容。`
return stage($, name, text)
}
if (head !== 'confirm') {
const src = parseWikiSource(args)
return src ? stageSource($, args, src) : stageFile($, args.trim())
}
const p = (await $.store.get(PENDING_KEY)) as Pending | undefined
if (!p) return `沒有待確認的萃取,請先執行 /jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>。`
if (p.phase === 'slug') {
for (const kv of rest) {
const [k, v] = kv.split('=')
const s = slugify(v ?? '')
if (!k || !s) continue
if (k === 'work') p.workSlug = s
else if (k in p.slugs) p.slugs[k] = s
}
const plan = await buildPlan($, p)
if (typeof plan === 'string') return plan
await $.store.set(PENDING_KEY, { ...p, phase: 'summary', plan } satisfies Pending)
return [
'變更摘要:',
...plan.lines,
...(plan.conflicts.length ? ['衝突(標記待確認,不覆蓋既有設定):', ...plan.conflicts.map(c => ` · ${c.field}:「${c.existing}」vs「${c.proposed}」`)] : []),
'',
`確認寫檔並對預設分支建立 PR 請再輸入 /jsc-anima:${EXTRACT_COMMAND} confirm,取消請輸入 cancel。`,
].join('\n')
}
const plan = p.plan!
await $.store.delete(PENDING_KEY)
return nextChapter($, await openPr($, p, plan))
}