Files
beaver_project/app-instance/backend/beaver/services/skillhub_service.py
steven_li ebfa242862 feat(outlook): 添加Outlook集成功能支持
添加完整的Outlook MCP集成,包括邮件和日历功能,通过AuthZ模式进行认证和权限管理,
支持邮箱连接、断开、状态检查和数据同步等功能。

fix(config): 统一配置文件路径从.nanobot到.beaver

将配置文件路径从/root/.nanobot统一更改为/root/.beaver,更新Dockerfile中的环境变量定义,
确保所有组件使用一致的配置目录结构。

feat(agent): 添加代理删除功能和助手身份提示

为代理注册表添加delete_agent方法,实现代理的动态删除功能;同时添加海狸助手身份提示,
确保AI助手在交互中保持一致的身份认知。

feat(engine): 增强引擎循环并添加意图决策快照

扩展AgentLoop类,添加intent_agent_decision参数用于意图驱动的代理决策,并在会话中记录
决策快照,便于后续分析和调试。

feat(authz): 扩展认证客户端功能

为AuthzClient添加设置权限、用户注册、后端注册和Outlook设置管理等新方法,增强系统
的认证和授权能力。
2026-05-14 16:01:46 +08:00

288 lines
12 KiB
Python

"""SkillHub marketplace client and installer."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import posixpath
from typing import Any
import httpx
from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter
from beaver.skills.specs import SkillSpec, SkillSpecStore, SkillVersion
from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
SKILLHUB_BASE_URL = "https://skillhub.bwgdi.com"
SKILLHUB_API_BASE = f"{SKILLHUB_BASE_URL}/api/web"
@dataclass(slots=True)
class SkillHubService:
store: SkillSpecStore
timeout_seconds: int = 30
async def search(
self,
*,
q: str = "",
sort: str = "relevance",
page: int = 0,
size: int = 12,
namespace: str | None = None,
) -> dict[str, Any]:
params = {
"q": q,
"sort": sort,
"page": str(max(0, page)),
"size": str(max(1, min(size, 50))),
}
if namespace:
params["namespace"] = namespace.removeprefix("@")
data = await self._get_json("/skills", params=params)
payload = _unwrap(data)
if not isinstance(payload, dict):
payload = {}
items = [self._with_install_state(item) for item in list(payload.get("items") or [])]
return {
"items": items,
"total": int(payload.get("total") or len(items)),
"page": int(payload.get("page") or page),
"size": int(payload.get("size") or size),
}
async def detail(self, namespace: str, slug: str) -> dict[str, Any]:
data = await self._get_json(f"/skills/{namespace.removeprefix('@')}/{slug}")
payload = _unwrap(data)
item = self._with_install_state(payload if isinstance(payload, dict) else {})
return item
async def version(self, namespace: str, slug: str, version: str) -> dict[str, Any]:
namespace = namespace.removeprefix("@")
detail = _unwrap(await self._get_json(f"/skills/{namespace}/{slug}/versions/{version}"))
files = _unwrap(await self._get_json(f"/skills/{namespace}/{slug}/versions/{version}/files"))
if not isinstance(detail, dict):
detail = {}
if not isinstance(files, list):
files = []
return {"detail": detail, "files": files}
async def versions(self, namespace: str, slug: str) -> dict[str, Any]:
namespace = namespace.removeprefix("@")
payload = _unwrap(await self._get_json(f"/skills/{namespace}/{slug}/versions"))
if not isinstance(payload, dict):
payload = {}
items = payload.get("items")
return {
"items": items if isinstance(items, list) else [],
"total": int(payload.get("total") or len(items or [])),
"page": int(payload.get("page") or 0),
"size": int(payload.get("size") or len(items or [])),
}
async def file_content(self, namespace: str, slug: str, version: str, file_path: str) -> dict[str, Any]:
namespace = namespace.removeprefix("@")
safe_path = _safe_posix_path(file_path)
content = await self._get_text(
f"/skills/{namespace}/{slug}/versions/{version}/file",
params={"path": safe_path},
)
return {
"filePath": safe_path,
"content": content,
"contentType": _guess_content_type(safe_path),
"isBinary": False,
"fileSize": len(content.encode("utf-8")),
}
async def install(self, namespace: str, slug: str, version: str | None = None) -> dict[str, Any]:
namespace = namespace.removeprefix("@")
skill = await self.detail(namespace, slug)
selected_version = version or _published_version(skill)
if not selected_version:
raise ValueError("SkillHub skill has no published version")
version_payload = await self.version(namespace, slug, selected_version)
files = list(version_payload.get("files") or [])
contents: dict[str, str] = {}
for item in files:
file_path = _safe_posix_path(str(item.get("filePath") or item.get("path") or ""))
contents[file_path] = await self._get_text(
f"/skills/{namespace}/{slug}/versions/{selected_version}/file",
params={"path": file_path},
)
skill_content = contents.get("SKILL.md")
if not skill_content:
raise ValueError("SkillHub version does not contain SKILL.md")
frontmatter, body = parse_frontmatter(skill_content)
skill_name = str(frontmatter.get("name") or skill.get("slug") or slug).strip()
if not skill_name or "/" in skill_name or "\\" in skill_name or skill_name in {".", ".."}:
raise ValueError(f"Unsafe skill name from SkillHub: {skill_name}")
normalized_frontmatter = normalize_frontmatter(
{
**frontmatter,
"name": skill_name,
"description": frontmatter.get("description") or skill.get("summary") or skill_name,
}
)
rendered = _render_skill_content(normalized_frontmatter, body)
content_hash = canonical_hash(rendered)
existing = self.store.read_published_skill(skill_name)
existing_spec = self.store.get_skill_spec(skill_name)
if existing is not None and existing.version.content_hash == content_hash:
return {
"ok": True,
"skill_name": skill_name,
"version": existing.version.version,
"source": "skillhub",
"namespace": namespace,
"slug": slug,
"installed_path": str(self.store.root / skill_name),
"already_installed": True,
}
next_version = self._next_version(skill_name)
now = datetime.now(timezone.utc).isoformat()
skill_version = SkillVersion(
skill_name=skill_name,
version=next_version,
content_hash=content_hash,
summary_hash=canonical_hash(strip_frontmatter(rendered).strip()),
created_at=now,
created_by="skillhub",
change_reason=f"Install SkillHub {namespace}/{slug}@{selected_version}",
parent_version=existing.version.version if existing is not None else None,
review_state="published",
frontmatter=normalized_frontmatter,
summary=summarize_skill_content(body),
tool_hints=self.store._extract_tool_hints(normalized_frontmatter),
provenance={
"source": "skillhub",
"namespace": namespace,
"slug": slug,
"skillhub_version": selected_version,
"source_url": f"{SKILLHUB_BASE_URL}/space/{namespace}/{slug}",
},
)
self.store.write_skill_version(skill_version, rendered)
for file_path, content in contents.items():
if file_path == "SKILL.md":
continue
target = self.store.root / skill_name / "versions" / next_version / file_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
spec = existing_spec or SkillSpec(
name=skill_name,
display_name=str(skill.get("displayName") or skill_name),
description=str(normalized_frontmatter.get("description") or skill_name),
created_at=now,
updated_at=now,
current_version=next_version,
status="active",
tags=[],
owners=["skillhub"],
source_kind="skillhub",
lineage=[],
)
spec.current_version = next_version
spec.updated_at = now
spec.status = "active"
spec.source_kind = "skillhub"
if "skillhub" not in spec.owners:
spec.owners.append("skillhub")
self.store.write_skill_spec(spec)
self.store.set_current_version(skill_name, next_version)
published = self.store.read_index("published")
if skill_name not in published:
published.append(skill_name)
self.store.update_index("published", published)
return {
"ok": True,
"skill_name": skill_name,
"version": next_version,
"source": "skillhub",
"namespace": namespace,
"slug": slug,
"installed_path": str(self.store.root / skill_name),
"already_installed": False,
}
async def _get_json(self, path: str, *, params: dict[str, str] | None = None) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, trust_env=False) as client:
response = await client.get(f"{SKILLHUB_API_BASE}{path}", params=params)
response.raise_for_status()
data = response.json()
return data if isinstance(data, dict) else {}
async def _get_text(self, path: str, *, params: dict[str, str]) -> str:
async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, trust_env=False) as client:
response = await client.get(f"{SKILLHUB_API_BASE}{path}", params=params)
response.raise_for_status()
return response.text
def _with_install_state(self, item: dict[str, Any]) -> dict[str, Any]:
result = dict(item)
slug = str(result.get("slug") or result.get("displayName") or "")
namespace = str(result.get("namespace") or "").removeprefix("@")
installed = self.store.get_skill_spec(slug) or self._find_installed_skillhub_spec(namespace, slug)
result["installed"] = installed is not None and installed.status == "active"
result["installed_version"] = installed.current_version if installed is not None else None
return result
def _find_installed_skillhub_spec(self, namespace: str, slug: str) -> SkillSpec | None:
for spec in self.store.list_skill_specs():
loaded = self.store.read_published_skill(spec.name)
provenance = loaded.version.provenance if loaded is not None else {}
if provenance.get("source") == "skillhub" and provenance.get("namespace") == namespace and provenance.get("slug") == slug:
return spec
return None
def _next_version(self, skill_name: str) -> str:
versions = [item for item in self.store.list_versions(skill_name) if item.startswith("v")]
numbers = [int(item[1:]) for item in versions if item[1:].isdigit()]
return f"v{(max(numbers) if numbers else 0) + 1:04d}"
def _unwrap(payload: dict[str, Any]) -> Any:
if "data" in payload:
return payload["data"]
return payload
def _published_version(item: dict[str, Any]) -> str | None:
for key in ("publishedVersion", "headlineVersion"):
value = item.get(key)
if isinstance(value, dict) and value.get("version"):
return str(value["version"])
return None
def _safe_posix_path(value: str) -> str:
cleaned = posixpath.normpath(value.replace("\\", "/")).lstrip("/")
if cleaned in {"", ".", ".."} or cleaned.startswith("../") or "/../" in cleaned:
raise ValueError(f"Unsafe SkillHub file path: {value}")
return cleaned
def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str:
lines = ["---"]
for key, value in normalize_frontmatter(frontmatter).items():
if isinstance(value, list):
lines.append(f"{key}:")
for item in value:
lines.append(f" - {item}")
else:
lines.append(f"{key}: {value}")
lines.extend(["---", "", body.strip()])
return "\n".join(lines).rstrip() + "\n"
def _guess_content_type(file_path: str) -> str:
lower = file_path.lower()
if lower.endswith(".md"):
return "text/markdown"
if lower.endswith(".json"):
return "application/json"
if lower.endswith((".txt", ".yaml", ".yml", ".toml", ".csv", ".log")):
return "text/plain"
return "text/plain"