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:
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
const store = useProjectStore()
|
||||
const api = useApi()
|
||||
const pid = store.projectId
|
||||
|
||||
const { data: characters, refresh: refreshChars } = await useAsyncData(`chars-${pid}`, () => api.get<CharacterItem[]>(`/api/projects/${pid}/characters`), { default: () => [] })
|
||||
const { data: lore, refresh: refreshLore } = await useAsyncData(`lore-${pid}`, () => api.get<LoreItem[]>(`/api/projects/${pid}/lore`), { default: () => [] })
|
||||
|
||||
const importOpen = ref(false)
|
||||
async function onImported() {
|
||||
await Promise.all([refreshChars(), refreshLore()])
|
||||
}
|
||||
|
||||
/* ---------- 角色编辑 ---------- */
|
||||
const charOpen = ref(false)
|
||||
const charForm = reactive({ id: '', name: '', identity: '', description: '', personality: '', speechExamples: '', notes: '', avatar: '' })
|
||||
function openChar(c?: CharacterItem) {
|
||||
Object.assign(charForm, c
|
||||
? { id: c.id, name: c.name, identity: c.identity, description: c.description, personality: c.personality, speechExamples: c.speechExamples, notes: c.notes, avatar: c.avatar }
|
||||
: { id: '', name: '', identity: '', description: '', personality: '', speechExamples: '', notes: '', avatar: '' })
|
||||
charOpen.value = true
|
||||
}
|
||||
async function saveChar() {
|
||||
const { id, avatar: _a, ...body } = charForm
|
||||
if (id) await api.put(`/api/characters/${id}`, body)
|
||||
else await api.post(`/api/projects/${pid}/characters`, { ...body, sortOrder: characters.value.length })
|
||||
charOpen.value = false
|
||||
await refreshChars()
|
||||
}
|
||||
async function deleteChar() {
|
||||
if (!charForm.id || !window.confirm(`删除角色「${charForm.name}」?`)) return
|
||||
await api.del(`/api/characters/${charForm.id}`)
|
||||
charOpen.value = false
|
||||
await refreshChars()
|
||||
}
|
||||
const avatarSrc = (c: { avatar: string }) => (c.avatar ? `/api/avatars/${pid}/${c.avatar}` : undefined)
|
||||
|
||||
/* ---------- 世界书 ---------- */
|
||||
const loreOpen = ref(false)
|
||||
const loreForm = reactive({ id: '', title: '', keysText: '', content: '', constant: false, enabled: true })
|
||||
function openLore(l?: LoreItem) {
|
||||
Object.assign(loreForm, l
|
||||
? { id: l.id, title: l.title, keysText: l.keys.join(', '), content: l.content, constant: l.constant, enabled: l.enabled }
|
||||
: { id: '', title: '', keysText: '', content: '', constant: false, enabled: true })
|
||||
loreOpen.value = true
|
||||
}
|
||||
async function saveLore() {
|
||||
const body = {
|
||||
title: loreForm.title,
|
||||
keys: loreForm.keysText.split(/[,,\n]/).map(s => s.trim()).filter(Boolean),
|
||||
content: loreForm.content,
|
||||
constant: loreForm.constant,
|
||||
enabled: loreForm.enabled
|
||||
}
|
||||
if (loreForm.id) await api.put(`/api/lore/${loreForm.id}`, body)
|
||||
else await api.post(`/api/projects/${pid}/lore`, body)
|
||||
loreOpen.value = false
|
||||
await refreshLore()
|
||||
}
|
||||
async function toggleLore(l: LoreItem, enabled: boolean) {
|
||||
await api.put(`/api/lore/${l.id}`, { enabled })
|
||||
l.enabled = enabled
|
||||
}
|
||||
async function deleteLore(l: LoreItem) {
|
||||
if (!window.confirm(`删除世界书条目「${l.title}」?`)) return
|
||||
await api.del(`/api/lore/${l.id}`)
|
||||
await refreshLore()
|
||||
}
|
||||
|
||||
const tab = ref('characters')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="project-characters">
|
||||
<template #header>
|
||||
<UDashboardNavbar title="角色与世界书">
|
||||
<template #right>
|
||||
<UButton icon="i-lucide-id-card" label="导入角色卡" variant="soft" @click="importOpen = true" />
|
||||
<UButton v-if="tab === 'characters'" icon="i-lucide-user-plus" label="新建角色" @click="openChar()" />
|
||||
<UButton v-else icon="i-lucide-plus" label="新建条目" @click="openLore()" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<UTabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ label: `角色 (${characters.length})`, value: 'characters', icon: 'i-lucide-users' },
|
||||
{ label: `世界书 (${lore.length})`, value: 'lore', icon: 'i-lucide-scroll-text' }
|
||||
]"
|
||||
:content="false"
|
||||
variant="link"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<template v-if="tab === 'characters'">
|
||||
<div v-if="!characters.length" class="flex flex-col items-center py-20 gap-3 text-center">
|
||||
<UIcon name="i-lucide-users" class="size-12 text-muted" />
|
||||
<p class="text-muted">
|
||||
还没有角色。可以手动新建,也可以直接导入酒馆角色卡(PNG/JSON)。
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<UButton icon="i-lucide-id-card" label="导入角色卡" variant="soft" @click="importOpen = true" />
|
||||
<UButton icon="i-lucide-user-plus" label="新建角色" @click="openChar()" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
<UCard v-for="c in characters" :key="c.id" class="cursor-pointer hover:ring-primary/50 transition" @click="openChar(c)">
|
||||
<div class="flex gap-4">
|
||||
<UAvatar :src="avatarSrc(c)" :alt="c.name" size="3xl" class="shrink-0" />
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{{ c.name }}</span>
|
||||
<UBadge v-if="c.identity" variant="subtle" size="sm">
|
||||
{{ c.identity }}
|
||||
</UBadge>
|
||||
<UBadge v-if="c.sourceCard" variant="outline" color="neutral" size="sm">
|
||||
角色卡
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-sm text-muted line-clamp-4 whitespace-pre-wrap">
|
||||
{{ c.description || c.personality || '暂无描述' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-muted mb-3">
|
||||
常驻条目每次都会提供给 AI;其他条目只有当章节简报、梗概或大纲中出现关键词时才会注入。关键词支持 <code>/正则/</code> 写法。
|
||||
</p>
|
||||
<div v-if="!lore.length" class="text-center text-muted py-16">
|
||||
暂无世界书条目
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="l in lore" :key="l.id" class="rounded-lg border border-default px-4 py-3 flex gap-3 items-start" :class="l.enabled ? '' : 'opacity-50'">
|
||||
<USwitch :model-value="l.enabled" size="sm" class="mt-0.5" @update:model-value="v => toggleLore(l, v)" />
|
||||
<div class="min-w-0 flex-1 cursor-pointer" @click="openLore(l)">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium">{{ l.title || '未命名条目' }}</span>
|
||||
<UBadge v-if="l.constant" color="primary" variant="subtle" size="sm">
|
||||
常驻
|
||||
</UBadge>
|
||||
<UBadge v-if="l.source === 'card'" color="neutral" variant="outline" size="sm">
|
||||
角色卡
|
||||
</UBadge>
|
||||
<UBadge v-for="k in l.keys.slice(0, 8)" :key="k" color="neutral" variant="soft" size="sm">
|
||||
{{ k }}
|
||||
</UBadge>
|
||||
<span v-if="l.keys.length > 8" class="text-xs text-muted">+{{ l.keys.length - 8 }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted line-clamp-2 mt-1 whitespace-pre-wrap">
|
||||
{{ l.content }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton icon="i-lucide-trash-2" size="xs" color="error" variant="ghost" @click="deleteLore(l)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
|
||||
<CardImportModal v-model:open="importOpen" @imported="onImported" />
|
||||
|
||||
<USlideover v-model:open="charOpen" :title="charForm.id ? `编辑角色:${charForm.name}` : '新建角色'" :ui="{ content: 'max-w-2xl' }">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-4 items-start">
|
||||
<UAvatar :src="avatarSrc(charForm)" :alt="charForm.name || '?'" size="3xl" />
|
||||
<div class="grid grid-cols-2 gap-3 flex-1">
|
||||
<UFormField label="姓名" required>
|
||||
<UInput v-model="charForm.name" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="身份">
|
||||
<UInput v-model="charForm.identity" placeholder="例如:女主角 / 反派" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
<UFormField label="人物设定" hint="外貌、背景、经历、人际关系等">
|
||||
<UTextarea v-model="charForm.description" autoresize :rows="8" :maxrows="24" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="性格">
|
||||
<UTextarea v-model="charForm.personality" autoresize :rows="3" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="说话风格 / 示例" hint="对白示例能显著提升人物辨识度">
|
||||
<UTextarea v-model="charForm.speechExamples" autoresize :rows="5" :maxrows="16" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="备注">
|
||||
<UTextarea v-model="charForm.notes" autoresize :rows="2" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex gap-2 w-full">
|
||||
<UButton v-if="charForm.id" label="删除" icon="i-lucide-trash-2" color="error" variant="ghost" @click="deleteChar" />
|
||||
<UButton class="ms-auto" label="取消" color="neutral" variant="outline" @click="charOpen = false" />
|
||||
<UButton label="保存" :disabled="!charForm.name.trim()" @click="saveChar" />
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
|
||||
<UModal v-model:open="loreOpen" :title="loreForm.id ? '编辑世界书条目' : '新建世界书条目'" :ui="{ content: 'max-w-2xl' }">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<UFormField label="标题">
|
||||
<UInput v-model="loreForm.title" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="关键词" hint="用逗号分隔;常驻条目可不填">
|
||||
<UInput v-model="loreForm.keysText" placeholder="例如:青云宗, 掌门, /剑[阵法]/" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="内容">
|
||||
<UTextarea v-model="loreForm.content" autoresize :rows="8" :maxrows="24" class="w-full" />
|
||||
</UFormField>
|
||||
<div class="flex gap-6">
|
||||
<UCheckbox v-model="loreForm.constant" label="常驻(总是注入)" />
|
||||
<UCheckbox v-model="loreForm.enabled" label="启用" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="保存" :disabled="!loreForm.content.trim()" @click="saveLore" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
Reference in New Issue
Block a user