import * as Popover from "@radix-ui/react-popover"; import Fuse, { FuseResult } from "fuse.js"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { t } from "../../lang/helpers"; import { ApiProvider } from "../../types/llm/model"; import { InfioSettings } from "../../types/settings"; // import { PROVIDERS } from '../constants'; import { GetAllProviders, GetEmbeddingProviderModelIds, GetEmbeddingProviders, GetProviderModelIds } from "../../utils/api"; import { getProviderSettingKey } from "./ModelProviderSettings"; type TextSegment = { text: string; isHighlighted: boolean; }; type SearchableItem = { id: string; html: string | TextSegment[]; }; type HighlightedItem = { id: string; html: TextSegment[]; isCustom?: boolean; }; // Type guard for Record function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } // https://gist.github.com/evenfrost/1ba123656ded32fb7a0cd4651efd4db0 export const highlight = (fuseSearchResult: FuseResult[]): HighlightedItem[] => { const set = (obj: Record, path: string, value: TextSegment[]): void => { const pathValue = path.split(".") let i: number let current = obj for (i = 0; i < pathValue.length - 1; i++) { const nextValue = current[pathValue[i]] if (isRecord(nextValue)) { current = nextValue } else { throw new Error(`Invalid path: ${path}`) } } current[pathValue[i]] = value } // Function to merge overlapping regions const mergeRegions = (regions: [number, number][]): [number, number][] => { if (regions.length === 0) return regions // Sort regions by start index regions.sort((a, b) => a[0] - b[0]) const merged: [number, number][] = [regions[0]] for (let i = 1; i < regions.length; i++) { const last = merged[merged.length - 1] const current = regions[i] if (current[0] <= last[1] + 1) { // Overlapping or adjacent regions last[1] = Math.max(last[1], current[1]) } else { merged.push(current) } } return merged } const generateHighlightedSegments = (inputText: string, regions: [number, number][] = []): TextSegment[] => { if (regions.length === 0) { return [{ text: inputText, isHighlighted: false }]; } // Sort and merge overlapping regions const mergedRegions = mergeRegions(regions); const segments: TextSegment[] = []; let nextUnhighlightedRegionStartingIndex = 0; mergedRegions.forEach((region) => { const start = region[0]; const end = region[1]; const lastRegionNextIndex = end + 1; // Add unhighlighted segment before the highlight if (nextUnhighlightedRegionStartingIndex < start) { segments.push({ text: inputText.substring(nextUnhighlightedRegionStartingIndex, start), isHighlighted: false, }); } // Add highlighted segment segments.push({ text: inputText.substring(start, lastRegionNextIndex), isHighlighted: true, }); nextUnhighlightedRegionStartingIndex = lastRegionNextIndex; }); // Add remaining unhighlighted text if (nextUnhighlightedRegionStartingIndex < inputText.length) { segments.push({ text: inputText.substring(nextUnhighlightedRegionStartingIndex), isHighlighted: false, }); } return segments; } return fuseSearchResult .filter(({ matches }) => matches && matches.length) .map(({ item, matches }): HighlightedItem => { const highlightedItem: HighlightedItem = { id: item.id, html: typeof item.html === 'string' ? [{ text: item.html, isHighlighted: false }] : [...item.html] } matches?.forEach((match) => { if (match.key && typeof match.value === "string" && match.indices) { const mergedIndices = mergeRegions([...match.indices]) set(highlightedItem, match.key, generateHighlightedSegments(match.value, mergedIndices)) } }) return highlightedItem }) } const HighlightedText: React.FC<{ segments: TextSegment[] }> = ({ segments }) => { return ( <> {segments.map((segment, index) => ( segment.isHighlighted ? ( {segment.text} ) : ( {segment.text} ) ))} ); }; export type ComboBoxComponentProps = { name: string; provider: ApiProvider; modelId: string; settings?: InfioSettings | null; isEmbedding?: boolean, description?: string; updateModel: (provider: ApiProvider, modelId: string, isCustom?: boolean) => void; }; export const ComboBoxComponent: React.FC = ({ name, provider, modelId, settings = null, isEmbedding = false, description, updateModel, }) => { // provider state const [modelProvider, setModelProvider] = useState(provider); // search state const [searchTerm, setSearchTerm] = useState(""); const [isOpen, setIsOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const providers = isEmbedding ? GetEmbeddingProviders() : GetAllProviders() const [modelIds, setModelIds] = useState([]); // 统一处理模型选择和保存 const handleModelSelect = (provider: ApiProvider, modelId: string, isCustom?: boolean) => { console.debug(`handleModelSelect: ${provider} -> ${modelId}`) // 检查是否是自定义模型(不在官方模型列表中) // const isCustomModel = !modelIds.includes(modelId); updateModel(provider, modelId, isCustom); }; // Replace useMemo with useEffect for async fetching useEffect(() => { const fetchModelIds = async () => { const ids = isEmbedding ? GetEmbeddingProviderModelIds(modelProvider) : await GetProviderModelIds(modelProvider, settings); console.debug(`📝 Fetched ${ids.length} official models for ${modelProvider}:`, ids); setModelIds(ids); }; fetchModelIds(); }, [modelProvider, isEmbedding, settings]); const combinedModelIds = useMemo(() => { const providerKey = getProviderSettingKey(modelProvider); const providerModels = settings?.[providerKey]?.models; console.debug(`🔍 Custom models in settings for ${modelProvider}:`, providerModels || 'none') // Ensure providerModels is an array of strings if (!providerModels || !Array.isArray(providerModels)) { console.debug(`📋 Using only official models (${modelIds.length}):`, modelIds); return modelIds; } const additionalModels = providerModels.filter((model): model is string => typeof model === 'string'); console.debug(`📋 Combined models: ${modelIds.length} official + ${additionalModels.length} custom`); return [...modelIds, ...additionalModels]; }, [modelIds, settings, modelProvider]); const searchableItems = useMemo(() => { return combinedModelIds.map((id): SearchableItem => ({ id: String(id), html: String(id), })) }, [combinedModelIds]) // fuse, used for fuzzy search, simple configuration threshold can be adjusted as needed const fuse: Fuse = useMemo(() => { return new Fuse(searchableItems, { keys: ["html"], threshold: 1, shouldSort: true, isCaseSensitive: false, ignoreLocation: false, includeMatches: true, minMatchCharLength: 4, }) }, [searchableItems]) // 根据 searchTerm 得到过滤后的数据列表 const filteredOptions = useMemo(() => { const results: HighlightedItem[] = searchTerm ? highlight(fuse.search(searchTerm)) : searchableItems.map(item => ({ ...item, html: typeof item.html === 'string' ? [{ text: item.html, isHighlighted: false }] : item.html })) // 如果有搜索词,添加自定义选项(如果不存在完全匹配的话) if (searchTerm && searchTerm.trim()) { const exactMatch = searchableItems.some(item => item.id === searchTerm); if (!exactMatch) { results.unshift({ id: searchTerm, html: [{ text: `${modelIds.length > 0 ? t("settings.ModelProvider.custom") : ''}${searchTerm}`, isHighlighted: false }], isCustom: true }); } } return results }, [searchableItems, searchTerm, fuse, modelIds.length]) const listRef = useRef(null); const itemRefs = useRef>([]); // when selected index changes, scroll to visible area useEffect(() => { if (itemRefs.current[selectedIndex]) { itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest", behavior: "smooth" }); } }, [selectedIndex]); // Handle provider change const handleProviderChange = (newProvider: string) => { // Use proper type checking without type assertion const availableProviders = providers; const isValidProvider = (value: string): value is ApiProvider => { // @ts-expect-error - checking if providers array includes the value return availableProviders.includes(value); }; if (isValidProvider(newProvider)) { setModelProvider(newProvider); // 当提供商变更时,清空模型选择让用户重新选择 updateModel(newProvider, '', false); } }; return (
{name}
{description && (
{description}
)}
{/* Provider Selection - Now visible outside */}
{/* Model Selection */}
0 ? t("settings.ModelProvider.searchOrEnterModelName") : t("settings.ModelProvider.enterCustomModelName")} value={searchTerm} onChange={(e) => { setSearchTerm(e.target.value); setSelectedIndex(0); }} onKeyDown={(e) => { switch (e.key) { case "ArrowDown": e.preventDefault(); setSelectedIndex((prev) => Math.min(prev + 1, filteredOptions.length - 1) ); break; case "ArrowUp": e.preventDefault(); setSelectedIndex((prev) => Math.max(prev - 1, 0)); break; case "Enter": { e.preventDefault(); if (filteredOptions.length > 0) { const selectedOption = filteredOptions[selectedIndex]; if (selectedOption) { handleModelSelect(modelProvider, selectedOption.id, selectedOption.isCustom); } } else if (searchTerm.trim()) { // If no options but there is input content, use the input content directly handleModelSelect(modelProvider, searchTerm.trim(), true); } setSearchTerm(""); setIsOpen(false); break; } case "Escape": e.preventDefault(); setIsOpen(false); setSearchTerm(""); break; } }} />
{filteredOptions.length > 0 ? (
{filteredOptions.map((option, index) => (
(itemRefs.current[index] = el)} onMouseEnter={() => setSelectedIndex(index)} onClick={() => { handleModelSelect(modelProvider, option.id, option.isCustom); setSearchTerm(""); setIsOpen(false); }} className={`infio-llm-setting-combobox-option ${index === selectedIndex ? 'is-selected' : ''}`} >
))}
) : null}
); };