refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
@@ -0,0 +1,84 @@
/*
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
*/
'use client'
import type { ComponentProps } from 'react'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
export type ActionsProps = ComponentProps<'div'>
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn('flex items-center gap-1', className)} {...props}>
{children}
</div>
)
export type ActionProps = ComponentProps<typeof Button> & {
tooltip?: string
label?: string
}
export const Action = ({
tooltip,
children,
label,
className,
variant = 'ghost',
size = 'sm',
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
'text-muted-foreground hover:text-foreground relative size-9 p-1.5',
className
)}
size={size}
type='button'
variant={variant}
{...props}
>
{children}
<span className='sr-only'>{label || tooltip}</span>
</Button>
)
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={button}></TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return button
}
+170
View File
@@ -0,0 +1,170 @@
/*
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
*/
'use client'
import { type LucideIcon, XIcon } from 'lucide-react'
import type { ComponentProps, HTMLAttributes } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
export type ArtifactProps = HTMLAttributes<HTMLDivElement>
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
'bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm',
className
)}
{...props}
/>
)
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>
export const ArtifactHeader = ({
className,
...props
}: ArtifactHeaderProps) => (
<div
className={cn(
'bg-muted/50 flex items-center justify-between border-b px-4 py-3',
className
)}
{...props}
/>
)
export type ArtifactCloseProps = ComponentProps<typeof Button>
export const ArtifactClose = ({
className,
children,
size = 'sm',
variant = 'ghost',
...props
}: ArtifactCloseProps) => {
const { t } = useTranslation()
return (
<Button
className={cn(
'text-muted-foreground hover:text-foreground size-8 p-0',
className
)}
size={size}
type='button'
variant={variant}
{...props}
>
{children ?? <XIcon className='size-4' />}
<span className='sr-only'>{t('Close')}</span>
</Button>
)
}
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
<p
className={cn('text-foreground text-sm font-medium', className)}
{...props}
/>
)
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>
export const ArtifactDescription = ({
className,
...props
}: ArtifactDescriptionProps) => (
<p className={cn('text-muted-foreground text-sm', className)} {...props} />
)
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>
export const ArtifactActions = ({
className,
...props
}: ArtifactActionsProps) => (
<div className={cn('flex items-center gap-1', className)} {...props} />
)
export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string
label?: string
icon?: LucideIcon
}
export const ArtifactAction = ({
tooltip,
label,
icon: Icon,
children,
className,
size = 'sm',
variant = 'ghost',
...props
}: ArtifactActionProps) => {
const button = (
<Button
className={cn(
'text-muted-foreground hover:text-foreground size-8 p-0',
className
)}
size={size}
type='button'
variant={variant}
{...props}
>
{Icon ? <Icon className='size-4' /> : children}
<span className='sr-only'>{label || tooltip}</span>
</Button>
)
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={button}></TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return button
}
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>
export const ArtifactContent = ({
className,
...props
}: ArtifactContentProps) => (
<div className={cn('flex-1 overflow-auto p-4', className)} {...props} />
)
+246
View File
@@ -0,0 +1,246 @@
/*
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
*/
'use client'
import type { UIMessage } from 'ai'
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import {
type ComponentProps,
createContext,
type HTMLAttributes,
type ReactElement,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type BranchContextType = {
currentBranch: number
totalBranches: number
goToPrevious: () => void
goToNext: () => void
branches: ReactElement[]
setBranches: (branches: ReactElement[]) => void
}
const BranchContext = createContext<BranchContextType | null>(null)
const useBranch = () => {
const context = useContext(BranchContext)
if (!context) {
throw new Error('Branch components must be used within Branch')
}
return context
}
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number
onBranchChange?: (branchIndex: number) => void
}
export const Branch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: BranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch)
const [branches, setBranches] = useState<ReactElement[]>([])
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch)
onBranchChange?.(newBranch)
}
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1
handleBranchChange(newBranch)
}
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0
handleBranchChange(newBranch)
}
const contextValue: BranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
}
return (
<BranchContext.Provider value={contextValue}>
<div
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
{...props}
/>
</BranchContext.Provider>
)
}
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
const { currentBranch, setBranches, branches } = useBranch()
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children]
)
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray)
}
}, [childrenArray, branches, setBranches])
return childrenArray.map((branch, index) => (
<div
className={cn(
'grid gap-2 overflow-hidden [&>div]:pb-0',
index === currentBranch ? 'block' : 'hidden'
)}
key={branch.key}
{...props}
>
{branch}
</div>
))
}
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role']
}
export const BranchSelector = ({
className,
from,
...props
}: BranchSelectorProps) => {
const { totalBranches } = useBranch()
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null
}
return (
<div
className={cn(
'flex items-center gap-2 self-end px-10',
from === 'assistant' ? 'justify-start' : 'justify-end',
className
)}
{...props}
/>
)
}
export type BranchPreviousProps = ComponentProps<typeof Button>
export const BranchPrevious = ({
className,
children,
...props
}: BranchPreviousProps) => {
const { t } = useTranslation()
const { goToPrevious, totalBranches } = useBranch()
return (
<Button
aria-label={t('Previous branch')}
className={cn(
'text-muted-foreground size-7 shrink-0 transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
disabled={totalBranches <= 1}
onClick={goToPrevious}
size='icon'
type='button'
variant='ghost'
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
)
}
export type BranchNextProps = ComponentProps<typeof Button>
export const BranchNext = ({
className,
children,
...props
}: BranchNextProps) => {
const { t } = useTranslation()
const { goToNext, totalBranches } = useBranch()
return (
<Button
aria-label={t('Next branch')}
className={cn(
'text-muted-foreground size-7 shrink-0 transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
disabled={totalBranches <= 1}
onClick={goToNext}
size='icon'
type='button'
variant='ghost'
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
)
}
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
const { t } = useTranslation()
const { currentBranch, totalBranches } = useBranch()
return (
<span
className={cn(
'text-muted-foreground text-xs font-medium tabular-nums',
className
)}
{...props}
>
{currentBranch + 1} {t('of')} {totalBranches}
</span>
)
}
+43
View File
@@ -0,0 +1,43 @@
/*
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 { Background, ReactFlow, type ReactFlowProps } from '@xyflow/react'
import type { ReactNode } from 'react'
import '@xyflow/react/dist/style.css'
import { Controls } from './controls'
type CanvasProps = ReactFlowProps & {
children?: ReactNode
}
export const Canvas = ({ children, ...props }: CanvasProps) => (
<ReactFlow
deleteKeyCode={['Backspace', 'Delete']}
fitView
panOnDrag={false}
panOnScroll
selectionOnDrag={true}
zoomOnDoubleClick={false}
{...props}
>
<Background bgColor='var(--sidebar)' />
<Controls />
{children}
</ReactFlow>
)
@@ -0,0 +1,252 @@
/*
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
*/
'use client'
import {
BrainIcon,
ChevronDownIcon,
DotIcon,
type LucideIcon,
} from 'lucide-react'
import {
type ComponentProps,
createContext,
memo,
useContext,
useMemo,
} from 'react'
import { Badge } from '@/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { useControllableState } from '@/lib/use-controllable-state'
import { cn } from '@/lib/utils'
type ChainOfThoughtContextValue = {
isOpen: boolean
setIsOpen: (open: boolean) => void
}
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
null
)
const useChainOfThought = () => {
const context = useContext(ChainOfThoughtContext)
if (!context) {
throw new Error(
'ChainOfThought components must be used within ChainOfThought'
)
}
return context
}
export type ChainOfThoughtProps = ComponentProps<'div'> & {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
export const ChainOfThought = memo(
({
className,
open,
defaultOpen = false,
onOpenChange,
children,
...props
}: ChainOfThoughtProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const chainOfThoughtContext = useMemo(
() => ({ isOpen, setIsOpen }),
[isOpen, setIsOpen]
)
return (
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
<div
className={cn('not-prose max-w-prose space-y-4', className)}
{...props}
>
{children}
</div>
</ChainOfThoughtContext.Provider>
)
}
)
export type ChainOfThoughtHeaderProps = ComponentProps<
typeof CollapsibleTrigger
>
export const ChainOfThoughtHeader = memo(
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought()
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger
className={cn(
'text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors',
className
)}
{...props}
>
<BrainIcon className='size-4' />
<span className='flex-1 text-left'>
{children ?? 'Chain of Thought'}
</span>
<ChevronDownIcon
className={cn(
'size-4 transition-transform',
isOpen ? 'rotate-180' : 'rotate-0'
)}
/>
</CollapsibleTrigger>
</Collapsible>
)
}
)
export type ChainOfThoughtStepProps = ComponentProps<'div'> & {
icon?: LucideIcon
label: string
description?: string
status?: 'complete' | 'active' | 'pending'
}
export const ChainOfThoughtStep = memo(
({
className,
icon: Icon = DotIcon,
label,
description,
status = 'complete',
children,
...props
}: ChainOfThoughtStepProps) => {
const statusStyles = {
complete: 'text-muted-foreground',
active: 'text-foreground',
pending: 'text-muted-foreground/50',
}
return (
<div
className={cn(
'flex gap-2 text-sm',
statusStyles[status],
'fade-in-0 slide-in-from-top-2 animate-in',
className
)}
{...props}
>
<div className='relative mt-0.5'>
<Icon className='size-4' />
<div className='bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px' />
</div>
<div className='flex-1 space-y-2'>
<div>{label}</div>
{description && (
<div className='text-muted-foreground text-xs'>{description}</div>
)}
{children}
</div>
</div>
)
}
)
export type ChainOfThoughtSearchResultsProps = ComponentProps<'div'>
export const ChainOfThoughtSearchResults = memo(
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
<div className={cn('flex items-center gap-2', className)} {...props} />
)
)
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>
export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
<Badge
className={cn('gap-1 px-2 py-0.5 text-xs font-normal', className)}
variant='secondary'
{...props}
>
{children}
</Badge>
)
)
export type ChainOfThoughtContentProps = ComponentProps<
typeof CollapsibleContent
>
export const ChainOfThoughtContent = memo(
({ className, children, ...props }: ChainOfThoughtContentProps) => {
const { isOpen } = useChainOfThought()
return (
<Collapsible open={isOpen}>
<CollapsibleContent
className={cn(
'mt-2 space-y-3',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-popover-foreground data-closed:animate-out data-open:animate-in outline-none',
className
)}
{...props}
>
{children}
</CollapsibleContent>
</Collapsible>
)
}
)
export type ChainOfThoughtImageProps = ComponentProps<'div'> & {
caption?: string
}
export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
<div className={cn('mt-2 space-y-2', className)} {...props}>
<div className='bg-muted relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg p-3'>
{children}
</div>
{caption && <p className='text-muted-foreground text-xs'>{caption}</p>}
</div>
)
)
ChainOfThought.displayName = 'ChainOfThought'
ChainOfThoughtHeader.displayName = 'ChainOfThoughtHeader'
ChainOfThoughtStep.displayName = 'ChainOfThoughtStep'
ChainOfThoughtSearchResults.displayName = 'ChainOfThoughtSearchResults'
ChainOfThoughtSearchResult.displayName = 'ChainOfThoughtSearchResult'
ChainOfThoughtContent.displayName = 'ChainOfThoughtContent'
ChainOfThoughtImage.displayName = 'ChainOfThoughtImage'
@@ -0,0 +1,663 @@
/*
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
*/
/* 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 { 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
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 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',
},
})
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',
},
{
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 { 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
)
const downloadCode = () => {
if (typeof window === 'undefined') {
return
}
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, 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}
>
<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
timeout?: number
}
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
const { code } = useContext(CodeBlockContext)
const copyToClipboard = async () => {
if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) {
onError?.(new Error('Clipboard API not available'))
return
}
try {
await navigator.clipboard.writeText(code)
setIsCopied(true)
onCopy?.()
setTimeout(() => setIsCopied(false), timeout)
} catch (error) {
onError?.(error as Error)
}
}
const Icon = isCopied ? CheckIcon : CopyIcon
const button = (
<Button
aria-label={isCopied ? t('Copied!') : t('Copy code')}
className={cn('size-8 shrink-0', className)}
onClick={copyToClipboard}
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>
)
}
@@ -0,0 +1,173 @@
/*
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
*/
'use client'
import type { ToolUIPart } from 'ai'
import {
type ComponentProps,
createContext,
type ReactNode,
useContext,
} from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
// Workaround for missing types in 'ai' package
type ExtendedToolState =
| ToolUIPart['state']
| 'approval-requested'
| 'approval-responded'
| 'output-denied'
type ExtendedToolApproval = { approved: boolean }
type ConfirmationContextValue = {
approval: ExtendedToolApproval | undefined
state: ExtendedToolState
}
const ConfirmationContext = createContext<ConfirmationContextValue | null>(null)
const useConfirmation = () => {
const context = useContext(ConfirmationContext)
if (!context) {
throw new Error('Confirmation components must be used within Confirmation')
}
return context
}
export type ConfirmationProps = ComponentProps<typeof Alert> & {
approval?: ExtendedToolApproval
state: ExtendedToolState
}
export const Confirmation = ({
className,
approval,
state,
...props
}: ConfirmationProps) => {
if (!approval || state === 'input-streaming' || state === 'input-available') {
return null
}
return (
<ConfirmationContext.Provider value={{ approval, state }}>
<Alert className={cn('flex flex-col gap-2', className)} {...props} />
</ConfirmationContext.Provider>
)
}
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>
export const ConfirmationTitle = ({
className,
...props
}: ConfirmationTitleProps) => (
<AlertDescription className={cn('inline', className)} {...props} />
)
export type ConfirmationRequestProps = {
children?: ReactNode
}
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
const { state } = useConfirmation()
// Only show when approval is requested
if (state !== 'approval-requested') {
return null
}
return children
}
export type ConfirmationAcceptedProps = {
children?: ReactNode
}
export const ConfirmationAccepted = ({
children,
}: ConfirmationAcceptedProps) => {
const { approval, state } = useConfirmation()
// Only show when approved and in response states
if (
!approval?.approved ||
(state !== 'approval-responded' &&
state !== 'output-denied' &&
state !== 'output-available')
) {
return null
}
return children
}
export type ConfirmationRejectedProps = {
children?: ReactNode
}
export const ConfirmationRejected = ({
children,
}: ConfirmationRejectedProps) => {
const { approval, state } = useConfirmation()
// Only show when rejected and in response states
if (
approval?.approved !== false ||
(state !== 'approval-responded' &&
state !== 'output-denied' &&
state !== 'output-available')
) {
return null
}
return children
}
export type ConfirmationActionsProps = ComponentProps<'div'>
export const ConfirmationActions = ({
className,
...props
}: ConfirmationActionsProps) => {
const { state } = useConfirmation()
// Only show when approval is requested
if (state !== 'approval-requested') {
return null
}
return (
<div
className={cn('flex items-center justify-end gap-2 self-end', className)}
{...props}
/>
)
}
export type ConfirmationActionProps = ComponentProps<typeof Button>
export const ConfirmationAction = (props: ConfirmationActionProps) => (
<Button className='h-8 px-3 text-sm' type='button' {...props} />
)
@@ -0,0 +1,46 @@
/*
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 { ConnectionLineComponent } from '@xyflow/react'
const HALF = 0.5
export const Connection: ConnectionLineComponent = ({
fromX,
fromY,
toX,
toY,
}) => (
<g>
<path
className='animated'
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
fill='none'
stroke='var(--color-ring)'
strokeWidth={1}
/>
<circle
cx={toX}
cy={toY}
fill='#fff'
r={3}
stroke='var(--color-ring)'
strokeWidth={1}
/>
</g>
)
+440
View File
@@ -0,0 +1,440 @@
/*
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
*/
'use client'
import type { LanguageModelUsage } from 'ai'
import { type ComponentProps, createContext, useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { getUsage } from 'tokenlens'
import { Button } from '@/components/ui/button'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { Progress } from '@/components/ui/progress'
import { cn } from '@/lib/utils'
const PERCENT_MAX = 100
const ICON_RADIUS = 10
const ICON_VIEWBOX = 24
const ICON_CENTER = 12
const ICON_STROKE_WIDTH = 2
type ModelId = string
type ContextSchema = {
usedTokens: number
maxTokens: number
usage?: LanguageModelUsage
modelId?: ModelId
}
const ContextContext = createContext<ContextSchema | null>(null)
const useContextValue = () => {
const context = useContext(ContextContext)
if (!context) {
throw new Error('Context components must be used within Context')
}
return context
}
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema
export const Context = ({
usedTokens,
maxTokens,
usage,
modelId,
...props
}: ContextProps) => (
<ContextContext.Provider
value={{
usedTokens,
maxTokens,
usage,
modelId,
}}
>
<HoverCard {...props} />
</ContextContext.Provider>
)
const ContextIcon = () => {
const { t } = useTranslation()
const { usedTokens, maxTokens } = useContextValue()
const circumference = 2 * Math.PI * ICON_RADIUS
const usedPercent = usedTokens / maxTokens
const dashOffset = circumference * (1 - usedPercent)
return (
<svg
aria-label={t('Model context usage')}
height='20'
role='img'
style={{ color: 'currentcolor' }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width='20'
>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill='none'
opacity='0.25'
r={ICON_RADIUS}
stroke='currentColor'
strokeWidth={ICON_STROKE_WIDTH}
/>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill='none'
opacity='0.7'
r={ICON_RADIUS}
stroke='currentColor'
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
strokeLinecap='round'
strokeWidth={ICON_STROKE_WIDTH}
style={{ transformOrigin: 'center', transform: 'rotate(-90deg)' }}
/>
</svg>
)
}
export type ContextTriggerProps = ComponentProps<typeof Button>
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
const { usedTokens, maxTokens } = useContextValue()
const usedPercent = usedTokens / maxTokens
const renderedPercent = new Intl.NumberFormat('en-US', {
style: 'percent',
maximumFractionDigits: 1,
}).format(usedPercent)
return (
<HoverCardTrigger
delay={0}
closeDelay={0}
render={
<Button type='button' variant='ghost' {...props}>
{children ?? (
<>
<span className='text-muted-foreground font-medium'>
{renderedPercent}
</span>
<ContextIcon />
</>
)}
</Button>
}
/>
)
}
export type ContextContentProps = ComponentProps<typeof HoverCardContent>
export const ContextContent = ({
className,
...props
}: ContextContentProps) => (
<HoverCardContent
className={cn('min-w-60 divide-y overflow-hidden p-0', className)}
{...props}
/>
)
export type ContextContentHeaderProps = ComponentProps<'div'>
export const ContextContentHeader = ({
children,
className,
...props
}: ContextContentHeaderProps) => {
const { usedTokens, maxTokens } = useContextValue()
const usedPercent = usedTokens / maxTokens
const displayPct = new Intl.NumberFormat('en-US', {
style: 'percent',
maximumFractionDigits: 1,
}).format(usedPercent)
const used = new Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(usedTokens)
const total = new Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(maxTokens)
return (
<div className={cn('w-full space-y-2 p-3', className)} {...props}>
{children ?? (
<>
<div className='flex items-center justify-between gap-3 text-xs'>
<p>{displayPct}</p>
<p className='text-muted-foreground font-mono'>
{used} / {total}
</p>
</div>
<div className='space-y-2'>
<Progress className='bg-muted' value={usedPercent * PERCENT_MAX} />
</div>
</>
)}
</div>
)
}
export type ContextContentBodyProps = ComponentProps<'div'>
export const ContextContentBody = ({
children,
className,
...props
}: ContextContentBodyProps) => (
<div className={cn('w-full p-3', className)} {...props}>
{children}
</div>
)
export type ContextContentFooterProps = ComponentProps<'div'>
export const ContextContentFooter = ({
children,
className,
...props
}: ContextContentFooterProps) => {
const { t } = useTranslation()
const { modelId, usage } = useContextValue()
const costUSD = modelId
? getUsage({
modelId,
usage: {
input: usage?.inputTokens ?? 0,
output: usage?.outputTokens ?? 0,
},
}).costUSD?.totalUSD
: undefined
const totalCost = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(costUSD ?? 0)
return (
<div
className={cn(
'bg-secondary flex w-full items-center justify-between gap-3 p-3 text-xs',
className
)}
{...props}
>
{children ?? (
<>
<span className='text-muted-foreground'>{t('Total cost')}</span>
<span>{totalCost}</span>
</>
)}
</div>
)
}
export type ContextInputUsageProps = ComponentProps<'div'>
export const ContextInputUsage = ({
className,
children,
...props
}: ContextInputUsageProps) => {
const { t } = useTranslation()
const { usage, modelId } = useContextValue()
const inputTokens = usage?.inputTokens ?? 0
if (children) {
return children
}
if (!inputTokens) {
return null
}
const inputCost = modelId
? getUsage({
modelId,
usage: { input: inputTokens, output: 0 },
}).costUSD?.totalUSD
: undefined
const inputCostText = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(inputCost ?? 0)
return (
<div
className={cn('flex items-center justify-between text-xs', className)}
{...props}
>
<span className='text-muted-foreground'>{t('Input')}</span>
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
</div>
)
}
export type ContextOutputUsageProps = ComponentProps<'div'>
export const ContextOutputUsage = ({
className,
children,
...props
}: ContextOutputUsageProps) => {
const { t } = useTranslation()
const { usage, modelId } = useContextValue()
const outputTokens = usage?.outputTokens ?? 0
if (children) {
return children
}
if (!outputTokens) {
return null
}
const outputCost = modelId
? getUsage({
modelId,
usage: { input: 0, output: outputTokens },
}).costUSD?.totalUSD
: undefined
const outputCostText = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(outputCost ?? 0)
return (
<div
className={cn('flex items-center justify-between text-xs', className)}
{...props}
>
<span className='text-muted-foreground'>{t('Output')}</span>
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
</div>
)
}
export type ContextReasoningUsageProps = ComponentProps<'div'>
export const ContextReasoningUsage = ({
className,
children,
...props
}: ContextReasoningUsageProps) => {
const { t } = useTranslation()
const { usage, modelId } = useContextValue()
const reasoningTokens = usage?.outputTokenDetails.reasoningTokens ?? 0
if (children) {
return children
}
if (!reasoningTokens) {
return null
}
const reasoningCost = modelId
? getUsage({
modelId,
usage: { reasoningTokens },
}).costUSD?.totalUSD
: undefined
const reasoningCostText = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(reasoningCost ?? 0)
return (
<div
className={cn('flex items-center justify-between text-xs', className)}
{...props}
>
<span className='text-muted-foreground'>{t('Reasoning')}</span>
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
</div>
)
}
export type ContextCacheUsageProps = ComponentProps<'div'>
export const ContextCacheUsage = ({
className,
children,
...props
}: ContextCacheUsageProps) => {
const { t } = useTranslation()
const { usage, modelId } = useContextValue()
const cacheTokens = usage?.inputTokenDetails.cacheReadTokens ?? 0
if (children) {
return children
}
if (!cacheTokens) {
return null
}
const cacheCost = modelId
? getUsage({
modelId,
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
}).costUSD?.totalUSD
: undefined
const cacheCostText = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(cacheCost ?? 0)
return (
<div
className={cn('flex items-center justify-between text-xs', className)}
{...props}
>
<span className='text-muted-foreground'>{t('Cache')}</span>
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
</div>
)
}
const TokensWithCost = ({
tokens,
costText,
}: {
tokens?: number
costText?: string
}) => (
<span>
{tokens === undefined
? '—'
: new Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(tokens)}
{costText ? (
<span className='text-muted-foreground ml-2'> {costText}</span>
) : null}
</span>
)
@@ -0,0 +1,37 @@
/*
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
*/
'use client'
import { Controls as ControlsPrimitive } from '@xyflow/react'
import type { ComponentProps } from 'react'
import { cn } from '@/lib/utils'
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>
export const Controls = ({ className, ...props }: ControlsProps) => (
<ControlsPrimitive
className={cn(
'bg-card gap-px overflow-hidden rounded-md border p-1 shadow-none!',
'[&>button]:hover:bg-secondary! [&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent!',
className
)}
{...props}
/>
)
@@ -0,0 +1,126 @@
/*
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
*/
'use client'
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 { 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 min-h-0 flex-1 overflow-hidden', className)}
initial='smooth'
resize='smooth'
role='log'
{...props}
/>
)
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content className={cn('p-4', className)} {...props} />
)
export type ConversationEmptyStateProps = ComponentProps<'div'> & {
title?: string
description?: string
icon?: React.ReactNode
}
export const ConversationEmptyState = ({
className,
title,
description,
icon,
children,
...props
}: ConversationEmptyStateProps) => {
const { t } = useTranslation()
const resolvedTitle = title ?? t('No messages yet')
const resolvedDescription =
description ?? t('Start a conversation to see messages here')
return (
<div
className={cn(
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className='text-muted-foreground'>{icon}</div>}
<div className='space-y-1'>
<h3 className='text-sm font-medium'>{resolvedTitle}</h3>
{resolvedDescription && (
<p className='text-muted-foreground text-sm'>
{resolvedDescription}
</p>
)}
</div>
</>
)}
</div>
)
}
export type ConversationScrollButtonProps = ComponentProps<typeof Button>
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
const handleScrollToBottom = useCallback(() => {
scrollToBottom()
}, [scrollToBottom])
return (
!isAtBottom && (
<Button
className={cn(
'absolute bottom-4 left-[50%] translate-x-[-50%]',
className
)}
onClick={handleScrollToBottom}
size='icon'
type='button'
variant='outline'
aria-label='Scroll to bottom'
{...props}
>
<ArrowDownIcon className='size-4' aria-hidden='true' />
</Button>
)
)
}
+159
View File
@@ -0,0 +1,159 @@
/*
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
*/
/* eslint-disable react-refresh/only-export-components */
import {
BaseEdge,
type EdgeProps,
getBezierPath,
getSimpleBezierPath,
type InternalNode,
type Node,
Position,
useInternalNode,
} from '@xyflow/react'
const Temporary = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
}: EdgeProps) => {
const [edgePath] = getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
return (
<BaseEdge
className='stroke-ring stroke-1'
id={id}
path={edgePath}
style={{
strokeDasharray: '5, 5',
}}
/>
)
}
const getHandleCoordsByPosition = (
node: InternalNode<Node>,
handlePosition: Position
) => {
// Choose the handle type based on position - Left is for target, Right is for source
const handleType = handlePosition === Position.Left ? 'target' : 'source'
const handle = node.internals.handleBounds?.[handleType]?.find(
(h) => h.position === handlePosition
)
if (!handle) {
return [0, 0] as const
}
let offsetX = handle.width / 2
let offsetY = handle.height / 2
// this is a tiny detail to make the markerEnd of an edge visible.
// The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
// when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
switch (handlePosition) {
case Position.Left:
offsetX = 0
break
case Position.Right:
offsetX = handle.width
break
case Position.Top:
offsetY = 0
break
case Position.Bottom:
offsetY = handle.height
break
default:
throw new Error(`Invalid handle position: ${handlePosition}`)
}
const x = node.internals.positionAbsolute.x + handle.x + offsetX
const y = node.internals.positionAbsolute.y + handle.y + offsetY
return [x, y] as const
}
const getEdgeParams = (
source: InternalNode<Node>,
target: InternalNode<Node>
) => {
const sourcePos = Position.Right
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos)
const targetPos = Position.Left
const [tx, ty] = getHandleCoordsByPosition(target, targetPos)
return {
sx,
sy,
tx,
ty,
sourcePos,
targetPos,
}
}
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
const sourceNode = useInternalNode(source)
const targetNode = useInternalNode(target)
if (!(sourceNode && targetNode)) {
return null
}
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
sourceNode,
targetNode
)
const [edgePath] = getBezierPath({
sourceX: sx,
sourceY: sy,
sourcePosition: sourcePos,
targetX: tx,
targetY: ty,
targetPosition: targetPos,
})
return (
<>
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
<circle fill='var(--primary)' r='4'>
<animateMotion dur='2s' path={edgePath} repeatCount='indefinite' />
</circle>
</>
)
}
export const Edge = {
Temporary,
Animated,
}
+43
View File
@@ -0,0 +1,43 @@
/*
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 { Experimental_GeneratedImage } from 'ai'
import { cn } from '@/lib/utils'
export type ImageProps = Experimental_GeneratedImage & {
className?: string
alt?: string
}
export const Image = ({
base64,
uint8Array,
mediaType,
...props
}: ImageProps) => (
<img
{...props}
alt={props.alt}
className={cn(
'h-auto max-w-full overflow-hidden rounded-md',
props.className
)}
src={`data:${mediaType};base64,${base64}`}
/>
)
@@ -0,0 +1,316 @@
/*
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
*/
'use client'
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react'
import {
type ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
} from '@/components/ui/carousel'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { cn } from '@/lib/utils'
export type InlineCitationProps = ComponentProps<'span'>
export const InlineCitation = ({
className,
...props
}: InlineCitationProps) => (
<span
className={cn('group inline items-center gap-1', className)}
{...props}
/>
)
export type InlineCitationTextProps = ComponentProps<'span'>
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn('group-hover:bg-accent transition-colors', className)}
{...props}
/>
)
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard {...props} />
)
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
sources: string[]
}
export const InlineCitationCardTrigger = ({
sources,
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger
delay={0}
closeDelay={0}
render={
<Badge className={cn('ml-1', className)} variant='secondary' {...props}>
{sources[0] ? (
<>
{new URL(sources[0]).hostname}{' '}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
'unknown'
)}
</Badge>
}
/>
)
export type InlineCitationCardBodyProps = ComponentProps<'div'>
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
)
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined)
const useCarouselApi = () => {
const context = useContext(CarouselApiContext)
return context
}
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>
export const InlineCitationCarousel = ({
className,
children,
...props
}: InlineCitationCarouselProps) => {
const [api, setApi] = useState<CarouselApi>()
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
)
}
export type InlineCitationCarouselContentProps = ComponentProps<'div'>
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps
) => <CarouselContent {...props} />
export type InlineCitationCarouselItemProps = ComponentProps<'div'>
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn('w-full space-y-2 p-4 pl-8', className)}
{...props}
/>
)
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
'bg-secondary flex items-center justify-between gap-2 rounded-t-md p-2',
className
)}
{...props}
/>
)
export type InlineCitationCarouselIndexProps = ComponentProps<'div'>
export const InlineCitationCarouselIndex = ({
children,
className,
...props
}: InlineCitationCarouselIndexProps) => {
const api = useCarouselApi()
const [current, setCurrent] = useState(0)
const [count, setCount] = useState(0)
useEffect(() => {
if (!api) {
return
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setCount(api.scrollSnapList().length)
setCurrent(api.selectedScrollSnap() + 1)
api.on('select', () => {
setCurrent(api.selectedScrollSnap() + 1)
})
}, [api])
return (
<div
className={cn(
'text-muted-foreground flex flex-1 items-center justify-end px-3 py-1 text-xs',
className
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
)
}
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const { t } = useTranslation()
const api = useCarouselApi()
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev()
}
}, [api])
return (
<Button
variant='ghost'
size='icon-sm'
aria-label={t('Previous')}
className={cn('shrink-0', className)}
onClick={handleClick}
type='button'
{...props}
>
<ArrowLeftIcon className='text-muted-foreground size-4' />
</Button>
)
}
export type InlineCitationCarouselNextProps = ComponentProps<'button'>
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const { t } = useTranslation()
const api = useCarouselApi()
const handleClick = useCallback(() => {
if (api) {
api.scrollNext()
}
}, [api])
return (
<Button
variant='ghost'
size='icon-sm'
aria-label={t('Next')}
className={cn('shrink-0', className)}
onClick={handleClick}
type='button'
{...props}
>
<ArrowRightIcon className='text-muted-foreground size-4' />
</Button>
)
}
export type InlineCitationSourceProps = ComponentProps<'div'> & {
title?: string
url?: string
description?: string
}
export const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn('space-y-1', className)} {...props}>
{title && (
<h4 className='truncate text-sm leading-tight font-medium'>{title}</h4>
)}
{url && (
<p className='text-muted-foreground truncate text-xs break-all'>{url}</p>
)}
{description && (
<p className='text-muted-foreground line-clamp-3 text-sm leading-relaxed'>
{description}
</p>
)}
{children}
</div>
)
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
'border-muted text-muted-foreground border-l-2 pl-3 text-sm italic',
className
)}
{...props}
>
{children}
</blockquote>
)
+119
View File
@@ -0,0 +1,119 @@
/*
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 { HTMLAttributes } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
type LoaderIconProps = {
size?: number
}
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => {
const { t } = useTranslation()
return (
<svg
height={size}
strokeLinejoin='round'
style={{ color: 'currentcolor' }}
viewBox='0 0 16 16'
width={size}
>
<title>{t('Loader')}</title>
<g clipPath='url(#clip0_2393_1490)'>
<path d='M8 0V4' stroke='currentColor' strokeWidth='1.5' />
<path
d='M8 16V12'
opacity='0.5'
stroke='currentColor'
strokeWidth='1.5'
/>
<path
d='M3.29773 1.52783L5.64887 4.7639'
opacity='0.9'
stroke='currentColor'
strokeWidth='1.5'
/>
<path
d='M12.7023 1.52783L10.3511 4.7639'
opacity='0.1'
stroke='currentColor'
strokeWidth='1.5'
/>
<path
d='M12.7023 14.472L10.3511 11.236'
opacity='0.4'
stroke='currentColor'
strokeWidth='1.5'
/>
<path
d='M3.29773 14.472L5.64887 11.236'
opacity='0.6'
stroke='currentColor'
strokeWidth='1.5'
/>
<path
d='M15.6085 5.52783L11.8043 6.7639'
opacity='0.2'
stroke='currentColor'
strokeWidth='1.5'
/>
<path
d='M0.391602 10.472L4.19583 9.23598'
opacity='0.7'
stroke='currentColor'
strokeWidth='1.5'
/>
<path
d='M15.6085 10.4722L11.8043 9.2361'
opacity='0.3'
stroke='currentColor'
strokeWidth='1.5'
/>
<path
d='M0.391602 5.52783L4.19583 6.7639'
opacity='0.8'
stroke='currentColor'
strokeWidth='1.5'
/>
</g>
<defs>
<clipPath id='clip0_2393_1490'>
<rect fill='white' height='16' width='16' />
</clipPath>
</defs>
</svg>
)
}
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number
}
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
'inline-flex animate-spin items-center justify-center',
className
)}
{...props}
>
<LoaderIcon size={size} />
</div>
)
@@ -0,0 +1,95 @@
/*
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 { UIMessage } from 'ai'
import { cva, type VariantProps } from 'class-variance-authority'
import type { ComponentProps, HTMLAttributes } from 'react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { cn } from '@/lib/utils'
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role']
}
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full items-end justify-end gap-2',
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
className
)}
{...props}
/>
)
const messageContentVariants = cva(
'is-user:dark flex flex-col gap-2 overflow-hidden rounded-lg text-sm',
{
variants: {
variant: {
contained: [
'max-w-[100%] px-3 py-1',
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
],
flat: [
'group-[.is-user]:max-w-[80%] group-[.is-user]:bg-secondary group-[.is-user]:px-3 group-[.is-user]:py-1 group-[.is-user]:text-foreground',
'group-[.is-assistant]:text-foreground',
],
},
},
defaultVariants: {
variant: 'contained',
},
}
)
export type MessageContentProps = HTMLAttributes<HTMLDivElement> &
VariantProps<typeof messageContentVariants>
export const MessageContent = ({
children,
className,
variant,
...props
}: MessageContentProps) => (
<div
className={cn(messageContentVariants({ variant, className }))}
{...props}
>
{children}
</div>
)
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
src: string
name?: string
}
export const MessageAvatar = ({
src,
name,
className,
...props
}: MessageAvatarProps) => (
<Avatar className={cn('ring-border size-8 ring-1', className)} {...props}>
<AvatarImage alt='' className='mt-0 mb-0' src={src} />
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
</Avatar>
)
+90
View File
@@ -0,0 +1,90 @@
/*
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 { Handle, Position } from '@xyflow/react'
import type { ComponentProps } from 'react'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { cn } from '@/lib/utils'
export type NodeProps = ComponentProps<typeof Card> & {
handles: {
target: boolean
source: boolean
}
}
export const Node = ({ handles, className, ...props }: NodeProps) => (
<Card
className={cn(
'node-container relative size-full h-auto w-sm gap-0 rounded-md p-0',
className
)}
{...props}
>
{handles.target && <Handle position={Position.Left} type='target' />}
{handles.source && <Handle position={Position.Right} type='source' />}
{props.children}
</Card>
)
export type NodeHeaderProps = ComponentProps<typeof CardHeader>
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
<CardHeader
className={cn('bg-secondary gap-0.5 rounded-t-md border-b p-3!', className)}
{...props}
/>
)
export type NodeTitleProps = ComponentProps<typeof CardTitle>
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>
export const NodeDescription = (props: NodeDescriptionProps) => (
<CardDescription {...props} />
)
export type NodeActionProps = ComponentProps<typeof CardAction>
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />
export type NodeContentProps = ComponentProps<typeof CardContent>
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
<CardContent className={cn('p-3', className)} {...props} />
)
export type NodeFooterProps = ComponentProps<typeof CardFooter>
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
<CardFooter
className={cn('bg-secondary rounded-b-md border-t p-3!', className)}
{...props}
/>
)
@@ -0,0 +1,409 @@
/*
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 {
ChevronDownIcon,
ExternalLinkIcon,
MessageCircleIcon,
} from 'lucide-react'
import { type ComponentProps, createContext, useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
const providers = {
github: {
title: 'Open in GitHub',
createUrl: (url: string) => url,
icon: (
<svg fill='currentColor' role='img' viewBox='0 0 24 24'>
<title>GitHub</title>
<path d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12' />
</svg>
),
},
scira: {
title: 'Open in Scira',
createUrl: (q: string) =>
`https://scira.ai/?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill='none'
height='934'
viewBox='0 0 910 934'
width='910'
xmlns='http://www.w3.org/2000/svg'
>
<title>Scira AI</title>
<path
d='M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z'
fill='currentColor'
stroke='currentColor'
strokeLinejoin='round'
strokeWidth='8'
/>
<path
d='M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z'
fill='currentColor'
stroke='currentColor'
strokeLinejoin='round'
strokeWidth='8'
/>
<path
d='M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z'
stroke='currentColor'
strokeLinejoin='round'
strokeWidth='20'
/>
<path
d='M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z'
stroke='currentColor'
strokeLinejoin='round'
strokeWidth='20'
/>
<path
d='M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z'
fill='currentColor'
stroke='currentColor'
strokeLinejoin='round'
strokeWidth='8'
/>
<path
d='M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z'
fill='currentColor'
stroke='currentColor'
strokeLinejoin='round'
strokeWidth='8'
/>
<path
d='M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='30'
/>
</svg>
),
},
chatgpt: {
title: 'Open in ChatGPT',
createUrl: (prompt: string) =>
`https://chatgpt.com/?${new URLSearchParams({
hints: 'search',
prompt,
})}`,
icon: (
<svg
fill='currentColor'
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<title>OpenAI</title>
<path d='M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z' />
</svg>
),
},
claude: {
title: 'Open in Claude',
createUrl: (q: string) =>
`https://claude.ai/new?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill='currentColor'
role='img'
viewBox='0 0 12 12'
xmlns='http://www.w3.org/2000/svg'
>
<title>Claude</title>
<path
clipRule='evenodd'
d='M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z'
fillRule='evenodd'
/>
</svg>
),
},
t3: {
title: 'Open in T3 Chat',
createUrl: (q: string) =>
`https://t3.chat/new?${new URLSearchParams({
q,
})}`,
icon: <MessageCircleIcon />,
},
v0: {
title: 'Open in v0',
createUrl: (q: string) =>
`https://v0.app?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill='currentColor'
viewBox='0 0 147 70'
xmlns='http://www.w3.org/2000/svg'
>
<title>v0</title>
<path d='M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z' />
<path d='M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z' />
</svg>
),
},
cursor: {
title: 'Open in Cursor',
createUrl: (text: string) => {
const url = new URL('https://cursor.com/link/prompt')
url.searchParams.set('text', text)
return url.toString()
},
icon: (
<svg
version='1.1'
viewBox='0 0 466.73 532.09'
xmlns='http://www.w3.org/2000/svg'
>
<title>Cursor</title>
<path
d='M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z'
fill='currentColor'
/>
</svg>
),
},
}
const OpenInContext = createContext<{ query: string } | undefined>(undefined)
const useOpenInContext = () => {
const context = useContext(OpenInContext)
if (!context) {
throw new Error('OpenIn components must be used within an OpenIn provider')
}
return context
}
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
query: string
}
export const OpenIn = ({ query, ...props }: OpenInProps) => (
<OpenInContext.Provider value={{ query }}>
<DropdownMenu {...props} />
</OpenInContext.Provider>
)
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
<DropdownMenuContent
align='start'
className={cn('w-[240px]', className)}
{...props}
/>
)
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInItem = (props: OpenInItemProps) => (
<DropdownMenuItem {...props} />
)
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>
export const OpenInLabel = (props: OpenInLabelProps) => (
<DropdownMenuLabel {...props} />
)
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
<DropdownMenuSeparator {...props} />
)
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => {
const { t } = useTranslation()
return (
<DropdownMenuTrigger
{...props}
render={
<Button type='button' variant='outline'>
{children ?? (
<>
{t('Open in chat')}
<ChevronDownIcon className='ml-2 size-4' />
</>
)}
</Button>
}
/>
)
}
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.chatgpt.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.chatgpt.icon}</span>
<span className='flex-1'>{providers.chatgpt.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInClaude = (props: OpenInClaudeProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.claude.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.claude.icon}</span>
<span className='flex-1'>{providers.claude.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>
export const OpenInT3 = (props: OpenInT3Props) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.t3.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.t3.icon}</span>
<span className='flex-1'>{providers.t3.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInScira = (props: OpenInSciraProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.scira.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.scira.icon}</span>
<span className='flex-1'>{providers.scira.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>
export const OpenInv0 = (props: OpenInv0Props) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.v0.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.v0.icon}</span>
<span className='flex-1'>{providers.v0.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInCursor = (props: OpenInCursorProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.cursor.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.cursor.icon}</span>
<span className='flex-1'>{providers.cursor.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
+34
View File
@@ -0,0 +1,34 @@
/*
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 { Panel as PanelPrimitive } from '@xyflow/react'
import type { ComponentProps } from 'react'
import { cn } from '@/lib/utils'
type PanelProps = ComponentProps<typeof PanelPrimitive>
export const Panel = ({ className, ...props }: PanelProps) => (
<PanelPrimitive
className={cn(
'bg-card m-4 overflow-hidden rounded-md border p-1',
className
)}
{...props}
/>
)
+171
View File
@@ -0,0 +1,171 @@
/*
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
*/
'use client'
import { ChevronsUpDownIcon } from 'lucide-react'
import { type ComponentProps, createContext, useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
import { Shimmer } from './shimmer'
type PlanContextValue = {
isStreaming: boolean
}
const PlanContext = createContext<PlanContextValue | null>(null)
const usePlan = () => {
const context = useContext(PlanContext)
if (!context) {
throw new Error('Plan components must be used within Plan')
}
return context
}
export type PlanProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean
}
export const Plan = ({
className,
isStreaming = false,
children,
...props
}: PlanProps) => (
<PlanContext.Provider value={{ isStreaming }}>
<Collapsible
data-slot='plan'
{...props}
render={<Card className={cn('shadow-none', className)} />}
>
{children}
</Collapsible>
</PlanContext.Provider>
)
export type PlanHeaderProps = ComponentProps<typeof CardHeader>
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
<CardHeader
className={cn('flex items-start justify-between', className)}
data-slot='plan-header'
{...props}
/>
)
export type PlanTitleProps = Omit<
ComponentProps<typeof CardTitle>,
'children'
> & {
children: string
}
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
const { isStreaming } = usePlan()
return (
<CardTitle data-slot='plan-title' {...props}>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardTitle>
)
}
export type PlanDescriptionProps = Omit<
ComponentProps<typeof CardDescription>,
'children'
> & {
children: string
}
export const PlanDescription = ({
className,
children,
...props
}: PlanDescriptionProps) => {
const { isStreaming } = usePlan()
return (
<CardDescription
className={cn('text-balance', className)}
data-slot='plan-description'
{...props}
>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardDescription>
)
}
export type PlanActionProps = ComponentProps<typeof CardAction>
export const PlanAction = (props: PlanActionProps) => (
<CardAction data-slot='plan-action' {...props} />
)
export type PlanContentProps = ComponentProps<typeof CardContent>
export const PlanContent = (props: PlanContentProps) => (
<CollapsibleContent
render={<CardContent data-slot='plan-content' {...props} />}
></CollapsibleContent>
)
export type PlanFooterProps = ComponentProps<'div'>
export const PlanFooter = (props: PlanFooterProps) => (
<CardFooter data-slot='plan-footer' {...props} />
)
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => {
const { t } = useTranslation()
return (
<CollapsibleTrigger
render={
<Button
className={cn('size-8', className)}
data-slot='plan-trigger'
size='icon'
variant='ghost'
/>
}
{...props}
>
<ChevronsUpDownIcon className='size-4' />
<span className='sr-only'>{t('Toggle plan')}</span>
</CollapsibleTrigger>
)
}
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
/*
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
*/
'use client'
import { ChevronDownIcon, PaperclipIcon } from 'lucide-react'
import type { ComponentProps } from 'react'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
export type QueueMessagePart = {
type: string
text?: string
url?: string
filename?: string
mediaType?: string
}
export type QueueMessage = {
id: string
parts: QueueMessagePart[]
}
export type QueueTodo = {
id: string
title: string
description?: string
status?: 'pending' | 'completed'
}
export type QueueItemProps = ComponentProps<'li'>
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
<li
className={cn(
'group hover:bg-muted flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors',
className
)}
{...props}
/>
)
export type QueueItemIndicatorProps = ComponentProps<'span'> & {
completed?: boolean
}
export const QueueItemIndicator = ({
completed = false,
className,
...props
}: QueueItemIndicatorProps) => (
<span
className={cn(
'mt-0.5 inline-block size-2.5 rounded-full border',
completed
? 'border-muted-foreground/20 bg-muted-foreground/10'
: 'border-muted-foreground/50',
className
)}
{...props}
/>
)
export type QueueItemContentProps = ComponentProps<'span'> & {
completed?: boolean
}
export const QueueItemContent = ({
completed = false,
className,
...props
}: QueueItemContentProps) => (
<span
className={cn(
'line-clamp-1 grow break-words',
completed
? 'text-muted-foreground/50 line-through'
: 'text-muted-foreground',
className
)}
{...props}
/>
)
export type QueueItemDescriptionProps = ComponentProps<'div'> & {
completed?: boolean
}
export const QueueItemDescription = ({
completed = false,
className,
...props
}: QueueItemDescriptionProps) => (
<div
className={cn(
'ml-6 text-xs',
completed
? 'text-muted-foreground/40 line-through'
: 'text-muted-foreground',
className
)}
{...props}
/>
)
export type QueueItemActionsProps = ComponentProps<'div'>
export const QueueItemActions = ({
className,
...props
}: QueueItemActionsProps) => (
<div className={cn('flex gap-1', className)} {...props} />
)
export type QueueItemActionProps = Omit<
ComponentProps<typeof Button>,
'variant' | 'size'
>
export const QueueItemAction = ({
className,
...props
}: QueueItemActionProps) => (
<Button
className={cn(
'text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground size-auto rounded p-1 opacity-0 transition-opacity group-hover:opacity-100',
className
)}
size='icon'
type='button'
variant='ghost'
{...props}
/>
)
export type QueueItemAttachmentProps = ComponentProps<'div'>
export const QueueItemAttachment = ({
className,
...props
}: QueueItemAttachmentProps) => (
<div className={cn('mt-1 flex flex-wrap gap-2', className)} {...props} />
)
export type QueueItemImageProps = ComponentProps<'img'>
export const QueueItemImage = ({
className,
...props
}: QueueItemImageProps) => (
<img
alt=''
className={cn('h-8 w-8 rounded border object-cover', className)}
height={32}
width={32}
{...props}
/>
)
export type QueueItemFileProps = ComponentProps<'span'>
export const QueueItemFile = ({
children,
className,
...props
}: QueueItemFileProps) => (
<span
className={cn(
'bg-muted flex items-center gap-1 rounded border px-2 py-1 text-xs',
className
)}
{...props}
>
<PaperclipIcon size={12} />
<span className='max-w-[100px] truncate'>{children}</span>
</span>
)
export type QueueListProps = ComponentProps<typeof ScrollArea>
export const QueueList = ({
children,
className,
...props
}: QueueListProps) => (
<ScrollArea className={cn('mt-2 -mb-1', className)} {...props}>
<div className='max-h-40 pr-4'>
<ul>{children}</ul>
</div>
</ScrollArea>
)
// QueueSection - collapsible section container
export type QueueSectionProps = ComponentProps<typeof Collapsible>
export const QueueSection = ({
className,
defaultOpen = true,
...props
}: QueueSectionProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
)
// QueueSectionTrigger - section header/trigger
export type QueueSectionTriggerProps = ComponentProps<'button'>
export const QueueSectionTrigger = ({
children,
className,
...props
}: QueueSectionTriggerProps) => (
<CollapsibleTrigger
render={
<Button
variant='ghost'
className={cn(
'group bg-muted/40 text-muted-foreground hover:bg-muted h-auto w-full justify-between px-3 py-2 text-left',
className
)}
type='button'
{...props}
/>
}
>
{children}
</CollapsibleTrigger>
)
// QueueSectionLabel - label content with icon and count
export type QueueSectionLabelProps = ComponentProps<'span'> & {
count?: number
label: string
icon?: React.ReactNode
}
export const QueueSectionLabel = ({
count,
label,
icon,
className,
...props
}: QueueSectionLabelProps) => (
<span className={cn('flex items-center gap-2', className)} {...props}>
<ChevronDownIcon className='size-4 -rotate-90 transition-transform group-data-[panel-open]:rotate-0' />
{icon}
<span>
{count} {label}
</span>
</span>
)
// QueueSectionContent - collapsible content area
export type QueueSectionContentProps = ComponentProps<typeof CollapsibleContent>
export const QueueSectionContent = ({
className,
...props
}: QueueSectionContentProps) => (
<CollapsibleContent className={cn(className)} {...props} />
)
export type QueueProps = ComponentProps<'div'>
export const Queue = ({ className, ...props }: QueueProps) => (
<div
className={cn(
'border-border bg-background flex flex-col gap-2 rounded-xl border px-3 pt-2 pb-2 shadow-xs',
className
)}
{...props}
/>
)
@@ -0,0 +1,214 @@
/*
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
*/
'use client'
import { BrainIcon, ChevronDownIcon } from 'lucide-react'
import {
type ComponentProps,
createContext,
memo,
useContext,
useEffect,
useState,
} from 'react'
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'
type ReasoningContextValue = {
isStreaming: boolean
isOpen: boolean
setIsOpen: (open: boolean) => void
duration: number
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
const useReasoning = () => {
const context = useContext(ReasoningContext)
if (!context) {
throw new Error('Reasoning components must be used within Reasoning')
}
return context
}
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
duration?: number
}
const AUTO_CLOSE_DELAY = 1000
const MS_IN_S = 1000
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: 0,
})
const [hasAutoClosed, setHasAutoClosed] = useState(false)
const [startTime, setStartTime] = useState<number | null>(null)
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setStartTime(Date.now())
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
setStartTime(null)
}
}, [isStreaming, startTime, setDuration])
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => {
setIsOpen(false)
setHasAutoClosed(true)
}, AUTO_CLOSE_DELAY)
return () => clearTimeout(timer)
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen)
}
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn('not-prose mb-4', className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
)
}
)
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>
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 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 ?? (
<>
<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>
)
}
)
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string
}
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
'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}
>
<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>
)
)
Reasoning.displayName = 'Reasoning'
ReasoningTrigger.displayName = 'ReasoningTrigger'
ReasoningContent.displayName = 'ReasoningContent'
@@ -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 { t } from 'i18next'
import type { ReactNode } from 'react'
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 { t } from 'i18next'
import type { ReactNode } from 'react'
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 { t } from 'i18next'
import type { ReactNode } from 'react'
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
}
@@ -0,0 +1,66 @@
/*
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
*/
'use client'
import { memo, useMemo } from 'react'
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { cn } from '@/lib/utils'
import { getMarkdownContent, parseResponseContent } from './response-content'
import { renderChildren, renderFootnotes } from './response-renderer'
import type { ResponseProps } from './response-types'
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 []
}
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 (
<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'
@@ -0,0 +1,74 @@
/*
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
*/
'use client'
import { motion } from 'motion/react'
import { type CSSProperties, type ElementType, memo, useMemo } from 'react'
import { cn } from '@/lib/utils'
export type TextShimmerProps = {
children: string
as?: ElementType
className?: string
duration?: number
spread?: number
}
const MotionP = motion.p
const ShimmerComponent = ({
children,
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
)
return (
<MotionP
animate={{ backgroundPosition: '0% center' }}
className={cn(
'relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent',
'[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))]',
className
)}
initial={{ backgroundPosition: '100% center' }}
style={
{
'--spread': `${dynamicSpread}px`,
backgroundImage:
'var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))',
} as CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: 'linear',
}}
>
{children}
</MotionP>
)
}
export const Shimmer = memo(ShimmerComponent)
+102
View File
@@ -0,0 +1,102 @@
/*
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
*/
'use client'
import { BookIcon, ChevronDownIcon } from 'lucide-react'
import type { ComponentProps } from 'react'
import { useTranslation } from 'react-i18next'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
export type SourcesProps = ComponentProps<'div'>
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
className={cn('not-prose text-primary mb-4 text-xs', className)}
{...props}
/>
)
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number
}
export const SourcesTrigger = ({
className,
count,
children,
...props
}: SourcesTriggerProps) => {
const { t } = useTranslation()
return (
<CollapsibleTrigger
className={cn('flex items-center gap-2', className)}
{...props}
>
{children ?? (
<>
<p className='font-medium'>
{t('Used')} {count} {t('sources')}
</p>
<ChevronDownIcon className='h-4 w-4' />
</>
)}
</CollapsibleTrigger>
)
}
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>
export const SourcesContent = ({
className,
...props
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
'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
)}
{...props}
/>
)
export type SourceProps = ComponentProps<'a'>
export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a
className='flex items-center gap-2'
href={href}
rel='noreferrer'
target='_blank'
{...props}
>
{children ?? (
<>
<BookIcon className='h-4 w-4' />
<span className='block font-medium'>{title}</span>
</>
)}
</a>
)
@@ -0,0 +1,72 @@
/*
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
*/
'use client'
import type { ComponentProps } from 'react'
import { Button } from '@/components/ui/button'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
export type SuggestionsProps = ComponentProps<typeof ScrollArea>
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className='w-full overflow-x-auto whitespace-nowrap' {...props}>
<div className={cn('flex w-max flex-nowrap items-center gap-2', className)}>
{children}
</div>
<ScrollBar className='hidden' orientation='horizontal' />
</ScrollArea>
)
export type SuggestionProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
suggestion: string
onClick?: (suggestion: string) => void
}
export const Suggestion = ({
suggestion,
onClick,
className,
variant = 'outline',
size = 'sm',
children,
...props
}: SuggestionProps) => {
const handleClick = () => {
onClick?.(suggestion)
}
return (
<Button
className={cn('cursor-pointer px-4', className)}
onClick={handleClick}
size={size}
type='button'
variant={variant}
{...props}
>
{children || suggestion}
</Button>
)
}
+112
View File
@@ -0,0 +1,112 @@
/*
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
*/
'use client'
import { ChevronDownIcon, SearchIcon } from 'lucide-react'
import type { ComponentProps } from 'react'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
export type TaskItemFileProps = ComponentProps<'div'>
export const TaskItemFile = ({
children,
className,
...props
}: TaskItemFileProps) => (
<div
className={cn(
'bg-secondary text-foreground inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs',
className
)}
{...props}
>
{children}
</div>
)
export type TaskItemProps = ComponentProps<'div'>
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn('text-muted-foreground text-sm', className)} {...props}>
{children}
</div>
)
export type TaskProps = ComponentProps<typeof Collapsible>
export const Task = ({
defaultOpen = true,
className,
...props
}: TaskProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
)
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string
}
export const TaskTrigger = ({
children,
className,
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger
className={cn('group', className)}
{...props}
render={
<div className='text-muted-foreground hover:text-foreground flex w-full cursor-pointer items-center gap-2 text-sm transition-colors'>
{children ?? (
<>
<SearchIcon className='size-4' />
<p className='text-sm'>{title}</p>
<ChevronDownIcon className='size-4 transition-transform group-data-[panel-open]:rotate-180' />
</>
)}
</div>
}
/>
)
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>
export const TaskContent = ({
children,
className,
...props
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-popover-foreground data-closed:animate-out data-open:animate-in outline-none',
className
)}
{...props}
>
<div className='border-muted mt-4 space-y-2 border-l-2 pl-4'>
{children}
</div>
</CollapsibleContent>
)
+193
View File
@@ -0,0 +1,193 @@
/*
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
*/
'use client'
import type { ToolUIPart } from 'ai'
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from 'lucide-react'
import { type ComponentProps, isValidElement, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
import { CodeBlock } from './code-block'
// Workaround for missing types in 'ai' package
type ExtendedToolState =
| ToolUIPart['state']
| 'approval-requested'
| 'approval-responded'
| 'output-denied'
export type ToolProps = ComponentProps<typeof Collapsible>
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn('not-prose mb-4 w-full rounded-md border', className)}
{...props}
/>
)
export type ToolHeaderProps = {
title?: string
type: ToolUIPart['type']
state: ExtendedToolState
className?: string
}
const getStatusBadge = (status: ExtendedToolState) => {
const labels: Record<ExtendedToolState, string> = {
'input-streaming': 'Pending',
'input-available': 'Running',
'approval-requested': 'Awaiting Approval',
'approval-responded': 'Responded',
'output-available': 'Completed',
'output-error': 'Error',
'output-denied': 'Denied',
}
const icons: Record<ExtendedToolState, ReactNode> = {
'input-streaming': <CircleIcon className='size-4' />,
'input-available': <ClockIcon className='size-4 animate-pulse' />,
'approval-requested': <ClockIcon className='text-warning size-4' />,
'approval-responded': <CheckCircleIcon className='text-info size-4' />,
'output-available': <CheckCircleIcon className='text-success size-4' />,
'output-error': <XCircleIcon className='text-destructive size-4' />,
'output-denied': <XCircleIcon className='text-warning size-4' />,
}
return (
<Badge className='gap-1.5 text-xs' variant='secondary'>
{icons[status]}
{labels[status]}
</Badge>
)
}
export const ToolHeader = ({
className,
title,
type,
state,
...props
}: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
'group flex w-full items-center justify-between gap-4 p-3',
className
)}
{...props}
>
<div className='flex items-center gap-2'>
<WrenchIcon className='text-muted-foreground size-4' />
<span className='text-sm font-medium'>
{title ?? type.split('-').slice(1).join('-')}
</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className='text-muted-foreground size-4 transition-transform group-data-[panel-open]:rotate-180' />
</CollapsibleTrigger>
)
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-popover-foreground data-closed:animate-out data-open:animate-in outline-none',
className
)}
{...props}
/>
)
export type ToolInputProps = ComponentProps<'div'> & {
input: ToolUIPart['input']
}
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => {
const { t } = useTranslation()
return (
<div className={cn('space-y-2 overflow-hidden p-4', className)} {...props}>
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
{t('Parameters')}
</h4>
<div className='bg-muted/50 rounded-md'>
<CodeBlock code={JSON.stringify(input, null, 2)} language='json' />
</div>
</div>
)
}
export type ToolOutputProps = ComponentProps<'div'> & {
output: ToolUIPart['output']
errorText: ToolUIPart['errorText']
}
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null
}
let Output = <div>{output as ReactNode}</div>
if (typeof output === 'object' && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language='json' />
)
} else if (typeof output === 'string') {
Output = <CodeBlock code={output} language='json' />
}
return (
<div className={cn('space-y-2 p-4', className)} {...props}>
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
{errorText ? 'Error' : 'Result'}
</h4>
<div
className={cn(
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
errorText
? 'bg-destructive/10 text-destructive'
: 'bg-muted/50 text-foreground'
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
)
}
@@ -0,0 +1,35 @@
/*
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 { NodeToolbar, Position } from '@xyflow/react'
import type { ComponentProps } from 'react'
import { cn } from '@/lib/utils'
type ToolbarProps = ComponentProps<typeof NodeToolbar>
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
<NodeToolbar
className={cn(
'bg-background flex items-center gap-1 rounded-sm border p-1.5',
className
)}
position={Position.Bottom}
{...props}
/>
)
@@ -0,0 +1,297 @@
/*
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
*/
'use client'
import { ChevronDownIcon } from 'lucide-react'
import {
type ComponentProps,
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
export type WebPreviewContextValue = {
url: string
setUrl: (url: string) => void
consoleOpen: boolean
setConsoleOpen: (open: boolean) => void
}
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null)
const useWebPreview = () => {
const context = useContext(WebPreviewContext)
if (!context) {
throw new Error('WebPreview components must be used within a WebPreview')
}
return context
}
export type WebPreviewProps = ComponentProps<'div'> & {
defaultUrl?: string
onUrlChange?: (url: string) => void
}
export const WebPreview = ({
className,
children,
defaultUrl = '',
onUrlChange,
...props
}: WebPreviewProps) => {
const [url, setUrl] = useState(defaultUrl)
const [consoleOpen, setConsoleOpen] = useState(false)
const handleUrlChange = (newUrl: string) => {
setUrl(newUrl)
onUrlChange?.(newUrl)
}
const contextValue: WebPreviewContextValue = {
url,
setUrl: handleUrlChange,
consoleOpen,
setConsoleOpen,
}
return (
<WebPreviewContext.Provider value={contextValue}>
<div
className={cn(
'bg-card flex size-full flex-col rounded-lg border',
className
)}
{...props}
>
{children}
</div>
</WebPreviewContext.Provider>
)
}
export type WebPreviewNavigationProps = ComponentProps<'div'>
export const WebPreviewNavigation = ({
className,
children,
...props
}: WebPreviewNavigationProps) => (
<div
className={cn('flex items-center gap-1 border-b p-2', className)}
{...props}
>
{children}
</div>
)
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
tooltip?: string
}
export const WebPreviewNavigationButton = ({
onClick,
disabled,
tooltip,
children,
...props
}: WebPreviewNavigationButtonProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className='hover:text-foreground h-8 w-8 p-0'
disabled={disabled}
onClick={onClick}
size='sm'
variant='ghost'
{...props}
/>
}
>
{children}
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
export type WebPreviewUrlProps = ComponentProps<typeof Input>
export const WebPreviewUrl = ({
value,
onChange,
onKeyDown,
...props
}: WebPreviewUrlProps) => {
const { t } = useTranslation()
const { url, setUrl } = useWebPreview()
const [inputValue, setInputValue] = useState(url)
// Sync input value with context URL when it changes externally
useEffect(() => {
setInputValue(url)
}, [url])
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value)
onChange?.(event)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
const target = event.target as HTMLInputElement
setUrl(target.value)
}
onKeyDown?.(event)
}
return (
<Input
className='h-8 flex-1 text-sm'
onChange={onChange ?? handleChange}
onKeyDown={handleKeyDown}
placeholder={t('Enter URL...')}
value={value ?? inputValue}
{...props}
/>
)
}
export type WebPreviewBodyProps = ComponentProps<'iframe'> & {
loading?: ReactNode
}
export const WebPreviewBody = ({
className,
loading,
src,
...props
}: WebPreviewBodyProps) => {
const { t } = useTranslation()
const { url } = useWebPreview()
return (
<div className='flex-1'>
<iframe
className={cn('size-full', className)}
sandbox='allow-scripts allow-same-origin allow-forms allow-popups allow-presentation'
src={(src ?? url) || undefined}
title={t('Preview')}
{...props}
/>
{loading}
</div>
)
}
export type WebPreviewConsoleProps = ComponentProps<'div'> & {
logs?: Array<{
level: 'log' | 'warn' | 'error'
message: string
timestamp: Date
}>
}
export const WebPreviewConsole = ({
className,
logs = [],
children,
...props
}: WebPreviewConsoleProps) => {
const { t } = useTranslation()
const { consoleOpen, setConsoleOpen } = useWebPreview()
return (
<Collapsible
className={cn('bg-muted/50 border-t font-mono text-sm', className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
>
<CollapsibleTrigger
render={
<Button
className='hover:bg-muted/50 flex w-full items-center justify-between p-4 text-left font-medium'
variant='ghost'
/>
}
>
{t('Console')}
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
consoleOpen && 'rotate-180'
)}
/>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
'px-4 pb-4',
'data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-closed:animate-out data-open:animate-in outline-none'
)}
>
<div className='max-h-48 space-y-1 overflow-y-auto'>
{logs.length === 0 ? (
<p className='text-muted-foreground'>{t('No console output')}</p>
) : (
logs.map((log, index) => (
<div
className={cn(
'text-xs',
log.level === 'error' && 'text-destructive',
log.level === 'warn' && 'text-warning',
log.level === 'log' && 'text-foreground'
)}
key={`${log.timestamp.getTime()}-${index}`}
>
<span className='text-muted-foreground'>
{dayjs(log.timestamp).format('HH:mm:ss')}
</span>{' '}
{log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
)
}