feat(luci-app-ollama): Add LuCI dashboard for Ollama LLM

New LuCI application for Ollama management:
- Dashboard with service status and controls
- Model management (pull, remove, list)
- Chat interface with model selection
- Settings page for configuration

Files:
- RPCD backend (luci.ollama)
- Dashboard, Models, Chat, Settings views
- ACL and menu definitions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-21 19:02:12 +01:00
parent 1cce649751
commit 48deeccb99
9 changed files with 1883 additions and 1 deletions

View File

@ -103,7 +103,9 @@
"Bash(./build.sh:*)",
"Bash(cd /home/reepost/CyberMindStudio/_files/secubox-openwrt/secubox-tools/sdk ./scripts/feeds update secubox)",
"Bash(./staging_dir/host/bin/ipkg-build:*)",
"Bash(./scripts/ipkg-build:*)"
"Bash(./scripts/ipkg-build:*)",
"WebFetch(domain:hub.docker.com)",
"WebFetch(domain:localai.io)"
]
}
}

View File

@ -0,0 +1,33 @@
# SPDX-License-Identifier: Apache-2.0
#
# Copyright (C) 2025 CyberMind.fr - Gandalf
#
# LuCI Ollama - Self-hosted LLM Management Interface
#
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-ollama
PKG_VERSION:=0.1.0
PKG_RELEASE:=1
PKG_ARCH:=all
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
LUCI_TITLE:=LuCI Ollama Dashboard
LUCI_DESCRIPTION:=Modern dashboard for Ollama LLM management on OpenWrt
LUCI_DEPENDS:=+luci-base +luci-app-secubox +luci-lib-jsonc +rpcd +rpcd-mod-luci +secubox-app-ollama
LUCI_PKGARCH:=all
# File permissions
PKG_FILE_MODES:=/usr/libexec/rpcd/luci.ollama:root:root:755
include $(TOPDIR)/feeds/luci/luci.mk
define Package/$(PKG_NAME)/conffiles
/etc/config/ollama
endef
# call BuildPackage - OpenWrt buildroot

View File

