Compare commits

...

61 Commits

Author SHA1 Message Date
QuentinHsu f53b557a17 perf(playground): improve chat markdown rendering
- refine assistant and user message surfaces so chat content matches the app UI.
- normalize markdown typography, tables, images, lists, blockquotes, and details rendering.
- add indentation cues for collapsible reasoning and source sections.
2026-06-01 00:30:06 +08:00
QuentinHsu 4372abd787 feat(playground): add chat history clearing
- add a toolbar action that is enabled only when saved playground messages exist.
- confirm destructive clears before removing browser-stored conversation state.
- add localized strings for the action, dialog, and completion toast.
2026-05-31 14:23:50 +08:00
QuentinHsu 9b633a4131 fix(playground): constrain markdown code block height
- collapse long playground code blocks after a short preview instead of waiting for very large snippets
- cap expanded code blocks so long responses scroll inside the code block
- keep generic code block usage unconstrained unless a caller opts in
2026-05-30 19:13:50 +08:00
QuentinHsu ffb1e8e97a perf(playground): improve markdown code blocks
- render fenced markdown code with syntax highlighting, line numbers, and fallback plain text
- add copy, download, and collapse controls for playground AI responses
- tighten code block layout and theme token styles for responsive markdown rendering
2026-05-30 19:07:53 +08:00
QuentinHsu fa334c1eb0 perf(playground): refine message editing experience
- present message edits in a focused bordered editor panel
- add unsaved-change state, reset, and cancel confirmation flows
- improve mobile touch targets and keyboard shortcuts for editing
2026-05-30 18:46:07 +08:00
QuentinHsu fbcaf75b62 perf(playground): add error recovery actions
- show retry, edit, and delete actions inside error message alerts
- route edit recovery to the previous user prompt when available
- keep recovery controls touch-friendly on mobile layouts
2026-05-30 18:42:24 +08:00
QuentinHsu 47e912123c perf(playground): improve mobile message actions
- collapse mobile message actions into a touch-friendly dropdown menu
- keep the desktop hover action strip unchanged for pointer workflows
- share one action list between desktop buttons and the mobile menu
2026-05-30 18:37:08 +08:00
QuentinHsu 4f03641ac7 perf(playground): add starter empty state
- show starter prompts in the empty playground chat area
- wire empty-state prompt selection into the existing send flow
- add localized copy for the new empty state
2026-05-30 18:32:11 +08:00
QuentinHsu efc9c5844b perf(playground): improve mobile input controls
- split mobile input controls into selector and action rows
- keep the desktop input footer compact while reducing mobile control crowding
2026-05-30 18:16:34 +08:00
QuentinHsu 5a5286967d refactor(playground): extract pending assistant check
- centralize pending assistant message detection in streaming utilities
- reuse the helper when sanitizing stored playground messages
2026-05-30 11:08:24 +08:00
QuentinHsu 80a54b5b4b refactor(playground): centralize stream cleanup
- reuse one stream cleanup path for completion, errors, startup failures, and manual stops
- preserve the current-source guard when closing SSE streams
2026-05-30 11:06:42 +08:00
QuentinHsu 6ba23572b2 refactor(playground): extract non-stream response handling
- move chat completion response choice handling into message streaming utilities
- keep the chat handler focused on request lifecycle and error routing
2026-05-30 11:04:59 +08:00
QuentinHsu 378eed2bd4 refactor(playground): replace raw message role checks
- use shared message role constants in conversation edit handling
- avoid raw assistant role literals when validating API messages
2026-05-30 11:02:49 +08:00
QuentinHsu 61717ee53b refactor(playground): extract message content display checks
- move loader and content visibility decisions into local helper functions
- keep message content state assembly focused on composing render state
2026-05-30 10:59:29 +08:00
QuentinHsu f738ee481c refactor(playground): centralize error message checks
- add a shared helper for identifying error messages
- remove direct status string checks from message content rendering
2026-05-30 10:57:39 +08:00
QuentinHsu 0f94c07f16 refactor(playground): extract input submit text helper
- move prompt submit text validation into input control utilities
- let the input component submit only when a concrete text value is available
2026-05-30 10:55:47 +08:00
QuentinHsu d75e393b11 refactor(playground): extract option error messages
- move option load error message selection into playground option utilities
- keep the options hook focused on query effects and fallback updates
2026-05-30 10:53:08 +08:00
QuentinHsu eef921d188 refactor(playground): extract message removal helper
- move delete-message filtering into conversation message utilities.

- keep the conversation hook focused on action orchestration.
2026-05-30 10:50:13 +08:00
QuentinHsu b6ad800e77 refactor(playground): extract stream protocol checks
- move SSE done-message and closed-ready-state checks into stream utilities.

- keep the stream request hook focused on event handling flow.
2026-05-30 10:44:17 +08:00
QuentinHsu c82242f0d2 refactor(playground): extract input tool state
- move attachment action metadata and development notices into input tool utilities.

- keep the input tools component focused on menu and button rendering.
2026-05-30 10:39:59 +08:00
QuentinHsu a8c19eec50 refactor(playground): extract assistant message state checks
- move final and pending assistant status checks into streaming utilities.

- keep the chat handler focused on request lifecycle updates.
2026-05-30 10:35:18 +08:00
QuentinHsu 0f625f33a0 refactor(playground): extract suggestion display state
- move suggestion class selection into a pure helper.

- keep the suggestions component focused on translation and rendering.
2026-05-30 10:30:08 +08:00
QuentinHsu 902593926a refactor(playground): extract chat render state
- move editing content lookup and per-message render flags into conversation helpers.

- keep the chat component focused on mapping messages to editor and content views.
2026-05-30 10:23:47 +08:00
QuentinHsu b4e7c48e42 refactor(playground): extract message error state
- move error kind, fallback content, and admin visibility checks into a pure helper.

- centralize the model pricing settings path used by the error action.
2026-05-30 10:19:13 +08:00
QuentinHsu f03c8cc709 refactor(playground): extract message editor state
- move save eligibility and submit visibility checks into a pure helper.

- keep the editor component focused on textarea and button rendering.
2026-05-30 10:14:50 +08:00
QuentinHsu 6aba2b3eec refactor(playground): extract message content state
- move source, reasoning, loader, and body visibility checks into a pure helper.

- use a discriminated state shape so rendered reasoning content stays type-safe.
2026-05-30 10:10:06 +08:00
QuentinHsu 80ee5244d9 refactor(playground): extract input control state
- move submit, stop, and selector state derivation into a pure helper.

- keep input controls focused on rendering model selectors and action buttons.
2026-05-30 10:00:54 +08:00
QuentinHsu f87af88ca5 refactor(playground): extract message action helpers
- move message action state derivation into focused utilities.

