feat: artifacts

This commit is contained in:
2026-03-16 15:25:22 +01:00
parent 9f56b728c0
commit 9d38b1df2b
8 changed files with 275 additions and 47 deletions

View File

@@ -76,12 +76,60 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
})
function FlowFitViewOnLoad() {
function FlowFitViewOnLoad({ disabled }: { disabled?: boolean }) {
const nodesInitialized = useNodesInitialized()
const { fitView } = useReactFlow()
React.useEffect(() => {
if (nodesInitialized) fitView?.({ duration: 200 })
}, [nodesInitialized, fitView])
if (disabled || !nodesInitialized) return
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
}
@@ -152,9 +200,11 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
export type CanvasPageProps = {
/** Optional recollection id for per-recollection graph loading */
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 { showMinimap } = usePlatform()
const {
@@ -688,7 +738,8 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
)}
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
<ViewportDisplayProvider>
<FlowFitViewOnLoad />
<FlowFitViewOnLoad disabled={Boolean(focusNodeId)} />
<FocusNodeOnLoad focusNodeId={focusNodeId} />
<FlowKeyboardShortcuts />
<ReactFlow
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}

View File

@@ -1,14 +1,16 @@
/**
* 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 { useParams } from 'react-router-dom'
import { useParams, useSearchParams } from 'react-router-dom'
import { CanvasPage } from '@/app/canvas/CanvasPage'
import { usePlatform } from '@/app/kosmos/KosmosContext'
export function FluxRoute() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const [searchParams] = useSearchParams()
const { updateLastEdited } = usePlatform()
const updateLastEditedRef = useRef(updateLastEdited)
updateLastEditedRef.current = updateLastEdited
@@ -17,11 +19,13 @@ export function FluxRoute() {
if (recollectionId) updateLastEditedRef.current(recollectionId)
}, [recollectionId])
const focusNodeId = searchParams.get('focusNode') ?? undefined
if (!recollectionId) return null
return (
<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>
)
}

View File

@@ -1,29 +1,125 @@
/**
* 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 { useParams } from 'react-router-dom'
import React, { useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { getRenderOutputCache, formatTimeSinceLastUpdate } from '../state/recollectionStore'
import { Button } from '@/components/ui/button'
export function KatalogosPage() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { recollections } = usePlatform()
const navigate = useNavigate()
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
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 (
<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">
{title}
</h1>
<p className="mb-6 text-sm text-muted-foreground">Katalogos</p>
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground">
Katalogos view coming soon.
</div>
<p className="mb-6 text-sm text-muted-foreground">
Katalogos · Live artifacts produced by Flux rendering nodes for this recollection.
</p>
{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>
)

View File

@@ -22,8 +22,8 @@ export function RecollectionMenubar() {
) : null
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="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<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 items-center gap-2">
<Link
to="/recollections"
aria-label="Back to recollections"
@@ -31,12 +31,14 @@ export function RecollectionMenubar() {
>
<ArrowLeft className="size-4" />
</Link>
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
{titleContent ?? defaultTitle}
</div>
<RecollectionViewSwitcher />
</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 />
<RecollectionEditViewMenus />
</div>

View File

@@ -150,15 +150,15 @@ export function LogosPage() {
async (query: string) => {
const defaultItems = getDefaultReactSlashMenuItems(editor)
const fluxItem = {
title: 'Insert from Flux',
subtext: 'Insert output from a Flux rendering node',
title: 'Insert Artifact',
subtext: 'Insert an Artifact produced by a Flux rendering node',
icon: <FluxIcon className="size-4" />,
onItemClick: () => {
const pos = editor.getTextCursorPosition()
editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }])
},
aliases: ['flux', 'output', 'render'] as const,
group: 'Flux',
aliases: ['artifact', 'flux', 'output', 'render'] as const,
group: 'Artifacts',
}
const all = [...defaultItems, fluxItem]
const q = query.trim().toLowerCase()

View File

@@ -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).
* 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).
* Shows live/static status and time since last update.
*/
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 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -39,6 +44,7 @@ function FluxOutputBlockContent({
contentRef,
}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const navigate = useNavigate()
const [pickerOpen, setPickerOpen] = useState(false)
const nodeId = block.props.nodeId ?? ''
const storedContent = block.props.content ?? ''
@@ -60,35 +66,47 @@ function FluxOutputBlockContent({
[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).
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 rawContentType = liveEntry ? liveEntry.type : storedContentType
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).
const isSvgContent = Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
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
if (!content) {
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}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
Insert from Flux
Insert Artifact
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
{entries.length === 0 ? (
{artifacts.length === 0 ? (
<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>
) : (
<ul className="max-h-64 overflow-auto py-2">
{entries.map((entry) => (
{artifacts.map((entry) => (
<li key={entry.nodeId}>
<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') {
const src =
content.startsWith('data:') ? content : `data:image/svg+xml;utf8,${encodeURIComponent(content)}`
return (
<div ref={contentRef} className="min-h-[40px]">
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
<img src={src} alt={label || 'Flux output'} className="max-w-full rounded border object-contain" />
<div
ref={contentRef}
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>
)
}
// HTML: render in iframe to limit script execution (XSS safety)
return (
<div ref={contentRef} className="min-h-[40px]">
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
<iframe
title={label || 'Flux HTML output'}
srcDoc={content}
className="min-h-[120px] w-full rounded border border-border bg-background"
sandbox="allow-same-origin"
/>
<div
ref={contentRef}
className="min-h-[40px] overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm"
>
{header}
<div className="p-2">
<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>
)
}

View File

@@ -16,6 +16,19 @@ export type RenderOutputCacheEntry = {
label: string
type: 'image' | 'html'
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'

View File

@@ -302,6 +302,7 @@ export function useRenderingNodeState(
label: id,
type: isSvgContent ? 'image' : 'html',
content: cached,
updatedAt: Date.now(),
})
return
}
@@ -366,6 +367,7 @@ export function useRenderingNodeState(
label: id,
type: cacheType,
content: htmlOrSvg ?? '',
updatedAt: Date.now(),
})
const mode = outputModeRef.current
const cachedOutputValue =