feat(admin): 接入AI服务商配置与提示词优化

This commit is contained in:
WuYongJie 2026-05-29 12:14:26 +08:00
parent bd02176bdf
commit 285bb3c180
13 changed files with 1079 additions and 180 deletions

View File

@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "ProviderConfig" (
"id" TEXT NOT NULL,
"serviceType" TEXT NOT NULL,
"name" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"apiKey" TEXT NOT NULL,
"model" TEXT,
"extra" JSONB,
"isActive" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProviderConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProviderConfig_serviceType_idx" ON "ProviderConfig"("serviceType");
-- CreateIndex
CREATE INDEX "ProviderConfig_serviceType_isActive_idx" ON "ProviderConfig"("serviceType", "isActive");

View File

@ -1924,3 +1924,24 @@ model SystemConfig {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ============================================
// AI 服务商配置(多份配置 + 激活切换)
// ============================================
model ProviderConfig {
id String @id @default(cuid())
serviceType String // ai-render | prompt-optimizer
name String // 用户自定义名称,如 "生产环境"、"测试 Key"
baseUrl String
apiKey String
model String?
extra Json? // 扩展字段
isActive Boolean @default(false)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([serviceType])
@@index([serviceType, isActive])
}

View File

@ -2,85 +2,270 @@
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { useToast } from '@/components/ui/Toast'
import { useAuth } from '@/hooks/useAuth'
import {
ObsidianControlShell,
ObsidianDataRow,
ObsidianInspectorSection,
ObsidianPanel,
ObsidianRailButton,
ObsidianRailGroup,
} from '@/components/platform/ObsidianControlShell'
import { Sparkles, Save, Plug, Eye, EyeOff } from 'lucide-react'
import {
Sparkles,
Wand2,
Plus,
Pencil,
Trash2,
CheckCircle2,
XCircle,
Loader2,
Eye,
EyeOff,
Plug,
Save,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface AiRenderApiConfig {
enabled: boolean
interface ProviderConfig {
id: string
serviceType: string
name: string
baseUrl: string
apiKey: string
model: string | null
isActive: boolean
createdAt: string
}
const CONFIG_KEY = 'aiRenderApi'
type ActiveTab = 'ai-render' | 'prompt-optimizer'
interface TestResult { status: 'idle' | 'testing' | 'success' | 'error'; message: string }
interface ModelOption { value: string; label: string }
const TAB_META: Record<ActiveTab, { label: string; icon: typeof Sparkles; desc: string; serviceType: string }> = {
'ai-render': { label: '生图服务', icon: Sparkles, desc: '辅助装配 AI 效果图的外部生图接口', serviceType: 'ai-render' },
'prompt-optimizer': { label: '提示词优化', icon: Wand2, desc: '用 AI 优化用户的粗糙描述为高质量生图 prompt', serviceType: 'prompt-optimizer' },
}
function ConfigCard({ config, onActivate, onEdit, onDelete }: {
config: ProviderConfig
onActivate: () => void
onEdit: () => void
onDelete: () => void
}) {
return (
<div className={cn(
'rounded-[8px] p-4 ring-1 transition',
config.isActive ? 'bg-emerald-500/[0.04] ring-emerald-500/20' : 'bg-white/[0.02] ring-white/[0.06] hover:ring-white/[0.1]',
)}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-bold text-[#edf4f2]">{config.name}</h4>
{config.isActive && (
<span className="rounded-[4px] bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-black text-emerald-400">使用中</span>
)}
</div>
<p className="mt-1 truncate text-[10px] text-[#5a6a6d]">{config.baseUrl}</p>
{config.model && <p className="mt-0.5 text-[10px] text-[#6a7a7d]">模型:{config.model}</p>}
</div>
<div className="flex shrink-0 items-center gap-1">
{!config.isActive && (
<button onClick={onActivate} className="flex h-7 items-center gap-1 rounded-[5px] bg-white/5 px-2 text-[10px] font-bold text-[#8f9c9d] transition hover:bg-emerald-500/10 hover:text-emerald-400" title="激活">
<CheckCircle2 className="h-3 w-3" /> 激活
</button>
)}
<button onClick={onEdit} className="flex h-7 w-7 items-center justify-center rounded-[5px] text-[#6a7a7d] transition hover:bg-white/5 hover:text-[#8f9c9d]" title="编辑">
<Pencil className="h-3 w-3" />
</button>
{!config.isActive && (
<button onClick={onDelete} className="flex h-7 w-7 items-center justify-center rounded-[5px] text-[#6a7a7d] transition hover:bg-red-500/10 hover:text-red-400" title="删除">
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
</div>
</div>
)
}
function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
config?: ProviderConfig | null
serviceType: string
onSave: (data: { name: string; baseUrl: string; apiKey: string; model: string }) => void
onCancel: () => void
saving: boolean
}) {
const [name, setName] = useState(config?.name ?? '')
const [baseUrl, setBaseUrl] = useState(config?.baseUrl ?? '')
const [apiKey, setApiKey] = useState(config?.apiKey ?? '')
const [model, setModel] = useState(config?.model ?? '')
const [showKey, setShowKey] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<TestResult>({ status: 'idle', message: '' })
const [models, setModels] = useState<ModelOption[]>([])
const [modelsLoaded, setModelsLoaded] = useState(false)
function parseModels(serviceType: string, responseData: unknown): ModelOption[] {
try {
const outer = responseData as Record<string, unknown>
if (serviceType === 'prompt-optimizer') {
const list = (outer?.data as Array<{ id: string }>) ?? []
return list.map((m) => ({ value: m.id, label: m.id }))
}
const inner = outer?.data as Record<string, unknown> | undefined
const rawModels = (inner?.models as Array<{ modelId: string; displayName: string }>) ?? []
return rawModels.map((m) => ({ value: m.modelId, label: m.displayName || m.modelId }))
} catch {
return []
}
}
async function handleTest() {
setTesting(true)
setTestResult({ status: 'testing', message: '正在连接…' })
setModels([])
setModelsLoaded(false)
const testPath = serviceType === 'prompt-optimizer' ? '/models' : '/api/external/image/models'
try {
const res = await fetch('/api/admin/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-test-api-key': apiKey },
body: JSON.stringify({ baseUrl, path: testPath }),
})
const data = await res.json()
if (data.success && data.data?.ok) {
const parsed = parseModels(serviceType, data.data.data)
setModels(parsed)
setModelsLoaded(true)
if (parsed.length > 0) {
setTestResult({ status: 'success', message: `连接成功,${parsed.length} 个模型可用,${data.data.latency}ms` })
if (!model && parsed.length === 1) setModel(parsed[0].value)
} else {
setTestResult({ status: 'success', message: `连接成功,${data.data.latency}ms` })
}
} else {
setTestResult({ status: 'error', message: data.data?.error || data.error?.message || '连接失败' })
}
} catch {
setTestResult({ status: 'error', message: '无法连接' })
} finally {
setTesting(false)
}
}
return (
<div className="space-y-4 rounded-[8px] bg-white/[0.03] p-4 ring-1 ring-white/[0.08]">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-[#edf4f2]">{config ? '编辑配置' : '新建配置'}</h4>
<button onClick={onCancel} className="text-[#5a6a6d] hover:text-[#8f9c9d]"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]">名称</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:生产环境、测试 Key" className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] placeholder:text-[#4a5a5d] focus:ring-[#47646b]/40" />
</div>
<div>
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]">服务地址</label>
<input value={baseUrl} onChange={(e) => { setBaseUrl(e.target.value); setModelsLoaded(false); setModels([]) }} placeholder="https://ai.yydd.ai" className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] placeholder:text-[#4a5a5d] focus:ring-[#47646b]/40" />
</div>
<div>
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]">API 密钥</label>
<div className="relative">
<input type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => { setApiKey(e.target.value); setModelsLoaded(false); setModels([]) }} placeholder="extimg_xxx... / sk-..." className="h-8 w-full rounded-[6px] bg-white/5 pr-8 pl-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] placeholder:text-[#4a5a5d] focus:ring-[#47646b]/40" />
<button type="button" onClick={() => setShowKey((v) => !v)} className="absolute right-2 top-1/2 -translate-y-1/2 text-[#5a6a6d] hover:text-[#8f9c9d]">
{showKey ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
<div>
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]">模型(可选)</label>
{models.length > 0 ? (
<select value={model} onChange={(e) => setModel(e.target.value)} className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] focus:ring-[#47646b]/40">
<option value="" className="bg-[#111719] text-[#6a7a7d]">-- 请选择模型 --</option>
{models.map((m) => (
<option key={m.value} value={m.value} className="bg-[#111719] text-[#edf4f2]">{m.label}</option>
))}
</select>
) : (
<input value={model} onChange={(e) => setModel(e.target.value)} placeholder={modelsLoaded ? '未获取到模型列表,可手动输入' : '点击「测试连接」获取模型列表'} className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] placeholder:text-[#4a5a5d] focus:ring-[#47646b]/40" />
)}
</div>
</div>
{testResult.status !== 'idle' && (
<div className={cn(
'flex items-start gap-2 rounded-[6px] px-3 py-2 text-[10px]',
testResult.status === 'success' && 'bg-emerald-500/[0.08] text-emerald-300',
testResult.status === 'error' && 'bg-red-500/[0.08] text-red-300',
testResult.status === 'testing' && 'bg-amber-500/[0.08] text-amber-300',
)}>
{testResult.status === 'success' ? <CheckCircle2 className="mt-px h-3 w-3 shrink-0" /> :
testResult.status === 'error' ? <XCircle className="mt-px h-3 w-3 shrink-0" /> :
<Loader2 className="mt-px h-3 w-3 shrink-0 animate-spin" />}
<span>{testResult.message}</span>
</div>
)}
<div className="flex items-center gap-2">
<Button variant="gray" size="small" onClick={handleTest} disabled={testing || !baseUrl.trim() || !apiKey.trim()} className="h-7 text-[10px]">
<Plug className="mr-1 h-3 w-3" /> {modelsLoaded ? '重新获取' : '测试连接'}
</Button>
<div className="flex-1" />
<Button variant="gray" size="small" onClick={onCancel} className="h-7 text-[10px]">取消</Button>
<Button size="small" onClick={() => onSave({ name: name.trim(), baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), model: model.trim() })} disabled={saving || !name.trim() || !baseUrl.trim() || !apiKey.trim()} className="h-7 text-[10px]">
<Save className="mr-1 h-3 w-3" /> {saving ? '保存中…' : '保存'}
</Button>
</div>
</div>
)
}
export default function AiRenderConfigClient() {
const toast = useToast()
const { user } = useAuth()
const canManage = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN'
const [config, setConfig] = useState<AiRenderApiConfig>({ enabled: false, baseUrl: '', apiKey: '' })
const [activeTab, setActiveTab] = useState<ActiveTab>('ai-render')
const [configs, setConfigs] = useState<ProviderConfig[]>([])
const [loading, setLoading] = useState(false)
const [editing, setEditing] = useState<ProviderConfig | null>(null)
const [showForm, setShowForm] = useState(false)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [showApiKey, setShowApiKey] = useState(false)
const [lastSaved, setLastSaved] = useState<string | null>(null)
const fetchConfig = useCallback(async () => {
const fetchConfigs = useCallback(async () => {
try {
setLoading(true)
const response = await fetch(`/api/admin/system-config?key=${CONFIG_KEY}`)
const data = await response.json()
if (data.success && data.data?.value) {
const v = data.data.value
setConfig({
enabled: v.enabled === true,
baseUrl: v.baseUrl ?? '',
apiKey: v.apiKey ?? '',
})
}
const res = await fetch(`/api/admin/provider-configs?serviceType=${activeTab}`)
const data = await res.json()
if (data.success) setConfigs(data.data.configs)
} catch {
toast.error('读取配置失败')
} finally {
setLoading(false)
}
}, [toast])
}, [activeTab, toast])
useEffect(() => {
if (canManage) fetchConfig()
}, [canManage, fetchConfig])
if (canManage) fetchConfigs()
}, [canManage, fetchConfigs])
const handleSave = async () => {
async function handleSave(formData: { name: string; baseUrl: string; apiKey: string; model: string }) {
setSaving(true)
try {
const response = await fetch('/api/admin/system-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: CONFIG_KEY,
value: {
enabled: config.enabled,
baseUrl: config.baseUrl.trim(),
apiKey: config.apiKey.trim(),
},
description: '辅助装配 AI 效果图外部生图服务配置',
}),
})
const data = await response.json()
const url = editing ? `/api/admin/provider-configs/${editing.id}` : '/api/admin/provider-configs'
const method = editing ? 'PUT' : 'POST'
const body = editing ? formData : { ...formData, serviceType: activeTab }
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const data = await res.json()
if (data.success) {
toast.success('配置已保存')
setLastSaved(new Date().toLocaleTimeString())
toast.success(editing ? '已更新' : '已创建')
setShowForm(false)
setEditing(null)
fetchConfigs()
} else {
toast.error(data.error?.message || '保存失败')
}
@ -91,167 +276,108 @@ export default function AiRenderConfigClient() {
}
}
const handleTest = async () => {
if (!config.baseUrl.trim() || !config.apiKey.trim()) {
toast.error('请先填写 API 地址和密钥')
return
}
setTesting(true)
async function handleActivate(id: string) {
try {
const endpoint = `${config.baseUrl.trim().replace(/\/+$/, '')}/api/external/image/models`
const response = await fetch(endpoint, {
headers: { Authorization: `Bearer ${config.apiKey.trim()}` },
})
if (response.ok) {
const data = await response.json()
if (data.success) {
const count = data.data?.models?.length ?? 0
toast.success(`连接成功,可用模型 ${count} 个`)
} else {
toast.error(data.error || '接口返回异常')
}
} else {
toast.error(`接口返回 ${response.status}`)
}
} catch {
toast.error('无法连接到生图服务')
} finally {
setTesting(false)
}
const res = await fetch(`/api/admin/provider-configs/${id}/activate`, { method: 'PUT' })
const data = await res.json()
if (data.success) { toast.success('已激活'); fetchConfigs() }
else toast.error(data.error?.message || '激活失败')
} catch { toast.error('激活失败') }
}
async function handleDelete(id: string) {
if (!confirm('确定删除此配置?')) return
try {
const res = await fetch(`/api/admin/provider-configs/${id}`, { method: 'DELETE' })
const data = await res.json()
if (data.success) { toast.success('已删除'); fetchConfigs() }
else toast.error(data.error?.message || '删除失败')
} catch { toast.error('删除失败') }
}
const activeConfig = configs.find((c) => c.isActive)
const tabMeta = TAB_META[activeTab]
if (!canManage) {
return (
<ObsidianControlShell
eyebrow="AI 生图"
title="AI 效果图配置"
icon={Sparkles}
backHref="/admin"
backLabel="后台设置"
rail={
<ObsidianRailGroup title="状态">
<ObsidianRailButton active icon={Sparkles} label="权限受限" />
</ObsidianRailGroup>
}
<ObsidianControlShell eyebrow="AI" title="AI 服务配置" icon={Sparkles} backHref="/admin" backLabel="后台设置"
rail={<ObsidianRailGroup title="状态"><ObsidianRailButton icon={Sparkles} label="权限受限" /></ObsidianRailGroup>}
>
<div className="flex h-full items-center justify-center">
<ObsidianPanel>
<p className="text-sm text-zinc-400">需要管理员权限才能访问此页面</p>
</ObsidianPanel>
</div>
<div className="flex h-full items-center justify-center bg-[#0f1416]"><p className="text-sm text-[#6a7a7d]">需要管理员权限</p></div>
</ObsidianControlShell>
)
}
return (
<ObsidianControlShell
eyebrow="AI 生图"
title="AI 效果图配置"
eyebrow="AI"
title="AI 服务配置"
icon={Sparkles}
backHref="/admin"
backLabel="后台设置"
actions={
<div className="flex gap-2">
<Button variant="gray" onClick={handleTest} disabled={testing || loading}>
<Plug className="mr-1.5 h-4 w-4" />
{testing ? '测试中…' : '测试连接'}
!showForm && (
<Button variant="plain" size="small" className="obsidian-control-action rounded-md border" onClick={() => { setEditing(null); setShowForm(true) }}>
<Plus className="mr-1 h-3 w-3" /> 新建配置
</Button>
<Button onClick={handleSave} disabled={saving || loading}>
<Save className="mr-1.5 h-4 w-4" />
{saving ? '保存中…' : '保存配置'}
</Button>
</div>
)
}
rail={
<div className="flex h-full flex-col gap-6">
<ObsidianRailGroup title="配置状态">
<ObsidianRailButton active icon={Sparkles} label="生图 API" />
</ObsidianRailGroup>
</div>
<ObsidianRailGroup title="服务">
{(Object.entries(TAB_META) as Array<[ActiveTab, typeof TAB_META['ai-render']]>).map(([key, meta]) => (
<ObsidianRailButton key={key} active={activeTab === key} onClick={() => { setActiveTab(key); setShowForm(false); setEditing(null) }} icon={meta.icon} label={meta.label} />
))}
</ObsidianRailGroup>
}
inspector={
<div>
<ObsidianInspectorSection title="说明">
<ObsidianDataRow label="配置项" value="外部生图服务连接" />
<ObsidianDataRow
label="状态"
value={config.enabled ? '已启用' : '已关闭'}
tone={config.enabled ? 'success' : 'warning'}
/>
{lastSaved && <ObsidianDataRow label="上次保存" value={lastSaved} />}
</ObsidianInspectorSection>
<ObsidianInspectorSection title="提示">
<p className="text-xs leading-relaxed text-zinc-500">
配置外部生图 API 后,辅助装配页面的「AI 效果图」功能将可用。
API 地址和密钥从后台数据库读取,无需写入环境变量。
</p>
<ObsidianInspectorSection title={tabMeta.label}>
<ObsidianDataRow label="配置数" value={String(configs.length)} />
<ObsidianDataRow label="当前使用" value={activeConfig?.name ?? '未配置'} tone={activeConfig ? 'success' : 'warning'} />
{activeConfig?.model && <ObsidianDataRow label="模型" value={activeConfig.model} />}
</ObsidianInspectorSection>
</div>
}
>
<div className="grid h-full grid-rows-[auto_minmax(0,1fr)] bg-[#0f1416]">
<header className="border-b border-zinc-800 px-6 py-4">
<p className="text-sm text-zinc-400">配置辅助装配 AI 效果图的外部生图服务接口</p>
</header>
<div className="min-h-0 overflow-y-auto p-6">
<div className="h-full overflow-y-auto bg-[#0f1416] p-6">
<div className="mx-auto max-w-2xl">
{/* Tab description */}
<p className="mb-6 text-xs text-[#6a7a7d]">{tabMeta.desc}</p>
{loading ? (
<ObsidianPanel>
<p className="text-sm text-zinc-500">加载中…</p>
</ObsidianPanel>
<div className="flex h-24 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin text-[#6a7a7d]" /></div>
) : (
<div className="max-w-2xl space-y-6">
<ObsidianPanel title="启用状态" eyebrow="开关">
<label className="flex cursor-pointer items-center gap-3">
<button
type="button"
role="switch"
aria-checked={config.enabled}
onClick={() => setConfig((c) => ({ ...c, enabled: !c.enabled }))}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
config.enabled ? 'bg-emerald-600' : 'bg-zinc-700'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
config.enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className="text-sm text-zinc-300">
{config.enabled ? '已启用 — 辅助装配页面可调用 AI 生图' : '已关闭 — AI 生图功能不可用'}
</span>
</label>
</ObsidianPanel>
<ObsidianPanel title="服务地址" eyebrow="API">
<Input
placeholder="https://ai.yydd.ai"
value={config.baseUrl}
onChange={(e) => setConfig((c) => ({ ...c, baseUrl: e.target.value }))}
<div className="space-y-3">
{/* Form */}
{showForm && (
<ConfigForm
config={editing}
serviceType={activeTab}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditing(null) }}
saving={saving}
/>
<p className="mt-2 text-xs text-zinc-500">填入生图服务的基地址,不含路径</p>
</ObsidianPanel>
)}
<ObsidianPanel title="API 密钥" eyebrow="鉴权">
<div className="relative">
<Input
type={showApiKey ? 'text' : 'password'}
placeholder="extimg_xxx..."
value={config.apiKey}
onChange={(e) => setConfig((c) => ({ ...c, apiKey: e.target.value }))}
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{/* Config list */}
{configs.length === 0 && !showForm ? (
<div className="rounded-[8px] border border-dashed border-white/[0.08] py-12 text-center">
<p className="text-xs text-[#5a6a6d]">暂无配置</p>
<button onClick={() => { setEditing(null); setShowForm(true) }} className="mt-3 text-xs font-bold text-[#47646b] hover:text-[#5a8a8d]">
+ 新建第一个配置
</button>
</div>
<p className="mt-2 text-xs text-zinc-500">Bearer Token,用于调用外部生图接口</p>
</ObsidianPanel>
) : (
configs.map((config) => (
<ConfigCard
key={config.id}
config={config}
onActivate={() => void handleActivate(config.id)}
onEdit={() => { setEditing(config); setShowForm(true) }}
onDelete={() => void handleDelete(config.id)}
/>
))
)}
</div>
)}
</div>

