secubox-openwrt/package/secubox/luci-app-localai/htdocs/luci-static/resources/view/localai/dashboard.js
CyberMind-FR daa4c48375 fix(localai): Add gte-small preset, fix RPC expect unwrapping and chat JSON escaping
- Add gte-small embedding model preset to localaictl with proper YAML
  config (embeddings: true, context_size: 512)
- Fix RPC expect declarations across api.js, dashboard.js, models.js to
  use empty expect objects, preserving full response including error fields
- Replace fragile sed/awk JSON escaping in RPCD chat and completion
  handlers with file I/O streaming through awk for robust handling of
  special characters in LLM responses
- Switch RPCD chat handler from curl to wget to avoid missing output
  file on timeout (curl doesn't create -o file on exit code 28)
- Bypass RPCD 30s script timeout for chat by calling LocalAI API
  directly from the browser via fetch()
- Add embeddings flag to models RPC and filter embedding models from
  chat view model selector

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 08:36:20 +01:00

606 lines
17 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
'require view';
'require ui';
'require rpc';
var callStatus = rpc.declare({
object: 'luci.localai',
method: 'status',
expect: { }
});
var callModels = rpc.declare({
object: 'luci.localai',
method: 'models',
expect: { models: [] }
});
var callHealth = rpc.declare({
object: 'luci.localai',
method: 'health',
expect: { }
});
var callMetrics = rpc.declare({
object: 'luci.localai',
method: 'metrics',
expect: { }
});
var callStart = rpc.declare({
object: 'luci.localai',
method: 'start',
expect: { }
});
var callStop = rpc.declare({
object: 'luci.localai',
method: 'stop',
expect: { }
});
var callRestart = rpc.declare({
object: 'luci.localai',
method: 'restart',
expect: { }
});
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
var k = 1024;
var sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatUptime(seconds) {
if (!seconds) return 'N/A';
var days = Math.floor(seconds / 86400);
var hours = Math.floor((seconds % 86400) / 3600);
var mins = Math.floor((seconds % 3600) / 60);
if (days > 0) return days + 'd ' + hours + 'h';
if (hours > 0) return hours + 'h ' + mins + 'm';
return mins + 'm';
}
return view.extend({
title: _('LocalAI Dashboard'),
refreshInterval: 5000,
data: null,
load: function() {
return Promise.all([
callStatus(),
callModels(),
callHealth(),
callMetrics()
]).then(function(results) {
console.log('LocalAI Dashboard RPC results:', JSON.stringify(results));
// RPC with expect returns arrays directly, not wrapped objects
var modelsData = Array.isArray(results[1]) ? results[1] : [];
return {
status: results[0] || {},
models: modelsData,
health: results[2] || {},
metrics: results[3] || {}
};
});
},
render: function(data) {
var self = this;
this.data = data;
var container = E('div', { 'class': 'localai-dashboard' }, [
// Header
E('div', { 'class': 'lai-header' }, [
E('div', { 'class': 'lai-logo' }, [
E('div', { 'class': 'lai-logo-icon' }, '🤖'),
E('div', { 'class': 'lai-logo-text' }, [
E('span', {}, 'Local'),
'AI'
])
]),
E('div', { 'class': 'lai-header-info' }, [
E('div', {
'class': 'lai-status-badge ' + (data.status.running ? '' : 'offline'),
'id': 'lai-status-badge'
}, [
E('span', { 'class': 'lai-status-dot' }),
data.status.running ? _('Running') : _('Stopped')
])
])
]),
// Quick Stats
E('div', { 'class': 'lai-quick-stats' }, [
E('div', { 'class': 'lai-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #a855f7, #6366f1)' }, [
E('div', { 'class': 'lai-quick-stat-header' }, [
E('span', { 'class': 'lai-quick-stat-icon' }, '📊'),
E('span', { 'class': 'lai-quick-stat-label' }, _('Models'))
]),
E('div', { 'class': 'lai-quick-stat-value', 'id': 'models-count' },
(data.models || []).length.toString()
),
E('div', { 'class': 'lai-quick-stat-sub' }, _('Installed'))
]),
E('div', { 'class': 'lai-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #10b981, #059669)' }, [
E('div', { 'class': 'lai-quick-stat-header' }, [
E('span', { 'class': 'lai-quick-stat-icon' }, '💾'),
E('span', { 'class': 'lai-quick-stat-label' }, _('Memory'))
]),
E('div', { 'class': 'lai-quick-stat-value', 'id': 'memory-used' },
formatBytes(data.metrics.memory_used || 0)
),
E('div', { 'class': 'lai-quick-stat-sub' }, _('Used'))
]),
E('div', { 'class': 'lai-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #06b6d4, #0ea5e9)' }, [
E('div', { 'class': 'lai-quick-stat-header' }, [
E('span', { 'class': 'lai-quick-stat-icon' }, '⏱️'),
E('span', { 'class': 'lai-quick-stat-label' }, _('Uptime'))
]),
E('div', { 'class': 'lai-quick-stat-value', 'id': 'uptime' },
data.status.running ? formatUptime(data.status.uptime) : '--'
),
E('div', { 'class': 'lai-quick-stat-sub' }, _('Running'))
]),
E('div', { 'class': 'lai-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #f59e0b, #d97706)' }, [
E('div', { 'class': 'lai-quick-stat-header' }, [
E('span', { 'class': 'lai-quick-stat-icon' }, '🔌'),
E('span', { 'class': 'lai-quick-stat-label' }, _('API Port'))
]),
E('div', { 'class': 'lai-quick-stat-value' }, data.status.api_port || '8080'),
E('div', { 'class': 'lai-quick-stat-sub' }, _('Endpoint'))
])
]),
// Main Cards Grid
E('div', { 'class': 'lai-cards-grid' }, [
// Service Control Card
E('div', { 'class': 'lai-card' }, [
E('div', { 'class': 'lai-card-header' }, [
E('div', { 'class': 'lai-card-title' }, [
E('span', { 'class': 'lai-card-title-icon' }, ''),
_('Service Control')
]),
E('div', {
'class': 'lai-card-badge ' + (data.status.running ? 'running' : 'stopped')
}, data.status.running ? _('Active') : _('Inactive'))
]),
E('div', { 'class': 'lai-card-body' }, [
E('div', { 'class': 'lai-service-info' }, [
E('div', { 'class': 'lai-service-row' }, [
E('span', { 'class': 'lai-service-label' }, _('Status')),
E('span', {
'class': 'lai-service-value ' + (data.status.running ? 'running' : 'stopped'),
'id': 'service-status'
}, data.status.running ? _('Running') : _('Stopped'))
]),
E('div', { 'class': 'lai-service-row' }, [
E('span', { 'class': 'lai-service-label' }, _('Memory Limit')),
E('span', { 'class': 'lai-service-value' }, data.status.memory_limit || '2G')
]),
E('div', { 'class': 'lai-service-row' }, [
E('span', { 'class': 'lai-service-label' }, _('Threads')),
E('span', { 'class': 'lai-service-value' }, data.status.threads || '4')
]),
E('div', { 'class': 'lai-service-row' }, [
E('span', { 'class': 'lai-service-label' }, _('Context Size')),
E('span', { 'class': 'lai-service-value' }, data.status.context_size || '2048')
])
]),
E('div', { 'class': 'lai-service-controls' }, [
E('button', {
'class': 'lai-btn lai-btn-success' + (data.status.running ? ' disabled' : ''),
'click': function() { self.handleServiceAction('start'); },
'disabled': data.status.running
}, [E('span', {}, ''), _('Start')]),
E('button', {
'class': 'lai-btn lai-btn-danger' + (!data.status.running ? ' disabled' : ''),
'click': function() { self.handleServiceAction('stop'); },
'disabled': !data.status.running
}, [E('span', {}, ''), _('Stop')]),
E('button', {
'class': 'lai-btn lai-btn-warning',
'click': function() { self.handleServiceAction('restart'); }
}, [E('span', {}, '🔄'), _('Restart')])
])
])
]),
// Models Card
E('div', { 'class': 'lai-card' }, [
E('div', { 'class': 'lai-card-header' }, [
E('div', { 'class': 'lai-card-title' }, [
E('span', { 'class': 'lai-card-title-icon' }, '🧠'),
_('Installed Models')
]),
E('div', { 'class': 'lai-card-badge' },
(data.models || []).length + ' ' + _('models')
)
]),
E('div', { 'class': 'lai-card-body' }, [
this.renderModelsList(data.models || [])
])
])
]),
// API Info Card
E('div', { 'class': 'lai-card', 'style': 'margin-top: 20px' }, [
E('div', { 'class': 'lai-card-header' }, [
E('div', { 'class': 'lai-card-title' }, [
E('span', { 'class': 'lai-card-title-icon' }, '🔗'),
_('API Endpoints')
])
]),
E('div', { 'class': 'lai-card-body' }, [
E('div', { 'class': 'lai-api-info' }, [
E('div', { 'class': 'lai-api-endpoint' }, [
E('code', {}, 'http://' + window.location.hostname + ':' + (data.status.api_port || '8080') + '/v1/chat/completions'),
E('span', { 'class': 'lai-api-method' }, 'POST'),
E('span', { 'class': 'lai-api-desc' }, _('Chat completion'))
]),
E('div', { 'class': 'lai-api-endpoint' }, [
E('code', {}, 'http://' + window.location.hostname + ':' + (data.status.api_port || '8080') + '/v1/models'),
E('span', { 'class': 'lai-api-method get' }, 'GET'),
E('span', { 'class': 'lai-api-desc' }, _('List models'))
])
])
])
])
]);
// Include CSS
var style = E('style', {}, this.getCSS());
container.insertBefore(style, container.firstChild);
return container;
},
renderModelsList: function(models) {
if (!models || models.length === 0) {
return E('div', { 'class': 'lai-empty' }, [
E('div', { 'class': 'lai-empty-icon' }, '📦'),
E('div', { 'class': 'lai-empty-text' }, _('No models installed')),
E('div', { 'class': 'lai-empty-hint' }, [
_('Install a model with: '),
E('code', {}, 'localaictl model-install tinyllama')
])
]);
}
return E('div', { 'class': 'lai-models-list' },
models.map(function(model) {
var displayName = model.id || model.name;
return E('div', { 'class': 'lai-model-item' + (model.loaded ? ' loaded' : '') }, [
E('div', { 'class': 'lai-model-icon' }, model.loaded ? '✅' : '🤖'),
E('div', { 'class': 'lai-model-info' }, [
E('div', { 'class': 'lai-model-name' }, displayName),
E('div', { 'class': 'lai-model-meta' }, [
model.size > 0 ? E('span', { 'class': 'lai-model-size' }, formatBytes(model.size)) : null,
E('span', { 'class': 'lai-model-type' }, model.loaded ? _('Active') : model.type)
].filter(Boolean))
])
]);
})
);
},
handleServiceAction: function(action) {
var self = this;
ui.showModal(_('Service Control'), [
E('p', {}, _('Processing...')),
E('div', { 'class': 'spinning' })
]);
var actionFn;
switch(action) {
case 'start': actionFn = callStart(); break;
case 'stop': actionFn = callStop(); break;
case 'restart': actionFn = callRestart(); break;
}
actionFn.then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Service ' + action + ' successful')), 'success');
window.location.reload();
} else {
ui.addNotification(null, E('p', result.error || _('Operation failed')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', err.message), 'error');
});
},
getCSS: function() {
return `
.localai-dashboard {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #030712;
color: #f8fafc;
min-height: 100vh;
padding: 16px;
}
.lai-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0 20px;
border-bottom: 1px solid #334155;
margin-bottom: 20px;
}
.lai-logo {
display: flex;
align-items: center;
gap: 14px;
}
.lai-logo-icon {
width: 46px;
height: 46px;
background: linear-gradient(135deg, #a855f7, #6366f1);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.lai-logo-text {
font-size: 24px;
font-weight: 700;
}
.lai-logo-text span {
background: linear-gradient(135deg, #a855f7, #6366f1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.lai-status-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 24px;
background: rgba(16, 185, 129, 0.15);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
font-weight: 600;
}
.lai-status-badge.offline {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.lai-status-dot {
width: 10px;
height: 10px;
background: currentColor;
border-radius: 50%;
}
.lai-quick-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.lai-quick-stat {
background: #0f172a;
border: 1px solid #334155;
border-radius: 12px;
padding: 20px;
position: relative;
overflow: hidden;
}
.lai-quick-stat::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--stat-gradient);
}
.lai-quick-stat-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.lai-quick-stat-icon { font-size: 22px; }
.lai-quick-stat-label {
font-size: 11px;
text-transform: uppercase;
color: #64748b;
}
.lai-quick-stat-value {
font-size: 32px;
font-weight: 700;
background: var(--stat-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.lai-quick-stat-sub {
font-size: 11px;
color: #64748b;
margin-top: 6px;
}
.lai-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.lai-card {
background: #0f172a;
border: 1px solid #334155;
border-radius: 12px;
overflow: hidden;
}
.lai-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #334155;
background: rgba(0, 0, 0, 0.3);
}
.lai-card-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
font-weight: 600;
}
.lai-card-title-icon { font-size: 20px; }
.lai-card-badge {
font-size: 12px;
padding: 5px 12px;
border-radius: 16px;
background: linear-gradient(135deg, #a855f7, #6366f1);
color: white;
}
.lai-card-badge.running { background: linear-gradient(135deg, #10b981, #059669); }
.lai-card-badge.stopped { background: rgba(100, 116, 139, 0.3); color: #94a3b8; }
.lai-card-body { padding: 20px; }
.lai-service-info {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.lai-service-row {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: #030712;
border-radius: 8px;
}
.lai-service-label { color: #94a3b8; font-size: 13px; }
.lai-service-value { font-size: 13px; }
.lai-service-value.running { color: #10b981; }
.lai-service-value.stopped { color: #ef4444; }
.lai-service-controls {
display: flex;
gap: 10px;
}
.lai-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.lai-btn-success {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
}
.lai-btn-danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
}
.lai-btn-warning {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
}
.lai-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.lai-models-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.lai-model-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
background: #1e293b;
border-radius: 10px;
}
.lai-model-item.loaded {
border: 1px solid rgba(16, 185, 129, 0.3);
background: rgba(16, 185, 129, 0.05);
}
.lai-model-icon {
width: 44px;
height: 44px;
background: linear-gradient(135deg, #a855f7, #6366f1);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.lai-model-name {
font-weight: 600;
margin-bottom: 4px;
}
.lai-model-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #94a3b8;
}
.lai-model-type {
padding: 2px 8px;
background: #334155;
border-radius: 4px;
}
.lai-empty {
text-align: center;
padding: 40px 20px;
color: #64748b;
}
.lai-empty-icon { font-size: 48px; margin-bottom: 12px; }
.lai-empty-text { font-size: 16px; margin-bottom: 8px; }
.lai-empty-hint { font-size: 13px; }
.lai-empty-hint code {
background: #1e293b;
padding: 4px 8px;
border-radius: 4px;
}
.lai-api-info {
display: flex;
flex-direction: column;
gap: 10px;
}
.lai-api-endpoint {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #030712;
border-radius: 8px;
}
.lai-api-endpoint code {
font-size: 12px;
color: #06b6d4;
flex: 1;
}
.lai-api-method {
padding: 4px 8px;
background: #f59e0b;
color: #030712;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
}
.lai-api-method.get { background: #10b981; }
.lai-api-desc {
font-size: 12px;
color: #94a3b8;
min-width: 120px;
}
`;
}
});