274 lines
7.9 KiB
TypeScript
274 lines
7.9 KiB
TypeScript
// @ts-nocheck
|
|
|
|
|
|
import { zhongtaiMetricsUrl, CONFIG, loadModelSource, loadAnthropicConfig, loadOllamaConfig, loadOmlxConfig, loadSparkleConfig } from './config.js';
|
|
|
|
const els = {
|
|
model: document.getElementById('badge-model'),
|
|
cache: document.getElementById('badge-cache'),
|
|
tokens: document.getElementById('badge-tokens'),
|
|
mem: document.getElementById('badge-mem'),
|
|
cpu: document.getElementById('badge-cpu'),
|
|
};
|
|
|
|
let _timer = null;
|
|
|
|
let _lastTokRate = 0;
|
|
let _lastTokAt = 0;
|
|
|
|
let _cacheRead = 0;
|
|
let _cacheWrite = 0;
|
|
let _cacheInput = 0;
|
|
let _cacheAt = 0;
|
|
let _cacheLast = null;
|
|
|
|
export function reportTokens(tokens, durationMs) {
|
|
if (!tokens || !durationMs || durationMs < 100) return;
|
|
_lastTokRate = Math.round(tokens / (durationMs / 1000));
|
|
_lastTokAt = Date.now();
|
|
}
|
|
|
|
let _streamStartAt = 0;
|
|
let _streamChars = 0;
|
|
let _streamTokens = 0;
|
|
let _lastStreamUpdate = 0;
|
|
|
|
export function reportStreamDelta(charCount) {
|
|
const now = Date.now();
|
|
if (_streamStartAt === 0) {
|
|
_streamStartAt = now;
|
|
_streamChars = 0;
|
|
_streamTokens = 0;
|
|
}
|
|
_streamChars += charCount;
|
|
_streamTokens = Math.ceil(_streamChars / 3.5);
|
|
|
|
const dt = (now - _streamStartAt) / 1000;
|
|
|
|
if (dt > 0.3) {
|
|
_lastTokRate = Math.round(_streamTokens / dt);
|
|
_lastTokAt = now;
|
|
_lastStreamUpdate = now;
|
|
}
|
|
}
|
|
|
|
export function reportStreamEnd(finalTokens) {
|
|
|
|
if (finalTokens && finalTokens > 0) {
|
|
const dt = (Date.now() - _streamStartAt) / 1000;
|
|
if (dt > 0.1) {
|
|
_lastTokRate = Math.round(finalTokens / dt);
|
|
_lastTokAt = Date.now();
|
|
}
|
|
}
|
|
_streamStartAt = 0;
|
|
_streamChars = 0;
|
|
_streamTokens = 0;
|
|
}
|
|
|
|
export function reportPromptCache(usage) {
|
|
const parsed = parseCacheUsage(usage);
|
|
if (!parsed) return;
|
|
_cacheRead += parsed.read;
|
|
_cacheWrite += parsed.write;
|
|
_cacheInput += parsed.input;
|
|
_cacheAt = Date.now();
|
|
_cacheLast = parsed;
|
|
renderCacheBadge();
|
|
}
|
|
|
|
export function resetPromptCache() {
|
|
_cacheRead = 0;
|
|
_cacheWrite = 0;
|
|
_cacheInput = 0;
|
|
_cacheAt = 0;
|
|
_cacheLast = null;
|
|
renderCacheBadge();
|
|
}
|
|
|
|
function parseCacheUsage(usage) {
|
|
if (!usage || typeof usage !== 'object') return null;
|
|
|
|
|
|
const aRead = Number(usage.cache_read_input_tokens) || 0;
|
|
const aWrite = Number(usage.cache_creation_input_tokens) || 0;
|
|
const aInput = Number(usage.input_tokens) || 0;
|
|
const hasAnthropicCacheKeys = (
|
|
'cache_read_input_tokens' in usage
|
|
|| 'cache_creation_input_tokens' in usage
|
|
);
|
|
|
|
|
|
const oPrompt = Number(usage.prompt_tokens) || 0;
|
|
const oCached = Number(
|
|
usage.prompt_tokens_details?.cached_tokens
|
|
?? usage.cached_tokens
|
|
) || 0;
|
|
|
|
if (hasAnthropicCacheKeys) {
|
|
|
|
const read = Math.max(aRead, oCached);
|
|
const write = aWrite;
|
|
const input = aInput > 0
|
|
? aInput
|
|
: Math.max(0, oPrompt - oCached);
|
|
if (read + write + input <= 0) return null;
|
|
return { read, write, input };
|
|
}
|
|
|
|
|
|
if (oPrompt > 0 || oCached > 0) {
|
|
return {
|
|
read: oCached,
|
|
write: 0,
|
|
input: Math.max(0, (oPrompt || oCached) - oCached),
|
|
};
|
|
}
|
|
|
|
|
|
if (aInput > 0) {
|
|
return { read: 0, write: 0, input: aInput };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function renderCacheBadge() {
|
|
if (!els.cache) return;
|
|
const total = _cacheRead + _cacheWrite + _cacheInput;
|
|
if (total <= 0) {
|
|
els.cache.textContent = '缓存命中 -';
|
|
els.cache.className = 'badge';
|
|
els.cache.title = 'Prompt 缓存命中率(尚无数据)';
|
|
return;
|
|
}
|
|
const pct = Math.round((_cacheRead / total) * 100);
|
|
els.cache.textContent = `缓存命中 ${pct}%`;
|
|
els.cache.className = pct > 0 ? 'badge active' : 'badge';
|
|
const last = _cacheLast
|
|
? ` · 上次 读${_cacheLast.read}/写${_cacheLast.write}/新${_cacheLast.input}`
|
|
: '';
|
|
els.cache.title = `本会话累计命中率 ${pct}%`
|
|
+ ` · 读 ${_cacheRead} · 写 ${_cacheWrite} · 未缓存 ${_cacheInput}`
|
|
+ last;
|
|
}
|
|
|
|
async function fetchOnce() {
|
|
try {
|
|
|
|
const r = await fetch(zhongtaiMetricsUrl());
|
|
if (!r.ok) return;
|
|
const m = await r.json();
|
|
render(m);
|
|
} catch (e) {
|
|
|
|
}
|
|
}
|
|
|
|
function render(m) {
|
|
const source = loadModelSource();
|
|
|
|
|
|
if (source === 'anthropic') {
|
|
const cfg = loadAnthropicConfig();
|
|
const tierModel = ({
|
|
fast: cfg.fastModel,
|
|
standard: cfg.standardModel,
|
|
pro: cfg.proModel,
|
|
})[cfg.currentTier] || cfg.fastModel;
|
|
const tierLabel = ({ fast: '快速', standard: '平衡', pro: '增强' })[cfg.currentTier] || '';
|
|
const proto = cfg.protocol === 'openai' ? 'OpenAI' : 'Anthropic';
|
|
els.model.textContent = `云端 ${shortName(tierModel)}`;
|
|
els.model.className = 'badge active';
|
|
els.model.title = `云端 · ${tierLabel}档 · ${proto} · ${cfg.baseUrl}`;
|
|
} else if (source === 'omlx') {
|
|
const cfg = loadOmlxConfig();
|
|
els.model.textContent = `oMLX ${shortName(cfg.model)}`;
|
|
els.model.className = 'badge active';
|
|
els.model.title = `本地 oMLX · ${cfg.baseUrl}`;
|
|
} else if (source === 'sparkle') {
|
|
const cfg = loadSparkleConfig();
|
|
els.model.textContent = `Sparkle ${shortName(cfg.model)}`;
|
|
els.model.className = 'badge active';
|
|
els.model.title = `本地 Sparkle · ${cfg.baseUrl}`;
|
|
} else {
|
|
const model = m.ollama;
|
|
if (model?.running && model.models?.length > 0) {
|
|
const m0 = model.models[0];
|
|
const name = shortName(m0.name);
|
|
els.model.textContent = `本地 ${name}`;
|
|
els.model.className = 'badge active';
|
|
} else {
|
|
els.model.textContent = '本地 未加载';
|
|
els.model.className = 'badge';
|
|
}
|
|
}
|
|
|
|
renderCacheBadge();
|
|
|
|
|
|
if (_lastTokAt && Date.now() - _lastTokAt < 5000) {
|
|
els.tokens.textContent = `速率 ${_lastTokRate} tok/s`;
|
|
els.tokens.className = 'badge active';
|
|
} else {
|
|
els.tokens.textContent = `速率 - tok/s`;
|
|
els.tokens.className = 'badge';
|
|
_lastTokRate = 0;
|
|
}
|
|
|
|
|
|
if (m.memory) {
|
|
const used = m.memory.used;
|
|
const total = m.memory.total;
|
|
const pct = m.memory.usagePercent.toFixed(0);
|
|
const model = m.ollama;
|
|
|
|
|
|
if (source === 'ollama' && model?.processMem && model.running) {
|
|
els.mem.textContent = `模型 ${gb(model.processMem)}G · 系统 ${pct}%`;
|
|
els.mem.title = `模型占用 ${gb(model.processMem)}GB · 系统内存压力 ${pct}%(活动监视器口径,总 ${gb(total)}GB)`;
|
|
} else if (source === 'omlx') {
|
|
els.mem.textContent = `内存/显存 ${gb(used)}/${gb(total)}GB · ${pct}%`;
|
|
els.mem.title = `oMLX 模型占用看 admin dashboard · 系统内存压力 ${pct}%`;
|
|
} else {
|
|
els.mem.textContent = `内存 ${gb(used)}/${gb(total)}GB · ${pct}%`;
|
|
els.mem.title = `系统内存使用 ${pct}%(总 ${gb(total)}GB)`;
|
|
}
|
|
|
|
|
|
els.mem.className = pct > 90 ? 'badge danger' : pct > 75 ? 'badge warn' : 'badge';
|
|
}
|
|
|
|
|
|
if (m.cpu) {
|
|
const cpu = m.cpu;
|
|
const cpuPct = cpu.usagePercent.toFixed(0);
|
|
els.cpu.textContent = `CPU ${cpuPct}% · ${cpu.count}核`;
|
|
els.cpu.className = cpu.usagePercent > 80 ? 'badge danger' : cpu.usagePercent > 50 ? 'badge warn' : 'badge';
|
|
}
|
|
}
|
|
|
|
function gb(bytes) {
|
|
return (bytes / 1024 / 1024 / 1024).toFixed(1);
|
|
}
|
|
|
|
export function shortName(name) {
|
|
if (!name) return '?';
|
|
return String(name)
|
|
.replace(':latest', '')
|
|
.replace(/-A\d+B(?=-|$)/i, '');
|
|
}
|
|
|
|
export function start() {
|
|
if (_timer) return;
|
|
fetchOnce();
|
|
_timer = setInterval(fetchOnce, CONFIG.METRICS_POLL_MS);
|
|
}
|
|
|
|
export function stop() {
|
|
if (_timer) {
|
|
clearInterval(_timer);
|
|
_timer = null;
|
|
}
|
|
}
|