Compare commits
52 Commits
main
...
perf/playground
| Author | SHA1 | Date | |
|---|---|---|---|
| f53b557a17 | |||
| 4372abd787 | |||
| 9b633a4131 | |||
| ffb1e8e97a | |||
| fa334c1eb0 | |||
| fbcaf75b62 | |||
| 47e912123c | |||
| 4f03641ac7 | |||
| efc9c5844b | |||
| 5a5286967d | |||
| 80a54b5b4b | |||
| 6ba23572b2 | |||
| 378eed2bd4 | |||
| 61717ee53b | |||
| f738ee481c | |||
| 0f94c07f16 | |||
| d75e393b11 | |||
| eef921d188 | |||
| b6ad800e77 | |||
| c82242f0d2 | |||
| a8c19eec50 | |||
| 0f625f33a0 | |||
| 902593926a | |||
| b4e7c48e42 | |||
| f03c8cc709 | |||
| 6aba2b3eec | |||
| 80ee5244d9 | |||
| f87af88ca5 | |||
| 5816f69c20 | |||
| 3606367104 | |||
| 0339e36246 | |||
| 1f3eb1e419 | |||
| 6b5ee783f1 | |||
| 2f16326562 | |||
| 76469cb944 | |||
| 47d4d74bd6 | |||
| 59f3758175 | |||
| 0deab07bb6 | |||
| 7465f682f8 | |||
| 894f25ca51 | |||
| 12b103e9b6 | |||
| fdffe43533 | |||
| 809e1dce6d | |||
| 43c003e8e1 | |||
| b0bf0b949b | |||
| 2d94a24912 | |||
| a297c00cc3 | |||
| 5489c68eec | |||
| c40d00e740 | |||
| e6e86b8e8c | |||
| 3f2107fb6d | |||
| 8a3e353231 |
+254
-23
@@ -23,34 +23,66 @@ import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { Element } from 'hast'
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import {
|
||||
type BundledLanguage,
|
||||
codeToHtml,
|
||||
type ShikiTransformer,
|
||||
} from 'shiki/bundle/web'
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BundledLanguage, ShikiTransformer } from 'shiki'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string
|
||||
language: BundledLanguage
|
||||
collapsedLines?: number
|
||||
defaultCollapsed?: boolean
|
||||
enableCollapse?: boolean
|
||||
filename?: string
|
||||
language: BundledLanguage | string
|
||||
maxExpandedLines?: number
|
||||
/** @deprecated use collapsedLines for collapsed preview height. */
|
||||
maxCollapsedLines?: number
|
||||
showLineNumbers?: boolean
|
||||
showToolbar?: boolean
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string
|
||||
language: string
|
||||
}
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: '',
|
||||
language: 'plaintext',
|
||||
})
|
||||
|
||||
const highlightCache = new Map<string, string>()
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
|
||||
csharp: 'c#',
|
||||
golang: 'go',
|
||||
js: 'javascript',
|
||||
shell: 'bash',
|
||||
shellscript: 'bash',
|
||||
ts: 'typescript',
|
||||
}
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: 'line-numbers',
|
||||
line(node: Element, line: number) {
|
||||
@@ -72,64 +104,251 @@ const lineNumberTransformer: ShikiTransformer = {
|
||||
},
|
||||
}
|
||||
|
||||
function getRequestedCodeLanguage(language?: string) {
|
||||
const normalized = language?.trim().toLowerCase() || 'plaintext'
|
||||
return LANGUAGE_ALIASES[normalized] ?? normalized
|
||||
}
|
||||
|
||||
async function normalizeCodeLanguage(language?: string) {
|
||||
const aliased = getRequestedCodeLanguage(language)
|
||||
const { bundledLanguages } = await import('shiki')
|
||||
if (aliased in bundledLanguages) {
|
||||
return aliased as BundledLanguage
|
||||
}
|
||||
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
function escapeCodeHtml(code: string) {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function renderPlainCodeHtml(code: string, showLineNumbers: boolean) {
|
||||
const lines = code.split('\n')
|
||||
const renderedCode = lines
|
||||
.map((line, index) => {
|
||||
const escapedLine = escapeCodeHtml(line) || ' '
|
||||
if (!showLineNumbers) {
|
||||
return escapedLine
|
||||
}
|
||||
|
||||
return `<span class="inline-block min-w-10 mr-4 text-right select-none text-muted-foreground">${index + 1}</span>${escapedLine}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<pre class="shiki"><code>${renderedCode}</code></pre>`
|
||||
}
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
language: BundledLanguage | string,
|
||||
showLineNumbers = false
|
||||
) {
|
||||
const resolvedLanguage = await normalizeCodeLanguage(language)
|
||||
const cacheKey = `${resolvedLanguage}:${showLineNumbers ? 'line' : 'plain'}:${code}`
|
||||
const cachedHtml = highlightCache.get(cacheKey)
|
||||
|
||||
if (cachedHtml) {
|
||||
return cachedHtml
|
||||
}
|
||||
|
||||
const transformers: ShikiTransformer[] = showLineNumbers
|
||||
? [lineNumberTransformer]
|
||||
: []
|
||||
|
||||
return codeToHtml(code, {
|
||||
lang: language,
|
||||
if (resolvedLanguage === 'plaintext') {
|
||||
const html = renderPlainCodeHtml(code, showLineNumbers)
|
||||
highlightCache.set(cacheKey, html)
|
||||
return html
|
||||
}
|
||||
|
||||
const { codeToHtml } = await import('shiki')
|
||||
const html = await codeToHtml(code, {
|
||||
lang: resolvedLanguage,
|
||||
themes: {
|
||||
light: 'one-light',
|
||||
dark: 'one-dark-pro',
|
||||
},
|
||||
defaultColor: false,
|
||||
transformers,
|
||||
})
|
||||
|
||||
highlightCache.set(cacheKey, html)
|
||||
return html
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
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 [html, setHtml] = useState<string>('')
|
||||
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 = isCodeCollapsed
|
||||
? getCodeBlockHeight(previewLines)
|
||||
: maxExpandedLines
|
||||
? getCodeBlockHeight(maxExpandedLines)
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
highlightCode(code, language, showLineNumbers).then((next) => {
|
||||
if (!cancelled) {
|
||||
setHtml(next)
|
||||
}
|
||||
})
|
||||
highlightCode(code, language, showLineNumbers)
|
||||
.then((next) => {
|
||||
if (!cancelled) {
|
||||
setHtml(next)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setHtml(renderPlainCodeHtml(code, showLineNumbers))
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [code, language, showLineNumbers])
|
||||
|
||||
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 }}>
|
||||
<CodeBlockContext.Provider value={{ code, language: displayLanguage }}>
|
||||
<div
|
||||
className={cn(
|
||||
'group bg-background text-foreground relative w-full overflow-hidden rounded-md border',
|
||||
'group/code-block bg-muted/20 text-foreground my-3 w-full max-w-full overflow-hidden rounded-lg border shadow-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='relative'>
|
||||
{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'>
|
||||
{displayTitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1'>
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='relative min-w-0'>
|
||||
<div
|
||||
className='[&>pre]:bg-background! [&>pre]:text-foreground! overflow-hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm'
|
||||
className={cn(
|
||||
'code-block-scroll max-w-full overflow-auto transition-[max-height] duration-200 ease-out',
|
||||
'[&_.shiki]:bg-transparent! [&_.shiki]:text-foreground! [&_code]:font-mono [&_code]:text-[13px] [&_code]:leading-6',
|
||||
'[&>pre]:m-0 [&>pre]:min-w-max [&>pre]:p-4 [&>pre]:text-[13px] [&>pre]:leading-6'
|
||||
)}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
style={{ maxHeight: bodyMaxHeight }}
|
||||
/>
|
||||
{children && (
|
||||
<div className='absolute top-2 right-2 flex items-center gap-2'>
|
||||
{isCodeCollapsed && (
|
||||
<div className='from-muted/20 pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-linear-to-b to-background' />
|
||||
)}
|
||||
{!showToolbar && children && (
|
||||
<div className='absolute top-2 right-2 flex items-center gap-1'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -153,6 +372,7 @@ export const CodeBlockCopyButton = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const { code } = useContext(CodeBlockContext)
|
||||
|
||||
@@ -174,15 +394,26 @@ export const CodeBlockCopyButton = ({
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<Button
|
||||
className={cn('shrink-0', className)}
|
||||
aria-label={isCopied ? t('Copied!') : t('Copy code')}
|
||||
className={cn('size-8 shrink-0', className)}
|
||||
onClick={copyToClipboard}
|
||||
size='icon'
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent>
|
||||
<p>{isCopied ? t('Copied!') : t('Copy code')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
className={cn('relative min-h-0 flex-1 overflow-hidden', className)}
|
||||
initial='smooth'
|
||||
resize='smooth'
|
||||
role='log'
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-4 text-sm',
|
||||
'border-border/70 mt-3 ml-2 border-l pl-4 text-sm',
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-muted-foreground data-closed:animate-out data-open:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
|
||||
+436
-4
@@ -18,14 +18,436 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { type ComponentProps, memo } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import {
|
||||
Children,
|
||||
type ComponentProps,
|
||||
type JSX,
|
||||
isValidElement,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Streamdown, type Components } from 'streamdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
} from '@/components/ai-elements/code-block'
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown>
|
||||
|
||||
type CodeComponentProps = ComponentProps<'code'> & {
|
||||
node?: unknown
|
||||
'data-block'?: boolean
|
||||
}
|
||||
|
||||
type MarkdownElementProps<T extends keyof JSX.IntrinsicElements> =
|
||||
ComponentProps<T> & {
|
||||
node?: unknown
|
||||
}
|
||||
|
||||
function getCodeText(children: ReactNode) {
|
||||
if (typeof children === 'string') {
|
||||
return children.replace(/\n$/, '')
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return children.join('').replace(/\n$/, '')
|
||||
}
|
||||
|
||||
return String(children ?? '')
|
||||
}
|
||||
|
||||
function getCodeLanguage(className?: string) {
|
||||
return className?.match(/language-([\w#+.-]+)/)?.[1] ?? 'plaintext'
|
||||
}
|
||||
|
||||
function isSummaryElement(child: ReactNode) {
|
||||
return isValidElement(child) && child.type === 'summary'
|
||||
}
|
||||
|
||||
function MarkdownImage({
|
||||
alt,
|
||||
className,
|
||||
node: _node,
|
||||
src,
|
||||
...props
|
||||
}: MarkdownElementProps<'img'>) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
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'>
|
||||
{alt || 'Image not available'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'border-border/70 my-4 block h-auto max-h-96 max-w-full rounded-lg border object-contain',
|
||||
className
|
||||
)}
|
||||
loading='lazy'
|
||||
onError={() => setHasError(true)}
|
||||
src={src}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const responseComponents: Components = {
|
||||
h1({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h1'>) {
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
'mt-6 mb-3 text-xl font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
},
|
||||
h2({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h2'>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
'mt-6 mb-3 text-lg font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
},
|
||||
h3({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h3'>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
'mt-5 mb-2 text-base font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
},
|
||||
h4({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h4'>) {
|
||||
return (
|
||||
<h4
|
||||
className={cn('mt-5 mb-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
},
|
||||
h5({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h5'>) {
|
||||
return (
|
||||
<h5
|
||||
className={cn(
|
||||
'text-muted-foreground mt-4 mb-2 text-sm font-semibold',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
)
|
||||
},
|
||||
h6({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h6'>) {
|
||||
return (
|
||||
<h6
|
||||
className={cn(
|
||||
'text-muted-foreground mt-4 mb-2 text-xs font-semibold uppercase',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
)
|
||||
},
|
||||
ul({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
className={cn(
|
||||
'my-3 list-outside list-disc space-y-1.5 pl-5',
|
||||
'[&.contains-task-list]:list-none [&.contains-task-list]:pl-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
ol({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
className={cn(
|
||||
'my-3 list-outside list-decimal space-y-1.5 pl-5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
},
|
||||
li({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'marker:text-muted-foreground pl-1 leading-7',
|
||||
'[&.task-list-item]:flex [&.task-list-item]:items-start [&.task-list-item]:gap-2 [&.task-list-item]:pl-0',
|
||||
'[&.task-list-item>input]:accent-primary [&.task-list-item>input]:mt-1.5 [&.task-list-item>input]:size-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
details({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'details'>) {
|
||||
const childArray = Children.toArray(children)
|
||||
const summaryChildren = childArray.filter(isSummaryElement)
|
||||
const contentChildren = childArray.filter(
|
||||
(child) => !isSummaryElement(child)
|
||||
)
|
||||
|
||||
return (
|
||||
<details className={cn('my-4', className)} {...props}>
|
||||
{summaryChildren}
|
||||
{contentChildren.length > 0 && (
|
||||
<div className='border-border/70 ml-5 border-l pl-4'>
|
||||
{contentChildren}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)
|
||||
},
|
||||
summary({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'summary'>) {
|
||||
return (
|
||||
<summary
|
||||
className={cn(
|
||||
'text-foreground marker:text-muted-foreground mb-2 cursor-pointer text-sm font-semibold',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</summary>
|
||||
)
|
||||
},
|
||||
blockquote({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'blockquote'>) {
|
||||
return (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'border-border text-muted-foreground my-4 border-l-2 pl-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
},
|
||||
hr({ className, node: _node, ...props }: MarkdownElementProps<'hr'>) {
|
||||
return <hr className={cn('border-border/70 my-6', className)} {...props} />
|
||||
},
|
||||
img: MarkdownImage,
|
||||
table({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'table'>) {
|
||||
return (
|
||||
<div className='border-border/70 my-4 w-full overflow-x-auto rounded-lg border'>
|
||||
<table
|
||||
className={cn(
|
||||
'w-full min-w-max border-separate border-spacing-0 text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
thead({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'thead'>) {
|
||||
return (
|
||||
<thead className={cn('bg-muted/60', className)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
)
|
||||
},
|
||||
tbody({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'tbody'>) {
|
||||
return (
|
||||
<tbody className={cn('divide-border/70 divide-y', className)} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
)
|
||||
},
|
||||
tr({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'tr'>) {
|
||||
return (
|
||||
<tr className={cn('border-border/70', className)} {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
)
|
||||
},
|
||||
th({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
td({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'td'>) {
|
||||
return (
|
||||
<td className={cn('px-3 py-2 align-top', className)} {...props}>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
},
|
||||
code({ children, className, ...props }: CodeComponentProps) {
|
||||
if (!props['data-block']) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
'bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
const code = getCodeText(children)
|
||||
const language = getCodeLanguage(className)
|
||||
const lineCount = code.split('\n').length
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
collapsedLines={14}
|
||||
code={code}
|
||||
defaultCollapsed={lineCount > 14}
|
||||
language={language}
|
||||
maxExpandedLines={44}
|
||||
showLineNumbers={true}
|
||||
showToolbar={true}
|
||||
title={language}
|
||||
>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlock>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Response = memo(
|
||||
({ className, children, ...props }: ResponseProps) => {
|
||||
({ className, children, components, ...props }: ResponseProps) => {
|
||||
const stripCustomTags = (input: unknown): unknown => {
|
||||
if (typeof input !== 'string') return input
|
||||
return (
|
||||
@@ -45,9 +467,19 @@ export const Response = memo(
|
||||
return (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
'size-full min-w-0 text-pretty',
|
||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
'[&_p]:my-3 [&_p]:leading-7',
|
||||
'[&_strong]:text-foreground [&_strong]:font-semibold',
|
||||
'[&_a]:text-primary [&_a]:underline-offset-4 hover:[&_a]:underline',
|
||||
'[&_details>summary~*]:border-border/70 [&_details]:my-4 [&_details>summary~*]:ml-5 [&_details>summary~*]:border-l [&_details>summary~*]:pl-4',
|
||||
'[&_summary]:text-foreground [&_summary::marker]:text-muted-foreground [&_summary]:mb-2 [&_summary]:cursor-pointer [&_summary]:text-sm [&_summary]:font-semibold',
|
||||
'[&_[data-streamdown=table-wrapper]]:border-0 [&_[data-streamdown=table-wrapper]]:bg-transparent [&_[data-streamdown=table-wrapper]]:p-0 [&_[data-streamdown=table-wrapper]]:shadow-none',
|
||||
'[&_[data-streamdown=table-wrapper]>div:first-child]:hidden',
|
||||
'[&_[data-streamdown=table-wrapper]>div:last-child]:border-border/70 [&_[data-streamdown=table-wrapper]>div:last-child]:rounded-lg',
|
||||
className
|
||||
)}
|
||||
components={{ ...responseComponents, ...components }}
|
||||
{...props}
|
||||
>
|
||||
{safeChildren}
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export const SourcesContent = ({
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-3 flex w-fit flex-col gap-2',
|
||||
'border-border/70 mt-3 ml-2 flex w-fit flex-col gap-2 border-l pl-4',
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 data-closed:animate-out data-open:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
|
||||
+3
-1
@@ -29,9 +29,11 @@ import type {
|
||||
* Send chat completion request (non-streaming)
|
||||
*/
|
||||
export async function sendChatCompletion(
|
||||
payload: ChatCompletionRequest
|
||||
payload: ChatCompletionRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<ChatCompletionResponse> {
|
||||
const res = await api.post(API_ENDPOINTS.CHAT_COMPLETIONS, payload, {
|
||||
signal,
|
||||
skipErrorHandler: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
|
||||
+129
-56
@@ -16,12 +16,33 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Copy, Check, RefreshCw, Edit, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Edit,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { MESSAGE_ACTION_LABELS } from '../constants'
|
||||
import { useMessageActionGuard } from '../hooks/use-message-action-guard'
|
||||
import {
|
||||
getMessageActionState,
|
||||
getMessageActionsVisibilityClass,
|
||||
} from '../lib'
|
||||
import type { Message } from '../types'
|
||||
import { MessageActionButton } from './message-action-button'
|
||||
|
||||
@@ -36,6 +57,15 @@ interface MessageActionsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
type MessageActionItem = {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'destructive'
|
||||
}
|
||||
|
||||
export function MessageActions({
|
||||
message,
|
||||
onCopy,
|
||||
@@ -46,14 +76,12 @@ export function MessageActions({
|
||||
alwaysVisible = false,
|
||||
className = '',
|
||||
}: MessageActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copiedText, copyToClipboard } = useCopyToClipboard()
|
||||
const { guardAction } = useMessageActionGuard(isGenerating)
|
||||
|
||||
const isAssistant = message.from === 'assistant'
|
||||
const hasContent = message.versions.some((v) => v.content)
|
||||
const isLoading =
|
||||
message.status === 'loading' || message.status === 'streaming'
|
||||
const content = message.versions[0]?.content || ''
|
||||
const { content, hasContent, isAssistant, isLoading } =
|
||||
getMessageActionState(message)
|
||||
const isCopied = copiedText === content
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -69,60 +97,105 @@ export function MessageActions({
|
||||
const handleEdit = guardAction(() => onEdit?.(message))
|
||||
const handleDelete = guardAction(() => onDelete?.(message))
|
||||
|
||||
const visibilityClass = alwaysVisible
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 max-md:opacity-100'
|
||||
const visibilityClass = getMessageActionsVisibilityClass(alwaysVisible)
|
||||
const actions: MessageActionItem[] = []
|
||||
|
||||
if (hasContent) {
|
||||
actions.push({
|
||||
className: isCopied ? 'text-green-600' : '',
|
||||
icon: isCopied ? Check : Copy,
|
||||
label: isCopied ? MESSAGE_ACTION_LABELS.COPIED : MESSAGE_ACTION_LABELS.COPY,
|
||||
onClick: handleCopy,
|
||||
})
|
||||
}
|
||||
|
||||
if (isAssistant && !isLoading && onRegenerate) {
|
||||
actions.push({
|
||||
disabled: isGenerating,
|
||||
icon: RefreshCw,
|
||||
label: MESSAGE_ACTION_LABELS.REGENERATE,
|
||||
onClick: handleRegenerate,
|
||||
})
|
||||
}
|
||||
|
||||
if (hasContent && onEdit) {
|
||||
actions.push({
|
||||
disabled: isGenerating,
|
||||
icon: Edit,
|
||||
label: MESSAGE_ACTION_LABELS.EDIT,
|
||||
onClick: handleEdit,
|
||||
})
|
||||
}
|
||||
|
||||
if (onDelete) {
|
||||
actions.push({
|
||||
disabled: isGenerating,
|
||||
icon: Trash2,
|
||||
label: MESSAGE_ACTION_LABELS.DELETE,
|
||||
onClick: handleDelete,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={300}>
|
||||
<div
|
||||
className={`flex items-center gap-0.5 transition-opacity ${visibilityClass} ${className}`}
|
||||
>
|
||||
{/* Copy */}
|
||||
{hasContent && (
|
||||
<MessageActionButton
|
||||
icon={isCopied ? Check : Copy}
|
||||
label={
|
||||
isCopied
|
||||
? MESSAGE_ACTION_LABELS.COPIED
|
||||
: MESSAGE_ACTION_LABELS.COPY
|
||||
<>
|
||||
<TooltipProvider delay={300}>
|
||||
<div
|
||||
className={`hidden items-center gap-0.5 transition-opacity md:flex ${visibilityClass} ${className}`}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<MessageActionButton
|
||||
className={action.className}
|
||||
disabled={action.disabled}
|
||||
icon={action.icon}
|
||||
key={action.label}
|
||||
label={action.label}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className={`md:hidden ${className}`}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t('Open menu')}
|
||||
className='data-popup-open:bg-muted size-11 text-muted-foreground hover:text-foreground'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
/>
|
||||
}
|
||||
onClick={handleCopy}
|
||||
className={isCopied ? 'text-green-600' : ''}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className='size-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-44'>
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon
|
||||
|
||||
{/* Regenerate - only for assistant messages */}
|
||||
{isAssistant && !isLoading && onRegenerate && (
|
||||
<MessageActionButton
|
||||
icon={RefreshCw}
|
||||
label={MESSAGE_ACTION_LABELS.REGENERATE}
|
||||
onClick={handleRegenerate}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit */}
|
||||
{hasContent && onEdit && (
|
||||
<MessageActionButton
|
||||
icon={Edit}
|
||||
label={MESSAGE_ACTION_LABELS.EDIT}
|
||||
onClick={handleEdit}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
{onDelete && (
|
||||
<MessageActionButton
|
||||
icon={Trash2}
|
||||
label={MESSAGE_ACTION_LABELS.DELETE}
|
||||
onClick={handleDelete}
|
||||
disabled={isGenerating}
|
||||
variant='destructive'
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className='min-h-11'
|
||||
disabled={action.disabled}
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant}
|
||||
>
|
||||
{action.label}
|
||||
<DropdownMenuShortcut>
|
||||
<Icon className='size-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
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 { Edit, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type MessageErrorActionsProps = {
|
||||
disabled?: boolean
|
||||
onDelete?: () => void
|
||||
onEditPrompt?: () => void
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export function MessageErrorActions({
|
||||
disabled = false,
|
||||
onDelete,
|
||||
onEditPrompt,
|
||||
onRetry,
|
||||
}: MessageErrorActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!onRetry && !onEditPrompt && !onDelete) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap gap-2 pt-2'>
|
||||
{onRetry && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={disabled}
|
||||
onClick={onRetry}
|
||||
size='sm'
|
||||
>
|
||||
<RefreshCw className='size-3.5' />
|
||||
{t('Retry')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onEditPrompt && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={disabled}
|
||||
onClick={onEditPrompt}
|
||||
size='sm'
|
||||
variant='outline'
|
||||
>
|
||||
<Edit className='size-3.5' />
|
||||
{t('Edit')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={disabled}
|
||||
onClick={onDelete}
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
>
|
||||
<Trash2 className='size-3.5' />
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,54 +16,62 @@ 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 { AlertCircle, AlertTriangle, Settings } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { MESSAGE_STATUS } from '../constants'
|
||||
import {
|
||||
getMessageErrorState,
|
||||
isAdminRole,
|
||||
MODEL_PRICING_SETTINGS_PATH,
|
||||
} from '../lib'
|
||||
import type { Message } from '../types'
|
||||
|
||||
interface MessageErrorProps {
|
||||
message: Message
|
||||
className?: string
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Display error messages using Alert component
|
||||
* Following ai-elements pattern for error handling
|
||||
*/
|
||||
export function MessageError({ message, className = '' }: MessageErrorProps) {
|
||||
export function MessageError({
|
||||
message,
|
||||
className = '',
|
||||
actions,
|
||||
}: MessageErrorProps) {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthStore((s) => s.auth.user)
|
||||
const isAdmin = user?.role != null && user.role >= 10
|
||||
const errorState = getMessageErrorState(message, isAdminRole(user?.role))
|
||||
|
||||
if (message.status !== MESSAGE_STATUS.ERROR) {
|
||||
if (!errorState) {
|
||||
return null
|
||||
}
|
||||
|
||||
const errorContent =
|
||||
message.versions[0]?.content || 'An unknown error occurred'
|
||||
|
||||
if (message.errorCode === 'model_price_error') {
|
||||
if (errorState.kind === 'model-price') {
|
||||
return (
|
||||
<Alert variant='default' className={className}>
|
||||
<AlertTriangle className='text-orange-500' />
|
||||
<AlertTitle>{t('Model Price Not Configured')}</AlertTitle>
|
||||
<AlertDescription className='space-y-2'>
|
||||
<p>{errorContent}</p>
|
||||
{isAdmin && (
|
||||
<p>{errorState.content}</p>
|
||||
{errorState.showSettingsLink && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
window.open('/system-settings/billing/model-pricing', '_blank')
|
||||
window.open(MODEL_PRICING_SETTINGS_PATH, '_blank')
|
||||
}
|
||||
>
|
||||
<Settings className='mr-1 h-3.5 w-3.5' />
|
||||
{t('Go to Settings')}
|
||||
</Button>
|
||||
)}
|
||||
{actions}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
@@ -73,7 +81,10 @@ export function MessageError({ message, className = '' }: MessageErrorProps) {
|
||||
<Alert variant='destructive' className={className}>
|
||||
<AlertCircle />
|
||||
<AlertTitle>{t('Error')}</AlertTitle>
|
||||
<AlertDescription>{errorContent}</AlertDescription>
|
||||
<AlertDescription className='space-y-2'>
|
||||
<p>{errorState.content}</p>
|
||||
{actions}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
+95
-216
@@ -16,44 +16,25 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Branch,
|
||||
BranchMessages,
|
||||
BranchNext,
|
||||
BranchPage,
|
||||
BranchPrevious,
|
||||
BranchSelector,
|
||||
} from '@/components/ai-elements/branch'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import { Loader } from '@/components/ai-elements/loader'
|
||||
import { Message, MessageContent } from '@/components/ai-elements/message'
|
||||
import { Message } from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { Response } from '@/components/ai-elements/response'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import {
|
||||
Source,
|
||||
Sources,
|
||||
SourcesContent,
|
||||
SourcesTrigger,
|
||||
} from '@/components/ai-elements/sources'
|
||||
import { MESSAGE_ROLES } from '../constants'
|
||||
import { getMessageContentStyles } from '../lib/message-styles'
|
||||
import { parseThinkTags } from '../lib/message-utils'
|
||||
getChatMessageRenderState,
|
||||
getEditingMessageContent,
|
||||
getPreviousUserMessage,
|
||||
isErrorMessage,
|
||||
} from '../lib'
|
||||
import type { Message as MessageType } from '../types'
|
||||
import { MessageActions } from './message-actions'
|
||||
import { MessageError } from './message-error'
|
||||
import { MessageErrorActions } from './message-error-actions'
|
||||
import { PlaygroundEmptyState } from './playground-empty-state'
|
||||
import { PlaygroundMessageContent } from './playground-message-content'
|
||||
import { PlaygroundMessageEditor } from './playground-message-editor'
|
||||
|
||||
interface PlaygroundChatProps {
|
||||
messages: MessageType[]
|
||||
@@ -61,6 +42,7 @@ interface PlaygroundChatProps {
|
||||
onRegenerateMessage?: (message: MessageType) => void
|
||||
onEditMessage?: (message: MessageType) => void
|
||||
onDeleteMessage?: (message: MessageType) => void
|
||||
onSelectPrompt?: (prompt: string) => void
|
||||
isGenerating?: boolean
|
||||
editingKey?: string | null
|
||||
onSaveEdit?: (newContent: string) => void
|
||||
@@ -74,6 +56,7 @@ export function PlaygroundChat({
|
||||
onRegenerateMessage,
|
||||
onEditMessage,
|
||||
onDeleteMessage,
|
||||
onSelectPrompt,
|
||||
isGenerating = false,
|
||||
editingKey,
|
||||
onSaveEdit,
|
||||
@@ -85,204 +68,100 @@ export function PlaygroundChat({
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingKey) return
|
||||
const message = messages.find((m) => m.key === editingKey)
|
||||
const content = message?.versions?.[0]?.content || ''
|
||||
const content = getEditingMessageContent(messages, editingKey)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setEditText(content)
|
||||
|
||||
setOriginalText(content)
|
||||
}, [editingKey, messages])
|
||||
|
||||
const isEditing = (key: string) => editingKey === key
|
||||
const isEmpty = useMemo(() => !editText.trim(), [editText])
|
||||
const isChanged = useMemo(
|
||||
() => editText !== originalText,
|
||||
[editText, originalText]
|
||||
)
|
||||
return (
|
||||
<Conversation>
|
||||
{/* Remove outer padding; apply padding to inner centered container to align with input */}
|
||||
<ConversationContent className='p-0'>
|
||||
<div className='mx-auto w-full max-w-4xl px-4 py-4'>
|
||||
{messages.map((message, messageIndex) => {
|
||||
const { versions = [] } = message
|
||||
const isLastAssistantMessage =
|
||||
messageIndex === messages.length - 1 &&
|
||||
message.from === MESSAGE_ROLES.ASSISTANT
|
||||
return (
|
||||
<Branch defaultBranch={0} key={message.key}>
|
||||
<BranchMessages>
|
||||
{versions.map((version, versionIndex) => (
|
||||
<Message
|
||||
className='group flex-row-reverse'
|
||||
from={message.from}
|
||||
key={`${message.key}-${version.id}-${versionIndex}`}
|
||||
>
|
||||
<div className='w-full min-w-0 flex-1 basis-full py-1'>
|
||||
{isEditing(message.key) ? (
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
className='font-mono text-sm'
|
||||
rows={8}
|
||||
{messages.length === 0 && onSelectPrompt ? (
|
||||
<PlaygroundEmptyState onSelectPrompt={onSelectPrompt} />
|
||||
) : (
|
||||
messages.map((message, messageIndex) => {
|
||||
const { alwaysShowActions, content, isEditing } =
|
||||
getChatMessageRenderState(
|
||||
messages,
|
||||
message,
|
||||
messageIndex,
|
||||
editingKey
|
||||
)
|
||||
const isError = isErrorMessage(message)
|
||||
const previousUserMessage = isError
|
||||
? getPreviousUserMessage(messages, messageIndex)
|
||||
: null
|
||||
|
||||
return (
|
||||
<Message
|
||||
className={
|
||||
message.from === 'assistant'
|
||||
? 'group flex-row-reverse py-3'
|
||||
: 'group flex-row-reverse py-1.5'
|
||||
}
|
||||
from={message.from}
|
||||
key={message.key}
|
||||
>
|
||||
<div className='w-full min-w-0 flex-1 basis-full'>
|
||||
{isEditing ? (
|
||||
<PlaygroundMessageEditor
|
||||
editText={editText}
|
||||
message={message}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onEditTextChange={setEditText}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onSaveEditAndSubmit={onSaveEditAndSubmit}
|
||||
originalText={originalText}
|
||||
/>
|
||||
) : (
|
||||
<PlaygroundMessageContent
|
||||
actions={
|
||||
<MessageActions
|
||||
message={message}
|
||||
onCopy={onCopyMessage}
|
||||
onRegenerate={onRegenerateMessage}
|
||||
onEdit={onEditMessage}
|
||||
onDelete={onDeleteMessage}
|
||||
isGenerating={isGenerating}
|
||||
alwaysVisible={alwaysShowActions}
|
||||
className='mt-2'
|
||||
/>
|
||||
}
|
||||
message={message}
|
||||
errorActions={
|
||||
isError ? (
|
||||
<MessageErrorActions
|
||||
disabled={isGenerating}
|
||||
onRetry={
|
||||
onRegenerateMessage
|
||||
? () => onRegenerateMessage(message)
|
||||
: undefined
|
||||
}
|
||||
onEditPrompt={
|
||||
onEditMessage && previousUserMessage
|
||||
? () => onEditMessage(previousUserMessage)
|
||||
: undefined
|
||||
}
|
||||
onDelete={
|
||||
onDeleteMessage
|
||||
? () => onDeleteMessage(message)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
{/* Save & Submit only makes sense for user messages */}
|
||||
{message.from === MESSAGE_ROLES.USER && (
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
onSaveEditAndSubmit?.(editText)
|
||||
}
|
||||
disabled={isEmpty || !isChanged}
|
||||
>
|
||||
Save & Submit
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => onSaveEdit?.(editText)}
|
||||
disabled={isEmpty || !isChanged}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => onCancelEdit?.(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
const isAssistant =
|
||||
message.from === MESSAGE_ROLES.ASSISTANT
|
||||
const hasSources = !!message.sources?.length
|
||||
const showReasoning =
|
||||
isAssistant && !!message.reasoning?.content
|
||||
const showLoader =
|
||||
isAssistant &&
|
||||
!message.isReasoningStreaming &&
|
||||
(message.status === 'loading' ||
|
||||
(message.status === 'streaming' &&
|
||||
!version.content))
|
||||
const showMessageContent =
|
||||
(message.from === MESSAGE_ROLES.USER ||
|
||||
!message.isReasoningStreaming) &&
|
||||
!!version.content
|
||||
|
||||
// Extract visible content (remove <think> tags for assistant messages)
|
||||
const displayContent = isAssistant
|
||||
? parseThinkTags(version.content).visibleContent
|
||||
: version.content
|
||||
|
||||
const actions = (
|
||||
<MessageActions
|
||||
message={message}
|
||||
onCopy={onCopyMessage}
|
||||
onRegenerate={onRegenerateMessage}
|
||||
onEdit={onEditMessage}
|
||||
onDelete={onDeleteMessage}
|
||||
isGenerating={isGenerating}
|
||||
alwaysVisible={isLastAssistantMessage}
|
||||
className='mt-1'
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sources */}
|
||||
{hasSources && (
|
||||
<Sources>
|
||||
<SourcesTrigger
|
||||
count={message.sources!.length}
|
||||
/>
|
||||
<SourcesContent>
|
||||
{message.sources!.map(
|
||||
(source, sourceIndex) => (
|
||||
<Source
|
||||
href={source.href}
|
||||
key={`${message.key}-source-${sourceIndex}`}
|
||||
title={source.title}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</SourcesContent>
|
||||
</Sources>
|
||||
)}
|
||||
|
||||
{/* Reasoning */}
|
||||
{showReasoning && (
|
||||
<Reasoning
|
||||
defaultOpen={true}
|
||||
isStreaming={message.isReasoningStreaming}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
{message.reasoning!.content}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
)}
|
||||
|
||||
{/* Loader */}
|
||||
{showLoader && (
|
||||
<div className='flex items-center gap-2 py-2'>
|
||||
<Loader />
|
||||
<Shimmer className='text-sm' duration={1}>
|
||||
Responding...
|
||||
</Shimmer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error or Content */}
|
||||
{message.status === 'error' ? (
|
||||
<>
|
||||
<MessageError
|
||||
message={message}
|
||||
className='mb-2'
|
||||
/>
|
||||
{actions}
|
||||
</>
|
||||
) : (
|
||||
showMessageContent && (
|
||||
<>
|
||||
<MessageContent
|
||||
variant='flat'
|
||||
className={cn(
|
||||
getMessageContentStyles()
|
||||
)}
|
||||
>
|
||||
<Response>{displayContent}</Response>
|
||||
</MessageContent>
|
||||
{actions}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Message>
|
||||
))}
|
||||
</BranchMessages>
|
||||
|
||||
{/* Branch selector for multiple versions */}
|
||||
{versions.length > 1 && (
|
||||
<BranchSelector className='px-0' from={message.from}>
|
||||
<BranchPrevious />
|
||||
<BranchPage />
|
||||
<BranchNext />
|
||||
</BranchSelector>
|
||||
)}
|
||||
</Branch>
|
||||
)
|
||||
})}
|
||||
) : undefined
|
||||
}
|
||||
versionContent={content}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Message>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
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 {
|
||||
BarChartIcon,
|
||||
CodeSquareIcon,
|
||||
GraduationCapIcon,
|
||||
MessageSquarePlusIcon,
|
||||
NotepadTextIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type PlaygroundEmptyStateProps = {
|
||||
onSelectPrompt: (prompt: string) => void
|
||||
}
|
||||
|
||||
const starterPrompts = [
|
||||
{ icon: BarChartIcon, text: 'Analyze data' },
|
||||
{ icon: NotepadTextIcon, text: 'Summarize text' },
|
||||
{ icon: CodeSquareIcon, text: 'Code' },
|
||||
{ icon: GraduationCapIcon, text: 'Get advice' },
|
||||
]
|
||||
|
||||
export function PlaygroundEmptyState({
|
||||
onSelectPrompt,
|
||||
}: PlaygroundEmptyStateProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[min(520px,calc(100svh-18rem))] items-center justify-center px-1 py-8 md:py-12'>
|
||||
<div className='grid w-full max-w-2xl gap-5 text-center'>
|
||||
<div className='mx-auto flex size-11 items-center justify-center rounded-xl border bg-muted/50 text-muted-foreground'>
|
||||
<MessageSquarePlusIcon className='size-5' aria-hidden='true' />
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<h2 className='text-balance text-xl font-semibold tracking-tight md:text-2xl'>
|
||||
{t('Start a playground chat')}
|
||||
</h2>
|
||||
<p className='mx-auto max-w-lg text-balance text-sm leading-6 text-muted-foreground'>
|
||||
{t(
|
||||
'Test a model with a starter prompt, or write your own request below.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
{starterPrompts.map(({ icon: Icon, text }) => {
|
||||
const prompt = t(text)
|
||||
|
||||
return (
|
||||
<Button
|
||||
className='h-auto min-h-11 justify-start gap-2 whitespace-normal px-3 py-2.5 text-left'
|
||||
key={text}
|
||||
onClick={() => onSelectPrompt(prompt)}
|
||||
variant='outline'
|
||||
>
|
||||
<Icon className='size-4 text-muted-foreground' />
|
||||
<span>{prompt}</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
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 { SendIcon, SquareIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PromptInputButton } from '@/components/ai-elements/prompt-input'
|
||||
import { ModelGroupSelector } from '@/components/model-group-selector'
|
||||
import { getInputControlState } from '../lib'
|
||||
import type { GroupOption, ModelOption } from '../types'
|
||||
|
||||
type PlaygroundInputControlsProps = {
|
||||
disabled?: boolean
|
||||
groups: GroupOption[]
|
||||
groupValue: string
|
||||
isGenerating?: boolean
|
||||
isModelLoading?: boolean
|
||||
models: ModelOption[]
|
||||
modelValue: string
|
||||
onGroupChange: (value: string) => void
|
||||
onModelChange: (value: string) => void
|
||||
onStop?: () => void
|
||||
text: string
|
||||
tools: ReactNode
|
||||
}
|
||||
|
||||
export function PlaygroundInputControls({
|
||||
disabled,
|
||||
groups,
|
||||
groupValue,
|
||||
isGenerating,
|
||||
isModelLoading = false,
|
||||
models,
|
||||
modelValue,
|
||||
onGroupChange,
|
||||
onModelChange,
|
||||
onStop,
|
||||
text,
|
||||
tools,
|
||||
}: PlaygroundInputControlsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { canSubmit, isSelectorDisabled, shouldShowStop } =
|
||||
getInputControlState({
|
||||
disabled,
|
||||
groups,
|
||||
hasStopHandler: Boolean(onStop),
|
||||
isGenerating,
|
||||
isModelLoading,
|
||||
models,
|
||||
text,
|
||||
})
|
||||
|
||||
const renderSelector = () => (
|
||||
<ModelGroupSelector
|
||||
className='gap-1.5 md:gap-2'
|
||||
selectedModel={modelValue}
|
||||
models={models}
|
||||
onModelChange={onModelChange}
|
||||
selectedGroup={groupValue}
|
||||
groups={groups}
|
||||
onGroupChange={onGroupChange}
|
||||
disabled={isSelectorDisabled}
|
||||
/>
|
||||
)
|
||||
|
||||
const renderSubmitButton = () => (
|
||||
<>
|
||||
{shouldShowStop ? (
|
||||
<PromptInputButton
|
||||
className='text-foreground font-medium'
|
||||
onClick={onStop}
|
||||
variant='secondary'
|
||||
>
|
||||
<SquareIcon className='fill-current' size={16} />
|
||||
<span className='hidden sm:inline'>{t('Stop')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Stop')}</span>
|
||||
</PromptInputButton>
|
||||
) : (
|
||||
<PromptInputButton
|
||||
className='text-foreground font-medium'
|
||||
disabled={!canSubmit}
|
||||
type='submit'
|
||||
variant='secondary'
|
||||
>
|
||||
<SendIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Send')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Send')}</span>
|
||||
</PromptInputButton>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between'>
|
||||
<div className='flex min-w-0 items-center justify-end md:hidden'>
|
||||
{renderSelector()}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between gap-2 md:justify-start'>
|
||||
{tools}
|
||||
<div className='flex items-center gap-1.5 md:hidden'>
|
||||
{renderSubmitButton()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='hidden items-center gap-2 md:flex'>
|
||||
{renderSelector()}
|
||||
{renderSubmitButton()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
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 { GlobeIcon, PaperclipIcon, Trash2Icon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
PromptInputButton,
|
||||
PromptInputTools,
|
||||
} from '@/components/ai-elements/prompt-input'
|
||||
import {
|
||||
ATTACHMENT_ACTIONS,
|
||||
getAttachmentActionNotice,
|
||||
getSearchActionNotice,
|
||||
} from '../lib'
|
||||
|
||||
type PlaygroundInputToolsProps = {
|
||||
disabled?: boolean
|
||||
hasMessages?: boolean
|
||||
onClearMessages?: () => void
|
||||
}
|
||||
|
||||
export function PlaygroundInputTools({
|
||||
disabled,
|
||||
hasMessages = false,
|
||||
onClearMessages,
|
||||
}: PlaygroundInputToolsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false)
|
||||
|
||||
const handleFileAction = (action: string) => {
|
||||
const notice = getAttachmentActionNotice(action)
|
||||
toast.info(t(notice.title), {
|
||||
description: notice.description,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearchAction = () => {
|
||||
const notice = getSearchActionNotice()
|
||||
toast.info(t(notice.title))
|
||||
}
|
||||
|
||||
const handleClearMessages = () => {
|
||||
onClearMessages?.()
|
||||
setClearConfirmOpen(false)
|
||||
toast.success(t('Conversation cleared'))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PromptInputTools>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PromptInputButton
|
||||
className='border font-medium'
|
||||
disabled={disabled}
|
||||
variant='outline'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Attach')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Attach')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
{ATTACHMENT_ACTIONS.map(({ action, icon: Icon, label }) => (
|
||||
<DropdownMenuItem
|
||||
key={action}
|
||||
onClick={() => handleFileAction(action)}
|
||||
>
|
||||
<Icon className='mr-2' size={16} />
|
||||
{t(label)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptInputButton
|
||||
className='border font-medium'
|
||||
disabled={disabled}
|
||||
onClick={handleSearchAction}
|
||||
variant='outline'
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Search')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Search')}</span>
|
||||
</PromptInputButton>
|
||||
|
||||
<PromptInputButton
|
||||
className='border font-medium text-muted-foreground hover:text-destructive'
|
||||
disabled={disabled || !hasMessages || !onClearMessages}
|
||||
onClick={() => setClearConfirmOpen(true)}
|
||||
variant='outline'
|
||||
>
|
||||
<Trash2Icon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Clear chat history')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Clear chat history')}</span>
|
||||
</PromptInputButton>
|
||||
</PromptInputTools>
|
||||
|
||||
<ConfirmDialog
|
||||
destructive
|
||||
desc={t(
|
||||
'All playground messages saved in this browser will be removed. This cannot be undone.'
|
||||
)}
|
||||
confirmText={t('Clear')}
|
||||
handleConfirm={handleClearMessages}
|
||||
open={clearConfirmOpen}
|
||||
onOpenChange={setClearConfirmOpen}
|
||||
title={t('Clear chat history?')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+33
-155
@@ -17,40 +17,18 @@ 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 {
|
||||
PaperclipIcon,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
ScreenShareIcon,
|
||||
CameraIcon,
|
||||
GlobeIcon,
|
||||
SendIcon,
|
||||
SquareIcon,
|
||||
BarChartIcon,
|
||||
BoxIcon,
|
||||
NotepadTextIcon,
|
||||
CodeSquareIcon,
|
||||
GraduationCapIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputButton,
|
||||
PromptInputFooter,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
type PromptInputMessage,
|
||||
} from '@/components/ai-elements/prompt-input'
|
||||
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion'
|
||||
import { ModelGroupSelector } from '@/components/model-group-selector'
|
||||
import { getSubmittableInputText } from '../lib'
|
||||
import type { ModelOption, GroupOption } from '../types'
|
||||
import { PlaygroundInputControls } from './playground-input-controls'
|
||||
import { PlaygroundInputTools } from './playground-input-tools'
|
||||
import { PlaygroundSuggestions } from './playground-suggestions'
|
||||
|
||||
interface PlaygroundInputProps {
|
||||
onSubmit: (text: string) => void
|
||||
@@ -64,17 +42,10 @@ interface PlaygroundInputProps {
|
||||
groups: GroupOption[]
|
||||
groupValue: string
|
||||
onGroupChange: (value: string) => void
|
||||
hasMessages?: boolean
|
||||
onClearMessages?: () => void
|
||||
}
|
||||
|
||||
const suggestions = [
|
||||
{ icon: BarChartIcon, text: 'Analyze data', color: '#76d0eb' },
|
||||
{ icon: BoxIcon, text: 'Surprise me', color: '#76d0eb' },
|
||||
{ icon: NotepadTextIcon, text: 'Summarize text', color: '#ea8444' },
|
||||
{ icon: CodeSquareIcon, text: 'Code', color: '#6c71ff' },
|
||||
{ icon: GraduationCapIcon, text: 'Get advice', color: '#76d0eb' },
|
||||
{ icon: null, text: 'More' },
|
||||
]
|
||||
|
||||
export function PlaygroundInput({
|
||||
onSubmit,
|
||||
onStop,
|
||||
@@ -87,30 +58,20 @@ export function PlaygroundInput({
|
||||
groups,
|
||||
groupValue,
|
||||
onGroupChange,
|
||||
hasMessages = false,
|
||||
onClearMessages,
|
||||
}: PlaygroundInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const [text, setText] = useState('')
|
||||
|
||||
const isModelSelectDisabled =
|
||||
disabled || isModelLoading || models.length === 0
|
||||
const isGroupSelectDisabled = disabled || groups.length === 0
|
||||
|
||||
const handleSubmit = (message: PromptInputMessage) => {
|
||||
if (!message.text?.trim() || disabled) return
|
||||
onSubmit(message.text)
|
||||
const submittableText = getSubmittableInputText(message, disabled)
|
||||
|
||||
if (!submittableText) return
|
||||
onSubmit(submittableText)
|
||||
setText('')
|
||||
}
|
||||
|
||||
const handleFileAction = (action: string) => {
|
||||
toast.info(t('Feature in development'), {
|
||||
description: action,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
onSubmit(suggestion)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid shrink-0 gap-4 px-1 md:pb-4'>
|
||||
<PromptInput groupClassName='rounded-xl' onSubmit={handleSubmit}>
|
||||
@@ -127,113 +88,30 @@ export function PlaygroundInput({
|
||||
/>
|
||||
|
||||
<PromptInputFooter className='p-2.5'>
|
||||
<PromptInputTools>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PromptInputButton
|
||||
className='border font-medium'
|
||||
disabled={disabled}
|
||||
variant='outline'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Attach')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Attach')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('upload-file')}
|
||||
>
|
||||
<FileIcon className='mr-2' size={16} />
|
||||
{t('Upload file')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('upload-photo')}
|
||||
>
|
||||
<ImageIcon className='mr-2' size={16} />
|
||||
{t('Upload photo')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('take-screenshot')}
|
||||
>
|
||||
<ScreenShareIcon className='mr-2' size={16} />
|
||||
{t('Take screenshot')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('take-photo')}
|
||||
>
|
||||
<CameraIcon className='mr-2' size={16} />
|
||||
{t('Take photo')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptInputButton
|
||||
className='border font-medium'
|
||||
disabled={disabled}
|
||||
onClick={() => toast.info(t('Search feature in development'))}
|
||||
variant='outline'
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Search')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Search')}</span>
|
||||
</PromptInputButton>
|
||||
</PromptInputTools>
|
||||
|
||||
<div className='flex items-center gap-1.5 md:gap-2'>
|
||||
<ModelGroupSelector
|
||||
selectedModel={modelValue}
|
||||
models={models}
|
||||
onModelChange={onModelChange}
|
||||
selectedGroup={groupValue}
|
||||
groups={groups}
|
||||
onGroupChange={onGroupChange}
|
||||
disabled={isModelSelectDisabled || isGroupSelectDisabled}
|
||||
/>
|
||||
|
||||
{isGenerating && onStop ? (
|
||||
<PromptInputButton
|
||||
className='text-foreground font-medium'
|
||||
onClick={onStop}
|
||||
variant='secondary'
|
||||
>
|
||||
<SquareIcon className='fill-current' size={16} />
|
||||
<span className='hidden sm:inline'>{t('Stop')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Stop')}</span>
|
||||
</PromptInputButton>
|
||||
) : (
|
||||
<PromptInputButton
|
||||
className='text-foreground font-medium'
|
||||
disabled={disabled || !text.trim()}
|
||||
type='submit'
|
||||
variant='secondary'
|
||||
>
|
||||
<SendIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Send')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Send')}</span>
|
||||
</PromptInputButton>
|
||||
)}
|
||||
</div>
|
||||
<PlaygroundInputControls
|
||||
disabled={disabled}
|
||||
groups={groups}
|
||||
groupValue={groupValue}
|
||||
isGenerating={isGenerating}
|
||||
isModelLoading={isModelLoading}
|
||||
models={models}
|
||||
modelValue={modelValue}
|
||||
onGroupChange={onGroupChange}
|
||||
onModelChange={onModelChange}
|
||||
onStop={onStop}
|
||||
text={text}
|
||||
tools={
|
||||
<PlaygroundInputTools
|
||||
disabled={disabled}
|
||||
hasMessages={hasMessages}
|
||||
onClearMessages={onClearMessages}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
|
||||
<Suggestions>
|
||||
{suggestions.map(({ icon: Icon, text, color }) => (
|
||||
<Suggestion
|
||||
className={`text-xs font-normal sm:text-sm ${
|
||||
text === 'More' ? 'hidden sm:flex' : ''
|
||||
}`}
|
||||
key={text}
|
||||
onClick={() => handleSuggestionClick(text)}
|
||||
suggestion={text}
|
||||
>
|
||||
{Icon && <Icon size={16} style={{ color }} />}
|
||||
{text}
|
||||
</Suggestion>
|
||||
))}
|
||||
</Suggestions>
|
||||
<PlaygroundSuggestions onSelect={onSubmit} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+126
@@ -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
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Loader } from '@/components/ai-elements/loader'
|
||||
import { MessageContent } from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { Response } from '@/components/ai-elements/response'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import {
|
||||
Source,
|
||||
Sources,
|
||||
SourcesContent,
|
||||
SourcesTrigger,
|
||||
} from '@/components/ai-elements/sources'
|
||||
import { getMessageContentStyles } from '../lib/message-styles'
|
||||
import { getMessageContentState, isErrorMessage } from '../lib'
|
||||
import type { Message } from '../types'
|
||||
import { MessageError } from './message-error'
|
||||
|
||||
type PlaygroundMessageContentProps = {
|
||||
actions: ReactNode
|
||||
errorActions?: ReactNode
|
||||
message: Message
|
||||
versionContent: string
|
||||
}
|
||||
|
||||
export function PlaygroundMessageContent({
|
||||
actions,
|
||||
errorActions,
|
||||
message,
|
||||
versionContent,
|
||||
}: PlaygroundMessageContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
displayContent,
|
||||
hasReasoning,
|
||||
hasSources,
|
||||
reasoningContent,
|
||||
showLoader,
|
||||
showMessageContent,
|
||||
sources,
|
||||
} = getMessageContentState(message, versionContent)
|
||||
const isError = isErrorMessage(message)
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasSources && (
|
||||
<Sources>
|
||||
<SourcesTrigger count={sources.length} />
|
||||
<SourcesContent>
|
||||
{sources.map((source) => (
|
||||
<Source
|
||||
href={source.href}
|
||||
key={`${source.href}-${source.title}`}
|
||||
title={source.title}
|
||||
/>
|
||||
))}
|
||||
</SourcesContent>
|
||||
</Sources>
|
||||
)}
|
||||
|
||||
{hasReasoning && (
|
||||
<Reasoning
|
||||
defaultOpen={true}
|
||||
isStreaming={message.isReasoningStreaming}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
||||
</Reasoning>
|
||||
)}
|
||||
|
||||
{showLoader && (
|
||||
<div className='flex items-center gap-2 py-2'>
|
||||
<Loader />
|
||||
<Shimmer className='text-sm' duration={1}>
|
||||
{t('Responding...')}
|
||||
</Shimmer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<>
|
||||
<MessageError
|
||||
actions={errorActions}
|
||||
message={message}
|
||||
className='mb-2'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isError && showMessageContent && (
|
||||
<>
|
||||
<MessageContent
|
||||
variant='flat'
|
||||
className={cn(getMessageContentStyles())}
|
||||
>
|
||||
<Response>{displayContent}</Response>
|
||||
</MessageContent>
|
||||
{actions}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
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 { useEffect, useRef, type KeyboardEvent } from 'react'
|
||||
import { Check, RotateCcw, Send, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getMessageEditorState } from '../lib'
|
||||
import type { Message } from '../types'
|
||||
|
||||
type PlaygroundMessageEditorProps = {
|
||||
editText: string
|
||||
message: Message
|
||||
onCancelEdit?: (open: boolean) => void
|
||||
onEditTextChange: (text: string) => void
|
||||
onSaveEdit?: (newContent: string) => void
|
||||
onSaveEditAndSubmit?: (newContent: string) => void
|
||||
originalText: string
|
||||
}
|
||||
|
||||
export function PlaygroundMessageEditor({
|
||||
editText,
|
||||
message,
|
||||
onCancelEdit,
|
||||
onEditTextChange,
|
||||
onSaveEdit,
|
||||
onSaveEditAndSubmit,
|
||||
originalText,
|
||||
}: PlaygroundMessageEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { canSave, hasChanged, showSaveAndSubmit } = getMessageEditorState(
|
||||
message,
|
||||
editText,
|
||||
originalText
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleCancel = () => {
|
||||
if (
|
||||
hasChanged &&
|
||||
!window.confirm(
|
||||
t('You have unsaved changes. Are you sure you want to leave?')
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
onCancelEdit?.(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (!canSave) return
|
||||
|
||||
if (showSaveAndSubmit) {
|
||||
onSaveEditAndSubmit?.(editText)
|
||||
} else {
|
||||
onSaveEdit?.(editText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-lg border bg-background/80 p-2 shadow-sm'>
|
||||
<Textarea
|
||||
aria-label={t('Edit')}
|
||||
className='min-h-36 resize-y font-mono text-sm leading-6 md:min-h-48'
|
||||
onChange={(event) => onEditTextChange(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={textareaRef}
|
||||
rows={8}
|
||||
value={editText}
|
||||
/>
|
||||
|
||||
<div className='mt-2 flex flex-col gap-2 md:flex-row md:items-center md:justify-between'>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{hasChanged ? t('Unsaved changes') : t('No changes')}
|
||||
</p>
|
||||
|
||||
<div className='grid gap-2 sm:flex sm:justify-end'>
|
||||
{showSaveAndSubmit && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={!canSave}
|
||||
onClick={() => onSaveEditAndSubmit?.(editText)}
|
||||
size='sm'
|
||||
>
|
||||
<Send className='size-3.5' />
|
||||
{t('Save & Submit')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={!canSave}
|
||||
onClick={() => onSaveEdit?.(editText)}
|
||||
size='sm'
|
||||
variant={showSaveAndSubmit ? 'outline' : 'default'}
|
||||
>
|
||||
<Check className='size-3.5' />
|
||||
{t('Save')}
|
||||
</Button>
|
||||
|
||||
{hasChanged && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
onClick={() => onEditTextChange(originalText)}
|
||||
size='sm'
|
||||
variant='outline'
|
||||
>
|
||||
<RotateCcw className='size-3.5' />
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
onClick={handleCancel}
|
||||
size='sm'
|
||||
variant='outline'
|
||||
>
|
||||
<X className='size-3.5' />
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
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 {
|
||||
BarChartIcon,
|
||||
BoxIcon,
|
||||
CodeSquareIcon,
|
||||
GraduationCapIcon,
|
||||
NotepadTextIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion'
|
||||
import { getSuggestionDisplayState } from '../lib'
|
||||
|
||||
type PlaygroundSuggestion = {
|
||||
icon: LucideIcon | null
|
||||
text: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
type PlaygroundSuggestionsProps = {
|
||||
onSelect: (suggestion: string) => void
|
||||
}
|
||||
|
||||
const suggestions = [
|
||||
{ icon: BarChartIcon, text: 'Analyze data', color: '#76d0eb' },
|
||||
{ icon: BoxIcon, text: 'Surprise me', color: '#76d0eb' },
|
||||
{ icon: NotepadTextIcon, text: 'Summarize text', color: '#ea8444' },
|
||||
{ icon: CodeSquareIcon, text: 'Code', color: '#6c71ff' },
|
||||
{ icon: GraduationCapIcon, text: 'Get advice', color: '#76d0eb' },
|
||||
{ icon: null, text: 'More' },
|
||||
] satisfies PlaygroundSuggestion[]
|
||||
|
||||
export function PlaygroundSuggestions({
|
||||
onSelect,
|
||||
}: PlaygroundSuggestionsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Suggestions>
|
||||
{suggestions.map(({ icon: Icon, text, color }) => {
|
||||
const suggestion = t(text)
|
||||
const { className } = getSuggestionDisplayState(text)
|
||||
|
||||
return (
|
||||
<Suggestion
|
||||
className={className}
|
||||
key={text}
|
||||
onClick={onSelect}
|
||||
suggestion={suggestion}
|
||||
>
|
||||
{Icon && <Icon aria-hidden='true' size={16} style={{ color }} />}
|
||||
{suggestion}
|
||||
</Suggestion>
|
||||
)
|
||||
})}
|
||||
</Suggestions>
|
||||
)
|
||||
}
|
||||
@@ -20,3 +20,5 @@ export * from './use-playground-state'
|
||||
export * from './use-stream-request'
|
||||
export * from './use-chat-handler'
|
||||
export * from './use-message-action-guard'
|
||||
export * from './use-playground-conversation'
|
||||
export * from './use-playground-options'
|
||||
|
||||
+58
-62
@@ -16,16 +16,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { sendChatCompletion } from '../api'
|
||||
import { MESSAGE_STATUS, ERROR_MESSAGES } from '../constants'
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
import {
|
||||
applyStreamingChunk,
|
||||
buildChatCompletionPayload,
|
||||
updateAssistantMessageWithError,
|
||||
updateLastAssistantMessage,
|
||||
processStreamingContent,
|
||||
finalizeMessage,
|
||||
parseRequestErrorDetails,
|
||||
applyChatCompletionResponse,
|
||||
completeAssistantMessage,
|
||||
hasChatCompletionChoice,
|
||||
isAssistantMessageFinal,
|
||||
isAssistantMessagePending,
|
||||
} from '../lib'
|
||||
import type { Message, PlaygroundConfig, ParameterEnabled } from '../types'
|
||||
import { useStreamRequest } from './use-stream-request'
|
||||
@@ -45,33 +50,17 @@ export function useChatHandler({
|
||||
onMessageUpdate,
|
||||
}: UseChatHandlerOptions) {
|
||||
const { sendStreamRequest, stopStream, isStreaming } = useStreamRequest()
|
||||
const [isRequesting, setIsRequesting] = useState(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// Handle stream update
|
||||
const handleStreamUpdate = useCallback(
|
||||
(type: 'reasoning' | 'content', chunk: string) => {
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) => {
|
||||
if (message.status === MESSAGE_STATUS.ERROR) return message
|
||||
|
||||
if (type === 'reasoning') {
|
||||
// Direct API reasoning_content
|
||||
return {
|
||||
...message,
|
||||
reasoning: {
|
||||
content: (message.reasoning?.content || '') + chunk,
|
||||
duration: 0,
|
||||
},
|
||||
isReasoningStreaming: true,
|
||||
status: MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
}
|
||||
|
||||
// Content streaming: handle <think> tags
|
||||
return {
|
||||
...processStreamingContent(message, chunk),
|
||||
status: MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
})
|
||||
updateLastAssistantMessage(prev, (message) =>
|
||||
applyStreamingChunk(message, type, chunk)
|
||||
)
|
||||
)
|
||||
},
|
||||
[onMessageUpdate]
|
||||
@@ -79,12 +68,12 @@ export function useChatHandler({
|
||||
|
||||
// Handle stream complete
|
||||
const handleStreamComplete = useCallback(() => {
|
||||
setIsRequesting(false)
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) =>
|
||||
message.status === MESSAGE_STATUS.COMPLETE ||
|
||||
message.status === MESSAGE_STATUS.ERROR
|
||||
isAssistantMessageFinal(message)
|
||||
? message
|
||||
: { ...finalizeMessage(message), status: MESSAGE_STATUS.COMPLETE }
|
||||
: completeAssistantMessage(message)
|
||||
)
|
||||
)
|
||||
}, [onMessageUpdate])
|
||||
@@ -92,6 +81,7 @@ export function useChatHandler({
|
||||
// Handle stream error
|
||||
const handleStreamError = useCallback(
|
||||
(error: string, errorCode?: string) => {
|
||||
setIsRequesting(false)
|
||||
toast.error(error)
|
||||
onMessageUpdate((prev) =>
|
||||
updateAssistantMessageWithError(prev, error, errorCode)
|
||||
@@ -103,6 +93,7 @@ export function useChatHandler({
|
||||
// Send streaming chat request
|
||||
const sendStreamingChat = useCallback(
|
||||
(messages: Message[]) => {
|
||||
setIsRequesting(true)
|
||||
const payload = buildChatCompletionPayload(
|
||||
messages,
|
||||
config,
|
||||
@@ -133,42 +124,45 @@ export function useChatHandler({
|
||||
config,
|
||||
parameterEnabled
|
||||
)
|
||||
const requestId = requestIdRef.current + 1
|
||||
const abortController = new AbortController()
|
||||
|
||||
requestIdRef.current = requestId
|
||||
abortControllerRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await sendChatCompletion(payload)
|
||||
const choice = response.choices?.[0]
|
||||
if (!choice) return
|
||||
setIsRequesting(true)
|
||||
const response = await sendChatCompletion(
|
||||
payload,
|
||||
abortController.signal
|
||||
)
|
||||
if (abortController.signal.aborted) return
|
||||
|
||||
if (!hasChatCompletionChoice(response)) {
|
||||
handleStreamError(ERROR_MESSAGES.API_REQUEST_ERROR)
|
||||
return
|
||||
}
|
||||
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) => ({
|
||||
...finalizeMessage(
|
||||
{
|
||||
...message,
|
||||
versions: [
|
||||
{
|
||||
...message.versions[0],
|
||||
content: choice.message?.content || '',
|
||||
},
|
||||
],
|
||||
},
|
||||
choice.message?.reasoning_content
|
||||
),
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
}))
|
||||
updateLastAssistantMessage(prev, (message) => {
|
||||
const updatedMessage = applyChatCompletionResponse(
|
||||
message,
|
||||
response
|
||||
)
|
||||
|
||||
return updatedMessage ?? message
|
||||
})
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: {
|
||||
data?: { message?: string; error?: { code?: string } }
|
||||
}
|
||||
message?: string
|
||||
if (abortController.signal.aborted) return
|
||||
|
||||
const { errorCode, errorMessage } = parseRequestErrorDetails(error)
|
||||
handleStreamError(errorMessage, errorCode)
|
||||
} finally {
|
||||
if (requestIdRef.current === requestId) {
|
||||
abortControllerRef.current = null
|
||||
setIsRequesting(false)
|
||||
}
|
||||
handleStreamError(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
ERROR_MESSAGES.API_REQUEST_ERROR,
|
||||
err?.response?.data?.error?.code || undefined
|
||||
)
|
||||
}
|
||||
},
|
||||
[config, parameterEnabled, onMessageUpdate, handleStreamError]
|
||||
@@ -189,11 +183,13 @@ export function useChatHandler({
|
||||
// Stop generation
|
||||
const stopGeneration = useCallback(() => {
|
||||
stopStream()
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
setIsRequesting(false)
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) =>
|
||||
message.status === MESSAGE_STATUS.LOADING ||
|
||||
message.status === MESSAGE_STATUS.STREAMING
|
||||
? { ...finalizeMessage(message), status: MESSAGE_STATUS.COMPLETE }
|
||||
isAssistantMessagePending(message)
|
||||
? completeAssistantMessage(message)
|
||||
: message
|
||||
)
|
||||
)
|
||||
@@ -202,6 +198,6 @@ export function useChatHandler({
|
||||
return {
|
||||
sendChat,
|
||||
stopGeneration,
|
||||
isGenerating: isStreaming,
|
||||
isGenerating: isStreaming || isRequesting,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
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 { useCallback, useState } from 'react'
|
||||
import {
|
||||
appendUserMessagePair,
|
||||
applyMessageEdit,
|
||||
createRegeneratedMessages,
|
||||
removeMessageByKey,
|
||||
} from '../lib'
|
||||
import type { Message } from '../types'
|
||||
|
||||
type UsePlaygroundConversationOptions = {
|
||||
messages: Message[]
|
||||
updateMessages: (
|
||||
updater: Message[] | ((prev: Message[]) => Message[])
|
||||
) => void
|
||||
sendChat: (messages: Message[]) => void
|
||||
}
|
||||
|
||||
export function usePlaygroundConversation({
|
||||
messages,
|
||||
updateMessages,
|
||||
sendChat,
|
||||
}: UsePlaygroundConversationOptions) {
|
||||
const [editingMessageKey, setEditingMessageKey] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(text: string) => {
|
||||
const nextMessages = appendUserMessagePair(messages, text)
|
||||
updateMessages(nextMessages)
|
||||
sendChat(nextMessages)
|
||||
},
|
||||
[messages, updateMessages, sendChat]
|
||||
)
|
||||
|
||||
const handleRegenerateMessage = useCallback(
|
||||
(message: Message) => {
|
||||
const nextMessages = createRegeneratedMessages(messages, message.key)
|
||||
if (!nextMessages) return
|
||||
|
||||
updateMessages(nextMessages)
|
||||
sendChat(nextMessages)
|
||||
},
|
||||
[messages, updateMessages, sendChat]
|
||||
)
|
||||
|
||||
const handleEditMessage = useCallback((message: Message) => {
|
||||
setEditingMessageKey(message.key)
|
||||
}, [])
|
||||
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setEditingMessageKey(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyEdit = useCallback(
|
||||
(newContent: string, shouldSubmit: boolean) => {
|
||||
if (!editingMessageKey) return
|
||||
|
||||
const editResult = applyMessageEdit(
|
||||
messages,
|
||||
editingMessageKey,
|
||||
newContent,
|
||||
shouldSubmit
|
||||
)
|
||||
if (!editResult) return
|
||||
|
||||
setEditingMessageKey(null)
|
||||
updateMessages(editResult.messages)
|
||||
|
||||
if (editResult.shouldSend) {
|
||||
sendChat(editResult.messages)
|
||||
}
|
||||
},
|
||||
[editingMessageKey, messages, updateMessages, sendChat]
|
||||
)
|
||||
|
||||
const handleDeleteMessage = useCallback(
|
||||
(message: Message) => {
|
||||
updateMessages((previousMessages) =>
|
||||
removeMessageByKey(previousMessages, message.key)
|
||||
)
|
||||
},
|
||||
[updateMessages]
|
||||
)
|
||||
|
||||
return {
|
||||
editingMessageKey,
|
||||
handleSendMessage,
|
||||
handleRegenerateMessage,
|
||||
handleEditMessage,
|
||||
handleEditOpenChange,
|
||||
applyEdit,
|
||||
handleDeleteMessage,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
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 { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { getUserGroups, getUserModels } from '../api'
|
||||
import {
|
||||
getGroupFallback,
|
||||
getModelFallback,
|
||||
getOptionLoadErrorMessage,
|
||||
} from '../lib'
|
||||
import type { GroupOption, ModelOption, PlaygroundConfig } from '../types'
|
||||
|
||||
type UsePlaygroundOptionsParams = {
|
||||
currentGroup: string
|
||||
currentModel: string
|
||||
setGroups: (groups: GroupOption[]) => void
|
||||
setModels: (models: ModelOption[]) => void
|
||||
updateConfig: <K extends keyof PlaygroundConfig>(
|
||||
key: K,
|
||||
value: PlaygroundConfig[K]
|
||||
) => void
|
||||
}
|
||||
|
||||
export function usePlaygroundOptions({
|
||||
currentGroup,
|
||||
currentModel,
|
||||
setGroups,
|
||||
setModels,
|
||||
updateConfig,
|
||||
}: UsePlaygroundOptionsParams) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
data: modelsData,
|
||||
error: modelsError,
|
||||
isError: isModelsError,
|
||||
isLoading: isLoadingModels,
|
||||
} = useQuery({
|
||||
queryKey: ['playground-models'],
|
||||
queryFn: getUserModels,
|
||||
})
|
||||
|
||||
const {
|
||||
data: groupsData,
|
||||
error: groupsError,
|
||||
isError: isGroupsError,
|
||||
} = useQuery({
|
||||
queryKey: ['playground-groups'],
|
||||
queryFn: getUserGroups,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModelsError) return
|
||||
|
||||
toast.error(
|
||||
getOptionLoadErrorMessage(
|
||||
modelsError,
|
||||
t('Failed to load playground models')
|
||||
)
|
||||
)
|
||||
}, [isModelsError, modelsError, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGroupsError) return
|
||||
|
||||
toast.error(
|
||||
getOptionLoadErrorMessage(
|
||||
groupsError,
|
||||
t('Failed to load playground groups')
|
||||
)
|
||||
)
|
||||
}, [isGroupsError, groupsError, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelsData) return
|
||||
|
||||
setModels(modelsData)
|
||||
const fallback = getModelFallback(modelsData, currentModel)
|
||||
|
||||
if (fallback) {
|
||||
updateConfig('model', fallback)
|
||||
}
|
||||
}, [modelsData, currentModel, setModels, updateConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupsData) return
|
||||
|
||||
setGroups(groupsData)
|
||||
const fallback = getGroupFallback(groupsData, currentGroup)
|
||||
|
||||
if (fallback) {
|
||||
updateConfig('group', fallback)
|
||||
}
|
||||
}, [groupsData, currentGroup, setGroups, updateConfig])
|
||||
|
||||
return {
|
||||
isLoadingModels,
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { useState, useCallback } from 'react'
|
||||
import { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../constants'
|
||||
import {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
loadParameterEnabled,
|
||||
saveParameterEnabled,
|
||||
loadMessages,
|
||||
saveMessages,
|
||||
applyMessageStateUpdate,
|
||||
getInitialMessages,
|
||||
getInitialParameterEnabled,
|
||||
getInitialPlaygroundConfig,
|
||||
type MessageStateUpdater,
|
||||
} from '../lib'
|
||||
import type {
|
||||
Message,
|
||||
@@ -39,21 +41,15 @@ import type {
|
||||
*/
|
||||
export function usePlaygroundState() {
|
||||
// Load initial state from localStorage
|
||||
const [config, setConfig] = useState<PlaygroundConfig>(() => {
|
||||
const savedConfig = loadConfig()
|
||||
return { ...DEFAULT_CONFIG, ...savedConfig }
|
||||
})
|
||||
|
||||
const [parameterEnabled, setParameterEnabled] = useState<ParameterEnabled>(
|
||||
() => {
|
||||
const saved = loadParameterEnabled()
|
||||
return { ...DEFAULT_PARAMETER_ENABLED, ...saved }
|
||||
}
|
||||
const [config, setConfig] = useState<PlaygroundConfig>(
|
||||
getInitialPlaygroundConfig
|
||||
)
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>(() => {
|
||||
return loadMessages() || []
|
||||
})
|
||||
const [parameterEnabled, setParameterEnabled] = useState<ParameterEnabled>(
|
||||
getInitialParameterEnabled
|
||||
)
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>(getInitialMessages)
|
||||
|
||||
const [models, setModels] = useState<ModelOption[]>([])
|
||||
const [groups, setGroups] = useState<GroupOption[]>([])
|
||||
@@ -84,10 +80,9 @@ export function usePlaygroundState() {
|
||||
|
||||
// Update messages with automatic save
|
||||
const updateMessages = useCallback(
|
||||
(updater: Message[] | ((prev: Message[]) => Message[])) => {
|
||||
(updater: MessageStateUpdater) => {
|
||||
setMessages((prev) => {
|
||||
const newMessages =
|
||||
typeof updater === 'function' ? updater(prev) : updater
|
||||
const newMessages = applyMessageStateUpdate(prev, updater)
|
||||
saveMessages(newMessages)
|
||||
return newMessages
|
||||
})
|
||||
|
||||
+39
-54
@@ -16,11 +16,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { SSE } from 'sse.js'
|
||||
import { getCommonHeaders } from '@/lib/api'
|
||||
import { API_ENDPOINTS, ERROR_MESSAGES } from '../constants'
|
||||
import type { ChatCompletionRequest, ChatCompletionChunk } from '../types'
|
||||
import {
|
||||
getStreamReadyStateError,
|
||||
isStreamClosedReadyState,
|
||||
isStreamDoneMessage,
|
||||
parseStreamErrorDetails,
|
||||
parseStreamMessageUpdates,
|
||||
} from '../lib'
|
||||
import type { ChatCompletionRequest } from '../types'
|
||||
|
||||
/**
|
||||
* Hook for handling streaming chat completion requests
|
||||
@@ -28,6 +35,17 @@ import type { ChatCompletionRequest, ChatCompletionChunk } from '../types'
|
||||
export function useStreamRequest() {
|
||||
const sseSourceRef = useRef<SSE | null>(null)
|
||||
const isStreamCompleteRef = useRef(false)
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
|
||||
const closeActiveStream = useCallback((source?: SSE) => {
|
||||
const streamSource = source ?? sseSourceRef.current
|
||||
streamSource?.close()
|
||||
|
||||
if (!source || sseSourceRef.current === source) {
|
||||
sseSourceRef.current = null
|
||||
setIsStreaming(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sendStreamRequest = useCallback(
|
||||
(
|
||||
@@ -36,6 +54,8 @@ export function useStreamRequest() {
|
||||
onComplete: () => void,
|
||||
onError: (error: string, errorCode?: string) => void
|
||||
) => {
|
||||
sseSourceRef.current?.close()
|
||||
|
||||
const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
|
||||
headers: getCommonHeaders(),
|
||||
method: 'POST',
|
||||
@@ -44,38 +64,28 @@ export function useStreamRequest() {
|
||||
|
||||
sseSourceRef.current = source
|
||||
isStreamCompleteRef.current = false
|
||||
|
||||
const closeSource = () => {
|
||||
source.close()
|
||||
sseSourceRef.current = null
|
||||
}
|
||||
setIsStreaming(true)
|
||||
|
||||
const handleError = (errorMessage: string, errorCode?: string) => {
|
||||
if (!isStreamCompleteRef.current) {
|
||||
onError(errorMessage, errorCode)
|
||||
closeSource()
|
||||
closeActiveStream(source)
|
||||
}
|
||||
}
|
||||
|
||||
source.addEventListener('message', (e: MessageEvent) => {
|
||||
if (e.data === '[DONE]') {
|
||||
if (isStreamDoneMessage(e.data)) {
|
||||
isStreamCompleteRef.current = true
|
||||
closeSource()
|
||||
closeActiveStream(source)
|
||||
onComplete()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const chunk: ChatCompletionChunk = JSON.parse(e.data)
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
const updates = parseStreamMessageUpdates(e.data)
|
||||
|
||||
if (delta) {
|
||||
if (delta.reasoning_content) {
|
||||
onUpdate('reasoning', delta.reasoning_content)
|
||||
}
|
||||
if (delta.content) {
|
||||
onUpdate('content', delta.content)
|
||||
}
|
||||
for (const update of updates) {
|
||||
onUpdate(update.type, update.chunk)
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -86,24 +96,10 @@ export function useStreamRequest() {
|
||||
|
||||
source.addEventListener('error', (e: Event & { data?: string }) => {
|
||||
// Only handle errors if stream didn't complete normally
|
||||
if (source.readyState !== 2) {
|
||||
if (!isStreamClosedReadyState(source.readyState)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('SSE Error:', e)
|
||||
let errorMessage = e.data || ERROR_MESSAGES.API_REQUEST_ERROR
|
||||
let errorCode: string | undefined
|
||||
if (e.data) {
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as {
|
||||
error?: { message?: string; code?: string }
|
||||
}
|
||||
if (parsed?.error) {
|
||||
errorMessage = parsed.error.message || errorMessage
|
||||
errorCode = parsed.error.code || undefined
|
||||
}
|
||||
} catch {
|
||||
// not JSON, use raw string
|
||||
}
|
||||
}
|
||||
const { errorCode, errorMessage } = parseStreamErrorDetails(e.data)
|
||||
handleError(errorMessage, errorCode)
|
||||
}
|
||||
})
|
||||
@@ -111,14 +107,10 @@ export function useStreamRequest() {
|
||||
source.addEventListener(
|
||||
'readystatechange',
|
||||
(e: Event & { readyState?: number }) => {
|
||||
const status = (source as unknown as { status?: number }).status
|
||||
if (
|
||||
e.readyState !== undefined &&
|
||||
e.readyState >= 2 &&
|
||||
status !== undefined &&
|
||||
status !== 200
|
||||
) {
|
||||
handleError(`HTTP ${status}: ${ERROR_MESSAGES.CONNECTION_CLOSED}`)
|
||||
const errorMessage = getStreamReadyStateError(e.readyState, source)
|
||||
|
||||
if (errorMessage) {
|
||||
handleError(errorMessage)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -129,26 +121,19 @@ export function useStreamRequest() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to start SSE stream:', error)
|
||||
onError(ERROR_MESSAGES.STREAM_START_ERROR)
|
||||
sseSourceRef.current = null
|
||||
closeActiveStream(source)
|
||||
}
|
||||
},
|
||||
[]
|
||||
[closeActiveStream]
|
||||
)
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (sseSourceRef.current) {
|
||||
sseSourceRef.current.close()
|
||||
sseSourceRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const isStreaming = sseSourceRef.current !== null
|
||||
closeActiveStream()
|
||||
}, [closeActiveStream])
|
||||
|
||||
return {
|
||||
sendStreamRequest,
|
||||
stopStream,
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
isStreaming,
|
||||
}
|
||||
}
|
||||
|
||||
+35
-149
@@ -16,19 +16,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { getUserModels, getUserGroups } from './api'
|
||||
import { PlaygroundChat } from './components/playground-chat'
|
||||
import { PlaygroundInput } from './components/playground-input'
|
||||
import { usePlaygroundState, useChatHandler } from './hooks'
|
||||
import { createUserMessage, createLoadingAssistantMessage } from './lib'
|
||||
import type { Message as MessageType } from './types'
|
||||
import {
|
||||
useChatHandler,
|
||||
usePlaygroundConversation,
|
||||
usePlaygroundOptions,
|
||||
usePlaygroundState,
|
||||
} from './hooks'
|
||||
|
||||
export function Playground() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
config,
|
||||
parameterEnabled,
|
||||
@@ -39,6 +36,7 @@ export function Playground() {
|
||||
setModels,
|
||||
setGroups,
|
||||
updateConfig,
|
||||
clearMessages,
|
||||
} = usePlaygroundState()
|
||||
|
||||
const { sendChat, stopGeneration, isGenerating } = useChatHandler({
|
||||
@@ -47,157 +45,43 @@ export function Playground() {
|
||||
onMessageUpdate: updateMessages,
|
||||
})
|
||||
|
||||
// Edit dialog state
|
||||
const [editingMessageKey, setEditingMessageKey] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
|
||||
// Load models
|
||||
const { data: modelsData, isLoading: isLoadingModels } = useQuery({
|
||||
queryKey: ['playground-models'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await getUserModels()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('Failed to load playground models')
|
||||
)
|
||||
return []
|
||||
}
|
||||
},
|
||||
const {
|
||||
editingMessageKey,
|
||||
handleSendMessage,
|
||||
handleRegenerateMessage,
|
||||
handleEditMessage,
|
||||
handleEditOpenChange,
|
||||
applyEdit,
|
||||
handleDeleteMessage,
|
||||
} = usePlaygroundConversation({
|
||||
messages,
|
||||
updateMessages,
|
||||
sendChat,
|
||||
})
|
||||
|
||||
// Load groups
|
||||
const { data: groupsData } = useQuery({
|
||||
queryKey: ['playground-groups'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await getUserGroups()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('Failed to load playground groups')
|
||||
)
|
||||
return []
|
||||
}
|
||||
},
|
||||
const handleClearMessages = () => {
|
||||
handleEditOpenChange(false)
|
||||
clearMessages()
|
||||
}
|
||||
|
||||
const { isLoadingModels } = usePlaygroundOptions({
|
||||
currentGroup: config.group,
|
||||
currentModel: config.model,
|
||||
setGroups,
|
||||
setModels,
|
||||
updateConfig,
|
||||
})
|
||||
|
||||
// Update models when data changes
|
||||
useEffect(() => {
|
||||
if (!modelsData) return
|
||||
|
||||
setModels(modelsData)
|
||||
|
||||
// Set default model if current model is not available
|
||||
const isCurrentModelValid = modelsData.some((m) => m.value === config.model)
|
||||
if (modelsData.length > 0 && !isCurrentModelValid) {
|
||||
updateConfig('model', modelsData[0].value)
|
||||
}
|
||||
}, [modelsData, config.model, setModels, updateConfig])
|
||||
|
||||
// Update groups when data changes
|
||||
useEffect(() => {
|
||||
if (!groupsData) return
|
||||
|
||||
setGroups(groupsData)
|
||||
|
||||
const hasCurrentGroup = groupsData.some((g) => g.value === config.group)
|
||||
if (!hasCurrentGroup && groupsData.length > 0) {
|
||||
const fallback =
|
||||
groupsData.find((g) => g.value === 'default')?.value ??
|
||||
groupsData[0].value
|
||||
updateConfig('group', fallback)
|
||||
}
|
||||
}, [groupsData, setGroups, config.group, updateConfig])
|
||||
|
||||
const handleSendMessage = (text: string) => {
|
||||
const userMessage = createUserMessage(text)
|
||||
const assistantMessage = createLoadingAssistantMessage()
|
||||
|
||||
const newMessages = [...messages, userMessage, assistantMessage]
|
||||
updateMessages(newMessages)
|
||||
|
||||
// Send chat request
|
||||
sendChat(newMessages)
|
||||
}
|
||||
|
||||
const handleCopyMessage = (message: MessageType) => {
|
||||
// Copy is handled in MessageActions component
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Message copied:', message.key)
|
||||
}
|
||||
|
||||
const handleRegenerateMessage = (message: MessageType) => {
|
||||
// Find the message index and regenerate from there
|
||||
const messageIndex = messages.findIndex((m) => m.key === message.key)
|
||||
if (messageIndex === -1) return
|
||||
|
||||
// Remove messages after this one and regenerate
|
||||
const messagesUpToHere = messages.slice(0, messageIndex)
|
||||
const loadingMessage = createLoadingAssistantMessage()
|
||||
const newMessages = [...messagesUpToHere, loadingMessage]
|
||||
|
||||
updateMessages(newMessages)
|
||||
sendChat(newMessages)
|
||||
}
|
||||
|
||||
const handleEditMessage = useCallback((message: MessageType) => {
|
||||
setEditingMessageKey(message.key)
|
||||
}, [])
|
||||
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) setEditingMessageKey(null)
|
||||
}, [])
|
||||
|
||||
// Apply edit and optionally re-submit from the edited user message
|
||||
const applyEdit = useCallback(
|
||||
(newContent: string, submit: boolean) => {
|
||||
if (!editingMessageKey) return
|
||||
const index = messages.findIndex((m) => m.key === editingMessageKey)
|
||||
if (index === -1) return
|
||||
|
||||
const updated = messages.map((m) =>
|
||||
m.key === editingMessageKey
|
||||
? { ...m, versions: [{ ...m.versions[0], content: newContent }] }
|
||||
: m
|
||||
)
|
||||
|
||||
setEditingMessageKey(null)
|
||||
|
||||
if (!submit || updated[index].from !== 'user') {
|
||||
updateMessages(updated)
|
||||
return
|
||||
}
|
||||
|
||||
const toSubmit = [
|
||||
...updated.slice(0, index + 1),
|
||||
createLoadingAssistantMessage(),
|
||||
]
|
||||
updateMessages(toSubmit)
|
||||
sendChat(toSubmit)
|
||||
},
|
||||
[editingMessageKey, messages, updateMessages, sendChat]
|
||||
)
|
||||
|
||||
const handleDeleteMessage = (message: MessageType) => {
|
||||
const newMessages = messages.filter((m) => m.key !== message.key)
|
||||
updateMessages(newMessages)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative flex size-full flex-col overflow-hidden'>
|
||||
<div className='relative flex size-full min-h-0 flex-col overflow-hidden'>
|
||||
{/* Full-width scroll container: scrolling works even over side whitespace */}
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
<div className='flex min-h-0 flex-1 flex-col overflow-hidden'>
|
||||
<PlaygroundChat
|
||||
messages={messages}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
onRegenerateMessage={handleRegenerateMessage}
|
||||
onEditMessage={handleEditMessage}
|
||||
onDeleteMessage={handleDeleteMessage}
|
||||
onSelectPrompt={handleSendMessage}
|
||||
isGenerating={isGenerating}
|
||||
editingKey={editingMessageKey}
|
||||
onCancelEdit={handleEditOpenChange}
|
||||
@@ -217,9 +101,11 @@ export function Playground() {
|
||||
modelValue={config.model}
|
||||
models={models}
|
||||
onGroupChange={(value) => updateConfig('group', value)}
|
||||
onClearMessages={handleClearMessages}
|
||||
onModelChange={(value) => updateConfig('model', value)}
|
||||
onStop={stopGeneration}
|
||||
onSubmit={handleSendMessage}
|
||||
hasMessages={messages.length > 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
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 { Message } from '../types'
|
||||
import { MESSAGE_ROLES } from '../constants'
|
||||
import {
|
||||
createLoadingAssistantMessage,
|
||||
createUserMessage,
|
||||
getMessageContent,
|
||||
updateCurrentVersionContent,
|
||||
} from './message-utils'
|
||||
|
||||
type ApplyMessageEditResult = {
|
||||
messages: Message[]
|
||||
shouldSend: boolean
|
||||
}
|
||||
|
||||
type ChatMessageRenderState = {
|
||||
alwaysShowActions: boolean
|
||||
content: string
|
||||
isEditing: boolean
|
||||
}
|
||||
|
||||
export function appendUserMessagePair(
|
||||
messages: Message[],
|
||||
content: string
|
||||
): Message[] {
|
||||
return [
|
||||
...messages,
|
||||
createUserMessage(content),
|
||||
createLoadingAssistantMessage(),
|
||||
]
|
||||
}
|
||||
|
||||
export function createRegeneratedMessages(
|
||||
messages: Message[],
|
||||
messageKey: string
|
||||
): Message[] | null {
|
||||
const messageIndex = messages.findIndex((message) => message.key === messageKey)
|
||||
|
||||
if (messageIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [...messages.slice(0, messageIndex), createLoadingAssistantMessage()]
|
||||
}
|
||||
|
||||
export function removeMessageByKey(
|
||||
messages: Message[],
|
||||
messageKey: string
|
||||
): Message[] {
|
||||
return messages.filter((message) => message.key !== messageKey)
|
||||
}
|
||||
|
||||
export function getPreviousUserMessage(
|
||||
messages: Message[],
|
||||
beforeIndex: number
|
||||
): Message | null {
|
||||
for (let index = beforeIndex - 1; index >= 0; index--) {
|
||||
if (messages[index].from === MESSAGE_ROLES.USER) {
|
||||
return messages[index]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function applyMessageEdit(
|
||||
messages: Message[],
|
||||
messageKey: string,
|
||||
content: string,
|
||||
shouldSubmit: boolean
|
||||
): ApplyMessageEditResult | null {
|
||||
const messageIndex = messages.findIndex((message) => message.key === messageKey)
|
||||
|
||||
if (messageIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedMessages = messages.map((message) =>
|
||||
message.key === messageKey
|
||||
? updateCurrentVersionContent(message, content)
|
||||
: message
|
||||
)
|
||||
|
||||
if (
|
||||
!shouldSubmit ||
|
||||
updatedMessages[messageIndex].from !== MESSAGE_ROLES.USER
|
||||
) {
|
||||
return { messages: updatedMessages, shouldSend: false }
|
||||
}
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...updatedMessages.slice(0, messageIndex + 1),
|
||||
createLoadingAssistantMessage(),
|
||||
],
|
||||
shouldSend: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function getEditingMessageContent(
|
||||
messages: Message[],
|
||||
editingKey?: string | null
|
||||
): string {
|
||||
if (!editingKey) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const message = messages.find((item) => item.key === editingKey)
|
||||
return message ? getMessageContent(message) : ''
|
||||
}
|
||||
|
||||
export function getChatMessageRenderState(
|
||||
messages: Message[],
|
||||
message: Message,
|
||||
messageIndex: number,
|
||||
editingKey?: string | null
|
||||
): ChatMessageRenderState {
|
||||
return {
|
||||
alwaysShowActions:
|
||||
messageIndex === messages.length - 1 &&
|
||||
message.from === MESSAGE_ROLES.ASSISTANT,
|
||||
content: getMessageContent(message),
|
||||
isEditing: editingKey === message.key,
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export * from './message-utils'
|
||||
export * from './input-control-utils'
|
||||
export * from './input-tool-utils'
|
||||
export * from './message-action-utils'
|
||||
export * from './message-content-utils'
|
||||
export * from './message-editor-utils'
|
||||
export * from './message-error-utils'
|
||||
export * from './message-reasoning-utils'
|
||||
export * from './message-streaming-utils'
|
||||
export * from './message-update-utils'
|
||||
export * from './payload-builder'
|
||||
export * from './storage'
|
||||
export * from './message-styles'
|
||||
export * from './stream-utils'
|
||||
export * from './request-error-utils'
|
||||
export * from './conversation-message-utils'
|
||||
export * from './playground-state-utils'
|
||||
export * from './playground-option-utils'
|
||||
export * from './suggestion-utils'
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
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 { GroupOption, ModelOption } from '../types'
|
||||
|
||||
type InputControlStateOptions = {
|
||||
disabled?: boolean
|
||||
groups: GroupOption[]
|
||||
hasStopHandler: boolean
|
||||
isGenerating?: boolean
|
||||
isModelLoading?: boolean
|
||||
models: ModelOption[]
|
||||
text: string
|
||||
}
|
||||
|
||||
type InputControlState = {
|
||||
canSubmit: boolean
|
||||
isSelectorDisabled: boolean
|
||||
shouldShowStop: boolean
|
||||
}
|
||||
|
||||
type SubmittableInputMessage = {
|
||||
text?: string | null
|
||||
}
|
||||
|
||||
export function getSubmittableInputText(
|
||||
message: SubmittableInputMessage,
|
||||
disabled?: boolean
|
||||
): string | null {
|
||||
if (disabled || !message.text?.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return message.text
|
||||
}
|
||||
|
||||
export function getInputControlState({
|
||||
disabled,
|
||||
groups,
|
||||
hasStopHandler,
|
||||
isGenerating,
|
||||
isModelLoading,
|
||||
models,
|
||||
text,
|
||||
}: InputControlStateOptions): InputControlState {
|
||||
return {
|
||||
canSubmit: !disabled && text.trim().length > 0,
|
||||
isSelectorDisabled:
|
||||
disabled || isModelLoading || models.length === 0 || groups.length === 0,
|
||||
shouldShowStop: Boolean(isGenerating && hasStopHandler),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
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 {
|
||||
CameraIcon,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
ScreenShareIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
type AttachmentAction = {
|
||||
action: string
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
}
|
||||
|
||||
type InputToolNotice = {
|
||||
description?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export const ATTACHMENT_ACTIONS = [
|
||||
{ action: 'upload-file', icon: FileIcon, label: 'Upload file' },
|
||||
{ action: 'upload-photo', icon: ImageIcon, label: 'Upload photo' },
|
||||
{
|
||||
action: 'take-screenshot',
|
||||
icon: ScreenShareIcon,
|
||||
label: 'Take screenshot',
|
||||
},
|
||||
{ action: 'take-photo', icon: CameraIcon, label: 'Take photo' },
|
||||
] satisfies AttachmentAction[]
|
||||
|
||||
export function getAttachmentActionNotice(action: string): InputToolNotice {
|
||||
return {
|
||||
description: action,
|
||||
title: 'Feature in development',
|
||||
}
|
||||
}
|
||||
|
||||
export function getSearchActionNotice(): InputToolNotice {
|
||||
return {
|
||||
title: 'Search feature in development',
|
||||
}
|
||||
}
|
||||
@@ -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 { MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { getMessageContent, hasMessageContent } from './message-utils'
|
||||
|
||||
type MessageActionState = {
|
||||
content: string
|
||||
hasContent: boolean
|
||||
isAssistant: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function getMessageActionState(message: Message): MessageActionState {
|
||||
return {
|
||||
content: getMessageContent(message),
|
||||
hasContent: hasMessageContent(message),
|
||||
isAssistant: message.from === MESSAGE_ROLES.ASSISTANT,
|
||||
isLoading:
|
||||
message.status === MESSAGE_STATUS.LOADING ||
|
||||
message.status === MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessageActionsVisibilityClass(alwaysVisible: boolean): string {
|
||||
return alwaysVisible
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 max-md:opacity-100'
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
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 { MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { parseThinkTags } from './message-reasoning-utils'
|
||||
|
||||
type MessageContentStateBase = {
|
||||
displayContent: string
|
||||
hasSources: boolean
|
||||
isAssistant: boolean
|
||||
showLoader: boolean
|
||||
showMessageContent: boolean
|
||||
sources: NonNullable<Message['sources']>
|
||||
}
|
||||
|
||||
type MessageContentState = MessageContentStateBase &
|
||||
(
|
||||
| {
|
||||
hasReasoning: true
|
||||
reasoningContent: string
|
||||
}
|
||||
| {
|
||||
hasReasoning: false
|
||||
reasoningContent: undefined
|
||||
}
|
||||
)
|
||||
|
||||
function shouldShowMessageLoader(
|
||||
message: Message,
|
||||
isAssistant: boolean,
|
||||
versionContent: string
|
||||
): boolean {
|
||||
return (
|
||||
isAssistant &&
|
||||
!message.isReasoningStreaming &&
|
||||
(message.status === MESSAGE_STATUS.LOADING ||
|
||||
(message.status === MESSAGE_STATUS.STREAMING && !versionContent))
|
||||
)
|
||||
}
|
||||
|
||||
function shouldShowMessageContent(
|
||||
message: Message,
|
||||
versionContent: string
|
||||
): boolean {
|
||||
return (
|
||||
(message.from === MESSAGE_ROLES.USER || !message.isReasoningStreaming) &&
|
||||
versionContent.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function getMessageContentState(
|
||||
message: Message,
|
||||
versionContent: string
|
||||
): MessageContentState {
|
||||
const isAssistant = message.from === MESSAGE_ROLES.ASSISTANT
|
||||
const sources = message.sources ?? []
|
||||
const reasoningContent = isAssistant ? message.reasoning?.content : undefined
|
||||
const showLoader = shouldShowMessageLoader(
|
||||
message,
|
||||
isAssistant,
|
||||
versionContent
|
||||
)
|
||||
const showMessageContent = shouldShowMessageContent(message, versionContent)
|
||||
|
||||
const baseState: MessageContentStateBase = {
|
||||
displayContent: isAssistant
|
||||
? parseThinkTags(versionContent).visibleContent
|
||||
: versionContent,
|
||||
hasSources: sources.length > 0,
|
||||
isAssistant,
|
||||
showLoader,
|
||||
showMessageContent,
|
||||
sources,
|
||||
}
|
||||
|
||||
if (reasoningContent) {
|
||||
return {
|
||||
...baseState,
|
||||
hasReasoning: true,
|
||||
reasoningContent,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
hasReasoning: false,
|
||||
reasoningContent: undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
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 { MESSAGE_ROLES } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
|
||||
type MessageEditorState = {
|
||||
canSave: boolean
|
||||
hasChanged: boolean
|
||||
showSaveAndSubmit: boolean
|
||||
}
|
||||
|
||||
export function getMessageEditorState(
|
||||
message: Message,
|
||||
editText: string,
|
||||
originalText: string
|
||||
): MessageEditorState {
|
||||
const hasText = editText.trim().length > 0
|
||||
const hasChanged = editText !== originalText
|
||||
|
||||
return {
|
||||
canSave: hasText && hasChanged,
|
||||
hasChanged,
|
||||
showSaveAndSubmit: message.from === MESSAGE_ROLES.USER,
|
||||
}
|
||||
}
|
||||
@@ -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 { MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { getMessageContent } from './message-utils'
|
||||
|
||||
export const MODEL_PRICING_SETTINGS_PATH =
|
||||
'/system-settings/billing/model-pricing'
|
||||
|
||||
const MODEL_PRICE_ERROR_CODE = 'model_price_error'
|
||||
const FALLBACK_ERROR_CONTENT = 'An unknown error occurred'
|
||||
|
||||
type MessageErrorState = {
|
||||
content: string
|
||||
kind: 'generic' | 'model-price'
|
||||
showSettingsLink: boolean
|
||||
}
|
||||
|
||||
export function isAdminRole(role?: number | null): boolean {
|
||||
return role != null && role >= 10
|
||||
}
|
||||
|
||||
export function isErrorMessage(message: Message): boolean {
|
||||
return message.status === MESSAGE_STATUS.ERROR
|
||||
}
|
||||
|
||||
export function getMessageErrorState(
|
||||
message: Message,
|
||||
isAdmin: boolean
|
||||
): MessageErrorState | null {
|
||||
if (!isErrorMessage(message)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = getMessageContent(message) || FALLBACK_ERROR_CONTENT
|
||||
const isModelPriceError = message.errorCode === MODEL_PRICE_ERROR_CODE
|
||||
|
||||
return {
|
||||
content,
|
||||
kind: isModelPriceError ? 'model-price' : 'generic',
|
||||
showSettingsLink: isModelPriceError && isAdmin,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
|
||||
interface ParsedThinkTags {
|
||||
visibleContent: string
|
||||
reasoning: string
|
||||
hasUnclosedTag: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content to separate thinking from visible text.
|
||||
* Handles both complete and incomplete <think> tags.
|
||||
*/
|
||||
export function parseThinkTags(content: string): ParsedThinkTags {
|
||||
if (!content.includes('<think>')) {
|
||||
return { visibleContent: content, reasoning: '', hasUnclosedTag: false }
|
||||
}
|
||||
|
||||
const visibleParts: string[] = []
|
||||
const reasoningParts: string[] = []
|
||||
let currentPos = 0
|
||||
let hasUnclosedTag = false
|
||||
|
||||
while (true) {
|
||||
const openPos = content.indexOf('<think>', currentPos)
|
||||
|
||||
if (openPos === -1) {
|
||||
if (currentPos < content.length) {
|
||||
visibleParts.push(content.substring(currentPos))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (openPos > currentPos) {
|
||||
visibleParts.push(content.substring(currentPos, openPos))
|
||||
}
|
||||
|
||||
const closePos = content.indexOf('</think>', openPos + 7)
|
||||
|
||||
if (closePos === -1) {
|
||||
reasoningParts.push(content.substring(openPos + 7))
|
||||
hasUnclosedTag = true
|
||||
break
|
||||
}
|
||||
|
||||
reasoningParts.push(content.substring(openPos + 7, closePos))
|
||||
currentPos = closePos + 8
|
||||
}
|
||||
|
||||
return {
|
||||
visibleContent: visibleParts.join('').trim(),
|
||||
reasoning: reasoningParts.join('\n\n').trim(),
|
||||
hasUnclosedTag,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
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 { ERROR_MESSAGES, MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { ChatCompletionResponse, Message } from '../types'
|
||||
import {
|
||||
getCurrentVersion,
|
||||
hasMessageContent,
|
||||
updateCurrentVersionContent,
|
||||
} from './message-utils'
|
||||
import { parseThinkTags } from './message-reasoning-utils'
|
||||
|
||||
/**
|
||||
* Process content chunk during streaming.
|
||||
* Separates <think> reasoning from visible content in real-time.
|
||||
* Note: versions[0].content keeps the full raw content with tags during streaming.
|
||||
*/
|
||||
export function processStreamingContent(
|
||||
message: Message,
|
||||
contentChunk?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const fullContent = contentChunk
|
||||
? currentVersion.content + contentChunk
|
||||
: currentVersion.content
|
||||
|
||||
const { reasoning, hasUnclosedTag } = parseThinkTags(fullContent)
|
||||
const finalReasoning = reasoning
|
||||
? { content: reasoning, duration: 0 }
|
||||
: message.reasoning
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, fullContent),
|
||||
reasoning: finalReasoning,
|
||||
isReasoningStreaming: hasUnclosedTag,
|
||||
}
|
||||
}
|
||||
|
||||
export type StreamChunkType = 'reasoning' | 'content'
|
||||
|
||||
export function applyStreamingChunk(
|
||||
message: Message,
|
||||
type: StreamChunkType,
|
||||
chunk: string
|
||||
): Message {
|
||||
if (message.status === MESSAGE_STATUS.ERROR) {
|
||||
return message
|
||||
}
|
||||
|
||||
if (type === 'reasoning') {
|
||||
return {
|
||||
...message,
|
||||
reasoning: {
|
||||
content: (message.reasoning?.content || '') + chunk,
|
||||
duration: 0,
|
||||
},
|
||||
isReasoningStreaming: true,
|
||||
status: MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...processStreamingContent(message, chunk),
|
||||
status: MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize message after streaming completes.
|
||||
* Cleans content and consolidates reasoning from all sources.
|
||||
*/
|
||||
export function finalizeMessage(
|
||||
message: Message,
|
||||
apiReasoningContent?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const { visibleContent, reasoning } = parseThinkTags(currentVersion.content)
|
||||
const finalReasoning =
|
||||
apiReasoningContent || message.reasoning?.content || reasoning || ''
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, visibleContent),
|
||||
reasoning: finalReasoning
|
||||
? { content: finalReasoning, duration: message.reasoning?.duration || 0 }
|
||||
: undefined,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function completeAssistantMessage(message: Message): Message {
|
||||
return {
|
||||
...finalizeMessage(message),
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
}
|
||||
}
|
||||
|
||||
export function isAssistantMessageFinal(message: Message): boolean {
|
||||
return (
|
||||
message.status === MESSAGE_STATUS.COMPLETE ||
|
||||
message.status === MESSAGE_STATUS.ERROR
|
||||
)
|
||||
}
|
||||
|
||||
export function isAssistantMessagePending(message: Message): boolean {
|
||||
return (
|
||||
message.status === MESSAGE_STATUS.LOADING ||
|
||||
message.status === MESSAGE_STATUS.STREAMING
|
||||
)
|
||||
}
|
||||
|
||||
export function isPendingAssistantMessage(message?: Message): boolean {
|
||||
return Boolean(
|
||||
message?.from === MESSAGE_ROLES.ASSISTANT &&
|
||||
isAssistantMessagePending(message)
|
||||
)
|
||||
}
|
||||
|
||||
type ChatCompletionChoice = ChatCompletionResponse['choices'][number]
|
||||
|
||||
export function hasChatCompletionChoice(
|
||||
response: ChatCompletionResponse
|
||||
): boolean {
|
||||
return Boolean(response.choices?.[0])
|
||||
}
|
||||
|
||||
export function applyChatCompletionChoice(
|
||||
message: Message,
|
||||
choice: ChatCompletionChoice
|
||||
): Message {
|
||||
return {
|
||||
...finalizeMessage(
|
||||
updateCurrentVersionContent(message, choice.message?.content || ''),
|
||||
choice.message?.reasoning_content
|
||||
),
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyChatCompletionResponse(
|
||||
message: Message,
|
||||
response: ChatCompletionResponse
|
||||
): Message | null {
|
||||
const choice = response.choices?.[0]
|
||||
|
||||
if (!choice) {
|
||||
return null
|
||||
}
|
||||
|
||||
return applyChatCompletionChoice(message, choice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize messages loaded from storage.
|
||||
* Converts stuck loading/streaming messages to stable state.
|
||||
*/
|
||||
export function sanitizeMessagesOnLoad(messages: Message[]): Message[] {
|
||||
let targetIndex = -1
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
|
||||
if (isPendingAssistantMessage(message)) {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex === -1) return messages
|
||||
|
||||
const finalized = finalizeMessage(messages[targetIndex])
|
||||
const hasContent = hasMessageContent(finalized)
|
||||
const hasReasoning = finalized.reasoning?.content?.trim()
|
||||
|
||||
const sanitized: Message =
|
||||
hasContent || hasReasoning
|
||||
? {
|
||||
...finalized,
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
: {
|
||||
...updateCurrentVersionContent(
|
||||
finalized,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${ERROR_MESSAGES.INTERRUPTED}`
|
||||
),
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
|
||||
const result = [...messages]
|
||||
result[targetIndex] = sanitized
|
||||
return result
|
||||
}
|
||||
+24
-10
@@ -22,25 +22,39 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function getMessageContentStyles() {
|
||||
return [
|
||||
// Assistant content fills the row; user bubble auto-width
|
||||
// Assistant content reads like a document column; user bubble stays compact.
|
||||
'group-[.is-assistant]:w-full',
|
||||
'group-[.is-assistant]:max-w-none',
|
||||
'group-[.is-assistant]:max-w-[78ch]',
|
||||
'group-[.is-user]:w-fit',
|
||||
// User bubble: rounded and themed background
|
||||
|
||||
// User bubble: compact surface that stays calm in both light and dark themes.
|
||||
'group-[.is-user]:rounded-2xl',
|
||||
'group-[.is-user]:rounded-br-md',
|
||||
'group-[.is-user]:border',
|
||||
'group-[.is-user]:border-border/70',
|
||||
'group-[.is-user]:bg-muted/70',
|
||||
'group-[.is-user]:px-4',
|
||||
'group-[.is-user]:py-2.5',
|
||||
'group-[.is-user]:text-foreground',
|
||||
'group-[.is-user]:bg-secondary',
|
||||
'dark:group-[.is-user]:bg-muted',
|
||||
'group-[.is-user]:rounded-3xl',
|
||||
// Assistant bubble: flat serif style (one-sided style)
|
||||
'group-[.is-assistant]:text-foreground',
|
||||
'group-[.is-user]:shadow-sm',
|
||||
'group-[.is-user]:shadow-black/5',
|
||||
|
||||
// Assistant response: flat reading surface using the active UI font axis.
|
||||
'group-[.is-assistant]:bg-transparent',
|
||||
'group-[.is-assistant]:p-0',
|
||||
'group-[.is-assistant]:font-serif',
|
||||
'group-[.is-assistant]:rounded-none',
|
||||
'group-[.is-assistant]:overflow-visible',
|
||||
'group-[.is-assistant]:[font-family:var(--font-body)]',
|
||||
'group-[.is-assistant]:text-foreground/90',
|
||||
|
||||
// Preferred readable widths and wrapping
|
||||
'leading-relaxed',
|
||||
'text-[0.95rem]',
|
||||
'leading-6',
|
||||
'break-words',
|
||||
'whitespace-pre-wrap',
|
||||
'sm:text-[0.975rem]',
|
||||
'sm:leading-7',
|
||||
|
||||
// Cap user bubble width so it does not look like a banner
|
||||
'group-[.is-user]:max-w-[85%]',
|
||||
'sm:group-[.is-user]:max-w-[62ch]',
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
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 { ERROR_MESSAGES, MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { updateCurrentVersionContent } from './message-utils'
|
||||
|
||||
/**
|
||||
* Update the last assistant message with an error.
|
||||
*/
|
||||
export function updateAssistantMessageWithError(
|
||||
messages: Message[],
|
||||
errorMessage: string,
|
||||
errorCode?: string
|
||||
): Message[] {
|
||||
return updateLastAssistantMessage(messages, (message) => {
|
||||
const updatedMessage = updateCurrentVersionContent(
|
||||
message,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${errorMessage}`
|
||||
)
|
||||
|
||||
return {
|
||||
...updatedMessage,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
errorCode: errorCode || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the most recent assistant message, preserving the array when absent.
|
||||
*/
|
||||
export function updateLastAssistantMessage(
|
||||
messages: Message[],
|
||||
updater: (message: Message) => Message
|
||||
): Message[] {
|
||||
if (messages.length === 0) return messages
|
||||
|
||||
const last = messages[messages.length - 1]
|
||||
if (!last || last.from !== MESSAGE_ROLES.ASSISTANT) return messages
|
||||
|
||||
const updated = [...messages]
|
||||
updated[updated.length - 1] = updater(last)
|
||||
return updated
|
||||
}
|
||||
+18
-206
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { nanoid } from 'nanoid'
|
||||
import { MESSAGE_ROLES, MESSAGE_STATUS, ERROR_MESSAGES } from '../constants'
|
||||
import { MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type {
|
||||
Message,
|
||||
MessageVersion,
|
||||
@@ -42,6 +42,20 @@ export function getCurrentVersion(message: Message): MessageVersion {
|
||||
return message.versions[0] || { id: 'default', content: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get displayable content from the current message version.
|
||||
*/
|
||||
export function getMessageContent(message: Message): string {
|
||||
return getCurrentVersion(message).content
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a message has non-empty content in its current version.
|
||||
*/
|
||||
export function hasMessageContent(message: Message): boolean {
|
||||
return getMessageContent(message).trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current version content in message
|
||||
*/
|
||||
@@ -144,212 +158,10 @@ export function formatMessageForAPI(message: Message): ChatCompletionMessage {
|
||||
export function isValidMessage(message: Message): boolean {
|
||||
if (!message || !message.from || !message.versions.length) return false
|
||||
|
||||
const content = message.versions[0]?.content
|
||||
if (content === undefined) return false
|
||||
|
||||
// Exclude empty assistant messages (loading/streaming placeholders)
|
||||
if (message.from === 'assistant' && !content.trim()) return false
|
||||
if (message.from === MESSAGE_ROLES.ASSISTANT && !hasMessageContent(message)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content to separate thinking from visible text
|
||||
* Handles both complete and incomplete <think> tags
|
||||
*/
|
||||
export function parseThinkTags(content: string): {
|
||||
visibleContent: string
|
||||
reasoning: string
|
||||
hasUnclosedTag: boolean
|
||||
} {
|
||||
if (!content.includes('<think>')) {
|
||||
return { visibleContent: content, reasoning: '', hasUnclosedTag: false }
|
||||
}
|
||||
|
||||
const visibleParts: string[] = []
|
||||
const reasoningParts: string[] = []
|
||||
let currentPos = 0
|
||||
let hasUnclosed = false
|
||||
|
||||
while (true) {
|
||||
// Find next <think> tag
|
||||
const openPos = content.indexOf('<think>', currentPos)
|
||||
|
||||
if (openPos === -1) {
|
||||
// No more think tags, add remaining content
|
||||
if (currentPos < content.length) {
|
||||
visibleParts.push(content.substring(currentPos))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Add visible content before this tag
|
||||
if (openPos > currentPos) {
|
||||
visibleParts.push(content.substring(currentPos, openPos))
|
||||
}
|
||||
|
||||
// Look for matching </think> tag
|
||||
const closePos = content.indexOf('</think>', openPos + 7)
|
||||
|
||||
if (closePos === -1) {
|
||||
// Unclosed tag: rest is reasoning buffer
|
||||
reasoningParts.push(content.substring(openPos + 7))
|
||||
hasUnclosed = true
|
||||
break
|
||||
}
|
||||
|
||||
// Extract reasoning content between tags
|
||||
reasoningParts.push(content.substring(openPos + 7, closePos))
|
||||
currentPos = closePos + 8
|
||||
}
|
||||
|
||||
return {
|
||||
visibleContent: visibleParts.join('').trim(),
|
||||
reasoning: reasoningParts.join('\n\n').trim(),
|
||||
hasUnclosedTag: hasUnclosed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last assistant message with an error
|
||||
* @param messages - Current messages array
|
||||
* @param errorMessage - Error message to display
|
||||
* @returns Updated messages array
|
||||
*/
|
||||
export function updateAssistantMessageWithError(
|
||||
messages: Message[],
|
||||
errorMessage: string,
|
||||
errorCode?: string
|
||||
): Message[] {
|
||||
return updateLastAssistantMessage(messages, (message) => {
|
||||
const updatedMessage = updateCurrentVersionContent(
|
||||
message,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${errorMessage}`
|
||||
)
|
||||
return {
|
||||
...updatedMessage,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
errorCode: errorCode || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to update the last assistant message
|
||||
* @param messages - Current messages array
|
||||
* @param updater - Function to update the message
|
||||
* @returns Updated messages array or original if no assistant message found
|
||||
*/
|
||||
export function updateLastAssistantMessage(
|
||||
messages: Message[],
|
||||
updater: (message: Message) => Message
|
||||
): Message[] {
|
||||
if (messages.length === 0) return messages
|
||||
const last = messages[messages.length - 1]
|
||||
if (!last || last.from !== MESSAGE_ROLES.ASSISTANT) return messages
|
||||
|
||||
const updated = [...messages]
|
||||
updated[updated.length - 1] = updater(last)
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Process content chunk during streaming
|
||||
* Separates <think> reasoning from visible content in real-time
|
||||
* Note: versions[0].content keeps the full raw content (with tags) during streaming
|
||||
*/
|
||||
export function processStreamingContent(
|
||||
message: Message,
|
||||
contentChunk?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const fullContent = contentChunk
|
||||
? currentVersion.content + contentChunk
|
||||
: currentVersion.content
|
||||
|
||||
const { reasoning, hasUnclosedTag } = parseThinkTags(fullContent)
|
||||
|
||||
// Preserve existing reasoning if no think tags found (e.g., from API reasoning_content)
|
||||
const finalReasoning = reasoning
|
||||
? { content: reasoning, duration: 0 }
|
||||
: message.reasoning
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, fullContent),
|
||||
reasoning: finalReasoning,
|
||||
isReasoningStreaming: hasUnclosedTag,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize message after streaming completes
|
||||
* Cleans content and consolidates reasoning from all sources
|
||||
*/
|
||||
export function finalizeMessage(
|
||||
message: Message,
|
||||
apiReasoningContent?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const { visibleContent, reasoning } = parseThinkTags(currentVersion.content)
|
||||
|
||||
// Priority:
|
||||
// 1. API reasoning_content passed as parameter (non-streaming response)
|
||||
// 2. Existing message.reasoning (from streaming reasoning_content)
|
||||
// 3. Extracted think tags from content
|
||||
const finalReasoning =
|
||||
apiReasoningContent || message.reasoning?.content || reasoning || ''
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, visibleContent),
|
||||
reasoning: finalReasoning
|
||||
? { content: finalReasoning, duration: message.reasoning?.duration || 0 }
|
||||
: undefined,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize messages loaded from storage
|
||||
* Converts stuck loading/streaming messages to stable state
|
||||
*/
|
||||
export function sanitizeMessagesOnLoad(messages: Message[]): Message[] {
|
||||
let targetIndex = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]
|
||||
if (
|
||||
m?.from === MESSAGE_ROLES.ASSISTANT &&
|
||||
(m?.status === MESSAGE_STATUS.LOADING ||
|
||||
m?.status === MESSAGE_STATUS.STREAMING)
|
||||
) {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex === -1) return messages
|
||||
|
||||
const finalized = finalizeMessage(messages[targetIndex])
|
||||
const hasContent = finalized.versions?.[0]?.content?.trim()
|
||||
const hasReasoning = finalized.reasoning?.content?.trim()
|
||||
|
||||
const sanitized: Message =
|
||||
hasContent || hasReasoning
|
||||
? {
|
||||
...finalized,
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
: {
|
||||
...updateCurrentVersionContent(
|
||||
finalized,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${ERROR_MESSAGES.INTERRUPTED}`
|
||||
),
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
|
||||
const result = [...messages]
|
||||
result[targetIndex] = sanitized
|
||||
return result
|
||||
}
|
||||
|
||||
+22
-17
@@ -44,24 +44,29 @@ export function buildChatCompletionPayload(
|
||||
stream: config.stream,
|
||||
}
|
||||
|
||||
// Add enabled parameters
|
||||
const parameterKeys: Array<keyof ParameterEnabled> = [
|
||||
'temperature',
|
||||
'top_p',
|
||||
'max_tokens',
|
||||
'frequency_penalty',
|
||||
'presence_penalty',
|
||||
'seed',
|
||||
]
|
||||
if (parameterEnabled.temperature) {
|
||||
payload.temperature = config.temperature
|
||||
}
|
||||
|
||||
parameterKeys.forEach((key) => {
|
||||
if (parameterEnabled[key]) {
|
||||
const value = config[key as keyof PlaygroundConfig]
|
||||
if (value !== undefined && value !== null) {
|
||||
;(payload as unknown as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
if (parameterEnabled.top_p) {
|
||||
payload.top_p = config.top_p
|
||||
}
|
||||
|
||||
if (parameterEnabled.max_tokens) {
|
||||
payload.max_tokens = config.max_tokens
|
||||
}
|
||||
|
||||
if (parameterEnabled.frequency_penalty) {
|
||||
payload.frequency_penalty = config.frequency_penalty
|
||||
}
|
||||
|
||||
if (parameterEnabled.presence_penalty) {
|
||||
payload.presence_penalty = config.presence_penalty
|
||||
}
|
||||
|
||||
if (parameterEnabled.seed && config.seed !== null) {
|
||||
payload.seed = config.seed
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
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 { GroupOption, ModelOption } from '../types'
|
||||
|
||||
export function getModelFallback(
|
||||
models: ModelOption[],
|
||||
currentModel: string
|
||||
): string | null {
|
||||
const hasCurrentModel = models.some((model) => model.value === currentModel)
|
||||
|
||||
if (hasCurrentModel || models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return models[0].value
|
||||
}
|
||||
|
||||
export function getGroupFallback(
|
||||
groups: GroupOption[],
|
||||
currentGroup: string
|
||||
): string | null {
|
||||
const hasCurrentGroup = groups.some((group) => group.value === currentGroup)
|
||||
|
||||
if (hasCurrentGroup || groups.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
groups.find((group) => group.value === 'default')?.value ?? groups[0].value
|
||||
)
|
||||
}
|
||||
|
||||
export function getOptionLoadErrorMessage(
|
||||
error: unknown,
|
||||
fallbackMessage: string
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallbackMessage
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
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 { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../constants'
|
||||
import type { Message, ParameterEnabled, PlaygroundConfig } from '../types'
|
||||
import { loadConfig, loadMessages, loadParameterEnabled } from './storage'
|
||||
|
||||
export type MessageStateUpdater =
|
||||
| Message[]
|
||||
| ((previousMessages: Message[]) => Message[])
|
||||
|
||||
export function getInitialPlaygroundConfig(): PlaygroundConfig {
|
||||
return { ...DEFAULT_CONFIG, ...loadConfig() }
|
||||
}
|
||||
|
||||
export function getInitialParameterEnabled(): ParameterEnabled {
|
||||
return { ...DEFAULT_PARAMETER_ENABLED, ...loadParameterEnabled() }
|
||||
}
|
||||
|
||||
export function getInitialMessages(): Message[] {
|
||||
return loadMessages() || []
|
||||
}
|
||||
|
||||
export function applyMessageStateUpdate(
|
||||
previousMessages: Message[],
|
||||
updater: MessageStateUpdater
|
||||
): Message[] {
|
||||
return typeof updater === 'function' ? updater(previousMessages) : updater
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
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 { ERROR_MESSAGES } from '../constants'
|
||||
|
||||
type RequestErrorLike = {
|
||||
message?: string
|
||||
response?: {
|
||||
data?: {
|
||||
error?: {
|
||||
code?: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestErrorDetails = {
|
||||
errorCode?: string
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export function parseRequestErrorDetails(error: unknown): RequestErrorDetails {
|
||||
const requestError = error as RequestErrorLike
|
||||
|
||||
return {
|
||||
errorCode: requestError?.response?.data?.error?.code || undefined,
|
||||
errorMessage:
|
||||
requestError?.response?.data?.message ||
|
||||
requestError?.message ||
|
||||
ERROR_MESSAGES.API_REQUEST_ERROR,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
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 { z } from 'zod'
|
||||
|
||||
export const STORAGE_VERSION = 1
|
||||
export const MAX_STORED_MESSAGES = 100
|
||||
|
||||
export const playgroundConfigSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
temperature: z.number().finite().optional(),
|
||||
top_p: z.number().finite().optional(),
|
||||
max_tokens: z.number().finite().optional(),
|
||||
frequency_penalty: z.number().finite().optional(),
|
||||
presence_penalty: z.number().finite().optional(),
|
||||
seed: z.number().finite().nullable().optional(),
|
||||
stream: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const parameterEnabledSchema = z.object({
|
||||
temperature: z.boolean().optional(),
|
||||
top_p: z.boolean().optional(),
|
||||
max_tokens: z.boolean().optional(),
|
||||
frequency_penalty: z.boolean().optional(),
|
||||
presence_penalty: z.boolean().optional(),
|
||||
seed: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const messageRoleSchema = z.enum(['user', 'assistant', 'system'])
|
||||
const messageStatusSchema = z.enum([
|
||||
'loading',
|
||||
'streaming',
|
||||
'complete',
|
||||
'error',
|
||||
])
|
||||
|
||||
const messageVersionSchema = z.object({
|
||||
id: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
const sourceSchema = z.object({
|
||||
href: z.string(),
|
||||
title: z.string(),
|
||||
})
|
||||
|
||||
const reasoningSchema = z.object({
|
||||
content: z.string(),
|
||||
duration: z.number().finite(),
|
||||
})
|
||||
|
||||
const messageSchema = z.object({
|
||||
key: z.string(),
|
||||
from: messageRoleSchema,
|
||||
versions: z.array(messageVersionSchema).min(1),
|
||||
sources: z.array(sourceSchema).optional(),
|
||||
reasoning: reasoningSchema.optional(),
|
||||
isReasoningStreaming: z.boolean().optional(),
|
||||
isReasoningComplete: z.boolean().optional(),
|
||||
isContentComplete: z.boolean().optional(),
|
||||
status: messageStatusSchema.optional(),
|
||||
errorCode: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export const messagesSchema = z.array(messageSchema)
|
||||
+75
-27
@@ -18,17 +18,65 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { STORAGE_KEYS } from '../constants'
|
||||
import type { PlaygroundConfig, ParameterEnabled, Message } from '../types'
|
||||
import { sanitizeMessagesOnLoad } from './message-utils'
|
||||
import { sanitizeMessagesOnLoad } from './message-streaming-utils'
|
||||
import {
|
||||
MAX_STORED_MESSAGES,
|
||||
STORAGE_VERSION,
|
||||
messagesSchema,
|
||||
parameterEnabledSchema,
|
||||
playgroundConfigSchema,
|
||||
} from './storage-schema'
|
||||
|
||||
type StoredEnvelope<T> = {
|
||||
version: number
|
||||
data: T
|
||||
}
|
||||
|
||||
function readStoredValue(key: string): unknown | null {
|
||||
const saved = localStorage.getItem(key)
|
||||
if (!saved) return null
|
||||
|
||||
return JSON.parse(saved) as unknown
|
||||
}
|
||||
|
||||
function unwrapStoredValue(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value
|
||||
}
|
||||
|
||||
if ('version' in value && 'data' in value) {
|
||||
return (value as StoredEnvelope<unknown>).data
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function writeStoredValue<T>(key: string, data: T): void {
|
||||
const payload: StoredEnvelope<T> = {
|
||||
version: STORAGE_VERSION,
|
||||
data,
|
||||
}
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function trimMessages(messages: Message[]): Message[] {
|
||||
if (messages.length <= MAX_STORED_MESSAGES) {
|
||||
return messages
|
||||
}
|
||||
|
||||
return messages.slice(-MAX_STORED_MESSAGES)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load playground config from localStorage
|
||||
*/
|
||||
export function loadConfig(): Partial<PlaygroundConfig> {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.CONFIG)
|
||||
if (saved) {
|
||||
return JSON.parse(saved)
|
||||
}
|
||||
const saved = readStoredValue(STORAGE_KEYS.CONFIG)
|
||||
if (!saved) return {}
|
||||
|
||||
return playgroundConfigSchema.parse(unwrapStoredValue(saved))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load config:', error)
|
||||
@@ -41,7 +89,8 @@ export function loadConfig(): Partial<PlaygroundConfig> {
|
||||
*/
|
||||
export function saveConfig(config: Partial<PlaygroundConfig>): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(config))
|
||||
const parsed = playgroundConfigSchema.parse(config)
|
||||
writeStoredValue(STORAGE_KEYS.CONFIG, parsed)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save config:', error)
|
||||
@@ -53,10 +102,10 @@ export function saveConfig(config: Partial<PlaygroundConfig>): void {
|
||||
*/
|
||||
export function loadParameterEnabled(): Partial<ParameterEnabled> {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.PARAMETER_ENABLED)
|
||||
if (saved) {
|
||||
return JSON.parse(saved)
|
||||
}
|
||||
const saved = readStoredValue(STORAGE_KEYS.PARAMETER_ENABLED)
|
||||
if (!saved) return {}
|
||||
|
||||
return parameterEnabledSchema.parse(unwrapStoredValue(saved))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load parameter enabled:', error)
|
||||
@@ -71,10 +120,8 @@ export function saveParameterEnabled(
|
||||
parameterEnabled: Partial<ParameterEnabled>
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.PARAMETER_ENABLED,
|
||||
JSON.stringify(parameterEnabled)
|
||||
)
|
||||
const parsed = parameterEnabledSchema.parse(parameterEnabled)
|
||||
writeStoredValue(STORAGE_KEYS.PARAMETER_ENABLED, parsed)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save parameter enabled:', error)
|
||||
@@ -86,19 +133,18 @@ export function saveParameterEnabled(
|
||||
*/
|
||||
export function loadMessages(): Message[] | null {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.MESSAGES)
|
||||
if (saved) {
|
||||
const parsed: unknown = JSON.parse(saved)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
const sanitized = sanitizeMessagesOnLoad(parsed as Message[])
|
||||
// Persist sanitized result to avoid re-sanitizing on subsequent loads
|
||||
if (sanitized !== parsed) {
|
||||
saveMessages(sanitized)
|
||||
}
|
||||
return sanitized
|
||||
const saved = readStoredValue(STORAGE_KEYS.MESSAGES)
|
||||
if (!saved) return null
|
||||
|
||||
const parsed = messagesSchema.parse(unwrapStoredValue(saved)) as Message[]
|
||||
const trimmed = trimMessages(parsed)
|
||||
const sanitized = sanitizeMessagesOnLoad(trimmed)
|
||||
|
||||
if (sanitized !== parsed || trimmed !== parsed) {
|
||||
saveMessages(sanitized)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load messages:', error)
|
||||
@@ -111,7 +157,9 @@ export function loadMessages(): Message[] | null {
|
||||
*/
|
||||
export function saveMessages(messages: Message[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messages))
|
||||
const trimmed = trimMessages(messages)
|
||||
const parsed = messagesSchema.parse(trimmed) as Message[]
|
||||
writeStoredValue(STORAGE_KEYS.MESSAGES, parsed)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save messages:', error)
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
import type { ChatCompletionChunk } from '../types'
|
||||
|
||||
const STREAM_DONE_MESSAGE = '[DONE]'
|
||||
const STREAM_CLOSED_READY_STATE = 2
|
||||
|
||||
export type StreamUpdateType = 'reasoning' | 'content'
|
||||
|
||||
export type StreamMessageUpdate = {
|
||||
type: StreamUpdateType
|
||||
chunk: string
|
||||
}
|
||||
|
||||
type StreamErrorPayload = {
|
||||
error?: {
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type StreamErrorDetails = {
|
||||
errorCode?: string
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export function parseStreamErrorDetails(data?: string): StreamErrorDetails {
|
||||
const fallbackMessage = data || ERROR_MESSAGES.API_REQUEST_ERROR
|
||||
|
||||
if (!data) {
|
||||
return { errorMessage: fallbackMessage }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as StreamErrorPayload
|
||||
|
||||
if (!parsed?.error) {
|
||||
return { errorMessage: fallbackMessage }
|
||||
}
|
||||
|
||||
return {
|
||||
errorCode: parsed.error.code || undefined,
|
||||
errorMessage: parsed.error.message || fallbackMessage,
|
||||
}
|
||||
} catch {
|
||||
return { errorMessage: fallbackMessage }
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStreamMessageUpdates(data: string): StreamMessageUpdate[] {
|
||||
const chunk = JSON.parse(data) as ChatCompletionChunk
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
|
||||
if (!delta) {
|
||||
return []
|
||||
}
|
||||
|
||||
const updates: StreamMessageUpdate[] = []
|
||||
|
||||
if (delta.reasoning_content) {
|
||||
updates.push({ type: 'reasoning', chunk: delta.reasoning_content })
|
||||
}
|
||||
|
||||
if (delta.content) {
|
||||
updates.push({ type: 'content', chunk: delta.content })
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
export function isStreamDoneMessage(data: string): boolean {
|
||||
return data === STREAM_DONE_MESSAGE
|
||||
}
|
||||
|
||||
export function isStreamClosedReadyState(readyState?: number): boolean {
|
||||
return readyState === STREAM_CLOSED_READY_STATE
|
||||
}
|
||||
|
||||
export function getStreamReadyStateError(
|
||||
eventReadyState: number | undefined,
|
||||
source: unknown
|
||||
): string | null {
|
||||
const status = (source as { status?: number }).status
|
||||
|
||||
if (
|
||||
eventReadyState !== undefined &&
|
||||
eventReadyState >= STREAM_CLOSED_READY_STATE &&
|
||||
status !== undefined &&
|
||||
status !== 200
|
||||
) {
|
||||
return `HTTP ${status}: ${ERROR_MESSAGES.CONNECTION_CLOSED}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
const MORE_SUGGESTION_TEXT = 'More'
|
||||
const SUGGESTION_CLASS_NAME = 'text-xs font-normal sm:text-sm'
|
||||
const MOBILE_HIDDEN_SUGGESTION_CLASS_NAME = `${SUGGESTION_CLASS_NAME} hidden sm:flex`
|
||||
|
||||
type SuggestionDisplayState = {
|
||||
className: string
|
||||
}
|
||||
|
||||
export function getSuggestionDisplayState(
|
||||
text: string
|
||||
): SuggestionDisplayState {
|
||||
return {
|
||||
className:
|
||||
text === MORE_SUGGESTION_TEXT
|
||||
? MOBILE_HIDDEN_SUGGESTION_CLASS_NAME
|
||||
: SUGGESTION_CLASS_NAME,
|
||||
}
|
||||
}
|
||||
+46
-55
@@ -16,11 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
flexRender,
|
||||
type Cell,
|
||||
type Table,
|
||||
} from '@tanstack/react-table'
|
||||
import { flexRender, type Cell, type Table } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
@@ -33,14 +29,20 @@ import {
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { dotColorMap, textColorMap, type StatusVariant } from '@/components/status-badge'
|
||||
import type { LogCategory } from '../types'
|
||||
import {
|
||||
dotColorMap,
|
||||
textColorMap,
|
||||
type StatusVariant,
|
||||
} from '@/components/status-badge'
|
||||
import { LOG_TYPE_ENUM } from '../constants'
|
||||
import { getLogTypeConfig } from '../lib/utils'
|
||||
import type { LogCategory } from '../types'
|
||||
|
||||
const logTypeRowTint: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.ERROR]: 'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30',
|
||||
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30',
|
||||
[LOG_TYPE_ENUM.ERROR]:
|
||||
'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30',
|
||||
[LOG_TYPE_ENUM.REFUND]:
|
||||
'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30',
|
||||
}
|
||||
|
||||
interface UsageLogsMobileListProps<TData> {
|
||||
@@ -53,11 +55,11 @@ interface UsageLogsMobileListProps<TData> {
|
||||
|
||||
function UsageLogsMobileSkeleton() {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'>
|
||||
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='space-y-2.5 border-b border-border/40 p-3 last:border-b-0'
|
||||
className='border-border/40 space-y-2.5 border-b p-3 last:border-b-0'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Skeleton className='h-5 w-40 rounded-md' />
|
||||
@@ -93,7 +95,7 @@ function CompactCell<TData>({
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden leading-tight [&_button]:max-w-full [&_span]:max-w-full',
|
||||
primaryOnly &&
|
||||
'[&_.flex-col>*:not(:first-child)]:hidden [&_.flex-col]:min-w-0',
|
||||
'[&_.flex-col]:min-w-0 [&_.flex-col>*:not(:first-child)]:hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -123,10 +125,7 @@ function SummaryField<TData>({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 rounded-md bg-muted/20 px-2 py-1.5',
|
||||
className
|
||||
)}
|
||||
className={cn('bg-muted/20 min-w-0 rounded-md px-2 py-1.5', className)}
|
||||
>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{label}
|
||||
@@ -174,6 +173,19 @@ function MobileLogTimeStatus({
|
||||
)
|
||||
}
|
||||
|
||||
function getCellOriginalField<TData>(
|
||||
cell: Cell<TData, unknown> | undefined,
|
||||
field: string
|
||||
): unknown {
|
||||
const original = cell?.row.original
|
||||
|
||||
if (!original || typeof original !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (original as Record<string, unknown>)[field]
|
||||
}
|
||||
|
||||
function CommonLogsCard<TData>({
|
||||
cells,
|
||||
}: {
|
||||
@@ -183,6 +195,7 @@ function CommonLogsCard<TData>({
|
||||
|
||||
const modelCell = cells.get('model_name')
|
||||
const quotaCell = cells.get('quota')
|
||||
const createdAtCell = cells.get('created_at')
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
@@ -195,13 +208,13 @@ function CommonLogsCard<TData>({
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] gap-1.5'>
|
||||
<div className='min-w-0 rounded-md bg-muted/20 px-2 py-1.5'>
|
||||
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{t('Time')}
|
||||
</div>
|
||||
<MobileLogTimeStatus
|
||||
createdAt={cells.get('created_at')?.row.original?.created_at}
|
||||
type={cells.get('created_at')?.row.original?.type}
|
||||
createdAt={getCellOriginalField(createdAtCell, 'created_at')}
|
||||
type={getCellOriginalField(createdAtCell, 'type')}
|
||||
/>
|
||||
</div>
|
||||
<SummaryField
|
||||
@@ -254,15 +267,8 @@ function TaskLogsCard<TData>({
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField
|
||||
label={t('Submit Time')}
|
||||
cell={submitTimeCell}
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('User')}
|
||||
cell={cells.get('user')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField label={t('Submit Time')} cell={submitTimeCell} />
|
||||
<SummaryField label={t('User')} cell={cells.get('user')} primaryOnly />
|
||||
<SummaryField
|
||||
label={t('Result')}
|
||||
cell={cells.get('fail_reason')}
|
||||
@@ -292,28 +298,19 @@ function DrawingLogsCard<TData>({
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField
|
||||
label={t('Submit Time')}
|
||||
cell={submitTimeCell}
|
||||
/>
|
||||
<SummaryField label={t('Submit Time')} cell={submitTimeCell} />
|
||||
<SummaryField
|
||||
label={t('Channel')}
|
||||
cell={cells.get('channel')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Task ID')}
|
||||
cell={cells.get('mj_id')}
|
||||
/>
|
||||
<SummaryField label={t('Task ID')} cell={cells.get('mj_id')} />
|
||||
<SummaryField
|
||||
label={t('Duration')}
|
||||
cell={cells.get('duration')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Image')}
|
||||
cell={cells.get('image_url')}
|
||||
/>
|
||||
<SummaryField label={t('Image')} cell={cells.get('image_url')} />
|
||||
<SummaryField
|
||||
label={t('Prompt')}
|
||||
cell={cells.get('prompt')}
|
||||
@@ -351,11 +348,11 @@ export function UsageLogsMobileList<TData>({
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<Empty className="border-none p-0">
|
||||
<div className='rounded-lg border p-6'>
|
||||
<Empty className='border-none p-0'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Database className="size-6" />
|
||||
<EmptyMedia variant='icon'>
|
||||
<Database className='size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
@@ -366,7 +363,7 @@ export function UsageLogsMobileList<TData>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'>
|
||||
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
|
||||
{rows.map((row) => {
|
||||
const cells = new Map(
|
||||
row.getVisibleCells().map((cell) => [cell.column.id, cell])
|
||||
@@ -381,19 +378,13 @@ export function UsageLogsMobileList<TData>({
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'border-l-2 border-l-transparent border-b border-border/40 p-3 transition-colors last:border-b-0',
|
||||
'border-border/40 border-b border-l-2 border-l-transparent p-3 transition-colors last:border-b-0',
|
||||
tintClass
|
||||
)}
|
||||
>
|
||||
{logCategory === 'common' && (
|
||||
<CommonLogsCard cells={cells} />
|
||||
)}
|
||||
{logCategory === 'task' && (
|
||||
<TaskLogsCard cells={cells} />
|
||||
)}
|
||||
{logCategory === 'drawing' && (
|
||||
<DrawingLogsCard cells={cells} />
|
||||
)}
|
||||
{logCategory === 'common' && <CommonLogsCard cells={cells} />}
|
||||
{logCategory === 'task' && <TaskLogsCard cells={cells} />}
|
||||
{logCategory === 'drawing' && <DrawingLogsCard cells={cells} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Vendored
+14
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "All conditions must match before this tier is used.",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "All edits are overwrite operations. Leave fields empty to keep current values unchanged.",
|
||||
"All files exceed the maximum size.": "All files exceed the maximum size.",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "All playground messages saved in this browser will be removed. This cannot be undone.",
|
||||
"All Groups": "All Groups",
|
||||
"All Models": "All Models",
|
||||
"All models in use are properly configured.": "All models in use are properly configured.",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "Amount options must be a JSON array",
|
||||
"Amount to pay:": "Amount to pay:",
|
||||
"An unexpected error occurred": "An unexpected error occurred",
|
||||
"Analyze data": "Analyze data",
|
||||
"and": "and",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "Announcement added. Click \"Save Settings\" to apply.",
|
||||
"Announcement content": "Announcement content",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "Clear All Cache",
|
||||
"Clear all filters": "Clear all filters",
|
||||
"Clear cache for this rule": "Clear cache for this rule",
|
||||
"Clear chat history": "Clear chat history",
|
||||
"Clear chat history?": "Clear chat history?",
|
||||
"Clear filters": "Clear filters",
|
||||
"Clear Mapping": "Clear Mapping",
|
||||
"Clear mode flags in prompts": "Clear mode flags in prompts",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "Continue with Telegram",
|
||||
"Continue with WeChat": "Continue with WeChat",
|
||||
"Contract review, compliance, summarisation": "Contract review, compliance, summarisation",
|
||||
"Conversation cleared": "Conversation cleared",
|
||||
"Control which models are exposed and which groups may use them.": "Control which models are exposed and which groups may use them.",
|
||||
"Controls how much the model thinks before answering": "Controls how much the model thinks before answering",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Controls whether user verification (biometrics/PIN) is required during Passkey flows.",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "Generating...",
|
||||
"Generation quality preset": "Generation quality preset",
|
||||
"Generic cache": "Generic cache",
|
||||
"Get advice": "Get advice",
|
||||
"Get notified when balance falls below this value": "Get notified when balance falls below this value",
|
||||
"Get one here": "Get one here",
|
||||
"Get started": "Get started",
|
||||
@@ -2853,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "Path Regex (one per line)",
|
||||
"Path:": "Path:",
|
||||
"Pay": "Pay",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Pay-as-you-go with real-time usage monitoring",
|
||||
"Pay with Balance": "Pay with Balance",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Pay-as-you-go with real-time usage monitoring",
|
||||
"Payment": "Payment",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.",
|
||||
"Payment Channel": "Payment Channel",
|
||||
@@ -3352,6 +3358,7 @@
|
||||
"Resets in:": "Resets in:",
|
||||
"Resolve Conflicts": "Resolve Conflicts",
|
||||
"Resource Configuration": "Resource Configuration",
|
||||
"Responding...": "Responding...",
|
||||
"Response": "Response",
|
||||
"Response Time": "Response Time",
|
||||
"Responses API Version": "Responses API Version",
|
||||
@@ -3422,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "Sampling temperature; lower is more deterministic",
|
||||
"Sandbox mode": "Sandbox mode",
|
||||
"Save": "Save",
|
||||
"Save & Submit": "Save & Submit",
|
||||
"Save all settings": "Save all settings",
|
||||
"Save Backup Codes": "Save Backup Codes",
|
||||
"Save changes": "Save changes",
|
||||
@@ -3709,6 +3717,7 @@
|
||||
"Standard": "Standard",
|
||||
"Standard price": "Standard price",
|
||||
"Start": "Start",
|
||||
"Start a playground chat": "Start a playground chat",
|
||||
"Start a conversation to see messages here": "Start a conversation to see messages here",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.",
|
||||
"Start for free with generous limits. No credit card required.": "Start for free with generous limits. No credit card required.",
|
||||
@@ -3767,10 +3776,10 @@
|
||||
"Subscription First": "Subscription First",
|
||||
"Subscription Management": "Subscription Management",
|
||||
"Subscription Only": "Subscription Only",
|
||||
"Subscription purchased successfully": "Subscription purchased successfully",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.",
|
||||
"Subscription Plans": "Subscription Plans",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).",
|
||||
"Subscription purchased successfully": "Subscription purchased successfully",
|
||||
"Subtract": "Subtract",
|
||||
"Success": "Success",
|
||||
"Success rate": "Success rate",
|
||||
@@ -3783,6 +3792,7 @@
|
||||
"Successfully enabled {{count}} model(s)": "Successfully enabled {{count}} model(s)",
|
||||
"Suffix": "Suffix",
|
||||
"Suffix Match": "Suffix Match",
|
||||
"Summarize text": "Summarize text",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "Sunset Glow",
|
||||
"Super Admin": "Super Admin",
|
||||
@@ -3797,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.",
|
||||
"Surprise me": "Surprise me",
|
||||
"Sustained tokens per second": "Sustained tokens per second",
|
||||
"Swap Face": "Swap Face",
|
||||
"Switch affinity on success": "Switch affinity on success",
|
||||
@@ -3883,6 +3894,7 @@
|
||||
"Test Model": "Test Model",
|
||||
"Test models and prompts from the browser": "Test models and prompts from the browser",
|
||||
"Test selected models": "Test selected models",
|
||||
"Test a model with a starter prompt, or write your own request below.": "Test a model with a starter prompt, or write your own request below.",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "Testing all enabled channels started. Please refresh to see results.",
|
||||
"Testing...": "Testing...",
|
||||
"Text": "Text",
|
||||
|
||||
Vendored
+14
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "Toutes les conditions doivent correspondre avant que ce palier soit utilisé.",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Toutes les modifications sont des opérations d'écrasement. Laissez les champs vides pour conserver les valeurs actuelles inchangées.",
|
||||
"All files exceed the maximum size.": "Tous les fichiers dépassent la taille maximale.",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "Tous les messages du Playground enregistrés dans ce navigateur seront supprimés. Cette action est irréversible.",
|
||||
"All Groups": "Tous les groupes",
|
||||
"All Models": "Tous les modèles",
|
||||
"All models in use are properly configured.": "Tous les modèles utilisés sont correctement configurés.",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "Les options de montant doivent être un tableau JSON",
|
||||
"Amount to pay:": "Montant à payer :",
|
||||
"An unexpected error occurred": "Une erreur inattendue est survenue",
|
||||
"Analyze data": "Analyser les données",
|
||||
"and": "et",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "Annonce ajoutée. Cliquez sur \"Enregistrer les paramètres\" pour appliquer.",
|
||||
"Announcement content": "Contenu de l'annonce",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "Vider tout le cache",
|
||||
"Clear all filters": "Effacer tous les filtres",
|
||||
"Clear cache for this rule": "Vider le cache de cette règle",
|
||||
"Clear chat history": "Effacer l'historique du chat",
|
||||
"Clear chat history?": "Effacer l'historique du chat ?",
|
||||
"Clear filters": "Effacer les filtres",
|
||||
"Clear Mapping": "Effacer le mappage",
|
||||
"Clear mode flags in prompts": "Effacer les indicateurs de mode dans les prompts",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "Continuer avec Telegram",
|
||||
"Continue with WeChat": "Continuer avec WeChat",
|
||||
"Contract review, compliance, summarisation": "Revue de contrats, conformité, résumé",
|
||||
"Conversation cleared": "Conversation effacée",
|
||||
"Control which models are exposed and which groups may use them.": "Contrôlez les modèles exposés et les groupes autorisés à les utiliser.",
|
||||
"Controls how much the model thinks before answering": "Contrôle la quantité de raisonnement avant la réponse",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Contrôle si la vérification de l'utilisateur (biométrie/PIN) est requise lors des flux de Passkey.",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "Génération...",
|
||||
"Generation quality preset": "Préréglage de qualité de génération",
|
||||
"Generic cache": "Cache générique",
|
||||
"Get advice": "Obtenir des conseils",
|
||||
"Get notified when balance falls below this value": "Recevoir une notification lorsque le solde tombe en dessous de cette valeur",
|
||||
"Get one here": "Obtenir ici",
|
||||
"Get started": "Commencer",
|
||||
@@ -2853,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "Regex du chemin (un par ligne)",
|
||||
"Path:": "Chemin :",
|
||||
"Pay": "Pay",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Paiement à l'usage avec suivi de la consommation en temps réel",
|
||||
"Pay with Balance": "Payer avec le solde",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Paiement à l'usage avec suivi de la consommation en temps réel",
|
||||
"Payment": "Paiement",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Mode agrégateur de paiement — embarquez avec votre propre société enregistrée (entité offshore). Conçu pour les entreprises.",
|
||||
"Payment Channel": "Canal de paiement",
|
||||
@@ -3352,6 +3358,7 @@
|
||||
"Resets in:": "Réinitialise dans :",
|
||||
"Resolve Conflicts": "Résoudre les conflits",
|
||||
"Resource Configuration": "Configuration des ressources",
|
||||
"Responding...": "Réponse en cours...",
|
||||
"Response": "Réponse",
|
||||
"Response Time": "Temps de réponse",
|
||||
"Responses API Version": "Version de l'API des réponses",
|
||||
@@ -3422,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "Température d'échantillonnage ; plus c'est bas, plus c'est déterministe",
|
||||
"Sandbox mode": "Mode sandbox",
|
||||
"Save": "Enregistrer",
|
||||
"Save & Submit": "Enregistrer et envoyer",
|
||||
"Save all settings": "Enregistrer tous les paramètres",
|
||||
"Save Backup Codes": "Sauvegarder les codes de secours",
|
||||
"Save changes": "Enregistrer les modifications",
|
||||
@@ -3709,6 +3717,7 @@
|
||||
"Standard": "Standard",
|
||||
"Standard price": "Prix standard",
|
||||
"Start": "Début",
|
||||
"Start a playground chat": "Démarrer une conversation dans le playground",
|
||||
"Start a conversation to see messages here": "Démarrez une conversation pour voir les messages ici",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Commencez à encaisser des paiements dans le monde entier sans créer de société. Conçu pour les développeurs indépendants, les entrepreneurs individuels OPC et les startups. Waffo Pancake agit comme Merchant of Record et prend en charge la conformité liée à l’encaissement mondial : taxes à la consommation, facturation, gestion des abonnements, remboursements et rétrofacturations. Les développeurs solo peuvent lancer rapidement leur produit et rester concentrés sur celui-ci plutôt que sur la conformité. Intégration en quelques minutes, d’une seule invite à une intégration complète.",
|
||||
"Start for free with generous limits. No credit card required.": "Commencez gratuitement avec des limites généreuses. Aucune carte de crédit requise.",
|
||||
@@ -3767,10 +3776,10 @@
|
||||
"Subscription First": "Abonnement en priorité",
|
||||
"Subscription Management": "Gestion des abonnements",
|
||||
"Subscription Only": "Abonnement uniquement",
|
||||
"Subscription purchased successfully": "Abonnement acheté avec succès",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "La création et la modification des forfaits d’abonnement sont verrouillées jusqu’à ce que l’administrateur confirme les conditions de conformité dans les paramètres de la passerelle de paiement.",
|
||||
"Subscription Plans": "Plans d'abonnement",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Les forfaits d’abonnement n’utilisent PAS le produit associé : chaque forfait dispose de son propre produit Pancake dédié, défini dans l’administration des abonnements (ou créé automatiquement via le bouton « + Créer »).",
|
||||
"Subscription purchased successfully": "Abonnement acheté avec succès",
|
||||
"Subtract": "Soustraire",
|
||||
"Success": "Succès",
|
||||
"Success rate": "Taux de réussite",
|
||||
@@ -3783,6 +3792,7 @@
|
||||
"Successfully enabled {{count}} model(s)": "{{count}} modèle(s) activé(s) avec succès",
|
||||
"Suffix": "Suffixe",
|
||||
"Suffix Match": "Correspondance de suffixe",
|
||||
"Summarize text": "Résumer le texte",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "Lueur du couchant",
|
||||
"Super Admin": "Super Administrateur",
|
||||
@@ -3797,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Prend en charge le balisage HTML ou l'intégration d'iframe. Entrez le code HTML directement, ou fournissez une URL complète pour l'intégrer automatiquement en tant qu'iframe.",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Prend en charge la configuration en un clic et s'adapte parfaitement à la configuration multi-protocole NewAPI.",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Prend en charge PNG, JPG, SVG ou WebP. Taille recommandée : 128×128 ou moins.",
|
||||
"Surprise me": "Surprends-moi",
|
||||
"Sustained tokens per second": "Jetons par seconde soutenus",
|
||||
"Swap Face": "Échanger le visage",
|
||||
"Switch affinity on success": "Changer l'affinité en cas de succès",
|
||||
@@ -3883,6 +3894,7 @@
|
||||
"Test Model": "Tester le modèle",
|
||||
"Test models and prompts from the browser": "Tester les modèles et les prompts depuis le navigateur",
|
||||
"Test selected models": "Tester les modèles sélectionnés",
|
||||
"Test a model with a starter prompt, or write your own request below.": "Testez un modèle avec un prompt de départ, ou rédigez votre propre demande ci-dessous.",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "Test de tous les canaux activés démarré. Veuillez actualiser pour voir les résultats.",
|
||||
"Testing...": "Test en cours...",
|
||||
"Text": "Texte",
|
||||
|
||||
Vendored
+14
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "この段階を使用するには、すべての条件に一致する必要があります。",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "すべての編集は上書き操作です。現在の値を変更しないままにするには、フィールドを空のままにしてください。",
|
||||
"All files exceed the maximum size.": "すべてのファイルが最大サイズを超えています。",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "このブラウザに保存されたすべての Playground メッセージが削除されます。この操作は元に戻せません。",
|
||||
"All Groups": "すべてのグループ",
|
||||
"All Models": "すべてのモデル",
|
||||
"All models in use are properly configured.": "使用中のすべてのモデルが適切に構成されています。",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "金額オプションは JSON 配列でなければなりません",
|
||||
"Amount to pay:": "支払い金額:",
|
||||
"An unexpected error occurred": "予期せぬエラーが発生しました",
|
||||
"Analyze data": "データを分析",
|
||||
"and": "および",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "お知らせが追加されました。\"設定を保存\" をクリックして適用してください。",
|
||||
"Announcement content": "お知らせの内容",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "全キャッシュをクリア",
|
||||
"Clear all filters": "すべてのフィルターをクリア",
|
||||
"Clear cache for this rule": "このルールのキャッシュをクリア",
|
||||
"Clear chat history": "チャット履歴を消去",
|
||||
"Clear chat history?": "チャット履歴を消去しますか?",
|
||||
"Clear filters": "フィルターをクリア",
|
||||
"Clear Mapping": "マッピングをクリア",
|
||||
"Clear mode flags in prompts": "プロンプト内のモードフラグをクリア",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "Telegram で続行",
|
||||
"Continue with WeChat": "WeChat で続行",
|
||||
"Contract review, compliance, summarisation": "契約レビュー・コンプライアンス・要約",
|
||||
"Conversation cleared": "会話を消去しました",
|
||||
"Control which models are exposed and which groups may use them.": "公開するモデルと、それらを利用できるグループを制御します。",
|
||||
"Controls how much the model thinks before answering": "モデルが回答前に考える深さを制御します",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Passkeyフロー中にユーザー認証(生体認証/PIN)が必要かどうかを制御します。",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "生成中...",
|
||||
"Generation quality preset": "生成品質プリセット",
|
||||
"Generic cache": "汎用キャッシュ",
|
||||
"Get advice": "アドバイスを得る",
|
||||
"Get notified when balance falls below this value": "残高がこの値を下回ったときに通知を受け取る",
|
||||
"Get one here": "こちらから取得",
|
||||
"Get started": "はじめる",
|
||||
@@ -2853,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "パス正規表現(1行に1つ)",
|
||||
"Path:": "パス:",
|
||||
"Pay": "Pay",
|
||||
"Pay-as-you-go with real-time usage monitoring": "リアルタイム使用量監視付き従量課金制",
|
||||
"Pay with Balance": "残高で支払う",
|
||||
"Pay-as-you-go with real-time usage monitoring": "リアルタイム使用量監視付き従量課金制",
|
||||
"Payment": "支払い",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "決済アグリゲーターモード — 自社の登録済み法人(オフショア法人)でオンボーディングします。エンタープライズ向けです。",
|
||||
"Payment Channel": "決済チャネル",
|
||||
@@ -3352,6 +3358,7 @@
|
||||
"Resets in:": "リセットまで:",
|
||||
"Resolve Conflicts": "競合を解決",
|
||||
"Resource Configuration": "リソース設定",
|
||||
"Responding...": "応答中...",
|
||||
"Response": "レスポンス",
|
||||
"Response Time": "応答時間",
|
||||
"Responses API Version": "応答APIバージョン",
|
||||
@@ -3422,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "サンプリング温度。低いほど決定論的になります",
|
||||
"Sandbox mode": "サンドボックスモード",
|
||||
"Save": "保存",
|
||||
"Save & Submit": "保存して送信",
|
||||
"Save all settings": "すべての設定を保存",
|
||||
"Save Backup Codes": "バックアップコードを保存",
|
||||
"Save changes": "変更を保存",
|
||||
@@ -3709,6 +3717,7 @@
|
||||
"Standard": "標準",
|
||||
"Standard price": "標準価格",
|
||||
"Start": "開始",
|
||||
"Start a playground chat": "Playground でチャットを開始",
|
||||
"Start a conversation to see messages here": "会話を開始すると、ここにメッセージが表示されます",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "法人を設立せずに世界中で決済を受け付けられます。個人開発者、OPC 個人事業主、スタートアップ向けに設計されています。Waffo Pancake は Merchant of Record として、消費税、請求書、サブスクリプション管理、返金、チャージバックなど、グローバル決済のコンプライアンス負担を引き受けます。個人開発者はコンプライアンスではなくプロダクトに集中しながら素早くローンチできます。数分でオンボーディングし、1 つのプロンプトから完全な統合まで進められます。",
|
||||
"Start for free with generous limits. No credit card required.": "豊富な無料枠で始められます。クレジットカードは不要です。",
|
||||
@@ -3767,10 +3776,10 @@
|
||||
"Subscription First": "サブスクリプション優先",
|
||||
"Subscription Management": "サブスクリプション管理",
|
||||
"Subscription Only": "サブスクリプションのみ",
|
||||
"Subscription purchased successfully": "サブスクリプションを購入しました",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "管理者が支払いゲートウェイ設定でコンプライアンス条件を確認するまで、サブスクリプションプランの作成と変更はロックされます。",
|
||||
"Subscription Plans": "サブスクリプションプラン",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "サブスクリプションプランは紐付け済み商品を使用しません。各プランには専用の Pancake 商品があり、サブスクリプション管理画面で設定します(または「+ 作成」ボタンで自動作成します)。",
|
||||
"Subscription purchased successfully": "サブスクリプションを購入しました",
|
||||
"Subtract": "減算",
|
||||
"Success": "成功",
|
||||
"Success rate": "成功率",
|
||||
@@ -3783,6 +3792,7 @@
|
||||
"Successfully enabled {{count}} model(s)": "{{count}} 個のモデルを有効にしました",
|
||||
"Suffix": "サフィックス",
|
||||
"Suffix Match": "サフィックス一致",
|
||||
"Summarize text": "テキストを要約",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "サンセットグロウ",
|
||||
"Super Admin": "スーパー管理者",
|
||||
@@ -3797,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "HTMLマークアップまたはiframe埋め込みをサポートします。HTMLコードを直接入力するか、完全なURLを提供してiframeとして自動的に埋め込みます。",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "ワンクリック設定をサポートし、NewAPIマルチプロトコル設定に完全に適応します。",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "PNG、JPG、SVG、WebPに対応。推奨サイズ: 128×128以下。",
|
||||
"Surprise me": "おまかせ",
|
||||
"Sustained tokens per second": "持続的な毎秒トークン数",
|
||||
"Swap Face": "顔入れ替え",
|
||||
"Switch affinity on success": "成功時にアフィニティを切替",
|
||||
@@ -3883,6 +3894,7 @@
|
||||
"Test Model": "モデルをテスト",
|
||||
"Test models and prompts from the browser": "ブラウザでモデルとプロンプトをテスト",
|
||||
"Test selected models": "選択したモデルをテスト",
|
||||
"Test a model with a starter prompt, or write your own request below.": "スタータープロンプトでモデルをテストするか、下に独自のリクエストを入力してください。",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "有効な全チャネルのテストを開始しました。結果を確認するにはページを更新してください。",
|
||||
"Testing...": "テスト中...",
|
||||
"Text": "テキスト",
|
||||
|
||||
Vendored
+14
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "Все условия должны совпасть, прежде чем будет использован этот уровень.",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Все изменения являются операциями перезаписи. Оставьте поля пустыми, чтобы сохранить текущие значения без изменений.",
|
||||
"All files exceed the maximum size.": "Все файлы превышают максимальный размер.",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "Все сообщения Playground, сохраненные в этом браузере, будут удалены. Это действие нельзя отменить.",
|
||||
"All Groups": "Все группы",
|
||||
"All Models": "Все модели",
|
||||
"All models in use are properly configured.": "Все используемые модели настроены правильно.",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "Варианты сумм должны быть JSON-массивом",
|
||||
"Amount to pay:": "Сумма к оплате:",
|
||||
"An unexpected error occurred": "Произошла непредвиденная ошибка",
|
||||
"Analyze data": "Анализировать данные",
|
||||
"and": "и",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "Объявление добавлено. Нажмите \"Сохранить настройки\", чтобы применить.",
|
||||
"Announcement content": "Содержимое объявления",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "Очистить весь кэш",
|
||||
"Clear all filters": "Очистить все фильтры",
|
||||
"Clear cache for this rule": "Очистить кэш этого правила",
|
||||
"Clear chat history": "Очистить историю чата",
|
||||
"Clear chat history?": "Очистить историю чата?",
|
||||
"Clear filters": "Очистить фильтры",
|
||||
"Clear Mapping": "Очистить сопоставление",
|
||||
"Clear mode flags in prompts": "Очистить флаги режимов в промптах",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "Продолжить с Telegram",
|
||||
"Continue with WeChat": "Продолжить с WeChat",
|
||||
"Contract review, compliance, summarisation": "Анализ контрактов, комплаенс, резюме",
|
||||
"Conversation cleared": "Диалог очищен",
|
||||
"Control which models are exposed and which groups may use them.": "Управляйте тем, какие модели доступны и какие группы могут их использовать.",
|
||||
"Controls how much the model thinks before answering": "Регулирует глубину размышлений модели перед ответом",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Определяет, требуется ли проверка пользователя (биометрия/PIN) во время процессов Passkey.",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "Создание...",
|
||||
"Generation quality preset": "Пресет качества генерации",
|
||||
"Generic cache": "Общий кэш",
|
||||
"Get advice": "Получить совет",
|
||||
"Get notified when balance falls below this value": "Получать уведомления, когда баланс опускается ниже этого значения",
|
||||
"Get one here": "Получить здесь",
|
||||
"Get started": "Начало работы",
|
||||
@@ -2853,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "Регулярное выражение пути (по одному на строку)",
|
||||
"Path:": "Путь:",
|
||||
"Pay": "Pay",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Оплата по мере использования с мониторингом в реальном времени",
|
||||
"Pay with Balance": "Оплатить балансом",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Оплата по мере использования с мониторингом в реальном времени",
|
||||
"Payment": "Платеж",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Режим платежного агрегатора — подключение через вашу зарегистрированную компанию (офшорное юрлицо). Создано для Enterprise.",
|
||||
"Payment Channel": "Платёжный канал",
|
||||
@@ -3352,6 +3358,7 @@
|
||||
"Resets in:": "Сброс через:",
|
||||
"Resolve Conflicts": "Разрешить конфликты",
|
||||
"Resource Configuration": "Конфигурация ресурсов",
|
||||
"Responding...": "Отвечаем...",
|
||||
"Response": "Ответ",
|
||||
"Response Time": "Время ответа",
|
||||
"Responses API Version": "Версия API ответов",
|
||||
@@ -3422,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "Температура сэмплирования; чем ниже, тем детерминированнее",
|
||||
"Sandbox mode": "Режим песочницы",
|
||||
"Save": "Сохранить",
|
||||
"Save & Submit": "Сохранить и отправить",
|
||||
"Save all settings": "Сохранить все настройки",
|
||||
"Save Backup Codes": "Сохранить резервные коды",
|
||||
"Save changes": "Сохранить изменения",
|
||||
@@ -3709,6 +3717,7 @@
|
||||
"Standard": "Стандартный",
|
||||
"Standard price": "Стандартная цена",
|
||||
"Start": "Начало",
|
||||
"Start a playground chat": "Начните чат в Playground",
|
||||
"Start a conversation to see messages here": "Начните разговор, чтобы увидеть сообщения здесь",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Начните принимать платежи по всему миру без регистрации компании. Подходит для независимых разработчиков, индивидуальных предпринимателей OPC и стартапов. Waffo Pancake выступает как Merchant of Record и берет на себя комплаенс глобального приема платежей: потребительские налоги, выставление счетов, управление подписками, возвраты и чарджбеки. Одиночные разработчики могут быстро запуститься и сосредоточиться на продукте, а не на комплаенсе. Подключение за минуты — от одного запроса до полной интеграции.",
|
||||
"Start for free with generous limits. No credit card required.": "Начните бесплатно с щедрыми лимитами. Кредитная карта не требуется.",
|
||||
@@ -3767,10 +3776,10 @@
|
||||
"Subscription First": "Подписка в приоритете",
|
||||
"Subscription Management": "Управление подписками",
|
||||
"Subscription Only": "Только подписка",
|
||||
"Subscription purchased successfully": "Подписка успешно приобретена",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Создание и изменение планов подписки заблокированы, пока администратор не подтвердит условия соответствия в настройках платежного шлюза.",
|
||||
"Subscription Plans": "Планы подписки",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Планы подписки НЕ используют привязанный продукт — у каждого плана есть собственный продукт Pancake, задаваемый в администрировании подписок (или автоматически создаваемый кнопкой «+ Создать»).",
|
||||
"Subscription purchased successfully": "Подписка успешно приобретена",
|
||||
"Subtract": "Вычесть",
|
||||
"Success": "Успешно",
|
||||
"Success rate": "Доля успешных запросов",
|
||||
@@ -3783,6 +3792,7 @@
|
||||
"Successfully enabled {{count}} model(s)": "Успешно включено {{count}} моделей",
|
||||
"Suffix": "Суффикс",
|
||||
"Suffix Match": "Совпадение по суффиксу",
|
||||
"Summarize text": "Кратко изложить текст",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "Закатное сияние",
|
||||
"Super Admin": "Суперадмин",
|
||||
@@ -3797,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Поддерживает HTML-разметку или встраивание iframe. Введите HTML-код напрямую или укажите полный URL для автоматического встраивания в виде iframe.",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Поддерживает настройку в один клик и идеально адаптируется к многопротокольной конфигурации NewAPI.",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Поддерживаются PNG, JPG, SVG или WebP. Рекомендуемый размер: 128×128 или меньше.",
|
||||
"Surprise me": "Удиви меня",
|
||||
"Sustained tokens per second": "Устойчивая скорость токенов в секунду",
|
||||
"Swap Face": "Замена лица",
|
||||
"Switch affinity on success": "Переключить привязку при успехе",
|
||||
@@ -3883,6 +3894,7 @@
|
||||
"Test Model": "Проверить модель",
|
||||
"Test models and prompts from the browser": "Тестируйте модели и промпты в браузере",
|
||||
"Test selected models": "Проверить выбранные модели",
|
||||
"Test a model with a starter prompt, or write your own request below.": "Проверьте модель с начальным промптом или напишите собственный запрос ниже.",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "Тестирование всех включенных каналов начато. Пожалуйста, обновите страницу, чтобы увидеть результаты.",
|
||||
"Testing...": "Тестирование...",
|
||||
"Text": "Текст",
|
||||
|
||||
Vendored
+14
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "Tất cả điều kiện phải khớp trước khi tầng này được sử dụng.",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Tất cả các chỉnh sửa đều là thao tác ghi đè. Để trống các trường để giữ nguyên giá trị hiện tại.",
|
||||
"All files exceed the maximum size.": "Tất cả các tệp vượt quá kích thước tối đa.",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "Tất cả tin nhắn Playground đã lưu trong trình duyệt này sẽ bị xóa. Không thể hoàn tác hành động này.",
|
||||
"All Groups": "Tất cả các nhóm",
|
||||
"All Models": "Tất cả các mẫu",
|
||||
"All models in use are properly configured.": "Tất cả các mô hình đang được sử dụng đều được cấu hình đúng cách.",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "Tùy chọn số tiền phải là mảng JSON",
|
||||
"Amount to pay:": "Amount due:",
|
||||
"An unexpected error occurred": "Đã xảy ra lỗi không mong muốn",
|
||||
"Analyze data": "Phân tích dữ liệu",
|
||||
"and": "and",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "Đã thêm thông báo. Nhấp \"Save Settings\" để áp dụng.",
|
||||
"Announcement content": "Nội dung thông báo",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "Xóa toàn bộ bộ nhớ đệm",
|
||||
"Clear all filters": "Xóa tất cả bộ lọc",
|
||||
"Clear cache for this rule": "Xóa bộ nhớ đệm của quy tắc này",
|
||||
"Clear chat history": "Xóa lịch sử trò chuyện",
|
||||
"Clear chat history?": "Xóa lịch sử trò chuyện?",
|
||||
"Clear filters": "Clear filter",
|
||||
"Clear Mapping": "Xóa Ánh xạ",
|
||||
"Clear mode flags in prompts": "Xóa các cờ chế độ trong lời nhắc",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "Tiếp tục với Telegram",
|
||||
"Continue with WeChat": "Tiếp tục với WeChat",
|
||||
"Contract review, compliance, summarisation": "Rà soát hợp đồng, tuân thủ, tóm tắt",
|
||||
"Conversation cleared": "Đã xóa cuộc trò chuyện",
|
||||
"Control which models are exposed and which groups may use them.": "Kiểm soát mô hình được hiển thị và nhóm nào có thể sử dụng chúng.",
|
||||
"Controls how much the model thinks before answering": "Điều chỉnh mức suy luận trước khi trả lời",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Kiểm soát xem liệu có yêu cầu xác minh người dùng (sinh trắc học/mã PIN) trong các luồng Passkey hay không.",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "Đang tạo...",
|
||||
"Generation quality preset": "Mức chất lượng sinh",
|
||||
"Generic cache": "Bộ đệm chung",
|
||||
"Get advice": "Nhận lời khuyên",
|
||||
"Get notified when balance falls below this value": "Nhận thông báo khi số dư giảm xuống dưới giá trị này",
|
||||
"Get one here": "Nhận tại đây",
|
||||
"Get started": "Bắt đầu",
|
||||
@@ -2853,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "Regex đường dẫn (mỗi dòng một mục)",
|
||||
"Path:": "Đường dẫn:",
|
||||
"Pay": "Pay",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Thanh toán theo mức sử dụng với theo dõi mức sử dụng theo thời gian thực",
|
||||
"Pay with Balance": "Thanh toán bằng số dư",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Thanh toán theo mức sử dụng với theo dõi mức sử dụng theo thời gian thực",
|
||||
"Payment": "Thanh toán",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Chế độ tổng hợp thanh toán — đăng ký bằng công ty đã đăng ký của bạn (pháp nhân offshore). Dành cho doanh nghiệp.",
|
||||
"Payment Channel": "Kênh thanh toán",
|
||||
@@ -3352,6 +3358,7 @@
|
||||
"Resets in:": "Đặt lại sau:",
|
||||
"Resolve Conflicts": "Giải quyết Xung đột",
|
||||
"Resource Configuration": "Cấu hình tài nguyên",
|
||||
"Responding...": "Đang phản hồi...",
|
||||
"Response": "Phản hồi",
|
||||
"Response Time": "Thời gian phản hồi",
|
||||
"Responses API Version": "Phiên bản API Phản hồi",
|
||||
@@ -3422,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "Nhiệt độ lấy mẫu; càng thấp càng ổn định",
|
||||
"Sandbox mode": "Chế độ sandbox",
|
||||
"Save": "Lưu",
|
||||
"Save & Submit": "Lưu và gửi",
|
||||
"Save all settings": "Lưu tất cả cài đặt",
|
||||
"Save Backup Codes": "Lưu mã dự phòng",
|
||||
"Save changes": "Lưu thay đổi",
|
||||
@@ -3709,6 +3717,7 @@
|
||||
"Standard": "Tiêu chuẩn",
|
||||
"Standard price": "Giá tiêu chuẩn",
|
||||
"Start": "Bắt đầu",
|
||||
"Start a playground chat": "Bắt đầu cuộc trò chuyện trong playground",
|
||||
"Start a conversation to see messages here": "Bắt đầu một cuộc trò chuyện để xem tin nhắn tại đây",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Bắt đầu thu thanh toán toàn cầu mà không cần đăng ký công ty. Dành cho lập trình viên độc lập, chủ sở hữu OPC và startup. Waffo Pancake đóng vai trò Merchant of Record, chịu trách nhiệm tuân thủ cho việc thu thanh toán toàn cầu — thuế tiêu dùng, hóa đơn, quản lý đăng ký, hoàn tiền và tranh chấp thanh toán. Lập trình viên cá nhân có thể ra mắt nhanh và tập trung vào sản phẩm thay vì tuân thủ. Onboard trong vài phút — từ một prompt đến tích hợp hoàn chỉnh.",
|
||||
"Start for free with generous limits. No credit card required.": "Bắt đầu miễn phí với giới hạn hào phóng. Không cần thẻ tín dụng.",
|
||||
@@ -3767,10 +3776,10 @@
|
||||
"Subscription First": "Ưu tiên đăng ký",
|
||||
"Subscription Management": "Quản lý đăng ký",
|
||||
"Subscription Only": "Chỉ đăng ký",
|
||||
"Subscription purchased successfully": "Đã mua gói đăng ký thành công",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Việc tạo và thay đổi gói đăng ký bị khóa cho đến khi quản trị viên xác nhận điều khoản tuân thủ trong cài đặt Cổng thanh toán.",
|
||||
"Subscription Plans": "Gói đăng ký",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Gói đăng ký KHÔNG dùng Sản phẩm đã liên kết — mỗi gói có một sản phẩm Pancake riêng, được đặt trong quản trị Đăng ký (hoặc tự động tạo bằng nút \"+ Create\" tại đó).",
|
||||
"Subscription purchased successfully": "Đã mua gói đăng ký thành công",
|
||||
"Subtract": "Trừ",
|
||||
"Success": "Thành công",
|
||||
"Success rate": "Tỷ lệ thành công",
|
||||
@@ -3783,6 +3792,7 @@
|
||||
"Successfully enabled {{count}} model(s)": "Đã bật thành công {{count}} mô hình",
|
||||
"Suffix": "Hậu tố",
|
||||
"Suffix Match": "Khớp hậu tố",
|
||||
"Summarize text": "Tóm tắt văn bản",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "Hoàng hôn",
|
||||
"Super Admin": "Siêu Quản trị viên",
|
||||
@@ -3797,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Hỗ trợ đánh dấu HTML hoặc nhúng iframe. Nhập mã HTML trực tiếp, hoặc cung cấp một URL đầy đủ để tự động nhúng nó dưới dạng một iframe.",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Hỗ trợ cấu hình bằng một cú nhấp chuột và thích ứng hoàn hảo với cấu hình đa giao thức NewAPI.",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Hỗ trợ PNG, JPG, SVG hoặc WebP. Kích thước khuyến nghị: 128×128 hoặc nhỏ hơn.",
|
||||
"Surprise me": "Gợi ý bất ngờ",
|
||||
"Sustained tokens per second": "Token mỗi giây duy trì",
|
||||
"Swap Face": "Đổi mặt",
|
||||
"Switch affinity on success": "Chuyển ưu tiên khi thành công",
|
||||
@@ -3883,6 +3894,7 @@
|
||||
"Test Model": "Kiểm tra Mô hình",
|
||||
"Test models and prompts from the browser": "Kiểm thử mô hình và prompt trong trình duyệt",
|
||||
"Test selected models": "Kiểm tra các mô hình đã chọn",
|
||||
"Test a model with a starter prompt, or write your own request below.": "Kiểm thử mô hình bằng prompt gợi ý, hoặc viết yêu cầu của riêng bạn bên dưới.",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "Bắt đầu kiểm tra tất cả các kênh đã kích hoạt. Vui lòng làm mới để xem kết quả.",
|
||||
"Testing...": "Đang kiểm tra...",
|
||||
"Text": "Văn bản",
|
||||
|
||||
Vendored
+14
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "所有条件都匹配后才会使用此阶梯。",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "所有编辑都是覆盖操作。留空字段将保持当前值不变。",
|
||||
"All files exceed the maximum size.": "所有文件都超过最大尺寸。",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "保存在此浏览器中的所有游乐场消息都将被移除。此操作无法撤销。",
|
||||
"All Groups": "所有分组",
|
||||
"All Models": "所有模型",
|
||||
"All models in use are properly configured.": "所有正在使用的模型都已正确配置。",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "金额选项必须是 JSON 数组",
|
||||
"Amount to pay:": "待支付金额:",
|
||||
"An unexpected error occurred": "发生意外错误",
|
||||
"Analyze data": "分析数据",
|
||||
"and": "和",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "公告已添加。点击 \"保存设置\" 以应用。",
|
||||
"Announcement content": "公告内容",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "清空全部缓存",
|
||||
"Clear all filters": "清除所有筛选",
|
||||
"Clear cache for this rule": "清空该规则缓存",
|
||||
"Clear chat history": "清空聊天历史",
|
||||
"Clear chat history?": "清空聊天历史?",
|
||||
"Clear filters": "清除筛选器",
|
||||
"Clear Mapping": "清除映射",
|
||||
"Clear mode flags in prompts": "在提示中清除模式标志",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "使用 Telegram 继续",
|
||||
"Continue with WeChat": "使用 微信 继续",
|
||||
"Contract review, compliance, summarisation": "合同审阅、合规与摘要",
|
||||
"Conversation cleared": "对话已清空",
|
||||
"Control which models are exposed and which groups may use them.": "控制对外暴露的模型,以及哪些分组可以使用它们。",
|
||||
"Controls how much the model thinks before answering": "控制模型回答前的推理深度",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "控制在通行密钥流程中是否需要用户验证(生物识别/PIN)。",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "生成中...",
|
||||
"Generation quality preset": "生成质量预设",
|
||||
"Generic cache": "通用缓存",
|
||||
"Get advice": "获取建议",
|
||||
"Get notified when balance falls below this value": "当余额低于此值时接收通知",
|
||||
"Get one here": "点此获取",
|
||||
"Get started": "开始使用",
|
||||
@@ -2853,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "路径正则(每行一个)",
|
||||
"Path:": "路径:",
|
||||
"Pay": "支付",
|
||||
"Pay-as-you-go with real-time usage monitoring": "按量付费,实时监控使用情况",
|
||||
"Pay with Balance": "使用余额支付",
|
||||
"Pay-as-you-go with real-time usage monitoring": "按量付费,实时监控使用情况",
|
||||
"Payment": "支付",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "支付聚合模式:使用你自己的注册公司(离岸实体)入驻。面向企业场景构建。",
|
||||
"Payment Channel": "支付渠道",
|
||||
@@ -3352,6 +3358,7 @@
|
||||
"Resets in:": "将于以下时间重置:",
|
||||
"Resolve Conflicts": "解决冲突",
|
||||
"Resource Configuration": "资源配置",
|
||||
"Responding...": "正在回复...",
|
||||
"Response": "响应",
|
||||
"Response Time": "响应时间",
|
||||
"Responses API Version": "响应 API 版本",
|
||||
@@ -3422,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "采样温度;越低越稳定",
|
||||
"Sandbox mode": "沙盒模式",
|
||||
"Save": "保存",
|
||||
"Save & Submit": "保存并提交",
|
||||
"Save all settings": "保存所有设置",
|
||||
"Save Backup Codes": "保存备份代码",
|
||||
"Save changes": "保存更改",
|
||||
@@ -3709,6 +3717,7 @@
|
||||
"Standard": "标准",
|
||||
"Standard price": "标准价格",
|
||||
"Start": "开始",
|
||||
"Start a playground chat": "开始一场游乐场对话",
|
||||
"Start a conversation to see messages here": "开始对话以在此处查看消息",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "无需注册公司即可开始全球收款。面向独立开发者、OPC 个体经营者和初创团队构建。Waffo Pancake 作为你的登记商户(Merchant of Record),承担全球收款相关的合规负担,包括消费税、开票、订阅管理、退款和拒付。个人开发者可以快速上线,专注产品而不是合规事务。几分钟即可完成入驻,从一个提示词到完整集成。",
|
||||
"Start for free with generous limits. No credit card required.": "免费开始使用,额度充足,无需绑定信用卡。",
|
||||
@@ -3767,10 +3776,10 @@
|
||||
"Subscription First": "优先订阅",
|
||||
"Subscription Management": "订阅管理",
|
||||
"Subscription Only": "仅用订阅",
|
||||
"Subscription purchased successfully": "订阅购买成功",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "管理员在支付网关设置中确认合规条款之前,订阅套餐的创建和修改会被锁定。",
|
||||
"Subscription Plans": "订阅套餐",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "订阅套餐不会使用已绑定的产品。每个套餐都有独立的 Pancake 产品,可在订阅管理中设置,或通过其中的“+ 创建”按钮自动生成。",
|
||||
"Subscription purchased successfully": "订阅购买成功",
|
||||
"Subtract": "减少",
|
||||
"Success": "成功",
|
||||
"Success rate": "成功率",
|
||||
@@ -3783,6 +3792,7 @@
|
||||
"Successfully enabled {{count}} model(s)": "成功启用 {{count}} 个模型",
|
||||
"Suffix": "后缀",
|
||||
"Suffix Match": "后缀匹配",
|
||||
"Summarize text": "总结文本",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "日落霞光",
|
||||
"Super Admin": "超级管理员",
|
||||
@@ -3797,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "支持 HTML 标记或 iframe 嵌入。直接输入 HTML 代码,或提供完整的 URL 以将其自动嵌入为 iframe。",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "支持一键配置并完美适配 NewAPI 多协议配置",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "支持 PNG、JPG、SVG 或 WebP,建议尺寸不超过 128×128。",
|
||||
"Surprise me": "给我惊喜",
|
||||
"Sustained tokens per second": "持续每秒 Token 数",
|
||||
"Swap Face": "换脸",
|
||||
"Switch affinity on success": "成功后切换亲和",
|
||||
@@ -3883,6 +3894,7 @@
|
||||
"Test Model": "测试模型",
|
||||
"Test models and prompts from the browser": "在浏览器中测试模型和提示词",
|
||||
"Test selected models": "测试所选模型",
|
||||
"Test a model with a starter prompt, or write your own request below.": "使用入门提示词测试模型,或在下方编写自己的请求。",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "测试所有启用的通道已开始。请刷新以查看结果。",
|
||||
"Testing...": "测试中...",
|
||||
"Text": "文本",
|
||||
|
||||
Vendored
+8
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
@import 'tailwindcss';
|
||||
@source "../../node_modules/streamdown/dist/*.js";
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
@import '@fontsource-variable/public-sans';
|
||||
@@ -32,6 +33,13 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
/* Shiki dual themes: token colors follow dark theme (pre background stays `bg-background` on the block) */
|
||||
@layer components {
|
||||
.shiki span {
|
||||
color: var(--shiki-light) !important;
|
||||
font-style: var(--shiki-light-font-style) !important;
|
||||
font-weight: var(--shiki-light-font-weight) !important;
|
||||
text-decoration: var(--shiki-light-text-decoration) !important;
|
||||
}
|
||||
|
||||
.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
|
||||
Reference in New Issue
Block a user