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:
@ -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`
|
||||
|
||||
当前版本里,新实例的默认大模型配置就是从这里分发的:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
*,
|
||||
|
||||
220
app-instance/backend/beaver/coordinator/subagents.py
Normal file
220
app-instance/backend/beaver/coordinator/subagents.py
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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
322
app-instance/backend/beaver/interfaces/web/files.py
Normal file
322
app-instance/backend/beaver/interfaces/web/files.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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`.
|
||||
@ -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"}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -18,7 +18,10 @@
|
||||
└─ future channels(未来扩展入口)
|
||||
│
|
||||
└─ AgentService(统一服务层:所有入口都先汇总到这里)
|
||||
├─ MainAgentRouter(LLM 语义判断 simple / continue task / new task / close / abandon)
|
||||
├─ Intent Agent / MainAgentRouter(第一层意图判断:simple chat / continue task / create task / close / abandon)
|
||||
├─ load intent-agent-router skill(内部 skill 指引:只做路由,不回答用户,不使用工具)
|
||||
├─ classify(...)(LLM 语义判断)
|
||||
├─ session hidden event: intent_agent_decision_snapshotted(记录选择 simple_chat / create_task / continue_task 等)
|
||||
├─ create_loop()(创建 AgentLoop 运行核心)
|
||||
├─ start()(启动后台运行模式)
|
||||
├─ submit_direct()(把任务提交到运行队列)
|
||||
@ -73,7 +76,10 @@ AgentService.process_direct / submit_direct(聊天入口统一进入服务层
|
||||
│
|
||||
├─ resolve session_id(复用请求 session,或生成新 session)
|
||||
├─ task_service.get_latest_open_task(session_id)(查找同会话未关闭 Task)
|
||||
├─ MainAgentRouter.classify(message, active_task, recent_messages)(LLM 语义分类)
|
||||
├─ MainAgentRouter.classify(message, active_task, recent_messages, intent-agent-router skill)(Intent Agent 语义分类)
|
||||
│ ├─ Intent Agent 只返回 JSON 路由结果,不直接回答用户
|
||||
│ ├─ Intent Agent 没有 tools;凡是需要工具、实时/外部数据、文件、执行、验证的请求都应进入 Task
|
||||
│ ├─ session hidden event: intent_agent_decision_snapshotted(调试日志展示 choice / reason / short_title)
|
||||
│ ├─ simple(简单问题)
|
||||
│ │ └─ runner(message, include_skill_assembly=False, include_tools=False)(不创建 Task,不跑 skills/tools)
|
||||
│ │
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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(
|
||||
|
||||
70
app-instance/backend/tests/unit/test_web_files_api.py
Normal file
70
app-instance/backend/tests/unit/test_web_files_api.py
Normal 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
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -245,7 +245,7 @@ docker build \
|
||||
当前仓库的部分技术标识仍沿用旧命名,例如:
|
||||
|
||||
- `nanobot web`
|
||||
- `~/.nanobot/plugins/`
|
||||
- `~/.beaver/plugins/`
|
||||
- 本地存储中的旧 token key
|
||||
|
||||
这些属于兼容性和后端约定的一部分,前端展示品牌已替换为 `Boardware Genius`,但技术标识没有在这个仓库里强制迁移。
|
||||
|
||||
@ -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/<id>_agent</code>
|
||||
<code className="mx-1">~/.beaver/workspace/agents/<id>_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.')}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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
@ -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>
|
||||
|
||||
@ -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';
|
||||
|
||||
222
app-instance/frontend/components/skills/SkillDetailView.tsx
Normal file
222
app-instance/frontend/components/skills/SkillDetailView.tsx
Normal 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`;
|
||||
}
|
||||
@ -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)}`);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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="")
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user