Files
ai-novel-studio/app/pages/projects/[id]/characters.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

231 lines
10 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">
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>