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
+310
View File
@@ -0,0 +1,310 @@
<script setup lang="ts">
import type { ProviderType } from '#shared/types'
const api = useApi()
const { data: providers, refresh: refreshProviders } = await useAsyncData('providers', () => api.get<ProviderItem[]>('/api/providers'), { default: () => [] })
const { data: models, refresh: refreshModels } = await useAsyncData('models', () => api.get<ModelItem[]>('/api/models'), { default: () => [] })
const TYPE_LABEL: Record<ProviderType, string> = {
'openai-compatible': 'OpenAI 兼容',
'anthropic': 'Anthropic (Claude)',
'google': 'Google (Gemini)'
}
const typeItems = Object.entries(TYPE_LABEL).map(([value, label]) => ({ value, label }))
const PRESETS: { label: string, name: string, type: ProviderType, baseUrl: string }[] = [
{ label: 'Anthropic Claude', name: 'Claude', type: 'anthropic', baseUrl: '' },
{ label: 'Google Gemini', name: 'Gemini', type: 'google', baseUrl: '' },
{ label: 'OpenAI', name: 'OpenAI', type: 'openai-compatible', baseUrl: 'https://api.openai.com/v1' },
{ label: 'DeepSeek', name: 'DeepSeek', type: 'openai-compatible', baseUrl: 'https://api.deepseek.com/v1' },
{ label: 'OpenRouter', name: 'OpenRouter', type: 'openai-compatible', baseUrl: 'https://openrouter.ai/api/v1' },
{ label: '硅基流动', name: '硅基流动', type: 'openai-compatible', baseUrl: 'https://api.siliconflow.cn/v1' },
{ label: 'Ollama(本地)', name: 'Ollama', type: 'openai-compatible', baseUrl: 'http://localhost:11434/v1' }
]
const DEFAULT_URL: Record<ProviderType, string> = {
'openai-compatible': 'https://api.openai.com/v1',
'anthropic': 'https://api.anthropic.com/v1',
'google': 'https://generativelanguage.googleapis.com/v1beta'
}
/* ---------- 提供方 ---------- */
const providerOpen = ref(false)
const providerForm = reactive({ id: '', name: '', type: 'openai-compatible' as ProviderType, baseUrl: '', apiKey: '', hasKey: false })
const savingProvider = ref(false)
function openProvider(p?: ProviderItem) {
Object.assign(providerForm, p
? { id: p.id, name: p.name, type: p.type, baseUrl: p.baseUrl, apiKey: '', hasKey: p.hasKey }
: { id: '', name: '', type: 'openai-compatible', baseUrl: '', apiKey: '', hasKey: false })
providerOpen.value = true
}
function applyPreset(label: string) {
const p = PRESETS.find(x => x.label === label)
if (!p) return
providerForm.name = p.name
providerForm.type = p.type
providerForm.baseUrl = p.baseUrl
}
async function saveProvider() {
savingProvider.value = true
try {
const body = { name: providerForm.name, type: providerForm.type, baseUrl: providerForm.baseUrl, apiKey: providerForm.apiKey }
if (providerForm.id) await api.put(`/api/providers/${providerForm.id}`, body)
else await api.post('/api/providers', body)
providerOpen.value = false
await refreshProviders()
}
finally {
savingProvider.value = false
}
}
const deletingProvider = ref<ProviderItem | null>(null)
async function deleteProvider() {
await api.del(`/api/providers/${deletingProvider.value!.id}`)
deletingProvider.value = null
await Promise.all([refreshProviders(), refreshModels()])
}
/* ---------- 模型 ---------- */
const modelOpen = ref(false)
const modelForm = reactive({
id: '', providerId: '', label: '', modelId: '',
temperature: null as number | null, topP: null as number | null, maxOutputTokens: null as number | null
})
const remoteModels = ref<string[]>([])
const loadingRemote = ref(false)
const savingModel = ref(false)
function openModel(providerId: string, m?: ModelItem) {
Object.assign(modelForm, m
? { id: m.id, providerId, label: m.label, modelId: m.modelId, temperature: m.temperature, topP: m.topP, maxOutputTokens: m.maxOutputTokens }
: { id: '', providerId, label: '', modelId: '', temperature: null, topP: null, maxOutputTokens: null })
remoteModels.value = []
modelOpen.value = true
}
async function fetchRemote() {
loadingRemote.value = true
try {
remoteModels.value = await api.get<string[]>(`/api/providers/${modelForm.providerId}/remote-models`)
if (!remoteModels.value.length) api.toast.add({ title: '没有获取到模型', color: 'warning' })
}
finally {
loadingRemote.value = false
}
}
async function saveModel() {
savingModel.value = true
try {
const body = {
providerId: modelForm.providerId,
label: modelForm.label.trim() || modelForm.modelId.trim(),
modelId: modelForm.modelId.trim(),
temperature: modelForm.temperature,
topP: modelForm.topP,
maxOutputTokens: modelForm.maxOutputTokens
}
if (modelForm.id) await api.put(`/api/models/${modelForm.id}`, body)
else await api.post('/api/models', body)
modelOpen.value = false
await refreshModels()
}
finally {
savingModel.value = false
}
}
async function deleteModel(m: ModelItem) {
await api.del(`/api/models/${m.id}`)
await refreshModels()
}
const testing = ref<string | null>(null)
async function testModel(m: ModelItem) {
testing.value = m.id
try {
const res = await api.post<{ ok: boolean, text?: string, error?: string, ms: number }>(`/api/models/${m.id}/test`)
if (res.ok) api.toast.add({ title: `${m.label} 连接成功`, description: `${res.ms} ms:${res.text}`, color: 'success', icon: 'i-lucide-circle-check' })
else api.toast.add({ title: `${m.label} 连接失败`, description: res.error, color: 'error', duration: 10000 })
}
finally {
testing.value = null
}
}
const modelsOf = (providerId: string) => models.value.filter(m => m.providerId === providerId)
</script>
<template>
<UDashboardPanel id="settings">
<template #header>
<UDashboardNavbar title="模型与 API">
<template #right>
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<p class="text-sm text-muted mb-4">
API Key 使用 AES-256-GCM 加密后保存在本地 <code>data/app.db</code>。添加提供方后,在其下添加具体模型,然后就可以在作品的「AI 团队」中把模型分配给主编和作家。
</p>
<div v-if="!providers.length" class="flex flex-col items-center py-20 gap-3 text-center">
<UIcon name="i-lucide-plug" class="size-12 text-muted" />
<p class="text-muted">还没有 API 提供方</p>
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
</div>
<div class="space-y-4">
<UCard v-for="p in providers" :key="p.id">
<template #header>
<div class="flex items-center gap-3">
<UIcon name="i-lucide-server" class="size-5 text-primary" />
<div class="min-w-0">
<div class="font-semibold">
{{ p.name }}
<UBadge variant="subtle" color="neutral" size="sm" class="ms-2">
{{ TYPE_LABEL[p.type] }}
</UBadge>
</div>
<div class="text-xs text-muted truncate">
{{ p.baseUrl || DEFAULT_URL[p.type] }} · Key:{{ p.apiKeyMasked || '未设置' }}
</div>
</div>
<div class="ms-auto flex gap-1">
<UButton icon="i-lucide-plus" label="添加模型" size="sm" variant="soft" @click="openModel(p.id)" />
<UButton icon="i-lucide-pencil" size="sm" color="neutral" variant="ghost" @click="openProvider(p)" />
<UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="deletingProvider = p" />
</div>
</div>
</template>
<div v-if="!modelsOf(p.id).length" class="text-sm text-muted">
还没有模型,点击「添加模型」。
</div>
<div v-else class="divide-y divide-default">
<div v-for="m in modelsOf(p.id)" :key="m.id" class="flex flex-wrap items-center gap-3 py-2">
<div class="min-w-0">
<div class="font-medium">
{{ m.label }}
</div>
<div class="text-xs text-muted">
{{ m.modelId }}
<template v-if="m.temperature != null">
· 温度 {{ m.temperature }}
</template>
<template v-if="m.topP != null">
· top_p {{ m.topP }}
</template>
<template v-if="m.maxOutputTokens">
· 最大输出 {{ m.maxOutputTokens }}
</template>
</div>
</div>
<div class="ms-auto flex gap-1">
<UButton icon="i-lucide-activity" label="测试" size="xs" color="neutral" variant="outline" :loading="testing === m.id" @click="testModel(m)" />
<UButton icon="i-lucide-pencil" size="xs" color="neutral" variant="ghost" @click="openModel(p.id, m)" />
<UButton icon="i-lucide-trash-2" size="xs" color="error" variant="ghost" @click="deleteModel(m)" />
</div>
</div>
</div>
</UCard>
</div>
</template>
</UDashboardPanel>
<!-- 提供方编辑 -->
<UModal v-model:open="providerOpen" :title="providerForm.id ? '编辑 API 提供方' : '添加 API 提供方'">
<template #body>
<div class="space-y-4">
<UFormField v-if="!providerForm.id" label="快速填写">
<div class="flex flex-wrap gap-1.5">
<UButton v-for="pr in PRESETS" :key="pr.label" :label="pr.label" size="xs" color="neutral" variant="outline" @click="applyPreset(pr.label)" />
</div>
</UFormField>
<UFormField label="名称" required>
<UInput v-model="providerForm.name" class="w-full" />
</UFormField>
<UFormField label="接口类型" required>
<USelect v-model="providerForm.type" :items="typeItems" class="w-full" />
</UFormField>
<UFormField label="Base URL" :hint="`留空使用默认:${DEFAULT_URL[providerForm.type]}`">
<UInput v-model="providerForm.baseUrl" :placeholder="DEFAULT_URL[providerForm.type]" class="w-full" />
</UFormField>
<UFormField label="API Key" :hint="providerForm.hasKey ? '留空则保留原有 Key' : ''">
<UInput v-model="providerForm.apiKey" type="password" autocomplete="off" class="w-full" />
</UFormField>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="保存" :loading="savingProvider" :disabled="!providerForm.name" @click="saveProvider" />
</div>
</template>
</UModal>
<!-- 模型编辑 -->
<UModal v-model:open="modelOpen" :title="modelForm.id ? '编辑模型' : '添加模型'">
<template #body>
<div class="space-y-4">
<UFormField label="模型 ID" required hint="与 API 中的 model 参数一致">
<div class="flex gap-2">
<UInput v-model="modelForm.modelId" placeholder="例如 claude-opus-5-5 / gemini-3-pro" class="flex-1" />
<UButton icon="i-lucide-download" label="获取列表" color="neutral" variant="outline" :loading="loadingRemote" @click="fetchRemote" />
</div>
</UFormField>
<UFormField v-if="remoteModels.length" label="从列表选择">
<USelectMenu
:items="remoteModels"
:model-value="remoteModels.includes(modelForm.modelId) ? modelForm.modelId : undefined"
placeholder="搜索模型…"
class="w-full"
@update:model-value="(v: string) => (modelForm.modelId = v)"
/>
</UFormField>
<UFormField label="显示名称" hint="可选">
<UInput v-model="modelForm.label" :placeholder="modelForm.modelId" class="w-full" />
</UFormField>
<div class="grid grid-cols-3 gap-3">
<UFormField label="温度" hint="留空=默认">
<UInputNumber v-model="modelForm.temperature" :min="0" :max="2" :step="0.1" placeholder="默认" />
</UFormField>
<UFormField label="top_p">
<UInputNumber v-model="modelForm.topP" :min="0" :max="1" :step="0.05" placeholder="默认" />
</UFormField>
<UFormField label="最大输出 tokens">
<UInputNumber v-model="modelForm.maxOutputTokens" :min="256" :step="1024" placeholder="默认" />
</UFormField>
</div>
<p class="text-xs text-muted">
写长章节时建议把「最大输出 tokens」调大(例如 16000 以上);如果输出仍被截断,系统会自动续写。
</p>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="close" />
<UButton label="保存" :loading="savingModel" :disabled="!modelForm.modelId.trim()" @click="saveModel" />
</div>
</template>
</UModal>
<UModal :open="!!deletingProvider" title="删除 API 提供方" @update:open="v => !v && (deletingProvider = null)">
<template #body>
<p>删除「{{ deletingProvider?.name }}」会同时删除它下面的所有模型,已分配这些模型的 AI 成员需要重新选择模型。</p>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<UButton label="取消" color="neutral" variant="outline" @click="deletingProvider = null" />
<UButton label="删除" color="error" @click="deleteProvider" />
</div>
</template>
</UModal>
</template>