From ebfa2428620eade3c6f1bbff002fbf0a40053a52 Mon Sep 17 00:00:00 2001 From: steven_li Date: Thu, 14 May 2026 16:01:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(outlook):=20=E6=B7=BB=E5=8A=A0Outlook?= =?UTF-8?q?=E9=9B=86=E6=88=90=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加完整的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设置管理等新方法,增强系统 的认证和授权能力。 --- README.md | 2 +- app-instance/Dockerfile | 2 +- .../beaver/coordinator/registry/store.py | 11 + .../backend/beaver/coordinator/subagents.py | 220 ++++ .../backend/beaver/engine/context/builder.py | 20 +- app-instance/backend/beaver/engine/loop.py | 18 + .../beaver/integrations/authz/client.py | 61 + .../beaver/integrations/outlook/__init__.py | 527 +++++++- .../backend/beaver/interfaces/web/app.py | 908 +++++++++++++- .../backend/beaver/interfaces/web/files.py | 322 +++++ .../backend/beaver/services/agent_service.py | 27 + .../beaver/services/skillhub_service.py | 39 + .../builtin/intent-agent-router/SKILL.md | 59 + .../backend/beaver/skills/catalog/loader.py | 25 +- .../backend/beaver/skills/learning/service.py | 29 +- app-instance/backend/beaver/tasks/models.py | 1 + app-instance/backend/beaver/tasks/router.py | 61 +- app-instance/backend/flow.md | 10 +- .../tests/unit/test_debug_chat_logs_api.py | 20 +- .../tests/unit/test_main_agent_router.py | 19 + .../backend/tests/unit/test_web_files_api.py | 70 ++ app-instance/create-instance.sh | 46 +- app-instance/entrypoint.sh | 13 +- app-instance/frontend/README.md | 2 +- .../frontend/app/(app)/agents/page.tsx | 2 +- .../frontend/app/(app)/files/page.tsx | 294 +++-- .../frontend/app/(app)/marketplace/page.tsx | 193 ++- .../frontend/app/(app)/skills/page.tsx | 1067 ++++++++++++++--- .../frontend/app/(app)/status/page.tsx | 30 - app-instance/frontend/components/Header.tsx | 6 +- .../components/skills/SkillDetailView.tsx | 222 ++++ app-instance/frontend/lib/api.ts | 55 + app-instance/frontend/types/index.ts | 48 + app-instance/instance-registry.py | 8 +- deploy-control/server.py | 4 +- 35 files changed, 3979 insertions(+), 462 deletions(-) create mode 100644 app-instance/backend/beaver/coordinator/subagents.py create mode 100644 app-instance/backend/beaver/interfaces/web/files.py create mode 100644 app-instance/backend/beaver/skills/builtin/intent-agent-router/SKILL.md create mode 100644 app-instance/backend/tests/unit/test_web_files_api.py create mode 100644 app-instance/frontend/components/skills/SkillDetailView.tsx diff --git a/README.md b/README.md index 326314d..c945db8 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ docker run -d \ - 注册接口超时 - `app-instance` 容器反复重启 -- 日志里出现 `Missing Boardware Genius config: /root/.nanobot/config.json` +- 日志里出现 `Missing Boardware Genius config: /root/.beaver/config.json` 当前版本里,新实例的默认大模型配置就是从这里分发的: diff --git a/app-instance/Dockerfile b/app-instance/Dockerfile index 7de3bc4..18ad32f 100644 --- a/app-instance/Dockerfile +++ b/app-instance/Dockerfile @@ -39,7 +39,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ BEAVER_HOME=/root/.beaver \ BEAVER_CONFIG_PATH=/root/.beaver/config.json \ BEAVER_WORKSPACE=/root/.beaver/workspace \ - NANOBOT_AUTH_FILE=/root/.beaver/web_auth_users.json \ + BEAVER_AUTH_FILE=/root/.beaver/web_auth_users.json \ PORT=3000 \ HOSTNAME=127.0.0.1 diff --git a/app-instance/backend/beaver/coordinator/registry/store.py b/app-instance/backend/beaver/coordinator/registry/store.py index 489b521..7e151db 100644 --- a/app-instance/backend/beaver/coordinator/registry/store.py +++ b/app-instance/backend/beaver/coordinator/registry/store.py @@ -59,6 +59,17 @@ class AgentRegistry: return agent raise ValueError(f"Unknown agent_id: {agent_id}") + def delete_agent(self, agent_id: str) -> bool: + target = agent_id.strip() + if not target: + return False + agents = self.list_agents() + kept = [agent for agent in agents if agent.agent_id != target] + if len(kept) == len(agents): + return False + self._write_agents(kept) + return True + def search( self, *, diff --git a/app-instance/backend/beaver/coordinator/subagents.py b/app-instance/backend/beaver/coordinator/subagents.py new file mode 100644 index 0000000..97280d7 --- /dev/null +++ b/app-instance/backend/beaver/coordinator/subagents.py @@ -0,0 +1,220 @@ +"""Persistent local sub-agent storage for the web UI.""" + +from __future__ import annotations + +import json +import re +import shutil +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from beaver.coordinator.registry import AgentRegistry + + +_INVALID_ID_RE = re.compile(r"[^a-z0-9-]+") + + +def normalize_subagent_id(value: str) -> str: + normalized = _INVALID_ID_RE.sub("-", str(value or "").strip().lower()).strip("-") + normalized = re.sub(r"-{2,}", "-", normalized) + if not normalized: + raise ValueError("Sub-agent id is required") + return normalized + + +@dataclass(slots=True) +class SubagentSpec: + id: str + name: str + description: str + enabled: bool = True + workspace: str = "" + system_prompt: str = "" + model: str | None = None + delegation_mode: str = "remote_a2a_only" + allow_mcp: bool = True + tags: list[str] = field(default_factory=list) + aliases: list[str] = field(default_factory=list) + mcp_servers: dict[str, dict[str, Any]] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, payload: dict[str, Any], *, workspace_path: Path | None = None) -> "SubagentSpec": + agent_id = normalize_subagent_id(str(payload.get("id") or "")) + name = str(payload.get("name") or agent_id).strip() or agent_id + description = str(payload.get("description") or name).strip() or name + workspace = str(payload.get("workspace") or "").strip() + if not workspace and workspace_path is not None: + workspace = str(workspace_path) + mcp_servers = payload.get("mcp_servers", {}) + metadata = payload.get("metadata", {}) + return cls( + id=agent_id, + name=name, + description=description, + enabled=bool(payload.get("enabled", True)), + workspace=workspace, + system_prompt=str(payload.get("system_prompt") or "").strip(), + model=(str(payload.get("model") or "").strip() or None), + delegation_mode=str(payload.get("delegation_mode") or "remote_a2a_only").strip() or "remote_a2a_only", + allow_mcp=bool(payload.get("allow_mcp", True)), + tags=_string_list(payload.get("tags")), + aliases=_string_list(payload.get("aliases")), + mcp_servers=mcp_servers if isinstance(mcp_servers, dict) else {}, + metadata=metadata if isinstance(metadata, dict) else {}, + ) + + def to_dict(self) -> dict[str, Any]: + payload = asdict(self) + if not self.model: + payload["model"] = None + return payload + + +class LocalSubagentStore: + """Persist sub-agent definitions under `/agents/_agent/`.""" + + def __init__(self, workspace: Path, *, public_base_url: str = "") -> None: + self.workspace = workspace.expanduser().resolve() + self.directory = self.workspace / "agents" + self.public_base_url = public_base_url.rstrip("/") + + def list_subagents(self) -> list[SubagentSpec]: + if not self.directory.exists(): + return [] + result: list[SubagentSpec] = [] + for child in sorted(self.directory.iterdir()): + agents_json = child / "AGENTS.json" + if not child.is_dir() or not agents_json.exists(): + continue + try: + payload = json.loads(agents_json.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + continue + if isinstance(payload, dict): + result.append(SubagentSpec.from_dict(payload, workspace_path=child)) + return result + + def get_subagent(self, agent_id: str) -> SubagentSpec | None: + path = self.agents_json_path(agent_id) + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + return None + if not isinstance(payload, dict): + return None + return SubagentSpec.from_dict(payload, workspace_path=self.subagent_dir(agent_id)) + + def upsert_subagent(self, payload: dict[str, Any]) -> SubagentSpec: + agent_id = normalize_subagent_id(str(payload.get("id") or "")) + workspace_path = self.subagent_dir(agent_id) + spec = SubagentSpec.from_dict(payload, workspace_path=workspace_path) + self._ensure_workspace(workspace_path) + spec.workspace = str(workspace_path) + self._sync_agents_md(workspace_path, spec) + self.agents_json_path(agent_id).write_text( + json.dumps(spec.to_dict(), indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + AgentRegistry(self.workspace).upsert_agent(self.build_registry_record(spec)) + return spec + + def delete_subagent(self, agent_id: str) -> bool: + agent_id = normalize_subagent_id(agent_id) + target = self.subagent_dir(agent_id) + if not target.exists(): + return False + AgentRegistry(self.workspace).delete_agent(agent_id) + shutil.rmtree(target) + return True + + def subagent_dir(self, agent_id: str) -> Path: + return self.directory / f"{normalize_subagent_id(agent_id)}_agent" + + def agents_json_path(self, agent_id: str) -> Path: + return self.subagent_dir(agent_id) / "AGENTS.json" + + def local_base_url(self, agent_id: str) -> str: + if self.public_base_url: + return f"{self.public_base_url}/subagents/{normalize_subagent_id(agent_id)}" + return f"/subagents/{normalize_subagent_id(agent_id)}" + + def build_registry_record(self, spec: SubagentSpec) -> dict[str, Any]: + base_url = self.local_base_url(spec.id) + return { + "agent_id": spec.id, + "name": spec.id, + "display_name": spec.name, + "role": spec.description, + "description": spec.description, + "system_prompt": spec.system_prompt, + "model": spec.model, + "tags": sorted(set(["local-subagent", *spec.tags])), + "status": "active" if spec.enabled else "disabled", + "source": "workspace", + "metadata": { + **spec.metadata, + "workspace": spec.workspace, + "managed_by": "subagent-manager", + "local_subagent": True, + "kind": "local_subagent", + "protocol": "a2a", + "base_url": base_url, + "endpoint": f"{base_url}/rpc", + "card_url": f"{base_url}/.well-known/agent-card", + "aliases": sorted(set([spec.name, *spec.aliases])), + }, + } + + def serialize(self, spec: SubagentSpec) -> dict[str, Any]: + base_url = self.local_base_url(spec.id) + return { + **spec.to_dict(), + "base_url": base_url, + "endpoint": f"{base_url}/rpc", + "card_url": f"{base_url}/.well-known/agent-card", + } + + def _ensure_workspace(self, workspace_path: Path) -> None: + workspace_path.mkdir(parents=True, exist_ok=True) + (workspace_path / "memory").mkdir(exist_ok=True) + (workspace_path / "skills").mkdir(exist_ok=True) + + def _sync_agents_md(self, workspace_path: Path, spec: SubagentSpec) -> None: + (workspace_path / "AGENTS.md").write_text(self._render_agents_md(spec), encoding="utf-8") + + @staticmethod + def _render_agents_md(spec: SubagentSpec) -> str: + prompt = spec.system_prompt.strip() or "Complete delegated tasks accurately and concisely." + return f"""# {spec.name} + +You are {spec.name}, a persistent local sub-agent managed by Beaver. + +## Role +{spec.description} + +## System Prompt +{prompt} + +## Constraints +- Work only inside this workspace. +- Respond only to delegated tasks. +- Do not create or manage local sub-agents. +- Do not message end users directly. +""" + + +def _string_list(value: Any) -> list[str]: + if isinstance(value, str): + value = [item.strip() for item in value.split(",")] + if not isinstance(value, list): + return [] + result: list[str] = [] + for item in value: + text = str(item).strip() + if text and text not in result: + result.append(text) + return result diff --git a/app-instance/backend/beaver/engine/context/builder.py b/app-instance/backend/beaver/engine/context/builder.py index c66e351..c09e2d5 100644 --- a/app-instance/backend/beaver/engine/context/builder.py +++ b/app-instance/backend/beaver/engine/context/builder.py @@ -29,6 +29,13 @@ from typing import Any from beaver.memory.curated.snapshot import MemorySnapshot +BEAVER_USER_ASSISTANT_IDENTITY_PROMPT = ( + "You are 海狸 (Beaver), an AI assistant developed by 博维资讯系统有限公司. " + "When communicating with users, keep this identity consistent. " + "If users ask who you are, say that you are 海狸 (Beaver), 博维资讯系统有限公司研发的 AI 助手." +) + + @dataclass(slots=True) class SkillContext: """单个已激活 skill 的最小表示。 @@ -133,11 +140,12 @@ class ContextBuilder: 顺序固定非常重要,当前约定是: - 1. base system prompt - 2. session metadata - 3. execution context - 4. frozen memory snapshot - 5. extra sections + 1. Beaver user-facing assistant identity + 2. base system prompt + 3. session metadata + 4. execution context + 5. frozen memory snapshot + 6. extra sections 这样设计的原因: - 身份与总规则要最靠前 @@ -146,7 +154,7 @@ class ContextBuilder: - activated skill 正文按 Hermes 风格放到显式消息里,避免 system prompt 持续膨胀 """ - sections: list[str] = [] + sections: list[str] = [BEAVER_USER_ASSISTANT_IDENTITY_PROMPT] base_system_prompt = (build_input.base_system_prompt or "").strip() if base_system_prompt: diff --git a/app-instance/backend/beaver/engine/loop.py b/app-instance/backend/beaver/engine/loop.py index 1940f90..a1f86ce 100644 --- a/app-instance/backend/beaver/engine/loop.py +++ b/app-instance/backend/beaver/engine/loop.py @@ -217,6 +217,7 @@ class AgentLoop: pinned_skill_names: list[str] | None = None, pinned_skill_contexts: list[SkillContext] | None = None, allow_candidate_generation: bool = False, + intent_agent_decision: dict[str, Any] | None = None, ) -> AgentRunResult: """跑通最小 direct run 主链。 @@ -265,6 +266,7 @@ class AgentLoop: pinned_skill_names=pinned_skill_names, pinned_skill_contexts=pinned_skill_contexts, allow_candidate_generation=allow_candidate_generation, + intent_agent_decision=intent_agent_decision, ) async def _process_direct_impl( @@ -301,6 +303,7 @@ class AgentLoop: pinned_skill_names: list[str] | None = None, pinned_skill_contexts: list[SkillContext] | None = None, allow_candidate_generation: bool = False, + intent_agent_decision: dict[str, Any] | None = None, ) -> AgentRunResult: """真正执行一轮 direct run 的内部实现。 @@ -381,6 +384,7 @@ class AgentLoop: "parent_session_id": parent_session_id, "pinned_skill_names": list(pinned_skill_names or []), "pinned_skill_context_names": [skill.name for skill in pinned_skill_contexts or []], + "intent_agent_decision": intent_agent_decision, }, content=task, context_visible=False, @@ -389,6 +393,20 @@ class AgentLoop: model=resolved_model, user_id=user_id, ) + if intent_agent_decision: + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="system", + event_type="intent_agent_decision_snapshotted", + event_payload=dict(intent_agent_decision), + content=str(intent_agent_decision.get("choice") or ""), + context_visible=False, + source=source, + title=title, + model=resolved_model, + user_id=user_id, + ) user_message_recorded = False iterations = 0 diff --git a/app-instance/backend/beaver/integrations/authz/client.py b/app-instance/backend/beaver/integrations/authz/client.py index 3543487..411a718 100644 --- a/app-instance/backend/beaver/integrations/authz/client.py +++ b/app-instance/backend/beaver/integrations/authz/client.py @@ -48,3 +48,64 @@ class AuthzClient: async def get_permissions(self, backend_id: str) -> dict[str, Any]: data = await self._request("GET", f"/backends/{backend_id}/permissions") return data if isinstance(data, dict) else {} + + async def set_permissions(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]: + data = await self._request("POST", f"/backends/{backend_id}/permissions", json_body=payload) + return data if isinstance(data, dict) else {} + + async def register_user( + self, + *, + username: str, + password: str, + email: str | None = None, + backend_name: str | None = None, + backend_id: str | None = None, + base_url: str | None = None, + frontend_base_url: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "username": username, + "password": password, + } + optional = { + "email": email, + "backend_name": backend_name, + "backend_id": backend_id, + "base_url": base_url, + "frontend_base_url": frontend_base_url, + } + payload.update({key: value for key, value in optional.items() if value}) + data = await self._request("POST", "/oauth/register", json_body=payload) + return data if isinstance(data, dict) else {} + + async def register_backend( + self, + *, + name: str, + base_url: str, + frontend_base_url: str | None = None, + backend_id: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "name": name, + "base_url": base_url, + } + if frontend_base_url: + payload["frontend_base_url"] = frontend_base_url + if backend_id: + payload["backend_id"] = backend_id + data = await self._request("POST", "/backends/register", json_body=payload) + return data if isinstance(data, dict) else {} + + async def get_outlook_settings(self, backend_id: str) -> dict[str, Any]: + data = await self._request("GET", f"/backends/{backend_id}/settings/outlook") + return data if isinstance(data, dict) else {} + + async def set_outlook_settings(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]: + data = await self._request("POST", f"/backends/{backend_id}/settings/outlook", json_body=payload) + return data if isinstance(data, dict) else {} + + async def delete_outlook_settings(self, backend_id: str) -> dict[str, Any]: + data = await self._request("DELETE", f"/backends/{backend_id}/settings/outlook") + return data if isinstance(data, dict) else {} diff --git a/app-instance/backend/beaver/integrations/outlook/__init__.py b/app-instance/backend/beaver/integrations/outlook/__init__.py index 55484f3..c4f3b7a 100644 --- a/app-instance/backend/beaver/integrations/outlook/__init__.py +++ b/app-instance/backend/beaver/integrations/outlook/__init__.py @@ -1,2 +1,527 @@ -"""Outlook integration.""" +"""Workspace-scoped Outlook helpers for the web UI.""" +from __future__ import annotations + +import asyncio +import json +import os +import shlex +from contextlib import AsyncExitStack +from dataclasses import asdict, dataclass +from datetime import datetime, time, timedelta +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +import httpx + +from beaver.foundation.config import BeaverConfig +from beaver.integrations.authz import AuthzClient + + +OUTLOOK_SERVER_ID = os.getenv("BEAVER_OUTLOOK_MCP_SERVER_ID") or os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp") +OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8 +OUTLOOK_OVERVIEW_EVENT_LIMIT = 20 +OUTLOOK_MAX_PAGE_SIZE = 100 + + +class OutlookIntegrationError(RuntimeError): + """Raised when the Outlook integration backend is unavailable or misconfigured.""" + + +@dataclass(frozen=True) +class OutlookDefaults: + domain: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_DOMAIN", "") + service_endpoint: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_URL", "") + server: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_SERVER", "") + default_timezone: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_TIMEZONE", "Asia/Shanghai") + autodiscover: bool = os.getenv("NANOBOT_OUTLOOK_DEFAULT_AUTODISCOVER", "0") == "1" + + +@dataclass(frozen=True) +class OutlookConnectionInput: + email: str + password: str + username: str | None = None + domain: str | None = None + service_endpoint: str | None = None + server: str | None = None + autodiscover: bool = False + default_timezone: str = "Asia/Shanghai" + + +OUTLOOK_TOOL_NAMES = [ + "auth_status", + "mail_list_folders", + "mail_list_messages", + "mail_search_messages", + "mail_get_message", + "mail_send_email", + "mail_reply_to_message", + "mail_forward_message", + "mail_move_message", + "mail_delta_sync", + "calendar_list_events", + "calendar_create_event", + "calendar_update_event", + "calendar_get_schedule", + "calendar_find_meeting_times", + "calendar_delta_sync", +] + + +def _call_timeout_seconds() -> float: + raw = os.getenv("NANOBOT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip() + try: + return max(1.0, float(raw)) if raw else 10.0 + except ValueError: + return 10.0 + + +def _use_authz_mode(config: BeaverConfig) -> bool: + return bool(config.authz.enabled and config.authz.base_url.strip()) + + +def _authz_client(config: BeaverConfig) -> AuthzClient: + if not _use_authz_mode(config): + raise OutlookIntegrationError("AuthZ mode is not enabled.") + return AuthzClient(config.authz.base_url, timeout_seconds=int(config.authz.request_timeout_seconds)) + + +def _require_backend_identity(config: BeaverConfig) -> str: + backend_id = config.backend_identity.backend_id.strip() + client_id = config.backend_identity.client_id.strip() + client_secret = config.backend_identity.client_secret.strip() + if not (backend_id and client_id and client_secret): + raise OutlookIntegrationError("Backend is not registered with AuthZ yet.") + return backend_id + + +def _outlook_mcp_url(config: BeaverConfig) -> str: + url = config.authz.outlook_mcp_url.strip() + if not url: + raise OutlookIntegrationError("AuthZ mode requires authz.outlook_mcp_url to be configured.") + return url + + +def outlook_defaults() -> dict[str, Any]: + return { + "provider": "ews", + "server_id": OUTLOOK_SERVER_ID, + "mcp_command": os.getenv("NANOBOT_OUTLOOK_MCP_COMMAND", "bw-outlook-mcp"), + "mcp_extra_args": shlex.split(os.getenv("NANOBOT_OUTLOOK_MCP_EXTRA_ARGS", "").strip()), + "fields": asdict(OutlookDefaults()), + } + + +def outlook_mcp_config_payload(config: BeaverConfig) -> dict[str, Any]: + url = _outlook_mcp_url(config) + return { + "url": url, + "authMode": "oauth_backend_token", + "authAudience": f"mcp:{OUTLOOK_SERVER_ID}", + "authScopes": ["list_tools", *[f"tool:{name}" for name in OUTLOOK_TOOL_NAMES]], + "sensitive": True, + "toolTimeout": 60, + "kind": "online", + "category": "outlook", + "managed": True, + "displayName": "Outlook MCP", + "source": "beaver-managed", + } + + +def _meta_file(workspace: Path) -> Path: + return workspace.expanduser().resolve() / "state" / "bw_outlook_mcp" / "ui_meta.json" + + +def _load_meta(workspace: Path) -> dict[str, Any]: + path = _meta_file(workspace) + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _update_meta(workspace: Path, **fields: Any) -> dict[str, Any]: + payload = _load_meta(workspace) + payload.update(fields) + payload["updated_at"] = datetime.now().isoformat() + path = _meta_file(workspace) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return payload + + +def _normalize_input(data: OutlookConnectionInput) -> OutlookConnectionInput: + email = data.email.strip() + password = data.password + username = (data.username or "").strip() or email.partition("@")[0].strip() + domain = (data.domain or "").strip() or None + service_endpoint = (data.service_endpoint or "").strip() or None + server = (data.server or "").strip() or None + default_timezone = (data.default_timezone or "").strip() or OutlookDefaults.default_timezone + if service_endpoint: + server = None + if not email: + raise OutlookIntegrationError("Email is required.") + if not password: + raise OutlookIntegrationError("Password is required.") + if not username: + raise OutlookIntegrationError("Username is required.") + if not data.autodiscover and not service_endpoint and not server: + raise OutlookIntegrationError("Provide an EWS URL, a server host, or enable autodiscover.") + return OutlookConnectionInput( + email=email, + password=password, + username=username, + domain=domain, + service_endpoint=service_endpoint, + server=server, + autodiscover=bool(data.autodiscover), + default_timezone=default_timezone, + ) + + +def _default_outlook_permissions() -> dict[str, Any]: + return { + "mcp": { + OUTLOOK_SERVER_ID: { + "enabled": True, + "tools": list(OUTLOOK_TOOL_NAMES), + } + }, + "a2a": {"enabled": False, "agents": []}, + } + + +async def ensure_outlook_authz_permissions(config: BeaverConfig) -> None: + backend_id = _require_backend_identity(config) + client = _authz_client(config) + existing = await client.get_permissions(backend_id) + mcp_settings = existing.get("mcp", {}).get(OUTLOOK_SERVER_ID, {}) if isinstance(existing, dict) else {} + if isinstance(mcp_settings, dict) and mcp_settings.get("enabled"): + return + await client.set_permissions(backend_id, _default_outlook_permissions()) + + +async def _call_outlook_mcp_tool( + config: BeaverConfig, + tool_name: str, + arguments: dict[str, Any], + *, + scopes: list[str] | None = None, + timeout_seconds: float | None = None, +) -> dict[str, Any]: + from mcp import ClientSession, types + from mcp.client.streamable_http import streamable_http_client + + url = _outlook_mcp_url(config) + client = _authz_client(config) + try: + token_response = await client.issue_token( + client_id=config.backend_identity.client_id, + client_secret=config.backend_identity.client_secret, + audience=f"mcp:{OUTLOOK_SERVER_ID}", + scopes=scopes or ["list_tools", f"tool:{tool_name}"], + ) + except httpx.TimeoutException as exc: + raise OutlookIntegrationError("AuthZ token 请求超时。") from exc + except httpx.HTTPError as exc: + detail = str(exc).strip() or exc.__class__.__name__ + raise OutlookIntegrationError(f"AuthZ token 获取失败:{detail}") from exc + + access_token = str(token_response.get("access_token") or "").strip() + if not access_token: + raise OutlookIntegrationError("Failed to obtain an Outlook MCP access token.") + + async def _invoke() -> dict[str, Any]: + async with AsyncExitStack() as stack: + http_client = await stack.enter_async_context( + httpx.AsyncClient( + headers={"Authorization": f"Bearer {access_token}"}, + follow_redirects=True, + trust_env=False, + timeout=timeout_seconds or _call_timeout_seconds(), + ) + ) + read, write, _ = await stack.enter_async_context(streamable_http_client(url, http_client=http_client)) + session = await stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + result = await session.call_tool(tool_name, arguments=arguments) + parts: list[str] = [] + for block in result.content: + parts.append(block.text if isinstance(block, types.TextContent) else str(block)) + output = "\n".join(parts).strip() + if not output: + return {} + try: + parsed = json.loads(output) + except json.JSONDecodeError: + return {"text": output} + return parsed if isinstance(parsed, dict) else {"value": parsed} + + timeout_value = timeout_seconds or _call_timeout_seconds() + try: + return await asyncio.wait_for(_invoke(), timeout=timeout_value) + except TimeoutError as exc: + raise OutlookIntegrationError(f"Outlook MCP 请求超时:{tool_name} 超过 {int(timeout_value)}s") from exc + except OutlookIntegrationError: + raise + except Exception as exc: + detail = str(exc).strip() or exc.__class__.__name__ + raise OutlookIntegrationError(f"Outlook MCP 调用失败:{detail}") from exc + + +async def test_connection(data: OutlookConnectionInput, config: BeaverConfig) -> dict[str, Any]: + if not _use_authz_mode(config): + raise OutlookIntegrationError("Outlook setup requires AuthZ mode in this Beaver instance.") + normalized = _normalize_input(data) + return { + "ok": True, + "provider": "ews", + "mailbox": normalized.email, + "resolved_username": normalized.username or "", + "resolved_domain": normalized.domain, + "sample": {"folders": [], "inbox": [], "events": []}, + "warnings": [ + "AuthZ mode skips local EWS validation. Credentials will be validated by the Outlook MCP service after save." + ], + } + + +async def connect_workspace(config: BeaverConfig, workspace: Path, data: OutlookConnectionInput) -> dict[str, Any]: + probe = await test_connection(data, config) + normalized = _normalize_input(data) + backend_id = _require_backend_identity(config) + client = _authz_client(config) + await client.set_outlook_settings( + backend_id, + { + "configured": True, + "email": normalized.email, + "username": normalized.username, + "domain": normalized.domain, + "service_endpoint": normalized.service_endpoint, + "server": normalized.server, + "autodiscover": normalized.autodiscover, + "default_timezone": normalized.default_timezone, + "password": normalized.password, + }, + ) + await ensure_outlook_authz_permissions(config) + meta = _update_meta( + workspace, + provider="ews", + mailbox=normalized.email, + last_verified_at=datetime.now().isoformat(), + last_connected_at=datetime.now().isoformat(), + ) + return { + "ok": True, + "probe": probe["sample"], + "saved": {"backend_id": backend_id, "configured": True}, + "mcp": {"id": OUTLOOK_SERVER_ID, **outlook_mcp_config_payload(config)}, + "meta": meta, + } + + +async def disconnect_workspace(config: BeaverConfig) -> dict[str, Any]: + backend_id = _require_backend_identity(config) + removed = False + try: + result = await _authz_client(config).delete_outlook_settings(backend_id) + removed = bool(result.get("ok")) + except Exception: + removed = False + return {"ok": True, "removed_state": removed, "removed_mcp": False, "server_id": OUTLOOK_SERVER_ID} + + +async def outlook_status(config: BeaverConfig, workspace: Path) -> dict[str, Any]: + meta = _load_meta(workspace) + if not _use_authz_mode(config): + return { + "configured": False, + "connected": False, + "provider": None, + "storage_mode": "workspace", + "saved": None, + "auth_status": None, + "mcp_registered": OUTLOOK_SERVER_ID in config.tools.mcp_servers, + "mcp_server_id": OUTLOOK_SERVER_ID, + "defaults": outlook_defaults(), + "meta": meta, + "error": "Outlook setup requires AuthZ mode in this Beaver instance.", + } + + client = _authz_client(config) + backend_id = _require_backend_identity(config) + saved = await client.get_outlook_settings(backend_id) + configured = bool(saved.get("configured")) + connected = False + auth_status: dict[str, Any] | None = None + error: str | None = None + if configured: + try: + auth_status = await _call_outlook_mcp_tool(config, "auth_status", {}, scopes=["list_tools", "tool:auth_status"]) + connected = bool(auth_status.get("authenticated")) + except Exception as exc: + error = str(exc) + return { + "configured": configured, + "connected": connected, + "provider": "ews" if configured else None, + "storage_mode": "authz", + "saved": saved if configured else None, + "auth_status": auth_status, + "mcp_registered": bool(OUTLOOK_SERVER_ID in config.tools.mcp_servers or config.authz.outlook_mcp_url.strip()), + "mcp_server_id": OUTLOOK_SERVER_ID, + "defaults": outlook_defaults(), + "meta": meta, + "error": error, + } + + +async def get_overview(config: BeaverConfig, workspace: Path) -> dict[str, Any]: + saved = await _authz_client(config).get_outlook_settings(_require_backend_identity(config)) + if not saved.get("configured"): + raise OutlookIntegrationError("Outlook is not configured for this backend.") + timezone_name = str(saved.get("default_timezone") or "Asia/Shanghai") + now = datetime.now(ZoneInfo(timezone_name)) + start_of_day = datetime.combine(now.date(), time.min, tzinfo=now.tzinfo) + end_of_day = start_of_day + timedelta(days=1) + warnings: list[str] = [] + + async def _load_section(label: str, coro: Any) -> dict[str, Any]: + try: + payload = await coro + return payload if isinstance(payload, dict) else {"value": []} + except Exception as exc: + warnings.append(f"{label} unavailable: {exc}") + return {"value": []} + + inbox, sent, calendar = await asyncio.gather( + _load_section( + "inbox", + _call_outlook_mcp_tool( + config, + "mail_list_messages", + {"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, + scopes=["list_tools", "tool:mail_list_messages"], + ), + ), + _load_section( + "sent items", + _call_outlook_mcp_tool( + config, + "mail_list_messages", + {"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, + scopes=["list_tools", "tool:mail_list_messages"], + ), + ), + _load_section( + "calendar", + _call_outlook_mcp_tool( + config, + "calendar_list_events", + { + "start_time": start_of_day.isoformat(), + "end_time": end_of_day.isoformat(), + "top": OUTLOOK_OVERVIEW_EVENT_LIMIT, + "skip": 0, + }, + scopes=["list_tools", "tool:calendar_list_events"], + ), + ), + ) + meta = _update_meta(workspace, last_overview_refresh_at=datetime.now().isoformat()) + return { + "mailbox": saved.get("email") or "", + "timezone": timezone_name, + "today": now.date().isoformat(), + "connection": await outlook_status(config, workspace), + "recentInbox": inbox.get("value", []), + "recentSent": sent.get("value", []), + "todayEvents": calendar.get("value", []), + "warnings": warnings, + "meta": meta, + } + + +def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]: + return max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE)), max(0, int(skip)) + + +def _normalize_page_payload(payload: dict[str, Any], *, top: int, skip: int) -> dict[str, Any]: + items = payload.get("value", []) if isinstance(payload, dict) else [] + returned = len(items) if isinstance(items, list) else 0 + page = payload.get("page") if isinstance(payload, dict) else None + if isinstance(page, dict): + return { + **payload, + "page": { + "top": int(page.get("top", top)), + "skip": int(page.get("skip", skip)), + "returned": int(page.get("returned", returned)), + "has_more": bool(page.get("has_more", False)), + "next_skip": page.get("next_skip"), + }, + } + return { + **payload, + "page": { + "top": top, + "skip": skip, + "returned": returned, + "has_more": returned >= top, + "next_skip": skip + returned if returned >= top else None, + }, + } + + +async def list_messages( + config: BeaverConfig, + *, + folder: str, + top: int, + skip: int = 0, + unread_only: bool = False, +) -> dict[str, Any]: + safe_top, safe_skip = _normalize_page_args(top=top, skip=skip) + payload = await _call_outlook_mcp_tool( + config, + "mail_list_messages", + {"folder": folder, "top": safe_top, "skip": safe_skip, "unread_only": unread_only}, + scopes=["list_tools", "tool:mail_list_messages"], + ) + return {"folder": folder, "unread_only": unread_only, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip)} + + +async def list_events( + config: BeaverConfig, + *, + start_time: str, + end_time: str, + top: int, + skip: int = 0, +) -> dict[str, Any]: + safe_top, safe_skip = _normalize_page_args(top=top, skip=skip) + payload = await _call_outlook_mcp_tool( + config, + "calendar_list_events", + {"start_time": start_time, "end_time": end_time, "top": safe_top, "skip": safe_skip}, + scopes=["list_tools", "tool:calendar_list_events"], + ) + return {"start_time": start_time, "end_time": end_time, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip)} + + +async def get_message_detail(config: BeaverConfig, message_id: str, *, changekey: str | None = None) -> dict[str, Any]: + return await _call_outlook_mcp_tool( + config, + "mail_get_message", + {"message_id": message_id, "changekey": changekey}, + scopes=["list_tools", "tool:mail_get_message"], + ) diff --git a/app-instance/backend/beaver/interfaces/web/app.py b/app-instance/backend/beaver/interfaces/web/app.py index a79dbcc..46e1885 100644 --- a/app-instance/backend/beaver/interfaces/web/app.py +++ b/app-instance/backend/beaver/interfaces/web/app.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import asyncio import io +import mimetypes import os import secrets import shutil @@ -28,6 +29,21 @@ from beaver.skills.learning import SkillLearningWorker, SkillLearningWorkerConfi from beaver.skills.catalog.utils import parse_frontmatter from .deps import get_agent_service +from .files import ( + browse_workspace, + content_disposition, + create_workspace_dir, + delete_file, + delete_workspace_path, + generate_file_id, + get_file_metadata, + get_file_path, + list_files, + save_file, + save_to_workspace, + workspace_file_preview, + workspace_file_path, +) from .schemas import ( WebChatFeedbackRequest, WebChatFeedbackResponse, @@ -40,12 +56,15 @@ from .schemas import ( ) try: - from fastapi import FastAPI, File, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect + from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect from fastapi.responses import Response except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments def File(default: Any = None) -> Any: # type: ignore[override] return default + def Form(default: Any = None) -> Any: # type: ignore[override] + return default + def Header(default: Any = None) -> Any: # type: ignore[override] return default @@ -286,7 +305,8 @@ def create_app( ) app.state.auth_tokens = {} app.state.handoff_codes = {} - app.state.auth_file = Path(os.getenv("NANOBOT_AUTH_FILE") or os.getenv("BEAVER_AUTH_FILE") or "") + app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or os.getenv("NANOBOT_AUTH_FILE") or "") + max_file_size = 50 * 1024 * 1024 @app.get("/api/ping", response_model=WebStatusResponse) async def ping(request: Request) -> WebStatusResponse: @@ -380,6 +400,122 @@ def create_app( "local_backend": _local_backend_view(), } + @app.post("/api/auth/register") + async def auth_register(request: Request, payload: dict[str, Any]) -> dict[str, Any]: + username = _clean_text(payload.get("username")) + password = str(payload.get("password") or "") + email = _clean_text(payload.get("email")) or "" + if not username or not password: + raise HTTPException(status_code=400, detail="Username and password are required") + + auth_file = _auth_file_path() + users = _load_auth_users_if_present(auth_file) + user_exists = username in users + if user_exists and not secrets.compare_digest(users[username], password): + raise HTTPException( + status_code=409, + detail="Username already exists. Use the existing password to finish setup or log in.", + ) + + agent_service = get_agent_service(request) + loaded = agent_service.create_loop().boot() + config = loaded.config + authz_base_url = _clean_text(payload.get("authz_base_url")) or (config.authz.base_url if config.authz.enabled else "") + backend_name = _clean_text(payload.get("backend_name")) or config.backend_identity.name or username + requested_backend_id = _clean_text(payload.get("backend_id")) or config.backend_identity.backend_id or None + public_base_url = ( + _clean_text(payload.get("base_url")) + or config.backend_identity.public_base_url + or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL") + or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL") + or str(request.base_url).rstrip("/") + ) + frontend_base_url = _clean_text(payload.get("frontend_base_url")) or public_base_url + + authz_user_registered = False + authz_backend_registered = False + local_backend: dict[str, Any] | None = None + + if authz_base_url: + from beaver.integrations.authz import AuthzClient + + try: + authz_payload = await AuthzClient( + authz_base_url, + timeout_seconds=config.authz.request_timeout_seconds, + ).register_user( + username=username, + password=password, + email=email or None, + backend_name=backend_name, + backend_id=requested_backend_id, + base_url=public_base_url, + frontend_base_url=frontend_base_url, + ) + except Exception as exc: # noqa: BLE001 - expose upstream setup failures to portal + raise HTTPException(status_code=502, detail=f"AuthZ registration failed: {exc}") from exc + + backend = authz_payload.get("backend") if isinstance(authz_payload, dict) else {} + if isinstance(backend, dict): + backend_id = _clean_text(backend.get("backend_id")) or requested_backend_id + client_id = _clean_text(backend.get("client_id")) or backend_id + client_secret = _clean_text(backend.get("client_secret")) or config.backend_identity.client_secret + if backend_id and client_id and client_secret: + local_backend = _save_backend_identity( + agent_service, + config_path=config.config_path or default_config_path(workspace=loaded.workspace), + backend_id=backend_id, + client_id=client_id, + client_secret=client_secret, + name=_clean_text(backend.get("name")) or backend_name, + public_base_url=public_base_url, + authz_base_url=authz_base_url, + ) + authz_backend_registered = True + authz_user_registered = bool(authz_payload) + + if not user_exists: + users[username] = password + _save_auth_users(auth_file, users) + + token = _issue_web_token(app, username) + handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token) + backend_connection = { + **_backend_connection_view(request), + "public_base_url": public_base_url, + "api_base_url": public_base_url, + "frontend_base_url": frontend_base_url, + "registered": bool(local_backend), + } + if local_backend is not None: + backend_connection.update( + { + "backend_id": local_backend.get("backend_id"), + "client_id": local_backend.get("client_id"), + "name": local_backend.get("name"), + } + ) + return { + "access_token": token, + "refresh_token": "", + "token_type": "bearer", + "user_id": username, + "username": username, + "email": email, + "role": "owner", + "handoff_code": handoff_code, + "handoff_expires_at": handoff_expires_at, + "existing_user": user_exists, + "authz": { + "enabled": bool(authz_base_url), + "base_url": authz_base_url or None, + "user_registered": authz_user_registered, + "backend_registered": authz_backend_registered, + }, + "backend_connection": backend_connection, + "local_backend": local_backend or _local_backend_view(), + } + @app.post("/api/auth/handoff/consume") async def auth_handoff_consume(payload: dict[str, Any]) -> dict[str, Any]: return _consume_handoff_code(app, str(payload.get("code") or "")) @@ -390,7 +526,7 @@ def create_app( return { "id": username, "username": username, - "email": os.getenv("NANOBOT_BACKEND_IDENTITY__EMAIL", ""), + "email": os.getenv("BEAVER_BACKEND_IDENTITY__EMAIL") or os.getenv("NANOBOT_BACKEND_IDENTITY__EMAIL", ""), "role": "owner", "quota_tier": "single-user", } @@ -555,6 +691,130 @@ def create_app( loaded.session_manager.end_session(session_id, "archived") # type: ignore[union-attr] return {"ok": True, "archived": True} + @app.post("/api/files/upload") + async def upload_file( + request: Request, + file: UploadFile = File(...), + session_id: str = Form("web:default"), + ) -> dict[str, Any]: + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + content = await file.read() + if len(content) > max_file_size: + raise HTTPException(status_code=413, detail="File too large (max 50MB)") + + loaded = get_agent_service(request).create_loop().boot() + file_id = generate_file_id() + metadata = save_file( + workspace=loaded.workspace, + file_id=file_id, + filename=file.filename, + content=content, + content_type=file.content_type or "application/octet-stream", + session_id=session_id, + ) + metadata["url"] = f"/api/files/{file_id}" + return metadata + + @app.get("/api/files") + async def list_uploaded_files(request: Request, session_id: str | None = None) -> list[dict[str, Any]]: + loaded = get_agent_service(request).create_loop().boot() + return list_files(loaded.workspace, session_id=session_id) + + @app.get("/api/files/{file_id}") + async def download_file(file_id: str, request: Request) -> Response: + loaded = get_agent_service(request).create_loop().boot() + metadata = get_file_metadata(loaded.workspace, file_id) + if metadata is None: + raise HTTPException(status_code=404, detail="File not found") + + file_path = get_file_path(loaded.workspace, file_id) + if file_path is None: + raise HTTPException(status_code=404, detail="File data missing") + + content_type = str(metadata.get("content_type") or "application/octet-stream") + disposition = "inline" if content_type.startswith("image/") else "attachment" + filename = str(metadata.get("name") or file_path.name) + return Response( + content=file_path.read_bytes(), + media_type=content_type, + headers={"Content-Disposition": content_disposition(disposition, filename)}, + ) + + @app.delete("/api/files/{file_id}") + async def remove_file(file_id: str, request: Request) -> dict[str, bool]: + loaded = get_agent_service(request).create_loop().boot() + if delete_file(loaded.workspace, file_id): + return {"ok": True} + raise HTTPException(status_code=404, detail="File not found") + + @app.get("/api/workspace/browse") + async def browse_workspace_dir(request: Request, path: str = "") -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + try: + return browse_workspace(loaded.workspace, path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/workspace/download") + async def download_workspace_file(path: str, request: Request) -> Response: + loaded = get_agent_service(request).create_loop().boot() + file_path = workspace_file_path(loaded.workspace, path) + if file_path is None: + raise HTTPException(status_code=404, detail="File not found") + + content_type, _ = mimetypes.guess_type(file_path.name) + content_type = content_type or "application/octet-stream" + disposition = "inline" if content_type.startswith("image/") else "attachment" + return Response( + content=file_path.read_bytes(), + media_type=content_type, + headers={"Content-Disposition": content_disposition(disposition, file_path.name)}, + ) + + @app.get("/api/workspace/file") + async def preview_workspace_file(path: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + try: + return workspace_file_preview(loaded.workspace, path) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.post("/api/workspace/upload") + async def upload_to_workspace( + request: Request, + file: UploadFile = File(...), + path: str = Form(""), + ) -> dict[str, Any]: + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + content = await file.read() + if len(content) > max_file_size: + raise HTTPException(status_code=413, detail="File too large (max 50MB)") + + loaded = get_agent_service(request).create_loop().boot() + try: + return save_to_workspace(loaded.workspace, path, file.filename, content) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.delete("/api/workspace/delete") + async def delete_workspace_item(path: str, request: Request) -> dict[str, bool]: + loaded = get_agent_service(request).create_loop().boot() + if delete_workspace_path(loaded.workspace, path): + return {"ok": True} + raise HTTPException(status_code=404, detail="Path not found") + + @app.post("/api/workspace/mkdir") + async def create_workspace_directory(path: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + try: + return create_workspace_dir(loaded.workspace, path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + @app.get("/api/agents") async def list_agents(request: Request) -> list[dict[str, Any]]: loaded = get_agent_service(request).create_loop().boot() @@ -588,6 +848,66 @@ def create_app( raise HTTPException(status_code=404, detail=str(exc)) from exc return _registered_agent_to_ui(agent) + @app.delete("/api/agents/{agent_id}") + async def delete_agent(agent_id: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + deleted = loaded.agent_registry.delete_agent(agent_id) # type: ignore[union-attr] + if not deleted: + raise HTTPException(status_code=404, detail="Agent not found") + return {"ok": True, "id": agent_id} + + @app.post("/api/agents/refresh") + async def refresh_agents(request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + return {"agents": [_registered_agent_to_ui(agent) for agent in loaded.agent_registry.list_agents()]} # type: ignore[union-attr] + + @app.get("/api/subagents") + async def list_subagents(request: Request) -> list[dict[str, Any]]: + from beaver.coordinator.subagents import LocalSubagentStore + + loaded = get_agent_service(request).create_loop().boot() + store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url) + return [store.serialize(spec) for spec in store.list_subagents()] + + @app.get("/api/subagents/{agent_id}") + async def get_subagent(agent_id: str, request: Request) -> dict[str, Any]: + from beaver.coordinator.subagents import LocalSubagentStore + + loaded = get_agent_service(request).create_loop().boot() + store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url) + spec = store.get_subagent(agent_id) + if spec is None: + raise HTTPException(status_code=404, detail="Sub-agent not found") + return store.serialize(spec) + + @app.post("/api/subagents") + async def create_subagent(request: Request, payload: dict[str, Any]) -> dict[str, Any]: + from beaver.coordinator.subagents import LocalSubagentStore + + loaded = get_agent_service(request).create_loop().boot() + store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url) + try: + spec = store.upsert_subagent(payload) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return store.serialize(spec) + + @app.put("/api/subagents/{agent_id}") + async def update_subagent(agent_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]: + if _clean_text(payload.get("id")) != agent_id: + raise HTTPException(status_code=400, detail="Path id must match body id") + return await create_subagent(request, payload) + + @app.delete("/api/subagents/{agent_id}") + async def delete_subagent(agent_id: str, request: Request) -> dict[str, Any]: + from beaver.coordinator.subagents import LocalSubagentStore + + loaded = get_agent_service(request).create_loop().boot() + store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url) + if not store.delete_subagent(agent_id): + raise HTTPException(status_code=404, detail="Sub-agent not found") + return {"ok": True, "id": agent_id} + @app.get("/api/authz/status") async def get_authz_status(request: Request) -> dict[str, Any]: loaded = get_agent_service(request).create_loop().boot() @@ -677,6 +997,143 @@ def create_app( backend_identity=loaded.config.backend_identity, ) + @app.get("/api/integrations/outlook/status") + async def get_outlook_status(request: Request) -> dict[str, Any]: + from beaver.integrations.outlook import OutlookIntegrationError, outlook_status + + loaded = get_agent_service(request).create_loop().boot() + try: + return await outlook_status(loaded.config, loaded.workspace) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.post("/api/integrations/outlook/test-connection") + async def test_outlook_connection(request: Request, payload: dict[str, Any]) -> dict[str, Any]: + from beaver.integrations.outlook import OutlookConnectionInput, OutlookIntegrationError, test_connection + + loaded = get_agent_service(request).create_loop().boot() + try: + return await test_connection(OutlookConnectionInput(**payload), loaded.config) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except TypeError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/integrations/outlook/connect") + async def connect_outlook(request: Request, payload: dict[str, Any]) -> dict[str, Any]: + from beaver.integrations.outlook import ( + OUTLOOK_SERVER_ID, + OutlookConnectionInput, + OutlookIntegrationError, + connect_workspace, + outlook_mcp_config_payload, + ) + + agent_service = get_agent_service(request) + loaded = agent_service.create_loop().boot() + try: + result = await connect_workspace(loaded.config, loaded.workspace, OutlookConnectionInput(**payload)) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except TypeError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + config_path = loaded.config.config_path or default_config_path(workspace=loaded.workspace) + raw = _read_config_json(config_path) + servers = _ensure_dict(_ensure_dict(raw, "tools"), "mcpServers") + servers[OUTLOOK_SERVER_ID] = outlook_mcp_config_payload(loaded.config) + _write_config_json(config_path, raw) + _reload_agent_config(agent_service, config_path) + return result + + @app.post("/api/integrations/outlook/disconnect") + async def disconnect_outlook(request: Request) -> dict[str, Any]: + from beaver.integrations.outlook import OutlookIntegrationError, disconnect_workspace + + loaded = get_agent_service(request).create_loop().boot() + try: + return await disconnect_workspace(loaded.config) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/integrations/outlook/overview") + async def get_outlook_overview(request: Request) -> dict[str, Any]: + from beaver.integrations.outlook import OutlookIntegrationError, get_overview + + loaded = get_agent_service(request).create_loop().boot() + try: + return await get_overview(loaded.config, loaded.workspace) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/integrations/outlook/messages") + async def get_outlook_messages( + request: Request, + folder: str = "inbox", + top: int = 20, + skip: int = 0, + unread_only: bool = False, + ) -> dict[str, Any]: + from beaver.integrations.outlook import OutlookIntegrationError, list_messages + + if not folder.strip(): + raise HTTPException(status_code=400, detail="folder is required") + loaded = get_agent_service(request).create_loop().boot() + try: + return await list_messages( + loaded.config, + folder=folder.strip(), + top=top, + skip=skip, + unread_only=unread_only, + ) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/integrations/outlook/events") + async def get_outlook_events( + request: Request, + start_time: str, + end_time: str, + top: int = 20, + skip: int = 0, + ) -> dict[str, Any]: + from beaver.integrations.outlook import OutlookIntegrationError, list_events + + if not start_time.strip() or not end_time.strip(): + raise HTTPException(status_code=400, detail="start_time and end_time are required") + loaded = get_agent_service(request).create_loop().boot() + try: + return await list_events( + loaded.config, + start_time=start_time.strip(), + end_time=end_time.strip(), + top=top, + skip=skip, + ) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/integrations/outlook/message-detail") + async def get_outlook_message_detail( + request: Request, + message_id: str, + changekey: str | None = None, + ) -> dict[str, Any]: + from beaver.integrations.outlook import OutlookIntegrationError, get_message_detail + + if not message_id.strip(): + raise HTTPException(status_code=400, detail="message_id is required") + loaded = get_agent_service(request).create_loop().boot() + try: + return await get_message_detail( + loaded.config, + message_id.strip(), + changekey=changekey.strip() if changekey else None, + ) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + @app.get("/api/mcp/tools") async def list_mcp_tools(request: Request) -> list[dict[str, Any]]: loaded = get_agent_service(request).create_loop().boot() @@ -776,6 +1233,34 @@ def create_app( for record in skills ] + @app.get("/api/skills/{name}/detail") + async def get_skill_detail(name: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + record = loaded.skills_loader.get_skill_record(name) # type: ignore[union-attr] + if record is None: + raise HTTPException(status_code=404, detail="Skill not found") + return _skill_detail_payload(loaded, name, record.version) + + @app.get("/api/skills/{name}/versions/{version}") + async def get_skill_version(name: str, version: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + record = loaded.skills_loader.get_skill_record(name) # type: ignore[union-attr] + if record is None: + raise HTTPException(status_code=404, detail="Skill not found") + return _skill_detail_payload(loaded, name, version) + + @app.get("/api/skills/{name}/versions/{version}/file") + async def get_skill_file(name: str, version: str, request: Request, path: str) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + record = loaded.skills_loader.get_skill_record(name) # type: ignore[union-attr] + if record is None: + raise HTTPException(status_code=404, detail="Skill not found") + base_dir = _skill_version_base_dir(loaded, record, version) + file_path = _safe_child_path(base_dir, path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="Skill file not found") + return _skill_file_content_payload(base_dir, file_path) + @app.get("/api/skills/{name}/download") async def download_skill(name: str, request: Request) -> Response: loaded = get_agent_service(request).create_loop().boot() @@ -863,6 +1348,26 @@ def create_app( except Exception as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc + @app.get("/api/marketplaces/skills/{namespace}/{slug}/versions") + async def list_skillhub_versions(namespace: str, slug: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type] + try: + return await service.versions(namespace, slug) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + @app.get("/api/marketplaces/skills/{namespace}/{slug}/versions/{version}/file") + async def get_skillhub_file(namespace: str, slug: str, version: str, request: Request, path: str) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type] + try: + return await service.file_content(namespace, slug, version, path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + @app.post("/api/marketplaces/skills/{namespace}/{slug}/install") async def install_skillhub_skill(namespace: str, slug: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]: loaded = get_agent_service(request).create_loop().boot() @@ -891,8 +1396,14 @@ def create_app( async def synthesize_skill_draft(candidate_id: str, request: Request) -> dict[str, Any]: agent_service = get_agent_service(request) loaded = agent_service.create_loop().boot() - provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {}) # noqa: SLF001 try: + candidate = loaded.skill_learning_pipeline.get_candidate(candidate_id) # type: ignore[union-attr] + if candidate.draft_skill_name and candidate.draft_id: + try: + return _skill_draft_payload(loaded, candidate.draft_skill_name, candidate.draft_id) + except ValueError: + pass + provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {}) # noqa: SLF001 draft = await loaded.skill_learning_pipeline.synthesize_draft( # type: ignore[union-attr] candidate_id, provider_bundle=provider_bundle, @@ -944,43 +1455,18 @@ def create_app( @app.get("/api/skills/drafts") async def list_skill_drafts(request: Request) -> list[dict[str, Any]]: loaded = get_agent_service(request).create_loop().boot() - results = [] - for item in loaded.skill_learning_pipeline.list_drafts(): # type: ignore[union-attr] - safety = loaded.skill_learning_pipeline.get_safety_report(item.skill_name, item.draft_id) # type: ignore[union-attr] - eval_report = loaded.skill_learning_pipeline.get_eval_report(item.skill_name, item.draft_id) # type: ignore[union-attr] - results.append( - { - **item.to_dict(), - "safety_report": safety.to_dict() if safety is not None else None, - "eval_report": eval_report.to_dict() if eval_report is not None else None, - } - ) - return results + return [ + _skill_draft_payload(loaded, item.skill_name, item.draft_id) + for item in loaded.skill_learning_pipeline.list_drafts() # type: ignore[union-attr] + ] @app.get("/api/skills/{skill_name}/drafts/{draft_id}") async def get_skill_draft(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]: loaded = get_agent_service(request).create_loop().boot() try: - draft = loaded.skill_learning_pipeline.get_draft(skill_name, draft_id) # type: ignore[union-attr] + return _skill_draft_payload(loaded, skill_name, draft_id, include_reviews=True) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc - return { - **draft.to_dict(), - "reviews": [ - item.to_dict() - for item in loaded.skill_learning_pipeline.reviews_for_draft(skill_name, draft_id) # type: ignore[union-attr] - ], - "safety_report": ( - loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id).to_dict() # type: ignore[union-attr] - if loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id) is not None # type: ignore[union-attr] - else None - ), - "eval_report": ( - loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id).to_dict() # type: ignore[union-attr] - if loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id) is not None # type: ignore[union-attr] - else None - ), - } @app.get("/api/skills/{skill_name}/drafts/{draft_id}/safety") async def get_skill_draft_safety(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]: @@ -1567,9 +2053,11 @@ def _debug_runs_for_session(session_manager: Any, session_id: str) -> list[dict[ None, ) user_event = next((item for item in records if item.event_type == "user_message_added"), None) + intent_event = next((item for item in records if item.event_type == "intent_agent_decision_snapshotted"), None) task_id = None attempt_index = None task_mode = None + intent_decision = intent_event.event_payload if intent_event is not None else None source = None title = None if started is not None and isinstance(started.event_payload, dict): @@ -1577,6 +2065,8 @@ def _debug_runs_for_session(session_manager: Any, session_id: str) -> list[dict[ attempt_index = started.event_payload.get("attempt_index") task_mode = started.event_payload.get("task_mode") source = started.event_payload.get("source") + if intent_decision is None: + intent_decision = started.event_payload.get("intent_agent_decision") if started is not None: title = getattr(started, "title", None) if title is None: @@ -1590,6 +2080,8 @@ def _debug_runs_for_session(session_manager: Any, session_id: str) -> list[dict[ "task_id": task_id, "attempt_index": attempt_index, "task_mode": task_mode, + "intent_agent_choice": intent_decision.get("choice") if isinstance(intent_decision, dict) else None, + "intent_agent_decision": intent_decision if isinstance(intent_decision, dict) else None, "user_input": user_event.content if user_event is not None else "", "started_at": _iso_from_timestamp(started.timestamp if started is not None else None), "ended_at": _iso_from_timestamp(completed.timestamp) if completed is not None else None, @@ -1744,24 +2236,27 @@ def _iso_from_timestamp(value: Any) -> str: def _registered_agent_to_ui(agent: Any) -> dict[str, Any]: + metadata = dict(agent.metadata or {}) + source = agent.source if agent.source in {"workspace", "skill", "builtin"} else "workspace" + aliases = metadata.get("aliases") return { "id": agent.agent_id, "name": agent.display_name or agent.name, "description": agent.description, - "source": agent.source if agent.source in {"workspace", "skill", "builtin"} else "workspace", - "kind": "specialist", - "protocol": None, - "endpoint": None, - "base_url": None, - "card_url": None, - "auth_env": None, - "auth_mode": "none", - "auth_audience": None, - "auth_scopes": [], + "source": source, + "kind": metadata.get("kind") or ("local_subagent" if metadata.get("local_subagent") else "specialist"), + "protocol": metadata.get("protocol"), + "endpoint": metadata.get("endpoint"), + "base_url": metadata.get("base_url"), + "card_url": metadata.get("card_url"), + "auth_env": metadata.get("auth_env"), + "auth_mode": metadata.get("auth_mode") or "none", + "auth_audience": metadata.get("auth_audience"), + "auth_scopes": _coerce_str_list(metadata.get("auth_scopes")), "tags": list(agent.tags), - "aliases": [agent.name], + "aliases": _coerce_str_list(aliases) or [agent.name], "metadata": { - **dict(agent.metadata), + **metadata, "role": agent.role, "capabilities": list(agent.capabilities), "skill_names": list(agent.skill_names), @@ -1769,20 +2264,37 @@ def _registered_agent_to_ui(agent: Any) -> dict[str, Any]: "priority": agent.priority, "status": agent.status, }, - "support_streaming": False, + "support_streaming": bool(metadata.get("support_streaming", False)), } def _agent_payload_from_ui(payload: dict[str, Any]) -> dict[str, Any]: metadata = dict(payload.get("metadata") or {}) + for key in ( + "protocol", + "endpoint", + "base_url", + "card_url", + "auth_env", + "auth_mode", + "auth_audience", + "auth_scopes", + "aliases", + "support_streaming", + ): + if key in payload: + metadata[key] = payload.get(key) + if metadata.get("protocol") == "a2a" or metadata.get("base_url") or metadata.get("endpoint") or metadata.get("card_url"): + metadata.setdefault("kind", "a2a_remote") capabilities = payload.get("capabilities") if capabilities is None and isinstance(metadata.get("capabilities"), list): capabilities = metadata.get("capabilities") role = payload.get("role") or metadata.get("role") or payload.get("kind") or "" + agent_id = payload.get("agent_id") or payload.get("id") or payload.get("name") or _derive_agent_id_from_metadata(metadata) return { - "agent_id": payload.get("agent_id") or payload.get("id") or payload.get("name"), - "name": payload.get("name") or payload.get("id"), - "display_name": payload.get("display_name") or payload.get("name") or payload.get("id"), + "agent_id": agent_id, + "name": payload.get("name") or payload.get("id") or agent_id, + "display_name": payload.get("display_name") or payload.get("name") or payload.get("id") or agent_id, "role": role, "description": payload.get("description") or "", "system_prompt": payload.get("system_prompt") or metadata.get("system_prompt") or "", @@ -1799,6 +2311,19 @@ def _agent_payload_from_ui(payload: dict[str, Any]) -> dict[str, Any]: } +def _derive_agent_id_from_metadata(metadata: dict[str, Any]) -> str: + raw = metadata.get("base_url") or metadata.get("endpoint") or metadata.get("card_url") or "workspace-agent" + text = str(raw).strip().lower() + for prefix in ("https://", "http://"): + if text.startswith(prefix): + text = text[len(prefix):] + text = text.split("/", 1)[0] or text + normalized = "".join(ch if ch.isalnum() else "-" for ch in text).strip("-") + while "--" in normalized: + normalized = normalized.replace("--", "-") + return normalized or "workspace-agent" + + def _mcp_server_view(server_id: str, cfg: Any, report: dict[str, Any]) -> dict[str, Any]: transport = "stdio" if getattr(cfg, "command", "") else "http" tool_names = list(report.get("tool_names") or []) @@ -1957,7 +2482,7 @@ def _provider_enabled(provider_name: str, provider_cfg: Any) -> bool: def _auth_file_path() -> Path: - raw = os.getenv("NANOBOT_AUTH_FILE") or os.getenv("BEAVER_AUTH_FILE") + raw = os.getenv("BEAVER_AUTH_FILE") or os.getenv("NANOBOT_AUTH_FILE") if raw: return Path(raw) return Path.home() / ".beaver" / "web_auth_users.json" @@ -1993,6 +2518,23 @@ def _load_auth_users(path: Path) -> dict[str, str]: return users +def _load_auth_users_if_present(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + return _load_auth_users(path) + + +def _save_auth_users(path: Path, users: dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "users": [ + {"username": username, "password": password} + for username, password in sorted(users.items()) + ] + } + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + def _issue_web_token(app: FastAPI, username: str) -> str: token = secrets.token_urlsafe(32) app.state.auth_tokens[token] = username @@ -2094,16 +2636,23 @@ def _require_web_user(app: FastAPI, authorization: str | None) -> str: def _backend_connection_view(request: Request) -> dict[str, Any]: public_base_url = ( - os.getenv("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL") + os.getenv("BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL") + or os.getenv("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL") + or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL") or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL") or str(request.base_url).rstrip("/") ) - backend_id = os.getenv("NANOBOT_BACKEND_IDENTITY__BACKEND_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID") - client_id = os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID") or backend_id + backend_id = ( + os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID") + or os.getenv("NANOBOT_BACKEND_IDENTITY__BACKEND_ID") + or os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID") + or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID") + ) + client_id = os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID") or backend_id return { "backend_id": backend_id, "client_id": client_id, - "name": os.getenv("NANOBOT_BACKEND_IDENTITY__NAME") or backend_id, + "name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME") or os.getenv("NANOBOT_BACKEND_IDENTITY__NAME") or backend_id, "public_base_url": public_base_url, "api_base_url": public_base_url, "frontend_base_url": public_base_url, @@ -2114,14 +2663,16 @@ def _backend_connection_view(request: Request) -> dict[str, Any]: def _local_backend_view() -> dict[str, Any]: return { - "backend_id": os.getenv("NANOBOT_BACKEND_IDENTITY__BACKEND_ID"), - "client_id": os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID"), - "name": os.getenv("NANOBOT_BACKEND_IDENTITY__NAME"), - "public_base_url": os.getenv("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL") + "backend_id": os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__BACKEND_ID"), + "client_id": os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID"), + "name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME") or os.getenv("NANOBOT_BACKEND_IDENTITY__NAME"), + "public_base_url": os.getenv("BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL") + or os.getenv("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL") + or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL") or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL"), "authz": { - "enabled": os.getenv("NANOBOT_AUTHZ__ENABLED", "").strip() in {"1", "true", "True"}, - "base_url": os.getenv("NANOBOT_AUTHZ__BASE_URL"), + "enabled": (os.getenv("BEAVER_AUTHZ__ENABLED") or os.getenv("NANOBOT_AUTHZ__ENABLED", "")).strip() in {"1", "true", "True"}, + "base_url": os.getenv("BEAVER_AUTHZ__BASE_URL") or os.getenv("NANOBOT_AUTHZ__BASE_URL"), }, } @@ -2133,6 +2684,204 @@ def _clean_text(value: Any) -> str | None: return text or None +def _skill_detail_payload(loaded: Any, name: str, version: str | None) -> dict[str, Any]: + record = loaded.skills_loader.get_skill_record(name) # type: ignore[union-attr] + if record is None: + raise HTTPException(status_code=404, detail="Skill not found") + selected_version = version or record.version or "legacy" + loaded_version = loaded.skill_spec_store.read_published_skill(name, selected_version) # type: ignore[union-attr] + if loaded_version is not None: + content = loaded_version.content + frontmatter = dict(loaded_version.version.frontmatter) + version_detail = loaded_version.version.to_dict() + else: + if record.source == "workspace" and selected_version != record.version: + raise HTTPException(status_code=404, detail="Skill version not found") + content = record.path.read_text(encoding="utf-8") + frontmatter, _ = parse_frontmatter(content) + version_detail = { + "skill_name": name, + "version": record.version or selected_version, + "review_state": record.status, + "frontmatter": dict(frontmatter), + "summary": record.description, + "tool_hints": list(record.tool_hints), + "provenance": {"source": record.source}, + } + + spec = loaded.skill_spec_store.get_skill_spec(name) # type: ignore[union-attr] + base_dir = _skill_version_base_dir(loaded, record, selected_version) + files = _list_skill_files(base_dir) + versions = _skill_versions_payload(loaded, record) + return { + "skill": { + "name": record.name, + "description": record.description, + "source": "builtin" if record.source == "builtin" else "workspace", + "available": loaded.skills_loader._record_available(record), # type: ignore[union-attr] + "path": str(record.path), + "version": record.version, + "status": record.status, + "source_kind": record.source_kind, + "tool_hints": list(record.tool_hints), + "provenance": version_detail.get("provenance") or {}, + "agent_cards": [], + }, + "spec": spec.to_dict() if spec is not None else None, + "currentVersion": selected_version, + "versions": versions, + "versionDetail": version_detail, + "files": files, + "content": content, + "frontmatter": frontmatter, + } + + +def _skill_draft_payload(loaded: Any, skill_name: str, draft_id: str, *, include_reviews: bool = False) -> dict[str, Any]: + draft = loaded.skill_learning_pipeline.get_draft(skill_name, draft_id) # type: ignore[union-attr] + safety = loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id) # type: ignore[union-attr] + eval_report = loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id) # type: ignore[union-attr] + payload = { + **draft.to_dict(), + "safety_report": safety.to_dict() if safety is not None else None, + "eval_report": eval_report.to_dict() if eval_report is not None else None, + } + if include_reviews: + payload["reviews"] = [ + item.to_dict() + for item in loaded.skill_learning_pipeline.reviews_for_draft(skill_name, draft_id) # type: ignore[union-attr] + ] + return payload + + +def _skill_versions_payload(loaded: Any, record: Any) -> list[dict[str, Any]]: + if record.source != "workspace": + return [ + { + "version": record.version or "legacy", + "status": record.status, + "createdAt": None, + "publishedAt": None, + } + ] + result: list[dict[str, Any]] = [] + for version in loaded.skill_spec_store.list_versions(record.name): # type: ignore[union-attr] + loaded_version = loaded.skill_spec_store.read_published_skill(record.name, version) # type: ignore[union-attr] + if loaded_version is None: + continue + result.append( + { + "version": loaded_version.version.version, + "status": loaded_version.version.review_state, + "createdAt": loaded_version.version.created_at, + "publishedAt": loaded_version.version.created_at, + "changeReason": loaded_version.version.change_reason, + "parentVersion": loaded_version.version.parent_version, + "contentHash": loaded_version.version.content_hash, + } + ) + if not result: + result.append( + { + "version": record.version or "legacy", + "status": record.status, + "createdAt": None, + "publishedAt": None, + } + ) + return result + + +def _skill_version_base_dir(loaded: Any, record: Any, version: str) -> Path: + if record.source != "workspace" or version == "legacy": + if record.source == "workspace": + legacy_dir = loaded.skill_spec_store.root / record.name # type: ignore[union-attr] + if (legacy_dir / "SKILL.md").exists(): + return legacy_dir + return record.path.parent + loaded_version = loaded.skill_spec_store.read_published_skill(record.name, version) # type: ignore[union-attr] + if loaded_version is None: + raise HTTPException(status_code=404, detail="Skill version not found") + return loaded.skill_spec_store.root / record.name / "versions" / version # type: ignore[union-attr] + + +def _list_skill_files(base_dir: Path) -> list[dict[str, Any]]: + if not base_dir.exists(): + return [] + ignored_dirs = {"drafts", "reviews", "archive", "versions", "__pycache__"} + ignored_files = {"version.json", "skill.json", "current.json"} + files: list[dict[str, Any]] = [] + for file_path in sorted(base_dir.rglob("*")): + if not file_path.is_file() or file_path.is_symlink(): + continue + rel_path = file_path.relative_to(base_dir).as_posix() + parts = set(file_path.relative_to(base_dir).parts) + if parts & ignored_dirs or file_path.name in ignored_files: + continue + stat = file_path.stat() + files.append( + { + "filePath": rel_path, + "fileSize": stat.st_size, + "contentType": _content_type_for_path(rel_path), + "sha256": None, + } + ) + return files + + +def _skill_file_content_payload(base_dir: Path, file_path: Path) -> dict[str, Any]: + rel_path = file_path.relative_to(base_dir).as_posix() + raw = file_path.read_bytes() + is_binary = _is_probably_binary(raw) + content = None if is_binary else raw.decode("utf-8", errors="replace") + return { + "filePath": rel_path, + "fileSize": len(raw), + "contentType": _content_type_for_path(rel_path), + "isBinary": is_binary, + "content": content, + } + + +def _safe_child_path(base_dir: Path, rel_path: str) -> Path: + cleaned = rel_path.replace("\\", "/").lstrip("/") + if not cleaned or cleaned in {".", ".."}: + raise HTTPException(status_code=400, detail="Invalid file path") + base_resolved = base_dir.resolve() + target = (base_dir / cleaned).resolve() + if target != base_resolved and base_resolved not in target.parents: + raise HTTPException(status_code=400, detail="Invalid file path") + return target + + +def _content_type_for_path(path: str) -> str: + lower = path.lower() + if lower.endswith(".md"): + return "text/markdown" + if lower.endswith(".json"): + return "application/json" + if lower.endswith((".yaml", ".yml", ".toml", ".txt", ".csv", ".log")): + return "text/plain" + if lower.endswith((".py", ".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".sh")): + return "text/plain" + if lower.endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")): + return f"image/{lower.rsplit('.', 1)[-1].replace('jpg', 'jpeg')}" + return "application/octet-stream" + + +def _is_probably_binary(raw: bytes) -> bool: + if not raw: + return False + if b"\x00" in raw[:4096]: + return True + try: + raw[:4096].decode("utf-8") + except UnicodeDecodeError: + return True + return False + + def _skill_draft_http_error(exc: ValueError) -> HTTPException: detail = str(exc) status_code = 404 if detail.startswith("Draft not found:") else 400 @@ -2165,6 +2914,43 @@ def _ensure_dict(parent: dict[str, Any], key: str) -> dict[str, Any]: return value +def _save_backend_identity( + agent_service: AgentService, + *, + config_path: Path, + backend_id: str, + client_id: str, + client_secret: str, + name: str, + public_base_url: str, + authz_base_url: str, +) -> dict[str, Any]: + raw = _read_config_json(config_path) + authz = _ensure_dict(raw, "authz") + authz["enabled"] = True + authz["baseUrl"] = authz_base_url + + identity = _ensure_dict(raw, "backend_identity") + identity["backendId"] = backend_id + identity["clientId"] = client_id + identity["clientSecret"] = client_secret + identity["name"] = name + identity["publicBaseUrl"] = public_base_url + + _write_config_json(config_path, raw) + _reload_agent_config(agent_service, config_path) + return { + "backend_id": backend_id, + "client_id": client_id, + "name": name, + "public_base_url": public_base_url, + "authz": { + "enabled": True, + "base_url": authz_base_url, + }, + } + + def _write_config_json(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_name(f"{path.name}.tmp") diff --git a/app-instance/backend/beaver/interfaces/web/files.py b/app-instance/backend/beaver/interfaces/web/files.py new file mode 100644 index 0000000..d6e8e87 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/web/files.py @@ -0,0 +1,322 @@ +"""File storage and workspace browsing helpers for the web API.""" + +from __future__ import annotations + +import json +import mimetypes +import shutil +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import quote + + +def content_disposition(disposition: str, filename: str) -> str: + """Build a Content-Disposition header, including RFC 5987 for non-ASCII names.""" + + try: + filename.encode("ascii") + return f'{disposition}; filename="{filename}"' + except UnicodeEncodeError: + utf8_quoted = quote(filename) + return f"{disposition}; filename*=UTF-8''{utf8_quoted}" + + +def generate_file_id() -> str: + """Generate a short unique file id.""" + + return uuid.uuid4().hex[:12] + + +def save_file( + workspace: Path, + file_id: str, + filename: str, + content: bytes, + content_type: str, + session_id: str = "web:default", +) -> dict[str, Any]: + """Save an uploaded attachment under workspace/files//.""" + + if not _is_safe_filename(filename): + raise ValueError(f"Invalid filename: {filename}") + + file_dir = _files_dir(workspace) / file_id + file_dir.mkdir(parents=True, exist_ok=True) + file_path = file_dir / filename + file_path.write_bytes(content) + + metadata = { + "file_id": file_id, + "name": filename, + "content_type": content_type, + "size": len(content), + "created_at": datetime.now(timezone.utc).isoformat(), + "session_id": session_id, + } + (file_dir / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8") + return metadata + + +def get_file_metadata(workspace: Path, file_id: str) -> dict[str, Any] | None: + """Load attachment metadata.""" + + if not _is_safe_file_id(file_id): + return None + + meta_path = _files_dir(workspace) / file_id / "metadata.json" + if not meta_path.exists(): + return None + + try: + data = json.loads(meta_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def get_file_path(workspace: Path, file_id: str) -> Path | None: + """Resolve the stored attachment path.""" + + meta = get_file_metadata(workspace, file_id) + if meta is None: + return None + + file_path = _files_dir(workspace) / file_id / str(meta.get("name") or "") + try: + file_path.resolve().relative_to(_files_dir(workspace).resolve()) + except ValueError: + return None + return file_path if file_path.exists() and file_path.is_file() else None + + +def list_files(workspace: Path, session_id: str | None = None) -> list[dict[str, Any]]: + """List uploaded attachments, optionally filtered by session.""" + + files_dir = _files_dir(workspace) + result: list[dict[str, Any]] = [] + for entry in sorted(files_dir.iterdir()): + if not entry.is_dir(): + continue + meta_path = entry / "metadata.json" + if not meta_path.exists(): + continue + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError): + continue + if not isinstance(meta, dict): + continue + if session_id and meta.get("session_id") != session_id: + continue + result.append(meta) + return result + + +def delete_file(workspace: Path, file_id: str) -> bool: + """Delete a stored attachment by id.""" + + if not _is_safe_file_id(file_id): + return False + + file_dir = _files_dir(workspace) / file_id + if not file_dir.exists(): + return False + shutil.rmtree(file_dir) + return True + + +def browse_workspace(workspace: Path, rel_path: str = "") -> dict[str, Any]: + """List files and directories below the workspace root.""" + + workspace = _ensure_workspace(workspace) + target = _resolve_workspace_path(workspace, rel_path) + if target is None or not target.is_dir(): + raise ValueError("Invalid directory path") + + try: + entries = sorted(target.iterdir(), key=lambda entry: (not entry.is_dir(), entry.name.lower())) + except PermissionError as exc: + raise ValueError("Permission denied") from exc + + items: list[dict[str, Any]] = [] + for entry in entries: + if entry.name.startswith("."): + continue + rel = str(entry.relative_to(workspace)) + if entry.is_dir(): + items.append( + { + "name": entry.name, + "path": rel, + "type": "directory", + "size": None, + "modified": datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc).isoformat(), + } + ) + elif entry.is_file(): + stat = entry.stat() + content_type, _ = mimetypes.guess_type(entry.name) + items.append( + { + "name": entry.name, + "path": rel, + "type": "file", + "size": stat.st_size, + "content_type": content_type or "application/octet-stream", + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + } + ) + + return { + "path": str(target.relative_to(workspace)) if target != workspace else "", + "items": items, + } + + +def workspace_file_path(workspace: Path, rel_path: str) -> Path | None: + """Resolve a workspace file path for download.""" + + workspace = _ensure_workspace(workspace) + target = _resolve_workspace_path(workspace, rel_path) + if target is None or not target.is_file(): + return None + return target + + +def workspace_file_preview(workspace: Path, rel_path: str, *, max_bytes: int = 1024 * 1024) -> dict[str, Any]: + """Return a bounded preview payload for a workspace file.""" + + file_path = workspace_file_path(workspace, rel_path) + if file_path is None: + raise ValueError("File not found") + + stat = file_path.stat() + content_type, _ = mimetypes.guess_type(file_path.name) + content_type = content_type or "application/octet-stream" + raw = file_path.read_bytes() if stat.st_size <= max_bytes else file_path.read_bytes()[:max_bytes] + is_binary = _is_probably_binary(raw, content_type) + content = None if is_binary else raw.decode("utf-8", errors="replace") + return { + "name": file_path.name, + "path": str(file_path.relative_to(_ensure_workspace(workspace))), + "size": stat.st_size, + "content_type": content_type, + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "is_binary": is_binary, + "is_truncated": stat.st_size > max_bytes, + "content": content, + } + + +def save_to_workspace(workspace: Path, rel_dir: str, filename: str, content: bytes) -> dict[str, Any]: + """Save an uploaded file to a workspace directory.""" + + if not filename: + raise ValueError("Invalid filename") + + workspace = _ensure_workspace(workspace) + target_dir = _resolve_workspace_path(workspace, rel_dir) + if target_dir is None: + raise ValueError("Invalid directory path") + target_dir.mkdir(parents=True, exist_ok=True) + + file_path = (target_dir / filename).resolve() + try: + file_path.relative_to(workspace) + except ValueError as exc: + raise ValueError("Invalid filename") from exc + + file_path.write_bytes(content) + stat = file_path.stat() + content_type, _ = mimetypes.guess_type(filename) + return { + "name": filename, + "path": str(file_path.relative_to(workspace)), + "type": "file", + "size": stat.st_size, + "content_type": content_type or "application/octet-stream", + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + } + + +def delete_workspace_path(workspace: Path, rel_path: str) -> bool: + """Delete a file or directory below workspace root.""" + + workspace = _ensure_workspace(workspace) + target = _resolve_workspace_path(workspace, rel_path) + if target is None or not target.exists() or target == workspace: + return False + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + return True + + +def create_workspace_dir(workspace: Path, rel_path: str) -> dict[str, Any]: + """Create a directory below workspace root.""" + + workspace = _ensure_workspace(workspace) + target = _resolve_workspace_path(workspace, rel_path) + if target is None or target == workspace: + raise ValueError("Invalid directory path") + target.mkdir(parents=True, exist_ok=True) + stat = target.stat() + return { + "name": target.name, + "path": str(target.relative_to(workspace)), + "type": "directory", + "size": None, + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + } + + +def _files_dir(workspace: Path) -> Path: + directory = _ensure_workspace(workspace) / "files" + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def _ensure_workspace(workspace: Path) -> Path: + root = Path(workspace).expanduser() + root.mkdir(parents=True, exist_ok=True) + return root.resolve() + + +def _resolve_workspace_path(workspace: Path, rel_path: str) -> Path | None: + root = _ensure_workspace(workspace) + target = (root / rel_path).resolve() + try: + target.relative_to(root) + except ValueError: + return None + return target + + +def _is_probably_binary(raw: bytes, content_type: str) -> bool: + if content_type.startswith("text/") or content_type in { + "application/json", + "application/javascript", + "application/xml", + "application/x-yaml", + }: + return False + if not raw: + return False + if b"\x00" in raw[:4096]: + return True + try: + raw[:4096].decode("utf-8") + except UnicodeDecodeError: + return True + return False + + +def _is_safe_filename(filename: str) -> bool: + return bool(filename) and "/" not in filename and "\\" not in filename and not filename.startswith(".") + + +def _is_safe_file_id(file_id: str) -> bool: + return bool(file_id) and all(char in "0123456789abcdef" for char in file_id) diff --git a/app-instance/backend/beaver/services/agent_service.py b/app-instance/backend/beaver/services/agent_service.py index f9e2293..e616df2 100644 --- a/app-instance/backend/beaver/services/agent_service.py +++ b/app-instance/backend/beaver/services/agent_service.py @@ -548,8 +548,13 @@ class AgentService: provider=router_provider, model=getattr(router_runtime, "model", None), recent_messages=session_manager.get_messages_as_conversation(session_id), + intent_skill=self._load_intent_agent_skill(loaded), thinking_enabled=kwargs.get("thinking_enabled"), ) + kwargs["intent_agent_decision"] = self._intent_decision_payload( + decision, + active_task=active_task, + ) if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"): active_task.metadata["short_title"] = decision.short_title task_service.store.upsert_task(active_task) @@ -796,6 +801,28 @@ class AgentService: raise RuntimeError(f"Engine loader did not provide required dependency {field_name!r}") return value + @staticmethod + def _load_intent_agent_skill(loaded: Any) -> str | None: + skills_loader = getattr(loaded, "skills_loader", None) + if skills_loader is None: + return None + return skills_loader.load_skill("intent-agent-router") + + @staticmethod + def _intent_decision_payload(decision: Any, *, active_task: TaskRecord | None) -> dict[str, Any]: + action = decision.action or ("create_task" if decision.is_task and active_task is None else decision.mode) + return { + "agent": "intent_agent", + "choice": action, + "mode": "task" if decision.is_task else "simple", + "reason": decision.reason, + "active_task_id": active_task.task_id if active_task is not None else None, + "starts_new_task": bool(decision.starts_new_task or (decision.is_task and active_task is None)), + "closes_task": bool(decision.closes_task), + "abandons_task": bool(decision.abandons_task), + "short_title": decision.short_title, + } + @staticmethod def _skill_names_for_run(loaded: Any, run_id: str) -> list[str]: store = getattr(loaded, "run_memory_store", None) diff --git a/app-instance/backend/beaver/services/skillhub_service.py b/app-instance/backend/beaver/services/skillhub_service.py index b480f47..123ddba 100644 --- a/app-instance/backend/beaver/services/skillhub_service.py +++ b/app-instance/backend/beaver/services/skillhub_service.py @@ -68,6 +68,34 @@ class SkillHubService: 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) @@ -246,3 +274,14 @@ def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str: 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" diff --git a/app-instance/backend/beaver/skills/builtin/intent-agent-router/SKILL.md b/app-instance/backend/beaver/skills/builtin/intent-agent-router/SKILL.md new file mode 100644 index 0000000..291e89f --- /dev/null +++ b/app-instance/backend/beaver/skills/builtin/intent-agent-router/SKILL.md @@ -0,0 +1,59 @@ +--- +name: intent-agent-router +description: Internal routing guidance for the first-layer Intent Agent. +internal: true +--- +# Intent Agent Router + +## Role + +You are the first-layer Intent Agent. You are not the main assistant and you do not execute the user request. + +Your only job is to classify the current user message into one routing decision: + +- `simple_chat` +- `continue_task` +- `new_task` +- `close_task` +- `abandon_task` + +Return compact JSON only. Never answer the user directly. + +## Core Boundary + +Choose `simple_chat` only when the message can be answered safely from ordinary language understanding without tools, external data, local files, user-private data, execution, validation, or multi-step work. + +Choose `new_task` when the user asks for anything that needs the main Task agent's capabilities, including tools, skills, files, web/search, execution, iteration, planning, validation, or multi-agent work. + +The Intent Agent has no tools. If a request needs a tool, do not apologize and do not say you cannot access it. Route it to Task mode so the main agent can use tools. + +## Must Create Task + +Choose `new_task` when there is no active task and the request asks to: + +- look up, search, browse, fetch, verify, check, monitor, compare, or summarize current/external information +- answer about today's weather, live conditions, latest news, prices, schedules, exchange rates, regulations, releases, or other changing facts +- inspect, read, write, patch, run, test, build, deploy, debug, or modify local files or systems +- use email, calendar, messages, databases, MCP tools, shell commands, web APIs, or other integrations +- produce a deliverable that needs multiple steps, validation, or follow-up execution + +Examples that must be `new_task`: + +- "帮我查一下今天珠海天气" +- "查一下最新的 OpenAI API 价格" +- "看看这个项目测试为什么失败" +- "帮我改一下登录页面" +- "给我查一下明天的航班" + +## Simple Chat + +Choose `simple_chat` only for lightweight conversation or stable knowledge that does not need tools. + +Examples: + +- "你好" +- "解释一下什么是递归" +- "把这句话润色一下" +- "给我一个学习 Python 的大纲" + +If uncertain whether tools may be needed, prefer `new_task`. diff --git a/app-instance/backend/beaver/skills/catalog/loader.py b/app-instance/backend/beaver/skills/catalog/loader.py index e9a2543..7d097cc 100644 --- a/app-instance/backend/beaver/skills/catalog/loader.py +++ b/app-instance/backend/beaver/skills/catalog/loader.py @@ -67,7 +67,12 @@ class SkillsLoader: self.extra_dirs = [Path(item) for item in (extra_dirs or [])] self.skill_store = skill_store or SkillSpecStore(self.workspace) - def list_skills(self, *, filter_unavailable: bool = True) -> list[SkillRecord]: + def list_skills( + self, + *, + filter_unavailable: bool = True, + include_internal: bool = False, + ) -> list[SkillRecord]: """列出当前可见的 skills。 优先级: @@ -80,9 +85,11 @@ class SkillsLoader: found: dict[str, SkillRecord] = {} - for record in self.list_published_skills(): + for record in self.list_published_skills(filter_unavailable=filter_unavailable): if record.name in found: continue + if not include_internal and self._record_internal(record): + continue if filter_unavailable and not self._record_available(record): continue found[record.name] = record @@ -101,6 +108,8 @@ class SkillsLoader: if name in found: continue frontmatter, body = parse_frontmatter(skill_file.read_text(encoding="utf-8")) + if not include_internal and _truthy(frontmatter.get("internal")): + continue normalized_frontmatter = dict(frontmatter) record = SkillRecord( name=name, @@ -375,11 +384,15 @@ class SkillsLoader: return [] def _find_record(self, name: str) -> SkillRecord | None: - for record in self.list_skills(filter_unavailable=False): + for record in self.list_skills(filter_unavailable=False, include_internal=True): if record.name == name: return record return None + @staticmethod + def _record_internal(record: SkillRecord) -> bool: + return _truthy((record.frontmatter or {}).get("internal")) + def _record_available(self, record: SkillRecord) -> bool: content = record.path.read_text(encoding="utf-8") frontmatter, _ = parse_frontmatter(content) @@ -405,3 +418,9 @@ class SkillsLoader: def summarize_body(body: str) -> str: cleaned = " ".join(line.strip() for line in body.splitlines()[:3] if line.strip()).strip() return cleaned[:240] + + +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value or "").strip().lower() in {"1", "true", "yes", "y", "on"} diff --git a/app-instance/backend/beaver/skills/learning/service.py b/app-instance/backend/beaver/skills/learning/service.py index 18c064e..c350672 100644 --- a/app-instance/backend/beaver/skills/learning/service.py +++ b/app-instance/backend/beaver/skills/learning/service.py @@ -181,7 +181,7 @@ class SkillLearningService: if candidate.kind == "new_skill": payload = await self.synthesizer.synthesize_new_skill(candidate, packet, provider, model) return self.draft_service.create_new_skill_draft( - skill_name=self._suggest_skill_name(candidate, packet), + skill_name=self._suggest_skill_name(candidate, packet, payload.get("frontmatter")), proposed_content=payload["content"], proposed_frontmatter=payload["frontmatter"], created_by="learning-loop", @@ -382,15 +382,34 @@ class SkillLearningService: return " ".join(words[:8]).strip() @staticmethod - def _suggest_skill_name(candidate: SkillLearningCandidate, packet: EvidencePacket) -> str: + def _suggest_skill_name( + candidate: SkillLearningCandidate, + packet: EvidencePacket, + frontmatter: dict[str, Any] | None = None, + ) -> str: if candidate.related_skill_names: return candidate.related_skill_names[0] - if packet.task_summaries: - seed = re.sub(r"[^a-z0-9]+", "-", packet.task_summaries[0].lower()).strip("-") + if isinstance(frontmatter, dict): + description = str(frontmatter.get("description") or "") + seed = SkillLearningService._slugify_skill_name(description) if seed: - return seed[:48] + return seed + if packet.task_summaries: + seed = SkillLearningService._slugify_skill_name(packet.task_summaries[0]) + if seed: + return seed return f"generated-skill-{uuid4().hex[:8]}" + @staticmethod + def _slugify_skill_name(value: str) -> str: + seed = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + seed = re.sub(r"-+", "-", seed) + if not seed or seed.isdigit() or len(seed) < 3: + return "" + words = [part for part in seed.split("-") if part and not part.isdigit()] + seed = "-".join(words) or seed + return seed[:48].strip("-") + @staticmethod def _parse_timestamp(value: str) -> datetime | None: try: diff --git a/app-instance/backend/beaver/tasks/models.py b/app-instance/backend/beaver/tasks/models.py index 007c70e..e8093e1 100644 --- a/app-instance/backend/beaver/tasks/models.py +++ b/app-instance/backend/beaver/tasks/models.py @@ -163,6 +163,7 @@ class MainAgentDecision: closes_task: bool = False abandons_task: bool = False short_title: str | None = None + action: str = "" @property def is_task(self) -> bool: diff --git a/app-instance/backend/beaver/tasks/router.py b/app-instance/backend/beaver/tasks/router.py index 36aa709..e771e2d 100644 --- a/app-instance/backend/beaver/tasks/router.py +++ b/app-instance/backend/beaver/tasks/router.py @@ -20,6 +20,7 @@ class MainAgentRouter: provider: Any | None = None, model: str | None = None, recent_messages: list[dict[str, Any]] | None = None, + intent_skill: str | None = None, thinking_enabled: bool | None = None, timeout_seconds: float = 8.0, ) -> MainAgentDecision: @@ -31,8 +32,9 @@ class MainAgentRouter: { "role": "system", "content": ( - "You route user messages for Beaver's internal Task mode. " - "Return only compact JSON. Do not explain." + "You are Beaver's Intent Agent. Your only job is to route the user's " + "message to simple chat or internal Task mode. Return only compact JSON. " + "Do not answer the user. Do not explain." ), }, { @@ -41,6 +43,7 @@ class MainAgentRouter: message=message, active_task=active_task, recent_messages=recent_messages or [], + intent_skill=intent_skill, ), }, ], @@ -63,19 +66,48 @@ class MainAgentRouter: short_title = _clean_short_title(payload.get("short_title") or payload.get("title")) if raw_action in {"continue_task", "continue", "task"}: - return MainAgentDecision(mode="task", reason=reason, short_title=short_title) + return MainAgentDecision( + mode="task", + reason=reason, + starts_new_task=active_task is None, + short_title=short_title, + action="continue_task" if active_task is not None else "create_task", + ) if raw_action in {"new_task", "new"}: - return MainAgentDecision(mode="task", reason=reason, starts_new_task=True, short_title=short_title) + return MainAgentDecision( + mode="task", + reason=reason, + starts_new_task=True, + short_title=short_title, + action="create_task", + ) if raw_action in {"close_task", "close", "done", "finish"}: - return MainAgentDecision(mode="simple", reason=reason, closes_task=active_task is not None, short_title=short_title) + return MainAgentDecision( + mode="simple", + reason=reason, + closes_task=active_task is not None, + short_title=short_title, + action="close_task", + ) if raw_action in {"abandon_task", "abandon", "cancel_task"}: - return MainAgentDecision(mode="simple", reason=reason, abandons_task=active_task is not None, short_title=short_title) - return MainAgentDecision(mode="simple", reason=reason or "simple_chat", short_title=short_title) + return MainAgentDecision( + mode="simple", + reason=reason, + abandons_task=active_task is not None, + short_title=short_title, + action="abandon_task", + ) + return MainAgentDecision( + mode="simple", + reason=reason or "simple_chat", + short_title=short_title, + action="simple_chat", + ) def _fallback(self, *, active_task: TaskRecord | None, reason: str) -> MainAgentDecision: if active_task is not None: - return MainAgentDecision(mode="task", reason=reason) - return MainAgentDecision(mode="simple", reason=reason) + return MainAgentDecision(mode="task", reason=reason, action="continue_task") + return MainAgentDecision(mode="simple", reason=reason, action="simple_chat") @staticmethod def _prompt( @@ -83,6 +115,7 @@ class MainAgentRouter: message: str, active_task: TaskRecord | None, recent_messages: list[dict[str, Any]], + intent_skill: str | None, ) -> str: active_task_payload = None if active_task is not None: @@ -98,8 +131,14 @@ class MainAgentRouter: for item in recent_messages[-8:] if item.get("role") in {"user", "assistant"} ] + skill_section = ( + f"Intent Agent skill guidance:\n{intent_skill.strip()}\n\n" + if intent_skill and intent_skill.strip() + else "" + ) return ( "Decide how to route the current user message.\n\n" + f"{skill_section}" "Actions:\n" "- simple_chat: no Task should be created or continued.\n" "- continue_task: keep the user in the active Task.\n" @@ -113,6 +152,10 @@ class MainAgentRouter: "- Use new_task only when the user clearly asks to start a different task.\n" "- If there is no active Task, choose new_task only for work that requires execution, iteration, tools, files, " "implementation, validation, or multi-step completion. Otherwise choose simple_chat.\n" + "- Requests that need current, real-time, external, user-private, local-file, web, weather, price, news, " + "calendar, email, or system data require tools. Choose new_task for them because the Intent Agent has no tools.\n" + "- The Intent Agent must never answer tool-dependent requests itself or apologize for lacking tools. " + "It only routes the request so the main Task agent can use tools.\n" "- short_title must be 5-15 Chinese characters or a similarly short English phrase when a Task is involved.\n\n" "Return JSON only with keys: action, reason, short_title.\n\n" f"Active task:\n{json.dumps(active_task_payload, ensure_ascii=False)}\n\n" diff --git a/app-instance/backend/flow.md b/app-instance/backend/flow.md index 5cdfeca..b5e97b6 100644 --- a/app-instance/backend/flow.md +++ b/app-instance/backend/flow.md @@ -18,7 +18,10 @@ └─ future channels(未来扩展入口) │ └─ AgentService(统一服务层:所有入口都先汇总到这里) -├─ MainAgentRouter(LLM 语义判断 simple / continue task / new task / close / abandon) +├─ Intent Agent / MainAgentRouter(第一层意图判断:simple chat / continue task / create task / close / abandon) + ├─ load intent-agent-router skill(内部 skill 指引:只做路由,不回答用户,不使用工具) + ├─ classify(...)(LLM 语义判断) + ├─ session hidden event: intent_agent_decision_snapshotted(记录选择 simple_chat / create_task / continue_task 等) ├─ create_loop()(创建 AgentLoop 运行核心) ├─ start()(启动后台运行模式) ├─ submit_direct()(把任务提交到运行队列) @@ -73,7 +76,10 @@ AgentService.process_direct / submit_direct(聊天入口统一进入服务层 │ ├─ resolve session_id(复用请求 session,或生成新 session) ├─ task_service.get_latest_open_task(session_id)(查找同会话未关闭 Task) -├─ MainAgentRouter.classify(message, active_task, recent_messages)(LLM 语义分类) +├─ MainAgentRouter.classify(message, active_task, recent_messages, intent-agent-router skill)(Intent Agent 语义分类) +│ ├─ Intent Agent 只返回 JSON 路由结果,不直接回答用户 +│ ├─ Intent Agent 没有 tools;凡是需要工具、实时/外部数据、文件、执行、验证的请求都应进入 Task +│ ├─ session hidden event: intent_agent_decision_snapshotted(调试日志展示 choice / reason / short_title) │ ├─ simple(简单问题) │ │ └─ runner(message, include_skill_assembly=False, include_tools=False)(不创建 Task,不跑 skills/tools) │ │ diff --git a/app-instance/backend/tests/unit/test_debug_chat_logs_api.py b/app-instance/backend/tests/unit/test_debug_chat_logs_api.py index 9d939fa..7521144 100644 --- a/app-instance/backend/tests/unit/test_debug_chat_logs_api.py +++ b/app-instance/backend/tests/unit/test_debug_chat_logs_api.py @@ -20,10 +20,24 @@ def test_debug_chat_logs_group_events_by_run(tmp_path: Path) -> None: run_id=run_id, role="system", event_type="run_started", - event_payload={"source": "web", "task_id": "task-1", "attempt_index": 1}, + event_payload={ + "source": "web", + "task_id": "task-1", + "attempt_index": 1, + "intent_agent_decision": {"choice": "create_task", "reason": "needs tools"}, + }, content="hello", context_visible=False, ) + manager.append_message( + session_id, + run_id=run_id, + role="system", + event_type="intent_agent_decision_snapshotted", + event_payload={"choice": "create_task", "reason": "needs tools"}, + content="create_task", + context_visible=False, + ) manager.append_message( session_id, run_id=run_id, @@ -57,11 +71,13 @@ def test_debug_chat_logs_group_events_by_run(tmp_path: Path) -> None: sessions = response.json()["sessions"] run = sessions[0]["runs"][0] assert run["run_id"] == run_id + assert run["intent_agent_choice"] == "create_task" assert run["user_input"] == "hello" assert [event["event_type"] for event in run["events"]] == [ "run_started", + "intent_agent_decision_snapshotted", "llm_request_snapshotted", "user_message_added", "assistant_message_added", ] - assert run["events"][1]["event_payload"]["messages"][0]["content"] == "hello" + assert run["events"][2]["event_payload"]["messages"][0]["content"] == "hello" diff --git a/app-instance/backend/tests/unit/test_main_agent_router.py b/app-instance/backend/tests/unit/test_main_agent_router.py index 8884bb5..a30961e 100644 --- a/app-instance/backend/tests/unit/test_main_agent_router.py +++ b/app-instance/backend/tests/unit/test_main_agent_router.py @@ -23,6 +23,7 @@ class RouterProvider(LLMProvider): ) -> LLMResponse: self.calls.append( { + "messages": messages, "max_tokens": max_tokens, "temperature": temperature, "model": model, @@ -83,6 +84,24 @@ def test_router_receives_thinking_mode() -> None: assert provider.calls[0]["thinking_enabled"] is False +def test_router_injects_intent_skill_guidance() -> None: + provider = RouterProvider('{"action":"new_task","reason":"needs weather tool","short_title":"珠海天气"}') + decision = asyncio.run( + MainAgentRouter().classify( + "帮我查一下今天珠海天气", + provider=provider, + intent_skill="Weather and current external data must be routed to new_task.", + ) + ) + + assert decision.is_task + assert decision.starts_new_task is True + assert decision.action == "create_task" + prompt = provider.calls[0]["messages"][1]["content"] + assert "Intent Agent skill guidance" in prompt + assert "Weather and current external data" in prompt + + def test_router_closes_active_task_from_llm_decision() -> None: decision = asyncio.run( MainAgentRouter().classify( diff --git a/app-instance/backend/tests/unit/test_web_files_api.py b/app-instance/backend/tests/unit/test_web_files_api.py new file mode 100644 index 0000000..f40bda4 --- /dev/null +++ b/app-instance/backend/tests/unit/test_web_files_api.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +def test_workspace_browser_api_manages_workspace_files(tmp_path: Path) -> None: + service = AgentService(workspace=tmp_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + root = client.get("/api/workspace/browse") + mkdir = client.post("/api/workspace/mkdir", params={"path": "docs"}) + upload = client.post( + "/api/workspace/upload", + data={"path": "docs"}, + files={"file": ("hello.txt", b"hello workspace", "text/plain")}, + ) + docs = client.get("/api/workspace/browse", params={"path": "docs"}) + download = client.get("/api/workspace/download", params={"path": "docs/hello.txt"}) + deleted = client.delete("/api/workspace/delete", params={"path": "docs/hello.txt"}) + after_delete = client.get("/api/workspace/browse", params={"path": "docs"}) + + assert root.status_code == 200 + assert root.json()["path"] == "" + assert all(item["name"] != "docs" for item in root.json()["items"]) + assert mkdir.status_code == 200 + assert mkdir.json()["path"] == "docs" + assert upload.status_code == 200 + assert upload.json()["path"] == "docs/hello.txt" + assert docs.status_code == 200 + assert [item["name"] for item in docs.json()["items"]] == ["hello.txt"] + assert download.status_code == 200 + assert download.content == b"hello workspace" + assert deleted.status_code == 200 + assert deleted.json() == {"ok": True} + assert after_delete.status_code == 200 + assert after_delete.json()["items"] == [] + + +def test_attachment_file_api_round_trips_uploaded_file(tmp_path: Path) -> None: + service = AgentService(workspace=tmp_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + upload = client.post( + "/api/files/upload", + data={"session_id": "web:test"}, + files={"file": ("note.txt", b"hello attachment", "text/plain")}, + ) + file_id = upload.json()["file_id"] + listed = client.get("/api/files", params={"session_id": "web:test"}) + download = client.get(f"/api/files/{file_id}") + deleted = client.delete(f"/api/files/{file_id}") + missing = client.get(f"/api/files/{file_id}") + + assert upload.status_code == 200 + assert upload.json()["name"] == "note.txt" + assert upload.json()["url"] == f"/api/files/{file_id}" + assert listed.status_code == 200 + assert [item["file_id"] for item in listed.json()] == [file_id] + assert download.status_code == 200 + assert download.content == b"hello attachment" + assert deleted.status_code == 200 + assert deleted.json() == {"ok": True} + assert missing.status_code == 404 diff --git a/app-instance/create-instance.sh b/app-instance/create-instance.sh index 118142b..af7a9a0 100755 --- a/app-instance/create-instance.sh +++ b/app-instance/create-instance.sh @@ -266,24 +266,24 @@ from pathlib import Path target = Path(os.environ["TARGET_PATH"]) values = { - "NANOBOT_AUTHZ__ENABLED": "1" if os.environ["AUTHZ_BASE_URL"].strip() else "0", - "NANOBOT_AUTHZ__BASE_URL": os.environ["AUTHZ_BASE_URL"].strip(), - "NANOBOT_AUTHZ__OUTLOOK_MCP_URL": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(), - "NANOBOT_BACKEND_IDENTITY__BACKEND_ID": os.environ["BACKEND_ID"].strip(), - "NANOBOT_BACKEND_IDENTITY__CLIENT_ID": os.environ["CLIENT_ID"].strip(), - "NANOBOT_BACKEND_IDENTITY__CLIENT_SECRET": os.environ["CLIENT_SECRET"].strip(), - "NANOBOT_BACKEND_IDENTITY__NAME": os.environ["BACKEND_NAME"].strip(), - "NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL": os.environ["PUBLIC_URL"].strip(), + "BEAVER_AUTHZ__ENABLED": "1" if os.environ["AUTHZ_BASE_URL"].strip() else "0", + "BEAVER_AUTHZ__BASE_URL": os.environ["AUTHZ_BASE_URL"].strip(), + "BEAVER_AUTHZ__OUTLOOK_MCP_URL": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(), + "BEAVER_BACKEND_IDENTITY__BACKEND_ID": os.environ["BACKEND_ID"].strip(), + "BEAVER_BACKEND_IDENTITY__CLIENT_ID": os.environ["CLIENT_ID"].strip(), + "BEAVER_BACKEND_IDENTITY__CLIENT_SECRET": os.environ["CLIENT_SECRET"].strip(), + "BEAVER_BACKEND_IDENTITY__NAME": os.environ["BACKEND_NAME"].strip(), + "BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL": os.environ["PUBLIC_URL"].strip(), } ordered_keys = [ - "NANOBOT_AUTHZ__ENABLED", - "NANOBOT_AUTHZ__BASE_URL", - "NANOBOT_AUTHZ__OUTLOOK_MCP_URL", - "NANOBOT_BACKEND_IDENTITY__BACKEND_ID", - "NANOBOT_BACKEND_IDENTITY__CLIENT_ID", - "NANOBOT_BACKEND_IDENTITY__CLIENT_SECRET", - "NANOBOT_BACKEND_IDENTITY__NAME", - "NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL", + "BEAVER_AUTHZ__ENABLED", + "BEAVER_AUTHZ__BASE_URL", + "BEAVER_AUTHZ__OUTLOOK_MCP_URL", + "BEAVER_BACKEND_IDENTITY__BACKEND_ID", + "BEAVER_BACKEND_IDENTITY__CLIENT_ID", + "BEAVER_BACKEND_IDENTITY__CLIENT_SECRET", + "BEAVER_BACKEND_IDENTITY__NAME", + "BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL", ] lines: list[str] = [] for key in ordered_keys: @@ -291,8 +291,8 @@ for key in ordered_keys: if value: lines.append(f"export {key}={shlex.quote(value)}") continue - if key == "NANOBOT_AUTHZ__ENABLED": - lines.append("export NANOBOT_AUTHZ__ENABLED=0") + if key == "BEAVER_AUTHZ__ENABLED": + lines.append("export BEAVER_AUTHZ__ENABLED=0") else: lines.append(f"unset {key}") target.write_text("\n".join(lines) + "\n", encoding="utf-8") @@ -544,13 +544,12 @@ RUN_ARGS=( -e "BEAVER_HOME=/root/.beaver" -e "BEAVER_CONFIG_PATH=/root/.beaver/config.json" -e "BEAVER_WORKSPACE=/root/.beaver/workspace" - -e "NANOBOT_HOME=/root/.beaver" - -e "NANOBOT_AUTH_FILE=/root/.beaver/web_auth_users.json" - -e "NANOBOT_FRONTEND_PUBLIC_BASE_URL=${PUBLIC_URL}" + -e "BEAVER_AUTH_FILE=/root/.beaver/web_auth_users.json" + -e "BEAVER_FRONTEND_PUBLIC_BASE_URL=${PUBLIC_URL}" -e "APP_PUBLIC_PORT=8080" -e "APP_FRONTEND_PORT=3000" -e "APP_BACKEND_PORT=18080" - -e "NANOBOT_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}" + -e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}" --label "nano.instance.id=${INSTANCE_ID}" --label "nano.instance.slug=${INSTANCE_SLUG}" --label "nano.instance.public_url=${PUBLIC_URL}" @@ -571,7 +570,7 @@ docker run "${RUN_ARGS[@]}" "$IMAGE_NAME" >/dev/null --host-port "$HOST_PORT" \ --public-url "$PUBLIC_URL" \ --instance-root "$INSTANCE_ROOT" \ - --nanobot-home "$BEAVER_HOME" \ + --beaver-home "$BEAVER_HOME" \ --config-path "$CONFIG_PATH" \ --auth-users-path "$AUTH_USERS_PATH" \ --network-name "$NETWORK_NAME" \ @@ -594,7 +593,6 @@ host_port=${HOST_PORT} public_url=${PUBLIC_URL} instance_root=${INSTANCE_ROOT} beaver_home=${BEAVER_HOME} -nanobot_home=${BEAVER_HOME} config_path=${CONFIG_PATH} auth_users_path=${AUTH_USERS_PATH} runtime_env_path=${RUNTIME_ENV_PATH} diff --git a/app-instance/entrypoint.sh b/app-instance/entrypoint.sh index 385ce08..42f6f84 100755 --- a/app-instance/entrypoint.sh +++ b/app-instance/entrypoint.sh @@ -7,9 +7,8 @@ APP_BACKEND_PORT="${APP_BACKEND_PORT:-18080}" BEAVER_HOME="${BEAVER_HOME:-/root/.beaver}" BEAVER_CONFIG_PATH="${BEAVER_CONFIG_PATH:-$BEAVER_HOME/config.json}" BEAVER_WORKSPACE="${BEAVER_WORKSPACE:-$BEAVER_HOME/workspace}" -NANOBOT_HOME="${NANOBOT_HOME:-$BEAVER_HOME}" -NANOBOT_AUTH_FILE="${NANOBOT_AUTH_FILE:-$BEAVER_HOME/web_auth_users.json}" -NANOBOT_RUNTIME_ENV_FILE="${NANOBOT_RUNTIME_ENV_FILE:-$BEAVER_HOME/runtime.env}" +BEAVER_AUTH_FILE="${BEAVER_AUTH_FILE:-$BEAVER_HOME/web_auth_users.json}" +BEAVER_RUNTIME_ENV_FILE="${BEAVER_RUNTIME_ENV_FILE:-$BEAVER_HOME/runtime.env}" log() { printf '[app-instance] %s\n' "$*" @@ -45,16 +44,16 @@ trap cleanup EXIT INT TERM mkdir -p "$BEAVER_HOME" "$BEAVER_WORKSPACE" -if [[ -f "$NANOBOT_RUNTIME_ENV_FILE" ]]; then +if [[ -f "$BEAVER_RUNTIME_ENV_FILE" ]]; then set -a - . "$NANOBOT_RUNTIME_ENV_FILE" + . "$BEAVER_RUNTIME_ENV_FILE" set +a fi require_file "$BEAVER_CONFIG_PATH" "Missing Beaver config" -export NANOBOT_AUTH_FILE -export NANOBOT_RUNTIME_ENV_FILE +export BEAVER_AUTH_FILE +export BEAVER_RUNTIME_ENV_FILE export BEAVER_HOME export BEAVER_CONFIG_PATH export BEAVER_WORKSPACE diff --git a/app-instance/frontend/README.md b/app-instance/frontend/README.md index e110f98..5877a95 100644 --- a/app-instance/frontend/README.md +++ b/app-instance/frontend/README.md @@ -245,7 +245,7 @@ docker build \ 当前仓库的部分技术标识仍沿用旧命名,例如: - `nanobot web` -- `~/.nanobot/plugins/` +- `~/.beaver/plugins/` - 本地存储中的旧 token key 这些属于兼容性和后端约定的一部分,前端展示品牌已替换为 `Boardware Genius`,但技术标识没有在这个仓库里强制迁移。 diff --git a/app-instance/frontend/app/(app)/agents/page.tsx b/app-instance/frontend/app/(app)/agents/page.tsx index 479711f..7955a18 100644 --- a/app-instance/frontend/app/(app)/agents/page.tsx +++ b/app-instance/frontend/app/(app)/agents/page.tsx @@ -721,7 +721,7 @@ export default function AgentsPage() { {t('持久化 Sub-Agent 会在', 'Persistent sub-agents keep their own workspace under')} - ~/.nanobot/workspace/agents/<id>_agent + ~/.beaver/workspace/agents/<id>_agent {t('下拥有自己的 workspace、`AGENTS.json`、`AGENTS.md`、skills 和 memory。默认委派模式是', ', plus `AGENTS.json`, `AGENTS.md`, skills, and memory. The default delegation mode is')} remote_a2a_only {t(',即只能向外委派到远端 A2A agent。', ', which only allows delegation to remote A2A agents.')} diff --git a/app-instance/frontend/app/(app)/files/page.tsx b/app-instance/frontend/app/(app)/files/page.tsx index c29df10..984e2a1 100644 --- a/app-instance/frontend/app/(app)/files/page.tsx +++ b/app-instance/frontend/app/(app)/files/page.tsx @@ -18,18 +18,21 @@ import { FileArchive, FileSpreadsheet, } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import { browseWorkspace, + getWorkspaceFile, getWorkspaceDownloadUrl, uploadToWorkspace, deleteWorkspacePath, createWorkspaceDir, getAccessToken, } from '@/lib/api'; -import type { WorkspaceItem } from '@/lib/api'; +import type { WorkspaceFileContent, WorkspaceItem } from '@/lib/api'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { pickAppText } from '@/lib/i18n/core'; +import { type AppLocale, pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; export default function FilesPage() { @@ -41,6 +44,9 @@ export default function FilesPage() { const [uploadProgress, setUploadProgress] = useState(0); const [showMkdir, setShowMkdir] = useState(false); const [newDirName, setNewDirName] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); const fileInputRef = useRef(null); const mkdirInputRef = useRef(null); @@ -50,6 +56,8 @@ export default function FilesPage() { const data = await browseWorkspace(path); setItems(data.items); setCurrentPath(data.path); + setSelectedFile(null); + setPreviewError(null); } catch { // ignore } finally { @@ -65,6 +73,20 @@ export default function FilesPage() { load(path); }; + const openFile = async (item: WorkspaceItem) => { + if (item.type !== 'file') return; + setPreviewLoading(true); + setPreviewError(null); + try { + setSelectedFile(await getWorkspaceFile(item.path)); + } catch (err: any) { + setPreviewError(err.message || pickAppText(locale, '加载文件失败', 'Failed to load file')); + setSelectedFile(null); + } finally { + setPreviewLoading(false); + } + }; + const handleDelete = async (item: WorkspaceItem) => { const label = item.type === 'directory' ? pickAppText(locale, '文件夹', 'folder') @@ -79,6 +101,9 @@ export default function FilesPage() { try { await deleteWorkspacePath(item.path); setItems((prev) => prev.filter((i) => i.path !== item.path)); + if (selectedFile?.path === item.path) { + setSelectedFile(null); + } } catch { // ignore } @@ -165,7 +190,7 @@ export default function FilesPage() { }; return ( -
+
{/* Header */}

