Files
sovereign-chat/client/main.js
T

1319 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OpenClaw WebUI - Main Application
*
* A modern chat interface for OpenClaw with:
* - Multi-file upload
* - Code canvas
* - Chat history
* - Streaming responses
* - LDAP SSO
*/
// ==================== State Management ====================
const state = {
user: null,
conversations: [],
currentConversation: null,
messages: [],
isLoading: false,
models: [],
selectedModel: 'main',
files: [],
canvasOpen: false,
canvasContent: '',
canvasLanguage: 'javascript',
adminPanelOpen: false,
adminTab: 'models',
adminStatus: null,
preapprovedModels: [],
auditLogs: [],
activeSessions: []
};
// ==================== API Client ====================
const api = {
async request(path, options = {}) {
const res = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
},
body: options.body ? JSON.stringify(options.body) : undefined
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Request failed');
}
if (res.status === 204) return null;
return res.json();
},
// Auth
async getStatus() {
return this.request('/api/auth/status');
},
async login(username, password) {
return this.request('/api/auth/login', {
method: 'POST',
body: { username, password }
});
},
async logout() {
return this.request('/api/auth/logout', { method: 'POST' });
},
// Conversations
async getConversations() {
return this.request('/api/conversations');
},
async createConversation(title) {
return this.request('/api/conversations', {
method: 'POST',
body: { title }
});
},
async updateConversation(id, data) {
return this.request(`/api/conversations/${id}`, {
method: 'PUT',
body: data
});
},
async deleteConversation(id) {
return this.request(`/api/conversations/${id}`, { method: 'DELETE' });
},
async getMessages(convId) {
return this.request(`/api/conversations/${convId}/messages`);
},
async saveMessage(convId, message) {
return this.request(`/api/conversations/${convId}/messages`, {
method: 'POST',
body: message
});
},
// Models
async getModels() {
return this.request('/api/models');
},
// Upload
async uploadFile(file) {
const res = await fetch(`/api/upload?filename=${encodeURIComponent(file.name)}`, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file
});
return res.json();
},
// Admin APIs
async getAdminStatus() {
return this.request('/api/admin/status');
},
async saveNetworkConfig(config) {
return this.request('/api/admin/network', {
method: 'POST',
body: config
});
},
async getAuditLogs() {
return this.request('/api/admin/audit-logs');
},
async getPreapprovedModels() {
return this.request('/api/admin/preapproved');
},
async downloadPreapprovedModel(filename, url, sha256) {
return this.request('/api/admin/models/download', {
method: 'POST',
body: { filename, url, sha256 }
});
},
async uploadCustomModel(filename, file, sha256) {
const res = await fetch('/api/admin/models/upload', {
method: 'POST',
headers: {
'x-filename': filename,
'x-sha256': sha256 || ''
},
body: file
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Upload failed');
}
return res.json();
},
async saveAdminSettings(settings) {
return this.request('/api/admin/settings', {
method: 'POST',
body: settings
});
},
async setActiveModel(model) {
return this.request('/api/admin/models/active', {
method: 'POST',
body: { model }
});
},
async getMonitoringStats() {
return this.request('/api/admin/monitoring');
}
};
// ==================== Markdown Parser (Simple) ====================
function parseMarkdown(text) {
if (!text) return '';
// Escape HTML
text = text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Code blocks (with language)
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
const langClass = lang ? `language-${lang}` : '';
return `<pre class="code-block ${langClass}" data-language="${lang}"><code>${code}</code></pre>`;
});
// Inline code
text = text.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
// Bold
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Links
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Line breaks
text = text.replace(/\n/g, '<br>');
return text;
}
// ==================== Streaming Chat ====================
async function streamChat(messages, onToken, onComplete, onError) {
const body = {
model: `openclaw:${state.selectedModel}`,
stream: true,
messages: messages.map(m => ({
role: m.role,
content: m.content
}))
};
try {
const res = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer a41984619a5f4b9bf9148ab6eb4abca53eb796d046cbbec5',
'x-openclaw-agent-id': state.selectedModel
},
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error?.message || err.error || 'Request failed');
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let fullContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const content = json.choices?.[0]?.delta?.content;
if (content) {
fullContent += content;
onToken(content, fullContent);
}
} catch {
// Ignore parse errors
}
}
}
}
onComplete(fullContent);
} catch (err) {
onError(err);
}
}
// ==================== UI Components ====================
function render(selector, html) {
document.querySelector(selector).innerHTML = html;
}
function renderApp() {
const app = document.getElementById('app');
if (!state.user) {
app.innerHTML = renderLoginPage();
} else if (state.adminPanelOpen) {
app.innerHTML = renderAdminPage();
} else {
app.innerHTML = renderMainPage();
}
attachEventListeners();
}
function renderLoginPage() {
return `
<div class="login-container">
<div class="login-card">
<div class="login-header">
<svg class="logo" viewBox="0 0 100 100" width="64" height="64">
<circle cx="50" cy="50" r="45" fill="none" stroke="var(--primary)" stroke-width="4"/>
<path d="M30 50 L45 65 L70 35" stroke="var(--primary)" stroke-width="6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<h1>Sovereign Chat</h1>
<p>Theta42 Sovereign AI Appliance</p>
</div>
<form id="login-form" class="login-form">
<input type="text" id="login-username" placeholder="Username" autocomplete="username" required>
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password" required>
<button type="submit" class="btn-primary">Sign In</button>
</form>
<div id="login-error" class="login-error hidden"></div>
</div>
</div>
`;
}
function renderMainPage() {
return `
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header flex flex-col gap-2">
<button id="new-chat-btn" class="btn-new-chat">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
New Chat
</button>
${state.user.isAdmin ? `
<button id="admin-toggle-btn" class="btn-admin-toggle">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
</svg>
Admin Dashboard
</button>
` : ''}
</div>
<div class="sidebar-content">
<div id="conversations-list" class="conversations-list">
${renderConversationsList()}
</div>
</div>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar">${state.user.username?.[0]?.toUpperCase() || '?'}</div>
<div class="user-name">${state.user.displayName || state.user.username}</div>
</div>
<button id="logout-btn" class="btn-logout" title="Sign out">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/>
</svg>
</button>
</div>
</aside>
<!-- Main Chat Area -->
<main class="main-content">
<div class="chat-header">
<div class="model-selector">
<label for="model-select">Model:</label>
<select id="model-select">
${state.models.map(m => `
<option value="${m.id}" ${m.id === state.selectedModel ? 'selected' : ''}>
${m.name || m.id}
</option>
`).join('')}
</select>
</div>
<button id="canvas-toggle" class="btn-canvas ${state.canvasOpen ? 'active' : ''}">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/>
</svg>
Code Canvas
</button>
</div>
<div class="chat-container">
<div id="messages-container" class="messages-container">
${state.messages.length === 0 ? renderEmptyState() : renderMessages()}
</div>
<div class="input-container">
<div id="files-preview" class="files-preview"></div>
<div class="input-wrapper">
<label for="file-input" class="btn-attach" title="Attach files">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/>
</svg>
</label>
<input type="file" id="file-input" multiple accept="*/*" style="display:none">
<textarea id="message-input" placeholder="Message Sovereign..." rows="1"></textarea>
<button id="send-btn" class="btn-send" title="Send">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Code Canvas Panel -->
<div id="canvas-panel" class="canvas-panel ${state.canvasOpen ? 'open' : ''}">
<div class="canvas-header">
<select id="canvas-language">
<option value="javascript">JavaScript</option>
<option value="python">Python</option>
<option value="html">HTML</option>
<option value="css">CSS</option>
<option value="json">JSON</option>
<option value="markdown">Markdown</option>
<option value="bash">Bash</option>
<option value="sql">SQL</option>
</select>
<button id="canvas-copy" class="btn-canvas-action" title="Copy">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
</button>
<button id="canvas-close" class="btn-canvas-action" title="Close">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
<textarea id="canvas-content" class="canvas-editor" spellcheck="false"></textarea>
</div>
</main>
</div>
`;
}
function renderConversationsList() {
if (state.conversations.length === 0) {
return '<div class="no-conversations">No conversations yet</div>';
}
return state.conversations.map(conv => `
<div class="conversation-item ${conv.id === state.currentConversation?.id ? 'active' : ''}" data-id="${conv.id}">
<div class="conv-title">${escapeHtml(conv.title)}</div>
<div class="conv-date">${formatDate(conv.updatedAt)}</div>
<button class="conv-delete" data-id="${conv.id}" title="Delete">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
</button>
</div>
`).join('');
}
function renderEmptyState() {
return `
<div class="empty-state">
<svg viewBox="0 0 100 100" width="80" height="80">
<circle cx="50" cy="50" r="45" fill="none" stroke="var(--text-muted)" stroke-width="2"/>
<circle cx="50" cy="40" r="12" fill="none" stroke="var(--text-muted)" stroke-width="2"/>
<path d="M35 55 Q50 70 65 55" fill="none" stroke="var(--text-muted)" stroke-width="2"/>
</svg>
<h2>Welcome to Sovereign Chat</h2>
<p>Start a conversation or upload files to begin</p>
</div>
`;
}
function renderMessages() {
return state.messages.map(msg => renderMessage(msg)).join('');
}
function renderMessage(msg) {
const isUser = msg.role === 'user';
const content = msg.content || '';
let filesHtml = '';
if (msg.files?.length) {
filesHtml = `<div class="message-files">
${msg.files.map(f => `
<div class="attached-file">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/>
</svg>
${escapeHtml(f.name)}
</div>
`).join('')}
</div>`;
}
return `
<div class="message ${isUser ? 'user' : 'assistant'}">
<div class="message-avatar">${isUser ? (state.user.username?.[0]?.toUpperCase() || 'U') : '⚡'}</div>
<div class="message-content">
${filesHtml}
<div class="message-text">${parseMarkdown(content)}</div>
</div>
</div>
`;
}
// ==================== Event Handlers ====================
function attachEventListeners() {
// Login form (if on login page)
document.getElementById('login-form')?.addEventListener('submit', handleLogin);
// Logout
// Logout
document.getElementById('logout-btn')?.addEventListener('click', handleLogout);
// New chat
document.getElementById('new-chat-btn')?.addEventListener('click', handleNewChat);
// Model selection
document.getElementById('model-select')?.addEventListener('change', (e) => {
state.selectedModel = e.target.value;
localStorage.setItem('selected-model', state.selectedModel);
});
// Message input
const input = document.getElementById('message-input');
input?.addEventListener('keydown', handleInputKeydown);
input?.addEventListener('input', autoResizeTextarea);
// Send button
document.getElementById('send-btn')?.addEventListener('click', handleSendMessage);
// File input
document.getElementById('file-input')?.addEventListener('change', handleFileSelect);
// Canvas toggle
document.getElementById('canvas-toggle')?.addEventListener('click', toggleCanvas);
document.getElementById('canvas-close')?.addEventListener('click', () => toggleCanvas(false));
document.getElementById('canvas-copy')?.addEventListener('click', copyCanvasContent);
// Conversation clicks
document.getElementById('conversations-list')?.addEventListener('click', handleConversationClick);
// Admin Panel triggers
document.getElementById('admin-toggle-btn')?.addEventListener('click', async () => {
state.adminPanelOpen = true;
state.adminTab = 'models';
await loadAdminData();
renderApp();
});
document.getElementById('admin-close-btn')?.addEventListener('click', () => {
state.adminPanelOpen = false;
renderApp();
});
// Admin Sidebar menu items
const menuItems = document.querySelectorAll('.admin-menu-item');
menuItems.forEach(item => {
item.addEventListener('click', async (e) => {
state.adminTab = e.target.getAttribute('data-tab');
await loadAdminData();
renderApp();
});
});
// Tab: Models actions
document.getElementById('btn-reload-active-model')?.addEventListener('click', async () => {
const select = document.getElementById('admin-active-model-select');
const model = select.value;
const btn = document.getElementById('btn-reload-active-model');
btn.disabled = true;
btn.textContent = 'Reloading...';
try {
await api.setActiveModel(model);
alert('Model switched and reloaded successfully.');
} catch (err) {
alert('Error reloading model: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Apply & Reload';
await loadAdminData();
renderApp();
}
});
const downloadBtns = document.querySelectorAll('.btn-download-model');
downloadBtns.forEach(btn => {
btn.addEventListener('click', async (e) => {
const filename = e.target.getAttribute('data-filename');
const sha256 = e.target.getAttribute('data-sha256');
const url = `https://huggingface.co/HuggingFaceTB/SmolLM2-135M-Instruct/resolve/main/${filename}`;
e.target.disabled = true;
e.target.textContent = 'Downloading...';
try {
await api.downloadPreapprovedModel(filename, url, sha256);
alert('Download triggered in background. Check list again in a few moments.');
} catch (err) {
alert('Failed to trigger download: ' + err.message);
} finally {
await loadAdminData();
renderApp();
}
});
});
// Custom model upload form
document.getElementById('custom-model-upload-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const nameInput = document.getElementById('upload-model-name');
const hashInput = document.getElementById('upload-model-hash');
const fileInput = document.getElementById('upload-model-file');
const progressDiv = document.getElementById('upload-progress');
const progressSpan = document.getElementById('upload-percentage');
const file = fileInput.files[0];
if (!file) return;
progressDiv.classList.remove('hidden');
progressSpan.textContent = 'Uploading...';
try {
await api.uploadCustomModel(nameInput.value, file, hashInput.value);
alert('Custom model uploaded and staged successfully. Check models tab in a few moments.');
nameInput.value = '';
hashInput.value = '';
fileInput.value = '';
progressDiv.classList.add('hidden');
} catch (err) {
alert('Upload failed: ' + err.message);
progressSpan.textContent = 'Error';
} finally {
await loadAdminData();
renderApp();
}
});
// Save settings (quota, schedules)
document.getElementById('btn-save-settings')?.addEventListener('click', async () => {
const quotaSlider = document.getElementById('quota-slider');
const scheduleCheck = document.getElementById('schedule-enabled-check');
const scheduleStart = document.getElementById('schedule-start');
const scheduleEnd = document.getElementById('schedule-end');
const dailyTokenQuota = parseInt(quotaSlider.value);
const schedule = {
enabled: scheduleCheck.checked,
startHour: parseInt(scheduleStart.value),
endHour: parseInt(scheduleEnd.value)
};
try {
await api.saveAdminSettings({ dailyTokenQuota, schedule });
alert('Quota and schedules settings saved successfully.');
} catch (err) {
alert('Failed to save settings: ' + err.message);
} finally {
await loadAdminData();
renderApp();
}
});
// Update quota value display
const slider = document.getElementById('quota-slider');
slider?.addEventListener('input', (e) => {
const display = document.getElementById('quota-value');
if (display) display.textContent = `${e.target.value} tokens`;
});
// Network settings save
document.getElementById('network-settings-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const ipInput = document.getElementById('net-ip');
const maskInput = document.getElementById('net-mask');
const gwInput = document.getElementById('net-gateway');
try {
await api.saveNetworkConfig({
address: ipInput.value,
netmask: maskInput.value,
gateway: gwInput.value
});
alert('Network settings saved. In a production system, host interfaces will reload.');
} catch (err) {
alert('Failed to save network settings: ' + err.message);
} finally {
await loadAdminData();
renderApp();
}
});
}
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
const errorDiv = document.getElementById('login-error');
try {
const result = await api.login(username, password);
if (result.success) {
state.user = result.user;
await loadInitialData();
renderApp();
}
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.remove('hidden');
}
}
async function handleLogout() {
await api.logout();
state.user = null;
renderApp();
}
async function handleNewChat() {
const conv = await api.createConversation('New Chat');
state.conversations.unshift(conv);
state.currentConversation = conv;
state.messages = [];
renderApp();
}
async function handleConversationClick(e) {
const deleteBtn = e.target.closest('.conv-delete');
if (deleteBtn) {
e.stopPropagation();
await handleDeleteConversation(deleteBtn.dataset.id);
return;
}
const item = e.target.closest('.conversation-item');
if (item) {
await selectConversation(item.dataset.id);
}
}
async function handleDeleteConversation(id) {
if (!confirm('Delete this conversation?')) return;
await api.deleteConversation(id);
state.conversations = state.conversations.filter(c => c.id !== id);
if (state.currentConversation?.id === id) {
state.currentConversation = state.conversations[0] || null;
state.messages = state.currentConversation ? await api.getMessages(state.currentConversation.id) : [];
}
renderApp();
}
async function selectConversation(id) {
state.currentConversation = state.conversations.find(c => c.id === id);
state.messages = await api.getMessages(id);
renderApp();
scrollToBottom();
}
function handleInputKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}
function autoResizeTextarea(e) {
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
}
async function handleFileSelect(e) {
const files = Array.from(e.target.files);
const MAX_FILE_SIZE = 50000; // 50KB text limit
for (const file of files) {
try {
// Check file size first
if (file.size > 1024 * 1024) { // 1MB
alert(`File "${file.name}" is too large (${(file.size / 1024 / 1024).toFixed(1)}MB). Maximum size is 1MB.`);
continue;
}
// Read file content
const content = await readFileContent(file);
// Warn if content will be truncated
let finalContent = content;
let warning = '';
if (content.length > MAX_FILE_SIZE) {
warning = ` (truncated - file is ${Math.round(content.length / 1000)}KB, max ${MAX_FILE_SIZE / 1000}KB per file)`;
finalContent = content.substring(0, MAX_FILE_SIZE) + '\n\n... [TRUNCATED - file too large for context window]';
}
state.files.push({
id: file.name + '-' + Date.now(),
name: file.name + warning,
type: file.type,
size: file.size,
content: finalContent,
truncated: content.length > MAX_FILE_SIZE
});
} catch (err) {
console.error('Failed to read file:', file.name, err);
alert(`Failed to read file "${file.name}": ${err.message}`);
}
}
renderFilesPreview();
e.target.value = '';
}
function readFileContent(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
function renderFilesPreview() {
const container = document.getElementById('files-preview');
if (!container) return;
container.innerHTML = state.files.map(f => `
<div class="file-preview" data-id="${f.id}">
<span>${escapeHtml(f.name)}</span>
<button class="file-remove" data-id="${f.id}">×</button>
</div>
`).join('');
container.querySelectorAll('.file-remove').forEach(btn => {
btn.addEventListener('click', () => {
state.files = state.files.filter(f => f.id !== btn.dataset.id);
renderFilesPreview();
});
});
}
async function handleSendMessage() {
const input = document.getElementById('message-input');
const content = input.value.trim();
if (!content && state.files.length === 0) return;
if (state.isLoading) return;
// Create conversation if needed
if (!state.currentConversation) {
const conv = await api.createConversation(content.substring(0, 50));
state.conversations.unshift(conv);
state.currentConversation = conv;
}
// Build message
const userMessage = {
role: 'user',
content: buildMessageContent(content, state.files),
files: state.files.length ? [...state.files] : null
};
// Add user message
state.messages.push(userMessage);
await api.saveMessage(state.currentConversation.id, userMessage);
// Clear input
input.value = '';
input.style.height = 'auto';
state.files = [];
renderFilesPreview();
// Add placeholder for assistant response
const assistantMessage = { role: 'assistant', content: '' };
state.messages.push(assistantMessage);
state.isLoading = true;
updateSendButton(true);
renderApp();
scrollToBottom();
// Stream response
const messagesForApi = state.messages.slice(0, -1).map(m => ({
role: m.role,
content: m.content
}));
await streamChat(
messagesForApi,
(token, full) => {
assistantMessage.content = full;
updateLastMessage(full);
},
async (fullContent) => {
state.isLoading = false;
updateSendButton(false);
await api.saveMessage(state.currentConversation.id, assistantMessage);
// Update conversation title if first message
if (state.messages.length === 2) {
const title = content.substring(0, 50) + (content.length > 50 ? '...' : '');
await api.updateConversation(state.currentConversation.id, { title });
state.currentConversation.title = title;
const titleEl = document.querySelector(`.conv-title[data-id="${state.currentConversation.id}"]`);
if (titleEl) titleEl.textContent = title;
}
},
(err) => {
state.isLoading = false;
updateSendButton(false);
assistantMessage.content = `**Error:** ${err.message}`;
updateLastMessage(assistantMessage.content);
}
);
}
function buildMessageContent(text, files) {
if (!files.length) return text;
const fileContents = files.map(f => {
return `--- ${f.name.replace(/ \(truncated.*\)$/, '')} ---\n${f.content}\n--- end of file ---`;
}).join('\n\n');
return `${text}\n\nAttached files:\n\n${fileContents}`;
}
function updateLastMessage(content) {
const container = document.getElementById('messages-container');
const messages = container.querySelectorAll('.message');
const last = messages[messages.length - 1];
if (last) {
const textDiv = last.querySelector('.message-text');
if (textDiv) {
textDiv.innerHTML = parseMarkdown(content);
scrollToBottom();
}
}
}
function updateSendButton(loading) {
const btn = document.getElementById('send-btn');
if (btn) {
btn.disabled = loading;
btn.classList.toggle('loading', loading);
}
}
function toggleCanvas(open) {
state.canvasOpen = open ?? !state.canvasOpen;
const panel = document.getElementById('canvas-panel');
const btn = document.getElementById('canvas-toggle');
if (state.canvasOpen) {
panel?.classList.add('open');
btn?.classList.add('active');
} else {
panel?.classList.remove('open');
btn?.classList.remove('active');
}
}
function copyCanvasContent() {
const content = document.getElementById('canvas-content')?.value;
if (content) {
navigator.clipboard.writeText(content);
}
}
function scrollToBottom() {
const container = document.getElementById('messages-container');
if (container) {
container.scrollTop = container.scrollHeight;
}
}
// ==================== Utilities ====================
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(ts) {
const date = new Date(ts);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
const diff = now - date;
if (diff < 7 * 24 * 60 * 60 * 1000) {
return date.toLocaleDateString([], { weekday: 'short' });
}
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
// ==================== Admin Panel Rendering ====================
function renderAdminPage() {
return `
<div class="admin-layout">
<!-- Admin Sidebar -->
<aside class="admin-sidebar">
<div class="admin-sidebar-header">
<h2>Sovereign Admin</h2>
<p>Theta42 Sovereign AI Appliance</p>
</div>
<div class="admin-sidebar-menu">
<button class="admin-menu-item ${state.adminTab === 'models' ? 'active' : ''}" data-tab="models">Model Management</button>
<button class="admin-menu-item ${state.adminTab === 'quotas' ? 'active' : ''}" data-tab="quotas">Quotas & Schedules</button>
<button class="admin-menu-item ${state.adminTab === 'auditing' ? 'active' : ''}" data-tab="auditing">Live Monitoring & Logs</button>
<button class="admin-menu-item ${state.adminTab === 'networking' ? 'active' : ''}" data-tab="networking">Network Settings</button>
</div>
<div class="admin-sidebar-footer">
<button id="admin-close-btn" class="btn-admin-close">Return to Chat</button>
</div>
</aside>
<!-- Admin Content -->
<main class="admin-content">
<div class="admin-header">
<h1>Dashboard: ${getTabTitle()}</h1>
</div>
<div class="admin-body">
${renderAdminTabContent()}
</div>
</main>
</div>
`;
}
function getTabTitle() {
switch (state.adminTab) {
case 'models': return 'Model Management';
case 'quotas': return 'Quotas & Schedules';
case 'auditing': return 'Live Monitoring & Compliance Logs';
case 'networking': return 'Network Interfaces Settings';
default: return '';
}
}
function renderAdminTabContent() {
switch (state.adminTab) {
case 'models': return renderModelsTab();
case 'quotas': return renderQuotasTab();
case 'auditing': return renderAuditingTab();
case 'networking': return renderNetworkingTab();
default: return '';
}
}
function renderModelsTab() {
const manifestModels = state.preapprovedModels?.models || [
{ filename: 'SmolLM2-135M-Instruct-Q8_0.gguf', sha256: '5a1395716f7913741cc51d98581b9b1228d80987a9f7d3664106742eb06bba83' }
];
return `
<div class="admin-card">
<h3>Active Inference Model</h3>
<p class="card-desc">Select the active LLM to load into the AI-Core engine.</p>
<div class="active-model-selector flex gap-2">
<select id="admin-active-model-select" style="flex: 1; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text);">
${state.models.map(m => `
<option value="${m.id}" ${m.id === state.adminStatus?.activeModel ? 'selected' : ''}>${m.id}</option>
`).join('')}
</select>
<button id="btn-reload-active-model" class="btn-primary">Apply & Reload</button>
</div>
</div>
<div class="admin-card">
<h3>Pre-approved Models (Theta42 Registry)</h3>
<p class="card-desc">Download and verify official pre-approved model assets directly from HQ registry.</p>
<table class="admin-table">
<thead>
<tr>
<th>Filename</th>
<th>SHA-256 Hash</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${manifestModels.map(m => {
const isInstalled = state.models.some(loaded => loaded.id === m.filename);
return `
<tr>
<td class="font-mono text-sm">${m.filename}</td>
<td class="font-mono text-xs">${m.sha256.substring(0, 20)}...</td>
<td><span class="badge ${isInstalled ? 'badge-success' : 'badge-warn'}">${isInstalled ? 'Installed' : 'Available'}</span></td>
<td>
${isInstalled ? `
<button class="btn-secondary" style="opacity: 0.5;" disabled>Downloaded</button>
` : `
<button class="btn-primary btn-download-model" data-filename="${m.filename}" data-sha256="${m.sha256}">Download</button>
`}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
<div class="admin-card">
<h3>Upload Custom Model (.gguf)</h3>
<p class="card-desc">Staged models are automatically checked against the manifest and reload the active vLLM context.</p>
<form id="custom-model-upload-form" class="flex flex-col gap-2">
<div class="flex gap-2">
<input type="text" id="upload-model-name" placeholder="Filename (e.g. MyModel-Q4_K_M.gguf)" required style="flex:1; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text);">
<input type="text" id="upload-model-hash" placeholder="SHA-256 Hash (Optional)" style="flex:1; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text);">
</div>
<div class="upload-dropzone" style="border: 2px dashed var(--border); padding: 2rem; text-align: center; border-radius: 8px; cursor: pointer; margin: 1rem 0;">
<input type="file" id="upload-model-file" accept=".gguf" required style="cursor: pointer;">
<p style="margin-top: 0.5rem; color: var(--text-muted);">Select custom GGUF model file from disk...</p>
</div>
<button type="submit" class="btn-primary" style="align-self: start;">Upload & Deploy</button>
</form>
<div id="upload-progress" class="hidden font-mono text-sm" style="margin-top: 1rem;">Progress: <span id="upload-percentage">0%</span></div>
</div>
`;
}
function renderQuotasTab() {
const settings = state.adminStatus?.settings || { dailyTokenQuota: 50000, schedule: { enabled: false, startHour: 9, endHour: 17 } };
return `
<div class="admin-card">
<h3>Inference API Usage Quota</h3>
<p class="card-desc">Set the maximum daily prompt token consumption allowed per user before rate-limiting.</p>
<div class="flex flex-col gap-2" style="max-width: 400px; margin-top: 1rem;">
<div class="flex justify-between font-mono text-sm" style="display: flex; justify-content: space-between;">
<label for="quota-slider">Daily Token Limit:</label>
<span id="quota-value" style="font-weight: bold; color: var(--primary);">${settings.dailyTokenQuota} tokens</span>
</div>
<input type="range" id="quota-slider" min="1000" max="1000000" step="5000" value="${settings.dailyTokenQuota}" style="width: 100%;">
</div>
</div>
<div class="admin-card">
<h3>Scheduled Operational Hours</h3>
<p class="card-desc">Enable scheduled hours of operation to secure inference resources outside business hours.</p>
<div class="flex flex-col gap-4" style="max-width: 400px; display: flex; flex-direction: column; gap: 1rem; margin-top: 1rem;">
<label class="toggle-switch flex gap-2 align-items-center" style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="schedule-enabled-check" ${settings.schedule?.enabled ? 'checked' : ''}>
<span class="toggle-label font-semibold">Enable Schedule Restrictions</span>
</label>
<div class="flex gap-4" style="display: flex; gap: 1.5rem;">
<div class="flex flex-col gap-1" style="display: flex; flex-direction: column; gap: 0.25rem;">
<label for="schedule-start">Start Hour (0-23):</label>
<input type="number" id="schedule-start" min="0" max="23" value="${settings.schedule?.startHour || 9}" style="padding: 0.4rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text);">
</div>
<div class="flex flex-col gap-1" style="display: flex; flex-direction: column; gap: 0.25rem;">
<label for="schedule-end">End Hour (0-23):</label>
<input type="number" id="schedule-end" min="0" max="23" value="${settings.schedule?.endHour || 17}" style="padding: 0.4rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text);">
</div>
</div>
</div>
</div>
<button id="btn-save-settings" class="btn-primary" style="margin-top: 1.5rem;">Save Quotas & Schedules</button>
`;
}
function renderAuditingTab() {
const sessions = state.activeSessions || [];
const logs = state.auditLogs || [];
return `
<div class="admin-card">
<h3>Live Session Monitor</h3>
<p class="card-desc">Real-time usage metrics and active models of connected users.</p>
<table class="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Inference Queries</th>
<th>Daily Prompt Tokens (Est)</th>
<th>Last Model Used</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody>
${sessions.length === 0 ? `
<tr><td colspan="5" style="text-align: center; font-family: monospace; padding: 1.5rem;">No active user sessions tracked today</td></tr>
` : sessions.map(s => `
<tr>
<td style="font-weight: bold;">${s.user}</td>
<td class="font-mono">${s.queriesCount}</td>
<td class="font-mono">${s.totalPromptTokensEstimate} / ${state.adminStatus?.settings?.dailyTokenQuota || 50000}</td>
<td class="font-mono text-xs">${s.lastModel || '-'}</td>
<td style="font-size: 0.8rem;">${s.lastActive ? new Date(s.lastActive).toLocaleString() : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div class="admin-card">
<h3>Cryptographic Compliance Audit Logs</h3>
<p class="card-desc">Download log packages signed by the <code>cryptographic-audit-logger</code> on the host VM.</p>
<table class="admin-table">
<thead>
<tr>
<th>Log Package / Signature</th>
<th>Size (Bytes)</th>
<th>Date Created</th>
<th>Verification Action</th>
</tr>
</thead>
<tbody>
${logs.length === 0 ? `
<tr><td colspan="4" style="text-align: center; font-family: monospace; padding: 1.5rem;">No compliance logs rotated in audit storage</td></tr>
` : logs.map(l => `
<tr>
<td class="font-mono text-sm">${l.name}</td>
<td class="font-mono text-xs">${l.size}</td>
<td style="font-size: 0.8rem;">${new Date(l.mtime).toLocaleString()}</td>
<td>
<a class="btn-link" href="/api/admin/audit-logs/${l.name}" target="_blank" style="color: var(--primary); text-decoration: underline; cursor: pointer;">Download Package</a>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
function renderNetworkingTab() {
const net = state.adminStatus?.network || {};
const configured = net.configured || { address: '192.168.1.237', gateway: '192.168.1.1', netmask: '255.255.255.0' };
return `
<div class="admin-card">
<h3>Static IP Configuration</h3>
<p class="card-desc">Exposes the static networking parameters of the host appliance bridge (vmbr0).</p>
<form id="network-settings-form" class="flex flex-col gap-4" style="max-width: 400px; display: flex; flex-direction: column; gap: 1rem;">
<div class="flex flex-col gap-1" style="display: flex; flex-direction: column; gap: 0.25rem;">
<label for="net-ip">Static IP Address:</label>
<input type="text" id="net-ip" value="${configured.address || '192.168.1.237'}" placeholder="e.g. 192.168.1.237" required style="padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text);">
</div>
<div class="flex flex-col gap-1" style="display: flex; flex-direction: column; gap: 0.25rem;">
<label for="net-mask">Network Subnet Mask:</label>
<input type="text" id="net-mask" value="${configured.netmask || '255.255.255.0'}" placeholder="e.g. 255.255.255.0" required style="padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text);">
</div>
<div class="flex flex-col gap-1" style="display: flex; flex-direction: column; gap: 0.25rem;">
<label for="net-gateway">Default Gateway IP:</label>
<input type="text" id="net-gateway" value="${configured.gateway || '192.168.1.1'}" placeholder="e.g. 192.168.1.1" required style="padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text);">
</div>
<button type="submit" class="btn-primary" style="align-self: start; margin-top: 0.5rem;">Apply Network Settings</button>
</form>
<div id="net-warning" class="hidden" style="margin-top: 1rem; color: #f59e0b; font-size: 0.9rem; font-weight: bold;">
WARNING: Changing the static IP will restart host interfaces. You may temporarily lose connection.
</div>
</div>
<div class="admin-card" style="margin-top: 1.5rem;">
<h3>Active Host Network Configuration File</h3>
<p class="card-desc">Raw print of <code>/etc/network/interfaces</code> on the host hypervisor.</p>
<pre class="network-interfaces-pre" style="font-family: monospace; font-size: 0.8rem; background: #0f172a; color: #e2e8f0; border-radius: 6px; padding: 1rem; overflow-x: auto; margin-top: 0.75rem; white-space: pre-wrap;">${escapeHtml(net.interfaces || 'No configuration output fetched')}</pre>
</div>
`;
}
async function loadAdminData() {
try {
const [statusRes, manifestRes, logsRes, monitorRes, modelsRes] = await Promise.all([
api.getAdminStatus(),
api.getPreapprovedModels(),
api.getAuditLogs(),
api.getMonitoringStats(),
api.getModels()
]);
state.adminStatus = statusRes;
state.preapprovedModels = manifestRes;
state.auditLogs = logsRes?.logs || [];
state.activeSessions = monitorRes?.activeSessions || [];
state.models = (modelsRes?.data || []).map(m => ({
id: m.id,
name: m.name || m.id
}));
} catch (err) {
console.error('Failed to load admin panel data:', err);
}
}
// ==================== Initialization ====================
async function loadInitialData() {
try {
const [convRes, modelsRes] = await Promise.all([
api.getConversations(),
api.getModels()
]);
state.conversations = convRes || [];
state.models = (modelsRes?.data || []).map(m => ({
id: m.id,
name: m.name || m.id
}));
// Restore selected model
const savedModel = localStorage.getItem('selected-model');
if (savedModel && state.models.find(m => m.id === savedModel)) {
state.selectedModel = savedModel;
}
} catch (err) {
console.error('Failed to load initial data:', err);
}
}
async function init() {
try {
const result = await api.getStatus();
if (result.authenticated) {
state.user = result.user;
await loadInitialData();
}
} catch (err) {
console.error('Failed to check auth status:', err);
}
renderApp();
}
init();