@jvittechs/j 1.0.11

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.
@@ -0,0 +1,2012 @@
1
+ /**
2
+ * Jai1 Web Chat - Main Application
3
+ * Refined Terminal Aesthetic - Vanilla JS
4
+ */
5
+
6
+ (function () {
7
+ 'use strict';
8
+
9
+ // ============================================
10
+ // Constants
11
+ // ============================================
12
+ const STORAGE_KEYS = {
13
+ SESSION: 'wc_session',
14
+ CONVERSATIONS: 'wc_conversations',
15
+ SETTINGS: 'wc_settings',
16
+ THEME: 'wc_theme',
17
+ };
18
+
19
+ const MAX_STORAGE_SIZE = 4.5 * 1024 * 1024;
20
+ const MAX_CONTEXT_TOKENS = 175000; // 175K token limit
21
+ const MAX_MENTIONED_FILES = 5; // Maximum files that can be mentioned
22
+
23
+ // ============================================
24
+ // Agent Definitions
25
+ // ============================================
26
+ const AGENTS = [
27
+ {
28
+ id: 'default',
29
+ name: 'General Assistant',
30
+ icon: '⚡',
31
+ description: 'General purpose AI assistant',
32
+ systemPrompt: null,
33
+ },
34
+ {
35
+ id: 'architect',
36
+ name: 'Software Architect',
37
+ icon: '🏗️',
38
+ description: 'System design & architecture expert',
39
+ systemPrompt: `You are a Senior Software Architect specializing in web application architecture for offshore development projects.
40
+
41
+ ## Core Expertise
42
+ - **Architecture Patterns**: Monolithic (primary), MVC, Modular architecture
43
+ - **Backend**: Laravel 12, NestJS 11, CakePHP 5, ExpressJS 5, Hono 4
44
+ - **Frontend**: Next.js 16, Nuxt.js 4 with Tailwind CSS 4
45
+ - **Databases**: MySQL 9, PostgreSQL 18, MSSQL (SQL Server 2022)
46
+ - **Caching**: Redis 8, Valkey 9
47
+ - **Cloud**: AWS (EC2, ALB, S3, SQS, SES, CloudFront), Cloudflare (Workers, D1, R2, KV, Pages)
48
+
49
+ ## Mindset
50
+ - Design for maintainability and scalability within monolithic architecture
51
+ - Consider team size (2-15 members per lab) when proposing solutions
52
+ - Balance between ideal architecture and practical implementation
53
+ - Focus on clear documentation for offshore collaboration
54
+
55
+ ## Best Practices
56
+ - Prefer monolithic architecture unless microservices are truly needed
57
+ - Use modular structure within monolith (Laravel Modules, NestJS modules)
58
+ - Design clear API contracts for frontend-backend separation
59
+ - Plan database schema with proper indexes and relationships
60
+ - Consider caching strategy early (Redis/Valkey)
61
+
62
+ When designing architecture:
63
+ 1. Understand project requirements and team constraints
64
+ 2. Choose appropriate tech stack from the standard toolset
65
+ 3. Design database schema and API structure
66
+ 4. Plan for deployment (AWS/Cloudflare)
67
+ 5. Document architecture decisions and rationale`,
68
+ },
69
+ {
70
+ id: 'fullstack',
71
+ name: 'Fullstack Developer',
72
+ icon: '🔄',
73
+ description: 'End-to-end development specialist',
74
+ systemPrompt: `You are a Senior Fullstack Developer proficient in the JV-IT tech stack.
75
+
76
+ ## Tech Stack
77
+ **Backend**: Laravel 12, NestJS 11, ExpressJS 5, Hono 4
78
+ **Frontend**: Next.js 16, Nuxt.js 4
79
+ **Styling**: Tailwind CSS 4, shadcn/ui + Radix UI, Nuxt UI, Lucide React
80
+ **Database**: MySQL 9, PostgreSQL 18, Redis 8
81
+ **Tools**: Git, Monorepo structure for full-stack Node.js projects
82
+
83
+ ## Mindset
84
+ - Build features end-to-end with cohesive user experience
85
+ - Write clean, maintainable code following framework conventions
86
+ - Consider both developer experience and user experience
87
+ - Think about data flow from UI to database and back
88
+ - Use Day.js or date-fns for date/time handling
89
+
90
+ ## Best Practices
91
+ - Follow MVC pattern consistently
92
+ - Use TypeScript in Node.js projects for type safety
93
+ - Implement proper error handling at every layer
94
+ - Use Tailwind CSS utility classes efficiently
95
+ - Leverage shadcn/ui components for React, Nuxt UI for Vue
96
+
97
+ When developing features:
98
+ 1. Understand requirements completely
99
+ 2. Design database schema and API contracts
100
+ 3. Implement backend with proper validation
101
+ 4. Build frontend with accessible UI components
102
+ 5. Add error handling and edge cases
103
+ 6. Test across different scenarios`,
104
+ },
105
+ {
106
+ id: 'backend',
107
+ name: 'Backend Developer',
108
+ icon: '⚙️',
109
+ description: 'Server-side & API specialist',
110
+ systemPrompt: `You are a Senior Backend Developer specializing in Laravel, NestJS, and Node.js.
111
+
112
+ ## Core Expertise
113
+ **PHP**: Laravel 12, CakePHP 5, PSR standards, Composer
114
+ **Node.js**: NestJS 11, ExpressJS 5, Hono 4, TypeScript
115
+ **Databases**: MySQL 9, PostgreSQL 18, MSSQL, Redis 8, Valkey 9
116
+ **Cloud**: AWS (EC2, S3, SQS, SES), Cloudflare Workers, D1, R2
117
+
118
+ ## Mindset
119
+ - Performance-first: optimize queries, use caching (Redis/Valkey)
120
+ - Security-conscious: input validation, SQL injection prevention, XSS
121
+ - Reliability: proper error handling, logging
122
+ - Follow framework conventions strictly
123
+
124
+ ## Laravel Best Practices
125
+ - Use Eloquent relationships and scopes effectively
126
+ - Leverage Form Requests for validation
127
+ - Use Jobs and Queues (SQS) for heavy operations
128
+ - Cache aggressively with Redis
129
+ - Follow PSR-12 coding standards
130
+
131
+ ## Node.js Best Practices
132
+ - Use NestJS modules for clean architecture
133
+ - Leverage decorators and dependency injection
134
+ - Use Hono for edge computing (Cloudflare Workers)
135
+ - TypeScript with strict mode enabled
136
+
137
+ When developing backend features:
138
+ 1. Define API contracts (endpoints, request/response)
139
+ 2. Design database schema with proper migrations
140
+ 3. Implement business logic with separation of concerns
141
+ 4. Add input validation and error handling
142
+ 5. Consider caching and queue strategies`,
143
+ },
144
+ {
145
+ id: 'laravel',
146
+ name: 'Laravel Specialist',
147
+ icon: '🔴',
148
+ description: 'Laravel framework expert',
149
+ systemPrompt: `You are a Laravel Expert with deep knowledge of Laravel 12 and the PHP ecosystem.
150
+
151
+ ## Core Expertise
152
+ - Laravel 12 features and conventions
153
+ - Eloquent ORM: relationships, scopes, accessors/mutators, query optimization
154
+ - Blade templates and components
155
+ - Laravel packages: Sanctum, Horizon, Scout, Telescope
156
+ - Queue system with AWS SQS
157
+ - Testing: PHPUnit, Pest
158
+
159
+ ## Laravel Patterns
160
+ - Repository pattern for data access abstraction
161
+ - Service classes for business logic
162
+ - Form Requests for validation
163
+ - API Resources for response transformation
164
+ - Events, Listeners, and Jobs for async processing
165
+ - Policies and Gates for authorization
166
+
167
+ ## Database
168
+ - MySQL 9 / PostgreSQL 18 optimization
169
+ - Proper migrations and seeders
170
+ - Query optimization with eager loading
171
+ - Redis 8 / Valkey 9 for caching and sessions
172
+
173
+ ## Best Practices
174
+ - Follow "The Laravel Way" - use built-in features first
175
+ - Keep controllers thin, models focused
176
+ - Use strict typing and return types (PHP 8.3+)
177
+ - Follow PSR-12 coding standards
178
+ - Cache routes, config, views in production
179
+ - Use environment-based configuration
180
+
181
+ When building Laravel features:
182
+ 1. Plan database schema with migrations
183
+ 2. Create Models with relationships and scopes
184
+ 3. Add Form Requests for validation
185
+ 4. Implement business logic in Service classes
186
+ 5. Build Controllers that orchestrate the flow
187
+ 6. Write Feature and Unit tests`,
188
+ },
189
+ {
190
+ id: 'frontend',
191
+ name: 'Frontend Developer',
192
+ icon: '🎨',
193
+ description: 'UI/UX & frontend specialist',
194
+ systemPrompt: `You are a Senior Frontend Developer specializing in Next.js and Nuxt.js.
195
+
196
+ ## Core Expertise
197
+ **React**: Next.js 16, shadcn/ui, Radix UI, Lucide React
198
+ **Vue**: Nuxt.js 4, Nuxt UI
199
+ **Styling**: Tailwind CSS 4 (utility-first approach)
200
+ **State**: React Query, SWR, Pinia (Vue)
201
+ **Date/Time**: Day.js, date-fns
202
+
203
+ ## Mindset
204
+ - User-centric: prioritize usability and accessibility
205
+ - Performance: Core Web Vitals, lazy loading, image optimization
206
+ - Component-driven: reusable, composable UI components
207
+ - Use Tailwind CSS utility classes efficiently
208
+
209
+ ## Component Libraries
210
+ - **React projects**: shadcn/ui + Radix UI (accessible, customizable)
211
+ - **Vue projects**: Nuxt UI (native Nuxt integration)
212
+ - **Icons**: Lucide React for React, Lucide Vue for Vue
213
+
214
+ ## Best Practices
215
+ - Follow accessibility standards (WCAG 2.1 AA)
216
+ - Implement responsive design (mobile-first with Tailwind)
217
+ - Use semantic HTML elements
218
+ - Handle loading, error, and empty states properly
219
+ - Optimize images with Next.js Image or Nuxt Image
220
+
221
+ ## Tailwind CSS Tips
222
+ - Use design tokens from tailwind.config
223
+ - Leverage @apply sparingly, prefer utility classes
224
+ - Use dark mode with class strategy
225
+ - Group related utilities logically
226
+
227
+ When building UI:
228
+ 1. Break down into reusable components
229
+ 2. Use appropriate component library (shadcn or Nuxt UI)
230
+ 3. Implement with accessibility in mind
231
+ 4. Add proper loading and error states
232
+ 5. Ensure responsive design with Tailwind`,
233
+ },
234
+ {
235
+ id: 'devops',
236
+ name: 'DevOps Engineer',
237
+ icon: '🚀',
238
+ description: 'CI/CD & infrastructure specialist',
239
+ systemPrompt: `You are a DevOps Engineer with expertise in AWS and Cloudflare infrastructure.
240
+
241
+ ## AWS Stack
242
+ - **Compute**: EC2 with Auto Scaling, Bastion Host for secure access
243
+ - **Load Balancing**: ALB (Application Load Balancer)
244
+ - **Storage**: S3 for files and assets
245
+ - **Messaging**: SQS for queues, SES for emails
246
+ - **CDN**: CloudFront for content delivery
247
+ - **Monitoring**: CloudWatch for logs and metrics
248
+ - **API**: API Gateway for managed APIs
249
+
250
+ ## Cloudflare Stack
251
+ - **Edge Computing**: Cloudflare Workers (JavaScript/TypeScript)
252
+ - **Database**: D1 (serverless SQLite)
253
+ - **Storage**: R2 (S3-compatible, zero egress)
254
+ - **Cache**: KV for low-latency data
255
+ - **Hosting**: Cloudflare Pages for static sites
256
+
257
+ ## Mindset
258
+ - Automate deployment processes (CI/CD improvement area)
259
+ - Infrastructure as Code when possible
260
+ - Security: proper IAM roles, security groups
261
+ - Cost optimization: right-sizing instances, reserved capacity
262
+ - Monitoring and alerting for production issues
263
+
264
+ ## Deployment Patterns
265
+ - EC2 with ALB for traditional apps
266
+ - Cloudflare Workers for edge computing
267
+ - S3/R2 + CloudFront/CDN for static assets
268
+ - SQS for async job processing
269
+
270
+ ## Best Practices
271
+ - Use Bastion Host for secure SSH access
272
+ - Configure proper security groups
273
+ - Set up CloudWatch alarms for critical metrics
274
+ - Use environment variables for configuration
275
+ - Document deployment procedures
276
+
277
+ When designing infrastructure:
278
+ 1. Understand workload requirements
279
+ 2. Choose AWS vs Cloudflare based on use case
280
+ 3. Design for high availability
281
+ 4. Set up monitoring and alerting
282
+ 5. Document runbooks for operations`,
283
+ },
284
+ {
285
+ id: 'reviewer',
286
+ name: 'Code Reviewer',
287
+ icon: '🔍',
288
+ description: 'Code quality & best practices expert',
289
+ systemPrompt: `You are a Senior Code Reviewer focused on code quality and best practices.
290
+
291
+ ## Review Focus
292
+ **Code Quality**: Readability, maintainability, framework conventions
293
+ **Security**: OWASP Top 10, input validation, SQL injection, XSS
294
+ **Performance**: Query optimization, N+1 problems, caching
295
+ **Testing**: Coverage gaps, test quality (improvement area)
296
+
297
+ ## Framework-Specific Checks
298
+
299
+ ### Laravel
300
+ - Eloquent N+1 queries (use eager loading)
301
+ - Mass assignment protection
302
+ - Form Request validation
303
+ - Proper use of middleware
304
+ - PSR-12 compliance
305
+
306
+ ### NestJS/Node.js
307
+ - TypeScript strict mode compliance
308
+ - Proper dependency injection
309
+ - Error handling middleware
310
+ - Input validation with class-validator
311
+
312
+ ### Frontend (Next.js/Nuxt.js)
313
+ - Component accessibility
314
+ - Proper use of Tailwind utilities
315
+ - Image optimization
316
+ - Client vs server rendering decisions
317
+
318
+ ## Mindset
319
+ - Constructive feedback with specific suggestions
320
+ - Explain the "why" behind each suggestion
321
+ - Prioritize: security > correctness > performance > style
322
+ - Consider team context and constraints
323
+ - Acknowledge good practices too
324
+
325
+ ## Common Issues
326
+ - SQL injection in raw queries
327
+ - Missing input validation
328
+ - N+1 query problems in Eloquent/ORM
329
+ - Hardcoded credentials or secrets
330
+ - Missing error handling
331
+ - Inconsistent coding style
332
+
333
+ When reviewing code:
334
+ 1. Check for security vulnerabilities first
335
+ 2. Verify correctness and edge cases
336
+ 3. Evaluate framework convention compliance
337
+ 4. Suggest specific improvements with examples
338
+ 5. Note areas that need test coverage`,
339
+ },
340
+ ];
341
+
342
+ // ============================================
343
+ // State
344
+ // ============================================
345
+ let state = {
346
+ session: null,
347
+ models: [],
348
+ selectedModel: '',
349
+ selectedAgent: 'default',
350
+ conversations: [],
351
+ currentConversationId: null,
352
+ isStreaming: false,
353
+ modelStats: null, // { date, models: { [modelId]: { limit, used, remaining } } }
354
+ // File mention state
355
+ mentionedFiles: [], // Array of { path, name, size, language }
356
+ fileSearchResults: [], // Autocomplete results
357
+ isFileSearching: false, // Autocomplete loading state
358
+ fileServiceInfo: null, // { workingDir, maxFiles }
359
+ };
360
+
361
+ // ============================================
362
+ // Token Estimation Helper
363
+ // ============================================
364
+ function estimateTokens(text) {
365
+ if (!text) return 0;
366
+ // Rough estimation: ~4 characters per token for English
367
+ // This is a simple heuristic, actual tokenization varies by model
368
+ return Math.ceil(text.length / 4);
369
+ }
370
+
371
+ function getConversationTokens(conversation) {
372
+ if (!conversation || !conversation.messages) return 0;
373
+ let totalTokens = 0;
374
+ conversation.messages.forEach((msg) => {
375
+ totalTokens += estimateTokens(msg.content);
376
+ });
377
+ return totalTokens;
378
+ }
379
+
380
+ function formatTokenCount(tokens) {
381
+ // Always show in K format, minimum 1K
382
+ if (tokens < 1000) {
383
+ return '< 1K';
384
+ }
385
+ return (tokens / 1000).toFixed(1) + 'K';
386
+ };
387
+
388
+ // ============================================
389
+ // DOM Elements
390
+ // ============================================
391
+ const elements = {
392
+ modelSelect: document.getElementById('model-select'),
393
+ connectionStatus: document.getElementById('connection-status'),
394
+ newChatBtn: document.getElementById('new-chat-btn'),
395
+ conversationsList: document.getElementById('conversations-list'),
396
+ messages: document.getElementById('messages'),
397
+ welcomeMessage: document.getElementById('welcome-message'),
398
+ messageInput: document.getElementById('message-input'),
399
+ sendBtn: document.getElementById('send-btn'),
400
+ charCount: document.getElementById('char-count'),
401
+ errorModal: document.getElementById('error-modal'),
402
+ errorTitle: document.getElementById('error-title'),
403
+ errorMessage: document.getElementById('error-message'),
404
+ errorCloseBtn: document.getElementById('error-close-btn'),
405
+ loadingOverlay: document.getElementById('loading-overlay'),
406
+ // Stats panel elements
407
+ sidebarRight: document.getElementById('sidebar-right'),
408
+ toggleRightSidebarBtn: document.getElementById('toggle-right-sidebar-btn'),
409
+ statsList: document.getElementById('stats-list'),
410
+ statsDate: document.getElementById('stats-date'),
411
+ refreshStatsBtn: document.getElementById('refresh-stats-btn'),
412
+ promptsList: document.getElementById('prompts-list'),
413
+ // Theme toggle
414
+ themeToggleBtn: document.getElementById('theme-toggle-btn'),
415
+ // Agent selector
416
+ agentSelect: document.getElementById('agent-select'),
417
+ };
418
+
419
+ // ============================================
420
+ // Storage Utilities
421
+ // ============================================
422
+ const Storage = {
423
+ get(key) {
424
+ try {
425
+ const item = localStorage.getItem(key);
426
+ return item ? JSON.parse(item) : null;
427
+ } catch (e) {
428
+ console.error('Storage get error:', e);
429
+ return null;
430
+ }
431
+ },
432
+
433
+ set(key, value) {
434
+ try {
435
+ localStorage.setItem(key, JSON.stringify(value));
436
+ return true;
437
+ } catch (e) {
438
+ if (e.name === 'QuotaExceededError') {
439
+ this.cleanup();
440
+ try {
441
+ localStorage.setItem(key, JSON.stringify(value));
442
+ return true;
443
+ } catch (e2) {
444
+ console.error('Storage still full after cleanup:', e2);
445
+ return false;
446
+ }
447
+ }
448
+ console.error('Storage set error:', e);
449
+ return false;
450
+ }
451
+ },
452
+
453
+ remove(key) {
454
+ try {
455
+ localStorage.removeItem(key);
456
+ } catch (e) {
457
+ console.error('Storage remove error:', e);
458
+ }
459
+ },
460
+
461
+ cleanup() {
462
+ const conversations = this.get(STORAGE_KEYS.CONVERSATIONS) || [];
463
+ while (conversations.length > 1 && this.getSize() > MAX_STORAGE_SIZE) {
464
+ conversations.shift();
465
+ this.set(STORAGE_KEYS.CONVERSATIONS, conversations);
466
+ }
467
+ },
468
+
469
+ getSize() {
470
+ let total = 0;
471
+ for (const key in localStorage) {
472
+ if (localStorage.hasOwnProperty(key)) {
473
+ total += localStorage[key].length * 2;
474
+ }
475
+ }
476
+ return total;
477
+ },
478
+ };
479
+
480
+ // ============================================
481
+ // Theme Manager
482
+ // ============================================
483
+ const Theme = {
484
+ DARK: 'dark',
485
+ LIGHT: 'light',
486
+
487
+ init() {
488
+ // Check saved preference, then system preference
489
+ const saved = Storage.get(STORAGE_KEYS.THEME);
490
+ if (saved) {
491
+ this.set(saved);
492
+ } else {
493
+ // Check system preference
494
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
495
+ this.set(prefersDark ? this.DARK : this.LIGHT);
496
+ }
497
+
498
+ // Listen for system theme changes
499
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
500
+ if (!Storage.get(STORAGE_KEYS.THEME)) {
501
+ this.set(e.matches ? this.DARK : this.LIGHT);
502
+ }
503
+ });
504
+ },
505
+
506
+ get() {
507
+ return document.documentElement.getAttribute('data-theme') || this.DARK;
508
+ },
509
+
510
+ set(theme) {
511
+ document.documentElement.setAttribute('data-theme', theme);
512
+ Storage.set(STORAGE_KEYS.THEME, theme);
513
+ },
514
+
515
+ toggle() {
516
+ const current = this.get();
517
+ this.set(current === this.DARK ? this.LIGHT : this.DARK);
518
+ },
519
+ };
520
+
521
+ // ============================================
522
+ // API Client
523
+ // ============================================
524
+ const API = {
525
+ getSessionToken() {
526
+ const urlParams = new URLSearchParams(window.location.search);
527
+ const urlToken = urlParams.get('session');
528
+ if (urlToken) {
529
+ return urlToken;
530
+ }
531
+ const session = Storage.get(STORAGE_KEYS.SESSION);
532
+ return session?.token || null;
533
+ },
534
+
535
+ async request(endpoint, options = {}) {
536
+ const token = this.getSessionToken();
537
+ const headers = {
538
+ 'Content-Type': 'application/json',
539
+ ...(token && { 'X-Session-Token': token }),
540
+ ...options.headers,
541
+ };
542
+
543
+ const response = await fetch(endpoint, {
544
+ ...options,
545
+ headers,
546
+ });
547
+
548
+ if (!response.ok) {
549
+ const error = await response.json().catch(() => ({}));
550
+ throw new APIError(
551
+ error.error?.message || response.statusText,
552
+ error.error?.code || `HTTP_${response.status}`,
553
+ response.status
554
+ );
555
+ }
556
+
557
+ return response;
558
+ },
559
+
560
+ async getSession() {
561
+ const response = await this.request('/api/session');
562
+ return response.json();
563
+ },
564
+
565
+ async getModels() {
566
+ const response = await this.request('/api/models');
567
+ return response.json();
568
+ },
569
+
570
+ async getStats() {
571
+ const response = await this.request('/api/stats');
572
+ return response.json();
573
+ },
574
+
575
+ async *streamChat(message, model, history = [], systemPrompt = null) {
576
+ const response = await this.request('/api/chat', {
577
+ method: 'POST',
578
+ body: JSON.stringify({ message, model, history, systemPrompt }),
579
+ });
580
+
581
+ const reader = response.body.getReader();
582
+ const decoder = new TextDecoder();
583
+ let buffer = '';
584
+
585
+ try {
586
+ while (true) {
587
+ const { done, value } = await reader.read();
588
+ if (done) break;
589
+
590
+ // Append new chunk to buffer
591
+ buffer += decoder.decode(value, { stream: true });
592
+
593
+ // Process only complete lines (ending with \n)
594
+ const lines = buffer.split('\n');
595
+ // Keep the last incomplete line in buffer
596
+ buffer = lines.pop() || '';
597
+
598
+ for (const line of lines) {
599
+ const trimmedLine = line.trim();
600
+ if (!trimmedLine) continue;
601
+
602
+ if (trimmedLine.startsWith('data: ')) {
603
+ const data = trimmedLine.slice(6);
604
+ try {
605
+ yield JSON.parse(data);
606
+ } catch (e) {
607
+ // Skip malformed data
608
+ }
609
+ }
610
+ }
611
+ }
612
+
613
+ // Process any remaining content in buffer
614
+ if (buffer.trim()) {
615
+ const trimmedLine = buffer.trim();
616
+ if (trimmedLine.startsWith('data: ')) {
617
+ const data = trimmedLine.slice(6);
618
+ try {
619
+ yield JSON.parse(data);
620
+ } catch (e) {
621
+ // Skip malformed data
622
+ }
623
+ }
624
+ }
625
+ } finally {
626
+ reader.releaseLock();
627
+ }
628
+ },
629
+
630
+ // === File API Methods ===
631
+
632
+ async getFileServiceInfo() {
633
+ const response = await this.request('/api/files/info');
634
+ return response.json();
635
+ },
636
+
637
+ async searchFiles(query, limit = 10) {
638
+ const response = await this.request(`/api/files/search?q=${encodeURIComponent(query)}&limit=${limit}`);
639
+ return response.json();
640
+ },
641
+
642
+ async readFile(filePath) {
643
+ const response = await this.request(`/api/files/read?path=${encodeURIComponent(filePath)}`);
644
+ return response.json();
645
+ },
646
+
647
+ async readFiles(paths) {
648
+ const response = await this.request('/api/files/batch', {
649
+ method: 'POST',
650
+ body: JSON.stringify({ paths }),
651
+ });
652
+ return response.json();
653
+ },
654
+ };
655
+
656
+ class APIError extends Error {
657
+ constructor(message, code, status) {
658
+ super(message);
659
+ this.code = code;
660
+ this.status = status;
661
+ }
662
+ }
663
+
664
+ // ============================================
665
+ // Conversation Management
666
+ // ============================================
667
+ const Conversations = {
668
+ load() {
669
+ state.conversations = Storage.get(STORAGE_KEYS.CONVERSATIONS) || [];
670
+ },
671
+
672
+ save() {
673
+ Storage.set(STORAGE_KEYS.CONVERSATIONS, state.conversations);
674
+ },
675
+
676
+ create() {
677
+ const id = 'conv_' + Date.now();
678
+ const conversation = {
679
+ id,
680
+ title: 'New Thread',
681
+ model: state.selectedModel,
682
+ agent: state.selectedAgent,
683
+ createdAt: new Date().toISOString(),
684
+ messages: [],
685
+ };
686
+ state.conversations.unshift(conversation);
687
+ state.currentConversationId = id;
688
+ this.save();
689
+ return conversation;
690
+ },
691
+
692
+ getCurrent() {
693
+ return state.conversations.find((c) => c.id === state.currentConversationId);
694
+ },
695
+
696
+ addMessage(role, content) {
697
+ const conversation = this.getCurrent();
698
+ if (!conversation) return;
699
+
700
+ conversation.messages.push({
701
+ role,
702
+ content,
703
+ timestamp: new Date().toISOString(),
704
+ });
705
+
706
+ if (role === 'user' && conversation.messages.length === 1) {
707
+ conversation.title = content.slice(0, 40) + (content.length > 40 ? '...' : '');
708
+ }
709
+
710
+ this.save();
711
+ },
712
+
713
+ updateLastAssistantMessage(content) {
714
+ const conversation = this.getCurrent();
715
+ if (!conversation) return;
716
+
717
+ const lastMsg = conversation.messages[conversation.messages.length - 1];
718
+ if (lastMsg && lastMsg.role === 'assistant') {
719
+ lastMsg.content = content;
720
+ this.save();
721
+ }
722
+ },
723
+
724
+ delete(id) {
725
+ const index = state.conversations.findIndex((c) => c.id === id);
726
+ if (index !== -1) {
727
+ state.conversations.splice(index, 1);
728
+ if (state.currentConversationId === id) {
729
+ state.currentConversationId = state.conversations[0]?.id || null;
730
+ }
731
+ this.save();
732
+ }
733
+ },
734
+
735
+ switchTo(id) {
736
+ state.currentConversationId = id;
737
+ const conversation = this.getCurrent();
738
+ if (conversation) {
739
+ if (conversation.model) {
740
+ state.selectedModel = conversation.model;
741
+ elements.modelSelect.value = conversation.model;
742
+ }
743
+ if (conversation.agent) {
744
+ state.selectedAgent = conversation.agent;
745
+ if (elements.agentSelect) {
746
+ elements.agentSelect.value = conversation.agent;
747
+ }
748
+ }
749
+ }
750
+ },
751
+ };
752
+
753
+ // ============================================
754
+ // File Mention System
755
+ // ============================================
756
+ const FileMention = {
757
+ autocompleteEl: null,
758
+ debounceTimer: null,
759
+ selectedIndex: -1,
760
+ mentionStartPos: -1,
761
+ lastQuery: '',
762
+
763
+ init() {
764
+ // Create autocomplete dropdown
765
+ this.createAutocompleteElement();
766
+ // Create mentioned files container
767
+ this.createMentionedFilesContainer();
768
+ // Load file service info
769
+ this.loadFileServiceInfo();
770
+ },
771
+
772
+ createAutocompleteElement() {
773
+ this.autocompleteEl = document.createElement('div');
774
+ this.autocompleteEl.className = 'file-autocomplete hidden';
775
+ this.autocompleteEl.innerHTML = `
776
+ <div class="file-autocomplete-header">
777
+ <span class="autocomplete-icon">📁</span>
778
+ <span class="autocomplete-title">Files</span>
779
+ <span class="autocomplete-hint">↑↓ to navigate, Enter to select, Esc to close</span>
780
+ </div>
781
+ <div class="file-autocomplete-list"></div>
782
+ `;
783
+ // Insert before input container
784
+ const inputArea = document.querySelector('.input-area');
785
+ if (inputArea) {
786
+ inputArea.insertBefore(this.autocompleteEl, inputArea.querySelector('.input-container'));
787
+ }
788
+ },
789
+
790
+ createMentionedFilesContainer() {
791
+ const container = document.createElement('div');
792
+ container.id = 'mentioned-files';
793
+ container.className = 'mentioned-files hidden';
794
+ // Insert before input selectors
795
+ const inputArea = document.querySelector('.input-area');
796
+ const selectors = inputArea?.querySelector('.input-selectors');
797
+ if (inputArea && selectors) {
798
+ inputArea.insertBefore(container, selectors);
799
+ }
800
+ },
801
+
802
+ async loadFileServiceInfo() {
803
+ try {
804
+ const response = await API.getFileServiceInfo();
805
+ if (response.success && response.data) {
806
+ state.fileServiceInfo = response.data;
807
+ }
808
+ } catch (error) {
809
+ console.error('Failed to load file service info:', error);
810
+ }
811
+ },
812
+
813
+ // Detect @ mention in textarea
814
+ detectMention(textarea) {
815
+ const text = textarea.value;
816
+ const cursorPos = textarea.selectionStart;
817
+
818
+ // Find @ before cursor
819
+ let atPos = -1;
820
+ for (let i = cursorPos - 1; i >= 0; i--) {
821
+ const char = text[i];
822
+ if (char === '@') {
823
+ atPos = i;
824
+ break;
825
+ }
826
+ // Stop at whitespace or newline (not a mention)
827
+ if (char === ' ' || char === '\n' || char === '\t') {
828
+ break;
829
+ }
830
+ }
831
+
832
+ if (atPos === -1) {
833
+ this.hideAutocomplete();
834
+ return null;
835
+ }
836
+
837
+ // Extract query after @
838
+ const query = text.substring(atPos + 1, cursorPos);
839
+ this.mentionStartPos = atPos;
840
+ return query;
841
+ },
842
+
843
+ async handleInput(textarea) {
844
+ const query = this.detectMention(textarea);
845
+
846
+ if (query === null) {
847
+ return;
848
+ }
849
+
850
+ // Debounce search
851
+ clearTimeout(this.debounceTimer);
852
+ this.debounceTimer = setTimeout(async () => {
853
+ if (query.length >= 1) {
854
+ await this.searchFiles(query);
855
+ } else {
856
+ this.hideAutocomplete();
857
+ }
858
+ }, 150);
859
+ },
860
+
861
+ async searchFiles(query) {
862
+ if (query === this.lastQuery) return;
863
+ this.lastQuery = query;
864
+
865
+ state.isFileSearching = true;
866
+ this.selectedIndex = 0; // Reset selection for new search
867
+ this.showAutocomplete();
868
+ this.renderLoading();
869
+
870
+ try {
871
+ const response = await API.searchFiles(query, 10);
872
+ if (response.success && response.data) {
873
+ state.fileSearchResults = response.data.files || [];
874
+ this.selectedIndex = state.fileSearchResults.length > 0 ? 0 : -1;
875
+ this.renderResults();
876
+ }
877
+ } catch (error) {
878
+ console.error('File search error:', error);
879
+ this.renderError('Failed to search files');
880
+ } finally {
881
+ state.isFileSearching = false;
882
+ }
883
+ },
884
+
885
+ showAutocomplete() {
886
+ if (this.autocompleteEl) {
887
+ this.autocompleteEl.classList.remove('hidden');
888
+ }
889
+ },
890
+
891
+ hideAutocomplete() {
892
+ if (this.autocompleteEl) {
893
+ this.autocompleteEl.classList.add('hidden');
894
+ }
895
+ this.selectedIndex = -1;
896
+ this.mentionStartPos = -1;
897
+ this.lastQuery = '';
898
+ state.fileSearchResults = [];
899
+ },
900
+
901
+ renderLoading() {
902
+ const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
903
+ if (list) {
904
+ list.innerHTML = `
905
+ <div class="file-autocomplete-loading">
906
+ <span class="loading-spinner">◌</span>
907
+ <span>Searching files...</span>
908
+ </div>
909
+ `;
910
+ }
911
+ },
912
+
913
+ renderError(message) {
914
+ const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
915
+ if (list) {
916
+ list.innerHTML = `
917
+ <div class="file-autocomplete-error">
918
+ <span>⚠</span>
919
+ <span>${escapeHtml(message)}</span>
920
+ </div>
921
+ `;
922
+ }
923
+ },
924
+
925
+ renderResults() {
926
+ const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
927
+ if (!list) return;
928
+
929
+ if (state.fileSearchResults.length === 0) {
930
+ list.innerHTML = `
931
+ <div class="file-autocomplete-empty">
932
+ <span>No files found</span>
933
+ </div>
934
+ `;
935
+ return;
936
+ }
937
+
938
+ list.innerHTML = '';
939
+ state.fileSearchResults.forEach((file, index) => {
940
+ const item = document.createElement('div');
941
+ item.className = 'file-autocomplete-item' + (index === this.selectedIndex ? ' selected' : '');
942
+ item.dataset.index = index;
943
+ item.innerHTML = `
944
+ <span class="file-icon">${this.getFileIcon(file.extension)}</span>
945
+ <span class="file-path">${escapeHtml(file.path)}</span>
946
+ <span class="file-size">${this.formatFileSize(file.size)}</span>
947
+ `;
948
+ item.addEventListener('click', () => this.selectFile(index));
949
+ item.addEventListener('mouseenter', () => {
950
+ this.selectedIndex = index;
951
+ this.updateSelection();
952
+ });
953
+ list.appendChild(item);
954
+ });
955
+ },
956
+
957
+ getFileIcon(extension) {
958
+ const iconMap = {
959
+ 'ts': '📘', 'tsx': '📘', 'js': '📙', 'jsx': '📙',
960
+ 'vue': '💚', 'php': '🐘', 'py': '🐍',
961
+ 'json': '📋', 'md': '📝', 'css': '🎨', 'scss': '🎨',
962
+ 'html': '🌐', 'sql': '🗄️', 'sh': '⚙️',
963
+ };
964
+ return iconMap[extension] || '📄';
965
+ },
966
+
967
+ formatFileSize(bytes) {
968
+ if (bytes < 1024) return bytes + 'B';
969
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
970
+ return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
971
+ },
972
+
973
+ updateSelection() {
974
+ const items = this.autocompleteEl?.querySelectorAll('.file-autocomplete-item');
975
+ items?.forEach((item, index) => {
976
+ item.classList.toggle('selected', index === this.selectedIndex);
977
+ });
978
+ },
979
+
980
+ handleKeydown(e, textarea) {
981
+ if (this.autocompleteEl?.classList.contains('hidden')) {
982
+ return false;
983
+ }
984
+
985
+ // No results to navigate
986
+ if (state.fileSearchResults.length === 0) {
987
+ if (e.key === 'Escape') {
988
+ e.preventDefault();
989
+ this.hideAutocomplete();
990
+ return true;
991
+ }
992
+ return false;
993
+ }
994
+
995
+ switch (e.key) {
996
+ case 'ArrowDown':
997
+ e.preventDefault();
998
+ if (this.selectedIndex < 0) {
999
+ this.selectedIndex = 0;
1000
+ } else {
1001
+ this.selectedIndex = Math.min(this.selectedIndex + 1, state.fileSearchResults.length - 1);
1002
+ }
1003
+ this.updateSelection();
1004
+ this.scrollToSelected();
1005
+ return true;
1006
+
1007
+ case 'ArrowUp':
1008
+ e.preventDefault();
1009
+ if (this.selectedIndex < 0) {
1010
+ this.selectedIndex = state.fileSearchResults.length - 1;
1011
+ } else if (this.selectedIndex > 0) {
1012
+ this.selectedIndex--;
1013
+ }
1014
+ this.updateSelection();
1015
+ this.scrollToSelected();
1016
+ return true;
1017
+
1018
+ case 'Enter':
1019
+ if (this.selectedIndex >= 0) {
1020
+ e.preventDefault();
1021
+ this.selectFile(this.selectedIndex, textarea);
1022
+ return true;
1023
+ } else if (state.fileSearchResults.length > 0) {
1024
+ // Select first item if none selected
1025
+ e.preventDefault();
1026
+ this.selectFile(0, textarea);
1027
+ return true;
1028
+ }
1029
+ break;
1030
+
1031
+ case 'Escape':
1032
+ e.preventDefault();
1033
+ this.hideAutocomplete();
1034
+ return true;
1035
+
1036
+ case 'Tab':
1037
+ if (state.fileSearchResults.length > 0) {
1038
+ e.preventDefault();
1039
+ this.selectFile(Math.max(this.selectedIndex, 0), textarea);
1040
+ return true;
1041
+ }
1042
+ break;
1043
+ }
1044
+
1045
+ return false;
1046
+ },
1047
+
1048
+ scrollToSelected() {
1049
+ const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
1050
+ const selectedItem = list?.querySelector('.file-autocomplete-item.selected');
1051
+ if (selectedItem && list) {
1052
+ selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1053
+ }
1054
+ },
1055
+
1056
+ selectFile(index, textarea = elements.messageInput) {
1057
+ const file = state.fileSearchResults[index];
1058
+ if (!file) return;
1059
+
1060
+ // Check if already mentioned
1061
+ if (state.mentionedFiles.some(f => f.path === file.path)) {
1062
+ this.hideAutocomplete();
1063
+ // Remove the @query from textarea
1064
+ this.removeMentionText(textarea);
1065
+ return;
1066
+ }
1067
+
1068
+ // Check max files limit
1069
+ if (state.mentionedFiles.length >= MAX_MENTIONED_FILES) {
1070
+ alert(`Maximum ${MAX_MENTIONED_FILES} files can be mentioned per message`);
1071
+ this.hideAutocomplete();
1072
+ return;
1073
+ }
1074
+
1075
+ // Add to mentioned files
1076
+ state.mentionedFiles.push(file);
1077
+ this.renderMentionedFiles();
1078
+
1079
+ // Remove @query from textarea
1080
+ this.removeMentionText(textarea);
1081
+
1082
+ this.hideAutocomplete();
1083
+ textarea.focus();
1084
+ },
1085
+
1086
+ removeMentionText(textarea) {
1087
+ if (this.mentionStartPos >= 0) {
1088
+ const text = textarea.value;
1089
+ const cursorPos = textarea.selectionStart;
1090
+ const newText = text.substring(0, this.mentionStartPos) + text.substring(cursorPos);
1091
+ textarea.value = newText;
1092
+ textarea.selectionStart = textarea.selectionEnd = this.mentionStartPos;
1093
+ // Trigger input event
1094
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
1095
+ }
1096
+ },
1097
+
1098
+ renderMentionedFiles() {
1099
+ const container = document.getElementById('mentioned-files');
1100
+ if (!container) return;
1101
+
1102
+ if (state.mentionedFiles.length === 0) {
1103
+ container.classList.add('hidden');
1104
+ container.innerHTML = '';
1105
+ return;
1106
+ }
1107
+
1108
+ container.classList.remove('hidden');
1109
+ container.innerHTML = `
1110
+ <div class="mentioned-files-header">
1111
+ <span class="mentioned-icon">📎</span>
1112
+ <span class="mentioned-title">Attached Files (${state.mentionedFiles.length}/${MAX_MENTIONED_FILES})</span>
1113
+ </div>
1114
+ <div class="mentioned-files-list">
1115
+ ${state.mentionedFiles.map((file, index) => `
1116
+ <div class="mentioned-file-badge" data-index="${index}">
1117
+ <span class="badge-icon">${this.getFileIcon(file.extension)}</span>
1118
+ <span class="badge-path">${escapeHtml(file.path)}</span>
1119
+ <button class="badge-remove" title="Remove file">×</button>
1120
+ </div>
1121
+ `).join('')}
1122
+ </div>
1123
+ `;
1124
+
1125
+ // Add remove handlers
1126
+ container.querySelectorAll('.badge-remove').forEach(btn => {
1127
+ btn.addEventListener('click', (e) => {
1128
+ const index = parseInt(e.target.closest('.mentioned-file-badge').dataset.index);
1129
+ this.removeFile(index);
1130
+ });
1131
+ });
1132
+ },
1133
+
1134
+ removeFile(index) {
1135
+ state.mentionedFiles.splice(index, 1);
1136
+ this.renderMentionedFiles();
1137
+ },
1138
+
1139
+ clearFiles() {
1140
+ state.mentionedFiles = [];
1141
+ this.renderMentionedFiles();
1142
+ },
1143
+
1144
+ // Get file contents for mentioned files
1145
+ async getFileContents() {
1146
+ if (state.mentionedFiles.length === 0) {
1147
+ return [];
1148
+ }
1149
+
1150
+ const paths = state.mentionedFiles.map(f => f.path);
1151
+ try {
1152
+ const response = await API.readFiles(paths);
1153
+ if (response.success && response.data) {
1154
+ return response.data.files || [];
1155
+ }
1156
+ } catch (error) {
1157
+ console.error('Failed to read files:', error);
1158
+ }
1159
+ return [];
1160
+ },
1161
+
1162
+ // Format file context for LLM
1163
+ formatFileContext(files) {
1164
+ if (!files || files.length === 0) return '';
1165
+
1166
+ let context = '\n\n[Attached Files]\n\n';
1167
+ files.forEach(file => {
1168
+ if (file.error) {
1169
+ context += `--- ${file.path} ---\n[Error: ${file.error}]\n\n`;
1170
+ } else {
1171
+ context += `--- ${file.path} ---\n\`\`\`${file.language || 'text'}\n${file.content}\n\`\`\`\n\n`;
1172
+ }
1173
+ });
1174
+ return context;
1175
+ },
1176
+ };
1177
+
1178
+ // ============================================
1179
+ // UI Rendering
1180
+ // ============================================
1181
+ const UI = {
1182
+ renderConversationsList() {
1183
+ elements.conversationsList.innerHTML = '';
1184
+
1185
+ if (state.conversations.length === 0) {
1186
+ elements.conversationsList.innerHTML = `
1187
+ <div style="padding: 16px; color: var(--color-text-dim); text-align: center; font-size: 12px; font-family: var(--font-mono);">
1188
+ No threads yet
1189
+ </div>
1190
+ `;
1191
+ return;
1192
+ }
1193
+
1194
+ state.conversations.forEach((conv) => {
1195
+ const tokens = getConversationTokens(conv);
1196
+ const tokenClass = tokens > MAX_CONTEXT_TOKENS ? 'token-exceeded' : (tokens > MAX_CONTEXT_TOKENS * 0.8 ? 'token-warning' : '');
1197
+ const item = document.createElement('div');
1198
+ item.className = 'conversation-item' + (conv.id === state.currentConversationId ? ' active' : '');
1199
+ item.innerHTML = `
1200
+ <div class="conversation-info">
1201
+ <span class="title">${escapeHtml(conv.title)}</span>
1202
+ <span class="token-count ${tokenClass}" title="Estimated tokens">${formatTokenCount(tokens)}</span>
1203
+ </div>
1204
+ <button class="delete-btn" title="Delete thread">×</button>
1205
+ `;
1206
+
1207
+ item.querySelector('.conversation-info').addEventListener('click', () => {
1208
+ Conversations.switchTo(conv.id);
1209
+ this.renderConversationsList();
1210
+ this.renderMessages();
1211
+ });
1212
+
1213
+ item.querySelector('.delete-btn').addEventListener('click', (e) => {
1214
+ e.stopPropagation();
1215
+ if (confirm('Delete this thread?')) {
1216
+ Conversations.delete(conv.id);
1217
+ this.renderConversationsList();
1218
+ this.renderMessages();
1219
+ }
1220
+ });
1221
+
1222
+ elements.conversationsList.appendChild(item);
1223
+ });
1224
+ },
1225
+
1226
+ renderMessages() {
1227
+ const conversation = Conversations.getCurrent();
1228
+
1229
+ if (!conversation || conversation.messages.length === 0) {
1230
+ elements.welcomeMessage.classList.remove('hidden');
1231
+ const children = Array.from(elements.messages.children);
1232
+ children.forEach((child) => {
1233
+ if (child !== elements.welcomeMessage) {
1234
+ child.remove();
1235
+ }
1236
+ });
1237
+ return;
1238
+ }
1239
+
1240
+ elements.welcomeMessage.classList.add('hidden');
1241
+ elements.messages.innerHTML = '';
1242
+
1243
+ conversation.messages.forEach((msg) => {
1244
+ this.appendMessage(msg.role, msg.content, msg.timestamp);
1245
+ });
1246
+
1247
+ this.scrollToBottom();
1248
+ },
1249
+
1250
+ appendMessage(role, content, timestamp = null, isLoading = false) {
1251
+ elements.welcomeMessage.classList.add('hidden');
1252
+
1253
+ const time = timestamp
1254
+ ? new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
1255
+ : new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1256
+ const avatar = role === 'user' ? '◉' : '⚡';
1257
+ const roleName = role === 'user' ? 'you' : 'jai1';
1258
+
1259
+ const msgEl = document.createElement('div');
1260
+ msgEl.className = `message ${role}`;
1261
+
1262
+ msgEl.innerHTML = `
1263
+ <div class="message-avatar">${avatar}</div>
1264
+ <div class="message-content">
1265
+ <div class="message-header">
1266
+ <span class="message-role">${roleName}</span>
1267
+ <div class="message-meta">
1268
+ <button class="message-copy-btn" title="Copy raw message" aria-label="Copy raw message">
1269
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1270
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
1271
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
1272
+ </svg>
1273
+ </button>
1274
+ <span class="message-time">${time}</span>
1275
+ </div>
1276
+ </div>
1277
+ <div class="message-body"></div>
1278
+ </div>
1279
+ `;
1280
+
1281
+ // Add content to message body (after DOM is created)
1282
+ const bodyEl = msgEl.querySelector('.message-body');
1283
+ if (isLoading) {
1284
+ bodyEl.innerHTML = '<div class="loading-message"><span class="loading-dot"></span><span class="loading-dot"></span><span class="loading-dot"></span></div>';
1285
+ } else {
1286
+ // Create zero-md element programmatically
1287
+ const zeroMd = this.createZeroMdElement(content);
1288
+ bodyEl.appendChild(zeroMd);
1289
+ }
1290
+
1291
+ // Attach event listener for copy button
1292
+ const copyBtn = msgEl.querySelector('.message-copy-btn');
1293
+ if (copyBtn) {
1294
+ copyBtn.addEventListener('click', async () => {
1295
+ try {
1296
+ await navigator.clipboard.writeText(content);
1297
+ copyBtn.classList.add('copied');
1298
+ setTimeout(() => copyBtn.classList.remove('copied'), 2000);
1299
+ } catch (err) {
1300
+ console.error('Failed to copy', err);
1301
+ }
1302
+ });
1303
+ }
1304
+
1305
+ elements.messages.appendChild(msgEl);
1306
+ this.postProcessMessage(msgEl);
1307
+ this.scrollToBottom();
1308
+
1309
+ return msgEl;
1310
+ },
1311
+
1312
+ updateStreamingMessage(msgEl, content, isComplete = false) {
1313
+ const bodyEl = msgEl.querySelector('.message-body');
1314
+ if (bodyEl) {
1315
+ // Clear and recreate zero-md element
1316
+ bodyEl.innerHTML = '';
1317
+ const zeroMd = this.createZeroMdElement(content);
1318
+ bodyEl.appendChild(zeroMd);
1319
+
1320
+ if (!isComplete) {
1321
+ const cursor = document.createElement('span');
1322
+ cursor.className = 'streaming-cursor';
1323
+ bodyEl.appendChild(cursor);
1324
+ } else {
1325
+ this.postProcessMessage(msgEl);
1326
+ }
1327
+ this.scrollToBottom();
1328
+ }
1329
+ },
1330
+
1331
+ showError(msgEl, errorMessage) {
1332
+ const bodyEl = msgEl.querySelector('.message-body');
1333
+ if (bodyEl) {
1334
+ bodyEl.innerHTML += `<div class="message-error">⚠ ${escapeHtml(errorMessage)}</div>`;
1335
+ }
1336
+ },
1337
+
1338
+ createZeroMdElement(content) {
1339
+ // Create zero-md web component programmatically
1340
+ const zeroMd = document.createElement('zero-md');
1341
+ zeroMd.id = 'md-' + Math.random().toString(36).substr(2, 9);
1342
+
1343
+ // Create markdown script
1344
+ const mdScript = document.createElement('script');
1345
+ mdScript.type = 'text/markdown';
1346
+ mdScript.textContent = content;
1347
+ zeroMd.appendChild(mdScript);
1348
+
1349
+ // Create custom styles template
1350
+ const template = document.createElement('template');
1351
+ template.setAttribute('data-append', '');
1352
+ template.innerHTML = `
1353
+ <style>
1354
+ /* Integrate with chat theme */
1355
+ :host {
1356
+ display: block;
1357
+ color: var(--text-primary);
1358
+ }
1359
+ a {
1360
+ color: var(--accent-primary);
1361
+ text-decoration: none;
1362
+ }
1363
+ a:hover {
1364
+ text-decoration: underline;
1365
+ }
1366
+ code {
1367
+ background: var(--bg-secondary);
1368
+ padding: 0.2em 0.4em;
1369
+ border-radius: 3px;
1370
+ font-family: 'JetBrains Mono', monospace;
1371
+ }
1372
+ pre {
1373
+ background: var(--bg-secondary);
1374
+ padding: 1em;
1375
+ border-radius: 8px;
1376
+ overflow-x: auto;
1377
+ }
1378
+ pre code {
1379
+ background: none;
1380
+ padding: 0;
1381
+ }
1382
+ table {
1383
+ border-collapse: collapse;
1384
+ width: 100%;
1385
+ }
1386
+ th, td {
1387
+ border: 1px solid var(--border-color);
1388
+ padding: 0.5em;
1389
+ }
1390
+ th {
1391
+ background: var(--bg-secondary);
1392
+ }
1393
+ </style>
1394
+ `;
1395
+ zeroMd.appendChild(template);
1396
+
1397
+ return zeroMd;
1398
+ },
1399
+
1400
+ // Process rendered content - zero-md handles rendering automatically
1401
+ async postProcessMessage(msgEl) {
1402
+ // Wait for zero-md to finish rendering
1403
+ const zeroMdEls = msgEl.querySelectorAll('zero-md');
1404
+ if (zeroMdEls.length > 0) {
1405
+ await Promise.all(
1406
+ Array.from(zeroMdEls).map(el =>
1407
+ new Promise(resolve => {
1408
+ if (el.shadowRoot) {
1409
+ resolve();
1410
+ } else {
1411
+ el.addEventListener('zero-md-rendered', () => resolve(), { once: true });
1412
+ }
1413
+ })
1414
+ )
1415
+ );
1416
+ }
1417
+ },
1418
+ scrollToBottom() {
1419
+ elements.messages.scrollTop = elements.messages.scrollHeight;
1420
+ },
1421
+ updateModelsDropdown() {
1422
+ elements.modelSelect.innerHTML = '';
1423
+ state.models.forEach((model) => {
1424
+ const option = document.createElement('option');
1425
+ option.value = model.id;
1426
+ option.textContent = model.id;
1427
+ if (model.id === state.selectedModel) {
1428
+ option.selected = true;
1429
+ }
1430
+ elements.modelSelect.appendChild(option);
1431
+ });
1432
+ },
1433
+
1434
+ updateAgentsDropdown() {
1435
+ if (!elements.agentSelect) return;
1436
+ elements.agentSelect.innerHTML = '';
1437
+ AGENTS.forEach((agent) => {
1438
+ const option = document.createElement('option');
1439
+ option.value = agent.id;
1440
+ option.textContent = `${agent.icon} ${agent.name}`;
1441
+ if (agent.id === state.selectedAgent) {
1442
+ option.selected = true;
1443
+ }
1444
+ elements.agentSelect.appendChild(option);
1445
+ });
1446
+ },
1447
+
1448
+ getAgentById(id) {
1449
+ return AGENTS.find((a) => a.id === id) || AGENTS[0];
1450
+ },
1451
+
1452
+ setLoading(show) {
1453
+ if (show) {
1454
+ elements.loadingOverlay.classList.remove('hidden');
1455
+ } else {
1456
+ elements.loadingOverlay.classList.add('hidden');
1457
+ }
1458
+ },
1459
+
1460
+ setStreaming(streaming) {
1461
+ state.isStreaming = streaming;
1462
+ const conversation = Conversations.getCurrent();
1463
+ const tokens = conversation ? getConversationTokens(conversation) : 0;
1464
+ const isOverLimit = tokens > MAX_CONTEXT_TOKENS;
1465
+
1466
+ elements.messageInput.disabled = streaming || isOverLimit;
1467
+ elements.sendBtn.disabled = streaming || isOverLimit || !elements.messageInput.value.trim();
1468
+ elements.modelSelect.disabled = streaming;
1469
+
1470
+ // Show/hide token limit warning
1471
+ this.updateTokenLimitWarning(isOverLimit);
1472
+
1473
+ if (streaming) {
1474
+ elements.sendBtn.innerHTML = `
1475
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
1476
+ <rect x="6" y="6" width="12" height="12" rx="2"></rect>
1477
+ </svg>
1478
+ `;
1479
+ } else {
1480
+ elements.sendBtn.innerHTML = `
1481
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
1482
+ <line x1="5" y1="12" x2="19" y2="12"></line>
1483
+ <polyline points="12 5 19 12 12 19"></polyline>
1484
+ </svg>
1485
+ `;
1486
+ }
1487
+ },
1488
+
1489
+ showErrorModal(title, message) {
1490
+ elements.errorTitle.textContent = title;
1491
+ elements.errorMessage.textContent = message;
1492
+ elements.errorModal.classList.remove('hidden');
1493
+ document.body.style.overflow = 'hidden';
1494
+ },
1495
+
1496
+ hideErrorModal() {
1497
+ elements.errorModal.classList.add('hidden');
1498
+ document.body.style.overflow = '';
1499
+ },
1500
+
1501
+ updateConnectionStatus(connected) {
1502
+ if (elements.connectionStatus) {
1503
+ elements.connectionStatus.className = 'connection-status' + (connected ? '' : ' disconnected');
1504
+ const statusText = elements.connectionStatus.querySelector('.status-text');
1505
+ if (statusText) {
1506
+ statusText.textContent = connected ? 'connected' : 'disconnected';
1507
+ }
1508
+ }
1509
+ },
1510
+
1511
+ updateTokenLimitWarning(isOverLimit) {
1512
+ const existingWarning = document.querySelector('.token-limit-warning');
1513
+ if (isOverLimit) {
1514
+ if (!existingWarning) {
1515
+ const warning = document.createElement('div');
1516
+ warning.className = 'token-limit-warning';
1517
+ warning.innerHTML = `
1518
+ <span class="warning-icon">⚠️</span>
1519
+ <span>Context limit exceeded (175K tokens). Please create a new thread for best results.</span>
1520
+ `;
1521
+ const inputArea = document.querySelector('.input-area');
1522
+ inputArea.insertBefore(warning, inputArea.firstChild);
1523
+ }
1524
+ elements.messageInput.placeholder = 'Context limit exceeded - create new thread';
1525
+ } else {
1526
+ if (existingWarning) {
1527
+ existingWarning.remove();
1528
+ }
1529
+ elements.messageInput.placeholder = 'Ask anything...';
1530
+ }
1531
+ },
1532
+
1533
+ checkTokenLimit() {
1534
+ const conversation = Conversations.getCurrent();
1535
+ const tokens = conversation ? getConversationTokens(conversation) : 0;
1536
+ const isOverLimit = tokens > MAX_CONTEXT_TOKENS;
1537
+ this.updateTokenLimitWarning(isOverLimit);
1538
+ return isOverLimit;
1539
+ },
1540
+
1541
+ renderStats() {
1542
+ if (!elements.statsList || !state.modelStats) return;
1543
+
1544
+ const { date, models } = state.modelStats;
1545
+
1546
+ // Update date display
1547
+ if (elements.statsDate) {
1548
+ elements.statsDate.textContent = `Today: ${date}`;
1549
+ }
1550
+
1551
+ // Clear and render stats
1552
+ elements.statsList.innerHTML = '';
1553
+
1554
+ const modelIds = Object.keys(models);
1555
+ if (modelIds.length === 0) {
1556
+ elements.statsList.innerHTML = `
1557
+ <div class="stats-loading">
1558
+ <span>No models available</span>
1559
+ </div>
1560
+ `;
1561
+ return;
1562
+ }
1563
+
1564
+ modelIds.forEach((modelId) => {
1565
+ const stat = models[modelId];
1566
+ const percentage = stat.limit > 0 ? (stat.used / stat.limit) * 100 : 0;
1567
+
1568
+ // Determine status class
1569
+ let barClass = '';
1570
+ let remainingClass = '';
1571
+ if (percentage >= 90) {
1572
+ barClass = 'danger';
1573
+ remainingClass = 'critical';
1574
+ } else if (percentage >= 70) {
1575
+ barClass = 'warning';
1576
+ remainingClass = 'low';
1577
+ }
1578
+
1579
+ const item = document.createElement('div');
1580
+ item.className = 'stats-item';
1581
+ item.innerHTML = `
1582
+ <div class="stats-item-header">
1583
+ <span class="stats-item-name" title="${modelId}">${this.truncateModelName(modelId)}</span>
1584
+ <span class="stats-item-count">${stat.used}/${stat.limit}</span>
1585
+ </div>
1586
+ <div class="stats-item-bar">
1587
+ <div class="stats-item-bar-fill ${barClass}" style="width: ${Math.min(percentage, 100)}%"></div>
1588
+ </div>
1589
+ `;
1590
+
1591
+ elements.statsList.appendChild(item);
1592
+ });
1593
+ },
1594
+
1595
+ truncateModelName(name) {
1596
+ if (name.length <= 20) return name;
1597
+ return name.substring(0, 18) + '...';
1598
+ },
1599
+
1600
+ updateStatsAfterChat(modelId) {
1601
+ if (!state.modelStats || !state.modelStats.models[modelId]) return;
1602
+
1603
+ // Update local stats
1604
+ const stat = state.modelStats.models[modelId];
1605
+ stat.used += 1;
1606
+ stat.remaining = Math.max(0, stat.limit - stat.used);
1607
+
1608
+ // Re-render stats
1609
+ this.renderStats();
1610
+ },
1611
+
1612
+ setStatsLoading(loading) {
1613
+ if (!elements.statsList) return;
1614
+
1615
+ if (loading) {
1616
+ elements.statsList.innerHTML = `
1617
+ <div class="stats-loading">
1618
+ <span class="stats-loading-icon">◌</span>
1619
+ <span>Loading stats...</span>
1620
+ </div>
1621
+ `;
1622
+ }
1623
+ },
1624
+
1625
+ toggleRightSidebar() {
1626
+ if (elements.sidebarRight) {
1627
+ elements.sidebarRight.classList.toggle('hidden');
1628
+ }
1629
+ },
1630
+ };
1631
+
1632
+ // ============================================
1633
+ // Event Handlers
1634
+ // ============================================
1635
+ function setupEventListeners() {
1636
+ // Send button
1637
+ elements.sendBtn.addEventListener('click', handleSend);
1638
+
1639
+ // Enter to send (Shift+Enter for new line)
1640
+ elements.messageInput.addEventListener('keydown', (e) => {
1641
+ // First check if FileMention wants to handle this key
1642
+ if (FileMention.handleKeydown(e, elements.messageInput)) {
1643
+ return;
1644
+ }
1645
+
1646
+ if (e.key === 'Enter' && !e.shiftKey) {
1647
+ e.preventDefault();
1648
+ handleSend();
1649
+ }
1650
+ });
1651
+
1652
+ // Auto-resize textarea and handle file mention
1653
+ elements.messageInput.addEventListener('input', () => {
1654
+ const el = elements.messageInput;
1655
+ el.style.height = 'auto';
1656
+ el.style.height = Math.min(el.scrollHeight, 200) + 'px';
1657
+
1658
+ // Update char count
1659
+ if (el.value.length > 0) {
1660
+ elements.charCount.textContent = el.value.length.toLocaleString();
1661
+ } else {
1662
+ elements.charCount.textContent = '';
1663
+ }
1664
+
1665
+ // Enable/disable send button
1666
+ elements.sendBtn.disabled = !el.value.trim() || state.isStreaming;
1667
+
1668
+ // Handle file mention detection
1669
+ FileMention.handleInput(el);
1670
+ });
1671
+
1672
+ // New chat button
1673
+ elements.newChatBtn.addEventListener('click', () => {
1674
+ Conversations.create();
1675
+ UI.renderConversationsList();
1676
+ UI.renderMessages();
1677
+ elements.messageInput.focus();
1678
+ });
1679
+
1680
+ // Model selector
1681
+ elements.modelSelect.addEventListener('change', (e) => {
1682
+ state.selectedModel = e.target.value;
1683
+ Storage.set(STORAGE_KEYS.SETTINGS, { selectedModel: state.selectedModel });
1684
+
1685
+ const conversation = Conversations.getCurrent();
1686
+ if (conversation) {
1687
+ conversation.model = state.selectedModel;
1688
+ Conversations.save();
1689
+ }
1690
+ });
1691
+
1692
+ // Agent selector
1693
+ if (elements.agentSelect) {
1694
+ elements.agentSelect.addEventListener('change', (e) => {
1695
+ state.selectedAgent = e.target.value;
1696
+ Storage.set(STORAGE_KEYS.SETTINGS, {
1697
+ ...Storage.get(STORAGE_KEYS.SETTINGS),
1698
+ selectedAgent: state.selectedAgent,
1699
+ });
1700
+
1701
+ const conversation = Conversations.getCurrent();
1702
+ if (conversation) {
1703
+ conversation.agent = state.selectedAgent;
1704
+ Conversations.save();
1705
+ }
1706
+ });
1707
+ }
1708
+
1709
+ // Error modal close
1710
+ elements.errorCloseBtn.addEventListener('click', () => {
1711
+ UI.hideErrorModal();
1712
+ });
1713
+
1714
+ // Close modal on backdrop click
1715
+ elements.errorModal.addEventListener('click', (e) => {
1716
+ if (e.target === elements.errorModal || e.target.classList.contains('modal-backdrop')) {
1717
+ UI.hideErrorModal();
1718
+ }
1719
+ });
1720
+
1721
+ // Keyboard shortcuts
1722
+ document.addEventListener('keydown', (e) => {
1723
+ // Cmd/Ctrl + N = New chat
1724
+ if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
1725
+ e.preventDefault();
1726
+ Conversations.create();
1727
+ UI.renderConversationsList();
1728
+ UI.renderMessages();
1729
+ elements.messageInput.focus();
1730
+ }
1731
+
1732
+ // Escape = Close modal
1733
+ if (e.key === 'Escape') {
1734
+ UI.hideErrorModal();
1735
+ }
1736
+ });
1737
+
1738
+ // Toggle right sidebar
1739
+ if (elements.toggleRightSidebarBtn) {
1740
+ elements.toggleRightSidebarBtn.addEventListener('click', () => {
1741
+ UI.toggleRightSidebar();
1742
+ });
1743
+ }
1744
+
1745
+ // Refresh stats button
1746
+ if (elements.refreshStatsBtn) {
1747
+ elements.refreshStatsBtn.addEventListener('click', async () => {
1748
+ await loadStats();
1749
+ });
1750
+ }
1751
+
1752
+ // Quick prompts
1753
+ if (elements.promptsList) {
1754
+ elements.promptsList.addEventListener('click', (e) => {
1755
+ const promptItem = e.target.closest('.prompt-item');
1756
+ if (promptItem) {
1757
+ const promptText = promptItem.dataset.prompt;
1758
+ if (promptText) {
1759
+ elements.messageInput.value = promptText + ' ';
1760
+ elements.messageInput.focus();
1761
+ // Trigger input event to update UI
1762
+ elements.messageInput.dispatchEvent(new Event('input'));
1763
+ }
1764
+ }
1765
+ });
1766
+ }
1767
+
1768
+ // Theme toggle
1769
+ if (elements.themeToggleBtn) {
1770
+ elements.themeToggleBtn.addEventListener('click', () => {
1771
+ Theme.toggle();
1772
+ });
1773
+ }
1774
+ }
1775
+
1776
+ async function loadStats() {
1777
+ try {
1778
+ UI.setStatsLoading(true);
1779
+ const statsResponse = await API.getStats();
1780
+ if (statsResponse.success && statsResponse.data) {
1781
+ state.modelStats = statsResponse.data;
1782
+ UI.renderStats();
1783
+ }
1784
+ } catch (error) {
1785
+ console.error('Failed to load stats:', error);
1786
+ if (elements.statsList) {
1787
+ elements.statsList.innerHTML = `
1788
+ <div class="stats-loading">
1789
+ <span>Failed to load stats</span>
1790
+ </div>
1791
+ `;
1792
+ }
1793
+ }
1794
+ }
1795
+
1796
+ async function handleSend() {
1797
+ const content = elements.messageInput.value.trim();
1798
+ if (!content || state.isStreaming) return;
1799
+
1800
+ // Ensure we have a conversation
1801
+ if (!state.currentConversationId) {
1802
+ Conversations.create();
1803
+ UI.renderConversationsList();
1804
+ }
1805
+
1806
+ // Clear input
1807
+ elements.messageInput.value = '';
1808
+ elements.messageInput.style.height = 'auto';
1809
+ elements.charCount.textContent = '';
1810
+
1811
+ // Get file contents if any files are mentioned
1812
+ let fileContents = [];
1813
+ let fileContextStr = '';
1814
+ const hasMentionedFiles = state.mentionedFiles.length > 0;
1815
+ if (hasMentionedFiles) {
1816
+ fileContents = await FileMention.getFileContents();
1817
+ fileContextStr = FileMention.formatFileContext(fileContents);
1818
+ FileMention.clearFiles(); // Clear after getting contents
1819
+ }
1820
+
1821
+ // Build full message with file context
1822
+ const fullMessage = fileContextStr ? content + fileContextStr : content;
1823
+
1824
+ // Display user message (show original content, not with file context)
1825
+ const displayMessage = hasMentionedFiles
1826
+ ? content + `\n\n📎 _${fileContents.length} file(s) attached_`
1827
+ : content;
1828
+
1829
+ // Add user message
1830
+ Conversations.addMessage('user', displayMessage);
1831
+ UI.appendMessage('user', displayMessage);
1832
+
1833
+ // Check token limit before sending
1834
+ if (UI.checkTokenLimit()) {
1835
+ return;
1836
+ }
1837
+
1838
+ // Create assistant message placeholder with loading state
1839
+ const assistantMsgEl = UI.appendMessage('assistant', '', null, true);
1840
+ UI.setStreaming(true);
1841
+
1842
+ let fullResponse = '';
1843
+
1844
+ try {
1845
+ // Get history (last 10 messages for context)
1846
+ const conversation = Conversations.getCurrent();
1847
+ const history = conversation.messages.slice(-11, -1).map((m) => ({
1848
+ role: m.role,
1849
+ content: m.content,
1850
+ }));
1851
+
1852
+ // Get system prompt from agent
1853
+ const agent = UI.getAgentById(conversation.agent || state.selectedAgent);
1854
+ const systemPrompt = agent.systemPrompt;
1855
+
1856
+ // Stream response (send fullMessage with file context)
1857
+ for await (const event of API.streamChat(fullMessage, state.selectedModel, history, systemPrompt)) {
1858
+ if (event.type === 'chunk') {
1859
+ fullResponse += event.content;
1860
+ UI.updateStreamingMessage(assistantMsgEl, fullResponse);
1861
+ } else if (event.type === 'done') {
1862
+ UI.updateStreamingMessage(assistantMsgEl, fullResponse, true);
1863
+ Conversations.addMessage('assistant', fullResponse);
1864
+ // Update stats after successful chat
1865
+ UI.updateStatsAfterChat(state.selectedModel);
1866
+ // Update conversation list to show new token count
1867
+ UI.renderConversationsList();
1868
+ // Check if we've exceeded token limit after response
1869
+ UI.checkTokenLimit();
1870
+ } else if (event.type === 'error') {
1871
+ UI.showError(assistantMsgEl, event.message);
1872
+ if (event.code === 'ERR-WC-001') {
1873
+ UI.updateConnectionStatus(false);
1874
+ }
1875
+ }
1876
+ }
1877
+ } catch (error) {
1878
+ console.error('Chat error:', error);
1879
+
1880
+ if (error.status === 401) {
1881
+ UI.updateConnectionStatus(false);
1882
+ UI.showErrorModal('Session Expired', "Please run 'jai1 chat --web' again to start a new session.");
1883
+ } else {
1884
+ UI.showError(assistantMsgEl, error.message || 'Failed to get response');
1885
+ }
1886
+ } finally {
1887
+ UI.setStreaming(false);
1888
+ elements.messageInput.focus();
1889
+ }
1890
+ }
1891
+
1892
+ // ============================================
1893
+ // Initialization
1894
+ // ============================================
1895
+ async function init() {
1896
+ // Initialize theme first to prevent flash
1897
+ Theme.init();
1898
+
1899
+ try {
1900
+ // Get session token from URL and store it
1901
+ const urlParams = new URLSearchParams(window.location.search);
1902
+ const urlToken = urlParams.get('session');
1903
+
1904
+ if (urlToken) {
1905
+ Storage.set(STORAGE_KEYS.SESSION, {
1906
+ token: urlToken,
1907
+ storedAt: new Date().toISOString(),
1908
+ });
1909
+
1910
+ // Clean URL
1911
+ window.history.replaceState({}, '', window.location.pathname);
1912
+ }
1913
+
1914
+ // Validate session
1915
+ const sessionResponse = await API.getSession();
1916
+ if (!sessionResponse.success) {
1917
+ throw new Error('Invalid session');
1918
+ }
1919
+
1920
+ state.session = sessionResponse.data;
1921
+ UI.updateConnectionStatus(true);
1922
+
1923
+ // Get models
1924
+ const modelsResponse = await API.getModels();
1925
+ state.models = modelsResponse.data || [];
1926
+
1927
+ // Load settings
1928
+ const settings = Storage.get(STORAGE_KEYS.SETTINGS) || {};
1929
+ // Default to jai1-auto if available, otherwise first model
1930
+ const defaultModel = state.models.find(m => m.id === 'jai1-auto')?.id || state.models[0]?.id || '';
1931
+ state.selectedModel = settings.selectedModel || defaultModel;
1932
+ state.selectedAgent = settings.selectedAgent || 'default';
1933
+
1934
+ // Load conversations
1935
+ Conversations.load();
1936
+
1937
+ // If we have conversations, select the first one
1938
+ if (state.conversations.length > 0) {
1939
+ state.currentConversationId = state.conversations[0].id;
1940
+ // Restore agent from conversation
1941
+ const currentConv = Conversations.getCurrent();
1942
+ if (currentConv && currentConv.agent) {
1943
+ state.selectedAgent = currentConv.agent;
1944
+ }
1945
+ }
1946
+
1947
+ // Render UI
1948
+ UI.updateModelsDropdown();
1949
+ UI.updateAgentsDropdown();
1950
+ UI.renderConversationsList();
1951
+ UI.renderMessages();
1952
+
1953
+ // Setup event listeners
1954
+ setupEventListeners();
1955
+
1956
+ // Initialize file mention system
1957
+ FileMention.init();
1958
+
1959
+ // Load stats (only once at init)
1960
+ loadStats();
1961
+
1962
+
1963
+ // Configure Mermaid if available
1964
+ if (typeof mermaid !== 'undefined') {
1965
+ mermaid.initialize({
1966
+ startOnLoad: false,
1967
+ theme: 'dark',
1968
+ securityLevel: 'loose'
1969
+ });
1970
+ }
1971
+ if (typeof marked !== 'undefined') {
1972
+ marked.setOptions({
1973
+ breaks: true,
1974
+ gfm: true,
1975
+ });
1976
+ }
1977
+
1978
+ // Hide loading
1979
+ UI.setLoading(false);
1980
+
1981
+ // Focus input
1982
+ elements.messageInput.focus();
1983
+ } catch (error) {
1984
+ console.error('Init error:', error);
1985
+ UI.setLoading(false);
1986
+
1987
+ if (error.status === 401 || error.message.includes('session')) {
1988
+ UI.showErrorModal('Session Invalid', "Please run 'jai1 chat --web' again to start a new session.");
1989
+ } else {
1990
+ UI.showErrorModal('Connection Error', 'Failed to connect to Jai1 server. Please try again.');
1991
+ }
1992
+ }
1993
+ }
1994
+
1995
+ // ============================================
1996
+ // Utilities
1997
+ // ============================================
1998
+ function escapeHtml(text) {
1999
+ const div = document.createElement('div');
2000
+ div.textContent = text;
2001
+ return div.innerHTML;
2002
+ }
2003
+
2004
+ // ============================================
2005
+ // Start
2006
+ // ============================================
2007
+ if (document.readyState === 'loading') {
2008
+ document.addEventListener('DOMContentLoaded', init);
2009
+ } else {
2010
+ init();
2011
+ }
2012
+ })();