feat(webui): Add Models tab and refactor model configuration
- Add standalone Models tab with real-time quota/status display - Move model identity table from Dashboard to Models tab - Slim down Dashboard to KPI cards and charts only - Dashboard charts now use unfiltered data (independent of Models filters) Settings > Models improvements: - Remove redundant Alias column (only Mapping is functional) - Fix column misalignment bug (empty td) - Add column widths and hidden row opacity styling - Single row edit constraint (only one Mapping editable at a time) - showHiddenModels toggle now only affects Settings (not Models tab) - Update description text to match current functionality i18n: - Add 'models' and 'modelsPageDesc' keys (EN/ZH) - Add 'modelMappingHint' for Claude CLI guidance - Update 'modelsDesc' to reflect new functionality
This commit is contained in:
@@ -439,7 +439,7 @@ window.Components.dashboard = () => ({
|
||||
padding: 10,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
label: function (context) {
|
||||
return context.dataset.label + ': ' + context.parsed.y;
|
||||
}
|
||||
}
|
||||
@@ -531,7 +531,8 @@ window.Components.dashboard = () => ({
|
||||
this.charts.quotaDistribution.destroy();
|
||||
}
|
||||
|
||||
const rows = Alpine.store('data').quotaRows;
|
||||
// Use UNFILTERED data for global health chart
|
||||
const rows = Alpine.store('data').getUnfilteredQuotaData();
|
||||
|
||||
// Dynamic family aggregation (supports any model family)
|
||||
const familyStats = {};
|
||||
@@ -580,12 +581,12 @@ window.Components.dashboard = () => ({
|
||||
|
||||
// Labels using translations if possible
|
||||
const activeLabel = family === 'claude' ? store.t('claudeActive') :
|
||||
family === 'gemini' ? store.t('geminiActive') :
|
||||
`${familyName} ${store.t('activeSuffix')}`;
|
||||
family === 'gemini' ? store.t('geminiActive') :
|
||||
`${familyName} ${store.t('activeSuffix')}`;
|
||||
|
||||
const depletedLabel = family === 'claude' ? store.t('claudeEmpty') :
|
||||
family === 'gemini' ? store.t('geminiEmpty') :
|
||||
`${familyName} ${store.t('depleted')}`;
|
||||
family === 'gemini' ? store.t('geminiEmpty') :
|
||||
`${familyName} ${store.t('depleted')}`;
|
||||
|
||||
// Active segment
|
||||
data.push(activeVal);
|
||||
|
||||
@@ -6,10 +6,36 @@
|
||||
window.Components = window.Components || {};
|
||||
|
||||
window.Components.modelManager = () => ({
|
||||
// Track which model is currently being edited (null = none)
|
||||
editingModelId: null,
|
||||
|
||||
init() {
|
||||
// Component is ready
|
||||
},
|
||||
|
||||
/**
|
||||
* Start editing a model's mapping
|
||||
* @param {string} modelId - The model to edit
|
||||
*/
|
||||
startEditing(modelId) {
|
||||
this.editingModelId = modelId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop editing
|
||||
*/
|
||||
stopEditing() {
|
||||
this.editingModelId = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a model is being edited
|
||||
* @param {string} modelId - The model to check
|
||||
*/
|
||||
isEditing(modelId) {
|
||||
return this.editingModelId === modelId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update model configuration with authentication
|
||||
* @param {string} modelId - The model ID to update
|
||||
|
||||
58
public/js/components/models.js
Normal file
58
public/js/components/models.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Models Component
|
||||
* Displays model quota/status list
|
||||
* Registers itself to window.Components for Alpine.js to consume
|
||||
*/
|
||||
window.Components = window.Components || {};
|
||||
|
||||
window.Components.models = () => ({
|
||||
init() {
|
||||
// Ensure data is fetched when this tab becomes active
|
||||
this.$watch('$store.global.activeTab', (val) => {
|
||||
if (val === 'models') {
|
||||
// Trigger recompute to ensure filters are applied
|
||||
this.$nextTick(() => {
|
||||
Alpine.store('data').computeQuotaRows();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initial compute if already on models tab
|
||||
if (this.$store.global.activeTab === 'models') {
|
||||
this.$nextTick(() => {
|
||||
Alpine.store('data').computeQuotaRows();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update model configuration (Pin/Hide quick actions)
|
||||
* @param {string} modelId - The model ID to update
|
||||
* @param {object} configUpdates - Configuration updates (pinned, hidden)
|
||||
*/
|
||||
async updateModelConfig(modelId, configUpdates) {
|
||||
const store = Alpine.store('global');
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request('/api/models/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ modelId, config: configUpdates })
|
||||
}, store.webuiPassword);
|
||||
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update model config');
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
Alpine.store('data').modelConfig[modelId] = {
|
||||
...Alpine.store('data').modelConfig[modelId],
|
||||
...configUpdates
|
||||
};
|
||||
Alpine.store('data').computeQuotaRows();
|
||||
} catch (e) {
|
||||
store.showToast('Failed to update: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -69,34 +69,32 @@ document.addEventListener('alpine:init', () => {
|
||||
computeQuotaRows() {
|
||||
const models = this.models || [];
|
||||
const rows = [];
|
||||
const showExhausted = Alpine.store('settings')?.showExhausted ?? true; // Need settings store
|
||||
// Temporary debug flag or settings flag to show hidden models
|
||||
const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
|
||||
const showExhausted = Alpine.store('settings')?.showExhausted ?? true;
|
||||
|
||||
models.forEach(modelId => {
|
||||
// Config
|
||||
const config = this.modelConfig[modelId] || {};
|
||||
const family = this.getModelFamily(modelId);
|
||||
|
||||
// Smart Visibility Logic:
|
||||
// 1. If explicit config exists, use it.
|
||||
// 2. If no config, default 'unknown' families to HIDDEN to prevent clutter.
|
||||
// 3. Known families (Claude/Gemini) default to VISIBLE.
|
||||
// Visibility Logic for Models Tab (quotaRows):
|
||||
// 1. If explicitly hidden via config, always hide
|
||||
// 2. If no config, default 'unknown' families to HIDDEN
|
||||
// 3. Known families (Claude/Gemini) default to VISIBLE
|
||||
// Note: showHiddenModels toggle is for Settings page only, NOT here
|
||||
let isHidden = config.hidden;
|
||||
if (isHidden === undefined) {
|
||||
isHidden = (family === 'other' || family === 'unknown');
|
||||
}
|
||||
|
||||
// Skip hidden models unless "Show Hidden" is enabled
|
||||
if (isHidden && !showHidden) return;
|
||||
// Models Tab: ALWAYS hide hidden models (no toggle check)
|
||||
if (isHidden) return;
|
||||
|
||||
// Filters
|
||||
if (this.filters.family !== 'all' && this.filters.family !== family) return;
|
||||
if (this.filters.search) {
|
||||
const searchLower = this.filters.search.toLowerCase();
|
||||
const aliasMatch = config.alias && config.alias.toLowerCase().includes(searchLower);
|
||||
const idMatch = modelId.toLowerCase().includes(searchLower);
|
||||
if (!aliasMatch && !idMatch) return;
|
||||
if (!idMatch) return;
|
||||
}
|
||||
|
||||
// Data Collection
|
||||
@@ -138,7 +136,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
rows.push({
|
||||
modelId,
|
||||
displayName: config.alias || modelId, // Use alias if available
|
||||
displayName: modelId, // Simplified: no longer using alias
|
||||
family,
|
||||
minQuota,
|
||||
avgQuota, // Added Average Quota
|
||||
@@ -165,6 +163,43 @@ document.addEventListener('alpine:init', () => {
|
||||
if (lower.includes('claude')) return 'claude';
|
||||
if (lower.includes('gemini')) return 'gemini';
|
||||
return 'other';
|
||||
},
|
||||
|
||||
/**
|
||||
* Get quota data without filters applied (for Dashboard global charts)
|
||||
* Returns array of { modelId, family, quotaInfo: [{pct}] }
|
||||
*/
|
||||
getUnfilteredQuotaData() {
|
||||
const models = this.models || [];
|
||||
const rows = [];
|
||||
const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
|
||||
|
||||
models.forEach(modelId => {
|
||||
const config = this.modelConfig[modelId] || {};
|
||||
const family = this.getModelFamily(modelId);
|
||||
|
||||
// Smart visibility (same logic as computeQuotaRows)
|
||||
let isHidden = config.hidden;
|
||||
if (isHidden === undefined) {
|
||||
isHidden = (family === 'other' || family === 'unknown');
|
||||
}
|
||||
if (isHidden && !showHidden) return;
|
||||
|
||||
const quotaInfo = [];
|
||||
// Use ALL accounts (no account filter)
|
||||
this.accounts.forEach(acc => {
|
||||
const limit = acc.limits?.[modelId];
|
||||
if (!limit) return;
|
||||
const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
|
||||
quotaInfo.push({ pct });
|
||||
});
|
||||
|
||||
if (quotaInfo.length === 0) return;
|
||||
|
||||
rows.push({ modelId, family, quotaInfo });
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ document.addEventListener('alpine:init', () => {
|
||||
translations: {
|
||||
en: {
|
||||
dashboard: "Dashboard",
|
||||
models: "Models",
|
||||
accounts: "Accounts",
|
||||
logs: "Logs",
|
||||
settings: "Settings",
|
||||
@@ -77,14 +78,16 @@ document.addEventListener('alpine:init', () => {
|
||||
noSignal: "NO SIGNAL DETECTED",
|
||||
establishingUplink: "ESTABLISHING UPLINK...",
|
||||
// Settings - Models
|
||||
modelsDesc: "Manage visibility and ordering of models in the dashboard.",
|
||||
modelsDesc: "Configure model visibility, pinning, and request redirection.",
|
||||
modelsPageDesc: "Real-time quota and status for all available models.",
|
||||
showHidden: "Show Hidden Models",
|
||||
modelId: "Model ID",
|
||||
alias: "Alias",
|
||||
actions: "Actions",
|
||||
pinToTop: "Pin to top",
|
||||
toggleVisibility: "Toggle Visibility",
|
||||
noModels: "NO MODELS DETECTED",
|
||||
modelMappingHint: "Server-side model redirection. Claude Code users: see 'Claude CLI' tab for easier setup.",
|
||||
modelMapping: "Mapping (Target Model)",
|
||||
// Settings - Claude
|
||||
proxyConnection: "Proxy Connection",
|
||||
modelSelection: "Model Selection",
|
||||
@@ -219,6 +222,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
zh: {
|
||||
dashboard: "仪表盘",
|
||||
models: "模型列表",
|
||||
accounts: "账号管理",
|
||||
logs: "运行日志",
|
||||
settings: "系统设置",
|
||||
@@ -282,14 +286,16 @@ document.addEventListener('alpine:init', () => {
|
||||
noSignal: "无信号连接",
|
||||
establishingUplink: "正在建立上行链路...",
|
||||
// Settings - Models
|
||||
modelsDesc: "管理仪表盘中模型的可见性和排序。",
|
||||
modelsDesc: "配置模型的可见性、置顶和请求重定向。",
|
||||
modelsPageDesc: "所有可用模型的实时配额和状态。",
|
||||
showHidden: "显示隐藏模型",
|
||||
modelId: "模型 ID",
|
||||
alias: "别名",
|
||||
actions: "操作",
|
||||
pinToTop: "置顶",
|
||||
toggleVisibility: "切换可见性",
|
||||
noModels: "未检测到模型",
|
||||
modelMappingHint: "服务端模型重定向功能。Claude Code 用户请使用 'Claude CLI' 标签页以便捷配置。",
|
||||
modelMapping: "映射 (目标模型)",
|
||||
// Settings - Claude
|
||||
proxyConnection: "代理连接",
|
||||
modelSelection: "模型选择",
|
||||
|
||||
Reference in New Issue
Block a user