feat(autocomplete): 添加自动完成输入组件及相关逻辑

feat(channel-models): 添加通道模型解析和获取逻辑
feat(api-test): 集成自动完成输入,优化模型选择
feat(pricing): 增加定价页面模型选择的自动完成支持
This commit is contained in:
Jeff Gu committed 2026-04-09 12:33:17 +08:00
1 parent 84cc346ebd
commit 31c9788f99
4 files changed
+414 -16

No files matched your search

+140
View File
@@ -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'
+115
View File
@@ -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
View File
@@ -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"
/>
+33 -5
View File
@@ -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"