Files
jocay 3c9b65d982 AI 小说工坊:主编 + 作家多模型协作写作应用
- 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>
2026-10-18 02:04:40 +08:00

121 lines
4.9 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}