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>
94 lines
3.6 KiB
TypeScript
94 lines
3.6 KiB
TypeScript
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { eq, sql } from 'drizzle-orm'
|
|
import { z } from 'zod'
|
|
|
|
const optionsSchema = z.object({
|
|
/** 把卡片的 scenario 追加到故事背景 */
|
|
scenarioToWorld: z.boolean().default(true),
|
|
/** 导入角色卡内置世界书 */
|
|
importLore: z.boolean().default(true),
|
|
/** 把开场白(first_mes)作为文风/说话示例 */
|
|
firstMesAsSample: z.boolean().default(true),
|
|
/** 把卡片的 system_prompt 写入角色备注 */
|
|
systemPromptToNotes: z.boolean().default(false),
|
|
/** {{user}} 的替换名,留空则使用项目设置 */
|
|
userName: z.string().default(''),
|
|
/** 角色身份标签,如“女主角” */
|
|
identity: z.string().default('')
|
|
})
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const projectId = param(event)
|
|
const project = getProject(projectId)
|
|
const form = await readMultipartFormData(event)
|
|
const file = form?.find(f => f.name === 'file')
|
|
if (!file?.data?.length) throw createError({ statusCode: 400, message: '请上传角色卡文件' })
|
|
const optRaw = form?.find(f => f.name === 'options')?.data.toString('utf8') || '{}'
|
|
const opts = optionsSchema.parse(JSON.parse(optRaw))
|
|
|
|
let card
|
|
try { card = parseCardFile(file.data, file.filename) }
|
|
catch (err) { throw createError({ statusCode: 400, message: (err as Error).message }) }
|
|
|
|
const userName = opts.userName || project.userName
|
|
const m = (s: string) => replaceMacros(s, card.name, userName).trim()
|
|
const db = useDb()
|
|
const charId = newId()
|
|
|
|
const samples: string[] = []
|
|
if (card.mesExample.trim()) samples.push(m(card.mesExample))
|
|
if (opts.firstMesAsSample && card.firstMes.trim()) samples.push(`【开场白示例】\n${m(card.firstMes)}`)
|
|
const notes: string[] = []
|
|
if (!opts.scenarioToWorld && card.scenario.trim()) notes.push(`【场景】${m(card.scenario)}`)
|
|
if (opts.systemPromptToNotes && card.systemPrompt.trim()) notes.push(`【卡片系统提示】${m(card.systemPrompt)}`)
|
|
if (card.postHistoryInstructions.trim() && opts.systemPromptToNotes) notes.push(`【补充指令】${m(card.postHistoryInstructions)}`)
|
|
|
|
let avatar = ''
|
|
if (file.data[0] === 0x89) {
|
|
const dir = join(projectDir(projectId), 'avatars')
|
|
mkdirSync(dir, { recursive: true })
|
|
avatar = `${charId}.png`
|
|
writeFileSync(join(dir, avatar), file.data)
|
|
}
|
|
|
|
const count = db.select({ n: sql<number>`count(*)` }).from(tables.characters).where(eq(tables.characters.projectId, projectId)).get()?.n ?? 0
|
|
|
|
db.transaction((tx) => {
|
|
tx.insert(tables.characters).values({
|
|
id: charId,
|
|
projectId,
|
|
name: card.name,
|
|
identity: opts.identity,
|
|
description: m(card.description),
|
|
personality: m(card.personality),
|
|
speechExamples: samples.join('\n\n'),
|
|
notes: notes.join('\n\n'),
|
|
avatar,
|
|
sourceCard: card.raw,
|
|
sortOrder: count
|
|
}).run()
|
|
|
|
if (opts.importLore && card.lore.length) {
|
|
tx.insert(tables.loreEntries).values(card.lore.map(l => ({
|
|
id: newId(),
|
|
projectId,
|
|
title: `${card.name}·${l.title}`,
|
|
keys: l.keys,
|
|
content: m(l.content),
|
|
constant: l.constant,
|
|
enabled: l.enabled,
|
|
source: 'card' as const,
|
|
sortOrder: l.order
|
|
}))).run()
|
|
}
|
|
|
|
if (opts.scenarioToWorld && card.scenario.trim()) {
|
|
const world = [project.worldSetting.trim(), `【${card.name}·场景】\n${m(card.scenario)}`].filter(Boolean).join('\n\n')
|
|
tx.update(tables.projects).set({ worldSetting: world }).where(eq(tables.projects.id, projectId)).run()
|
|
}
|
|
})
|
|
|
|
return { id: charId, name: card.name, loreCount: opts.importLore ? card.lore.length : 0 }
|
|
})
|