View File

@ -5,7 +5,7 @@ import AiRenderConfigClient from './AiRenderConfigClient'
export const metadata: Metadata = {
title: 'AI 生图配置 | 后台设置',
description: '配置辅助装配 AI 效果图外部生图服务',
description: '配置 AI 生图和提示词优化服务',
}
export default function AiRenderConfigPage() {

View File

@ -0,0 +1,18 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { providerConfigService } from '@/server/services'
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await params
await providerConfigService.activate(id)
return jsonSuccess({ activated: true }, 200, ctx.requestId)
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN'] },
)
}

View File

@ -0,0 +1,72 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { providerConfigService } from '@/server/services'
const updateSchema = z.object({
name: z.string().trim().min(1).max(64).optional(),
baseUrl: z.string().url().optional(),
apiKey: z.string().min(1).optional(),
model: z.string().trim().max(128).optional().nullable(),
extra: z.record(z.string(), z.unknown()).optional().nullable(),
})
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await params
const config = await providerConfigService.getById(id)
if (!config) return jsonError(ErrorCode.FILE_NOT_FOUND, '配置不存在', 404, ctx.requestId)
return jsonSuccess({ config }, 200, ctx.requestId)
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN'] },
)
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await params
const body = await request.json().catch(() => null)
const parsed = updateSchema.safeParse(body)
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_FAILED, parsed.error.issues[0]?.message ?? '参数不正确', 400, ctx.requestId)
}
try {
const config = await providerConfigService.update(id, parsed.data)
return jsonSuccess({ config }, 200, ctx.requestId)
} catch {
return jsonError(ErrorCode.FILE_NOT_FOUND, '配置不存在', 404, ctx.requestId)
}
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN'] },
)
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await params
try {
await providerConfigService.delete(id)
return jsonSuccess({ deleted: true }, 200, ctx.requestId)
} catch (e) {
return jsonError(ErrorCode.SYSTEM_INTERNAL, e instanceof Error ? e.message : '删除失败', 400, ctx.requestId)
}
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN'] },
)
}

