lollmsbot 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lollmsbot/ui/app.py ADDED
@@ -0,0 +1,1122 @@
1
+ """
2
+ Web UI application for LollmsBot.
3
+
4
+ Uses shared Agent for all business logic.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import os
11
+ import time
12
+ import hashlib
13
+ from contextlib import asynccontextmanager
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import AsyncGenerator, Dict, List, Optional, Set, Any
17
+
18
+ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, Query
19
+ from fastapi.staticfiles import StaticFiles
20
+ from fastapi.templating import Jinja2Templates
21
+ from fastapi.responses import FileResponse
22
+
23
+ from lollmsbot.config import BotConfig, LollmsSettings
24
+ from lollmsbot.agent import Agent
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class ConnectionManager:
31
+ """Manage WebSocket connections."""
32
+
33
+ def __init__(self) -> None:
34
+ self.active_connections: List[WebSocket] = []
35
+ self._lock = asyncio.Lock()
36
+
37
+ async def connect(self, websocket: WebSocket) -> None:
38
+ await websocket.accept()
39
+ async with self._lock:
40
+ self.active_connections.append(websocket)
41
+ logger.info(f"WebSocket connected. Total: {len(self.active_connections)}")
42
+
43
+ async def disconnect(self, websocket: WebSocket) -> None:
44
+ async with self._lock:
45
+ if websocket in self.active_connections:
46
+ self.active_connections.remove(websocket)
47
+
48
+ async def send_to(self, websocket: WebSocket, message: dict) -> bool:
49
+ try:
50
+ await websocket.send_json(message)
51
+ return True
52
+ except Exception:
53
+ return False
54
+
55
+
56
+ class PendingFile:
57
+ """Represents a file pending download in the Web UI."""
58
+ def __init__(
59
+ self,
60
+ file_id: str,
61
+ file_path: str,
62
+ filename: str,
63
+ description: str,
64
+ user_id: str,
65
+ ):
66
+ self.file_id = file_id
67
+ self.file_path = file_path
68
+ self.filename = filename
69
+ self.description = description
70
+ self.user_id = user_id
71
+ self.created_at = time.time()
72
+ self.content_type = self._guess_content_type(filename)
73
+
74
+ def _guess_content_type(self, filename: str) -> Optional[str]:
75
+ """Guess MIME type from filename extension."""
76
+ ext = Path(filename).suffix.lower()
77
+ mime_types = {
78
+ ".html": "text/html",
79
+ ".htm": "text/html",
80
+ ".css": "text/css",
81
+ ".js": "application/javascript",
82
+ ".json": "application/json",
83
+ ".py": "text/x-python",
84
+ ".txt": "text/plain",
85
+ ".md": "text/markdown",
86
+ ".csv": "text/csv",
87
+ ".png": "image/png",
88
+ ".jpg": "image/jpeg",
89
+ ".jpeg": "image/jpeg",
90
+ ".gif": "image/gif",
91
+ ".svg": "image/svg+xml",
92
+ ".pdf": "application/pdf",
93
+ ".zip": "application/zip",
94
+ }
95
+ return mime_types.get(ext, "application/octet-stream")
96
+
97
+ def is_expired(self, ttl_seconds: float = 3600.0) -> bool:
98
+ """Check if file download has expired."""
99
+ return time.time() - self.created_at > ttl_seconds
100
+
101
+
102
+ class WebUI:
103
+ """
104
+ Web UI for LollmsBot using shared Agent.
105
+
106
+ All chat processing is delegated to the Agent instance.
107
+ Includes file delivery support with download endpoints.
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ agent: Agent,
113
+ lollms_settings: Optional[LollmsSettings] = None,
114
+ bot_config: Optional[BotConfig] = None,
115
+ static_dir: Optional[Path] = None,
116
+ templates_dir: Optional[Path] = None,
117
+ verbose: bool = True,
118
+ ) -> None:
119
+ self.agent = agent # Shared Agent instance!
120
+ self.lollms_settings = lollms_settings or LollmsSettings.from_env()
121
+ self.bot_config = bot_config or BotConfig.from_env()
122
+ self.verbose = verbose
123
+
124
+ self.package_dir = Path(__file__).parent
125
+ self.static_dir = static_dir or self.package_dir / "static"
126
+ self.templates_dir = templates_dir or self.package_dir / "templates"
127
+
128
+ # File delivery storage
129
+ self._pending_files: Dict[str, PendingFile] = {}
130
+ self._file_cleanup_task: Optional[asyncio.Task] = None
131
+ self._file_ttl_seconds: float = 3600.0 # Files expire after 1 hour
132
+
133
+ if self.verbose:
134
+ self._print_startup_banner()
135
+
136
+ self.static_dir.mkdir(parents=True, exist_ok=True)
137
+ self.templates_dir.mkdir(parents=True, exist_ok=True)
138
+ self._ensure_static_files()
139
+
140
+ self.manager = ConnectionManager()
141
+ self.active_sessions: Set[str] = set()
142
+
143
+ # Set up file delivery callback
144
+ self.agent.set_file_delivery_callback(self._deliver_files)
145
+
146
+ # Set up tool event callback for real-time updates
147
+ self.agent.set_tool_event_callback(self._handle_tool_event)
148
+
149
+ self.app = self._create_app()
150
+
151
+ if self.verbose:
152
+ logger.info(f"WebUI initialized with Agent: {agent.name}")
153
+
154
+ def _generate_file_id(self, user_id: str, filename: str) -> str:
155
+ """Generate unique file ID for download URL."""
156
+ timestamp = str(time.time())
157
+ hash_input = f"{user_id}:{filename}:{timestamp}:{os.urandom(8).hex()}"
158
+ return hashlib.sha256(hash_input.encode()).hexdigest()[:16]
159
+
160
+ async def _deliver_files(self, user_id: str, files: List[Dict[str, Any]]) -> bool:
161
+ """Store files for Web UI download delivery.
162
+
163
+ This is the callback registered with the Agent for file delivery.
164
+ Files are stored with unique IDs and made available via download endpoint.
165
+ Users are notified via WebSocket about available downloads.
166
+
167
+ Args:
168
+ user_id: Agent-format user ID (e.g., "web:session_abc123").
169
+ files: List of file dicts with 'path', 'filename', 'description' keys.
170
+
171
+ Returns:
172
+ True if files were registered for delivery successfully.
173
+ """
174
+ if not files:
175
+ logger.debug(f"No files to deliver for {user_id}")
176
+ return True
177
+
178
+ try:
179
+ logger.info(f"šŸ“¤ Registering {len(files)} file(s) for Web UI delivery to {user_id}")
180
+
181
+ file_infos = []
182
+ for file_info in files:
183
+ file_path = file_info.get("path")
184
+ filename = file_info.get("filename") or Path(file_path).name if file_path else "unnamed"
185
+ description = file_info.get("description", "")
186
+
187
+ if not file_path or not Path(file_path).exists():
188
+ logger.warning(f"File not found for delivery: {file_path}")
189
+ continue
190
+
191
+ # Generate unique file ID
192
+ file_id = self._generate_file_id(user_id, filename)
193
+
194
+ # Store file info
195
+ pending = PendingFile(
196
+ file_id=file_id,
197
+ file_path=file_path,
198
+ filename=filename,
199
+ description=description,
200
+ user_id=user_id,
201
+ )
202
+ self._pending_files[file_id] = pending
203
+
204
+ file_infos.append({
205
+ "file_id": file_id,
206
+ "filename": filename,
207
+ "description": description,
208
+ "download_url": f"/download/{file_id}",
209
+ })
210
+ logger.info(f"āœ… Registered file '{filename}' with ID {file_id}")
211
+
212
+ # Notify connected clients about available downloads
213
+ # Find WebSocket connections for this user
214
+ await self._notify_file_available(user_id, file_infos)
215
+
216
+ return True
217
+
218
+ except Exception as e:
219
+ logger.error(f"Failed to register files for delivery to {user_id}: {e}")
220
+ return False
221
+
222
+ async def _notify_file_available(self, user_id: str, file_infos: List[Dict[str, Any]]) -> None:
223
+ """Notify WebSocket clients about available file downloads."""
224
+ # For simplicity, broadcast to all connections with matching user_id prefix
225
+ message = {
226
+ "type": "files_ready",
227
+ "files": file_infos,
228
+ }
229
+
230
+ # In a real implementation, you'd track which WebSocket belongs to which user
231
+ # For now, broadcast to all and let client filter
232
+ disconnected = []
233
+ for ws in self.manager.active_connections:
234
+ try:
235
+ await ws.send_json(message)
236
+ except Exception:
237
+ disconnected.append(ws)
238
+
239
+ # Clean up disconnected clients
240
+ for ws in disconnected:
241
+ await self.manager.disconnect(ws)
242
+
243
+ async def _cleanup_expired_files(self) -> None:
244
+ """Background task to clean up expired file registrations."""
245
+ while True:
246
+ try:
247
+ expired_ids = [
248
+ file_id for file_id, pending in list(self._pending_files.items())
249
+ if pending.is_expired(self._file_ttl_seconds)
250
+ ]
251
+ for file_id in expired_ids:
252
+ del self._pending_files[file_id]
253
+ logger.debug(f"Cleaned up expired file registration: {file_id}")
254
+
255
+ await asyncio.sleep(60.0) # Check every minute
256
+
257
+ except asyncio.CancelledError:
258
+ break
259
+ except Exception as e:
260
+ logger.error(f"Error in file cleanup: {e}")
261
+ await asyncio.sleep(60.0)
262
+
263
+ async def _handle_tool_event(self, event_type: str, tool_name: str, data: Optional[Dict[str, Any]]) -> None:
264
+ """Handle tool execution events from the Agent and broadcast to WebSocket clients."""
265
+ ui_events = {
266
+ "planning_start": {"type": "thinking", "content": "Analyzing your request..."},
267
+ "planning_complete": {
268
+ "type": "tools_planned",
269
+ "content": f"Planning complete: {data.get('tools_count', 0)} tool(s) will be used" if data else "Planning complete",
270
+ "tools": data.get("tools_list", []) if data else [],
271
+ },
272
+ "tool_start": {
273
+ "type": "tool_calling",
274
+ "content": f"Calling tool: {tool_name}...",
275
+ "tool_name": tool_name,
276
+ "parameters": data.get("parameters", {}) if data else {},
277
+ },
278
+ "tool_complete": {
279
+ "type": "tool_complete",
280
+ "content": f"Tool '{tool_name}' completed",
281
+ "tool_name": tool_name,
282
+ "success": data.get("success", False) if data else False,
283
+ "duration": data.get("duration", 0) if data else 0,
284
+ },
285
+ "tool_error": {
286
+ "type": "tool_error",
287
+ "content": f"Tool '{tool_name}' failed: {data.get('error', 'Unknown error') if data else 'Unknown error'}",
288
+ "tool_name": tool_name,
289
+ "error": data.get("error", "Unknown error") if data else "Unknown error",
290
+ },
291
+ "tool_denied": {
292
+ "type": "tool_denied",
293
+ "content": f"Tool '{tool_name}' not available: {data.get('reason', 'Permission denied') if data else 'Permission denied'}",
294
+ "tool_name": tool_name,
295
+ },
296
+ }
297
+
298
+ ui_message = ui_events.get(event_type)
299
+ if ui_message:
300
+ disconnected = []
301
+ for ws in self.manager.active_connections:
302
+ try:
303
+ await ws.send_json({
304
+ "type": "tool_event",
305
+ "event_type": event_type,
306
+ **ui_message,
307
+ })
308
+ except Exception:
309
+ disconnected.append(ws)
310
+
311
+ for ws in disconnected:
312
+ await self.manager.disconnect(ws)
313
+
314
+ def _print_startup_banner(self) -> None:
315
+ """Print startup banner."""
316
+ try:
317
+ from rich.console import Console
318
+ from rich.panel import Panel
319
+ from rich.text import Text
320
+
321
+ console = Console()
322
+
323
+ banner_text = Text()
324
+ banner_text.append("šŸ¤– Web UI\n", style="bold cyan")
325
+ banner_text.append(f"Agent: {self.agent.name}\n", style="dim")
326
+
327
+ panel = Panel(
328
+ banner_text,
329
+ border_style="blue",
330
+ title="[bold]Web Interface[/bold]",
331
+ )
332
+ console.print()
333
+ console.print(panel)
334
+ console.print()
335
+
336
+ except ImportError:
337
+ print(f"\nšŸ¤– Web UI - Agent: {self.agent.name}\n")
338
+
339
+ def _ensure_static_files(self) -> None:
340
+ """Create default static files if missing."""
341
+ css_dir = self.static_dir / "css"
342
+ js_dir = self.static_dir / "js"
343
+ css_dir.mkdir(parents=True, exist_ok=True)
344
+ js_dir.mkdir(parents=True, exist_ok=True)
345
+
346
+ style_css = css_dir / "style.css"
347
+ if not style_css.exists():
348
+ style_css.write_text(self._default_css(), encoding='utf-8')
349
+ logger.info(f"Created default CSS at {style_css}")
350
+
351
+ app_js = js_dir / "app.js"
352
+ if not app_js.exists():
353
+ app_js.write_text(self._default_js(), encoding='utf-8')
354
+ logger.info(f"Created default JS at {app_js}")
355
+
356
+ index_html = self.templates_dir / "index.html"
357
+ if not index_html.exists():
358
+ index_html.write_text(self._default_html_template(), encoding='utf-8')
359
+ logger.info(f"Created default HTML template at {index_html}")
360
+
361
+ def _default_css(self) -> str:
362
+ """Default CSS."""
363
+ return """/* Minimal CSS for LollmsBot Web UI */
364
+ :root { --primary: #6366f1; --bg: #0f172a; --text: #f8fafc; }
365
+ body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; }
366
+ .chat-container { display: flex; flex-direction: column; height: 100vh; }
367
+ .messages { flex: 1; overflow-y: auto; padding: 20px; }
368
+ .input-area { padding: 20px; border-top: 1px solid #334155; }
369
+ .message { margin: 10px 0; padding: 12px; border-radius: 8px; }
370
+ .message.user { background: var(--primary); margin-left: 20%; }
371
+ .message.assistant { background: #1e293b; margin-right: 20%; }
372
+ input, button { padding: 10px; border-radius: 4px; border: none; }
373
+ input { flex: 1; background: #334155; color: var(--text); }
374
+ button { background: var(--primary); color: white; cursor: pointer; }
375
+ .input-row { display: flex; gap: 10px; }
376
+
377
+ /* File download styles */
378
+ .file-download {
379
+ margin: 10px 0;
380
+ padding: 12px 16px;
381
+ background: #1e293b;
382
+ border: 1px solid #334155;
383
+ border-radius: 8px;
384
+ display: flex;
385
+ align-items: center;
386
+ gap: 12px;
387
+ }
388
+
389
+ .file-download-icon {
390
+ font-size: 1.5rem;
391
+ }
392
+
393
+ .file-download-info {
394
+ flex: 1;
395
+ }
396
+
397
+ .file-download-name {
398
+ font-weight: 500;
399
+ color: #f8fafc;
400
+ }
401
+
402
+ .file-download-desc {
403
+ font-size: 0.85rem;
404
+ color: #94a3b8;
405
+ }
406
+
407
+ .file-download-btn {
408
+ padding: 8px 16px;
409
+ background: #10b981;
410
+ color: white;
411
+ border-radius: 6px;
412
+ text-decoration: none;
413
+ font-size: 0.9rem;
414
+ transition: background 0.2s;
415
+ }
416
+
417
+ .file-download-btn:hover {
418
+ background: #059669;
419
+ }
420
+
421
+ .files-section {
422
+ margin: 16px 0;
423
+ padding: 16px;
424
+ background: rgba(99, 102, 241, 0.05);
425
+ border: 1px solid rgba(99, 102, 241, 0.2);
426
+ border-radius: 12px;
427
+ }
428
+
429
+ .files-section-title {
430
+ font-size: 0.9rem;
431
+ font-weight: 600;
432
+ color: #6366f1;
433
+ margin-bottom: 12px;
434
+ display: flex;
435
+ align-items: center;
436
+ gap: 8px;
437
+ }
438
+ """
439
+
440
+ def _default_js(self) -> str:
441
+ """Default JavaScript with file download support."""
442
+ return """// Web UI JavaScript with file download support
443
+ console.log('[ChatApp] Script loading...');
444
+
445
+ class ChatApp {
446
+ constructor() {
447
+ console.log('[ChatApp] Initializing...');
448
+ this.ws = null;
449
+ this.sessionId = 'web_' + Math.random().toString(36).substr(2, 9);
450
+ this.isConnected = false;
451
+ this.reconnectAttempts = 0;
452
+ this.maxReconnectAttempts = 5;
453
+ this.pendingFiles = [];
454
+ this.init();
455
+ }
456
+
457
+ init() {
458
+ console.log('[ChatApp] Setting up event listeners...');
459
+
460
+ if (document.readyState === 'loading') {
461
+ document.addEventListener('DOMContentLoaded', () => this.setupEventListeners());
462
+ } else {
463
+ this.setupEventListeners();
464
+ }
465
+
466
+ this.connect();
467
+ }
468
+
469
+ setupEventListeners() {
470
+ console.log('[ChatApp] Attaching button and input handlers...');
471
+
472
+ const sendBtn = document.getElementById('send-btn');
473
+ const messageInput = document.getElementById('message-input');
474
+
475
+ if (!sendBtn) {
476
+ console.error('[ChatApp] Send button not found!');
477
+ return;
478
+ }
479
+ if (!messageInput) {
480
+ console.error('[ChatApp] Message input not found!');
481
+ return;
482
+ }
483
+
484
+ sendBtn.addEventListener('click', (e) => {
485
+ console.log('[ChatApp] Send button clicked');
486
+ e.preventDefault();
487
+ this.send();
488
+ });
489
+
490
+ messageInput.addEventListener('keydown', (e) => {
491
+ if (e.key === 'Enter' && !e.shiftKey) {
492
+ console.log('[ChatApp] Enter key pressed');
493
+ e.preventDefault();
494
+ this.send();
495
+ }
496
+ });
497
+
498
+ console.log('[ChatApp] Event listeners attached successfully');
499
+ }
500
+
501
+ updateStatus(status, text) {
502
+ const statusDot = document.getElementById('status-dot');
503
+ const statusText = document.getElementById('status-text');
504
+
505
+ if (statusDot) {
506
+ statusDot.className = 'status-dot ' + (status === 'connected' ? '' : 'disconnected');
507
+ }
508
+ if (statusText) {
509
+ statusText.textContent = text;
510
+ }
511
+ console.log('[ChatApp] Status updated:', status, text);
512
+ }
513
+
514
+ connect() {
515
+ console.log('[ChatApp] Attempting WebSocket connection...');
516
+
517
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
518
+ const host = window.location.host;
519
+ let path = window.location.pathname;
520
+ path = path.replace(/\\\\/$/, '');
521
+
522
+ const wsPath = path ? path + '/ws/chat' : '/ws/chat';
523
+ const wsUrl = wsProtocol + '//' + host + wsPath;
524
+
525
+ console.log('[ChatApp] WebSocket URL:', wsUrl);
526
+
527
+ try {
528
+ this.ws = new WebSocket(wsUrl);
529
+ } catch (err) {
530
+ console.error('[ChatApp] Failed to create WebSocket:', err);
531
+ this.updateStatus('error', 'Connection failed');
532
+ return;
533
+ }
534
+
535
+ this.ws.onopen = () => {
536
+ console.log('[ChatApp] WebSocket connected!');
537
+ this.isConnected = true;
538
+ this.reconnectAttempts = 0;
539
+ this.updateStatus('connected', 'Connected');
540
+ this.addMessage('system', 'Connected to AI assistant');
541
+ };
542
+
543
+ this.ws.onmessage = (e) => {
544
+ console.log('[ChatApp] Message received:', e.data);
545
+ try {
546
+ const data = JSON.parse(e.data);
547
+
548
+ if (data.type === 'tool_event') {
549
+ this.handleToolEvent(data);
550
+ return;
551
+ }
552
+
553
+ if (data.type === 'files_ready') {
554
+ this.handleFilesReady(data.files);
555
+ return;
556
+ }
557
+
558
+ if (data.type === 'response') {
559
+ this.addMessage('assistant', data.content);
560
+ } else if (data.type === 'error') {
561
+ this.addMessage('system', 'Error: ' + data.message);
562
+ } else if (data.type === 'ping') {
563
+ console.log('[ChatApp] Ping received');
564
+ }
565
+ } catch (err) {
566
+ console.error('[ChatApp] Failed to parse message:', err);
567
+ }
568
+ };
569
+
570
+ this.ws.onerror = (err) => {
571
+ console.error('[ChatApp] WebSocket error:', err);
572
+ this.updateStatus('error', 'Connection error');
573
+ };
574
+
575
+ this.ws.onclose = (e) => {
576
+ console.log('[ChatApp] WebSocket closed:', e.code, e.reason);
577
+ this.isConnected = false;
578
+ this.updateStatus('disconnected', 'Disconnected');
579
+
580
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
581
+ this.reconnectAttempts++;
582
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts - 1), 30000);
583
+ console.log(`[ChatApp] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
584
+ this.updateStatus('reconnecting', `Reconnecting... (${this.reconnectAttempts})`);
585
+ setTimeout(() => this.connect(), delay);
586
+ } else {
587
+ this.updateStatus('failed', 'Connection failed - refresh page');
588
+ this.addMessage('system', 'Connection lost. Please refresh the page to reconnect.');
589
+ }
590
+ };
591
+ }
592
+
593
+ handleToolEvent(data) {
594
+ console.log('[ChatApp] Tool event:', data);
595
+
596
+ const eventType = data.event_type;
597
+ let messageText = '';
598
+
599
+ switch(eventType) {
600
+ case 'planning_start':
601
+ messageText = 'šŸ¤” Thinking...';
602
+ break;
603
+ case 'planning_complete':
604
+ const toolsCount = data.tools ? data.tools.length : 0;
605
+ messageText = toolsCount > 0 ? `šŸ“‹ Will use: ${data.tools.join(', ')}` : 'šŸ’¬ Responding directly...';
606
+ break;
607
+ case 'tool_calling':
608
+ messageText = `šŸ”§ Calling ${data.tool_name}...`;
609
+ break;
610
+ case 'tool_complete':
611
+ const successIcon = data.success ? 'āœ…' : 'āŒ';
612
+ messageText = `${successIcon} ${data.tool_name} done (${(data.duration || 0).toFixed(1)}s)`;
613
+ break;
614
+ case 'tool_error':
615
+ messageText = `šŸ’„ ${data.tool_name} failed: ${data.error}`;
616
+ break;
617
+ default:
618
+ messageText = `[${eventType}]`;
619
+ }
620
+
621
+ this.removeToolIndicators();
622
+ this.addToolIndicator(messageText);
623
+ }
624
+
625
+ handleFilesReady(files) {
626
+ console.log('[ChatApp] Files ready:', files);
627
+ this.pendingFiles = files;
628
+ this.showFileDownloads(files);
629
+ }
630
+
631
+ showFileDownloads(files) {
632
+ const messagesArea = document.getElementById('messages-area');
633
+ if (!messagesArea) return;
634
+
635
+ // Create files section
636
+ const filesSection = document.createElement('div');
637
+ filesSection.className = 'files-section';
638
+
639
+ const title = document.createElement('div');
640
+ title.className = 'files-section-title';
641
+ title.innerHTML = 'šŸ“Ž Generated Files';
642
+ filesSection.appendChild(title);
643
+
644
+ files.forEach(file => {
645
+ const downloadDiv = document.createElement('div');
646
+ downloadDiv.className = 'file-download';
647
+
648
+ const icon = document.createElement('span');
649
+ icon.className = 'file-download-icon';
650
+ icon.textContent = this.getFileIcon(file.filename);
651
+
652
+ const info = document.createElement('div');
653
+ info.className = 'file-download-info';
654
+
655
+ const name = document.createElement('div');
656
+ name.className = 'file-download-name';
657
+ name.textContent = file.filename;
658
+
659
+ const desc = document.createElement('div');
660
+ desc.className = 'file-download-desc';
661
+ desc.textContent = file.description || 'Generated file ready for download';
662
+
663
+ info.appendChild(name);
664
+ info.appendChild(desc);
665
+
666
+ const btn = document.createElement('a');
667
+ btn.className = 'file-download-btn';
668
+ btn.href = file.download_url;
669
+ btn.download = file.filename;
670
+ btn.textContent = 'Download';
671
+
672
+ downloadDiv.appendChild(icon);
673
+ downloadDiv.appendChild(info);
674
+ downloadDiv.appendChild(btn);
675
+ filesSection.appendChild(downloadDiv);
676
+ });
677
+
678
+ messagesArea.appendChild(filesSection);
679
+ messagesArea.scrollTop = messagesArea.scrollHeight;
680
+ }
681
+
682
+ getFileIcon(filename) {
683
+ const ext = filename.split('.').pop().toLowerCase();
684
+ const icons = {
685
+ html: '🌐', htm: '🌐', css: 'šŸŽØ', js: '⚔', py: 'šŸ',
686
+ json: 'šŸ“‹', txt: 'šŸ“', md: 'šŸ“„', csv: 'šŸ“Š',
687
+ png: 'šŸ–¼ļø', jpg: 'šŸ–¼ļø', jpeg: 'šŸ–¼ļø', gif: 'šŸ–¼ļø', svg: 'šŸ–¼ļø',
688
+ pdf: 'šŸ“‘', zip: 'šŸ“¦', tar: 'šŸ“¦', gz: 'šŸ“¦',
689
+ };
690
+ return icons[ext] || 'šŸ“„';
691
+ }
692
+
693
+ removeToolIndicators() {
694
+ const indicators = document.querySelectorAll('.tool-indicator');
695
+ indicators.forEach(el => {
696
+ el.style.opacity = '0.5';
697
+ setTimeout(() => el.remove(), 500);
698
+ });
699
+ }
700
+
701
+ addToolIndicator(text) {
702
+ const messagesArea = document.getElementById('messages-area');
703
+ if (!messagesArea) return;
704
+
705
+ const indicator = document.createElement('div');
706
+ indicator.className = 'tool-indicator';
707
+ indicator.style.cssText = `
708
+ padding: 8px 16px;
709
+ margin: 8px 20%;
710
+ background: rgba(99, 102, 241, 0.1);
711
+ border: 1px solid rgba(99, 102, 241, 0.3);
712
+ border-radius: 8px;
713
+ font-size: 0.85rem;
714
+ color: #94a3b8;
715
+ text-align: center;
716
+ transition: all 0.3s ease;
717
+ `;
718
+ indicator.textContent = text;
719
+ messagesArea.appendChild(indicator);
720
+ messagesArea.scrollTop = messagesArea.scrollHeight;
721
+ }
722
+
723
+ send() {
724
+ console.log('[ChatApp] Send called, connection state:', this.isConnected);
725
+
726
+ const input = document.getElementById('message-input');
727
+ if (!input) {
728
+ console.error('[ChatApp] Input not found!');
729
+ return;
730
+ }
731
+
732
+ const text = input.value.trim();
733
+ if (!text) {
734
+ console.log('[ChatApp] Empty message, ignoring');
735
+ return;
736
+ }
737
+
738
+ if (!this.isConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
739
+ console.error('[ChatApp] WebSocket not connected!');
740
+ this.addMessage('system', 'Not connected - please wait or refresh');
741
+ return;
742
+ }
743
+
744
+ console.log('[ChatApp] Sending message:', text);
745
+
746
+ this.removeToolIndicators();
747
+ this.addMessage('user', text);
748
+ input.value = '';
749
+
750
+ const message = {
751
+ type: 'message',
752
+ session_id: this.sessionId,
753
+ message: text
754
+ };
755
+
756
+ try {
757
+ this.ws.send(JSON.stringify(message));
758
+ console.log('[ChatApp] Message sent successfully');
759
+ } catch (err) {
760
+ console.error('[ChatApp] Failed to send:', err);
761
+ this.addMessage('system', 'Failed to send message');
762
+ }
763
+ }
764
+
765
+ addMessage(role, text) {
766
+ console.log('[ChatApp] Adding message:', role, text.substring(0, 50));
767
+
768
+ this.removeToolIndicators();
769
+
770
+ const messagesArea = document.getElementById('messages-area');
771
+ if (!messagesArea) {
772
+ console.error('[ChatApp] Messages area not found!');
773
+ return;
774
+ }
775
+
776
+ const welcomeScreen = document.getElementById('welcome-screen');
777
+ if (welcomeScreen) {
778
+ welcomeScreen.style.display = 'none';
779
+ }
780
+
781
+ const msgDiv = document.createElement('div');
782
+ msgDiv.className = 'message ' + role;
783
+
784
+ if (role === 'system') {
785
+ msgDiv.style.cssText = 'text-align: center; color: #64748b; font-style: italic; margin: 10px 0;';
786
+ } else if (role === 'user') {
787
+ msgDiv.style.cssText = 'background: #6366f1; color: white; margin-left: 20%; padding: 12px; border-radius: 8px; margin-bottom: 10px;';
788
+ } else if (role === 'assistant') {
789
+ msgDiv.style.cssText = 'background: #1e293b; color: #f8fafc; margin-right: 20%; padding: 12px; border-radius: 8px; margin-bottom: 10px;';
790
+ }
791
+
792
+ // Simple markdown-like formatting
793
+ let formattedText = text
794
+ .replace(/&/g, '&amp;')
795
+ .replace(/</g, '&lt;')
796
+ .replace(/>/g, '&gt;')
797
+ .replace(/\\`\\`\\`([\\s\\S]*?)\\`\\`\\`/g, '<pre><code>$1</code></pre>')
798
+ .replace(/\\`([^`]+)\\`/g, '<code>$1</code>')
799
+ .replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')
800
+ .replace(/\\*([^*]+)\\*/g, '<em>$1</em>')
801
+ .replace(/\\n/g, '<br>');
802
+
803
+ msgDiv.innerHTML = formattedText;
804
+ messagesArea.appendChild(msgDiv);
805
+ messagesArea.scrollTop = messagesArea.scrollHeight;
806
+ }
807
+ }
808
+
809
+ // Initialize
810
+ console.log('[ChatApp] Script loaded, waiting for initialization...');
811
+ const app = new ChatApp();
812
+ window.chatApp = app;
813
+ """
814
+
815
+ def _default_html_template(self) -> str:
816
+ """Default HTML template with download support."""
817
+ return """<!DOCTYPE html>
818
+ <html lang="en">
819
+ <head>
820
+ <meta charset="UTF-8">
821
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
822
+ <title>LollmsBot - AI Assistant</title>
823
+ <link rel="preconnect" href="https://fonts.googleapis.com">
824
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
825
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Fira+Code&display=swap" rel="stylesheet">
826
+ <link rel="stylesheet" href="./static/css/style.css">
827
+ </head>
828
+ <body>
829
+ <div class="app">
830
+ <header class="header">
831
+ <div class="logo">
832
+ <div class="logo-icon">AI</div>
833
+ <span>LollmsBot</span>
834
+ </div>
835
+ <div class="header-actions">
836
+ <button class="btn-icon" id="menu-btn" title="Menu">☰</button>
837
+ <button class="btn-icon" id="settings-btn" title="Settings">āš™</button>
838
+ </div>
839
+ </header>
840
+
841
+ <aside class="sidebar">
842
+ <div class="sidebar-section">
843
+ <button class="new-chat-btn" id="new-chat-btn">
844
+ <span>+</span>
845
+ New Chat
846
+ </button>
847
+ </div>
848
+
849
+ <div class="sidebar-section">
850
+ <div class="sidebar-title">Conversations</div>
851
+ <div class="conversation-list" id="conversation-list">
852
+ <div class="conversation-item active">
853
+ <div class="conversation-icon">C</div>
854
+ <span>Current Chat</span>
855
+ </div>
856
+ </div>
857
+ </div>
858
+
859
+ <div class="sidebar-section">
860
+ <div class="sidebar-title">Available Tools</div>
861
+ <div class="tools-grid">
862
+ <div class="tool-badge" title="File operations">Files</div>
863
+ <div class="tool-badge" title="HTTP requests">HTTP</div>
864
+ <div class="tool-badge" title="Calendar">Calendar</div>
865
+ <div class="tool-badge" title="Shell commands">Shell</div>
866
+ </div>
867
+ </div>
868
+
869
+ <div class="sidebar-section" style="margin-top: auto;">
870
+ <div class="status-indicator">
871
+ <span class="status-dot" id="status-dot"></span>
872
+ <span id="status-text">Connecting...</span>
873
+ </div>
874
+ </div>
875
+ </aside>
876
+
877
+ <main class="chat-container">
878
+ <div class="messages-area" id="messages-area">
879
+ <div class="welcome-screen" id="welcome-screen">
880
+ <div class="welcome-icon">šŸ¤–</div>
881
+ <div class="welcome-title">Welcome to LollmsBot</div>
882
+ <div class="welcome-subtitle">Your AI assistant is ready</div>
883
+ <div style="margin-top: 20px; font-size: 0.9rem; color: #64748b;">
884
+ <p>I can generate files for you! Try asking me to:</p>
885
+ <ul style="text-align: left; display: inline-block; margin-top: 8px;">
886
+ <li>Create an HTML game or app</li>
887
+ <li>Write Python scripts</li>
888
+ <li>Generate CSV data files</li>
889
+ <li>Build ZIP archives</li>
890
+ </ul>
891
+ </div>
892
+ </div>
893
+ </div>
894
+
895
+ <div class="input-area">
896
+ <div class="input-container">
897
+ <textarea
898
+ class="message-input"
899
+ id="message-input"
900
+ placeholder="Message LollmsBot... Try 'create a snake game'"
901
+ rows="1"
902
+ ></textarea>
903
+ <div class="input-actions">
904
+ <button class="send-btn" id="send-btn" title="Send">āž¤</button>
905
+ </div>
906
+ </div>
907
+ </div>
908
+ </main>
909
+ </div>
910
+
911
+ <script src="./static/js/app.js"></script>
912
+ </body>
913
+ </html>
914
+ """
915
+
916
+ def _create_app(self) -> FastAPI:
917
+ """Create FastAPI application with file download endpoints."""
918
+ app = FastAPI(title="LollmsBot Web UI")
919
+
920
+ # Mount static files
921
+ app.mount("/static", StaticFiles(directory=str(self.static_dir)), name="static")
922
+ templates = Jinja2Templates(directory=str(self.templates_dir))
923
+
924
+ @app.on_event("startup")
925
+ async def startup_event():
926
+ self._file_cleanup_task = asyncio.create_task(self._cleanup_expired_files())
927
+
928
+ @app.on_event("shutdown")
929
+ async def shutdown_event():
930
+ if self._file_cleanup_task:
931
+ self._file_cleanup_task.cancel()
932
+
933
+ @app.get("/")
934
+ async def index(request: Request):
935
+ return templates.TemplateResponse("index.html", {
936
+ "request": request,
937
+ "agent_name": self.agent.name,
938
+ "lollms_host": self.lollms_settings.host_address,
939
+ "max_history": self.bot_config.max_history,
940
+ })
941
+
942
+ @app.get("/health")
943
+ async def health():
944
+ return {
945
+ "status": "ok",
946
+ "ui": "running",
947
+ "agent": self.agent.name,
948
+ "agent_state": self.agent.state.name,
949
+ "websocket_clients": len(self.manager.active_connections),
950
+ "pending_files": len(self._pending_files),
951
+ }
952
+
953
+ @app.get("/download/{file_id}")
954
+ async def download_file(file_id: str):
955
+ """Download a generated file by its temporary ID."""
956
+ if file_id not in self._pending_files:
957
+ return {"error": "File not found or expired"}, 404
958
+
959
+ pending = self._pending_files[file_id]
960
+
961
+ # Check if file still exists
962
+ file_path = Path(pending.file_path)
963
+ if not file_path.exists():
964
+ del self._pending_files[file_id]
965
+ return {"error": "File no longer available"}, 404
966
+
967
+ # Check expiration
968
+ if pending.is_expired(self._file_ttl_seconds):
969
+ del self._pending_files[file_id]
970
+ return {"error": "File download link has expired"}, 410
971
+
972
+ return FileResponse(
973
+ path=str(file_path),
974
+ filename=pending.filename,
975
+ media_type=pending.content_type or "application/octet-stream",
976
+ )
977
+
978
+ @app.get("/files/list")
979
+ async def list_files(user_id: Optional[str] = Query(None)):
980
+ """List pending files."""
981
+ files = []
982
+ for file_id, pending in self._pending_files.items():
983
+ if user_id is None or pending.user_id == user_id:
984
+ files.append({
985
+ "file_id": file_id,
986
+ "filename": pending.filename,
987
+ "description": pending.description,
988
+ "download_url": f"/download/{file_id}",
989
+ "expires_in": int(self._file_ttl_seconds - (time.time() - pending.created_at)),
990
+ })
991
+ return {"files": files, "count": len(files)}
992
+
993
+ @app.websocket("/ws/chat")
994
+ async def websocket_endpoint(websocket: WebSocket):
995
+ await self.manager.connect(websocket)
996
+ session_id: Optional[str] = None
997
+
998
+ try:
999
+ while True:
1000
+ data = await websocket.receive_json()
1001
+ msg_type = data.get("type")
1002
+ session_id = data.get("session_id", "unknown")
1003
+
1004
+ if msg_type == "ping":
1005
+ await websocket.send_json({"type": "pong"})
1006
+ continue
1007
+
1008
+ if msg_type == "message":
1009
+ await self._handle_message(websocket, data, session_id)
1010
+
1011
+ except WebSocketDisconnect:
1012
+ logger.info(f"Client disconnected: {session_id}")
1013
+ except Exception as exc:
1014
+ logger.error(f"WebSocket error: {exc}")
1015
+ try:
1016
+ await websocket.send_json({
1017
+ "type": "error",
1018
+ "message": f"Server error: {str(exc)}"
1019
+ })
1020
+ except:
1021
+ pass
1022
+ finally:
1023
+ await self.manager.disconnect(websocket)
1024
+
1025
+ return app
1026
+
1027
+ async def _handle_message(
1028
+ self,
1029
+ websocket: WebSocket,
1030
+ data: dict,
1031
+ session_id: str,
1032
+ ) -> None:
1033
+ """Handle chat message via Agent with file delivery."""
1034
+ message = data.get("message", "").strip()
1035
+ if not message:
1036
+ await self.manager.send_to(websocket, {
1037
+ "type": "error",
1038
+ "message": "Empty message"
1039
+ })
1040
+ return
1041
+
1042
+ user_id = f"web:{session_id}"
1043
+
1044
+ logger.info(f"Processing message from web user {user_id}: {message[:50]}...")
1045
+
1046
+ try:
1047
+ result = await self.agent.chat(
1048
+ user_id=user_id,
1049
+ message=message,
1050
+ context={"channel": "web_ui", "session_id": session_id},
1051
+ )
1052
+
1053
+ if result.get("permission_denied"):
1054
+ await self.manager.send_to(websocket, {
1055
+ "type": "error",
1056
+ "message": "Access denied"
1057
+ })
1058
+ return
1059
+
1060
+ if not result.get("success"):
1061
+ await self.manager.send_to(websocket, {
1062
+ "type": "error",
1063
+ "message": result.get("error", "Unknown error")
1064
+ })
1065
+ return
1066
+
1067
+ response = result.get("response", "No response")
1068
+
1069
+ # Send response first
1070
+ await self.manager.send_to(websocket, {
1071
+ "type": "response",
1072
+ "content": response,
1073
+ "tools_used": result.get("tools_used", [])
1074
+ })
1075
+
1076
+ # Files will be delivered via the callback and subsequent files_ready message
1077
+
1078
+ except Exception as exc:
1079
+ logger.error(f"Error processing message: {exc}")
1080
+ await self.manager.send_to(websocket, {
1081
+ "type": "error",
1082
+ "message": f"Processing error: {str(exc)}"
1083
+ })
1084
+
1085
+ def print_server_ready(self, host: str, port: int) -> None:
1086
+ """Print server ready message."""
1087
+ if not self.verbose:
1088
+ return
1089
+
1090
+ display_host = "localhost" if host in ("0.0.0.0", "") else host
1091
+
1092
+ try:
1093
+ from rich.console import Console
1094
+ from rich.panel import Panel
1095
+ from rich.table import Table
1096
+
1097
+ console = Console()
1098
+
1099
+ table = Table(show_header=True, header_style="bold magenta")
1100
+ table.add_column("Access Point", style="cyan")
1101
+ table.add_column("URL", style="green")
1102
+
1103
+ table.add_row("Chat UI", f"http://{display_host}:{port}")
1104
+ table.add_row("Download Files", f"http://{display_host}:{port}/download/<file_id>")
1105
+ table.add_row("List Files", f"http://{display_host}:{port}/files/list")
1106
+
1107
+ panel = Panel(
1108
+ f"[bold green]āœ… Web UI Ready[/bold green]\n\n{table}",
1109
+ border_style="green",
1110
+ )
1111
+ console.print()
1112
+ console.print(panel)
1113
+ console.print()
1114
+
1115
+ except ImportError:
1116
+ print(f"\nāœ… Web UI running at http://{display_host}:{port}")
1117
+ print(f" File downloads: http://{display_host}:{port}/download/<file_id>\n")
1118
+
1119
+ def _print_shutdown_message(self) -> None:
1120
+ """Print shutdown message."""
1121
+ if self.verbose:
1122
+ print("\nšŸ‘‹ Web UI shutting down...\n")