UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

1,216 lines (1,117 loc) 38.5 kB
import { CURRENT_VERSION, DEFAULT_DISCOVER_ASSISTANT_ITEM, DEFAULT_DISCOVER_PLUGIN_ITEM, DEFAULT_DISCOVER_PROVIDER_ITEM, isDesktop, } from '@lobechat/const'; import { AssistantListResponse, AssistantQueryParams, AssistantSorts, CacheRevalidate, CacheTag, DiscoverAssistantDetail, DiscoverAssistantItem, DiscoverMcpDetail, DiscoverModelDetail, DiscoverModelItem, DiscoverPluginDetail, DiscoverPluginItem, DiscoverProviderDetail, DiscoverProviderItem, IdentifiersResponse, McpListResponse, McpQueryParams, ModelListResponse, ModelQueryParams, ModelSorts, PluginListResponse, PluginQueryParams, PluginSorts, ProviderListResponse, ProviderQueryParams, ProviderSorts, } from '@lobechat/types'; import { getAudioInputUnitRate, getTextInputUnitRate, getTextOutputUnitRate, } from '@lobechat/utils'; import { CategoryItem, CategoryListQuery, MarketSDK } from '@lobehub/market-sdk'; import { CallReportRequest, InstallReportRequest } from '@lobehub/market-types'; import dayjs from 'dayjs'; import debug from 'debug'; import matter from 'gray-matter'; import { cloneDeep, countBy, isString, merge, uniq, uniqBy } from 'lodash-es'; import urlJoin from 'url-join'; import { normalizeLocale } from '@/locales/resources'; import { AssistantStore } from '@/server/modules/AssistantStore'; import { PluginStore } from '@/server/modules/PluginStore'; const log = debug('lobe-server:discover'); export class DiscoverService { assistantStore = new AssistantStore(); pluginStore = new PluginStore(); market: MarketSDK; constructor({ accessToken }: { accessToken?: string } = {}) { this.market = new MarketSDK({ accessToken, baseURL: process.env.MARKET_BASE_URL, }); log('DiscoverService initialized with market baseURL: %s', process.env.MARKET_BASE_URL); } async registerClient({ userAgent }: { userAgent?: string }) { const getDeviceId = async (): Promise<string> => { // 1. Vercel 环境下使用 VERCEL_PROJECT_ID if (process.env.VERCEL_PROJECT_ID) { return process.env.VERCEL_PROJECT_ID; } // 2. 桌面端使用 machine-id if (isDesktop) { try { // 动态导入 const { machineId } = await import('node-machine-id'); return await machineId(); } catch (error) { console.error('Failed to get machine-id:', error); } } return 'unknown-device'; }; const deviceId = await getDeviceId(); const { client_id, client_secret } = await this.market.registerClient({ clientName: `LobeHub ${isDesktop ? 'Desktop' : 'Web'}`, clientType: isDesktop ? 'desktop' : 'web', deviceId, platform: isDesktop ? process.platform : userAgent, version: CURRENT_VERSION, }); return { clientId: client_id, clientSecret: client_secret }; } async fetchM2MToken(params: { clientId: string; clientSecret: string }) { // 使用传入的客户端凭证创建新的 MarketSDK 实例 const tokenMarket = new MarketSDK({ baseURL: process.env.MARKET_BASE_URL, clientId: params.clientId, clientSecret: params.clientSecret, }); const tokenInfo = await tokenMarket.fetchM2MToken(); return { accessToken: tokenInfo.accessToken, expiresIn: tokenInfo.expiresIn, }; } // ============================== Helper Methods ============================== /** * 计算 ModelAbilities 的完整度分数 * 分数越高表示 abilities 越全 */ private calculateAbilitiesScore = (abilities?: any): number => { if (!abilities) return 0; let score = 0; const abilityWeights = { files: 1, functionCall: 1, imageOutput: 1, reasoning: 1, search: 1, vision: 1, }; Object.entries(abilityWeights).forEach(([ability, weight]) => { if (abilities[ability]) { score += weight; } }); log('calculateAbilitiesScore: abilities=%O, score=%d', abilities, score); return score; }; /** * 在模型数组中选择 abilities 最全的模型 * 组合最全的 abilities 和最大的 contextWindowTokens */ private selectModelWithBestAbilities = (models: DiscoverModelItem[]): DiscoverModelItem => { log('selectModelWithBestAbilities: input models count=%d', models.length); if (models.length === 1) return models[0]; // 找到最全的 abilities let bestAbilities: Record<string, boolean> = {}; let maxAbilitiesScore = 0; models.forEach((model) => { const score = this.calculateAbilitiesScore(model.abilities); if (score > maxAbilitiesScore) { maxAbilitiesScore = score; bestAbilities = { ...(model.abilities as Record<string, boolean>) }; } else if (score === maxAbilitiesScore && model.abilities) { // 合并相同分数的 abilities,确保获得最全的组合 const abilities = model.abilities as Record<string, boolean>; Object.keys(abilities).forEach((key) => { if (abilities[key]) { bestAbilities[key] = true; } }); } }); // 找到最大的 contextWindowTokens const maxContextWindowTokens = Math.max( ...models.map((model) => model.contextWindowTokens || 0), ); // 找到最新的 releasedAt const latestReleasedAt = models .map((model) => model.releasedAt) .filter(Boolean) .sort((a, b) => new Date(b!).getTime() - new Date(a!).getTime())[0]; // 找到最短的 identifier const shortestIdentifier = models .map((model) => model.identifier) .reduce((shortest, current) => (current.length < shortest.length ? current : shortest)); // 选择一个基础模型(通常选择第一个) const baseModel = models[0]; // 组装最终模型,使用最佳的各项属性 const result: DiscoverModelItem = { ...baseModel, abilities: bestAbilities as any, contextWindowTokens: maxContextWindowTokens || baseModel.contextWindowTokens, identifier: shortestIdentifier, releasedAt: latestReleasedAt || baseModel.releasedAt, }; log('selectModelWithBestAbilities: selected model=%O', { abilities: result.abilities, contextWindowTokens: result.contextWindowTokens, identifier: result.identifier, releasedAt: result.releasedAt, }); return result; }; // ============================== Assistant Market ============================== private _getAssistantList = async (locale?: string): Promise<DiscoverAssistantItem[]> => { log('_getAssistantList: locale=%s', locale); const normalizedLocale = normalizeLocale(locale); const list = await this.assistantStore.getAgentIndex(normalizedLocale); if (!list || !Array.isArray(list)) { log('_getAssistantList: no valid list found, returning empty array'); return []; } const result = list.map(({ meta, ...item }) => ({ ...item, ...meta })); log('_getAssistantList: returning %d items', result.length); return result; }; getAssistantCategories = async (params: CategoryListQuery = {}): Promise<CategoryItem[]> => { log('getAssistantCategories: params=%O', params); const { q, locale } = params; let list = await this._getAssistantList(locale); if (q) { const originalCount = list.length; list = list.filter((item) => { return [item.author, item.title, item.description, item?.tags] .flat() .filter(Boolean) .join(',') .toLowerCase() .includes(decodeURIComponent(q).toLowerCase()); }); log( 'getAssistantCategories: filtered by query "%s", %d -> %d items', q, originalCount, list.length, ); } const categoryCounts = countBy(list, (item) => item.category); const result = Object.entries(categoryCounts) .filter(([category]) => Boolean(category)) // 过滤掉空值 .map(([category, count]) => ({ category, count, })); log('getAssistantCategories: returning %d categories', result.length); return result; }; getAssistantDetail = async (params: { identifier: string; locale?: string; }): Promise<DiscoverAssistantDetail | undefined> => { log('getAssistantDetail: params=%O', params); const { locale, identifier } = params; const normalizedLocale = normalizeLocale(locale); let data = await this.assistantStore.getAgent(identifier, normalizedLocale); if (!data) { log('getAssistantDetail: assistant not found for identifier=%s', identifier); return; } const { meta, ...item } = data; const assistant = merge(cloneDeep(DEFAULT_DISCOVER_ASSISTANT_ITEM), { ...item, ...meta }); const list = await this.getAssistantList({ category: assistant.category, locale, page: 1, pageSize: 7, }); const result = { ...assistant, related: list.items.filter((item) => item.identifier !== assistant.identifier).slice(0, 6), }; log('getAssistantDetail: returning assistant with %d related items', result.related.length); return result; }; getAssistantIdentifiers = async (): Promise<IdentifiersResponse> => { log('getAssistantIdentifiers: fetching identifiers'); const list = await this._getAssistantList(); const result = list.map((item) => { return { identifier: item.identifier, lastModified: item.createdAt, }; }); log('getAssistantIdentifiers: returning %d identifiers', result.length); return result; }; getAssistantList = async (params: AssistantQueryParams = {}): Promise<AssistantListResponse> => { log('getAssistantList: params=%O', params); const { locale, category, order = 'desc', page = 1, pageSize = 20, q, sort = AssistantSorts.CreatedAt, } = params; let list = await this._getAssistantList(locale); const originalCount = list.length; if (category) { list = list.filter((item) => item.category === category); log( 'getAssistantList: filtered by category "%s", %d -> %d items', category, originalCount, list.length, ); } if (q) { const beforeFilter = list.length; list = list.filter((item) => { return [item.author, item.title, item.description, item?.tags] .flat() .filter(Boolean) .join(',') .toLowerCase() .includes(decodeURIComponent(q).toLowerCase()); }); log('getAssistantList: filtered by query "%s", %d -> %d items', q, beforeFilter, list.length); } if (sort) { log('getAssistantList: sorting by %s %s', sort, order); switch (sort) { case AssistantSorts.CreatedAt: { list = list.sort((a, b) => { if (order === 'asc') { return dayjs(a.createdAt).unix() - dayjs(b.createdAt).unix(); } else { return dayjs(b.createdAt).unix() - dayjs(a.createdAt).unix(); } }); break; } case AssistantSorts.KnowledgeCount: { list = list.sort((a, b) => { if (order === 'asc') { return a.knowledgeCount - b.knowledgeCount; } else { return b.knowledgeCount - a.knowledgeCount; } }); break; } case AssistantSorts.PluginCount: { list = list.sort((a, b) => { if (order === 'asc') { return a.pluginCount - b.pluginCount; } else { return b.pluginCount - a.pluginCount; } }); break; } case AssistantSorts.TokenUsage: { list = list.sort((a, b) => { if (order === 'asc') { return a.tokenUsage - b.tokenUsage; } else { return b.tokenUsage - a.tokenUsage; } }); break; } case AssistantSorts.Identifier: { list = list.sort((a, b) => { if (order !== 'desc') { return a.identifier.localeCompare(b.identifier); } else { return b.identifier.localeCompare(a.identifier); } }); break; } case AssistantSorts.Title: { list = list.sort((a, b) => { if (order === 'desc') { return (a.title || a.identifier).localeCompare(b.title || b.identifier); } else { return (b.title || b.identifier).localeCompare(a.title || a.identifier); } }); break; } } } const result = { currentPage: page, items: list.slice((page - 1) * pageSize, page * pageSize), pageSize, totalCount: list.length, totalPages: Math.ceil(list.length / pageSize), }; log( 'getAssistantList: returning page %d/%d with %d items', page, result.totalPages, result.items.length, ); return result; }; // ============================== MCP Market ============================== getMcpCategories = async (params: CategoryListQuery = {}): Promise<CategoryItem[]> => { log('getMcpCategories: params=%O', params); const { locale } = params; const normalizedLocale = normalizeLocale(locale); const result = await this.market.plugins.getCategories( { ...params, locale: normalizedLocale, }, { next: { revalidate: 3600, }, }, ); log('getMcpCategories: returning %d categories', result.length); return result; }; getMcpDetail = async (params: { identifier: string; locale?: string; version?: string; }): Promise<DiscoverMcpDetail> => { log('getMcpDetail: params=%O', params); const { locale } = params; const normalizedLocale = normalizeLocale(locale); const mcp = await this.market.plugins.getPluginDetail( { ...params, locale: normalizedLocale }, { next: { revalidate: 3600, }, }, ); const list = await this.getMcpList({ category: mcp.category, locale, page: 1, pageSize: 7, }); const result = { ...mcp, related: list.items.filter((item) => item.identifier !== mcp.identifier).slice(0, 6), }; log('getMcpDetail: returning mcp with %d related items', result.related.length); return result; }; getMcpList = async (params: McpQueryParams = {}): Promise<McpListResponse> => { log('getMcpList: params=%O', params); const { locale } = params; const normalizedLocale = normalizeLocale(locale); const result = await this.market.plugins.getPluginList( { ...params, locale: normalizedLocale, }, { next: { revalidate: CacheRevalidate.List, tags: [CacheTag.Discover, CacheTag.MCP], }, }, ); log('getMcpList: returning %d items on page %d', result.items.length, result.currentPage); return result; }; getMcpManifest = async (params: { identifier: string; locale?: string; version?: string }) => { log('getMcpManifest: params=%O', params); const { locale } = params; const normalizedLocale = normalizeLocale(locale); const result = await this.market.plugins.getPluginManifest( { ...params, locale: normalizedLocale, }, { next: { revalidate: CacheRevalidate.List, tags: [CacheTag.Discover, CacheTag.MCP], }, }, ); log('getMcpManifest: returning manifest for %s', params.identifier); return result; }; // ============================== MCP Analytics ============================== /** * report MCP plugin result marketplace */ reportPluginInstallation = async (params: InstallReportRequest) => { await this.market.plugins.reportInstallation(params); }; /** * report plugin call result to marketplace */ reportCall = async (params: CallReportRequest) => { await this.market.plugins.reportCall(params); }; // ============================== Plugin Market ============================== private _getPluginList = async (locale?: string): Promise<DiscoverPluginItem[]> => { log('_getPluginList: locale=%s', locale); const normalizedLocale = normalizeLocale(locale); const list = await this.pluginStore.getPluginList(normalizedLocale); if (!list || !Array.isArray(list)) { log('_getPluginList: no valid list found, returning empty array'); return []; } const result = list.map(({ meta, ...item }) => ({ ...item, ...meta })); log('_getPluginList: returning %d items', result.length); return result; }; getLegacyPluginList = async ({ locale }: { locale?: string } = {}): Promise<any> => { log('getLegacyPluginList: locale=%s', locale); const normalizedLocale = normalizeLocale(locale); const result = await this.pluginStore.getPluginList(normalizedLocale); log('getLegacyPluginList: returning plugin list'); return result; }; getPluginCategories = async (params: CategoryListQuery = {}): Promise<CategoryItem[]> => { log('getPluginCategories: params=%O', params); const { q, locale } = params; let list = await this._getPluginList(locale); if (q) { const originalCount = list.length; list = list.filter((item) => { return [item.author, item.title, item.description, item?.tags] .flat() .filter(Boolean) .join(',') .toLowerCase() .includes(decodeURIComponent(q).toLowerCase()); }); log( 'getPluginCategories: filtered by query "%s", %d -> %d items', q, originalCount, list.length, ); } const categoryCounts = countBy(list, (item) => item.category); const result = Object.entries(categoryCounts) .filter(([category]) => Boolean(category)) // 过滤掉空值 .map(([category, count]) => ({ category, count, })); log('getPluginCategories: returning %d categories', result.length); return result; }; getPluginDetail = async (params: { identifier: string; locale?: string; withManifest?: boolean; }): Promise<DiscoverPluginDetail | undefined> => { log('getPluginDetail: params=%O', params); const { locale, identifier, withManifest } = params; const all = await this._getPluginList(locale); let raw = all.find((item) => item.identifier === identifier); if (!raw) { log('getPluginDetail: plugin not found for identifier=%s', identifier); return; } raw = merge(cloneDeep(DEFAULT_DISCOVER_PLUGIN_ITEM), raw); const list = await this.getPluginList({ category: raw.category, locale, page: 1, pageSize: 7, }); let plugin = { ...raw, related: list.items.filter((item) => item.identifier !== raw.identifier).slice(0, 6), }; if (!withManifest || !plugin?.manifest || !isString(plugin?.manifest)) { log('getPluginDetail: returning plugin without manifest processing'); return plugin; } // 在 Edge Runtime 环境中使用了 Node.js 的 path 模块,但 Edge Runtime 不支持所有 Node.js API // 这个函数使用了 @lobehub/chat-plugin-sdk/openapi,该包最终依赖了 @apidevtools/swagger-parser,而这个包在 Edge Runtime 环境中使用了不被支持的 Node.js path 模块。 // try { // const manifest = await getToolManifest(plugin.manifest); // // return { // ...plugin, // manifest, // }; // } catch { // return plugin; // } return plugin; }; getPluginIdentifiers = async (): Promise<IdentifiersResponse> => { log('getPluginIdentifiers: fetching identifiers'); const list = await this._getPluginList(); const result = list.map((item) => { return { identifier: item.identifier, lastModified: item.createdAt, }; }); log('getPluginIdentifiers: returning %d identifiers', result.length); return result; }; getPluginList = async (params: PluginQueryParams = {}): Promise<PluginListResponse> => { log('getPluginList: params=%O', params); const { locale, category, order = 'desc', page = 1, pageSize = 20, q, sort = PluginSorts.CreatedAt, } = params; let list = await this._getPluginList(locale); const originalCount = list.length; if (category) { list = list.filter((item) => item.category === category); log( 'getPluginList: filtered by category "%s", %d -> %d items', category, originalCount, list.length, ); } if (q) { const beforeFilter = list.length; list = list.filter((item) => { return [item.author, item.title, item.description, item?.tags] .flat() .filter(Boolean) .join(',') .toLowerCase() .includes(decodeURIComponent(q).toLowerCase()); }); log('getPluginList: filtered by query "%s", %d -> %d items', q, beforeFilter, list.length); } if (sort) { log('getPluginList: sorting by %s %s', sort, order); switch (sort) { case PluginSorts.CreatedAt: { list = list.sort((a, b) => { if (order === 'asc') { return dayjs(a.createdAt).unix() - dayjs(b.createdAt).unix(); } else { return dayjs(b.createdAt).unix() - dayjs(a.createdAt).unix(); } }); break; } case PluginSorts.Identifier: { list = list.sort((a, b) => { if (order === 'desc') { return a.identifier.localeCompare(b.identifier); } else { return b.identifier.localeCompare(a.identifier); } }); break; } case PluginSorts.Title: { list = list.sort((a, b) => { if (order === 'desc') { return a.title.localeCompare(b.title); } else { return b.title.localeCompare(a.title); } }); break; } } } const result = { currentPage: page, items: list.slice((page - 1) * pageSize, page * pageSize), pageSize, totalCount: list.length, totalPages: Math.ceil(list.length / pageSize), }; log( 'getPluginList: returning page %d/%d with %d items', page, result.totalPages, result.items.length, ); return result; }; // ============================== Providers ============================== private _getProviderList = async (): Promise<DiscoverProviderItem[]> => { log('_getProviderList: fetching provider list'); const [{ LOBE_DEFAULT_MODEL_LIST }, { DEFAULT_MODEL_PROVIDER_LIST }] = await Promise.all([ import('model-bank'), import('@/config/modelProviders'), ]); const result = DEFAULT_MODEL_PROVIDER_LIST.map((item) => { const models = uniq( LOBE_DEFAULT_MODEL_LIST.filter((m) => m.providerId === item.id).map((m) => m.id), ); const provider = { ...item, identifier: item.id, modelCount: models.length, models, }; return merge(cloneDeep(DEFAULT_DISCOVER_PROVIDER_ITEM), provider); }); log('_getProviderList: returning %d providers', result.length); return result; }; getProviderDetail = async (params: { identifier: string; locale?: string; withReadme?: boolean; }): Promise<DiscoverProviderDetail | undefined> => { log('getProviderDetail: params=%O', params); const { identifier, locale, withReadme } = params; const { LOBE_DEFAULT_MODEL_LIST } = await import('model-bank'); const all = await this._getProviderList(); let provider = all.find((item) => item.identifier === identifier); if (!provider) { log('getProviderDetail: provider not found for identifier=%s', identifier); return; } const list = await this.getProviderList({ page: 1, pageSize: 7, }); let readme; if (withReadme) { log('getProviderDetail: fetching readme for provider=%s', identifier); try { const normalizedLocale = normalizeLocale(locale); const readmeUrl = urlJoin( 'https://raw.githubusercontent.com/lobehub/lobe-chat/refs/heads/main/docs/usage/providers', normalizedLocale === 'zh-CN' ? `${identifier}.zh-CN.mdx` : `${identifier}.mdx`, ); log('getProviderDetail: readme URL=%s', readmeUrl); const res = await fetch(readmeUrl, { next: { tags: [CacheTag.Discover, CacheTag.Providers], }, }); const data = await res.text(); const { content } = matter(data); readme = content.trimEnd(); log('getProviderDetail: readme loaded successfully, length=%d', readme.length); } catch (error) { log( 'getProviderDetail: failed to load readme for provider=%s, error: %O', identifier, error, ); } } const result = { ...provider, models: uniqBy( LOBE_DEFAULT_MODEL_LIST.filter((m) => m.providerId === provider.id), (item) => item.id, ), readme, related: list.items.filter((item) => item.identifier !== provider.identifier).slice(0, 6), }; log( 'getProviderDetail: returning provider with %d models and %d related items', result.models.length, result.related.length, ); return result; }; getProviderIdentifiers = async (): Promise<IdentifiersResponse> => { log('getProviderIdentifiers: fetching identifiers'); const list = await this._getProviderList(); const result = list.map((item) => { return { identifier: item.identifier, lastModified: dayjs().toISOString(), }; }); log('getProviderIdentifiers: returning %d identifiers', result.length); return result; }; getProviderList = async (params: ProviderQueryParams = {}): Promise<ProviderListResponse> => { log('getProviderList: params=%O', params); const { page = 1, pageSize = 20, q, sort = ProviderSorts.Default, order = 'desc' } = params; let list = await this._getProviderList(); const originalCount = list.length; if (q) { list = list.filter((item) => { return [item.identifier, item.description, item.name] .filter(Boolean) .join(',') .toLowerCase() .includes(decodeURIComponent(q).toLowerCase()); }); log('getProviderList: filtered by query "%s", %d -> %d items', q, originalCount, list.length); } if (sort) { log('getProviderList: sorting by %s %s', sort, order); switch (sort) { case ProviderSorts.Identifier: { list = list.sort((a, b) => { if (order === 'desc') { return a.identifier.localeCompare(b.identifier); } else { return b.identifier.localeCompare(a.identifier); } }); break; } case ProviderSorts.ModelCount: { list = list.sort((a, b) => { if (order === 'asc') { return a.modelCount - b.modelCount; } else { return b.modelCount - a.modelCount; } }); break; } } } const result = { currentPage: page, items: list.slice((page - 1) * pageSize, page * pageSize), pageSize, totalCount: list.length, totalPages: Math.ceil(list.length / pageSize), }; log( 'getProviderList: returning page %d/%d with %d items', page, result.totalPages, result.items.length, ); return result; }; // ============================== Models ============================== private _getRawModelList = async (): Promise<DiscoverModelItem[]> => { log('_getRawModelList: fetching raw model list'); const { LOBE_DEFAULT_MODEL_LIST } = await import('model-bank'); const result = LOBE_DEFAULT_MODEL_LIST.map((item) => { const identifier = (item.id.split('/').at(-1) || item.id).toLowerCase(); const providers = uniq( LOBE_DEFAULT_MODEL_LIST.filter( (m) => m.id.toLowerCase() === identifier || m.id.includes(`/${identifier}`) || m.displayName?.toLowerCase() === item.displayName?.toLowerCase(), ).map((m) => m.providerId), ); const model = { ...item, category: item.providerId, identifier, providerCount: providers.length, providers, }; // 使用简单的合并而不是 DEFAULT_DISCOVER_MODEL_ITEM,避免类型冲突 return { ...model, abilities: model.abilities || {}, } as DiscoverModelItem; }); log('_getRawModelList: returning %d raw models', result.length); return result; }; private _getModelList = async (category?: string): Promise<DiscoverModelItem[]> => { log('_getModelList: category=%s', category); let list = await this._getRawModelList(); const originalCount = list.length; if (category) { list = list.filter((item) => item.providerId === category); log( '_getModelList: filtered by category "%s", %d -> %d items', category, originalCount, list.length, ); } // 优化去重逻辑:选择 abilities 最全的模型 // 1. 按 identifier 分组 const identifierGroups = new Map<string, DiscoverModelItem[]>(); list.forEach((item) => { const key = item.identifier; if (!identifierGroups.has(key)) { identifierGroups.set(key, []); } identifierGroups.get(key)!.push(item); }); log( '_getModelList: grouped %d items into %d identifier groups', list.length, identifierGroups.size, ); // 2. 从每个 identifier 组中选择 abilities 最全的 let deduplicatedByIdentifier = Array.from(identifierGroups.values()).map((models) => this.selectModelWithBestAbilities(models), ); // 3. 按 displayName 分组 const displayNameGroups = new Map<string, DiscoverModelItem[]>(); deduplicatedByIdentifier.forEach((item) => { const key = item.displayName?.toLowerCase() || ''; if (!displayNameGroups.has(key)) { displayNameGroups.set(key, []); } displayNameGroups.get(key)!.push(item); }); log( '_getModelList: grouped %d items into %d displayName groups', deduplicatedByIdentifier.length, displayNameGroups.size, ); // 4. 从每个 displayName 组中选择 abilities 最全的 const finalList: DiscoverModelItem[] = Array.from(displayNameGroups.values()).map((models) => this.selectModelWithBestAbilities(models), ); log('_getModelList: returning %d deduplicated models', finalList.length); return finalList; }; getModelCategories = async (params: CategoryListQuery = {}): Promise<CategoryItem[]> => { log('getModelCategories: params=%O', params); const { q } = params; const { LOBE_DEFAULT_MODEL_LIST } = await import('model-bank'); let list = LOBE_DEFAULT_MODEL_LIST; if (q) { const originalCount = list.length; list = list.filter((item) => { return [item.id, item.displayName, item.description] .flat() .filter(Boolean) .join(',') .toLowerCase() .includes(decodeURIComponent(q).toLowerCase()); }); log( 'getModelCategories: filtered by query "%s", %d -> %d items', q, originalCount, list.length, ); } const categoryCounts = countBy(list, (item) => item.providerId); const result = Object.entries(categoryCounts) .filter(([category]) => Boolean(category)) // 过滤掉空值 .map(([category, count]) => ({ category, count, })); log('getModelCategories: returning %d categories', result.length); return result; }; getModelDetail = async (params: { identifier: string; }): Promise<DiscoverModelDetail | undefined> => { log('getModelDetail: params=%O', params); const [{ LOBE_DEFAULT_MODEL_LIST }, { DEFAULT_MODEL_PROVIDER_LIST }] = await Promise.all([ import('model-bank'), import('@/config/modelProviders'), ]); const { identifier } = params; const all = await this._getModelList(); let model = all.find((item) => item.identifier.toLowerCase() === identifier.toLowerCase()); if (!model) { log('getModelDetail: model not found in deduplicated list, searching raw list'); const raw = await this._getRawModelList(); model = raw.find((item) => item.identifier.toLowerCase() === identifier.toLowerCase()); } if (!model) { log('getModelDetail: model not found for identifier=%s', identifier); return; } const providers = DEFAULT_MODEL_PROVIDER_LIST.filter((item) => model.providers?.includes(item.id), ); log('getModelDetail: found %d providers for model %s', providers.length, model.identifier); const list = await this.getModelList({ page: 1, pageSize: 7, q: model.identifier.split('-')[0], }); const result = { ...model, providers: providers.map((item) => ({ ...item, model: LOBE_DEFAULT_MODEL_LIST.find((m) => { if (m.providerId !== item.id) return false; return ( m.id.toLowerCase() === model.identifier.toLowerCase() || m.id.toLowerCase().includes(`/${model.identifier.toLowerCase()}`) || m.displayName?.toLowerCase() === model.displayName?.toLowerCase() ); }), })), related: list.items .filter( (item) => item.identifier !== model.identifier && item.displayName !== model?.displayName, ) .slice(0, 6), }; log( 'getModelDetail: returning model with %d providers and %d related items', result.providers.length, result.related.length, ); return result; }; getModelIdentifiers = async (): Promise<IdentifiersResponse> => { log('getModelIdentifiers: fetching identifiers'); const list = await this._getModelList(); const result = list.map((item) => { return { identifier: item.identifier, lastModified: item.releasedAt || dayjs().toISOString(), }; }); log('getModelIdentifiers: returning %d identifiers', result.length); return result; }; getModelList = async (params: ModelQueryParams = {}): Promise<ModelListResponse> => { log('getModelList: params=%O', params); const { category, order = 'desc', page = 1, pageSize = 20, q, sort = ModelSorts.ReleasedAt, } = params; let list = await this._getModelList(category); // if (category) { // list = list.filter((item) => item.category === category); // } if (q) { const beforeFilter = list.length; list = list.filter((item) => { return [item.identifier, item.displayName, item.description] .flat() .filter(Boolean) .join(',') .toLowerCase() .includes(decodeURIComponent(q).toLowerCase()); }); log('getModelList: filtered by query "%s", %d -> %d items', q, beforeFilter, list.length); } if (sort) { log('getModelList: sorting by %s %s', sort, order); switch (sort) { case ModelSorts.ReleasedAt: { list = list.sort((a, b) => { if (order === 'asc') { return dayjs(a.releasedAt).unix() - dayjs(b.releasedAt).unix(); } else { return dayjs(b.releasedAt).unix() - dayjs(a.releasedAt).unix(); } }); break; } case ModelSorts.Identifier: { list = list.sort((a, b) => { if (order === 'desc') { return a.identifier.localeCompare(b.identifier); } else { return b.identifier.localeCompare(a.identifier); } }); break; } case ModelSorts.InputPrice: { list = list.sort((a, b) => { if (order === 'asc') { return ( (getTextInputUnitRate(a.pricing) || getAudioInputUnitRate(a.pricing) || 0) - (getTextInputUnitRate(b.pricing) || getAudioInputUnitRate(b.pricing) || 0) ); } else { return ( (getTextInputUnitRate(b.pricing) || getAudioInputUnitRate(b.pricing) || 0) - (getTextInputUnitRate(a.pricing) || getAudioInputUnitRate(a.pricing) || 0) ); } }); break; } case ModelSorts.OutputPrice: { list = list.sort((a, b) => { if (order === 'asc') { return ( (getTextOutputUnitRate(a.pricing) || 0) - (getTextOutputUnitRate(b.pricing) || 0) ); } else { return ( (getTextOutputUnitRate(b.pricing) || 0) - (getTextOutputUnitRate(a.pricing) || 0) ); } }); break; } case ModelSorts.ContextWindowTokens: { list = list.sort((a, b) => { if (order === 'asc') { return (a.contextWindowTokens || 0) - (b.contextWindowTokens || 0); } else { return (b.contextWindowTokens || 0) - (a.contextWindowTokens || 0); } }); break; } case ModelSorts.ProviderCount: { list = list.sort((a, b) => { if (order === 'asc') { return a.providerCount - b.providerCount; } else { return b.providerCount - a.providerCount; } }); break; } } } const result = { currentPage: page, items: list.slice((page - 1) * pageSize, page * pageSize), pageSize, totalCount: list.length, totalPages: Math.ceil(list.length / pageSize), }; log( 'getModelList: returning page %d/%d with %d items', page, result.totalPages, result.items.length, ); return result; }; }