loopengine 0.1.49 → 0.1.51

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.
@@ -175,6 +175,96 @@ 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-plain-img (no download link — see renderMessageMarkdown's own
204
+ doc comment) gets the same capped size and click-to-zoom as the
205
+ merged .msg-preview-img, just without the corner download icon and
206
+ without a wrap element of its own (nothing needs to be positioned
207
+ relative to it) — see the chatPane click handler below and
208
+ openImageLightbox's own handling of a missing downloadHref. */
209
+ .msg-preview-img, .msg-plain-img { display: block; max-width: 100%; max-height: 260px; width: auto; height: auto; border-radius: 8px; cursor: zoom-in; }
210
+ .msg-plain-img { margin: 6px 0; }
211
+ .msg-download-icon {
212
+ position: absolute;
213
+ top: 8px;
214
+ right: 8px;
215
+ width: 30px;
216
+ height: 30px;
217
+ display: flex;
218
+ align-items: center;
219
+ justify-content: center;
220
+ background: rgba(0, 0, 0, 0.6);
221
+ color: #fff;
222
+ border-radius: 50%;
223
+ text-decoration: none;
224
+ font-size: 15px;
225
+ line-height: 1;
226
+ }
227
+ .msg-download-icon:hover { background: rgba(0, 0, 0, 0.8); }
228
+ .lightbox-overlay {
229
+ display: none;
230
+ position: fixed;
231
+ inset: 0;
232
+ background: rgba(0, 0, 0, 0.85);
233
+ z-index: 1000;
234
+ align-items: center;
235
+ justify-content: center;
236
+ padding: 48px;
237
+ }
238
+ .lightbox-overlay.open { display: flex; }
239
+ .lightbox-inner { position: relative; max-width: 100%; max-height: 100%; }
240
+ .lightbox-inner img { display: block; max-width: 100%; max-height: 85vh; border-radius: 8px; }
241
+ .lightbox-download {
242
+ position: absolute;
243
+ top: 10px;
244
+ right: 10px;
245
+ display: flex;
246
+ align-items: center;
247
+ gap: 6px;
248
+ padding: 8px 14px;
249
+ background: rgba(0, 0, 0, 0.7);
250
+ color: #fff;
251
+ border-radius: 8px;
252
+ text-decoration: none;
253
+ font-size: 13px;
254
+ }
255
+ .lightbox-download:hover { background: rgba(0, 0, 0, 0.9); }
256
+ .lightbox-close {
257
+ position: absolute;
258
+ top: -40px;
259
+ right: 0;
260
+ background: none;
261
+ border: none;
262
+ color: #fff;
263
+ font-size: 28px;
264
+ line-height: 1;
265
+ cursor: pointer;
266
+ padding: 4px 8px;
267
+ }
178
268
  .dot {
179
269
  width: 6px;
180
270
  height: 6px;
@@ -280,6 +370,13 @@ export const playgroundHtml = `<!doctype html>
280
370
  <div class="pane-body" id="timelinePane"></div>
281
371
  </section>
282
372
  </main>
373
+ <div class="lightbox-overlay" id="lightboxOverlay">
374
+ <div class="lightbox-inner">
375
+ <button type="button" class="lightbox-close" id="lightboxClose" aria-label="Close">&times;</button>
376
+ <img id="lightboxImage" src="" alt="">
377
+ <a id="lightboxDownload" class="lightbox-download" href="" target="_blank" rel="noopener noreferrer">&#8681; Download</a>
378
+ </div>
379
+ </div>
283
380
  <script>
284
381
  (function () {
285
382
  var agentSelect = document.getElementById('agentSelect');
@@ -338,6 +435,208 @@ export const playgroundHtml = `<!doctype html>
338
435
  pane.appendChild(p);
339
436
  }
340
437
 
