mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-08 23:06:03 +00:00
feat: support github skill installation
This commit is contained in:
@@ -31,6 +31,7 @@ import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
import type { Skill } from '@/app/infra/entities/api';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
||||
import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||
@@ -51,6 +52,8 @@ enum GithubInstallStatus {
|
||||
SELECT_ASSET = 'select_asset',
|
||||
ASK_CONFIRM = 'ask_confirm',
|
||||
INSTALLING = 'installing',
|
||||
SKILL_PREVIEW = 'skill_preview',
|
||||
SKILL_INSTALLING = 'skill_installing',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
@@ -73,6 +76,53 @@ interface GithubAsset {
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
interface GithubSkillMdInfo {
|
||||
owner: string;
|
||||
repo: string;
|
||||
ref: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function isGithubSkillMdUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
const url = new URL(rawUrl.trim());
|
||||
return url.pathname.toLowerCase().endsWith('/skill.md');
|
||||
} catch {
|
||||
return rawUrl.trim().toLowerCase().split('?', 1)[0].endsWith('skill.md');
|
||||
}
|
||||
}
|
||||
|
||||
function parseGithubSkillMdUrl(rawUrl: string): GithubSkillMdInfo {
|
||||
const url = new URL(rawUrl.trim());
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (url.hostname === 'github.com') {
|
||||
if (parts.length < 5 || parts[2] !== 'blob') {
|
||||
throw new Error('Invalid GitHub SKILL.md URL');
|
||||
}
|
||||
return {
|
||||
owner: parts[0],
|
||||
repo: parts[1],
|
||||
ref: parts[3],
|
||||
path: parts.slice(4).join('/'),
|
||||
};
|
||||
}
|
||||
|
||||
if (url.hostname === 'raw.githubusercontent.com') {
|
||||
if (parts.length < 4) {
|
||||
throw new Error('Invalid GitHub SKILL.md URL');
|
||||
}
|
||||
return {
|
||||
owner: parts[0],
|
||||
repo: parts[1],
|
||||
ref: parts[2],
|
||||
path: parts.slice(3).join('/'),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Invalid GitHub SKILL.md URL');
|
||||
}
|
||||
|
||||
enum PluginInstallStatus {
|
||||
ASK_CONFIRM = 'ask_confirm',
|
||||
INSTALLING = 'installing',
|
||||
@@ -137,6 +187,12 @@ function AddExtensionContent() {
|
||||
const [githubRepo, setGithubRepo] = useState('');
|
||||
const [fetchingReleases, setFetchingReleases] = useState(false);
|
||||
const [fetchingAssets, setFetchingAssets] = useState(false);
|
||||
const [fetchingSkillPreview, setFetchingSkillPreview] = useState(false);
|
||||
const [githubSkillInfo, setGithubSkillInfo] =
|
||||
useState<GithubSkillMdInfo | null>(null);
|
||||
const [githubSkillPreview, setGithubSkillPreview] = useState<Skill | null>(
|
||||
null,
|
||||
);
|
||||
const [githubInstallStatus, setGithubInstallStatus] =
|
||||
useState<GithubInstallStatus>(GithubInstallStatus.WAIT_INPUT);
|
||||
const [githubInstallError, setGithubInstallError] = useState<string | null>(
|
||||
@@ -324,12 +380,15 @@ function AddExtensionContent() {
|
||||
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
|
||||
if (maxExtensions < 0) return true;
|
||||
try {
|
||||
const [pluginsResp, mcpResp] = await Promise.all([
|
||||
const [pluginsResp, mcpResp, skillsResp] = await Promise.all([
|
||||
httpClient.getPlugins(),
|
||||
httpClient.getMCPServers(),
|
||||
httpClient.getSkills(),
|
||||
]);
|
||||
const total =
|
||||
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
|
||||
(pluginsResp.plugins?.length ?? 0) +
|
||||
(mcpResp.servers?.length ?? 0) +
|
||||
(skillsResp.skills?.length ?? 0);
|
||||
if (total >= maxExtensions) {
|
||||
toast.error(
|
||||
t('limitation.maxExtensionsReached', { max: maxExtensions }),
|
||||
@@ -352,10 +411,21 @@ function AddExtensionContent() {
|
||||
setGithubRepo('');
|
||||
setFetchingReleases(false);
|
||||
setFetchingAssets(false);
|
||||
setFetchingSkillPreview(false);
|
||||
setGithubSkillInfo(null);
|
||||
setGithubSkillPreview(null);
|
||||
setGithubInstallStatus(GithubInstallStatus.WAIT_INPUT);
|
||||
setGithubInstallError(null);
|
||||
}
|
||||
|
||||
async function handleGithubAddressSubmit() {
|
||||
if (isGithubSkillMdUrl(githubURL)) {
|
||||
await previewGithubSkillMd();
|
||||
return;
|
||||
}
|
||||
await fetchGithubReleases();
|
||||
}
|
||||
|
||||
async function fetchGithubReleases() {
|
||||
if (!githubURL.trim()) {
|
||||
toast.error(t('plugins.enterRepoUrl'));
|
||||
@@ -364,6 +434,8 @@ function AddExtensionContent() {
|
||||
|
||||
setFetchingReleases(true);
|
||||
setGithubInstallError(null);
|
||||
setGithubSkillInfo(null);
|
||||
setGithubSkillPreview(null);
|
||||
|
||||
try {
|
||||
const result = await httpClient.getGithubReleases(githubURL);
|
||||
@@ -386,6 +458,46 @@ function AddExtensionContent() {
|
||||
}
|
||||
}
|
||||
|
||||
async function previewGithubSkillMd() {
|
||||
if (!githubURL.trim()) {
|
||||
toast.error(t('addExtension.githubUrlRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingSkillPreview(true);
|
||||
setGithubInstallError(null);
|
||||
setGithubReleases([]);
|
||||
setGithubAssets([]);
|
||||
setSelectedRelease(null);
|
||||
setSelectedAsset(null);
|
||||
|
||||
try {
|
||||
const skillInfo = parseGithubSkillMdUrl(githubURL);
|
||||
const result = await httpClient.previewSkillInstallFromGithub(
|
||||
githubURL.trim(),
|
||||
skillInfo.owner,
|
||||
skillInfo.repo,
|
||||
skillInfo.ref,
|
||||
);
|
||||
const preview = result.skills?.[0];
|
||||
if (!preview) {
|
||||
throw new Error(t('addExtension.noSkillPreviewFound'));
|
||||
}
|
||||
setGithubOwner(skillInfo.owner);
|
||||
setGithubRepo(skillInfo.repo);
|
||||
setGithubSkillInfo(skillInfo);
|
||||
setGithubSkillPreview(preview);
|
||||
setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setGithubInstallError(errorMessage || t('skills.previewLoadError'));
|
||||
setGithubInstallStatus(GithubInstallStatus.ERROR);
|
||||
} finally {
|
||||
setFetchingSkillPreview(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReleaseSelect(release: GithubRelease) {
|
||||
setSelectedRelease(release);
|
||||
setFetchingAssets(true);
|
||||
@@ -455,6 +567,35 @@ function AddExtensionContent() {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGithubSkillConfirm() {
|
||||
if (!githubSkillInfo) return;
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
|
||||
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
|
||||
try {
|
||||
await httpClient.installSkillFromGithub(
|
||||
githubURL.trim(),
|
||||
githubSkillInfo.owner,
|
||||
githubSkillInfo.repo,
|
||||
githubSkillInfo.ref,
|
||||
);
|
||||
toast.success(t('skills.installSuccess'));
|
||||
refreshPlugins();
|
||||
refreshSkills();
|
||||
resetGithubState();
|
||||
setPopoverOpen(false);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === 'object' && err && 'msg' in err
|
||||
? String((err as { msg?: string }).msg || '')
|
||||
: String(err);
|
||||
setGithubInstallError(errorMessage);
|
||||
setGithubInstallStatus(GithubInstallStatus.ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
@@ -470,7 +611,7 @@ function AddExtensionContent() {
|
||||
case 'skill':
|
||||
return 'w-[calc(100vw-2rem)] sm:w-[560px]';
|
||||
case 'github':
|
||||
return 'w-[calc(100vw-2rem)] sm:w-[480px]';
|
||||
return 'w-[calc(100vw-2rem)] sm:w-[560px]';
|
||||
default:
|
||||
return 'w-[calc(100vw-2rem)] sm:w-[380px]';
|
||||
}
|
||||
@@ -707,7 +848,7 @@ function AddExtensionContent() {
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h4 className="text-sm font-medium leading-none">
|
||||
{t('plugins.installFromGithub')}
|
||||
{t('addExtension.installFromGithub')}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
@@ -715,25 +856,32 @@ function AddExtensionContent() {
|
||||
{githubInstallStatus === GithubInstallStatus.WAIT_INPUT && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('plugins.enterRepoUrl')}
|
||||
{t('addExtension.githubUrlHelp')}
|
||||
</p>
|
||||
<Input
|
||||
placeholder={t('plugins.repoUrlPlaceholder')}
|
||||
placeholder={t('addExtension.githubUrlPlaceholder')}
|
||||
value={githubURL}
|
||||
onChange={(e) => setGithubURL(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') fetchGithubReleases();
|
||||
if (e.key === 'Enter') handleGithubAddressSubmit();
|
||||
}}
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
{t('addExtension.skillMdUrlHelp')}
|
||||
</p>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={fetchGithubReleases}
|
||||
disabled={!githubURL.trim() || fetchingReleases}
|
||||
onClick={handleGithubAddressSubmit}
|
||||
disabled={
|
||||
!githubURL.trim() ||
|
||||
fetchingReleases ||
|
||||
fetchingSkillPreview
|
||||
}
|
||||
>
|
||||
{fetchingReleases && (
|
||||
{(fetchingReleases || fetchingSkillPreview) && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
)}
|
||||
{fetchingReleases
|
||||
{fetchingReleases || fetchingSkillPreview
|
||||
? t('plugins.loading')
|
||||
: t('common.confirm')}
|
||||
</Button>
|
||||
@@ -887,6 +1035,88 @@ function AddExtensionContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{githubInstallStatus === GithubInstallStatus.SKILL_PREVIEW && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium">
|
||||
{t('addExtension.previewSkill')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={() => {
|
||||
setGithubInstallStatus(
|
||||
GithubInstallStatus.WAIT_INPUT,
|
||||
);
|
||||
setGithubSkillInfo(null);
|
||||
setGithubSkillPreview(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3 mr-1" />
|
||||
{t('plugins.backToRepoUrl')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{githubSkillPreview && (
|
||||
<div className="space-y-2 rounded-md bg-muted/40 p-3 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
|
||||
<BookOpen className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{githubSkillPreview.display_name ||
|
||||
githubSkillPreview.name}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{githubSkillPreview.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{githubSkillPreview.description && (
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
{githubSkillPreview.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1 text-[11px] text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">
|
||||
Repository:{' '}
|
||||
</span>
|
||||
{githubSkillInfo?.owner}/{githubSkillInfo?.repo}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">
|
||||
File:{' '}
|
||||
</span>
|
||||
<span className="break-all">
|
||||
{githubSkillInfo?.path}
|
||||
</span>
|
||||
</div>
|
||||
{githubSkillPreview.package_root && (
|
||||
<div>
|
||||
<span className="font-medium text-foreground">
|
||||
Directory:{' '}
|
||||
</span>
|
||||
<span className="break-all">
|
||||
{githubSkillPreview.package_root}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGithubSkillConfirm}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{githubInstallStatus === GithubInstallStatus.INSTALLING && (
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
@@ -894,6 +1124,14 @@ function AddExtensionContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{githubInstallStatus ===
|
||||
GithubInstallStatus.SKILL_INSTALLING && (
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>{t('skills.installing')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{githubInstallStatus === GithubInstallStatus.ERROR && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-destructive">
|
||||
|
||||
@@ -1451,6 +1451,36 @@ function PluginPagesNav() {
|
||||
);
|
||||
}
|
||||
|
||||
function findSidebarChildForPath(pathname: string): SidebarChildVO | undefined {
|
||||
const matchedChild =
|
||||
sidebarConfigList.find((childConfig) => childConfig.route === pathname) ||
|
||||
sidebarConfigList.find((childConfig) =>
|
||||
pathname.startsWith(childConfig.route + '/'),
|
||||
);
|
||||
if (matchedChild) return matchedChild;
|
||||
|
||||
if (
|
||||
pathname === '/home/mcp' ||
|
||||
pathname === '/home/skills' ||
|
||||
pathname === '/home/plugin-pages' ||
|
||||
pathname.startsWith('/home/mcp/') ||
|
||||
pathname.startsWith('/home/skills/') ||
|
||||
pathname.startsWith('/home/plugin-pages/')
|
||||
) {
|
||||
return sidebarConfigList.find(
|
||||
(childConfig) => childConfig.id === 'plugins',
|
||||
);
|
||||
}
|
||||
|
||||
if (pathname === '/home/market' || pathname.startsWith('/home/market/')) {
|
||||
return sidebarConfigList.find(
|
||||
(childConfig) => childConfig.id === 'add-extension',
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function HomeSidebar({
|
||||
onSelectedChangeAction,
|
||||
}: {
|
||||
@@ -1624,14 +1654,7 @@ export default function HomeSidebar({
|
||||
|
||||
function initSelect() {
|
||||
const currentPath = pathname;
|
||||
// Match exact route or sub-routes (e.g., /home/bots/abc-123 matches /home/bots)
|
||||
const matchedChild =
|
||||
sidebarConfigList.find(
|
||||
(childConfig) => childConfig.route === currentPath,
|
||||
) ||
|
||||
sidebarConfigList.find((childConfig) =>
|
||||
currentPath.startsWith(childConfig.route + '/'),
|
||||
);
|
||||
const matchedChild = findSidebarChildForPath(currentPath);
|
||||
if (matchedChild) {
|
||||
// Route already matches — just select without navigating (preserves ?id= query params)
|
||||
selectChild(matchedChild);
|
||||
@@ -1646,12 +1669,7 @@ export default function HomeSidebar({
|
||||
|
||||
function handleRouteChange(pathname: string) {
|
||||
if (!pathname.startsWith('/home')) return;
|
||||
// Match exact route or sub-routes (entity detail pages)
|
||||
const routeSelectChild =
|
||||
sidebarConfigList.find((childConfig) => childConfig.route === pathname) ||
|
||||
sidebarConfigList.find((childConfig) =>
|
||||
pathname.startsWith(childConfig.route + '/'),
|
||||
);
|
||||
const routeSelectChild = findSidebarChildForPath(pathname);
|
||||
if (routeSelectChild) {
|
||||
setSelectedChild(routeSelectChild);
|
||||
onSelectedChangeAction(routeSelectChild);
|
||||
|
||||
@@ -27,6 +27,14 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Server, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type MCPRuntimeState = 'connected' | 'connecting' | 'error';
|
||||
type MCPConnectionState =
|
||||
| 'connected'
|
||||
| 'connecting'
|
||||
| 'error'
|
||||
| 'disabled'
|
||||
| 'disconnected';
|
||||
|
||||
export default function MCPDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
@@ -58,13 +66,55 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
// Enable state managed here so the header switch works
|
||||
const [serverEnabled, setServerEnabled] = useState(true);
|
||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
||||
const [detailRuntimeStatus, setDetailRuntimeStatus] =
|
||||
useState<MCPRuntimeState | null>(null);
|
||||
|
||||
const runtimeStatus = detailRuntimeStatus ?? server?.runtimeStatus;
|
||||
|
||||
const currentConnectionState: MCPConnectionState =
|
||||
(enableLoaded ? serverEnabled : server?.enabled) === false
|
||||
? 'disabled'
|
||||
: runtimeStatus === 'connected' ||
|
||||
runtimeStatus === 'connecting' ||
|
||||
runtimeStatus === 'error'
|
||||
? runtimeStatus
|
||||
: 'disconnected';
|
||||
|
||||
const connectionStatusLabel: Record<MCPConnectionState, string> = {
|
||||
connected: t('mcp.statusConnected'),
|
||||
connecting: t('mcp.connecting'),
|
||||
error: t('mcp.statusError'),
|
||||
disabled: t('mcp.statusDisabled'),
|
||||
disconnected: t('mcp.statusDisconnected'),
|
||||
};
|
||||
|
||||
const connectionStatusClassName: Record<MCPConnectionState, string> = {
|
||||
connected:
|
||||
'border-green-200 bg-green-50 text-green-700 dark:border-green-900/70 dark:bg-green-950/40 dark:text-green-300',
|
||||
connecting:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-300',
|
||||
error:
|
||||
'border-red-200 bg-red-50 text-red-700 dark:border-red-900/70 dark:bg-red-950/40 dark:text-red-300',
|
||||
disabled: 'border-muted-foreground/20 bg-muted text-muted-foreground',
|
||||
disconnected: 'border-muted-foreground/20 bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
const connectionDotClassName: Record<MCPConnectionState, string> = {
|
||||
connected: 'bg-green-500',
|
||||
connecting: 'bg-amber-500',
|
||||
error: 'bg-red-500',
|
||||
disabled: 'bg-muted-foreground/50',
|
||||
disconnected: 'bg-muted-foreground/50',
|
||||
};
|
||||
|
||||
// Fetch server enable state
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
setDetailRuntimeStatus(null);
|
||||
httpClient.getMCPServer(id).then((res) => {
|
||||
const server = res.server ?? res;
|
||||
setServerEnabled(server.enable ?? true);
|
||||
setDetailRuntimeStatus(server.runtime_info?.status ?? null);
|
||||
setEnableLoaded(true);
|
||||
});
|
||||
}
|
||||
@@ -264,6 +314,15 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
<Server className="size-3.5" />
|
||||
{t('mcp.title')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`shrink-0 gap-1.5 text-[0.7rem] ${connectionStatusClassName[currentConnectionState]}`}
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${connectionDotClassName[currentConnectionState]}`}
|
||||
/>
|
||||
{connectionStatusLabel[currentConnectionState]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -292,6 +351,9 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
onTestingChange={setMcpTesting}
|
||||
onRuntimeInfoChange={(runtimeInfo) =>
|
||||
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -330,6 +330,7 @@ interface MCPFormProps {
|
||||
onDraftChange?: (draft: MCPFormDraft) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onTestingChange?: (testing: boolean) => void;
|
||||
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
|
||||
layout?: 'stacked' | 'split';
|
||||
sideHeader?: ReactNode;
|
||||
sideFooter?: ReactNode;
|
||||
@@ -349,6 +350,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
onDraftChange,
|
||||
onDirtyChange,
|
||||
onTestingChange,
|
||||
onRuntimeInfoChange,
|
||||
layout = 'stacked',
|
||||
sideHeader,
|
||||
sideFooter,
|
||||
@@ -396,6 +398,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
onTestingChange?.(mcpTesting);
|
||||
}, [mcpTesting, onTestingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onRuntimeInfoChange?.(runtimeInfo);
|
||||
}, [onRuntimeInfoChange, runtimeInfo]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
|
||||
Reference in New Issue
Block a user