231 lines
10 KiB
TypeScript
231 lines
10 KiB
TypeScript
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 }
|
||
language?: 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']
|
||
language?: string
|
||
state: Omit<StateEntry, 'chapter'>
|
||
}[]
|
||
}
|
||
|
||
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":""},',
|
||
'"language":"回覆語言與口癖使用方式,例如:繁體中文,少量原作口癖",',
|
||
'"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<string, Extracted['characters'][number]>()
|
||
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()] }
|
||
}
|
||
|
||
// 引號內長句多半是原作台詞:角色包只放自己撰寫的概括。
|
||
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) {
|
||
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 (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}:時間線缺少來源章節`)
|
||
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<T> = { pack: T; action: '新增' | '更新'; detail: string; conflicts: Conflict[] }
|
||
|
||
export const mergeCharacter = (
|
||
incoming: Extracted['characters'][number],
|
||
workSlug: string,
|
||
slug: string,
|
||
chapter: string,
|
||
existing: CharacterPack | undefined,
|
||
): MergeResult<CharacterPack> => {
|
||
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, language: incoming.language, 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, 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]])
|
||
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<WorkPack> => {
|
||
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
|
||
}
|