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:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -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);
});
});
});