Files
ai-novel-studio/app/components/ChapterWorkspace.vue
T
jocay 3c9b65d982 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>
2026-10-18 02:04:40 +08:00

405 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>