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
+49
View File
@@ -0,0 +1,49 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
let _key: Buffer | null = null
function getKey() {
if (_key) return _key
const configured = process.env.NOVEL_SECRET_KEY || useRuntimeConfig().secretKey
if (configured) {
const buf = Buffer.from(configured, 'hex')
if (buf.length !== 32) throw new Error('NOVEL_SECRET_KEY 必须是 64 位十六进制字符串(32 字节)')
_key = buf
return _key
}
const file = join(dataDir(), 'secret.key')
if (!existsSync(file)) writeFileSync(file, randomBytes(32).toString('hex'), { mode: 0o600 })
_key = Buffer.from(readFileSync(file, 'utf8').trim(), 'hex')
return _key
}
/** AES-256-GCM 加密,输出 iv.tag.ciphertext(base64) */
export function encryptSecret(plain: string) {
if (!plain) return ''
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', getKey(), iv)
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
return [iv, cipher.getAuthTag(), enc].map(b => b.toString('base64')).join('.')
}
export function decryptSecret(payload: string) {
if (!payload) return ''
const [iv, tag, enc] = payload.split('.').map(s => Buffer.from(s, 'base64'))
if (!iv || !tag || !enc) throw new Error('密钥数据损坏')
try {
const decipher = createDecipheriv('aes-256-gcm', getKey(), iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
}
catch {
throw createError({ statusCode: 400, message: 'API Key 无法解密:加密密钥(data/secret.key 或 NOVEL_SECRET_KEY)已变更。请在「模型与 API」中重新填写该提供方的 API Key。' })
}
}
export function maskSecret(plain: string) {
if (!plain) return ''
if (plain.length <= 8) return '••••'
return `${plain.slice(0, 3)}••••${plain.slice(-4)}`
}