Files
ai-novel-studio/app/pages/settings.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

311 lines
13 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">
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>