Files
jsc-anima/hooks/extractCommand.ts
T

196 lines
9.6 KiB
TypeScript
Raw 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 { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, slugify, validateCharacter } from './extract'
import type { CharacterPack, Conflict, Extracted, WorkPack } from './extract'
export const EXTRACT_COMMAND = 'anima-extract'
const PENDING_KEY = 'extract:pending'
const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>,之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。`
type Plan = {
files: { path: string; json: unknown }[]
lines: string[]
conflicts: Conflict[]
workSlug: string
workName: 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
async function stage($: 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}`
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 extracted = mergeChunks(parts)
const chapter = (path.split('/').pop() ?? path).replace(/\.[^.]+$/, '')
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)
}
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 root = p.root
const git = async (...a: string[]) => {
const r = await run($, ['git', ...a], root)
if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`)
return String(r.stdout).trim()
}
try {
const prev = await git('rev-parse', '--abbrev-ref', 'HEAD')
const base = (await git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD')).replace(/^origin\//, '')
const branch = `feat/extract-${plan.workSlug}-${slugify(p.chapter) || 'chapter'}`
await git('fetch', 'origin', base)
await git('switch', '-c', 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', 'origin', branch)
await git('switch', prev)
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)
return '已取消,沒有寫入任何檔案。'
}
if (head !== 'confirm') return stage($, 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 openPr($, p, plan)
}