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>
398 lines
10 KiB
JavaScript
398 lines
10 KiB
JavaScript
'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;
|
||
}
|
||
`;
|
||
}
|
||
});
|