feat(openclaw): Add LuCI web interface for OpenClaw AI assistant
Complete LuCI app with: - Chat view with real-time AI conversation - Settings view for provider/model/API key configuration - Integrations view for Telegram/Discord/Slack/Email/Calendar - RPCD backend handling all ubus calls - Support for Anthropic, OpenAI, and Ollama providers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a8dc5f58fe
commit
56f5d8c61f
35
package/secubox/luci-app-openclaw/Makefile
Normal file
35
package/secubox/luci-app-openclaw/Makefile
Normal file
@ -0,0 +1,35 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# LuCI interface for OpenClaw Personal AI Assistant
|
||||
#
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
LUCI_TITLE:=LuCI OpenClaw AI Assistant
|
||||
LUCI_DESCRIPTION:=Web interface for OpenClaw personal AI with Claude/OpenAI/Ollama support
|
||||
LUCI_DEPENDS:=+luci-base +secubox-app-openclaw
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
PKG_NAME:=luci-app-openclaw
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=1
|
||||
PKG_LICENSE:=MIT
|
||||
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
define Package/luci-app-openclaw/install
|
||||
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
|
||||
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-openclaw.json $(1)/usr/share/luci/menu.d/
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
|
||||
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-openclaw.json $(1)/usr/share/rpcd/acl.d/
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
|
||||
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.openclaw $(1)/usr/libexec/rpcd/
|
||||
|
||||
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/openclaw
|
||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/openclaw/*.js $(1)/www/luci-static/resources/view/openclaw/
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,luci-app-openclaw))
|
||||
@ -0,0 +1,238 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
|
||||
var callOpenClawStatus = rpc.declare({
|
||||
object: 'luci.openclaw',
|
||||
method: 'status',
|
||||
expect: { }
|
||||
});
|
||||
|
||||
var callOpenClawChat = rpc.declare({
|
||||
object: 'luci.openclaw',
|
||||
method: 'chat',
|
||||
params: ['message'],
|
||||
expect: { }
|
||||
});
|
||||
|
||||
var callOpenClawHistory = rpc.declare({
|
||||
object: 'luci.openclaw',
|
||||
method: 'get_history',
|
||||
expect: { }
|
||||
});
|
||||
|
||||
var callOpenClawClearHistory = rpc.declare({
|
||||
object: 'luci.openclaw',
|
||||
method: 'clear_history',
|
||||
expect: { }
|
||||
});
|
||||
|
||||
return view.extend({
|
||||
chatContainer: null,
|
||||
messageInput: null,
|
||||
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
callOpenClawStatus(),
|
||||
callOpenClawHistory()
|
||||
]);
|
||||
},
|
||||
|
||||
addMessage: function(content, isUser) {
|
||||
var msgDiv = E('div', {
|
||||
'class': 'openclaw-message ' + (isUser ? 'user-message' : 'ai-message')
|
||||
});
|
||||
|
||||
if (isUser) {
|
||||
msgDiv.appendChild(E('div', { 'class': 'message-avatar user-avatar' }, 'You'));
|
||||
} else {
|
||||
msgDiv.appendChild(E('div', { 'class': 'message-avatar ai-avatar' }, 'AI'));
|
||||
}
|
||||
|
||||
var contentDiv = E('div', { 'class': 'message-content' });
|
||||
contentDiv.innerHTML = this.formatMessage(content);
|
||||
msgDiv.appendChild(contentDiv);
|
||||
|
||||
this.chatContainer.appendChild(msgDiv);
|
||||
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
|
||||
},
|
||||
|
||||
formatMessage: function(text) {
|
||||
// Basic markdown-like formatting
|
||||
text = text.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
|
||||
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
text = text.replace(/\n/g, '<br>');
|
||||
return text;
|
||||
},
|
||||
|
||||
sendMessage: function() {
|
||||
var self = this;
|
||||
var message = this.messageInput.value.trim();
|
||||
|
||||
if (!message)
|
||||
return;
|
||||
|
||||
this.messageInput.value = '';
|
||||
this.addMessage(message, true);
|
||||
|
||||
// Show typing indicator
|
||||
var typingDiv = E('div', { 'class': 'openclaw-typing' }, 'AI is thinking...');
|
||||
this.chatContainer.appendChild(typingDiv);
|
||||
|
||||
callOpenClawChat(message).then(function(response) {
|
||||
typingDiv.remove();
|
||||
|
||||
if (response.error) {
|
||||
self.addMessage('Error: ' + response.error, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse response based on provider
|
||||
var aiResponse = '';
|
||||
if (response.content && response.content[0]) {
|
||||
// Anthropic format
|
||||
aiResponse = response.content[0].text || '';
|
||||
} else if (response.choices && response.choices[0]) {
|
||||
// OpenAI format
|
||||
aiResponse = response.choices[0].message.content || '';
|
||||
} else if (response.message && response.message.content) {
|
||||
// Ollama format
|
||||
aiResponse = response.message.content || '';
|
||||
} else {
|
||||
aiResponse = JSON.stringify(response);
|
||||
}
|
||||
|
||||
self.addMessage(aiResponse, false);
|
||||
}).catch(function(err) {
|
||||
typingDiv.remove();
|
||||
self.addMessage('Error: ' + err.message, false);
|
||||
});
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var self = this;
|
||||
var status = data[0] || {};
|
||||
var history = data[1] || {};
|
||||
|
||||
var styleEl = E('style', {}, [
|
||||
'.openclaw-container { max-width: 900px; margin: 0 auto; }',
|
||||
'.openclaw-status { padding: 10px; border-radius: 8px; margin-bottom: 15px; }',
|
||||
'.openclaw-status.online { background: #d4edda; border: 1px solid #28a745; }',
|
||||
'.openclaw-status.offline { background: #f8d7da; border: 1px solid #dc3545; }',
|
||||
'.openclaw-chat { border: 1px solid #ddd; border-radius: 8px; height: 500px; overflow-y: auto; padding: 15px; background: #fafafa; margin-bottom: 15px; }',
|
||||
'.openclaw-message { display: flex; margin-bottom: 15px; }',
|
||||
'.user-message { flex-direction: row-reverse; }',
|
||||
'.message-avatar { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 12px; flex-shrink: 0; }',
|
||||
'.user-avatar { background: #007bff; color: white; margin-left: 10px; }',
|
||||
'.ai-avatar { background: #6c757d; color: white; margin-right: 10px; }',
|
||||
'.message-content { max-width: 70%; padding: 12px 16px; border-radius: 18px; line-height: 1.5; }',
|
||||
'.user-message .message-content { background: #007bff; color: white; border-bottom-right-radius: 4px; }',
|
||||
'.ai-message .message-content { background: #e9ecef; color: #333; border-bottom-left-radius: 4px; }',
|
||||
'.message-content pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 4px; overflow-x: auto; margin: 8px 0; }',
|
||||
'.message-content code { background: rgba(0,0,0,0.1); padding: 2px 4px; border-radius: 3px; font-family: monospace; }',
|
||||
'.openclaw-input { display: flex; gap: 10px; }',
|
||||
'.openclaw-input textarea { flex: 1; min-height: 60px; padding: 12px; border: 1px solid #ddd; border-radius: 8px; resize: vertical; font-size: 14px; }',
|
||||
'.openclaw-input button { padding: 12px 24px; background: #007bff; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; }',
|
||||
'.openclaw-input button:hover { background: #0056b3; }',
|
||||
'.openclaw-typing { padding: 12px 16px; background: #e9ecef; border-radius: 18px; color: #666; font-style: italic; display: inline-block; margin-left: 50px; }',
|
||||
'.openclaw-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }',
|
||||
'.openclaw-header h2 { margin: 0; }',
|
||||
'.btn-clear { background: #dc3545; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; }',
|
||||
'.btn-clear:hover { background: #c82333; }'
|
||||
]);
|
||||
|
||||
var statusClass = (status.running === '1') ? 'online' : 'offline';
|
||||
var statusText = (status.running === '1') ? 'Connected' : 'Offline';
|
||||
|
||||
this.chatContainer = E('div', { 'class': 'openclaw-chat' });
|
||||
this.messageInput = E('textarea', {
|
||||
'placeholder': 'Type your message here...',
|
||||
'id': 'openclaw-message-input'
|
||||
});
|
||||
|
||||
// Add keypress handler for Enter key
|
||||
this.messageInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
self.sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
var sendButton = E('button', {
|
||||
'click': function() { self.sendMessage(); }
|
||||
}, 'Send');
|
||||
|
||||
var clearButton = E('button', {
|
||||
'class': 'btn-clear',
|
||||
'click': function() {
|
||||
ui.showModal('Clear History', [
|
||||
E('p', {}, 'Are you sure you want to clear all chat history?'),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.hideModal
|
||||
}, 'Cancel'),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-negative',
|
||||
'click': function() {
|
||||
callOpenClawClearHistory().then(function() {
|
||||
self.chatContainer.innerHTML = '';
|
||||
ui.hideModal();
|
||||
});
|
||||
}
|
||||
}, 'Clear')
|
||||
])
|
||||
]);
|
||||
}
|
||||
}, 'Clear History');
|
||||
|
||||
// Load existing history
|
||||
if (history.history && history.history.length > 0) {
|
||||
history.history.forEach(function(item) {
|
||||
if (item.user) {
|
||||
self.addMessage(item.user, true);
|
||||
}
|
||||
if (item.response) {
|
||||
var aiResponse = '';
|
||||
if (item.response.content && item.response.content[0]) {
|
||||
aiResponse = item.response.content[0].text || '';
|
||||
} else if (item.response.choices && item.response.choices[0]) {
|
||||
aiResponse = item.response.choices[0].message.content || '';
|
||||
} else if (item.response.message) {
|
||||
aiResponse = item.response.message.content || '';
|
||||
}
|
||||
if (aiResponse) {
|
||||
self.addMessage(aiResponse, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return E('div', { 'class': 'openclaw-container' }, [
|
||||
styleEl,
|
||||
E('div', { 'class': 'openclaw-header' }, [
|
||||
E('h2', {}, 'OpenClaw AI Chat'),
|
||||
clearButton
|
||||
]),
|
||||
E('div', { 'class': 'openclaw-status ' + statusClass }, [
|
||||
E('strong', {}, 'Status: '),
|
||||
statusText,
|
||||
(status.running === '1') ? ' (Port ' + (status.port || '3333') + ')' : ''
|
||||
]),
|
||||
this.chatContainer,
|
||||
E('div', { 'class': 'openclaw-input' }, [
|
||||
this.messageInput,
|
||||
sendButton
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return uci.load('openclaw');
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var m, s, o;
|
||||
|
||||
m = new form.Map('openclaw', 'OpenClaw Integrations',
|
||||
'Configure external integrations for your AI assistant. ' +
|
||||
'These integrations allow OpenClaw to interact with messaging platforms, email, and calendars.');
|
||||
|
||||
// Telegram Integration
|
||||
s = m.section(form.TypedSection, 'integration', 'Telegram Bot');
|
||||
s.anonymous = true;
|
||||
s.filter = function(section_id) {
|
||||
return section_id === 'telegram';
|
||||
};
|
||||
|
||||
o = s.option(form.Flag, 'enabled', 'Enable');
|
||||
o.rmempty = false;
|
||||
o.default = '0';
|
||||
|
||||
o = s.option(form.Value, 'bot_token', 'Bot Token');
|
||||
o.password = true;
|
||||
o.placeholder = 'Enter Telegram bot token from @BotFather';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
// Discord Integration
|
||||
s = m.section(form.TypedSection, 'integration', 'Discord Bot');
|
||||
s.anonymous = true;
|
||||
s.filter = function(section_id) {
|
||||
return section_id === 'discord';
|
||||
};
|
||||
|
||||
o = s.option(form.Flag, 'enabled', 'Enable');
|
||||
o.rmempty = false;
|
||||
o.default = '0';
|
||||
|
||||
o = s.option(form.Value, 'bot_token', 'Bot Token');
|
||||
o.password = true;
|
||||
o.placeholder = 'Enter Discord bot token';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
// Slack Integration
|
||||
s = m.section(form.TypedSection, 'integration', 'Slack Bot');
|
||||
s.anonymous = true;
|
||||
s.filter = function(section_id) {
|
||||
return section_id === 'slack';
|
||||
};
|
||||
|
||||
o = s.option(form.Flag, 'enabled', 'Enable');
|
||||
o.rmempty = false;
|
||||
o.default = '0';
|
||||
|
||||
o = s.option(form.Value, 'bot_token', 'Bot Token');
|
||||
o.password = true;
|
||||
o.placeholder = 'xoxb-...';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
o = s.option(form.Value, 'app_token', 'App Token');
|
||||
o.password = true;
|
||||
o.placeholder = 'xapp-...';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
// Email Integration
|
||||
s = m.section(form.TypedSection, 'integration', 'Email');
|
||||
s.anonymous = true;
|
||||
s.filter = function(section_id) {
|
||||
return section_id === 'email';
|
||||
};
|
||||
|
||||
o = s.option(form.Flag, 'enabled', 'Enable');
|
||||
o.rmempty = false;
|
||||
o.default = '0';
|
||||
|
||||
o = s.option(form.Value, 'imap_host', 'IMAP Server');
|
||||
o.placeholder = 'imap.example.com';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
o = s.option(form.Value, 'imap_port', 'IMAP Port');
|
||||
o.placeholder = '993';
|
||||
o.datatype = 'port';
|
||||
o.default = '993';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
o = s.option(form.Value, 'smtp_host', 'SMTP Server');
|
||||
o.placeholder = 'smtp.example.com';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
o = s.option(form.Value, 'smtp_port', 'SMTP Port');
|
||||
o.placeholder = '587';
|
||||
o.datatype = 'port';
|
||||
o.default = '587';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
o = s.option(form.Value, 'email', 'Email Address');
|
||||
o.placeholder = 'your@email.com';
|
||||
o.datatype = 'email';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
o = s.option(form.Value, 'password', 'Password');
|
||||
o.password = true;
|
||||
o.depends('enabled', '1');
|
||||
|
||||
// Calendar Integration
|
||||
s = m.section(form.TypedSection, 'integration', 'Calendar (CalDAV)');
|
||||
s.anonymous = true;
|
||||
s.filter = function(section_id) {
|
||||
return section_id === 'calendar';
|
||||
};
|
||||
|
||||
o = s.option(form.Flag, 'enabled', 'Enable');
|
||||
o.rmempty = false;
|
||||
o.default = '0';
|
||||
|
||||
o = s.option(form.Value, 'caldav_url', 'CalDAV URL');
|
||||
o.placeholder = 'https://calendar.example.com/caldav';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
o = s.option(form.Value, 'username', 'Username');
|
||||
o.depends('enabled', '1');
|
||||
|
||||
o = s.option(form.Value, 'password', 'Password');
|
||||
o.password = true;
|
||||
o.depends('enabled', '1');
|
||||
|
||||
return m.render();
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,196 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'require form';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
|
||||
var callOpenClawConfig = rpc.declare({
|
||||
object: 'luci.openclaw',
|
||||
method: 'get_config',
|
||||
expect: { }
|
||||
});
|
||||
|
||||
var callOpenClawSetConfig = rpc.declare({
|
||||
object: 'luci.openclaw',
|
||||
method: 'set_config',
|
||||
params: ['provider', 'model', 'api_key', 'ollama_url'],
|
||||
expect: { }
|
||||
});
|
||||
|
||||
var callOpenClawModels = rpc.declare({
|
||||
object: 'luci.openclaw',
|
||||
method: 'list_models',
|
||||
expect: { }
|
||||
});
|
||||
|
||||
var callOpenClawTestApi = rpc.declare({
|
||||
object: 'luci.openclaw',
|
||||
method: 'test_api',
|
||||
expect: { }
|
||||
});
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
callOpenClawConfig(),
|
||||
callOpenClawModels()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var config = data[0] || {};
|
||||
var modelsData = data[1] || {};
|
||||
var models = modelsData.models || {};
|
||||
|
||||
var styleEl = E('style', {}, [
|
||||
'.openclaw-settings { max-width: 700px; }',
|
||||
'.setting-group { background: #f9f9f9; border: 1px solid #ddd; border-radius: 8px; padding: 20px; margin-bottom: 20px; }',
|
||||
'.setting-group h3 { margin-top: 0; border-bottom: 1px solid #ddd; padding-bottom: 10px; }',
|
||||
'.setting-row { margin-bottom: 15px; }',
|
||||
'.setting-row label { display: block; font-weight: bold; margin-bottom: 5px; }',
|
||||
'.setting-row input, .setting-row select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }',
|
||||
'.setting-row .hint { font-size: 12px; color: #666; margin-top: 5px; }',
|
||||
'.btn-group { display: flex; gap: 10px; margin-top: 20px; }',
|
||||
'.btn { padding: 12px 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }',
|
||||
'.btn-primary { background: #007bff; color: white; }',
|
||||
'.btn-primary:hover { background: #0056b3; }',
|
||||
'.btn-success { background: #28a745; color: white; }',
|
||||
'.btn-success:hover { background: #1e7e34; }',
|
||||
'.api-status { padding: 10px; border-radius: 4px; margin-top: 10px; }',
|
||||
'.api-status.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }',
|
||||
'.api-status.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }',
|
||||
'.provider-info { background: #e7f3ff; border: 1px solid #b6d4fe; padding: 10px; border-radius: 4px; margin-bottom: 15px; }',
|
||||
'.provider-info a { color: #0056b3; }'
|
||||
]);
|
||||
|
||||
var providerSelect = E('select', { 'id': 'provider-select' }, [
|
||||
E('option', { 'value': 'anthropic', 'selected': config.provider === 'anthropic' }, 'Anthropic (Claude)'),
|
||||
E('option', { 'value': 'openai', 'selected': config.provider === 'openai' }, 'OpenAI (GPT)'),
|
||||
E('option', { 'value': 'ollama', 'selected': config.provider === 'ollama' }, 'Ollama (Local)')
|
||||
]);
|
||||
|
||||
var modelSelect = E('select', { 'id': 'model-select' });
|
||||
var updateModels = function() {
|
||||
var provider = providerSelect.value;
|
||||
var providerModels = models[provider] || [];
|
||||
modelSelect.innerHTML = '';
|
||||
providerModels.forEach(function(m) {
|
||||
var opt = E('option', { 'value': m }, m);
|
||||
if (m === config.model) opt.selected = true;
|
||||
modelSelect.appendChild(opt);
|
||||
});
|
||||
};
|
||||
providerSelect.addEventListener('change', updateModels);
|
||||
updateModels();
|
||||
|
||||
var apiKeyInput = E('input', {
|
||||
'type': 'password',
|
||||
'id': 'api-key-input',
|
||||
'placeholder': config.api_key_set === '1' ? '(Key is set: ' + config.api_key_masked + ')' : 'Enter your API key...'
|
||||
});
|
||||
|
||||
var ollamaUrlInput = E('input', {
|
||||
'type': 'text',
|
||||
'id': 'ollama-url-input',
|
||||
'value': config.ollama_url || 'http://127.0.0.1:11434',
|
||||
'placeholder': 'http://127.0.0.1:11434'
|
||||
});
|
||||
|
||||
var apiStatusDiv = E('div', { 'id': 'api-status', 'style': 'display:none;' });
|
||||
|
||||
var testButton = E('button', {
|
||||
'class': 'btn btn-success',
|
||||
'click': function() {
|
||||
apiStatusDiv.style.display = 'none';
|
||||
apiStatusDiv.className = 'api-status';
|
||||
apiStatusDiv.textContent = 'Testing...';
|
||||
apiStatusDiv.style.display = 'block';
|
||||
|
||||
callOpenClawTestApi().then(function(result) {
|
||||
if (result.success === '1') {
|
||||
apiStatusDiv.className = 'api-status success';
|
||||
apiStatusDiv.textContent = 'Connection successful! Provider: ' + result.provider + ', Model: ' + result.model;
|
||||
} else {
|
||||
apiStatusDiv.className = 'api-status error';
|
||||
apiStatusDiv.textContent = 'Connection failed: ' + (result.error || 'Unknown error');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
apiStatusDiv.className = 'api-status error';
|
||||
apiStatusDiv.textContent = 'Error: ' + err.message;
|
||||
});
|
||||
}
|
||||
}, 'Test Connection');
|
||||
|
||||
var saveButton = E('button', {
|
||||
'class': 'btn btn-primary',
|
||||
'click': function() {
|
||||
var provider = providerSelect.value;
|
||||
var model = modelSelect.value;
|
||||
var apiKey = apiKeyInput.value || null;
|
||||
var ollamaUrl = ollamaUrlInput.value || null;
|
||||
|
||||
callOpenClawSetConfig(provider, model, apiKey, ollamaUrl).then(function(result) {
|
||||
if (result.status === 'ok') {
|
||||
ui.addNotification(null, E('p', {}, 'Settings saved successfully!'), 'success');
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, 'Failed to save settings'), 'error');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
ui.addNotification(null, E('p', {}, 'Error: ' + err.message), 'error');
|
||||
});
|
||||
}
|
||||
}, 'Save Settings');
|
||||
|
||||
return E('div', { 'class': 'openclaw-settings' }, [
|
||||
styleEl,
|
||||
E('h2', {}, 'OpenClaw Settings'),
|
||||
|
||||
E('div', { 'class': 'setting-group' }, [
|
||||
E('h3', {}, 'AI Provider'),
|
||||
E('div', { 'class': 'provider-info' }, [
|
||||
E('strong', {}, 'Get your API keys:'),
|
||||
E('br'),
|
||||
E('a', { 'href': 'https://console.anthropic.com/', 'target': '_blank' }, 'Anthropic Console'),
|
||||
' | ',
|
||||
E('a', { 'href': 'https://platform.openai.com/', 'target': '_blank' }, 'OpenAI Platform'),
|
||||
' | ',
|
||||
E('a', { 'href': 'https://ollama.ai/', 'target': '_blank' }, 'Ollama (Free/Local)')
|
||||
]),
|
||||
E('div', { 'class': 'setting-row' }, [
|
||||
E('label', { 'for': 'provider-select' }, 'Provider'),
|
||||
providerSelect
|
||||
]),
|
||||
E('div', { 'class': 'setting-row' }, [
|
||||
E('label', { 'for': 'model-select' }, 'Model'),
|
||||
modelSelect
|
||||
])
|
||||
]),
|
||||
|
||||
E('div', { 'class': 'setting-group' }, [
|
||||
E('h3', {}, 'Authentication'),
|
||||
E('div', { 'class': 'setting-row' }, [
|
||||
E('label', { 'for': 'api-key-input' }, 'API Key'),
|
||||
apiKeyInput,
|
||||
E('div', { 'class': 'hint' }, 'Required for Anthropic and OpenAI. Leave empty for Ollama.')
|
||||
]),
|
||||
E('div', { 'class': 'setting-row' }, [
|
||||
E('label', { 'for': 'ollama-url-input' }, 'Ollama URL'),
|
||||
ollamaUrlInput,
|
||||
E('div', { 'class': 'hint' }, 'Only used when provider is set to Ollama.')
|
||||
])
|
||||
]),
|
||||
|
||||
apiStatusDiv,
|
||||
|
||||
E('div', { 'class': 'btn-group' }, [
|
||||
saveButton,
|
||||
testButton
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@ -0,0 +1,361 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: MIT
|
||||
# RPCD backend for OpenClaw AI Assistant LuCI app
|
||||
|
||||
. /lib/functions.sh
|
||||
. /usr/share/libubox/jshn.sh
|
||||
|
||||
OPENCLAW_CONFIG="openclaw"
|
||||
OPENCLAW_DATA="/srv/openclaw"
|
||||
HISTORY_FILE="$OPENCLAW_DATA/chat_history.json"
|
||||
|
||||
log_msg() {
|
||||
logger -t "luci.openclaw" "$1"
|
||||
}
|
||||
|
||||
# Get service status
|
||||
get_status() {
|
||||
local enabled port running pid
|
||||
|
||||
enabled=$(uci -q get $OPENCLAW_CONFIG.main.enabled)
|
||||
port=$(uci -q get $OPENCLAW_CONFIG.main.port)
|
||||
|
||||
# Check if service is running
|
||||
if pgrep -f "openclaw" >/dev/null 2>&1; then
|
||||
running="1"
|
||||
pid=$(pgrep -f "openclaw" | head -1)
|
||||
else
|
||||
running="0"
|
||||
pid=""
|
||||
fi
|
||||
|
||||
json_init
|
||||
json_add_string "enabled" "${enabled:-0}"
|
||||
json_add_string "running" "$running"
|
||||
json_add_string "pid" "$pid"
|
||||
json_add_string "port" "${port:-3333}"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Get current configuration
|
||||
get_config() {
|
||||
local provider model api_key ollama_url
|
||||
|
||||
provider=$(uci -q get $OPENCLAW_CONFIG.llm.type)
|
||||
model=$(uci -q get $OPENCLAW_CONFIG.llm.model)
|
||||
api_key=$(uci -q get $OPENCLAW_CONFIG.llm.api_key)
|
||||
ollama_url=$(uci -q get $OPENCLAW_CONFIG.llm.ollama_url)
|
||||
|
||||
# Mask API key for security
|
||||
local masked_key=""
|
||||
if [ -n "$api_key" ]; then
|
||||
local key_len=${#api_key}
|
||||
if [ $key_len -gt 8 ]; then
|
||||
masked_key="${api_key:0:4}****${api_key: -4}"
|
||||
else
|
||||
masked_key="****"
|
||||
fi
|
||||
fi
|
||||
|
||||
json_init
|
||||
json_add_string "provider" "${provider:-anthropic}"
|
||||
json_add_string "model" "${model:-claude-sonnet-4-20250514}"
|
||||
json_add_string "api_key_set" "$([ -n "$api_key" ] && echo '1' || echo '0')"
|
||||
json_add_string "api_key_masked" "$masked_key"
|
||||
json_add_string "ollama_url" "${ollama_url:-http://127.0.0.1:11434}"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Set configuration
|
||||
set_config() {
|
||||
local provider="$1" model="$2" api_key="$3" ollama_url="$4"
|
||||
|
||||
[ -n "$provider" ] && uci set $OPENCLAW_CONFIG.llm.type="$provider"
|
||||
[ -n "$model" ] && uci set $OPENCLAW_CONFIG.llm.model="$model"
|
||||
[ -n "$api_key" ] && uci set $OPENCLAW_CONFIG.llm.api_key="$api_key"
|
||||
[ -n "$ollama_url" ] && uci set $OPENCLAW_CONFIG.llm.ollama_url="$ollama_url"
|
||||
|
||||
uci commit $OPENCLAW_CONFIG
|
||||
|
||||
json_init
|
||||
json_add_string "status" "ok"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# List available models per provider
|
||||
list_models() {
|
||||
json_init
|
||||
json_add_object "models"
|
||||
|
||||
json_add_array "anthropic"
|
||||
json_add_string "" "claude-opus-4-20250514"
|
||||
json_add_string "" "claude-sonnet-4-20250514"
|
||||
json_add_string "" "claude-3-5-haiku-20241022"
|
||||
json_close_array
|
||||
|
||||
json_add_array "openai"
|
||||
json_add_string "" "gpt-4o"
|
||||
json_add_string "" "gpt-4-turbo"
|
||||
json_add_string "" "gpt-4"
|
||||
json_add_string "" "gpt-3.5-turbo"
|
||||
json_close_array
|
||||
|
||||
json_add_array "ollama"
|
||||
json_add_string "" "mistral"
|
||||
json_add_string "" "llama2"
|
||||
json_add_string "" "llama3"
|
||||
json_add_string "" "tinyllama"
|
||||
json_add_string "" "codellama"
|
||||
json_close_array
|
||||
|
||||
json_close_object
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Chat with AI
|
||||
do_chat() {
|
||||
local message="$1"
|
||||
local provider model api_key response
|
||||
|
||||
provider=$(uci -q get $OPENCLAW_CONFIG.llm.type)
|
||||
model=$(uci -q get $OPENCLAW_CONFIG.llm.model)
|
||||
api_key=$(uci -q get $OPENCLAW_CONFIG.llm.api_key)
|
||||
|
||||
if [ -z "$api_key" ] && [ "$provider" != "ollama" ]; then
|
||||
json_init
|
||||
json_add_string "error" "API key not configured"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
|
||||
# Ensure data directory exists
|
||||
mkdir -p "$OPENCLAW_DATA"
|
||||
|
||||
case "$provider" in
|
||||
anthropic)
|
||||
response=$(wget -q -O - \
|
||||
--header="Content-Type: application/json" \
|
||||
--header="x-api-key: $api_key" \
|
||||
--header="anthropic-version: 2023-06-01" \
|
||||
--post-data="{\"model\":\"$model\",\"max_tokens\":4096,\"messages\":[{\"role\":\"user\",\"content\":\"$message\"}]}" \
|
||||
"https://api.anthropic.com/v1/messages" 2>/dev/null)
|
||||
;;
|
||||
openai)
|
||||
response=$(wget -q -O - \
|
||||
--header="Content-Type: application/json" \
|
||||
--header="Authorization: Bearer $api_key" \
|
||||
--post-data="{\"model\":\"$model\",\"messages\":[{\"role\":\"user\",\"content\":\"$message\"}]}" \
|
||||
"https://api.openai.com/v1/chat/completions" 2>/dev/null)
|
||||
;;
|
||||
ollama)
|
||||
local ollama_url=$(uci -q get $OPENCLAW_CONFIG.llm.ollama_url)
|
||||
ollama_url="${ollama_url:-http://127.0.0.1:11434}"
|
||||
response=$(wget -q -O - \
|
||||
--header="Content-Type: application/json" \
|
||||
--post-data="{\"model\":\"$model\",\"messages\":[{\"role\":\"user\",\"content\":\"$message\"}],\"stream\":false}" \
|
||||
"${ollama_url}/api/chat" 2>/dev/null)
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -n "$response" ]; then
|
||||
# Save to history
|
||||
local timestamp=$(date +%s)
|
||||
echo "{\"ts\":$timestamp,\"user\":\"$message\",\"response\":$response}" >> "$HISTORY_FILE"
|
||||
|
||||
# Return response
|
||||
echo "$response"
|
||||
else
|
||||
json_init
|
||||
json_add_string "error" "Failed to get response from AI provider"
|
||||
json_dump
|
||||
fi
|
||||
}
|
||||
|
||||
# Test API connection
|
||||
test_api() {
|
||||
local provider model api_key
|
||||
|
||||
provider=$(uci -q get $OPENCLAW_CONFIG.llm.type)
|
||||
model=$(uci -q get $OPENCLAW_CONFIG.llm.model)
|
||||
api_key=$(uci -q get $OPENCLAW_CONFIG.llm.api_key)
|
||||
|
||||
if [ -z "$api_key" ] && [ "$provider" != "ollama" ]; then
|
||||
json_init
|
||||
json_add_string "success" "0"
|
||||
json_add_string "error" "API key not configured"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
|
||||
local test_result
|
||||
case "$provider" in
|
||||
anthropic)
|
||||
test_result=$(wget -q -O - \
|
||||
--header="Content-Type: application/json" \
|
||||
--header="x-api-key: $api_key" \
|
||||
--header="anthropic-version: 2023-06-01" \
|
||||
--post-data="{\"model\":\"$model\",\"max_tokens\":10,\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}" \
|
||||
"https://api.anthropic.com/v1/messages" 2>&1)
|
||||
;;
|
||||
openai)
|
||||
test_result=$(wget -q -O - \
|
||||
--header="Content-Type: application/json" \
|
||||
--header="Authorization: Bearer $api_key" \
|
||||
--post-data="{\"model\":\"$model\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":10}" \
|
||||
"https://api.openai.com/v1/chat/completions" 2>&1)
|
||||
;;
|
||||
ollama)
|
||||
local ollama_url=$(uci -q get $OPENCLAW_CONFIG.llm.ollama_url)
|
||||
ollama_url="${ollama_url:-http://127.0.0.1:11434}"
|
||||
test_result=$(wget -q -O - "${ollama_url}/api/tags" 2>&1)
|
||||
;;
|
||||
esac
|
||||
|
||||
if echo "$test_result" | grep -qE '(error|Error|ERROR)'; then
|
||||
json_init
|
||||
json_add_string "success" "0"
|
||||
json_add_string "error" "API test failed"
|
||||
json_dump
|
||||
else
|
||||
json_init
|
||||
json_add_string "success" "1"
|
||||
json_add_string "provider" "$provider"
|
||||
json_add_string "model" "$model"
|
||||
json_dump
|
||||
fi
|
||||
}
|
||||
|
||||
# Get chat history
|
||||
get_history() {
|
||||
if [ -f "$HISTORY_FILE" ]; then
|
||||
# Return last 50 entries
|
||||
tail -50 "$HISTORY_FILE" | {
|
||||
echo '{"history":['
|
||||
local first=1
|
||||
while read line; do
|
||||
[ $first -eq 0 ] && echo ","
|
||||
echo "$line"
|
||||
first=0
|
||||
done
|
||||
echo ']}'
|
||||
}
|
||||
else
|
||||
echo '{"history":[]}'
|
||||
fi
|
||||
}
|
||||
|
||||
# Clear chat history
|
||||
clear_history() {
|
||||
rm -f "$HISTORY_FILE"
|
||||
json_init
|
||||
json_add_string "status" "ok"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Install/setup OpenClaw
|
||||
do_install() {
|
||||
mkdir -p "$OPENCLAW_DATA"
|
||||
|
||||
# Enable and start service
|
||||
uci set $OPENCLAW_CONFIG.main.enabled='1'
|
||||
uci commit $OPENCLAW_CONFIG
|
||||
|
||||
/etc/init.d/openclaw enable 2>/dev/null
|
||||
/etc/init.d/openclaw start 2>/dev/null
|
||||
|
||||
json_init
|
||||
json_add_string "status" "ok"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Update OpenClaw
|
||||
do_update() {
|
||||
# Restart service
|
||||
/etc/init.d/openclaw restart 2>/dev/null
|
||||
|
||||
json_init
|
||||
json_add_string "status" "ok"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Main RPC handler
|
||||
case "$1" in
|
||||
list)
|
||||
json_init
|
||||
json_add_object "status"
|
||||
json_close_object
|
||||
json_add_object "get_config"
|
||||
json_close_object
|
||||
json_add_object "set_config"
|
||||
json_add_string "provider" "string"
|
||||
json_add_string "model" "string"
|
||||
json_add_string "api_key" "string"
|
||||
json_add_string "ollama_url" "string"
|
||||
json_close_object
|
||||
json_add_object "list_models"
|
||||
json_close_object
|
||||
json_add_object "chat"
|
||||
json_add_string "message" "string"
|
||||
json_close_object
|
||||
json_add_object "test_api"
|
||||
json_close_object
|
||||
json_add_object "get_history"
|
||||
json_close_object
|
||||
json_add_object "clear_history"
|
||||
json_close_object
|
||||
json_add_object "install"
|
||||
json_close_object
|
||||
json_add_object "update"
|
||||
json_close_object
|
||||
json_dump
|
||||
;;
|
||||
call)
|
||||
case "$2" in
|
||||
status)
|
||||
get_status
|
||||
;;
|
||||
get_config)
|
||||
get_config
|
||||
;;
|
||||
set_config)
|
||||
read input
|
||||
json_load "$input"
|
||||
json_get_var provider provider
|
||||
json_get_var model model
|
||||
json_get_var api_key api_key
|
||||
json_get_var ollama_url ollama_url
|
||||
set_config "$provider" "$model" "$api_key" "$ollama_url"
|
||||
;;
|
||||
list_models)
|
||||
list_models
|
||||
;;
|
||||
chat)
|
||||
read input
|
||||
json_load "$input"
|
||||
json_get_var message message
|
||||
do_chat "$message"
|
||||
;;
|
||||
test_api)
|
||||
test_api
|
||||
;;
|
||||
get_history)
|
||||
get_history
|
||||
;;
|
||||
clear_history)
|
||||
clear_history
|
||||
;;
|
||||
install)
|
||||
do_install
|
||||
;;
|
||||
update)
|
||||
do_update
|
||||
;;
|
||||
*)
|
||||
echo '{"error":"Unknown method"}'
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo '{"error":"Invalid action"}'
|
||||
;;
|
||||
esac
|
||||
@ -0,0 +1,36 @@
|
||||
{
|
||||
"admin/services/openclaw": {
|
||||
"title": "OpenClaw AI",
|
||||
"order": 75,
|
||||
"action": {
|
||||
"type": "firstchild"
|
||||
},
|
||||
"depends": {
|
||||
"acl": ["luci-app-openclaw"]
|
||||
}
|
||||
},
|
||||
"admin/services/openclaw/chat": {
|
||||
"title": "AI Chat",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "openclaw/chat"
|
||||
}
|
||||
},
|
||||
"admin/services/openclaw/settings": {
|
||||
"title": "Settings",
|
||||
"order": 20,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "openclaw/settings"
|
||||
}
|
||||
},
|
||||
"admin/services/openclaw/integrations": {
|
||||
"title": "Integrations",
|
||||
"order": 30,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "openclaw/integrations"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
{
|
||||
"luci-app-openclaw": {
|
||||
"description": "OpenClaw AI Assistant",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"luci.openclaw": ["status", "get_config", "list_models", "get_history"]
|
||||
},
|
||||
"uci": ["openclaw"]
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"luci.openclaw": ["chat", "set_config", "install", "update", "test_api", "clear_history"]
|
||||
},
|
||||
"uci": ["openclaw"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user