diff --git a/frontend/src/components/ui/autocomplete.tsx b/frontend/src/components/ui/autocomplete.tsx new file mode 100644 index 0000000..0f5dc74 --- /dev/null +++ b/frontend/src/components/ui/autocomplete.tsx @@ -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, '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( + ( + { + value, + onChange, + options, + emptyText = '无匹配结果', + className, + dropdownClassName, + inputClassName, + onFocus, + onBlur, + disabled, + ...props + }, + ref + ) => { + const containerRef = React.useRef(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 ( +
+ { + setIsOpen(true) + onFocus?.(event) + }} + onBlur={(event) => { + onBlur?.(event) + }} + onChange={(event) => { + onChange(event.target.value) + setIsOpen(true) + }} + className={inputClassName} + /> + + {isOpen && !disabled && ( +
+ {filteredOptions.length > 0 ? ( +
+ {filteredOptions.map((option) => { + const label = option.label || option.value + const showDescription = option.description && option.description !== label + + return ( + + ) + })} +
+ ) : ( +
{emptyText}
+ )} +
+ )} +
+ ) + } +) + +AutoCompleteInput.displayName = 'AutoCompleteInput' diff --git a/frontend/src/lib/channel-models.ts b/frontend/src/lib/channel-models.ts new file mode 100644 index 0000000..51099fb --- /dev/null +++ b/frontend/src/lib/channel-models.ts @@ -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() + + 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() + + 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) +} diff --git a/frontend/src/pages/ApiTest.tsx b/frontend/src/pages/ApiTest.tsx index 3a33f11..56e1ee8 100644 --- a/frontend/src/pages/ApiTest.tsx +++ b/frontend/src/pages/ApiTest.tsx @@ -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 = { '/v1/chat/completions': { @@ -52,12 +53,22 @@ const requestTemplates: Record = { }, } +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(null) const [responseTime, setResponseTime] = useState(0) @@ -65,6 +76,9 @@ export function ApiTest() { const { addToast } = useToast() + const [tokens, setTokens] = useState([]) + const [channels, setChannels] = useState([]) + 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 ( - setApiToken(e.target.value)} + onChange={setApiToken} placeholder="sk-..." + inputClassName="font-mono" + options={tokenOptions} + emptyText="没有匹配的令牌" + /> + + +
+ +
@@ -182,7 +297,7 @@ export function ApiTest() {