1319 lines
46 KiB
JavaScript
1319 lines
46 KiB
JavaScript
/**
|
||
* 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, '<')
|
||
.replace(/>/g, '>');
|
||
|
||
// 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(); |