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` 容器反复重启
|
- `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_HOME=/root/.beaver \
|
||||||
BEAVER_CONFIG_PATH=/root/.beaver/config.json \
|
BEAVER_CONFIG_PATH=/root/.beaver/config.json \
|
||||||
BEAVER_WORKSPACE=/root/.beaver/workspace \
|
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 \
|
PORT=3000 \
|
||||||
HOSTNAME=127.0.0.1
|
HOSTNAME=127.0.0.1
|
||||||
|
|
||||||
|
|||||||
@ -59,6 +59,17 @@ class AgentRegistry:
|
|||||||
return agent
|
return agent
|
||||||
raise ValueError(f"Unknown agent_id: {agent_id}")
|
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(
|
def search(
|
||||||
self,
|
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
|
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)
|
@dataclass(slots=True)
|
||||||
class SkillContext:
|
class SkillContext:
|
||||||
"""单个已激活 skill 的最小表示。
|
"""单个已激活 skill 的最小表示。
|
||||||
@ -133,11 +140,12 @@ class ContextBuilder:
|
|||||||
|
|
||||||
顺序固定非常重要,当前约定是:
|
顺序固定非常重要,当前约定是:
|
||||||
|
|
||||||
1. base system prompt
|
1. Beaver user-facing assistant identity
|
||||||
2. session metadata
|
2. base system prompt
|
||||||
3. execution context
|
3. session metadata
|
||||||
4. frozen memory snapshot
|
4. execution context
|
||||||
5. extra sections
|
5. frozen memory snapshot
|
||||||
|
6. extra sections
|
||||||
|
|
||||||
这样设计的原因:
|
这样设计的原因:
|
||||||
- 身份与总规则要最靠前
|
- 身份与总规则要最靠前
|
||||||
@ -146,7 +154,7 @@ class ContextBuilder:
|
|||||||
- activated skill 正文按 Hermes 风格放到显式消息里,避免 system prompt 持续膨胀
|
- 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()
|
base_system_prompt = (build_input.base_system_prompt or "").strip()
|
||||||
if base_system_prompt:
|
if base_system_prompt:
|
||||||
|
|||||||
@ -217,6 +217,7 @@ class AgentLoop:
|
|||||||
pinned_skill_names: list[str] | None = None,
|
pinned_skill_names: list[str] | None = None,
|
||||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||||
allow_candidate_generation: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
|
intent_agent_decision: dict[str, Any] | None = None,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
"""跑通最小 direct run 主链。
|
"""跑通最小 direct run 主链。
|
||||||
|
|
||||||
@ -265,6 +266,7 @@ class AgentLoop:
|
|||||||
pinned_skill_names=pinned_skill_names,
|
pinned_skill_names=pinned_skill_names,
|
||||||
pinned_skill_contexts=pinned_skill_contexts,
|
pinned_skill_contexts=pinned_skill_contexts,
|
||||||
allow_candidate_generation=allow_candidate_generation,
|
allow_candidate_generation=allow_candidate_generation,
|
||||||
|
intent_agent_decision=intent_agent_decision,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _process_direct_impl(
|
async def _process_direct_impl(
|
||||||
@ -301,6 +303,7 @@ class AgentLoop:
|
|||||||
pinned_skill_names: list[str] | None = None,
|
pinned_skill_names: list[str] | None = None,
|
||||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||||
allow_candidate_generation: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
|
intent_agent_decision: dict[str, Any] | None = None,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
"""真正执行一轮 direct run 的内部实现。
|
"""真正执行一轮 direct run 的内部实现。
|
||||||
|
|
||||||
@ -381,6 +384,7 @@ class AgentLoop:
|
|||||||
"parent_session_id": parent_session_id,
|
"parent_session_id": parent_session_id,
|
||||||
"pinned_skill_names": list(pinned_skill_names or []),
|
"pinned_skill_names": list(pinned_skill_names or []),
|
||||||
"pinned_skill_context_names": [skill.name for skill in pinned_skill_contexts or []],
|
"pinned_skill_context_names": [skill.name for skill in pinned_skill_contexts or []],
|
||||||
|
"intent_agent_decision": intent_agent_decision,
|
||||||
},
|
},
|
||||||
content=task,
|
content=task,
|
||||||
context_visible=False,
|
context_visible=False,
|
||||||
@ -389,6 +393,20 @@ class AgentLoop:
|
|||||||
model=resolved_model,
|
model=resolved_model,
|
||||||
user_id=user_id,
|
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
|
user_message_recorded = False
|
||||||
iterations = 0
|
iterations = 0
|
||||||
|
|||||||
@ -48,3 +48,64 @@ class AuthzClient:
|
|||||||
async def get_permissions(self, backend_id: str) -> dict[str, Any]:
|
async def get_permissions(self, backend_id: str) -> dict[str, Any]:
|
||||||
data = await self._request("GET", f"/backends/{backend_id}/permissions")
|
data = await self._request("GET", f"/backends/{backend_id}/permissions")
|
||||||
return data if isinstance(data, dict) else {}
|
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,
|
provider=router_provider,
|
||||||
model=getattr(router_runtime, "model", None),
|
model=getattr(router_runtime, "model", None),
|
||||||
recent_messages=session_manager.get_messages_as_conversation(session_id),
|
recent_messages=session_manager.get_messages_as_conversation(session_id),
|
||||||
|
intent_skill=self._load_intent_agent_skill(loaded),
|
||||||
thinking_enabled=kwargs.get("thinking_enabled"),
|
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"):
|
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
|
active_task.metadata["short_title"] = decision.short_title
|
||||||
task_service.store.upsert_task(active_task)
|
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}")
|
raise RuntimeError(f"Engine loader did not provide required dependency {field_name!r}")
|
||||||
return value
|
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
|
@staticmethod
|
||||||
def _skill_names_for_run(loaded: Any, run_id: str) -> list[str]:
|
def _skill_names_for_run(loaded: Any, run_id: str) -> list[str]:
|
||||||
store = getattr(loaded, "run_memory_store", None)
|
store = getattr(loaded, "run_memory_store", None)
|
||||||
|
|||||||
@ -68,6 +68,34 @@ class SkillHubService:
|
|||||||
files = []
|
files = []
|
||||||
return {"detail": detail, "files": 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]:
|
async def install(self, namespace: str, slug: str, version: str | None = None) -> dict[str, Any]:
|
||||||
namespace = namespace.removeprefix("@")
|
namespace = namespace.removeprefix("@")
|
||||||
skill = await self.detail(namespace, slug)
|
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.append(f"{key}: {value}")
|
||||||
lines.extend(["---", "", body.strip()])
|
lines.extend(["---", "", body.strip()])
|
||||||
return "\n".join(lines).rstrip() + "\n"
|
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.extra_dirs = [Path(item) for item in (extra_dirs or [])]
|
||||||
self.skill_store = skill_store or SkillSpecStore(self.workspace)
|
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。
|
"""列出当前可见的 skills。
|
||||||
|
|
||||||
优先级:
|
优先级:
|
||||||
@ -80,9 +85,11 @@ class SkillsLoader:
|
|||||||
|
|
||||||
found: dict[str, SkillRecord] = {}
|
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:
|
if record.name in found:
|
||||||
continue
|
continue
|
||||||
|
if not include_internal and self._record_internal(record):
|
||||||
|
continue
|
||||||
if filter_unavailable and not self._record_available(record):
|
if filter_unavailable and not self._record_available(record):
|
||||||
continue
|
continue
|
||||||
found[record.name] = record
|
found[record.name] = record
|
||||||
@ -101,6 +108,8 @@ class SkillsLoader:
|
|||||||
if name in found:
|
if name in found:
|
||||||
continue
|
continue
|
||||||
frontmatter, body = parse_frontmatter(skill_file.read_text(encoding="utf-8"))
|
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)
|
normalized_frontmatter = dict(frontmatter)
|
||||||
record = SkillRecord(
|
record = SkillRecord(
|
||||||
name=name,
|
name=name,
|
||||||
@ -375,11 +384,15 @@ class SkillsLoader:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def _find_record(self, name: str) -> SkillRecord | None:
|
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:
|
if record.name == name:
|
||||||
return record
|
return record
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _record_internal(record: SkillRecord) -> bool:
|
||||||
|
return _truthy((record.frontmatter or {}).get("internal"))
|
||||||
|
|
||||||
def _record_available(self, record: SkillRecord) -> bool:
|
def _record_available(self, record: SkillRecord) -> bool:
|
||||||
content = record.path.read_text(encoding="utf-8")
|
content = record.path.read_text(encoding="utf-8")
|
||||||
frontmatter, _ = parse_frontmatter(content)
|
frontmatter, _ = parse_frontmatter(content)
|
||||||
@ -405,3 +418,9 @@ class SkillsLoader:
|
|||||||
def summarize_body(body: str) -> str:
|
def summarize_body(body: str) -> str:
|
||||||
cleaned = " ".join(line.strip() for line in body.splitlines()[:3] if line.strip()).strip()
|
cleaned = " ".join(line.strip() for line in body.splitlines()[:3] if line.strip()).strip()
|
||||||
return cleaned[:240]
|
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":
|
if candidate.kind == "new_skill":
|
||||||
payload = await self.synthesizer.synthesize_new_skill(candidate, packet, provider, model)
|
payload = await self.synthesizer.synthesize_new_skill(candidate, packet, provider, model)
|
||||||
return self.draft_service.create_new_skill_draft(
|
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_content=payload["content"],
|
||||||
proposed_frontmatter=payload["frontmatter"],
|
proposed_frontmatter=payload["frontmatter"],
|
||||||
created_by="learning-loop",
|
created_by="learning-loop",
|
||||||
@ -382,15 +382,34 @@ class SkillLearningService:
|
|||||||
return " ".join(words[:8]).strip()
|
return " ".join(words[:8]).strip()
|
||||||
|
|
||||||
@staticmethod
|
@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:
|
if candidate.related_skill_names:
|
||||||
return candidate.related_skill_names[0]
|
return candidate.related_skill_names[0]
|
||||||
if packet.task_summaries:
|
if isinstance(frontmatter, dict):
|
||||||
seed = re.sub(r"[^a-z0-9]+", "-", packet.task_summaries[0].lower()).strip("-")
|
description = str(frontmatter.get("description") or "")
|
||||||
|
seed = SkillLearningService._slugify_skill_name(description)
|
||||||
if seed:
|
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]}"
|
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
|
@staticmethod
|
||||||
def _parse_timestamp(value: str) -> datetime | None:
|
def _parse_timestamp(value: str) -> datetime | None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -163,6 +163,7 @@ class MainAgentDecision:
|
|||||||
closes_task: bool = False
|
closes_task: bool = False
|
||||||
abandons_task: bool = False
|
abandons_task: bool = False
|
||||||
short_title: str | None = None
|
short_title: str | None = None
|
||||||
|
action: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_task(self) -> bool:
|
def is_task(self) -> bool:
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class MainAgentRouter:
|
|||||||
provider: Any | None = None,
|
provider: Any | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
recent_messages: list[dict[str, Any]] | None = None,
|
recent_messages: list[dict[str, Any]] | None = None,
|
||||||
|
intent_skill: str | None = None,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
timeout_seconds: float = 8.0,
|
timeout_seconds: float = 8.0,
|
||||||
) -> MainAgentDecision:
|
) -> MainAgentDecision:
|
||||||
@ -31,8 +32,9 @@ class MainAgentRouter:
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
"You route user messages for Beaver's internal Task mode. "
|
"You are Beaver's Intent Agent. Your only job is to route the user's "
|
||||||
"Return only compact JSON. Do not explain."
|
"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,
|
message=message,
|
||||||
active_task=active_task,
|
active_task=active_task,
|
||||||
recent_messages=recent_messages or [],
|
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"))
|
short_title = _clean_short_title(payload.get("short_title") or payload.get("title"))
|
||||||
|
|
||||||
if raw_action in {"continue_task", "continue", "task"}:
|
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"}:
|
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"}:
|
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"}:
|
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(
|
||||||
return MainAgentDecision(mode="simple", reason=reason or "simple_chat", short_title=short_title)
|
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:
|
def _fallback(self, *, active_task: TaskRecord | None, reason: str) -> MainAgentDecision:
|
||||||
if active_task is not None:
|
if active_task is not None:
|
||||||
return MainAgentDecision(mode="task", reason=reason)
|
return MainAgentDecision(mode="task", reason=reason, action="continue_task")
|
||||||
return MainAgentDecision(mode="simple", reason=reason)
|
return MainAgentDecision(mode="simple", reason=reason, action="simple_chat")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _prompt(
|
def _prompt(
|
||||||
@ -83,6 +115,7 @@ class MainAgentRouter:
|
|||||||
message: str,
|
message: str,
|
||||||
active_task: TaskRecord | None,
|
active_task: TaskRecord | None,
|
||||||
recent_messages: list[dict[str, Any]],
|
recent_messages: list[dict[str, Any]],
|
||||||
|
intent_skill: str | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
active_task_payload = None
|
active_task_payload = None
|
||||||
if active_task is not None:
|
if active_task is not None:
|
||||||
@ -98,8 +131,14 @@ class MainAgentRouter:
|
|||||||
for item in recent_messages[-8:]
|
for item in recent_messages[-8:]
|
||||||
if item.get("role") in {"user", "assistant"}
|
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 (
|
return (
|
||||||
"Decide how to route the current user message.\n\n"
|
"Decide how to route the current user message.\n\n"
|
||||||
|
f"{skill_section}"
|
||||||
"Actions:\n"
|
"Actions:\n"
|
||||||
"- simple_chat: no Task should be created or continued.\n"
|
"- simple_chat: no Task should be created or continued.\n"
|
||||||
"- continue_task: keep the user in the active Task.\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"
|
"- 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, "
|
"- 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"
|
"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"
|
"- 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"
|
"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"
|
f"Active task:\n{json.dumps(active_task_payload, ensure_ascii=False)}\n\n"
|
||||||
|
|||||||
@ -18,7 +18,10 @@
|
|||||||
└─ future channels(未来扩展入口)
|
└─ future channels(未来扩展入口)
|
||||||
│
|
│
|
||||||
└─ AgentService(统一服务层:所有入口都先汇总到这里)
|
└─ 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 运行核心)
|
├─ create_loop()(创建 AgentLoop 运行核心)
|
||||||
├─ start()(启动后台运行模式)
|
├─ start()(启动后台运行模式)
|
||||||
├─ submit_direct()(把任务提交到运行队列)
|
├─ submit_direct()(把任务提交到运行队列)
|
||||||
@ -73,7 +76,10 @@ AgentService.process_direct / submit_direct(聊天入口统一进入服务层
|
|||||||
│
|
│
|
||||||
├─ resolve session_id(复用请求 session,或生成新 session)
|
├─ resolve session_id(复用请求 session,或生成新 session)
|
||||||
├─ task_service.get_latest_open_task(session_id)(查找同会话未关闭 Task)
|
├─ 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(简单问题)
|
│ ├─ simple(简单问题)
|
||||||
│ │ └─ runner(message, include_skill_assembly=False, include_tools=False)(不创建 Task,不跑 skills/tools)
|
│ │ └─ 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,
|
run_id=run_id,
|
||||||
role="system",
|
role="system",
|
||||||
event_type="run_started",
|
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",
|
content="hello",
|
||||||
context_visible=False,
|
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(
|
manager.append_message(
|
||||||
session_id,
|
session_id,
|
||||||
run_id=run_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"]
|
sessions = response.json()["sessions"]
|
||||||
run = sessions[0]["runs"][0]
|
run = sessions[0]["runs"][0]
|
||||||
assert run["run_id"] == run_id
|
assert run["run_id"] == run_id
|
||||||
|
assert run["intent_agent_choice"] == "create_task"
|
||||||
assert run["user_input"] == "hello"
|
assert run["user_input"] == "hello"
|
||||||
assert [event["event_type"] for event in run["events"]] == [
|
assert [event["event_type"] for event in run["events"]] == [
|
||||||
"run_started",
|
"run_started",
|
||||||
|
"intent_agent_decision_snapshotted",
|
||||||
"llm_request_snapshotted",
|
"llm_request_snapshotted",
|
||||||
"user_message_added",
|
"user_message_added",
|
||||||
"assistant_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:
|
) -> LLMResponse:
|
||||||
self.calls.append(
|
self.calls.append(
|
||||||
{
|
{
|
||||||
|
"messages": messages,
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"model": model,
|
"model": model,
|
||||||
@ -83,6 +84,24 @@ def test_router_receives_thinking_mode() -> None:
|
|||||||
assert provider.calls[0]["thinking_enabled"] is False
|
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:
|
def test_router_closes_active_task_from_llm_decision() -> None:
|
||||||
decision = asyncio.run(
|
decision = asyncio.run(
|
||||||
MainAgentRouter().classify(
|
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"])
|
target = Path(os.environ["TARGET_PATH"])
|
||||||
values = {
|
values = {
|
||||||
"NANOBOT_AUTHZ__ENABLED": "1" if os.environ["AUTHZ_BASE_URL"].strip() else "0",
|
"BEAVER_AUTHZ__ENABLED": "1" if os.environ["AUTHZ_BASE_URL"].strip() else "0",
|
||||||
"NANOBOT_AUTHZ__BASE_URL": os.environ["AUTHZ_BASE_URL"].strip(),
|
"BEAVER_AUTHZ__BASE_URL": os.environ["AUTHZ_BASE_URL"].strip(),
|
||||||
"NANOBOT_AUTHZ__OUTLOOK_MCP_URL": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(),
|
"BEAVER_AUTHZ__OUTLOOK_MCP_URL": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(),
|
||||||
"NANOBOT_BACKEND_IDENTITY__BACKEND_ID": os.environ["BACKEND_ID"].strip(),
|
"BEAVER_BACKEND_IDENTITY__BACKEND_ID": os.environ["BACKEND_ID"].strip(),
|
||||||
"NANOBOT_BACKEND_IDENTITY__CLIENT_ID": os.environ["CLIENT_ID"].strip(),
|
"BEAVER_BACKEND_IDENTITY__CLIENT_ID": os.environ["CLIENT_ID"].strip(),
|
||||||
"NANOBOT_BACKEND_IDENTITY__CLIENT_SECRET": os.environ["CLIENT_SECRET"].strip(),
|
"BEAVER_BACKEND_IDENTITY__CLIENT_SECRET": os.environ["CLIENT_SECRET"].strip(),
|
||||||
"NANOBOT_BACKEND_IDENTITY__NAME": os.environ["BACKEND_NAME"].strip(),
|
"BEAVER_BACKEND_IDENTITY__NAME": os.environ["BACKEND_NAME"].strip(),
|
||||||
"NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL": os.environ["PUBLIC_URL"].strip(),
|
"BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL": os.environ["PUBLIC_URL"].strip(),
|
||||||
}
|
}
|
||||||
ordered_keys = [
|
ordered_keys = [
|
||||||
"NANOBOT_AUTHZ__ENABLED",
|
"BEAVER_AUTHZ__ENABLED",
|
||||||
"NANOBOT_AUTHZ__BASE_URL",
|
"BEAVER_AUTHZ__BASE_URL",
|
||||||
"NANOBOT_AUTHZ__OUTLOOK_MCP_URL",
|
"BEAVER_AUTHZ__OUTLOOK_MCP_URL",
|
||||||
"NANOBOT_BACKEND_IDENTITY__BACKEND_ID",
|
"BEAVER_BACKEND_IDENTITY__BACKEND_ID",
|
||||||
"NANOBOT_BACKEND_IDENTITY__CLIENT_ID",
|
"BEAVER_BACKEND_IDENTITY__CLIENT_ID",
|
||||||
"NANOBOT_BACKEND_IDENTITY__CLIENT_SECRET",
|
"BEAVER_BACKEND_IDENTITY__CLIENT_SECRET",
|
||||||
"NANOBOT_BACKEND_IDENTITY__NAME",
|
"BEAVER_BACKEND_IDENTITY__NAME",
|
||||||
"NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL",
|
"BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL",
|
||||||
]
|
]
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for key in ordered_keys:
|
for key in ordered_keys:
|
||||||
@ -291,8 +291,8 @@ for key in ordered_keys:
|
|||||||
if value:
|
if value:
|
||||||
lines.append(f"export {key}={shlex.quote(value)}")
|
lines.append(f"export {key}={shlex.quote(value)}")
|
||||||
continue
|
continue
|
||||||
if key == "NANOBOT_AUTHZ__ENABLED":
|
if key == "BEAVER_AUTHZ__ENABLED":
|
||||||
lines.append("export NANOBOT_AUTHZ__ENABLED=0")
|
lines.append("export BEAVER_AUTHZ__ENABLED=0")
|
||||||
else:
|
else:
|
||||||
lines.append(f"unset {key}")
|
lines.append(f"unset {key}")
|
||||||
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
@ -544,13 +544,12 @@ RUN_ARGS=(
|
|||||||
-e "BEAVER_HOME=/root/.beaver"
|
-e "BEAVER_HOME=/root/.beaver"
|
||||||
-e "BEAVER_CONFIG_PATH=/root/.beaver/config.json"
|
-e "BEAVER_CONFIG_PATH=/root/.beaver/config.json"
|
||||||
-e "BEAVER_WORKSPACE=/root/.beaver/workspace"
|
-e "BEAVER_WORKSPACE=/root/.beaver/workspace"
|
||||||
-e "NANOBOT_HOME=/root/.beaver"
|
-e "BEAVER_AUTH_FILE=/root/.beaver/web_auth_users.json"
|
||||||
-e "NANOBOT_AUTH_FILE=/root/.beaver/web_auth_users.json"
|
-e "BEAVER_FRONTEND_PUBLIC_BASE_URL=${PUBLIC_URL}"
|
||||||
-e "NANOBOT_FRONTEND_PUBLIC_BASE_URL=${PUBLIC_URL}"
|
|
||||||
-e "APP_PUBLIC_PORT=8080"
|
-e "APP_PUBLIC_PORT=8080"
|
||||||
-e "APP_FRONTEND_PORT=3000"
|
-e "APP_FRONTEND_PORT=3000"
|
||||||
-e "APP_BACKEND_PORT=18080"
|
-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.id=${INSTANCE_ID}"
|
||||||
--label "nano.instance.slug=${INSTANCE_SLUG}"
|
--label "nano.instance.slug=${INSTANCE_SLUG}"
|
||||||
--label "nano.instance.public_url=${PUBLIC_URL}"
|
--label "nano.instance.public_url=${PUBLIC_URL}"
|
||||||
@ -571,7 +570,7 @@ docker run "${RUN_ARGS[@]}" "$IMAGE_NAME" >/dev/null
|
|||||||
--host-port "$HOST_PORT" \
|
--host-port "$HOST_PORT" \
|
||||||
--public-url "$PUBLIC_URL" \
|
--public-url "$PUBLIC_URL" \
|
||||||
--instance-root "$INSTANCE_ROOT" \
|
--instance-root "$INSTANCE_ROOT" \
|
||||||
--nanobot-home "$BEAVER_HOME" \
|
--beaver-home "$BEAVER_HOME" \
|
||||||
--config-path "$CONFIG_PATH" \
|
--config-path "$CONFIG_PATH" \
|
||||||
--auth-users-path "$AUTH_USERS_PATH" \
|
--auth-users-path "$AUTH_USERS_PATH" \
|
||||||
--network-name "$NETWORK_NAME" \
|
--network-name "$NETWORK_NAME" \
|
||||||
@ -594,7 +593,6 @@ host_port=${HOST_PORT}
|
|||||||
public_url=${PUBLIC_URL}
|
public_url=${PUBLIC_URL}
|
||||||
instance_root=${INSTANCE_ROOT}
|
instance_root=${INSTANCE_ROOT}
|
||||||
beaver_home=${BEAVER_HOME}
|
beaver_home=${BEAVER_HOME}
|
||||||
nanobot_home=${BEAVER_HOME}
|
|
||||||
config_path=${CONFIG_PATH}
|
config_path=${CONFIG_PATH}
|
||||||
auth_users_path=${AUTH_USERS_PATH}
|
auth_users_path=${AUTH_USERS_PATH}
|
||||||
runtime_env_path=${RUNTIME_ENV_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_HOME="${BEAVER_HOME:-/root/.beaver}"
|
||||||
BEAVER_CONFIG_PATH="${BEAVER_CONFIG_PATH:-$BEAVER_HOME/config.json}"
|
BEAVER_CONFIG_PATH="${BEAVER_CONFIG_PATH:-$BEAVER_HOME/config.json}"
|
||||||
BEAVER_WORKSPACE="${BEAVER_WORKSPACE:-$BEAVER_HOME/workspace}"
|
BEAVER_WORKSPACE="${BEAVER_WORKSPACE:-$BEAVER_HOME/workspace}"
|
||||||
NANOBOT_HOME="${NANOBOT_HOME:-$BEAVER_HOME}"
|
BEAVER_AUTH_FILE="${BEAVER_AUTH_FILE:-$BEAVER_HOME/web_auth_users.json}"
|
||||||
NANOBOT_AUTH_FILE="${NANOBOT_AUTH_FILE:-$BEAVER_HOME/web_auth_users.json}"
|
BEAVER_RUNTIME_ENV_FILE="${BEAVER_RUNTIME_ENV_FILE:-$BEAVER_HOME/runtime.env}"
|
||||||
NANOBOT_RUNTIME_ENV_FILE="${NANOBOT_RUNTIME_ENV_FILE:-$BEAVER_HOME/runtime.env}"
|
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '[app-instance] %s\n' "$*"
|
printf '[app-instance] %s\n' "$*"
|
||||||
@ -45,16 +44,16 @@ trap cleanup EXIT INT TERM
|
|||||||
|
|
||||||
mkdir -p "$BEAVER_HOME" "$BEAVER_WORKSPACE"
|
mkdir -p "$BEAVER_HOME" "$BEAVER_WORKSPACE"
|
||||||
|
|
||||||
if [[ -f "$NANOBOT_RUNTIME_ENV_FILE" ]]; then
|
if [[ -f "$BEAVER_RUNTIME_ENV_FILE" ]]; then
|
||||||
set -a
|
set -a
|
||||||
. "$NANOBOT_RUNTIME_ENV_FILE"
|
. "$BEAVER_RUNTIME_ENV_FILE"
|
||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
require_file "$BEAVER_CONFIG_PATH" "Missing Beaver config"
|
require_file "$BEAVER_CONFIG_PATH" "Missing Beaver config"
|
||||||
|
|
||||||
export NANOBOT_AUTH_FILE
|
export BEAVER_AUTH_FILE
|
||||||
export NANOBOT_RUNTIME_ENV_FILE
|
export BEAVER_RUNTIME_ENV_FILE
|
||||||
export BEAVER_HOME
|
export BEAVER_HOME
|
||||||
export BEAVER_CONFIG_PATH
|
export BEAVER_CONFIG_PATH
|
||||||
export BEAVER_WORKSPACE
|
export BEAVER_WORKSPACE
|
||||||
|
|||||||
@ -245,7 +245,7 @@ docker build \
|
|||||||
当前仓库的部分技术标识仍沿用旧命名,例如:
|
当前仓库的部分技术标识仍沿用旧命名,例如:
|
||||||
|
|
||||||
- `nanobot web`
|
- `nanobot web`
|
||||||
- `~/.nanobot/plugins/`
|
- `~/.beaver/plugins/`
|
||||||
- 本地存储中的旧 token key
|
- 本地存储中的旧 token key
|
||||||
|
|
||||||
这些属于兼容性和后端约定的一部分,前端展示品牌已替换为 `Boardware Genius`,但技术标识没有在这个仓库里强制迁移。
|
这些属于兼容性和后端约定的一部分,前端展示品牌已替换为 `Boardware Genius`,但技术标识没有在这个仓库里强制迁移。
|
||||||
|
|||||||
@ -721,7 +721,7 @@ export default function AgentsPage() {
|
|||||||
<Card className="border-border/70 bg-muted/20">
|
<Card className="border-border/70 bg-muted/20">
|
||||||
<CardContent className="pt-6 text-sm text-muted-foreground leading-relaxed">
|
<CardContent className="pt-6 text-sm text-muted-foreground leading-relaxed">
|
||||||
{t('持久化 Sub-Agent 会在', 'Persistent sub-agents keep their own workspace under')}
|
{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')}
|
{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>
|
<code className="mx-1">remote_a2a_only</code>
|
||||||
{t(',即只能向外委派到远端 A2A agent。', ', which only allows delegation to remote A2A agents.')}
|
{t(',即只能向外委派到远端 A2A agent。', ', which only allows delegation to remote A2A agents.')}
|
||||||
|
|||||||
@ -18,18 +18,21 @@ import {
|
|||||||
FileArchive,
|
FileArchive,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
import {
|
import {
|
||||||
browseWorkspace,
|
browseWorkspace,
|
||||||
|
getWorkspaceFile,
|
||||||
getWorkspaceDownloadUrl,
|
getWorkspaceDownloadUrl,
|
||||||
uploadToWorkspace,
|
uploadToWorkspace,
|
||||||
deleteWorkspacePath,
|
deleteWorkspacePath,
|
||||||
createWorkspaceDir,
|
createWorkspaceDir,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import type { WorkspaceItem } from '@/lib/api';
|
import type { WorkspaceFileContent, WorkspaceItem } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
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';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
@ -41,6 +44,9 @@ export default function FilesPage() {
|
|||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [showMkdir, setShowMkdir] = useState(false);
|
const [showMkdir, setShowMkdir] = useState(false);
|
||||||
const [newDirName, setNewDirName] = useState('');
|
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const mkdirInputRef = useRef<HTMLInputElement>(null);
|
const mkdirInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@ -50,6 +56,8 @@ export default function FilesPage() {
|
|||||||
const data = await browseWorkspace(path);
|
const data = await browseWorkspace(path);
|
||||||
setItems(data.items);
|
setItems(data.items);
|
||||||
setCurrentPath(data.path);
|
setCurrentPath(data.path);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setPreviewError(null);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
@ -65,6 +73,20 @@ export default function FilesPage() {
|
|||||||
load(path);
|
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 handleDelete = async (item: WorkspaceItem) => {
|
||||||
const label = item.type === 'directory'
|
const label = item.type === 'directory'
|
||||||
? pickAppText(locale, '文件夹', 'folder')
|
? pickAppText(locale, '文件夹', 'folder')
|
||||||
@ -79,6 +101,9 @@ export default function FilesPage() {
|
|||||||
try {
|
try {
|
||||||
await deleteWorkspacePath(item.path);
|
await deleteWorkspacePath(item.path);
|
||||||
setItems((prev) => prev.filter((i) => i.path !== item.path));
|
setItems((prev) => prev.filter((i) => i.path !== item.path));
|
||||||
|
if (selectedFile?.path === item.path) {
|
||||||
|
setSelectedFile(null);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -165,7 +190,7 @@ export default function FilesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="mx-auto max-w-7xl p-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-bold">{pickAppText(locale, '文件管理', 'Files')}</h1>
|
<h1 className="text-2xl font-bold">{pickAppText(locale, '文件管理', 'Files')}</h1>
|
||||||
@ -280,84 +305,191 @@ export default function FilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File list */}
|
<div className="grid gap-4 lg:grid-cols-[minmax(360px,440px)_minmax(0,1fr)]">
|
||||||
{loading && items.length === 0 ? (
|
{/* File list */}
|
||||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
<div className="min-h-[520px] rounded-lg border border-border bg-card">
|
||||||
<Loader2 className="w-6 h-6 animate-spin" />
|
{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>
|
</div>
|
||||||
) : items.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<FilePreviewPanel
|
||||||
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
|
file={selectedFile}
|
||||||
<p className="text-lg font-medium">{pickAppText(locale, '空文件夹', 'Empty folder')}</p>
|
loading={previewLoading}
|
||||||
<p className="text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-[calc(100vh-14rem)]">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
|
||||||
{items.map((item) => (
|
<div className="min-w-0">
|
||||||
<div
|
<h2 className="break-all text-base font-semibold">{file.name}</h2>
|
||||||
key={item.path}
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
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"
|
{formatSize(file.size)} · {formatDate(file.modified)} · {file.content_type}
|
||||||
>
|
{file.is_truncated ? ` · ${pickAppText(locale, '仅预览前 1MB', 'Showing first 1MB')}` : ''}
|
||||||
{/* Icon */}
|
</p>
|
||||||
<div className="flex-shrink-0">
|
</div>
|
||||||
{item.type === 'directory' ? (
|
{downloadUrl && (
|
||||||
<Folder className="w-5 h-5 text-blue-500" />
|
<Button variant="outline" size="sm" asChild>
|
||||||
) : (
|
<a href={downloadUrl}>
|
||||||
<FileIcon name={item.name} contentType={item.content_type} />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
)}
|
{pickAppText(locale, '下载', 'Download')}
|
||||||
</div>
|
</a>
|
||||||
|
</Button>
|
||||||
{/* 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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
@ -383,3 +515,11 @@ function FileIcon({ name, contentType }: { name: string; contentType?: string })
|
|||||||
}
|
}
|
||||||
return <FileText className="w-5 h-5 text-muted-foreground" />;
|
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 { AlertCircle, ArrowLeft, Check, Download, Loader2, Search, Star } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getSkillHubFile,
|
||||||
getSkillHubDetail,
|
getSkillHubDetail,
|
||||||
getSkillHubVersion,
|
getSkillHubVersion,
|
||||||
|
getSkillHubVersions,
|
||||||
installSkillHubSkill,
|
installSkillHubSkill,
|
||||||
searchSkillHubSkills,
|
searchSkillHubSkills,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
@ -13,7 +15,8 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
|
||||||
@ -23,6 +26,20 @@ function publishedVersion(skill: SkillHubSearchItem | null): string {
|
|||||||
return skill?.publishedVersion?.version || skill?.headlineVersion?.version || '';
|
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() {
|
export default function MarketplacePage() {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
const t = useCallback((zh: string, en: string) => pickAppText(locale, zh, en), [locale]);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<SkillHubSearchItem | null>(null);
|
const [selected, setSelected] = useState<SkillHubSearchItem | null>(null);
|
||||||
const [versionDetail, setVersionDetail] = useState<SkillHubVersionResponse | 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 [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [versionLoading, setVersionLoading] = useState(false);
|
||||||
|
const [fileLoading, setFileLoading] = useState(false);
|
||||||
const [installing, setInstalling] = useState(false);
|
const [installing, setInstalling] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@ -61,14 +84,25 @@ export default function MarketplacePage() {
|
|||||||
const openDetail = async (item: SkillHubSearchItem) => {
|
const openDetail = async (item: SkillHubSearchItem) => {
|
||||||
setSelected(item);
|
setSelected(item);
|
||||||
setVersionDetail(null);
|
setVersionDetail(null);
|
||||||
|
setVersions([]);
|
||||||
|
setSelectedVersion('');
|
||||||
|
setReadmeContent('');
|
||||||
|
setSelectedFile(null);
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const detail = await getSkillHubDetail(item.namespace, item.slug);
|
const detail = await getSkillHubDetail(item.namespace, item.slug);
|
||||||
setSelected(detail);
|
setSelected(detail);
|
||||||
const version = publishedVersion(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) {
|
if (version) {
|
||||||
setVersionDetail(await getSkillHubVersion(detail.namespace, detail.slug, version));
|
await loadVersion(detail, version);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || t('加载技能详情失败', 'Failed to load skill details'));
|
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 () => {
|
const installSelected = async () => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
setInstalling(true);
|
setInstalling(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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 });
|
setSelected({ ...selected, installed: true, installed_version: result.version });
|
||||||
await load();
|
await load();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -131,67 +204,71 @@ export default function MarketplacePage() {
|
|||||||
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<div className="space-y-5">
|
<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" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
{t('返回搜索', 'Back to search')}
|
{t('返回搜索', 'Back to search')}
|
||||||
</Button>
|
</Button>
|
||||||
<Card>
|
{detailLoading ? (
|
||||||
<CardHeader>
|
<Card>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<CardContent className="flex justify-center py-16">
|
||||||
<div>
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
<div className="mb-2 flex items-center gap-2">
|
</CardContent>
|
||||||
<Badge variant="outline">@{selected.namespace}</Badge>
|
</Card>
|
||||||
{selected.installed && (
|
) : (
|
||||||
<Badge variant="secondary" className="gap-1">
|
<SkillDetailView
|
||||||
<Check className="h-3 w-3" />
|
title={selected.displayName || selected.slug}
|
||||||
{t('已安装', 'Installed')}
|
summary={selected.summary}
|
||||||
</Badge>
|
currentVersion={selectedVersion || publishedVersion(selected)}
|
||||||
)}
|
versions={versions.length > 0 ? versions : [{ version: selectedVersion || publishedVersion(selected) }]}
|
||||||
</div>
|
files={versionDetail?.files || []}
|
||||||
<CardTitle className="text-2xl">{selected.displayName || selected.slug}</CardTitle>
|
content={readmeContent || readmeFromVersion(versionDetail)}
|
||||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">{selected.summary}</p>
|
selectedFile={selectedFile}
|
||||||
</div>
|
loadingFile={fileLoading}
|
||||||
<Button onClick={installSelected} disabled={installing || detailLoading}>
|
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" />}
|
{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')}
|
{selected.installed ? t('重新安装/更新', 'Reinstall/update') : t('安装', 'Install')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</CardHeader>
|
labels={{
|
||||||
<CardContent className="space-y-4">
|
overview: t('说明', 'Overview'),
|
||||||
{detailLoading ? (
|
files: t('文件', 'Files'),
|
||||||
<div className="flex justify-center py-10">
|
versions: t('版本', 'Versions'),
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
noReadme: t('暂无说明', 'No overview available'),
|
||||||
</div>
|
noFiles: t('暂无文件', 'No files'),
|
||||||
) : (
|
selectFile: t('选择一个文件查看详情', 'Select a file to view details'),
|
||||||
<>
|
binaryFile: t('二进制文件暂不预览', 'Binary file preview is not available'),
|
||||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
current: t('当前', 'Current'),
|
||||||
<Badge variant="outline">v{publishedVersion(selected) || '-'}</Badge>
|
size: t('大小', 'Size'),
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<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;
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl p-6 space-y-6">
|
<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">
|
<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>
|
</Button>
|
||||||
</div>
|
</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 */}
|
{/* System Info */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
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 { logout } from '@/lib/api';
|
||||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
@ -15,7 +15,7 @@ import { useAppI18n } from '@/lib/i18n/provider';
|
|||||||
import { useChatStore } from '@/lib/store';
|
import { useChatStore } from '@/lib/store';
|
||||||
|
|
||||||
type NavItem = {
|
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;
|
href: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
matchPrefixes?: string[];
|
matchPrefixes?: string[];
|
||||||
@ -26,6 +26,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ key: 'tasks', href: '/tasks', icon: ListTodo, matchPrefixes: ['/tasks', '/office', '/cron'] },
|
{ key: 'tasks', href: '/tasks', icon: ListTodo, matchPrefixes: ['/tasks', '/office', '/cron'] },
|
||||||
{ key: 'notifications', href: '/notifications', icon: Bell, matchPrefixes: ['/notifications'] },
|
{ key: 'notifications', href: '/notifications', icon: Bell, matchPrefixes: ['/notifications'] },
|
||||||
{ key: 'skills', href: '/skills', icon: Puzzle },
|
{ key: 'skills', href: '/skills', icon: Puzzle },
|
||||||
|
{ key: 'files', href: '/files', icon: FolderOpen, matchPrefixes: ['/files'] },
|
||||||
{ key: 'tools', href: '/mcp', icon: Wrench, matchPrefixes: ['/mcp'] },
|
{ key: 'tools', href: '/mcp', icon: Wrench, matchPrefixes: ['/mcp'] },
|
||||||
{ key: 'agents', href: '/agents', icon: Bot, matchPrefixes: ['/agents'] },
|
{ key: 'agents', href: '/agents', icon: Bot, matchPrefixes: ['/agents'] },
|
||||||
{ key: 'outlook', href: '/outlook', icon: Mail, matchPrefixes: ['/outlook'] },
|
{ key: 'outlook', href: '/outlook', icon: Mail, matchPrefixes: ['/outlook'] },
|
||||||
@ -78,6 +79,7 @@ const Header = () => {
|
|||||||
if (key === 'tasks') return 'Task';
|
if (key === 'tasks') return 'Task';
|
||||||
if (key === 'notifications') return pickAppText(locale, '通知', 'Notifications');
|
if (key === 'notifications') return pickAppText(locale, '通知', 'Notifications');
|
||||||
if (key === 'skills') return pickAppText(locale, '技能', 'Skills');
|
if (key === 'skills') return pickAppText(locale, '技能', 'Skills');
|
||||||
|
if (key === 'files') return pickAppText(locale, '文件', 'Files');
|
||||||
if (key === 'tools') return pickAppText(locale, '工具', 'Tools');
|
if (key === 'tools') return pickAppText(locale, '工具', 'Tools');
|
||||||
if (key === 'agents') return pickAppText(locale, '智能体', 'Agents');
|
if (key === 'agents') return pickAppText(locale, '智能体', 'Agents');
|
||||||
if (key === 'outlook') return 'Outlook';
|
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,
|
Session,
|
||||||
SessionDetail,
|
SessionDetail,
|
||||||
Skill,
|
Skill,
|
||||||
|
SkillDetailResponse,
|
||||||
SkillDraft,
|
SkillDraft,
|
||||||
SkillDraftEvalReport,
|
SkillDraftEvalReport,
|
||||||
SkillDraftSafetyReport,
|
SkillDraftSafetyReport,
|
||||||
|
SkillFileContent,
|
||||||
SkillHubInstallResponse,
|
SkillHubInstallResponse,
|
||||||
SkillHubSearchItem,
|
SkillHubSearchItem,
|
||||||
SkillHubSearchResponse,
|
SkillHubSearchResponse,
|
||||||
SkillHubVersionResponse,
|
SkillHubVersionResponse,
|
||||||
|
SkillHubVersionsResponse,
|
||||||
SkillLearningCandidate,
|
SkillLearningCandidate,
|
||||||
SkillReviewRecord,
|
SkillReviewRecord,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
@ -56,6 +59,7 @@ const ACCESS_TOKEN_KEY = 'nanobot_access_token';
|
|||||||
const REFRESH_TOKEN_KEY = 'nanobot_refresh_token';
|
const REFRESH_TOKEN_KEY = 'nanobot_refresh_token';
|
||||||
const REQUEST_TIMEOUT_MS = 8000;
|
const REQUEST_TIMEOUT_MS = 8000;
|
||||||
const OUTLOOK_REQUEST_TIMEOUT_MS = 45000;
|
const OUTLOOK_REQUEST_TIMEOUT_MS = 45000;
|
||||||
|
const SKILL_LEARNING_REQUEST_TIMEOUT_MS = 120000;
|
||||||
|
|
||||||
function isBrowser(): boolean {
|
function isBrowser(): boolean {
|
||||||
return typeof window !== 'undefined';
|
return typeof window !== 'undefined';
|
||||||
@ -728,6 +732,21 @@ export async function listSkills(): Promise<Skill[]> {
|
|||||||
return fetchJSON('/api/skills');
|
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[]> {
|
export async function listSkillCandidates(status?: string): Promise<SkillLearningCandidate[]> {
|
||||||
const query = status ? `?status=${encodeURIComponent(status)}` : '';
|
const query = status ? `?status=${encodeURIComponent(status)}` : '';
|
||||||
return fetchJSON(`/api/skills/candidates${query}`);
|
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`, {
|
return fetchJSON(`/api/skills/candidates/${encodeURIComponent(candidateId)}/draft`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
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`, {
|
return fetchJSON(`/api/skills/candidates/${encodeURIComponent(candidateId)}/regenerate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
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', {
|
return fetchJSON('/api/skills/learning/run-once', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
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(
|
export async function installSkillHubSkill(
|
||||||
namespace: string,
|
namespace: string,
|
||||||
slug: string,
|
slug: string,
|
||||||
@ -1441,11 +1481,26 @@ export interface BrowseResult {
|
|||||||
items: WorkspaceItem[];
|
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> {
|
export async function browseWorkspace(path: string = ''): Promise<BrowseResult> {
|
||||||
const params = path ? `?path=${encodeURIComponent(path)}` : '';
|
const params = path ? `?path=${encodeURIComponent(path)}` : '';
|
||||||
return fetchJSON(`/api/workspace/browse${params}`);
|
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 {
|
export function getWorkspaceDownloadUrl(path: string): string {
|
||||||
return buildApiUrl(`/api/workspace/download?path=${encodeURIComponent(path)}`);
|
return buildApiUrl(`/api/workspace/download?path=${encodeURIComponent(path)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -178,9 +178,50 @@ export interface Skill {
|
|||||||
source: 'builtin' | 'workspace';
|
source: 'builtin' | 'workspace';
|
||||||
available: boolean;
|
available: boolean;
|
||||||
path: string;
|
path: string;
|
||||||
|
version?: string;
|
||||||
|
status?: string;
|
||||||
|
source_kind?: string;
|
||||||
|
tool_hints?: string[];
|
||||||
|
provenance?: Record<string, unknown>;
|
||||||
agent_cards?: 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 {
|
export interface SlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@ -385,6 +426,13 @@ export interface SkillHubSearchResponse {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillHubVersionsResponse {
|
||||||
|
items: SkillVersionRef[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SkillHubFileInfo {
|
export interface SkillHubFileInfo {
|
||||||
id?: number;
|
id?: number;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|||||||
@ -28,7 +28,7 @@ def _normalize_record(record: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"image_name",
|
"image_name",
|
||||||
"public_url",
|
"public_url",
|
||||||
"instance_root",
|
"instance_root",
|
||||||
"nanobot_home",
|
"beaver_home",
|
||||||
"config_path",
|
"config_path",
|
||||||
"auth_users_path",
|
"auth_users_path",
|
||||||
"network_name",
|
"network_name",
|
||||||
@ -43,6 +43,8 @@ def _normalize_record(record: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"api_base_url",
|
"api_base_url",
|
||||||
):
|
):
|
||||||
normalized[key] = str(record.get(key, "") or "")
|
normalized[key] = str(record.get(key, "") or "")
|
||||||
|
if not normalized["beaver_home"]:
|
||||||
|
normalized["beaver_home"] = str(record.get("nanobot_home", "") or "")
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
@ -169,7 +171,7 @@ def cmd_upsert(args: argparse.Namespace) -> int:
|
|||||||
"host_port": int(args.host_port),
|
"host_port": int(args.host_port),
|
||||||
"public_url": args.public_url,
|
"public_url": args.public_url,
|
||||||
"instance_root": args.instance_root,
|
"instance_root": args.instance_root,
|
||||||
"nanobot_home": args.nanobot_home,
|
"beaver_home": args.beaver_home,
|
||||||
"config_path": args.config_path,
|
"config_path": args.config_path,
|
||||||
"auth_users_path": args.auth_users_path,
|
"auth_users_path": args.auth_users_path,
|
||||||
"network_name": args.network_name or "",
|
"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("--host-port", required=True, type=int)
|
||||||
upsert_parser.add_argument("--public-url", required=True)
|
upsert_parser.add_argument("--public-url", required=True)
|
||||||
upsert_parser.add_argument("--instance-root", 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("--config-path", required=True)
|
||||||
upsert_parser.add_argument("--auth-users-path", required=True)
|
upsert_parser.add_argument("--auth-users-path", required=True)
|
||||||
upsert_parser.add_argument("--network-name", default="")
|
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(),
|
str(record.get("public_url", "") or "").strip(),
|
||||||
"--instance-root",
|
"--instance-root",
|
||||||
str(record.get("instance_root", "") or "").strip(),
|
str(record.get("instance_root", "") or "").strip(),
|
||||||
"--nanobot-home",
|
"--beaver-home",
|
||||||
str(record.get("nanobot_home", "") or "").strip(),
|
str(record.get("beaver_home", "") or record.get("nanobot_home", "") or "").strip(),
|
||||||
"--config-path",
|
"--config-path",
|
||||||
str(record.get("config_path", "") or "").strip(),
|
str(record.get("config_path", "") or "").strip(),
|
||||||
"--auth-users-path",
|
"--auth-users-path",
|
||||||
|
|||||||
Reference in New Issue
Block a user