feat(autocomplete): 添加自动完成输入组件及相关逻辑
feat(channel-models): 添加通道模型解析和获取逻辑 feat(api-test): 集成自动完成输入,优化模型选择 feat(pricing): 增加定价页面模型选择的自动完成支持
This commit is contained in:
1 parent
84cc346ebd
commit
31c9788f99
4 files changed
+414
-16
No files matched your search
@@ -0,0 +1,140 @@
|
||||
import * as React from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type AutoCompleteOption = {
|
||||
value: string
|
||||
label?: string
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
}
|
||||
|
||||
type AutoCompleteProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> & {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: AutoCompleteOption[]
|
||||
emptyText?: string
|
||||
dropdownClassName?: string
|
||||
inputClassName?: string
|
||||
}
|
||||
|
||||
const normalizeText = (value: string): string => value.trim().toLowerCase()
|
||||
|
||||
const matchesOption = (option: AutoCompleteOption, query: string): boolean => {
|
||||
if (!query) return true
|
||||
|
||||
const haystack = [
|
||||
option.value,
|
||||
option.label || '',
|
||||
option.description || '',
|
||||
...(option.keywords || []),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(query)
|
||||
}
|
||||
|
||||
export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoCompleteProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
emptyText = '无匹配结果',
|
||||
className,
|
||||
dropdownClassName,
|
||||
inputClassName,
|
||||
onFocus,
|
||||
onBlur,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
|
||||
const query = normalizeText(value)
|
||||
const dedupedOptions = options.filter((option, index) => {
|
||||
return options.findIndex((candidate) => candidate.value === option.value) === index
|
||||
})
|
||||
const filteredOptions = dedupedOptions.filter((option) => matchesOption(option, query)).slice(0, 12)
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onChange(selectedValue)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('relative', className)}>
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onFocus={(event) => {
|
||||
setIsOpen(true)
|
||||
onFocus?.(event)
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event)
|
||||
}}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
className={inputClassName}
|
||||
/>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-20 mt-1 w-full overflow-hidden rounded-lg border bg-popover shadow-lg',
|
||||
dropdownClassName
|
||||
)}
|
||||
>
|
||||
{filteredOptions.length > 0 ? (
|
||||
<div className="max-h-72 overflow-y-auto py-1">
|
||||
{filteredOptions.map((option) => {
|
||||
const label = option.label || option.value
|
||||
const showDescription = option.description && option.description !== label
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className="flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left hover:bg-muted/80 transition-colors"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
{showDescription && (
|
||||
<span className="text-xs text-muted-foreground">{option.description}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">{emptyText}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
AutoCompleteInput.displayName = 'AutoCompleteInput'
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Channel, ChannelConfig, ChannelModelMapping, Token, TokenConfig } from '@/types'
|
||||
|
||||
const normalizeModels = (models?: ChannelModelMapping[]): ChannelModelMapping[] => {
|
||||
if (!Array.isArray(models)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return models
|
||||
.map((model) => ({
|
||||
id: typeof model?.id === 'string' ? model.id.trim() : '',
|
||||
name: typeof model?.name === 'string' ? model.name.trim() : '',
|
||||
}))
|
||||
.filter((model) => model.id.length > 0)
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}))
|
||||
}
|
||||
|
||||
export const parseChannelConfig = (channel: Channel): ChannelConfig => {
|
||||
if (typeof channel.value !== 'string') {
|
||||
return channel.value
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(channel.value) as ChannelConfig
|
||||
} catch {
|
||||
return {} as ChannelConfig
|
||||
}
|
||||
}
|
||||
|
||||
export const parseTokenConfig = (token: Token): TokenConfig => {
|
||||
if (typeof token.value !== 'string') {
|
||||
return token.value
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(token.value) as TokenConfig
|
||||
} catch {
|
||||
return {} as TokenConfig
|
||||
}
|
||||
}
|
||||
|
||||
export const getChannelModels = (config: ChannelConfig): ChannelModelMapping[] => {
|
||||
const normalizedModels = normalizeModels(config.models)
|
||||
if (normalizedModels.length > 0) {
|
||||
return normalizedModels
|
||||
}
|
||||
|
||||
const deploymentMapper = config.deployment_mapper || {}
|
||||
const supportedModels = Array.isArray(config.supported_models) ? config.supported_models : []
|
||||
const models: ChannelModelMapping[] = []
|
||||
const seenNames = new Set<string>()
|
||||
|
||||
const pushModel = (id: string, name?: string) => {
|
||||
const normalizedId = id.trim()
|
||||
const normalizedName = (name || id).trim()
|
||||
|
||||
if (!normalizedId || !normalizedName || seenNames.has(normalizedName)) {
|
||||
return
|
||||
}
|
||||
|
||||
seenNames.add(normalizedName)
|
||||
models.push({
|
||||
id: normalizedId,
|
||||
name: normalizedName,
|
||||
})
|
||||
}
|
||||
|
||||
supportedModels.forEach((modelName) => {
|
||||
const normalizedName = typeof modelName === 'string' ? modelName.trim() : ''
|
||||
if (!normalizedName) {
|
||||
return
|
||||
}
|
||||
|
||||
pushModel(deploymentMapper[normalizedName] || normalizedName, normalizedName)
|
||||
})
|
||||
|
||||
Object.entries(deploymentMapper).forEach(([modelName, modelId]) => {
|
||||
if (typeof modelId !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
pushModel(modelId, modelName)
|
||||
})
|
||||
|
||||
return models
|
||||
}
|
||||
|
||||
export const getUniqueModelNamesFromChannels = (channels: Channel[]): string[] => {
|
||||
const modelNames = new Set<string>()
|
||||
|
||||
channels.forEach((channel) => {
|
||||
const config = parseChannelConfig(channel)
|
||||
getChannelModels(config).forEach((model) => modelNames.add(model.name))
|
||||
})
|
||||
|
||||
return Array.from(modelNames).sort()
|
||||
}
|
||||
|
||||
export const getModelNamesForToken = (tokenKey: string, tokens: Token[], channels: Channel[]): string[] => {
|
||||
const matchedToken = tokens.find((token) => token.key === tokenKey)
|
||||
|
||||
if (!matchedToken) {
|
||||
return getUniqueModelNamesFromChannels(channels)
|
||||
}
|
||||
|
||||
const tokenConfig = parseTokenConfig(matchedToken)
|
||||
const allowedChannelKeys = tokenConfig.channel_keys || []
|
||||
const targetChannels = allowedChannelKeys.length === 0
|
||||
? channels
|
||||
: channels.filter((channel) => allowedChannelKeys.includes(channel.key))
|
||||
|
||||
return getUniqueModelNamesFromChannels(targetChannels)
|
||||
}
|
||||
+126
-11
@@ -1,17 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { AudioTestResponse, TestResponse } from '@/types'
|
||||
import { AudioTestResponse, Channel, TestResponse, Token } from '@/types'
|
||||
import { AutoCompleteInput, type AutoCompleteOption } from '@/components/ui/autocomplete'
|
||||
import { Send, Clock, CheckCircle, XCircle, Copy, Download } from 'lucide-react'
|
||||
import { PageContainer } from '@/components/ui/page-container'
|
||||
import { cn, copyToClipboard } from '@/lib/utils'
|
||||
import { getModelNamesForToken, parseTokenConfig } from '@/lib/channel-models'
|
||||
|
||||
const requestTemplates: Record<string, any> = {
|
||||
'/v1/chat/completions': {
|
||||
@@ -52,12 +53,22 @@ const requestTemplates: Record<string, any> = {
|
||||
},
|
||||
}
|
||||
|
||||
const buildRequestBody = (endpoint: string, model = '') => {
|
||||
const template = requestTemplates[endpoint] || {}
|
||||
const { model: _templateModel, ...rest } = template
|
||||
|
||||
return JSON.stringify(
|
||||
model ? { ...rest, model } : rest,
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiTest() {
|
||||
const [endpoint, setEndpoint] = useState('/v1/chat/completions')
|
||||
const [apiToken, setApiToken] = useState('')
|
||||
const [requestBody, setRequestBody] = useState(
|
||||
JSON.stringify(requestTemplates['/v1/chat/completions'], null, 2)
|
||||
)
|
||||
const [modelValue, setModelValue] = useState('')
|
||||
const [requestBody, setRequestBody] = useState(buildRequestBody('/v1/chat/completions'))
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [response, setResponse] = useState<any>(null)
|
||||
const [responseTime, setResponseTime] = useState<number>(0)
|
||||
@@ -65,6 +76,9 @@ export function ApiTest() {
|
||||
|
||||
const { addToast } = useToast()
|
||||
|
||||
const [tokens, setTokens] = useState<Token[]>([])
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
|
||||
const isAudioResponse = (value: TestResponse | { error: string } | null): value is AudioTestResponse => {
|
||||
return !!value && value.object === 'audio' && typeof value.url === 'string'
|
||||
}
|
||||
@@ -77,9 +91,56 @@ export function ApiTest() {
|
||||
}
|
||||
}, [response])
|
||||
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const [tokenResponse, channelResponse] = await Promise.all([
|
||||
apiClient.getTokens(),
|
||||
apiClient.getChannels(),
|
||||
])
|
||||
|
||||
setTokens((tokenResponse.data as Token[]) || [])
|
||||
setChannels((channelResponse.data as Channel[]) || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load API test options:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadOptions()
|
||||
}, [])
|
||||
|
||||
const availableModels = useMemo(
|
||||
() => getModelNamesForToken(apiToken, tokens, channels),
|
||||
[apiToken, tokens, channels]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (availableModels.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (modelValue) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextModel = availableModels[0]
|
||||
setModelValue(nextModel)
|
||||
setRequestBody((currentBody) => {
|
||||
try {
|
||||
const parsedBody = JSON.parse(currentBody)
|
||||
parsedBody.model = nextModel
|
||||
return JSON.stringify(parsedBody, null, 2)
|
||||
} catch {
|
||||
return buildRequestBody(endpoint, nextModel)
|
||||
}
|
||||
})
|
||||
}, [availableModels])
|
||||
|
||||
const handleEndpointChange = (newEndpoint: string) => {
|
||||
setEndpoint(newEndpoint)
|
||||
setRequestBody(JSON.stringify(requestTemplates[newEndpoint], null, 2))
|
||||
const nextModel = availableModels[0] || ''
|
||||
setModelValue(nextModel)
|
||||
setRequestBody(buildRequestBody(newEndpoint, nextModel))
|
||||
}
|
||||
|
||||
const handleCopyResponse = async () => {
|
||||
@@ -101,6 +162,31 @@ export function ApiTest() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateRequestBodyModel = (nextModel: string) => {
|
||||
setModelValue(nextModel)
|
||||
|
||||
try {
|
||||
const parsedBody = JSON.parse(requestBody)
|
||||
parsedBody.model = nextModel
|
||||
setRequestBody(JSON.stringify(parsedBody, null, 2))
|
||||
} catch {
|
||||
setRequestBody(buildRequestBody(endpoint, nextModel))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRequestBodyChange = (value: string) => {
|
||||
setRequestBody(value)
|
||||
|
||||
try {
|
||||
const parsedBody = JSON.parse(value)
|
||||
if (typeof parsedBody?.model === 'string') {
|
||||
setModelValue(parsedBody.model)
|
||||
}
|
||||
} catch {
|
||||
// Keep the current model selection while the JSON is temporarily invalid.
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -139,6 +225,21 @@ export function ApiTest() {
|
||||
}
|
||||
}
|
||||
|
||||
const tokenOptions: AutoCompleteOption[] = tokens.map((token) => {
|
||||
const tokenConfig = parseTokenConfig(token)
|
||||
|
||||
return {
|
||||
value: token.key,
|
||||
label: tokenConfig.name || token.key,
|
||||
description: token.key,
|
||||
keywords: [token.key, tokenConfig.name || ''],
|
||||
}
|
||||
})
|
||||
|
||||
const modelOptions: AutoCompleteOption[] = availableModels.map((model) => ({
|
||||
value: model,
|
||||
}))
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="API 测试"
|
||||
@@ -170,11 +271,25 @@ export function ApiTest() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">API 令牌</Label>
|
||||
<Input
|
||||
type="password"
|
||||
<AutoCompleteInput
|
||||
value={apiToken}
|
||||
onChange={(e) => setApiToken(e.target.value)}
|
||||
onChange={setApiToken}
|
||||
placeholder="sk-..."
|
||||
inputClassName="font-mono"
|
||||
options={tokenOptions}
|
||||
emptyText="没有匹配的令牌"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">模型</Label>
|
||||
<AutoCompleteInput
|
||||
value={modelValue}
|
||||
onChange={updateRequestBodyModel}
|
||||
placeholder="选择或输入模型名称"
|
||||
inputClassName="font-mono"
|
||||
options={modelOptions}
|
||||
emptyText="没有匹配的模型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -182,7 +297,7 @@ export function ApiTest() {
|
||||
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">请求体</Label>
|
||||
<Textarea
|
||||
value={requestBody}
|
||||
onChange={(e) => setRequestBody(e.target.value)}
|
||||
onChange={(e) => handleRequestBodyChange(e.target.value)}
|
||||
rows={14}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { PricingConfig } from '@/types'
|
||||
import { Channel, PricingConfig } from '@/types'
|
||||
import { AutoCompleteInput, type AutoCompleteOption } from '@/components/ui/autocomplete'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getUniqueModelNamesFromChannels } from '@/lib/channel-models'
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -30,6 +32,7 @@ export function Pricing() {
|
||||
Array<{ model: string; input: number; output: number; cache: number; request: number }>
|
||||
>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [modelOptions, setModelOptions] = useState<AutoCompleteOption[]>([])
|
||||
|
||||
const { addToast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -42,6 +45,21 @@ export function Pricing() {
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const loadChannelModels = async () => {
|
||||
try {
|
||||
const response = await apiClient.getChannels()
|
||||
const channels = response.data as Channel[]
|
||||
const modelNames = getUniqueModelNamesFromChannels(channels)
|
||||
setModelOptions(modelNames.map((model) => ({ value: model })))
|
||||
} catch (error) {
|
||||
console.error('Failed to load channels for pricing suggestions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadChannelModels()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const rows = Object.entries(data).map(([model, pricing]) => ({
|
||||
@@ -152,6 +170,14 @@ export function Pricing() {
|
||||
.map((row, index) => ({ ...row, _i: index }))
|
||||
.filter((row) => row.model.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
|
||||
const pricingModelOptions = [
|
||||
...pricingRows
|
||||
.map((row) => row.model.trim())
|
||||
.filter((model) => model.length > 0)
|
||||
.map((model) => ({ value: model })),
|
||||
...modelOptions,
|
||||
].filter((option, index, options) => options.findIndex((candidate) => candidate.value === option.value) === index)
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="定价管理"
|
||||
@@ -217,7 +243,7 @@ export function Pricing() {
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border bg-card overflow-hidden">
|
||||
<div className="rounded-xl border bg-card">
|
||||
{/* Header */}
|
||||
<div className="grid gap-2 px-4 py-2.5 border-b bg-muted/30" style={{ gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr 40px' }}>
|
||||
<span className="text-xs font-medium text-muted-foreground">模型名称</span>
|
||||
@@ -232,11 +258,13 @@ export function Pricing() {
|
||||
<div className="divide-y">
|
||||
{filteredRows.map((row) => (
|
||||
<div key={row._i} className="grid gap-2 px-4 py-2 items-center hover:bg-muted/20 transition-colors" style={{ gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr 40px' }}>
|
||||
<Input
|
||||
<AutoCompleteInput
|
||||
value={row.model}
|
||||
onChange={(e) => updateRow(row._i, 'model', e.target.value)}
|
||||
onChange={(value) => updateRow(row._i, 'model', value)}
|
||||
placeholder="模型名称"
|
||||
className="font-mono text-sm h-8"
|
||||
inputClassName="font-mono text-sm h-8"
|
||||
options={pricingModelOptions}
|
||||
emptyText="没有匹配的模型"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
|
||||
Reference in new issue
Block a user