Compare commits

...

52 Commits

Author SHA1 Message Date
QuentinHsu f53b557a17 perf(playground): improve chat markdown rendering
- refine assistant and user message surfaces so chat content matches the app UI.
- normalize markdown typography, tables, images, lists, blockquotes, and details rendering.
- add indentation cues for collapsible reasoning and source sections.
2026-06-01 00:30:06 +08:00
QuentinHsu 4372abd787 feat(playground): add chat history clearing
- add a toolbar action that is enabled only when saved playground messages exist.
- confirm destructive clears before removing browser-stored conversation state.
- add localized strings for the action, dialog, and completion toast.
2026-05-31 14:23:50 +08:00
QuentinHsu 9b633a4131 fix(playground): constrain markdown code block height
- collapse long playground code blocks after a short preview instead of waiting for very large snippets
- cap expanded code blocks so long responses scroll inside the code block
- keep generic code block usage unconstrained unless a caller opts in
2026-05-30 19:13:50 +08:00
QuentinHsu ffb1e8e97a perf(playground): improve markdown code blocks
- render fenced markdown code with syntax highlighting, line numbers, and fallback plain text
- add copy, download, and collapse controls for playground AI responses
- tighten code block layout and theme token styles for responsive markdown rendering
2026-05-30 19:07:53 +08:00
QuentinHsu fa334c1eb0 perf(playground): refine message editing experience
- present message edits in a focused bordered editor panel
- add unsaved-change state, reset, and cancel confirmation flows
- improve mobile touch targets and keyboard shortcuts for editing
2026-05-30 18:46:07 +08:00
QuentinHsu fbcaf75b62 perf(playground): add error recovery actions
- show retry, edit, and delete actions inside error message alerts
- route edit recovery to the previous user prompt when available
- keep recovery controls touch-friendly on mobile layouts
2026-05-30 18:42:24 +08:00
QuentinHsu 47e912123c perf(playground): improve mobile message actions
- collapse mobile message actions into a touch-friendly dropdown menu
- keep the desktop hover action strip unchanged for pointer workflows
- share one action list between desktop buttons and the mobile menu
2026-05-30 18:37:08 +08:00
QuentinHsu 4f03641ac7 perf(playground): add starter empty state
- show starter prompts in the empty playground chat area
- wire empty-state prompt selection into the existing send flow
- add localized copy for the new empty state
2026-05-30 18:32:11 +08:00
QuentinHsu efc9c5844b perf(playground): improve mobile input controls
- split mobile input controls into selector and action rows
- keep the desktop input footer compact while reducing mobile control crowding
2026-05-30 18:16:34 +08:00
QuentinHsu 5a5286967d refactor(playground): extract pending assistant check
- centralize pending assistant message detection in streaming utilities
- reuse the helper when sanitizing stored playground messages
2026-05-30 11:08:24 +08:00
QuentinHsu 80a54b5b4b refactor(playground): centralize stream cleanup
- reuse one stream cleanup path for completion, errors, startup failures, and manual stops
- preserve the current-source guard when closing SSE streams
2026-05-30 11:06:42 +08:00
QuentinHsu 6ba23572b2 refactor(playground): extract non-stream response handling
- move chat completion response choice handling into message streaming utilities
- keep the chat handler focused on request lifecycle and error routing
2026-05-30 11:04:59 +08:00
QuentinHsu 378eed2bd4 refactor(playground): replace raw message role checks
- use shared message role constants in conversation edit handling
- avoid raw assistant role literals when validating API messages
2026-05-30 11:02:49 +08:00
QuentinHsu 61717ee53b refactor(playground): extract message content display checks
- move loader and content visibility decisions into local helper functions
- keep message content state assembly focused on composing render state
2026-05-30 10:59:29 +08:00
QuentinHsu f738ee481c refactor(playground): centralize error message checks
- add a shared helper for identifying error messages
- remove direct status string checks from message content rendering
2026-05-30 10:57:39 +08:00
QuentinHsu 0f94c07f16 refactor(playground): extract input submit text helper
- move prompt submit text validation into input control utilities
- let the input component submit only when a concrete text value is available
2026-05-30 10:55:47 +08:00
QuentinHsu d75e393b11 refactor(playground): extract option error messages
- move option load error message selection into playground option utilities
- keep the options hook focused on query effects and fallback updates
2026-05-30 10:53:08 +08:00
QuentinHsu eef921d188 refactor(playground): extract message removal helper
- move delete-message filtering into conversation message utilities.

- keep the conversation hook focused on action orchestration.
2026-05-30 10:50:13 +08:00
QuentinHsu b6ad800e77 refactor(playground): extract stream protocol checks
- move SSE done-message and closed-ready-state checks into stream utilities.

