feat(playground): improve Playground chat experience and Markdown rendering (#5217)
* refactor(playground): streamline chat request state - extract conversation actions from the page component to keep message flow logic reusable. - unify streaming and non-streaming generation state, including abort support for non-stream requests. - simplify message rendering and payload construction while localizing Playground prompts. * fix(playground): validate persisted chat state - wrap saved Playground state with a storage version while still reading legacy values. - validate config, parameter toggles, and messages before restoring them from localStorage. - cap stored chat history to the latest messages to avoid oversized or stale state. * refactor(playground): centralize message content access - route chat rendering, copy actions, and error display through shared message helpers. - reuse the current-version update helper for non-streaming assistant responses. - keep message version details behind utility functions to reduce future model churn. * refactor(playground): split storage schemas - move Playground storage validation schemas into a dedicated module. - keep storage read and write logic focused on migration, trimming, and persistence. - preserve the existing storage envelope and validation behavior. * refactor(playground): extract options loading hook - move model and group queries into a dedicated hook so the page component stays focused on layout wiring. - preserve existing fallback selection and error toast behavior while reusing the hook through the playground barrel export. * refactor(playground): extract prompt suggestions - move static prompt suggestion rendering into a focused component so the input stays centered on compose controls. - preserve translated suggestion submission behavior while isolating icon metadata from the input form. * refactor(playground): extract input tools - move attachment and search controls into a dedicated component so the prompt input stays focused on compose state. - keep existing development toast behavior and disabled handling while centralizing tool metadata. * refactor(playground): extract input controls - move model, group, send, and stop controls into a focused component so the input only manages compose state. - preserve existing disabled states and generation button behavior while isolating control rendering. * refactor(playground): extract message content display - move sources, reasoning, loading, error, and response rendering into a dedicated message content component. - keep the chat list focused on message iteration, edit state, and action wiring without changing display behavior. * refactor(playground): extract message editor - move inline message editing controls into a dedicated editor component so the chat list stays focused on rendering flow. - preserve save, save-and-submit, cancel, and disabled-state behavior for edited messages. * refactor(playground): extract stream error parsing - move SSE error payload parsing into a reusable stream utility so the request hook stays focused on lifecycle handling. - preserve existing error message, error code, and fallback behavior for raw or empty stream errors. * refactor(playground): extract request error parsing - move non-stream request error extraction into a shared utility so the chat handler stays focused on request flow. - preserve the existing response message, error code, and fallback priority for failed chat completions. * refactor(playground): extract streaming chunk updates - move reasoning and content chunk application into a message utility so the chat handler only wires stream events. - preserve error-state skipping, reasoning accumulation, and content streaming behavior for assistant messages. * refactor(playground): extract message reasoning parser - move think tag parsing into a dedicated playground message utility. - export the parser through the shared playground lib barrel for consistent imports. * refactor(playground): extract message streaming utilities - move stream chunk application and message finalization into a dedicated utility. - keep stored message sanitization with the streaming lifecycle helpers. * refactor(playground): extract message update utilities - move assistant message update helpers into a focused playground utility. - keep error-state message updates separate from core message construction helpers. * refactor(playground): extract completion choice handling - move non-streaming choice application into the message streaming utilities. - keep the chat handler focused on request orchestration and message updates. * refactor(playground): centralize assistant completion state - add a helper for finalizing assistant messages with complete status. - reuse the helper in stream completion and stop-generation paths. * refactor(playground): extract stream message parsing - move SSE delta parsing into a shared stream utility. - keep the stream request hook focused on lifecycle handling and update dispatch. * refactor(playground): extract stream ready state checks - move SSE ready-state status handling into stream utilities. - keep weak source status typing outside the stream request hook. * refactor(playground): extract conversation message helpers - move send, regenerate, and edit message list construction into focused utilities. - keep the conversation hook focused on edit state and update dispatch. * refactor(playground): extract state initialization helpers - move playground initial state loading into focused utility helpers. - centralize message state updater resolution outside the React state hook. * refactor(playground): extract option fallback helpers - move model and group fallback selection into focused playground utilities. - keep the options hook focused on query results, toasts, and config updates. * refactor(playground): extract message action helpers - move message action state derivation into focused utilities. - keep the action component focused on guarded handlers and rendering. * refactor(playground): extract input control state - move submit, stop, and selector state derivation into a pure helper. - keep input controls focused on rendering model selectors and action buttons. * refactor(playground): extract message content state - move source, reasoning, loader, and body visibility checks into a pure helper. - use a discriminated state shape so rendered reasoning content stays type-safe. * refactor(playground): extract message editor state - move save eligibility and submit visibility checks into a pure helper. - keep the editor component focused on textarea and button rendering. * refactor(playground): extract message error state - move error kind, fallback content, and admin visibility checks into a pure helper. - centralize the model pricing settings path used by the error action. * refactor(playground): extract chat render state - move editing content lookup and per-message render flags into conversation helpers. - keep the chat component focused on mapping messages to editor and content views. * refactor(playground): extract suggestion display state - move suggestion class selection into a pure helper. - keep the suggestions component focused on translation and rendering. * refactor(playground): extract assistant message state checks - move final and pending assistant status checks into streaming utilities. - keep the chat handler focused on request lifecycle updates. * refactor(playground): extract input tool state - move attachment action metadata and development notices into input tool utilities. - keep the input tools component focused on menu and button rendering. * refactor(playground): extract stream protocol checks - move SSE done-message and closed-ready-state checks into stream utilities. - keep the stream request hook focused on event handling flow. * refactor(playground): extract message removal helper - move delete-message filtering into conversation message utilities. - keep the conversation hook focused on action orchestration. * refactor(playground): extract option error messages - move option load error message selection into playground option utilities - keep the options hook focused on query effects and fallback updates * refactor(playground): extract input submit text helper - move prompt submit text validation into input control utilities - let the input component submit only when a concrete text value is available * refactor(playground): centralize error message checks - add a shared helper for identifying error messages - remove direct status string checks from message content rendering * refactor(playground): extract message content display checks - move loader and content visibility decisions into local helper functions - keep message content state assembly focused on composing render state * refactor(playground): replace raw message role checks - use shared message role constants in conversation edit handling - avoid raw assistant role literals when validating API messages * refactor(playground): extract non-stream response handling - move chat completion response choice handling into message streaming utilities - keep the chat handler focused on request lifecycle and error routing * refactor(playground): centralize stream cleanup - reuse one stream cleanup path for completion, errors, startup failures, and manual stops - preserve the current-source guard when closing SSE streams * refactor(playground): extract pending assistant check - centralize pending assistant message detection in streaming utilities - reuse the helper when sanitizing stored playground messages * perf(playground): improve mobile input controls - split mobile input controls into selector and action rows - keep the desktop input footer compact while reducing mobile control crowding * perf(playground): add starter empty state - show starter prompts in the empty playground chat area - wire empty-state prompt selection into the existing send flow - add localized copy for the new empty state * perf(playground): improve mobile message actions - collapse mobile message actions into a touch-friendly dropdown menu - keep the desktop hover action strip unchanged for pointer workflows - share one action list between desktop buttons and the mobile menu * perf(playground): add error recovery actions - show retry, edit, and delete actions inside error message alerts - route edit recovery to the previous user prompt when available - keep recovery controls touch-friendly on mobile layouts * perf(playground): refine message editing experience - present message edits in a focused bordered editor panel - add unsaved-change state, reset, and cancel confirmation flows - improve mobile touch targets and keyboard shortcuts for editing * perf(playground): improve markdown code blocks - render fenced markdown code with syntax highlighting, line numbers, and fallback plain text - add copy, download, and collapse controls for playground AI responses - tighten code block layout and theme token styles for responsive markdown rendering * fix(playground): constrain markdown code block height - collapse long playground code blocks after a short preview instead of waiting for very large snippets - cap expanded code blocks so long responses scroll inside the code block - keep generic code block usage unconstrained unless a caller opts in * feat(playground): add chat history clearing - add a toolbar action that is enabled only when saved playground messages exist. - confirm destructive clears before removing browser-stored conversation state. - add localized strings for the action, dialog, and completion toast. * perf(playground): improve chat markdown rendering - refine assistant and user message surfaces so chat content matches the app UI. - normalize markdown typography, tables, images, lists, blockquotes, and details rendering. - add indentation cues for collapsible reasoning and source sections. * style: format code block component * style: format playground frontend files * feat(playground): render markdown with stream parser - replace Streamdown with stream-markdown-parser for project-owned markdown rendering and styling. - split response rendering into focused block, inline, table, alert, details, and footnote modules. - pass message final state into response parsing so streaming content can be parsed incrementally. * fix(playground): localize reasoning and chat feedback - translate reasoning status, message actions, playground errors, and response renderer fallbacks across supported locales. - keep reasoning duration numeric and tighten the collapsible layout to prevent trigger jitter. - register dynamic keys so i18n sync keeps runtime labels covered. * refactor(playground): group files by functional area - move chat, input, and message components into focused subdirectories to make the UI structure easier to scan. - split playground helpers into input, message, streaming, storage, options, state, and suggestions modules. - update barrel exports and imports so existing feature entry points continue to work. * fix(playground): prevent history replay from freezing page - defer saved conversation loading so route entry no longer blocks on localStorage parsing and markdown rendering. - limit initial history rendering and skip expensive markdown parsing for oversized responses. - normalize corrupted streaming snapshots and cumulative chunks to keep saved playground history bounded. - add message timing metadata and layout alignment groundwork without introducing live timers. * feat(playground): allow regenerating from user messages - show regenerate actions on user messages with saved content. - truncate following conversation state before starting a fresh assistant response. * feat(playground): add raw response source view - add a per-message source toggle for assistant responses. - render raw response content with the existing code block viewer. - localize the new source and preview action labels. * feat(playground): render code with unified editor - replace Shiki HTML rendering with a read-only CodeMirror view for code blocks and raw responses. - reuse the same CodeMirror frame for message editing so source and edit modes stay visually aligned. - add lightweight CodeMirror dependencies while keeping language support scoped to Markdown. * perf(playground): streamline chat input controls - combine model and group selection into one compact picker for faster context switching. - switch playground action buttons to icon-first controls with tooltips to reduce toolbar width. - refresh input footer styling and submit states so active and destructive actions are clearer. - bump dompurify lockfile entry to keep the frontend dependency current. * fix(playground): filter models by selected group - query user models by the selected playground group instead of reusing the cross-group model union. - clear unavailable model selections and block sending when the active group has no models. - align model selector and error action controls with the existing playground interaction style. * perf(playground): remove input suggestion chips - remove the prompt suggestion row below the playground input to reduce visual noise. - delete the now-unused suggestion component and display helper. * perf(playground): stabilize reasoning trigger layout - use fixed icon slots around the reasoning label so the left content stays still when toggling. - limit the open state animation to the chevron rotation for a smoother collapse interaction. * perf(playground): smooth reasoning expansion - use the collapsible panel height animation for vertical reasoning reveals. - sync inner content opacity and position with the panel state.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
+84
@@ -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>
|
||||
)
|
||||
}
|
||||
+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 { 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>
|
||||
)
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
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'
|
||||
|
||||
type PlaygroundInputToolsProps = {
|
||||
disabled?: boolean
|
||||
hasMessages?: boolean
|
||||
onClearMessages?: () => void
|
||||
}
|
||||
|
||||
export function PlaygroundInputTools({
|
||||
disabled,
|
||||
hasMessages = false,
|
||||
onClearMessages,
|
||||
}: PlaygroundInputToolsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false)
|
||||
|
||||
const handleFileAction = (action: string) => {
|
||||
const notice = getAttachmentActionNotice(action)
|
||||
toast.info(t(notice.title), {
|
||||
description: notice.description,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearchAction = () => {
|
||||
const notice = getSearchActionNotice()
|
||||
toast.info(t(notice.title))
|
||||
}
|
||||
|
||||
const handleClearMessages = () => {
|
||||
onClearMessages?.()
|
||||
setClearConfirmOpen(false)
|
||||
toast.success(t('Conversation cleared'))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PromptInputTools 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>
|
||||
|
||||
<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,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 { 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 } from '../../types'
|
||||
import { PlaygroundInputControls } from './playground-input-controls'
|
||||
import { PlaygroundInputTools } from './playground-input-tools'
|
||||
|
||||
interface PlaygroundInputProps {
|
||||
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
|
||||
onClearMessages?: () => void
|
||||
}
|
||||
|
||||
export function PlaygroundInput({
|
||||
onSubmit,
|
||||
onStop,
|
||||
disabled,
|
||||
isGenerating,
|
||||
models,
|
||||
modelValue,
|
||||
onModelChange,
|
||||
isModelLoading = false,
|
||||
groups,
|
||||
groupValue,
|
||||
onGroupChange,
|
||||
hasMessages = false,
|
||||
onClearMessages,
|
||||
}: 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
|
||||
disabled={disabled}
|
||||
hasMessages={hasMessages}
|
||||
onClearMessages={onClearMessages}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
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 { Copy, Check, RefreshCw, Edit, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { MESSAGE_ACTION_LABELS } from '../constants'
|
||||
import { useMessageActionGuard } from '../hooks/use-message-action-guard'
|
||||
import type { Message } from '../types'
|
||||
import { MessageActionButton } from './message-action-button'
|
||||
|
||||
interface MessageActionsProps {
|
||||
message: Message
|
||||
onCopy?: (message: Message) => void
|
||||
onRegenerate?: (message: Message) => void
|
||||
onEdit?: (message: Message) => void
|
||||
onDelete?: (message: Message) => void
|
||||
isGenerating?: boolean
|
||||
alwaysVisible?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MessageActions({
|
||||
message,
|
||||
onCopy,
|
||||
onRegenerate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isGenerating = false,
|
||||
alwaysVisible = false,
|
||||
className = '',
|
||||
}: MessageActionsProps) {
|
||||
const { copiedText, copyToClipboard } = useCopyToClipboard()
|
||||
const { guardAction } = useMessageActionGuard(isGenerating)
|
||||
|
||||
const isAssistant = message.from === 'assistant'
|
||||
const hasContent = message.versions.some((v) => v.content)
|
||||
const isLoading =
|
||||
message.status === 'loading' || message.status === 'streaming'
|
||||
const content = message.versions[0]?.content || ''
|
||||
const isCopied = copiedText === content
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!content) {
|
||||
toast.warning(MESSAGE_ACTION_LABELS.NO_CONTENT)
|
||||
return
|
||||
}
|
||||
copyToClipboard(content)
|
||||
onCopy?.(message)
|
||||
}
|
||||
|
||||
const handleRegenerate = guardAction(() => onRegenerate?.(message))
|
||||
const handleEdit = guardAction(() => onEdit?.(message))
|
||||
const handleDelete = guardAction(() => onDelete?.(message))
|
||||
|
||||
const visibilityClass = alwaysVisible
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 max-md:opacity-100'
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={300}>
|
||||
<div
|
||||
className={`flex items-center gap-0.5 transition-opacity ${visibilityClass} ${className}`}
|
||||
>
|
||||
{/* Copy */}
|
||||
{hasContent && (
|
||||
<MessageActionButton
|
||||
icon={isCopied ? Check : Copy}
|
||||
label={
|
||||
isCopied
|
||||
? MESSAGE_ACTION_LABELS.COPIED
|
||||
: MESSAGE_ACTION_LABELS.COPY
|
||||
}
|
||||
onClick={handleCopy}
|
||||
className={isCopied ? 'text-green-600' : ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Regenerate - only for assistant messages */}
|
||||
{isAssistant && !isLoading && onRegenerate && (
|
||||
<MessageActionButton
|
||||
icon={RefreshCw}
|
||||
label={MESSAGE_ACTION_LABELS.REGENERATE}
|
||||
onClick={handleRegenerate}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit */}
|
||||
{hasContent && onEdit && (
|
||||
<MessageActionButton
|
||||
icon={Edit}
|
||||
label={MESSAGE_ACTION_LABELS.EDIT}
|
||||
onClick={handleEdit}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
{onDelete && (
|
||||
<MessageActionButton
|
||||
icon={Trash2}
|
||||
label={MESSAGE_ACTION_LABELS.DELETE}
|
||||
onClick={handleDelete}
|
||||
disabled={isGenerating}
|
||||
variant='destructive'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -23,7 +23,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { MESSAGE_ACTION_BUTTON_STYLES } from '../constants'
|
||||
import { MESSAGE_ACTION_BUTTON_STYLES } from '../../constants'
|
||||
|
||||
interface MessageActionButtonProps {
|
||||
icon: LucideIcon
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+74
@@ -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>
|
||||
)
|
||||
}
|
||||
+37
-16
@@ -1,3 +1,4 @@
|
||||
import { AlertCircle, AlertTriangle, Settings } from 'lucide-react'
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
@@ -16,54 +17,67 @@ 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'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
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 = '' }: MessageErrorProps) {
|
||||
export function MessageError({
|
||||
message,
|
||||
className = '',
|
||||
actions,
|
||||
}: MessageErrorProps) {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthStore((s) => s.auth.user)
|
||||
const isAdmin = user?.role != null && user.role >= 10
|
||||
const errorState = getMessageErrorState(message, isAdminRole(user?.role))
|
||||
|
||||
if (message.status !== MESSAGE_STATUS.ERROR) {
|
||||
if (!errorState) {
|
||||
return null
|
||||
}
|
||||
|
||||
const errorContent =
|
||||
message.versions[0]?.content || 'An unknown error occurred'
|
||||
if (errorState.kind === 'model-price') {
|
||||
const content =
|
||||
errorState.content === FALLBACK_ERROR_CONTENT
|
||||
? t(FALLBACK_ERROR_CONTENT)
|
||||
: errorState.content
|
||||
|
||||
if (message.errorCode === 'model_price_error') {
|
||||
return (
|
||||
<Alert variant='default' className={className}>
|
||||
<AlertTriangle className='text-orange-500' />
|
||||
<AlertTitle>{t('Model Price Not Configured')}</AlertTitle>
|
||||
<AlertDescription className='space-y-2'>
|
||||
<p>{errorContent}</p>
|
||||
{isAdmin && (
|
||||
<p>{content}</p>
|
||||
{errorState.showSettingsLink && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
window.open('/system-settings/billing/model-pricing', '_blank')
|
||||
}
|
||||
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>
|
||||
)
|
||||
@@ -73,7 +87,14 @@ export function MessageError({ message, className = '' }: MessageErrorProps) {
|
||||
<Alert variant='destructive' className={className}>
|
||||
<AlertCircle />
|
||||
<AlertTitle>{t('Error')}</AlertTitle>
|
||||
<AlertDescription>{errorContent}</AlertDescription>
|
||||
<AlertDescription className='space-y-2'>
|
||||
<p>
|
||||
{errorState.content === 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>
|
||||
)
|
||||
}
|
||||
+167
@@ -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>
|
||||
)
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CodeBlockEditor } from '@/components/ai-elements/code-block'
|
||||
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 { canSave, hasChanged, showSaveAndSubmit } = getMessageEditorState(
|
||||
message,
|
||||
editText,
|
||||
originalText
|
||||
)
|
||||
|
||||
const handleCancel = () => {
|
||||
if (
|
||||
hasChanged &&
|
||||
!window.confirm(
|
||||
t('You have unsaved changes. Are you sure you want to leave?')
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
onCancelEdit?.(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (!canSave) return
|
||||
|
||||
if (showSaveAndSubmit) {
|
||||
onSaveEditAndSubmit?.(editText)
|
||||
} else {
|
||||
onSaveEdit?.(editText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
/*
|
||||
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, useMemo, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Branch,
|
||||
BranchMessages,
|
||||
BranchNext,
|
||||
BranchPage,
|
||||
BranchPrevious,
|
||||
BranchSelector,
|
||||
} from '@/components/ai-elements/branch'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import { Loader } from '@/components/ai-elements/loader'
|
||||
import { Message, 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 { MESSAGE_ROLES } from '../constants'
|
||||
import { getMessageContentStyles } from '../lib/message-styles'
|
||||
import { parseThinkTags } from '../lib/message-utils'
|
||||
import type { Message as MessageType } from '../types'
|
||||
import { MessageActions } from './message-actions'
|
||||
import { MessageError } from './message-error'
|
||||
|
||||
interface PlaygroundChatProps {
|
||||
messages: MessageType[]
|
||||
onCopyMessage?: (message: MessageType) => void
|
||||
onRegenerateMessage?: (message: MessageType) => void
|
||||
onEditMessage?: (message: MessageType) => void
|
||||
onDeleteMessage?: (message: MessageType) => void
|
||||
isGenerating?: boolean
|
||||
editingKey?: string | null
|
||||
onSaveEdit?: (newContent: string) => void
|
||||
onCancelEdit?: (open: boolean) => void
|
||||
onSaveEditAndSubmit?: (newContent: string) => void
|
||||
}
|
||||
|
||||
export function PlaygroundChat({
|
||||
messages,
|
||||
onCopyMessage,
|
||||
onRegenerateMessage,
|
||||
onEditMessage,
|
||||
onDeleteMessage,
|
||||
isGenerating = false,
|
||||
editingKey,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
onSaveEditAndSubmit,
|
||||
}: PlaygroundChatProps) {
|
||||
const [editText, setEditText] = useState('')
|
||||
const [originalText, setOriginalText] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingKey) return
|
||||
const message = messages.find((m) => m.key === editingKey)
|
||||
const content = message?.versions?.[0]?.content || ''
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setEditText(content)
|
||||
|
||||
setOriginalText(content)
|
||||
}, [editingKey, messages])
|
||||
|
||||
const isEditing = (key: string) => editingKey === key
|
||||
const isEmpty = useMemo(() => !editText.trim(), [editText])
|
||||
const isChanged = useMemo(
|
||||
() => editText !== originalText,
|
||||
[editText, originalText]
|
||||
)
|
||||
return (
|
||||
<Conversation>
|
||||
{/* Remove outer padding; apply padding to inner centered container to align with input */}
|
||||
<ConversationContent className='p-0'>
|
||||
<div className='mx-auto w-full max-w-4xl px-4 py-4'>
|
||||
{messages.map((message, messageIndex) => {
|
||||
const { versions = [] } = message
|
||||
const isLastAssistantMessage =
|
||||
messageIndex === messages.length - 1 &&
|
||||
message.from === MESSAGE_ROLES.ASSISTANT
|
||||
return (
|
||||
<Branch defaultBranch={0} key={message.key}>
|
||||
<BranchMessages>
|
||||
{versions.map((version, versionIndex) => (
|
||||
<Message
|
||||
className='group flex-row-reverse'
|
||||
from={message.from}
|
||||
key={`${message.key}-${version.id}-${versionIndex}`}
|
||||
>
|
||||
<div className='w-full min-w-0 flex-1 basis-full py-1'>
|
||||
{isEditing(message.key) ? (
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
className='font-mono text-sm'
|
||||
rows={8}
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
{/* Save & Submit only makes sense for user messages */}
|
||||
{message.from === MESSAGE_ROLES.USER && (
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
onSaveEditAndSubmit?.(editText)
|
||||
}
|
||||
disabled={isEmpty || !isChanged}
|
||||
>
|
||||
Save & Submit
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => onSaveEdit?.(editText)}
|
||||
disabled={isEmpty || !isChanged}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => onCancelEdit?.(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
const isAssistant =
|
||||
message.from === MESSAGE_ROLES.ASSISTANT
|
||||
const hasSources = !!message.sources?.length
|
||||
const showReasoning =
|
||||
isAssistant && !!message.reasoning?.content
|
||||
const showLoader =
|
||||
isAssistant &&
|
||||
!message.isReasoningStreaming &&
|
||||
(message.status === 'loading' ||
|
||||
(message.status === 'streaming' &&
|
||||
!version.content))
|
||||
const showMessageContent =
|
||||
(message.from === MESSAGE_ROLES.USER ||
|
||||
!message.isReasoningStreaming) &&
|
||||
!!version.content
|
||||
|
||||
// Extract visible content (remove <think> tags for assistant messages)
|
||||
const displayContent = isAssistant
|
||||
? parseThinkTags(version.content).visibleContent
|
||||
: version.content
|
||||
|
||||
const actions = (
|
||||
<MessageActions
|
||||
message={message}
|
||||
onCopy={onCopyMessage}
|
||||
onRegenerate={onRegenerateMessage}
|
||||
onEdit={onEditMessage}
|
||||
onDelete={onDeleteMessage}
|
||||
isGenerating={isGenerating}
|
||||
alwaysVisible={isLastAssistantMessage}
|
||||
className='mt-1'
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sources */}
|
||||
{hasSources && (
|
||||
<Sources>
|
||||
<SourcesTrigger
|
||||
count={message.sources!.length}
|
||||
/>
|
||||
<SourcesContent>
|
||||
{message.sources!.map(
|
||||
(source, sourceIndex) => (
|
||||
<Source
|
||||
href={source.href}
|
||||
key={`${message.key}-source-${sourceIndex}`}
|
||||
title={source.title}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</SourcesContent>
|
||||
</Sources>
|
||||
)}
|
||||
|
||||
{/* Reasoning */}
|
||||
{showReasoning && (
|
||||
<Reasoning
|
||||
defaultOpen={true}
|
||||
isStreaming={message.isReasoningStreaming}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
{message.reasoning!.content}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
)}
|
||||
|
||||
{/* Loader */}
|
||||
{showLoader && (
|
||||
<div className='flex items-center gap-2 py-2'>
|
||||
<Loader />
|
||||
<Shimmer className='text-sm' duration={1}>
|
||||
Responding...
|
||||
</Shimmer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error or Content */}
|
||||
{message.status === 'error' ? (
|
||||
<>
|
||||
<MessageError
|
||||
message={message}
|
||||
className='mb-2'
|
||||
/>
|
||||
{actions}
|
||||
</>
|
||||
) : (
|
||||
showMessageContent && (
|
||||
<>
|
||||
<MessageContent
|
||||
variant='flat'
|
||||
className={cn(
|
||||
getMessageContentStyles()
|
||||
)}
|
||||
>
|
||||
<Response>{displayContent}</Response>
|
||||
</MessageContent>
|
||||
{actions}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Message>
|
||||
))}
|
||||
</BranchMessages>
|
||||
|
||||
{/* Branch selector for multiple versions */}
|
||||
{versions.length > 1 && (
|
||||
<BranchSelector className='px-0' from={message.from}>
|
||||
<BranchPrevious />
|
||||
<BranchPage />
|
||||
<BranchNext />
|
||||
</BranchSelector>
|
||||
)}
|
||||
</Branch>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
)
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
/*
|
||||
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 {
|
||||
PaperclipIcon,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
ScreenShareIcon,
|
||||
CameraIcon,
|
||||
GlobeIcon,
|
||||
SendIcon,
|
||||
SquareIcon,
|
||||
BarChartIcon,
|
||||
BoxIcon,
|
||||
NotepadTextIcon,
|
||||
CodeSquareIcon,
|
||||
GraduationCapIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputButton,
|
||||
PromptInputFooter,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
type PromptInputMessage,
|
||||
} from '@/components/ai-elements/prompt-input'
|
||||
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion'
|
||||
import { ModelGroupSelector } from '@/components/model-group-selector'
|
||||
import type { ModelOption, GroupOption } from '../types'
|
||||
|
||||
interface PlaygroundInputProps {
|
||||
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
|
||||
}
|
||||
|
||||
const suggestions = [
|
||||
{ icon: BarChartIcon, text: 'Analyze data', color: '#76d0eb' },
|
||||
{ icon: BoxIcon, text: 'Surprise me', color: '#76d0eb' },
|
||||
{ icon: NotepadTextIcon, text: 'Summarize text', color: '#ea8444' },
|
||||
{ icon: CodeSquareIcon, text: 'Code', color: '#6c71ff' },
|
||||
{ icon: GraduationCapIcon, text: 'Get advice', color: '#76d0eb' },
|
||||
{ icon: null, text: 'More' },
|
||||
]
|
||||
|
||||
export function PlaygroundInput({
|
||||
onSubmit,
|
||||
onStop,
|
||||
disabled,
|
||||
isGenerating,
|
||||
models,
|
||||
modelValue,
|
||||
onModelChange,
|
||||
isModelLoading = false,
|
||||
groups,
|
||||
groupValue,
|
||||
onGroupChange,
|
||||
}: PlaygroundInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const [text, setText] = useState('')
|
||||
|
||||
const isModelSelectDisabled =
|
||||
disabled || isModelLoading || models.length === 0
|
||||
const isGroupSelectDisabled = disabled || groups.length === 0
|
||||
|
||||
const handleSubmit = (message: PromptInputMessage) => {
|
||||
if (!message.text?.trim() || disabled) return
|
||||
onSubmit(message.text)
|
||||
setText('')
|
||||
}
|
||||
|
||||
const handleFileAction = (action: string) => {
|
||||
toast.info(t('Feature in development'), {
|
||||
description: action,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
onSubmit(suggestion)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid shrink-0 gap-4 px-1 md:pb-4'>
|
||||
<PromptInput groupClassName='rounded-xl' onSubmit={handleSubmit}>
|
||||
<PromptInputTextarea
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck={false}
|
||||
className='px-5 md:text-base'
|
||||
disabled={disabled}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder={t('Ask anything')}
|
||||
value={text}
|
||||
/>
|
||||
|
||||
<PromptInputFooter className='p-2.5'>
|
||||
<PromptInputTools>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PromptInputButton
|
||||
className='border font-medium'
|
||||
disabled={disabled}
|
||||
variant='outline'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Attach')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Attach')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('upload-file')}
|
||||
>
|
||||
<FileIcon className='mr-2' size={16} />
|
||||
{t('Upload file')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('upload-photo')}
|
||||
>
|
||||
<ImageIcon className='mr-2' size={16} />
|
||||
{t('Upload photo')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('take-screenshot')}
|
||||
>
|
||||
<ScreenShareIcon className='mr-2' size={16} />
|
||||
{t('Take screenshot')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('take-photo')}
|
||||
>
|
||||
<CameraIcon className='mr-2' size={16} />
|
||||
{t('Take photo')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptInputButton
|
||||
className='border font-medium'
|
||||
disabled={disabled}
|
||||
onClick={() => toast.info(t('Search feature in development'))}
|
||||
variant='outline'
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Search')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Search')}</span>
|
||||
</PromptInputButton>
|
||||
</PromptInputTools>
|
||||
|
||||
<div className='flex items-center gap-1.5 md:gap-2'>
|
||||
<ModelGroupSelector
|
||||
selectedModel={modelValue}
|
||||
models={models}
|
||||
onModelChange={onModelChange}
|
||||
selectedGroup={groupValue}
|
||||
groups={groups}
|
||||
onGroupChange={onGroupChange}
|
||||
disabled={isModelSelectDisabled || isGroupSelectDisabled}
|
||||
/>
|
||||
|
||||
{isGenerating && onStop ? (
|
||||
<PromptInputButton
|
||||
className='text-foreground font-medium'
|
||||
onClick={onStop}
|
||||
variant='secondary'
|
||||
>
|
||||
<SquareIcon className='fill-current' size={16} />
|
||||
<span className='hidden sm:inline'>{t('Stop')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Stop')}</span>
|
||||
</PromptInputButton>
|
||||
) : (
|
||||
<PromptInputButton
|
||||
className='text-foreground font-medium'
|
||||
disabled={disabled || !text.trim()}
|
||||
type='submit'
|
||||
variant='secondary'
|
||||
>
|
||||
<SendIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Send')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Send')}</span>
|
||||
</PromptInputButton>
|
||||
)}
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
|
||||
<Suggestions>
|
||||
{suggestions.map(({ icon: Icon, text, color }) => (
|
||||
<Suggestion
|
||||
className={`text-xs font-normal sm:text-sm ${
|
||||
text === 'More' ? 'hidden sm:flex' : ''
|
||||
}`}
|
||||
key={text}
|
||||
onClick={() => handleSuggestionClick(text)}
|
||||
suggestion={text}
|
||||
>
|
||||
{Icon && <Icon size={16} style={{ color }} />}
|
||||
{text}
|
||||
</Suggestion>
|
||||
))}
|
||||
</Suggestions>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user