Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f53b557a17 | |||
| 4372abd787 | |||
| 9b633a4131 | |||
| ffb1e8e97a | |||
| fa334c1eb0 | |||
| fbcaf75b62 | |||
| 47e912123c | |||
| 4f03641ac7 | |||
| efc9c5844b | |||
| 5a5286967d | |||
| 80a54b5b4b | |||
| 6ba23572b2 | |||
| 378eed2bd4 | |||
| 61717ee53b | |||
| f738ee481c | |||
| 0f94c07f16 | |||
| d75e393b11 | |||
| eef921d188 | |||
| b6ad800e77 | |||
| c82242f0d2 | |||
| a8c19eec50 | |||
| 0f625f33a0 | |||
| 902593926a | |||
| b4e7c48e42 | |||
| f03c8cc709 | |||
| 6aba2b3eec | |||
| 80ee5244d9 | |||
| f87af88ca5 | |||
| 5816f69c20 | |||
| 3606367104 | |||
| 0339e36246 | |||
| 1f3eb1e419 | |||
| 6b5ee783f1 | |||
| 2f16326562 | |||
| 76469cb944 | |||
| 47d4d74bd6 | |||
| 59f3758175 | |||
| 0deab07bb6 | |||
| 7465f682f8 | |||
| 894f25ca51 | |||
| 12b103e9b6 | |||
| fdffe43533 | |||
| 809e1dce6d | |||
| 43c003e8e1 | |||
| b0bf0b949b | |||
| 2d94a24912 | |||
| a297c00cc3 | |||
| 5489c68eec | |||
| c40d00e740 | |||
| e6e86b8e8c | |||
| 3f2107fb6d | |||
| 8a3e353231 | |||
| e8c836d705 | |||
| e79cee1e9e | |||
| 63ead2bf7f | |||
| 5b86ce0d70 | |||
| 74985fa877 | |||
| 1d32037364 | |||
| dc245ae764 | |||
| f8add4ca49 | |||
| 65f8afe922 |
@@ -35,3 +35,4 @@ data/
|
||||
.test
|
||||
token_estimator_test.go
|
||||
skills-lock.json
|
||||
.playwright-mcp
|
||||
|
||||
+31
-20
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -16,6 +17,20 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func applyExplicitLogTextFilter(tx *gorm.DB, column string, value string) (*gorm.DB, error) {
|
||||
if value == "" {
|
||||
return tx, nil
|
||||
}
|
||||
if strings.Contains(value, "%") {
|
||||
pattern, err := sanitizeLikePattern(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tx.Where(column+" LIKE ? ESCAPE '!'", pattern), nil
|
||||
}
|
||||
return tx.Where(column+" = ?", value), nil
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"`
|
||||
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
|
||||
@@ -308,11 +323,11 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
tx = LOG_DB.Where("logs.type = ?", logType)
|
||||
}
|
||||
|
||||
if modelName != "" {
|
||||
tx = tx.Where("logs.model_name like ?", modelName)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if username != "" {
|
||||
tx = tx.Where("logs.username = ?", username)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.username", username); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
@@ -397,12 +412,8 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
|
||||
tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType)
|
||||
}
|
||||
|
||||
if modelName != "" {
|
||||
modelNamePattern, err := sanitizeLikePattern(modelName)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
tx = tx.Where("logs.model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
@@ -449,9 +460,11 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
|
||||
// 为rpm和tpm创建单独的查询
|
||||
rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, sum(prompt_tokens) + sum(completion_tokens) tpm")
|
||||
|
||||
if username != "" {
|
||||
tx = tx.Where("username = ?", username)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("username = ?", username)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "username", username); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "username", username); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("token_name = ?", tokenName)
|
||||
@@ -463,13 +476,11 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
|
||||
if endTimestamp != 0 {
|
||||
tx = tx.Where("created_at <= ?", endTimestamp)
|
||||
}
|
||||
if modelName != "" {
|
||||
modelNamePattern, err := sanitizeLikePattern(modelName)
|
||||
if err != nil {
|
||||
return stat, err
|
||||
}
|
||||
tx = tx.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "model_name", modelName); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "model_name", modelName); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if channel != 0 {
|
||||
tx = tx.Where("channel_id = ?", channel)
|
||||
|
||||
@@ -984,6 +984,23 @@ func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) {
|
||||
//}
|
||||
}
|
||||
|
||||
func updateUserQuotaUsedQuotaAndRequestCount(id int, quota int, usedQuota int, requestCount int) {
|
||||
if quota == 0 && usedQuota == 0 && requestCount == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := DB.Model(&User{}).Where("id = ?", id).Updates(
|
||||
map[string]interface{}{
|
||||
"quota": gorm.Expr("quota + ?", quota),
|
||||
"used_quota": gorm.Expr("used_quota + ?", usedQuota),
|
||||
"request_count": gorm.Expr("request_count + ?", requestCount),
|
||||
},
|
||||
).Error
|
||||
if err != nil {
|
||||
common.SysLog("failed to batch update user quota, used quota and request count: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func updateUserUsedQuota(id int, quota int) {
|
||||
err := DB.Model(&User{}).Where("id = ?", id).Updates(
|
||||
map[string]interface{}{
|
||||
|
||||
+26
-11
@@ -67,33 +67,48 @@ func batchUpdate() {
|
||||
}
|
||||
|
||||
common.SysLog("batch update started")
|
||||
stores := make([]map[int]int, BatchUpdateTypeCount)
|
||||
for i := 0; i < BatchUpdateTypeCount; i++ {
|
||||
batchUpdateLocks[i].Lock()
|
||||
store := batchUpdateStores[i]
|
||||
stores[i] = batchUpdateStores[i]
|
||||
batchUpdateStores[i] = make(map[int]int)
|
||||
batchUpdateLocks[i].Unlock()
|
||||
// TODO: maybe we can combine updates with same key?
|
||||
}
|
||||
|
||||
for i, store := range stores {
|
||||
if i == BatchUpdateTypeUserQuota || i == BatchUpdateTypeUsedQuota || i == BatchUpdateTypeRequestCount {
|
||||
continue
|
||||
}
|
||||
for key, value := range store {
|
||||
switch i {
|
||||
case BatchUpdateTypeUserQuota:
|
||||
err := increaseUserQuota(key, value)
|
||||
if err != nil {
|
||||
common.SysLog("failed to batch update user quota: " + err.Error())
|
||||
}
|
||||
case BatchUpdateTypeTokenQuota:
|
||||
err := increaseTokenQuota(key, value)
|
||||
if err != nil {
|
||||
common.SysLog("failed to batch update token quota: " + err.Error())
|
||||
}
|
||||
case BatchUpdateTypeUsedQuota:
|
||||
updateUserUsedQuota(key, value)
|
||||
case BatchUpdateTypeRequestCount:
|
||||
updateUserRequestCount(key, value)
|
||||
case BatchUpdateTypeChannelUsedQuota:
|
||||
updateChannelUsedQuota(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userQuotaStore := stores[BatchUpdateTypeUserQuota]
|
||||
usedQuotaStore := stores[BatchUpdateTypeUsedQuota]
|
||||
requestCountStore := stores[BatchUpdateTypeRequestCount]
|
||||
|
||||
userIDs := make(map[int]struct{}, len(userQuotaStore)+len(usedQuotaStore)+len(requestCountStore))
|
||||
for key := range userQuotaStore {
|
||||
userIDs[key] = struct{}{}
|
||||
}
|
||||
for key := range usedQuotaStore {
|
||||
userIDs[key] = struct{}{}
|
||||
}
|
||||
for key := range requestCountStore {
|
||||
userIDs[key] = struct{}{}
|
||||
}
|
||||
for key := range userIDs {
|
||||
updateUserQuotaUsedQuotaAndRequestCount(key, userQuotaStore[key], usedQuotaStore[key], requestCountStore[key])
|
||||
}
|
||||
common.SysLog("batch update finished")
|
||||
}
|
||||
|
||||
|
||||
+254
-23
@@ -23,34 +23,66 @@ import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { Element } from 'hast'
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import {
|
||||
type BundledLanguage,
|
||||
codeToHtml,
|
||||
type ShikiTransformer,
|
||||
} from 'shiki/bundle/web'
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BundledLanguage, ShikiTransformer } from 'shiki'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string
|
||||
language: BundledLanguage
|
||||
collapsedLines?: number
|
||||
defaultCollapsed?: boolean
|
||||
enableCollapse?: boolean
|
||||
filename?: string
|
||||
language: BundledLanguage | string
|
||||
maxExpandedLines?: number
|
||||
/** @deprecated use collapsedLines for collapsed preview height. */
|
||||
maxCollapsedLines?: number
|
||||
showLineNumbers?: boolean
|
||||
showToolbar?: boolean
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string
|
||||
language: string
|
||||
}
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: '',
|
||||
language: 'plaintext',
|
||||
})
|
||||
|
||||
const highlightCache = new Map<string, string>()
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
|
||||
csharp: 'c#',
|
||||
golang: 'go',
|
||||
js: 'javascript',
|
||||
shell: 'bash',
|
||||
shellscript: 'bash',
|
||||
ts: 'typescript',
|
||||
}
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: 'line-numbers',
|
||||
line(node: Element, line: number) {
|
||||
@@ -72,64 +104,251 @@ const lineNumberTransformer: ShikiTransformer = {
|
||||
},
|
||||
}
|
||||
|
||||
function getRequestedCodeLanguage(language?: string) {
|
||||
const normalized = language?.trim().toLowerCase() || 'plaintext'
|
||||
return LANGUAGE_ALIASES[normalized] ?? normalized
|
||||
}
|
||||
|
||||
async function normalizeCodeLanguage(language?: string) {
|
||||
const aliased = getRequestedCodeLanguage(language)
|
||||
const { bundledLanguages } = await import('shiki')
|
||||
if (aliased in bundledLanguages) {
|
||||
return aliased as BundledLanguage
|
||||
}
|
||||
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
function escapeCodeHtml(code: string) {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function renderPlainCodeHtml(code: string, showLineNumbers: boolean) {
|
||||
const lines = code.split('\n')
|
||||
const renderedCode = lines
|
||||
.map((line, index) => {
|
||||
const escapedLine = escapeCodeHtml(line) || ' '
|
||||
if (!showLineNumbers) {
|
||||
return escapedLine
|
||||
}
|
||||
|
||||
return `<span class="inline-block min-w-10 mr-4 text-right select-none text-muted-foreground">${index + 1}</span>${escapedLine}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<pre class="shiki"><code>${renderedCode}</code></pre>`
|
||||
}
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
language: BundledLanguage | string,
|
||||
showLineNumbers = false
|
||||
) {
|
||||
const resolvedLanguage = await normalizeCodeLanguage(language)
|
||||
const cacheKey = `${resolvedLanguage}:${showLineNumbers ? 'line' : 'plain'}:${code}`
|
||||
const cachedHtml = highlightCache.get(cacheKey)
|
||||
|
||||
if (cachedHtml) {
|
||||
return cachedHtml
|
||||
}
|
||||
|
||||
const transformers: ShikiTransformer[] = showLineNumbers
|
||||
? [lineNumberTransformer]
|
||||
: []
|
||||
|
||||
return codeToHtml(code, {
|
||||
lang: language,
|
||||
if (resolvedLanguage === 'plaintext') {
|
||||
const html = renderPlainCodeHtml(code, showLineNumbers)
|
||||
highlightCache.set(cacheKey, html)
|
||||
return html
|
||||
}
|
||||
|
||||
const { codeToHtml } = await import('shiki')
|
||||
const html = await codeToHtml(code, {
|
||||
lang: resolvedLanguage,
|
||||
themes: {
|
||||
light: 'one-light',
|
||||
dark: 'one-dark-pro',
|
||||
},
|
||||
defaultColor: false,
|
||||
transformers,
|
||||
})
|
||||
|
||||
highlightCache.set(cacheKey, html)
|
||||
return html
|
||||
}
|
||||
|
||||
function getCodeLineCount(code: string) {
|
||||
if (!code) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return code.split('\n').length
|
||||
}
|
||||
|
||||
function getDownloadFilename(language: string, filename?: string) {
|
||||
if (filename) {
|
||||
return filename
|
||||
}
|
||||
|
||||
const extension = language === 'plaintext' ? 'txt' : language
|
||||
return `code.${extension}`
|
||||
}
|
||||
|
||||
function getCodeBlockHeight(lines: number) {
|
||||
return `${Math.max(4, lines) * 1.5 + 2}rem`
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
collapsedLines = 12,
|
||||
defaultCollapsed,
|
||||
enableCollapse = true,
|
||||
filename,
|
||||
language,
|
||||
maxExpandedLines,
|
||||
maxCollapsedLines,
|
||||
showLineNumbers = false,
|
||||
showToolbar = false,
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [html, setHtml] = useState<string>('')
|
||||
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed))
|
||||
const displayLanguage = getRequestedCodeLanguage(language)
|
||||
const lineCount = useMemo(() => getCodeLineCount(code), [code])
|
||||
const previewLines = maxCollapsedLines ?? collapsedLines
|
||||
const canCollapse = enableCollapse && lineCount > previewLines
|
||||
const isCodeCollapsed = canCollapse && isCollapsed
|
||||
const displayTitle = title ?? displayLanguage
|
||||
const bodyMaxHeight = isCodeCollapsed
|
||||
? getCodeBlockHeight(previewLines)
|
||||
: maxExpandedLines
|
||||
? getCodeBlockHeight(maxExpandedLines)
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
highlightCode(code, language, showLineNumbers).then((next) => {
|
||||
if (!cancelled) {
|
||||
setHtml(next)
|
||||
}
|
||||
})
|
||||
highlightCode(code, language, showLineNumbers)
|
||||
.then((next) => {
|
||||
if (!cancelled) {
|
||||
setHtml(next)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setHtml(renderPlainCodeHtml(code, showLineNumbers))
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [code, language, showLineNumbers])
|
||||
|
||||
const downloadCode = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = getDownloadFilename(displayLanguage, filename)
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<CodeBlockContext.Provider value={{ code, language: displayLanguage }}>
|
||||
<div
|
||||
className={cn(
|
||||
'group bg-background text-foreground relative w-full overflow-hidden rounded-md border',
|
||||
'group/code-block bg-muted/20 text-foreground my-3 w-full max-w-full overflow-hidden rounded-lg border shadow-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='relative'>
|
||||
{showToolbar && (
|
||||
<div className='bg-muted/35 border-border/70 flex min-h-10 items-center gap-2 border-b px-2 py-1.5'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='text-muted-foreground truncate font-mono text-[11px] font-medium tracking-wide uppercase'>
|
||||
{displayTitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1'>
|
||||
{canCollapse && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={
|
||||
isCodeCollapsed ? t('Expand') : t('Collapse')
|
||||
}
|
||||
className='size-8'
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
>
|
||||
{isCodeCollapsed ? (
|
||||
<ChevronRightIcon className='size-4' />
|
||||
) : (
|
||||
<ChevronDownIcon className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>{isCodeCollapsed ? t('Expand') : t('Collapse')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{children}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t('Download')}
|
||||
className='size-8'
|
||||
onClick={downloadCode}
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
>
|
||||
<DownloadIcon className='size-4' />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>{t('Download')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='relative min-w-0'>
|
||||
<div
|
||||
className='[&>pre]:bg-background! [&>pre]:text-foreground! overflow-hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm'
|
||||
className={cn(
|
||||
'code-block-scroll max-w-full overflow-auto transition-[max-height] duration-200 ease-out',
|
||||
'[&_.shiki]:bg-transparent! [&_.shiki]:text-foreground! [&_code]:font-mono [&_code]:text-[13px] [&_code]:leading-6',
|
||||
'[&>pre]:m-0 [&>pre]:min-w-max [&>pre]:p-4 [&>pre]:text-[13px] [&>pre]:leading-6'
|
||||
)}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
style={{ maxHeight: bodyMaxHeight }}
|
||||
/>
|
||||
{children && (
|
||||
<div className='absolute top-2 right-2 flex items-center gap-2'>
|
||||
{isCodeCollapsed && (
|
||||
<div className='from-muted/20 pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-linear-to-b to-background' />
|
||||
)}
|
||||
{!showToolbar && children && (
|
||||
<div className='absolute top-2 right-2 flex items-center gap-1'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -153,6 +372,7 @@ export const CodeBlockCopyButton = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const { code } = useContext(CodeBlockContext)
|
||||
|
||||
@@ -174,15 +394,26 @@ export const CodeBlockCopyButton = ({
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<Button
|
||||
className={cn('shrink-0', className)}
|
||||
aria-label={isCopied ? t('Copied!') : t('Copy code')}
|
||||
className={cn('size-8 shrink-0', className)}
|
||||
onClick={copyToClipboard}
|
||||
size='icon'
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent>
|
||||
<p>{isCopied ? t('Copied!') : t('Copy code')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
className={cn('relative min-h-0 flex-1 overflow-hidden', className)}
|
||||
initial='smooth'
|
||||
resize='smooth'
|
||||
role='log'
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-4 text-sm',
|
||||
'border-border/70 mt-3 ml-2 border-l pl-4 text-sm',
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
|
||||
+436
-4
@@ -18,14 +18,436 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { type ComponentProps, memo } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import {
|
||||
Children,
|
||||
type ComponentProps,
|
||||
type JSX,
|
||||
isValidElement,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Streamdown, type Components } from 'streamdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
} from '@/components/ai-elements/code-block'
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown>
|
||||
|
||||
type CodeComponentProps = ComponentProps<'code'> & {
|
||||
node?: unknown
|
||||
'data-block'?: boolean
|
||||
}
|
||||
|
||||
type MarkdownElementProps<T extends keyof JSX.IntrinsicElements> =
|
||||
ComponentProps<T> & {
|
||||
node?: unknown
|
||||
}
|
||||
|
||||
function getCodeText(children: ReactNode) {
|
||||
if (typeof children === 'string') {
|
||||
return children.replace(/\n$/, '')
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return children.join('').replace(/\n$/, '')
|
||||
}
|
||||
|
||||
return String(children ?? '')
|
||||
}
|
||||
|
||||
function getCodeLanguage(className?: string) {
|
||||
return className?.match(/language-([\w#+.-]+)/)?.[1] ?? 'plaintext'
|
||||
}
|
||||
|
||||
function isSummaryElement(child: ReactNode) {
|
||||
return isValidElement(child) && child.type === 'summary'
|
||||
}
|
||||
|
||||
function MarkdownImage({
|
||||
alt,
|
||||
className,
|
||||
node: _node,
|
||||
src,
|
||||
...props
|
||||
}: MarkdownElementProps<'img'>) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
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'>
|
||||
{alt || 'Image not available'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'border-border/70 my-4 block h-auto max-h-96 max-w-full rounded-lg border object-contain',
|
||||
className
|
||||
)}
|
||||
loading='lazy'
|
||||
onError={() => setHasError(true)}
|
||||
src={src}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const responseComponents: Components = {
|
||||
h1({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h1'>) {
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
'mt-6 mb-3 text-xl font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
},
|
||||
h2({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h2'>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
'mt-6 mb-3 text-lg font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
},
|
||||
h3({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h3'>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
'mt-5 mb-2 text-base font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
},
|
||||
h4({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h4'>) {
|
||||
return (
|
||||
<h4
|
||||
className={cn('mt-5 mb-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
},
|
||||
h5({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h5'>) {
|
||||
return (
|
||||
<h5
|
||||
className={cn(
|
||||
'text-muted-foreground mt-4 mb-2 text-sm font-semibold',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
)
|
||||
},
|
||||
h6({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h6'>) {
|
||||
return (
|
||||
<h6
|
||||
className={cn(
|
||||
'text-muted-foreground mt-4 mb-2 text-xs font-semibold uppercase',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
)
|
||||
},
|
||||
ul({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
className={cn(
|
||||
'my-3 list-outside list-disc space-y-1.5 pl-5',
|
||||
'[&.contains-task-list]:list-none [&.contains-task-list]:pl-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
ol({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
className={cn(
|
||||
'my-3 list-outside list-decimal space-y-1.5 pl-5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
},
|
||||
li({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'marker:text-muted-foreground pl-1 leading-7',
|
||||
'[&.task-list-item]:flex [&.task-list-item]:items-start [&.task-list-item]:gap-2 [&.task-list-item]:pl-0',
|
||||
'[&.task-list-item>input]:accent-primary [&.task-list-item>input]:mt-1.5 [&.task-list-item>input]:size-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
details({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'details'>) {
|
||||
const childArray = Children.toArray(children)
|
||||
const summaryChildren = childArray.filter(isSummaryElement)
|
||||
const contentChildren = childArray.filter(
|
||||
(child) => !isSummaryElement(child)
|
||||
)
|
||||
|
||||
return (
|
||||
<details className={cn('my-4', className)} {...props}>
|
||||
{summaryChildren}
|
||||
{contentChildren.length > 0 && (
|
||||
<div className='border-border/70 ml-5 border-l pl-4'>
|
||||
{contentChildren}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)
|
||||
},
|
||||
summary({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'summary'>) {
|
||||
return (
|
||||
<summary
|
||||
className={cn(
|
||||
'text-foreground marker:text-muted-foreground mb-2 cursor-pointer text-sm font-semibold',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</summary>
|
||||
)
|
||||
},
|
||||
blockquote({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'blockquote'>) {
|
||||
return (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'border-border text-muted-foreground my-4 border-l-2 pl-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
},
|
||||
hr({ className, node: _node, ...props }: MarkdownElementProps<'hr'>) {
|
||||
return <hr className={cn('border-border/70 my-6', className)} {...props} />
|
||||
},
|
||||
img: MarkdownImage,
|
||||
table({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'table'>) {
|
||||
return (
|
||||
<div className='border-border/70 my-4 w-full overflow-x-auto rounded-lg border'>
|
||||
<table
|
||||
className={cn(
|
||||
'w-full min-w-max border-separate border-spacing-0 text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
thead({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'thead'>) {
|
||||
return (
|
||||
<thead className={cn('bg-muted/60', className)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
)
|
||||
},
|
||||
tbody({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'tbody'>) {
|
||||
return (
|
||||
<tbody className={cn('divide-border/70 divide-y', className)} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
)
|
||||
},
|
||||
tr({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'tr'>) {
|
||||
return (
|
||||
<tr className={cn('border-border/70', className)} {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
)
|
||||
},
|
||||
th({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
td({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'td'>) {
|
||||
return (
|
||||
<td className={cn('px-3 py-2 align-top', className)} {...props}>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
},
|
||||
code({ children, className, ...props }: CodeComponentProps) {
|
||||
if (!props['data-block']) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
'bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
const code = getCodeText(children)
|
||||
const language = getCodeLanguage(className)
|
||||
const lineCount = code.split('\n').length
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
collapsedLines={14}
|
||||
code={code}
|
||||
defaultCollapsed={lineCount > 14}
|
||||
language={language}
|
||||
maxExpandedLines={44}
|
||||
showLineNumbers={true}
|
||||
showToolbar={true}
|
||||
title={language}
|
||||
>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlock>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Response = memo(
|
||||
({ className, children, ...props }: ResponseProps) => {
|
||||
({ className, children, components, ...props }: ResponseProps) => {
|
||||
const stripCustomTags = (input: unknown): unknown => {
|
||||
if (typeof input !== 'string') return input
|
||||
return (
|
||||
@@ -45,9 +467,19 @@ export const Response = memo(
|
||||
return (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
'size-full min-w-0 text-pretty',
|
||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
'[&_p]:my-3 [&_p]:leading-7',
|
||||
'[&_strong]:text-foreground [&_strong]:font-semibold',
|
||||
'[&_a]:text-primary [&_a]:underline-offset-4 hover:[&_a]:underline',
|
||||
'[&_details>summary~*]:border-border/70 [&_details]:my-4 [&_details>summary~*]:ml-5 [&_details>summary~*]:border-l [&_details>summary~*]:pl-4',
|
||||
'[&_summary]:text-foreground [&_summary::marker]:text-muted-foreground [&_summary]:mb-2 [&_summary]:cursor-pointer [&_summary]:text-sm [&_summary]:font-semibold',
|
||||
'[&_[data-streamdown=table-wrapper]]:border-0 [&_[data-streamdown=table-wrapper]]:bg-transparent [&_[data-streamdown=table-wrapper]]:p-0 [&_[data-streamdown=table-wrapper]]:shadow-none',
|
||||
'[&_[data-streamdown=table-wrapper]>div:first-child]:hidden',
|
||||
'[&_[data-streamdown=table-wrapper]>div:last-child]:border-border/70 [&_[data-streamdown=table-wrapper]>div:last-child]:rounded-lg',
|
||||
className
|
||||
)}
|
||||
components={{ ...responseComponents, ...components }}
|
||||
{...props}
|
||||
>
|
||||
{safeChildren}
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export const SourcesContent = ({
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
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',
|
||||
className
|
||||
)}
|
||||
|
||||
+2
-1
@@ -490,6 +490,7 @@ function ScaleConfig() {
|
||||
{ value: 'sm', label: t('Compact'), rows: 4, rowGap: '3px' },
|
||||
{ value: 'default', label: t('Default'), rows: 3, rowGap: '6px' },
|
||||
{ value: 'lg', label: t('Comfortable'), rows: 2, rowGap: '10px' },
|
||||
{ value: 'xl', label: t('Super Large'), rows: 1, rowGap: '14px' },
|
||||
]
|
||||
return (
|
||||
<div>
|
||||
@@ -501,7 +502,7 @@ function ScaleConfig() {
|
||||
<Radio
|
||||
value={customization.scale}
|
||||
onValueChange={(v) => setScale(v as ThemeScale)}
|
||||
className='grid w-full grid-cols-3 gap-4'
|
||||
className='grid w-full grid-cols-4 gap-3'
|
||||
aria-label={t('Select interface density')}
|
||||
>
|
||||
{scaleOptions.map((option) => (
|
||||
|
||||
+1
-2
@@ -79,7 +79,6 @@ const sizeMap = {
|
||||
lg: 'h-6 gap-1.5 px-2 text-xs leading-none',
|
||||
} as const
|
||||
|
||||
|
||||
export interface StatusBadgeProps extends Omit<
|
||||
React.HTMLAttributes<HTMLSpanElement>,
|
||||
'children'
|
||||
@@ -132,7 +131,7 @@ export function StatusBadge({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex w-fit max-w-full shrink-0 items-center rounded-full font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
sizeMap[size ?? 'sm'],
|
||||
textColorMap[computedVariant],
|
||||
pulse && 'animate-pulse',
|
||||
|
||||
+99
-1
@@ -31,7 +31,99 @@ import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const Form = FormProvider
|
||||
type FormRootContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormRootContext = React.createContext<FormRootContextValue | null>(null)
|
||||
|
||||
function getFormScopedSelector(formId: string, selector: string): string {
|
||||
return `[data-form-root="${formId}"]${selector}`
|
||||
}
|
||||
|
||||
function hasFormErrors(errors: unknown): boolean {
|
||||
return (
|
||||
typeof errors === 'object' &&
|
||||
errors !== null &&
|
||||
Object.keys(errors).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function getFirstFormErrorTarget(
|
||||
invalidControl: HTMLElement | null,
|
||||
errorMessage: HTMLElement | null
|
||||
): HTMLElement | null {
|
||||
if (!invalidControl) return errorMessage
|
||||
if (!errorMessage) return invalidControl
|
||||
|
||||
const position = invalidControl.compareDocumentPosition(errorMessage)
|
||||
return position & Node.DOCUMENT_POSITION_PRECEDING
|
||||
? errorMessage
|
||||
: invalidControl
|
||||
}
|
||||
|
||||
function FormValidationFocus() {
|
||||
const formContext = React.useContext(FormRootContext)
|
||||
const { control } = useFormContext()
|
||||
const { errors, submitCount } = useFormState({ control })
|
||||
const handledSubmitCountRef = React.useRef(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!formContext || submitCount === 0 || !hasFormErrors(errors)) return
|
||||
if (handledSubmitCountRef.current === submitCount) return
|
||||
|
||||
handledSubmitCountRef.current = submitCount
|
||||
|
||||
const animationFrameId = window.requestAnimationFrame(() => {
|
||||
const invalidControl = document.querySelector<HTMLElement>(
|
||||
getFormScopedSelector(formContext.id, '[aria-invalid="true"]')
|
||||
)
|
||||
const errorMessage = document.querySelector<HTMLElement>(
|
||||
getFormScopedSelector(formContext.id, '[data-slot="form-message"]')
|
||||
)
|
||||
const target = getFirstFormErrorTarget(invalidControl, errorMessage)
|
||||
if (!target) return
|
||||
|
||||
const formItem = target.closest<HTMLElement>(
|
||||
getFormScopedSelector(formContext.id, '[data-slot="form-item"]')
|
||||
)
|
||||
const scrollTarget = formItem ?? target
|
||||
const focusTarget =
|
||||
target === invalidControl
|
||||
? invalidControl
|
||||
: (formItem?.querySelector<HTMLElement>(
|
||||
'[aria-invalid="true"], input, textarea, select, button, [tabindex]:not([tabindex="-1"])'
|
||||
) ?? null)
|
||||
|
||||
scrollTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
focusTarget?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
return () => window.cancelAnimationFrame(animationFrameId)
|
||||
}, [errors, formContext, submitCount])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Form<TFieldValues extends FieldValues = FieldValues>({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof FormProvider<TFieldValues>>) {
|
||||
const reactId = React.useId()
|
||||
const id = React.useMemo(
|
||||
() => `form-${reactId.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}`,
|
||||
[reactId]
|
||||
)
|
||||
|
||||
return (
|
||||
<FormRootContext.Provider value={{ id }}>
|
||||
<FormProvider {...props}>
|
||||
<FormValidationFocus />
|
||||
{children}
|
||||
</FormProvider>
|
||||
</FormRootContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
@@ -90,11 +182,13 @@ const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const id = React.useId()
|
||||
const formContext = React.useContext(FormRootContext)
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot='form-item'
|
||||
data-form-root={formContext?.id}
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -124,11 +218,13 @@ function FormControl({
|
||||
...props
|
||||
}: { children: React.ReactElement } & Record<string, unknown>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
const formContext = React.useContext(FormRootContext)
|
||||
|
||||
return useRender({
|
||||
render: children,
|
||||
props: {
|
||||
'data-slot': 'form-control',
|
||||
'data-form-root': formContext?.id,
|
||||
id: formItemId,
|
||||
'aria-describedby': !error
|
||||
? `${formDescriptionId}`
|
||||
@@ -154,6 +250,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const formContext = React.useContext(FormRootContext)
|
||||
const { t } = useTranslation()
|
||||
const body = error ? String(error?.message ?? '') : props.children
|
||||
|
||||
@@ -166,6 +263,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot='form-message'
|
||||
data-form-root={formContext?.id}
|
||||
id={formMessageId}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
|
||||
+31
-24
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { Select as SelectPrimitive } from '@base-ui/react/select'
|
||||
import {
|
||||
UnfoldMoreIcon,
|
||||
@@ -97,32 +98,38 @@ function SelectContent({
|
||||
SelectPrimitive.Positioner.Props,
|
||||
'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className='isolate z-50'
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
const content = (
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className='isolate z-50'
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot='select-content'
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot='select-content'
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return content
|
||||
}
|
||||
|
||||
return <SelectPrimitive.Portal>{content}</SelectPrimitive.Portal>
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
|
||||
+13
-10
@@ -40,6 +40,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
@@ -418,7 +419,7 @@ function SidebarGroupLabel({
|
||||
props: mergeProps<'div'>(
|
||||
{
|
||||
className: cn(
|
||||
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
className
|
||||
),
|
||||
},
|
||||
@@ -556,15 +557,17 @@ function SidebarMenuButton({
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side='right'
|
||||
align='center'
|
||||
hidden={state !== 'collapsed' || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
<TooltipProvider delay={0}>
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side='right'
|
||||
align='center'
|
||||
hidden={state !== 'collapsed' || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ChevronRight,
|
||||
ListOrdered,
|
||||
Shuffle,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -301,15 +302,18 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
|
||||
const usedDisplay = withSuffix(formatQuotaValue(usedQuota))
|
||||
const remainingDisplay = withSuffix(formatBalance(balance))
|
||||
const usedLabel = `${t('Used:')} ${usedDisplay}`
|
||||
const remainingLabel = `${t('Remaining:')} ${remainingDisplay}`
|
||||
|
||||
// Tag row: only show cumulative used quota
|
||||
if (isTagRow) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={`Used: ${usedDisplay}`}
|
||||
label={usedLabel}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -354,14 +358,13 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='cursor-help'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t('Used:')} {usedDisplay}
|
||||
</p>
|
||||
<p>{usedLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -384,6 +387,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='cursor-pointer'
|
||||
onClick={handleClickUpdate}
|
||||
/>
|
||||
@@ -393,7 +397,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
<p>
|
||||
{channel.type === 57
|
||||
? t('Click to view Codex usage')
|
||||
: `${t('Remaining:')} ${remainingDisplay}`}
|
||||
: remainingLabel}
|
||||
</p>
|
||||
{channel.type !== 57 && <p>{t('Click to update balance')}</p>}
|
||||
</TooltipContent>
|
||||
@@ -494,7 +498,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
const name = row.getValue('name') as string
|
||||
const channel = row.original
|
||||
const isMultiKey = isMultiKeyChannel(channel)
|
||||
|
||||
// Tag row with expand/collapse
|
||||
if (isTagRow) {
|
||||
@@ -531,6 +534,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Regular channel row
|
||||
const settings = parseChannelSettings(channel.setting)
|
||||
const isPassThrough = settings.pass_through_body_enabled === true
|
||||
const hasParamOverride = Boolean(channel.param_override?.trim())
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
@@ -557,13 +561,19 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isMultiKey && (
|
||||
<StatusBadge
|
||||
label={`${channel.channel_info.multi_key_size} keys`}
|
||||
variant='purple'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
{hasParamOverride && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<SlidersHorizontal className='text-info h-3.5 w-3.5 flex-shrink-0' />
|
||||
}
|
||||
></TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
{t('Override request parameters')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<UpstreamUpdateTags channel={channel} />
|
||||
</div>
|
||||
@@ -638,14 +648,12 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className='border-border bg-muted text-primary inline-flex h-5 w-5 items-center justify-center rounded-md border shrink-0' />
|
||||
<span className='border-border bg-muted text-primary inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border' />
|
||||
}
|
||||
>
|
||||
<MultiKeyModeIcon className='h-3 w-3' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
{multiKeyTooltip}
|
||||
</TooltipContent>
|
||||
<TooltipContent side='top'>{multiKeyTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
@@ -654,7 +662,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='pl-1 gap-1'
|
||||
className='gap-1 pl-1'
|
||||
>
|
||||
{icon}
|
||||
<span className='truncate'>{typeName}</span>
|
||||
|
||||
+5
-13
@@ -2754,23 +2754,15 @@ export function ChannelMutateDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<JsonEditor
|
||||
<Textarea
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
disabled={isSubmitting}
|
||||
keyPlaceholder='temperature'
|
||||
valuePlaceholder='0.7'
|
||||
keyLabel='Parameter'
|
||||
valueLabel='Value'
|
||||
emptyMessage={t(
|
||||
'No parameter overrides configured.'
|
||||
rows={8}
|
||||
placeholder={t(
|
||||
'Override request parameters. Cannot override stream parameter.'
|
||||
)}
|
||||
template={{
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
top_p: 1,
|
||||
}}
|
||||
valueType='any'
|
||||
className='max-h-72 min-h-40 resize-y overflow-auto font-mono text-xs'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
+50
-35
@@ -26,6 +26,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Circle,
|
||||
Copy,
|
||||
CreditCard,
|
||||
FileText,
|
||||
KeyRound,
|
||||
@@ -38,13 +39,14 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { motion, useReducedMotion } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { getUserModels } from '@/lib/api'
|
||||
import { MOTION_TRANSITION } from '@/lib/motion'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import {
|
||||
CardStaggerContainer,
|
||||
CardStaggerItem,
|
||||
@@ -104,8 +106,8 @@ interface RequestExample {
|
||||
endpoint: string
|
||||
model: string
|
||||
keyName: string
|
||||
keyId?: number
|
||||
displayKey: string
|
||||
curl: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
@@ -179,7 +181,7 @@ function SetupGuideBackdrop(props: { compact?: boolean }) {
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 bg-[linear-gradient(112deg,oklch(0.97_0.04_250/.92)_0%,oklch(0.95_0.08_315/.82)_38%,oklch(0.96_0.12_92/.78)_74%,oklch(0.94_0.1_132/.62)_100%)] dark:opacity-25',
|
||||
'pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_48%_120%_at_78%_0%,color-mix(in_oklch,var(--primary)_8%,transparent)_0%,transparent_62%),linear-gradient(112deg,color-mix(in_oklch,var(--card)_98%,var(--primary)_2%)_0%,color-mix(in_oklch,var(--card)_94%,var(--muted)_6%)_48%,color-mix(in_oklch,var(--background)_92%,var(--accent)_8%)_100%)] dark:opacity-65',
|
||||
props.compact
|
||||
? '[mask-image:linear-gradient(90deg,black_0%,black_48%,transparent_74%)] opacity-55'
|
||||
: 'opacity-85'
|
||||
@@ -188,7 +190,7 @@ function SetupGuideBackdrop(props: { compact?: boolean }) {
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 right-0 hidden overflow-hidden font-mono text-lime-100/75 sm:block dark:text-lime-200/25',
|
||||
'text-foreground/5 pointer-events-none absolute inset-y-0 right-0 hidden overflow-hidden font-mono sm:block dark:text-foreground/8',
|
||||
props.compact ? 'w-1/2 opacity-45' : 'w-[58%] opacity-75'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
@@ -275,12 +277,41 @@ function RequestPreview(props: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const shouldReduceMotion = useReducedMotion()
|
||||
const previewLines = props.example.curl.split('\n').map((line) => {
|
||||
if (line.includes('Authorization: Bearer')) {
|
||||
return ` -H "Authorization: Bearer ${props.example.displayKey}" \\`
|
||||
}
|
||||
return line
|
||||
const [isCopying, setIsCopying] = useState(false)
|
||||
const { copyToClipboard } = useCopyToClipboard({ notify: false })
|
||||
const previewCurl = buildCurlCommand({
|
||||
endpoint: props.example.endpoint,
|
||||
apiKey: props.example.displayKey,
|
||||
model: props.example.model,
|
||||
})
|
||||
const previewLines = previewCurl.split('\n')
|
||||
const handleCopyRequest = async () => {
|
||||
if (!props.example.keyId || isCopying) return
|
||||
|
||||
setIsCopying(true)
|
||||
try {
|
||||
const result = await fetchTokenKey(props.example.keyId)
|
||||
const key = result.success && result.data?.key ? result.data.key : ''
|
||||
if (!key) {
|
||||
toast.error(result.message || t('Failed to copy to clipboard'))
|
||||
return
|
||||
}
|
||||
|
||||
const realCurl = buildCurlCommand({
|
||||
endpoint: props.example.endpoint,
|
||||
apiKey: `sk-${key}`,
|
||||
model: props.example.model,
|
||||
})
|
||||
const copied = await copyToClipboard(realCurl)
|
||||
if (copied) {
|
||||
toast.success(t('Copied to clipboard'))
|
||||
} else {
|
||||
toast.error(t('Failed to copy to clipboard'))
|
||||
}
|
||||
} finally {
|
||||
setIsCopying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -315,17 +346,17 @@ function RequestPreview(props: {
|
||||
</div>
|
||||
</div>
|
||||
{props.example.ready ? (
|
||||
<CopyButton
|
||||
value={props.example.curl}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 gap-1.5 px-2 text-xs'
|
||||
tooltip={t('Copy ready-to-run curl')}
|
||||
successTooltip={t('Copied!')}
|
||||
disabled={isCopying}
|
||||
onClick={handleCopyRequest}
|
||||
aria-label={t('Copy ready-to-run curl')}
|
||||
>
|
||||
{t('Copy')}
|
||||
</CopyButton>
|
||||
<Copy data-icon='inline-start' />
|
||||
{isCopying ? t('Loading') : t('Copy')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size='sm' variant='outline' render={<Link to='/keys' />}>
|
||||
{t('Create API Key')}
|
||||
@@ -463,17 +494,6 @@ export function OverviewDashboard() {
|
||||
[apiKeysQuery.data]
|
||||
)
|
||||
|
||||
const realKeyQuery = useQuery({
|
||||
queryKey: ['dashboard', 'overview', 'token-key', preferredKey?.id],
|
||||
queryFn: async () => {
|
||||
if (!preferredKey?.id) return ''
|
||||
const result = await fetchTokenKey(preferredKey.id)
|
||||
return result.success && result.data?.key ? `sk-${result.data.key}` : ''
|
||||
},
|
||||
enabled: Boolean(preferredKey?.id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const startSteps = useMemo<StartStep[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -561,23 +581,18 @@ export function OverviewDashboard() {
|
||||
const requestExample = useMemo<RequestExample>(() => {
|
||||
const endpoint = normalizeEndpoint(apiInfoItems[0]?.url)
|
||||
const model = modelsQuery.data?.[0] ?? 'gpt-4o-mini'
|
||||
const apiKey = realKeyQuery.data ?? ''
|
||||
const keyName = preferredKey?.name ?? t('No API key yet')
|
||||
const ready = Boolean(apiKey && model)
|
||||
const ready = Boolean(preferredKey?.id && model)
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
model,
|
||||
keyName,
|
||||
displayKey: formatDisplayKey(apiKey),
|
||||
keyId: preferredKey?.id,
|
||||
displayKey: preferredKey ? formatDisplayKey(`sk-${preferredKey.key}`) : 'sk-...',
|
||||
ready,
|
||||
curl: buildCurlCommand({
|
||||
endpoint,
|
||||
apiKey: apiKey || 'sk-...',
|
||||
model,
|
||||
}),
|
||||
}
|
||||
}, [apiInfoItems, modelsQuery.data, preferredKey, realKeyQuery.data, t])
|
||||
}, [apiInfoItems, modelsQuery.data, preferredKey, t])
|
||||
|
||||
const completedStepCount = startSteps.filter((step) => step.completed).length
|
||||
const setupComplete = completedStepCount === startSteps.length
|
||||
|
||||
+3
-1
@@ -29,9 +29,11 @@ import type {
|
||||
* Send chat completion request (non-streaming)
|
||||
*/
|
||||
export async function sendChatCompletion(
|
||||
payload: ChatCompletionRequest
|
||||
payload: ChatCompletionRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<ChatCompletionResponse> {
|
||||
const res = await api.post(API_ENDPOINTS.CHAT_COMPLETIONS, payload, {
|
||||
signal,
|
||||
skipErrorHandler: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
|
||||
+129
-56
@@ -16,12 +16,33 @@ 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 {
|
||||
Check,
|
||||
Copy,
|
||||
Edit,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
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'
|
||||
|
||||
@@ -36,6 +57,15 @@ interface MessageActionsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
type MessageActionItem = {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'destructive'
|
||||
}
|
||||
|
||||
export function MessageActions({
|
||||
message,
|
||||
onCopy,
|
||||
@@ -46,14 +76,12 @@ export function MessageActions({
|
||||
alwaysVisible = false,
|
||||
className = '',
|
||||
}: MessageActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
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 { content, hasContent, isAssistant, isLoading } =
|
||||
getMessageActionState(message)
|
||||
const isCopied = copiedText === content
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -69,60 +97,105 @@ export function MessageActions({
|
||||
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'
|
||||
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 && !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={`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
|
||||
<>
|
||||
<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={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 size-11 text-muted-foreground hover:text-foreground'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
/>
|
||||
}
|
||||
onClick={handleCopy}
|
||||
className={isCopied ? 'text-green-600' : ''}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<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
|
||||
|
||||
{/* 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'
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className='min-h-11'
|
||||
disabled={action.disabled}
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant}
|
||||
>
|
||||
{action.label}
|
||||
<DropdownMenuShortcut>
|
||||
<Icon className='size-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
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 { Button } from '@/components/ui/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 gap-2 pt-2'>
|
||||
{onRetry && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={disabled}
|
||||
onClick={onRetry}
|
||||
size='sm'
|
||||
>
|
||||
<RefreshCw className='size-3.5' />
|
||||
{t('Retry')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onEditPrompt && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={disabled}
|
||||
onClick={onEditPrompt}
|
||||
size='sm'
|
||||
variant='outline'
|
||||
>
|
||||
<Edit className='size-3.5' />
|
||||
{t('Edit')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={disabled}
|
||||
onClick={onDelete}
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
>
|
||||
<Trash2 className='size-3.5' />
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,54 +16,62 @@ 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 { AlertCircle, AlertTriangle, Settings } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { MESSAGE_STATUS } from '../constants'
|
||||
import {
|
||||
getMessageErrorState,
|
||||
isAdminRole,
|
||||
MODEL_PRICING_SETTINGS_PATH,
|
||||
} from '../lib'
|
||||
import type { Message } from '../types'
|
||||
|
||||
interface MessageErrorProps {
|
||||
message: Message
|
||||
className?: string
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Display error messages using Alert component
|
||||
* Following ai-elements pattern for error handling
|
||||
*/
|
||||
export function MessageError({ message, className = '' }: MessageErrorProps) {
|
||||
export function MessageError({
|
||||
message,
|
||||
className = '',
|
||||
actions,
|
||||
}: MessageErrorProps) {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthStore((s) => s.auth.user)
|
||||
const isAdmin = user?.role != null && user.role >= 10
|
||||
const errorState = getMessageErrorState(message, isAdminRole(user?.role))
|
||||
|
||||
if (message.status !== MESSAGE_STATUS.ERROR) {
|
||||
if (!errorState) {
|
||||
return null
|
||||
}
|
||||
|
||||
const errorContent =
|
||||
message.versions[0]?.content || 'An unknown error occurred'
|
||||
|
||||
if (message.errorCode === 'model_price_error') {
|
||||
if (errorState.kind === 'model-price') {
|
||||
return (
|
||||
<Alert variant='default' className={className}>
|
||||
<AlertTriangle className='text-orange-500' />
|
||||
<AlertTitle>{t('Model Price Not Configured')}</AlertTitle>
|
||||
<AlertDescription className='space-y-2'>
|
||||
<p>{errorContent}</p>
|
||||
{isAdmin && (
|
||||
<p>{errorState.content}</p>
|
||||
{errorState.showSettingsLink && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
window.open('/system-settings/billing/model-pricing', '_blank')
|
||||
window.open(MODEL_PRICING_SETTINGS_PATH, '_blank')
|
||||
}
|
||||
>
|
||||
<Settings className='mr-1 h-3.5 w-3.5' />
|
||||
{t('Go to Settings')}
|
||||
</Button>
|
||||
)}
|
||||
{actions}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
@@ -73,7 +81,10 @@ export function MessageError({ message, className = '' }: MessageErrorProps) {
|
||||
<Alert variant='destructive' className={className}>
|
||||
<AlertCircle />
|
||||
<AlertTitle>{t('Error')}</AlertTitle>
|
||||
<AlertDescription>{errorContent}</AlertDescription>
|
||||
<AlertDescription className='space-y-2'>
|
||||
<p>{errorState.content}</p>
|
||||
{actions}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
+95
-216
@@ -16,44 +16,25 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Branch,
|
||||
BranchMessages,
|
||||
BranchNext,
|
||||
BranchPage,
|
||||
BranchPrevious,
|
||||
BranchSelector,
|
||||
} from '@/components/ai-elements/branch'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import { Loader } from '@/components/ai-elements/loader'
|
||||
import { Message, MessageContent } from '@/components/ai-elements/message'
|
||||
import { Message } from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { Response } from '@/components/ai-elements/response'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import {
|
||||
Source,
|
||||
Sources,
|
||||
SourcesContent,
|
||||
SourcesTrigger,
|
||||
} from '@/components/ai-elements/sources'
|
||||
import { MESSAGE_ROLES } from '../constants'
|
||||
import { getMessageContentStyles } from '../lib/message-styles'
|
||||
import { parseThinkTags } from '../lib/message-utils'
|
||||
getChatMessageRenderState,
|
||||
getEditingMessageContent,
|
||||
getPreviousUserMessage,
|
||||
isErrorMessage,
|
||||
} from '../lib'
|
||||
import type { Message as MessageType } from '../types'
|
||||
import { MessageActions } from './message-actions'
|
||||
import { MessageError } from './message-error'
|
||||
import { MessageErrorActions } from './message-error-actions'
|
||||
import { PlaygroundEmptyState } from './playground-empty-state'
|
||||
import { PlaygroundMessageContent } from './playground-message-content'
|
||||
import { PlaygroundMessageEditor } from './playground-message-editor'
|
||||
|
||||
interface PlaygroundChatProps {
|
||||
messages: MessageType[]
|
||||
@@ -61,6 +42,7 @@ interface PlaygroundChatProps {
|
||||
onRegenerateMessage?: (message: MessageType) => void
|
||||
onEditMessage?: (message: MessageType) => void
|
||||
onDeleteMessage?: (message: MessageType) => void
|
||||
onSelectPrompt?: (prompt: string) => void
|
||||
isGenerating?: boolean
|
||||
editingKey?: string | null
|
||||
onSaveEdit?: (newContent: string) => void
|
||||
@@ -74,6 +56,7 @@ export function PlaygroundChat({
|
||||
onRegenerateMessage,
|
||||
onEditMessage,
|
||||
onDeleteMessage,
|
||||
onSelectPrompt,
|
||||
isGenerating = false,
|
||||
editingKey,
|
||||
onSaveEdit,
|
||||
@@ -85,204 +68,100 @@ export function PlaygroundChat({
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingKey) return
|
||||
const message = messages.find((m) => m.key === editingKey)
|
||||
const content = message?.versions?.[0]?.content || ''
|
||||
const content = getEditingMessageContent(messages, editingKey)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setEditText(content)
|
||||
|
||||
setOriginalText(content)
|
||||
}, [editingKey, messages])
|
||||
|
||||
const isEditing = (key: string) => editingKey === key
|
||||
const isEmpty = useMemo(() => !editText.trim(), [editText])
|
||||
const isChanged = useMemo(
|
||||
() => editText !== originalText,
|
||||
[editText, originalText]
|
||||
)
|
||||
return (
|
||||
<Conversation>
|
||||
{/* Remove outer padding; apply padding to inner centered container to align with input */}
|
||||
<ConversationContent className='p-0'>
|
||||
<div className='mx-auto w-full max-w-4xl px-4 py-4'>
|
||||
{messages.map((message, messageIndex) => {
|
||||
const { versions = [] } = message
|
||||
const isLastAssistantMessage =
|
||||
messageIndex === messages.length - 1 &&
|
||||
message.from === MESSAGE_ROLES.ASSISTANT
|
||||
return (
|
||||
<Branch defaultBranch={0} key={message.key}>
|
||||
<BranchMessages>
|
||||
{versions.map((version, versionIndex) => (
|
||||
<Message
|
||||
className='group flex-row-reverse'
|
||||
from={message.from}
|
||||
key={`${message.key}-${version.id}-${versionIndex}`}
|
||||
>
|
||||
<div className='w-full min-w-0 flex-1 basis-full py-1'>
|
||||
{isEditing(message.key) ? (
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
className='font-mono text-sm'
|
||||
rows={8}
|
||||
{messages.length === 0 && onSelectPrompt ? (
|
||||
<PlaygroundEmptyState onSelectPrompt={onSelectPrompt} />
|
||||
) : (
|
||||
messages.map((message, messageIndex) => {
|
||||
const { alwaysShowActions, content, isEditing } =
|
||||
getChatMessageRenderState(
|
||||
messages,
|
||||
message,
|
||||
messageIndex,
|
||||
editingKey
|
||||
)
|
||||
const isError = isErrorMessage(message)
|
||||
const previousUserMessage = isError
|
||||
? getPreviousUserMessage(messages, messageIndex)
|
||||
: null
|
||||
|
||||
return (
|
||||
<Message
|
||||
className={
|
||||
message.from === 'assistant'
|
||||
? 'group flex-row-reverse py-3'
|
||||
: 'group flex-row-reverse py-1.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
|
||||
actions={
|
||||
<MessageActions
|
||||
message={message}
|
||||
onCopy={onCopyMessage}
|
||||
onRegenerate={onRegenerateMessage}
|
||||
onEdit={onEditMessage}
|
||||
onDelete={onDeleteMessage}
|
||||
isGenerating={isGenerating}
|
||||
alwaysVisible={alwaysShowActions}
|
||||
className='mt-2'
|
||||
/>
|
||||
}
|
||||
message={message}
|
||||
errorActions={
|
||||
isError ? (
|
||||
<MessageErrorActions
|
||||
disabled={isGenerating}
|
||||
onRetry={
|
||||
onRegenerateMessage
|
||||
? () => onRegenerateMessage(message)
|
||||
: undefined
|
||||
}
|
||||
onEditPrompt={
|
||||
onEditMessage && previousUserMessage
|
||||
? () => onEditMessage(previousUserMessage)
|
||||
: undefined
|
||||
}
|
||||
onDelete={
|
||||
onDeleteMessage
|
||||
? () => onDeleteMessage(message)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
{/* Save & Submit only makes sense for user messages */}
|
||||
{message.from === MESSAGE_ROLES.USER && (
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
onSaveEditAndSubmit?.(editText)
|
||||
}
|
||||
disabled={isEmpty || !isChanged}
|
||||
>
|
||||
Save & Submit
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => onSaveEdit?.(editText)}
|
||||
disabled={isEmpty || !isChanged}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => onCancelEdit?.(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
const isAssistant =
|
||||
message.from === MESSAGE_ROLES.ASSISTANT
|
||||
const hasSources = !!message.sources?.length
|
||||
const showReasoning =
|
||||
isAssistant && !!message.reasoning?.content
|
||||
const showLoader =
|
||||
isAssistant &&
|
||||
!message.isReasoningStreaming &&
|
||||
(message.status === 'loading' ||
|
||||
(message.status === 'streaming' &&
|
||||
!version.content))
|
||||
const showMessageContent =
|
||||
(message.from === MESSAGE_ROLES.USER ||
|
||||
!message.isReasoningStreaming) &&
|
||||
!!version.content
|
||||
|
||||
// Extract visible content (remove <think> tags for assistant messages)
|
||||
const displayContent = isAssistant
|
||||
? parseThinkTags(version.content).visibleContent
|
||||
: version.content
|
||||
|
||||
const actions = (
|
||||
<MessageActions
|
||||
message={message}
|
||||
onCopy={onCopyMessage}
|
||||
onRegenerate={onRegenerateMessage}
|
||||
onEdit={onEditMessage}
|
||||
onDelete={onDeleteMessage}
|
||||
isGenerating={isGenerating}
|
||||
alwaysVisible={isLastAssistantMessage}
|
||||
className='mt-1'
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sources */}
|
||||
{hasSources && (
|
||||
<Sources>
|
||||
<SourcesTrigger
|
||||
count={message.sources!.length}
|
||||
/>
|
||||
<SourcesContent>
|
||||
{message.sources!.map(
|
||||
(source, sourceIndex) => (
|
||||
<Source
|
||||
href={source.href}
|
||||
key={`${message.key}-source-${sourceIndex}`}
|
||||
title={source.title}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</SourcesContent>
|
||||
</Sources>
|
||||
)}
|
||||
|
||||
{/* Reasoning */}
|
||||
{showReasoning && (
|
||||
<Reasoning
|
||||
defaultOpen={true}
|
||||
isStreaming={message.isReasoningStreaming}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
{message.reasoning!.content}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
)}
|
||||
|
||||
{/* Loader */}
|
||||
{showLoader && (
|
||||
<div className='flex items-center gap-2 py-2'>
|
||||
<Loader />
|
||||
<Shimmer className='text-sm' duration={1}>
|
||||
Responding...
|
||||
</Shimmer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error or Content */}
|
||||
{message.status === 'error' ? (
|
||||
<>
|
||||
<MessageError
|
||||
message={message}
|
||||
className='mb-2'
|
||||
/>
|
||||
{actions}
|
||||
</>
|
||||
) : (
|
||||
showMessageContent && (
|
||||
<>
|
||||
<MessageContent
|
||||
variant='flat'
|
||||
className={cn(
|
||||
getMessageContentStyles()
|
||||
)}
|
||||
>
|
||||
<Response>{displayContent}</Response>
|
||||
</MessageContent>
|
||||
{actions}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Message>
|
||||
))}
|
||||
</BranchMessages>
|
||||
|
||||
{/* Branch selector for multiple versions */}
|
||||
{versions.length > 1 && (
|
||||
<BranchSelector className='px-0' from={message.from}>
|
||||
<BranchPrevious />
|
||||
<BranchPage />
|
||||
<BranchNext />
|
||||
</BranchSelector>
|
||||
)}
|
||||
</Branch>
|
||||
)
|
||||
})}
|
||||
) : undefined
|
||||
}
|
||||
versionContent={content}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Message>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
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='mx-auto flex size-11 items-center justify-center rounded-xl border bg-muted/50 text-muted-foreground'>
|
||||
<MessageSquarePlusIcon className='size-5' aria-hidden='true' />
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<h2 className='text-balance text-xl font-semibold tracking-tight md:text-2xl'>
|
||||
{t('Start a playground chat')}
|
||||
</h2>
|
||||
<p className='mx-auto max-w-lg text-balance text-sm leading-6 text-muted-foreground'>
|
||||
{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 whitespace-normal px-3 py-2.5 text-left'
|
||||
key={text}
|
||||
onClick={() => onSelectPrompt(prompt)}
|
||||
variant='outline'
|
||||
>
|
||||
<Icon className='size-4 text-muted-foreground' />
|
||||
<span>{prompt}</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
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
|
||||
className='gap-1.5 md:gap-2'
|
||||
selectedModel={modelValue}
|
||||
models={models}
|
||||
onModelChange={onModelChange}
|
||||
selectedGroup={groupValue}
|
||||
groups={groups}
|
||||
onGroupChange={onGroupChange}
|
||||
disabled={isSelectorDisabled}
|
||||
/>
|
||||
)
|
||||
|
||||
const renderSubmitButton = () => (
|
||||
<>
|
||||
{shouldShowStop ? (
|
||||
<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={!canSubmit}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-2 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 items-center gap-2 md:flex'>
|
||||
{renderSelector()}
|
||||
{renderSubmitButton()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
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 { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
PromptInputButton,
|
||||
PromptInputTools,
|
||||
} from '@/components/ai-elements/prompt-input'
|
||||
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>
|
||||
<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'>
|
||||
{ATTACHMENT_ACTIONS.map(({ action, icon: Icon, label }) => (
|
||||
<DropdownMenuItem
|
||||
key={action}
|
||||
onClick={() => handleFileAction(action)}
|
||||
>
|
||||
<Icon className='mr-2' size={16} />
|
||||
{t(label)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptInputButton
|
||||
className='border font-medium'
|
||||
disabled={disabled}
|
||||
onClick={handleSearchAction}
|
||||
variant='outline'
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Search')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Search')}</span>
|
||||
</PromptInputButton>
|
||||
|
||||
<PromptInputButton
|
||||
className='border font-medium text-muted-foreground hover:text-destructive'
|
||||
disabled={disabled || !hasMessages || !onClearMessages}
|
||||
onClick={() => setClearConfirmOpen(true)}
|
||||
variant='outline'
|
||||
>
|
||||
<Trash2Icon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Clear chat history')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Clear chat history')}</span>
|
||||
</PromptInputButton>
|
||||
</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?')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+33
-155
@@ -17,40 +17,18 @@ 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 { getSubmittableInputText } from '../lib'
|
||||
import type { ModelOption, GroupOption } from '../types'
|
||||
import { PlaygroundInputControls } from './playground-input-controls'
|
||||
import { PlaygroundInputTools } from './playground-input-tools'
|
||||
import { PlaygroundSuggestions } from './playground-suggestions'
|
||||
|
||||
interface PlaygroundInputProps {
|
||||
onSubmit: (text: string) => void
|
||||
@@ -64,17 +42,10 @@ interface PlaygroundInputProps {
|
||||
groups: GroupOption[]
|
||||
groupValue: string
|
||||
onGroupChange: (value: string) => void
|
||||
hasMessages?: boolean
|
||||
onClearMessages?: () => 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,
|
||||
@@ -87,30 +58,20 @@ export function PlaygroundInput({
|
||||
groups,
|
||||
groupValue,
|
||||
onGroupChange,
|
||||
hasMessages = false,
|
||||
onClearMessages,
|
||||
}: 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)
|
||||
const submittableText = getSubmittableInputText(message, disabled)
|
||||
|
||||
if (!submittableText) return
|
||||
onSubmit(submittableText)
|
||||
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}>
|
||||
@@ -127,113 +88,30 @@ export function PlaygroundInput({
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<PlaygroundSuggestions onSelect={onSubmit} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
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 { cn } from '@/lib/utils'
|
||||
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 { getMessageContentStyles } from '../lib/message-styles'
|
||||
import { getMessageContentState, isErrorMessage } from '../lib'
|
||||
import type { Message } from '../types'
|
||||
import { MessageError } from './message-error'
|
||||
|
||||
type PlaygroundMessageContentProps = {
|
||||
actions: ReactNode
|
||||
errorActions?: ReactNode
|
||||
message: Message
|
||||
versionContent: string
|
||||
}
|
||||
|
||||
export function PlaygroundMessageContent({
|
||||
actions,
|
||||
errorActions,
|
||||
message,
|
||||
versionContent,
|
||||
}: PlaygroundMessageContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
displayContent,
|
||||
hasReasoning,
|
||||
hasSources,
|
||||
reasoningContent,
|
||||
showLoader,
|
||||
showMessageContent,
|
||||
sources,
|
||||
} = getMessageContentState(message, versionContent)
|
||||
const isError = isErrorMessage(message)
|
||||
|
||||
return (
|
||||
<>
|
||||
{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={true}
|
||||
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
|
||||
actions={errorActions}
|
||||
message={message}
|
||||
className='mb-2'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isError && showMessageContent && (
|
||||
<>
|
||||
<MessageContent
|
||||
variant='flat'
|
||||
className={cn(getMessageContentStyles())}
|
||||
>
|
||||
<Response>{displayContent}</Response>
|
||||
</MessageContent>
|
||||
{actions}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
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, useRef, type KeyboardEvent } from 'react'
|
||||
import { Check, RotateCcw, Send, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
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 textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { canSave, hasChanged, showSaveAndSubmit } = getMessageEditorState(
|
||||
message,
|
||||
editText,
|
||||
originalText
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-lg border bg-background/80 p-2 shadow-sm'>
|
||||
<Textarea
|
||||
aria-label={t('Edit')}
|
||||
className='min-h-36 resize-y font-mono text-sm leading-6 md:min-h-48'
|
||||
onChange={(event) => onEditTextChange(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={textareaRef}
|
||||
rows={8}
|
||||
value={editText}
|
||||
/>
|
||||
|
||||
<div className='mt-2 flex flex-col gap-2 md:flex-row md:items-center md:justify-between'>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{hasChanged ? t('Unsaved changes') : t('No changes')}
|
||||
</p>
|
||||
|
||||
<div className='grid gap-2 sm:flex sm:justify-end'>
|
||||
{showSaveAndSubmit && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={!canSave}
|
||||
onClick={() => onSaveEditAndSubmit?.(editText)}
|
||||
size='sm'
|
||||
>
|
||||
<Send className='size-3.5' />
|
||||
{t('Save & Submit')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={!canSave}
|
||||
onClick={() => onSaveEdit?.(editText)}
|
||||
size='sm'
|
||||
variant={showSaveAndSubmit ? 'outline' : 'default'}
|
||||
>
|
||||
<Check className='size-3.5' />
|
||||
{t('Save')}
|
||||
</Button>
|
||||
|
||||
{hasChanged && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
onClick={() => onEditTextChange(originalText)}
|
||||
size='sm'
|
||||
variant='outline'
|
||||
>
|
||||
<RotateCcw className='size-3.5' />
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
onClick={handleCancel}
|
||||
size='sm'
|
||||
variant='outline'
|
||||
>
|
||||
<X className='size-3.5' />
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
BarChartIcon,
|
||||
BoxIcon,
|
||||
CodeSquareIcon,
|
||||
GraduationCapIcon,
|
||||
NotepadTextIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion'
|
||||
import { getSuggestionDisplayState } from '../lib'
|
||||
|
||||
type PlaygroundSuggestion = {
|
||||
icon: LucideIcon | null
|
||||
text: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
type PlaygroundSuggestionsProps = {
|
||||
onSelect: (suggestion: 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' },
|
||||
] satisfies PlaygroundSuggestion[]
|
||||
|
||||
export function PlaygroundSuggestions({
|
||||
onSelect,
|
||||
}: PlaygroundSuggestionsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Suggestions>
|
||||
{suggestions.map(({ icon: Icon, text, color }) => {
|
||||
const suggestion = t(text)
|
||||
const { className } = getSuggestionDisplayState(text)
|
||||
|
||||
return (
|
||||
<Suggestion
|
||||
className={className}
|
||||
key={text}
|
||||
onClick={onSelect}
|
||||
suggestion={suggestion}
|
||||
>
|
||||
{Icon && <Icon aria-hidden='true' size={16} style={{ color }} />}
|
||||
{suggestion}
|
||||
</Suggestion>
|
||||
)
|
||||
})}
|
||||
</Suggestions>
|
||||
)
|
||||
}
|
||||
@@ -20,3 +20,5 @@ export * from './use-playground-state'
|
||||
export * from './use-stream-request'
|
||||
export * from './use-chat-handler'
|
||||
export * from './use-message-action-guard'
|
||||
export * from './use-playground-conversation'
|
||||
export * from './use-playground-options'
|
||||
|
||||
+58
-62
@@ -16,16 +16,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { sendChatCompletion } from '../api'
|
||||
import { MESSAGE_STATUS, ERROR_MESSAGES } from '../constants'
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
import {
|
||||
applyStreamingChunk,
|
||||
buildChatCompletionPayload,
|
||||
updateAssistantMessageWithError,
|
||||
updateLastAssistantMessage,
|
||||
processStreamingContent,
|
||||
finalizeMessage,
|
||||
parseRequestErrorDetails,
|
||||
applyChatCompletionResponse,
|
||||
completeAssistantMessage,
|
||||
hasChatCompletionChoice,
|
||||
isAssistantMessageFinal,
|
||||
isAssistantMessagePending,
|
||||
} from '../lib'
|
||||
import type { Message, PlaygroundConfig, ParameterEnabled } from '../types'
|
||||
import { useStreamRequest } from './use-stream-request'
|
||||
@@ -45,33 +50,17 @@ export function useChatHandler({
|
||||
onMessageUpdate,
|
||||
}: UseChatHandlerOptions) {
|
||||
const { sendStreamRequest, stopStream, isStreaming } = useStreamRequest()
|
||||
const [isRequesting, setIsRequesting] = useState(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// Handle stream update
|
||||
const handleStreamUpdate = useCallback(
|
||||
(type: 'reasoning' | 'content', chunk: string) => {
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) => {
|
||||
if (message.status === MESSAGE_STATUS.ERROR) return message
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
updateLastAssistantMessage(prev, (message) =>
|
||||
applyStreamingChunk(message, type, chunk)
|
||||
)
|
||||
)
|
||||
},
|
||||
[onMessageUpdate]
|
||||
@@ -79,12 +68,12 @@ export function useChatHandler({
|
||||
|
||||
// Handle stream complete
|
||||
const handleStreamComplete = useCallback(() => {
|
||||
setIsRequesting(false)
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) =>
|
||||
message.status === MESSAGE_STATUS.COMPLETE ||
|
||||
message.status === MESSAGE_STATUS.ERROR
|
||||
isAssistantMessageFinal(message)
|
||||
? message
|
||||
: { ...finalizeMessage(message), status: MESSAGE_STATUS.COMPLETE }
|
||||
: completeAssistantMessage(message)
|
||||
)
|
||||
)
|
||||
}, [onMessageUpdate])
|
||||
@@ -92,6 +81,7 @@ export function useChatHandler({
|
||||
// Handle stream error
|
||||
const handleStreamError = useCallback(
|
||||
(error: string, errorCode?: string) => {
|
||||
setIsRequesting(false)
|
||||
toast.error(error)
|
||||
onMessageUpdate((prev) =>
|
||||
updateAssistantMessageWithError(prev, error, errorCode)
|
||||
@@ -103,6 +93,7 @@ export function useChatHandler({
|
||||
// Send streaming chat request
|
||||
const sendStreamingChat = useCallback(
|
||||
(messages: Message[]) => {
|
||||
setIsRequesting(true)
|
||||
const payload = buildChatCompletionPayload(
|
||||
messages,
|
||||
config,
|
||||
@@ -133,42 +124,45 @@ export function useChatHandler({
|
||||
config,
|
||||
parameterEnabled
|
||||
)
|
||||
const requestId = requestIdRef.current + 1
|
||||
const abortController = new AbortController()
|
||||
|
||||
requestIdRef.current = requestId
|
||||
abortControllerRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await sendChatCompletion(payload)
|
||||
const choice = response.choices?.[0]
|
||||
if (!choice) return
|
||||
setIsRequesting(true)
|
||||
const response = await sendChatCompletion(
|
||||
payload,
|
||||
abortController.signal
|
||||
)
|
||||
if (abortController.signal.aborted) return
|
||||
|
||||
if (!hasChatCompletionChoice(response)) {
|
||||
handleStreamError(ERROR_MESSAGES.API_REQUEST_ERROR)
|
||||
return
|
||||
}
|
||||
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) => ({
|
||||
...finalizeMessage(
|
||||
{
|
||||
...message,
|
||||
versions: [
|
||||
{
|
||||
...message.versions[0],
|
||||
content: choice.message?.content || '',
|
||||
},
|
||||
],
|
||||
},
|
||||
choice.message?.reasoning_content
|
||||
),
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
}))
|
||||
updateLastAssistantMessage(prev, (message) => {
|
||||
const updatedMessage = applyChatCompletionResponse(
|
||||
message,
|
||||
response
|
||||
)
|
||||
|
||||
return updatedMessage ?? message
|
||||
})
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: {
|
||||
data?: { message?: string; error?: { code?: string } }
|
||||
}
|
||||
message?: string
|
||||
if (abortController.signal.aborted) return
|
||||
|
||||
const { errorCode, errorMessage } = parseRequestErrorDetails(error)
|
||||
handleStreamError(errorMessage, errorCode)
|
||||
} 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]
|
||||
@@ -189,11 +183,13 @@ export function useChatHandler({
|
||||
// Stop generation
|
||||
const stopGeneration = useCallback(() => {
|
||||
stopStream()
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
setIsRequesting(false)
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) =>
|
||||
message.status === MESSAGE_STATUS.LOADING ||
|
||||
message.status === MESSAGE_STATUS.STREAMING
|
||||
? { ...finalizeMessage(message), status: MESSAGE_STATUS.COMPLETE }
|
||||
isAssistantMessagePending(message)
|
||||
? completeAssistantMessage(message)
|
||||
: message
|
||||
)
|
||||
)
|
||||
@@ -202,6 +198,6 @@ export function useChatHandler({
|
||||
return {
|
||||
sendChat,
|
||||
stopGeneration,
|
||||
isGenerating: isStreaming,
|
||||
isGenerating: isStreaming || isRequesting,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
appendUserMessagePair,
|
||||
applyMessageEdit,
|
||||
createRegeneratedMessages,
|
||||
removeMessageByKey,
|
||||
} from '../lib'
|
||||
import type { Message } from '../types'
|
||||
|
||||
type UsePlaygroundConversationOptions = {
|
||||
messages: Message[]
|
||||
updateMessages: (
|
||||
updater: Message[] | ((prev: Message[]) => Message[])
|
||||
) => void
|
||||
sendChat: (messages: Message[]) => void
|
||||
}
|
||||
|
||||
export function usePlaygroundConversation({
|
||||
messages,
|
||||
updateMessages,
|
||||
sendChat,
|
||||
}: UsePlaygroundConversationOptions) {
|
||||
const [editingMessageKey, setEditingMessageKey] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(text: string) => {
|
||||
const nextMessages = appendUserMessagePair(messages, text)
|
||||
updateMessages(nextMessages)
|
||||
sendChat(nextMessages)
|
||||
},
|
||||
[messages, updateMessages, sendChat]
|
||||
)
|
||||
|
||||
const handleRegenerateMessage = useCallback(
|
||||
(message: Message) => {
|
||||
const nextMessages = createRegeneratedMessages(messages, message.key)
|
||||
if (!nextMessages) return
|
||||
|
||||
updateMessages(nextMessages)
|
||||
sendChat(nextMessages)
|
||||
},
|
||||
[messages, updateMessages, sendChat]
|
||||
)
|
||||
|
||||
const handleEditMessage = useCallback((message: Message) => {
|
||||
setEditingMessageKey(message.key)
|
||||
}, [])
|
||||
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setEditingMessageKey(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyEdit = useCallback(
|
||||
(newContent: string, shouldSubmit: boolean) => {
|
||||
if (!editingMessageKey) return
|
||||
|
||||
const editResult = applyMessageEdit(
|
||||
messages,
|
||||
editingMessageKey,
|
||||
newContent,
|
||||
shouldSubmit
|
||||
)
|
||||
if (!editResult) return
|
||||
|
||||
setEditingMessageKey(null)
|
||||
updateMessages(editResult.messages)
|
||||
|
||||
if (editResult.shouldSend) {
|
||||
sendChat(editResult.messages)
|
||||
}
|
||||
},
|
||||
[editingMessageKey, messages, updateMessages, sendChat]
|
||||
)
|
||||
|
||||
const handleDeleteMessage = useCallback(
|
||||
(message: Message) => {
|
||||
updateMessages((previousMessages) =>
|
||||
removeMessageByKey(previousMessages, message.key)
|
||||
)
|
||||
},
|
||||
[updateMessages]
|
||||
)
|
||||
|
||||
return {
|
||||
editingMessageKey,
|
||||
handleSendMessage,
|
||||
handleRegenerateMessage,
|
||||
handleEditMessage,
|
||||
handleEditOpenChange,
|
||||
applyEdit,
|
||||
handleDeleteMessage,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
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 { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { getUserGroups, getUserModels } from '../api'
|
||||
import {
|
||||
getGroupFallback,
|
||||
getModelFallback,
|
||||
getOptionLoadErrorMessage,
|
||||
} 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'],
|
||||
queryFn: getUserModels,
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}, [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,
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { useState, useCallback } from 'react'
|
||||
import { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../constants'
|
||||
import {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
loadParameterEnabled,
|
||||
saveParameterEnabled,
|
||||
loadMessages,
|
||||
saveMessages,
|
||||
applyMessageStateUpdate,
|
||||
getInitialMessages,
|
||||
getInitialParameterEnabled,
|
||||
getInitialPlaygroundConfig,
|
||||
type MessageStateUpdater,
|
||||
} from '../lib'
|
||||
import type {
|
||||
Message,
|
||||
@@ -39,21 +41,15 @@ import type {
|
||||
*/
|
||||
export function usePlaygroundState() {
|
||||
// Load initial state from localStorage
|
||||
const [config, setConfig] = useState<PlaygroundConfig>(() => {
|
||||
const savedConfig = loadConfig()
|
||||
return { ...DEFAULT_CONFIG, ...savedConfig }
|
||||
})
|
||||
|
||||
const [parameterEnabled, setParameterEnabled] = useState<ParameterEnabled>(
|
||||
() => {
|
||||
const saved = loadParameterEnabled()
|
||||
return { ...DEFAULT_PARAMETER_ENABLED, ...saved }
|
||||
}
|
||||
const [config, setConfig] = useState<PlaygroundConfig>(
|
||||
getInitialPlaygroundConfig
|
||||
)
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>(() => {
|
||||
return loadMessages() || []
|
||||
})
|
||||
const [parameterEnabled, setParameterEnabled] = useState<ParameterEnabled>(
|
||||
getInitialParameterEnabled
|
||||
)
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>(getInitialMessages)
|
||||
|
||||
const [models, setModels] = useState<ModelOption[]>([])
|
||||
const [groups, setGroups] = useState<GroupOption[]>([])
|
||||
@@ -84,10 +80,9 @@ export function usePlaygroundState() {
|
||||
|
||||
// Update messages with automatic save
|
||||
const updateMessages = useCallback(
|
||||
(updater: Message[] | ((prev: Message[]) => Message[])) => {
|
||||
(updater: MessageStateUpdater) => {
|
||||
setMessages((prev) => {
|
||||
const newMessages =
|
||||
typeof updater === 'function' ? updater(prev) : updater
|
||||
const newMessages = applyMessageStateUpdate(prev, updater)
|
||||
saveMessages(newMessages)
|
||||
return newMessages
|
||||
})
|
||||
|
||||
+39
-54
@@ -16,11 +16,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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 { getCommonHeaders } from '@/lib/api'
|
||||
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
|
||||
@@ -28,6 +35,17 @@ import type { ChatCompletionRequest, ChatCompletionChunk } from '../types'
|
||||
export function useStreamRequest() {
|
||||
const sseSourceRef = useRef<SSE | null>(null)
|
||||
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(
|
||||
(
|
||||
@@ -36,6 +54,8 @@ export function useStreamRequest() {
|
||||
onComplete: () => void,
|
||||
onError: (error: string, errorCode?: string) => void
|
||||
) => {
|
||||
sseSourceRef.current?.close()
|
||||
|
||||
const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
|
||||
headers: getCommonHeaders(),
|
||||
method: 'POST',
|
||||
@@ -44,38 +64,28 @@ export function useStreamRequest() {
|
||||
|
||||
sseSourceRef.current = source
|
||||
isStreamCompleteRef.current = false
|
||||
|
||||
const closeSource = () => {
|
||||
source.close()
|
||||
sseSourceRef.current = null
|
||||
}
|
||||
setIsStreaming(true)
|
||||
|
||||
const handleError = (errorMessage: string, errorCode?: string) => {
|
||||
if (!isStreamCompleteRef.current) {
|
||||
onError(errorMessage, errorCode)
|
||||
closeSource()
|
||||
closeActiveStream(source)
|
||||
}
|
||||
}
|
||||
|
||||
source.addEventListener('message', (e: MessageEvent) => {
|
||||
if (e.data === '[DONE]') {
|
||||
if (isStreamDoneMessage(e.data)) {
|
||||
isStreamCompleteRef.current = true
|
||||
closeSource()
|
||||
closeActiveStream(source)
|
||||
onComplete()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const chunk: ChatCompletionChunk = JSON.parse(e.data)
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
const updates = parseStreamMessageUpdates(e.data)
|
||||
|
||||
if (delta) {
|
||||
if (delta.reasoning_content) {
|
||||
onUpdate('reasoning', delta.reasoning_content)
|
||||
}
|
||||
if (delta.content) {
|
||||
onUpdate('content', delta.content)
|
||||
}
|
||||
for (const update of updates) {
|
||||
onUpdate(update.type, update.chunk)
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -86,24 +96,10 @@ export function useStreamRequest() {
|
||||
|
||||
source.addEventListener('error', (e: Event & { data?: string }) => {
|
||||
// Only handle errors if stream didn't complete normally
|
||||
if (source.readyState !== 2) {
|
||||
if (!isStreamClosedReadyState(source.readyState)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('SSE Error:', e)
|
||||
let errorMessage = e.data || ERROR_MESSAGES.API_REQUEST_ERROR
|
||||
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
|
||||
}
|
||||
}
|
||||
const { errorCode, errorMessage } = parseStreamErrorDetails(e.data)
|
||||
handleError(errorMessage, errorCode)
|
||||
}
|
||||
})
|
||||
@@ -111,14 +107,10 @@ export function useStreamRequest() {
|
||||
source.addEventListener(
|
||||
'readystatechange',
|
||||
(e: Event & { readyState?: number }) => {
|
||||
const status = (source as unknown as { status?: number }).status
|
||||
if (
|
||||
e.readyState !== undefined &&
|
||||
e.readyState >= 2 &&
|
||||
status !== undefined &&
|
||||
status !== 200
|
||||
) {
|
||||
handleError(`HTTP ${status}: ${ERROR_MESSAGES.CONNECTION_CLOSED}`)
|
||||
const errorMessage = getStreamReadyStateError(e.readyState, source)
|
||||
|
||||
if (errorMessage) {
|
||||
handleError(errorMessage)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -129,26 +121,19 @@ export function useStreamRequest() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to start SSE stream:', error)
|
||||
onError(ERROR_MESSAGES.STREAM_START_ERROR)
|
||||
sseSourceRef.current = null
|
||||
closeActiveStream(source)
|
||||
}
|
||||
},
|
||||
[]
|
||||
[closeActiveStream]
|
||||
)
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (sseSourceRef.current) {
|
||||
sseSourceRef.current.close()
|
||||
sseSourceRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const isStreaming = sseSourceRef.current !== null
|
||||
closeActiveStream()
|
||||
}, [closeActiveStream])
|
||||
|
||||
return {
|
||||
sendStreamRequest,
|
||||
stopStream,
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
isStreaming,
|
||||
}
|
||||
}
|
||||
|
||||
+35
-149
@@ -16,19 +16,16 @@ 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'
|
||||
import {
|
||||
useChatHandler,
|
||||
usePlaygroundConversation,
|
||||
usePlaygroundOptions,
|
||||
usePlaygroundState,
|
||||
} from './hooks'
|
||||
|
||||
export function Playground() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
config,
|
||||
parameterEnabled,
|
||||
@@ -39,6 +36,7 @@ export function Playground() {
|
||||
setModels,
|
||||
setGroups,
|
||||
updateConfig,
|
||||
clearMessages,
|
||||
} = usePlaygroundState()
|
||||
|
||||
const { sendChat, stopGeneration, isGenerating } = useChatHandler({
|
||||
@@ -47,157 +45,43 @@ export function Playground() {
|
||||
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 []
|
||||
}
|
||||
},
|
||||
const {
|
||||
editingMessageKey,
|
||||
handleSendMessage,
|
||||
handleRegenerateMessage,
|
||||
handleEditMessage,
|
||||
handleEditOpenChange,
|
||||
applyEdit,
|
||||
handleDeleteMessage,
|
||||
} = usePlaygroundConversation({
|
||||
messages,
|
||||
updateMessages,
|
||||
sendChat,
|
||||
})
|
||||
|
||||
// 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 []
|
||||
}
|
||||
},
|
||||
const handleClearMessages = () => {
|
||||
handleEditOpenChange(false)
|
||||
clearMessages()
|
||||
}
|
||||
|
||||
const { isLoadingModels } = usePlaygroundOptions({
|
||||
currentGroup: config.group,
|
||||
currentModel: config.model,
|
||||
setGroups,
|
||||
setModels,
|
||||
updateConfig,
|
||||
})
|
||||
|
||||
// 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'>
|
||||
<div className='relative flex size-full min-h-0 flex-col overflow-hidden'>
|
||||
{/* Full-width scroll container: scrolling works even over side whitespace */}
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
<div className='flex min-h-0 flex-1 flex-col overflow-hidden'>
|
||||
<PlaygroundChat
|
||||
messages={messages}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
onRegenerateMessage={handleRegenerateMessage}
|
||||
onEditMessage={handleEditMessage}
|
||||
onDeleteMessage={handleDeleteMessage}
|
||||
onSelectPrompt={handleSendMessage}
|
||||
isGenerating={isGenerating}
|
||||
editingKey={editingMessageKey}
|
||||
onCancelEdit={handleEditOpenChange}
|
||||
@@ -217,9 +101,11 @@ export function Playground() {
|
||||
modelValue={config.model}
|
||||
models={models}
|
||||
onGroupChange={(value) => updateConfig('group', value)}
|
||||
onClearMessages={handleClearMessages}
|
||||
onModelChange={(value) => updateConfig('model', value)}
|
||||
onStop={stopGeneration}
|
||||
onSubmit={handleSendMessage}
|
||||
hasMessages={messages.length > 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Message } from '../types'
|
||||
import { MESSAGE_ROLES } from '../constants'
|
||||
import {
|
||||
createLoadingAssistantMessage,
|
||||
createUserMessage,
|
||||
getMessageContent,
|
||||
updateCurrentVersionContent,
|
||||
} from './message-utils'
|
||||
|
||||
type ApplyMessageEditResult = {
|
||||
messages: Message[]
|
||||
shouldSend: boolean
|
||||
}
|
||||
|
||||
type ChatMessageRenderState = {
|
||||
alwaysShowActions: boolean
|
||||
content: string
|
||||
isEditing: boolean
|
||||
}
|
||||
|
||||
export function appendUserMessagePair(
|
||||
messages: Message[],
|
||||
content: string
|
||||
): Message[] {
|
||||
return [
|
||||
...messages,
|
||||
createUserMessage(content),
|
||||
createLoadingAssistantMessage(),
|
||||
]
|
||||
}
|
||||
|
||||
export function createRegeneratedMessages(
|
||||
messages: Message[],
|
||||
messageKey: string
|
||||
): Message[] | null {
|
||||
const messageIndex = messages.findIndex((message) => message.key === messageKey)
|
||||
|
||||
if (messageIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [...messages.slice(0, messageIndex), createLoadingAssistantMessage()]
|
||||
}
|
||||
|
||||
export function removeMessageByKey(
|
||||
messages: Message[],
|
||||
messageKey: string
|
||||
): Message[] {
|
||||
return messages.filter((message) => message.key !== messageKey)
|
||||
}
|
||||
|
||||
export function getPreviousUserMessage(
|
||||
messages: Message[],
|
||||
beforeIndex: number
|
||||
): Message | null {
|
||||
for (let index = beforeIndex - 1; index >= 0; index--) {
|
||||
if (messages[index].from === MESSAGE_ROLES.USER) {
|
||||
return messages[index]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function applyMessageEdit(
|
||||
messages: Message[],
|
||||
messageKey: string,
|
||||
content: string,
|
||||
shouldSubmit: boolean
|
||||
): ApplyMessageEditResult | null {
|
||||
const messageIndex = messages.findIndex((message) => message.key === messageKey)
|
||||
|
||||
if (messageIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedMessages = messages.map((message) =>
|
||||
message.key === messageKey
|
||||
? updateCurrentVersionContent(message, content)
|
||||
: message
|
||||
)
|
||||
|
||||
if (
|
||||
!shouldSubmit ||
|
||||
updatedMessages[messageIndex].from !== MESSAGE_ROLES.USER
|
||||
) {
|
||||
return { messages: updatedMessages, shouldSend: false }
|
||||
}
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...updatedMessages.slice(0, messageIndex + 1),
|
||||
createLoadingAssistantMessage(),
|
||||
],
|
||||
shouldSend: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function getEditingMessageContent(
|
||||
messages: Message[],
|
||||
editingKey?: string | null
|
||||
): string {
|
||||
if (!editingKey) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const message = messages.find((item) => item.key === editingKey)
|
||||
return message ? getMessageContent(message) : ''
|
||||
}
|
||||
|
||||
export function getChatMessageRenderState(
|
||||
messages: Message[],
|
||||
message: Message,
|
||||
messageIndex: number,
|
||||
editingKey?: string | null
|
||||
): ChatMessageRenderState {
|
||||
return {
|
||||
alwaysShowActions:
|
||||
messageIndex === messages.length - 1 &&
|
||||
message.from === MESSAGE_ROLES.ASSISTANT,
|
||||
content: getMessageContent(message),
|
||||
isEditing: editingKey === message.key,
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export * from './message-utils'
|
||||
export * from './input-control-utils'
|
||||
export * from './input-tool-utils'
|
||||
export * from './message-action-utils'
|
||||
export * from './message-content-utils'
|
||||
export * from './message-editor-utils'
|
||||
export * from './message-error-utils'
|
||||
export * from './message-reasoning-utils'
|
||||
export * from './message-streaming-utils'
|
||||
export * from './message-update-utils'
|
||||
export * from './payload-builder'
|
||||
export * from './storage'
|
||||
export * from './message-styles'
|
||||
export * from './stream-utils'
|
||||
export * from './request-error-utils'
|
||||
export * from './conversation-message-utils'
|
||||
export * from './playground-state-utils'
|
||||
export * from './playground-option-utils'
|
||||
export * from './suggestion-utils'
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
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 {
|
||||
return {
|
||||
canSubmit: !disabled && text.trim().length > 0,
|
||||
isSelectorDisabled:
|
||||
disabled || isModelLoading || models.length === 0 || groups.length === 0,
|
||||
shouldShowStop: Boolean(isGenerating && hasStopHandler),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
CameraIcon,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
ScreenShareIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
type AttachmentAction = {
|
||||
action: string
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
}
|
||||
|
||||
type InputToolNotice = {
|
||||
description?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export const ATTACHMENT_ACTIONS = [
|
||||
{ action: 'upload-file', icon: FileIcon, label: 'Upload file' },
|
||||
{ action: 'upload-photo', icon: ImageIcon, label: 'Upload photo' },
|
||||
{
|
||||
action: 'take-screenshot',
|
||||
icon: ScreenShareIcon,
|
||||
label: 'Take screenshot',
|
||||
},
|
||||
{ action: 'take-photo', icon: CameraIcon, label: 'Take photo' },
|
||||
] satisfies AttachmentAction[]
|
||||
|
||||
export function getAttachmentActionNotice(action: string): InputToolNotice {
|
||||
return {
|
||||
description: action,
|
||||
title: 'Feature in development',
|
||||
}
|
||||
}
|
||||
|
||||
export function getSearchActionNotice(): InputToolNotice {
|
||||
return {
|
||||
title: 'Search feature in development',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { getMessageContent, hasMessageContent } from './message-utils'
|
||||
|
||||
type MessageActionState = {
|
||||
content: string
|
||||
hasContent: boolean
|
||||
isAssistant: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function getMessageActionState(message: Message): MessageActionState {
|
||||
return {
|
||||
content: getMessageContent(message),
|
||||
hasContent: hasMessageContent(message),
|
||||
isAssistant: message.from === MESSAGE_ROLES.ASSISTANT,
|
||||
isLoading:
|
||||
message.status === MESSAGE_STATUS.LOADING ||
|
||||
message.status === MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessageActionsVisibilityClass(alwaysVisible: boolean): string {
|
||||
return alwaysVisible
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 max-md:opacity-100'
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { parseThinkTags } from './message-reasoning-utils'
|
||||
|
||||
type MessageContentStateBase = {
|
||||
displayContent: string
|
||||
hasSources: boolean
|
||||
isAssistant: boolean
|
||||
showLoader: boolean
|
||||
showMessageContent: boolean
|
||||
sources: NonNullable<Message['sources']>
|
||||
}
|
||||
|
||||
type MessageContentState = MessageContentStateBase &
|
||||
(
|
||||
| {
|
||||
hasReasoning: true
|
||||
reasoningContent: string
|
||||
}
|
||||
| {
|
||||
hasReasoning: false
|
||||
reasoningContent: undefined
|
||||
}
|
||||
)
|
||||
|
||||
function shouldShowMessageLoader(
|
||||
message: Message,
|
||||
isAssistant: boolean,
|
||||
versionContent: string
|
||||
): boolean {
|
||||
return (
|
||||
isAssistant &&
|
||||
!message.isReasoningStreaming &&
|
||||
(message.status === MESSAGE_STATUS.LOADING ||
|
||||
(message.status === MESSAGE_STATUS.STREAMING && !versionContent))
|
||||
)
|
||||
}
|
||||
|
||||
function shouldShowMessageContent(
|
||||
message: Message,
|
||||
versionContent: string
|
||||
): boolean {
|
||||
return (
|
||||
(message.from === MESSAGE_ROLES.USER || !message.isReasoningStreaming) &&
|
||||
versionContent.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function getMessageContentState(
|
||||
message: Message,
|
||||
versionContent: string
|
||||
): MessageContentState {
|
||||
const isAssistant = message.from === MESSAGE_ROLES.ASSISTANT
|
||||
const sources = message.sources ?? []
|
||||
const reasoningContent = isAssistant ? message.reasoning?.content : undefined
|
||||
const showLoader = shouldShowMessageLoader(
|
||||
message,
|
||||
isAssistant,
|
||||
versionContent
|
||||
)
|
||||
const showMessageContent = shouldShowMessageContent(message, versionContent)
|
||||
|
||||
const baseState: MessageContentStateBase = {
|
||||
displayContent: isAssistant
|
||||
? parseThinkTags(versionContent).visibleContent
|
||||
: versionContent,
|
||||
hasSources: sources.length > 0,
|
||||
isAssistant,
|
||||
showLoader,
|
||||
showMessageContent,
|
||||
sources,
|
||||
}
|
||||
|
||||
if (reasoningContent) {
|
||||
return {
|
||||
...baseState,
|
||||
hasReasoning: true,
|
||||
reasoningContent,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
hasReasoning: false,
|
||||
reasoningContent: undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { MESSAGE_ROLES } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
|
||||
type MessageEditorState = {
|
||||
canSave: boolean
|
||||
hasChanged: boolean
|
||||
showSaveAndSubmit: boolean
|
||||
}
|
||||
|
||||
export function getMessageEditorState(
|
||||
message: Message,
|
||||
editText: string,
|
||||
originalText: string
|
||||
): MessageEditorState {
|
||||
const hasText = editText.trim().length > 0
|
||||
const hasChanged = editText !== originalText
|
||||
|
||||
return {
|
||||
canSave: hasText && hasChanged,
|
||||
hasChanged,
|
||||
showSaveAndSubmit: message.from === MESSAGE_ROLES.USER,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { getMessageContent } from './message-utils'
|
||||
|
||||
export const MODEL_PRICING_SETTINGS_PATH =
|
||||
'/system-settings/billing/model-pricing'
|
||||
|
||||
const MODEL_PRICE_ERROR_CODE = 'model_price_error'
|
||||
const FALLBACK_ERROR_CONTENT = 'An unknown error occurred'
|
||||
|
||||
type MessageErrorState = {
|
||||
content: string
|
||||
kind: 'generic' | 'model-price'
|
||||
showSettingsLink: boolean
|
||||
}
|
||||
|
||||
export function isAdminRole(role?: number | null): boolean {
|
||||
return role != null && role >= 10
|
||||
}
|
||||
|
||||
export function isErrorMessage(message: Message): boolean {
|
||||
return message.status === MESSAGE_STATUS.ERROR
|
||||
}
|
||||
|
||||
export function getMessageErrorState(
|
||||
message: Message,
|
||||
isAdmin: boolean
|
||||
): MessageErrorState | null {
|
||||
if (!isErrorMessage(message)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = getMessageContent(message) || FALLBACK_ERROR_CONTENT
|
||||
const isModelPriceError = message.errorCode === MODEL_PRICE_ERROR_CODE
|
||||
|
||||
return {
|
||||
content,
|
||||
kind: isModelPriceError ? 'model-price' : 'generic',
|
||||
showSettingsLink: isModelPriceError && isAdmin,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
interface ParsedThinkTags {
|
||||
visibleContent: string
|
||||
reasoning: string
|
||||
hasUnclosedTag: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content to separate thinking from visible text.
|
||||
* Handles both complete and incomplete <think> tags.
|
||||
*/
|
||||
export function parseThinkTags(content: string): ParsedThinkTags {
|
||||
if (!content.includes('<think>')) {
|
||||
return { visibleContent: content, reasoning: '', hasUnclosedTag: false }
|
||||
}
|
||||
|
||||
const visibleParts: string[] = []
|
||||
const reasoningParts: string[] = []
|
||||
let currentPos = 0
|
||||
let hasUnclosedTag = false
|
||||
|
||||
while (true) {
|
||||
const openPos = content.indexOf('<think>', currentPos)
|
||||
|
||||
if (openPos === -1) {
|
||||
if (currentPos < content.length) {
|
||||
visibleParts.push(content.substring(currentPos))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (openPos > currentPos) {
|
||||
visibleParts.push(content.substring(currentPos, openPos))
|
||||
}
|
||||
|
||||
const closePos = content.indexOf('</think>', openPos + 7)
|
||||
|
||||
if (closePos === -1) {
|
||||
reasoningParts.push(content.substring(openPos + 7))
|
||||
hasUnclosedTag = true
|
||||
break
|
||||
}
|
||||
|
||||
reasoningParts.push(content.substring(openPos + 7, closePos))
|
||||
currentPos = closePos + 8
|
||||
}
|
||||
|
||||
return {
|
||||
visibleContent: visibleParts.join('').trim(),
|
||||
reasoning: reasoningParts.join('\n\n').trim(),
|
||||
hasUnclosedTag,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ERROR_MESSAGES, MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { ChatCompletionResponse, Message } from '../types'
|
||||
import {
|
||||
getCurrentVersion,
|
||||
hasMessageContent,
|
||||
updateCurrentVersionContent,
|
||||
} from './message-utils'
|
||||
import { parseThinkTags } from './message-reasoning-utils'
|
||||
|
||||
/**
|
||||
* Process content chunk during streaming.
|
||||
* Separates <think> reasoning from visible content in real-time.
|
||||
* Note: versions[0].content keeps the full raw content with tags during streaming.
|
||||
*/
|
||||
export function processStreamingContent(
|
||||
message: Message,
|
||||
contentChunk?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const fullContent = contentChunk
|
||||
? currentVersion.content + contentChunk
|
||||
: currentVersion.content
|
||||
|
||||
const { reasoning, hasUnclosedTag } = parseThinkTags(fullContent)
|
||||
const finalReasoning = reasoning
|
||||
? { content: reasoning, duration: 0 }
|
||||
: message.reasoning
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, fullContent),
|
||||
reasoning: finalReasoning,
|
||||
isReasoningStreaming: hasUnclosedTag,
|
||||
}
|
||||
}
|
||||
|
||||
export type StreamChunkType = 'reasoning' | 'content'
|
||||
|
||||
export function applyStreamingChunk(
|
||||
message: Message,
|
||||
type: StreamChunkType,
|
||||
chunk: string
|
||||
): Message {
|
||||
if (message.status === MESSAGE_STATUS.ERROR) {
|
||||
return message
|
||||
}
|
||||
|
||||
if (type === 'reasoning') {
|
||||
return {
|
||||
...message,
|
||||
reasoning: {
|
||||
content: (message.reasoning?.content || '') + chunk,
|
||||
duration: 0,
|
||||
},
|
||||
isReasoningStreaming: true,
|
||||
status: MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...processStreamingContent(message, chunk),
|
||||
status: MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize message after streaming completes.
|
||||
* Cleans content and consolidates reasoning from all sources.
|
||||
*/
|
||||
export function finalizeMessage(
|
||||
message: Message,
|
||||
apiReasoningContent?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const { visibleContent, reasoning } = parseThinkTags(currentVersion.content)
|
||||
const finalReasoning =
|
||||
apiReasoningContent || message.reasoning?.content || reasoning || ''
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, visibleContent),
|
||||
reasoning: finalReasoning
|
||||
? { content: finalReasoning, duration: message.reasoning?.duration || 0 }
|
||||
: undefined,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function completeAssistantMessage(message: Message): Message {
|
||||
return {
|
||||
...finalizeMessage(message),
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
}
|
||||
}
|
||||
|
||||
export function isAssistantMessageFinal(message: Message): boolean {
|
||||
return (
|
||||
message.status === MESSAGE_STATUS.COMPLETE ||
|
||||
message.status === MESSAGE_STATUS.ERROR
|
||||
)
|
||||
}
|
||||
|
||||
export function isAssistantMessagePending(message: Message): boolean {
|
||||
return (
|
||||
message.status === MESSAGE_STATUS.LOADING ||
|
||||
message.status === MESSAGE_STATUS.STREAMING
|
||||
)
|
||||
}
|
||||
|
||||
export function isPendingAssistantMessage(message?: Message): boolean {
|
||||
return Boolean(
|
||||
message?.from === MESSAGE_ROLES.ASSISTANT &&
|
||||
isAssistantMessagePending(message)
|
||||
)
|
||||
}
|
||||
|
||||
type ChatCompletionChoice = ChatCompletionResponse['choices'][number]
|
||||
|
||||
export function hasChatCompletionChoice(
|
||||
response: ChatCompletionResponse
|
||||
): boolean {
|
||||
return Boolean(response.choices?.[0])
|
||||
}
|
||||
|
||||
export function applyChatCompletionChoice(
|
||||
message: Message,
|
||||
choice: ChatCompletionChoice
|
||||
): Message {
|
||||
return {
|
||||
...finalizeMessage(
|
||||
updateCurrentVersionContent(message, choice.message?.content || ''),
|
||||
choice.message?.reasoning_content
|
||||
),
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyChatCompletionResponse(
|
||||
message: Message,
|
||||
response: ChatCompletionResponse
|
||||
): Message | null {
|
||||
const choice = response.choices?.[0]
|
||||
|
||||
if (!choice) {
|
||||
return null
|
||||
}
|
||||
|
||||
return applyChatCompletionChoice(message, choice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize messages loaded from storage.
|
||||
* Converts stuck loading/streaming messages to stable state.
|
||||
*/
|
||||
export function sanitizeMessagesOnLoad(messages: Message[]): Message[] {
|
||||
let targetIndex = -1
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
|
||||
if (isPendingAssistantMessage(message)) {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex === -1) return messages
|
||||
|
||||
const finalized = finalizeMessage(messages[targetIndex])
|
||||
const hasContent = hasMessageContent(finalized)
|
||||
const hasReasoning = finalized.reasoning?.content?.trim()
|
||||
|
||||
const sanitized: Message =
|
||||
hasContent || hasReasoning
|
||||
? {
|
||||
...finalized,
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
: {
|
||||
...updateCurrentVersionContent(
|
||||
finalized,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${ERROR_MESSAGES.INTERRUPTED}`
|
||||
),
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
|
||||
const result = [...messages]
|
||||
result[targetIndex] = sanitized
|
||||
return result
|
||||
}
|
||||
+24
-10
@@ -22,25 +22,39 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function getMessageContentStyles() {
|
||||
return [
|
||||
// Assistant content fills the row; user bubble auto-width
|
||||
// Assistant content reads like a document column; user bubble stays compact.
|
||||
'group-[.is-assistant]:w-full',
|
||||
'group-[.is-assistant]:max-w-none',
|
||||
'group-[.is-assistant]:max-w-[78ch]',
|
||||
'group-[.is-user]:w-fit',
|
||||
// User bubble: rounded and themed background
|
||||
|
||||
// User bubble: compact surface that stays calm in both light and dark themes.
|
||||
'group-[.is-user]:rounded-2xl',
|
||||
'group-[.is-user]:rounded-br-md',
|
||||
'group-[.is-user]:border',
|
||||
'group-[.is-user]:border-border/70',
|
||||
'group-[.is-user]:bg-muted/70',
|
||||
'group-[.is-user]:px-4',
|
||||
'group-[.is-user]:py-2.5',
|
||||
'group-[.is-user]:text-foreground',
|
||||
'group-[.is-user]:bg-secondary',
|
||||
'dark:group-[.is-user]:bg-muted',
|
||||
'group-[.is-user]:rounded-3xl',
|
||||
// Assistant bubble: flat serif style (one-sided style)
|
||||
'group-[.is-assistant]:text-foreground',
|
||||
'group-[.is-user]:shadow-sm',
|
||||
'group-[.is-user]:shadow-black/5',
|
||||
|
||||
// Assistant response: flat reading surface using the active UI font axis.
|
||||
'group-[.is-assistant]:bg-transparent',
|
||||
'group-[.is-assistant]:p-0',
|
||||
'group-[.is-assistant]:font-serif',
|
||||
'group-[.is-assistant]:rounded-none',
|
||||
'group-[.is-assistant]:overflow-visible',
|
||||
'group-[.is-assistant]:[font-family:var(--font-body)]',
|
||||
'group-[.is-assistant]:text-foreground/90',
|
||||
|
||||
// Preferred readable widths and wrapping
|
||||
'leading-relaxed',
|
||||
'text-[0.95rem]',
|
||||
'leading-6',
|
||||
'break-words',
|
||||
'whitespace-pre-wrap',
|
||||
'sm:text-[0.975rem]',
|
||||
'sm:leading-7',
|
||||
|
||||
// Cap user bubble width so it does not look like a banner
|
||||
'group-[.is-user]:max-w-[85%]',
|
||||
'sm:group-[.is-user]:max-w-[62ch]',
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ERROR_MESSAGES, MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { updateCurrentVersionContent } from './message-utils'
|
||||
|
||||
/**
|
||||
* Update the last assistant message with an error.
|
||||
*/
|
||||
export function updateAssistantMessageWithError(
|
||||
messages: Message[],
|
||||
errorMessage: string,
|
||||
errorCode?: string
|
||||
): Message[] {
|
||||
return updateLastAssistantMessage(messages, (message) => {
|
||||
const updatedMessage = updateCurrentVersionContent(
|
||||
message,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${errorMessage}`
|
||||
)
|
||||
|
||||
return {
|
||||
...updatedMessage,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
errorCode: errorCode || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the most recent assistant message, preserving the array when absent.
|
||||
*/
|
||||
export function updateLastAssistantMessage(
|
||||
messages: Message[],
|
||||
updater: (message: Message) => Message
|
||||
): Message[] {
|
||||
if (messages.length === 0) return messages
|
||||
|
||||
const last = messages[messages.length - 1]
|
||||
if (!last || last.from !== MESSAGE_ROLES.ASSISTANT) return messages
|
||||
|
||||
const updated = [...messages]
|
||||
updated[updated.length - 1] = updater(last)
|
||||
return updated
|
||||
}
|
||||
+18
-206
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { nanoid } from 'nanoid'
|
||||
import { MESSAGE_ROLES, MESSAGE_STATUS, ERROR_MESSAGES } from '../constants'
|
||||
import { MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type {
|
||||
Message,
|
||||
MessageVersion,
|
||||
@@ -42,6 +42,20 @@ export function getCurrentVersion(message: Message): MessageVersion {
|
||||
return message.versions[0] || { id: 'default', content: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get displayable content from the current message version.
|
||||
*/
|
||||
export function getMessageContent(message: Message): string {
|
||||
return getCurrentVersion(message).content
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a message has non-empty content in its current version.
|
||||
*/
|
||||
export function hasMessageContent(message: Message): boolean {
|
||||
return getMessageContent(message).trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current version content in message
|
||||
*/
|
||||
@@ -144,212 +158,10 @@ export function formatMessageForAPI(message: Message): ChatCompletionMessage {
|
||||
export function isValidMessage(message: Message): boolean {
|
||||
if (!message || !message.from || !message.versions.length) return false
|
||||
|
||||
const content = message.versions[0]?.content
|
||||
if (content === undefined) return false
|
||||
|
||||
// Exclude empty assistant messages (loading/streaming placeholders)
|
||||
if (message.from === 'assistant' && !content.trim()) return false
|
||||
if (message.from === MESSAGE_ROLES.ASSISTANT && !hasMessageContent(message)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content to separate thinking from visible text
|
||||
* Handles both complete and incomplete <think> tags
|
||||
*/
|
||||
export function parseThinkTags(content: string): {
|
||||
visibleContent: string
|
||||
reasoning: string
|
||||
hasUnclosedTag: boolean
|
||||
} {
|
||||
if (!content.includes('<think>')) {
|
||||
return { visibleContent: content, reasoning: '', hasUnclosedTag: false }
|
||||
}
|
||||
|
||||
const visibleParts: string[] = []
|
||||
const reasoningParts: string[] = []
|
||||
let currentPos = 0
|
||||
let hasUnclosed = false
|
||||
|
||||
while (true) {
|
||||
// Find next <think> tag
|
||||
const openPos = content.indexOf('<think>', currentPos)
|
||||
|
||||
if (openPos === -1) {
|
||||
// No more think tags, add remaining content
|
||||
if (currentPos < content.length) {
|
||||
visibleParts.push(content.substring(currentPos))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Add visible content before this tag
|
||||
if (openPos > currentPos) {
|
||||
visibleParts.push(content.substring(currentPos, openPos))
|
||||
}
|
||||
|
||||
// Look for matching </think> tag
|
||||
const closePos = content.indexOf('</think>', openPos + 7)
|
||||
|
||||
if (closePos === -1) {
|
||||
// Unclosed tag: rest is reasoning buffer
|
||||
reasoningParts.push(content.substring(openPos + 7))
|
||||
hasUnclosed = true
|
||||
break
|
||||
}
|
||||
|
||||
// Extract reasoning content between tags
|
||||
reasoningParts.push(content.substring(openPos + 7, closePos))
|
||||
currentPos = closePos + 8
|
||||
}
|
||||
|
||||
return {
|
||||
visibleContent: visibleParts.join('').trim(),
|
||||
reasoning: reasoningParts.join('\n\n').trim(),
|
||||
hasUnclosedTag: hasUnclosed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last assistant message with an error
|
||||
* @param messages - Current messages array
|
||||
* @param errorMessage - Error message to display
|
||||
* @returns Updated messages array
|
||||
*/
|
||||
export function updateAssistantMessageWithError(
|
||||
messages: Message[],
|
||||
errorMessage: string,
|
||||
errorCode?: string
|
||||
): Message[] {
|
||||
return updateLastAssistantMessage(messages, (message) => {
|
||||
const updatedMessage = updateCurrentVersionContent(
|
||||
message,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${errorMessage}`
|
||||
)
|
||||
return {
|
||||
...updatedMessage,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
errorCode: errorCode || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to update the last assistant message
|
||||
* @param messages - Current messages array
|
||||
* @param updater - Function to update the message
|
||||
* @returns Updated messages array or original if no assistant message found
|
||||
*/
|
||||
export function updateLastAssistantMessage(
|
||||
messages: Message[],
|
||||
updater: (message: Message) => Message
|
||||
): Message[] {
|
||||
if (messages.length === 0) return messages
|
||||
const last = messages[messages.length - 1]
|
||||
if (!last || last.from !== MESSAGE_ROLES.ASSISTANT) return messages
|
||||
|
||||
const updated = [...messages]
|
||||
updated[updated.length - 1] = updater(last)
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Process content chunk during streaming
|
||||
* Separates <think> reasoning from visible content in real-time
|
||||
* Note: versions[0].content keeps the full raw content (with tags) during streaming
|
||||
*/
|
||||
export function processStreamingContent(
|
||||
message: Message,
|
||||
contentChunk?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const fullContent = contentChunk
|
||||
? currentVersion.content + contentChunk
|
||||
: currentVersion.content
|
||||
|
||||
const { reasoning, hasUnclosedTag } = parseThinkTags(fullContent)
|
||||
|
||||
// Preserve existing reasoning if no think tags found (e.g., from API reasoning_content)
|
||||
const finalReasoning = reasoning
|
||||
? { content: reasoning, duration: 0 }
|
||||
: message.reasoning
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, fullContent),
|
||||
reasoning: finalReasoning,
|
||||
isReasoningStreaming: hasUnclosedTag,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize message after streaming completes
|
||||
* Cleans content and consolidates reasoning from all sources
|
||||
*/
|
||||
export function finalizeMessage(
|
||||
message: Message,
|
||||
apiReasoningContent?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const { visibleContent, reasoning } = parseThinkTags(currentVersion.content)
|
||||
|
||||
// Priority:
|
||||
// 1. API reasoning_content passed as parameter (non-streaming response)
|
||||
// 2. Existing message.reasoning (from streaming reasoning_content)
|
||||
// 3. Extracted think tags from content
|
||||
const finalReasoning =
|
||||
apiReasoningContent || message.reasoning?.content || reasoning || ''
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, visibleContent),
|
||||
reasoning: finalReasoning
|
||||
? { content: finalReasoning, duration: message.reasoning?.duration || 0 }
|
||||
: undefined,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize messages loaded from storage
|
||||
* Converts stuck loading/streaming messages to stable state
|
||||
*/
|
||||
export function sanitizeMessagesOnLoad(messages: Message[]): Message[] {
|
||||
let targetIndex = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]
|
||||
if (
|
||||
m?.from === MESSAGE_ROLES.ASSISTANT &&
|
||||
(m?.status === MESSAGE_STATUS.LOADING ||
|
||||
m?.status === MESSAGE_STATUS.STREAMING)
|
||||
) {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex === -1) return messages
|
||||
|
||||
const finalized = finalizeMessage(messages[targetIndex])
|
||||
const hasContent = finalized.versions?.[0]?.content?.trim()
|
||||
const hasReasoning = finalized.reasoning?.content?.trim()
|
||||
|
||||
const sanitized: Message =
|
||||
hasContent || hasReasoning
|
||||
? {
|
||||
...finalized,
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
: {
|
||||
...updateCurrentVersionContent(
|
||||
finalized,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${ERROR_MESSAGES.INTERRUPTED}`
|
||||
),
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
|
||||
const result = [...messages]
|
||||
result[targetIndex] = sanitized
|
||||
return result
|
||||
}
|
||||
|
||||
+22
-17
@@ -44,24 +44,29 @@ export function buildChatCompletionPayload(
|
||||
stream: config.stream,
|
||||
}
|
||||
|
||||
// Add enabled parameters
|
||||
const parameterKeys: Array<keyof ParameterEnabled> = [
|
||||
'temperature',
|
||||
'top_p',
|
||||
'max_tokens',
|
||||
'frequency_penalty',
|
||||
'presence_penalty',
|
||||
'seed',
|
||||
]
|
||||
if (parameterEnabled.temperature) {
|
||||
payload.temperature = config.temperature
|
||||
}
|
||||
|
||||
parameterKeys.forEach((key) => {
|
||||
if (parameterEnabled[key]) {
|
||||
const value = config[key as keyof PlaygroundConfig]
|
||||
if (value !== undefined && value !== null) {
|
||||
;(payload as unknown as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
if (parameterEnabled.top_p) {
|
||||
payload.top_p = config.top_p
|
||||
}
|
||||
|
||||
if (parameterEnabled.max_tokens) {
|
||||
payload.max_tokens = config.max_tokens
|
||||
}
|
||||
|
||||
if (parameterEnabled.frequency_penalty) {
|
||||
payload.frequency_penalty = config.frequency_penalty
|
||||
}
|
||||
|
||||
if (parameterEnabled.presence_penalty) {
|
||||
payload.presence_penalty = config.presence_penalty
|
||||
}
|
||||
|
||||
if (parameterEnabled.seed && config.seed !== null) {
|
||||
payload.seed = config.seed
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { GroupOption, ModelOption } from '../types'
|
||||
|
||||
export function getModelFallback(
|
||||
models: ModelOption[],
|
||||
currentModel: string
|
||||
): string | null {
|
||||
const hasCurrentModel = models.some((model) => model.value === currentModel)
|
||||
|
||||
if (hasCurrentModel || models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return models[0].value
|
||||
}
|
||||
|
||||
export function getGroupFallback(
|
||||
groups: GroupOption[],
|
||||
currentGroup: string
|
||||
): string | null {
|
||||
const hasCurrentGroup = groups.some((group) => group.value === currentGroup)
|
||||
|
||||
if (hasCurrentGroup || groups.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
groups.find((group) => group.value === 'default')?.value ?? groups[0].value
|
||||
)
|
||||
}
|
||||
|
||||
export function getOptionLoadErrorMessage(
|
||||
error: unknown,
|
||||
fallbackMessage: string
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallbackMessage
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../constants'
|
||||
import type { Message, ParameterEnabled, PlaygroundConfig } from '../types'
|
||||
import { loadConfig, loadMessages, loadParameterEnabled } from './storage'
|
||||
|
||||
export type MessageStateUpdater =
|
||||
| Message[]
|
||||
| ((previousMessages: Message[]) => Message[])
|
||||
|
||||
export function getInitialPlaygroundConfig(): PlaygroundConfig {
|
||||
return { ...DEFAULT_CONFIG, ...loadConfig() }
|
||||
}
|
||||
|
||||
export function getInitialParameterEnabled(): ParameterEnabled {
|
||||
return { ...DEFAULT_PARAMETER_ENABLED, ...loadParameterEnabled() }
|
||||
}
|
||||
|
||||
export function getInitialMessages(): Message[] {
|
||||
return loadMessages() || []
|
||||
}
|
||||
|
||||
export function applyMessageStateUpdate(
|
||||
previousMessages: Message[],
|
||||
updater: MessageStateUpdater
|
||||
): Message[] {
|
||||
return typeof updater === 'function' ? updater(previousMessages) : updater
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
|
||||
type RequestErrorLike = {
|
||||
message?: string
|
||||
response?: {
|
||||
data?: {
|
||||
error?: {
|
||||
code?: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestErrorDetails = {
|
||||
errorCode?: string
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export function parseRequestErrorDetails(error: unknown): RequestErrorDetails {
|
||||
const requestError = error as RequestErrorLike
|
||||
|
||||
return {
|
||||
errorCode: requestError?.response?.data?.error?.code || undefined,
|
||||
errorMessage:
|
||||
requestError?.response?.data?.message ||
|
||||
requestError?.message ||
|
||||
ERROR_MESSAGES.API_REQUEST_ERROR,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
export const STORAGE_VERSION = 1
|
||||
export const MAX_STORED_MESSAGES = 100
|
||||
|
||||
export const playgroundConfigSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
temperature: z.number().finite().optional(),
|
||||
top_p: z.number().finite().optional(),
|
||||
max_tokens: z.number().finite().optional(),
|
||||
frequency_penalty: z.number().finite().optional(),
|
||||
presence_penalty: z.number().finite().optional(),
|
||||
seed: z.number().finite().nullable().optional(),
|
||||
stream: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const parameterEnabledSchema = z.object({
|
||||
temperature: z.boolean().optional(),
|
||||
top_p: z.boolean().optional(),
|
||||
max_tokens: z.boolean().optional(),
|
||||
frequency_penalty: z.boolean().optional(),
|
||||
presence_penalty: z.boolean().optional(),
|
||||
seed: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const messageRoleSchema = z.enum(['user', 'assistant', 'system'])
|
||||
const messageStatusSchema = z.enum([
|
||||
'loading',
|
||||
'streaming',
|
||||
'complete',
|
||||
'error',
|
||||
])
|
||||
|
||||
const messageVersionSchema = z.object({
|
||||
id: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
const sourceSchema = z.object({
|
||||
href: z.string(),
|
||||
title: z.string(),
|
||||
})
|
||||
|
||||
const reasoningSchema = z.object({
|
||||
content: z.string(),
|
||||
duration: z.number().finite(),
|
||||
})
|
||||
|
||||
const messageSchema = z.object({
|
||||
key: z.string(),
|
||||
from: messageRoleSchema,
|
||||
versions: z.array(messageVersionSchema).min(1),
|
||||
sources: z.array(sourceSchema).optional(),
|
||||
reasoning: reasoningSchema.optional(),
|
||||
isReasoningStreaming: z.boolean().optional(),
|
||||
isReasoningComplete: z.boolean().optional(),
|
||||
isContentComplete: z.boolean().optional(),
|
||||
status: messageStatusSchema.optional(),
|
||||
errorCode: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export const messagesSchema = z.array(messageSchema)
|
||||
+75
-27
@@ -18,17 +18,65 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { STORAGE_KEYS } from '../constants'
|
||||
import type { PlaygroundConfig, ParameterEnabled, Message } from '../types'
|
||||
import { sanitizeMessagesOnLoad } from './message-utils'
|
||||
import { sanitizeMessagesOnLoad } from './message-streaming-utils'
|
||||
import {
|
||||
MAX_STORED_MESSAGES,
|
||||
STORAGE_VERSION,
|
||||
messagesSchema,
|
||||
parameterEnabledSchema,
|
||||
playgroundConfigSchema,
|
||||
} from './storage-schema'
|
||||
|
||||
type StoredEnvelope<T> = {
|
||||
version: number
|
||||
data: T
|
||||
}
|
||||
|
||||
function readStoredValue(key: string): unknown | null {
|
||||
const saved = localStorage.getItem(key)
|
||||
if (!saved) return null
|
||||
|
||||
return JSON.parse(saved) as unknown
|
||||
}
|
||||
|
||||
function unwrapStoredValue(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value
|
||||
}
|
||||
|
||||
if ('version' in value && 'data' in value) {
|
||||
return (value as StoredEnvelope<unknown>).data
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function writeStoredValue<T>(key: string, data: T): void {
|
||||
const payload: StoredEnvelope<T> = {
|
||||
version: STORAGE_VERSION,
|
||||
data,
|
||||
}
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function trimMessages(messages: Message[]): Message[] {
|
||||
if (messages.length <= MAX_STORED_MESSAGES) {
|
||||
return messages
|
||||
}
|
||||
|
||||
return messages.slice(-MAX_STORED_MESSAGES)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load playground config from localStorage
|
||||
*/
|
||||
export function loadConfig(): Partial<PlaygroundConfig> {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.CONFIG)
|
||||
if (saved) {
|
||||
return JSON.parse(saved)
|
||||
}
|
||||
const saved = readStoredValue(STORAGE_KEYS.CONFIG)
|
||||
if (!saved) return {}
|
||||
|
||||
return playgroundConfigSchema.parse(unwrapStoredValue(saved))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load config:', error)
|
||||
@@ -41,7 +89,8 @@ export function loadConfig(): Partial<PlaygroundConfig> {
|
||||
*/
|
||||
export function saveConfig(config: Partial<PlaygroundConfig>): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(config))
|
||||
const parsed = playgroundConfigSchema.parse(config)
|
||||
writeStoredValue(STORAGE_KEYS.CONFIG, parsed)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save config:', error)
|
||||
@@ -53,10 +102,10 @@ export function saveConfig(config: Partial<PlaygroundConfig>): void {
|
||||
*/
|
||||
export function loadParameterEnabled(): Partial<ParameterEnabled> {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.PARAMETER_ENABLED)
|
||||
if (saved) {
|
||||
return JSON.parse(saved)
|
||||
}
|
||||
const saved = readStoredValue(STORAGE_KEYS.PARAMETER_ENABLED)
|
||||
if (!saved) return {}
|
||||
|
||||
return parameterEnabledSchema.parse(unwrapStoredValue(saved))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load parameter enabled:', error)
|
||||
@@ -71,10 +120,8 @@ export function saveParameterEnabled(
|
||||
parameterEnabled: Partial<ParameterEnabled>
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.PARAMETER_ENABLED,
|
||||
JSON.stringify(parameterEnabled)
|
||||
)
|
||||
const parsed = parameterEnabledSchema.parse(parameterEnabled)
|
||||
writeStoredValue(STORAGE_KEYS.PARAMETER_ENABLED, parsed)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save parameter enabled:', error)
|
||||
@@ -86,19 +133,18 @@ export function saveParameterEnabled(
|
||||
*/
|
||||
export function loadMessages(): Message[] | null {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.MESSAGES)
|
||||
if (saved) {
|
||||
const parsed: unknown = JSON.parse(saved)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
const sanitized = sanitizeMessagesOnLoad(parsed as Message[])
|
||||
// Persist sanitized result to avoid re-sanitizing on subsequent loads
|
||||
if (sanitized !== parsed) {
|
||||
saveMessages(sanitized)
|
||||
}
|
||||
return sanitized
|
||||
const saved = readStoredValue(STORAGE_KEYS.MESSAGES)
|
||||
if (!saved) return null
|
||||
|
||||
const parsed = messagesSchema.parse(unwrapStoredValue(saved)) as Message[]
|
||||
const trimmed = trimMessages(parsed)
|
||||
const sanitized = sanitizeMessagesOnLoad(trimmed)
|
||||
|
||||
if (sanitized !== parsed || trimmed !== parsed) {
|
||||
saveMessages(sanitized)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load messages:', error)
|
||||
@@ -111,7 +157,9 @@ export function loadMessages(): Message[] | null {
|
||||
*/
|
||||
export function saveMessages(messages: Message[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messages))
|
||||
const trimmed = trimMessages(messages)
|
||||
const parsed = messagesSchema.parse(trimmed) as Message[]
|
||||
writeStoredValue(STORAGE_KEYS.MESSAGES, parsed)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save messages:', error)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
import type { ChatCompletionChunk } from '../types'
|
||||
|
||||
const STREAM_DONE_MESSAGE = '[DONE]'
|
||||
const STREAM_CLOSED_READY_STATE = 2
|
||||
|
||||
export type StreamUpdateType = 'reasoning' | 'content'
|
||||
|
||||
export type StreamMessageUpdate = {
|
||||
type: StreamUpdateType
|
||||
chunk: string
|
||||
}
|
||||
|
||||
type StreamErrorPayload = {
|
||||
error?: {
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type StreamErrorDetails = {
|
||||
errorCode?: string
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export function parseStreamErrorDetails(data?: string): StreamErrorDetails {
|
||||
const fallbackMessage = data || ERROR_MESSAGES.API_REQUEST_ERROR
|
||||
|
||||
if (!data) {
|
||||
return { errorMessage: fallbackMessage }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as StreamErrorPayload
|
||||
|
||||
if (!parsed?.error) {
|
||||
return { errorMessage: fallbackMessage }
|
||||
}
|
||||
|
||||
return {
|
||||
errorCode: parsed.error.code || undefined,
|
||||
errorMessage: parsed.error.message || fallbackMessage,
|
||||
}
|
||||
} catch {
|
||||
return { errorMessage: fallbackMessage }
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStreamMessageUpdates(data: string): StreamMessageUpdate[] {
|
||||
const chunk = JSON.parse(data) as ChatCompletionChunk
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
|
||||
if (!delta) {
|
||||
return []
|
||||
}
|
||||
|
||||
const updates: StreamMessageUpdate[] = []
|
||||
|
||||
if (delta.reasoning_content) {
|
||||
updates.push({ type: 'reasoning', chunk: delta.reasoning_content })
|
||||
}
|
||||
|
||||
if (delta.content) {
|
||||
updates.push({ type: 'content', chunk: delta.content })
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
export function isStreamDoneMessage(data: string): boolean {
|
||||
return data === STREAM_DONE_MESSAGE
|
||||
}
|
||||
|
||||
export function isStreamClosedReadyState(readyState?: number): boolean {
|
||||
return readyState === STREAM_CLOSED_READY_STATE
|
||||
}
|
||||
|
||||
export function getStreamReadyStateError(
|
||||
eventReadyState: number | undefined,
|
||||
source: unknown
|
||||
): string | null {
|
||||
const status = (source as { status?: number }).status
|
||||
|
||||
if (
|
||||
eventReadyState !== undefined &&
|
||||
eventReadyState >= STREAM_CLOSED_READY_STATE &&
|
||||
status !== undefined &&
|
||||
status !== 200
|
||||
) {
|
||||
return `HTTP ${status}: ${ERROR_MESSAGES.CONNECTION_CLOSED}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
const MORE_SUGGESTION_TEXT = 'More'
|
||||
const SUGGESTION_CLASS_NAME = 'text-xs font-normal sm:text-sm'
|
||||
const MOBILE_HIDDEN_SUGGESTION_CLASS_NAME = `${SUGGESTION_CLASS_NAME} hidden sm:flex`
|
||||
|
||||
type SuggestionDisplayState = {
|
||||
className: string
|
||||
}
|
||||
|
||||
export function getSuggestionDisplayState(
|
||||
text: string
|
||||
): SuggestionDisplayState {
|
||||
return {
|
||||
className:
|
||||
text === MORE_SUGGESTION_TEXT
|
||||
? MOBILE_HIDDEN_SUGGESTION_CLASS_NAME
|
||||
: SUGGESTION_CLASS_NAME,
|
||||
}
|
||||
}
|
||||
+301
-166
@@ -16,7 +16,7 @@ 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 { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import axios from 'axios'
|
||||
import { useForm } from 'react-hook-form'
|
||||
@@ -46,129 +46,197 @@ import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
/**
|
||||
* react-hook-form 7 treats dotted `name` strings as nested paths. To keep
|
||||
* form state, schema validation, and dirty tracking aligned, the
|
||||
* `discord.*` and `oidc.*` fields are modeled as nested objects here and
|
||||
* flattened back to dotted server keys only when persisting.
|
||||
*/
|
||||
const oauthSchema = z.object({
|
||||
GitHubOAuthEnabled: z.boolean(),
|
||||
GitHubClientId: z.string().optional(),
|
||||
GitHubClientSecret: z.string().optional(),
|
||||
'discord.enabled': z.boolean(),
|
||||
'discord.client_id': z.string().optional(),
|
||||
'discord.client_secret': z.string().optional(),
|
||||
'oidc.enabled': z.boolean(),
|
||||
'oidc.client_id': z.string().optional(),
|
||||
'oidc.client_secret': z.string().optional(),
|
||||
'oidc.well_known': z.string().optional(),
|
||||
'oidc.authorization_endpoint': z.string().optional(),
|
||||
'oidc.token_endpoint': z.string().optional(),
|
||||
'oidc.user_info_endpoint': z.string().optional(),
|
||||
GitHubClientId: z.string(),
|
||||
GitHubClientSecret: z.string(),
|
||||
discord: z.object({
|
||||
enabled: z.boolean(),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
}),
|
||||
oidc: z.object({
|
||||
enabled: z.boolean(),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
well_known: z.string(),
|
||||
authorization_endpoint: z.string(),
|
||||
token_endpoint: z.string(),
|
||||
user_info_endpoint: z.string(),
|
||||
}),
|
||||
TelegramOAuthEnabled: z.boolean(),
|
||||
TelegramBotToken: z.string().optional(),
|
||||
TelegramBotName: z.string().optional(),
|
||||
TelegramBotToken: z.string(),
|
||||
TelegramBotName: z.string(),
|
||||
LinuxDOOAuthEnabled: z.boolean(),
|
||||
LinuxDOClientId: z.string().optional(),
|
||||
LinuxDOClientSecret: z.string().optional(),
|
||||
LinuxDOMinimumTrustLevel: z.string().optional(),
|
||||
LinuxDOClientId: z.string(),
|
||||
LinuxDOClientSecret: z.string(),
|
||||
LinuxDOMinimumTrustLevel: z.string(),
|
||||
WeChatAuthEnabled: z.boolean(),
|
||||
WeChatServerAddress: z.string().optional(),
|
||||
WeChatServerToken: z.string().optional(),
|
||||
WeChatAccountQRCodeImageURL: z.string().optional(),
|
||||
WeChatServerAddress: z.string(),
|
||||
WeChatServerToken: z.string(),
|
||||
WeChatAccountQRCodeImageURL: z.string(),
|
||||
})
|
||||
|
||||
type OAuthFormValues = z.infer<typeof oauthSchema>
|
||||
|
||||
type FlatOAuthDefaults = {
|
||||
GitHubOAuthEnabled: boolean
|
||||
GitHubClientId: string
|
||||
GitHubClientSecret: string
|
||||
'discord.enabled': boolean
|
||||
'discord.client_id': string
|
||||
'discord.client_secret': string
|
||||
'oidc.enabled': boolean
|
||||
'oidc.client_id': string
|
||||
'oidc.client_secret': string
|
||||
'oidc.well_known': string
|
||||
'oidc.authorization_endpoint': string
|
||||
'oidc.token_endpoint': string
|
||||
'oidc.user_info_endpoint': string
|
||||
TelegramOAuthEnabled: boolean
|
||||
TelegramBotToken: string
|
||||
TelegramBotName: string
|
||||
LinuxDOOAuthEnabled: boolean
|
||||
LinuxDOClientId: string
|
||||
LinuxDOClientSecret: string
|
||||
LinuxDOMinimumTrustLevel: string
|
||||
WeChatAuthEnabled: boolean
|
||||
WeChatServerAddress: string
|
||||
WeChatServerToken: string
|
||||
WeChatAccountQRCodeImageURL: string
|
||||
}
|
||||
|
||||
const oauthTabContentClassName =
|
||||
'grid min-w-0 gap-x-5 gap-y-6 lg:grid-cols-2 [&>[data-slot=form-item]]:min-w-0 lg:[&>[data-slot=form-item]:has([data-slot=switch])]:col-span-2'
|
||||
|
||||
type OAuthFormValues = z.infer<typeof oauthSchema>
|
||||
const buildFormDefaults = (defaults: FlatOAuthDefaults): OAuthFormValues => ({
|
||||
GitHubOAuthEnabled: defaults.GitHubOAuthEnabled,
|
||||
GitHubClientId: defaults.GitHubClientId ?? '',
|
||||
GitHubClientSecret: defaults.GitHubClientSecret ?? '',
|
||||
discord: {
|
||||
enabled: defaults['discord.enabled'],
|
||||
client_id: defaults['discord.client_id'] ?? '',
|
||||
client_secret: defaults['discord.client_secret'] ?? '',
|
||||
},
|
||||
oidc: {
|
||||
enabled: defaults['oidc.enabled'],
|
||||
client_id: defaults['oidc.client_id'] ?? '',
|
||||
client_secret: defaults['oidc.client_secret'] ?? '',
|
||||
well_known: defaults['oidc.well_known'] ?? '',
|
||||
authorization_endpoint: defaults['oidc.authorization_endpoint'] ?? '',
|
||||
token_endpoint: defaults['oidc.token_endpoint'] ?? '',
|
||||
user_info_endpoint: defaults['oidc.user_info_endpoint'] ?? '',
|
||||
},
|
||||
TelegramOAuthEnabled: defaults.TelegramOAuthEnabled,
|
||||
TelegramBotToken: defaults.TelegramBotToken ?? '',
|
||||
TelegramBotName: defaults.TelegramBotName ?? '',
|
||||
LinuxDOOAuthEnabled: defaults.LinuxDOOAuthEnabled,
|
||||
LinuxDOClientId: defaults.LinuxDOClientId ?? '',
|
||||
LinuxDOClientSecret: defaults.LinuxDOClientSecret ?? '',
|
||||
LinuxDOMinimumTrustLevel: defaults.LinuxDOMinimumTrustLevel ?? '',
|
||||
WeChatAuthEnabled: defaults.WeChatAuthEnabled,
|
||||
WeChatServerAddress: defaults.WeChatServerAddress ?? '',
|
||||
WeChatServerToken: defaults.WeChatServerToken ?? '',
|
||||
WeChatAccountQRCodeImageURL: defaults.WeChatAccountQRCodeImageURL ?? '',
|
||||
})
|
||||
|
||||
const normalizeFormValues = (values: OAuthFormValues): FlatOAuthDefaults => ({
|
||||
GitHubOAuthEnabled: values.GitHubOAuthEnabled,
|
||||
GitHubClientId: values.GitHubClientId,
|
||||
GitHubClientSecret: values.GitHubClientSecret,
|
||||
'discord.enabled': values.discord.enabled,
|
||||
'discord.client_id': values.discord.client_id,
|
||||
'discord.client_secret': values.discord.client_secret,
|
||||
'oidc.enabled': values.oidc.enabled,
|
||||
'oidc.client_id': values.oidc.client_id,
|
||||
'oidc.client_secret': values.oidc.client_secret,
|
||||
'oidc.well_known': values.oidc.well_known,
|
||||
'oidc.authorization_endpoint': values.oidc.authorization_endpoint,
|
||||
'oidc.token_endpoint': values.oidc.token_endpoint,
|
||||
'oidc.user_info_endpoint': values.oidc.user_info_endpoint,
|
||||
TelegramOAuthEnabled: values.TelegramOAuthEnabled,
|
||||
TelegramBotToken: values.TelegramBotToken,
|
||||
TelegramBotName: values.TelegramBotName,
|
||||
LinuxDOOAuthEnabled: values.LinuxDOOAuthEnabled,
|
||||
LinuxDOClientId: values.LinuxDOClientId,
|
||||
LinuxDOClientSecret: values.LinuxDOClientSecret,
|
||||
LinuxDOMinimumTrustLevel: values.LinuxDOMinimumTrustLevel,
|
||||
WeChatAuthEnabled: values.WeChatAuthEnabled,
|
||||
WeChatServerAddress: values.WeChatServerAddress,
|
||||
WeChatServerToken: values.WeChatServerToken,
|
||||
WeChatAccountQRCodeImageURL: values.WeChatAccountQRCodeImageURL,
|
||||
})
|
||||
|
||||
type OAuthSectionProps = {
|
||||
defaultValues: OAuthFormValues
|
||||
defaultValues: FlatOAuthDefaults
|
||||
}
|
||||
|
||||
export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
export function OAuthSection(props: OAuthSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const [activeTab, setActiveTab] = useState('github')
|
||||
|
||||
// Normalize empty strings for optional fields (only at mount)
|
||||
const normalizedDefaults: OAuthFormValues = {
|
||||
...defaultValues,
|
||||
GitHubClientId: defaultValues.GitHubClientId ?? '',
|
||||
GitHubClientSecret: defaultValues.GitHubClientSecret ?? '',
|
||||
'discord.client_id': defaultValues['discord.client_id'] ?? '',
|
||||
'discord.client_secret': defaultValues['discord.client_secret'] ?? '',
|
||||
'oidc.client_id': defaultValues['oidc.client_id'] ?? '',
|
||||
'oidc.client_secret': defaultValues['oidc.client_secret'] ?? '',
|
||||
'oidc.well_known': defaultValues['oidc.well_known'] ?? '',
|
||||
'oidc.authorization_endpoint':
|
||||
defaultValues['oidc.authorization_endpoint'] ?? '',
|
||||
'oidc.token_endpoint': defaultValues['oidc.token_endpoint'] ?? '',
|
||||
'oidc.user_info_endpoint': defaultValues['oidc.user_info_endpoint'] ?? '',
|
||||
TelegramBotToken: defaultValues.TelegramBotToken ?? '',
|
||||
TelegramBotName: defaultValues.TelegramBotName ?? '',
|
||||
LinuxDOClientId: defaultValues.LinuxDOClientId ?? '',
|
||||
LinuxDOClientSecret: defaultValues.LinuxDOClientSecret ?? '',
|
||||
LinuxDOMinimumTrustLevel: defaultValues.LinuxDOMinimumTrustLevel ?? '',
|
||||
WeChatServerAddress: defaultValues.WeChatServerAddress ?? '',
|
||||
WeChatServerToken: defaultValues.WeChatServerToken ?? '',
|
||||
WeChatAccountQRCodeImageURL:
|
||||
defaultValues.WeChatAccountQRCodeImageURL ?? '',
|
||||
}
|
||||
const formDefaults = useMemo(
|
||||
() => buildFormDefaults(props.defaultValues),
|
||||
[props.defaultValues]
|
||||
)
|
||||
|
||||
const form = useForm<OAuthFormValues>({
|
||||
resolver: zodResolver(oauthSchema),
|
||||
defaultValues: normalizedDefaults,
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
// Get raw form values directly
|
||||
// React Hook Form treats "oidc.xxx" as nested paths, so we need to flatten
|
||||
const rawData = form.getValues() as Record<string, unknown>
|
||||
const baselineRef = useRef<FlatOAuthDefaults>(props.defaultValues)
|
||||
const baselineSerializedRef = useRef<string>(
|
||||
JSON.stringify(props.defaultValues)
|
||||
)
|
||||
|
||||
// Flatten nested oidc object back to dot notation keys
|
||||
const flattenedData: Record<string, unknown> = {}
|
||||
useEffect(() => {
|
||||
const serialized = JSON.stringify(props.defaultValues)
|
||||
if (serialized === baselineSerializedRef.current) return
|
||||
baselineRef.current = props.defaultValues
|
||||
baselineSerializedRef.current = serialized
|
||||
form.reset(buildFormDefaults(props.defaultValues))
|
||||
}, [props.defaultValues, form])
|
||||
|
||||
Object.entries(rawData).forEach(([key, value]) => {
|
||||
const onSubmit = async (values: OAuthFormValues) => {
|
||||
let finalValues = values
|
||||
|
||||
if (values.oidc.well_known && values.oidc.well_known.trim() !== '') {
|
||||
const wellKnown = values.oidc.well_known.trim()
|
||||
if (
|
||||
(key === 'oidc' || key === 'discord') &&
|
||||
typeof value === 'object' &&
|
||||
value !== null
|
||||
) {
|
||||
// React Hook Form auto-nested these fields, flatten them back
|
||||
Object.entries(value as Record<string, unknown>).forEach(
|
||||
([nestedKey, nestedValue]) => {
|
||||
flattenedData[`${key}.${nestedKey}`] = nestedValue
|
||||
}
|
||||
)
|
||||
} else {
|
||||
flattenedData[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
const finalData = flattenedData as OAuthFormValues
|
||||
|
||||
if (finalData['oidc.well_known'] && finalData['oidc.well_known'] !== '') {
|
||||
if (
|
||||
!finalData['oidc.well_known'].startsWith('http://') &&
|
||||
!finalData['oidc.well_known'].startsWith('https://')
|
||||
!wellKnown.startsWith('http://') &&
|
||||
!wellKnown.startsWith('https://')
|
||||
) {
|
||||
toast.error(t('Well-Known URL must start with http:// or https://'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.create().get(finalData['oidc.well_known'])
|
||||
const res = await axios.create().get(wellKnown)
|
||||
const authEndpoint = res.data['authorization_endpoint'] || ''
|
||||
const tokenEndpoint = res.data['token_endpoint'] || ''
|
||||
const userInfoEndpoint = res.data['userinfo_endpoint'] || ''
|
||||
|
||||
finalData['oidc.authorization_endpoint'] = authEndpoint
|
||||
finalData['oidc.token_endpoint'] = tokenEndpoint
|
||||
finalData['oidc.user_info_endpoint'] = userInfoEndpoint
|
||||
finalValues = {
|
||||
...values,
|
||||
oidc: {
|
||||
...values.oidc,
|
||||
authorization_endpoint: authEndpoint,
|
||||
token_endpoint: tokenEndpoint,
|
||||
user_info_endpoint: userInfoEndpoint,
|
||||
},
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form.setValue('oidc.authorization_endpoint' as any, authEndpoint)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form.setValue('oidc.token_endpoint' as any, tokenEndpoint)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form.setValue('oidc.user_info_endpoint' as any, userInfoEndpoint)
|
||||
form.setValue('oidc.authorization_endpoint', authEndpoint)
|
||||
form.setValue('oidc.token_endpoint', tokenEndpoint)
|
||||
form.setValue('oidc.user_info_endpoint', userInfoEndpoint)
|
||||
|
||||
toast.success(t('OIDC configuration fetched successfully'))
|
||||
} catch (err) {
|
||||
@@ -183,73 +251,30 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Find changed fields by comparing to initial values
|
||||
const updates = Object.entries(finalData).filter(
|
||||
([key, value]) =>
|
||||
value !== normalizedDefaults[key as keyof OAuthFormValues]
|
||||
)
|
||||
const normalized = normalizeFormValues(finalValues)
|
||||
const changedKeys = (
|
||||
Object.keys(normalized) as Array<keyof FlatOAuthDefaults>
|
||||
).filter((key) => normalized[key] !== baselineRef.current[key])
|
||||
|
||||
if (updates.length === 0) {
|
||||
if (changedKeys.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
// Save all changed fields
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({ key, value: value ?? '' })
|
||||
for (const key of changedKeys) {
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: normalized[key],
|
||||
})
|
||||
}
|
||||
|
||||
// Reset form dirty state after successful save
|
||||
form.reset(finalData)
|
||||
baselineRef.current = normalized
|
||||
baselineSerializedRef.current = JSON.stringify(normalized)
|
||||
form.reset(buildFormDefaults(normalized))
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
// React Hook Form auto-nests 'oidc.xxx' fields into { oidc: { xxx: value } }
|
||||
// So we need to pass the same structure when resetting
|
||||
const currentValues = form.getValues() as Record<string, unknown>
|
||||
|
||||
// Create reset values matching RHF's internal structure
|
||||
const resetValues = { ...currentValues }
|
||||
|
||||
// Update nested oidc fields
|
||||
if (resetValues.oidc && typeof resetValues.oidc === 'object') {
|
||||
Object.keys(resetValues.oidc as Record<string, unknown>).forEach(
|
||||
(key) => {
|
||||
const flatKey = `oidc.${key}` as keyof typeof normalizedDefaults
|
||||
if (flatKey in normalizedDefaults) {
|
||||
;(resetValues.oidc as Record<string, unknown>)[key] =
|
||||
normalizedDefaults[flatKey]
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Update nested discord fields
|
||||
if (resetValues.discord && typeof resetValues.discord === 'object') {
|
||||
Object.keys(resetValues.discord as Record<string, unknown>).forEach(
|
||||
(key) => {
|
||||
const flatKey = `discord.${key}` as keyof typeof normalizedDefaults
|
||||
if (flatKey in normalizedDefaults) {
|
||||
;(resetValues.discord as Record<string, unknown>)[key] =
|
||||
normalizedDefaults[flatKey]
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Update top-level fields
|
||||
Object.keys(resetValues).forEach((key) => {
|
||||
if (key !== 'oidc' && key in normalizedDefaults) {
|
||||
resetValues[key] =
|
||||
normalizedDefaults[key as keyof typeof normalizedDefaults]
|
||||
}
|
||||
})
|
||||
|
||||
form.reset(resetValues, {
|
||||
keepDirty: false,
|
||||
keepDirtyValues: false,
|
||||
keepErrors: false,
|
||||
})
|
||||
form.reset(buildFormDefaults(baselineRef.current))
|
||||
toast.success(t('Form reset to saved values'))
|
||||
}
|
||||
|
||||
@@ -310,7 +335,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
<Input
|
||||
placeholder={t('Your GitHub OAuth Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -329,7 +360,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
type='password'
|
||||
placeholder={t('Your GitHub OAuth Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -362,8 +399,7 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
name={'discord.client_id' as any}
|
||||
name='discord.client_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client ID')}</FormLabel>
|
||||
@@ -371,7 +407,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
<Input
|
||||
placeholder={t('Your Discord OAuth Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -390,7 +432,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
type='password'
|
||||
placeholder={t('Your Discord OAuth Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -423,8 +471,7 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
name={'oidc.client_id' as any}
|
||||
name='oidc.client_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client ID')}</FormLabel>
|
||||
@@ -432,7 +479,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
<Input
|
||||
placeholder={t('OIDC Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -451,7 +504,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
type='password'
|
||||
placeholder={t('OIDC Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -471,7 +530,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
'https://provider.com/.well-known/openid-configuration'
|
||||
)}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -494,7 +559,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
<Input
|
||||
placeholder={t('Override auto-discovered endpoint')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -512,7 +583,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
<Input
|
||||
placeholder={t('Override auto-discovered endpoint')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -532,7 +609,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
<Input
|
||||
placeholder={t('Override auto-discovered endpoint')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -577,7 +660,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
type='password'
|
||||
placeholder={t('Your Telegram Bot Token')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -595,7 +684,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
<Input
|
||||
placeholder={t('Your Bot Name')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -636,7 +731,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
<Input
|
||||
placeholder={t('LinuxDO Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -655,7 +756,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
type='password'
|
||||
placeholder={t('LinuxDO Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -670,7 +777,17 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t('Minimum Trust Level')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='0' autoComplete='off' {...field} />
|
||||
<Input
|
||||
placeholder='0'
|
||||
autoComplete='off'
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Minimum LinuxDO trust level required')}
|
||||
@@ -713,7 +830,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
<Input
|
||||
placeholder={t('https://wechat-server.example.com')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -732,7 +855,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
type='password'
|
||||
placeholder={t('Server Token')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -750,7 +879,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
<Input
|
||||
placeholder={t('https://example.com/qr-code.png')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
+126
-93
@@ -16,11 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -48,116 +49,139 @@ import {
|
||||
} from '../components/settings-form-layout'
|
||||
import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
type AttachmentPreference = '' | 'platform' | 'cross-platform'
|
||||
type AttachmentSelectValue = 'none' | 'platform' | 'cross-platform'
|
||||
|
||||
/**
|
||||
* Use a nested object so the dotted FormField `name` props line up with
|
||||
* react-hook-form's path semantics. Flat keys with dots cause the form state
|
||||
* to silently diverge from what zod validates on submit.
|
||||
*/
|
||||
const passkeySchema = z.object({
|
||||
'passkey.enabled': z.boolean(),
|
||||
'passkey.rp_display_name': z.string(),
|
||||
'passkey.rp_id': z.string(),
|
||||
'passkey.origins': z.string(),
|
||||
'passkey.allow_insecure_origin': z.boolean(),
|
||||
'passkey.user_verification': z.enum(['required', 'preferred', 'discouraged']),
|
||||
'passkey.attachment_preference': z.enum([
|
||||
'none',
|
||||
'platform',
|
||||
'cross-platform',
|
||||
]),
|
||||
passkey: z.object({
|
||||
enabled: z.boolean(),
|
||||
rp_display_name: z.string(),
|
||||
rp_id: z.string(),
|
||||
origins: z.string(),
|
||||
allow_insecure_origin: z.boolean(),
|
||||
user_verification: z.enum(['required', 'preferred', 'discouraged']),
|
||||
attachment_preference: z.enum(['none', 'platform', 'cross-platform']),
|
||||
}),
|
||||
})
|
||||
|
||||
type PasskeyFormValues = z.infer<typeof passkeySchema>
|
||||
type PasskeyFormInput = z.input<typeof passkeySchema>
|
||||
type PasskeyFormValues = z.output<typeof passkeySchema>
|
||||
|
||||
interface PasskeySectionProps {
|
||||
defaultValues: PasskeyFormValues
|
||||
type FlatPasskeyDefaults = {
|
||||
'passkey.enabled': boolean
|
||||
'passkey.rp_display_name': string
|
||||
'passkey.rp_id': string
|
||||
'passkey.origins': string
|
||||
'passkey.allow_insecure_origin': boolean
|
||||
'passkey.user_verification': 'required' | 'preferred' | 'discouraged'
|
||||
'passkey.attachment_preference': AttachmentPreference
|
||||
}
|
||||
|
||||
export function PasskeySection({ defaultValues }: PasskeySectionProps) {
|
||||
const toAttachmentSelectValue = (
|
||||
value: AttachmentPreference
|
||||
): AttachmentSelectValue => (value === '' ? 'none' : value)
|
||||
|
||||
const fromAttachmentSelectValue = (
|
||||
value: AttachmentSelectValue
|
||||
): AttachmentPreference => (value === 'none' ? '' : value)
|
||||
|
||||
const buildFormDefaults = (
|
||||
defaults: FlatPasskeyDefaults
|
||||
): PasskeyFormInput => ({
|
||||
passkey: {
|
||||
enabled: defaults['passkey.enabled'],
|
||||
rp_display_name: defaults['passkey.rp_display_name'] ?? '',
|
||||
rp_id: defaults['passkey.rp_id'] ?? '',
|
||||
origins: (defaults['passkey.origins'] ?? '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
allow_insecure_origin: defaults['passkey.allow_insecure_origin'],
|
||||
user_verification: defaults['passkey.user_verification'],
|
||||
attachment_preference: toAttachmentSelectValue(
|
||||
defaults['passkey.attachment_preference']
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
const normalizeFormValues = (
|
||||
values: PasskeyFormValues
|
||||
): FlatPasskeyDefaults => ({
|
||||
'passkey.enabled': values.passkey.enabled,
|
||||
'passkey.rp_display_name': values.passkey.rp_display_name,
|
||||
'passkey.rp_id': values.passkey.rp_id,
|
||||
'passkey.origins': values.passkey.origins
|
||||
.split('\n')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
.join(','),
|
||||
'passkey.allow_insecure_origin': values.passkey.allow_insecure_origin,
|
||||
'passkey.user_verification': values.passkey.user_verification,
|
||||
'passkey.attachment_preference': fromAttachmentSelectValue(
|
||||
values.passkey.attachment_preference
|
||||
),
|
||||
})
|
||||
|
||||
interface PasskeySectionProps {
|
||||
defaultValues: FlatPasskeyDefaults
|
||||
}
|
||||
|
||||
export function PasskeySection(props: PasskeySectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const formDefaults = useMemo<PasskeyFormValues>(
|
||||
() => ({
|
||||
...defaultValues,
|
||||
'passkey.origins': (defaultValues['passkey.origins'] as string)
|
||||
.split(',')
|
||||
.map((origin: string) => origin.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
'passkey.attachment_preference':
|
||||
(defaultValues['passkey.attachment_preference'] as string) === ''
|
||||
? 'none'
|
||||
: (defaultValues['passkey.attachment_preference'] as
|
||||
| 'platform'
|
||||
| 'cross-platform'),
|
||||
}),
|
||||
[defaultValues]
|
||||
const formDefaults = useMemo(
|
||||
() => buildFormDefaults(props.defaultValues),
|
||||
[props.defaultValues]
|
||||
)
|
||||
|
||||
const form = useForm<PasskeyFormValues>({
|
||||
const form = useForm<PasskeyFormInput, unknown, PasskeyFormValues>({
|
||||
resolver: zodResolver(passkeySchema),
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
useResetForm(form, formDefaults)
|
||||
const baselineRef = useRef<FlatPasskeyDefaults>(props.defaultValues)
|
||||
const baselineSerializedRef = useRef<string>(
|
||||
JSON.stringify(props.defaultValues)
|
||||
)
|
||||
|
||||
const onSubmit = async () => {
|
||||
const rawData = form.getValues() as Record<string, unknown>
|
||||
const flattenedEntries: Array<
|
||||
[keyof PasskeyFormValues, PasskeyFormValues[keyof PasskeyFormValues]]
|
||||
> = []
|
||||
useEffect(() => {
|
||||
const serialized = JSON.stringify(props.defaultValues)
|
||||
if (serialized === baselineSerializedRef.current) return
|
||||
baselineRef.current = props.defaultValues
|
||||
baselineSerializedRef.current = serialized
|
||||
form.reset(buildFormDefaults(props.defaultValues))
|
||||
}, [props.defaultValues, form])
|
||||
|
||||
Object.entries(rawData).forEach(([key, value]) => {
|
||||
if (key === 'passkey' && value && typeof value === 'object') {
|
||||
Object.entries(value as Record<string, unknown>).forEach(
|
||||
([nestedKey, nestedValue]) => {
|
||||
flattenedEntries.push([
|
||||
`passkey.${nestedKey}` as keyof PasskeyFormValues,
|
||||
nestedValue as PasskeyFormValues[keyof PasskeyFormValues],
|
||||
])
|
||||
}
|
||||
)
|
||||
} else {
|
||||
flattenedEntries.push([
|
||||
key as keyof PasskeyFormValues,
|
||||
value as PasskeyFormValues[keyof PasskeyFormValues],
|
||||
])
|
||||
}
|
||||
})
|
||||
const onSubmit = async (values: PasskeyFormValues) => {
|
||||
const normalized = normalizeFormValues(values)
|
||||
const changedKeys = (
|
||||
Object.keys(normalized) as Array<keyof FlatPasskeyDefaults>
|
||||
).filter((key) => normalized[key] !== baselineRef.current[key])
|
||||
|
||||
const data = Object.fromEntries(flattenedEntries) as PasskeyFormValues
|
||||
const updates: Array<{ key: string; value: string | boolean }> = []
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (key === 'passkey.origins') {
|
||||
const processed = (value as string)
|
||||
.split('\n')
|
||||
.map((origin: string) => origin.trim())
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
const currentDefault = defaultValues['passkey.origins'] as string
|
||||
if (processed !== currentDefault) {
|
||||
updates.push({ key, value: processed })
|
||||
}
|
||||
} else if (key === 'passkey.attachment_preference') {
|
||||
const attachmentPreference =
|
||||
value as PasskeyFormValues['passkey.attachment_preference']
|
||||
const incoming =
|
||||
attachmentPreference === 'none' ? '' : attachmentPreference
|
||||
const currentDefault =
|
||||
defaultValues['passkey.attachment_preference'] === 'none'
|
||||
? ''
|
||||
: defaultValues['passkey.attachment_preference']
|
||||
if (incoming !== currentDefault) {
|
||||
updates.push({ key, value: incoming })
|
||||
}
|
||||
} else if (value !== defaultValues[key as keyof PasskeyFormValues]) {
|
||||
updates.push({ key, value })
|
||||
}
|
||||
})
|
||||
|
||||
for (const update of updates) {
|
||||
await updateOption.mutateAsync(update)
|
||||
if (changedKeys.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of changedKeys) {
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: normalized[key],
|
||||
})
|
||||
}
|
||||
|
||||
baselineRef.current = normalized
|
||||
baselineSerializedRef.current = JSON.stringify(normalized)
|
||||
form.reset(buildFormDefaults(normalized))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -200,8 +224,11 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) {
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. New API Console')}
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -223,8 +250,11 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) {
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. example.com')}
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -356,8 +386,11 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) {
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder={t('https://example.com')}
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
@@ -93,14 +93,8 @@ const AUTH_SECTIONS = [
|
||||
| 'required'
|
||||
| 'preferred'
|
||||
| 'discouraged',
|
||||
'passkey.attachment_preference': (settings[
|
||||
'passkey.attachment_preference'
|
||||
] === ''
|
||||
? 'none'
|
||||
: settings['passkey.attachment_preference']) as
|
||||
| 'none'
|
||||
| 'platform'
|
||||
| 'cross-platform',
|
||||
'passkey.attachment_preference':
|
||||
settings['passkey.attachment_preference'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { safeNumberFieldProps } from '../utils/numeric-field'
|
||||
|
||||
const dataDashboardSchema = z.object({
|
||||
DataExportEnabled: z.boolean(),
|
||||
@@ -132,9 +133,8 @@ export function DashboardSection({ defaultValues }: DashboardSectionProps) {
|
||||
min={1}
|
||||
max={1440}
|
||||
step={1}
|
||||
{...safeNumberFieldProps(field)}
|
||||
disabled={!isEnabled}
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
@@ -51,6 +51,7 @@ import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useSettingsForm } from '../hooks/use-settings-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { safeNumberFieldProps } from '../utils/numeric-field'
|
||||
|
||||
const createPricingSchema = (t: (key: string) => string) =>
|
||||
z
|
||||
@@ -243,11 +244,7 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
+2
-5
@@ -40,6 +40,7 @@ import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { safeNumberFieldProps } from '../utils/numeric-field'
|
||||
|
||||
const behaviorSchema = z.object({
|
||||
RetryTimes: z.coerce.number().min(0).max(10),
|
||||
@@ -96,11 +97,7 @@ export function SystemBehaviorSection({
|
||||
type='number'
|
||||
min='0'
|
||||
max='10'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
+3
-4
@@ -49,6 +49,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { CreemProduct } from '@/features/wallet/types'
|
||||
import { safeNumberFieldProps } from '../utils/numeric-field'
|
||||
|
||||
const creemProductDialogSchema = z.object({
|
||||
name: z.string().min(1, 'Product name is required'),
|
||||
@@ -216,8 +217,7 @@ export function CreemProductDialog({
|
||||
step='0.01'
|
||||
min={0.01}
|
||||
placeholder='10.00'
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -237,8 +237,7 @@ export function CreemProductDialog({
|
||||
type='number'
|
||||
min={1}
|
||||
placeholder={t('e.g., 500000')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
+2
-12
@@ -44,6 +44,7 @@ import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { safeNumberFieldProps } from '../utils/numeric-field'
|
||||
|
||||
const numericString = z.string().refine((value) => {
|
||||
const trimmed = value.trim()
|
||||
@@ -289,18 +290,7 @@ export function MonitoringSettingsSection({
|
||||
type='number'
|
||||
min={1}
|
||||
step={1}
|
||||
value={
|
||||
typeof field.value === 'number' &&
|
||||
Number.isFinite(field.value)
|
||||
? field.value
|
||||
: ''
|
||||
}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
+5
-16
@@ -55,6 +55,7 @@ import {
|
||||
import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { safeNumberFieldProps } from '../utils/numeric-field'
|
||||
import { AmountDiscountVisualEditor } from './amount-discount-visual-editor'
|
||||
import { AmountOptionsVisualEditor } from './amount-options-visual-editor'
|
||||
import { CreemProductsVisualEditor } from './creem-products-visual-editor'
|
||||
@@ -876,10 +877,7 @@ export function PaymentSettingsSection({
|
||||
type='number'
|
||||
step='0.01'
|
||||
min={0}
|
||||
value={(field.value ?? 0) as number}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -903,10 +901,7 @@ export function PaymentSettingsSection({
|
||||
type='number'
|
||||
step='0.01'
|
||||
min={0}
|
||||
value={(field.value ?? 0) as number}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -1314,10 +1309,7 @@ export function PaymentSettingsSection({
|
||||
type='number'
|
||||
step='0.01'
|
||||
min={0}
|
||||
value={(field.value ?? 0) as number}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -1339,10 +1331,7 @@ export function PaymentSettingsSection({
|
||||
type='number'
|
||||
step='0.01'
|
||||
min={0}
|
||||
value={(field.value ?? 0) as number}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
+175
-47
@@ -16,7 +16,7 @@ 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -66,31 +67,102 @@ import {
|
||||
} from '../components/settings-form-layout'
|
||||
import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { safeNumberFieldProps } from '../utils/numeric-field'
|
||||
|
||||
/**
|
||||
* IMPORTANT: react-hook-form 7 interprets dotted `name` strings as nested
|
||||
* paths. If we declare the schema with literal flat keys like
|
||||
* `'performance_setting.disk_cache_enabled'`, the form state diverges from
|
||||
* what zod validates and saves silently turn into no-ops. So we model the
|
||||
* form internally with proper nested objects and only flatten back to the
|
||||
* server-side key format right before persisting.
|
||||
*/
|
||||
const perfSchema = z.object({
|
||||
'performance_setting.disk_cache_enabled': z.boolean(),
|
||||
'performance_setting.disk_cache_threshold_mb': z.coerce.number().min(1),
|
||||
'performance_setting.disk_cache_max_size_mb': z.coerce.number().min(100),
|
||||
'performance_setting.disk_cache_path': z.string().optional(),
|
||||
'performance_setting.monitor_enabled': z.boolean(),
|
||||
'performance_setting.monitor_cpu_threshold': z.coerce.number().min(0),
|
||||
'performance_setting.monitor_memory_threshold': z.coerce
|
||||
.number()
|
||||
.min(0)
|
||||
.max(100),
|
||||
'performance_setting.monitor_disk_threshold': z.coerce
|
||||
.number()
|
||||
.min(0)
|
||||
.max(100),
|
||||
'perf_metrics_setting.enabled': z.boolean(),
|
||||
'perf_metrics_setting.flush_interval': z.coerce.number().min(1),
|
||||
'perf_metrics_setting.bucket_time': z.enum(['minute', '5min', 'hour']),
|
||||
'perf_metrics_setting.retention_days': z.coerce.number().min(0),
|
||||
performance_setting: z.object({
|
||||
disk_cache_enabled: z.boolean(),
|
||||
disk_cache_threshold_mb: z.coerce.number().min(1),
|
||||
disk_cache_max_size_mb: z.coerce.number().min(100),
|
||||
disk_cache_path: z.string(),
|
||||
monitor_enabled: z.boolean(),
|
||||
monitor_cpu_threshold: z.coerce.number().min(0),
|
||||
monitor_memory_threshold: z.coerce.number().min(0).max(100),
|
||||
monitor_disk_threshold: z.coerce.number().min(0).max(100),
|
||||
}),
|
||||
perf_metrics_setting: z.object({
|
||||
enabled: z.boolean(),
|
||||
flush_interval: z.coerce.number().min(1),
|
||||
bucket_time: z.enum(['minute', '5min', 'hour']),
|
||||
retention_days: z.coerce.number().min(0),
|
||||
}),
|
||||
})
|
||||
|
||||
type PerfFormValues = z.infer<typeof perfSchema>
|
||||
type PerfFormInput = z.input<typeof perfSchema>
|
||||
type PerfFormValues = z.output<typeof perfSchema>
|
||||
|
||||
type FlatPerfDefaults = {
|
||||
'performance_setting.disk_cache_enabled': boolean
|
||||
'performance_setting.disk_cache_threshold_mb': number
|
||||
'performance_setting.disk_cache_max_size_mb': number
|
||||
'performance_setting.disk_cache_path': string
|
||||
'performance_setting.monitor_enabled': boolean
|
||||
'performance_setting.monitor_cpu_threshold': number
|
||||
'performance_setting.monitor_memory_threshold': number
|
||||
'performance_setting.monitor_disk_threshold': number
|
||||
'perf_metrics_setting.enabled': boolean
|
||||
'perf_metrics_setting.flush_interval': number
|
||||
'perf_metrics_setting.bucket_time': 'minute' | '5min' | 'hour'
|
||||
'perf_metrics_setting.retention_days': number
|
||||
}
|
||||
|
||||
const buildFormDefaults = (defaults: FlatPerfDefaults): PerfFormInput => ({
|
||||
performance_setting: {
|
||||
disk_cache_enabled: defaults['performance_setting.disk_cache_enabled'],
|
||||
disk_cache_threshold_mb:
|
||||
defaults['performance_setting.disk_cache_threshold_mb'],
|
||||
disk_cache_max_size_mb:
|
||||
defaults['performance_setting.disk_cache_max_size_mb'],
|
||||
disk_cache_path: defaults['performance_setting.disk_cache_path'] ?? '',
|
||||
monitor_enabled: defaults['performance_setting.monitor_enabled'],
|
||||
monitor_cpu_threshold:
|
||||
defaults['performance_setting.monitor_cpu_threshold'],
|
||||
monitor_memory_threshold:
|
||||
defaults['performance_setting.monitor_memory_threshold'],
|
||||
monitor_disk_threshold:
|
||||
defaults['performance_setting.monitor_disk_threshold'],
|
||||
},
|
||||
perf_metrics_setting: {
|
||||
enabled: defaults['perf_metrics_setting.enabled'],
|
||||
flush_interval: defaults['perf_metrics_setting.flush_interval'],
|
||||
bucket_time: defaults['perf_metrics_setting.bucket_time'],
|
||||
retention_days: defaults['perf_metrics_setting.retention_days'],
|
||||
},
|
||||
})
|
||||
|
||||
const normalizeFormValues = (values: PerfFormValues): FlatPerfDefaults => ({
|
||||
'performance_setting.disk_cache_enabled':
|
||||
values.performance_setting.disk_cache_enabled,
|
||||
'performance_setting.disk_cache_threshold_mb':
|
||||
values.performance_setting.disk_cache_threshold_mb,
|
||||
'performance_setting.disk_cache_max_size_mb':
|
||||
values.performance_setting.disk_cache_max_size_mb,
|
||||
'performance_setting.disk_cache_path':
|
||||
values.performance_setting.disk_cache_path ?? '',
|
||||
'performance_setting.monitor_enabled':
|
||||
values.performance_setting.monitor_enabled,
|
||||
'performance_setting.monitor_cpu_threshold':
|
||||
values.performance_setting.monitor_cpu_threshold,
|
||||
'performance_setting.monitor_memory_threshold':
|
||||
values.performance_setting.monitor_memory_threshold,
|
||||
'performance_setting.monitor_disk_threshold':
|
||||
values.performance_setting.monitor_disk_threshold,
|
||||
'perf_metrics_setting.enabled': values.perf_metrics_setting.enabled,
|
||||
'perf_metrics_setting.flush_interval':
|
||||
values.perf_metrics_setting.flush_interval,
|
||||
'perf_metrics_setting.bucket_time': values.perf_metrics_setting.bucket_time,
|
||||
'perf_metrics_setting.retention_days':
|
||||
values.perf_metrics_setting.retention_days,
|
||||
})
|
||||
|
||||
function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (!bytes || isNaN(bytes)) return '0 Bytes'
|
||||
@@ -104,7 +176,7 @@ function formatBytes(bytes: number, decimals = 2): string {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
defaultValues: PerfFormValues
|
||||
defaultValues: FlatPerfDefaults
|
||||
}
|
||||
|
||||
type LogInfo = {
|
||||
@@ -158,14 +230,28 @@ export function PerformanceSection(props: Props) {
|
||||
const [logCleanupValue, setLogCleanupValue] = useState(10)
|
||||
const [logCleanupLoading, setLogCleanupLoading] = useState(false)
|
||||
|
||||
const form = useForm<PerfFormValues>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(perfSchema) as any,
|
||||
defaultValues: props.defaultValues,
|
||||
const formDefaults = useMemo(
|
||||
() => buildFormDefaults(props.defaultValues),
|
||||
[props.defaultValues]
|
||||
)
|
||||
|
||||
const form = useForm<PerfFormInput, unknown, PerfFormValues>({
|
||||
resolver: zodResolver(perfSchema),
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
useResetForm(form as any, props.defaultValues)
|
||||
const baselineRef = useRef<FlatPerfDefaults>(props.defaultValues)
|
||||
const baselineSerializedRef = useRef<string>(
|
||||
JSON.stringify(props.defaultValues)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const serialized = JSON.stringify(props.defaultValues)
|
||||
if (serialized === baselineSerializedRef.current) return
|
||||
baselineRef.current = props.defaultValues
|
||||
baselineSerializedRef.current = serialized
|
||||
form.reset(buildFormDefaults(props.defaultValues))
|
||||
}, [props.defaultValues, form])
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
@@ -190,23 +276,27 @@ export function PerformanceSection(props: Props) {
|
||||
fetchLogInfo()
|
||||
}, [fetchStats, fetchLogInfo])
|
||||
|
||||
const onSubmit = async (data: PerfFormValues) => {
|
||||
const entries = Object.entries(data) as [string, unknown][]
|
||||
const updates = entries.filter(
|
||||
([key, value]) =>
|
||||
value !== (props.defaultValues[key as keyof PerfFormValues] as unknown)
|
||||
)
|
||||
if (updates.length === 0) {
|
||||
const onSubmit = async (values: PerfFormValues) => {
|
||||
const normalized = normalizeFormValues(values)
|
||||
const changedKeys = (
|
||||
Object.keys(normalized) as Array<keyof FlatPerfDefaults>
|
||||
).filter((key) => normalized[key] !== baselineRef.current[key])
|
||||
|
||||
if (changedKeys.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
for (const [key, value] of updates) {
|
||||
|
||||
for (const key of changedKeys) {
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: value as string | number | boolean,
|
||||
value: normalized[key],
|
||||
})
|
||||
}
|
||||
toast.success(t('Saved successfully'))
|
||||
|
||||
baselineRef.current = normalized
|
||||
baselineSerializedRef.current = JSON.stringify(normalized)
|
||||
form.reset(buildFormDefaults(normalized))
|
||||
fetchStats()
|
||||
}
|
||||
|
||||
@@ -278,9 +368,13 @@ export function PerformanceSection(props: Props) {
|
||||
const diskEnabled = form.watch('performance_setting.disk_cache_enabled')
|
||||
const monitorEnabled = form.watch('performance_setting.monitor_enabled')
|
||||
const perfMetricsEnabled = form.watch('perf_metrics_setting.enabled')
|
||||
const maxCacheSizeMb = form.watch(
|
||||
const maxCacheSizeRaw = form.watch(
|
||||
'performance_setting.disk_cache_max_size_mb'
|
||||
)
|
||||
const maxCacheSizeMb =
|
||||
typeof maxCacheSizeRaw === 'number'
|
||||
? maxCacheSizeRaw
|
||||
: Number(maxCacheSizeRaw) || 0
|
||||
|
||||
const lowDiskSpace =
|
||||
diskEnabled &&
|
||||
@@ -342,11 +436,18 @@ export function PerformanceSection(props: Props) {
|
||||
<FormItem>
|
||||
<FormLabel>{t('Disk Cache Threshold (MB)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' {...field} disabled={!diskEnabled} />
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
step={1}
|
||||
{...safeNumberFieldProps(field)}
|
||||
disabled={!diskEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Use disk cache when request body exceeds this size')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -357,7 +458,13 @@ export function PerformanceSection(props: Props) {
|
||||
<FormItem>
|
||||
<FormLabel>{t('Max Disk Cache Size (MB)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' {...field} disabled={!diskEnabled} />
|
||||
<Input
|
||||
type='number'
|
||||
min={100}
|
||||
step={1}
|
||||
{...safeNumberFieldProps(field)}
|
||||
disabled={!diskEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
{stats?.disk_space_info &&
|
||||
stats.disk_space_info.total > 0 && (
|
||||
@@ -368,6 +475,7 @@ export function PerformanceSection(props: Props) {
|
||||
})}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -393,11 +501,15 @@ export function PerformanceSection(props: Props) {
|
||||
placeholder={t(
|
||||
'Leave empty to use system temp directory'
|
||||
)}
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
disabled={!diskEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -444,10 +556,13 @@ export function PerformanceSection(props: Props) {
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
step={1}
|
||||
{...safeNumberFieldProps(field)}
|
||||
disabled={!monitorEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -460,10 +575,14 @@ export function PerformanceSection(props: Props) {
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
{...safeNumberFieldProps(field)}
|
||||
disabled={!monitorEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -476,10 +595,14 @@ export function PerformanceSection(props: Props) {
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
{...safeNumberFieldProps(field)}
|
||||
disabled={!monitorEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -526,10 +649,12 @@ export function PerformanceSection(props: Props) {
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
{...field}
|
||||
step={1}
|
||||
{...safeNumberFieldProps(field)}
|
||||
disabled={!perfMetricsEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -562,6 +687,7 @@ export function PerformanceSection(props: Props) {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -575,13 +701,15 @@ export function PerformanceSection(props: Props) {
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
{...field}
|
||||
step={1}
|
||||
{...safeNumberFieldProps(field)}
|
||||
disabled={!perfMetricsEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('0 means data is kept permanently')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -16,10 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
@@ -37,48 +40,97 @@ import {
|
||||
} from '../components/settings-form-layout'
|
||||
import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { safeNumberFieldProps } from '../utils/numeric-field'
|
||||
|
||||
const XAI_VIOLATION_FEE_DOC_URL =
|
||||
'https://docs.x.ai/docs/models#usage-guidelines-violation-fee'
|
||||
|
||||
/**
|
||||
* The schema uses a nested object so the dotted FormField `name` props line
|
||||
* up with react-hook-form's path semantics. Using flat keys like
|
||||
* `'grok.violation_deduction_enabled'` causes RHF to silently maintain two
|
||||
* parallel value trees and saves never see the user input.
|
||||
*/
|
||||
const grokSchema = z.object({
|
||||
'grok.violation_deduction_enabled': z.boolean(),
|
||||
'grok.violation_deduction_amount': z.coerce.number().min(0),
|
||||
grok: z.object({
|
||||
violation_deduction_enabled: z.boolean(),
|
||||
violation_deduction_amount: z.coerce.number().min(0),
|
||||
}),
|
||||
})
|
||||
|
||||
type GrokFormValues = z.infer<typeof grokSchema>
|
||||
type GrokFormInput = z.input<typeof grokSchema>
|
||||
type GrokFormValues = z.output<typeof grokSchema>
|
||||
|
||||
type FlatGrokDefaults = {
|
||||
'grok.violation_deduction_enabled': boolean
|
||||
'grok.violation_deduction_amount': number
|
||||
}
|
||||
|
||||
const buildFormDefaults = (defaults: FlatGrokDefaults): GrokFormInput => ({
|
||||
grok: {
|
||||
violation_deduction_enabled: defaults['grok.violation_deduction_enabled'],
|
||||
violation_deduction_amount: defaults['grok.violation_deduction_amount'],
|
||||
},
|
||||
})
|
||||
|
||||
const normalizeFormValues = (values: GrokFormValues): FlatGrokDefaults => ({
|
||||
'grok.violation_deduction_enabled': values.grok.violation_deduction_enabled,
|
||||
'grok.violation_deduction_amount': values.grok.violation_deduction_amount,
|
||||
})
|
||||
|
||||
interface Props {
|
||||
defaultValues: GrokFormValues
|
||||
defaultValues: FlatGrokDefaults
|
||||
}
|
||||
|
||||
export function GrokSettingsCard(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const form = useForm<GrokFormValues>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(grokSchema) as any,
|
||||
defaultValues: props.defaultValues,
|
||||
const formDefaults = useMemo(
|
||||
() => buildFormDefaults(props.defaultValues),
|
||||
[props.defaultValues]
|
||||
)
|
||||
|
||||
const form = useForm<GrokFormInput, unknown, GrokFormValues>({
|
||||
resolver: zodResolver(grokSchema),
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
useResetForm(form as any, props.defaultValues)
|
||||
const baselineRef = useRef<FlatGrokDefaults>(props.defaultValues)
|
||||
const baselineSerializedRef = useRef<string>(
|
||||
JSON.stringify(props.defaultValues)
|
||||
)
|
||||
|
||||
const onSubmit = async (data: GrokFormValues) => {
|
||||
const entries = Object.entries(data) as [string, unknown][]
|
||||
const updates = entries.filter(
|
||||
([key, value]) =>
|
||||
value !== (props.defaultValues[key as keyof GrokFormValues] as unknown)
|
||||
)
|
||||
for (const [key, value] of updates) {
|
||||
useEffect(() => {
|
||||
const serialized = JSON.stringify(props.defaultValues)
|
||||
if (serialized === baselineSerializedRef.current) return
|
||||
baselineRef.current = props.defaultValues
|
||||
baselineSerializedRef.current = serialized
|
||||
form.reset(buildFormDefaults(props.defaultValues))
|
||||
}, [props.defaultValues, form])
|
||||
|
||||
const onSubmit = async (values: GrokFormValues) => {
|
||||
const normalized = normalizeFormValues(values)
|
||||
const changedKeys = (
|
||||
Object.keys(normalized) as Array<keyof FlatGrokDefaults>
|
||||
).filter((key) => normalized[key] !== baselineRef.current[key])
|
||||
|
||||
if (changedKeys.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of changedKeys) {
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: value as string | number | boolean,
|
||||
value: normalized[key],
|
||||
})
|
||||
}
|
||||
|
||||
baselineRef.current = normalized
|
||||
baselineSerializedRef.current = JSON.stringify(normalized)
|
||||
form.reset(buildFormDefaults(normalized))
|
||||
}
|
||||
|
||||
const enabled = form.watch('grok.violation_deduction_enabled')
|
||||
@@ -133,7 +185,7 @@ export function GrokSettingsCard(props: Props) {
|
||||
type='number'
|
||||
step={0.01}
|
||||
min={0}
|
||||
{...field}
|
||||
{...safeNumberFieldProps(field)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -142,6 +194,7 @@ export function GrokSettingsCard(props: Props) {
|
||||
'Base amount. Actual deduction = base amount × system group rate.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type {
|
||||
ControllerRenderProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
} from 'react-hook-form'
|
||||
|
||||
/**
|
||||
* Props produced by {@link safeNumberFieldProps} for a native
|
||||
* `<input type="number">`. They are intentionally narrow so consumers can
|
||||
* spread them onto our shared `Input` component without leaking the
|
||||
* react-hook-form internals (e.g. `disabled`) that need overriding per call.
|
||||
*/
|
||||
export type SafeNumberFieldProps = {
|
||||
value: number | ''
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
onBlur: () => void
|
||||
name: string
|
||||
ref: (instance: HTMLInputElement | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter for binding a react-hook-form numeric field to a native
|
||||
* `<input type="number">` without ever putting `NaN` into form state.
|
||||
*
|
||||
* Why this exists:
|
||||
* - `<input type="number">` reports `valueAsNumber === NaN` whenever the field
|
||||
* is empty or holds an in-progress non-numeric token (e.g. just a minus
|
||||
* sign or a trailing dot). Forwarding `NaN` to `field.onChange` makes Zod
|
||||
* numeric validators (`z.number().min(...)`, `z.coerce.number()`, etc.)
|
||||
* fail at submit time, so `form.handleSubmit` silently refuses to call
|
||||
* `onSubmit` — the save button appears frozen with no toast and no error.
|
||||
* - The legacy Semi `InputNumber` avoids this by snapping the input back to
|
||||
* the previous valid number. We replicate that behaviour by ignoring `NaN`
|
||||
* updates: React's controlled-input reconciliation will restore the last
|
||||
* valid value to the DOM on the next render.
|
||||
*
|
||||
* Display:
|
||||
* - When the underlying state is not a finite number, the prop returns `''`
|
||||
* so the input visibly renders empty instead of literal "NaN".
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <FormField
|
||||
* control={form.control}
|
||||
* name='performance_setting.monitor_cpu_threshold'
|
||||
* render={({ field }) => (
|
||||
* <Input type='number' min={0} {...safeNumberFieldProps(field)} />
|
||||
* )}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function safeNumberFieldProps<
|
||||
TFieldValues extends FieldValues,
|
||||
TName extends FieldPath<TFieldValues>,
|
||||
>(field: ControllerRenderProps<TFieldValues, TName>): SafeNumberFieldProps {
|
||||
const raw = field.value as unknown
|
||||
const display: number | '' =
|
||||
typeof raw === 'number' && Number.isFinite(raw) ? raw : ''
|
||||
|
||||
return {
|
||||
value: display,
|
||||
onChange: (event) => {
|
||||
const next = event.target.valueAsNumber
|
||||
if (Number.isFinite(next)) {
|
||||
;(field.onChange as (value: number) => void)(next)
|
||||
}
|
||||
},
|
||||
onBlur: field.onBlur,
|
||||
name: field.name,
|
||||
ref: field.ref,
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export function createDurationColumn<T>(config: {
|
||||
variant={variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className={cn('font-mono', durationBgMap[variant])}
|
||||
className={cn('rounded-md font-mono', durationBgMap[variant])}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -183,6 +183,7 @@ export function createChannelColumn<T>(config: {
|
||||
autoColor={String(channelId)}
|
||||
copyText={String(channelId)}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='font-mono'
|
||||
/>
|
||||
)
|
||||
|
||||
+34
-15
@@ -90,6 +90,12 @@ function getGroupRatioText(other: LogOtherData | null): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function splitQuotaDisplay(value: string): { prefix: string; amount: string } {
|
||||
const match = value.match(/^([^0-9+\-.,\s]+)(.+)$/)
|
||||
if (!match) return { prefix: '', amount: value }
|
||||
return { prefix: match[1], amount: match[2] }
|
||||
}
|
||||
|
||||
function buildDetailSegments(
|
||||
log: UsageLog,
|
||||
other: LogOtherData | null,
|
||||
@@ -277,6 +283,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
variant={config.color as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='!text-xs [&_span]:!text-xs'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -295,6 +302,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
columns.push(
|
||||
{
|
||||
id: 'channel',
|
||||
accessorFn: (row) => row.channel,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Channel')} />
|
||||
),
|
||||
@@ -332,6 +340,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
autoColor={String(log.channel)}
|
||||
copyText={String(log.channel)}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='font-mono'
|
||||
/>
|
||||
{affinity && (
|
||||
@@ -357,7 +366,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
)}
|
||||
</div>
|
||||
{log.channel_name && (
|
||||
<span className='text-muted-foreground/70 truncate !text-xs [font-family:var(--font-body)]'>
|
||||
<span className='text-muted-foreground/70 truncate [font-family:var(--font-body)] !text-xs'>
|
||||
{channelName}
|
||||
</span>
|
||||
)}
|
||||
@@ -394,10 +403,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Channel'), mobileHidden: true },
|
||||
meta: { label: t('Channel') },
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
accessorFn: (row) => row.username,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('User')} />
|
||||
),
|
||||
@@ -418,7 +428,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
setUserInfoDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Avatar className='ring-border/60 size-6 ring-1'>
|
||||
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
'text-[11px] font-semibold',
|
||||
@@ -450,7 +460,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</button>
|
||||
)
|
||||
},
|
||||
meta: { label: t('User'), mobileHidden: true },
|
||||
meta: { label: t('User') },
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -502,7 +512,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{metaParts.length > 0 && (
|
||||
<span className='text-muted-foreground/60 truncate !text-xs [font-family:var(--font-body)]'>
|
||||
<span className='text-muted-foreground/60 truncate [font-family:var(--font-body)] !text-xs'>
|
||||
{metaParts.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
@@ -554,7 +564,9 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
? log.completion_tokens / useTime
|
||||
: null
|
||||
const timeVariant = getResponseTimeColor(useTime, log.completion_tokens)
|
||||
const frtVariant = frt ? getFirstResponseTimeColor(frt / 1000) : null
|
||||
const frtVariant = frt
|
||||
? getFirstResponseTimeColor(frt / 1000)
|
||||
: 'neutral'
|
||||
|
||||
const timingBgMap: Record<string, string> = {
|
||||
success:
|
||||
@@ -575,7 +587,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
variant={timeVariant as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className={cn('font-mono', timingBgMap[timeVariant])}
|
||||
className={cn('rounded-md font-mono', timingBgMap[timeVariant])}
|
||||
/>
|
||||
{log.is_stream &&
|
||||
(frt != null && frt > 0 ? (
|
||||
@@ -585,7 +597,10 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
size='sm'
|
||||
showDot={false}
|
||||
copyable={false}
|
||||
className={cn('font-mono', timingBgMap[frtVariant])}
|
||||
className={cn(
|
||||
'rounded-md font-mono',
|
||||
timingBgMap[frtVariant]
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge
|
||||
@@ -594,12 +609,12 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
size='sm'
|
||||
showDot={false}
|
||||
copyable={false}
|
||||
className={timingBgMap.neutral}
|
||||
className={cn('rounded-md font-mono', timingBgMap.neutral)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 !text-xs leading-none [font-family:var(--font-body)]'>
|
||||
<span className='text-muted-foreground/60 !text-xs leading-none [font-family:var(--font-body)]'>
|
||||
<div className='flex items-center gap-1 [font-family:var(--font-body)] !text-xs leading-none'>
|
||||
<span className='text-muted-foreground/60 [font-family:var(--font-body)] !text-xs leading-none'>
|
||||
{log.is_stream ? t('Stream') : t('Non-stream')}
|
||||
{tokensPerSecond != null && (
|
||||
<>
|
||||
@@ -640,7 +655,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Timing'), mobileHidden: true },
|
||||
meta: { label: t('Timing') },
|
||||
},
|
||||
|
||||
{
|
||||
@@ -691,7 +706,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { label: 'Tokens', mobileHidden: true },
|
||||
meta: { label: 'Tokens' },
|
||||
},
|
||||
|
||||
{
|
||||
@@ -733,11 +748,15 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
}
|
||||
|
||||
const quotaStr = formatLogQuota(quota)
|
||||
const quotaDisplay = splitQuotaDisplay(quotaStr)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='border-border/80 bg-muted/60 inline-flex w-fit items-center rounded-md border px-1.5 py-0.5 font-semibold tabular-nums [font-family:var(--font-body)]'>
|
||||
{quotaStr}
|
||||
<span className='border-border/80 bg-muted/60 inline-flex h-6 w-fit items-center rounded-md border px-2 text-sm leading-none [font-family:var(--font-body)] font-semibold tabular-nums'>
|
||||
{quotaDisplay.prefix && (
|
||||
<span className='mr-1'>{quotaDisplay.prefix}</span>
|
||||
)}
|
||||
<span>{quotaDisplay.amount}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
+2
-2
@@ -231,7 +231,7 @@ export function useDrawingLogsColumns(
|
||||
</>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Image'), mobileHidden: true },
|
||||
meta: { label: t('Image') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'prompt',
|
||||
@@ -268,7 +268,7 @@ export function useDrawingLogsColumns(
|
||||
</>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Prompt'), mobileHidden: true },
|
||||
meta: { label: t('Prompt') },
|
||||
size: 200,
|
||||
maxSize: 220,
|
||||
},
|
||||
|
||||
+3
-2
@@ -123,6 +123,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
if (isAdmin) {
|
||||
columns.push(createChannelColumn<TaskLog>({ headerLabel: t('Channel') }), {
|
||||
id: 'user',
|
||||
accessorFn: (row) => row.username || row.user_id,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('User')} />
|
||||
),
|
||||
@@ -142,7 +143,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
setUserInfoDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Avatar className='ring-border/60 size-6 ring-1'>
|
||||
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
'text-[11px] font-semibold',
|
||||
@@ -161,7 +162,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
</button>
|
||||
)
|
||||
},
|
||||
meta: { label: t('User'), mobileHidden: true },
|
||||
meta: { label: t('User') },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -101,12 +101,12 @@ function ModelBadgeContent(props: ModelBadgeProps) {
|
||||
showDot={!provider}
|
||||
autoColor={provider ? undefined : props.modelName}
|
||||
className={cn(
|
||||
'border-border/60 bg-muted/30 h-auto min-h-6 gap-1.5 rounded-md border px-2 py-0.5 whitespace-normal break-all [font-family:var(--font-body)]',
|
||||
'border-border/60 bg-muted/30 h-6 max-w-full gap-1.5 rounded-md border px-2 [font-family:var(--font-body)]',
|
||||
provider && 'text-foreground',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5 min-w-0'>
|
||||
<span className='flex max-w-full min-w-0 items-center gap-1.5'>
|
||||
{provider && (
|
||||
<span
|
||||
className='flex size-3.5 shrink-0 items-center justify-center'
|
||||
@@ -116,7 +116,7 @@ function ModelBadgeContent(props: ModelBadgeProps) {
|
||||
{getLobeIcon(provider.icon, 14)}
|
||||
</span>
|
||||
)}
|
||||
<span>{props.modelName}</span>
|
||||
<span className='truncate'>{props.modelName}</span>
|
||||
</span>
|
||||
</StatusBadge>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
/*
|
||||
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 { flexRender, type Cell, type Table } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
dotColorMap,
|
||||
textColorMap,
|
||||
type StatusVariant,
|
||||
} from '@/components/status-badge'
|
||||
import { LOG_TYPE_ENUM } from '../constants'
|
||||
import { getLogTypeConfig } from '../lib/utils'
|
||||
import type { LogCategory } from '../types'
|
||||
|
||||
const logTypeRowTint: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.ERROR]:
|
||||
'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30',
|
||||
[LOG_TYPE_ENUM.REFUND]:
|
||||
'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30',
|
||||
}
|
||||
|
||||
interface UsageLogsMobileListProps<TData> {
|
||||
table: Table<TData>
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
logCategory: LogCategory
|
||||
}
|
||||
|
||||
function UsageLogsMobileSkeleton() {
|
||||
return (
|
||||
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='border-border/40 space-y-2.5 border-b p-3 last:border-b-0'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Skeleton className='h-5 w-40 rounded-md' />
|
||||
<Skeleton className='h-5 w-16 rounded-md' />
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-x-4 gap-y-2'>
|
||||
{[1, 2, 3, 4, 5, 6].map((j) => (
|
||||
<div key={j} className='min-w-0 space-y-1'>
|
||||
<Skeleton className='h-3 w-10 rounded' />
|
||||
<Skeleton className='h-4 w-full rounded' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactCell<TData>({
|
||||
cell,
|
||||
fallback = '-',
|
||||
className,
|
||||
primaryOnly = false,
|
||||
}: {
|
||||
cell?: Cell<TData, unknown>
|
||||
fallback?: string
|
||||
className?: string
|
||||
primaryOnly?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden leading-tight [&_button]:max-w-full [&_span]:max-w-full',
|
||||
primaryOnly &&
|
||||
'[&_.flex-col]:min-w-0 [&_.flex-col>*:not(:first-child)]:hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{cell ? (
|
||||
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
) : (
|
||||
<span className='text-muted-foreground/50'>{fallback}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryField<TData>({
|
||||
label,
|
||||
cell,
|
||||
className,
|
||||
valueClassName,
|
||||
primaryOnly = false,
|
||||
}: {
|
||||
label: string
|
||||
cell?: Cell<TData, unknown>
|
||||
className?: string
|
||||
valueClassName?: string
|
||||
primaryOnly?: boolean
|
||||
}) {
|
||||
if (!cell) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('bg-muted/20 min-w-0 rounded-md px-2 py-1.5', className)}
|
||||
>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{label}
|
||||
</div>
|
||||
<CompactCell
|
||||
cell={cell}
|
||||
primaryOnly={primaryOnly}
|
||||
className={valueClassName}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileLogTimeStatus({
|
||||
createdAt,
|
||||
type,
|
||||
}: {
|
||||
createdAt: unknown
|
||||
type: unknown
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const timestamp = typeof createdAt === 'number' ? createdAt : undefined
|
||||
const logType = typeof type === 'number' ? type : undefined
|
||||
const config = getLogTypeConfig(logType ?? LOG_TYPE_ENUM.UNKNOWN)
|
||||
const variant = config.color as StatusVariant
|
||||
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='font-mono text-xs leading-tight tabular-nums'>
|
||||
{formatTimestampToDate(timestamp)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-xs leading-none font-medium',
|
||||
textColorMap[variant]
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn('size-1.5 shrink-0 rounded-full', dotColorMap[variant])}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span>{t(config.label)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getCellOriginalField<TData>(
|
||||
cell: Cell<TData, unknown> | undefined,
|
||||
field: string
|
||||
): unknown {
|
||||
const original = cell?.row.original
|
||||
|
||||
if (!original || typeof original !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (original as Record<string, unknown>)[field]
|
||||
}
|
||||
|
||||
function CommonLogsCard<TData>({
|
||||
cells,
|
||||
}: {
|
||||
cells: Map<string, Cell<TData, unknown>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const modelCell = cells.get('model_name')
|
||||
const quotaCell = cells.get('quota')
|
||||
const createdAtCell = cells.get('created_at')
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
<CompactCell cell={modelCell} className='flex-1' />
|
||||
<CompactCell
|
||||
cell={quotaCell}
|
||||
className='shrink-0 text-right [&_span]:!h-6 [&_span]:!px-2 [&_span]:!text-sm [&_span]:!leading-none'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] gap-1.5'>
|
||||
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{t('Time')}
|
||||
</div>
|
||||
<MobileLogTimeStatus
|
||||
createdAt={getCellOriginalField(createdAtCell, 'created_at')}
|
||||
type={getCellOriginalField(createdAtCell, 'type')}
|
||||
/>
|
||||
</div>
|
||||
<SummaryField
|
||||
label={t('Channel')}
|
||||
cell={cells.get('channel')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField label={t('User')} cell={cells.get('user')} primaryOnly />
|
||||
<SummaryField
|
||||
label={t('Token')}
|
||||
cell={cells.get('token_name')}
|
||||
valueClassName='[&_.flex-col]:max-w-none [&_.flex-col>*:not(:first-child)]:text-[11px] [&_.flex-col>*:not(:first-child)]:leading-none'
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Timing')}
|
||||
cell={cells.get('use_time')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Tokens')}
|
||||
cell={cells.get('prompt_tokens')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Details')}
|
||||
cell={cells.get('content')}
|
||||
className='col-span-2 bg-transparent px-0 py-0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskLogsCard<TData>({
|
||||
cells,
|
||||
}: {
|
||||
cells: Map<string, Cell<TData, unknown>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const taskIdCell = cells.get('task_id')
|
||||
const statusCell = cells.get('status')
|
||||
const submitTimeCell = cells.get('submit_time')
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
<CompactCell cell={taskIdCell} className='flex-1' />
|
||||
<CompactCell cell={statusCell} className='shrink-0 text-right' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField label={t('Submit Time')} cell={submitTimeCell} />
|
||||
<SummaryField label={t('User')} cell={cells.get('user')} primaryOnly />
|
||||
<SummaryField
|
||||
label={t('Result')}
|
||||
cell={cells.get('fail_reason')}
|
||||
className='col-span-2 bg-transparent px-0 py-0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawingLogsCard<TData>({
|
||||
cells,
|
||||
}: {
|
||||
cells: Map<string, Cell<TData, unknown>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const actionCell = cells.get('action')
|
||||
const codeCell = cells.get('code')
|
||||
const submitTimeCell = cells.get('submit_time')
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
<CompactCell cell={actionCell} className='flex-1' />
|
||||
<CompactCell cell={codeCell} className='shrink-0 text-right' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField label={t('Submit Time')} cell={submitTimeCell} />
|
||||
<SummaryField
|
||||
label={t('Channel')}
|
||||
cell={cells.get('channel')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField label={t('Task ID')} cell={cells.get('mj_id')} />
|
||||
<SummaryField
|
||||
label={t('Duration')}
|
||||
cell={cells.get('duration')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField label={t('Image')} cell={cells.get('image_url')} />
|
||||
<SummaryField
|
||||
label={t('Prompt')}
|
||||
cell={cells.get('prompt')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Fail Reason')}
|
||||
cell={cells.get('fail_reason')}
|
||||
className='col-span-2 bg-transparent px-0 py-0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UsageLogsMobileList<TData>({
|
||||
table,
|
||||
isLoading = false,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
logCategory,
|
||||
}: UsageLogsMobileListProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const resolvedEmptyTitle = emptyTitle ?? t('No Logs Found')
|
||||
const resolvedEmptyDescription =
|
||||
emptyDescription ??
|
||||
t('No usage logs available. Logs will appear here once API calls are made.')
|
||||
|
||||
if (isLoading) {
|
||||
return <UsageLogsMobileSkeleton />
|
||||
}
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return (
|
||||
<div className='rounded-lg border p-6'>
|
||||
<Empty className='border-none p-0'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Database className='size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
|
||||
{rows.map((row) => {
|
||||
const cells = new Map(
|
||||
row.getVisibleCells().map((cell) => [cell.column.id, cell])
|
||||
)
|
||||
|
||||
const logType = (row.original as Record<string, unknown>).type as
|
||||
| number
|
||||
| undefined
|
||||
const tintClass = logType != null ? (logTypeRowTint[logType] ?? '') : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'border-border/40 border-b border-l-2 border-l-transparent p-3 transition-colors last:border-b-0',
|
||||
tintClass
|
||||
)}
|
||||
>
|
||||
{logCategory === 'common' && <CommonLogsCard cells={cells} />}
|
||||
{logCategory === 'task' && <TaskLogsCard cells={cells} />}
|
||||
{logCategory === 'drawing' && <DrawingLogsCard cells={cells} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { fetchLogsByCategory } from '../lib/utils'
|
||||
import type { LogCategory } from '../types'
|
||||
import { CommonLogsFilterBar } from './common-logs-filter-bar'
|
||||
import { TaskLogsFilterBar } from './task-logs-filter-bar'
|
||||
import { UsageLogsMobileList } from './usage-logs-mobile-card'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
@@ -164,6 +165,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
|
||||
})
|
||||
|
||||
@@ -190,6 +192,13 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
'[&_[data-slot=table]]:text-[13px] [&_[data-slot=table]_td]:text-[13px] [&_[data-slot=table]_td_*]:text-[13px] [&_[data-slot=table]_th]:text-[13px] [&_[data-slot=table]_th_*]:text-[13px]'
|
||||
)}
|
||||
tableHeaderClassName='bg-muted/30 sticky top-0 z-10'
|
||||
mobile={
|
||||
<UsageLogsMobileList
|
||||
table={table}
|
||||
isLoading={isLoadingData}
|
||||
logCategory={logCategory}
|
||||
/>
|
||||
}
|
||||
toolbar={
|
||||
isCommon ? (
|
||||
<CommonLogsFilterBar table={table} />
|
||||
|
||||
Vendored
+17
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "All conditions must match before this tier is used.",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "All edits are overwrite operations. Leave fields empty to keep current values unchanged.",
|
||||
"All files exceed the maximum size.": "All files exceed the maximum size.",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "All playground messages saved in this browser will be removed. This cannot be undone.",
|
||||
"All Groups": "All Groups",
|
||||
"All Models": "All Models",
|
||||
"All models in use are properly configured.": "All models in use are properly configured.",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "Amount options must be a JSON array",
|
||||
"Amount to pay:": "Amount to pay:",
|
||||
"An unexpected error occurred": "An unexpected error occurred",
|
||||
"Analyze data": "Analyze data",
|
||||
"and": "and",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "Announcement added. Click \"Save Settings\" to apply.",
|
||||
"Announcement content": "Announcement content",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "Clear All Cache",
|
||||
"Clear all filters": "Clear all filters",
|
||||
"Clear cache for this rule": "Clear cache for this rule",
|
||||
"Clear chat history": "Clear chat history",
|
||||
"Clear chat history?": "Clear chat history?",
|
||||
"Clear filters": "Clear filters",
|
||||
"Clear Mapping": "Clear Mapping",
|
||||
"Clear mode flags in prompts": "Clear mode flags in prompts",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "Continue with Telegram",
|
||||
"Continue with WeChat": "Continue with WeChat",
|
||||
"Contract review, compliance, summarisation": "Contract review, compliance, summarisation",
|
||||
"Conversation cleared": "Conversation cleared",
|
||||
"Control which models are exposed and which groups may use them.": "Control which models are exposed and which groups may use them.",
|
||||
"Controls how much the model thinks before answering": "Controls how much the model thinks before answering",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Controls whether user verification (biometrics/PIN) is required during Passkey flows.",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "Generating...",
|
||||
"Generation quality preset": "Generation quality preset",
|
||||
"Generic cache": "Generic cache",
|
||||
"Get advice": "Get advice",
|
||||
"Get notified when balance falls below this value": "Get notified when balance falls below this value",
|
||||
"Get one here": "Get one here",
|
||||
"Get started": "Get started",
|
||||
@@ -2778,6 +2784,7 @@
|
||||
"Override auto-discovered endpoint": "Override auto-discovered endpoint",
|
||||
"Override request headers": "Override request headers",
|
||||
"Override request headers (JSON format)": "Override request headers (JSON format)",
|
||||
"Override request parameters": "Override request parameters",
|
||||
"Override request parameters (JSON format)": "Override request parameters (JSON format)",
|
||||
"Override request parameters. Cannot override": "Override request parameters. Cannot override",
|
||||
"Override request parameters. Cannot override stream parameter.": "Override request parameters. Cannot override stream parameter.",
|
||||
@@ -2852,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "Path Regex (one per line)",
|
||||
"Path:": "Path:",
|
||||
"Pay": "Pay",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Pay-as-you-go with real-time usage monitoring",
|
||||
"Pay with Balance": "Pay with Balance",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Pay-as-you-go with real-time usage monitoring",
|
||||
"Payment": "Payment",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.",
|
||||
"Payment Channel": "Payment Channel",
|
||||
@@ -3019,6 +3026,7 @@
|
||||
"preset.lavender-dream": "Lavender Dream",
|
||||
"preset.ocean-breeze": "Ocean Breeze",
|
||||
"preset.rose-garden": "Rose Garden",
|
||||
"preset.simple-large": "Simple Large-font",
|
||||
"preset.sunset-glow": "Sunset Glow",
|
||||
"preset.underground": "Underground",
|
||||
"Press Enter or comma to add tags": "Press Enter or comma to add tags",
|
||||
@@ -3350,6 +3358,7 @@
|
||||
"Resets in:": "Resets in:",
|
||||
"Resolve Conflicts": "Resolve Conflicts",
|
||||
"Resource Configuration": "Resource Configuration",
|
||||
"Responding...": "Responding...",
|
||||
"Response": "Response",
|
||||
"Response Time": "Response Time",
|
||||
"Responses API Version": "Responses API Version",
|
||||
@@ -3420,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "Sampling temperature; lower is more deterministic",
|
||||
"Sandbox mode": "Sandbox mode",
|
||||
"Save": "Save",
|
||||
"Save & Submit": "Save & Submit",
|
||||
"Save all settings": "Save all settings",
|
||||
"Save Backup Codes": "Save Backup Codes",
|
||||
"Save changes": "Save changes",
|
||||
@@ -3707,6 +3717,7 @@
|
||||
"Standard": "Standard",
|
||||
"Standard price": "Standard price",
|
||||
"Start": "Start",
|
||||
"Start a playground chat": "Start a playground chat",
|
||||
"Start a conversation to see messages here": "Start a conversation to see messages here",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.",
|
||||
"Start for free with generous limits. No credit card required.": "Start for free with generous limits. No credit card required.",
|
||||
@@ -3765,10 +3776,10 @@
|
||||
"Subscription First": "Subscription First",
|
||||
"Subscription Management": "Subscription Management",
|
||||
"Subscription Only": "Subscription Only",
|
||||
"Subscription purchased successfully": "Subscription purchased successfully",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.",
|
||||
"Subscription Plans": "Subscription Plans",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).",
|
||||
"Subscription purchased successfully": "Subscription purchased successfully",
|
||||
"Subtract": "Subtract",
|
||||
"Success": "Success",
|
||||
"Success rate": "Success rate",
|
||||
@@ -3781,9 +3792,11 @@
|
||||
"Successfully enabled {{count}} model(s)": "Successfully enabled {{count}} model(s)",
|
||||
"Suffix": "Suffix",
|
||||
"Suffix Match": "Suffix Match",
|
||||
"Summarize text": "Summarize text",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "Sunset Glow",
|
||||
"Super Admin": "Super Admin",
|
||||
"Super Large": "Super Large",
|
||||
"Support for high concurrency with automatic load balancing": "Support for high concurrency with automatic load balancing",
|
||||
"Supported Applications": "Supported Applications",
|
||||
"Supported Imagine Models": "Supported Imagine Models",
|
||||
@@ -3794,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.",
|
||||
"Surprise me": "Surprise me",
|
||||
"Sustained tokens per second": "Sustained tokens per second",
|
||||
"Swap Face": "Swap Face",
|
||||
"Switch affinity on success": "Switch affinity on success",
|
||||
@@ -3880,6 +3894,7 @@
|
||||
"Test Model": "Test Model",
|
||||
"Test models and prompts from the browser": "Test models and prompts from the browser",
|
||||
"Test selected models": "Test selected models",
|
||||
"Test a model with a starter prompt, or write your own request below.": "Test a model with a starter prompt, or write your own request below.",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "Testing all enabled channels started. Please refresh to see results.",
|
||||
"Testing...": "Testing...",
|
||||
"Text": "Text",
|
||||
|
||||
Vendored
+17
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "Toutes les conditions doivent correspondre avant que ce palier soit utilisé.",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Toutes les modifications sont des opérations d'écrasement. Laissez les champs vides pour conserver les valeurs actuelles inchangées.",
|
||||
"All files exceed the maximum size.": "Tous les fichiers dépassent la taille maximale.",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "Tous les messages du Playground enregistrés dans ce navigateur seront supprimés. Cette action est irréversible.",
|
||||
"All Groups": "Tous les groupes",
|
||||
"All Models": "Tous les modèles",
|
||||
"All models in use are properly configured.": "Tous les modèles utilisés sont correctement configurés.",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "Les options de montant doivent être un tableau JSON",
|
||||
"Amount to pay:": "Montant à payer :",
|
||||
"An unexpected error occurred": "Une erreur inattendue est survenue",
|
||||
"Analyze data": "Analyser les données",
|
||||
"and": "et",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "Annonce ajoutée. Cliquez sur \"Enregistrer les paramètres\" pour appliquer.",
|
||||
"Announcement content": "Contenu de l'annonce",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "Vider tout le cache",
|
||||
"Clear all filters": "Effacer tous les filtres",
|
||||
"Clear cache for this rule": "Vider le cache de cette règle",
|
||||
"Clear chat history": "Effacer l'historique du chat",
|
||||
"Clear chat history?": "Effacer l'historique du chat ?",
|
||||
"Clear filters": "Effacer les filtres",
|
||||
"Clear Mapping": "Effacer le mappage",
|
||||
"Clear mode flags in prompts": "Effacer les indicateurs de mode dans les prompts",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "Continuer avec Telegram",
|
||||
"Continue with WeChat": "Continuer avec WeChat",
|
||||
"Contract review, compliance, summarisation": "Revue de contrats, conformité, résumé",
|
||||
"Conversation cleared": "Conversation effacée",
|
||||
"Control which models are exposed and which groups may use them.": "Contrôlez les modèles exposés et les groupes autorisés à les utiliser.",
|
||||
"Controls how much the model thinks before answering": "Contrôle la quantité de raisonnement avant la réponse",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Contrôle si la vérification de l'utilisateur (biométrie/PIN) est requise lors des flux de Passkey.",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "Génération...",
|
||||
"Generation quality preset": "Préréglage de qualité de génération",
|
||||
"Generic cache": "Cache générique",
|
||||
"Get advice": "Obtenir des conseils",
|
||||
"Get notified when balance falls below this value": "Recevoir une notification lorsque le solde tombe en dessous de cette valeur",
|
||||
"Get one here": "Obtenir ici",
|
||||
"Get started": "Commencer",
|
||||
@@ -2778,6 +2784,7 @@
|
||||
"Override auto-discovered endpoint": "Remplacer le point de terminaison auto-découvert",
|
||||
"Override request headers": "Remplacer les en-têtes de requête",
|
||||
"Override request headers (JSON format)": "Surcharge des en-têtes de requête (format JSON)",
|
||||
"Override request parameters": "Remplacer les paramètres de requête",
|
||||
"Override request parameters (JSON format)": "Remplacer les paramètres de requête (format JSON)",
|
||||
"Override request parameters. Cannot override": "Remplacer les paramètres de requête. Impossible de remplacer",
|
||||
"Override request parameters. Cannot override stream parameter.": "Remplace les paramètres de requête. Impossible de remplacer le paramètre stream.",
|
||||
@@ -2852,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "Regex du chemin (un par ligne)",
|
||||
"Path:": "Chemin :",
|
||||
"Pay": "Pay",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Paiement à l'usage avec suivi de la consommation en temps réel",
|
||||
"Pay with Balance": "Payer avec le solde",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Paiement à l'usage avec suivi de la consommation en temps réel",
|
||||
"Payment": "Paiement",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Mode agrégateur de paiement — embarquez avec votre propre société enregistrée (entité offshore). Conçu pour les entreprises.",
|
||||
"Payment Channel": "Canal de paiement",
|
||||
@@ -3019,6 +3026,7 @@
|
||||
"preset.lavender-dream": "Rêve de lavande",
|
||||
"preset.ocean-breeze": "Brise océane",
|
||||
"preset.rose-garden": "Roseraie",
|
||||
"preset.simple-large": "Simple & Grands Caractères",
|
||||
"preset.sunset-glow": "Lueur du couchant",
|
||||
"preset.underground": "Souterrain",
|
||||
"Press Enter or comma to add tags": "Appuyez sur Entrée ou sur la virgule pour ajouter des tags",
|
||||
@@ -3350,6 +3358,7 @@
|
||||
"Resets in:": "Réinitialise dans :",
|
||||
"Resolve Conflicts": "Résoudre les conflits",
|
||||
"Resource Configuration": "Configuration des ressources",
|
||||
"Responding...": "Réponse en cours...",
|
||||
"Response": "Réponse",
|
||||
"Response Time": "Temps de réponse",
|
||||
"Responses API Version": "Version de l'API des réponses",
|
||||
@@ -3420,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "Température d'échantillonnage ; plus c'est bas, plus c'est déterministe",
|
||||
"Sandbox mode": "Mode sandbox",
|
||||
"Save": "Enregistrer",
|
||||
"Save & Submit": "Enregistrer et envoyer",
|
||||
"Save all settings": "Enregistrer tous les paramètres",
|
||||
"Save Backup Codes": "Sauvegarder les codes de secours",
|
||||
"Save changes": "Enregistrer les modifications",
|
||||
@@ -3707,6 +3717,7 @@
|
||||
"Standard": "Standard",
|
||||
"Standard price": "Prix standard",
|
||||
"Start": "Début",
|
||||
"Start a playground chat": "Démarrer une conversation dans le playground",
|
||||
"Start a conversation to see messages here": "Démarrez une conversation pour voir les messages ici",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Commencez à encaisser des paiements dans le monde entier sans créer de société. Conçu pour les développeurs indépendants, les entrepreneurs individuels OPC et les startups. Waffo Pancake agit comme Merchant of Record et prend en charge la conformité liée à l’encaissement mondial : taxes à la consommation, facturation, gestion des abonnements, remboursements et rétrofacturations. Les développeurs solo peuvent lancer rapidement leur produit et rester concentrés sur celui-ci plutôt que sur la conformité. Intégration en quelques minutes, d’une seule invite à une intégration complète.",
|
||||
"Start for free with generous limits. No credit card required.": "Commencez gratuitement avec des limites généreuses. Aucune carte de crédit requise.",
|
||||
@@ -3765,10 +3776,10 @@
|
||||
"Subscription First": "Abonnement en priorité",
|
||||
"Subscription Management": "Gestion des abonnements",
|
||||
"Subscription Only": "Abonnement uniquement",
|
||||
"Subscription purchased successfully": "Abonnement acheté avec succès",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "La création et la modification des forfaits d’abonnement sont verrouillées jusqu’à ce que l’administrateur confirme les conditions de conformité dans les paramètres de la passerelle de paiement.",
|
||||
"Subscription Plans": "Plans d'abonnement",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Les forfaits d’abonnement n’utilisent PAS le produit associé : chaque forfait dispose de son propre produit Pancake dédié, défini dans l’administration des abonnements (ou créé automatiquement via le bouton « + Créer »).",
|
||||
"Subscription purchased successfully": "Abonnement acheté avec succès",
|
||||
"Subtract": "Soustraire",
|
||||
"Success": "Succès",
|
||||
"Success rate": "Taux de réussite",
|
||||
@@ -3781,9 +3792,11 @@
|
||||
"Successfully enabled {{count}} model(s)": "{{count}} modèle(s) activé(s) avec succès",
|
||||
"Suffix": "Suffixe",
|
||||
"Suffix Match": "Correspondance de suffixe",
|
||||
"Summarize text": "Résumer le texte",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "Lueur du couchant",
|
||||
"Super Admin": "Super Administrateur",
|
||||
"Super Large": "Très grand",
|
||||
"Support for high concurrency with automatic load balancing": "Prise en charge de la haute concurrence avec équilibrage de charge automatique",
|
||||
"Supported Applications": "Applications prises en charge",
|
||||
"Supported Imagine Models": "Modèles Imagine pris en charge",
|
||||
@@ -3794,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Prend en charge le balisage HTML ou l'intégration d'iframe. Entrez le code HTML directement, ou fournissez une URL complète pour l'intégrer automatiquement en tant qu'iframe.",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Prend en charge la configuration en un clic et s'adapte parfaitement à la configuration multi-protocole NewAPI.",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Prend en charge PNG, JPG, SVG ou WebP. Taille recommandée : 128×128 ou moins.",
|
||||
"Surprise me": "Surprends-moi",
|
||||
"Sustained tokens per second": "Jetons par seconde soutenus",
|
||||
"Swap Face": "Échanger le visage",
|
||||
"Switch affinity on success": "Changer l'affinité en cas de succès",
|
||||
@@ -3880,6 +3894,7 @@
|
||||
"Test Model": "Tester le modèle",
|
||||
"Test models and prompts from the browser": "Tester les modèles et les prompts depuis le navigateur",
|
||||
"Test selected models": "Tester les modèles sélectionnés",
|
||||
"Test a model with a starter prompt, or write your own request below.": "Testez un modèle avec un prompt de départ, ou rédigez votre propre demande ci-dessous.",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "Test de tous les canaux activés démarré. Veuillez actualiser pour voir les résultats.",
|
||||
"Testing...": "Test en cours...",
|
||||
"Text": "Texte",
|
||||
|
||||
Vendored
+17
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "この段階を使用するには、すべての条件に一致する必要があります。",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "すべての編集は上書き操作です。現在の値を変更しないままにするには、フィールドを空のままにしてください。",
|
||||
"All files exceed the maximum size.": "すべてのファイルが最大サイズを超えています。",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "このブラウザに保存されたすべての Playground メッセージが削除されます。この操作は元に戻せません。",
|
||||
"All Groups": "すべてのグループ",
|
||||
"All Models": "すべてのモデル",
|
||||
"All models in use are properly configured.": "使用中のすべてのモデルが適切に構成されています。",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "金額オプションは JSON 配列でなければなりません",
|
||||
"Amount to pay:": "支払い金額:",
|
||||
"An unexpected error occurred": "予期せぬエラーが発生しました",
|
||||
"Analyze data": "データを分析",
|
||||
"and": "および",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "お知らせが追加されました。\"設定を保存\" をクリックして適用してください。",
|
||||
"Announcement content": "お知らせの内容",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "全キャッシュをクリア",
|
||||
"Clear all filters": "すべてのフィルターをクリア",
|
||||
"Clear cache for this rule": "このルールのキャッシュをクリア",
|
||||
"Clear chat history": "チャット履歴を消去",
|
||||
"Clear chat history?": "チャット履歴を消去しますか?",
|
||||
"Clear filters": "フィルターをクリア",
|
||||
"Clear Mapping": "マッピングをクリア",
|
||||
"Clear mode flags in prompts": "プロンプト内のモードフラグをクリア",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "Telegram で続行",
|
||||
"Continue with WeChat": "WeChat で続行",
|
||||
"Contract review, compliance, summarisation": "契約レビュー・コンプライアンス・要約",
|
||||
"Conversation cleared": "会話を消去しました",
|
||||
"Control which models are exposed and which groups may use them.": "公開するモデルと、それらを利用できるグループを制御します。",
|
||||
"Controls how much the model thinks before answering": "モデルが回答前に考える深さを制御します",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Passkeyフロー中にユーザー認証(生体認証/PIN)が必要かどうかを制御します。",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "生成中...",
|
||||
"Generation quality preset": "生成品質プリセット",
|
||||
"Generic cache": "汎用キャッシュ",
|
||||
"Get advice": "アドバイスを得る",
|
||||
"Get notified when balance falls below this value": "残高がこの値を下回ったときに通知を受け取る",
|
||||
"Get one here": "こちらから取得",
|
||||
"Get started": "はじめる",
|
||||
@@ -2778,6 +2784,7 @@
|
||||
"Override auto-discovered endpoint": "自動検出されたエンドポイントを上書きする",
|
||||
"Override request headers": "リクエストヘッダーを上書きする",
|
||||
"Override request headers (JSON format)": "リクエストヘッダーのオーバーライド (JSON 形式)",
|
||||
"Override request parameters": "リクエストパラメータを上書き",
|
||||
"Override request parameters (JSON format)": "リクエストパラメータの上書き (JSON形式)",
|
||||
"Override request parameters. Cannot override": "リクエストパラメーターを上書きします。上書きできません",
|
||||
"Override request parameters. Cannot override stream parameter.": "リクエストパラメータを上書きします。stream パラメータは上書きできません。",
|
||||
@@ -2852,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "パス正規表現(1行に1つ)",
|
||||
"Path:": "パス:",
|
||||
"Pay": "Pay",
|
||||
"Pay-as-you-go with real-time usage monitoring": "リアルタイム使用量監視付き従量課金制",
|
||||
"Pay with Balance": "残高で支払う",
|
||||
"Pay-as-you-go with real-time usage monitoring": "リアルタイム使用量監視付き従量課金制",
|
||||
"Payment": "支払い",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "決済アグリゲーターモード — 自社の登録済み法人(オフショア法人)でオンボーディングします。エンタープライズ向けです。",
|
||||
"Payment Channel": "決済チャネル",
|
||||
@@ -3019,6 +3026,7 @@
|
||||
"preset.lavender-dream": "ラベンダードリーム",
|
||||
"preset.ocean-breeze": "オーシャンブリーズ",
|
||||
"preset.rose-garden": "ローズガーデン",
|
||||
"preset.simple-large": "特大フォント・シンプル",
|
||||
"preset.sunset-glow": "サンセットグロウ",
|
||||
"preset.underground": "アンダーグラウンド",
|
||||
"Press Enter or comma to add tags": "Enterキーまたはコンマを押してタグを追加",
|
||||
@@ -3350,6 +3358,7 @@
|
||||
"Resets in:": "リセットまで:",
|
||||
"Resolve Conflicts": "競合を解決",
|
||||
"Resource Configuration": "リソース設定",
|
||||
"Responding...": "応答中...",
|
||||
"Response": "レスポンス",
|
||||
"Response Time": "応答時間",
|
||||
"Responses API Version": "応答APIバージョン",
|
||||
@@ -3420,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "サンプリング温度。低いほど決定論的になります",
|
||||
"Sandbox mode": "サンドボックスモード",
|
||||
"Save": "保存",
|
||||
"Save & Submit": "保存して送信",
|
||||
"Save all settings": "すべての設定を保存",
|
||||
"Save Backup Codes": "バックアップコードを保存",
|
||||
"Save changes": "変更を保存",
|
||||
@@ -3707,6 +3717,7 @@
|
||||
"Standard": "標準",
|
||||
"Standard price": "標準価格",
|
||||
"Start": "開始",
|
||||
"Start a playground chat": "Playground でチャットを開始",
|
||||
"Start a conversation to see messages here": "会話を開始すると、ここにメッセージが表示されます",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "法人を設立せずに世界中で決済を受け付けられます。個人開発者、OPC 個人事業主、スタートアップ向けに設計されています。Waffo Pancake は Merchant of Record として、消費税、請求書、サブスクリプション管理、返金、チャージバックなど、グローバル決済のコンプライアンス負担を引き受けます。個人開発者はコンプライアンスではなくプロダクトに集中しながら素早くローンチできます。数分でオンボーディングし、1 つのプロンプトから完全な統合まで進められます。",
|
||||
"Start for free with generous limits. No credit card required.": "豊富な無料枠で始められます。クレジットカードは不要です。",
|
||||
@@ -3765,10 +3776,10 @@
|
||||
"Subscription First": "サブスクリプション優先",
|
||||
"Subscription Management": "サブスクリプション管理",
|
||||
"Subscription Only": "サブスクリプションのみ",
|
||||
"Subscription purchased successfully": "サブスクリプションを購入しました",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "管理者が支払いゲートウェイ設定でコンプライアンス条件を確認するまで、サブスクリプションプランの作成と変更はロックされます。",
|
||||
"Subscription Plans": "サブスクリプションプラン",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "サブスクリプションプランは紐付け済み商品を使用しません。各プランには専用の Pancake 商品があり、サブスクリプション管理画面で設定します(または「+ 作成」ボタンで自動作成します)。",
|
||||
"Subscription purchased successfully": "サブスクリプションを購入しました",
|
||||
"Subtract": "減算",
|
||||
"Success": "成功",
|
||||
"Success rate": "成功率",
|
||||
@@ -3781,9 +3792,11 @@
|
||||
"Successfully enabled {{count}} model(s)": "{{count}} 個のモデルを有効にしました",
|
||||
"Suffix": "サフィックス",
|
||||
"Suffix Match": "サフィックス一致",
|
||||
"Summarize text": "テキストを要約",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "サンセットグロウ",
|
||||
"Super Admin": "スーパー管理者",
|
||||
"Super Large": "極大",
|
||||
"Support for high concurrency with automatic load balancing": "自動ロードバランシングによる高並行性のサポート",
|
||||
"Supported Applications": "サポートされているアプリケーション",
|
||||
"Supported Imagine Models": "対応Imagineモデル",
|
||||
@@ -3794,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "HTMLマークアップまたはiframe埋め込みをサポートします。HTMLコードを直接入力するか、完全なURLを提供してiframeとして自動的に埋め込みます。",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "ワンクリック設定をサポートし、NewAPIマルチプロトコル設定に完全に適応します。",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "PNG、JPG、SVG、WebPに対応。推奨サイズ: 128×128以下。",
|
||||
"Surprise me": "おまかせ",
|
||||
"Sustained tokens per second": "持続的な毎秒トークン数",
|
||||
"Swap Face": "顔入れ替え",
|
||||
"Switch affinity on success": "成功時にアフィニティを切替",
|
||||
@@ -3880,6 +3894,7 @@
|
||||
"Test Model": "モデルをテスト",
|
||||
"Test models and prompts from the browser": "ブラウザでモデルとプロンプトをテスト",
|
||||
"Test selected models": "選択したモデルをテスト",
|
||||
"Test a model with a starter prompt, or write your own request below.": "スタータープロンプトでモデルをテストするか、下に独自のリクエストを入力してください。",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "有効な全チャネルのテストを開始しました。結果を確認するにはページを更新してください。",
|
||||
"Testing...": "テスト中...",
|
||||
"Text": "テキスト",
|
||||
|
||||
Vendored
+17
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "Все условия должны совпасть, прежде чем будет использован этот уровень.",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Все изменения являются операциями перезаписи. Оставьте поля пустыми, чтобы сохранить текущие значения без изменений.",
|
||||
"All files exceed the maximum size.": "Все файлы превышают максимальный размер.",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "Все сообщения Playground, сохраненные в этом браузере, будут удалены. Это действие нельзя отменить.",
|
||||
"All Groups": "Все группы",
|
||||
"All Models": "Все модели",
|
||||
"All models in use are properly configured.": "Все используемые модели настроены правильно.",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "Варианты сумм должны быть JSON-массивом",
|
||||
"Amount to pay:": "Сумма к оплате:",
|
||||
"An unexpected error occurred": "Произошла непредвиденная ошибка",
|
||||
"Analyze data": "Анализировать данные",
|
||||
"and": "и",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "Объявление добавлено. Нажмите \"Сохранить настройки\", чтобы применить.",
|
||||
"Announcement content": "Содержимое объявления",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "Очистить весь кэш",
|
||||
"Clear all filters": "Очистить все фильтры",
|
||||
"Clear cache for this rule": "Очистить кэш этого правила",
|
||||
"Clear chat history": "Очистить историю чата",
|
||||
"Clear chat history?": "Очистить историю чата?",
|
||||
"Clear filters": "Очистить фильтры",
|
||||
"Clear Mapping": "Очистить сопоставление",
|
||||
"Clear mode flags in prompts": "Очистить флаги режимов в промптах",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "Продолжить с Telegram",
|
||||
"Continue with WeChat": "Продолжить с WeChat",
|
||||
"Contract review, compliance, summarisation": "Анализ контрактов, комплаенс, резюме",
|
||||
"Conversation cleared": "Диалог очищен",
|
||||
"Control which models are exposed and which groups may use them.": "Управляйте тем, какие модели доступны и какие группы могут их использовать.",
|
||||
"Controls how much the model thinks before answering": "Регулирует глубину размышлений модели перед ответом",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Определяет, требуется ли проверка пользователя (биометрия/PIN) во время процессов Passkey.",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "Создание...",
|
||||
"Generation quality preset": "Пресет качества генерации",
|
||||
"Generic cache": "Общий кэш",
|
||||
"Get advice": "Получить совет",
|
||||
"Get notified when balance falls below this value": "Получать уведомления, когда баланс опускается ниже этого значения",
|
||||
"Get one here": "Получить здесь",
|
||||
"Get started": "Начало работы",
|
||||
@@ -2778,6 +2784,7 @@
|
||||
"Override auto-discovered endpoint": "Переопределить автоматически обнаруженную конечную точку",
|
||||
"Override request headers": "Переопределить заголовки запроса",
|
||||
"Override request headers (JSON format)": "Переопределение заголовков запроса (формат JSON)",
|
||||
"Override request parameters": "Переопределить параметры запроса",
|
||||
"Override request parameters (JSON format)": "Переопределить параметры запроса (формат JSON)",
|
||||
"Override request parameters. Cannot override": "Переопределить параметры запроса. Невозможно переопределить",
|
||||
"Override request parameters. Cannot override stream parameter.": "Переопределяет параметры запроса. Параметр stream переопределить нельзя.",
|
||||
@@ -2852,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "Регулярное выражение пути (по одному на строку)",
|
||||
"Path:": "Путь:",
|
||||
"Pay": "Pay",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Оплата по мере использования с мониторингом в реальном времени",
|
||||
"Pay with Balance": "Оплатить балансом",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Оплата по мере использования с мониторингом в реальном времени",
|
||||
"Payment": "Платеж",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Режим платежного агрегатора — подключение через вашу зарегистрированную компанию (офшорное юрлицо). Создано для Enterprise.",
|
||||
"Payment Channel": "Платёжный канал",
|
||||
@@ -3019,6 +3026,7 @@
|
||||
"preset.lavender-dream": "Лавандовый сон",
|
||||
"preset.ocean-breeze": "Морской бриз",
|
||||
"preset.rose-garden": "Розовый сад",
|
||||
"preset.simple-large": "Простая крупная",
|
||||
"preset.sunset-glow": "Закатное сияние",
|
||||
"preset.underground": "Подполье",
|
||||
"Press Enter or comma to add tags": "Нажмите Enter или запятую, чтобы добавить теги",
|
||||
@@ -3350,6 +3358,7 @@
|
||||
"Resets in:": "Сброс через:",
|
||||
"Resolve Conflicts": "Разрешить конфликты",
|
||||
"Resource Configuration": "Конфигурация ресурсов",
|
||||
"Responding...": "Отвечаем...",
|
||||
"Response": "Ответ",
|
||||
"Response Time": "Время ответа",
|
||||
"Responses API Version": "Версия API ответов",
|
||||
@@ -3420,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "Температура сэмплирования; чем ниже, тем детерминированнее",
|
||||
"Sandbox mode": "Режим песочницы",
|
||||
"Save": "Сохранить",
|
||||
"Save & Submit": "Сохранить и отправить",
|
||||
"Save all settings": "Сохранить все настройки",
|
||||
"Save Backup Codes": "Сохранить резервные коды",
|
||||
"Save changes": "Сохранить изменения",
|
||||
@@ -3707,6 +3717,7 @@
|
||||
"Standard": "Стандартный",
|
||||
"Standard price": "Стандартная цена",
|
||||
"Start": "Начало",
|
||||
"Start a playground chat": "Начните чат в Playground",
|
||||
"Start a conversation to see messages here": "Начните разговор, чтобы увидеть сообщения здесь",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Начните принимать платежи по всему миру без регистрации компании. Подходит для независимых разработчиков, индивидуальных предпринимателей OPC и стартапов. Waffo Pancake выступает как Merchant of Record и берет на себя комплаенс глобального приема платежей: потребительские налоги, выставление счетов, управление подписками, возвраты и чарджбеки. Одиночные разработчики могут быстро запуститься и сосредоточиться на продукте, а не на комплаенсе. Подключение за минуты — от одного запроса до полной интеграции.",
|
||||
"Start for free with generous limits. No credit card required.": "Начните бесплатно с щедрыми лимитами. Кредитная карта не требуется.",
|
||||
@@ -3765,10 +3776,10 @@
|
||||
"Subscription First": "Подписка в приоритете",
|
||||
"Subscription Management": "Управление подписками",
|
||||
"Subscription Only": "Только подписка",
|
||||
"Subscription purchased successfully": "Подписка успешно приобретена",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Создание и изменение планов подписки заблокированы, пока администратор не подтвердит условия соответствия в настройках платежного шлюза.",
|
||||
"Subscription Plans": "Планы подписки",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Планы подписки НЕ используют привязанный продукт — у каждого плана есть собственный продукт Pancake, задаваемый в администрировании подписок (или автоматически создаваемый кнопкой «+ Создать»).",
|
||||
"Subscription purchased successfully": "Подписка успешно приобретена",
|
||||
"Subtract": "Вычесть",
|
||||
"Success": "Успешно",
|
||||
"Success rate": "Доля успешных запросов",
|
||||
@@ -3781,9 +3792,11 @@
|
||||
"Successfully enabled {{count}} model(s)": "Успешно включено {{count}} моделей",
|
||||
"Suffix": "Суффикс",
|
||||
"Suffix Match": "Совпадение по суффиксу",
|
||||
"Summarize text": "Кратко изложить текст",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "Закатное сияние",
|
||||
"Super Admin": "Суперадмин",
|
||||
"Super Large": "Очень крупная",
|
||||
"Support for high concurrency with automatic load balancing": "Поддержка высокой конкурентности с автоматической балансировкой нагрузки",
|
||||
"Supported Applications": "Поддерживаемые приложения",
|
||||
"Supported Imagine Models": "Поддерживаемые модели Imagine",
|
||||
@@ -3794,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Поддерживает HTML-разметку или встраивание iframe. Введите HTML-код напрямую или укажите полный URL для автоматического встраивания в виде iframe.",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Поддерживает настройку в один клик и идеально адаптируется к многопротокольной конфигурации NewAPI.",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Поддерживаются PNG, JPG, SVG или WebP. Рекомендуемый размер: 128×128 или меньше.",
|
||||
"Surprise me": "Удиви меня",
|
||||
"Sustained tokens per second": "Устойчивая скорость токенов в секунду",
|
||||
"Swap Face": "Замена лица",
|
||||
"Switch affinity on success": "Переключить привязку при успехе",
|
||||
@@ -3880,6 +3894,7 @@
|
||||
"Test Model": "Проверить модель",
|
||||
"Test models and prompts from the browser": "Тестируйте модели и промпты в браузере",
|
||||
"Test selected models": "Проверить выбранные модели",
|
||||
"Test a model with a starter prompt, or write your own request below.": "Проверьте модель с начальным промптом или напишите собственный запрос ниже.",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "Тестирование всех включенных каналов начато. Пожалуйста, обновите страницу, чтобы увидеть результаты.",
|
||||
"Testing...": "Тестирование...",
|
||||
"Text": "Текст",
|
||||
|
||||
Vendored
+17
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "Tất cả điều kiện phải khớp trước khi tầng này được sử dụng.",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Tất cả các chỉnh sửa đều là thao tác ghi đè. Để trống các trường để giữ nguyên giá trị hiện tại.",
|
||||
"All files exceed the maximum size.": "Tất cả các tệp vượt quá kích thước tối đa.",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "Tất cả tin nhắn Playground đã lưu trong trình duyệt này sẽ bị xóa. Không thể hoàn tác hành động này.",
|
||||
"All Groups": "Tất cả các nhóm",
|
||||
"All Models": "Tất cả các mẫu",
|
||||
"All models in use are properly configured.": "Tất cả các mô hình đang được sử dụng đều được cấu hình đúng cách.",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "Tùy chọn số tiền phải là mảng JSON",
|
||||
"Amount to pay:": "Amount due:",
|
||||
"An unexpected error occurred": "Đã xảy ra lỗi không mong muốn",
|
||||
"Analyze data": "Phân tích dữ liệu",
|
||||
"and": "and",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "Đã thêm thông báo. Nhấp \"Save Settings\" để áp dụng.",
|
||||
"Announcement content": "Nội dung thông báo",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "Xóa toàn bộ bộ nhớ đệm",
|
||||
"Clear all filters": "Xóa tất cả bộ lọc",
|
||||
"Clear cache for this rule": "Xóa bộ nhớ đệm của quy tắc này",
|
||||
"Clear chat history": "Xóa lịch sử trò chuyện",
|
||||
"Clear chat history?": "Xóa lịch sử trò chuyện?",
|
||||
"Clear filters": "Clear filter",
|
||||
"Clear Mapping": "Xóa Ánh xạ",
|
||||
"Clear mode flags in prompts": "Xóa các cờ chế độ trong lời nhắc",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "Tiếp tục với Telegram",
|
||||
"Continue with WeChat": "Tiếp tục với WeChat",
|
||||
"Contract review, compliance, summarisation": "Rà soát hợp đồng, tuân thủ, tóm tắt",
|
||||
"Conversation cleared": "Đã xóa cuộc trò chuyện",
|
||||
"Control which models are exposed and which groups may use them.": "Kiểm soát mô hình được hiển thị và nhóm nào có thể sử dụng chúng.",
|
||||
"Controls how much the model thinks before answering": "Điều chỉnh mức suy luận trước khi trả lời",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Kiểm soát xem liệu có yêu cầu xác minh người dùng (sinh trắc học/mã PIN) trong các luồng Passkey hay không.",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "Đang tạo...",
|
||||
"Generation quality preset": "Mức chất lượng sinh",
|
||||
"Generic cache": "Bộ đệm chung",
|
||||
"Get advice": "Nhận lời khuyên",
|
||||
"Get notified when balance falls below this value": "Nhận thông báo khi số dư giảm xuống dưới giá trị này",
|
||||
"Get one here": "Nhận tại đây",
|
||||
"Get started": "Bắt đầu",
|
||||
@@ -2778,6 +2784,7 @@
|
||||
"Override auto-discovered endpoint": "Ghi đè điểm cuối tự động phát hiện",
|
||||
"Override request headers": "Ghi đè tiêu đề yêu cầu",
|
||||
"Override request headers (JSON format)": "Ghi đè tiêu đề yêu cầu (định dạng JSON)",
|
||||
"Override request parameters": "Ghi đè tham số yêu cầu",
|
||||
"Override request parameters (JSON format)": "Ghi đè tham số yêu cầu (định dạng JSON)",
|
||||
"Override request parameters. Cannot override": "Ghi đè tham số yêu cầu. Không thể ghi đè",
|
||||
"Override request parameters. Cannot override stream parameter.": "Ghi đè tham số yêu cầu. Không thể ghi đè tham số stream.",
|
||||
@@ -2852,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "Regex đường dẫn (mỗi dòng một mục)",
|
||||
"Path:": "Đường dẫn:",
|
||||
"Pay": "Pay",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Thanh toán theo mức sử dụng với theo dõi mức sử dụng theo thời gian thực",
|
||||
"Pay with Balance": "Thanh toán bằng số dư",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Thanh toán theo mức sử dụng với theo dõi mức sử dụng theo thời gian thực",
|
||||
"Payment": "Thanh toán",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Chế độ tổng hợp thanh toán — đăng ký bằng công ty đã đăng ký của bạn (pháp nhân offshore). Dành cho doanh nghiệp.",
|
||||
"Payment Channel": "Kênh thanh toán",
|
||||
@@ -3019,6 +3026,7 @@
|
||||
"preset.lavender-dream": "Giấc mơ oải hương",
|
||||
"preset.ocean-breeze": "Gió biển",
|
||||
"preset.rose-garden": "Vườn hồng",
|
||||
"preset.simple-large": "Chữ lớn & Đơn giản",
|
||||
"preset.sunset-glow": "Hoàng hôn",
|
||||
"preset.underground": "Bóng đêm",
|
||||
"Press Enter or comma to add tags": "Nhấn Enter hoặc dấu phẩy để thêm thẻ",
|
||||
@@ -3350,6 +3358,7 @@
|
||||
"Resets in:": "Đặt lại sau:",
|
||||
"Resolve Conflicts": "Giải quyết Xung đột",
|
||||
"Resource Configuration": "Cấu hình tài nguyên",
|
||||
"Responding...": "Đang phản hồi...",
|
||||
"Response": "Phản hồi",
|
||||
"Response Time": "Thời gian phản hồi",
|
||||
"Responses API Version": "Phiên bản API Phản hồi",
|
||||
@@ -3420,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "Nhiệt độ lấy mẫu; càng thấp càng ổn định",
|
||||
"Sandbox mode": "Chế độ sandbox",
|
||||
"Save": "Lưu",
|
||||
"Save & Submit": "Lưu và gửi",
|
||||
"Save all settings": "Lưu tất cả cài đặt",
|
||||
"Save Backup Codes": "Lưu mã dự phòng",
|
||||
"Save changes": "Lưu thay đổi",
|
||||
@@ -3707,6 +3717,7 @@
|
||||
"Standard": "Tiêu chuẩn",
|
||||
"Standard price": "Giá tiêu chuẩn",
|
||||
"Start": "Bắt đầu",
|
||||
"Start a playground chat": "Bắt đầu cuộc trò chuyện trong playground",
|
||||
"Start a conversation to see messages here": "Bắt đầu một cuộc trò chuyện để xem tin nhắn tại đây",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Bắt đầu thu thanh toán toàn cầu mà không cần đăng ký công ty. Dành cho lập trình viên độc lập, chủ sở hữu OPC và startup. Waffo Pancake đóng vai trò Merchant of Record, chịu trách nhiệm tuân thủ cho việc thu thanh toán toàn cầu — thuế tiêu dùng, hóa đơn, quản lý đăng ký, hoàn tiền và tranh chấp thanh toán. Lập trình viên cá nhân có thể ra mắt nhanh và tập trung vào sản phẩm thay vì tuân thủ. Onboard trong vài phút — từ một prompt đến tích hợp hoàn chỉnh.",
|
||||
"Start for free with generous limits. No credit card required.": "Bắt đầu miễn phí với giới hạn hào phóng. Không cần thẻ tín dụng.",
|
||||
@@ -3765,10 +3776,10 @@
|
||||
"Subscription First": "Ưu tiên đăng ký",
|
||||
"Subscription Management": "Quản lý đăng ký",
|
||||
"Subscription Only": "Chỉ đăng ký",
|
||||
"Subscription purchased successfully": "Đã mua gói đăng ký thành công",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Việc tạo và thay đổi gói đăng ký bị khóa cho đến khi quản trị viên xác nhận điều khoản tuân thủ trong cài đặt Cổng thanh toán.",
|
||||
"Subscription Plans": "Gói đăng ký",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Gói đăng ký KHÔNG dùng Sản phẩm đã liên kết — mỗi gói có một sản phẩm Pancake riêng, được đặt trong quản trị Đăng ký (hoặc tự động tạo bằng nút \"+ Create\" tại đó).",
|
||||
"Subscription purchased successfully": "Đã mua gói đăng ký thành công",
|
||||
"Subtract": "Trừ",
|
||||
"Success": "Thành công",
|
||||
"Success rate": "Tỷ lệ thành công",
|
||||
@@ -3781,9 +3792,11 @@
|
||||
"Successfully enabled {{count}} model(s)": "Đã bật thành công {{count}} mô hình",
|
||||
"Suffix": "Hậu tố",
|
||||
"Suffix Match": "Khớp hậu tố",
|
||||
"Summarize text": "Tóm tắt văn bản",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "Hoàng hôn",
|
||||
"Super Admin": "Siêu Quản trị viên",
|
||||
"Super Large": "Rất lớn",
|
||||
"Support for high concurrency with automatic load balancing": "Hỗ trợ đồng thời cao với cân bằng tải tự động",
|
||||
"Supported Applications": "Ứng dụng được hỗ trợ",
|
||||
"Supported Imagine Models": "Mô hình Imagine được hỗ trợ",
|
||||
@@ -3794,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Hỗ trợ đánh dấu HTML hoặc nhúng iframe. Nhập mã HTML trực tiếp, hoặc cung cấp một URL đầy đủ để tự động nhúng nó dưới dạng một iframe.",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Hỗ trợ cấu hình bằng một cú nhấp chuột và thích ứng hoàn hảo với cấu hình đa giao thức NewAPI.",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Hỗ trợ PNG, JPG, SVG hoặc WebP. Kích thước khuyến nghị: 128×128 hoặc nhỏ hơn.",
|
||||
"Surprise me": "Gợi ý bất ngờ",
|
||||
"Sustained tokens per second": "Token mỗi giây duy trì",
|
||||
"Swap Face": "Đổi mặt",
|
||||
"Switch affinity on success": "Chuyển ưu tiên khi thành công",
|
||||
@@ -3880,6 +3894,7 @@
|
||||
"Test Model": "Kiểm tra Mô hình",
|
||||
"Test models and prompts from the browser": "Kiểm thử mô hình và prompt trong trình duyệt",
|
||||
"Test selected models": "Kiểm tra các mô hình đã chọn",
|
||||
"Test a model with a starter prompt, or write your own request below.": "Kiểm thử mô hình bằng prompt gợi ý, hoặc viết yêu cầu của riêng bạn bên dưới.",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "Bắt đầu kiểm tra tất cả các kênh đã kích hoạt. Vui lòng làm mới để xem kết quả.",
|
||||
"Testing...": "Đang kiểm tra...",
|
||||
"Text": "Văn bản",
|
||||
|
||||
Vendored
+17
-2
@@ -253,6 +253,7 @@
|
||||
"All conditions must match before this tier is used.": "所有条件都匹配后才会使用此阶梯。",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "所有编辑都是覆盖操作。留空字段将保持当前值不变。",
|
||||
"All files exceed the maximum size.": "所有文件都超过最大尺寸。",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "保存在此浏览器中的所有游乐场消息都将被移除。此操作无法撤销。",
|
||||
"All Groups": "所有分组",
|
||||
"All Models": "所有模型",
|
||||
"All models in use are properly configured.": "所有正在使用的模型都已正确配置。",
|
||||
@@ -311,6 +312,7 @@
|
||||
"Amount options must be a JSON array": "金额选项必须是 JSON 数组",
|
||||
"Amount to pay:": "待支付金额:",
|
||||
"An unexpected error occurred": "发生意外错误",
|
||||
"Analyze data": "分析数据",
|
||||
"and": "和",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "公告已添加。点击 \"保存设置\" 以应用。",
|
||||
"Announcement content": "公告内容",
|
||||
@@ -725,6 +727,8 @@
|
||||
"Clear All Cache": "清空全部缓存",
|
||||
"Clear all filters": "清除所有筛选",
|
||||
"Clear cache for this rule": "清空该规则缓存",
|
||||
"Clear chat history": "清空聊天历史",
|
||||
"Clear chat history?": "清空聊天历史?",
|
||||
"Clear filters": "清除筛选器",
|
||||
"Clear Mapping": "清除映射",
|
||||
"Clear mode flags in prompts": "在提示中清除模式标志",
|
||||
@@ -917,6 +921,7 @@
|
||||
"Continue with Telegram": "使用 Telegram 继续",
|
||||
"Continue with WeChat": "使用 微信 继续",
|
||||
"Contract review, compliance, summarisation": "合同审阅、合规与摘要",
|
||||
"Conversation cleared": "对话已清空",
|
||||
"Control which models are exposed and which groups may use them.": "控制对外暴露的模型,以及哪些分组可以使用它们。",
|
||||
"Controls how much the model thinks before answering": "控制模型回答前的推理深度",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "控制在通行密钥流程中是否需要用户验证(生物识别/PIN)。",
|
||||
@@ -1832,6 +1837,7 @@
|
||||
"Generating...": "生成中...",
|
||||
"Generation quality preset": "生成质量预设",
|
||||
"Generic cache": "通用缓存",
|
||||
"Get advice": "获取建议",
|
||||
"Get notified when balance falls below this value": "当余额低于此值时接收通知",
|
||||
"Get one here": "点此获取",
|
||||
"Get started": "开始使用",
|
||||
@@ -2778,6 +2784,7 @@
|
||||
"Override auto-discovered endpoint": "覆盖自动发现的端点",
|
||||
"Override request headers": "覆盖请求标头",
|
||||
"Override request headers (JSON format)": "覆盖请求头(JSON 格式)",
|
||||
"Override request parameters": "覆盖请求参数",
|
||||
"Override request parameters (JSON format)": "覆盖请求参数 (JSON 格式)",
|
||||
"Override request parameters. Cannot override": "覆盖请求参数。无法覆盖",
|
||||
"Override request parameters. Cannot override stream parameter.": "覆盖请求参数。无法覆盖 stream 参数。",
|
||||
@@ -2852,8 +2859,8 @@
|
||||
"Path Regex (one per line)": "路径正则(每行一个)",
|
||||
"Path:": "路径:",
|
||||
"Pay": "支付",
|
||||
"Pay-as-you-go with real-time usage monitoring": "按量付费,实时监控使用情况",
|
||||
"Pay with Balance": "使用余额支付",
|
||||
"Pay-as-you-go with real-time usage monitoring": "按量付费,实时监控使用情况",
|
||||
"Payment": "支付",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "支付聚合模式:使用你自己的注册公司(离岸实体)入驻。面向企业场景构建。",
|
||||
"Payment Channel": "支付渠道",
|
||||
@@ -3019,6 +3026,7 @@
|
||||
"preset.lavender-dream": "薰衣草梦",
|
||||
"preset.ocean-breeze": "海风",
|
||||
"preset.rose-garden": "玫瑰花园",
|
||||
"preset.simple-large": "超大字体简易",
|
||||
"preset.sunset-glow": "日落霞光",
|
||||
"preset.underground": "暗夜",
|
||||
"Press Enter or comma to add tags": "按 Enter 或逗号添加标签",
|
||||
@@ -3350,6 +3358,7 @@
|
||||
"Resets in:": "将于以下时间重置:",
|
||||
"Resolve Conflicts": "解决冲突",
|
||||
"Resource Configuration": "资源配置",
|
||||
"Responding...": "正在回复...",
|
||||
"Response": "响应",
|
||||
"Response Time": "响应时间",
|
||||
"Responses API Version": "响应 API 版本",
|
||||
@@ -3420,6 +3429,7 @@
|
||||
"Sampling temperature; lower is more deterministic": "采样温度;越低越稳定",
|
||||
"Sandbox mode": "沙盒模式",
|
||||
"Save": "保存",
|
||||
"Save & Submit": "保存并提交",
|
||||
"Save all settings": "保存所有设置",
|
||||
"Save Backup Codes": "保存备份代码",
|
||||
"Save changes": "保存更改",
|
||||
@@ -3707,6 +3717,7 @@
|
||||
"Standard": "标准",
|
||||
"Standard price": "标准价格",
|
||||
"Start": "开始",
|
||||
"Start a playground chat": "开始一场游乐场对话",
|
||||
"Start a conversation to see messages here": "开始对话以在此处查看消息",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "无需注册公司即可开始全球收款。面向独立开发者、OPC 个体经营者和初创团队构建。Waffo Pancake 作为你的登记商户(Merchant of Record),承担全球收款相关的合规负担,包括消费税、开票、订阅管理、退款和拒付。个人开发者可以快速上线,专注产品而不是合规事务。几分钟即可完成入驻,从一个提示词到完整集成。",
|
||||
"Start for free with generous limits. No credit card required.": "免费开始使用,额度充足,无需绑定信用卡。",
|
||||
@@ -3765,10 +3776,10 @@
|
||||
"Subscription First": "优先订阅",
|
||||
"Subscription Management": "订阅管理",
|
||||
"Subscription Only": "仅用订阅",
|
||||
"Subscription purchased successfully": "订阅购买成功",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "管理员在支付网关设置中确认合规条款之前,订阅套餐的创建和修改会被锁定。",
|
||||
"Subscription Plans": "订阅套餐",
|
||||
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "订阅套餐不会使用已绑定的产品。每个套餐都有独立的 Pancake 产品,可在订阅管理中设置,或通过其中的“+ 创建”按钮自动生成。",
|
||||
"Subscription purchased successfully": "订阅购买成功",
|
||||
"Subtract": "减少",
|
||||
"Success": "成功",
|
||||
"Success rate": "成功率",
|
||||
@@ -3781,9 +3792,11 @@
|
||||
"Successfully enabled {{count}} model(s)": "成功启用 {{count}} 个模型",
|
||||
"Suffix": "后缀",
|
||||
"Suffix Match": "后缀匹配",
|
||||
"Summarize text": "总结文本",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "日落霞光",
|
||||
"Super Admin": "超级管理员",
|
||||
"Super Large": "超大",
|
||||
"Support for high concurrency with automatic load balancing": "支持高并发和自动负载均衡",
|
||||
"Supported Applications": "常用应用支持",
|
||||
"Supported Imagine Models": "支持的 Imagine 模型",
|
||||
@@ -3794,6 +3807,7 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "支持 HTML 标记或 iframe 嵌入。直接输入 HTML 代码,或提供完整的 URL 以将其自动嵌入为 iframe。",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "支持一键配置并完美适配 NewAPI 多协议配置",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "支持 PNG、JPG、SVG 或 WebP,建议尺寸不超过 128×128。",
|
||||
"Surprise me": "给我惊喜",
|
||||
"Sustained tokens per second": "持续每秒 Token 数",
|
||||
"Swap Face": "换脸",
|
||||
"Switch affinity on success": "成功后切换亲和",
|
||||
@@ -3880,6 +3894,7 @@
|
||||
"Test Model": "测试模型",
|
||||
"Test models and prompts from the browser": "在浏览器中测试模型和提示词",
|
||||
"Test selected models": "测试所选模型",
|
||||
"Test a model with a starter prompt, or write your own request below.": "使用入门提示词测试模型,或在下方编写自己的请求。",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "测试所有启用的通道已开始。请刷新以查看结果。",
|
||||
"Testing...": "测试中...",
|
||||
"Text": "文本",
|
||||
|
||||
Vendored
+1
@@ -315,6 +315,7 @@ export const STATIC_I18N_KEYS = [
|
||||
'Regex Replace',
|
||||
'Return Error',
|
||||
'Param Override',
|
||||
'Override request parameters',
|
||||
|
||||
// Profile / 2FA
|
||||
'Backed up',
|
||||
|
||||
Vendored
+1
-1
@@ -292,7 +292,7 @@ function formatCurrencyValue(
|
||||
maximumFractionDigits: digits,
|
||||
}).format(adjustedValue)
|
||||
|
||||
return `${meta.symbol}${decimal}`
|
||||
return `${meta.symbol} ${decimal}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+7
-1
@@ -37,6 +37,11 @@ export const THEME_PRESETS = [
|
||||
name: 'Anthropic',
|
||||
swatches: ['oklch(0.984 0.005 95)', 'oklch(0.685 0.142 38)'],
|
||||
},
|
||||
{
|
||||
value: 'simple-large',
|
||||
name: 'Simple Large-font',
|
||||
swatches: ['oklch(0.15 0 0)', 'oklch(0.99 0 0)'],
|
||||
},
|
||||
{
|
||||
value: 'underground',
|
||||
name: 'Underground',
|
||||
@@ -76,7 +81,7 @@ export const THEME_PRESETS = [
|
||||
|
||||
export type ThemePreset = (typeof THEME_PRESETS)[number]['value']
|
||||
export type ThemeRadius = 'default' | 'none' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
export type ThemeScale = 'default' | 'sm' | 'lg'
|
||||
export type ThemeScale = 'default' | 'sm' | 'lg' | 'xl'
|
||||
export type ContentLayout = 'full' | 'centered'
|
||||
|
||||
/**
|
||||
@@ -141,6 +146,7 @@ export const THEME_SCALE_VALUES: ReadonlySet<ThemeScale> = new Set([
|
||||
'default',
|
||||
'sm',
|
||||
'lg',
|
||||
'xl',
|
||||
])
|
||||
|
||||
export const CONTENT_LAYOUT_VALUES: ReadonlySet<ContentLayout> = new Set([
|
||||
|
||||
@@ -38,7 +38,7 @@ const logTypeSearchSchema = z
|
||||
const usageLogsSearchSchema = z.object({
|
||||
page: z.number().optional().catch(1),
|
||||
pageSize: z.number().optional().catch(undefined),
|
||||
type: logTypeSearchSchema,
|
||||
type: logTypeSearchSchema.optional(),
|
||||
filter: z.string().optional().catch(''),
|
||||
model: z.string().optional().catch(''),
|
||||
token: z.string().optional().catch(''),
|
||||
|
||||
Vendored
+8
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
@import 'tailwindcss';
|
||||
@source "../../node_modules/streamdown/dist/*.js";
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
@import '@fontsource-variable/public-sans';
|
||||
@@ -32,6 +33,13 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
/* Shiki dual themes: token colors follow dark theme (pre background stays `bg-background` on the block) */
|
||||
@layer components {
|
||||
.shiki span {
|
||||
color: var(--shiki-light) !important;
|
||||
font-style: var(--shiki-light-font-style) !important;
|
||||
font-weight: var(--shiki-light-font-weight) !important;
|
||||
text-decoration: var(--shiki-light-text-decoration) !important;
|
||||
}
|
||||
|
||||
.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
|
||||
+129
-6
@@ -291,23 +291,136 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--sidebar-ring: oklch(0.6359 0.1699 307.95);
|
||||
}
|
||||
|
||||
/* ── Simple Large-font ────────────────────────────────────────────────── */
|
||||
[data-theme-preset='simple-large'] {
|
||||
--background: oklch(0.99 0 0);
|
||||
--foreground: oklch(0.15 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.15 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.15 0 0);
|
||||
|
||||
--primary: oklch(0.22 0 0);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.93 0 0);
|
||||
--secondary-foreground: oklch(0.15 0 0);
|
||||
--muted: oklch(0.95 0 0);
|
||||
--muted-foreground: oklch(0.36 0 0);
|
||||
--accent: oklch(0.91 0 0);
|
||||
--accent-foreground: oklch(0.15 0 0);
|
||||
|
||||
--destructive: oklch(0.55 0.2 28);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--success: oklch(0.45 0.12 145);
|
||||
--success-foreground: oklch(1 0 0);
|
||||
--warning: oklch(0.72 0.14 75);
|
||||
--warning-foreground: oklch(0.15 0 0);
|
||||
--info: oklch(0.48 0.13 250);
|
||||
--info-foreground: oklch(1 0 0);
|
||||
--neutral: oklch(0.36 0 0);
|
||||
--neutral-foreground: oklch(1 0 0);
|
||||
|
||||
--border: oklch(0.82 0 0);
|
||||
--input: oklch(0.82 0 0);
|
||||
--ring: oklch(0.22 0 0);
|
||||
|
||||
--chart-1: oklch(0.22 0 0);
|
||||
--chart-2: oklch(0.45 0.12 145);
|
||||
--chart-3: oklch(0.48 0.13 250);
|
||||
--chart-4: oklch(0.72 0.14 75);
|
||||
--chart-5: oklch(0.55 0.2 28);
|
||||
|
||||
--sidebar: oklch(0.96 0 0);
|
||||
--sidebar-foreground: oklch(0.15 0 0);
|
||||
--sidebar-primary: oklch(0.22 0 0);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.9 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.15 0 0);
|
||||
--sidebar-border: oklch(0.82 0 0);
|
||||
--sidebar-ring: oklch(0.22 0 0);
|
||||
|
||||
--skeleton-base: oklch(0.9 0 0);
|
||||
--skeleton-highlight: oklch(0.97 0 0);
|
||||
|
||||
--radius: 0.5rem;
|
||||
--text-xs: 0.9rem;
|
||||
--text-sm: 1rem;
|
||||
--text-base: 1.125rem;
|
||||
--text-lg: 1.25rem;
|
||||
--text-xl: 1.45rem;
|
||||
--text-2xl: 1.75rem;
|
||||
--text-3xl: 2.15rem;
|
||||
--spacing: 0.3rem;
|
||||
}
|
||||
.dark [data-theme-preset='simple-large'] {
|
||||
--background: oklch(0.12 0 0);
|
||||
--foreground: oklch(0.98 0 0);
|
||||
--card: oklch(0.18 0 0);
|
||||
--card-foreground: oklch(0.98 0 0);
|
||||
--popover: oklch(0.2 0 0);
|
||||
--popover-foreground: oklch(0.98 0 0);
|
||||
|
||||
--primary: oklch(0.94 0 0);
|
||||
--primary-foreground: oklch(0.12 0 0);
|
||||
--secondary: oklch(0.24 0 0);
|
||||
--secondary-foreground: oklch(0.98 0 0);
|
||||
--muted: oklch(0.24 0 0);
|
||||
--muted-foreground: oklch(0.82 0 0);
|
||||
--accent: oklch(0.3 0 0);
|
||||
--accent-foreground: oklch(0.98 0 0);
|
||||
|
||||
--destructive: oklch(0.68 0.19 25);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
--success: oklch(0.72 0.13 145);
|
||||
--success-foreground: oklch(0.12 0 0);
|
||||
--warning: oklch(0.82 0.13 75);
|
||||
--warning-foreground: oklch(0.12 0 0);
|
||||
--info: oklch(0.72 0.12 250);
|
||||
--info-foreground: oklch(0.12 0 0);
|
||||
--neutral: oklch(0.82 0 0);
|
||||
--neutral-foreground: oklch(0.12 0 0);
|
||||
|
||||
--border: oklch(1 0 0 / 18%);
|
||||
--input: oklch(1 0 0 / 24%);
|
||||
--ring: oklch(0.94 0 0);
|
||||
|
||||
--chart-1: oklch(0.94 0 0);
|
||||
--chart-2: oklch(0.72 0.13 145);
|
||||
--chart-3: oklch(0.72 0.12 250);
|
||||
--chart-4: oklch(0.82 0.13 75);
|
||||
--chart-5: oklch(0.68 0.19 25);
|
||||
|
||||
--sidebar: oklch(0.16 0 0);
|
||||
--sidebar-foreground: oklch(0.98 0 0);
|
||||
--sidebar-primary: oklch(0.94 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.12 0 0);
|
||||
--sidebar-accent: oklch(0.28 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.98 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 18%);
|
||||
--sidebar-ring: oklch(0.94 0 0);
|
||||
|
||||
--skeleton-base: oklch(0.24 0 0);
|
||||
--skeleton-highlight: oklch(0.34 0 0);
|
||||
}
|
||||
|
||||
/* ── Semantic surface bridge ──────────────────────────────────────────── */
|
||||
/* Color presets should tint the surfaces most components actually use, not
|
||||
* only primary buttons. These derived tokens keep the app theme-aware without
|
||||
* duplicating per-component dark-mode overrides.
|
||||
*
|
||||
* NOTE: `:not()` contributes its argument's specificity, so this selector
|
||||
* resolves to (0,2,0). Presets that define bespoke surfaces below need to
|
||||
* resolves to (0,3,0). Presets that define bespoke surfaces below need to
|
||||
* either match that specificity or opt out here — the latter is cleaner.
|
||||
*
|
||||
* Opt-outs:
|
||||
* - `default`: keeps neutral surfaces from :root.
|
||||
* - `anthropic`: warm cream surfaces are a brand choice, NOT a primary-mix
|
||||
* derivation (the Anthropic system deliberately uses warm neutrals for
|
||||
* cards/borders rather than tinting them with the clay accent). */
|
||||
* cards/borders rather than tinting them with the clay accent).
|
||||
* - `simple-large`: keeps intentionally neutral, high-contrast surfaces. */
|
||||
[data-theme-preset]:not([data-theme-preset='default']):not(
|
||||
[data-theme-preset='anthropic']
|
||||
) {
|
||||
):not([data-theme-preset='simple-large']) {
|
||||
--card: color-mix(in oklch, var(--primary) 3%, var(--background));
|
||||
--popover: color-mix(in oklch, var(--primary) 5%, var(--background));
|
||||
--muted: color-mix(in oklch, var(--primary) 7%, var(--background));
|
||||
@@ -332,7 +445,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
.dark
|
||||
[data-theme-preset]:not([data-theme-preset='default']):not(
|
||||
[data-theme-preset='anthropic']
|
||||
) {
|
||||
):not([data-theme-preset='simple-large']) {
|
||||
--card: color-mix(in oklch, var(--primary) 8%, var(--background));
|
||||
--popover: color-mix(in oklch, var(--primary) 12%, var(--background));
|
||||
--muted: color-mix(in oklch, var(--primary) 12%, var(--background));
|
||||
@@ -362,7 +475,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*
|
||||
* Anthropic is opted out of the semantic surface bridge above so these
|
||||
* bespoke warm-neutral surface tokens win the cascade. Without the opt-out,
|
||||
* the bridge selector (specificity 0,2,0 because of `:not()`) would override
|
||||
* the bridge selector (specificity 0,3,0 because of `:not()`) would override
|
||||
* this block (specificity 0,1,0) and tint every surface with the clay
|
||||
* accent — producing the peach/pink look that doesn't match Anthropic.
|
||||
*
|
||||
@@ -574,7 +687,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
}
|
||||
|
||||
/* ── Density scale ────────────────────────────────────────────────────── */
|
||||
/* `sm` = compact UI; `lg` = comfortable. Default (no attribute) keeps Tailwind defaults. */
|
||||
/* `sm` = compact UI; `lg` = comfortable; `xl` = extra readable. Default keeps Tailwind defaults. */
|
||||
[data-theme-scale='sm'] {
|
||||
--text-xs: 0.7rem;
|
||||
--text-sm: 0.78rem;
|
||||
@@ -595,6 +708,16 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
--text-3xl: 2rem;
|
||||
--spacing: 0.28rem;
|
||||
}
|
||||
[data-theme-scale='xl'] {
|
||||
--text-xs: 0.9rem;
|
||||
--text-sm: 1rem;
|
||||
--text-base: 1.125rem;
|
||||
--text-lg: 1.25rem;
|
||||
--text-xl: 1.45rem;
|
||||
--text-2xl: 1.75rem;
|
||||
--text-3xl: 2.15rem;
|
||||
--spacing: 0.3rem;
|
||||
}
|
||||
|
||||
/* ── Content layout ───────────────────────────────────────────────────── */
|
||||
/* `centered` clamps inset content to a comfortable reading width on large screens.
|
||||
|
||||
Reference in New Issue
Block a user