Jelajahi Sumber

feat: an image viewer in the editor, and a re-run mark on the row it belongs to

Clicking a thumbnail opened a browser tab. It now opens a viewer in the app:
fit to window by default, an actual-size toggle that scrolls, download, Esc or
a backdrop click to close, and arrow keys with a counter when a node produced
several images.

It renders through a portal onto document.body. The results panel scrolls and
clips, so an overlay inside it is cut off at the panel's edges however high its
z-index - the portal is what makes it full-window rather than panel-sized.

It also finds images it could not before. Only file-store ids and image URLs
were recognised, so base64 in an output - a generated image, an attachment, a
node answering with b64_json - rendered as a wall of characters and nothing
else. Detection is by field name plus a magic-byte check of the base64 itself,
deliberately not by sniffing every long string: an OCR text attachment is
base64 too, and a credential drawn as a broken thumbnail would be worse than
not drawing it at all.

Base64 becomes a blob rather than a data: URL because a browser will not open a
data: URL as a top-level navigation - the thumbnail would render and the click
would do nothing. The resolved src is reported upward, so the viewer shows the
bytes the thumbnail already has instead of fetching a stored file a second
time. Both resolvers hold their callback in a ref: the parent passes a fresh
arrow each render, and depending on it would re-fetch on every render.

Separately, a node row whose historical status is "failed" now carries a small
"re-run ok" mark when a re-run of that same node has since succeeded. The
record was never stale - a re-run is a separate execution and does not rewrite
this one, which is verifiable: the original re-reads as failed afterwards. But
the two sat side by side with nothing saying they were different runs, so a red
badge read as the node's current state. The mark says which is which without
changing what either records.
fszontagh 3 minggu lalu
induk
melakukan
e91ff8d793

+ 29 - 0
webui/src/components/workflow/ExecutionResultsPanel.tsx

@@ -338,6 +338,7 @@ export function ExecutionResultsPanel({
             )}
 
             <StatusIcon status={nodeState.status} />
+            {rerunNote(node.id, isLoopBodyNode ? getSelectedIteration() : undefined)}
 
             <div className="flex-1 min-w-0">
               <span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate block">
@@ -507,6 +508,33 @@ export function ExecutionResultsPanel({
     )
   }
 
+  // A node whose historical row says "failed" while a re-run of that exact node
+  // succeeded a moment ago reads as a contradiction. The record is right - a
+  // re-run is a separate execution and does not rewrite this one - but the two
+  // sit next to each other with nothing saying they are different runs. This
+  // marks the row so the badge is not read as the current state of the node.
+  function rerunNote(nodeId: string, iteration: number | undefined) {
+    if (!rerunResult || rerunResult.nodeId !== nodeId) return null
+    if (rerunResult.iteration !== iteration) return null
+    if (rerunResult.status === 'running') return null
+    const ok = rerunResult.status !== 'failed'
+    return (
+      <span
+        title={ok
+          ? 'A re-run of this node succeeded - see the re-run result above. This row is the original execution and is unchanged.'
+          : 'A re-run of this node also failed - see the re-run result above. This row is the original execution and is unchanged.'}
+        className={`shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${
+          ok
+            ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
+            : 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-300'
+        }`}
+      >
+        <RotateCw className="w-2.5 h-2.5" />
+        {ok ? 're-run ok' : 're-run failed'}
+      </span>
+    )
+  }
+
   // A single node's result row inside an expanded iteration group. Simpler
   // than renderNodeRow: no loop-of-its-own concerns, since a node that owns
   // a loop cannot itself be a member of another iteration in this data.
@@ -540,6 +568,7 @@ export function ExecutionResultsPanel({
             <span className="w-3.5 h-3.5 shrink-0" />
           )}
           <StatusIcon status={exec.status} />
+          {rerunNote(exec.nodeId, group.iteration)}
           <span className="flex-1 min-w-0 text-xs text-gray-800 dark:text-gray-200 truncate">
             {label}
           </span>

+ 125 - 0
webui/src/components/workflow/ImageLightbox.tsx

