gijl commited on
Commit
8a4157c
·
verified ·
1 Parent(s): 25eaf4e

Delete index.html

Browse files
Files changed (1) hide show
  1. index.html +0 -1093
index.html DELETED
@@ -1,1093 +0,0 @@
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>