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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/melchat.js +114 -0
- package/dist/index.html +2114 -0
- package/package.json +55 -0
package/dist/index.html
ADDED
|
@@ -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 += `\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 += `\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
|
+
|