diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html
index c1ed534..3b6a9fe 100644
--- a/internal/gateway/ui/index.html
+++ b/internal/gateway/ui/index.html
@@ -157,6 +157,31 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
justify-content:center; overflow:auto; padding:48px 20px; z-index:50; }
.twrap { overflow-x:auto; -webkit-overflow-scrolling:touch; }
+/* ---------- scratch-style priority sorting ---------- */
+.sort-canvas { display:flex; flex-direction:column; gap:26px; }
+.sort-col { display:flex; flex-direction:column; gap:3px; }
+.sort-group { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:16px 18px;
+ box-shadow:var(--shadow); }
+.sort-group > h3 { margin:0 0 12px; font-size:13px; color:var(--muted); font-weight:600;
+ display:flex; align-items:center; gap:8px; }
+.sort-group > h3 .grow { flex:1; }
+.sort-chip { display:flex; align-items:center; gap:12px; padding:12px 14px; border-radius:12px; cursor:grab;
+ color:#fff; font-weight:600; font-size:13.5px; box-shadow:0 3px 0 rgba(0,0,0,.14);
+ position:relative; user-select:none; transition:transform .12s,box-shadow .12s,opacity .15s; touch-action:none; }
+.sort-chip::before { content:''; position:absolute; left:0; top:0; bottom:0; width:10px; border-radius:9px 0 0 9px;
+ background:rgba(0,0,0,.22); }
+.sort-chip.dragging { opacity:.55; transform:scale(1.02); }
+.sort-chip .grip { font-size:15px; opacity:.75; }
+.sort-chip .mid { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.sort-chip .src { font-size:11px; opacity:.85; font-weight:500; padding:2px 9px; border-radius:20px;
+ background:rgba(255,255,255,.18); }
+.sort-chip .prio { font-size:11px; opacity:.95; font-weight:500; background:rgba(0,0,0,.18);
+ padding:2px 9px; border-radius:20px; white-space:nowrap; }
+.sort-dropline { height:8px; border-radius:6px; margin:0; background:var(--accent); opacity:.8; }
+.chip-shell { display:flex; flex-direction:column; }
+.sortbar { display:flex; align-items:center; gap:12px; margin-bottom:18px; flex-wrap:wrap; }
+.sortbar .grow { flex:1; }
+
/* ---------- responsive / narrow screens ---------- */
@media (max-width: 900px) {
header { padding:12px 16px; }
@@ -217,12 +242,14 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
+
@@ -232,7 +259,7 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
const STR = {
zh: {
tagline:'统一 LLM 网关', logout:'退出登录', langTo:'EN',
- navStatus:'状态', navChat:'Chat 测试', navSources:'源', navAdapters:'适配器',
+ navStatus:'状态', navChat:'Chat 测试', navSources:'源', navAdapters:'适配器', navSort:'优先级',
connTitle:'连接配置(Agent / OpenAI SDK)', connHint:'模型名默认 AUTO,按优先级自动选择可用源;点击任一模型可生成固定到该模型的配置。',
copyCfg:'一键复制配置', copyEnv:'复制为环境变量',
srcTitle:'源状态', srcCount:'共 %d 个',
@@ -258,10 +285,13 @@ const STR = {
toastDelOk:'已删除', confirmDelSrc:'确定删除源 %s 吗?', confirmDelAdp:'确定删除适配器 %s 吗?',
u:'用户', a:'助手', at:'助手[思考]', aEmpty:'(空回复)', aErr:'请求失败: %s', cErr:'错误: %s',
chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s (源 %s)', connAuto:'# 模型名 AUTO(自动选择可用源)',
+ sortTitle:'拖拽排序:调整源下各模型的优先级', sortHint:'从高(上)到低(下)拖动各源下的模型方块,松手后自动重算 priority。数值越大 AUTO 越优先使用。',
+ sortSave:'保存排序', sortReset:'重置', sortSaved:'排序已保存并热重载',
+ sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)',
},
en: {
tagline:'Unified LLM Gateway', logout:'Log out', langTo:'中',
- navStatus:'Status', navChat:'Chat', navSources:'Sources', navAdapters:'Adapters',
+ navStatus:'Status', navChat:'Chat', navSources:'Sources', navAdapters:'Adapters', navSort:'Priority',
connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — picks the best healthy source by priority. Click a model to pin it.',
copyCfg:'Copy config', copyEnv:'Copy as env vars',
srcTitle:'Sources', srcCount:'%d total',
@@ -287,6 +317,9 @@ const STR = {
toastDelOk:'Deleted', confirmDelSrc:'Delete source %s?', confirmDelAdp:'Delete adapter %s?',
u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'(empty)', aErr:'Request failed: %s',
cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s (source %s)', connAuto:'# Model "AUTO" picks healthy source by priority',
+ sortTitle:'Drag to reorder: priority of each source’s models', sortHint:'Drag model blocks from high (top) to low (bottom); priority is recomputed on drop. Larger values are preferred by AUTO.',
+ sortSave:'Save order', sortReset:'Reset', sortSaved:'Order saved & hot-reloaded',
+ sortSource:'source', sortPrio:'priority %s', sortEmpty:'(no models in this source)',
}
};
let LANG = localStorage.getItem('llms-proxy.lang') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
@@ -322,7 +355,7 @@ document.getElementById('btn-logout').onclick = () => { location.href = '/api/lo
document.querySelectorAll('nav button').forEach(b => {
b.onclick = () => {
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('active', x === b));
- ['status','chat','sources','adapters'].forEach(tn => $('#tab-' + tn).classList.toggle('hidden', tn !== b.dataset.tab));
+ ['status','chat','sort','sources','adapters'].forEach(tn => $('#tab-' + tn).classList.toggle('hidden', tn !== b.dataset.tab));
lastTab = b.dataset.tab; refresh(lastTab);
};
});
@@ -722,6 +755,180 @@ async function delSource(name) {
toast(t('toastDelOk')); renderSources();
}
+/* ---------- sort tab (scratch-style drag priority) ---------- */
+const SORT_COLORS = [
+ ['#5b8def', '#3f6ef5'], ['#37b273', '#1f9d5a'], ['#f2a541', '#e08a15'],
+ ['#a466e8', '#8b4dd4'], ['#e86f9c', '#d74f86'], ['#45c4c0', '#2aa8a4'],
+ ['#e8564a', '#d03f34'], ['#8ba3c7', '#6f8ab3'],
+];
+const sortState = { cols: [], origin: null, drag: null };
+async function renderSort() {
+ const j = await api('/api/sources');
+ sortState.cols = j.sources.map((s, i) => ({
+ name: s.name, colors: SORT_COLORS[i % SORT_COLORS.length],
+ models: s.models.map(m => ({ id: m.id, priority: m.priority || 0 })),
+ }));
+ sortState.origin = JSON.stringify(sortState.cols);
+ paintSort();
+}
+function paintSort() {
+ const groups = sortState.cols.map((c, ci) => `
+
+
+ ${esc(c.name)}${t('sortSource')}
+
+
+ ${c.models.length ? c.models.map((m, mi) => sortShell(ci, mi, m)).join('')
+ : `
${t('sortEmpty')}
`}
+
+
`).join('');
+ $('#tab-sort').innerHTML = `
+
+
${t('sortTitle')}
+
+
+
${t('sortHint')}
+
${groups}
+
`;
+ bindSortDrag();
+}
+function sortShell(ci, mi, m) {
+ const c = sortState.cols[ci];
+ return `
+ ${mi === 0 ? '' : '
'}
+
+ ⋮⋮
+ ${esc(m.id)}
+ ${esc(c.name)}
+ ${tFmt('sortPrio', m.priority)}
+
+
`;
+}
+function bindSortDrag() {
+ document.querySelectorAll('.sort-chip').forEach(chip => {
+ chip.addEventListener('dragstart', e => {
+ sortState.drag = { col: +chip.dataset.col, id: chip.dataset.id };
+ chip.classList.add('dragging');
+ if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); }
+ });
+ chip.addEventListener('dragend', () => { sortDragCleanup(); });
+ chip.addEventListener('touchstart', e => {
+ sortState.drag = { col: +chip.dataset.col, id: chip.dataset.id };
+ chip.classList.add('dragging');
+ }, { passive: true });
+ chip.addEventListener('touchmove', e => {
+ if (!sortState.drag) return;
+ e.preventDefault();
+ const t = e.touches[0];
+ const el = document.elementFromPoint(t.clientX, t.clientY);
+ if (el && el.classList && el.classList.contains('sort-chip')) {
+ showDropLine(+el.dataset.col, el.dataset.id, t.clientY, el);
+ }
+ }, { passive: false });
+ chip.addEventListener('touchend', () => {
+ const d = sortState.drag;
+ if (d) {
+ const tgt = document.querySelector('#tab-sort .sort-dropline:not(.hidden)');
+ if (tgt) {
+ const tcol = +tgt.closest('[data-col]').dataset.col;
+ const before = tgt.dataset.before === '1';
+ sortDrop(tcol, before ? tgt.dataset.nextId : tgt.dataset.id, before);
+ }
+ }
+ sortDragCleanup();
+ }, { passive: true });
+ });
+ document.querySelectorAll('.sort-col').forEach(col => {
+ col.addEventListener('dragover', e => {
+ e.preventDefault();
+ const t = e.clientY;
+ const chips = [...col.querySelectorAll('.sort-chip')];
+ const under = chips.filter(c => { const r = c.getBoundingClientRect(); return t < r.bottom; })
+ .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top)[0];
+ if (under) showDropLine(+col.dataset.col, under.dataset.id, t, under);
+ });
+ col.addEventListener('drop', e => {
+ e.preventDefault();
+ const dl = col.querySelector('.sort-dropline:not(.hidden)');
+ if (dl) sortDrop(+col.dataset.col, dl.dataset.before === '1' ? dl.dataset.nextId : dl.dataset.id, dl.dataset.before === '1');
+ else sortDrop(+col.dataset.col, null, true);
+ sortDragCleanup();
+ });
+ });
+}
+function showDropLine(ci, id, y, refChip) {
+ const shells = [...document.querySelectorAll(`#tab-sort .chip-shell[data-col="${ci}"]`)];
+ const r = refChip.getBoundingClientRect();
+ const before = y < r.top + r.height / 2;
+ const idx = shells.findIndex(s => s.dataset.id === id);
+ const target = shells[idx + (before ? 0 : 1)];
+ document.querySelectorAll('#tab-sort .sort-dropline:not(.hidden)').forEach(l => l.classList.add('hidden'));
+ if (!target) return;
+ const line = target.querySelector('.sort-dropline');
+ if (!line) return;
+ line.dataset.before = before ? '1' : '0';
+ line.dataset.nextId = target.dataset.id;
+ line.classList.remove('hidden');
+}
+function sortDrop(tci, tid, before) {
+ if (!sortState.drag) return;
+ const dci = sortState.drag.col, did = sortState.drag.id;
+ const dCol = sortState.cols[dci], tCol = sortState.cols[tci];
+ const dIdx = dCol.models.findIndex(m => m.id === did);
+ if (dIdx < 0) return;
+ const [mdl] = dCol.models.splice(dIdx, 1);
+ let insertAt = tCol.models.length;
+ if (tid !== null) {
+ const tIdx = tCol.models.findIndex(m => m.id === tid);
+ if (tIdx >= 0) {
+ // 同列且目标原本在被拖着下方时,移除后目标会前移一位
+ let pos = tIdx;
+ if (tci === dci && dIdx < tIdx) pos -= 1;
+ insertAt = before ? pos : pos + 1;
+ }
+ }
+ insertAt = Math.max(0, Math.min(insertAt, tCol.models.length));
+ tCol.models.splice(insertAt, 0, mdl);
+ recomputePriorities();
+ paintSort();
+}
+function recomputePriorities() {
+ let rank = 0;
+ sortState.cols.forEach(c => c.models.forEach(m => { m.priority = Math.max(100 - rank * 5, 5); rank++; }));
+}
+function sortDragCleanup() {
+ sortState.drag = null;
+ document.querySelectorAll('#tab-sort .sort-dropline').forEach(l => l.classList.add('hidden'));
+ document.querySelectorAll('#tab-sort .sort-chip.dragging').forEach(c => c.classList.remove('dragging'));
+}
+function sortReset() {
+ if (sortState.origin) sortState.cols = JSON.parse(sortState.origin);
+ sortDragCleanup();
+ paintSort();
+}
+async function saveSort() {
+ const snap = await api('/api/sources');
+ const byName = {}; snap.sources.forEach(s => byName[s.name] = s);
+ let dirty = 0;
+ for (const c of sortState.cols) {
+ const base = byName[c.name];
+ if (!base) continue;
+ const next = c.models.map(m => {
+ const old = (base.models || []).find(x => x.id === m.id) || { kind: 'chat' };
+ return { id: m.id, priority: m.priority, kind: old.kind || 'chat' };
+ });
+ if (JSON.stringify(next) !== JSON.stringify(base.models)) {
+ base.models = next;
+ await api('/api/sources', { method: 'POST', body: JSON.stringify(base) });
+ dirty++;
+ }
+ }
+ toast(t('sortSaved'));
+ sortState.origin = JSON.stringify(sortState.cols);
+ renderSort();
+}
+
/* ---------- adapters tab ---------- */
async function renderAdapters() {
const j = await api('/api/status');
@@ -783,6 +990,7 @@ async function delAdapter(name) {
function refresh(tab) {
if (tab === 'status') return renderStatus();
if (tab === 'chat') return renderChat();
+ if (tab === 'sort') return renderSort();
if (tab === 'sources') return renderSources();
return renderAdapters();
}