@ -0,0 +1,397 @@
'use strict';
'require view';
'require ui';
'require rpc';
var callModels = rpc.declare({
object: 'luci.ollama',
method: 'models',
expect: { models: [] }
});
var callChat = rpc.declare({
object: 'luci.ollama',
method: 'chat',
params: ['model', 'message'],
expect: { response: '' }
});
var callStatus = rpc.declare({
object: 'luci.ollama',
method: 'status',
expect: { running: false }
});
return view.extend({
title: _('Ollama Chat'),
chatHistory: [],
selectedModel: null,
load: function() {
return Promise.all([
callModels(),
callStatus()
]).then(function(results) {
return {
models: Array.isArray(results[0]) ? results[0] : [],
status: results[1] || {}
};
});
},
render: function(data) {
var self = this;
var models = data.models;
var status = data.status;
if (!status.running) {
return E('div', { 'class': 'ollama-chat' }, [
E('style', {}, this.getCSS()),
E('div', { 'class': 'oll-chat-offline' }, [
E('span', { 'class': 'oll-offline-icon' }, '⚠️'),
E('h3', {}, _('Ollama is not running')),
E('p', {}, _('Start the service to use chat')),
E('code', {}, '/etc/init.d/ollama start')
])
]);
}
if (models.length === 0) {
return E('div', { 'class': 'ollama-chat' }, [
E('style', {}, this.getCSS()),
E('div', { 'class': 'oll-chat-offline' }, [
E('span', { 'class': 'oll-offline-icon' }, '📦'),
E('h3', {}, _('No models available')),
E('p', {}, _('Download a model first')),
E('code', {}, 'ollamactl pull tinyllama')
])
]);
}
this.selectedModel = models[0].name;
var container = E('div', { 'class': 'ollama-chat' }, [
E('style', {}, this.getCSS()),
// Header
E('div', { 'class': 'oll-chat-header' }, [
E('div', { 'class': 'oll-chat-title' }, [
E('span', { 'class': 'oll-chat-icon' }, '🦙'),
E('span', {}, _('Ollama Chat'))
]),
E('div', { 'class': 'oll-model-select-wrapper' }, [
E('label', {}, _('Model:')),
E('select', {
'class': 'oll-model-select',
'id': 'model-select',
'change': function(ev) { self.selectedModel = ev.target.value; }
}, models.map(function(m) {
return E('option', { 'value': m.name }, m.name);
}))
])
]),
// Chat Messages
E('div', { 'class': 'oll-chat-messages', 'id': 'chat-messages' }, [
E('div', { 'class': 'oll-chat-welcome' }, [
E('span', { 'class': 'oll-welcome-icon' }, '👋'),
E('h3', {}, _('Welcome to Ollama Chat')),
E('p', {}, _('Select a model and start chatting. Your conversation is processed locally.'))
])
]),
// Input Area
E('div', { 'class': 'oll-chat-input-area' }, [
E('textarea', {
'class': 'oll-chat-input',
'id': 'chat-input',
'placeholder': _('Type your message...'),
'rows': 3,
'keydown': function(ev) {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
self.sendMessage();
}
}
}),
E('button', {
'class': 'oll-send-btn',
'id': 'send-btn',
'click': function() { self.sendMessage(); }
}, [E('span', {}, '➤'), _('Send')])
])
]);
return container;
},
sendMessage: function() {
var self = this;
var input = document.getElementById('chat-input');
var message = input.value.trim();
if (!message) return;
var messagesContainer = document.getElementById('chat-messages');
var sendBtn = document.getElementById('send-btn');
// Clear welcome message if present
var welcome = messagesContainer.querySelector('.oll-chat-welcome');
if (welcome) welcome.remove();
// Add user message
this.addMessage('user', message);
input.value = '';
sendBtn.disabled = true;
// Add typing indicator
var typingId = 'typing-' + Date.now();
this.addTypingIndicator(typingId);
// Send to API
callChat(this.selectedModel, message).then(function(result) {
self.removeTypingIndicator(typingId);
sendBtn.disabled = false;
if (result.error) {
self.addMessage('error', result.error);
} else if (result.response) {
self.addMessage('assistant', result.response);
} else {
self.addMessage('error', _('No response received'));
}
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}).catch(function(err) {
self.removeTypingIndicator(typingId);
sendBtn.disabled = false;
self.addMessage('error', err.message);
});
},
addMessage: function(role, content) {
var messagesContainer = document.getElementById('chat-messages');
var msgClass = 'oll-message oll-message-' + role;
var icon = role === 'user' ? '👤' : (role === 'error' ? '⚠️' : '🦙');
var msg = E('div', { 'class': msgClass }, [
E('div', { 'class': 'oll-message-icon' }, icon),
E('div', { 'class': 'oll-message-content' }, content)
]);
messagesContainer.appendChild(msg);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
},
addTypingIndicator: function(id) {
var messagesContainer = document.getElementById('chat-messages');
var typing = E('div', { 'class': 'oll-message oll-message-assistant oll-typing', 'id': id }, [
E('div', { 'class': 'oll-message-icon' }, '🦙'),
E('div', { 'class': 'oll-message-content' }, [
E('div', { 'class': 'oll-typing-dots' }, [
E('span', {}), E('span', {}), E('span', {})
])
])
]);
messagesContainer.appendChild(typing);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
},
removeTypingIndicator: function(id) {
var el = document.getElementById(id);
if (el) el.remove();
},
getCSS: function() {
return `
.ollama-chat {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #030712;
color: #f8fafc;
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
border-radius: 12px;
overflow: hidden;
}
.oll-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: #0f172a;
border-bottom: 1px solid #334155;
}
.oll-chat-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
font-weight: 600;
}
.oll-chat-icon { font-size: 24px; }
.oll-model-select-wrapper {
display: flex;
align-items: center;
gap: 10px;
}
.oll-model-select-wrapper label {
color: #94a3b8;
font-size: 13px;
}
.oll-model-select {
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 8px 12px;
color: #f8fafc;
font-size: 13px;
cursor: pointer;
}
.oll-chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.oll-chat-welcome {
text-align: center;
padding: 60px 20px;
color: #64748b;
}
.oll-welcome-icon { font-size: 48px; display: block; margin-bottom: 16px; }
.oll-chat-welcome h3 { margin: 0 0 8px; color: #f8fafc; }
.oll-chat-welcome p { margin: 0; }
.oll-message {
display: flex;
gap: 12px;
max-width: 85%;
}
.oll-message-user {
align-self: flex-end;
flex-direction: row-reverse;
}
.oll-message-assistant {
align-self: flex-start;
}
.oll-message-error {
align-self: center;
}
.oll-message-icon {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.oll-message-user .oll-message-icon {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
.oll-message-assistant .oll-message-icon {
background: linear-gradient(135deg, #f97316, #ea580c);
}
.oll-message-error .oll-message-icon {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
.oll-message-content {
background: #1e293b;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.oll-message-user .oll-message-content {
background: linear-gradient(135deg, #3b82f6, #2563eb);
border-radius: 12px 12px 4px 12px;
}
.oll-message-assistant .oll-message-content {
border-radius: 12px 12px 12px 4px;
}
.oll-message-error .oll-message-content {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.oll-typing-dots {
display: flex;
gap: 4px;
}
.oll-typing-dots span {
width: 8px;
height: 8px;
background: #64748b;
border-radius: 50%;
animation: typing 1.4s infinite;
}
.oll-typing-dots span:nth-child(2) { animation-delay: 0.2s; }
.oll-typing-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); }
30% { opacity: 1; transform: scale(1); }
}
.oll-chat-input-area {
padding: 16px 20px;
background: #0f172a;
border-top: 1px solid #334155;
display: flex;
gap: 12px;
}
.oll-chat-input {
flex: 1;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 12px 16px;
color: #f8fafc;
font-size: 14px;
resize: none;
font-family: inherit;
}
.oll-chat-input:focus {
outline: none;
border-color: #f97316;
}
.oll-send-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: linear-gradient(135deg, #f97316, #ea580c);
border: none;
border-radius: 12px;
color: white;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.oll-send-btn:hover { opacity: 0.9; }
.oll-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.oll-chat-offline {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: #64748b;
padding: 40px;
}
.oll-offline-icon { font-size: 48px; margin-bottom: 16px; }
.oll-chat-offline h3 { margin: 0 0 8px; color: #f8fafc; }
.oll-chat-offline p { margin: 0 0 16px; }
.oll-chat-offline code {
background: #1e293b;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
}
`;
}
});

View File

