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;
|
|
}
|
|
`;
|
|
}
|
|
});
|