feat: runnersok
This commit is contained in:
@@ -68,6 +68,8 @@ import {
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import { toast } from 'sonner'
|
||||
import { RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||
import { useRunStream, createAndStreamRun } from '@/hooks/useRunStream'
|
||||
import { useRunStore } from '@/lib/graph/runStore'
|
||||
|
||||
const SNAP_GRID: [number, number] = [15, 15]
|
||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||
@@ -257,6 +259,33 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||
|
||||
const connectionPath = useCanvasConnectionPathFromStore()
|
||||
|
||||
// --- Run execution ---
|
||||
const { connectToRun } = useRunStream()
|
||||
const runStatus = useRunStore((s) => s.status)
|
||||
const resetRun = useRunStore((s) => s.reset)
|
||||
const markDirty = useRunStore((s) => s.markDirty)
|
||||
|
||||
// Mark run state as dirty when graph structure or data changes
|
||||
const prevNodesLenRef = useRef(nodes.length)
|
||||
const prevEdgesLenRef = useRef(edges.length)
|
||||
useEffect(() => {
|
||||
// Skip the initial render
|
||||
if (prevNodesLenRef.current === nodes.length && prevEdgesLenRef.current === edges.length) return
|
||||
prevNodesLenRef.current = nodes.length
|
||||
prevEdgesLenRef.current = edges.length
|
||||
markDirty()
|
||||
}, [nodes.length, edges.length, markDirty])
|
||||
const handleRun = useCallback(() => {
|
||||
if (!recollectionId) return
|
||||
if (runStatus === 'running' || runStatus === 'pending') return
|
||||
// Reset previous run state, save, then run
|
||||
resetRun()
|
||||
save()
|
||||
createAndStreamRun(recollectionId, { nodes, edges }, connectToRun).catch((err) => {
|
||||
toast.error(`Run failed: ${err.message}`)
|
||||
})
|
||||
}, [recollectionId, nodes, edges, connectToRun, save, runStatus, resetRun])
|
||||
|
||||
const nodesRef = useRef(nodes)
|
||||
nodesRef.current = nodes
|
||||
const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] })
|
||||
@@ -464,6 +493,7 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||
canDuplicate: selectedNodes.length > 0,
|
||||
canCopy: selectedNodes.length === 1,
|
||||
onFitView: () => flowActionsRef.current?.fitView?.(),
|
||||
onRun: recollectionId ? handleRun : undefined,
|
||||
}
|
||||
setFluxSlot(slot)
|
||||
return () => setFluxSlot(null)
|
||||
@@ -481,6 +511,7 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||
handleCopy,
|
||||
handlePaste,
|
||||
selectedNodes.length,
|
||||
handleRun,
|
||||
])
|
||||
|
||||
const graphContextValue = useMemo(
|
||||
|
||||
@@ -3,17 +3,19 @@
|
||||
* Shows live \"Artifacts\" from Flux rendering nodes in a card grid.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useState } 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'
|
||||
import { RunsTab } from './RunsTab'
|
||||
|
||||
export function KatalogosPage() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { recollections } = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'artifacts' | 'runs'>('artifacts')
|
||||
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||
const title = recollection?.name ?? 'Untitled'
|
||||
|
||||
@@ -33,10 +35,25 @@ export function KatalogosPage() {
|
||||
<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 · Live artifacts produced by Flux rendering nodes for this recollection.
|
||||
</p>
|
||||
{artifacts.length === 0 ? (
|
||||
<div className="mb-4 flex gap-1 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('artifacts')}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'artifacts' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Artifacts
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('runs')}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'runs' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Runs
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'runs' ? (
|
||||
<RunsTab />
|
||||
) : 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.
|
||||
|
||||
135
frontend/src/app/recollections/katalogos/RunsTab.tsx
Normal file
135
frontend/src/app/recollections/katalogos/RunsTab.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Runs tab for Katalogos: shows execution history for the current recollection.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle2, XCircle, Clock, Loader2 } from 'lucide-react'
|
||||
|
||||
type RunSummary = {
|
||||
id: string
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
type RunDetail = RunSummary & {
|
||||
steps: Array<{
|
||||
id: string
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
status: string
|
||||
error?: string | null
|
||||
startedAt?: string | null
|
||||
endedAt?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
const statusIcon: Record<string, React.ReactNode> = {
|
||||
pending: <Clock className="size-3.5 text-muted-foreground" />,
|
||||
running: <Loader2 className="size-3.5 animate-spin text-blue-500" />,
|
||||
completed: <CheckCircle2 className="size-3.5 text-emerald-500" />,
|
||||
failed: <XCircle className="size-3.5 text-destructive" />,
|
||||
}
|
||||
|
||||
export function RunsTab() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const [runs, setRuns] = useState<RunSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedRunId, setExpandedRunId] = useState<string | null>(null)
|
||||
const [runDetail, setRunDetail] = useState<RunDetail | null>(null)
|
||||
|
||||
const fetchRuns = useCallback(async () => {
|
||||
if (!recollectionId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/recollections/${recollectionId}/runs`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRuns(data.runs)
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [recollectionId])
|
||||
|
||||
useEffect(() => { fetchRuns() }, [fetchRuns])
|
||||
|
||||
const toggleExpand = async (runId: string) => {
|
||||
if (expandedRunId === runId) {
|
||||
setExpandedRunId(null)
|
||||
setRunDetail(null)
|
||||
return
|
||||
}
|
||||
setExpandedRunId(runId)
|
||||
try {
|
||||
const res = await fetch(`/api/runs/${runId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRunDetail(data)
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Loading runs…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground">
|
||||
No runs yet. Use the Run button in Flux to execute your graph.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{runs.map((run) => (
|
||||
<div key={run.id} className="rounded-lg border border-border bg-card text-card-foreground shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(run.id)}
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left text-sm hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcon[run.status] ?? statusIcon.pending}
|
||||
<span className="font-medium capitalize">{run.status}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(run.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
{expandedRunId === run.id && runDetail && (
|
||||
<div className="border-t border-border/60 px-3 py-2">
|
||||
{run.error && (
|
||||
<p className="mb-2 text-xs text-destructive">{run.error}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{runDetail.steps.map((step) => (
|
||||
<div key={step.id} className="flex items-center gap-2 rounded px-2 py-1 text-xs">
|
||||
{statusIcon[step.status] ?? statusIcon.pending}
|
||||
<span className="font-mono text-muted-foreground">{step.nodeId.slice(0, 8)}</span>
|
||||
<span className="capitalize text-muted-foreground">{step.nodeType}</span>
|
||||
<span className="capitalize">{step.status}</span>
|
||||
{step.error && <span className="truncate text-destructive">{step.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export type FluxSlot = {
|
||||
canDuplicate?: boolean
|
||||
canCopy?: boolean
|
||||
onFitView?: () => void
|
||||
onRun?: () => void
|
||||
}
|
||||
|
||||
export type LogosSlot = {
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from '@/components/ui/menubar'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { useRecollectionActions } from './RecollectionActionsContext'
|
||||
import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2 } from 'lucide-react'
|
||||
import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2, Play, Square } from 'lucide-react'
|
||||
import { useRunStore } from '@/lib/graph/runStore'
|
||||
|
||||
const UNDO_KEYS = { key: 'z', shiftKey: false }
|
||||
const REDO_KEYS = { key: 'z', shiftKey: true }
|
||||
@@ -26,8 +27,28 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
||||
|
||||
export function RecollectionEditViewMenus() {
|
||||
const { activeSlot, flux, isFluxActive } = useRecollectionActions()
|
||||
const runStatus = useRunStore((s) => s.status)
|
||||
const resetRun = useRunStore((s) => s.reset)
|
||||
const isDirty = useRunStore((s) => s.dirty)
|
||||
|
||||
const fluxSlot = isFluxActive ? flux : null
|
||||
const isRunning = runStatus === 'running' || runStatus === 'pending'
|
||||
const hasFinished = runStatus === 'completed' || runStatus === 'failed'
|
||||
|
||||
// Keyboard shortcut: Cmd+Enter to run
|
||||
useEffect(() => {
|
||||
if (!fluxSlot?.onRun) return
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
const mod = ev.ctrlKey || ev.metaKey
|
||||
if (mod && ev.key === 'Enter' && !isRunning) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
fluxSlot.onRun?.()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [fluxSlot?.onRun, isRunning])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSlot) return
|
||||
@@ -150,5 +171,40 @@ export function RecollectionEditViewMenus() {
|
||||
[activeSlot, fluxSlot, hasFluxOnly]
|
||||
)
|
||||
|
||||
return menus
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{menus}
|
||||
{fluxSlot?.onRun && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fluxSlot.onRun}
|
||||
disabled={isRunning}
|
||||
className={`relative flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium shadow-sm transition-colors disabled:cursor-not-allowed ${
|
||||
isRunning
|
||||
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
|
||||
: isDirty
|
||||
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
|
||||
: 'border-border/60 bg-card text-foreground hover:bg-accent'
|
||||
}`}
|
||||
aria-label={isRunning ? 'Run in progress' : isDirty ? 'Changes pending — run graph (⌘↵)' : 'Run graph (⌘↵)'}
|
||||
title="⌘↵"
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Square className="size-3 fill-current" />
|
||||
<span>Running</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="size-3 fill-current" />
|
||||
<span>Run</span>
|
||||
</>
|
||||
)}
|
||||
{isDirty && !isRunning && (
|
||||
<span className="absolute -top-1 -right-1 size-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useContext } from "react";
|
||||
import { FlowUIContext } from "@/lib/graph/flowContext";
|
||||
import { useConnectionPathRoleFromStore } from "@/app/canvas/useCanvasConnectionPathFromStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NodeRunStatusOverlay } from "./NodeRunStatusOverlay";
|
||||
|
||||
/** Default min size for resizable nodes (used by NodeResizer). */
|
||||
export const RESIZE_MIN_WIDTH = 120;
|
||||
@@ -102,6 +103,7 @@ export function BaseNode({
|
||||
{!isFullscreenInstance && (
|
||||
<div className={cn(!selected && "pointer-events-none")}>{handles}</div>
|
||||
)}
|
||||
{nodeId && <NodeRunStatusOverlay nodeId={nodeId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GraphContext } from '@/lib/graph/flowContext'
|
||||
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
|
||||
import { NodeHelpPopover } from '@/components/graph/NodeHelpPopover'
|
||||
import { getNodeType, getNodeClassificationLabel } from '@/lib/graph/nodeRegistry'
|
||||
import { NodeRunStatusBadge } from './NodeRunStatusOverlay'
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
@@ -55,6 +56,7 @@ export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props)
|
||||
<span className="shrink-0" title="Node classification">{classificationLabel}</span>
|
||||
</>
|
||||
)}
|
||||
<NodeRunStatusBadge nodeId={nodeId} />
|
||||
<span className="shrink-0 ml-auto">
|
||||
<NodeHelpPopover nodeType={nodeType} />
|
||||
</span>
|
||||
|
||||
84
frontend/src/components/graph/NodeRunStatusOverlay.tsx
Normal file
84
frontend/src/components/graph/NodeRunStatusOverlay.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Run status indicator for nodes. Two exports:
|
||||
* - NodeRunStatusBadge: inline badge for node footers (primary integration point)
|
||||
* - NodeRunStatusOverlay: subtle border overlay for running/pending/failed states
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { useRunStore, type NodeRunStatus } from '@/lib/graph/runStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CheckCircle2, Loader2, XCircle, Clock } from 'lucide-react'
|
||||
|
||||
const badgeConfig: Record<NodeRunStatus, { icon: React.ReactNode; label: string; className: string }> = {
|
||||
pending: {
|
||||
icon: <Clock className="size-3" />,
|
||||
label: 'Pending',
|
||||
className: 'text-muted-foreground',
|
||||
},
|
||||
running: {
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
label: 'Running',
|
||||
className: 'text-blue-500',
|
||||
},
|
||||
completed: {
|
||||
icon: <CheckCircle2 className="size-3" />,
|
||||
label: 'Done',
|
||||
className: 'text-emerald-500',
|
||||
},
|
||||
failed: {
|
||||
icon: <XCircle className="size-3" />,
|
||||
label: 'Failed',
|
||||
className: 'text-destructive',
|
||||
},
|
||||
}
|
||||
|
||||
/** Inline badge for node footers — shows run status next to edge indicators. */
|
||||
export function NodeRunStatusBadge({ nodeId }: { nodeId: string }) {
|
||||
const nodeState = useRunStore((s) => s.nodeStates[nodeId])
|
||||
const runStatus = useRunStore((s) => s.status)
|
||||
|
||||
if (runStatus === 'idle') return null
|
||||
if (!nodeState) return null
|
||||
|
||||
const { icon, label, className } = badgeConfig[nodeState.status]
|
||||
|
||||
return (
|
||||
<span className={cn('flex items-center gap-1 shrink-0', className)} title={label}>
|
||||
{icon}
|
||||
<span className="text-[10px]">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const overlayBorder: Record<NodeRunStatus, string> = {
|
||||
pending: 'border-muted-foreground/20',
|
||||
running: 'border-blue-500/40',
|
||||
completed: 'border-transparent',
|
||||
failed: 'border-destructive/40',
|
||||
}
|
||||
|
||||
/** Subtle border overlay — only visible for running/pending/failed. */
|
||||
export function NodeRunStatusOverlay({ nodeId }: { nodeId: string }) {
|
||||
const nodeState = useRunStore((s) => s.nodeStates[nodeId])
|
||||
const runStatus = useRunStore((s) => s.status)
|
||||
|
||||
if (runStatus === 'idle') return null
|
||||
if (!nodeState) return null
|
||||
// No overlay needed for completed — the footer badge is enough
|
||||
if (nodeState.status === 'completed') return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-10 pointer-events-none rounded-md border-2 transition-colors duration-300',
|
||||
overlayBorder[nodeState.status]
|
||||
)}
|
||||
>
|
||||
{nodeState.error && (
|
||||
<div className="absolute bottom-0 left-0 right-0 truncate rounded-b-md bg-destructive/90 px-2 py-0.5 text-[10px] text-destructive-foreground">
|
||||
{nodeState.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,17 +22,9 @@ import {
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
} from '@/components/ui/menubar'
|
||||
import { Sparkles, Play, ChevronDown, Loader2, RotateCw } from 'lucide-react'
|
||||
import { Sparkles, Loader2, RotateCw } from 'lucide-react'
|
||||
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ButtonGroup } from '@/components/ui/button-group'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import {
|
||||
useRenderingNodeState,
|
||||
@@ -165,95 +157,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
: undefined
|
||||
}
|
||||
right={
|
||||
state.incomingIds.length > 0 ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<ButtonGroup className="nodrag nopan">
|
||||
{state.effectiveUpdateMode === 'manual' ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={state.loading || !state.hasPendingInputs}
|
||||
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
state.incrementRunTrigger()
|
||||
}}
|
||||
title={
|
||||
state.hasPendingInputs
|
||||
? 'Inputs changed — click to render'
|
||||
: !state.hasPendingInputs
|
||||
? 'No new data to render'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{state.loading ? (
|
||||
<Loader2 className="size-3.5 animate-spin shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Play className="size-3.5 shrink-0" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
) : state.loading ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
|
||||
aria-label="Updating"
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin shrink-0" aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`h-7 min-w-[4.5rem] gap-1 pl-2 pr-1.5 text-xs font-normal ${state.effectiveUpdateMode === 'manual' || state.loading ? 'rounded-l-none' : 'rounded-l-md'}`}
|
||||
aria-label="Update mode"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{state.effectiveUpdateMode === 'manual' ? 'Manual' : 'Auto'}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
When to re-render
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={state.effectiveUpdateMode === 'auto'}
|
||||
onCheckedChange={(checked) =>
|
||||
checked && state.setUpdateMode('auto')
|
||||
}
|
||||
className="flex flex-col items-start gap-0.5 py-2"
|
||||
>
|
||||
<span className="font-medium">Auto</span>
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
Re-renders when upstream content changes
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={state.effectiveUpdateMode === 'manual'}
|
||||
onCheckedChange={(checked) =>
|
||||
checked && state.setUpdateMode('manual')
|
||||
}
|
||||
className="flex flex-col items-start gap-0.5 py-2"
|
||||
>
|
||||
<span className="font-medium">Manual</span>
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
Re-renders only when you click Run
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
state.incomingIds.length > 0 && state.loading ? (
|
||||
<Loader2 className="size-3.5 animate-spin shrink-0 text-muted-foreground" aria-label="Rendering" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph/renderingSignatures'
|
||||
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
|
||||
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
||||
import { useRunStore } from '@/lib/graph/runStore'
|
||||
|
||||
export type OutputMode = 'image' | 'string'
|
||||
|
||||
@@ -207,8 +208,11 @@ export function useRenderingNodeState(
|
||||
() => (srcNode?.type ? getSourceRenderingLogic(srcNode.type as string) : null),
|
||||
[srcNode?.type]
|
||||
)
|
||||
const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual'
|
||||
const runTrigger = data?.runTrigger ?? 0
|
||||
// All rendering is now triggered by the global Run button — no auto/manual distinction.
|
||||
const effectiveUpdateMode: 'auto' | 'manual' = 'manual'
|
||||
// Use global run trigger from runStore instead of per-node trigger
|
||||
const globalRunTrigger = useRunStore((s) => s.globalRunTrigger)
|
||||
const runTrigger = globalRunTrigger
|
||||
const outputMode: OutputMode = (data?.outputMode ?? 'image') as OutputMode
|
||||
const setOutputMode = useCallback(
|
||||
(mode: OutputMode) => updateData({ outputMode: mode }),
|
||||
@@ -330,7 +334,7 @@ export function useRenderingNodeState(
|
||||
if (!hasCachedOutput) {
|
||||
setRenderedContent(null)
|
||||
setResolvedContent(null)
|
||||
setError({ kind: 'no-content', message: 'Click Run to render.' })
|
||||
setError({ kind: 'no-content', message: 'Press Run (⌘↵) to render.' })
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
|
||||
109
frontend/src/hooks/useRunStream.ts
Normal file
109
frontend/src/hooks/useRunStream.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* SSE subscriber hook for graph run execution.
|
||||
* Connects to GET /api/runs/:id/stream and updates runStore.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useRunStore } from '@/lib/graph/runStore'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
|
||||
export function useRunStream() {
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const { startRun, setRunStatus, setNodeStatus, appendChunk } = useRunStore()
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connectToRun = useCallback(
|
||||
(runId: string) => {
|
||||
disconnect()
|
||||
startRun(runId)
|
||||
|
||||
const es = new EventSource(`/api/runs/${runId}/stream`)
|
||||
eventSourceRef.current = es
|
||||
|
||||
es.addEventListener('run/started', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
setRunStatus('running')
|
||||
// Initialize all nodes as pending so overlays appear immediately
|
||||
const nodeIds = data.nodeIds as string[] | undefined
|
||||
if (nodeIds) {
|
||||
for (const nodeId of nodeIds) {
|
||||
setNodeStatus(nodeId, { status: 'pending' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('run/completed', () => {
|
||||
setRunStatus('completed')
|
||||
es.close()
|
||||
})
|
||||
|
||||
es.addEventListener('run/failed', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
setRunStatus('failed', data.error)
|
||||
es.close()
|
||||
})
|
||||
|
||||
es.addEventListener('step/started', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
setNodeStatus(data.nodeId, { status: 'running' })
|
||||
// Fire trail animation along edges leading to this node
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: data.nodeId })
|
||||
})
|
||||
|
||||
es.addEventListener('step/completed', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
setNodeStatus(data.nodeId, { status: 'completed', output: data.output })
|
||||
})
|
||||
|
||||
es.addEventListener('step/failed', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
setNodeStatus(data.nodeId, { status: 'failed', error: data.error })
|
||||
})
|
||||
|
||||
es.addEventListener('step/chunk', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
appendChunk(data.nodeId, data.chunk)
|
||||
})
|
||||
|
||||
es.onerror = () => {
|
||||
setRunStatus('failed', 'Connection lost')
|
||||
es.close()
|
||||
}
|
||||
},
|
||||
[disconnect, startRun, setRunStatus, setNodeStatus, appendChunk]
|
||||
)
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => disconnect, [disconnect])
|
||||
|
||||
return { connectToRun, disconnect }
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a new run: POST the graph, then connect SSE.
|
||||
*/
|
||||
export async function createAndStreamRun(
|
||||
recollectionId: string,
|
||||
graph: { nodes: unknown[]; edges: unknown[] },
|
||||
connectToRun: (runId: string) => void
|
||||
): Promise<void> {
|
||||
const res = await fetch('/api/runs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recollectionId, graph }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Failed to create run' }))
|
||||
throw new Error(err.error ?? 'Failed to create run')
|
||||
}
|
||||
|
||||
const { id } = await res.json()
|
||||
connectToRun(id)
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
|
||||
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
|
||||
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData()
|
||||
* also marks this node as a connection-path trigger so edges update on data changes.
|
||||
* also marks the run state as dirty so the Run button shows pending changes.
|
||||
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
|
||||
*
|
||||
* **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should
|
||||
@@ -17,9 +17,23 @@
|
||||
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import { GraphContext } from './flowContext'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
import { nodePropsAreEqual } from './flowUtils'
|
||||
import type { AppNode } from './nodeTypes'
|
||||
import { useRunStore } from './runStore'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Data keys written by the render pipeline cache — these should NOT mark the run state as dirty. */
|
||||
const CACHE_DATA_KEYS = new Set([
|
||||
'cachedRenderedContent',
|
||||
'cachedResolvedContent',
|
||||
'cachedReasoningContent',
|
||||
'cachedOutputValue',
|
||||
'lastRunSourceSignature',
|
||||
'outputMarkdown',
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -87,6 +101,7 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const setEdges = graphCtx?.setEdges
|
||||
|
||||
const markDirty = useRunStore((s) => s.markDirty)
|
||||
const updateData = useCallback(
|
||||
(partial: Partial<TData>) => {
|
||||
if (!setNodes) return
|
||||
@@ -95,9 +110,12 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
||||
) as AppNode[]
|
||||
)
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||
// Only mark dirty for user-facing changes, not internal cache writes from the render pipeline
|
||||
const keys = Object.keys(partial)
|
||||
const isCacheOnly = keys.length > 0 && keys.every((k) => CACHE_DATA_KEYS.has(k))
|
||||
if (!isCacheOnly) markDirty()
|
||||
},
|
||||
[id, setNodes]
|
||||
[id, setNodes, markDirty]
|
||||
)
|
||||
|
||||
const incomingEdges = useMemo(
|
||||
|
||||
99
frontend/src/lib/graph/runStore.ts
Normal file
99
frontend/src/lib/graph/runStore.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Run execution state (Zustand). Tracks active run status and per-node execution state.
|
||||
* Separate from canvasStore to avoid coupling graph editing with run state.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type NodeRunStatus = 'pending' | 'running' | 'completed' | 'failed'
|
||||
export type RunStatus = 'idle' | 'pending' | 'running' | 'completed' | 'failed'
|
||||
|
||||
export type NodeStepState = {
|
||||
status: NodeRunStatus
|
||||
output?: string
|
||||
error?: string
|
||||
chunk?: string
|
||||
}
|
||||
|
||||
export type RunState = {
|
||||
/** Current run ID (null when no run is active) */
|
||||
activeRunId: string | null
|
||||
/** Overall run status */
|
||||
status: RunStatus
|
||||
/** Per-node execution state, keyed by node ID */
|
||||
nodeStates: Record<string, NodeStepState>
|
||||
/** Error message if the run failed */
|
||||
error: string | null
|
||||
/** Whether the graph has changed since the last run */
|
||||
dirty: boolean
|
||||
/** Global trigger counter — render nodes subscribe to this to trigger their pipeline */
|
||||
globalRunTrigger: number
|
||||
}
|
||||
|
||||
type RunActions = {
|
||||
startRun: (runId: string) => void
|
||||
setRunStatus: (status: RunStatus, error?: string) => void
|
||||
setNodeStatus: (nodeId: string, state: Partial<NodeStepState>) => void
|
||||
appendChunk: (nodeId: string, chunk: string) => void
|
||||
markDirty: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const initialState: RunState = {
|
||||
activeRunId: null,
|
||||
status: 'idle',
|
||||
nodeStates: {},
|
||||
error: null,
|
||||
dirty: true,
|
||||
globalRunTrigger: 0,
|
||||
}
|
||||
|
||||
export const useRunStore = create<RunState & RunActions>((set) => ({
|
||||
...initialState,
|
||||
|
||||
startRun: (runId) =>
|
||||
set((s) => ({
|
||||
activeRunId: runId,
|
||||
status: 'pending',
|
||||
nodeStates: {},
|
||||
error: null,
|
||||
dirty: false,
|
||||
globalRunTrigger: s.globalRunTrigger + 1,
|
||||
})),
|
||||
|
||||
setRunStatus: (status, error) =>
|
||||
set({ status, error: error ?? null }),
|
||||
|
||||
setNodeStatus: (nodeId, partial) =>
|
||||
set((s) => ({
|
||||
nodeStates: {
|
||||
...s.nodeStates,
|
||||
[nodeId]: { ...s.nodeStates[nodeId], ...partial } as NodeStepState,
|
||||
},
|
||||
})),
|
||||
|
||||
appendChunk: (nodeId, chunk) =>
|
||||
set((s) => {
|
||||
const prev = s.nodeStates[nodeId]
|
||||
return {
|
||||
nodeStates: {
|
||||
...s.nodeStates,
|
||||
[nodeId]: {
|
||||
...prev,
|
||||
chunk: (prev?.chunk ?? '') + chunk,
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
markDirty: () => set((s) => {
|
||||
// Clear completed/failed overlays when graph changes — stale results no longer meaningful
|
||||
const isFinished = s.status === 'completed' || s.status === 'failed'
|
||||
return {
|
||||
dirty: true,
|
||||
...(isFinished ? { nodeStates: {}, status: 'idle' as RunStatus } : {}),
|
||||
}
|
||||
}),
|
||||
|
||||
reset: () => set((s) => ({ ...initialState, globalRunTrigger: s.globalRunTrigger })),
|
||||
}))
|
||||
@@ -16,6 +16,14 @@ export default defineConfig({
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/runs': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/recollections': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// Kroki diagram service
|
||||
'/api/kroki': {
|
||||
target: 'https://kroki.io',
|
||||
|
||||
Reference in New Issue
Block a user