import * as Popover from "@radix-ui/react-popover"; import Fuse, { FuseResult } from "fuse.js"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { ApiProvider } from "../types/llm/model"; // import { PROVIDERS } from '../constants'; import { GetAllProviders, GetEmbeddingProviderModelIds, GetEmbeddingProviders, GetProviderModelIds } from "../utils/api"; type TextSegment = { text: string; isHighlighted: boolean; }; type SearchableItem = { id: string; html: string | TextSegment[]; }; type HighlightedItem = { id: string; html: TextSegment[]; }; // 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; isEmbedding?: boolean, updateModel: (provider: ApiProvider, modelId: string) => void; }; export const ComboBoxComponent: React.FC = ({ name, provider, modelId, isEmbedding = false, updateModel, }) => { // 提供商选择状态 const [modelProvider, setModelProvider] = useState(provider); // 搜索输入状态 const [searchTerm, setSearchTerm] = useState(""); const [isOpen, setIsOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const providers = isEmbedding ? GetEmbeddingProviders() : GetAllProviders() const [modelIds, setModelIds] = useState([]); // Replace useMemo with useEffect for async fetching useEffect(() => { const fetchModelIds = async () => { const ids = isEmbedding ? GetEmbeddingProviderModelIds(modelProvider) : await GetProviderModelIds(modelProvider); setModelIds(ids); }; fetchModelIds(); }, [modelProvider, isEmbedding]); const searchableItems = useMemo(() => { return modelIds.map((id) => ({ id, html: id, })) }, [modelIds]) // 初始化 fuse,用于模糊搜索,简单配置 threshold 可按需调整 const fuse: Fuse = useMemo(() => { return new Fuse(searchableItems, { keys: ["html"], threshold: 0.6, shouldSort: true, isCaseSensitive: false, ignoreLocation: false, includeMatches: true, minMatchCharLength: 1, }) }, [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 })) return results }, [searchableItems, searchTerm, fuse]) const listRef = useRef(null); const itemRefs = useRef>([]); // 当选中项发生变化时,滚动到可视区域内 useEffect(() => { if (itemRefs.current[selectedIndex]) { itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest", behavior: "smooth" }); } }, [selectedIndex]); return (
{name}
{modelId}
{ 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(); const selectedOption = filteredOptions[selectedIndex]; if (selectedOption) { updateModel(modelProvider, selectedOption.id); setSearchTerm(""); setIsOpen(false); } break; } case "Escape": e.preventDefault(); setIsOpen(false); setSearchTerm(""); break; } }} />
{filteredOptions.map((option, index) => (
(itemRefs.current[index] = el)} onMouseEnter={() => setSelectedIndex(index)} onClick={() => { updateModel(modelProvider, option.id); setSearchTerm(""); setIsOpen(false); }} className={`infio-llm-setting-combobox-option ${index === selectedIndex ? 'is-selected' : ''}`} >
))}
); };