feat: flux to logos
This commit is contained in:
@@ -11,11 +11,15 @@ import {
|
|||||||
setGraph,
|
setGraph,
|
||||||
getLogosContent,
|
getLogosContent,
|
||||||
setLogosContent,
|
setLogosContent,
|
||||||
|
upsertRenderOutputEntry,
|
||||||
RECOLLECTION_FILE_EXT,
|
RECOLLECTION_FILE_EXT,
|
||||||
RECOLLECTION_VERSION,
|
RECOLLECTION_VERSION,
|
||||||
type StoredGraphState,
|
type StoredGraphState,
|
||||||
type StoredLogosContent,
|
type StoredLogosContent,
|
||||||
|
type RenderOutputCacheEntry,
|
||||||
} from './recollectionStore'
|
} from './recollectionStore'
|
||||||
|
|
||||||
|
export type { RenderOutputCacheEntry }
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
import { backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
|
import { backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -70,6 +74,8 @@ export type RecollectionActionsContextValue = {
|
|||||||
activeSlot: FluxSlot | LogosSlot | null
|
activeSlot: FluxSlot | LogosSlot | null
|
||||||
onImport: () => void
|
onImport: () => void
|
||||||
onExport: () => 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)
|
const RecollectionActionsContext = createContext<RecollectionActionsContextValue | null>(null)
|
||||||
@@ -147,6 +153,13 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
|
|||||||
importInputRef.current?.click()
|
importInputRef.current?.click()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const upsertRenderOutputToLogos = useCallback(
|
||||||
|
(entry: RenderOutputCacheEntry) => {
|
||||||
|
if (recollectionId) upsertRenderOutputEntry(recollectionId, entry)
|
||||||
|
},
|
||||||
|
[recollectionId]
|
||||||
|
)
|
||||||
|
|
||||||
const onImportFileChange = useCallback(
|
const onImportFileChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
@@ -198,8 +211,9 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
|
|||||||
activeSlot,
|
activeSlot,
|
||||||
onImport,
|
onImport,
|
||||||
onExport,
|
onExport,
|
||||||
|
upsertRenderOutputToLogos,
|
||||||
}),
|
}),
|
||||||
[flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport]
|
[flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport, upsertRenderOutputToLogos]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -222,3 +236,8 @@ export function useRecollectionActions(): RecollectionActionsContextValue {
|
|||||||
if (!ctx) throw new Error('useRecollectionActions must be used within RecollectionActionsProvider')
|
if (!ctx) throw new Error('useRecollectionActions must be used within RecollectionActionsProvider')
|
||||||
return ctx
|
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 React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
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 { BlockNoteView } from '@blocknote/shadcn'
|
||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import { useRecollectionActions } from '../RecollectionActionsContext'
|
import { useRecollectionActions } from '../RecollectionActionsContext'
|
||||||
import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore'
|
import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore'
|
||||||
|
import { logosSchema } from './logosSchema'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
|
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
|
||||||
@@ -58,7 +60,10 @@ export function LogosPage() {
|
|||||||
[recollectionId, reloadKey]
|
[recollectionId, reloadKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
const editor = useCreateBlockNote({ initialContent }, [recollectionId, reloadKey])
|
const editor = useCreateBlockNote(
|
||||||
|
{ schema: logosSchema, initialContent },
|
||||||
|
[recollectionId, reloadKey]
|
||||||
|
)
|
||||||
|
|
||||||
const persistContent = useCallback(() => {
|
const persistContent = useCallback(() => {
|
||||||
if (!recollectionId || !editor) return
|
if (!recollectionId || !editor) return
|
||||||
@@ -137,14 +142,43 @@ export function LogosPage() {
|
|||||||
|
|
||||||
patchBlockNoteRefWarning()
|
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 (
|
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-3xl p-4">
|
||||||
<BlockNoteViewWrapper
|
<BlockNoteViewWrapper
|
||||||
editor={editor}
|
editor={editor as any}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
className="min-h-full w-full"
|
className="min-h-full w-full"
|
||||||
/>
|
slashMenu={false}
|
||||||
|
>
|
||||||
|
<SuggestionMenuController triggerCharacter="/" getItems={getSlashMenuItems} />
|
||||||
|
</BlockNoteViewWrapper>
|
||||||
</div>
|
</div>
|
||||||
</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. */
|
/** BlockNote document: array of blocks (PartialBlock). Stored as JSON. */
|
||||||
export type StoredLogosContent = Record<string, unknown>[]
|
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_FILE_EXT = '.zui.json'
|
||||||
export const RECOLLECTION_VERSION = 1
|
export const RECOLLECTION_VERSION = 1
|
||||||
|
|
||||||
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
||||||
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
||||||
|
const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_'
|
||||||
|
|
||||||
function getGraphKey(recollectionId: string): string {
|
function getGraphKey(recollectionId: string): string {
|
||||||
return `${GRAPH_KEY_PREFIX}${recollectionId}`
|
return `${GRAPH_KEY_PREFIX}${recollectionId}`
|
||||||
@@ -24,6 +33,10 @@ function getLogosKey(recollectionId: string): string {
|
|||||||
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRenderCacheKey(recollectionId: string): string {
|
||||||
|
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
|
||||||
|
}
|
||||||
|
|
||||||
export function getGraph(recollectionId: string): StoredGraphState | null {
|
export function getGraph(recollectionId: string): StoredGraphState | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(getGraphKey(recollectionId))
|
const raw = localStorage.getItem(getGraphKey(recollectionId))
|
||||||
@@ -63,8 +76,40 @@ export function setLogosContent(recollectionId: string, content: StoredLogosCont
|
|||||||
localStorage.setItem(getLogosKey(recollectionId), JSON.stringify(content))
|
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 {
|
export function removeRecollectionData(recollectionId: string): void {
|
||||||
localStorage.removeItem(getGraphKey(recollectionId))
|
localStorage.removeItem(getGraphKey(recollectionId))
|
||||||
localStorage.removeItem(getLogosKey(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 { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||||
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
import { useOptionalRecollectionActions } from '@/app/recollections/RecollectionActionsContext'
|
||||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||||
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||||
import {
|
import {
|
||||||
@@ -132,6 +133,9 @@ export function useRenderingNodeState(
|
|||||||
nodesEdgesRef.current = { nodes, edges }
|
nodesEdgesRef.current = { nodes, edges }
|
||||||
|
|
||||||
const { aiConnection } = usePlatform()
|
const { aiConnection } = usePlatform()
|
||||||
|
const recollectionActions = useOptionalRecollectionActions()
|
||||||
|
const recollectionActionsRef = useRef(recollectionActions)
|
||||||
|
recollectionActionsRef.current = recollectionActions
|
||||||
|
|
||||||
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
||||||
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
||||||
@@ -289,7 +293,18 @@ export function useRenderingNodeState(
|
|||||||
data?.lastRunSourceSignature != null && data.lastRunSourceSignature === sourceSignature
|
data?.lastRunSourceSignature != null && data.lastRunSourceSignature === sourceSignature
|
||||||
const withinVisibilityGrace =
|
const withinVisibilityGrace =
|
||||||
mountTimeRef.current > 0 && Date.now() - mountTimeRef.current < VISIBILITY_GRACE_MS
|
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
|
runIdRef.current += 1
|
||||||
@@ -342,6 +357,16 @@ export function useRenderingNodeState(
|
|||||||
if (thisRunId !== runIdRef.current) return
|
if (thisRunId !== runIdRef.current) return
|
||||||
setRenderedContent(htmlOrSvg)
|
setRenderedContent(htmlOrSvg)
|
||||||
setError(null)
|
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 mode = outputModeRef.current
|
||||||
const cachedOutputValue =
|
const cachedOutputValue =
|
||||||
mode === 'string'
|
mode === 'string'
|
||||||
@@ -406,6 +431,8 @@ export function useRenderingNodeState(
|
|||||||
}
|
}
|
||||||
// Content updates only when connected-node data changes (sourceSignature) or explicit run/viewport.
|
// 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.
|
// 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,
|
id,
|
||||||
srcId,
|
srcId,
|
||||||
|
|||||||
Reference in New Issue
Block a user