Resolve verified V1 frontend feedback by improving channel workflows, auth behavior, API key interactions, user filtering, layout persistence, subscription quota handling, i18n text, pricing metadata, and stale frontend cache recovery. - Add a global frontend cache version cleanup to prevent old frontend localStorage from causing page errors after upgrades. - Fix channel copy refresh, model mapping input focus loss, create-channel fetch-model title state, upstream model update confirmation, and batch test toast behavior. - Respect password login settings and improve Turnstile, forgot-password, registration, and invite-link flows. - Make user role/status filtering server-side and preserve table page size in URLs. - Improve API key edit validation feedback and prefetch real keys for reliable copy actions. - Fix rankings access fail-open behavior, double scrollbars, subscription received amount conversion/display, token i18n wording, model deletion confirmation grammar, and Claude pricing context inference. - Add clearer Playground model/group loading errors. Validation: - bun run typecheck - bun run i18n:sync - gofmt on modified Go files - go test ./controller ./model -run '^$'
228 lines
7.0 KiB
TypeScript
Vendored
228 lines
7.0 KiB
TypeScript
Vendored
/*
|
|
Copyright (C) 2023-2026 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import { getUserModels, getUserGroups } from './api'
|
|
import { PlaygroundChat } from './components/playground-chat'
|
|
import { PlaygroundInput } from './components/playground-input'
|
|
import { usePlaygroundState, useChatHandler } from './hooks'
|
|
import { createUserMessage, createLoadingAssistantMessage } from './lib'
|
|
import type { Message as MessageType } from './types'
|
|
|
|
export function Playground() {
|
|
const { t } = useTranslation()
|
|
const {
|
|
config,
|
|
parameterEnabled,
|
|
messages,
|
|
models,
|
|
groups,
|
|
updateMessages,
|
|
setModels,
|
|
setGroups,
|
|
updateConfig,
|
|
} = usePlaygroundState()
|
|
|
|
const { sendChat, stopGeneration, isGenerating } = useChatHandler({
|
|
config,
|
|
parameterEnabled,
|
|
onMessageUpdate: updateMessages,
|
|
})
|
|
|
|
// Edit dialog state
|
|
const [editingMessageKey, setEditingMessageKey] = useState<string | null>(
|
|
null
|
|
)
|
|
|
|
// Load models
|
|
const { data: modelsData, isLoading: isLoadingModels } = useQuery({
|
|
queryKey: ['playground-models'],
|
|
queryFn: async () => {
|
|
try {
|
|
return await getUserModels()
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: t('Failed to load playground models')
|
|
)
|
|
return []
|
|
}
|
|
},
|
|
})
|
|
|
|
// 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
|
|
useEffect(() => {
|
|
if (!modelsData) return
|
|
|
|
setModels(modelsData)
|
|
|
|
// Set default model if current model is not available
|
|
const isCurrentModelValid = modelsData.some((m) => m.value === config.model)
|
|
if (modelsData.length > 0 && !isCurrentModelValid) {
|
|
updateConfig('model', modelsData[0].value)
|
|
}
|
|
}, [modelsData, config.model, setModels, updateConfig])
|
|
|
|
// Update groups when data changes
|
|
useEffect(() => {
|
|
if (!groupsData) return
|
|
|
|
setGroups(groupsData)
|
|
|
|
const hasCurrentGroup = groupsData.some((g) => g.value === config.group)
|
|
if (!hasCurrentGroup && groupsData.length > 0) {
|
|
const fallback =
|
|
groupsData.find((g) => g.value === 'default')?.value ??
|
|
groupsData[0].value
|
|
updateConfig('group', fallback)
|
|
}
|
|
}, [groupsData, setGroups, config.group, updateConfig])
|
|
|
|
const handleSendMessage = (text: string) => {
|
|
const userMessage = createUserMessage(text)
|
|
const assistantMessage = createLoadingAssistantMessage()
|
|
|
|
const newMessages = [...messages, userMessage, assistantMessage]
|
|
updateMessages(newMessages)
|
|
|
|
// Send chat request
|
|
sendChat(newMessages)
|
|
}
|
|
|
|
const handleCopyMessage = (message: MessageType) => {
|
|
// Copy is handled in MessageActions component
|
|
// eslint-disable-next-line no-console
|
|
console.log('Message copied:', message.key)
|
|
}
|
|
|
|
const handleRegenerateMessage = (message: MessageType) => {
|
|
// Find the message index and regenerate from there
|
|
const messageIndex = messages.findIndex((m) => m.key === message.key)
|
|
if (messageIndex === -1) return
|
|
|
|
// Remove messages after this one and regenerate
|
|
const messagesUpToHere = messages.slice(0, messageIndex)
|
|
const loadingMessage = createLoadingAssistantMessage()
|
|
const newMessages = [...messagesUpToHere, loadingMessage]
|
|
|
|
updateMessages(newMessages)
|
|
sendChat(newMessages)
|
|
}
|
|
|
|
const handleEditMessage = useCallback((message: MessageType) => {
|
|
setEditingMessageKey(message.key)
|
|
}, [])
|
|
|
|
const handleEditOpenChange = useCallback((open: boolean) => {
|
|
if (!open) setEditingMessageKey(null)
|
|
}, [])
|
|
|
|
// Apply edit and optionally re-submit from the edited user message
|
|
const applyEdit = useCallback(
|
|
(newContent: string, submit: boolean) => {
|
|
if (!editingMessageKey) return
|
|
const index = messages.findIndex((m) => m.key === editingMessageKey)
|
|
if (index === -1) return
|
|
|
|
const updated = messages.map((m) =>
|
|
m.key === editingMessageKey
|
|
? { ...m, versions: [{ ...m.versions[0], content: newContent }] }
|
|
: m
|
|
)
|
|
|
|
setEditingMessageKey(null)
|
|
|
|
if (!submit || updated[index].from !== 'user') {
|
|
updateMessages(updated)
|
|
return
|
|
}
|
|
|
|
const toSubmit = [
|
|
...updated.slice(0, index + 1),
|
|
createLoadingAssistantMessage(),
|
|
]
|
|
updateMessages(toSubmit)
|
|
sendChat(toSubmit)
|
|
},
|
|
[editingMessageKey, messages, updateMessages, sendChat]
|
|
)
|
|
|
|
const handleDeleteMessage = (message: MessageType) => {
|
|
const newMessages = messages.filter((m) => m.key !== message.key)
|
|
updateMessages(newMessages)
|
|
}
|
|
|
|
return (
|
|
<div className='relative flex size-full flex-col overflow-hidden'>
|
|
{/* Full-width scroll container: scrolling works even over side whitespace */}
|
|
<div className='flex flex-1 flex-col overflow-hidden'>
|
|
<PlaygroundChat
|
|
messages={messages}
|
|
onCopyMessage={handleCopyMessage}
|
|
onRegenerateMessage={handleRegenerateMessage}
|
|
onEditMessage={handleEditMessage}
|
|
onDeleteMessage={handleDeleteMessage}
|
|
isGenerating={isGenerating}
|
|
editingKey={editingMessageKey}
|
|
onCancelEdit={handleEditOpenChange}
|
|
onSaveEdit={(newContent) => applyEdit(newContent, false)}
|
|
onSaveEditAndSubmit={(newContent) => applyEdit(newContent, true)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Input area: center content and constrain to the same container width */}
|
|
<div className='mx-auto w-full max-w-4xl'>
|
|
<PlaygroundInput
|
|
disabled={isGenerating}
|
|
groups={groups}
|
|
groupValue={config.group}
|
|
isGenerating={isGenerating}
|
|
isModelLoading={isLoadingModels}
|
|
modelValue={config.model}
|
|
models={models}
|
|
onGroupChange={(value) => updateConfig('group', value)}
|
|
onModelChange={(value) => updateConfig('model', value)}
|
|
onStop={stopGeneration}
|
|
onSubmit={handleSendMessage}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|