- keep the action component focused on guarded handlers and rendering.
2026-05-30 09:55:36 +08:00
QuentinHsu 5816f69c20 refactor(playground): extract option fallback helpers
- move model and group fallback selection into focused playground utilities.
- keep the options hook focused on query results, toasts, and config updates.
2026-05-30 09:51:57 +08:00
QuentinHsu 3606367104 refactor(playground): extract state initialization helpers
- move playground initial state loading into focused utility helpers.
- centralize message state updater resolution outside the React state hook.
2026-05-30 09:34:15 +08:00
QuentinHsu 0339e36246 refactor(playground): extract conversation message helpers
- move send, regenerate, and edit message list construction into focused utilities.
- keep the conversation hook focused on edit state and update dispatch.
2026-05-30 09:30:47 +08:00
QuentinHsu 1f3eb1e419 refactor(playground): extract stream ready state checks
- move SSE ready-state status handling into stream utilities.
- keep weak source status typing outside the stream request hook.
2026-05-29 23:45:57 +08:00
QuentinHsu 6b5ee783f1 refactor(playground): extract stream message parsing
- move SSE delta parsing into a shared stream utility.
- keep the stream request hook focused on lifecycle handling and update dispatch.
2026-05-29 23:26:59 +08:00
QuentinHsu 2f16326562 refactor(playground): centralize assistant completion state
- add a helper for finalizing assistant messages with complete status.
- reuse the helper in stream completion and stop-generation paths.
2026-05-29 23:17:59 +08:00
QuentinHsu 76469cb944 refactor(playground): extract completion choice handling
- move non-streaming choice application into the message streaming utilities.
- keep the chat handler focused on request orchestration and message updates.
2026-05-29 23:12:53 +08:00
QuentinHsu 47d4d74bd6 refactor(playground): extract message update utilities
- move assistant message update helpers into a focused playground utility.
- keep error-state message updates separate from core message construction helpers.
2026-05-29 23:04:15 +08:00
QuentinHsu 59f3758175 refactor(playground): extract message streaming utilities
- move stream chunk application and message finalization into a dedicated utility.
- keep stored message sanitization with the streaming lifecycle helpers.
2026-05-29 22:51:30 +08:00
QuentinHsu 0deab07bb6 refactor(playground): extract message reasoning parser
- move think tag parsing into a dedicated playground message utility.
- export the parser through the shared playground lib barrel for consistent imports.
2026-05-29 22:43:47 +08:00
QuentinHsu 7465f682f8 refactor(playground): extract streaming chunk updates
- move reasoning and content chunk application into a message utility so the chat handler only wires stream events.
- preserve error-state skipping, reasoning accumulation, and content streaming behavior for assistant messages.
2026-05-29 22:28:50 +08:00
QuentinHsu 894f25ca51 refactor(playground): extract request error parsing
- move non-stream request error extraction into a shared utility so the chat handler stays focused on request flow.
- preserve the existing response message, error code, and fallback priority for failed chat completions.
2026-05-29 14:53:59 +08:00
QuentinHsu 12b103e9b6 refactor(playground): extract stream error parsing
- move SSE error payload parsing into a reusable stream utility so the request hook stays focused on lifecycle handling.
- preserve existing error message, error code, and fallback behavior for raw or empty stream errors.
2026-05-29 14:36:27 +08:00
QuentinHsu fdffe43533 refactor(playground): extract message editor
- move inline message editing controls into a dedicated editor component so the chat list stays focused on rendering flow.
- preserve save, save-and-submit, cancel, and disabled-state behavior for edited messages.
2026-05-29 10:55:33 +08:00
QuentinHsu 809e1dce6d refactor(playground): extract message content display
- move sources, reasoning, loading, error, and response rendering into a dedicated message content component.
- keep the chat list focused on message iteration, edit state, and action wiring without changing display behavior.
2026-05-29 10:49:48 +08:00
QuentinHsu 43c003e8e1 refactor(playground): extract input controls
- move model, group, send, and stop controls into a focused component so the input only manages compose state.
- preserve existing disabled states and generation button behavior while isolating control rendering.
2026-05-29 10:42:38 +08:00
QuentinHsu b0bf0b949b refactor(playground): extract input tools
- move attachment and search controls into a dedicated component so the prompt input stays focused on compose state.
- keep existing development toast behavior and disabled handling while centralizing tool metadata.
2026-05-29 10:10:48 +08:00
QuentinHsu 2d94a24912 refactor(playground): extract prompt suggestions
- move static prompt suggestion rendering into a focused component so the input stays centered on compose controls.
- preserve translated suggestion submission behavior while isolating icon metadata from the input form.
2026-05-29 10:07:33 +08:00
QuentinHsu a297c00cc3 refactor(playground): extract options loading hook
- move model and group queries into a dedicated hook so the page component stays focused on layout wiring.
- preserve existing fallback selection and error toast behavior while reusing the hook through the playground barrel export.
2026-05-29 10:04:50 +08:00
QuentinHsu 5489c68eec fix(usage-logs): handle mobile card row fields safely
- read generic table row fields through an unknown-safe helper to avoid invalid property access on unconstrained data.
- keep mobile time status inputs typed as unknown while preserving existing rendering behavior.
2026-05-29 10:00:30 +08:00
QuentinHsu c40d00e740 refactor(playground): split storage schemas
- move Playground storage validation schemas into a dedicated module.

- keep storage read and write logic focused on migration, trimming, and persistence.

- preserve the existing storage envelope and validation behavior.
2026-05-29 09:57:24 +08:00
QuentinHsu e6e86b8e8c refactor(playground): centralize message content access
- route chat rendering, copy actions, and error display through shared message helpers.

- reuse the current-version update helper for non-streaming assistant responses.

- keep message version details behind utility functions to reduce future model churn.
2026-05-29 09:31:30 +08:00
QuentinHsu 3f2107fb6d fix(playground): validate persisted chat state
- wrap saved Playground state with a storage version while still reading legacy values.

- validate config, parameter toggles, and messages before restoring them from localStorage.

