AI 小说工坊:主编 + 作家多模型协作写作应用
- Nuxt 4 + Nuxt UI 4 + AI SDK 7 + Drizzle/SQLite - 主编规划大纲与章节简报、选派作家;作家流式写入章节文件并实时推送前端 - 逐章确认模式(默认)与自动模式;摘要与故事记忆 - 酒馆角色卡(PNG/JSON,V1-V3)与世界书导入 - 自定义最高优先级系统提示词、审稿/修改/多版本、任务日志与提示词检视 - 模拟 LLM 服务与端到端测试脚本 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.ico binary
|
||||
+27
@@ -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
|
||||
@@ -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 服务与端到端测试
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'rose',
|
||||
neutral: 'stone'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<UApp :toaster="{ position: 'top-right', duration: 4000 }">
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UApp>
|
||||
</template>
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { marked } from 'marked'
|
||||
|
||||
/** 渲染 Markdown(先转义 HTML,避免模型输出的 HTML 被直接执行) */
|
||||
export function renderMarkdown(md: string) {
|
||||
if (!md) return ''
|
||||
const escaped = md.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
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
|
||||
}
|
||||
@@ -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'>
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+9647
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
better-sqlite3: true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
+210
@@ -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)
|
||||
@@ -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`))
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const id = param(event)
|
||||
return { ...getProject(id), running: getActiveRun(id) }
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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 }))
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export default defineEventHandler((event) => {
|
||||
return { stopped: stopRun(param(event)) }
|
||||
})
|
||||
@@ -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)
|
||||
}))
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
/** 内置角色提示词(只读展示,让用户了解自定义提示词会覆盖什么) */
|
||||
export default defineEventHandler(() => ({
|
||||
editor: EDITOR_BUILTIN,
|
||||
writer: WRITER_BUILTIN,
|
||||
summarizer: SUMMARIZER_BUILTIN
|
||||
}))
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)}` })
|
||||
}
|
||||
})
|
||||
@@ -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) }
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)) }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1792257891167,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)])
|
||||
@@ -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: '需要访问密码' })
|
||||
})
|
||||
@@ -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()}`)
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { inflateSync } from 'node:zlib'
|
||||
import type { ParsedCard } from '#shared/types'
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||
|
||||
/** 读取 PNG 中所有文本块(tEXt / zTXt / iTXt),返回 关键字 → 文本 */
|
||||
export function readPngTextChunks(buf: Buffer): Record<string, string> {
|
||||
if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('不是有效的 PNG 文件')
|
||||
const out: Record<string, string> = {}
|
||||
let off = 8
|
||||
while (off + 8 <= buf.length) {
|
||||
const len = buf.readUInt32BE(off)
|
||||
const type = buf.toString('ascii', off + 4, off + 8)
|
||||
const data = buf.subarray(off + 8, off + 8 + len)
|
||||
if (type === 'tEXt') {
|
||||
const sep = data.indexOf(0)
|
||||
if (sep > 0) out[data.toString('latin1', 0, sep).toLowerCase()] = data.toString('latin1', sep + 1)
|
||||
}
|
||||
else if (type === 'zTXt') {
|
||||
const sep = data.indexOf(0)
|
||||
if (sep > 0) out[data.toString('latin1', 0, sep).toLowerCase()] = inflateSync(data.subarray(sep + 2)).toString('latin1')
|
||||
}
|
||||
else if (type === 'iTXt') {
|
||||
// keyword\0 compressionFlag compressionMethod languageTag\0 translatedKeyword\0 text
|
||||
const sep = data.indexOf(0)
|
||||
if (sep > 0) {
|
||||
const keyword = data.toString('latin1', 0, sep).toLowerCase()
|
||||
const compressed = data[sep + 1] === 1
|
||||
const langEnd = data.indexOf(0, sep + 3)
|
||||
const transEnd = data.indexOf(0, langEnd + 1)
|
||||
const body = data.subarray(transEnd + 1)
|
||||
out[keyword] = (compressed ? inflateSync(body) : body).toString('utf8')
|
||||
}
|
||||
}
|
||||
if (type === 'IEND') break
|
||||
off += 12 + len
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function decodeCardText(text: string): Record<string, unknown> {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed.startsWith('{')) return JSON.parse(trimmed)
|
||||
return JSON.parse(Buffer.from(trimmed, 'base64').toString('utf8'))
|
||||
}
|
||||
|
||||
/** 从 PNG 或 JSON 中提取原始角色卡对象 */
|
||||
export function extractRawCard(buf: Buffer, filename = ''): Record<string, unknown> {
|
||||
const isPng = buf.subarray(0, 8).equals(PNG_SIGNATURE)
|
||||
if (isPng) {
|
||||
const chunks = readPngTextChunks(buf)
|
||||
// V3 卡优先读 ccv3,同时兼容只写了 chara 的 V2/V1 卡
|
||||
const raw = chunks.ccv3 ?? chunks.chara
|
||||
if (!raw) throw new Error('这张 PNG 里没有角色卡数据(缺少 chara / ccv3 文本块)。如果图片经过截图、压缩或聊天软件转发,元数据会丢失,请使用原始文件。')
|
||||
return decodeCardText(raw)
|
||||
}
|
||||
const text = buf.toString('utf8').replace(/^/, '')
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
}
|
||||
catch {
|
||||
throw new Error(`无法解析文件 ${filename}:既不是 PNG 角色卡也不是 JSON`)
|
||||
}
|
||||
}
|
||||
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : String(v))
|
||||
const strArr = (v: unknown) => (Array.isArray(v) ? v.map(str).filter(Boolean) : [])
|
||||
|
||||
/** 将 V1 / V2 / V3 角色卡统一成内部结构 */
|
||||
export function normalizeCard(raw: Record<string, unknown>): ParsedCard {
|
||||
const specRaw = str(raw.spec)
|
||||
const hasData = raw.data && typeof raw.data === 'object'
|
||||
const d = (hasData ? raw.data : raw) as Record<string, unknown>
|
||||
const spec: ParsedCard['spec'] = specRaw === 'chara_card_v3' ? 'v3' : specRaw === 'chara_card_v2' || hasData ? 'v2' : 'v1'
|
||||
const ext = (d.extensions ?? {}) as Record<string, unknown>
|
||||
const book = (d.character_book ?? null) as { entries?: Record<string, unknown>[] } | null
|
||||
|
||||
const lore = (book?.entries ?? []).map((e, i) => {
|
||||
const extE = (e.extensions ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
title: str(e.comment) || str(e.name) || `条目 ${i + 1}`,
|
||||
keys: [...strArr(e.keys), ...strArr(e.secondary_keys)],
|
||||
content: str(e.content),
|
||||
constant: Boolean(e.constant),
|
||||
enabled: e.enabled !== false && extE.disable !== true,
|
||||
order: Number(e.insertion_order ?? e.priority ?? 100) || 100
|
||||
}
|
||||
}).filter(e => e.content.trim())
|
||||
|
||||
return {
|
||||
spec,
|
||||
name: str(d.name) || str(raw.name) || '未命名角色',
|
||||
description: str(d.description),
|
||||
personality: str(d.personality),
|
||||
scenario: str(d.scenario),
|
||||
firstMes: str(d.first_mes),
|
||||
mesExample: str(d.mes_example),
|
||||
systemPrompt: str(d.system_prompt),
|
||||
postHistoryInstructions: str(d.post_history_instructions),
|
||||
creatorNotes: str(d.creator_notes) || str(raw.creatorcomment),
|
||||
alternateGreetings: strArr(d.alternate_greetings),
|
||||
tags: strArr(d.tags),
|
||||
world: typeof ext.world === 'string' ? ext.world : '',
|
||||
lore,
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
/** 替换酒馆宏:{{char}} / {{user}} / <BOT> / <USER> */
|
||||
export function replaceMacros(text: string, charName: string, userName: string) {
|
||||
if (!text) return text
|
||||
return text
|
||||
.replace(/\{\{\s*char\s*\}\}|<BOT>/gi, charName)
|
||||
.replace(/\{\{\s*user\s*\}\}|<USER>/gi, userName || '主角')
|
||||
.replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
export function parseCardFile(buf: Buffer, filename = '') {
|
||||
return normalizeCard(extractRawCard(buf, filename))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { and, asc, eq } from 'drizzle-orm'
|
||||
|
||||
export type ProjectRow = typeof tables.projects.$inferSelect
|
||||
export type CharacterRow = typeof tables.characters.$inferSelect
|
||||
export type LoreRow = typeof tables.loreEntries.$inferSelect
|
||||
export type AgentRow = typeof tables.agents.$inferSelect
|
||||
export type ChapterRow = typeof tables.chapters.$inferSelect
|
||||
export type VersionRow = typeof tables.chapterVersions.$inferSelect
|
||||
export type ModelRow = typeof tables.models.$inferSelect
|
||||
|
||||
export function getProject(projectId: string): ProjectRow {
|
||||
const p = useDb().select().from(tables.projects).where(eq(tables.projects.id, projectId)).get()
|
||||
if (!p) throw createError({ statusCode: 404, message: '项目不存在' })
|
||||
return p
|
||||
}
|
||||
|
||||
export function getChapter(chapterId: string): ChapterRow {
|
||||
const c = useDb().select().from(tables.chapters).where(eq(tables.chapters.id, chapterId)).get()
|
||||
if (!c) throw createError({ statusCode: 404, message: '章节不存在' })
|
||||
return c
|
||||
}
|
||||
|
||||
export function getAgent(agentId: string | null | undefined): AgentRow | undefined {
|
||||
if (!agentId) return undefined
|
||||
return useDb().select().from(tables.agents).where(eq(tables.agents.id, agentId)).get()
|
||||
}
|
||||
|
||||
export function getRoleAgent(projectId: string, role: 'editor' | 'summarizer'): AgentRow | undefined {
|
||||
return useDb().select().from(tables.agents)
|
||||
.where(and(eq(tables.agents.projectId, projectId), eq(tables.agents.role, role)))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function listChapters(projectId: string) {
|
||||
return useDb().select().from(tables.chapters).where(eq(tables.chapters.projectId, projectId)).orderBy(asc(tables.chapters.index)).all()
|
||||
}
|
||||
|
||||
export function getVersion(versionId: string | null | undefined): VersionRow | undefined {
|
||||
if (!versionId) return undefined
|
||||
return useDb().select().from(tables.chapterVersions).where(eq(tables.chapterVersions.id, versionId)).get()
|
||||
}
|
||||
|
||||
/** 一次性加载生成提示词所需的全部项目数据 */
|
||||
export function loadProjectContext(projectId: string) {
|
||||
const db = useDb()
|
||||
const project = getProject(projectId)
|
||||
return {
|
||||
project,
|
||||
characters: db.select().from(tables.characters).where(eq(tables.characters.projectId, projectId)).orderBy(asc(tables.characters.sortOrder), asc(tables.characters.createdAt)).all(),
|
||||
lore: db.select().from(tables.loreEntries).where(eq(tables.loreEntries.projectId, projectId)).orderBy(asc(tables.loreEntries.sortOrder)).all(),
|
||||
agents: db.select().from(tables.agents).where(eq(tables.agents.projectId, projectId)).orderBy(asc(tables.agents.createdAt)).all(),
|
||||
chapters: listChapters(projectId),
|
||||
models: db.select({ id: tables.models.id, label: tables.models.label, modelId: tables.models.modelId, providerName: tables.providers.name })
|
||||
.from(tables.models).innerJoin(tables.providers, eq(tables.models.providerId, tables.providers.id)).all()
|
||||
}
|
||||
}
|
||||
|
||||
export type ProjectContext = ReturnType<typeof loadProjectContext>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let _key: Buffer | null = null
|
||||
|
||||
function getKey() {
|
||||
if (_key) return _key
|
||||
const configured = process.env.NOVEL_SECRET_KEY || useRuntimeConfig().secretKey
|
||||
if (configured) {
|
||||
const buf = Buffer.from(configured, 'hex')
|
||||
if (buf.length !== 32) throw new Error('NOVEL_SECRET_KEY 必须是 64 位十六进制字符串(32 字节)')
|
||||
_key = buf
|
||||
return _key
|
||||
}
|
||||
const file = join(dataDir(), 'secret.key')
|
||||
if (!existsSync(file)) writeFileSync(file, randomBytes(32).toString('hex'), { mode: 0o600 })
|
||||
_key = Buffer.from(readFileSync(file, 'utf8').trim(), 'hex')
|
||||
return _key
|
||||
}
|
||||
|
||||
/** AES-256-GCM 加密,输出 iv.tag.ciphertext(base64) */
|
||||
export function encryptSecret(plain: string) {
|
||||
if (!plain) return ''
|
||||
const iv = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', getKey(), iv)
|
||||
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
|
||||
return [iv, cipher.getAuthTag(), enc].map(b => b.toString('base64')).join('.')
|
||||
}
|
||||
|
||||
export function decryptSecret(payload: string) {
|
||||
if (!payload) return ''
|
||||
const [iv, tag, enc] = payload.split('.').map(s => Buffer.from(s, 'base64'))
|
||||
if (!iv || !tag || !enc) throw new Error('密钥数据损坏')
|
||||
try {
|
||||
const decipher = createDecipheriv('aes-256-gcm', getKey(), iv)
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
|
||||
}
|
||||
catch {
|
||||
throw createError({ statusCode: 400, message: 'API Key 无法解密:加密密钥(data/secret.key 或 NOVEL_SECRET_KEY)已变更。请在「模型与 API」中重新填写该提供方的 API Key。' })
|
||||
}
|
||||
}
|
||||
|
||||
export function maskSecret(plain: string) {
|
||||
if (!plain) return ''
|
||||
if (plain.length <= 8) return '••••'
|
||||
return `${plain.slice(0, 3)}••••${plain.slice(-4)}`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mkdirSync, existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
|
||||
import * as schema from '../db/schema'
|
||||
|
||||
export { schema }
|
||||
export const tables = schema
|
||||
|
||||
let _db: BetterSQLite3Database<typeof schema> | null = null
|
||||
|
||||
export function dataDir() {
|
||||
// 运行时读取环境变量(runtimeConfig 的默认值在构建时就已固定)
|
||||
const dir = resolve(process.env.NOVEL_DATA_DIR || useRuntimeConfig().dataDir || './data')
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
function migrationsFolder() {
|
||||
const candidates = [
|
||||
resolve(process.cwd(), 'server/db/migrations'),
|
||||
resolve(process.cwd(), '../server/db/migrations')
|
||||
]
|
||||
const found = candidates.find(p => existsSync(join(p, 'meta', '_journal.json')))
|
||||
if (!found) throw new Error('找不到数据库迁移目录 server/db/migrations,请在项目根目录启动应用')
|
||||
return found
|
||||
}
|
||||
|
||||
export function useDb() {
|
||||
if (_db) return _db
|
||||
const sqlite = new Database(join(dataDir(), 'app.db'))
|
||||
sqlite.pragma('journal_mode = WAL')
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
_db = drizzle({ client: sqlite, schema })
|
||||
migrate(_db, { migrationsFolder: migrationsFolder() })
|
||||
return _db
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ProjectEvent } from '#shared/types'
|
||||
|
||||
type Listener = (e: ProjectEvent) => void
|
||||
|
||||
const listeners = new Map<string, Set<Listener>>()
|
||||
|
||||
export function subscribeProject(projectId: string, fn: Listener) {
|
||||
let set = listeners.get(projectId)
|
||||
if (!set) listeners.set(projectId, set = new Set())
|
||||
set.add(fn)
|
||||
return () => {
|
||||
set!.delete(fn)
|
||||
if (!set!.size) listeners.delete(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
export function emitProject(projectId: string, e: ProjectEvent) {
|
||||
const set = listeners.get(projectId)
|
||||
if (!set) return
|
||||
for (const fn of set) {
|
||||
try { fn(e) }
|
||||
catch (err) { console.error('[events] listener error', err) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import type { output, ZodObject, ZodType } from 'zod'
|
||||
|
||||
async function readRawJson(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const raw = await readRawBody(event, 'utf8')
|
||||
if (!raw) return {}
|
||||
try { return JSON.parse(raw) }
|
||||
catch { throw createError({ statusCode: 400, message: '请求体不是合法的 JSON' }) }
|
||||
}
|
||||
|
||||
function validationError(issues: { path: PropertyKey[], message: string }[]) {
|
||||
const first = issues[0]
|
||||
return createError({ statusCode: 400, message: `参数错误:${first?.path.map(String).join('.') || ''} ${first?.message || ''}`.trim() })
|
||||
}
|
||||
|
||||
export async function readJson<T>(event: H3Event, schema: ZodType<T>): Promise<T> {
|
||||
const parsed = schema.safeParse(await readRawJson(event))
|
||||
if (!parsed.success) throw validationError(parsed.error.issues)
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取部分更新的请求体:只保留请求中实际出现的字段
|
||||
* (zod 4 中 optional 包裹的 default 仍会生效,直接 partial() 会把缺省字段覆盖成默认值)
|
||||
*/
|
||||
export async function readPatch<S extends ZodObject>(event: H3Event, schema: S): Promise<Partial<output<S>>> {
|
||||
const data = await readRawJson(event)
|
||||
const parsed = schema.partial().safeParse(data)
|
||||
if (!parsed.success) throw validationError(parsed.error.issues)
|
||||
const result = parsed.data as Record<string, unknown>
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const k of Object.keys(data)) {
|
||||
if (k in result) out[k] = result[k]
|
||||
}
|
||||
return out as Partial<output<S>>
|
||||
}
|
||||
|
||||
export function param(event: H3Event, name = 'id') {
|
||||
const v = getRouterParam(event, name)
|
||||
if (!v) throw createError({ statusCode: 400, message: `缺少参数 ${name}` })
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { customAlphabet } from 'nanoid'
|
||||
|
||||
export const newId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16)
|
||||
@@ -0,0 +1,118 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { JobInfo, JobType, ProjectEvent } from '#shared/types'
|
||||
|
||||
type JobRow = typeof tables.jobs.$inferSelect
|
||||
|
||||
export function toJobInfo(row: JobRow): JobInfo {
|
||||
const { prompt: _p, projectId: _pid, ...rest } = row
|
||||
return rest
|
||||
}
|
||||
|
||||
/** 一次 AI 调用(或一组调用)的记录:持久化到数据库,并实时推送给前端 */
|
||||
export class Job {
|
||||
readonly id = newId()
|
||||
private row: JobRow
|
||||
|
||||
constructor(readonly projectId: string, type: JobType, opts: { chapterId?: string | null, agentName?: string, modelLabel?: string } = {}) {
|
||||
const db = useDb()
|
||||
this.row = db.insert(tables.jobs).values({
|
||||
id: this.id,
|
||||
projectId,
|
||||
type,
|
||||
chapterId: opts.chapterId ?? null,
|
||||
agentName: opts.agentName ?? '',
|
||||
modelLabel: opts.modelLabel ?? ''
|
||||
}).returning().get()
|
||||
this.emit()
|
||||
}
|
||||
|
||||
private update(patch: Partial<JobRow>) {
|
||||
this.row = useDb().update(tables.jobs).set(patch).where(eq(tables.jobs.id, this.id)).returning().get()!
|
||||
this.emit()
|
||||
}
|
||||
|
||||
private emit() {
|
||||
emitProject(this.projectId, { type: 'job', job: toJobInfo(this.row) })
|
||||
}
|
||||
|
||||
log(message: string) {
|
||||
const line = `[${new Date().toLocaleTimeString('zh-CN', { hour12: false })}] ${message}`
|
||||
this.update({ log: this.row.log ? `${this.row.log}\n${line}` : line })
|
||||
}
|
||||
|
||||
setPrompt(system: string, prompt: string) {
|
||||
this.update({ prompt: { system, prompt } })
|
||||
}
|
||||
|
||||
addUsage(usage: { inputTokens?: number, outputTokens?: number } | undefined) {
|
||||
if (!usage) return
|
||||
this.update({
|
||||
inputTokens: this.row.inputTokens + (usage.inputTokens ?? 0),
|
||||
outputTokens: this.row.outputTokens + (usage.outputTokens ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
done() {
|
||||
this.update({ status: 'done', finishedAt: new Date() })
|
||||
}
|
||||
|
||||
fail(err: unknown) {
|
||||
const cancelled = isAbort(err)
|
||||
this.update({ status: cancelled ? 'cancelled' : 'error', error: cancelled ? '' : describeError(err), finishedAt: new Date() })
|
||||
}
|
||||
}
|
||||
|
||||
export function isAbort(err: unknown) {
|
||||
const e = err as { name?: string } | undefined
|
||||
return e?.name === 'AbortError' || e?.name === 'ResponseAborted' || (err instanceof Error && /aborted|abort/i.test(err.message) && /signal|user/i.test(err.message))
|
||||
}
|
||||
|
||||
/* ---------------- 项目级互斥运行 ---------------- */
|
||||
|
||||
interface ActiveRun {
|
||||
label: string
|
||||
controller: AbortController
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
const activeRuns = new Map<string, ActiveRun>()
|
||||
|
||||
export function getActiveRun(projectId: string) {
|
||||
const run = activeRuns.get(projectId)
|
||||
return run ? { label: run.label, startedAt: run.startedAt } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个项目同一时间只允许一个生成任务(保证章节连贯、避免状态竞争)。
|
||||
* 任务在后台执行,接口立即返回;进度通过 SSE 推送。
|
||||
*/
|
||||
export function runExclusive(projectId: string, label: string, fn: (signal: AbortSignal) => Promise<void>) {
|
||||
if (activeRuns.has(projectId)) {
|
||||
throw createError({ statusCode: 409, message: `当前项目正在执行「${activeRuns.get(projectId)!.label}」,请等待完成或先停止` })
|
||||
}
|
||||
const controller = new AbortController()
|
||||
activeRuns.set(projectId, { label, controller, startedAt: Date.now() })
|
||||
emitProject(projectId, { type: 'pipeline', running: true, message: label })
|
||||
fn(controller.signal)
|
||||
.catch((err) => {
|
||||
if (!isAbort(err)) {
|
||||
console.error(`[run:${label}]`, err)
|
||||
emitProject(projectId, { type: 'log', level: 'error', message: `${label}失败:${describeError(err)}` })
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
activeRuns.delete(projectId)
|
||||
emitProject(projectId, { type: 'pipeline', running: false })
|
||||
})
|
||||
}
|
||||
|
||||
export function stopRun(projectId: string) {
|
||||
const run = activeRuns.get(projectId)
|
||||
if (!run) return false
|
||||
run.controller.abort()
|
||||
return true
|
||||
}
|
||||
|
||||
export function emitAll(projectId: string, ...events: ProjectEvent[]) {
|
||||
for (const e of events) emitProject(projectId, e)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createAnthropic } from '@ai-sdk/anthropic'
|
||||
import { createGoogle } from '@ai-sdk/google'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
||||
import type { LanguageModel } from 'ai'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { ProviderType } from '#shared/types'
|
||||
|
||||
type ProviderRow = typeof tables.providers.$inferSelect
|
||||
type ModelRow = typeof tables.models.$inferSelect
|
||||
|
||||
export const DEFAULT_BASE_URL: Record<ProviderType, string> = {
|
||||
'openai-compatible': 'https://api.openai.com/v1',
|
||||
'anthropic': 'https://api.anthropic.com/v1',
|
||||
'google': 'https://generativelanguage.googleapis.com/v1beta'
|
||||
}
|
||||
|
||||
function trimUrl(url: string) {
|
||||
return url.trim().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function createLanguageModel(provider: ProviderRow, modelId: string): LanguageModel {
|
||||
const apiKey = decryptSecret(provider.apiKeyEnc)
|
||||
const baseURL = trimUrl(provider.baseUrl) || DEFAULT_BASE_URL[provider.type]
|
||||
switch (provider.type) {
|
||||
case 'anthropic':
|
||||
return createAnthropic({ apiKey, baseURL })(modelId)
|
||||
case 'google':
|
||||
return createGoogle({ apiKey, baseURL })(modelId)
|
||||
default:
|
||||
return createOpenAICompatible({ name: 'openai-compatible', apiKey, baseURL, includeUsage: true })(modelId)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolvedModel {
|
||||
model: LanguageModel
|
||||
label: string
|
||||
settings: { temperature?: number, topP?: number, maxOutputTokens?: number }
|
||||
}
|
||||
|
||||
export function resolveModel(modelRowId: string | null | undefined): ResolvedModel {
|
||||
if (!modelRowId) throw createError({ statusCode: 400, message: '该成员尚未指定模型,请先在「AI 团队」里选择模型' })
|
||||
const db = useDb()
|
||||
const row = db.select().from(tables.models).where(eq(tables.models.id, modelRowId)).get()
|
||||
if (!row) throw createError({ statusCode: 400, message: '指定的模型不存在,可能已被删除' })
|
||||
const provider = db.select().from(tables.providers).where(eq(tables.providers.id, row.providerId)).get()
|
||||
if (!provider) throw createError({ statusCode: 400, message: '模型所属的 API 提供方不存在' })
|
||||
return {
|
||||
model: createLanguageModel(provider, row.modelId),
|
||||
label: `${provider.name} / ${row.label || row.modelId}`,
|
||||
settings: modelSettings(row)
|
||||
}
|
||||
}
|
||||
|
||||
export function modelSettings(row: ModelRow) {
|
||||
const s: ResolvedModel['settings'] = {}
|
||||
if (row.temperature != null) s.temperature = row.temperature
|
||||
if (row.topP != null) s.topP = row.topP
|
||||
if (row.maxOutputTokens != null) s.maxOutputTokens = row.maxOutputTokens
|
||||
return s
|
||||
}
|
||||
|
||||
/** 从提供方拉取可用模型列表 */
|
||||
export async function listRemoteModels(type: ProviderType, baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
const base = trimUrl(baseUrl) || DEFAULT_BASE_URL[type]
|
||||
if (type === 'anthropic') {
|
||||
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
|
||||
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
||||
query: { limit: 1000 }
|
||||
})
|
||||
return res.data.map(m => m.id)
|
||||
}
|
||||
if (type === 'google') {
|
||||
const res = await $fetch<{ models: { name: string, supportedGenerationMethods?: string[] }[] }>(`${base}/models`, {
|
||||
headers: { 'x-goog-api-key': apiKey },
|
||||
query: { pageSize: 1000 }
|
||||
})
|
||||
return res.models
|
||||
.filter(m => !m.supportedGenerationMethods || m.supportedGenerationMethods.includes('generateContent'))
|
||||
.map(m => m.name.replace(/^models\//, ''))
|
||||
}
|
||||
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
})
|
||||
return res.data.map(m => m.id).sort()
|
||||
}
|
||||
|
||||
/** 把 AI SDK / HTTP 错误整理成用户能看懂的信息 */
|
||||
export function describeError(err: unknown): string {
|
||||
if (!err) return '未知错误'
|
||||
const e = err as { name?: string, message?: string, statusCode?: number, responseBody?: string, data?: unknown, cause?: unknown }
|
||||
if (e.name === 'AbortError') return '已取消'
|
||||
let msg = e.message || String(err)
|
||||
if (e.statusCode) msg = `[HTTP ${e.statusCode}] ${msg}`
|
||||
if (e.responseBody) msg += `\n${String(e.responseBody).slice(0, 800)}`
|
||||
else if (e.data) msg += `\n${JSON.stringify(e.data).slice(0, 800)}`
|
||||
return msg
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user