View File

@ -0,0 +1,45 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { providerConfigService } from '@/server/services'
const createSchema = z.object({
serviceType: z.string().min(1),
name: z.string().trim().min(1, '请输入名称').max(64),
baseUrl: z.string().url('地址格式不正确'),
apiKey: z.string().min(1, '请输入密钥'),
model: z.string().trim().max(128).optional().nullable(),
extra: z.record(z.string(), z.unknown()).optional().nullable(),
})
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const serviceType = request.nextUrl.searchParams.get('serviceType')
if (!serviceType) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少 serviceType', 400, ctx.requestId)
}
const configs = await providerConfigService.list(serviceType)
return jsonSuccess({ configs }, 200, ctx.requestId)
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN'] },
)
}
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = createSchema.safeParse(body)
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_FAILED, parsed.error.issues[0]?.message ?? '参数不正确', 400, ctx.requestId)
}
const config = await providerConfigService.create(parsed.data)
return jsonSuccess({ config }, 201, ctx.requestId)
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN'] },
)
}

View File

@ -0,0 +1,56 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
const schema = z.object({
baseUrl: z.string().url('地址格式不正确'),
path: z.string().min(1),
})
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = schema.safeParse(body)
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_FAILED, parsed.error.issues[0]?.message ?? '参数不正确', 400, ctx.requestId)
}
const apiKey = request.headers.get('x-test-api-key') ?? ''
if (!apiKey) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少 API 密钥', 400, ctx.requestId)
}
const url = `${parsed.data.baseUrl.replace(/\/+$/, '')}${parsed.data.path}`
const t0 = Date.now()
try {
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(15000),
})
const latency = Date.now() - t0
if (!res.ok) {
const text = await res.text().catch(() => '')
return jsonSuccess({ ok: false, status: res.status, latency, data: null as unknown, error: text.slice(0, 300) } as Record<string, unknown>, 200, ctx.requestId)
}
const data = await res.json().catch(() => null)
return jsonSuccess({ ok: true, status: res.status, latency, data, error: null } as Record<string, unknown>, 200, ctx.requestId)
} catch (e) {
return jsonSuccess({
ok: false,
status: 0,
latency: Date.now() - t0,
data: null,
error: e instanceof Error ? e.message : '连接失败',
} as Record<string, unknown>, 200, ctx.requestId)
}
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN'] },
)
}