{pickAppText(locale, '文件管理', 'Files')}

@@ -280,84 +305,191 @@ export default function FilesPage() {
)} - {/* File list */} - {loading && items.length === 0 ? ( -
- +
+ {/* File list */} +
+ {loading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ +

{pickAppText(locale, '空文件夹', 'Empty folder')}

+

{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}

+
+ ) : ( + +
+ {items.map((item) => ( + + ))} +
+
+ )}
- ) : items.length === 0 ? ( -
- -

{pickAppText(locale, '空文件夹', 'Empty folder')}

-

{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}

+ + +
+
+ ); +} + +function FilePreviewPanel({ + file, + loading, + error, + formatSize, + formatDate, + downloadUrl, + locale, +}: { + file: WorkspaceFileContent | null; + loading: boolean; + error: string | null; + formatSize: (bytes: number | null) => string; + formatDate: (iso: string) => string; + downloadUrl: string | null; + locale: AppLocale; +}) { + return ( +
+ {loading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : !file ? ( +
+ +

{pickAppText(locale, '点击左侧文件查看内容', 'Click a file to preview its contents')}

) : ( - -
- {items.map((item) => ( -
- {/* Icon */} -
- {item.type === 'directory' ? ( - - ) : ( - - )} -
- - {/* Name - clickable for directories */} -
- {item.type === 'directory' ? ( - - ) : ( -

{item.name}

- )} -

- {item.type === 'file' && formatSize(item.size)} - {item.modified && ( - <> - {item.type === 'file' && ' · '} - {formatDate(item.modified)} - - )} -

-
- - {/* Actions */} -
- {item.type === 'file' && ( - - )} - -
-
- ))} +
+
+
+

{file.name}

+

+ {formatSize(file.size)} · {formatDate(file.modified)} · {file.content_type} + {file.is_truncated ? ` · ${pickAppText(locale, '仅预览前 1MB', 'Showing first 1MB')}` : ''} +

+
+ {downloadUrl && ( + + )}
- + + {isImage(file) && downloadUrl ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {file.name} +
+ ) : file.is_binary ? ( +
+ +

{pickAppText(locale, '该文件不能直接预览', 'This file cannot be previewed')}

+
+ ) : isMarkdown(file) ? ( +
+ {file.content || ''} +
+ ) : ( +
+              {file.content || ''}
+            
+ )} +
)}
); @@ -383,3 +515,11 @@ function FileIcon({ name, contentType }: { name: string; contentType?: string }) } return ; } + +function isImage(file: WorkspaceFileContent): boolean { + return file.content_type.startsWith('image/'); +} + +function isMarkdown(file: WorkspaceFileContent): boolean { + return file.path.toLowerCase().endsWith('.md') || file.content_type.includes('markdown'); +} diff --git a/app-instance/frontend/app/(app)/marketplace/page.tsx b/app-instance/frontend/app/(app)/marketplace/page.tsx index a17adc5..2ef4949 100644 --- a/app-instance/frontend/app/(app)/marketplace/page.tsx +++ b/app-instance/frontend/app/(app)/marketplace/page.tsx @@ -4,8 +4,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { AlertCircle, ArrowLeft, Check, Download, Loader2, Search, Star } from 'lucide-react'; import { + getSkillHubFile, getSkillHubDetail, getSkillHubVersion, + getSkillHubVersions, installSkillHubSkill, searchSkillHubSkills, } from '@/lib/api'; @@ -13,7 +15,8 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; -import type { SkillHubSearchItem, SkillHubVersionResponse } from '@/types'; +import { SkillDetailView } from '@/components/skills/SkillDetailView'; +import type { SkillFileContent, SkillHubSearchItem, SkillHubVersionResponse, SkillVersionRef } from '@/types'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; @@ -23,6 +26,20 @@ function publishedVersion(skill: SkillHubSearchItem | null): string { return skill?.publishedVersion?.version || skill?.headlineVersion?.version || ''; } +function readmeFromVersion(version: SkillHubVersionResponse | null): string { + const raw = version?.detail?.parsedMetadataJson; + if (!raw) return ''; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.body === 'string') { + return parsed.body; + } + } catch { + // keep empty fallback + } + return ''; +} + export default function MarketplacePage() { const { locale } = useAppI18n(); const t = useCallback((zh: string, en: string) => pickAppText(locale, zh, en), [locale]); @@ -36,7 +53,13 @@ export default function MarketplacePage() { const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [versionDetail, setVersionDetail] = useState(null); + const [versions, setVersions] = useState([]); + const [selectedVersion, setSelectedVersion] = useState(''); + const [readmeContent, setReadmeContent] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); const [detailLoading, setDetailLoading] = useState(false); + const [versionLoading, setVersionLoading] = useState(false); + const [fileLoading, setFileLoading] = useState(false); const [installing, setInstalling] = useState(false); const load = useCallback(async () => { @@ -61,14 +84,25 @@ export default function MarketplacePage() { const openDetail = async (item: SkillHubSearchItem) => { setSelected(item); setVersionDetail(null); + setVersions([]); + setSelectedVersion(''); + setReadmeContent(''); + setSelectedFile(null); setDetailLoading(true); setError(null); try { const detail = await getSkillHubDetail(item.namespace, item.slug); setSelected(detail); const version = publishedVersion(detail); + const versionList = await getSkillHubVersions(detail.namespace, detail.slug).catch(() => ({ + items: version ? [{ version, status: detail.publishedVersion?.status || detail.headlineVersion?.status }] : [], + total: version ? 1 : 0, + page: 0, + size: 1, + })); + setVersions(versionList.items || []); if (version) { - setVersionDetail(await getSkillHubVersion(detail.namespace, detail.slug, version)); + await loadVersion(detail, version); } } catch (err: any) { setError(err.message || t('加载技能详情失败', 'Failed to load skill details')); @@ -77,12 +111,51 @@ export default function MarketplacePage() { } }; + const loadVersion = async (skill: SkillHubSearchItem, version: string) => { + setVersionLoading(true); + setSelectedVersion(version); + setSelectedFile(null); + try { + const nextVersion = await getSkillHubVersion(skill.namespace, skill.slug, version); + setVersionDetail(nextVersion); + const readme = await getSkillHubFile(skill.namespace, skill.slug, version, 'SKILL.md') + .then((file) => file.content || '') + .catch(() => readmeFromVersion(nextVersion)); + setReadmeContent(readme); + } finally { + setVersionLoading(false); + } + }; + + const openVersion = async (version: string) => { + if (!selected || selectedVersion === version) return; + setError(null); + try { + await loadVersion(selected, version); + } catch (err: any) { + setError(err.message || t('加载技能版本失败', 'Failed to load skill version')); + } + }; + + const openFile = async (filePath: string) => { + if (!selected || !selectedVersion) return; + setFileLoading(true); + setError(null); + try { + setSelectedFile(await getSkillHubFile(selected.namespace, selected.slug, selectedVersion, filePath)); + } catch (err: any) { + setError(err.message || t('加载文件失败', 'Failed to load file')); + } finally { + setFileLoading(false); + } + }; + const installSelected = async () => { if (!selected) return; setInstalling(true); setError(null); try { - const result = await installSkillHubSkill(selected.namespace, selected.slug, publishedVersion(selected)); + const result = await installSkillHubSkill(selected.namespace, selected.slug, selectedVersion || publishedVersion(selected)); setSelected({ ...selected, installed: true, installed_version: result.version }); await load(); } catch (err: any) { @@ -131,67 +204,71 @@ export default function MarketplacePage() { {selected ? (
- - - -
-
-
- @{selected.namespace} - {selected.installed && ( - - - {t('已安装', 'Installed')} - - )} -
- {selected.displayName || selected.slug} -

{selected.summary}

-
- -
-
- - {detailLoading ? ( -
- -
- ) : ( - <> -
- v{publishedVersion(selected) || '-'} - {t('下载', 'Downloads')}: {selected.downloadCount || 0} - {t('收藏', 'Stars')}: {selected.starCount || 0} -
-
-
-
SKILL.md
-
-                        {versionDetail?.detail?.parsedMetadataJson || t('暂无预览', 'No preview available')}
-                      
-
-
-
{t('版本文件', 'Version files')}
-
- {(versionDetail?.files || []).map((file) => ( -
- {file.filePath} - {file.fileSize} B -
- ))} -
-
-
- - )} -
-
+ } + labels={{ + overview: t('说明', 'Overview'), + files: t('文件', 'Files'), + versions: t('版本', 'Versions'), + noReadme: t('暂无说明', 'No overview available'), + noFiles: t('暂无文件', 'No files'), + selectFile: t('选择一个文件查看详情', 'Select a file to view details'), + binaryFile: t('二进制文件暂不预览', 'Binary file preview is not available'), + current: t('当前', 'Current'), + size: t('大小', 'Size'), + }} + /> + )}
) : (
diff --git a/app-instance/frontend/app/(app)/skills/page.tsx b/app-instance/frontend/app/(app)/skills/page.tsx index 2c839e5..8630c58 100644 --- a/app-instance/frontend/app/(app)/skills/page.tsx +++ b/app-instance/frontend/app/(app)/skills/page.tsx @@ -3,26 +3,40 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { AlertCircle, + BarChart3, Check, + CheckCircle2, + ChevronDown, + ClipboardList, Download, + FileCode2, FileText, + GitCompare, + Info, + ListChecks, Loader2, Puzzle, RefreshCw, Rocket, Send, ShieldCheck, + ShieldAlert, Trash2, Upload, X, XCircle, } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import { approveSkillDraft, deleteSkill, disablePublishedSkill, downloadSkill, + getSkillDetail, + getSkillFile, + getSkillVersion, listSkillCandidates, listSkillDrafts, listSkills, @@ -47,7 +61,16 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import type { Skill, SkillDraft, SkillLearningCandidate } from '@/types'; +import { SkillDetailView } from '@/components/skills/SkillDetailView'; +import type { + Skill, + SkillDetailResponse, + SkillDraft, + SkillDraftEvalReport, + SkillDraftSafetyReport, + SkillFileContent, + SkillLearningCandidate, +} from '@/types'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; @@ -65,6 +88,12 @@ export default function SkillsPage() { const [error, setError] = useState(null); const [showUpload, setShowUpload] = useState(false); const [ignoredCandidates, setIgnoredCandidates] = useState>(new Set()); + const [selectedSkillName, setSelectedSkillName] = useState(null); + const [skillDetail, setSkillDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [versionLoading, setVersionLoading] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [fileLoading, setFileLoading] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -102,9 +131,55 @@ export default function SkillsPage() { } }; - const hiddenCandidateStatuses = new Set(['rejected', 'superseded', 'published']); + const openSkillDetail = async (name: string) => { + setSelectedSkillName(name); + setSkillDetail(null); + setSelectedFile(null); + setDetailLoading(true); + setError(null); + try { + setSkillDetail(await getSkillDetail(name)); + } catch (err: any) { + setError(err.message || t('加载技能详情失败', 'Failed to load skill details')); + setSelectedSkillName(null); + } finally { + setDetailLoading(false); + } + }; + + const openSkillVersion = async (version: string) => { + if (!selectedSkillName || skillDetail?.currentVersion === version) return; + setVersionLoading(true); + setSelectedFile(null); + setError(null); + try { + setSkillDetail(await getSkillVersion(selectedSkillName, version)); + } catch (err: any) { + setError(err.message || t('加载技能版本失败', 'Failed to load skill version')); + } finally { + setVersionLoading(false); + } + }; + + const openSkillFile = async (filePath: string) => { + if (!selectedSkillName || !skillDetail) return; + setFileLoading(true); + setError(null); + try { + setSelectedFile(await getSkillFile(selectedSkillName, skillDetail.currentVersion, filePath)); + } catch (err: any) { + setError(err.message || t('加载文件失败', 'Failed to load file')); + } finally { + setFileLoading(false); + } + }; + + const hiddenCandidateStatuses = new Set(['draft_ready', 'in_review', 'approved', 'rejected', 'superseded', 'published']); const visibleCandidates = candidates.filter( - (candidate) => !ignoredCandidates.has(candidate.candidate_id) && !hiddenCandidateStatuses.has(candidate.status) + (candidate) => + !ignoredCandidates.has(candidate.candidate_id) && + !hiddenCandidateStatuses.has(candidate.status) && + !candidate.draft_id ); const visibleDrafts = drafts.filter((draft) => !TERMINAL_DRAFT_STATUSES.has(draft.status)); @@ -117,7 +192,7 @@ export default function SkillsPage() { } return ( -
+

@@ -166,7 +241,104 @@ export default function SkillsPage() { /> )} - + {selectedSkillName && ( +
+ + {detailLoading || !skillDetail ? ( + + + + + + ) : ( + void openSkillVersion(version)} + onOpenFile={(filePath) => void openSkillFile(filePath)} + badges={ + <> + + {skillDetail.skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')} + + + {skillDetail.skill.available ? t('可用', 'Available') : t('不可用', 'Unavailable')} + + + } + actions={ +
+ + {skillDetail.skill.source === 'workspace' && ( + <> + + + + + )} +
+ } + labels={{ + overview: t('说明', 'Overview'), + files: t('文件', 'Files'), + versions: t('版本', 'Versions'), + noReadme: t('暂无说明', 'No overview available'), + noFiles: t('暂无文件', 'No files'), + selectFile: t('选择一个文件查看详情', 'Select a file to view details'), + binaryFile: t('二进制文件暂不预览', 'Binary file preview is not available'), + current: t('当前', 'Current'), + size: t('大小', 'Size'), + }} + /> + )} +
+ )} + + {!selectedSkillName && ( + {t('已发布', 'Published')} {t('候选', 'Candidates')} @@ -176,19 +348,13 @@ export default function SkillsPage() { void openSkillDetail(name)} onDownload={(name) => downloadSkill(name).catch((err) => setError(err.message))} onDelete={(name) => void runAction(`delete:${name}`, () => deleteSkill(name))} onDisable={(name) => runAction(`disable:${name}`, () => disablePublishedSkill(name, t('人工禁用', 'Manual disable'))) } - onRollback={(name) => { - const target = window.prompt(t('回滚到版本,例如 v0001', 'Rollback target version, for example v0001')); - if (target) { - void runAction(`rollback:${name}`, () => - rollbackPublishedSkill(name, target, t('人工回滚', 'Manual rollback')) - ); - } - }} + onRollback={onRollbackSkill} /> @@ -203,76 +369,22 @@ export default function SkillsPage() { ) : (
{visibleCandidates.map((candidate) => ( -
-
-
-
- {candidate.kind} - {candidate.status} - - {candidate.risk_level || 'medium'} - - {candidate.candidate_id} -
-

{candidate.reason}

- {candidate.evidence_summary && ( -

{candidate.evidence_summary}

- )} -

- {t('来源 runs', 'Source runs')}: {candidate.source_run_ids.join(', ') || '-'} -

- {candidate.related_skill_names.length > 0 && ( -

- {t('关联技能', 'Related skills')}: {candidate.related_skill_names.join(', ')} -

- )} - {candidate.last_error && ( -

{candidate.last_error}

- )} -
-
- - - {candidate.draft_id && ( - - )} -
-
-
+ setIgnoredCandidates((prev) => new Set(prev).add(candidate.candidate_id))} + onSynthesize={() => + runAction(`draft:${candidate.candidate_id}`, () => + synthesizeSkillDraft(candidate.candidate_id) + ) + } + onRegenerate={() => + runAction(`regen:${candidate.candidate_id}`, () => + regenerateSkillDraft(candidate.candidate_id) + ) + } + /> ))}
)} @@ -322,19 +434,35 @@ export default function SkillsPage() { -
+
+ )}

); + + function onRollbackSkill(name: string) { + const target = window.prompt(t('回滚到版本,例如 v0001', 'Rollback target version, for example v0001')); + if (target) { + void runAction(`rollback:${name}`, () => + rollbackPublishedSkill(name, target, t('人工回滚', 'Manual rollback')) + ).then(() => { + if (selectedSkillName === name) { + void openSkillDetail(name); + } + }); + } + } } function PublishedSkillsTable({ skills, + onOpen, onDownload, onDelete, onDisable, onRollback, }: { skills: Skill[]; + onOpen: (name: string) => void; onDownload: (name: string) => void; onDelete: (name: string) => void; onDisable: (name: string) => void; @@ -360,7 +488,11 @@ function PublishedSkillsTable({ {skills.map((skill) => ( - + onOpen(skill.name)} + > {skill.name} @@ -379,27 +511,46 @@ function PublishedSkillsTable({
- - {skill.source === 'workspace' && ( - <> - - - - - )} + {skill.source === 'workspace' && ( + <> + + + + + )}
@@ -412,6 +563,144 @@ function PublishedSkillsTable({ ); } +function CandidateCard({ + candidate, + actionId, + onIgnore, + onSynthesize, + onRegenerate, +}: { + candidate: SkillLearningCandidate; + actionId: string | null; + onIgnore: () => void; + onSynthesize: () => Promise; + onRegenerate: () => Promise; +}) { + const { locale } = useAppI18n(); + const t = (zh: string, en: string) => pickAppText(locale, zh, en); + const evidence = candidate.evidence || {}; + const title = candidateTitle(candidate, t); + const affectedSkills = candidate.draft_skill_name + ? [candidate.draft_skill_name] + : candidate.related_skill_names.length > 0 + ? candidate.related_skill_names + : []; + const sourceRuns = candidate.source_run_ids || []; + const sourceSessions = candidate.source_session_ids || []; + const risk = candidate.risk_level || 'medium'; + const confidence = typeof candidate.confidence === 'number' && candidate.confidence > 0 + ? `${Math.round(candidate.confidence * 100)}%` + : null; + + return ( +
+
+
+
+ {candidateKindLabel(candidate.kind, t)} + {candidateStatusLabel(candidate.status, t)} + + {t('风险', 'Risk')}: {riskLabel(risk, t)} + + {confidence && {t('置信度', 'Confidence')}: {confidence}} + {typeof candidate.priority === 'number' && candidate.priority > 0 && ( + {t('优先级', 'Priority')}: {candidate.priority} + )} +
+ +
+

{title}

+

+ {candidate.reason || t('没有提供候选理由。', 'No candidate reason was provided.')} +

+
+ +
+ } + label={t('候选任务', 'Candidate task')} + value={candidateTaskSummary(candidate, t)} + /> + } + label={t('影响范围', 'Impact')} + value={affectedSkills.length > 0 ? affectedSkills.join(', ') : t('新增技能', 'New skill')} + /> + } + label={t('证据数量', 'Evidence')} + value={t( + `${sourceRuns.length} 个运行,${sourceSessions.length} 个会话`, + `${sourceRuns.length} run(s), ${sourceSessions.length} session(s)` + )} + /> +
+ + {(candidate.evidence_summary || candidate.trigger_reason || candidate.last_error) && ( +
+ {candidate.evidence_summary && ( +

+ {t('证据摘要', 'Evidence summary')}:{' '} + {candidate.evidence_summary} +

+ )} + {candidate.trigger_reason && ( +

+ {t('触发原因', 'Trigger')}:{' '} + {triggerReasonLabel(candidate.trigger_reason, t)} +

+ )} + {candidate.last_error && ( +

+ {t('最近错误', 'Last error')}: {candidate.last_error} +

+ )} +
+ )} + +
+ {candidate.candidate_id} + {String(evidence.task_id || '') && {t('任务', 'Task')}: {String(evidence.task_id)}} + {String(evidence.skill_version || '') && {t('基线版本', 'Base version')}: {String(evidence.skill_version)}} + {candidate.created_at && {t('创建于', 'Created')}: {formatDateTime(candidate.created_at)}} +
+ + +
+ +
+ + + {candidate.draft_id && ( + + )} +
+
+
+ ); +} + function DraftCard({ draft, actionId, @@ -432,6 +721,9 @@ function DraftCard({ const busy = Boolean(actionId); const safety = draft.safety_report; const evalReport = draft.eval_report; + const frontmatter = draft.proposed_frontmatter || {}; + const description = String(frontmatter.description || '').trim(); + const toolHints = normalizeStringList(frontmatter.tools); const publishBlocked = draft.status !== 'approved' || !safety @@ -447,6 +739,11 @@ function DraftCard({ const submitBlocked = draft.status !== 'draft' || safetyBlocksReview; const approveBlocked = draft.status !== 'in_review' || safetyBlocksReview; const rejectBlocked = !REJECTABLE_DRAFT_STATUSES.has(draft.status); + const canPublishLabel = publishBlocked + ? publishBlockReason(draft, t) + : isHighRisk + ? t('高风险草稿,发布前需要再次确认。', 'High-risk draft; publishing requires confirmation.') + : t('已满足发布门禁。', 'Publish gates are satisfied.'); const handlePublish = () => { if (isHighRisk) { const confirmed = window.confirm( @@ -457,34 +754,62 @@ function DraftCard({ void onPublish(isHighRisk); }; return ( -
+
-
+
- {draft.proposal_kind} - {draft.status} - {safety && ( - - {safety.risk_level} - - )} - {evalReport && ( - - {evalReport.status === 'skipped_provider_unavailable' ? t('未评估', 'Eval skipped') : evalReport.passed ? t('评估通过', 'Eval passed') : t('评估失败', 'Eval failed')} - - )} - {draft.skill_name}/{draft.draft_id} + {candidateKindLabel(draft.proposal_kind, t)} + {draftStatusLabel(draft.status, t)} + {safety && ( + + {t('安全', 'Safety')}: {safety.passed ? t('通过', 'Pass') : t('阻断', 'Blocked')} · {riskLabel(safety.risk_level, t)} + + )} + {evalReport && ( + + {evalReport.status === 'skipped_provider_unavailable' + ? t('评估跳过', 'Eval skipped') + : evalReport.passed + ? t('评估通过', 'Eval passed') + : t('评估失败', 'Eval failed')} + + )}
-

{draft.reason || t('无说明', 'No notes')}

-

- {t('base', 'base')}: {draft.base_version || '-'} +

+
{t('技能名', 'Skill name')}
+

{draft.skill_name}

+
+

+ {draft.reason || description || t('没有提供草稿说明。', 'No draft notes were provided.')}

+
+ } + label={t('草稿内容', 'Draft content')} + value={description || t('未填写技能描述', 'No skill description')} + /> + } + label={t('基线版本', 'Base version')} + value={draft.base_version || t('新增技能,无基线', 'New skill, no base')} + /> + } + label={t('来源', 'Source')} + value={draft.trigger_run_id || draft.trigger_session_id || draft.created_by || '-'} + /> +
{isHighRisk && (
{t('高风险理由来自 safety report', 'High-risk reason from safety report')}
{highRiskReason || t('未提供具体理由', 'No concrete reason provided')}
)} +
+ {draft.skill_name}/{draft.draft_id} + {t('创建者', 'Author')}: {draft.created_by} + {t('创建于', 'Created')}: {formatDateTime(draft.created_at)} +
- +
-
-
-
{t('当前版本', 'Current version')}
-
-            {draft.base_version ? `${t('基线版本', 'Base version')}: ${draft.base_version}` : t('无基线版本,视为新增技能', 'No base version, treated as a new skill')}
-          
-
-
-
{t('草稿变更', 'Draft changes')}
-
-            {JSON.stringify(draft.proposed_frontmatter, null, 2)}
-            {'\n\n---\n\n'}
-            {draft.proposed_content}
-          
-
-
-
- - -
-
- ); -} -function ReportBlock({ title, empty, payload }: { title: string; empty: string; payload: unknown }) { - return ( -
-
{title}
- {payload ? ( -
{JSON.stringify(payload, null, 2)}
- ) : ( -

{empty}

- )} +
+
+
+
+ + {t('拟发布的技能正文', 'Proposed skill body')} +
+ {toolHints.length > 0 && ( +
+ {toolHints.map((tool) => ( + + {tool} + + ))} +
+ )} +
+ {draft.proposed_content.trim() ? ( + + ) : ( +

{t('草稿没有正文内容。', 'This draft has no body content.')}

+ )} +
+ +
+ + +
+
+ +
+ + +
); } +function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null }) { + const { locale } = useAppI18n(); + const t = (zh: string, en: string) => pickAppText(locale, zh, en); + if (!report) { + return ( + } + title={t('安全报告', 'Safety report')} + empty={t('还没有安全报告,不能送审或发布。', 'No safety report yet; review and publishing are blocked.')} + /> + ); + } + const problems = [...(report.blocked_reasons || []), ...(report.issues || [])]; + return ( +
+
+
+ {report.passed ? : } + {t('安全报告', 'Safety report')} +
+ + {report.passed ? t('通过', 'Pass') : t('阻断', 'Blocked')} · {riskLabel(report.risk_level, t)} + +
+ {problems.length > 0 ? ( +
+

{t('发现的问题', 'Findings')}

+
    + {problems.map((item, index) => ( +
  • + + {item} +
  • + ))} +
+
+ ) : ( +

+ {t('没有发现阻断项或需要人工注意的问题。', 'No blockers or manual-review issues were found.')} +

+ )} + {report.suggested_fix && ( +

+ {t('建议处理', 'Suggested fix')}: {report.suggested_fix} +

+ )} +
{formatDateTime(report.created_at)}
+ +
+ ); +} + +function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) { + const { locale } = useAppI18n(); + const t = (zh: string, en: string) => pickAppText(locale, zh, en); + if (!report) { + return ( + } + title={t('评估报告', 'Eval report')} + empty={t('还没有评估报告。若 provider 不可用,系统会标记为跳过。', 'No eval report yet. If the provider is unavailable, it will be marked as skipped.')} + /> + ); + } + if (report.status === 'skipped_provider_unavailable') { + return ( +
+
+ + {t('评估报告', 'Eval report')} +
+

+ {t('评估被跳过:当前没有可用 provider。这个状态不会阻断发布,但代表没有回放验证。', 'Eval was skipped because no provider was available. This does not block publishing, but no replay validation was run.')} +

+ +
+ ); + } + return ( +
+
+
+ + {t('评估报告', 'Eval report')} +
+ + {report.passed ? t('通过', 'Pass') : t('失败', 'Failed')} + +
+ +
+ + + = 0 ? '+' : ''}${formatScore(report.score_delta)}`} + tone={report.score_delta < 0 ? 'bad' : report.score_delta > 0 ? 'good' : 'neutral'} + /> +
+ +
+ } label={t('改进', 'Improved')} value={String(report.improved_count)} /> + } label={t('回退', 'Regressed')} value={String(report.regression_count)} /> + } label={t('不变', 'Unchanged')} value={String(report.unchanged_count)} /> +
+ + {report.cases.length > 0 && ( +
+
+ {t('回放案例', 'Replay cases')} +
+
+ + + + + + + + + + + {report.cases.map((item, index) => ( + + + + + + + ))} + +
{t('运行', 'Run')}{t('基线', 'Baseline')}{t('候选', 'Candidate')}{t('变化', 'Delta')}
{String(item.run_id || '-')}{formatScore(toNumber(item.baseline_score))}{formatScore(toNumber(item.candidate_score))}{formatSignedScore(toNumber(item.delta))}
+
+
+ )} +
{formatDateTime(report.created_at)}
+ +
+ ); +} + +function GateSummary({ + title, + summary, + items, +}: { + title: string; + summary: string; + items: Array<{ label: string; ok: boolean }>; +}) { + return ( +
+
+ + {title} +
+

{summary}

+
+ {items.map((item) => ( +
+ {item.ok ? : } + {item.label} +
+ ))} +
+
+ ); +} + +function ReadablePanel({ + icon, + title, + empty, +}: { + icon: React.ReactNode; + title: string; + empty: string; +}) { + return ( +
+
+ {icon} + {title} +
+

{empty}

+
+ ); +} + +function ReadableFact({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( +
+
+ {icon} + {label} +
+
{value || '-'}
+
+ ); +} + +function MetricTile({ + label, + value, + tone = 'neutral', +}: { + label: string; + value: string; + tone?: 'neutral' | 'good' | 'bad'; +}) { + const toneClass = tone === 'bad' ? 'text-destructive' : 'text-foreground'; + return ( +
+
{label}
+
{value}
+
+ ); +} + +function RawDetails({ title, payload }: { title: string; payload: unknown }) { + return ( +
+ + {title} + + +
+        {JSON.stringify(payload, null, 2)}
+      
+
+ ); +} + +function MarkdownPreview({ content }: { content: string }) { + return ( +
+ {content} +
+ ); +} + +function candidateTitle(candidate: SkillLearningCandidate, t: (zh: string, en: string) => string): string { + const evidence = candidate.evidence || {}; + const theme = String(evidence.theme || '').trim(); + const taskId = String(evidence.task_id || '').trim(); + const related = candidate.related_skill_names.filter(Boolean).join(', '); + if (candidate.draft_skill_name) { + return t(`为 ${candidate.draft_skill_name} 生成草稿`, `Draft for ${candidate.draft_skill_name}`); + } + if (candidate.kind === 'new_skill') { + return theme + ? t(`把“${theme}”沉淀成新技能`, `Extract "${theme}" into a new skill`) + : taskId + ? t(`从任务 ${taskId} 提炼新技能`, `Extract a new skill from task ${taskId}`) + : t('提炼一个新技能', 'Extract a new skill'); + } + if (candidate.kind === 'revise_skill') { + return related + ? t(`修订技能 ${related}`, `Revise skill ${related}`) + : t('修订已有技能', 'Revise an existing skill'); + } + if (candidate.kind === 'merge_skills') { + return related + ? t(`合并技能 ${related}`, `Merge skills ${related}`) + : t('合并相似技能', 'Merge similar skills'); + } + if (candidate.kind === 'retire_skill') { + return related + ? t(`考虑下线技能 ${related}`, `Consider retiring ${related}`) + : t('考虑下线技能', 'Consider retiring a skill'); + } + return candidate.reason || candidate.candidate_id; +} + +function candidateTaskSummary(candidate: SkillLearningCandidate, t: (zh: string, en: string) => string): string { + const evidence = candidate.evidence || {}; + const taskId = String(evidence.task_id || '').trim(); + const theme = String(evidence.theme || '').trim(); + const triggerRun = String(evidence.trigger_run_id || '').trim(); + if (taskId) return t(`任务 ${taskId}`, `Task ${taskId}`); + if (theme) return t(`主题:${theme}`, `Theme: ${theme}`); + if (triggerRun) return t(`由运行 ${triggerRun} 触发`, `Triggered by run ${triggerRun}`); + const firstRun = candidate.source_run_ids?.[0]; + if (firstRun) return t(`来自 ${candidate.source_run_ids.length} 个运行`, `From ${candidate.source_run_ids.length} run(s)`); + return t('系统学习候选', 'System learning candidate'); +} + +function candidateKindLabel(kind: string, t: (zh: string, en: string) => string): string { + const labels: Record = { + new_skill: t('新增技能', 'New skill'), + revise_skill: t('修订技能', 'Revise skill'), + merge_skills: t('合并技能', 'Merge skills'), + retire_skill: t('下线技能', 'Retire skill'), + }; + return labels[kind] || kind; +} + +function candidateStatusLabel(status: string, t: (zh: string, en: string) => string): string { + const labels: Record = { + open: t('待处理', 'Open'), + queued: t('排队中', 'Queued'), + synthesizing: t('生成中', 'Synthesizing'), + draft_ready: t('草稿已就绪', 'Draft ready'), + safety_failed: t('安全未通过', 'Safety failed'), + eval_failed: t('评估未通过', 'Eval failed'), + review_pending: t('等待评审', 'Review pending'), + approved: t('已批准', 'Approved'), + rejected: t('已拒绝', 'Rejected'), + published: t('已发布', 'Published'), + failed: t('失败', 'Failed'), + superseded: t('已被替代', 'Superseded'), + }; + return labels[status] || status; +} + +function draftStatusLabel(status: string, t: (zh: string, en: string) => string): string { + const labels: Record = { + draft: t('草稿', 'Draft'), + in_review: t('评审中', 'In review'), + approved: t('已批准', 'Approved'), + rejected: t('已拒绝', 'Rejected'), + published: t('已发布', 'Published'), + disabled: t('已禁用', 'Disabled'), + archived: t('已归档', 'Archived'), + }; + return labels[status] || candidateStatusLabel(status, t); +} + +function riskLabel(risk: string, t: (zh: string, en: string) => string): string { + const labels: Record = { + low: t('低', 'Low'), + medium: t('中', 'Medium'), + high: t('高', 'High'), + critical: t('严重', 'Critical'), + }; + return labels[risk] || risk; +} + +function triggerReasonLabel(reason: string, t: (zh: string, en: string) => string): string { + const labels: Record = { + validation_accepted_and_user_satisfied: t('任务验证通过且用户满意', 'Validation accepted and user satisfied'), + }; + return labels[reason] || reason; +} + +function publishBlockReason(draft: SkillDraft, t: (zh: string, en: string) => string): string { + if (draft.status !== 'approved') return t('草稿还没有批准,不能发布。', 'The draft is not approved yet.'); + if (!draft.safety_report) return t('缺少安全报告,不能发布。', 'A safety report is required before publishing.'); + if (draft.safety_report.risk_level === 'critical' || !draft.safety_report.passed) { + return t('安全报告存在阻断项,不能发布。', 'The safety report has blockers.'); + } + if (draft.eval_report?.status !== 'skipped_provider_unavailable' && draft.eval_report?.passed === false) { + return t('评估报告显示回退或未达标,不能发布。', 'The eval report shows a regression or failed score.'); + } + return t('当前状态不能发布。', 'The current state cannot be published.'); +} + +function normalizeStringList(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map((item) => String(item).trim()).filter(Boolean); + } + if (typeof value === 'string') { + return value.split(',').map((item) => item.trim()).filter(Boolean); + } + return []; +} + +function formatDateTime(value?: string | null): string { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +function formatScore(value: number): string { + if (!Number.isFinite(value)) return '-'; + return value.toFixed(2); +} + +function formatSignedScore(value: number): string { + if (!Number.isFinite(value)) return '-'; + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}`; +} + +function toNumber(value: unknown): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) { return (
diff --git a/app-instance/frontend/app/(app)/status/page.tsx b/app-instance/frontend/app/(app)/status/page.tsx index c924567..abada94 100644 --- a/app-instance/frontend/app/(app)/status/page.tsx +++ b/app-instance/frontend/app/(app)/status/page.tsx @@ -191,15 +191,6 @@ export default function StatusPage() { if (!status) return null; - const settingsLinks = [ - { - href: '/logs', - icon: ScrollText, - title: pickAppText(locale, '运行日志', 'Runtime Logs'), - description: pickAppText(locale, '查看每次对话和后台任务的运行日志。', 'Inspect chat and background runtime logs.'), - }, - ]; - return (
@@ -219,27 +210,6 @@ export default function StatusPage() {
-
- {settingsLinks.map((item) => { - const Icon = item.icon; - return ( - - - - - - {item.title} - {item.description} - - - ); - })} -
- {/* System Info */} diff --git a/app-instance/frontend/components/Header.tsx b/app-instance/frontend/components/Header.tsx index c6f37dc..afccc51 100644 --- a/app-instance/frontend/components/Header.tsx +++ b/app-instance/frontend/components/Header.tsx @@ -3,7 +3,7 @@ import React from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; -import { Bell, Bot, ChevronDown, ListTodo, LogOut, Mail, MessageSquare, PackageOpen, Puzzle, Settings, Store, Wrench } from 'lucide-react'; +import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, MessageSquare, PackageOpen, Puzzle, Settings, Store, Wrench } from 'lucide-react'; import { logout } from '@/lib/api'; import { LanguageSwitcher } from '@/components/LanguageSwitcher'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; @@ -15,7 +15,7 @@ import { useAppI18n } from '@/lib/i18n/provider'; import { useChatStore } from '@/lib/store'; type NavItem = { - key: 'chat' | 'tasks' | 'notifications' | 'skills' | 'tools' | 'agents' | 'outlook' | 'marketplace' | 'plugins' | 'settings'; + key: 'chat' | 'tasks' | 'notifications' | 'skills' | 'files' | 'tools' | 'agents' | 'outlook' | 'marketplace' | 'plugins' | 'settings'; href: string; icon: React.ComponentType<{ className?: string }>; matchPrefixes?: string[]; @@ -26,6 +26,7 @@ const NAV_ITEMS: NavItem[] = [ { key: 'tasks', href: '/tasks', icon: ListTodo, matchPrefixes: ['/tasks', '/office', '/cron'] }, { key: 'notifications', href: '/notifications', icon: Bell, matchPrefixes: ['/notifications'] }, { key: 'skills', href: '/skills', icon: Puzzle }, + { key: 'files', href: '/files', icon: FolderOpen, matchPrefixes: ['/files'] }, { key: 'tools', href: '/mcp', icon: Wrench, matchPrefixes: ['/mcp'] }, { key: 'agents', href: '/agents', icon: Bot, matchPrefixes: ['/agents'] }, { key: 'outlook', href: '/outlook', icon: Mail, matchPrefixes: ['/outlook'] }, @@ -78,6 +79,7 @@ const Header = () => { if (key === 'tasks') return 'Task'; if (key === 'notifications') return pickAppText(locale, '通知', 'Notifications'); if (key === 'skills') return pickAppText(locale, '技能', 'Skills'); + if (key === 'files') return pickAppText(locale, '文件', 'Files'); if (key === 'tools') return pickAppText(locale, '工具', 'Tools'); if (key === 'agents') return pickAppText(locale, '智能体', 'Agents'); if (key === 'outlook') return 'Outlook'; diff --git a/app-instance/frontend/components/skills/SkillDetailView.tsx b/app-instance/frontend/components/skills/SkillDetailView.tsx new file mode 100644 index 0000000..42737ca --- /dev/null +++ b/app-instance/frontend/components/skills/SkillDetailView.tsx @@ -0,0 +1,222 @@ +'use client'; + +import React from 'react'; +import { FileText, GitBranch, Loader2 } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import type { SkillFileContent, SkillFileInfo, SkillVersionRef } from '@/types'; + +type SkillDetailViewProps = { + title: string; + summary?: string | null; + badges?: React.ReactNode; + actions?: React.ReactNode; + currentVersion: string; + versions: SkillVersionRef[]; + files: SkillFileInfo[]; + content: string; + selectedFile?: SkillFileContent | null; + loadingFile?: boolean; + loadingVersion?: boolean; + onSelectVersion: (version: string) => void; + onOpenFile: (filePath: string) => void; + labels: { + overview: string; + files: string; + versions: string; + noReadme: string; + noFiles: string; + selectFile: string; + binaryFile: string; + current: string; + size: string; + }; +}; + +export function SkillDetailView({ + title, + summary, + badges, + actions, + currentVersion, + versions, + files, + content, + selectedFile, + loadingFile, + loadingVersion, + onSelectVersion, + onOpenFile, + labels, +}: SkillDetailViewProps) { + const readme = stripFrontmatter(content || ''); + return ( +
+
+
+
+
+ {badges} + v{currentVersion || '-'} + {loadingVersion && } +
+

{title}

+ {summary &&

{summary}

} +
+ {actions} +
+
+ + + + {labels.overview} + {labels.files} + {labels.versions} + + + + {readme.trim() ? ( + + ) : ( + } text={labels.noReadme} /> + )} + + + +
+
+ {files.length === 0 ? ( + } text={labels.noFiles} /> + ) : ( +
+ {files.map((file) => ( + + ))} +
+ )} +
+ +
+ {loadingFile ? ( +
+ +
+ ) : selectedFile ? ( + + ) : ( + } text={labels.selectFile} /> + )} +
+
+
+ + +
+ {versions.map((version) => ( + + ))} +
+
+
+
+ ); +} + +function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDetailViewProps['labels'] }) { + const content = file.content || ''; + return ( +
+
+
{file.filePath}
+ + {labels.size}: {formatBytes(file.fileSize)} + +
+ {file.isBinary ? ( + } text={labels.binaryFile} /> + ) : isMarkdown(file.filePath, file.contentType) ? ( + + ) : ( +
+          {content}
+        
+ )} +
+ ); +} + +function MarkdownPreview({ content }: { content: string }) { + return ( +
+ {content} +
+ ); +} + +function EmptyPanel({ icon, text }: { icon: React.ReactNode; text: string }) { + return ( +
+
{icon}
+

{text}

+
+ ); +} + +function stripFrontmatter(value: string): string { + if (!value.startsWith('---')) return value; + const marker = value.indexOf('\n---', 3); + if (marker < 0) return value; + const after = value.indexOf('\n', marker + 4); + return after >= 0 ? value.slice(after + 1) : ''; +} + +function isMarkdown(filePath: string, contentType?: string | null): boolean { + return filePath.toLowerCase().endsWith('.md') || (contentType || '').includes('markdown'); +} + +function formatBytes(value: number | undefined): string { + const size = Number(value || 0); + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +} diff --git a/app-instance/frontend/lib/api.ts b/app-instance/frontend/lib/api.ts index a8db246..7eef5d0 100644 --- a/app-instance/frontend/lib/api.ts +++ b/app-instance/frontend/lib/api.ts @@ -21,13 +21,16 @@ import type { Session, SessionDetail, Skill, + SkillDetailResponse, SkillDraft, SkillDraftEvalReport, SkillDraftSafetyReport, + SkillFileContent, SkillHubInstallResponse, SkillHubSearchItem, SkillHubSearchResponse, SkillHubVersionResponse, + SkillHubVersionsResponse, SkillLearningCandidate, SkillReviewRecord, SlashCommand, @@ -56,6 +59,7 @@ const ACCESS_TOKEN_KEY = 'nanobot_access_token'; const REFRESH_TOKEN_KEY = 'nanobot_refresh_token'; const REQUEST_TIMEOUT_MS = 8000; const OUTLOOK_REQUEST_TIMEOUT_MS = 45000; +const SKILL_LEARNING_REQUEST_TIMEOUT_MS = 120000; function isBrowser(): boolean { return typeof window !== 'undefined'; @@ -728,6 +732,21 @@ export async function listSkills(): Promise { return fetchJSON('/api/skills'); } +export async function getSkillDetail(skillName: string): Promise { + return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/detail`); +} + +export async function getSkillVersion(skillName: string, version: string): Promise { + return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/versions/${encodeURIComponent(version)}`); +} + +export async function getSkillFile(skillName: string, version: string, filePath: string): Promise { + const search = new URLSearchParams({ path: filePath }); + return fetchJSON( + `/api/skills/${encodeURIComponent(skillName)}/versions/${encodeURIComponent(version)}/file?${search.toString()}` + ); +} + export async function listSkillCandidates(status?: string): Promise { const query = status ? `?status=${encodeURIComponent(status)}` : ''; return fetchJSON(`/api/skills/candidates${query}`); @@ -737,6 +756,7 @@ export async function synthesizeSkillDraft(candidateId: string): Promise { + return fetchJSON( + `/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}/versions` + ); +} + +export async function getSkillHubFile( + namespace: string, + slug: string, + version: string, + filePath: string +): Promise { + const search = new URLSearchParams({ path: filePath }); + return fetchJSON( + `/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?${search.toString()}` + ); +} + export async function installSkillHubSkill( namespace: string, slug: string, @@ -1441,11 +1481,26 @@ export interface BrowseResult { items: WorkspaceItem[]; } +export interface WorkspaceFileContent { + name: string; + path: string; + size: number; + content_type: string; + modified: string; + is_binary: boolean; + is_truncated: boolean; + content: string | null; +} + export async function browseWorkspace(path: string = ''): Promise { const params = path ? `?path=${encodeURIComponent(path)}` : ''; return fetchJSON(`/api/workspace/browse${params}`); } +export async function getWorkspaceFile(path: string): Promise { + return fetchJSON(`/api/workspace/file?path=${encodeURIComponent(path)}`); +} + export function getWorkspaceDownloadUrl(path: string): string { return buildApiUrl(`/api/workspace/download?path=${encodeURIComponent(path)}`); } diff --git a/app-instance/frontend/types/index.ts b/app-instance/frontend/types/index.ts index 87a6719..bd68c4a 100644 --- a/app-instance/frontend/types/index.ts +++ b/app-instance/frontend/types/index.ts @@ -178,9 +178,50 @@ export interface Skill { source: 'builtin' | 'workspace'; available: boolean; path: string; + version?: string; + status?: string; + source_kind?: string; + tool_hints?: string[]; + provenance?: Record; agent_cards?: Record[]; } +export interface SkillVersionRef { + version: string; + status?: string | null; + createdAt?: string | null; + publishedAt?: string | null; + changeReason?: string | null; + parentVersion?: string | null; + contentHash?: string | null; + fileCount?: number; + totalSize?: number; +} + +export interface SkillFileInfo { + id?: number; + filePath: string; + fileSize: number; + contentType?: string | null; + sha256?: string | null; +} + +export interface SkillFileContent extends SkillFileInfo { + content?: string | null; + isBinary?: boolean; +} + +export interface SkillDetailResponse { + skill: Skill; + spec?: Record | null; + currentVersion: string; + versions: SkillVersionRef[]; + versionDetail?: Record | null; + files: SkillFileInfo[]; + content: string; + frontmatter?: Record; +} + export interface SlashCommand { name: string; description: string; @@ -385,6 +426,13 @@ export interface SkillHubSearchResponse { size: number; } +export interface SkillHubVersionsResponse { + items: SkillVersionRef[]; + total: number; + page: number; + size: number; +} + export interface SkillHubFileInfo { id?: number; filePath: string; diff --git a/app-instance/instance-registry.py b/app-instance/instance-registry.py index b4f85ec..4d6539e 100755 --- a/app-instance/instance-registry.py +++ b/app-instance/instance-registry.py @@ -28,7 +28,7 @@ def _normalize_record(record: dict[str, Any]) -> dict[str, Any]: "image_name", "public_url", "instance_root", - "nanobot_home", + "beaver_home", "config_path", "auth_users_path", "network_name", @@ -43,6 +43,8 @@ def _normalize_record(record: dict[str, Any]) -> dict[str, Any]: "api_base_url", ): normalized[key] = str(record.get(key, "") or "") + if not normalized["beaver_home"]: + normalized["beaver_home"] = str(record.get("nanobot_home", "") or "") return normalized @@ -169,7 +171,7 @@ def cmd_upsert(args: argparse.Namespace) -> int: "host_port": int(args.host_port), "public_url": args.public_url, "instance_root": args.instance_root, - "nanobot_home": args.nanobot_home, + "beaver_home": args.beaver_home, "config_path": args.config_path, "auth_users_path": args.auth_users_path, "network_name": args.network_name or "", @@ -285,7 +287,7 @@ def build_parser() -> argparse.ArgumentParser: upsert_parser.add_argument("--host-port", required=True, type=int) upsert_parser.add_argument("--public-url", required=True) upsert_parser.add_argument("--instance-root", required=True) - upsert_parser.add_argument("--nanobot-home", required=True) + upsert_parser.add_argument("--beaver-home", required=True) upsert_parser.add_argument("--config-path", required=True) upsert_parser.add_argument("--auth-users-path", required=True) upsert_parser.add_argument("--network-name", default="") diff --git a/deploy-control/server.py b/deploy-control/server.py index c66cbf1..c91278d 100755 --- a/deploy-control/server.py +++ b/deploy-control/server.py @@ -306,8 +306,8 @@ def _upsert_registry_record(record: dict[str, Any]) -> dict[str, Any]: str(record.get("public_url", "") or "").strip(), "--instance-root", str(record.get("instance_root", "") or "").strip(), - "--nanobot-home", - str(record.get("nanobot_home", "") or "").strip(), + "--beaver-home", + str(record.get("beaver_home", "") or record.get("nanobot_home", "") or "").strip(), "--config-path", str(record.get("config_path", "") or "").strip(), "--auth-users-path",