diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 8e2b347..a7a75b9 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -31,6 +31,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
+ "markdown-to-jsx": "^9.7.9",
"marked": "^17.0.4",
"next-themes": "^0.4.6",
"nunjucks": "^3.2.4",
@@ -5949,6 +5950,34 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/markdown-to-jsx": {
+ "version": "9.7.9",
+ "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.7.9.tgz",
+ "integrity": "sha512-0gO/MOmtM9N2m2F4SUwMqskQe4qih9vaA8M3MEPu51UqjX5MMb/IYSIomLVIyHjmkWql20GuXiyYsPzt6c54FA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "react": ">= 16.0.0",
+ "solid-js": ">=1.0.0",
+ "vue": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "solid-js": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
"node_modules/marked": {
"version": "17.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 0e18528..3843135 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -31,6 +31,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
+ "markdown-to-jsx": "^9.7.9",
"marked": "^17.0.4",
"next-themes": "^0.4.6",
"nunjucks": "^3.2.4",
diff --git a/frontend/src/components/nodes/render/RenderingNode.tsx b/frontend/src/components/nodes/render/RenderingNode.tsx
index 0240d1b..7c5d52d 100644
--- a/frontend/src/components/nodes/render/RenderingNode.tsx
+++ b/frontend/src/components/nodes/render/RenderingNode.tsx
@@ -39,7 +39,13 @@ import {
useRenderingNodeState,
type RenderingNodeData,
} from './useRenderingNodeState'
-import { ImageOutputView, MarkdownOutputView, RawOutputView } from './views'
+import {
+ ImageOutputView,
+ MarkdownJsxView,
+ RawOutputView,
+ StaticMarkdownHtmlView,
+ StreamingMarkdownView,
+ } from './views'
export type { RenderingNodeData }
@@ -114,10 +120,19 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
/>
)
}
- return
+ const isConfigMarkdown =
+ state.sourceNodeType === 'config' && state.rawLanguage === 'markdown'
+ if (isConfigMarkdown) {
+ return (
+
+
+
+ )
+ }
+ return
}
if (state.loading && state.streamingMarkdown !== null) {
- return
+ return
}
if (state.loading) {
return (
diff --git a/frontend/src/components/nodes/render/views/MarkdownJsxView.tsx b/frontend/src/components/nodes/render/views/MarkdownJsxView.tsx
new file mode 100644
index 0000000..45dc1c0
--- /dev/null
+++ b/frontend/src/components/nodes/render/views/MarkdownJsxView.tsx
@@ -0,0 +1,29 @@
+/**
+ * Renders markdown as React (JSX) using markdown-to-jsx.
+ * Used for config node markdown preview to avoid dangerouslySetInnerHTML and support
+ * safe, composable rendering. See https://markdown-to-jsx.quantizor.dev/
+ */
+
+import React from 'react'
+import Markdown from 'markdown-to-jsx/react'
+
+const MARKDOWN_PREVIEW_CLASS =
+ 'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
+
+export type MarkdownJsxViewProps = {
+ /** Raw markdown string (e.g. resolved content from config node). */
+ markdown: string
+ /** Optional wrapper className (defaults to MARKDOWN_PREVIEW_CLASS). */
+ className?: string
+}
+
+export function MarkdownJsxView({ markdown, className = MARKDOWN_PREVIEW_CLASS }: MarkdownJsxViewProps) {
+ if (!markdown.trim()) {
+ return
+ }
+ return (
+
+ {markdown}
+
+ )
+}
diff --git a/frontend/src/components/nodes/render/views/MarkdownOutputView.tsx b/frontend/src/components/nodes/render/views/MarkdownOutputView.tsx
deleted file mode 100644
index ac1b0bb..0000000
--- a/frontend/src/components/nodes/render/views/MarkdownOutputView.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import React from 'react'
-import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
-import { ChevronDown } from 'lucide-react'
-import type { RenderingNodeState } from '../useRenderingNodeState'
-
-const MARKDOWN_CLASS = 'rendering-markdown p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
-const MARKDOWN_MAIN_CLASS = 'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
-
-export type MarkdownOutputViewProps = {
- /** Final content: reasoning + think + main */
- state: RenderingNodeState
- /** When true, show streaming content (streamingThinkSplit, streamingPreviewHtml, streamingMarkdown) instead of final */
- streaming: boolean
-}
-
-export function MarkdownOutputView({ state, streaming }: MarkdownOutputViewProps) {
- if (streaming) {
- return (
-
- {state.streamingThinkSplit.think ? (
-
-
- Thinking
-
-
-
- {state.streamingPreviewHtml ? (
-
- ) : (
-
- {state.streamingThinkSplit.think}
-
- )}
-
-
- ) : null}
- {state.streamingPreviewHtml ? (
-
- ) : (
-
- {state.streamingThinkSplit.main || state.streamingMarkdown}
-
- )}
-
- )
- }
-
- return (
-
- {state.reasoningHtml ? (
-
-
- Reasoning
-
-
-
-
-
-
- ) : null}
- {state.renderedThinkSplit.think ? (
-
-
- Thinking
-
-
-
-
-
-
- ) : null}
-
-
- )
-}
diff --git a/frontend/src/components/nodes/render/views/StaticMarkdownHtmlView.tsx b/frontend/src/components/nodes/render/views/StaticMarkdownHtmlView.tsx
new file mode 100644
index 0000000..88f1573
--- /dev/null
+++ b/frontend/src/components/nodes/render/views/StaticMarkdownHtmlView.tsx
@@ -0,0 +1,57 @@
+/**
+ * Renders final (non-streaming) markdown as HTML: reasoning + think collapsibles + main.
+ * Used for agent final output and any other source that uses the marked → HTML pipeline.
+ */
+
+import React from 'react'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
+import { ChevronDown } from 'lucide-react'
+import type { RenderingNodeState } from '../useRenderingNodeState'
+
+const MARKDOWN_CLASS =
+ 'rendering-markdown p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
+const MARKDOWN_MAIN_CLASS =
+ 'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
+
+export type StaticMarkdownHtmlViewProps = {
+ state: RenderingNodeState
+}
+
+export function StaticMarkdownHtmlView({ state }: StaticMarkdownHtmlViewProps) {
+ return (
+
+ {state.reasoningHtml ? (
+
+
+ Reasoning
+
+
+
+
+
+
+ ) : null}
+ {state.renderedThinkSplit.think ? (
+
+
+ Thinking
+
+
+
+
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/frontend/src/components/nodes/render/views/StreamingMarkdownView.tsx b/frontend/src/components/nodes/render/views/StreamingMarkdownView.tsx
new file mode 100644
index 0000000..dbcf222
--- /dev/null
+++ b/frontend/src/components/nodes/render/views/StreamingMarkdownView.tsx
@@ -0,0 +1,59 @@
+/**
+ * Renders streaming markdown from the agent node.
+ * Uses marked → HTML for live preview during stream; think/reasoning in a collapsible.
+ * Kept separate from config-node markdown preview (MarkdownJsxView) so streaming (agent)
+ * and static markdown (config) are distinct code paths.
+ */
+
+import React from 'react'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
+import { ChevronDown } from 'lucide-react'
+import type { RenderingNodeState } from '../useRenderingNodeState'
+
+const MARKDOWN_CLASS =
+ 'rendering-markdown p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
+const MARKDOWN_MAIN_CLASS =
+ 'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
+
+export type StreamingMarkdownViewProps = {
+ state: RenderingNodeState
+}
+
+export function StreamingMarkdownView({ state }: StreamingMarkdownViewProps) {
+ return (
+
+ {state.streamingThinkSplit.think ? (
+
+
+ Thinking
+
+
+
+ {state.streamingPreviewHtml ? (
+
+ ) : (
+
+ {state.streamingThinkSplit.think}
+
+ )}
+
+
+ ) : null}
+ {state.streamingPreviewHtml ? (
+
+ ) : (
+
+ {state.streamingThinkSplit.main || state.streamingMarkdown}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/nodes/render/views/index.ts b/frontend/src/components/nodes/render/views/index.ts
index aa037fd..2e4d3bf 100644
--- a/frontend/src/components/nodes/render/views/index.ts
+++ b/frontend/src/components/nodes/render/views/index.ts
@@ -1,12 +1,21 @@
/**
* Output view components: "Display" step of the pipeline (see lib/graph/rendering.ts).
* Which view is used comes from the source's outputType ('image' | 'html').
+ *
+ * - Streaming (agent): StreamingMarkdownView
+ * - Config markdown preview: MarkdownJsxView (markdown-to-jsx)
+ * - Agent final / other HTML: StaticMarkdownHtmlView
+ * RenderingNode chooses the view directly; no shared dispatcher.
*/
export { ImageOutputView } from './ImageOutputView'
-export { MarkdownOutputView } from './MarkdownOutputView'
+export { MarkdownJsxView } from './MarkdownJsxView'
export { RawOutputView } from './RawOutputView'
+export { StaticMarkdownHtmlView } from './StaticMarkdownHtmlView'
+export { StreamingMarkdownView } from './StreamingMarkdownView'
export type { ImageOutputViewProps } from './ImageOutputView'
-export type { MarkdownOutputViewProps } from './MarkdownOutputView'
+export type { MarkdownJsxViewProps } from './MarkdownJsxView'
export type { RawOutputViewProps } from './RawOutputView'
+export type { StaticMarkdownHtmlViewProps } from './StaticMarkdownHtmlView'
+export type { StreamingMarkdownViewProps } from './StreamingMarkdownView'