melchat 0.0.1

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,2114 @@
1
+
2
+ <!DOCTYPE html>
3
+ <!-- Version: 1.6 - Local model quick switcher (Qwen/Dolphin) -->
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="UTF-8">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ <title>Chat - AI Assistant</title>
9
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css">
10
+ <style>
11
+ * { box-sizing: border-box; margin: 0; padding: 0; }
12
+ body {
13
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
14
+ background: #f5f5f5;
15
+ height: 100vh;
16
+ height: calc(var(--vh, 1vh) * 100);
17
+ overflow: hidden;
18
+ }
19
+ .app-container {
20
+ display: flex;
21
+ height: 100vh;
22
+ height: calc(var(--vh, 1vh) * 100);
23
+ max-width: 1400px;
24
+ margin: 0 auto;
25
+ background: white;
26
+ box-shadow: 0 0 20px rgba(0,0,0,0.1);
27
+ }
28
+ .sidebar {
29
+ width: 260px;
30
+ background: #f8f9fa;
31
+ border-right: 1px solid #e0e0e0;
32
+ display: flex;
33
+ flex-direction: column;
34
+ }
35
+ .sidebar-header {
36
+ padding: 1rem;
37
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
38
+ color: white;
39
+ }
40
+ .sidebar-header h2 {
41
+ font-size: 1rem;
42
+ margin-bottom: 0.75rem;
43
+ }
44
+ .new-chat-btn {
45
+ width: 100%;
46
+ padding: 0.75rem;
47
+ background: rgba(255,255,255,0.2);
48
+ color: white;
49
+ border: 1px solid rgba(255,255,255,0.3);
50
+ border-radius: 6px;
51
+ cursor: pointer;
52
+ font-size: 0.9rem;
53
+ transition: background 0.2s;
54
+ }
55
+ .new-chat-btn:hover {
56
+ background: rgba(255,255,255,0.3);
57
+ }
58
+ .conversations-list {
59
+ flex: 1;
60
+ overflow-y: auto;
61
+ padding: 0.5rem;
62
+ }
63
+ .conversation-item {
64
+ padding: 0.75rem;
65
+ margin-bottom: 0.25rem;
66
+ border-radius: 6px;
67
+ cursor: pointer;
68
+ transition: background 0.2s;
69
+ display: flex;
70
+ justify-content: space-between;
71
+ align-items: center;
72
+ gap: 0.5rem;
73
+ }
74
+ .conversation-item:hover {
75
+ background: #e9ecef;
76
+ }
77
+ .conversation-item.active {
78
+ background: #667eea;
79
+ color: white;
80
+ }
81
+ .conversation-title {
82
+ flex: 1;
83
+ font-size: 0.9rem;
84
+ overflow: hidden;
85
+ text-overflow: ellipsis;
86
+ white-space: nowrap;
87
+ }
88
+ .conversation-date {
89
+ font-size: 0.75rem;
90
+ color: #999;
91
+ white-space: nowrap;
92
+ }
93
+ .conversation-item.active .conversation-date {
94
+ color: rgba(255,255,255,0.8);
95
+ }
96
+ .delete-conversation {
97
+ opacity: 0;
98
+ background: none;
99
+ border: none;
100
+ color: inherit;
101
+ cursor: pointer;
102
+ padding: 0.25rem;
103
+ font-size: 1rem;
104
+ }
105
+ .conversation-item:hover .delete-conversation {
106
+ opacity: 0.6;
107
+ }
108
+ .delete-conversation:hover {
109
+ opacity: 1 !important;
110
+ }
111
+ .chat-container {
112
+ flex: 1;
113
+ display: flex;
114
+ flex-direction: column;
115
+ }
116
+ .header {
117
+ padding: 1rem 1.5rem;
118
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
119
+ color: white;
120
+ display: flex;
121
+ justify-content: space-between;
122
+ align-items: center;
123
+ }
124
+ .header h1 {
125
+ font-size: 1.25rem;
126
+ font-weight: 600;
127
+ cursor: pointer;
128
+ }
129
+ .header-controls {
130
+ display: flex;
131
+ gap: 1rem;
132
+ align-items: center;
133
+ }
134
+ .token-counter {
135
+ background: rgba(255,255,255,0.2);
136
+ padding: 0.5rem 0.75rem;
137
+ border-radius: 6px;
138
+ font-size: 0.875rem;
139
+ border: 1px solid rgba(255,255,255,0.3);
140
+ }
141
+ .token-counter-detail {
142
+ font-size: 0.75rem;
143
+ opacity: 0.9;
144
+ margin-top: 0.25rem;
145
+ }
146
+ .model-select, .clear-btn {
147
+ background: rgba(255,255,255,0.2);
148
+ color: white;
149
+ border: 1px solid rgba(255,255,255,0.3);
150
+ border-radius: 6px;
151
+ padding: 0.5rem 0.75rem;
152
+ font-size: 0.875rem;
153
+ cursor: pointer;
154
+ transition: background 0.2s;
155
+ }
156
+ .model-select:hover, .clear-btn:hover {
157
+ background: rgba(255,255,255,0.3);
158
+ }
159
+ #localModelSwitcher {
160
+ display: flex;
161
+ gap: 5px;
162
+ }
163
+ .model-switch-btn {
164
+ background: rgba(255,255,255,0.2);
165
+ color: white;
166
+ border: 1px solid rgba(255,255,255,0.3);
167
+ border-radius: 6px;
168
+ padding: 0.5rem 0.75rem;
169
+ font-size: 0.875rem;
170
+ cursor: pointer;
171
+ transition: all 0.2s;
172
+ }
173
+ .model-switch-btn:hover {
174
+ background: rgba(255,255,255,0.3);
175
+ }
176
+ .model-switch-btn.active {
177
+ background: rgba(255,255,255,0.4);
178
+ border-color: rgba(255,255,255,0.6);
179
+ font-weight: bold;
180
+ }
181
+ .model-select {
182
+ max-width: 250px;
183
+ }
184
+ .language-select {
185
+ max-width: 100px;
186
+ }
187
+ .model-select option {
188
+ background: #764ba2;
189
+ color: white;
190
+ }
191
+ .model-select optgroup {
192
+ background: #5a3980;
193
+ font-weight: bold;
194
+ }
195
+ .messages {
196
+ flex: 1;
197
+ overflow-y: auto;
198
+ padding: 1.5rem;
199
+ display: flex;
200
+ flex-direction: column;
201
+ gap: 1.5rem;
202
+ will-change: contents;
203
+ }
204
+ .message {
205
+ display: flex;
206
+ gap: 1rem;
207
+ max-width: 80%;
208
+ animation: fadeIn 0.3s ease-in;
209
+ position: relative;
210
+ }
211
+ @keyframes fadeIn {
212
+ from { opacity: 0; transform: translateY(10px); }
213
+ to { opacity: 1; transform: translateY(0); }
214
+ }
215
+ .message.user {
216
+ align-self: flex-end;
217
+ flex-direction: row-reverse;
218
+ }
219
+ .message-avatar {
220
+ width: 36px;
221
+ height: 36px;
222
+ border-radius: 50%;
223
+ display: flex;
224
+ align-items: center;
225
+ justify-content: center;
226
+ font-size: 1.25rem;
227
+ flex-shrink: 0;
228
+ }
229
+ .message.user .message-avatar {
230
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
231
+ }
232
+ .message.assistant .message-avatar {
233
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
234
+ }
235
+ .message-content {
236
+ background: #f8f9fa;
237
+ padding: 1rem;
238
+ border-radius: 12px;
239
+ line-height: 1.6;
240
+ word-wrap: break-word;
241
+ flex: 1;
242
+ }
243
+ .message.user .message-content {
244
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
245
+ color: white;
246
+ }
247
+ .message-actions {
248
+ position: absolute;
249
+ top: -0.5rem;
250
+ right: -0.5rem;
251
+ display: flex;
252
+ gap: 0.25rem;
253
+ opacity: 0;
254
+ transition: opacity 0.2s;
255
+ }
256
+ .message.user .message-actions {
257
+ left: -0.5rem;
258
+ right: auto;
259
+ }
260
+ .message:hover .message-actions {
261
+ opacity: 1;
262
+ }
263
+ .message-action-btn {
264
+ background: white;
265
+ border: 1px solid #e0e0e0;
266
+ border-radius: 4px;
267
+ padding: 0.25rem 0.5rem;
268
+ font-size: 0.75rem;
269
+ cursor: pointer;
270
+ transition: all 0.2s;
271
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
272
+ }
273
+ .message-action-btn:hover {
274
+ background: #f0f0f0;
275
+ transform: translateY(-1px);
276
+ }
277
+ .edit-area {
278
+ margin-top: 0.5rem;
279
+ display: flex;
280
+ gap: 0.5rem;
281
+ }
282
+ .edit-textarea {
283
+ flex: 1;
284
+ padding: 0.5rem;
285
+ border: 2px solid #667eea;
286
+ border-radius: 8px;
287
+ font-family: inherit;
288
+ font-size: 1rem;
289
+ resize: vertical;
290
+ min-height: 60px;
291
+ }
292
+ .edit-btn {
293
+ padding: 0.5rem 1rem;
294
+ background: #667eea;
295
+ color: white;
296
+ border: none;
297
+ border-radius: 6px;
298
+ cursor: pointer;
299
+ font-size: 0.875rem;
300
+ }
301
+ .edit-btn-cancel {
302
+ background: #999;
303
+ }
304
+ .message-content p {
305
+ margin-bottom: 0.75rem;
306
+ }
307
+ .message-content p:last-child {
308
+ margin-bottom: 0;
309
+ }
310
+ .message-content ul, .message-content ol {
311
+ margin-left: 1.5rem;
312
+ margin-bottom: 0.75rem;
313
+ }
314
+ .message-content h1, .message-content h2, .message-content h3 {
315
+ margin-top: 1rem;
316
+ margin-bottom: 0.5rem;
317
+ }
318
+ .message-content h1 { font-size: 1.5rem; }
319
+ .message-content h2 { font-size: 1.25rem; }
320
+ .message-content h3 { font-size: 1.1rem; }
321
+ .message-content blockquote {
322
+ border-left: 4px solid #667eea;
323
+ padding-left: 1rem;
324
+ margin: 0.75rem 0;
325
+ color: #666;
326
+ }
327
+ .message.user .message-content blockquote {
328
+ border-left-color: rgba(255,255,255,0.5);
329
+ color: rgba(255,255,255,0.9);
330
+ }
331
+ .message-content a {
332
+ color: #667eea;
333
+ text-decoration: underline;
334
+ }
335
+ .message.user .message-content a {
336
+ color: white;
337
+ }
338
+ .message-content img {
339
+ max-width: 100%;
340
+ height: auto;
341
+ border-radius: 8px;
342
+ margin: 0.5rem 0;
343
+ display: block;
344
+ }
345
+ .message-content video {
346
+ max-width: 100%;
347
+ height: auto;
348
+ border-radius: 8px;
349
+ margin: 0.5rem 0;
350
+ display: block;
351
+ }
352
+ .message-content code {
353
+ background: rgba(0,0,0,0.05);
354
+ padding: 0.2rem 0.4rem;
355
+ border-radius: 4px;
356
+ font-family: 'Courier New', monospace;
357
+ font-size: 0.9em;
358
+ }
359
+ .message.user .message-content code {
360
+ background: rgba(0,0,0,0.2);
361
+ }
362
+ .message-content pre {
363
+ position: relative;
364
+ background: #1e1e1e;
365
+ padding: 1rem;
366
+ border-radius: 8px;
367
+ overflow-x: auto;
368
+ margin: 0.75rem 0;
369
+ }
370
+ .message-content pre code {
371
+ background: none;
372
+ padding: 0;
373
+ color: #d4d4d4;
374
+ font-size: 0.9rem;
375
+ }
376
+ .copy-btn {
377
+ position: absolute;
378
+ top: 0.5rem;
379
+ right: 0.5rem;
380
+ background: rgba(255,255,255,0.1);
381
+ color: white;
382
+ border: 1px solid rgba(255,255,255,0.2);
383
+ border-radius: 4px;
384
+ padding: 0.25rem 0.5rem;
385
+ font-size: 0.75rem;
386
+ cursor: pointer;
387
+ transition: background 0.2s;
388
+ }
389
+ .copy-btn:hover {
390
+ background: rgba(255,255,255,0.2);
391
+ }
392
+ .copy-btn.copied {
393
+ background: #4caf50;
394
+ border-color: #4caf50;
395
+ }
396
+ .input-container {
397
+ padding: 1.5rem;
398
+ background: white;
399
+ border-top: 1px solid #e0e0e0;
400
+ }
401
+ .input-wrapper {
402
+ display: flex;
403
+ gap: 0.75rem;
404
+ }
405
+ .input-wrapper textarea {
406
+ flex: 1;
407
+ padding: 0.75rem 1rem;
408
+ border: 2px solid #e0e0e0;
409
+ border-radius: 12px;
410
+ font-family: inherit;
411
+ font-size: 1rem;
412
+ resize: none;
413
+ transition: border-color 0.2s;
414
+ }
415
+ .input-wrapper textarea:focus {
416
+ outline: none;
417
+ border-color: #667eea;
418
+ }
419
+ .send-btn {
420
+ padding: 0.75rem 1.5rem;
421
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
422
+ color: white;
423
+ border: none;
424
+ border-radius: 12px;
425
+ font-size: 1rem;
426
+ cursor: pointer;
427
+ transition: transform 0.2s;
428
+ }
429
+ .send-btn:hover:not(:disabled) {
430
+ transform: translateY(-2px);
431
+ }
432
+ .send-btn:disabled {
433
+ opacity: 0.5;
434
+ cursor: not-allowed;
435
+ }
436
+ .settings-overlay {
437
+ position: fixed;
438
+ top: 0;
439
+ left: 0;
440
+ right: 0;
441
+ bottom: 0;
442
+ background: rgba(0,0,0,0.5);
443
+ display: flex;
444
+ align-items: center;
445
+ justify-content: center;
446
+ z-index: 1000;
447
+ }
448
+ .settings-dialog {
449
+ background: white;
450
+ padding: 2rem;
451
+ border-radius: 12px;
452
+ max-width: 600px;
453
+ width: 90%;
454
+ max-height: 80vh;
455
+ overflow-y: auto;
456
+ }
457
+ .form-group {
458
+ margin-bottom: 1.5rem;
459
+ }
460
+ .form-group label {
461
+ display: block;
462
+ margin-bottom: 0.5rem;
463
+ font-weight: 500;
464
+ color: #333;
465
+ }
466
+ .form-group input, .form-group textarea {
467
+ width: 100%;
468
+ padding: 0.75rem;
469
+ border: 2px solid #e0e0e0;
470
+ border-radius: 8px;
471
+ font-size: 1rem;
472
+ font-family: inherit;
473
+ transition: border-color 0.2s;
474
+ }
475
+ .form-group textarea {
476
+ resize: vertical;
477
+ min-height: 80px;
478
+ }
479
+ .form-group input:focus, .form-group textarea:focus {
480
+ outline: none;
481
+ border-color: #667eea;
482
+ }
483
+ .form-group small {
484
+ color: #999;
485
+ display: block;
486
+ margin-top: 0.5rem;
487
+ font-size: 0.85rem;
488
+ }
489
+ .btn {
490
+ padding: 0.75rem 1.5rem;
491
+ border-radius: 8px;
492
+ font-size: 1rem;
493
+ cursor: pointer;
494
+ border: none;
495
+ font-weight: 500;
496
+ margin-left: 0.5rem;
497
+ transition: transform 0.2s;
498
+ }
499
+ .btn:hover {
500
+ transform: translateY(-2px);
501
+ }
502
+ .btn-primary {
503
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
504
+ color: white;
505
+ }
506
+ .btn-secondary {
507
+ background: #e0e0e0;
508
+ color: #333;
509
+ }
510
+ .error {
511
+ background: #fee;
512
+ color: #c33;
513
+ padding: 1rem;
514
+ margin: 0 1.5rem;
515
+ border-radius: 8px;
516
+ border-left: 4px solid #c33;
517
+ }
518
+ .success {
519
+ background: #efe;
520
+ color: #3c3;
521
+ padding: 1rem;
522
+ margin: 0 1.5rem;
523
+ border-radius: 8px;
524
+ border-left: 4px solid #3c3;
525
+ }
526
+ .hidden { display: none; }
527
+ .typing-indicator {
528
+ display: flex;
529
+ gap: 0.25rem;
530
+ padding: 0.5rem 0;
531
+ }
532
+ .typing-indicator span {
533
+ width: 8px;
534
+ height: 8px;
535
+ border-radius: 50%;
536
+ background: #999;
537
+ animation: typing 1.4s infinite;
538
+ }
539
+ .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
540
+ .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
541
+ @keyframes typing {
542
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
543
+ 30% { transform: translateY(-10px); opacity: 1; }
544
+ }
545
+ .empty-state {
546
+ text-align: center;
547
+ padding: 4rem 2rem;
548
+ color: #999;
549
+ }
550
+ .empty-state-icon {
551
+ font-size: 3rem;
552
+ margin-bottom: 1rem;
553
+ }
554
+
555
+ /* Mobile menu toggle */
556
+ .menu-toggle {
557
+ display: none;
558
+ background: rgba(255,255,255,0.2);
559
+ color: white;
560
+ border: 1px solid rgba(255,255,255,0.3);
561
+ border-radius: 6px;
562
+ padding: 0.5rem 0.75rem;
563
+ font-size: 1.25rem;
564
+ cursor: pointer;
565
+ line-height: 1;
566
+ }
567
+
568
+ /* Tablet and below - hide sidebar by default, make it overlay */
569
+ @media (max-width: 840px) {
570
+ .menu-toggle {
571
+ display: block;
572
+ }
573
+
574
+ .app-container {
575
+ position: relative;
576
+ }
577
+
578
+ .sidebar {
579
+ position: fixed;
580
+ left: -280px;
581
+ top: 0;
582
+ bottom: 0;
583
+ z-index: 100;
584
+ transition: left 0.3s ease;
585
+ box-shadow: 2px 0 10px rgba(0,0,0,0.1);
586
+ }
587
+
588
+ .sidebar.open {
589
+ left: 0;
590
+ }
591
+
592
+ .sidebar-overlay {
593
+ display: none;
594
+ position: fixed;
595
+ top: 0;
596
+ left: 0;
597
+ right: 0;
598
+ bottom: 0;
599
+ background: rgba(0,0,0,0.5);
600
+ z-index: 99;
601
+ }
602
+
603
+ .sidebar-overlay.active {
604
+ display: block;
605
+ }
606
+
607
+ .header-controls {
608
+ gap: 0.5rem;
609
+ }
610
+
611
+ .model-select {
612
+ max-width: 200px;
613
+ }
614
+
615
+ .token-counter {
616
+ padding: 0.4rem 0.6rem;
617
+ font-size: 0.8rem;
618
+ }
619
+
620
+ .token-counter-detail {
621
+ display: none;
622
+ }
623
+ }
624
+
625
+ /* Mobile phones - Galaxy Fold cover screen and similar */
626
+ @media (max-width: 600px) {
627
+ .header {
628
+ padding: 0.75rem 1rem;
629
+ }
630
+
631
+ .header h1 {
632
+ font-size: 1rem;
633
+ }
634
+
635
+ .header-controls {
636
+ gap: 0.4rem;
637
+ flex-wrap: wrap;
638
+ }
639
+
640
+ .model-select {
641
+ max-width: 140px;
642
+ font-size: 0.8rem;
643
+ padding: 0.4rem 0.5rem;
644
+ }
645
+
646
+ .language-select {
647
+ max-width: 70px;
648
+ }
649
+
650
+ .token-counter {
651
+ font-size: 0.75rem;
652
+ padding: 0.35rem 0.5rem;
653
+ }
654
+
655
+ .clear-btn {
656
+ font-size: 0.8rem;
657
+ padding: 0.4rem 0.6rem;
658
+ }
659
+
660
+ .messages {
661
+ padding: 1rem;
662
+ gap: 1rem;
663
+ }
664
+
665
+ .message {
666
+ max-width: 95%;
667
+ }
668
+
669
+ .message-avatar {
670
+ width: 32px;
671
+ height: 32px;
672
+ font-size: 1rem;
673
+ }
674
+
675
+ .message-content {
676
+ padding: 0.75rem;
677
+ font-size: 0.95rem;
678
+ }
679
+
680
+ .input-container {
681
+ padding: 1rem;
682
+ }
683
+
684
+ .input-wrapper {
685
+ gap: 0.5rem;
686
+ }
687
+
688
+ .input-wrapper textarea {
689
+ font-size: 0.95rem;
690
+ padding: 0.65rem 0.85rem;
691
+ }
692
+
693
+ .send-btn {
694
+ padding: 0.65rem 1rem;
695
+ font-size: 0.95rem;
696
+ }
697
+
698
+ .settings-dialog {
699
+ padding: 1.5rem;
700
+ width: 95%;
701
+ }
702
+
703
+ .sidebar {
704
+ width: 280px;
705
+ left: -300px;
706
+ }
707
+
708
+ .conversation-item {
709
+ padding: 0.6rem;
710
+ }
711
+
712
+ .conversation-title {
713
+ font-size: 0.85rem;
714
+ }
715
+ }
716
+
717
+ /* Very narrow screens - Galaxy Fold folded position */
718
+ @media (max-width: 380px) {
719
+ .header h1 {
720
+ font-size: 0.9rem;
721
+ }
722
+
723
+ .model-select {
724
+ max-width: 100px;
725
+ font-size: 0.75rem;
726
+ }
727
+
728
+ .message {
729
+ max-width: 100%;
730
+ gap: 0.5rem;
731
+ }
732
+
733
+ .message-avatar {
734
+ width: 28px;
735
+ height: 28px;
736
+ }
737
+
738
+ .input-wrapper textarea {
739
+ font-size: 0.9rem;
740
+ }
741
+
742
+ .send-btn {
743
+ padding: 0.6rem 0.8rem;
744
+ font-size: 0.9rem;
745
+ }
746
+ }
747
+ </style>
748
+ </head>
749
+ <body>
750
+ <div class="app-container">
751
+ <div class="sidebar">
752
+ <div class="sidebar-header">
753
+ <h2>💬 Conversations</h2>
754
+ <button class="new-chat-btn" onclick="app.newConversation()">+ New Chat</button>
755
+ </div>
756
+ <div class="conversations-list" id="conversationsList"></div>
757
+ </div>
758
+ <div class="sidebar-overlay" onclick="app.toggleSidebar()"></div>
759
+ <div class="chat-container">
760
+ <div class="header">
761
+ <button class="menu-toggle" onclick="app.toggleSidebar()">☰</button>
762
+ <h1 id="chatTitle" onclick="app.renameConversation()">New Chat</h1>
763
+ <div class="header-controls">
764
+ <div class="token-counter" id="tokenCounter">
765
+ <div>🎯 Tokens: 0</div>
766
+ <div class="token-counter-detail">Cost: $0.00</div>
767
+ </div>
768
+ <select class="model-select language-select" id="languageSelect" onchange="app.changeLanguage()">
769
+ <option value="en">🇬🇧 EN</option>
770
+ <option value="cs">🇨🇿 CS</option>
771
+ <option value="fr">🇫🇷 FR</option>
772
+ <option value="de">🇩🇪 DE</option>
773
+ <option value="uk">🇺🇦 UA</option>
774
+ <option value="ru">🇷🇺 RU</option>
775
+ </select>
776
+ <div id="localModelSwitcher" style="display: none; gap: 5px;">
777
+ <button class="model-switch-btn" onclick="app.switchLocalModel('qwen2.5:7b')" data-model="qwen2.5:7b">🧠 Qwen</button>
778
+ <button class="model-switch-btn" onclick="app.switchLocalModel('dolphin-mistral:7b')" data-model="dolphin-mistral:7b">🐬 Dolphin</button>
779
+ </div>
780
+ <select class="model-select" id="modelSelect">
781
+ <optgroup label="🎁 Free Models">
782
+ <option value="xiaomi/mimo-v2-flash:free">MiMo V2 Flash (Free)</option>
783
+ <option value="google/gemma-2-9b-it:free">Gemma 2 9B (Free)</option>
784
+ <option value="meta-llama/llama-3.1-8b-instruct:free">Llama 3.1 8B (Free)</option>
785
+ <option value="microsoft/phi-3-mini-128k-instruct:free">Phi-3 Mini (Free)</option>
786
+ <option value="qwen/qwen-2-7b-instruct:free">Qwen 2 7B (Free)</option>
787
+ </optgroup>
788
+ <optgroup label="🌟 Frontier (Open Source)">
789
+ <option value="deepseek/deepseek-chat">DeepSeek V3 (Best Value)</option>
790
+ <option value="qwen/qwen-2.5-72b-instruct">Qwen 2.5 72B</option>
791
+ <option value="meta-llama/llama-3.3-70b-instruct">Llama 3.3 70B</option>
792
+ <option value="meta-llama/llama-3.1-405b-instruct">Llama 3.1 405B</option>
793
+ <option value="cohere/command-r-plus">Command R+ (128k)</option>
794
+ </optgroup>
795
+ <optgroup label="🚀 GPT Models">
796
+ <option value="openai/gpt-4-turbo">GPT-4 Turbo</option>
797
+ <option value="openai/gpt-4">GPT-4</option>
798
+ <option value="openai/gpt-3.5-turbo">GPT-3.5 Turbo</option>
799
+ </optgroup>
800
+ <optgroup label="🧠 Claude Models">
801
+ <option value="anthropic/claude-3.5-sonnet">Claude 3.5 Sonnet</option>
802
+ <option value="anthropic/claude-3-opus">Claude 3 Opus</option>
803
+ <option value="anthropic/claude-3-sonnet">Claude 3 Sonnet</option>
804
+ <option value="anthropic/claude-3-haiku">Claude 3 Haiku</option>
805
+ </optgroup>
806
+ <optgroup label="💎 Gemini Models">
807
+ <option value="google/gemini-pro-1.5">Gemini 1.5 Pro</option>
808
+ <option value="google/gemini-flash-1.5">Gemini 1.5 Flash</option>
809
+ <option value="google/gemini-pro">Gemini Pro</option>
810
+ </optgroup>
811
+ <optgroup label="🦙 Open Source">
812
+ <option value="meta-llama/llama-3-70b-instruct">Llama 3 70B</option>
813
+ <option value="mistralai/mistral-large">Mistral Large</option>
814
+ <option value="mistralai/mixtral-8x7b-instruct">Mixtral 8x7B</option>
815
+ <option value="microsoft/wizardlm-2-8x22b">WizardLM 2 8x22B</option>
816
+ </optgroup>
817
+ <optgroup label="🎨 Image Generation">
818
+ <option value="black-forest-labs/flux.2-klein-4b">Flux.2 Klein 4B (OpenRouter)</option>
819
+ <option value="fal-ai/z-image/turbo">Z-Image Turbo (fal.ai - Fastest)</option>
820
+ </optgroup>
821
+ <optgroup label="🎬 Video Generation">
822
+ <option value="fal-ai/kling-video/v2.6/pro/text-to-video">Kling 2.6 Pro (5s video)</option>
823
+ </optgroup>
824
+ </select>
825
+ <button class="clear-btn" onclick="app.exportChat()">📥 Export</button>
826
+ <button class="clear-btn" onclick="app.showSettings()">⚙️</button>
827
+ <button class="clear-btn" onclick="app.clearChat()">🗑️</button>
828
+ </div>
829
+ </div>
830
+ <div class="messages" id="messages"></div>
831
+ <div id="notification" class="hidden"></div>
832
+ <div class="input-container">
833
+ <div class="input-wrapper">
834
+ <textarea id="input" placeholder="Type your message... (Enter to send, Shift+Enter for new line)" rows="1"></textarea>
835
+ <button class="send-btn" id="sendBtn" onclick="app.sendMessage()">Send</button>
836
+ </div>
837
+ </div>
838
+ </div>
839
+ <div id="settingsOverlay" class="settings-overlay hidden" onclick="if(event.target === this) app.hideSettings()">
840
+ <div class="settings-dialog">
841
+ <h2>⚙️ Settings</h2>
842
+ <div class="form-group">
843
+ <label>Endpoint Type</label>
844
+ <select id="endpointType" onchange="app.toggleEndpointFields()">
845
+ <option value="openrouter">OpenRouter (100+ models)</option>
846
+ <option value="custom">Custom Endpoint (RunPod/Ollama/vLLM/etc)</option>
847
+ </select>
848
+ <small>Choose OpenRouter for easy access or custom for self-hosted models.</small>
849
+ </div>
850
+ <div id="openrouterFields">
851
+ <div class="form-group">
852
+ <label>OpenRouter API Key</label>
853
+ <input type="password" id="apiKeyInput" placeholder="sk-or-..." />
854
+ <small>One key for 100+ models. <a href="https://openrouter.ai/keys" target="_blank">Get one here</a></small>
855
+ </div>
856
+ <div class="form-group">
857
+ <label>fal.ai API Key (Optional - for Z-Image Turbo)</label>
858
+ <input type="password" id="falApiKeyInput" placeholder="FAL_KEY..." />
859
+ <small>Only needed for fal.ai image models. <a href="https://fal.ai/dashboard/keys" target="_blank">Get one here</a></small>
860
+ </div>
861
+ </div>
862
+ <div id="customFields" class="hidden">
863
+ <div class="form-group">
864
+ <label>Custom Endpoint URL</label>
865
+ <input type="text" id="customEndpoint" placeholder="https://your-pod-id.runpod.net/v1" />
866
+ <small>Base URL for your RunPod/vLLM/Ollama endpoint (with /v1 suffix)</small>
867
+ </div>
868
+ <div class="form-group">
869
+ <label>API Key (Optional)</label>
870
+ <input type="password" id="customApiKey" placeholder="Leave empty if not required" />
871
+ <small>Some endpoints don't require authentication</small>
872
+ </div>
873
+ <div class="form-group">
874
+ <label>Model Name</label>
875
+ <input type="text" id="customModel" placeholder="meta-llama/Llama-3-70b-instruct" />
876
+ <small>Model identifier (check your endpoint's docs)</small>
877
+ </div>
878
+ </div>
879
+ <div class="form-group">
880
+ <label>System Prompt (Optional)</label>
881
+ <textarea id="systemPromptInput" placeholder="You are a helpful assistant..."></textarea>
882
+ <small>Set AI behavior and personality.</small>
883
+ </div>
884
+ <div>
885
+ <button class="btn btn-secondary" onclick="app.hideSettings()">Cancel</button>
886
+ <button class="btn btn-primary" onclick="app.saveSettings()">Save</button>
887
+ </div>
888
+ </div>
889
+ </div>
890
+ </div>
891
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
892
+ <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/highlight.min.js"></script>
893
+ <script>
894
+ marked.setOptions({ breaks: true, gfm: true });
895
+
896
+ const app = {
897
+ // Model pricing (per million tokens)
898
+ pricing: {
899
+ // Free models
900
+ 'xiaomi/mimo-v2-flash:free': { input: 0, output: 0 },
901
+ 'google/gemma-2-9b-it:free': { input: 0, output: 0 },
902
+ 'meta-llama/llama-3.1-8b-instruct:free': { input: 0, output: 0 },
903
+ 'microsoft/phi-3-mini-128k-instruct:free': { input: 0, output: 0 },
904
+ 'qwen/qwen-2-7b-instruct:free': { input: 0, output: 0 },
905
+ // Frontier models (open source)
906
+ 'deepseek/deepseek-chat': { input: 0.27, output: 1.10 },
907
+ 'qwen/qwen-2.5-72b-instruct': { input: 0.35, output: 0.70 },
908
+ 'meta-llama/llama-3.3-70b-instruct': { input: 0.35, output: 0.40 },
909
+ 'meta-llama/llama-3.1-405b-instruct': { input: 2.70, output: 2.70 },
910
+ 'cohere/command-r-plus': { input: 3, output: 15 },
911
+ // Paid models
912
+ 'openai/gpt-4-turbo': { input: 10, output: 30 },
913
+ 'openai/gpt-4': { input: 30, output: 60 },
914
+ 'openai/gpt-3.5-turbo': { input: 0.5, output: 1.5 },
915
+ 'anthropic/claude-3.5-sonnet': { input: 3, output: 15 },
916
+ 'anthropic/claude-3-opus': { input: 15, output: 75 },
917
+ 'anthropic/claude-3-sonnet': { input: 3, output: 15 },
918
+ 'anthropic/claude-3-haiku': { input: 0.25, output: 1.25 },
919
+ 'google/gemini-pro-1.5': { input: 3.5, output: 10.5 },
920
+ 'google/gemini-flash-1.5': { input: 0.35, output: 1.05 },
921
+ 'google/gemini-pro': { input: 0.5, output: 1.5 },
922
+ 'meta-llama/llama-3-70b-instruct': { input: 0.7, output: 0.8 },
923
+ 'mistralai/mistral-large': { input: 4, output: 12 },
924
+ 'mistralai/mixtral-8x7b-instruct': { input: 0.5, output: 0.5 },
925
+ 'microsoft/wizardlm-2-8x22b': { input: 1.0, output: 1.0 },
926
+ // Image generation (cost per image, not per token)
927
+ 'black-forest-labs/flux.2-klein-4b': { input: 0.014, output: 0 },
928
+ 'fal-ai/z-image/turbo': { input: 0.005, output: 0 },
929
+ // Video generation (cost per video)
930
+ 'fal-ai/kling-video/v2.6/pro/text-to-video': { input: 0.70, output: 0 }
931
+ },
932
+
933
+ conversations: {},
934
+ currentConversationId: null,
935
+ apiKey: '',
936
+ falApiKey: '',
937
+ systemPrompt: '',
938
+ model: 'anthropic/claude-3.5-sonnet',
939
+ endpointType: 'openrouter',
940
+ customEndpoint: '',
941
+ customApiKey: '',
942
+ customModel: '',
943
+ isLoading: false,
944
+ editingMessageIndex: null,
945
+ language: 'en',
946
+ renderedMessageCount: 0,
947
+ streamingElement: null,
948
+ streamThrottleTimeout: null,
949
+ pendingStreamUpdate: false,
950
+
951
+ init() {
952
+ const saved = localStorage.getItem('conversations');
953
+ if (saved) this.conversations = JSON.parse(saved);
954
+
955
+ // Clean up any empty assistant messages
956
+ Object.values(this.conversations).forEach(conv => {
957
+ conv.messages = conv.messages.filter(m =>
958
+ m.role !== 'assistant' || m.content.trim()
959
+ );
960
+ });
961
+
962
+ this.apiKey = localStorage.getItem('openrouter_api_key') || '';
963
+ this.falApiKey = localStorage.getItem('fal_api_key') || '';
964
+ this.systemPrompt = localStorage.getItem('system_prompt') || '';
965
+ this.model = localStorage.getItem('current_model') || 'anthropic/claude-3.5-sonnet';
966
+ this.endpointType = localStorage.getItem('endpoint_type') || 'openrouter';
967
+ this.customEndpoint = localStorage.getItem('custom_endpoint') || '';
968
+ this.customApiKey = localStorage.getItem('custom_api_key') || '';
969
+ this.customModel = localStorage.getItem('custom_model') || '';
970
+ this.language = localStorage.getItem('language') || 'en';
971
+
972
+ const conversationIds = Object.keys(this.conversations);
973
+ if (conversationIds.length === 0) {
974
+ this.newConversation();
975
+ } else {
976
+ const lastConv = localStorage.getItem('last_conversation');
977
+ this.currentConversationId = lastConv && this.conversations[lastConv]
978
+ ? lastConv : conversationIds[0];
979
+ }
980
+
981
+ // Show settings if no endpoint is configured
982
+ const hasOpenRouter = this.apiKey;
983
+ const hasCustom = this.customEndpoint && this.customModel;
984
+ if (!hasOpenRouter && !hasCustom) {
985
+ this.showSettings();
986
+ }
987
+
988
+ this.renderConversationsList();
989
+ this.render(true); // Initial full render
990
+ this.updateTokenCounter();
991
+
992
+ document.getElementById('input').addEventListener('keypress', (e) => {
993
+ if (e.key === 'Enter' && !e.shiftKey) {
994
+ e.preventDefault();
995
+ this.sendMessage();
996
+ }
997
+ });
998
+
999
+ document.getElementById('modelSelect').addEventListener('change', (e) => {
1000
+ this.model = e.target.value;
1001
+ localStorage.setItem('current_model', this.model);
1002
+ this.updateTokenCounter();
1003
+ });
1004
+
1005
+ document.getElementById('modelSelect').value = this.model;
1006
+ document.getElementById('languageSelect').value = this.language;
1007
+
1008
+ // Update dropdown to show custom model if using custom endpoint
1009
+ this.updateModelDropdown();
1010
+
1011
+ document.addEventListener('keydown', (e) => {
1012
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
1013
+ e.preventDefault();
1014
+ this.clearChat();
1015
+ }
1016
+ if (e.key === 'Escape') {
1017
+ this.hideSettings();
1018
+ }
1019
+ });
1020
+ },
1021
+
1022
+ estimateTokens(text) {
1023
+ return Math.ceil(text.length / 4);
1024
+ },
1025
+
1026
+ calculateCost(tokens, model) {
1027
+ const pricing = this.pricing[model];
1028
+ if (!pricing) return 0;
1029
+ const cost = (tokens / 2) * (pricing.input / 1000000) + (tokens / 2) * (pricing.output / 1000000);
1030
+ return cost;
1031
+ },
1032
+
1033
+ updateTokenCounter() {
1034
+ const conv = this.getCurrentConversation();
1035
+ if (!conv) return;
1036
+
1037
+ let totalTokens = 0;
1038
+ conv.messages.forEach(msg => {
1039
+ totalTokens += this.estimateTokens(msg.content);
1040
+ });
1041
+
1042
+ if (this.systemPrompt) {
1043
+ totalTokens += this.estimateTokens(this.systemPrompt);
1044
+ }
1045
+
1046
+ const cost = this.calculateCost(totalTokens, this.model);
1047
+
1048
+ const counter = document.getElementById('tokenCounter');
1049
+ counter.innerHTML = `
1050
+ <div>🎯 ${totalTokens.toLocaleString()} tokens</div>
1051
+ <div class="token-counter-detail">≈ $${cost.toFixed(4)}</div>
1052
+ `;
1053
+ },
1054
+
1055
+ toggleSidebar() {
1056
+ const sidebar = document.querySelector('.sidebar');
1057
+ const overlay = document.querySelector('.sidebar-overlay');
1058
+ sidebar.classList.toggle('open');
1059
+ overlay.classList.toggle('active');
1060
+ },
1061
+
1062
+ changeLanguage() {
1063
+ this.language = document.getElementById('languageSelect').value;
1064
+ localStorage.setItem('language', this.language);
1065
+ const messages = {
1066
+ en: 'Language changed to English',
1067
+ cs: 'Jazyk změněn na češtinu',
1068
+ fr: 'Langue changée en français',
1069
+ de: 'Sprache auf Deutsch geändert',
1070
+ uk: 'Мову змінено на українську',
1071
+ ru: 'Язык изменён на русский'
1072
+ };
1073
+ this.showNotification(messages[this.language] || messages.en);
1074
+ },
1075
+
1076
+ switchLocalModel(modelName) {
1077
+ this.customModel = modelName;
1078
+ localStorage.setItem('custom_model', modelName);
1079
+ this.updateModelDropdown();
1080
+
1081
+ // Update active button state
1082
+ document.querySelectorAll('.model-switch-btn').forEach(btn => {
1083
+ if (btn.dataset.model === modelName) {
1084
+ btn.classList.add('active');
1085
+ } else {
1086
+ btn.classList.remove('active');
1087
+ }
1088
+ });
1089
+
1090
+ const modelNames = {
1091
+ 'qwen2.5:7b': 'Qwen 2.5 7B',
1092
+ 'dolphin-mistral:7b': 'Dolphin-Mistral 7B'
1093
+ };
1094
+ this.showNotification(`Switched to ${modelNames[modelName] || modelName}`);
1095
+ },
1096
+
1097
+ newConversation() {
1098
+ const id = 'conv_' + Date.now();
1099
+ this.conversations[id] = {
1100
+ id,
1101
+ title: 'New Chat',
1102
+ messages: [],
1103
+ createdAt: Date.now(),
1104
+ updatedAt: Date.now()
1105
+ };
1106
+ this.currentConversationId = id;
1107
+ this.saveConversations();
1108
+ this.renderConversationsList();
1109
+ this.render(true); // Full re-render for new conversation
1110
+ this.updateTokenCounter();
1111
+ },
1112
+
1113
+ switchConversation(id) {
1114
+ this.currentConversationId = id;
1115
+ localStorage.setItem('last_conversation', id);
1116
+ this.renderConversationsList();
1117
+ this.render(true); // Full re-render when switching conversations
1118
+ this.updateTokenCounter();
1119
+ },
1120
+
1121
+ renameConversation() {
1122
+ const conv = this.conversations[this.currentConversationId];
1123
+ const newTitle = prompt('Rename conversation:', conv.title);
1124
+ if (newTitle && newTitle.trim()) {
1125
+ conv.title = newTitle.trim();
1126
+ conv.updatedAt = Date.now();
1127
+ this.saveConversations();
1128
+ this.renderConversationsList();
1129
+ document.getElementById('chatTitle').textContent = conv.title;
1130
+ }
1131
+ },
1132
+
1133
+ deleteConversation(id) {
1134
+ if (!confirm('Delete this conversation?')) return;
1135
+ delete this.conversations[id];
1136
+ const conversationIds = Object.keys(this.conversations);
1137
+ if (conversationIds.length === 0) {
1138
+ this.newConversation();
1139
+ } else if (this.currentConversationId === id) {
1140
+ this.currentConversationId = conversationIds[0];
1141
+ }
1142
+ this.saveConversations();
1143
+ this.renderConversationsList();
1144
+ this.render(true); // Full re-render after deleting conversation
1145
+ this.updateTokenCounter();
1146
+ },
1147
+
1148
+ deleteMessage(index) {
1149
+ if (!confirm('Delete this message?')) return;
1150
+ const conv = this.getCurrentConversation();
1151
+ conv.messages.splice(index, 1);
1152
+ conv.updatedAt = Date.now();
1153
+ this.saveConversations();
1154
+ this.render(true); // Full re-render when deleting
1155
+ this.updateTokenCounter();
1156
+ },
1157
+
1158
+ startEditMessage(index) {
1159
+ this.editingMessageIndex = index;
1160
+ this.render(true); // Full re-render to show edit UI
1161
+ },
1162
+
1163
+ cancelEdit() {
1164
+ this.editingMessageIndex = null;
1165
+ this.render(true); // Full re-render to hide edit UI
1166
+ },
1167
+
1168
+ async saveEdit(index) {
1169
+ const textarea = document.getElementById(`edit-textarea-${index}`);
1170
+ const newContent = textarea.value.trim();
1171
+ if (!newContent) return;
1172
+
1173
+ const conv = this.getCurrentConversation();
1174
+ const msg = conv.messages[index];
1175
+
1176
+ if (msg.role === 'user') {
1177
+ msg.content = newContent;
1178
+ conv.messages = conv.messages.slice(0, index + 1);
1179
+ conv.updatedAt = Date.now();
1180
+ this.editingMessageIndex = null;
1181
+ this.saveConversations();
1182
+ this.render(true); // Full re-render after edit
1183
+ this.updateTokenCounter();
1184
+ await this.sendMessage(true);
1185
+ }
1186
+ },
1187
+
1188
+ async regenerateMessage(index) {
1189
+ const conv = this.getCurrentConversation();
1190
+ conv.messages = conv.messages.slice(0, index);
1191
+ conv.updatedAt = Date.now();
1192
+ this.saveConversations();
1193
+ this.render(true); // Full re-render before regenerating
1194
+ this.updateTokenCounter();
1195
+ await this.sendMessage(true);
1196
+ },
1197
+
1198
+ saveConversations() {
1199
+ localStorage.setItem('conversations', JSON.stringify(this.conversations));
1200
+ },
1201
+
1202
+ renderConversationsList() {
1203
+ const container = document.getElementById('conversationsList');
1204
+ container.innerHTML = '';
1205
+ const sorted = Object.values(this.conversations)
1206
+ .sort((a, b) => b.updatedAt - a.updatedAt);
1207
+
1208
+ sorted.forEach(conv => {
1209
+ const div = document.createElement('div');
1210
+ div.className = 'conversation-item' + (conv.id === this.currentConversationId ? ' active' : '');
1211
+ div.onclick = () => this.switchConversation(conv.id);
1212
+ const date = new Date(conv.updatedAt);
1213
+ const dateStr = date.toLocaleDateString();
1214
+ div.innerHTML = `
1215
+ <div class="conversation-title">${this.escapeHtml(conv.title)}</div>
1216
+ <div class="conversation-date">${dateStr}</div>
1217
+ <button class="delete-conversation" onclick="event.stopPropagation(); app.deleteConversation('${conv.id}')">×</button>
1218
+ `;
1219
+ container.appendChild(div);
1220
+ });
1221
+ },
1222
+
1223
+ getCurrentConversation() {
1224
+ return this.conversations[this.currentConversationId];
1225
+ },
1226
+
1227
+ createMessageElement(msg, index) {
1228
+ const div = document.createElement('div');
1229
+ div.className = `message ${msg.role}`;
1230
+ div.dataset.messageIndex = index;
1231
+
1232
+ const isEditing = this.editingMessageIndex === index;
1233
+
1234
+ let content;
1235
+ if (isEditing) {
1236
+ content = `
1237
+ <div class="edit-area">
1238
+ <textarea id="edit-textarea-${index}" class="edit-textarea">${this.escapeHtml(msg.content).replace(/<br>/g, '\n')}</textarea>
1239
+ <div style="display: flex; flex-direction: column; gap: 0.5rem;">
1240
+ <button class="edit-btn" onclick="app.saveEdit(${index})">Save</button>
1241
+ <button class="edit-btn edit-btn-cancel" onclick="app.cancelEdit()">Cancel</button>
1242
+ </div>
1243
+ </div>
1244
+ `;
1245
+ } else {
1246
+ content = msg.role === 'user'
1247
+ ? this.escapeHtml(msg.content)
1248
+ : this.renderMarkdown(msg.content);
1249
+ }
1250
+
1251
+ let actions = '';
1252
+ if (!isEditing) {
1253
+ if (msg.role === 'user') {
1254
+ actions = `
1255
+ <div class="message-actions">
1256
+ <button class="message-action-btn" onclick="app.startEditMessage(${index})">✏️ Edit</button>
1257
+ <button class="message-action-btn" onclick="app.deleteMessage(${index})">🗑️</button>
1258
+ </div>
1259
+ `;
1260
+ } else {
1261
+ actions = `
1262
+ <div class="message-actions">
1263
+ <button class="message-action-btn" onclick="app.regenerateMessage(${index})">🔄 Regenerate</button>
1264
+ <button class="message-action-btn" onclick="app.deleteMessage(${index})">🗑️</button>
1265
+ </div>
1266
+ `;
1267
+ }
1268
+ }
1269
+
1270
+ div.innerHTML = `
1271
+ ${actions}
1272
+ <div class="message-avatar">${msg.role === 'user' ? '👤' : '🤖'}</div>
1273
+ <div class="message-content">${content}</div>
1274
+ `;
1275
+
1276
+ if (msg.role === 'assistant' && !isEditing) {
1277
+ div.querySelectorAll('pre code').forEach((block) => {
1278
+ const button = document.createElement('button');
1279
+ button.className = 'copy-btn';
1280
+ button.textContent = 'Copy';
1281
+ button.onclick = () => this.copyCode(button, block);
1282
+ block.parentElement.style.position = 'relative';
1283
+ block.parentElement.appendChild(button);
1284
+ });
1285
+ }
1286
+
1287
+ return div;
1288
+ },
1289
+
1290
+ scrollToBottom() {
1291
+ const container = document.getElementById('messages');
1292
+ container.scrollTop = container.scrollHeight;
1293
+ },
1294
+
1295
+ renderMessages(fullRender = false) {
1296
+ const conv = this.getCurrentConversation();
1297
+ if (!conv) return;
1298
+
1299
+ const container = document.getElementById('messages');
1300
+
1301
+ // Full re-render (e.g., when switching conversations or editing)
1302
+ if (fullRender) {
1303
+ container.innerHTML = '';
1304
+ this.renderedMessageCount = 0;
1305
+ this.streamingElement = null;
1306
+ }
1307
+
1308
+ if (conv.messages.length === 0) {
1309
+ container.innerHTML = `
1310
+ <div class="empty-state">
1311
+ <div class="empty-state-icon">💭</div>
1312
+ <h3>Start a conversation</h3>
1313
+ <p style="margin-top: 0.5rem;">Type a message below to begin chatting with AI</p>
1314
+ </div>
1315
+ `;
1316
+ this.renderedMessageCount = 0;
1317
+ return;
1318
+ }
1319
+
1320
+ // Append-only: only render new messages
1321
+ const messages = conv.messages.filter(m => m.role !== 'system' && (m.role !== 'assistant' || m.content.trim()));
1322
+
1323
+ for (let i = this.renderedMessageCount; i < messages.length; i++) {
1324
+ const msgElement = this.createMessageElement(messages[i], i);
1325
+ container.appendChild(msgElement);
1326
+ }
1327
+
1328
+ this.renderedMessageCount = messages.length;
1329
+ this.scrollToBottom();
1330
+ },
1331
+
1332
+ updateStreamingMessage(content) {
1333
+ const container = document.getElementById('messages');
1334
+
1335
+ if (!this.streamingElement) {
1336
+ // Create dedicated streaming element
1337
+ this.streamingElement = document.createElement('div');
1338
+ this.streamingElement.className = 'message assistant';
1339
+ this.streamingElement.id = 'streaming-message';
1340
+ this.streamingElement.innerHTML = `
1341
+ <div class="message-avatar">🤖</div>
1342
+ <div class="message-content"></div>
1343
+ `;
1344
+ container.appendChild(this.streamingElement);
1345
+ }
1346
+
1347
+ // Throttle updates to 50ms
1348
+ if (!this.pendingStreamUpdate) {
1349
+ this.pendingStreamUpdate = true;
1350
+ clearTimeout(this.streamThrottleTimeout);
1351
+
1352
+ this.streamThrottleTimeout = setTimeout(() => {
1353
+ const contentEl = this.streamingElement.querySelector('.message-content');
1354
+ contentEl.innerHTML = this.renderMarkdown(content);
1355
+
1356
+ // Add copy buttons to code blocks
1357
+ contentEl.querySelectorAll('pre code').forEach((block) => {
1358
+ if (!block.parentElement.querySelector('.copy-btn')) {
1359
+ const button = document.createElement('button');
1360
+ button.className = 'copy-btn';
1361
+ button.textContent = 'Copy';
1362
+ button.onclick = () => this.copyCode(button, block);
1363
+ block.parentElement.style.position = 'relative';
1364
+ block.parentElement.appendChild(button);
1365
+ }
1366
+ });
1367
+
1368
+ this.scrollToBottom();
1369
+ this.pendingStreamUpdate = false;
1370
+ }, 50);
1371
+ }
1372
+ },
1373
+
1374
+ finalizeStreamingMessage() {
1375
+ if (this.streamingElement) {
1376
+ this.streamingElement.remove();
1377
+ this.streamingElement = null;
1378
+ }
1379
+ clearTimeout(this.streamThrottleTimeout);
1380
+ this.pendingStreamUpdate = false;
1381
+ },
1382
+
1383
+ render(fullRender = false) {
1384
+ const conv = this.getCurrentConversation();
1385
+ if (!conv) return;
1386
+
1387
+ document.getElementById('chatTitle').textContent = conv.title;
1388
+ this.renderMessages(fullRender);
1389
+ },
1390
+
1391
+ renderMarkdown(text) {
1392
+ const html = marked.parse(text);
1393
+ const temp = document.createElement('div');
1394
+ temp.innerHTML = html;
1395
+ temp.querySelectorAll('pre code').forEach((block) => {
1396
+ hljs.highlightElement(block);
1397
+ });
1398
+ return temp.innerHTML;
1399
+ },
1400
+
1401
+ escapeHtml(text) {
1402
+ const div = document.createElement('div');
1403
+ div.textContent = text;
1404
+ return div.innerHTML.replace(/\n/g, '<br>');
1405
+ },
1406
+
1407
+ copyCode(button, codeBlock) {
1408
+ const code = codeBlock.textContent;
1409
+ navigator.clipboard.writeText(code).then(() => {
1410
+ button.textContent = 'Copied!';
1411
+ button.classList.add('copied');
1412
+ setTimeout(() => {
1413
+ button.textContent = 'Copy';
1414
+ button.classList.remove('copied');
1415
+ }, 2000);
1416
+ });
1417
+ },
1418
+
1419
+ showNotification(msg, type = 'success') {
1420
+ const el = document.getElementById('notification');
1421
+ el.className = type;
1422
+ el.textContent = msg;
1423
+ const timeout = type === 'error' ? 8000 : 3000;
1424
+ setTimeout(() => el.classList.add('hidden'), timeout);
1425
+ },
1426
+
1427
+ updateModelDropdown() {
1428
+ const modelSelect = document.getElementById('modelSelect');
1429
+ const localSwitcher = document.getElementById('localModelSwitcher');
1430
+
1431
+ // Remove any existing custom option
1432
+ const existingCustom = modelSelect.querySelector('#custom-model-option');
1433
+ if (existingCustom) {
1434
+ existingCustom.remove();
1435
+ }
1436
+
1437
+ if (this.endpointType === 'custom' && this.customModel) {
1438
+ // Show local model switcher
1439
+ localSwitcher.style.display = 'flex';
1440
+
1441
+ // Update active button
1442
+ document.querySelectorAll('.model-switch-btn').forEach(btn => {
1443
+ if (btn.dataset.model === this.customModel) {
1444
+ btn.classList.add('active');
1445
+ } else {
1446
+ btn.classList.remove('active');
1447
+ }
1448
+ });
1449
+
1450
+ // Add custom model option at the top
1451
+ const customOption = document.createElement('option');
1452
+ customOption.id = 'custom-model-option';
1453
+ customOption.value = this.customModel;
1454
+ customOption.textContent = `🖥️ ${this.customModel} (Custom)`;
1455
+ modelSelect.insertBefore(customOption, modelSelect.firstChild);
1456
+
1457
+ // Select the custom model
1458
+ modelSelect.value = this.customModel;
1459
+
1460
+ // Disable the dropdown since other options won't work with custom endpoint
1461
+ modelSelect.style.opacity = '0.7';
1462
+ } else {
1463
+ // Hide local model switcher
1464
+ localSwitcher.style.display = 'none';
1465
+
1466
+ // Re-enable dropdown and select the OpenRouter model
1467
+ modelSelect.style.opacity = '1';
1468
+ modelSelect.value = this.model;
1469
+ }
1470
+ },
1471
+
1472
+ toggleEndpointFields() {
1473
+ const endpointType = document.getElementById('endpointType').value;
1474
+ const openrouterFields = document.getElementById('openrouterFields');
1475
+ const customFields = document.getElementById('customFields');
1476
+
1477
+ if (endpointType === 'openrouter') {
1478
+ openrouterFields.classList.remove('hidden');
1479
+ customFields.classList.add('hidden');
1480
+ } else {
1481
+ openrouterFields.classList.add('hidden');
1482
+ customFields.classList.remove('hidden');
1483
+ }
1484
+ },
1485
+
1486
+ showSettings() {
1487
+ document.getElementById('endpointType').value = this.endpointType;
1488
+ document.getElementById('apiKeyInput').value = this.apiKey;
1489
+ document.getElementById('falApiKeyInput').value = this.falApiKey;
1490
+ document.getElementById('customEndpoint').value = this.customEndpoint;
1491
+ document.getElementById('customApiKey').value = this.customApiKey;
1492
+ document.getElementById('customModel').value = this.customModel;
1493
+ document.getElementById('systemPromptInput').value = this.systemPrompt;
1494
+ this.toggleEndpointFields();
1495
+ document.getElementById('settingsOverlay').classList.remove('hidden');
1496
+ },
1497
+
1498
+ hideSettings() {
1499
+ document.getElementById('settingsOverlay').classList.add('hidden');
1500
+ },
1501
+
1502
+ saveSettings() {
1503
+ this.endpointType = document.getElementById('endpointType').value;
1504
+ this.systemPrompt = document.getElementById('systemPromptInput').value.trim();
1505
+
1506
+ if (this.endpointType === 'openrouter') {
1507
+ const key = document.getElementById('apiKeyInput').value.trim();
1508
+ if (!key) {
1509
+ alert('Please enter an OpenRouter API key');
1510
+ return;
1511
+ }
1512
+ this.apiKey = key;
1513
+ localStorage.setItem('openrouter_api_key', key);
1514
+
1515
+ // Save fal.ai key (optional)
1516
+ const falKey = document.getElementById('falApiKeyInput').value.trim();
1517
+ this.falApiKey = falKey;
1518
+ localStorage.setItem('fal_api_key', falKey);
1519
+ } else {
1520
+ this.customEndpoint = document.getElementById('customEndpoint').value.trim();
1521
+ this.customApiKey = document.getElementById('customApiKey').value.trim();
1522
+ this.customModel = document.getElementById('customModel').value.trim();
1523
+
1524
+ if (!this.customEndpoint) {
1525
+ alert('Please enter a custom endpoint URL');
1526
+ return;
1527
+ }
1528
+ if (!this.customModel) {
1529
+ alert('Please enter a model name');
1530
+ return;
1531
+ }
1532
+
1533
+ localStorage.setItem('custom_endpoint', this.customEndpoint);
1534
+ localStorage.setItem('custom_api_key', this.customApiKey);
1535
+ localStorage.setItem('custom_model', this.customModel);
1536
+ }
1537
+
1538
+ localStorage.setItem('endpoint_type', this.endpointType);
1539
+ localStorage.setItem('system_prompt', this.systemPrompt);
1540
+ this.hideSettings();
1541
+ this.showNotification('Settings saved!');
1542
+ this.updateTokenCounter();
1543
+ this.updateModelDropdown();
1544
+ },
1545
+
1546
+ clearChat() {
1547
+ if (confirm('Clear all messages in this conversation?')) {
1548
+ const conv = this.getCurrentConversation();
1549
+ conv.messages = [];
1550
+ conv.updatedAt = Date.now();
1551
+ this.saveConversations();
1552
+ this.renderConversationsList();
1553
+ this.render(true); // Full re-render after clearing
1554
+ this.updateTokenCounter();
1555
+ }
1556
+ },
1557
+
1558
+ exportChat() {
1559
+ const conv = this.getCurrentConversation();
1560
+ if (conv.messages.length === 0) {
1561
+ alert('No messages to export');
1562
+ return;
1563
+ }
1564
+ const choice = prompt('Export as:\n1. Markdown\n2. JSON\n3. Text\n\nEnter 1, 2, or 3:');
1565
+ let content, filename, type;
1566
+ switch(choice) {
1567
+ case '1':
1568
+ content = this.exportAsMarkdown(conv);
1569
+ filename = 'chat-export.md';
1570
+ type = 'text/markdown';
1571
+ break;
1572
+ case '2':
1573
+ content = JSON.stringify(conv, null, 2);
1574
+ filename = 'chat-export.json';
1575
+ type = 'application/json';
1576
+ break;
1577
+ case '3':
1578
+ content = this.exportAsText(conv);
1579
+ filename = 'chat-export.txt';
1580
+ type = 'text/plain';
1581
+ break;
1582
+ default:
1583
+ return;
1584
+ }
1585
+ const blob = new Blob([content], { type });
1586
+ const url = URL.createObjectURL(blob);
1587
+ const a = document.createElement('a');
1588
+ a.href = url;
1589
+ a.download = filename;
1590
+ a.click();
1591
+ URL.revokeObjectURL(url);
1592
+ this.showNotification('Chat exported!');
1593
+ },
1594
+
1595
+ exportAsMarkdown(conv) {
1596
+ let md = `# ${conv.title}\n\n`;
1597
+ md += `**Date:** ${new Date(conv.updatedAt).toLocaleString()}\n`;
1598
+ md += `**Model:** ${this.model}\n\n---\n\n`;
1599
+ conv.messages.forEach(msg => {
1600
+ if (msg.role === 'system') return;
1601
+ const role = msg.role === 'user' ? '**You:**' : '**Assistant:**';
1602
+ md += `${role}\n\n${msg.content}\n\n---\n\n`;
1603
+ });
1604
+ return md;
1605
+ },
1606
+
1607
+ exportAsText(conv) {
1608
+ let txt = `${conv.title}\n`;
1609
+ txt += `Date: ${new Date(conv.updatedAt).toLocaleString()}\n`;
1610
+ txt += `Model: ${this.model}\n\n${'='.repeat(50)}\n\n`;
1611
+ conv.messages.forEach(msg => {
1612
+ if (msg.role === 'system') return;
1613
+ const role = msg.role === 'user' ? 'You' : 'Assistant';
1614
+ txt += `${role}:\n${msg.content}\n\n${'-'.repeat(50)}\n\n`;
1615
+ });
1616
+ return txt;
1617
+ },
1618
+
1619
+ async generateImage() {
1620
+ const conv = this.getCurrentConversation();
1621
+
1622
+ try {
1623
+ // Get the user's prompt (last message)
1624
+ const userMessage = conv.messages[conv.messages.length - 1];
1625
+ const prompt = userMessage.content;
1626
+
1627
+ // OpenRouter uses the same chat completions endpoint with modalities parameter
1628
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
1629
+ method: 'POST',
1630
+ headers: {
1631
+ 'Authorization': `Bearer ${this.apiKey}`,
1632
+ 'Content-Type': 'application/json',
1633
+ 'HTTP-Referer': window.location.href,
1634
+ 'X-Title': 'Chat MVP'
1635
+ },
1636
+ body: JSON.stringify({
1637
+ model: this.model,
1638
+ messages: [{ role: 'user', content: prompt }],
1639
+ modalities: ["image", "text"]
1640
+ })
1641
+ });
1642
+
1643
+ if (!response.ok) {
1644
+ const errorText = await response.text();
1645
+ let errorMsg = 'Image generation failed';
1646
+ try {
1647
+ const error = JSON.parse(errorText);
1648
+ errorMsg = error.error?.message || error.message || errorText;
1649
+ } catch {
1650
+ errorMsg = errorText || 'Unknown error';
1651
+ }
1652
+ throw new Error(errorMsg);
1653
+ }
1654
+
1655
+ const data = await response.json();
1656
+
1657
+ // Images come back in the assistant message
1658
+ const assistantMessage = data.choices?.[0]?.message;
1659
+ const images = assistantMessage?.images;
1660
+ const textContent = assistantMessage?.content || '';
1661
+
1662
+ if (!images || images.length === 0) {
1663
+ throw new Error('No images in response');
1664
+ }
1665
+
1666
+ // Build content with images (images are objects with image_url.url structure)
1667
+ let content = '';
1668
+ images.forEach((imageData, index) => {
1669
+ // Extract the actual data URL from the nested structure
1670
+ const imageUrl = imageData.image_url?.url || imageData.url || imageData;
1671
+ content += `![Generated Image ${index + 1}](${imageUrl})\n\n`;
1672
+ });
1673
+
1674
+ if (textContent) {
1675
+ content += textContent + '\n\n';
1676
+ }
1677
+
1678
+ content += `*Image generated using ${this.model}*`;
1679
+
1680
+ // Add assistant message with images
1681
+ const assistantMsg = {
1682
+ role: 'assistant',
1683
+ content: content
1684
+ };
1685
+ conv.messages.push(assistantMsg);
1686
+
1687
+ conv.updatedAt = Date.now();
1688
+ this.saveConversations();
1689
+ this.renderConversationsList();
1690
+ this.renderMessages();
1691
+ this.updateTokenCounter();
1692
+
1693
+ } catch (err) {
1694
+ this.showNotification(err.message, 'error');
1695
+ // Remove the empty assistant message that was added
1696
+ if (conv.messages.length > 0 && conv.messages[conv.messages.length - 1].role === 'assistant') {
1697
+ conv.messages.pop();
1698
+ }
1699
+ this.saveConversations();
1700
+ } finally {
1701
+ this.isLoading = false;
1702
+ document.getElementById('sendBtn').disabled = false;
1703
+ }
1704
+ },
1705
+
1706
+ async generateImageFal() {
1707
+ const conv = this.getCurrentConversation();
1708
+
1709
+ try {
1710
+ // Check if fal.ai API key is set
1711
+ if (!this.falApiKey) {
1712
+ alert('Please set your fal.ai API key in settings to use Z-Image Turbo');
1713
+ this.showSettings();
1714
+ throw new Error('fal.ai API key required');
1715
+ }
1716
+
1717
+ // Get the user's prompt (last message)
1718
+ const userMessage = conv.messages[conv.messages.length - 1];
1719
+ const prompt = userMessage.content;
1720
+
1721
+ // fal.ai REST API endpoint
1722
+ const response = await fetch('https://fal.run/fal-ai/z-image/turbo', {
1723
+ method: 'POST',
1724
+ headers: {
1725
+ 'Authorization': `Key ${this.falApiKey}`,
1726
+ 'Content-Type': 'application/json'
1727
+ },
1728
+ body: JSON.stringify({
1729
+ prompt: prompt,
1730
+ num_images: 1
1731
+ })
1732
+ });
1733
+
1734
+ if (!response.ok) {
1735
+ const errorText = await response.text();
1736
+ let errorMsg = 'fal.ai image generation failed';
1737
+ try {
1738
+ const error = JSON.parse(errorText);
1739
+ errorMsg = error.error?.message || error.message || errorText;
1740
+ } catch {
1741
+ errorMsg = errorText || 'Unknown error';
1742
+ }
1743
+ throw new Error(errorMsg);
1744
+ }
1745
+
1746
+ const data = await response.json();
1747
+
1748
+ // fal.ai returns images array with url, width, height
1749
+ const images = data.images;
1750
+
1751
+ if (!images || images.length === 0) {
1752
+ throw new Error('No images in response');
1753
+ }
1754
+
1755
+ // Build content with images
1756
+ let content = '';
1757
+ images.forEach((imageData, index) => {
1758
+ content += `![Generated Image ${index + 1}](${imageData.url})\n\n`;
1759
+ });
1760
+
1761
+ content += `*Image generated using ${this.model} (${images[0].width}x${images[0].height})*`;
1762
+
1763
+ // Add assistant message with images
1764
+ const assistantMsg = {
1765
+ role: 'assistant',
1766
+ content: content
1767
+ };
1768
+ conv.messages.push(assistantMsg);
1769
+
1770
+ conv.updatedAt = Date.now();
1771
+ this.saveConversations();
1772
+ this.renderConversationsList();
1773
+ this.renderMessages();
1774
+ this.updateTokenCounter();
1775
+
1776
+ } catch (err) {
1777
+ this.showNotification(err.message, 'error');
1778
+ // Remove the empty assistant message that was added
1779
+ if (conv.messages.length > 0 && conv.messages[conv.messages.length - 1].role === 'assistant') {
1780
+ conv.messages.pop();
1781
+ }
1782
+ this.saveConversations();
1783
+ } finally {
1784
+ this.isLoading = false;
1785
+ document.getElementById('sendBtn').disabled = false;
1786
+ }
1787
+ },
1788
+
1789
+ async generateVideoFal() {
1790
+ const conv = this.getCurrentConversation();
1791
+
1792
+ try {
1793
+ // Check if fal.ai API key is set
1794
+ if (!this.falApiKey) {
1795
+ alert('Please set your fal.ai API key in settings to use Kling video generation');
1796
+ this.showSettings();
1797
+ throw new Error('fal.ai API key required');
1798
+ }
1799
+
1800
+ // Get the user's prompt (last message)
1801
+ const userMessage = conv.messages[conv.messages.length - 1];
1802
+ const prompt = userMessage.content;
1803
+
1804
+ // Submit request to queue
1805
+ this.showNotification('Submitting video generation request...', 'success');
1806
+
1807
+ const submitResponse = await fetch(`https://queue.fal.run/${this.model}`, {
1808
+ method: 'POST',
1809
+ headers: {
1810
+ 'Authorization': `Key ${this.falApiKey}`,
1811
+ 'Content-Type': 'application/json'
1812
+ },
1813
+ body: JSON.stringify({
1814
+ prompt: prompt,
1815
+ duration: "5",
1816
+ aspect_ratio: "16:9",
1817
+ generate_audio: true
1818
+ })
1819
+ });
1820
+
1821
+ if (!submitResponse.ok) {
1822
+ const errorText = await submitResponse.text();
1823
+ throw new Error(`Failed to submit video request: ${errorText}`);
1824
+ }
1825
+
1826
+ const submitData = await submitResponse.json();
1827
+ const requestId = submitData.request_id;
1828
+ const statusUrl = submitData.status_url;
1829
+
1830
+ this.showNotification('Video generation in progress... This may take 30-60 seconds.', 'success');
1831
+
1832
+ // Poll for completion
1833
+ let attempts = 0;
1834
+ const maxAttempts = 120; // 2 minutes max (1 second intervals)
1835
+
1836
+ while (attempts < maxAttempts) {
1837
+ await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
1838
+
1839
+ const statusResponse = await fetch(statusUrl, {
1840
+ headers: {
1841
+ 'Authorization': `Key ${this.falApiKey}`
1842
+ }
1843
+ });
1844
+
1845
+ if (!statusResponse.ok) {
1846
+ throw new Error('Failed to check video generation status');
1847
+ }
1848
+
1849
+ const statusData = await statusResponse.json();
1850
+
1851
+ if (statusData.status === 'COMPLETED') {
1852
+ // Get the result
1853
+ const resultResponse = await fetch(submitData.response_url, {
1854
+ headers: {
1855
+ 'Authorization': `Key ${this.falApiKey}`
1856
+ }
1857
+ });
1858
+
1859
+ if (!resultResponse.ok) {
1860
+ throw new Error('Failed to get video result');
1861
+ }
1862
+
1863
+ const resultData = await resultResponse.json();
1864
+ const videoUrl = resultData.video?.url;
1865
+
1866
+ if (!videoUrl) {
1867
+ throw new Error('No video URL in response');
1868
+ }
1869
+
1870
+ // Build content with video
1871
+ let content = `<video controls>\n <source src="${videoUrl}" type="video/mp4">\n Your browser does not support video playback.\n</video>\n\n`;
1872
+ content += `*5-second video generated using Kling 2.6 Pro with audio*\n\n`;
1873
+ content += `[Download Video](${videoUrl})`;
1874
+
1875
+ // Add assistant message with video
1876
+ const assistantMsg = {
1877
+ role: 'assistant',
1878
+ content: content
1879
+ };
1880
+ conv.messages.push(assistantMsg);
1881
+
1882
+ conv.updatedAt = Date.now();
1883
+ this.saveConversations();
1884
+ this.renderConversationsList();
1885
+ this.renderMessages();
1886
+ this.updateTokenCounter();
1887
+ this.showNotification('Video generated successfully!', 'success');
1888
+ break;
1889
+ } else if (statusData.status === 'FAILED') {
1890
+ throw new Error('Video generation failed');
1891
+ } else {
1892
+ // Still in progress
1893
+ const queuePos = statusData.queue_position !== undefined ? ` (queue position: ${statusData.queue_position})` : '';
1894
+ this.showNotification(`Generating video${queuePos}...`, 'success');
1895
+ }
1896
+
1897
+ attempts++;
1898
+ }
1899
+
1900
+ if (attempts >= maxAttempts) {
1901
+ throw new Error('Video generation timed out');
1902
+ }
1903
+
1904
+ } catch (err) {
1905
+ this.showNotification(err.message, 'error');
1906
+ // Remove the empty assistant message that was added
1907
+ if (conv.messages.length > 0 && conv.messages[conv.messages.length - 1].role === 'assistant') {
1908
+ conv.messages.pop();
1909
+ }
1910
+ this.saveConversations();
1911
+ } finally {
1912
+ this.isLoading = false;
1913
+ document.getElementById('sendBtn').disabled = false;
1914
+ }
1915
+ },
1916
+
1917
+ async sendMessage(isRegeneration = false) {
1918
+ if (this.isLoading) return;
1919
+
1920
+ // Validate settings based on endpoint type
1921
+ if (this.endpointType === 'openrouter' && !this.apiKey) {
1922
+ alert('Please set your OpenRouter API key in settings');
1923
+ this.showSettings();
1924
+ return;
1925
+ } else if (this.endpointType === 'custom' && !this.customEndpoint) {
1926
+ alert('Please configure your custom endpoint in settings');
1927
+ this.showSettings();
1928
+ return;
1929
+ }
1930
+
1931
+ const input = document.getElementById('input');
1932
+ let text;
1933
+
1934
+ if (isRegeneration) {
1935
+ text = null;
1936
+ } else {
1937
+ text = input.value.trim();
1938
+ if (!text) return;
1939
+ }
1940
+
1941
+ const conv = this.getCurrentConversation();
1942
+
1943
+ if (!isRegeneration) {
1944
+ if (conv.messages.length === 0) {
1945
+ conv.title = text.substring(0, 50) + (text.length > 50 ? '...' : '');
1946
+ }
1947
+ conv.messages.push({ role: 'user', content: text });
1948
+ conv.updatedAt = Date.now();
1949
+ input.value = '';
1950
+ }
1951
+
1952
+ this.saveConversations();
1953
+ this.renderConversationsList();
1954
+ this.renderMessages(); // Append new user message
1955
+ this.updateTokenCounter();
1956
+
1957
+ this.isLoading = true;
1958
+ document.getElementById('sendBtn').disabled = true;
1959
+
1960
+ // Check if using image/video generation model
1961
+ const isFluxModel = this.model.startsWith('black-forest-labs/flux');
1962
+ const isFalImageModel = this.model === 'fal-ai/z-image/turbo';
1963
+ const isFalVideoModel = this.model.startsWith('fal-ai/kling-video');
1964
+
1965
+ if (isFluxModel && this.endpointType === 'openrouter') {
1966
+ return this.generateImage();
1967
+ }
1968
+
1969
+ if (isFalImageModel) {
1970
+ return this.generateImageFal();
1971
+ }
1972
+
1973
+ if (isFalVideoModel) {
1974
+ return this.generateVideoFal();
1975
+ }
1976
+
1977
+ try {
1978
+ const apiMessages = [];
1979
+
1980
+ // Build system prompt with language instruction
1981
+ let systemPrompt = this.systemPrompt || '';
1982
+ if (this.language !== 'en') {
1983
+ const langInstructions = {
1984
+ cs: 'You must respond in Czech language (čeština). Always write your responses in Czech.',
1985
+ fr: 'You must respond in French language (français). Always write your responses in French.',
1986
+ de: 'You must respond in German language (Deutsch). Always write your responses in German.',
1987
+ uk: 'You must respond in Ukrainian language (українська). Always write your responses in Ukrainian.',
1988
+ ru: 'You must respond in Russian language (русский). Always write your responses in Russian.'
1989
+ };
1990
+ const langInstruction = langInstructions[this.language];
1991
+ if (langInstruction) {
1992
+ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${langInstruction}` : langInstruction;
1993
+ }
1994
+ }
1995
+
1996
+ if (systemPrompt) {
1997
+ apiMessages.push({ role: 'system', content: systemPrompt });
1998
+ }
1999
+ apiMessages.push(...conv.messages.filter(m => m.role !== 'system'));
2000
+
2001
+ // Build endpoint URL and headers based on endpoint type
2002
+ let endpoint, headers, model;
2003
+
2004
+ if (this.endpointType === 'openrouter') {
2005
+ endpoint = 'https://openrouter.ai/api/v1/chat/completions';
2006
+ headers = {
2007
+ 'Authorization': `Bearer ${this.apiKey}`,
2008
+ 'Content-Type': 'application/json',
2009
+ 'HTTP-Referer': window.location.href,
2010
+ 'X-Title': 'Chat MVP'
2011
+ };
2012
+ model = this.model;
2013
+ } else {
2014
+ // Custom endpoint (RunPod/Ollama/vLLM/etc)
2015
+ endpoint = `${this.customEndpoint}/chat/completions`;
2016
+ headers = {
2017
+ 'Content-Type': 'application/json'
2018
+ };
2019
+ // Only add Authorization header if API key is provided
2020
+ if (this.customApiKey) {
2021
+ headers['Authorization'] = `Bearer ${this.customApiKey}`;
2022
+ }
2023
+ model = this.customModel;
2024
+ }
2025
+
2026
+ const response = await fetch(endpoint, {
2027
+ method: 'POST',
2028
+ headers: headers,
2029
+ body: JSON.stringify({
2030
+ model: model,
2031
+ messages: apiMessages,
2032
+ stream: true
2033
+ })
2034
+ });
2035
+
2036
+ if (!response.ok) {
2037
+ const errorText = await response.text();
2038
+ let errorMsg = 'API request failed';
2039
+ try {
2040
+ const error = JSON.parse(errorText);
2041
+ errorMsg = error.error?.message || error.message || errorText;
2042
+ } catch {
2043
+ errorMsg = errorText || 'Unknown error';
2044
+ }
2045
+ throw new Error(errorMsg);
2046
+ }
2047
+
2048
+ const reader = response.body.getReader();
2049
+ const decoder = new TextDecoder();
2050
+ let assistantMsg = { role: 'assistant', content: '' };
2051
+ conv.messages.push(assistantMsg);
2052
+
2053
+ while (true) {
2054
+ const { done, value } = await reader.read();
2055
+ if (done) break;
2056
+
2057
+ const chunk = decoder.decode(value);
2058
+ const lines = chunk.split('\n').filter(l => l.trim());
2059
+
2060
+ for (const line of lines) {
2061
+ if (line.startsWith('data: ')) {
2062
+ const data = line.slice(6);
2063
+ if (data === '[DONE]') continue;
2064
+
2065
+ try {
2066
+ const json = JSON.parse(data);
2067
+ const delta = json.choices?.[0]?.delta?.content;
2068
+ if (delta) {
2069
+ assistantMsg.content += delta;
2070
+ this.updateStreamingMessage(assistantMsg.content);
2071
+ }
2072
+ } catch (e) {}
2073
+ }
2074
+ }
2075
+ }
2076
+
2077
+ // Finalize streaming and render the complete message
2078
+ this.finalizeStreamingMessage();
2079
+ this.renderMessages();
2080
+
2081
+ conv.updatedAt = Date.now();
2082
+ this.saveConversations();
2083
+ this.renderConversationsList();
2084
+ this.updateTokenCounter();
2085
+
2086
+ } catch (err) {
2087
+ this.showNotification(err.message, 'error');
2088
+ // Remove the empty assistant message that was added
2089
+ if (conv.messages.length > 0 && conv.messages[conv.messages.length - 1].role === 'assistant') {
2090
+ conv.messages.pop();
2091
+ }
2092
+ this.saveConversations();
2093
+ } finally {
2094
+ this.isLoading = false;
2095
+ document.getElementById('sendBtn').disabled = false;
2096
+ this.finalizeStreamingMessage(); // Clean up streaming element
2097
+ }
2098
+ }
2099
+ };
2100
+
2101
+ // Fix for mobile viewport height (address bar issue)
2102
+ function setVH() {
2103
+ const vh = window.innerHeight * 0.01;
2104
+ document.documentElement.style.setProperty('--vh', `${vh}px`);
2105
+ }
2106
+ setVH();
2107
+ window.addEventListener('resize', setVH);
2108
+ window.addEventListener('orientationchange', setVH);
2109
+
2110
+ document.addEventListener('DOMContentLoaded', () => app.init());
2111
+ </script>
2112
+ </body>
2113
+ </html>
2114
+