Commit 966af88e by QuentinHsu Committed by GitHub

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.
parent df44a75d
...@@ -26,6 +26,11 @@ type listModelsResponse struct { ...@@ -26,6 +26,11 @@ type listModelsResponse struct {
Object string `json:"object"` Object string `json:"object"`
} }
type userModelsResponse struct {
Success bool `json:"success"`
Data []string `json:"data"`
}
func setupModelListControllerTestDB(t *testing.T) *gorm.DB { func setupModelListControllerTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
...@@ -147,6 +152,50 @@ func pricingByModelName(pricings []model.Pricing) map[string]model.Pricing { ...@@ -147,6 +152,50 @@ func pricingByModelName(pricings []model.Pricing) map[string]model.Pricing {
return byName return byName
} }
func decodeUserModelsResponse(t *testing.T, recorder *httptest.ResponseRecorder) []string {
t.Helper()
require.Equal(t, http.StatusOK, recorder.Code)
var payload userModelsResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
require.True(t, payload.Success)
return payload.Data
}
func TestGetUserModelsFiltersByRequestedGroup(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.Create(&model.User{
Id: 1002,
Username: "playground-model-user",
Password: "password",
Group: "default",
Status: common.UserStatusEnabled,
}).Error)
require.NoError(t, db.Create(&[]model.Ability{
{Group: "default", Model: "zz-default-only-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-disabled-model", ChannelId: 1, Enabled: false},
}).Error)
defaultRecorder := httptest.NewRecorder()
defaultContext, _ := gin.CreateTestContext(defaultRecorder)
defaultContext.Request = httptest.NewRequest(http.MethodGet, "/api/user/models?group=default", nil)
defaultContext.Set("id", 1002)
GetUserModels(defaultContext)
defaultModels := decodeUserModelsResponse(t, defaultRecorder)
require.ElementsMatch(t, []string{"zz-default-only-model"}, defaultModels)
vipRecorder := httptest.NewRecorder()
vipContext, _ := gin.CreateTestContext(vipRecorder)
vipContext.Request = httptest.NewRequest(http.MethodGet, "/api/user/models?group=vip", nil)
vipContext.Set("id", 1002)
GetUserModels(vipContext)
require.Empty(t, decodeUserModelsResponse(t, vipRecorder))
}
func TestListModelsIncludesTieredBillingModel(t *testing.T) { func TestListModelsIncludesTieredBillingModel(t *testing.T) {
withSelfUseModeDisabled(t) withSelfUseModeDisabled(t)
withTieredBillingConfig(t, map[string]string{ withTieredBillingConfig(t, map[string]string{
......
...@@ -589,6 +589,25 @@ func GetUserModels(c *gin.Context) { ...@@ -589,6 +589,25 @@ func GetUserModels(c *gin.Context) {
return return
} }
groups := service.GetUserUsableGroups(user.Group) groups := service.GetUserUsableGroups(user.Group)
group := c.Query("group")
if group != "" {
if _, ok := groups[group]; !ok {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": []string{},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": model.GetGroupEnabledModels(group),
})
return
}
var models []string var models []string
for group := range groups { for group := range groups {
for _, g := range model.GetGroupEnabledModels(group) { for _, g := range model.GetGroupEnabledModels(group) {
......
...@@ -20,11 +20,16 @@ ...@@ -20,11 +20,16 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.5.0", "@base-ui/react": "^1.5.0",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.43.3",
"@fontsource-variable/lora": "^5.2.8", "@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/public-sans": "^5.2.7", "@fontsource-variable/public-sans": "^5.2.7",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@hugeicons/core-free-icons": "^4.1.4", "@hugeicons/core-free-icons": "^4.1.4",
"@hugeicons/react": "^1.1.6", "@hugeicons/react": "^1.1.6",
"@lezer/highlight": "^1.2.3",
"@lobehub/icons": "catalog:", "@lobehub/icons": "catalog:",
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"@tanstack/react-query": "^5.100.14", "@tanstack/react-query": "^5.100.14",
...@@ -64,7 +69,7 @@ ...@@ -64,7 +69,7 @@
"shiki": "^4.1.0", "shiki": "^4.1.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"sse.js": "catalog:", "sse.js": "catalog:",
"streamdown": "^2.5.0", "stream-markdown-parser": "^1.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"tokenlens": "^1.3.1", "tokenlens": "^1.3.1",
......
import path from 'path' import path from 'node:path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'node:url'
import { defineConfig, loadEnv } from '@rsbuild/core' import { defineConfig, loadEnv } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react' import { pluginReact } from '@rsbuild/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/rspack' import { tanstackRouter } from '@tanstack/router-plugin/rspack'
...@@ -18,7 +19,7 @@ export default defineConfig(({ envMode }) => { ...@@ -18,7 +19,7 @@ export default defineConfig(({ envMode }) => {
(['/api', '/mj', '/pg'] as const).map((key) => [ (['/api', '/mj', '/pg'] as const).map((key) => [
key, key,
{ target: serverUrl, changeOrigin: true }, { target: serverUrl, changeOrigin: true },
]), ])
) as Record<string, { target: string; changeOrigin: boolean }> ) as Record<string, { target: string; changeOrigin: boolean }>
return { return {
......
...@@ -18,18 +18,19 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,18 +18,19 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
'use client' 'use client'
import { type ComponentProps, useCallback } from 'react'
import { ArrowDownIcon } from 'lucide-react' import { ArrowDownIcon } from 'lucide-react'
import { type ComponentProps, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom' import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export type ConversationProps = ComponentProps<typeof StickToBottom> export type ConversationProps = ComponentProps<typeof StickToBottom>
export const Conversation = ({ className, ...props }: ConversationProps) => ( export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom <StickToBottom
className={cn('relative flex-1 overflow-y-auto', className)} className={cn('relative min-h-0 flex-1 overflow-hidden', className)}
initial='smooth' initial='smooth'
resize='smooth' resize='smooth'
role='log' role='log'
......
...@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
'use client' 'use client'
import { BrainIcon, ChevronDownIcon } from 'lucide-react'
import { import {
type ComponentProps, type ComponentProps,
createContext, createContext,
...@@ -26,14 +27,16 @@ import { ...@@ -26,14 +27,16 @@ import {
useEffect, useEffect,
useState, useState,
} from 'react' } from 'react'
import { BrainIcon, ChevronDownIcon } from 'lucide-react' import { useTranslation } from 'react-i18next'
import { useControllableState } from '@/lib/use-controllable-state'
import { cn } from '@/lib/utils'
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { useControllableState } from '@/lib/use-controllable-state'
import { cn } from '@/lib/utils'
import { Response } from './response' import { Response } from './response'
import { Shimmer } from './shimmer' import { Shimmer } from './shimmer'
...@@ -138,39 +141,42 @@ export const Reasoning = memo( ...@@ -138,39 +141,42 @@ export const Reasoning = memo(
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger> export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>
const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming) {
return <Shimmer duration={1}>Thinking...</Shimmer>
}
// When duration is unknown or 0 (e.g., non-streaming responses), show a generic message
if (duration === undefined || duration === 0) {
return <p>Thought for a few seconds</p>
}
return <p>Thought for {duration} seconds</p>
}
export const ReasoningTrigger = memo( export const ReasoningTrigger = memo(
({ className, children, ...props }: ReasoningTriggerProps) => { ({ className, children, ...props }: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning() const { isStreaming, isOpen, duration } = useReasoning()
const { t } = useTranslation()
const thinkingText = t('Thought for {{duration}} seconds', {
duration: duration ?? 0,
})
return ( return (
<CollapsibleTrigger <CollapsibleTrigger
className={cn( className={cn(
'text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors', 'text-muted-foreground hover:text-foreground inline-grid w-fit max-w-full grid-cols-[0.875rem_minmax(0,auto)_0.875rem] items-center gap-1.5 text-sm leading-none transition-colors [&_p]:m-0',
className className
)} )}
{...props} {...props}
> >
{children ?? ( {children ?? (
<> <>
<BrainIcon className='size-4' /> <span className='grid size-3.5 place-items-center'>
{getThinkingMessage(isStreaming, duration)} <BrainIcon className='size-3.5' />
<ChevronDownIcon </span>
className={cn( <span className='min-w-0 truncate leading-none'>
'size-4 transition-transform', {isStreaming ? (
isOpen ? 'rotate-180' : 'rotate-0' <Shimmer duration={1}>{t('Thinking...')}</Shimmer>
) : (
thinkingText
)} )}
/> </span>
<span className='grid size-3.5 place-items-center'>
<ChevronDownIcon
className={cn(
'size-3.5 transition-transform duration-200 ease-out',
isOpen ? 'rotate-180' : 'rotate-0'
)}
/>
</span>
</> </>
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
...@@ -188,13 +194,17 @@ export const ReasoningContent = memo( ...@@ -188,13 +194,17 @@ export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => ( ({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
'mt-4 text-sm', 'CollapsibleContent group/reasoning-content border-border/70 mt-2 ml-1.5 border-l pl-3 text-sm leading-5',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-muted-foreground data-closed:animate-out data-open:animate-in outline-none', 'text-muted-foreground outline-none',
className className
)} )}
{...props} {...props}
> >
<Response className='grid gap-2'>{children}</Response> <div className='transition-[opacity,transform] duration-200 ease-out group-data-[closed]/reasoning-content:-translate-y-1 group-data-[closed]/reasoning-content:opacity-0 group-data-[open]/reasoning-content:translate-y-0 group-data-[open]/reasoning-content:opacity-100 motion-reduce:transition-none'>
<Response className='grid gap-1.5 [&_li]:my-0.5 [&_ol]:my-1.5 [&_p]:my-1.5 [&_p]:leading-5 [&_ul]:my-1.5'>
{children}
</Response>
</div>
</CollapsibleContent> </CollapsibleContent>
) )
) )
......
/*
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 type { ParsedNode } from 'stream-markdown-parser'
import { isFootnoteNode } from './response-node-guards'
import type { ParsedResponseContent } from './response-types'
const FENCE_START_PATTERN = /^(`{3,}|~{3,})([^\n]*)$/
const FENCE_END_PATTERN = /^(`{3,}|~{3,})\s*$/
const SECTION_HEADING_PATTERN = /^#{2,6}\s+\d+\.\s+/
const MARKDOWN_EXAMPLE_LANGUAGES = new Set(['markdown', 'md', 'mdx'])
type MarkdownExampleFence = {
contentLines: string[]
fenceChar: string
language: string
nestedFence: boolean
}
function getFenceRunLength(line: string, fenceChar: string): number {
let length = 0
for (const char of line) {
if (char !== fenceChar) {
break
}
length++
}
return length
}
function getMarkdownExampleFenceLength(block: MarkdownExampleFence): number {
let maxFenceLength = 3
for (const line of block.contentLines) {
if (!line.startsWith(block.fenceChar)) {
continue
}
maxFenceLength = Math.max(
maxFenceLength,
getFenceRunLength(line, block.fenceChar) + 1
)
}
return maxFenceLength
}
function appendMarkdownExampleFence(
output: string[],
block: MarkdownExampleFence
): void {
const fence = block.fenceChar.repeat(getMarkdownExampleFenceLength(block))
output.push(`${fence}${block.language}`)
output.push(...block.contentLines)
output.push(fence)
}
function normalizeMarkdownExampleFences(input: string): string {
const lines = input.split('\n')
const output: string[] = []
let exampleFence: MarkdownExampleFence | null = null
for (const line of lines) {
if (!exampleFence) {
const match = line.match(FENCE_START_PATTERN)
if (!match) {
output.push(line)
continue
}
const language = match[2].trim().toLowerCase()
if (MARKDOWN_EXAMPLE_LANGUAGES.has(language)) {
exampleFence = {
contentLines: [],
fenceChar: match[1][0],
language,
nestedFence: false,
}
continue
}
output.push(line)
continue
}
if (!exampleFence.nestedFence && SECTION_HEADING_PATTERN.test(line)) {
appendMarkdownExampleFence(output, exampleFence)
output.push(line)
exampleFence = null
continue
}
if (exampleFence.nestedFence && FENCE_END_PATTERN.test(line)) {
exampleFence.contentLines.push(line)
exampleFence.nestedFence = false
continue
}
if (
line.startsWith(exampleFence.fenceChar.repeat(3)) &&
!FENCE_END_PATTERN.test(line)
) {
exampleFence.contentLines.push(line)
exampleFence.nestedFence = true
continue
}
if (FENCE_END_PATTERN.test(line)) {
appendMarkdownExampleFence(output, exampleFence)
exampleFence = null
continue
}
exampleFence.contentLines.push(line)
}
if (exampleFence) {
appendMarkdownExampleFence(output, exampleFence)
}
return output.join('\n')
}
export function stripCustomTags(input: unknown): string {
if (typeof input !== 'string') {
return String(input ?? '')
}
return input
.replaceAll(
/<\/?(conversation|conversationcontent|reasoning|reasoningcontent|reasoningtrigger|sources|sourcescontent|sourcestrigger|branch|branchmessages|branchnext|branchpage|branchprevious|branchselector|message|messagecontent)\b[^>]*>/gi,
''
)
.replaceAll(/<\/?think\b[^>]*>/gi, '')
}
export function getMarkdownContent(children: ReactNode): string {
if (Array.isArray(children)) {
return normalizeMarkdownExampleFences(stripCustomTags(children.join('')))
}
return normalizeMarkdownExampleFences(stripCustomTags(children))
}
export function getNodeKey(node: ParsedNode, index: number): string {
const raw = typeof node.raw === 'string' ? node.raw : ''
return `${node.type}-${index}-${raw.slice(0, 24)}`
}
export function parseResponseContent(
nodes: ParsedNode[]
): ParsedResponseContent {
const footnotes: ParsedResponseContent['footnotes'] = []
const bodyNodes: ParsedNode[] = []
for (const node of nodes) {
if (isFootnoteNode(node)) {
footnotes.push(node)
continue
}
bodyNodes.push(node)
}
return { bodyNodes, footnotes }
}
/*
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 {
BlockquoteNode,
CodeBlockNode,
DefinitionListNode,
FootnoteNode,
HeadingNode,
HtmlBlockNode,
ImageNode,
LinkNode,
ListNode,
MathBlockNode,
MathInlineNode,
ParsedNode,
TableNode,
TextNode,
} from 'stream-markdown-parser'
export function hasParsedChildren(
node: ParsedNode
): node is ParsedNode & { children: ParsedNode[] } {
return 'children' in node && Array.isArray(node.children)
}
export function isTextNode(node: ParsedNode): node is TextNode {
return node.type === 'text' && 'content' in node
}
export function isHeadingNode(node: ParsedNode): node is HeadingNode {
return node.type === 'heading' && 'level' in node && hasParsedChildren(node)
}
export function isListNode(node: ParsedNode): node is ListNode {
return node.type === 'list' && 'items' in node && Array.isArray(node.items)
}
export function isCodeBlockNode(node: ParsedNode): node is CodeBlockNode {
return node.type === 'code_block' && 'code' in node && 'language' in node
}
export function isLinkNode(node: ParsedNode): node is LinkNode {
return node.type === 'link' && 'href' in node && hasParsedChildren(node)
}
export function isImageNode(node: ParsedNode): node is ImageNode {
return node.type === 'image' && 'src' in node && 'alt' in node
}
export function isBlockquoteNode(node: ParsedNode): node is BlockquoteNode {
return node.type === 'blockquote' && hasParsedChildren(node)
}
export function isTableNode(node: ParsedNode): node is TableNode {
return node.type === 'table' && 'header' in node && 'rows' in node
}
export function isDefinitionListNode(
node: ParsedNode
): node is DefinitionListNode {
return (
node.type === 'definition_list' &&
'items' in node &&
Array.isArray(node.items)
)
}
export function isMathBlockNode(node: ParsedNode): node is MathBlockNode {
return node.type === 'math_block' && 'content' in node
}
export function isMathInlineNode(node: ParsedNode): node is MathInlineNode {
return node.type === 'math_inline' && 'content' in node
}
export function isFootnoteNode(node: ParsedNode): node is FootnoteNode {
return node.type === 'footnote' && 'id' in node && hasParsedChildren(node)
}
export function isHtmlBlockNode(node: ParsedNode): node is HtmlBlockNode {
return node.type === 'html_block' && 'tag' in node
}
/*
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 { t } from 'i18next'
import type { BlockquoteNode, ParsedNode } from 'stream-markdown-parser'
import { cn } from '@/lib/utils'
import { hasParsedChildren } from './response-node-guards'
import type {
AlertConfig,
AlertKind,
BlockRendererOptions,
} from './response-types'
const alertConfig = {
note: {
label: 'Note',
className:
'border-blue-500/40 bg-blue-500/8 text-blue-950 dark:text-blue-100',
markerClassName: 'text-blue-600 dark:text-blue-300',
},
tip: {
label: 'Tip',
className:
'border-emerald-500/40 bg-emerald-500/8 text-emerald-950 dark:text-emerald-100',
markerClassName: 'text-emerald-600 dark:text-emerald-300',
},
important: {
label: 'Important',
className:
'border-violet-500/40 bg-violet-500/8 text-violet-950 dark:text-violet-100',
markerClassName: 'text-violet-600 dark:text-violet-300',
},
warning: {
label: 'Warning',
className:
'border-amber-500/40 bg-amber-500/8 text-amber-950 dark:text-amber-100',
markerClassName: 'text-amber-600 dark:text-amber-300',
},
caution: {
label: 'Caution',
className: 'border-red-500/40 bg-red-500/8 text-red-950 dark:text-red-100',
markerClassName: 'text-red-600 dark:text-red-300',
},
} satisfies Record<AlertKind, AlertConfig>
function getAlertKind(node: BlockquoteNode): AlertKind | null {
const firstChild = node.children[0]
if (!firstChild || firstChild.type !== 'paragraph') {
return null
}
if (!hasParsedChildren(firstChild)) {
return null
}
const firstInline = firstChild.children[0]
if (!firstInline || firstInline.type !== 'text') {
return null
}
if (!('content' in firstInline) || typeof firstInline.content !== 'string') {
return null
}
const markerPattern = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n?/i
const match = firstInline.content.match(markerPattern)
if (!match) {
return null
}
return match[1].toLowerCase() as AlertKind
}
function getAlertChildren(node: BlockquoteNode, kind: AlertKind): ParsedNode[] {
const firstChild = node.children[0]
if (!firstChild || firstChild.type !== 'paragraph') {
return node.children
}
if (!hasParsedChildren(firstChild)) {
return node.children
}
const firstInline = firstChild.children[0]
if (!firstInline || firstInline.type !== 'text') {
return node.children
}
if (!('content' in firstInline) || typeof firstInline.content !== 'string') {
return node.children
}
const marker = `[!${kind.toUpperCase()}]`
const content = firstInline.content.replace(marker, '').replace(/^\s*\n?/, '')
const nextParagraph = {
...firstChild,
children: [
{ ...firstInline, content, raw: content },
...firstChild.children.slice(1),
],
}
if (!content && nextParagraph.children.length === 1) {
return node.children.slice(1)
}
return [nextParagraph, ...node.children.slice(1)]
}
export function renderBlockquote(
node: BlockquoteNode,
key: string,
options: BlockRendererOptions
): ReactNode {
const alertKind = getAlertKind(node)
if (alertKind) {
const config = alertConfig[alertKind]
const alertChildren = getAlertChildren(node, alertKind)
return (
<aside
className={cn(
'my-4 rounded-lg border px-4 py-3 text-sm',
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
config.className
)}
key={key}
>
<div
className={cn(
'mb-2 text-xs font-semibold tracking-wide uppercase',
config.markerClassName
)}
>
{t(config.label)}
</div>
{options.renderChildren(alertChildren)}
</aside>
)
}
return (
<blockquote
className='border-border text-muted-foreground my-4 border-l-2 pl-4'
key={key}
>
{options.renderChildren(node.children)}
</blockquote>
)
}
/*
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 type {
CodeBlockNode,
DefinitionItemNode,
DefinitionListNode,
HeadingNode,
ListNode,
MathBlockNode,
MathInlineNode,
} from 'stream-markdown-parser'
import {
CodeBlock,
CodeBlockCopyButton,
} from '@/components/ai-elements/code-block'
import { cn } from '@/lib/utils'
import { getNodeKey } from './response-content'
import type { BlockRendererOptions } from './response-types'
const headingClasses = {
1: 'mt-6 mb-3 text-xl font-semibold tracking-normal',
2: 'mt-6 mb-3 text-lg font-semibold tracking-normal',
3: 'mt-5 mb-2 text-base font-semibold tracking-normal',
4: 'mt-5 mb-2 text-sm font-semibold tracking-normal',
5: 'text-muted-foreground mt-4 mb-2 text-sm font-semibold tracking-normal',
6: 'text-muted-foreground mt-4 mb-2 text-xs font-semibold tracking-normal uppercase',
} satisfies Record<1 | 2 | 3 | 4 | 5 | 6, string>
export function renderHeading(
node: HeadingNode,
key: string,
options: BlockRendererOptions
): ReactNode {
const headingLevel = Math.min(Math.max(node.level, 1), 6) as
| 1
| 2
| 3
| 4
| 5
| 6
const className = headingClasses[headingLevel]
const children = options.renderChildren(node.children)
if (headingLevel === 1) {
return (
<h1 className={className} key={key}>
{children}
</h1>
)
}
if (headingLevel === 2) {
return (
<h2 className={className} key={key}>
{children}
</h2>
)
}
if (headingLevel === 3) {
return (
<h3 className={className} key={key}>
{children}
</h3>
)
}
if (headingLevel === 4) {
return (
<h4 className={className} key={key}>
{children}
</h4>
)
}
if (headingLevel === 5) {
return (
<h5 className={className} key={key}>
{children}
</h5>
)
}
return (
<h6 className={className} key={key}>
{children}
</h6>
)
}
export function renderList(
node: ListNode,
key: string,
options: BlockRendererOptions
): ReactNode {
const className = cn(
'my-3 list-outside space-y-1.5 pl-5',
node.ordered ? 'list-decimal' : 'list-disc'
)
const items = node.items.map((item, index) => (
<li
className='marker:text-muted-foreground pl-1 leading-7'
key={getNodeKey(item, index)}
>
{options.renderChildren(item.children)}
</li>
))
if (node.ordered) {
return (
<ol className={className} key={key} start={node.start}>
{items}
</ol>
)
}
return (
<ul className={className} key={key}>
{items}
</ul>
)
}
export function renderCodeBlock(node: CodeBlockNode, key: string): ReactNode {
const language = node.language || 'plaintext'
const lineCount = node.code.split('\n').length
return (
<CodeBlock
collapsedLines={14}
code={node.code}
defaultCollapsed={lineCount > 14}
key={key}
language={language}
maxExpandedLines={44}
showLineNumbers
showToolbar
title={language}
>
<CodeBlockCopyButton />
</CodeBlock>
)
}
export function renderDefinitionList(
node: DefinitionListNode,
key: string,
options: BlockRendererOptions
): ReactNode {
return (
<dl className='my-4 space-y-3' key={key}>
{node.items.map((item, index) =>
renderDefinitionItem(item, index, options)
)}
</dl>
)
}
function renderDefinitionItem(
node: DefinitionItemNode,
index: number,
options: BlockRendererOptions
): ReactNode {
return (
<div key={`definition-${index}`}>
<dt className='font-semibold'>{options.renderChildren(node.term)}</dt>
<dd className='text-muted-foreground mt-1 pl-4'>
{options.renderChildren(node.definition)}
</dd>
</div>
)
}
export function renderMathBlock(node: MathBlockNode, key: string): ReactNode {
return (
<pre
className='border-border bg-muted/40 my-4 overflow-x-auto rounded-lg border p-4 font-mono text-sm'
key={key}
>
{node.content}
</pre>
)
}
export function renderMathInline(node: MathInlineNode, key: string): ReactNode {
return (
<code
className='bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]'
key={key}
>
{node.content}
</code>
)
}
/*
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 { t } from 'i18next'
import type { HtmlBlockNode, ParsedNode } from 'stream-markdown-parser'
import { hasParsedChildren, isHtmlBlockNode } from './response-node-guards'
import type { BlockRendererOptions } from './response-types'
export function renderDetails(
node: HtmlBlockNode,
key: string,
options: BlockRendererOptions
): ReactNode {
const children = Array.isArray(node.children) ? node.children : []
const summaryNode = children.find(isSummaryHtmlNode)
const contentNodes = children.filter((child) => child !== summaryNode)
const summary = getDetailsSummary(summaryNode, options)
return (
<details
className='border-border/70 my-4 rounded-lg border px-4 py-3'
key={key}
>
<summary className='text-foreground cursor-pointer text-sm font-semibold'>
{summary}
</summary>
<div className='border-border/70 mt-3 border-l pl-4 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0'>
{options.renderChildren(contentNodes)}
</div>
</details>
)
}
function isSummaryHtmlNode(node: ParsedNode): node is HtmlBlockNode {
return isHtmlBlockNode(node) && node.tag === 'summary'
}
function getDetailsSummary(
node: HtmlBlockNode | undefined,
options: BlockRendererOptions
): ReactNode {
if (!node || !hasParsedChildren(node)) {
return t('Details')
}
return options.renderChildren(node.children)
}
/*
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 { t } from 'i18next'
import type { FootnoteNode } from 'stream-markdown-parser'
import type { BlockRendererOptions } from './response-types'
export function renderFootnotes(
footnotes: FootnoteNode[],
options: BlockRendererOptions
): ReactNode {
if (footnotes.length === 0) {
return null
}
return (
<section className='border-border/70 text-muted-foreground mt-6 border-t pt-3 text-sm'>
<ol className='list-decimal space-y-2 pl-5'>
{footnotes.map((footnote) => (
<li id={`footnote-${footnote.id}`} key={footnote.id}>
<div className='inline [&>*:first-child]:mt-0 [&>*:last-child]:mb-0'>
{options.renderChildren(footnote.children)}
</div>
<a
aria-label={t('Back to footnote {{id}} reference', {
id: footnote.id,
})}
className='text-primary ml-2 underline-offset-2 hover:underline'
href={`#footnote-ref-${footnote.id}`}
>
{t('Back')}
</a>
</li>
))}
</ol>
</section>
)
}
/*
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 { sanitizeImageSrc, type ImageNode } from 'stream-markdown-parser'
type ResponseImageProps = {
node: ImageNode
}
export function ResponseImage(props: ResponseImageProps) {
const { t } = useTranslation()
const [hasError, setHasError] = useState(false)
const src = sanitizeImageSrc(props.node.src)
if (!src || hasError) {
return (
<span className='border-border/70 text-muted-foreground my-4 inline-flex rounded-md border px-3 py-2 text-xs italic'>
{props.node.alt || t('Image not available')}
</span>
)
}
return (
<img
alt={props.node.alt}
className='border-border/70 my-4 block h-auto max-h-96 max-w-full rounded-lg border object-contain'
loading='lazy'
onError={() => setHasError(true)}
src={src}
title={props.node.title ?? undefined}
/>
)
}
/*
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 {
shouldOpenLinkInNewTab,
type ImageNode,
type LinkNode,
type TextNode,
} from 'stream-markdown-parser'
import { ResponseImage } from './response-renderer-image'
import type { RenderChildren } from './response-types'
export function renderTextNode(node: TextNode): ReactNode {
return node.content
}
export function renderLink(
node: LinkNode,
key: string,
renderChildren: RenderChildren
): ReactNode {
const opensInNewTab = shouldOpenLinkInNewTab(node.href)
const rel = opensInNewTab ? 'noreferrer noopener' : undefined
const target = opensInNewTab ? '_blank' : undefined
return (
<a
className='text-primary underline-offset-4 hover:underline'
href={node.href}
key={key}
rel={rel}
target={target}
title={node.title ?? undefined}
>
{renderChildren(node.children)}
</a>
)
}
export function renderImage(node: ImageNode, key: string): ReactNode {
return <ResponseImage key={key} node={node} />
}
/*
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 type { TableCellNode, TableNode } from 'stream-markdown-parser'
import { cn } from '@/lib/utils'
import { getNodeKey } from './response-content'
import type { BlockRendererOptions } from './response-types'
function getTableCellAlignClass(
align: TableCellNode['align'] | undefined
): string {
if (align === 'right') {
return 'text-right'
}
if (align === 'center') {
return 'text-center'
}
return 'text-left'
}
function renderTableCell(
node: TableCellNode,
key: string,
options: BlockRendererOptions
): ReactNode {
const alignClass = getTableCellAlignClass(node.align)
if (node.header) {
return (
<th
className={cn(
'text-muted-foreground px-3 py-2 text-xs font-semibold whitespace-nowrap',
alignClass
)}
key={key}
>
{options.renderChildren(node.children)}
</th>
)
}
return (
<td className={cn('px-3 py-2 align-top', alignClass)} key={key}>
{options.renderChildren(node.children)}
</td>
)
}
export function renderTable(
node: TableNode,
key: string,
options: BlockRendererOptions
): ReactNode {
return (
<div
className='border-border/70 my-4 w-full overflow-x-auto rounded-lg border'
key={key}
>
<table className='my-0 w-full min-w-max border-separate border-spacing-0 text-sm'>
<thead className='bg-muted/60'>
<tr className='border-border/70'>
{node.header.cells.map((cell, index) =>
renderTableCell(cell, getNodeKey(cell, index), options)
)}
</tr>
</thead>
<tbody className='divide-border/70 divide-y'>
{node.rows.map((row, rowIndex) => (
<tr className='border-border/70' key={getNodeKey(row, rowIndex)}>
{row.cells.map((cell, cellIndex) =>
renderTableCell(cell, getNodeKey(cell, cellIndex), options)
)}
</tr>
))}
</tbody>
</table>
</div>
)
}
/*
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 type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
import { getNodeKey } from './response-content'
import {
hasParsedChildren,
isBlockquoteNode,
isCodeBlockNode,
isDefinitionListNode,
isHeadingNode,
isHtmlBlockNode,
isImageNode,
isLinkNode,
isListNode,
isMathBlockNode,
isMathInlineNode,
isTableNode,
isTextNode,
} from './response-node-guards'
import { renderBlockquote } from './response-renderer-alert'
import {
renderCodeBlock,
renderDefinitionList,
renderHeading,
renderList,
renderMathBlock,
renderMathInline,
} from './response-renderer-blocks'
import { renderDetails } from './response-renderer-details'
import { renderFootnotes as renderFootnotesBlock } from './response-renderer-footnotes'
import {
renderImage,
renderLink,
renderTextNode,
} from './response-renderer-inline'
import { renderTable } from './response-renderer-table'
export function renderChildren(nodes: ParsedNode[]): ReactNode {
return nodes.map((node, index) => renderNode(node, getNodeKey(node, index)))
}
export function renderFootnotes(footnotes: FootnoteNode[]): ReactNode {
return renderFootnotesBlock(footnotes, { renderChildren })
}
function renderNode(node: ParsedNode, key: string): ReactNode {
if (isTextNode(node)) {
return renderTextNode(node)
}
if (isHeadingNode(node)) {
return renderHeading(node, key, { renderChildren })
}
if (node.type === 'paragraph' && hasParsedChildren(node)) {
return (
<p className='my-3 leading-7' key={key}>
{renderChildren(node.children)}
</p>
)
}
if (node.type === 'inline' && hasParsedChildren(node)) {
return <span key={key}>{renderChildren(node.children)}</span>
}
if (isListNode(node)) {
return renderList(node, key, { renderChildren })
}
if (isCodeBlockNode(node)) {
return renderCodeBlock(node, key)
}
if (node.type === 'inline_code' && 'code' in node) {
return (
<code
className='bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]'
key={key}
>
{String(node.code)}
</code>
)
}
if (isLinkNode(node)) {
return renderLink(node, key, renderChildren)
}
if (isImageNode(node)) {
return renderImage(node, key)
}
if (isBlockquoteNode(node)) {
return renderBlockquote(node, key, { renderChildren })
}
if (isTableNode(node)) {
return renderTable(node, key, { renderChildren })
}
if (isDefinitionListNode(node)) {
return renderDefinitionList(node, key, { renderChildren })
}
if (node.type === 'strong' && hasParsedChildren(node)) {
return (
<strong className='text-foreground font-semibold' key={key}>
{renderChildren(node.children)}
</strong>
)
}
if (node.type === 'emphasis' && hasParsedChildren(node)) {
return <em key={key}>{renderChildren(node.children)}</em>
}
if (node.type === 'strikethrough' && hasParsedChildren(node)) {
return <del key={key}>{renderChildren(node.children)}</del>
}
if (node.type === 'highlight' && hasParsedChildren(node)) {
return <mark key={key}>{renderChildren(node.children)}</mark>
}
if (node.type === 'insert' && hasParsedChildren(node)) {
return <ins key={key}>{renderChildren(node.children)}</ins>
}
if (node.type === 'subscript' && hasParsedChildren(node)) {
return <sub key={key}>{renderChildren(node.children)}</sub>
}
if (node.type === 'superscript' && hasParsedChildren(node)) {
return <sup key={key}>{renderChildren(node.children)}</sup>
}
if (
(node.type === 'checkbox' || node.type === 'checkbox_input') &&
'checked' in node
) {
return (
<input
checked={Boolean(node.checked)}
className='accent-primary mr-2 size-4 align-[-0.15em]'
disabled
key={key}
readOnly
type='checkbox'
/>
)
}
if (node.type === 'hardbreak') {
return <br key={key} />
}
if (node.type === 'thematic_break') {
return <hr className='border-border/70 my-6' key={key} />
}
if (isMathBlockNode(node)) {
return renderMathBlock(node, key)
}
if (isMathInlineNode(node)) {
return renderMathInline(node, key)
}
if (node.type === 'footnote_reference' && 'id' in node) {
return (
<sup className='text-primary mx-0.5 text-xs' key={key}>
<a
className='underline-offset-2 hover:underline'
href={`#footnote-${String(node.id)}`}
id={`footnote-ref-${String(node.id)}`}
>
[{String(node.id)}]
</a>
</sup>
)
}
if (node.type === 'footnote_anchor') {
return null
}
if (isHtmlBlockNode(node) && node.tag === 'details') {
return renderDetails(node, key, { renderChildren })
}
if (node.type === 'html_block' && 'content' in node) {
return <span key={key}>{String(node.content)}</span>
}
if (node.type === 'html_inline' && 'content' in node) {
return <span key={key}>{String(node.content)}</span>
}
if (hasParsedChildren(node)) {
return <span key={key}>{renderChildren(node.children)}</span>
}
if ('content' in node && typeof node.content === 'string') {
return <span key={key}>{node.content}</span>
}
return <span key={key}>{node.raw}</span>
}
/*
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 type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
export type ResponseProps = {
children?: ReactNode
className?: string
final?: boolean
}
export type AlertKind = 'note' | 'tip' | 'important' | 'warning' | 'caution'
export type AlertConfig = {
label: string
className: string
markerClassName: string
}
export type ParsedResponseContent = {
bodyNodes: ParsedNode[]
footnotes: FootnoteNode[]
}
export type RenderChildren = (nodes: ParsedNode[]) => ReactNode
export type BlockRendererOptions = {
renderChildren: RenderChildren
}
...@@ -18,43 +18,49 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,43 +18,49 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
'use client' 'use client'
import { type ComponentProps, memo } from 'react' import { memo, useMemo } from 'react'
import { Streamdown } from 'streamdown' import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type ResponseProps = ComponentProps<typeof Streamdown> import { getMarkdownContent, parseResponseContent } from './response-content'
import { renderChildren, renderFootnotes } from './response-renderer'
export const Response = memo( import type { ResponseProps } from './response-types'
({ className, children, ...props }: ResponseProps) => {
const stripCustomTags = (input: unknown): unknown => { const markdown = getMarkdown('new-api-response')
if (typeof input !== 'string') return input const MAX_PARSED_MARKDOWN_CHARS = 20_000
return (
input export const Response = memo((props: ResponseProps) => {
// Remove known AI custom wrapper tags but keep inner content const content = getMarkdownContent(props.children)
.replace( const shouldParseMarkdown = content.length <= MAX_PARSED_MARKDOWN_CHARS
/<\/?(conversation|conversationcontent|reasoning|reasoningcontent|reasoningtrigger|sources|sourcescontent|sourcestrigger|branch|branchmessages|branchnext|branchpage|branchprevious|branchselector|message|messagecontent)\b[^>]*>/gi, const nodes = useMemo(() => {
'' if (!shouldParseMarkdown) {
) return []
// Remove any stray <think> tags if they still appear
.replace(/<\/?think\b[^>]*>/gi, '')
)
} }
const safeChildren = stripCustomTags(children) as string return parseMarkdownToStructure(content, markdown, {
final: props.final ?? true,
return ( validateLink: markdown.options.validateLink,
<Streamdown })
className={cn( }, [content, props.final, shouldParseMarkdown])
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0', const parsedContent = useMemo(() => parseResponseContent(nodes), [nodes])
className const renderedContent =
)} parsedContent.bodyNodes.length > 0
{...props} ? renderChildren(parsedContent.bodyNodes)
> : content
{safeChildren} const footnotes = renderFootnotes(parsedContent.footnotes)
</Streamdown>
) return (
}, <div
(prevProps, nextProps) => prevProps.children === nextProps.children className={cn(
) 'size-full min-w-0 text-pretty [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
props.className
)}
>
{renderedContent}
{footnotes}
</div>
)
})
Response.displayName = 'Response' Response.displayName = 'Response'
...@@ -18,15 +18,16 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,15 +18,16 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
'use client' 'use client'
import type { ComponentProps } from 'react'
import { BookIcon, ChevronDownIcon } from 'lucide-react' import { BookIcon, ChevronDownIcon } from 'lucide-react'
import type { ComponentProps } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
export type SourcesProps = ComponentProps<'div'> export type SourcesProps = ComponentProps<'div'>
...@@ -73,7 +74,7 @@ export const SourcesContent = ({ ...@@ -73,7 +74,7 @@ export const SourcesContent = ({
}: SourcesContentProps) => ( }: SourcesContentProps) => (
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
'mt-3 flex w-fit flex-col gap-2', 'border-border/70 mt-3 ml-2 flex w-fit flex-col gap-2 border-l pl-4',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 data-closed:animate-out data-open:animate-in outline-none', 'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 data-closed:animate-out data-open:animate-in outline-none',
className className
)} )}
......
...@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { API_ENDPOINTS } from './constants' import { API_ENDPOINTS } from './constants'
import type { import type {
ChatCompletionRequest, ChatCompletionRequest,
...@@ -29,9 +30,11 @@ import type { ...@@ -29,9 +30,11 @@ import type {
* Send chat completion request (non-streaming) * Send chat completion request (non-streaming)
*/ */
export async function sendChatCompletion( export async function sendChatCompletion(
payload: ChatCompletionRequest payload: ChatCompletionRequest,
signal?: AbortSignal
): Promise<ChatCompletionResponse> { ): Promise<ChatCompletionResponse> {
const res = await api.post(API_ENDPOINTS.CHAT_COMPLETIONS, payload, { const res = await api.post(API_ENDPOINTS.CHAT_COMPLETIONS, payload, {
signal,
skipErrorHandler: true, skipErrorHandler: true,
} as Record<string, unknown>) } as Record<string, unknown>)
return res.data return res.data
...@@ -40,8 +43,10 @@ export async function sendChatCompletion( ...@@ -40,8 +43,10 @@ export async function sendChatCompletion(
/** /**
* Get user available models * Get user available models
*/ */
export async function getUserModels(): Promise<ModelOption[]> { export async function getUserModels(group: string): Promise<ModelOption[]> {
const res = await api.get(API_ENDPOINTS.USER_MODELS) const res = await api.get(API_ENDPOINTS.USER_MODELS, {
params: { group },
})
const { data } = res const { data } = res
if (!data.success || !Array.isArray(data.data)) { if (!data.success || !Array.isArray(data.data)) {
......
/*
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>
)
}
/*
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>
)
}
/*
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>
)
}
/*
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?')}
/>
</>
)
}
/*
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>
)
}
/*
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>
)
}
...@@ -23,7 +23,7 @@ import { ...@@ -23,7 +23,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { MESSAGE_ACTION_BUTTON_STYLES } from '../constants' import { MESSAGE_ACTION_BUTTON_STYLES } from '../../constants'
interface MessageActionButtonProps { interface MessageActionButtonProps {
icon: LucideIcon icon: LucideIcon
......
/*
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>
</>
)
}
/*
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>
)
}
import { AlertCircle, AlertTriangle, Settings } from 'lucide-react'
/* /*
Copyright (C) 2023-2026 QuantumNous Copyright (C) 2023-2026 QuantumNous
...@@ -16,54 +17,67 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,54 +17,67 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { MESSAGE_STATUS } from '../constants' import { useAuthStore } from '@/stores/auth-store'
import type { Message } from '../types'
import {
FALLBACK_ERROR_CONTENT,
getMessageErrorState,
isAdminRole,
MODEL_PRICING_SETTINGS_PATH,
} from '../../lib'
import type { Message } from '../../types'
interface MessageErrorProps { interface MessageErrorProps {
message: Message message: Message
className?: string className?: string
actions?: ReactNode
} }
/** /**
* Display error messages using Alert component * Display error messages using Alert component
* Following ai-elements pattern for error handling * Following ai-elements pattern for error handling
*/ */
export function MessageError({ message, className = '' }: MessageErrorProps) { export function MessageError({
message,
className = '',
actions,
}: MessageErrorProps) {
const { t } = useTranslation() const { t } = useTranslation()
const user = useAuthStore((s) => s.auth.user) 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 return null
} }
const errorContent = if (errorState.kind === 'model-price') {
message.versions[0]?.content || 'An unknown error occurred' const content =
errorState.content === FALLBACK_ERROR_CONTENT
? t(FALLBACK_ERROR_CONTENT)
: errorState.content
if (message.errorCode === 'model_price_error') {
return ( return (
<Alert variant='default' className={className}> <Alert variant='default' className={className}>
<AlertTriangle className='text-orange-500' /> <AlertTriangle className='text-orange-500' />
<AlertTitle>{t('Model Price Not Configured')}</AlertTitle> <AlertTitle>{t('Model Price Not Configured')}</AlertTitle>
<AlertDescription className='space-y-2'> <AlertDescription className='space-y-2'>
<p>{errorContent}</p> <p>{content}</p>
{isAdmin && ( {errorState.showSettingsLink && (
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
onClick={() => onClick={() => window.open(MODEL_PRICING_SETTINGS_PATH, '_blank')}
window.open('/system-settings/billing/model-pricing', '_blank')
}
> >
<Settings className='mr-1 h-3.5 w-3.5' /> <Settings className='mr-1 h-3.5 w-3.5' />
{t('Go to Settings')} {t('Go to Settings')}
</Button> </Button>
)} )}
{actions}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) )
...@@ -73,7 +87,14 @@ export function MessageError({ message, className = '' }: MessageErrorProps) { ...@@ -73,7 +87,14 @@ export function MessageError({ message, className = '' }: MessageErrorProps) {
<Alert variant='destructive' className={className}> <Alert variant='destructive' className={className}>
<AlertCircle /> <AlertCircle />
<AlertTitle>{t('Error')}</AlertTitle> <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> </Alert>
) )
} }
/*
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>
)
}
/*
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>
)
}
/*
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}
/>
)
}
/*
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>
)
}
...@@ -94,6 +94,8 @@ export const MESSAGE_ACTION_LABELS = { ...@@ -94,6 +94,8 @@ export const MESSAGE_ACTION_LABELS = {
COPY: 'Copy', COPY: 'Copy',
COPIED: 'Copied!', COPIED: 'Copied!',
REGENERATE: 'Regenerate', REGENERATE: 'Regenerate',
SHOW_PREVIEW: 'Show preview',
SHOW_SOURCE: 'Show source',
EDIT: 'Edit', EDIT: 'Edit',
DELETE: 'Delete', DELETE: 'Delete',
NO_CONTENT: 'No content to copy', NO_CONTENT: 'No content to copy',
......
...@@ -20,3 +20,5 @@ export * from './use-playground-state' ...@@ -20,3 +20,5 @@ export * from './use-playground-state'
export * from './use-stream-request' export * from './use-stream-request'
export * from './use-chat-handler' export * from './use-chat-handler'
export * from './use-message-action-guard' export * from './use-message-action-guard'
export * from './use-playground-conversation'
export * from './use-playground-options'
...@@ -16,16 +16,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,16 +16,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useCallback } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { sendChatCompletion } from '../api' import { sendChatCompletion } from '../api'
import { MESSAGE_STATUS, ERROR_MESSAGES } from '../constants' import { ERROR_MESSAGES } from '../constants'
import { import {
applyStreamingChunk,
buildChatCompletionPayload, buildChatCompletionPayload,
updateAssistantMessageWithError, updateAssistantMessageWithError,
updateLastAssistantMessage, updateLastAssistantMessage,
processStreamingContent, parseRequestErrorDetails,
finalizeMessage, applyChatCompletionResponse,
completeAssistantMessage,
hasChatCompletionChoice,
isAssistantMessageFinal,
isAssistantMessagePending,
} from '../lib' } from '../lib'
import type { Message, PlaygroundConfig, ParameterEnabled } from '../types' import type { Message, PlaygroundConfig, ParameterEnabled } from '../types'
import { useStreamRequest } from './use-stream-request' import { useStreamRequest } from './use-stream-request'
...@@ -36,6 +43,25 @@ interface UseChatHandlerOptions { ...@@ -36,6 +43,25 @@ interface UseChatHandlerOptions {
onMessageUpdate: (updater: (prev: Message[]) => Message[]) => void 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 = {
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 * Hook for handling chat message sending and receiving
*/ */
...@@ -44,65 +70,141 @@ export function useChatHandler({ ...@@ -44,65 +70,141 @@ export function useChatHandler({
parameterEnabled, parameterEnabled,
onMessageUpdate, onMessageUpdate,
}: UseChatHandlerOptions) { }: UseChatHandlerOptions) {
const { t } = useTranslation()
const { sendStreamRequest, stopStream, isStreaming } = useStreamRequest() const { sendStreamRequest, stopStream, isStreaming } = useStreamRequest()
const [isRequesting, setIsRequesting] = useState(false)
const abortControllerRef = useRef<AbortController | null>(null)
const requestIdRef = useRef(0)
const pendingStreamChunksRef = useRef<PendingStreamChunks>({
content: '',
reasoning: '',
})
const streamFlushTimerRef = useRef<number | null>(null)
const flushStreamUpdates = useCallback(() => {
if (streamFlushTimerRef.current !== null) {
window.clearTimeout(streamFlushTimerRef.current)
streamFlushTimerRef.current = null
}
const pendingChunks = pendingStreamChunksRef.current
if (!pendingChunks.reasoning && !pendingChunks.content) {
return
}
pendingStreamChunksRef.current = { content: '', reasoning: '' }
onMessageUpdate((prev) =>
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(() => {
if (streamFlushTimerRef.current !== null) {
return
}
streamFlushTimerRef.current = window.setTimeout(
flushStreamUpdates,
STREAM_UPDATE_FLUSH_MS
)
}, [flushStreamUpdates])
useEffect(
() => () => {
if (streamFlushTimerRef.current !== null) {
window.clearTimeout(streamFlushTimerRef.current)
}
},
[]
)
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 // Handle stream update
const handleStreamUpdate = useCallback( const handleStreamUpdate = useCallback(
(type: 'reasoning' | 'content', chunk: string) => { (type: 'reasoning' | 'content', chunk: string) => {
onMessageUpdate((prev) => pendingStreamChunksRef.current[type] = mergePendingStreamChunk(
updateLastAssistantMessage(prev, (message) => { pendingStreamChunksRef.current[type],
if (message.status === MESSAGE_STATUS.ERROR) return message chunk
if (type === 'reasoning') {
// Direct API reasoning_content
return {
...message,
reasoning: {
content: (message.reasoning?.content || '') + chunk,
duration: 0,
},
isReasoningStreaming: true,
status: MESSAGE_STATUS.STREAMING,
}
}
// Content streaming: handle <think> tags
return {
...processStreamingContent(message, chunk),
status: MESSAGE_STATUS.STREAMING,
}
})
) )
scheduleStreamFlush()
}, },
[onMessageUpdate] [scheduleStreamFlush]
) )
// Handle stream complete // Handle stream complete
const handleStreamComplete = useCallback(() => { const handleStreamComplete = useCallback(() => {
flushStreamUpdates()
setIsRequesting(false)
onMessageUpdate((prev) => onMessageUpdate((prev) =>
updateLastAssistantMessage(prev, (message) => updateLastAssistantMessage(prev, (message) =>
message.status === MESSAGE_STATUS.COMPLETE || isAssistantMessageFinal(message)
message.status === MESSAGE_STATUS.ERROR
? message ? message
: { ...finalizeMessage(message), status: MESSAGE_STATUS.COMPLETE } : completeAssistantMessage(message)
) )
) )
}, [onMessageUpdate]) }, [flushStreamUpdates, onMessageUpdate])
// Handle stream error // Handle stream error
const handleStreamError = useCallback( const handleStreamError = useCallback(
(error: string, errorCode?: string) => { (error: string, errorCode?: string) => {
toast.error(error) flushStreamUpdates()
setIsRequesting(false)
const displayError = getDisplayError(error)
toast.error(displayError)
const errorTitle = t(ERROR_MESSAGES.API_REQUEST_ERROR)
onMessageUpdate((prev) => onMessageUpdate((prev) =>
updateAssistantMessageWithError(prev, error, errorCode) updateAssistantMessageWithError(
prev,
displayError,
errorCode,
errorTitle
)
) )
}, },
[onMessageUpdate] [flushStreamUpdates, getDisplayError, onMessageUpdate, t]
) )
// Send streaming chat request // Send streaming chat request
const sendStreamingChat = useCallback( const sendStreamingChat = useCallback(
(messages: Message[]) => { (messages: Message[]) => {
setIsRequesting(true)
const payload = buildChatCompletionPayload( const payload = buildChatCompletionPayload(
messages, messages,
config, config,
...@@ -133,42 +235,45 @@ export function useChatHandler({ ...@@ -133,42 +235,45 @@ export function useChatHandler({
config, config,
parameterEnabled parameterEnabled
) )
const requestId = requestIdRef.current + 1
const abortController = new AbortController()
requestIdRef.current = requestId
abortControllerRef.current = abortController
try { try {
const response = await sendChatCompletion(payload) setIsRequesting(true)
const choice = response.choices?.[0] const response = await sendChatCompletion(
if (!choice) return payload,
abortController.signal
)
if (abortController.signal.aborted) return
if (!hasChatCompletionChoice(response)) {
handleStreamError(ERROR_MESSAGES.API_REQUEST_ERROR)
return
}
onMessageUpdate((prev) => onMessageUpdate((prev) =>
updateLastAssistantMessage(prev, (message) => ({ updateLastAssistantMessage(prev, (message) => {
...finalizeMessage( const updatedMessage = applyChatCompletionResponse(
{ message,
...message, response
versions: [ )
{
...message.versions[0], return updatedMessage ?? message
content: choice.message?.content || '', })
},
],
},
choice.message?.reasoning_content
),
status: MESSAGE_STATUS.COMPLETE,
}))
) )
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { if (abortController.signal.aborted) return
response?: {
data?: { message?: string; error?: { code?: string } } const { errorCode, errorMessage } = parseRequestErrorDetails(error)
} handleStreamError(errorMessage, errorCode)
message?: string } finally {
if (requestIdRef.current === requestId) {
abortControllerRef.current = null
setIsRequesting(false)
} }
handleStreamError(
err?.response?.data?.message ||
err?.message ||
ERROR_MESSAGES.API_REQUEST_ERROR,
err?.response?.data?.error?.code || undefined
)
} }
}, },
[config, parameterEnabled, onMessageUpdate, handleStreamError] [config, parameterEnabled, onMessageUpdate, handleStreamError]
...@@ -189,19 +294,22 @@ export function useChatHandler({ ...@@ -189,19 +294,22 @@ export function useChatHandler({
// Stop generation // Stop generation
const stopGeneration = useCallback(() => { const stopGeneration = useCallback(() => {
stopStream() stopStream()
flushStreamUpdates()
abortControllerRef.current?.abort()
abortControllerRef.current = null
setIsRequesting(false)
onMessageUpdate((prev) => onMessageUpdate((prev) =>
updateLastAssistantMessage(prev, (message) => updateLastAssistantMessage(prev, (message) =>
message.status === MESSAGE_STATUS.LOADING || isAssistantMessagePending(message)
message.status === MESSAGE_STATUS.STREAMING ? completeAssistantMessage(message)
? { ...finalizeMessage(message), status: MESSAGE_STATUS.COMPLETE }
: message : message
) )
) )
}, [stopStream, onMessageUpdate]) }, [stopStream, flushStreamUpdates, onMessageUpdate])
return { return {
sendChat, sendChat,
stopGeneration, stopGeneration,
isGenerating: isStreaming, isGenerating: isStreaming || isRequesting,
} }
} }
...@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { MESSAGE_ACTION_LABELS } from '../constants' import { MESSAGE_ACTION_LABELS } from '../constants'
...@@ -25,17 +26,18 @@ import { MESSAGE_ACTION_LABELS } from '../constants' ...@@ -25,17 +26,18 @@ import { MESSAGE_ACTION_LABELS } from '../constants'
* Provides a wrapper that checks if generation is active before executing * Provides a wrapper that checks if generation is active before executing
*/ */
export function useMessageActionGuard(isGenerating: boolean) { export function useMessageActionGuard(isGenerating: boolean) {
const { t } = useTranslation()
const guardAction = useCallback( const guardAction = useCallback(
(action: () => void) => { (action: () => void) => {
return () => { return () => {
if (isGenerating) { if (isGenerating) {
toast.warning(MESSAGE_ACTION_LABELS.WAIT_GENERATION) toast.warning(t(MESSAGE_ACTION_LABELS.WAIT_GENERATION))
return return
} }
action() action()
} }
}, },
[isGenerating] [isGenerating, t]
) )
return { guardAction } return { guardAction }
......
/*
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,
}
}
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,
}
}
...@@ -16,15 +16,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,15 +16,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useState, useCallback } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../constants' import { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../constants'
import { import {
loadConfig,
saveConfig, saveConfig,
loadParameterEnabled,
saveParameterEnabled, saveParameterEnabled,
loadMessages,
saveMessages, saveMessages,
applyMessageStateUpdate,
getInitialParameterEnabled,
getInitialPlaygroundConfig,
loadMessages,
type MessageStateUpdater,
} from '../lib' } from '../lib'
import type { import type {
Message, Message,
...@@ -34,30 +37,77 @@ import type { ...@@ -34,30 +37,77 @@ import type {
GroupOption, GroupOption,
} from '../types' } from '../types'
const MESSAGE_SAVE_DEBOUNCE_MS = 500
/** /**
* Main state management hook for playground * Main state management hook for playground
*/ */
export function usePlaygroundState() { export function usePlaygroundState() {
// Load initial state from localStorage // Load initial state from localStorage
const [config, setConfig] = useState<PlaygroundConfig>(() => { const [config, setConfig] = useState<PlaygroundConfig>(
const savedConfig = loadConfig() getInitialPlaygroundConfig
return { ...DEFAULT_CONFIG, ...savedConfig } )
})
const [parameterEnabled, setParameterEnabled] = useState<ParameterEnabled>( const [parameterEnabled, setParameterEnabled] = useState<ParameterEnabled>(
() => { getInitialParameterEnabled
const saved = loadParameterEnabled()
return { ...DEFAULT_PARAMETER_ENABLED, ...saved }
}
) )
const [messages, setMessages] = useState<Message[]>(() => { const [messages, setMessages] = useState<Message[]>([])
return loadMessages() || [] 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 [models, setModels] = useState<ModelOption[]>([])
const [groups, setGroups] = useState<GroupOption[]>([]) 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 // Update config with automatic save
const updateConfig = useCallback( const updateConfig = useCallback(
<K extends keyof PlaygroundConfig>(key: K, value: PlaygroundConfig[K]) => { <K extends keyof PlaygroundConfig>(key: K, value: PlaygroundConfig[K]) => {
...@@ -84,15 +134,14 @@ export function usePlaygroundState() { ...@@ -84,15 +134,14 @@ export function usePlaygroundState() {
// Update messages with automatic save // Update messages with automatic save
const updateMessages = useCallback( const updateMessages = useCallback(
(updater: Message[] | ((prev: Message[]) => Message[])) => { (updater: MessageStateUpdater) => {
setMessages((prev) => { setMessages((prev) => {
const newMessages = const newMessages = applyMessageStateUpdate(prev, updater)
typeof updater === 'function' ? updater(prev) : updater persistMessages(newMessages)
saveMessages(newMessages)
return newMessages return newMessages
}) })
}, },
[] [persistMessages]
) )
// Clear all messages // Clear all messages
...@@ -113,6 +162,7 @@ export function usePlaygroundState() { ...@@ -113,6 +162,7 @@ export function usePlaygroundState() {
config, config,
parameterEnabled, parameterEnabled,
messages, messages,
isLoadingMessages,
models, models,
groups, groups,
......
...@@ -16,11 +16,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,11 +16,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useCallback, useRef } from 'react' import { useCallback, useRef, useState } from 'react'
import { SSE } from 'sse.js' import { SSE } from 'sse.js'
import { getCommonHeaders } from '@/lib/api' import { getCommonHeaders } from '@/lib/api'
import { API_ENDPOINTS, ERROR_MESSAGES } from '../constants' import { API_ENDPOINTS, ERROR_MESSAGES } from '../constants'
import type { ChatCompletionRequest, ChatCompletionChunk } from '../types' import {
getStreamReadyStateError,
isStreamClosedReadyState,
isStreamDoneMessage,
parseStreamErrorDetails,
parseStreamMessageUpdates,
} from '../lib'
import type { ChatCompletionRequest } from '../types'
/** /**
* Hook for handling streaming chat completion requests * Hook for handling streaming chat completion requests
...@@ -28,6 +37,17 @@ import type { ChatCompletionRequest, ChatCompletionChunk } from '../types' ...@@ -28,6 +37,17 @@ import type { ChatCompletionRequest, ChatCompletionChunk } from '../types'
export function useStreamRequest() { export function useStreamRequest() {
const sseSourceRef = useRef<SSE | null>(null) const sseSourceRef = useRef<SSE | null>(null)
const isStreamCompleteRef = useRef(false) const isStreamCompleteRef = useRef(false)
const [isStreaming, setIsStreaming] = useState(false)
const closeActiveStream = useCallback((source?: SSE) => {
const streamSource = source ?? sseSourceRef.current
streamSource?.close()
if (!source || sseSourceRef.current === source) {
sseSourceRef.current = null
setIsStreaming(false)
}
}, [])
const sendStreamRequest = useCallback( const sendStreamRequest = useCallback(
( (
...@@ -36,6 +56,8 @@ export function useStreamRequest() { ...@@ -36,6 +56,8 @@ export function useStreamRequest() {
onComplete: () => void, onComplete: () => void,
onError: (error: string, errorCode?: string) => void onError: (error: string, errorCode?: string) => void
) => { ) => {
sseSourceRef.current?.close()
const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, { const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
headers: getCommonHeaders(), headers: getCommonHeaders(),
method: 'POST', method: 'POST',
...@@ -44,38 +66,28 @@ export function useStreamRequest() { ...@@ -44,38 +66,28 @@ export function useStreamRequest() {
sseSourceRef.current = source sseSourceRef.current = source
isStreamCompleteRef.current = false isStreamCompleteRef.current = false
setIsStreaming(true)
const closeSource = () => {
source.close()
sseSourceRef.current = null
}
const handleError = (errorMessage: string, errorCode?: string) => { const handleError = (errorMessage: string, errorCode?: string) => {
if (!isStreamCompleteRef.current) { if (!isStreamCompleteRef.current) {
onError(errorMessage, errorCode) onError(errorMessage, errorCode)
closeSource() closeActiveStream(source)
} }
} }
source.addEventListener('message', (e: MessageEvent) => { source.addEventListener('message', (e: MessageEvent) => {
if (e.data === '[DONE]') { if (isStreamDoneMessage(e.data)) {
isStreamCompleteRef.current = true isStreamCompleteRef.current = true
closeSource() closeActiveStream(source)
onComplete() onComplete()
return return
} }
try { try {
const chunk: ChatCompletionChunk = JSON.parse(e.data) const updates = parseStreamMessageUpdates(e.data)
const delta = chunk.choices?.[0]?.delta
for (const update of updates) {
if (delta) { onUpdate(update.type, update.chunk)
if (delta.reasoning_content) {
onUpdate('reasoning', delta.reasoning_content)
}
if (delta.content) {
onUpdate('content', delta.content)
}
} }
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
...@@ -86,24 +98,10 @@ export function useStreamRequest() { ...@@ -86,24 +98,10 @@ export function useStreamRequest() {
source.addEventListener('error', (e: Event & { data?: string }) => { source.addEventListener('error', (e: Event & { data?: string }) => {
// Only handle errors if stream didn't complete normally // Only handle errors if stream didn't complete normally
if (source.readyState !== 2) { if (!isStreamClosedReadyState(source.readyState)) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('SSE Error:', e) console.error('SSE Error:', e)
let errorMessage = e.data || ERROR_MESSAGES.API_REQUEST_ERROR const { errorCode, errorMessage } = parseStreamErrorDetails(e.data)
let errorCode: string | undefined
if (e.data) {
try {
const parsed = JSON.parse(e.data) as {
error?: { message?: string; code?: string }
}
if (parsed?.error) {
errorMessage = parsed.error.message || errorMessage
errorCode = parsed.error.code || undefined
}
} catch {
// not JSON, use raw string
}
}
handleError(errorMessage, errorCode) handleError(errorMessage, errorCode)
} }
}) })
...@@ -111,14 +109,10 @@ export function useStreamRequest() { ...@@ -111,14 +109,10 @@ export function useStreamRequest() {
source.addEventListener( source.addEventListener(
'readystatechange', 'readystatechange',
(e: Event & { readyState?: number }) => { (e: Event & { readyState?: number }) => {
const status = (source as unknown as { status?: number }).status const errorMessage = getStreamReadyStateError(e.readyState, source)
if (
e.readyState !== undefined && if (errorMessage) {
e.readyState >= 2 && handleError(errorMessage)
status !== undefined &&
status !== 200
) {
handleError(`HTTP ${status}: ${ERROR_MESSAGES.CONNECTION_CLOSED}`)
} }
} }
) )
...@@ -129,26 +123,19 @@ export function useStreamRequest() { ...@@ -129,26 +123,19 @@ export function useStreamRequest() {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('Failed to start SSE stream:', error) console.error('Failed to start SSE stream:', error)
onError(ERROR_MESSAGES.STREAM_START_ERROR) onError(ERROR_MESSAGES.STREAM_START_ERROR)
sseSourceRef.current = null closeActiveStream(source)
} }
}, },
[] [closeActiveStream]
) )
const stopStream = useCallback(() => { const stopStream = useCallback(() => {
if (sseSourceRef.current) { closeActiveStream()
sseSourceRef.current.close() }, [closeActiveStream])
sseSourceRef.current = null
}
}, [])
// eslint-disable-next-line react-hooks/refs
const isStreaming = sseSourceRef.current !== null
return { return {
sendStreamRequest, sendStreamRequest,
stopStream, stopStream,
// eslint-disable-next-line react-hooks/refs
isStreaming, isStreaming,
} }
} }
...@@ -16,29 +16,28 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,29 +16,28 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useCallback, useEffect, useState } from 'react' import { PlaygroundChat } from './components/chat/playground-chat'
import { useQuery } from '@tanstack/react-query' import { PlaygroundInput } from './components/input/playground-input'
import { useTranslation } from 'react-i18next' import {
import { toast } from 'sonner' useChatHandler,
import { getUserModels, getUserGroups } from './api' usePlaygroundConversation,
import { PlaygroundChat } from './components/playground-chat' usePlaygroundOptions,
import { PlaygroundInput } from './components/playground-input' usePlaygroundState,
import { usePlaygroundState, useChatHandler } from './hooks' } from './hooks'
import { createUserMessage, createLoadingAssistantMessage } from './lib'
import type { Message as MessageType } from './types'
export function Playground() { export function Playground() {
const { t } = useTranslation()
const { const {
config, config,
parameterEnabled, parameterEnabled,
messages, messages,
isLoadingMessages,
models, models,
groups, groups,
updateMessages, updateMessages,
setModels, setModels,
setGroups, setGroups,
updateConfig, updateConfig,
clearMessages,
} = usePlaygroundState() } = usePlaygroundState()
const { sendChat, stopGeneration, isGenerating } = useChatHandler({ const { sendChat, stopGeneration, isGenerating } = useChatHandler({
...@@ -47,157 +46,44 @@ export function Playground() { ...@@ -47,157 +46,44 @@ export function Playground() {
onMessageUpdate: updateMessages, onMessageUpdate: updateMessages,
}) })
// Edit dialog state const {
const [editingMessageKey, setEditingMessageKey] = useState<string | null>( editingMessageKey,
null handleSendMessage,
) handleRegenerateMessage,
handleEditMessage,
// Load models handleEditOpenChange,
const { data: modelsData, isLoading: isLoadingModels } = useQuery({ applyEdit,
queryKey: ['playground-models'], handleDeleteMessage,
queryFn: async () => { } = usePlaygroundConversation({
try { messages,
return await getUserModels() updateMessages,
} catch (error) { sendChat,
toast.error(
error instanceof Error
? error.message
: t('Failed to load playground models')
)
return []
}
},
})
// Load groups
const { data: groupsData } = useQuery({
queryKey: ['playground-groups'],
queryFn: async () => {
try {
return await getUserGroups()
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t('Failed to load playground groups')
)
return []
}
},
}) })
// Update models when data changes const handleClearMessages = () => {
useEffect(() => { handleEditOpenChange(false)
if (!modelsData) return clearMessages()
setModels(modelsData)
// Set default model if current model is not available
const isCurrentModelValid = modelsData.some((m) => m.value === config.model)
if (modelsData.length > 0 && !isCurrentModelValid) {
updateConfig('model', modelsData[0].value)
}
}, [modelsData, config.model, setModels, updateConfig])
// Update groups when data changes
useEffect(() => {
if (!groupsData) return
setGroups(groupsData)
const hasCurrentGroup = groupsData.some((g) => g.value === config.group)
if (!hasCurrentGroup && groupsData.length > 0) {
const fallback =
groupsData.find((g) => g.value === 'default')?.value ??
groupsData[0].value
updateConfig('group', fallback)
}
}, [groupsData, setGroups, config.group, updateConfig])
const handleSendMessage = (text: string) => {
const userMessage = createUserMessage(text)
const assistantMessage = createLoadingAssistantMessage()
const newMessages = [...messages, userMessage, assistantMessage]
updateMessages(newMessages)
// Send chat request
sendChat(newMessages)
}
const handleCopyMessage = (message: MessageType) => {
// Copy is handled in MessageActions component
// eslint-disable-next-line no-console
console.log('Message copied:', message.key)
} }
const handleRegenerateMessage = (message: MessageType) => { const { isLoadingModels } = usePlaygroundOptions({
// Find the message index and regenerate from there currentGroup: config.group,
const messageIndex = messages.findIndex((m) => m.key === message.key) currentModel: config.model,
if (messageIndex === -1) return setGroups,
setModels,
// Remove messages after this one and regenerate updateConfig,
const messagesUpToHere = messages.slice(0, messageIndex) })
const loadingMessage = createLoadingAssistantMessage()
const newMessages = [...messagesUpToHere, loadingMessage]
updateMessages(newMessages)
sendChat(newMessages)
}
const handleEditMessage = useCallback((message: MessageType) => {
setEditingMessageKey(message.key)
}, [])
const handleEditOpenChange = useCallback((open: boolean) => {
if (!open) setEditingMessageKey(null)
}, [])
// Apply edit and optionally re-submit from the edited user message
const applyEdit = useCallback(
(newContent: string, submit: boolean) => {
if (!editingMessageKey) return
const index = messages.findIndex((m) => m.key === editingMessageKey)
if (index === -1) return
const updated = messages.map((m) =>
m.key === editingMessageKey
? { ...m, versions: [{ ...m.versions[0], content: newContent }] }
: m
)
setEditingMessageKey(null)
if (!submit || updated[index].from !== 'user') {
updateMessages(updated)
return
}
const toSubmit = [
...updated.slice(0, index + 1),
createLoadingAssistantMessage(),
]
updateMessages(toSubmit)
sendChat(toSubmit)
},
[editingMessageKey, messages, updateMessages, sendChat]
)
const handleDeleteMessage = (message: MessageType) => {
const newMessages = messages.filter((m) => m.key !== message.key)
updateMessages(newMessages)
}
return ( return (
<div className='relative flex size-full flex-col overflow-hidden'> <div className='relative flex size-full min-h-0 flex-col overflow-hidden'>
{/* Full-width scroll container: scrolling works even over side whitespace */} {/* Full-width scroll container: scrolling works even over side whitespace */}
<div className='flex flex-1 flex-col overflow-hidden'> <div className='flex min-h-0 flex-1 flex-col overflow-hidden'>
<PlaygroundChat <PlaygroundChat
messages={messages} messages={messages}
onCopyMessage={handleCopyMessage} isLoadingMessages={isLoadingMessages}
onRegenerateMessage={handleRegenerateMessage} onRegenerateMessage={handleRegenerateMessage}
onEditMessage={handleEditMessage} onEditMessage={handleEditMessage}
onDeleteMessage={handleDeleteMessage} onDeleteMessage={handleDeleteMessage}
onSelectPrompt={handleSendMessage}
isGenerating={isGenerating} isGenerating={isGenerating}
editingKey={editingMessageKey} editingKey={editingMessageKey}
onCancelEdit={handleEditOpenChange} onCancelEdit={handleEditOpenChange}
...@@ -217,9 +103,11 @@ export function Playground() { ...@@ -217,9 +103,11 @@ export function Playground() {
modelValue={config.model} modelValue={config.model}
models={models} models={models}
onGroupChange={(value) => updateConfig('group', value)} onGroupChange={(value) => updateConfig('group', value)}
onClearMessages={handleClearMessages}
onModelChange={(value) => updateConfig('model', value)} onModelChange={(value) => updateConfig('model', value)}
onStop={stopGeneration} onStop={stopGeneration}
onSubmit={handleSendMessage} onSubmit={handleSendMessage}
hasMessages={messages.length > 0}
/> />
</div> </div>
</div> </div>
......
...@@ -16,7 +16,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
export * from './message-utils' export * from './input/input-control-utils'
export * from './payload-builder' export * from './input/input-tool-utils'
export * from './storage' export * from './message/conversation-message-utils'
export * from './message-styles' 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 './state/playground-state-utils'
export * from './storage/storage'
export * from './streaming/payload-builder'
export * from './streaming/request-error-utils'
export * from './streaming/stream-utils'
/*
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),
}
}
/*
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',
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment