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
+105
View File
@@ -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>
+154
View File
@@ -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>
+404
View File
@@ -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>
+16
View File
@@ -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>
+50
View File
@@ -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>