feat: polish extension detail pages

This commit is contained in:
Junyan Qin
2026-05-15 14:41:23 +08:00
parent d5ce3b302e
commit ae11bce8b6
18 changed files with 1232 additions and 645 deletions
@@ -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 ? (
+101 -85
View File
@@ -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>
); );
+250 -54
View File
@@ -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>
); );
} }
+83 -58
View File
@@ -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>
); );
} }
+9 -12
View File
@@ -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;
} }
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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: '接続エラー',
+5 -2
View File
@@ -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: 'Ошибка подключения',
+4
View File
@@ -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: 'ข้อผิดพลาดการเชื่อมต่อ',
+6 -1
View File
@@ -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',
+4
View File
@@ -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: '连接错误',
+4
View File
@@ -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: '連接錯誤',