v2.1.1: Auto配置改为模型粒度拖动排序 + 历史缓存复用 + 对话生图

- Auto配置编辑: 粒度从提供商改为具体模型, 原生HTML5拖动排序, 修复已保存模型不显示勾选(字符串/对象混拼bug)
- 历史缓存: 带历史上下文的auto请求按首条消息识别会话, 优先复用上次模型命中上游前缀缓存
  (可配置: prefer_cache_model + cache_ttl_seconds, 系统配置页开关 + /api/admin/routing)
- 对话生图: chat页选 auto-image/Qwen-Image 直接调生图接口并在对话内展示图片
- 修复: Flask模板缓存导致改模板不生效 -> TEMPLATES_AUTO_RELOAD
- 修复: 自动回退/显式有序模型列表路由, 空列表回退提供商能力选择
This commit is contained in:
2026-08-28 12:16:25 +08:00
parent 178968ba1a
commit d387d5ba09
7 changed files with 538 additions and 143 deletions
+146 -108
View File
@@ -8,7 +8,9 @@
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style>
.gradient-bg { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); }
.dragging { opacity: 0.5; transform: scale(1.02); }
.dragging { opacity: 0.5; transform: scale(1.02); border: 2px dashed #6366f1; }
.drag-over { border: 2px dashed #6366f1; }
.model-row { transition: all 0.15s; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
@@ -51,7 +53,7 @@
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">Auto配置管理</h1>
<p class="text-gray-500 text-sm mt-1">每个Auto配置固定一个功能类型,并从模型管理中挑选具备该能力的模型</p>
<p class="text-gray-500 text-sm mt-1">每个Auto配置固定一个功能类型,按「具体模型」的顺序自动选择(可拖动排序)</p>
</div>
<button onclick="showCreateModal()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90 transition">
<i class="ri-add-line mr-1"></i> 创建Auto配置
@@ -61,7 +63,7 @@
<!-- 说明卡片 -->
<div class="mb-6 p-4 bg-indigo-50 rounded-lg text-sm text-indigo-600">
<i class="ri-information-line mr-1"></i>
<strong>Auto配置:</strong>例如 <code>model="auto-vision"</code> 只选择具备<strong>视觉能力</strong>的模型,<code>model="auto-image"</code> 只选择<strong>图片生成</strong>模型。调用时使用 <code>model="配置名称"</code>
<strong>Auto配置:</strong>例如 <code>model="auto-vision"</code> 只选择具备<strong>视觉能力</strong>的模型,<code>model="auto-image"</code> 只选择<strong>图片生成</strong>模型。每个 Auto 配置内部是一个<strong>有序的具体模型列表</strong>,请求时按顺序选择第一个可用的模型;带历史上下文的请求会优先复用上次使用的模型(缓存命中)。可在「系统配置」页开关
</div>
<!-- 配置列表 -->
@@ -82,7 +84,7 @@
</div>
<div class="p-6 overflow-y-auto flex-1">
<form id="profileForm">
<form id="profileForm" onsubmit="return false;">
<input type="hidden" id="profileId">
<div class="grid grid-cols-2 gap-4 mb-4">
@@ -104,7 +106,6 @@
<select id="profileCapability" onchange="onCapabilityChange()" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<option value="">请选择功能类型...</option>
</select>
<p class="text-xs text-gray-500 mt-1">文本推理 / 视觉能力 / 语音输出 / 语音输入 / 图片生成 / 视频生成</p>
</div>
<div class="mb-4">
@@ -116,24 +117,20 @@
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">选择策略</label>
<select id="profileStrategy" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<option value="priority">优先级选择(推荐)</option>
<option value="priority">顺序选择(推荐)</option>
<option value="random">随机选择</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">候选提供商(仅列出具备所选能力模型的提供商,可拖拽调整优先级)</label>
<div id="providerList" class="border border-gray-200 rounded-lg p-3 space-y-2 min-h-[100px] bg-gray-50">
<label class="block text-sm font-medium text-gray-700 mb-2">
候选模型(勾选参与 · 拖动调整优先级顺序):
<span class="text-red-500 text-xs" id="modelListHint"></span>
</label>
<div id="modelList" class="border border-gray-200 rounded-lg p-3 space-y-2 min-h-[100px] bg-gray-50">
<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>
</div>
<p class="text-xs text-gray-500 mt-1">勾选参与自动选择的提供商,拖拽调整优先级</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">将匹配到以下模型(该能力下各提供商将选择的模型)</label>
<div id="matchedModels" class="flex flex-wrap gap-1">
<span class="text-gray-400 text-xs">请先选择功能类型</span>
</div>
<p class="text-xs text-gray-500 mt-1">⬍ 拖动左侧手柄调整顺序,勾选后保存。顺序即优先级,请求时按序选择第一个可用模型</p>
</div>
</form>
</div>
@@ -147,21 +144,21 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
let providers = [];
let allModels = [];
let profiles = [];
let capabilityDefs = {};
let draggedRow = null;
// 加载数据
async function loadData() {
const [providersRes, profilesRes, capsRes] = await Promise.all([
fetch('/api/admin/providers'),
const [modelsRes, profilesRes, capsRes] = await Promise.all([
fetch('/api/admin/models'),
fetch('/api/admin/auto-profiles'),
fetch('/api/admin/capabilities')
]);
providers = await providersRes.json();
allModels = await modelsRes.json();
profiles = await profilesRes.json();
capabilityDefs = await capsRes.json();
@@ -175,7 +172,13 @@
Object.entries(capabilityDefs).map(([key, label]) => `<option value="${key}">${label}</option>`).join('');
}
// 渲染配置列表
// 某能力的可用模型
function modelsForCapability(cap) {
return allModels.filter(m => (m.capabilities || []).includes(cap))
.sort((a, b) => (a.provider_priority || 99) - (b.provider_priority || 99));
}
// 渲染配置卡片
function renderProfiles() {
const container = document.getElementById('profilesList');
@@ -200,11 +203,11 @@
${profile.name === 'auto' ? '<span class="ml-2 px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">默认</span>' : ''}
</div>
<div class="flex gap-1">
<button onclick="editProfile('${profile.name}')" class="p-2 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition">
<button onclick="editProfile('${profile.name}')" class="p-2 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition" title="编辑">
<i class="ri-pencil-line"></i>
</button>
${profile.name !== 'auto' ? `
<button onclick="deleteProfile('${profile.name}')" class="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition">
<button onclick="deleteProfile('${profile.name}')" class="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition" title="删除">
<i class="ri-delete-bin-line"></i>
</button>
` : ''}
@@ -218,21 +221,20 @@
<i class="ri-focus-3-line mr-1"></i>${profile.capability_label || profile.capability || 'text'}
</span>
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
${profile.strategy === 'priority' ? '按优先级' : '随机'}
</span>
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
${profile.provider_details?.length || 0} 个提供商
${profile.strategy === 'priority' ? '按顺序' : '随机'}
</span>
<span class="px-2 py-1 bg-green-50 text-green-600 text-xs rounded">
${profile.matched_models?.length || 0}匹配模型
${(profile.models || []).length || 0} 个模型
</span>
</div>
<div class="mt-3 flex flex-wrap gap-1">
${(profile.matched_models || []).slice(0, 5).map(m =>
`<span class="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">${m}</span>`
).join('') || ''}
${(profile.matched_models?.length || 0) > 5 ?
`<span class="text-xs text-gray-400">+${profile.matched_models.length - 5}</span>` : ''}
<div class="mt-3 space-y-1">
${(profile.models || []).slice(0, 5).map((m, i) => `
<div class="flex items-center gap-2 text-xs">
<span class="w-4 h-4 bg-indigo-100 text-indigo-600 rounded flex items-center justify-center font-bold">${i + 1}</span>
<code class="text-slate-600 truncate">${m}</code>
</div>
`).join('') || '<span class="text-xs text-gray-400">未配置模型</span>'}
${(profile.models || []).length > 5 ? `<div class="text-xs text-gray-400">+${profile.models.length - 5} 个</div>` : ''}
</div>
</div>
</div>
@@ -246,9 +248,8 @@
document.getElementById('profileForm').reset();
document.getElementById('profileName').disabled = false;
document.getElementById('profileCapability').value = '';
document.getElementById('providerList').innerHTML = '<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>';
document.getElementById('matchedModels').innerHTML = '<span class="text-gray-400 text-xs">请先选择功能类型</span>';
document.getElementById('modelList').innerHTML = '<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>';
document.getElementById('modelListHint').textContent = '';
document.getElementById('editModal').classList.remove('hidden');
document.getElementById('editModal').classList.add('flex');
@@ -268,8 +269,7 @@
document.getElementById('profileStrategy').value = profile.strategy;
document.getElementById('profileCapability').value = profile.capability || 'text';
renderProviderList(profile.provider_details || [], profile.capability || 'text');
renderMatchedModels(profile.matched_models || []);
renderModelList(profile.capability || 'text', profile.models || []);
document.getElementById('editModal').classList.remove('hidden');
document.getElementById('editModal').classList.add('flex');
@@ -281,91 +281,122 @@
document.getElementById('editModal').classList.remove('flex');
}
// 能力切换时重新渲染提供商列表
// 能力切换时重新渲染模型列表
function onCapabilityChange() {
const capability = document.getElementById('profileCapability').value;
if (!capability) {
document.getElementById('providerList').innerHTML = '<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>';
document.getElementById('matchedModels').innerHTML = '<span class="text-gray-400 text-xs">请先选择功能类型</span>';
document.getElementById('modelList').innerHTML = '<p class="text-gray-400 text-sm text-center py-4">请先选择功能类型</p>';
document.getElementById('modelListHint').textContent = '';
return;
}
renderProviderList([], capability);
renderMatchedModels([]);
renderModelList(capability, []);
}
// 提供商是否具备该能力
function providerHasCapability(provider, capability) {
if (!provider.models) return false;
return provider.models.some(m => (m.capabilities || []).includes(capability));
}
// 渲染可拖拽模型列表
function renderModelList(capability, savedOrder) {
const container = document.getElementById('modelList');
const avail = modelsForCapability(capability);
function providerMatchedModels(provider, capability) {
return (provider.models || [])
.filter(m => (m.capabilities || []).includes(capability))
.map(m => m.name);
}
// 已保存的排前面(注意:保存的是模型名字符串,需映射回对象)
const saved = (savedOrder || []).map(n => avail.find(m => m.name === n)).filter(Boolean);
const savedNames = saved.map(o => o.name);
const rest = avail.filter(m => !savedNames.includes(m.name));
const ordered = saved.concat(rest);
// 渲染提供商列表(只展示具备所选能力的提供商)
function renderProviderList(selectedProviders, capability) {
const container = document.getElementById('providerList');
const selectedIds = selectedProviders.map(p => p.id);
const sortedProviders = [...providers].sort((a, b) => a.priority - b.priority);
const capable = sortedProviders.filter(p => providerHasCapability(p, capability));
if (capable.length === 0) {
if (ordered.length === 0) {
container.innerHTML = `
<div class="text-center py-4">
<p class="text-gray-500 text-sm">暂无提供商具备该能力</p>
<p class="text-gray-500 text-sm">暂无具备该能力的模型</p>
<p class="text-xs text-gray-400 mt-1">请先在 <a href="/admin/providers" class="text-indigo-500 underline">提供商管理</a> 中配置,或在 <a href="/admin/models" class="text-indigo-500 underline">模型管理</a> 中为模型添加能力标签</p>
</div>
`;
document.getElementById('modelListHint').textContent = '';
return;
}
container.innerHTML = capable.map(p => {
const isSelected = selectedIds.includes(p.id) || (selectedIds.includes('*') && p.id !== '*');
const matched = providerMatchedModels(p, capability);
const allSelected = selectedIds.includes('*');
return `
<div class="flex items-center gap-3 p-3 bg-white rounded-lg border ${isSelected || allSelected ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200'} hover:border-indigo-300 transition cursor-move"
data-id="${p.id}" data-priority="${p.priority}">
<i class="ri-drag-move-2 text-gray-400 cursor-grab"></i>
<input type="checkbox" class="provider-check w-4 h-4 text-indigo-600 rounded"
id="provider-${p.id}" ${isSelected || allSelected ? 'checked' : ''}
onchange="this.closest('[data-id]').classList.toggle('border-indigo-500', this.checked);this.closest('[data-id]').classList.toggle('bg-indigo-50', this.checked)">
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-800 truncate">${p.name}</div>
<div class="text-xs text-gray-500">
优先级 ${p.priority} · 匹配模型: ${matched.join(', ') || '无'}
</div>
</div>
${p.available ?
'<span class="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded">可用</span>' :
'<span class="px-2 py-0.5 bg-red-100 text-red-700 text-xs rounded">不可用</span>'}
</div>
`;
}).join('');
document.getElementById('modelListHint').textContent = `${ordered.length} 个可挑选模型,已选 ${saved.length}`;
// 初始化拖拽排序
new Sortable(container, {
animation: 150,
handle: '.ri-drag-move-2',
ghostClass: 'dragging',
});
container.innerHTML = ordered.map(m => `
<div class="model-row flex items-center gap-3 p-3 bg-white rounded-lg border border-gray-200 ${savedNames.includes(m.name) ? 'border-indigo-400 bg-indigo-50/50' : ''}"
data-model="${m.name}"
draggable="true"
ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"
ondragover="handleDragOver(event)" ondrop="handleDrop(event)">
<i class="ri-drag-move-2 text-gray-400 cursor-grab" title="拖动排序"></i>
<span class="row-num w-5 h-5 bg-indigo-100 text-indigo-600 rounded text-xs flex items-center justify-center font-bold"></span>
<input type="checkbox" class="model-check w-4 h-4 text-indigo-600 rounded" ${savedNames.includes(m.name) ? 'checked' : ''}
onchange="onCheckChange(this)">
<div class="flex-1 min-w-0">
<code class="text-sm text-gray-800 font-medium truncate block">${m.name}</code>
<div class="text-xs text-gray-500">
${m.provider_name} · 优先级 ${m.provider_priority}
${m.is_default ? '<span class="ml-1 px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px]">默认</span>' : ''}
</div>
</div>
<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-xs">${capabilityDefs[capability] || capability}</span>
</div>
`).join('');
renumber();
}
// 渲染匹配模型预览
function renderMatchedModels(models) {
const container = document.getElementById('matchedModels');
if (!models || models.length === 0) {
container.innerHTML = '<span class="text-gray-400 text-xs">当前选择下暂无匹配模型</span>';
return;
// 重新编号 + 更新已选计数
function renumber() {
document.querySelectorAll('#modelList .model-row').forEach((row, i) => {
row.querySelector('.row-num').textContent = i + 1;
});
const total = document.querySelectorAll('#modelList .model-row').length;
const checked = document.querySelectorAll('#modelList .model-check:checked').length;
document.getElementById('modelListHint').textContent = `${total} 个可挑选模型,已选 ${checked}`;
}
function onCheckChange(cb) {
const row = cb.closest('.model-row');
if (cb.checked) {
row.classList.add('border-indigo-400', 'bg-indigo-50/50');
} else {
row.classList.remove('border-indigo-400', 'bg-indigo-50/50');
}
container.innerHTML = models.map(m =>
`<span class="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">${m}</span>`
).join('');
renumber();
}
// 原生拖拽排序
function handleDragStart(e) {
draggedRow = e.target.closest('.model-row');
draggedRow.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
}
function handleDragEnd(e) {
const row = e.target.closest('.model-row');
if (row) row.classList.remove('dragging');
document.querySelectorAll('.model-row').forEach(r => r.classList.remove('drag-over'));
draggedRow = null;
}
function handleDragOver(e) {
e.preventDefault();
const row = e.target.closest('.model-row');
if (row && row !== draggedRow) row.classList.add('drag-over');
}
function handleDrop(e) {
e.preventDefault();
const targetRow = e.target.closest('.model-row');
if (!targetRow || targetRow === draggedRow) return;
targetRow.classList.remove('drag-over');
const container = document.getElementById('modelList');
const rows = [...container.querySelectorAll('.model-row')];
const draggedIndex = rows.indexOf(draggedRow);
const targetIndex = rows.indexOf(targetRow);
if (draggedIndex < targetIndex) {
targetRow.after(draggedRow);
} else {
targetRow.before(draggedRow);
}
renumber();
}
// 保存配置
@@ -385,18 +416,25 @@
return;
}
const selectedProviders = [];
document.querySelectorAll('.provider-check:checked').forEach(c => {
selectedProviders.push(c.id.replace('provider-', ''));
// 收集勾选的模型(按当前DOM顺序 = 优先级顺序)
const models = [];
document.querySelectorAll('#modelList .model-row').forEach(row => {
if (row.querySelector('.model-check').checked) models.push(row.dataset.model);
});
if (models.length === 0) {
alert('请至少勾选一个模型');
return;
}
const data = {
name: profileName,
display_name: displayName || profileName,
description: description,
strategy: strategy,
capability: capability,
providers: selectedProviders.length > 0 ? selectedProviders : ['*']
models: models,
providers: ['*']
};
const profileId = document.getElementById('profileId').value;
+2 -1
View File
@@ -249,6 +249,7 @@
<div class="flex justify-start">
<div class="message-assistant px-4 py-3 rounded-2xl rounded-bl-md max-w-[80%]">
<div class="whitespace-pre-wrap text-gray-800">${escapeHtml(msg.content)}</div>
${msg.image_url ? `<img src="${msg.image_url}" class="mt-2 rounded-lg max-w-full border border-gray-200" />` : ''}
${msg.model ? `<div class="text-xs text-gray-400 mt-2">模型: ${msg.model}</div>` : ''}
</div>
</div>
@@ -311,7 +312,7 @@
currentChatId = data.chat_id;
// 添加助手回复
const assistantMsg = { role: 'assistant', content: data.response, model: data.model };
const assistantMsg = { role: 'assistant', content: data.response, model: data.model, image_url: data.image_url };
container.innerHTML += renderMessage(assistantMsg);
// 更新标题
+65
View File
@@ -84,6 +84,32 @@
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">路由缓存(历史上下文优先复用上次模型)</h2>
<div class="space-y-4">
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p class="font-medium text-gray-800">优先复用上次模型</p>
<p class="text-sm text-gray-500 mt-1">带历史上下文的 auto 请求,优先使用上一次成功响应的模型,命中上游前缀缓存、节省成本</p>
</div>
<button id="cacheToggle" onclick="toggleCacheModel()" class="relative w-12 h-6 rounded-full transition ${data.routing_config.prefer_cache_model ? 'bg-green-500' : 'bg-gray-300'}">
<span id="cacheKnob" class="absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition ${data.routing_config.prefer_cache_model ? 'left-6' : 'left-0.5'}"></span>
</button>
</div>
<div class="flex items-end gap-3 p-4 bg-gray-50 rounded-lg">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">记忆有效期(秒)</label>
<input type="number" id="cacheTtl" value="${data.routing_config.cache_ttl_seconds}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<p class="text-xs text-gray-500 mt-1">超过该时长后不再强制复用,重新按顺序选择</p>
</div>
<button onclick="saveRouting()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">
<i class="ri-save-line mr-1"></i> 保存
</button>
</div>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">提供商配置</h2>
<div class="space-y-4">
@@ -127,6 +153,45 @@
`;
}
async function toggleCacheModel() {
const data = await (await fetch('/api/admin/routing')).json();
const newVal = !data.prefer_cache_model;
const res = await fetch('/api/admin/routing', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefer_cache_model: newVal })
});
const result = await res.json();
if (result.success) {
const btn = document.getElementById('cacheToggle');
const knob = document.getElementById('cacheKnob');
if (newVal) {
btn.classList.remove('bg-gray-300'); btn.classList.add('bg-green-500');
knob.classList.remove('left-0.5'); knob.classList.add('left-6');
} else {
btn.classList.remove('bg-green-500'); btn.classList.add('bg-gray-300');
knob.classList.remove('left-6'); knob.classList.add('left-0.5');
}
} else {
alert('保存失败: ' + (result.error || ''));
}
}
async function saveRouting() {
const ttl = parseInt(document.getElementById('cacheTtl').value) || 3600;
const res = await fetch('/api/admin/routing', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cache_ttl_seconds: ttl })
});
const result = await res.json();
if (result.success) {
alert('✅ 已保存');
} else {
alert('保存失败: ' + (result.error || ''));
}
}
loadConfig();
</script>
</body>