loopengine 0.1.48 → 0.1.50
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.
|
@@ -17,6 +17,15 @@ export interface AbilityEnvDecl {
|
|
|
17
17
|
* of a free-text field. Omit for anything that isn't genuinely a fixed
|
|
18
18
|
* enum (an API key, a free-form model name, a path, ...). */
|
|
19
19
|
options?: string[];
|
|
20
|
+
/** True for a value that's naturally multi-line (a pasted JSON key
|
|
21
|
+
* file, a PEM block, ...) — the Admin UI renders a <textarea> instead
|
|
22
|
+
* of a single-line <input>, which per the HTML spec's own value
|
|
23
|
+
* sanitization strips line breaks entirely and would otherwise mangle
|
|
24
|
+
* exactly this kind of value on paste, regardless of how correctly the
|
|
25
|
+
* .env file itself round-trips real embedded newlines (see
|
|
26
|
+
* env-admin.ts's own serializeEnvValue doc comment). Omit for anything
|
|
27
|
+
* genuinely single-line. */
|
|
28
|
+
multiline?: boolean;
|
|
20
29
|
}
|
|
21
30
|
/** `name`/`version` deliberately aren't declared here — they're read off
|
|
22
31
|
* the ability's own sibling `package.json` instead (see readManifest),
|
|
@@ -1187,11 +1187,21 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1187
1187
|
// field, so there's nothing to mistype — the same "value" form field
|
|
1188
1188
|
// name either element uses means wireEnvHandlers's own submit
|
|
1189
1189
|
// handler doesn't need to know which one it's looking at.
|
|
1190
|
+
// A naturally multi-line value (AbilityEnvDecl.multiline, e.g. a
|
|
1191
|
+
// pasted JSON key file) gets a <textarea> — a single-line <input>
|
|
1192
|
+
// (even type="text") strips embedded line breaks from its value
|
|
1193
|
+
// entirely per the HTML spec's own value sanitization, mangling
|
|
1194
|
+
// exactly this kind of paste. No password-style masking for it
|
|
1195
|
+
// either — <textarea> has no such mode, and masking a large blob
|
|
1196
|
+
// secret buys little anyway compared to being able to paste it
|
|
1197
|
+
// correctly at all.
|
|
1190
1198
|
var fieldHtml = v.options
|
|
1191
1199
|
? '<select name="value">' + v.options.map(function (opt) {
|
|
1192
1200
|
return '<option value="' + escapeHtml(opt) + '"' + (v.value === opt ? ' selected' : '') + '>' + escapeHtml(opt) + '</option>';
|
|
1193
1201
|
}).join('') + '</select>'
|
|
1194
|
-
:
|
|
1202
|
+
: v.multiline
|
|
1203
|
+
? '<textarea name="value" rows="8" placeholder="' + (v.set ? 'unchanged unless you paste a new value' : 'paste the full value') + '" required></textarea>'
|
|
1204
|
+
: '<input type="' + (v.secret ? 'password' : 'text') + '" name="value" placeholder="' + (v.set ? 'unchanged unless you type a new value' : 'value') + '" required>';
|
|
1195
1205
|
return '<tr>' +
|
|
1196
1206
|
'<td><code>' + escapeHtml(v.name) + '</code>' + sharedNote + '</td>' +
|
|
1197
1207
|
'<td style="max-width:320px">' + escapeHtml(v.description || '') + '</td>' +
|
package/dist/web/env-admin.d.ts
CHANGED
|
@@ -18,6 +18,11 @@ export interface DeclaredEnvVar {
|
|
|
18
18
|
* only when the declaring ability actually named one; the Admin UI
|
|
19
19
|
* renders a dropdown instead of a free-text field when this is set. */
|
|
20
20
|
options?: string[];
|
|
21
|
+
/** True for a naturally multi-line value (see AbilityEnvDecl.multiline)
|
|
22
|
+
* — the Admin UI renders a <textarea> instead of a single-line
|
|
23
|
+
* <input>, which strips embedded line breaks entirely per the HTML
|
|
24
|
+
* spec's own value sanitization. */
|
|
25
|
+
multiline?: boolean;
|
|
21
26
|
}
|
|
22
27
|
/** Every env var any ability installed for `agentName` declared it
|
|
23
28
|
* needs, merged by name. There's one .env per *project*, not per agent
|
package/dist/web/env-admin.js
CHANGED
|
@@ -66,6 +66,7 @@ export function listDeclaredEnvVars(agentName) {
|
|
|
66
66
|
set: rawValue !== undefined,
|
|
67
67
|
value: decl.secret === true ? undefined : rawValue,
|
|
68
68
|
options: decl.options,
|
|
69
|
+
multiline: decl.multiline,
|
|
69
70
|
});
|
|
70
71
|
}
|
|
71
72
|
}
|
|
@@ -81,13 +82,26 @@ function envFilePath() {
|
|
|
81
82
|
return join(process.cwd(), '.env');
|
|
82
83
|
}
|
|
83
84
|
// Node's own --env-file parser (what bin/cli.ts's runTsx already passes
|
|
84
|
-
// as --env-file-if-exists=.env)
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
85
|
+
// as --env-file-if-exists=.env) needs a value containing whitespace, '#',
|
|
86
|
+
// or a quote character quoted, so it reads back as one value instead of
|
|
87
|
+
// being reinterpreted as a comment or truncated at the first space.
|
|
88
|
+
// Single-quoting wins over double — confirmed live against Node's actual
|
|
89
|
+
// parser that a single-quoted value reads back fully literally, with no
|
|
90
|
+
// backslash-escape processing inside it at all: a real embedded newline,
|
|
91
|
+
// a literal double-quote, or a literal backslash (a JSON secret like
|
|
92
|
+
// GOOGLE_APPLICATION_CREDENTIALS_JSON has all three at once) all survive
|
|
93
|
+
// untouched. Double-quoting a value with an *escaped* embedded quote does
|
|
94
|
+
// NOT round-trip — confirmed live that Node's parser has no support for
|
|
95
|
+
// \" as an escaped literal quote inside a double-quoted value; it just
|
|
96
|
+
// ends the value there instead (`X="a\"b"` reads back as only `a\`,
|
|
97
|
+
// silently dropping `b"` entirely). The double-quote fallback below is
|
|
98
|
+
// only reached when the value itself contains a literal single quote —
|
|
99
|
+
// the one character single-quoting can't represent at all.
|
|
88
100
|
function serializeEnvValue(value) {
|
|
89
101
|
if (!/[\s#"'\\]/.test(value) && value !== '')
|
|
90
102
|
return value;
|
|
103
|
+
if (!value.includes("'"))
|
|
104
|
+
return `'${value}'`;
|
|
91
105
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
92
106
|
}
|
|
93
107
|
/** Upserts `NAME=VALUE` into the project's `.env` file — preserving
|
package/dist/web/playground.js
CHANGED
|
@@ -175,6 +175,89 @@ export const playgroundHtml = `<!doctype html>
|
|
|
175
175
|
.question-answer-row input { flex: 1; font-size: 12px; }
|
|
176
176
|
.question-answer-row button { font-size: 12px; padding: 5px 12px; }
|
|
177
177
|
.question-status { font-size: 12px; font-style: italic; color: light-dark(#666, #999); }
|
|
178
|
+
/* An assistant message's rendered markdown (renderMessageMarkdown) —
|
|
179
|
+
tight margins since .msg-body already pads the bubble; a nested
|
|
180
|
+
paragraph/heading with normal margins would double up on that. */
|
|
181
|
+
.msg-body p { margin: 6px 0; }
|
|
182
|
+
.msg-body p:first-child { margin-top: 0; }
|
|
183
|
+
.msg-body p:last-child { margin-bottom: 0; }
|
|
184
|
+
.msg-body h1, .msg-body h2, .msg-body h3, .msg-body h4 { margin: 10px 0 4px; font-size: 14px; }
|
|
185
|
+
.msg-body code { font-family: ui-monospace, monospace; font-size: 12px; background: light-dark(#f3f3f4, #333); padding: 1px 4px; border-radius: 4px; }
|
|
186
|
+
.msg-body pre { background: light-dark(#f3f3f4, #26262b); border-radius: 6px; padding: 8px; overflow-x: auto; }
|
|
187
|
+
.msg-body pre code { background: none; padding: 0; }
|
|
188
|
+
.msg-body a { color: light-dark(#2563eb, #60a5fa); }
|
|
189
|
+
/* An image immediately followed by a download link (see
|
|
190
|
+
renderMessageMarkdown's own doc comment) merges into this — full
|
|
191
|
+
bubble width, a corner download icon that's a plain <a href> (a real
|
|
192
|
+
Content-Disposition: attachment response, so a plain click already
|
|
193
|
+
downloads correctly with no JS or same-origin requirement), and the
|
|
194
|
+
image itself opens openImageLightbox on click rather than
|
|
195
|
+
navigating anywhere. */
|
|
196
|
+
/* inline-block, not block — the wrap's own box needs to shrink to the
|
|
197
|
+
image's actual rendered width (capped by max-height below, so a
|
|
198
|
+
landscape shot is much narrower than the full chat bubble) so the
|
|
199
|
+
corner download icon, positioned relative to this wrap, lands on the
|
|
200
|
+
image's real visible edge instead of floating in empty space beside
|
|
201
|
+
a narrower image inside a full-width box. */
|
|
202
|
+
.msg-preview-wrap { position: relative; display: inline-block; max-width: 100%; margin: 6px 0; }
|
|
203
|
+
.msg-preview-img { display: block; max-width: 100%; max-height: 260px; width: auto; height: auto; border-radius: 8px; cursor: zoom-in; }
|
|
204
|
+
.msg-download-icon {
|
|
205
|
+
position: absolute;
|
|
206
|
+
top: 8px;
|
|
207
|
+
right: 8px;
|
|
208
|
+
width: 30px;
|
|
209
|
+
height: 30px;
|
|
210
|
+
display: flex;
|
|
211
|
+
align-items: center;
|
|
212
|
+
justify-content: center;
|
|
213
|
+
background: rgba(0, 0, 0, 0.6);
|
|
214
|
+
color: #fff;
|
|
215
|
+
border-radius: 50%;
|
|
216
|
+
text-decoration: none;
|
|
217
|
+
font-size: 15px;
|
|
218
|
+
line-height: 1;
|
|
219
|
+
}
|
|
220
|
+
.msg-download-icon:hover { background: rgba(0, 0, 0, 0.8); }
|
|
221
|
+
.lightbox-overlay {
|
|
222
|
+
display: none;
|
|
223
|
+
position: fixed;
|
|
224
|
+
inset: 0;
|
|
225
|
+
background: rgba(0, 0, 0, 0.85);
|
|
226
|
+
z-index: 1000;
|
|
227
|
+
align-items: center;
|
|
228
|
+
justify-content: center;
|
|
229
|
+
padding: 48px;
|
|
230
|
+
}
|
|
231
|
+
.lightbox-overlay.open { display: flex; }
|
|
232
|
+
.lightbox-inner { position: relative; max-width: 100%; max-height: 100%; }
|
|
233
|
+
.lightbox-inner img { display: block; max-width: 100%; max-height: 85vh; border-radius: 8px; }
|
|
234
|
+
.lightbox-download {
|
|
235
|
+
position: absolute;
|
|
236
|
+
top: 10px;
|
|
237
|
+
right: 10px;
|
|
238
|
+
display: flex;
|
|
239
|
+
align-items: center;
|
|
240
|
+
gap: 6px;
|
|
241
|
+
padding: 8px 14px;
|
|
242
|
+
background: rgba(0, 0, 0, 0.7);
|
|
243
|
+
color: #fff;
|
|
244
|
+
border-radius: 8px;
|
|
245
|
+
text-decoration: none;
|
|
246
|
+
font-size: 13px;
|
|
247
|
+
}
|
|
248
|
+
.lightbox-download:hover { background: rgba(0, 0, 0, 0.9); }
|
|
249
|
+
.lightbox-close {
|
|
250
|
+
position: absolute;
|
|
251
|
+
top: -40px;
|
|
252
|
+
right: 0;
|
|
253
|
+
background: none;
|
|
254
|
+
border: none;
|
|
255
|
+
color: #fff;
|
|
256
|
+
font-size: 28px;
|
|
257
|
+
line-height: 1;
|
|
258
|
+
cursor: pointer;
|
|
259
|
+
padding: 4px 8px;
|
|
260
|
+
}
|
|
178
261
|
.dot {
|
|
179
262
|
width: 6px;
|
|
180
263
|
height: 6px;
|
|
@@ -280,6 +363,13 @@ export const playgroundHtml = `<!doctype html>
|
|
|
280
363
|
<div class="pane-body" id="timelinePane"></div>
|
|
281
364
|
</section>
|
|
282
365
|
</main>
|
|
366
|
+
<div class="lightbox-overlay" id="lightboxOverlay">
|
|
367
|
+
<div class="lightbox-inner">
|
|
368
|
+
<button type="button" class="lightbox-close" id="lightboxClose" aria-label="Close">×</button>
|
|
369
|
+
<img id="lightboxImage" src="" alt="">
|
|
370
|
+
<a id="lightboxDownload" class="lightbox-download" href="" target="_blank" rel="noopener noreferrer">⇩ Download</a>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
283
373
|
<script>
|
|
284
374
|
(function () {
|
|
285
375
|
var agentSelect = document.getElementById('agentSelect');
|
|
@@ -338,6 +428,199 @@ export const playgroundHtml = `<!doctype html>
|
|
|
338
428
|
pane.appendChild(p);
|
|
339
429
|
}
|
|
340
430
|
|
|
431
|
+
function escapeHtml(s) {
|
|
432
|
+
return String(s).replace(/[&<>"']/g, function (c) {
|
|
433
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// A small, deliberately non-CommonMark markdown-to-HTML renderer for
|
|
438
|
+
// an assistant message's own reply text (see appendChatMessage below —
|
|
439
|
+
// only ever applied to role: 'assistant', never to a user's own typed
|
|
440
|
+
// message or a raw error string). Same design and same escape-safety
|
|
441
|
+
// reasoning as web/agents-config-page.ts's own renderMarkdownPreview —
|
|
442
|
+
// duplicated here rather than shared since these are two separate
|
|
443
|
+
// self-contained pages with no shared module between them. Covers
|
|
444
|
+
// headings, bold/italic, inline code, fenced code blocks, links,
|
|
445
|
+
// images, and lists — good enough for what an agent's own final reply
|
|
446
|
+
// realistically contains, not a spec-complete parser. Escapes first,
|
|
447
|
+
// so no markdown source can inject raw HTML.
|
|
448
|
+
//
|
|
449
|
+
// The one thing beyond plain CommonMark: an image immediately followed
|
|
450
|
+
// on its own line by a link (see lp-product-ad-images' own SKILL.md,
|
|
451
|
+
// "After generating") merges into one widget — a full-bubble-width
|
|
452
|
+
// preview with its own corner download icon, the image itself opening
|
|
453
|
+
// openImageLightbox on click instead of navigating anywhere. The
|
|
454
|
+
// download icon (inline or in the lightbox) is a plain <a href> to a
|
|
455
|
+
// separately-signed URL carrying its own Content-Disposition:
|
|
456
|
+
// attachment — a real HTTP response header, so a plain click already
|
|
457
|
+
// downloads correctly regardless of origin, no JS fetch/blob trick or
|
|
458
|
+
// same-origin requirement needed.
|
|
459
|
+
function renderMessageMarkdown(md) {
|
|
460
|
+
var lines = escapeHtml(md).split('\\n');
|
|
461
|
+
var html = '';
|
|
462
|
+
var inCode = false;
|
|
463
|
+
var codeBuffer = '';
|
|
464
|
+
var listType = null;
|
|
465
|
+
var paragraph = [];
|
|
466
|
+
|
|
467
|
+
// Same "build patterns from a runtime backslash character, never a
|
|
468
|
+
// literal backslash-letter sequence in this file's own source" gotcha
|
|
469
|
+
// web/agents-config-page.ts's own renderMarkdownPreview already
|
|
470
|
+
// documents in full — this page is just as much an outer template
|
|
471
|
+
// literal whose own output is itself parsed as JS a second time.
|
|
472
|
+
var bs = String.fromCharCode(92);
|
|
473
|
+
var inlineCodePattern = new RegExp('\\u0060([^\\u0060]+)\\u0060', 'g');
|
|
474
|
+
var fencePattern = new RegExp('^\\u0060\\u0060\\u0060');
|
|
475
|
+
var boldPattern = new RegExp(bs + '*' + bs + '*([^*]+)' + bs + '*' + bs + '*', 'g');
|
|
476
|
+
var italicPattern = new RegExp(bs + '*([^*]+)' + bs + '*', 'g');
|
|
477
|
+
var imagePattern = new RegExp('!' + bs + '[([^' + bs + ']]*)' + bs + ']' + bs + '(([^)]+)' + bs + ')', 'g');
|
|
478
|
+
var linkPattern = new RegExp(bs + '[([^' + bs + ']]+)' + bs + ']' + bs + '(([^)]+)' + bs + ')', 'g');
|
|
479
|
+
var headingPattern = new RegExp('^(#{1,6})' + bs + 's+(.*)$');
|
|
480
|
+
var hrPattern = new RegExp('^(---|' + bs + '*' + bs + '*' + bs + '*)' + bs + 's*$');
|
|
481
|
+
var ulPattern = new RegExp('^[-*]' + bs + 's+(.*)$');
|
|
482
|
+
var olPattern = new RegExp('^' + bs + 'd+' + bs + '.' + bs + 's+(.*)$');
|
|
483
|
+
// Runs before linkPattern — an already-replaced image's own <img> tag
|
|
484
|
+
// has no literal "[...](...)" text left in it for linkPattern to
|
|
485
|
+
// also (mis)match.
|
|
486
|
+
function inlineFormat(text) {
|
|
487
|
+
return text
|
|
488
|
+
.replace(inlineCodePattern, '<code>$1</code>')
|
|
489
|
+
.replace(boldPattern, '<strong>$1</strong>')
|
|
490
|
+
.replace(italicPattern, '<em>$1</em>')
|
|
491
|
+
.replace(imagePattern, '<img src="$2" alt="$1" class="msg-plain-img">')
|
|
492
|
+
.replace(linkPattern, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
493
|
+
}
|
|
494
|
+
function flushParagraph() {
|
|
495
|
+
if (paragraph.length) {
|
|
496
|
+
html += '<p>' + inlineFormat(paragraph.join(' ')) + '</p>';
|
|
497
|
+
paragraph = [];
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function closeList() {
|
|
501
|
+
if (listType) {
|
|
502
|
+
html += '</' + listType + '>';
|
|
503
|
+
listType = null;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
for (var i = 0; i < lines.length; i++) {
|
|
508
|
+
var line = lines[i];
|
|
509
|
+
if (fencePattern.test(line)) {
|
|
510
|
+
if (!inCode) {
|
|
511
|
+
flushParagraph();
|
|
512
|
+
closeList();
|
|
513
|
+
inCode = true;
|
|
514
|
+
codeBuffer = '';
|
|
515
|
+
} else {
|
|
516
|
+
html += '<pre><code>' + codeBuffer + '</code></pre>';
|
|
517
|
+
inCode = false;
|
|
518
|
+
}
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (inCode) {
|
|
522
|
+
codeBuffer += line + '\\n';
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
var heading = line.match(headingPattern);
|
|
527
|
+
if (heading) {
|
|
528
|
+
flushParagraph();
|
|
529
|
+
closeList();
|
|
530
|
+
var level = heading[1].length;
|
|
531
|
+
html += '<h' + level + '>' + inlineFormat(heading[2]) + '</h' + level + '>';
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (hrPattern.test(line)) {
|
|
535
|
+
flushParagraph();
|
|
536
|
+
closeList();
|
|
537
|
+
html += '<hr>';
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
var ul = line.match(ulPattern);
|
|
541
|
+
var ol = line.match(olPattern);
|
|
542
|
+
if (ul || ol) {
|
|
543
|
+
flushParagraph();
|
|
544
|
+
var wantType = ul ? 'ul' : 'ol';
|
|
545
|
+
if (listType !== wantType) {
|
|
546
|
+
closeList();
|
|
547
|
+
html += '<' + wantType + '>';
|
|
548
|
+
listType = wantType;
|
|
549
|
+
}
|
|
550
|
+
html += '<li>' + inlineFormat((ul || ol)[1]) + '</li>';
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
closeList();
|
|
554
|
+
|
|
555
|
+
if (line.trim() === '') {
|
|
556
|
+
flushParagraph();
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
paragraph.push(line.trim());
|
|
560
|
+
}
|
|
561
|
+
flushParagraph();
|
|
562
|
+
closeList();
|
|
563
|
+
|
|
564
|
+
// The merge step: an <img class="msg-plain-img"> directly followed
|
|
565
|
+
// by (only whitespace between) an <a href>...</a> becomes one
|
|
566
|
+
// preview widget instead of two unrelated-looking elements. Built
|
|
567
|
+
// with new RegExp(string) rather than a /pattern/ literal — a
|
|
568
|
+
// literal's own closing "/" inside "</a>" would need escaping, and
|
|
569
|
+
// "\/" hits the exact same silently-dropped-backslash problem as
|
|
570
|
+
// "\d"/"\s" do (see this function's own header comment) since "/"
|
|
571
|
+
// isn't a recognized string escape character either. A plain string
|
|
572
|
+
// needs no such escaping for "/" at all.
|
|
573
|
+
var previewMergePattern = new RegExp(
|
|
574
|
+
'<img src="([^"]*)" alt="([^"]*)" class="msg-plain-img">' + bs + 's*<a href="([^"]*)"[^>]*>[^<]*</a>',
|
|
575
|
+
'g',
|
|
576
|
+
);
|
|
577
|
+
html = html.replace(previewMergePattern, function (match, src, alt, href) {
|
|
578
|
+
return '<span class="msg-preview-wrap">' +
|
|
579
|
+
'<img src="' + src + '" alt="' + alt + '" class="msg-preview-img" data-full-src="' + src + '" data-download-href="' + href + '">' +
|
|
580
|
+
'<a href="' + href + '" class="msg-download-icon" target="_blank" rel="noopener noreferrer" title="Download" aria-label="Download">⇩</a>' +
|
|
581
|
+
'</span>';
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
return html;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
var lightboxOverlay = document.getElementById('lightboxOverlay');
|
|
588
|
+
var lightboxImage = document.getElementById('lightboxImage');
|
|
589
|
+
var lightboxDownload = document.getElementById('lightboxDownload');
|
|
590
|
+
var lightboxClose = document.getElementById('lightboxClose');
|
|
591
|
+
|
|
592
|
+
function openImageLightbox(src, downloadHref) {
|
|
593
|
+
lightboxImage.src = src;
|
|
594
|
+
lightboxDownload.href = downloadHref;
|
|
595
|
+
lightboxOverlay.classList.add('open');
|
|
596
|
+
}
|
|
597
|
+
function closeImageLightbox() {
|
|
598
|
+
lightboxOverlay.classList.remove('open');
|
|
599
|
+
lightboxImage.src = '';
|
|
600
|
+
}
|
|
601
|
+
lightboxClose.addEventListener('click', closeImageLightbox);
|
|
602
|
+
// Clicking the darkened backdrop closes it; clicking the image/download
|
|
603
|
+
// icon/close button (all inside .lightbox-inner) must not, since a
|
|
604
|
+
// click anywhere inside that inner box would otherwise bubble up to
|
|
605
|
+
// this same listener on the overlay and immediately close what was
|
|
606
|
+
// just opened.
|
|
607
|
+
lightboxOverlay.addEventListener('click', function (ev) {
|
|
608
|
+
if (ev.target === lightboxOverlay) closeImageLightbox();
|
|
609
|
+
});
|
|
610
|
+
document.addEventListener('keydown', function (ev) {
|
|
611
|
+
if (ev.key === 'Escape' && lightboxOverlay.classList.contains('open')) closeImageLightbox();
|
|
612
|
+
});
|
|
613
|
+
// Delegated on chatPane, not per-image — messages (and their preview
|
|
614
|
+
// widgets) get created continuously as the conversation goes on, and a
|
|
615
|
+
// single listener here covers every one of them without needing to be
|
|
616
|
+
// rewired after each new message.
|
|
617
|
+
chatPane.addEventListener('click', function (ev) {
|
|
618
|
+
var target = ev.target;
|
|
619
|
+
if (target && target.classList && target.classList.contains('msg-preview-img')) {
|
|
620
|
+
openImageLightbox(target.getAttribute('data-full-src'), target.getAttribute('data-download-href'));
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
341
624
|
function appendChatMessage(role, text) {
|
|
342
625
|
clearEmptyHint(chatPane);
|
|
343
626
|
var div = document.createElement('div');
|
|
@@ -347,7 +630,15 @@ export const playgroundHtml = `<!doctype html>
|
|
|
347
630
|
label.textContent = role;
|
|
348
631
|
var body = document.createElement('div');
|
|
349
632
|
body.className = 'msg-body';
|
|
350
|
-
|
|
633
|
+
// Only an assistant's own reply gets rendered as markdown — a user's
|
|
634
|
+
// typed message is shown exactly as typed (no reinterpreting
|
|
635
|
+
// whatever they literally typed as formatting), and an error string
|
|
636
|
+
// is plain diagnostic text, not content worth rendering.
|
|
637
|
+
if (role === 'assistant') {
|
|
638
|
+
body.innerHTML = renderMessageMarkdown(text);
|
|
639
|
+
} else {
|
|
640
|
+
body.textContent = text;
|
|
641
|
+
}
|
|
351
642
|
div.appendChild(label);
|
|
352
643
|
div.appendChild(body);
|
|
353
644
|
chatPane.appendChild(div);
|