mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 17:36:07 +00:00
feat: support github skill installation
This commit is contained in:
@@ -98,10 +98,13 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def install_skill_from_github() -> quart.Response:
|
async def install_skill_from_github() -> quart.Response:
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
required_fields = ['asset_url', 'owner', 'repo', 'release_tag']
|
required_fields = ['asset_url', 'owner', 'repo']
|
||||||
for field in required_fields:
|
for field in required_fields:
|
||||||
if field not in data or not data[field]:
|
if field not in data or not data[field]:
|
||||||
return self.http_status(400, -1, f'Missing required field: {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:
|
try:
|
||||||
skill = await self.ap.skill_service.install_from_github(data)
|
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)
|
@self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def preview_skill_from_github() -> quart.Response:
|
async def preview_skill_from_github() -> quart.Response:
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
required_fields = ['asset_url', 'owner', 'repo', 'release_tag']
|
required_fields = ['asset_url', 'owner', 'repo']
|
||||||
for field in required_fields:
|
for field in required_fields:
|
||||||
if field not in data or not data[field]:
|
if field not in data or not data[field]:
|
||||||
return self.http_status(400, -1, f'Missing required field: {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:
|
try:
|
||||||
preview = await self.ap.skill_service.preview_install_from_github(data)
|
preview = await self.ap.skill_service.preview_install_from_github(data)
|
||||||
|
|||||||
@@ -297,7 +297,11 @@ class SkillService:
|
|||||||
owner = str(data['owner']).strip()
|
owner = str(data['owner']).strip()
|
||||||
repo = str(data['repo']).strip()
|
repo = str(data['repo']).strip()
|
||||||
release_tag = str(data.get('release_tag', '')).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()
|
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
||||||
|
|
||||||
box_service = self._box_service()
|
box_service = self._box_service()
|
||||||
@@ -335,7 +339,11 @@ class SkillService:
|
|||||||
owner = str(data['owner']).strip()
|
owner = str(data['owner']).strip()
|
||||||
repo = str(data['repo']).strip()
|
repo = str(data['repo']).strip()
|
||||||
release_tag = str(data.get('release_tag', '')).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()
|
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
||||||
|
|
||||||
box_service = self._box_service()
|
box_service = self._box_service()
|
||||||
@@ -420,6 +428,63 @@ class SkillService:
|
|||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
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]:
|
async def reload_skills(self) -> list[dict]:
|
||||||
await self._reload_skills()
|
await self._reload_skills()
|
||||||
return await self.list_skills()
|
return await self.list_skills()
|
||||||
@@ -507,6 +572,31 @@ class SkillService:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.content
|
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:
|
def _extract_uploaded_skill_to_temp(self, file_bytes: bytes, tmp_dir: str) -> str:
|
||||||
extract_dir = os.path.join(tmp_dir, 'extracted')
|
extract_dir = os.path.join(tmp_dir, 'extracted')
|
||||||
try:
|
try:
|
||||||
@@ -656,6 +746,54 @@ class SkillService:
|
|||||||
return root_path
|
return root_path
|
||||||
return os.path.join(root_path, normalized)
|
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
|
@staticmethod
|
||||||
def _validate_github_asset_url(asset_url: str, *, owner: str, repo: str, release_tag: str) -> str:
|
def _validate_github_asset_url(asset_url: str, *, owner: str, repo: str, release_tag: str) -> str:
|
||||||
parsed = urlparse(str(asset_url).strip())
|
parsed = urlparse(str(asset_url).strip())
|
||||||
|
|||||||
@@ -333,8 +333,14 @@ class BoxService:
|
|||||||
async def write_skill_file(self, name: str, path: str, content: str) -> dict:
|
async def write_skill_file(self, name: str, path: str, content: str) -> dict:
|
||||||
return await self.client.write_skill_file(name, path, content)
|
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]:
|
async def preview_skill_zip(
|
||||||
return await self.client.preview_skill_zip(file_bytes, filename, source_subdir)
|
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(
|
async def install_skill_zip(
|
||||||
self,
|
self,
|
||||||
@@ -343,8 +349,16 @@ class BoxService:
|
|||||||
source_paths: list[str] | None = None,
|
source_paths: list[str] | None = None,
|
||||||
source_path: str = '',
|
source_path: str = '',
|
||||||
source_subdir: str = '',
|
source_subdir: str = '',
|
||||||
|
target_suffix: str = 'upload',
|
||||||
) -> list[dict]:
|
) -> 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:
|
def _serialize_result(self, result: BoxExecutionResult) -> dict:
|
||||||
stdout, stdout_truncated = self._truncate(result.stdout)
|
stdout, stdout_truncated = self._truncate(result.stdout)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
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 { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
||||||
import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm';
|
import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||||
@@ -51,6 +52,8 @@ enum GithubInstallStatus {
|
|||||||
SELECT_ASSET = 'select_asset',
|
SELECT_ASSET = 'select_asset',
|
||||||
ASK_CONFIRM = 'ask_confirm',
|
ASK_CONFIRM = 'ask_confirm',
|
||||||
INSTALLING = 'installing',
|
INSTALLING = 'installing',
|
||||||
|
SKILL_PREVIEW = 'skill_preview',
|
||||||
|
SKILL_INSTALLING = 'skill_installing',
|
||||||
ERROR = 'error',
|
ERROR = 'error',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +76,53 @@ interface GithubAsset {
|
|||||||
content_type: string;
|
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 {
|
enum PluginInstallStatus {
|
||||||
ASK_CONFIRM = 'ask_confirm',
|
ASK_CONFIRM = 'ask_confirm',
|
||||||
INSTALLING = 'installing',
|
INSTALLING = 'installing',
|
||||||
@@ -137,6 +187,12 @@ function AddExtensionContent() {
|
|||||||
const [githubRepo, setGithubRepo] = useState('');
|
const [githubRepo, setGithubRepo] = useState('');
|
||||||
const [fetchingReleases, setFetchingReleases] = useState(false);
|
const [fetchingReleases, setFetchingReleases] = useState(false);
|
||||||
const [fetchingAssets, setFetchingAssets] = 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] =
|
const [githubInstallStatus, setGithubInstallStatus] =
|
||||||
useState<GithubInstallStatus>(GithubInstallStatus.WAIT_INPUT);
|
useState<GithubInstallStatus>(GithubInstallStatus.WAIT_INPUT);
|
||||||
const [githubInstallError, setGithubInstallError] = useState<string | null>(
|
const [githubInstallError, setGithubInstallError] = useState<string | null>(
|
||||||
@@ -324,12 +380,15 @@ function AddExtensionContent() {
|
|||||||
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
|
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
|
||||||
if (maxExtensions < 0) return true;
|
if (maxExtensions < 0) return true;
|
||||||
try {
|
try {
|
||||||
const [pluginsResp, mcpResp] = await Promise.all([
|
const [pluginsResp, mcpResp, skillsResp] = await Promise.all([
|
||||||
httpClient.getPlugins(),
|
httpClient.getPlugins(),
|
||||||
httpClient.getMCPServers(),
|
httpClient.getMCPServers(),
|
||||||
|
httpClient.getSkills(),
|
||||||
]);
|
]);
|
||||||
const total =
|
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) {
|
if (total >= maxExtensions) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t('limitation.maxExtensionsReached', { max: maxExtensions }),
|
t('limitation.maxExtensionsReached', { max: maxExtensions }),
|
||||||
@@ -352,10 +411,21 @@ function AddExtensionContent() {
|
|||||||
setGithubRepo('');
|
setGithubRepo('');
|
||||||
setFetchingReleases(false);
|
setFetchingReleases(false);
|
||||||
setFetchingAssets(false);
|
setFetchingAssets(false);
|
||||||
|
setFetchingSkillPreview(false);
|
||||||
|
setGithubSkillInfo(null);
|
||||||
|
setGithubSkillPreview(null);
|
||||||
setGithubInstallStatus(GithubInstallStatus.WAIT_INPUT);
|
setGithubInstallStatus(GithubInstallStatus.WAIT_INPUT);
|
||||||
setGithubInstallError(null);
|
setGithubInstallError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGithubAddressSubmit() {
|
||||||
|
if (isGithubSkillMdUrl(githubURL)) {
|
||||||
|
await previewGithubSkillMd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchGithubReleases();
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchGithubReleases() {
|
async function fetchGithubReleases() {
|
||||||
if (!githubURL.trim()) {
|
if (!githubURL.trim()) {
|
||||||
toast.error(t('plugins.enterRepoUrl'));
|
toast.error(t('plugins.enterRepoUrl'));
|
||||||
@@ -364,6 +434,8 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
setFetchingReleases(true);
|
setFetchingReleases(true);
|
||||||
setGithubInstallError(null);
|
setGithubInstallError(null);
|
||||||
|
setGithubSkillInfo(null);
|
||||||
|
setGithubSkillPreview(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await httpClient.getGithubReleases(githubURL);
|
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) {
|
async function handleReleaseSelect(release: GithubRelease) {
|
||||||
setSelectedRelease(release);
|
setSelectedRelease(release);
|
||||||
setFetchingAssets(true);
|
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 {
|
function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -470,7 +611,7 @@ function AddExtensionContent() {
|
|||||||
case 'skill':
|
case 'skill':
|
||||||
return 'w-[calc(100vw-2rem)] sm:w-[560px]';
|
return 'w-[calc(100vw-2rem)] sm:w-[560px]';
|
||||||
case 'github':
|
case 'github':
|
||||||
return 'w-[calc(100vw-2rem)] sm:w-[480px]';
|
return 'w-[calc(100vw-2rem)] sm:w-[560px]';
|
||||||
default:
|
default:
|
||||||
return 'w-[calc(100vw-2rem)] sm:w-[380px]';
|
return 'w-[calc(100vw-2rem)] sm:w-[380px]';
|
||||||
}
|
}
|
||||||
@@ -707,7 +848,7 @@ function AddExtensionContent() {
|
|||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h4 className="text-sm font-medium leading-none">
|
<h4 className="text-sm font-medium leading-none">
|
||||||
{t('plugins.installFromGithub')}
|
{t('addExtension.installFromGithub')}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -715,25 +856,32 @@ function AddExtensionContent() {
|
|||||||
{githubInstallStatus === GithubInstallStatus.WAIT_INPUT && (
|
{githubInstallStatus === GithubInstallStatus.WAIT_INPUT && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('plugins.enterRepoUrl')}
|
{t('addExtension.githubUrlHelp')}
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('plugins.repoUrlPlaceholder')}
|
placeholder={t('addExtension.githubUrlPlaceholder')}
|
||||||
value={githubURL}
|
value={githubURL}
|
||||||
onChange={(e) => setGithubURL(e.target.value)}
|
onChange={(e) => setGithubURL(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
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
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={fetchGithubReleases}
|
onClick={handleGithubAddressSubmit}
|
||||||
disabled={!githubURL.trim() || fetchingReleases}
|
disabled={
|
||||||
|
!githubURL.trim() ||
|
||||||
|
fetchingReleases ||
|
||||||
|
fetchingSkillPreview
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{fetchingReleases && (
|
{(fetchingReleases || fetchingSkillPreview) && (
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{fetchingReleases
|
{fetchingReleases || fetchingSkillPreview
|
||||||
? t('plugins.loading')
|
? t('plugins.loading')
|
||||||
: t('common.confirm')}
|
: t('common.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -887,6 +1035,88 @@ function AddExtensionContent() {
|
|||||||
</div>
|
</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 && (
|
{githubInstallStatus === GithubInstallStatus.INSTALLING && (
|
||||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
<div className="flex items-center gap-2 text-sm text-blue-600">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
@@ -894,6 +1124,14 @@ function AddExtensionContent() {
|
|||||||
</div>
|
</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 && (
|
{githubInstallStatus === GithubInstallStatus.ERROR && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-destructive">
|
<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({
|
export default function HomeSidebar({
|
||||||
onSelectedChangeAction,
|
onSelectedChangeAction,
|
||||||
}: {
|
}: {
|
||||||
@@ -1624,14 +1654,7 @@ export default function HomeSidebar({
|
|||||||
|
|
||||||
function initSelect() {
|
function initSelect() {
|
||||||
const currentPath = pathname;
|
const currentPath = pathname;
|
||||||
// Match exact route or sub-routes (e.g., /home/bots/abc-123 matches /home/bots)
|
const matchedChild = findSidebarChildForPath(currentPath);
|
||||||
const matchedChild =
|
|
||||||
sidebarConfigList.find(
|
|
||||||
(childConfig) => childConfig.route === currentPath,
|
|
||||||
) ||
|
|
||||||
sidebarConfigList.find((childConfig) =>
|
|
||||||
currentPath.startsWith(childConfig.route + '/'),
|
|
||||||
);
|
|
||||||
if (matchedChild) {
|
if (matchedChild) {
|
||||||
// Route already matches — just select without navigating (preserves ?id= query params)
|
// Route already matches — just select without navigating (preserves ?id= query params)
|
||||||
selectChild(matchedChild);
|
selectChild(matchedChild);
|
||||||
@@ -1646,12 +1669,7 @@ export default function HomeSidebar({
|
|||||||
|
|
||||||
function handleRouteChange(pathname: string) {
|
function handleRouteChange(pathname: string) {
|
||||||
if (!pathname.startsWith('/home')) return;
|
if (!pathname.startsWith('/home')) return;
|
||||||
// Match exact route or sub-routes (entity detail pages)
|
const routeSelectChild = findSidebarChildForPath(pathname);
|
||||||
const routeSelectChild =
|
|
||||||
sidebarConfigList.find((childConfig) => childConfig.route === pathname) ||
|
|
||||||
sidebarConfigList.find((childConfig) =>
|
|
||||||
pathname.startsWith(childConfig.route + '/'),
|
|
||||||
);
|
|
||||||
if (routeSelectChild) {
|
if (routeSelectChild) {
|
||||||
setSelectedChild(routeSelectChild);
|
setSelectedChild(routeSelectChild);
|
||||||
onSelectedChangeAction(routeSelectChild);
|
onSelectedChangeAction(routeSelectChild);
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Server, Trash2 } from 'lucide-react';
|
import { Server, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
type MCPRuntimeState = 'connected' | 'connecting' | 'error';
|
||||||
|
type MCPConnectionState =
|
||||||
|
| 'connected'
|
||||||
|
| 'connecting'
|
||||||
|
| 'error'
|
||||||
|
| 'disabled'
|
||||||
|
| 'disconnected';
|
||||||
|
|
||||||
export default function MCPDetailContent({ id }: { id: string }) {
|
export default function MCPDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -58,13 +66,55 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
// Enable state managed here so the header switch works
|
// Enable state managed here so the header switch works
|
||||||
const [serverEnabled, setServerEnabled] = useState(true);
|
const [serverEnabled, setServerEnabled] = useState(true);
|
||||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
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
|
// Fetch server enable state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCreateMode) {
|
if (!isCreateMode) {
|
||||||
|
setDetailRuntimeStatus(null);
|
||||||
httpClient.getMCPServer(id).then((res) => {
|
httpClient.getMCPServer(id).then((res) => {
|
||||||
const server = res.server ?? res;
|
const server = res.server ?? res;
|
||||||
setServerEnabled(server.enable ?? true);
|
setServerEnabled(server.enable ?? true);
|
||||||
|
setDetailRuntimeStatus(server.runtime_info?.status ?? null);
|
||||||
setEnableLoaded(true);
|
setEnableLoaded(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -264,6 +314,15 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
<Server className="size-3.5" />
|
<Server className="size-3.5" />
|
||||||
{t('mcp.title')}
|
{t('mcp.title')}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -292,6 +351,9 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
onNewServerCreated={handleNewServerCreated}
|
onNewServerCreated={handleNewServerCreated}
|
||||||
onDirtyChange={setFormDirty}
|
onDirtyChange={setFormDirty}
|
||||||
onTestingChange={setMcpTesting}
|
onTestingChange={setMcpTesting}
|
||||||
|
onRuntimeInfoChange={(runtimeInfo) =>
|
||||||
|
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ 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;
|
||||||
|
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
|
||||||
layout?: 'stacked' | 'split';
|
layout?: 'stacked' | 'split';
|
||||||
sideHeader?: ReactNode;
|
sideHeader?: ReactNode;
|
||||||
sideFooter?: ReactNode;
|
sideFooter?: ReactNode;
|
||||||
@@ -349,6 +350,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
onDraftChange,
|
onDraftChange,
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
onTestingChange,
|
onTestingChange,
|
||||||
|
onRuntimeInfoChange,
|
||||||
layout = 'stacked',
|
layout = 'stacked',
|
||||||
sideHeader,
|
sideHeader,
|
||||||
sideFooter,
|
sideFooter,
|
||||||
@@ -396,6 +398,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
onTestingChange?.(mcpTesting);
|
onTestingChange?.(mcpTesting);
|
||||||
}, [mcpTesting, onTestingChange]);
|
}, [mcpTesting, onTestingChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onRuntimeInfoChange?.(runtimeInfo);
|
||||||
|
}, [onRuntimeInfoChange, runtimeInfo]);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -1333,7 +1333,7 @@ const enUS = {
|
|||||||
maxPipelinesReached:
|
maxPipelinesReached:
|
||||||
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
|
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
|
||||||
maxExtensionsReached:
|
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: {
|
skills: {
|
||||||
title: 'Skills',
|
title: 'Skills',
|
||||||
@@ -1379,7 +1379,7 @@ const enUS = {
|
|||||||
selectSkills: 'Select Skills',
|
selectSkills: 'Select Skills',
|
||||||
addSkill: 'Add Skill',
|
addSkill: 'Add Skill',
|
||||||
builtin: 'Built-in',
|
builtin: 'Built-in',
|
||||||
importFromGithub: 'Install Plugin from GitHub',
|
importFromGithub: 'Install Skill from GitHub',
|
||||||
createManually: 'Create Manually',
|
createManually: 'Create Manually',
|
||||||
uploadZip: 'Upload ZIP Package',
|
uploadZip: 'Upload ZIP Package',
|
||||||
uploadZipOnly: 'Only .zip skill packages are supported',
|
uploadZipOnly: 'Only .zip skill packages are supported',
|
||||||
@@ -1487,8 +1487,18 @@ const enUS = {
|
|||||||
uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files',
|
uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files',
|
||||||
orContinueWith: 'or choose an action below',
|
orContinueWith: 'or choose an action below',
|
||||||
addMCPServerHint: 'Connect an MCP tool server extension',
|
addMCPServerHint: 'Connect an MCP tool server extension',
|
||||||
installFromGithub: 'Install Plugin from GitHub',
|
installFromGithub: 'Install Plugin or Skill from GitHub',
|
||||||
installFromGithubHint: 'Install plugin extension from GitHub Release',
|
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',
|
createSkill: 'Create New Skill',
|
||||||
createSkillHint: 'Manually create a new skill extension',
|
createSkillHint: 'Manually create a new skill extension',
|
||||||
unsupportedFileType:
|
unsupportedFileType:
|
||||||
|
|||||||
@@ -713,8 +713,8 @@ const zhHans = {
|
|||||||
toolCount: '工具:{{count}}',
|
toolCount: '工具:{{count}}',
|
||||||
parameterCount: '参数:{{count}}',
|
parameterCount: '参数:{{count}}',
|
||||||
noParameters: '无参数',
|
noParameters: '无参数',
|
||||||
statusConnected: '已打开',
|
statusConnected: '已连接',
|
||||||
statusDisconnected: '未打开',
|
statusDisconnected: '未连接',
|
||||||
statusError: '连接错误',
|
statusError: '连接错误',
|
||||||
statusDisabled: '已禁用',
|
statusDisabled: '已禁用',
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
@@ -1279,7 +1279,7 @@ const zhHans = {
|
|||||||
maxPipelinesReached:
|
maxPipelinesReached:
|
||||||
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
|
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'已达到扩展数量上限({{max}}个)。请先删除已有的 MCP 服务器或插件后再添加新的。',
|
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
|
||||||
},
|
},
|
||||||
skills: {
|
skills: {
|
||||||
title: '技能',
|
title: '技能',
|
||||||
@@ -1322,7 +1322,7 @@ const zhHans = {
|
|||||||
selectSkills: '选择技能',
|
selectSkills: '选择技能',
|
||||||
builtin: '内置',
|
builtin: '内置',
|
||||||
addSkill: '添加技能',
|
addSkill: '添加技能',
|
||||||
importFromGithub: '从 GitHub 安装插件',
|
importFromGithub: '从 GitHub 安装技能',
|
||||||
createManually: '手动创建',
|
createManually: '手动创建',
|
||||||
uploadZip: '上传 ZIP 包',
|
uploadZip: '上传 ZIP 包',
|
||||||
uploadZipOnly: '仅支持 .zip 技能包',
|
uploadZipOnly: '仅支持 .zip 技能包',
|
||||||
@@ -1426,8 +1426,18 @@ const zhHans = {
|
|||||||
uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件',
|
uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件',
|
||||||
orContinueWith: '或选择以下操作',
|
orContinueWith: '或选择以下操作',
|
||||||
addMCPServerHint: '连接一个 MCP 工具服务器扩展',
|
addMCPServerHint: '连接一个 MCP 工具服务器扩展',
|
||||||
installFromGithub: '从 GitHub 安装插件',
|
installFromGithub: '从 GitHub 安装插件或 Skill',
|
||||||
installFromGithubHint: '从 GitHub Release 安装插件扩展',
|
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: '创建新的技能',
|
createSkill: '创建新的技能',
|
||||||
createSkillHint: '手动创建一个新的技能扩展',
|
createSkillHint: '手动创建一个新的技能扩展',
|
||||||
unsupportedFileType: '不支持的文件类型,仅支持 .zip 和 .lbpkg 文件',
|
unsupportedFileType: '不支持的文件类型,仅支持 .zip 和 .lbpkg 文件',
|
||||||
|
|||||||
@@ -705,8 +705,8 @@ const zhHant = {
|
|||||||
toolCount: '工具:{{count}}',
|
toolCount: '工具:{{count}}',
|
||||||
parameterCount: '參數:{{count}}',
|
parameterCount: '參數:{{count}}',
|
||||||
noParameters: '無參數',
|
noParameters: '無參數',
|
||||||
statusConnected: '已開啟',
|
statusConnected: '已連線',
|
||||||
statusDisconnected: '未開啟',
|
statusDisconnected: '未連線',
|
||||||
statusError: '連接錯誤',
|
statusError: '連接錯誤',
|
||||||
statusDisabled: '已停用',
|
statusDisabled: '已停用',
|
||||||
loading: '載入中...',
|
loading: '載入中...',
|
||||||
@@ -1264,7 +1264,7 @@ const zhHant = {
|
|||||||
maxPipelinesReached:
|
maxPipelinesReached:
|
||||||
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
|
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'已達到擴充功能數量上限({{max}}個)。請先刪除已有的 MCP 伺服器或外掛後再新增。',
|
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
sidebarDescription: '透過引導步驟建立機器人',
|
sidebarDescription: '透過引導步驟建立機器人',
|
||||||
@@ -1332,8 +1332,18 @@ const zhHant = {
|
|||||||
uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案',
|
uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案',
|
||||||
orContinueWith: '或選擇以下操作',
|
orContinueWith: '或選擇以下操作',
|
||||||
addMCPServerHint: '連接一個 MCP 工具伺服器擴充',
|
addMCPServerHint: '連接一個 MCP 工具伺服器擴充',
|
||||||
installFromGithub: '從 GitHub 安裝插件',
|
installFromGithub: '從 GitHub 安裝插件或 Skill',
|
||||||
installFromGithubHint: '從 GitHub Release 安裝插件擴充',
|
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: '建立新的技能',
|
createSkill: '建立新的技能',
|
||||||
createSkillHint: '手動建立一個新的技能擴充',
|
createSkillHint: '手動建立一個新的技能擴充',
|
||||||
unsupportedFileType: '不支援的檔案類型,僅支援 .zip 和 .lbpkg 檔案',
|
unsupportedFileType: '不支援的檔案類型,僅支援 .zip 和 .lbpkg 檔案',
|
||||||
@@ -1392,7 +1402,7 @@ const zhHant = {
|
|||||||
selectSkills: '選擇技能',
|
selectSkills: '選擇技能',
|
||||||
builtin: '內建',
|
builtin: '內建',
|
||||||
addSkill: '添加技能',
|
addSkill: '添加技能',
|
||||||
importFromGithub: '從 GitHub 安裝插件',
|
importFromGithub: '從 GitHub 安裝技能',
|
||||||
createManually: '手動創建',
|
createManually: '手動創建',
|
||||||
uploadZip: '上傳 ZIP 包',
|
uploadZip: '上傳 ZIP 包',
|
||||||
uploadZipOnly: '僅支援 .zip 技能包',
|
uploadZipOnly: '僅支援 .zip 技能包',
|
||||||
|
|||||||
Reference in New Issue
Block a user