@ -0,0 +1,577 @@
'use strict';
'require view';
'require ui';
'require rpc';
var callStatus = rpc.declare({
object: 'luci.ollama',
method: 'status',
expect: { }
});
var callModels = rpc.declare({
object: 'luci.ollama',
method: 'models',
expect: { models: [] }
});
var callHealth = rpc.declare({
object: 'luci.ollama',
method: 'health',
expect: { healthy: false }
});
var callStart = rpc.declare({
object: 'luci.ollama',
method: 'start',
expect: { success: false }
});
var callStop = rpc.declare({
object: 'luci.ollama',
method: 'stop',
expect: { success: false }
});
var callRestart = rpc.declare({
object: 'luci.ollama',
method: 'restart',
expect: { success: false }
});
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: _('Ollama Dashboard'),
refreshInterval: 5000,
data: null,
load: function() {
return Promise.all([
callStatus(),
callModels(),
callHealth()
]).then(function(results) {
var modelsData = Array.isArray(results[1]) ? results[1] : [];
return {
status: results[0] || {},
models: modelsData,
health: results[2] || {}
};
});
},
render: function(data) {
var self = this;
this.data = data;
var container = E('div', { 'class': 'ollama-dashboard' }, [
// Header
E('div', { 'class': 'oll-header' }, [
E('div', { 'class': 'oll-logo' }, [
E('div', { 'class': 'oll-logo-icon' }, '🦙'),
E('div', { 'class': 'oll-logo-text' }, 'Ollama')
]),
E('div', { 'class': 'oll-header-info' }, [
E('div', {
'class': 'oll-status-badge ' + (data.status.running ? '' : 'offline'),
'id': 'oll-status-badge'
}, [
E('span', { 'class': 'oll-status-dot' }),
data.status.running ? _('Running') : _('Stopped')
])
])
]),
// Quick Stats
E('div', { 'class': 'oll-quick-stats' }, [
E('div', { 'class': 'oll-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #f97316, #ea580c)' }, [
E('div', { 'class': 'oll-quick-stat-header' }, [
E('span', { 'class': 'oll-quick-stat-icon' }, '🧠'),
E('span', { 'class': 'oll-quick-stat-label' }, _('Models'))
]),
E('div', { 'class': 'oll-quick-stat-value', 'id': 'models-count' },
(data.models || []).length.toString()
),
E('div', { 'class': 'oll-quick-stat-sub' }, _('Downloaded'))
]),
E('div', { 'class': 'oll-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #10b981, #059669)' }, [
E('div', { 'class': 'oll-quick-stat-header' }, [
E('span', { 'class': 'oll-quick-stat-icon' }, '⏱️'),
E('span', { 'class': 'oll-quick-stat-label' }, _('Uptime'))
]),
E('div', { 'class': 'oll-quick-stat-value', 'id': 'uptime' },
data.status.running ? formatUptime(data.status.uptime) : '--'
),
E('div', { 'class': 'oll-quick-stat-sub' }, _('Running'))
]),
E('div', { 'class': 'oll-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #06b6d4, #0ea5e9)' }, [
E('div', { 'class': 'oll-quick-stat-header' }, [
E('span', { 'class': 'oll-quick-stat-icon' }, '🔌'),
E('span', { 'class': 'oll-quick-stat-label' }, _('API Port'))
]),
E('div', { 'class': 'oll-quick-stat-value' }, data.status.api_port || '11434'),
E('div', { 'class': 'oll-quick-stat-sub' }, _('Endpoint'))
]),
E('div', { 'class': 'oll-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #8b5cf6, #7c3aed)' }, [
E('div', { 'class': 'oll-quick-stat-header' }, [
E('span', { 'class': 'oll-quick-stat-icon' }, '🐋'),
E('span', { 'class': 'oll-quick-stat-label' }, _('Runtime'))
]),
E('div', { 'class': 'oll-quick-stat-value' }, data.status.runtime || 'none'),
E('div', { 'class': 'oll-quick-stat-sub' }, _('Container'))
])
]),
// Main Cards Grid
E('div', { 'class': 'oll-cards-grid' }, [
// Service Control Card
E('div', { 'class': 'oll-card' }, [
E('div', { 'class': 'oll-card-header' }, [
E('div', { 'class': 'oll-card-title' }, [
E('span', { 'class': 'oll-card-title-icon' }, '⚙️'),
_('Service Control')
]),
E('div', {
'class': 'oll-card-badge ' + (data.status.running ? 'running' : 'stopped')
}, data.status.running ? _('Active') : _('Inactive'))
]),
E('div', { 'class': 'oll-card-body' }, [
E('div', { 'class': 'oll-service-info' }, [
E('div', { 'class': 'oll-service-row' }, [
E('span', { 'class': 'oll-service-label' }, _('Status')),
E('span', {
'class': 'oll-service-value ' + (data.status.running ? 'running' : 'stopped'),
'id': 'service-status'
}, data.status.running ? _('Running') : _('Stopped'))
]),
E('div', { 'class': 'oll-service-row' }, [
E('span', { 'class': 'oll-service-label' }, _('Memory Limit')),
E('span', { 'class': 'oll-service-value' }, data.status.memory_limit || '2g')
]),
E('div', { 'class': 'oll-service-row' }, [
E('span', { 'class': 'oll-service-label' }, _('Data Path')),
E('span', { 'class': 'oll-service-value' }, data.status.data_path || '/srv/ollama')
])
]),
E('div', { 'class': 'oll-service-controls' }, [
E('button', {
'class': 'oll-btn oll-btn-success' + (data.status.running ? ' disabled' : ''),
'click': function() { self.handleServiceAction('start'); },
'disabled': data.status.running
}, [E('span', {}, '▶'), _('Start')]),
E('button', {
'class': 'oll-btn oll-btn-danger' + (!data.status.running ? ' disabled' : ''),
'click': function() { self.handleServiceAction('stop'); },
'disabled': !data.status.running
}, [E('span', {}, '⏹'), _('Stop')]),
E('button', {
'class': 'oll-btn oll-btn-warning',
'click': function() { self.handleServiceAction('restart'); }
}, [E('span', {}, '🔄'), _('Restart')])
])
])
]),
// Models Card
E('div', { 'class': 'oll-card' }, [
E('div', { 'class': 'oll-card-header' }, [
E('div', { 'class': 'oll-card-title' }, [
E('span', { 'class': 'oll-card-title-icon' }, '🦙'),
_('Downloaded Models')
]),
E('div', { 'class': 'oll-card-badge' },
(data.models || []).length + ' ' + _('models')
)
]),
E('div', { 'class': 'oll-card-body' }, [
this.renderModelsList(data.models || [])
])
])
]),
// API Info Card
E('div', { 'class': 'oll-card', 'style': 'margin-top: 20px' }, [
E('div', { 'class': 'oll-card-header' }, [
E('div', { 'class': 'oll-card-title' }, [
E('span', { 'class': 'oll-card-title-icon' }, '🔗'),
_('API Endpoints')
])
]),
E('div', { 'class': 'oll-card-body' }, [
E('div', { 'class': 'oll-api-info' }, [
E('div', { 'class': 'oll-api-endpoint' }, [
E('code', {}, 'http://' + window.location.hostname + ':' + (data.status.api_port || '11434') + '/api/chat'),
E('span', { 'class': 'oll-api-method' }, 'POST'),
E('span', { 'class': 'oll-api-desc' }, _('Chat completion'))
]),
E('div', { 'class': 'oll-api-endpoint' }, [
E('code', {}, 'http://' + window.location.hostname + ':' + (data.status.api_port || '11434') + '/api/generate'),
E('span', { 'class': 'oll-api-method' }, 'POST'),
E('span', { 'class': 'oll-api-desc' }, _('Text generation'))
]),
E('div', { 'class': 'oll-api-endpoint' }, [
E('code', {}, 'http://' + window.location.hostname + ':' + (data.status.api_port || '11434') + '/api/tags'),
E('span', { 'class': 'oll-api-method get' }, 'GET'),
E('span', { 'class': 'oll-api-desc' }, _('List models'))
])
])
])
])
]);
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': 'oll-empty' }, [
E('div', { 'class': 'oll-empty-icon' }, '📦'),
E('div', { 'class': 'oll-empty-text' }, _('No models downloaded')),
E('div', { 'class': 'oll-empty-hint' }, [
_('Download a model with: '),
E('code', {}, 'ollamactl pull tinyllama')
])
]);
}
return E('div', { 'class': 'oll-models-list' },
models.map(function(model) {
return E('div', { 'class': 'oll-model-item' }, [
E('div', { 'class': 'oll-model-icon' }, '🦙'),
E('div', { 'class': 'oll-model-info' }, [
E('div', { 'class': 'oll-model-name' }, model.name),
E('div', { 'class': 'oll-model-meta' }, [
model.size > 0 ? E('span', { 'class': 'oll-model-size' }, formatBytes(model.size)) : null
].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 `
.ollama-dashboard {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #030712;
color: #f8fafc;
min-height: 100vh;
padding: 16px;
}
.oll-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0 20px;
border-bottom: 1px solid #334155;
margin-bottom: 20px;
}
.oll-logo {
display: flex;
align-items: center;
gap: 14px;
}
.oll-logo-icon {
width: 46px;
height: 46px;
background: linear-gradient(135deg, #f97316, #ea580c);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.oll-logo-text {
font-size: 24px;
font-weight: 700;
background: linear-gradient(135deg, #f97316, #ea580c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.oll-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;
}
.oll-status-badge.offline {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.oll-status-dot {
width: 10px;
height: 10px;
background: currentColor;
border-radius: 50%;
}
.oll-quick-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.oll-quick-stat {
background: #0f172a;
border: 1px solid #334155;
border-radius: 12px;
padding: 20px;
position: relative;
overflow: hidden;
}
.oll-quick-stat::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--stat-gradient);
}
.oll-quick-stat-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.oll-quick-stat-icon { font-size: 22px; }
.oll-quick-stat-label {
font-size: 11px;
text-transform: uppercase;
color: #64748b;
}
.oll-quick-stat-value {
font-size: 32px;
font-weight: 700;
background: var(--stat-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.oll-quick-stat-sub {
font-size: 11px;
color: #64748b;
margin-top: 6px;
}
.oll-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.oll-card {
background: #0f172a;
border: 1px solid #334155;
border-radius: 12px;
overflow: hidden;
}
.oll-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);
}
.oll-card-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
font-weight: 600;
}
.oll-card-title-icon { font-size: 20px; }
.oll-card-badge {
font-size: 12px;
padding: 5px 12px;
border-radius: 16px;
background: linear-gradient(135deg, #f97316, #ea580c);
color: white;
}
.oll-card-badge.running { background: linear-gradient(135deg, #10b981, #059669); }
.oll-card-badge.stopped { background: rgba(100, 116, 139, 0.3); color: #94a3b8; }
.oll-card-body { padding: 20px; }
.oll-service-info {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.oll-service-row {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: #030712;
border-radius: 8px;
}
.oll-service-label { color: #94a3b8; font-size: 13px; }
.oll-service-value { font-size: 13px; }
.oll-service-value.running { color: #10b981; }
.oll-service-value.stopped { color: #ef4444; }
.oll-service-controls {
display: flex;
gap: 10px;
}
.oll-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;
}
.oll-btn-success {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
}
.oll-btn-danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
}
.oll-btn-warning {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
}
.oll-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.oll-models-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.oll-model-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
background: #1e293b;
border-radius: 10px;
}
.oll-model-icon {
width: 44px;
height: 44px;
background: linear-gradient(135deg, #f97316, #ea580c);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.oll-model-name {
font-weight: 600;
margin-bottom: 4px;
}
.oll-model-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #94a3b8;
}
.oll-empty {
text-align: center;
padding: 40px 20px;
color: #64748b;
}
.oll-empty-icon { font-size: 48px; margin-bottom: 12px; }
.oll-empty-text { font-size: 16px; margin-bottom: 8px; }
.oll-empty-hint { font-size: 13px; }
.oll-empty-hint code {
background: #1e293b;
padding: 4px 8px;
border-radius: 4px;
}
.oll-api-info {
display: flex;
flex-direction: column;
gap: 10px;
}
.oll-api-endpoint {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #030712;
border-radius: 8px;
}
.oll-api-endpoint code {
font-size: 12px;
color: #f97316;
flex: 1;
}
.oll-api-method {
padding: 4px 8px;
background: #f97316;
color: #030712;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
}
.oll-api-method.get { background: #10b981; }
.oll-api-desc {
font-size: 12px;
color: #94a3b8;
min-width: 120px;
}
`;
}
});

View File

@ -0,0 +1,357 @@
'use strict';
'require view';
'require ui';
'require rpc';
var callModels = rpc.declare({
object: 'luci.ollama',
method: 'models',
expect: { models: [] }
});
var callModelPull = rpc.declare({
object: 'luci.ollama',
method: 'model_pull',
params: ['name'],
expect: { success: false }
});
var callModelRemove = rpc.declare({
object: 'luci.ollama',
method: 'model_remove',
params: ['name'],
expect: { success: false }
});
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];
}
var AVAILABLE_MODELS = [
{ name: 'tinyllama', size: '637 MB', description: 'Ultra-lightweight, fast responses' },
{ name: 'phi', size: '1.6 GB', description: 'Microsoft Phi-2 - Small but capable' },
{ name: 'gemma:2b', size: '1.4 GB', description: 'Google Gemma 2B - Efficient and modern' },
{ name: 'mistral', size: '4.1 GB', description: 'High quality general assistant' },
{ name: 'llama2', size: '3.8 GB', description: 'Meta LLaMA 2 7B - Popular general model' },
{ name: 'codellama', size: '3.8 GB', description: 'Code LLaMA - Specialized for coding' },
{ name: 'neural-chat', size: '4.1 GB', description: 'Intel Neural Chat - Optimized' },
{ name: 'starling-lm', size: '4.1 GB', description: 'Starling - Strong reasoning' }
];
return view.extend({
title: _('Ollama Models'),
load: function() {
return callModels().then(function(result) {
return Array.isArray(result) ? result : [];
});
},
render: function(models) {
var self = this;
var container = E('div', { 'class': 'ollama-models' }, [
E('style', {}, this.getCSS()),
// Header
E('div', { 'class': 'oll-page-header' }, [
E('h2', {}, [
E('span', { 'class': 'oll-page-icon' }, '🦙'),
_('Model Management')
]),
E('p', { 'class': 'oll-page-desc' }, _('Download and manage Ollama models'))
]),
// Installed Models
E('div', { 'class': 'oll-section' }, [
E('h3', { 'class': 'oll-section-title' }, _('Downloaded Models')),
E('div', { 'class': 'oll-models-grid', 'id': 'installed-models' },
models.length > 0 ?
models.map(function(model) {
return self.renderModelCard(model, true);
}) :
[E('div', { 'class': 'oll-empty-state' }, [
E('span', { 'class': 'oll-empty-icon' }, '📦'),
E('p', {}, _('No models downloaded yet')),
E('p', { 'class': 'oll-empty-hint' }, _('Download a model from the list below'))
])]
)
]),
// Available Models
E('div', { 'class': 'oll-section' }, [
E('h3', { 'class': 'oll-section-title' }, _('Available Models')),
E('div', { 'class': 'oll-models-grid' },
AVAILABLE_MODELS.map(function(model) {
var installed = models.some(function(m) {
return m.name === model.name || m.name.startsWith(model.name + ':');
});
return self.renderAvailableCard(model, installed);
})
)
]),
// Custom Model Pull
E('div', { 'class': 'oll-section' }, [
E('h3', { 'class': 'oll-section-title' }, _('Pull Custom Model')),
E('div', { 'class': 'oll-custom-pull' }, [
E('input', {
'type': 'text',
'id': 'custom-model-name',
'class': 'oll-input',
'placeholder': 'e.g., llama2:13b or mistral:7b-instruct'
}),
E('button', {
'class': 'oll-btn oll-btn-primary',
'click': function() { self.handleCustomPull(); }
}, [E('span', {}, '⬇️'), _('Pull Model')])
]),
E('p', { 'class': 'oll-hint' }, [
_('Browse more models at: '),
E('a', { 'href': 'https://ollama.com/library', 'target': '_blank' }, 'ollama.com/library')
])
])
]);
return container;
},
renderModelCard: function(model, canRemove) {
var self = this;
return E('div', { 'class': 'oll-model-card installed' }, [
E('div', { 'class': 'oll-model-card-icon' }, '🦙'),
E('div', { 'class': 'oll-model-card-info' }, [
E('div', { 'class': 'oll-model-card-name' }, model.name),
E('div', { 'class': 'oll-model-card-meta' }, [
model.size > 0 ? formatBytes(model.size) : ''
])
]),
canRemove ? E('button', {
'class': 'oll-btn oll-btn-sm oll-btn-danger',
'click': function() { self.handleRemove(model.name); }
}, '🗑️') : null
]);
},
renderAvailableCard: function(model, installed) {
var self = this;
return E('div', { 'class': 'oll-model-card available' + (installed ? ' installed' : '') }, [
E('div', { 'class': 'oll-model-card-icon' }, installed ? '✅' : '🦙'),
E('div', { 'class': 'oll-model-card-info' }, [
E('div', { 'class': 'oll-model-card-name' }, model.name),
E('div', { 'class': 'oll-model-card-desc' }, model.description),
E('div', { 'class': 'oll-model-card-meta' }, model.size)
]),
!installed ? E('button', {
'class': 'oll-btn oll-btn-sm oll-btn-primary',
'click': function() { self.handlePull(model.name); }
}, [E('span', {}, '⬇️'), _('Pull')]) :
E('span', { 'class': 'oll-installed-badge' }, _('Installed'))
]);
},
handlePull: function(name) {
var self = this;
ui.showModal(_('Pulling Model'), [
E('p', {}, _('Downloading %s... This may take several minutes.').format(name)),
E('div', { 'class': 'spinning' })
]);
callModelPull(name).then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Model %s downloaded successfully').format(name)), 'success');
window.location.reload();
} else {
ui.addNotification(null, E('p', result.error || _('Failed to pull model')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', err.message), 'error');
});
},
handleRemove: function(name) {
var self = this;
if (!confirm(_('Remove model %s?').format(name))) return;
ui.showModal(_('Removing Model'), [
E('p', {}, _('Removing %s...').format(name)),
E('div', { 'class': 'spinning' })
]);
callModelRemove(name).then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Model removed')), 'success');
window.location.reload();
} else {
ui.addNotification(null, E('p', result.error || _('Failed to remove')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', err.message), 'error');
});
},
handleCustomPull: function() {
var input = document.getElementById('custom-model-name');
var name = input.value.trim();
if (!name) {
ui.addNotification(null, E('p', _('Enter a model name')), 'error');
return;
}
this.handlePull(name);
},
getCSS: function() {
return `
.ollama-models {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #030712;
color: #f8fafc;
min-height: 100vh;
padding: 20px;
}
.oll-page-header {
margin-bottom: 30px;
}
.oll-page-header h2 {
display: flex;
align-items: center;
gap: 12px;
font-size: 24px;
margin: 0 0 8px;
}
.oll-page-icon { font-size: 28px; }
.oll-page-desc {
color: #94a3b8;
margin: 0;
}
.oll-section {
margin-bottom: 30px;
}
.oll-section-title {
font-size: 16px;
color: #f97316;
margin: 0 0 16px;
padding-bottom: 8px;
border-bottom: 1px solid #334155;
}
.oll-models-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.oll-model-card {
background: #0f172a;
border: 1px solid #334155;
border-radius: 12px;
padding: 16px;
display: flex;
align-items: center;
gap: 14px;
}
.oll-model-card.installed {
border-color: rgba(16, 185, 129, 0.3);
}
.oll-model-card-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #f97316, #ea580c);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.oll-model-card-info {
flex: 1;
min-width: 0;
}
.oll-model-card-name {
font-weight: 600;
margin-bottom: 4px;
}
.oll-model-card-desc {
font-size: 12px;
color: #94a3b8;
margin-bottom: 4px;
}
.oll-model-card-meta {
font-size: 11px;
color: #64748b;
}
.oll-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;
transition: opacity 0.2s;
}
.oll-btn:hover { opacity: 0.9; }
.oll-btn-sm { padding: 6px 12px; font-size: 12px; }
.oll-btn-primary {
background: linear-gradient(135deg, #f97316, #ea580c);
color: white;
}
.oll-btn-danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
}
.oll-installed-badge {
font-size: 11px;
color: #10b981;
padding: 4px 10px;
background: rgba(16, 185, 129, 0.15);
border-radius: 12px;
}
.oll-empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: #64748b;
}
.oll-empty-icon { font-size: 48px; display: block; margin-bottom: 12px; }
.oll-empty-hint { font-size: 13px; color: #475569; }
.oll-custom-pull {
display: flex;
gap: 12px;
max-width: 500px;
}
.oll-input {
flex: 1;
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
padding: 10px 14px;
color: #f8fafc;
font-size: 14px;
}
.oll-input:focus {
outline: none;
border-color: #f97316;
}
.oll-hint {
font-size: 13px;
color: #64748b;
margin-top: 12px;
}
.oll-hint a {
color: #f97316;
text-decoration: none;
}
.oll-hint a:hover { text-decoration: underline; }
`;
}
});

View File

@ -0,0 +1,56 @@
'use strict';
'require view';
'require form';
'require uci';
return view.extend({
title: _('Ollama Settings'),
load: function() {
return uci.load('ollama');
},
render: function() {
var m, s, o;
m = new form.Map('ollama', _('Ollama Configuration'),
_('Configure Ollama LLM service settings'));
s = m.section(form.TypedSection, 'main', _('General Settings'));
s.anonymous = true;
o = s.option(form.Flag, 'enabled', _('Enable Service'));
o.rmempty = false;
o = s.option(form.Value, 'api_port', _('API Port'));
o.datatype = 'port';
o.default = '11434';
o.rmempty = false;
o = s.option(form.Value, 'api_host', _('API Host'));
o.default = '0.0.0.0';
o.rmempty = false;
o.description = _('Use 0.0.0.0 to listen on all interfaces');
o = s.option(form.Value, 'data_path', _('Data Path'));
o.default = '/srv/ollama';
o.rmempty = false;
o.description = _('Directory for storing models and data');
o = s.option(form.Value, 'memory_limit', _('Memory Limit'));
o.default = '2g';
o.rmempty = false;
o.description = _('Container memory limit (e.g., 2g, 4g)');
// Docker Settings
s = m.section(form.TypedSection, 'docker', _('Container Settings'));
s.anonymous = true;
o = s.option(form.Value, 'image', _('Docker Image'));
o.default = 'ollama/ollama:latest';
o.rmempty = false;
o.description = _('Ollama Docker image to use');
return m.render();
}
});

View File

@ -0,0 +1,368 @@
#!/bin/sh
# RPCD backend for Ollama LuCI integration
# Copyright (C) 2025 CyberMind.fr
. /lib/functions.sh
CONFIG="ollama"
OLLAMA_CTL="/usr/sbin/ollamactl"
# Load UCI config
load_config() {
config_load "$CONFIG"
config_get API_PORT main api_port "11434"
config_get DATA_PATH main data_path "/srv/ollama"
config_get MEMORY_LIMIT main memory_limit "2g"
}
# Detect container runtime
detect_runtime() {
if command -v podman >/dev/null 2>&1; then
echo "podman"
elif command -v docker >/dev/null 2>&1; then
echo "docker"
else
echo ""
fi
}
# Check if Ollama is running
is_running() {
local rt=$(detect_runtime)
[ -z "$rt" ] && return 1
$rt ps --format '{{.Names}}' 2>/dev/null | grep -q "^ollama$"
}
# Get service status
get_status() {
load_config
local running="false"
local uptime=0
local rt=$(detect_runtime)
if is_running; then
running="true"
# Get container uptime
if [ -n "$rt" ]; then
local status=$($rt ps --filter "name=ollama" --format '{{.Status}}' 2>/dev/null | head -1)
if [ -n "$status" ]; then
case "$status" in
*minute*) uptime=$(($(echo "$status" | grep -oE '[0-9]+' | head -1) * 60)) ;;
*hour*) uptime=$(($(echo "$status" | grep -oE '[0-9]+' | head -1) * 3600)) ;;
*second*) uptime=$(echo "$status" | grep -oE '[0-9]+' | head -1) ;;
*) uptime=0 ;;
esac
fi
fi
fi
local enabled="false"
[ "$(uci -q get ${CONFIG}.main.enabled)" = "1" ] && enabled="true"
cat <<EOF
{
"running": $running,
"enabled": $enabled,
"uptime": $uptime,
"api_port": $API_PORT,
"memory_limit": "$MEMORY_LIMIT",
"data_path": "$DATA_PATH",
"runtime": "${rt:-none}"
}
EOF
}
# Get installed models from Ollama API
get_models() {
load_config
local tmpfile="/tmp/ollama_models_$$"
local first=1
echo '{"models":['
if is_running; then
wget -q -O "$tmpfile" "http://127.0.0.1:$API_PORT/api/tags" 2>/dev/null
if [ -f "$tmpfile" ] && [ -s "$tmpfile" ]; then
local i=0
while [ $i -lt 50 ]; do
local model_name=$(jsonfilter -i "$tmpfile" -e "@.models[$i].name" 2>/dev/null)
[ -z "$model_name" ] && break
local model_size=$(jsonfilter -i "$tmpfile" -e "@.models[$i].size" 2>/dev/null)
[ -z "$model_size" ] && model_size=0
local modified=$(jsonfilter -i "$tmpfile" -e "@.models[$i].modified_at" 2>/dev/null)
[ $first -eq 0 ] && echo ","
first=0
cat <<EOF
{
"name": "$model_name",
"size": $model_size,
"modified": "$modified"
}
EOF
i=$((i + 1))
done
fi
rm -f "$tmpfile"
fi
echo ']}'
}
# Get configuration
get_config() {
load_config
cat <<EOF
{
"api_port": $API_PORT,
"data_path": "$DATA_PATH",
"memory_limit": "$MEMORY_LIMIT"
}
EOF
}
# Health check
get_health() {
load_config
local healthy="false"
local api_status="unknown"
if is_running; then
if wget -q -O /dev/null "http://127.0.0.1:$API_PORT" 2>/dev/null; then
healthy="true"
api_status="ok"
else
api_status="unhealthy"
fi
else
api_status="stopped"
fi
cat <<EOF
{
"healthy": $healthy,
"api_status": "$api_status"
}
EOF
}
# Start service
do_start() {
if is_running; then
echo '{"success":false,"error":"Already running"}'
return
fi
/etc/init.d/ollama start >/dev/null 2>&1
sleep 3
if is_running; then
echo '{"success":true}'
else
echo '{"success":false,"error":"Failed to start"}'
fi
}
# Stop service
do_stop() {
/etc/init.d/ollama stop >/dev/null 2>&1
sleep 1
if ! is_running; then
echo '{"success":true}'
else
echo '{"success":false,"error":"Failed to stop"}'
fi
}
# Restart service
do_restart() {
/etc/init.d/ollama restart >/dev/null 2>&1
sleep 3
if is_running; then
echo '{"success":true}'
else
echo '{"success":false,"error":"Failed to restart"}'
fi
}
# Pull model
do_model_pull() {
local name="$1"
[ -z "$name" ] && { echo '{"success":false,"error":"Model name required"}'; return; }
if ! is_running; then
echo '{"success":false,"error":"Ollama not running"}'
return
fi
local rt=$(detect_runtime)
local output=$($rt exec ollama ollama pull "$name" 2>&1)
local ret=$?
if [ $ret -eq 0 ]; then
echo '{"success":true}'
else
local error=$(echo "$output" | tail -1 | sed 's/"/\\"/g')
echo "{\"success\":false,\"error\":\"$error\"}"
fi
}
# Remove model
do_model_remove() {
local name="$1"
[ -z "$name" ] && { echo '{"success":false,"error":"Model name required"}'; return; }
if ! is_running; then
echo '{"success":false,"error":"Ollama not running"}'
return
fi
local rt=$(detect_runtime)
local output=$($rt exec ollama ollama rm "$name" 2>&1)
local ret=$?
if [ $ret -eq 0 ]; then
echo '{"success":true}'
else
local error=$(echo "$output" | tail -1 | sed 's/"/\\"/g')
echo "{\"success\":false,\"error\":\"$error\"}"
fi
}
# Chat completion (proxy to Ollama API)
do_chat() {
load_config
local model="$1"
local message="$2"
if ! is_running; then
echo '{"response":"","error":"Ollama is not running. Start with: /etc/init.d/ollama start"}'
return
fi
[ -z "$model" ] && { echo '{"response":"","error":"Model not specified"}'; return; }
[ -z "$message" ] && { echo '{"response":"","error":"Message not provided"}'; return; }
# Build request body for Ollama /api/chat endpoint
local request_body="{\"model\":\"$model\",\"messages\":[{\"role\":\"user\",\"content\":\"$message\"}],\"stream\":false}"
logger -t ollama-chat "Request to model: $model"
local tmpfile="/tmp/ollama_chat_$$"
if command -v curl >/dev/null 2>&1; then
curl -s -X POST "http://127.0.0.1:$API_PORT/api/chat" \
-H "Content-Type: application/json" \
-d "$request_body" \
-o "$tmpfile" 2>/dev/null
else
wget -q -O "$tmpfile" --post-data "$request_body" \
--header="Content-Type: application/json" \
"http://127.0.0.1:$API_PORT/api/chat" 2>/dev/null
fi
if [ -f "$tmpfile" ] && [ -s "$tmpfile" ]; then
local content=$(jsonfilter -i "$tmpfile" -e '@.message.content' 2>/dev/null)
local error=$(jsonfilter -i "$tmpfile" -e '@.error' 2>/dev/null)
if [ -n "$error" ]; then
error=$(echo "$error" | sed 's/"/\\"/g' | tr '\n' ' ')
echo "{\"response\":\"\",\"error\":\"$error\"}"
elif [ -n "$content" ]; then
content=$(printf '%s' "$content" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk '{printf "%s\\n", $0}' | sed 's/\\n$//')
echo "{\"response\":\"$content\"}"
else
echo '{"response":"","error":"Empty response from Ollama API"}'
fi
rm -f "$tmpfile"
else
rm -f "$tmpfile"
echo '{"response":"","error":"API request failed"}'
fi
}
# Generate completion
do_generate() {
load_config
local model="$1"
local prompt="$2"
if ! is_running; then
echo '{"text":"","error":"Ollama not running"}'
return
fi
local response=$(wget -q -O - --post-data "{\"model\":\"$model\",\"prompt\":\"$prompt\",\"stream\":false}" \
--header="Content-Type: application/json" \
"http://127.0.0.1:$API_PORT/api/generate" 2>/dev/null)
if [ -n "$response" ]; then
local text=$(echo "$response" | jsonfilter -e '@.response' 2>/dev/null)
text=$(printf '%s' "$text" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk '{printf "%s\\n", $0}' | sed 's/\\n$//')
echo "{\"text\":\"$text\"}"
else
echo '{"text":"","error":"API request failed"}'
fi
}
# UBUS method list
case "$1" in
list)
cat <<'EOF'
{
"status": {},
"models": {},
"config": {},
"health": {},
"start": {},
"stop": {},
"restart": {},
"model_pull": {"name": "string"},
"model_remove": {"name": "string"},
"chat": {"model": "string", "message": "string"},
"generate": {"model": "string", "prompt": "string"}
}
EOF
;;
call)
case "$2" in
status) get_status ;;
models) get_models ;;
config) get_config ;;
health) get_health ;;
start) do_start ;;
stop) do_stop ;;
restart) do_restart ;;
model_pull)
read -r input
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
do_model_pull "$name"
;;
model_remove)
read -r input
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
do_model_remove "$name"
;;
chat)
read -r input
model=$(echo "$input" | jsonfilter -e '@.model' 2>/dev/null)
message=$(echo "$input" | jsonfilter -e '@.message' 2>/dev/null)
do_chat "$model" "$message"
;;
generate)
read -r input
model=$(echo "$input" | jsonfilter -e '@.model' 2>/dev/null)
prompt=$(echo "$input" | jsonfilter -e '@.prompt' 2>/dev/null)
do_generate "$model" "$prompt"
;;
*) echo '{"error":"Unknown method"}' ;;
esac
;;
esac

