From 48deeccb996480fa59e14e6c8ac611f340774467 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Wed, 21 Jan 2026 19:02:12 +0100 Subject: [PATCH] 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 --- .claude/settings.local.json | 4 +- package/secubox/luci-app-ollama/Makefile | 33 + .../luci-static/resources/view/ollama/chat.js | 397 ++++++++++++ .../resources/view/ollama/dashboard.js | 577 ++++++++++++++++++ .../resources/view/ollama/models.js | 357 +++++++++++ .../resources/view/ollama/settings.js | 56 ++ .../root/usr/libexec/rpcd/luci.ollama | 368 +++++++++++ .../share/luci/menu.d/luci-app-ollama.json | 56 ++ .../usr/share/rpcd/acl.d/luci-app-ollama.json | 36 ++ 9 files changed, 1883 insertions(+), 1 deletion(-) create mode 100644 package/secubox/luci-app-ollama/Makefile create mode 100644 package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/chat.js create mode 100644 package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/dashboard.js create mode 100644 package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/models.js create mode 100644 package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/settings.js create mode 100644 package/secubox/luci-app-ollama/root/usr/libexec/rpcd/luci.ollama create mode 100644 package/secubox/luci-app-ollama/root/usr/share/luci/menu.d/luci-app-ollama.json create mode 100644 package/secubox/luci-app-ollama/root/usr/share/rpcd/acl.d/luci-app-ollama.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e263c84..0127083 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/package/secubox/luci-app-ollama/Makefile b/package/secubox/luci-app-ollama/Makefile new file mode 100644 index 0000000..ed3f793 --- /dev/null +++ b/package/secubox/luci-app-ollama/Makefile @@ -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 + +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 diff --git a/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/chat.js b/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/chat.js new file mode 100644 index 0000000..703dc07 --- /dev/null +++ b/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/chat.js @@ -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; + } + `; + } +}); diff --git a/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/dashboard.js b/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/dashboard.js new file mode 100644 index 0000000..16b4f40 --- /dev/null +++ b/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/dashboard.js @@ -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; + } + `; + } +}); diff --git a/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/models.js b/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/models.js new file mode 100644 index 0000000..ba6f8c5 --- /dev/null +++ b/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/models.js @@ -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; } + `; + } +}); diff --git a/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/settings.js b/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/settings.js new file mode 100644 index 0000000..3d9d155 --- /dev/null +++ b/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/settings.js @@ -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(); + } +}); diff --git a/package/secubox/luci-app-ollama/root/usr/libexec/rpcd/luci.ollama b/package/secubox/luci-app-ollama/root/usr/libexec/rpcd/luci.ollama new file mode 100644 index 0000000..19b8715 --- /dev/null +++ b/package/secubox/luci-app-ollama/root/usr/libexec/rpcd/luci.ollama @@ -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 </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 </dev/null; then + healthy="true" + api_status="ok" + else + api_status="unhealthy" + fi + else + api_status="stopped" + fi + + cat </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 diff --git a/package/secubox/luci-app-ollama/root/usr/share/luci/menu.d/luci-app-ollama.json b/package/secubox/luci-app-ollama/root/usr/share/luci/menu.d/luci-app-ollama.json new file mode 100644 index 0000000..aa12784 --- /dev/null +++ b/package/secubox/luci-app-ollama/root/usr/share/luci/menu.d/luci-app-ollama.json @@ -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"] + } + } +} diff --git a/package/secubox/luci-app-ollama/root/usr/share/rpcd/acl.d/luci-app-ollama.json b/package/secubox/luci-app-ollama/root/usr/share/rpcd/acl.d/luci-app-ollama.json new file mode 100644 index 0000000..34265f3 --- /dev/null +++ b/package/secubox/luci-app-ollama/root/usr/share/rpcd/acl.d/luci-app-ollama.json @@ -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" ] + } + } +}