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:
@@ -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
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user