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:
jocay
2026-10-18 02:04:40 +08:00
commit 3c9b65d982
106 changed files with 16286 additions and 0 deletions
+120
View File
@@ -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))
}