feat(playground): improve Playground chat experience and Markdown rendering (#5217)
* refactor(playground): streamline chat request state - extract conversation actions from the page component to keep message flow logic reusable. - unify streaming and non-streaming generation state, including abort support for non-stream requests. - simplify message rendering and payload construction while localizing Playground prompts. * fix(playground): validate persisted chat state - wrap saved Playground state with a storage version while still reading legacy values. - validate config, parameter toggles, and messages before restoring them from localStorage. - cap stored chat history to the latest messages to avoid oversized or stale state. * refactor(playground): centralize message content access - route chat rendering, copy actions, and error display through shared message helpers. - reuse the current-version update helper for non-streaming assistant responses. - keep message version details behind utility functions to reduce future model churn. * refactor(playground): split storage schemas - move Playground storage validation schemas into a dedicated module. - keep storage read and write logic focused on migration, trimming, and persistence. - preserve the existing storage envelope and validation behavior. * refactor(playground): extract options loading hook - move model and group queries into a dedicated hook so the page component stays focused on layout wiring. - preserve existing fallback selection and error toast behavior while reusing the hook through the playground barrel export. * refactor(playground): extract prompt suggestions - move static prompt suggestion rendering into a focused component so the input stays centered on compose controls. - preserve translated suggestion submission behavior while isolating icon metadata from the input form. * refactor(playground): extract input tools - move attachment and search controls into a dedicated component so the prompt input stays focused on compose state. - keep existing development toast behavior and disabled handling while centralizing tool metadata. * refactor(playground): extract input controls - move model, group, send, and stop controls into a focused component so the input only manages compose state. - preserve existing disabled states and generation button behavior while isolating control rendering. * refactor(playground): extract message content display - move sources, reasoning, loading, error, and response rendering into a dedicated message content component. - keep the chat list focused on message iteration, edit state, and action wiring without changing display behavior. * refactor(playground): extract message editor - move inline message editing controls into a dedicated editor component so the chat list stays focused on rendering flow. - preserve save, save-and-submit, cancel, and disabled-state behavior for edited messages. * refactor(playground): extract stream error parsing - move SSE error payload parsing into a reusable stream utility so the request hook stays focused on lifecycle handling. - preserve existing error message, error code, and fallback behavior for raw or empty stream errors. * refactor(playground): extract request error parsing - move non-stream request error extraction into a shared utility so the chat handler stays focused on request flow. - preserve the existing response message, error code, and fallback priority for failed chat completions. * refactor(playground): extract streaming chunk updates - move reasoning and content chunk application into a message utility so the chat handler only wires stream events. - preserve error-state skipping, reasoning accumulation, and content streaming behavior for assistant messages. * refactor(playground): extract message reasoning parser - move think tag parsing into a dedicated playground message utility. - export the parser through the shared playground lib barrel for consistent imports. * refactor(playground): extract message streaming utilities - move stream chunk application and message finalization into a dedicated utility. - keep stored message sanitization with the streaming lifecycle helpers. * refactor(playground): extract message update utilities - move assistant message update helpers into a focused playground utility. - keep error-state message updates separate from core message construction helpers. * refactor(playground): extract completion choice handling - move non-streaming choice application into the message streaming utilities. - keep the chat handler focused on request orchestration and message updates. * refactor(playground): centralize assistant completion state - add a helper for finalizing assistant messages with complete status. - reuse the helper in stream completion and stop-generation paths. * refactor(playground): extract stream message parsing - move SSE delta parsing into a shared stream utility. - keep the stream request hook focused on lifecycle handling and update dispatch. * refactor(playground): extract stream ready state checks - move SSE ready-state status handling into stream utilities. - keep weak source status typing outside the stream request hook. * refactor(playground): extract conversation message helpers - move send, regenerate, and edit message list construction into focused utilities. - keep the conversation hook focused on edit state and update dispatch. * refactor(playground): extract state initialization helpers - move playground initial state loading into focused utility helpers. - centralize message state updater resolution outside the React state hook. * refactor(playground): extract option fallback helpers - move model and group fallback selection into focused playground utilities. - keep the options hook focused on query results, toasts, and config updates. * refactor(playground): extract message action helpers - move message action state derivation into focused utilities. - keep the action component focused on guarded handlers and rendering. * refactor(playground): extract input control state - move submit, stop, and selector state derivation into a pure helper. - keep input controls focused on rendering model selectors and action buttons. * refactor(playground): extract message content state - move source, reasoning, loader, and body visibility checks into a pure helper. - use a discriminated state shape so rendered reasoning content stays type-safe. * refactor(playground): extract message editor state - move save eligibility and submit visibility checks into a pure helper. - keep the editor component focused on textarea and button rendering. * refactor(playground): extract message error state - move error kind, fallback content, and admin visibility checks into a pure helper. - centralize the model pricing settings path used by the error action. * refactor(playground): extract chat render state - move editing content lookup and per-message render flags into conversation helpers. - keep the chat component focused on mapping messages to editor and content views. * refactor(playground): extract suggestion display state - move suggestion class selection into a pure helper. - keep the suggestions component focused on translation and rendering. * refactor(playground): extract assistant message state checks - move final and pending assistant status checks into streaming utilities. - keep the chat handler focused on request lifecycle updates. * refactor(playground): extract input tool state - move attachment action metadata and development notices into input tool utilities. - keep the input tools component focused on menu and button rendering. * refactor(playground): extract stream protocol checks - move SSE done-message and closed-ready-state checks into stream utilities. - keep the stream request hook focused on event handling flow. * refactor(playground): extract message removal helper - move delete-message filtering into conversation message utilities. - keep the conversation hook focused on action orchestration. * refactor(playground): extract option error messages - move option load error message selection into playground option utilities - keep the options hook focused on query effects and fallback updates * refactor(playground): extract input submit text helper - move prompt submit text validation into input control utilities - let the input component submit only when a concrete text value is available * refactor(playground): centralize error message checks - add a shared helper for identifying error messages - remove direct status string checks from message content rendering * refactor(playground): extract message content display checks - move loader and content visibility decisions into local helper functions - keep message content state assembly focused on composing render state * refactor(playground): replace raw message role checks - use shared message role constants in conversation edit handling - avoid raw assistant role literals when validating API messages * refactor(playground): extract non-stream response handling - move chat completion response choice handling into message streaming utilities - keep the chat handler focused on request lifecycle and error routing * refactor(playground): centralize stream cleanup - reuse one stream cleanup path for completion, errors, startup failures, and manual stops - preserve the current-source guard when closing SSE streams * refactor(playground): extract pending assistant check - centralize pending assistant message detection in streaming utilities - reuse the helper when sanitizing stored playground messages * perf(playground): improve mobile input controls - split mobile input controls into selector and action rows - keep the desktop input footer compact while reducing mobile control crowding * perf(playground): add starter empty state - show starter prompts in the empty playground chat area - wire empty-state prompt selection into the existing send flow - add localized copy for the new empty state * perf(playground): improve mobile message actions - collapse mobile message actions into a touch-friendly dropdown menu - keep the desktop hover action strip unchanged for pointer workflows - share one action list between desktop buttons and the mobile menu * perf(playground): add error recovery actions - show retry, edit, and delete actions inside error message alerts - route edit recovery to the previous user prompt when available - keep recovery controls touch-friendly on mobile layouts * perf(playground): refine message editing experience - present message edits in a focused bordered editor panel - add unsaved-change state, reset, and cancel confirmation flows - improve mobile touch targets and keyboard shortcuts for editing * perf(playground): improve markdown code blocks - render fenced markdown code with syntax highlighting, line numbers, and fallback plain text - add copy, download, and collapse controls for playground AI responses - tighten code block layout and theme token styles for responsive markdown rendering * fix(playground): constrain markdown code block height - collapse long playground code blocks after a short preview instead of waiting for very large snippets - cap expanded code blocks so long responses scroll inside the code block - keep generic code block usage unconstrained unless a caller opts in * feat(playground): add chat history clearing - add a toolbar action that is enabled only when saved playground messages exist. - confirm destructive clears before removing browser-stored conversation state. - add localized strings for the action, dialog, and completion toast. * perf(playground): improve chat markdown rendering - refine assistant and user message surfaces so chat content matches the app UI. - normalize markdown typography, tables, images, lists, blockquotes, and details rendering. - add indentation cues for collapsible reasoning and source sections. * style: format code block component * style: format playground frontend files * feat(playground): render markdown with stream parser - replace Streamdown with stream-markdown-parser for project-owned markdown rendering and styling. - split response rendering into focused block, inline, table, alert, details, and footnote modules. - pass message final state into response parsing so streaming content can be parsed incrementally. * fix(playground): localize reasoning and chat feedback - translate reasoning status, message actions, playground errors, and response renderer fallbacks across supported locales. - keep reasoning duration numeric and tighten the collapsible layout to prevent trigger jitter. - register dynamic keys so i18n sync keeps runtime labels covered. * refactor(playground): group files by functional area - move chat, input, and message components into focused subdirectories to make the UI structure easier to scan. - split playground helpers into input, message, streaming, storage, options, state, and suggestions modules. - update barrel exports and imports so existing feature entry points continue to work. * fix(playground): prevent history replay from freezing page - defer saved conversation loading so route entry no longer blocks on localStorage parsing and markdown rendering. - limit initial history rendering and skip expensive markdown parsing for oversized responses. - normalize corrupted streaming snapshots and cumulative chunks to keep saved playground history bounded. - add message timing metadata and layout alignment groundwork without introducing live timers. * feat(playground): allow regenerating from user messages - show regenerate actions on user messages with saved content. - truncate following conversation state before starting a fresh assistant response. * feat(playground): add raw response source view - add a per-message source toggle for assistant responses. - render raw response content with the existing code block viewer. - localize the new source and preview action labels. * feat(playground): render code with unified editor - replace Shiki HTML rendering with a read-only CodeMirror view for code blocks and raw responses. - reuse the same CodeMirror frame for message editing so source and edit modes stay visually aligned. - add lightweight CodeMirror dependencies while keeping language support scoped to Markdown. * perf(playground): streamline chat input controls - combine model and group selection into one compact picker for faster context switching. - switch playground action buttons to icon-first controls with tooltips to reduce toolbar width. - refresh input footer styling and submit states so active and destructive actions are clearer. - bump dompurify lockfile entry to keep the frontend dependency current. * fix(playground): filter models by selected group - query user models by the selected playground group instead of reusing the cross-group model union. - clear unavailable model selections and block sending when the active group has no models. - align model selector and error action controls with the existing playground interaction style. * perf(playground): remove input suggestion chips - remove the prompt suggestion row below the playground input to reduce visual noise. - delete the now-unused suggestion component and display helper. * perf(playground): stabilize reasoning trigger layout - use fixed icon slots around the reasoning label so the left content stays still when toggling. - limit the open state animation to the chevron rotation for a smoother collapse interaction. * perf(playground): smooth reasoning expansion - use the collapsible panel height animation for vertical reasoning reveals. - sync inner content opacity and position with the panel state.
This commit is contained in:
+552
-76
@@ -19,125 +19,589 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
'use client'
|
||||
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { EditorState, type Extension } from '@codemirror/state'
|
||||
import { EditorView, lineNumbers } from '@codemirror/view'
|
||||
import { tags as highlightTags } from '@lezer/highlight'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type CSSProperties,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import {
|
||||
type BundledLanguage,
|
||||
codeToHtml,
|
||||
type ShikiTransformer,
|
||||
} from 'shiki/bundle/web'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BundledLanguage } from 'shiki'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string
|
||||
language: BundledLanguage
|
||||
collapsedLines?: number
|
||||
defaultCollapsed?: boolean
|
||||
enableCollapse?: boolean
|
||||
filename?: string
|
||||
language: BundledLanguage | string
|
||||
maxExpandedLines?: number
|
||||
/** @deprecated use collapsedLines for collapsed preview height. */
|
||||
maxCollapsedLines?: number
|
||||
showLineNumbers?: boolean
|
||||
showToolbar?: boolean
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
type CodeBlockEditorProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'onChange' | 'onKeyDown' | 'title'
|
||||
> & {
|
||||
actions?: ReactNode
|
||||
ariaLabel: string
|
||||
language: BundledLanguage | string
|
||||
onChange: (value: string) => void
|
||||
onKeyDown?: (event: globalThis.KeyboardEvent) => void
|
||||
rows?: number
|
||||
title?: ReactNode
|
||||
value: string
|
||||
}
|
||||
|
||||
type CodeMirrorCodeViewProps = {
|
||||
ariaLabel: string
|
||||
autoFocus?: boolean
|
||||
language: BundledLanguage | string
|
||||
onChange?: (value: string) => void
|
||||
onKeyDown?: (event: globalThis.KeyboardEvent) => void
|
||||
readOnly?: boolean
|
||||
rows?: number
|
||||
showLineNumbers?: boolean
|
||||
value: string
|
||||
}
|
||||
|
||||
type CodeBlockFrameProps = Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
|
||||
bodyClassName?: string
|
||||
bodyMaxHeight?: string
|
||||
bodyOverlay?: ReactNode
|
||||
children: ReactNode
|
||||
endActions?: ReactNode
|
||||
showToolbar?: boolean
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string
|
||||
language: string
|
||||
}
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: '',
|
||||
language: 'plaintext',
|
||||
})
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: 'line-numbers',
|
||||
line(node, line) {
|
||||
node.children.unshift({
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: {
|
||||
className: [
|
||||
'inline-block',
|
||||
'min-w-10',
|
||||
'mr-4',
|
||||
'text-right',
|
||||
'select-none',
|
||||
'text-muted-foreground',
|
||||
],
|
||||
},
|
||||
children: [{ type: 'text', value: String(line) }],
|
||||
})
|
||||
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
|
||||
csharp: 'c#',
|
||||
golang: 'go',
|
||||
js: 'javascript',
|
||||
shell: 'bash',
|
||||
shellscript: 'bash',
|
||||
ts: 'typescript',
|
||||
}
|
||||
|
||||
const LANGUAGE_PATTERN = /^[a-z0-9][a-z0-9+#._-]{0,31}$/i
|
||||
const codeMirrorTheme = EditorView.theme({
|
||||
'&': {
|
||||
background: 'transparent',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: '13px',
|
||||
},
|
||||
}
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--foreground)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
lineHeight: '1.5rem',
|
||||
minHeight: 'var(--code-editor-min-height)',
|
||||
minWidth: 'max-content',
|
||||
padding: '1rem 1rem 1rem 0',
|
||||
},
|
||||
'.cm-editor': {
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
},
|
||||
'.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
background: 'transparent',
|
||||
borderRight: '0',
|
||||
color: 'var(--muted-foreground)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5rem',
|
||||
padding: '1rem 1rem 1rem 0',
|
||||
},
|
||||
'.cm-gutters:empty': {
|
||||
display: 'none',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
minWidth: '2.5rem',
|
||||
padding: '0 1rem 0 0',
|
||||
textAlign: 'right',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'var(--font-mono)',
|
||||
lineHeight: '1.5rem',
|
||||
minHeight: 'var(--code-editor-min-height)',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
background:
|
||||
'color-mix(in oklch, var(--primary) 28%, transparent) !important',
|
||||
},
|
||||
})
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
showLineNumbers = false
|
||||
) {
|
||||
const transformers: ShikiTransformer[] = showLineNumbers
|
||||
? [lineNumberTransformer]
|
||||
: []
|
||||
|
||||
return codeToHtml(code, {
|
||||
lang: language,
|
||||
themes: {
|
||||
light: 'one-light',
|
||||
dark: 'one-dark-pro',
|
||||
const codeMirrorHighlightStyle = syntaxHighlighting(
|
||||
HighlightStyle.define([
|
||||
{ tag: highlightTags.heading, color: '#e06c75', fontWeight: '600' },
|
||||
{ tag: [highlightTags.strong, highlightTags.emphasis], color: '#d19a66' },
|
||||
{ tag: [highlightTags.link, highlightTags.url], color: '#61afef' },
|
||||
{
|
||||
tag: [highlightTags.monospace, highlightTags.contentSeparator],
|
||||
color: '#98c379',
|
||||
},
|
||||
transformers,
|
||||
})
|
||||
{
|
||||
tag: [highlightTags.keyword, highlightTags.processingInstruction],
|
||||
color: '#c678dd',
|
||||
},
|
||||
{
|
||||
tag: [highlightTags.atom, highlightTags.bool, highlightTags.number],
|
||||
color: '#d19a66',
|
||||
},
|
||||
{ tag: [highlightTags.string, highlightTags.inserted], color: '#98c379' },
|
||||
{ tag: [highlightTags.deleted, highlightTags.invalid], color: '#e06c75' },
|
||||
{
|
||||
tag: [highlightTags.meta, highlightTags.comment],
|
||||
color: 'var(--muted-foreground)',
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
function getRequestedCodeLanguage(language?: string) {
|
||||
const normalized = language?.trim().toLowerCase() || 'plaintext'
|
||||
if (!LANGUAGE_PATTERN.test(normalized)) {
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
return LANGUAGE_ALIASES[normalized] ?? normalized
|
||||
}
|
||||
|
||||
function getCodeMirrorLanguageExtension(language: BundledLanguage | string) {
|
||||
const requestedLanguage = getRequestedCodeLanguage(language)
|
||||
if (
|
||||
requestedLanguage === 'markdown' ||
|
||||
requestedLanguage === 'md' ||
|
||||
requestedLanguage === 'mdx'
|
||||
) {
|
||||
return markdown()
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function getCodeLineCount(code: string) {
|
||||
if (!code) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return code.split('\n').length
|
||||
}
|
||||
|
||||
function getDownloadFilename(language: string, filename?: string) {
|
||||
if (filename) {
|
||||
return filename
|
||||
}
|
||||
|
||||
const extension = language === 'plaintext' ? 'txt' : language
|
||||
return `code.${extension}`
|
||||
}
|
||||
|
||||
function getCodeBlockHeight(lines: number) {
|
||||
return `${Math.max(4, lines) * 1.5 + 2}rem`
|
||||
}
|
||||
|
||||
function getCodeBlockMaxHeight(
|
||||
isCodeCollapsed: boolean,
|
||||
previewLines: number,
|
||||
maxExpandedLines?: number
|
||||
): string | undefined {
|
||||
if (isCodeCollapsed) {
|
||||
return getCodeBlockHeight(previewLines)
|
||||
}
|
||||
|
||||
if (maxExpandedLines) {
|
||||
return getCodeBlockHeight(maxExpandedLines)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getCodeMirrorExtensions(options: {
|
||||
language: BundledLanguage | string
|
||||
onKeyDown?: (event: globalThis.KeyboardEvent) => void
|
||||
readOnly: boolean
|
||||
showLineNumbers: boolean
|
||||
}): Extension[] {
|
||||
const extensions: Extension[] = [
|
||||
getCodeMirrorLanguageExtension(options.language),
|
||||
codeMirrorHighlightStyle,
|
||||
codeMirrorTheme,
|
||||
EditorState.tabSize.of(2),
|
||||
EditorState.readOnly.of(options.readOnly),
|
||||
EditorView.editable.of(!options.readOnly),
|
||||
]
|
||||
|
||||
if (options.showLineNumbers) {
|
||||
extensions.unshift(lineNumbers())
|
||||
}
|
||||
|
||||
if (options.onKeyDown) {
|
||||
extensions.push(
|
||||
EditorView.domEventHandlers({
|
||||
keydown(event) {
|
||||
options.onKeyDown?.(event)
|
||||
return event.defaultPrevented
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
function CodeMirrorCodeView({
|
||||
ariaLabel,
|
||||
autoFocus = false,
|
||||
language,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
readOnly = false,
|
||||
rows = 8,
|
||||
showLineNumbers = true,
|
||||
value,
|
||||
}: CodeMirrorCodeViewProps) {
|
||||
const editorHostRef = useRef<HTMLDivElement>(null)
|
||||
const editorViewRef = useRef<EditorView | null>(null)
|
||||
const initialValueRef = useRef(value)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const editorMinHeight = `${Math.max(4, rows) * 1.5 + 2}rem`
|
||||
const editorExtensions = useMemo(
|
||||
() =>
|
||||
getCodeMirrorExtensions({
|
||||
language,
|
||||
onKeyDown,
|
||||
readOnly,
|
||||
showLineNumbers,
|
||||
}),
|
||||
[language, onKeyDown, readOnly, showLineNumbers]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
useEffect(() => {
|
||||
const editorHost = editorHostRef.current
|
||||
if (!editorHost) {
|
||||
return
|
||||
}
|
||||
|
||||
const editorView = new EditorView({
|
||||
doc: initialValueRef.current,
|
||||
extensions: [
|
||||
...editorExtensions,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current?.(update.state.doc.toString())
|
||||
}
|
||||
}),
|
||||
],
|
||||
parent: editorHost,
|
||||
})
|
||||
editorViewRef.current = editorView
|
||||
if (autoFocus) {
|
||||
editorView.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
editorView.destroy()
|
||||
editorViewRef.current = null
|
||||
}
|
||||
}, [autoFocus, editorExtensions])
|
||||
|
||||
useEffect(() => {
|
||||
const editorView = editorViewRef.current
|
||||
if (!editorView) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentValue = editorView.state.doc.toString()
|
||||
if (currentValue === value) {
|
||||
return
|
||||
}
|
||||
|
||||
editorView.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editorView.state.doc.length,
|
||||
insert: value,
|
||||
},
|
||||
})
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={ariaLabel}
|
||||
aria-readonly={readOnly}
|
||||
className='min-h-(--code-editor-min-height)'
|
||||
ref={editorHostRef}
|
||||
role='textbox'
|
||||
style={
|
||||
{
|
||||
'--code-editor-min-height': editorMinHeight,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const CodeBlockFrame = ({
|
||||
bodyClassName,
|
||||
bodyMaxHeight,
|
||||
bodyOverlay,
|
||||
children,
|
||||
className,
|
||||
endActions,
|
||||
showToolbar = false,
|
||||
title,
|
||||
...props
|
||||
}: CodeBlockFrameProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group/code-block bg-muted/20 text-foreground my-3 w-full max-w-full overflow-hidden rounded-lg border shadow-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{showToolbar && (
|
||||
<div className='bg-muted/35 border-border/70 flex min-h-10 items-center gap-2 border-b px-2 py-1.5'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='text-muted-foreground truncate font-mono text-[11px] font-medium tracking-wide uppercase'>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
{endActions && (
|
||||
<div className='flex shrink-0 items-center gap-1'>{endActions}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className='relative min-w-0'>
|
||||
<div
|
||||
className={cn(
|
||||
'code-block-scroll max-w-full overflow-auto transition-[max-height] duration-200 ease-out',
|
||||
bodyClassName
|
||||
)}
|
||||
style={{ maxHeight: bodyMaxHeight }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{bodyOverlay}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
collapsedLines = 12,
|
||||
defaultCollapsed,
|
||||
enableCollapse = true,
|
||||
filename,
|
||||
language,
|
||||
maxExpandedLines,
|
||||
maxCollapsedLines,
|
||||
showLineNumbers = false,
|
||||
showToolbar = false,
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const [html, setHtml] = useState<string>('')
|
||||
const { t } = useTranslation()
|
||||
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed))
|
||||
const displayLanguage = getRequestedCodeLanguage(language)
|
||||
const lineCount = useMemo(() => getCodeLineCount(code), [code])
|
||||
const previewLines = maxCollapsedLines ?? collapsedLines
|
||||
const canCollapse = enableCollapse && lineCount > previewLines
|
||||
const isCodeCollapsed = canCollapse && isCollapsed
|
||||
const displayTitle = title ?? displayLanguage
|
||||
const bodyMaxHeight = getCodeBlockMaxHeight(
|
||||
isCodeCollapsed,
|
||||
previewLines,
|
||||
maxExpandedLines
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
highlightCode(code, language, showLineNumbers).then((next) => {
|
||||
if (!cancelled) {
|
||||
setHtml(next)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
const downloadCode = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
}, [code, language, showLineNumbers])
|
||||
|
||||
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = getDownloadFilename(displayLanguage, filename)
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
'group bg-background text-foreground relative w-full overflow-hidden rounded-md border',
|
||||
className
|
||||
)}
|
||||
<CodeBlockContext.Provider value={{ code, language: displayLanguage }}>
|
||||
<CodeBlockFrame
|
||||
bodyClassName='p-0'
|
||||
bodyMaxHeight={bodyMaxHeight}
|
||||
bodyOverlay={
|
||||
<>
|
||||
{isCodeCollapsed && (
|
||||
<div className='from-muted/20 to-background pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-linear-to-b' />
|
||||
)}
|
||||
{!showToolbar && children && (
|
||||
<div className='absolute top-2 right-2 flex items-center gap-1'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
className={className}
|
||||
endActions={
|
||||
<>
|
||||
{canCollapse && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={isCodeCollapsed ? t('Expand') : t('Collapse')}
|
||||
className='size-8'
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
>
|
||||
{isCodeCollapsed ? (
|
||||
<ChevronRightIcon className='size-4' />
|
||||
) : (
|
||||
<ChevronDownIcon className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>{isCodeCollapsed ? t('Expand') : t('Collapse')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showToolbar && children}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t('Download')}
|
||||
className='size-8'
|
||||
onClick={downloadCode}
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
>
|
||||
<DownloadIcon className='size-4' />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>{t('Download')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
showToolbar={showToolbar}
|
||||
title={displayTitle}
|
||||
{...props}
|
||||
>
|
||||
<div className='relative'>
|
||||
<div
|
||||
className='[&>pre]:bg-background! [&>pre]:text-foreground! overflow-hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm'
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
{children && (
|
||||
<div className='absolute top-2 right-2 flex items-center gap-2'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CodeMirrorCodeView
|
||||
ariaLabel={
|
||||
typeof displayTitle === 'string' ? displayTitle : displayLanguage
|
||||
}
|
||||
language={language}
|
||||
readOnly
|
||||
rows={Math.min(Math.max(lineCount, 4), maxExpandedLines ?? lineCount)}
|
||||
showLineNumbers={showLineNumbers}
|
||||
value={code}
|
||||
/>
|
||||
</CodeBlockFrame>
|
||||
</CodeBlockContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const CodeBlockEditor = ({
|
||||
actions,
|
||||
ariaLabel,
|
||||
className,
|
||||
language,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
rows = 8,
|
||||
title,
|
||||
value,
|
||||
...props
|
||||
}: CodeBlockEditorProps) => {
|
||||
return (
|
||||
<CodeBlockFrame
|
||||
bodyClassName='p-0'
|
||||
className={className}
|
||||
endActions={actions}
|
||||
showToolbar
|
||||
title={title}
|
||||
{...props}
|
||||
>
|
||||
<CodeMirrorCodeView
|
||||
ariaLabel={ariaLabel}
|
||||
autoFocus
|
||||
language={language}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={rows}
|
||||
showLineNumbers
|
||||
value={value}
|
||||
/>
|
||||
</CodeBlockFrame>
|
||||
)
|
||||
}
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void
|
||||
onError?: (error: Error) => void
|
||||
@@ -152,6 +616,7 @@ export const CodeBlockCopyButton = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const { code } = useContext(CodeBlockContext)
|
||||
|
||||
@@ -173,15 +638,26 @@ export const CodeBlockCopyButton = ({
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<Button
|
||||
className={cn('shrink-0', className)}
|
||||
aria-label={isCopied ? t('Copied!') : t('Copy code')}
|
||||
className={cn('size-8 shrink-0', className)}
|
||||
onClick={copyToClipboard}
|
||||
size='icon'
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent>
|
||||
<p>{isCopied ? t('Copied!') : t('Copy code')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-3
@@ -18,18 +18,19 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { type ComponentProps, useCallback } from 'react'
|
||||
import { ArrowDownIcon } from 'lucide-react'
|
||||
import { type ComponentProps, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
className={cn('relative min-h-0 flex-1 overflow-hidden', className)}
|
||||
initial='smooth'
|
||||
resize='smooth'
|
||||
role='log'
|
||||
|
||||
+35
-25
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { BrainIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
@@ -26,14 +27,16 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { BrainIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { useControllableState } from '@/lib/use-controllable-state'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { useControllableState } from '@/lib/use-controllable-state'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { Response } from './response'
|
||||
import { Shimmer } from './shimmer'
|
||||
|
||||
@@ -138,39 +141,42 @@ export const Reasoning = memo(
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>
|
||||
|
||||
const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>
|
||||
}
|
||||
// When duration is unknown or 0 (e.g., non-streaming responses), show a generic message
|
||||
if (duration === undefined || duration === 0) {
|
||||
return <p>Thought for a few seconds</p>
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>
|
||||
}
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({ className, children, ...props }: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning()
|
||||
const { t } = useTranslation()
|
||||
const thinkingText = t('Thought for {{duration}} seconds', {
|
||||
duration: duration ?? 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors',
|
||||
'text-muted-foreground hover:text-foreground inline-grid w-fit max-w-full grid-cols-[0.875rem_minmax(0,auto)_0.875rem] items-center gap-1.5 text-sm leading-none transition-colors [&_p]:m-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className='size-4' />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-4 transition-transform',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
<span className='grid size-3.5 place-items-center'>
|
||||
<BrainIcon className='size-3.5' />
|
||||
</span>
|
||||
<span className='min-w-0 truncate leading-none'>
|
||||
{isStreaming ? (
|
||||
<Shimmer duration={1}>{t('Thinking...')}</Shimmer>
|
||||
) : (
|
||||
thinkingText
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className='grid size-3.5 place-items-center'>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200 ease-out',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
@@ -188,13 +194,17 @@ export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-4 text-sm',
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-muted-foreground data-closed:animate-out data-open:animate-in outline-none',
|
||||
'CollapsibleContent group/reasoning-content border-border/70 mt-2 ml-1.5 border-l pl-3 text-sm leading-5',
|
||||
'text-muted-foreground outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Response className='grid gap-2'>{children}</Response>
|
||||
<div className='transition-[opacity,transform] duration-200 ease-out group-data-[closed]/reasoning-content:-translate-y-1 group-data-[closed]/reasoning-content:opacity-0 group-data-[open]/reasoning-content:translate-y-0 group-data-[open]/reasoning-content:opacity-100 motion-reduce:transition-none'>
|
||||
<Response className='grid gap-1.5 [&_li]:my-0.5 [&_ol]:my-1.5 [&_p]:my-1.5 [&_p]:leading-5 [&_ul]:my-1.5'>
|
||||
{children}
|
||||
</Response>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import { isFootnoteNode } from './response-node-guards'
|
||||
import type { ParsedResponseContent } from './response-types'
|
||||
|
||||
const FENCE_START_PATTERN = /^(`{3,}|~{3,})([^\n]*)$/
|
||||
const FENCE_END_PATTERN = /^(`{3,}|~{3,})\s*$/
|
||||
const SECTION_HEADING_PATTERN = /^#{2,6}\s+\d+\.\s+/
|
||||
const MARKDOWN_EXAMPLE_LANGUAGES = new Set(['markdown', 'md', 'mdx'])
|
||||
|
||||
type MarkdownExampleFence = {
|
||||
contentLines: string[]
|
||||
fenceChar: string
|
||||
language: string
|
||||
nestedFence: boolean
|
||||
}
|
||||
|
||||
function getFenceRunLength(line: string, fenceChar: string): number {
|
||||
let length = 0
|
||||
|
||||
for (const char of line) {
|
||||
if (char !== fenceChar) {
|
||||
break
|
||||
}
|
||||
|
||||
length++
|
||||
}
|
||||
|
||||
return length
|
||||
}
|
||||
|
||||
function getMarkdownExampleFenceLength(block: MarkdownExampleFence): number {
|
||||
let maxFenceLength = 3
|
||||
|
||||
for (const line of block.contentLines) {
|
||||
if (!line.startsWith(block.fenceChar)) {
|
||||
continue
|
||||
}
|
||||
|
||||
maxFenceLength = Math.max(
|
||||
maxFenceLength,
|
||||
getFenceRunLength(line, block.fenceChar) + 1
|
||||
)
|
||||
}
|
||||
|
||||
return maxFenceLength
|
||||
}
|
||||
|
||||
function appendMarkdownExampleFence(
|
||||
output: string[],
|
||||
block: MarkdownExampleFence
|
||||
): void {
|
||||
const fence = block.fenceChar.repeat(getMarkdownExampleFenceLength(block))
|
||||
|
||||
output.push(`${fence}${block.language}`)
|
||||
output.push(...block.contentLines)
|
||||
output.push(fence)
|
||||
}
|
||||
|
||||
function normalizeMarkdownExampleFences(input: string): string {
|
||||
const lines = input.split('\n')
|
||||
const output: string[] = []
|
||||
let exampleFence: MarkdownExampleFence | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
if (!exampleFence) {
|
||||
const match = line.match(FENCE_START_PATTERN)
|
||||
|
||||
if (!match) {
|
||||
output.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
const language = match[2].trim().toLowerCase()
|
||||
if (MARKDOWN_EXAMPLE_LANGUAGES.has(language)) {
|
||||
exampleFence = {
|
||||
contentLines: [],
|
||||
fenceChar: match[1][0],
|
||||
language,
|
||||
nestedFence: false,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
output.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!exampleFence.nestedFence && SECTION_HEADING_PATTERN.test(line)) {
|
||||
appendMarkdownExampleFence(output, exampleFence)
|
||||
output.push(line)
|
||||
exampleFence = null
|
||||
continue
|
||||
}
|
||||
|
||||
if (exampleFence.nestedFence && FENCE_END_PATTERN.test(line)) {
|
||||
exampleFence.contentLines.push(line)
|
||||
exampleFence.nestedFence = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
line.startsWith(exampleFence.fenceChar.repeat(3)) &&
|
||||
!FENCE_END_PATTERN.test(line)
|
||||
) {
|
||||
exampleFence.contentLines.push(line)
|
||||
exampleFence.nestedFence = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (FENCE_END_PATTERN.test(line)) {
|
||||
appendMarkdownExampleFence(output, exampleFence)
|
||||
exampleFence = null
|
||||
continue
|
||||
}
|
||||
|
||||
exampleFence.contentLines.push(line)
|
||||
}
|
||||
|
||||
if (exampleFence) {
|
||||
appendMarkdownExampleFence(output, exampleFence)
|
||||
}
|
||||
|
||||
return output.join('\n')
|
||||
}
|
||||
|
||||
export function stripCustomTags(input: unknown): string {
|
||||
if (typeof input !== 'string') {
|
||||
return String(input ?? '')
|
||||
}
|
||||
|
||||
return input
|
||||
.replaceAll(
|
||||
/<\/?(conversation|conversationcontent|reasoning|reasoningcontent|reasoningtrigger|sources|sourcescontent|sourcestrigger|branch|branchmessages|branchnext|branchpage|branchprevious|branchselector|message|messagecontent)\b[^>]*>/gi,
|
||||
''
|
||||
)
|
||||
.replaceAll(/<\/?think\b[^>]*>/gi, '')
|
||||
}
|
||||
|
||||
export function getMarkdownContent(children: ReactNode): string {
|
||||
if (Array.isArray(children)) {
|
||||
return normalizeMarkdownExampleFences(stripCustomTags(children.join('')))
|
||||
}
|
||||
|
||||
return normalizeMarkdownExampleFences(stripCustomTags(children))
|
||||
}
|
||||
|
||||
export function getNodeKey(node: ParsedNode, index: number): string {
|
||||
const raw = typeof node.raw === 'string' ? node.raw : ''
|
||||
return `${node.type}-${index}-${raw.slice(0, 24)}`
|
||||
}
|
||||
|
||||
export function parseResponseContent(
|
||||
nodes: ParsedNode[]
|
||||
): ParsedResponseContent {
|
||||
const footnotes: ParsedResponseContent['footnotes'] = []
|
||||
const bodyNodes: ParsedNode[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (isFootnoteNode(node)) {
|
||||
footnotes.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
bodyNodes.push(node)
|
||||
}
|
||||
|
||||
return { bodyNodes, footnotes }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type {
|
||||
BlockquoteNode,
|
||||
CodeBlockNode,
|
||||
DefinitionListNode,
|
||||
FootnoteNode,
|
||||
HeadingNode,
|
||||
HtmlBlockNode,
|
||||
ImageNode,
|
||||
LinkNode,
|
||||
ListNode,
|
||||
MathBlockNode,
|
||||
MathInlineNode,
|
||||
ParsedNode,
|
||||
TableNode,
|
||||
TextNode,
|
||||
} from 'stream-markdown-parser'
|
||||
|
||||
export function hasParsedChildren(
|
||||
node: ParsedNode
|
||||
): node is ParsedNode & { children: ParsedNode[] } {
|
||||
return 'children' in node && Array.isArray(node.children)
|
||||
}
|
||||
|
||||
export function isTextNode(node: ParsedNode): node is TextNode {
|
||||
return node.type === 'text' && 'content' in node
|
||||
}
|
||||
|
||||
export function isHeadingNode(node: ParsedNode): node is HeadingNode {
|
||||
return node.type === 'heading' && 'level' in node && hasParsedChildren(node)
|
||||
}
|
||||
|
||||
export function isListNode(node: ParsedNode): node is ListNode {
|
||||
return node.type === 'list' && 'items' in node && Array.isArray(node.items)
|
||||
}
|
||||
|
||||
export function isCodeBlockNode(node: ParsedNode): node is CodeBlockNode {
|
||||
return node.type === 'code_block' && 'code' in node && 'language' in node
|
||||
}
|
||||
|
||||
export function isLinkNode(node: ParsedNode): node is LinkNode {
|
||||
return node.type === 'link' && 'href' in node && hasParsedChildren(node)
|
||||
}
|
||||
|
||||
export function isImageNode(node: ParsedNode): node is ImageNode {
|
||||
return node.type === 'image' && 'src' in node && 'alt' in node
|
||||
}
|
||||
|
||||
export function isBlockquoteNode(node: ParsedNode): node is BlockquoteNode {
|
||||
return node.type === 'blockquote' && hasParsedChildren(node)
|
||||
}
|
||||
|
||||
export function isTableNode(node: ParsedNode): node is TableNode {
|
||||
return node.type === 'table' && 'header' in node && 'rows' in node
|
||||
}
|
||||
|
||||
export function isDefinitionListNode(
|
||||
node: ParsedNode
|
||||
): node is DefinitionListNode {
|
||||
return (
|
||||
node.type === 'definition_list' &&
|
||||
'items' in node &&
|
||||
Array.isArray(node.items)
|
||||
)
|
||||
}
|
||||
|
||||
export function isMathBlockNode(node: ParsedNode): node is MathBlockNode {
|
||||
return node.type === 'math_block' && 'content' in node
|
||||
}
|
||||
|
||||
export function isMathInlineNode(node: ParsedNode): node is MathInlineNode {
|
||||
return node.type === 'math_inline' && 'content' in node
|
||||
}
|
||||
|
||||
export function isFootnoteNode(node: ParsedNode): node is FootnoteNode {
|
||||
return node.type === 'footnote' && 'id' in node && hasParsedChildren(node)
|
||||
}
|
||||
|
||||
export function isHtmlBlockNode(node: ParsedNode): node is HtmlBlockNode {
|
||||
return node.type === 'html_block' && 'tag' in node
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import type { BlockquoteNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { hasParsedChildren } from './response-node-guards'
|
||||
import type {
|
||||
AlertConfig,
|
||||
AlertKind,
|
||||
BlockRendererOptions,
|
||||
} from './response-types'
|
||||
|
||||
const alertConfig = {
|
||||
note: {
|
||||
label: 'Note',
|
||||
className:
|
||||
'border-blue-500/40 bg-blue-500/8 text-blue-950 dark:text-blue-100',
|
||||
markerClassName: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
tip: {
|
||||
label: 'Tip',
|
||||
className:
|
||||
'border-emerald-500/40 bg-emerald-500/8 text-emerald-950 dark:text-emerald-100',
|
||||
markerClassName: 'text-emerald-600 dark:text-emerald-300',
|
||||
},
|
||||
important: {
|
||||
label: 'Important',
|
||||
className:
|
||||
'border-violet-500/40 bg-violet-500/8 text-violet-950 dark:text-violet-100',
|
||||
markerClassName: 'text-violet-600 dark:text-violet-300',
|
||||
},
|
||||
warning: {
|
||||
label: 'Warning',
|
||||
className:
|
||||
'border-amber-500/40 bg-amber-500/8 text-amber-950 dark:text-amber-100',
|
||||
markerClassName: 'text-amber-600 dark:text-amber-300',
|
||||
},
|
||||
caution: {
|
||||
label: 'Caution',
|
||||
className: 'border-red-500/40 bg-red-500/8 text-red-950 dark:text-red-100',
|
||||
markerClassName: 'text-red-600 dark:text-red-300',
|
||||
},
|
||||
} satisfies Record<AlertKind, AlertConfig>
|
||||
|
||||
function getAlertKind(node: BlockquoteNode): AlertKind | null {
|
||||
const firstChild = node.children[0]
|
||||
if (!firstChild || firstChild.type !== 'paragraph') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasParsedChildren(firstChild)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const firstInline = firstChild.children[0]
|
||||
if (!firstInline || firstInline.type !== 'text') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!('content' in firstInline) || typeof firstInline.content !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const markerPattern = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n?/i
|
||||
const match = firstInline.content.match(markerPattern)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return match[1].toLowerCase() as AlertKind
|
||||
}
|
||||
|
||||
function getAlertChildren(node: BlockquoteNode, kind: AlertKind): ParsedNode[] {
|
||||
const firstChild = node.children[0]
|
||||
if (!firstChild || firstChild.type !== 'paragraph') {
|
||||
return node.children
|
||||
}
|
||||
|
||||
if (!hasParsedChildren(firstChild)) {
|
||||
return node.children
|
||||
}
|
||||
|
||||
const firstInline = firstChild.children[0]
|
||||
if (!firstInline || firstInline.type !== 'text') {
|
||||
return node.children
|
||||
}
|
||||
|
||||
if (!('content' in firstInline) || typeof firstInline.content !== 'string') {
|
||||
return node.children
|
||||
}
|
||||
|
||||
const marker = `[!${kind.toUpperCase()}]`
|
||||
const content = firstInline.content.replace(marker, '').replace(/^\s*\n?/, '')
|
||||
const nextParagraph = {
|
||||
...firstChild,
|
||||
children: [
|
||||
{ ...firstInline, content, raw: content },
|
||||
...firstChild.children.slice(1),
|
||||
],
|
||||
}
|
||||
|
||||
if (!content && nextParagraph.children.length === 1) {
|
||||
return node.children.slice(1)
|
||||
}
|
||||
|
||||
return [nextParagraph, ...node.children.slice(1)]
|
||||
}
|
||||
|
||||
export function renderBlockquote(
|
||||
node: BlockquoteNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
const alertKind = getAlertKind(node)
|
||||
if (alertKind) {
|
||||
const config = alertConfig[alertKind]
|
||||
const alertChildren = getAlertChildren(node, alertKind)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'my-4 rounded-lg border px-4 py-3 text-sm',
|
||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
config.className
|
||||
)}
|
||||
key={key}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2 text-xs font-semibold tracking-wide uppercase',
|
||||
config.markerClassName
|
||||
)}
|
||||
>
|
||||
{t(config.label)}
|
||||
</div>
|
||||
{options.renderChildren(alertChildren)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<blockquote
|
||||
className='border-border text-muted-foreground my-4 border-l-2 pl-4'
|
||||
key={key}
|
||||
>
|
||||
{options.renderChildren(node.children)}
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
CodeBlockNode,
|
||||
DefinitionItemNode,
|
||||
DefinitionListNode,
|
||||
HeadingNode,
|
||||
ListNode,
|
||||
MathBlockNode,
|
||||
MathInlineNode,
|
||||
} from 'stream-markdown-parser'
|
||||
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
} from '@/components/ai-elements/code-block'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getNodeKey } from './response-content'
|
||||
import type { BlockRendererOptions } from './response-types'
|
||||
|
||||
const headingClasses = {
|
||||
1: 'mt-6 mb-3 text-xl font-semibold tracking-normal',
|
||||
2: 'mt-6 mb-3 text-lg font-semibold tracking-normal',
|
||||
3: 'mt-5 mb-2 text-base font-semibold tracking-normal',
|
||||
4: 'mt-5 mb-2 text-sm font-semibold tracking-normal',
|
||||
5: 'text-muted-foreground mt-4 mb-2 text-sm font-semibold tracking-normal',
|
||||
6: 'text-muted-foreground mt-4 mb-2 text-xs font-semibold tracking-normal uppercase',
|
||||
} satisfies Record<1 | 2 | 3 | 4 | 5 | 6, string>
|
||||
|
||||
export function renderHeading(
|
||||
node: HeadingNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
const headingLevel = Math.min(Math.max(node.level, 1), 6) as
|
||||
| 1
|
||||
| 2
|
||||
| 3
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
const className = headingClasses[headingLevel]
|
||||
const children = options.renderChildren(node.children)
|
||||
|
||||
if (headingLevel === 1) {
|
||||
return (
|
||||
<h1 className={className} key={key}>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
||||
if (headingLevel === 2) {
|
||||
return (
|
||||
<h2 className={className} key={key}>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
if (headingLevel === 3) {
|
||||
return (
|
||||
<h3 className={className} key={key}>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
if (headingLevel === 4) {
|
||||
return (
|
||||
<h4 className={className} key={key}>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
}
|
||||
|
||||
if (headingLevel === 5) {
|
||||
return (
|
||||
<h5 className={className} key={key}>
|
||||
{children}
|
||||
</h5>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<h6 className={className} key={key}>
|
||||
{children}
|
||||
</h6>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderList(
|
||||
node: ListNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
const className = cn(
|
||||
'my-3 list-outside space-y-1.5 pl-5',
|
||||
node.ordered ? 'list-decimal' : 'list-disc'
|
||||
)
|
||||
const items = node.items.map((item, index) => (
|
||||
<li
|
||||
className='marker:text-muted-foreground pl-1 leading-7'
|
||||
key={getNodeKey(item, index)}
|
||||
>
|
||||
{options.renderChildren(item.children)}
|
||||
</li>
|
||||
))
|
||||
|
||||
if (node.ordered) {
|
||||
return (
|
||||
<ol className={className} key={key} start={node.start}>
|
||||
{items}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={className} key={key}>
|
||||
{items}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderCodeBlock(node: CodeBlockNode, key: string): ReactNode {
|
||||
const language = node.language || 'plaintext'
|
||||
const lineCount = node.code.split('\n').length
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
collapsedLines={14}
|
||||
code={node.code}
|
||||
defaultCollapsed={lineCount > 14}
|
||||
key={key}
|
||||
language={language}
|
||||
maxExpandedLines={44}
|
||||
showLineNumbers
|
||||
showToolbar
|
||||
title={language}
|
||||
>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlock>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderDefinitionList(
|
||||
node: DefinitionListNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
return (
|
||||
<dl className='my-4 space-y-3' key={key}>
|
||||
{node.items.map((item, index) =>
|
||||
renderDefinitionItem(item, index, options)
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDefinitionItem(
|
||||
node: DefinitionItemNode,
|
||||
index: number,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
return (
|
||||
<div key={`definition-${index}`}>
|
||||
<dt className='font-semibold'>{options.renderChildren(node.term)}</dt>
|
||||
<dd className='text-muted-foreground mt-1 pl-4'>
|
||||
{options.renderChildren(node.definition)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderMathBlock(node: MathBlockNode, key: string): ReactNode {
|
||||
return (
|
||||
<pre
|
||||
className='border-border bg-muted/40 my-4 overflow-x-auto rounded-lg border p-4 font-mono text-sm'
|
||||
key={key}
|
||||
>
|
||||
{node.content}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderMathInline(node: MathInlineNode, key: string): ReactNode {
|
||||
return (
|
||||
<code
|
||||
className='bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]'
|
||||
key={key}
|
||||
>
|
||||
{node.content}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import type { HtmlBlockNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import { hasParsedChildren, isHtmlBlockNode } from './response-node-guards'
|
||||
import type { BlockRendererOptions } from './response-types'
|
||||
|
||||
export function renderDetails(
|
||||
node: HtmlBlockNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
const children = Array.isArray(node.children) ? node.children : []
|
||||
const summaryNode = children.find(isSummaryHtmlNode)
|
||||
const contentNodes = children.filter((child) => child !== summaryNode)
|
||||
const summary = getDetailsSummary(summaryNode, options)
|
||||
|
||||
return (
|
||||
<details
|
||||
className='border-border/70 my-4 rounded-lg border px-4 py-3'
|
||||
key={key}
|
||||
>
|
||||
<summary className='text-foreground cursor-pointer text-sm font-semibold'>
|
||||
{summary}
|
||||
</summary>
|
||||
<div className='border-border/70 mt-3 border-l pl-4 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0'>
|
||||
{options.renderChildren(contentNodes)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function isSummaryHtmlNode(node: ParsedNode): node is HtmlBlockNode {
|
||||
return isHtmlBlockNode(node) && node.tag === 'summary'
|
||||
}
|
||||
|
||||
function getDetailsSummary(
|
||||
node: HtmlBlockNode | undefined,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
if (!node || !hasParsedChildren(node)) {
|
||||
return t('Details')
|
||||
}
|
||||
|
||||
return options.renderChildren(node.children)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import type { FootnoteNode } from 'stream-markdown-parser'
|
||||
|
||||
import type { BlockRendererOptions } from './response-types'
|
||||
|
||||
export function renderFootnotes(
|
||||
footnotes: FootnoteNode[],
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
if (footnotes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='border-border/70 text-muted-foreground mt-6 border-t pt-3 text-sm'>
|
||||
<ol className='list-decimal space-y-2 pl-5'>
|
||||
{footnotes.map((footnote) => (
|
||||
<li id={`footnote-${footnote.id}`} key={footnote.id}>
|
||||
<div className='inline [&>*:first-child]:mt-0 [&>*:last-child]:mb-0'>
|
||||
{options.renderChildren(footnote.children)}
|
||||
</div>
|
||||
<a
|
||||
aria-label={t('Back to footnote {{id}} reference', {
|
||||
id: footnote.id,
|
||||
})}
|
||||
className='text-primary ml-2 underline-offset-2 hover:underline'
|
||||
href={`#footnote-ref-${footnote.id}`}
|
||||
>
|
||||
{t('Back')}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { sanitizeImageSrc, type ImageNode } from 'stream-markdown-parser'
|
||||
|
||||
type ResponseImageProps = {
|
||||
node: ImageNode
|
||||
}
|
||||
|
||||
export function ResponseImage(props: ResponseImageProps) {
|
||||
const { t } = useTranslation()
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const src = sanitizeImageSrc(props.node.src)
|
||||
|
||||
if (!src || hasError) {
|
||||
return (
|
||||
<span className='border-border/70 text-muted-foreground my-4 inline-flex rounded-md border px-3 py-2 text-xs italic'>
|
||||
{props.node.alt || t('Image not available')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={props.node.alt}
|
||||
className='border-border/70 my-4 block h-auto max-h-96 max-w-full rounded-lg border object-contain'
|
||||
loading='lazy'
|
||||
onError={() => setHasError(true)}
|
||||
src={src}
|
||||
title={props.node.title ?? undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
shouldOpenLinkInNewTab,
|
||||
type ImageNode,
|
||||
type LinkNode,
|
||||
type TextNode,
|
||||
} from 'stream-markdown-parser'
|
||||
|
||||
import { ResponseImage } from './response-renderer-image'
|
||||
import type { RenderChildren } from './response-types'
|
||||
|
||||
export function renderTextNode(node: TextNode): ReactNode {
|
||||
return node.content
|
||||
}
|
||||
|
||||
export function renderLink(
|
||||
node: LinkNode,
|
||||
key: string,
|
||||
renderChildren: RenderChildren
|
||||
): ReactNode {
|
||||
const opensInNewTab = shouldOpenLinkInNewTab(node.href)
|
||||
const rel = opensInNewTab ? 'noreferrer noopener' : undefined
|
||||
const target = opensInNewTab ? '_blank' : undefined
|
||||
|
||||
return (
|
||||
<a
|
||||
className='text-primary underline-offset-4 hover:underline'
|
||||
href={node.href}
|
||||
key={key}
|
||||
rel={rel}
|
||||
target={target}
|
||||
title={node.title ?? undefined}
|
||||
>
|
||||
{renderChildren(node.children)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderImage(node: ImageNode, key: string): ReactNode {
|
||||
return <ResponseImage key={key} node={node} />
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { TableCellNode, TableNode } from 'stream-markdown-parser'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getNodeKey } from './response-content'
|
||||
import type { BlockRendererOptions } from './response-types'
|
||||
|
||||
function getTableCellAlignClass(
|
||||
align: TableCellNode['align'] | undefined
|
||||
): string {
|
||||
if (align === 'right') {
|
||||
return 'text-right'
|
||||
}
|
||||
|
||||
if (align === 'center') {
|
||||
return 'text-center'
|
||||
}
|
||||
|
||||
return 'text-left'
|
||||
}
|
||||
|
||||
function renderTableCell(
|
||||
node: TableCellNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
const alignClass = getTableCellAlignClass(node.align)
|
||||
|
||||
if (node.header) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'text-muted-foreground px-3 py-2 text-xs font-semibold whitespace-nowrap',
|
||||
alignClass
|
||||
)}
|
||||
key={key}
|
||||
>
|
||||
{options.renderChildren(node.children)}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<td className={cn('px-3 py-2 align-top', alignClass)} key={key}>
|
||||
{options.renderChildren(node.children)}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderTable(
|
||||
node: TableNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className='border-border/70 my-4 w-full overflow-x-auto rounded-lg border'
|
||||
key={key}
|
||||
>
|
||||
<table className='my-0 w-full min-w-max border-separate border-spacing-0 text-sm'>
|
||||
<thead className='bg-muted/60'>
|
||||
<tr className='border-border/70'>
|
||||
{node.header.cells.map((cell, index) =>
|
||||
renderTableCell(cell, getNodeKey(cell, index), options)
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-border/70 divide-y'>
|
||||
{node.rows.map((row, rowIndex) => (
|
||||
<tr className='border-border/70' key={getNodeKey(row, rowIndex)}>
|
||||
{row.cells.map((cell, cellIndex) =>
|
||||
renderTableCell(cell, getNodeKey(cell, cellIndex), options)
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import { getNodeKey } from './response-content'
|
||||
import {
|
||||
hasParsedChildren,
|
||||
isBlockquoteNode,
|
||||
isCodeBlockNode,
|
||||
isDefinitionListNode,
|
||||
isHeadingNode,
|
||||
isHtmlBlockNode,
|
||||
isImageNode,
|
||||
isLinkNode,
|
||||
isListNode,
|
||||
isMathBlockNode,
|
||||
isMathInlineNode,
|
||||
isTableNode,
|
||||
isTextNode,
|
||||
} from './response-node-guards'
|
||||
import { renderBlockquote } from './response-renderer-alert'
|
||||
import {
|
||||
renderCodeBlock,
|
||||
renderDefinitionList,
|
||||
renderHeading,
|
||||
renderList,
|
||||
renderMathBlock,
|
||||
renderMathInline,
|
||||
} from './response-renderer-blocks'
|
||||
import { renderDetails } from './response-renderer-details'
|
||||
import { renderFootnotes as renderFootnotesBlock } from './response-renderer-footnotes'
|
||||
import {
|
||||
renderImage,
|
||||
renderLink,
|
||||
renderTextNode,
|
||||
} from './response-renderer-inline'
|
||||
import { renderTable } from './response-renderer-table'
|
||||
|
||||
export function renderChildren(nodes: ParsedNode[]): ReactNode {
|
||||
return nodes.map((node, index) => renderNode(node, getNodeKey(node, index)))
|
||||
}
|
||||
|
||||
export function renderFootnotes(footnotes: FootnoteNode[]): ReactNode {
|
||||
return renderFootnotesBlock(footnotes, { renderChildren })
|
||||
}
|
||||
|
||||
function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
if (isTextNode(node)) {
|
||||
return renderTextNode(node)
|
||||
}
|
||||
|
||||
if (isHeadingNode(node)) {
|
||||
return renderHeading(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (node.type === 'paragraph' && hasParsedChildren(node)) {
|
||||
return (
|
||||
<p className='my-3 leading-7' key={key}>
|
||||
{renderChildren(node.children)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'inline' && hasParsedChildren(node)) {
|
||||
return <span key={key}>{renderChildren(node.children)}</span>
|
||||
}
|
||||
|
||||
if (isListNode(node)) {
|
||||
return renderList(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (isCodeBlockNode(node)) {
|
||||
return renderCodeBlock(node, key)
|
||||
}
|
||||
|
||||
if (node.type === 'inline_code' && 'code' in node) {
|
||||
return (
|
||||
<code
|
||||
className='bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]'
|
||||
key={key}
|
||||
>
|
||||
{String(node.code)}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLinkNode(node)) {
|
||||
return renderLink(node, key, renderChildren)
|
||||
}
|
||||
|
||||
if (isImageNode(node)) {
|
||||
return renderImage(node, key)
|
||||
}
|
||||
|
||||
if (isBlockquoteNode(node)) {
|
||||
return renderBlockquote(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (isTableNode(node)) {
|
||||
return renderTable(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (isDefinitionListNode(node)) {
|
||||
return renderDefinitionList(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (node.type === 'strong' && hasParsedChildren(node)) {
|
||||
return (
|
||||
<strong className='text-foreground font-semibold' key={key}>
|
||||
{renderChildren(node.children)}
|
||||
</strong>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'emphasis' && hasParsedChildren(node)) {
|
||||
return <em key={key}>{renderChildren(node.children)}</em>
|
||||
}
|
||||
|
||||
if (node.type === 'strikethrough' && hasParsedChildren(node)) {
|
||||
return <del key={key}>{renderChildren(node.children)}</del>
|
||||
}
|
||||
|
||||
if (node.type === 'highlight' && hasParsedChildren(node)) {
|
||||
return <mark key={key}>{renderChildren(node.children)}</mark>
|
||||
}
|
||||
|
||||
if (node.type === 'insert' && hasParsedChildren(node)) {
|
||||
return <ins key={key}>{renderChildren(node.children)}</ins>
|
||||
}
|
||||
|
||||
if (node.type === 'subscript' && hasParsedChildren(node)) {
|
||||
return <sub key={key}>{renderChildren(node.children)}</sub>
|
||||
}
|
||||
|
||||
if (node.type === 'superscript' && hasParsedChildren(node)) {
|
||||
return <sup key={key}>{renderChildren(node.children)}</sup>
|
||||
}
|
||||
|
||||
if (
|
||||
(node.type === 'checkbox' || node.type === 'checkbox_input') &&
|
||||
'checked' in node
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
checked={Boolean(node.checked)}
|
||||
className='accent-primary mr-2 size-4 align-[-0.15em]'
|
||||
disabled
|
||||
key={key}
|
||||
readOnly
|
||||
type='checkbox'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'hardbreak') {
|
||||
return <br key={key} />
|
||||
}
|
||||
|
||||
if (node.type === 'thematic_break') {
|
||||
return <hr className='border-border/70 my-6' key={key} />
|
||||
}
|
||||
|
||||
if (isMathBlockNode(node)) {
|
||||
return renderMathBlock(node, key)
|
||||
}
|
||||
|
||||
if (isMathInlineNode(node)) {
|
||||
return renderMathInline(node, key)
|
||||
}
|
||||
|
||||
if (node.type === 'footnote_reference' && 'id' in node) {
|
||||
return (
|
||||
<sup className='text-primary mx-0.5 text-xs' key={key}>
|
||||
<a
|
||||
className='underline-offset-2 hover:underline'
|
||||
href={`#footnote-${String(node.id)}`}
|
||||
id={`footnote-ref-${String(node.id)}`}
|
||||
>
|
||||
[{String(node.id)}]
|
||||
</a>
|
||||
</sup>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'footnote_anchor') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isHtmlBlockNode(node) && node.tag === 'details') {
|
||||
return renderDetails(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (node.type === 'html_block' && 'content' in node) {
|
||||
return <span key={key}>{String(node.content)}</span>
|
||||
}
|
||||
|
||||
if (node.type === 'html_inline' && 'content' in node) {
|
||||
return <span key={key}>{String(node.content)}</span>
|
||||
}
|
||||
|
||||
if (hasParsedChildren(node)) {
|
||||
return <span key={key}>{renderChildren(node.children)}</span>
|
||||
}
|
||||
|
||||
if ('content' in node && typeof node.content === 'string') {
|
||||
return <span key={key}>{node.content}</span>
|
||||
}
|
||||
|
||||
return <span key={key}>{node.raw}</span>
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
export type ResponseProps = {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
final?: boolean
|
||||
}
|
||||
|
||||
export type AlertKind = 'note' | 'tip' | 'important' | 'warning' | 'caution'
|
||||
|
||||
export type AlertConfig = {
|
||||
label: string
|
||||
className: string
|
||||
markerClassName: string
|
||||
}
|
||||
|
||||
export type ParsedResponseContent = {
|
||||
bodyNodes: ParsedNode[]
|
||||
footnotes: FootnoteNode[]
|
||||
}
|
||||
|
||||
export type RenderChildren = (nodes: ParsedNode[]) => ReactNode
|
||||
|
||||
export type BlockRendererOptions = {
|
||||
renderChildren: RenderChildren
|
||||
}
|
||||
+38
-32
@@ -18,43 +18,49 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { type ComponentProps, memo } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown>
|
||||
import { getMarkdownContent, parseResponseContent } from './response-content'
|
||||
import { renderChildren, renderFootnotes } from './response-renderer'
|
||||
import type { ResponseProps } from './response-types'
|
||||
|
||||
export const Response = memo(
|
||||
({ className, children, ...props }: ResponseProps) => {
|
||||
const stripCustomTags = (input: unknown): unknown => {
|
||||
if (typeof input !== 'string') return input
|
||||
return (
|
||||
input
|
||||
// Remove known AI custom wrapper tags but keep inner content
|
||||
.replace(
|
||||
/<\/?(conversation|conversationcontent|reasoning|reasoningcontent|reasoningtrigger|sources|sourcescontent|sourcestrigger|branch|branchmessages|branchnext|branchpage|branchprevious|branchselector|message|messagecontent)\b[^>]*>/gi,
|
||||
''
|
||||
)
|
||||
// Remove any stray <think> tags if they still appear
|
||||
.replace(/<\/?think\b[^>]*>/gi, '')
|
||||
)
|
||||
const markdown = getMarkdown('new-api-response')
|
||||
const MAX_PARSED_MARKDOWN_CHARS = 20_000
|
||||
|
||||
export const Response = memo((props: ResponseProps) => {
|
||||
const content = getMarkdownContent(props.children)
|
||||
const shouldParseMarkdown = content.length <= MAX_PARSED_MARKDOWN_CHARS
|
||||
const nodes = useMemo(() => {
|
||||
if (!shouldParseMarkdown) {
|
||||
return []
|
||||
}
|
||||
|
||||
const safeChildren = stripCustomTags(children) as string
|
||||
return parseMarkdownToStructure(content, markdown, {
|
||||
final: props.final ?? true,
|
||||
validateLink: markdown.options.validateLink,
|
||||
})
|
||||
}, [content, props.final, shouldParseMarkdown])
|
||||
const parsedContent = useMemo(() => parseResponseContent(nodes), [nodes])
|
||||
const renderedContent =
|
||||
parsedContent.bodyNodes.length > 0
|
||||
? renderChildren(parsedContent.bodyNodes)
|
||||
: content
|
||||
const footnotes = renderFootnotes(parsedContent.footnotes)
|
||||
|
||||
return (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{safeChildren}
|
||||
</Streamdown>
|
||||
)
|
||||
},
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'size-full min-w-0 text-pretty [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{renderedContent}
|
||||
{footnotes}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Response.displayName = 'Response'
|
||||
|
||||
+4
-3
@@ -18,15 +18,16 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import { BookIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type SourcesProps = ComponentProps<'div'>
|
||||
|
||||
@@ -73,7 +74,7 @@ export const SourcesContent = ({
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-3 flex w-fit flex-col gap-2',
|
||||
'border-border/70 mt-3 ml-2 flex w-fit flex-col gap-2 border-l pl-4',
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 data-closed:animate-out data-open:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user