250 lines
13 KiB
TypeScript
250 lines
13 KiB
TypeScript
import type { Engine } from 'claude-code'
|
||
|
||
import { teaToken } from './wiki'
|
||
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'
|
||
// 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 USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>(或 paste <章節名稱> 並於下一行起貼上內容),之後用 /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
|
||
|
||
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 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)
|
||
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') return 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 openPr($, p, plan)
|
||
}
|