feat: 補齊工具一(最新章節狀態、作品用語、語言欄位、貼上章節、台詞引號檢查)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-10-15 08:34:36 +00:00
co-authored by Claude Sonnet 5.5
parent 8c25728206
commit fb04b0fa23
7 changed files with 89 additions and 10 deletions
+1
View File
@@ -33,6 +33,7 @@ claude plugin install jsc-anima
## 使用 ## 使用
- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。 - `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。
- `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。
## 更新 ## 更新
+21
View File
@@ -73,3 +73,24 @@ test('角色包轉成可啟動角色:代號、年齡與原作戀人', () => {
registerCharacters([c, c]) registerCharacters([c, c])
expect(CHARACTERS.filter(x => x.slug === 'demo-rin').length).toBe(1) 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('不使用口癖')
})
+15 -3
View File
@@ -20,6 +20,9 @@ export type Character = {
persona: string persona: string
axes: Axes axes: Axes
anime?: Anime anime?: Anime
language?: string
latest?: { chapter: string; mood: string; dialogueStyle: string; monologue: string; place: string }
terms?: readonly { term: string; definition: string }[]
timeline: readonly Timeline[] timeline: readonly Timeline[]
} }
@@ -70,7 +73,8 @@ export type PackLike = {
persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string } persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string }
axes: Axes axes: Axes
anime: Anime 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 => { 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 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}`, slug: `${p.work}-${p.slug}`,
name: p.name, name: p.name,
work: workName, work: workName,
persona: `自稱「${p.persona.selfRef}」,稱呼使用者為「${p.persona.address}」。語氣:${p.persona.tone}。口頭禪:${p.persona.catchphrase}。價值觀:${p.persona.values}。禁忌:${p.persona.taboos}。`, persona: `自稱「${p.persona.selfRef}」,稱呼使用者為「${p.persona.address}」。語氣:${p.persona.tone}。口頭禪:${p.persona.catchphrase}。價值觀:${p.persona.values}。禁忌:${p.persona.taboos}。`,
axes: p.axes, axes: p.axes,
anime: p.anime, 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) => { timeline: p.timeline.map((t, i) => {
const lover = t.relationships.find(r => r.kind === 'romantic') const lover = t.relationships.find(r => r.kind === 'romantic')
return { chapter: chapterNo(t.chapter, i), age: t.age ?? 0, ...(lover ? { romance: lover.with } : {}) } 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
? [`動漫刻畫:萌屬性——${c.anime.moe};說話規格——${c.anime.speech};情緒反應——${c.anime.emotions};顏文字傾向——${c.anime.kaomoji}。`] ? [`動漫刻畫:萌屬性——${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('、')}。`, `個性數值(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') ].join('\n')
+24
View File
@@ -102,3 +102,27 @@ test('schema 驗證失敗不寫檔、不建 PR;cancel 清除待確認', async
expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false) expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false)
expect(await run($ as never, 'cancel')).toContain('已取消') 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)
})
+11 -2
View File
@@ -19,6 +19,7 @@ export type CharacterPack = {
persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string } persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string }
axes: Axes axes: Axes
anime: { moe: string; speech: string; emotions: string; kaomoji: string; themeColor: string } anime: { moe: string; speech: string; emotions: string; kaomoji: string; themeColor: string }
language?: string
baseChapter: string baseChapter: string
timeline: StateEntry[] timeline: StateEntry[]
conflicts: Conflict[] conflicts: Conflict[]
@@ -42,6 +43,7 @@ export type Extracted = {
persona: CharacterPack['persona'] persona: CharacterPack['persona']
axes: Axes axes: Axes
anime: CharacterPack['anime'] anime: CharacterPack['anime']
language?: string
state: Omit<StateEntry, 'chapter'> state: Omit<StateEntry, 'chapter'>
}[] }[]
} }
@@ -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":""},', '"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},', '"axes":{"gentle":0,"tsundere":0,"lively":0,"formal":0,"talkative":0,"humor":0,"proactive":0,"expressive":0},',
'"anime":{"moe":"","speech":"","emotions":"","kaomoji":"","themeColor":""},', '"anime":{"moe":"","speech":"","emotions":"","kaomoji":"","themeColor":""},',
'"language":"回覆語言與口癖使用方式,例如:繁體中文,少量原作口癖",',
'"state":{"mood":"","dialogueStyle":"","monologue":"","relationships":[{"with":"","kind":"friend|romantic|family|other","note":""}],"age":null,"place":""}}]}', '"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()] } return { work, characters: [...chars.values()] }
} }
// 引號內長句多半是原作台詞:角色包只放自己撰寫的概括。
const QUOTED = /[「『“"][^」』”"]{20,}[」』”"]/
export const validateCharacter = (c: CharacterPack): string[] => { export const validateCharacter = (c: CharacterPack): string[] => {
const errs: 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 (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(c.slug)) errs.push(`${c.name}:角色代號格式不合法`)
if (!c.name) errs.push('缺少角色名稱') if (!c.name) errs.push('缺少角色名稱')
for (const k of PERSONA_KEYS) { for (const k of PERSONA_KEYS) {
@@ -139,6 +147,7 @@ export const validateCharacter = (c: CharacterPack): string[] => {
const v = c.axes?.[k] const v = c.axes?.[k]
if (!Number.isInteger(v) || v < 0 || v > 10) errs.push(`${c.name}:axes.${k} 必須是 0-10 的整數`) 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}:時間線不可為空`) if (!Array.isArray(c.timeline) || c.timeline.length === 0) errs.push(`${c.name}:時間線不可為空`)
for (const t of c.timeline ?? []) { for (const t of c.timeline ?? []) {
if (!t.chapter) errs.push(`${c.name}:時間線缺少來源章節`) if (!t.chapter) errs.push(`${c.name}:時間線缺少來源章節`)
@@ -172,7 +181,7 @@ export const mergeCharacter = (
action: '新增', action: '新增',
detail: `時間線 ${chapter}`, detail: `時間線 ${chapter}`,
conflicts: [], 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) 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) const conflicts: Conflict[] = existing.conflicts.filter(c => c.chapter !== chapter)
let base = existing let base = existing
if (overwriteBase) { 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 { } else {
const diffs: [string, string, string][] = [] 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 PERSONA_KEYS) if (existing.persona[k] !== incoming.persona[k]) diffs.push([`persona.${k}`, existing.persona[k], incoming.persona[k]])
+15 -4
View File
@@ -5,7 +5,7 @@ import type { CharacterPack, Conflict, Extracted, WorkPack } from './extract'
export const EXTRACT_COMMAND = 'anima-extract' export const EXTRACT_COMMAND = 'anima-extract'
const PENDING_KEY = 'extract:pending' 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 = { type Plan = {
files: { path: string; json: unknown }[] files: { path: string; json: unknown }[]
@@ -38,13 +38,17 @@ async function readJson<T>($: Engine, path: string): Promise<T | undefined> {
const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name
async function stage($: Engine, path: string): Promise<string> { async function stageFile($: Engine, path: string): Promise<string> {
const cwd = await $.session.cwd().catch(() => '') const cwd = await $.session.cwd().catch(() => '')
const full = path.startsWith('/') ? path : `${cwd}/${path}` const full = path.startsWith('/') ? path : `${cwd}/${path}`
if (!(await $.fs.exists(full).catch(() => false))) return `找不到檔案:${path}` if (!(await $.fs.exists(full).catch(() => false))) return `找不到檔案:${path}`
const text = String(await $.fs.read(full)) const text = String(await $.fs.read(full))
if (!text.trim()) return `檔案是空的:${path}` if (!text.trim()) return `檔案是空的:${path}`
return stage($, (path.split('/').pop() ?? path).replace(/\.[^.]+$/, ''), text)
}
async function stage($: Engine, chapter: string, text: string): Promise<string> {
const cwd = await $.session.cwd().catch(() => '')
const chunks = chunkText(text) const chunks = chunkText(text)
const parts: Extracted[] = [] const parts: Extracted[] = []
for (const [i, chunk] of chunks.entries()) { for (const [i, chunk] of chunks.entries()) {
@@ -55,7 +59,6 @@ async function stage($: Engine, path: string): Promise<string> {
parts.push(parsed) parts.push(parsed)
} }
const extracted = mergeChunks(parts) const extracted = mergeChunks(parts)
const chapter = (path.split('/').pop() ?? path).replace(/\.[^.]+$/, '')
const workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name) const workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name)
if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。' if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。'
const slugs: Record<string, string> = {} const slugs: Record<string, string> = {}
@@ -172,7 +175,15 @@ export async function runExtract($: Engine, args: string): Promise<string> {
await $.store.delete(PENDING_KEY) await $.store.delete(PENDING_KEY)
return '已取消,沒有寫入任何檔案。' 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 const p = (await $.store.get(PENDING_KEY)) as Pending | undefined
if (!p) return `沒有待確認的萃取,請先執行 /jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>。` if (!p) return `沒有待確認的萃取,請先執行 /jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>。`
+2 -1
View File
@@ -255,7 +255,8 @@ async function loadPacks($: Engine): Promise<void> {
const index = JSON.parse(await $.fs.read(`${dir}index.json`)) as { packs: { work: string; workName: string; slug: string }[] } const index = JSON.parse(await $.fs.read(`${dir}index.json`)) as { packs: { work: string; workName: string; slug: string }[] }
for (const i of index.packs) { for (const i of index.packs) {
const pack = JSON.parse(await $.fs.read(`${dir}${i.work}/${i.slug}.json`)) as PackLike 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 { } catch {
// 角色包損壞不影響內建角色。 // 角色包損壞不影響內建角色。