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))
}
+58
View File
@@ -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>
+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)}`
}
+38
View File
@@ -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
}
+24
View File
@@ -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) }
}
}
+42
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
import { customAlphabet } from 'nanoid'
export const newId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16)
+118
View File
@@ -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)
}
+97
View File
@@ -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
}
+513
View File
@@ -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()
}
+255
View File
@@ -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 条"]
}`)
)
}
+68
View File
@@ -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)
})
+52
View File
@@ -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
}