752 lines
27 KiB
Vue
752 lines
27 KiB
Vue
<template>
|
||
<div>
|
||
<div class="page-header">
|
||
<h2>AI 模型配置</h2>
|
||
<p>对接 OpenAI / Dify / ComfyUI 接口</p>
|
||
</div>
|
||
|
||
<div class="toolbar">
|
||
<button v-if="auth.hasButton('btn:model:create')" class="btn btn-primary" @click="openCreate">添加模型</button>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>名称</th>
|
||
<th>接口类型</th>
|
||
<th>Model ID</th>
|
||
<th>API 地址</th>
|
||
<th>Max Tokens</th>
|
||
<th>默认</th>
|
||
<th>上下文</th>
|
||
<th>图片</th>
|
||
<th>状态</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="m in models" :key="m.id">
|
||
<td>{{ m.name }}</td>
|
||
<td>
|
||
<span class="badge" :class="providerBadgeClass(m.provider)">
|
||
{{ providerLabel(m.provider) }}
|
||
</span>
|
||
</td>
|
||
<td><code>{{ m.model_id || '-' }}</code></td>
|
||
<td class="url-cell">{{ m.api_base_url }}</td>
|
||
<td>{{ m.provider === 'dify' || m.provider === 'comfy' ? '-' : m.max_tokens }}</td>
|
||
<td>{{ m.is_default ? '✓' : '-' }}</td>
|
||
<td>
|
||
<span class="badge" :class="m.support_context ? 'badge-success' : 'badge-info'">
|
||
{{ m.support_context ? '支持' : '不支持' }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span class="badge" :class="m.support_image ? 'badge-success' : 'badge-info'">
|
||
{{ m.support_image ? '支持' : '不支持' }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span class="badge" :class="m.enabled ? 'badge-success' : 'badge-danger'">
|
||
{{ m.enabled ? '启用' : '禁用' }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<button v-if="auth.hasButton('btn:model:edit')" class="btn btn-ghost" @click="openEdit(m)">编辑</button>
|
||
<button v-if="auth.hasButton('btn:model:test')" class="btn btn-ghost" @click="quickTest(m.id)" :disabled="testingId === m.id">
|
||
{{ testingId === m.id ? '测试中...' : '测试' }}
|
||
</button>
|
||
<button v-if="auth.hasButton('btn:model:delete')" class="btn btn-danger" @click="handleDelete(m.id)">删除</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h3>{{ editingId ? '编辑模型' : '添加模型' }}</h3>
|
||
<button @click="showModal = false">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label>显示名称</label>
|
||
<input v-model="form.name" class="form-input" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>接口类型</label>
|
||
<select v-model="form.provider" class="form-input" @change="onProviderChange">
|
||
<option value="openai">OpenAI 兼容(GPT / DeepSeek / vLLM / SGLang 等)</option>
|
||
<option value="dify">Dify 应用(chat-messages 接口)</option>
|
||
<option value="comfy">ComfyUI(图片 / 短剧工作节点)</option>
|
||
</select>
|
||
<p class="field-hint" v-if="form.provider === 'dify'">
|
||
Dify 使用自己的一套接口协议(/chat-messages),跟 OpenAI 的 /chat/completions 不同,不要混用,否则会报 404
|
||
</p>
|
||
<p class="field-hint" v-if="form.provider === 'comfy'">
|
||
每个不同的 API 地址会被短剧系统识别为一个独立工作节点,最多并发 3 个;相同地址仍按 ComfyUI 单队列串行,避免假并发和显存溢出。
|
||
下方工作流 JSON 用于对话生图;留空则使用服务器默认文件 <code>backend/config/comfyui_workflow.json</code>。
|
||
</p>
|
||
</div>
|
||
<div class="form-group" v-if="form.provider === 'openai'">
|
||
<label>Model ID</label>
|
||
<input v-model="form.model_id" class="form-input" placeholder="gpt-4o-mini" />
|
||
</div>
|
||
<div class="form-group" v-if="form.provider === 'comfy'">
|
||
<label>宽高比(可选)</label>
|
||
<select v-model="form.model_id" class="form-input">
|
||
<option value="">使用工作流默认</option>
|
||
<option v-for="opt in aspectRatioOptions" :key="opt" :value="opt">{{ opt }}</option>
|
||
<option v-if="customSizeOption" :value="customSizeOption">{{ customSizeOption }}(自定义分辨率)</option>
|
||
</select>
|
||
<p class="field-hint">
|
||
对应 ResolutionSelector 的 aspect_ratio。不要填无关文字;需要像素尺寸时可在下方自定义,例如
|
||
<code>1024x1024</code>
|
||
</p>
|
||
<input
|
||
v-model="customSizeInput"
|
||
class="form-input"
|
||
style="margin-top: 8px"
|
||
placeholder="可选:自定义分辨率 1024x1024"
|
||
@change="applyCustomSize"
|
||
/>
|
||
</div>
|
||
<template v-if="form.provider === 'comfy'">
|
||
<div class="form-group">
|
||
<label>自定义工作流 JSON(可选)</label>
|
||
<textarea
|
||
v-model="form.workflow_json"
|
||
class="form-input workflow-json"
|
||
rows="10"
|
||
placeholder="从 ComfyUI 导出 API Format 的 workflow JSON,粘贴到这里;留空使用默认工作流"
|
||
/>
|
||
<div class="workflow-actions">
|
||
<label class="btn btn-secondary btn-sm file-btn">
|
||
从文件导入
|
||
<input type="file" accept=".json,application/json" @change="onWorkflowFile" hidden />
|
||
</label>
|
||
<button type="button" class="btn btn-secondary btn-sm" @click="form.workflow_json = ''">清空(用默认)</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label><input type="checkbox" v-model="form.refine_prompt" /> AI 提示词扩写(推荐)</label>
|
||
<p class="field-hint">
|
||
开启后走工作流 TextGenerate:把用户描述扩成英文视觉提示词再画图。画风以用户描述为准,也可在下方自定义扩写规则。
|
||
</p>
|
||
</div>
|
||
<div class="form-group" v-if="form.refine_prompt">
|
||
<label>扩写 System Prompt(可选,自定义风格规则)</label>
|
||
<textarea
|
||
v-model="form.system_prompt"
|
||
class="form-input workflow-json"
|
||
rows="8"
|
||
:placeholder="defaultSystemPromptPlaceholder"
|
||
/>
|
||
<p class="field-hint">
|
||
留空用内置通用规则(不写死清明上河图等具体画风)。若要固定品牌/画风偏好,在此自行编写,例如「默认国潮插画、禁止照片风」等。
|
||
</p>
|
||
<button type="button" class="btn btn-secondary btn-sm" style="margin-top:6px" @click="form.system_prompt = ''">恢复默认</button>
|
||
</div>
|
||
<div class="form-row-2">
|
||
<div class="form-group">
|
||
<label>提示词节点 ID(可选)</label>
|
||
<input v-model="form.prompt_node" class="form-input" placeholder="自动识别,如 30:19" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>种子节点 ID(可选)</label>
|
||
<input v-model="form.seed_node" class="form-input" placeholder="自动识别,如 30:3" />
|
||
</div>
|
||
</div>
|
||
<div class="form-row-2">
|
||
<div class="form-group">
|
||
<label>img2img 重绘强度</label>
|
||
<input v-model.number="form.img2img_denoise" type="number" min="0.01" max="1" step="0.01" class="form-input" />
|
||
<p class="field-hint">默认 0.45;越低越保留原图,越高变化越明显。</p>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>inpaint 重绘强度</label>
|
||
<input v-model.number="form.inpaint_denoise" type="number" min="0.01" max="1" step="0.01" class="form-input" />
|
||
<p class="field-hint">默认 0.72;仅作用于白色遮罩区域。</p>
|
||
</div>
|
||
</div>
|
||
<p class="field-hint">
|
||
必须使用 <code>Save (API Format)</code> 导出的 JSON(节点含 class_type)。
|
||
UI Format(含 nodes/links)导入后会秒结束且不出图。
|
||
ZImageTurbo 默认提示词节点 <code>30:19</code>,采样种子 <code>30:3</code>。
|
||
</p>
|
||
</template>
|
||
<div class="form-group">
|
||
<label>API Base URL</label>
|
||
<input
|
||
v-model="form.api_base_url"
|
||
class="form-input"
|
||
:placeholder="apiBasePlaceholder"
|
||
/>
|
||
</div>
|
||
<div class="form-group" v-if="form.provider !== 'comfy'">
|
||
<label>API Key</label>
|
||
<p v-if="editingId && hasSavedKey" class="saved-key-hint">
|
||
已保存 Key:<code>{{ savedKeyHint }}</code>(输入新值可覆盖,留空则不修改)
|
||
</p>
|
||
<p v-else-if="editingId && !hasSavedKey" class="field-hint field-hint-warn">
|
||
当前未配置 Key,请填写
|
||
</p>
|
||
<input
|
||
v-model="form.api_key"
|
||
type="password"
|
||
class="form-input"
|
||
:placeholder="editingId ? '留空则使用已保存的 Key' : (form.provider === 'dify' ? 'Dify 应用“访问 API”页面获取的密钥' : '可选,部分本地模型可不填')"
|
||
/>
|
||
</div>
|
||
<div class="form-group" v-else>
|
||
<label>API Key(可选)</label>
|
||
<input
|
||
v-model="form.api_key"
|
||
type="password"
|
||
class="form-input"
|
||
:placeholder="editingId ? '留空则使用已保存的 Key' : '本地 ComfyUI 一般无需填写'"
|
||
/>
|
||
</div>
|
||
<div class="form-group" v-if="form.provider === 'openai'">
|
||
<label>Max Tokens</label>
|
||
<input v-model.number="form.max_tokens" type="number" class="form-input" />
|
||
</div>
|
||
<div class="form-group" v-if="form.provider === 'openai'">
|
||
<label>Temperature</label>
|
||
<input v-model.number="form.temperature" type="number" step="0.1" class="form-input" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label><input type="checkbox" v-model="form.is_default" /> 设为默认模型</label>
|
||
</div>
|
||
<div class="form-group">
|
||
<label><input type="checkbox" v-model="form.enabled" /> 启用</label>
|
||
</div>
|
||
<div class="form-group" v-if="form.provider !== 'comfy'">
|
||
<label><input type="checkbox" v-model="form.support_context" /> 支持上下文(多轮对话)</label>
|
||
<p class="field-hint" v-if="form.provider === 'dify'">关闭后每次提问都会让 Dify 开启一个新会话,不延续之前的上下文</p>
|
||
<p class="field-hint" v-else>关闭后每次提问只发送当前这一条消息,不携带历史聊天记录</p>
|
||
</div>
|
||
<div class="form-group" v-if="form.provider !== 'comfy'">
|
||
<label><input type="checkbox" v-model="form.support_image" /> 支持图片/多模态输入</label>
|
||
<p class="field-hint">关闭后用户仍可上传图片给自己看,但不会把图片发给该模型识别(避免"not a multimodal model"报错),关闭后聊天界面也会自动隐藏图片上传按钮</p>
|
||
</div>
|
||
<template v-if="form.provider === 'openai'">
|
||
<div class="form-group">
|
||
<label>Frequency Penalty(频率惩罚)</label>
|
||
<input v-model.number="form.frequency_penalty" type="number" step="0.1" min="0" max="2" class="form-input" />
|
||
<p class="field-hint">部分自部署/OCR 类模型容易陷入重复输出循环(同一句话刷屏),调高此值(如 1.0~1.5)可以有效抑制,默认 0 表示不干预</p>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Presence Penalty(存在惩罚)</label>
|
||
<input v-model.number="form.presence_penalty" type="number" step="0.1" min="0" max="2" class="form-input" />
|
||
<p class="field-hint">抑制模型反复围绕同一话题/短语,与频率惩罚搭配使用效果更好,默认 0 表示不干预</p>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-if="testResult" class="test-result" :class="testResult.ok ? 'success' : 'error'">
|
||
<strong>{{ testResult.ok ? '✓ 测试成功' : '✗ 测试失败' }}</strong>
|
||
<p v-if="testResult.ok">
|
||
延迟 {{ testResult.latency_ms }}ms
|
||
<span v-if="testResult.tokens"> · Token {{ testResult.tokens }}</span>
|
||
</p>
|
||
<p v-if="testResult.reply" class="reply-preview">模型回复:{{ testResult.reply }}</p>
|
||
<p v-if="!testResult.ok">{{ testResult.message }}</p>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" @click="showModal = false">取消</button>
|
||
<button v-if="auth.hasButton('btn:model:test')" class="btn btn-ghost" @click="testModel" :disabled="testing">
|
||
{{ testing ? '测试中...' : '测试连接' }}
|
||
</button>
|
||
<button class="btn btn-primary" @click="saveModel">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted } from 'vue'
|
||
import api from '@/api'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
const auth = useAuthStore()
|
||
const models = ref([])
|
||
const showModal = ref(false)
|
||
const editingId = ref(null)
|
||
const testing = ref(false)
|
||
const testingId = ref(null)
|
||
const testResult = ref(null)
|
||
const hasSavedKey = ref(false)
|
||
const savedKeyHint = ref('')
|
||
const customSizeInput = ref('')
|
||
const aspectRatioOptions = [
|
||
'1:1 (Square)',
|
||
'2:3 (Portrait Photo)',
|
||
'3:2 (Photo)',
|
||
'3:4 (Portrait Standard)',
|
||
'4:3 (Standard)',
|
||
'9:16 (Portrait Widescreen)',
|
||
'16:9 (Widescreen)',
|
||
'21:9 (Ultrawide)'
|
||
]
|
||
|
||
const customSizeOption = computed(() => {
|
||
const v = (customSizeInput.value || '').trim()
|
||
if (/^\d+\s*[xX×]\s*\d+$/.test(v)) {
|
||
return v.replace(/\s*[xX×]\s*/, 'x')
|
||
}
|
||
const mid = (form.model_id || '').trim()
|
||
if (/^\d+\s*[xX×]\s*\d+$/.test(mid) && !aspectRatioOptions.includes(mid)) {
|
||
return mid.replace(/\s*[xX×]\s*/, 'x')
|
||
}
|
||
return ''
|
||
})
|
||
|
||
const form = reactive({
|
||
name: '',
|
||
provider: 'openai',
|
||
model_id: '',
|
||
api_base_url: 'https://api.openai.com/v1',
|
||
api_key: '',
|
||
max_tokens: 4096,
|
||
temperature: 0.7,
|
||
is_default: false,
|
||
enabled: true,
|
||
support_context: true,
|
||
support_image: true,
|
||
frequency_penalty: 0,
|
||
presence_penalty: 0,
|
||
workflow_json: '',
|
||
prompt_node: '',
|
||
seed_node: '',
|
||
img2img_denoise: 0.45,
|
||
inpaint_denoise: 0.72,
|
||
refine_prompt: true,
|
||
system_prompt: ''
|
||
})
|
||
|
||
const defaultSystemPromptPlaceholder = `留空则使用内置通用扩写规则。
|
||
可自定义,例如:
|
||
You are a prompt engineer...
|
||
Always prefer Chinese mythic illustration style when relevant.
|
||
Never put readable text in the image.`
|
||
|
||
function applyCustomSize() {
|
||
const v = (customSizeInput.value || '').trim()
|
||
if (/^\d+\s*[xX×]\s*\d+$/.test(v)) {
|
||
form.model_id = v.replace(/\s*[xX×]\s*/, 'x')
|
||
}
|
||
}
|
||
|
||
function normalizeComfyModelId(value) {
|
||
const v = (value || '').trim()
|
||
if (!v) return ''
|
||
if (aspectRatioOptions.includes(v)) return v
|
||
if (/^\d+\s*[xX×]\s*\d+$/.test(v)) return v.replace(/\s*[xX×]\s*/, 'x')
|
||
// 简写 1:1 / 16:9
|
||
const m = v.match(/^(\d+)\s*:\s*(\d+)$/)
|
||
if (m) {
|
||
const short = `${m[1]}:${m[2]}`
|
||
const hit = aspectRatioOptions.find(opt => opt.startsWith(short + ' '))
|
||
if (hit) return hit
|
||
}
|
||
// 历史脏数据(如误填 admin)直接清空,避免写入非法 aspect_ratio
|
||
return ''
|
||
}
|
||
|
||
const apiBasePlaceholder = computed(() => {
|
||
if (form.provider === 'dify') return 'https://api.dify.ai/v1(或自部署地址)'
|
||
if (form.provider === 'comfy') return 'http://127.0.0.1:8188'
|
||
return 'https://api.openai.com/v1'
|
||
})
|
||
|
||
function providerLabel(provider) {
|
||
if (provider === 'dify') return 'Dify'
|
||
if (provider === 'comfy') return 'ComfyUI'
|
||
return 'OpenAI 兼容'
|
||
}
|
||
|
||
function providerBadgeClass(provider) {
|
||
if (provider === 'dify') return 'badge-info'
|
||
if (provider === 'comfy') return 'badge-warn'
|
||
return 'badge-success'
|
||
}
|
||
|
||
function normalizeProvider(provider) {
|
||
if (provider === 'dify' || provider === 'comfy') return provider
|
||
return 'openai'
|
||
}
|
||
|
||
function onProviderChange() {
|
||
if (form.provider === 'comfy') {
|
||
if (!form.api_base_url || form.api_base_url.includes('openai') || form.api_base_url.includes('dify')) {
|
||
form.api_base_url = 'http://127.0.0.1:8188'
|
||
}
|
||
form.support_context = false
|
||
form.support_image = false
|
||
form.model_id = normalizeComfyModelId(form.model_id) || '1:1 (Square)'
|
||
customSizeInput.value = /^\d+x\d+$/.test(form.model_id) ? form.model_id : ''
|
||
} else if (form.provider === 'dify') {
|
||
if (!form.api_base_url || form.api_base_url.includes('8188') || form.api_base_url.includes('openai')) {
|
||
form.api_base_url = 'https://api.dify.ai/v1'
|
||
}
|
||
} else if (!form.api_base_url || form.api_base_url.includes('8188') || form.api_base_url.includes('dify')) {
|
||
form.api_base_url = 'https://api.openai.com/v1'
|
||
}
|
||
}
|
||
|
||
function parseExtraConfig(extra) {
|
||
if (!extra) return { workflow_json: '', prompt_node: '', seed_node: '', img2img_denoise: 0.45, inpaint_denoise: 0.72, refine_prompt: true, system_prompt: '' }
|
||
if (typeof extra === 'string') {
|
||
try { extra = JSON.parse(extra) } catch { return { workflow_json: '', prompt_node: '', seed_node: '', img2img_denoise: 0.45, inpaint_denoise: 0.72, refine_prompt: true, system_prompt: '' } }
|
||
}
|
||
return {
|
||
workflow_json: extra.workflow ? JSON.stringify(extra.workflow, null, 2) : '',
|
||
prompt_node: extra.prompt_node || '',
|
||
seed_node: extra.seed_node || '',
|
||
img2img_denoise: Number(extra.img2img_denoise ?? 0.45),
|
||
inpaint_denoise: Number(extra.inpaint_denoise ?? 0.72),
|
||
refine_prompt: extra.refine_prompt === undefined ? true : !!extra.refine_prompt,
|
||
system_prompt: extra.system_prompt || ''
|
||
}
|
||
}
|
||
|
||
function isUiWorkflow(obj) {
|
||
return !!(obj && typeof obj === 'object' && Array.isArray(obj.nodes) && (obj.links || obj.version || obj.last_node_id))
|
||
}
|
||
|
||
function isApiWorkflow(obj) {
|
||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false
|
||
return Object.values(obj).some(
|
||
(n) => n && typeof n === 'object' && n.class_type && n.inputs && typeof n.inputs === 'object'
|
||
)
|
||
}
|
||
|
||
function unwrapWorkflow(obj) {
|
||
if (obj?.prompt && isApiWorkflow(obj.prompt)) return obj.prompt
|
||
if (obj?.workflow && isApiWorkflow(obj.workflow)) return obj.workflow
|
||
return obj
|
||
}
|
||
|
||
function buildExtraConfig() {
|
||
if (form.provider !== 'comfy') return null
|
||
const config = {}
|
||
const raw = (form.workflow_json || '').trim()
|
||
if (raw) {
|
||
let workflow
|
||
try {
|
||
workflow = JSON.parse(raw)
|
||
} catch {
|
||
throw new Error('工作流 JSON 格式无效,请检查是否为合法 JSON')
|
||
}
|
||
workflow = unwrapWorkflow(workflow)
|
||
if (isUiWorkflow(workflow)) {
|
||
throw new Error('请导入 API Format 工作流(ComfyUI 开发者模式 → Save (API Format)),不要导入带 nodes/links 的 UI Format')
|
||
}
|
||
if (!isApiWorkflow(workflow)) {
|
||
throw new Error('工作流不是有效的 API Format(节点需包含 class_type / inputs)')
|
||
}
|
||
config.workflow = workflow
|
||
}
|
||
if ((form.prompt_node || '').trim()) config.prompt_node = form.prompt_node.trim()
|
||
if ((form.seed_node || '').trim()) config.seed_node = form.seed_node.trim()
|
||
config.img2img_denoise = Math.max(0.01, Math.min(1, Number(form.img2img_denoise) || 0.45))
|
||
config.inpaint_denoise = Math.max(0.01, Math.min(1, Number(form.inpaint_denoise) || 0.72))
|
||
if ((form.system_prompt || '').trim()) config.system_prompt = form.system_prompt.trim()
|
||
config.refine_prompt = !!form.refine_prompt
|
||
return config
|
||
}
|
||
|
||
function onWorkflowFile(e) {
|
||
const file = e.target.files?.[0]
|
||
e.target.value = ''
|
||
if (!file) return
|
||
const reader = new FileReader()
|
||
reader.onload = () => {
|
||
try {
|
||
const text = String(reader.result || '')
|
||
let parsed = JSON.parse(text)
|
||
parsed = unwrapWorkflow(parsed)
|
||
if (isUiWorkflow(parsed)) {
|
||
alert('这是 UI Format 工作流,不能用于后端生图。请在 ComfyUI 开启开发者模式后使用 Save (API Format) 重新导出。')
|
||
return
|
||
}
|
||
if (!isApiWorkflow(parsed)) {
|
||
alert('无法识别为 API Format 工作流')
|
||
return
|
||
}
|
||
form.workflow_json = JSON.stringify(parsed, null, 2)
|
||
} catch {
|
||
alert('无法解析该 JSON 文件')
|
||
}
|
||
}
|
||
reader.readAsText(file)
|
||
}
|
||
|
||
onMounted(loadModels)
|
||
|
||
async function loadModels() {
|
||
const res = await api.get('/admin/models')
|
||
models.value = res.data.data
|
||
}
|
||
|
||
function openCreate() {
|
||
editingId.value = null
|
||
testResult.value = null
|
||
hasSavedKey.value = false
|
||
savedKeyHint.value = ''
|
||
customSizeInput.value = ''
|
||
Object.assign(form, {
|
||
name: '', provider: 'openai', model_id: '', api_base_url: 'https://api.openai.com/v1',
|
||
api_key: '', max_tokens: 4096, temperature: 0.7, is_default: false, enabled: true, support_context: true, support_image: true,
|
||
frequency_penalty: 0, presence_penalty: 0,
|
||
workflow_json: '', prompt_node: '', seed_node: '', img2img_denoise: 0.45, inpaint_denoise: 0.72, refine_prompt: true, system_prompt: ''
|
||
})
|
||
showModal.value = true
|
||
}
|
||
|
||
function openEdit(m) {
|
||
editingId.value = m.id
|
||
testResult.value = null
|
||
hasSavedKey.value = !!m.has_api_key
|
||
savedKeyHint.value = m.api_key_hint || ''
|
||
const extra = parseExtraConfig(m.extra_config)
|
||
const provider = normalizeProvider(m.provider)
|
||
const modelId = provider === 'comfy' ? normalizeComfyModelId(m.model_id) : (m.model_id || '')
|
||
customSizeInput.value = /^\d+x\d+$/.test(modelId) ? modelId : ''
|
||
Object.assign(form, {
|
||
name: m.name, provider, model_id: modelId, api_base_url: m.api_base_url,
|
||
api_key: '', max_tokens: m.max_tokens, temperature: parseFloat(m.temperature),
|
||
is_default: !!m.is_default, enabled: !!m.enabled,
|
||
support_context: m.support_context === undefined ? true : !!m.support_context,
|
||
support_image: m.support_image === undefined ? true : !!m.support_image,
|
||
frequency_penalty: m.frequency_penalty !== undefined && m.frequency_penalty !== null ? parseFloat(m.frequency_penalty) : 0,
|
||
presence_penalty: m.presence_penalty !== undefined && m.presence_penalty !== null ? parseFloat(m.presence_penalty) : 0,
|
||
workflow_json: extra.workflow_json,
|
||
prompt_node: extra.prompt_node,
|
||
seed_node: extra.seed_node,
|
||
img2img_denoise: extra.img2img_denoise,
|
||
inpaint_denoise: extra.inpaint_denoise,
|
||
refine_prompt: extra.refine_prompt,
|
||
system_prompt: extra.system_prompt
|
||
})
|
||
showModal.value = true
|
||
}
|
||
|
||
async function saveModel() {
|
||
let extra_config = null
|
||
try {
|
||
extra_config = buildExtraConfig()
|
||
} catch (e) {
|
||
alert(e.message || '配置无效')
|
||
return
|
||
}
|
||
|
||
if (form.provider === 'comfy') {
|
||
form.model_id = normalizeComfyModelId(form.model_id)
|
||
}
|
||
|
||
const payload = {
|
||
...form,
|
||
is_default: form.is_default ? 1 : 0,
|
||
enabled: form.enabled ? 1 : 0,
|
||
support_context: form.support_context ? 1 : 0,
|
||
support_image: form.support_image ? 1 : 0,
|
||
extra_config
|
||
}
|
||
delete payload.workflow_json
|
||
delete payload.prompt_node
|
||
delete payload.seed_node
|
||
delete payload.img2img_denoise
|
||
delete payload.inpaint_denoise
|
||
delete payload.refine_prompt
|
||
delete payload.system_prompt
|
||
|
||
if (editingId.value) {
|
||
if (!payload.api_key) delete payload.api_key
|
||
await api.put(`/admin/models/${editingId.value}`, payload)
|
||
} else {
|
||
await api.post('/admin/models', payload)
|
||
}
|
||
showModal.value = false
|
||
await loadModels()
|
||
}
|
||
|
||
async function handleDelete(id) {
|
||
if (confirm('确定删除此模型?')) {
|
||
await api.delete(`/admin/models/${id}`)
|
||
await loadModels()
|
||
}
|
||
}
|
||
|
||
async function testModel() {
|
||
if (form.provider === 'comfy') {
|
||
if (!form.api_base_url) {
|
||
testResult.value = { ok: false, message: '请填写 ComfyUI 地址' }
|
||
return
|
||
}
|
||
} else if (form.provider === 'dify') {
|
||
if (!form.api_base_url) {
|
||
testResult.value = { ok: false, message: '请填写 API 地址' }
|
||
return
|
||
}
|
||
if (!form.api_key && !editingId.value) {
|
||
testResult.value = { ok: false, message: '请填写 API Key' }
|
||
return
|
||
}
|
||
if (!form.api_key && editingId.value && !hasSavedKey.value) {
|
||
testResult.value = { ok: false, message: '当前模型未配置 Key,请填写' }
|
||
return
|
||
}
|
||
} else if (!form.model_id || !form.api_base_url) {
|
||
testResult.value = { ok: false, message: '请填写 Model ID 和 API 地址' }
|
||
return
|
||
}
|
||
|
||
testing.value = true
|
||
testResult.value = null
|
||
try {
|
||
const payload = {
|
||
provider: form.provider,
|
||
model_id: form.model_id,
|
||
api_base_url: form.api_base_url,
|
||
temperature: form.temperature
|
||
}
|
||
if (form.api_key) payload.api_key = form.api_key
|
||
|
||
const url = editingId.value
|
||
? `/admin/models/${editingId.value}/test`
|
||
: '/admin/models/test'
|
||
const res = await api.post(url, payload)
|
||
testResult.value = { ok: true, ...res.data.data }
|
||
} catch (e) {
|
||
testResult.value = { ok: false, message: e.message }
|
||
} finally {
|
||
testing.value = false
|
||
}
|
||
}
|
||
|
||
async function quickTest(id) {
|
||
testingId.value = id
|
||
try {
|
||
const res = await api.post(`/admin/models/${id}/test`, {})
|
||
alert(`测试成功!\n延迟: ${res.data.data.latency_ms}ms\n回复: ${res.data.data.reply}`)
|
||
} catch (e) {
|
||
alert('测试失败: ' + e.message)
|
||
} finally {
|
||
testingId.value = null
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.toolbar {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.url-cell {
|
||
max-width: 200px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
code {
|
||
background: var(--bg-tertiary);
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.test-result {
|
||
margin-top: 8px;
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
}
|
||
.test-result.success {
|
||
background: rgba(34, 197, 94, 0.12);
|
||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||
color: var(--success);
|
||
}
|
||
.test-result.error {
|
||
background: rgba(239, 68, 68, 0.12);
|
||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||
color: var(--danger);
|
||
}
|
||
.test-result p {
|
||
margin-top: 6px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.test-result.success p {
|
||
color: #86efac;
|
||
}
|
||
.reply-preview {
|
||
word-break: break-all;
|
||
}
|
||
|
||
.field-hint {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.field-hint-warn {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.saved-key-hint {
|
||
margin-bottom: 6px;
|
||
font-size: 12px;
|
||
color: var(--success);
|
||
}
|
||
|
||
.saved-key-hint code {
|
||
background: rgba(34, 197, 94, 0.12);
|
||
color: var(--success);
|
||
}
|
||
|
||
.workflow-json {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
min-height: 180px;
|
||
resize: vertical;
|
||
}
|
||
|
||
.workflow-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.form-row-2 {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.file-btn {
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.form-row-2 {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|