Files
LangBot/web/src/app/infra/http/CloudServiceClient.ts
RockChinQ 170a6756f4 fix(add-extension): load real icon in install confirm dialog from URL params
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.
2026-06-06 04:45:46 -04:00

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[];
}