feat(outlook): 添加Outlook集成功能支持

添加完整的Outlook MCP集成,包括邮件和日历功能,通过AuthZ模式进行认证和权限管理,
支持邮箱连接、断开、状态检查和数据同步等功能。

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

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

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

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

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

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

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

为AuthzClient添加设置权限、用户注册、后端注册和Outlook设置管理等新方法,增强系统
的认证和授权能力。
This commit is contained in:
2026-05-14 16:01:46 +08:00
parent 30ab74ffb2
commit ebfa242862
35 changed files with 3979 additions and 462 deletions

View File

@ -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`
当前版本里,新实例的默认大模型配置就是从这里分发的:

View File

@ -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

View File

@ -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,
*,

View File

@ -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 `<workspace>/agents/<id>_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

View File

@ -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:

View File

@ -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

View File

@ -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 {}

View File

@ -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"],
)

File diff suppressed because it is too large Load Diff

View File

@ -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/<file_id>/."""
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)

View File

@ -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)

View File

@ -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"

View File

@ -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`.

View File

@ -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"}

View File

@ -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:

View File

@ -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:

View File

@ -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"

View File

@ -18,7 +18,10 @@
└─ future channels未来扩展入口
└─ AgentService统一服务层所有入口都先汇总到这里
├─ MainAgentRouterLLM 语义判断 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
│ │

View File

@ -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"

View File

@ -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(

View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -245,7 +245,7 @@ docker build \
当前仓库的部分技术标识仍沿用旧命名,例如:
- `nanobot web`
- `~/.nanobot/plugins/`
- `~/.beaver/plugins/`
- 本地存储中的旧 token key
这些属于兼容性和后端约定的一部分,前端展示品牌已替换为 `Boardware Genius`,但技术标识没有在这个仓库里强制迁移。

View File

@ -721,7 +721,7 @@ export default function AgentsPage() {
<Card className="border-border/70 bg-muted/20">
<CardContent className="pt-6 text-sm text-muted-foreground leading-relaxed">
{t('持久化 Sub-Agent 会在', 'Persistent sub-agents keep their own workspace under')}
<code className="mx-1">~/.nanobot/workspace/agents/&lt;id&gt;_agent</code>
<code className="mx-1">~/.beaver/workspace/agents/&lt;id&gt;_agent</code>
{t('下拥有自己的 workspace、`AGENTS.json`、`AGENTS.md`、skills 和 memory。默认委派模式是', ', plus `AGENTS.json`, `AGENTS.md`, skills, and memory. The default delegation mode is')}
<code className="mx-1">remote_a2a_only</code>
{t(',即只能向外委派到远端 A2A agent。', ', which only allows delegation to remote A2A agents.')}

View File

@ -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<WorkspaceFileContent | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const mkdirInputRef = useRef<HTMLInputElement>(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 (
<div className="max-w-4xl mx-auto p-6">
<div className="mx-auto max-w-7xl p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">{pickAppText(locale, '文件管理', 'Files')}</h1>
@ -280,84 +305,191 @@ export default function FilesPage() {
</div>
)}
{/* File list */}
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin" />
<div className="grid gap-4 lg:grid-cols-[minmax(360px,440px)_minmax(0,1fr)]">
{/* File list */}
<div className="min-h-[520px] rounded-lg border border-border bg-card">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">{pickAppText(locale, '空文件夹', 'Empty folder')}</p>
<p className="text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
</div>
) : (
<ScrollArea className="h-[calc(100vh-15rem)] min-h-[520px]">
<div className="space-y-1 p-2">
{items.map((item) => (
<button
key={item.path}
type="button"
className={`group flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors hover:bg-accent/30 ${
selectedFile?.path === item.path ? 'border-primary bg-accent/40' : 'border-border bg-card'
}`}
onClick={() => {
if (item.type === 'directory') {
navigateTo(item.path);
} else {
void openFile(item);
}
}}
>
<div className="flex-shrink-0">
{item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" />
) : (
<FileIcon name={item.name} contentType={item.content_type} />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{item.name}</div>
<p className="text-xs text-muted-foreground">
{item.type === 'file' && formatSize(item.size)}
{item.modified && (
<>
{item.type === 'file' && ' · '}
{formatDate(item.modified)}
</>
)}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{item.type === 'file' && (
<span
role="button"
tabIndex={0}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent"
onClick={(event) => {
event.stopPropagation();
void handleDownload(item);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
void handleDownload(item);
}
}}
title={pickAppText(locale, '下载', 'Download')}
>
<Download className="w-4 h-4" />
</span>
)}
<span
role="button"
tabIndex={0}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-destructive hover:bg-accent hover:text-destructive"
onClick={(event) => {
event.stopPropagation();
void handleDelete(item);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
void handleDelete(item);
}
}}
title={pickAppText(locale, '删除', 'Delete')}
>
<Trash2 className="w-4 h-4" />
</span>
</div>
</button>
))}
</div>
</ScrollArea>
)}
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">{pickAppText(locale, '空文件夹', 'Empty folder')}</p>
<p className="text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
<FilePreviewPanel
file={selectedFile}
loading={previewLoading}
error={previewError}
formatSize={formatSize}
formatDate={formatDate}
downloadUrl={selectedFile ? getWorkspaceDownloadUrl(selectedFile.path) : null}
locale={locale}
/>
</div>
</div>
);
}
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 (
<div className="min-h-[520px] rounded-lg border border-border bg-card p-4">
{loading ? (
<div className="flex h-[420px] items-center justify-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : error ? (
<div className="flex h-[420px] items-center justify-center text-center text-sm text-destructive">{error}</div>
) : !file ? (
<div className="flex h-[420px] flex-col items-center justify-center text-center text-muted-foreground">
<FileText className="mb-3 h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{pickAppText(locale, '点击左侧文件查看内容', 'Click a file to preview its contents')}</p>
</div>
) : (
<ScrollArea className="h-[calc(100vh-14rem)]">
<div className="space-y-1">
{items.map((item) => (
<div
key={item.path}
className="flex items-center gap-3 px-4 py-2.5 rounded-lg border border-border bg-card hover:bg-accent/30 transition-colors group"
>
{/* Icon */}
<div className="flex-shrink-0">
{item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" />
) : (
<FileIcon name={item.name} contentType={item.content_type} />
)}
</div>
{/* Name - clickable for directories */}
<div className="flex-1 min-w-0">
{item.type === 'directory' ? (
<button
onClick={() => navigateTo(item.path)}
className="text-sm font-medium truncate hover:underline text-left block w-full"
>
{item.name}
</button>
) : (
<p className="text-sm font-medium truncate">{item.name}</p>
)}
<p className="text-xs text-muted-foreground">
{item.type === 'file' && formatSize(item.size)}
{item.modified && (
<>
{item.type === 'file' && ' · '}
{formatDate(item.modified)}
</>
)}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{item.type === 'file' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleDownload(item)}
title={pickAppText(locale, '下载', 'Download')}
>
<Download className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(item)}
title={pickAppText(locale, '删除', 'Delete')}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
<div className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0">
<h2 className="break-all text-base font-semibold">{file.name}</h2>
<p className="mt-1 text-xs text-muted-foreground">
{formatSize(file.size)} · {formatDate(file.modified)} · {file.content_type}
{file.is_truncated ? ` · ${pickAppText(locale, '仅预览前 1MB', 'Showing first 1MB')}` : ''}
</p>
</div>
{downloadUrl && (
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl}>
<Download className="mr-2 h-4 w-4" />
{pickAppText(locale, '下载', 'Download')}
</a>
</Button>
)}
</div>
</ScrollArea>
{isImage(file) && downloadUrl ? (
<div className="flex max-h-[620px] items-start justify-center overflow-auto rounded-md border border-border bg-muted/20 p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={downloadUrl} alt={file.name} className="max-h-[580px] max-w-full object-contain" />
</div>
) : file.is_binary ? (
<div className="flex h-[360px] flex-col items-center justify-center text-center text-muted-foreground">
<FileArchive className="mb-3 h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{pickAppText(locale, '该文件不能直接预览', 'This file cannot be previewed')}</p>
</div>
) : isMarkdown(file) ? (
<div className="prose prose-sm max-h-[620px] max-w-none overflow-auto text-black prose-a:text-black prose-code:text-black prose-headings:text-black prose-li:text-black prose-p:text-black prose-pre:bg-muted prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{file.content || ''}</ReactMarkdown>
</div>
) : (
<pre className="max-h-[620px] overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background p-4 text-xs leading-5 text-black">
{file.content || ''}
</pre>
)}
</div>
)}
</div>
);
@ -383,3 +515,11 @@ function FileIcon({ name, contentType }: { name: string; contentType?: string })
}
return <FileText className="w-5 h-5 text-muted-foreground" />;
}
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');
}

View File

@ -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<string | null>(null);
const [selected, setSelected] = useState<SkillHubSearchItem | null>(null);
const [versionDetail, setVersionDetail] = useState<SkillHubVersionResponse | null>(null);
const [versions, setVersions] = useState<SkillVersionRef[]>([]);
const [selectedVersion, setSelectedVersion] = useState('');
const [readmeContent, setReadmeContent] = useState('');
const [selectedFile, setSelectedFile] = useState<SkillFileContent | null>(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 ? (
<div className="space-y-5">
<Button variant="ghost" onClick={() => setSelected(null)}>
<Button
variant="ghost"
onClick={() => {
setSelected(null);
setVersionDetail(null);
setVersions([]);
setSelectedVersion('');
setReadmeContent('');
setSelectedFile(null);
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('返回搜索', 'Back to search')}
</Button>
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="mb-2 flex items-center gap-2">
<Badge variant="outline">@{selected.namespace}</Badge>
{selected.installed && (
<Badge variant="secondary" className="gap-1">
<Check className="h-3 w-3" />
{t('已安装', 'Installed')}
</Badge>
)}
</div>
<CardTitle className="text-2xl">{selected.displayName || selected.slug}</CardTitle>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">{selected.summary}</p>
</div>
<Button onClick={installSelected} disabled={installing || detailLoading}>
{detailLoading ? (
<Card>
<CardContent className="flex justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : (
<SkillDetailView
title={selected.displayName || selected.slug}
summary={selected.summary}
currentVersion={selectedVersion || publishedVersion(selected)}
versions={versions.length > 0 ? versions : [{ version: selectedVersion || publishedVersion(selected) }]}
files={versionDetail?.files || []}
content={readmeContent || readmeFromVersion(versionDetail)}
selectedFile={selectedFile}
loadingFile={fileLoading}
loadingVersion={versionLoading}
onSelectVersion={(version) => void openVersion(version)}
onOpenFile={(filePath) => void openFile(filePath)}
badges={
<>
<Badge variant="outline">@{selected.namespace}</Badge>
<Badge variant="outline">{t('下载', 'Downloads')}: {selected.downloadCount || 0}</Badge>
<Badge variant="outline">{t('收藏', 'Stars')}: {selected.starCount || 0}</Badge>
{selected.installed && (
<Badge variant="secondary" className="gap-1">
<Check className="h-3 w-3" />
{t('已安装', 'Installed')}
</Badge>
)}
</>
}
actions={
<Button onClick={installSelected} disabled={installing || detailLoading || versionLoading}>
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
{selected.installed ? t('重新安装/更新', 'Reinstall/update') : t('安装', 'Install')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{detailLoading ? (
<div className="flex justify-center py-10">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<Badge variant="outline">v{publishedVersion(selected) || '-'}</Badge>
<span>{t('下载', 'Downloads')}: {selected.downloadCount || 0}</span>
<span>{t('收藏', 'Stars')}: {selected.starCount || 0}</span>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<div className="rounded-lg border border-border bg-muted/20 p-4">
<div className="mb-2 text-sm font-medium">SKILL.md</div>
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap text-xs">
{versionDetail?.detail?.parsedMetadataJson || t('暂无预览', 'No preview available')}
</pre>
</div>
<div className="rounded-lg border border-border bg-muted/20 p-4">
<div className="mb-3 text-sm font-medium">{t('版本文件', 'Version files')}</div>
<div className="space-y-2">
{(versionDetail?.files || []).map((file) => (
<div key={file.filePath} className="flex items-center justify-between gap-3 rounded-md bg-background px-3 py-2 text-xs">
<span className="break-all font-mono">{file.filePath}</span>
<span className="shrink-0 text-muted-foreground">{file.fileSize} B</span>
</div>
))}
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
}
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'),
}}
/>
)}
</div>
) : (
<div className="space-y-6">

File diff suppressed because it is too large Load Diff

View File

@ -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 (
<div className="mx-auto max-w-6xl p-6 space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
@ -219,27 +210,6 @@ export default function StatusPage() {
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{settingsLinks.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className="group flex min-h-[116px] items-start gap-4 rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/50 hover:bg-muted/40"
>
<span className="mt-0.5 rounded-md border border-border bg-muted p-2 text-muted-foreground group-hover:text-primary">
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 space-y-1">
<span className="block text-sm font-semibold text-foreground">{item.title}</span>
<span className="block text-sm leading-6 text-muted-foreground">{item.description}</span>
</span>
</Link>
);
})}
</div>
{/* System Info */}
<Card>
<CardHeader>

View File

@ -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';

View File

@ -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 (
<div className="rounded-lg border border-border bg-white text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%]">
<div className="border-b border-border p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
{badges}
<Badge variant="outline">v{currentVersion || '-'}</Badge>
{loadingVersion && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</div>
<h2 className="break-words text-2xl font-semibold tracking-normal">{title}</h2>
{summary && <p className="mt-2 max-w-4xl text-sm leading-6 text-muted-foreground">{summary}</p>}
</div>
{actions}
</div>
</div>
<Tabs defaultValue="overview" className="p-5">
<TabsList>
<TabsTrigger value="overview">{labels.overview}</TabsTrigger>
<TabsTrigger value="files">{labels.files}</TabsTrigger>
<TabsTrigger value="versions">{labels.versions}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-5">
{readme.trim() ? (
<MarkdownPreview content={readme} />
) : (
<EmptyPanel icon={<FileText className="h-7 w-7" />} text={labels.noReadme} />
)}
</TabsContent>
<TabsContent value="files" className="mt-5">
<div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="min-h-[420px] rounded-lg border border-border">
{files.length === 0 ? (
<EmptyPanel icon={<FileText className="h-7 w-7" />} text={labels.noFiles} />
) : (
<div className="max-h-[620px] overflow-auto p-2">
{files.map((file) => (
<button
key={file.filePath}
type="button"
className={`flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm transition hover:bg-muted ${
selectedFile?.filePath === file.filePath ? 'bg-muted' : ''
}`}
onClick={() => onOpenFile(file.filePath)}
>
<span className="min-w-0 break-all font-mono text-xs">{file.filePath}</span>
<span className="shrink-0 text-xs text-muted-foreground">{formatBytes(file.fileSize)}</span>
</button>
))}
</div>
)}
</div>
<div className="min-h-[420px] rounded-lg border border-border bg-muted/20 p-4">
{loadingFile ? (
<div className="flex h-[360px] items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : selectedFile ? (
<FilePreview file={selectedFile} labels={labels} />
) : (
<EmptyPanel icon={<FileText className="h-7 w-7" />} text={labels.selectFile} />
)}
</div>
</div>
</TabsContent>
<TabsContent value="versions" className="mt-5">
<div className="space-y-2">
{versions.map((version) => (
<button
key={version.version}
type="button"
className={`flex w-full items-center justify-between gap-4 rounded-lg border px-4 py-3 text-left transition hover:bg-muted ${
version.version === currentVersion ? 'border-primary bg-muted' : 'border-border'
}`}
onClick={() => onSelectVersion(version.version)}
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-sm">{version.version}</span>
{version.version === currentVersion && <Badge variant="secondary">{labels.current}</Badge>}
{version.status && <Badge variant="outline">{version.status}</Badge>}
</div>
{version.changeReason && (
<p className="mt-1 truncate text-xs text-muted-foreground">{version.changeReason}</p>
)}
</div>
<div className="shrink-0 text-right text-xs text-muted-foreground">
{version.publishedAt || version.createdAt || ''}
{typeof version.totalSize === 'number' && (
<div>
{labels.size}: {formatBytes(version.totalSize)}
</div>
)}
</div>
</button>
))}
</div>
</TabsContent>
</Tabs>
</div>
);
}
function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDetailViewProps['labels'] }) {
const content = file.content || '';
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="break-all font-mono text-sm font-medium">{file.filePath}</div>
<Badge variant="outline">
{labels.size}: {formatBytes(file.fileSize)}
</Badge>
</div>
{file.isBinary ? (
<EmptyPanel icon={<FileText className="h-7 w-7" />} text={labels.binaryFile} />
) : isMarkdown(file.filePath, file.contentType) ? (
<MarkdownPreview content={stripFrontmatter(content)} />
) : (
<pre className="max-h-[560px] overflow-auto rounded-md border border-border bg-background p-4 text-xs leading-5">
{content}
</pre>
)}
</div>
);
}
function MarkdownPreview({ content }: { content: string }) {
return (
<div className="prose prose-sm max-w-none text-black prose-a:text-black prose-blockquote:text-black prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-black prose-strong:text-black prose-table:text-black prose-td:text-black prose-th:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
function EmptyPanel({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="flex min-h-[220px] flex-col items-center justify-center text-center text-muted-foreground">
<div className="mb-3 opacity-40">{icon}</div>
<p className="text-sm font-medium">{text}</p>
</div>
);
}
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`;
}

View File

@ -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<Skill[]> {
return fetchJSON('/api/skills');
}
export async function getSkillDetail(skillName: string): Promise<SkillDetailResponse> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/detail`);
}
export async function getSkillVersion(skillName: string, version: string): Promise<SkillDetailResponse> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/versions/${encodeURIComponent(version)}`);
}
export async function getSkillFile(skillName: string, version: string, filePath: string): Promise<SkillFileContent> {
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<SkillLearningCandidate[]> {
const query = status ? `?status=${encodeURIComponent(status)}` : '';
return fetchJSON(`/api/skills/candidates${query}`);
@ -737,6 +756,7 @@ export async function synthesizeSkillDraft(candidateId: string): Promise<SkillDr
return fetchJSON(`/api/skills/candidates/${encodeURIComponent(candidateId)}/draft`, {
method: 'POST',
body: JSON.stringify({}),
timeoutMs: SKILL_LEARNING_REQUEST_TIMEOUT_MS,
});
}
@ -744,6 +764,7 @@ export async function regenerateSkillDraft(candidateId: string): Promise<SkillDr
return fetchJSON(`/api/skills/candidates/${encodeURIComponent(candidateId)}/regenerate`, {
method: 'POST',
body: JSON.stringify({}),
timeoutMs: SKILL_LEARNING_REQUEST_TIMEOUT_MS,
});
}
@ -757,6 +778,7 @@ export async function runSkillLearningOnce(): Promise<{
return fetchJSON('/api/skills/learning/run-once', {
method: 'POST',
body: JSON.stringify({}),
timeoutMs: SKILL_LEARNING_REQUEST_TIMEOUT_MS,
});
}
@ -1301,6 +1323,24 @@ export async function getSkillHubVersion(
);
}
export async function getSkillHubVersions(namespace: string, slug: string): Promise<SkillHubVersionsResponse> {
return fetchJSON(
`/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}/versions`
);
}
export async function getSkillHubFile(
namespace: string,
slug: string,
version: string,
filePath: string
): Promise<SkillFileContent> {
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<BrowseResult> {
const params = path ? `?path=${encodeURIComponent(path)}` : '';
return fetchJSON(`/api/workspace/browse${params}`);
}
export async function getWorkspaceFile(path: string): Promise<WorkspaceFileContent> {
return fetchJSON(`/api/workspace/file?path=${encodeURIComponent(path)}`);
}
export function getWorkspaceDownloadUrl(path: string): string {
return buildApiUrl(`/api/workspace/download?path=${encodeURIComponent(path)}`);
}

View File

@ -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<string, unknown>;
agent_cards?: Record<string, unknown>[];
}
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<string, unknown> | null;
currentVersion: string;
versions: SkillVersionRef[];
versionDetail?: Record<string, unknown> | null;
files: SkillFileInfo[];
content: string;
frontmatter?: Record<string, unknown>;
}
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;

View File

@ -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="")

View File

@ -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",