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
File diff suppressed because it is too large Load Diff
@@ -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 { useQueryClient } from '@tanstack/react-query'
import { Loader2, RefreshCw, DollarSign } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { IconBadge } from '@/components/ui/icon-badge'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { getCodexUsage, updateChannelBalance } from '../../api'
import { channelsQueryKeys } from '../../lib'
import { useChannels } from '../channels-provider'
import {
CodexUsageDialog,
type CodexUsageDialogData,
} from './codex-usage-dialog'
type BalanceQueryDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function BalanceQueryDialog({
open,
onOpenChange,
}: BalanceQueryDialogProps) {
const { t } = useTranslation()
const { currentRow, setCurrentRow } = useChannels()
const queryClient = useQueryClient()
const [isQuerying, setIsQuerying] = useState(false)
const [balance, setBalance] = useState<number | null>(null)
const [balanceUpdatedTime, setBalanceUpdatedTime] = useState<number | null>(
null
)
const [codexUsageResponse, setCodexUsageResponse] =
useState<CodexUsageDialogData | null>(null)
const isCodex = currentRow?.type === 57
const handleQueryCodexUsage = async () => {
const row = currentRow
if (!row) return
setIsQuerying(true)
try {
const res = await getCodexUsage(row.id)
if (!res.success) {
throw new Error(res.message || t('Failed to fetch usage'))
}
setCodexUsageResponse(res)
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to fetch usage')
)
} finally {
setIsQuerying(false)
}
}
useEffect(() => {
if (!isCodex) return
if (!open) return
handleQueryCodexUsage()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isCodex])
if (!currentRow) return null
const handleQueryBalance = async () => {
setIsQuerying(true)
try {
const response = await updateChannelBalance(currentRow.id)
if (response.success && response.balance !== undefined) {
const newBalance = response.balance
const now = Math.floor(Date.now() / 1000)
setBalance(newBalance)
setBalanceUpdatedTime(now)
toast.success(t('Balance updated successfully'))
// Update currentRow immediately with new balance and timestamp
setCurrentRow({
...currentRow,
balance: newBalance,
balance_updated_time: now,
})
// Invalidate queries to refresh the table
await queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(),
})
} else {
toast.error(response.message || t('Failed to query balance'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to query balance')
)
} finally {
setIsQuerying(false)
}
}
const handleClose = () => {
setBalance(null)
setBalanceUpdatedTime(null)
setCodexUsageResponse(null)
onOpenChange(false)
}
const formatBalance = (bal: number) =>
formatCurrencyFromUSD(bal, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
})
const formatDate = (timestamp: number) => {
if (!timestamp) return 'Never'
return formatTimestampToDate(timestamp)
}
if (isCodex) {
return (
<CodexUsageDialog
open={open}
onOpenChange={(v) => {
if (!v) handleClose()
}}
channelName={currentRow.name}
channelId={currentRow.id}
response={codexUsageResponse}
onRefresh={handleQueryCodexUsage}
isRefreshing={isQuerying}
/>
)
}
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Query Balance')}
description={
<>
{t('Update balance for:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')}
</Button>
}
>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<IconBadge tone='success' size='xs'>
<DollarSign />
</IconBadge>
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(balanceUpdatedTime ?? currentRow.balance_updated_time)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
</Dialog>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
/*
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 { useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { handleCopyChannel } from '../../lib'
import { useChannels } from '../channels-provider'
type CopyChannelDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function CopyChannelDialog({
open,
onOpenChange,
}: CopyChannelDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const queryClient = useQueryClient()
const [suffix, setSuffix] = useState('_copy')
const [resetBalance, setResetBalance] = useState(true)
const [isCopying, setIsCopying] = useState(false)
if (!currentRow) return null
const handleCopy = async () => {
setIsCopying(true)
await handleCopyChannel(
currentRow.id,
{
suffix,
reset_balance: resetBalance,
},
queryClient,
() => {
onOpenChange(false)
setSuffix('_copy')
setResetBalance(true)
}
)
setIsCopying(false)
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Copy Channel')}
description={
<>
{t('Create a copy of:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isCopying}
>
{t('Cancel')}
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isCopying ? t('Copying...') : t('Copy Channel')}
</Button>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
<Input
id='suffix'
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
</Dialog>
)
}
@@ -0,0 +1,444 @@
/*
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 { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import {
editTagChannels,
getTagModels,
getAllModels,
getGroups,
} from '../../api'
import { channelsQueryKeys } from '../../lib'
import type { TagOperationParams } from '../../types'
import { useChannels } from '../channels-provider'
type EditTagDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
const { t } = useTranslation()
const { currentTag } = useChannels()
const queryClient = useQueryClient()
// Form state
const [newTag, setNewTag] = useState('')
const [selectedModels, setSelectedModels] = useState<string[]>([])
const [customModel, setCustomModel] = useState('')
const [modelMapping, setModelMapping] = useState('')
const [selectedGroups, setSelectedGroups] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
// Fetch tag models
const { data: tagModelsData, isLoading: isLoadingTagModels } = useQuery({
queryKey: ['tag-models', currentTag],
queryFn: () => (currentTag ? getTagModels(currentTag) : null),
enabled: open && !!currentTag,
})
// Fetch all available models
const { data: allModelsData } = useQuery({
queryKey: ['all-models'],
queryFn: getAllModels,
enabled: open,
})
// Fetch groups
const { data: groupsData } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
enabled: open,
})
const availableModels =
allModelsData?.data?.map((m) => m.id).filter(Boolean) || []
const availableGroups = groupsData?.data || []
// Initialize form when tag changes
useEffect(() => {
if (open && currentTag) {
setNewTag(currentTag)
setModelMapping('')
setSelectedGroups([])
setCustomModel('')
// Load tag models
if (tagModelsData?.data) {
const models = tagModelsData.data.split(',').filter(Boolean)
setSelectedModels(models)
} else {
setSelectedModels([])
}
}
}, [open, currentTag, tagModelsData])
const handleAddCustomModel = () => {
if (!customModel.trim()) return
const modelsToAdd = customModel
.split(',')
.map((m) => m.trim())
.filter(Boolean)
.filter((m) => !selectedModels.includes(m))
if (modelsToAdd.length > 0) {
setSelectedModels([...selectedModels, ...modelsToAdd])
toast.success(
t('Added {{count}} model(s)', { count: modelsToAdd.length })
)
setCustomModel('')
} else {
toast.info(t('No new models to add'))
}
}
const handleRemoveModel = (model: string) => {
setSelectedModels(selectedModels.filter((m) => m !== model))
}
const handleToggleGroup = (group: string) => {
setSelectedGroups((prev) =>
prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
)
}
const validateForm = () => {
// Validate model mapping if provided
if (modelMapping.trim()) {
try {
JSON.parse(modelMapping)
} catch {
toast.error(t('Model mapping must be valid JSON'))
return false
}
}
return true
}
const handleSubmit = async () => {
if (!currentTag) return
if (!validateForm()) return
// Check if anything changed
const hasChanges =
newTag !== currentTag ||
modelMapping.trim() ||
selectedModels.length > 0 ||
selectedGroups.length > 0
if (!hasChanges) {
toast.warning(t('No changes to save'))
return
}
setIsSubmitting(true)
try {
const params: Record<string, string | null> = { tag: currentTag }
if (newTag && newTag !== currentTag) {
params.new_tag = newTag || null
}
if (modelMapping.trim()) {
params.model_mapping = modelMapping
}
if (selectedModels.length > 0) {
params.models = selectedModels.join(',')
}
if (selectedGroups.length > 0) {
params.groups = selectedGroups.join(',')
}
const response = await editTagChannels(
params as unknown as TagOperationParams
)
if (response.success) {
toast.success(t('Tag updated successfully'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onOpenChange(false)
} else {
toast.error(response.message || t('Failed to update tag'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update tag')
)
} finally {
setIsSubmitting(false)
}
}
const handleClose = () => {
onOpenChange(false)
}
if (!currentTag) return null
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={
<>
{t('Edit Tag:')}
{currentTag}
</>
}
description={t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
contentClassName='max-h-[90vh] max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose}>
{t('Cancel')}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Save Changes')}
</Button>
</>
}
>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<GroupBadge
key={group}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
/>
))}
</div>
</div>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -0,0 +1,512 @@
/*
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 { useQueryClient } from '@tanstack/react-query'
import { Loader2, Search, Info, ChevronDown } from 'lucide-react'
import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { fetchUpstreamModels, updateChannel } from '../../api'
import {
channelsQueryKeys,
categorizeModelsWithRedirect,
normalizeModelName,
parseModelsString,
} from '../../lib'
import { useChannels } from '../channels-provider'
function normalizeModelNameList(models: readonly string[]): string[] {
return Array.from(
new Set(models.map((m) => normalizeModelName(m)).filter(Boolean))
)
}
type FetchModelsDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onModelsSelected?: (models: string[]) => void
redirectModels?: string[]
redirectSourceModels?: string[]
customFetcher?: () => Promise<string[]>
existingModelsOverride?: string[]
channelName?: string | null
}
export function FetchModelsDialog({
open,
onOpenChange,
onModelsSelected,
redirectModels = [],
redirectSourceModels = [],
customFetcher,
existingModelsOverride,
channelName,
}: FetchModelsDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const activeChannel = customFetcher ? null : currentRow
const queryClient = useQueryClient()
const [isFetching, setIsFetching] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [fetchedModels, setFetchedModels] = useState<string[]>([])
const [selectedModels, setSelectedModels] = useState<string[]>([])
const [searchKeyword, setSearchKeyword] = useState('')
// Parse existing models
const existingModels = useMemo(
() =>
existingModelsOverride ?? parseModelsString(activeChannel?.models || ''),
[existingModelsOverride, activeChannel?.models]
)
// Categorize models with redirect models
const modelCategories = useMemo(
() => categorizeModelsWithRedirect(existingModels, redirectModels),
[existingModels, redirectModels]
)
const { classificationSet, redirectOnlySet } = modelCategories
const fetchedModelSet = useMemo(
() => new Set(normalizeModelNameList(fetchedModels)),
[fetchedModels]
)
// Source keys in model_mapping are aliases, not real upstream IDs, so we
// must skip them when computing "removed upstream" entries to avoid false
// positives.
const redirectSourceKeysSet = useMemo(
() => new Set(normalizeModelNameList(redirectSourceModels)),
[redirectSourceModels]
)
const removedModels = useMemo(() => {
const kw = searchKeyword.toLowerCase().trim()
return normalizeModelNameList(selectedModels).filter((model) => {
if (fetchedModelSet.has(model)) return false
if (redirectSourceKeysSet.has(model)) return false
if (!kw) return true
return model.toLowerCase().includes(kw)
})
}, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels])
useEffect(() => {
if (open && (activeChannel || customFetcher)) {
handleFetchModels()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeChannel?.id, customFetcher])
const handleFetchModels = async () => {
if (!activeChannel && !customFetcher) return
setIsFetching(true)
try {
if (customFetcher) {
const list = await customFetcher()
setFetchedModels(list)
setSelectedModels(existingModels)
toast.success(t('Fetched {{count}} models', { count: list.length }))
} else {
const response = await fetchUpstreamModels(activeChannel!.id)
if (response.success) {
const list = Array.isArray(response.data) ? response.data : []
setFetchedModels(list)
setSelectedModels(existingModels)
toast.success(t('Fetched {{count}} models', { count: list.length }))
} else {
toast.error(response.message || t('Failed to fetch models'))
setFetchedModels([])
}
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to fetch models')
)
setFetchedModels([])
} finally {
setIsFetching(false)
}
}
const handleSave = async () => {
// If onModelsSelected callback is provided, use it (form filling mode)
if (onModelsSelected) {
onModelsSelected(selectedModels)
toast.success(t('Models filled to form'))
onOpenChange(false)
return
}
// Otherwise, directly save to API (standalone mode)
if (!activeChannel) return
setIsSaving(true)
try {
const modelsString = selectedModels.join(',')
const response = await updateChannel(activeChannel.id, {
models: modelsString,
})
if (response.success) {
toast.success(t('Models updated successfully'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onOpenChange(false)
} else {
toast.error(response.message || t('Failed to update models'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update models')
)
} finally {
setIsSaving(false)
}
}
const handleClose = () => {
setFetchedModels([])
setSelectedModels([])
setSearchKeyword('')
onOpenChange(false)
}
// Categorize models by common prefixes
const categorizeModels = (models: string[]) => {
const categories: Record<string, string[]> = {}
models.forEach((model) => {
let category = 'Other'
// Determine category based on model name
if (
model.toLowerCase().includes('gpt') ||
model.toLowerCase().includes('o1') ||
model.toLowerCase().includes('o3')
) {
category = 'OpenAI'
} else if (model.toLowerCase().includes('claude')) {
category = 'Anthropic'
} else if (model.toLowerCase().includes('gemini')) {
category = 'Gemini'
} else if (model.toLowerCase().includes('qwen')) {
category = 'Qwen'
} else if (model.toLowerCase().includes('deepseek')) {
category = 'DeepSeek'
} else if (model.toLowerCase().includes('glm')) {
category = 'Zhipu'
} else if (model.toLowerCase().includes('llama')) {
category = 'Meta'
} else if (model.toLowerCase().includes('mistral')) {
category = 'Mistral'
}
if (!categories[category]) {
categories[category] = []
}
categories[category].push(model)
})
return categories
}
// Filter models by search
const filteredModels = useMemo(() => {
if (!searchKeyword) return fetchedModels
return fetchedModels.filter((model) =>
model.toLowerCase().includes(searchKeyword.toLowerCase())
)
}, [fetchedModels, searchKeyword])
// Helper to check if a model is considered "existing" (in selected or redirect)
const isExistingModel = (model: string) =>
classificationSet.has(normalizeModelName(model))
// Separate new and existing models
const newModels = filteredModels.filter((m) => !isExistingModel(m))
const existingFilteredModels = filteredModels.filter((m) =>
isExistingModel(m)
)
const newModelsByCategory = categorizeModels(newModels)
const existingModelsByCategory = categorizeModels(existingFilteredModels)
// 厂商分类按 a-z 排序,Other 放最后,便于查找
const getSortedCategoryEntries = (
categories: Record<string, string[]>
): [string, string[]][] =>
Object.entries(categories).sort(([a], [b]) => {
if (a === 'Other') return 1
if (b === 'Other') return -1
return a.localeCompare(b, undefined, { sensitivity: 'base' })
})
const toggleModel = (model: string) => {
setSelectedModels((prev) =>
prev.includes(model) ? prev.filter((m) => m !== model) : [...prev, model]
)
}
const toggleCategory = (categoryModels: string[], isChecked: boolean) => {
setSelectedModels((prev) => {
if (isChecked) {
const newSelected = [...prev]
categoryModels.forEach((model) => {
if (!newSelected.includes(model)) {
newSelected.push(model)
}
})
return newSelected
} else {
return prev.filter((m) => !categoryModels.includes(m))
}
})
}
const isCategorySelected = (categoryModels: string[]) => {
return categoryModels.every((m) => selectedModels.includes(m))
}
const renderModelCategory = (
categoryName: string,
categoryModels: string[]
) => {
const allSelected = isCategorySelected(categoryModels)
return (
<Collapsible key={categoryName} defaultOpen>
<CollapsibleTrigger className='hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border p-3'>
<div className='flex items-center gap-2'>
<ChevronDown className='h-4 w-4' />
<span className='font-medium'>
{categoryName} ({categoryModels.length})
</span>
</div>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>
{categoryModels.filter((m) => selectedModels.includes(m)).length}{' '}
/ {categoryModels.length} selected
</span>
<Checkbox
checked={allSelected}
onCheckedChange={(checked) =>
toggleCategory(categoryModels, !!checked)
}
onClick={(e) => e.stopPropagation()}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className='px-4 py-2'>
<div className='grid grid-cols-2 gap-2'>
{categoryModels.map((model) => (
<div key={model} className='flex items-center space-x-2'>
<Checkbox
id={model}
checked={selectedModels.includes(model)}
onCheckedChange={() => toggleModel(model)}
/>
<Label
htmlFor={model}
className='flex cursor-pointer items-center gap-1.5 text-sm font-normal'
>
<span>{model}</span>
{redirectOnlySet.has(normalizeModelName(model)) && (
<Tooltip>
<TooltipTrigger
render={<Info className='h-3.5 w-3.5 text-amber-500' />}
></TooltipTrigger>
<TooltipContent>
{t('From model redirect, not yet added to models list')}
</TooltipContent>
</Tooltip>
)}
</Label>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}
const showFooterActions =
!!(activeChannel || customFetcher) &&
!isFetching &&
(fetchedModels.length > 0 || removedModels.length > 0)
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Fetch Models')}
description={
activeChannel ? (
<>
{t('Channel:')} <strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Channel:')} <strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)
}
contentClassName='max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
showFooterActions ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</>
) : null
}
>
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
{t('Fetch Models')}
</Button>
</div>
) : (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
</div>
</>
)}
</Dialog>
)
}
@@ -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
*/
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
export type MissingModelsAction = 'cancel' | 'submit' | 'add'
type MissingModelsConfirmationDialogProps = {
open: boolean
missingModels: string[]
onConfirm: (action: MissingModelsAction) => void
onOpenChange?: (open: boolean) => void
}
/**
* Confirmation dialog shown when models in model_mapping are missing from the models list
* Provides three options:
* 1. Cancel - Go back to edit
* 2. Submit - Submit anyway without adding missing models
* 3. Add - Automatically add missing models and submit
*/
export function MissingModelsConfirmationDialog({
open,
missingModels,
onConfirm,
onOpenChange,
}: MissingModelsConfirmationDialogProps) {
const { t } = useTranslation()
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
onConfirm('cancel')
}
onOpenChange?.(newOpen)
}
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('Models not in list, may fail to invoke')}
</AlertDialogTitle>
<AlertDialogDescription
render={<div className='space-y-3 text-sm' />}
>
<div>
{t(
'The following models in the model redirect have not been added to the "Models" list and may fail during invocation due to missing available models:'
)}
</div>
<div className='rounded-md bg-red-50 p-2 font-mono text-xs break-all text-red-600 dark:bg-red-950/50 dark:text-red-400'>
{missingModels.join(', ')}
</div>
<div>
{t(
'You can manually add them in "Custom Model Names", click "Fill" and then submit, or use the operations below to handle automatically.'
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex-col gap-2 sm:flex-row'>
<AlertDialogCancel onClick={() => onConfirm('cancel')}>
{t('Go back and edit')}
</AlertDialogCancel>
<Button variant='secondary' onClick={() => onConfirm('submit')}>
{t('Submit directly')}
</Button>
<AlertDialogAction onClick={() => onConfirm('add')}>
{t('Add and submit')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,485 @@
/*
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 { useQueryClient } from '@tanstack/react-query'
import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import {
getMultiKeyStatus,
enableMultiKey,
disableMultiKey,
deleteMultiKey,
enableAllMultiKeys,
disableAllMultiKeys,
deleteDisabledMultiKeys,
} from '../../api'
import { MULTI_KEY_FILTER_OPTIONS } from '../../constants'
import {
channelsQueryKeys,
formatTimestamp,
getMultiKeyStatusConfig,
getMultiKeyConfirmMessage,
isDestructiveAction,
} from '../../lib'
import type { KeyStatus, MultiKeyConfirmAction } from '../../types'
import { useChannels } from '../channels-provider'
import { StatisticsCard } from './multi-key-statistics-card'
import { MultiKeyTableRowActions } from './multi-key-table-row-actions'
type MultiKeyManageDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function MultiKeyManageDialog({
open,
onOpenChange,
}: MultiKeyManageDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const queryClient = useQueryClient()
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
// Data state
const [isLoading, setIsLoading] = useState(false)
const [keys, setKeys] = useState<KeyStatus[]>([])
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [enabledCount, setEnabledCount] = useState(0)
const [manualDisabledCount, setManualDisabledCount] = useState(0)
const [autoDisabledCount, setAutoDisabledCount] = useState(0)
// UI state
const [statusFilter, setStatusFilter] = useState<number | null>(null)
const [confirmAction, setConfirmAction] =
useState<MultiKeyConfirmAction | null>(null)
const [isPerformingAction, setIsPerformingAction] = useState(false)
// Reset and load data when dialog opens
useEffect(() => {
if (open && currentRow) {
setCurrentPage(1)
setStatusFilter(null)
loadKeyStatus(1, pageSize, null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRow?.id])
const loadKeyStatus = async (
page: number = currentPage,
size: number = pageSize,
status: number | null = statusFilter
) => {
if (!currentRow) return
setIsLoading(true)
try {
const response = await getMultiKeyStatus(
currentRow.id,
page,
size,
status === null ? undefined : status
)
if (response.success && response.data) {
setKeys(response.data.keys || [])
setTotal(response.data.total || 0)
setCurrentPage(response.data.page || 1)
setPageSize(response.data.page_size || 10)
setTotalPages(response.data.total_pages || 0)
setEnabledCount(response.data.enabled_count || 0)
setManualDisabledCount(response.data.manual_disabled_count || 0)
setAutoDisabledCount(response.data.auto_disabled_count || 0)
} else {
toast.error(response.message || t('Failed to load key status'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to load key status')
)
} finally {
setIsLoading(false)
}
}
const handleStatusFilterChange = (value: string) => {
const newFilter = value === 'all' ? null : parseInt(value)
setStatusFilter(newFilter)
setCurrentPage(1)
loadKeyStatus(1, pageSize, newFilter)
}
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage)
loadKeyStatus(newPage, pageSize)
}
const performAction = async () => {
if (!confirmAction || !currentRow) return
if (
!canEditSensitive &&
(confirmAction.type === 'delete' ||
confirmAction.type === 'delete-disabled')
) {
setConfirmAction(null)
return
}
setIsPerformingAction(true)
try {
const { type, keyIndex } = confirmAction
let response
// Execute the appropriate action
if (type === 'enable' && keyIndex !== undefined) {
response = await enableMultiKey(currentRow.id, keyIndex)
} else if (type === 'disable' && keyIndex !== undefined) {
response = await disableMultiKey(currentRow.id, keyIndex)
} else if (type === 'delete' && keyIndex !== undefined) {
response = await deleteMultiKey(currentRow.id, keyIndex)
} else if (type === 'enable-all') {
response = await enableAllMultiKeys(currentRow.id)
} else if (type === 'disable-all') {
response = await disableAllMultiKeys(currentRow.id)
} else if (type === 'delete-disabled') {
response = await deleteDisabledMultiKeys(currentRow.id)
}
if (response?.success) {
toast.success(response.message || t('Operation successful'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
// Reload data - reset to page 1 for bulk actions
const isBulkAction = type.includes('all') || type === 'delete-disabled'
if (isBulkAction) {
setCurrentPage(1)
loadKeyStatus(1, pageSize)
} else {
loadKeyStatus(currentPage, pageSize)
}
} else {
toast.error(response?.message || t('Operation failed'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Operation failed')
)
} finally {
setIsPerformingAction(false)
setConfirmAction(null)
}
}
const renderStatusBadge = (status: number) => {
const config = getMultiKeyStatusConfig(status)
return (
<StatusBadge
label={t(config.label)}
variant={config.variant}
showDot
copyable={false}
/>
)
}
const formatKeyTimestamp = (timestamp?: number) => {
if (!timestamp) return '-'
return formatTimestamp(timestamp)
}
if (!currentRow) return null
return (
<>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
{t('Multi-Key Management')}
<StatusBadge
label={currentRow.name}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
)}
</>
}
description={t(
'Manage multi-key status and configuration for this channel'
)}
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<Separator className='shrink-0' />
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
>
<RefreshCw className='h-4 w-4' />
</Button>
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='default'
size='sm'
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => {
if (!canEditSensitive) return
setConfirmAction({ type: 'delete-disabled' })
}}
disabled={!canEditSensitive}
title={
canEditSensitive
? undefined
: t('No permission to perform this action')
}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div>
</div>
{!canEditSensitive && (
<p className='text-muted-foreground text-xs'>
{t('No permission to perform this action')}
</p>
)}
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<StaticDataTable
className='rounded-none border-0'
tableClassName='min-w-[800px]'
data={keys}
getRowKey={(key) => key.index}
columns={[
{
id: 'index',
header: t('Index'),
className: 'w-20',
cellClassName: 'font-mono text-sm',
cell: (key) => `#${key.index + 1}`,
},
{
id: 'status',
header: t('Status'),
className: 'w-32',
cell: (key) => renderStatusBadge(key.status),
},
{
id: 'reason',
header: t('Disabled Reason'),
className: 'min-w-[200px]',
cellClassName: 'max-w-xs truncate text-sm',
cell: (key) => key.reason || '-',
},
{
id: 'disabled-time',
header: t('Disabled Time'),
className: 'w-44',
cellClassName: 'text-muted-foreground text-sm',
cell: (key) => formatKeyTimestamp(key.disabled_time),
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cell: (key) => (
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
canDelete={canEditSensitive}
onAction={setConfirmAction}
/>
),
},
]}
/>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
</div>
)}
</div>
</Dialog>
{/* Confirmation Dialog */}
<ConfirmDialog
open={confirmAction !== null}
onOpenChange={(open) => !open && setConfirmAction(null)}
title={t('Confirm Action')}
desc={t(getMultiKeyConfirmMessage(confirmAction))}
destructive={isDestructiveAction(confirmAction)}
isLoading={isPerformingAction}
handleConfirm={performAction}
/>
</>
)
}
@@ -0,0 +1,42 @@
/*
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 { useTranslation } from 'react-i18next'
type StatisticsCardProps = {
label: string
count: number
total: number
}
export function StatisticsCard({ label, count, total }: StatisticsCardProps) {
const { t } = useTranslation()
return (
<div className='rounded-md border p-3'>
<div className='text-muted-foreground mb-1 text-xs font-medium'>
{label}
</div>
<div className='flex items-baseline gap-2'>
<span className='text-foreground text-2xl font-semibold'>{count}</span>
<span className='text-muted-foreground text-sm'>
{t('of')} {total}
</span>
</div>
</div>
)
}
@@ -0,0 +1,76 @@
/*
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 { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import type { MultiKeyConfirmAction } from '../../types'
type MultiKeyTableRowActionsProps = {
keyIndex: number
status: number
canDelete: boolean
onAction: (action: MultiKeyConfirmAction) => void
}
export function MultiKeyTableRowActions({
keyIndex,
status,
canDelete,
onAction,
}: MultiKeyTableRowActionsProps) {
const { t } = useTranslation()
const isEnabled = status === 1
return (
<div className='flex justify-end gap-2'>
{isEnabled ? (
<Button
variant='outline'
size='sm'
onClick={() => onAction({ type: 'disable', keyIndex })}
>
{t('Disable')}
</Button>
) : (
<Button
variant='outline'
size='sm'
onClick={() => onAction({ type: 'enable', keyIndex })}
>
{t('Enable')}
</Button>
)}
<Button
variant='destructive'
size='sm'
onClick={() => {
if (!canDelete) return
onAction({ type: 'delete', keyIndex })
}}
disabled={!canDelete}
title={
canDelete ? undefined : t('No permission to perform this action')
}
>
{t('Delete')}
</Button>
</div>
)
}
@@ -0,0 +1,610 @@
/*
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 { useQueryClient } from '@tanstack/react-query'
import { Loader2, RefreshCw, Trash2, Download, Search } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { getFreshAuthHeaders } from '@/lib/api'
import {
deleteOllamaModel,
fetchModels as fetchModelsFromEndpoint,
fetchUpstreamModels,
updateChannel,
} from '../../api'
import { channelsQueryKeys, parseModelsString } from '../../lib'
import {
formatBytes,
normalizeOllamaModels,
resolveOllamaBaseUrl,
type OllamaModel,
type PullProgress,
} from '../../lib/ollama-utils'
import { useChannels } from '../channels-provider'
const CHANNEL_TYPE_OLLAMA = 4
export function OllamaModelsDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { currentRow } = useChannels()
const isOllamaChannel = currentRow?.type === CHANNEL_TYPE_OLLAMA
const channelId = currentRow?.id
const [isFetching, setIsFetching] = useState(false)
const [models, setModels] = useState<OllamaModel[]>([])
const [selected, setSelected] = useState<string[]>([])
const [search, setSearch] = useState('')
const [pullName, setPullName] = useState('')
const [isPulling, setIsPulling] = useState(false)
const [pullProgress, setPullProgress] = useState<PullProgress | null>(null)
const pullAbortRef = useRef<AbortController | null>(null)
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const filteredModels = useMemo(() => {
if (!search.trim()) return models
const keyword = search.trim().toLowerCase()
return models.filter((m) => m.id.toLowerCase().includes(keyword))
}, [models, search])
const existingModels = useMemo(
() => parseModelsString(currentRow?.models ?? ''),
[currentRow?.models]
)
useEffect(() => {
if (!open) {
setModels([])
setSelected([])
setSearch('')
setPullName('')
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current?.abort()
pullAbortRef.current = null
return
}
if (open && isOllamaChannel && channelId) {
void fetchOllamaModels()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isOllamaChannel, channelId])
const fetchOllamaModels = useCallback(async () => {
if (!channelId) return
setIsFetching(true)
try {
let normalized: OllamaModel[] = []
let lastErr = ''
// 1) Prefer live fetch for Ollama if base_url is set (more accurate / supports unsaved changes)
const baseUrl = resolveOllamaBaseUrl(currentRow ?? null)
if (isOllamaChannel && baseUrl) {
try {
const payloadLive = await fetchModelsFromEndpoint({
base_url: baseUrl,
type: CHANNEL_TYPE_OLLAMA,
key: typeof currentRow?.key === 'string' ? currentRow.key : '',
})
if (payloadLive?.success) {
normalized = normalizeOllamaModels(payloadLive.data)
} else if (payloadLive?.message) {
lastErr = String(payloadLive.message)
}
} catch (err: unknown) {
lastErr = err instanceof Error ? err.message : ''
}
}
// 2) Fallback to server-side fetch by channelId
if (!normalized.length) {
const payload = await fetchUpstreamModels(Number(channelId))
if (payload?.success) {
normalized = normalizeOllamaModels(payload.data)
lastErr = ''
} else {
lastErr = String(payload?.message || '')
}
}
if (!normalized.length && lastErr) {
toast.error(lastErr || t('Failed to fetch models'))
}
setModels(normalized)
setSelected((prev) => {
if (!prev.length) return normalized.map((m) => m.id)
const stillAvailable = prev.filter((id) =>
normalized.some((m) => m.id === id)
)
return stillAvailable.length
? stillAvailable
: normalized.map((m) => m.id)
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : undefined
toast.error(msg || t('Failed to fetch models'))
setModels([])
} finally {
setIsFetching(false)
}
}, [channelId, currentRow, isOllamaChannel, t])
const toggleSelected = (modelId: string, checked: boolean) => {
setSelected((prev) => {
if (checked) return prev.includes(modelId) ? prev : [...prev, modelId]
return prev.filter((id) => id !== modelId)
})
}
const selectAllFiltered = () => {
setSelected((prev) => {
const next = new Set(prev)
filteredModels.forEach((m) => next.add(m.id))
return [...next]
})
}
const clearSelection = () => setSelected([])
const applySelection = async (mode: 'append' | 'replace') => {
if (!currentRow) return
if (!selected.length) {
toast.info(t('No models selected'))
return
}
const next =
mode === 'replace'
? [...new Set(selected)]
: [...new Set([...existingModels, ...selected])]
try {
const res = await updateChannel(currentRow.id, { models: next.join(',') })
if (res.success) {
toast.success(
mode === 'replace'
? t('Models updated successfully')
: t('Models appended successfully')
)
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
} else {
toast.error(res.message || t('Failed to update models'))
}
} catch (err: unknown) {
toast.error(
err instanceof Error ? err.message : t('Failed to update models')
)
}
}
const pullModel = async () => {
if (!channelId) return
if (!pullName.trim()) {
toast.error(t('Please enter model name'))
return
}
if (!resolveOllamaBaseUrl(currentRow)) {
toast.error(t('Please set Ollama API Base URL first'))
return
}
pullAbortRef.current?.abort()
const controller = new AbortController()
pullAbortRef.current = controller
setIsPulling(true)
setPullProgress({ status: 'starting', completed: 0, total: 0 })
try {
const authHeaders = await getFreshAuthHeaders()
const response = await fetch('/api/channel/ollama/pull/stream', {
method: 'POST',
credentials: 'include',
headers: {
...authHeaders,
Accept: 'text/event-stream',
},
body: JSON.stringify({
channel_id: channelId,
model_name: pullName.trim(),
}),
signal: controller.signal,
})
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const eventData = line.slice(6)
if (!eventData) continue
if (eventData === '[DONE]') {
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
return
}
try {
const data = JSON.parse(eventData)
if (data?.status) {
setPullProgress(data)
} else if (data?.error) {
toast.error(String(data.error))
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
return
} else if (data?.message) {
toast.success(String(data.message))
setPullName('')
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
await fetchOllamaModels()
queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(),
})
return
}
} catch {
// ignore malformed events
}
}
}
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
await fetchOllamaModels()
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
} catch (err: unknown) {
const isAbort =
typeof err === 'object' &&
err !== null &&
'name' in err &&
(err as { name?: unknown }).name === 'AbortError'
if (!isAbort) {
const msg = err instanceof Error ? err.message : ''
toast.error(t('Model pull failed: {{msg}}', { msg }))
}
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
}
}
const deleteModel = async (modelName: string) => {
if (!channelId) return
try {
setIsDeleting(true)
const payload = await deleteOllamaModel({
channel_id: Number(channelId),
model_name: modelName,
})
if (payload?.success) {
toast.success(t('Model deleted'))
await fetchOllamaModels()
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
setDeleteOpen(false)
setDeleteTarget(null)
} else {
toast.error(payload?.message || t('Failed to delete model'))
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : undefined
toast.error(msg || t('Failed to delete model'))
} finally {
setIsDeleting(false)
}
}
const close = () => {
pullAbortRef.current?.abort()
pullAbortRef.current = null
onOpenChange(false)
}
if (!open) return null
return (
<Dialog
open={open}
onOpenChange={close}
title={t('Ollama Models')}
description={
<>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</>
}
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
}
>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='space-y-4 py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
<div className='flex gap-2'>
<Input
id='ollama-pull'
placeholder={t('e.g. llama3.1:8b')}
value={pullName}
onChange={(e) => setPullName(e.target.value)}
disabled={!channelId || isPulling}
/>
<Button
onClick={() => void pullModel()}
disabled={!channelId || isPulling}
>
{isPulling ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Pulling...')}
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
{t('Pull')}
</>
)}
</Button>
</div>
{pullProgress && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>
{t('Status:')} {String(pullProgress.status || '-')}
</div>
<Progress
value={
typeof pullProgress.completed === 'number' &&
typeof pullProgress.total === 'number' &&
pullProgress.total > 0
? Math.min(
100,
Math.round(
(pullProgress.completed / pullProgress.total) *
100
)
)
: 0
}
/>
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() => void fetchOllamaModels()}
disabled={!channelId || isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Local models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models and apply to channel models list.')}
</p>
</div>
<div className='relative sm:w-72'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='pl-9'
/>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
{t('Append to channel')}
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
{t('Replace channel models')}
</Button>
</div>
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[420px] overflow-y-auto'>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground p-6 text-center text-sm'>
{t('No models found.')}
</div>
) : (
<div className='divide-y'>
{filteredModels.map((m) => {
const checked = selected.includes(m.id)
return (
<div
key={m.id}
className='flex items-center justify-between gap-3 p-3'
>
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleSelected(m.id, !!v)}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{m.id}
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
<span>
{t('Size:')} {formatBytes(m.size)}
</span>
{m.digest && (
<span className='truncate'>
{t('Digest:')} {String(m.digest)}
</span>
)}
</div>
</div>
</div>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
setDeleteOpen(true)
}}
disabled={!channelId}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</div>
)}
<AlertDialog
open={deleteOpen}
onOpenChange={(v) => {
setDeleteOpen(v)
if (!v) setDeleteTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Confirm delete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('Delete model "{{name}}"? This cannot be undone.', {
name: deleteTarget || '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
disabled={isDeleting || !deleteTarget}
onClick={() => {
if (!deleteTarget) return
void deleteModel(deleteTarget)
}}
>
{isDeleting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,187 @@
/*
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 { AlertTriangle } from 'lucide-react'
import { lazy, Suspense, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const Markdown = lazy(() =>
import('@/components/ui/markdown').then((module) => ({
default: module.Markdown,
}))
)
interface StatusCodeRiskDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
detailItems: string[]
onConfirm: () => void
}
const CHECKLIST_KEYS = [
'High-risk status code retry risk check 1',
'High-risk status code retry risk check 2',
'High-risk status code retry risk check 3',
'High-risk status code retry risk check 4',
] as const
export function StatusCodeRiskDialog({
open,
onOpenChange,
detailItems,
onConfirm,
}: StatusCodeRiskDialogProps) {
const { t } = useTranslation()
const [checkedItems, setCheckedItems] = useState<Set<number>>(new Set())
const [confirmText, setConfirmText] = useState('')
const requiredText = t('High-risk status code retry confirmation text')
const allChecked = checkedItems.size === CHECKLIST_KEYS.length
const textMatches = confirmText.trim() === requiredText.trim()
const canConfirm = allChecked && textMatches
const handleConfirm = () => {
if (!canConfirm) return
setCheckedItems(new Set())
setConfirmText('')
onConfirm()
}
const handleCancel = () => {
setCheckedItems(new Set())
setConfirmText('')
onOpenChange(false)
}
const toggleCheck = (idx: number) => {
setCheckedItems((prev) => {
const next = new Set(prev)
if (next.has(idx)) next.delete(idx)
else next.add(idx)
return next
})
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</>
}
contentClassName='sm:max-w-3xl'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
<Button
variant='destructive'
disabled={!canConfirm}
onClick={handleConfirm}
>
{t('I confirm enabling high-risk retry')}
</Button>
</>
}
>
<div className='space-y-4'>
{open ? (
<div className='border-warning/40 bg-warning/5 rounded-lg border p-3 sm:p-4'>
<Suspense
fallback={
<div
aria-hidden='true'
className='bg-warning/10 h-32 animate-pulse rounded-md'
/>
}
>
<Markdown className='[&_h3]:text-warning text-sm [&_h3]:text-base'>
{t('High-risk status code retry risk disclaimer')}
</Markdown>
</Suspense>
</div>
) : null}
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
<p className='mb-2 text-sm font-medium'>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
</Dialog>
)
}
@@ -0,0 +1,302 @@
/*
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 { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2, AlertCircle } from 'lucide-react'
import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { MultiSelect } from '@/components/multi-select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import {
getTagModels,
editTagChannels,
getAllModels,
getGroups,
} from '../../api'
import { channelsQueryKeys } from '../../lib'
import type { TagOperationParams } from '../../types'
import { useChannels } from '../channels-provider'
import { ModelMappingEditor } from '../model-mapping-editor'
type TagBatchEditDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function TagBatchEditDialog({
open,
onOpenChange,
}: TagBatchEditDialogProps) {
const { t } = useTranslation()
const { currentTag } = useChannels()
const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// Form fields
const [newTag, setNewTag] = useState('')
const [models, setModels] = useState('')
const [modelMapping, setModelMapping] = useState('')
const [groups, setGroups] = useState<string[]>([])
// Fetch available groups
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
})
// Transform groups to multi-select options
const groupOptions = useMemo(() => {
if (!groupsData?.data) return []
const allGroups = new Set([...groupsData.data, ...groups])
return Array.from(allGroups).map((group) => ({
value: group,
label: group,
}))
}, [groupsData, groups])
useEffect(() => {
if (open && currentTag) {
loadTagData()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentTag])
const loadTagData = async () => {
if (!currentTag) return
setIsLoading(true)
try {
// Fetch current tag models
const tagModelsResponse = await getTagModels(currentTag)
if (tagModelsResponse.success && tagModelsResponse.data) {
setModels(tagModelsResponse.data)
}
// Fetch all available models (for future use if needed)
const allModelsResponse = await getAllModels()
if (allModelsResponse.success && allModelsResponse.data) {
// Available models could be used for autocomplete in the future
}
// Initialize new tag with current tag name
setNewTag(currentTag)
} catch (_error: unknown) {
toast.error(
_error instanceof Error ? _error.message : t('Failed to load tag data')
)
} finally {
setIsLoading(false)
}
}
const handleSave = async () => {
if (!currentTag) return
// Validate model mapping JSON if provided
if (modelMapping.trim()) {
try {
JSON.parse(modelMapping)
} catch (_error) {
toast.error(t('Model mapping must be valid JSON'))
return
}
}
setIsSaving(true)
try {
const params: Record<string, string | undefined> = {
tag: currentTag,
}
if (newTag !== currentTag) {
params.new_tag = newTag || undefined
}
if (models.trim()) {
params.models = models
}
if (modelMapping.trim()) {
params.model_mapping = modelMapping
}
if (groups.length > 0) {
params.groups = groups.join(',')
}
// Check if there are any changes
if (Object.keys(params).length === 1) {
toast.warning(t('No changes made'))
return
}
const response = await editTagChannels(
params as unknown as TagOperationParams
)
if (response.success) {
toast.success(t('Tag updated successfully'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
handleClose()
} else {
toast.error(response.message || t('Failed to update tag'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update tag')
)
} finally {
setIsSaving(false)
}
}
const handleClose = () => {
setNewTag('')
setModels('')
setModelMapping('')
setGroups([])
onOpenChange(false)
}
if (!currentTag) return null
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Batch Edit by Tag')}
description={
<>
{t('Edit all channels with tag:')}
<strong>{currentTag}</strong>
</>
}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
!isLoading ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</>
) : null
}
>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t('Select groups (leave empty to keep current)')}
/>
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</div>
</>
)}
</Dialog>
)
}
@@ -0,0 +1,292 @@
/*
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 { Search } from 'lucide-react'
import { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface UpstreamUpdateDialogProps {
open: boolean
addModels: string[]
removeModels: string[]
preferredTab: 'add' | 'remove'
confirmLoading: boolean
onConfirm: (data: { addModels: string[]; removeModels: string[] }) => void
onCancel: () => void
}
export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState(props.preferredTab)
const [searchAdd, setSearchAdd] = useState('')
const [searchRemove, setSearchRemove] = useState('')
const [selectedAdd, setSelectedAdd] = useState<Set<string>>(
() => new Set(props.addModels)
)
const [selectedRemove, setSelectedRemove] = useState<Set<string>>(
() => new Set(props.removeModels)
)
const [partialConfirmOpen, setPartialConfirmOpen] = useState(false)
const filteredAdd = useMemo(
() =>
props.addModels.filter((m) =>
m.toLowerCase().includes(searchAdd.toLowerCase())
),
[props.addModels, searchAdd]
)
const filteredRemove = useMemo(
() =>
props.removeModels.filter((m) =>
m.toLowerCase().includes(searchRemove.toLowerCase())
),
[props.removeModels, searchRemove]
)
const toggleModel = (
model: string,
set: Set<string>,
setter: (s: Set<string>) => void
) => {
const next = new Set(set)
if (next.has(model)) next.delete(model)
else next.add(model)
setter(next)
}
const toggleAllVisible = (
models: string[],
set: Set<string>,
setter: (s: Set<string>) => void
) => {
const allSelected = models.every((m) => set.has(m))
const next = new Set(set)
if (allSelected) {
models.forEach((m) => next.delete(m))
} else {
models.forEach((m) => next.add(m))
}
setter(next)
}
const handleConfirm = () => {
const hasAdd = props.addModels.length > 0
const hasRemove = props.removeModels.length > 0
const selectedAddArr = Array.from(selectedAdd)
const selectedRemoveArr = Array.from(selectedRemove)
const anyAdd = selectedAddArr.length > 0
const anyRemove = selectedRemoveArr.length > 0
if (hasAdd && hasRemove && anyAdd !== anyRemove) {
setPartialConfirmOpen(true)
return
}
props.onConfirm({
addModels: selectedAddArr,
removeModels: selectedRemoveArr,
})
}
return (
<>
<Dialog
open={props.open}
onOpenChange={(v) => !v && props.onCancel()}
title={t('Upstream Model Updates')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={props.onCancel}>
{t('Cancel')}
</Button>
<Button
onClick={handleConfirm}
disabled={
props.confirmLoading ||
(props.addModels.length === 0 &&
props.removeModels.length === 0)
}
>
{t('Confirm')}
</Button>
</>
}
>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedRemove, setSelectedRemove)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</Dialog>
<ConfirmDialog
open={partialConfirmOpen}
onOpenChange={setPartialConfirmOpen}
title={t('Partial Submission')}
desc={t(
'There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?'
)}
handleConfirm={() => {
setPartialConfirmOpen(false)
props.onConfirm({
addModels: Array.from(selectedAdd),
removeModels: Array.from(selectedRemove),
})
}}
/>
</>
)
}