438
+ function escapeHtml(s) {
439
+ return String(s).replace(/[&<>"']/g, function (c) {
440
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
441
+ });
442
+ }
443
+
444
+ // A small, deliberately non-CommonMark markdown-to-HTML renderer for
445
+ // an assistant message's own reply text (see appendChatMessage below —
446
+ // only ever applied to role: 'assistant', never to a user's own typed
447
+ // message or a raw error string). Same design and same escape-safety
448
+ // reasoning as web/agents-config-page.ts's own renderMarkdownPreview —
449
+ // duplicated here rather than shared since these are two separate
450
+ // self-contained pages with no shared module between them. Covers
451
+ // headings, bold/italic, inline code, fenced code blocks, links,
452
+ // images, and lists — good enough for what an agent's own final reply
453
+ // realistically contains, not a spec-complete parser. Escapes first,
454
+ // so no markdown source can inject raw HTML.
455
+ //
456
+ // The one thing beyond plain CommonMark: an image immediately followed
457
+ // on its own line by a link (see lp-product-ad-images' own SKILL.md,
458
+ // "After generating") merges into one widget — a full-bubble-width
459
+ // preview with its own corner download icon, the image itself opening
460
+ // openImageLightbox on click instead of navigating anywhere. The
461
+ // download icon (inline or in the lightbox) is a plain <a href> to a
462
+ // separately-signed URL carrying its own Content-Disposition:
463
+ // attachment — a real HTTP response header, so a plain click already
464
+ // downloads correctly regardless of origin, no JS fetch/blob trick or
465
+ // same-origin requirement needed.
466
+ function renderMessageMarkdown(md) {
467
+ var lines = escapeHtml(md).split('\\n');
468
+ var html = '';
469
+ var inCode = false;
470
+ var codeBuffer = '';
471
+ var listType = null;
472
+ var paragraph = [];
473
+
474
+ // Same "build patterns from a runtime backslash character, never a
475
+ // literal backslash-letter sequence in this file's own source" gotcha
476
+ // web/agents-config-page.ts's own renderMarkdownPreview already
477
+ // documents in full — this page is just as much an outer template
478
+ // literal whose own output is itself parsed as JS a second time.
479
+ var bs = String.fromCharCode(92);
480
+ var inlineCodePattern = new RegExp('\\u0060([^\\u0060]+)\\u0060', 'g');
481
+ var fencePattern = new RegExp('^\\u0060\\u0060\\u0060');
482
+ var boldPattern = new RegExp(bs + '*' + bs + '*([^*]+)' + bs + '*' + bs + '*', 'g');
483
+ var italicPattern = new RegExp(bs + '*([^*]+)' + bs + '*', 'g');
484
+ var imagePattern = new RegExp('!' + bs + '[([^' + bs + ']]*)' + bs + ']' + bs + '(([^)]+)' + bs + ')', 'g');
485
+ var linkPattern = new RegExp(bs + '[([^' + bs + ']]+)' + bs + ']' + bs + '(([^)]+)' + bs + ')', 'g');
486
+ var headingPattern = new RegExp('^(#{1,6})' + bs + 's+(.*)$');
487
+ var hrPattern = new RegExp('^(---|' + bs + '*' + bs + '*' + bs + '*)' + bs + 's*$');
488
+ var ulPattern = new RegExp('^[-*]' + bs + 's+(.*)$');
489
+ var olPattern = new RegExp('^' + bs + 'd+' + bs + '.' + bs + 's+(.*)$');
490
+ // Runs before linkPattern — an already-replaced image's own <img> tag
491
+ // has no literal "[...](...)" text left in it for linkPattern to
492
+ // also (mis)match.
493
+ function inlineFormat(text) {
494
+ return text
495
+ .replace(inlineCodePattern, '<code>$1</code>')
496
+ .replace(boldPattern, '<strong>$1</strong>')
497
+ .replace(italicPattern, '<em>$1</em>')
498
+ .replace(imagePattern, '<img src="$2" alt="$1" class="msg-plain-img" data-full-src="$2">')
499
+ .replace(linkPattern, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
500
+ }
501
+ function flushParagraph() {
502
+ if (paragraph.length) {
503
+ html += '<p>' + inlineFormat(paragraph.join(' ')) + '</p>';
504
+ paragraph = [];
505
+ }
506
+ }
507
+ function closeList() {
508
+ if (listType) {
509
+ html += '</' + listType + '>';
510
+ listType = null;
511
+ }
512
+ }
513
+
514
+ for (var i = 0; i < lines.length; i++) {
515
+ var line = lines[i];
516
+ if (fencePattern.test(line)) {
517
+ if (!inCode) {
518
+ flushParagraph();
519
+ closeList();
520
+ inCode = true;
521
+ codeBuffer = '';
522
+ } else {
523
+ html += '<pre><code>' + codeBuffer + '</code></pre>';
524
+ inCode = false;
525
+ }
526
+ continue;
527
+ }
528
+ if (inCode) {
529
+ codeBuffer += line + '\\n';
530
+ continue;
531
+ }
532
+
533
+ var heading = line.match(headingPattern);
534
+ if (heading) {
535
+ flushParagraph();
536
+ closeList();
537
+ var level = heading[1].length;
538
+ html += '<h' + level + '>' + inlineFormat(heading[2]) + '</h' + level + '>';
539
+ continue;
540
+ }
541
+ if (hrPattern.test(line)) {
542
+ flushParagraph();
543
+ closeList();
544
+ html += '<hr>';
545
+ continue;
546
+ }
547
+ var ul = line.match(ulPattern);
548
+ var ol = line.match(olPattern);
549
+ if (ul || ol) {
550
+ flushParagraph();
551
+ var wantType = ul ? 'ul' : 'ol';
552
+ if (listType !== wantType) {
553
+ closeList();
554
+ html += '<' + wantType + '>';
555
+ listType = wantType;
556
+ }
557
+ html += '<li>' + inlineFormat((ul || ol)[1]) + '</li>';
558
+ continue;
559
+ }
560
+ closeList();
561
+
562
+ if (line.trim() === '') {
563
+ flushParagraph();
564
+ continue;
565
+ }
566
+ paragraph.push(line.trim());
567
+ }
568
+ flushParagraph();
569
+ closeList();
570
+
571
+ // The merge step: an <img class="msg-plain-img"> directly followed
572
+ // by (only whitespace between) an <a href>...</a> becomes one
573
+ // preview widget instead of two unrelated-looking elements. Built
574
+ // with new RegExp(string) rather than a /pattern/ literal — a
575
+ // literal's own closing "/" inside "</a>" would need escaping, and
576
+ // "\/" hits the exact same silently-dropped-backslash problem as
577
+ // "\d"/"\s" do (see this function's own header comment) since "/"
578
+ // isn't a recognized string escape character either. A plain string
579
+ // needs no such escaping for "/" at all.
580
+ var previewMergePattern = new RegExp(
581
+ '<img src="([^"]*)" alt="([^"]*)" class="msg-plain-img" data-full-src="[^"]*">' + bs + 's*<a href="([^"]*)"[^>]*>[^<]*</a>',
582
+ 'g',
583
+ );
584
+ html = html.replace(previewMergePattern, function (match, src, alt, href) {
585
+ return '<span class="msg-preview-wrap">' +
586
+ '<img src="' + src + '" alt="' + alt + '" class="msg-preview-img" data-full-src="' + src + '" data-download-href="' + href + '">' +
587
+ '<a href="' + href + '" class="msg-download-icon" target="_blank" rel="noopener noreferrer" title="Download" aria-label="Download">&#8681;</a>' +
588
+ '</span>';
589
+ });
590
+
591
+ return html;
592
+ }
593
+
594
+ var lightboxOverlay = document.getElementById('lightboxOverlay');
595
+ var lightboxImage = document.getElementById('lightboxImage');
596
+ var lightboxDownload = document.getElementById('lightboxDownload');
597
+ var lightboxClose = document.getElementById('lightboxClose');
598
+
599
+ function openImageLightbox(src, downloadHref) {
600
+ lightboxImage.src = src;
601
+ // A plain image (no download link — see renderMessageMarkdown's own
602
+ // doc comment on .msg-plain-img) has no downloadHref at all; hiding
603
+ // the button beats pointing it at '' (a dead link that would just
604
+ // reload the page) or at src (opens inline, not an actual download).
605
+ if (downloadHref) {
606
+ lightboxDownload.href = downloadHref;
607
+ lightboxDownload.style.display = '';
608
+ } else {
609
+ lightboxDownload.style.display = 'none';
610
+ }
611
+ lightboxOverlay.classList.add('open');
612
+ }
613
+ function closeImageLightbox() {
614
+ lightboxOverlay.classList.remove('open');
615
+ lightboxImage.src = '';
616
+ }
617
+ lightboxClose.addEventListener('click', closeImageLightbox);
618
+ // Clicking the darkened backdrop closes it; clicking the image/download
619
+ // icon/close button (all inside .lightbox-inner) must not, since a
620
+ // click anywhere inside that inner box would otherwise bubble up to
621
+ // this same listener on the overlay and immediately close what was
622
+ // just opened.
623
+ lightboxOverlay.addEventListener('click', function (ev) {
624
+ if (ev.target === lightboxOverlay) closeImageLightbox();
625
+ });
626
+ document.addEventListener('keydown', function (ev) {
627
+ if (ev.key === 'Escape' && lightboxOverlay.classList.contains('open')) closeImageLightbox();
628
+ });
629
+ // Delegated on chatPane, not per-image — messages (and their preview
630
+ // widgets) get created continuously as the conversation goes on, and a
631
+ // single listener here covers every one of them without needing to be
632
+ // rewired after each new message.
633
+ chatPane.addEventListener('click', function (ev) {
634
+ var target = ev.target;
635
+ if (target && target.classList && (target.classList.contains('msg-preview-img') || target.classList.contains('msg-plain-img'))) {
636
+ openImageLightbox(target.getAttribute('data-full-src'), target.getAttribute('data-download-href'));
637
+ }
638
+ });
639
+
341
640
  function appendChatMessage(role, text) {
342
641
  clearEmptyHint(chatPane);
343
642
  var div = document.createElement('div');
@@ -347,7 +646,15 @@ export const playgroundHtml = `<!doctype html>
347
646
  label.textContent = role;
348
647
  var body = document.createElement('div');
349
648
  body.className = 'msg-body';
350
- body.textContent = text;
649
+ // Only an assistant's own reply gets rendered as markdown — a user's
650
+ // typed message is shown exactly as typed (no reinterpreting
651
+ // whatever they literally typed as formatting), and an error string
652
+ // is plain diagnostic text, not content worth rendering.
653
+ if (role === 'assistant') {
654
+ body.innerHTML = renderMessageMarkdown(text);
655
+ } else {
656
+ body.textContent = text;
657
+ }
351
658
  div.appendChild(label);
352
659
  div.appendChild(body);
353
660
  chatPane.appendChild(div);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",