gijl commited on
Commit
5ab4e79
·
verified ·
1 Parent(s): 839d4e5

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +1093 -0
index.html ADDED
@@ -0,0 +1,1093 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ar" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Gemma Vision Chat</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
9
+ <style>
10
+ :root {
11
+ --bg: #0d0d0f;
12
+ --surface: #141416;
13
+ --surface2: #1c1c20;
14
+ --surface3: #242428;
15
+ --border: #2a2a30;
16
+ --border2: #35353d;
17
+ --text: #e8e8ec;
18
+ --text2: #9898a8;
19
+ --text3: #5a5a6a;
20
+ --accent: #7c6af7;
21
+ --accent2: #9d8fff;
22
+ --accent-glow: rgba(124, 106, 247, 0.15);
23
+ --user-bg: #1a1830;
24
+ --user-border: #3d3580;
25
+ --think-bg: #0f1a1a;
26
+ --think-border: #1a3a3a;
27
+ --think-text: #4a9e9e;
28
+ --error: #e05555;
29
+ --success: #4aaa7a;
30
+ --radius: 14px;
31
+ --radius-sm: 8px;
32
+ --mono: 'IBM Plex Mono', monospace;
33
+ }
34
+
35
+ * { box-sizing: border-box; margin: 0; padding: 0; }
36
+
37
+ body {
38
+ font-family: 'IBM Plex Sans Arabic', system-ui, sans-serif;
39
+ background: var(--bg);
40
+ color: var(--text);
41
+ height: 100dvh;
42
+ display: flex;
43
+ flex-direction: column;
44
+ overflow: hidden;
45
+ }
46
+
47
+ /* ── HEADER ── */
48
+ header {
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: space-between;
52
+ padding: 14px 20px;
53
+ border-bottom: 1px solid var(--border);
54
+ background: var(--surface);
55
+ flex-shrink: 0;
56
+ gap: 12px;
57
+ }
58
+
59
+ .header-left {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 12px;
63
+ }
64
+
65
+ .logo {
66
+ width: 34px; height: 34px;
67
+ background: linear-gradient(135deg, var(--accent), #4f8af7);
68
+ border-radius: 10px;
69
+ display: grid; place-items: center;
70
+ font-size: 16px;
71
+ flex-shrink: 0;
72
+ }
73
+
74
+ .header-title h1 {
75
+ font-size: 15px;
76
+ font-weight: 600;
77
+ color: var(--text);
78
+ letter-spacing: -0.2px;
79
+ }
80
+
81
+ .header-title p {
82
+ font-size: 12px;
83
+ color: var(--text3);
84
+ margin-top: 1px;
85
+ }
86
+
87
+ .header-controls {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 10px;
91
+ }
92
+
93
+ .toggle-wrap {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 8px;
97
+ background: var(--surface2);
98
+ border: 1px solid var(--border);
99
+ padding: 6px 12px;
100
+ border-radius: 20px;
101
+ cursor: pointer;
102
+ user-select: none;
103
+ transition: border-color .2s;
104
+ }
105
+
106
+ .toggle-wrap:hover { border-color: var(--border2); }
107
+
108
+ .toggle-wrap span {
109
+ font-size: 12px;
110
+ color: var(--text2);
111
+ white-space: nowrap;
112
+ }
113
+
114
+ .toggle {
115
+ width: 32px; height: 18px;
116
+ background: var(--surface3);
117
+ border-radius: 9px;
118
+ position: relative;
119
+ transition: background .25s;
120
+ flex-shrink: 0;
121
+ }
122
+
123
+ .toggle.on { background: var(--accent); }
124
+
125
+ .toggle::after {
126
+ content: '';
127
+ position: absolute;
128
+ width: 12px; height: 12px;
129
+ background: white;
130
+ border-radius: 50%;
131
+ top: 3px;
132
+ right: 3px;
133
+ transition: right .25s;
134
+ }
135
+
136
+ .toggle.on::after { right: calc(100% - 15px); }
137
+
138
+ .btn-icon {
139
+ width: 34px; height: 34px;
140
+ display: grid; place-items: center;
141
+ background: var(--surface2);
142
+ border: 1px solid var(--border);
143
+ border-radius: var(--radius-sm);
144
+ cursor: pointer;
145
+ color: var(--text2);
146
+ font-size: 16px;
147
+ transition: all .2s;
148
+ }
149
+ .btn-icon:hover { background: var(--surface3); color: var(--text); }
150
+
151
+ /* ── MESSAGES ── */
152
+ #messages {
153
+ flex: 1;
154
+ overflow-y: auto;
155
+ padding: 24px 0;
156
+ scroll-behavior: smooth;
157
+ }
158
+
159
+ #messages::-webkit-scrollbar { width: 5px; }
160
+ #messages::-webkit-scrollbar-track { background: transparent; }
161
+ #messages::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
162
+
163
+ .msg-wrap {
164
+ max-width: 780px;
165
+ margin: 0 auto 6px;
166
+ padding: 0 20px;
167
+ animation: fadeUp .25s ease both;
168
+ }
169
+
170
+ @keyframes fadeUp {
171
+ from { opacity: 0; transform: translateY(10px); }
172
+ to { opacity: 1; transform: translateY(0); }
173
+ }
174
+
175
+ .msg {
176
+ padding: 14px 18px;
177
+ border-radius: var(--radius);
178
+ line-height: 1.7;
179
+ font-size: 14.5px;
180
+ position: relative;
181
+ }
182
+
183
+ .msg.user {
184
+ background: var(--user-bg);
185
+ border: 1px solid var(--user-border);
186
+ margin-right: 40px;
187
+ text-align: right;
188
+ }
189
+
190
+ .msg.assistant {
191
+ background: var(--surface);
192
+ border: 1px solid var(--border);
193
+ margin-left: 40px;
194
+ text-align: right;
195
+ }
196
+
197
+ .msg.assistant.streaming::after {
198
+ content: '▋';
199
+ color: var(--accent);
200
+ animation: blink .7s step-end infinite;
201
+ }
202
+
203
+ @keyframes blink { 50% { opacity: 0; } }
204
+
205
+ .msg-role {
206
+ font-size: 11px;
207
+ font-weight: 600;
208
+ text-transform: uppercase;
209
+ letter-spacing: .8px;
210
+ margin-bottom: 8px;
211
+ }
212
+
213
+ .msg.user .msg-role { color: var(--accent2); }
214
+ .msg.assistant .msg-role { color: var(--text3); }
215
+
216
+ /* attached image preview inside message */
217
+ .msg-image {
218
+ max-width: 260px;
219
+ max-height: 200px;
220
+ border-radius: 8px;
221
+ margin-bottom: 10px;
222
+ border: 1px solid var(--border2);
223
+ object-fit: cover;
224
+ display: block;
225
+ margin-right: auto;
226
+ }
227
+
228
+ /* ── THINKING BLOCK ── */
229
+ .think-wrap {
230
+ margin-bottom: 10px;
231
+ }
232
+
233
+ .think-toggle-btn {
234
+ display: inline-flex;
235
+ align-items: center;
236
+ gap: 6px;
237
+ background: var(--think-bg);
238
+ border: 1px solid var(--think-border);
239
+ color: var(--think-text);
240
+ border-radius: 20px;
241
+ padding: 4px 12px;
242
+ font-size: 12px;
243
+ cursor: pointer;
244
+ transition: all .2s;
245
+ font-family: inherit;
246
+ }
247
+
248
+ .think-toggle-btn:hover { background: #152525; }
249
+
250
+ .think-toggle-btn .arrow {
251
+ font-size: 10px;
252
+ transition: transform .2s;
253
+ display: inline-block;
254
+ }
255
+
256
+ .think-toggle-btn.open .arrow { transform: rotate(90deg); }
257
+
258
+ .think-content {
259
+ margin-top: 8px;
260
+ padding: 12px 14px;
261
+ background: var(--think-bg);
262
+ border: 1px solid var(--think-border);
263
+ border-radius: var(--radius-sm);
264
+ font-size: 13px;
265
+ color: var(--think-text);
266
+ font-family: var(--mono);
267
+ line-height: 1.65;
268
+ white-space: pre-wrap;
269
+ display: none;
270
+ direction: ltr;
271
+ text-align: left;
272
+ }
273
+
274
+ .think-content.visible { display: block; }
275
+
276
+ /* ── EMPTY STATE ── */
277
+ .empty-state {
278
+ display: flex;
279
+ flex-direction: column;
280
+ align-items: center;
281
+ justify-content: center;
282
+ height: 100%;
283
+ gap: 14px;
284
+ color: var(--text3);
285
+ padding: 40px 20px;
286
+ }
287
+
288
+ .empty-icon {
289
+ font-size: 42px;
290
+ opacity: .5;
291
+ }
292
+
293
+ .empty-state h2 {
294
+ font-size: 18px;
295
+ color: var(--text2);
296
+ font-weight: 500;
297
+ }
298
+
299
+ .empty-state p {
300
+ font-size: 13px;
301
+ text-align: center;
302
+ max-width: 360px;
303
+ line-height: 1.6;
304
+ }
305
+
306
+ .suggestions {
307
+ display: flex;
308
+ flex-wrap: wrap;
309
+ gap: 8px;
310
+ justify-content: center;
311
+ margin-top: 4px;
312
+ }
313
+
314
+ .suggestion-chip {
315
+ background: var(--surface2);
316
+ border: 1px solid var(--border);
317
+ padding: 8px 14px;
318
+ border-radius: 20px;
319
+ font-size: 12.5px;
320
+ color: var(--text2);
321
+ cursor: pointer;
322
+ transition: all .2s;
323
+ }
324
+
325
+ .suggestion-chip:hover { border-color: var(--accent); color: var(--accent2); }
326
+
327
+ /* ── INPUT AREA ── */
328
+ #input-area {
329
+ border-top: 1px solid var(--border);
330
+ background: var(--surface);
331
+ padding: 14px 20px 20px;
332
+ flex-shrink: 0;
333
+ }
334
+
335
+ .input-container {
336
+ max-width: 780px;
337
+ margin: 0 auto;
338
+ display: flex;
339
+ flex-direction: column;
340
+ gap: 10px;
341
+ }
342
+
343
+ /* file preview */
344
+ #file-preview {
345
+ display: none;
346
+ align-items: center;
347
+ gap: 10px;
348
+ background: var(--surface2);
349
+ border: 1px solid var(--border);
350
+ border-radius: var(--radius-sm);
351
+ padding: 8px 12px;
352
+ font-size: 13px;
353
+ color: var(--text2);
354
+ }
355
+
356
+ #file-preview.active { display: flex; }
357
+
358
+ #file-preview img {
359
+ width: 40px; height: 40px;
360
+ object-fit: cover;
361
+ border-radius: 6px;
362
+ border: 1px solid var(--border2);
363
+ }
364
+
365
+ #file-preview .file-info { flex: 1; }
366
+ #file-preview .file-name { font-size: 13px; color: var(--text); font-weight: 500; }
367
+ #file-preview .file-size { font-size: 11px; color: var(--text3); margin-top: 2px; }
368
+
369
+ .remove-file {
370
+ width: 22px; height: 22px;
371
+ display: grid; place-items: center;
372
+ background: var(--surface3);
373
+ border-radius: 50%;
374
+ cursor: pointer;
375
+ color: var(--text3);
376
+ font-size: 12px;
377
+ transition: all .2s;
378
+ flex-shrink: 0;
379
+ }
380
+ .remove-file:hover { background: var(--error); color: white; }
381
+
382
+ /* input row */
383
+ .input-row {
384
+ display: flex;
385
+ align-items: flex-end;
386
+ gap: 8px;
387
+ background: var(--surface2);
388
+ border: 1px solid var(--border);
389
+ border-radius: var(--radius);
390
+ padding: 10px 12px;
391
+ transition: border-color .2s;
392
+ }
393
+
394
+ .input-row:focus-within { border-color: var(--accent); }
395
+
396
+ #upload-btn {
397
+ width: 34px; height: 34px;
398
+ display: grid; place-items: center;
399
+ background: transparent;
400
+ border: none;
401
+ cursor: pointer;
402
+ color: var(--text3);
403
+ font-size: 18px;
404
+ border-radius: 8px;
405
+ transition: all .2s;
406
+ flex-shrink: 0;
407
+ }
408
+ #upload-btn:hover { background: var(--surface3); color: var(--accent2); }
409
+
410
+ #text-input {
411
+ flex: 1;
412
+ background: transparent;
413
+ border: none;
414
+ outline: none;
415
+ color: var(--text);
416
+ font-family: 'IBM Plex Sans Arabic', system-ui, sans-serif;
417
+ font-size: 14.5px;
418
+ resize: none;
419
+ min-height: 34px;
420
+ max-height: 200px;
421
+ line-height: 1.6;
422
+ padding: 6px 4px;
423
+ direction: auto;
424
+ }
425
+
426
+ #text-input::placeholder { color: var(--text3); }
427
+
428
+ #send-btn {
429
+ width: 36px; height: 36px;
430
+ display: grid; place-items: center;
431
+ background: var(--accent);
432
+ border: none;
433
+ border-radius: 10px;
434
+ cursor: pointer;
435
+ color: white;
436
+ font-size: 16px;
437
+ transition: all .2s;
438
+ flex-shrink: 0;
439
+ box-shadow: 0 0 0 0 var(--accent-glow);
440
+ }
441
+
442
+ #send-btn:hover:not(:disabled) {
443
+ background: var(--accent2);
444
+ box-shadow: 0 0 0 6px var(--accent-glow);
445
+ }
446
+
447
+ #send-btn:disabled { background: var(--surface3); color: var(--text3); cursor: not-allowed; }
448
+
449
+ #send-btn.stop-mode { background: var(--error); }
450
+ #send-btn.stop-mode:hover:not(:disabled) { background: #f07070; }
451
+
452
+ .input-hint {
453
+ font-size: 11.5px;
454
+ color: var(--text3);
455
+ text-align: center;
456
+ }
457
+
458
+ /* ── COPY BTN ── */
459
+ .copy-btn {
460
+ position: absolute;
461
+ top: 10px;
462
+ left: 10px;
463
+ opacity: 0;
464
+ background: var(--surface3);
465
+ border: 1px solid var(--border2);
466
+ border-radius: 6px;
467
+ padding: 4px 8px;
468
+ font-size: 11px;
469
+ color: var(--text2);
470
+ cursor: pointer;
471
+ transition: all .2s;
472
+ }
473
+ .msg:hover .copy-btn { opacity: 1; }
474
+ .copy-btn:hover { color: var(--text); border-color: var(--accent); }
475
+
476
+ /* ── pre / code ── */
477
+ pre {
478
+ background: var(--bg);
479
+ border: 1px solid var(--border2);
480
+ border-radius: var(--radius-sm);
481
+ padding: 12px 14px;
482
+ font-family: var(--mono);
483
+ font-size: 13px;
484
+ overflow-x: auto;
485
+ margin: 8px 0;
486
+ direction: ltr;
487
+ text-align: left;
488
+ }
489
+
490
+ code {
491
+ font-family: var(--mono);
492
+ background: var(--surface3);
493
+ padding: 1px 5px;
494
+ border-radius: 4px;
495
+ font-size: .9em;
496
+ }
497
+
498
+ pre code { background: none; padding: 0; }
499
+
500
+ /* ── SCROLLBAR ── */
501
+ html { scrollbar-width: thin; scrollbar-color: var(--border2) transparent; }
502
+ </style>
503
+ </head>
504
+ <body>
505
+
506
+ <!-- HEADER -->
507
+ <header>
508
+ <div class="header-left">
509
+ <div class="logo">🔮</div>
510
+ <div class="header-title">
511
+ <h1>Gemma Vision Chat</h1>
512
+ <p id="model-label">نموذج multimodal محلي</p>
513
+ </div>
514
+ </div>
515
+
516
+ <div class="header-controls">
517
+ <div class="toggle-wrap" id="think-toggle-wrap" title="عرض التفكير">
518
+ <span>التفكير</span>
519
+ <div class="toggle" id="think-toggle"></div>
520
+ </div>
521
+ <div class="btn-icon" id="clear-btn" title="محادثة جديدة">🗑️</div>
522
+ </div>
523
+ </header>
524
+
525
+ <!-- MESSAGES -->
526
+ <div id="messages">
527
+ <div class="empty-state" id="empty-state">
528
+ <div class="empty-icon">🤖</div>
529
+ <h2>كيف يمكنني مساعدتك؟</h2>
530
+ <p>يمكنك إرسال نص أو رفع صورة لتحليلها. النموذج يعمل محلياً على جهازك.</p>
531
+ <div class="suggestions">
532
+ <div class="suggestion-chip" onclick="useSuggestion('صف هذه الصورة')">📸 صف الصورة</div>
533
+ <div class="suggestion-chip" onclick="useSuggestion('ما النص الموجود في الصورة؟')">🔤 استخراج نص</div>
534
+ <div class="suggestion-chip" onclick="useSuggestion('حلل المحتوى بالتفصيل')">🔍 تحليل مفصل</div>
535
+ <div class="suggestion-chip" onclick="useSuggestion('ما المشاعر التي تنقلها هذه الصورة؟')">🎨 تحليل مشاعر</div>
536
+ </div>
537
+ </div>
538
+ </div>
539
+
540
+ <!-- INPUT AREA -->
541
+ <div id="input-area">
542
+ <div class="input-container">
543
+
544
+ <!-- file preview -->
545
+ <div id="file-preview">
546
+ <img id="preview-img" src="" alt="">
547
+ <div class="file-info">
548
+ <div class="file-name" id="file-name-text">—</div>
549
+ <div class="file-size" id="file-size-text">—</div>
550
+ </div>
551
+ <div class="remove-file" onclick="removeFile()">✕</div>
552
+ </div>
553
+
554
+ <!-- input row -->
555
+ <div class="input-row">
556
+ <button id="upload-btn" onclick="document.getElementById('file-input').click()" title="إرفاق صورة">📎</button>
557
+ <input type="file" id="file-input" accept="image/*" style="display:none" onchange="handleFile(this)">
558
+ <textarea id="text-input" placeholder="اكتب رسالتك..." rows="1" onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
559
+ <button id="send-btn" onclick="handleSend()" title="إرسال">➤</button>
560
+ </div>
561
+
562
+ <div class="input-hint">Shift+Enter للسطر الجديد · Enter للإرسال</div>
563
+ </div>
564
+ </div>
565
+
566
+ <!-- hidden file input -->
567
+ <input type="file" id="file-input-hidden" accept="image/*" style="display:none">
568
+
569
+ <script>
570
+ // ── STATE ──
571
+ let messages = [];
572
+ let currentFile = null;
573
+ let isStreaming = false;
574
+ let abortController = null;
575
+ let showThinking = false;
576
+
577
+ // ── THINKING TOGGLE ──
578
+ document.getElementById('think-toggle-wrap').addEventListener('click', () => {
579
+ showThinking = !showThinking;
580
+ document.getElementById('think-toggle').classList.toggle('on', showThinking);
581
+ });
582
+
583
+ // ── CLEAR ──
584
+ document.getElementById('clear-btn').addEventListener('click', () => {
585
+ if (isStreaming) return;
586
+ messages = [];
587
+ document.getElementById('messages').innerHTML = '';
588
+ document.getElementById('messages').appendChild(emptyState());
589
+ });
590
+
591
+ function emptyState() {
592
+ const div = document.createElement('div');
593
+ div.id = 'empty-state';
594
+ div.className = 'empty-state';
595
+ div.innerHTML = `
596
+ <div class="empty-icon">🤖</div>
597
+ <h2>كيف يمكنني مساعدتك؟</h2>
598
+ <p>يمكنك إرسال نص أو رفع صورة لتحليلها. النموذج يعمل محلياً على جهازك.</p>
599
+ <div class="suggestions">
600
+ <div class="suggestion-chip" onclick="useSuggestion('صف هذه الصورة')">📸 صف الصورة</div>
601
+ <div class="suggestion-chip" onclick="useSuggestion('ما النص الموجود في الصورة؟')">🔤 استخراج نص</div>
602
+ <div class="suggestion-chip" onclick="useSuggestion('حلل المحتوى بالتفصيل')">🔍 تحليل مفصل</div>
603
+ <div class="suggestion-chip" onclick="useSuggestion('ما المشاعر التي تنقلها هذه الصورة؟')">🎨 تحليل مشاعر</div>
604
+ </div>`;
605
+ return div;
606
+ }
607
+
608
+ // ── FILE HANDLING ──
609
+ function handleFile(input) {
610
+ const file = input.files[0];
611
+ if (!file) return;
612
+ currentFile = file;
613
+
614
+ const reader = new FileReader();
615
+ reader.onload = (e) => {
616
+ document.getElementById('preview-img').src = e.target.result;
617
+ };
618
+ reader.readAsDataURL(file);
619
+
620
+ document.getElementById('file-name-text').textContent = file.name;
621
+ document.getElementById('file-size-text').textContent = formatSize(file.size);
622
+ document.getElementById('file-preview').classList.add('active');
623
+ input.value = '';
624
+ }
625
+
626
+ function removeFile() {
627
+ currentFile = null;
628
+ document.getElementById('file-preview').classList.remove('active');
629
+ document.getElementById('preview-img').src = '';
630
+ }
631
+
632
+ function formatSize(bytes) {
633
+ if (bytes < 1024) return bytes + ' B';
634
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
635
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
636
+ }
637
+
638
+ // ── SUGGESTIONS ──
639
+ function useSuggestion(text) {
640
+ document.getElementById('text-input').value = text;
641
+ autoResize(document.getElementById('text-input'));
642
+ document.getElementById('text-input').focus();
643
+ }
644
+
645
+ // ── TEXTAREA AUTO-RESIZE ──
646
+ function autoResize(el) {
647
+ el.style.height = 'auto';
648
+ el.style.height = Math.min(el.scrollHeight, 200) + 'px';
649
+ }
650
+
651
+ // ── KEY HANDLER ──
652
+ function handleKey(e) {
653
+ if (e.key === 'Enter' && !e.shiftKey) {
654
+ e.preventDefault();
655
+ handleSend();
656
+ }
657
+ }
658
+
659
+ // ── RENDER MARKDOWN (basic) ──
660
+ function renderMarkdown(text) {
661
+ return text
662
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
663
+ .replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
664
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
665
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
666
+ .replace(/\*(.*?)\*/g, '<em>$1</em>')
667
+ .replace(/\n/g, '<br>');
668
+ }
669
+
670
+ // ── ADD MESSAGE TO DOM ──
671
+ function addMessage(role, content, imageDataUrl, thinkingText) {
672
+ const empty = document.getElementById('empty-state');
673
+ if (empty) empty.remove();
674
+
675
+ const wrap = document.createElement('div');
676
+ wrap.className = 'msg-wrap';
677
+
678
+ const msg = document.createElement('div');
679
+ msg.className = `msg ${role}`;
680
+
681
+ const roleLabel = document.createElement('div');
682
+ roleLabel.className = 'msg-role';
683
+ roleLabel.textContent = role === 'user' ? '👤 أنت' : '🔮 النموذج';
684
+ msg.appendChild(roleLabel);
685
+
686
+ // image preview
687
+ if (imageDataUrl) {
688
+ const img = document.createElement('img');
689
+ img.className = 'msg-image';
690
+ img.src = imageDataUrl;
691
+ msg.appendChild(img);
692
+ }
693
+
694
+ // thinking block
695
+ if (thinkingText && showThinking) {
696
+ const thinkWrap = document.createElement('div');
697
+ thinkWrap.className = 'think-wrap';
698
+
699
+ const btn = document.createElement('button');
700
+ btn.className = 'think-toggle-btn';
701
+ btn.innerHTML = `<span class="arrow">▶</span> تفكير النموذج`;
702
+ btn.onclick = () => {
703
+ btn.classList.toggle('open');
704
+ content_el.classList.toggle('visible');
705
+ };
706
+
707
+ const content_el = document.createElement('div');
708
+ content_el.className = 'think-content';
709
+ content_el.textContent = thinkingText;
710
+
711
+ thinkWrap.appendChild(btn);
712
+ thinkWrap.appendChild(content_el);
713
+ msg.appendChild(thinkWrap);
714
+ }
715
+
716
+ // main content
717
+ const contentDiv = document.createElement('div');
718
+ contentDiv.className = 'msg-content';
719
+ contentDiv.innerHTML = renderMarkdown(content);
720
+ msg.appendChild(contentDiv);
721
+
722
+ // copy btn (assistant only)
723
+ if (role === 'assistant') {
724
+ const copyBtn = document.createElement('button');
725
+ copyBtn.className = 'copy-btn';
726
+ copyBtn.textContent = 'نسخ';
727
+ copyBtn.onclick = () => {
728
+ navigator.clipboard.writeText(content).then(() => {
729
+ copyBtn.textContent = '✓';
730
+ setTimeout(() => copyBtn.textContent = 'نسخ', 1500);
731
+ });
732
+ };
733
+ msg.appendChild(copyBtn);
734
+ }
735
+
736
+ wrap.appendChild(msg);
737
+ document.getElementById('messages').appendChild(wrap);
738
+ scrollToBottom();
739
+
740
+ return { wrap, msg, contentDiv };
741
+ }
742
+
743
+ // ── STREAMING MESSAGE ──
744
+ function addStreamingMessage() {
745
+ const empty = document.getElementById('empty-state');
746
+ if (empty) empty.remove();
747
+
748
+ const wrap = document.createElement('div');
749
+ wrap.className = 'msg-wrap';
750
+
751
+ const msg = document.createElement('div');
752
+ msg.className = 'msg assistant streaming';
753
+
754
+ const roleLabel = document.createElement('div');
755
+ roleLabel.className = 'msg-role';
756
+ roleLabel.textContent = '🔮 النموذج';
757
+ msg.appendChild(roleLabel);
758
+
759
+ // thinking section (will be populated during stream)
760
+ const thinkWrap = document.createElement('div');
761
+ thinkWrap.className = 'think-wrap';
762
+ thinkWrap.style.display = 'none';
763
+
764
+ const thinkBtn = document.createElement('button');
765
+ thinkBtn.className = 'think-toggle-btn';
766
+ thinkBtn.innerHTML = `<span class="arrow">▶</span> تفكير النموذج <span class="think-status" style="opacity:.6;font-size:11px;">...</span>`;
767
+
768
+ const thinkContent = document.createElement('div');
769
+ thinkContent.className = 'think-content';
770
+
771
+ thinkBtn.onclick = () => {
772
+ thinkBtn.classList.toggle('open');
773
+ thinkContent.classList.toggle('visible');
774
+ };
775
+
776
+ thinkWrap.appendChild(thinkBtn);
777
+ thinkWrap.appendChild(thinkContent);
778
+ msg.appendChild(thinkWrap);
779
+
780
+ const contentDiv = document.createElement('div');
781
+ contentDiv.className = 'msg-content';
782
+ msg.appendChild(contentDiv);
783
+
784
+ wrap.appendChild(msg);
785
+ document.getElementById('messages').appendChild(wrap);
786
+ scrollToBottom();
787
+
788
+ return {
789
+ wrap, msg, contentDiv, thinkWrap, thinkContent, thinkBtn,
790
+ thinkStatus: thinkBtn.querySelector('.think-status'),
791
+ finalize(fullText, thinkingText) {
792
+ msg.classList.remove('streaming');
793
+ contentDiv.innerHTML = renderMarkdown(fullText);
794
+
795
+ if (thinkingText && showThinking) {
796
+ thinkContent.textContent = thinkingText;
797
+ thinkWrap.style.display = 'block';
798
+ thinkStatus.textContent = `(${thinkingText.split(' ').length} كلمة)`;
799
+ }
800
+
801
+ // copy btn
802
+ const copyBtn = document.createElement('button');
803
+ copyBtn.className = 'copy-btn';
804
+ copyBtn.textContent = 'نسخ';
805
+ copyBtn.onclick = () => {
806
+ navigator.clipboard.writeText(fullText).then(() => {
807
+ copyBtn.textContent = '✓';
808
+ setTimeout(() => copyBtn.textContent = 'نسخ', 1500);
809
+ });
810
+ };
811
+ msg.appendChild(copyBtn);
812
+ }
813
+ };
814
+ }
815
+
816
+ // ── SCROLL ──
817
+ function scrollToBottom() {
818
+ const msgs = document.getElementById('messages');
819
+ msgs.scrollTo({ top: msgs.scrollHeight, behavior: 'smooth' });
820
+ }
821
+
822
+ // ── SEND ──
823
+ async function handleSend() {
824
+ if (isStreaming) {
825
+ // STOP
826
+ if (abortController) abortController.abort();
827
+ return;
828
+ }
829
+
830
+ const input = document.getElementById('text-input');
831
+ const text = input.value.trim();
832
+ if (!text && !currentFile) return;
833
+
834
+ // ── add user message ──
835
+ let imageDataUrl = null;
836
+ if (currentFile) {
837
+ imageDataUrl = await readFileAsDataUrl(currentFile);
838
+ }
839
+
840
+ addMessage('user', text, imageDataUrl);
841
+ messages.push({ role: 'user', content: text, hasImage: !!currentFile });
842
+
843
+ // reset input
844
+ input.value = '';
845
+ autoResize(input);
846
+
847
+ // capture file then remove
848
+ const fileToSend = currentFile;
849
+ if (currentFile) removeFile();
850
+
851
+ // UI: streaming mode
852
+ isStreaming = true;
853
+ setSendBtn('stop');
854
+
855
+ // build form data
856
+ const formData = new FormData();
857
+ formData.append('text', text);
858
+ formData.append('show_thinking', showThinking ? '1' : '0');
859
+ // include history as JSON (without images for brevity)
860
+ formData.append('history', JSON.stringify(messages.slice(0, -1).map(m => ({
861
+ role: m.role, content: m.content
862
+ }))));
863
+ if (fileToSend) formData.append('image', fileToSend);
864
+
865
+ abortController = new AbortController();
866
+
867
+ const streamEl = addStreamingMessage();
868
+ let fullText = '';
869
+ let thinkingText = '';
870
+ let inThinking = false;
871
+
872
+ try {
873
+ const response = await fetch('/generate', {
874
+ method: 'POST',
875
+ body: formData,
876
+ signal: abortController.signal
877
+ });
878
+
879
+ if (!response.ok) {
880
+ const err = await response.json().catch(() => ({ detail: 'خطأ غير معروف' }));
881
+ streamEl.contentDiv.innerHTML = `<span style="color:var(--error)">⚠️ ${err.detail || 'خطأ من الخادم'}</span>`;
882
+ streamEl.msg.classList.remove('streaming');
883
+ return;
884
+ }
885
+
886
+ // ── SSE / chunked streaming ──
887
+ const reader = response.body.getReader();
888
+ const decoder = new TextDecoder();
889
+ let buffer = '';
890
+
891
+ while (true) {
892
+ const { done, value } = await reader.read();
893
+ if (done) break;
894
+
895
+ buffer += decoder.decode(value, { stream: true });
896
+ const lines = buffer.split('\n');
897
+ buffer = lines.pop(); // keep incomplete line
898
+
899
+ for (const line of lines) {
900
+ if (!line.trim()) continue;
901
+
902
+ // SSE format: "data: ..."
903
+ if (line.startsWith('data: ')) {
904
+ const rawData = line.slice(6);
905
+ if (rawData === '[DONE]') break;
906
+
907
+ let parsed;
908
+ try { parsed = JSON.parse(rawData); } catch { continue; }
909
+
910
+ // Ollama-style: { response, done } or { message: { content } }
911
+ // Generic streaming: { token } or { text } or { content }
912
+ const token = parsed.response
913
+ ?? parsed.token
914
+ ?? parsed.text
915
+ ?? parsed.content
916
+ ?? parsed.message?.content
917
+ ?? '';
918
+
919
+ // thinking detection via <think>...</think> tags
920
+ if (token) {
921
+ processToken(token, streamEl, { fullText: r => { fullText = r; }, thinkingText: r => { thinkingText = r; } }, { fullText: () => fullText, thinkingText: () => thinkingText });
922
+ }
923
+
924
+ if (parsed.done === true) break;
925
+
926
+ // plain JSON lines (no SSE prefix)
927
+ } else {
928
+ let parsed;
929
+ try { parsed = JSON.parse(line); } catch { continue; }
930
+
931
+ const token = parsed.response
932
+ ?? parsed.token
933
+ ?? parsed.text
934
+ ?? parsed.content
935
+ ?? parsed.message?.content
936
+ ?? '';
937
+
938
+ if (token) {
939
+ processToken(token, streamEl, { fullText: r => { fullText = r; }, thinkingText: r => { thinkingText = r; } }, { fullText: () => fullText, thinkingText: () => thinkingText });
940
+ }
941
+ if (parsed.done === true) break;
942
+ }
943
+ }
944
+
945
+ scrollToBottom();
946
+ }
947
+
948
+ // finalize
949
+ streamEl.finalize(fullText, thinkingText);
950
+ messages.push({ role: 'assistant', content: fullText });
951
+
952
+ } catch (err) {
953
+ if (err.name === 'AbortError') {
954
+ streamEl.msg.classList.remove('streaming');
955
+ streamEl.contentDiv.innerHTML += '<br><span style="color:var(--text3);font-size:12px;">— تم الإيقاف —</span>';
956
+ if (fullText) messages.push({ role: 'assistant', content: fullText });
957
+ } else {
958
+ streamEl.contentDiv.innerHTML = `<span style="color:var(--error)">⚠️ فشل الاتصال بالخادم: ${err.message}</span>`;
959
+ streamEl.msg.classList.remove('streaming');
960
+ }
961
+ } finally {
962
+ isStreaming = false;
963
+ setSendBtn('send');
964
+ abortController = null;
965
+ }
966
+ }
967
+
968
+ // ── PROCESS STREAMING TOKEN ──
969
+ // Handles <think>...</think> tags inline
970
+ let _buffer_think = '';
971
+ let _in_think = false;
972
+ let _full = '';
973
+ let _thinking = '';
974
+
975
+ function processToken(token, streamEl, setters, getters) {
976
+ for (const char of token) {
977
+ if (!_in_think) {
978
+ // check for opening tag
979
+ _buffer_think += char;
980
+ if ('<think>'.startsWith(_buffer_think)) {
981
+ if (_buffer_think === '<think>') {
982
+ _in_think = true;
983
+ _buffer_think = '';
984
+ // show thinking area
985
+ if (showThinking) {
986
+ streamEl.thinkWrap.style.display = 'block';
987
+ streamEl.thinkBtn.classList.add('open');
988
+ streamEl.thinkContent.classList.add('visible');
989
+ }
990
+ }
991
+ } else {
992
+ _full += _buffer_think;
993
+ _buffer_think = '';
994
+ streamEl.contentDiv.innerHTML = renderMarkdown(_full) + '<span style="color:var(--accent)">▋</span>';
995
+ }
996
+ } else {
997
+ // inside thinking block
998
+ if (char === '<') {
999
+ _buffer_think = '<';
1000
+ } else if (_buffer_think === '<') {
1001
+ _buffer_think += char;
1002
+ if (_buffer_think === '</') {
1003
+ // keep checking
1004
+ } else if (!'</think>'.startsWith(_buffer_think)) {
1005
+ _thinking += _buffer_think;
1006
+ _buffer_think = '';
1007
+ if (showThinking) streamEl.thinkContent.textContent = _thinking;
1008
+ }
1009
+ } else if (_buffer_think.length > 1) {
1010
+ _buffer_think += char;
1011
+ if (_buffer_think === '</think>') {
1012
+ _in_think = false;
1013
+ _buffer_think = '';
1014
+ if (showThinking) {
1015
+ streamEl.thinkStatus.textContent = `(${_thinking.split(' ').length} كلمة)`;
1016
+ }
1017
+ } else if (!'</think>'.startsWith(_buffer_think)) {
1018
+ _thinking += _buffer_think;
1019
+ _buffer_think = '';
1020
+ if (showThinking) streamEl.thinkContent.textContent = _thinking;
1021
+ }
1022
+ } else {
1023
+ _thinking += char;
1024
+ if (showThinking) streamEl.thinkContent.textContent = _thinking;
1025
+ }
1026
+ }
1027
+ }
1028
+ setters.fullText(_full);
1029
+ setters.thinkingText(_thinking);
1030
+ }
1031
+
1032
+ // Reset token processor state before each send
1033
+ const origHandleSend = handleSend;
1034
+ // Patch to reset parser state
1035
+ document.getElementById('send-btn').addEventListener('click', () => {
1036
+ _buffer_think = '';
1037
+ _in_think = false;
1038
+ _full = '';
1039
+ _thinking = '';
1040
+ }, true);
1041
+
1042
+ document.getElementById('text-input').addEventListener('keydown', (e) => {
1043
+ if (e.key === 'Enter' && !e.shiftKey) {
1044
+ _buffer_think = '';
1045
+ _in_think = false;
1046
+ _full = '';
1047
+ _thinking = '';
1048
+ }
1049
+ }, true);
1050
+
1051
+ // ── SEND BUTTON STATE ──
1052
+ function setSendBtn(mode) {
1053
+ const btn = document.getElementById('send-btn');
1054
+ if (mode === 'stop') {
1055
+ btn.textContent = '⏹';
1056
+ btn.classList.add('stop-mode');
1057
+ btn.title = 'إيقاف التوليد';
1058
+ } else {
1059
+ btn.textContent = '➤';
1060
+ btn.classList.remove('stop-mode');
1061
+ btn.title = 'إرسال';
1062
+ }
1063
+ }
1064
+
1065
+ // ── UTILS ──
1066
+ function readFileAsDataUrl(file) {
1067
+ return new Promise((res, rej) => {
1068
+ const r = new FileReader();
1069
+ r.onload = e => res(e.target.result);
1070
+ r.onerror = rej;
1071
+ r.readAsDataURL(file);
1072
+ });
1073
+ }
1074
+
1075
+ // Drag & drop on messages area
1076
+ const messagesEl = document.getElementById('messages');
1077
+ messagesEl.addEventListener('dragover', e => { e.preventDefault(); messagesEl.style.outline = '2px dashed var(--accent)'; });
1078
+ messagesEl.addEventListener('dragleave', () => { messagesEl.style.outline = ''; });
1079
+ messagesEl.addEventListener('drop', e => {
1080
+ e.preventDefault();
1081
+ messagesEl.style.outline = '';
1082
+ const file = e.dataTransfer.files[0];
1083
+ if (file && file.type.startsWith('image/')) {
1084
+ currentFile = file;
1085
+ const dt = new DataTransfer();
1086
+ dt.items.add(file);
1087
+ document.getElementById('file-input').files = dt.files;
1088
+ handleFile(document.getElementById('file-input'));
1089
+ }
1090
+ });
1091
+ </script>
1092
+ </body>
1093
+ </html>