AI 小说工坊:主编 + 作家多模型协作写作应用

- Nuxt 4 + Nuxt UI 4 + AI SDK 7 + Drizzle/SQLite
- 主编规划大纲与章节简报、选派作家;作家流式写入章节文件并实时推送前端
- 逐章确认模式(默认)与自动模式;摘要与故事记忆
- 酒馆角色卡(PNG/JSON,V1-V3)与世界书导入
- 自定义最高优先级系统提示词、审稿/修改/多版本、任务日志与提示词检视
- 模拟 LLM 服务与端到端测试脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jocay
2026-10-18 02:04:40 +08:00
commit 3c9b65d982
106 changed files with 16286 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
<script setup lang="ts">
const api = useApi()
const { data: projects, refresh } = await useAsyncData('projects', () => api.get<ProjectListItem[]>('/api/projects'), { default: () => [] })
const { data: models } = await useAsyncData('models-count', () => api.get<ModelItem[]>('/api/models'), { default: () => [] })
const createOpen = ref(false)
const form = reactive({ title: '', description: '' })
const creating = ref(false)
async function create() {
if (!form.title.trim()) return
creating.value = true
try {
const { id } = await api.post<{ id: string }>('/api/projects', form)
createOpen.value = false
form.title = ''
form.description = ''
await navigateTo(`/projects/${id}/setting`)
}
finally {
creating.value = false
}
}
const deleting = ref<ProjectListItem | null>(null)
async function confirmDelete() {
if (!deleting.value) return
await api.del(`/api/projects/${deleting.value.id}`)
deleting.value = null
await refresh()
}
function formatTime(t: string | number | Date) {
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
</script>
<template>
<UDashboardPanel id="projects">
<template #header>
<UDashboardNavbar title="我的作品">
<template #right>
<UButton icon="i-lucide-plus" label="新建作品" @click="createOpen = true" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<UAlert
v-if="!models.length"
color="warning"
variant="subtle"
icon="i-lucide-plug-zap"
title="还没有配置模型"
description="先去「模型与 API」添加一个 API 提供方和至少一个模型,主编和作家才能开始工作。"
:actions="[{ label: '去配置', to: '/settings', color: 'warning', variant: 'solid' }]"
class="mb-4"
/>
<div v-if="!projects.length" class="flex flex-col items-center justify-center py-24 text-center gap-3">
<UIcon name="i-lucide-book-plus" class="size-12 text-muted" />
<p class="text-muted">还没有作品,开始创作你的第一部小说吧</p>
<UButton icon="i-lucide-plus" label="新建作品" @click="createOpen = true" />
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<UCard v-for="p in projects" :key="p.id" class="hover:ring-primary/50 transition" :ui="{ body: 'space-y-3' }">
<div class="flex items-start justify-between gap-2">
<NuxtLink :to="`/projects/${p.id}`" class="font-semibold text-lg hover:text-primary line-clamp-1">
{{ p.title }}
</NuxtLink>
<UDropdownMenu :items="[[{ label: '删除作品', icon: 'i-lucide-trash-2', color: 'error', onSelect: () => (deleting = p) }]]">
<UButton icon="i-lucide-ellipsis" color="neutral" variant="ghost" size="sm" />
</UDropdownMenu>
</div>
<p class="text-sm text-muted line-clamp-2 min-h-10">
{{ p.description || '暂无简介' }}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs">
<UBadge variant="subtle" color="neutral">
{{ p.stats.approved }}/{{ p.stats.total }} 章
</UBadge>
<UBadge variant="subtle" color="neutral">
{{ (p.stats.words / 10000).toFixed(1) }} 万字
</UBadge>
<UBadge v-if="p.running" variant="subtle" color="warning" icon="i-lucide-loader-circle">
{{ p.running.label }}
</UBadge>
<span class="ms-auto text-muted">{{ formatTime(p.updatedAt) }}</span>
</div>
<div class="flex gap-2">
<UButton :to="`/projects/${p.id}`" label="写作台" icon="i-lucide-pen-line" size="sm" />
<UButton :to="`/projects/${p.id}/setting`" label="设定" icon="i-lucide-book-open" size="sm" color="neutral" variant="outline" />
</div>
</UCard>
</div>
</template>
</UDashboardPanel>
<UModal v-model:open="createOpen" title="新建作品">
<template #body>
<div class="space-y-4">
<UFormField label="作品名" required>
<UInput v-model="form.title" placeholder="例如:雨季来临之前" class="w-full" autofocus @keydown.enter="create" />
</UFormField>
<UFormField label="简介" hint="可选">
<UTextarea v-model="form.description" :rows="3" autoresize class="w-full" />
</UFormField>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="创建" :loading="creating" :disabled="!form.title.trim()" @click="create" />
</div>
</template>
</UModal>
<UModal :open="!!deleting" title="删除作品" @update:open="v => !v && (deleting = null)">
<template #body>
<p>确定要删除《{{ deleting?.title }}》吗?所有章节、设定和生成的文件都会被永久删除,无法恢复。</p>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="deleting = null" />
<UButton label="永久删除" color="error" @click="confirmDelete" />
</div>
</template>
</UModal>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
const route = useRoute()
const projectId = route.params.id as string
const store = createProjectStore(projectId)
provide(projectStoreKey, store)
const error = ref('')
onMounted(async () => {
try {
await store.load()
store.connect()
}
catch (e) {
error.value = apiErrorMessage(e, '加载失败')
}
})
onBeforeUnmount(() => store.disconnect())
</script>
<template>
<UDashboardPanel v-if="error" id="project-error">
<template #body>
<div class="flex flex-col items-center py-24 gap-3">
<UIcon name="i-lucide-circle-alert" class="size-10 text-error" />
<p>{{ error }}</p>
<UButton to="/" label="返回作品列表" />
</div>
</template>
</UDashboardPanel>
<div v-else-if="!store.project.value" class="flex flex-1 items-center justify-center">
<UIcon name="i-lucide-loader-circle" class="size-8 animate-spin text-muted" />
</div>
<NuxtPage v-else />
</template>
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
const store = useProjectStore()
const api = useApi()
const pid = store.projectId
const { data: characters, refresh: refreshChars } = await useAsyncData(`chars-${pid}`, () => api.get<CharacterItem[]>(`/api/projects/${pid}/characters`), { default: () => [] })
const { data: lore, refresh: refreshLore } = await useAsyncData(`lore-${pid}`, () => api.get<LoreItem[]>(`/api/projects/${pid}/lore`), { default: () => [] })
const importOpen = ref(false)
async function onImported() {
await Promise.all([refreshChars(), refreshLore()])
}
/* ---------- 角色编辑 ---------- */
const charOpen = ref(false)
const charForm = reactive({ id: '', name: '', identity: '', description: '', personality: '', speechExamples: '', notes: '', avatar: '' })
function openChar(c?: CharacterItem) {
Object.assign(charForm, c
? { id: c.id, name: c.name, identity: c.identity, description: c.description, personality: c.personality, speechExamples: c.speechExamples, notes: c.notes, avatar: c.avatar }
: { id: '', name: '', identity: '', description: '', personality: '', speechExamples: '', notes: '', avatar: '' })
charOpen.value = true
}
async function saveChar() {
const { id, avatar: _a, ...body } = charForm
if (id) await api.put(`/api/characters/${id}`, body)
else await api.post(`/api/projects/${pid}/characters`, { ...body, sortOrder: characters.value.length })
charOpen.value = false
await refreshChars()
}
async function deleteChar() {
if (!charForm.id || !window.confirm(`删除角色「${charForm.name}」?`)) return
await api.del(`/api/characters/${charForm.id}`)
charOpen.value = false
await refreshChars()
}
const avatarSrc = (c: { avatar: string }) => (c.avatar ? `/api/avatars/${pid}/${c.avatar}` : undefined)
/* ---------- 世界书 ---------- */
const loreOpen = ref(false)
const loreForm = reactive({ id: '', title: '', keysText: '', content: '', constant: false, enabled: true })
function openLore(l?: LoreItem) {
Object.assign(loreForm, l
? { id: l.id, title: l.title, keysText: l.keys.join(', '), content: l.content, constant: l.constant, enabled: l.enabled }
: { id: '', title: '', keysText: '', content: '', constant: false, enabled: true })
loreOpen.value = true
}
async function saveLore() {
const body = {
title: loreForm.title,
keys: loreForm.keysText.split(/[,,\n]/).map(s => s.trim()).filter(Boolean),
content: loreForm.content,
constant: loreForm.constant,
enabled: loreForm.enabled
}
if (loreForm.id) await api.put(`/api/lore/${loreForm.id}`, body)
else await api.post(`/api/projects/${pid}/lore`, body)
loreOpen.value = false
await refreshLore()
}
async function toggleLore(l: LoreItem, enabled: boolean) {
await api.put(`/api/lore/${l.id}`, { enabled })
l.enabled = enabled
}
async function deleteLore(l: LoreItem) {
if (!window.confirm(`删除世界书条目「${l.title}」?`)) return
await api.del(`/api/lore/${l.id}`)
await refreshLore()
}
const tab = ref('characters')
</script>
<template>
<UDashboardPanel id="project-characters">
<template #header>
<UDashboardNavbar title="角色与世界书">
<template #right>
<UButton icon="i-lucide-id-card" label="导入角色卡" variant="soft" @click="importOpen = true" />
<UButton v-if="tab === 'characters'" icon="i-lucide-user-plus" label="新建角色" @click="openChar()" />
<UButton v-else icon="i-lucide-plus" label="新建条目" @click="openLore()" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<UTabs
v-model="tab"
:items="[
{ label: `角色 (${characters.length})`, value: 'characters', icon: 'i-lucide-users' },
{ label: `世界书 (${lore.length})`, value: 'lore', icon: 'i-lucide-scroll-text' }
]"
:content="false"
variant="link"
class="mb-4"
/>
<template v-if="tab === 'characters'">
<div v-if="!characters.length" class="flex flex-col items-center py-20 gap-3 text-center">
<UIcon name="i-lucide-users" class="size-12 text-muted" />
<p class="text-muted">
还没有角色。可以手动新建,也可以直接导入酒馆角色卡(PNG/JSON)。
</p>
<div class="flex gap-2">
<UButton icon="i-lucide-id-card" label="导入角色卡" variant="soft" @click="importOpen = true" />
<UButton icon="i-lucide-user-plus" label="新建角色" @click="openChar()" />
</div>
</div>
<div class="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
<UCard v-for="c in characters" :key="c.id" class="cursor-pointer hover:ring-primary/50 transition" @click="openChar(c)">
<div class="flex gap-4">
<UAvatar :src="avatarSrc(c)" :alt="c.name" size="3xl" class="shrink-0" />
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2">
<span class="font-semibold">{{ c.name }}</span>
<UBadge v-if="c.identity" variant="subtle" size="sm">
{{ c.identity }}
</UBadge>
<UBadge v-if="c.sourceCard" variant="outline" color="neutral" size="sm">
角色卡
</UBadge>
</div>
<p class="text-sm text-muted line-clamp-4 whitespace-pre-wrap">
{{ c.description || c.personality || '暂无描述' }}
</p>
</div>
</div>
</UCard>
</div>
</template>
<template v-else>
<p class="text-sm text-muted mb-3">
常驻条目每次都会提供给 AI;其他条目只有当章节简报、梗概或大纲中出现关键词时才会注入。关键词支持 <code>/正则/</code> 写法。
</p>
<div v-if="!lore.length" class="text-center text-muted py-16">
暂无世界书条目
</div>
<div class="space-y-2">
<div v-for="l in lore" :key="l.id" class="rounded-lg border border-default px-4 py-3 flex gap-3 items-start" :class="l.enabled ? '' : 'opacity-50'">
<USwitch :model-value="l.enabled" size="sm" class="mt-0.5" @update:model-value="v => toggleLore(l, v)" />
<div class="min-w-0 flex-1 cursor-pointer" @click="openLore(l)">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{{ l.title || '未命名条目' }}</span>
<UBadge v-if="l.constant" color="primary" variant="subtle" size="sm">
常驻
</UBadge>
<UBadge v-if="l.source === 'card'" color="neutral" variant="outline" size="sm">
角色卡
</UBadge>
<UBadge v-for="k in l.keys.slice(0, 8)" :key="k" color="neutral" variant="soft" size="sm">
{{ k }}
</UBadge>
<span v-if="l.keys.length > 8" class="text-xs text-muted">+{{ l.keys.length - 8 }}</span>
</div>
<p class="text-sm text-muted line-clamp-2 mt-1 whitespace-pre-wrap">
{{ l.content }}
</p>
</div>
<UButton icon="i-lucide-trash-2" size="xs" color="error" variant="ghost" @click="deleteLore(l)" />
</div>
</div>
</template>
</template>
</UDashboardPanel>
<CardImportModal v-model:open="importOpen" @imported="onImported" />
<USlideover v-model:open="charOpen" :title="charForm.id ? `编辑角色:${charForm.name}` : '新建角色'" :ui="{ content: 'max-w-2xl' }">
<template #body>
<div class="space-y-4">
<div class="flex gap-4 items-start">
<UAvatar :src="avatarSrc(charForm)" :alt="charForm.name || '?'" size="3xl" />
<div class="grid grid-cols-2 gap-3 flex-1">
<UFormField label="姓名" required>
<UInput v-model="charForm.name" class="w-full" />
</UFormField>
<UFormField label="身份">
<UInput v-model="charForm.identity" placeholder="例如:女主角 / 反派" class="w-full" />
</UFormField>
</div>
</div>
<UFormField label="人物设定" hint="外貌、背景、经历、人际关系等">
<UTextarea v-model="charForm.description" autoresize :rows="8" :maxrows="24" class="w-full" />
</UFormField>
<UFormField label="性格">
<UTextarea v-model="charForm.personality" autoresize :rows="3" class="w-full" />
</UFormField>
<UFormField label="说话风格 / 示例" hint="对白示例能显著提升人物辨识度">
<UTextarea v-model="charForm.speechExamples" autoresize :rows="5" :maxrows="16" class="w-full" />
</UFormField>
<UFormField label="备注">
<UTextarea v-model="charForm.notes" autoresize :rows="2" class="w-full" />
</UFormField>
</div>
</template>
<template #footer>
<div class="flex gap-2 w-full">
<UButton v-if="charForm.id" label="删除" icon="i-lucide-trash-2" color="error" variant="ghost" @click="deleteChar" />
<UButton class="ms-auto" label="取消" color="neutral" variant="outline" @click="charOpen = false" />
<UButton label="保存" :disabled="!charForm.name.trim()" @click="saveChar" />
</div>
</template>
</USlideover>
<UModal v-model:open="loreOpen" :title="loreForm.id ? '编辑世界书条目' : '新建世界书条目'" :ui="{ content: 'max-w-2xl' }">
<template #body>
<div class="space-y-4">
<UFormField label="标题">
<UInput v-model="loreForm.title" class="w-full" />
</UFormField>
<UFormField label="关键词" hint="用逗号分隔;常驻条目可不填">
<UInput v-model="loreForm.keysText" placeholder="例如:青云宗, 掌门, /剑[阵法]/" class="w-full" />
</UFormField>
<UFormField label="内容">
<UTextarea v-model="loreForm.content" autoresize :rows="8" :maxrows="24" class="w-full" />
</UFormField>
<div class="flex gap-6">
<UCheckbox v-model="loreForm.constant" label="常驻(总是注入)" />
<UCheckbox v-model="loreForm.enabled" label="启用" />
</div>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="保存" :disabled="!loreForm.content.trim()" @click="saveLore" />
</div>
</template>
</UModal>
</template>
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { CHAPTER_STATUS_COLOR, CHAPTER_STATUS_LABEL } from '#shared/types'
const store = useProjectStore()
const api = useApi()
const route = useRoute()
const router = useRouter()
const { chapters, running, live } = store
const selectedId = computed<string | null>({
get: () => (route.query.c as string) || chapters.value.find(c => c.status !== 'approved')?.id || chapters.value.at(-1)?.id || null,
set: v => router.replace({ query: { ...route.query, c: v ?? undefined } })
})
// 写作开始时自动切换到正在写的章节
watch(() => chapters.value.find(c => c.status === 'writing')?.id, (id) => {
if (id) selectedId.value = id
})
const totalWords = computed(() => chapters.value.reduce((s, c) => s + c.wordCount, 0))
const approvedCount = computed(() => chapters.value.filter(c => c.status === 'approved').length)
/* 规划大纲 */
const planOpen = ref(false)
const planForm = reactive({ count: 10, fromIndex: null as number | null })
async function plan() {
await api.post(`/api/projects/${store.projectId}/plan`, { count: planForm.count, ...(planForm.fromIndex ? { fromIndex: planForm.fromIndex } : {}) })
planOpen.value = false
}
/* 新增章节 */
async function addChapter() {
const row = await api.post<{ id: string }>(`/api/projects/${store.projectId}/chapters`, { title: '' })
await store.refreshChapters()
selectedId.value = row.id
}
const exportUrl = (format: string, scope: string) => `/api/projects/${store.projectId}/export?format=${format}&scope=${scope}`
const exportItems = [
[{ label: '导出全部章节', type: 'label' as const }, { label: 'TXT', icon: 'i-lucide-file-text', to: exportUrl('txt', 'all'), external: true, target: '_blank' }, { label: 'Markdown', icon: 'i-lucide-file-code', to: exportUrl('md', 'all'), external: true, target: '_blank' }],
[{ label: '仅导出已确认章节', type: 'label' as const }, { label: 'TXT', icon: 'i-lucide-file-text', to: exportUrl('txt', 'approved'), external: true, target: '_blank' }, { label: 'Markdown', icon: 'i-lucide-file-code', to: exportUrl('md', 'approved'), external: true, target: '_blank' }]
]
function liveWords(chapterId: string) {
const t = live[chapterId]?.text
return t ? t.replace(/\s/g, '').length : 0
}
</script>
<template>
<UDashboardPanel id="chapter-list" :default-size="22" :min-size="16" :max-size="35" resizable>
<template #header>
<UDashboardNavbar title="章节">
<template #right>
<UTooltip text="让主编规划章节大纲">
<UButton icon="i-lucide-list-tree" size="sm" color="neutral" variant="ghost" :disabled="running" @click="planOpen = true" />
</UTooltip>
<UTooltip text="手动新增章节">
<UButton icon="i-lucide-plus" size="sm" color="neutral" variant="ghost" @click="addChapter" />
</UTooltip>
<UDropdownMenu :items="exportItems">
<UButton icon="i-lucide-download" size="sm" color="neutral" variant="ghost" />
</UDropdownMenu>
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="text-xs text-muted flex gap-3 px-1 -mt-1 mb-2">
<span>{{ approvedCount }}/{{ chapters.length }} 章已确认</span>
<span>{{ formatWords(totalWords) }}</span>
</div>
<div v-if="!chapters.length" class="text-sm text-muted text-center py-10 space-y-3">
<p>还没有章节</p>
<UButton label="让主编规划大纲" icon="i-lucide-list-tree" size="sm" :disabled="running" @click="planOpen = true" />
<p class="text-xs">
或直接点击右上角「继续下一章」,主编会自动规划
</p>
</div>
<div class="flex flex-col gap-1">
<button
v-for="c in chapters"
:key="c.id"
class="text-left rounded-md px-2.5 py-2 transition border border-transparent"
:class="c.id === selectedId ? 'bg-elevated border-default' : 'hover:bg-elevated/50'"
@click="selectedId = c.id"
>
<div class="flex items-center gap-2">
<span class="text-xs text-muted tabular-nums w-10 shrink-0">第{{ c.index }}章</span>
<span class="truncate text-sm font-medium flex-1">{{ c.title || '(未命名)' }}</span>
<UIcon v-if="c.status === 'writing'" name="i-lucide-loader-circle" class="size-3.5 animate-spin text-warning" />
</div>
<div class="flex items-center gap-2 mt-1 ps-12">
<UBadge :color="CHAPTER_STATUS_COLOR[c.status]" variant="subtle" size="sm">
{{ CHAPTER_STATUS_LABEL[c.status] }}
</UBadge>
<span v-if="c.status === 'writing'" class="text-xs text-muted">{{ liveWords(c.id) }} 字…</span>
<span v-else-if="c.wordCount" class="text-xs text-muted">{{ c.wordCount }} 字</span>
</div>
</button>
</div>
</template>
</UDashboardPanel>
<UDashboardPanel id="chapter-main">
<template #header>
<UDashboardNavbar :title="store.project.value?.title">
<template #right>
<RunControls />
</template>
</UDashboardNavbar>
</template>
<template #body>
<ChapterWorkspace v-if="selectedId" :key="selectedId" :chapter-id="selectedId" />
<div v-else class="flex flex-col items-center justify-center py-24 gap-4 text-center">
<UIcon name="i-lucide-feather" class="size-12 text-muted" />
<div class="space-y-1">
<p class="font-medium">
准备开始
</p>
<p class="text-sm text-muted max-w-md">
确认已经填写「故事设定」、添加了角色,并在「AI 团队」中为主编和作家分配了模型。然后点击右上角「继续下一章」,主编会先规划章节,再为第一章写简报并交给作家。
</p>
</div>
</div>
</template>
</UDashboardPanel>
<UModal v-model:open="planOpen" title="主编规划章节大纲">
<template #body>
<div class="space-y-4">
<p class="text-sm text-muted">
主编会根据故事设定、大纲和已完成章节的摘要,规划后续章节的标题和梗概。已开写的章节不会被改动。
</p>
<div class="grid grid-cols-2 gap-3">
<UFormField label="规划章节数">
<UInputNumber v-model="planForm.count" :min="1" :max="100" />
</UFormField>
<UFormField label="起始章节" hint="留空=接在已写章节之后">
<UInputNumber v-model="planForm.fromIndex" :min="1" placeholder="自动" />
</UFormField>
</div>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="开始规划" icon="i-lucide-sparkles" @click="plan" />
</div>
</template>
</UModal>
</template>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { JOB_TYPE_LABEL, type JobInfo } from '#shared/types'
const store = useProjectStore()
const api = useApi()
const { jobs, chapters } = store
onMounted(() => store.refreshJobs())
const STATUS: Record<string, { label: string, color: 'warning' | 'success' | 'error' | 'neutral', icon: string, cls: string }> = {
running: { label: '进行中', color: 'warning', icon: 'i-lucide-loader-circle', cls: 'text-warning animate-spin' },
done: { label: '完成', color: 'success', icon: 'i-lucide-circle-check', cls: 'text-success' },
error: { label: '出错', color: 'error', icon: 'i-lucide-circle-x', cls: 'text-error' },
cancelled: { label: '已取消', color: 'neutral', icon: 'i-lucide-circle-slash', cls: 'text-muted' }
}
const totals = computed(() => jobs.value.reduce((s, j) => ({ input: s.input + j.inputTokens, output: s.output + j.outputTokens }), { input: 0, output: 0 }))
function chapterLabel(id: string | null) {
if (!id) return ''
const c = chapters.value.find(x => x.id === id)
return c ? `第${c.index}章` : ''
}
function duration(j: JobInfo) {
if (!j.finishedAt) return ''
const s = Math.round((new Date(j.finishedAt).getTime() - new Date(j.createdAt).getTime()) / 1000)
return s >= 60 ? `${Math.floor(s / 60)}分${s % 60}秒` : `${s}秒`
}
const detail = ref<JobDetail | null>(null)
async function openDetail(j: JobInfo) {
detail.value = await api.get<JobDetail>(`/api/jobs/${j.id}`)
}
// 正在查看的任务有更新时刷新
watch(jobs, async () => {
if (!detail.value) return
const j = jobs.value.find(x => x.id === detail.value!.id)
if (j && (j.status !== detail.value.status || j.log !== detail.value.log)) detail.value = await api.get<JobDetail>(`/api/jobs/${j.id}`)
}, { deep: true })
async function copy(text: string) {
await navigator.clipboard.writeText(text)
api.toast.add({ title: '已复制', color: 'success' })
}
</script>
<template>
<UDashboardPanel id="project-jobs">
<template #header>
<UDashboardNavbar title="任务日志">
<template #right>
<span class="text-xs text-muted">最近 {{ jobs.length }} 个任务 · 输入 {{ totals.input.toLocaleString() }} / 输出 {{ totals.output.toLocaleString() }} tokens</span>
<UButton icon="i-lucide-refresh-cw" size="sm" color="neutral" variant="ghost" @click="store.refreshJobs()" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div v-if="!jobs.length" class="text-center text-muted py-20">
暂无任务
</div>
<div class="space-y-1.5">
<button
v-for="j in jobs"
:key="j.id"
class="w-full text-left flex flex-wrap items-center gap-3 rounded-md border border-default px-3 py-2 hover:bg-elevated/50"
@click="openDetail(j)"
>
<UIcon :name="STATUS[j.status]!.icon" class="size-4 shrink-0" :class="STATUS[j.status]!.cls" />
<span class="text-xs text-muted tabular-nums w-28 shrink-0">{{ formatTime(j.createdAt) }}</span>
<UBadge variant="subtle" size="sm" color="neutral">
{{ JOB_TYPE_LABEL[j.type] }}
</UBadge>
<span class="text-sm">{{ chapterLabel(j.chapterId) }}</span>
<span class="text-sm font-medium">{{ j.agentName }}</span>
<span class="text-xs text-muted truncate max-w-64">{{ j.modelLabel }}</span>
<span class="ms-auto text-xs text-muted tabular-nums">
<template v-if="j.inputTokens || j.outputTokens">{{ j.inputTokens.toLocaleString() }} → {{ j.outputTokens.toLocaleString() }} tokens · </template>{{ duration(j) }}
</span>
<p v-if="j.error" class="w-full text-xs text-error line-clamp-2 ps-7">
{{ j.error }}
</p>
<p v-else-if="j.log" class="w-full text-xs text-muted truncate ps-7">
{{ j.log.split('\n').at(-1) }}
</p>
</button>
</div>
</template>
</UDashboardPanel>
<USlideover :open="!!detail" :title="detail ? `${JOB_TYPE_LABEL[detail.type]} · ${detail.agentName}` : ''" :description="detail?.modelLabel" :ui="{ content: 'max-w-3xl' }" @update:open="v => !v && (detail = null)">
<template #body>
<div v-if="detail" class="space-y-5">
<UAlert v-if="detail.error" color="error" variant="subtle" icon="i-lucide-circle-x" title="错误" :description="detail.error" :ui="{ description: 'whitespace-pre-wrap break-all' }" />
<div>
<h3 class="font-medium mb-1">
日志
</h3>
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3">{{ detail.log || '(无)' }}</pre>
</div>
<template v-if="detail.prompt">
<div>
<div class="flex items-center mb-1">
<h3 class="font-medium">
系统提示词(System)
</h3>
<UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" class="ms-auto" @click="copy(detail.prompt.system)" />
</div>
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3 max-h-96 overflow-y-auto">{{ detail.prompt.system }}</pre>
</div>
<div>
<div class="flex items-center mb-1">
<h3 class="font-medium">
用户提示词(Prompt)
</h3>
<span class="text-xs text-muted ms-2">{{ detail.prompt.prompt.length.toLocaleString() }} 字符</span>
<UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" class="ms-auto" @click="copy(detail.prompt.prompt)" />
</div>
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3 max-h-[32rem] overflow-y-auto">{{ detail.prompt.prompt }}</pre>
</div>
</template>
</div>
</template>
</USlideover>
</template>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import type { ProjectMemory } from '#shared/types'
const store = useProjectStore()
const api = useApi()
const { chapters } = store
const mem = reactive<ProjectMemory>(structuredClone(toRaw(store.project.value!.memory)))
const snapshot = ref(JSON.stringify(mem))
const dirty = computed(() => JSON.stringify(mem) !== snapshot.value)
// 确认章节后服务端会更新记忆;没有本地修改时同步最新数据
watch(() => store.project.value?.memory, (m) => {
if (m && !dirty.value) {
Object.assign(mem, structuredClone(toRaw(m)))
snapshot.value = JSON.stringify(mem)
}
}, { deep: true })
async function save() {
await store.saveProject({ memory: JSON.parse(JSON.stringify(mem)) })
snapshot.value = JSON.stringify(mem)
api.toast.add({ title: '故事记忆已保存', color: 'success' })
}
const openForeshadowing = computed(() => mem.foreshadowing.filter(f => f.status === 'open'))
const resolvedForeshadowing = computed(() => mem.foreshadowing.filter(f => f.status === 'resolved'))
function addState() { mem.characterStates.push({ name: '', state: '' }) }
function addForeshadowing() { mem.foreshadowing.push({ text: '', status: 'open', chapter: chapters.value.filter(c => c.status === 'approved').at(-1)?.index ?? 0 }) }
function removeItem<T>(arr: T[], item: T) {
const i = arr.indexOf(item)
if (i >= 0) arr.splice(i, 1)
}
</script>
<template>
<UDashboardPanel id="project-memory">
<template #header>
<UDashboardNavbar title="故事记忆">
<template #right>
<span v-if="dirty" class="text-xs text-warning">有未保存的修改</span>
<UButton icon="i-lucide-save" label="保存" :disabled="!dirty" @click="save" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="max-w-5xl mx-auto w-full space-y-8 pb-16">
<p class="text-sm text-muted">
每章确认后,摘要员会自动更新这里的内容。主编和作家写作时会参考这些信息来保持人物状态与情节的连贯。你可以随时手动修正。
</p>
<section class="space-y-3">
<div class="flex items-center gap-2">
<h2 class="font-semibold">
人物当前状态
</h2>
<UButton icon="i-lucide-plus" size="xs" variant="soft" label="添加" class="ms-auto" @click="addState" />
</div>
<div v-if="!mem.characterStates.length" class="text-sm text-muted">
暂无
</div>
<div v-for="(s, i) in mem.characterStates" :key="i" class="flex gap-2 items-start">
<UInput v-model="s.name" placeholder="人物" class="w-32 shrink-0" />
<UTextarea v-model="s.state" autoresize :rows="1" class="flex-1" />
<UButton icon="i-lucide-x" size="sm" color="neutral" variant="ghost" @click="removeItem(mem.characterStates, s)" />
</div>
</section>
<section class="space-y-3">
<div class="flex items-center gap-2">
<h2 class="font-semibold">
伏笔
</h2>
<UBadge variant="subtle" size="sm">
未回收 {{ openForeshadowing.length }}
</UBadge>
<UBadge variant="subtle" color="success" size="sm">
已回收 {{ resolvedForeshadowing.length }}
</UBadge>
<UButton icon="i-lucide-plus" size="xs" variant="soft" label="添加" class="ms-auto" @click="addForeshadowing" />
</div>
<div v-for="(f, i) in mem.foreshadowing" :key="i" class="flex gap-2 items-center">
<UBadge :color="f.status === 'open' ? 'warning' : 'success'" variant="subtle" class="shrink-0 cursor-pointer" @click="f.status = f.status === 'open' ? 'resolved' : 'open'">
{{ f.status === 'open' ? '未回收' : `已回收${f.resolvedChapter ? `·第${f.resolvedChapter}章` : ''}` }}
</UBadge>
<span class="text-xs text-muted shrink-0 w-16">第{{ f.chapter }}章</span>
<UInput v-model="f.text" class="flex-1" />
<UButton icon="i-lucide-x" size="sm" color="neutral" variant="ghost" @click="removeItem(mem.foreshadowing, f)" />
</div>
</section>
<section class="space-y-3">
<h2 class="font-semibold">
时间线
</h2>
<div v-if="!mem.timeline.length" class="text-sm text-muted">
暂无
</div>
<ol class="relative border-s border-default ms-2 space-y-2">
<li v-for="(t, i) in mem.timeline" :key="i" class="ms-4 flex gap-2 items-center">
<span class="absolute -start-1.5 size-3 rounded-full bg-primary/70" />
<span class="text-xs text-muted w-14 shrink-0">第{{ t.chapter }}章</span>
<UInput v-model="t.event" variant="ghost" class="flex-1" />
<UButton icon="i-lucide-x" size="xs" color="neutral" variant="ghost" @click="removeItem(mem.timeline, t)" />
</li>
</ol>
</section>
<section class="space-y-3">
<h2 class="font-semibold">
章节摘要一览
</h2>
<div v-for="c in chapters.filter(c => c.status === 'approved')" :key="c.id" class="text-sm">
<NuxtLink :to="`/projects/${store.projectId}?c=${c.id}`" class="font-medium hover:text-primary">
第{{ c.index }}章 {{ c.title }}
</NuxtLink>
</div>
<p class="text-xs text-muted">
点击章节可在写作台的「审稿与摘要」中查看和编辑摘要。
</p>
</section>
</div>
</template>
</UDashboardPanel>
</template>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { ProjectRequirements, ProjectSettings } from '#shared/types'
const store = useProjectStore()
const api = useApi()
const p = store.project
const draft = reactive({
title: p.value!.title,
description: p.value!.description,
worldSetting: p.value!.worldSetting,
storyOutline: p.value!.storyOutline,
userName: p.value!.userName,
requirements: { ...p.value!.requirements } as ProjectRequirements,
settings: { ...p.value!.settings } as ProjectSettings
})
const snapshot = ref(JSON.stringify(draft))
const dirty = computed(() => JSON.stringify(draft) !== snapshot.value)
const saving = ref(false)
async function save() {
saving.value = true
try {
await store.saveProject(JSON.parse(JSON.stringify(draft)))
snapshot.value = JSON.stringify(draft)
api.toast.add({ title: '设定已保存', color: 'success', icon: 'i-lucide-save' })
}
finally {
saving.value = false
}
}
// Ctrl+S 保存
defineShortcuts({ meta_s: { usingInput: true, handler: () => { if (dirty.value) save() } } })
onBeforeRouteLeave(() => {
if (dirty.value && !window.confirm('有未保存的修改,确定离开吗?')) return false
})
const POV_OPTIONS = ['第一人称', '第三人称有限视角', '第三人称全知视角', '第二人称', '多视角轮换']
</script>
<template>
<UDashboardPanel id="project-setting">
<template #header>
<UDashboardNavbar title="故事设定">
<template #right>
<span v-if="dirty" class="text-xs text-warning">有未保存的修改</span>
<UButton icon="i-lucide-save" label="保存" :loading="saving" :disabled="!dirty" @click="save" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="max-w-4xl mx-auto w-full space-y-8 pb-16">
<section class="space-y-4">
<h2 class="text-base font-semibold flex items-center gap-2">
<UIcon name="i-lucide-book" class="text-primary" />基本信息
</h2>
<div class="grid sm:grid-cols-2 gap-4">
<UFormField label="作品名" required>
<UInput v-model="draft.title" class="w-full" />
</UFormField>
<UFormField label="主角名 / {{user}}" hint="导入角色卡时用于替换 {{user}}">
<UInput v-model="draft.userName" placeholder="例如:陈默" class="w-full" />
</UFormField>
</div>
<UFormField label="简介">
<UTextarea v-model="draft.description" autoresize :rows="2" class="w-full" />
</UFormField>
</section>
<section class="space-y-4">
<h2 class="text-base font-semibold flex items-center gap-2">
<UIcon name="i-lucide-globe" class="text-primary" />故事背景 / 世界观
</h2>
<UTextarea
v-model="draft.worldSetting"
autoresize
:rows="8"
:maxrows="30"
class="w-full"
placeholder="时代、地点、社会环境、力量体系、重要组织、基调……越具体,AI 写得越贴合。导入角色卡时,卡片的场景描述会追加到这里。"
/>
</section>
<section class="space-y-4">
<h2 class="text-base font-semibold flex items-center gap-2">
<UIcon name="i-lucide-route" class="text-primary" />故事大纲
</h2>
<UTextarea
v-model="draft.storyOutline"
autoresize
:rows="10"
:maxrows="40"
class="w-full"
placeholder="整个故事的主线:起因、发展、高潮、结局,以及主要的转折点和人物弧光。主编会据此拆分章节。"
/>
</section>
<section class="space-y-4">
<h2 class="text-base font-semibold flex items-center gap-2">
<UIcon name="i-lucide-pen-tool" class="text-primary" />写作要求 <span class="text-xs font-normal text-muted">(可选)</span>
</h2>
<div class="grid sm:grid-cols-3 gap-4">
<UFormField label="语言">
<UInput v-model="draft.requirements.language" class="w-full" />
</UFormField>
<UFormField label="叙事视角">
<UInputMenu v-model="draft.requirements.pov" :items="POV_OPTIONS" create-item class="w-full" @create="(v: string) => (draft.requirements.pov = v)" />
</UFormField>
<UFormField label="每章字数">
<UInputNumber v-model="draft.requirements.wordsPerChapter" :min="500" :max="50000" :step="500" class="w-full" />
</UFormField>
</div>
<UFormField label="文风">
<UInput v-model="draft.requirements.style" placeholder="例如:细腻克制的现实主义,带一点潮湿的南方气息" class="w-full" />
</UFormField>
<UFormField label="叙述方式">
<UTextarea v-model="draft.requirements.narration" autoresize :rows="2" placeholder="例如:过去时;对白占比约三成;多用环境烘托情绪;少用心理独白" class="w-full" />
</UFormField>
<UFormField label="其他要求">
<UTextarea v-model="draft.requirements.extra" autoresize :rows="2" placeholder="例如:避免现代网络用语;不要出现“仿佛”“不禁”等滥用词" class="w-full" />
</UFormField>
</section>
<section class="space-y-4">
<h2 class="text-base font-semibold flex items-center gap-2">
<UIcon name="i-lucide-settings-2" class="text-primary" />写作流程
</h2>
<div class="grid sm:grid-cols-2 gap-4">
<USwitch v-model="draft.settings.autoMode" label="自动模式" description="章节写完后自动确认并继续下一章;关闭时每章写完都会停下来等你确认" />
<USwitch v-model="draft.settings.pauseAfterBrief" label="简报后暂停" description="主编写完简报后先暂停,你确认或修改简报后再开始写作(仅逐章模式)" />
<USwitch v-model="draft.settings.allowEditorCreateWriters" label="允许主编创建作家" description="主编可以用已配置的模型创建新的作家来写特定风格的章节" />
</div>
<div class="grid sm:grid-cols-4 gap-4">
<UFormField label="上一章衔接字数" hint="注入作家">
<UInputNumber v-model="draft.settings.prevTailChars" :min="0" :max="20000" :step="500" class="w-full" />
</UFormField>
<UFormField label="详细前情章数">
<UInputNumber v-model="draft.settings.recentSummaryCount" :min="0" :max="50" class="w-full" />
</UFormField>
<UFormField label="截断自动续写次数">
<UInputNumber v-model="draft.settings.maxContinuations" :min="0" :max="10" class="w-full" />
</UFormField>
<UFormField label="自动模式章节上限">
<UInputNumber v-model="draft.settings.autoChapterLimit" :min="1" :max="200" class="w-full" />
</UFormField>
</div>
</section>
</div>
</template>
</UDashboardPanel>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
const store = useProjectStore()
const api = useApi()
const { agents, models } = store
const { data: builtin } = await useAsyncData('builtin-prompts', () => api.get<{ editor: string, writer: string, summarizer: string }>('/api/prompts/builtin'), {
default: () => ({ editor: '', writer: '', summarizer: '' })
})
onMounted(() => store.refreshModels())
const editor = computed(() => agents.value.find(a => a.role === 'editor'))
const summarizer = computed(() => agents.value.find(a => a.role === 'summarizer'))
const writers = computed(() => agents.value.filter(a => a.role === 'writer'))
async function addWriter() {
await api.post(`/api/projects/${store.projectId}/agents`, {
role: 'writer',
name: `作家${writers.value.length + 1}`,
modelId: models.value[0]?.id ?? null
})
await store.refreshAgents()
}
</script>
<template>
<UDashboardPanel id="project-team">
<template #header>
<UDashboardNavbar title="AI 团队">
<template #right>
<UButton icon="i-lucide-user-plus" label="添加作家" @click="addWriter" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="max-w-4xl mx-auto w-full space-y-6 pb-16">
<UAlert
v-if="!models.length"
color="warning"
variant="subtle"
icon="i-lucide-plug-zap"
title="还没有可用模型"
description="请先在「模型与 API」中添加模型。"
:actions="[{ label: '去配置', to: '/settings', color: 'warning', variant: 'solid' }]"
/>
<section class="space-y-3">
<h2 class="font-semibold">
编辑部
</h2>
<AgentCard v-if="editor" :agent="editor" :builtin="builtin.editor" />
<AgentCard v-if="summarizer" :agent="summarizer" :builtin="builtin.summarizer" />
</section>
<section class="space-y-3">
<div class="flex items-center gap-2">
<h2 class="font-semibold">
作家团队
</h2>
<span class="text-xs text-muted">主编会根据「擅长风格」为每章选派作家;你也可以在写作台为某章手动指定作家</span>
</div>
<AgentCard v-for="w in writers" :key="w.id" :agent="w" :builtin="builtin.writer" />
<div v-if="!writers.length" class="text-sm text-muted text-center py-8">
还没有作家
</div>
</section>
</div>
</template>
</UDashboardPanel>
</template>
+310
View File
@@ -0,0 +1,310 @@
<script setup lang="ts">
import type { ProviderType } from '#shared/types'
const api = useApi()
const { data: providers, refresh: refreshProviders } = await useAsyncData('providers', () => api.get<ProviderItem[]>('/api/providers'), { default: () => [] })
const { data: models, refresh: refreshModels } = await useAsyncData('models', () => api.get<ModelItem[]>('/api/models'), { default: () => [] })
const TYPE_LABEL: Record<ProviderType, string> = {
'openai-compatible': 'OpenAI 兼容',
'anthropic': 'Anthropic (Claude)',
'google': 'Google (Gemini)'
}
const typeItems = Object.entries(TYPE_LABEL).map(([value, label]) => ({ value, label }))
const PRESETS: { label: string, name: string, type: ProviderType, baseUrl: string }[] = [
{ label: 'Anthropic Claude', name: 'Claude', type: 'anthropic', baseUrl: '' },
{ label: 'Google Gemini', name: 'Gemini', type: 'google', baseUrl: '' },
{ label: 'OpenAI', name: 'OpenAI', type: 'openai-compatible', baseUrl: 'https://api.openai.com/v1' },
{ label: 'DeepSeek', name: 'DeepSeek', type: 'openai-compatible', baseUrl: 'https://api.deepseek.com/v1' },
{ label: 'OpenRouter', name: 'OpenRouter', type: 'openai-compatible', baseUrl: 'https://openrouter.ai/api/v1' },
{ label: '硅基流动', name: '硅基流动', type: 'openai-compatible', baseUrl: 'https://api.siliconflow.cn/v1' },
{ label: 'Ollama(本地)', name: 'Ollama', type: 'openai-compatible', baseUrl: 'http://localhost:11434/v1' }
]
const DEFAULT_URL: Record<ProviderType, string> = {
'openai-compatible': 'https://api.openai.com/v1',
'anthropic': 'https://api.anthropic.com/v1',
'google': 'https://generativelanguage.googleapis.com/v1beta'
}
/* ---------- 提供方 ---------- */
const providerOpen = ref(false)
const providerForm = reactive({ id: '', name: '', type: 'openai-compatible' as ProviderType, baseUrl: '', apiKey: '', hasKey: false })
const savingProvider = ref(false)
function openProvider(p?: ProviderItem) {
Object.assign(providerForm, p
? { id: p.id, name: p.name, type: p.type, baseUrl: p.baseUrl, apiKey: '', hasKey: p.hasKey }
: { id: '', name: '', type: 'openai-compatible', baseUrl: '', apiKey: '', hasKey: false })
providerOpen.value = true
}
function applyPreset(label: string) {
const p = PRESETS.find(x => x.label === label)
if (!p) return
providerForm.name = p.name
providerForm.type = p.type
providerForm.baseUrl = p.baseUrl
}
async function saveProvider() {
savingProvider.value = true
try {
const body = { name: providerForm.name, type: providerForm.type, baseUrl: providerForm.baseUrl, apiKey: providerForm.apiKey }
if (providerForm.id) await api.put(`/api/providers/${providerForm.id}`, body)
else await api.post('/api/providers', body)
providerOpen.value = false
await refreshProviders()
}
finally {
savingProvider.value = false
}
}
const deletingProvider = ref<ProviderItem | null>(null)
async function deleteProvider() {
await api.del(`/api/providers/${deletingProvider.value!.id}`)
deletingProvider.value = null
await Promise.all([refreshProviders(), refreshModels()])
}
/* ---------- 模型 ---------- */
const modelOpen = ref(false)
const modelForm = reactive({
id: '', providerId: '', label: '', modelId: '',
temperature: null as number | null, topP: null as number | null, maxOutputTokens: null as number | null
})
const remoteModels = ref<string[]>([])
const loadingRemote = ref(false)
const savingModel = ref(false)
function openModel(providerId: string, m?: ModelItem) {
Object.assign(modelForm, m
? { id: m.id, providerId, label: m.label, modelId: m.modelId, temperature: m.temperature, topP: m.topP, maxOutputTokens: m.maxOutputTokens }
: { id: '', providerId, label: '', modelId: '', temperature: null, topP: null, maxOutputTokens: null })
remoteModels.value = []
modelOpen.value = true
}
async function fetchRemote() {
loadingRemote.value = true
try {
remoteModels.value = await api.get<string[]>(`/api/providers/${modelForm.providerId}/remote-models`)
if (!remoteModels.value.length) api.toast.add({ title: '没有获取到模型', color: 'warning' })
}
finally {
loadingRemote.value = false
}
}
async function saveModel() {
savingModel.value = true
try {
const body = {
providerId: modelForm.providerId,
label: modelForm.label.trim() || modelForm.modelId.trim(),
modelId: modelForm.modelId.trim(),
temperature: modelForm.temperature,
topP: modelForm.topP,
maxOutputTokens: modelForm.maxOutputTokens
}
if (modelForm.id) await api.put(`/api/models/${modelForm.id}`, body)
else await api.post('/api/models', body)
modelOpen.value = false
await refreshModels()
}
finally {
savingModel.value = false
}
}
async function deleteModel(m: ModelItem) {
await api.del(`/api/models/${m.id}`)
await refreshModels()
}
const testing = ref<string | null>(null)
async function testModel(m: ModelItem) {
testing.value = m.id
try {
const res = await api.post<{ ok: boolean, text?: string, error?: string, ms: number }>(`/api/models/${m.id}/test`)
if (res.ok) api.toast.add({ title: `${m.label} 连接成功`, description: `${res.ms} ms:${res.text}`, color: 'success', icon: 'i-lucide-circle-check' })
else api.toast.add({ title: `${m.label} 连接失败`, description: res.error, color: 'error', duration: 10000 })
}
finally {
testing.value = null
}
}
const modelsOf = (providerId: string) => models.value.filter(m => m.providerId === providerId)
</script>
<template>
<UDashboardPanel id="settings">
<template #header>
<UDashboardNavbar title="模型与 API">
<template #right>
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<p class="text-sm text-muted mb-4">
API Key 使用 AES-256-GCM 加密后保存在本地 <code>data/app.db</code>。添加提供方后,在其下添加具体模型,然后就可以在作品的「AI 团队」中把模型分配给主编和作家。
</p>
<div v-if="!providers.length" class="flex flex-col items-center py-20 gap-3 text-center">
<UIcon name="i-lucide-plug" class="size-12 text-muted" />
<p class="text-muted">还没有 API 提供方</p>
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
</div>
<div class="space-y-4">
<UCard v-for="p in providers" :key="p.id">
<template #header>
<div class="flex items-center gap-3">
<UIcon name="i-lucide-server" class="size-5 text-primary" />
<div class="min-w-0">
<div class="font-semibold">
{{ p.name }}
<UBadge variant="subtle" color="neutral" size="sm" class="ms-2">
{{ TYPE_LABEL[p.type] }}
</UBadge>
</div>
<div class="text-xs text-muted truncate">
{{ p.baseUrl || DEFAULT_URL[p.type] }} · Key:{{ p.apiKeyMasked || '未设置' }}
</div>
</div>
<div class="ms-auto flex gap-1">
<UButton icon="i-lucide-plus" label="添加模型" size="sm" variant="soft" @click="openModel(p.id)" />
<UButton icon="i-lucide-pencil" size="sm" color="neutral" variant="ghost" @click="openProvider(p)" />
<UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="deletingProvider = p" />
</div>
</div>
</template>
<div v-if="!modelsOf(p.id).length" class="text-sm text-muted">
还没有模型,点击「添加模型」。
</div>
<div v-else class="divide-y divide-default">
<div v-for="m in modelsOf(p.id)" :key="m.id" class="flex flex-wrap items-center gap-3 py-2">
<div class="min-w-0">
<div class="font-medium">
{{ m.label }}
</div>
<div class="text-xs text-muted">
{{ m.modelId }}
<template v-if="m.temperature != null">
· 温度 {{ m.temperature }}
</template>
<template v-if="m.topP != null">
· top_p {{ m.topP }}
</template>
<template v-if="m.maxOutputTokens">
· 最大输出 {{ m.maxOutputTokens }}
</template>
</div>
</div>
<div class="ms-auto flex gap-1">
<UButton icon="i-lucide-activity" label="测试" size="xs" color="neutral" variant="outline" :loading="testing === m.id" @click="testModel(m)" />
<UButton icon="i-lucide-pencil" size="xs" color="neutral" variant="ghost" @click="openModel(p.id, m)" />
<UButton icon="i-lucide-trash-2" size="xs" color="error" variant="ghost" @click="deleteModel(m)" />
</div>
</div>
</div>
</UCard>
</div>
</template>
</UDashboardPanel>
<!-- 提供方编辑 -->
<UModal v-model:open="providerOpen" :title="providerForm.id ? '编辑 API 提供方' : '添加 API 提供方'">
<template #body>
<div class="space-y-4">
<UFormField v-if="!providerForm.id" label="快速填写">
<div class="flex flex-wrap gap-1.5">
<UButton v-for="pr in PRESETS" :key="pr.label" :label="pr.label" size="xs" color="neutral" variant="outline" @click="applyPreset(pr.label)" />
</div>
</UFormField>
<UFormField label="名称" required>
<UInput v-model="providerForm.name" class="w-full" />
</UFormField>
<UFormField label="接口类型" required>
<USelect v-model="providerForm.type" :items="typeItems" class="w-full" />
</UFormField>
<UFormField label="Base URL" :hint="`留空使用默认:${DEFAULT_URL[providerForm.type]}`">
<UInput v-model="providerForm.baseUrl" :placeholder="DEFAULT_URL[providerForm.type]" class="w-full" />
</UFormField>
<UFormField label="API Key" :hint="providerForm.hasKey ? '留空则保留原有 Key' : ''">
<UInput v-model="providerForm.apiKey" type="password" autocomplete="off" class="w-full" />
</UFormField>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="保存" :loading="savingProvider" :disabled="!providerForm.name" @click="saveProvider" />
</div>
</template>
</UModal>
<!-- 模型编辑 -->
<UModal v-model:open="modelOpen" :title="modelForm.id ? '编辑模型' : '添加模型'">
<template #body>
<div class="space-y-4">
<UFormField label="模型 ID" required hint="与 API 中的 model 参数一致">
<div class="flex gap-2">
<UInput v-model="modelForm.modelId" placeholder="例如 claude-opus-5-5 / gemini-3-pro" class="flex-1" />
<UButton icon="i-lucide-download" label="获取列表" color="neutral" variant="outline" :loading="loadingRemote" @click="fetchRemote" />
</div>
</UFormField>
<UFormField v-if="remoteModels.length" label="从列表选择">
<USelectMenu
:items="remoteModels"
:model-value="remoteModels.includes(modelForm.modelId) ? modelForm.modelId : undefined"
placeholder="搜索模型…"
class="w-full"
@update:model-value="(v: string) => (modelForm.modelId = v)"
/>
</UFormField>
<UFormField label="显示名称" hint="可选">
<UInput v-model="modelForm.label" :placeholder="modelForm.modelId" class="w-full" />
</UFormField>
<div class="grid grid-cols-3 gap-3">
<UFormField label="温度" hint="留空=默认">
<UInputNumber v-model="modelForm.temperature" :min="0" :max="2" :step="0.1" placeholder="默认" />
</UFormField>
<UFormField label="top_p">
<UInputNumber v-model="modelForm.topP" :min="0" :max="1" :step="0.05" placeholder="默认" />
</UFormField>
<UFormField label="最大输出 tokens">
<UInputNumber v-model="modelForm.maxOutputTokens" :min="256" :step="1024" placeholder="默认" />
</UFormField>
</div>
<p class="text-xs text-muted">
写长章节时建议把「最大输出 tokens」调大(例如 16000 以上);如果输出仍被截断,系统会自动续写。
</p>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="保存" :loading="savingModel" :disabled="!modelForm.modelId.trim()" @click="saveModel" />
</div>
</template>
</UModal>
<UModal :open="!!deletingProvider" title="删除 API 提供方" @update:open="v => !v && (deletingProvider = null)">
<template #body>
<p>删除「{{ deletingProvider?.name }}」会同时删除它下面的所有模型,已分配这些模型的 AI 成员需要重新选择模型。</p>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="deletingProvider = null" />
<UButton label="删除" color="error" @click="deleteProvider" />
</div>
</template>
</UModal>
</template>