/**
* 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, '>');
// Code blocks (with language)
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
const langClass = lang ? `language-${lang}` : '';
return `
${code}
`;
});
// Inline code
text = text.replace(/`([^`]+)`/g, '$1');
// Bold
text = text.replace(/\*\*([^*]+)\*\*/g, '$1');
// Italic
text = text.replace(/\*([^*]+)\*/g, '$1');
// Links
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
// Line breaks
text = text.replace(/\n/g, '
');
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 `
`;
}
function renderMainPage() {
return `
${state.messages.length === 0 ? renderEmptyState() : renderMessages()}
`;
}
function renderConversationsList() {
if (state.conversations.length === 0) {
return 'No conversations yet
';
}
return state.conversations.map(conv => `
${escapeHtml(conv.title)}
${formatDate(conv.updatedAt)}
`).join('');
}
function renderEmptyState() {
return `
Welcome to Sovereign Chat
Start a conversation or upload files to begin
`;
}
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 = `
${msg.files.map(f => `
`).join('')}
`;
}
return `
${isUser ? (state.user.username?.[0]?.toUpperCase() || 'U') : '⚡'}
${filesHtml}
${parseMarkdown(content)}
`;
}
// ==================== 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 => `
${escapeHtml(f.name)}
`).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 `
${renderAdminTabContent()}
`;
}
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 `
Active Inference Model
Select the active LLM to load into the AI-Core engine.
Pre-approved Models (Theta42 Registry)
Download and verify official pre-approved model assets directly from HQ registry.
| Filename |
SHA-256 Hash |
Status |
Action |
${manifestModels.map(m => {
const isInstalled = state.models.some(loaded => loaded.id === m.filename);
return `
| ${m.filename} |
${m.sha256.substring(0, 20)}... |
${isInstalled ? 'Installed' : 'Available'} |
${isInstalled ? `
` : `
`}
|
`;
}).join('')}
Upload Custom Model (.gguf)
Staged models are automatically checked against the manifest and reload the active vLLM context.
Progress: 0%
`;
}
function renderQuotasTab() {
const settings = state.adminStatus?.settings || { dailyTokenQuota: 50000, schedule: { enabled: false, startHour: 9, endHour: 17 } };
return `
Inference API Usage Quota
Set the maximum daily prompt token consumption allowed per user before rate-limiting.
Scheduled Operational Hours
Enable scheduled hours of operation to secure inference resources outside business hours.
`;
}
function renderAuditingTab() {
const sessions = state.activeSessions || [];
const logs = state.auditLogs || [];
return `
Live Session Monitor
Real-time usage metrics and active models of connected users.
| Username |
Inference Queries |
Daily Prompt Tokens (Est) |
Last Model Used |
Last Activity |
${sessions.length === 0 ? `
| No active user sessions tracked today |
` : sessions.map(s => `
| ${s.user} |
${s.queriesCount} |
${s.totalPromptTokensEstimate} / ${state.adminStatus?.settings?.dailyTokenQuota || 50000} |
${s.lastModel || '-'} |
${s.lastActive ? new Date(s.lastActive).toLocaleString() : '-'} |
`).join('')}
Cryptographic Compliance Audit Logs
Download log packages signed by the cryptographic-audit-logger on the host VM.
| Log Package / Signature |
Size (Bytes) |
Date Created |
Verification Action |
${logs.length === 0 ? `
| No compliance logs rotated in audit storage |
` : logs.map(l => `
| ${l.name} |
${l.size} |
${new Date(l.mtime).toLocaleString()} |
Download Package
|
`).join('')}
`;
}
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 `
Static IP Configuration
Exposes the static networking parameters of the host appliance bridge (vmbr0).
WARNING: Changing the static IP will restart host interfaces. You may temporarily lose connection.
Active Host Network Configuration File
Raw print of /etc/network/interfaces on the host hypervisor.
${escapeHtml(net.interfaces || 'No configuration output fetched')}
`;
}
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();