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))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { and, asc, eq } from 'drizzle-orm'
|
||||
|
||||
export type ProjectRow = typeof tables.projects.$inferSelect
|
||||
export type CharacterRow = typeof tables.characters.$inferSelect
|
||||
export type LoreRow = typeof tables.loreEntries.$inferSelect
|
||||
export type AgentRow = typeof tables.agents.$inferSelect
|
||||
export type ChapterRow = typeof tables.chapters.$inferSelect
|
||||
export type VersionRow = typeof tables.chapterVersions.$inferSelect
|
||||
export type ModelRow = typeof tables.models.$inferSelect
|
||||
|
||||
export function getProject(projectId: string): ProjectRow {
|
||||
const p = useDb().select().from(tables.projects).where(eq(tables.projects.id, projectId)).get()
|
||||
if (!p) throw createError({ statusCode: 404, message: '项目不存在' })
|
||||
return p
|
||||
}
|
||||
|
||||
export function getChapter(chapterId: string): ChapterRow {
|
||||
const c = useDb().select().from(tables.chapters).where(eq(tables.chapters.id, chapterId)).get()
|
||||
if (!c) throw createError({ statusCode: 404, message: '章节不存在' })
|
||||
return c
|
||||
}
|
||||
|
||||
export function getAgent(agentId: string | null | undefined): AgentRow | undefined {
|
||||
if (!agentId) return undefined
|
||||
return useDb().select().from(tables.agents).where(eq(tables.agents.id, agentId)).get()
|
||||
}
|
||||
|
||||
export function getRoleAgent(projectId: string, role: 'editor' | 'summarizer'): AgentRow | undefined {
|
||||
return useDb().select().from(tables.agents)
|
||||
.where(and(eq(tables.agents.projectId, projectId), eq(tables.agents.role, role)))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function listChapters(projectId: string) {
|
||||
return useDb().select().from(tables.chapters).where(eq(tables.chapters.projectId, projectId)).orderBy(asc(tables.chapters.index)).all()
|
||||
}
|
||||
|
||||
export function getVersion(versionId: string | null | undefined): VersionRow | undefined {
|
||||
if (!versionId) return undefined
|
||||
return useDb().select().from(tables.chapterVersions).where(eq(tables.chapterVersions.id, versionId)).get()
|
||||
}
|
||||
|
||||
/** 一次性加载生成提示词所需的全部项目数据 */
|
||||
export function loadProjectContext(projectId: string) {
|
||||
const db = useDb()
|
||||
const project = getProject(projectId)
|
||||
return {
|
||||
project,
|
||||
characters: db.select().from(tables.characters).where(eq(tables.characters.projectId, projectId)).orderBy(asc(tables.characters.sortOrder), asc(tables.characters.createdAt)).all(),
|
||||
lore: db.select().from(tables.loreEntries).where(eq(tables.loreEntries.projectId, projectId)).orderBy(asc(tables.loreEntries.sortOrder)).all(),
|
||||
agents: db.select().from(tables.agents).where(eq(tables.agents.projectId, projectId)).orderBy(asc(tables.agents.createdAt)).all(),
|
||||
chapters: listChapters(projectId),
|
||||
models: db.select({ id: tables.models.id, label: tables.models.label, modelId: tables.models.modelId, providerName: tables.providers.name })
|
||||
.from(tables.models).innerJoin(tables.providers, eq(tables.models.providerId, tables.providers.id)).all()
|
||||
}
|
||||
}
|
||||
|
||||
export type ProjectContext = ReturnType<typeof loadProjectContext>
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mkdirSync, existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
|
||||
import * as schema from '../db/schema'
|
||||
|
||||
export { schema }
|
||||
export const tables = schema
|
||||
|
||||
let _db: BetterSQLite3Database<typeof schema> | null = null
|
||||
|
||||
export function dataDir() {
|
||||
// 运行时读取环境变量(runtimeConfig 的默认值在构建时就已固定)
|
||||
const dir = resolve(process.env.NOVEL_DATA_DIR || useRuntimeConfig().dataDir || './data')
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
function migrationsFolder() {
|
||||
const candidates = [
|
||||
resolve(process.cwd(), 'server/db/migrations'),
|
||||
resolve(process.cwd(), '../server/db/migrations')
|
||||
]
|
||||
const found = candidates.find(p => existsSync(join(p, 'meta', '_journal.json')))
|
||||
if (!found) throw new Error('找不到数据库迁移目录 server/db/migrations,请在项目根目录启动应用')
|
||||
return found
|
||||
}
|
||||
|
||||
export function useDb() {
|
||||
if (_db) return _db
|
||||
const sqlite = new Database(join(dataDir(), 'app.db'))
|
||||
sqlite.pragma('journal_mode = WAL')
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
_db = drizzle({ client: sqlite, schema })
|
||||
migrate(_db, { migrationsFolder: migrationsFolder() })
|
||||
return _db
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ProjectEvent } from '#shared/types'
|
||||
|
||||
type Listener = (e: ProjectEvent) => void
|
||||
|
||||
const listeners = new Map<string, Set<Listener>>()
|
||||
|
||||
export function subscribeProject(projectId: string, fn: Listener) {
|
||||
let set = listeners.get(projectId)
|
||||
if (!set) listeners.set(projectId, set = new Set())
|
||||
set.add(fn)
|
||||
return () => {
|
||||
set!.delete(fn)
|
||||
if (!set!.size) listeners.delete(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
export function emitProject(projectId: string, e: ProjectEvent) {
|
||||
const set = listeners.get(projectId)
|
||||
if (!set) return
|
||||
for (const fn of set) {
|
||||
try { fn(e) }
|
||||
catch (err) { console.error('[events] listener error', err) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import type { output, ZodObject, ZodType } from 'zod'
|
||||
|
||||
async function readRawJson(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const raw = await readRawBody(event, 'utf8')
|
||||
if (!raw) return {}
|
||||
try { return JSON.parse(raw) }
|
||||
catch { throw createError({ statusCode: 400, message: '请求体不是合法的 JSON' }) }
|
||||
}
|
||||
|
||||
function validationError(issues: { path: PropertyKey[], message: string }[]) {
|
||||
const first = issues[0]
|
||||
return createError({ statusCode: 400, message: `参数错误:${first?.path.map(String).join('.') || ''} ${first?.message || ''}`.trim() })
|
||||
}
|
||||
|
||||
export async function readJson<T>(event: H3Event, schema: ZodType<T>): Promise<T> {
|
||||
const parsed = schema.safeParse(await readRawJson(event))
|
||||
if (!parsed.success) throw validationError(parsed.error.issues)
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取部分更新的请求体:只保留请求中实际出现的字段
|
||||
* (zod 4 中 optional 包裹的 default 仍会生效,直接 partial() 会把缺省字段覆盖成默认值)
|
||||
*/
|
||||
export async function readPatch<S extends ZodObject>(event: H3Event, schema: S): Promise<Partial<output<S>>> {
|
||||
const data = await readRawJson(event)
|
||||
const parsed = schema.partial().safeParse(data)
|
||||
if (!parsed.success) throw validationError(parsed.error.issues)
|
||||
const result = parsed.data as Record<string, unknown>
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const k of Object.keys(data)) {
|
||||
if (k in result) out[k] = result[k]
|
||||
}
|
||||
return out as Partial<output<S>>
|
||||
}
|
||||
|
||||
export function param(event: H3Event, name = 'id') {
|
||||
const v = getRouterParam(event, name)
|
||||
if (!v) throw createError({ statusCode: 400, message: `缺少参数 ${name}` })
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { customAlphabet } from 'nanoid'
|
||||
|
||||
export const newId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16)
|
||||
@@ -0,0 +1,118 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { JobInfo, JobType, ProjectEvent } from '#shared/types'
|
||||
|
||||
type JobRow = typeof tables.jobs.$inferSelect
|
||||
|
||||
export function toJobInfo(row: JobRow): JobInfo {
|
||||
const { prompt: _p, projectId: _pid, ...rest } = row
|
||||
return rest
|
||||
}
|
||||
|
||||
/** 一次 AI 调用(或一组调用)的记录:持久化到数据库,并实时推送给前端 */
|
||||
export class Job {
|
||||
readonly id = newId()
|
||||
private row: JobRow
|
||||
|
||||
constructor(readonly projectId: string, type: JobType, opts: { chapterId?: string | null, agentName?: string, modelLabel?: string } = {}) {
|
||||
const db = useDb()
|
||||
this.row = db.insert(tables.jobs).values({
|
||||
id: this.id,
|
||||
projectId,
|
||||
type,
|
||||
chapterId: opts.chapterId ?? null,
|
||||
agentName: opts.agentName ?? '',
|
||||
modelLabel: opts.modelLabel ?? ''
|
||||
}).returning().get()
|
||||
this.emit()
|
||||
}
|
||||
|
||||
private update(patch: Partial<JobRow>) {
|
||||
this.row = useDb().update(tables.jobs).set(patch).where(eq(tables.jobs.id, this.id)).returning().get()!
|
||||
this.emit()
|
||||
}
|
||||
|
||||
private emit() {
|
||||
emitProject(this.projectId, { type: 'job', job: toJobInfo(this.row) })
|
||||
}
|
||||
|
||||
log(message: string) {
|
||||
const line = `[${new Date().toLocaleTimeString('zh-CN', { hour12: false })}] ${message}`
|
||||
this.update({ log: this.row.log ? `${this.row.log}\n${line}` : line })
|
||||
}
|
||||
|
||||
setPrompt(system: string, prompt: string) {
|
||||
this.update({ prompt: { system, prompt } })
|
||||
}
|
||||
|
||||
addUsage(usage: { inputTokens?: number, outputTokens?: number } | undefined) {
|
||||
if (!usage) return
|
||||
this.update({
|
||||
inputTokens: this.row.inputTokens + (usage.inputTokens ?? 0),
|
||||
outputTokens: this.row.outputTokens + (usage.outputTokens ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
done() {
|
||||
this.update({ status: 'done', finishedAt: new Date() })
|
||||
}
|
||||
|
||||
fail(err: unknown) {
|
||||
const cancelled = isAbort(err)
|
||||
this.update({ status: cancelled ? 'cancelled' : 'error', error: cancelled ? '' : describeError(err), finishedAt: new Date() })
|
||||
}
|
||||
}
|
||||
|
||||
export function isAbort(err: unknown) {
|
||||
const e = err as { name?: string } | undefined
|
||||
return e?.name === 'AbortError' || e?.name === 'ResponseAborted' || (err instanceof Error && /aborted|abort/i.test(err.message) && /signal|user/i.test(err.message))
|
||||
}
|
||||
|
||||
/* ---------------- 项目级互斥运行 ---------------- */
|
||||
|
||||
interface ActiveRun {
|
||||
label: string
|
||||
controller: AbortController
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
const activeRuns = new Map<string, ActiveRun>()
|
||||
|
||||
export function getActiveRun(projectId: string) {
|
||||
const run = activeRuns.get(projectId)
|
||||
return run ? { label: run.label, startedAt: run.startedAt } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个项目同一时间只允许一个生成任务(保证章节连贯、避免状态竞争)。
|
||||
* 任务在后台执行,接口立即返回;进度通过 SSE 推送。
|
||||
*/
|
||||
export function runExclusive(projectId: string, label: string, fn: (signal: AbortSignal) => Promise<void>) {
|
||||
if (activeRuns.has(projectId)) {
|
||||
throw createError({ statusCode: 409, message: `当前项目正在执行「${activeRuns.get(projectId)!.label}」,请等待完成或先停止` })
|
||||
}
|
||||
const controller = new AbortController()
|
||||
activeRuns.set(projectId, { label, controller, startedAt: Date.now() })
|
||||
emitProject(projectId, { type: 'pipeline', running: true, message: label })
|
||||
fn(controller.signal)
|
||||
.catch((err) => {
|
||||
if (!isAbort(err)) {
|
||||
console.error(`[run:${label}]`, err)
|
||||
emitProject(projectId, { type: 'log', level: 'error', message: `${label}失败:${describeError(err)}` })
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
activeRuns.delete(projectId)
|
||||
emitProject(projectId, { type: 'pipeline', running: false })
|
||||
})
|
||||
}
|
||||
|
||||
export function stopRun(projectId: string) {
|
||||
const run = activeRuns.get(projectId)
|
||||
if (!run) return false
|
||||
run.controller.abort()
|
||||
return true
|
||||
}
|
||||
|
||||
export function emitAll(projectId: string, ...events: ProjectEvent[]) {
|
||||
for (const e of events) emitProject(projectId, e)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createAnthropic } from '@ai-sdk/anthropic'
|
||||
import { createGoogle } from '@ai-sdk/google'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
||||
import type { LanguageModel } from 'ai'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { ProviderType } from '#shared/types'
|
||||
|
||||
type ProviderRow = typeof tables.providers.$inferSelect
|
||||
type ModelRow = typeof tables.models.$inferSelect
|
||||
|
||||
export const DEFAULT_BASE_URL: Record<ProviderType, string> = {
|
||||
'openai-compatible': 'https://api.openai.com/v1',
|
||||
'anthropic': 'https://api.anthropic.com/v1',
|
||||
'google': 'https://generativelanguage.googleapis.com/v1beta'
|
||||
}
|
||||
|
||||
function trimUrl(url: string) {
|
||||
return url.trim().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function createLanguageModel(provider: ProviderRow, modelId: string): LanguageModel {
|
||||
const apiKey = decryptSecret(provider.apiKeyEnc)
|
||||
const baseURL = trimUrl(provider.baseUrl) || DEFAULT_BASE_URL[provider.type]
|
||||
switch (provider.type) {
|
||||
case 'anthropic':
|
||||
return createAnthropic({ apiKey, baseURL })(modelId)
|
||||
case 'google':
|
||||
return createGoogle({ apiKey, baseURL })(modelId)
|
||||
default:
|
||||
return createOpenAICompatible({ name: 'openai-compatible', apiKey, baseURL, includeUsage: true })(modelId)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolvedModel {
|
||||
model: LanguageModel
|
||||
label: string
|
||||
settings: { temperature?: number, topP?: number, maxOutputTokens?: number }
|
||||
}
|
||||
|
||||
export function resolveModel(modelRowId: string | null | undefined): ResolvedModel {
|
||||
if (!modelRowId) throw createError({ statusCode: 400, message: '该成员尚未指定模型,请先在「AI 团队」里选择模型' })
|
||||
const db = useDb()
|
||||
const row = db.select().from(tables.models).where(eq(tables.models.id, modelRowId)).get()
|
||||
if (!row) throw createError({ statusCode: 400, message: '指定的模型不存在,可能已被删除' })
|
||||
const provider = db.select().from(tables.providers).where(eq(tables.providers.id, row.providerId)).get()
|
||||
if (!provider) throw createError({ statusCode: 400, message: '模型所属的 API 提供方不存在' })
|
||||
return {
|
||||
model: createLanguageModel(provider, row.modelId),
|
||||
label: `${provider.name} / ${row.label || row.modelId}`,
|
||||
settings: modelSettings(row)
|
||||
}
|
||||
}
|
||||
|
||||
export function modelSettings(row: ModelRow) {
|
||||
const s: ResolvedModel['settings'] = {}
|
||||
if (row.temperature != null) s.temperature = row.temperature
|
||||
if (row.topP != null) s.topP = row.topP
|
||||
if (row.maxOutputTokens != null) s.maxOutputTokens = row.maxOutputTokens
|
||||
return s
|
||||
}
|
||||
|
||||
/** 从提供方拉取可用模型列表 */
|
||||
export async function listRemoteModels(type: ProviderType, baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
const base = trimUrl(baseUrl) || DEFAULT_BASE_URL[type]
|
||||
if (type === 'anthropic') {
|
||||
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
|
||||
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
||||
query: { limit: 1000 }
|
||||
})
|
||||
return res.data.map(m => m.id)
|
||||
}
|
||||
if (type === 'google') {
|
||||
const res = await $fetch<{ models: { name: string, supportedGenerationMethods?: string[] }[] }>(`${base}/models`, {
|
||||
headers: { 'x-goog-api-key': apiKey },
|
||||
query: { pageSize: 1000 }
|
||||
})
|
||||
return res.models
|
||||
.filter(m => !m.supportedGenerationMethods || m.supportedGenerationMethods.includes('generateContent'))
|
||||
.map(m => m.name.replace(/^models\//, ''))
|
||||
}
|
||||
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
})
|
||||
return res.data.map(m => m.id).sort()
|
||||
}
|
||||
|
||||
/** 把 AI SDK / HTTP 错误整理成用户能看懂的信息 */
|
||||
export function describeError(err: unknown): string {
|
||||
if (!err) return '未知错误'
|
||||
const e = err as { name?: string, message?: string, statusCode?: number, responseBody?: string, data?: unknown, cause?: unknown }
|
||||
if (e.name === 'AbortError') return '已取消'
|
||||
let msg = e.message || String(err)
|
||||
if (e.statusCode) msg = `[HTTP ${e.statusCode}] ${msg}`
|
||||
if (e.responseBody) msg += `\n${String(e.responseBody).slice(0, 800)}`
|
||||
else if (e.data) msg += `\n${JSON.stringify(e.data).slice(0, 800)}`
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import { generateText, isStepCount, streamText, tool, type ModelMessage } from 'ai'
|
||||
import { and, desc, eq, gt } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import type { ProjectMemory } from '#shared/types'
|
||||
import type { ChapterRow } from './context'
|
||||
|
||||
/* ---------------- 工具函数 ---------------- */
|
||||
|
||||
function updateChapter(projectId: string, chapterId: string, patch: Partial<ChapterRow>) {
|
||||
const row = useDb().update(tables.chapters).set(patch).where(eq(tables.chapters.id, chapterId)).returning().get()
|
||||
emitProject(projectId, { type: 'chapter', chapterId })
|
||||
return row!
|
||||
}
|
||||
|
||||
function updateMemory(projectId: string, memory: ProjectMemory) {
|
||||
useDb().update(tables.projects).set({ memory }).where(eq(tables.projects.id, projectId)).run()
|
||||
}
|
||||
|
||||
/** 去掉章节文件开头的 “# 第N章 标题” 行,得到纯正文 */
|
||||
export function stripHeading(text: string) {
|
||||
return text.replace(/^#\s[^\n]*\n+/, '')
|
||||
}
|
||||
|
||||
export async function readActiveBody(chapter: ChapterRow) {
|
||||
const v = getVersion(chapter.activeVersionId)
|
||||
if (!v) return ''
|
||||
return stripHeading(await readChapterFile(v.filePath))
|
||||
}
|
||||
|
||||
function requireModelAgent(agent: ReturnType<typeof getAgent>, roleName: string) {
|
||||
if (!agent) throw createError({ statusCode: 400, message: `项目缺少${roleName},请在「AI 团队」中配置` })
|
||||
return { agent, ...resolveModel(agent.modelId) }
|
||||
}
|
||||
|
||||
function formatBriefMarkdown(b: BriefInput) {
|
||||
const lines: string[] = []
|
||||
lines.push(`**本章目标**:${b.goal}`)
|
||||
if (b.plotPoints.length) lines.push('', '**情节要点**:', ...b.plotPoints.map((p, i) => `${i + 1}. ${p}`))
|
||||
if (b.characters.length) lines.push('', `**出场人物**:${b.characters.join(';')}`)
|
||||
if (b.setting) lines.push('', `**场景**:${b.setting}`)
|
||||
if (b.mood) lines.push('', `**情绪基调**:${b.mood}`)
|
||||
if (b.pacing) lines.push('', `**节奏**:${b.pacing}`)
|
||||
if (b.style) lines.push('', `**文风要求**:${b.style}`)
|
||||
if (b.targetWords) lines.push('', `**目标字数**:约 ${b.targetWords} 字`)
|
||||
if (b.foreshadowing) lines.push('', `**伏笔**:${b.foreshadowing}`)
|
||||
if (b.endingHook) lines.push('', `**结尾钩子**:${b.endingHook}`)
|
||||
if (b.notes) lines.push('', `**注意事项**:${b.notes}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const briefSchema = z.object({
|
||||
title: z.string().describe('本章标题(不含“第N章”)'),
|
||||
goal: z.string().describe('本章要完成的剧情目标,一两句话'),
|
||||
plotPoints: z.array(z.string()).describe('按发生顺序排列的关键情节点,每条要具体可执行,通常 4~8 条'),
|
||||
characters: z.array(z.string()).describe('出场人物及其在本章的状态/动机,如“林晚:刚失业,强撑体面”'),
|
||||
setting: z.string().describe('时间与场景'),
|
||||
mood: z.string().describe('情绪基调'),
|
||||
pacing: z.string().optional().describe('节奏安排,如“前慢后快,结尾留白”'),
|
||||
style: z.string().optional().describe('针对本章的文风/写法要求'),
|
||||
targetWords: z.number().optional().describe('目标字数'),
|
||||
foreshadowing: z.string().optional().describe('需要埋下或回收的伏笔'),
|
||||
endingHook: z.string().optional().describe('结尾钩子/悬念'),
|
||||
notes: z.string().optional().describe('其他注意事项,例如需要避免的内容')
|
||||
})
|
||||
type BriefInput = z.infer<typeof briefSchema>
|
||||
|
||||
/* ---------------- 主编:规划大纲 ---------------- */
|
||||
|
||||
export async function planOutline(projectId: string, signal: AbortSignal, opts: { count: number, fromIndex?: number }) {
|
||||
const ctx = loadProjectContext(projectId)
|
||||
const { agent, model, label, settings } = requireModelAgent(getRoleAgent(projectId, 'editor'), '主编')
|
||||
const locked = ctx.chapters.filter(c => c.status !== 'planned' && c.status !== 'briefed')
|
||||
const fromIndex = opts.fromIndex ?? ((locked.at(-1)?.index ?? 0) + 1)
|
||||
const job = new Job(projectId, 'outline', { agentName: agent.name, modelLabel: label })
|
||||
try {
|
||||
const system = composeSystem(agent.systemPrompt, EDITOR_BUILTIN)
|
||||
const prompt = buildEditorOutlinePrompt(ctx, opts.count, fromIndex)
|
||||
job.setPrompt(system, prompt)
|
||||
job.log(`主编开始规划第${fromIndex}~${fromIndex + opts.count - 1}章`)
|
||||
|
||||
let saved = 0
|
||||
const result = await generateText({
|
||||
model, ...settings, abortSignal: signal,
|
||||
instructions: system,
|
||||
prompt,
|
||||
stopWhen: isStepCount(4),
|
||||
tools: {
|
||||
set_chapter_plan: tool({
|
||||
description: '提交章节规划。会覆盖对应序号上尚未开写的章节。',
|
||||
inputSchema: z.object({
|
||||
chapters: z.array(z.object({
|
||||
index: z.number().int().describe('章节序号'),
|
||||
title: z.string(),
|
||||
synopsis: z.string().describe('本章梗概')
|
||||
}))
|
||||
}),
|
||||
execute: async ({ chapters }) => {
|
||||
const db = useDb()
|
||||
const existing = listChapters(projectId)
|
||||
for (const c of chapters) {
|
||||
const cur = existing.find(e => e.index === c.index)
|
||||
if (cur && cur.status !== 'planned' && cur.status !== 'briefed') continue
|
||||
if (cur) {
|
||||
db.update(tables.chapters).set({ title: c.title, synopsis: c.synopsis }).where(eq(tables.chapters.id, cur.id)).run()
|
||||
}
|
||||
else {
|
||||
db.insert(tables.chapters).values({ id: newId(), projectId, index: c.index, title: c.title, synopsis: c.synopsis }).run()
|
||||
}
|
||||
saved++
|
||||
}
|
||||
emitProject(projectId, { type: 'chapters' })
|
||||
return { ok: true, saved }
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
job.addUsage(result.totalUsage)
|
||||
if (!saved) throw new Error(`主编没有提交章节规划(模型可能不支持工具调用)。模型回复:${truncate(result.text, 500)}`)
|
||||
job.log(`已保存 ${saved} 个章节规划。${result.text ? `主编说明:${result.text}` : ''}`)
|
||||
job.done()
|
||||
}
|
||||
catch (err) {
|
||||
job.fail(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 主编:章节简报 + 指派作家 ---------------- */
|
||||
|
||||
export async function generateBrief(projectId: string, chapterId: string, signal: AbortSignal) {
|
||||
const ctx = loadProjectContext(projectId)
|
||||
const chapter = getChapter(chapterId)
|
||||
const { agent, model, label, settings } = requireModelAgent(getRoleAgent(projectId, 'editor'), '主编')
|
||||
const writers = ctx.agents.filter(a => a.role === 'writer' && a.enabled)
|
||||
const allowCreate = ctx.project.settings.allowEditorCreateWriters && ctx.models.length > 0
|
||||
const job = new Job(projectId, 'brief', { chapterId, agentName: agent.name, modelLabel: label })
|
||||
try {
|
||||
const system = composeSystem(agent.systemPrompt, EDITOR_BUILTIN)
|
||||
const prompt = buildEditorBriefPrompt(ctx, chapter, writers, allowCreate)
|
||||
job.setPrompt(system, prompt)
|
||||
job.log(`主编正在为第${chapter.index}章撰写简报`)
|
||||
|
||||
let brief: string | null = null
|
||||
let title = chapter.title
|
||||
let writerId = chapter.writerLocked ? chapter.writerAgentId : null
|
||||
let reason = ''
|
||||
|
||||
const tools = {
|
||||
write_brief: tool({
|
||||
description: '提交本章的章节简报',
|
||||
inputSchema: briefSchema,
|
||||
execute: async (input) => {
|
||||
brief = formatBriefMarkdown(input)
|
||||
if (input.title) title = input.title
|
||||
job.log('简报已提交')
|
||||
return { ok: true }
|
||||
}
|
||||
}),
|
||||
assign_writer: tool({
|
||||
description: '从作家团队中指派一位作家来写本章',
|
||||
inputSchema: z.object({ writerId: z.string().describe('作家 id'), reason: z.string().describe('选择理由') }),
|
||||
execute: async (input) => {
|
||||
if (chapter.writerLocked) return { ok: false, error: '用户已锁定本章作家,无需指派' }
|
||||
const w = writers.find(x => x.id === input.writerId)
|
||||
if (!w) return { ok: false, error: `不存在 id 为 ${input.writerId} 的作家,请从 writers 列表中选择` }
|
||||
writerId = w.id
|
||||
reason = input.reason
|
||||
job.log(`指派作家:${w.name}(${input.reason})`)
|
||||
return { ok: true }
|
||||
}
|
||||
}),
|
||||
...(allowCreate && !chapter.writerLocked
|
||||
? {
|
||||
create_writer: tool({
|
||||
description: '创建一位新作家并指派其写本章。仅在现有作家都不合适时使用。',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('作家名称,体现其风格,如“冷峻悬疑派”'),
|
||||
modelId: z.string().describe('available_models 中的 modelId'),
|
||||
systemPrompt: z.string().describe('该作家的风格设定提示词'),
|
||||
styleNotes: z.string().describe('一句话描述其擅长的风格'),
|
||||
reason: z.string()
|
||||
}),
|
||||
execute: async (input) => {
|
||||
if (!ctx.models.some(m => m.id === input.modelId)) return { ok: false, error: '无效的 modelId,请从 available_models 中选择' }
|
||||
const id = newId()
|
||||
useDb().insert(tables.agents).values({
|
||||
id, projectId, role: 'writer', name: input.name, modelId: input.modelId,
|
||||
systemPrompt: input.systemPrompt, styleNotes: input.styleNotes, createdBy: 'editor'
|
||||
}).run()
|
||||
emitProject(projectId, { type: 'agents' })
|
||||
writerId = id
|
||||
reason = input.reason
|
||||
job.log(`主编创建了新作家:${input.name}(${input.reason})`)
|
||||
return { ok: true, writerId: id }
|
||||
}
|
||||
})
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
const result = await generateText({
|
||||
model, ...settings, abortSignal: signal,
|
||||
instructions: system,
|
||||
prompt,
|
||||
tools,
|
||||
stopWhen: isStepCount(6)
|
||||
})
|
||||
job.addUsage(result.totalUsage)
|
||||
|
||||
if (!brief) throw new Error(`主编没有提交简报(模型可能不支持工具调用)。模型回复:${truncate(result.text, 500)}`)
|
||||
if (!writerId) {
|
||||
writerId = writers[0]?.id ?? null
|
||||
if (writerId) job.log(`主编未指派作家,默认使用:${writers[0]!.name}`)
|
||||
}
|
||||
updateChapter(projectId, chapterId, {
|
||||
brief,
|
||||
title,
|
||||
writerAgentId: writerId,
|
||||
editorNotes: [reason && `选派理由:${reason}`, result.text].filter(Boolean).join('\n'),
|
||||
status: chapter.status === 'planned' ? 'briefed' : chapter.status
|
||||
})
|
||||
job.done()
|
||||
}
|
||||
catch (err) {
|
||||
job.fail(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 作家:写章节(流式写入文件 + 同步推送到前端) ---------------- */
|
||||
|
||||
export async function writeChapter(
|
||||
projectId: string,
|
||||
chapterId: string,
|
||||
signal: AbortSignal,
|
||||
opts: { writerAgentId?: string | null, feedback?: string, revise?: boolean } = {}
|
||||
) {
|
||||
const db = useDb()
|
||||
const ctx = loadProjectContext(projectId)
|
||||
const chapter = getChapter(chapterId)
|
||||
const writer = getAgent(opts.writerAgentId || chapter.writerAgentId) ?? ctx.agents.find(a => a.role === 'writer' && a.enabled)
|
||||
const { agent, model, label, settings } = requireModelAgent(writer, '作家')
|
||||
|
||||
const prev = ctx.chapters.filter(c => c.index < chapter.index && c.activeVersionId).at(-1)
|
||||
const prevTail = prev ? tail(await readActiveBody(prev), ctx.project.settings.prevTailChars) : ''
|
||||
const previousDraft = opts.revise ? await readActiveBody(chapter) : ''
|
||||
const feedback = opts.feedback?.trim() ?? ''
|
||||
|
||||
const last = db.select().from(tables.chapterVersions).where(eq(tables.chapterVersions.chapterId, chapterId)).orderBy(desc(tables.chapterVersions.version)).get()
|
||||
const version = (last?.version ?? 0) + 1
|
||||
const filePath = chapterFileRel(projectId, chapter.index, version)
|
||||
const versionId = newId()
|
||||
db.insert(tables.chapterVersions).values({
|
||||
id: versionId, chapterId, version, filePath,
|
||||
writerAgentId: agent.id, writerName: agent.name, modelLabel: label, feedback
|
||||
}).run()
|
||||
const prevStatus = chapter.status
|
||||
updateChapter(projectId, chapterId, { status: 'writing', writerAgentId: agent.id })
|
||||
|
||||
const job = new Job(projectId, 'write', { chapterId, agentName: agent.name, modelLabel: label })
|
||||
const out = openChapterWriter(filePath)
|
||||
let body = ''
|
||||
let lastProgress = 0
|
||||
try {
|
||||
const system = composeSystem(agent.systemPrompt, WRITER_BUILTIN, writerExtra(ctx.project.requirements))
|
||||
const prompt = buildWriterPrompt(ctx, chapter, prevTail, feedback, previousDraft)
|
||||
job.setPrompt(system, prompt)
|
||||
job.log(`${agent.name} 开始写第${chapter.index}章 → ${filePath}`)
|
||||
|
||||
const heading = `# 第${chapter.index}章 ${chapter.title}\n\n`
|
||||
out.write(heading)
|
||||
|
||||
const messages: ModelMessage[] = [{ role: 'user', content: prompt }]
|
||||
const maxRounds = 1 + Math.max(0, ctx.project.settings.maxContinuations)
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
const result = streamText({ model, ...settings, abortSignal: signal, instructions: system, messages })
|
||||
let segment = ''
|
||||
for await (const part of result.stream) {
|
||||
if (part.type === 'text-delta') {
|
||||
segment += part.text
|
||||
body += part.text
|
||||
out.write(part.text)
|
||||
emitProject(projectId, { type: 'delta', chapterId, versionId, text: part.text })
|
||||
if (body.length - lastProgress > 2000) {
|
||||
lastProgress = body.length
|
||||
db.update(tables.chapterVersions).set({ wordCount: countWords(body) }).where(eq(tables.chapterVersions.id, versionId)).run()
|
||||
}
|
||||
}
|
||||
else if (part.type === 'error') {
|
||||
throw part.error
|
||||
}
|
||||
}
|
||||
job.addUsage(await result.usage)
|
||||
const finish = await result.finishReason
|
||||
if (finish !== 'length') break
|
||||
if (round + 1 >= maxRounds) {
|
||||
job.log('输出达到长度上限,已达到最大续写次数')
|
||||
break
|
||||
}
|
||||
job.log(`输出达到长度上限,自动续写(第${round + 1}次)`)
|
||||
messages.push({ role: 'assistant', content: segment })
|
||||
messages.push({ role: 'user', content: '请从上文中断处无缝继续写下去,不要重复已经写过的内容,不要添加任何说明。' })
|
||||
}
|
||||
|
||||
await new Promise<void>((res, rej) => out.end((err?: Error | null) => (err ? rej(err) : res())))
|
||||
if (!body.trim()) throw new Error('模型没有返回任何内容')
|
||||
const wordCount = countWords(body)
|
||||
db.update(tables.chapterVersions).set({ status: 'done', wordCount }).where(eq(tables.chapterVersions.id, versionId)).run()
|
||||
updateChapter(projectId, chapterId, { status: 'written', activeVersionId: versionId, reviewNotes: '' })
|
||||
job.log(`完成,共 ${wordCount} 字`)
|
||||
job.done()
|
||||
}
|
||||
catch (err) {
|
||||
out.end()
|
||||
const cancelled = isAbort(err)
|
||||
db.update(tables.chapterVersions).set({ status: cancelled ? 'cancelled' : 'error', wordCount: countWords(body) })
|
||||
.where(eq(tables.chapterVersions.id, versionId)).run()
|
||||
// 恢复为写作前的状态(保留之前的有效版本)
|
||||
updateChapter(projectId, chapterId, { status: prevStatus === 'writing' ? (chapter.activeVersionId ? 'written' : 'briefed') : prevStatus })
|
||||
job.fail(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 摘要员:确认章节 → 摘要 + 更新故事记忆 ---------------- */
|
||||
|
||||
function parseJsonLoose<T>(text: string): T | null {
|
||||
const cleaned = text.replace(/```(?:json)?/gi, '').trim()
|
||||
const start = cleaned.indexOf('{')
|
||||
const end = cleaned.lastIndexOf('}')
|
||||
if (start < 0 || end <= start) return null
|
||||
try { return JSON.parse(cleaned.slice(start, end + 1)) as T }
|
||||
catch { return null }
|
||||
}
|
||||
|
||||
interface SummaryResult {
|
||||
summary?: string
|
||||
characterStates?: { name: string, state: string }[]
|
||||
newForeshadowing?: string[]
|
||||
resolvedForeshadowing?: string[]
|
||||
events?: string[]
|
||||
}
|
||||
|
||||
function similar(a: string, b: string) {
|
||||
const x = a.replace(/\s/g, '')
|
||||
const y = b.replace(/\s/g, '')
|
||||
if (!x || !y) return false
|
||||
return x.includes(y) || y.includes(x) || (x.length >= 8 && y.includes(x.slice(0, 8)))
|
||||
}
|
||||
|
||||
export async function approveChapter(projectId: string, chapterId: string, signal: AbortSignal) {
|
||||
const ctx = loadProjectContext(projectId)
|
||||
const chapter = getChapter(chapterId)
|
||||
if (!chapter.activeVersionId) throw createError({ statusCode: 400, message: '本章还没有正文,无法确认' })
|
||||
const summarizer = getRoleAgent(projectId, 'summarizer')
|
||||
const agentRow = summarizer?.modelId ? summarizer : getRoleAgent(projectId, 'editor')
|
||||
const { agent, model, label, settings } = requireModelAgent(agentRow, '摘要员')
|
||||
const text = await readActiveBody(chapter)
|
||||
|
||||
const job = new Job(projectId, 'summarize', { chapterId, agentName: agent.name, modelLabel: label })
|
||||
try {
|
||||
const system = composeSystem(agent.role === 'summarizer' ? agent.systemPrompt : '', SUMMARIZER_BUILTIN)
|
||||
const prompt = buildSummaryPrompt(ctx, chapter, text)
|
||||
job.setPrompt(system, prompt)
|
||||
job.log(`正在整理第${chapter.index}章摘要与故事记忆`)
|
||||
const result = await generateText({ model, ...settings, abortSignal: signal, instructions: system, prompt })
|
||||
job.addUsage(result.totalUsage)
|
||||
|
||||
const parsed = parseJsonLoose<SummaryResult>(result.text)
|
||||
if (!parsed) job.log('摘要未能解析为 JSON,已将原文作为摘要保存')
|
||||
const summary = parsed?.summary?.trim() || result.text.trim()
|
||||
|
||||
// 合并故事记忆;重新确认同一章节时先撤销该章之前的贡献
|
||||
const mem: ProjectMemory = structuredClone(ctx.project.memory)
|
||||
mem.timeline = mem.timeline.filter(t => t.chapter !== chapter.index)
|
||||
mem.foreshadowing = mem.foreshadowing
|
||||
.filter(f => f.chapter !== chapter.index)
|
||||
.map(f => (f.resolvedChapter === chapter.index ? { text: f.text, status: 'open' as const, chapter: f.chapter } : f))
|
||||
if (parsed) {
|
||||
for (const s of parsed.characterStates ?? []) {
|
||||
if (!s?.name || !s.state) continue
|
||||
const cur = mem.characterStates.find(c => c.name === s.name)
|
||||
if (cur) cur.state = s.state
|
||||
else mem.characterStates.push({ name: s.name, state: s.state })
|
||||
}
|
||||
for (const r of parsed.resolvedForeshadowing ?? []) {
|
||||
const f = mem.foreshadowing.find(x => x.status === 'open' && similar(x.text, r))
|
||||
if (f) { f.status = 'resolved'; f.resolvedChapter = chapter.index }
|
||||
}
|
||||
for (const n of parsed.newForeshadowing ?? []) {
|
||||
if (n?.trim()) mem.foreshadowing.push({ text: n.trim(), status: 'open', chapter: chapter.index })
|
||||
}
|
||||
for (const e of (parsed.events ?? []).slice(0, 5)) {
|
||||
if (e?.trim()) mem.timeline.push({ chapter: chapter.index, event: e.trim() })
|
||||
}
|
||||
mem.timeline.sort((a, b) => a.chapter - b.chapter)
|
||||
}
|
||||
updateMemory(projectId, mem)
|
||||
updateChapter(projectId, chapterId, { summary, status: 'approved' })
|
||||
job.log('已确认章节并更新故事记忆')
|
||||
job.done()
|
||||
}
|
||||
catch (err) {
|
||||
job.fail(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 主编:审稿 ---------------- */
|
||||
|
||||
export async function reviewChapter(projectId: string, chapterId: string, signal: AbortSignal) {
|
||||
const ctx = loadProjectContext(projectId)
|
||||
const chapter = getChapter(chapterId)
|
||||
const text = await readActiveBody(chapter)
|
||||
if (!text) throw createError({ statusCode: 400, message: '本章还没有正文' })
|
||||
const { agent, model, label, settings } = requireModelAgent(getRoleAgent(projectId, 'editor'), '主编')
|
||||
const job = new Job(projectId, 'review', { chapterId, agentName: agent.name, modelLabel: label })
|
||||
try {
|
||||
const system = composeSystem(agent.systemPrompt, EDITOR_BUILTIN)
|
||||
const prompt = buildEditorReviewPrompt(ctx, chapter, text)
|
||||
job.setPrompt(system, prompt)
|
||||
job.log(`主编正在审阅第${chapter.index}章`)
|
||||
const result = await generateText({ model, ...settings, abortSignal: signal, instructions: system, prompt })
|
||||
job.addUsage(result.totalUsage)
|
||||
updateChapter(projectId, chapterId, { reviewNotes: result.text.trim() })
|
||||
job.done()
|
||||
}
|
||||
catch (err) {
|
||||
job.fail(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 流水线:继续写作 ---------------- */
|
||||
|
||||
/**
|
||||
* 推进到下一章:
|
||||
* - 没有待写章节时,主编先规划后续章节
|
||||
* - 待写章节没有简报时,主编写简报(可选暂停等待确认)
|
||||
* - 作家写作;逐章模式下写完即暂停,等用户确认
|
||||
* - 自动模式下自动确认并继续,直到达到连续章节上限
|
||||
*/
|
||||
export async function runPipeline(projectId: string, signal: AbortSignal) {
|
||||
const project = getProject(projectId)
|
||||
const { autoMode, pauseAfterBrief, autoChapterLimit } = project.settings
|
||||
const job = new Job(projectId, 'pipeline', { agentName: autoMode ? '自动模式' : '逐章模式' })
|
||||
let written = 0
|
||||
try {
|
||||
for (let guard = 0; guard < 200; guard++) {
|
||||
if (signal.aborted) throw new DOMException('aborted', 'AbortError')
|
||||
const chapters = listChapters(projectId)
|
||||
|
||||
const pending = chapters.find(c => c.status === 'written')
|
||||
if (pending) {
|
||||
if (!autoMode) {
|
||||
job.log(`第${pending.index}章已写完,等待你确认后再继续`)
|
||||
break
|
||||
}
|
||||
job.log(`自动确认第${pending.index}章`)
|
||||
await approveChapter(projectId, pending.id, signal)
|
||||
continue
|
||||
}
|
||||
|
||||
if (autoMode && written >= autoChapterLimit) {
|
||||
job.log(`已连续写作 ${written} 章,达到自动模式上限,暂停`)
|
||||
break
|
||||
}
|
||||
|
||||
let next = chapters.find(c => c.status !== 'approved')
|
||||
if (!next) {
|
||||
job.log('没有待写章节,请主编规划后续章节')
|
||||
await planOutline(projectId, signal, { count: 5 })
|
||||
next = listChapters(projectId).find(c => c.status !== 'approved')
|
||||
if (!next) throw new Error('主编未能规划出新的章节')
|
||||
}
|
||||
|
||||
if (next.status === 'planned' || !next.brief.trim()) {
|
||||
await generateBrief(projectId, next.id, signal)
|
||||
if (pauseAfterBrief && !autoMode) {
|
||||
job.log(`第${next.index}章简报已生成,请确认或修改后点击「继续」开始写作`)
|
||||
break
|
||||
}
|
||||
next = getChapter(next.id)
|
||||
}
|
||||
|
||||
await writeChapter(projectId, next.id, signal)
|
||||
written++
|
||||
if (!autoMode) {
|
||||
job.log(`第${next.index}章已写完,请阅读并确认`)
|
||||
break
|
||||
}
|
||||
if (written >= autoChapterLimit) {
|
||||
// 最后一章留给用户审阅,下次启动自动模式时再确认
|
||||
job.log(`已连续写作 ${written} 章,达到自动模式上限,暂停(第${next.index}章待确认)`)
|
||||
break
|
||||
}
|
||||
}
|
||||
job.done()
|
||||
}
|
||||
catch (err) {
|
||||
job.fail(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除某章之后的章节号整体前移 */
|
||||
export function reindexAfterDelete(projectId: string, index: number) {
|
||||
const db = useDb()
|
||||
const after = db.select().from(tables.chapters)
|
||||
.where(and(eq(tables.chapters.projectId, projectId), gt(tables.chapters.index, index)))
|
||||
.all()
|
||||
for (const c of after) db.update(tables.chapters).set({ index: c.index - 1 }).where(eq(tables.chapters.id, c.id)).run()
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { ProjectMemory, ProjectRequirements } from '#shared/types'
|
||||
import type { AgentRow, ChapterRow, CharacterRow, LoreRow, ProjectContext } from './context'
|
||||
|
||||
/* ============================================================
|
||||
* 系统提示词分层:用户自定义(最高优先级)> 角色内置 > 写作要求
|
||||
* ============================================================ */
|
||||
|
||||
export function composeSystem(customPrompt: string, builtin: string, extra = '') {
|
||||
const parts: string[] = []
|
||||
if (customPrompt.trim()) {
|
||||
parts.push([
|
||||
'<highest_priority_instructions>',
|
||||
'以下是用户自定义的最高优先级指令。它们的优先级高于本提示中其他所有内容;如有任何冲突,一律以此为准。',
|
||||
'',
|
||||
customPrompt.trim(),
|
||||
'</highest_priority_instructions>'
|
||||
].join('\n'))
|
||||
}
|
||||
parts.push(builtin.trim())
|
||||
if (extra.trim()) parts.push(extra.trim())
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
const section = (tag: string, body: string) => (body.trim() ? `<${tag}>\n${body.trim()}\n</${tag}>` : '')
|
||||
const join = (...parts: string[]) => parts.filter(Boolean).join('\n\n')
|
||||
|
||||
export function formatRequirements(r: ProjectRequirements) {
|
||||
const lines = [
|
||||
r.language && `- 语言:${r.language}`,
|
||||
r.style && `- 文风:${r.style}`,
|
||||
r.pov && `- 叙事视角:${r.pov}`,
|
||||
r.narration && `- 叙述方式:${r.narration}`,
|
||||
r.wordsPerChapter && `- 每章目标字数:约 ${r.wordsPerChapter} 字`,
|
||||
r.extra && `- 其他要求:${r.extra}`
|
||||
].filter(Boolean)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function formatCharacter(c: CharacterRow, full = true) {
|
||||
const lines = [`## ${c.name}${c.identity ? `(${c.identity})` : ''}`]
|
||||
if (c.description) lines.push(full ? c.description : truncate(c.description, 300))
|
||||
if (c.personality) lines.push(`性格:${c.personality}`)
|
||||
if (full && c.speechExamples) lines.push(`说话风格示例:\n${truncate(c.speechExamples, 1500)}`)
|
||||
if (full && c.notes) lines.push(`备注:${c.notes}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** 根据文本挑出出场人物;一个都没匹配到时返回全部角色 */
|
||||
export function pickCharacters(chars: CharacterRow[], scanText: string) {
|
||||
const hit = chars.filter(c => scanText.includes(c.name))
|
||||
return hit.length ? hit : chars
|
||||
}
|
||||
|
||||
function keyMatches(key: string, text: string) {
|
||||
const m = key.match(/^\/(.+)\/([a-z]*)$/)
|
||||
if (m) {
|
||||
try { return new RegExp(m[1]!, m[2]).test(text) }
|
||||
catch { return false }
|
||||
}
|
||||
return key.trim() !== '' && text.toLowerCase().includes(key.toLowerCase())
|
||||
}
|
||||
|
||||
/** 世界书:常驻条目 + 关键词命中的条目 */
|
||||
export function selectLore(lore: LoreRow[], scanText: string) {
|
||||
return lore.filter(l => l.enabled && (l.constant || l.keys.some(k => keyMatches(k, scanText))))
|
||||
}
|
||||
|
||||
export function formatLore(entries: LoreRow[]) {
|
||||
return entries.map(e => `### ${e.title || e.keys.slice(0, 3).join('/') || '设定'}\n${e.content}`).join('\n\n')
|
||||
}
|
||||
|
||||
export function formatMemory(m: ProjectMemory) {
|
||||
const parts: string[] = []
|
||||
if (m.characterStates.length) parts.push(`人物当前状态:\n${m.characterStates.map(s => `- ${s.name}:${s.state}`).join('\n')}`)
|
||||
const open = m.foreshadowing.filter(f => f.status === 'open')
|
||||
if (open.length) parts.push(`未回收的伏笔:\n${open.map(f => `- (第${f.chapter}章埋下)${f.text}`).join('\n')}`)
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
export function formatChapterPlan(chapters: ChapterRow[], highlightIndex?: number) {
|
||||
return chapters.map((c) => {
|
||||
const mark = c.index === highlightIndex ? ' ← 当前章节' : ''
|
||||
return `第${c.index}章 ${c.title || '(未命名)'}${mark}${c.synopsis ? `:${c.synopsis}` : ''}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
/** 前情提要:最近 N 章给完整摘要,更早的章节只给梗概 */
|
||||
export function formatPreviousSummaries(chapters: ChapterRow[], beforeIndex: number, recent: number) {
|
||||
const prev = chapters.filter(c => c.index < beforeIndex && (c.summary || c.synopsis))
|
||||
const older = prev.slice(0, Math.max(0, prev.length - recent))
|
||||
const near = prev.slice(-recent)
|
||||
const parts: string[] = []
|
||||
if (older.length) parts.push(`更早章节梗概:\n${older.map(c => `- 第${c.index}章 ${c.title}:${truncate(c.summary || c.synopsis, 120)}`).join('\n')}`)
|
||||
if (near.length) parts.push(near.map(c => `### 第${c.index}章 ${c.title}\n${c.summary || c.synopsis}`).join('\n\n'))
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
export function truncate(s: string, n: number) {
|
||||
return s.length > n ? `${s.slice(0, n)}…` : s
|
||||
}
|
||||
|
||||
export function tail(s: string, n: number) {
|
||||
return s.length > n ? `…${s.slice(-n)}` : s
|
||||
}
|
||||
|
||||
function storyBible(ctx: ProjectContext, scanText: string, opts: { fullCharacters: boolean }) {
|
||||
const { project, characters, lore } = ctx
|
||||
const chars = opts.fullCharacters ? characters : pickCharacters(characters, scanText)
|
||||
return join(
|
||||
section('story_title', project.title),
|
||||
section('world_setting', project.worldSetting),
|
||||
section('characters', chars.map(c => formatCharacter(c, true)).join('\n\n')),
|
||||
section('lore', formatLore(selectLore(lore, scanText))),
|
||||
section('story_outline', project.storyOutline),
|
||||
section('writing_requirements', formatRequirements(project.requirements))
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 主编 Editor
|
||||
* ============================================================ */
|
||||
|
||||
export const EDITOR_BUILTIN = `你是一部长篇小说的主编,负责把控整部作品的走向、结构与质量。
|
||||
你的职责:
|
||||
1. 依据故事背景、人物设定和故事大纲,规划全书的章节结构,保证起承转合、节奏张弛有度、伏笔有埋有收。
|
||||
2. 在每一章开写之前,为作家撰写一份清晰、可执行的「章节简报」:本章要完成的剧情目标、关键情节点(按顺序)、出场人物及其状态与动机、场景、情绪基调、节奏、文风要求、目标字数、需要埋下或回收的伏笔、结尾钩子。
|
||||
3. 从作家团队中挑选最适合本章风格的作家(或在允许时创建新作家)来执行写作。
|
||||
4. 你不亲自写正文。你看到的是前文的摘要而不是全文,请据此保证连续性。
|
||||
工作原则:简报要具体,避免空泛的形容词;尊重既定设定和人物性格,不要擅自改动核心设定;输出使用简体中文(除非写作要求另有规定)。`
|
||||
|
||||
export function buildEditorBriefPrompt(ctx: ProjectContext, chapter: ChapterRow, writers: AgentRow[], allowCreate: boolean) {
|
||||
const { project, chapters } = ctx
|
||||
const scan = `${chapter.title}\n${chapter.synopsis}\n${project.storyOutline}`
|
||||
const roster = writers.length
|
||||
? writers.map((w) => {
|
||||
const model = ctx.models.find(m => m.id === w.modelId)
|
||||
return `- id=${w.id}|${w.name}|模型:${model ? `${model.providerName}/${model.label || model.modelId}` : '未配置'}${w.styleNotes ? `|擅长:${w.styleNotes}` : ''}`
|
||||
}).join('\n')
|
||||
: '(暂无作家)'
|
||||
const modelList = ctx.models.map(m => `- modelId=${m.id}|${m.providerName} / ${m.label || m.modelId}`).join('\n')
|
||||
|
||||
const prompt = join(
|
||||
storyBible(ctx, scan, { fullCharacters: true }),
|
||||
section('chapter_plan', formatChapterPlan(chapters, chapter.index)),
|
||||
section('previous_chapters', formatPreviousSummaries(chapters, chapter.index, project.settings.recentSummaryCount)),
|
||||
section('story_memory', formatMemory(project.memory)),
|
||||
section('writers', roster),
|
||||
allowCreate ? section('available_models', modelList) : '',
|
||||
chapter.brief ? section('previous_brief_draft', chapter.brief) : '',
|
||||
section('task', [
|
||||
`请为第${chapter.index}章${chapter.title ? `「${chapter.title}」` : ''}制定章节简报。`,
|
||||
chapter.synopsis ? `本章既定梗概:${chapter.synopsis}` : '本章尚无梗概,请根据大纲和前情自行确定本章内容。',
|
||||
'步骤:',
|
||||
'1. 调用 write_brief 工具提交章节简报(必须调用)。',
|
||||
chapter.writerLocked
|
||||
? '2. 用户已为本章指定作家,无需再选择作家。'
|
||||
: allowCreate
|
||||
? '2. 调用 assign_writer 从作家团队中选择一位作家;如果没有合适的作家,可以调用 create_writer 用 available_models 中的模型创建一位新作家(创建后自动指派)。'
|
||||
: '2. 调用 assign_writer 从作家团队中选择一位作家。',
|
||||
'3. 完成后用一两句话说明你的安排思路。'
|
||||
].join('\n'))
|
||||
)
|
||||
return prompt
|
||||
}
|
||||
|
||||
export function buildEditorOutlinePrompt(ctx: ProjectContext, count: number, fromIndex: number) {
|
||||
const { project, chapters } = ctx
|
||||
return join(
|
||||
storyBible(ctx, project.storyOutline, { fullCharacters: true }),
|
||||
section('existing_chapter_plan', formatChapterPlan(chapters)),
|
||||
section('previous_chapters', formatPreviousSummaries(chapters, fromIndex, project.settings.recentSummaryCount)),
|
||||
section('story_memory', formatMemory(project.memory)),
|
||||
section('task', [
|
||||
`请规划从第${fromIndex}章开始的 ${count} 个章节(第${fromIndex}章 ~ 第${fromIndex + count - 1}章)。`,
|
||||
'要求:每章给出标题和 80~200 字的梗概,梗概需写明本章的核心事件、冲突与转折、结尾落点;章节之间要有因果递进,节奏有张有弛。',
|
||||
'已确认(approved)或已写完的章节不要改动;之后的章节可以根据需要重新规划。',
|
||||
'请调用 set_chapter_plan 工具一次性提交规划结果,然后用一两句话概括你的整体设计思路。'
|
||||
].join('\n'))
|
||||
)
|
||||
}
|
||||
|
||||
export function buildEditorReviewPrompt(ctx: ProjectContext, chapter: ChapterRow, text: string) {
|
||||
return join(
|
||||
section('writing_requirements', formatRequirements(ctx.project.requirements)),
|
||||
section('characters', pickCharacters(ctx.characters, text).map(c => formatCharacter(c, false)).join('\n\n')),
|
||||
section('story_memory', formatMemory(ctx.project.memory)),
|
||||
section('chapter_brief', chapter.brief),
|
||||
section('chapter_text', text),
|
||||
section('task', [
|
||||
`请以主编身份审阅第${chapter.index}章正文。`,
|
||||
'从以下维度给出评价:简报完成度(逐条对照情节点)、人物是否 OOC、与设定/前文是否矛盾、文风与要求是否一致、节奏与可读性、语言问题。',
|
||||
'最后给出 1~10 分的总评分,并列出可以直接交给作家执行的修改意见(编号列表)。用 Markdown 输出。'
|
||||
].join('\n'))
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 作家 Writer
|
||||
* ============================================================ */
|
||||
|
||||
export const WRITER_BUILTIN = `你是一位专业的小说作家,正在为一部长篇小说执笔其中一章。
|
||||
你会收到故事设定、人物资料、前情提要和主编给出的「章节简报」。你的任务是把简报写成完整、生动、可直接发表的小说正文。
|
||||
写作原则:
|
||||
- 严格完成简报中的全部情节点,按顺序推进,不要跳过,也不要擅自写到下一章的内容。
|
||||
- 人物言行必须符合其性格与当前状态;对话要有个性。
|
||||
- 多用具体的动作、细节、感官描写和对白来展现,少用概括性的叙述。
|
||||
- 与上一章结尾自然衔接,不要重复上一章已经写过的内容。
|
||||
- 只输出小说正文本身:不要输出章节标题、不要写“好的”“以下是”等说明,不要在结尾加总结或注释。`
|
||||
|
||||
export function buildWriterPrompt(ctx: ProjectContext, chapter: ChapterRow, prevTail: string, feedback: string, previousDraft: string) {
|
||||
const { project, chapters } = ctx
|
||||
const scan = `${chapter.brief}\n${chapter.title}\n${chapter.synopsis}`
|
||||
const plan = chapters.filter(c => Math.abs(c.index - chapter.index) <= 2)
|
||||
return join(
|
||||
storyBible(ctx, scan, { fullCharacters: false }),
|
||||
section('nearby_chapter_plan', formatChapterPlan(plan, chapter.index)),
|
||||
section('previous_chapters', formatPreviousSummaries(chapters, chapter.index, project.settings.recentSummaryCount)),
|
||||
section('story_memory', formatMemory(project.memory)),
|
||||
section('previous_chapter_ending', prevTail),
|
||||
section('chapter_brief', `第${chapter.index}章 ${chapter.title}\n\n${chapter.brief || chapter.synopsis}`),
|
||||
previousDraft ? section('previous_draft', previousDraft) : '',
|
||||
feedback ? section('revision_feedback', feedback) : '',
|
||||
section('task', previousDraft
|
||||
? `请根据 revision_feedback 修改 previous_draft,输出修改后的第${chapter.index}章完整正文。`
|
||||
: `请开始写第${chapter.index}章正文,目标约 ${project.requirements.wordsPerChapter} 字。`)
|
||||
)
|
||||
}
|
||||
|
||||
export function writerExtra(r: ProjectRequirements) {
|
||||
const req = formatRequirements(r)
|
||||
return req ? `写作要求:\n${req}` : ''
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 摘要员 Summarizer
|
||||
* ============================================================ */
|
||||
|
||||
export const SUMMARIZER_BUILTIN = `你是小说编辑部的资料员,负责在每章定稿后整理“故事记忆”,供主编和后续作家保持连贯。
|
||||
你只输出一个 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。`
|
||||
|
||||
export function buildSummaryPrompt(ctx: ProjectContext, chapter: ChapterRow, text: string) {
|
||||
return join(
|
||||
section('character_names', ctx.characters.map(c => c.name).join('、')),
|
||||
section('story_memory_before', formatMemory(ctx.project.memory)),
|
||||
section('chapter_text', `第${chapter.index}章 ${chapter.title}\n\n${text}`),
|
||||
section('task', `请阅读本章正文,输出如下 JSON:
|
||||
{
|
||||
"summary": "本章剧情摘要,300~500 字,按时间顺序写清楚发生了什么、人物做出了什么选择、关系和局势有什么变化、结尾停在哪里",
|
||||
"characterStates": [{ "name": "人物名", "state": "该人物在本章结束时的处境、情绪、位置、身体状况、关键持有物等(只列本章有变化的人物)" }],
|
||||
"newForeshadowing": ["本章新埋下的伏笔或悬念"],
|
||||
"resolvedForeshadowing": ["本章回收/揭晓的伏笔(尽量引用 story_memory_before 中的原文)"],
|
||||
"events": ["本章关键事件,一句话一条,最多 5 条"]
|
||||
}`)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const modelSchema = z.object({
|
||||
providerId: z.string().min(1),
|
||||
label: z.string().default(''),
|
||||
modelId: z.string().min(1),
|
||||
temperature: z.number().min(0).max(2).nullable().default(null),
|
||||
topP: z.number().min(0).max(1).nullable().default(null),
|
||||
maxOutputTokens: z.number().int().positive().nullable().default(null)
|
||||
})
|
||||
|
||||
export const requirementsSchema = z.object({
|
||||
style: z.string(),
|
||||
language: z.string(),
|
||||
pov: z.string(),
|
||||
narration: z.string(),
|
||||
wordsPerChapter: z.number().int().min(100).max(50000),
|
||||
extra: z.string()
|
||||
})
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
autoMode: z.boolean(),
|
||||
pauseAfterBrief: z.boolean(),
|
||||
allowEditorCreateWriters: z.boolean(),
|
||||
prevTailChars: z.number().int().min(0).max(20000),
|
||||
recentSummaryCount: z.number().int().min(0).max(50),
|
||||
maxContinuations: z.number().int().min(0).max(10),
|
||||
autoChapterLimit: z.number().int().min(1).max(200)
|
||||
})
|
||||
|
||||
export const memorySchema = z.object({
|
||||
characterStates: z.array(z.object({ name: z.string(), state: z.string() })),
|
||||
foreshadowing: z.array(z.object({
|
||||
text: z.string(),
|
||||
status: z.enum(['open', 'resolved']),
|
||||
chapter: z.number(),
|
||||
resolvedChapter: z.number().optional()
|
||||
})),
|
||||
timeline: z.array(z.object({ chapter: z.number(), event: z.string() }))
|
||||
})
|
||||
|
||||
export const characterSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
identity: z.string().default(''),
|
||||
description: z.string().default(''),
|
||||
personality: z.string().default(''),
|
||||
speechExamples: z.string().default(''),
|
||||
notes: z.string().default(''),
|
||||
sortOrder: z.number().int().default(0)
|
||||
})
|
||||
|
||||
export const loreSchema = z.object({
|
||||
title: z.string().default(''),
|
||||
keys: z.array(z.string()).default([]),
|
||||
content: z.string().default(''),
|
||||
constant: z.boolean().default(false),
|
||||
enabled: z.boolean().default(true),
|
||||
sortOrder: z.number().int().default(100)
|
||||
})
|
||||
|
||||
export const agentSchema = z.object({
|
||||
role: z.enum(['editor', 'writer', 'summarizer']),
|
||||
name: z.string().min(1),
|
||||
modelId: z.string().nullable().default(null),
|
||||
systemPrompt: z.string().default(''),
|
||||
styleNotes: z.string().default(''),
|
||||
enabled: z.boolean().default(true)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, type WriteStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||
|
||||
export function projectDir(projectId: string) {
|
||||
return join(dataDir(), 'projects', projectId)
|
||||
}
|
||||
|
||||
/** 章节文件相对 data 目录的路径,例如 projects/xxx/chapters/0003-v2.md */
|
||||
export function chapterFileRel(projectId: string, index: number, version: number) {
|
||||
return ['projects', projectId, 'chapters', `${String(index).padStart(4, '0')}-v${version}.md`].join('/')
|
||||
}
|
||||
|
||||
export function absPath(rel: string) {
|
||||
const root = dataDir()
|
||||
const p = resolve(root, rel)
|
||||
if (p !== root && !p.startsWith(root + sep)) throw new Error('非法路径')
|
||||
return p
|
||||
}
|
||||
|
||||
export function openChapterWriter(rel: string): WriteStream {
|
||||
const p = absPath(rel)
|
||||
mkdirSync(dirname(p), { recursive: true })
|
||||
return createWriteStream(p, { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
export async function readChapterFile(rel: string) {
|
||||
const p = absPath(rel)
|
||||
if (!existsSync(p)) return ''
|
||||
return readFile(p, 'utf8')
|
||||
}
|
||||
|
||||
export function readChapterFileSync(rel: string) {
|
||||
const p = absPath(rel)
|
||||
return existsSync(p) ? readFileSync(p, 'utf8') : ''
|
||||
}
|
||||
|
||||
export function removeProjectFiles(projectId: string) {
|
||||
const dir = projectDir(projectId)
|
||||
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
export function relToData(abs: string) {
|
||||
return relative(dataDir(), abs).split(sep).join('/')
|
||||
}
|
||||
|
||||
/** 统计字数:中日韩字符按 1 字,英文按单词计 */
|
||||
export function countWords(text: string) {
|
||||
const cjk = text.match(/[㐀-鿿豈--ヿ가-]/g)?.length ?? 0
|
||||
const latin = text.replace(/[㐀-鿿豈--ヿ가-]/g, ' ').match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g)?.length ?? 0
|
||||
return cjk + latin
|
||||
}
|
||||
Reference in New Issue
Block a user