fix(ui): chat model dropdown shows source (src:model pin), tokens card stacked bar + swatch legend, tokens in/out on two lines

- chat tab model select: list each model grouped by source as 'source · model' with value 'source:model' (the unambiguous pin syntax — all 18 real models contain '-' but none contain ':'), so same-named models on different sources are distinguishable in tests; falls back to flat model list for non-admin (sources not exposed)
- tokens card: remove on-chart text, add color-swatch legend (#tb-tokens-legend) mapping bar color -> model; in/out tokens on separate lines (.k-sub.k2)
- deployed (bak .bak.20260811r), server active
This commit is contained in:
root
2026-08-11 17:34:19 +08:00
parent eee0122092
commit 5ca3b7f680

View File

@ -108,6 +108,7 @@ pre.configbox { background:var(--card2); border:1px solid var(--line); border-ra
.kpi .k-val { font-size:26px; font-weight:800; margin-top:4px; font-variant-numeric:tabular-nums; letter-spacing:.3px; }
.kpi .k-val .u { font-size:13px; font-weight:600; color:var(--muted); margin-left:3px; }
.kpi .k-sub { font-size:11.5px; color:var(--muted); margin-top:4px; }
.kpi .k-sub.k2 { display:flex; flex-direction:column; align-items:flex-start; line-height:1.5; }
.kpi .k-chart { margin-top:8px; position:relative; height:86px; }
.kpi canvas { width:100%; height:86px; display:block; }
.kpi.kpi-status .k-chart-pie { height:110px; }
@ -781,8 +782,9 @@ async function paintStats() {
<div class="kpi">
<div class="k-hdr"><span class="k-lab">${t('kpiTokens')}</span></div>
<div class="k-val">${fmtTok(tot.tokens)}</div>
<div class="k-sub">${t('thPrompt')} ${fmtTok(tot.prompt_tokens)} · ${t('thCompl')} ${fmtTok(tot.completion_tokens)}</div>
<div class="k-sub k2"><span>${t('thPrompt')} ${fmtTok(tot.prompt_tokens)}</span><span>${t('thCompl')} ${fmtTok(tot.completion_tokens)}</span></div>
<div class="k-chart"><canvas id="cv-tokens"></canvas></div>
<div class="status-legend" id="tb-tokens-legend"></div>
</div>
<div class="kpi">
<div class="k-hdr"><span class="k-lab">${t('kpiLat')}</span></div>
@ -919,6 +921,7 @@ function drawReqsChart(st) {
const counts = Array.from(byDay.values()).slice(-7);
dailyBars(ctx, counts, rect.width, 86, '#3fb27f');
}
const tokPalette = ['#3fb27f','#4fa3d8','#f0a45c','#b06ce0'];
function drawTokensChart(models) {
const cv = document.getElementById('cv-tokens'); if (!cv) return;
const ctx = cv.getContext('2d');
@ -926,22 +929,20 @@ function drawTokensChart(models) {
const parent = cv.parentElement, rect = parent.getBoundingClientRect();
cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cv.width, cv.height);
// top-4 models by tokens as a stacked bar (share ~= pie) + legend line
const rows = (models||[]).slice().sort((a,b)=>b.tokens-a.tokens).slice(0,4);
const tot = rows.reduce((a,x)=>a+x.tokens,0) || 1;
const palette = ['#3fb27f','#4fa3d8','#f0a45c','#b06ce0'];
let x = 0;
ctx.fillRect;
rows.forEach((m,i) => {
const w = Math.max(2, m.tokens / tot * rect.width);
ctx.fillStyle = palette[i%palette.length];
ctx.fillStyle = tokPalette[i%tokPalette.length];
ctx.fillRect(x, 0, w, 86);
x += w;
});
ctx.fillStyle = 'rgba(226,232,240,.85)';
ctx.font = '10px ui-monospace,Menlo,Consolas,monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
ctx.fillText((rows[0]||{}).name ? '@' + rows[0].name + ' ' + fmtTok(rows[0].tokens) : '', 3, 54);
// color-swatch legend instead of on-chart text
const lg = document.getElementById('tb-tokens-legend');
if (lg) lg.innerHTML = rows.map((m,i) =>
`<span class="lg" title="${esc(m.name)}: ${fmtTok(m.tokens)} (${(m.tokens/tot*100).toFixed(1)}%)">
<i style="background:${tokPalette[i%tokPalette.length]}"></i>${esc(m.name)}<b>${fmtTok(m.tokens)}</b></span>`).join('');
}
function drawLatencyChart(st) {
const cv = document.getElementById('cv-lat'); if (!cv) return;
@ -1025,7 +1026,18 @@ let chatHistory = [];
let chatImages = [];
async function renderChat() {
const s = await api('/api/status');
const opts = ['AUTO', ...(s.models || [])].map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).join('');
// Prefer per-source options ("source:model" routing) when the caller sees
// sources (admin); otherwise fall back to the flat model list (user scope).
let optList;
if (Array.isArray(s.sources) && s.sources.length) {
optList = ['AUTO'];
s.sources.forEach(src => (src.models || []).forEach(m => {
optList.push(`<option value="${escAttr(`${src.name}:${m}`)}">${esc(`${src.name} · ${m}`)}</option>`);
}));
} else {
optList = ['AUTO'].concat((s.models || []).map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`));
}
const finalOpts = optList.join('');
$('#tab-chat').innerHTML = `
<div class="tab-chat">
<div class="chat-wrap">
@ -1039,7 +1051,7 @@ async function renderChat() {
<div class="chat-composer">
<div class="chat-tools">
<span class="tl">${t('cModel')}</span>
<select id="ct-model">${opts}</select>
<select id="ct-model">${finalOpts}</select>
<span class="grow"></span>
<label class="chk"><input id="ct-thinking" type="checkbox" checked>${t('cThinking')}</label>
</div>