diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py
index f1da1bce..31bca2e5 100644
--- a/src/langbot/pkg/provider/tools/loaders/mcp.py
+++ b/src/langbot/pkg/provider/tools/loaders/mcp.py
@@ -314,6 +314,7 @@ class RuntimeMCPSession:
{
'name': tool.name,
'description': tool.description,
+ 'parameters': tool.parameters,
}
for tool in self.get_tools()
],
diff --git a/tests/unit_tests/provider/test_mcp_box_integration.py b/tests/unit_tests/provider/test_mcp_box_integration.py
index 273f0f79..df079518 100644
--- a/tests/unit_tests/provider/test_mcp_box_integration.py
+++ b/tests/unit_tests/provider/test_mcp_box_integration.py
@@ -513,6 +513,36 @@ class TestGetRuntimeInfoDict:
assert info['status'] == 'connecting'
assert 'box_session_id' not in info
+ def test_runtime_tools_include_parameters(self, mcp_module):
+ s = _make_session(
+ mcp_module,
+ {
+ 'name': 'test',
+ 'uuid': 'test-uuid',
+ 'mode': 'sse',
+ 'command': 'python',
+ 'args': [],
+ },
+ )
+ s.functions = [
+ SimpleNamespace(
+ name='create-service',
+ description='Create a service',
+ parameters={
+ 'type': 'object',
+ 'properties': {
+ 'project_id': {'type': 'string'},
+ },
+ 'required': ['project_id'],
+ },
+ )
+ ]
+
+ info = s.get_runtime_info_dict()
+
+ assert info['tools'][0]['parameters']['properties']['project_id']['type'] == 'string'
+ assert info['tools'][0]['parameters']['required'] == ['project_id']
+
def test_stdio_session_includes_box_info(self, mcp_module):
ap = _make_ap()
ap.box_service.available = True
diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx
index 39a4754f..d759147c 100644
--- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx
+++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx
@@ -233,6 +233,27 @@ function mcpStatusColor(item: SidebarEntityItem): string {
}
}
+function MCPStatusIcon({
+ item,
+ borderClass,
+}: {
+ item: SidebarEntityItem;
+ borderClass: string;
+}) {
+ return (
+
+
+
+
+ );
+}
+
// Plugin operation type enum
enum PluginOperationType {
DELETE = 'DELETE',
@@ -514,7 +535,7 @@ function NavItems({
}}
>
{item.extensionType === 'mcp' ? (
-
+
) : item.extensionType === 'skill' ? (
) : item.emoji ? (
@@ -574,7 +595,10 @@ function NavItems({
}}
>
{item.extensionType === 'mcp' ? (
-
+
) : item.extensionType === 'skill' ? (
) : item.emoji ? (
diff --git a/web/src/app/home/mcp/MCPDetailContent.tsx b/web/src/app/home/mcp/MCPDetailContent.tsx
index f085a297..96d5c955 100644
--- a/web/src/app/home/mcp/MCPDetailContent.tsx
+++ b/web/src/app/home/mcp/MCPDetailContent.tsx
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
+import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
@@ -23,7 +24,7 @@ import type { MCPFormHandle } from '@/app/home/mcp/components/mcp-form/MCPForm';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
-import { Trash2 } from 'lucide-react';
+import { Server, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
export default function MCPDetailContent({ id }: { id: string }) {
@@ -32,19 +33,18 @@ export default function MCPDetailContent({ id }: { id: string }) {
const { t } = useTranslation();
const { refreshMCPServers, mcpServers, setDetailEntityName } =
useSidebarData();
+ const server = mcpServers.find((s) => s.id === id);
+ const displayName = (server?.name ?? id).replace(/__/g, '/');
// Set breadcrumb entity name
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('mcp.createServer'));
} else {
- const server = mcpServers.find((s) => s.id === id);
- // Convert __ back to / for display (since / is used as separator in stored names)
- const displayName = (server?.name ?? id).replace(/__/g, '/');
setDetailEntityName(displayName);
}
return () => setDetailEntityName(null);
- }, [id, isCreateMode, mcpServers, setDetailEntityName, t]);
+ }, [displayName, isCreateMode, setDetailEntityName, t]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -144,10 +144,17 @@ export default function MCPDetailContent({ id }: { id: string }) {
if (isCreateMode) {
return (
- {/* Header */}
-
-
{t('mcp.createServer')}
-
+
+
+
+ {t('mcp.createServer')}
+
+
+
+ {t('mcp.title')}
+
+
+
- {/* Content */}
-
);
}
+ const enableControl = enableLoaded && (
+
+
+ {t('common.enable')}
+
+
+
+
+
+
+
+
+ );
+
+ const editActions = (
+
+
+
+ {t('mcp.dangerZone')}
+
+ {t('mcp.dangerZoneDescription')}
+
+
+
+
+
{t('mcp.deleteMCPAction')}
+
+ {t('mcp.deleteMCPHint')}
+
+
+
+
+
+
+ );
+
// ==================== Edit Mode ====================
return (
<>
- {/* Header: title + enable switch + save button */}
-
-
-
{t('mcp.editServer')}
- {enableLoaded && (
-
-
-
-
- )}
+
+
+
+
{displayName}
+
+
+ {t('mcp.title')}
+
+
-
+
- {/* Content */}
-
-
-
-
- {/* Card: Danger Zone */}
-
-
-
- {t('mcp.dangerZone')}
-
-
- {t('mcp.dangerZoneDescription')}
-
-
-
-
-
-
- {t('mcp.deleteMCPAction')}
-
-
- {t('mcp.deleteMCPHint')}
-
-
-
-
-
-
-
+
+
diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx
index 2051081a..127dc4db 100644
--- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx
+++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx
@@ -1,4 +1,5 @@
import React, {
+ type ReactNode,
useState,
useEffect,
useRef,
@@ -6,7 +7,8 @@ import React, {
useImperativeHandle,
} from 'react';
import { useTranslation } from 'react-i18next';
-import { Loader2, XCircle, Trash2 } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import { Braces, Loader2, Trash2, Wrench, XCircle } from 'lucide-react';
import { Resolver, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -29,6 +31,14 @@ import {
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
MCPServerRuntimeInfo,
@@ -41,7 +51,6 @@ import {
} from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
-// Status display for test / connecting / error states
function StatusDisplay({
testing,
runtimeInfo,
@@ -49,12 +58,12 @@ function StatusDisplay({
}: {
testing: boolean;
runtimeInfo: MCPServerRuntimeInfo;
- t: (key: string) => string;
+ t: TFunction;
}) {
if (testing) {
return (
-
+
{t('mcp.testing')}
);
@@ -63,7 +72,7 @@ function StatusDisplay({
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
return (
-
+
{t('mcp.connecting')}
);
@@ -72,11 +81,11 @@ function StatusDisplay({
return (
-
+
{t('mcp.connectionFailed')}
{runtimeInfo.error_message && (
-
+
{runtimeInfo.error_message}
)}
@@ -84,25 +93,181 @@ function StatusDisplay({
);
}
-// Tools list component
-function ToolsList({ tools }: { tools: MCPTool[] }) {
+type ToolParameter = {
+ name: string;
+ type?: string;
+ description?: string;
+ required?: boolean;
+};
+
+function getToolParameters(parameters?: object): ToolParameter[] {
+ if (!parameters || typeof parameters !== 'object') return [];
+
+ const schema = parameters as {
+ properties?: Record<
+ string,
+ { type?: string; description?: string; title?: string }
+ >;
+ required?: string[];
+ };
+
+ if (schema.properties && typeof schema.properties === 'object') {
+ const required = new Set(schema.required ?? []);
+ return Object.entries(schema.properties).map(([name, parameter]) => ({
+ name,
+ type: parameter?.type,
+ description: parameter?.description || parameter?.title,
+ required: required.has(name),
+ }));
+ }
+
+ return Object.keys(parameters).map((name) => ({ name }));
+}
+
+function ToolsList({ tools, t }: { tools: MCPTool[]; t: TFunction }) {
return (
-
- {tools.map((tool, index) => (
-
-
{tool.name}
- {tool.description && (
-
- {tool.description}
+
+ {tools.map((tool, index) => {
+ const parameters = getToolParameters(tool.parameters);
+ const visibleParameters = parameters.slice(0, 4);
+ const hiddenParameterCount =
+ parameters.length - visibleParameters.length;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {tool.name}
+
+
+ #{index + 1}
+
+
+
+ {tool.description || t('market.noDescription')}
+
+
+
+
+
+
+
+ {t('mcp.parameterCount', {
+ count: parameters.length,
+ })}
+
+
+
+ {visibleParameters.length > 0 ? (
+
+ {visibleParameters.map((parameter) => (
+
+
+ {parameter.name}
+
+ {parameter.type && (
+
+ {parameter.type}
+
+ )}
+ {parameter.required && (
+ *
+ )}
+
+ ))}
+ {hiddenParameterCount > 0 && (
+
+ +{hiddenParameterCount}
+
+ )}
+
+ ) : (
+
+ {t('mcp.noParameters')}
+
+ )}
+
+
- )}
-
- ))}
+
+ );
+ })}
);
}
-const getFormSchema = (t: (key: string) => string) =>
+function RuntimePanel({
+ isEditMode,
+ mcpTesting,
+ runtimeInfo,
+ t,
+}: {
+ isEditMode: boolean;
+ mcpTesting: boolean;
+ runtimeInfo: MCPServerRuntimeInfo | null;
+ t: TFunction;
+}) {
+ if (!isEditMode || !runtimeInfo) {
+ return (
+
+ {t('mcp.noToolsFound')}
+
+ );
+ }
+
+ const isConnected =
+ !mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED;
+ const tools = runtimeInfo.tools || [];
+
+ return (
+
+
+
+
{t('mcp.title')}
+
+ {isConnected
+ ? t('mcp.toolCount', { count: tools.length })
+ : t('mcp.connectionFailedStatus')}
+
+
+ {isConnected && (
+
+ {t('mcp.toolCount', { count: tools.length })}
+
+ )}
+
+
+ {!isConnected && (
+
+
+
+ )}
+
+ {isConnected && tools.length > 0 && }
+
+ {isConnected && tools.length === 0 && (
+
+ {t('mcp.noToolsFound')}
+
+ )}
+
+ );
+}
+
+const getFormSchema = (t: TFunction) =>
z
.object({
name: z
@@ -165,9 +330,11 @@ interface MCPFormProps {
onDraftChange?: (draft: MCPFormDraft) => void;
onDirtyChange?: (dirty: boolean) => void;
onTestingChange?: (testing: boolean) => void;
+ layout?: 'stacked' | 'split';
+ sideHeader?: ReactNode;
+ sideFooter?: ReactNode;
}
-// Handle exposed to parent via ref
export interface MCPFormHandle {
testMcp: () => void;
isTesting: boolean;
@@ -182,6 +349,9 @@ const MCPForm = forwardRef
(function MCPForm(
onDraftChange,
onDirtyChange,
onTestingChange,
+ layout = 'stacked',
+ sideHeader,
+ sideFooter,
},
ref,
) {
@@ -205,9 +375,7 @@ const MCPForm = forwardRef(function MCPForm(
},
});
- // Track whether initial data loading is complete (to avoid marking form dirty)
const isInitializing = useRef(true);
-
const [extraArgs, setExtraArgs] = useState<
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
>([]);
@@ -217,21 +385,17 @@ const MCPForm = forwardRef(function MCPForm(
null,
);
const pollingIntervalRef = useRef(null);
-
const watchMode = form.watch('mode');
- // Notify parent when dirty state changes
const { isDirty } = form.formState;
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
- // Notify parent when testing state changes
useEffect(() => {
onTestingChange?.(mcpTesting);
}, [mcpTesting, onTestingChange]);
- // Expose test action and testing state to parent
useImperativeHandle(
ref,
() => ({
@@ -241,7 +405,6 @@ const MCPForm = forwardRef(function MCPForm(
[mcpTesting],
);
- // Load server data
useEffect(() => {
isInitializing.current = true;
if (isEditMode && initServerName) {
@@ -288,7 +451,6 @@ const MCPForm = forwardRef(function MCPForm(
return () => subscription.unsubscribe();
}, [form, isEditMode, onDraftChange, extraArgs, stdioArgs]);
- // Poll for updates when runtime_info status is CONNECTING
useEffect(() => {
if (
!isEditMode ||
@@ -323,7 +485,7 @@ const MCPForm = forwardRef(function MCPForm(
const server = resp.server ?? resp;
const formValues: FormValues = {
- name: server.name.replace(/__/g, '/'), // Convert __ back to / for display
+ name: server.name.replace(/__/g, '/'),
mode: server.mode,
url: '',
command: '',
@@ -379,15 +541,8 @@ const MCPForm = forwardRef(function MCPForm(
setExtraArgs(newExtraArgs);
setStdioArgs(newStdioArgs);
-
- // Use form.reset so isDirty stays false after initial load
form.reset(formValues);
-
- if (server.runtime_info) {
- setRuntimeInfo(server.runtime_info);
- } else {
- setRuntimeInfo(null);
- }
+ setRuntimeInfo(server.runtime_info ?? null);
} catch (error) {
console.error('Failed to load server:', error);
toast.error(t('mcp.loadFailed'));
@@ -411,7 +566,7 @@ const MCPForm = forwardRef(function MCPForm(
enable: true,
extra_args: {
url: value.url!,
- headers: headers,
+ headers,
timeout: value.timeout,
ssereadtimeout: value.ssereadtimeout,
},
@@ -423,7 +578,7 @@ const MCPForm = forwardRef(function MCPForm(
enable: true,
extra_args: {
url: value.url!,
- headers: headers,
+ headers,
timeout: value.timeout,
},
};
@@ -433,7 +588,6 @@ const MCPForm = forwardRef(function MCPForm(
value.extra_args?.forEach((arg) => {
env[arg.key] = String(arg.value);
});
- const args = value.args?.map((arg) => arg.value) || [];
serverConfig = {
name: value.name,
@@ -441,8 +595,8 @@ const MCPForm = forwardRef(function MCPForm(
enable: true,
extra_args: {
command: value.command!,
- args: args,
- env: env,
+ args: value.args?.map((arg) => arg.value) || [],
+ env,
},
};
}
@@ -450,7 +604,6 @@ const MCPForm = forwardRef(function MCPForm(
if (isEditMode && initServerName) {
await httpClient.updateMCPServer(initServerName, serverConfig);
toast.success(t('mcp.updateSuccess'));
- // Reset dirty baseline to current values
form.reset(form.getValues());
onFormSubmit();
} else {
@@ -504,7 +657,7 @@ const MCPForm = forwardRef(function MCPForm(
const { task_id } = await httpClient.testMCPServer('_', {
name: form.getValues('name'),
- mode: mode,
+ mode,
enable: true,
extra_args: extraArgsData,
} as MCPServer);
@@ -604,121 +757,108 @@ const MCPForm = forwardRef(function MCPForm(
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
};
- return (
-
+ );
+ }
+
+ return (
+
);
diff --git a/web/src/app/home/plugins/PluginDetailContent.tsx b/web/src/app/home/plugins/PluginDetailContent.tsx
index 30d1f8bb..10ba7adc 100644
--- a/web/src/app/home/plugins/PluginDetailContent.tsx
+++ b/web/src/app/home/plugins/PluginDetailContent.tsx
@@ -1,10 +1,34 @@
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
+import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
-import { Bug } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { httpClient } from '@/app/infra/http/HttpClient';
+import { Plugin } from '@/app/infra/entities/plugin';
+import { extractI18nObject } from '@/i18n/I18nProvider';
+import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
+import { Bug, Puzzle, Trash2 } from 'lucide-react';
+import { toast } from 'sonner';
/**
* Plugin detail page content.
@@ -12,7 +36,11 @@ import { Bug } from 'lucide-react';
*/
export default function PluginDetailContent({ id }: { id: string }) {
const { t } = useTranslation();
+ const navigate = useNavigate();
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
+ const [pluginInfo, setPluginInfo] = useState(null);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [deleteData, setDeleteData] = useState(false);
// Parse "author/name" composite key
const slashIndex = id.indexOf('/');
@@ -20,6 +48,23 @@ export default function PluginDetailContent({ id }: { id: string }) {
const pluginName = slashIndex >= 0 ? id.substring(slashIndex + 1) : id;
const plugin = plugins.find((p) => p.id === id);
+ const title =
+ pluginInfo?.manifest.manifest.metadata.label &&
+ extractI18nObject(pluginInfo.manifest.manifest.metadata.label)
+ ? extractI18nObject(pluginInfo.manifest.manifest.metadata.label)
+ : plugin?.name || `${pluginAuthor}/${pluginName}`;
+ const description = pluginInfo?.manifest.manifest.metadata.description
+ ? extractI18nObject(pluginInfo.manifest.manifest.metadata.description)
+ : plugin?.description;
+
+ const asyncTask = useAsyncTask({
+ onSuccess: () => {
+ toast.success(t('plugins.deleteSuccess'));
+ setShowDeleteConfirm(false);
+ void refreshPlugins();
+ navigate('/home/extensions');
+ },
+ });
// Set breadcrumb entity name
useEffect(() => {
@@ -27,6 +72,18 @@ export default function PluginDetailContent({ id }: { id: string }) {
return () => setDetailEntityName(null);
}, [plugin, pluginAuthor, pluginName, setDetailEntityName]);
+ useEffect(() => {
+ let cancelled = false;
+ httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
+ if (!cancelled) {
+ setPluginInfo(res.plugin);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [pluginAuthor, pluginName]);
+
function handleFormSubmit(timeout?: number) {
if (timeout) {
setTimeout(() => {
@@ -37,60 +94,199 @@ export default function PluginDetailContent({ id }: { id: string }) {
}
}
+ function executeDelete() {
+ httpClient
+ .removePlugin(pluginAuthor, pluginName, deleteData)
+ .then((res) => {
+ asyncTask.startTask(res.task_id);
+ })
+ .catch((error) => {
+ toast.error(t('plugins.deleteError') + error.message);
+ });
+ }
+
+ const sourceBadge = plugin?.debug ? (
+
+
+ {t('plugins.debugging')}
+
+ ) : plugin?.installSource === 'github' ? (
+
+ {t('plugins.fromGithub')}
+
+ ) : plugin?.installSource === 'local' ? (
+
+ {t('plugins.fromLocal')}
+
+ ) : plugin?.installSource === 'marketplace' ? (
+
+ {t('plugins.fromMarketplace')}
+
+ ) : null;
+
+ const componentBadges = pluginInfo && (
+ >(
+ (acc, component) => {
+ const kind = component.manifest.manifest.kind;
+ acc[kind] = (acc[kind] ?? 0) + 1;
+ return acc;
+ },
+ {},
+ )}
+ showComponentName
+ showTitle={false}
+ useBadge
+ t={t}
+ />
+ );
+
+ const dangerZone = (
+
+
+
+ {t('plugins.dangerZone')}
+
+ {t('plugins.dangerZoneDescription')}
+
+
+
+
+
{t('plugins.deletePlugin')}
+
+ {t('plugins.confirmDeletePlugin', {
+ author: pluginAuthor,
+ name: pluginName,
+ })}
+
+
+
+
+
+
+ );
+
return (
-
-
-
- {pluginAuthor}/{pluginName}
-
- {plugin?.debug ? (
-
-
- {t('plugins.debugging')}
-
- ) : plugin?.installSource === 'github' ? (
-
- {t('plugins.fromGithub')}
-
- ) : plugin?.installSource === 'local' ? (
-
- {t('plugins.fromLocal')}
-
- ) : plugin?.installSource === 'marketplace' ? (
-
- {t('plugins.fromMarketplace')}
-
- ) : null}
+ <>
+
+
+
+
{title}
+
+
+ {t('market.typePlugin')}
+
+ {sourceBadge}
+ {componentBadges}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
-
- {/* Left side - Config */}
-
- {/* Divider */}
-
- {/* Right side - Readme */}
-
-
-
+
+ >
);
}
diff --git a/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx
index 0d5eaed2..64248b1b 100644
--- a/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx
+++ b/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx
@@ -4,10 +4,16 @@ import { Plugin } from '@/app/infra/entities/plugin';
import { httpClient } from '@/app/infra/http/HttpClient';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
import { toast } from 'sonner';
-import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
-import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
export default function PluginForm({
pluginAuthor,
@@ -137,65 +143,34 @@ export default function PluginForm({
}
return (
-
-
-
- {extractI18nObject(pluginInfo.manifest.manifest.metadata.label)}
-
-
- {extractI18nObject(
- pluginInfo.manifest.manifest.metadata.description ?? {
- en_US: '',
- zh_Hans: '',
- },
+
+
+
+ {t('plugins.pluginConfig')}
+ {t('plugins.saveConfig')}
+
+
+ {pluginInfo.manifest.manifest.spec.config.length > 0 ? (
+ }
+ onSubmit={(values) => {
+ // 只保存表单值的引用,不触发状态更新
+ currentFormValues.current = values;
+ }}
+ onFileUploaded={(fileKey) => {
+ // 追踪上传的文件
+ uploadedFileKeys.current.add(fileKey);
+ }}
+ />
+ ) : (
+
+ {t('plugins.pluginNoConfig')}
+
)}
-
-
-
-
{
- const componentKindCount: Record = {};
- for (const component of pluginInfo.components) {
- const kind = component.manifest.manifest.kind;
- if (componentKindCount[kind]) {
- componentKindCount[kind]++;
- } else {
- componentKindCount[kind] = 1;
- }
- }
- return componentKindCount;
- })()}
- showComponentName={true}
- showTitle={false}
- useBadge={true}
- t={t}
- />
-
-
+
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
-
}
- onSubmit={(values) => {
- // 只保存表单值的引用,不触发状态更新
- currentFormValues.current = values;
- }}
- onFileUploaded={(fileKey) => {
- // 追踪上传的文件
- uploadedFileKeys.current.add(fileKey);
- }}
- />
- )}
- {pluginInfo.manifest.manifest.spec.config.length === 0 && (
-
- {t('plugins.pluginNoConfig')}
-
- )}
-
-
- {pluginInfo.manifest.manifest.spec.config.length > 0 && (
-
-
+
-
-
- )}
+
+ )}
+
);
}
diff --git a/web/src/app/home/skills/SkillDetailContent.tsx b/web/src/app/home/skills/SkillDetailContent.tsx
index 3284d967..c8c4d386 100644
--- a/web/src/app/home/skills/SkillDetailContent.tsx
+++ b/web/src/app/home/skills/SkillDetailContent.tsx
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
@@ -20,6 +21,7 @@ import {
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { httpClient } from '@/app/infra/http/HttpClient';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
+import { Sparkles, Trash2 } from 'lucide-react';
export default function SkillDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
@@ -27,16 +29,16 @@ export default function SkillDetailContent({ id }: { id: string }) {
const { t } = useTranslation();
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const skill = skills.find((item) => item.id === id);
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('skills.createSkill'));
} else {
- const skill = skills.find((item) => item.id === id);
setDetailEntityName(skill?.name ?? id);
}
return () => setDetailEntityName(null);
- }, [id, isCreateMode, setDetailEntityName, skills, t]);
+ }, [id, isCreateMode, setDetailEntityName, skill, t]);
function handleImportedSkills(skillNames: string[]) {
void refreshSkills();
@@ -67,78 +69,101 @@ export default function SkillDetailContent({ id }: { id: string }) {
if (isCreateMode) {
return (
-
-
{t('skills.createSkill')}
-