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