feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { theme } from 'antd';
|
||||
|
||||
interface AvatarEditBadgeProps {
|
||||
children: ReactNode;
|
||||
/** Avatar size in px — used to calculate badge size proportionally */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlays a small pencil badge at the bottom-right of an avatar
|
||||
* to indicate it is user-editable.
|
||||
*/
|
||||
export function AvatarEditBadge({ children, size = 64 }: AvatarEditBadgeProps) {
|
||||
const { token } = theme.useToken();
|
||||
const badgeSize = Math.max(18, Math.round(size * 0.3));
|
||||
const iconSize = Math.round(badgeSize * 0.55);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'inline-flex' }}>
|
||||
{children}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: badgeSize,
|
||||
height: badgeSize,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: token.colorPrimary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: `2px solid ${token.colorBgElevated}`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Pencil size={iconSize} color="#fff" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { memo } from 'react';
|
||||
// Static barrel import — all icon components are already mostly bundled
|
||||
// via providerConfig/modelConfig; marginal addition is small.
|
||||
import * as LobeIcons from '@lobehub/icons/es/icons.js';
|
||||
|
||||
const iconsMap = LobeIcons as unknown as Record<string, any>;
|
||||
|
||||
interface DynamicLobeIconProps {
|
||||
iconId: string;
|
||||
size?: number;
|
||||
type?: 'color' | 'avatar' | 'mono';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a @lobehub/icons icon by its toc `id` (e.g., "Ai302", "OpenAI")
|
||||
* via direct component lookup, bypassing the incomplete keyword matching
|
||||
* in ProviderIcon/ModelIcon.
|
||||
*/
|
||||
export const DynamicLobeIcon = memo(function DynamicLobeIcon({
|
||||
iconId,
|
||||
size = 24,
|
||||
type = 'avatar',
|
||||
}: DynamicLobeIconProps) {
|
||||
const IconModule = iconsMap[iconId];
|
||||
if (!IconModule) return <div style={{ width: size, height: size }} />;
|
||||
|
||||
if (type === 'color' && IconModule.Color) {
|
||||
return <IconModule.Color size={size} />;
|
||||
}
|
||||
if (type === 'avatar' && IconModule.Avatar) {
|
||||
return <IconModule.Avatar size={size} />;
|
||||
}
|
||||
return <IconModule size={size} />;
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useMemo, useCallback, useEffect } from 'react';
|
||||
import { Select, theme } from 'antd';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { useProviderStore } from '@/stores';
|
||||
import { parseModelValue, useProviderNameMap } from './ModelSelect';
|
||||
|
||||
function isEmbeddingModel(model: { model_id: string; model_type?: string }) {
|
||||
return model.model_type === 'Embedding' || /embed/i.test(model.model_id);
|
||||
}
|
||||
|
||||
/** Hook: returns grouped Select options filtered to embedding-capable models */
|
||||
function useEmbeddingModelOptions() {
|
||||
const providers = useProviderStore((s) => s.providers);
|
||||
const fetchProviders = useProviderStore((s) => s.fetchProviders);
|
||||
|
||||
useEffect(() => {
|
||||
if (providers.length === 0) {
|
||||
void fetchProviders();
|
||||
}
|
||||
}, [fetchProviders, providers.length]);
|
||||
|
||||
return useMemo(() => {
|
||||
return providers
|
||||
.filter((p) => p.enabled)
|
||||
.map((p) => {
|
||||
const embeddingModels = p.models.filter(
|
||||
(m) => m.enabled && isEmbeddingModel(m),
|
||||
);
|
||||
if (embeddingModels.length === 0) return null;
|
||||
return {
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<ModelIcon model={p.name} size={16} type="avatar" />
|
||||
{p.name}
|
||||
</span>
|
||||
),
|
||||
title: p.name,
|
||||
options: embeddingModels.map((m) => ({
|
||||
label: m.name,
|
||||
value: `${p.id}::${m.model_id}`,
|
||||
modelId: m.model_id,
|
||||
providerName: p.name,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter((opt): opt is NonNullable<typeof opt> => opt !== null);
|
||||
}, [providers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Model selector filtered to embedding-capable models.
|
||||
*/
|
||||
export function EmbeddingModelSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
allowClear = true,
|
||||
style,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange: (value: string | undefined) => void;
|
||||
placeholder?: string;
|
||||
allowClear?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const { token } = theme.useToken();
|
||||
const embeddingOptions = useEmbeddingModelOptions();
|
||||
const providerNameMap = useProviderNameMap();
|
||||
|
||||
const optionRender = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(option: any) => (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<ModelIcon model={option.data?.modelId ?? ''} size={18} type="avatar" />
|
||||
{option.label}
|
||||
</span>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const labelRender = useCallback(
|
||||
(props: { label?: React.ReactNode; value?: string | number }) => {
|
||||
const parsed = parseModelValue(String(props.value ?? ''));
|
||||
if (!parsed) return <span>{props.label}</span>;
|
||||
const providerName = providerNameMap.get(parsed.providerId) ?? '';
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<ModelIcon model={parsed.modelId} size={18} type="avatar" />
|
||||
{props.label}
|
||||
<span style={{ fontSize: 11, color: token.colorTextSecondary }}>
|
||||
({providerName})
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[providerNameMap, token.colorTextSecondary],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
allowClear={allowClear}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
optionRender={optionRender}
|
||||
labelRender={labelRender}
|
||||
options={embeddingOptions}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { Modal, theme } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSettingsStore } from '@/stores';
|
||||
import { useResolvedDarkMode } from '@/hooks/useResolvedDarkMode';
|
||||
import 'emoji-picker-element';
|
||||
|
||||
interface EmojiPickerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
}
|
||||
|
||||
export function EmojiPicker({ open, onClose, onEmojiSelect }: EmojiPickerProps) {
|
||||
const pickerRef = useRef<HTMLElement | null>(null);
|
||||
const { token } = theme.useToken();
|
||||
const { t, i18n } = useTranslation();
|
||||
const themeMode = useSettingsStore((s) => s.settings.theme_mode);
|
||||
const isDark = useResolvedDarkMode(themeMode);
|
||||
|
||||
const onEmojiSelectRef = useRef(onEmojiSelect);
|
||||
onEmojiSelectRef.current = onEmojiSelect;
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
const handleClick = useCallback((e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.unicode) {
|
||||
onEmojiSelectRef.current(detail.unicode);
|
||||
onCloseRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Use callback ref to attach event listener when the element is available
|
||||
const setPickerRef = useCallback((node: HTMLElement | null) => {
|
||||
// Detach from old node
|
||||
if (pickerRef.current) {
|
||||
pickerRef.current.removeEventListener('emoji-click', handleClick);
|
||||
}
|
||||
pickerRef.current = node;
|
||||
// Attach to new node
|
||||
if (node) {
|
||||
node.addEventListener('emoji-click', handleClick);
|
||||
}
|
||||
}, [handleClick]);
|
||||
|
||||
// Sync locale + i18n translations
|
||||
useEffect(() => {
|
||||
const picker = pickerRef.current as any;
|
||||
if (!picker) return;
|
||||
const lang = i18n.language;
|
||||
if (lang.startsWith('zh')) {
|
||||
import('emoji-picker-element/i18n/zh_CN').then((mod) => {
|
||||
picker.locale = 'zh';
|
||||
picker.i18n = mod.default;
|
||||
picker.dataSource = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/zh/emojibase/data.json';
|
||||
});
|
||||
} else {
|
||||
import('emoji-picker-element/i18n/en').then((mod) => {
|
||||
picker.locale = 'en';
|
||||
picker.i18n = mod.default;
|
||||
picker.dataSource = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json';
|
||||
});
|
||||
}
|
||||
}, [i18n.language, open]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
title={t('common.selectEmoji')}
|
||||
width={400}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{
|
||||
borderRadius: token.borderRadius,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* @ts-ignore - web component */}
|
||||
<emoji-picker
|
||||
ref={setPickerRef}
|
||||
class={isDark ? 'dark' : 'light'}
|
||||
style={{
|
||||
'--num-columns': '8',
|
||||
'--emoji-padding': '0.25rem',
|
||||
'--border-radius': `${token.borderRadius}px`,
|
||||
'--background': isDark ? token.colorBgElevated : token.colorBgContainer,
|
||||
'--border-color': 'transparent',
|
||||
'--indicator-color': token.colorPrimary,
|
||||
'--input-border-color': token.colorBorder,
|
||||
'--input-font-size': '14px',
|
||||
'--input-padding': '0.4rem',
|
||||
'--button-hover-background': token.colorFillSecondary,
|
||||
'--button-active-background': token.colorFillTertiary,
|
||||
'--outline-color': token.colorPrimary,
|
||||
width: '100%',
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState, useRef, lazy, Suspense, type ReactNode } from 'react';
|
||||
import { Avatar, Dropdown, Input, Button, theme } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Smile, Link, FileImage, Trash2, Grid2x2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invoke, isTauri } from '@/lib/invoke';
|
||||
import { AvatarEditBadge } from './AvatarEditBadge';
|
||||
import { EmojiPicker } from './EmojiPicker';
|
||||
import { DynamicLobeIcon } from './DynamicLobeIcon';
|
||||
import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc';
|
||||
import type { AvatarType } from '@/stores/userProfileStore';
|
||||
|
||||
const IconPickerModal = lazy(() => import('@/components/settings/IconPickerModal'));
|
||||
|
||||
export interface IconEditorProps {
|
||||
/** Current icon type: 'emoji' | 'url' | 'file' | 'model_icon' | or custom */
|
||||
iconType?: string | null;
|
||||
/** Current icon value */
|
||||
iconValue?: string | null;
|
||||
/** Called when icon changes */
|
||||
onChange: (iconType: string | null, iconValue: string | null) => void;
|
||||
/** Display size in px */
|
||||
size?: number;
|
||||
/** Default icon when nothing is set */
|
||||
defaultIcon?: ReactNode;
|
||||
/** Extra menu items prepended before the standard ones */
|
||||
prependMenuItems?: MenuProps['items'];
|
||||
/** Extra menu items appended after the standard ones (e.g. "reset to default") */
|
||||
extraMenuItems?: MenuProps['items'];
|
||||
/** Whether to show the "clear" option when an icon is set */
|
||||
showClear?: boolean;
|
||||
/** Shape of the avatar: 'circle' | 'square' */
|
||||
shape?: 'circle' | 'square';
|
||||
/** Show "model/provider icons" option (opens IconPickerModal) */
|
||||
showModelIcons?: boolean;
|
||||
/** Default tab when opening IconPickerModal */
|
||||
modelIconsDefaultTab?: 'model' | 'provider';
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared icon editor with AvatarEditBadge + Dropdown.
|
||||
* Supports emoji, URL, file upload, and optionally model/provider icon library.
|
||||
*/
|
||||
export function IconEditor({
|
||||
iconType,
|
||||
iconValue,
|
||||
onChange,
|
||||
size = 32,
|
||||
defaultIcon,
|
||||
prependMenuItems,
|
||||
extraMenuItems,
|
||||
showClear = true,
|
||||
shape = 'circle',
|
||||
showModelIcons = false,
|
||||
modelIconsDefaultTab = 'model',
|
||||
}: IconEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [showUrlInput, setShowUrlInput] = useState(false);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
const resolvedSrc = useResolvedAvatarSrc((iconType as AvatarType) ?? 'icon', iconValue ?? '');
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const dataUri = reader.result as string;
|
||||
const match = dataUri.match(/^data:([^;]+);base64,(.+)$/s);
|
||||
if (match && isTauri()) {
|
||||
try {
|
||||
const relativePath = await invoke<string>('save_avatar_file', { data: match[2], mimeType: match[1] });
|
||||
onChange('file', relativePath);
|
||||
} catch {
|
||||
onChange('file', dataUri);
|
||||
}
|
||||
} else {
|
||||
onChange('file', dataUri);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// Build menu items
|
||||
const menuItems: MenuProps['items'] = [
|
||||
...(prependMenuItems ?? []),
|
||||
...(showModelIcons ? [{
|
||||
key: 'model_icon',
|
||||
icon: <Grid2x2 size={14} />,
|
||||
label: t('settings.chooseIcon'),
|
||||
onClick: () => setShowIconPicker(true),
|
||||
}] : []),
|
||||
{
|
||||
key: 'emoji',
|
||||
icon: <Smile size={14} />,
|
||||
label: t('userProfile.emoji'),
|
||||
onClick: () => { setShowEmojiPicker(true); setShowUrlInput(false); },
|
||||
},
|
||||
{
|
||||
key: 'url',
|
||||
icon: <Link size={14} />,
|
||||
label: t('userProfile.imageUrl'),
|
||||
onClick: () => { setShowUrlInput(true); setShowEmojiPicker(false); setUrlInput(iconType === 'url' ? (iconValue ?? '') : ''); },
|
||||
},
|
||||
{
|
||||
key: 'file',
|
||||
icon: <FileImage size={14} />,
|
||||
label: t('userProfile.selectImage'),
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
},
|
||||
...(showClear && iconType ? [
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'clear',
|
||||
icon: <Trash2 size={14} />,
|
||||
label: t('settings.memory.clearIcon'),
|
||||
danger: true as const,
|
||||
onClick: () => onChange(null, null),
|
||||
},
|
||||
] : []),
|
||||
...(extraMenuItems ?? []),
|
||||
];
|
||||
|
||||
// Parse model_icon value format: "group:iconId"
|
||||
const parseModelIcon = (value: string) => {
|
||||
const idx = value.indexOf(':');
|
||||
return idx > 0 ? value.substring(idx + 1) : value;
|
||||
};
|
||||
|
||||
// Render current icon
|
||||
const renderIcon = () => {
|
||||
if (iconType === 'emoji' && iconValue) {
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size,
|
||||
borderRadius: shape === 'circle' ? '50%' : token.borderRadius,
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.55, lineHeight: 1, cursor: 'pointer',
|
||||
}}>
|
||||
{iconValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if ((iconType === 'url' || iconType === 'file') && iconValue) {
|
||||
const src = iconType === 'file' ? (resolvedSrc ?? iconValue) : iconValue;
|
||||
return <Avatar size={size} src={src} shape={shape} style={{ cursor: 'pointer' }} />;
|
||||
}
|
||||
if (iconType === 'model_icon' && iconValue) {
|
||||
return (
|
||||
<div style={{ cursor: 'pointer' }}>
|
||||
<DynamicLobeIcon iconId={parseModelIcon(iconValue)} size={size} type="avatar" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (defaultIcon) {
|
||||
return <div style={{ cursor: 'pointer' }}>{defaultIcon}</div>;
|
||||
}
|
||||
return (
|
||||
<Avatar
|
||||
size={size}
|
||||
shape={shape}
|
||||
style={{ cursor: 'pointer', backgroundColor: token.colorFillSecondary, color: token.colorTextSecondary }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['click']} placement="bottomLeft">
|
||||
<div style={{ cursor: 'pointer', display: 'inline-flex' }}>
|
||||
<AvatarEditBadge size={size}>
|
||||
{renderIcon()}
|
||||
</AvatarEditBadge>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<EmojiPicker
|
||||
open={showEmojiPicker}
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
onEmojiSelect={(emoji) => {
|
||||
onChange('emoji', emoji);
|
||||
setShowEmojiPicker(false);
|
||||
}}
|
||||
/>
|
||||
{showModelIcons && (
|
||||
<Suspense>
|
||||
<IconPickerModal
|
||||
open={showIconPicker}
|
||||
onClose={() => setShowIconPicker(false)}
|
||||
defaultTab={modelIconsDefaultTab}
|
||||
onSelect={(iconId, group) => {
|
||||
onChange('model_icon', `${group}:${iconId}`);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{showUrlInput && (
|
||||
<div className="flex items-center gap-2" style={{ marginTop: 8 }}>
|
||||
<Input
|
||||
placeholder={t('settings.memory.iconUrlPlaceholder')}
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
autoFocus
|
||||
onPressEnter={() => {
|
||||
if (urlInput.trim()) {
|
||||
onChange('url', urlInput.trim());
|
||||
}
|
||||
setShowUrlInput(false);
|
||||
}}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Button size="small" onClick={() => {
|
||||
if (urlInput.trim()) {
|
||||
onChange('url', urlInput.trim());
|
||||
}
|
||||
setShowUrlInput(false);
|
||||
}}>{t('common.ok')}</Button>
|
||||
<Button size="small" onClick={() => setShowUrlInput(false)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Avatar, theme } from 'antd';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc';
|
||||
import type { AvatarType } from '@/stores/userProfileStore';
|
||||
import type { KnowledgeBase } from '@/types';
|
||||
|
||||
interface KnowledgeBaseIconProps {
|
||||
kb: KnowledgeBase;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function KnowledgeBaseIcon({ kb, size = 16 }: KnowledgeBaseIconProps) {
|
||||
const resolvedSrc = useResolvedAvatarSrc((kb.iconType as AvatarType) ?? 'icon', kb.iconValue ?? '');
|
||||
const { token } = theme.useToken();
|
||||
|
||||
if (kb.iconType === 'emoji' && kb.iconValue) {
|
||||
return (
|
||||
<span style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.7, lineHeight: 1, flexShrink: 0,
|
||||
}}>
|
||||
{kb.iconValue}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if ((kb.iconType === 'url' || kb.iconType === 'file') && kb.iconValue) {
|
||||
const src = kb.iconType === 'file' ? resolvedSrc : kb.iconValue;
|
||||
return <Avatar size={size} src={src} style={{ flexShrink: 0 }} />;
|
||||
}
|
||||
return <BookOpen size={size} style={{ flexShrink: 0, color: token.colorTextSecondary }} />;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Avatar, theme } from 'antd';
|
||||
import { Globe, FileSearch, Plug } from 'lucide-react';
|
||||
import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc';
|
||||
import type { McpServer } from '@/types';
|
||||
|
||||
/** Icons for builtin MCP servers, keyed by server name. Returns icon at given size. */
|
||||
const BUILTIN_ICON_FACTORY: Record<string, (size: number) => React.ReactNode> = {
|
||||
'@boosapi/fetch': (s) => <Globe size={s} />,
|
||||
'@boosapi/search-file': (s) => <FileSearch size={s} />,
|
||||
};
|
||||
|
||||
/** Static 16px icons for external use. */
|
||||
export const BUILTIN_ICONS: Record<string, React.ReactNode> = {
|
||||
'@boosapi/fetch': <Globe size={16} />,
|
||||
'@boosapi/search-file': <FileSearch size={16} />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the appropriate icon for an MCP server:
|
||||
* - Builtin servers → fixed icon from BUILTIN_ICONS
|
||||
* - Custom servers with emoji/url/file iconType → user-chosen icon
|
||||
* - Default → Plug icon
|
||||
*/
|
||||
export function McpServerIcon({ server, size = 24 }: { server: McpServer; size?: number }) {
|
||||
const resolvedSrc = useResolvedAvatarSrc(
|
||||
(server.iconType as 'icon' | 'emoji' | 'url' | 'file') ?? 'icon',
|
||||
server.iconValue ?? '',
|
||||
);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// Builtin servers: use fixed icon
|
||||
if (server.source === 'builtin' && BUILTIN_ICON_FACTORY[server.name]) {
|
||||
return (
|
||||
<span style={{
|
||||
width: size, height: size,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, color: token.colorTextSecondary,
|
||||
}}>
|
||||
{BUILTIN_ICON_FACTORY[server.name](size * 0.7)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom: emoji
|
||||
if (server.iconType === 'emoji' && server.iconValue) {
|
||||
return (
|
||||
<span style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.6, lineHeight: 1, flexShrink: 0,
|
||||
}}>
|
||||
{server.iconValue}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom: url or file image
|
||||
if ((server.iconType === 'url' || server.iconType === 'file') && server.iconValue) {
|
||||
const src = server.iconType === 'file' ? resolvedSrc : server.iconValue;
|
||||
return <Avatar size={size} src={src} style={{ flexShrink: 0 }} />;
|
||||
}
|
||||
|
||||
// Default: plug icon
|
||||
return (
|
||||
<span style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, color: token.colorTextSecondary,
|
||||
}}>
|
||||
<Plug size={size * 0.6} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Select, theme } from 'antd';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { useProviderStore } from '@/stores';
|
||||
import { SmartProviderIcon } from '@/lib/providerIcons';
|
||||
import type { ModelType } from '@/types';
|
||||
|
||||
/** Parse a combined `providerId::modelId` value. */
|
||||
export function parseModelValue(value: string | undefined) {
|
||||
if (!value) return null;
|
||||
const idx = value.indexOf('::');
|
||||
if (idx < 0) return null;
|
||||
return { providerId: value.slice(0, idx), modelId: value.slice(idx + 2) };
|
||||
}
|
||||
|
||||
/** Hook: returns grouped Select options (Provider → Models) */
|
||||
export function useGroupedModelOptions(modelType?: ModelType) {
|
||||
const providers = useProviderStore((s) => s.providers);
|
||||
return useMemo(() => {
|
||||
return providers
|
||||
.filter((p) => p.enabled)
|
||||
.map((p) => {
|
||||
const models = p.models.filter((m) => m.enabled && (!modelType || m.model_type === modelType));
|
||||
if (models.length === 0) return null;
|
||||
return {
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<SmartProviderIcon provider={p} size={16} type="avatar" />
|
||||
{p.name}
|
||||
</span>
|
||||
),
|
||||
title: p.name,
|
||||
options: models.map((m) => ({
|
||||
label: m.name,
|
||||
value: `${p.id}::${m.model_id}`,
|
||||
modelId: m.model_id,
|
||||
providerName: p.name,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter((option): option is NonNullable<typeof option> => option !== null);
|
||||
}, [providers, modelType]);
|
||||
}
|
||||
|
||||
/** Hook: returns Map<providerId, providerName> */
|
||||
export function useProviderNameMap() {
|
||||
const providers = useProviderStore((s) => s.providers);
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
providers.forEach((p) => map.set(p.id, p.name));
|
||||
return map;
|
||||
}, [providers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable model selector with provider-grouped options, ModelIcon rendering,
|
||||
* and search support. Value format: `providerId::modelId`.
|
||||
*/
|
||||
export function ModelSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
allowClear = true,
|
||||
style,
|
||||
modelType,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange: (value: string | undefined) => void;
|
||||
placeholder?: string;
|
||||
allowClear?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
modelType?: ModelType;
|
||||
}) {
|
||||
const { token } = theme.useToken();
|
||||
const groupedOptions = useGroupedModelOptions(modelType);
|
||||
const providerNameMap = useProviderNameMap();
|
||||
|
||||
const optionRender = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(option: any) => (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<ModelIcon model={option.data?.modelId ?? ''} size={18} type="avatar" />
|
||||
{option.label}
|
||||
</span>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const labelRender = useCallback(
|
||||
(props: { label?: React.ReactNode; value?: string | number }) => {
|
||||
const parsed = parseModelValue(String(props.value ?? ''));
|
||||
if (!parsed) return <span>{props.label}</span>;
|
||||
const providerName = providerNameMap.get(parsed.providerId) ?? '';
|
||||
return (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<ModelIcon model={parsed.modelId} size={18} type="avatar" />
|
||||
{props.label}
|
||||
<span style={{ fontSize: 11, color: token.colorTextSecondary }}>
|
||||
({providerName})
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[providerNameMap, token.colorTextSecondary],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
allowClear={allowClear}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
optionRender={optionRender}
|
||||
labelRender={labelRender}
|
||||
options={groupedOptions}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Avatar, theme } from 'antd';
|
||||
import { Brain } from 'lucide-react';
|
||||
import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc';
|
||||
import type { AvatarType } from '@/stores/userProfileStore';
|
||||
import type { MemoryNamespace } from '@/types';
|
||||
|
||||
interface NamespaceIconProps {
|
||||
ns: MemoryNamespace;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function NamespaceIcon({ ns, size = 16 }: NamespaceIconProps) {
|
||||
const resolvedSrc = useResolvedAvatarSrc((ns.iconType as AvatarType) ?? 'icon', ns.iconValue ?? '');
|
||||
const { token } = theme.useToken();
|
||||
|
||||
if (ns.iconType === 'emoji' && ns.iconValue) {
|
||||
return (
|
||||
<span style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.7, lineHeight: 1, flexShrink: 0,
|
||||
}}>
|
||||
{ns.iconValue}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if ((ns.iconType === 'url' || ns.iconType === 'file') && ns.iconValue) {
|
||||
const src = ns.iconType === 'file' ? resolvedSrc : ns.iconValue;
|
||||
return <Avatar size={size} src={src} style={{ flexShrink: 0 }} />;
|
||||
}
|
||||
return <Brain size={size} style={{ flexShrink: 0, color: token.colorTextSecondary }} />;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo, useCallback, useEffect } from 'react';
|
||||
import { Select, theme } from 'antd';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { useProviderStore } from '@/stores';
|
||||
import { parseModelValue, useProviderNameMap } from './ModelSelect';
|
||||
|
||||
function isRerankModel(model: { model_id: string; model_type?: string }) {
|
||||
return model.model_type === 'Rerank' || /rerank|colbert/i.test(model.model_id);
|
||||
}
|
||||
|
||||
function useRerankModelOptions() {
|
||||
const providers = useProviderStore((s) => s.providers);
|
||||
const fetchProviders = useProviderStore((s) => s.fetchProviders);
|
||||
|
||||
useEffect(() => {
|
||||
if (providers.length === 0) {
|
||||
void fetchProviders();
|
||||
}
|
||||
}, [fetchProviders, providers.length]);
|
||||
|
||||
return useMemo(() => {
|
||||
return providers
|
||||
.filter((p) => p.enabled)
|
||||
.map((p) => {
|
||||
const rerankModels = p.models.filter((m) => m.enabled && isRerankModel(m));
|
||||
if (rerankModels.length === 0) return null;
|
||||
return {
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<ModelIcon model={p.name} size={16} type="avatar" />
|
||||
{p.name}
|
||||
</span>
|
||||
),
|
||||
title: p.name,
|
||||
options: rerankModels.map((m) => ({
|
||||
label: m.name,
|
||||
value: `${p.id}::${m.model_id}`,
|
||||
modelId: m.model_id,
|
||||
providerName: p.name,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter((opt): opt is NonNullable<typeof opt> => opt !== null);
|
||||
}, [providers]);
|
||||
}
|
||||
|
||||
export function RerankModelSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
allowClear = true,
|
||||
style,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange: (value: string | undefined) => void;
|
||||
placeholder?: string;
|
||||
allowClear?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const { token } = theme.useToken();
|
||||
const rerankOptions = useRerankModelOptions();
|
||||
const providerNameMap = useProviderNameMap();
|
||||
|
||||
const optionRender = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(option: any) => (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<ModelIcon model={option.data?.modelId ?? ''} size={18} type="avatar" />
|
||||
{option.label}
|
||||
</span>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const labelRender = useCallback(
|
||||
(props: { label?: React.ReactNode; value?: string | number }) => {
|
||||
const parsed = parseModelValue(String(props.value ?? ''));
|
||||
if (!parsed) return <span>{props.label}</span>;
|
||||
const providerName = providerNameMap.get(parsed.providerId) ?? '';
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<ModelIcon model={parsed.modelId} size={18} type="avatar" />
|
||||
{props.label}
|
||||
<span style={{ fontSize: 11, color: token.colorTextSecondary }}>
|
||||
({providerName})
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[providerNameMap, token.colorTextSecondary],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
allowClear={allowClear}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
optionRender={optionRender}
|
||||
labelRender={labelRender}
|
||||
options={rerankOptions}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ProviderIcon } from '@lobehub/icons';
|
||||
import Tavily from '@lobehub/icons/es/Tavily';
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
|
||||
/** Renders the correct icon for a given SearchProviderType */
|
||||
export function SearchProviderTypeIcon({ type, size = 20 }: { type: string; size?: number }) {
|
||||
switch (type) {
|
||||
case 'tavily':
|
||||
return <Tavily.Color size={size} />;
|
||||
case 'zhipu':
|
||||
return <ProviderIcon provider="zhipu" size={size} type="color" />;
|
||||
case 'bocha':
|
||||
return <img src="/icons/bocha.ico" alt="Bocha" style={{ width: size, height: size }} />;
|
||||
default:
|
||||
return <SearchIcon size={size - 2} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const PROVIDER_TYPE_LABELS: Record<string, string> = {
|
||||
tavily: 'Tavily',
|
||||
zhipu: '智谱',
|
||||
bocha: '博查',
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ProviderConfig } from '@/types';
|
||||
import { EmbeddingModelSelect } from '../EmbeddingModelSelect';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchProviders: vi.fn(),
|
||||
}));
|
||||
|
||||
let providers: ProviderConfig[] = [];
|
||||
|
||||
function makeProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||||
return {
|
||||
id: 'provider-1',
|
||||
name: 'OpenAI Compatible',
|
||||
provider_type: 'openai',
|
||||
api_host: 'https://api.example.com',
|
||||
api_path: '/v1/chat/completions',
|
||||
enabled: true,
|
||||
models: [],
|
||||
keys: [],
|
||||
proxy_config: null,
|
||||
custom_headers: null,
|
||||
icon: null,
|
||||
builtin_id: null,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => <span data-testid="model-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Select: ({ options }: { options?: Array<{ title: string; options: Array<{ label: string; value: string }> }> }) => (
|
||||
<div>
|
||||
{options?.map((group) => (
|
||||
<section key={group.title} aria-label={group.title}>
|
||||
{group.options.map((option) => (
|
||||
<div key={option.value}>{option.label}</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
theme: {
|
||||
useToken: () => ({ token: { colorTextSecondary: '#666' } }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useProviderStore: (selector: (state: { providers: ProviderConfig[]; fetchProviders: () => Promise<void> }) => unknown) =>
|
||||
selector({
|
||||
providers,
|
||||
fetchProviders: mocks.fetchProviders,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../ModelSelect', () => ({
|
||||
parseModelValue: (value: string) => {
|
||||
const [providerId, modelId] = value.split('::');
|
||||
return providerId && modelId ? { providerId, modelId } : null;
|
||||
},
|
||||
useProviderNameMap: () => new Map(providers.map((provider) => [provider.id, provider.name])),
|
||||
}));
|
||||
|
||||
describe('EmbeddingModelSelect', () => {
|
||||
beforeEach(() => {
|
||||
providers = [];
|
||||
mocks.fetchProviders.mockReset();
|
||||
mocks.fetchProviders.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('shows enabled models explicitly marked as embedding even when the model id does not contain embed', () => {
|
||||
providers = [
|
||||
makeProvider({
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'BAAI/bge-m3',
|
||||
name: 'BGE M3',
|
||||
group_name: null,
|
||||
model_type: 'Embedding',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'gpt-5.4',
|
||||
name: 'GPT 5.4',
|
||||
group_name: null,
|
||||
model_type: 'Chat',
|
||||
capabilities: ['TextChat'],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
render(<EmbeddingModelSelect onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('BGE M3')).toBeInTheDocument();
|
||||
expect(screen.queryByText('GPT 5.4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads providers when mounted with an empty provider store', async () => {
|
||||
render(<EmbeddingModelSelect onChange={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.fetchProviders).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ProviderConfig } from '@/types';
|
||||
import { ModelSelect } from '../ModelSelect';
|
||||
|
||||
let providers: ProviderConfig[] = [];
|
||||
|
||||
function makeProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||||
return {
|
||||
id: 'provider-1',
|
||||
name: 'OpenAI Compatible',
|
||||
provider_type: 'openai',
|
||||
api_host: 'https://api.example.com',
|
||||
api_path: '/v1/chat/completions',
|
||||
enabled: true,
|
||||
models: [],
|
||||
keys: [],
|
||||
proxy_config: null,
|
||||
custom_headers: null,
|
||||
icon: null,
|
||||
builtin_id: null,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => <span data-testid="model-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/providerIcons', () => ({
|
||||
SmartProviderIcon: () => <span data-testid="provider-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Select: ({ options }: { options?: Array<{ title: string; options: Array<{ label: string; value: string }> }> }) => (
|
||||
<div>
|
||||
{options?.map((group) => (
|
||||
<section key={group.title} aria-label={group.title}>
|
||||
{group.options.map((option) => (
|
||||
<div key={option.value}>{option.label}</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
theme: {
|
||||
useToken: () => ({ token: { colorTextSecondary: '#666' } }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useProviderStore: (selector: (state: { providers: ProviderConfig[] }) => unknown) =>
|
||||
selector({ providers }),
|
||||
}));
|
||||
|
||||
describe('ModelSelect', () => {
|
||||
beforeEach(() => {
|
||||
providers = [
|
||||
makeProvider({
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'gpt-5.4',
|
||||
name: 'GPT 5.4',
|
||||
group_name: null,
|
||||
model_type: 'Chat',
|
||||
capabilities: ['TextChat'],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'gpt-image-2',
|
||||
name: 'GPT Image 2',
|
||||
group_name: null,
|
||||
model_type: 'Image',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
it('keeps the default model list unfiltered', () => {
|
||||
render(<ModelSelect onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('GPT 5.4')).toBeInTheDocument();
|
||||
expect(screen.getByText('GPT Image 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters options by model type when requested', () => {
|
||||
render(<ModelSelect onChange={vi.fn()} modelType="Chat" />);
|
||||
|
||||
expect(screen.getByText('GPT 5.4')).toBeInTheDocument();
|
||||
expect(screen.queryByText('GPT Image 2')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ProviderConfig } from '@/types';
|
||||
import { RerankModelSelect } from '../RerankModelSelect';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchProviders: vi.fn(),
|
||||
}));
|
||||
|
||||
let providers: ProviderConfig[] = [];
|
||||
|
||||
function makeProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||||
return {
|
||||
id: 'provider-1',
|
||||
name: 'Jina',
|
||||
provider_type: 'jina',
|
||||
api_host: 'https://api.jina.ai',
|
||||
api_path: null,
|
||||
enabled: true,
|
||||
models: [],
|
||||
keys: [],
|
||||
proxy_config: null,
|
||||
custom_headers: null,
|
||||
icon: null,
|
||||
builtin_id: null,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => <span data-testid="model-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Select: ({ options }: { options?: Array<{ title: string; options: Array<{ label: string; value: string }> }> }) => (
|
||||
<div>
|
||||
{options?.map((group) => (
|
||||
<section key={group.title} aria-label={group.title}>
|
||||
{group.options.map((option) => (
|
||||
<div key={option.value}>{option.label}</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
theme: {
|
||||
useToken: () => ({ token: { colorTextSecondary: '#666' } }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useProviderStore: (selector: (state: { providers: ProviderConfig[]; fetchProviders: () => Promise<void> }) => unknown) =>
|
||||
selector({
|
||||
providers,
|
||||
fetchProviders: mocks.fetchProviders,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../ModelSelect', () => ({
|
||||
parseModelValue: (value: string) => {
|
||||
const [providerId, modelId] = value.split('::');
|
||||
return providerId && modelId ? { providerId, modelId } : null;
|
||||
},
|
||||
useProviderNameMap: () => new Map(providers.map((provider) => [provider.id, provider.name])),
|
||||
}));
|
||||
|
||||
describe('RerankModelSelect', () => {
|
||||
beforeEach(() => {
|
||||
providers = [];
|
||||
mocks.fetchProviders.mockReset();
|
||||
mocks.fetchProviders.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('shows enabled rerank models and hides chat models', () => {
|
||||
providers = [
|
||||
makeProvider({
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'jina-reranker-v3',
|
||||
name: 'Jina Reranker v3',
|
||||
group_name: null,
|
||||
model_type: 'Rerank',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'jina-embeddings-v4',
|
||||
name: 'Jina Embeddings v4',
|
||||
group_name: null,
|
||||
model_type: 'Embedding',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
render(<RerankModelSelect onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Jina Reranker v3')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Jina Embeddings v4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads providers when mounted with an empty provider store', async () => {
|
||||
render(<RerankModelSelect onChange={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.fetchProviders).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user