feat(admin): 接入AI服务商配置与提示词优化
This commit is contained in:
parent
bd02176bdf
commit
285bb3c180
@ -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");
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -5,7 +5,7 @@ import AiRenderConfigClient from './AiRenderConfigClient'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI 生图配置 | 后台设置',
|
||||
description: '配置辅助装配 AI 效果图外部生图服务',
|
||||
description: '配置 AI 生图和提示词优化服务',
|
||||
}
|
||||
|
||||
export default function AiRenderConfigPage() {
|
||||
|
||||
@ -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'] },
|
||||
)
|
||||
}
|
||||
@ -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'] },
|
||||
)
|
||||
}
|
||||
@ -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'] },
|
||||
)
|
||||
}
|
||||
@ -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'] },
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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()
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user