From bf8b51569fcdcd427b1bc9bbf52676a6b1595c52 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 17 May 2026 23:09:10 +0800 Subject: [PATCH] feat: support github skill installation --- .../pkg/api/http/controller/groups/skills.py | 10 +- src/langbot/pkg/api/http/service/skill.py | 142 +++++++++- src/langbot/pkg/box/service.py | 20 +- web/src/app/home/add-extension/page.tsx | 260 +++++++++++++++++- .../components/home-sidebar/HomeSidebar.tsx | 46 +++- web/src/app/home/mcp/MCPDetailContent.tsx | 62 +++++ .../home/mcp/components/mcp-form/MCPForm.tsx | 6 + web/src/i18n/locales/en-US.ts | 18 +- web/src/i18n/locales/zh-Hans.ts | 22 +- web/src/i18n/locales/zh-Hant.ts | 22 +- 10 files changed, 560 insertions(+), 48 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py index a3c941c8..21add5a6 100644 --- a/src/langbot/pkg/api/http/controller/groups/skills.py +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -98,10 +98,13 @@ class SkillsRouterGroup(group.RouterGroup): @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def install_skill_from_github() -> quart.Response: data = await quart.request.json - required_fields = ['asset_url', 'owner', 'repo', 'release_tag'] + required_fields = ['asset_url', 'owner', 'repo'] for field in required_fields: if field not in data or not data[field]: return self.http_status(400, -1, f'Missing required field: {field}') + asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0] + if not asset_url.endswith('skill.md') and not data.get('release_tag'): + return self.http_status(400, -1, 'Missing required field: release_tag') try: skill = await self.ap.skill_service.install_from_github(data) @@ -114,10 +117,13 @@ class SkillsRouterGroup(group.RouterGroup): @self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def preview_skill_from_github() -> quart.Response: data = await quart.request.json - required_fields = ['asset_url', 'owner', 'repo', 'release_tag'] + required_fields = ['asset_url', 'owner', 'repo'] for field in required_fields: if field not in data or not data[field]: return self.http_status(400, -1, f'Missing required field: {field}') + asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0] + if not asset_url.endswith('skill.md') and not data.get('release_tag'): + return self.http_status(400, -1, 'Missing required field: release_tag') try: preview = await self.ap.skill_service.preview_install_from_github(data) diff --git a/src/langbot/pkg/api/http/service/skill.py b/src/langbot/pkg/api/http/service/skill.py index e81e8c39..2a08a298 100644 --- a/src/langbot/pkg/api/http/service/skill.py +++ b/src/langbot/pkg/api/http/service/skill.py @@ -297,7 +297,11 @@ class SkillService: owner = str(data['owner']).strip() repo = str(data['repo']).strip() release_tag = str(data.get('release_tag', '')).strip() - asset_url = self._validate_github_asset_url(data['asset_url'], owner=owner, repo=repo, release_tag=release_tag) + raw_asset_url = str(data['asset_url']).strip() + if self._is_github_skill_md_url(raw_asset_url): + return await self._install_github_skill_md(raw_asset_url, owner=owner, repo=repo, data=data) + + asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag) source_subdir = str(data.get('source_subdir', '') or '').strip() box_service = self._box_service() @@ -335,7 +339,11 @@ class SkillService: owner = str(data['owner']).strip() repo = str(data['repo']).strip() release_tag = str(data.get('release_tag', '')).strip() - asset_url = self._validate_github_asset_url(data['asset_url'], owner=owner, repo=repo, release_tag=release_tag) + raw_asset_url = str(data['asset_url']).strip() + if self._is_github_skill_md_url(raw_asset_url): + return await self._preview_github_skill_md(raw_asset_url, owner=owner, repo=repo) + + asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag) source_subdir = str(data.get('source_subdir', '') or '').strip() box_service = self._box_service() @@ -420,6 +428,63 @@ class SkillService: finally: shutil.rmtree(tmp_dir, ignore_errors=True) + async def _install_github_skill_md(self, asset_url: str, *, owner: str, repo: str, data: dict) -> list[dict]: + zip_bytes, filename, package_name = await self._download_github_skill_md_as_zip( + asset_url, + owner=owner, + repo=repo, + ) + + box_service = self._box_service() + if box_service is not None: + installed = await box_service.install_skill_zip( + zip_bytes, + filename, + source_paths=data.get('source_paths') or [], + source_path=str(data.get('source_path', '') or ''), + target_suffix='', + ) + await self._reload_skills() + return [self._serialize_skill(skill) for skill in installed] + + tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_md_') + try: + skill_root = self._extract_uploaded_skill_to_temp(zip_bytes, tmp_dir) + previews = self._preview_skill_candidates( + skill_root, + base_target_name=package_name, + suffix='', + ) + selected_previews = self._select_preview_candidates(previews, data) + scanned = self._install_preview_candidates(skill_root, selected_previews) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + await self._reload_skills() + return await self._resolve_installed_skills(scanned) + + async def _preview_github_skill_md(self, asset_url: str, *, owner: str, repo: str) -> list[dict]: + zip_bytes, _filename, package_name = await self._download_github_skill_md_as_zip( + asset_url, + owner=owner, + repo=repo, + ) + + box_service = self._box_service() + if box_service is not None: + return await box_service.preview_skill_zip(zip_bytes, f'{package_name}.zip', target_suffix='') + + tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_md_preview_') + try: + skill_root = self._extract_uploaded_skill_to_temp(zip_bytes, tmp_dir) + return self._preview_skill_candidates( + skill_root, + base_target_name=package_name, + suffix='', + ) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + async def reload_skills(self) -> list[dict]: await self._reload_skills() return await self.list_skills() @@ -507,6 +572,31 @@ class SkillService: resp.raise_for_status() return resp.content + async def _download_github_skill_md_as_zip( + self, asset_url: str, *, owner: str, repo: str + ) -> tuple[bytes, str, str]: + info = self._parse_github_skill_md_url(asset_url, owner=owner, repo=repo) + content = await self._download_github_skill_md(info['raw_url']) + package_name = self._resolve_github_skill_md_package_name(content, info['package_name']) + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.writestr(f'{package_name}/SKILL.md', content) + return buffer.getvalue(), f'{package_name}.zip', package_name + + async def _download_github_skill_md(self, raw_url: str) -> str: + async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client: + try: + resp = await client.get(raw_url) + resp.raise_for_status() + except httpx.HTTPError as exc: + raise ValueError(f'Failed to download SKILL.md from GitHub: {exc}') from exc + + try: + return resp.content.decode('utf-8') + except UnicodeDecodeError as exc: + raise ValueError('GitHub SKILL.md must be valid UTF-8 text') from exc + def _extract_uploaded_skill_to_temp(self, file_bytes: bytes, tmp_dir: str) -> str: extract_dir = os.path.join(tmp_dir, 'extracted') try: @@ -656,6 +746,54 @@ class SkillService: return root_path return os.path.join(root_path, normalized) + @staticmethod + def _is_github_skill_md_url(asset_url: str) -> bool: + parsed = urlparse(str(asset_url or '').strip()) + normalized_path = posixpath.normpath(parsed.path or '/') + return normalized_path.lower().endswith('/skill.md') + + def _parse_github_skill_md_url(self, asset_url: str, *, owner: str, repo: str) -> dict: + parsed = urlparse(str(asset_url or '').strip()) + if parsed.scheme != 'https' or not parsed.netloc: + raise ValueError('asset_url must be a valid HTTPS GitHub SKILL.md URL') + + host = parsed.netloc.lower() + path_parts = [part for part in (parsed.path or '').split('/') if part] + if host == 'github.com': + if len(path_parts) < 5 or path_parts[0] != owner or path_parts[1] != repo or path_parts[2] != 'blob': + raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo blob path') + ref = path_parts[3] + file_path = '/'.join(path_parts[4:]) + raw_url = f'https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{file_path}' + elif host == 'raw.githubusercontent.com': + if len(path_parts) < 4 or path_parts[0] != owner or path_parts[1] != repo: + raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo raw path') + ref = path_parts[2] + file_path = '/'.join(path_parts[3:]) + raw_url = parsed.geturl() + else: + raise ValueError('asset_url must point to a GitHub SKILL.md file') + + normalized_file_path = posixpath.normpath(file_path).lower() + if normalized_file_path != 'skill.md' and not normalized_file_path.endswith('/skill.md'): + raise ValueError('GitHub skill import requires a URL ending with SKILL.md') + + parent_dir = posixpath.basename(posixpath.dirname(file_path)) or repo + return { + 'raw_url': raw_url, + 'ref': ref, + 'file_path': file_path, + 'package_name': self._uploaded_skill_target_stem(parent_dir), + } + + def _resolve_github_skill_md_package_name(self, content: str, fallback: str) -> str: + metadata, _instructions = parse_frontmatter(content) + candidate = str(metadata.get('name') or fallback or '').strip() + try: + return self._validate_skill_name(candidate) + except ValueError: + return self._validate_skill_name(fallback) + @staticmethod def _validate_github_asset_url(asset_url: str, *, owner: str, repo: str, release_tag: str) -> str: parsed = urlparse(str(asset_url).strip()) diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 91a51f4a..31c57e14 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -333,8 +333,14 @@ class BoxService: async def write_skill_file(self, name: str, path: str, content: str) -> dict: return await self.client.write_skill_file(name, path, content) - async def preview_skill_zip(self, file_bytes: bytes, filename: str, source_subdir: str = '') -> list[dict]: - return await self.client.preview_skill_zip(file_bytes, filename, source_subdir) + async def preview_skill_zip( + self, + file_bytes: bytes, + filename: str, + source_subdir: str = '', + target_suffix: str = 'upload', + ) -> list[dict]: + return await self.client.preview_skill_zip(file_bytes, filename, source_subdir, target_suffix) async def install_skill_zip( self, @@ -343,8 +349,16 @@ class BoxService: source_paths: list[str] | None = None, source_path: str = '', source_subdir: str = '', + target_suffix: str = 'upload', ) -> list[dict]: - return await self.client.install_skill_zip(file_bytes, filename, source_paths, source_path, source_subdir) + return await self.client.install_skill_zip( + file_bytes, + filename, + source_paths, + source_path, + source_subdir, + target_suffix, + ) def _serialize_result(self, result: BoxExecutionResult) -> dict: stdout, stdout_truncated = self._truncate(result.stdout) diff --git a/web/src/app/home/add-extension/page.tsx b/web/src/app/home/add-extension/page.tsx index ce4d9ea4..51643f2e 100644 --- a/web/src/app/home/add-extension/page.tsx +++ b/web/src/app/home/add-extension/page.tsx @@ -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(null); + const [githubSkillPreview, setGithubSkillPreview] = useState( + null, + ); const [githubInstallStatus, setGithubInstallStatus] = useState(GithubInstallStatus.WAIT_INPUT); const [githubInstallError, setGithubInstallError] = useState( @@ -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() {

- {t('plugins.installFromGithub')} + {t('addExtension.installFromGithub')}

@@ -715,25 +856,32 @@ function AddExtensionContent() { {githubInstallStatus === GithubInstallStatus.WAIT_INPUT && (

- {t('plugins.enterRepoUrl')} + {t('addExtension.githubUrlHelp')}

setGithubURL(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter') fetchGithubReleases(); + if (e.key === 'Enter') handleGithubAddressSubmit(); }} /> +

+ {t('addExtension.skillMdUrlHelp')} +

@@ -887,6 +1035,88 @@ function AddExtensionContent() {
)} + {githubInstallStatus === GithubInstallStatus.SKILL_PREVIEW && ( +
+
+

+ {t('addExtension.previewSkill')} +

+ +
+ + {githubSkillPreview && ( +
+
+ + + +
+
+ {githubSkillPreview.display_name || + githubSkillPreview.name} +
+
+ {githubSkillPreview.name} +
+
+
+ {githubSkillPreview.description && ( +

+ {githubSkillPreview.description} +

+ )} +
+
+ + Repository:{' '} + + {githubSkillInfo?.owner}/{githubSkillInfo?.repo} +
+
+ + File:{' '} + + + {githubSkillInfo?.path} + +
+ {githubSkillPreview.package_root && ( +
+ + Directory:{' '} + + + {githubSkillPreview.package_root} + +
+ )} +
+
+ )} + + +
+ )} + {githubInstallStatus === GithubInstallStatus.INSTALLING && (
@@ -894,6 +1124,14 @@ function AddExtensionContent() {
)} + {githubInstallStatus === + GithubInstallStatus.SKILL_INSTALLING && ( +
+ + {t('skills.installing')} +
+ )} + {githubInstallStatus === GithubInstallStatus.ERROR && (

diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 31a2b829..22bd2d19 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -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); diff --git a/web/src/app/home/mcp/MCPDetailContent.tsx b/web/src/app/home/mcp/MCPDetailContent.tsx index 96d5c955..c2a2a07d 100644 --- a/web/src/app/home/mcp/MCPDetailContent.tsx +++ b/web/src/app/home/mcp/MCPDetailContent.tsx @@ -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(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 = { + connected: t('mcp.statusConnected'), + connecting: t('mcp.connecting'), + error: t('mcp.statusError'), + disabled: t('mcp.statusDisabled'), + disconnected: t('mcp.statusDisconnected'), + }; + + const connectionStatusClassName: Record = { + 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 = { + 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 }) { {t('mcp.title')} + + + {connectionStatusLabel[currentConnectionState]} +

@@ -292,6 +351,9 @@ export default function MCPDetailContent({ id }: { id: string }) { onNewServerCreated={handleNewServerCreated} onDirtyChange={setFormDirty} onTestingChange={setMcpTesting} + onRuntimeInfoChange={(runtimeInfo) => + setDetailRuntimeStatus(runtimeInfo?.status ?? null) + } />
diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index 127dc4db..a4ed1359 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -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(function MCPForm( onDraftChange, onDirtyChange, onTestingChange, + onRuntimeInfoChange, layout = 'stacked', sideHeader, sideFooter, @@ -396,6 +398,10 @@ const MCPForm = forwardRef(function MCPForm( onTestingChange?.(mcpTesting); }, [mcpTesting, onTestingChange]); + useEffect(() => { + onRuntimeInfoChange?.(runtimeInfo); + }, [onRuntimeInfoChange, runtimeInfo]); + useImperativeHandle( ref, () => ({ diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 0112bb5e..0bae3fed 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1333,7 +1333,7 @@ const enUS = { maxPipelinesReached: 'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.', maxExtensionsReached: - 'Maximum number of extensions ({{max}}) reached. Please remove an existing MCP server or plugin before adding a new one.', + 'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.', }, skills: { title: 'Skills', @@ -1379,7 +1379,7 @@ const enUS = { selectSkills: 'Select Skills', addSkill: 'Add Skill', builtin: 'Built-in', - importFromGithub: 'Install Plugin from GitHub', + importFromGithub: 'Install Skill from GitHub', createManually: 'Create Manually', uploadZip: 'Upload ZIP Package', uploadZipOnly: 'Only .zip skill packages are supported', @@ -1487,8 +1487,18 @@ const enUS = { uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files', orContinueWith: 'or choose an action below', addMCPServerHint: 'Connect an MCP tool server extension', - installFromGithub: 'Install Plugin from GitHub', - installFromGithubHint: 'Install plugin extension from GitHub Release', + installFromGithub: 'Install Plugin or Skill from GitHub', + installFromGithubHint: + 'Supports GitHub Release plugin packages and direct GitHub SKILL.md imports', + githubUrlHelp: + 'Paste a GitHub repository URL to install a plugin. To install a Skill, paste the GitHub SKILL.md file URL.', + githubUrlPlaceholder: + 'e.g. https://github.com/owner/repo or https://github.com/owner/repo/blob/main/path/SKILL.md', + githubUrlRequired: 'Enter a GitHub URL', + skillMdUrlHelp: + 'For Skills, copy the exact SKILL.md file link from the skill directory in GitHub.', + previewSkill: 'Preview Skill', + noSkillPreviewFound: 'No importable Skill found', createSkill: 'Create New Skill', createSkillHint: 'Manually create a new skill extension', unsupportedFileType: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 37c48206..848c9547 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -713,8 +713,8 @@ const zhHans = { toolCount: '工具:{{count}}', parameterCount: '参数:{{count}}', noParameters: '无参数', - statusConnected: '已打开', - statusDisconnected: '未打开', + statusConnected: '已连接', + statusDisconnected: '未连接', statusError: '连接错误', statusDisabled: '已禁用', loading: '加载中...', @@ -1279,7 +1279,7 @@ const zhHans = { maxPipelinesReached: '已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。', maxExtensionsReached: - '已达到扩展数量上限({{max}}个)。请先删除已有的 MCP 服务器或插件后再添加新的。', + '已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。', }, skills: { title: '技能', @@ -1322,7 +1322,7 @@ const zhHans = { selectSkills: '选择技能', builtin: '内置', addSkill: '添加技能', - importFromGithub: '从 GitHub 安装插件', + importFromGithub: '从 GitHub 安装技能', createManually: '手动创建', uploadZip: '上传 ZIP 包', uploadZipOnly: '仅支持 .zip 技能包', @@ -1426,8 +1426,18 @@ const zhHans = { uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件', orContinueWith: '或选择以下操作', addMCPServerHint: '连接一个 MCP 工具服务器扩展', - installFromGithub: '从 GitHub 安装插件', - installFromGithubHint: '从 GitHub Release 安装插件扩展', + installFromGithub: '从 GitHub 安装插件或 Skill', + installFromGithubHint: + '支持 GitHub Release 插件包,也支持直接导入 GitHub 上的 SKILL.md', + githubUrlHelp: + '粘贴 GitHub 仓库地址安装插件;如果要安装 Skill,请粘贴 GitHub 上的 SKILL.md 文件地址。', + githubUrlPlaceholder: + '例如 https://github.com/owner/repo 或 https://github.com/owner/repo/blob/main/path/SKILL.md', + githubUrlRequired: '请输入 GitHub 地址', + skillMdUrlHelp: + 'Skill 需要复制具体文件链接,例如仓库中某个技能目录下的 SKILL.md 页面地址。', + previewSkill: '预览 Skill', + noSkillPreviewFound: '未找到可导入的 Skill', createSkill: '创建新的技能', createSkillHint: '手动创建一个新的技能扩展', unsupportedFileType: '不支持的文件类型,仅支持 .zip 和 .lbpkg 文件', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 00aca7c5..b75e5846 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -705,8 +705,8 @@ const zhHant = { toolCount: '工具:{{count}}', parameterCount: '參數:{{count}}', noParameters: '無參數', - statusConnected: '已開啟', - statusDisconnected: '未開啟', + statusConnected: '已連線', + statusDisconnected: '未連線', statusError: '連接錯誤', statusDisabled: '已停用', loading: '載入中...', @@ -1264,7 +1264,7 @@ const zhHant = { maxPipelinesReached: '已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。', maxExtensionsReached: - '已達到擴充功能數量上限({{max}}個)。請先刪除已有的 MCP 伺服器或外掛後再新增。', + '已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。', }, wizard: { sidebarDescription: '透過引導步驟建立機器人', @@ -1332,8 +1332,18 @@ const zhHant = { uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案', orContinueWith: '或選擇以下操作', addMCPServerHint: '連接一個 MCP 工具伺服器擴充', - installFromGithub: '從 GitHub 安裝插件', - installFromGithubHint: '從 GitHub Release 安裝插件擴充', + installFromGithub: '從 GitHub 安裝插件或 Skill', + installFromGithubHint: + '支援 GitHub Release 插件包,也支援直接匯入 GitHub 上的 SKILL.md', + githubUrlHelp: + '貼上 GitHub 倉庫地址安裝插件;如果要安裝 Skill,請貼上 GitHub 上的 SKILL.md 檔案地址。', + githubUrlPlaceholder: + '例如 https://github.com/owner/repo 或 https://github.com/owner/repo/blob/main/path/SKILL.md', + githubUrlRequired: '請輸入 GitHub 地址', + skillMdUrlHelp: + 'Skill 需要複製具體檔案連結,例如倉庫中某個技能目錄下的 SKILL.md 頁面地址。', + previewSkill: '預覽 Skill', + noSkillPreviewFound: '未找到可匯入的 Skill', createSkill: '建立新的技能', createSkillHint: '手動建立一個新的技能擴充', unsupportedFileType: '不支援的檔案類型,僅支援 .zip 和 .lbpkg 檔案', @@ -1392,7 +1402,7 @@ const zhHant = { selectSkills: '選擇技能', builtin: '內建', addSkill: '添加技能', - importFromGithub: '從 GitHub 安裝插件', + importFromGithub: '從 GitHub 安裝技能', createManually: '手動創建', uploadZip: '上傳 ZIP 包', uploadZipOnly: '僅支援 .zip 技能包',