@@ -0,0 +1,125 @@
+import { useCallback, useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { X, ChevronLeft, ChevronRight, Maximize2, Minimize2, Download } from 'lucide-react'
+
+export interface LightboxImage {
+  src: string
+  title: string
+}
+
+interface Props {
+  images: LightboxImage[]
+  index: number
+  onClose: () => void
+  onIndexChange: (next: number) => void
+}
+
+/**
+ * A full-window image viewer, inside the app.
+ *
+ * Rendered through a portal onto document.body rather than in place: the
+ * results panel is a scrolling, clipping container, and an overlay inside it
+ * would be cut off at its edges however high its z-index.
+ *
+ * Fit-to-window by default, because the common case is "what did this node
+ * produce" and the whole picture answers it. Actual size is a click away for
+ * looking closely, and then the image scrolls.
+ */
+export function ImageLightbox({ images, index, onClose, onIndexChange }: Props) {
+  const [actualSize, setActualSize] = useState(false)
+  const image = images[index]
+  const many = images.length > 1
+
+  const step = useCallback((delta: number) => {
+    // Wraps, so paging through a set of generated images never dead-ends.
+    onIndexChange((index + delta + images.length) % images.length)
+    setActualSize(false)
+  }, [index, images.length, onIndexChange])
+
+  useEffect(() => {
+    const onKey = (event: KeyboardEvent) => {
+      if (event.key === 'Escape') onClose()
+      else if (event.key === 'ArrowRight' && many) step(1)
+      else if (event.key === 'ArrowLeft' && many) step(-1)
+    }
+    window.addEventListener('keydown', onKey)
+    // The page behind must not scroll while this is open, or a trackpad
+    // gesture aimed at the image moves the panel underneath it instead.
+    const previousOverflow = document.body.style.overflow
+    document.body.style.overflow = 'hidden'
+    return () => {
+      window.removeEventListener('keydown', onKey)
+      document.body.style.overflow = previousOverflow
+    }
+  }, [onClose, step, many])
+
+  if (!image) return null
+
+  return createPortal(
+    <div
+      className="fixed inset-0 z-[100] flex flex-col bg-black/85 backdrop-blur-sm"
+      // Only a click on the backdrop itself closes. Without the target check a
+      // click that started on the image and drifted would close it too.
+      onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
+      role="dialog"
+      aria-modal="true"
+      aria-label={image.title}
+    >
+      <div className="flex items-center gap-2 px-4 py-2 text-white/90 text-xs">
+        <span className="flex-1 min-w-0 truncate font-mono" title={image.title}>{image.title}</span>
+        {many && <span className="shrink-0 tabular-nums text-white/60">{index + 1} / {images.length}</span>}
+        <button
+          onClick={() => setActualSize((v) => !v)}
+          title={actualSize ? 'Fit to window' : 'Actual size'}
+          className="shrink-0 p-1.5 rounded hover:bg-white/15"
+        >
+          {actualSize ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
+        </button>
+        <a
+          href={image.src}
+          download={image.title.replace(/[^\w.-]+/g, '_') || 'image'}
+          title="Download"
+          className="shrink-0 p-1.5 rounded hover:bg-white/15"
+        >
+          <Download className="w-4 h-4" />
+        </a>
+        <button onClick={onClose} title="Close (Esc)" className="shrink-0 p-1.5 rounded hover:bg-white/15">
+          <X className="w-4 h-4" />
+        </button>
+      </div>
+
+      <div
+        className={`flex-1 min-h-0 flex items-center justify-center px-4 pb-4 ${actualSize ? 'overflow-auto' : 'overflow-hidden'}`}
+        onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
+      >
+        <img
+          src={image.src}
+          alt={image.title}
+          className={actualSize
+            ? 'max-w-none rounded shadow-2xl'
+            : 'max-h-full max-w-full object-contain rounded shadow-2xl'}
+        />
+      </div>
+
+      {many && (
+        <>
+          <button
+            onClick={() => step(-1)}
+            title="Previous (left arrow)"
+            className="absolute left-2 top-1/2 -translate-y-1/2 p-2 rounded-full bg-black/40 text-white hover:bg-black/70"
+          >
+            <ChevronLeft className="w-6 h-6" />
+          </button>
+          <button
+            onClick={() => step(1)}
+            title="Next (right arrow)"
+            className="absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-full bg-black/40 text-white hover:bg-black/70"
+          >
+            <ChevronRight className="w-6 h-6" />
+          </button>
+        </>
+      )}
+    </div>,
+    document.body
+  )
+}

+ 161 - 11
webui/src/components/workflow/NodeOutputView.tsx

@@ -1,6 +1,7 @@
-import { useEffect, useState } from 'react'
-import { ImageOff } from 'lucide-react'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { ImageOff, Maximize2 } from 'lucide-react'
 import { api } from '../../api/client'
+import { ImageLightbox } from './ImageLightbox'
 
 // A picture an execution produced, referenced rather than embedded. Node outputs
 // no longer carry base64 - they carry a file-store id, or the address the image
@@ -9,10 +10,38 @@ interface ImageRef {
   key: string
   fileId?: string
   url?: string
+  // Base64 bytes carried in the output itself. A generated image on its way to
+  // the file store, an attachment, a node that answers with b64_json - none of
+  // these have an id or an address yet, so before this they showed as a wall of
+  // characters in the JSON and nothing else.
+  base64?: string
+  mimeType?: string
 }
 
 const IMAGE_URL = /\.(png|jpe?g|gif|webp|avif|bmp)(\?|$)/i
 
+// A data: URL carries its own type, so it can be shown as it stands.
+const DATA_URL = /^data:image\/[a-z0-9.+-]+;base64,/i
+
+// Fields that hold image bytes. Matched by name rather than by sniffing every
+// long string: a base64 blob and a long token look alike, and rendering a
+// credential as a broken image would be worse than not rendering it.
+const BASE64_FIELDS = [
+  'imageBase64', 'contentBase64', 'base64Data', 'base64', 'b64_json', 'imageData',
+]
+
+// The first bytes of the common formats, base64-encoded. Used to tell an image
+// from any other base64 - an OCR text attachment is base64 too, and showing it
+// as a broken thumbnail helps nobody.
+function imageTypeOf(base64: string): string | null {
+  if (base64.startsWith('iVBORw0KGgo')) return 'image/png'
+  if (base64.startsWith('/9j/')) return 'image/jpeg'
+  if (base64.startsWith('R0lGOD')) return 'image/gif'
+  if (base64.startsWith('UklGR')) return 'image/webp'
+  if (base64.startsWith('Qk')) return 'image/bmp'
+  return null
+}
+
 function collectImageRefs(value: unknown, depth = 0, found: ImageRef[] = []): ImageRef[] {
   if (!value || typeof value !== 'object' || depth > 4 || found.length >= 4) {
     return found
@@ -31,10 +60,36 @@ function collectImageRefs(value: unknown, depth = 0, found: ImageRef[] = []): Im
   if (typeof obj.fileId === 'string' && obj.fileId.length > 0) {
     found.push({ key: obj.fileId, fileId: obj.fileId })
   } else {
+    let matched = false
     for (const field of ['sourceUrl', 'url']) {
       const candidate = obj[field]
-      if (typeof candidate === 'string' && IMAGE_URL.test(candidate)) {
+      if (typeof candidate === 'string' && (IMAGE_URL.test(candidate) || DATA_URL.test(candidate))) {
         found.push({ key: candidate, url: candidate })
+        matched = true
+        break
+      }
+    }
+
+    if (!matched) {
+      for (const field of BASE64_FIELDS) {
+        const candidate = obj[field]
+        if (typeof candidate !== 'string' || candidate.length < 32) continue
+        // A stored execution replaces long strings with "[omitted N bytes]",
+        // so most historical records have no bytes to show - only a live run
+        // or a re-run does. Nothing to render is not an error.
+        if (candidate.startsWith('[omitted')) continue
+
+        const inline = DATA_URL.test(candidate)
+        const type = inline ? null : imageTypeOf(candidate)
+        if (!inline && !type) continue
+
+        found.push({
+          key: field + ':' + candidate.slice(0, 24),
+          base64: inline ? candidate.slice(candidate.indexOf(',') + 1) : candidate,
+          mimeType: inline
+            ? candidate.slice(5, candidate.indexOf(';'))
+            : (typeof obj.mimeType === 'string' ? obj.mimeType : type!),
+        })
         break
       }
     }
@@ -50,10 +105,18 @@ function collectImageRefs(value: unknown, depth = 0, found: ImageRef[] = []): Im
 // Stored files sit behind the authenticated API, and an img tag cannot send the
 // bearer token, so the bytes are fetched through the API client and handed to
 // the tag as an object URL.
-function StoredImage({ fileId }: { fileId: string }) {
+function StoredImage({ fileId, onReady, onOpen }: { fileId: string; onReady: (src: string) => void; onOpen: () => void }) {
   const [src, setSrc] = useState<string | null>(null)
   const [failed, setFailed] = useState(false)
 
+  // Held in a ref rather than listed as a dependency. The caller passes a fresh
+  // arrow every render, so depending on it would re-fetch the file on every
+  // render - and listing it while ignoring that is what the lint rule is
+  // warning about. The ref keeps the effect keyed to the fileId alone, which is
+  // the only thing that should cause a re-fetch.
+  const onReadyRef = useRef(onReady)
+  onReadyRef.current = onReady
+
   useEffect(() => {
     let objectUrl: string | null = null
     let cancelled = false
@@ -64,6 +127,7 @@ function StoredImage({ fileId }: { fileId: string }) {
         if (cancelled) return
         objectUrl = URL.createObjectURL(response.data)
         setSrc(objectUrl)
+        onReadyRef.current(objectUrl)
       })
       .catch(() => {
         if (!cancelled) setFailed(true)
@@ -88,41 +152,127 @@ function StoredImage({ fileId }: { fileId: string }) {
     return <div className="h-24 w-24 rounded bg-gray-100 dark:bg-slate-700 animate-pulse" />
   }
 
-  return <Preview src={src} title={`file ${fileId}`} />
+  return <Preview src={src} title={`file ${fileId}`} onOpen={onOpen} />
 }
 
-function Preview({ src, title }: { src: string; title: string }) {
+// Bytes carried in the output. Turned into a blob URL rather than used as a
+// data: URL directly, because browsers refuse to open a data: URL as a
+// top-level navigation - the thumbnail would render and clicking it would do
+// nothing at all.
+function InlineImage({ base64, mimeType, onReady, onOpen }: { base64: string; mimeType: string; onReady: (src: string) => void; onOpen: () => void }) {
+  const [src, setSrc] = useState<string | null>(null)
+  const [failed, setFailed] = useState(false)
+
+  // See StoredImage: kept in a ref so decoding is keyed to the bytes, not to
+  // the identity of a callback the parent recreates on every render.
+  const onReadyRef = useRef(onReady)
+  onReadyRef.current = onReady
+
+  useEffect(() => {
+    let objectUrl: string | null = null
+    try {
+      const binary = atob(base64)
+      const bytes = new Uint8Array(binary.length)
+      for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
+      objectUrl = URL.createObjectURL(new Blob([bytes], { type: mimeType }))
+      setSrc(objectUrl)
+      onReadyRef.current(objectUrl)
+    } catch {
+      // Not decodable - a truncated or mislabelled value. Nothing to show.
+      setFailed(true)
+    }
+    return () => {
+      if (objectUrl) URL.revokeObjectURL(objectUrl)
+    }
+  }, [base64, mimeType])
+
+  if (failed || !src) return null
+  return <Preview src={src} title={mimeType} onOpen={onOpen} />
+}
+
+function Preview({ src, title, onOpen }: { src: string; title: string; onOpen: () => void }) {
   const [failed, setFailed] = useState(false)
   if (failed) return null
 
   return (
-    <a href={src} target="_blank" rel="noreferrer" title={title}>
+    <button
+      type="button"
+      onClick={onOpen}
+      title={`${title} - click to view`}
+      className="group relative block rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
+    >
       <img
         src={src}
         alt={title}
         onError={() => setFailed(true)}
-        className="h-24 w-24 object-cover rounded border border-gray-200 dark:border-slate-700 hover:opacity-90"
+        className="h-24 w-24 object-cover rounded border border-gray-200 dark:border-slate-700 transition group-hover:opacity-75"
       />
-    </a>
+      <span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded opacity-0 transition group-hover:opacity-100 bg-black/40">
+        <Maximize2 className="w-4 h-4 text-white" />
+      </span>
+    </button>
   )
 }
 
 export function NodeOutputView({ value }: { value: unknown }) {
   const refs = collectImageRefs(value)
 
+  // The src each thumbnail resolved to, so the viewer shows the bytes already
+  // in hand - a stored file is not fetched twice, and base64 is decoded once.
+  const [sources, setSources] = useState<Record<string, string>>({})
+  const [openKey, setOpenKey] = useState<string | null>(null)
+
+  const remember = useCallback((key: string, src: string) => {
+    setSources((prev) => (prev[key] === src ? prev : { ...prev, [key]: src }))
+  }, [])
+
+  // Only images that have actually resolved can be paged through; one still
+  // loading would show an empty frame in the viewer.
+  const viewable = refs
+    .map((ref) => ({ key: ref.key, src: sources[ref.key] ?? ref.url, title: ref.mimeType || ref.url || ref.key }))
+    .filter((item): item is { key: string; src: string; title: string } => Boolean(item.src))
+  const openIndex = viewable.findIndex((item) => item.key === openKey)
+
   return (
     <div className="space-y-2">
       {refs.length > 0 && (
         <div className="flex flex-wrap gap-2">
           {refs.map((ref) =>
             ref.fileId ? (
-              <StoredImage key={ref.key} fileId={ref.fileId} />
+              <StoredImage
+                key={ref.key}
+                fileId={ref.fileId}
+                onReady={(src) => remember(ref.key, src)}
+                onOpen={() => setOpenKey(ref.key)}
+              />
+            ) : ref.base64 ? (
+              <InlineImage
+                key={ref.key}
+                base64={ref.base64}
+                mimeType={ref.mimeType || 'image/png'}
+                onReady={(src) => remember(ref.key, src)}
+                onOpen={() => setOpenKey(ref.key)}
+              />
             ) : (
-              <Preview key={ref.key} src={ref.url!} title={ref.url!} />
+              <Preview
+                key={ref.key}
+                src={ref.url!}
+                title={ref.url!}
+                onOpen={() => setOpenKey(ref.key)}
+              />
             )
           )}
         </div>
       )}
+
+      {openIndex >= 0 && (
+        <ImageLightbox
+          images={viewable}
+          index={openIndex}
+          onClose={() => setOpenKey(null)}
+          onIndexChange={(next) => setOpenKey(viewable[next].key)}
+        />
+      )}
       <pre className="p-2 bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded text-xs overflow-auto max-h-48 font-mono text-gray-900 dark:text-gray-100">
         {typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}
       </pre>