Files
ai-novel-studio/app/components/CardImportModal.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

155 lines
6.3 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 open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ imported: [] }>()
const store = useProjectStore()
const api = useApi()
const file = ref<File | null>(null)
const preview = ref<CardPreview | null>(null)
const parsing = ref(false)
const importing = ref(false)
const parseError = ref('')
const avatarUrl = ref('')
const opts = reactive({
scenarioToWorld: true,
importLore: true,
firstMesAsSample: true,
systemPromptToNotes: false,
userName: '',
identity: ''
})
watch(open, (v) => {
if (!v) return
file.value = null
preview.value = null
parseError.value = ''
opts.userName = store.project.value?.userName ?? ''
opts.identity = ''
})
watch(file, async (f) => {
preview.value = null
parseError.value = ''
if (avatarUrl.value) URL.revokeObjectURL(avatarUrl.value)
avatarUrl.value = f && f.type.startsWith('image/') ? URL.createObjectURL(f) : ''
if (!f) return
parsing.value = true
try {
const form = new FormData()
form.append('file', f)
preview.value = await $fetch<CardPreview>(`/api/projects/${store.projectId}/cards/preview`, { method: 'POST', body: form })
}
catch (err) {
parseError.value = apiErrorMessage(err, '解析失败')
}
finally {
parsing.value = false
}
})
async function doImport() {
if (!file.value) return
importing.value = true
try {
const form = new FormData()
form.append('file', file.value)
form.append('options', JSON.stringify(opts))
const res = await api.upload<{ name: string, loreCount: number }>(`/api/projects/${store.projectId}/cards/import`, form)
api.toast.add({ title: `已导入角色「${res.name}」`, description: res.loreCount ? `同时导入 ${res.loreCount} 条世界书条目` : undefined, color: 'success', icon: 'i-lucide-user-check' })
open.value = false
await store.refreshProject()
emit('imported')
}
finally {
importing.value = false
}
}
const preview160 = (s: string) => (s.length > 160 ? `${s.slice(0, 160)}…` : s)
</script>
<template>
<UModal v-model:open="open" title="导入酒馆角色卡" description="支持 SillyTavern 的 PNG 角色卡(V1/V2/V3)和 JSON 文件" :ui="{ content: 'max-w-3xl' }">
<template #body>
<div class="space-y-4">
<UFileUpload
v-model="file"
accept="image/png,application/json,.json,.png"
label="拖入角色卡文件,或点击选择"
description="请使用原始 PNG 文件:截图或经聊天软件压缩的图片会丢失角色数据"
icon="i-lucide-id-card"
layout="list"
class="w-full"
:ui="{ base: 'min-h-28' }"
/>
<div v-if="parsing" class="flex items-center gap-2 text-sm text-muted">
<UIcon name="i-lucide-loader-circle" class="animate-spin" />正在解析…
</div>
<UAlert v-if="parseError" color="error" variant="subtle" icon="i-lucide-circle-alert" :description="parseError" />
<template v-if="preview">
<div class="flex gap-4 rounded-lg border border-default p-4">
<img v-if="avatarUrl" :src="avatarUrl" class="w-24 h-32 object-cover rounded-md shrink-0" alt="">
<div class="min-w-0 space-y-1.5 text-sm">
<div class="flex items-center gap-2">
<span class="text-lg font-semibold">{{ preview.name }}</span>
<UBadge variant="subtle" size="sm">
{{ preview.spec.toUpperCase() }}
</UBadge>
<UBadge v-for="t in preview.tags.slice(0, 5)" :key="t" variant="outline" color="neutral" size="sm">
{{ t }}
</UBadge>
</div>
<p v-if="preview.description" class="text-muted whitespace-pre-wrap">
<b class="text-default">描述</b> {{ preview160(preview.description) }}
</p>
<p v-if="preview.personality" class="text-muted">
<b class="text-default">性格</b> {{ preview160(preview.personality) }}
</p>
<p v-if="preview.scenario" class="text-muted">
<b class="text-default">场景</b> {{ preview160(preview.scenario) }}
</p>
<p v-if="preview.firstMes" class="text-muted">
<b class="text-default">开场白</b> {{ preview160(preview.firstMes) }}
</p>
<p v-if="preview.world" class="text-muted">
<b class="text-default">关联世界书</b> {{ preview.world }}
</p>
<p class="text-muted">
<b class="text-default">内置世界书</b> {{ preview.lore.length }} 条
<template v-if="preview.lore.length">
(常驻 {{ preview.lore.filter(l => l.constant).length }} 条)
</template>
</p>
</div>
</div>
<div class="grid sm:grid-cols-2 gap-3">
<UFormField label="角色身份" hint="可选">
<UInput v-model="opts.identity" placeholder="例如:女主角" class="w-full" />
</UFormField>
<UFormField label="{{user}} 替换为">
<UInput v-model="opts.userName" placeholder="主角" class="w-full" />
</UFormField>
</div>
<div class="grid sm:grid-cols-2 gap-3">
<UCheckbox v-model="opts.scenarioToWorld" label="场景(scenario)追加到故事背景" :disabled="!preview.scenario" />
<UCheckbox v-model="opts.importLore" :label="`导入内置世界书(${preview.lore.length} 条)`" :disabled="!preview.lore.length" />
<UCheckbox v-model="opts.firstMesAsSample" label="开场白作为文风/说话示例" :disabled="!preview.firstMes" />
<UCheckbox v-model="opts.systemPromptToNotes" label="卡片系统提示词写入角色备注" :disabled="!preview.systemPrompt && !preview.postHistoryInstructions" />
</div>
</template>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="导入" icon="i-lucide-download" :loading="importing" :disabled="!preview" @click="doImport" />
</div>
</template>
</UModal>
</template>