agentbridge-cli 0.1.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1361 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}agentbridge chat{% endblock %}
4
+
5
+ {% block page_styles %}
6
+ :root {
7
+ --danger-bg: #fff1f0;
8
+ --shadow: 0 18px 50px rgba(18, 27, 27, 0.08);
9
+ }
10
+
11
+ button {
12
+ border: 0;
13
+ cursor: pointer;
14
+ }
15
+
16
+ .app {
17
+ grid-template-rows: var(--header-height) 1fr;
18
+ }
19
+
20
+ .header-actions {
21
+ min-width: 0;
22
+ justify-content: flex-end;
23
+ }
24
+
25
+ .field {
26
+ height: 36px;
27
+ border: 1px solid var(--border);
28
+ border-radius: 7px;
29
+ background: var(--surface);
30
+ color: var(--text);
31
+ display: inline-flex;
32
+ align-items: center;
33
+ gap: 8px;
34
+ padding: 0 10px;
35
+ }
36
+
37
+ .field span {
38
+ color: var(--muted);
39
+ font-size: 12px;
40
+ white-space: nowrap;
41
+ }
42
+
43
+ .field input,
44
+ .field select {
45
+ height: 30px;
46
+ min-width: 0;
47
+ border: 0;
48
+ outline: 0;
49
+ color: var(--text);
50
+ background: transparent;
51
+ }
52
+
53
+ .field-model {
54
+ flex: 0 1 310px;
55
+ }
56
+
57
+ .field-model select {
58
+ width: 100%;
59
+ font-size: 12px;
60
+ }
61
+
62
+ .status {
63
+ min-width: 82px;
64
+ display: inline-flex;
65
+ justify-content: center;
66
+ align-items: center;
67
+ gap: 6px;
68
+ border: 1px solid var(--border);
69
+ border-radius: 999px;
70
+ padding: 7px 10px;
71
+ color: var(--muted);
72
+ background: var(--surface);
73
+ font-size: 12px;
74
+ white-space: nowrap;
75
+ }
76
+
77
+ .status::before {
78
+ content: "";
79
+ width: 7px;
80
+ height: 7px;
81
+ border-radius: 50%;
82
+ background: var(--muted);
83
+ }
84
+
85
+ .status.ok {
86
+ color: var(--ok);
87
+ border-color: rgba(27, 128, 106, 0.28);
88
+ background: rgba(27, 128, 106, 0.06);
89
+ }
90
+
91
+ .status.ok::before { background: var(--ok); }
92
+
93
+ .status.error {
94
+ color: var(--danger);
95
+ border-color: var(--danger-border);
96
+ background: var(--danger-bg);
97
+ }
98
+
99
+ .status.error::before { background: var(--danger); }
100
+
101
+ .main {
102
+ min-height: 0;
103
+ display: grid;
104
+ grid-template-rows: 1fr auto;
105
+ width: min(980px, 100%);
106
+ margin: 0 auto;
107
+ padding: 22px 18px;
108
+ gap: 16px;
109
+ }
110
+
111
+ .messages {
112
+ min-height: 0;
113
+ overflow-y: auto;
114
+ display: flex;
115
+ flex-direction: column;
116
+ gap: 12px;
117
+ padding-right: 4px;
118
+ }
119
+
120
+ .empty {
121
+ margin: auto;
122
+ color: var(--muted);
123
+ text-align: center;
124
+ line-height: 1.5;
125
+ }
126
+
127
+ .empty strong {
128
+ display: block;
129
+ margin-bottom: 4px;
130
+ color: var(--text);
131
+ font-size: 16px;
132
+ }
133
+
134
+ .message {
135
+ max-width: 760px;
136
+ border: 1px solid var(--border);
137
+ border-radius: 8px;
138
+ background: var(--surface);
139
+ padding: 12px 14px;
140
+ box-shadow: 0 1px 0 rgba(18, 27, 27, 0.03);
141
+ }
142
+
143
+ .message.user {
144
+ align-self: flex-end;
145
+ background: #eef8f6;
146
+ border-color: rgba(22, 133, 117, 0.22);
147
+ }
148
+
149
+ .message.assistant {
150
+ align-self: flex-start;
151
+ }
152
+
153
+ .message.error {
154
+ align-self: stretch;
155
+ max-width: none;
156
+ border-color: var(--danger-border);
157
+ background: var(--danger-bg);
158
+ }
159
+
160
+ .message-header {
161
+ display: flex;
162
+ align-items: center;
163
+ gap: 12px;
164
+ margin-bottom: 7px;
165
+ color: var(--muted);
166
+ font-size: 12px;
167
+ }
168
+
169
+ .message-role {
170
+ font-weight: 650;
171
+ }
172
+
173
+ .message-actions {
174
+ margin-left: auto;
175
+ display: inline-flex;
176
+ align-items: center;
177
+ gap: 6px;
178
+ }
179
+
180
+ .message-info {
181
+ width: 24px;
182
+ height: 24px;
183
+ border: 1px solid var(--border);
184
+ border-radius: 6px;
185
+ display: none;
186
+ align-items: center;
187
+ justify-content: center;
188
+ color: var(--muted);
189
+ background: var(--surface);
190
+ text-decoration: none;
191
+ }
192
+
193
+ .message-info:hover {
194
+ color: var(--accent-strong);
195
+ border-color: rgba(22, 133, 117, 0.3);
196
+ background: rgba(22, 133, 117, 0.06);
197
+ }
198
+
199
+ .message-info.available {
200
+ display: inline-flex;
201
+ }
202
+
203
+ .model-badge {
204
+ min-width: 0;
205
+ max-width: 260px;
206
+ overflow: hidden;
207
+ text-overflow: ellipsis;
208
+ white-space: nowrap;
209
+ border: 1px solid var(--border);
210
+ border-radius: 6px;
211
+ background: var(--surface-muted);
212
+ color: var(--muted);
213
+ padding: 3px 7px;
214
+ font-family: var(--mono);
215
+ font-size: 11px;
216
+ }
217
+
218
+ .message-body {
219
+ white-space: pre-wrap;
220
+ line-height: 1.5;
221
+ }
222
+
223
+ .message-body.typing {
224
+ min-width: 52px;
225
+ white-space: normal;
226
+ }
227
+
228
+ .typing-indicator {
229
+ height: 20px;
230
+ display: inline-flex;
231
+ align-items: center;
232
+ gap: 5px;
233
+ }
234
+
235
+ .typing-indicator span {
236
+ width: 6px;
237
+ height: 6px;
238
+ border-radius: 50%;
239
+ background: var(--muted);
240
+ animation: typingPulse 1s ease-in-out infinite;
241
+ }
242
+
243
+ .typing-indicator span:nth-child(2) {
244
+ animation-delay: 0.14s;
245
+ }
246
+
247
+ .typing-indicator span:nth-child(3) {
248
+ animation-delay: 0.28s;
249
+ }
250
+
251
+ .message-body.markdown {
252
+ white-space: normal;
253
+ }
254
+
255
+ .message-body.markdown > :first-child {
256
+ margin-top: 0;
257
+ }
258
+
259
+ .message-body.markdown > :last-child {
260
+ margin-bottom: 0;
261
+ }
262
+
263
+ .message-body.markdown p,
264
+ .message-body.markdown ul,
265
+ .message-body.markdown ol,
266
+ .message-body.markdown blockquote,
267
+ .message-body.markdown pre {
268
+ margin: 0 0 10px;
269
+ }
270
+
271
+ .message-body.markdown ul,
272
+ .message-body.markdown ol {
273
+ padding-left: 22px;
274
+ }
275
+
276
+ .message-body.markdown li + li {
277
+ margin-top: 4px;
278
+ }
279
+
280
+ .message-body.markdown h1,
281
+ .message-body.markdown h2,
282
+ .message-body.markdown h3 {
283
+ margin: 14px 0 8px;
284
+ color: var(--text);
285
+ line-height: 1.25;
286
+ }
287
+
288
+ .message-body.markdown h1 {
289
+ font-size: 18px;
290
+ }
291
+
292
+ .message-body.markdown h2 {
293
+ font-size: 16px;
294
+ }
295
+
296
+ .message-body.markdown h3 {
297
+ font-size: 14px;
298
+ }
299
+
300
+ .message-body.markdown blockquote {
301
+ border-left: 3px solid var(--border-strong);
302
+ padding-left: 12px;
303
+ color: var(--muted);
304
+ }
305
+
306
+ .message-body.markdown a {
307
+ color: var(--accent);
308
+ text-decoration: none;
309
+ }
310
+
311
+ .message-body.markdown a:hover {
312
+ text-decoration: underline;
313
+ }
314
+
315
+ .message-body.markdown code {
316
+ border: 1px solid var(--border);
317
+ border-radius: 5px;
318
+ background: var(--surface-muted);
319
+ padding: 1px 4px;
320
+ font-family: var(--mono);
321
+ font-size: 12px;
322
+ }
323
+
324
+ .message-body.markdown pre code {
325
+ display: block;
326
+ border: 0;
327
+ background: transparent;
328
+ padding: 0;
329
+ white-space: pre;
330
+ }
331
+
332
+ .message-files {
333
+ display: flex;
334
+ flex-wrap: wrap;
335
+ gap: 6px;
336
+ margin-top: 10px;
337
+ }
338
+
339
+ .file-pill {
340
+ display: inline-flex;
341
+ align-items: center;
342
+ gap: 6px;
343
+ max-width: 240px;
344
+ border: 1px solid var(--border);
345
+ border-radius: 6px;
346
+ padding: 4px 7px;
347
+ background: var(--surface);
348
+ color: var(--muted);
349
+ font-size: 12px;
350
+ }
351
+
352
+ .file-pill span {
353
+ overflow: hidden;
354
+ text-overflow: ellipsis;
355
+ white-space: nowrap;
356
+ }
357
+
358
+ .error-title {
359
+ display: flex;
360
+ align-items: center;
361
+ gap: 8px;
362
+ color: var(--danger);
363
+ font-weight: 700;
364
+ }
365
+
366
+ .error-summary {
367
+ margin: 2px 0 10px;
368
+ color: var(--text);
369
+ }
370
+
371
+ .error-actions {
372
+ display: flex;
373
+ gap: 8px;
374
+ margin-top: 10px;
375
+ }
376
+
377
+ .error-details {
378
+ margin-top: 10px;
379
+ border-top: 1px solid var(--danger-border);
380
+ padding-top: 10px;
381
+ }
382
+
383
+ .error-details summary {
384
+ width: fit-content;
385
+ cursor: pointer;
386
+ color: var(--danger);
387
+ font-size: 12px;
388
+ }
389
+
390
+ pre {
391
+ margin: 10px 0 0;
392
+ max-height: 240px;
393
+ overflow: auto;
394
+ border: 1px solid var(--border);
395
+ border-radius: 7px;
396
+ background: #fbfbfb;
397
+ padding: 10px;
398
+ color: #263030;
399
+ font-family: var(--mono);
400
+ font-size: 12px;
401
+ line-height: 1.45;
402
+ white-space: pre-wrap;
403
+ word-break: break-word;
404
+ }
405
+
406
+ .composer {
407
+ border: 1px solid var(--border);
408
+ border-radius: 8px;
409
+ background: var(--surface);
410
+ box-shadow: var(--shadow);
411
+ overflow: hidden;
412
+ }
413
+
414
+ .drop-hint {
415
+ display: none;
416
+ margin: 10px 10px 0;
417
+ border: 1px dashed rgba(22, 133, 117, 0.5);
418
+ border-radius: 7px;
419
+ padding: 10px;
420
+ color: var(--accent-strong);
421
+ background: rgba(22, 133, 117, 0.06);
422
+ text-align: center;
423
+ font-size: 13px;
424
+ }
425
+
426
+ .composer.dragging .drop-hint {
427
+ display: block;
428
+ }
429
+
430
+ .attached-files {
431
+ display: flex;
432
+ flex-wrap: wrap;
433
+ gap: 6px;
434
+ padding: 10px 10px 0;
435
+ }
436
+
437
+ .remove-file {
438
+ width: 18px;
439
+ height: 18px;
440
+ border-radius: 50%;
441
+ background: transparent;
442
+ color: var(--muted);
443
+ line-height: 18px;
444
+ padding: 0;
445
+ }
446
+
447
+ .remove-file:hover {
448
+ background: var(--surface-muted);
449
+ color: var(--text);
450
+ }
451
+
452
+ .input-row {
453
+ display: grid;
454
+ grid-template-columns: auto 1fr auto;
455
+ align-items: center;
456
+ gap: 10px;
457
+ padding: 6px 10px 10px;
458
+ }
459
+
460
+ textarea {
461
+ width: 100%;
462
+ min-height: 24px;
463
+ max-height: 180px;
464
+ resize: none;
465
+ overflow-y: hidden;
466
+ border: 0;
467
+ outline: 0;
468
+ color: var(--text);
469
+ line-height: 1.5;
470
+ padding: 1px 0;
471
+ box-sizing: border-box;
472
+ }
473
+
474
+ textarea::placeholder {
475
+ color: #9aa2a2;
476
+ }
477
+
478
+ .icon-button,
479
+ .secondary,
480
+ .primary {
481
+ height: 36px;
482
+ border-radius: 7px;
483
+ display: inline-flex;
484
+ align-items: center;
485
+ justify-content: center;
486
+ gap: 7px;
487
+ white-space: nowrap;
488
+ }
489
+
490
+ .icon-button {
491
+ width: 36px;
492
+ border: 1px solid var(--border);
493
+ background: var(--surface);
494
+ color: var(--muted);
495
+ }
496
+
497
+ .icon-button:hover {
498
+ color: var(--text);
499
+ background: var(--surface-muted);
500
+ }
501
+
502
+ .primary {
503
+ width: 40px;
504
+ min-width: 40px;
505
+ background: var(--accent);
506
+ color: #ffffff;
507
+ padding: 0;
508
+ font-weight: 650;
509
+ }
510
+
511
+ .primary svg {
512
+ flex: none;
513
+ }
514
+
515
+ .primary:hover {
516
+ background: var(--accent-strong);
517
+ }
518
+
519
+ .primary:disabled {
520
+ cursor: not-allowed;
521
+ opacity: 0.62;
522
+ }
523
+
524
+ .secondary {
525
+ border: 1px solid var(--border);
526
+ background: var(--surface);
527
+ color: var(--muted);
528
+ padding: 0 10px;
529
+ font-size: 12px;
530
+ }
531
+
532
+ .secondary:hover {
533
+ color: var(--text);
534
+ background: var(--surface-muted);
535
+ }
536
+
537
+ .hidden-input {
538
+ position: absolute;
539
+ width: 1px;
540
+ height: 1px;
541
+ opacity: 0;
542
+ pointer-events: none;
543
+ }
544
+
545
+ @keyframes typingPulse {
546
+ 0%, 70%, 100% { opacity: 0.35; transform: translateY(0); }
547
+ 35% { opacity: 1; transform: translateY(-2px); }
548
+ }
549
+
550
+ @media (max-width: 1180px) {
551
+ body { overflow: auto; }
552
+ .app {
553
+ min-height: 100vh;
554
+ height: auto;
555
+ grid-template-rows: auto 1fr;
556
+ }
557
+ .topbar {
558
+ grid-template-columns: 1fr;
559
+ grid-template-rows: auto auto auto;
560
+ height: auto;
561
+ min-height: var(--header-height);
562
+ padding: 12px 14px;
563
+ }
564
+ .main-nav,
565
+ .header-actions {
566
+ justify-self: stretch;
567
+ }
568
+ .header-actions {
569
+ flex-wrap: wrap;
570
+ justify-content: space-between;
571
+ align-items: stretch;
572
+ }
573
+ .field-model {
574
+ min-width: 260px;
575
+ max-width: none;
576
+ flex: 1 1 260px;
577
+ }
578
+ .status {
579
+ flex: 0 0 auto;
580
+ }
581
+ .main {
582
+ min-height: calc(100vh - 178px);
583
+ }
584
+ }
585
+
586
+ @media (max-width: 820px) {
587
+ .brand,
588
+ .status {
589
+ min-width: 0;
590
+ }
591
+ .field-model,
592
+ .status {
593
+ width: 100%;
594
+ flex: 1 1 100%;
595
+ }
596
+ .main {
597
+ min-height: calc(100vh - 160px);
598
+ padding: 14px;
599
+ }
600
+ .nav-item {
601
+ min-width: 0;
602
+ flex: 1;
603
+ }
604
+ .input-row {
605
+ grid-template-columns: auto minmax(0, 1fr) auto;
606
+ }
607
+ .secondary {
608
+ width: 100%;
609
+ }
610
+ }
611
+ {% endblock %}
612
+
613
+ {% block header_actions %}
614
+ <input id="base-url" type="hidden" value="/api/v1">
615
+ <label class="field field-model">
616
+ <span>Model</span>
617
+ <select id="model">
618
+ {% for model in available_models %}
619
+ <option value="{{ model.slug }}"{% if model.slug == default_model %} selected{% endif %}>{{ model.slug }}</option>
620
+ {% endfor %}
621
+ </select>
622
+ </label>
623
+ <div id="status" class="status">Checking</div>
624
+ {% endblock %}
625
+
626
+ {% block content %}
627
+ <main class="main">
628
+ <section id="messages" class="messages" aria-live="polite">
629
+ <div class="empty">
630
+ <strong>Send a test message</strong>
631
+ Use a model namespace, attach files if needed, and inspect any server error inline.
632
+ </div>
633
+ </section>
634
+
635
+ <section id="composer" class="composer">
636
+ <div class="drop-hint">Drop files to attach</div>
637
+ <div id="attached-files" class="attached-files"></div>
638
+ <div class="input-row">
639
+ <button id="attach-button" class="icon-button" title="Attach files" type="button" aria-label="Attach files">
640
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
641
+ <path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
642
+ </svg>
643
+ </button>
644
+ <textarea id="prompt" placeholder="Send a test message..." rows="1"></textarea>
645
+ <button id="send" class="primary" type="button" aria-label="Send message" title="Send message">
646
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
647
+ <path d="m5 12 7-7 7 7"></path>
648
+ <path d="M12 19V5"></path>
649
+ </svg>
650
+ </button>
651
+ </div>
652
+ <input id="file-input" class="hidden-input" type="file" multiple accept="image/*,application/pdf,text/plain,.txt">
653
+ </section>
654
+ </main>
655
+ {% endblock %}
656
+
657
+ {% block scripts %}
658
+ <script>
659
+ (function() {
660
+ var baseUrlEl = document.getElementById("base-url");
661
+ var modelEl = document.getElementById("model");
662
+ var statusEl = document.getElementById("status");
663
+ var messagesEl = document.getElementById("messages");
664
+ var composerEl = document.getElementById("composer");
665
+ var attachedFilesEl = document.getElementById("attached-files");
666
+ var fileInputEl = document.getElementById("file-input");
667
+ var attachButtonEl = document.getElementById("attach-button");
668
+ var promptEl = document.getElementById("prompt");
669
+ var sendEl = document.getElementById("send");
670
+ var attachedFiles = [];
671
+ var conversationMessages = [];
672
+ var renderedMessages = [];
673
+ var chatStateKey = "agentbridge.chat.state.v1";
674
+
675
+ function endpoint(path) {
676
+ return baseUrlEl.value.replace(/\/$/, "") + path;
677
+ }
678
+
679
+ function setStatus(kind, label) {
680
+ statusEl.classList.remove("ok", "error");
681
+ if (kind) statusEl.classList.add(kind);
682
+ statusEl.textContent = label;
683
+ }
684
+
685
+ function autosizePrompt() {
686
+ promptEl.style.height = "auto";
687
+ var maxHeight = 180;
688
+ var nextHeight = Math.min(promptEl.scrollHeight, maxHeight);
689
+ promptEl.style.height = nextHeight + "px";
690
+ promptEl.style.overflowY = promptEl.scrollHeight > maxHeight ? "auto" : "hidden";
691
+ }
692
+
693
+ function clearEmptyState() {
694
+ var empty = messagesEl.querySelector(".empty");
695
+ if (empty) empty.remove();
696
+ }
697
+
698
+ function escapeHtml(value) {
699
+ return String(value)
700
+ .replace(/&/g, "&amp;")
701
+ .replace(/</g, "&lt;")
702
+ .replace(/>/g, "&gt;")
703
+ .replace(/"/g, "&quot;")
704
+ .replace(/'/g, "&#39;");
705
+ }
706
+
707
+ function safeLinkUrl(value) {
708
+ var url = String(value || "").trim();
709
+ return /^(https?:\/\/|mailto:)/i.test(url) ? url : "#";
710
+ }
711
+
712
+ function renderInlineMarkdown(text) {
713
+ var codeTokens = [];
714
+ var html = escapeHtml(text).replace(/`([^`]+)`/g, function(match, code) {
715
+ var token = "\u0000CODE" + codeTokens.length + "\u0000";
716
+ codeTokens.push("<code>" + code + "</code>");
717
+ return token;
718
+ });
719
+
720
+ html = html.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function(match, label, url) {
721
+ var href = escapeHtml(safeLinkUrl(url));
722
+ var rel = href === "#" ? "" : " rel=\"noreferrer\" target=\"_blank\"";
723
+ return "<a href=\"" + href + "\"" + rel + ">" + label + "</a>";
724
+ });
725
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
726
+ html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
727
+
728
+ codeTokens.forEach(function(tokenHtml, index) {
729
+ html = html.replace("\u0000CODE" + index + "\u0000", tokenHtml);
730
+ });
731
+ return html;
732
+ }
733
+
734
+ function markdownToHtml(markdown) {
735
+ var lines = String(markdown || "").replace(/\r\n?/g, "\n").split("\n");
736
+ var html = [];
737
+ var paragraph = [];
738
+ var listItems = [];
739
+ var listType = null;
740
+ var codeLines = [];
741
+ var inCode = false;
742
+
743
+ function flushParagraph() {
744
+ if (!paragraph.length) return;
745
+ html.push("<p>" + renderInlineMarkdown(paragraph.join(" ")) + "</p>");
746
+ paragraph = [];
747
+ }
748
+
749
+ function flushList() {
750
+ if (!listItems.length) return;
751
+ html.push("<" + listType + ">" + listItems.join("") + "</" + listType + ">");
752
+ listItems = [];
753
+ listType = null;
754
+ }
755
+
756
+ function flushCode() {
757
+ html.push("<pre><code>" + escapeHtml(codeLines.join("\n")) + "</code></pre>");
758
+ codeLines = [];
759
+ }
760
+
761
+ lines.forEach(function(line) {
762
+ var fenceMatch = line.match(/^```/);
763
+ var headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
764
+ var listMatch = line.match(/^\s*([-*+]|\d+[.)])\s+(.+)$/);
765
+ var quoteMatch = line.match(/^>\s?(.*)$/);
766
+
767
+ if (fenceMatch) {
768
+ if (inCode) {
769
+ flushCode();
770
+ inCode = false;
771
+ } else {
772
+ flushParagraph();
773
+ flushList();
774
+ inCode = true;
775
+ }
776
+ return;
777
+ }
778
+
779
+ if (inCode) {
780
+ codeLines.push(line);
781
+ return;
782
+ }
783
+
784
+ if (!line.trim()) {
785
+ flushParagraph();
786
+ flushList();
787
+ return;
788
+ }
789
+
790
+ if (headingMatch) {
791
+ flushParagraph();
792
+ flushList();
793
+ var level = headingMatch[1].length;
794
+ html.push("<h" + level + ">" + renderInlineMarkdown(headingMatch[2]) + "</h" + level + ">");
795
+ return;
796
+ }
797
+
798
+ if (listMatch) {
799
+ flushParagraph();
800
+ var nextType = /^\d/.test(listMatch[1]) ? "ol" : "ul";
801
+ if (listType && listType !== nextType) flushList();
802
+ listType = nextType;
803
+ listItems.push("<li>" + renderInlineMarkdown(listMatch[2]) + "</li>");
804
+ return;
805
+ }
806
+
807
+ if (quoteMatch) {
808
+ flushParagraph();
809
+ flushList();
810
+ html.push("<blockquote>" + renderInlineMarkdown(quoteMatch[1]) + "</blockquote>");
811
+ return;
812
+ }
813
+
814
+ flushList();
815
+ paragraph.push(line.trim());
816
+ });
817
+
818
+ if (inCode) flushCode();
819
+ flushParagraph();
820
+ flushList();
821
+ return html.join("");
822
+ }
823
+
824
+ function renderAssistantMarkdown(target, text) {
825
+ target.classList.remove("typing");
826
+ target.classList.add("markdown");
827
+ target.removeAttribute("aria-label");
828
+ target.removeAttribute("role");
829
+ target.dataset.rawMarkdown = text || "";
830
+ target.innerHTML = markdownToHtml(text);
831
+ }
832
+
833
+ function renderAssistantTyping(target) {
834
+ target.classList.remove("markdown");
835
+ target.classList.add("typing");
836
+ target.dataset.rawMarkdown = "";
837
+ target.setAttribute("role", "status");
838
+ target.setAttribute("aria-label", "Assistant is typing");
839
+ target.innerHTML = '<span class="typing-indicator" aria-hidden="true"><span></span><span></span><span></span></span>';
840
+ }
841
+
842
+ function removeMessageBody(target) {
843
+ var article = target && target.closest ? target.closest(".message") : null;
844
+ if (article) article.remove();
845
+ }
846
+
847
+ function messageFileNames(files) {
848
+ return (files || []).map(function(file) {
849
+ return typeof file === "string" ? file : file.name;
850
+ }).filter(Boolean);
851
+ }
852
+
853
+ function saveChatState() {
854
+ try {
855
+ sessionStorage.setItem(chatStateKey, JSON.stringify({
856
+ model: modelEl.value,
857
+ conversationMessages: conversationMessages,
858
+ renderedMessages: renderedMessages
859
+ }));
860
+ } catch (e) {
861
+ try {
862
+ sessionStorage.setItem(chatStateKey, JSON.stringify({
863
+ model: modelEl.value,
864
+ conversationMessages: [],
865
+ renderedMessages: renderedMessages
866
+ }));
867
+ } catch (fallbackError) {
868
+ // Attachments can exceed sessionStorage quota; keep the live chat usable.
869
+ }
870
+ }
871
+ }
872
+
873
+ function updateRenderedMessage(target, values) {
874
+ var article = target && target.closest ? target.closest(".message") : null;
875
+ if (!article || article.dataset.chatIndex === undefined) return;
876
+ var index = Number(article.dataset.chatIndex);
877
+ if (!renderedMessages[index]) return;
878
+ Object.keys(values).forEach(function(key) {
879
+ renderedMessages[index][key] = values[key];
880
+ });
881
+ saveChatState();
882
+ }
883
+
884
+ function appendAssistantDelta(target, delta) {
885
+ renderAssistantMarkdown(target, (target.dataset.rawMarkdown || "") + delta);
886
+ messagesEl.scrollTop = messagesEl.scrollHeight;
887
+ }
888
+
889
+ function appendMessage(role, text, files, options) {
890
+ options = options || {};
891
+ clearEmptyState();
892
+ var article = document.createElement("article");
893
+ article.className = "message " + role;
894
+ if (options.persist !== false) {
895
+ article.dataset.chatIndex = String(renderedMessages.length);
896
+ renderedMessages.push({
897
+ role: role,
898
+ text: text || "",
899
+ model: options.model || null,
900
+ requestId: options.requestId || null,
901
+ files: messageFileNames(files)
902
+ });
903
+ }
904
+ var header = document.createElement("div");
905
+ header.className = "message-header";
906
+
907
+ var roleLabel = document.createElement("span");
908
+ roleLabel.className = "message-role";
909
+ roleLabel.textContent = role === "user" ? "You" : "Assistant";
910
+ header.appendChild(roleLabel);
911
+
912
+ var actions = document.createElement("div");
913
+ actions.className = "message-actions";
914
+
915
+ if (role === "assistant" && options.model) {
916
+ var modelBadge = document.createElement("span");
917
+ modelBadge.className = "model-badge";
918
+ modelBadge.title = "Model used for this response";
919
+ modelBadge.textContent = options.model;
920
+ actions.appendChild(modelBadge);
921
+ }
922
+
923
+ if (role === "user" || role === "assistant") {
924
+ var infoLink = document.createElement("a");
925
+ infoLink.className = "message-info";
926
+ infoLink.title = "View request in Monitor";
927
+ infoLink.setAttribute("aria-label", "View request in Monitor");
928
+ infoLink.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg>';
929
+ actions.appendChild(infoLink);
930
+ article.dataset.requestLinkReady = "false";
931
+ }
932
+
933
+ header.appendChild(actions);
934
+
935
+ var body = document.createElement("div");
936
+ body.className = "message-body";
937
+ if (role === "assistant" && options.loading) {
938
+ renderAssistantTyping(body);
939
+ } else if (role === "assistant") {
940
+ renderAssistantMarkdown(body, text || "");
941
+ } else {
942
+ body.textContent = text || "";
943
+ }
944
+ article.appendChild(header);
945
+ article.appendChild(body);
946
+
947
+ if (files && files.length) {
948
+ var fileWrap = document.createElement("div");
949
+ fileWrap.className = "message-files";
950
+ files.forEach(function(file) {
951
+ fileWrap.appendChild(filePill(file.name, false));
952
+ });
953
+ article.appendChild(fileWrap);
954
+ }
955
+
956
+ messagesEl.appendChild(article);
957
+ messagesEl.scrollTop = messagesEl.scrollHeight;
958
+ if (options.persist !== false) {
959
+ saveChatState();
960
+ }
961
+ return body;
962
+ }
963
+
964
+ function setMessageRequestId(target, requestId) {
965
+ if (!target || !requestId) return;
966
+ var article = target.closest ? target.closest(".message") : null;
967
+ if (!article) return;
968
+ article.dataset.requestId = requestId;
969
+ article.dataset.requestLinkReady = "true";
970
+ var infoLink = article.querySelector(".message-info");
971
+ if (!infoLink) return;
972
+ infoLink.href = "/dashboard?request_id=" + encodeURIComponent(requestId);
973
+ infoLink.classList.add("available");
974
+ updateRenderedMessage(target, { requestId: requestId });
975
+ }
976
+
977
+ function restoreChatState() {
978
+ var raw = null;
979
+ try {
980
+ raw = sessionStorage.getItem(chatStateKey);
981
+ } catch (e) {
982
+ return;
983
+ }
984
+ if (!raw) return;
985
+ try {
986
+ var state = JSON.parse(raw);
987
+ if (!state || !Array.isArray(state.renderedMessages)) return;
988
+ conversationMessages = Array.isArray(state.conversationMessages) ? state.conversationMessages : [];
989
+ if (state.model) {
990
+ var hasSavedModel = Array.from(modelEl.options).some(function(option) {
991
+ return option.value === state.model;
992
+ });
993
+ if (hasSavedModel) modelEl.value = state.model;
994
+ }
995
+ state.renderedMessages.forEach(function(message) {
996
+ var body = appendMessage(
997
+ message.role,
998
+ message.text || "",
999
+ (message.files || []).map(function(name) { return { name: name }; }),
1000
+ {
1001
+ model: message.model || null,
1002
+ requestId: message.requestId || null,
1003
+ persist: false
1004
+ }
1005
+ );
1006
+ renderedMessages.push({
1007
+ role: message.role,
1008
+ text: message.text || "",
1009
+ model: message.model || null,
1010
+ requestId: message.requestId || null,
1011
+ files: message.files || []
1012
+ });
1013
+ body.closest(".message").dataset.chatIndex = String(renderedMessages.length - 1);
1014
+ setMessageRequestId(body, message.requestId);
1015
+ });
1016
+ saveChatState();
1017
+ } catch (e) {
1018
+ try {
1019
+ sessionStorage.removeItem(chatStateKey);
1020
+ } catch (removeError) {}
1021
+ }
1022
+ }
1023
+
1024
+ function compactJson(value) {
1025
+ try {
1026
+ return JSON.stringify(value, null, 2);
1027
+ } catch (e) {
1028
+ return String(value);
1029
+ }
1030
+ }
1031
+
1032
+ function summarizeError(status, statusText, body, requestId) {
1033
+ var err = body && body.error ? body.error : null;
1034
+ var title = "Server error";
1035
+ var summary = status + (statusText ? " " + statusText : "");
1036
+ if (err && err.message) summary = summary + ": " + err.message;
1037
+ return {
1038
+ title: title,
1039
+ summary: summary,
1040
+ details: {
1041
+ status: status,
1042
+ statusText: statusText || null,
1043
+ request_id: requestId || null,
1044
+ error: err || body || null
1045
+ }
1046
+ };
1047
+ }
1048
+
1049
+ function appendError(status, statusText, body, requestId) {
1050
+ clearEmptyState();
1051
+ var info = summarizeError(status, statusText, body, requestId);
1052
+ var article = document.createElement("article");
1053
+ article.className = "message error";
1054
+
1055
+ var title = document.createElement("div");
1056
+ title.className = "error-title";
1057
+ title.textContent = info.title;
1058
+
1059
+ var summary = document.createElement("div");
1060
+ summary.className = "error-summary";
1061
+ summary.textContent = info.summary;
1062
+
1063
+ var details = document.createElement("details");
1064
+ details.className = "error-details";
1065
+ details.open = true;
1066
+ var detailsSummary = document.createElement("summary");
1067
+ detailsSummary.textContent = "Details";
1068
+ var pre = document.createElement("pre");
1069
+ pre.textContent = compactJson(info.details);
1070
+ details.appendChild(detailsSummary);
1071
+ details.appendChild(pre);
1072
+
1073
+ var actions = document.createElement("div");
1074
+ actions.className = "error-actions";
1075
+
1076
+ var retry = document.createElement("button");
1077
+ retry.className = "secondary";
1078
+ retry.type = "button";
1079
+ retry.textContent = "Retry";
1080
+ retry.addEventListener("click", sendMessage);
1081
+
1082
+ var copy = document.createElement("button");
1083
+ copy.className = "secondary";
1084
+ copy.type = "button";
1085
+ copy.textContent = "Copy details";
1086
+ copy.addEventListener("click", function() {
1087
+ navigator.clipboard.writeText(compactJson(info.details));
1088
+ });
1089
+
1090
+ actions.appendChild(retry);
1091
+ actions.appendChild(copy);
1092
+
1093
+ article.appendChild(title);
1094
+ article.appendChild(summary);
1095
+ article.appendChild(details);
1096
+ article.appendChild(actions);
1097
+ messagesEl.appendChild(article);
1098
+ messagesEl.scrollTop = messagesEl.scrollHeight;
1099
+ setStatus("error", "Error");
1100
+ }
1101
+
1102
+ function filePill(fileName, removable, index) {
1103
+ var pill = document.createElement("span");
1104
+ pill.className = "file-pill";
1105
+
1106
+ var name = document.createElement("span");
1107
+ name.textContent = fileName;
1108
+ pill.appendChild(name);
1109
+
1110
+ if (removable) {
1111
+ var remove = document.createElement("button");
1112
+ remove.className = "remove-file";
1113
+ remove.type = "button";
1114
+ remove.textContent = "x";
1115
+ remove.title = "Remove file";
1116
+ remove.addEventListener("click", function() {
1117
+ attachedFiles.splice(index, 1);
1118
+ renderAttachedFiles();
1119
+ });
1120
+ pill.appendChild(remove);
1121
+ }
1122
+ return pill;
1123
+ }
1124
+
1125
+ function renderAttachedFiles() {
1126
+ attachedFilesEl.replaceChildren();
1127
+ attachedFiles.forEach(function(file, index) {
1128
+ attachedFilesEl.appendChild(filePill(file.name, true, index));
1129
+ });
1130
+ }
1131
+
1132
+ function isTextFile(file) {
1133
+ return file.type === "text/plain" || /\.txt$/i.test(file.name || "");
1134
+ }
1135
+
1136
+ function addFiles(fileList) {
1137
+ Array.from(fileList).forEach(function(file) {
1138
+ var fileType = file.type || "";
1139
+ if (!fileType.startsWith("image/") && fileType !== "application/pdf" && !isTextFile(file)) {
1140
+ appendError(0, "Unsupported attachment", {
1141
+ error: {
1142
+ message: file.name + " is not an image, PDF, or TXT file.",
1143
+ type: "invalid_attachment",
1144
+ code: "unsupported_file_type"
1145
+ }
1146
+ }, null);
1147
+ return;
1148
+ }
1149
+ attachedFiles.push(file);
1150
+ });
1151
+ renderAttachedFiles();
1152
+ }
1153
+
1154
+ function fileToDataUrl(file) {
1155
+ return new Promise(function(resolve, reject) {
1156
+ var reader = new FileReader();
1157
+ reader.onload = function() { resolve(reader.result); };
1158
+ reader.onerror = function() { reject(reader.error); };
1159
+ reader.readAsDataURL(file);
1160
+ });
1161
+ }
1162
+
1163
+ function fileToText(file) {
1164
+ return new Promise(function(resolve, reject) {
1165
+ var reader = new FileReader();
1166
+ reader.onload = function() { resolve(reader.result || ""); };
1167
+ reader.onerror = function() { reject(reader.error); };
1168
+ reader.readAsText(file);
1169
+ });
1170
+ }
1171
+
1172
+ async function buildUserContent(text) {
1173
+ if (!attachedFiles.length) return text;
1174
+ var parts = [];
1175
+ if (text) parts.push({ type: "text", text: text });
1176
+ for (var i = 0; i < attachedFiles.length; i += 1) {
1177
+ if (isTextFile(attachedFiles[i])) {
1178
+ var fileText = await fileToText(attachedFiles[i]);
1179
+ parts.push({
1180
+ type: "text",
1181
+ text: [
1182
+ '[Attached text file: "' + attachedFiles[i].name + '"]',
1183
+ fileText,
1184
+ '[End attached text file: "' + attachedFiles[i].name + '"]'
1185
+ ].join("\n")
1186
+ });
1187
+ } else {
1188
+ parts.push({
1189
+ type: "image_url",
1190
+ image_url: { url: await fileToDataUrl(attachedFiles[i]) }
1191
+ });
1192
+ }
1193
+ }
1194
+ return parts;
1195
+ }
1196
+
1197
+ function displayTextForMessage(text, files) {
1198
+ if (text) return text;
1199
+ if (!files || !files.length) return "";
1200
+ return files.length === 1 ? "Attached 1 file" : "Attached " + files.length + " files";
1201
+ }
1202
+
1203
+ async function parseResponseBody(response) {
1204
+ var text = await response.text();
1205
+ if (!text) return null;
1206
+ try {
1207
+ return JSON.parse(text);
1208
+ } catch (e) {
1209
+ return { raw: text };
1210
+ }
1211
+ }
1212
+
1213
+ function parseSseEvent(buffer, onData) {
1214
+ var events = buffer.split(/\n\n/);
1215
+ var remainder = events.pop();
1216
+ events.forEach(function(event) {
1217
+ var dataLines = event.split(/\n/).filter(function(line) {
1218
+ return line.indexOf("data:") === 0;
1219
+ }).map(function(line) {
1220
+ return line.slice(5).trimStart();
1221
+ });
1222
+ if (dataLines.length) onData(dataLines.join("\n"));
1223
+ });
1224
+ return remainder;
1225
+ }
1226
+
1227
+ async function streamResponse(response, target) {
1228
+ var reader = response.body.getReader();
1229
+ var decoder = new TextDecoder();
1230
+ var buffer = "";
1231
+
1232
+ while (true) {
1233
+ var read = await reader.read();
1234
+ if (read.done) break;
1235
+ buffer += decoder.decode(read.value, { stream: true });
1236
+ buffer = parseSseEvent(buffer, function(data) {
1237
+ if (data === "[DONE]") return;
1238
+ try {
1239
+ var chunk = JSON.parse(data);
1240
+ var choice = chunk.choices && chunk.choices[0];
1241
+ var delta = choice && choice.delta && choice.delta.content;
1242
+ if (delta) {
1243
+ appendAssistantDelta(target, delta);
1244
+ }
1245
+ } catch (e) {
1246
+ appendAssistantDelta(target, data);
1247
+ }
1248
+ });
1249
+ }
1250
+ if (target.classList.contains("typing")) renderAssistantMarkdown(target, "");
1251
+ return target.dataset.rawMarkdown || "";
1252
+ }
1253
+
1254
+ async function sendMessage() {
1255
+ var text = promptEl.value.trim();
1256
+ if (!text && !attachedFiles.length) return;
1257
+ var assistantTarget = null;
1258
+
1259
+ sendEl.disabled = true;
1260
+ setStatus(null, "Sending");
1261
+
1262
+ try {
1263
+ var content = await buildUserContent(text);
1264
+ var filesForMessage = attachedFiles.slice();
1265
+ var userMessage = { role: "user", content: content };
1266
+ var payload = {
1267
+ model: modelEl.value,
1268
+ messages: conversationMessages.concat([userMessage]),
1269
+ stream: true
1270
+ };
1271
+
1272
+ var userTarget = appendMessage("user", displayTextForMessage(text, filesForMessage), filesForMessage);
1273
+ assistantTarget = appendMessage("assistant", "", null, { model: payload.model, loading: true });
1274
+ promptEl.value = "";
1275
+ autosizePrompt();
1276
+ attachedFiles = [];
1277
+ renderAttachedFiles();
1278
+
1279
+ var response = await fetch(endpoint("/chat/completions"), {
1280
+ method: "POST",
1281
+ headers: { "Content-Type": "application/json" },
1282
+ body: JSON.stringify(payload)
1283
+ });
1284
+ var requestId = response.headers.get("x-request-id");
1285
+ setMessageRequestId(userTarget, requestId);
1286
+ setMessageRequestId(assistantTarget, requestId);
1287
+
1288
+ if (!response.ok) {
1289
+ removeMessageBody(assistantTarget);
1290
+ appendError(response.status, response.statusText, await parseResponseBody(response), requestId);
1291
+ return;
1292
+ }
1293
+
1294
+ setStatus("ok", "Healthy");
1295
+ conversationMessages.push(userMessage);
1296
+ var streamedText = await streamResponse(response, assistantTarget);
1297
+ conversationMessages.push({ role: "assistant", content: streamedText });
1298
+ updateRenderedMessage(assistantTarget, { text: streamedText });
1299
+ saveChatState();
1300
+ } catch (error) {
1301
+ removeMessageBody(assistantTarget);
1302
+ appendError(0, "Request failed", {
1303
+ error: {
1304
+ message: error && error.message ? error.message : String(error),
1305
+ type: "network_error",
1306
+ code: "request_failed"
1307
+ }
1308
+ }, null);
1309
+ } finally {
1310
+ sendEl.disabled = false;
1311
+ }
1312
+ }
1313
+
1314
+ async function checkHealth() {
1315
+ try {
1316
+ var response = await fetch("/health");
1317
+ setStatus(response.ok ? "ok" : "error", response.ok ? "Healthy" : "Error");
1318
+ } catch (e) {
1319
+ setStatus("error", "Offline");
1320
+ }
1321
+ }
1322
+
1323
+ attachButtonEl.addEventListener("click", function() {
1324
+ fileInputEl.click();
1325
+ });
1326
+ fileInputEl.addEventListener("change", function() {
1327
+ addFiles(fileInputEl.files);
1328
+ fileInputEl.value = "";
1329
+ });
1330
+ sendEl.addEventListener("click", sendMessage);
1331
+ modelEl.addEventListener("change", saveChatState);
1332
+ promptEl.addEventListener("input", autosizePrompt);
1333
+ promptEl.addEventListener("keydown", function(e) {
1334
+ if (e.key !== "Enter" || e.isComposing) return;
1335
+ if (e.altKey) return;
1336
+ e.preventDefault();
1337
+ sendMessage();
1338
+ });
1339
+
1340
+ ["dragenter", "dragover"].forEach(function(eventName) {
1341
+ composerEl.addEventListener(eventName, function(e) {
1342
+ e.preventDefault();
1343
+ composerEl.classList.add("dragging");
1344
+ });
1345
+ });
1346
+ ["dragleave", "drop"].forEach(function(eventName) {
1347
+ composerEl.addEventListener(eventName, function(e) {
1348
+ e.preventDefault();
1349
+ if (eventName === "drop" && e.dataTransfer && e.dataTransfer.files) {
1350
+ addFiles(e.dataTransfer.files);
1351
+ }
1352
+ composerEl.classList.remove("dragging");
1353
+ });
1354
+ });
1355
+
1356
+ restoreChatState();
1357
+ checkHealth();
1358
+ autosizePrompt();
1359
+ })();
1360
+ </script>
1361
+ {% endblock %}