feat: flux to logos
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
140
frontend/src/app/recollections/logos/blocks/fluxOutputBlock.tsx
Normal file
140
frontend/src/app/recollections/logos/blocks/fluxOutputBlock.tsx
Normal 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,
|
||||
})
|
||||
17
frontend/src/app/recollections/logos/logosSchema.ts
Normal file
17
frontend/src/app/recollections/logos/logosSchema.ts
Normal 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
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user