fix: folder org
This commit is contained in:
240
frontend/src/lib/graph/configTypes.ts
Normal file
240
frontend/src/lib/graph/configTypes.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Registry of config node types. Each type defines syntax highlighting,
|
||||
* insert-menu blocks, and how to render the content (after Nunjucks) for the renderer.
|
||||
* Add a new entry here and wire its language in ConfigNode to add a new config type.
|
||||
*/
|
||||
|
||||
export type ConfigTypeId = 'plantuml' | 'markdown' | 'wireframe'
|
||||
|
||||
/** A single insert option (label + snippet to insert at cursor). */
|
||||
export type InsertBlock = { label: string; snippet: string }
|
||||
|
||||
/** A group of insert options (e.g. "Diagram" with startuml, actor, etc.). */
|
||||
export type InsertBlockGroup = { label: string; items: InsertBlock[] }
|
||||
|
||||
export type InsertBlockOrGroup = InsertBlock | InsertBlockGroup
|
||||
|
||||
function isGroup(b: InsertBlockOrGroup): b is InsertBlockGroup {
|
||||
return 'items' in b && Array.isArray((b as InsertBlockGroup).items)
|
||||
}
|
||||
|
||||
/** Optional options passed to render (e.g. node size for wireframe SVG). */
|
||||
export type RenderOptions = { width?: number; height?: number }
|
||||
|
||||
/** How the renderer should display this type: HTML in a div, or image (SVG/PNG) in a viewport. */
|
||||
export type ConfigOutputType = 'html' | 'image'
|
||||
|
||||
export type ConfigType = {
|
||||
id: ConfigTypeId
|
||||
label: string
|
||||
/** How the RenderingNode should display output: 'html' (div) or 'image' (viewport). */
|
||||
outputType: ConfigOutputType
|
||||
/** CodeMirror language key; used to pick the extension in ConfigNode. */
|
||||
language: ConfigTypeId
|
||||
/** Options under Insert → [type-specific]. Can be flat blocks or groups. */
|
||||
insertBlocks: InsertBlockOrGroup[]
|
||||
/** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */
|
||||
render: (content: string, options?: RenderOptions) => Promise<string>
|
||||
}
|
||||
|
||||
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
||||
const KROKI_TIMEOUT_MS = 15000
|
||||
|
||||
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
||||
{ label: 'Actor', snippet: 'actor ' },
|
||||
{ label: 'Participant', snippet: 'participant "" as ' },
|
||||
{ label: 'Arrow', snippet: ' -> ' },
|
||||
{ label: 'Note', snippet: 'note right of ' },
|
||||
{
|
||||
label: 'Templating',
|
||||
items: [
|
||||
{ label: 'Variable {{ }}', snippet: '{{ }}' },
|
||||
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
|
||||
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
|
||||
{ label: 'set', snippet: '{% set = %}' },
|
||||
{ label: 'extends', snippet: '{% extends "" %}' },
|
||||
{ label: 'include', snippet: '{% include "" %}' },
|
||||
{ label: 'import', snippet: '{% import "" as %}' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const MARKDOWN_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||
{ label: 'Heading 1', snippet: '# ' },
|
||||
{ label: 'Heading 2', snippet: '## ' },
|
||||
{ label: 'Heading 3', snippet: '### ' },
|
||||
{ label: 'Bold', snippet: '****' },
|
||||
{ label: 'Italic', snippet: '**' },
|
||||
{ label: 'Code inline', snippet: '``' },
|
||||
{ label: 'Code block', snippet: '```\n\n```' },
|
||||
{ label: 'Link', snippet: '[]()' },
|
||||
{ label: 'Image', snippet: '![]()' },
|
||||
{ label: 'List item', snippet: '- ' },
|
||||
{ label: 'Blockquote', snippet: '> ' },
|
||||
{
|
||||
label: 'Templating',
|
||||
items: [
|
||||
{ label: 'Variable {{ }}', snippet: '{{ }}' },
|
||||
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
|
||||
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
|
||||
{ label: 'set', snippet: '{% set = %}' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Wireweave DSL: https://github.com/wireweave/core */
|
||||
const WIREFRAME_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||
{ label: 'Page', snippet: 'page "Title" {\n \n}' },
|
||||
{ label: 'Card', snippet: 'card p=4 {\n \n}' },
|
||||
{ label: 'Title', snippet: 'title "' },
|
||||
{ label: 'Text', snippet: 'text "' },
|
||||
{ label: 'Button', snippet: 'button "Label"' },
|
||||
{ label: 'Primary button', snippet: 'button "Label" primary' },
|
||||
{ label: 'Input', snippet: 'input placeholder=""' },
|
||||
{ label: 'Row', snippet: 'row {\n col span=6 { }\n}' },
|
||||
{ label: 'Col', snippet: 'col span=6 { }' },
|
||||
{ label: 'Header / Main / Footer', snippet: 'header { }\nmain { }\nfooter { }' },
|
||||
{ label: 'Image', snippet: 'image "" w=400 h=300' },
|
||||
{
|
||||
label: 'Templating',
|
||||
items: [
|
||||
{ label: 'Variable {{ }}', snippet: '{{ }}' },
|
||||
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
|
||||
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
|
||||
{ label: 'set', snippet: '{% set = %}' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Markdown to HTML using dynamic import to avoid loading if only PlantUML is used. */
|
||||
async function renderMarkdown(content: string): Promise<string> {
|
||||
const { marked } = await import('marked')
|
||||
const html = typeof marked.parse === 'function' ? await marked.parse(content) : (marked as (s: string) => string)(content)
|
||||
return typeof html === 'string' ? html : String(html)
|
||||
}
|
||||
|
||||
/** Wireweave DSL to SVG; theme from document dark mode. See https://www.wireweave.org/ */
|
||||
async function renderWireframe(content: string, options?: RenderOptions): Promise<string> {
|
||||
const { parse, renderToSvg } = await import('@wireweave/core')
|
||||
const doc = parse(content)
|
||||
const isDark =
|
||||
typeof document !== 'undefined' &&
|
||||
document.documentElement?.classList?.contains('dark')
|
||||
const { svg } = renderToSvg(doc, {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
width: options?.width ?? 1200,
|
||||
height: options?.height,
|
||||
padding: 24,
|
||||
})
|
||||
return svg
|
||||
}
|
||||
|
||||
export const CONFIG_TYPES: ConfigType[] = [
|
||||
{
|
||||
id: 'plantuml',
|
||||
label: 'Diagram',
|
||||
outputType: 'image',
|
||||
language: 'plantuml',
|
||||
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
||||
render: async (content: string) => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(KROKI_PLANTUML_SVG, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content,
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
if (res.status >= 500) {
|
||||
throw new Error('Diagram service unavailable. Try again later.')
|
||||
}
|
||||
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
||||
}
|
||||
return res.text()
|
||||
} catch (err: unknown) {
|
||||
clearTimeout(timeoutId)
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
|
||||
}
|
||||
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
|
||||
throw new Error('Diagram service unavailable. Check your connection or try again later.')
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
label: 'Markdown',
|
||||
outputType: 'html',
|
||||
language: 'markdown',
|
||||
insertBlocks: MARKDOWN_INSERT_BLOCKS,
|
||||
render: renderMarkdown,
|
||||
},
|
||||
{
|
||||
id: 'wireframe',
|
||||
label: 'Wireframe',
|
||||
outputType: 'image',
|
||||
language: 'markdown',
|
||||
insertBlocks: WIREFRAME_INSERT_BLOCKS,
|
||||
render: renderWireframe,
|
||||
},
|
||||
]
|
||||
|
||||
export const CONFIG_TYPE_IDS = CONFIG_TYPES.map((t) => t.id)
|
||||
export const DEFAULT_CONFIG_TYPE_ID: ConfigTypeId = 'plantuml'
|
||||
|
||||
export function getConfigType(id: ConfigTypeId): ConfigType {
|
||||
const t = CONFIG_TYPES.find((c) => c.id === id)
|
||||
if (!t) throw new Error(`Unknown config type: ${id}`)
|
||||
return t
|
||||
}
|
||||
|
||||
export function getDefaultContentForConfigType(configTypeId: ConfigTypeId): string {
|
||||
if (configTypeId === 'plantuml') return '@startuml\n\n@enduml\n'
|
||||
if (configTypeId === 'markdown') return ''
|
||||
if (configTypeId === 'wireframe') {
|
||||
return `page "Hello" {
|
||||
card p=4 {
|
||||
title "Welcome"
|
||||
text "Hello, wireweave!"
|
||||
button "Get Started" primary
|
||||
}
|
||||
}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Flatten insert blocks for iteration: either a single block or a group's items. */
|
||||
export function* iterateInsertBlocks(blocks: InsertBlockOrGroup[]): Generator<InsertBlock> {
|
||||
for (const b of blocks) {
|
||||
if (isGroup(b)) {
|
||||
for (const item of b.items) yield item
|
||||
} else {
|
||||
yield b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { isGroup }
|
||||
|
||||
/** Content from config node data (backward compat: content ?? plantuml). */
|
||||
export function getConfigContent(data: Record<string, unknown> | undefined): string {
|
||||
if (!data) return ''
|
||||
const content = data.content ?? data.plantuml
|
||||
return typeof content === 'string' ? content : ''
|
||||
}
|
||||
|
||||
/** Config type id from data (default plantuml). */
|
||||
export function getConfigTypeId(data: Record<string, unknown> | undefined): ConfigTypeId {
|
||||
if (!data || data.configType == null) return 'plantuml'
|
||||
const id = data.configType
|
||||
return CONFIG_TYPE_IDS.includes(id as ConfigTypeId) ? (id as ConfigTypeId) : 'plantuml'
|
||||
}
|
||||
51
frontend/src/lib/graph/connectionStatus.ts
Normal file
51
frontend/src/lib/graph/connectionStatus.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Connection status: visual state of an edge (color/class).
|
||||
* Priority when multiple apply: error > paused > updating > default.
|
||||
*
|
||||
* Status is derived from node lifecycle state: nodes report updating / paused / error
|
||||
* via useSyncConnectionStatus() in nodeLifecycle.ts, which updates FlowContext sets.
|
||||
* Edges read those sets here to pick the single status per edge.
|
||||
*/
|
||||
|
||||
export type ConnectionStatus = 'default' | 'updating' | 'paused' | 'error'
|
||||
|
||||
export type ConnectionStatusInputs = {
|
||||
source: string
|
||||
target: string
|
||||
pathNodeIds: Set<string>
|
||||
pausedSegmentNodeIds: Set<string>
|
||||
activeSegmentNodeIds: Set<string>
|
||||
errorTargetNodeIds: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the single connection status for an edge (priority: error > paused > updating > default).
|
||||
* Use in edge components; add new statuses by extending the type and adding a branch here.
|
||||
*/
|
||||
export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionStatus {
|
||||
const { target, pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds, errorTargetNodeIds, source } = inputs
|
||||
if (errorTargetNodeIds.has(target)) return 'error'
|
||||
if (
|
||||
pathNodeIds.has(source) &&
|
||||
pathNodeIds.has(target) &&
|
||||
pausedSegmentNodeIds.has(source) &&
|
||||
pausedSegmentNodeIds.has(target)
|
||||
)
|
||||
return 'paused'
|
||||
if (
|
||||
pathNodeIds.has(source) &&
|
||||
pathNodeIds.has(target) &&
|
||||
activeSegmentNodeIds.has(source) &&
|
||||
activeSegmentNodeIds.has(target)
|
||||
)
|
||||
return 'updating'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
/** CSS class suffix for each status (animated-edge-path--{status}). */
|
||||
export const CONNECTION_STATUS_CLASS: Record<ConnectionStatus, string> = {
|
||||
default: '',
|
||||
updating: 'animated-edge-path--updating',
|
||||
paused: 'animated-edge-path--paused',
|
||||
error: 'animated-edge-path--error',
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import type React from 'react'
|
||||
import { registerSourceRenderingLogic } from '../sourceRenderingLogic'
|
||||
import type { SourceRenderingLogic } from '../sourceRenderingLogic'
|
||||
import { registerSourceRenderingLogic } from './sourceRenderingLogic'
|
||||
import type { SourceRenderingLogic } from './sourceRenderingLogic'
|
||||
|
||||
/** Node classification for UI: Psyche, Pneuma, Physis, Archon (AI Agent). */
|
||||
export type NodeClassification = 'psyche' | 'pneuma' | 'physis' | 'archon'
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import type React from 'react'
|
||||
import type { NodeTypeDescriptor, NodeClassification, NodeHelpEntry } from './nodeRegistry'
|
||||
import type { SourceRenderingLogic } from '../sourceRenderingLogic'
|
||||
import type { SourceRenderingLogic } from './sourceRenderingLogic'
|
||||
|
||||
type OptionalDescriptor = Partial<
|
||||
Omit<
|
||||
|
||||
47
frontend/src/lib/graph/sourceRenderingLogic.ts
Normal file
47
frontend/src/lib/graph/sourceRenderingLogic.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Source rendering logic: each node type that can feed the Rendering node
|
||||
* registers how to get "resolved" content and default update behavior.
|
||||
* The Rendering node uses this to run the right logic and respect auto vs manual updates.
|
||||
*
|
||||
* Register in registerBuiltinNodes (or at app init) via registerSourceRenderingLogic(nodeType, logic).
|
||||
* Node types that are allowed sources for the Renderer should register here (see nodeRegistry NodeTypeDescriptor).
|
||||
*/
|
||||
|
||||
import type { ConfigTypeId } from './configTypes'
|
||||
|
||||
export type SourceRenderingLogicContext = {
|
||||
nodes: { id: string; type?: string; data?: unknown }[]
|
||||
edges: { id: string; source: string; target: string }[]
|
||||
sourceNodeId: string
|
||||
renderNodeId: string
|
||||
viewportWidth?: number
|
||||
viewportHeight?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of getResolvedContent: resolved string plus the config type to use for final render.
|
||||
*/
|
||||
export type ResolvedContentResult = {
|
||||
resolved: string
|
||||
outputTypeId: ConfigTypeId
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendering logic provided by a source node type (e.g. config, agent).
|
||||
* - defaultUpdateMode: 'auto' = re-render on upstream changes; 'manual' = only on Run
|
||||
* - getResolvedContent: async resolve step; returns resolved string and which renderer (ConfigTypeId) to use
|
||||
*/
|
||||
export type SourceRenderingLogic = {
|
||||
defaultUpdateMode: 'auto' | 'manual'
|
||||
getResolvedContent: (context: SourceRenderingLogicContext) => Promise<ResolvedContentResult>
|
||||
}
|
||||
|
||||
const registry = new Map<string, SourceRenderingLogic>()
|
||||
|
||||
export function registerSourceRenderingLogic(nodeType: string, logic: SourceRenderingLogic): void {
|
||||
registry.set(nodeType, logic)
|
||||
}
|
||||
|
||||
export function getSourceRenderingLogic(nodeType: string): SourceRenderingLogic | null {
|
||||
return registry.get(nodeType) ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user