- keep the stream request hook focused on event handling flow.
2026-05-30 10:44:17 +08:00
QuentinHsu c82242f0d2 refactor(playground): extract input tool state
- move attachment action metadata and development notices into input tool utilities.

- keep the input tools component focused on menu and button rendering.
2026-05-30 10:39:59 +08:00
QuentinHsu a8c19eec50 refactor(playground): extract assistant message state checks
- move final and pending assistant status checks into streaming utilities.

- keep the chat handler focused on request lifecycle updates.
2026-05-30 10:35:18 +08:00
QuentinHsu 0f625f33a0 refactor(playground): extract suggestion display state
- move suggestion class selection into a pure helper.

- keep the suggestions component focused on translation and rendering.
2026-05-30 10:30:08 +08:00
QuentinHsu 902593926a refactor(playground): extract chat render state
- move editing content lookup and per-message render flags into conversation helpers.

- keep the chat component focused on mapping messages to editor and content views.
2026-05-30 10:23:47 +08:00
QuentinHsu b4e7c48e42 refactor(playground): extract message error state
- move error kind, fallback content, and admin visibility checks into a pure helper.

- centralize the model pricing settings path used by the error action.
2026-05-30 10:19:13 +08:00
QuentinHsu f03c8cc709 refactor(playground): extract message editor state
- move save eligibility and submit visibility checks into a pure helper.

- keep the editor component focused on textarea and button rendering.
2026-05-30 10:14:50 +08:00
QuentinHsu 6aba2b3eec refactor(playground): extract message content state
- move source, reasoning, loader, and body visibility checks into a pure helper.

- use a discriminated state shape so rendered reasoning content stays type-safe.
2026-05-30 10:10:06 +08:00
QuentinHsu 80ee5244d9 refactor(playground): extract input control state
- move submit, stop, and selector state derivation into a pure helper.

- keep input controls focused on rendering model selectors and action buttons.
2026-05-30 10:00:54 +08:00
QuentinHsu f87af88ca5 refactor(playground): extract message action helpers
- move message action state derivation into focused utilities.

- keep the action component focused on guarded handlers and rendering.
2026-05-30 09:55:36 +08:00
QuentinHsu 5816f69c20 refactor(playground): extract option fallback helpers
- move model and group fallback selection into focused playground utilities.
- keep the options hook focused on query results, toasts, and config updates.
2026-05-30 09:51:57 +08:00
QuentinHsu 3606367104 refactor(playground): extract state initialization helpers
- move playground initial state loading into focused utility helpers.
- centralize message state updater resolution outside the React state hook.
2026-05-30 09:34:15 +08:00
QuentinHsu 0339e36246 refactor(playground): extract conversation message helpers
- move send, regenerate, and edit message list construction into focused utilities.
- keep the conversation hook focused on edit state and update dispatch.
2026-05-30 09:30:47 +08:00
QuentinHsu 1f3eb1e419 refactor(playground): extract stream ready state checks
- move SSE ready-state status handling into stream utilities.
- keep weak source status typing outside the stream request hook.
2026-05-29 23:45:57 +08:00
QuentinHsu 6b5ee783f1 refactor(playground): extract stream message parsing
- move SSE delta parsing into a shared stream utility.
- keep the stream request hook focused on lifecycle handling and update dispatch.
2026-05-29 23:26:59 +08:00
QuentinHsu 2f16326562 refactor(playground): centralize assistant completion state
- add a helper for finalizing assistant messages with complete status.
- reuse the helper in stream completion and stop-generation paths.
2026-05-29 23:17:59 +08:00
QuentinHsu 76469cb944 refactor(playground): extract completion choice handling
- move non-streaming choice application into the message streaming utilities.
- keep the chat handler focused on request orchestration and message updates.
2026-05-29 23:12:53 +08:00
QuentinHsu 47d4d74bd6 refactor(playground): extract message update utilities
- move assistant message update helpers into a focused playground utility.
- keep error-state message updates separate from core message construction helpers.
2026-05-29 23:04:15 +08:00
QuentinHsu 59f3758175 refactor(playground): extract message streaming utilities
- move stream chunk application and message finalization into a dedicated utility.
- keep stored message sanitization with the streaming lifecycle helpers.
2026-05-29 22:51:30 +08:00
QuentinHsu 0deab07bb6 refactor(playground): extract message reasoning parser
- move think tag parsing into a dedicated playground message utility.
- export the parser through the shared playground lib barrel for consistent imports.
2026-05-29 22:43:47 +08:00
QuentinHsu 7465f682f8 refactor(playground): extract streaming chunk updates
- move reasoning and content chunk application into a message utility so the chat handler only wires stream events.
- preserve error-state skipping, reasoning accumulation, and content streaming behavior for assistant messages.
2026-05-29 22:28:50 +08:00
QuentinHsu 894f25ca51 refactor(playground): extract request error parsing
- move non-stream request error extraction into a shared utility so the chat handler stays focused on request flow.
- preserve the existing response message, error code, and fallback priority for failed chat completions.
2026-05-29 14:53:59 +08:00
QuentinHsu 12b103e9b6 refactor(playground): extract stream error parsing
- move SSE error payload parsing into a reusable stream utility so the request hook stays focused on lifecycle handling.
- preserve existing error message, error code, and fallback behavior for raw or empty stream errors.
2026-05-29 14:36:27 +08:00
QuentinHsu fdffe43533 refactor(playground): extract message editor
- move inline message editing controls into a dedicated editor component so the chat list stays focused on rendering flow.
- preserve save, save-and-submit, cancel, and disabled-state behavior for edited messages.
2026-05-29 10:55:33 +08:00
QuentinHsu 809e1dce6d refactor(playground): extract message content display
- move sources, reasoning, loading, error, and response rendering into a dedicated message content component.
- keep the chat list focused on message iteration, edit state, and action wiring without changing display behavior.
2026-05-29 10:49:48 +08:00
QuentinHsu 43c003e8e1 refactor(playground): extract input controls
- move model, group, send, and stop controls into a focused component so the input only manages compose state.
- preserve existing disabled states and generation button behavior while isolating control rendering.
2026-05-29 10:42:38 +08:00
QuentinHsu b0bf0b949b refactor(playground): extract input tools
- move attachment and search controls into a dedicated component so the prompt input stays focused on compose state.
- keep existing development toast behavior and disabled handling while centralizing tool metadata.
2026-05-29 10:10:48 +08:00
QuentinHsu 2d94a24912 refactor(playground): extract prompt suggestions
- move static prompt suggestion rendering into a focused component so the input stays centered on compose controls.
- preserve translated suggestion submission behavior while isolating icon metadata from the input form.
2026-05-29 10:07:33 +08:00
QuentinHsu a297c00cc3 refactor(playground): extract options loading hook
- move model and group queries into a dedicated hook so the page component stays focused on layout wiring.
- preserve existing fallback selection and error toast behavior while reusing the hook through the playground barrel export.
2026-05-29 10:04:50 +08:00
QuentinHsu 5489c68eec fix(usage-logs): handle mobile card row fields safely
- read generic table row fields through an unknown-safe helper to avoid invalid property access on unconstrained data.
- keep mobile time status inputs typed as unknown while preserving existing rendering behavior.
2026-05-29 10:00:30 +08:00
QuentinHsu c40d00e740 refactor(playground): split storage schemas
- move Playground storage validation schemas into a dedicated module.

