import { BaseHttpClient } from './BaseHttpClient'; import { ApiRespMarketplacePluginDetail, ApiRespMarketplacePlugins, } from '@/app/infra/entities/api'; import { PluginV4 } from '@/app/infra/entities/plugin'; import { I18nObject } from '@/app/infra/entities/common'; /** * 云服务客户端 * 负责与 cloud service 的所有交互 */ export class CloudServiceClient extends BaseHttpClient { constructor(baseURL: string = '') { // cloud service 不需要 token 认证 super(baseURL, true); } public getMarketplacePlugins( page: number, page_size: number, sort_by?: string, sort_order?: string, ): Promise { return this.get('/api/v1/marketplace/plugins', { page, page_size, sort_by, sort_order, }); } public searchMarketplacePlugins( query: string, page: number, page_size: number, sort_by?: string, sort_order?: string, component_filter?: string, tags_filter?: string[], type_filter?: string, ): Promise { // Use different endpoints based on type_filter if (type_filter === 'mcp') { return this.post<{ mcps: PluginV4[]; total: number }>( '/api/v1/marketplace/mcps/search', { query, page, page_size, sort_by, sort_order, tags_filter, }, ).then((resp) => ({ plugins: (resp?.mcps || []).map((mcp) => ({ ...mcp, plugin_id: mcp.mcp_id || mcp.plugin_id, type: 'mcp' as const, })), total: resp?.total || 0, })); } else if (type_filter === 'skill') { return this.post<{ skills: PluginV4[]; total: number }>( '/api/v1/marketplace/skills/search', { query, page, page_size, sort_by, sort_order, tags_filter, }, ).then((resp) => ({ plugins: (resp?.skills || []).map((skill) => ({ ...skill, plugin_id: skill.skill_id || skill.plugin_id, type: 'skill' as const, })), total: resp?.total || 0, })); } return this.post( '/api/v1/marketplace/plugins/search', { query, page, page_size, sort_by, sort_order, component_filter, tags_filter, type_filter, }, ); } public searchMarketplaceExtensions(data: { query?: string; page: number; page_size: number; sort_by?: string; sort_order?: string; type_filter?: string; component_filter?: string; tags_filter?: string[]; }): Promise { return this.post<{ extensions: PluginV4[]; total: number }>( '/api/v1/marketplace/extensions/search', data, ) .then((resp) => ({ plugins: resp?.extensions || [], total: resp?.total || 0, })) .catch(() => this.searchMarketplaceExtensionsLegacy(data)); } private async searchMarketplaceExtensionsLegacy(data: { query?: string; page: number; page_size: number; sort_by?: string; sort_order?: string; type_filter?: string; component_filter?: string; tags_filter?: string[]; }): Promise { const query = data.query || ''; if ( data.type_filter === 'plugin' || data.type_filter === 'mcp' || data.type_filter === 'skill' || data.component_filter ) { return this.searchMarketplacePlugins( query, data.page, data.page_size, data.sort_by, data.sort_order, data.component_filter, data.tags_filter, data.component_filter ? 'plugin' : data.type_filter, ).catch((error) => { if (data.type_filter === 'mcp' || data.type_filter === 'skill') { return { plugins: [], total: 0 }; } throw error; }); } const [pluginsResp, mcpsResp, skillsResp] = await Promise.all([ this.searchMarketplacePlugins( query, data.page, data.page_size, data.sort_by, data.sort_order, undefined, data.tags_filter, 'plugin', ).catch(() => ({ plugins: [], total: 0 })), this.searchMarketplacePlugins( query, data.page, data.page_size, data.sort_by, data.sort_order, undefined, data.tags_filter, 'mcp', ).catch(() => ({ plugins: [], total: 0 })), this.searchMarketplacePlugins( query, data.page, data.page_size, data.sort_by, data.sort_order, undefined, data.tags_filter, 'skill', ).catch(() => ({ plugins: [], total: 0 })), ]); return { plugins: [ ...(pluginsResp.plugins || []), ...(mcpsResp.plugins || []), ...(skillsResp.plugins || []), ], total: (pluginsResp.total || 0) + (mcpsResp.total || 0) + (skillsResp.total || 0), }; } public getPluginDetail( author: string, pluginName: string, ): Promise { return this.get( `/api/v1/marketplace/plugins/${author}/${pluginName}`, ); } public getMCPDetail( author: string, name: string, ): Promise<{ mcp: PluginV4 }> { return this.get<{ mcp: PluginV4 }>( `/api/v1/marketplace/mcps/${author}/${name}`, ); } public getSkillDetail( author: string, name: string, ): Promise<{ skill: PluginV4 }> { return this.get<{ skill: PluginV4 }>( `/api/v1/marketplace/skills/${author}/${name}`, ); } /** * Resolve the marketplace ``icon`` field for an extension by author/name/type. * Used when the icon is not already known locally (e.g. an install confirm * dialog opened from a URL query param carries no icon). Returns the raw * ``icon`` value from the marketplace record (often an absolute external URL), * or an empty string if it cannot be fetched. */ public async fetchMarketplaceIcon( type: 'plugin' | 'mcp' | 'skill' | undefined, author: string, name: string, ): Promise { try { if (type === 'mcp') { const resp = await this.getMCPDetail(author, name); return resp?.mcp?.icon || ''; } if (type === 'skill') { const resp = await this.getSkillDetail(author, name); return resp?.skill?.icon || ''; } const resp = await this.getPluginDetail(author, name); return resp?.plugin?.icon || ''; } catch { return ''; } } public getPluginREADME( author: string, pluginName: string, language?: string, ): Promise<{ readme: string }> { return this.get<{ readme: string }>( `/api/v1/marketplace/plugins/${author}/${pluginName}/resources/README`, language ? { language } : undefined, ); } public getPluginIconURL(author: string, name: string): string { return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${name}/resources/icon`; } public getMCPMarketplaceIconURL(author: string, name: string): string { return `${this.baseURL}/api/v1/marketplace/mcps/${author}/${name}/resources/icon`; } public getSkillMarketplaceIconURL(author: string, name: string): string { return `${this.baseURL}/api/v1/marketplace/skills/${author}/${name}/resources/icon`; } /** * Resolve the best icon URL for a marketplace extension. * * Many MCP / skill records store their ``icon`` as an absolute external URL * (e.g. simpleicons.org / iconify.design logos) rather than a file uploaded * to Space storage. For those, the ``/resources/icon`` endpoint 404s, so we * must use the external URL directly. Records whose ``icon`` is empty or a * relative path fall back to the ``/resources/icon`` endpoint (real uploads). */ public resolveMarketplaceIconURL( type: 'plugin' | 'mcp' | 'skill' | undefined, author: string, name: string, icon?: string, ): string { if (icon && /^https?:\/\//i.test(icon)) { return icon; } if (type === 'mcp') return this.getMCPMarketplaceIconURL(author, name); if (type === 'skill') return this.getSkillMarketplaceIconURL(author, name); return this.getPluginIconURL(author, name); } public getPluginAssetURL( author: string, pluginName: string, filepath: string, ): string { return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${pluginName}/resources/assets/${filepath}`; } public getPluginMarketplaceURL( cloud_service_url: string, author: string, name: string, ): string { return `${cloud_service_url}/market/${author}/${name}`; } public getLangBotReleases(): Promise { return this.get('/api/v1/dist/info/releases'); } public getGitHubRepoInfo(): Promise { return this.get('/api/v1/dist/info/repo'); } public getAllTags(): Promise<{ tags: PluginTag[] }> { return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags'); } public getRecommendationLists(): Promise<{ lists: RecommendationList[] }> { return this.get<{ lists: RecommendationList[] }>( '/api/v1/marketplace/recommendation-lists', ); } } export interface RecommendationList { uuid: string; label: I18nObject; sort_order: number; plugins: PluginV4[]; } export interface PluginTag { tag: string; display_name: { zh_Hans?: string; en_US?: string; zh_Hant?: string; ja_JP?: string; }; } export interface GitHubRelease { tag_name: string; name: string; body: string; html_url: string; published_at: string; prerelease: boolean; draft: boolean; } export interface GitHubRepoInfo { repo: { stargazers_count: number; forks_count: number; open_issues_count: number; [key: string]: unknown; }; contributors: unknown[]; }