feat: artifacts
This commit is contained in:
@@ -76,12 +76,60 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
|||||||
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
||||||
})
|
})
|
||||||
|
|
||||||
function FlowFitViewOnLoad() {
|
function FlowFitViewOnLoad({ disabled }: { disabled?: boolean }) {
|
||||||
const nodesInitialized = useNodesInitialized()
|
const nodesInitialized = useNodesInitialized()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (nodesInitialized) fitView?.({ duration: 200 })
|
if (disabled || !nodesInitialized) return
|
||||||
}, [nodesInitialized, fitView])
|
fitView?.({ duration: 200 })
|
||||||
|
}, [disabled, nodesInitialized, fitView])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function FocusNodeOnLoad({ focusNodeId }: { focusNodeId?: string }) {
|
||||||
|
const nodesInitialized = useNodesInitialized()
|
||||||
|
const { getNodes, setCenter, screenToFlowPosition, project } = useReactFlow()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!focusNodeId || !nodesInitialized) return
|
||||||
|
// Small delay so node DOM and layout are fully ready before centering/zooming.
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const nodes = getNodes()
|
||||||
|
const node = nodes.find((n) => n.id === focusNodeId)
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
const el = document.querySelector(
|
||||||
|
`.react-flow__node[data-id="${focusNodeId}"]`
|
||||||
|
) as HTMLElement | null
|
||||||
|
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const screenCenter = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
||||||
|
const toFlow = screenToFlowPosition ?? project
|
||||||
|
if (toFlow) {
|
||||||
|
const flowCenter = toFlow(screenCenter)
|
||||||
|
setCenter(flowCenter.x, flowCenter.y, { duration: 400, zoom: 1.8 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: center using node position + dimensions from React Flow state.
|
||||||
|
const anyNode = node as Node & {
|
||||||
|
positionAbsolute?: { x: number; y: number }
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
const basePos = anyNode.positionAbsolute ?? anyNode.position ?? { x: 0, y: 0 }
|
||||||
|
const width = anyNode.width ?? 0
|
||||||
|
const height = anyNode.height ?? 0
|
||||||
|
const centerX = basePos.x + width / 2
|
||||||
|
const centerY = basePos.y + height / 2
|
||||||
|
setCenter(centerX, centerY, { duration: 400, zoom: 1.8 })
|
||||||
|
}, 150)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [focusNodeId, nodesInitialized, getNodes, setCenter, screenToFlowPosition, project])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +200,11 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
|
|||||||
export type CanvasPageProps = {
|
export type CanvasPageProps = {
|
||||||
/** Optional recollection id for per-recollection graph loading */
|
/** Optional recollection id for per-recollection graph loading */
|
||||||
recollectionId?: string
|
recollectionId?: string
|
||||||
|
/** Optional node id to focus when the canvas loads (e.g. from Katalogos artifacts). */
|
||||||
|
focusNodeId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { showMinimap } = usePlatform()
|
const { showMinimap } = usePlatform()
|
||||||
const {
|
const {
|
||||||
@@ -688,7 +738,8 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
|||||||
)}
|
)}
|
||||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||||
<ViewportDisplayProvider>
|
<ViewportDisplayProvider>
|
||||||
<FlowFitViewOnLoad />
|
<FlowFitViewOnLoad disabled={Boolean(focusNodeId)} />
|
||||||
|
<FocusNodeOnLoad focusNodeId={focusNodeId} />
|
||||||
<FlowKeyboardShortcuts />
|
<FlowKeyboardShortcuts />
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Flux route: renders the graph canvas (CanvasPage) for the current recollection.
|
* Flux route: renders the graph canvas (CanvasPage) for the current recollection.
|
||||||
|
* Supports optional focusNode query param to center on a specific node.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useRef } from 'react'
|
import React, { useEffect, useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
|
||||||
export function FluxRoute() {
|
export function FluxRoute() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const { updateLastEdited } = usePlatform()
|
const { updateLastEdited } = usePlatform()
|
||||||
const updateLastEditedRef = useRef(updateLastEdited)
|
const updateLastEditedRef = useRef(updateLastEdited)
|
||||||
updateLastEditedRef.current = updateLastEdited
|
updateLastEditedRef.current = updateLastEdited
|
||||||
@@ -17,11 +19,13 @@ export function FluxRoute() {
|
|||||||
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
||||||
}, [recollectionId])
|
}, [recollectionId])
|
||||||
|
|
||||||
|
const focusNodeId = searchParams.get('focusNode') ?? undefined
|
||||||
|
|
||||||
if (!recollectionId) return null
|
if (!recollectionId) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<CanvasPage key={recollectionId} recollectionId={recollectionId} />
|
<CanvasPage key={recollectionId} recollectionId={recollectionId} focusNodeId={focusNodeId} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,125 @@
|
|||||||
/**
|
/**
|
||||||
* Katalogos page: third view for a recollection.
|
* Katalogos page: third view for a recollection.
|
||||||
* Simple placeholder layout, matching Logos/Flux flex and typography.
|
* Shows live \"Artifacts\" from Flux rendering nodes in a card grid.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
import { getRenderOutputCache, formatTimeSinceLastUpdate } from '../state/recollectionStore'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
export function KatalogosPage() {
|
export function KatalogosPage() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const { recollections } = usePlatform()
|
const { recollections } = usePlatform()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||||
const title = recollection?.name ?? 'Untitled'
|
const title = recollection?.name ?? 'Untitled'
|
||||||
|
|
||||||
|
const artifacts = useMemo(
|
||||||
|
() => (recollectionId ? getRenderOutputCache(recollectionId) : []),
|
||||||
|
[recollectionId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleViewInFlux = (nodeId: string) => {
|
||||||
|
if (!recollectionId) return
|
||||||
|
navigate(`/recollections/${recollectionId}/flux?focusNode=${encodeURIComponent(nodeId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||||
<div className="mx-auto w-full max-w-3xl p-4">
|
<div className="mx-auto w-full max-w-5xl p-4">
|
||||||
<h1 className="mb-2 truncate font-serif text-2xl font-semibold tracking-tight text-foreground">
|
<h1 className="mb-2 truncate font-serif text-2xl font-semibold tracking-tight text-foreground">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mb-6 text-sm text-muted-foreground">Katalogos</p>
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground">
|
Katalogos · Live artifacts produced by Flux rendering nodes for this recollection.
|
||||||
Katalogos view coming soon.
|
</p>
|
||||||
</div>
|
{artifacts.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground">
|
||||||
|
No artifacts yet. In Flux, run a graph with a rendering node; its output will be cached as an artifact and
|
||||||
|
appear here, as well as in Logos blocks that insert artifacts.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{artifacts.map((artifact) => {
|
||||||
|
const label = artifact.label || artifact.nodeId
|
||||||
|
const isImage =
|
||||||
|
artifact.type === 'image' ||
|
||||||
|
Boolean(artifact.content?.trim() && /<svg[\\s>]/i.test(artifact.content.trim()))
|
||||||
|
const imageSrc =
|
||||||
|
isImage && artifact.content
|
||||||
|
? artifact.content.startsWith('data:')
|
||||||
|
? artifact.content
|
||||||
|
: `data:image/svg+xml;utf8,${encodeURIComponent(artifact.content)}`
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={artifact.nodeId}
|
||||||
|
className="flex h-full flex-col rounded-lg border border-border bg-card text-card-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{label}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">Node: {artifact.nodeId}</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-emerald-500">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2 px-3 py-3">
|
||||||
|
{isImage && imageSrc ? (
|
||||||
|
<div className="flex min-h-[120px] items-center justify-center overflow-hidden rounded-md border bg-muted">
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={label || 'Artifact image'}
|
||||||
|
className="max-h-48 w-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : artifact.content ? (
|
||||||
|
<div className="min-h-[120px] overflow-hidden rounded-md border border-border bg-background">
|
||||||
|
<iframe
|
||||||
|
title={label || 'Artifact HTML output'}
|
||||||
|
srcDoc={artifact.content}
|
||||||
|
className="h-40 w-full"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[80px] items-center justify-center rounded-md border border-dashed border-muted-foreground/40 bg-muted/40 px-3 text-xs text-muted-foreground">
|
||||||
|
No preview available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-card px-3 py-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
Type: <span className="font-medium">{artifact.type}</span>
|
||||||
|
</p>
|
||||||
|
{artifact.updatedAt != null && (
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
· {formatTimeSinceLastUpdate(artifact.updatedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => handleViewInFlux(artifact.nodeId)}
|
||||||
|
>
|
||||||
|
View in Flux
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export function RecollectionMenubar() {
|
|||||||
) : null
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-9 w-full shrink-0 items-center justify-between gap-2 overflow-visible border-b border-border/40 bg-background px-2">
|
<div className="relative flex h-9 w-full shrink-0 items-center gap-2 overflow-visible border-b border-border/40 bg-background px-2">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
to="/recollections"
|
to="/recollections"
|
||||||
aria-label="Back to recollections"
|
aria-label="Back to recollections"
|
||||||
@@ -31,12 +31,14 @@ export function RecollectionMenubar() {
|
|||||||
>
|
>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
|
||||||
{titleContent ?? defaultTitle}
|
|
||||||
</div>
|
|
||||||
<RecollectionViewSwitcher />
|
<RecollectionViewSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2 pl-2">
|
<div className="pointer-events-none absolute left-1/2 top-1/2 flex min-w-0 -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||||
|
<div className="pointer-events-auto flex min-w-0 items-center gap-1.5">
|
||||||
|
{titleContent ?? defaultTitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-2 pl-2">
|
||||||
<RecollectionFileMenu />
|
<RecollectionFileMenu />
|
||||||
<RecollectionEditViewMenus />
|
<RecollectionEditViewMenus />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -150,15 +150,15 @@ export function LogosPage() {
|
|||||||
async (query: string) => {
|
async (query: string) => {
|
||||||
const defaultItems = getDefaultReactSlashMenuItems(editor)
|
const defaultItems = getDefaultReactSlashMenuItems(editor)
|
||||||
const fluxItem = {
|
const fluxItem = {
|
||||||
title: 'Insert from Flux',
|
title: 'Insert Artifact',
|
||||||
subtext: 'Insert output from a Flux rendering node',
|
subtext: 'Insert an Artifact produced by a Flux rendering node',
|
||||||
icon: <FluxIcon className="size-4" />,
|
icon: <FluxIcon className="size-4" />,
|
||||||
onItemClick: () => {
|
onItemClick: () => {
|
||||||
const pos = editor.getTextCursorPosition()
|
const pos = editor.getTextCursorPosition()
|
||||||
editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }])
|
editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }])
|
||||||
},
|
},
|
||||||
aliases: ['flux', 'output', 'render'] as const,
|
aliases: ['artifact', 'flux', 'output', 'render'] as const,
|
||||||
group: 'Flux',
|
group: 'Artifacts',
|
||||||
}
|
}
|
||||||
const all = [...defaultItems, fluxItem]
|
const all = [...defaultItems, fluxItem]
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* BlockNote block "Flux output": insert output from Flux rendering nodes into Logos.
|
* BlockNote block "Flux output": insert Artifacts produced by Flux rendering nodes into Logos.
|
||||||
* Empty state: placeholder + picker over getRenderOutputCache(recollectionId).
|
* Empty state: placeholder + picker over getRenderOutputCache(recollectionId).
|
||||||
* Filled state: when nodeId is set, display is live from the cache (updates when the rendering node changes in Flux);
|
* Filled state: when nodeId is set, display is live from the cache (updates when the rendering node changes in Flux);
|
||||||
* otherwise or when cache entry is missing, show stored content (static).
|
* otherwise or when cache entry is missing, show stored content (static).
|
||||||
|
* Shows live/static status and time since last update.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { createReactBlockSpec } from '@blocknote/react'
|
import { createReactBlockSpec } from '@blocknote/react'
|
||||||
import type { ReactCustomBlockRenderProps } from '@blocknote/react'
|
import type { ReactCustomBlockRenderProps } from '@blocknote/react'
|
||||||
import { getRenderOutputCache, type RenderOutputCacheEntry } from '../../state/recollectionStore'
|
import {
|
||||||
|
getRenderOutputCache,
|
||||||
|
formatTimeSinceLastUpdate,
|
||||||
|
type RenderOutputCacheEntry,
|
||||||
|
} from '../../state/recollectionStore'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
|
||||||
@@ -39,6 +44,7 @@ function FluxOutputBlockContent({
|
|||||||
contentRef,
|
contentRef,
|
||||||
}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) {
|
}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const nodeId = block.props.nodeId ?? ''
|
const nodeId = block.props.nodeId ?? ''
|
||||||
const storedContent = block.props.content ?? ''
|
const storedContent = block.props.content ?? ''
|
||||||
@@ -60,35 +66,47 @@ function FluxOutputBlockContent({
|
|||||||
[editor, block.id]
|
[editor, block.id]
|
||||||
)
|
)
|
||||||
|
|
||||||
const entries = recollectionId ? getRenderOutputCache(recollectionId) : []
|
const artifacts = recollectionId ? getRenderOutputCache(recollectionId) : []
|
||||||
|
|
||||||
// Resolve display from cache when block is tied to a node (live); otherwise use stored props (static).
|
// Resolve display from cache when block is tied to a node (live); otherwise use stored props (static).
|
||||||
const liveEntry = nodeId && recollectionId ? entries.find((e) => e.nodeId === nodeId) : null
|
const liveEntry = nodeId && recollectionId ? artifacts.find((e) => e.nodeId === nodeId) : null
|
||||||
const content = liveEntry ? liveEntry.content : storedContent
|
const content = liveEntry ? liveEntry.content : storedContent
|
||||||
const rawContentType = liveEntry ? liveEntry.type : storedContentType
|
const rawContentType = liveEntry ? liveEntry.type : storedContentType
|
||||||
const label = liveEntry ? liveEntry.label : storedLabel
|
const label = liveEntry ? liveEntry.label : storedLabel
|
||||||
|
const isLive = Boolean(liveEntry)
|
||||||
|
const timeSince = liveEntry?.updatedAt != null ? formatTimeSinceLastUpdate(liveEntry.updatedAt) : ''
|
||||||
// If content is SVG but type was stored as html, show as image (fixes incorrect cache or legacy data).
|
// If content is SVG but type was stored as html, show as image (fixes incorrect cache or legacy data).
|
||||||
const isSvgContent = Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
const isSvgContent = Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
||||||
const contentType = rawContentType === 'image' || isSvgContent ? 'image' : 'html'
|
const contentType = rawContentType === 'image' || isSvgContent ? 'image' : 'html'
|
||||||
|
|
||||||
|
const handleViewInFlux = useCallback(() => {
|
||||||
|
if (recollectionId && nodeId) {
|
||||||
|
navigate(`/recollections/${recollectionId}/flux?focusNode=${encodeURIComponent(nodeId)}`)
|
||||||
|
}
|
||||||
|
}, [recollectionId, nodeId, navigate])
|
||||||
|
|
||||||
// Empty: show placeholder + picker when nothing inserted yet
|
// Empty: show placeholder + picker when nothing inserted yet
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="min-h-[80px] rounded-md border border-dashed border-muted-foreground/30 bg-muted/30 p-4">
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="min-h-[80px] rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 p-4"
|
||||||
|
>
|
||||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
Insert from Flux
|
Insert Artifact
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80 p-0" align="start">
|
<PopoverContent className="w-80 p-0" align="start">
|
||||||
{entries.length === 0 ? (
|
{artifacts.length === 0 ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
No Flux outputs yet. In Flux, run a graph with a rendering node; its output will appear here automatically.
|
No Artifacts yet. In Flux, run a graph with a rendering node; its output will be cached as an Artifact
|
||||||
|
and appear here and in Katalogos automatically.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="max-h-64 overflow-auto py-2">
|
<ul className="max-h-64 overflow-auto py-2">
|
||||||
{entries.map((entry) => (
|
{artifacts.map((entry) => (
|
||||||
<li key={entry.nodeId}>
|
<li key={entry.nodeId}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -108,28 +126,70 @@ function FluxOutputBlockContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filled: show image or HTML
|
// Filled: card layout with header (label + live/static status + time) and content
|
||||||
|
const header = (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 bg-muted/30 px-3 py-1.5 rounded-t-lg">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{label ? <span className="truncate text-xs font-medium text-foreground">{label}</span> : null}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isLive
|
||||||
|
? 'inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLive && <span className="h-1 w-1 shrink-0 rounded-full bg-emerald-500" aria-hidden />}
|
||||||
|
{isLive ? 'Live' : 'Static'}
|
||||||
|
</span>
|
||||||
|
{timeSince ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{timeSince}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{nodeId && recollectionId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={handleViewInFlux}
|
||||||
|
>
|
||||||
|
View in Flux
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
if (contentType === 'image') {
|
if (contentType === 'image') {
|
||||||
const src =
|
const src =
|
||||||
content.startsWith('data:') ? content : `data:image/svg+xml;utf8,${encodeURIComponent(content)}`
|
content.startsWith('data:') ? content : `data:image/svg+xml;utf8,${encodeURIComponent(content)}`
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="min-h-[40px]">
|
<div
|
||||||
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
|
ref={contentRef}
|
||||||
<img src={src} alt={label || 'Flux output'} className="max-w-full rounded border object-contain" />
|
className="min-h-[40px] overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
<div className="p-2">
|
||||||
|
<img src={src} alt={label || 'Artifact'} className="max-w-full rounded-md border border-border/60 object-contain" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML: render in iframe to limit script execution (XSS safety)
|
// HTML: render in iframe to limit script execution (XSS safety)
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="min-h-[40px]">
|
<div
|
||||||
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
|
ref={contentRef}
|
||||||
<iframe
|
className="min-h-[40px] overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm"
|
||||||
title={label || 'Flux HTML output'}
|
>
|
||||||
srcDoc={content}
|
{header}
|
||||||
className="min-h-[120px] w-full rounded border border-border bg-background"
|
<div className="p-2">
|
||||||
sandbox="allow-same-origin"
|
<iframe
|
||||||
/>
|
title={label || 'Artifact HTML'}
|
||||||
|
srcDoc={content}
|
||||||
|
className="min-h-[120px] w-full rounded-md border border-border/60 bg-background"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,19 @@ export type RenderOutputCacheEntry = {
|
|||||||
label: string
|
label: string
|
||||||
type: 'image' | 'html'
|
type: 'image' | 'html'
|
||||||
content: string
|
content: string
|
||||||
|
/** Timestamp (ms) when this entry was last updated. */
|
||||||
|
updatedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a short "time since" string for display (e.g. "Just now", "2 min ago"). */
|
||||||
|
export function formatTimeSinceLastUpdate(updatedAt: number | undefined): string {
|
||||||
|
if (updatedAt == null || typeof updatedAt !== 'number') return ''
|
||||||
|
const delta = Date.now() - updatedAt
|
||||||
|
if (delta < 15_000) return 'Just now'
|
||||||
|
if (delta < 60_000) return `${Math.round(delta / 1000)}s ago`
|
||||||
|
if (delta < 3600_000) return `${Math.round(delta / 60_000)} min ago`
|
||||||
|
if (delta < 86400_000) return `${Math.round(delta / 3600_000)}h ago`
|
||||||
|
return `${Math.round(delta / 86400_000)}d ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ export function useRenderingNodeState(
|
|||||||
label: id,
|
label: id,
|
||||||
type: isSvgContent ? 'image' : 'html',
|
type: isSvgContent ? 'image' : 'html',
|
||||||
content: cached,
|
content: cached,
|
||||||
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -366,6 +367,7 @@ export function useRenderingNodeState(
|
|||||||
label: id,
|
label: id,
|
||||||
type: cacheType,
|
type: cacheType,
|
||||||
content: htmlOrSvg ?? '',
|
content: htmlOrSvg ?? '',
|
||||||
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
const mode = outputModeRef.current
|
const mode = outputModeRef.current
|
||||||
const cachedOutputValue =
|
const cachedOutputValue =
|
||||||
|
|||||||
Reference in New Issue
Block a user