diff --git a/README.md b/README.md index cc088b1..6a306a6 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ claude plugin install jsc-anima ## 使用 - `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。 +- `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。 ## 更新 diff --git a/hooks/anima.test.ts b/hooks/anima.test.ts index 3e385cb..6c07913 100644 --- a/hooks/anima.test.ts +++ b/hooks/anima.test.ts @@ -73,3 +73,24 @@ test('角色包轉成可啟動角色:代號、年齡與原作戀人', () => { registerCharacters([c, c]) expect(CHARACTERS.filter(x => x.slug === 'demo-rin').length).toBe(1) }) + +test('角色包:最新章節狀態、作品用語與語言設定進入 persona', () => { + const pack: PackLike = { + slug: 'mio', name: '澪', work: 'demo2', + persona: { selfRef: '我', address: '你', tone: '冷靜', catchphrase: '嗯', values: '誠實', taboos: '不說謊' }, + axes: CHARACTERS[0]!.axes, + anime: { moe: 'm', speech: 's', emotions: 'e', kaomoji: 'k', themeColor: 'red' }, + language: '繁體中文,不使用口癖', + timeline: [ + { chapter: '第1章', mood: '緊張', dialogueStyle: '客氣', monologue: '想變強', place: '學院', age: 16, relationships: [] }, + { chapter: '第2章', mood: '安心', dialogueStyle: '放鬆', monologue: '想守護', place: '街上', age: 16, relationships: [] }, + ], + } + const c = packToCharacter(pack, '示範二', [{ term: '魔核', definition: '力量來源' }]) + const text = personaSection(c, c.axes) + expect(text).toContain('最新章節(第2章)') + expect(text).toContain('安心') + expect(text).not.toContain('緊張') + expect(text).toContain('魔核=力量來源') + expect(text).toContain('不使用口癖') +}) diff --git a/hooks/characters.ts b/hooks/characters.ts index ee3b0b7..67c3de4 100644 --- a/hooks/characters.ts +++ b/hooks/characters.ts @@ -20,6 +20,9 @@ export type Character = { persona: string axes: Axes anime?: Anime + language?: string + latest?: { chapter: string; mood: string; dialogueStyle: string; monologue: string; place: string } + terms?: readonly { term: string; definition: string }[] timeline: readonly Timeline[] } @@ -70,7 +73,8 @@ export type PackLike = { persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string } axes: Axes anime: Anime - timeline: { chapter: string; age: number | null; relationships: { with: string; kind: string }[] }[] + language?: string + timeline: { chapter: string; mood?: string; dialogueStyle?: string; monologue?: string; place?: string; age: number | null; relationships: { with: string; kind: string }[] }[] } const chapterNo = (s: string, i: number): number => { @@ -78,13 +82,19 @@ const chapterNo = (s: string, i: number): number => { return n ? Number(n[0]) : i + 1 } -export const packToCharacter = (p: PackLike, workName: string): Character => ({ +export const packToCharacter = (p: PackLike, workName: string, terms: Character['terms'] = []): Character => ({ slug: `${p.work}-${p.slug}`, name: p.name, work: workName, persona: `自稱「${p.persona.selfRef}」,稱呼使用者為「${p.persona.address}」。語氣:${p.persona.tone}。口頭禪:${p.persona.catchphrase}。價值觀:${p.persona.values}。禁忌:${p.persona.taboos}。`, axes: p.axes, anime: p.anime, + language: p.language, + terms, + // 永遠採最新已處理章節的狀態。 + latest: p.timeline.length + ? (t => ({ chapter: t.chapter, mood: t.mood ?? '', dialogueStyle: t.dialogueStyle ?? '', monologue: t.monologue ?? '', place: t.place ?? '' }))(p.timeline[p.timeline.length - 1]!) + : undefined, timeline: p.timeline.map((t, i) => { const lover = t.relationships.find(r => r.kind === 'romantic') return { chapter: chapterNo(t.chapter, i), age: t.age ?? 0, ...(lover ? { romance: lover.with } : {}) } @@ -127,6 +137,8 @@ export const personaSection = (c: Character, axes: Axes): string => ...(c.anime ? [`動漫刻畫:萌屬性——${c.anime.moe};說話規格——${c.anime.speech};情緒反應——${c.anime.emotions};顏文字傾向——${c.anime.kaomoji}。`] : []), + ...(c.latest ? [`最新章節(${c.latest.chapter})的狀態:心情——${c.latest.mood};說話方式——${c.latest.dialogueStyle};內心獨白傾向——${c.latest.monologue};所在——${c.latest.place}。`] : []), + ...(c.terms && c.terms.length ? [`作品用語:${c.terms.slice(0, 30).map(t => `${t.term}=${t.definition}`).join(';')}。`] : []), `個性數值(0-10):${(Object.keys(AXIS_LABEL) as (keyof Axes)[]).map(k => `${AXIS_LABEL[k]} ${axes[k]}`).join('、')}。`, - '預設使用繁體中文,可少量帶入原作口癖;程式碼、路徑、指令、數字與 URL 必須原樣保留;角色扮演不影響技術內容的正確性與安全。', + `${c.language ? `回覆語言與口癖:${c.language}。` : '預設使用繁體中文,可少量帶入原作口癖。'}程式碼、路徑、指令、數字與 URL 必須原樣保留;角色扮演不影響技術內容的正確性與安全。`, ].join('\n') diff --git a/hooks/extract.test.ts b/hooks/extract.test.ts index 471e09c..0c8dde9 100644 --- a/hooks/extract.test.ts +++ b/hooks/extract.test.ts @@ -102,3 +102,27 @@ test('schema 驗證失敗不寫檔、不建 PR;cancel 清除待確認', async expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false) expect(await run($ as never, 'cancel')).toContain('已取消') }) + +test('貼上章節內容:paste <章節名稱> 加下一行起的內容,並寫入語言欄位與 index.json', async ($, on) => { + const s = setup(on as never, [reply(char({ language: '繁體中文,少量原作口癖' }))]) + await $.session.start({ source: 'startup', cwd: '/repo' } as never) + expect(await run($ as never, 'paste')).toContain('貼上章節的用法') + const t1 = await run($ as never, 'paste 第一章\n這是貼上的章節內容。') + expect(t1).toContain('aoi') + expect(s.prompts[0]).toContain('這是貼上的章節內容。') + await run($ as never, 'confirm') + await run($ as never, 'confirm') + const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!) + expect(pack.timeline[0].chapter).toBe('第一章') + expect(pack.language).toBe('繁體中文,少量原作口癖') + expect(JSON.parse(s.files.get('/repo/characters/index.json')!).packs[0]).toEqual({ work: 'ts', workName: '測試物語', slug: 'aoi' }) +}) + +test('欄位含長段引號內容視為抄錄原作台詞,不寫檔', async ($, on) => { + const quoted = char({ persona: { selfRef: '我', address: '你', tone: '「這是一段很長很長的原作台詞內容,不應該出現在角色包裡面」', catchphrase: '加油', values: '誠實', taboos: '背叛' } }) + const s = setup(on as never, [reply(quoted)], 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('疑似抄錄原作台詞') + expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false) +}) diff --git a/hooks/extract.ts b/hooks/extract.ts index 016b81c..39ad990 100644 --- a/hooks/extract.ts +++ b/hooks/extract.ts @@ -19,6 +19,7 @@ export type CharacterPack = { 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 } + language?: string baseChapter: string timeline: StateEntry[] conflicts: Conflict[] @@ -42,6 +43,7 @@ export type Extracted = { persona: CharacterPack['persona'] axes: Axes anime: CharacterPack['anime'] + language?: string state: Omit }[] } @@ -88,6 +90,7 @@ export const extractPrompt = (chunk: string, index: number, total: number): stri '"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":""},', + '"language":"回覆語言與口癖使用方式,例如:繁體中文,少量原作口癖",', '"state":{"mood":"","dialogueStyle":"","monologue":"","relationships":[{"with":"","kind":"friend|romantic|family|other","note":""}],"age":null,"place":""}}]}', '', '章節內容:', @@ -121,8 +124,13 @@ export const mergeChunks = (parts: Extracted[]): Extracted => { return { work, characters: [...chars.values()] } } +// 引號內長句多半是原作台詞:角色包只放自己撰寫的概括。 +const QUOTED = /[「『“"][^」』”"]{20,}[」』”"]/ + export const validateCharacter = (c: CharacterPack): string[] => { const errs: string[] = [] + const texts = [...Object.values(c.persona ?? {}), ...Object.values(c.anime ?? {}), ...(c.timeline ?? []).flatMap(t => [t.mood, t.dialogueStyle, t.monologue])] + if (texts.some(t => typeof t === 'string' && QUOTED.test(t))) errs.push(`${c.name}:欄位含長段引號內容(疑似抄錄原作台詞)`) 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) { @@ -139,6 +147,7 @@ export const validateCharacter = (c: CharacterPack): string[] => { const v = c.axes?.[k] if (!Number.isInteger(v) || v < 0 || v > 10) errs.push(`${c.name}:axes.${k} 必須是 0-10 的整數`) } + if (c.language !== undefined && (typeof c.language !== 'string' || c.language.length > MAX_FIELD)) errs.push(`${c.name}:language 必須是簡短字串`) 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}:時間線缺少來源章節`) @@ -172,7 +181,7 @@ export const mergeCharacter = ( 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: [] }, + pack: { slug, name: incoming.name, work: workSlug, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime, language: incoming.language, baseChapter: chapter, timeline: [entry], conflicts: [] }, } } const sameChapter = existing.timeline.some(t => t.chapter === chapter) @@ -184,7 +193,7 @@ export const mergeCharacter = ( 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 } + base = { ...existing, name: incoming.name, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime, language: incoming.language ?? existing.language } } 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]]) diff --git a/hooks/extractCommand.ts b/hooks/extractCommand.ts index a0e1a1e..d125bbb 100644 --- a/hooks/extractCommand.ts +++ b/hooks/extractCommand.ts @@ -5,7 +5,7 @@ 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 取消。` +const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>(或 paste <章節名稱> 並於下一行起貼上內容),之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。` type Plan = { files: { path: string; json: unknown }[] @@ -38,13 +38,17 @@ async function readJson($: Engine, path: string): Promise { const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name -async function stage($: Engine, path: string): Promise { +async function stageFile($: 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}` + return stage($, (path.split('/').pop() ?? path).replace(/\.[^.]+$/, ''), text) +} +async function stage($: Engine, chapter: string, text: string): Promise { + const cwd = await $.session.cwd().catch(() => '') const chunks = chunkText(text) const parts: Extracted[] = [] for (const [i, chunk] of chunks.entries()) { @@ -55,7 +59,6 @@ async function stage($: Engine, path: string): Promise { 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 = {} @@ -172,7 +175,15 @@ export async function runExtract($: Engine, args: string): Promise { await $.store.delete(PENDING_KEY) return '已取消,沒有寫入任何檔案。' } - if (head !== 'confirm') return stage($, args.trim()) + if (head === 'paste') { + // 貼上:第一行 paste <章節名稱>,下一行起為章節內容。 + const [first = '', ...body] = args.trim().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} <章節檔案路徑>。` diff --git a/hooks/register.tsx b/hooks/register.tsx index ef9335d..afaaf31 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -255,7 +255,8 @@ async function loadPacks($: Engine): Promise { const index = JSON.parse(await $.fs.read(`${dir}index.json`)) as { packs: { work: string; workName: string; slug: string }[] } for (const i of index.packs) { const pack = JSON.parse(await $.fs.read(`${dir}${i.work}/${i.slug}.json`)) as PackLike - registerCharacters([packToCharacter(pack, i.workName)]) + const work = (await $.fs.exists(`${dir}${i.work}/work.json`)) ? (JSON.parse(await $.fs.read(`${dir}${i.work}/work.json`)) as { terms?: { term: string; definition: string }[] }) : undefined + registerCharacters([packToCharacter(pack, i.workName, work?.terms ?? [])]) } } catch { // 角色包損壞不影響內建角色。