- keep storage read and write logic focused on migration, trimming, and persistence.

- preserve the existing storage envelope and validation behavior.
2026-05-29 09:57:24 +08:00
QuentinHsu e6e86b8e8c refactor(playground): centralize message content access
- route chat rendering, copy actions, and error display through shared message helpers.

- reuse the current-version update helper for non-streaming assistant responses.

- keep message version details behind utility functions to reduce future model churn.
2026-05-29 09:31:30 +08:00
QuentinHsu 3f2107fb6d fix(playground): validate persisted chat state
- wrap saved Playground state with a storage version while still reading legacy values.

- validate config, parameter toggles, and messages before restoring them from localStorage.

- cap stored chat history to the latest messages to avoid oversized or stale state.
2026-05-29 09:21:41 +08:00
QuentinHsu 8a3e353231 refactor(playground): streamline chat request state
- extract conversation actions from the page component to keep message flow logic reusable.
- unify streaming and non-streaming generation state, including abort support for non-stream requests.
- simplify message rendering and payload construction while localizing Playground prompts.
2026-05-29 09:21:40 +08:00
53 changed files with 3670 additions and 1081 deletions
+254 -23
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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>
)
}
@@ -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>
)
}
@@ -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?')}
/>
</>
)
}
@@ -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>
)
}
@@ -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}
</>
)}
</>
)
}
@@ -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>
)
}
+2
View File
@@ -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
View File
@@ -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
})
@@ -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
View File
@@ -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,
}
}
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+112
View File
@@ -0,0 +1,112 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
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,
}
}
@@ -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>
)
})}
+14 -2
View File
@@ -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",
+14 -2
View File
@@ -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 à lencaissement 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, dune 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 dabonnement sont verrouillées jusqu’à ce que ladministrateur 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 dabonnement nutilisent PAS le produit associé : chaque forfait dispose de son propre produit Pancake dédié, défini dans ladministration 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",
+14 -2
View File
@@ -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": "テキスト",
+14 -2
View File
@@ -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": "Текст",
+14 -2
View File
@@ -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",
+14 -2
View File
@@ -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": "文本",
+8
View File
@@ -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;