refactor: markdown split form agent

This commit is contained in:
2026-03-12 20:37:16 +01:00
parent 35b2e9d538
commit fb1df18256
8 changed files with 204 additions and 80 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 <MarkdownOutputView state={state} streaming={false} />
const isConfigMarkdown =
state.sourceNodeType === 'config' && state.rawLanguage === 'markdown'
if (isConfigMarkdown) {
return (
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<MarkdownJsxView markdown={state.resolvedContent ?? ''} />
</div>
)
}
return <StaticMarkdownHtmlView state={state} />
}
if (state.loading && state.streamingMarkdown !== null) {
return <MarkdownOutputView state={state} streaming={true} />
return <StreamingMarkdownView state={state} />
}
if (state.loading) {
return (

View File

@@ -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 <div className={className} />
}
return (
<div className={className}>
<Markdown>{markdown}</Markdown>
</div>
)
}

View File

@@ -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 (
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
{state.streamingThinkSplit.think ? (
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
Thinking
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
{state.streamingPreviewHtml ? (
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.think }} />
) : (
<pre className="rendering-markdown max-h-48 overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans text-muted-foreground">
{state.streamingThinkSplit.think}
</pre>
)}
</CollapsibleContent>
</Collapsible>
) : null}
{state.streamingPreviewHtml ? (
<div className={MARKDOWN_MAIN_CLASS} dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.main || state.streamingPreviewHtml }} />
) : (
<pre className="min-h-0 flex-1 w-full overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans">
{state.streamingThinkSplit.main || state.streamingMarkdown}
</pre>
)}
</div>
)
}
return (
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
{state.reasoningHtml ? (
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
Reasoning
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.reasoningHtml }} />
</CollapsibleContent>
</Collapsible>
) : null}
{state.renderedThinkSplit.think ? (
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
Thinking
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.think }} />
</CollapsibleContent>
</Collapsible>
) : null}
<div className={MARKDOWN_MAIN_CLASS} dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.main }} />
</div>
)
}

View File

@@ -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 (
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
{state.reasoningHtml ? (
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
Reasoning
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<div
className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'}
dangerouslySetInnerHTML={{ __html: state.reasoningHtml }}
/>
</CollapsibleContent>
</Collapsible>
) : null}
{state.renderedThinkSplit.think ? (
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
Thinking
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<div
className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'}
dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.think }}
/>
</CollapsibleContent>
</Collapsible>
) : null}
<div
className={MARKDOWN_MAIN_CLASS}
dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.main }}
/>
</div>
)
}

View File

@@ -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 (
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
{state.streamingThinkSplit.think ? (
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
Thinking
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
{state.streamingPreviewHtml ? (
<div
className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'}
dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.think }}
/>
) : (
<pre className="rendering-markdown max-h-48 overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans text-muted-foreground">
{state.streamingThinkSplit.think}
</pre>
)}
</CollapsibleContent>
</Collapsible>
) : null}
{state.streamingPreviewHtml ? (
<div
className={MARKDOWN_MAIN_CLASS}
dangerouslySetInnerHTML={{
__html: state.streamingThinkSplit.main || state.streamingPreviewHtml,
}}
/>
) : (
<pre className="min-h-0 flex-1 w-full overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans">
{state.streamingThinkSplit.main || state.streamingMarkdown}
</pre>
)}
</div>
)
}

View File

@@ -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'