View File

@ -0,0 +1,56 @@
{
"admin/secubox/services/ollama": {
"title": "Ollama",
"order": 51,
"action": {
"type": "firstchild"
},
"depends": {
"acl": ["luci-app-ollama"]
}
},
"admin/secubox/services/ollama/dashboard": {
"title": "Dashboard",
"order": 10,
"action": {
"type": "view",
"path": "ollama/dashboard"
},
"depends": {
"acl": ["luci-app-ollama"]
}
},
"admin/secubox/services/ollama/models": {
"title": "Models",
"order": 20,
"action": {
"type": "view",
"path": "ollama/models"
},
"depends": {
"acl": ["luci-app-ollama"]
}
},
"admin/secubox/services/ollama/chat": {
"title": "Chat",
"order": 30,
"action": {
"type": "view",
"path": "ollama/chat"
},
"depends": {
"acl": ["luci-app-ollama"]
}
},
"admin/secubox/services/ollama/settings": {
"title": "Settings",
"order": 90,
"action": {
"type": "view",
"path": "ollama/settings"
},
"depends": {
"acl": ["luci-app-ollama"]
}
}
}

View File

@ -0,0 +1,36 @@
{
"luci-app-ollama": {
"description": "Grant access to LuCI Ollama Dashboard",
"read": {
"ubus": {
"luci.ollama": [
"status",
"models",
"config",
"health"
],
"system": [ "info", "board" ],
"file": [ "read", "stat", "exec" ]
},
"uci": [ "ollama" ],
"file": {
"/etc/config/ollama": [ "read" ],
"/srv/ollama/*": [ "read" ]
}
},
"write": {
"ubus": {
"luci.ollama": [
"start",
"stop",
"restart",
"model_pull",
"model_remove",
"chat",
"generate"
]
},
"uci": [ "ollama" ]
}
}
}