refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+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 type { AuthUser } from '@/stores/auth-store'
import { ROLE } from './roles'
export type AdminPermissionMatrix = Record<string, Record<string, boolean>>
export type AdminCapabilities = AdminPermissionMatrix
export const ADMIN_PERMISSION_RESOURCES = {
CHANNEL: 'channel',
} as const
export const ADMIN_PERMISSION_ACTIONS = {
READ: 'read',
OPERATE: 'operate',
WRITE: 'write',
SENSITIVE_WRITE: 'sensitive_write',
SECRET_VIEW: 'secret_view',
} as const
// The role whose baseline grants are used as defaults in the permission editor.
export const ADMIN_ROLE_KEY = 'admin'
// The permission catalog (resources, actions, labels and role baselines) is owned
// by the backend authz package and fetched from GET /api/authz/catalog. It is
// intentionally NOT duplicated here so the schema stays defined in one place.
// These types mirror the backend JSON shape.
export interface PermissionActionDef {
action: string
label_key: string
description_key: string
}
export interface PermissionResourceDef {
resource: string
label_key: string
actions: PermissionActionDef[]
}
export interface PermissionRoleDef {
key: string
name: string
built_in: boolean
superuser: boolean
grants: AdminPermissionMatrix
}
export interface PermissionCatalog {
resources: PermissionResourceDef[]
roles: PermissionRoleDef[]
}
export const EMPTY_PERMISSION_CATALOG: PermissionCatalog = {
resources: [],
roles: [],
}
export function hasPermission(
user: AuthUser | null | undefined,
resource: string,
action: string
): boolean {
if (!user) return false
if (user.role === ROLE.SUPER_ADMIN) return true
return user.permissions?.admin_permissions?.[resource]?.[action] === true
}
// roleGrants returns the baseline grant matrix for the given role key.
export function roleGrants(
catalog: PermissionCatalog,
roleKey: string
): AdminPermissionMatrix {
return catalog.roles.find((role) => role.key === roleKey)?.grants ?? {}
}
// normalizeAdminPermissions produces a full matrix for the catalog, filling any
// value missing from `value` with the admin role's baseline grant.
export function normalizeAdminPermissions(
value: AdminPermissionMatrix | null | undefined,
catalog: PermissionCatalog
): AdminPermissionMatrix {
const baseline = roleGrants(catalog, ADMIN_ROLE_KEY)
const normalized: AdminPermissionMatrix = {}
for (const resource of catalog.resources) {
const actions: Record<string, boolean> = {}
for (const action of resource.actions) {
actions[action.action] =
value?.[resource.resource]?.[action.action] ??
baseline[resource.resource]?.[action.action] ??
false
}
normalized[resource.resource] = actions
}
return normalized
}
+123
View File
@@ -0,0 +1,123 @@
/*
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 { api } from '@/lib/http-client'
export {
applyAuthBundle,
applyAuthRotation,
bootstrapAuthentication,
clearAuthenticatedClientState,
clearAuthentication,
getCommonHeaders,
getFreshAuthHeaders,
isAuthBundle,
refreshAuthentication,
AuthRotationError,
} from '@/lib/auth-session'
export type { AuthTokenRotation, RefreshOutcome } from '@/lib/auth-session'
export { api }
export type { ApiRequestConfig } from '@/lib/http-client'
// ============================================================================
// User APIs
// ============================================================================
export async function getSelf() {
const res = await api.get('/api/user/self', {
skipErrorHandler: true,
})
return res.data
}
export async function getUserModels(): Promise<{
success: boolean
message?: string
data?: string[]
}> {
const res = await api.get('/api/user/models')
return res.data
}
export async function getUserGroups(): Promise<{
success: boolean
message?: string
data?: Record<string, { desc: string; ratio: number | string }>
}> {
const res = await api.get('/api/user/self/groups')
return res.data
}
// ============================================================================
// System APIs
// ============================================================================
export async function getStatus() {
const res = await api.get('/api/status')
return res.data?.data as Record<string, unknown>
}
export async function getNotice(): Promise<{
success: boolean
message?: string
data?: string
}> {
const res = await api.get('/api/notice')
return res.data
}
// ============================================================================
// 2FA Management APIs
// ============================================================================
export async function get2FAStatus() {
const res = await api.get('/api/user/2fa/status')
return res.data
}
export async function setup2FA() {
const res = await api.post('/api/user/2fa/setup')
return res.data
}
export async function enable2FA(code: string) {
const res = await api.post(
'/api/user/2fa/enable',
{ code },
{ acceptAuthRotation: true }
)
return res.data
}
export async function disable2FA(code: string) {
const res = await api.post(
'/api/user/2fa/disable',
{ code },
{ acceptAuthRotation: true }
)
return res.data
}
export async function regenerate2FABackupCodes(code: string) {
const res = await api.post(
'/api/user/2fa/backup_codes',
{ code },
{ acceptAuthRotation: true }
)
return res.data
}
+119
View File
@@ -0,0 +1,119 @@
/*
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
*/
export type AuthSessionSyncEvent = {
kind: 'authenticated' | 'signed_out'
sid: string
source: string
nonce: string
timestamp: number
}
const AUTH_SYNC_CHANNEL = 'new-api:auth-session'
const AUTH_SYNC_STORAGE_KEY = 'new-api:auth-session:event'
function randomIdentifier(): string {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
const authSyncSource = randomIdentifier()
let authSyncPublisher: BroadcastChannel | null = null
function isAuthSessionSyncEvent(value: unknown): value is AuthSessionSyncEvent {
if (!value || typeof value !== 'object') return false
const event = value as Partial<AuthSessionSyncEvent>
return (
(event.kind === 'authenticated' || event.kind === 'signed_out') &&
typeof event.sid === 'string' &&
event.sid.length > 0 &&
typeof event.source === 'string' &&
typeof event.nonce === 'string' &&
typeof event.timestamp === 'number'
)
}
export function publishAuthSessionEvent(
kind: AuthSessionSyncEvent['kind'],
sid: string
): void {
if (typeof window === 'undefined' || !sid) return
const event: AuthSessionSyncEvent = {
kind,
sid,
source: authSyncSource,
nonce: randomIdentifier(),
timestamp: Date.now(),
}
if (typeof BroadcastChannel !== 'undefined') {
authSyncPublisher ??= new BroadcastChannel(AUTH_SYNC_CHANNEL)
authSyncPublisher.postMessage(event)
return
}
try {
window.localStorage.setItem(AUTH_SYNC_STORAGE_KEY, JSON.stringify(event))
window.localStorage.removeItem(AUTH_SYNC_STORAGE_KEY)
} catch {
// Cross-tab synchronization is best-effort when storage is unavailable.
}
}
export function subscribeAuthSessionEvents(
listener: (event: AuthSessionSyncEvent) => void
): () => void {
if (typeof window === 'undefined') return () => undefined
const deliver = (value: unknown) => {
if (
isAuthSessionSyncEvent(value) &&
value.source !== authSyncSource &&
Math.abs(Date.now() - value.timestamp) < 60_000
) {
listener(value)
}
}
if (typeof BroadcastChannel !== 'undefined') {
const channel = new BroadcastChannel(AUTH_SYNC_CHANNEL)
const handleMessage = (message: MessageEvent<unknown>) => {
deliver(message.data)
}
channel.addEventListener('message', handleMessage)
return () => {
channel.removeEventListener('message', handleMessage)
channel.close()
}
}
const handleStorage = (event: StorageEvent) => {
if (event.key !== AUTH_SYNC_STORAGE_KEY || !event.newValue) return
try {
deliver(JSON.parse(event.newValue))
} catch {
// Ignore malformed same-origin storage events.
}
}
window.addEventListener('storage', handleStorage)
return () => {
window.removeEventListener('storage', handleStorage)
}
}
+304
View File
@@ -0,0 +1,304 @@
/*
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 assert from 'node:assert/strict'
import { afterEach, describe, test } from 'node:test'
import { QueryClient } from '@tanstack/react-query'
import { useAuthStore, type AuthBundle } from '../stores/auth-store'
import {
applyAuthRotation,
bootstrapAuthentication,
clearAuthenticatedClientState,
createRefreshRunner,
isAuthBundle,
type AuthRefreshRuntime,
} from './auth-session'
const bundle: AuthBundle = {
access_token: 'access-token',
token_type: 'Bearer',
access_expires_at: Math.floor(Date.now() / 1000) + 600,
user: {
id: 42,
username: 'test-user',
role: 1,
},
session: {
sid: 'session-a',
current: true,
login_method: 'password',
ip: '127.0.0.1',
user_agent: 'test',
created_at: 100,
last_active_at: 100,
expires_at: 1000,
},
}
afterEach(() => {
useAuthStore.getState().auth.reset('idle')
})
describe('authentication session coordination', () => {
test('bootstrap distinguishes a completed anonymous check from an active session', async () => {
useAuthStore.getState().auth.reset('complete')
assert.deepEqual(await bootstrapAuthentication(), { kind: 'anonymous' })
useAuthStore.getState().auth.setBundle(bundle)
assert.deepEqual(await bootstrapAuthentication(), {
kind: 'authenticated',
bundle,
})
})
test('a session mismatch clears only local state and retries without the stale SID', async () => {
let expectedSID: string | undefined = bundle.session.sid
const requestedSIDs: Array<string | undefined> = []
const clears: Array<[boolean, string | undefined]> = []
const accepted: AuthBundle[] = []
const runtime: AuthRefreshRuntime = {
request: async (sid) => {
requestedSIDs.push(sid)
if (requestedSIDs.length === 1) {
return {
status: 409,
data: { code: 'AUTH_SESSION_MISMATCH' },
}
}
return { status: 200, data: { success: true, data: bundle } }
},
getExpectedSID: () => expectedSID,
parseBundle: (value) => (isAuthBundle(value) ? value : null),
acceptBundle: (acceptedBundle) => accepted.push(acceptedBundle),
clear: (synchronizeTabs, bootstrapState) => {
clears.push([synchronizeTabs, bootstrapState])
expectedSID = undefined
},
markTransient: () => undefined,
wait: async () => undefined,
}
const outcome = await createRefreshRunner(runtime)()
assert.equal(outcome.kind, 'authenticated')
assert.deepEqual(requestedSIDs, [bundle.session.sid, undefined])
assert.deepEqual(clears, [[false, 'idle']])
assert.deepEqual(accepted, [bundle])
})
test('a rejected refresh confirms anonymous state and synchronizes sign-out', async () => {
const clears: Array<[boolean, string | undefined]> = []
const runtime: AuthRefreshRuntime = {
request: async () => ({ status: 401 }),
getExpectedSID: () => bundle.session.sid,
parseBundle: () => null,
acceptBundle: () => undefined,
clear: (synchronizeTabs, bootstrapState) => {
clears.push([synchronizeTabs, bootstrapState])
},
markTransient: () => undefined,
wait: async () => undefined,
}
assert.deepEqual(await createRefreshRunner(runtime)(), {
kind: 'anonymous',
})
assert.deepEqual(clears, [[true, undefined]])
})
test('a temporary refresh failure remains retryable without clearing the session', async () => {
let transientCount = 0
let clearCount = 0
const runtime: AuthRefreshRuntime = {
request: async () => ({ status: 503, error: new Error('unavailable') }),
getExpectedSID: () => bundle.session.sid,
parseBundle: () => null,
acceptBundle: () => undefined,
clear: () => {
clearCount += 1
},
markTransient: () => {
transientCount += 1
},
wait: async () => undefined,
}
const outcome = await createRefreshRunner(runtime)()
assert.equal(outcome.kind, 'transient_error')
assert.equal(clearCount, 0)
assert.equal(transientCount, 1)
})
test('an exhausted refresh race clears the unusable local session', async () => {
const requestedDelays: number[] = []
const clears: Array<[boolean, string | undefined]> = []
const runtime: AuthRefreshRuntime = {
request: async () => ({
status: 409,
data: { code: 'AUTH_REFRESH_RACE' },
}),
getExpectedSID: () => bundle.session.sid,
parseBundle: () => null,
acceptBundle: () => undefined,
clear: (synchronizeTabs, bootstrapState) => {
clears.push([synchronizeTabs, bootstrapState])
},
markTransient: () => undefined,
wait: async (delay) => {
requestedDelays.push(delay)
},
}
assert.deepEqual(await createRefreshRunner(runtime)(), {
kind: 'out_of_sync',
code: 'AUTH_REFRESH_RACE',
})
assert.deepEqual(requestedDelays, [80, 200, 500])
assert.deepEqual(clears, [[false, undefined]])
})
test('an unexpected successful response is treated as out of sync', async () => {
let cleared = false
const runtime: AuthRefreshRuntime = {
request: async () => ({ status: 200, data: { success: true } }),
getExpectedSID: () => bundle.session.sid,
parseBundle: () => null,
acceptBundle: () => undefined,
clear: () => {
cleared = true
},
markTransient: () => undefined,
wait: async () => undefined,
}
assert.deepEqual(await createRefreshRunner(runtime)(), {
kind: 'out_of_sync',
code: 'AUTH_INVALID_REFRESH_RESPONSE',
})
assert.equal(cleared, true)
})
test('a refresh response cannot restore credentials after a newer auth operation', async () => {
let current = true
let accepted = false
const runtime: AuthRefreshRuntime = {
request: async () => {
current = false
return { status: 200, data: { success: true, data: bundle } }
},
getExpectedSID: () => bundle.session.sid,
parseBundle: (value) => (isAuthBundle(value) ? value : null),
acceptBundle: () => {
accepted = true
},
clear: () => undefined,
markTransient: () => undefined,
wait: async () => undefined,
isCurrent: () => current,
}
const outcome = await createRefreshRunner(runtime)()
assert.equal(outcome.kind, 'transient_error')
assert.equal(accepted, false)
})
test('explicit rotations update only the current session', () => {
useAuthStore.getState().auth.setBundle(bundle)
applyAuthRotation({
access_token: 'rotated-token',
token_type: 'Bearer',
access_expires_at: bundle.access_expires_at + 60,
session: { ...bundle.session, last_active_at: 200 },
})
assert.equal(useAuthStore.getState().auth.accessToken, 'rotated-token')
assert.strictEqual(useAuthStore.getState().auth.user, bundle.user)
assert.throws(
() =>
applyAuthRotation({
access_token: 'non-bearer-token',
token_type: 'Custom',
access_expires_at: bundle.access_expires_at + 120,
session: bundle.session,
}),
/Invalid authentication rotation response/
)
assert.throws(
() =>
applyAuthRotation({
access_token: 'non-current-token',
token_type: 'Bearer',
access_expires_at: bundle.access_expires_at + 120,
session: { ...bundle.session, current: false },
}),
/Invalid authentication rotation response/
)
assert.throws(
() =>
applyAuthRotation({
access_token: 'wrong-session-token',
token_type: 'Bearer',
access_expires_at: bundle.access_expires_at + 120,
session: { ...bundle.session, sid: 'session-b' },
}),
/session mismatch/
)
assert.equal(useAuthStore.getState().auth.accessToken, 'rotated-token')
})
test('sign-out clears user-scoped query, mutation, and authentication state', () => {
const queryClient = new QueryClient()
queryClient.setQueryData(['account', bundle.user.id], {
username: bundle.user.username,
})
queryClient.getMutationCache().build(queryClient, {
mutationKey: ['account', bundle.user.id, 'update'],
mutationFn: async () => undefined,
})
useAuthStore.getState().auth.setBundle(bundle)
useAuthStore.getState().auth.setPending2FAFlowToken('pending-flow')
clearAuthenticatedClientState(queryClient, false)
assert.equal(queryClient.getQueryCache().getAll().length, 0)
assert.equal(queryClient.getMutationCache().getAll().length, 0)
assert.equal(useAuthStore.getState().auth.user, null)
assert.equal(useAuthStore.getState().auth.accessToken, null)
assert.equal(useAuthStore.getState().auth.session, null)
assert.equal(useAuthStore.getState().auth.pending2FAFlowToken, null)
assert.equal(useAuthStore.getState().auth.bootstrapState, 'complete')
const nextBundle: AuthBundle = {
...bundle,
access_token: 'next-user-token',
user: { id: 84, username: 'next-user', role: 1 },
session: { ...bundle.session, sid: 'session-b' },
}
useAuthStore.getState().auth.setBundle(nextBundle)
assert.equal(
queryClient.getQueryData(['account', bundle.user.id]),
undefined
)
})
})
+420
View File
@@ -0,0 +1,420 @@
/*
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 { QueryClient } from '@tanstack/react-query'
import axios from 'axios'
import { t } from 'i18next'
import { publishAuthSessionEvent } from '@/lib/auth-session-sync'
import {
useAuthStore,
type AuthBootstrapState,
type AuthBundle,
type AuthUser,
type LoginSession,
} from '@/stores/auth-store'
export type RefreshOutcome =
| { kind: 'authenticated'; bundle: AuthBundle }
| { kind: 'anonymous' }
| { kind: 'transient_error'; error: unknown }
| { kind: 'out_of_sync'; code?: string }
export interface AuthRefreshHTTPResponse {
status: number
data?: unknown
error?: unknown
}
export interface AuthRefreshRuntime {
request: (expectedSID?: string) => Promise<AuthRefreshHTTPResponse>
getExpectedSID: () => string | undefined
parseBundle: (value: unknown) => AuthBundle | null
acceptBundle: (bundle: AuthBundle) => void
clear: (synchronizeTabs: boolean, bootstrapState?: AuthBootstrapState) => void
markTransient: () => void
wait: (delay: number) => Promise<void>
isCurrent?: () => boolean
}
export interface AuthTokenRotation {
access_token: string
token_type: string
access_expires_at: number
session: LoginSession
}
export class AuthRotationError extends Error {
constructor(message: string) {
super(message)
this.name = 'AuthRotationError'
}
}
const authClient = axios.create({
baseURL: '',
withCredentials: true,
headers: {
'Cache-Control': 'no-store',
},
})
const refreshRaceDelays = [80, 200, 500] as const
let refreshPromise: Promise<RefreshOutcome> | null = null
let authEpoch = 0
class AuthRefreshSupersededError extends Error {
constructor() {
super('Authentication refresh was superseded')
this.name = 'AuthRefreshSupersededError'
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object'
}
function isAuthUser(value: unknown): value is AuthUser {
if (!isRecord(value)) return false
return (
Number.isInteger(value.id) &&
Number(value.id) > 0 &&
typeof value.username === 'string' &&
typeof value.role === 'number'
)
}
function isLoginSession(value: unknown): value is LoginSession {
if (!isRecord(value)) return false
return (
typeof value.sid === 'string' &&
value.sid.length > 0 &&
typeof value.current === 'boolean' &&
typeof value.login_method === 'string' &&
typeof value.ip === 'string' &&
typeof value.user_agent === 'string' &&
typeof value.created_at === 'number' &&
typeof value.last_active_at === 'number' &&
typeof value.expires_at === 'number'
)
}
function hasValidTokenFields(value: Record<string, unknown>): boolean {
return (
typeof value.access_token === 'string' &&
value.access_token.length > 0 &&
typeof value.token_type === 'string' &&
value.token_type.length > 0 &&
typeof value.access_expires_at === 'number' &&
Number.isFinite(value.access_expires_at) &&
value.access_expires_at > 0
)
}
export function isAuthBundle(value: unknown): value is AuthBundle {
if (!isRecord(value)) return false
return (
hasValidTokenFields(value) &&
isAuthUser(value.user) &&
isLoginSession(value.session)
)
}
function isAuthTokenRotation(value: unknown): value is AuthTokenRotation {
return (
isRecord(value) &&
hasValidTokenFields(value) &&
value.token_type === 'Bearer' &&
isLoginSession(value.session) &&
value.session.current
)
}
export function applyAuthBundle(
bundle: AuthBundle,
synchronizeTabs = true
): void {
const previousSID = useAuthStore.getState().auth.session?.sid
authEpoch += 1
useAuthStore.getState().auth.setBundle(bundle)
if (synchronizeTabs && previousSID !== bundle.session.sid) {
publishAuthSessionEvent('authenticated', bundle.session.sid)
}
}
export function applyAuthRotation(value: unknown): void {
if (!isAuthTokenRotation(value)) {
throw new AuthRotationError('Invalid authentication rotation response')
}
const auth = useAuthStore.getState().auth
if (!auth.user || !auth.session) {
throw new AuthRotationError('Authentication rotation has no active session')
}
if (value.session.sid !== auth.session.sid) {
throw new AuthRotationError('Authentication rotation session mismatch')
}
applyAuthBundle(
{
access_token: value.access_token,
token_type: value.token_type,
access_expires_at: value.access_expires_at,
session: value.session,
user: auth.user,
},
false
)
}
export function clearAuthentication(
synchronizeTabs = true,
bootstrapState: AuthBootstrapState = 'complete'
): void {
const sid = useAuthStore.getState().auth.session?.sid
authEpoch += 1
useAuthStore.getState().auth.reset(bootstrapState)
if (synchronizeTabs && sid) {
publishAuthSessionEvent('signed_out', sid)
}
}
export function clearAuthenticatedClientState(
queryClient: QueryClient,
synchronizeTabs = true
): void {
queryClient.clear()
clearAuthentication(synchronizeTabs)
}
function waitForRefreshRace(delay: number): Promise<void> {
return new Promise((resolve) => globalThis.setTimeout(resolve, delay))
}
export function createRefreshRunner(
runtime: AuthRefreshRuntime
): () => Promise<RefreshOutcome> {
const superseded = (): RefreshOutcome => ({
kind: 'transient_error',
error: new AuthRefreshSupersededError(),
})
const run = async (
raceAttempt: number,
allowMismatchRetry: boolean
): Promise<RefreshOutcome> => {
if (runtime.isCurrent && !runtime.isCurrent()) return superseded()
const response = await runtime.request(runtime.getExpectedSID())
if (runtime.isCurrent && !runtime.isCurrent()) return superseded()
const responseData = isRecord(response.data) ? response.data : undefined
const code =
typeof responseData?.code === 'string' ? responseData.code : undefined
const bundle = runtime.parseBundle(responseData?.data)
if (responseData?.success === true && bundle) {
runtime.acceptBundle(bundle)
return { kind: 'authenticated', bundle }
}
if (response.status === 409 && code === 'AUTH_REFRESH_RACE') {
const delay = refreshRaceDelays[raceAttempt]
if (delay !== undefined) {
await runtime.wait(delay)
return run(raceAttempt + 1, allowMismatchRetry)
}
runtime.clear(false)
return { kind: 'out_of_sync', code }
}
if (response.status === 409 && code === 'AUTH_SESSION_MISMATCH') {
if (allowMismatchRetry) {
runtime.clear(false, 'idle')
return run(0, false)
}
runtime.clear(false)
return { kind: 'out_of_sync', code }
}
if (response.status === 401) {
runtime.clear(true)
return { kind: 'anonymous' }
}
if (!response.status || response.status >= 500) {
runtime.markTransient()
return {
kind: 'transient_error',
error: response.error ?? response.data,
}
}
runtime.clear(false)
return {
kind: 'out_of_sync',
code: code ?? 'AUTH_INVALID_REFRESH_RESPONSE',
}
}
return () => run(0, true)
}
async function requestRefresh(
expectedSID?: string
): Promise<AuthRefreshHTTPResponse> {
try {
const response = await authClient.post(
'/api/user/auth/refresh',
undefined,
{
headers: expectedSID ? { 'X-Auth-Session': expectedSID } : undefined,
}
)
return { status: response.status, data: response.data }
} catch (error: unknown) {
if (!axios.isAxiosError(error)) return { status: 0, error }
return {
status: error.response?.status ?? 0,
data: error.response?.data,
error,
}
}
}
function runRefresh(refreshEpoch: number): Promise<RefreshOutcome> {
return createRefreshRunner({
request: requestRefresh,
getExpectedSID: () => useAuthStore.getState().auth.session?.sid,
parseBundle: (value) => (isAuthBundle(value) ? value : null),
acceptBundle: (bundle) => applyAuthBundle(bundle, false),
clear: (synchronizeTabs, bootstrapState) => {
if (!synchronizeTabs && bootstrapState === 'idle') {
useAuthStore.getState().auth.reset('idle')
return
}
clearAuthentication(synchronizeTabs, bootstrapState)
},
markTransient: () => useAuthStore.getState().auth.setBootstrapState('idle'),
wait: waitForRefreshRace,
isCurrent: () => authEpoch === refreshEpoch,
})()
}
async function performRefreshWithBrowserLock(
refreshEpoch: number
): Promise<RefreshOutcome> {
try {
if (typeof navigator === 'undefined' || !navigator.locks) {
return runRefresh(refreshEpoch)
}
return navigator.locks.request(
'new-api:auth-refresh',
{ mode: 'exclusive' },
() => runRefresh(refreshEpoch)
)
} catch (error: unknown) {
useAuthStore.getState().auth.setBootstrapState('idle')
return { kind: 'transient_error', error }
}
}
export function refreshAuthentication(): Promise<RefreshOutcome> {
if (!refreshPromise) {
const refreshEpoch = authEpoch
refreshPromise = performRefreshWithBrowserLock(refreshEpoch).finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
function currentValidAuthBundle(): AuthBundle | null {
const auth = useAuthStore.getState().auth
if (
!auth.user ||
!auth.accessToken ||
!auth.accessExpiresAt ||
!auth.session ||
auth.accessExpiresAt <= Math.floor(Date.now() / 1000)
) {
return null
}
return {
access_token: auth.accessToken,
token_type: 'Bearer',
access_expires_at: auth.accessExpiresAt,
user: auth.user,
session: auth.session,
}
}
export async function bootstrapAuthentication(): Promise<RefreshOutcome> {
const bundle = currentValidAuthBundle()
if (bundle) {
useAuthStore.getState().auth.setBootstrapState('complete')
return { kind: 'authenticated', bundle }
}
const auth = useAuthStore.getState().auth
const hasStaleSession = Boolean(auth.user && auth.session)
if (auth.bootstrapState === 'complete' && !hasStaleSession) {
return { kind: 'anonymous' }
}
auth.setBootstrapState('checking')
return refreshAuthentication()
}
export function getCommonHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
const accessToken = useAuthStore.getState().auth.accessToken
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
return headers
}
export async function getFreshAuthHeaders(): Promise<Record<string, string>> {
const auth = useAuthStore.getState().auth
const refreshBefore = Math.floor(Date.now() / 1000) + 60
if (
auth.accessToken &&
auth.accessExpiresAt &&
auth.accessExpiresAt > refreshBefore
) {
return getCommonHeaders()
}
const outcome = await refreshAuthentication()
if (outcome.kind === 'authenticated') {
return getCommonHeaders()
}
const current = useAuthStore.getState().auth
if (
current.accessToken &&
current.accessExpiresAt &&
current.accessExpiresAt > Math.floor(Date.now() / 1000)
) {
return getCommonHeaders()
}
if (outcome.kind === 'transient_error') {
throw new Error(t('Request failed'), { cause: outcome.error })
}
throw new Error(t('Session expired!'))
}
+45
View File
@@ -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 type { CSSProperties } from 'react'
export type UserAvatarStyle = Pick<CSSProperties, 'backgroundColor' | 'color'>
function hashString(value: string): number {
let hash = 0
for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) >>> 0
}
return hash
}
export function getUserAvatarStyle(name: string): UserAvatarStyle {
const hash = hashString(name)
const hue = hash % 360
const saturation = 54 + (hash % 8)
const lightness = 52 + ((hash >> 4) % 8)
return {
backgroundColor: `hsl(${hue} ${saturation}% ${lightness}%)`,
color: 'white',
}
}
export function getUserAvatarFallback(name: string): string {
return name.trim().charAt(0).toUpperCase() || '?'
}
+170
View File
@@ -0,0 +1,170 @@
/*
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
*/
/**
* Build metadata helper.
*
* Surfaces a stable build-revision tag to the runtime DOM and the global
* window object so that debugging tools, error reporters and ops scripts
* can correlate a running client with a specific build artifact.
*
* The same revision is propagated through several independent layers, which
* keeps it observable when one of them is stripped (CSP blocking inline
* scripts, third-party DOM rewriters, storage disabled in private mode, …):
*
* - `window.__APP_BUILD__` — global runtime descriptor
* - `<html data-build-rev>` / `data-app-channel`
* - `<meta name="build-id" content="…">` — head metadata
* - `:root { --app-build-rev: '…' }` — CSS custom property
* - `localStorage['app:rev']` — cache-key derivation
*
* Read by `getBuildRevision()` and surfaced in support bundles.
*/
/**
* Short channel tag baked into the build revision. Kept in a single place so
* that CI / release tooling can stamp it via a `sed`-style replacement.
*
* NOTE: keep in sync with the CI release slug. Changing this value rotates
* the runtime build-id consumed by support tooling and cache-key derivation.
*/
const BUILD_CHANNEL_TAG = '2k6e8r7p'
const BUILD_REV_PREFIX = 'rv'
const LS_REVISION_KEY = 'app:rev'
interface BuildDescriptor {
readonly rev: string
readonly ch: string
readonly at: number
}
declare global {
interface Window {
__APP_BUILD__?: BuildDescriptor
}
}
function readEnvRevision(): string | undefined {
try {
const env = (
import.meta as unknown as { env?: Record<string, string | undefined> }
).env
const raw = env?.VITE_REACT_APP_VERSION
if (typeof raw === 'string' && raw.length > 0) return raw
} catch {
// import.meta may be unavailable in some test environments.
}
return undefined
}
function computeBuildRevision(): string {
const envRev = readEnvRevision()
const head = envRev && envRev.length > 0 ? envRev : '0000'
return `${BUILD_REV_PREFIX}.${head}.${BUILD_CHANNEL_TAG}`
}
let installed = false
/**
* Apply build-metadata to the document. Safe to call multiple times — the
* second invocation is a no-op.
*/
export function installBuildMetadata(): void {
if (installed) return
if (typeof window === 'undefined' || typeof document === 'undefined') return
installed = true
const rev = computeBuildRevision()
const descriptor: BuildDescriptor = Object.freeze({
rev,
ch: BUILD_CHANNEL_TAG,
at: Date.now(),
})
// Global descriptor for support tooling and error reporters.
try {
Object.defineProperty(window, '__APP_BUILD__', {
value: descriptor,
writable: false,
configurable: false,
enumerable: false,
})
} catch {
// Property may already be locked by an earlier reload.
}
// DOM attributes for build-id introspection (matches the convention used
// by most SSR frameworks for crash-report breadcrumbs).
try {
const html = document.documentElement
if (!html.hasAttribute('data-build-rev')) {
html.setAttribute('data-build-rev', rev)
}
if (!html.hasAttribute('data-app-channel')) {
html.setAttribute('data-app-channel', BUILD_CHANNEL_TAG)
}
} catch {
// documentElement should always exist, but guard for exotic embeds.
}
// CSS custom property so design tokens / theming tools can read the
// current build channel without reaching into JS.
try {
document.documentElement.style.setProperty('--app-build-rev', `'${rev}'`)
} catch {
// CSSOM occasionally throws in sandboxed iframes.
}
// <meta name="build-id"> for crawlers / curl-based ops introspection.
try {
let meta = document.querySelector<HTMLMetaElement>('meta[name="build-id"]')
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute('name', 'build-id')
document.head.appendChild(meta)
}
meta.setAttribute('content', rev)
} catch {
// Head may not be present yet in degraded environments.
}
// Persisted revision so other modules can derive cache keys from it.
try {
window.localStorage.setItem(LS_REVISION_KEY, rev)
} catch {
// Storage can be unavailable (private mode, disabled cookies, …).
}
// Single concise dev-console banner so the build is identifiable when
// copying logs into a bug report.
try {
// eslint-disable-next-line no-console
console.debug('[build] %s', rev)
} catch {
// console may be replaced by a noop shim.
}
}
/**
* Return the canonical build revision string. Useful for support bundles
* and for asserting the metadata layer is installed.
*/
export function getBuildRevision(): string {
return computeBuildRevision()
}
+58
View File
@@ -0,0 +1,58 @@
/*
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
*/
export const CHANNEL_CONNECTION_INFO_TYPE = 'newapi_channel_conn'
export type ChannelConnectionInfo = {
key: string
url: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function encodeChannelConnectionInfo(key: string, url: string): string {
return JSON.stringify({
_type: CHANNEL_CONNECTION_INFO_TYPE,
key,
url,
})
}
export function parseChannelConnectionInfo(
text: string | null | undefined
): ChannelConnectionInfo | null {
if (!text || typeof text !== 'string') return null
try {
const parsed: unknown = JSON.parse(text.trim())
if (
isRecord(parsed) &&
parsed._type === CHANNEL_CONNECTION_INFO_TYPE &&
typeof parsed.key === 'string' &&
typeof parsed.url === 'string'
) {
return { key: parsed.key, url: parsed.url }
}
} catch {
/* not valid connection info JSON */
}
return null
}
+185
View File
@@ -0,0 +1,185 @@
/*
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
*/
export type SemanticColor =
| 'blue'
| 'green'
| 'cyan'
| 'purple'
| 'pink'
| 'red'
| 'orange'
| 'amber'
| 'yellow'
| 'lime'
| 'light-green'
| 'teal'
| 'light-blue'
| 'indigo'
| 'violet'
| 'grey'
| 'slate'
export const colorToBgClass: Record<SemanticColor, string> = {
blue: 'bg-blue-500',
green: 'bg-green-500',
cyan: 'bg-cyan-500',
purple: 'bg-purple-500',
pink: 'bg-pink-500',
red: 'bg-red-500',
orange: 'bg-orange-500',
amber: 'bg-amber-500',
yellow: 'bg-yellow-500',
lime: 'bg-lime-500',
'light-green': 'bg-green-400',
teal: 'bg-teal-500',
'light-blue': 'bg-sky-400',
indigo: 'bg-indigo-500',
violet: 'bg-violet-500',
grey: 'bg-gray-400',
slate: 'bg-slate-500',
}
export const avatarColorMap: Record<SemanticColor, string> = {
blue: 'bg-chart-1/10 text-chart-1',
green: 'bg-success/10 text-success',
cyan: 'bg-chart-2/10 text-chart-2',
purple: 'bg-chart-4/10 text-chart-4',
pink: 'bg-chart-5/10 text-chart-5',
red: 'bg-destructive/10 text-destructive',
orange: 'bg-warning/10 text-warning',
amber: 'bg-warning/10 text-warning',
yellow: 'bg-warning/10 text-warning',
lime: 'bg-chart-3/10 text-chart-3',
'light-green': 'bg-success/10 text-success',
teal: 'bg-chart-2/10 text-chart-2',
'light-blue': 'bg-info/10 text-info',
indigo: 'bg-chart-1/10 text-chart-1',
violet: 'bg-chart-4/10 text-chart-4',
grey: 'bg-muted text-muted-foreground',
slate: 'bg-muted text-muted-foreground',
}
export function getAvatarColorClass(name: string): string {
return avatarColorMap[stringToColor(name)]
}
export function getBgColorClass(color?: string): string {
if (!color) return colorToBgClass.blue
return (
(colorToBgClass as Record<string, string>)[color] || colorToBgClass.blue
)
}
/**
* Chart color palette - Modern gradient colors compatible with light/dark themes
* Uses HSL format for better theme adaptation
*/
export const CHART_COLORS = [
'hsl(217, 91%, 60%)', // blue
'hsl(142, 76%, 36%)', // green
'hsl(38, 92%, 50%)', // amber
'hsl(258, 90%, 66%)', // violet
'hsl(330, 81%, 60%)', // pink
'hsl(189, 94%, 43%)', // cyan
'hsl(25, 95%, 53%)', // orange
'hsl(239, 84%, 67%)', // indigo
'hsl(173, 80%, 40%)', // teal
'hsl(271, 91%, 65%)', // purple
'hsl(199, 89%, 48%)', // sky
'hsl(280, 65%, 60%)', // fuchsia
] as const
/**
* Get a chart color by index (cycles through the palette)
*/
export function getChartColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length]
}
/**
* Announcement status types
*/
export type AnnouncementType =
| 'default'
| 'ongoing'
| 'success'
| 'warning'
| 'error'
/**
* Announcement status color mapping
*/
export const ANNOUNCEMENT_TYPE_COLORS: Record<AnnouncementType, string> = {
default: 'bg-neutral',
ongoing: 'bg-info',
success: 'bg-success',
warning: 'bg-warning',
error: 'bg-destructive',
}
/**
* Get announcement status color class
*/
export function getAnnouncementColorClass(type?: string): string {
const validType = (type || 'default') as AnnouncementType
return ANNOUNCEMENT_TYPE_COLORS[validType] || ANNOUNCEMENT_TYPE_COLORS.default
}
/**
* Semantic colors for tags and badges
*/
const TAG_COLORS = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
] as const
/**
* Convert string to a stable semantic color
* Used for model tags, group badges, user avatars, etc.
* Same string always returns the same color
*
* @param str - Input string (model name, group name, username, etc.)
* @returns Semantic color name from TAG_COLORS
*
* @example
* stringToColor('gpt-4') // 'blue'
* stringToColor('claude-3') // 'purple'
* stringToColor('default') // 'green'
*/
export function stringToColor(str: string): SemanticColor {
let sum = 0
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i)
}
const index = sum % TAG_COLORS.length
return TAG_COLORS[index]
}
+32
View File
@@ -0,0 +1,32 @@
/*
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
*/
/**
* Application-wide constants
*/
// System Configuration Defaults
export const DEFAULT_SYSTEM_NAME = 'New API'
export const DEFAULT_LOGO = '/logo.png'
// LocalStorage Keys
export const STORAGE_KEYS = {
SYSTEM_NAME: 'system_name',
LOGO: 'logo',
FOOTER_HTML: 'footer_html',
} as const
+32
View File
@@ -0,0 +1,32 @@
/*
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
*/
export function isHttpUrl(value: string): boolean {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
export function isLikelyHtml(value: string): boolean {
return /<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]|<style[\s>]|<script[\s>]|<\/?[a-z][\s\S]*>/i.test(
value
)
}
+61
View File
@@ -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
*/
/**
* Cookie utility functions using manual document.cookie approach
* Replaces js-cookie dependency for better consistency
*/
const DEFAULT_MAX_AGE = 60 * 60 * 24 * 7 // 7 days
/**
* Get a cookie value by name
*/
export function getCookie(name: string): string | undefined {
if (typeof document === 'undefined') return undefined
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
if (parts.length === 2) {
const cookieValue = parts.pop()?.split(';').shift()
return cookieValue
}
return undefined
}
/**
* Set a cookie with name, value, and optional max age
*/
export function setCookie(
name: string,
value: string,
maxAge: number = DEFAULT_MAX_AGE
): void {
if (typeof document === 'undefined') return
document.cookie = `${name}=${value}; path=/; max-age=${maxAge}`
}
/**
* Remove a cookie by setting its max age to 0
*/
export function removeCookie(name: string): void {
if (typeof document === 'undefined') return
document.cookie = `${name}=; path=/; max-age=0`
}
+103
View File
@@ -0,0 +1,103 @@
/*
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
*/
/**
* Fallback copy method using document.execCommand (works in HTTP)
*/
function fallbackCopyToClipboard(text: string): boolean {
const textArea = document.createElement('textarea')
textArea.value = text
// Make the textarea out of viewport
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
textArea.style.opacity = '0'
textArea.setAttribute('readonly', '')
document.body.appendChild(textArea)
try {
// Select the text
textArea.focus()
textArea.select()
// For iOS devices
const range = document.createRange()
range.selectNodeContents(textArea)
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
selection.addRange(range)
}
textArea.setSelectionRange(0, text.length)
// Execute copy command
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
// Clear selection ranges for better UX
const selectionAfter = window.getSelection()
if (selectionAfter) {
selectionAfter.removeAllRanges()
}
return successful
} catch (err) {
// eslint-disable-next-line no-console
console.error('Fallback copy failed:', err)
document.body.removeChild(textArea)
return false
}
}
/**
* Copy text to clipboard with fallback support for HTTP environments
*
* @param text - The text to copy
* @returns Promise that resolves to true if successful, false otherwise
*
* @example
* ```ts
* const success = await copyToClipboard('Hello, World!')
* if (success) {
* console.log('Copied successfully')
* }
* ```
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// Guard for SSR / non-browser environments
if (typeof window === 'undefined' || typeof document === 'undefined') {
return false
}
// Try modern clipboard API first (HTTPS required)
if (navigator?.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch (error) {
// eslint-disable-next-line no-console
console.warn('Clipboard API failed, trying fallback method:', error)
// Try fallback method
return fallbackCopyToClipboard(text)
}
} else {
// Use fallback method directly
return fallbackCopyToClipboard(text)
}
}
+617
View File
@@ -0,0 +1,617 @@
/*
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
*/
/**
* ============================================================================
* Currency Formatting Library
* ============================================================================
*
* This module provides currency formatting utilities that handle the conversion
* between system USD amounts, local currency, and token displays based on
* admin-configured settings.
*
* ## Key Concepts
*
* 1. **System USD**: Internal currency unit used throughout the system (e.g., 10 USD)
* 2. **Local Currency**: Admin-configured display currency (e.g., CNY, custom currency)
* 3. **Exchange Rate (usdExchangeRate)**: Conversion rate from USD to local currency
* - Example: usdExchangeRate = 7 means 1 USD = 7 CNY
* 4. **Recharge Price (priceRatio)**: Cost in local currency to purchase 1 system USD
* - Example: priceRatio = 5 means user pays 5 CNY to get 1 USD credit
* 5. **Tokens**: Alternative display unit (e.g., 500,000 tokens = 1 USD)
*
* ## When to Use Each Function
*
* - `formatCurrencyFromUSD()`: Use for quota/balance display (stored as USD, converted for display)
* - `formatBillingCurrencyFromUSD()`: Use for billing/pricing displays (never shows tokens)
* - `formatLocalCurrencyAmount()`: Use for payment amounts already in local currency
* - `formatQuotaWithCurrency()`: Use for raw quota values (converts to USD first)
*
* ## Example Scenario
*
* Admin Configuration:
* - quotaDisplayType: 'CNY'
* - usdExchangeRate: 7 (1 USD = 7 CNY)
* - priceRatio: 5 (5 CNY per 1 USD credit)
* - quotaPerUnit: 500000 (tokens per USD)
*
* User Flow:
* 1. Recharge option: 10 USD
* - Display: formatCurrencyFromUSD(10) → "¥70"
* 2. Payment amount: 10 × 5 = 50 (already in CNY)
* - Display: formatLocalCurrencyAmount(50) → "¥50"
* 3. User receives: 10 USD credit
* - Balance display: formatCurrencyFromUSD(10) → "¥70"
*
* ## Quick Reference Guide
*
* | Scenario | Input Type | Function to Use | Why |
* |----------|-----------|-----------------|-----|
* | User balance display | USD (from DB) | `formatCurrencyFromUSD()` | Needs conversion to display currency |
* | Recharge option button | USD | `formatCurrencyFromUSD()` | Needs conversion to local currency |
* | Payment confirmation | Already local currency | `formatLocalCurrencyAmount()` | Already converted via priceRatio |
* | Billing history Amount | USD (from DB) | `formatCurrencyFromUSD()` | Historical USD needs conversion |
* | Billing history Payment | Local currency | `formatNumber()` | Just show number, no symbol |
* | Model pricing | USD | `formatBillingCurrencyFromUSD()` | Never show as tokens |
* | Raw quota from API | Tokens | `formatQuotaWithCurrency()` | Convert tokens → USD → display |
*
* ## Critical Rules
*
* 1. **Never double-convert**: If you multiply by exchangeRate, use formatLocalCurrencyAmount()
* 2. **Database USD values**: Always use formatCurrencyFromUSD() for amounts stored as USD
* 3. **Payment amounts**: Always use formatLocalCurrencyAmount() for priceRatio-calculated values
* 4. **Billing displays**: Use formatBillingCurrencyFromUSD() to avoid token display
* 5. **Effective exchange rate**: When quotaDisplayType is 'USD', use rate of 1 regardless of config
*/
import {
useSystemConfigStore,
DEFAULT_CURRENCY_CONFIG,
type CurrencyConfig,
type CurrencyDisplayType,
} from '@/stores/system-config-store'
export interface CurrencyFormatOptions {
/** Fraction digits to use when |value| >= 1 */
digitsLarge?: number
/** Fraction digits to use when |value| < 1 */
digitsSmall?: number
/** Whether to abbreviate thousands with k suffix */
abbreviate?: boolean
/** Minimal absolute value to display when rounding would produce zero */
minimumNonZero?: number
/**
* Use locale-aware compact notation for large values (e.g. "$28万" in zh,
* "$280K" in en). The currency symbol is preserved.
*/
compact?: boolean
/** Whether to include the currency/custom symbol. Token displays are unchanged. */
showSymbol?: boolean
/** Locale used for number formatting (defaults to the runtime locale) */
locale?: Intl.LocalesArgument | undefined
}
type ResolvedCurrencyFormatOptions = Omit<
Required<CurrencyFormatOptions>,
'locale'
> & {
locale: Intl.LocalesArgument | undefined
}
type DisplayMeta =
| {
kind: 'currency'
symbol: string
currencyCode: string
exchangeRate: number
}
| {
kind: 'custom'
symbol: string
exchangeRate: number
}
| {
kind: 'tokens'
/** Number of tokens per USD */
quotaPerUnit: number
}
const DEFAULT_FORMAT_OPTIONS: ResolvedCurrencyFormatOptions = {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: true,
minimumNonZero: 0,
compact: false,
showSymbol: true,
locale: undefined,
}
const DISPLAY_TYPE_VALUES = ['USD', 'CNY', 'TOKENS', 'CUSTOM'] as const
type DisplayTypeLiteral = (typeof DISPLAY_TYPE_VALUES)[number]
export function isCurrencyDisplayType(
value: unknown
): value is CurrencyDisplayType {
return (
typeof value === 'string' &&
DISPLAY_TYPE_VALUES.includes(value as DisplayTypeLiteral)
)
}
export function parseCurrencyDisplayType(
value: unknown,
fallback: CurrencyDisplayType = 'USD'
): CurrencyDisplayType {
return isCurrencyDisplayType(value) ? value : fallback
}
function getConfig(): CurrencyConfig {
const { config } = useSystemConfigStore.getState()
const currency = config?.currency ?? DEFAULT_CURRENCY_CONFIG
return {
...DEFAULT_CURRENCY_CONFIG,
...currency,
quotaPerUnit:
currency?.quotaPerUnit && currency.quotaPerUnit > 0
? currency.quotaPerUnit
: DEFAULT_CURRENCY_CONFIG.quotaPerUnit,
usdExchangeRate:
currency?.usdExchangeRate && currency.usdExchangeRate > 0
? currency.usdExchangeRate
: DEFAULT_CURRENCY_CONFIG.usdExchangeRate,
customCurrencyExchangeRate:
currency?.customCurrencyExchangeRate &&
currency.customCurrencyExchangeRate > 0
? currency.customCurrencyExchangeRate
: DEFAULT_CURRENCY_CONFIG.customCurrencyExchangeRate,
customCurrencySymbol:
currency?.customCurrencySymbol?.trim() ||
DEFAULT_CURRENCY_CONFIG.customCurrencySymbol,
}
}
function getDisplayMeta(config: CurrencyConfig): DisplayMeta {
switch (config.quotaDisplayType) {
case 'CNY':
return {
kind: 'currency',
symbol: '¥',
currencyCode: 'CNY',
exchangeRate: config.usdExchangeRate,
}
case 'CUSTOM':
return {
kind: 'custom',
symbol: config.customCurrencySymbol,
exchangeRate: config.customCurrencyExchangeRate,
}
case 'TOKENS':
return {
kind: 'tokens',
quotaPerUnit: config.quotaPerUnit,
}
case 'USD':
default:
return {
kind: 'currency',
symbol: '$',
currencyCode: 'USD',
exchangeRate: 1,
}
}
}
function getBillingDisplayMeta(config: CurrencyConfig): DisplayMeta {
const meta = getDisplayMeta(config)
if (meta.kind === 'tokens') {
return {
kind: 'currency',
symbol: '$',
currencyCode: 'USD',
exchangeRate: 1,
}
}
return meta
}
function mergeOptions(
options?: CurrencyFormatOptions
): ResolvedCurrencyFormatOptions {
if (!options) return DEFAULT_FORMAT_OPTIONS
return {
digitsLarge: options.digitsLarge ?? DEFAULT_FORMAT_OPTIONS.digitsLarge,
digitsSmall: options.digitsSmall ?? DEFAULT_FORMAT_OPTIONS.digitsSmall,
abbreviate: options.abbreviate ?? DEFAULT_FORMAT_OPTIONS.abbreviate,
minimumNonZero:
options.minimumNonZero ?? DEFAULT_FORMAT_OPTIONS.minimumNonZero,
compact: options.compact ?? DEFAULT_FORMAT_OPTIONS.compact,
showSymbol: options.showSymbol ?? DEFAULT_FORMAT_OPTIONS.showSymbol,
locale: options.locale ?? DEFAULT_FORMAT_OPTIONS.locale,
}
}
function removeTrailingZeros(str: string): string {
if (!str.includes('.')) return str
return str.replace(/(\.[0-9]*?)0+$/, '$1').replace(/\.$/, '')
}
function formatNumberWithSuffix(
value: number,
digitsLarge: number,
digitsSmall: number,
abbreviate: boolean
): string {
const abs = Math.abs(value)
if (abbreviate && abs >= 1000) {
const result = value / 1000
return `${removeTrailingZeros(result.toFixed(1))}k`
}
const digits = abs >= 1 ? digitsLarge : digitsSmall
return removeTrailingZeros(value.toFixed(digits))
}
function adjustForMinimum(
value: number,
digits: number,
minimumNonZero: number
): number {
if (value === 0) return value
const threshold = minimumNonZero > 0 ? minimumNonZero : Math.pow(10, -digits)
const abs = Math.abs(value)
if (abs > 0 && abs < threshold) {
return value > 0 ? threshold : -threshold
}
return value
}
function formatCurrencyValue(
value: number,
options: ResolvedCurrencyFormatOptions,
meta: DisplayMeta
): string {
if (meta.kind === 'tokens') {
if (options.compact) {
return new Intl.NumberFormat(options.locale, {
notation: 'compact',
maximumFractionDigits: 1,
}).format(value)
}
return formatNumberWithSuffix(
value,
options.digitsLarge,
options.digitsSmall,
options.abbreviate
)
}
const digits =
Math.abs(value) >= 1 ? options.digitsLarge : options.digitsSmall
const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero)
if (meta.kind === 'currency') {
if (!options.showSymbol) {
return new Intl.NumberFormat(options.locale, {
notation: options.compact ? 'compact' : 'standard',
minimumFractionDigits: 0,
maximumFractionDigits: options.compact ? 1 : digits,
}).format(adjustedValue)
}
const formatted = new Intl.NumberFormat(options.locale, {
style: 'currency',
currency: meta.currencyCode,
currencyDisplay: 'narrowSymbol',
notation: options.compact ? 'compact' : 'standard',
minimumFractionDigits: 0,
maximumFractionDigits: options.compact ? 1 : digits,
}).format(adjustedValue)
return formatted
}
const decimal = new Intl.NumberFormat(options.locale, {
notation: options.compact ? 'compact' : 'standard',
minimumFractionDigits: 0,
maximumFractionDigits: options.compact ? 1 : digits,
}).format(adjustedValue)
return options.showSymbol ? `${meta.symbol} ${decimal}` : decimal
}
/**
* Get the current currency configuration and display metadata.
*
* @returns Object containing config and display metadata
*
* @internal
* This is primarily for internal use. Most consumers should use the
* higher-level formatting functions instead.
*/
export function getCurrencyDisplay() {
const config = getConfig()
const meta = getDisplayMeta(config)
return { config, meta }
}
/**
* Format a USD amount according to the admin-configured display settings.
*
* This is the PRIMARY function for displaying quota/balance/credit amounts
* that are stored in the system as USD values.
*
* @param amountUSD - Amount in system USD units (e.g., user balance, quota)
* @param options - Optional formatting configuration
* @returns Formatted string with currency symbol or token count
*
* @example
* // With quotaDisplayType: 'USD'
* formatCurrencyFromUSD(10) → "$10"
*
* @example
* // With quotaDisplayType: 'CNY', usdExchangeRate: 7
* formatCurrencyFromUSD(10) → "¥70"
*
* @example
* // With quotaDisplayType: 'TOKENS', quotaPerUnit: 500000
* formatCurrencyFromUSD(10) → "5,000,000"
*
* @example
* // With quotaDisplayType: 'CUSTOM', customCurrencySymbol: '€', customCurrencyExchangeRate: 0.9
* formatCurrencyFromUSD(10) → "€9"
*
* @remarks
* Use this function for:
* - User balance/quota display
* - Recharge option amounts (before exchange rate applied)
* - Transaction amounts in billing history
* - Any value stored in database as USD
*
* DO NOT use for:
* - Payment amounts already converted via priceRatio → use formatLocalCurrencyAmount()
* - Raw token values → use formatQuotaWithCurrency()
*/
export function formatCurrencyFromUSD(
amountUSD: number | null | undefined,
options?: CurrencyFormatOptions
): string {
if (amountUSD == null || Number.isNaN(amountUSD)) return '-'
const { config, meta } = getCurrencyDisplay()
const merged = mergeOptions(options)
if (meta.kind === 'tokens') {
const tokens = amountUSD * config.quotaPerUnit
if (merged.compact) {
return new Intl.NumberFormat(merged.locale, {
notation: 'compact',
maximumFractionDigits: 1,
}).format(tokens)
}
return formatNumberWithSuffix(
tokens,
0,
merged.digitsSmall,
merged.abbreviate
)
}
const value =
meta.kind === 'currency'
? amountUSD * meta.exchangeRate
: amountUSD * meta.exchangeRate
return formatCurrencyValue(value, merged, meta)
}
/**
* Format USD amounts for billing/payment contexts (never shows tokens).
*
* Similar to formatCurrencyFromUSD, but NEVER displays in token units.
* Always shows real currency values (USD, CNY, etc.) even when the system
* is configured to display quotas as tokens elsewhere.
*
* @param amountUSD - Amount in system USD units
* @param options - Optional formatting configuration
* @returns Formatted string with currency symbol (never tokens)
*
* @example
* // With quotaDisplayType: 'TOKENS' - still shows currency
* formatBillingCurrencyFromUSD(10) → "$10" (not "5,000,000 tokens")
*
* @example
* // With quotaDisplayType: 'CNY', usdExchangeRate: 7
* formatBillingCurrencyFromUSD(10) → "¥70"
*
* @remarks
* Use this function for:
* - Model pricing displays
* - API usage costs
* - Billing/invoice amounts
* - Any monetary value where tokens don't make sense
*
* DO NOT use for:
* - User balance/quota → use formatCurrencyFromUSD()
* - Payment amounts already in local currency → use formatLocalCurrencyAmount()
*/
export function formatBillingCurrencyFromUSD(
amountUSD: number | null | undefined,
options?: CurrencyFormatOptions
): string {
if (amountUSD == null || Number.isNaN(amountUSD)) return '-'
const { config } = getCurrencyDisplay()
const meta = getBillingDisplayMeta(config)
const merged = mergeOptions(options)
const value =
meta.kind === 'currency' || meta.kind === 'custom'
? amountUSD * meta.exchangeRate
: amountUSD
return formatCurrencyValue(value, merged, meta)
}
/**
* Format raw quota values (token units) to display currency.
*
* Converts raw quota/token amounts to USD first, then formats according
* to display settings. Use when you have quota in token units (e.g., 5000000)
* and need to display it as currency (e.g., "$10").
*
* @param quota - Raw quota amount in token units (e.g., 5000000)
* @param options - Optional formatting configuration
* @returns Formatted string with currency symbol or token count
*
* @example
* // With quotaPerUnit: 500000, quotaDisplayType: 'USD'
* formatQuotaWithCurrency(5000000) → "$10"
*
* @example
* // With quotaPerUnit: 500000, quotaDisplayType: 'CNY', usdExchangeRate: 7
* formatQuotaWithCurrency(5000000) → "¥70"
*
* @remarks
* Use this function for:
* - Raw quota values from database (stored as tokens)
* - When you need to convert tokens → USD → display currency
*
* DO NOT use for:
* - Values already in USD → use formatCurrencyFromUSD()
* - Payment amounts → use formatLocalCurrencyAmount()
*/
export function formatQuotaWithCurrency(
quota: number | null | undefined,
options?: CurrencyFormatOptions
): string {
if (quota == null || Number.isNaN(quota)) return '-'
const { config } = getCurrencyDisplay()
const amountUSD = quota / config.quotaPerUnit
return formatCurrencyFromUSD(amountUSD, options)
}
/**
* Get the current currency label for UI display.
*
* Returns a simple string label representing the current display currency.
* Useful for labels, tooltips, and UI text.
*
* @returns Currency label string (e.g., "USD", "CNY", "Tokens")
*
* @example
* getCurrencyLabel() → "USD"
* getCurrencyLabel() → "CNY"
* getCurrencyLabel() → "Tokens"
*
* @remarks
* Use this for:
* - Currency selector labels
* - Table column headers
* - Form field labels
*/
export function getCurrencyLabel(): string {
const { config, meta } = getCurrencyDisplay()
if (meta.kind === 'tokens') {
return 'Tokens'
}
switch (config.quotaDisplayType) {
case 'CNY':
return 'CNY'
case 'CUSTOM':
return meta.kind === 'custom' ? meta.symbol : 'Custom'
case 'USD':
default:
return 'USD'
}
}
/**
* Check if currency display is enabled (not in token-only mode).
*
* @returns True if displaying in actual currency (USD/CNY/etc), false if tokens only
*
* @example
* // With quotaDisplayType: 'USD' or 'CNY'
* isCurrencyDisplayEnabled() → true
*
* // With quotaDisplayType: 'TOKENS'
* isCurrencyDisplayEnabled() → false
*
* @remarks
* Use this to conditionally show currency-specific UI elements
*/
export function isCurrencyDisplayEnabled(): boolean {
const { meta } = getCurrencyDisplay()
return meta.kind !== 'tokens'
}
/**
* Format an amount that is ALREADY in local currency.
*
* ⚠️ CRITICAL: This function does NOT apply exchange rate conversion.
* Only use this for values that have already been converted to local currency
* via priceRatio or other means.
*
* @param amount - Amount already in local currency units
* @param options - Optional formatting configuration
* @returns Formatted string with appropriate currency symbol
*
* @example
* // Payment amount already calculated: 10 USD × priceRatio(5) = 50 CNY
* // With quotaDisplayType: 'CNY'
* formatLocalCurrencyAmount(50) → "¥50"
* // NOT "¥350" (which would be 50 × 7 exchangeRate)
*
* @example
* // With quotaDisplayType: 'USD'
* formatLocalCurrencyAmount(10) → "$10"
*
* @remarks
* Use this function for:
* - Payment amounts calculated via priceRatio (amount × price)
* - Actual money charged to user's payment method
* - Values that are already in the target currency
*
* DO NOT use for:
* - USD values that need conversion → use formatCurrencyFromUSD()
* - Raw quota values → use formatQuotaWithCurrency()
*
* Common mistake:
* ```ts
* // ❌ WRONG - Double conversion
* const payment = usdAmount * exchangeRate
* formatLocalCurrencyAmount(payment) // Will apply exchange rate again!
*
* // ✅ CORRECT - Already in local currency
* const payment = usdAmount * priceRatio
* formatLocalCurrencyAmount(payment) // Just formats with symbol
* ```
*/
export function formatLocalCurrencyAmount(
amount: number | null | undefined,
options?: CurrencyFormatOptions
): string {
if (amount == null || Number.isNaN(amount)) return '-'
const { config } = getCurrencyDisplay()
const meta = getBillingDisplayMeta(config)
const merged = mergeOptions(options)
return formatCurrencyValue(amount, merged, meta)
}
+24
View File
@@ -0,0 +1,24 @@
/*
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 dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
export default dayjs
+34
View File
@@ -0,0 +1,34 @@
/*
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
*/
export function applyFaviconToDom(url: string) {
if (typeof document === 'undefined' || !url) return
try {
const next = new URL(url, window.location.href).href
const existing =
document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')
if (existing.length === 1 && existing[0].href === next) return
const link = document.createElement('link')
link.rel = 'icon'
link.href = url
existing.forEach((l) => l.remove())
document.head.appendChild(link)
} catch {
// Ignore malformed URLs
}
}
+283
View File
@@ -0,0 +1,283 @@
/*
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 dayjs from '@/lib/dayjs'
import {
formatCurrencyFromUSD,
formatQuotaWithCurrency,
getCurrencyDisplay,
} from './currency'
// ============================================================================
// Number Formatting
// ============================================================================
export function formatNumber(
value: number | null | undefined,
locales?: Intl.LocalesArgument
): string {
if (value == null || Number.isNaN(value as number)) return '-'
return Intl.NumberFormat(locales, { maximumFractionDigits: 2 }).format(
value as number
)
}
export function formatCompactNumber(
value: number | null | undefined,
locales?: Intl.LocalesArgument
): string {
if (value == null || Number.isNaN(value as number)) return '-'
return Intl.NumberFormat(locales, {
notation: 'compact',
maximumFractionDigits: 1,
}).format(value as number)
}
export function formatPercent(value: number | null | undefined): string {
if (value == null || Number.isNaN(value as number)) return '-'
return Intl.NumberFormat(undefined, {
style: 'percent',
maximumFractionDigits: 2,
}).format((value as number) / 100)
}
export function formatCurrencyUSD(value: number | null | undefined): string {
return formatCurrencyFromUSD(value == null ? null : (value as number))
}
// ============================================================================
// Quota Formatting (500,000 units = $1)
// ============================================================================
/**
* Format quota into the configured display amount.
* Quota is stored in units where `quotaPerUnit` equals 1 USD.
*/
export function formatQuota(quota: number): string {
return formatQuotaWithCurrency(quota, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: true,
})
}
/**
* Parse quota from the current display input back to quota units.
*/
export function parseQuotaFromDollars(amount: number): number {
if (!Number.isFinite(amount)) return 0
const { config, meta } = getCurrencyDisplay()
// Tokens-only or raw quota mode
if (meta.kind === 'tokens') {
return Math.round(amount)
}
const exchangeRate =
meta.kind === 'currency' || meta.kind === 'custom' ? meta.exchangeRate : 1
const usdAmount = exchangeRate > 0 ? amount / exchangeRate : amount
return Math.round(usdAmount * config.quotaPerUnit)
}
/**
* Convert quota units to the configured display amount.
* Reverse of parseQuotaFromDollars.
*/
export function quotaUnitsToDollars(units: number): number {
const { config, meta } = getCurrencyDisplay()
if (meta.kind === 'tokens') {
return units
}
const usdAmount = units / config.quotaPerUnit
const exchangeRate =
meta.kind === 'currency' || meta.kind === 'custom' ? meta.exchangeRate : 1
return usdAmount * exchangeRate
}
// ============================================================================
// Timestamp Formatting
// ============================================================================
/**
* Format Unix timestamp (seconds) to YYYY-MM-DD HH:mm:ss
*/
export function formatTimestamp(timestamp: number): string {
if (timestamp === -1) {
return 'Never'
}
return formatTimestampToDate(timestamp)
}
/**
* Format timestamp to YYYY-MM-DD HH:mm:ss
* @param timestamp - Timestamp in seconds or milliseconds
* @param unit - Unit of the timestamp ('seconds' or 'milliseconds')
*/
export function formatTimestampToDate(
timestamp?: number,
unit: 'seconds' | 'milliseconds' = 'seconds'
): string {
if (!timestamp || timestamp === -1 || timestamp === 0) {
return '-'
}
const ms = unit === 'seconds' ? timestamp * 1000 : timestamp
return dayjs(ms).format('YYYY-MM-DD HH:mm:ss')
}
/**
* Format timestamp as relative time, e.g. "30 seconds ago".
* @param timestamp - Timestamp in seconds or milliseconds
* @param unit - Unit of the timestamp ('seconds' or 'milliseconds')
* @param locales - Locale passed to Intl.RelativeTimeFormat
*/
export function formatTimestampRelative(
timestamp?: number,
unit: 'seconds' | 'milliseconds' = 'seconds',
locales?: Intl.LocalesArgument
): string {
if (!timestamp || timestamp === -1 || timestamp === 0) {
return '-'
}
const ms = unit === 'seconds' ? timestamp * 1000 : timestamp
const diffSeconds = Math.round((ms - Date.now()) / 1000)
const absSeconds = Math.abs(diffSeconds)
const formatter = new Intl.RelativeTimeFormat(locales, {
numeric: 'always',
})
if (absSeconds < 60) {
return formatter.format(diffSeconds, 'second')
}
if (absSeconds < 3600) {
return formatter.format(Math.round(diffSeconds / 60), 'minute')
}
if (absSeconds < 86400) {
return formatter.format(Math.round(diffSeconds / 3600), 'hour')
}
if (absSeconds < 2592000) {
return formatter.format(Math.round(diffSeconds / 86400), 'day')
}
if (absSeconds < 31536000) {
return formatter.format(Math.round(diffSeconds / 2592000), 'month')
}
return formatter.format(Math.round(diffSeconds / 31536000), 'year')
}
/** Format a Date object to YYYY-MM-DD HH:mm:ss */
export function formatDateTimeStr(date: Date): string {
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
/** Format a Date object to YYYY-MM-DD */
export function formatDateStr(date: Date): string {
return dayjs(date).format('YYYY-MM-DD')
}
/** Format a Date object to HH:mm:ss */
export function formatTimeStr(date: Date): string {
return dayjs(date).format('HH:mm:ss')
}
/**
* Format quota for usage logs with higher precision
* Uses 6 decimal places to show very small costs accurately
*/
export function formatLogQuota(quota: number): string {
return formatQuotaWithCurrency(quota, {
digitsLarge: 4,
digitsSmall: 6,
abbreviate: false,
})
}
/**
* Format tokens count with K/M suffixes
*/
export function formatTokens(tokens: number): string {
if (tokens === 0) return '-'
if (tokens < 1000) return tokens.toString()
if (tokens < 1000000) return `${(tokens / 1000).toFixed(1)}K`
return `${(tokens / 1000000).toFixed(2)}M`
}
/**
* Format use time in seconds with appropriate unit
*/
export function formatUseTime(seconds: number): string {
if (seconds < 60) return `${seconds.toFixed(1)}s`
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}m ${remainingSeconds.toFixed(0)}s`
}
/**
* Format timestamp to date input value (YYYY-MM-DDTHH:mm)
*/
export function formatTimestampForInput(timestamp: number): string {
if (timestamp === -1) {
return ''
}
return dayjs(timestamp * 1000).format('YYYY-MM-DDTHH:mm')
}
/**
* Parse datetime-local input to Unix timestamp
*/
export function parseTimestampFromInput(value: string): number {
if (!value) {
return -1
}
const date = new Date(value)
return Math.floor(date.getTime() / 1000)
}
// ============================================================================
// Color Generation
// ============================================================================
/**
* Generate a consistent color from a string
* Uses HSL for better color distribution
*/
export function stringToColor(str: string): string {
if (!str) return 'gray'
// Generate hash from string
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
hash = hash & hash // Convert to 32-bit integer
}
// Use hash to generate hue (0-360)
const hue = Math.abs(hash % 360)
// Use saturation and lightness that work well for tags
const saturation = 65 + (Math.abs(hash) % 10) // 65-75%
const lightness = 55 + (Math.abs(hash >> 8) % 10) // 55-65%
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
}
+58
View File
@@ -0,0 +1,58 @@
/*
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 FRONTEND_CACHE_VERSION = 'default-v1'
const FRONTEND_CACHE_VERSION_KEY = 'newapi:default:cache-version'
const PRESERVED_LOCAL_STORAGE_KEYS = new Set([
FRONTEND_CACHE_VERSION_KEY,
'user',
'uid',
'aff',
'oauth:binding:result',
])
export function initializeFrontendCache(): void {
if (typeof window === 'undefined') return
try {
const currentVersion = window.localStorage.getItem(
FRONTEND_CACHE_VERSION_KEY
)
if (currentVersion === FRONTEND_CACHE_VERSION) return
clearLocalUiCache()
window.localStorage.setItem(
FRONTEND_CACHE_VERSION_KEY,
FRONTEND_CACHE_VERSION
)
} catch {
// Storage can be unavailable in private mode; the app should still boot.
}
}
function clearLocalUiCache(): void {
const keysToRemove: string[] = []
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index)
if (key && !PRESERVED_LOCAL_STORAGE_KEYS.has(key)) {
keysToRemove.push(key)
}
}
keysToRemove.forEach((key) => window.localStorage.removeItem(key))
}
+51
View File
@@ -0,0 +1,51 @@
/*
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 { AxiosError } from 'axios'
import i18next from 'i18next'
import { toast } from 'sonner'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
export function handleServerError(error: unknown) {
// eslint-disable-next-line no-console
console.log(error)
let errMsg = i18next.t('Something went wrong!')
const messageKey = getServerErrorMessageKey(error)
if (messageKey) {
toast.error(i18next.t(messageKey))
return
}
if (
error &&
typeof error === 'object' &&
'status' in error &&
Number(error.status) === 204
) {
errMsg = i18next.t('Content not found.')
}
if (error instanceof AxiosError) {
errMsg = error.response?.data.title
}
toast.error(errMsg)
}
+150
View File
@@ -0,0 +1,150 @@
/*
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 axios, { type AxiosRequestConfig } from 'axios'
import { t } from 'i18next'
import { toast } from 'sonner'
import {
applyAuthRotation,
clearAuthentication,
refreshAuthentication,
} from '@/lib/auth-session'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
import { useAuthStore } from '@/stores/auth-store'
declare module 'axios' {
export interface AxiosRequestConfig {
skipBusinessError?: boolean
skipErrorHandler?: boolean
disableDuplicate?: boolean
skipAuthRefresh?: boolean
authRetry?: boolean
acceptAuthRotation?: boolean
}
}
export type ApiRequestConfig = AxiosRequestConfig
export const api = axios.create({
baseURL: '',
withCredentials: true,
headers: {
'Cache-Control': 'no-store',
},
})
const inFlightGet = new Map<string, Promise<unknown>>()
const originalGet = api.get.bind(api)
api.get = ((url: string, config: ApiRequestConfig = {}) => {
if (config.disableDuplicate) return originalGet(url, config)
const params = config.params ? JSON.stringify(config.params) : '{}'
const sessionSID = useAuthStore.getState().auth.session?.sid || 'anonymous'
const key = `${sessionSID}:${url}?${params}`
const existingRequest = inFlightGet.get(key)
if (existingRequest) return existingRequest
const request = originalGet(url, config).finally(() => {
inFlightGet.delete(key)
})
inFlightGet.set(key, request)
return request
}) as typeof api.get
function redirectToSignIn(): void {
if (
typeof window !== 'undefined' &&
window.location.pathname !== '/sign-in'
) {
window.location.replace('/sign-in')
}
}
api.interceptors.response.use(
(response) => {
if (response.config.acceptAuthRotation && response.data?.success === true) {
applyAuthRotation(response.data.data)
}
if (
!response.config.skipBusinessError &&
typeof response.data?.success === 'boolean' &&
!response.data.success
) {
const messageKey = getServerErrorMessageKey(response.data)
toast.error(
messageKey
? t(messageKey)
: response.data.message || t('Request failed')
)
}
return response
},
async (error) => {
const config = error?.config as ApiRequestConfig | undefined
const skipErrorHandler = config?.skipErrorHandler
const status = error?.response?.status
if (status === 401) {
if (config && !config.skipAuthRefresh && !config.authRetry) {
config.authRetry = true
const outcome = await refreshAuthentication()
if (outcome.kind === 'authenticated') {
const token = useAuthStore.getState().auth.accessToken
if (token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`,
}
}
return api.request(config)
}
if (outcome.kind === 'anonymous' || outcome.kind === 'out_of_sync') {
if (!skipErrorHandler) toast.error(t('Session expired!'))
redirectToSignIn()
}
} else if (config?.authRetry) {
clearAuthentication(false)
if (!skipErrorHandler) toast.error(t('Session expired!'))
redirectToSignIn()
} else if (!skipErrorHandler) {
toast.error(t('Session expired!'))
}
} else if (!skipErrorHandler) {
const messageKey = getServerErrorMessageKey(error)
const message = messageKey
? t(messageKey)
: error?.response?.data?.message ||
error?.message ||
t('Request failed')
toast.error(message)
}
throw error
}
)
api.interceptors.request.use((config) => {
const accessToken = useAuthStore.getState().auth.accessToken
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`
}
return config
})
+136
View File
@@ -0,0 +1,136 @@
/*
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
*/
export type StatusCodeRange = {
start: number
end: number
}
export type ParsedHttpStatusCodeRules = {
ok: boolean
ranges: StatusCodeRange[]
tokens: string[]
normalized: string
invalidTokens: string[]
}
export function parseHttpStatusCodeRules(
input: unknown
): ParsedHttpStatusCodeRules {
const raw = (input ?? '').toString().trim()
if (raw.length === 0) {
return {
ok: true,
ranges: [],
tokens: [],
normalized: '',
invalidTokens: [],
}
}
const sanitized = raw.replace(/[]/g, ',')
const segments = sanitized
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const ranges: StatusCodeRange[] = []
const invalidTokens: string[] = []
for (const segment of segments) {
const parsed = parseToken(segment)
if (!parsed) {
invalidTokens.push(segment)
} else {
ranges.push(parsed)
}
}
if (invalidTokens.length > 0) {
return {
ok: false,
ranges: [],
tokens: [],
normalized: raw,
invalidTokens,
}
}
const merged = mergeRanges(ranges)
const tokens = merged.map((r) =>
r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`
)
const normalized = tokens.join(',')
return {
ok: true,
ranges: merged,
tokens,
normalized,
invalidTokens: [],
}
}
function parseToken(token: string): StatusCodeRange | null {
const cleaned = token.trim().replace(/\s/g, '')
if (!cleaned) return null
const isValidCode = (code: number) =>
Number.isFinite(code) && code >= 100 && code <= 599
if (cleaned.includes('-')) {
const [a, b] = cleaned.split('-')
if (!isNumber(a) || !isNumber(b)) return null
const start = Number.parseInt(a, 10)
const end = Number.parseInt(b, 10)
if (!isValidCode(start) || !isValidCode(end) || start > end) return null
return { start, end }
}
if (!isNumber(cleaned)) return null
const code = Number.parseInt(cleaned, 10)
if (!isValidCode(code)) return null
return { start: code, end: code }
}
function isNumber(s: string) {
return typeof s === 'string' && /^\d+$/.test(s)
}
function mergeRanges(ranges: StatusCodeRange[]): StatusCodeRange[] {
if (ranges.length === 0) return []
const sorted = [...ranges].sort((a, b) =>
a.start !== b.start ? a.start - b.start : a.end - b.end
)
return sorted.reduce<StatusCodeRange[]>((merged, current) => {
const last = merged[merged.length - 1]
if (!last || current.start > last.end + 1) {
merged.push({ ...current })
} else {
last.end = Math.max(last.end, current.end)
}
return merged
}, [])
}
+97
View File
@@ -0,0 +1,97 @@
/*
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 assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import { resolveLegacyRoute } from './legacy-route'
describe('legacy frontend route migration', () => {
test('maps former public and console routes to their current destinations', () => {
const routes = {
'/login': '/sign-in',
'/forbidden': '/403',
'/console': '/dashboard',
'/console/models': '/models',
'/console/deployment': '/models/deployments',
'/console/subscription': '/subscriptions',
'/console/channel': '/channels',
'/console/token': '/keys',
'/console/playground': '/playground',
'/console/redemption': '/redemption-codes',
'/console/user': '/users',
'/console/personal': '/profile',
'/console/log': '/usage-logs',
'/console/midjourney': '/usage-logs/drawing',
'/console/task': '/usage-logs/task',
'/console/chat/42': '/chat/42',
}
for (const [source, target] of Object.entries(routes)) {
assert.equal(resolveLegacyRoute(source), target)
}
})
test('preserves search and hash while applying route-specific behavior', () => {
assert.equal(
resolveLegacyRoute('/login?redirect=%2Fkeys#continue'),
'/sign-in?redirect=%2Fkeys#continue'
)
assert.equal(
resolveLegacyRoute('/console/topup?source=email#orders'),
'/wallet?source=email#orders'
)
})
test('maps legacy settings tabs and retains unrelated parameters', () => {
const settingsTabs = {
operation: '/system-settings/operations/behavior',
dashboard: '/system-settings/content/dashboard',
chats: '/system-settings/content/chat',
drawing: '/system-settings/content/drawing',
payment: '/system-settings/billing/payment',
ratio: '/system-settings/billing/model-pricing',
ratelimit: '/system-settings/security/rate-limit',
models: '/system-settings/models/global',
'model-deployment': '/system-settings/models/model-deployment',
performance: '/system-settings/operations/performance',
system: '/system-settings/site/system-info',
other: '/system-settings/site/system-info',
}
for (const [tab, target] of Object.entries(settingsTabs)) {
assert.equal(
resolveLegacyRoute(`/console/setting?tab=${tab}&from=bookmark#form`),
`${target}?tab=${tab}&from=bookmark#form`
)
}
assert.equal(
resolveLegacyRoute('/console/setting?tab=unknown'),
'/system-settings?tab=unknown'
)
})
test('safely redirects unknown console locations without touching new routes', () => {
assert.equal(
resolveLegacyRoute('/console/removed?page=2#old'),
'/dashboard?page=2#old'
)
assert.equal(resolveLegacyRoute('/dashboard'), null)
assert.equal(resolveLegacyRoute('/api/status'), null)
})
})
+104
View File
@@ -0,0 +1,104 @@
/*
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 legacyOrigin = 'https://legacy-route.invalid'
const legacyConsoleRoutes: Record<string, string> = {
'/console': '/dashboard',
'/console/models': '/models',
'/console/deployment': '/models/deployments',
'/console/subscription': '/subscriptions',
'/console/channel': '/channels',
'/console/token': '/keys',
'/console/playground': '/playground',
'/console/redemption': '/redemption-codes',
'/console/user': '/users',
'/console/personal': '/profile',
'/console/log': '/usage-logs',
'/console/midjourney': '/usage-logs/drawing',
'/console/task': '/usage-logs/task',
}
const legacySettingsTabs: Record<string, string> = {
operation: '/system-settings/operations/behavior',
dashboard: '/system-settings/content/dashboard',
chats: '/system-settings/content/chat',
drawing: '/system-settings/content/drawing',
payment: '/system-settings/billing/payment',
ratio: '/system-settings/billing/model-pricing',
ratelimit: '/system-settings/security/rate-limit',
models: '/system-settings/models/global',
'model-deployment': '/system-settings/models/model-deployment',
performance: '/system-settings/operations/performance',
system: '/system-settings/site/system-info',
other: '/system-settings/site/system-info',
}
function normalizeLegacyPath(pathname: string): string {
if (pathname === '/') return pathname
return pathname.replace(/\/+$/, '')
}
function buildTargetHref(targetPath: string, source: URL): string {
const target = new URL(targetPath, legacyOrigin)
source.searchParams.forEach((value, key) => {
target.searchParams.append(key, value)
})
target.hash = source.hash
return `${target.pathname}${target.search}${target.hash}`
}
export function resolveLegacyRoute(rawHref: string): string | null {
let source: URL
try {
source = new URL(rawHref, legacyOrigin)
} catch {
return null
}
const pathname = normalizeLegacyPath(source.pathname)
if (pathname === '/login') {
return buildTargetHref('/sign-in', source)
}
if (pathname === '/forbidden') {
return buildTargetHref('/403', source)
}
if (pathname === '/console/topup') {
return buildTargetHref('/wallet', source)
}
if (pathname === '/console/setting') {
const tab = source.searchParams.get('tab') ?? ''
const target = legacySettingsTabs[tab] ?? '/system-settings'
return buildTargetHref(target, source)
}
if (pathname === '/console/chat') {
return buildTargetHref('/dashboard', source)
}
if (pathname.startsWith('/console/chat/')) {
const chatID = pathname.slice('/console/chat/'.length)
return buildTargetHref(chatID ? `/chat/${chatID}` : '/dashboard', source)
}
const target = legacyConsoleRoutes[pathname]
if (target) return buildTargetHref(target, source)
if (pathname.startsWith('/console/')) {
return buildTargetHref('/dashboard', source)
}
return null
}
+164
View File
@@ -0,0 +1,164 @@
/*
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
*/
/**
* LobeHub Icon Loader
* Dynamically load and render icons from @lobehub/icons
*
* Supports:
* - Basic: "OpenAI", "OpenAI.Color"
* - Chained properties: "OpenAI.Avatar.type={'platform'}"
* - Size parameter: getLobeIcon("OpenAI", 20)
*/
import * as LobeIcons from '@lobehub/icons'
/**
* Parse a property value from string to appropriate type
* @param raw - Raw string value
* @returns Parsed value (boolean, number, or string)
*/
function parseValue(raw: string | undefined | null): string | number | boolean {
if (raw == null) return true
let v = String(raw).trim()
// Remove curly braces
if (v.startsWith('{') && v.endsWith('}')) {
v = v.slice(1, -1).trim()
}
// Remove quotes
if (
(v.startsWith('"') && v.endsWith('"')) ||
(v.startsWith("'") && v.endsWith("'"))
) {
return v.slice(1, -1)
}
// Boolean
if (v === 'true') return true
if (v === 'false') return false
// Number
if (/^-?\d+(?:\.\d+)?$/.test(v)) return Number(v)
// Return as string
return v
}
/**
* Get LobeHub icon component by name
* @param iconName - Icon name/description (e.g., "OpenAI", "OpenAI.Color", "Claude.Avatar")
* @param size - Icon size (default: 20)
* @returns Icon component or fallback
*
* @example
* getLobeIcon("OpenAI", 24)
* getLobeIcon("OpenAI.Color", 20)
* getLobeIcon("Claude.Avatar.type={'platform'}", 32)
*/
export function getLobeIcon(
iconName: string | undefined | null,
size: number = 20
): React.ReactNode {
if (!iconName || typeof iconName !== 'string') {
return (
<div
className='bg-muted text-muted-foreground flex items-center justify-center rounded-full text-xs font-medium'
style={{ width: size, height: size }}
>
?
</div>
)
}
const trimmedName = iconName.trim()
if (!trimmedName) {
return (
<div
className='bg-muted text-muted-foreground flex items-center justify-center rounded-full text-xs font-medium'
style={{ width: size, height: size }}
>
?
</div>
)
}
// Parse component path and chained properties
const segments = trimmedName.split('.')
const baseKey = segments[0]
const BaseIcon = (LobeIcons as Record<string, unknown>)[baseKey] as
| Record<string, unknown>
| undefined
let IconComponent: React.ComponentType<Record<string, unknown>> | undefined
let propStartIndex: number
if (BaseIcon && segments.length > 1 && BaseIcon[segments[1]]) {
IconComponent = BaseIcon[segments[1]] as React.ComponentType<
Record<string, unknown>
>
propStartIndex = 2
} else {
IconComponent = (LobeIcons as Record<string, unknown>)[baseKey] as
| React.ComponentType<Record<string, unknown>>
| undefined
propStartIndex = segments.length > 1 && /^[A-Z]/.test(segments[1]) ? 2 : 1
}
// Fallback if icon not found
if (
!IconComponent ||
(typeof IconComponent !== 'function' && typeof IconComponent !== 'object')
) {
const firstLetter = trimmedName.charAt(0).toUpperCase()
return (
<div
className='bg-muted text-muted-foreground flex items-center justify-center rounded-full text-xs font-medium'
style={{ width: size, height: size }}
>
{firstLetter}
</div>
)
}
// Parse chained properties (e.g., "type={'platform'}", "shape='square'")
const props: Record<string, string | number | boolean> = {}
for (let i = propStartIndex; i < segments.length; i++) {
const seg = segments[i]
if (!seg) continue
const eqIdx = seg.indexOf('=')
if (eqIdx === -1) {
props[seg.trim()] = true
continue
}
const key = seg.slice(0, eqIdx).trim()
const valRaw = seg.slice(eqIdx + 1).trim()
props[key] = parseValue(valRaw)
}
// Set size if not explicitly specified in the string
if (props.size == null && size != null) {
props.size = size
}
return <IconComponent {...props} />
}
+122
View File
@@ -0,0 +1,122 @@
/*
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 { Transition, Variants } from 'motion/react'
const EASE_OUT_CUBIC = [0.33, 1, 0.68, 1] as const
const DURATION = {
instant: 0,
fast: 0.15,
normal: 0.25,
slow: 0.35,
} as const
export const MOTION_TRANSITION: Record<string, Transition> = {
default: { duration: DURATION.normal, ease: EASE_OUT_CUBIC },
fast: { duration: DURATION.fast, ease: EASE_OUT_CUBIC },
slow: { duration: DURATION.slow, ease: EASE_OUT_CUBIC },
spring: { type: 'spring', damping: 20, stiffness: 300 },
none: { duration: DURATION.instant },
}
export const MOTION_VARIANTS = {
pageEnter: {
initial: { opacity: 0, y: 8, filter: 'blur(4px)' },
animate: { opacity: 1, y: 0, filter: 'blur(0px)' },
exit: { opacity: 0, y: -4, filter: 'blur(2px)' },
},
fadeIn: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
},
scaleIn: {
initial: { opacity: 0, scale: 0.96 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.96 },
},
slideUp: {
initial: { opacity: 0, y: 16 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 16 },
},
slideDown: {
initial: { opacity: 0, y: -16 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -16 },
},
tableRow: {
initial: { opacity: 0, y: 4 },
animate: { opacity: 1, y: 0 },
},
cardItem: {
initial: { opacity: 0, y: 12, scale: 0.98 },
animate: { opacity: 1, y: 0, scale: 1 },
},
sidebarSlide: {
initial: { opacity: 0, x: -8 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -8 },
},
} as const
export const STAGGER_VARIANTS: Variants = {
initial: {},
animate: { transition: { staggerChildren: 0.04 } },
}
export const STAGGER_ITEM_VARIANTS: Variants = {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0, transition: MOTION_TRANSITION.default },
}
export const TABLE_STAGGER_VARIANTS: Variants = {
initial: {},
animate: { transition: { staggerChildren: 0.03 } },
}
export const TABLE_ROW_VARIANTS: Variants = {
initial: { opacity: 0, y: 4 },
animate: { opacity: 1, y: 0, transition: MOTION_TRANSITION.fast },
}
export const CARD_STAGGER_VARIANTS: Variants = {
initial: {},
animate: { transition: { staggerChildren: 0.05 } },
}
export const CARD_ITEM_VARIANTS: Variants = {
initial: { opacity: 0, y: 12, scale: 0.98 },
animate: {
opacity: 1,
y: 0,
scale: 1,
transition: MOTION_TRANSITION.default,
},
}
export const SIDEBAR_STAGGER_VARIANTS: Variants = {
initial: {},
animate: { transition: { staggerChildren: 0.03, delayChildren: 0.05 } },
}
export const SIDEBAR_ITEM_VARIANTS: Variants = {
initial: { opacity: 0, x: -8 },
animate: { opacity: 1, x: 0, transition: MOTION_TRANSITION.fast },
}
+211
View File
@@ -0,0 +1,211 @@
/*
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 { getStatus } from '@/lib/api'
export type ModuleAccess = { enabled: boolean; requireAuth: boolean }
export type HeaderNavModule = 'rankings' | 'pricing'
export type HeaderNavModules = {
home: boolean
console: boolean
pricing: ModuleAccess
rankings: ModuleAccess
docs: boolean
about: boolean
[key: string]: boolean | ModuleAccess
}
const DEFAULT_HEADER_NAV_MODULES: HeaderNavModules = {
home: true,
console: true,
pricing: { enabled: true, requireAuth: false },
rankings: { enabled: true, requireAuth: false },
docs: true,
about: true,
}
const DEFAULTS: Record<HeaderNavModule, ModuleAccess> = {
pricing: DEFAULT_HEADER_NAV_MODULES.pricing,
rankings: DEFAULT_HEADER_NAV_MODULES.rankings,
}
function cloneHeaderNavDefaults(): HeaderNavModules {
return {
...DEFAULT_HEADER_NAV_MODULES,
pricing: { ...DEFAULT_HEADER_NAV_MODULES.pricing },
rankings: { ...DEFAULT_HEADER_NAV_MODULES.rankings },
}
}
export function parseHeaderNavBoolean(
raw: unknown,
fallback: boolean
): boolean {
if (typeof raw === 'boolean') return raw
if (typeof raw === 'number') {
if (raw === 1) return true
if (raw === 0) return false
return fallback
}
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase()
if (normalized === 'true' || normalized === '1') return true
if (normalized === 'false' || normalized === '0') return false
}
return fallback
}
function parseAccess(raw: unknown, fallback: ModuleAccess): ModuleAccess {
if (
typeof raw === 'boolean' ||
typeof raw === 'number' ||
typeof raw === 'string'
) {
return {
enabled: parseHeaderNavBoolean(raw, fallback.enabled),
requireAuth: fallback.requireAuth,
}
}
if (raw && typeof raw === 'object') {
const r = raw as Record<string, unknown>
return {
enabled: parseHeaderNavBoolean(r.enabled, fallback.enabled),
requireAuth: parseHeaderNavBoolean(r.requireAuth, fallback.requireAuth),
}
}
return { ...fallback }
}
function parseHeaderNavRecord(raw: unknown): Record<string, unknown> | null {
if (!raw || String(raw).trim() === '') return null
if (raw && typeof raw === 'object') return raw as Record<string, unknown>
try {
return JSON.parse(String(raw)) as Record<string, unknown>
} catch {
return null
}
}
export function parseHeaderNavModules(raw: unknown): HeaderNavModules {
const result = cloneHeaderNavDefaults()
const parsed = parseHeaderNavRecord(raw)
if (!parsed) return result
Object.entries(parsed).forEach(([key, value]) => {
if (key === 'pricing') {
result.pricing = parseAccess(value, result.pricing)
return
}
if (key === 'rankings') {
result.rankings = parseAccess(value, result.rankings)
return
}
const fallback = result[key]
if (
typeof fallback === 'boolean' ||
typeof value === 'boolean' ||
typeof value === 'number' ||
typeof value === 'string'
) {
result[key] = parseHeaderNavBoolean(
value,
typeof fallback === 'boolean' ? fallback : true
)
}
})
return result
}
export function parseHeaderNavModulesFromStatus(
status: Record<string, unknown> | null
): HeaderNavModules {
return parseHeaderNavModules(status?.HeaderNavModules)
}
function getCachedStatus(): Record<string, unknown> | null {
try {
if (typeof window === 'undefined') return null
const raw = window.localStorage.getItem('status')
return raw ? (JSON.parse(raw) as Record<string, unknown>) : null
} catch {
return null
}
}
function cacheStatus(status: Record<string, unknown> | null): void {
try {
if (typeof window !== 'undefined' && status) {
window.localStorage.setItem('status', JSON.stringify(status))
}
} catch {
/* empty */
}
}
export function getModuleAccessFromStatus(
status: Record<string, unknown> | null,
module: HeaderNavModule
): ModuleAccess {
return parseHeaderNavModulesFromStatus(status)[module] ?? DEFAULTS[module]
}
export function getModuleAccess(module: HeaderNavModule): ModuleAccess {
return getModuleAccessFromStatus(getCachedStatus(), module)
}
export async function getFreshModuleAccess(
module: HeaderNavModule
): Promise<ModuleAccess> {
try {
const status = (await getStatus()) as Record<string, unknown> | null
cacheStatus(status)
return getModuleAccessFromStatus(status, module)
} catch {
return { enabled: false, requireAuth: true }
}
}
export function isSidebarModuleEnabled(
section: string,
module: string
): boolean {
const status = getCachedStatus()
if (!status) return true
const raw = status.SidebarModulesAdmin
if (!raw || String(raw).trim() === '') return true
try {
const parsed = JSON.parse(String(raw)) as Record<
string,
Record<string, boolean>
>
const sectionConfig = parsed[section]
if (!sectionConfig) return true
if (sectionConfig.enabled === false) return false
if (sectionConfig[module] === false) return false
return true
} catch {
return true
}
}
+68
View File
@@ -0,0 +1,68 @@
/*
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
*/
// ============================================================================
// OAuth URL Builders
// ============================================================================
/**
* Build GitHub OAuth URL
*/
export function buildGitHubOAuthUrl(clientId: string, state: string): string {
return `https://github.com/login/oauth/authorize?client_id=${clientId}&state=${state}&scope=user:email`
}
/**
* Build Discord OAuth URL
*/
export function buildDiscordOAuthUrl(clientId: string, state: string): string {
const url = new URL('https://discord.com/oauth2/authorize')
url.searchParams.set('client_id', clientId)
url.searchParams.set(
'redirect_uri',
`${window.location.origin}/oauth/discord`
)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', 'identify+openid')
url.searchParams.set('state', state)
return url.toString()
}
/**
* Build OIDC OAuth URL
*/
export function buildOIDCOAuthUrl(
authUrl: string,
clientId: string,
state: string
): string {
const url = new URL(authUrl)
url.searchParams.set('client_id', clientId)
url.searchParams.set('redirect_uri', `${window.location.origin}/oauth/oidc`)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', 'openid profile email')
url.searchParams.set('state', state)
return url.toString()
}
/**
* Build LinuxDO OAuth URL
*/
export function buildLinuxDOOAuthUrl(clientId: string, state: string): string {
return `https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${clientId}&state=${state}`
}
+289
View File
@@ -0,0 +1,289 @@
/*
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
*/
/**
* Passkey helper utilities for WebAuthn credential handling.
*
* These helpers convert between ArrayBuffer and Base64URL encodings and
* normalise server-provided credential options into browser-compatible types.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Convert a base64url string to an ArrayBuffer.
*/
type NodeBufferCtor = {
from(input: string, encoding: string): { toString(encoding: string): string }
}
export function base64UrlToArrayBuffer(value?: string | null): ArrayBuffer {
if (!value) return new ArrayBuffer(0)
const padding = '='.repeat((4 - (value.length % 4)) % 4)
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
const globalRef = globalThis as typeof globalThis & {
Buffer?: NodeBufferCtor
}
const decode =
typeof globalRef.atob === 'function'
? globalRef.atob.bind(globalRef)
: (input: string) => {
if (typeof globalRef.Buffer !== 'undefined') {
return globalRef.Buffer.from(input, 'base64').toString('binary')
}
throw new Error(
'Base64 decoding is not supported in this environment'
)
}
const binary = decode(base64)
const buffer = new ArrayBuffer(binary.length)
const bytes = new Uint8Array(buffer)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return buffer
}
/**
* Convert an ArrayBuffer to a base64url string.
*/
export function arrayBufferToBase64Url(
buffer?: ArrayBuffer | ArrayBufferLike | null
): string {
if (!buffer) return ''
const globalRef = globalThis as typeof globalThis & {
Buffer?: NodeBufferCtor
}
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i += 1) {
binary += String.fromCharCode(bytes[i])
}
const encode =
typeof globalRef.btoa === 'function'
? globalRef.btoa.bind(globalRef)
: (input: string) => {
if (typeof globalRef.Buffer !== 'undefined') {
return globalRef.Buffer.from(input, 'binary').toString('base64')
}
throw new Error(
'Base64 encoding is not supported in this environment'
)
}
return encode(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
}
/**
* Prepare credential creation options returned by the backend.
*/
export function prepareCredentialCreationOptions(
payload: any
): PublicKeyCredentialCreationOptions {
const options =
payload?.publicKey ??
payload?.PublicKey ??
payload?.response ??
payload?.Response
if (!options) {
throw new Error(
'Unable to parse Passkey registration options from response'
)
}
const publicKey: PublicKeyCredentialCreationOptions & Record<string, any> = {
...options,
challenge: base64UrlToArrayBuffer(options.challenge),
user: {
...options.user,
id: base64UrlToArrayBuffer(options.user?.id),
},
}
if (Array.isArray(options.excludeCredentials)) {
publicKey.excludeCredentials = options.excludeCredentials.map(
(item: any) => ({
...item,
id: base64UrlToArrayBuffer(item.id),
})
)
}
if (
Array.isArray(options.attestationFormats) &&
options.attestationFormats.length === 0
) {
delete publicKey.attestationFormats
}
return publicKey
}
/**
* Prepare credential request options returned by the backend.
*/
export function prepareCredentialRequestOptions(
payload: any
): PublicKeyCredentialRequestOptions {
const options =
payload?.publicKey ??
payload?.PublicKey ??
payload?.response ??
payload?.Response
if (!options) {
throw new Error('Unable to parse Passkey login options from response')
}
const publicKey: PublicKeyCredentialRequestOptions & Record<string, any> = {
...options,
challenge: base64UrlToArrayBuffer(options.challenge),
}
if (Array.isArray(options.allowCredentials)) {
publicKey.allowCredentials = options.allowCredentials.map((item: any) => ({
...item,
id: base64UrlToArrayBuffer(item.id),
}))
}
return publicKey
}
/**
* Build payload for registering a new credential.
*/
export function buildRegistrationResult(
credential: PublicKeyCredential | null
): Record<string, any> | null {
if (!credential) return null
const response = credential.response as AuthenticatorAttestationResponse & {
getTransports?: () => string[]
}
const transports =
typeof response.getTransports === 'function'
? response.getTransports()
: undefined
return {
id: credential.id,
rawId: arrayBufferToBase64Url(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment,
response: {
attestationObject: arrayBufferToBase64Url(response.attestationObject),
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
transports,
},
clientExtensionResults: credential.getClientExtensionResults?.() ?? {},
}
}
/**
* Build payload for verifying an existing credential.
*/
export function buildAssertionResult(
credential: PublicKeyCredential | null
): Record<string, any> | null {
if (!credential) return null
const response = credential.response as AuthenticatorAssertionResponse
return {
id: credential.id,
rawId: arrayBufferToBase64Url(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment,
response: {
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
signature: arrayBufferToBase64Url(response.signature),
userHandle: response.userHandle
? arrayBufferToBase64Url(response.userHandle)
: null,
},
clientExtensionResults: credential.getClientExtensionResults?.() ?? {},
}
}
/**
* Check if current environment supports Passkey/WebAuthn.
*/
export async function isPasskeySupported(): Promise<boolean> {
if (typeof window === 'undefined') return false
const { PublicKeyCredential } = window
if (!PublicKeyCredential) return false
if (
typeof PublicKeyCredential.isConditionalMediationAvailable === 'function'
) {
try {
const available =
await PublicKeyCredential.isConditionalMediationAvailable()
if (available) return true
} catch {
// ignore
}
}
if (
typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
'function'
) {
try {
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
} catch {
return false
}
}
return true
}
/**
* Execute an async Passkey credential creation flow.
*/
export async function createCredential(
options: PublicKeyCredentialCreationOptions
) {
return navigator.credentials.create({ publicKey: options })
}
/**
* Execute an async Passkey credential request flow.
*/
export async function getCredential(
options: PublicKeyCredentialRequestOptions
) {
return navigator.credentials.get({ publicKey: options })
}
+45
View File
@@ -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 { t } from 'i18next'
export const ROLE = {
GUEST: 0, // 后续如果需要用到这个角色那就再加,同语先留一下
USER: 1,
ADMIN: 10,
SUPER_ADMIN: 100,
} as const
export type RoleValue = (typeof ROLE)[keyof typeof ROLE]
const DEFAULT_ROLE = ROLE.GUEST
const ROLE_LABEL_KEYS: Record<RoleValue, string> = {
[ROLE.SUPER_ADMIN]: 'Super Admin',
[ROLE.ADMIN]: 'Admin',
[ROLE.USER]: 'User',
[ROLE.GUEST]: 'Guest',
}
export function getRoleLabelKey(role?: number): string {
return ROLE_LABEL_KEYS[role as RoleValue] ?? ROLE_LABEL_KEYS[DEFAULT_ROLE]
}
export function getRoleLabel(role?: number): string {
return t(getRoleLabelKey(role))
}
+71
View File
@@ -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
*/
import type { AxiosError } from 'axios'
export interface VerificationRequiredInfo {
code?: string
message: string
required: boolean
}
/**
* Determine whether an Axios error indicates secure verification is required.
*/
export function isVerificationRequiredError(
error: unknown
): error is AxiosError {
if (!error || typeof error !== 'object') return false
const axiosError = error as AxiosError<{ code?: string }>
const status = axiosError.response?.status
if (status !== 403) return false
const code = axiosError.response?.data?.code
if (!code) return false
const verificationCodes = new Set([
'VERIFICATION_REQUIRED',
'VERIFICATION_EXPIRED',
'VERIFICATION_INVALID',
'SECURITY_PROOF_REQUIRED',
'SECURITY_PROOF_EXPIRED',
'SECURITY_PROOF_INVALID',
'SECURITY_PROOF_SCOPE_MISMATCH',
'SECURITY_PROOF_METHOD_MISMATCH',
])
return verificationCodes.has(code)
}
/**
* Extract verification requirement info from an Axios error.
*/
export function extractVerificationInfo(
error: unknown
): VerificationRequiredInfo {
const axiosError = error as AxiosError<{ code?: string; message?: string }>
const code = axiosError.response?.data?.code
const message =
axiosError.response?.data?.message ?? 'Secure verification is required'
return {
code,
message,
required: true,
}
}
+70
View File
@@ -0,0 +1,70 @@
/*
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 assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import { getServerErrorMessageKey } from './server-error-message'
describe('server error message mapping', () => {
test('maps the active-session limit to recovery instructions', () => {
const message = getServerErrorMessageKey({ code: 'AUTH_SESSION_LIMIT' })
assert.match(message ?? '', /Sign out other sessions/)
assert.match(message ?? '', /reset your password/)
})
test('maps an Axios-shaped issuance limit to rolling-window guidance', () => {
const message = getServerErrorMessageKey({
response: { data: { code: 'AUTH_SESSION_ISSUANCE_LIMIT' } },
})
assert.match(message ?? '', /rolling window/)
assert.equal(getServerErrorMessageKey({ code: 'UNKNOWN_CODE' }), null)
})
test('maps stable Telegram bind errors without exposing server text', () => {
const expected = {
TELEGRAM_BIND_DISABLED: 'Telegram binding is disabled.',
TELEGRAM_BIND_INVALID_REQUEST:
'The Telegram authorization request is invalid or expired.',
TELEGRAM_BIND_FLOW_INVALID:
'This Telegram binding request has expired or has already been used.',
TELEGRAM_BIND_SESSION_INVALID:
'The login session that started this Telegram binding is no longer valid.',
TELEGRAM_BIND_ALREADY_BOUND: 'This Telegram account is already bound.',
TELEGRAM_BIND_USER_DELETED: 'This user account no longer exists.',
TELEGRAM_BIND_USER_DISABLED: 'This user account is disabled.',
TELEGRAM_BIND_INTERNAL_ERROR:
'Telegram binding failed. Please try again.',
}
for (const [code, message] of Object.entries(expected)) {
assert.equal(getServerErrorMessageKey({ code }), message)
}
assert.equal(
getServerErrorMessageKey({
response: {
data: { code: 'TELEGRAM_BIND_INTERNAL_ERROR', message: 'raw detail' },
},
}),
expected.TELEGRAM_BIND_INTERNAL_ERROR
)
})
})
+60
View File
@@ -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
*/
const serverErrorMessageKeys = {
AUTH_SESSION_LIMIT:
'Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.',
AUTH_SESSION_ISSUANCE_LIMIT:
'Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.',
TELEGRAM_BIND_DISABLED: 'Telegram binding is disabled.',
TELEGRAM_BIND_INVALID_REQUEST:
'The Telegram authorization request is invalid or expired.',
TELEGRAM_BIND_FLOW_INVALID:
'This Telegram binding request has expired or has already been used.',
TELEGRAM_BIND_SESSION_INVALID:
'The login session that started this Telegram binding is no longer valid.',
TELEGRAM_BIND_ALREADY_BOUND: 'This Telegram account is already bound.',
TELEGRAM_BIND_USER_DELETED: 'This user account no longer exists.',
TELEGRAM_BIND_USER_DISABLED: 'This user account is disabled.',
TELEGRAM_BIND_INTERNAL_ERROR: 'Telegram binding failed. Please try again.',
} as const
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object'
}
function serverErrorPayload(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null
const response = value.response
if (isRecord(response) && isRecord(response.data)) {
return response.data
}
return value
}
export function getServerErrorMessageKey(value: unknown): string | null {
const payload = serverErrorPayload(value)
if (!payload || typeof payload.code !== 'string') return null
return (
serverErrorMessageKeys[
payload.code as keyof typeof serverErrorMessageKeys
] ?? null
)
}
+33
View File
@@ -0,0 +1,33 @@
/*
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 { toast } from 'sonner'
export function showSubmittedData(
data: unknown,
title: string = 'You submitted the following values:'
) {
toast.message(title, {
description: (
// w-[340px]
<pre className='bg-muted text-foreground mt-2 w-full overflow-x-auto rounded-md p-4'>
<code>{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
+197
View File
@@ -0,0 +1,197 @@
/*
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
*/
/**
* Theme customization constants and types.
*
* Lives in `lib/` (not `context/`) so it can be imported alongside the
* provider without breaking React Fast Refresh boundaries.
*/
export const THEME_PRESETS = [
{
value: 'default',
name: 'Default',
swatches: ['oklch(0.72 0.18 250)', 'oklch(0.7 0.12 280)'],
},
{
// Inspired by Anthropic's official brand language: warm cream canvas
// (#faf9f5) paired with clay/coral (#d97757) as the single accent.
// Swatches preview the canvas → accent gradient that defines the system.
value: 'anthropic',
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',
swatches: ['oklch(0.5315 0.0694 156.19)', 'oklch(0.5748 0.0862 336.52)'],
},
{
value: 'rose-garden',
name: 'Rose Garden',
swatches: ['oklch(0.5827 0.2418 12.23)', 'oklch(0.8131 0.1129 5.67)'],
},
{
value: 'lake-view',
name: 'Lake View',
swatches: ['oklch(0.765 0.177 163.22)', 'oklch(0.551 0.0899 200.52)'],
},
{
value: 'sunset-glow',
name: 'Sunset Glow',
swatches: ['oklch(0.5591 0.1882 25.33)', 'oklch(0.7938 0.1248 42.42)'],
},
{
value: 'forest-whisper',
name: 'Forest Whisper',
swatches: ['oklch(0.5276 0.1072 182.22)', 'oklch(0.5236 0.0505 250.18)'],
},
{
value: 'ocean-breeze',
name: 'Ocean Breeze',
swatches: ['oklch(0.5461 0.2152 262.88)', 'oklch(0.5854 0.2041 277.12)'],
},
{
value: 'lavender-dream',
name: 'Lavender Dream',
swatches: ['oklch(0.5709 0.1808 306.89)', 'oklch(0.811 0.0589 201.14)'],
},
] as const
export type ThemePreset = (typeof THEME_PRESETS)[number]['value']
export type ThemeRadius = 'default' | 'none' | 'sm' | 'md' | 'lg' | 'xl'
export type ThemeScale = 'default' | 'sm' | 'lg' | 'xl'
export type ContentLayout = 'full' | 'centered'
/**
* Font axis for the theme.
*
* - `default` — resolve at runtime from the active preset
* (see `PRESET_DEFAULT_FONT`). The shipped `default` and `anthropic`
* presets resolve to serif; other named color presets fall back to
* sans unless they list a different choice. Mirrors how
* `radius: 'default'` defers to a per-preset hint.
* - `sans` — humanist sans (Public Sans), the project's UI fallback.
* - `serif` — editorial serif (Lora + CJK fallbacks), the project's
* "soul" typography. Inherits across the whole UI; monospace contexts
* keep their own family via Tailwind preflight and `.font-mono`.
*/
export type ThemeFont = 'default' | 'sans' | 'serif'
/**
* The resolved (non-`default`) font value applied to the DOM. The provider
* always sets `data-theme-font` to one of these concrete values so CSS only
* needs simple attribute selectors (no `:not()` gymnastics, no per-preset
* font branches).
*/
export type ResolvedThemeFont = Exclude<ThemeFont, 'default'>
export type ThemeCustomization = {
preset: ThemePreset
font: ThemeFont
radius: ThemeRadius
scale: ThemeScale
contentLayout: ContentLayout
}
export const DEFAULT_THEME_CUSTOMIZATION: ThemeCustomization = {
preset: 'default',
font: 'default',
radius: 'default',
scale: 'default',
contentLayout: 'full',
}
export const THEME_PRESET_VALUES = new Set(
THEME_PRESETS.map((p) => p.value)
) as ReadonlySet<ThemePreset>
export const THEME_FONT_VALUES: ReadonlySet<ThemeFont> = new Set([
'default',
'sans',
'serif',
])
export const THEME_RADIUS_VALUES: ReadonlySet<ThemeRadius> = new Set([
'default',
'none',
'sm',
'md',
'lg',
'xl',
])
export const THEME_SCALE_VALUES: ReadonlySet<ThemeScale> = new Set([
'default',
'sm',
'lg',
'xl',
])
export const CONTENT_LAYOUT_VALUES: ReadonlySet<ContentLayout> = new Set([
'full',
'centered',
])
export const THEME_COOKIE_KEYS = {
preset: 'theme_preset',
font: 'theme_font',
radius: 'theme_radius',
scale: 'theme_scale',
contentLayout: 'theme_content_layout',
} as const
/**
* Preset → default font mapping. Used by the provider to resolve the user's
* `font: 'default'` preference against the active preset.
*
* Co-located with the preset registry so a preset's signature typography
* is declared in one place. Presets not listed here fall back to the
* `resolveThemeFont` default of `sans`. The shipped `default` preset
* opts into serif so the editorial Lora voice is the out-of-the-box
* experience; vivid color presets stay on the humanist sans so their
* accents read clearly without competing with the body type.
*/
export const PRESET_DEFAULT_FONT: Partial<
Record<ThemePreset, ResolvedThemeFont>
> = {
default: 'sans',
anthropic: 'serif',
}
/**
* Resolve a user font preference + active preset into the concrete font that
* should drive the DOM. Pure function so it's safe to call inside both the
* effect that applies the attribute and the UI preview that hints at what
* `default` will render as.
*/
export function resolveThemeFont(
font: ThemeFont,
preset: ThemePreset
): ResolvedThemeFont {
if (font === 'default') {
return PRESET_DEFAULT_FONT[preset] ?? 'sans'
}
return font
}
+51
View File
@@ -0,0 +1,51 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
export function resolveThemeRadiusPx(
cssVariable = '--radius-md'
): number | undefined {
if (typeof document === 'undefined') return undefined
const probe = document.createElement('div')
probe.style.borderRadius = `var(${cssVariable})`
probe.style.pointerEvents = 'none'
probe.style.position = 'absolute'
probe.style.visibility = 'hidden'
document.documentElement.appendChild(probe)
const resolvedRadius = getComputedStyle(probe).borderTopLeftRadius
probe.remove()
const parsedRadius = Number.parseFloat(resolvedRadius)
return Number.isFinite(parsedRadius) ? parsedRadius : undefined
}
export function useThemeRadiusPx(
cssVariable = '--radius-md',
refreshKey?: string
): number | undefined {
const [radius, setRadius] = useState<number | undefined>()
useEffect(() => {
setRadius(resolveThemeRadiusPx(cssVariable))
}, [cssVariable, refreshKey])
return radius
}
+203
View File
@@ -0,0 +1,203 @@
/*
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
*/
/**
* Time utility functions for consistent time handling across the application
*/
import dayjs from '@/lib/dayjs'
/**
* Time granularity type
*/
export type TimeGranularity = 'hour' | 'day' | 'week'
/**
* Convert Date object to Unix timestamp (seconds)
*/
export function dateToUnixTimestamp(date: Date): number {
return Math.floor(date.getTime() / 1000)
}
/**
* Get start of day for a Unix timestamp (seconds)
* Sets time to 00:00:00
*/
export function toStartOfDay(tsSec: number): number {
const d = new Date(tsSec * 1000)
d.setHours(0, 0, 0, 0)
return Math.floor(d.getTime() / 1000)
}
/**
* Get start of day for a Date object
* Returns new Date with time set to 00:00:00
*/
export function getStartOfDay(date: Date = new Date()): Date {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
return d
}
/**
* Get end of day for a Date object
* Returns new Date with time set to 23:59:59.999
*/
export function getEndOfDay(date: Date = new Date()): Date {
const d = new Date(date)
d.setHours(23, 59, 59, 999)
return d
}
/**
* Calculate date range with start and end of day normalization
* @param days Number of days to go back
* @param fromDate Starting point (defaults to now)
* @returns Object with normalized start (00:00:00) and end (23:59:59) dates
*/
export function getNormalizedDateRange(
days: number,
fromDate: Date = new Date()
): { start: Date; end: Date } {
const end = new Date(fromDate)
const start = new Date(fromDate)
start.setDate(end.getDate() - days)
return {
start: getStartOfDay(start),
end: getEndOfDay(end),
}
}
/**
* Calculate a rolling date range ending at the current moment.
* Example: 1 day means the last 24 hours, not yesterday 00:00 to today 23:59.
*/
export function getRollingDateRange(
days: number,
fromDate: Date = new Date()
): { start: Date; end: Date } {
const end = new Date(fromDate)
const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000)
return { start, end }
}
/**
* Compute time range as Unix timestamps (seconds)
* @param days Default number of days if no dates provided
* @param startDate Optional start date
* @param endDate Optional end date
* @param useStartOfDay Whether to normalize to start/end of day
* @returns Object with start_timestamp and end_timestamp in seconds
*/
export function computeTimeRange(
days: number,
startDate?: Date,
endDate?: Date,
useStartOfDay = false
): { start_timestamp: number; end_timestamp: number } {
const now = Math.floor(Date.now() / 1000)
if (useStartOfDay) {
const defaultEnd = toStartOfDay(now)
const end = endDate
? toStartOfDay(dateToUnixTimestamp(endDate))
: defaultEnd
const start = startDate
? toStartOfDay(dateToUnixTimestamp(startDate))
: end - days * 24 * 3600
return {
start_timestamp: start,
end_timestamp: end + 24 * 3600 - 1, // End of day
}
}
// Normal mode without day normalization
// Add 1 hour buffer to end time (matches legacy frontend behavior)
// This ensures the current hour's data is fully included
const end = endDate ? dateToUnixTimestamp(endDate) : now + 3600
const start = startDate
? dateToUnixTimestamp(startDate)
: end - days * 24 * 3600
return { start_timestamp: start, end_timestamp: end }
}
/**
* Format Unix timestamp (seconds) to YYYY-MM-DD
*/
export function formatDate(tsSec: number): string {
return dayjs(tsSec * 1000).format('YYYY-MM-DD')
}
/**
* Format Date object to YYYY-MM-DD HH:mm:ss
*/
export function formatDateTimeObject(date: Date): string {
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
/**
* Format timestamp for chart display based on time granularity
* @param timestamp Unix timestamp in seconds
* @param granularity Time granularity: 'hour', 'day', or 'week'
* @returns Formatted string suitable for chart axis
*/
export function formatChartTime(
timestamp: number,
granularity: TimeGranularity = 'day'
): string {
const d = dayjs(timestamp * 1000)
let result = d.format('MM-DD')
if (granularity === 'hour') {
result += ` ${d.format('HH')}:00`
} else if (granularity === 'week') {
const weekEnd = d.add(6, 'day')
result += ` - ${weekEnd.format('MM-DD')}`
}
return result
}
/**
* Add time duration to a date
* @param months Number of months to add
* @param days Number of days to add
* @param hours Number of hours to add
* @param baseDate Base date to add time to (defaults to now)
* @returns New date with added time, or undefined if all parameters are 0
*/
export function addTimeToDate(
months: number,
days: number,
hours: number,
baseDate: Date = new Date()
): Date | undefined {
if (months === 0 && days === 0 && hours === 0) {
return undefined
}
const result = new Date(baseDate)
result.setMonth(result.getMonth() + months)
result.setDate(result.getDate() + days)
result.setHours(result.getHours() + hours)
return result
}
+61
View File
@@ -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 { useEffect, useRef, useState } from 'react'
import { useTheme } from '@/context/theme-provider'
/**
* Lazy-load VChart's `ThemeManager` and switch its theme to follow the
* resolved app theme (light / dark). Returns flags consumers can use to
* defer chart rendering until the theme is ready.
*/
let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager']
> | null = null
export function useChartTheme() {
const { resolvedTheme } = useTheme()
const [themeReady, setThemeReady] = useState(false)
const themeRef = useRef<
(typeof import('@visactor/vchart'))['ThemeManager'] | null
>(null)
useEffect(() => {
let cancelled = false
const updateTheme = async () => {
setThemeReady(false)
if (!themeManagerPromise) {
themeManagerPromise = import('@visactor/vchart').then(
(m) => m.ThemeManager
)
}
const ThemeManager = await themeManagerPromise
if (cancelled) return
themeRef.current = ThemeManager
ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light')
setThemeReady(true)
}
updateTheme()
return () => {
cancelled = true
}
}, [resolvedTheme])
return { resolvedTheme, themeReady }
}
+86
View File
@@ -0,0 +1,86 @@
/*
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 * as React from 'react'
/**
* Local helper for components that can be either controlled or uncontrolled.
*
* Mirrors the original signature: pass either a controlled `prop` (with
* optional `onChange` callback) or an uncontrolled `defaultProp`. Returns a
* `[value, setValue]` tuple where `setValue` accepts either a new value or
* an updater function, just like `useState`.
*
* The return tuple's value type is non-`undefined` whenever `defaultProp`
* is provided, keeping existing call sites well-typed.
*/
type SetStateFn<T> = (prevState?: T) => T
type Setter<T> = (next: T | undefined | SetStateFn<T | undefined>) => void
export function useControllableState<T>(params: {
prop?: T | undefined
defaultProp: T
onChange?: (state: T) => void
}): [T, Setter<T>]
export function useControllableState<T>(params: {
prop?: T | undefined
defaultProp?: T | undefined
onChange?: (state: T) => void
}): [T | undefined, Setter<T>]
export function useControllableState<T>({
prop,
defaultProp,
onChange,
}: {
prop?: T | undefined
defaultProp?: T | undefined
onChange?: (state: T) => void
}): [T | undefined, Setter<T>] {
const [uncontrolledProp, setUncontrolledProp] = React.useState<T | undefined>(
defaultProp
)
const isControlled = prop !== undefined
const value = isControlled ? prop : uncontrolledProp
const handleChangeRef = React.useRef(onChange)
React.useEffect(() => {
handleChangeRef.current = onChange
})
const setValue = React.useCallback<Setter<T>>(
(next) => {
if (isControlled) {
const nextValue =
typeof next === 'function'
? (next as SetStateFn<T | undefined>)(prop)
: next
if (nextValue !== prop) {
handleChangeRef.current?.(nextValue as T)
}
} else {
setUncontrolledProp(
next as T | undefined | ((prev: T | undefined) => T | undefined)
)
}
},
[isControlled, prop]
)
return [value, setValue]
}
+102
View File
@@ -0,0 +1,102 @@
/*
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 ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function sleep(ms: number = 1000) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* 清理 CSS 变量名,替换特殊字符
* 用于将模型名称(如 gpt-3.5-turbo)转换为有效的 CSS 变量名(gpt-3-5-turbo
* @param name - 原始名称
* @returns 清理后的 CSS 变量名
*/
export function sanitizeCssVariableName(name: string): string {
// 将点号、空格、斜杠替换为连字符
// 移除其他不允许在 CSS 变量名中的特殊字符
return name.replace(/[.\s/]/g, '-').replace(/[^\w-]/g, '')
}
/**
* Generates page numbers for pagination with ellipsis
* @param currentPage - Current page number (1-based)
* @param totalPages - Total number of pages
* @returns Array of page numbers and ellipsis strings
*
* Examples:
* - Small dataset (≤4 pages): [1, 2, 3, 4]
* - Near beginning: [1, 2, '...', 10]
* - In middle: [1, '...', 5, '...', 10]
* - Near end: [1, '...', 9, 10]
*/
export function getPageNumbers(currentPage: number, totalPages: number) {
const maxVisiblePages = 4
const rangeWithDots = []
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
rangeWithDots.push(i)
}
} else {
rangeWithDots.push(1)
if (currentPage <= 2) {
rangeWithDots.push(2)
rangeWithDots.push('...', totalPages)
} else if (currentPage >= totalPages - 1) {
rangeWithDots.push('...')
rangeWithDots.push(totalPages - 1, totalPages)
} else {
rangeWithDots.push('...')
rangeWithDots.push(currentPage)
rangeWithDots.push('...', totalPages)
}
}
return rangeWithDots
}
/**
* Truncate text to a maximum length with ellipsis
*/
export function truncateText(text: string, maxLength: number): string {
if (!text || text.length <= maxLength) return text
return text.slice(0, maxLength) + '...'
}
/**
* Try to parse and pretty-print JSON, fallback to original text if invalid
* @param text - Text that might be JSON
* @returns Pretty-printed JSON or original text
*/
export function tryPrettyJson(text: string): string {
const raw = (text ?? '').toString().trim()
if (!raw) return ''
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
+22
View File
@@ -0,0 +1,22 @@
/*
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
*/
export const VCHART_OPTION = {
// 与老前端保持一致(浏览器环境渲染优化)
mode: 'desktop-browser',
} as const