From e681e11ae9d9bb869964776e9af46c3c7527c2c0 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 15 Oct 2026 06:15:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E5=85=B7=E4=B8=80=20anima-ext?= =?UTF-8?q?ract=EF=BC=8C=E5=BE=9E=E8=BC=95=E5=B0=8F=E8=AA=AA=E7=AB=A0?= =?UTF-8?q?=E7=AF=80=E7=94=A2=E7=94=9F=E8=A7=92=E8=89=B2=E5=8C=85=E4=B8=A6?= =?UTF-8?q?=E5=BB=BA=E7=AB=8B=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 + hooks/extract.test.ts | 104 +++++++++++++++++++ hooks/extract.ts | 221 ++++++++++++++++++++++++++++++++++++++++ hooks/extractCommand.ts | 195 +++++++++++++++++++++++++++++++++++ hooks/register.tsx | 3 + 5 files changed, 527 insertions(+) create mode 100644 hooks/extract.test.ts create mode 100644 hooks/extract.ts create mode 100644 hooks/extractCommand.ts diff --git a/README.md b/README.md index 1303c54..3a6ef99 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-anima claude plugin install jsc-anima ``` +## 使用 + +- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。 + ## 更新 ``` diff --git a/hooks/extract.test.ts b/hooks/extract.test.ts new file mode 100644 index 0000000..471e09c --- /dev/null +++ b/hooks/extract.test.ts @@ -0,0 +1,104 @@ +import { expect, mock, test } from 'claude-code/testing' + +import type { Extracted } from './extract' + +const char = (over: Record = {}, tone = '溫柔'): Extracted['characters'][number] => + ({ + name: '小葵', nameEn: 'Aoi', slug: 'aoi', + persona: { selfRef: '我', address: '你', tone, catchphrase: '加油', values: '誠實', taboos: '背叛' }, + axes: { gentle: 7, tsundere: 2, lively: 5, formal: 4, talkative: 5, humor: 3, proactive: 6, expressive: 5 }, + anime: { moe: '認真', speech: '柔和', emotions: '會臉紅', kaomoji: '(^_^)', themeColor: '#88c' }, + state: { mood: '緊張', dialogueStyle: '客氣', monologue: '想變強', relationships: [{ with: '阿零', kind: 'friend', note: '同伴' }], age: 16, place: '學院' }, + ...over, + }) as never + +const reply = (c: Extracted['characters'][number]) => + JSON.stringify({ work: { name: '測試物語', nameEn: 'Test Story', slug: 'ts', timeline: [{ time: '春', place: '學院' }], terms: [{ term: '魔核', definition: '力量來源' }] }, characters: [c] }) + +type Setup = { files: Map; cmds: string[][]; prompts: string[] } + +const setup = (on: never, answers: string[], files = new Map()): Setup => { + const s: Setup = { files, cmds: [], prompts: [] } + const o = on as unknown as (n: string, h: (...a: never[]) => unknown) => void + mock.store(on) + o('command.register', () => ({ value: { name: 'x' } })) + o('session.start', () => ({ cwd: '/repo' })) + o('session.cwd', (() => ({ value: '/repo' })) as never) + o('fs.exists', ((_$: unknown, e: { path: string }) => ({ value: s.files.has(e.path) })) as never) + o('fs.read', ((_$: unknown, e: { path: string }) => ({ value: s.files.get(e.path) ?? '' })) as never) + o('fs.write', ((_$: unknown, e: { path: string; text: string }) => { s.files.set(e.path, e.text); return { value: undefined } }) as never) + o('model.complete', ((_$: unknown, e: { prompt: string }) => { + s.prompts.push(e.prompt) + return { value: { isAnswered: true, text: answers.shift() ?? '', usage: {} } } + }) as never) + o('env.get', (() => ({ value: undefined })) as never) + o('process.run', ((_$: unknown, e: { argv: string[] }) => { + s.cmds.push(e.argv) + const j = e.argv.join(' ') + const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.example/o/r.git' : j.startsWith('tea logins') ? 'admin https://gitea.example u' : j.startsWith('tea pulls') ? 'PR#1' : '' + return { value: { exitCode: 0, stdout: out, stderr: '' } } + }) as never) + return s +} + +const run = ($: never, args: string) => ($ as any).command.run({ command: 'anima-extract', args }).then((r: { text: string }) => r.text) + +test('萃取:先確認代號、再看變更摘要、最後寫檔並建立 PR', async ($, on) => { + const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', '第一段\n\n第二段']])) + await $.session.start({ source: 'startup', cwd: '/repo' } as never) + const t1 = await run($ as never, 'ch1.txt') + expect(t1).toContain('ts') + expect(t1).toContain('aoi') + expect(t1).toContain('尚未建立') + expect(s.files.has('/repo/characters/ts/aoi.json')).toBe(false) + + const t2 = await run($ as never, 'confirm work=testy aoi=ao') + expect(t2).toContain('變更摘要') + expect(s.files.has('/repo/characters/testy/ao.json')).toBe(false) + + const t3 = await run($ as never, 'confirm') + expect(t3).toContain('已建立 PR') + const pack = JSON.parse(s.files.get('/repo/characters/testy/ao.json')!) + expect(pack.timeline[0].chapter).toBe('ch1') + expect(pack.timeline[0].age).toBe(16) + expect(JSON.parse(s.files.get('/repo/characters/testy/work.json')!).terms[0].term).toBe('魔核') + const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')! + expect(pr).toContain('main') + expect(pr).toContain('feat/extract-testy-ch1') + expect(s.cmds.some(c => c[0] === 'git' && c[1] === 'commit' && c[3]!.startsWith('feat: '))).toBe(true) +}) + +test('同章節重跑覆蓋、新章節追加、基礎設定不同標記待確認', async ($, on) => { + const files = new Map([['/repo/ch1.txt', 'a'], ['/repo/ch2.txt', 'b']]) + const s = setup(on as never, [reply(char()), reply(char()), reply(char({ state: { mood: '開心', dialogueStyle: '', monologue: '', relationships: [], age: 17, place: '' } }, '傲嬌'))], files) + await $.session.start({ source: 'startup', cwd: '/repo' } as never) + const write = async (f: string) => { + await run($ as never, f) + await run($ as never, 'confirm') + return run($ as never, 'confirm') + } + await write('ch1.txt') + await write('ch1.txt') + expect(JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!).timeline.length).toBe(1) + + await run($ as never, 'ch2.txt') + const summary = await run($ as never, 'confirm') + expect(summary).toContain('追加') + expect(summary).toContain('待確認') + await run($ as never, 'confirm') + const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!) + expect(pack.timeline.map((t: { chapter: string }) => t.chapter)).toEqual(['ch1', 'ch2']) + expect(pack.persona.tone).toBe('溫柔') + expect(pack.conflicts[0]).toMatchObject({ field: 'persona.tone', proposed: '傲嬌', status: '待確認' }) +}) + +test('schema 驗證失敗不寫檔、不建 PR;cancel 清除待確認', async ($, on) => { + const bad = char({ axes: { gentle: 99, tsundere: 2, lively: 5, formal: 4, talkative: 5, humor: 3, proactive: 6, expressive: 5 } }) + const s = setup(on as never, [reply(bad)], new Map([['/repo/ch1.txt', 'a']])) + await $.session.start({ source: 'startup', cwd: '/repo' } as never) + await run($ as never, 'ch1.txt') + expect(await run($ as never, 'confirm')).toContain('schema 驗證失敗') + expect(s.cmds.some(c => c[0] === 'tea')).toBe(false) + expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false) + expect(await run($ as never, 'cancel')).toContain('已取消') +}) diff --git a/hooks/extract.ts b/hooks/extract.ts new file mode 100644 index 0000000..016b81c --- /dev/null +++ b/hooks/extract.ts @@ -0,0 +1,221 @@ +import type { Axes } from './characters' + +export type Relationship = { with: string; kind: 'friend' | 'romantic' | 'family' | 'other'; note: string } + +export type StateEntry = { + chapter: string + mood: string + dialogueStyle: string + monologue: string + relationships: Relationship[] + age: number | null + place: string +} + +export type CharacterPack = { + slug: string + name: string + work: string + persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string } + axes: Axes + anime: { moe: string; speech: string; emotions: string; kaomoji: string; themeColor: string } + baseChapter: string + timeline: StateEntry[] + conflicts: Conflict[] +} + +export type Conflict = { field: string; existing: string; proposed: string; chapter: string; status: '待確認' } + +export type WorkPack = { + slug: string + name: string + timeline: { chapter: string; time: string; place: string }[] + terms: { term: string; definition: string; chapter: string }[] +} + +export type Extracted = { + work: { name: string; nameEn: string; slug: string; timeline: { time: string; place: string }[]; terms: { term: string; definition: string }[] } + characters: { + name: string + nameEn: string + slug: string + persona: CharacterPack['persona'] + axes: Axes + anime: CharacterPack['anime'] + state: Omit + }[] +} + +export const AXIS_KEYS: (keyof Axes)[] = ['gentle', 'tsundere', 'lively', 'formal', 'talkative', 'humor', 'proactive', 'expressive'] +const PERSONA_KEYS = ['selfRef', 'address', 'tone', 'catchphrase', 'values', 'taboos'] as const +const ANIME_KEYS = ['moe', 'speech', 'emotions', 'kaomoji', 'themeColor'] as const +const MAX_FIELD = 400 +export const CHUNK_SIZE = 4000 + +export const slugify = (s: string): string => + s + .toLowerCase() + .normalize('NFKD') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + +export const chunkText = (text: string, size = CHUNK_SIZE): string[] => { + const out: string[] = [] + let cur = '' + for (const para of text.split(/\n\s*\n/)) { + if (cur && cur.length + para.length + 2 > size) { + out.push(cur) + cur = '' + } + let p = para + while (p.length > size) { + out.push(p.slice(0, size)) + p = p.slice(size) + } + cur = cur ? `${cur}\n\n${p}` : p + } + if (cur.trim()) out.push(cur) + return out +} + +export const extractPrompt = (chunk: string, index: number, total: number): string => + [ + `以下是輕小說章節的第 ${index + 1}/${total} 段。請萃取環境(時間、地點、用語)與人物(心情、對話風格、獨白、關係、年齡)。`, + '規則:所有欄位用你自己的話撰寫並簡短概括,不得抄錄原作台詞;每個欄位不超過 200 字;axes 為 0-10 的整數。', + 'slug 與 workSlug 使用官方英文名或羅馬拼音的小寫英數與連字號。', + '只輸出一個 JSON,不要其他文字,格式:', + '{"work":{"name":"","nameEn":"","slug":"","timeline":[{"time":"","place":""}],"terms":[{"term":"","definition":""}]},', + '"characters":[{"name":"","nameEn":"","slug":"","persona":{"selfRef":"","address":"","tone":"","catchphrase":"","values":"","taboos":""},', + '"axes":{"gentle":0,"tsundere":0,"lively":0,"formal":0,"talkative":0,"humor":0,"proactive":0,"expressive":0},', + '"anime":{"moe":"","speech":"","emotions":"","kaomoji":"","themeColor":""},', + '"state":{"mood":"","dialogueStyle":"","monologue":"","relationships":[{"with":"","kind":"friend|romantic|family|other","note":""}],"age":null,"place":""}}]}', + '', + '章節內容:', + chunk, + ].join('\n') + +export const parseExtracted = (text: string): Extracted | undefined => { + const m = text.match(/\{[\s\S]*\}/) + if (!m) return undefined + try { + const v = JSON.parse(m[0]) as Extracted + return v && v.work && Array.isArray(v.characters) ? v : undefined + } catch { + return undefined + } +} + +// 逐段結果合併:同一人物以英文名對應,後段的狀態蓋過前段(永遠採最新)。 +export const mergeChunks = (parts: Extracted[]): Extracted => { + const first = parts[0]! + const work = { ...first.work, timeline: [...first.work.timeline], terms: [...first.work.terms] } + const chars = new Map() + for (const p of parts) { + for (const t of p.work.terms ?? []) if (!work.terms.some(x => x.term === t.term)) work.terms.push(t) + for (const t of p.work.timeline ?? []) if (!work.timeline.some(x => x.time === t.time && x.place === t.place)) work.timeline.push(t) + for (const c of p.characters) { + const key = slugify(c.nameEn || c.slug || c.name) || c.name + chars.set(key, { ...(chars.get(key) ?? c), ...c, state: { ...(chars.get(key)?.state ?? c.state), ...c.state } }) + } + } + return { work, characters: [...chars.values()] } +} + +export const validateCharacter = (c: CharacterPack): string[] => { + const errs: string[] = [] + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(c.slug)) errs.push(`${c.name}:角色代號格式不合法`) + if (!c.name) errs.push('缺少角色名稱') + for (const k of PERSONA_KEYS) { + const v = c.persona?.[k] + if (typeof v !== 'string') errs.push(`${c.name}:persona.${k} 必須是字串`) + else if (v.length > MAX_FIELD) errs.push(`${c.name}:persona.${k} 過長(疑似抄錄原文)`) + } + for (const k of ANIME_KEYS) { + const v = c.anime?.[k] + if (typeof v !== 'string') errs.push(`${c.name}:anime.${k} 必須是字串`) + else if (v.length > MAX_FIELD) errs.push(`${c.name}:anime.${k} 過長(疑似抄錄原文)`) + } + for (const k of AXIS_KEYS) { + const v = c.axes?.[k] + if (!Number.isInteger(v) || v < 0 || v > 10) errs.push(`${c.name}:axes.${k} 必須是 0-10 的整數`) + } + if (!Array.isArray(c.timeline) || c.timeline.length === 0) errs.push(`${c.name}:時間線不可為空`) + for (const t of c.timeline ?? []) { + if (!t.chapter) errs.push(`${c.name}:時間線缺少來源章節`) + if (t.age !== null && !(Number.isInteger(t.age) && t.age >= 0)) errs.push(`${c.name}:年齡必須是非負整數或 null`) + for (const f of [t.mood, t.dialogueStyle, t.monologue]) + if (typeof f === 'string' && f.length > MAX_FIELD) errs.push(`${c.name}:時間線欄位過長(疑似抄錄原文)`) + } + return errs +} + +export type MergeResult = { pack: T; action: '新增' | '更新'; detail: string; conflicts: Conflict[] } + +export const mergeCharacter = ( + incoming: Extracted['characters'][number], + workSlug: string, + slug: string, + chapter: string, + existing: CharacterPack | undefined, +): MergeResult => { + const entry: StateEntry = { + chapter, + mood: incoming.state.mood ?? '', + dialogueStyle: incoming.state.dialogueStyle ?? '', + monologue: incoming.state.monologue ?? '', + relationships: incoming.state.relationships ?? [], + age: incoming.state.age ?? null, + place: incoming.state.place ?? '', + } + if (!existing) { + return { + action: '新增', + detail: `時間線 ${chapter}`, + conflicts: [], + pack: { slug, name: incoming.name, work: workSlug, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime, baseChapter: chapter, timeline: [entry], conflicts: [] }, + } + } + const sameChapter = existing.timeline.some(t => t.chapter === chapter) + const timeline = sameChapter + ? existing.timeline.map(t => (t.chapter === chapter ? entry : t)) + : [...existing.timeline, entry] + // 重跑同一章節:該章節產生的基礎設定直接覆蓋;新章節若與既有基礎設定不同,標記待確認而不覆蓋。 + const overwriteBase = existing.baseChapter === chapter + const conflicts: Conflict[] = existing.conflicts.filter(c => c.chapter !== chapter) + let base = existing + if (overwriteBase) { + base = { ...existing, name: incoming.name, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime } + } else { + const diffs: [string, string, string][] = [] + for (const k of PERSONA_KEYS) if (existing.persona[k] !== incoming.persona[k]) diffs.push([`persona.${k}`, existing.persona[k], incoming.persona[k]]) + for (const k of AXIS_KEYS) if (existing.axes[k] !== incoming.axes[k]) diffs.push([`axes.${k}`, String(existing.axes[k]), String(incoming.axes[k])]) + for (const [field, a, b] of diffs) conflicts.push({ field, existing: a, proposed: b, chapter, status: '待確認' }) + } + return { + action: '更新', + detail: `時間線 ${sameChapter ? '覆蓋' : '追加'} ${chapter}`, + conflicts: conflicts.filter(c => c.chapter === chapter), + pack: { ...base, timeline, conflicts }, + } +} + +export const mergeWork = ( + incoming: Extracted['work'], + slug: string, + chapter: string, + existing: WorkPack | undefined, +): MergeResult => { + const timeline = [...(existing?.timeline ?? []).filter(t => t.chapter !== chapter), ...(incoming.timeline ?? []).map(t => ({ chapter, ...t }))] + const terms = [...(existing?.terms ?? []).filter(t => t.chapter !== chapter), ...(incoming.terms ?? []).map(t => ({ chapter, ...t }))] + return { + action: existing ? '更新' : '新增', + detail: `時間線 ${incoming.timeline?.length ?? 0} 筆、用語 ${incoming.terms?.length ?? 0} 筆(${chapter})`, + conflicts: [], + pack: { slug, name: incoming.name, timeline, terms }, + } +} + +export const parseRemote = (url: string): { host: string; repo: string } | undefined => { + const m = url.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/]+@)([^/:]+)(?::\d+)?[/:](.+?)(?:\.git)?$/) + return m ? { host: m[1]!, repo: m[2]! } : undefined +} diff --git a/hooks/extractCommand.ts b/hooks/extractCommand.ts new file mode 100644 index 0000000..fd4b182 --- /dev/null +++ b/hooks/extractCommand.ts @@ -0,0 +1,195 @@ +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) + } + 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) +} diff --git a/hooks/register.tsx b/hooks/register.tsx index 2ac24c6..56a65ad 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -2,6 +2,7 @@ import type { Engine, Register } from 'claude-code' import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './characters' import type { Axes } from './characters' +import { EXTRACT_COMMAND, runExtract } from './extractCommand' import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline' import type { Segment } from './statusline' @@ -78,6 +79,7 @@ export const register: Register = (on, options) => { for (const c of CHARACTERS) { await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` }) } + await $.command.register({ name: EXTRACT_COMMAND, description: '從輕小說章節萃取作品與角色包,確認後建立 PR' }) await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' }) const saved = await $.store.get(ACTIVE_KEY) active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined @@ -87,6 +89,7 @@ export const register: Register = (on, options) => { on('command.run', async ($, e, next) => { if (!isEnabled) return next(e) + if (e.command === EXTRACT_COMMAND) return { text: await runExtract($, e.args) } if (e.command === OFF_COMMAND) { active = undefined await $.store.delete(ACTIVE_KEY)