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>