mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-13 09:16:04 +00:00
When the install confirm dialog is opened via URL query params (e.g. from a marketplace deep link), installInfo carried no icon, so the icon fell back to the /resources/icon endpoint which 404s for extensions whose icon is an external URL (simpleicons / iconify), showing a Package placeholder. Fetch the icon from the marketplace detail API (mcp/skill/plugin) after opening the dialog and inject it into installInfo, and reset the icon-failed state when the resolved URL changes so the <img> retries instead of sticking on the placeholder.
373 lines
9.8 KiB
TypeScript
373 lines
9.8 KiB
TypeScript
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<ApiRespMarketplacePlugins> {
|
|
return this.get<ApiRespMarketplacePlugins>('/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<ApiRespMarketplacePlugins> {
|
|
// 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<ApiRespMarketplacePlugins>(
|
|
'/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<ApiRespMarketplacePlugins> {
|
|
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<ApiRespMarketplacePlugins> {
|
|
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<ApiRespMarketplacePluginDetail> {
|
|
return this.get<ApiRespMarketplacePluginDetail>(
|
|
`/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<string> {
|
|
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<GitHubRelease[]> {
|
|
return this.get<GitHubRelease[]>('/api/v1/dist/info/releases');
|
|
}
|
|
|
|
public getGitHubRepoInfo(): Promise<GitHubRepoInfo> {
|
|
return this.get<GitHubRepoInfo>('/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[];
|
|
}
|