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
+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>