feat: flux to logos

This commit is contained in:
2026-03-15 23:41:10 +01:00
parent 45c387c9e6
commit f67c491deb
6 changed files with 289 additions and 7 deletions

View File

@@ -11,11 +11,15 @@ import {
setGraph,
getLogosContent,
setLogosContent,
upsertRenderOutputEntry,
RECOLLECTION_FILE_EXT,
RECOLLECTION_VERSION,
type StoredGraphState,
type StoredLogosContent,
type RenderOutputCacheEntry,
} from './recollectionStore'
export type { RenderOutputCacheEntry }
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
import { toast } from 'sonner'
@@ -70,6 +74,8 @@ export type RecollectionActionsContextValue = {
activeSlot: FluxSlot | LogosSlot | null
onImport: () => void
onExport: () => void
/** Upsert a rendering node's output into the cache so Logos "Insert from Flux" block can show it. */
upsertRenderOutputToLogos: (entry: RenderOutputCacheEntry) => void
}
const RecollectionActionsContext = createContext<RecollectionActionsContextValue | null>(null)
@@ -147,6 +153,13 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
importInputRef.current?.click()
}, [])
const upsertRenderOutputToLogos = useCallback(
(entry: RenderOutputCacheEntry) => {
if (recollectionId) upsertRenderOutputEntry(recollectionId, entry)
},
[recollectionId]
)
const onImportFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
@@ -198,8 +211,9 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
activeSlot,
onImport,
onExport,
upsertRenderOutputToLogos,
}),
[flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport]
[flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport, upsertRenderOutputToLogos]
)
return (
@@ -222,3 +236,8 @@ export function useRecollectionActions(): RecollectionActionsContextValue {
if (!ctx) throw new Error('useRecollectionActions must be used within RecollectionActionsProvider')
return ctx
}
/** Returns the context value or null when outside RecollectionActionsProvider. Use when the consumer may render outside recollection layout. */
export function useOptionalRecollectionActions(): RecollectionActionsContextValue | null {
return useContext(RecollectionActionsContext)
}

View File

@@ -5,11 +5,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
import { useParams } from 'react-router-dom'
import { useCreateBlockNote } from '@blocknote/react'
import { FluxIcon } from '@/lib/icons'
import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuController } from '@blocknote/react'
import { BlockNoteView } from '@blocknote/shadcn'
import { useTheme } from '@/lib/themeContext'
import { useRecollectionActions } from '../RecollectionActionsContext'
import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore'
import { logosSchema } from './logosSchema'
import { toast } from 'sonner'
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
@@ -58,7 +60,10 @@ export function LogosPage() {
[recollectionId, reloadKey]
)
const editor = useCreateBlockNote({ initialContent }, [recollectionId, reloadKey])
const editor = useCreateBlockNote(
{ schema: logosSchema, initialContent },
[recollectionId, reloadKey]
)
const persistContent = useCallback(() => {
if (!recollectionId || !editor) return
@@ -137,14 +142,43 @@ export function LogosPage() {
patchBlockNoteRefWarning()
const getSlashMenuItems = useCallback(
async (query: string) => {
const defaultItems = getDefaultReactSlashMenuItems(editor)
const fluxItem = {
title: 'Insert from Flux',
subtext: 'Insert output from 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',
}
const all = [...defaultItems, fluxItem]
const q = query.trim().toLowerCase()
if (!q) return all
return all.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
(item.aliases && item.aliases.some((a: string) => a.toLowerCase().includes(q)))
)
},
[editor]
)
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">
<BlockNoteViewWrapper
editor={editor}
editor={editor as any}
theme={theme}
className="min-h-full w-full"
/>
slashMenu={false}
>
<SuggestionMenuController triggerCharacter="/" getItems={getSlashMenuItems} />
</BlockNoteViewWrapper>
</div>
</div>
)

View File

@@ -0,0 +1,140 @@
/**
* BlockNote block "Flux output": insert output from 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).
*/
import React, { useCallback, useState } from 'react'
import { useParams } from 'react-router-dom'
import { createReactBlockSpec } from '@blocknote/react'
import type { ReactCustomBlockRenderProps } from '@blocknote/react'
import { getRenderOutputCache, type RenderOutputCacheEntry } from '../../recollectionStore'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const fluxOutputBlockConfig = {
type: 'fluxOutput' as const,
propSchema: {
nodeId: {
default: '',
},
contentType: {
default: 'image' as const,
values: ['image', 'html'] as const,
},
content: {
default: '',
},
label: {
default: '',
},
},
content: 'none' as const,
}
function FluxOutputBlockContent({
block,
editor,
contentRef,
}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const [pickerOpen, setPickerOpen] = useState(false)
const nodeId = block.props.nodeId ?? ''
const storedContent = block.props.content ?? ''
const storedContentType = block.props.contentType ?? 'image'
const storedLabel = block.props.label ?? ''
const handleSelect = useCallback(
(entry: RenderOutputCacheEntry) => {
editor.updateBlock(block.id, {
props: {
nodeId: entry.nodeId,
contentType: entry.type,
content: entry.content,
label: entry.label,
},
})
setPickerOpen(false)
},
[editor, block.id]
)
const entries = 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 content = liveEntry ? liveEntry.content : storedContent
const rawContentType = liveEntry ? liveEntry.type : storedContentType
const label = liveEntry ? liveEntry.label : storedLabel
// 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'
// 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">
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
Insert from Flux
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
{entries.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.
</div>
) : (
<ul className="max-h-64 overflow-auto py-2">
{entries.map((entry) => (
<li key={entry.nodeId}>
<button
type="button"
className="w-full px-4 py-2 text-left text-sm hover:bg-muted"
onClick={() => handleSelect(entry)}
>
{entry.label || entry.nodeId}
<span className="ml-2 text-muted-foreground">({entry.type})</span>
</button>
</li>
))}
</ul>
)}
</PopoverContent>
</Popover>
</div>
)
}
// Filled: show image or HTML
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>
)
}
// 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>
)
}
export const createFluxOutputBlock = () =>
createReactBlockSpec(fluxOutputBlockConfig, {
render: FluxOutputBlockContent,
})

