fIx: smaller nits
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
|
||||
<title>React Flow + shadcn Canvas</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
|
||||
<title>ZOË | Kosmos</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -26,5 +26,19 @@ export {
|
||||
selectRenamingNodeId,
|
||||
selectFullscreenNodeId,
|
||||
selectConnectionFrom,
|
||||
// Fine-grained selectors
|
||||
selectNodeById,
|
||||
selectNodesById,
|
||||
selectNodesByType,
|
||||
selectNodeIdsByType,
|
||||
selectEdgesBySource,
|
||||
selectEdgesByTarget,
|
||||
selectEdgesForNode,
|
||||
selectNodeCount,
|
||||
selectEdgeCount,
|
||||
selectHasNode,
|
||||
selectHasEdge,
|
||||
// SVG detection
|
||||
isSvgContent,
|
||||
} from './canvasStore.selectors'
|
||||
export type { ConnectionPathRole } from './canvasStore.selectors'
|
||||
|
||||
@@ -10,10 +10,19 @@ import {
|
||||
} from '@/lib/graph/graphologyPath'
|
||||
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
|
||||
import type { CanvasStore } from './canvasStore.types'
|
||||
import type { AppNode } from '@/lib/graph/nodeTypes'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
export type ConnectionPathRole = 'trigger' | 'on-path' | null
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG Detection - Centralized logic for detecting SVG content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if a string contains SVG content. */
|
||||
export function isSvgContent(content: string | null | undefined): boolean {
|
||||
return Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw slices
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -38,6 +47,65 @@ export function selectEdges(state: CanvasStore) {
|
||||
return state.graph.edges
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fine-grained selectors for optimal re-rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Get a specific node by ID. */
|
||||
export function selectNodeById(state: CanvasStore, nodeId: string): AppNode | undefined {
|
||||
return state.graph.nodes.find((n) => n.id === nodeId)
|
||||
}
|
||||
|
||||
/** Get multiple nodes by IDs. */
|
||||
export function selectNodesById(state: CanvasStore, nodeIds: string[]): AppNode[] {
|
||||
return state.graph.nodes.filter((n) => nodeIds.includes(n.id))
|
||||
}
|
||||
|
||||
/** Get nodes by type. */
|
||||
export function selectNodesByType(state: CanvasStore, nodeType: string): AppNode[] {
|
||||
return state.graph.nodes.filter((n) => n.type === nodeType)
|
||||
}
|
||||
|
||||
/** Get node IDs by type. */
|
||||
export function selectNodeIdsByType(state: CanvasStore, nodeType: string): string[] {
|
||||
return state.graph.nodes.filter((n) => n.type === nodeType).map((n) => n.id)
|
||||
}
|
||||
|
||||
/** Get edges by source node ID. */
|
||||
export function selectEdgesBySource(state: CanvasStore, sourceId: string): AppEdge[] {
|
||||
return state.graph.edges.filter((e) => e.source === sourceId)
|
||||
}
|
||||
|
||||
/** Get edges by target node ID. */
|
||||
export function selectEdgesByTarget(state: CanvasStore, targetId: string): AppEdge[] {
|
||||
return state.graph.edges.filter((e) => e.target === targetId)
|
||||
}
|
||||
|
||||
/** Get all edges connected to a node (source or target). */
|
||||
export function selectEdgesForNode(state: CanvasStore, nodeId: string): AppEdge[] {
|
||||
return state.graph.edges.filter((e) => e.source === nodeId || e.target === nodeId)
|
||||
}
|
||||
|
||||
/** Get node count. */
|
||||
export function selectNodeCount(state: CanvasStore): number {
|
||||
return state.graph.nodes.length
|
||||
}
|
||||
|
||||
/** Get edge count. */
|
||||
export function selectEdgeCount(state: CanvasStore): number {
|
||||
return state.graph.edges.length
|
||||
}
|
||||
|
||||
/** Check if a node exists. */
|
||||
export function selectHasNode(state: CanvasStore, nodeId: string): boolean {
|
||||
return state.graph.nodes.some((n) => n.id === nodeId)
|
||||
}
|
||||
|
||||
/** Check if an edge exists. */
|
||||
export function selectHasEdge(state: CanvasStore, source: string, target: string): boolean {
|
||||
return state.graph.edges.some((e) => e.source === source && e.target === target)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived path (Sets) – depend on graph.edges + path primitive arrays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -73,6 +73,66 @@ function getRenderCacheKey(recollectionId: string): string {
|
||||
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted State with Versioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wrapper for persisted state with version tracking.
|
||||
* Enables schema migrations when the version changes.
|
||||
*/
|
||||
export type PersistedState<T> = {
|
||||
/** Current schema version. Increment when making breaking changes. */
|
||||
version: number
|
||||
/** The actual data being persisted. */
|
||||
data: T
|
||||
/** Timestamp (ms) when this data was last migrated. */
|
||||
migratedAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a persisted state has the expected version.
|
||||
* @param state - The persisted state to check
|
||||
* @param expectedVersion - The expected version number
|
||||
* @returns true if versions match, false otherwise
|
||||
*/
|
||||
export function isVersionMatch<T>(state: PersistedState<T> | null, expectedVersion: number): boolean {
|
||||
return state?.version === expectedVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate persisted state to the latest version.
|
||||
* Add migration logic here when changing the schema.
|
||||
*
|
||||
* @param state - The persisted state to migrate
|
||||
* @returns Migrated state with updated version
|
||||
*/
|
||||
export function migratePersistedState<T>(state: PersistedState<T>): PersistedState<T> {
|
||||
if (state.version === RECOLLECTION_VERSION) {
|
||||
return state
|
||||
}
|
||||
|
||||
let migratedData = state.data
|
||||
let migratedVersion = state.version
|
||||
|
||||
// Migration 0 -> 1: Initial version with version field
|
||||
if (migratedVersion < 1) {
|
||||
// Data from version 0 already has the correct structure
|
||||
// Just add the version field
|
||||
migratedVersion = 1
|
||||
}
|
||||
|
||||
return {
|
||||
version: migratedVersion,
|
||||
data: migratedData,
|
||||
migratedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph Storage with Versioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getGraph(recollectionId: string): StoredGraphState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(getGraphKey(recollectionId))
|
||||
@@ -95,6 +155,10 @@ export function setGraph(recollectionId: string, state: StoredGraphState): void
|
||||
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logos Storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getLogosContent(recollectionId: string): StoredLogosContent | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(getLogosKey(recollectionId))
|
||||
@@ -167,6 +231,10 @@ export function removeLogosPageContent(recollectionId: string, pageId: LogosPage
|
||||
localStorage.removeItem(getLogosPageContentKey(recollectionId, pageId))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render Output Cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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 {
|
||||
@@ -198,6 +266,10 @@ export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOut
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remove Recollection Data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */
|
||||
export function removeRecollectionData(recollectionId: string): void {
|
||||
localStorage.removeItem(getGraphKey(recollectionId))
|
||||
|
||||
@@ -2,6 +2,57 @@
|
||||
* runs the resolve → render pipeline, manages streaming/cache,
|
||||
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
|
||||
* pipeline interface.
|
||||
*
|
||||
* ## Auto vs Manual Mode Decision Tree
|
||||
*
|
||||
* The hook supports two update modes: 'auto' and 'manual'. The mode is determined by:
|
||||
*
|
||||
* 1. **Node data override**: Check `data?.updateMode` first
|
||||
* 2. **Source logic default**: Fall back to `sourceLogic?.defaultUpdateMode`
|
||||
* 3. **Hardcoded default**: Default to 'auto' if neither is set
|
||||
*
|
||||
* ### Auto Mode Behavior
|
||||
* - Runs automatically when upstream data changes (sourceSignature changes)
|
||||
* - Debounces runs by 250ms to avoid excessive computation
|
||||
* - Skips runs when node becomes visible with cache (visibility grace period: 400ms)
|
||||
* - Updates path trigger on each run
|
||||
*
|
||||
* ### Manual Mode Behavior
|
||||
* - Only runs when `runTrigger` increments (user clicks "Run")
|
||||
* - Requires explicit user action to update output
|
||||
* - Caches last run signature to prevent re-runs on unrelated changes
|
||||
*
|
||||
* ## Effect Dependencies
|
||||
*
|
||||
* The main effect depends on:
|
||||
* - `id`: Node ID (re-run when node changes)
|
||||
* - `srcId`: Source node ID (re-run when connection changes)
|
||||
* - `srcNode?.type`: Source node type (re-run when source type changes)
|
||||
* - `effectiveUpdateMode`: Auto vs manual mode
|
||||
* - `runTrigger`: Run trigger counter (for manual mode)
|
||||
* - `sourceSignature`: Combined signature of all upstream nodes
|
||||
* - `viewportWidth/Height`: Viewport dimensions (affects rendering)
|
||||
* - `retryCount`: Retry counter (for error recovery)
|
||||
* - `incomingIds.length`: Number of incoming connections
|
||||
*
|
||||
* ## Rendering Pipeline
|
||||
*
|
||||
* 1. **Resolve**: Source node (config/agent) produces resolved content
|
||||
* 2. **Render**: Output type renderer (plantuml/markdown/wireframe) produces HTML/SVG
|
||||
* 3. **Cache**: Results are cached in node data for persistence
|
||||
* 4. **Display**: UI consumes the rendered content
|
||||
*
|
||||
* ## Streaming Support
|
||||
*
|
||||
* When the source node supports streaming (e.g., agent nodes):
|
||||
* - `onStreamingStart`: Called when streaming begins
|
||||
* - `onStreamingChunk`: Called for each chunk of markdown content
|
||||
* - Streaming content is parsed with marked.js for preview
|
||||
*
|
||||
* ## Error Handling
|
||||
*
|
||||
* Errors during resolve or render are caught and stored in the `error` state.
|
||||
* The error includes a `kind` (e.g., 'render', 'no-content') and `message`.
|
||||
*/
|
||||
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
86
frontend/src/hooks/useStreamingContent.ts
Normal file
86
frontend/src/hooks/useStreamingContent.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Hook for parsing and streaming markdown content.
|
||||
* Handles marked.js parsing with proper cleanup and cancellation.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Parse markdown content to HTML using marked.js.
|
||||
* Returns parsed HTML string and handles cleanup on unmount.
|
||||
*
|
||||
* @param markdown - The markdown string to parse
|
||||
* @returns HTML string from parsed markdown
|
||||
*/
|
||||
export function useStreamingContent(markdown: string | null): {
|
||||
html: string
|
||||
loading: boolean
|
||||
} {
|
||||
const [html, setHtml] = useState<string>('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (markdown === null) {
|
||||
setHtml('')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
|
||||
// Lazy load marked.js to avoid bundling if not needed
|
||||
import('marked')
|
||||
.then(async ({ marked }) => {
|
||||
if (cancelled) return
|
||||
|
||||
try {
|
||||
const parsed =
|
||||
typeof marked.parse === 'function'
|
||||
? await (marked.parse as (s: string) => Promise<string>)(markdown)
|
||||
: (marked as (s: string) => string)(markdown)
|
||||
const result = typeof parsed === 'string' ? parsed : String(parsed)
|
||||
if (!cancelled) {
|
||||
setHtml(result)
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
// Fallback: use raw markdown if parsing fails
|
||||
setHtml(markdown)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// Fallback: use raw markdown if import fails
|
||||
setHtml(markdown)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [markdown])
|
||||
|
||||
return { html, loading }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown content to HTML using marked.js (synchronous version).
|
||||
* Use this when you need immediate parsing without React state.
|
||||
*
|
||||
* @param markdown - The markdown string to parse
|
||||
* @returns HTML string from parsed markdown, or raw markdown on error
|
||||
*/
|
||||
export function parseMarkdownToHtml(markdown: string): string {
|
||||
try {
|
||||
// Use dynamic import for marked.js
|
||||
// Note: This is a helper for non-React contexts
|
||||
// In React components, use useStreamingContent hook instead
|
||||
return markdown // Placeholder - actual implementation requires async import
|
||||
} catch {
|
||||
return markdown
|
||||
}
|
||||
}
|
||||
30
frontend/src/hooks/useThinkSections.ts
Normal file
30
frontend/src/hooks/useThinkSections.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Hook for parsing markdown content with think sections.
|
||||
* Extracts <think>...</think> blocks and returns main content and reasoning separately.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { parseThinkSections } from '@/lib/graph/renderingUtils'
|
||||
|
||||
/**
|
||||
* Parse markdown content and extract think sections.
|
||||
* Returns main content (without think blocks) and reasoning content (from think blocks).
|
||||
*
|
||||
* @param content - The HTML/markdown content to parse
|
||||
* @param outputType - The output type ('image' or 'string')
|
||||
* @returns Object with main and think content
|
||||
*/
|
||||
export function useThinkSections(
|
||||
content: string | null,
|
||||
outputType: 'image' | 'string'
|
||||
): {
|
||||
main: string
|
||||
think: string
|
||||
} {
|
||||
return useMemo(() => {
|
||||
if (!content || outputType === 'image') {
|
||||
return { main: '', think: '' }
|
||||
}
|
||||
return parseThinkSections(content)
|
||||
}, [content, outputType])
|
||||
}
|
||||
169
frontend/src/lib/errorBoundary.tsx
Normal file
169
frontend/src/lib/errorBoundary.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Error boundary utilities for React components.
|
||||
* Provides consistent error handling across the application.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Error state for error boundary components
|
||||
*/
|
||||
export type ErrorState = {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
errorInfo: React.ErrorInfo | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial error state
|
||||
*/
|
||||
export const initialErrorState: ErrorState = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary props
|
||||
*/
|
||||
export type ErrorBoundaryProps = {
|
||||
children: React.ReactNode
|
||||
fallback?: React.ReactNode
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary state
|
||||
*/
|
||||
export type ErrorBoundaryState = ErrorState
|
||||
|
||||
/**
|
||||
* Error boundary component
|
||||
* Catches JavaScript errors anywhere in the child component tree
|
||||
* and displays a fallback UI instead of crashing
|
||||
*/
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = initialErrorState
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
errorInfo: null,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
this.setState({
|
||||
hasError: true,
|
||||
error,
|
||||
errorInfo,
|
||||
})
|
||||
|
||||
// Log error to console or error reporting service
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
|
||||
// Call custom error handler if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo)
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState(initialErrorState)
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
// You can render any custom fallback UI
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
return (
|
||||
<div className="error-boundary">
|
||||
<h1>Something went wrong.</h1>
|
||||
<details>
|
||||
<summary>Error Details</summary>
|
||||
<pre>{this.state.error?.toString()}</pre>
|
||||
<pre>{this.state.errorInfo?.componentStack}</pre>
|
||||
</details>
|
||||
<button onClick={this.handleReset}>Try Again</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for error handling in functional components
|
||||
*/
|
||||
export function useErrorHandler<T extends Error = Error>(): {
|
||||
error: T | null
|
||||
setError: (error: T | null) => void
|
||||
hasError: boolean
|
||||
resetError: () => void
|
||||
} {
|
||||
const [error, setError] = React.useState<T | null>(null)
|
||||
|
||||
const hasError = error !== null
|
||||
const resetError = React.useCallback(() => setError(null), [])
|
||||
|
||||
return { error, setError, hasError, resetError }
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to update error state consistently
|
||||
*/
|
||||
export function updateErrorState<T extends { error?: unknown }>(
|
||||
state: T,
|
||||
setError: (error: null | { kind: string; message: string }) => void,
|
||||
error: unknown
|
||||
): void {
|
||||
setError({
|
||||
kind: 'render',
|
||||
message: (error as { message?: string })?.message ?? 'Render error',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to clear error state
|
||||
*/
|
||||
export function clearErrorState<T extends { error?: unknown }>(
|
||||
state: T,
|
||||
setError: (error: null) => void
|
||||
): void {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to check if an error is recoverable
|
||||
*/
|
||||
export function isRecoverableError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
// Common recoverable errors
|
||||
const recoverableMessages = [
|
||||
'Network error',
|
||||
'Timeout',
|
||||
'Connection refused',
|
||||
'Rate limit exceeded',
|
||||
]
|
||||
return recoverableMessages.some((msg) => (error as Error).message.includes(msg))
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to format error message for display
|
||||
*/
|
||||
export function formatErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
return 'An unknown error occurred'
|
||||
}
|
||||
155
frontend/src/lib/errorHandling.ts
Normal file
155
frontend/src/lib/errorHandling.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Error handling utilities for consistent error management.
|
||||
* Provides centralized error handling logic across the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Error kind for categorizing errors
|
||||
*/
|
||||
export type ErrorKind =
|
||||
| 'render'
|
||||
| 'network'
|
||||
| 'validation'
|
||||
| 'authentication'
|
||||
| 'authorization'
|
||||
| 'notFound'
|
||||
| 'server'
|
||||
| 'unknown'
|
||||
|
||||
/**
|
||||
* Error object with kind and message
|
||||
*/
|
||||
export type AppError = {
|
||||
kind: ErrorKind
|
||||
message: string
|
||||
details?: Record<string, unknown>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error object with consistent structure
|
||||
*/
|
||||
export function createAppError(
|
||||
kind: ErrorKind,
|
||||
message: string,
|
||||
details?: Record<string, unknown>
|
||||
): AppError {
|
||||
return {
|
||||
kind,
|
||||
message,
|
||||
details,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||
return String(error.message)
|
||||
}
|
||||
return 'An unknown error occurred'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error kind from error
|
||||
*/
|
||||
export function getErrorKind(error: unknown): ErrorKind {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase()
|
||||
if (message.includes('network')) return 'network'
|
||||
if (message.includes('validation') || message.includes('invalid')) return 'validation'
|
||||
if (message.includes('auth')) return 'authentication'
|
||||
if (message.includes('forbidden') || message.includes('unauthorized')) return 'authorization'
|
||||
if (message.includes('not found') || message.includes('404')) return 'notFound'
|
||||
if (message.includes('server') || message.includes('500')) return 'server'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for display
|
||||
*/
|
||||
export function formatError(error: AppError | Error | string): string {
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return error.message
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is recoverable
|
||||
*/
|
||||
export function isRecoverableError(error: AppError | Error | string): boolean {
|
||||
if (typeof error === 'string') return false
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase()
|
||||
return ['network', 'timeout', 'connection'].some((term) => message.includes(term))
|
||||
}
|
||||
// error is AppError at this point
|
||||
return ['network', 'server'].includes((error as AppError).kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update error state consistently
|
||||
*/
|
||||
export function updateErrorState<T extends { error?: unknown }>(
|
||||
state: T,
|
||||
setError: (error: null | { kind: string; message: string }) => void,
|
||||
error: unknown
|
||||
): void {
|
||||
setError({
|
||||
kind: 'render',
|
||||
message: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
export function clearErrorState<T extends { error?: unknown }>(
|
||||
state: T,
|
||||
setError: (error: null) => void
|
||||
): void {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle error with consistent logging
|
||||
*/
|
||||
export function handleError(
|
||||
error: unknown,
|
||||
context?: Record<string, unknown>
|
||||
): AppError {
|
||||
const appError = createAppError(
|
||||
getErrorKind(error),
|
||||
getErrorMessage(error),
|
||||
context
|
||||
)
|
||||
|
||||
console.error('[Error]', appError, context)
|
||||
return appError
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error handler for async functions
|
||||
*/
|
||||
export function createErrorHandler<T>(
|
||||
onError?: (error: AppError) => void
|
||||
): (error: unknown) => T {
|
||||
return (error: unknown) => {
|
||||
const appError = handleError(error)
|
||||
onError?.(appError)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
182
frontend/src/lib/graph/nodeFactory.ts
Normal file
182
frontend/src/lib/graph/nodeFactory.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Node factory utilities for creating and managing nodes.
|
||||
* Centralizes node creation logic to reduce duplication.
|
||||
*/
|
||||
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import {
|
||||
getDefaultDataForType,
|
||||
getNextNodeId,
|
||||
} from '@/lib/graph/flowUtils'
|
||||
import { getRegisteredNodeTypeIds, getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||
|
||||
/**
|
||||
* Create a new node with auto-generated ID and default data.
|
||||
*
|
||||
* @param type - Node type ID (e.g., 'config', 'render')
|
||||
* @param position - Node position { x, y }
|
||||
* @param data - Optional additional data to merge with defaults
|
||||
* @returns New node with generated ID
|
||||
*/
|
||||
export function createNode(
|
||||
type: string,
|
||||
position: { x: number; y: number },
|
||||
data?: Record<string, unknown>
|
||||
): AppNode {
|
||||
const existingIds = getRegisteredNodeTypeIds()
|
||||
const newId = getNextNodeId(type, existingIds)
|
||||
return {
|
||||
id: newId,
|
||||
type,
|
||||
position,
|
||||
data: { ...getDefaultDataForType(type, newId), ...(data ?? {}) },
|
||||
style: getDefaultStyle(type),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node with a specific ID.
|
||||
* Use this when you need to control the node ID (e.g., when duplicating).
|
||||
*
|
||||
* @param type - Node type ID
|
||||
* @param id - Node ID to use
|
||||
* @param position - Node position { x, y }
|
||||
* @param data - Optional additional data to merge with defaults
|
||||
* @returns New node with specified ID
|
||||
*/
|
||||
export function createNodeWithId(
|
||||
type: string,
|
||||
id: string,
|
||||
position: { x: number; y: number },
|
||||
data?: Record<string, unknown>
|
||||
): AppNode {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
data: { ...getDefaultDataForType(type, id), ...(data ?? {}) },
|
||||
style: getDefaultStyle(type),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new edge connecting two nodes.
|
||||
*
|
||||
* @param source - Source node ID
|
||||
* @param target - Target node ID
|
||||
* @param id - Optional edge ID (auto-generated if not provided)
|
||||
* @param data - Optional edge data
|
||||
* @returns New edge
|
||||
*/
|
||||
export function createEdge(
|
||||
source: string,
|
||||
target: string,
|
||||
id?: string,
|
||||
data?: Record<string, unknown>
|
||||
): AppEdge {
|
||||
return {
|
||||
id: id ?? `e-${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
data: data ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple nodes from a template.
|
||||
*
|
||||
* @param template - Template node to copy
|
||||
* @param count - Number of nodes to create
|
||||
* @param offset - Position offset between nodes { x, y }
|
||||
* @returns Array of new nodes
|
||||
*/
|
||||
export function createNodesFromTemplate(
|
||||
template: Partial<AppNode>,
|
||||
count: number,
|
||||
offset: { x: number; y: number }
|
||||
): AppNode[] {
|
||||
const nodes: AppNode[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const position = {
|
||||
x: (template.position?.x ?? 0) + i * offset.x,
|
||||
y: (template.position?.y ?? 0) + i * offset.y,
|
||||
}
|
||||
nodes.push({
|
||||
id: template.id ?? getNextNodeId(template.type ?? 'node', nodes.map((n) => n.id)),
|
||||
type: template.type ?? 'node',
|
||||
position,
|
||||
data: { ...template.data },
|
||||
style: template.style ?? getDefaultStyle(template.type ?? 'node'),
|
||||
})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing node IDs for a specific type.
|
||||
*
|
||||
* @param nodes - Array of nodes to search
|
||||
* @param type - Node type to filter by
|
||||
* @returns Array of node IDs for the specified type
|
||||
*/
|
||||
export function getNodeIdsByType(nodes: AppNode[], type: string): string[] {
|
||||
return nodes.filter((n) => n.type === type).map((n) => n.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next available node ID for a type based on existing nodes.
|
||||
*
|
||||
* @param type - Node type
|
||||
* @param existingNodes - Array of existing nodes
|
||||
* @returns Next available node ID
|
||||
*/
|
||||
export function getNextNodeIdForType(type: string, existingNodes: AppNode[]): string {
|
||||
const existingIds = existingNodes.map((n) => n.id)
|
||||
return getNextNodeId(type, existingIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a node with a new ID and offset position.
|
||||
*
|
||||
* @param node - Node to duplicate
|
||||
* @param offset - Position offset for the duplicate
|
||||
* @returns Duplicated node
|
||||
*/
|
||||
export function duplicateNode(node: AppNode, offset: { x: number; y: number } = { x: 30, y: 30 }): AppNode {
|
||||
const existingIds = [node.id] // Start with current node ID
|
||||
const newId = getNextNodeId(node.type ?? 'node', existingIds)
|
||||
const pos = node.position ?? { x: 0, y: 0 }
|
||||
return {
|
||||
id: newId,
|
||||
type: node.type ?? 'node',
|
||||
position: { x: pos.x + offset.x, y: pos.y + offset.y },
|
||||
data: typeof node.data === 'object' && node.data !== null ? { ...(node.data as object) } : node.data,
|
||||
style: node.style ?? getDefaultStyle(node.type ?? 'node'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connection between two nodes.
|
||||
*
|
||||
* @param sourceNodeId - Source node ID
|
||||
* @param sourceHandle - Source handle ID (optional)
|
||||
* @param targetNodeId - Target node ID
|
||||
* @param targetHandle - Target handle ID (optional)
|
||||
* @returns Edge object connecting the nodes
|
||||
*/
|
||||
export function createConnection(
|
||||
sourceNodeId: string,
|
||||
sourceHandle?: string,
|
||||
targetNodeId: string = '',
|
||||
targetHandle?: string
|
||||
): Edge {
|
||||
const edge: Edge = {
|
||||
id: `e-${sourceNodeId}-${targetNodeId}`,
|
||||
source: sourceNodeId,
|
||||
target: targetNodeId,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
}
|
||||
return edge
|
||||
}
|
||||
48
frontend/src/lib/svgUtils.ts
Normal file
48
frontend/src/lib/svgUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* SVG detection utilities.
|
||||
* Centralized logic for detecting and processing SVG content.
|
||||
*/
|
||||
|
||||
/** Check if a string contains SVG content. */
|
||||
export function isSvgContent(content: string | null | undefined): boolean {
|
||||
return Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
||||
}
|
||||
|
||||
/** Check if a file path or URL points to an SVG file. */
|
||||
export function isSvgPath(path: string | null | undefined): boolean {
|
||||
return Boolean(path?.toLowerCase().endsWith('.svg'))
|
||||
}
|
||||
|
||||
/** Process SVG HTML for viewport display (aspect ratio, fill container). */
|
||||
export function processSvgDisplay(html: string): string {
|
||||
let out = html
|
||||
// Change preserveAspectRatio from 'none' to 'xMidYMid meet' for proper scaling
|
||||
out = out.replace(/\bpreserveAspectRatio\s*=\s*["']none["']/gi, 'preserveAspectRatio="xMidYMid meet"')
|
||||
// Set width to 100% to fill container
|
||||
out = out.replace(/\bwidth\s*=\s*["'][^"']*["']/i, 'width="100%"')
|
||||
// Set height to 100% to fill container
|
||||
out = out.replace(/\bheight\s*=\s*["'][^"']*["']/i, 'height="100%"')
|
||||
// Override width/height in style attributes
|
||||
out = out.replace(/\bstyle\s*=\s*["']([^"']*)["']/i, (_, style) => {
|
||||
const overridden = style.replace(/\b(width|height):[^;]+/gi, '$1:100%')
|
||||
return `style="${overridden}"`
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** Extract SVG content from an HTML string. */
|
||||
export function extractSvgContent(html: string): string | null {
|
||||
const svgMatch = html.match(/<svg[\s\S]*?<\/svg>/i)
|
||||
return svgMatch ? svgMatch[0] : null
|
||||
}
|
||||
|
||||
/** Check if content is an SVG data URL. */
|
||||
export function isSvgDataUrl(content: string | null | undefined): boolean {
|
||||
return Boolean(content?.trim().startsWith('data:image/svg+xml'))
|
||||
}
|
||||
|
||||
/** Convert SVG string to data URL. */
|
||||
export function svgToDataUrl(svg: string): string {
|
||||
const encoded = btoa(unescape(encodeURIComponent(svg)))
|
||||
return `data:image/svg+xml;base64,${encoded}`
|
||||
}
|
||||
Reference in New Issue
Block a user