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>