3c9b65d982
- Nuxt 4 + Nuxt UI 4 + AI SDK 7 + Drizzle/SQLite - 主编规划大纲与章节简报、选派作家;作家流式写入章节文件并实时推送前端 - 逐章确认模式(默认)与自动模式;摘要与故事记忆 - 酒馆角色卡(PNG/JSON,V1-V3)与世界书导入 - 自定义最高优先级系统提示词、审稿/修改/多版本、任务日志与提示词检视 - 模拟 LLM 服务与端到端测试脚本 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
4.9 KiB
TypeScript
121 lines
4.9 KiB
TypeScript
import { inflateSync } from 'node:zlib'
|
||
import type { ParsedCard } from '#shared/types'
|
||
|
||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||
|
||
/** 读取 PNG 中所有文本块(tEXt / zTXt / iTXt),返回 关键字 → 文本 */
|
||
export function readPngTextChunks(buf: Buffer): Record<string, string> {
|
||
if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('不是有效的 PNG 文件')
|
||
const out: Record<string, string> = {}
|
||
let off = 8
|
||
while (off + 8 <= buf.length) {
|
||
const len = buf.readUInt32BE(off)
|
||
const type = buf.toString('ascii', off + 4, off + 8)
|
||
const data = buf.subarray(off + 8, off + 8 + len)
|
||
if (type === 'tEXt') {
|
||
const sep = data.indexOf(0)
|
||
if (sep > 0) out[data.toString('latin1', 0, sep).toLowerCase()] = data.toString('latin1', sep + 1)
|
||
}
|
||
else if (type === 'zTXt') {
|
||
const sep = data.indexOf(0)
|
||
if (sep > 0) out[data.toString('latin1', 0, sep).toLowerCase()] = inflateSync(data.subarray(sep + 2)).toString('latin1')
|
||
}
|
||
else if (type === 'iTXt') {
|
||
// keyword\0 compressionFlag compressionMethod languageTag\0 translatedKeyword\0 text
|
||
const sep = data.indexOf(0)
|
||
if (sep > 0) {
|
||
const keyword = data.toString('latin1', 0, sep).toLowerCase()
|
||
const compressed = data[sep + 1] === 1
|
||
const langEnd = data.indexOf(0, sep + 3)
|
||
const transEnd = data.indexOf(0, langEnd + 1)
|
||
const body = data.subarray(transEnd + 1)
|
||
out[keyword] = (compressed ? inflateSync(body) : body).toString('utf8')
|
||
}
|
||
}
|
||
if (type === 'IEND') break
|
||
off += 12 + len
|
||
}
|
||
return out
|
||
}
|
||
|
||
function decodeCardText(text: string): Record<string, unknown> {
|
||
const trimmed = text.trim()
|
||
if (trimmed.startsWith('{')) return JSON.parse(trimmed)
|
||
return JSON.parse(Buffer.from(trimmed, 'base64').toString('utf8'))
|
||
}
|
||
|
||
/** 从 PNG 或 JSON 中提取原始角色卡对象 */
|
||
export function extractRawCard(buf: Buffer, filename = ''): Record<string, unknown> {
|
||
const isPng = buf.subarray(0, 8).equals(PNG_SIGNATURE)
|
||
if (isPng) {
|
||
const chunks = readPngTextChunks(buf)
|
||
// V3 卡优先读 ccv3,同时兼容只写了 chara 的 V2/V1 卡
|
||
const raw = chunks.ccv3 ?? chunks.chara
|
||
if (!raw) throw new Error('这张 PNG 里没有角色卡数据(缺少 chara / ccv3 文本块)。如果图片经过截图、压缩或聊天软件转发,元数据会丢失,请使用原始文件。')
|
||
return decodeCardText(raw)
|
||
}
|
||
const text = buf.toString('utf8').replace(/^/, '')
|
||
try {
|
||
return JSON.parse(text)
|
||
}
|
||
catch {
|
||
throw new Error(`无法解析文件 ${filename}:既不是 PNG 角色卡也不是 JSON`)
|
||
}
|
||
}
|
||
|
||
const str = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : String(v))
|
||
const strArr = (v: unknown) => (Array.isArray(v) ? v.map(str).filter(Boolean) : [])
|
||
|
||
/** 将 V1 / V2 / V3 角色卡统一成内部结构 */
|
||
export function normalizeCard(raw: Record<string, unknown>): ParsedCard {
|
||
const specRaw = str(raw.spec)
|
||
const hasData = raw.data && typeof raw.data === 'object'
|
||
const d = (hasData ? raw.data : raw) as Record<string, unknown>
|
||
const spec: ParsedCard['spec'] = specRaw === 'chara_card_v3' ? 'v3' : specRaw === 'chara_card_v2' || hasData ? 'v2' : 'v1'
|
||
const ext = (d.extensions ?? {}) as Record<string, unknown>
|
||
const book = (d.character_book ?? null) as { entries?: Record<string, unknown>[] } | null
|
||
|
||
const lore = (book?.entries ?? []).map((e, i) => {
|
||
const extE = (e.extensions ?? {}) as Record<string, unknown>
|
||
return {
|
||
title: str(e.comment) || str(e.name) || `条目 ${i + 1}`,
|
||
keys: [...strArr(e.keys), ...strArr(e.secondary_keys)],
|
||
content: str(e.content),
|
||
constant: Boolean(e.constant),
|
||
enabled: e.enabled !== false && extE.disable !== true,
|
||
order: Number(e.insertion_order ?? e.priority ?? 100) || 100
|
||
}
|
||
}).filter(e => e.content.trim())
|
||
|
||
return {
|
||
spec,
|
||
name: str(d.name) || str(raw.name) || '未命名角色',
|
||
description: str(d.description),
|
||
personality: str(d.personality),
|
||
scenario: str(d.scenario),
|
||
firstMes: str(d.first_mes),
|
||
mesExample: str(d.mes_example),
|
||
systemPrompt: str(d.system_prompt),
|
||
postHistoryInstructions: str(d.post_history_instructions),
|
||
creatorNotes: str(d.creator_notes) || str(raw.creatorcomment),
|
||
alternateGreetings: strArr(d.alternate_greetings),
|
||
tags: strArr(d.tags),
|
||
world: typeof ext.world === 'string' ? ext.world : '',
|
||
lore,
|
||
raw
|
||
}
|
||
}
|
||
|
||
/** 替换酒馆宏:{{char}} / {{user}} / <BOT> / <USER> */
|
||
export function replaceMacros(text: string, charName: string, userName: string) {
|
||
if (!text) return text
|
||
return text
|
||
.replace(/\{\{\s*char\s*\}\}|<BOT>/gi, charName)
|
||
.replace(/\{\{\s*user\s*\}\}|<USER>/gi, userName || '主角')
|
||
.replace(/\r\n/g, '\n')
|
||
}
|
||
|
||
export function parseCardFile(buf: Buffer, filename = '') {
|
||
return normalizeCard(extractRawCard(buf, filename))
|
||
}
|