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
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.png binary
*.ico binary
+27
View File
@@ -0,0 +1,27 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
# 应用数据(数据库、章节文件、加密密钥)
data
+97
View File
@@ -0,0 +1,97 @@
# AI 小说工坊
用「主编 + 作家」多模型协作来写长篇小说的本地 Web 应用。
- **主编**(例如 Claude):规划章节大纲,为每一章撰写「章节简报」(情节要点、出场人物、基调、文风、结尾钩子……),选派作家,审稿。主编只读每章的摘要,不读全文,长篇写作也不会撑爆上下文。
- **作家**(例如 Gemini,可以有多位):按简报写正文,正文流式**直接写入 `data/` 下的章节文件**,同时实时推送到前端显示。
- **摘要员**:章节确认后提炼摘要,并更新人物状态、伏笔、时间线等「故事记忆」,保证后续章节连贯。
主编、作家、摘要员都可以单独设置**最高优先级的自定义系统提示词**。
## 功能
| 模块 | 说明 |
|---|---|
| 模型与 API | OpenAI 兼容(OpenAI / DeepSeek / OpenRouter / 硅基流动 / Ollama…)、Anthropic、Google Gemini;拉取模型列表、连接测试;API Key 用 AES-256-GCM 加密存储 |
| 故事设定 | 故事背景/世界观、故事大纲、写作要求(语言、文风、视角、叙述方式、字数、其他要求)、写作流程开关 |
| 角色与世界书 | 手动管理角色;**导入酒馆角色卡**(PNG 的 `ccv3`/`chara` 文本块,或 JSON;兼容 V1/V2/V3),自动替换 `{{char}}`/`{{user}}`,内置世界书(character_book)转为项目世界书;世界书支持常驻条目和关键词/正则触发 |
| AI 团队 | 主编、摘要员、任意多位作家;每位成员可以选模型、设置自定义系统提示词;主编也可以用已配置的模型自行创建作家(可关闭) |
| 写作台 | 章节列表;简报查看/编辑;作家指派(手动指派会锁定,主编不会更换);实时写作显示;手动编辑正文;多版本切换;主编审稿;按意见修改或重写 |
| 写作流程 | **逐章模式(默认)**:每章写完就暂停,用户确认后再写下一章;**自动模式**:自动确认并连续写作,到达章节上限后暂停;可选「简报后暂停」;随时停止 |
| 故事记忆 | 人物当前状态、伏笔(未回收/已回收)、时间线,可以手动修正 |
| 任务日志 | 每次 AI 调用的状态、耗时、token 用量,以及**完整提示词检视** |
| 导出 | TXT / Markdown,可选择全部章节或仅已确认章节 |
## 快速开始
需要 Node.js 22+ 和 pnpm。
```bash
pnpm install
pnpm dev # http://localhost:3000
```
1. 打开「模型与 API」,添加提供方(可以用快速填写),再在提供方下添加模型并点击「测试」。
2. 新建作品,填写「故事设定」,在「角色与世界书」中新建角色或导入酒馆角色卡(**请使用原始 PNG 文件**:截图或经聊天软件压缩后,图片里的角色数据会丢失)。
3. 在「AI 团队」中给主编、摘要员、作家分配模型(例如主编用 Claude,作家用 Gemini),按需填写自定义系统提示词。
4. 回到「写作台」,点击右上角「继续下一章」。主编会先规划章节,再为第 1 章写简报并交给作家;写完后暂停,阅读后点击「确认并继续下一章」。
## 部署(单用户自托管)
```bash
pnpm build
NOVEL_DATA_DIR=/var/lib/novel HOST=127.0.0.1 PORT=3000 pnpm start
```
| 环境变量 | 说明 |
|---|---|
| `NOVEL_DATA_DIR` | 数据目录,默认 `./data`:包含 `app.db`、`secret.key`、`projects/<id>/chapters/*.md`、头像 |
| `NOVEL_SECRET_KEY` | 可选,64 位十六进制的 API Key 加密密钥;不设置时自动生成 `data/secret.key`(**请备份**,丢失后需要重新填写 API Key) |
| `NOVEL_PASSWORD` | 可选,设置后启用 HTTP Basic 访问密码(用户名任意)。**部署到公网或局域网时务必设置** |
| `HOST` / `PORT` | 监听地址和端口 |
应用需要在项目根目录启动(运行时会读取 `server/db/migrations` 执行数据库迁移)。
## 章节文件
每一个版本都是一个独立的 Markdown 文件,例如:
```
data/projects/<项目id>/chapters/0003-v2.md # 第 3 章第 2 版
```
作家输出时边生成边写入;中途停止或出错时,已写出的部分会保留在文件中,章节回退到之前的有效版本。
## 提示词分层
```
System:
<highest_priority_instructions> 用户自定义系统提示词 </highest_priority_instructions> ← 最高优先级
角色内置提示词(主编 / 作家 / 摘要员)
写作要求
Prompt:
世界观 → 相关角色 → 触发的世界书 → 故事大纲 → 章节规划 → 前情摘要 → 故事记忆
→ 上一章结尾原文 → 章节简报 →(修改意见 / 上一版草稿)→ 任务
```
每次调用实际发送的完整提示词都可以在「任务日志」中查看。
## 开发
```bash
pnpm typecheck # 类型检查(app + server)
pnpm db:generate # 修改 server/db/schema.ts 后生成迁移
pnpm mock:llm # 启动模拟的 OpenAI 兼容接口(不消耗 token)
pnpm test:e2e ./角色卡.png # 端到端测试(需先启动 pnpm dev 和 pnpm mock:llm;角色卡参数可选)
```
技术栈:Nuxt 4(Vue 3 + Vite)、Nuxt UI 4、Vercel AI SDK 7、Drizzle ORM + better-sqlite3、Zod 4。
```
app/ 前端(页面、组件、组合式函数)
server/api/ REST 接口
server/utils/ 核心逻辑:pipeline(主编/作家/摘要流程)、prompts(提示词)、card(角色卡解析)、llm(模型接入)
server/db/ 数据库表结构与迁移
shared/types/ 前后端共享类型
scripts/ 模拟 LLM 服务与端到端测试
```
+8
View File
@@ -0,0 +1,8 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'rose',
neutral: 'stone'
}
}
})
+7
View File
@@ -0,0 +1,7 @@
<template>
<UApp :toaster="{ position: 'top-right', duration: 4000 }">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>
+56
View File
@@ -0,0 +1,56 @@
@import "tailwindcss";
@import "@nuxt/ui";
@theme static {
--font-sans: "Inter", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
--font-serif: "Noto Serif SC", "Source Han Serif SC", "Songti SC", "SimSun", serif;
}
:root {
--ui-primary: var(--ui-color-primary-600);
}
.dark {
--ui-primary: var(--ui-color-primary-400);
}
/* 小说正文排版 */
.novel-text {
font-family: var(--font-serif);
font-size: 1.0625rem;
line-height: 2;
letter-spacing: 0.02em;
white-space: pre-wrap;
word-break: break-word;
}
.novel-body {
font-family: var(--font-serif);
font-size: 1.0625rem;
line-height: 1.95;
letter-spacing: 0.02em;
word-break: break-word;
}
.novel-body p {
text-indent: 2em;
margin: 0 0 0.6em;
}
.novel-cursor {
display: inline-block;
width: 0.5em;
height: 1.1em;
margin-inline-start: 2px;
vertical-align: text-bottom;
background: var(--ui-primary);
animation: novel-blink 1s steps(2) infinite;
}
@keyframes novel-blink { 50% { opacity: 0; } }
.prose-brief h1, .prose-brief h2, .prose-brief h3 {
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
}
.prose-brief ul { list-style: disc; padding-left: 1.25rem; }
.prose-brief ol { list-style: decimal; padding-left: 1.25rem; }
.prose-brief p { margin: 0.25rem 0; }
+105
View File
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { ROLE_LABEL } from '#shared/types'
const props = defineProps<{ agent: AgentItem, builtin: string }>()
const store = useProjectStore()
const api = useApi()
const form = reactive({
name: props.agent.name,
modelId: props.agent.modelId,
systemPrompt: props.agent.systemPrompt,
styleNotes: props.agent.styleNotes,
enabled: props.agent.enabled
})
watch(() => props.agent, (a) => {
Object.assign(form, { name: a.name, modelId: a.modelId, systemPrompt: a.systemPrompt, styleNotes: a.styleNotes, enabled: a.enabled })
})
const dirty = computed(() => form.name !== props.agent.name || form.modelId !== props.agent.modelId || form.systemPrompt !== props.agent.systemPrompt || form.styleNotes !== props.agent.styleNotes || form.enabled !== props.agent.enabled)
const saving = ref(false)
async function save() {
saving.value = true
try {
await api.put(`/api/agents/${props.agent.id}`, { ...form })
await store.refreshAgents()
api.toast.add({ title: `${form.name} 已保存`, color: 'success' })
}
finally {
saving.value = false
}
}
async function remove() {
if (!window.confirm(`删除作家「${props.agent.name}」?`)) return
await api.del(`/api/agents/${props.agent.id}`)
await store.refreshAgents()
}
const modelItems = computed(() => store.models.value.map(m => ({ label: `${m.providerName} / ${m.label}`, value: m.id })))
const showBuiltin = ref(false)
const ICON = { editor: 'i-lucide-crown', writer: 'i-lucide-pen-tool', summarizer: 'i-lucide-notebook-pen' } as const
const DESC = {
editor: '负责规划章节大纲、为每章撰写简报、选派作家、审稿。建议使用推理能力强的模型(如 Claude)。',
writer: '根据主编的简报写出章节正文。可以创建多位风格不同的作家,使用不同模型。',
summarizer: '章节确认后提炼摘要并更新人物状态、伏笔等故事记忆。可以用便宜快速的模型;未配置时使用主编的模型。'
} as const
</script>
<template>
<UCard :class="form.enabled ? '' : 'opacity-60'">
<template #header>
<div class="flex items-center gap-3">
<UIcon :name="ICON[agent.role]" class="size-5 text-primary" />
<UInput v-model="form.name" variant="ghost" class="font-semibold max-w-56" />
<UBadge variant="subtle" size="sm">
{{ ROLE_LABEL[agent.role] }}
</UBadge>
<UBadge v-if="agent.createdBy === 'editor'" color="info" variant="subtle" size="sm">
主编创建
</UBadge>
<div class="ms-auto flex items-center gap-2">
<USwitch v-if="agent.role === 'writer'" v-model="form.enabled" size="sm" label="启用" />
<UButton v-if="agent.role === 'writer'" icon="i-lucide-trash-2" size="xs" color="error" variant="ghost" @click="remove" />
</div>
</div>
</template>
<div class="space-y-4">
<p class="text-xs text-muted">
{{ DESC[agent.role] }}
</p>
<div class="grid sm:grid-cols-2 gap-4">
<UFormField label="模型" required>
<USelect
:model-value="form.modelId ?? undefined"
:items="modelItems"
placeholder="选择模型"
@update:model-value="(v: string) => (form.modelId = v)"
class="w-full"
/>
</UFormField>
<UFormField v-if="agent.role === 'writer'" label="擅长风格" hint="主编选派作家时参考">
<UInput v-model="form.styleNotes" placeholder="例如:细腻的情感描写、快节奏动作戏" class="w-full" />
</UFormField>
</div>
<UFormField label="自定义系统提示词" hint="最高优先级,与内置提示词冲突时以此为准">
<UTextarea
v-model="form.systemPrompt"
autoresize
:rows="4"
:maxrows="20"
class="w-full"
:placeholder="agent.role === 'writer' ? '例如:你的文字冷峻克制,擅长用白描和短句营造张力;对话不加多余的动作提示……' : agent.role === 'editor' ? '例如:每章必须有一个明确的小高潮;感情线推进要克制……' : '例如:摘要要保留关键对白原句……'"
/>
</UFormField>
<div>
<UButton :label="showBuiltin ? '收起内置提示词' : '查看内置提示词'" :icon="showBuiltin ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" size="xs" color="neutral" variant="link" class="px-0" @click="showBuiltin = !showBuiltin" />
<pre v-if="showBuiltin" class="mt-2 text-xs whitespace-pre-wrap rounded-md bg-elevated p-3 text-muted">{{ builtin }}</pre>
</div>
<div class="flex justify-end">
<UButton label="保存" icon="i-lucide-save" size="sm" :loading="saving" :disabled="!dirty" @click="save" />
</div>
</div>
</UCard>
</template>
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
/** 酒馆角色卡导入:上传 → 预览解析结果 → 选择映射方式 → 导入 */
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ imported: [] }>()
const store = useProjectStore()
const api = useApi()
const file = ref<File | null>(null)
const preview = ref<CardPreview | null>(null)
const parsing = ref(false)
const importing = ref(false)
const parseError = ref('')
const avatarUrl = ref('')
const opts = reactive({
scenarioToWorld: true,
importLore: true,
firstMesAsSample: true,
systemPromptToNotes: false,
userName: '',
identity: ''
})
watch(open, (v) => {
if (!v) return
file.value = null
preview.value = null
parseError.value = ''
opts.userName = store.project.value?.userName ?? ''
opts.identity = ''
})
watch(file, async (f) => {
preview.value = null
parseError.value = ''
if (avatarUrl.value) URL.revokeObjectURL(avatarUrl.value)
avatarUrl.value = f && f.type.startsWith('image/') ? URL.createObjectURL(f) : ''
if (!f) return
parsing.value = true
try {
const form = new FormData()
form.append('file', f)
preview.value = await $fetch<CardPreview>(`/api/projects/${store.projectId}/cards/preview`, { method: 'POST', body: form })
}
catch (err) {
parseError.value = apiErrorMessage(err, '解析失败')
}
finally {
parsing.value = false
}
})
async function doImport() {
if (!file.value) return
importing.value = true
try {
const form = new FormData()
form.append('file', file.value)
form.append('options', JSON.stringify(opts))
const res = await api.upload<{ name: string, loreCount: number }>(`/api/projects/${store.projectId}/cards/import`, form)
api.toast.add({ title: `已导入角色「${res.name}」`, description: res.loreCount ? `同时导入 ${res.loreCount} 条世界书条目` : undefined, color: 'success', icon: 'i-lucide-user-check' })
open.value = false
await store.refreshProject()
emit('imported')
}
finally {
importing.value = false
}
}
const preview160 = (s: string) => (s.length > 160 ? `${s.slice(0, 160)}…` : s)
</script>
<template>
<UModal v-model:open="open" title="导入酒馆角色卡" description="支持 SillyTavern 的 PNG 角色卡(V1/V2/V3)和 JSON 文件" :ui="{ content: 'max-w-3xl' }">
<template #body>
<div class="space-y-4">
<UFileUpload
v-model="file"
accept="image/png,application/json,.json,.png"
label="拖入角色卡文件,或点击选择"
description="请使用原始 PNG 文件:截图或经聊天软件压缩的图片会丢失角色数据"
icon="i-lucide-id-card"
layout="list"
class="w-full"
:ui="{ base: 'min-h-28' }"
/>
<div v-if="parsing" class="flex items-center gap-2 text-sm text-muted">
<UIcon name="i-lucide-loader-circle" class="animate-spin" />正在解析…
</div>
<UAlert v-if="parseError" color="error" variant="subtle" icon="i-lucide-circle-alert" :description="parseError" />
<template v-if="preview">
<div class="flex gap-4 rounded-lg border border-default p-4">
<img v-if="avatarUrl" :src="avatarUrl" class="w-24 h-32 object-cover rounded-md shrink-0" alt="">
<div class="min-w-0 space-y-1.5 text-sm">
<div class="flex items-center gap-2">
<span class="text-lg font-semibold">{{ preview.name }}</span>
<UBadge variant="subtle" size="sm">
{{ preview.spec.toUpperCase() }}
</UBadge>
<UBadge v-for="t in preview.tags.slice(0, 5)" :key="t" variant="outline" color="neutral" size="sm">
{{ t }}
</UBadge>
</div>
<p v-if="preview.description" class="text-muted whitespace-pre-wrap">
<b class="text-default">描述</b> {{ preview160(preview.description) }}
</p>
<p v-if="preview.personality" class="text-muted">
<b class="text-default">性格</b> {{ preview160(preview.personality) }}
</p>
<p v-if="preview.scenario" class="text-muted">
<b class="text-default">场景</b> {{ preview160(preview.scenario) }}
</p>
<p v-if="preview.firstMes" class="text-muted">
<b class="text-default">开场白</b> {{ preview160(preview.firstMes) }}
</p>
<p v-if="preview.world" class="text-muted">
<b class="text-default">关联世界书</b> {{ preview.world }}
</p>
<p class="text-muted">
<b class="text-default">内置世界书</b> {{ preview.lore.length }} 条
<template v-if="preview.lore.length">
(常驻 {{ preview.lore.filter(l => l.constant).length }} 条)
</template>
</p>
</div>
</div>
<div class="grid sm:grid-cols-2 gap-3">
<UFormField label="角色身份" hint="可选">
<UInput v-model="opts.identity" placeholder="例如:女主角" class="w-full" />
</UFormField>
<UFormField label="{{user}} 替换为">
<UInput v-model="opts.userName" placeholder="主角" class="w-full" />
</UFormField>
</div>
<div class="grid sm:grid-cols-2 gap-3">
<UCheckbox v-model="opts.scenarioToWorld" label="场景(scenario)追加到故事背景" :disabled="!preview.scenario" />
<UCheckbox v-model="opts.importLore" :label="`导入内置世界书(${preview.lore.length} 条)`" :disabled="!preview.lore.length" />
<UCheckbox v-model="opts.firstMesAsSample" label="开场白作为文风/说话示例" :disabled="!preview.firstMes" />
<UCheckbox v-model="opts.systemPromptToNotes" label="卡片系统提示词写入角色备注" :disabled="!preview.systemPrompt && !preview.postHistoryInstructions" />
</div>
</template>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="导入" icon="i-lucide-download" :loading="importing" :disabled="!preview" @click="doImport" />
</div>
</template>
</UModal>
</template>
+404
View File
@@ -0,0 +1,404 @@
<script setup lang="ts">
import type { TabsItem } from '@nuxt/ui'
import { CHAPTER_STATUS_COLOR, CHAPTER_STATUS_LABEL } from '#shared/types'
const props = defineProps<{ chapterId: string }>()
const store = useProjectStore()
const api = useApi()
const { running, live, chapterRev, agents } = store
const chapter = ref<ChapterDetail | null>(null)
const loading = ref(true)
async function load() {
try {
chapter.value = await api.get<ChapterDetail>(`/api/chapters/${props.chapterId}`)
}
finally {
loading.value = false
}
}
load()
watch(() => chapterRev[props.chapterId], () => { if (!editing.value && !editingBrief.value) load() })
const writers = computed(() => agents.value.filter(a => a.role === 'writer'))
const writerItems = computed(() => writers.value.map(w => ({ label: w.name + (w.createdBy === 'editor' ? '(主编创建)' : ''), value: w.id })))
const currentWriter = computed(() => agents.value.find(a => a.id === chapter.value?.writerAgentId))
const isWriting = computed(() => chapter.value?.status === 'writing')
const liveText = computed(() => live[props.chapterId]?.text ?? '')
const displayText = computed(() => (isWriting.value ? liveText.value : chapter.value?.content ?? ''))
const activeVersion = computed(() => chapter.value?.versions.find(v => v.id === chapter.value?.activeVersionId))
/* ---------- 标题 ---------- */
const titleDraft = ref('')
watch(() => chapter.value?.title, v => (titleDraft.value = v ?? ''), { immediate: true })
async function saveTitle() {
if (!chapter.value || titleDraft.value === chapter.value.title) return
await api.put(`/api/chapters/${props.chapterId}`, { title: titleDraft.value })
}
/* ---------- 作家指派 ---------- */
async function assignWriter(id: string) {
await api.put(`/api/chapters/${props.chapterId}`, { writerAgentId: id, writerLocked: true })
}
async function unlockWriter() {
await api.put(`/api/chapters/${props.chapterId}`, { writerLocked: false })
}
/* ---------- 正文:实时滚动 ---------- */
const textBox = ref<HTMLElement | null>(null)
const followTail = ref(true)
function onScroll() {
const el = textBox.value
if (!el) return
followTail.value = el.scrollHeight - el.scrollTop - el.clientHeight < 80
}
watch(liveText, async () => {
if (!isWriting.value || !followTail.value) return
await nextTick()
textBox.value?.scrollTo({ top: textBox.value.scrollHeight })
})
/* ---------- 正文:手动编辑 ---------- */
const editing = ref(false)
const contentDraft = ref('')
function startEdit() {
contentDraft.value = chapter.value?.content ?? ''
editing.value = true
}
async function saveContent() {
if (!chapter.value?.activeVersionId) return
await api.put(`/api/versions/${chapter.value.activeVersionId}`, { content: contentDraft.value })
editing.value = false
await load()
api.toast.add({ title: '已保存到文件', color: 'success', icon: 'i-lucide-save' })
}
/* ---------- 简报 ---------- */
const editingBrief = ref(false)
const briefDraft = ref('')
function startEditBrief() {
briefDraft.value = chapter.value?.brief || chapter.value?.synopsis || ''
editingBrief.value = true
}
async function saveBrief() {
await api.put(`/api/chapters/${props.chapterId}`, { brief: briefDraft.value })
editingBrief.value = false
await load()
}
const synopsisDraft = ref('')
watch(() => chapter.value?.synopsis, v => (synopsisDraft.value = v ?? ''), { immediate: true })
async function saveSynopsis() {
if (synopsisDraft.value === chapter.value?.synopsis) return
await api.put(`/api/chapters/${props.chapterId}`, { synopsis: synopsisDraft.value })
}
/* ---------- 动作 ---------- */
const act = (path: string, body?: unknown) => api.post(`/api/chapters/${props.chapterId}/${path}`, body)
const genBrief = () => act('brief')
const write = () => act('write', {})
const approve = (next: boolean) => act('approve', { continue: next })
const review = async () => { await act('review'); tab.value = 'review' }
const reopen = () => act('reopen')
const reviseOpen = ref(false)
const reviseForm = reactive({ feedback: '', mode: 'revise' as 'revise' | 'rewrite', writerAgentId: '' })
function openRevise(useReview = false) {
reviseForm.feedback = useReview ? (chapter.value?.reviewNotes ?? '') : ''
reviseForm.mode = chapter.value?.activeVersionId ? 'revise' : 'rewrite'
reviseForm.writerAgentId = chapter.value?.writerAgentId ?? writers.value[0]?.id ?? ''
reviseOpen.value = true
}
async function submitRevise() {
await act('write', { feedback: reviseForm.feedback, revise: reviseForm.mode === 'revise', writerAgentId: reviseForm.writerAgentId || null })
reviseOpen.value = false
tab.value = 'text'
}
async function activate(versionId: string) {
await act('activate', { versionId })
tab.value = 'text'
}
const viewing = ref<{ version: number, content: string } | null>(null)
async function viewVersion(id: string) {
const v = await api.get<{ version: number, content: string }>(`/api/versions/${id}`)
viewing.value = v
}
const deleteOpen = ref(false)
async function deleteChapter() {
await api.del(`/api/chapters/${props.chapterId}`)
deleteOpen.value = false
await store.refreshChapters()
}
/* ---------- 摘要 ---------- */
const summaryDraft = ref('')
watch(() => chapter.value?.summary, v => (summaryDraft.value = v ?? ''), { immediate: true })
async function saveSummary() {
await api.put(`/api/chapters/${props.chapterId}`, { summary: summaryDraft.value })
api.toast.add({ title: '摘要已保存', color: 'success' })
}
const tab = ref('text')
const tabs = computed<TabsItem[]>(() => [
{ label: '正文', value: 'text', icon: 'i-lucide-file-text' },
{ label: '简报', value: 'brief', icon: 'i-lucide-clipboard-list', badge: chapter.value?.brief ? undefined : { label: '空', color: 'neutral', variant: 'subtle' } },
{ label: `版本 (${chapter.value?.versions.length ?? 0})`, value: 'versions', icon: 'i-lucide-history' },
{ label: '审稿与摘要', value: 'review', icon: 'i-lucide-message-square-text' }
])
// 没有正文时默认显示简报
watch(() => chapter.value?.id, () => {
if (chapter.value && !chapter.value.activeVersionId && chapter.value.status !== 'writing') tab.value = 'brief'
}, { immediate: true })
watch(isWriting, (v) => { if (v) tab.value = 'text' })
const VERSION_STATUS: Record<string, { label: string, color: 'success' | 'warning' | 'error' | 'neutral' }> = {
done: { label: '完成', color: 'success' },
writing: { label: '写作中', color: 'warning' },
error: { label: '出错', color: 'error' },
cancelled: { label: '已取消', color: 'neutral' }
}
</script>
<template>
<div v-if="loading" class="flex justify-center py-20">
<UIcon name="i-lucide-loader-circle" class="size-6 animate-spin text-muted" />
</div>
<div v-else-if="chapter" class="flex flex-col gap-4 h-full">
<!-- 头部 -->
<div class="flex flex-wrap items-center gap-3">
<span class="text-muted font-medium shrink-0">第{{ chapter.index }}章</span>
<UInput
v-model="titleDraft"
variant="ghost"
placeholder="章节标题"
class="flex-1 min-w-48"
:ui="{ base: 'text-lg font-semibold' }"
@blur="saveTitle"
@keydown.enter="($event.target as HTMLInputElement).blur()"
/>
<UBadge :color="CHAPTER_STATUS_COLOR[chapter.status]" variant="subtle">
{{ CHAPTER_STATUS_LABEL[chapter.status] }}
</UBadge>
<div class="flex items-center gap-1">
<USelect
:model-value="chapter.writerAgentId ?? undefined"
:items="writerItems"
placeholder="由主编指派作家"
size="sm"
class="w-48"
icon="i-lucide-pen-tool"
:disabled="isWriting"
@update:model-value="assignWriter"
/>
<UTooltip v-if="chapter.writerLocked" text="已锁定作家(主编不会更换)。点击交给主编决定">
<UButton icon="i-lucide-lock" size="sm" color="neutral" variant="ghost" @click="unlockWriter" />
</UTooltip>
</div>
<UDropdownMenu :items="[[{ label: '删除本章', icon: 'i-lucide-trash-2', color: 'error', onSelect: () => (deleteOpen = true) }]]">
<UButton icon="i-lucide-ellipsis-vertical" size="sm" color="neutral" variant="ghost" />
</UDropdownMenu>
</div>
<!-- 动作条 -->
<div class="flex flex-wrap items-center gap-2 rounded-lg bg-elevated/50 border border-default px-3 py-2">
<template v-if="isWriting">
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin text-warning" />
<span class="text-sm">
{{ currentWriter?.name || '作家' }} 正在写作,已写 {{ liveText.replace(/\s/g, '').length }} 字,同步写入
<code class="text-xs">{{ chapter.versions[0]?.filePath }}</code>
</span>
<UButton class="ms-auto" icon="i-lucide-square" label="停止" size="sm" color="error" variant="soft" @click="store.stop()" />
</template>
<template v-else-if="chapter.status === 'written'">
<span class="text-sm text-muted me-auto">本章已写完,请阅读后确认。确认后会生成摘要、更新故事记忆。</span>
<UButton icon="i-lucide-check-check" label="确认并继续下一章" size="sm" :disabled="running" @click="approve(true)" />
<UButton icon="i-lucide-check" label="仅确认" size="sm" variant="soft" :disabled="running" @click="approve(false)" />
<UButton icon="i-lucide-scan-search" label="主编审稿" size="sm" color="neutral" variant="outline" :disabled="running" @click="review" />
<UButton icon="i-lucide-wand-sparkles" label="修改 / 重写" size="sm" color="neutral" variant="outline" :disabled="running" @click="openRevise()" />
</template>
<template v-else-if="chapter.status === 'approved'">
<UIcon name="i-lucide-circle-check" class="size-4 text-success" />
<span class="text-sm text-muted me-auto">本章已确认</span>
<UButton icon="i-lucide-wand-sparkles" label="修改 / 重写" size="sm" color="neutral" variant="outline" :disabled="running" @click="openRevise()" />
<UButton icon="i-lucide-undo-2" label="撤销确认" size="sm" color="neutral" variant="ghost" :disabled="running" @click="reopen" />
</template>
<template v-else>
<span class="text-sm text-muted me-auto">
{{ chapter.brief ? '简报已就绪,可以开始写作,也可以先修改简报。' : '还没有简报:可以让主编生成,也可以手动填写。' }}
</span>
<UButton icon="i-lucide-sparkles" :label="chapter.brief ? '主编重写简报' : '主编生成简报'" size="sm" :variant="chapter.brief ? 'soft' : 'solid'" :disabled="running" @click="genBrief" />
<UButton icon="i-lucide-pen-line" label="开始写作" size="sm" :variant="chapter.brief ? 'solid' : 'soft'" :disabled="running || (!chapter.brief && !chapter.synopsis)" @click="write" />
</template>
</div>
<UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" />
<!-- 正文 -->
<div v-if="tab === 'text'" class="flex-1 min-h-0 flex flex-col gap-2">
<div v-if="activeVersion && !isWriting" class="flex items-center gap-2 text-xs text-muted">
<span>v{{ activeVersion.version }} · {{ activeVersion.writerName }} · {{ activeVersion.modelLabel }} · {{ activeVersion.wordCount }} 字</span>
<code class="truncate">{{ activeVersion.filePath }}</code>
<div class="ms-auto flex gap-1">
<template v-if="editing">
<UButton label="取消" size="xs" color="neutral" variant="ghost" @click="editing = false" />
<UButton label="保存" icon="i-lucide-save" size="xs" @click="saveContent" />
</template>
<UButton v-else label="编辑正文" icon="i-lucide-pencil" size="xs" color="neutral" variant="outline" :disabled="running" @click="startEdit" />
</div>
</div>
<UTextarea v-if="editing" v-model="contentDraft" autoresize :rows="20" :maxrows="0" class="w-full" :ui="{ base: 'novel-text' }" />
<div
v-else-if="displayText"
ref="textBox"
class="overflow-y-auto rounded-lg border border-default bg-default px-8 py-6 max-h-[calc(100vh-18rem)]"
@scroll="onScroll"
>
<NovelText :text="displayText" :writing="isWriting" class="max-w-3xl mx-auto" />
</div>
<div v-else class="text-center text-muted py-16 text-sm">
{{ isWriting ? '等待模型输出…' : '还没有正文' }}
</div>
</div>
<!-- 简报 -->
<div v-else-if="tab === 'brief'" class="space-y-4">
<UFormField label="章节梗概" hint="来自大纲规划,可修改">
<UTextarea v-model="synopsisDraft" autoresize :rows="2" class="w-full" @blur="saveSynopsis" />
</UFormField>
<div class="flex items-center gap-2">
<h3 class="font-medium">
章节简报
</h3>
<span class="text-xs text-muted">主编写给作家的执行指令,作家会严格按此写作</span>
<div class="ms-auto flex gap-1">
<template v-if="editingBrief">
<UButton label="取消" size="xs" color="neutral" variant="ghost" @click="editingBrief = false" />
<UButton label="保存简报" icon="i-lucide-save" size="xs" @click="saveBrief" />
</template>
<template v-else>
<UButton label="编辑" icon="i-lucide-pencil" size="xs" color="neutral" variant="outline" :disabled="isWriting" @click="startEditBrief" />
<UButton :label="chapter.brief ? '主编重写' : '主编生成'" icon="i-lucide-sparkles" size="xs" variant="soft" :disabled="running" @click="genBrief" />
</template>
</div>
</div>
<UTextarea v-if="editingBrief" v-model="briefDraft" autoresize :rows="14" :maxrows="0" class="w-full font-mono text-sm" placeholder="支持 Markdown。写清楚本章目标、情节要点、出场人物、场景、基调、结尾钩子等。" />
<div v-else-if="chapter.brief" class="prose-brief text-sm rounded-lg border border-default px-5 py-4" v-html="renderMarkdown(chapter.brief)" />
<div v-else class="text-sm text-muted rounded-lg border border-dashed border-default px-5 py-8 text-center">
暂无简报
</div>
<UAlert v-if="chapter.editorNotes" icon="i-lucide-message-circle" color="neutral" variant="subtle" title="主编说明" :description="chapter.editorNotes" :ui="{ description: 'whitespace-pre-wrap' }" />
</div>
<!-- 版本 -->
<div v-else-if="tab === 'versions'" class="space-y-2">
<div v-if="!chapter.versions.length" class="text-sm text-muted py-8 text-center">
还没有版本
</div>
<div v-for="v in chapter.versions" :key="v.id" class="flex flex-wrap items-center gap-3 rounded-lg border border-default px-4 py-3" :class="v.id === chapter.activeVersionId ? 'ring-1 ring-primary' : ''">
<span class="font-medium">v{{ v.version }}</span>
<UBadge :color="VERSION_STATUS[v.status]?.color" variant="subtle" size="sm">
{{ VERSION_STATUS[v.status]?.label }}
</UBadge>
<UBadge v-if="v.id === chapter.activeVersionId" color="primary" variant="soft" size="sm">
当前
</UBadge>
<span class="text-sm">{{ v.writerName }}</span>
<span class="text-xs text-muted">{{ v.modelLabel }} · {{ v.wordCount }} 字 · {{ formatTime(v.createdAt) }}</span>
<div class="ms-auto flex gap-1">
<UButton label="查看" size="xs" color="neutral" variant="ghost" @click="viewVersion(v.id)" />
<UButton v-if="v.id !== chapter.activeVersionId && v.status !== 'writing'" label="设为当前" size="xs" variant="soft" :disabled="running" @click="activate(v.id)" />
</div>
<p v-if="v.feedback" class="w-full text-xs text-muted whitespace-pre-wrap line-clamp-3">
修改意见:{{ v.feedback }}
</p>
</div>
</div>
<!-- 审稿与摘要 -->
<div v-else class="space-y-6">
<div class="space-y-2">
<div class="flex items-center gap-2">
<h3 class="font-medium">
主编审稿意见
</h3>
<div class="ms-auto flex gap-1">
<UButton v-if="chapter.reviewNotes" label="按审稿意见修改" icon="i-lucide-wand-sparkles" size="xs" variant="soft" :disabled="running" @click="openRevise(true)" />
<UButton label="主编审稿" icon="i-lucide-scan-search" size="xs" color="neutral" variant="outline" :disabled="running || !chapter.activeVersionId" @click="review" />
</div>
</div>
<div v-if="chapter.reviewNotes" class="prose-brief text-sm rounded-lg border border-default px-5 py-4" v-html="renderMarkdown(chapter.reviewNotes)" />
<p v-else class="text-sm text-muted">
暂无审稿意见
</p>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<h3 class="font-medium">
本章摘要
</h3>
<span class="text-xs text-muted">确认章节时自动生成;主编和后续作家只会看到摘要而不是全文</span>
<UButton class="ms-auto" label="保存" icon="i-lucide-save" size="xs" variant="soft" :disabled="summaryDraft === chapter.summary" @click="saveSummary" />
</div>
<UTextarea v-model="summaryDraft" autoresize :rows="6" class="w-full" placeholder="确认章节后自动生成" />
</div>
</div>
</div>
<!-- 修改 / 重写 -->
<UModal v-model:open="reviseOpen" title="修改 / 重写本章" :ui="{ content: 'max-w-2xl' }">
<template #body>
<div class="space-y-4">
<UFormField label="方式">
<URadioGroup
v-model="reviseForm.mode"
orientation="horizontal"
:items="[
{ label: '在当前版本上修改', value: 'revise', disabled: !chapter?.activeVersionId },
{ label: '按简报重新写一版', value: 'rewrite' }
]"
/>
</UFormField>
<UFormField label="执笔作家">
<USelect v-model="reviseForm.writerAgentId" :items="writerItems" class="w-full" />
</UFormField>
<UFormField label="修改意见" :hint="reviseForm.mode === 'revise' ? '必填' : '可选'">
<UTextarea v-model="reviseForm.feedback" autoresize :rows="6" :maxrows="16" class="w-full" placeholder="例如:节奏太快,第二场对话要更有张力;结尾不要点破悬念……" />
</UFormField>
<p class="text-xs text-muted">
新内容会保存为新版本,旧版本仍可在「版本」中查看和恢复。
</p>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="开始" icon="i-lucide-play" :disabled="running || (reviseForm.mode === 'revise' && !reviseForm.feedback.trim())" @click="submitRevise" />
</div>
</template>
</UModal>
<USlideover :open="!!viewing" :title="viewing ? `版本 v${viewing.version}` : ''" :ui="{ content: 'max-w-3xl' }" @update:open="v => !v && (viewing = null)">
<template #body>
<NovelText :text="viewing?.content ?? ''" />
</template>
</USlideover>
<UModal v-model:open="deleteOpen" title="删除章节">
<template #body>
<p>确定删除第{{ chapter?.index }}章吗?之后的章节序号会前移。已生成的文件会保留在磁盘上。</p>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="删除" color="error" @click="deleteChapter" />
</div>
</template>
</UModal>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
/** 小说正文排版:按段落渲染,首行缩进 */
const props = defineProps<{ text: string, writing?: boolean }>()
const paragraphs = computed(() => props.text.split(/\n+/).map(s => s.trim()).filter(Boolean))
</script>
<template>
<div class="novel-body">
<p v-for="(p, i) in paragraphs" :key="i">
{{ p }}<span v-if="writing && i === paragraphs.length - 1" class="novel-cursor" />
</p>
<p v-if="writing && !paragraphs.length">
<span class="novel-cursor" />
</p>
</div>
</template>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
/** 流水线控制:继续写作 / 停止 / 模式切换 */
const store = useProjectStore()
const { project, running, runningLabel, connected } = store
const settings = computed(() => project.value!.settings)
const pendingChapter = computed(() => store.chapters.value.find(c => c.status === 'written'))
async function toggleAuto(v: boolean) {
await store.saveProject({ settings: { ...settings.value, autoMode: v } })
}
const starting = ref(false)
async function run() {
starting.value = true
try { await store.run() }
finally { starting.value = false }
}
</script>
<template>
<div class="flex items-center gap-2">
<UTooltip :text="connected ? '实时连接正常' : '实时连接断开,正在重连…'">
<span class="size-2 rounded-full" :class="connected ? 'bg-success' : 'bg-error animate-pulse'" />
</UTooltip>
<USwitch
:model-value="settings.autoMode"
label="自动模式"
:disabled="running"
size="sm"
@update:model-value="toggleAuto"
/>
<template v-if="running">
<UBadge color="warning" variant="subtle" icon="i-lucide-loader-circle" :ui="{ leadingIcon: 'animate-spin' }" class="max-w-64 truncate">
{{ runningLabel || '运行中' }}
</UBadge>
<UButton icon="i-lucide-square" label="停止" color="error" variant="soft" size="sm" @click="store.stop()" />
</template>
<UTooltip v-else :text="pendingChapter ? `第${pendingChapter.index}章待确认,请先在章节中确认` : (settings.autoMode ? `自动连续写作,最多 ${settings.autoChapterLimit} 章` : '主编规划/写简报 → 作家写下一章,写完后暂停等你确认')">
<UButton
icon="i-lucide-play"
:label="settings.autoMode ? '开始自动写作' : '继续下一章'"
size="sm"
:loading="starting"
:disabled="!!pendingChapter && !settings.autoMode"
@click="run"
/>
</UTooltip>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
// 使用宽松签名,返回类型由调用方通过泛型指定
const rawFetch = $fetch as unknown as <T>(url: string, opts?: { method?: Method, body?: unknown }) => Promise<T>
/** 统一的接口调用:自动弹出错误提示 */
export function useApi() {
const toast = useToast()
async function call<T = unknown>(url: string, method: Method = 'GET', body?: unknown, opts: { silent?: boolean, title?: string } = {}): Promise<T> {
try {
return await rawFetch<T>(url, { method, body })
}
catch (err) {
if (!opts.silent) toast.add({ title: opts.title ?? '操作失败', description: apiErrorMessage(err), color: 'error', icon: 'i-lucide-circle-alert' })
throw err
}
}
return {
get: <T>(url: string) => call<T>(url, 'GET'),
post: <T>(url: string, body?: unknown, opts?: { silent?: boolean }) => call<T>(url, 'POST', body ?? {}, opts),
put: <T>(url: string, body?: unknown) => call<T>(url, 'PUT', body ?? {}),
del: <T>(url: string) => call<T>(url, 'DELETE'),
upload: <T>(url: string, form: FormData) => call<T>(url, 'POST', form, { title: '上传失败' }),
toast
}
}
+121
View File
@@ -0,0 +1,121 @@
import type { InjectionKey } from 'vue'
import type { JobInfo, ProjectEvent } from '#shared/types'
/** 项目工作区的共享状态:数据 + SSE 实时事件 */
export function createProjectStore(projectId: string) {
const api = useApi()
const project = ref<ProjectDetail | null>(null)
const chapters = ref<ChapterListItem[]>([])
const agents = ref<AgentItem[]>([])
const models = ref<ModelItem[]>([])
const jobs = ref<JobInfo[]>([])
const running = ref(false)
const runningLabel = ref('')
const connected = ref(false)
/** 正在写作的章节实时文本:chapterId → { versionId, text } */
const live = reactive<Record<string, { versionId: string, text: string }>>({})
/** 章节变更计数,章节详情组件据此刷新 */
const chapterRev = reactive<Record<string, number>>({})
const refreshProject = async () => { project.value = await api.get<ProjectDetail>(`/api/projects/${projectId}`) }
const refreshChapters = async () => { chapters.value = await api.get<ChapterListItem[]>(`/api/projects/${projectId}/chapters`) }
const refreshAgents = async () => { agents.value = await api.get<AgentItem[]>(`/api/projects/${projectId}/agents`) }
const refreshModels = async () => { models.value = await api.get<ModelItem[]>('/api/models') }
const refreshJobs = async () => { jobs.value = await api.get<JobInfo[]>(`/api/projects/${projectId}/jobs?limit=100`) }
let chapterTimer: ReturnType<typeof setTimeout> | null = null
const refreshChaptersSoon = () => {
if (chapterTimer) clearTimeout(chapterTimer)
chapterTimer = setTimeout(() => { refreshChapters().catch(() => {}) }, 150)
}
function handle(e: ProjectEvent) {
switch (e.type) {
case 'job': {
const i = jobs.value.findIndex(j => j.id === e.job.id)
if (i >= 0) jobs.value[i] = e.job
else jobs.value = [e.job, ...jobs.value].slice(0, 200)
if (e.job.status === 'error') {
api.toast.add({ title: `${e.job.agentName || '任务'}出错`, description: e.job.error.slice(0, 300), color: 'error', icon: 'i-lucide-circle-alert' })
}
break
}
case 'delta': {
const cur = live[e.chapterId]
if (!cur || cur.versionId !== e.versionId) live[e.chapterId] = { versionId: e.versionId, text: e.text }
else cur.text += e.text
break
}
case 'chapter':
chapterRev[e.chapterId] = (chapterRev[e.chapterId] ?? 0) + 1
refreshChaptersSoon()
break
case 'chapters':
refreshChaptersSoon()
break
case 'agents':
refreshAgents().catch(() => {})
break
case 'pipeline':
running.value = e.running
runningLabel.value = e.message ?? ''
if (!e.running) {
refreshProject().catch(() => {})
refreshChaptersSoon()
}
break
case 'log':
api.toast.add({ title: e.level === 'error' ? '出错了' : '提示', description: e.message.slice(0, 400), color: e.level === 'error' ? 'error' : e.level === 'warn' ? 'warning' : 'info' })
break
}
}
let source: EventSource | null = null
function connect() {
if (source) return
source = new EventSource(`/api/projects/${projectId}/events`)
source.onopen = () => {
connected.value = true
// 断线重连后补齐可能错过的状态
refreshChapters().catch(() => {})
refreshJobs().catch(() => {})
}
source.onerror = () => { connected.value = false }
source.onmessage = (msg) => {
try { handle(JSON.parse(msg.data) as ProjectEvent) }
catch (err) { console.error('bad event', err) }
}
}
function disconnect() {
source?.close()
source = null
connected.value = false
}
async function load() {
await Promise.all([refreshProject(), refreshChapters(), refreshAgents(), refreshModels(), refreshJobs()])
running.value = Boolean(project.value?.running)
runningLabel.value = project.value?.running?.label ?? ''
}
async function saveProject(patch: Record<string, unknown>) {
project.value = { ...project.value!, ...(await api.put<ProjectDetail>(`/api/projects/${projectId}`, patch)), running: project.value?.running ?? null }
}
async function run() { await api.post(`/api/projects/${projectId}/run`) }
async function stop() { await api.post(`/api/projects/${projectId}/stop`) }
return {
projectId, project, chapters, agents, models, jobs, running, runningLabel, connected, live, chapterRev,
load, connect, disconnect, refreshProject, refreshChapters, refreshAgents, refreshModels, refreshJobs, saveProject, run, stop
}
}
export type ProjectStore = ReturnType<typeof createProjectStore>
export const projectStoreKey: InjectionKey<ProjectStore> = Symbol('project-store')
export function useProjectStore() {
const store = inject(projectStoreKey)
if (!store) throw new Error('useProjectStore 必须在项目页面内使用')
return store
}
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui'
const route = useRoute()
const projectId = computed(() => (route.params.id as string | undefined) ?? null)
const { data: projectTitle } = useAsyncData(
'layout-project-title',
async () => (projectId.value ? (await $fetch<ProjectDetail>(`/api/projects/${projectId.value}`)).title : ''),
{ watch: [projectId], default: () => '' }
)
const mainItems = computed<NavigationMenuItem[]>(() => [
{ label: '我的作品', icon: 'i-lucide-library', to: '/', exact: true },
{ label: '模型与 API', icon: 'i-lucide-plug-zap', to: '/settings' }
])
const projectItems = computed<NavigationMenuItem[]>(() => {
if (!projectId.value) return []
const base = `/projects/${projectId.value}`
return [
{ label: '写作台', icon: 'i-lucide-pen-line', to: base, exact: true },
{ label: '故事设定', icon: 'i-lucide-book-open', to: `${base}/setting` },
{ label: '角色与世界书', icon: 'i-lucide-users', to: `${base}/characters` },
{ label: 'AI 团队', icon: 'i-lucide-bot', to: `${base}/team` },
{ label: '故事记忆', icon: 'i-lucide-brain', to: `${base}/memory` },
{ label: '任务日志', icon: 'i-lucide-list-checks', to: `${base}/jobs` }
]
})
const colorMode = useColorMode()
const isDark = computed({
get: () => colorMode.value === 'dark',
set: v => (colorMode.preference = v ? 'dark' : 'light')
})
</script>
<template>
<UDashboardGroup unit="rem">
<UDashboardSidebar collapsible resizable :default-size="15" :ui="{ footer: 'border-t border-default' }">
<template #header="{ collapsed }">
<NuxtLink to="/" class="flex items-center gap-2 font-semibold">
<UIcon name="i-lucide-feather" class="size-5 text-primary shrink-0" />
<span v-if="!collapsed" class="truncate">AI 小说工坊</span>
</NuxtLink>
</template>
<template #default="{ collapsed }">
<UNavigationMenu :collapsed="collapsed" :items="mainItems" orientation="vertical" />
<template v-if="projectId">
<USeparator class="my-1" />
<div v-if="!collapsed" class="px-2.5 text-xs text-muted truncate" :title="projectTitle">
{{ projectTitle || '加载中…' }}
</div>
<UNavigationMenu :collapsed="collapsed" :items="projectItems" orientation="vertical" />
</template>
</template>
<template #footer="{ collapsed }">
<UButton
:icon="isDark ? 'i-lucide-moon' : 'i-lucide-sun'"
:label="collapsed ? undefined : (isDark ? '深色模式' : '浅色模式')"
color="neutral"
variant="ghost"
class="w-full"
:block="collapsed"
@click="isDark = !isDark"
/>
</template>
</UDashboardSidebar>
<slot />
</UDashboardGroup>
</template>
+130
View File
@@ -0,0 +1,130 @@
<script setup lang="ts">
const api = useApi()
const { data: projects, refresh } = await useAsyncData('projects', () => api.get<ProjectListItem[]>('/api/projects'), { default: () => [] })
const { data: models } = await useAsyncData('models-count', () => api.get<ModelItem[]>('/api/models'), { default: () => [] })
const createOpen = ref(false)
const form = reactive({ title: '', description: '' })
const creating = ref(false)
async function create() {
if (!form.title.trim()) return
creating.value = true
try {
const { id } = await api.post<{ id: string }>('/api/projects', form)
createOpen.value = false
form.title = ''
form.description = ''
await navigateTo(`/projects/${id}/setting`)
}
finally {
creating.value = false
}
}
const deleting = ref<ProjectListItem | null>(null)
async function confirmDelete() {
if (!deleting.value) return
await api.del(`/api/projects/${deleting.value.id}`)
deleting.value = null
await refresh()
}
function formatTime(t: string | number | Date) {
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
</script>
<template>
<UDashboardPanel id="projects">
<template #header>
<UDashboardNavbar title="我的作品">
<template #right>
<UButton icon="i-lucide-plus" label="新建作品" @click="createOpen = true" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<UAlert
v-if="!models.length"
color="warning"
variant="subtle"
icon="i-lucide-plug-zap"
title="还没有配置模型"
description="先去「模型与 API」添加一个 API 提供方和至少一个模型,主编和作家才能开始工作。"
:actions="[{ label: '去配置', to: '/settings', color: 'warning', variant: 'solid' }]"
class="mb-4"
/>
<div v-if="!projects.length" class="flex flex-col items-center justify-center py-24 text-center gap-3">
<UIcon name="i-lucide-book-plus" class="size-12 text-muted" />
<p class="text-muted">还没有作品,开始创作你的第一部小说吧</p>
<UButton icon="i-lucide-plus" label="新建作品" @click="createOpen = true" />
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<UCard v-for="p in projects" :key="p.id" class="hover:ring-primary/50 transition" :ui="{ body: 'space-y-3' }">
<div class="flex items-start justify-between gap-2">
<NuxtLink :to="`/projects/${p.id}`" class="font-semibold text-lg hover:text-primary line-clamp-1">
{{ p.title }}
</NuxtLink>
<UDropdownMenu :items="[[{ label: '删除作品', icon: 'i-lucide-trash-2', color: 'error', onSelect: () => (deleting = p) }]]">
<UButton icon="i-lucide-ellipsis" color="neutral" variant="ghost" size="sm" />
</UDropdownMenu>
</div>
<p class="text-sm text-muted line-clamp-2 min-h-10">
{{ p.description || '暂无简介' }}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs">
<UBadge variant="subtle" color="neutral">
{{ p.stats.approved }}/{{ p.stats.total }} 章
</UBadge>
<UBadge variant="subtle" color="neutral">
{{ (p.stats.words / 10000).toFixed(1) }} 万字
</UBadge>
<UBadge v-if="p.running" variant="subtle" color="warning" icon="i-lucide-loader-circle">
{{ p.running.label }}
</UBadge>
<span class="ms-auto text-muted">{{ formatTime(p.updatedAt) }}</span>
</div>
<div class="flex gap-2">
<UButton :to="`/projects/${p.id}`" label="写作台" icon="i-lucide-pen-line" size="sm" />
<UButton :to="`/projects/${p.id}/setting`" label="设定" icon="i-lucide-book-open" size="sm" color="neutral" variant="outline" />
</div>
</UCard>
</div>
</template>
</UDashboardPanel>
<UModal v-model:open="createOpen" title="新建作品">
<template #body>
<div class="space-y-4">
<UFormField label="作品名" required>
<UInput v-model="form.title" placeholder="例如:雨季来临之前" class="w-full" autofocus @keydown.enter="create" />
</UFormField>
<UFormField label="简介" hint="可选">
<UTextarea v-model="form.description" :rows="3" autoresize class="w-full" />
</UFormField>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="创建" :loading="creating" :disabled="!form.title.trim()" @click="create" />
</div>
</template>
</UModal>
<UModal :open="!!deleting" title="删除作品" @update:open="v => !v && (deleting = null)">
<template #body>
<p>确定要删除《{{ deleting?.title }}》吗?所有章节、设定和生成的文件都会被永久删除,无法恢复。</p>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="deleting = null" />
<UButton label="永久删除" color="error" @click="confirmDelete" />
</div>
</template>
</UModal>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
const route = useRoute()
const projectId = route.params.id as string
const store = createProjectStore(projectId)
provide(projectStoreKey, store)
const error = ref('')
onMounted(async () => {
try {
await store.load()
store.connect()
}
catch (e) {
error.value = apiErrorMessage(e, '加载失败')
}
})
onBeforeUnmount(() => store.disconnect())
</script>
<template>
<UDashboardPanel v-if="error" id="project-error">
<template #body>
<div class="flex flex-col items-center py-24 gap-3">
<UIcon name="i-lucide-circle-alert" class="size-10 text-error" />
<p>{{ error }}</p>
<UButton to="/" label="返回作品列表" />
</div>
</template>
</UDashboardPanel>
<div v-else-if="!store.project.value" class="flex flex-1 items-center justify-center">
<UIcon name="i-lucide-loader-circle" class="size-8 animate-spin text-muted" />
</div>
<NuxtPage v-else />
</template>
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
const store = useProjectStore()
const api = useApi()
const pid = store.projectId
const { data: characters, refresh: refreshChars } = await useAsyncData(`chars-${pid}`, () => api.get<CharacterItem[]>(`/api/projects/${pid}/characters`), { default: () => [] })
const { data: lore, refresh: refreshLore } = await useAsyncData(`lore-${pid}`, () => api.get<LoreItem[]>(`/api/projects/${pid}/lore`), { default: () => [] })
const importOpen = ref(false)
async function onImported() {
await Promise.all([refreshChars(), refreshLore()])
}
/* ---------- 角色编辑 ---------- */
const charOpen = ref(false)
const charForm = reactive({ id: '', name: '', identity: '', description: '', personality: '', speechExamples: '', notes: '', avatar: '' })
function openChar(c?: CharacterItem) {
Object.assign(charForm, c
? { id: c.id, name: c.name, identity: c.identity, description: c.description, personality: c.personality, speechExamples: c.speechExamples, notes: c.notes, avatar: c.avatar }
: { id: '', name: '', identity: '', description: '', personality: '', speechExamples: '', notes: '', avatar: '' })
charOpen.value = true
}
async function saveChar() {
const { id, avatar: _a, ...body } = charForm
if (id) await api.put(`/api/characters/${id}`, body)
else await api.post(`/api/projects/${pid}/characters`, { ...body, sortOrder: characters.value.length })
charOpen.value = false
await refreshChars()
}
async function deleteChar() {
if (!charForm.id || !window.confirm(`删除角色「${charForm.name}」?`)) return
await api.del(`/api/characters/${charForm.id}`)
charOpen.value = false
await refreshChars()
}
const avatarSrc = (c: { avatar: string }) => (c.avatar ? `/api/avatars/${pid}/${c.avatar}` : undefined)
/* ---------- 世界书 ---------- */
const loreOpen = ref(false)
const loreForm = reactive({ id: '', title: '', keysText: '', content: '', constant: false, enabled: true })
function openLore(l?: LoreItem) {
Object.assign(loreForm, l
? { id: l.id, title: l.title, keysText: l.keys.join(', '), content: l.content, constant: l.constant, enabled: l.enabled }
: { id: '', title: '', keysText: '', content: '', constant: false, enabled: true })
loreOpen.value = true
}
async function saveLore() {
const body = {
title: loreForm.title,
keys: loreForm.keysText.split(/[,,\n]/).map(s => s.trim()).filter(Boolean),
content: loreForm.content,
constant: loreForm.constant,
enabled: loreForm.enabled
}
if (loreForm.id) await api.put(`/api/lore/${loreForm.id}`, body)
else await api.post(`/api/projects/${pid}/lore`, body)
loreOpen.value = false
await refreshLore()
}
async function toggleLore(l: LoreItem, enabled: boolean) {
await api.put(`/api/lore/${l.id}`, { enabled })
l.enabled = enabled
}
async function deleteLore(l: LoreItem) {
if (!window.confirm(`删除世界书条目「${l.title}」?`)) return
await api.del(`/api/lore/${l.id}`)
await refreshLore()
}
const tab = ref('characters')
</script>
<template>
<UDashboardPanel id="project-characters">
<template #header>
<UDashboardNavbar title="角色与世界书">
<template #right>
<UButton icon="i-lucide-id-card" label="导入角色卡" variant="soft" @click="importOpen = true" />
<UButton v-if="tab === 'characters'" icon="i-lucide-user-plus" label="新建角色" @click="openChar()" />
<UButton v-else icon="i-lucide-plus" label="新建条目" @click="openLore()" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<UTabs
v-model="tab"
:items="[
{ label: `角色 (${characters.length})`, value: 'characters', icon: 'i-lucide-users' },
{ label: `世界书 (${lore.length})`, value: 'lore', icon: 'i-lucide-scroll-text' }
]"
:content="false"
variant="link"
class="mb-4"
/>
<template v-if="tab === 'characters'">
<div v-if="!characters.length" class="flex flex-col items-center py-20 gap-3 text-center">
<UIcon name="i-lucide-users" class="size-12 text-muted" />
<p class="text-muted">
还没有角色。可以手动新建,也可以直接导入酒馆角色卡(PNG/JSON)。
</p>
<div class="flex gap-2">
<UButton icon="i-lucide-id-card" label="导入角色卡" variant="soft" @click="importOpen = true" />
<UButton icon="i-lucide-user-plus" label="新建角色" @click="openChar()" />
</div>
</div>
<div class="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
<UCard v-for="c in characters" :key="c.id" class="cursor-pointer hover:ring-primary/50 transition" @click="openChar(c)">
<div class="flex gap-4">
<UAvatar :src="avatarSrc(c)" :alt="c.name" size="3xl" class="shrink-0" />
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2">
<span class="font-semibold">{{ c.name }}</span>
<UBadge v-if="c.identity" variant="subtle" size="sm">
{{ c.identity }}
</UBadge>
<UBadge v-if="c.sourceCard" variant="outline" color="neutral" size="sm">
角色卡
</UBadge>
</div>
<p class="text-sm text-muted line-clamp-4 whitespace-pre-wrap">
{{ c.description || c.personality || '暂无描述' }}
</p>
</div>
</div>
</UCard>
</div>
</template>
<template v-else>
<p class="text-sm text-muted mb-3">
常驻条目每次都会提供给 AI;其他条目只有当章节简报、梗概或大纲中出现关键词时才会注入。关键词支持 <code>/正则/</code> 写法。
</p>
<div v-if="!lore.length" class="text-center text-muted py-16">
暂无世界书条目
</div>
<div class="space-y-2">
<div v-for="l in lore" :key="l.id" class="rounded-lg border border-default px-4 py-3 flex gap-3 items-start" :class="l.enabled ? '' : 'opacity-50'">
<USwitch :model-value="l.enabled" size="sm" class="mt-0.5" @update:model-value="v => toggleLore(l, v)" />
<div class="min-w-0 flex-1 cursor-pointer" @click="openLore(l)">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{{ l.title || '未命名条目' }}</span>
<UBadge v-if="l.constant" color="primary" variant="subtle" size="sm">
常驻
</UBadge>
<UBadge v-if="l.source === 'card'" color="neutral" variant="outline" size="sm">
角色卡
</UBadge>
<UBadge v-for="k in l.keys.slice(0, 8)" :key="k" color="neutral" variant="soft" size="sm">
{{ k }}
</UBadge>
<span v-if="l.keys.length > 8" class="text-xs text-muted">+{{ l.keys.length - 8 }}</span>
</div>
<p class="text-sm text-muted line-clamp-2 mt-1 whitespace-pre-wrap">
{{ l.content }}
</p>
</div>
<UButton icon="i-lucide-trash-2" size="xs" color="error" variant="ghost" @click="deleteLore(l)" />
</div>
</div>
</template>
</template>
</UDashboardPanel>
<CardImportModal v-model:open="importOpen" @imported="onImported" />
<USlideover v-model:open="charOpen" :title="charForm.id ? `编辑角色:${charForm.name}` : '新建角色'" :ui="{ content: 'max-w-2xl' }">
<template #body>
<div class="space-y-4">
<div class="flex gap-4 items-start">
<UAvatar :src="avatarSrc(charForm)" :alt="charForm.name || '?'" size="3xl" />
<div class="grid grid-cols-2 gap-3 flex-1">
<UFormField label="姓名" required>
<UInput v-model="charForm.name" class="w-full" />
</UFormField>
<UFormField label="身份">
<UInput v-model="charForm.identity" placeholder="例如:女主角 / 反派" class="w-full" />
</UFormField>
</div>
</div>
<UFormField label="人物设定" hint="外貌、背景、经历、人际关系等">
<UTextarea v-model="charForm.description" autoresize :rows="8" :maxrows="24" class="w-full" />
</UFormField>
<UFormField label="性格">
<UTextarea v-model="charForm.personality" autoresize :rows="3" class="w-full" />
</UFormField>
<UFormField label="说话风格 / 示例" hint="对白示例能显著提升人物辨识度">
<UTextarea v-model="charForm.speechExamples" autoresize :rows="5" :maxrows="16" class="w-full" />
</UFormField>
<UFormField label="备注">
<UTextarea v-model="charForm.notes" autoresize :rows="2" class="w-full" />
</UFormField>
</div>
</template>
<template #footer>
<div class="flex gap-2 w-full">
<UButton v-if="charForm.id" label="删除" icon="i-lucide-trash-2" color="error" variant="ghost" @click="deleteChar" />
<UButton class="ms-auto" label="取消" color="neutral" variant="outline" @click="charOpen = false" />
<UButton label="保存" :disabled="!charForm.name.trim()" @click="saveChar" />
</div>
</template>
</USlideover>
<UModal v-model:open="loreOpen" :title="loreForm.id ? '编辑世界书条目' : '新建世界书条目'" :ui="{ content: 'max-w-2xl' }">
<template #body>
<div class="space-y-4">
<UFormField label="标题">
<UInput v-model="loreForm.title" class="w-full" />
</UFormField>
<UFormField label="关键词" hint="用逗号分隔;常驻条目可不填">
<UInput v-model="loreForm.keysText" placeholder="例如:青云宗, 掌门, /剑[阵法]/" class="w-full" />
</UFormField>
<UFormField label="内容">
<UTextarea v-model="loreForm.content" autoresize :rows="8" :maxrows="24" class="w-full" />
</UFormField>
<div class="flex gap-6">
<UCheckbox v-model="loreForm.constant" label="常驻(总是注入)" />
<UCheckbox v-model="loreForm.enabled" label="启用" />
</div>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="保存" :disabled="!loreForm.content.trim()" @click="saveLore" />
</div>
</template>
</UModal>
</template>
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { CHAPTER_STATUS_COLOR, CHAPTER_STATUS_LABEL } from '#shared/types'
const store = useProjectStore()
const api = useApi()
const route = useRoute()
const router = useRouter()
const { chapters, running, live } = store
const selectedId = computed<string | null>({
get: () => (route.query.c as string) || chapters.value.find(c => c.status !== 'approved')?.id || chapters.value.at(-1)?.id || null,
set: v => router.replace({ query: { ...route.query, c: v ?? undefined } })
})
// 写作开始时自动切换到正在写的章节
watch(() => chapters.value.find(c => c.status === 'writing')?.id, (id) => {
if (id) selectedId.value = id
})
const totalWords = computed(() => chapters.value.reduce((s, c) => s + c.wordCount, 0))
const approvedCount = computed(() => chapters.value.filter(c => c.status === 'approved').length)
/* 规划大纲 */
const planOpen = ref(false)
const planForm = reactive({ count: 10, fromIndex: null as number | null })
async function plan() {
await api.post(`/api/projects/${store.projectId}/plan`, { count: planForm.count, ...(planForm.fromIndex ? { fromIndex: planForm.fromIndex } : {}) })
planOpen.value = false
}
/* 新增章节 */
async function addChapter() {
const row = await api.post<{ id: string }>(`/api/projects/${store.projectId}/chapters`, { title: '' })
await store.refreshChapters()
selectedId.value = row.id
}
const exportUrl = (format: string, scope: string) => `/api/projects/${store.projectId}/export?format=${format}&scope=${scope}`
const exportItems = [
[{ label: '导出全部章节', type: 'label' as const }, { label: 'TXT', icon: 'i-lucide-file-text', to: exportUrl('txt', 'all'), external: true, target: '_blank' }, { label: 'Markdown', icon: 'i-lucide-file-code', to: exportUrl('md', 'all'), external: true, target: '_blank' }],
[{ label: '仅导出已确认章节', type: 'label' as const }, { label: 'TXT', icon: 'i-lucide-file-text', to: exportUrl('txt', 'approved'), external: true, target: '_blank' }, { label: 'Markdown', icon: 'i-lucide-file-code', to: exportUrl('md', 'approved'), external: true, target: '_blank' }]
]
function liveWords(chapterId: string) {
const t = live[chapterId]?.text
return t ? t.replace(/\s/g, '').length : 0
}
</script>
<template>
<UDashboardPanel id="chapter-list" :default-size="22" :min-size="16" :max-size="35" resizable>
<template #header>
<UDashboardNavbar title="章节">
<template #right>
<UTooltip text="让主编规划章节大纲">
<UButton icon="i-lucide-list-tree" size="sm" color="neutral" variant="ghost" :disabled="running" @click="planOpen = true" />
</UTooltip>
<UTooltip text="手动新增章节">
<UButton icon="i-lucide-plus" size="sm" color="neutral" variant="ghost" @click="addChapter" />
</UTooltip>
<UDropdownMenu :items="exportItems">
<UButton icon="i-lucide-download" size="sm" color="neutral" variant="ghost" />
</UDropdownMenu>
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="text-xs text-muted flex gap-3 px-1 -mt-1 mb-2">
<span>{{ approvedCount }}/{{ chapters.length }} 章已确认</span>
<span>{{ formatWords(totalWords) }}</span>
</div>
<div v-if="!chapters.length" class="text-sm text-muted text-center py-10 space-y-3">
<p>还没有章节</p>
<UButton label="让主编规划大纲" icon="i-lucide-list-tree" size="sm" :disabled="running" @click="planOpen = true" />
<p class="text-xs">
或直接点击右上角「继续下一章」,主编会自动规划
</p>
</div>
<div class="flex flex-col gap-1">
<button
v-for="c in chapters"
:key="c.id"
class="text-left rounded-md px-2.5 py-2 transition border border-transparent"
:class="c.id === selectedId ? 'bg-elevated border-default' : 'hover:bg-elevated/50'"
@click="selectedId = c.id"
>
<div class="flex items-center gap-2">
<span class="text-xs text-muted tabular-nums w-10 shrink-0">第{{ c.index }}章</span>
<span class="truncate text-sm font-medium flex-1">{{ c.title || '(未命名)' }}</span>
<UIcon v-if="c.status === 'writing'" name="i-lucide-loader-circle" class="size-3.5 animate-spin text-warning" />
</div>
<div class="flex items-center gap-2 mt-1 ps-12">
<UBadge :color="CHAPTER_STATUS_COLOR[c.status]" variant="subtle" size="sm">
{{ CHAPTER_STATUS_LABEL[c.status] }}
</UBadge>
<span v-if="c.status === 'writing'" class="text-xs text-muted">{{ liveWords(c.id) }} 字…</span>
<span v-else-if="c.wordCount" class="text-xs text-muted">{{ c.wordCount }} 字</span>
</div>
</button>
</div>
</template>
</UDashboardPanel>
<UDashboardPanel id="chapter-main">
<template #header>
<UDashboardNavbar :title="store.project.value?.title">
<template #right>
<RunControls />
</template>
</UDashboardNavbar>
</template>
<template #body>
<ChapterWorkspace v-if="selectedId" :key="selectedId" :chapter-id="selectedId" />
<div v-else class="flex flex-col items-center justify-center py-24 gap-4 text-center">
<UIcon name="i-lucide-feather" class="size-12 text-muted" />
<div class="space-y-1">
<p class="font-medium">
准备开始
</p>
<p class="text-sm text-muted max-w-md">
确认已经填写「故事设定」、添加了角色,并在「AI 团队」中为主编和作家分配了模型。然后点击右上角「继续下一章」,主编会先规划章节,再为第一章写简报并交给作家。
</p>
</div>
</div>
</template>
</UDashboardPanel>
<UModal v-model:open="planOpen" title="主编规划章节大纲">
<template #body>
<div class="space-y-4">
<p class="text-sm text-muted">
主编会根据故事设定、大纲和已完成章节的摘要,规划后续章节的标题和梗概。已开写的章节不会被改动。
</p>
<div class="grid grid-cols-2 gap-3">
<UFormField label="规划章节数">
<UInputNumber v-model="planForm.count" :min="1" :max="100" />
</UFormField>
<UFormField label="起始章节" hint="留空=接在已写章节之后">
<UInputNumber v-model="planForm.fromIndex" :min="1" placeholder="自动" />
</UFormField>
</div>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="开始规划" icon="i-lucide-sparkles" @click="plan" />
</div>
</template>
</UModal>
</template>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { JOB_TYPE_LABEL, type JobInfo } from '#shared/types'
const store = useProjectStore()
const api = useApi()
const { jobs, chapters } = store
onMounted(() => store.refreshJobs())
const STATUS: Record<string, { label: string, color: 'warning' | 'success' | 'error' | 'neutral', icon: string, cls: string }> = {
running: { label: '进行中', color: 'warning', icon: 'i-lucide-loader-circle', cls: 'text-warning animate-spin' },
done: { label: '完成', color: 'success', icon: 'i-lucide-circle-check', cls: 'text-success' },
error: { label: '出错', color: 'error', icon: 'i-lucide-circle-x', cls: 'text-error' },
cancelled: { label: '已取消', color: 'neutral', icon: 'i-lucide-circle-slash', cls: 'text-muted' }
}
const totals = computed(() => jobs.value.reduce((s, j) => ({ input: s.input + j.inputTokens, output: s.output + j.outputTokens }), { input: 0, output: 0 }))
function chapterLabel(id: string | null) {
if (!id) return ''
const c = chapters.value.find(x => x.id === id)
return c ? `第${c.index}章` : ''
}
function duration(j: JobInfo) {
if (!j.finishedAt) return ''
const s = Math.round((new Date(j.finishedAt).getTime() - new Date(j.createdAt).getTime()) / 1000)
return s >= 60 ? `${Math.floor(s / 60)}分${s % 60}秒` : `${s}秒`
}
const detail = ref<JobDetail | null>(null)
async function openDetail(j: JobInfo) {
detail.value = await api.get<JobDetail>(`/api/jobs/${j.id}`)
}
// 正在查看的任务有更新时刷新
watch(jobs, async () => {
if (!detail.value) return
const j = jobs.value.find(x => x.id === detail.value!.id)
if (j && (j.status !== detail.value.status || j.log !== detail.value.log)) detail.value = await api.get<JobDetail>(`/api/jobs/${j.id}`)
}, { deep: true })
async function copy(text: string) {
await navigator.clipboard.writeText(text)
api.toast.add({ title: '已复制', color: 'success' })
}
</script>
<template>
<UDashboardPanel id="project-jobs">
<template #header>
<UDashboardNavbar title="任务日志">
<template #right>
<span class="text-xs text-muted">最近 {{ jobs.length }} 个任务 · 输入 {{ totals.input.toLocaleString() }} / 输出 {{ totals.output.toLocaleString() }} tokens</span>
<UButton icon="i-lucide-refresh-cw" size="sm" color="neutral" variant="ghost" @click="store.refreshJobs()" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div v-if="!jobs.length" class="text-center text-muted py-20">
暂无任务
</div>
<div class="space-y-1.5">
<button
v-for="j in jobs"
:key="j.id"
class="w-full text-left flex flex-wrap items-center gap-3 rounded-md border border-default px-3 py-2 hover:bg-elevated/50"
@click="openDetail(j)"
>
<UIcon :name="STATUS[j.status]!.icon" class="size-4 shrink-0" :class="STATUS[j.status]!.cls" />
<span class="text-xs text-muted tabular-nums w-28 shrink-0">{{ formatTime(j.createdAt) }}</span>
<UBadge variant="subtle" size="sm" color="neutral">
{{ JOB_TYPE_LABEL[j.type] }}
</UBadge>
<span class="text-sm">{{ chapterLabel(j.chapterId) }}</span>
<span class="text-sm font-medium">{{ j.agentName }}</span>
<span class="text-xs text-muted truncate max-w-64">{{ j.modelLabel }}</span>
<span class="ms-auto text-xs text-muted tabular-nums">
<template v-if="j.inputTokens || j.outputTokens">{{ j.inputTokens.toLocaleString() }} → {{ j.outputTokens.toLocaleString() }} tokens · </template>{{ duration(j) }}
</span>
<p v-if="j.error" class="w-full text-xs text-error line-clamp-2 ps-7">
{{ j.error }}
</p>
<p v-else-if="j.log" class="w-full text-xs text-muted truncate ps-7">
{{ j.log.split('\n').at(-1) }}
</p>
</button>
</div>
</template>
</UDashboardPanel>
<USlideover :open="!!detail" :title="detail ? `${JOB_TYPE_LABEL[detail.type]} · ${detail.agentName}` : ''" :description="detail?.modelLabel" :ui="{ content: 'max-w-3xl' }" @update:open="v => !v && (detail = null)">
<template #body>
<div v-if="detail" class="space-y-5">
<UAlert v-if="detail.error" color="error" variant="subtle" icon="i-lucide-circle-x" title="错误" :description="detail.error" :ui="{ description: 'whitespace-pre-wrap break-all' }" />
<div>
<h3 class="font-medium mb-1">
日志
</h3>
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3">{{ detail.log || '(无)' }}</pre>
</div>
<template v-if="detail.prompt">
<div>
<div class="flex items-center mb-1">
<h3 class="font-medium">
系统提示词(System)
</h3>
<UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" class="ms-auto" @click="copy(detail.prompt.system)" />
</div>
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3 max-h-96 overflow-y-auto">{{ detail.prompt.system }}</pre>
</div>
<div>
<div class="flex items-center mb-1">
<h3 class="font-medium">
用户提示词(Prompt)
</h3>
<span class="text-xs text-muted ms-2">{{ detail.prompt.prompt.length.toLocaleString() }} 字符</span>
<UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" class="ms-auto" @click="copy(detail.prompt.prompt)" />
</div>
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3 max-h-[32rem] overflow-y-auto">{{ detail.prompt.prompt }}</pre>
</div>
</template>
</div>
</template>
</USlideover>
</template>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import type { ProjectMemory } from '#shared/types'
const store = useProjectStore()
const api = useApi()
const { chapters } = store
const mem = reactive<ProjectMemory>(structuredClone(toRaw(store.project.value!.memory)))
const snapshot = ref(JSON.stringify(mem))
const dirty = computed(() => JSON.stringify(mem) !== snapshot.value)
// 确认章节后服务端会更新记忆;没有本地修改时同步最新数据
watch(() => store.project.value?.memory, (m) => {
if (m && !dirty.value) {
Object.assign(mem, structuredClone(toRaw(m)))
snapshot.value = JSON.stringify(mem)
}
}, { deep: true })
async function save() {
await store.saveProject({ memory: JSON.parse(JSON.stringify(mem)) })
snapshot.value = JSON.stringify(mem)
api.toast.add({ title: '故事记忆已保存', color: 'success' })
}
const openForeshadowing = computed(() => mem.foreshadowing.filter(f => f.status === 'open'))
const resolvedForeshadowing = computed(() => mem.foreshadowing.filter(f => f.status === 'resolved'))
function addState() { mem.characterStates.push({ name: '', state: '' }) }
function addForeshadowing() { mem.foreshadowing.push({ text: '', status: 'open', chapter: chapters.value.filter(c => c.status === 'approved').at(-1)?.index ?? 0 }) }
function removeItem<T>(arr: T[], item: T) {
const i = arr.indexOf(item)
if (i >= 0) arr.splice(i, 1)
}
</script>
<template>
<UDashboardPanel id="project-memory">
<template #header>
<UDashboardNavbar title="故事记忆">
<template #right>
<span v-if="dirty" class="text-xs text-warning">有未保存的修改</span>
<UButton icon="i-lucide-save" label="保存" :disabled="!dirty" @click="save" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="max-w-5xl mx-auto w-full space-y-8 pb-16">
<p class="text-sm text-muted">
每章确认后,摘要员会自动更新这里的内容。主编和作家写作时会参考这些信息来保持人物状态与情节的连贯。你可以随时手动修正。
</p>
<section class="space-y-3">
<div class="flex items-center gap-2">
<h2 class="font-semibold">
人物当前状态
</h2>
<UButton icon="i-lucide-plus" size="xs" variant="soft" label="添加" class="ms-auto" @click="addState" />
</div>
<div v-if="!mem.characterStates.length" class="text-sm text-muted">
暂无
</div>
<div v-for="(s, i) in mem.characterStates" :key="i" class="flex gap-2 items-start">
<UInput v-model="s.name" placeholder="人物" class="w-32 shrink-0" />
<UTextarea v-model="s.state" autoresize :rows="1" class="flex-1" />
<UButton icon="i-lucide-x" size="sm" color="neutral" variant="ghost" @click="removeItem(mem.characterStates, s)" />
</div>
</section>
<section class="space-y-3">
<div class="flex items-center gap-2">
<h2 class="font-semibold">
伏笔
</h2>
<UBadge variant="subtle" size="sm">
未回收 {{ openForeshadowing.length }}
</UBadge>
<UBadge variant="subtle" color="success" size="sm">
已回收 {{ resolvedForeshadowing.length }}
</UBadge>
<UButton icon="i-lucide-plus" size="xs" variant="soft" label="添加" class="ms-auto" @click="addForeshadowing" />
</div>
<div v-for="(f, i) in mem.foreshadowing" :key="i" class="flex gap-2 items-center">
<UBadge :color="f.status === 'open' ? 'warning' : 'success'" variant="subtle" class="shrink-0 cursor-pointer" @click="f.status = f.status === 'open' ? 'resolved' : 'open'">
{{ f.status === 'open' ? '未回收' : `已回收${f.resolvedChapter ? `·第${f.resolvedChapter}章` : ''}` }}
</UBadge>
<span class="text-xs text-muted shrink-0 w-16">第{{ f.chapter }}章</span>
<UInput v-model="f.text" class="flex-1" />
<UButton icon="i-lucide-x" size="sm" color="neutral" variant="ghost" @click="removeItem(mem.foreshadowing, f)" />
</div>
</section>
<section class="space-y-3">
<h2 class="font-semibold">
时间线
</h2>
<div v-if="!mem.timeline.length" class="text-sm text-muted">
暂无
</div>
<ol class="relative border-s border-default ms-2 space-y-2">
<li v-for="(t, i) in mem.timeline" :key="i" class="ms-4 flex gap-2 items-center">
<span class="absolute -start-1.5 size-3 rounded-full bg-primary/70" />
<span class="text-xs text-muted w-14 shrink-0">第{{ t.chapter }}章</span>
<UInput v-model="t.event" variant="ghost" class="flex-1" />
<UButton icon="i-lucide-x" size="xs" color="neutral" variant="ghost" @click="removeItem(mem.timeline, t)" />
</li>
</ol>
</section>
<section class="space-y-3">
<h2 class="font-semibold">
章节摘要一览
</h2>
<div v-for="c in chapters.filter(c => c.status === 'approved')" :key="c.id" class="text-sm">
<NuxtLink :to="`/projects/${store.projectId}?c=${c.id}`" class="font-medium hover:text-primary">
第{{ c.index }}章 {{ c.title }}
</NuxtLink>
</div>
<p class="text-xs text-muted">
点击章节可在写作台的「审稿与摘要」中查看和编辑摘要。
</p>
</section>
</div>
</template>
</UDashboardPanel>
</template>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { ProjectRequirements, ProjectSettings } from '#shared/types'
const store = useProjectStore()
const api = useApi()
const p = store.project
const draft = reactive({
title: p.value!.title,
description: p.value!.description,
worldSetting: p.value!.worldSetting,
storyOutline: p.value!.storyOutline,
userName: p.value!.userName,
requirements: { ...p.value!.requirements } as ProjectRequirements,
settings: { ...p.value!.settings } as ProjectSettings
})
const snapshot = ref(JSON.stringify(draft))
const dirty = computed(() => JSON.stringify(draft) !== snapshot.value)
const saving = ref(false)
async function save() {
saving.value = true
try {
await store.saveProject(JSON.parse(JSON.stringify(draft)))
snapshot.value = JSON.stringify(draft)
api.toast.add({ title: '设定已保存', color: 'success', icon: 'i-lucide-save' })
}
finally {
saving.value = false
}
}
// Ctrl+S 保存
defineShortcuts({ meta_s: { usingInput: true, handler: () => { if (dirty.value) save() } } })
onBeforeRouteLeave(() => {
if (dirty.value && !window.confirm('有未保存的修改,确定离开吗?')) return false
})
const POV_OPTIONS = ['第一人称', '第三人称有限视角', '第三人称全知视角', '第二人称', '多视角轮换']
</script>
<template>
<UDashboardPanel id="project-setting">
<template #header>
<UDashboardNavbar title="故事设定">
<template #right>
<span v-if="dirty" class="text-xs text-warning">有未保存的修改</span>
<UButton icon="i-lucide-save" label="保存" :loading="saving" :disabled="!dirty" @click="save" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="max-w-4xl mx-auto w-full space-y-8 pb-16">
<section class="space-y-4">
<h2 class="text-base font-semibold flex items-center gap-2">
<UIcon name="i-lucide-book" class="text-primary" />基本信息
</h2>
<div class="grid sm:grid-cols-2 gap-4">
<UFormField label="作品名" required>
<UInput v-model="draft.title" class="w-full" />
</UFormField>
<UFormField label="主角名 / {{user}}" hint="导入角色卡时用于替换 {{user}}">
<UInput v-model="draft.userName" placeholder="例如:陈默" class="w-full" />
</UFormField>
</div>
<UFormField label="简介">
<UTextarea v-model="draft.description" autoresize :rows="2" class="w-full" />
</UFormField>
</section>
<section class="space-y-4">
<h2 class="text-base font-semibold flex items-center gap-2">
<UIcon name="i-lucide-globe" class="text-primary" />故事背景 / 世界观
</h2>
<UTextarea
v-model="draft.worldSetting"
autoresize
:rows="8"
:maxrows="30"
class="w-full"
placeholder="时代、地点、社会环境、力量体系、重要组织、基调……越具体,AI 写得越贴合。导入角色卡时,卡片的场景描述会追加到这里。"
/>
</section>
<section class="space-y-4">
<h2 class="text-base font-semibold flex items-center gap-2">
<UIcon name="i-lucide-route" class="text-primary" />故事大纲
</h2>
<UTextarea
v-model="draft.storyOutline"
autoresize
:rows="10"
:maxrows="40"
class="w-full"
placeholder="整个故事的主线:起因、发展、高潮、结局,以及主要的转折点和人物弧光。主编会据此拆分章节。"
/>
</section>
<section class="space-y-4">
<h2 class="text-base font-semibold flex items-center gap-2">
<UIcon name="i-lucide-pen-tool" class="text-primary" />写作要求 <span class="text-xs font-normal text-muted">(可选)</span>
</h2>
<div class="grid sm:grid-cols-3 gap-4">
<UFormField label="语言">
<UInput v-model="draft.requirements.language" class="w-full" />
</UFormField>
<UFormField label="叙事视角">
<UInputMenu v-model="draft.requirements.pov" :items="POV_OPTIONS" create-item class="w-full" @create="(v: string) => (draft.requirements.pov = v)" />
</UFormField>
<UFormField label="每章字数">
<UInputNumber v-model="draft.requirements.wordsPerChapter" :min="500" :max="50000" :step="500" class="w-full" />
</UFormField>
</div>
<UFormField label="文风">
<UInput v-model="draft.requirements.style" placeholder="例如:细腻克制的现实主义,带一点潮湿的南方气息" class="w-full" />
</UFormField>
<UFormField label="叙述方式">
<UTextarea v-model="draft.requirements.narration" autoresize :rows="2" placeholder="例如:过去时;对白占比约三成;多用环境烘托情绪;少用心理独白" class="w-full" />
</UFormField>
<UFormField label="其他要求">
<UTextarea v-model="draft.requirements.extra" autoresize :rows="2" placeholder="例如:避免现代网络用语;不要出现“仿佛”“不禁”等滥用词" class="w-full" />
</UFormField>
</section>
<section class="space-y-4">
<h2 class="text-base font-semibold flex items-center gap-2">
<UIcon name="i-lucide-settings-2" class="text-primary" />写作流程
</h2>
<div class="grid sm:grid-cols-2 gap-4">
<USwitch v-model="draft.settings.autoMode" label="自动模式" description="章节写完后自动确认并继续下一章;关闭时每章写完都会停下来等你确认" />
<USwitch v-model="draft.settings.pauseAfterBrief" label="简报后暂停" description="主编写完简报后先暂停,你确认或修改简报后再开始写作(仅逐章模式)" />
<USwitch v-model="draft.settings.allowEditorCreateWriters" label="允许主编创建作家" description="主编可以用已配置的模型创建新的作家来写特定风格的章节" />
</div>
<div class="grid sm:grid-cols-4 gap-4">
<UFormField label="上一章衔接字数" hint="注入作家">
<UInputNumber v-model="draft.settings.prevTailChars" :min="0" :max="20000" :step="500" class="w-full" />
</UFormField>
<UFormField label="详细前情章数">
<UInputNumber v-model="draft.settings.recentSummaryCount" :min="0" :max="50" class="w-full" />
</UFormField>
<UFormField label="截断自动续写次数">
<UInputNumber v-model="draft.settings.maxContinuations" :min="0" :max="10" class="w-full" />
</UFormField>
<UFormField label="自动模式章节上限">
<UInputNumber v-model="draft.settings.autoChapterLimit" :min="1" :max="200" class="w-full" />
</UFormField>
</div>
</section>
</div>
</template>
</UDashboardPanel>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
const store = useProjectStore()
const api = useApi()
const { agents, models } = store
const { data: builtin } = await useAsyncData('builtin-prompts', () => api.get<{ editor: string, writer: string, summarizer: string }>('/api/prompts/builtin'), {
default: () => ({ editor: '', writer: '', summarizer: '' })
})
onMounted(() => store.refreshModels())
const editor = computed(() => agents.value.find(a => a.role === 'editor'))
const summarizer = computed(() => agents.value.find(a => a.role === 'summarizer'))
const writers = computed(() => agents.value.filter(a => a.role === 'writer'))
async function addWriter() {
await api.post(`/api/projects/${store.projectId}/agents`, {
role: 'writer',
name: `作家${writers.value.length + 1}`,
modelId: models.value[0]?.id ?? null
})
await store.refreshAgents()
}
</script>
<template>
<UDashboardPanel id="project-team">
<template #header>
<UDashboardNavbar title="AI 团队">
<template #right>
<UButton icon="i-lucide-user-plus" label="添加作家" @click="addWriter" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="max-w-4xl mx-auto w-full space-y-6 pb-16">
<UAlert
v-if="!models.length"
color="warning"
variant="subtle"
icon="i-lucide-plug-zap"
title="还没有可用模型"
description="请先在「模型与 API」中添加模型。"
:actions="[{ label: '去配置', to: '/settings', color: 'warning', variant: 'solid' }]"
/>
<section class="space-y-3">
<h2 class="font-semibold">
编辑部
</h2>
<AgentCard v-if="editor" :agent="editor" :builtin="builtin.editor" />
<AgentCard v-if="summarizer" :agent="summarizer" :builtin="builtin.summarizer" />
</section>
<section class="space-y-3">
<div class="flex items-center gap-2">
<h2 class="font-semibold">
作家团队
</h2>
<span class="text-xs text-muted">主编会根据「擅长风格」为每章选派作家;你也可以在写作台为某章手动指定作家</span>
</div>
<AgentCard v-for="w in writers" :key="w.id" :agent="w" :builtin="builtin.writer" />
<div v-if="!writers.length" class="text-sm text-muted text-center py-8">
还没有作家
</div>
</section>
</div>
</template>
</UDashboardPanel>
</template>
+310
View File
@@ -0,0 +1,310 @@
<script setup lang="ts">
import type { ProviderType } from '#shared/types'
const api = useApi()
const { data: providers, refresh: refreshProviders } = await useAsyncData('providers', () => api.get<ProviderItem[]>('/api/providers'), { default: () => [] })
const { data: models, refresh: refreshModels } = await useAsyncData('models', () => api.get<ModelItem[]>('/api/models'), { default: () => [] })
const TYPE_LABEL: Record<ProviderType, string> = {
'openai-compatible': 'OpenAI 兼容',
'anthropic': 'Anthropic (Claude)',
'google': 'Google (Gemini)'
}
const typeItems = Object.entries(TYPE_LABEL).map(([value, label]) => ({ value, label }))
const PRESETS: { label: string, name: string, type: ProviderType, baseUrl: string }[] = [
{ label: 'Anthropic Claude', name: 'Claude', type: 'anthropic', baseUrl: '' },
{ label: 'Google Gemini', name: 'Gemini', type: 'google', baseUrl: '' },
{ label: 'OpenAI', name: 'OpenAI', type: 'openai-compatible', baseUrl: 'https://api.openai.com/v1' },
{ label: 'DeepSeek', name: 'DeepSeek', type: 'openai-compatible', baseUrl: 'https://api.deepseek.com/v1' },
{ label: 'OpenRouter', name: 'OpenRouter', type: 'openai-compatible', baseUrl: 'https://openrouter.ai/api/v1' },
{ label: '硅基流动', name: '硅基流动', type: 'openai-compatible', baseUrl: 'https://api.siliconflow.cn/v1' },
{ label: 'Ollama(本地)', name: 'Ollama', type: 'openai-compatible', baseUrl: 'http://localhost:11434/v1' }
]
const DEFAULT_URL: Record<ProviderType, string> = {
'openai-compatible': 'https://api.openai.com/v1',
'anthropic': 'https://api.anthropic.com/v1',
'google': 'https://generativelanguage.googleapis.com/v1beta'
}
/* ---------- 提供方 ---------- */
const providerOpen = ref(false)
const providerForm = reactive({ id: '', name: '', type: 'openai-compatible' as ProviderType, baseUrl: '', apiKey: '', hasKey: false })
const savingProvider = ref(false)
function openProvider(p?: ProviderItem) {
Object.assign(providerForm, p
? { id: p.id, name: p.name, type: p.type, baseUrl: p.baseUrl, apiKey: '', hasKey: p.hasKey }
: { id: '', name: '', type: 'openai-compatible', baseUrl: '', apiKey: '', hasKey: false })
providerOpen.value = true
}
function applyPreset(label: string) {
const p = PRESETS.find(x => x.label === label)
if (!p) return
providerForm.name = p.name
providerForm.type = p.type
providerForm.baseUrl = p.baseUrl
}
async function saveProvider() {
savingProvider.value = true
try {
const body = { name: providerForm.name, type: providerForm.type, baseUrl: providerForm.baseUrl, apiKey: providerForm.apiKey }
if (providerForm.id) await api.put(`/api/providers/${providerForm.id}`, body)
else await api.post('/api/providers', body)
providerOpen.value = false
await refreshProviders()
}
finally {
savingProvider.value = false
}
}
const deletingProvider = ref<ProviderItem | null>(null)
async function deleteProvider() {
await api.del(`/api/providers/${deletingProvider.value!.id}`)
deletingProvider.value = null
await Promise.all([refreshProviders(), refreshModels()])
}
/* ---------- 模型 ---------- */
const modelOpen = ref(false)
const modelForm = reactive({
id: '', providerId: '', label: '', modelId: '',
temperature: null as number | null, topP: null as number | null, maxOutputTokens: null as number | null
})
const remoteModels = ref<string[]>([])
const loadingRemote = ref(false)
const savingModel = ref(false)
function openModel(providerId: string, m?: ModelItem) {
Object.assign(modelForm, m
? { id: m.id, providerId, label: m.label, modelId: m.modelId, temperature: m.temperature, topP: m.topP, maxOutputTokens: m.maxOutputTokens }
: { id: '', providerId, label: '', modelId: '', temperature: null, topP: null, maxOutputTokens: null })
remoteModels.value = []
modelOpen.value = true
}
async function fetchRemote() {
loadingRemote.value = true
try {
remoteModels.value = await api.get<string[]>(`/api/providers/${modelForm.providerId}/remote-models`)
if (!remoteModels.value.length) api.toast.add({ title: '没有获取到模型', color: 'warning' })
}
finally {
loadingRemote.value = false
}
}
async function saveModel() {
savingModel.value = true
try {
const body = {
providerId: modelForm.providerId,
label: modelForm.label.trim() || modelForm.modelId.trim(),
modelId: modelForm.modelId.trim(),
temperature: modelForm.temperature,
topP: modelForm.topP,
maxOutputTokens: modelForm.maxOutputTokens
}
if (modelForm.id) await api.put(`/api/models/${modelForm.id}`, body)
else await api.post('/api/models', body)
modelOpen.value = false
await refreshModels()
}
finally {
savingModel.value = false
}
}
async function deleteModel(m: ModelItem) {
await api.del(`/api/models/${m.id}`)
await refreshModels()
}
const testing = ref<string | null>(null)
async function testModel(m: ModelItem) {
testing.value = m.id
try {
const res = await api.post<{ ok: boolean, text?: string, error?: string, ms: number }>(`/api/models/${m.id}/test`)
if (res.ok) api.toast.add({ title: `${m.label} 连接成功`, description: `${res.ms} ms:${res.text}`, color: 'success', icon: 'i-lucide-circle-check' })
else api.toast.add({ title: `${m.label} 连接失败`, description: res.error, color: 'error', duration: 10000 })
}
finally {
testing.value = null
}
}
const modelsOf = (providerId: string) => models.value.filter(m => m.providerId === providerId)
</script>
<template>
<UDashboardPanel id="settings">
<template #header>
<UDashboardNavbar title="模型与 API">
<template #right>
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<p class="text-sm text-muted mb-4">
API Key 使用 AES-256-GCM 加密后保存在本地 <code>data/app.db</code>。添加提供方后,在其下添加具体模型,然后就可以在作品的「AI 团队」中把模型分配给主编和作家。
</p>
<div v-if="!providers.length" class="flex flex-col items-center py-20 gap-3 text-center">
<UIcon name="i-lucide-plug" class="size-12 text-muted" />
<p class="text-muted">还没有 API 提供方</p>
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
</div>
<div class="space-y-4">
<UCard v-for="p in providers" :key="p.id">
<template #header>
<div class="flex items-center gap-3">
<UIcon name="i-lucide-server" class="size-5 text-primary" />
<div class="min-w-0">
<div class="font-semibold">
{{ p.name }}
<UBadge variant="subtle" color="neutral" size="sm" class="ms-2">
{{ TYPE_LABEL[p.type] }}
</UBadge>
</div>
<div class="text-xs text-muted truncate">
{{ p.baseUrl || DEFAULT_URL[p.type] }} · Key:{{ p.apiKeyMasked || '未设置' }}
</div>
</div>
<div class="ms-auto flex gap-1">
<UButton icon="i-lucide-plus" label="添加模型" size="sm" variant="soft" @click="openModel(p.id)" />
<UButton icon="i-lucide-pencil" size="sm" color="neutral" variant="ghost" @click="openProvider(p)" />
<UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="deletingProvider = p" />
</div>
</div>
</template>
<div v-if="!modelsOf(p.id).length" class="text-sm text-muted">
还没有模型,点击「添加模型」。
</div>
<div v-else class="divide-y divide-default">
<div v-for="m in modelsOf(p.id)" :key="m.id" class="flex flex-wrap items-center gap-3 py-2">
<div class="min-w-0">
<div class="font-medium">
{{ m.label }}
</div>
<div class="text-xs text-muted">
{{ m.modelId }}
<template v-if="m.temperature != null">
· 温度 {{ m.temperature }}
</template>
<template v-if="m.topP != null">
· top_p {{ m.topP }}
</template>
<template v-if="m.maxOutputTokens">
· 最大输出 {{ m.maxOutputTokens }}
</template>
</div>
</div>
<div class="ms-auto flex gap-1">
<UButton icon="i-lucide-activity" label="测试" size="xs" color="neutral" variant="outline" :loading="testing === m.id" @click="testModel(m)" />
<UButton icon="i-lucide-pencil" size="xs" color="neutral" variant="ghost" @click="openModel(p.id, m)" />
<UButton icon="i-lucide-trash-2" size="xs" color="error" variant="ghost" @click="deleteModel(m)" />
</div>
</div>
</div>
</UCard>
</div>
</template>
</UDashboardPanel>
<!-- 提供方编辑 -->
<UModal v-model:open="providerOpen" :title="providerForm.id ? '编辑 API 提供方' : '添加 API 提供方'">
<template #body>
<div class="space-y-4">
<UFormField v-if="!providerForm.id" label="快速填写">
<div class="flex flex-wrap gap-1.5">
<UButton v-for="pr in PRESETS" :key="pr.label" :label="pr.label" size="xs" color="neutral" variant="outline" @click="applyPreset(pr.label)" />
</div>
</UFormField>
<UFormField label="名称" required>
<UInput v-model="providerForm.name" class="w-full" />
</UFormField>
<UFormField label="接口类型" required>
<USelect v-model="providerForm.type" :items="typeItems" class="w-full" />
</UFormField>
<UFormField label="Base URL" :hint="`留空使用默认:${DEFAULT_URL[providerForm.type]}`">
<UInput v-model="providerForm.baseUrl" :placeholder="DEFAULT_URL[providerForm.type]" class="w-full" />
</UFormField>
<UFormField label="API Key" :hint="providerForm.hasKey ? '留空则保留原有 Key' : ''">
<UInput v-model="providerForm.apiKey" type="password" autocomplete="off" class="w-full" />
</UFormField>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="保存" :loading="savingProvider" :disabled="!providerForm.name" @click="saveProvider" />
</div>
</template>
</UModal>
<!-- 模型编辑 -->
<UModal v-model:open="modelOpen" :title="modelForm.id ? '编辑模型' : '添加模型'">
<template #body>
<div class="space-y-4">
<UFormField label="模型 ID" required hint="与 API 中的 model 参数一致">
<div class="flex gap-2">
<UInput v-model="modelForm.modelId" placeholder="例如 claude-opus-5-5 / gemini-3-pro" class="flex-1" />
<UButton icon="i-lucide-download" label="获取列表" color="neutral" variant="outline" :loading="loadingRemote" @click="fetchRemote" />
</div>
</UFormField>
<UFormField v-if="remoteModels.length" label="从列表选择">
<USelectMenu
:items="remoteModels"
:model-value="remoteModels.includes(modelForm.modelId) ? modelForm.modelId : undefined"
placeholder="搜索模型…"
class="w-full"
@update:model-value="(v: string) => (modelForm.modelId = v)"
/>
</UFormField>
<UFormField label="显示名称" hint="可选">
<UInput v-model="modelForm.label" :placeholder="modelForm.modelId" class="w-full" />
</UFormField>
<div class="grid grid-cols-3 gap-3">
<UFormField label="温度" hint="留空=默认">
<UInputNumber v-model="modelForm.temperature" :min="0" :max="2" :step="0.1" placeholder="默认" />
</UFormField>
<UFormField label="top_p">
<UInputNumber v-model="modelForm.topP" :min="0" :max="1" :step="0.05" placeholder="默认" />
</UFormField>
<UFormField label="最大输出 tokens">
<UInputNumber v-model="modelForm.maxOutputTokens" :min="256" :step="1024" placeholder="默认" />
</UFormField>
</div>
<p class="text-xs text-muted">
写长章节时建议把「最大输出 tokens」调大(例如 16000 以上);如果输出仍被截断,系统会自动续写。
</p>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="保存" :loading="savingModel" :disabled="!modelForm.modelId.trim()" @click="saveModel" />
</div>
</template>
</UModal>
<UModal :open="!!deletingProvider" title="删除 API 提供方" @update:open="v => !v && (deletingProvider = null)">
<template #body>
<p>删除「{{ deletingProvider?.name }}」会同时删除它下面的所有模型,已分配这些模型的 AI 成员需要重新选择模型。</p>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="deletingProvider = null" />
<UButton label="删除" color="error" @click="deleteProvider" />
</div>
</template>
</UModal>
</template>
+23
View File
@@ -0,0 +1,23 @@
import { marked } from 'marked'
/** 渲染 Markdown(先转义 HTML,避免模型输出的 HTML 被直接执行) */
export function renderMarkdown(md: string) {
if (!md) return ''
const escaped = md.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return marked.parse(escaped, { async: false, breaks: true, gfm: true }) as string
}
export function formatTime(t: string | number | Date | null | undefined) {
if (!t) return ''
return new Date(t).toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
export function formatWords(n: number) {
return n >= 10000 ? `${(n / 10000).toFixed(1)} 万字` : `${n} 字`
}
/** 取出接口错误信息 */
export function apiErrorMessage(err: unknown, fallback = '请求失败') {
const e = err as { data?: { message?: string, statusMessage?: string }, message?: string }
return e.data?.message || e.data?.statusMessage || e.message || fallback
}
+17
View File
@@ -0,0 +1,17 @@
import type { InternalApi } from 'nitropack/types'
/** 从服务端路由推导出的接口返回类型(已序列化,日期为字符串) */
export type Api<P extends keyof InternalApi, M extends keyof InternalApi[P] = 'get' & keyof InternalApi[P]> = InternalApi[P][M]
export type ProjectDetail = Api<'/api/projects/:id'>
export type ProjectListItem = Api<'/api/projects'>[number]
export type ChapterListItem = Api<'/api/projects/:id/chapters'>[number]
export type ChapterDetail = Api<'/api/chapters/:id'>
export type ChapterVersion = ChapterDetail['versions'][number]
export type AgentItem = Api<'/api/projects/:id/agents'>[number]
export type CharacterItem = Api<'/api/projects/:id/characters'>[number]
export type LoreItem = Api<'/api/projects/:id/lore'>[number]
export type ModelItem = Api<'/api/models'>[number]
export type ProviderItem = Api<'/api/providers'>[number]
export type JobDetail = Api<'/api/jobs/:id'>
export type CardPreview = Api<'/api/projects/:id/cards/preview', 'post'>
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'sqlite',
schema: './server/db/schema.ts',
out: './server/db/migrations',
dbCredentials: {
url: './data/app.db'
}
})
+25
View File
@@ -0,0 +1,25 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
ssr: false,
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
app: {
head: {
title: 'AI 小说工坊',
htmlAttrs: { lang: 'zh-CN' }
}
},
runtimeConfig: {
// 数据目录:数据库、章节文件、头像都存放在这里(运行时可用 NOVEL_DATA_DIR 覆盖)
dataDir: './data',
// 可选:API Key 加密密钥(64 位 hex),不填则自动生成到 data/secret.key(运行时可用 NOVEL_SECRET_KEY 覆盖)
secretKey: ''
},
nitro: {
externals: {
external: ['better-sqlite3']
}
}
})
+40
View File
@@ -0,0 +1,40 @@
{
"name": "ai-novel-studio",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"start": "node .output/server/index.mjs",
"typecheck": "vue-tsc -b --noEmit",
"db:generate": "drizzle-kit generate",
"mock:llm": "node scripts/mock-llm.mjs 4010",
"test:e2e": "node scripts/e2e.mjs http://localhost:3000 http://localhost:4010/v1"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.81",
"@ai-sdk/google": "^4.0.98",
"@ai-sdk/openai-compatible": "^3.0.70",
"@iconify-json/lucide": "^1.2.145",
"@nuxt/ui": "^4.12.0",
"ai": "^7.0.142",
"better-sqlite3": "^13.0.3",
"drizzle-orm": "^0.45.4",
"marked": "^18.1.1",
"nanoid": "^6.0.2",
"nuxt": "^4.6.1",
"tailwindcss": "^4.3.3",
"vue": "^3.5.43",
"vue-router": "^5.4.0",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/better-sqlite3": "^9.6.0",
"drizzle-kit": "^0.31.11",
"typescript": "^6.0.3",
"vue-tsc": "^3.3.13"
}
}
+9647
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
better-sqlite3: true
esbuild: true
vue-demi: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
+210
View File
@@ -0,0 +1,210 @@
// 端到端测试:配合 scripts/mock-llm.mjs 跑通完整写作流程
// 用法:node scripts/e2e.mjs <appBaseUrl> <mockBaseUrl> [角色卡.png]
import { readFileSync } from 'node:fs'
import assert from 'node:assert/strict'
const [app = 'http://localhost:3000', mock = 'http://localhost:4010/v1', cardPath] = process.argv.slice(2)
async function api(path, method = 'GET', body) {
const res = await fetch(app + path, {
method,
headers: body instanceof FormData || body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body)
})
const text = await res.text()
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${text}`)
try { return JSON.parse(text) }
catch { return text }
}
const sleep = ms => new Promise(r => setTimeout(r, ms))
async function waitIdle(pid, timeout = 30000) {
const start = Date.now()
await sleep(150)
while (Date.now() - start < timeout) {
const p = await api(`/api/projects/${pid}`)
if (!p.running) return p
await sleep(200)
}
throw new Error('等待任务完成超时')
}
const step = msg => console.log(`✔ ${msg}`)
// 1. 提供方与模型
const provider = await api('/api/providers', 'POST', { name: 'Mock', type: 'openai-compatible', baseUrl: mock, apiKey: 'test-key' })
const providers = await api('/api/providers')
assert.ok(providers.find(p => p.id === provider.id).apiKeyMasked.length > 0)
assert.ok(!JSON.stringify(providers).includes('test-key'), '接口不应返回明文 Key')
const remote = await api(`/api/providers/${provider.id}/remote-models`)
assert.deepEqual(remote, ['mock-editor', 'mock-writer-trunc'])
const editorModel = await api('/api/models', 'POST', { providerId: provider.id, modelId: 'mock-editor', label: '模拟主编' })
const writerModel = await api('/api/models', 'POST', { providerId: provider.id, modelId: 'mock-writer-trunc', label: '模拟作家', maxOutputTokens: 4096 })
const test = await api(`/api/models/${editorModel.id}/test`, 'POST', {})
assert.equal(test.ok, true, JSON.stringify(test))
step('提供方/模型/连接测试')
// 2. 项目与设定
const { id: pid } = await api('/api/projects', 'POST', { title: 'E2E 测试作品' })
await api(`/api/projects/${pid}`, 'PUT', {
worldSetting: '2011 年的昆明,雨季。',
storyOutline: '主角回到昆明,与旧识简琳琅重逢,逐步揭开百鸟县的往事。',
userName: '陈默'
})
const agents = await api(`/api/projects/${pid}/agents`)
assert.equal(agents.length, 3)
const editor = agents.find(a => a.role === 'editor')
const writer = agents.find(a => a.role === 'writer')
await api(`/api/agents/${editor.id}`, 'PUT', { modelId: editorModel.id, systemPrompt: '你是一位苛刻的主编。' })
await api(`/api/agents/${writer.id}`, 'PUT', { modelId: writerModel.id, styleNotes: '氛围描写' })
await api(`/api/agents/${agents.find(a => a.role === 'summarizer').id}`, 'PUT', { modelId: editorModel.id })
const writer2 = await api(`/api/projects/${pid}/agents`, 'POST', { role: 'writer', name: '作家二号', modelId: writerModel.id })
step('项目/AI 团队配置')
// 3. 角色卡导入
if (cardPath) {
const buf = readFileSync(cardPath)
const fd = new FormData()
fd.append('file', new Blob([buf], { type: 'image/png' }), 'card.png')
const preview = await api(`/api/projects/${pid}/cards/preview`, 'POST', fd)
assert.ok(preview.name)
assert.ok(preview.lore.length > 0)
const fd2 = new FormData()
fd2.append('file', new Blob([buf], { type: 'image/png' }), 'card.png')
fd2.append('options', JSON.stringify({ identity: '女主角' }))
const imported = await api(`/api/projects/${pid}/cards/import`, 'POST', fd2)
const chars = await api(`/api/projects/${pid}/characters`)
assert.equal(chars.length, 1)
assert.equal(chars[0].name, preview.name)
assert.ok(chars[0].avatar)
const lore = await api(`/api/projects/${pid}/lore`)
assert.equal(lore.length, imported.loreCount)
const avatar = await fetch(`${app}/api/avatars/${pid}/${chars[0].avatar}`)
assert.equal(avatar.status, 200)
step(`角色卡导入:${preview.name}(${preview.spec},世界书 ${lore.length} 条)`)
}
// 4. SSE 监听
const events = []
const ac = new AbortController()
;(async () => {
try {
const res = await fetch(`${app}/api/projects/${pid}/events`, { signal: ac.signal })
const reader = res.body.getReader()
const dec = new TextDecoder()
let buf = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
let i
while ((i = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, i)
buf = buf.slice(i + 2)
const data = block.split('\n').filter(l => l.startsWith('data:')).map(l => l.slice(5).trim()).join('')
if (data && !block.includes('event: ping')) events.push(JSON.parse(data))
}
}
}
catch {}
})()
await sleep(300)
// 5. 继续下一章(逐章模式):规划 → 简报 → 写作 → 暂停
await api(`/api/projects/${pid}/run`, 'POST', {})
await waitIdle(pid)
let chapters = await api(`/api/projects/${pid}/chapters`)
assert.equal(chapters.length, 3, '主编应规划 3 章')
assert.equal(chapters[0].status, 'written')
assert.equal(chapters[1].status, 'planned')
let ch1 = await api(`/api/chapters/${chapters[0].id}`)
assert.ok(ch1.brief.includes('情节要点'))
assert.equal(ch1.title, '雨落昆明')
assert.equal(ch1.writerAgentId, writer.id)
assert.ok(ch1.content.includes('百鸟县'), '自动续写后应包含续写内容')
assert.equal(ch1.versions.length, 1)
const deltas = events.filter(e => e.type === 'delta' && e.chapterId === ch1.id).map(e => e.text).join('')
assert.equal(deltas, ch1.content, 'SSE 推送的增量应与文件内容一致')
step(`逐章模式写完第1章(${ch1.versions[0].wordCount} 字,SSE 增量 ${events.filter(e => e.type === 'delta').length} 条)`)
// 6. 待确认时不能继续
const blocked = await api(`/api/projects/${pid}/run`, 'POST', {})
await waitIdle(pid)
chapters = await api(`/api/projects/${pid}/chapters`)
assert.equal(chapters[1].status, 'planned', '第1章未确认时不应开始第2章')
assert.ok(blocked.started)
// 7. 确认并继续
await api(`/api/chapters/${ch1.id}/approve`, 'POST', { continue: true })
await waitIdle(pid)
ch1 = await api(`/api/chapters/${ch1.id}`)
assert.equal(ch1.status, 'approved')
assert.ok(ch1.summary.includes('百鸟县'))
let project = await api(`/api/projects/${pid}`)
assert.equal(project.memory.characterStates.length, 1)
assert.equal(project.memory.foreshadowing.length, 1)
assert.equal(project.memory.timeline.length, 1)
chapters = await api(`/api/projects/${pid}/chapters`)
assert.equal(chapters[1].status, 'written')
step('确认第1章 → 摘要与故事记忆已更新 → 第2章写完')
// 8. 手动锁定作家
await api(`/api/chapters/${chapters[2].id}`, 'PUT', { writerAgentId: writer2.id, writerLocked: true })
// 9. 审稿与修改
const ch2Id = chapters[1].id
await api(`/api/chapters/${ch2Id}/review`, 'POST', {})
await waitIdle(pid)
let ch2 = await api(`/api/chapters/${ch2Id}`)
assert.ok(ch2.reviewNotes.includes('总评分'))
await api(`/api/chapters/${ch2Id}/write`, 'POST', { feedback: ch2.reviewNotes, revise: true })
await waitIdle(pid)
ch2 = await api(`/api/chapters/${ch2Id}`)
assert.equal(ch2.versions.length, 2)
assert.equal(ch2.activeVersionId, ch2.versions[0].id)
const writeJob = (await api(`/api/projects/${pid}/jobs`)).find(j => j.type === 'write' && j.chapterId === ch2Id)
const writeJobDetail = await api(`/api/jobs/${writeJob.id}`)
assert.ok(writeJobDetail.prompt.prompt.includes('revision_feedback'))
assert.ok(writeJobDetail.prompt.prompt.includes('previous_draft'))
step('主编审稿 → 按意见修改生成 v2')
// 10. 自动模式
await api(`/api/projects/${pid}`, 'PUT', { settings: { ...project.settings, autoMode: true, autoChapterLimit: 1 } })
await api(`/api/projects/${pid}/run`, 'POST', {})
await waitIdle(pid)
chapters = await api(`/api/projects/${pid}/chapters`)
assert.equal(chapters[1].status, 'approved', '自动模式应自动确认第2章')
assert.equal(chapters[2].status, 'written')
const ch3 = await api(`/api/chapters/${chapters[2].id}`)
assert.equal(ch3.writerAgentId, writer2.id, '锁定的作家不应被主编更换')
const briefJob = (await api(`/api/projects/${pid}/jobs`)).find(j => j.type === 'brief' && j.chapterId === ch3.id)
const briefDetail = await api(`/api/jobs/${briefJob.id}`)
assert.ok(briefDetail.prompt.system.startsWith('<highest_priority_instructions>'), '用户自定义提示词应位于最前')
assert.ok(briefDetail.prompt.prompt.includes('简琳琅') || !cardPath)
step('自动模式:自动确认第2章并写完第3章(遵守章节上限与作家锁定)')
// 11. 停止
await api(`/api/chapters/${chapters[2].id}/write`, 'POST', {})
await sleep(100)
await api(`/api/projects/${pid}/stop`, 'POST', {})
await waitIdle(pid)
const ch3b = await api(`/api/chapters/${chapters[2].id}`)
assert.equal(ch3b.status, 'written', '取消后应恢复为之前的状态')
assert.equal(ch3b.versions[0].status, 'cancelled')
assert.equal(ch3b.activeVersionId, ch3.activeVersionId, '取消的版本不应成为当前版本')
step('写作中途停止 → 状态回滚,旧版本保留')
// 12. 导出
const txt = await api(`/api/projects/${pid}/export?format=txt&scope=approved`)
assert.ok(txt.includes('第1章 雨落昆明'))
assert.ok(!txt.includes('第3章'))
step('导出 TXT')
const jobs = await api(`/api/projects/${pid}/jobs`)
const errors = jobs.filter(j => j.status === 'error')
assert.equal(errors.length, 0, JSON.stringify(errors, null, 2))
step(`共 ${jobs.length} 个任务,无错误;token 统计:${jobs.reduce((s, j) => s + j.inputTokens + j.outputTokens, 0)}`)
ac.abort()
await api(`/api/projects/${pid}`, 'DELETE')
console.log('\n全部通过 ✅')
process.exit(0)
+129
View File
@@ -0,0 +1,129 @@
// 模拟 OpenAI 兼容接口,用于本地联调和端到端测试(不消耗真实 token)
// 用法:node scripts/mock-llm.mjs [port]
// - 模型 id 含 "trunc" 时,作家第一轮输出会以 finish_reason=length 结束,用于测试自动续写
import { createServer } from 'node:http'
const port = Number(process.argv[2] || process.env.MOCK_PORT || 4010)
const log = (...a) => console.log('[mock]', ...a)
function json(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
function completion(model, message, finish = 'stop') {
return {
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, message: { role: 'assistant', content: null, ...message }, finish_reason: finish }],
usage: { prompt_tokens: 1200, completion_tokens: 300, total_tokens: 1500 }
}
}
const toolCall = (name, args) => ({
id: `call_${name}_${Math.random().toString(36).slice(2, 8)}`,
type: 'function',
function: { name, arguments: JSON.stringify(args) }
})
const text = m => (typeof m.content === 'string' ? m.content : (m.content || []).map(p => p.text || '').join(''))
function handleTools(body) {
const names = body.tools.map(t => t.function.name)
const hasToolResult = body.messages.some(m => m.role === 'tool')
const prompt = body.messages.filter(m => m.role === 'user').map(text).join('\n')
if (names.includes('set_chapter_plan')) {
if (hasToolResult) return completion(body.model, { content: '整体采用三幕结构,前三章完成人物登场与核心冲突的建立。' })
const m = prompt.match(/从第(\d+)章开始的 (\d+) 个章节/)
const from = m ? Number(m[1]) : 1
const count = Math.min(m ? Number(m[2]) : 3, 3)
const chapters = Array.from({ length: count }, (_, i) => ({
index: from + i,
title: ['雨季', '旧城', '回声'][i] ?? `第${from + i}章`,
synopsis: `第${from + i}章梗概:主角在雨中重逢旧识,埋下关于旧城往事的悬念。`
}))
return completion(body.model, { tool_calls: [toolCall('set_chapter_plan', { chapters })] }, 'tool_calls')
}
if (names.includes('write_brief')) {
if (hasToolResult) return completion(body.model, { content: '本章以环境开篇,节奏先慢后快。' })
const writerId = prompt.match(/id=([a-z0-9]+)|/)?.[1]
const calls = [toolCall('write_brief', {
title: '雨落昆明',
goal: '让主角与简琳琅在雨中初遇,建立两人之间的张力',
plotPoints: ['午后雷声,雨季开始', '主角在骑楼下避雨', '简琳琅撑伞经过,两人短暂交谈', '她留下一个意味深长的问题'],
characters: ['简琳琅:表面强势,内心戒备'],
setting: '2011 年 5 月底,昆明老街',
mood: '潮湿、暧昧、略带不安',
targetWords: 1200,
endingHook: '她问:你还记得百鸟县吗?'
})]
if (writerId && !prompt.includes('用户已为本章指定作家')) calls.push(toolCall('assign_writer', { writerId, reason: '擅长氛围描写' }))
return completion(body.model, { tool_calls: calls }, 'tool_calls')
}
return completion(body.model, { content: '好的。' })
}
function handleText(body) {
const system = body.messages.filter(m => m.role === 'system').map(text).join('\n')
if (system.includes('资料员')) {
return completion(body.model, {
content: '```json\n' + JSON.stringify({
summary: '雨季的第一声雷后,主角在骑楼下避雨,与撑伞经过的简琳琅短暂交谈。她看似随意,却在离开前提起了百鸟县。',
characterStates: [{ name: '简琳琅', state: '刚与主角重逢,情绪克制,似乎有所隐瞒' }],
newForeshadowing: ['简琳琅提起百鸟县的真正用意'],
resolvedForeshadowing: [],
events: ['主角与简琳琅雨中重逢']
}) + '\n```'
})
}
return completion(body.model, { content: '## 审稿意见\n\n1. 情节点全部完成。\n2. 结尾钩子可以更含蓄。\n\n**总评分:8/10**' })
}
function streamChapter(res, body) {
const userTurns = body.messages.filter(m => m.role === 'user').length
const truncate = body.model.includes('trunc') && userTurns === 1
const paragraphs = truncate
? ['午后,一声闷雷从天边滚过来。', '\n\n雨点砸在青石板上,溅起细碎的水花。']
: userTurns > 1
? ['\n\n她收起伞,抬眼看他:“你还记得百鸟县吗?”']
: ['午后,一声闷雷从天边滚过来。', '\n\n雨来得又急又密,他退到骑楼下。', '\n\n简琳琅撑着一把黑伞从街角走来,高跟鞋踩过积水。', '\n\n“借个地方躲雨。”她说。', '\n\n她收起伞,抬眼看他:“你还记得百鸟县吗?”']
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' })
const base = { id: `chatcmpl-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: body.model }
const send = obj => res.write(`data: ${JSON.stringify({ ...base, ...obj })}\n\n`)
send({ choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] })
const chunks = paragraphs.flatMap(p => p.match(/.{1,4}/gs))
let i = 0
const timer = setInterval(() => {
if (res.destroyed) return clearInterval(timer)
if (i < chunks.length) {
send({ choices: [{ index: 0, delta: { content: chunks[i++] }, finish_reason: null }] })
return
}
clearInterval(timer)
send({ choices: [{ index: 0, delta: {}, finish_reason: truncate ? 'length' : 'stop' }] })
send({ choices: [], usage: { prompt_tokens: 2000, completion_tokens: 400, total_tokens: 2400 } })
res.write('data: [DONE]\n\n')
res.end()
}, 15)
}
createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${port}`)
if (req.method === 'GET' && url.pathname.endsWith('/models')) {
return json(res, 200, { object: 'list', data: [{ id: 'mock-editor' }, { id: 'mock-writer-trunc' }] })
}
if (req.method === 'POST' && url.pathname.endsWith('/chat/completions')) {
let raw = ''
for await (const c of req) raw += c
const body = JSON.parse(raw)
if (req.headers.authorization !== 'Bearer test-key') return json(res, 401, { error: { message: 'invalid api key' } })
log(body.model, body.stream ? 'stream' : 'json', body.tools ? `tools=${body.tools.map(t => t.function.name).join(',')}` : '')
if (body.stream) return streamChapter(res, body)
return json(res, 200, body.tools?.length ? handleTools(body) : handleText(body))
}
json(res, 404, { error: { message: 'not found' } })
}).listen(port, () => log(`listening on http://localhost:${port}/v1`))
+10
View File
@@ -0,0 +1,10 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
const agent = getAgent(param(event))
if (!agent) return { ok: true }
if (agent.role !== 'writer') throw createError({ statusCode: 400, message: '主编和摘要员不能删除,可以修改其模型和提示词' })
useDb().delete(tables.agents).where(eq(tables.agents.id, agent.id)).run()
emitProject(agent.projectId, { type: 'agents' })
return { ok: true }
})
+9
View File
@@ -0,0 +1,9 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const { role: _role, ...body } = await readPatch(event, agentSchema)
const row = useDb().update(tables.agents).set(body).where(eq(tables.agents.id, param(event))).returning().get()
if (!row) throw createError({ statusCode: 404, message: '成员不存在' })
emitProject(row.projectId, { type: 'agents' })
return row
})
@@ -0,0 +1,13 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
export default defineEventHandler((event) => {
const projectId = param(event, 'projectId')
const file = param(event, 'file')
if (!/^[\w-]+\.png$/.test(file) || !/^[\w-]+$/.test(projectId)) throw createError({ statusCode: 400, message: '非法文件名' })
const p = join(projectDir(projectId), 'avatars', file)
if (!existsSync(p)) throw createError({ statusCode: 404, message: '头像不存在' })
setHeader(event, 'Content-Type', 'image/png')
setHeader(event, 'Cache-Control', 'public, max-age=86400')
return readFileSync(p)
})
+13
View File
@@ -0,0 +1,13 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
const chapter = getChapter(param(event))
if (getActiveRun(chapter.projectId)) throw createError({ statusCode: 409, message: '有任务正在运行,请先停止再删除章节' })
const db = useDb()
db.transaction(() => {
db.delete(tables.chapters).where(eq(tables.chapters.id, chapter.id)).run()
reindexAfterDelete(chapter.projectId, chapter.index)
})
emitProject(chapter.projectId, { type: 'chapters' })
return { ok: true }
})
+11
View File
@@ -0,0 +1,11 @@
import { desc, eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const chapter = getChapter(param(event))
const versions = useDb().select().from(tables.chapterVersions)
.where(eq(tables.chapterVersions.chapterId, chapter.id))
.orderBy(desc(tables.chapterVersions.version)).all()
const active = versions.find(v => v.id === chapter.activeVersionId)
const content = active ? stripHeading(await readChapterFile(active.filePath)) : ''
return { ...chapter, versions, content }
})
+24
View File
@@ -0,0 +1,24 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
const schema = z.object({
title: z.string(),
synopsis: z.string(),
brief: z.string(),
writerAgentId: z.string().nullable(),
writerLocked: z.boolean(),
summary: z.string(),
reviewNotes: z.string()
})
export default defineEventHandler(async (event) => {
const chapter = getChapter(param(event))
const body = await readPatch(event, schema)
const patch: Partial<typeof tables.chapters.$inferInsert> = { ...body }
// 手动填写简报后,章节进入“已出简报”状态
if (body.brief !== undefined && chapter.status === 'planned' && body.brief.trim()) patch.status = 'briefed'
if (body.brief !== undefined && chapter.status === 'briefed' && !body.brief.trim()) patch.status = 'planned'
const row = useDb().update(tables.chapters).set(patch).where(eq(tables.chapters.id, chapter.id)).returning().get()
emitProject(chapter.projectId, { type: 'chapter', chapterId: chapter.id })
return row
})
+19
View File
@@ -0,0 +1,19 @@
import { and, eq } from 'drizzle-orm'
import { z } from 'zod'
/** 切换章节的当前版本 */
export default defineEventHandler(async (event) => {
const chapter = getChapter(param(event))
const { versionId } = await readJson(event, z.object({ versionId: z.string() }))
const v = useDb().select().from(tables.chapterVersions)
.where(and(eq(tables.chapterVersions.id, versionId), eq(tables.chapterVersions.chapterId, chapter.id))).get()
if (!v) throw createError({ statusCode: 404, message: '版本不存在' })
if (v.status === 'writing') throw createError({ statusCode: 400, message: '该版本仍在写作中' })
useDb().update(tables.chapters).set({
activeVersionId: v.id,
// 切换版本后需要重新确认,以便重新生成摘要
status: chapter.status === 'approved' ? 'written' : chapter.status === 'writing' ? chapter.status : 'written'
}).where(eq(tables.chapters.id, chapter.id)).run()
emitProject(chapter.projectId, { type: 'chapter', chapterId: chapter.id })
return { ok: true }
})
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod'
/** 确认章节:生成摘要、更新故事记忆;continue=true 时接着写下一章 */
export default defineEventHandler(async (event) => {
const chapter = getChapter(param(event))
const { continue: next } = await readJson(event, z.object({ continue: z.boolean().default(false) }))
if (!chapter.activeVersionId) throw createError({ statusCode: 400, message: '本章还没有正文' })
runExclusive(chapter.projectId, next ? `确认第${chapter.index}章并继续` : `确认第${chapter.index}章`, async (signal) => {
await approveChapter(chapter.projectId, chapter.id, signal)
if (next) await runPipeline(chapter.projectId, signal)
})
return { started: true }
})
+7
View File
@@ -0,0 +1,7 @@
/** 让主编为本章生成简报并指派作家 */
export default defineEventHandler((event) => {
const chapter = getChapter(param(event))
if (chapter.status === 'writing') throw createError({ statusCode: 409, message: '本章正在写作中' })
runExclusive(chapter.projectId, `第${chapter.index}章简报`, signal => generateBrief(chapter.projectId, chapter.id, signal))
return { started: true }
})
+10
View File
@@ -0,0 +1,10 @@
import { eq } from 'drizzle-orm'
/** 撤销确认:已确认的章节回到“待确认”状态 */
export default defineEventHandler((event) => {
const chapter = getChapter(param(event))
if (chapter.status !== 'approved') return { ok: true }
useDb().update(tables.chapters).set({ status: 'written' }).where(eq(tables.chapters.id, chapter.id)).run()
emitProject(chapter.projectId, { type: 'chapter', chapterId: chapter.id })
return { ok: true }
})
+7
View File
@@ -0,0 +1,7 @@
/** 主编审稿 */
export default defineEventHandler((event) => {
const chapter = getChapter(param(event))
if (!chapter.activeVersionId) throw createError({ statusCode: 400, message: '本章还没有正文' })
runExclusive(chapter.projectId, `审阅第${chapter.index}章`, signal => reviewChapter(chapter.projectId, chapter.id, signal))
return { started: true }
})
+18
View File
@@ -0,0 +1,18 @@
import { z } from 'zod'
const schema = z.object({
/** 指定作家;不传则使用章节已指派的作家 */
writerAgentId: z.string().nullable().optional(),
/** 修改意见 */
feedback: z.string().optional(),
/** true:在当前版本基础上按意见修改;false:重新写一版 */
revise: z.boolean().optional()
})
export default defineEventHandler(async (event) => {
const chapter = getChapter(param(event))
const body = await readJson(event, schema)
if (body.revise && !chapter.activeVersionId) throw createError({ statusCode: 400, message: '本章还没有可修改的版本' })
runExclusive(chapter.projectId, `第${chapter.index}章写作`, signal => writeChapter(chapter.projectId, chapter.id, signal, body))
return { started: true }
})
+6
View File
@@ -0,0 +1,6 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
useDb().delete(tables.characters).where(eq(tables.characters.id, param(event))).run()
return { ok: true }
})
+8
View File
@@ -0,0 +1,8 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const body = await readPatch(event, characterSchema)
const row = useDb().update(tables.characters).set(body).where(eq(tables.characters.id, param(event))).returning().get()
if (!row) throw createError({ statusCode: 404, message: '角色不存在' })
return row
})
+8
View File
@@ -0,0 +1,8 @@
import { eq } from 'drizzle-orm'
/** 任务详情(含完整提示词,用于“提示词检视器”) */
export default defineEventHandler((event) => {
const job = useDb().select().from(tables.jobs).where(eq(tables.jobs.id, param(event))).get()
if (!job) throw createError({ statusCode: 404, message: '任务不存在' })
return job
})
+6
View File
@@ -0,0 +1,6 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
useDb().delete(tables.loreEntries).where(eq(tables.loreEntries.id, param(event))).run()
return { ok: true }
})
+8
View File
@@ -0,0 +1,8 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const body = await readPatch(event, loreSchema)
const row = useDb().update(tables.loreEntries).set(body).where(eq(tables.loreEntries.id, param(event))).returning().get()
if (!row) throw createError({ statusCode: 404, message: '条目不存在' })
return row
})
+6
View File
@@ -0,0 +1,6 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
useDb().delete(tables.models).where(eq(tables.models.id, param(event))).run()
return { ok: true }
})
+7
View File
@@ -0,0 +1,7 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const body = await readPatch(event, modelSchema)
useDb().update(tables.models).set(body).where(eq(tables.models.id, param(event))).run()
return { ok: true }
})
+14
View File
@@ -0,0 +1,14 @@
import { generateText } from 'ai'
/** 发送一条极短的请求,验证模型可用 */
export default defineEventHandler(async (event) => {
const { model, label } = resolveModel(param(event))
const started = Date.now()
try {
const res = await generateText({ model, prompt: '请只回复“连接成功”四个字。', maxOutputTokens: 512, abortSignal: AbortSignal.timeout(60_000) })
return { ok: true, label, text: res.text.slice(0, 200), ms: Date.now() - started }
}
catch (err) {
return { ok: false, label, error: describeError(err), ms: Date.now() - started }
}
})
+18
View File
@@ -0,0 +1,18 @@
import { asc, eq } from 'drizzle-orm'
export default defineEventHandler(() => {
return useDb().select({
id: tables.models.id,
providerId: tables.models.providerId,
providerName: tables.providers.name,
providerType: tables.providers.type,
label: tables.models.label,
modelId: tables.models.modelId,
temperature: tables.models.temperature,
topP: tables.models.topP,
maxOutputTokens: tables.models.maxOutputTokens
}).from(tables.models)
.innerJoin(tables.providers, eq(tables.models.providerId, tables.providers.id))
.orderBy(asc(tables.providers.createdAt), asc(tables.models.createdAt))
.all()
})
+5
View File
@@ -0,0 +1,5 @@
export default defineEventHandler(async (event) => {
const body = await readJson(event, modelSchema)
const row = useDb().insert(tables.models).values({ id: newId(), ...body, label: body.label || body.modelId }).returning().get()
return { id: row.id }
})
+9
View File
@@ -0,0 +1,9 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
const id = param(event)
stopRun(id)
useDb().delete(tables.projects).where(eq(tables.projects.id, id)).run()
removeProjectFiles(id)
return { ok: true }
})
+4
View File
@@ -0,0 +1,4 @@
export default defineEventHandler((event) => {
const id = param(event)
return { ...getProject(id), running: getActiveRun(id) }
})
+21
View File
@@ -0,0 +1,21 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
const schema = z.object({
title: z.string().min(1),
description: z.string(),
worldSetting: z.string(),
storyOutline: z.string(),
userName: z.string(),
requirements: requirementsSchema,
settings: settingsSchema,
memory: memorySchema
})
export default defineEventHandler(async (event) => {
const id = param(event)
getProject(id)
const body = await readPatch(event, schema)
useDb().update(tables.projects).set(body).where(eq(tables.projects.id, id)).run()
return getProject(id)
})
+7
View File
@@ -0,0 +1,7 @@
import { asc, eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
return useDb().select().from(tables.agents)
.where(eq(tables.agents.projectId, param(event)))
.orderBy(asc(tables.agents.createdAt)).all()
})
+11
View File
@@ -0,0 +1,11 @@
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const body = await readJson(event, agentSchema)
if (body.role !== 'writer' && getRoleAgent(projectId, body.role)) {
throw createError({ statusCode: 400, message: '每个项目只能有一个主编和一个摘要员' })
}
const row = useDb().insert(tables.agents).values({ id: newId(), projectId, ...body }).returning().get()
emitProject(projectId, { type: 'agents' })
return row
})
@@ -0,0 +1,93 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { eq, sql } from 'drizzle-orm'
import { z } from 'zod'
const optionsSchema = z.object({
/** 把卡片的 scenario 追加到故事背景 */
scenarioToWorld: z.boolean().default(true),
/** 导入角色卡内置世界书 */
importLore: z.boolean().default(true),
/** 把开场白(first_mes)作为文风/说话示例 */
firstMesAsSample: z.boolean().default(true),
/** 把卡片的 system_prompt 写入角色备注 */
systemPromptToNotes: z.boolean().default(false),
/** {{user}} 的替换名,留空则使用项目设置 */
userName: z.string().default(''),
/** 角色身份标签,如“女主角” */
identity: z.string().default('')
})
export default defineEventHandler(async (event) => {
const projectId = param(event)
const project = getProject(projectId)
const form = await readMultipartFormData(event)
const file = form?.find(f => f.name === 'file')
if (!file?.data?.length) throw createError({ statusCode: 400, message: '请上传角色卡文件' })
const optRaw = form?.find(f => f.name === 'options')?.data.toString('utf8') || '{}'
const opts = optionsSchema.parse(JSON.parse(optRaw))
let card
try { card = parseCardFile(file.data, file.filename) }
catch (err) { throw createError({ statusCode: 400, message: (err as Error).message }) }
const userName = opts.userName || project.userName
const m = (s: string) => replaceMacros(s, card.name, userName).trim()
const db = useDb()
const charId = newId()
const samples: string[] = []
if (card.mesExample.trim()) samples.push(m(card.mesExample))
if (opts.firstMesAsSample && card.firstMes.trim()) samples.push(`【开场白示例】\n${m(card.firstMes)}`)
const notes: string[] = []
if (!opts.scenarioToWorld && card.scenario.trim()) notes.push(`【场景】${m(card.scenario)}`)
if (opts.systemPromptToNotes && card.systemPrompt.trim()) notes.push(`【卡片系统提示】${m(card.systemPrompt)}`)
if (card.postHistoryInstructions.trim() && opts.systemPromptToNotes) notes.push(`【补充指令】${m(card.postHistoryInstructions)}`)
let avatar = ''
if (file.data[0] === 0x89) {
const dir = join(projectDir(projectId), 'avatars')
mkdirSync(dir, { recursive: true })
avatar = `${charId}.png`
writeFileSync(join(dir, avatar), file.data)
}
const count = db.select({ n: sql<number>`count(*)` }).from(tables.characters).where(eq(tables.characters.projectId, projectId)).get()?.n ?? 0
db.transaction((tx) => {
tx.insert(tables.characters).values({
id: charId,
projectId,
name: card.name,
identity: opts.identity,
description: m(card.description),
personality: m(card.personality),
speechExamples: samples.join('\n\n'),
notes: notes.join('\n\n'),
avatar,
sourceCard: card.raw,
sortOrder: count
}).run()
if (opts.importLore && card.lore.length) {
tx.insert(tables.loreEntries).values(card.lore.map(l => ({
id: newId(),
projectId,
title: `${card.name}·${l.title}`,
keys: l.keys,
content: m(l.content),
constant: l.constant,
enabled: l.enabled,
source: 'card' as const,
sortOrder: l.order
}))).run()
}
if (opts.scenarioToWorld && card.scenario.trim()) {
const world = [project.worldSetting.trim(), `【${card.name}·场景】\n${m(card.scenario)}`].filter(Boolean).join('\n\n')
tx.update(tables.projects).set({ worldSetting: world }).where(eq(tables.projects.id, projectId)).run()
}
})
return { id: charId, name: card.name, loreCount: opts.importLore ? card.lore.length : 0 }
})
@@ -0,0 +1,15 @@
/** 解析角色卡并返回预览(不写入数据库) */
export default defineEventHandler(async (event) => {
getProject(param(event))
const form = await readMultipartFormData(event)
const file = form?.find(f => f.name === 'file')
if (!file?.data?.length) throw createError({ statusCode: 400, message: '请上传角色卡文件(PNG 或 JSON)' })
try {
const card = parseCardFile(file.data, file.filename)
const { raw: _raw, ...rest } = card
return { ...rest, isPng: file.data[0] === 0x89, filename: file.filename ?? '' }
}
catch (err) {
throw createError({ statusCode: 400, message: (err as Error).message })
}
})
+24
View File
@@ -0,0 +1,24 @@
import { asc, eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
const projectId = param(event)
const db = useDb()
return db.select({
id: tables.chapters.id,
index: tables.chapters.index,
title: tables.chapters.title,
synopsis: tables.chapters.synopsis,
status: tables.chapters.status,
writerAgentId: tables.chapters.writerAgentId,
writerLocked: tables.chapters.writerLocked,
activeVersionId: tables.chapters.activeVersionId,
hasBrief: tables.chapters.brief,
wordCount: tables.chapterVersions.wordCount,
updatedAt: tables.chapters.updatedAt
}).from(tables.chapters)
.leftJoin(tables.chapterVersions, eq(tables.chapterVersions.id, tables.chapters.activeVersionId))
.where(eq(tables.chapters.projectId, projectId))
.orderBy(asc(tables.chapters.index))
.all()
.map(c => ({ ...c, hasBrief: Boolean(c.hasBrief?.trim()), wordCount: c.wordCount ?? 0 }))
})
+27
View File
@@ -0,0 +1,27 @@
import { and, eq, gte, sql } from 'drizzle-orm'
import { z } from 'zod'
const schema = z.object({
title: z.string().default(''),
synopsis: z.string().default(''),
/** 插入位置;不传则追加到末尾 */
index: z.number().int().positive().optional()
})
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const body = await readJson(event, schema)
const db = useDb()
const max = db.select({ m: sql<number>`coalesce(max(${tables.chapters.index}), 0)` }).from(tables.chapters).where(eq(tables.chapters.projectId, projectId)).get()?.m ?? 0
const index = body.index && body.index <= max ? body.index : max + 1
const row = db.transaction((tx) => {
if (index <= max) {
tx.update(tables.chapters).set({ index: sql`${tables.chapters.index} + 1` })
.where(and(eq(tables.chapters.projectId, projectId), gte(tables.chapters.index, index))).run()
}
return tx.insert(tables.chapters).values({ id: newId(), projectId, index, title: body.title, synopsis: body.synopsis }).returning().get()
})
emitProject(projectId, { type: 'chapters' })
return row
})
@@ -0,0 +1,7 @@
import { asc, eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
return useDb().select().from(tables.characters)
.where(eq(tables.characters.projectId, param(event)))
.orderBy(asc(tables.characters.sortOrder), asc(tables.characters.createdAt)).all()
})
@@ -0,0 +1,6 @@
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const body = await readJson(event, characterSchema)
return useDb().insert(tables.characters).values({ id: newId(), projectId, ...body }).returning().get()
})
+19
View File
@@ -0,0 +1,19 @@
import type { ProjectEvent } from '#shared/types'
/** SSE:推送任务进度、写作增量、章节变更 */
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const stream = createEventStream(event)
const send = (e: ProjectEvent) => { stream.push(JSON.stringify(e)).catch(() => {}) }
const unsubscribe = subscribeProject(projectId, send)
const run = getActiveRun(projectId)
send({ type: 'pipeline', running: Boolean(run), message: run?.label })
const heartbeat = setInterval(() => { stream.push({ event: 'ping', data: String(Date.now()) }).catch(() => {}) }, 20_000)
stream.onClosed(async () => {
clearInterval(heartbeat)
unsubscribe()
await stream.close()
})
return stream.send()
})
+23
View File
@@ -0,0 +1,23 @@
/** 导出整本小说:format=txt|md,scope=approved|all */
export default defineEventHandler(async (event) => {
const project = getProject(param(event))
const q = getQuery(event)
const format = q.format === 'md' ? 'md' : 'txt'
const scope = q.scope === 'approved' ? 'approved' : 'all'
const chapters = listChapters(project.id).filter(c => c.activeVersionId && (scope === 'all' || c.status === 'approved'))
const parts: string[] = []
parts.push(format === 'md' ? `# ${project.title}` : project.title)
if (project.description) parts.push(project.description)
for (const c of chapters) {
const v = getVersion(c.activeVersionId)
if (!v) continue
const body = stripHeading(await readChapterFile(v.filePath)).trim()
const heading = `第${c.index}章 ${c.title}`
parts.push(format === 'md' ? `## ${heading}\n\n${body}` : `${heading}\n\n${body}`)
}
const filename = encodeURIComponent(`${project.title}.${format}`)
setHeader(event, 'Content-Type', format === 'md' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8')
setHeader(event, 'Content-Disposition', `attachment; filename*=UTF-8''${filename}`)
return parts.join('\n\n\n')
})
+10
View File
@@ -0,0 +1,10 @@
import { desc, eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
const limit = Math.min(Number(getQuery(event).limit) || 100, 500)
return useDb().select().from(tables.jobs)
.where(eq(tables.jobs.projectId, param(event)))
.orderBy(desc(tables.jobs.createdAt))
.limit(limit).all()
.map(toJobInfo)
})
+7
View File
@@ -0,0 +1,7 @@
import { asc, eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
return useDb().select().from(tables.loreEntries)
.where(eq(tables.loreEntries.projectId, param(event)))
.orderBy(asc(tables.loreEntries.sortOrder), asc(tables.loreEntries.createdAt)).all()
})
+6
View File
@@ -0,0 +1,6 @@
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const body = await readJson(event, loreSchema)
return useDb().insert(tables.loreEntries).values({ id: newId(), projectId, ...body }).returning().get()
})
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod'
/** 主编规划章节大纲 */
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const body = await readJson(event, z.object({
count: z.number().int().min(1).max(100).default(10),
fromIndex: z.number().int().positive().optional()
}))
runExclusive(projectId, '规划章节大纲', signal => planOutline(projectId, signal, body))
return { started: true }
})
+7
View File
@@ -0,0 +1,7 @@
/** 继续写作:推进流水线 */
export default defineEventHandler((event) => {
const projectId = param(event)
const project = getProject(projectId)
runExclusive(projectId, project.settings.autoMode ? '自动写作' : '继续下一章', signal => runPipeline(projectId, signal))
return { started: true }
})
+3
View File
@@ -0,0 +1,3 @@
export default defineEventHandler((event) => {
return { stopped: stopRun(param(event)) }
})
+20
View File
@@ -0,0 +1,20 @@
import { desc, sql } from 'drizzle-orm'
export default defineEventHandler(() => {
const db = useDb()
const rows = db.select().from(tables.projects).orderBy(desc(tables.projects.updatedAt)).all()
const stats = db.select({
projectId: tables.chapters.projectId,
total: sql<number>`count(*)`,
approved: sql<number>`sum(case when ${tables.chapters.status} = 'approved' then 1 else 0 end)`,
words: sql<number>`coalesce(sum((select word_count from chapter_versions v where v.id = ${tables.chapters.activeVersionId})), 0)`
}).from(tables.chapters).groupBy(tables.chapters.projectId).all()
return rows.map(p => ({
id: p.id,
title: p.title,
description: p.description,
updatedAt: p.updatedAt,
stats: stats.find(s => s.projectId === p.id) ?? { total: 0, approved: 0, words: 0 },
running: getActiveRun(p.id)
}))
})
+32
View File
@@ -0,0 +1,32 @@
import { asc } from 'drizzle-orm'
import { z } from 'zod'
import { DEFAULT_REQUIREMENTS, DEFAULT_SETTINGS, EMPTY_MEMORY } from '#shared/types'
const schema = z.object({
title: z.string().min(1),
description: z.string().default('')
})
export default defineEventHandler(async (event) => {
const body = await readJson(event, schema)
const db = useDb()
const id = newId()
const firstModel = db.select().from(tables.models).orderBy(asc(tables.models.createdAt)).get()
const modelId = firstModel?.id ?? null
db.transaction((tx) => {
tx.insert(tables.projects).values({
id,
title: body.title,
description: body.description,
requirements: { ...DEFAULT_REQUIREMENTS },
settings: { ...DEFAULT_SETTINGS },
memory: structuredClone(EMPTY_MEMORY)
}).run()
tx.insert(tables.agents).values([
{ id: newId(), projectId: id, role: 'editor', name: '主编', modelId },
{ id: newId(), projectId: id, role: 'summarizer', name: '摘要员', modelId },
{ id: newId(), projectId: id, role: 'writer', name: '作家·默认', modelId, styleNotes: '通用叙事' }
]).run()
})
return { id }
})
+6
View File
@@ -0,0 +1,6 @@
/** 内置角色提示词(只读展示,让用户了解自定义提示词会覆盖什么) */
export default defineEventHandler(() => ({
editor: EDITOR_BUILTIN,
writer: WRITER_BUILTIN,
summarizer: SUMMARIZER_BUILTIN
}))
+6
View File
@@ -0,0 +1,6 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
useDb().delete(tables.providers).where(eq(tables.providers.id, param(event))).run()
return { ok: true }
})
+22
View File
@@ -0,0 +1,22 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1).optional(),
type: z.enum(['openai-compatible', 'anthropic', 'google']).optional(),
baseUrl: z.string().optional(),
// 不传或传空字符串表示保留原有密钥
apiKey: z.string().optional()
})
export default defineEventHandler(async (event) => {
const id = param(event)
const body = await readJson(event, schema)
const patch: Partial<typeof tables.providers.$inferInsert> = {}
if (body.name !== undefined) patch.name = body.name
if (body.type !== undefined) patch.type = body.type
if (body.baseUrl !== undefined) patch.baseUrl = body.baseUrl.trim()
if (body.apiKey) patch.apiKeyEnc = encryptSecret(body.apiKey.trim())
useDb().update(tables.providers).set(patch).where(eq(tables.providers.id, id)).run()
return { ok: true }
})
@@ -0,0 +1,13 @@
import { eq } from 'drizzle-orm'
/** 拉取提供方的可用模型列表 */
export default defineEventHandler(async (event) => {
const p = useDb().select().from(tables.providers).where(eq(tables.providers.id, param(event))).get()
if (!p) throw createError({ statusCode: 404, message: '提供方不存在' })
try {
return await listRemoteModels(p.type, p.baseUrl, decryptSecret(p.apiKeyEnc))
}
catch (err) {
throw createError({ statusCode: 502, message: `获取模型列表失败:${describeError(err)}` })
}
})
+11
View File
@@ -0,0 +1,11 @@
import { asc } from 'drizzle-orm'
export default defineEventHandler(() => {
const rows = useDb().select().from(tables.providers).orderBy(asc(tables.providers.createdAt)).all()
return rows.map(({ apiKeyEnc, ...p }) => {
let masked = ''
try { masked = maskSecret(decryptSecret(apiKeyEnc)) }
catch { masked = '(无法解密,请重新填写)' }
return { ...p, apiKeyMasked: masked, hasKey: Boolean(apiKeyEnc) }
})
})
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1),
type: z.enum(['openai-compatible', 'anthropic', 'google']),
baseUrl: z.string().default(''),
apiKey: z.string().default('')
})
export default defineEventHandler(async (event) => {
const body = await readJson(event, schema)
const row = useDb().insert(tables.providers).values({
id: newId(),
name: body.name,
type: body.type,
baseUrl: body.baseUrl.trim(),
apiKeyEnc: encryptSecret(body.apiKey.trim())
}).returning().get()
return { id: row.id }
})
+5
View File
@@ -0,0 +1,5 @@
export default defineEventHandler(async (event) => {
const v = getVersion(param(event))
if (!v) throw createError({ statusCode: 404, message: '版本不存在' })
return { ...v, content: stripHeading(await readChapterFile(v.filePath)) }
})
+16
View File
@@ -0,0 +1,16 @@
import { writeFile } from 'node:fs/promises'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
/** 手动编辑版本正文(直接改写文件) */
export default defineEventHandler(async (event) => {
const v = getVersion(param(event))
if (!v) throw createError({ statusCode: 404, message: '版本不存在' })
if (v.status === 'writing') throw createError({ statusCode: 409, message: '该版本正在写作中,无法编辑' })
const { content } = await readJson(event, z.object({ content: z.string() }))
const chapter = getChapter(v.chapterId)
await writeFile(absPath(v.filePath), `# 第${chapter.index}章 ${chapter.title}\n\n${content}`, 'utf8')
useDb().update(tables.chapterVersions).set({ wordCount: countWords(content), status: 'done' }).where(eq(tables.chapterVersions.id, v.id)).run()
emitProject(chapter.projectId, { type: 'chapter', chapterId: chapter.id })
return { ok: true }
})
+143
View File
@@ -0,0 +1,143 @@
CREATE TABLE `agents` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`role` text NOT NULL,
`name` text NOT NULL,
`model_id` text,
`system_prompt` text DEFAULT '' NOT NULL,
`style_notes` text DEFAULT '' NOT NULL,
`created_by` text DEFAULT 'user' NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`model_id`) REFERENCES `models`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `agents_project_idx` ON `agents` (`project_id`);--> statement-breakpoint
CREATE TABLE `chapter_versions` (
`id` text PRIMARY KEY NOT NULL,
`chapter_id` text NOT NULL,
`version` integer NOT NULL,
`file_path` text NOT NULL,
`word_count` integer DEFAULT 0 NOT NULL,
`writer_agent_id` text,
`writer_name` text DEFAULT '' NOT NULL,
`model_label` text DEFAULT '' NOT NULL,
`feedback` text DEFAULT '' NOT NULL,
`status` text DEFAULT 'writing' NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`chapter_id`) REFERENCES `chapters`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `versions_chapter_idx` ON `chapter_versions` (`chapter_id`);--> statement-breakpoint
CREATE TABLE `chapters` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`index` integer NOT NULL,
`title` text DEFAULT '' NOT NULL,
`synopsis` text DEFAULT '' NOT NULL,
`brief` text DEFAULT '' NOT NULL,
`writer_agent_id` text,
`writer_locked` integer DEFAULT false NOT NULL,
`editor_notes` text DEFAULT '' NOT NULL,
`review_notes` text DEFAULT '' NOT NULL,
`status` text DEFAULT 'planned' NOT NULL,
`active_version_id` text,
`summary` text DEFAULT '' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`writer_agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `chapters_project_idx` ON `chapters` (`project_id`,`index`);--> statement-breakpoint
CREATE TABLE `characters` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`identity` text DEFAULT '' NOT NULL,
`description` text DEFAULT '' NOT NULL,
`personality` text DEFAULT '' NOT NULL,
`speech_examples` text DEFAULT '' NOT NULL,
`notes` text DEFAULT '' NOT NULL,
`avatar` text DEFAULT '' NOT NULL,
`source_card` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `characters_project_idx` ON `characters` (`project_id`);--> statement-breakpoint
CREATE TABLE `jobs` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`chapter_id` text,
`type` text NOT NULL,
`status` text DEFAULT 'running' NOT NULL,
`agent_name` text DEFAULT '' NOT NULL,
`model_label` text DEFAULT '' NOT NULL,
`input_tokens` integer DEFAULT 0 NOT NULL,
`output_tokens` integer DEFAULT 0 NOT NULL,
`error` text DEFAULT '' NOT NULL,
`log` text DEFAULT '' NOT NULL,
`prompt` text,
`created_at` integer NOT NULL,
`finished_at` integer,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `jobs_project_idx` ON `jobs` (`project_id`,`created_at`);--> statement-breakpoint
CREATE TABLE `lore_entries` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`title` text DEFAULT '' NOT NULL,
`keys` text NOT NULL,
`content` text DEFAULT '' NOT NULL,
`constant` integer DEFAULT false NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`source` text DEFAULT 'user' NOT NULL,
`sort_order` integer DEFAULT 100 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `lore_project_idx` ON `lore_entries` (`project_id`);--> statement-breakpoint
CREATE TABLE `models` (
`id` text PRIMARY KEY NOT NULL,
`provider_id` text NOT NULL,
`label` text NOT NULL,
`model_id` text NOT NULL,
`temperature` real,
`top_p` real,
`max_output_tokens` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`provider_id`) REFERENCES `providers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `projects` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`description` text DEFAULT '' NOT NULL,
`world_setting` text DEFAULT '' NOT NULL,
`story_outline` text DEFAULT '' NOT NULL,
`requirements` text NOT NULL,
`settings` text NOT NULL,
`memory` text NOT NULL,
`user_name` text DEFAULT '' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `providers` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`base_url` text DEFAULT '' NOT NULL,
`api_key_enc` text DEFAULT '' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1792257891167,
"tag": "0000_init",
"breakpoints": true
}
]
}
+142
View File
@@ -0,0 +1,142 @@
import { sqliteTable, text, integer, real, index } from 'drizzle-orm/sqlite-core'
import type {
ProviderType, ProjectRequirements, ProjectSettings, ProjectMemory,
AgentRole, ChapterStatus, VersionStatus, JobType, JobStatus, LoreEntrySource
} from '../../shared/types'
const id = () => text('id').primaryKey()
const createdAt = () => integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date())
const updatedAt = () => integer('updated_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()).$onUpdateFn(() => new Date())
/** API 提供方(OpenAI 兼容 / Anthropic / Google) */
export const providers = sqliteTable('providers', {
id: id(),
name: text('name').notNull(),
type: text('type').$type<ProviderType>().notNull(),
baseUrl: text('base_url').notNull().default(''),
apiKeyEnc: text('api_key_enc').notNull().default(''),
createdAt: createdAt(),
updatedAt: updatedAt()
})
/** 模型预设:某个提供方下的具体模型 + 采样参数 */
export const models = sqliteTable('models', {
id: id(),
providerId: text('provider_id').notNull().references(() => providers.id, { onDelete: 'cascade' }),
label: text('label').notNull(),
modelId: text('model_id').notNull(),
temperature: real('temperature'),
topP: real('top_p'),
maxOutputTokens: integer('max_output_tokens'),
createdAt: createdAt(),
updatedAt: updatedAt()
})
export const projects = sqliteTable('projects', {
id: id(),
title: text('title').notNull(),
description: text('description').notNull().default(''),
worldSetting: text('world_setting').notNull().default(''),
storyOutline: text('story_outline').notNull().default(''),
requirements: text('requirements', { mode: 'json' }).$type<ProjectRequirements>().notNull(),
settings: text('settings', { mode: 'json' }).$type<ProjectSettings>().notNull(),
memory: text('memory', { mode: 'json' }).$type<ProjectMemory>().notNull(),
userName: text('user_name').notNull().default(''),
createdAt: createdAt(),
updatedAt: updatedAt()
})
export const characters = sqliteTable('characters', {
id: id(),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
identity: text('identity').notNull().default(''),
description: text('description').notNull().default(''),
personality: text('personality').notNull().default(''),
speechExamples: text('speech_examples').notNull().default(''),
notes: text('notes').notNull().default(''),
avatar: text('avatar').notNull().default(''),
sourceCard: text('source_card', { mode: 'json' }).$type<Record<string, unknown> | null>(),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt()
}, t => [index('characters_project_idx').on(t.projectId)])
/** 世界书条目:常驻条目每次注入,关键词条目在章节简报/大纲命中时注入 */
export const loreEntries = sqliteTable('lore_entries', {
id: id(),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
title: text('title').notNull().default(''),
keys: text('keys', { mode: 'json' }).$type<string[]>().notNull(),
content: text('content').notNull().default(''),
constant: integer('constant', { mode: 'boolean' }).notNull().default(false),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
source: text('source').$type<LoreEntrySource>().notNull().default('user'),
sortOrder: integer('sort_order').notNull().default(100),
createdAt: createdAt(),
updatedAt: updatedAt()
}, t => [index('lore_project_idx').on(t.projectId)])
/** AI 成员:主编 / 作家 / 摘要员 */
export const agents = sqliteTable('agents', {
id: id(),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
role: text('role').$type<AgentRole>().notNull(),
name: text('name').notNull(),
modelId: text('model_id').references(() => models.id, { onDelete: 'set null' }),
systemPrompt: text('system_prompt').notNull().default(''),
styleNotes: text('style_notes').notNull().default(''),
createdBy: text('created_by').$type<'user' | 'editor'>().notNull().default('user'),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
createdAt: createdAt(),
updatedAt: updatedAt()
}, t => [index('agents_project_idx').on(t.projectId)])
export const chapters = sqliteTable('chapters', {
id: id(),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
index: integer('index').notNull(),
title: text('title').notNull().default(''),
synopsis: text('synopsis').notNull().default(''),
brief: text('brief').notNull().default(''),
writerAgentId: text('writer_agent_id').references(() => agents.id, { onDelete: 'set null' }),
writerLocked: integer('writer_locked', { mode: 'boolean' }).notNull().default(false),
editorNotes: text('editor_notes').notNull().default(''),
reviewNotes: text('review_notes').notNull().default(''),
status: text('status').$type<ChapterStatus>().notNull().default('planned'),
activeVersionId: text('active_version_id'),
summary: text('summary').notNull().default(''),
createdAt: createdAt(),
updatedAt: updatedAt()
}, t => [index('chapters_project_idx').on(t.projectId, t.index)])
export const chapterVersions = sqliteTable('chapter_versions', {
id: id(),
chapterId: text('chapter_id').notNull().references(() => chapters.id, { onDelete: 'cascade' }),
version: integer('version').notNull(),
filePath: text('file_path').notNull(),
wordCount: integer('word_count').notNull().default(0),
writerAgentId: text('writer_agent_id'),
writerName: text('writer_name').notNull().default(''),
modelLabel: text('model_label').notNull().default(''),
feedback: text('feedback').notNull().default(''),
status: text('status').$type<VersionStatus>().notNull().default('writing'),
createdAt: createdAt()
}, t => [index('versions_chapter_idx').on(t.chapterId)])
export const jobs = sqliteTable('jobs', {
id: id(),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
chapterId: text('chapter_id'),
type: text('type').$type<JobType>().notNull(),
status: text('status').$type<JobStatus>().notNull().default('running'),
agentName: text('agent_name').notNull().default(''),
modelLabel: text('model_label').notNull().default(''),
inputTokens: integer('input_tokens').notNull().default(0),
outputTokens: integer('output_tokens').notNull().default(0),
error: text('error').notNull().default(''),
log: text('log').notNull().default(''),
prompt: text('prompt', { mode: 'json' }).$type<{ system: string, prompt: string } | null>(),
createdAt: createdAt(),
finishedAt: integer('finished_at', { mode: 'timestamp_ms' })
}, t => [index('jobs_project_idx').on(t.projectId, t.createdAt)])
+19
View File
@@ -0,0 +1,19 @@
import { timingSafeEqual } from 'node:crypto'
/**
* 可选的访问密码(HTTP Basic Auth)。
* 设置环境变量 NOVEL_PASSWORD 后启用,用户名任意;本机使用可以不设置。
*/
export default defineEventHandler((event) => {
const password = process.env.NOVEL_PASSWORD
if (!password) return
const header = getHeader(event, 'authorization') || ''
if (header.startsWith('Basic ')) {
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
const given = Buffer.from(decoded.slice(decoded.indexOf(':') + 1))
const expected = Buffer.from(password)
if (given.length === expected.length && timingSafeEqual(given, expected)) return
}
setHeader(event, 'WWW-Authenticate', 'Basic realm="AI Novel Studio", charset="UTF-8"')
throw createError({ statusCode: 401, message: '需要访问密码' })
})
+16
View File
@@ -0,0 +1,16 @@
import { eq, inArray } from 'drizzle-orm'
/** 启动时初始化数据库,并修复上次异常退出遗留的“进行中”状态 */
export default defineNitroPlugin(() => {
const db = useDb()
db.update(tables.jobs).set({ status: 'error', error: '服务重启,任务中断', finishedAt: new Date() })
.where(eq(tables.jobs.status, 'running')).run()
db.update(tables.chapterVersions).set({ status: 'error' })
.where(eq(tables.chapterVersions.status, 'writing')).run()
const stuck = db.select().from(tables.chapters).where(inArray(tables.chapters.status, ['writing'])).all()
for (const c of stuck) {
db.update(tables.chapters).set({ status: c.activeVersionId ? 'written' : (c.brief ? 'briefed' : 'planned') })
.where(eq(tables.chapters.id, c.id)).run()
}
console.log(`[novel] 数据目录:${dataDir()}`)
})
+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
}

Some files were not shown because too many files have changed in this diff Show More