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 }