View File

@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { jsonError } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { promptOptimizerService } from '@/server/services'
import { authenticateRequest } from '@/lib/auth-middleware'
const schema = z.object({
prompt: z.string().trim().min(1, '请填写提示词').max(2000, '提示词过长'),
referenceImageDataUrl: z.string().optional().nullable(),
stream: z.boolean().optional(),
tags: z.record(z.string(), z.string()).optional(),
})
export async function POST(request: NextRequest) {
// 手动鉴权(流式响应不能用 handleRequest)
const auth = await authenticateRequest(request)
if (!auth.success || !auth.userInfo) {
return jsonError(ErrorCode.AUTH_INVALID_TOKEN, '请先登录', 401, 'auth')
}
const body = await request.json().catch(() => null)
const parsed = schema.safeParse(body)
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_FAILED, parsed.error.issues[0]?.message ?? '参数不正确', 400, 'val')
}
const { prompt, referenceImageDataUrl, stream, tags } = parsed.data
if (stream) {
try {
const readable = await promptOptimizerService.optimizeStream(prompt, referenceImageDataUrl, tags)
return new NextResponse(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (error) {
return jsonError(
ErrorCode.SYSTEM_INTERNAL,
error instanceof Error ? error.message : '提示词优化失败',
502,
'stream',
)
}
}
// 非流式(兼容旧调用)
try {
const result = await promptOptimizerService.optimize(prompt, referenceImageDataUrl, tags)
return NextResponse.json({ success: true, data: result })
} catch (error) {
return jsonError(
ErrorCode.SYSTEM_INTERNAL,
error instanceof Error ? error.message : '提示词优化失败',
502,
'opt',
)
}
}

View File

@ -6,10 +6,11 @@ import { uploadBufferToS3 } from '@/lib/storage'
import type { AuthUser } from '@/server/core/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import { systemConfigService } from './system-config.service'
import { providerConfigService } from './provider-config.service'
const DEFAULT_IMAGE_COUNT = 1
const MAX_IMAGE_COUNT = 8
const MAX_PROMPT_LENGTH = 1200
const MAX_PROMPT_LENGTH = 5000
const MAX_NEGATIVE_PROMPT_LENGTH = 800
const DATA_URL_IMAGE_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,(.+)$/i
@ -258,13 +259,14 @@ interface AiRenderApiConfig {
}
async function getExternalImageApiConfig(): Promise<AiRenderApiConfig> {
const active = await providerConfigService.getActive('ai-render')
if (active) {
return { enabled: true, baseUrl: trimToNull(active.baseUrl), apiKey: trimToNull(active.apiKey) }
}
const dbConfig = await systemConfigService.get<{ enabled?: boolean; baseUrl?: string; apiKey?: string }>('aiRenderApi')
if (dbConfig) {
return {
enabled: dbConfig.enabled === true,
baseUrl: trimToNull(dbConfig.baseUrl ?? null),
apiKey: trimToNull(dbConfig.apiKey ?? null),
}
if (dbConfig?.enabled) {
return { enabled: true, baseUrl: trimToNull(dbConfig.baseUrl ?? null), apiKey: trimToNull(dbConfig.apiKey ?? null) }
}
const enabled = ['1', 'true', 'yes', 'on'].includes(

View File

@ -265,3 +265,6 @@ export type {
export { reportService } from './report.service'
export { systemConfigService, SystemConfigService } from './system-config.service'
export { promptOptimizerService, PromptOptimizerService, parsePromptFromResponse } from './prompt-optimizer.service'
export { providerConfigService, ProviderConfigService } from './provider-config.service'
export type { ProviderConfigDTO, CreateProviderConfigInput, UpdateProviderConfigInput } from './provider-config.service'

View File

@ -0,0 +1,333 @@
import { systemConfigService } from './system-config.service'
import { providerConfigService } from './provider-config.service'
const SYSTEM_PROMPT = `你是一个专业的 AI 生图提示词优化师,专门负责家具/室内场景的图生图优化。
## 你的工作流程
1. 如果收到参考图(3D 渲染截图),分析图中的:家具类型、部件组成、材质/颜色、摆放关系
2. 结合用户的描述意图
3. 输出优化后的提示词(最终提交给 GPT-image-2 使用,用英文)
## 输出格式
用中文向用户解释你做了什么优化,然后给出最终的英文提示词。
格式要求(必须严格遵守):
**优化说明:**
(简要说明你观察到的图中内容和优化思路,中文)
---PROMPT---
(纯英文提示词,直接可用于生图,不要加任何标题或前缀,不要用引号包裹)
## GPT-image-2 生图最佳实践
GPT-image-2(代号 duct-tape)的核心能力:
- 像素级文字渲染、跨图角色一致性、商用级插画品质
- 对风格的理解而非简单模仿
- 图生图模式下,输出质量与参考图分辨率相关
### 提示词结构(按层次组织)
**层级顺序:** 主体描述 → 材质/配件细节 → 环境/背景 → 灯光 → 相机/构图 → 色调/氛围 → 质量标记
### 具体化原则
- 用具体数字代替模糊描述:"3 soft-close drawers" 而非 "several drawers"
- 用具体颜色代码代替泛称:"warm walnut #5C4033" 而非 "dark wood"
- 用具体材质名代替泛称:"North American black walnut grain" 而非 "wood texture"
- 用具体尺寸参考:"full-height floor-to-ceiling wardrobe" 而非 "tall wardrobe"
### 摄影/渲染术语库
镜头:
- 85mm prime lens(产品特写)、35mm wide-angle(空间全景)、50mm standard(自然视角)
- macro lens(细节纹理)、tilt-shift lens(建筑摄影)
光圈/景深:
- f/1.4 shallow depth of field with bokeh background(主体突出)
- f/8 sharp detail throughout(全景清晰)
- f/2.8 moderate bokeh(平衡)
灯光类型:
- soft studio lighting(柔和摄影棚光)
- warm tungsten lighting(暖色钨丝灯)
- natural window light(自然窗户光)
- golden hour warm glow(黄金时段暖光)
- soft diffused overhead lighting(柔和顶光)
- three-point lighting setup(三点布光)
- dramatic side lighting(戏剧性侧光)
- rim light / back light(轮廓光/背光)
构图方式:
- centered symmetrical(居中对称)
- rule of thirds(三分法)
- three-quarter view(四分之三视角)
- eye-level(平视)
- bird's-eye view / top-down(俯视)
- low angle hero shot(仰角英雄镜头)
- close-up detail shot(特写细节)
- wide establishing shot(全景交代镜头)
### 风格锚定参考
真实摄影类:
- photorealistic product photography(真实产品摄影)
- editorial interior photography(编辑级室内摄影)
- high-end catalog photography(高端目录摄影)
- lifestyle product shot(生活场景产品照)
渲染类:
- clean 3D render with studio lighting(干净 3D 渲染)
- technical product visualization(技术产品可视化)
- architectural visualization render(建筑可视化渲染)
- Unreal Engine 5 photorealistic render(虚幻引擎 5 写实渲染)
电影/艺术类:
- cinematic film still(电影静帧)
- anamorphic lens cinematic look(变形宽银幕电影感)
- Wes Anderson symmetric composition(韦斯·安德森对称构图)
- National Geographic editorial style(国家地理编辑风格)
### 家具/室内专项词汇
木纹材质:
- walnut grain texture、white oak grain、teak wood、ash wood、cherry wood
- natural wood with visible grain pattern(带可见纹理的天然木材)
- matte lacquer finish(哑光漆面)、oil-rubbed finish(油磨处理)
五金配件:
- brushed brass handles(拉丝黄铜把手)
- matte black hardware(哑光黑五金)
- chrome fittings(镀铬配件)
- concealed hinges(隐藏铰链)
- soft-close drawer slides(阻尼滑轨)
空间氛围:
- bright modern apartment(明亮现代公寓)
- warm Scandinavian living room(温暖北欧客厅)
- minimalist Japanese interior(极简日式空间)
- contemporary Chinese home(当代中式家居)
- luxury walk-in closet(豪华步入式衣帽间)
产品展示方式:
- clean white background(纯白背景)
- studio product photography on seamless backdrop(无缝背景产品摄影)
- lifestyle in-situ visualization(实景生活化展示)
- exploded view diagram(爆炸图/分解图)
- before and after comparison(前后对比)
### 负面约束(融入提示词末尾)
- no people, no text, no watermark, no distortion, clean composition
- no extra elements, no cluttered background
- professional photography quality, no amateur snapshots
### 图生图特别注意
- 参考图是 3D 渲染截图,不是真实照片,提示词中要指定目标是"真实摄影效果"还是"3D 渲染效果"
- 如果用户说"保留结构",提示词中要明确 "maintain the exact spatial arrangement and furniture placement from the reference"
- 材质升级时要具体描述目标材质,不要只说"更好的材质"`
interface PromptOptimizerConfig {
enabled: boolean
baseUrl: string | null
apiKey: string | null
model: string | null
}
const PROMPT_SEPARATOR = '---PROMPT---'
export function parsePromptFromResponse(fullText: string): string {
const idx = fullText.indexOf(PROMPT_SEPARATOR)
if (idx === -1) return fullText.trim()
return fullText.slice(idx + PROMPT_SEPARATOR.length).trim()
}
export class PromptOptimizerService {
async optimize(userPrompt: string, referenceImageDataUrl?: string | null, tags?: Record<string, string>): Promise<{ fullText: string; prompt: string }> {
const config = await this.getConfig()
if (!config.enabled || !config.baseUrl || !config.apiKey) {
throw new Error('提示词优化服务未启用或未配置')
}
const model = config.model || 'gpt-4o'
const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`
const userContent = this.buildUserContent(userPrompt, referenceImageDataUrl, tags)
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userContent },
],
temperature: 0.7,
max_tokens: 2000,
}),
})
if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(`提示词优化服务返回 ${response.status}: ${body.slice(0, 200)}`)
}
const data = await response.json() as {
choices?: Array<{ message?: { content?: string } }>
}
const fullText = data.choices?.[0]?.message?.content?.trim()
if (!fullText) {
throw new Error('提示词优化服务未返回有效内容')
}
return { fullText, prompt: parsePromptFromResponse(fullText) }
}
async optimizeStream(userPrompt: string, referenceImageDataUrl?: string | null, tags?: Record<string, string>): Promise<ReadableStream<Uint8Array>> {
const config = await this.getConfig()
if (!config.enabled || !config.baseUrl || !config.apiKey) {
throw new Error('提示词优化服务未启用或未配置')
}
const model = config.model || 'gpt-4o'
const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`
const userContent = this.buildUserContent(userPrompt, referenceImageDataUrl, tags)
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userContent },
],
temperature: 0.7,
max_tokens: 2000,
stream: true,
}),
})
if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(`提示词优化服务返回 ${response.status}: ${body.slice(0, 200)}`)
}
if (!response.body) {
throw new Error('提示词优化服务未返回流式响应')
}
const upstream = response.body
const decoder = new TextDecoder()
let buffer = ''
let fullText = ''
const encoder = new TextEncoder()
return new ReadableStream({
async pull(controller) {
const reader = upstream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data: ')) continue
const payload = trimmed.slice(6)
if (payload === '[DONE]")' || payload === '[DONE]') continue
try {
const parsed = JSON.parse(payload) as { choices?: Array<{ delta?: { content?: string } }> }
const delta = parsed.choices?.[0]?.delta?.content
if (delta) {
fullText += delta
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', text: delta })}\n\n`))
}
} catch {
// skip malformed JSON
}
}
}
const prompt = parsePromptFromResponse(fullText)
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', fullText, prompt })}\n\n`))
controller.close()
} catch (e) {
controller.error(e)
} finally {
reader.releaseLock()
}
},
cancel() {
upstream.cancel()
},
})
}
async isAvailable(): Promise<boolean> {
const config = await this.getConfig()
return config.enabled === true && !!config.baseUrl && !!config.apiKey
}
private buildUserContent(
userPrompt: string,
referenceImageDataUrl?: string | null,
tags?: Record<string, string>,
): Array<{ type: string; text?: string; image_url?: { url: string } }> {
const content: Array<{ type: string; text?: string; image_url?: { url: string } }> = []
if (referenceImageDataUrl) {
content.push({ type: 'image_url', image_url: { url: referenceImageDataUrl } })
}
let textPrompt = ''
if (referenceImageDataUrl) {
textPrompt = `请分析这张参考图中的场景,然后结合以下用户描述优化为高质量生图提示词:\n\n${userPrompt}`
} else {
textPrompt = userPrompt
}
if (tags && Object.keys(tags).length > 0) {
const tagLines = Object.entries(tags)
.filter(([, v]) => v)
.map(([group, value]) => `${group}:${value}`)
.join(';')
if (tagLines) {
textPrompt += `\n\n用户选择的偏好参数(请在优化时重点参考):${tagLines}`
}
}
content.push({ type: 'text', text: textPrompt })
return content
}
private async getConfig(): Promise<PromptOptimizerConfig> {
const active = await providerConfigService.getActive('prompt-optimizer')
if (active) {
return { enabled: true, baseUrl: active.baseUrl, apiKey: active.apiKey, model: active.model }
}
const dbConfig = await systemConfigService.get<PromptOptimizerConfig>('aiPromptOptimizer')
if (dbConfig?.enabled) {
return { enabled: true, baseUrl: dbConfig.baseUrl ?? null, apiKey: dbConfig.apiKey ?? null, model: dbConfig.model ?? null }
}
return { enabled: false, baseUrl: null, apiKey: null, model: null }
}
}
export const promptOptimizerService = new PromptOptimizerService()

