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,154 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user