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:
parent
1cce649751
commit
48deeccb99
@ -103,7 +103,9 @@
|
|||||||
"Bash(./build.sh:*)",
|
"Bash(./build.sh:*)",
|
||||||
"Bash(cd /home/reepost/CyberMindStudio/_files/secubox-openwrt/secubox-tools/sdk ./scripts/feeds update secubox)",
|
"Bash(cd /home/reepost/CyberMindStudio/_files/secubox-openwrt/secubox-tools/sdk ./scripts/feeds update secubox)",
|
||||||
"Bash(./staging_dir/host/bin/ipkg-build:*)",
|
"Bash(./staging_dir/host/bin/ipkg-build:*)",
|
||||||
"Bash(./scripts/ipkg-build:*)"
|
"Bash(./scripts/ipkg-build:*)",
|
||||||
|
"WebFetch(domain:hub.docker.com)",
|
||||||
|
"WebFetch(domain:localai.io)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
package/secubox/luci-app-ollama/Makefile
Normal file
33
package/secubox/luci-app-ollama/Makefile
Normal 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
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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; }
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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
|
||||||
@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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" ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user