mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 18:06:06 +00:00
feat: polish extension detail pages
This commit is contained in:
@@ -314,6 +314,7 @@ class RuntimeMCPSession:
|
|||||||
{
|
{
|
||||||
'name': tool.name,
|
'name': tool.name,
|
||||||
'description': tool.description,
|
'description': tool.description,
|
||||||
|
'parameters': tool.parameters,
|
||||||
}
|
}
|
||||||
for tool in self.get_tools()
|
for tool in self.get_tools()
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -513,6 +513,36 @@ class TestGetRuntimeInfoDict:
|
|||||||
assert info['status'] == 'connecting'
|
assert info['status'] == 'connecting'
|
||||||
assert 'box_session_id' not in info
|
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):
|
def test_stdio_session_includes_box_info(self, mcp_module):
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
ap.box_service.available = True
|
ap.box_service.available = True
|
||||||
|
|||||||
@@ -233,6 +233,27 @@ function mcpStatusColor(item: SidebarEntityItem): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MCPStatusIcon({
|
||||||
|
item,
|
||||||
|
borderClass,
|
||||||
|
}: {
|
||||||
|
item: SidebarEntityItem;
|
||||||
|
borderClass: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="relative shrink-0">
|
||||||
|
<Server className="size-4 !text-blue-500" />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute -bottom-1 -right-1 size-3 rounded-full border-2',
|
||||||
|
borderClass,
|
||||||
|
mcpStatusColor(item),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Plugin operation type enum
|
// Plugin operation type enum
|
||||||
enum PluginOperationType {
|
enum PluginOperationType {
|
||||||
DELETE = 'DELETE',
|
DELETE = 'DELETE',
|
||||||
@@ -514,7 +535,7 @@ function NavItems({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.extensionType === 'mcp' ? (
|
{item.extensionType === 'mcp' ? (
|
||||||
<Server className="size-4 shrink-0 !text-blue-500" />
|
<MCPStatusIcon item={item} borderClass="border-popover" />
|
||||||
) : item.extensionType === 'skill' ? (
|
) : item.extensionType === 'skill' ? (
|
||||||
<Sparkles className="size-4 shrink-0 !text-blue-500" />
|
<Sparkles className="size-4 shrink-0 !text-blue-500" />
|
||||||
) : item.emoji ? (
|
) : item.emoji ? (
|
||||||
@@ -574,7 +595,10 @@ function NavItems({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.extensionType === 'mcp' ? (
|
{item.extensionType === 'mcp' ? (
|
||||||
<Server className="size-4 shrink-0 !text-blue-500" />
|
<MCPStatusIcon
|
||||||
|
item={item}
|
||||||
|
borderClass="border-sidebar"
|
||||||
|
/>
|
||||||
) : item.extensionType === 'skill' ? (
|
) : item.extensionType === 'skill' ? (
|
||||||
<Sparkles className="size-4 shrink-0 !text-blue-500" />
|
<Sparkles className="size-4 shrink-0 !text-blue-500" />
|
||||||
) : item.emoji ? (
|
) : item.emoji ? (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
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 { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { Server, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function MCPDetailContent({ id }: { id: string }) {
|
export default function MCPDetailContent({ id }: { id: string }) {
|
||||||
@@ -32,19 +33,18 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { refreshMCPServers, mcpServers, setDetailEntityName } =
|
const { refreshMCPServers, mcpServers, setDetailEntityName } =
|
||||||
useSidebarData();
|
useSidebarData();
|
||||||
|
const server = mcpServers.find((s) => s.id === id);
|
||||||
|
const displayName = (server?.name ?? id).replace(/__/g, '/');
|
||||||
|
|
||||||
// Set breadcrumb entity name
|
// Set breadcrumb entity name
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
setDetailEntityName(t('mcp.createServer'));
|
setDetailEntityName(t('mcp.createServer'));
|
||||||
} else {
|
} 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);
|
setDetailEntityName(displayName);
|
||||||
}
|
}
|
||||||
return () => setDetailEntityName(null);
|
return () => setDetailEntityName(null);
|
||||||
}, [id, isCreateMode, mcpServers, setDetailEntityName, t]);
|
}, [displayName, isCreateMode, setDetailEntityName, t]);
|
||||||
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
@@ -144,10 +144,17 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Header */}
|
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<h1 className="text-xl font-semibold">{t('mcp.createServer')}</h1>
|
<h1 className="truncate text-xl font-semibold">
|
||||||
<div className="flex items-center gap-2">
|
{t('mcp.createServer')}
|
||||||
|
</h1>
|
||||||
|
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
|
||||||
|
<Server className="size-3.5" />
|
||||||
|
{t('mcp.title')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -177,47 +184,89 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
<div className="min-h-0 flex-1">
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<MCPForm
|
||||||
<div className="mx-auto max-w-3xl pb-8">
|
ref={formRef}
|
||||||
<MCPForm
|
initServerName={undefined}
|
||||||
ref={formRef}
|
layout="split"
|
||||||
initServerName={undefined}
|
onFormSubmit={handleFormSubmit}
|
||||||
onFormSubmit={handleFormSubmit}
|
onNewServerCreated={handleNewServerCreated}
|
||||||
onNewServerCreated={handleNewServerCreated}
|
onTestingChange={setMcpTesting}
|
||||||
onTestingChange={setMcpTesting}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enableControl = enableLoaded && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('common.enable')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label
|
||||||
|
htmlFor="mcp-enable-switch"
|
||||||
|
className="cursor-pointer text-sm font-medium"
|
||||||
|
>
|
||||||
|
{t('common.enable')}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="mcp-enable-switch"
|
||||||
|
checked={serverEnabled}
|
||||||
|
onCheckedChange={handleEnableToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const editActions = (
|
||||||
|
<Card className="border-destructive/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">
|
||||||
|
{t('mcp.dangerZone')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t('mcp.dangerZoneDescription')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">{t('mcp.deleteMCPAction')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('mcp.deleteMCPHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 size-4" />
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
// ==================== Edit Mode ====================
|
// ==================== Edit Mode ====================
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Header: title + enable switch + save button */}
|
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="min-w-0 space-y-1">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<h1 className="text-xl font-semibold">{t('mcp.editServer')}</h1>
|
<h1 className="truncate text-xl font-semibold">{displayName}</h1>
|
||||||
{enableLoaded && (
|
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
|
||||||
<div className="flex items-center gap-2">
|
<Server className="size-3.5" />
|
||||||
<Switch
|
{t('mcp.title')}
|
||||||
id="mcp-enable-switch"
|
</Badge>
|
||||||
checked={serverEnabled}
|
</div>
|
||||||
onCheckedChange={handleEnableToggle}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor="mcp-enable-switch"
|
|
||||||
className="text-sm text-muted-foreground cursor-pointer"
|
|
||||||
>
|
|
||||||
{t('common.enable')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -232,51 +281,18 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
<div className="min-h-0 flex-1">
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<MCPForm
|
||||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
ref={formRef}
|
||||||
<MCPForm
|
initServerName={id}
|
||||||
ref={formRef}
|
layout="split"
|
||||||
initServerName={id}
|
sideHeader={enableControl}
|
||||||
onFormSubmit={handleFormSubmit}
|
sideFooter={editActions}
|
||||||
onNewServerCreated={handleNewServerCreated}
|
onFormSubmit={handleFormSubmit}
|
||||||
onDirtyChange={setFormDirty}
|
onNewServerCreated={handleNewServerCreated}
|
||||||
onTestingChange={setMcpTesting}
|
onDirtyChange={setFormDirty}
|
||||||
/>
|
onTestingChange={setMcpTesting}
|
||||||
|
/>
|
||||||
{/* Card: Danger Zone */}
|
|
||||||
<Card className="border-destructive/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-destructive">
|
|
||||||
{t('mcp.dangerZone')}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('mcp.dangerZoneDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{t('mcp.deleteMCPAction')}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('mcp.deleteMCPHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 mr-1.5" />
|
|
||||||
{t('common.delete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, {
|
import React, {
|
||||||
|
type ReactNode,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -6,7 +7,8 @@ import React, {
|
|||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Resolver, useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -29,6 +31,14 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import {
|
import {
|
||||||
MCPServerRuntimeInfo,
|
MCPServerRuntimeInfo,
|
||||||
@@ -41,7 +51,6 @@ import {
|
|||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { CustomApiError } from '@/app/infra/entities/common';
|
import { CustomApiError } from '@/app/infra/entities/common';
|
||||||
|
|
||||||
// Status display for test / connecting / error states
|
|
||||||
function StatusDisplay({
|
function StatusDisplay({
|
||||||
testing,
|
testing,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
@@ -49,12 +58,12 @@ function StatusDisplay({
|
|||||||
}: {
|
}: {
|
||||||
testing: boolean;
|
testing: boolean;
|
||||||
runtimeInfo: MCPServerRuntimeInfo;
|
runtimeInfo: MCPServerRuntimeInfo;
|
||||||
t: (key: string) => string;
|
t: TFunction;
|
||||||
}) {
|
}) {
|
||||||
if (testing) {
|
if (testing) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-blue-600">
|
<div className="flex items-center gap-2 text-blue-600">
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<Loader2 className="size-5 animate-spin" />
|
||||||
<span className="font-medium">{t('mcp.testing')}</span>
|
<span className="font-medium">{t('mcp.testing')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -63,7 +72,7 @@ function StatusDisplay({
|
|||||||
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
|
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-blue-600">
|
<div className="flex items-center gap-2 text-blue-600">
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<Loader2 className="size-5 animate-spin" />
|
||||||
<span className="font-medium">{t('mcp.connecting')}</span>
|
<span className="font-medium">{t('mcp.connecting')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -72,11 +81,11 @@ function StatusDisplay({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2 text-red-600">
|
<div className="flex items-center gap-2 text-red-600">
|
||||||
<XCircle className="w-5 h-5" />
|
<XCircle className="size-5" />
|
||||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||||
</div>
|
</div>
|
||||||
{runtimeInfo.error_message && (
|
{runtimeInfo.error_message && (
|
||||||
<div className="text-sm text-red-500 pl-7">
|
<div className="pl-7 text-sm text-red-500">
|
||||||
{runtimeInfo.error_message}
|
{runtimeInfo.error_message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -84,25 +93,181 @@ function StatusDisplay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tools list component
|
type ToolParameter = {
|
||||||
function ToolsList({ tools }: { tools: MCPTool[] }) {
|
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 (
|
return (
|
||||||
<div className="max-h-[300px] space-y-1 overflow-y-auto">
|
<div className="grid gap-3 pb-6 xl:grid-cols-2">
|
||||||
{tools.map((tool, index) => (
|
{tools.map((tool, index) => {
|
||||||
<div key={index} className="rounded-md px-1 py-2">
|
const parameters = getToolParameters(tool.parameters);
|
||||||
<div className="text-sm font-medium">{tool.name}</div>
|
const visibleParameters = parameters.slice(0, 4);
|
||||||
{tool.description && (
|
const hiddenParameterCount =
|
||||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
parameters.length - visibleParameters.length;
|
||||||
{tool.description}
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${tool.name}-${index}`}
|
||||||
|
className="rounded-lg border bg-background p-4 transition-colors hover:border-primary/40 hover:bg-muted/20"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<Wrench className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate font-mono text-sm font-semibold">
|
||||||
|
{tool.name}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="h-5 shrink-0 px-1.5">
|
||||||
|
#{index + 1}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-4 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{tool.description || t('market.noDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<Braces className="size-3.5" />
|
||||||
|
<span>
|
||||||
|
{t('mcp.parameterCount', {
|
||||||
|
count: parameters.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visibleParameters.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{visibleParameters.map((parameter) => (
|
||||||
|
<span
|
||||||
|
key={parameter.name}
|
||||||
|
title={parameter.description || parameter.name}
|
||||||
|
className="inline-flex max-w-full items-center gap-1 rounded-md border bg-muted/40 px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<span className="truncate font-mono">
|
||||||
|
{parameter.name}
|
||||||
|
</span>
|
||||||
|
{parameter.type && (
|
||||||
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
{parameter.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{parameter.required && (
|
||||||
|
<span className="shrink-0 text-destructive">*</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{hiddenParameterCount > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-md border bg-muted/40 px-2 py-1 text-xs text-muted-foreground">
|
||||||
|
+{hiddenParameterCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('mcp.noParameters')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
||||||
|
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
||||||
|
{t('mcp.noToolsFound')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConnected =
|
||||||
|
!mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED;
|
||||||
|
const tools = runtimeInfo.tools || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium">{t('mcp.title')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isConnected
|
||||||
|
? t('mcp.toolCount', { count: tools.length })
|
||||||
|
: t('mcp.connectionFailedStatus')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isConnected && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t('mcp.toolCount', { count: tools.length })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isConnected && (
|
||||||
|
<div className="rounded-md bg-muted/40 p-3">
|
||||||
|
<StatusDisplay testing={mcpTesting} runtimeInfo={runtimeInfo} t={t} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isConnected && tools.length > 0 && <ToolsList tools={tools} t={t} />}
|
||||||
|
|
||||||
|
{isConnected && tools.length === 0 && (
|
||||||
|
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
||||||
|
{t('mcp.noToolsFound')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFormSchema = (t: TFunction) =>
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -165,9 +330,11 @@ interface MCPFormProps {
|
|||||||
onDraftChange?: (draft: MCPFormDraft) => void;
|
onDraftChange?: (draft: MCPFormDraft) => void;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
onTestingChange?: (testing: boolean) => void;
|
onTestingChange?: (testing: boolean) => void;
|
||||||
|
layout?: 'stacked' | 'split';
|
||||||
|
sideHeader?: ReactNode;
|
||||||
|
sideFooter?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle exposed to parent via ref
|
|
||||||
export interface MCPFormHandle {
|
export interface MCPFormHandle {
|
||||||
testMcp: () => void;
|
testMcp: () => void;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
@@ -182,6 +349,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
onDraftChange,
|
onDraftChange,
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
onTestingChange,
|
onTestingChange,
|
||||||
|
layout = 'stacked',
|
||||||
|
sideHeader,
|
||||||
|
sideFooter,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -205,9 +375,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track whether initial data loading is complete (to avoid marking form dirty)
|
|
||||||
const isInitializing = useRef(true);
|
const isInitializing = useRef(true);
|
||||||
|
|
||||||
const [extraArgs, setExtraArgs] = useState<
|
const [extraArgs, setExtraArgs] = useState<
|
||||||
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
|
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -217,21 +385,17 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const watchMode = form.watch('mode');
|
const watchMode = form.watch('mode');
|
||||||
|
|
||||||
// Notify parent when dirty state changes
|
|
||||||
const { isDirty } = form.formState;
|
const { isDirty } = form.formState;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDirtyChange?.(isDirty);
|
onDirtyChange?.(isDirty);
|
||||||
}, [isDirty, onDirtyChange]);
|
}, [isDirty, onDirtyChange]);
|
||||||
|
|
||||||
// Notify parent when testing state changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onTestingChange?.(mcpTesting);
|
onTestingChange?.(mcpTesting);
|
||||||
}, [mcpTesting, onTestingChange]);
|
}, [mcpTesting, onTestingChange]);
|
||||||
|
|
||||||
// Expose test action and testing state to parent
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
@@ -241,7 +405,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
[mcpTesting],
|
[mcpTesting],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load server data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isInitializing.current = true;
|
isInitializing.current = true;
|
||||||
if (isEditMode && initServerName) {
|
if (isEditMode && initServerName) {
|
||||||
@@ -288,7 +451,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [form, isEditMode, onDraftChange, extraArgs, stdioArgs]);
|
}, [form, isEditMode, onDraftChange, extraArgs, stdioArgs]);
|
||||||
|
|
||||||
// Poll for updates when runtime_info status is CONNECTING
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!isEditMode ||
|
!isEditMode ||
|
||||||
@@ -323,7 +485,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
const server = resp.server ?? resp;
|
const server = resp.server ?? resp;
|
||||||
|
|
||||||
const formValues: FormValues = {
|
const formValues: FormValues = {
|
||||||
name: server.name.replace(/__/g, '/'), // Convert __ back to / for display
|
name: server.name.replace(/__/g, '/'),
|
||||||
mode: server.mode,
|
mode: server.mode,
|
||||||
url: '',
|
url: '',
|
||||||
command: '',
|
command: '',
|
||||||
@@ -379,15 +541,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
|
|
||||||
setExtraArgs(newExtraArgs);
|
setExtraArgs(newExtraArgs);
|
||||||
setStdioArgs(newStdioArgs);
|
setStdioArgs(newStdioArgs);
|
||||||
|
|
||||||
// Use form.reset so isDirty stays false after initial load
|
|
||||||
form.reset(formValues);
|
form.reset(formValues);
|
||||||
|
setRuntimeInfo(server.runtime_info ?? null);
|
||||||
if (server.runtime_info) {
|
|
||||||
setRuntimeInfo(server.runtime_info);
|
|
||||||
} else {
|
|
||||||
setRuntimeInfo(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load server:', error);
|
console.error('Failed to load server:', error);
|
||||||
toast.error(t('mcp.loadFailed'));
|
toast.error(t('mcp.loadFailed'));
|
||||||
@@ -411,7 +566,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
enable: true,
|
enable: true,
|
||||||
extra_args: {
|
extra_args: {
|
||||||
url: value.url!,
|
url: value.url!,
|
||||||
headers: headers,
|
headers,
|
||||||
timeout: value.timeout,
|
timeout: value.timeout,
|
||||||
ssereadtimeout: value.ssereadtimeout,
|
ssereadtimeout: value.ssereadtimeout,
|
||||||
},
|
},
|
||||||
@@ -423,7 +578,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
enable: true,
|
enable: true,
|
||||||
extra_args: {
|
extra_args: {
|
||||||
url: value.url!,
|
url: value.url!,
|
||||||
headers: headers,
|
headers,
|
||||||
timeout: value.timeout,
|
timeout: value.timeout,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -433,7 +588,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
value.extra_args?.forEach((arg) => {
|
value.extra_args?.forEach((arg) => {
|
||||||
env[arg.key] = String(arg.value);
|
env[arg.key] = String(arg.value);
|
||||||
});
|
});
|
||||||
const args = value.args?.map((arg) => arg.value) || [];
|
|
||||||
|
|
||||||
serverConfig = {
|
serverConfig = {
|
||||||
name: value.name,
|
name: value.name,
|
||||||
@@ -441,8 +595,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
enable: true,
|
enable: true,
|
||||||
extra_args: {
|
extra_args: {
|
||||||
command: value.command!,
|
command: value.command!,
|
||||||
args: args,
|
args: value.args?.map((arg) => arg.value) || [],
|
||||||
env: env,
|
env,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -450,7 +604,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
if (isEditMode && initServerName) {
|
if (isEditMode && initServerName) {
|
||||||
await httpClient.updateMCPServer(initServerName, serverConfig);
|
await httpClient.updateMCPServer(initServerName, serverConfig);
|
||||||
toast.success(t('mcp.updateSuccess'));
|
toast.success(t('mcp.updateSuccess'));
|
||||||
// Reset dirty baseline to current values
|
|
||||||
form.reset(form.getValues());
|
form.reset(form.getValues());
|
||||||
onFormSubmit();
|
onFormSubmit();
|
||||||
} else {
|
} else {
|
||||||
@@ -504,7 +657,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
|
|
||||||
const { task_id } = await httpClient.testMCPServer('_', {
|
const { task_id } = await httpClient.testMCPServer('_', {
|
||||||
name: form.getValues('name'),
|
name: form.getValues('name'),
|
||||||
mode: mode,
|
mode,
|
||||||
enable: true,
|
enable: true,
|
||||||
extra_args: extraArgsData,
|
extra_args: extraArgsData,
|
||||||
} as MCPServer);
|
} as MCPServer);
|
||||||
@@ -604,121 +757,108 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
|
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const configSection = (
|
||||||
<Form {...form}>
|
<Card>
|
||||||
<form
|
<CardHeader>
|
||||||
id="mcp-form"
|
<CardTitle>
|
||||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')}
|
||||||
className="space-y-5"
|
</CardTitle>
|
||||||
>
|
<CardDescription>{t('mcp.extraParametersDescription')}</CardDescription>
|
||||||
{/* Runtime info: status + tools (edit mode only) */}
|
</CardHeader>
|
||||||
{isEditMode && runtimeInfo && (
|
<CardContent className="space-y-4">
|
||||||
<section className="space-y-3">
|
<FormField
|
||||||
<h3 className="text-sm font-medium">{t('mcp.title')}</h3>
|
control={form.control}
|
||||||
{(mcpTesting ||
|
name="name"
|
||||||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
|
render={({ field }) => (
|
||||||
<div className="rounded-md bg-muted/40 p-3">
|
<FormItem>
|
||||||
<StatusDisplay
|
<FormLabel>
|
||||||
testing={mcpTesting}
|
{t('mcp.name')}
|
||||||
runtimeInfo={runtimeInfo}
|
<span className="text-destructive">*</span>
|
||||||
t={t}
|
</FormLabel>
|
||||||
/>
|
<FormControl>
|
||||||
</div>
|
<Input {...field} disabled={isEditMode} />
|
||||||
)}
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
{!mcpTesting &&
|
</FormItem>
|
||||||
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
|
|
||||||
runtimeInfo.tools?.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
{t('mcp.toolCount', {
|
|
||||||
count: runtimeInfo.tools?.length || 0,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<ToolsList tools={runtimeInfo.tools} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Server configuration */}
|
|
||||||
<section className="space-y-4">
|
|
||||||
{isEditMode && (
|
|
||||||
<h3 className="text-sm font-medium">{t('mcp.editServer')}</h3>
|
|
||||||
)}
|
)}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
|
||||||
name="name"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="mode"
|
||||||
<FormLabel>
|
render={({ field }) => (
|
||||||
{t('mcp.name')}
|
<FormItem>
|
||||||
<span className="text-destructive">*</span>
|
<FormLabel>{t('mcp.serverMode')}</FormLabel>
|
||||||
</FormLabel>
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} disabled={isEditMode} />
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('mcp.selectMode')} />
|
||||||
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<SelectContent>
|
||||||
</FormItem>
|
<SelectItem value="http">{t('mcp.http')}</SelectItem>
|
||||||
)}
|
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
|
||||||
/>
|
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
{(watchMode === 'sse' || watchMode === 'http') && (
|
||||||
control={form.control}
|
<>
|
||||||
name="mode"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="url"
|
||||||
<FormLabel>{t('mcp.serverMode')}</FormLabel>
|
render={({ field }) => (
|
||||||
<Select
|
<FormItem>
|
||||||
onValueChange={field.onChange}
|
<FormLabel>
|
||||||
defaultValue={field.value}
|
{t('mcp.url')}
|
||||||
value={field.value}
|
<span className="text-destructive">*</span>
|
||||||
>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<Input {...field} />
|
||||||
<SelectValue placeholder={t('mcp.selectMode')} />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<FormMessage />
|
||||||
<SelectItem value="http">{t('mcp.http')}</SelectItem>
|
</FormItem>
|
||||||
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
|
)}
|
||||||
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
|
/>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(watchMode === 'sse' || watchMode === 'http') && (
|
<FormField
|
||||||
<>
|
control={form.control}
|
||||||
|
name="timeout"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('mcp.timeout')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={t('mcp.timeout')}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{watchMode === 'sse' && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="url"
|
name="ssereadtimeout"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
|
||||||
{t('mcp.url')}
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="timeout"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('mcp.timeout')}</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder={t('mcp.timeout')}
|
placeholder={t('mcp.sseTimeoutDescription')}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
@@ -727,126 +867,147 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{watchMode === 'sse' && (
|
{watchMode === 'stdio' && (
|
||||||
<FormField
|
<>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="ssereadtimeout"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="command"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>
|
||||||
<Input
|
{t('mcp.command')}
|
||||||
type="number"
|
<span className="text-destructive">*</span>
|
||||||
placeholder={t('mcp.sseTimeoutDescription')}
|
</FormLabel>
|
||||||
{...field}
|
<FormControl>
|
||||||
onChange={(e) =>
|
<Input {...field} />
|
||||||
field.onChange(Number(e.target.value))
|
</FormControl>
|
||||||
}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{watchMode === 'stdio' && (
|
<FormItem>
|
||||||
<>
|
<FormLabel>{t('mcp.args')}</FormLabel>
|
||||||
<FormField
|
<div className="space-y-2">
|
||||||
control={form.control}
|
{stdioArgs.map((arg, index) => (
|
||||||
name="command"
|
<div key={index} className="flex gap-2">
|
||||||
render={({ field }) => (
|
<Input
|
||||||
<FormItem>
|
placeholder={t('mcp.args')}
|
||||||
<FormLabel>
|
value={arg.value}
|
||||||
{t('mcp.command')}
|
onChange={(e) => updateStdioArg(index, e.target.value)}
|
||||||
<span className="text-destructive">*</span>
|
/>
|
||||||
</FormLabel>
|
<Button
|
||||||
<FormControl>
|
type="button"
|
||||||
<Input {...field} />
|
variant="ghost"
|
||||||
</FormControl>
|
size="icon"
|
||||||
<FormMessage />
|
className="shrink-0 text-red-500 hover:text-red-600"
|
||||||
</FormItem>
|
onClick={() => removeStdioArg(index)}
|
||||||
)}
|
>
|
||||||
/>
|
<Trash2 className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" variant="outline" onClick={addStdioArg}>
|
||||||
|
{t('mcp.addArgument')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('mcp.args')}</FormLabel>
|
<FormLabel>
|
||||||
<div className="space-y-2">
|
{watchMode === 'sse' || watchMode === 'http'
|
||||||
{stdioArgs.map((arg, index) => (
|
? t('mcp.headers')
|
||||||
<div key={index} className="flex gap-2">
|
: t('mcp.env')}
|
||||||
<Input
|
</FormLabel>
|
||||||
placeholder={t('mcp.args')}
|
<div className="space-y-2">
|
||||||
value={arg.value}
|
{extraArgs.map((arg, index) => (
|
||||||
onChange={(e) => updateStdioArg(index, e.target.value)}
|
<div key={index} className="flex gap-2">
|
||||||
/>
|
<Input
|
||||||
<Button
|
placeholder={t('models.keyName')}
|
||||||
type="button"
|
value={arg.key}
|
||||||
variant="ghost"
|
onChange={(e) => updateExtraArg(index, 'key', e.target.value)}
|
||||||
size="icon"
|
/>
|
||||||
className="shrink-0 text-red-500 hover:text-red-600"
|
<Input
|
||||||
onClick={() => removeStdioArg(index)}
|
placeholder={t('models.value')}
|
||||||
>
|
value={arg.value}
|
||||||
<Trash2 className="w-5 h-5" />
|
onChange={(e) =>
|
||||||
</Button>
|
updateExtraArg(index, 'value', e.target.value)
|
||||||
</div>
|
}
|
||||||
))}
|
/>
|
||||||
<Button type="button" variant="outline" onClick={addStdioArg}>
|
<Button
|
||||||
{t('mcp.addArgument')}
|
type="button"
|
||||||
</Button>
|
variant="ghost"
|
||||||
</div>
|
size="icon"
|
||||||
</FormItem>
|
className="shrink-0 text-red-500 hover:text-red-600"
|
||||||
</>
|
onClick={() => removeExtraArg(index)}
|
||||||
)}
|
>
|
||||||
|
<Trash2 className="size-5" />
|
||||||
<FormItem>
|
</Button>
|
||||||
<FormLabel>
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" variant="outline" onClick={addExtraArg}>
|
||||||
{watchMode === 'sse' || watchMode === 'http'
|
{watchMode === 'sse' || watchMode === 'http'
|
||||||
? t('mcp.headers')
|
? t('mcp.addHeader')
|
||||||
: t('mcp.env')}
|
: t('mcp.addEnvVar')}
|
||||||
</FormLabel>
|
</Button>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
{extraArgs.map((arg, index) => (
|
<FormDescription>
|
||||||
<div key={index} className="flex gap-2">
|
{t('mcp.extraParametersDescription')}
|
||||||
<Input
|
</FormDescription>
|
||||||
placeholder={t('models.keyName')}
|
<FormMessage />
|
||||||
value={arg.key}
|
</FormItem>
|
||||||
onChange={(e) =>
|
</CardContent>
|
||||||
updateExtraArg(index, 'key', e.target.value)
|
</Card>
|
||||||
}
|
);
|
||||||
/>
|
|
||||||
<Input
|
const runtimePanel = (
|
||||||
placeholder={t('models.value')}
|
<RuntimePanel
|
||||||
value={arg.value}
|
isEditMode={isEditMode}
|
||||||
onChange={(e) =>
|
mcpTesting={mcpTesting}
|
||||||
updateExtraArg(index, 'value', e.target.value)
|
runtimeInfo={runtimeInfo}
|
||||||
}
|
t={t}
|
||||||
/>
|
/>
|
||||||
<Button
|
);
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
if (layout === 'split') {
|
||||||
size="icon"
|
return (
|
||||||
className="shrink-0 text-red-500 hover:text-red-600"
|
<Form {...form}>
|
||||||
onClick={() => removeExtraArg(index)}
|
<form
|
||||||
>
|
id="mcp-form"
|
||||||
<Trash2 className="w-5 h-5" />
|
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||||
</Button>
|
className="flex h-full min-h-0 max-w-full flex-col gap-6 overflow-y-auto lg:flex-row lg:overflow-hidden"
|
||||||
</div>
|
>
|
||||||
))}
|
<div className="space-y-5 pb-6 lg:min-h-0 lg:w-[360px] lg:flex-shrink-0 lg:overflow-y-auto lg:overflow-x-hidden xl:w-[400px]">
|
||||||
<Button type="button" variant="outline" onClick={addExtraArg}>
|
{sideHeader}
|
||||||
{watchMode === 'sse' || watchMode === 'http'
|
{configSection}
|
||||||
? t('mcp.addHeader')
|
{sideFooter}
|
||||||
: t('mcp.addEnvVar')}
|
</div>
|
||||||
</Button>
|
<div className="hidden w-px shrink-0 bg-border lg:block" />
|
||||||
</div>
|
<div className="min-w-0 flex-1 pb-6 lg:min-h-0 lg:overflow-y-auto lg:overflow-x-hidden">
|
||||||
<FormDescription>
|
{runtimePanel}
|
||||||
{t('mcp.extraParametersDescription')}
|
</div>
|
||||||
</FormDescription>
|
</form>
|
||||||
<FormMessage />
|
</Form>
|
||||||
</FormItem>
|
);
|
||||||
</section>
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="mcp-form"
|
||||||
|
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||||
|
className="space-y-5"
|
||||||
|
>
|
||||||
|
{sideHeader}
|
||||||
|
{runtimePanel}
|
||||||
|
{configSection}
|
||||||
|
{sideFooter}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
||||||
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
|
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 { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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.
|
* Plugin detail page content.
|
||||||
@@ -12,7 +36,11 @@ import { Bug } from 'lucide-react';
|
|||||||
*/
|
*/
|
||||||
export default function PluginDetailContent({ id }: { id: string }) {
|
export default function PluginDetailContent({ id }: { id: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
|
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
|
||||||
|
const [pluginInfo, setPluginInfo] = useState<Plugin | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [deleteData, setDeleteData] = useState(false);
|
||||||
|
|
||||||
// Parse "author/name" composite key
|
// Parse "author/name" composite key
|
||||||
const slashIndex = id.indexOf('/');
|
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 pluginName = slashIndex >= 0 ? id.substring(slashIndex + 1) : id;
|
||||||
|
|
||||||
const plugin = plugins.find((p) => p.id === 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
|
// Set breadcrumb entity name
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -27,6 +72,18 @@ export default function PluginDetailContent({ id }: { id: string }) {
|
|||||||
return () => setDetailEntityName(null);
|
return () => setDetailEntityName(null);
|
||||||
}, [plugin, pluginAuthor, pluginName, setDetailEntityName]);
|
}, [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) {
|
function handleFormSubmit(timeout?: number) {
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
setTimeout(() => {
|
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 ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 border-orange-400 text-[0.7rem] text-orange-400"
|
||||||
|
>
|
||||||
|
<Bug className="size-3.5" />
|
||||||
|
{t('plugins.debugging')}
|
||||||
|
</Badge>
|
||||||
|
) : plugin?.installSource === 'github' ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 border-blue-400 text-[0.7rem] text-blue-400"
|
||||||
|
>
|
||||||
|
{t('plugins.fromGithub')}
|
||||||
|
</Badge>
|
||||||
|
) : plugin?.installSource === 'local' ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 border-green-400 text-[0.7rem] text-green-400"
|
||||||
|
>
|
||||||
|
{t('plugins.fromLocal')}
|
||||||
|
</Badge>
|
||||||
|
) : plugin?.installSource === 'marketplace' ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 border-purple-400 text-[0.7rem] text-purple-400"
|
||||||
|
>
|
||||||
|
{t('plugins.fromMarketplace')}
|
||||||
|
</Badge>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const componentBadges = pluginInfo && (
|
||||||
|
<PluginComponentList
|
||||||
|
components={pluginInfo.components.reduce<Record<string, number>>(
|
||||||
|
(acc, component) => {
|
||||||
|
const kind = component.manifest.manifest.kind;
|
||||||
|
acc[kind] = (acc[kind] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)}
|
||||||
|
showComponentName
|
||||||
|
showTitle={false}
|
||||||
|
useBadge
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dangerZone = (
|
||||||
|
<Card className="border-destructive/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">
|
||||||
|
{t('plugins.dangerZone')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t('plugins.dangerZoneDescription')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">{t('plugins.deletePlugin')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('plugins.confirmDeletePlugin', {
|
||||||
|
author: pluginAuthor,
|
||||||
|
name: pluginName,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 size-4" />
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<>
|
||||||
<div className="flex items-center gap-3 pb-4 shrink-0">
|
<div className="flex h-full flex-col">
|
||||||
<h1 className="text-xl font-semibold">
|
<div className="flex shrink-0 flex-col gap-2 pb-4">
|
||||||
{pluginAuthor}/{pluginName}
|
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||||
</h1>
|
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||||
{plugin?.debug ? (
|
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
|
||||||
<Badge
|
<Puzzle className="size-3.5" />
|
||||||
variant="outline"
|
{t('market.typePlugin')}
|
||||||
className="text-[0.7rem] border-orange-400 text-orange-400"
|
</Badge>
|
||||||
>
|
{sourceBadge}
|
||||||
<Bug className="size-3.5" />
|
{componentBadges}
|
||||||
{t('plugins.debugging')}
|
</div>
|
||||||
</Badge>
|
{description && (
|
||||||
) : plugin?.installSource === 'github' ? (
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
<Badge
|
{description}
|
||||||
variant="outline"
|
</p>
|
||||||
className="text-[0.7rem] border-blue-400 text-blue-400"
|
)}
|
||||||
>
|
</div>
|
||||||
{t('plugins.fromGithub')}
|
|
||||||
</Badge>
|
<div className="flex min-h-0 max-w-full flex-1 flex-col gap-6 overflow-y-auto md:flex-row md:overflow-hidden">
|
||||||
) : plugin?.installSource === 'local' ? (
|
<div className="space-y-4 pb-6 md:min-h-0 md:w-[380px] md:flex-shrink-0 md:overflow-y-auto md:overflow-x-hidden xl:w-[420px]">
|
||||||
<Badge
|
<PluginForm
|
||||||
variant="outline"
|
pluginAuthor={pluginAuthor}
|
||||||
className="text-[0.7rem] border-green-400 text-green-400"
|
pluginName={pluginName}
|
||||||
>
|
onFormSubmit={handleFormSubmit}
|
||||||
{t('plugins.fromLocal')}
|
/>
|
||||||
</Badge>
|
{dangerZone}
|
||||||
) : plugin?.installSource === 'marketplace' ? (
|
</div>
|
||||||
<Badge
|
<div className="hidden w-px shrink-0 bg-border md:block" />
|
||||||
variant="outline"
|
<div className="min-w-0 flex-1 pb-6 md:min-h-0 md:overflow-y-auto md:overflow-x-hidden">
|
||||||
className="text-[0.7rem] border-purple-400 text-purple-400"
|
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />
|
||||||
>
|
</div>
|
||||||
{t('plugins.fromMarketplace')}
|
</div>
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col md:flex-row overflow-hidden min-h-0 gap-6 max-w-full">
|
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
{/* Left side - Config */}
|
<DialogContent>
|
||||||
<div className="md:w-[380px] md:flex-shrink-0 overflow-y-auto overflow-x-hidden">
|
<DialogHeader>
|
||||||
<PluginForm
|
<DialogTitle>{t('plugins.deleteConfirm')}</DialogTitle>
|
||||||
pluginAuthor={pluginAuthor}
|
<DialogDescription>
|
||||||
pluginName={pluginName}
|
{asyncTask.status === AsyncTaskStatus.RUNNING
|
||||||
onFormSubmit={handleFormSubmit}
|
? t('plugins.deleting')
|
||||||
/>
|
: t('plugins.confirmDeletePlugin', {
|
||||||
</div>
|
author: pluginAuthor,
|
||||||
{/* Divider */}
|
name: pluginName,
|
||||||
<div className="hidden md:block w-px bg-border shrink-0" />
|
})}
|
||||||
{/* Right side - Readme */}
|
</DialogDescription>
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden min-w-0">
|
</DialogHeader>
|
||||||
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />
|
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||||
</div>
|
<div className="flex items-center space-x-2">
|
||||||
</div>
|
<Checkbox
|
||||||
</div>
|
id="delete-plugin-data"
|
||||||
|
checked={deleteData}
|
||||||
|
onCheckedChange={(checked) => setDeleteData(checked === true)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="delete-plugin-data"
|
||||||
|
className="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{t('plugins.deleteDataCheckbox')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{asyncTask.status === AsyncTaskStatus.ERROR && (
|
||||||
|
<div className="text-sm text-destructive">{asyncTask.error}</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||||
|
<Button variant="destructive" onClick={executeDelete}>
|
||||||
|
{t('common.confirmDelete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
||||||
|
<Button variant="destructive" disabled>
|
||||||
|
{t('plugins.deleting')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{asyncTask.status === AsyncTaskStatus.ERROR && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
asyncTask.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('plugins.close')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import { Plugin } from '@/app/infra/entities/plugin';
|
|||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
|
||||||
|
|
||||||
export default function PluginForm({
|
export default function PluginForm({
|
||||||
pluginAuthor,
|
pluginAuthor,
|
||||||
@@ -137,65 +143,34 @@ export default function PluginForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<Card>
|
||||||
<div className="text-lg font-medium">
|
<CardHeader>
|
||||||
{extractI18nObject(pluginInfo.manifest.manifest.metadata.label)}
|
<CardTitle>{t('plugins.pluginConfig')}</CardTitle>
|
||||||
</div>
|
<CardDescription>{t('plugins.saveConfig')}</CardDescription>
|
||||||
<div className="text-sm text-gray-500 pb-2">
|
</CardHeader>
|
||||||
{extractI18nObject(
|
<CardContent>
|
||||||
pluginInfo.manifest.manifest.metadata.description ?? {
|
{pluginInfo.manifest.manifest.spec.config.length > 0 ? (
|
||||||
en_US: '',
|
<DynamicFormComponent
|
||||||
zh_Hans: '',
|
itemConfigList={pluginInfo.manifest.manifest.spec.config}
|
||||||
},
|
initialValues={pluginConfig.config as Record<string, object>}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
// 只保存表单值的引用,不触发状态更新
|
||||||
|
currentFormValues.current = values;
|
||||||
|
}}
|
||||||
|
onFileUploaded={(fileKey) => {
|
||||||
|
// 追踪上传的文件
|
||||||
|
uploadedFileKeys.current.add(fileKey);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t('plugins.pluginNoConfig')}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
|
||||||
<div className="mb-4 flex flex-row items-center justify-start gap-[0.4rem]">
|
|
||||||
<PluginComponentList
|
|
||||||
components={(() => {
|
|
||||||
const componentKindCount: Record<string, number> = {};
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
|
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
|
||||||
<DynamicFormComponent
|
<CardFooter className="justify-end">
|
||||||
itemConfigList={pluginInfo.manifest.manifest.spec.config}
|
|
||||||
initialValues={pluginConfig.config as Record<string, object>}
|
|
||||||
onSubmit={(values) => {
|
|
||||||
// 只保存表单值的引用,不触发状态更新
|
|
||||||
currentFormValues.current = values;
|
|
||||||
}}
|
|
||||||
onFileUploaded={(fileKey) => {
|
|
||||||
// 追踪上传的文件
|
|
||||||
uploadedFileKeys.current.add(fileKey);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{pluginInfo.manifest.manifest.spec.config.length === 0 && (
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{t('plugins.pluginNoConfig')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
|
|
||||||
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
onClick={() => handleSubmit()}
|
onClick={() => handleSubmit()}
|
||||||
@@ -203,9 +178,9 @@ export default function PluginForm({
|
|||||||
>
|
>
|
||||||
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
|
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</CardFooter>
|
||||||
</div>
|
)}
|
||||||
)}
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
||||||
|
import { Sparkles, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function SkillDetailContent({ id }: { id: string }) {
|
export default function SkillDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
@@ -27,16 +29,16 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
|
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const skill = skills.find((item) => item.id === id);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
setDetailEntityName(t('skills.createSkill'));
|
setDetailEntityName(t('skills.createSkill'));
|
||||||
} else {
|
} else {
|
||||||
const skill = skills.find((item) => item.id === id);
|
|
||||||
setDetailEntityName(skill?.name ?? id);
|
setDetailEntityName(skill?.name ?? id);
|
||||||
}
|
}
|
||||||
return () => setDetailEntityName(null);
|
return () => setDetailEntityName(null);
|
||||||
}, [id, isCreateMode, setDetailEntityName, skills, t]);
|
}, [id, isCreateMode, setDetailEntityName, skill, t]);
|
||||||
|
|
||||||
function handleImportedSkills(skillNames: string[]) {
|
function handleImportedSkills(skillNames: string[]) {
|
||||||
void refreshSkills();
|
void refreshSkills();
|
||||||
@@ -67,78 +69,101 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
|||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<h1 className="text-xl font-semibold">{t('skills.createSkill')}</h1>
|
<div className="min-w-0 space-y-1">
|
||||||
<Button type="submit" form="skill-form">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<h1 className="truncate text-xl font-semibold">
|
||||||
|
{t('skills.createSkill')}
|
||||||
|
</h1>
|
||||||
|
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
|
||||||
|
<Sparkles className="size-3.5" />
|
||||||
|
{t('skills.title')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" form="skill-form" className="shrink-0">
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="min-h-0 flex-1">
|
||||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
<SkillForm
|
||||||
<SkillForm
|
key="new-skill"
|
||||||
key="new-skill"
|
initSkillName={undefined}
|
||||||
initSkillName={undefined}
|
layout="split"
|
||||||
onNewSkillCreated={(skillName) =>
|
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
|
||||||
handleImportedSkills([skillName])
|
onSkillUpdated={() => {}}
|
||||||
}
|
/>
|
||||||
onSkillUpdated={() => {}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editActions = (
|
||||||
|
<Card className="border-destructive/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">
|
||||||
|
{t('skills.dangerZone')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t('skills.dangerZoneDescription')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">{t('skills.delete')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('skills.deleteConfirmation')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 size-4" />
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<h1 className="text-xl font-semibold">{t('skills.editSkill')}</h1>
|
<div className="min-w-0 space-y-1">
|
||||||
<Button type="submit" form="skill-form">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<h1 className="truncate text-xl font-semibold">
|
||||||
|
{skill?.name ?? id}
|
||||||
|
</h1>
|
||||||
|
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
|
||||||
|
<Sparkles className="size-3.5" />
|
||||||
|
{t('skills.title')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{skill?.description && (
|
||||||
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{skill.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" form="skill-form" className="shrink-0">
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="min-h-0 flex-1">
|
||||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
<SkillForm
|
||||||
<SkillForm
|
key={id}
|
||||||
key={id}
|
initSkillName={id}
|
||||||
initSkillName={id}
|
layout="split"
|
||||||
onNewSkillCreated={(skillName) =>
|
sideFooter={editActions}
|
||||||
handleImportedSkills([skillName])
|
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
|
||||||
}
|
onSkillUpdated={handleSkillUpdated}
|
||||||
onSkillUpdated={handleSkillUpdated}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<Card className="border-destructive/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-destructive">
|
|
||||||
{t('skills.dangerZone')}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('skills.dangerZoneDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium">{t('common.delete')}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('skills.deleteConfirmation')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
|
||||||
>
|
|
||||||
{t('common.delete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import {
|
||||||
|
type FormEvent,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { FolderSearch, ChevronDown, ChevronRight, File, Folder, FolderOpen, RefreshCw } from 'lucide-react';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
FolderSearch,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
File,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Skill } from '@/app/infra/entities/api';
|
import { Skill } from '@/app/infra/entities/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -15,6 +33,8 @@ interface SkillFormProps {
|
|||||||
onNewSkillCreated: (skillName: string) => void;
|
onNewSkillCreated: (skillName: string) => void;
|
||||||
onSkillUpdated: (skillName: string) => void;
|
onSkillUpdated: (skillName: string) => void;
|
||||||
onDraftChange?: (draft: SkillFormDraft) => void;
|
onDraftChange?: (draft: SkillFormDraft) => void;
|
||||||
|
layout?: 'stacked' | 'split';
|
||||||
|
sideFooter?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillFormDraft {
|
export interface SkillFormDraft {
|
||||||
@@ -30,27 +50,42 @@ interface FileEntry {
|
|||||||
size: number | null;
|
size: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DirectoryContent {
|
interface FileTreeProps {
|
||||||
path: string;
|
skillName: string;
|
||||||
entries: FileEntry[];
|
selectedFile?: string | null;
|
||||||
|
onFileSelect: (path: string, content: string) => void;
|
||||||
|
onLoadingChange?: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileTreeHandle {
|
||||||
|
refresh: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileTreeProps {
|
function getFileName(path: string) {
|
||||||
skillName: string;
|
return path.split('/').pop() || path;
|
||||||
onFileSelect: (path: string, content: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
|
||||||
|
{ skillName, selectedFile, onFileSelect, onLoadingChange },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [rootEntries, setRootEntries] = useState<FileEntry[]>([]);
|
const [rootEntries, setRootEntries] = useState<FileEntry[]>([]);
|
||||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||||
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(new Map());
|
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedPath(selectedFile ?? null);
|
||||||
|
}, [selectedFile]);
|
||||||
|
|
||||||
const loadRootFiles = useCallback(async () => {
|
const loadRootFiles = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
onLoadingChange?.(true);
|
||||||
try {
|
try {
|
||||||
const result = await httpClient.listSkillFiles(skillName, '.');
|
const result = await httpClient.listSkillFiles(skillName, '.');
|
||||||
setRootEntries(result.entries);
|
setRootEntries(result.entries);
|
||||||
@@ -59,27 +94,31 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
|||||||
toast.error(t('skills.loadFilesError') + String(error));
|
toast.error(t('skills.loadFilesError') + String(error));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
onLoadingChange?.(false);
|
||||||
}
|
}
|
||||||
}, [skillName, t]);
|
}, [skillName, t, onLoadingChange]);
|
||||||
|
|
||||||
const loadDirFiles = useCallback(async (dirPath: string) => {
|
const loadDirFiles = useCallback(
|
||||||
setDirContents(prev => {
|
async (dirPath: string) => {
|
||||||
const newMap = new Map(prev);
|
setDirContents((prev) => {
|
||||||
newMap.set(dirPath, []); // Clear while loading
|
|
||||||
return newMap;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const result = await httpClient.listSkillFiles(skillName, dirPath);
|
|
||||||
setDirContents(prev => {
|
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
newMap.set(dirPath, result.entries);
|
newMap.set(dirPath, []); // Clear while loading
|
||||||
return newMap;
|
return newMap;
|
||||||
});
|
});
|
||||||
} catch (error) {
|
try {
|
||||||
console.error('Failed to load directory files:', error);
|
const result = await httpClient.listSkillFiles(skillName, dirPath);
|
||||||
toast.error(t('skills.loadFilesError') + String(error));
|
setDirContents((prev) => {
|
||||||
}
|
const newMap = new Map(prev);
|
||||||
}, [skillName, t]);
|
newMap.set(dirPath, result.entries);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load directory files:', error);
|
||||||
|
toast.error(t('skills.loadFilesError') + String(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[skillName, t],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (skillName) {
|
if (skillName) {
|
||||||
@@ -87,6 +126,15 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
|||||||
}
|
}
|
||||||
}, [skillName, loadRootFiles]);
|
}, [skillName, loadRootFiles]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
refresh: loadRootFiles,
|
||||||
|
loading,
|
||||||
|
}),
|
||||||
|
[loadRootFiles, loading],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleDir = async (dirPath: string) => {
|
const toggleDir = async (dirPath: string) => {
|
||||||
const newExpanded = new Set(expandedDirs);
|
const newExpanded = new Set(expandedDirs);
|
||||||
if (newExpanded.has(dirPath)) {
|
if (newExpanded.has(dirPath)) {
|
||||||
@@ -110,7 +158,10 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderEntry = (entry: FileEntry, depth: number = 0): React.ReactNode => {
|
const renderEntry = (
|
||||||
|
entry: FileEntry,
|
||||||
|
depth: number = 0,
|
||||||
|
): React.ReactNode => {
|
||||||
const isExpanded = expandedDirs.has(entry.path);
|
const isExpanded = expandedDirs.has(entry.path);
|
||||||
const isSelected = selectedPath === entry.path;
|
const isSelected = selectedPath === entry.path;
|
||||||
|
|
||||||
@@ -121,7 +172,9 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
|||||||
isSelected ? 'bg-muted' : ''
|
isSelected ? 'bg-muted' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||||
onClick={() => entry.is_dir ? toggleDir(entry.path) : handleFileClick(entry.path)}
|
onClick={() =>
|
||||||
|
entry.is_dir ? toggleDir(entry.path) : handleFileClick(entry.path)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{entry.is_dir ? (
|
{entry.is_dir ? (
|
||||||
<>
|
<>
|
||||||
@@ -141,14 +194,18 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
|||||||
)}
|
)}
|
||||||
<span className="text-sm truncate">{entry.name}</span>
|
<span className="text-sm truncate">{entry.name}</span>
|
||||||
{!entry.is_dir && entry.size !== null && (
|
{!entry.is_dir && entry.size !== null && (
|
||||||
<span className="text-xs text-muted-foreground ml-auto">
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
{entry.size > 1024 ? `${Math.round(entry.size / 1024)}KB` : `${entry.size}B`}
|
{entry.size > 1024
|
||||||
|
? `${Math.round(entry.size / 1024)}KB`
|
||||||
|
: `${entry.size}B`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{entry.is_dir && isExpanded && (
|
{entry.is_dir && isExpanded && (
|
||||||
<div>
|
<div>
|
||||||
{(dirContents.get(entry.path) || []).map((child) => renderEntry(child, depth + 1))}
|
{(dirContents.get(entry.path) || []).map((child) =>
|
||||||
|
renderEntry(child, depth + 1),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,19 +213,8 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-md p-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||||
<span className="text-sm font-medium">{t('skills.files')}</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => loadRootFiles()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
||||||
{rootEntries.length === 0 && !loading && (
|
{rootEntries.length === 0 && !loading && (
|
||||||
<div className="text-sm text-muted-foreground py-2">
|
<div className="text-sm text-muted-foreground py-2">
|
||||||
{t('skills.noFiles')}
|
{t('skills.noFiles')}
|
||||||
@@ -178,7 +224,7 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const emptySkillDraft: SkillFormDraft = {
|
const emptySkillDraft: SkillFormDraft = {
|
||||||
skill: {
|
skill: {
|
||||||
@@ -197,6 +243,8 @@ export default function SkillForm({
|
|||||||
onNewSkillCreated,
|
onNewSkillCreated,
|
||||||
onSkillUpdated,
|
onSkillUpdated,
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
|
layout = 'stacked',
|
||||||
|
sideFooter,
|
||||||
}: SkillFormProps) {
|
}: SkillFormProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const initialDraftRef = useRef(initialDraft ?? emptySkillDraft);
|
const initialDraftRef = useRef(initialDraft ?? emptySkillDraft);
|
||||||
@@ -209,12 +257,15 @@ export default function SkillForm({
|
|||||||
);
|
);
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
const [fileContent, setFileContent] = useState<string>('');
|
const [fileContent, setFileContent] = useState<string>('');
|
||||||
|
const fileTreeRef = useRef<FileTreeHandle>(null);
|
||||||
|
const [fileTreeLoading, setFileTreeLoading] = useState(false);
|
||||||
|
|
||||||
const loadSkill = useCallback(
|
const loadSkill = useCallback(
|
||||||
async (skillName: string) => {
|
async (skillName: string) => {
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getSkill(skillName);
|
const resp = await httpClient.getSkill(skillName);
|
||||||
setSkill(resp.skill);
|
setSkill(resp.skill);
|
||||||
|
setSelectedFile('SKILL.md');
|
||||||
setFileContent(resp.skill.instructions || '');
|
setFileContent(resp.skill.instructions || '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load skill:', error);
|
console.error('Failed to load skill:', error);
|
||||||
@@ -229,13 +280,18 @@ export default function SkillForm({
|
|||||||
loadSkill(initSkillName);
|
loadSkill(initSkillName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSelectedFile(initialDraftRef.current.selectedFile ?? null);
|
||||||
setSkill(initialDraftRef.current.skill);
|
setSkill(initialDraftRef.current.skill);
|
||||||
setShowAdvanced(initialDraftRef.current.showAdvanced);
|
setShowAdvanced(initialDraftRef.current.showAdvanced);
|
||||||
}, [initSkillName, loadSkill]);
|
}, [initSkillName, loadSkill]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initSkillName) return;
|
if (initSkillName) return;
|
||||||
onDraftChange?.({ skill, showAdvanced, selectedFile: selectedFile || undefined });
|
onDraftChange?.({
|
||||||
|
skill,
|
||||||
|
showAdvanced,
|
||||||
|
selectedFile: selectedFile || undefined,
|
||||||
|
});
|
||||||
}, [initSkillName, onDraftChange, skill, showAdvanced, selectedFile]);
|
}, [initSkillName, onDraftChange, skill, showAdvanced, selectedFile]);
|
||||||
|
|
||||||
async function scanDirectory() {
|
async function scanDirectory() {
|
||||||
@@ -294,7 +350,7 @@ export default function SkillForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!skill.name?.trim()) {
|
if (!skill.name?.trim()) {
|
||||||
@@ -335,8 +391,8 @@ export default function SkillForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const metadataFields = (
|
||||||
<form id="skill-form" onSubmit={handleSubmit} className="space-y-4">
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="display_name">{t('skills.displayName')}</Label>
|
<Label htmlFor="display_name">{t('skills.displayName')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -377,27 +433,43 @@ export default function SkillForm({
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
{/* File tree for existing skills */}
|
const fileTreeSection = (
|
||||||
|
<>
|
||||||
{initSkillName && (
|
{initSkillName && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FileTree skillName={initSkillName} onFileSelect={handleFileSelect} />
|
<FileTree
|
||||||
|
skillName={initSkillName}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="space-y-2">
|
const instructionEditor = (showLabel = true) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{showLabel && (
|
||||||
<Label htmlFor="instructions">
|
<Label htmlFor="instructions">
|
||||||
{selectedFile ? `${t('skills.skillInstructions')} (${selectedFile})` : t('skills.skillInstructions')}
|
{selectedFile
|
||||||
|
? getFileName(selectedFile)
|
||||||
|
: t('skills.skillInstructions')}
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
)}
|
||||||
id="instructions"
|
<Textarea
|
||||||
value={fileContent}
|
id="instructions"
|
||||||
onChange={(e) => handleContentChange(e.target.value)}
|
value={fileContent}
|
||||||
placeholder={t('skills.instructionsPlaceholder')}
|
onChange={(e) => handleContentChange(e.target.value)}
|
||||||
rows={16}
|
placeholder={t('skills.instructionsPlaceholder')}
|
||||||
className="font-mono text-sm"
|
rows={16}
|
||||||
/>
|
className="min-h-[360px] resize-y font-mono text-sm lg:min-h-[calc(100vh-220px)]"
|
||||||
{selectedFile && selectedFile !== 'SKILL.md' && !selectedFile.endsWith('/SKILL.md') && (
|
/>
|
||||||
|
{selectedFile &&
|
||||||
|
selectedFile !== 'SKILL.md' &&
|
||||||
|
!selectedFile.endsWith('/SKILL.md') && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -407,58 +479,116 @@ export default function SkillForm({
|
|||||||
{t('skills.saveFile')}
|
{t('skills.saveFile')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="space-y-3">
|
const advancedSettings = (
|
||||||
<button
|
<div className="space-y-2">
|
||||||
|
<Label>{t('skills.packageRoot')}</Label>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={skill.package_root || ''}
|
||||||
|
onChange={(e) => setSkill({ ...skill, package_root: e.target.value })}
|
||||||
|
placeholder={`data/skills/${skill.name || '<skill-name>'}/`}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={Boolean(initSkillName)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left text-sm font-medium hover:bg-muted/70"
|
variant="outline"
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
size="sm"
|
||||||
|
onClick={scanDirectory}
|
||||||
|
disabled={
|
||||||
|
Boolean(initSkillName) || scanning || !skill.package_root?.trim()
|
||||||
|
}
|
||||||
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
{t('skills.advancedSettings')}
|
<FolderSearch className="mr-1 h-4 w-4" />
|
||||||
{showAdvanced ? (
|
{scanning ? t('common.loading') : t('skills.scan')}
|
||||||
<ChevronDown className="h-4 w-4" />
|
</Button>
|
||||||
) : (
|
</div>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<p className="text-xs text-muted-foreground">
|
||||||
)}
|
{t('skills.packageRootHelp')}
|
||||||
</button>
|
</p>
|
||||||
{showAdvanced && (
|
</div>
|
||||||
<div className="space-y-4">
|
);
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('skills.packageRoot')}</Label>
|
if (layout === 'split') {
|
||||||
<div className="flex gap-2">
|
return (
|
||||||
<Input
|
<form
|
||||||
value={skill.package_root || ''}
|
id="skill-form"
|
||||||
onChange={(e) =>
|
onSubmit={handleSubmit}
|
||||||
setSkill({ ...skill, package_root: e.target.value })
|
className="flex h-full min-h-0 max-w-full flex-col gap-6 overflow-y-auto lg:flex-row lg:overflow-hidden"
|
||||||
}
|
>
|
||||||
placeholder={`data/skills/${skill.name || '<skill-name>'}/`}
|
<div className="space-y-4 pb-6 lg:min-h-0 lg:w-[360px] lg:flex-shrink-0 lg:overflow-y-auto lg:overflow-x-hidden xl:w-[400px]">
|
||||||
className="flex-1"
|
<Card>
|
||||||
disabled={Boolean(initSkillName)}
|
<CardHeader>
|
||||||
/>
|
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">{metadataFields}</CardContent>
|
||||||
|
</Card>
|
||||||
|
{initSkillName && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle>{t('skills.files')}</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={scanDirectory}
|
onClick={() => fileTreeRef.current?.refresh()}
|
||||||
disabled={
|
disabled={fileTreeLoading}
|
||||||
Boolean(initSkillName) ||
|
className="size-8"
|
||||||
scanning ||
|
|
||||||
!skill.package_root?.trim()
|
|
||||||
}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
>
|
||||||
<FolderSearch className="h-4 w-4 mr-1" />
|
<RefreshCw
|
||||||
{scanning ? t('common.loading') : t('skills.scan')}
|
className={`h-4 w-4 ${fileTreeLoading ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</CardHeader>
|
||||||
<p className="text-xs text-muted-foreground">
|
<CardContent>
|
||||||
{t('skills.packageRootHelp')}
|
<FileTree
|
||||||
</p>
|
ref={fileTreeRef}
|
||||||
</div>
|
skillName={initSkillName}
|
||||||
</div>
|
selectedFile={selectedFile}
|
||||||
)}
|
onFileSelect={handleFileSelect}
|
||||||
</div>
|
onLoadingChange={setFileTreeLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('skills.advancedSettings')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{advancedSettings}</CardContent>
|
||||||
|
</Card>
|
||||||
|
{sideFooter}
|
||||||
|
</div>
|
||||||
|
<div className="hidden w-px shrink-0 bg-border lg:block" />
|
||||||
|
<div className="min-w-0 flex-1 pb-6 lg:min-h-0 lg:overflow-y-auto lg:overflow-x-hidden">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{selectedFile
|
||||||
|
? getFileName(selectedFile)
|
||||||
|
: initSkillName
|
||||||
|
? 'SKILL.md'
|
||||||
|
: t('skills.skillInstructions')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{instructionEditor(false)}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form id="skill-form" onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{metadataFields}
|
||||||
|
{fileTreeSection}
|
||||||
|
{instructionEditor()}
|
||||||
|
{advancedSettings}
|
||||||
|
{sideFooter}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,17 +64,14 @@ export default function SkillsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="min-h-0 flex-1">
|
||||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
<SkillForm
|
||||||
<SkillForm
|
key="new-skill"
|
||||||
key="new-skill"
|
initSkillName={undefined}
|
||||||
initSkillName={undefined}
|
layout="split"
|
||||||
onNewSkillCreated={(skillName) =>
|
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
|
||||||
handleImportedSkills([skillName])
|
onSkillUpdated={() => {}}
|
||||||
}
|
/>
|
||||||
onSkillUpdated={() => {}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -126,4 +123,4 @@ export default function SkillsPage() {
|
|||||||
|
|
||||||
navigate('/home/add-extension');
|
navigate('/home/add-extension');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -506,6 +506,8 @@ const enUS = {
|
|||||||
close: 'Close',
|
close: 'Close',
|
||||||
deleteConfirm: 'Delete Confirmation',
|
deleteConfirm: 'Delete Confirmation',
|
||||||
deleteSuccess: 'Delete successful',
|
deleteSuccess: 'Delete successful',
|
||||||
|
dangerZone: 'Danger Zone',
|
||||||
|
dangerZoneDescription: 'Irreversible and destructive actions',
|
||||||
modifyFailed: 'Modify failed: ',
|
modifyFailed: 'Modify failed: ',
|
||||||
componentName: {
|
componentName: {
|
||||||
Tool: 'Tool',
|
Tool: 'Tool',
|
||||||
@@ -736,6 +738,8 @@ const enUS = {
|
|||||||
loadFailed: 'Load failed',
|
loadFailed: 'Load failed',
|
||||||
modifyFailed: 'Modify failed: ',
|
modifyFailed: 'Modify failed: ',
|
||||||
toolCount: 'Tools: {{count}}',
|
toolCount: 'Tools: {{count}}',
|
||||||
|
parameterCount: 'Parameters: {{count}}',
|
||||||
|
noParameters: 'No parameters',
|
||||||
statusConnected: 'Connected',
|
statusConnected: 'Connected',
|
||||||
statusDisconnected: 'Disconnected',
|
statusDisconnected: 'Disconnected',
|
||||||
statusError: 'Connection Error',
|
statusError: 'Connection Error',
|
||||||
|
|||||||
@@ -519,6 +519,8 @@ const esES = {
|
|||||||
close: 'Cerrar',
|
close: 'Cerrar',
|
||||||
deleteConfirm: 'Confirmación de eliminación',
|
deleteConfirm: 'Confirmación de eliminación',
|
||||||
deleteSuccess: 'Eliminación exitosa',
|
deleteSuccess: 'Eliminación exitosa',
|
||||||
|
dangerZone: 'Zona de peligro',
|
||||||
|
dangerZoneDescription: 'Acciones irreversibles y destructivas',
|
||||||
modifyFailed: 'Error al modificar: ',
|
modifyFailed: 'Error al modificar: ',
|
||||||
componentName: {
|
componentName: {
|
||||||
Tool: 'Herramienta',
|
Tool: 'Herramienta',
|
||||||
@@ -744,6 +746,8 @@ const esES = {
|
|||||||
loadFailed: 'Error al cargar',
|
loadFailed: 'Error al cargar',
|
||||||
modifyFailed: 'Error al modificar: ',
|
modifyFailed: 'Error al modificar: ',
|
||||||
toolCount: 'Herramientas: {{count}}',
|
toolCount: 'Herramientas: {{count}}',
|
||||||
|
parameterCount: 'Parámetros: {{count}}',
|
||||||
|
noParameters: 'Sin parámetros',
|
||||||
statusConnected: 'Conectado',
|
statusConnected: 'Conectado',
|
||||||
statusDisconnected: 'Desconectado',
|
statusDisconnected: 'Desconectado',
|
||||||
statusError: 'Error de conexión',
|
statusError: 'Error de conexión',
|
||||||
|
|||||||
@@ -510,6 +510,8 @@ const jaJP = {
|
|||||||
close: '閉じる',
|
close: '閉じる',
|
||||||
deleteConfirm: '削除の確認',
|
deleteConfirm: '削除の確認',
|
||||||
deleteSuccess: '削除に成功しました',
|
deleteSuccess: '削除に成功しました',
|
||||||
|
dangerZone: '危険ゾーン',
|
||||||
|
dangerZoneDescription: '取り消しできない操作です',
|
||||||
modifyFailed: '変更に失敗しました:',
|
modifyFailed: '変更に失敗しました:',
|
||||||
componentName: {
|
componentName: {
|
||||||
Tool: 'ツール',
|
Tool: 'ツール',
|
||||||
@@ -734,6 +736,8 @@ const jaJP = {
|
|||||||
loadFailed: '読み込みに失敗しました',
|
loadFailed: '読み込みに失敗しました',
|
||||||
modifyFailed: '変更に失敗しました:',
|
modifyFailed: '変更に失敗しました:',
|
||||||
toolCount: 'ツール:{{count}}',
|
toolCount: 'ツール:{{count}}',
|
||||||
|
parameterCount: 'パラメータ:{{count}}',
|
||||||
|
noParameters: 'パラメータなし',
|
||||||
statusConnected: '接続済み',
|
statusConnected: '接続済み',
|
||||||
statusDisconnected: '未接続',
|
statusDisconnected: '未接続',
|
||||||
statusError: '接続エラー',
|
statusError: '接続エラー',
|
||||||
|
|||||||
@@ -515,6 +515,8 @@ const ruRU = {
|
|||||||
close: 'Закрыть',
|
close: 'Закрыть',
|
||||||
deleteConfirm: 'Подтверждение удаления',
|
deleteConfirm: 'Подтверждение удаления',
|
||||||
deleteSuccess: 'Удаление успешно',
|
deleteSuccess: 'Удаление успешно',
|
||||||
|
dangerZone: 'Опасная зона',
|
||||||
|
dangerZoneDescription: 'Необратимые и разрушительные действия',
|
||||||
modifyFailed: 'Ошибка изменения: ',
|
modifyFailed: 'Ошибка изменения: ',
|
||||||
componentName: {
|
componentName: {
|
||||||
Tool: 'Инструмент',
|
Tool: 'Инструмент',
|
||||||
@@ -536,8 +538,7 @@ const ruRU = {
|
|||||||
selectFileToUpload: 'Выберите файл плагина для загрузки',
|
selectFileToUpload: 'Выберите файл плагина для загрузки',
|
||||||
askConfirm:
|
askConfirm:
|
||||||
'Вы уверены, что хотите установить плагин "{{name}}" ({{version}})?',
|
'Вы уверены, что хотите установить плагин "{{name}}" ({{version}})?',
|
||||||
askConfirmNoVersion:
|
askConfirmNoVersion: 'Вы уверены, что хотите установить плагин "{{name}}"?',
|
||||||
'Вы уверены, что хотите установить плагин "{{name}}"?',
|
|
||||||
fromGithub: 'С GitHub',
|
fromGithub: 'С GitHub',
|
||||||
fromLocal: 'Из локального файла',
|
fromLocal: 'Из локального файла',
|
||||||
fromMarketplace: 'Из маркетплейса',
|
fromMarketplace: 'Из маркетплейса',
|
||||||
@@ -740,6 +741,8 @@ const ruRU = {
|
|||||||
loadFailed: 'Ошибка загрузки',
|
loadFailed: 'Ошибка загрузки',
|
||||||
modifyFailed: 'Ошибка изменения: ',
|
modifyFailed: 'Ошибка изменения: ',
|
||||||
toolCount: 'Инструменты: {{count}}',
|
toolCount: 'Инструменты: {{count}}',
|
||||||
|
parameterCount: 'Параметры: {{count}}',
|
||||||
|
noParameters: 'Нет параметров',
|
||||||
statusConnected: 'Подключён',
|
statusConnected: 'Подключён',
|
||||||
statusDisconnected: 'Отключён',
|
statusDisconnected: 'Отключён',
|
||||||
statusError: 'Ошибка подключения',
|
statusError: 'Ошибка подключения',
|
||||||
|
|||||||
@@ -500,6 +500,8 @@ const thTH = {
|
|||||||
close: 'ปิด',
|
close: 'ปิด',
|
||||||
deleteConfirm: 'ยืนยันการลบ',
|
deleteConfirm: 'ยืนยันการลบ',
|
||||||
deleteSuccess: 'ลบสำเร็จ',
|
deleteSuccess: 'ลบสำเร็จ',
|
||||||
|
dangerZone: 'พื้นที่อันตราย',
|
||||||
|
dangerZoneDescription: 'การดำเนินการที่ไม่สามารถย้อนกลับได้',
|
||||||
modifyFailed: 'แก้ไขล้มเหลว: ',
|
modifyFailed: 'แก้ไขล้มเหลว: ',
|
||||||
componentName: {
|
componentName: {
|
||||||
Tool: 'เครื่องมือ',
|
Tool: 'เครื่องมือ',
|
||||||
@@ -720,6 +722,8 @@ const thTH = {
|
|||||||
loadFailed: 'โหลดล้มเหลว',
|
loadFailed: 'โหลดล้มเหลว',
|
||||||
modifyFailed: 'แก้ไขล้มเหลว: ',
|
modifyFailed: 'แก้ไขล้มเหลว: ',
|
||||||
toolCount: 'เครื่องมือ: {{count}}',
|
toolCount: 'เครื่องมือ: {{count}}',
|
||||||
|
parameterCount: 'พารามิเตอร์: {{count}}',
|
||||||
|
noParameters: 'ไม่มีพารามิเตอร์',
|
||||||
statusConnected: 'เชื่อมต่อแล้ว',
|
statusConnected: 'เชื่อมต่อแล้ว',
|
||||||
statusDisconnected: 'ไม่ได้เชื่อมต่อ',
|
statusDisconnected: 'ไม่ได้เชื่อมต่อ',
|
||||||
statusError: 'ข้อผิดพลาดการเชื่อมต่อ',
|
statusError: 'ข้อผิดพลาดการเชื่อมต่อ',
|
||||||
|
|||||||
@@ -511,6 +511,8 @@ const viVN = {
|
|||||||
close: 'Đóng',
|
close: 'Đóng',
|
||||||
deleteConfirm: 'Xác nhận xóa',
|
deleteConfirm: 'Xác nhận xóa',
|
||||||
deleteSuccess: 'Xóa thành công',
|
deleteSuccess: 'Xóa thành công',
|
||||||
|
dangerZone: 'Vùng nguy hiểm',
|
||||||
|
dangerZoneDescription: 'Các thao tác không thể hoàn tác',
|
||||||
modifyFailed: 'Sửa đổi thất bại: ',
|
modifyFailed: 'Sửa đổi thất bại: ',
|
||||||
componentName: {
|
componentName: {
|
||||||
Tool: 'Công cụ',
|
Tool: 'Công cụ',
|
||||||
@@ -532,7 +534,8 @@ const viVN = {
|
|||||||
selectFileToUpload: 'Chọn tệp plugin để tải lên',
|
selectFileToUpload: 'Chọn tệp plugin để tải lên',
|
||||||
askConfirm:
|
askConfirm:
|
||||||
'Bạn có chắc chắn muốn cài đặt plugin "{{name}}" ({{version}}) không?',
|
'Bạn có chắc chắn muốn cài đặt plugin "{{name}}" ({{version}}) không?',
|
||||||
askConfirmNoVersion: 'Bạn có chắc chắn muốn cài đặt plugin "{{name}}" không?',
|
askConfirmNoVersion:
|
||||||
|
'Bạn có chắc chắn muốn cài đặt plugin "{{name}}" không?',
|
||||||
fromGithub: 'Từ GitHub',
|
fromGithub: 'Từ GitHub',
|
||||||
fromLocal: 'Từ cục bộ',
|
fromLocal: 'Từ cục bộ',
|
||||||
fromMarketplace: 'Từ chợ ứng dụng',
|
fromMarketplace: 'Từ chợ ứng dụng',
|
||||||
@@ -733,6 +736,8 @@ const viVN = {
|
|||||||
loadFailed: 'Tải thất bại',
|
loadFailed: 'Tải thất bại',
|
||||||
modifyFailed: 'Sửa đổi thất bại: ',
|
modifyFailed: 'Sửa đổi thất bại: ',
|
||||||
toolCount: 'Công cụ: {{count}}',
|
toolCount: 'Công cụ: {{count}}',
|
||||||
|
parameterCount: 'Tham số: {{count}}',
|
||||||
|
noParameters: 'Không có tham số',
|
||||||
statusConnected: 'Đã kết nối',
|
statusConnected: 'Đã kết nối',
|
||||||
statusDisconnected: 'Đã ngắt kết nối',
|
statusDisconnected: 'Đã ngắt kết nối',
|
||||||
statusError: 'Lỗi kết nối',
|
statusError: 'Lỗi kết nối',
|
||||||
|
|||||||
@@ -483,6 +483,8 @@ const zhHans = {
|
|||||||
close: '关闭',
|
close: '关闭',
|
||||||
deleteConfirm: '删除确认',
|
deleteConfirm: '删除确认',
|
||||||
deleteSuccess: '删除成功',
|
deleteSuccess: '删除成功',
|
||||||
|
dangerZone: '危险区域',
|
||||||
|
dangerZoneDescription: '不可逆的操作',
|
||||||
modifyFailed: '修改失败:',
|
modifyFailed: '修改失败:',
|
||||||
componentName: {
|
componentName: {
|
||||||
Tool: '工具',
|
Tool: '工具',
|
||||||
@@ -708,6 +710,8 @@ const zhHans = {
|
|||||||
loadFailed: '加载失败',
|
loadFailed: '加载失败',
|
||||||
modifyFailed: '修改失败:',
|
modifyFailed: '修改失败:',
|
||||||
toolCount: '工具:{{count}}',
|
toolCount: '工具:{{count}}',
|
||||||
|
parameterCount: '参数:{{count}}',
|
||||||
|
noParameters: '无参数',
|
||||||
statusConnected: '已打开',
|
statusConnected: '已打开',
|
||||||
statusDisconnected: '未打开',
|
statusDisconnected: '未打开',
|
||||||
statusError: '连接错误',
|
statusError: '连接错误',
|
||||||
|
|||||||
@@ -484,6 +484,8 @@ const zhHant = {
|
|||||||
close: '關閉',
|
close: '關閉',
|
||||||
deleteConfirm: '刪除確認',
|
deleteConfirm: '刪除確認',
|
||||||
deleteSuccess: '刪除成功',
|
deleteSuccess: '刪除成功',
|
||||||
|
dangerZone: '危險區域',
|
||||||
|
dangerZoneDescription: '不可逆的操作',
|
||||||
modifyFailed: '修改失敗:',
|
modifyFailed: '修改失敗:',
|
||||||
componentName: {
|
componentName: {
|
||||||
Tool: '工具',
|
Tool: '工具',
|
||||||
@@ -701,6 +703,8 @@ const zhHant = {
|
|||||||
loadFailed: '載入失敗',
|
loadFailed: '載入失敗',
|
||||||
modifyFailed: '修改失敗:',
|
modifyFailed: '修改失敗:',
|
||||||
toolCount: '工具:{{count}}',
|
toolCount: '工具:{{count}}',
|
||||||
|
parameterCount: '參數:{{count}}',
|
||||||
|
noParameters: '無參數',
|
||||||
statusConnected: '已開啟',
|
statusConnected: '已開啟',
|
||||||
statusDisconnected: '未開啟',
|
statusDisconnected: '未開啟',
|
||||||
statusError: '連接錯誤',
|
statusError: '連接錯誤',
|
||||||
|
|||||||
Reference in New Issue
Block a user