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'