- cap stored chat history to the latest messages to avoid oversized or stale state.
2026-05-29 09:21:41 +08:00
QuentinHsu 8a3e353231 refactor(playground): streamline chat request state
- extract conversation actions from the page component to keep message flow logic reusable.
- unify streaming and non-streaming generation state, including abort support for non-stream requests.
- simplify message rendering and payload construction while localizing Playground prompts.
2026-05-29 09:21:40 +08:00
同語 e8c836d705 fix(web): improve form validation error focus #5163
Merge pull request #5163 from QuantumNous/fix/form-validation-focus
2026-05-28 23:34:02 +08:00
QuentinHsu e79cee1e9e perf(form): focus first validation error on submit
- scope validation queries with a form root id so feedback stays inside the submitted form.
- scroll to the earliest invalid control or message and move focus without fighting scroll position.
- avoid handling the same failed submit twice to keep retries from jumping unexpectedly.
2026-05-28 15:10:17 +08:00
QuentinHsu 63ead2bf7f chore(repo): ignore playwright mcp artifacts 2026-05-28 15:02:00 +08:00
CaIon 5b86ce0d70 fix: optimize batch update process 2026-05-27 13:23:05 +08:00
CaIon 74985fa877 fix: keep token log filters exact 2026-05-26 21:17:25 +08:00
yyhhyyyyyy 1d32037364 fix: keep usage log filters exact unless wildcard is explicit (#5097) 2026-05-26 21:00:32 +08:00
CaIon dc245ae764 fix(web): improve channel and usage log UI
Fixes #5121
2026-05-26 20:28:28 +08:00
CaIon f8add4ca49 feat(theme): add simple-large preset, xl scale and clean up channel badge dots
Implement the Simple Large-font theme preset and xl font scale options to enhance interface accessibility. Remove status indicator dots from channel badges in logs to keep the table layout visual and clean.
2026-05-26 18:35:51 +08:00
t0ng7u 65f8afe922 🐛 fix(system-settings): resolve save detection and number input NaN issues
System settings forms that used flat dotted API keys (e.g.
`performance_setting.monitor_cpu_threshold`) with React Hook Form were
broken: RHF stores dotted paths as nested objects on update, while dirty
checks and submit comparisons still read flat keys from defaults. Users
could edit values but always saw "No changes to save".

Refactor affected sections to use nested Zod schemas and default values
for RHF, with explicit helpers to convert between nested form state and
flat API keys. Track a normalized baseline in refs for accurate change
detection and post-save resets.

Add `safeNumberFieldProps` to prevent native `<input type="number">`
from writing NaN into form state when cleared. NaN caused Zod validation
to fail silently and made the save button appear unresponsive. The
helper ignores non-finite updates so controlled inputs snap back to the
last valid value, matching legacy Semi InputNumber behavior.

Sections refactored for dotted-key handling:
- maintenance/performance-section
- models/grok-settings-card
- auth/passkey-section
- auth/oauth-section
- auth/section-registry (pass attachment_preference raw; normalize in section)

Sections migrated to safeNumberFieldProps:
- maintenance/performance-section
- models/grok-settings-card
- integrations/monitoring-settings-section
- integrations/payment-settings-section
- integrations/creem-product-dialog
- general/pricing-section (USD exchange rate)
- general/system-behavior-section
- content/dashboard-section

Optional numeric fields (e.g. custom currency exchange rate) keep their
existing empty-to-undefined semantics and are intentionally unchanged.
2026-05-26 15:43:56 +08:00
88 changed files with 5313 additions and 1571 deletions
+1
View File
@@ -35,3 +35,4 @@ data/
.test
token_estimator_test.go
skills-lock.json
.playwright-mcp
+31 -20
View File
@@ -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)
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
@@ -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 />
@@ -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
View File
@@ -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
@@ -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>
)
}
@@ -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>
)
}
@@ -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?')}
/>
</>
)
}
@@ -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>
)
}
@@ -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}
</>
)}
</>
)
}
@@ -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>
)
}
+2
View File
@@ -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
View File
@@ -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
})
@@ -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
View File
@@ -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,
}
}
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+112
View File
@@ -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
View File
@@ -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 />
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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'
/>
)
@@ -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>
)
@@ -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,
},
@@ -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} />
+17 -2
View File
@@ -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",
+17 -2
View File
@@ -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 à lencaissement 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, dune 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 dabonnement sont verrouillées jusqu’à ce que ladministrateur 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 dabonnement nutilisent PAS le produit associé : chaque forfait dispose de son propre produit Pancake dédié, défini dans ladministration 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",
+17 -2
View File
@@ -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": "テキスト",
+17 -2
View File
@@ -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": "Текст",
+17 -2
View File
@@ -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",
+17 -2
View File
@@ -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": "文本",
+1
View File
@@ -315,6 +315,7 @@ export const STATIC_I18N_KEYS = [
'Regex Replace',
'Return Error',
'Param Override',
'Override request parameters',
// Profile / 2FA
'Backed up',
+1 -1
View File
@@ -292,7 +292,7 @@ function formatCurrencyValue(
maximumFractionDigits: digits,
}).format(adjustedValue)
return `${meta.symbol}${decimal}`
return `${meta.symbol} ${decimal}`
}
/**
+7 -1
View File
@@ -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(''),
+8
View File
@@ -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
View File
@@ -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.