Download services/skill_service.py from zlcy/study: direct link, hf CLI and curl.
- Browser
- Download file 110 kB
-
https://huggingface.co/spaces/zlcy/study/resolve/main/services/skill_service.py
- Command line
-
hf download hf://spaces/zlcy/study/services/skill_service.py
-
curl -L -o skill_service.py https://huggingface.co/spaces/zlcy/study/resolve/main/services/skill_service.py
110 kB
| from __future__ import annotations | |
| import copy | |
| import io | |
| import ipaddress | |
| from functools import wraps | |
| import hashlib | |
| import json | |
| import mimetypes | |
| import os | |
| from pathlib import Path, PurePosixPath | |
| import re | |
| import secrets | |
| import shutil | |
| import socket | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import threading | |
| import time | |
| from typing import Any | |
| from urllib.parse import urlparse | |
| import uuid | |
| import zipfile | |
| import httpx | |
| from services.config import BASE_DIR, DATA_DIR | |
| MAX_ARCHIVE_BYTES = 25 * 1024 * 1024 | |
| MAX_FILE_BYTES = 8 * 1024 * 1024 | |
| MAX_FILES = 500 | |
| # An administrator-built export bundle carries every Skill together with its | |
| # showcase media, so it is orders of magnitude larger than a Skill downloaded | |
| # from a repository. Only the administrator-only import endpoint uses these | |
| # relaxed limits; public installs keep the tight ones above. | |
| MAX_IMPORT_ARCHIVE_BYTES = 256 * 1024 * 1024 | |
| MAX_IMPORT_FILES = 5000 | |
| MAX_RESOURCE_BYTES = 512 * 1024 | |
| MAX_SCRIPT_ARGS = 48 | |
| MAX_SCRIPT_ARG_CHARS = 2048 | |
| MAX_SCRIPT_OUTPUT_BYTES = 25 * 1024 * 1024 | |
| MEDIA_PARSER_OUTPUT_BYTES = max( | |
| MAX_SCRIPT_OUTPUT_BYTES, | |
| int(os.environ.get("CHATGPT2API_MEDIA_PARSER_MAX_BYTES", 512 * 1024 * 1024)), | |
| ) | |
| MEDIA_PARSER_ARTIFACT_TTL_SECONDS = max( | |
| 900, | |
| int(os.environ.get("CHATGPT2API_MEDIA_PARSER_TTL_SECONDS", 6 * 60 * 60)), | |
| ) | |
| MEDIA_PARSER_OWNER_STORAGE_BYTES = max( | |
| MEDIA_PARSER_OUTPUT_BYTES, | |
| int(os.environ.get("CHATGPT2API_MEDIA_PARSER_OWNER_STORAGE_BYTES", 1024 * 1024 * 1024)), | |
| ) | |
| MAX_SCRIPT_OUTPUT_FILES = 24 | |
| MAX_SCRIPT_STDIO_CHARS = 20000 | |
| DEFAULT_SCRIPT_TIMEOUT_SECONDS = 45 | |
| MAX_SCRIPT_TIMEOUT_SECONDS = 120 | |
| SKILLS_DIR = DATA_DIR / "skills" | |
| BUNDLED_SKILLS_DIR = BASE_DIR / "bundled_skills" | |
| BUNDLED_SKILL_VERSION_FILE = ".bundled-version" | |
| SKILL_RUNS_DIR = DATA_DIR / "files" / "skills" | |
| SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9._-]+") | |
| FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*(?:\n|$)", re.DOTALL) | |
| TEXT_RESOURCE_EXTENSIONS = { | |
| ".md", ".mdx", ".txt", ".json", ".jsonl", ".yaml", ".yml", ".toml", ".ini", | |
| ".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".css", ".html", ".svg", | |
| } | |
| SKILL_PRESENTATION_FILE = "skill-ui.json" | |
| SKILL_PRESENTATION_OVERRIDE_FILE = ".presentation-overrides.json" | |
| SKILL_PRESENTATION_ASSET_DIR = ".presentation-assets" | |
| # Updating a Skill replaces its directory wholesale. Before that happens the | |
| # previous copy is kept so an administrator can roll back a bad release. | |
| SKILL_BACKUP_DIR = ".backups" | |
| MAX_SKILL_BACKUPS = 3 | |
| SKILL_EXPORT_FORMAT = "chatsam-skills-export" | |
| SKILL_EXPORT_FORMAT_VERSION = 1 | |
| EXPORT_MANIFEST_FILE = "manifest.json" | |
| # Administrator state that survives an overwrite: script permissions, the | |
| # showcase overrides, uploaded showcase media and the user-facing visibility. | |
| PRESERVED_STATE_FILES = ( | |
| ".permissions.json", | |
| SKILL_PRESENTATION_OVERRIDE_FILE, | |
| ".visibility.json", | |
| ) | |
| PRESERVED_STATE_DIRS = (SKILL_PRESENTATION_ASSET_DIR,) | |
| SKILL_WORKFLOW_FILE = "skill-workflow.json" | |
| PRESENTATION_SOURCE_FILES = { | |
| SKILL_PRESENTATION_FILE, | |
| SKILL_PRESENTATION_OVERRIDE_FILE, | |
| SKILL_WORKFLOW_FILE, | |
| } | |
| PRESENTATION_SUMMARY_KEYS = ( | |
| "version", | |
| "title", | |
| "description", | |
| "category", | |
| "tags", | |
| "capabilities", | |
| "origin", | |
| "cover", | |
| "asset_count", | |
| "example_count", | |
| "input_count", | |
| "output_count", | |
| "workflow_count", | |
| ) | |
| PRESENTATION_ASSET_EXTENSIONS = { | |
| ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", | |
| ".html", ".htm", ".pdf", ".mp4", ".webm", ".mp3", ".wav", | |
| } | |
| PRESENTATION_MEDIA_ROOTS = {"assets", "examples", "example", "demo", "demos", "preview", "previews"} | |
| SCRIPT_EXTENSIONS = {".py", ".js", ".mjs", ".cjs"} | |
| USER_GUIDE_DOC_RE = re.compile( | |
| r"^(?:readme|usage|quickstart|getting[-_ ]started|user[-_ ]guide|how[-_ ]to)(?:[._-].*)?\.(?:md|mdx|txt)$", | |
| re.IGNORECASE, | |
| ) | |
| GUIDE_CACHE_FILE = ".user-guide-cache.json" | |
| SKILL_FAVORITES_FILE = "skill-favorites.json" | |
| # Caches and build leftovers add nothing to an export bundle. | |
| EXPORT_EXCLUDED_NAMES = {GUIDE_CACHE_FILE, ".DS_Store"} | |
| EXPORT_EXCLUDED_PARTS = {"__pycache__"} | |
| # A Skill package is not always a single directory. Repositories keep runnable | |
| # code beside the Skill folder, or ship it in a monorepo package, and describe | |
| # the layout in Markdown instead of in SKILL.md. These rules decide which | |
| # documents are read while deciding what belongs to the package. | |
| PACKAGE_DOC_RE = re.compile( | |
| r"^(?:readme|usage|quickstart|getting[-_ ]started|user[-_ ]guide)(?:[._-].*)?\.(?:md|mdx|txt)$", | |
| re.IGNORECASE, | |
| ) | |
| PACKAGE_DOCUMENT_SUFFIXES = {".md", ".mdx", ".txt"} | |
| MAX_PACKAGE_DOCUMENTS = 40 | |
| # ``scripts/`` already has a fixed meaning inside a Skill — it is where the | |
| # runtime looks for controlled scripts. A repository-level ``scripts/`` holds | |
| # development tooling, so re-homing it would produce ``scripts/scripts/`` and mix | |
| # unrelated files into the Skill's own script list. | |
| RESERVED_PACKAGE_ROOT_NAMES = {"scripts"} | |
| class _KeyedRunLocks: | |
| """Serialize only duplicate idempotent runs inside this API process.""" | |
| def __init__(self) -> None: | |
| self._guard = threading.Lock() | |
| self._locks: dict[str, tuple[threading.Lock, int]] = {} | |
| def acquire(self, key: str) -> threading.Lock: | |
| with self._guard: | |
| lock, references = self._locks.get(key, (threading.Lock(), 0)) | |
| self._locks[key] = (lock, references + 1) | |
| lock.acquire() | |
| return lock | |
| def release(self, key: str, lock: threading.Lock) -> None: | |
| lock.release() | |
| with self._guard: | |
| current, references = self._locks.get(key, (lock, 1)) | |
| if current is lock and references <= 1: | |
| self._locks.pop(key, None) | |
| elif current is lock: | |
| self._locks[key] = (lock, references - 1) | |
| _SCRIPT_RUN_LOCKS = _KeyedRunLocks() | |
| def _serialize_duplicate_runs(function): | |
| def wrapped(self, skill_id, identity, *args, **kwargs): | |
| client_run_id = str(kwargs.get("client_run_id") or "").strip() | |
| if not client_run_id: | |
| return function(self, skill_id, identity, *args, **kwargs) | |
| owner = _safe_name(str(identity.get("id") or "user"), "user") | |
| key = hashlib.sha256(f"{owner}:{skill_id}:{client_run_id}".encode("utf-8")).hexdigest() | |
| lock = _SCRIPT_RUN_LOCKS.acquire(key) | |
| try: | |
| return function(self, skill_id, identity, *args, **kwargs) | |
| finally: | |
| _SCRIPT_RUN_LOCKS.release(key, lock) | |
| return wrapped | |
| class SkillInstallError(ValueError): | |
| pass | |
| def _safe_name(value: str, fallback: str = "skill") -> str: | |
| normalized = SAFE_NAME_RE.sub("-", value.strip()).strip("-._").lower() | |
| return (normalized or fallback)[:80] | |
| def _frontmatter(skill_file: Path) -> dict[str, str]: | |
| try: | |
| text = skill_file.read_text(encoding="utf-8") | |
| except (OSError, UnicodeError) as exc: | |
| raise SkillInstallError(f"无法读取 {skill_file.name}: {exc}") from exc | |
| match = FRONTMATTER_RE.match(text) | |
| values: dict[str, str] = {} | |
| if match: | |
| for raw_line in match.group("body").splitlines(): | |
| key, separator, value = raw_line.partition(":") | |
| if separator and key.strip() in { | |
| "name", "description", "author", "version", "homepage", | |
| "repository", "source", "source_url", | |
| }: | |
| values[key.strip()] = value.strip().strip("\"'") | |
| values.setdefault("name", skill_file.parent.name) | |
| values.setdefault("description", "") | |
| return values | |
| def _presentation_category(skill_id: str, name: str, description: str) -> str: | |
| text = f"{skill_id} {name} {description}".lower() | |
| if re.search(r"\b(?:image|photo|poster|banner|logo|art|draw|paint|gif|wallpaper|holographic)\b|视觉|图片|海报|作图|封面|壁纸|摄影|插画|卡片", text): | |
| return "视觉创作" | |
| if re.search(r"\b(?:ui|ux|frontend|website|web)\b|界面|交互|网页", text): | |
| return "界面设计" | |
| if re.search(r"\b(?:document|pdf|ppt|slide|sheet|excel)\b|文档|表格|演示", text): | |
| return "文档效率" | |
| if re.search(r"\b(?:search|research)\b|搜索|调研", text): | |
| return "搜索研究" | |
| return "专业工作流" | |
| def _asset_kind(path: Path, mime_type: str) -> str: | |
| suffix = path.suffix.lower() | |
| if mime_type.startswith("image/"): | |
| return "image" | |
| if mime_type.startswith("video/"): | |
| return "video" | |
| if mime_type.startswith("audio/"): | |
| return "audio" | |
| if suffix in {".html", ".htm"}: | |
| return "html" | |
| if suffix == ".pdf": | |
| return "pdf" | |
| return "file" | |
| def _string_list(value: Any, *, limit: int = 16) -> list[str]: | |
| if isinstance(value, str): | |
| values = [value] | |
| elif isinstance(value, list): | |
| values = [str(item) for item in value if isinstance(item, (str, int, float))] | |
| else: | |
| return [] | |
| result: list[str] = [] | |
| for value_item in values: | |
| clean = value_item.strip() | |
| if clean and clean not in result: | |
| result.append(clean[:160]) | |
| if len(result) >= limit: | |
| break | |
| return result | |
| def _http_url(value: object) -> str: | |
| clean = str(value or "").strip() | |
| parsed = urlparse(clean) | |
| return clean if parsed.scheme in {"http", "https"} and parsed.hostname else "" | |
| def _require_public_remote_url(value: str) -> str: | |
| clean = _http_url(value) | |
| if not clean: | |
| raise SkillInstallError("安装说明地址必须是有效的 http/https URL") | |
| hostname = urlparse(clean).hostname or "" | |
| try: | |
| addresses = {item[4][0] for item in socket.getaddrinfo(hostname, None)} | |
| except OSError as exc: | |
| raise SkillInstallError("安装说明地址无法解析") from exc | |
| for address in addresses: | |
| ip = ipaddress.ip_address(address) | |
| if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified: | |
| raise SkillInstallError("安装说明地址不能指向本机或内网") | |
| return clean | |
| def _skill_origin(skill_dir: Path, metadata: dict[str, str], manifest: dict[str, Any]) -> dict[str, Any]: | |
| install_metadata: dict[str, Any] = {} | |
| try: | |
| raw_install = json.loads((skill_dir / ".install.json").read_text(encoding="utf-8")) | |
| if isinstance(raw_install, dict): | |
| install_metadata = raw_install | |
| except (OSError, UnicodeError, ValueError): | |
| pass | |
| package: dict[str, Any] = {} | |
| try: | |
| raw_package = json.loads((skill_dir / "package.json").read_text(encoding="utf-8")) | |
| if isinstance(raw_package, dict): | |
| package = raw_package | |
| except (OSError, UnicodeError, ValueError): | |
| pass | |
| manifest_source = manifest.get("source") | |
| source_config = manifest_source if isinstance(manifest_source, dict) else {} | |
| package_repository = package.get("repository") | |
| if isinstance(package_repository, dict): | |
| package_repository = package_repository.get("url") | |
| installed_source = str(install_metadata.get("source") or "").strip() | |
| url = next((candidate for candidate in ( | |
| _http_url(source_config.get("url")), | |
| _http_url(source_config.get("repository")), | |
| _http_url(metadata.get("source_url")), | |
| _http_url(metadata.get("repository")), | |
| _http_url(metadata.get("homepage")), | |
| _http_url(package_repository), | |
| _http_url(package.get("homepage")), | |
| _http_url(installed_source), | |
| ) if candidate), "") | |
| if url.startswith("git+"): | |
| url = url[4:] | |
| url = url.removesuffix(".git") | |
| package_author = package.get("author") | |
| if isinstance(package_author, dict): | |
| package_author = package_author.get("name") | |
| author = str( | |
| source_config.get("author") | |
| or manifest.get("author") | |
| or metadata.get("author") | |
| or package_author | |
| or "" | |
| ).strip()[:160] | |
| github_match = re.match(r"https?://github\.com/([^/]+)/([^/#?]+)", url, re.IGNORECASE) | |
| if not author and github_match: | |
| author = github_match.group(1) | |
| if github_match: | |
| source_type = "github" | |
| default_label = "GitHub 开源" | |
| elif installed_source.startswith("upload:"): | |
| source_type = "upload" | |
| default_label = "本地上传" | |
| elif url: | |
| source_type = "url" | |
| default_label = "外部来源" | |
| else: | |
| source_type = "local" | |
| default_label = "本地 Skill" | |
| return { | |
| "type": source_type, | |
| "label": str(source_config.get("label") or source_config.get("name") or default_label).strip()[:120], | |
| "url": url, | |
| "author": author, | |
| "version": str(source_config.get("version") or manifest.get("version") or metadata.get("version") or package.get("version") or "").strip()[:80], | |
| "open_source": bool(source_config.get("openSource", source_config.get("open_source", source_type == "github"))), | |
| } | |
| def _safe_relative_path(value: str) -> Path: | |
| clean = str(value or "").strip().replace("\\", "/") | |
| path = PurePosixPath(clean) | |
| if not clean or path.is_absolute() or ".." in path.parts or any(not part for part in path.parts): | |
| raise SkillInstallError("Skill 文件路径无效") | |
| return Path(*path.parts) | |
| def _safe_output_name(value: str) -> str: | |
| name = Path(str(value or "").replace("\\", "/")).name | |
| clean = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip(".-") | |
| if not clean: | |
| raise SkillInstallError("输出文件名无效") | |
| return clean[:160] | |
| def _is_text_resource(path: Path) -> bool: | |
| return path.suffix.lower() in TEXT_RESOURCE_EXTENSIONS | |
| def _script_kind(path: Path) -> str: | |
| suffix = path.suffix.lower() | |
| if suffix == ".py": | |
| return "python" | |
| if suffix in {".js", ".mjs", ".cjs"}: | |
| return "node" | |
| return "" | |
| class _ScannedFile: | |
| """A single file discovered by one recursive walk of a Skill directory.""" | |
| __slots__ = ("path", "relative", "name", "suffix", "size", "mtime_ns") | |
| def __init__(self, path: Path, relative: str, size: int, mtime_ns: int) -> None: | |
| self.path = path | |
| self.relative = relative | |
| self.name = path.name | |
| self.suffix = path.suffix.lower() | |
| self.size = size | |
| self.mtime_ns = mtime_ns | |
| def is_presentation_source(self) -> bool: | |
| return self.relative in PRESENTATION_SOURCE_FILES | |
| def is_user_document(self) -> bool: | |
| return bool(USER_GUIDE_DOC_RE.match(self.name.lower())) | |
| def is_presentation_asset(self) -> bool: | |
| return self.suffix in PRESENTATION_ASSET_EXTENSIONS | |
| def _scan_skill_files(skill_dir: Path) -> list[_ScannedFile]: | |
| """Walk a Skill directory exactly once and reuse the result everywhere. | |
| ``Path.rglob`` is a Python-level recursive scan and every call re-stats each | |
| entry, so listing one Skill used to cost four full walks plus three reads of | |
| the same SKILL.md. ``os.scandir`` reuses the stat data the OS already handed | |
| back, and every consumer below shares this single result instead. | |
| """ | |
| found: list[_ScannedFile] = [] | |
| pending: list[Path] = [skill_dir] | |
| while pending: | |
| current = pending.pop() | |
| try: | |
| with os.scandir(current) as entries: | |
| for entry in entries: | |
| try: | |
| if entry.is_dir(follow_symlinks=False): | |
| pending.append(Path(entry.path)) | |
| continue | |
| if not entry.is_file(): | |
| continue | |
| info = entry.stat() | |
| except OSError: | |
| continue | |
| path = Path(entry.path) | |
| found.append(_ScannedFile( | |
| path, | |
| path.relative_to(skill_dir).as_posix(), | |
| info.st_size, | |
| info.st_mtime_ns, | |
| )) | |
| except OSError: | |
| continue | |
| # Preserve the historical ordering: the guide fingerprint must stay stable | |
| # for unchanged directories so existing cached guides remain valid. | |
| found.sort(key=lambda item: item.path) | |
| return found | |
| def _guide_fingerprint(files: list[_ScannedFile]) -> str: | |
| """Cheap version of everything that can change a Skill's rendered surface.""" | |
| digest = hashlib.sha256() | |
| for item in files: | |
| if not ( | |
| item.relative == "SKILL.md" | |
| or item.is_user_document | |
| or item.is_presentation_source | |
| or item.is_presentation_asset | |
| ): | |
| continue | |
| digest.update(f"{item.relative}:{item.size}:{item.mtime_ns}\n".encode("utf-8")) | |
| return digest.hexdigest()[:20] | |
| def _content_fingerprint(files: list[_ScannedFile]) -> str: | |
| """Version of every file in the directory, used to key the describe cache.""" | |
| digest = hashlib.sha256() | |
| for item in files: | |
| digest.update(f"{item.relative}:{item.size}:{item.mtime_ns}\n".encode("utf-8")) | |
| return digest.hexdigest()[:20] | |
| def _presentation_summary(presentation: dict[str, Any]) -> dict[str, Any]: | |
| return { | |
| key: copy.deepcopy(presentation[key]) | |
| for key in PRESENTATION_SUMMARY_KEYS | |
| if key in presentation | |
| } | |
| def _guide_document_score(path: str) -> tuple[int, str]: | |
| normalized = path.replace("\\", "/").lower() | |
| name = normalized.rsplit("/", 1)[-1] | |
| score = 20 if "/" in normalized else 0 | |
| if re.search(r"zh[-_.]?(?:cn|hans)|chinese|中文", name): | |
| score -= 12 | |
| if name.startswith("readme"): | |
| score -= 8 | |
| if re.match(r"^(?:usage|quickstart|getting[-_ ]started|user[-_ ]guide)", name): | |
| score -= 6 | |
| if normalized.startswith("_package-docs/"): | |
| score -= 4 | |
| return score, normalized | |
| def _ancestor_directories(skill_file: Path, search_root: Path) -> list[Path]: | |
| """Directories from the Skill folder up to and including the search root.""" | |
| ancestors: list[Path] = [] | |
| search_root_resolved = search_root.resolve() | |
| current = skill_file.parent.parent | |
| while True: | |
| try: | |
| current.resolve().relative_to(search_root_resolved) | |
| except ValueError: | |
| break | |
| ancestors.append(current) | |
| if current.resolve() == search_root_resolved: | |
| break | |
| current = current.parent | |
| return ancestors | |
| def _package_documents(skill_file: Path, search_root: Path) -> list[Path]: | |
| """Return the documents that describe this Skill package. | |
| SKILL.md is the authoritative protocol file, but repositories also document | |
| how to run a Skill in ``references/*.md`` beside it and in the README above | |
| it. All of those can name directories the Skill needs at run time, so all of | |
| them are read when working out what the package contains. | |
| """ | |
| documents = [skill_file] | |
| taken = 0 | |
| for scanned in _scan_skill_files(skill_file.parent): | |
| if taken >= MAX_PACKAGE_DOCUMENTS: | |
| break | |
| if scanned.suffix not in PACKAGE_DOCUMENT_SUFFIXES: | |
| continue | |
| taken += 1 | |
| documents.append(scanned.path) | |
| for ancestor in _ancestor_directories(skill_file, search_root): | |
| try: | |
| entries = sorted(ancestor.iterdir()) | |
| except OSError: | |
| continue | |
| for candidate in entries: | |
| if candidate.is_file() and PACKAGE_DOC_RE.match(candidate.name): | |
| documents.append(candidate) | |
| return documents | |
| def _referenced_package_roots(skill_file: Path, search_root: Path) -> list[Path]: | |
| """Find repository directories that belong to this Skill package. | |
| Some repositories keep SKILL.md in a small definition directory while the | |
| executable implementation lives beside it (for example | |
| ``media-parser/`` + ``astrbot_plugin_media_parser/``), and monorepo layouts | |
| ship a companion service in a sibling package (``skills/holo-card/`` + | |
| ``packages/holo-card-api/``). Installing only the SKILL.md parent produces a | |
| Skill that can explain commands but cannot run them, so every directory named | |
| as ``dir/...`` by the package documents is included when it really exists | |
| next to the Skill. | |
| Copying an ancestor would drag the Skill folder into its own ``scripts`` | |
| directory, and copying a directory that holds another SKILL.md would install | |
| a second, competing Skill — both are skipped. | |
| """ | |
| segments: list[str] = [] | |
| for document in _package_documents(skill_file, search_root): | |
| try: | |
| content = document.read_text(encoding="utf-8")[:MAX_RESOURCE_BYTES] | |
| except (OSError, UnicodeError): | |
| continue | |
| for match in re.finditer(r"(?<![\w.:-])([A-Za-z0-9][A-Za-z0-9._-]*)/[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*", content): | |
| segment = match.group(1) | |
| if segment not in segments and segment not in {skill_file.parent.name, "http", "https"}: | |
| segments.append(segment) | |
| if len(segments) >= 256: | |
| break | |
| if len(segments) >= 256: | |
| break | |
| skill_dir = skill_file.parent.resolve() | |
| roots: list[Path] = [] | |
| search_root_resolved = search_root.resolve() | |
| for current in _ancestor_directories(skill_file, search_root): | |
| for segment in segments: | |
| candidate = (current / segment).resolve() | |
| try: | |
| candidate.relative_to(search_root_resolved) | |
| except ValueError: | |
| continue | |
| if not candidate.exists() or candidate in roots: | |
| continue | |
| if candidate.name in RESERVED_PACKAGE_ROOT_NAMES: | |
| continue | |
| if candidate == skill_dir or candidate in skill_dir.parents or skill_dir in candidate.parents: | |
| continue | |
| if next(candidate.rglob("SKILL.md"), None) is not None: | |
| continue | |
| roots.append(candidate) | |
| return roots | |
| def _validate_zip_member(info: zipfile.ZipInfo) -> PurePosixPath: | |
| path = PurePosixPath(info.filename.replace("\\", "/")) | |
| if path.is_absolute() or ".." in path.parts: | |
| raise SkillInstallError(f"压缩包包含不安全路径:{info.filename}") | |
| mode = info.external_attr >> 16 | |
| if mode and (mode & 0o170000) == 0o120000: | |
| raise SkillInstallError(f"压缩包不允许符号链接:{info.filename}") | |
| if info.file_size > MAX_FILE_BYTES: | |
| raise SkillInstallError(f"文件过大:{info.filename}") | |
| return path | |
| def _is_export_excluded(relative: str) -> bool: | |
| parts = relative.split("/") | |
| if parts[-1] in EXPORT_EXCLUDED_NAMES or parts[-1].endswith(".pyc"): | |
| return True | |
| return any(part in EXPORT_EXCLUDED_PARTS for part in parts) | |
| def _export_bundle_sources(extracted: Path) -> dict[str, dict[str, Any]]: | |
| """Return {skill_id: manifest entry} when the archive is an export bundle. | |
| A bundle produced by ``export_skills`` is the unit of migration between | |
| deployments: it carries each Skill's recorded origin so the importing | |
| deployment can keep updating it from the same place. Importing must | |
| therefore not relabel the Skill as a local upload — which only happens for | |
| archives that actually declare the bundle format. | |
| """ | |
| path = extracted / EXPORT_MANIFEST_FILE | |
| if not path.is_file(): | |
| return {} | |
| try: | |
| payload = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, UnicodeError, ValueError): | |
| return {} | |
| if not isinstance(payload, dict) or payload.get("format") != SKILL_EXPORT_FORMAT: | |
| return {} | |
| entries: dict[str, dict[str, Any]] = {} | |
| for item in payload.get("skills") or []: | |
| if isinstance(item, dict) and str(item.get("id") or "").strip(): | |
| entries[str(item["id"]).strip()] = item | |
| return entries | |
| def _restore_preserved_state(target: Path, backup: Path) -> None: | |
| """Copy administrator state from an overwrite snapshot into the fresh install. | |
| A reinstall brings new code, but the administrator's decisions about the | |
| Skill — which scripts may run, how it is presented, which media was uploaded | |
| and whether end users see it — belong to the installation, not to the | |
| package, so they are carried across. | |
| """ | |
| for name in PRESERVED_STATE_FILES: | |
| source = backup / name | |
| if source.is_file(): | |
| shutil.copy2(source, target / name) | |
| for name in PRESERVED_STATE_DIRS: | |
| source = backup / name | |
| if not source.is_dir(): | |
| continue | |
| destination = target / name | |
| if destination.exists(): | |
| shutil.rmtree(destination, ignore_errors=True) | |
| shutil.copytree(source, destination) | |
| def _format_size_limit(limit: int) -> str: | |
| return f"{limit // (1024 * 1024)}MB" | |
| def _extract_archive( | |
| payload: bytes, | |
| destination: Path, | |
| *, | |
| subpath: str = "", | |
| max_bytes: int = MAX_ARCHIVE_BYTES, | |
| max_files: int = MAX_FILES, | |
| ) -> None: | |
| if len(payload) > max_bytes: | |
| raise SkillInstallError(f"ZIP 不能超过 {_format_size_limit(max_bytes)}") | |
| try: | |
| archive = zipfile.ZipFile(io.BytesIO(payload)) | |
| except zipfile.BadZipFile as exc: | |
| raise SkillInstallError("上传内容不是有效 ZIP") from exc | |
| infos = archive.infolist() | |
| if subpath: | |
| wanted = PurePosixPath(subpath).parts | |
| infos = [ | |
| info for info in infos | |
| if tuple(PurePosixPath(info.filename.replace("\\", "/")).parts[1:1 + len(wanted)]) == wanted | |
| ] | |
| if not infos: | |
| raise SkillInstallError(f"仓库中找不到技能目录:{subpath}") | |
| if len(infos) > max_files: | |
| raise SkillInstallError(f"ZIP 文件数量不能超过 {max_files}") | |
| total_size = sum(info.file_size for info in infos) | |
| if total_size > max_bytes: | |
| raise SkillInstallError(f"ZIP 解压后不能超过 {_format_size_limit(max_bytes)}") | |
| for info in infos: | |
| relative = _validate_zip_member(info) | |
| if not relative.parts: | |
| continue | |
| target = destination.joinpath(*relative.parts) | |
| if info.is_dir(): | |
| target.mkdir(parents=True, exist_ok=True) | |
| continue | |
| target.parent.mkdir(parents=True, exist_ok=True) | |
| with archive.open(info) as source, target.open("wb") as output: | |
| shutil.copyfileobj(source, output) | |
| def _github_download_candidates(url: str) -> list[tuple[str, str]]: | |
| parsed = urlparse(url) | |
| if parsed.netloc.lower() not in {"github.com", "www.github.com"}: | |
| return [(url, "")] | |
| parts = [part for part in parsed.path.split("/") if part] | |
| if len(parts) < 2: | |
| raise SkillInstallError("GitHub URL 必须包含 owner/repository") | |
| owner, repository = parts[0], parts[1].removesuffix(".git") | |
| if len(parts) >= 5 and parts[2] == "tree": | |
| ref = parts[3] | |
| subpath = "/".join(parts[4:]) | |
| return [(f"https://codeload.github.com/{owner}/{repository}/zip/{ref}", subpath)] | |
| return [ | |
| (f"https://codeload.github.com/{owner}/{repository}/zip/refs/heads/main", ""), | |
| (f"https://codeload.github.com/{owner}/{repository}/zip/refs/heads/master", ""), | |
| ] | |
| class SkillService: | |
| def __init__( | |
| self, | |
| root: Path = SKILLS_DIR, | |
| runs_root: Path = SKILL_RUNS_DIR, | |
| favorites_path: Path | None = None, | |
| ) -> None: | |
| self.root = root | |
| self.runs_root = runs_root | |
| self.favorites_path = favorites_path or ( | |
| DATA_DIR / SKILL_FAVORITES_FILE | |
| if root == SKILLS_DIR | |
| else root.parent / SKILL_FAVORITES_FILE | |
| ) | |
| self._favorites_lock = threading.RLock() | |
| # Keyed by (resolved directory, guide version). The cache always holds the | |
| # full presentation; summaries are projected from it so the list view and | |
| # the detail view no longer build the same Skill twice. | |
| self._presentation_cache: dict[tuple[str, str], dict[str, Any]] = {} | |
| self._describe_cache: dict[tuple[str, str, bool], dict[str, Any]] = {} | |
| self._run_cleanup_lock = threading.RLock() | |
| self._last_run_cleanup_at = 0.0 | |
| self._media_link_lock = threading.RLock() | |
| self._media_links: dict[str, dict[str, Any]] = {} | |
| def sync_bundled_skills(self, source_root: Path = BUNDLED_SKILLS_DIR) -> list[str]: | |
| """Install the Skills shipped with the application into runtime storage. | |
| Runtime storage may be a Hugging Face persistent volume, whereas the | |
| bundled directory lives in the immutable application image. Refresh | |
| the application-owned files on every deployment while retaining the | |
| administrator-controlled visibility, permissions and presentation | |
| overrides already stored on the volume. | |
| """ | |
| if not source_root.is_dir(): | |
| return [] | |
| self.root.mkdir(parents=True, exist_ok=True) | |
| synced: list[str] = [] | |
| for source in sorted(path for path in source_root.iterdir() if path.is_dir()): | |
| skill_file = source / "SKILL.md" | |
| if not skill_file.is_file(): | |
| continue | |
| metadata = _frontmatter(skill_file) | |
| skill_id = _safe_name(metadata["name"], _safe_name(source.name)) | |
| target = self.root / skill_id | |
| digest = hashlib.sha256() | |
| for resource in sorted(path for path in source.rglob("*") if path.is_file()): | |
| relative = resource.relative_to(source).as_posix() | |
| digest.update(relative.encode("utf-8")) | |
| digest.update(b"\0") | |
| digest.update(resource.read_bytes()) | |
| digest.update(b"\0") | |
| version = digest.hexdigest() | |
| version_file = target / BUNDLED_SKILL_VERSION_FILE | |
| if version_file.is_file() and version_file.read_text(encoding="utf-8").strip() == version: | |
| continue | |
| staging = self.root / f".{skill_id}.bundled-{os.getpid()}-{uuid.uuid4().hex[:6]}" | |
| previous = self.root / f".{skill_id}.previous-{os.getpid()}-{uuid.uuid4().hex[:6]}" | |
| try: | |
| shutil.copytree(source, staging) | |
| (staging / BUNDLED_SKILL_VERSION_FILE).write_text(version + "\n", encoding="utf-8") | |
| if target.is_dir(): | |
| _restore_preserved_state(staging, target) | |
| os.replace(target, previous) | |
| os.replace(staging, target) | |
| shutil.rmtree(previous, ignore_errors=True) | |
| synced.append(skill_id) | |
| except Exception: | |
| shutil.rmtree(staging, ignore_errors=True) | |
| if previous.exists() and not target.exists(): | |
| os.replace(previous, target) | |
| raise | |
| self._presentation_cache.clear() | |
| self._describe_cache.clear() | |
| return synced | |
| def register_media_link( | |
| self, | |
| url: str, | |
| *, | |
| headers: dict[str, object] | None, | |
| name: str, | |
| media_type: str, | |
| ) -> dict[str, Any]: | |
| clean_url = _require_public_remote_url(url) | |
| allowed_headers = {"user-agent", "accept", "accept-language", "referer", "origin"} | |
| clean_headers = { | |
| str(key): str(value) | |
| for key, value in (headers or {}).items() | |
| if str(key).lower() in allowed_headers and str(value).strip() | |
| } | |
| token = secrets.token_urlsafe(32) | |
| expires_at = time.time() + MEDIA_PARSER_ARTIFACT_TTL_SECONDS | |
| with self._media_link_lock: | |
| current = time.time() | |
| self._media_links = { | |
| key: value | |
| for key, value in self._media_links.items() | |
| if float(value.get("expires_at") or 0) > current | |
| } | |
| self._media_links[token] = { | |
| "url": clean_url, | |
| "headers": clean_headers, | |
| "name": _safe_output_name(name), | |
| "media_type": str(media_type or "application/octet-stream"), | |
| "expires_at": expires_at, | |
| } | |
| return { | |
| "name": _safe_output_name(name), | |
| "type": str(media_type or "application/octet-stream"), | |
| "size": 0, | |
| "url": f"/api/skills/media/{token}", | |
| "expires_at": expires_at, | |
| "source_url": clean_url, | |
| "streamed": True, | |
| } | |
| def resolve_media_link(self, token: str) -> dict[str, Any]: | |
| clean_token = str(token or "").strip() | |
| if not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", clean_token): | |
| raise SkillInstallError("媒体下载链接无效") | |
| with self._media_link_lock: | |
| record = self._media_links.get(clean_token) | |
| if not record or float(record.get("expires_at") or 0) <= time.time(): | |
| self._media_links.pop(clean_token, None) | |
| raise SkillInstallError("媒体下载链接已过期,请重新解析") | |
| return dict(record) | |
| def _media_stream_artifacts(self, stdout: str) -> list[dict[str, Any]]: | |
| try: | |
| payload = json.loads(stdout) | |
| except (TypeError, ValueError): | |
| return [] | |
| results = payload.get("results") if isinstance(payload, dict) else [] | |
| if not isinstance(results, list): | |
| return [] | |
| artifacts: list[dict[str, Any]] = [] | |
| for result_index, result in enumerate(results): | |
| if not isinstance(result, dict) or result.get("error"): | |
| continue | |
| title = _safe_name(str(result.get("title") or f"media-{result_index + 1}"), f"media-{result_index + 1}") | |
| videos = result.get("videos") if isinstance(result.get("videos"), list) else [] | |
| images = result.get("images") if isinstance(result.get("images"), list) else [] | |
| for index, media in enumerate(videos): | |
| if not isinstance(media, dict) or not media.get("direct_url"): | |
| continue | |
| artifacts.append(self.register_media_link( | |
| str(media["direct_url"]), | |
| headers=media.get("headers") if isinstance(media.get("headers"), dict) else {}, | |
| name=f"{title}-video-{index + 1}.mp4", | |
| media_type="video/mp4", | |
| )) | |
| for index, media in enumerate(images): | |
| if not isinstance(media, dict) or not media.get("direct_url"): | |
| continue | |
| artifacts.append(self.register_media_link( | |
| str(media["direct_url"]), | |
| headers=media.get("headers") if isinstance(media.get("headers"), dict) else {}, | |
| name=f"{title}-image-{index + 1}.jpg", | |
| media_type="image/jpeg", | |
| )) | |
| return artifacts[:MAX_SCRIPT_OUTPUT_FILES] | |
| def cleanup_expired_runs(self, *, force: bool = False, now: float | None = None) -> int: | |
| """Remove expired temporary Skill artifacts and idempotency records.""" | |
| current = time.time() if now is None else float(now) | |
| with self._run_cleanup_lock: | |
| monotonic_now = time.monotonic() | |
| if not force and monotonic_now - self._last_run_cleanup_at < 60: | |
| return 0 | |
| self._last_run_cleanup_at = monotonic_now | |
| removed = 0 | |
| if self.runs_root.is_dir(): | |
| for marker in self.runs_root.glob("*/*/.expires-at"): | |
| try: | |
| expires_at = float(marker.read_text(encoding="utf-8").strip()) | |
| except (OSError, UnicodeError, ValueError): | |
| continue | |
| if expires_at > current: | |
| continue | |
| run_dir = marker.parent.resolve() | |
| try: | |
| run_dir.relative_to(self.runs_root.resolve()) | |
| except ValueError: | |
| continue | |
| shutil.rmtree(run_dir, ignore_errors=True) | |
| removed += 1 | |
| cache_root = self.runs_root / ".runs" | |
| if cache_root.is_dir(): | |
| for cache_path in cache_root.glob("*.json"): | |
| try: | |
| payload = json.loads(cache_path.read_text(encoding="utf-8")) | |
| expires_at = float(payload.get("expires_at") or 0) if isinstance(payload, dict) else 0 | |
| except (OSError, UnicodeError, ValueError, TypeError): | |
| continue | |
| legacy_run_dir: Path | None = None | |
| if isinstance(payload, dict) and payload.get("skill_id") == "media-parser" and not expires_at: | |
| artifacts = payload.get("artifacts") if isinstance(payload.get("artifacts"), list) else [] | |
| first_url = next((str(item.get("url") or "") for item in artifacts if isinstance(item, dict)), "") | |
| prefix = "/files/skills/" | |
| if first_url.startswith(prefix): | |
| relative = PurePosixPath(first_url[len(prefix):]) | |
| if len(relative.parts) >= 3 and ".." not in relative.parts: | |
| candidate = (self.runs_root / Path(*relative.parts[:2])).resolve() | |
| try: | |
| candidate.relative_to(self.runs_root.resolve()) | |
| if candidate.is_dir(): | |
| legacy_run_dir = candidate | |
| except ValueError: | |
| legacy_run_dir = None | |
| if legacy_run_dir is not None: | |
| expires_at = legacy_run_dir.stat().st_mtime + MEDIA_PARSER_ARTIFACT_TTL_SECONDS | |
| (legacy_run_dir / ".expires-at").write_text(str(expires_at), encoding="utf-8") | |
| payload["expires_at"] = expires_at | |
| cache_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") | |
| if expires_at and expires_at <= current: | |
| if legacy_run_dir is not None: | |
| shutil.rmtree(legacy_run_dir, ignore_errors=True) | |
| removed += 1 | |
| cache_path.unlink(missing_ok=True) | |
| return removed | |
| def _enforce_media_owner_quota(self, owner: str) -> int: | |
| owner_root = (self.runs_root / owner).resolve() | |
| try: | |
| owner_root.relative_to(self.runs_root.resolve()) | |
| except ValueError: | |
| return 0 | |
| if not owner_root.is_dir(): | |
| return 0 | |
| runs: list[tuple[float, Path, int]] = [] | |
| total = 0 | |
| for marker in owner_root.glob("*/.expires-at"): | |
| run_dir = marker.parent | |
| try: | |
| size = sum( | |
| path.stat().st_size | |
| for path in run_dir.rglob("*") | |
| if path.is_file() and path.name != ".expires-at" | |
| ) | |
| created = run_dir.stat().st_mtime | |
| except OSError: | |
| continue | |
| total += size | |
| runs.append((created, run_dir, size)) | |
| removed = 0 | |
| for _created, run_dir, size in sorted(runs): | |
| if total <= MEDIA_PARSER_OWNER_STORAGE_BYTES: | |
| break | |
| shutil.rmtree(run_dir, ignore_errors=True) | |
| total -= size | |
| removed += 1 | |
| return removed | |
| def list(self) -> list[dict[str, Any]]: | |
| self.root.mkdir(parents=True, exist_ok=True) | |
| items: list[dict[str, Any]] = [] | |
| for skill_file in sorted(self.root.glob("*/SKILL.md")): | |
| # Installations are assembled in a hidden staging directory and | |
| # atomically renamed when complete. Never expose a partial Skill. | |
| if skill_file.parent.name.startswith("."): | |
| continue | |
| skill_dir = skill_file.parent | |
| files = _scan_skill_files(skill_dir) | |
| metadata = _frontmatter(skill_file) | |
| install_metadata = self._install_metadata(skill_dir) | |
| permissions = self._permissions(skill_dir) | |
| enabled = self._visibility(skill_dir) | |
| guide_version = _guide_fingerprint(files) | |
| presentation = self._presentation_for( | |
| skill_dir, | |
| metadata, | |
| guide_version=guide_version, | |
| files=files, | |
| summary_only=True, | |
| ) | |
| items.append({ | |
| "id": skill_dir.name, | |
| "name": metadata["name"], | |
| "description": metadata["description"], | |
| "source": str(install_metadata.get("source") or "manual"), | |
| "installed_at": str(install_metadata.get("installed_at") or ""), | |
| "file_count": len(files), | |
| "scripts_allowed": bool(permissions.get("allow_scripts")), | |
| "allowed_scripts": list(permissions.get("allowed_scripts") or []), | |
| "enabled": enabled, | |
| "guide_version": guide_version, | |
| "presentation_summary": presentation, | |
| }) | |
| return items | |
| def _read_presentation_manifest(skill_dir: Path) -> dict[str, Any]: | |
| """Return skill-ui.json, or an empty config when it is absent or unusable.""" | |
| path = skill_dir / SKILL_PRESENTATION_FILE | |
| if not path.is_file(): | |
| return {} | |
| try: | |
| value = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, UnicodeError, ValueError): | |
| return {} | |
| return value if isinstance(value, dict) else {} | |
| def _read_presentation_overrides(skill_dir: Path) -> dict[str, Any]: | |
| """Return the administrator overrides, ignoring unreadable or malformed files.""" | |
| path = skill_dir / SKILL_PRESENTATION_OVERRIDE_FILE | |
| if not path.is_file(): | |
| return {} | |
| try: | |
| value = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, UnicodeError, ValueError): | |
| return {} | |
| return value if isinstance(value, dict) else {} | |
| def _merge_presentation_config(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]: | |
| result = dict(base) | |
| for key, value in overrides.items(): | |
| if key == "display" and isinstance(value, dict): | |
| current = result.get("display") if isinstance(result.get("display"), dict) else {} | |
| result["display"] = {**current, **value} | |
| else: | |
| result[key] = value | |
| return result | |
| def _safe_presentation_asset(skill_dir: Path, value: object) -> Path | None: | |
| if not isinstance(value, str) or not value.strip(): | |
| return None | |
| try: | |
| relative = _safe_relative_path(value.strip()) | |
| except SkillInstallError: | |
| return None | |
| target = (skill_dir / relative).resolve() | |
| try: | |
| target.relative_to(skill_dir.resolve()) | |
| except ValueError: | |
| return None | |
| if not target.is_file() or target.stat().st_size > MAX_FILE_BYTES: | |
| return None | |
| return target | |
| def _build_presentation( | |
| self, | |
| skill_dir: Path, | |
| metadata: dict[str, str], | |
| *, | |
| files: list[_ScannedFile] | None = None, | |
| ) -> dict[str, Any]: | |
| scanned = files if files is not None else _scan_skill_files(skill_dir) | |
| manifest = self._read_presentation_manifest(skill_dir) | |
| overrides = self._read_presentation_overrides(skill_dir) | |
| manifest = self._merge_presentation_config(manifest, overrides) | |
| display = manifest.get("display") if isinstance(manifest.get("display"), dict) else {} | |
| origin = _skill_origin(skill_dir, metadata, manifest) | |
| skill_id = skill_dir.name | |
| name = str(display.get("title") or metadata.get("name") or skill_id).strip() | |
| description = str(display.get("description") or metadata.get("description") or "").strip() | |
| category = str(display.get("category") or _presentation_category(skill_id, name, description)).strip() | |
| tags = _string_list(display.get("tags") or manifest.get("tags"), limit=12) | |
| content = "" | |
| try: | |
| content = (skill_dir / "SKILL.md").read_text(encoding="utf-8")[:50000] | |
| except (OSError, UnicodeError): | |
| content = "" | |
| searchable = f"{name}\n{description}\n{content}".lower() | |
| capabilities = _string_list(manifest.get("capabilities"), limit=8) | |
| inferred_capabilities: list[str] = [] | |
| if re.search(r"web[_ -]?search|联网|搜索", searchable): | |
| inferred_capabilities.append("web_search") | |
| if re.search(r"image[_ -]?(?:generation|edit)|生图|图生图|图片生成|图片编辑|poster|photo|logo|art", searchable): | |
| inferred_capabilities.append("image") | |
| if any((skill_dir / folder).is_dir() for folder in ("scripts",)): | |
| inferred_capabilities.append("script") | |
| if re.search(r"html|网页|interactive|交互", searchable): | |
| inferred_capabilities.append("html") | |
| if not capabilities: | |
| capabilities = inferred_capabilities or ["chat"] | |
| asset_entries: list[dict[str, Any]] = [] | |
| seen_assets: set[str] = set() | |
| def add_asset(raw: object, *, default_role: str = "gallery") -> None: | |
| item = raw if isinstance(raw, dict) else {"path": raw} | |
| path_value = item.get("path") if isinstance(item, dict) else None | |
| target = self._safe_presentation_asset(skill_dir, path_value) | |
| if target is None or target.suffix.lower() not in PRESENTATION_ASSET_EXTENSIONS: | |
| return | |
| relative = target.relative_to(skill_dir.resolve()).as_posix() | |
| if relative in seen_assets: | |
| return | |
| seen_assets.add(relative) | |
| mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" | |
| asset_entries.append({ | |
| "path": relative, | |
| "name": str(item.get("title") or item.get("name") or target.stem).strip()[:120], | |
| "description": str(item.get("description") or "").strip()[:500], | |
| "type": mime_type, | |
| "kind": _asset_kind(target, mime_type), | |
| "size": target.stat().st_size, | |
| "role": str(item.get("role") or default_role).strip()[:40], | |
| }) | |
| explicit_cover = display.get("cover") or manifest.get("cover") | |
| if explicit_cover: | |
| add_asset({"path": explicit_cover, "role": "cover", "title": name}, default_role="cover") | |
| raw_assets = manifest.get("assets") | |
| if isinstance(raw_assets, dict): | |
| raw_assets = raw_assets.get("gallery") or raw_assets.get("items") or [] | |
| if isinstance(raw_assets, list): | |
| for item in raw_assets[:24]: | |
| add_asset(item) | |
| readme_documents = sorted( | |
| (item for item in scanned if item.is_user_document), | |
| key=lambda item: _guide_document_score(item.relative), | |
| )[:4] | |
| readme_text = "" | |
| for readme in readme_documents: | |
| if readme.size > MAX_RESOURCE_BYTES: | |
| continue | |
| try: | |
| text_value = readme.path.read_text(encoding="utf-8") | |
| except (OSError, UnicodeError): | |
| continue | |
| readme_text += f"\n{text_value[:12000]}" | |
| for match in re.finditer(r"!\[[^\]]*\]\(([^)\s]+)(?:\s+[^)]*)?\)", text_value): | |
| source = match.group(1).strip("<>\"'") | |
| if re.match(r"^[a-z]+://", source, re.IGNORECASE): | |
| continue | |
| try: | |
| relative_to_skill = (readme.path.parent / source).resolve().relative_to(skill_dir.resolve()).as_posix() | |
| except (ValueError, OSError): | |
| continue | |
| add_asset(relative_to_skill) | |
| if len(asset_entries) < 24: | |
| for item in scanned: | |
| if not item.is_presentation_asset: | |
| continue | |
| parts = PurePosixPath(item.relative).parts | |
| if not parts or parts[0].lower() not in PRESENTATION_MEDIA_ROOTS: | |
| continue | |
| add_asset(item.relative) | |
| if len(asset_entries) >= 24: | |
| break | |
| cover = next((item for item in asset_entries if item["role"] == "cover"), None) | |
| if cover is None: | |
| cover = next((item for item in asset_entries if item["kind"] == "image"), None) | |
| if cover: | |
| cover = {**cover, "role": "cover"} | |
| raw_examples = manifest.get("examples") if isinstance(manifest.get("examples"), list) else [] | |
| examples: list[dict[str, Any]] = [] | |
| for index, raw in enumerate(raw_examples[:8]): | |
| if not isinstance(raw, dict): | |
| continue | |
| prompt = str(raw.get("prompt") or "").strip() | |
| if not prompt: | |
| continue | |
| examples.append({ | |
| "id": str(raw.get("id") or f"example-{index + 1}")[:80], | |
| "title": str(raw.get("title") or f"示例 {index + 1}")[:120], | |
| "description": str(raw.get("description") or "")[:500], | |
| "prompt": prompt[:4000], | |
| "assets": _string_list(raw.get("assets"), limit=8), | |
| "attachment_hint": str(raw.get("attachmentHint") or raw.get("attachment_hint") or "")[:300], | |
| }) | |
| if not examples: | |
| example_source = readme_text or content | |
| candidates = [ | |
| match.group(1).strip() | |
| for match in re.finditer(r"```(?:text|prompt|markdown)?\s*\n([\s\S]{12,1200}?)\n```", example_source, re.IGNORECASE) | |
| ] | |
| candidates.extend(match.group(0).strip() for match in re.finditer(rf"\${re.escape(skill_id)}\b[^\n]{{4,500}}", example_source, re.IGNORECASE)) | |
| for index, prompt in enumerate(candidates[:4]): | |
| if not prompt.startswith(f"${skill_id}"): | |
| prompt = f"${skill_id} {prompt}" | |
| examples.append({"id": f"auto-{index + 1}", "title": f"示例 {index + 1}", "description": "", "prompt": prompt, "assets": [], "attachment_hint": ""}) | |
| if not examples: | |
| examples.append({ | |
| "id": "starter", | |
| "title": "从你的需求开始", | |
| "description": description, | |
| "prompt": f"${skill_id} 请根据我接下来提供的要求完成任务", | |
| "assets": [], | |
| "attachment_hint": "如任务依赖图片或文件,请一并上传。", | |
| }) | |
| def normalize_specs(key: str, defaults: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| raw_specs = manifest.get(key) | |
| if not isinstance(raw_specs, list): | |
| return defaults | |
| result: list[dict[str, Any]] = [] | |
| for index, raw in enumerate(raw_specs[:12]): | |
| if isinstance(raw, str): | |
| raw = {"label": raw} | |
| if not isinstance(raw, dict) or not str(raw.get("label") or raw.get("name") or "").strip(): | |
| continue | |
| result.append({ | |
| "id": str(raw.get("id") or f"{key}-{index + 1}")[:80], | |
| "label": str(raw.get("label") or raw.get("name"))[:120], | |
| "type": str(raw.get("type") or "text")[:40], | |
| "required": bool(raw.get("required", False)), | |
| "description": str(raw.get("description") or "")[:500], | |
| }) | |
| return result or defaults | |
| default_inputs = [{"id": "request", "label": "任务要求", "type": "text", "required": True, "description": "说明目标、风格、尺寸或交付要求。"}] | |
| input_searchable = f"{searchable}\n{readme_text.lower()}" | |
| has_image_input = "image" in capabilities and bool(re.search( | |
| r"supplied image|uploaded image|user[- ]provided image|source image|reference image|" | |
| r"参考图|上传图片|上传照片|原图|照片", | |
| input_searchable, | |
| )) | |
| # Auto-discovered image inputs are suggestions, never hard gates. An | |
| # administrator can explicitly mark an image input as required through | |
| # skill-ui.json or the presentation overrides editor. | |
| image_input_required = False | |
| if has_image_input: | |
| default_inputs.append({ | |
| "id": "image", | |
| "label": "参考图片", | |
| "type": "image", | |
| "required": image_input_required, | |
| "description": "上传需要处理或参考的图片。" if image_input_required else "如需基于现有画面处理,可上传参考图片。", | |
| }) | |
| inputs = normalize_specs("inputs", default_inputs) | |
| default_outputs: list[dict[str, Any]] = [] | |
| if "image" in capabilities: | |
| default_outputs.append({"id": "image", "label": "图片结果", "type": "image", "required": False, "description": "可预览、下载并继续作为参考图使用。"}) | |
| if "html" in capabilities: | |
| default_outputs.append({"id": "html", "label": "互动 HTML", "type": "html", "required": False, "description": "在安全沙箱中预览,可手动启用交互。"}) | |
| if "script" in capabilities: | |
| default_outputs.append({"id": "file", "label": "文件产物", "type": "file", "required": False, "description": "由受控脚本生成的可下载文件。"}) | |
| if not default_outputs: | |
| default_outputs.append({"id": "answer", "label": "结构化结果", "type": "text", "required": False, "description": "在对话中直接交付。"}) | |
| outputs = normalize_specs("outputs", default_outputs) | |
| workflow: list[dict[str, Any]] = [] | |
| workflow_summary = "" | |
| workflow_path = skill_dir / SKILL_WORKFLOW_FILE | |
| if workflow_path.is_file(): | |
| try: | |
| raw_workflow = json.loads(workflow_path.read_text(encoding="utf-8")) | |
| workflow_summary = str(raw_workflow.get("summary") or "").strip()[:500] if isinstance(raw_workflow, dict) else "" | |
| tasks = raw_workflow.get("tasks") if isinstance(raw_workflow, dict) else [] | |
| if not isinstance(tasks, list) or not tasks: | |
| raise ValueError("tasks missing") | |
| for index, task in enumerate(tasks[:32]): | |
| if not isinstance(task, dict): | |
| continue | |
| workflow.append({ | |
| "id": str(task.get("id") or f"step-{index + 1}")[:80], | |
| "title": str(task.get("title") or task.get("id") or f"步骤 {index + 1}")[:120], | |
| "description": str(task.get("description") or task.get("prompt") or "")[:500], | |
| "capability": str(task.get("capability") or ("image" if "image" in capabilities else "task"))[:40], | |
| }) | |
| postprocess = raw_workflow.get("postprocess") if isinstance(raw_workflow, dict) else None | |
| if isinstance(postprocess, dict) and postprocess.get("script"): | |
| workflow.append({"id": "postprocess", "title": str(postprocess.get("title") or "整理最终产物")[:120], "description": "将前面步骤的结果组装为最终文件。", "capability": "script"}) | |
| except (OSError, UnicodeError, ValueError, TypeError): | |
| # A malformed workflow file simply falls back to the plain Skill flow. | |
| workflow = [] | |
| result = { | |
| "version": 1, | |
| "title": name, | |
| "description": description, | |
| "category": category, | |
| "tags": tags, | |
| "capabilities": capabilities, | |
| "origin": origin, | |
| "cover": cover, | |
| "asset_count": len(asset_entries), | |
| "example_count": len(examples), | |
| "input_count": len(inputs), | |
| "output_count": len(outputs), | |
| "workflow_count": len(workflow), | |
| } | |
| return { | |
| **result, | |
| "assets": asset_entries, | |
| "examples": examples, | |
| "inputs": inputs, | |
| "outputs": outputs, | |
| "workflow": {"summary": workflow_summary, "steps": workflow}, | |
| "manifest": { | |
| "present": bool(manifest), | |
| "path": SKILL_PRESENTATION_FILE, | |
| "overridden": bool(overrides), | |
| "override_path": SKILL_PRESENTATION_OVERRIDE_FILE, | |
| }, | |
| } | |
| def _presentation_for( | |
| self, | |
| skill_dir: Path, | |
| metadata: dict[str, str], | |
| *, | |
| guide_version: str | None = None, | |
| files: list[_ScannedFile] | None = None, | |
| summary_only: bool = False, | |
| ) -> dict[str, Any]: | |
| """Return the cached full presentation, projecting a summary on demand. | |
| The summary used to live under its own cache key, which meant the list | |
| request and the detail request could never share a build. Both views now | |
| read one entry, so opening a Skill normally reuses work already done. | |
| """ | |
| version = guide_version or (_guide_fingerprint(files) if files is not None else self._guide_version(skill_dir)) | |
| key = (str(skill_dir.resolve()), version) | |
| cached = self._presentation_cache.get(key) | |
| if cached is None: | |
| cached = self._build_presentation(skill_dir, metadata, files=files) | |
| self._presentation_cache[key] = cached | |
| if len(self._presentation_cache) > 256: | |
| self._presentation_cache = dict(list(self._presentation_cache.items())[-128:]) | |
| return _presentation_summary(cached) if summary_only else cached | |
| def presentation(self, skill_id: str) -> dict[str, Any]: | |
| skill_dir = self._skill_dir(skill_id) | |
| metadata = _frontmatter(skill_dir / "SKILL.md") | |
| return copy.deepcopy(self._presentation_for(skill_dir, metadata)) | |
| def _invalidate_caches(self, skill_dir: Path) -> None: | |
| prefix = str(skill_dir.resolve()) | |
| self._presentation_cache = { | |
| key: value for key, value in self._presentation_cache.items() if key[0] != prefix | |
| } | |
| self._describe_cache = { | |
| key: value for key, value in self._describe_cache.items() if key[0] != prefix | |
| } | |
| def reindex_presentation(self, skill_id: str) -> dict[str, Any]: | |
| skill_dir = self._skill_dir(skill_id) | |
| self._invalidate_caches(skill_dir) | |
| return self.presentation(skill_id) | |
| def update_presentation(self, skill_id: str, value: dict[str, Any]) -> dict[str, Any]: | |
| skill_dir = self._skill_dir(skill_id) | |
| allowed = {"display", "assets", "examples", "inputs", "outputs", "capabilities", "tags", "author", "version", "source"} | |
| current = self._read_presentation_overrides(skill_dir) | |
| incoming = {key: item for key, item in value.items() if key in allowed} | |
| payload = self._merge_presentation_config(current, incoming) | |
| display = payload.get("display") | |
| if display is not None and not isinstance(display, dict): | |
| raise SkillInstallError("display 必须是对象") | |
| if isinstance(display, dict): | |
| payload["display"] = { | |
| key: item for key, item in display.items() | |
| if key in {"title", "description", "category", "cover", "tags"} | |
| } | |
| for key in ("assets", "examples", "inputs", "outputs", "capabilities", "tags"): | |
| if key in payload and not isinstance(payload[key], list): | |
| raise SkillInstallError(f"{key} 必须是数组") | |
| path = skill_dir / SKILL_PRESENTATION_OVERRIDE_FILE | |
| temporary = path.with_suffix(f"{path.suffix}.{uuid.uuid4().hex}.tmp") | |
| temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") | |
| os.replace(temporary, path) | |
| return self.reindex_presentation(skill_id) | |
| def upload_presentation_assets( | |
| self, | |
| skill_id: str, | |
| uploads: list[tuple[bytes, str, str]], | |
| ) -> list[dict[str, Any]]: | |
| skill_dir = self._skill_dir(skill_id) | |
| if not uploads: | |
| raise SkillInstallError("请选择要上传的展示图片") | |
| if len(uploads) > 12: | |
| raise SkillInstallError("一次最多上传 12 张展示图片") | |
| target_dir = skill_dir / SKILL_PRESENTATION_ASSET_DIR | |
| target_dir.mkdir(parents=True, exist_ok=True) | |
| results: list[dict[str, Any]] = [] | |
| allowed_extensions = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"} | |
| for content, original_name, content_type in uploads: | |
| if not content or len(content) > MAX_FILE_BYTES: | |
| raise SkillInstallError(f"展示图片 {original_name or '未命名文件'} 为空或超过 8MB") | |
| suffix = Path(original_name or "image").suffix.lower() | |
| if suffix not in allowed_extensions or not str(content_type or "").lower().startswith("image/"): | |
| raise SkillInstallError(f"展示素材只支持 PNG、JPG、WebP、GIF 和 SVG:{original_name}") | |
| stem = _safe_name(Path(original_name).stem, "showcase")[:48] | |
| filename = f"{stem}-{uuid.uuid4().hex[:10]}{suffix}" | |
| target = target_dir / filename | |
| target.write_bytes(content) | |
| relative = target.relative_to(skill_dir).as_posix() | |
| mime_type = mimetypes.guess_type(target.name)[0] or content_type or "application/octet-stream" | |
| results.append({ | |
| "path": relative, | |
| "name": Path(original_name).stem[:120] or stem, | |
| "description": "", | |
| "type": mime_type, | |
| "kind": "image", | |
| "size": len(content), | |
| "role": "gallery", | |
| }) | |
| self.reindex_presentation(skill_id) | |
| return results | |
| def _read_favorites(self) -> dict[str, list[str]]: | |
| if not self.favorites_path.is_file(): | |
| return {} | |
| try: | |
| value = json.loads(self.favorites_path.read_text(encoding="utf-8")) | |
| except (OSError, UnicodeError, ValueError): | |
| return {} | |
| if not isinstance(value, dict): | |
| return {} | |
| result: dict[str, list[str]] = {} | |
| for owner, items in value.items(): | |
| if isinstance(owner, str) and isinstance(items, list): | |
| result[owner] = [str(item) for item in items if isinstance(item, str)] | |
| return result | |
| def favorite_ids(self, owner_id: str) -> list[str]: | |
| owner = str(owner_id or "").strip() | |
| if not owner: | |
| return [] | |
| with self._favorites_lock: | |
| known = {item["id"] for item in self.catalog()} | |
| return [item for item in self._read_favorites().get(owner, []) if item in known] | |
| def set_favorite(self, owner_id: str, skill_id: str, *, favorite: bool) -> list[str]: | |
| self.ensure_enabled(skill_id) | |
| owner = str(owner_id or "").strip() | |
| if not owner: | |
| raise SkillInstallError("用户身份无效") | |
| with self._favorites_lock: | |
| value = self._read_favorites() | |
| items = list(dict.fromkeys(value.get(owner, []))) | |
| if favorite and skill_id not in items: | |
| items.append(skill_id) | |
| if not favorite: | |
| items = [item for item in items if item != skill_id] | |
| value[owner] = items | |
| self.favorites_path.parent.mkdir(parents=True, exist_ok=True) | |
| temporary = self.favorites_path.with_suffix(f"{self.favorites_path.suffix}.{uuid.uuid4().hex}.tmp") | |
| temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8") | |
| os.replace(temporary, self.favorites_path) | |
| return items | |
| def resolve_asset(self, skill_id: str, asset_path: str) -> tuple[Path, str]: | |
| skill_dir = self._skill_dir(skill_id) | |
| target = self._safe_presentation_asset(skill_dir, asset_path) | |
| if target is None or target.suffix.lower() not in PRESENTATION_ASSET_EXTENSIONS: | |
| raise SkillInstallError("Skill 展示素材不存在、过大或类型不受支持") | |
| return target, mimetypes.guess_type(target.name)[0] or "application/octet-stream" | |
| def catalog(self, *, include_content: bool = False) -> list[dict[str, Any]]: | |
| items = [item for item in self.list() if item.get("enabled", True)] | |
| if not include_content: | |
| return items | |
| for item in items: | |
| skill_file = self.root / str(item["id"]) / "SKILL.md" | |
| try: | |
| item["content"] = skill_file.read_text(encoding="utf-8")[:50000] | |
| except (OSError, UnicodeError): | |
| item["content"] = "" | |
| return items | |
| def _skill_dir(self, skill_id: str) -> Path: | |
| safe_id = _safe_name(skill_id) | |
| if safe_id != skill_id or not safe_id or safe_id.startswith("."): | |
| raise SkillInstallError("Skill ID 无效") | |
| target = (self.root / safe_id).resolve() | |
| root = self.root.resolve() | |
| try: | |
| target.relative_to(root) | |
| except ValueError as exc: | |
| raise SkillInstallError("Skill 路径无效") from exc | |
| if not target.is_dir() or not (target / "SKILL.md").is_file(): | |
| raise SkillInstallError("Skill 不存在") | |
| return target | |
| def _install_metadata(skill_dir: Path) -> dict[str, Any]: | |
| """Return .install.json — where the Skill came from and when.""" | |
| path = skill_dir / ".install.json" | |
| if not path.is_file(): | |
| return {} | |
| try: | |
| value = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, ValueError): | |
| return {} | |
| return value if isinstance(value, dict) else {} | |
| def _install_record( | |
| self, | |
| *, | |
| staging: Path, | |
| target: Path, | |
| source: str, | |
| bundle_entry: dict[str, Any] | None, | |
| package_roots: list[str], | |
| ) -> dict[str, Any]: | |
| """Build the .install.json written for a freshly installed Skill. | |
| The recorded origin is what decides whether an administrator can pull | |
| updates, so provenance carried by the package must survive the install: | |
| a migrated export bundle keeps the origin of the deployment it came | |
| from (that is the whole point of migrating), and re-uploading a plain | |
| ZIP over an existing Skill keeps that Skill's known origin instead of | |
| downgrading it to an anonymous local upload. Only archives that declare | |
| the bundle format may set their own origin — a repository that happens | |
| to ship a .install.json must not redirect the update address. | |
| """ | |
| import datetime | |
| incoming = self._install_metadata(staging) if bundle_entry is not None else {} | |
| manifest_entry = bundle_entry or {} | |
| preserved_source = str(incoming.get("source") or manifest_entry.get("source") or "").strip() | |
| preserved_installed_at = str(incoming.get("installed_at") or manifest_entry.get("installed_at") or "").strip() | |
| origin = "bundle" if preserved_source else "" | |
| if not preserved_source and source.startswith("upload:") and target.is_dir(): | |
| previous = self._install_metadata(target) | |
| previous_source = str(previous.get("source") or "").strip() | |
| if previous_source: | |
| preserved_source = previous_source | |
| preserved_installed_at = str(previous.get("installed_at") or "").strip() | |
| origin = "previous" | |
| record: dict[str, Any] = dict(incoming) if origin == "bundle" else {} | |
| record["source"] = preserved_source or source | |
| record["installed_at"] = ( | |
| preserved_installed_at or datetime.datetime.now(datetime.timezone.utc).isoformat() | |
| ) | |
| record["package_roots"] = package_roots | |
| if origin: | |
| record["imported_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat() | |
| record["imported_from"] = source | |
| record["origin_preserved"] = origin | |
| return record | |
| def _permissions(skill_dir: Path) -> dict[str, Any]: | |
| path = skill_dir / ".permissions.json" | |
| raw: dict[str, Any] = {} | |
| if path.is_file(): | |
| try: | |
| value = json.loads(path.read_text(encoding="utf-8")) | |
| if isinstance(value, dict): | |
| raw = value | |
| except (OSError, ValueError): | |
| raw = {} | |
| allowed = [ | |
| str(item).replace("\\", "/") | |
| for item in raw.get("allowed_scripts") or [] | |
| if isinstance(item, str) and item.strip() | |
| ] | |
| try: | |
| timeout = min(MAX_SCRIPT_TIMEOUT_SECONDS, max(5, int(raw.get("timeout_seconds") or DEFAULT_SCRIPT_TIMEOUT_SECONDS))) | |
| except (TypeError, ValueError): | |
| timeout = DEFAULT_SCRIPT_TIMEOUT_SECONDS | |
| return { | |
| "allow_scripts": bool(raw.get("allow_scripts")), | |
| "allowed_scripts": allowed, | |
| "timeout_seconds": timeout, | |
| } | |
| def _visibility(skill_dir: Path) -> bool: | |
| path = skill_dir / ".visibility.json" | |
| if not path.is_file(): | |
| return True | |
| try: | |
| value = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, ValueError): | |
| return True | |
| return bool(value.get("enabled", True)) if isinstance(value, dict) else True | |
| def _guide_version(skill_dir: Path) -> str: | |
| return _guide_fingerprint(_scan_skill_files(skill_dir)) | |
| def ensure_enabled(self, skill_id: str) -> None: | |
| skill_dir = self._skill_dir(skill_id) | |
| if not self._visibility(skill_dir): | |
| raise SkillInstallError("Skill 已被管理员隐藏") | |
| def guide_context(self, skill_id: str) -> dict[str, Any]: | |
| skill_dir = self._skill_dir(skill_id) | |
| files = _scan_skill_files(skill_dir) | |
| metadata = _frontmatter(skill_dir / "SKILL.md") | |
| documents: list[dict[str, str]] = [] | |
| candidates = sorted( | |
| (item for item in files if item.is_user_document), | |
| key=lambda item: _guide_document_score(item.relative), | |
| )[:4] | |
| for item in candidates: | |
| if item.size > MAX_RESOURCE_BYTES: | |
| continue | |
| try: | |
| content = item.path.read_text(encoding="utf-8") | |
| except (OSError, UnicodeError): | |
| continue | |
| documents.append({"path": item.relative, "content": content[:10000]}) | |
| return { | |
| "id": skill_id, | |
| "name": metadata["name"], | |
| "description": metadata["description"], | |
| "version": _guide_fingerprint(files), | |
| "content": (skill_dir / "SKILL.md").read_text(encoding="utf-8")[:MAX_RESOURCE_BYTES], | |
| "documents": documents, | |
| } | |
| def read_cached_guide(self, skill_id: str) -> dict[str, Any] | None: | |
| skill_dir = self._skill_dir(skill_id) | |
| path = skill_dir / GUIDE_CACHE_FILE | |
| if not path.is_file(): | |
| return None | |
| try: | |
| value = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, ValueError): | |
| return None | |
| version = self._guide_version(skill_dir) | |
| guide = str(value.get("guide") or "").strip() if isinstance(value, dict) else "" | |
| if not guide or value.get("version") != version: | |
| return None | |
| return {"guide": guide, "version": version, "cached": True} | |
| def write_cached_guide(self, skill_id: str, *, version: str, guide: str) -> dict[str, Any]: | |
| skill_dir = self._skill_dir(skill_id) | |
| current_version = self._guide_version(skill_dir) | |
| clean_guide = guide.strip() | |
| if version != current_version or not clean_guide: | |
| raise SkillInstallError("Skill 文档已更新,请重新生成使用指南") | |
| (skill_dir / GUIDE_CACHE_FILE).write_text( | |
| json.dumps({"version": current_version, "guide": clean_guide}, ensure_ascii=False), | |
| encoding="utf-8", | |
| ) | |
| return {"guide": clean_guide, "version": current_version, "cached": False} | |
| def describe(self, skill_id: str, *, include_content: bool = True) -> dict[str, Any]: | |
| return self._describe(skill_id, include_content=include_content)[0] | |
| def describe_with_etag(self, skill_id: str, *, include_content: bool = True) -> tuple[dict[str, Any], str]: | |
| payload, version = self._describe(skill_id, include_content=include_content) | |
| return payload, f'"{version}"' | |
| def _describe(self, skill_id: str, *, include_content: bool) -> tuple[dict[str, Any], str]: | |
| skill_dir = self._skill_dir(skill_id) | |
| files = _scan_skill_files(skill_dir) | |
| content_version = _content_fingerprint(files) | |
| cache_key = (str(skill_dir.resolve()), content_version, include_content) | |
| cached = self._describe_cache.get(cache_key) | |
| if cached is not None: | |
| return copy.deepcopy(cached), content_version | |
| metadata = _frontmatter(skill_dir / "SKILL.md") | |
| permissions = self._permissions(skill_dir) | |
| resources: list[dict[str, Any]] = [] | |
| scripts: list[dict[str, Any]] = [] | |
| for item in files: | |
| if item.name in { | |
| ".install.json", | |
| ".permissions.json", | |
| ".visibility.json", | |
| GUIDE_CACHE_FILE, | |
| SKILL_PRESENTATION_OVERRIDE_FILE, | |
| }: | |
| continue | |
| kind = _script_kind(item.path) | |
| entry = { | |
| "path": item.relative, | |
| "size": item.size, | |
| "type": mimetypes.guess_type(item.name)[0] or "application/octet-stream", | |
| "readable": item.suffix in TEXT_RESOURCE_EXTENSIONS and item.size <= MAX_RESOURCE_BYTES, | |
| } | |
| resources.append(entry) | |
| if item.relative.startswith("scripts/") and kind: | |
| scripts.append({ | |
| **entry, | |
| "kind": kind, | |
| "allowed": bool(permissions["allow_scripts"]) and ( | |
| not permissions["allowed_scripts"] or item.relative in permissions["allowed_scripts"] | |
| ), | |
| }) | |
| result: dict[str, Any] = { | |
| "id": skill_id, | |
| "name": metadata["name"], | |
| "description": metadata["description"], | |
| "resources": resources, | |
| "scripts": scripts, | |
| "permissions": permissions, | |
| "enabled": self._visibility(skill_dir), | |
| "presentation": self._presentation_for( | |
| skill_dir, | |
| metadata, | |
| guide_version=_guide_fingerprint(files), | |
| files=files, | |
| ), | |
| } | |
| if include_content: | |
| result["content"] = (skill_dir / "SKILL.md").read_text(encoding="utf-8")[:MAX_RESOURCE_BYTES] | |
| self._describe_cache[cache_key] = result | |
| if len(self._describe_cache) > 128: | |
| self._describe_cache = dict(list(self._describe_cache.items())[-64:]) | |
| return copy.deepcopy(result), content_version | |
| def read_resource(self, skill_id: str, resource_path: str) -> dict[str, Any]: | |
| skill_dir = self._skill_dir(skill_id) | |
| relative = _safe_relative_path(resource_path) | |
| target = (skill_dir / relative).resolve() | |
| try: | |
| target.relative_to(skill_dir) | |
| except ValueError as exc: | |
| raise SkillInstallError("资源路径超出 Skill 目录") from exc | |
| if not target.is_file(): | |
| raise SkillInstallError("Skill 资源不存在") | |
| size = target.stat().st_size | |
| if size > MAX_RESOURCE_BYTES: | |
| raise SkillInstallError(f"Skill 文本资源不能超过 {MAX_RESOURCE_BYTES // 1024}KB") | |
| if not _is_text_resource(target): | |
| raise SkillInstallError("该资源不是可读取的文本文件") | |
| try: | |
| content = target.read_text(encoding="utf-8") | |
| except UnicodeError as exc: | |
| raise SkillInstallError("Skill 资源不是 UTF-8 文本") from exc | |
| return { | |
| "skill_id": skill_id, | |
| "path": relative.as_posix(), | |
| "content": content, | |
| "size": size, | |
| } | |
| def set_permissions( | |
| self, | |
| skill_id: str, | |
| *, | |
| allow_scripts: bool, | |
| allowed_scripts: list[str] | None = None, | |
| timeout_seconds: int = DEFAULT_SCRIPT_TIMEOUT_SECONDS, | |
| ) -> dict[str, Any]: | |
| skill_dir = self._skill_dir(skill_id) | |
| known_scripts = { | |
| path.relative_to(skill_dir).as_posix() | |
| for path in skill_dir.rglob("*") | |
| if path.is_file() and path.relative_to(skill_dir).as_posix().startswith("scripts/") and _script_kind(path) | |
| } | |
| requested: list[str] = [] | |
| for item in allowed_scripts or []: | |
| relative = _safe_relative_path(item).as_posix() | |
| if relative not in known_scripts: | |
| raise SkillInstallError(f"脚本不存在或类型不受支持:{relative}") | |
| if relative not in requested: | |
| requested.append(relative) | |
| payload = { | |
| "allow_scripts": bool(allow_scripts), | |
| "allowed_scripts": requested, | |
| "timeout_seconds": min(MAX_SCRIPT_TIMEOUT_SECONDS, max(5, int(timeout_seconds))), | |
| } | |
| (skill_dir / ".permissions.json").write_text( | |
| json.dumps(payload, ensure_ascii=False, indent=2), | |
| encoding="utf-8", | |
| ) | |
| return self.describe(skill_id, include_content=False) | |
| def set_visibility(self, skill_id: str, *, enabled: bool) -> dict[str, Any]: | |
| skill_dir = self._skill_dir(skill_id) | |
| (skill_dir / ".visibility.json").write_text( | |
| json.dumps({"enabled": bool(enabled)}, ensure_ascii=False, indent=2), | |
| encoding="utf-8", | |
| ) | |
| return self.describe(skill_id, include_content=False) | |
| def run_script( | |
| self, | |
| skill_id: str, | |
| identity: dict[str, object], | |
| *, | |
| script_path: str, | |
| args: list[str], | |
| uploads: list[tuple[bytes, str, str]], | |
| client_run_id: str = "", | |
| ) -> dict[str, Any]: | |
| self.cleanup_expired_runs() | |
| skill_dir = self._skill_dir(skill_id) | |
| permissions = self._permissions(skill_dir) | |
| if not permissions["allow_scripts"]: | |
| raise PermissionError("管理员尚未允许这个 Skill 执行脚本") | |
| relative = _safe_relative_path(script_path) | |
| relative_posix = relative.as_posix() | |
| if not relative_posix.startswith("scripts/"): | |
| raise SkillInstallError("只允许执行 Skill 的 scripts/ 目录中的脚本") | |
| if permissions["allowed_scripts"] and relative_posix not in permissions["allowed_scripts"]: | |
| raise PermissionError("该脚本不在管理员白名单中") | |
| script = (skill_dir / relative).resolve() | |
| try: | |
| script.relative_to(skill_dir) | |
| except ValueError as exc: | |
| raise SkillInstallError("脚本路径超出 Skill 目录") from exc | |
| kind = _script_kind(script) | |
| if not script.is_file() or not kind: | |
| raise SkillInstallError("脚本不存在或类型不受支持") | |
| if len(args) > MAX_SCRIPT_ARGS: | |
| raise SkillInstallError(f"脚本参数不能超过 {MAX_SCRIPT_ARGS} 个") | |
| owner = _safe_name(str(identity.get("id") or "user"), "user") | |
| clean_client_run_id = str(client_run_id or "").strip() | |
| if len(clean_client_run_id) > 200 or "\x00" in clean_client_run_id: | |
| raise SkillInstallError("client_run_id 无效") | |
| cache_path: Path | None = None | |
| if clean_client_run_id: | |
| cache_key = hashlib.sha256(f"{owner}:{skill_id}:{clean_client_run_id}".encode("utf-8")).hexdigest() | |
| cache_path = self.runs_root / ".runs" / f"{cache_key}.json" | |
| if cache_path.is_file(): | |
| try: | |
| cached = json.loads(cache_path.read_text(encoding="utf-8")) | |
| if isinstance(cached, dict): | |
| expires_at = float(cached.get("expires_at") or 0) | |
| cached_artifacts = cached.get("artifacts") if isinstance(cached.get("artifacts"), list) else [] | |
| cached_run_id = _safe_name(str(cached.get("run_id") or ""), "") | |
| def cached_artifact_exists(item: object) -> bool: | |
| if not isinstance(item, dict): | |
| return True | |
| artifact_url = str(item.get("url") or "") | |
| if item.get("streamed") and artifact_url.startswith("/api/skills/media/"): | |
| try: | |
| self.resolve_media_link(artifact_url.rsplit("/", 1)[-1]) | |
| return True | |
| except SkillInstallError: | |
| return False | |
| return ( | |
| self.runs_root | |
| / owner | |
| / cached_run_id | |
| / _safe_output_name(str(item.get("name") or "artifact")) | |
| ).is_file() | |
| artifacts_exist = all(cached_artifact_exists(item) for item in cached_artifacts) | |
| if (expires_at and expires_at <= time.time()) or not artifacts_exist: | |
| cache_path.unlink(missing_ok=True) | |
| else: | |
| return cached | |
| except (OSError, ValueError): | |
| pass | |
| run_id = f"{int(time.time())}-{uuid.uuid4().hex[:12]}" | |
| durable_dir = self.runs_root / owner / run_id | |
| with tempfile.TemporaryDirectory(prefix=f"skill-{skill_id}-") as temp_name: | |
| workspace = Path(temp_name).resolve() | |
| input_dir = workspace / "inputs" | |
| output_dir = workspace / "outputs" | |
| home_dir = workspace / "home" | |
| input_dir.mkdir() | |
| output_dir.mkdir() | |
| home_dir.mkdir() | |
| input_paths: list[Path] = [] | |
| total_upload_bytes = 0 | |
| for index, (data, name, _mime_type) in enumerate(uploads): | |
| total_upload_bytes += len(data) | |
| if total_upload_bytes > MAX_SCRIPT_OUTPUT_BYTES: | |
| raise SkillInstallError("脚本输入文件总大小不能超过 25MB") | |
| safe_name = _safe_output_name(name or f"input-{index + 1}") | |
| target = input_dir / f"{index + 1:02d}-{safe_name}" | |
| target.write_bytes(data) | |
| input_paths.append(target) | |
| output_pattern = re.compile(r"\{\{output:([^{}]+)\}\}") | |
| input_pattern = re.compile(r"\{\{input:(\d+)\}\}") | |
| def resolve_argument(raw: str) -> str: | |
| value = str(raw) | |
| # Arguments are passed as an argv list without a shell. Keep | |
| # NUL forbidden, but allow line breaks so parser Skills can | |
| # process pasted share text containing several URLs. | |
| if len(value) > MAX_SCRIPT_ARG_CHARS or "\x00" in value: | |
| raise SkillInstallError("脚本参数无效或过长") | |
| used_placeholder = False | |
| def input_replacement(match: re.Match[str]) -> str: | |
| nonlocal used_placeholder | |
| used_placeholder = True | |
| index = int(match.group(1)) | |
| if index < 0 or index >= len(input_paths): | |
| raise SkillInstallError(f"输入文件索引不存在:{index}") | |
| return str(input_paths[index]) | |
| def output_replacement(match: re.Match[str]) -> str: | |
| nonlocal used_placeholder | |
| used_placeholder = True | |
| return str(output_dir / _safe_output_name(match.group(1))) | |
| value = input_pattern.sub(input_replacement, value) | |
| value = output_pattern.sub(output_replacement, value) | |
| for token, replacement in { | |
| "{{workspace}}": workspace, | |
| "{{skill}}": skill_dir, | |
| "{{outputs}}": output_dir, | |
| }.items(): | |
| if token in value: | |
| used_placeholder = True | |
| value = value.replace(token, str(replacement)) | |
| if "{{" in value or "}}" in value: | |
| raise SkillInstallError("脚本参数包含未知占位符") | |
| if not used_placeholder: | |
| candidate = Path(value) | |
| if candidate.is_absolute() or PurePosixPath(value.replace("\\", "/")).is_absolute() or ".." in PurePosixPath(value.replace("\\", "/")).parts: | |
| raise SkillInstallError("脚本参数中的文件路径必须使用受控占位符") | |
| return value | |
| resolved_args = [resolve_argument(item) for item in args] | |
| if kind == "python": | |
| # Keep Python isolated from user/site configuration while still | |
| # allowing a Skill entry point to import sibling modules such as | |
| # scripts/core.py. The bootstrap is fixed by the host; neither | |
| # the Skill nor the model can inject code into it. | |
| bootstrap = ( | |
| "import runpy,sys; " | |
| "sys.path.insert(0,sys.argv[1]); " | |
| "sys.argv=sys.argv[2:]; " | |
| "runpy.run_path(sys.argv[0],run_name='__main__')" | |
| ) | |
| command = [ | |
| sys.executable, | |
| "-I", | |
| "-c", | |
| bootstrap, | |
| str(script.parent), | |
| str(script), | |
| *resolved_args, | |
| ] | |
| else: | |
| node = shutil.which("node") | |
| if not node: | |
| raise SkillInstallError("服务器没有可用的 Node.js 运行时") | |
| command = [node, str(script), *resolved_args] | |
| env = { | |
| "PATH": os.environ.get("PATH", ""), | |
| "PYTHONIOENCODING": "utf-8", | |
| "PYTHONUTF8": "1", | |
| "HOME": str(home_dir), | |
| "USERPROFILE": str(home_dir), | |
| "TEMP": str(workspace), | |
| "TMP": str(workspace), | |
| "SKILL_DIR": str(skill_dir), | |
| "SKILL_WORKSPACE": str(workspace), | |
| "SKILL_OUTPUT_DIR": str(output_dir), | |
| } | |
| # Windows needs these variables to initialise Winsock. Omitting | |
| # them from the deliberately small child environment makes even | |
| # ``import asyncio`` fail with WinError 10106 before the Skill's | |
| # own code starts. They identify system directories only and do | |
| # not broaden the script's data or credential access. | |
| if os.name == "nt": | |
| for name in ("SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT"): | |
| value = os.environ.get(name, "") | |
| if value: | |
| env[name] = value | |
| started_at = time.monotonic() | |
| try: | |
| completed = subprocess.run( | |
| command, | |
| cwd=workspace, | |
| env=env, | |
| stdin=subprocess.DEVNULL, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| encoding="utf-8", | |
| errors="replace", | |
| timeout=int(permissions["timeout_seconds"]), | |
| check=False, | |
| creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), | |
| ) | |
| except subprocess.TimeoutExpired as exc: | |
| raise SkillInstallError(f"脚本执行超过 {permissions['timeout_seconds']} 秒,已终止") from exc | |
| # Empty cache markers/lock files are implementation details, not | |
| # user-facing artifacts. Do not render them as bogus downloads. | |
| output_candidates = [ | |
| path for path in output_dir.rglob("*") | |
| if path.is_file() and path.stat().st_size > 0 | |
| ] | |
| if len(output_candidates) > MAX_SCRIPT_OUTPUT_FILES: | |
| raise SkillInstallError(f"脚本输出文件不能超过 {MAX_SCRIPT_OUTPUT_FILES} 个") | |
| total_output_bytes = sum(path.stat().st_size for path in output_candidates) | |
| output_limit = MEDIA_PARSER_OUTPUT_BYTES if skill_id.lower() == "media-parser" else MAX_SCRIPT_OUTPUT_BYTES | |
| if total_output_bytes > output_limit: | |
| raise SkillInstallError(f"脚本输出文件总大小不能超过 {output_limit // 1024 // 1024}MB") | |
| artifacts: list[dict[str, Any]] = [] | |
| expires_at: float | None = None | |
| if output_candidates: | |
| durable_dir.mkdir(parents=True, exist_ok=False) | |
| for source in output_candidates: | |
| name = _safe_output_name(source.name) | |
| target = durable_dir / name | |
| suffix = 2 | |
| while target.exists(): | |
| target = durable_dir / f"{Path(name).stem}-{suffix}{Path(name).suffix}" | |
| suffix += 1 | |
| shutil.copy2(source, target) | |
| try: | |
| relative_url = target.relative_to(DATA_DIR / "files").as_posix() | |
| except ValueError: | |
| # Custom roots are used by tests and embedders. Production | |
| # keeps runs under DATA_DIR/files and uses the first path. | |
| relative_url = (Path("skills") / owner / run_id / target.name).as_posix() | |
| artifacts.append({ | |
| "name": target.name, | |
| "type": mimetypes.guess_type(target.name)[0] or "application/octet-stream", | |
| "size": target.stat().st_size, | |
| "url": f"/files/{relative_url}", | |
| }) | |
| if skill_id.lower() == "media-parser": | |
| expires_at = time.time() + MEDIA_PARSER_ARTIFACT_TTL_SECONDS | |
| (durable_dir / ".expires-at").write_text(str(expires_at), encoding="utf-8") | |
| self._enforce_media_owner_quota(owner) | |
| elif skill_id.lower() == "media-parser" and completed.returncode == 0: | |
| artifacts.extend(self._media_stream_artifacts(completed.stdout)) | |
| if artifacts: | |
| expires_at = max(float(item.get("expires_at") or 0) for item in artifacts) | |
| result = { | |
| "run_id": run_id, | |
| "skill_id": skill_id, | |
| "script": relative_posix, | |
| "status": "success" if completed.returncode == 0 else "error", | |
| "exit_code": completed.returncode, | |
| "duration_ms": int((time.monotonic() - started_at) * 1000), | |
| "stdout": completed.stdout[-MAX_SCRIPT_STDIO_CHARS:], | |
| "stderr": completed.stderr[-MAX_SCRIPT_STDIO_CHARS:], | |
| "artifacts": artifacts, | |
| } | |
| if expires_at is not None: | |
| result["expires_at"] = expires_at | |
| if cache_path is not None: | |
| cache_path.parent.mkdir(parents=True, exist_ok=True) | |
| staging = cache_path.with_suffix(f".tmp-{os.getpid()}") | |
| staging.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8") | |
| os.replace(staging, cache_path) | |
| return result | |
| def _backup_skill(self, skill_dir: Path) -> Path: | |
| """Snapshot a Skill before it is overwritten and keep the newest few. | |
| An update is destructive: the previous files are deleted and replaced. | |
| Keeping a rolling snapshot means a bad release can be rolled back by | |
| copying the directory back, and it gives ``_restore_preserved_state`` a | |
| single source for the administrator state that has to survive. | |
| """ | |
| import datetime | |
| backups_root = self.root / SKILL_BACKUP_DIR / skill_dir.name | |
| backups_root.mkdir(parents=True, exist_ok=True) | |
| stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d-%H%M%S") | |
| destination = backups_root / f"{stamp}-{uuid.uuid4().hex[:6]}" | |
| try: | |
| shutil.copytree(skill_dir, destination) | |
| except OSError as exc: | |
| shutil.rmtree(destination, ignore_errors=True) | |
| raise SkillInstallError(f"无法备份现有 Skill,已中止覆盖:{exc}") from exc | |
| self._prune_skill_backups(backups_root) | |
| return destination | |
| def _prune_skill_backups(backups_root: Path) -> None: | |
| try: | |
| snapshots = sorted( | |
| (path for path in backups_root.iterdir() if path.is_dir()), | |
| key=lambda path: path.name, | |
| ) | |
| except OSError: | |
| return | |
| for stale in snapshots[:-MAX_SKILL_BACKUPS]: | |
| shutil.rmtree(stale, ignore_errors=True) | |
| def install_zip( | |
| self, | |
| payload: bytes, | |
| *, | |
| source: str, | |
| overwrite: bool = False, | |
| subpath: str = "", | |
| max_bytes: int = MAX_ARCHIVE_BYTES, | |
| max_files: int = MAX_FILES, | |
| ) -> list[dict[str, Any]]: | |
| import datetime | |
| self.root.mkdir(parents=True, exist_ok=True) | |
| with tempfile.TemporaryDirectory(prefix="chatgpt2api-skill-") as temp_name: | |
| extracted = Path(temp_name) | |
| _extract_archive(payload, extracted, subpath=subpath, max_bytes=max_bytes, max_files=max_files) | |
| search_root = extracted | |
| if subpath: | |
| matches = [path for path in extracted.rglob(subpath.split("/")[-1]) if path.is_dir() and path.as_posix().endswith(subpath)] | |
| if not matches: | |
| raise SkillInstallError(f"仓库中找不到技能目录:{subpath}") | |
| search_root = matches[0] | |
| skill_files = sorted(search_root.rglob("SKILL.md")) | |
| if not skill_files: | |
| raise SkillInstallError("ZIP 中没有找到 SKILL.md") | |
| bundle_skills = _export_bundle_sources(extracted) | |
| installed_ids: list[str] = [] | |
| discovered_ids: set[str] = set() | |
| for skill_file in skill_files: | |
| # A parent skill owns its nested resources; do not install a | |
| # nested SKILL.md twice when a package intentionally includes one. | |
| if any(parent.name == "SKILL.md" for parent in skill_file.parents): | |
| continue | |
| metadata = _frontmatter(skill_file) | |
| skill_id = _safe_name(metadata["name"], _safe_name(skill_file.parent.name)) | |
| # Release snapshots and examples sometimes contain older copies | |
| # of the same Skill. Prefer the first (normally shallowest) | |
| # definition instead of overwriting a complete runtime with an | |
| # archived duplicate later in the same package. | |
| if skill_id in discovered_ids: | |
| continue | |
| discovered_ids.add(skill_id) | |
| target = self.root / skill_id | |
| if target.exists() and not overwrite: | |
| raise SkillInstallError(f"Skill 已存在:{skill_id}") | |
| # Overwriting deletes the previous copy outright, so snapshot it | |
| # first. The snapshot doubles as the source of the administrator | |
| # state that must survive a reinstall: script permissions, | |
| # showcase overrides, uploaded showcase media and visibility. | |
| backup_dir = self._backup_skill(target) if target.is_dir() else None | |
| staging = self.root / f".{skill_id}.installing-{os.getpid()}" | |
| if staging.exists(): | |
| shutil.rmtree(staging) | |
| try: | |
| shutil.copytree(skill_file.parent, staging) | |
| package_roots = _referenced_package_roots(skill_file, search_root) | |
| for package_root in package_roots: | |
| # External implementations become controlled Skill | |
| # scripts. Keeping them below scripts/ makes them | |
| # discoverable to the runtime and subject to the same | |
| # administrator whitelist as native Skill scripts. | |
| destination = staging / "scripts" / package_root.name | |
| if package_root.is_dir(): | |
| shutil.copytree(package_root, destination, dirs_exist_ok=True) | |
| elif package_root.is_file(): | |
| destination.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copy2(package_root, destination) | |
| # A repository may keep its human-facing README/USAGE next | |
| # to (or above) the Skill directory rather than beside | |
| # SKILL.md. Preserve those package docs so the chat UI can | |
| # explain the Skill to end users after installation. | |
| copied_doc_names: set[str] = set() | |
| package_docs_dir = staging / "_package-docs" | |
| for ancestor in _ancestor_directories(skill_file, search_root): | |
| for candidate in sorted(ancestor.iterdir()): | |
| if not candidate.is_file() or not PACKAGE_DOC_RE.match(candidate.name): | |
| continue | |
| normalized_name = candidate.name.lower() | |
| if normalized_name in copied_doc_names: | |
| continue | |
| copied_doc_names.add(normalized_name) | |
| package_docs_dir.mkdir(parents=True, exist_ok=True) | |
| shutil.copy2(candidate, package_docs_dir / candidate.name) | |
| (staging / ".install.json").write_text(json.dumps( | |
| self._install_record( | |
| staging=staging, | |
| target=target, | |
| source=source, | |
| bundle_entry=bundle_skills.get(skill_id), | |
| package_roots=[path.name for path in package_roots], | |
| ), | |
| ensure_ascii=False, | |
| indent=2, | |
| ), encoding="utf-8") | |
| if target.exists(): | |
| shutil.rmtree(target) | |
| os.replace(staging, target) | |
| if backup_dir is not None: | |
| _restore_preserved_state(target, backup_dir) | |
| finally: | |
| if staging.exists(): | |
| shutil.rmtree(staging, ignore_errors=True) | |
| installed_ids.append(skill_id) | |
| if not installed_ids: | |
| raise SkillInstallError("没有可安装的 Skill") | |
| installed = {item["id"]: item for item in self.list()} | |
| return [installed[item] for item in installed_ids if item in installed] | |
| def install_url(self, url: str, *, overwrite: bool = False) -> list[dict[str, Any]]: | |
| clean_url = url.strip() | |
| parsed = urlparse(clean_url) | |
| if parsed.scheme not in {"http", "https"}: | |
| raise SkillInstallError("仅支持 http/https URL") | |
| last_error: Exception | None = None | |
| for download_url, subpath in _github_download_candidates(clean_url): | |
| try: | |
| with httpx.Client(follow_redirects=True, timeout=45) as client: | |
| response = client.get(download_url, headers={"User-Agent": "chatgpt2api-skill-installer/1.0"}) | |
| response.raise_for_status() | |
| payload = response.content | |
| return self.install_zip(payload, source=clean_url, overwrite=overwrite, subpath=subpath) | |
| except SkillInstallError as exc: | |
| # A valid branch/archive was already found. Do not mask an | |
| # installation result such as "already exists" by probing a | |
| # fallback branch that may return 404. | |
| if "Skill 已存在:" in str(exc): | |
| raise | |
| last_error = exc | |
| except httpx.HTTPError as exc: | |
| last_error = exc | |
| raise SkillInstallError(f"下载或安装失败:{last_error}") | |
| def update_skill(self, skill_id: str) -> list[dict[str, Any]]: | |
| """Reinstall a Skill from the source recorded when it was installed. | |
| Only remotely-sourced Skills can be refreshed. A Skill that arrived as an | |
| uploaded archive has no address to fetch from, so the administrator has | |
| to supply a new archive instead. | |
| """ | |
| skill_dir = self._skill_dir(skill_id) | |
| source = str(self._install_metadata(skill_dir).get("source") or "").strip() | |
| if not source or source.startswith("upload:"): | |
| raise SkillInstallError("这个 Skill 由本地上传安装,没有可用的远端地址,请重新上传新版 ZIP") | |
| if not self._is_direct_install_url(source): | |
| raise SkillInstallError(f"该 Skill 的来源不支持自动更新:{source}") | |
| installed = self.install_url(source, overwrite=True) | |
| self._invalidate_caches(skill_dir) | |
| return installed | |
| def export_skills(self, skill_ids: list[str] | None = None) -> tuple[bytes, str]: | |
| """Bundle Skills — code plus administrator state — into one archive. | |
| The result is the unit of migration between deployments: installing it | |
| elsewhere reproduces the Skills exactly, including script permissions, | |
| showcase overrides, uploaded showcase media and visibility, because the | |
| installer copies each Skill directory verbatim. | |
| """ | |
| import datetime | |
| if skill_ids: | |
| directories = [self._skill_dir(item) for item in dict.fromkeys(skill_ids)] | |
| else: | |
| directories = [self._skill_dir(str(item["id"])) for item in self.list()] | |
| if not directories: | |
| raise SkillInstallError("没有可导出的 Skill") | |
| manifest_skills: list[dict[str, Any]] = [] | |
| buffer = io.BytesIO() | |
| with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive: | |
| for skill_dir in directories: | |
| metadata = _frontmatter(skill_dir / "SKILL.md") | |
| install_metadata = self._install_metadata(skill_dir) | |
| exported = 0 | |
| for item in _scan_skill_files(skill_dir): | |
| if _is_export_excluded(item.relative): | |
| continue | |
| archive.write(item.path, f"{skill_dir.name}/{item.relative}") | |
| exported += 1 | |
| manifest_skills.append({ | |
| "id": skill_dir.name, | |
| "name": metadata["name"], | |
| "description": metadata["description"], | |
| "source": str(install_metadata.get("source") or "manual"), | |
| "installed_at": str(install_metadata.get("installed_at") or ""), | |
| "file_count": exported, | |
| }) | |
| archive.writestr(EXPORT_MANIFEST_FILE, json.dumps({ | |
| "format": SKILL_EXPORT_FORMAT, | |
| "format_version": SKILL_EXPORT_FORMAT_VERSION, | |
| "exported_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), | |
| "skill_count": len(manifest_skills), | |
| "skills": manifest_skills, | |
| }, ensure_ascii=False, indent=2)) | |
| stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") | |
| # Naming the file after a Skill only makes sense when one was picked | |
| # explicitly; a "export everything" request keeps the bundle name even | |
| # when the installation happens to hold a single Skill. | |
| filename = ( | |
| f"skill-{directories[0].name}-{stamp}.zip" | |
| if skill_ids and len(directories) == 1 | |
| else f"skills-export-{stamp}.zip" | |
| ) | |
| return buffer.getvalue(), filename | |
| def _instruction_urls(text: str) -> list[str]: | |
| result: list[str] = [] | |
| for match in re.finditer(r"https?://[^\s<>\]\[()\"']+", text, re.IGNORECASE): | |
| value = match.group(0).rstrip(".,;:!?,。;:!?") | |
| if value not in result: | |
| result.append(value) | |
| return result[:24] | |
| def _is_direct_install_url(value: str) -> bool: | |
| parsed = urlparse(value) | |
| path = parsed.path.lower() | |
| return (parsed.hostname or "").lower() == "github.com" or path.endswith(".zip") | |
| def _read_install_instruction(self, url: str) -> str: | |
| current = _require_public_remote_url(url) | |
| with httpx.Client(follow_redirects=False, timeout=25) as client: | |
| for _ in range(6): | |
| response = client.get(current, headers={"User-Agent": "chatgpt2api-skill-installer/1.0", "Accept": "text/markdown,text/plain;q=0.9,*/*;q=0.2"}) | |
| if response.is_redirect: | |
| location = response.headers.get("location") | |
| if not location: | |
| raise SkillInstallError("安装说明重定向缺少目标地址") | |
| current = _require_public_remote_url(str(httpx.URL(current).join(location))) | |
| continue | |
| response.raise_for_status() | |
| if len(response.content) > MAX_RESOURCE_BYTES: | |
| raise SkillInstallError("安装说明文件超过 512KB 限制") | |
| content_type = response.headers.get("content-type", "").lower() | |
| if not any(value in content_type for value in ("text/", "markdown", "json", "yaml", "octet-stream")): | |
| raise SkillInstallError("安装说明不是可读取的文本文件") | |
| try: | |
| return response.content.decode("utf-8") | |
| except UnicodeDecodeError as exc: | |
| raise SkillInstallError("安装说明必须使用 UTF-8 编码") from exc | |
| raise SkillInstallError("安装说明重定向次数过多") | |
| def install_instruction(self, instruction: str, *, overwrite: bool = False) -> dict[str, Any]: | |
| clean = str(instruction or "").strip() | |
| if not clean or len(clean) > 8000: | |
| raise SkillInstallError("安装请求为空或过长") | |
| supplied_urls = self._instruction_urls(clean) | |
| if not supplied_urls: | |
| raise SkillInstallError("没有找到安装说明、GitHub 或 ZIP 地址") | |
| candidates = [url for url in supplied_urls if self._is_direct_install_url(url)] | |
| instruction_urls = [url for url in supplied_urls if url not in candidates] | |
| inspected: list[str] = [] | |
| for instruction_url in instruction_urls[:3]: | |
| document = self._read_install_instruction(instruction_url) | |
| inspected.append(instruction_url) | |
| for candidate in self._instruction_urls(document): | |
| if self._is_direct_install_url(candidate) and candidate not in candidates: | |
| candidates.append(candidate) | |
| if not candidates: | |
| raise SkillInstallError("安装说明中没有找到可受控安装的 GitHub 仓库或 ZIP 地址;不会执行网页中的 shell/npx 命令") | |
| errors: list[str] = [] | |
| for candidate in candidates[:8]: | |
| try: | |
| installed = self.install_url(candidate, overwrite=overwrite) | |
| return {"installed": installed, "resolved_url": candidate, "instruction_urls": inspected} | |
| except SkillInstallError as exc: | |
| existing_match = re.search(r"Skill 已存在:([a-z0-9._-]+)", str(exc), re.IGNORECASE) | |
| if existing_match and not overwrite: | |
| existing = next((item for item in self.list() if item["id"] == existing_match.group(1)), None) | |
| if existing: | |
| return {"installed": [existing], "resolved_url": candidate, "instruction_urls": inspected, "already_installed": True} | |
| errors.append(str(exc)) | |
| raise SkillInstallError(f"安装说明已读取,但候选 Skill 均安装失败:{';'.join(errors[-3:])}") | |
| def delete(self, skill_id: str) -> None: | |
| target = self._skill_dir(skill_id) | |
| shutil.rmtree(target) | |
| skill_service = SkillService() | |