View File

@@ -0,0 +1,17 @@
/**
* BlockNote schema for Logos: default blocks + Flux output block.
*/
import { BlockNoteSchema, defaultBlockSpecs } from '@blocknote/core'
import { createFluxOutputBlock } from './blocks/fluxOutputBlock'
const fluxOutputBlock = createFluxOutputBlock()
export const logosSchema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
fluxOutput: fluxOutputBlock(),
},
})
export type LogosSchema = typeof logosSchema

View File

@@ -10,11 +10,20 @@ export type { StoredGraphState }
/** BlockNote document: array of blocks (PartialBlock). Stored as JSON. */
export type StoredLogosContent = Record<string, unknown>[]
/** One entry in the render-output cache (one per rendering node, overwritten on each update). */
export type RenderOutputCacheEntry = {
nodeId: string
label: string
type: 'image' | 'html'
content: string
}
export const RECOLLECTION_FILE_EXT = '.zui.json'
export const RECOLLECTION_VERSION = 1
const GRAPH_KEY_PREFIX = 'zui_graph_'
const LOGOS_KEY_PREFIX = 'zui_logos_'
const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_'
function getGraphKey(recollectionId: string): string {
return `${GRAPH_KEY_PREFIX}${recollectionId}`
@@ -24,6 +33,10 @@ function getLogosKey(recollectionId: string): string {
return `${LOGOS_KEY_PREFIX}${recollectionId}`
}
function getRenderCacheKey(recollectionId: string): string {
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
}
export function getGraph(recollectionId: string): StoredGraphState | null {
try {
const raw = localStorage.getItem(getGraphKey(recollectionId))
@@ -63,8 +76,40 @@ export function setLogosContent(recollectionId: string, content: StoredLogosCont
localStorage.setItem(getLogosKey(recollectionId), JSON.stringify(content))
}
/** Removes both graph and logos data for the recollection. */
/** Render output cache: one entry per rendering node (keyed by nodeId). Used by Logos "Insert from Flux" block. */
export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] {
try {
const raw = localStorage.getItem(getRenderCacheKey(recollectionId))
if (!raw) return []
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
return data.filter(
(item): item is RenderOutputCacheEntry =>
item != null &&
typeof item === 'object' &&
typeof (item as RenderOutputCacheEntry).nodeId === 'string' &&
typeof (item as RenderOutputCacheEntry).label === 'string' &&
((item as RenderOutputCacheEntry).type === 'image' || (item as RenderOutputCacheEntry).type === 'html') &&
typeof (item as RenderOutputCacheEntry).content === 'string'
) as RenderOutputCacheEntry[]
} catch {
return []
}
}
export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOutputCacheEntry): void {
const entries = getRenderOutputCache(recollectionId)
const byNodeId = new Map(entries.map((e) => [e.nodeId, e]))
byNodeId.set(entry.nodeId, entry)
localStorage.setItem(
getRenderCacheKey(recollectionId),
JSON.stringify(Array.from(byNodeId.values()))
)
}
/** Removes both graph, logos, and render cache data for the recollection. */
export function removeRecollectionData(recollectionId: string): void {
localStorage.removeItem(getGraphKey(recollectionId))
localStorage.removeItem(getLogosKey(recollectionId))
localStorage.removeItem(getRenderCacheKey(recollectionId))
}

View File

@@ -9,6 +9,7 @@ import { useAbstractNode } from '@/lib/graph/abstractNode'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useOptionalRecollectionActions } from '@/app/recollections/RecollectionActionsContext'
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
import {
@@ -132,6 +133,9 @@ export function useRenderingNodeState(
nodesEdgesRef.current = { nodes, edges }
const { aiConnection } = usePlatform()
const recollectionActions = useOptionalRecollectionActions()
const recollectionActionsRef = useRef(recollectionActions)
recollectionActionsRef.current = recollectionActions
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
@@ -289,7 +293,18 @@ export function useRenderingNodeState(
data?.lastRunSourceSignature != null && data.lastRunSourceSignature === sourceSignature
const withinVisibilityGrace =
mountTimeRef.current > 0 && Date.now() - mountTimeRef.current < VISIBILITY_GRACE_MS
if (hasCache && (signatureUnchanged || withinVisibilityGrace)) return
if (hasCache && (signatureUnchanged || withinVisibilityGrace)) {
// Keep Logos cache in sync with node's cached output so the picker shows the latest when not re-running.
const cached = (data?.cachedRenderedContent as string | undefined) ?? ''
const isSvgContent = Boolean(cached.trim() && /<svg[\s>]/i.test(cached.trim()))
recollectionActionsRef.current?.upsertRenderOutputToLogos?.({
nodeId: id,
label: id,
type: isSvgContent ? 'image' : 'html',
content: cached,
})
return
}
}
runIdRef.current += 1
@@ -342,6 +357,16 @@ export function useRenderingNodeState(
if (thisRunId !== runIdRef.current) return
setRenderedContent(htmlOrSvg)
setError(null)
// Use the rendering node's id as the label (same as the node name shown in Flux header).
// Infer image from SVG content so we never store 'html' for diagram output.
const isSvgContent = Boolean(htmlOrSvg?.trim() && /<svg[\s>]/i.test(htmlOrSvg.trim()))
const cacheType = typeRenderer.outputType === 'image' || isSvgContent ? 'image' : 'html'
recollectionActionsRef.current?.upsertRenderOutputToLogos?.({
nodeId: id,
label: id,
type: cacheType,
content: htmlOrSvg ?? '',
})
const mode = outputModeRef.current
const cachedOutputValue =
mode === 'string'
@@ -406,6 +431,8 @@ export function useRenderingNodeState(
}
// Content updates only when connected-node data changes (sourceSignature) or explicit run/viewport.
// React Flow updates (position, selection, context ref churn) do not trigger re-runs.
// recollectionActions is intentionally omitted: we use recollectionActionsRef so slot/context
// identity changes (e.g. after setFluxSlot) do not re-trigger this effect and cause an auto-run loop.
}, [
id,
srcId,