Files
chat/frontend-admin/src/views/ModelsView.vue
T
2026-08-05 15:56:08 +08:00

752 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>