3c9b65d982
- 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>
311 lines
13 KiB
Vue
311 lines
13 KiB
Vue
<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>
|