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>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user