This commit is contained in:
Your Name
2026-07-16 10:15:42 +08:00
parent 99a6ca0a77
commit 80b0c89a72
8 changed files with 598 additions and 40 deletions
+5
View File
@@ -189,6 +189,11 @@ input, select, textarea {
color: var(--accent); color: var(--accent);
} }
.badge-warn {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
+361 -20
View File
@@ -2,7 +2,7 @@
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>AI 模型配置</h2> <h2>AI 模型配置</h2>
<p>对接 OpenAI 格式 API 接口</p> <p>对接 OpenAI / Dify / ComfyUI 接口</p>
</div> </div>
<div class="toolbar"> <div class="toolbar">
@@ -29,13 +29,13 @@
<tr v-for="m in models" :key="m.id"> <tr v-for="m in models" :key="m.id">
<td>{{ m.name }}</td> <td>{{ m.name }}</td>
<td> <td>
<span class="badge" :class="m.provider === 'dify' ? 'badge-info' : 'badge-success'"> <span class="badge" :class="providerBadgeClass(m.provider)">
{{ m.provider === 'dify' ? 'Dify' : 'OpenAI 兼容' }} {{ providerLabel(m.provider) }}
</span> </span>
</td> </td>
<td><code>{{ m.model_id || '-' }}</code></td> <td><code>{{ m.model_id || '-' }}</code></td>
<td class="url-cell">{{ m.api_base_url }}</td> <td class="url-cell">{{ m.api_base_url }}</td>
<td>{{ m.provider === 'dify' ? '-' : m.max_tokens }}</td> <td>{{ m.provider === 'dify' || m.provider === 'comfy' ? '-' : m.max_tokens }}</td>
<td>{{ m.is_default ? '✓' : '-' }}</td> <td>{{ m.is_default ? '✓' : '-' }}</td>
<td> <td>
<span class="badge" :class="m.support_context ? 'badge-success' : 'badge-info'"> <span class="badge" :class="m.support_context ? 'badge-success' : 'badge-info'">
@@ -77,27 +77,103 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>接口类型</label> <label>接口类型</label>
<select v-model="form.provider" class="form-input"> <select v-model="form.provider" class="form-input" @change="onProviderChange">
<option value="openai">OpenAI 兼容GPT / DeepSeek / vLLM / SGLang </option> <option value="openai">OpenAI 兼容GPT / DeepSeek / vLLM / SGLang </option>
<option value="dify">Dify 应用chat-messages 接口</option> <option value="dify">Dify 应用chat-messages 接口</option>
<option value="comfy">ComfyUI 文生图</option>
</select> </select>
<p class="field-hint" v-if="form.provider === 'dify'"> <p class="field-hint" v-if="form.provider === 'dify'">
Dify 使用自己的一套接口协议/chat-messages OpenAI /chat/completions 不同不要混用否则会报 404 Dify 使用自己的一套接口协议/chat-messages OpenAI /chat/completions 不同不要混用否则会报 404
</p> </p>
<p class="field-hint" v-if="form.provider === 'comfy'">
用户发送的文字会写入工作流的提示词节点并调用本地 ComfyUI 生图可在下方粘贴自定义工作流 JSON留空则使用服务器默认文件
<code>backend/config/comfyui_workflow.json</code>当前为 ZImageTurbo
</p>
</div> </div>
<div class="form-group" v-if="form.provider !== 'dify'"> <div class="form-group" v-if="form.provider === 'openai'">
<label>Model ID</label> <label>Model ID</label>
<input v-model="form.model_id" class="form-input" placeholder="gpt-4o-mini" /> <input v-model="form.model_id" class="form-input" placeholder="gpt-4o-mini" />
</div> </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>
<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"> <div class="form-group">
<label>API Base URL</label> <label>API Base URL</label>
<input <input
v-model="form.api_base_url" v-model="form.api_base_url"
class="form-input" class="form-input"
:placeholder="form.provider === 'dify' ? 'https://api.dify.ai/v1(或自部署地址)' : 'https://api.openai.com/v1'" :placeholder="apiBasePlaceholder"
/> />
</div> </div>
<div class="form-group"> <div class="form-group" v-if="form.provider !== 'comfy'">
<label>API Key</label> <label>API Key</label>
<p v-if="editingId && hasSavedKey" class="saved-key-hint"> <p v-if="editingId && hasSavedKey" class="saved-key-hint">
已保存 Key<code>{{ savedKeyHint }}</code>输入新值可覆盖留空则不修改 已保存 Key<code>{{ savedKeyHint }}</code>输入新值可覆盖留空则不修改
@@ -112,11 +188,20 @@
:placeholder="editingId ? '留空则使用已保存的 Key' : (form.provider === 'dify' ? 'Dify 应用“访问 API”页面获取的密钥' : '可选,部分本地模型可不填')" :placeholder="editingId ? '留空则使用已保存的 Key' : (form.provider === 'dify' ? 'Dify 应用“访问 API”页面获取的密钥' : '可选,部分本地模型可不填')"
/> />
</div> </div>
<div class="form-group" v-if="form.provider !== 'dify'"> <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> <label>Max Tokens</label>
<input v-model.number="form.max_tokens" type="number" class="form-input" /> <input v-model.number="form.max_tokens" type="number" class="form-input" />
</div> </div>
<div class="form-group" v-if="form.provider !== 'dify'"> <div class="form-group" v-if="form.provider === 'openai'">
<label>Temperature</label> <label>Temperature</label>
<input v-model.number="form.temperature" type="number" step="0.1" class="form-input" /> <input v-model.number="form.temperature" type="number" step="0.1" class="form-input" />
</div> </div>
@@ -126,16 +211,16 @@
<div class="form-group"> <div class="form-group">
<label><input type="checkbox" v-model="form.enabled" /> 启用</label> <label><input type="checkbox" v-model="form.enabled" /> 启用</label>
</div> </div>
<div class="form-group"> <div class="form-group" v-if="form.provider !== 'comfy'">
<label><input type="checkbox" v-model="form.support_context" /> 支持上下文多轮对话</label> <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-if="form.provider === 'dify'">关闭后每次提问都会让 Dify 开启一个新会话不延续之前的上下文</p>
<p class="field-hint" v-else>关闭后每次提问只发送当前这一条消息不携带历史聊天记录</p> <p class="field-hint" v-else>关闭后每次提问只发送当前这一条消息不携带历史聊天记录</p>
</div> </div>
<div class="form-group"> <div class="form-group" v-if="form.provider !== 'comfy'">
<label><input type="checkbox" v-model="form.support_image" /> 支持图片/多模态输入</label> <label><input type="checkbox" v-model="form.support_image" /> 支持图片/多模态输入</label>
<p class="field-hint">关闭后用户仍可上传图片给自己看但不会把图片发给该模型识别避免"not a multimodal model"报错关闭后聊天界面也会自动隐藏图片上传按钮</p> <p class="field-hint">关闭后用户仍可上传图片给自己看但不会把图片发给该模型识别避免"not a multimodal model"报错关闭后聊天界面也会自动隐藏图片上传按钮</p>
</div> </div>
<template v-if="form.provider !== 'dify'"> <template v-if="form.provider === 'openai'">
<div class="form-group"> <div class="form-group">
<label>Frequency Penalty频率惩罚</label> <label>Frequency Penalty频率惩罚</label>
<input v-model.number="form.frequency_penalty" type="number" step="0.1" min="0" max="2" class="form-input" /> <input v-model.number="form.frequency_penalty" type="number" step="0.1" min="0" max="2" class="form-input" />
@@ -171,7 +256,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import api from '@/api' import api from '@/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -184,6 +269,30 @@ const testingId = ref(null)
const testResult = ref(null) const testResult = ref(null)
const hasSavedKey = ref(false) const hasSavedKey = ref(false)
const savedKeyHint = ref('') 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({ const form = reactive({
name: '', name: '',
provider: 'openai', provider: 'openai',
@@ -197,9 +306,168 @@ const form = reactive({
support_context: true, support_context: true,
support_image: true, support_image: true,
frequency_penalty: 0, frequency_penalty: 0,
presence_penalty: 0 presence_penalty: 0,
workflow_json: '',
prompt_node: '',
seed_node: '',
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: '', refine_prompt: true, system_prompt: '' }
if (typeof extra === 'string') {
try { extra = JSON.parse(extra) } catch { return { workflow_json: '', prompt_node: '', seed_node: '', 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 || '',
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()
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) onMounted(loadModels)
async function loadModels() { async function loadModels() {
@@ -212,10 +480,12 @@ function openCreate() {
testResult.value = null testResult.value = null
hasSavedKey.value = false hasSavedKey.value = false
savedKeyHint.value = '' savedKeyHint.value = ''
customSizeInput.value = ''
Object.assign(form, { Object.assign(form, {
name: '', provider: 'openai', model_id: '', api_base_url: 'https://api.openai.com/v1', 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, 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 frequency_penalty: 0, presence_penalty: 0,
workflow_json: '', prompt_node: '', seed_node: '', refine_prompt: true, system_prompt: ''
}) })
showModal.value = true showModal.value = true
} }
@@ -225,26 +495,54 @@ function openEdit(m) {
testResult.value = null testResult.value = null
hasSavedKey.value = !!m.has_api_key hasSavedKey.value = !!m.has_api_key
savedKeyHint.value = m.api_key_hint || '' 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, { Object.assign(form, {
name: m.name, provider: m.provider === 'dify' ? 'dify' : 'openai', model_id: m.model_id, api_base_url: m.api_base_url, 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), api_key: '', max_tokens: m.max_tokens, temperature: parseFloat(m.temperature),
is_default: !!m.is_default, enabled: !!m.enabled, is_default: !!m.is_default, enabled: !!m.enabled,
support_context: m.support_context === undefined ? true : !!m.support_context, support_context: m.support_context === undefined ? true : !!m.support_context,
support_image: m.support_image === undefined ? true : !!m.support_image, 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, 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 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,
refine_prompt: extra.refine_prompt,
system_prompt: extra.system_prompt
}) })
showModal.value = true showModal.value = true
} }
async function saveModel() { 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 = { const payload = {
...form, ...form,
is_default: form.is_default ? 1 : 0, is_default: form.is_default ? 1 : 0,
enabled: form.enabled ? 1 : 0, enabled: form.enabled ? 1 : 0,
support_context: form.support_context ? 1 : 0, support_context: form.support_context ? 1 : 0,
support_image: form.support_image ? 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.refine_prompt
delete payload.system_prompt
if (editingId.value) { if (editingId.value) {
if (!payload.api_key) delete payload.api_key if (!payload.api_key) delete payload.api_key
await api.put(`/admin/models/${editingId.value}`, payload) await api.put(`/admin/models/${editingId.value}`, payload)
@@ -263,7 +561,12 @@ async function handleDelete(id) {
} }
async function testModel() { async function testModel() {
if (form.provider === 'dify') { 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) { if (!form.api_base_url) {
testResult.value = { ok: false, message: '请填写 API 地址' } testResult.value = { ok: false, message: '请填写 API 地址' }
return return
@@ -385,4 +688,42 @@ code {
background: rgba(34, 197, 94, 0.12); background: rgba(34, 197, 94, 0.12);
color: var(--success); 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> </style>
+14 -5
View File
@@ -63,7 +63,7 @@
ref="textareaRef" ref="textareaRef"
v-model="content" v-model="content"
class="input-textarea" class="input-textarea"
placeholder="输入消息... (Shift+Enter 换行)" :placeholder="inputPlaceholder"
rows="1" rows="1"
@keydown="handleKeydown" @keydown="handleKeydown"
@input="autoResize" @input="autoResize"
@@ -82,6 +82,7 @@
</div> </div>
<p v-if="uploadingFiles.length" class="input-hint">正在上传 {{ uploadingFiles.length }} 个文件</p> <p v-if="uploadingFiles.length" class="input-hint">正在上传 {{ uploadingFiles.length }} 个文件</p>
<p v-else-if="isComfyModel" class="input-hint">当前为 ComfyUI 生图模式发送描述后将在对话中生成图片</p>
<p v-else-if="imageBlockedByModel" class="input-hint input-hint-warn">当前模型不支持图片识别已隐藏图片上传</p> <p v-else-if="imageBlockedByModel" class="input-hint input-hint-warn">当前模型不支持图片识别已隐藏图片上传</p>
<p v-else class="input-hint">AI 可能会犯错请核实重要信息</p> <p v-else class="input-hint">AI 可能会犯错请核实重要信息</p>
</div> </div>
@@ -113,16 +114,24 @@ const features = computed(() => settings.features)
const perms = computed(() => auth.user?.membership_permissions || {}) const perms = computed(() => auth.user?.membership_permissions || {})
const activeModel = computed(() => { const activeModel = computed(() => {
const conv = chat.conversations.find(c => c.id === chat.currentId) const modelId = chat.selectedModelId
const modelId = conv?.model_id ?? chat.conversations.find(c => c.id === chat.currentId)?.model_id
if (modelId) { if (modelId != null) {
return settings.models.find(m => m.id === modelId) || null return settings.models.find(m => Number(m.id) === Number(modelId)) || null
} }
return settings.models.find(m => m.is_default) || settings.models[0] || null return settings.models.find(m => m.is_default) || settings.models[0] || null
}) })
const isComfyModel = computed(() => activeModel.value?.provider === 'comfy')
const inputPlaceholder = computed(() => {
if (isComfyModel.value) return '描述你想生成的图片… (Shift+Enter 换行)'
return '输入消息... (Shift+Enter 换行)'
})
const modelSupportsImage = computed(() => { const modelSupportsImage = computed(() => {
if (!activeModel.value) return true if (!activeModel.value) return true
if (isComfyModel.value) return false
return activeModel.value.support_image !== 0 && activeModel.value.support_image !== false return activeModel.value.support_image !== 0 && activeModel.value.support_image !== false
}) })
+50 -2
View File
@@ -1,6 +1,19 @@
<template> <template>
<aside class="sidebar" :class="{ open: chat.sidebarOpen }"> <aside class="sidebar" :class="{ open: chat.sidebarOpen }">
<div class="sidebar-top"> <div class="sidebar-top">
<select
v-if="settings.models.length"
class="model-select"
title="选择模型"
:value="chat.selectedModelId == null ? '' : String(chat.selectedModelId)"
@change="onModelChange"
>
<option
v-for="m in settings.models"
:key="m.id"
:value="String(m.id)"
>{{ m.name }}{{ m.provider === 'comfy' ? ' · 生图' : '' }}</option>
</select>
<button class="new-chat-btn" @click="handleNewChat"> <button class="new-chat-btn" @click="handleNewChat">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/> <path d="M12 5v14M5 12h14"/>
@@ -50,12 +63,14 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, watch } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
const auth = useAuthStore() const auth = useAuthStore()
const chat = useChatStore() const chat = useChatStore()
const settings = useSettingsStore()
const avatarLetter = computed(() => { const avatarLetter = computed(() => {
const name = auth.user?.nickname || auth.user?.username || '?' const name = auth.user?.nickname || auth.user?.username || '?'
@@ -64,8 +79,28 @@ const avatarLetter = computed(() => {
const adminUrl = import.meta.env.VITE_ADMIN_URL || `${window.location.protocol}//${window.location.hostname}:5174` const adminUrl = import.meta.env.VITE_ADMIN_URL || `${window.location.protocol}//${window.location.hostname}:5174`
watch(
() => settings.models,
(list) => {
if (!list?.length) return
if (chat.selectedModelId != null && list.some(m => Number(m.id) === Number(chat.selectedModelId))) return
const def = list.find(m => m.is_default) || list[0]
chat.selectedModelId = def?.id != null ? Number(def.id) : null
},
{ immediate: true }
)
async function onModelChange(e) {
const value = e.target.value
try {
await chat.setSelectedModel(value === '' ? null : Number(value))
} catch (err) {
alert(err.message || '切换模型失败')
}
}
async function handleNewChat() { async function handleNewChat() {
await chat.createConversation() await chat.createConversation(chat.selectedModelId)
chat.closeSidebar() chat.closeSidebar()
} }
@@ -94,6 +129,19 @@ async function handleDelete(id) {
.sidebar-top { .sidebar-top {
padding: 12px; padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.model-select {
width: 100%;
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
color: var(--text-primary);
} }
.new-chat-btn { .new-chat-btn {
+12 -5
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="message-list" ref="listRef"> <div class="message-list" ref="listRef">
<div v-if="!visibleMessages.length && !loading && !sending && !streaming.trim()" class="welcome"> <div v-if="!visibleMessages.length && !loading && !sending && !hasStreaming" class="welcome">
<div class="welcome-icon">💬</div> <div class="welcome-icon">💬</div>
<h2>有什么可以帮您的</h2> <h2>有什么可以帮您的</h2>
<p>输入消息开始对话支持 Markdown图片文件等</p> <p>输入消息开始对话支持 Markdown图片文件等</p>
@@ -15,12 +15,12 @@
/> />
<MessageItem <MessageItem
v-if="streaming.trim()" v-if="hasStreaming"
:message="{ role: 'assistant', content: streaming, content_type: 'markdown' }" :message="{ role: 'assistant', content: streaming, content_type: 'mixed', attachments: streamingAttachments }"
:is-streaming="true" :is-streaming="true"
/> />
<div v-if="sending && !streaming" class="typing-indicator"> <div v-if="sending && !hasStreaming" class="typing-indicator">
<span></span><span></span><span></span> <span></span><span></span><span></span>
</div> </div>
</div> </div>
@@ -34,11 +34,18 @@ const props = defineProps({
messages: { type: Array, default: () => [] }, messages: { type: Array, default: () => [] },
loading: Boolean, loading: Boolean,
streaming: { type: String, default: '' }, streaming: { type: String, default: '' },
streamingAttachments: { type: Array, default: () => [] },
sending: Boolean sending: Boolean
}) })
const listRef = ref(null) const listRef = ref(null)
const hasStreaming = computed(() => {
const hasText = !!(props.streaming && String(props.streaming).trim())
const hasImages = Array.isArray(props.streamingAttachments) && props.streamingAttachments.length > 0
return hasText || hasImages
})
const visibleMessages = computed(() => { const visibleMessages = computed(() => {
return props.messages.filter(msg => { return props.messages.filter(msg => {
const hasContent = !!(msg.content && String(msg.content).trim()) const hasContent = !!(msg.content && String(msg.content).trim())
@@ -49,7 +56,7 @@ const visibleMessages = computed(() => {
}) })
watch( watch(
() => [props.messages.length, props.streaming], () => [props.messages.length, props.streaming, props.streamingAttachments?.length],
async () => { async () => {
await nextTick() await nextTick()
if (listRef.value) { if (listRef.value) {
+155 -8
View File
@@ -40,6 +40,10 @@ function isValidMessage(msg) {
return hasContent || hasAttachments return hasContent || hasAttachments
} }
function hasPendingJobs(list) {
return (list || []).some(m => m && m.pending_job)
}
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () => {
const conversations = ref([]) const conversations = ref([])
const currentId = ref(null) const currentId = ref(null)
@@ -47,7 +51,48 @@ export const useChatStore = defineStore('chat', () => {
const loading = ref(false) const loading = ref(false)
const sending = ref(false) const sending = ref(false)
const streamingContent = ref('') const streamingContent = ref('')
const streamingAttachments = ref([])
const sidebarOpen = ref(false) const sidebarOpen = ref(false)
/** 侧边栏当前选中的模型,发送/新建会话都会使用它 */
const selectedModelId = ref(null)
let pendingPollTimer = null
let pendingPollInFlight = false
function stopPendingJobPolling() {
if (pendingPollTimer) {
clearInterval(pendingPollTimer)
pendingPollTimer = null
}
pendingPollInFlight = false
}
function startPendingJobPolling() {
stopPendingJobPolling()
if (!currentId.value || !hasPendingJobs(messages.value)) return
pendingPollTimer = setInterval(async () => {
if (!currentId.value || pendingPollInFlight) return
if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
return
}
pendingPollInFlight = true
try {
await loadMessages(currentId.value, { quiet: true })
} catch {
// 轮询失败时下次再试
} finally {
pendingPollInFlight = false
}
if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
await fetchConversations().catch(() => {})
}
}, 4000)
}
async function fetchConversations() { async function fetchConversations() {
const res = await api.get('/conversations') const res = await api.get('/conversations')
@@ -55,24 +100,50 @@ export const useChatStore = defineStore('chat', () => {
conversations.value = normalizeList(data?.list ?? data) conversations.value = normalizeList(data?.list ?? data)
} }
async function loadMessages(id = currentId.value) { async function loadMessages(id = currentId.value, options = {}) {
if (!id) return if (!id) return
const quiet = !!options.quiet
const res = await api.get(`/conversations/${id}/messages`) const res = await api.get(`/conversations/${id}/messages`)
// 切换会话后丢弃过期响应
if (id !== currentId.value) return
const list = normalizeList(res.data?.data) const list = normalizeList(res.data?.data)
messages.value = list.filter(isValidMessage) messages.value = list.filter(isValidMessage)
if (!quiet) {
if (hasPendingJobs(messages.value)) {
startPendingJobPolling()
} else {
stopPendingJobPolling()
}
} else if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
} else if (!pendingPollTimer) {
startPendingJobPolling()
}
} }
async function createConversation(modelId = null) { async function createConversation(modelId = null) {
const res = await api.post('/conversations', { model_id: modelId }) const mid = modelId ?? selectedModelId.value
const res = await api.post('/conversations', { model_id: mid })
const conv = res.data.data const conv = res.data.data
conversations.value.unshift(conv) conversations.value.unshift(conv)
currentId.value = conv.id currentId.value = conv.id
if (conv.model_id != null) {
selectedModelId.value = Number(conv.model_id)
}
messages.value = [] messages.value = []
stopPendingJobPolling()
return conv return conv
} }
async function selectConversation(id) { async function selectConversation(id) {
currentId.value = id currentId.value = id
stopPendingJobPolling()
const conv = conversations.value.find(c => c.id === id)
if (conv?.model_id != null) {
selectedModelId.value = Number(conv.model_id)
}
loading.value = true loading.value = true
try { try {
await loadMessages(id) await loadMessages(id)
@@ -81,18 +152,46 @@ export const useChatStore = defineStore('chat', () => {
} }
} }
async function setSelectedModel(modelId) {
const mid = modelId == null || modelId === '' ? null : Number(modelId)
selectedModelId.value = mid
// 已有会话时立即切换绑定模型,否则下拉只改了 UI、实际仍走旧模型
if (!currentId.value || mid == null) return
const conv = conversations.value.find(c => c.id === currentId.value)
if (conv && Number(conv.model_id) === mid) return
const res = await api.put(`/conversations/${currentId.value}`, { model_id: mid })
const updated = res.data?.data
if (updated) {
conversations.value = conversations.value.map(c =>
c.id === currentId.value ? { ...c, ...updated } : c
)
} else if (conv) {
conv.model_id = mid
}
}
async function deleteConversation(id) { async function deleteConversation(id) {
await api.delete(`/conversations/${id}`) await api.delete(`/conversations/${id}`)
conversations.value = conversations.value.filter(c => c.id !== id) conversations.value = conversations.value.filter(c => c.id !== id)
if (currentId.value === id) { if (currentId.value === id) {
currentId.value = null currentId.value = null
messages.value = [] messages.value = []
stopPendingJobPolling()
} }
} }
async function sendMessage(content, attachments = []) { async function sendMessage(content, attachments = []) {
if (!currentId.value) { if (!currentId.value) {
await createConversation() await createConversation(selectedModelId.value)
} else if (selectedModelId.value != null) {
// 发送前确保当前会话已绑定侧边栏所选模型
const conv = conversations.value.find(c => c.id === currentId.value)
if (conv && Number(conv.model_id) !== Number(selectedModelId.value)) {
await setSelectedModel(selectedModelId.value)
}
} }
const userMsg = { const userMsg = {
@@ -105,18 +204,24 @@ export const useChatStore = defineStore('chat', () => {
messages.value.push(userMsg) messages.value.push(userMsg)
sending.value = true sending.value = true
streamingContent.value = '' streamingContent.value = ''
streamingAttachments.value = []
try { try {
await streamChat(content, attachments) await streamChat(content, attachments)
await loadMessages() await loadMessages()
} catch (e) { } catch (e) {
// 发送失败时移除本地临时用户消息,随后从服务端重新同步 // 连接断开/代理超时:任务可能已落库为 pending,先同步再决定是否抛错
messages.value = messages.value.filter(m => m.id !== userMsg.id) messages.value = messages.value.filter(m => m.id !== userMsg.id)
await loadMessages().catch(() => {}) await loadMessages().catch(() => {})
if (hasPendingJobs(messages.value)) {
startPendingJobPolling()
return
}
throw e throw e
} finally { } finally {
sending.value = false sending.value = false
streamingContent.value = '' streamingContent.value = ''
streamingAttachments.value = []
} }
} }
@@ -152,6 +257,8 @@ export const useChatStore = defineStore('chat', () => {
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
let fullContent = '' let fullContent = ''
let gotAttachments = false
let pendingDone = false
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
@@ -170,19 +277,55 @@ export const useChatStore = defineStore('chat', () => {
throw new Error(data?.message || 'AI 请求失败') throw new Error(data?.message || 'AI 请求失败')
} }
if (event === 'progress' && data?.content) {
streamingContent.value = data.content
}
if (event === 'image' && Array.isArray(data?.attachments) && data.attachments.length) {
streamingAttachments.value = data.attachments
gotAttachments = true
}
if (event === 'message' && data?.content) { if (event === 'message' && data?.content) {
fullContent += data.content fullContent += data.content
streamingContent.value = fullContent streamingContent.value = fullContent
} }
if (event === 'done' && data?.content && !fullContent) { if (event === 'done') {
fullContent = data.content if (data?.pending) {
streamingContent.value = fullContent pendingDone = true
if (data?.content) {
streamingContent.value = data.content
}
}
if (data?.content && !fullContent) {
fullContent = data.content
streamingContent.value = fullContent
}
if (Array.isArray(data?.attachments) && data.attachments.length) {
streamingAttachments.value = data.attachments
gotAttachments = true
}
} }
} }
} }
if (!fullContent.trim()) { if (pendingDone) {
await loadMessages().catch(() => {})
startPendingJobPolling()
await fetchConversations().catch(() => {})
return
}
if (!fullContent.trim() && !gotAttachments) {
// 生图可能已落库为 pending:刷新后由轮询恢复,不在此当成失败
await loadMessages().catch(() => {})
if (hasPendingJobs(messages.value) || messages.value.some(m =>
Array.isArray(m.attachments) && m.attachments.some(a => a?.type === 'image')
)) {
startPendingJobPolling()
return
}
throw new Error('AI 未返回内容,请在管理后台测试模型配置是否正确') throw new Error('AI 未返回内容,请在管理后台测试模型配置是否正确')
} }
@@ -204,12 +347,16 @@ export const useChatStore = defineStore('chat', () => {
loading, loading,
sending, sending,
streamingContent, streamingContent,
streamingAttachments,
sidebarOpen, sidebarOpen,
selectedModelId,
fetchConversations, fetchConversations,
createConversation, createConversation,
selectConversation, selectConversation,
setSelectedModel,
deleteConversation, deleteConversation,
sendMessage, sendMessage,
loadMessages,
toggleSidebar, toggleSidebar,
closeSidebar closeSidebar
} }
+1
View File
@@ -31,6 +31,7 @@
:messages="chat.messages" :messages="chat.messages"
:loading="chat.loading" :loading="chat.loading"
:streaming="chat.streamingContent" :streaming="chat.streamingContent"
:streaming-attachments="chat.streamingAttachments"
:sending="chat.sending" :sending="chat.sending"
/> />
View File