View File

@ -0,0 +1,139 @@
import { prisma } from '@/lib/prisma'
import type { Prisma } from '@prisma/client'
export interface ProviderConfigDTO {
id: string
serviceType: string
name: string
baseUrl: string
apiKey: string
model: string | null
extra: Record<string, unknown> | null
isActive: boolean
sortOrder: number
createdAt: string
updatedAt: string
}
export interface CreateProviderConfigInput {
serviceType: string
name: string
baseUrl: string
apiKey: string
model?: string | null
extra?: Record<string, unknown> | null
}
export interface UpdateProviderConfigInput {
name?: string
baseUrl?: string
apiKey?: string
model?: string | null
extra?: Record<string, unknown> | null
}
function toDTO(record: {
id: string
serviceType: string
name: string
baseUrl: string
apiKey: string
model: string | null
extra: Prisma.JsonValue | null
isActive: boolean
sortOrder: number
createdAt: Date
updatedAt: Date
}): ProviderConfigDTO {
return {
id: record.id,
serviceType: record.serviceType,
name: record.name,
baseUrl: record.baseUrl,
apiKey: record.apiKey,
model: record.model,
extra: (record.extra as Record<string, unknown>) ?? null,
isActive: record.isActive,
sortOrder: record.sortOrder,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
}
}
export class ProviderConfigService {
async list(serviceType: string): Promise<ProviderConfigDTO[]> {
const records = await prisma.providerConfig.findMany({
where: { serviceType },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
})
return records.map(toDTO)
}
async getActive(serviceType: string): Promise<ProviderConfigDTO | null> {
const record = await prisma.providerConfig.findFirst({
where: { serviceType, isActive: true },
})
return record ? toDTO(record) : null
}
async getById(id: string): Promise<ProviderConfigDTO | null> {
const record = await prisma.providerConfig.findUnique({ where: { id } })
return record ? toDTO(record) : null
}
async create(input: CreateProviderConfigInput): Promise<ProviderConfigDTO> {
const count = await prisma.providerConfig.count({ where: { serviceType: input.serviceType } })
const record = await prisma.providerConfig.create({
data: {
serviceType: input.serviceType,
name: input.name,
baseUrl: input.baseUrl,
apiKey: input.apiKey,
model: input.model ?? null,
extra: (input.extra as Prisma.InputJsonValue) ?? undefined,
isActive: count === 0,
sortOrder: count,
},
})
return toDTO(record)
}
async update(id: string, input: UpdateProviderConfigInput): Promise<ProviderConfigDTO> {
const data: Prisma.ProviderConfigUpdateInput = {}
if (input.name !== undefined) data.name = input.name
if (input.baseUrl !== undefined) data.baseUrl = input.baseUrl
if (input.apiKey !== undefined) data.apiKey = input.apiKey
if (input.model !== undefined) data.model = input.model
if (input.extra !== undefined) data.extra = input.extra as Prisma.InputJsonValue
const record = await prisma.providerConfig.update({ where: { id }, data })
return toDTO(record)
}
async activate(id: string): Promise<void> {
const config = await prisma.providerConfig.findUnique({ where: { id } })
if (!config) return
await prisma.$transaction([
prisma.providerConfig.updateMany({
where: { serviceType: config.serviceType, isActive: true },
data: { isActive: false },
}),
prisma.providerConfig.update({
where: { id },
data: { isActive: true },
}),
])
}
async delete(id: string): Promise<void> {
const config = await prisma.providerConfig.findUnique({ where: { id } })
if (!config) return
if (config.isActive) {
throw new Error('不能删除当前激活的配置')
}
await prisma.providerConfig.delete({ where: { id } })
}
}
export const providerConfigService = new ProviderConfigService()