feat: youhua frontend

This commit is contained in:
WangCham
2026-05-07 18:19:48 +08:00
parent 188511a911
commit e412ed5527
11 changed files with 698 additions and 149 deletions

View File

@@ -25,7 +25,7 @@
<a href="https://link.langbot.app/zh/docs/guide">文档</a>
<a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://space.langbot.app">插件市场</a>
<a href="https://space.langbot.app">拓展市场</a>
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
</div>

View File

@@ -48,6 +48,7 @@ function MarketplaceContent() {
} = usePluginInstallTasks();
const [modalOpen, setModalOpen] = useState(false);
const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
const [installExtensionType, setInstallExtensionType] = useState<'plugin' | 'mcp' | 'skill'>('plugin');
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
const [installError, setInstallError] = useState<string | null>(null);
@@ -96,6 +97,7 @@ function MarketplaceContent() {
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
});
setInstallExtensionType(plugin.type || 'plugin');
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setModalOpen(true);
@@ -119,6 +121,7 @@ function MarketplaceContent() {
taskId,
pluginName: pluginDisplayName,
source: 'marketplace',
extensionType: installExtensionType,
});
setSelectedTaskId(taskKey);
setModalOpen(false);

View File

@@ -28,6 +28,7 @@ export interface PluginInstallTask {
source: 'github' | 'marketplace' | 'local';
stage: InstallStage;
overallProgress: number; // 0-100
extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed
fileSize?: number; // bytes, if known
// Download progress
downloadCurrent?: number; // bytes downloaded so far
@@ -53,6 +54,7 @@ interface PluginInstallTaskContextValue {
taskId: number;
pluginName: string;
source: 'github' | 'marketplace' | 'local';
extensionType: 'plugin' | 'mcp' | 'skill';
fileSize?: number;
}) => void;
removeTask: (id: string) => void;
@@ -131,7 +133,7 @@ function extractSourceFromName(
* Check if a backend task name is a plugin install task.
*/
function isPluginInstallTask(name: string): boolean {
return name.startsWith('plugin-install-');
return name.startsWith('plugin-install-') || name.startsWith('mcp-install-') || name.startsWith('skill-install-');
}
/**
@@ -165,13 +167,21 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
overallProgress = Math.min(95, stageToProgress(stage));
}
const pluginName = str(md.plugin_name) || task.label || `${source} plugin`;
const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
let extensionType: 'plugin' | 'mcp' | 'skill' = 'plugin';
if (task.name.startsWith('mcp-install-')) {
extensionType = 'mcp';
} else if (task.name.startsWith('skill-install-')) {
extensionType = 'skill';
}
return {
id: `${source}-${task.id}`,
taskId: task.id,
pluginName,
source,
extensionType,
stage,
overallProgress,
downloadCurrent: num(md.download_current),
@@ -388,6 +398,7 @@ export function PluginInstallTaskProvider({
converted.startedAt = existing.startedAt;
converted.pluginName = existing.pluginName;
converted.fileSize = existing.fileSize;
converted.extensionType = existing.extensionType;
updatedTasks[idx] = converted;
}
}
@@ -433,6 +444,7 @@ export function PluginInstallTaskProvider({
taskId: number;
pluginName: string;
source: 'github' | 'marketplace' | 'local';
extensionType: 'plugin' | 'mcp' | 'skill';
fileSize?: number;
}) => {
const taskKey = `${params.source}-${params.taskId}`;
@@ -445,6 +457,7 @@ export function PluginInstallTaskProvider({
taskId: params.taskId,
pluginName: params.pluginName,
source: params.source,
extensionType: params.extensionType,
stage: InstallStage.DOWNLOADING,
overallProgress: 5,
fileSize: params.fileSize,

View File

@@ -11,6 +11,9 @@ import {
Loader2,
X,
ListTodo,
Wrench,
AudioWaveform,
Book,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -35,6 +38,12 @@ const STAGE_ICONS: Record<string, React.ElementType> = {
[InstallStage.ERROR]: XCircle,
};
const EXTENSION_TYPE_ICONS: Record<string, React.ElementType> = {
plugin: Wrench,
mcp: AudioWaveform,
skill: Book,
};
function TaskQueueItem({
task,
onClick,
@@ -49,6 +58,40 @@ function TaskQueueItem({
const isError = task.stage === InstallStage.ERROR;
const isRunning = !isDone && !isError;
const StageIcon = STAGE_ICONS[task.stage] || Download;
const TypeIcon = EXTENSION_TYPE_ICONS[task.extensionType] || Wrench;
const getTypeBadgeClass = () => {
switch (task.extensionType) {
case 'mcp':
return 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300';
case 'skill':
return 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300';
default:
return 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300';
}
};
const getTypeLabel = () => {
switch (task.extensionType) {
case 'mcp':
return 'MCP';
case 'skill':
return t('common.skill');
default:
return t('market.typePlugin');
}
};
const getInstallCompleteMessage = () => {
switch (task.extensionType) {
case 'mcp':
return t('plugins.installProgress.installCompleteMCP');
case 'skill':
return t('plugins.installProgress.installCompleteSkill');
default:
return t('plugins.installProgress.installCompletePlugin');
}
};
const stageLabel = (() => {
switch (task.stage) {
@@ -61,7 +104,7 @@ function TaskQueueItem({
case InstallStage.LAUNCHING:
return t('plugins.installProgress.launching');
case InstallStage.DONE:
return t('plugins.installProgress.completed');
return isDone ? getInstallCompleteMessage() : t('plugins.installProgress.completed');
case InstallStage.ERROR:
return t('plugins.installProgress.failed');
default:
@@ -93,7 +136,16 @@ function TaskQueueItem({
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{task.pluginName}</div>
<div className="flex items-center gap-2">
<div className="text-sm font-medium truncate">{task.pluginName}</div>
<Badge
variant="outline"
className={cn('text-[0.6rem] px-1 py-0 flex-shrink-0', getTypeBadgeClass())}
>
<TypeIcon className="w-3 h-3 mr-0.5" />
{getTypeLabel()}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{stageLabel}</span>
{isRunning && (

View File

@@ -0,0 +1,317 @@
import { ExtensionCardVO, ExtensionType } from './ExtensionCardVO';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
MCPSessionStatus,
} from '@/app/infra/entities/api';
type ExtensionCardComponentProps = {
cardVO: ExtensionCardVO;
onCardClick: () => void;
onDeleteClick: (cardVO: ExtensionCardVO) => void;
onUpgradeClick?: (cardVO: ExtensionCardVO) => void;
};
export default function ExtensionCardComponent({
cardVO,
onCardClick,
onDeleteClick,
onUpgradeClick,
}: ExtensionCardComponentProps) {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const getTypeBadgeColor = (type: ExtensionType) => {
switch (type) {
case 'mcp':
return 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300';
case 'skill':
return 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300';
default:
return 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300';
}
};
const getTypeLabel = (type: ExtensionType) => {
switch (type) {
case 'mcp':
return 'MCP';
case 'skill':
return t('common.skill');
default:
return t('market.typePlugin');
}
};
const getStatusColor = (status?: string) => {
switch (status) {
case MCPSessionStatus.CONNECTED:
return 'text-green-600';
case MCPSessionStatus.CONNECTING:
return 'text-yellow-600';
case MCPSessionStatus.ERROR:
return 'text-red-600';
default:
return 'text-gray-600';
}
};
const renderPluginContent = () => (
<>
<div className="text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
{cardVO.author} / {cardVO.name}
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] truncate max-w-[10rem]">
{cardVO.label}
</div>
<Badge
variant="outline"
className="text-[0.7rem] flex-shrink-0"
>
v{cardVO.version}
</Badge>
<Badge
variant="outline"
className={`text-[0.7rem] flex-shrink-0 ${getTypeBadgeColor(cardVO.type)}`}
>
{getTypeLabel(cardVO.type)}
</Badge>
{cardVO.debug && (
<Badge
variant="outline"
className="text-[0.7rem] border-orange-400 text-orange-400 flex-shrink-0"
>
<BugIcon className="w-4 h-4" />
{t('plugins.debugging')}
</Badge>
)}
{!cardVO.debug && (
<>
{cardVO.install_source === 'github' && (
<Badge
variant="outline"
className="text-[0.7rem] border-blue-400 text-blue-400 flex-shrink-0"
>
{t('plugins.fromGithub')}
</Badge>
)}
{cardVO.install_source === 'local' && (
<Badge
variant="outline"
className="text-[0.7rem] border-green-400 text-green-400 flex-shrink-0"
>
{t('plugins.fromLocal')}
</Badge>
)}
{cardVO.install_source === 'marketplace' && (
<Badge
variant="outline"
className="text-[0.7rem] border-purple-400 text-purple-400 flex-shrink-0"
>
{t('plugins.fromMarketplace')}
</Badge>
)}
</>
)}
</div>
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999] w-full">
{cardVO.description}
</div>
</>
);
const renderMCPContent = () => (
<>
<div className="text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
MCP Server
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] truncate max-w-[10rem]">
{cardVO.label}
</div>
<Badge
variant="outline"
className={`text-[0.7rem] flex-shrink-0 ${getTypeBadgeColor('mcp')}`}
>
MCP
</Badge>
{cardVO.mode && (
<Badge
variant="outline"
className="text-[0.7rem] border-gray-400 text-gray-600 dark:text-gray-300 flex-shrink-0"
>
{cardVO.mode.toUpperCase()}
</Badge>
)}
<Badge
variant="outline"
className={`text-[0.7rem] flex-shrink-0 ${
cardVO.enabled
? 'border-green-400 text-green-600 dark:text-green-400'
: 'border-gray-400 text-gray-600 dark:text-gray-300'
}`}
>
{cardVO.enabled ? t('mcp.statusConnected') : t('mcp.statusDisabled')}
</Badge>
</div>
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999] w-full">
{cardVO.description || t('mcp.noToolsFound')}
{cardVO.tools !== undefined && cardVO.tools > 0 && (
<span className="ml-1">{t('mcp.toolCount', { count: cardVO.tools })}</span>
)}
</div>
</>
);
const renderSkillContent = () => (
<>
<div className="text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
Skill
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] truncate max-w-[10rem]">
{cardVO.label}
</div>
<Badge
variant="outline"
className={`text-[0.7rem] flex-shrink-0 ${getTypeBadgeColor('skill')}`}
>
{t('common.skill')}
</Badge>
</div>
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999] w-full">
{cardVO.description}
</div>
</>
);
return (
<>
<div
className="w-[100%] h-[10rem] bg-white rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:border-[#a1a1aa] dark:hover:border-[#3f3f46]"
onClick={() => onCardClick()}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
<img
src={cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name)}
alt="extension icon"
className="w-16 h-16 rounded-[8%] flex-shrink-0"
/>
<div className="flex-1 min-w-0 h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start w-full min-w-0 flex-1 overflow-hidden">
{cardVO.type === 'plugin' && renderPluginContent()}
{cardVO.type === 'mcp' && renderMCPContent()}
{cardVO.type === 'skill' && renderSkillContent()}
</div>
</div>
<div
className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-center"></div>
<div className="flex items-center justify-center">
<DropdownMenu
open={dropdownOpen}
onOpenChange={(open) => {
setDropdownOpen(open);
}}
>
<DropdownMenuTrigger asChild>
<div className="relative">
<Button
variant="ghost"
className="bg-white dark:bg-[#1f1f22] hover:bg-gray-100 dark:hover:bg-[#2a2a2d]"
>
<Ellipsis className="w-4 h-4" />
</Button>
{cardVO.hasUpdate && (
<div className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-white dark:border-[#1f1f22]"></div>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{cardVO.type === 'plugin' && cardVO.install_source === 'marketplace' && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (onUpgradeClick) {
onUpgradeClick(cardVO);
}
setDropdownOpen(false);
}}
>
<ArrowUp className="w-4 h-4" />
<span>{t('plugins.update')}</span>
{cardVO.hasUpdate && (
<Badge className="ml-auto bg-red-500 hover:bg-red-500 text-white text-[0.6rem] px-1.5 py-0 h-4">
{t('plugins.new')}
</Badge>
)}
</DropdownMenuItem>
)}
{cardVO.type === 'plugin' && (cardVO.install_source === 'github' || cardVO.install_source === 'marketplace') && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (cardVO.install_source === 'github') {
window.open(cardVO.install_info?.github_url as string, '_blank');
} else if (cardVO.install_source === 'marketplace') {
window.open(
getCloudServiceClientSync().getPluginMarketplaceURL(
systemInfo.cloud_service_url,
cardVO.author,
cardVO.name,
),
'_blank',
);
}
setDropdownOpen(false);
}}
>
<ExternalLink className="w-4 h-4" />
<span>{t('plugins.viewSource')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer text-red-600 focus:text-red-600"
onClick={(e) => {
e.stopPropagation();
onDeleteClick(cardVO);
setDropdownOpen(false);
}}
>
<Trash className="w-4 h-4" />
<span>
{cardVO.type === 'mcp'
? t('mcp.deleteServer')
: cardVO.type === 'skill'
? t('skills.delete')
: t('plugins.delete')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,61 @@
export type ExtensionType = 'plugin' | 'mcp' | 'skill';
export interface IExtensionCardVO {
id: string;
author: string;
label: string;
name: string;
description: string;
version: string;
enabled: boolean;
type: ExtensionType;
iconURL?: string;
install_source?: string;
install_info?: Record<string, unknown>;
status?: string;
debug?: boolean;
hasUpdate?: boolean;
runtimeStatus?: 'connecting' | 'connected' | 'error' | 'disabled';
tools?: number;
mode?: 'stdio' | 'sse' | 'http';
}
export class ExtensionCardVO implements IExtensionCardVO {
id: string;
author: string;
label: string;
name: string;
description: string;
version: string;
enabled: boolean;
type: ExtensionType;
iconURL?: string;
install_source?: string;
install_info?: Record<string, unknown>;
status?: string;
debug?: boolean;
hasUpdate?: boolean;
runtimeStatus?: 'connecting' | 'connected' | 'error' | 'disabled';
tools?: number;
mode?: 'stdio' | 'sse' | 'http';
constructor(prop: IExtensionCardVO) {
this.id = prop.id;
this.author = prop.author;
this.label = prop.label;
this.name = prop.name;
this.description = prop.description;
this.version = prop.version;
this.enabled = prop.enabled;
this.type = prop.type;
this.iconURL = prop.iconURL;
this.install_source = prop.install_source;
this.install_info = prop.install_info;
this.status = prop.status;
this.debug = prop.debug;
this.hasUpdate = prop.hasUpdate;
this.runtimeStatus = prop.runtimeStatus;
this.tools = prop.tools;
this.mode = prop.mode;
}
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { useNavigate } from 'react-router-dom';
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
import { ExtensionCardVO, ExtensionType } from './ExtensionCardVO';
import ExtensionCardComponent from './ExtensionCardComponent';
import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import { getCloudServiceClientSync } from '@/app/infra/http';
@@ -22,42 +22,59 @@ import { toast } from 'sonner';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { Puzzle } from 'lucide-react';
import {
ToggleGroup,
ToggleGroupItem,
} from '@/components/ui/toggle-group';
import { Wrench, AudioWaveform, Book } from 'lucide-react';
import { MCPSessionStatus } from '@/app/infra/entities/api';
export interface PluginInstalledComponentRef {
refreshPluginList: () => void;
}
enum PluginOperationType {
enum ExtensionOperationType {
DELETE = 'DELETE',
UPDATE = 'UPDATE',
}
type FilterType = 'all' | ExtensionType;
const FilterOptions = [
{ value: 'all' as FilterType, labelKey: 'market.filters.allFormats', icon: null },
{ value: 'plugin' as FilterType, labelKey: 'market.typePlugin', icon: Wrench },
{ value: 'mcp' as FilterType, labelKey: 'market.typeMCP', icon: AudioWaveform },
{ value: 'skill' as FilterType, labelKey: 'market.typeSkill', icon: Book },
];
const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
(props, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { refreshPlugins } = useSidebarData();
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const [extensionList, setExtensionList] = useState<ExtensionCardVO[]>([]);
const [filterType, setFilterType] = useState<FilterType>('all');
const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<PluginOperationType>(
PluginOperationType.DELETE,
const [operationType, setOperationType] = useState<ExtensionOperationType>(
ExtensionOperationType.DELETE,
);
const [targetPlugin, setTargetPlugin] = useState<PluginCardVO | null>(null);
const [targetExtension, setTargetExtension] = useState<ExtensionCardVO | null>(null);
const [deleteData, setDeleteData] = useState<boolean>(false);
const asyncTask = useAsyncTask({
onSuccess: () => {
const successMessage =
operationType === PluginOperationType.DELETE
operationType === ExtensionOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(successMessage);
setShowOperationModal(false);
getPluginList();
getExtensionList();
refreshPlugins();
refreshMCPServers();
refreshSkills();
},
onError: () => {
// Error is already handled in the hook state
},
});
@@ -66,131 +83,171 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
}, []);
function initData() {
getPluginList();
getExtensionList();
}
async function getPluginList() {
async function getExtensionList() {
try {
// 获取已安装插件列表
const installedPluginsResp = await httpClient.getPlugins();
const installedPlugins = installedPluginsResp.plugins;
// 获取市场插件列表
const client = getCloudServiceClientSync();
const marketplaceResp = await client.getMarketplacePlugins(1, 100);
const marketplacePlugins = marketplaceResp.plugins;
// 创建市场插件映射,便于快速查找
const marketplacePluginMap = new Map();
marketplacePlugins.forEach((plugin) => {
const [installedPluginsResp, marketplaceResp, mcpResp, skillsResp] = await Promise.all([
httpClient.getPlugins().catch(() => ({ plugins: [] })),
client.getMarketplacePlugins(1, 100).catch(() => ({ plugins: [] })),
httpClient.getMCPServers().catch(() => ({ servers: [] })),
httpClient.getSkills().catch(() => ({ skills: [] })),
]);
const marketplacePluginMap = new Map<string, any>();
marketplaceResp.plugins.forEach((plugin: any) => {
const key = `${plugin.author}/${plugin.name}`;
marketplacePluginMap.set(key, plugin);
});
// 转换并比较版本号
const pluginCards = installedPlugins.map((plugin) => {
const marketplaceKey = `${plugin.manifest.manifest.metadata.author}/${plugin.manifest.manifest.metadata.name}`;
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
const cardVO = new PluginCardVO({
author: plugin.manifest.manifest.metadata.author ?? '',
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
description: extractI18nObject(
plugin.manifest.manifest.metadata.description ?? {
en_US: '',
zh_Hans: '',
},
),
debug: plugin.debug,
enabled: plugin.enabled,
name: plugin.manifest.manifest.metadata.name,
version: plugin.manifest.manifest.metadata.version ?? '',
status: plugin.status,
components: plugin.components,
priority: plugin.priority,
install_source: plugin.install_source,
install_info: plugin.install_info,
type: marketplacePlugin?.type,
});
const extensions: ExtensionCardVO[] = [];
// 检查是否来自市场且有更新
if (cardVO.install_source === 'marketplace' && marketplacePlugin) {
for (const plugin of installedPluginsResp.plugins) {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const marketplaceKey = `${author}/${name}`;
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
let hasUpdate = false;
if (plugin.install_source === 'marketplace' && marketplacePlugin) {
if (marketplacePlugin.latest_version) {
cardVO.hasUpdate = isNewerVersion(
hasUpdate = isNewerVersion(
marketplacePlugin.latest_version,
cardVO.version,
meta.version ?? '',
);
}
}
return cardVO;
});
extensions.push(new ExtensionCardVO({
id: marketplaceKey,
author,
label: extractI18nObject(meta.label) || name,
name,
description: extractI18nObject(meta.description ?? { en_US: '', zh_Hans: '' }),
version: meta.version ?? '',
enabled: plugin.enabled,
type: marketplacePlugin?.type || 'plugin',
iconURL: httpClient.getPluginIconURL(author, name),
install_source: plugin.install_source,
install_info: plugin.install_info,
status: plugin.status,
debug: plugin.debug,
hasUpdate,
}));
}
setPluginList(pluginCards);
for (const server of mcpResp.servers) {
extensions.push(new ExtensionCardVO({
id: `mcp-${server.name}`,
author: '',
label: server.name.replace(/__/g, '/'),
name: server.name,
description: '',
version: '',
enabled: server.enable,
type: 'mcp',
iconURL: httpClient.getPluginIconURL('mcp', server.name),
status: server.runtime_info?.status,
runtimeStatus: server.runtime_info?.status,
tools: server.runtime_info?.tool_count || 0,
mode: server.mode,
}));
}
for (const skill of skillsResp.skills) {
extensions.push(new ExtensionCardVO({
id: `skill-${skill.name}`,
author: '',
label: skill.display_name || skill.name,
name: skill.name,
description: skill.description || '',
version: '',
enabled: true,
type: 'skill',
iconURL: httpClient.getPluginIconURL('skill', skill.name),
}));
}
setExtensionList(extensions);
} catch (error) {
console.error('获取插件列表失败:', error);
// 失败时仍显示已安装插件,不影响用户体验
const installedPluginsResp = await httpClient.getPlugins();
setPluginList(
installedPluginsResp.plugins.map((plugin) => {
return new PluginCardVO({
author: plugin.manifest.manifest.metadata.author ?? '',
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
description: extractI18nObject(
plugin.manifest.manifest.metadata.description ?? {
en_US: '',
zh_Hans: '',
},
),
debug: plugin.debug,
enabled: plugin.enabled,
name: plugin.manifest.manifest.metadata.name,
version: plugin.manifest.manifest.metadata.version ?? '',
status: plugin.status,
components: plugin.components,
priority: plugin.priority,
install_source: plugin.install_source,
install_info: plugin.install_info,
});
}),
);
console.error('Failed to fetch extension list:', error);
setExtensionList([]);
}
}
useImperativeHandle(ref, () => ({
refreshPluginList: getPluginList,
refreshPluginList: getExtensionList,
}));
function handlePluginClick(plugin: PluginCardVO) {
const pluginId = `${plugin.author}/${plugin.name}`;
navigate(`/home/plugins?id=${encodeURIComponent(pluginId)}`);
function handleExtensionClick(extension: ExtensionCardVO) {
if (extension.type === 'mcp') {
navigate(`/home/mcp`);
} else if (extension.type === 'skill') {
navigate(`/home/skills`);
} else {
const extensionId = `${extension.author}/${extension.name}`;
navigate(`/home/plugins?id=${encodeURIComponent(extensionId)}`);
}
}
function handlePluginDelete(plugin: PluginCardVO) {
setTargetPlugin(plugin);
setOperationType(PluginOperationType.DELETE);
function handleExtensionDelete(extension: ExtensionCardVO) {
setTargetExtension(extension);
setOperationType(ExtensionOperationType.DELETE);
setShowOperationModal(true);
setDeleteData(false);
asyncTask.reset();
}
function handlePluginUpdate(plugin: PluginCardVO) {
setTargetPlugin(plugin);
setOperationType(PluginOperationType.UPDATE);
function handleExtensionUpdate(extension: ExtensionCardVO) {
setTargetExtension(extension);
setOperationType(ExtensionOperationType.UPDATE);
setShowOperationModal(true);
asyncTask.reset();
}
function executeOperation() {
if (!targetPlugin) return;
if (!targetExtension) return;
if (targetExtension.type === 'mcp') {
httpClient.deleteMCPServer(targetExtension.name)
.then(() => {
toast.success(t('mcp.deleteSuccess'));
setShowOperationModal(false);
getExtensionList();
refreshMCPServers();
})
.catch((error) => {
toast.error(t('mcp.deleteError') + error.message);
});
return;
}
if (targetExtension.type === 'skill') {
httpClient.deleteSkill(targetExtension.name)
.then(() => {
toast.success(t('skills.deleteSuccess'));
setShowOperationModal(false);
getExtensionList();
refreshSkills();
})
.catch((error) => {
toast.error(t('skills.deleteError') + error.message);
});
return;
}
const apiCall =
operationType === PluginOperationType.DELETE
operationType === ExtensionOperationType.DELETE
? httpClient.removePlugin(
targetPlugin.author,
targetPlugin.name,
targetExtension.author,
targetExtension.name,
deleteData,
)
: httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name);
: httpClient.upgradePlugin(targetExtension.author, targetExtension.name);
apiCall
.then((res) => {
@@ -198,13 +255,32 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
})
.catch((error) => {
const errorMessage =
operationType === PluginOperationType.DELETE
operationType === ExtensionOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
});
}
const filteredExtensions = extensionList.filter((ext) => {
if (filterType === 'all') return true;
return ext.type === filterType;
});
const getDeleteConfirmMessage = () => {
if (!targetExtension) return '';
if (targetExtension.type === 'mcp') {
return t('mcp.confirmDeleteServer');
}
if (targetExtension.type === 'skill') {
return t('skills.deleteConfirmation');
}
return t('plugins.confirmDeletePlugin', {
author: targetExtension.author,
name: targetExtension.name,
});
};
return (
<>
<Dialog
@@ -212,7 +288,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
onOpenChange={(open) => {
if (!open) {
setShowOperationModal(false);
setTargetPlugin(null);
setTargetExtension(null);
asyncTask.reset();
}
}}
@@ -220,7 +296,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
<DialogContent>
<DialogHeader>
<DialogTitle>
{operationType === PluginOperationType.DELETE
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleteConfirm')
: t('plugins.updateConfirm')}
</DialogTitle>
@@ -228,18 +304,8 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
<DialogDescription>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div className="flex flex-col gap-4">
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.confirmDeletePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})
: t('plugins.confirmUpdatePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})}
</div>
{operationType === PluginOperationType.DELETE && (
<div>{getDeleteConfirmMessage()}</div>
{operationType === ExtensionOperationType.DELETE && targetExtension?.type === 'plugin' && (
<div className="flex items-center space-x-2">
<Checkbox
id="delete-data"
@@ -260,14 +326,14 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<div>
{operationType === PluginOperationType.DELETE
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div>
{operationType === PluginOperationType.DELETE
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleteError')
: t('plugins.updateError')}
<div className="text-red-500">{asyncTask.error}</div>
@@ -280,7 +346,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
variant="outline"
onClick={() => {
setShowOperationModal(false);
setTargetPlugin(null);
setTargetExtension(null);
asyncTask.reset();
}}
>
@@ -290,7 +356,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant={
operationType === PluginOperationType.DELETE
operationType === ExtensionOperationType.DELETE
? 'destructive'
: 'default'
}
@@ -298,7 +364,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
executeOperation();
}}
>
{operationType === PluginOperationType.DELETE
{operationType === ExtensionOperationType.DELETE
? t('plugins.confirmDelete')
: t('plugins.confirmUpdate')}
</Button>
@@ -306,13 +372,13 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<Button
variant={
operationType === PluginOperationType.DELETE
operationType === ExtensionOperationType.DELETE
? 'destructive'
: 'default'
}
disabled
>
{operationType === PluginOperationType.DELETE
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</Button>
@@ -332,21 +398,44 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</DialogContent>
</Dialog>
{pluginList.length === 0 ? (
<div className="px-[0.8rem] pb-4">
<ToggleGroup
type="single"
value={filterType}
onValueChange={(value) => {
if (value) setFilterType(value as FilterType);
}}
className="justify-start"
>
{FilterOptions.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
variant="outline"
className="px-4 py-2"
>
{option.icon && <option.icon className="w-4 h-4 mr-2" />}
{t(option.labelKey)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{filteredExtensions.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<Puzzle className="h-[3rem] w-[3rem]" />
<div className="text-lg mb-2">{t('plugins.noPluginInstalled')}</div>
</div>
) : (
<div className={`${styles.pluginListContainer}`}>
{pluginList.map((vo, index) => {
{filteredExtensions.map((vo, index) => {
return (
<div key={index}>
<PluginCardComponent
<div key={vo.id || index}>
<ExtensionCardComponent
cardVO={vo}
onCardClick={() => handlePluginClick(vo)}
onDeleteClick={() => handlePluginDelete(vo)}
onUpgradeClick={() => handlePluginUpdate(vo)}
onCardClick={() => handleExtensionClick(vo)}
onDeleteClick={() => handleExtensionDelete(vo)}
onUpgradeClick={vo.type === 'plugin' ? () => handleExtensionUpdate(vo) : undefined}
/>
</div>
);

View File

@@ -315,6 +315,7 @@ function PluginListView() {
taskId,
pluginName: pluginDisplayName,
source: 'github',
extensionType: 'plugin',
fileSize: assetSize,
});
setSelectedTaskId(taskKey);
@@ -337,6 +338,7 @@ function PluginListView() {
taskId,
pluginName: fileName,
source: 'local',
extensionType: 'plugin',
fileSize: fileSize,
});
setSelectedTaskId(taskKey);

View File

@@ -8,7 +8,7 @@ HTTP Client 已经重构为更清晰的架构,将通用方法与业务逻辑
- **BaseHttpClient.ts** - 基础 HTTP 客户端类,包含所有通用的 HTTP 方法和拦截器配置
- **BackendClient.ts** - 后端服务客户端,处理与后端 API 的所有交互
- **CloudServiceClient.ts** - 云服务客户端,处理与 cloud service 的交互(如插件市场)
- **CloudServiceClient.ts** - 云服务客户端,处理与 cloud service 的交互(如拓展市场)
- **index.ts** - 主入口文件,管理客户端实例的创建和导出
- **HttpClient.ts** - 仅用于向后兼容的文件(已废弃)

View File

@@ -2,8 +2,8 @@ const enUS = {
sidebar: {
home: 'Home',
extensions: 'Extensions',
installedPlugins: 'Installed Plugins',
pluginMarket: 'Marketplace',
installedPlugins: 'Installed Extensions',
pluginMarket: 'Extension Market',
mcpServers: 'MCP Servers',
pluginPages: 'Plugin Pages',
pluginPagesTooltip: 'Visual pages provided by installed plugins',
@@ -429,7 +429,7 @@ const enUS = {
createPlugin: 'Create Plugin',
editPlugin: 'Edit Plugin',
installed: 'Installed',
marketplace: 'Marketplace',
marketplace: 'Extension Market',
arrange: 'Sort Plugins',
install: 'Install',
installPlugin: 'Install Plugin',
@@ -571,22 +571,28 @@ const enUS = {
assetSize: 'Size: {{size}}',
confirmInstall: 'Confirm Install',
installFromGithubDesc: 'Install plugin from GitHub Release',
goToMarketplace: 'Go to Marketplace',
goToMarketplace: 'Go to Extension Market',
installProgress: {
title: 'Installing {{name}}',
titleGeneric: 'Plugin Installation',
titleGeneric: 'Extension Installation',
titlePlugin: 'Installing Plugin {{name}}',
titleMCP: 'Installing MCP Server {{name}}',
titleSkill: 'Installing Skill {{name}}',
overallProgress: 'Overall Progress',
downloading: 'Downloading Plugin',
downloading: 'Downloading',
installingDeps: 'Installing Dependencies',
initializing: 'Initializing Settings',
launching: 'Launching Plugin',
launching: 'Launching',
completed: 'Completed',
failed: 'Failed',
downloadSize: 'Package size: {{size}}',
depsInfo: '{{count}} dependencies to install',
depsProgress:
'{{installed}}/{{total}} installed · {{remaining}} remaining',
installComplete: 'Plugin installed successfully',
installComplete: 'Installation successful',
installCompletePlugin: 'Plugin installed successfully',
installCompleteMCP: 'MCP Server installed successfully',
installCompleteSkill: 'Skill installed successfully',
dismiss: 'Dismiss',
background: 'Run in Background',
taskQueue: 'Install Tasks',
@@ -935,7 +941,7 @@ const enUS = {
builtInParser: 'Provided by Knowledge engine',
noParserAvailable:
'No parser supports this file type. Please install a parser plugin that can handle this format.',
installParserHint: 'Browse parser plugins in Marketplace →',
installParserHint: 'Browse parser plugins in Extension Market →',
confirmUpload: 'Upload',
cancelUpload: 'Cancel',
},

View File

@@ -2,8 +2,8 @@ const zhHans = {
sidebar: {
home: '首页',
extensions: '扩展',
installedPlugins: '已安装插件',
pluginMarket: '插件市场',
installedPlugins: '已安装扩展',
pluginMarket: '拓展市场',
mcpServers: 'MCP 服务器',
pluginPages: '插件页面',
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
@@ -412,7 +412,7 @@ const zhHans = {
createPlugin: '创建插件',
editPlugin: '编辑插件',
installed: '已安装',
marketplace: '插件市场',
marketplace: '拓展市场',
arrange: '编排',
install: '安装',
installPlugin: '安装插件',
@@ -546,21 +546,27 @@ const zhHans = {
assetSize: '大小: {{size}}',
confirmInstall: '确认安装',
installFromGithubDesc: '从 GitHub Release 安装插件',
goToMarketplace: '前往插件市场',
goToMarketplace: '前往拓展市场',
installProgress: {
title: '正在安装 {{name}}',
titleGeneric: '插件安装',
titleGeneric: '扩展安装',
titlePlugin: '正在安装插件 {{name}}',
titleMCP: '正在安装 MCP 服务器 {{name}}',
titleSkill: '正在安装技能 {{name}}',
overallProgress: '总体进度',
downloading: '下载插件',
downloading: '下载',
installingDeps: '安装依赖',
initializing: '初始化配置',
launching: '启动插件',
launching: '启动',
completed: '已完成',
failed: '安装失败',
downloadSize: '包大小: {{size}}',
depsInfo: '共 {{count}} 个依赖需要安装',
depsProgress: '已安装 {{installed}}/{{total}} · 剩余 {{remaining}} 个',
installComplete: '插件安装成功',
installComplete: '安装成功',
installCompletePlugin: '插件安装成功',
installCompleteMCP: 'MCP 服务器安装成功',
installCompleteSkill: '技能安装成功',
dismiss: '关闭',
background: '后台运行',
taskQueue: '安装任务',
@@ -895,7 +901,7 @@ const zhHans = {
builtInParser: '由知识引擎提供',
noParserAvailable:
'没有解析器支持此文件类型,请安装支持该格式的解析器插件。',
installParserHint: '前往插件市场安装解析器 →',
installParserHint: '前往拓展市场安装解析器 →',
confirmUpload: '上传',
cancelUpload: '取消',
},