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 plan?: Plan } const dirOf = (root: string, work: string) => `${root}/characters/${work}` async function readJson($: Engine, path: string): Promise { 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 { 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 = {} 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 { 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($, `${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($, `${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 { 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 { 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 { 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) }