Files
openclaw-webui/client/main.js
Nova 2ab0875ef2 Add user warnings for file truncation and size limits
- Alert user when file exceeds 1MB (rejected)
- Warn in filename when file is truncated (>50KB)
- Show clear '[TRUNCATED]' marker in content
- Better error handling with user-facing messages
2026-02-25 03:56:53 +00:00

814 lines
24 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'
};
// ==================== 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();
}
};
// ==================== 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 {
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>OpenClaw WebUI</h1>
<p>Sign in to continue</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">
<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>
</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 OpenClaw..." 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 OpenClaw</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);
}
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' });
}
// ==================== 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();