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:
jocay
2026-10-18 02:04:40 +08:00
commit 3c9b65d982
106 changed files with 16286 additions and 0 deletions
+154
View File
@@ -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>