3c9b65d982
- 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>
231 lines
10 KiB
Vue
231 lines
10 KiB
Vue
<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>
|