feat: publish v1.0.0-rc.36-custom.1
This commit is contained in:
commit
3a7e8f3b54
2381 files changed
+551494
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
+125
@@ -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')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user