feat: publish v1.0.0-rc.36-custom.1

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit 3a7e8f3b54
2381 files changed
+551494

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
/*
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 { api } from '@/lib/api'
import { API_ENDPOINTS } from './constants'
import type {
ChatCompletionRequest,
ChatCompletionResponse,
ModelOption,
GroupOption,
} from './types'
/**
* Send chat completion request (non-streaming)
*/
export async function sendChatCompletion(
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
}
/**
* Get user available models
*/
export async function getUserModels(group: string): Promise<ModelOption[]> {
const res = await api.get(API_ENDPOINTS.USER_MODELS, {
params: { group },
})
const { data } = res
if (!data.success || !Array.isArray(data.data)) {
return []
}
return data.data.map((model: string) => ({
label: model,
value: model,
}))
}
/**
* Get user groups
*/
export async function getUserGroups(): Promise<GroupOption[]> {
const res = await api.get(API_ENDPOINTS.USER_GROUPS)
const { data } = res
if (!data.success || !data.data) {
return []
}
const groupData = data.data as Record<string, { desc: string; ratio: number }>
// label is for button display (name only); desc is for dropdown content
return Object.entries(groupData).map(([group, info]) => ({
label: group,
value: group,
ratio: info.ratio,
desc: info.desc,
}))
}
@@ -0,0 +1,223 @@
/*
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, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import { Loader } from '@/components/ai-elements/loader'
import { Message } from '@/components/ai-elements/message'
import {
getChatMessageRenderState,
getEditingMessageContent,
getMessageAlignment,
getPreviousUserMessage,
isErrorMessage,
} from '../../lib'
import type {
Message as MessageType,
PlaygroundMessageLayoutMode,
} from '../../types'
import { MessageActions } from '../message/message-actions'
import { MessageErrorActions } from '../message/message-error-actions'
import { PlaygroundMessageContent } from '../message/playground-message-content'
import { PlaygroundMessageEditor } from '../message/playground-message-editor'
import { PlaygroundEmptyState } from './playground-empty-state'
const MAX_RENDERED_HISTORY_MESSAGES = 24
interface PlaygroundChatProps {
messages: MessageType[]
onCopyMessage?: (message: MessageType) => void
onRegenerateMessage?: (message: MessageType) => void
onEditMessage?: (message: MessageType) => void
onDeleteMessage?: (message: MessageType) => void
onSelectPrompt?: (prompt: string) => void
isGenerating?: boolean
isLoadingMessages?: boolean
editingKey?: string | null
onSaveEdit?: (newContent: string) => void
onCancelEdit?: (open: boolean) => void
onSaveEditAndSubmit?: (newContent: string) => void
messageLayoutMode?: PlaygroundMessageLayoutMode
}
export function PlaygroundChat({
messages,
onCopyMessage,
onRegenerateMessage,
onEditMessage,
onDeleteMessage,
onSelectPrompt,
isGenerating = false,
isLoadingMessages = false,
editingKey,
onSaveEdit,
onCancelEdit,
onSaveEditAndSubmit,
messageLayoutMode = 'alternating',
}: PlaygroundChatProps) {
const { t } = useTranslation()
const [editText, setEditText] = useState('')
const [originalText, setOriginalText] = useState('')
const [sourceMessageKeys, setSourceMessageKeys] = useState<
ReadonlySet<string>
>(() => new Set())
const visibleMessageOffset = Math.max(
0,
messages.length - MAX_RENDERED_HISTORY_MESSAGES
)
const visibleMessages = messages.slice(visibleMessageOffset)
function handleToggleMessageSource(message: MessageType): void {
setSourceMessageKeys((currentKeys) => {
const nextKeys = new Set(currentKeys)
if (nextKeys.has(message.key)) {
nextKeys.delete(message.key)
} else {
nextKeys.add(message.key)
}
return nextKeys
})
}
useEffect(() => {
if (!editingKey) return
const content = getEditingMessageContent(messages, editingKey)
// eslint-disable-next-line react-hooks/set-state-in-effect
setEditText(content)
setOriginalText(content)
}, [editingKey, messages])
let chatContent = visibleMessages.map((message, visibleMessageIndex) => {
const messageIndex = visibleMessageOffset + visibleMessageIndex
const { alwaysShowActions, content, isEditing } = getChatMessageRenderState(
messages,
message,
messageIndex,
editingKey
)
const isError = isErrorMessage(message)
const previousUserMessage = isError
? getPreviousUserMessage(messages, messageIndex)
: null
const alignment = getMessageAlignment(message, messageLayoutMode)
const isSourceVisible = sourceMessageKeys.has(message.key)
return (
<Message
className='group flex-row-reverse py-2.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
alignment={alignment}
actions={
<MessageActions
message={message}
onCopy={onCopyMessage}
onRegenerate={onRegenerateMessage}
onToggleSource={handleToggleMessageSource}
onEdit={onEditMessage}
onDelete={onDeleteMessage}
isSourceVisible={isSourceVisible}
isGenerating={isGenerating}
alwaysVisible={alwaysShowActions}
className='mt-1.5'
/>
}
isSourceVisible={isSourceVisible}
message={message}
errorActions={
isError ? (
<MessageErrorActions
disabled={isGenerating}
onRetry={
onRegenerateMessage
? () => onRegenerateMessage(message)
: undefined
}
onEditPrompt={
onEditMessage && previousUserMessage
? () => onEditMessage(previousUserMessage)
: undefined
}
onDelete={
onDeleteMessage
? () => onDeleteMessage(message)
: undefined
}
/>
) : undefined
}
versionContent={content}
/>
)}
</div>
</Message>
)
})
if (visibleMessages.length === 0 && onSelectPrompt) {
chatContent = [
<PlaygroundEmptyState key='empty' onSelectPrompt={onSelectPrompt} />,
]
}
if (isLoadingMessages) {
chatContent = [
<div
className='text-muted-foreground flex min-h-[min(520px,calc(100svh-18rem))] items-center justify-center gap-2 text-sm'
key='loading'
>
<Loader />
<span>{t('Loading conversation...')}</span>
</div>,
]
}
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'>{chatContent}</div>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
)
}
@@ -0,0 +1,84 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
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='bg-muted/50 text-muted-foreground mx-auto flex size-11 items-center justify-center rounded-xl border'>
<MessageSquarePlusIcon className='size-5' aria-hidden='true' />
</div>
<div className='grid gap-2'>
<h2 className='text-xl font-semibold tracking-tight text-balance md:text-2xl'>
{t('Start a playground chat')}
</h2>
<p className='text-muted-foreground mx-auto max-w-lg text-sm leading-6 text-balance'>
{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 px-3 py-2.5 text-left whitespace-normal'
key={text}
onClick={() => onSelectPrompt(prompt)}
variant='outline'
>
<Icon className='text-muted-foreground size-4' />
<span>{prompt}</span>
</Button>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,125 @@
/*
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
selectedModel={modelValue}
models={models}
onModelChange={onModelChange}
selectedGroup={groupValue}
groups={groups}
onGroupChange={onGroupChange}
disabled={isSelectorDisabled}
/>
)
const renderSubmitButton = () =>
shouldShowStop ? (
<PromptInputButton
className='border-destructive/25 bg-destructive/10 text-destructive hover:bg-destructive/15 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='bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground h-8 px-3 font-medium shadow-sm'
disabled={!canSubmit}
type='submit'
variant='default'
>
<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.5 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 min-w-0 items-center gap-2 md:flex'>
{renderSelector()}
{renderSubmitButton()}
</div>
</div>
)
}
@@ -0,0 +1,193 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { GlobeIcon, PaperclipIcon, Trash2Icon } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
PromptInputButton,
PromptInputTools,
} from '@/components/ai-elements/prompt-input'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
ATTACHMENT_ACTIONS,
getAttachmentActionNotice,
getSearchActionNotice,
} from '../../lib'
import type { ParameterEnabled, PlaygroundConfig } from '../../types'
import { PlaygroundParameterPanel } from './playground-parameter-panel'
type PlaygroundInputToolsProps = {
config: PlaygroundConfig
disabled?: boolean
hasMessages?: boolean
onClearMessages?: () => void
onConfigChange: <K extends keyof PlaygroundConfig>(
key: K,
value: PlaygroundConfig[K]
) => void
onParameterEnabledChange: (
key: keyof ParameterEnabled,
value: boolean
) => void
parameterEnabled: ParameterEnabled
}
export function PlaygroundInputTools({
config,
disabled,
hasMessages = false,
onClearMessages,
onConfigChange,
onParameterEnabledChange,
parameterEnabled,
}: 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 className='bg-background/70 border-border/60 rounded-lg border p-1 shadow-xs'>
<Tooltip>
<DropdownMenu>
<TooltipTrigger
render={
<DropdownMenuTrigger
render={
<PromptInputButton
aria-label={t('Attach')}
className='text-muted-foreground hover:text-foreground hover:bg-muted/70 font-medium'
disabled={disabled}
variant='ghost'
/>
}
>
<PaperclipIcon size={16} />
</DropdownMenuTrigger>
}
/>
<TooltipContent>
<p>{t('Attach')}</p>
</TooltipContent>
<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>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<PromptInputButton
aria-label={t('Search')}
className='text-muted-foreground hover:text-foreground hover:bg-muted/70 font-medium'
disabled={disabled}
onClick={handleSearchAction}
variant='ghost'
>
<GlobeIcon size={16} />
</PromptInputButton>
}
/>
<TooltipContent>
<p>{t('Search')}</p>
</TooltipContent>
</Tooltip>
<PlaygroundParameterPanel
config={config}
disabled={disabled}
onConfigChange={onConfigChange}
onParameterEnabledChange={onParameterEnabledChange}
parameterEnabled={parameterEnabled}
/>
<Tooltip>
<TooltipTrigger
render={
<PromptInputButton
aria-label={t('Clear chat history')}
className='text-muted-foreground hover:text-destructive hover:bg-destructive/10 font-medium'
disabled={disabled || !hasMessages || !onClearMessages}
onClick={() => setClearConfirmOpen(true)}
variant='ghost'
>
<Trash2Icon size={16} />
</PromptInputButton>
}
/>
<TooltipContent>
<p>{t('Clear chat history')}</p>
</TooltipContent>
</Tooltip>
</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?')}
/>
</>
)
}
@@ -0,0 +1,143 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PromptInput,
PromptInputFooter,
PromptInputTextarea,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input'
import { getSubmittableInputText } from '../../lib'
import type {
ModelOption,
GroupOption,
ParameterEnabled,
PlaygroundConfig,
} from '../../types'
import { PlaygroundInputControls } from './playground-input-controls'
import { PlaygroundInputTools } from './playground-input-tools'
interface PlaygroundInputProps {
config: PlaygroundConfig
onSubmit: (text: string) => void
onStop?: () => void
disabled?: boolean
isGenerating?: boolean
models: ModelOption[]
modelValue: string
onModelChange: (value: string) => void
isModelLoading?: boolean
groups: GroupOption[]
groupValue: string
onGroupChange: (value: string) => void
hasMessages?: boolean
onConfigChange: <K extends keyof PlaygroundConfig>(
key: K,
value: PlaygroundConfig[K]
) => void
onClearMessages?: () => void
onParameterEnabledChange: (
key: keyof ParameterEnabled,
value: boolean
) => void
parameterEnabled: ParameterEnabled
}
export function PlaygroundInput({
config,
onSubmit,
onStop,
disabled,
isGenerating,
models,
modelValue,
onModelChange,
isModelLoading = false,
groups,
groupValue,
onGroupChange,
hasMessages = false,
onConfigChange,
onClearMessages,
onParameterEnabledChange,
parameterEnabled,
}: PlaygroundInputProps) {
const { t } = useTranslation()
const [text, setText] = useState('')
const handleSubmit = (message: PromptInputMessage) => {
const submittableText = getSubmittableInputText(message, disabled)
if (!submittableText) return
onSubmit(submittableText)
setText('')
}
return (
<div className='grid shrink-0 gap-4 px-1 md:pb-4'>
<PromptInput
className='relative'
groupClassName='bg-background/95 dark:bg-background/80 border-border/70 shadow-[0_18px_60px_-32px_rgba(0,0,0,0.65)] ring-1 ring-foreground/5 rounded-xl overflow-hidden transition-all duration-200 focus-within:border-primary/45 focus-within:ring-primary/15 focus-within:shadow-[0_22px_70px_-34px_rgba(0,0,0,0.75)]'
onSubmit={handleSubmit}
>
<PromptInputTextarea
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
className='min-h-20 px-5 pt-4 pb-3 leading-7 md:min-h-24 md:text-base'
disabled={disabled}
onChange={(event) => setText(event.target.value)}
placeholder={t('Ask anything')}
value={text}
/>
<PromptInputFooter className='border-border/60 bg-muted/20 dark:bg-muted/10 border-t px-3 py-2.5 backdrop-blur'>
<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
config={config}
disabled={disabled}
hasMessages={hasMessages}
onConfigChange={onConfigChange}
onClearMessages={onClearMessages}
onParameterEnabledChange={onParameterEnabledChange}
parameterEnabled={parameterEnabled}
/>
}
/>
</PromptInputFooter>
</PromptInput>
</div>
)
}
@@ -0,0 +1,266 @@
/*
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 { SlidersHorizontalIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PromptInputButton } from '@/components/ai-elements/prompt-input'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import {
getParameterControlValueText,
normalizeParameterNumberValue,
PLAYGROUND_PARAMETER_CONTROLS,
PLAYGROUND_PARAMETER_PANEL_SCROLL_CLASS,
type PlaygroundParameterKey,
} from '../../lib/parameters/playground-parameters'
import type { ParameterEnabled, PlaygroundConfig } from '../../types'
type PlaygroundParameterPanelProps = {
config: PlaygroundConfig
disabled?: boolean
onConfigChange: <K extends keyof PlaygroundConfig>(
key: K,
value: PlaygroundConfig[K]
) => void
onParameterEnabledChange: (
key: PlaygroundParameterKey,
value: boolean
) => void
parameterEnabled: ParameterEnabled
}
type PlaygroundParameterContentProps = PlaygroundParameterPanelProps & {
compact?: boolean
}
function PlaygroundParameterContent({
compact = false,
config,
disabled,
onConfigChange,
onParameterEnabledChange,
parameterEnabled,
}: PlaygroundParameterContentProps) {
const { t } = useTranslation()
const updateParameterConfig = (
key: PlaygroundParameterKey,
value: number | null
) => {
if (key === 'seed') {
onConfigChange('seed', value)
return
}
onConfigChange(key, value ?? 0)
}
return (
<div
className={cn(
'grid gap-3',
PLAYGROUND_PARAMETER_PANEL_SCROLL_CLASS,
compact ? 'px-4 pb-4' : 'p-1'
)}
>
{PLAYGROUND_PARAMETER_CONTROLS.map((control) => {
const enabled = parameterEnabled[control.key]
const value = config[control.key]
const controlId = `playground-${control.key}`
return (
<div
className={cn(
'border-border/70 bg-background/60 grid gap-2 rounded-lg border p-3 transition-opacity',
(!enabled || disabled) && 'opacity-55'
)}
key={control.key}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0 space-y-1'>
<div className='flex min-w-0 items-center gap-2'>
<label
className='truncate text-sm leading-5 font-medium'
htmlFor={controlId}
>
{t(control.labelKey)}
</label>
<Badge
className='h-5 max-w-24 shrink-0 px-1.5 font-mono text-[11px]'
variant='outline'
>
{t(getParameterControlValueText(control.key, value))}
</Badge>
</div>
<p className='text-muted-foreground text-xs leading-4'>
{t(control.descriptionKey)}
</p>
</div>
<Switch
aria-label={t('Enable {{parameter}}', {
parameter: t(control.labelKey),
})}
checked={enabled}
disabled={disabled}
onCheckedChange={(checked) =>
onParameterEnabledChange(control.key, checked)
}
size='sm'
/>
</div>
{control.valueType === 'slider' ? (
<Slider
className='py-1.5'
disabled={disabled || !enabled}
id={controlId}
max={control.max}
min={control.min}
onValueChange={(nextValue) => {
const firstValue = Array.isArray(nextValue)
? nextValue[0]
: nextValue
updateParameterConfig(
control.key,
normalizeParameterNumberValue(control.key, firstValue)
)
}}
step={control.step}
value={[Number(value)]}
/>
) : (
<Input
disabled={disabled || !enabled}
id={controlId}
inputMode='numeric'
max={control.max}
min={control.min}
onChange={(event) => {
updateParameterConfig(
control.key,
normalizeParameterNumberValue(
control.key,
event.target.value
)
)
}}
step={control.step}
type='number'
value={value ?? ''}
/>
)}
</div>
)
})}
</div>
)
}
export function PlaygroundParameterPanel(props: PlaygroundParameterPanelProps) {
const { t } = useTranslation()
const isMobile = useIsMobile()
const activeCount = PLAYGROUND_PARAMETER_CONTROLS.filter(
(control) => props.parameterEnabled[control.key]
).length
const trigger = (
<PromptInputButton
aria-label={t('Parameters')}
className='text-muted-foreground hover:text-foreground hover:bg-muted/70 relative font-medium'
disabled={props.disabled}
variant='ghost'
>
<SlidersHorizontalIcon size={16} />
<span className='bg-primary text-primary-foreground absolute -top-1 -right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full px-1 text-[9px] leading-none font-semibold'>
{activeCount}
</span>
</PromptInputButton>
)
if (isMobile) {
return (
<Sheet>
<Tooltip>
<TooltipTrigger render={<SheetTrigger render={trigger} />} />
<TooltipContent>
<p>{t('Parameters')}</p>
</TooltipContent>
</Tooltip>
<SheetContent
className='max-h-[85vh] overflow-hidden rounded-t-xl'
side='bottom'
>
<SheetHeader>
<SheetTitle>{t('Parameter settings')}</SheetTitle>
</SheetHeader>
<PlaygroundParameterContent {...props} compact />
</SheetContent>
</Sheet>
)
}
return (
<Popover>
<Tooltip>
<TooltipTrigger render={<PopoverTrigger render={trigger} />} />
<TooltipContent>
<p>{t('Parameters')}</p>
</TooltipContent>
</Tooltip>
<PopoverContent
align='start'
className='w-[22rem] max-w-[calc(100vw-2rem)] gap-3 p-3'
collisionPadding={8}
side='top'
sideOffset={8}
>
<div className='space-y-1 px-1'>
<div className='text-sm font-semibold'>{t('Parameter settings')}</div>
<div className='text-muted-foreground text-xs leading-4'>
{t('Only enabled parameters are sent with the request.')}
</div>
</div>
<PlaygroundParameterContent {...props} />
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,125 @@
/*
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 { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import i18next from 'i18next'
import { beforeAll, describe, expect, test, vi } from 'vitest'
import type { Message } from '../../../types'
import { PlaygroundMessageEditor } from '../playground-message-editor'
const leavePrompt = 'You have unsaved changes. Are you sure you want to leave?'
const userMessage: Message = {
key: 'msg-1',
from: 'user',
versions: [{ id: 'v1', content: 'original' }],
}
function renderEditor(options: {
editText: string
onCancelEdit?: (open: boolean) => void
}) {
return render(
<PlaygroundMessageEditor
editText={options.editText}
message={userMessage}
onCancelEdit={options.onCancelEdit}
onEditTextChange={() => undefined}
originalText='original'
/>
)
}
describe('PlaygroundMessageEditor leave warning', () => {
beforeAll(() => {
i18next.addResourceBundle('en', 'translation', {
Cancel: 'Cancel',
Leave: 'Leave',
Stay: 'Stay',
[leavePrompt]: leavePrompt,
})
})
test('cancels immediately when the edit has no unsaved changes', async () => {
const user = userEvent.setup()
const onCancelEdit = vi.fn()
renderEditor({ editText: 'original', onCancelEdit })
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onCancelEdit).toHaveBeenCalledWith(false)
expect(screen.queryByText(leavePrompt)).not.toBeInTheDocument()
})
test('leaves the editor after confirming unsaved changes', async () => {
const user = userEvent.setup()
const onCancelEdit = vi.fn()
renderEditor({ editText: 'changed', onCancelEdit })
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await user.click(screen.getByRole('button', { name: 'Leave' }))
expect(onCancelEdit).toHaveBeenCalledWith(false)
})
test('keeps the editor open after staying with unsaved changes', async () => {
const user = userEvent.setup()
const onCancelEdit = vi.fn()
renderEditor({ editText: 'changed', onCancelEdit })
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await user.click(screen.getByRole('button', { name: 'Stay' }))
expect(onCancelEdit).not.toHaveBeenCalled()
expect(screen.queryByText(leavePrompt)).not.toBeInTheDocument()
})
})
describe('PlaygroundMessageEditor beforeunload guard', () => {
test('blocks page unload while the edit has unsaved changes', () => {
renderEditor({ editText: 'changed' })
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
test('stops blocking page unload after the edit reverts to the original text', () => {
const { rerender } = renderEditor({ editText: 'changed' })
rerender(
<PlaygroundMessageEditor
editText='original'
message={userMessage}
onEditTextChange={() => undefined}
originalText='original'
/>
)
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})
@@ -0,0 +1,73 @@
/*
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 { LucideIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { MESSAGE_ACTION_BUTTON_STYLES } from '../../constants'
interface MessageActionButtonProps {
icon: LucideIcon
label: string
onClick: () => void
disabled?: boolean
className?: string
variant?: 'default' | 'destructive'
}
export function MessageActionButton({
icon: Icon,
label,
onClick,
disabled = false,
className = '',
variant = 'default',
}: MessageActionButtonProps) {
const baseStyle =
variant === 'destructive'
? MESSAGE_ACTION_BUTTON_STYLES.DELETE
: MESSAGE_ACTION_BUTTON_STYLES.BASE
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
className={`${baseStyle} ${className}`}
onClick={onClick}
disabled={disabled}
aria-label={label}
/>
}
>
<Icon className={MESSAGE_ACTION_BUTTON_STYLES.ICON} />
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,221 @@
/*
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 {
Check,
Copy,
Edit,
FileCode2,
MoreHorizontal,
RefreshCw,
Trash2,
type LucideIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
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'
interface MessageActionsProps {
message: Message
onCopy?: (message: Message) => void
onRegenerate?: (message: Message) => void
onToggleSource?: (message: Message) => void
onEdit?: (message: Message) => void
onDelete?: (message: Message) => void
isSourceVisible?: boolean
isGenerating?: boolean
alwaysVisible?: boolean
className?: string
}
type MessageActionItem = {
className?: string
disabled?: boolean
icon: LucideIcon
label: string
onClick: () => void
variant?: 'default' | 'destructive'
}
export function MessageActions({
message,
onCopy,
onRegenerate,
onToggleSource,
onEdit,
onDelete,
isSourceVisible = false,
isGenerating = false,
alwaysVisible = false,
className = '',
}: MessageActionsProps) {
const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard()
const { guardAction } = useMessageActionGuard(isGenerating)
const { content, hasContent, isAssistant, isLoading, isUser } =
getMessageActionState(message)
const isCopied = copiedText === content
const handleCopy = () => {
if (!content) {
toast.warning(t(MESSAGE_ACTION_LABELS.NO_CONTENT))
return
}
copyToClipboard(content)
onCopy?.(message)
}
const handleRegenerate = guardAction(() => onRegenerate?.(message))
const handleToggleSource = () => onToggleSource?.(message)
const handleEdit = guardAction(() => onEdit?.(message))
const handleDelete = guardAction(() => onDelete?.(message))
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 && hasContent && !isLoading && onToggleSource) {
actions.push({
icon: FileCode2,
label: isSourceVisible
? MESSAGE_ACTION_LABELS.SHOW_PREVIEW
: MESSAGE_ACTION_LABELS.SHOW_SOURCE,
onClick: handleToggleSource,
})
}
if ((isAssistant || isUser) && hasContent && !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={`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={t(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 text-muted-foreground hover:text-foreground size-11'
size='icon'
variant='ghost'
/>
}
>
<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
return (
<DropdownMenuItem
className='min-h-11'
disabled={action.disabled}
key={action.label}
onClick={action.onClick}
variant={action.variant}
>
{t(action.label)}
<DropdownMenuShortcut>
<Icon className='size-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)
}
@@ -0,0 +1,74 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Edit, RefreshCw, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { MessageActionButton } from './message-action-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 items-center gap-0.5 pt-2'>
{onRetry && (
<MessageActionButton
disabled={disabled}
icon={RefreshCw}
label={t('Retry')}
onClick={onRetry}
/>
)}
{onEditPrompt && (
<MessageActionButton
disabled={disabled}
icon={Edit}
label={t('Edit')}
onClick={onEditPrompt}
/>
)}
{onDelete && (
<MessageActionButton
disabled={disabled}
icon={Trash2}
label={t('Delete')}
onClick={onDelete}
variant='destructive'
/>
)}
</div>
)
}
@@ -0,0 +1,118 @@
/*
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 { AlertCircle, AlertTriangle, Settings } from 'lucide-react'
/*
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { useAuthStore } from '@/stores/auth-store'
import {
FALLBACK_ERROR_CONTENT,
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 = '',
actions,
}: MessageErrorProps) {
const { t } = useTranslation()
const user = useAuthStore((s) => s.auth.user)
const errorState = getMessageErrorState(message, isAdminRole(user?.role))
if (!errorState) {
return null
}
if (errorState.kind === 'model-price') {
const content =
errorState.content === FALLBACK_ERROR_CONTENT
? t(FALLBACK_ERROR_CONTENT)
: errorState.content
return (
<Alert variant='default' className={className}>
<AlertTriangle className='text-orange-500' />
<AlertTitle>{t('Model Price Not Configured')}</AlertTitle>
<AlertDescription className='space-y-2'>
<p>{content}</p>
{errorState.showSettingsLink && (
<Button
variant='outline'
size='sm'
onClick={() => 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>
)
}
return (
<Alert variant='destructive' className={className}>
<AlertCircle />
<AlertTitle>{t('Error')}</AlertTitle>
<AlertDescription className='space-y-2'>
<p>
{errorState.content === FALLBACK_ERROR_CONTENT
? t(FALLBACK_ERROR_CONTENT)
: errorState.content}
</p>
{actions}
</AlertDescription>
</Alert>
)
}
@@ -0,0 +1,84 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import type { MessageAlignment } from '../../lib'
import type { Message } from '../../types'
type MessageMetadataProps = {
alignment: MessageAlignment
message: Message
}
function formatMessageTime(timestamp?: number): string | undefined {
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
return undefined
}
return new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(timestamp))
}
function formatDuration(
durationMs: number | undefined,
t: TFunction
): string | undefined {
if (typeof durationMs !== 'number' || !Number.isFinite(durationMs)) {
return undefined
}
if (durationMs < 1000) {
return t('{{value}}ms', { value: Math.max(1, Math.round(durationMs)) })
}
return t('{{value}}s', { value: (durationMs / 1000).toFixed(2) })
}
export function MessageMetadata(props: MessageMetadataProps) {
const { t } = useTranslation()
const messageTime = formatMessageTime(props.message.createdAt)
const duration = formatDuration(props.message.durationMs, t)
if (!messageTime && !duration) {
return null
}
return (
<div
className={cn(
'text-muted-foreground mt-1 flex min-h-4 items-center gap-1.5 text-[11px] leading-none',
props.alignment === 'right' && 'justify-end'
)}
>
{messageTime && <time>{messageTime}</time>}
{duration && (
<>
{messageTime && <span aria-hidden='true'>·</span>}
<span>{t('Response time: {{duration}}', { duration })}</span>
</>
)}
</div>
)
}
@@ -0,0 +1,167 @@
/*
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 {
CodeBlock,
CodeBlockCopyButton,
} from '@/components/ai-elements/code-block'
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 { cn } from '@/lib/utils'
import { MESSAGE_STATUS } from '../../constants'
import {
getMessageAlignmentClass,
getMessageContentState,
isErrorMessage,
type MessageAlignment,
} from '../../lib'
import { getMessageContentStyles } from '../../lib/message/message-styles'
import type { Message } from '../../types'
import { MessageError } from './message-error'
import { MessageMetadata } from './message-metadata'
type PlaygroundMessageContentProps = {
actions: ReactNode
alignment: MessageAlignment
errorActions?: ReactNode
isSourceVisible?: boolean
message: Message
versionContent: string
}
export function PlaygroundMessageContent({
actions,
alignment,
errorActions,
isSourceVisible = false,
message,
versionContent,
}: PlaygroundMessageContentProps) {
const { t } = useTranslation()
const {
displayContent,
hasReasoning,
hasSources,
reasoningContent,
showLoader,
showMessageContent,
sources,
} = getMessageContentState(message, versionContent)
const isError = isErrorMessage(message)
const isMessageFinal =
message.status !== MESSAGE_STATUS.LOADING &&
message.status !== MESSAGE_STATUS.STREAMING
return (
<div
className={cn(
'flex w-full min-w-0 flex-col',
getMessageAlignmentClass(alignment)
)}
>
{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
duration={message.reasoning?.duration}
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 message={message} className='mb-2' />
<MessageMetadata alignment={alignment} message={message} />
{errorActions}
</>
)}
{!isError && showMessageContent && (
<>
{isSourceVisible ? (
<CodeBlock
code={versionContent}
className='my-0 group-[.is-assistant]:w-full group-[.is-assistant]:max-w-[78ch]'
collapsedLines={24}
defaultCollapsed={false}
language='markdown'
maxExpandedLines={48}
showLineNumbers
showToolbar
title={t('Raw response')}
>
<CodeBlockCopyButton />
</CodeBlock>
) : (
<MessageContent
variant='flat'
className={cn(getMessageContentStyles())}
>
<Response final={isMessageFinal}>{displayContent}</Response>
</MessageContent>
)}
<MessageMetadata alignment={alignment} message={message} />
{actions}
</>
)}
</div>
)
}
@@ -0,0 +1,187 @@
/*
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 { Check, RotateCcw, Send, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CodeBlockEditor } from '@/components/ai-elements/code-block'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
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 [showLeaveDialog, setShowLeaveDialog] = useState(false)
const { canSave, hasChanged, showSaveAndSubmit } = getMessageEditorState(
message,
editText,
originalText
)
useEffect(() => {
if (!hasChanged) return
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
return ''
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [hasChanged])
const leaveEdit = () => {
setShowLeaveDialog(false)
onCancelEdit?.(false)
}
const handleCancel = () => {
if (hasChanged) {
setShowLeaveDialog(true)
return
}
leaveEdit()
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
if (showLeaveDialog) return
handleCancel()
return
}
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault()
if (!canSave) return
if (showSaveAndSubmit) {
onSaveEditAndSubmit?.(editText)
} else {
onSaveEdit?.(editText)
}
}
}
const editorActions = (
<>
{showSaveAndSubmit && (
<Button
aria-label={t('Save & Submit')}
disabled={!canSave}
onClick={() => onSaveEditAndSubmit?.(editText)}
size='icon-sm'
type='button'
>
<Send className='size-4' />
</Button>
)}
<Button
aria-label={t('Save')}
disabled={!canSave}
onClick={() => onSaveEdit?.(editText)}
size='icon-sm'
type='button'
variant={showSaveAndSubmit ? 'ghost' : 'default'}
>
<Check className='size-4' />
</Button>
{hasChanged && (
<Button
aria-label={t('Reset')}
onClick={() => onEditTextChange(originalText)}
size='icon-sm'
type='button'
variant='ghost'
>
<RotateCcw className='size-4' />
</Button>
)}
<Button
aria-label={t('Cancel')}
onClick={handleCancel}
size='icon-sm'
type='button'
variant='ghost'
>
<X className='size-4' />
</Button>
</>
)
return (
<>
<CodeBlockEditor
actions={editorActions}
ariaLabel={t('Edit')}
className='my-0 group-[.is-assistant]:w-full group-[.is-assistant]:max-w-[78ch] group-[.is-user]:max-w-[85%] sm:group-[.is-user]:max-w-[62ch] md:group-[.is-user]:max-w-[68ch] lg:group-[.is-user]:max-w-[72ch]'
language='markdown'
onChange={onEditTextChange}
onKeyDown={handleKeyDown}
rows={8}
title={
<span className='inline-flex items-center gap-2'>
<span>{t('Edit')}</span>
<span className='text-muted-foreground/80 normal-case'>
{hasChanged ? t('Unsaved changes') : t('No changes')}
</span>
</span>
}
value={editText}
/>
<ConfirmDialog
cancelBtnText={t('Stay')}
confirmText={t('Leave')}
desc={t('You have unsaved changes. Are you sure you want to leave?')}
destructive
handleConfirm={leaveEdit}
onOpenChange={(open) => {
if (!open) setShowLeaveDialog(false)
}}
open={showLeaveDialog}
title={t('Unsaved changes')}
/>
</>
)
}
+103
View File
@@ -0,0 +1,103 @@
/*
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 { PlaygroundConfig, ParameterEnabled } from './types'
// Message constants
export const MESSAGE_ROLES = {
USER: 'user',
ASSISTANT: 'assistant',
SYSTEM: 'system',
} as const
export const MESSAGE_STATUS = {
LOADING: 'loading',
STREAMING: 'streaming',
COMPLETE: 'complete',
ERROR: 'error',
} as const
// API endpoints
export const API_ENDPOINTS = {
CHAT_COMPLETIONS: '/pg/chat/completions',
USER_MODELS: '/api/user/models',
USER_GROUPS: '/api/user/self/groups',
} as const
// Default group — uses 'default' as the safe fallback; auto-group is
// only selected when the backend confirms it is available for the user.
export const DEFAULT_GROUP = 'default' as const
// Default configuration
export const DEFAULT_CONFIG: PlaygroundConfig = {
model: 'gpt-4o',
group: DEFAULT_GROUP,
temperature: 0.7,
top_p: 1,
max_tokens: 4096,
frequency_penalty: 0,
presence_penalty: 0,
seed: null,
stream: true,
}
export const DEFAULT_PARAMETER_ENABLED: ParameterEnabled = {
temperature: true,
top_p: true,
max_tokens: false,
frequency_penalty: true,
presence_penalty: true,
seed: false,
}
// Storage keys
export const STORAGE_KEYS = {
CONFIG: 'playground_config',
MESSAGES: 'playground_messages',
PARAMETER_ENABLED: 'playground_parameter_enabled',
} as const
// Error messages
export const ERROR_MESSAGES = {
API_REQUEST_ERROR: 'Request error occurred',
NETWORK_ERROR: 'Network connection failed or server not responding',
PARSE_ERROR: 'Error parsing response data',
STREAM_START_ERROR: 'Error establishing connection',
CONNECTION_CLOSED: 'Connection closed',
INTERRUPTED: 'Generation was interrupted',
} as const
// Message action button styles
export const MESSAGE_ACTION_BUTTON_STYLES = {
BASE: 'size-7 text-muted-foreground hover:text-foreground',
DELETE: 'size-7 text-muted-foreground hover:text-destructive',
ICON: 'size-4',
} as const
// Message action labels
export const MESSAGE_ACTION_LABELS = {
COPY: 'Copy',
COPIED: 'Copied!',
REGENERATE: 'Regenerate',
SHOW_PREVIEW: 'Show preview',
SHOW_SOURCE: 'Show source',
EDIT: 'Edit',
DELETE: 'Delete',
NO_CONTENT: 'No content to copy',
WAIT_GENERATION: 'Please wait for the current generation to complete',
} as const
@@ -0,0 +1,24 @@
/*
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
*/
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'
@@ -0,0 +1,391 @@
/*
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, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { sendChatCompletion } from '../api'
import { ERROR_MESSAGES } from '../constants'
import {
applyStreamingChunk,
buildChatCompletionPayload,
updateAssistantMessageWithError,
updateLastAssistantMessage,
parseRequestErrorDetails,
applyChatCompletionResponse,
completeAssistantMessage,
hasChatCompletionChoice,
isAssistantMessageFinal,
isAssistantMessagePending,
} from '../lib'
import type { Message, PlaygroundConfig, ParameterEnabled } from '../types'
import { useStreamRequest } from './use-stream-request'
interface UseChatHandlerOptions {
config: PlaygroundConfig
parameterEnabled: ParameterEnabled
onMessageUpdate: (updater: (prev: Message[]) => Message[]) => void
}
const KNOWN_ERROR_MESSAGES = new Set<string>(Object.values(ERROR_MESSAGES))
const STREAM_UPDATE_FLUSH_MS = 50
type PendingStreamChunks = {
generation: number
content: string
reasoning: string
}
function mergePendingStreamChunk(
currentChunk: string,
nextChunk: string
): string {
if (!currentChunk || !nextChunk.startsWith(currentChunk)) {
return currentChunk + nextChunk
}
return nextChunk
}
/**
* Hook for handling chat message sending and receiving
*/
export function useChatHandler({
config,
parameterEnabled,
onMessageUpdate,
}: UseChatHandlerOptions) {
const { t } = useTranslation()
const { sendStreamRequest, stopStream, isStreaming } = useStreamRequest()
const [isRequesting, setIsRequesting] = useState(false)
const abortControllerRef = useRef<AbortController | null>(null)
const requestGenerationRef = useRef(0)
const pendingStreamChunksRef = useRef<PendingStreamChunks>({
generation: 0,
content: '',
reasoning: '',
})
const streamFlushTimerRef = useRef<number | null>(null)
const discardPendingStreamUpdates = useCallback((generation: number) => {
if (streamFlushTimerRef.current !== null) {
window.clearTimeout(streamFlushTimerRef.current)
streamFlushTimerRef.current = null
}
pendingStreamChunksRef.current = {
generation,
content: '',
reasoning: '',
}
}, [])
const flushStreamUpdates = useCallback(
(generation: number) => {
if (generation !== requestGenerationRef.current) return
if (streamFlushTimerRef.current !== null) {
window.clearTimeout(streamFlushTimerRef.current)
streamFlushTimerRef.current = null
}
const pendingChunks = pendingStreamChunksRef.current
if (pendingChunks.generation !== generation) return
if (!pendingChunks.reasoning && !pendingChunks.content) {
return
}
pendingStreamChunksRef.current = {
generation,
content: '',
reasoning: '',
}
onMessageUpdate((prev) => {
if (generation !== requestGenerationRef.current) return prev
return updateLastAssistantMessage(prev, (message) => {
let updatedMessage = message
if (pendingChunks.reasoning) {
updatedMessage = applyStreamingChunk(
updatedMessage,
'reasoning',
pendingChunks.reasoning
)
}
if (pendingChunks.content) {
updatedMessage = applyStreamingChunk(
updatedMessage,
'content',
pendingChunks.content
)
}
return updatedMessage
})
})
},
[onMessageUpdate]
)
const scheduleStreamFlush = useCallback(
(generation: number) => {
if (generation !== requestGenerationRef.current) return
if (streamFlushTimerRef.current !== null) {
return
}
streamFlushTimerRef.current = window.setTimeout(() => {
flushStreamUpdates(generation)
}, STREAM_UPDATE_FLUSH_MS)
},
[flushStreamUpdates]
)
useEffect(
() => () => {
requestGenerationRef.current += 1
if (streamFlushTimerRef.current !== null) {
window.clearTimeout(streamFlushTimerRef.current)
}
abortControllerRef.current?.abort()
abortControllerRef.current = null
},
[]
)
const getDisplayError = useCallback(
(error: string) => {
if (KNOWN_ERROR_MESSAGES.has(error)) {
return t(error)
}
const connectionClosedSuffix = `: ${ERROR_MESSAGES.CONNECTION_CLOSED}`
if (error.endsWith(connectionClosedSuffix)) {
return `${error.slice(0, -ERROR_MESSAGES.CONNECTION_CLOSED.length)}${t(
ERROR_MESSAGES.CONNECTION_CLOSED
)}`
}
return error
},
[t]
)
// Handle stream update
const handleStreamUpdate = useCallback(
(generation: number, type: 'reasoning' | 'content', chunk: string) => {
if (generation !== requestGenerationRef.current) return
if (pendingStreamChunksRef.current.generation !== generation) return
pendingStreamChunksRef.current[type] = mergePendingStreamChunk(
pendingStreamChunksRef.current[type],
chunk
)
scheduleStreamFlush(generation)
},
[scheduleStreamFlush]
)
// Handle stream complete
const handleStreamComplete = useCallback(
(generation: number) => {
if (generation !== requestGenerationRef.current) return
flushStreamUpdates(generation)
setIsRequesting(false)
onMessageUpdate((prev) => {
if (generation !== requestGenerationRef.current) return prev
return updateLastAssistantMessage(prev, (message) =>
isAssistantMessageFinal(message)
? message
: completeAssistantMessage(message)
)
})
},
[flushStreamUpdates, onMessageUpdate]
)
// Handle stream error
const handleStreamError = useCallback(
(generation: number, error: string, errorCode?: string) => {
if (generation !== requestGenerationRef.current) return
flushStreamUpdates(generation)
setIsRequesting(false)
const displayError = getDisplayError(error)
toast.error(displayError)
const errorTitle = t(ERROR_MESSAGES.API_REQUEST_ERROR)
onMessageUpdate((prev) => {
if (generation !== requestGenerationRef.current) return prev
return updateAssistantMessageWithError(
prev,
displayError,
errorCode,
errorTitle
)
})
},
[flushStreamUpdates, getDisplayError, onMessageUpdate, t]
)
// Send streaming chat request
const sendStreamingChat = useCallback(
(messages: Message[]) => {
const generation = requestGenerationRef.current + 1
requestGenerationRef.current = generation
abortControllerRef.current?.abort()
abortControllerRef.current = null
discardPendingStreamUpdates(generation)
setIsRequesting(true)
const payload = buildChatCompletionPayload(
messages,
config,
parameterEnabled
)
void sendStreamRequest(
payload,
(type, chunk) => handleStreamUpdate(generation, type, chunk),
() => handleStreamComplete(generation),
(error, errorCode) => handleStreamError(generation, error, errorCode)
)
},
[
config,
parameterEnabled,
sendStreamRequest,
discardPendingStreamUpdates,
handleStreamUpdate,
handleStreamComplete,
handleStreamError,
]
)
// Send non-streaming chat request
const sendNonStreamingChat = useCallback(
async (messages: Message[]) => {
const payload = buildChatCompletionPayload(
messages,
config,
parameterEnabled
)
const generation = requestGenerationRef.current + 1
const abortController = new AbortController()
requestGenerationRef.current = generation
stopStream()
discardPendingStreamUpdates(generation)
abortControllerRef.current?.abort()
abortControllerRef.current = abortController
try {
setIsRequesting(true)
const response = await sendChatCompletion(
payload,
abortController.signal
)
if (
abortController.signal.aborted ||
requestGenerationRef.current !== generation
) {
return
}
if (!hasChatCompletionChoice(response)) {
handleStreamError(generation, ERROR_MESSAGES.API_REQUEST_ERROR)
return
}
onMessageUpdate((prev) => {
if (requestGenerationRef.current !== generation) return prev
return updateLastAssistantMessage(prev, (message) => {
const updatedMessage = applyChatCompletionResponse(
message,
response
)
return updatedMessage ?? message
})
})
} catch (error: unknown) {
if (
abortController.signal.aborted ||
requestGenerationRef.current !== generation
) {
return
}
const { errorCode, errorMessage } = parseRequestErrorDetails(error)
handleStreamError(generation, errorMessage, errorCode)
} finally {
if (requestGenerationRef.current === generation) {
abortControllerRef.current = null
setIsRequesting(false)
}
}
},
[
config,
parameterEnabled,
stopStream,
discardPendingStreamUpdates,
onMessageUpdate,
handleStreamError,
]
)
// Send chat request (stream or non-stream based on config)
const sendChat = useCallback(
(messages: Message[]) => {
if (config.stream) {
sendStreamingChat(messages)
} else {
sendNonStreamingChat(messages)
}
},
[config.stream, sendStreamingChat, sendNonStreamingChat]
)
// Stop generation
const stopGeneration = useCallback(() => {
const stoppedGeneration = requestGenerationRef.current
flushStreamUpdates(stoppedGeneration)
const idleGeneration = stoppedGeneration + 1
requestGenerationRef.current = idleGeneration
discardPendingStreamUpdates(idleGeneration)
stopStream()
abortControllerRef.current?.abort()
abortControllerRef.current = null
setIsRequesting(false)
onMessageUpdate((prev) => {
if (requestGenerationRef.current !== idleGeneration) return prev
return updateLastAssistantMessage(prev, (message) =>
isAssistantMessagePending(message)
? completeAssistantMessage(message)
: message
)
})
}, [
stopStream,
flushStreamUpdates,
discardPendingStreamUpdates,
onMessageUpdate,
])
return {
sendChat,
stopGeneration,
isGenerating: isStreaming || isRequesting,
}
}
@@ -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 { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { MESSAGE_ACTION_LABELS } from '../constants'
/**
* Hook to guard message actions when generation is in progress
* Provides a wrapper that checks if generation is active before executing
*/
export function useMessageActionGuard(isGenerating: boolean) {
const { t } = useTranslation()
const guardAction = useCallback(
(action: () => void) => {
return () => {
if (isGenerating) {
toast.warning(t(MESSAGE_ACTION_LABELS.WAIT_GENERATION))
return
}
action()
}
},
[isGenerating, t]
)
return { guardAction }
}
@@ -0,0 +1,116 @@
/*
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,143 @@
/*
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 { useQuery } from '@tanstack/react-query'
/*
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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getUserGroups, getUserModels } from '../api'
import {
getGroupFallback,
getModelFallback,
getOptionLoadErrorMessage,
shouldClearModelForGroup,
} 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', currentGroup],
queryFn: () => getUserModels(currentGroup),
enabled: currentGroup !== '',
})
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)
return
}
if (shouldClearModelForGroup(modelsData, currentModel)) {
updateConfig('model', '')
}
}, [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,
}
}
@@ -0,0 +1,180 @@
/*
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, useEffect, useRef, useState } from 'react'
import { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../constants'
import {
saveConfig,
saveParameterEnabled,
saveMessages,
applyMessageStateUpdate,
getInitialParameterEnabled,
getInitialPlaygroundConfig,
loadMessages,
type MessageStateUpdater,
} from '../lib'
import type {
Message,
PlaygroundConfig,
ParameterEnabled,
ModelOption,
GroupOption,
} from '../types'
const MESSAGE_SAVE_DEBOUNCE_MS = 500
/**
* Main state management hook for playground
*/
export function usePlaygroundState() {
// Load initial state from localStorage
const [config, setConfig] = useState<PlaygroundConfig>(
getInitialPlaygroundConfig
)
const [parameterEnabled, setParameterEnabled] = useState<ParameterEnabled>(
getInitialParameterEnabled
)
const [messages, setMessages] = useState<Message[]>([])
const [isLoadingMessages, setIsLoadingMessages] = useState(true)
const messagesSaveTimerRef = useRef<number | null>(null)
const latestMessagesRef = useRef<Message[]>(messages)
const hasLoadedMessagesRef = useRef(false)
const [models, setModels] = useState<ModelOption[]>([])
const [groups, setGroups] = useState<GroupOption[]>([])
const persistMessages = useCallback((messagesToSave: Message[]) => {
latestMessagesRef.current = messagesToSave
if (!hasLoadedMessagesRef.current) {
return
}
if (messagesSaveTimerRef.current !== null) {
window.clearTimeout(messagesSaveTimerRef.current)
}
messagesSaveTimerRef.current = window.setTimeout(() => {
messagesSaveTimerRef.current = null
saveMessages(latestMessagesRef.current)
}, MESSAGE_SAVE_DEBOUNCE_MS)
}, [])
useEffect(() => {
let cancelled = false
window.setTimeout(() => {
const loadedMessages = loadMessages() ?? []
if (cancelled) {
return
}
latestMessagesRef.current = loadedMessages
hasLoadedMessagesRef.current = true
setMessages(loadedMessages)
setIsLoadingMessages(false)
}, 0)
return () => {
cancelled = true
}
}, [])
useEffect(
() => () => {
if (messagesSaveTimerRef.current !== null) {
window.clearTimeout(messagesSaveTimerRef.current)
saveMessages(latestMessagesRef.current)
}
},
[]
)
// Update config with automatic save
const updateConfig = useCallback(
<K extends keyof PlaygroundConfig>(key: K, value: PlaygroundConfig[K]) => {
setConfig((prev) => {
const updated = { ...prev, [key]: value }
saveConfig(updated)
return updated
})
},
[]
)
// Update parameter enabled with automatic save
const updateParameterEnabled = useCallback(
(key: keyof ParameterEnabled, value: boolean) => {
setParameterEnabled((prev) => {
const updated = { ...prev, [key]: value }
saveParameterEnabled(updated)
return updated
})
},
[]
)
// Update messages with automatic save
const updateMessages = useCallback(
(updater: MessageStateUpdater) => {
setMessages((prev) => {
const newMessages = applyMessageStateUpdate(prev, updater)
persistMessages(newMessages)
return newMessages
})
},
[persistMessages]
)
// Clear all messages
const clearMessages = useCallback(() => {
updateMessages([])
}, [updateMessages])
// Reset config to defaults
const resetConfig = useCallback(() => {
setConfig(DEFAULT_CONFIG)
setParameterEnabled(DEFAULT_PARAMETER_ENABLED)
saveConfig(DEFAULT_CONFIG)
saveParameterEnabled(DEFAULT_PARAMETER_ENABLED)
}, [])
return {
// State
config,
parameterEnabled,
messages,
isLoadingMessages,
models,
groups,
// Setters
setModels,
setGroups,
// Actions
updateConfig,
updateParameterEnabled,
updateMessages,
clearMessages,
resetConfig,
}
}
@@ -0,0 +1,199 @@
/*
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 { describe, expect, test } from 'vitest'
import type { ChatCompletionRequest } from '../types'
import { createStreamRequestController } from './use-stream-request'
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve
})
return { promise, resolve }
}
class FakeStreamSource {
readyState = 0
closed = false
streamed = false
private listeners = new Map<
string,
Array<(event: Event & { data?: string; readyState?: number }) => void>
>()
addEventListener(
type: string,
listener: (event: Event & { data?: string; readyState?: number }) => void
) {
const listeners = this.listeners.get(type) ?? []
listeners.push(listener)
this.listeners.set(type, listeners)
}
close() {
this.closed = true
}
stream() {
this.streamed = true
}
emit(type: string, data?: string) {
for (const listener of this.listeners.get(type) ?? []) {
listener({ data, readyState: this.readyState } as Event & {
data?: string
readyState?: number
})
}
}
}
const payload: ChatCompletionRequest = {
model: 'test-model',
messages: [{ role: 'user', content: 'hello' }],
stream: true,
}
const noopCallbacks = {
onUpdate: () => undefined,
onComplete: () => undefined,
onError: () => undefined,
}
describe('latest-wins stream request coordination', () => {
test('only creates a stream for the latest header request', async () => {
const firstHeaders = deferred<Record<string, string>>()
const secondHeaders = deferred<Record<string, string>>()
let headerRequest = 0
const sources: FakeStreamSource[] = []
const controller = createStreamRequestController({
getHeaders: () => {
headerRequest += 1
return headerRequest === 1
? firstHeaders.promise
: secondHeaders.promise
},
createSource: () => {
const source = new FakeStreamSource()
sources.push(source)
return source
},
setStreaming: () => undefined,
})
const first = controller.send(payload, noopCallbacks)
const second = controller.send(payload, noopCallbacks)
firstHeaders.resolve({ Authorization: 'Bearer stale' })
await first
expect(sources.length).toBe(0)
secondHeaders.resolve({ Authorization: 'Bearer current' })
await second
expect(sources.length).toBe(1)
expect(sources[0]?.streamed).toBe(true)
})
test('stop cancels a request that is still waiting for headers', async () => {
const headers = deferred<Record<string, string>>()
let sourceCount = 0
const controller = createStreamRequestController({
getHeaders: () => headers.promise,
createSource: () => {
sourceCount += 1
return new FakeStreamSource()
},
setStreaming: () => undefined,
})
const request = controller.send(payload, noopCallbacks)
controller.stop()
headers.resolve({ Authorization: 'Bearer ignored' })
await request
expect(sourceCount).toBe(0)
})
test('dispose cancels a pending header request without a state update', async () => {
const headers = deferred<Record<string, string>>()
const streamingStates: boolean[] = []
let sourceCount = 0
const controller = createStreamRequestController({
getHeaders: () => headers.promise,
createSource: () => {
sourceCount += 1
return new FakeStreamSource()
},
setStreaming: (streaming) => streamingStates.push(streaming),
})
const request = controller.send(payload, noopCallbacks)
controller.dispose()
headers.resolve({ Authorization: 'Bearer ignored' })
await request
expect(sourceCount).toBe(0)
expect(streamingStates).toEqual([false])
})
test('closes the previous source and ignores all of its later events', async () => {
const nextHeaders = deferred<Record<string, string>>()
let headerRequest = 0
const sources: FakeStreamSource[] = []
const updates: string[] = []
const controller = createStreamRequestController({
getHeaders: () => {
headerRequest += 1
if (headerRequest === 1) {
return Promise.resolve({ Authorization: 'Bearer first' })
}
return nextHeaders.promise
},
createSource: () => {
const source = new FakeStreamSource()
sources.push(source)
return source
},
setStreaming: () => undefined,
})
const callbacks = {
onUpdate: (_type: 'reasoning' | 'content', chunk: string) =>
updates.push(chunk),
onComplete: () => undefined,
onError: () => undefined,
}
await controller.send(payload, callbacks)
const second = controller.send(payload, callbacks)
expect(sources[0]?.closed).toBe(true)
sources[0]?.emit(
'message',
JSON.stringify({ choices: [{ delta: { content: 'stale' } }] })
)
nextHeaders.resolve({ Authorization: 'Bearer second' })
await second
sources[1]?.emit(
'message',
JSON.stringify({ choices: [{ delta: { content: 'current' } }] })
)
expect(updates).toEqual(['current'])
})
})
@@ -0,0 +1,235 @@
/*
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, useEffect, useRef, useState } from 'react'
import { SSE } from 'sse.js'
import { getFreshAuthHeaders } from '@/lib/api'
import { API_ENDPOINTS, ERROR_MESSAGES } from '../constants'
import {
getStreamReadyStateError,
isStreamClosedReadyState,
isStreamDoneMessage,
parseStreamErrorDetails,
parseStreamMessageUpdates,
} from '../lib'
import type { ChatCompletionRequest } from '../types'
interface StreamEventSource {
readyState?: number
addEventListener: (
type: string,
listener: (event: Event & { data?: string; readyState?: number }) => void
) => void
close: () => void
stream: () => void
}
interface StreamRequestCallbacks {
onUpdate: (type: 'reasoning' | 'content', chunk: string) => void
onComplete: () => void
onError: (error: string, errorCode?: string) => void
}
interface StreamRequestControllerRuntime {
getHeaders: () => Promise<Record<string, string>>
createSource: (
payload: ChatCompletionRequest,
headers: Record<string, string>
) => StreamEventSource
setStreaming: (streaming: boolean) => void
}
export function createStreamRequestController(
runtime: StreamRequestControllerRuntime
) {
let source: StreamEventSource | null = null
let generation = 0
const closeActiveSource = (target: StreamEventSource) => {
target.close()
if (source === target) {
source = null
runtime.setStreaming(false)
}
}
const send = async (
payload: ChatCompletionRequest,
callbacks: StreamRequestCallbacks
) => {
const requestGeneration = generation + 1
generation = requestGeneration
const previousSource = source
source = null
previousSource?.close()
runtime.setStreaming(false)
let headers: Record<string, string>
try {
headers = await runtime.getHeaders()
} catch (error: unknown) {
if (generation !== requestGeneration) return
callbacks.onError(
error instanceof Error
? error.message
: ERROR_MESSAGES.STREAM_START_ERROR
)
return
}
if (generation !== requestGeneration) return
const nextSource = runtime.createSource(payload, headers)
source = nextSource
runtime.setStreaming(true)
let completed = false
const isCurrent = () =>
generation === requestGeneration && source === nextSource
const handleError = (errorMessage: string, errorCode?: string) => {
if (!isCurrent() || completed) return
completed = true
callbacks.onError(errorMessage, errorCode)
closeActiveSource(nextSource)
}
nextSource.addEventListener('message', (event) => {
if (!isCurrent() || completed) return
const data = event.data ?? ''
if (isStreamDoneMessage(data)) {
completed = true
closeActiveSource(nextSource)
callbacks.onComplete()
return
}
try {
const updates = parseStreamMessageUpdates(data)
for (const update of updates) {
callbacks.onUpdate(update.type, update.chunk)
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse SSE message:', error)
handleError(ERROR_MESSAGES.PARSE_ERROR)
}
})
nextSource.addEventListener('error', (event) => {
if (!isCurrent() || completed) return
if (!isStreamClosedReadyState(nextSource.readyState)) {
// eslint-disable-next-line no-console
console.error('SSE Error:', event)
const { errorCode, errorMessage } = parseStreamErrorDetails(event.data)
handleError(errorMessage, errorCode)
}
})
nextSource.addEventListener('readystatechange', (event) => {
if (!isCurrent() || completed) return
const errorMessage = getStreamReadyStateError(
event.readyState,
nextSource
)
if (errorMessage) {
handleError(errorMessage)
}
})
try {
if (!isCurrent()) return
nextSource.stream()
} catch (error: unknown) {
if (!isCurrent() || completed) return
// eslint-disable-next-line no-console
console.error('Failed to start SSE stream:', error)
handleError(ERROR_MESSAGES.STREAM_START_ERROR)
}
}
const cancel = (notify: boolean) => {
generation += 1
const activeSource = source
source = null
activeSource?.close()
if (notify) runtime.setStreaming(false)
}
const stop = () => cancel(true)
const dispose = () => cancel(false)
return { send, stop, dispose }
}
/**
* Hook for handling streaming chat completion requests
*/
export function useStreamRequest() {
const [isStreaming, setIsStreaming] = useState(false)
const controllerRef = useRef<ReturnType<
typeof createStreamRequestController
> | null>(null)
if (!controllerRef.current) {
controllerRef.current = createStreamRequestController({
getHeaders: getFreshAuthHeaders,
createSource: (payload, headers) =>
new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
headers,
method: 'POST',
payload: JSON.stringify(payload),
}) as StreamEventSource,
setStreaming: setIsStreaming,
})
}
const sendStreamRequest = useCallback(
(
payload: ChatCompletionRequest,
onUpdate: (type: 'reasoning' | 'content', chunk: string) => void,
onComplete: () => void,
onError: (error: string, errorCode?: string) => void
) =>
controllerRef.current?.send(payload, {
onUpdate,
onComplete,
onError,
}),
[]
)
const stopStream = useCallback(() => {
controllerRef.current?.stop()
}, [])
useEffect(
() => () => {
controllerRef.current?.dispose()
},
[]
)
return {
sendStreamRequest,
stopStream,
isStreaming,
}
}
+120
View File
@@ -0,0 +1,120 @@
/*
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 { PlaygroundChat } from './components/chat/playground-chat'
import { PlaygroundInput } from './components/input/playground-input'
import {
useChatHandler,
usePlaygroundConversation,
usePlaygroundOptions,
usePlaygroundState,
} from './hooks'
export function Playground() {
const {
config,
parameterEnabled,
messages,
isLoadingMessages,
models,
groups,
updateMessages,
setModels,
setGroups,
updateConfig,
updateParameterEnabled,
clearMessages,
} = usePlaygroundState()
const { sendChat, stopGeneration, isGenerating } = useChatHandler({
config,
parameterEnabled,
onMessageUpdate: updateMessages,
})
const {
editingMessageKey,
handleSendMessage,
handleRegenerateMessage,
handleEditMessage,
handleEditOpenChange,
applyEdit,
handleDeleteMessage,
} = usePlaygroundConversation({
messages,
updateMessages,
sendChat,
})
const handleClearMessages = () => {
handleEditOpenChange(false)
clearMessages()
}
const { isLoadingModels } = usePlaygroundOptions({
currentGroup: config.group,
currentModel: config.model,
setGroups,
setModels,
updateConfig,
})
return (
<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 min-h-0 flex-1 flex-col overflow-hidden'>
<PlaygroundChat
messages={messages}
isLoadingMessages={isLoadingMessages}
onRegenerateMessage={handleRegenerateMessage}
onEditMessage={handleEditMessage}
onDeleteMessage={handleDeleteMessage}
onSelectPrompt={handleSendMessage}
isGenerating={isGenerating}
editingKey={editingMessageKey}
onCancelEdit={handleEditOpenChange}
onSaveEdit={(newContent) => applyEdit(newContent, false)}
onSaveEditAndSubmit={(newContent) => applyEdit(newContent, true)}
/>
</div>
{/* Input area: center content and constrain to the same container width */}
<div className='mx-auto w-full max-w-4xl'>
<PlaygroundInput
config={config}
disabled={isGenerating}
groups={groups}
groupValue={config.group}
isGenerating={isGenerating}
isModelLoading={isLoadingModels}
modelValue={config.model}
models={models}
onGroupChange={(value) => updateConfig('group', value)}
onConfigChange={updateConfig}
onClearMessages={handleClearMessages}
onModelChange={(value) => updateConfig('model', value)}
onParameterEnabledChange={updateParameterEnabled}
onStop={stopGeneration}
onSubmit={handleSendMessage}
parameterEnabled={parameterEnabled}
hasMessages={messages.length > 0}
/>
</div>
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
/*
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
*/
export * from './input/input-control-utils'
export * from './input/input-tool-utils'
export * from './message/conversation-message-utils'
export * from './message/message-action-utils'
export * from './message/message-content-utils'
export * from './message/message-editor-utils'
export * from './message/message-error-utils'
export * from './message/message-layout-utils'
export * from './message/message-reasoning-utils'
export * from './message/message-streaming-utils'
export * from './message/message-styles'
export * from './message/message-timing-utils'
export * from './message/message-update-utils'
export * from './message/message-utils'
export * from './options/playground-option-utils'
export * from './parameters/playground-parameters'
export * from './state/playground-state-utils'
export * from './storage/storage'
export * from './streaming/payload-builder'
export * from './streaming/request-error-utils'
export * from './streaming/stream-utils'
@@ -0,0 +1,68 @@
/*
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 {
const hasModels = models.length > 0
return {
canSubmit: !disabled && hasModels && text.trim().length > 0,
isSelectorDisabled: disabled || isModelLoading || 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,177 @@
/*
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'
/*
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 {
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[] {
const submittedAt = Date.now()
return [
...messages,
createUserMessage(content, submittedAt),
createLoadingAssistantMessage(submittedAt),
]
}
export function createRegeneratedMessages(
messages: Message[],
messageKey: string
): Message[] | null {
const messageIndex = messages.findIndex(
(message) => message.key === messageKey
)
if (messageIndex === -1) {
return null
}
if (messages[messageIndex].from === MESSAGE_ROLES.USER) {
return [
...messages.slice(0, messageIndex + 1),
createLoadingAssistantMessage(),
]
}
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 submittedAt = Date.now()
const messageIndex = messages.findIndex(
(message) => message.key === messageKey
)
if (messageIndex === -1) {
return null
}
const updatedMessages = messages.map((message) =>
message.key === messageKey
? {
...updateCurrentVersionContent(message, content),
createdAt: shouldSubmit ? submittedAt : message.createdAt,
}
: message
)
if (
!shouldSubmit ||
updatedMessages[messageIndex].from !== MESSAGE_ROLES.USER
) {
return { messages: updatedMessages, shouldSend: false }
}
return {
messages: [
...updatedMessages.slice(0, messageIndex + 1),
createLoadingAssistantMessage(submittedAt),
],
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,
}
}
@@ -0,0 +1,49 @@
/*
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
isUser: boolean
}
export function getMessageActionState(message: Message): MessageActionState {
return {
content: getMessageContent(message),
hasContent: hasMessageContent(message),
isAssistant: message.from === MESSAGE_ROLES.ASSISTANT,
isUser: message.from === MESSAGE_ROLES.USER,
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,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 { 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
)
}
function getDisplayContent(message: Message, versionContent: string): string {
if (message.from !== MESSAGE_ROLES.ASSISTANT) {
return versionContent
}
if (!versionContent.includes('<think>')) {
return versionContent
}
return parseThinkTags(versionContent).visibleContent
}
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: getDisplayContent(message, 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'
export 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,39 @@
/*
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, PlaygroundMessageLayoutMode } from '../../types'
export type MessageAlignment = 'left' | 'right'
export function getMessageAlignment(
message: Message,
layoutMode: PlaygroundMessageLayoutMode
): MessageAlignment {
if (layoutMode === 'left') {
return 'left'
}
return message.from === MESSAGE_ROLES.USER ? 'right' : 'left'
}
export function getMessageAlignmentClass(alignment: MessageAlignment): string {
return alignment === 'right'
? 'items-end text-right'
: 'items-start text-left'
}
@@ -0,0 +1,70 @@
/*
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.slice(currentPos))
}
break
}
if (openPos > currentPos) {
visibleParts.push(content.slice(currentPos, openPos))
}
const closePos = content.indexOf('</think>', openPos + 7)
if (closePos === -1) {
reasoningParts.push(content.slice(openPos + 7))
hasUnclosedTag = true
break
}
reasoningParts.push(content.slice(openPos + 7, closePos))
currentPos = closePos + 8
}
return {
visibleContent: visibleParts.join('').trim(),
reasoning: reasoningParts.join('\n\n').trim(),
hasUnclosedTag,
}
}
@@ -0,0 +1,256 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { t } from 'i18next'
import { ERROR_MESSAGES, MESSAGE_ROLES, MESSAGE_STATUS } from '../../constants'
import type { ChatCompletionResponse, Message } from '../../types'
import { parseThinkTags } from './message-reasoning-utils'
import {
completeAssistantTiming,
completeReasoningTiming,
startReasoningTiming,
} from './message-timing-utils'
import {
getCurrentVersion,
hasMessageContent,
updateCurrentVersionContent,
} from './message-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
if (!message.reasoning && !fullContent.includes('<think>')) {
return {
...updateCurrentVersionContent(message, fullContent),
isReasoningStreaming: false,
}
}
const { reasoning, hasUnclosedTag } = parseThinkTags(fullContent)
const finalReasoning = reasoning
? {
...startReasoningTiming(message),
content: reasoning,
}
: message.reasoning
return {
...updateCurrentVersionContent(message, fullContent),
reasoning: finalReasoning,
isReasoningStreaming: hasUnclosedTag,
}
}
export type StreamChunkType = 'reasoning' | 'content'
function getAppendableChunk(currentContent: string, chunk: string): string {
if (!currentContent || !chunk.startsWith(currentContent)) {
return chunk
}
return chunk.slice(currentContent.length)
}
export function applyStreamingChunk(
message: Message,
type: StreamChunkType,
chunk: string
): Message {
if (message.status === MESSAGE_STATUS.ERROR) {
return message
}
if (type === 'reasoning') {
const reasoning = startReasoningTiming(message)
const appendableChunk = getAppendableChunk(reasoning.content, chunk)
return {
...message,
reasoning: {
...reasoning,
content: reasoning.content + appendableChunk,
},
isReasoningStreaming: true,
status: MESSAGE_STATUS.STREAMING,
}
}
const currentVersion = getCurrentVersion(message)
const appendableChunk = getAppendableChunk(currentVersion.content, chunk)
const contentMessage = processStreamingContent(message, appendableChunk)
return {
...(contentMessage.isReasoningStreaming
? contentMessage
: completeReasoningTiming(contentMessage)),
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 parsedThinkTags = currentVersion.content.includes('<think>')
? parseThinkTags(currentVersion.content)
: undefined
const visibleContent =
parsedThinkTags?.visibleContent ?? currentVersion.content
const finalReasoning =
apiReasoningContent ||
message.reasoning?.content ||
parsedThinkTags?.reasoning ||
''
const finalized = {
...updateCurrentVersionContent(message, visibleContent),
reasoning: finalReasoning
? {
...startReasoningTiming(message),
content: finalReasoning,
}
: undefined,
isReasoningStreaming: false,
}
return completeReasoningTiming(finalized)
}
export function completeAssistantMessage(message: Message): Message {
return completeAssistantTiming({
...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 completeAssistantTiming({
...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
? completeAssistantTiming({
...finalized,
status: MESSAGE_STATUS.COMPLETE,
isReasoningStreaming: false,
})
: completeAssistantTiming({
...updateCurrentVersionContent(
finalized,
`${t(ERROR_MESSAGES.API_REQUEST_ERROR)}: ${t(
ERROR_MESSAGES.INTERRUPTED
)}`
),
status: MESSAGE_STATUS.ERROR,
isReasoningStreaming: false,
})
const result = [...messages]
result[targetIndex] = sanitized
return result
}
@@ -0,0 +1,64 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/**
* Get message content styles based on role
* Encapsulates styling logic for user and assistant messages
*/
export function getMessageContentStyles() {
return [
// Assistant content reads like a document column; user bubble stays compact.
'group-[.is-assistant]:w-full',
'group-[.is-assistant]:max-w-[78ch]',
'group-[.is-user]:w-fit',
// 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]: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]: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
'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]',
'md:group-[.is-user]:max-w-[68ch]',
'lg:group-[.is-user]:max-w-[72ch]',
].join(' ')
}
@@ -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 { MESSAGE_ROLES } from '../../constants'
import type { Message } from '../../types'
export function completeAssistantTiming(
message: Message,
completedAt: number = Date.now()
): Message {
if (message.from !== MESSAGE_ROLES.ASSISTANT) {
return message
}
const startedAt = message.startedAt ?? message.createdAt ?? completedAt
return {
...message,
startedAt,
completedAt,
durationMs: Math.max(0, completedAt - startedAt),
}
}
export function startReasoningTiming(
message: Message,
startedAt: number = Date.now()
): NonNullable<Message['reasoning']> {
return {
content: message.reasoning?.content ?? '',
duration: message.reasoning?.duration ?? 0,
startedAt: message.reasoning?.startedAt ?? startedAt,
completedAt: message.reasoning?.completedAt,
durationMs: message.reasoning?.durationMs,
}
}
export function completeReasoningTiming(
message: Message,
completedAt: number = Date.now()
): Message {
if (!message.reasoning || message.reasoning.durationMs !== undefined) {
return message
}
const startedAt =
message.reasoning.startedAt ?? message.startedAt ?? completedAt
const durationMs = Math.max(0, completedAt - startedAt)
return {
...message,
reasoning: {
...message.reasoning,
startedAt,
completedAt,
durationMs,
duration: Math.ceil(durationMs / 1000),
},
}
}
@@ -0,0 +1,63 @@
/*
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 { completeAssistantTiming } from './message-timing-utils'
import { updateCurrentVersionContent } from './message-utils'
/**
* Update the last assistant message with an error.
*/
export function updateAssistantMessageWithError(
messages: Message[],
errorMessage: string,
errorCode?: string,
title: string = ERROR_MESSAGES.API_REQUEST_ERROR
): Message[] {
return updateLastAssistantMessage(messages, (message) => {
const updatedMessage = updateCurrentVersionContent(
message,
`${title}: ${errorMessage}`
)
return completeAssistantTiming({
...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.at(-1)
if (!last || last.from !== MESSAGE_ROLES.ASSISTANT) return messages
const updated = [...messages]
updated[updated.length - 1] = updater(last)
return updated
}
@@ -0,0 +1,176 @@
/*
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 { nanoid } from 'nanoid'
import { MESSAGE_ROLES, MESSAGE_STATUS } from '../../constants'
import type {
Message,
MessageVersion,
ChatCompletionMessage,
ContentPart,
} from '../../types'
/**
* Create a new message version
*/
export function createMessageVersion(content: string): MessageVersion {
return {
id: nanoid(),
content,
}
}
/**
* Get current version from message (always returns the first version)
*/
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
*/
export function updateCurrentVersionContent(
message: Message,
content: string
): Message {
const currentVersion = getCurrentVersion(message)
return {
...message,
versions: [{ ...currentVersion, content }],
}
}
/**
* Create a user message
*/
export function createUserMessage(
content: string,
createdAt: number = Date.now()
): Message {
return {
key: nanoid(),
from: MESSAGE_ROLES.USER,
versions: [createMessageVersion(content)],
createdAt,
}
}
/**
* Create a loading assistant message
*/
export function createLoadingAssistantMessage(
startedAt: number = Date.now()
): Message {
return {
key: nanoid(),
from: MESSAGE_ROLES.ASSISTANT,
versions: [createMessageVersion('')],
createdAt: startedAt,
startedAt,
reasoning: undefined,
isReasoningComplete: false,
isContentComplete: false,
isReasoningStreaming: false,
status: MESSAGE_STATUS.LOADING,
}
}
/**
* Build message content with optional images
*/
export function buildMessageContent(
text: string,
imageUrls: string[] = []
): string | ContentPart[] {
const validImages = imageUrls.filter((url) => url.trim() !== '')
if (validImages.length === 0) {
return text
}
const parts: ContentPart[] = [
{
type: 'text',
text: text || '',
},
...validImages.map((url) => ({
type: 'image_url' as const,
image_url: { url: url.trim() },
})),
]
return parts
}
/**
* Extract text content from message content
*/
export function getTextContent(content: string | ContentPart[]): string {
if (typeof content === 'string') {
return content
}
if (Array.isArray(content)) {
const textPart = content.find((part) => part.type === 'text')
return textPart?.text || ''
}
return ''
}
/**
* Format message for API request
*/
export function formatMessageForAPI(message: Message): ChatCompletionMessage {
const currentVersion = getCurrentVersion(message)
return {
role: message.from,
content: currentVersion.content,
}
}
/**
* Check if message is valid for API request
* Excludes loading/streaming assistant messages and empty content
*/
export function isValidMessage(message: Message): boolean {
if (!message || !message.from || !message.versions.length) return false
// Exclude empty assistant messages (loading/streaming placeholders)
if (message.from === MESSAGE_ROLES.ASSISTANT && !hasMessageContent(message)) {
return false
}
return true
}
@@ -0,0 +1,65 @@
/*
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 shouldClearModelForGroup(
models: ModelOption[],
currentModel: string
): boolean {
if (currentModel === '') {
return false
}
return !models.some((model) => model.value === currentModel)
}
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,129 @@
/*
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 { PlaygroundConfig, ParameterEnabled } from '../../types'
type ParameterValue = PlaygroundConfig[keyof PlaygroundConfig]
export type PlaygroundParameterKey = keyof ParameterEnabled
export type PlaygroundParameterControl = {
key: PlaygroundParameterKey
labelKey: string
descriptionKey: string
valueType: 'slider' | 'number'
min: number
max: number
step: number
}
export const PLAYGROUND_PARAMETER_CONTROLS = [
{
key: 'temperature',
labelKey: 'Temperature',
descriptionKey: 'Controls randomness and creativity',
valueType: 'slider',
min: 0.1,
max: 1,
step: 0.1,
},
{
key: 'top_p',
labelKey: 'Top P',
descriptionKey: 'Limits token selection to a probability mass',
valueType: 'slider',
min: 0.1,
max: 1,
step: 0.1,
},
{
key: 'frequency_penalty',
labelKey: 'Frequency Penalty',
descriptionKey: 'Reduces repeated wording',
valueType: 'slider',
min: -2,
max: 2,
step: 0.1,
},
{
key: 'presence_penalty',
labelKey: 'Presence Penalty',
descriptionKey: 'Encourages new topics',
valueType: 'slider',
min: -2,
max: 2,
step: 0.1,
},
{
key: 'max_tokens',
labelKey: 'Max Tokens',
descriptionKey: 'Caps the response length',
valueType: 'number',
min: 0,
max: 200000,
step: 1,
},
{
key: 'seed',
labelKey: 'Seed',
descriptionKey: 'Keeps compatible responses more repeatable',
valueType: 'number',
min: 0,
max: 2147483647,
step: 1,
},
] as const satisfies readonly PlaygroundParameterControl[]
export const PLAYGROUND_PARAMETER_PANEL_SCROLL_CLASS =
'max-h-[min(28rem,calc(100vh-10rem))] overflow-y-auto pr-1'
export function normalizeParameterNumberValue(
key: PlaygroundParameterKey,
value: string | number
): number | null {
if (value === '') {
return key === 'seed' ? null : 0
}
const control = PLAYGROUND_PARAMETER_CONTROLS.find((item) => item.key === key)
const parsed = typeof value === 'number' ? value : Number.parseFloat(value)
if (!control || Number.isNaN(parsed)) {
return key === 'seed' ? null : 0
}
const clamped = Math.min(control.max, Math.max(control.min, parsed))
if (control.step >= 1) {
return Math.trunc(clamped)
}
const precision = Math.max(0, String(control.step).split('.')[1]?.length ?? 0)
return Number(clamped.toFixed(precision))
}
export function getParameterControlValueText(
key: PlaygroundParameterKey,
value: ParameterValue
): string {
if (key === 'seed' && value === null) {
return 'Not set'
}
return String(value)
}
@@ -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 { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../../constants'
import type { Message, ParameterEnabled, PlaygroundConfig } from '../../types'
import {
loadConfig,
loadMessages,
loadParameterEnabled,
} from '../storage/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,91 @@
/*
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 MAX_STORED_MESSAGES_BYTES = 1024 * 1024
export const MAX_LOADED_MESSAGES_CHARS = 120_000
export const MAX_LOADED_MESSAGE_CHARS = 40_000
export const playgroundConfigSchema = z.object({
model: z.string().optional(),
group: z.string().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
max_tokens: z.number().optional(),
frequency_penalty: z.number().optional(),
presence_penalty: z.number().optional(),
seed: z.number().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(),
startedAt: z.number().optional(),
completedAt: z.number().optional(),
durationMs: z.number().optional(),
})
const messageSchema = z.object({
key: z.string(),
from: messageRoleSchema,
versions: z.array(messageVersionSchema).min(1),
createdAt: z.number().optional(),
startedAt: z.number().optional(),
completedAt: z.number().optional(),
durationMs: z.number().optional(),
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)
@@ -0,0 +1,398 @@
/*
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, STORAGE_KEYS } from '../../constants'
import type { PlaygroundConfig, ParameterEnabled, Message } from '../../types'
import {
finalizeMessage,
isAssistantMessagePending,
sanitizeMessagesOnLoad,
} from '../message/message-streaming-utils'
import { completeAssistantTiming } from '../message/message-timing-utils'
import { hasMessageContent } from '../message/message-utils'
import {
MAX_LOADED_MESSAGE_CHARS,
MAX_LOADED_MESSAGES_CHARS,
MAX_STORED_MESSAGES,
MAX_STORED_MESSAGES_BYTES,
STORAGE_VERSION,
messagesSchema,
parameterEnabledSchema,
playgroundConfigSchema,
} from './storage-schema'
type StoredEnvelope<T> = {
version: number
data: T
}
const TRUNCATED_CONTENT_SUFFIX = '\n\n[...]'
const MIN_PREFIX_COLLAPSE_LENGTH = 2000
const MIN_REPEATED_SECTION_COUNT = 3
const SECTION_HEADING_LINE_PATTERN = /^#{2,6}\s+\d+\.\s+.+$/gm
function readStoredValue(key: string): unknown | null {
const saved = localStorage.getItem(key)
if (!saved) return null
return JSON.parse(saved) as unknown
}
function readStoredMessagesValue(): unknown | null {
const saved = localStorage.getItem(STORAGE_KEYS.MESSAGES)
if (!saved) return null
if (saved.length > MAX_STORED_MESSAGES_BYTES) {
localStorage.removeItem(STORAGE_KEYS.MESSAGES)
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)
}
function getMessageSize(message: Message): number {
const versionsSize = message.versions.reduce(
(total, version) => total + version.content.length,
0
)
const reasoningSize = message.reasoning?.content.length ?? 0
return versionsSize + reasoningSize
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text
}
if (maxLength <= TRUNCATED_CONTENT_SUFFIX.length) {
return text.slice(0, maxLength)
}
return `${text.slice(0, maxLength - TRUNCATED_CONTENT_SUFFIX.length)}${TRUNCATED_CONTENT_SUFFIX}`
}
type SectionOccurrence = {
heading: string
index: number
}
function getSectionOccurrences(text: string): SectionOccurrence[] {
const occurrences: SectionOccurrence[] = []
const matches = text.matchAll(SECTION_HEADING_LINE_PATTERN)
for (const match of matches) {
const index = match.index
if (index === undefined) {
continue
}
occurrences.push({
heading: match[0],
index,
})
}
return occurrences
}
function getHeadingCounts(
occurrences: SectionOccurrence[]
): Map<string, number> {
const counts = new Map<string, number>()
for (const occurrence of occurrences) {
counts.set(occurrence.heading, (counts.get(occurrence.heading) ?? 0) + 1)
}
return counts
}
function findLastRepeatedSectionRunStart(text: string): number {
const occurrences = getSectionOccurrences(text)
const headingCounts = getHeadingCounts(occurrences)
const lastRepeatedIndexes: number[] = []
const seenHeadings = new Set<string>()
for (let index = occurrences.length - 1; index >= 0; index--) {
const occurrence = occurrences[index]
const count = headingCounts.get(occurrence.heading) ?? 0
if (
count < MIN_REPEATED_SECTION_COUNT ||
seenHeadings.has(occurrence.heading)
) {
continue
}
seenHeadings.add(occurrence.heading)
lastRepeatedIndexes.push(occurrence.index)
}
if (lastRepeatedIndexes.length === 0) {
return -1
}
return Math.min(...lastRepeatedIndexes)
}
function collapseRepeatedSectionSnapshots(text: string): string {
if (text.length < MIN_PREFIX_COLLAPSE_LENGTH) {
return text
}
const lastRepeatedRunStart = findLastRepeatedSectionRunStart(text)
if (lastRepeatedRunStart === -1) {
return text
}
return text.slice(lastRepeatedRunStart)
}
function normalizeStoredMessageForLoad(message: Message): Message {
let changed = false
const versions = message.versions.map((version) => {
const collapsedContent = collapseRepeatedSectionSnapshots(version.content)
const content = truncateText(collapsedContent, MAX_LOADED_MESSAGE_CHARS)
if (content === version.content && collapsedContent === version.content) {
return version
}
changed = true
return {
...version,
content,
}
})
const reasoning = message.reasoning
? {
...message.reasoning,
content: truncateText(
message.reasoning.content,
MAX_LOADED_MESSAGE_CHARS
),
}
: undefined
if (reasoning?.content !== message.reasoning?.content) {
changed = true
}
const normalized = changed ? { ...message, versions, reasoning } : message
if (!isAssistantMessagePending(normalized)) {
return normalized
}
const hasContent = hasMessageContent(normalized)
const hasReasoning = normalized.reasoning?.content.trim()
if (!hasContent && !hasReasoning) {
return normalized
}
const completedAt =
normalized.completedAt ??
normalized.reasoning?.completedAt ??
normalized.startedAt ??
normalized.createdAt ??
Date.now()
return completeAssistantTiming(
{
...finalizeMessage(normalized),
status: MESSAGE_STATUS.COMPLETE,
isReasoningStreaming: false,
},
completedAt
)
}
function trimMessagesByContentSize(messages: Message[]): Message[] {
let totalSize = 0
const result: Message[] = []
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index]
const messageSize = getMessageSize(message)
if (
result.length > 0 &&
totalSize + messageSize > MAX_LOADED_MESSAGES_CHARS
) {
break
}
totalSize += messageSize
result.push(message)
}
return result.reverse()
}
/**
* Load playground config from localStorage
*/
export function loadConfig(): Partial<PlaygroundConfig> {
try {
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)
}
return {}
}
/**
* Save playground config to localStorage
*/
export function saveConfig(config: Partial<PlaygroundConfig>): void {
try {
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)
}
}
/**
* Load parameter enabled state from localStorage
*/
export function loadParameterEnabled(): Partial<ParameterEnabled> {
try {
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)
}
return {}
}
/**
* Save parameter enabled state to localStorage
*/
export function saveParameterEnabled(
parameterEnabled: Partial<ParameterEnabled>
): void {
try {
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)
}
}
/**
* Load messages from localStorage
*/
export function loadMessages(): Message[] | null {
try {
const saved = readStoredMessagesValue()
if (!saved) return null
const parsed = messagesSchema.parse(unwrapStoredValue(saved)) as Message[]
const normalized = parsed.map(normalizeStoredMessageForLoad)
const normalizedChanged = normalized.some(
(message, index) => message !== parsed[index]
)
const trimmed = trimMessages(normalized)
const sizeTrimmed = trimMessagesByContentSize(trimmed)
const sanitized = sanitizeMessagesOnLoad(sizeTrimmed)
if (
normalizedChanged ||
trimmed !== normalized ||
sizeTrimmed !== trimmed ||
sanitized !== sizeTrimmed
) {
saveMessages(sanitized)
}
return sanitized
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load messages:', error)
}
return null
}
/**
* Save messages to localStorage
*/
export function saveMessages(messages: Message[]): void {
try {
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)
}
}
/**
* Clear all playground data
*/
export function clearPlaygroundData(): void {
try {
localStorage.removeItem(STORAGE_KEYS.CONFIG)
localStorage.removeItem(STORAGE_KEYS.PARAMETER_ENABLED)
localStorage.removeItem(STORAGE_KEYS.MESSAGES)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to clear playground data:', error)
}
}
@@ -0,0 +1,72 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type {
ChatCompletionRequest,
Message,
PlaygroundConfig,
ParameterEnabled,
} from '../../types'
import { formatMessageForAPI, isValidMessage } from '../message/message-utils'
/**
* Build API request payload from messages and config
*/
export function buildChatCompletionPayload(
messages: Message[],
config: PlaygroundConfig,
parameterEnabled: ParameterEnabled
): ChatCompletionRequest {
// Filter and format valid messages
const processedMessages = messages
.filter(isValidMessage)
.map(formatMessageForAPI)
const payload: ChatCompletionRequest = {
model: config.model,
group: config.group,
messages: processedMessages,
stream: config.stream,
}
if (parameterEnabled.temperature) {
payload.temperature = config.temperature
}
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,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,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
}
+151
View File
@@ -0,0 +1,151 @@
/*
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
*/
// Message types
export type MessageRole = 'user' | 'assistant' | 'system'
export type MessageStatus = 'loading' | 'streaming' | 'complete' | 'error'
export type PlaygroundMessageLayoutMode = 'alternating' | 'left'
export interface MessageVersion {
id: string
content: string
}
export interface Message {
key: string
from: MessageRole
versions: MessageVersion[]
createdAt?: number
startedAt?: number
completedAt?: number
durationMs?: number
sources?: { href: string; title: string }[]
reasoning?: {
content: string
duration: number
startedAt?: number
completedAt?: number
durationMs?: number
}
isReasoningStreaming?: boolean
isReasoningComplete?: boolean
isContentComplete?: boolean
status?: MessageStatus
errorCode?: string | null
}
// API payload types
export interface ChatCompletionMessage {
role: MessageRole
content: string | ContentPart[]
}
export interface ContentPart {
type: 'text' | 'image_url'
text?: string
image_url?: {
url: string
}
}
export interface ChatCompletionRequest {
model: string
group?: string
messages: ChatCompletionMessage[]
stream: boolean
temperature?: number
top_p?: number
max_tokens?: number
frequency_penalty?: number
presence_penalty?: number
seed?: number
}
export interface ChatCompletionChunk {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
delta: {
role?: MessageRole
content?: string
reasoning_content?: string
}
finish_reason: string | null
}>
}
export interface ChatCompletionResponse {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
message: {
role: MessageRole
content: string
reasoning_content?: string
}
finish_reason: string
}>
usage?: {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
}
// Configuration types
export interface PlaygroundConfig {
model: string
group: string
temperature: number
top_p: number
max_tokens: number
frequency_penalty: number
presence_penalty: number
seed: number | null
stream: boolean
}
export interface ParameterEnabled {
temperature: boolean
top_p: boolean
max_tokens: boolean
frequency_penalty: boolean
presence_penalty: boolean
seed: boolean
}
// Model and group options
export interface ModelOption {
label: string
value: string
}
export interface GroupOption {
label: string
value: string
ratio: number
desc?: string
}