refactor(beaver): 移除Hermes相关引用和迁移代码,完善Beaver后端主线实现

移除了所有Hermes相关的命名引用,包括:
- 从.gitignore中清理相关构建缓存文件
- 将README中的beaver-home路径配置更新
- 完善backend/README.md文档说明Beaver后端主线实现
- 移除Hermes风格的相关注释和兼容性代码
- 清理nanobot环境变量兼容性处理
- 删除技能迁移和服务迁移相关功能代码
- 更新测试用例中相关命名和函数名

BREAKING CHANGE: 移除了Hermes迁移相关API和CLI命令,不再支持nanobot环境变量兼容性
This commit is contained in:
2026-05-14 17:20:32 +08:00
parent b59968167e
commit 3b0af173cc
57 changed files with 245 additions and 4109 deletions

23
.gitignore vendored
View File

@ -1,12 +1,35 @@
# Runtime data generated by local Docker deployment
authz-service/runtime/data/
authz-service/src/data/
app-instance/runtime/instances/
app-instance/runtime/registry/
router-proxy/runtime/conf.d/
runtime/
sessions/
**/sessions/state.db
**/runtime/**/*.lock
# Local build / cache artifacts
**/__pycache__/
**/.pytest_cache/
**/node_modules/
**/.next/
**/.next-dev/
**/.turbo/
**/.ruff_cache/
**/.mypy_cache/
**/.cache/
**/.venv/
**/dist/
**/build/
**/*.egg-info/
**/tsconfig.tsbuildinfo
*.log
*.tmp
*.py[cod]
# Local secrets / env files
.env
*.env
*.pem
app-instance/frontend/.env_prod

View File

@ -377,7 +377,7 @@ http://alice.203.0.113.10.nip.io:8088
写到每个实例自己的:
```text
app-instance/runtime/instances/<slug>/nanobot-home/config.json
app-instance/runtime/instances/<slug>/beaver-home/config.json
```
不是写在 AuthZ 的某个 setting 里。

View File

@ -1,8 +1,6 @@
# Beaver Backend
这是新的 `Beaver` 后端。
旧实现已保留在 [backend-old](/home/ivan/xuan/nano_project/app-instance/backend-old),新目录用于按 [change.md](/home/ivan/xuan/nano_project/app-instance/backend/change.md) 的蓝图逐步重建后端。
这是 `Beaver` 后端。
当前已经落地的主线:
@ -28,11 +26,4 @@
## 说明
这个目录已经不是空骨架,但仍不等于完成迁移
后续迁移原则:
1. 不再新增 `nanobot` 命名。
2. 不在新目录中保留 `third_party/`
3. 所有 agent 最终都复用 `beaver.engine`
4. 高级 team 策略先编译成 Beaver 自有 `ExecutionGraph`,不直接暴露 swarms runtime。
后端已切到 Beaver 主线不再保留旧实现、vendored 第三方 runtime 或迁移期旧命名兼容入口。所有 agent 运行都复用 `beaver.engine`,多 agent 协调通过 Beaver 自有 coordinator 和 `ExecutionGraph` 表达

View File

@ -16,7 +16,7 @@
1. 先服务单 agent 主链
2. 先支持 frozen curated memory而不是 live memory
3. skills 按 Hermes 风格支持“显式激活消息注入,不在这里做磁盘扫描
3. skills 通过显式激活消息注入,不在这里做磁盘扫描
4. 为后续 channel / gateway / team metadata 预留注入位,但不提前做复杂逻辑
"""
@ -45,7 +45,7 @@ class SkillContext:
- `name`:用于生成激活提示
- `content`skill 的完整正文
注意:按当前 Hermes 风格实现,skill 正文不再塞进 system prompt而是转成显式消息注入。
注意skill 正文不再塞进 system prompt而是转成显式消息注入。
"""
name: str
@ -151,7 +151,7 @@ class ContextBuilder:
- 身份与总规则要最靠前
- session/execution 是本轮运行语境,优先级高于长期记忆
- memory 必须是 frozen snapshot避免中途写 memory 后 prompt 失真
- activated skill 正文按 Hermes 风格放到显式消息里,避免 system prompt 持续膨胀
- activated skill 正文放到显式消息里,避免 system prompt 持续膨胀
"""
sections: list[str] = [BEAVER_USER_ASSISTANT_IDENTITY_PROMPT]
@ -190,7 +190,7 @@ class ContextBuilder:
这里做三件事:
1. 先生成最终 system prompt
2. 按 Hermes 风格,把已激活 skill 的完整正文作为显式消息注入
2. 把已激活 skill 的完整正文作为显式消息注入
3. 把历史消息按原顺序接到后面
4. 如果存在当前用户输入,则把本轮输入追加为最后一条 user message
@ -348,7 +348,7 @@ class ContextBuilder:
return "# Current Session\n\n" + "\n".join(rows)
def build_skill_activation_messages(self, activated_skills: list[SkillContext]) -> list[dict[str, str]]:
"""按 Hermes 风格把已激活 skill 转成显式消息。
"""把已激活 skill 转成显式消息。
关键区别:
- system prompt 只保留轻量 skills index

View File

@ -1,4 +1,4 @@
"""Hermes 风格的 provider runtime resolution"""
"""Provider runtime resolution for Beaver."""
from __future__ import annotations
@ -165,7 +165,7 @@ def resolve_fallback_runtime(
) -> ProviderRuntime | None:
"""把 fallback 配置解析成独立 runtime。
Hermes 的 fallback 是“主 provider 失败后切换到另一个 provider:model”。
fallback 的语义是“主 provider 失败后切换到另一个 provider:model”。
这里先把 fallback 解析独立出来,具体何时激活交给上层 chain/factory。
"""

View File

@ -1,6 +1,6 @@
"""Beaver session 子系统的 SQLite 存储实现。
设计来源主要参考 Hermes-agent
设计目标
1. SQLite 作为统一 session/transcript backend
2. WAL 模式支持多读单写
3. FTS5 支持跨 session 文本检索

View File

@ -35,14 +35,12 @@ def default_config_path(*, workspace: str | Path | None = None) -> Path:
Priority:
1. `BEAVER_CONFIG_PATH`
2. `NANOBOT_CONFIG_PATH` for compatibility during migration
3. `BEAVER_HOME/config.json`
4. `NANOBOT_HOME/config.json` for migration compatibility
5. `<workspace>/.beaver/config.json`
6. `./.beaver/config.json`
2. `BEAVER_HOME/config.json`
3. `<workspace>/.beaver/config.json`
4. `./.beaver/config.json`
"""
explicit = os.getenv("BEAVER_CONFIG_PATH") or os.getenv("NANOBOT_CONFIG_PATH")
explicit = os.getenv("BEAVER_CONFIG_PATH")
if explicit:
return Path(explicit).expanduser()
@ -50,10 +48,6 @@ def default_config_path(*, workspace: str | Path | None = None) -> Path:
if beaver_home:
return Path(beaver_home).expanduser() / "config.json"
nanobot_home = os.getenv("NANOBOT_HOME")
if nanobot_home:
return Path(nanobot_home).expanduser() / "config.json"
root = Path(workspace).expanduser() if workspace is not None else Path.cwd()
return root / ".beaver" / "config.json"

View File

@ -1,8 +1,7 @@
"""Scheduled task models for Beaver cron.
The scheduler borrows Hermes' durable JSON + explicit schedule parsing shape,
but the execution target is Beaver Task mode: every trigger creates a normal
Task run instead of a detached agent turn.
Every trigger targets Beaver Task mode so scheduled work remains visible as a
normal Task instead of a detached agent turn.
"""
from __future__ import annotations

View File

@ -19,7 +19,7 @@ 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_SERVER_ID = os.getenv("BEAVER_OUTLOOK_MCP_SERVER_ID", "outlook_mcp")
OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8
OUTLOOK_OVERVIEW_EVENT_LIMIT = 20
OUTLOOK_MAX_PAGE_SIZE = 100
@ -31,11 +31,11 @@ class OutlookIntegrationError(RuntimeError):
@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"
domain: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_DOMAIN", "")
service_endpoint: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_EWS_URL", "")
server: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_EWS_SERVER", "")
default_timezone: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_TIMEZONE", "Asia/Shanghai")
autodiscover: bool = os.getenv("BEAVER_OUTLOOK_DEFAULT_AUTODISCOVER", "0") == "1"
@dataclass(frozen=True)
@ -71,7 +71,7 @@ OUTLOOK_TOOL_NAMES = [
def _call_timeout_seconds() -> float:
raw = os.getenv("NANOBOT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip()
raw = os.getenv("BEAVER_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip()
try:
return max(1.0, float(raw)) if raw else 10.0
except ValueError:
@ -108,8 +108,8 @@ 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()),
"mcp_command": os.getenv("BEAVER_OUTLOOK_MCP_COMMAND", "bw-outlook-mcp"),
"mcp_extra_args": shlex.split(os.getenv("BEAVER_OUTLOOK_MCP_EXTRA_ARGS", "").strip()),
"fields": asdict(OutlookDefaults()),
}

View File

@ -1,7 +1,5 @@
"""CLI entry for Beaver."""
from pathlib import Path
try:
import typer
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
@ -29,8 +27,6 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
typer = _FallbackTyper() # type: ignore[assignment]
from beaver.services.agent_service import AgentService
from beaver.services.hermes_migration import HermesMigrationService
from beaver.skills.specs import SkillSpecStore
app = typer.Typer(help="Beaver backend CLI") if hasattr(typer, "Typer") else typer
@ -59,26 +55,6 @@ def run(
typer.echo(result.output_text)
@app.command("migrate-hermes")
def migrate_hermes(
repo: str = typer.Option(..., "--repo", help="Local checkout of https://github.com/NousResearch/hermes-agent."),
workspace: str | None = typer.Option(None, "--workspace", help="Workspace root to import skills into."),
manifest: str | None = typer.Option(None, "--manifest", help="Path for hermes_migration_manifest.json."),
dry_run: bool = typer.Option(False, "--dry-run", help="Only write the manifest without importing skills."),
) -> None:
"""Import no-credential Hermes Agent skills and write a manifest."""
service = AgentService(workspace=workspace)
loaded = service.create_loop().boot()
store = loaded.skill_spec_store or SkillSpecStore(loaded.workspace)
migration = HermesMigrationService(store, manifest_path=Path(manifest) if manifest else None)
result = migration.migrate(repo, dry_run=dry_run)
typer.echo(
f"Hermes migration complete: {len(result['included'])} included, "
f"{len(result['skipped'])} skipped."
)
def main() -> None:
"""Project script entrypoint."""
app()

View File

@ -58,7 +58,7 @@ LOCAL_TOOL_CATEGORIES = {
def _workspace_path(value: str | None = None) -> Path:
raw = value or os.getenv("BEAVER_WORKSPACE") or os.getenv("NANOBOT_WORKSPACE")
raw = value or os.getenv("BEAVER_WORKSPACE")
if raw:
return Path(raw).expanduser().resolve()
return Path.cwd()

View File

@ -23,7 +23,6 @@ from beaver.foundation.models import CronExecutionResult, CronRunRecord
from beaver.integrations.mcp import MCPConnectionManager
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
from beaver.services.cron_service import CronService, schedule_from_api
from beaver.services.skill_migration import SkillMigrationService
from beaver.services.skillhub_service import SkillHubService
from beaver.skills.learning import SkillLearningWorker, SkillLearningWorkerConfig
from beaver.skills.catalog.utils import parse_frontmatter
@ -305,7 +304,7 @@ def create_app(
)
app.state.auth_tokens = {}
app.state.handoff_codes = {}
app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or os.getenv("NANOBOT_AUTH_FILE") or "")
app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or "")
max_file_size = 50 * 1024 * 1024
@app.get("/api/ping", response_model=WebStatusResponse)
@ -427,7 +426,6 @@ def create_app(
_clean_text(payload.get("base_url"))
or config.backend_identity.public_base_url
or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL")
or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL")
or str(request.base_url).rstrip("/")
)
frontend_base_url = _clean_text(payload.get("frontend_base_url")) or public_base_url
@ -526,7 +524,7 @@ def create_app(
return {
"id": username,
"username": username,
"email": os.getenv("BEAVER_BACKEND_IDENTITY__EMAIL") or os.getenv("NANOBOT_BACKEND_IDENTITY__EMAIL", ""),
"email": os.getenv("BEAVER_BACKEND_IDENTITY__EMAIL", ""),
"role": "owner",
"quota_tier": "single-user",
}
@ -1184,30 +1182,6 @@ def create_app(
)
return result
@app.get("/api/tools/servers")
async def list_tool_servers(request: Request) -> list[dict[str, Any]]:
return await list_mcp_servers(request)
@app.get("/api/tools")
async def list_tools(request: Request) -> dict[str, Any]:
servers = await list_mcp_servers(request)
tool_groups = await list_mcp_tools(request)
server_map = {server["id"]: server for server in servers}
grouped = {"local": [], "online": []}
for group in tool_groups:
server = server_map.get(group["server_id"], {})
kind = str(server.get("kind") or "online")
item = {
**group,
"server_name": server.get("name") or group["server_id"],
"transport": server.get("transport"),
"kind": kind,
"category": server.get("category") or kind,
"status": server.get("status"),
}
grouped["local" if kind == "local" else "online"].append(item)
return {"servers": servers, "groups": grouped}
@app.get("/api/skills")
async def list_skills(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
@ -1301,19 +1275,6 @@ def create_app(
raise HTTPException(status_code=400, detail=str(exc)) from exc
return draft
@app.post("/api/skills/migrate")
async def migrate_skills(request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
return SkillMigrationService(loaded.skill_spec_store).migrate_all() # type: ignore[arg-type]
@app.get("/api/skills/migration-manifest")
async def get_skill_migration_manifest(request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
path = loaded.workspace / "skill_migration_manifest.json"
if not path.exists():
return {"included": [], "skipped": []}
return json.loads(path.read_text(encoding="utf-8"))
@app.get("/api/marketplaces/skills/search")
async def search_skillhub(
request: Request,
@ -2482,7 +2443,7 @@ def _provider_enabled(provider_name: str, provider_cfg: Any) -> bool:
def _auth_file_path() -> Path:
raw = os.getenv("BEAVER_AUTH_FILE") or os.getenv("NANOBOT_AUTH_FILE")
raw = os.getenv("BEAVER_AUTH_FILE")
if raw:
return Path(raw)
return Path.home() / ".beaver" / "web_auth_users.json"
@ -2542,7 +2503,7 @@ def _issue_web_token(app: FastAPI, username: str) -> str:
def _handoff_ttl_seconds() -> int:
raw = os.getenv("NANOBOT_HANDOFF_CODE_TTL_SECONDS", "90").strip()
raw = os.getenv("BEAVER_HANDOFF_CODE_TTL_SECONDS", "90").strip()
try:
return max(15, int(raw))
except ValueError:
@ -2550,7 +2511,7 @@ def _handoff_ttl_seconds() -> int:
def _handoff_replay_window_seconds() -> int:
raw = os.getenv("NANOBOT_HANDOFF_REPLAY_WINDOW_SECONDS", "15").strip()
raw = os.getenv("BEAVER_HANDOFF_REPLAY_WINDOW_SECONDS", "15").strip()
try:
return max(1, int(raw))
except ValueError:
@ -2637,22 +2598,18 @@ def _require_web_user(app: FastAPI, authorization: str | None) -> str:
def _backend_connection_view(request: Request) -> dict[str, Any]:
public_base_url = (
os.getenv("BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL")
or os.getenv("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL")
or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL")
or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL")
or str(request.base_url).rstrip("/")
)
backend_id = (
os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID")
or os.getenv("NANOBOT_BACKEND_IDENTITY__BACKEND_ID")
or os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID")
or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID")
)
client_id = os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID") or backend_id
client_id = os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID") or backend_id
return {
"backend_id": backend_id,
"client_id": client_id,
"name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME") or os.getenv("NANOBOT_BACKEND_IDENTITY__NAME") or backend_id,
"name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME") or backend_id,
"public_base_url": public_base_url,
"api_base_url": public_base_url,
"frontend_base_url": public_base_url,
@ -2663,16 +2620,14 @@ def _backend_connection_view(request: Request) -> dict[str, Any]:
def _local_backend_view() -> dict[str, Any]:
return {
"backend_id": os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__BACKEND_ID"),
"client_id": os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID"),
"name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME") or os.getenv("NANOBOT_BACKEND_IDENTITY__NAME"),
"backend_id": os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID"),
"client_id": os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID"),
"name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME"),
"public_base_url": os.getenv("BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL")
or os.getenv("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL")
or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL")
or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL"),
or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL"),
"authz": {
"enabled": (os.getenv("BEAVER_AUTHZ__ENABLED") or os.getenv("NANOBOT_AUTHZ__ENABLED", "")).strip() in {"1", "true", "True"},
"base_url": os.getenv("BEAVER_AUTHZ__BASE_URL") or os.getenv("NANOBOT_AUTHZ__BASE_URL"),
"enabled": os.getenv("BEAVER_AUTHZ__ENABLED", "").strip() in {"1", "true", "True"},
"base_url": os.getenv("BEAVER_AUTHZ__BASE_URL"),
},
}

View File

@ -1,6 +1,6 @@
"""Beaver 的精炼长期记忆存储层。
这个文件实现的是以 Hermes-agent 为基线的 curated memory 模型,目标不是
这个文件实现的是 Beaver curated memory 模型,目标不是
“把所有历史都存下来”,而是只保存跨会话仍然值得保留的稳定事实。
核心设计:

View File

@ -38,10 +38,9 @@ _MAX_HISTORY = 20
class CronService:
"""Persistent single-timer scheduler.
Hermes' cron implementation stores jobs as JSON and ticks safely in the
background. Beaver keeps that shape, but the callback is required to route
agent work through Task mode so every scheduled trigger is visible as a
normal Task.
Jobs are stored as JSON and ticked safely in the background. The callback
routes agent work through Task mode so every scheduled trigger is visible as
a normal Task.
"""
def __init__(self, store_path: str | Path, *, on_job: CronCallback | None = None) -> None:

View File

@ -43,7 +43,7 @@ class MemoryService:
def reload_for_new_run(self) -> None:
"""每次新 run 开始前刷新 live state。
这是 Hermes 风格 memory policy 的关键点:
这是 Beaver memory policy 的关键点:
- 上一次会话中通过 tool 写入的持久记忆,下一次运行应该能看到
- 但同一次 run 中途写入的新记忆,不应反向修改当前 frozen snapshot
"""

View File

@ -237,7 +237,7 @@ class SkillsLoader:
def build_skills_summary(self) -> str:
"""构建可注入 system prompt 的 skills index。
虽然函数名还沿用 `summary`,但当前语义已经更接近 Hermes 的 skills index
虽然函数名还沿用 `summary`,但当前语义是轻量 skills index
- 这里只告诉模型“系统里有哪些 skill 可用”
- 不负责把 skill 正文塞进 system prompt
- 真正激活的 skill 正文由 resolver/builder 走显式消息注入

View File

@ -87,9 +87,7 @@ def strip_frontmatter(content: str) -> str:
def parse_skill_metadata_blob(raw: str) -> dict[str, Any]:
"""解析 metadata 字段里的 JSON 扩展配置。
为了兼容旧 nanobot 习惯,这里同时支持:
- `nanobot`
- `openclaw`
Supports plain metadata objects and the current `openclaw` namespace.
第一版主要关心的字段有:
- `always`
@ -103,7 +101,7 @@ def parse_skill_metadata_blob(raw: str) -> dict[str, Any]:
if not isinstance(data, dict):
return {}
nested = data.get("nanobot", data.get("openclaw", data))
nested = data.get("openclaw", data)
return nested if isinstance(nested, dict) else {}

View File

@ -20,6 +20,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import inspect
import json
from typing import Any
@ -181,9 +182,21 @@ class ObjectBackedTool(BaseTool):
arguments["current_session_id"] = context.session_id
if "workspace" not in arguments and hasattr(self.backend, "workspace"):
arguments["workspace"] = context.workspace
if "metadata" not in arguments:
if "metadata" not in arguments and self._backend_accepts_argument("metadata"):
arguments["metadata"] = context.metadata
def _backend_accepts_argument(self, name: str) -> bool:
try:
signature = inspect.signature(self.backend.execute)
except (TypeError, ValueError):
return False
for parameter in signature.parameters.values():
if parameter.kind == inspect.Parameter.VAR_KEYWORD:
return True
if parameter.name == name:
return True
return False
@staticmethod
def _normalize_output(content: Any) -> dict[str, Any]:
"""把后端工具返回值转成统一 success/content/error 语义。

View File

@ -33,7 +33,7 @@ CRON_TOOL_PARAMETERS: dict[str, Any] = {
},
"schedule": {
"type": "string",
"description": "Hermes-style schedule, for example 'every 15m', '0 9 * * *', or an ISO datetime.",
"description": "Schedule expression, for example 'every 15m', '0 9 * * *', or an ISO datetime.",
},
"every_seconds": {
"type": "integer",

View File

@ -59,7 +59,7 @@ def memory_tool(
old_text: str | None = None,
store: MemoryStore | None = None,
) -> str:
"""分发 Hermes 风格的 CRUD memory API并返回 JSON 字符串。
"""分发 CRUD memory API并返回 JSON 字符串。
这里统一采用 JSON 返回,是为了兼容常见 tool-calling 场景:
- LLM 更容易消费结构化结果

View File

@ -1,6 +1,6 @@
"""Beaver 内置 session_search tool。
这个工具对应 Hermes-agent 的跨会话检索能力,目标不是把所有历史内容塞回主上下文,
这个工具提供跨会话检索能力,目标不是把所有历史内容塞回主上下文,
而是按需从过去的 session 中找回“之前发生过什么”。
当前实现保留了几个关键行为:
@ -28,7 +28,7 @@ class SessionSearchDB(Protocol):
"""session_search 依赖的最小数据库契约。
这里没有直接绑定某个具体 SQLite 实现,而是先定义行为接口。
这样后面无论你接的是 Hermes 风格 state DB、还是 Beaver 自己的 transcript store
这样后面无论你接的是当前 SQLite state DB、还是其他 transcript store
只要满足这些方法就能工作。
"""

View File

@ -1,6 +1,6 @@
"""Beaver 内置 skill_view tool。
这个工具对应 Hermes 风格的显式 skill loading path
这个工具对应显式 skill loading path
1. skill 正文默认不会长期塞进 system prompt
2. 模型若想查看某个 skill 的完整正文或支持文件,必须显式调用 `skill_view`

View File

@ -1,5 +1,4 @@
import json
from pathlib import Path
from beaver.engine import AgentLoop, EngineLoader
from beaver.engine.providers import make_provider_bundle
@ -139,9 +138,36 @@ def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
assert bundle.main_provider._resolve_model("qwen-plus") == "openai/qwen-plus"
def test_load_config_reads_stevenli_mcp_authz_identity() -> None:
repo_root = Path(__file__).resolve().parents[4]
config_path = repo_root / "app-instance" / "runtime" / "instances" / "stevenli" / "nanobot-home" / "config.json"
def test_load_config_reads_mcp_authz_identity(tmp_path) -> None:
config_path = tmp_path / "beaver-home" / "config.json"
config_path.parent.mkdir()
config_path.write_text(
json.dumps(
{
"tools": {
"mcpServers": {
"outlook_mcp": {
"url": "http://10.6.80.29:8000/mcp",
"authMode": "oauth_backend_token",
"authAudience": "mcp:outlook_mcp",
"authScopes": ["list_tools", "tool:mail_list_messages"],
"toolTimeout": 60,
"sensitive": True,
}
}
},
"authz": {
"enabled": True,
"baseUrl": "http://nano-authz-service:19090",
},
"backend_identity": {
"backend_id": "stevenli",
"client_id": "stevenli",
},
}
),
encoding="utf-8",
)
config = load_config(config_path=config_path)
server = config.tools.mcp_servers["outlook_mcp"]

View File

@ -6,7 +6,7 @@ from beaver.tools.builtins import CronTool
from beaver.services.cron_service import CronService, compute_next_run, parse_schedule, schedule_from_api
def test_parse_hermes_style_schedules() -> None:
def test_parse_schedule_expressions() -> None:
interval = parse_schedule("every 15m")
assert interval.kind == "every"
assert interval.every_ms == 15 * 60 * 1000

View File

@ -1,13 +1,11 @@
import asyncio
import io
import json
import zipfile
from types import SimpleNamespace
import pytest
from beaver.interfaces.web.app import _create_skill_upload_draft
from beaver.services.hermes_migration import HermesMigrationService
from beaver.services.skillhub_service import SkillHubService
from beaver.skills.drafts import DraftService
from beaver.skills.specs import SkillSpecStore
@ -101,27 +99,6 @@ def test_upload_skill_zip_keeps_supporting_files_on_draft(tmp_path):
assert upload_dir.endswith(draft["draft_id"])
def test_hermes_migration_manifest_includes_no_credential_skill_and_skips_api_skill(tmp_path):
repo = tmp_path / "hermes"
safe = repo / "skills" / "safe"
unsafe = repo / "skills" / "unsafe"
safe.mkdir(parents=True)
unsafe.mkdir(parents=True)
safe.joinpath("SKILL.md").write_text("---\nname: safe\n---\nUse local files only.\n", encoding="utf-8")
unsafe.joinpath("SKILL.md").write_text("---\nname: unsafe\n---\nRequires API_KEY.\n", encoding="utf-8")
store = SkillSpecStore(tmp_path / "workspace")
manifest = HermesMigrationService(store).migrate(repo)
included = {item["skill_name"] for item in manifest["included"]}
skipped = {item.get("skill_name"): item["reason"] for item in manifest["skipped"]}
assert "safe" in included
assert skipped["unsafe"] == "requires_external_credentials"
assert store.get_skill_spec("safe") is not None
manifest_path = tmp_path / "workspace" / "hermes_migration_manifest.json"
assert json.loads(manifest_path.read_text(encoding="utf-8"))["source"] == "hermes-agent"
def test_mcp_wrapper_metadata_preserves_server_id_with_underscores():
tool_def = SimpleNamespace(name="auth_status", description="Auth", inputSchema={"type": "object", "properties": {}})

View File

@ -53,10 +53,9 @@
| `/status` | 系统状态 |
| `/cron` | 定时任务 |
| `/skills` | 技能管理 |
| `/plugins` | 插件管理 |
| `/agents` | 智能体管理 |
| `/mcp` | MCP 服务管理 |
| `/marketplace` | 插件市场 |
| `/marketplace` | 技能市场 |
| `/files` | 工作区文件管理 |
| `/help` | 帮助说明 |
@ -240,15 +239,9 @@ docker build \
- 状态接口
- WebSocket 连接
### 2. 命令名和目录名未做品牌迁移
### 2. 技术标识
当前仓库的部分技术标识仍沿用旧命名,例如:
- `nanobot web`
- `~/.beaver/plugins/`
- 本地存储中的旧 token key
这些属于兼容性和后端约定的一部分,前端展示品牌已替换为 `Boardware Genius`,但技术标识没有在这个仓库里强制迁移。
当前前端使用 Beaver 技术命名,本地 token、语言和 handoff 状态都使用 `beaver_*` key。
### 3. 动态内容可能仍包含英文

View File

@ -149,7 +149,6 @@ export default function NotificationDetailPage() {
processArtifacts={[]}
selectedRunId={null}
onSelectRun={() => {}}
onCancelRun={() => {}}
onFeedback={() => {}}
/>
</div>

View File

@ -7,7 +7,6 @@ import { Brain, Plus, Send, Trash2, X } from 'lucide-react';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
cancelDelegation,
archiveSession,
createSession,
getActiveTask,
@ -464,18 +463,6 @@ export default function ChatPage() {
setSessionId(key);
};
const handleCancelRun = useCallback(async (runId: string) => {
try {
await cancelDelegation(runId);
} catch (err: any) {
addMessage({
role: 'assistant',
content: pickAppText(locale, `取消任务 ${runId} 失败:${err.message || '未知错误'}`, `Failed to cancel task ${runId}: ${err.message || 'Unknown error'}`),
timestamp: new Date().toISOString(),
});
}
}, [addMessage, locale]);
const removePendingFile = useCallback((file: File) => {
setPendingFiles((prev) => prev.filter((item) => item.file !== file));
}, []);
@ -566,7 +553,6 @@ export default function ChatPage() {
processArtifacts={sessionProcessArtifacts}
selectedRunId={selectedSessionRunId}
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
onCancelRun={handleCancelRun}
onFeedback={handleFeedback}
/>
</div>

View File

@ -40,7 +40,6 @@ import {
listSkillCandidates,
listSkillDrafts,
listSkills,
migrateSkills,
publishSkillDraft,
regenerateSkillDraft,
rejectSkillDraft,
@ -207,15 +206,6 @@ export default function SkillsPage() {
<Upload className="mr-2 h-4 w-4" />
{t('上传技能', 'Upload skill')}
</Button>
<Button
onClick={() => void runAction('migrate-skills', () => migrateSkills())}
variant="outline"
size="sm"
disabled={Boolean(actionId)}
>
{actionId === 'migrate-skills' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Rocket className="mr-2 h-4 w-4" />}
{t('迁移旧技能', 'Migrate legacy skills')}
</Button>
</div>
</div>

View File

@ -15,17 +15,7 @@ import {
Settings2,
ScrollText,
} from 'lucide-react';
import { getStatus, restartSystem, updateProviderConfig } from '@/lib/api';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { getStatus, updateProviderConfig } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@ -57,9 +47,6 @@ export default function StatusPage() {
const [status, setStatus] = useState<SystemStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const [selectedProvider, setSelectedProvider] = useState<ProviderStatus | null>(null);
const [providerForm, setProviderForm] = useState<ProviderFormState>(() => ({
enabled: false,
@ -88,36 +75,6 @@ export default function StatusPage() {
loadStatus();
}, []);
useEffect(() => {
if (!restarting) {
return;
}
const intervalId = window.setInterval(async () => {
try {
await getStatus();
window.location.reload();
} catch {
// Ignore failures until the container is back.
}
}, 3000);
return () => {
window.clearInterval(intervalId);
};
}, [restarting]);
const handleRestart = async () => {
setRestartError(null);
try {
await restartSystem();
setRestartDialogOpen(false);
setRestarting(true);
} catch (err: any) {
setRestartError(err.message || pickAppText(locale, '重启失败', 'Restart failed'));
}
};
const openProviderDialog = (provider: ProviderStatus) => {
setSelectedProvider(provider);
setProviderError(null);
@ -204,7 +161,7 @@ export default function StatusPage() {
)}
</p>
</div>
<Button onClick={loadStatus} variant="outline" size="sm" disabled={restarting}>
<Button onClick={loadStatus} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
@ -223,13 +180,8 @@ export default function StatusPage() {
<div className="space-y-1">
<p className="text-sm font-medium">{pickAppText(locale, '运行与调试', 'Runtime and debugging')}</p>
<p className="text-sm text-muted-foreground">
{restarting
? pickAppText(locale, '正在重启当前 docker服务恢复后页面会自动刷新。', 'Restarting the current Docker container. The page will refresh automatically once the service is back.')
: pickAppText(locale, '查看每次对话的运行日志,或重启当前 docker 容器。重启完成后需要重新登录。', 'Inspect per-chat runtime logs or restart the current Docker container. You will need to sign in again afterwards.')}
{pickAppText(locale, '查看每次对话的运行日志和当前实例运行状态。', 'Inspect per-chat runtime logs and current instance status.')}
</p>
{restartError ? (
<p className="text-sm text-destructive">{restartError}</p>
) : null}
</div>
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
<Button asChild variant="outline">
@ -238,34 +190,6 @@ export default function StatusPage() {
{pickAppText(locale, '运行日志', 'Runtime Logs')}
</Link>
</Button>
<AlertDialog open={restartDialogOpen} onOpenChange={setRestartDialogOpen}>
<Button
variant="destructive"
onClick={() => setRestartDialogOpen(true)}
disabled={restarting}
>
{restarting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
Restart
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{pickAppText(locale, '确认重启当前实例?', 'Restart the current instance?')}</AlertDialogTitle>
<AlertDialogDescription>
{pickAppText(locale, '这会重启当前 docker 容器,页面会短暂不可用。由于当前登录态保存在内存里,重启完成后需要重新登录。', 'This restarts the current Docker container and the page will be temporarily unavailable. Because the current sign-in state is stored in memory, you will need to sign in again after the restart.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={restarting}>{pickAppText(locale, '取消', 'Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleRestart} disabled={restarting}>
{restarting ? pickAppText(locale, '重启中...', 'Restarting...') : pickAppText(locale, '确认重启', 'Confirm restart')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="mt-5 grid gap-3 border-t pt-5 md:grid-cols-2">

View File

@ -3,21 +3,21 @@
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import React, { useMemo, useState } from 'react';
import { AlertCircle, ArrowLeft, Bot, CheckCircle2, Download, FileText, HelpCircle, MessageSquare, RefreshCw, RotateCcw, Trash2, User, XCircle } from 'lucide-react';
import { AlertCircle, ArrowLeft, Bot, CheckCircle2, Download, FileText, HelpCircle, MessageSquare, RefreshCw, Trash2, User, XCircle } from 'lucide-react';
import { OfficeStatusBadge, formatOfficeDuration, formatOfficeTime, progressPercent } from '@/components/office/OfficeShared';
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime, progressPercent } from '@/components/task-runtime/TaskRuntimeShared';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { cancelDelegation, deleteBackendTask, getBackendTask, getFileUrl, retryDelegation, submitChatFeedback } from '@/lib/api';
import { deleteBackendTask, getBackendTask, getFileUrl, submitChatFeedback } from '@/lib/api';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { buildOfficeView, isOfficeTaskTerminal, type OfficeTaskView } from '@/lib/office';
import { buildTaskRuntimeView, type TaskRuntimeNodeView } from '@/lib/task-runtime';
import { useChatStore } from '@/lib/store';
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent } from '@/types';
function taskVisibleStatus(task: OfficeTaskView, locale: 'zh-CN' | 'en-US') {
function taskVisibleStatus(task: TaskRuntimeNodeView, locale: 'zh-CN' | 'en-US') {
if (task.status === 'error') return pickAppText(locale, '任务失败', 'Task failed');
if (task.status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
return task.stageLabel || task.status;
@ -46,7 +46,7 @@ export default function TaskDetailPage() {
const updateMessageFeedback = useChatStore((state) => state.updateMessageFeedback);
const task = useMemo(
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale),
() => buildTaskRuntimeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale),
[locale, processArtifacts, processEvents, processRuns, sessions, taskId]
);
const [backendTask, setBackendTask] = useState<BackendTask | null>(null);
@ -105,7 +105,7 @@ export default function TaskDetailPage() {
return map;
}, [artifacts]);
const phaseGroups = useMemo(() => {
const groups = new Map<string, OfficeTaskView[]>();
const groups = new Map<string, TaskRuntimeNodeView[]>();
for (const item of task?.tasks ?? []) {
const label = item.stageLabel || taskVisibleStatus(item, locale);
groups.set(label, [...(groups.get(label) ?? []), item]);
@ -180,7 +180,7 @@ export default function TaskDetailPage() {
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{pickAppText(locale, '来源会话', 'Session')}: {backendTask.session_id}</span>
<span>{pickAppText(locale, '创建者', 'Creator')}: {backendTask.creator}</span>
<span>{pickAppText(locale, '更新', 'Updated')}: {formatOfficeTime(backendTask.updated_at, locale)}</span>
<span>{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(backendTask.updated_at, locale)}</span>
</div>
</CardContent>
</Card>
@ -242,7 +242,7 @@ export default function TaskDetailPage() {
<div key={index} className="rounded-md border border-border p-3">
<div className="font-medium">{humanFeedback(String(item.feedback_type || ''), locale)}</div>
{item.comment ? <p className="mt-1 text-muted-foreground">{String(item.comment)}</p> : null}
{item.created_at ? <p className="mt-1 text-xs text-muted-foreground">{formatOfficeTime(String(item.created_at), locale)}</p> : null}
{item.created_at ? <p className="mt-1 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(item.created_at), locale)}</p> : null}
</div>
))
)}
@ -295,25 +295,6 @@ export default function TaskDetailPage() {
</Link>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={Boolean(actionBusy) || isOfficeTaskTerminal(task.status)}
onClick={() => void runAction('cancel', () => cancelDelegation(task.rootRunId))}
>
<XCircle className="mr-2 h-4 w-4" />
{pickAppText(locale, '取消任务', 'Cancel task')}
</Button>
<Button
size="sm"
disabled={Boolean(actionBusy) || !isOfficeTaskTerminal(task.status)}
onClick={() => void runAction('retry', () => retryDelegation(task.rootRunId))}
>
<RotateCcw className="mr-2 h-4 w-4" />
{pickAppText(locale, '重试任务', 'Retry task')}
</Button>
</div>
</div>
<Card>
@ -322,20 +303,20 @@ export default function TaskDetailPage() {
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 className="truncate text-2xl font-semibold">{task.title}</h1>
<OfficeStatusBadge status={task.status} />
<TaskRuntimeStatusBadge status={task.status} />
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{pickAppText(locale, '来源会话', 'Session')}: {task.sourceSessionLabel}</span>
<span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {task.rootActorName}</span>
<span>{pickAppText(locale, '开始', 'Started')}: {formatOfficeTime(task.createdAt, locale)}</span>
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatOfficeDuration(task.durationMs, locale)}</span>
<span>{pickAppText(locale, '开始', 'Started')}: {formatTaskRuntimeTime(task.createdAt, locale)}</span>
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(task.durationMs, locale)}</span>
</div>
</div>
<div className="grid w-full gap-3 sm:grid-cols-4 lg:w-[520px]">
<Metric label={pickAppText(locale, '节点', 'Nodes')} value={String(task.stats.totalRuns)} />
<Metric label={pickAppText(locale, '活跃', 'Active')} value={String(task.stats.activeRuns)} />
<Metric label={pickAppText(locale, '产物', 'Artifacts')} value={String(task.stats.artifactCount)} />
<Metric label={pickAppText(locale, '异常', 'Alerts')} value={String(task.alerts.length)} />
<Metric label={pickAppText(locale, '异常', 'Alerts')} value={String(task.stats.alertCount)} />
</div>
</div>
<div className="mt-5 space-y-2">
@ -398,7 +379,7 @@ export default function TaskDetailPage() {
<div className="truncate font-medium">{node.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{node.actorName}</div>
</div>
<OfficeStatusBadge status={node.status} />
<TaskRuntimeStatusBadge status={node.status} />
</div>
<div className="mt-3 text-sm text-muted-foreground">
{node.summary || taskVisibleStatus(node, locale)}
@ -426,13 +407,13 @@ export default function TaskDetailPage() {
<div className="font-medium">{selectedNode.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{selectedNode.runId}</div>
</div>
<OfficeStatusBadge status={selectedNode.status} />
<TaskRuntimeStatusBadge status={selectedNode.status} />
<p className="text-sm text-muted-foreground">{selectedNode.summary || '-'}</p>
<div className="space-y-2">
{(eventsByRun.get(selectedNode.runId) ?? []).slice(-5).map((event) => (
<div key={event.event_id} className="rounded-md border border-border bg-muted/30 p-2 text-xs">
<div className="font-medium">{event.kind}</div>
<div className="mt-1 text-muted-foreground">{event.text || formatOfficeTime(event.created_at, locale)}</div>
<div className="mt-1 text-muted-foreground">{event.text || formatTaskRuntimeTime(event.created_at, locale)}</div>
</div>
))}
</div>
@ -548,7 +529,7 @@ function BackendRunConversation({ run, index }: { run: BackendTaskRun; index: nu
<div>
<div className="font-medium">{run.title || pickAppText(locale, `Agent ${index + 1}`, `Agent ${index + 1}`)}</div>
<div className="mt-1 text-xs text-muted-foreground">
{run.started_at ? formatOfficeTime(run.started_at, locale) : pickAppText(locale, '时间未知', 'Unknown time')}
{run.started_at ? formatTaskRuntimeTime(run.started_at, locale) : pickAppText(locale, '时间未知', 'Unknown time')}
{run.finish_reason ? ` · ${humanFinishReason(run.finish_reason, locale)}` : ''}
</div>
</div>
@ -573,7 +554,7 @@ function BackendRunConversation({ run, index }: { run: BackendTaskRun; index: nu
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{isAssistant ? run.title || pickAppText(locale, 'Agent 回复', 'Agent reply') : isTool ? message.tool_name || pickAppText(locale, '工具结果', 'Tool result') : pickAppText(locale, '用户要求', 'User request')}</span>
{message.created_at ? <span>{formatOfficeTime(message.created_at, locale)}</span> : null}
{message.created_at ? <span>{formatTaskRuntimeTime(message.created_at, locale)}</span> : null}
</div>
<div className="whitespace-pre-wrap rounded-md border border-border bg-muted/20 px-3 py-2 text-sm leading-6">
{message.content}

View File

@ -5,7 +5,7 @@ import { useSearchParams } from 'next/navigation';
import React, { useEffect, useMemo, useState } from 'react';
import { AlertCircle, ArrowRight, Clock3, FolderDown, ListTodo, Loader2, Play, Plus, RefreshCw, Trash2, X } from 'lucide-react';
import { formatOfficeTime } from '@/components/office/OfficeShared';
import { formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -151,7 +151,7 @@ function OrdinaryTasks() {
</TableCell>
<TableCell className="text-sm text-muted-foreground">{task.run_ids.length}</TableCell>
<TableCell className="text-sm text-muted-foreground">{task.skill_names.length}</TableCell>
<TableCell className="text-xs text-muted-foreground">{formatOfficeTime(task.updated_at, locale)}</TableCell>
<TableCell className="text-xs text-muted-foreground">{formatTaskRuntimeTime(task.updated_at, locale)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button asChild size="sm" variant="outline">

View File

@ -8,7 +8,7 @@ import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
const HANDOFF_STATE_KEY = 'nanobot_handoff_state';
const HANDOFF_STATE_KEY = 'beaver_handoff_state';
type HandoffState = {
code?: string;

View File

@ -40,7 +40,7 @@ export function AppRuntimeBridge() {
const sessionId = useChatStore((state) => state.sessionId);
const setSessions = useChatStore((state) => state.setSessions);
const setWsStatus = useChatStore((state) => state.setWsStatus);
const setNanobotReady = useChatStore((state) => state.setNanobotReady);
const setBeaverReady = useChatStore((state) => state.setBeaverReady);
const resetProcessState = useChatStore((state) => state.resetProcessState);
const ingestProcessEvent = useChatStore((state) => state.ingestProcessEvent);
const statusCheckCleanupRef = React.useRef<(() => void) | null>(null);
@ -63,14 +63,14 @@ export function AppRuntimeBridge() {
statusCheckInFlightRef.current = true;
try {
await getStatus();
setNanobotReady(true);
setBeaverReady(true);
} catch {
setNanobotReady(false);
setBeaverReady(false);
} finally {
statusCheckInFlightRef.current = false;
}
});
}, [setNanobotReady]);
}, [setBeaverReady]);
React.useEffect(() => {
void loadSessions();
@ -89,7 +89,7 @@ export function AppRuntimeBridge() {
} else {
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
setNanobotReady(null);
setBeaverReady(null);
}
});
@ -98,7 +98,7 @@ export function AppRuntimeBridge() {
statusCheckCleanupRef.current = null;
unsubStatus();
};
}, [scheduleStatusCheck, setNanobotReady, setWsStatus]);
}, [scheduleStatusCheck, setBeaverReady, setWsStatus]);
React.useEffect(() => {
const unsubMessage = wsManager.onMessage((data) => {

View File

@ -3,7 +3,7 @@
import React from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, MessageSquare, PackageOpen, Puzzle, Settings, Store, Wrench } from 'lucide-react';
import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, MessageSquare, Puzzle, Settings, Store, Wrench } from 'lucide-react';
import { logout } from '@/lib/api';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
@ -15,7 +15,7 @@ import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
type NavItem = {
key: 'chat' | 'tasks' | 'notifications' | 'skills' | 'files' | 'tools' | 'agents' | 'outlook' | 'marketplace' | 'plugins' | 'settings';
key: 'chat' | 'tasks' | 'notifications' | 'skills' | 'files' | 'tools' | 'agents' | 'outlook' | 'marketplace' | 'settings';
href: string;
icon: React.ComponentType<{ className?: string }>;
matchPrefixes?: string[];
@ -23,7 +23,7 @@ type NavItem = {
const NAV_ITEMS: NavItem[] = [
{ key: 'chat', href: '/', icon: MessageSquare },
{ key: 'tasks', href: '/tasks', icon: ListTodo, matchPrefixes: ['/tasks', '/office', '/cron'] },
{ key: 'tasks', href: '/tasks', icon: ListTodo, matchPrefixes: ['/tasks', '/cron'] },
{ key: 'notifications', href: '/notifications', icon: Bell, matchPrefixes: ['/notifications'] },
{ key: 'skills', href: '/skills', icon: Puzzle },
{ key: 'files', href: '/files', icon: FolderOpen, matchPrefixes: ['/files'] },
@ -31,7 +31,6 @@ const NAV_ITEMS: NavItem[] = [
{ key: 'agents', href: '/agents', icon: Bot, matchPrefixes: ['/agents'] },
{ key: 'outlook', href: '/outlook', icon: Mail, matchPrefixes: ['/outlook'] },
{ key: 'marketplace', href: '/marketplace', icon: Store, matchPrefixes: ['/marketplace'] },
{ key: 'plugins', href: '/plugins', icon: PackageOpen, matchPrefixes: ['/plugins'] },
{
key: 'settings',
href: '/settings',
@ -43,12 +42,12 @@ const NAV_ITEMS: NavItem[] = [
function ConnectionDot() {
const { locale } = useAppI18n();
const wsStatus = useChatStore((s) => s.wsStatus);
const nanobotReady = useChatStore((s) => s.nanobotReady);
const beaverReady = useChatStore((s) => s.beaverReady);
const isOnline = wsStatus === 'connected' && nanobotReady === true;
const isChecking = wsStatus === 'connected' && nanobotReady === null;
const isOnline = wsStatus === 'connected' && beaverReady === true;
const isChecking = wsStatus === 'connected' && beaverReady === null;
const isConnecting = wsStatus === 'connecting' || isChecking;
const isOffline = wsStatus === 'disconnected' || (wsStatus === 'connected' && nanobotReady === false);
const isOffline = wsStatus === 'disconnected' || (wsStatus === 'connected' && beaverReady === false);
const color = isOnline
? 'bg-[#869683]'
@ -56,7 +55,7 @@ function ConnectionDot() {
? 'bg-[#8B7E77]'
: 'bg-[#5F5550]';
const label = appConnectionStatusLabel(wsStatus, nanobotReady, locale);
const label = appConnectionStatusLabel(wsStatus, beaverReady, locale);
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
@ -84,7 +83,6 @@ const Header = () => {
if (key === 'agents') return pickAppText(locale, '智能体', 'Agents');
if (key === 'outlook') return 'Outlook';
if (key === 'marketplace') return pickAppText(locale, '市场', 'Marketplace');
if (key === 'plugins') return pickAppText(locale, '插件', 'Plugins');
return pickAppText(locale, '配置', 'Settings');
}, [locale]);

View File

@ -1,11 +1,10 @@
'use client';
import React from 'react';
import { CheckCircle2, Loader2, Sparkles, Square } from 'lucide-react';
import { CheckCircle2, Loader2, Sparkles } from 'lucide-react';
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { appArtifactPreview, appFeedRoleLabel, appStatusLabel } from '@/lib/i18n/common';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
@ -410,7 +409,6 @@ export function AgentTeamBlock({
artifacts,
selectedRunId,
onSelectRun,
onCancelRun,
}: {
rootRun: ProcessRun;
memberRuns: ProcessRun[];
@ -418,7 +416,6 @@ export function AgentTeamBlock({
artifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
}) {
const { locale } = useAppI18n();
const phases = useRunCardPhases(memberRuns);
@ -435,10 +432,6 @@ export function AgentTeamBlock({
const terminalRuns = sortedRuns.filter((run) => TERMINAL_STATUSES.has(run.status));
const collapsedRuns = sortedRuns.filter((run) => phases[run.run_id] === 'collapsed');
const liveCount = liveRuns.filter((run) => !TERMINAL_STATUSES.has(run.status)).length;
const canCancelRoot =
!rootRun.parent_run_id &&
(rootRun.status === 'running' || rootRun.status === 'waiting');
if (liveRuns.length === 0 && terminalRuns.length > 0) {
return (
<div className="inline-flex max-w-full flex-wrap items-start gap-2 rounded-2xl border border-border/60 bg-card/35 px-3 py-3 backdrop-blur-sm">
@ -486,12 +479,6 @@ export function AgentTeamBlock({
</p>
</div>
<div className="flex items-center gap-2">
{canCancelRoot && (
<Button variant="outline" size="sm" className="bg-background/60" onClick={() => onCancelRun(rootRun.run_id)}>
<Square className="mr-1.5 h-3.5 w-3.5" />
{pickAppText(locale, '取消', 'Cancel')}
</Button>
)}
<Badge variant="outline" className="border-border/70 bg-background/55 text-foreground/85">
{pickAppText(locale, `${memberRuns.length} 个子任务`, `${memberRuns.length} subtasks`)}
</Badge>

View File

@ -15,7 +15,6 @@ export function ChatWorkbench({
processArtifacts,
selectedRunId,
onSelectRun,
onCancelRun,
onFeedback,
}: {
messages: ChatMessage[];
@ -27,7 +26,6 @@ export function ChatWorkbench({
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
}) {
return (
@ -42,7 +40,6 @@ export function ChatWorkbench({
processArtifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
onFeedback={onFeedback}
/>
</div>

View File

@ -329,7 +329,6 @@ export function MessageList({
processArtifacts,
selectedRunId,
onSelectRun,
onCancelRun,
onFeedback,
}: {
messages: ChatMessage[];
@ -341,7 +340,6 @@ export function MessageList({
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
}) {
const { locale } = useAppI18n();
@ -411,7 +409,6 @@ export function MessageList({
artifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
)
)}

View File

@ -3,14 +3,14 @@
import { useAppI18n } from '@/lib/i18n/provider';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { OfficeTaskStatus, OfficeZoneView } from '@/lib/office';
import { officeTaskStatusLabel } from '@/lib/office';
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
import { taskRuntimeStatusLabel } from '@/lib/task-runtime';
export function OfficeStatusBadge({
export function TaskRuntimeStatusBadge({
status,
className,
}: {
status: OfficeTaskStatus;
status: TaskRuntimeStatus;
className?: string;
}) {
const { locale } = useAppI18n();
@ -30,12 +30,12 @@ export function OfficeStatusBadge({
className
)}
>
{officeTaskStatusLabel(status, locale)}
{taskRuntimeStatusLabel(status, locale)}
</Badge>
);
}
export function formatOfficeTime(value?: string | null, locale: 'zh-CN' | 'en-US' = 'zh-CN'): string {
export function formatTaskRuntimeTime(value?: string | null, locale: 'zh-CN' | 'en-US' = 'zh-CN'): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
@ -47,7 +47,7 @@ export function formatOfficeTime(value?: string | null, locale: 'zh-CN' | 'en-US
}).format(date);
}
export function formatOfficeDuration(durationMs: number | null, locale: 'zh-CN' | 'en-US' = 'zh-CN'): string {
export function formatTaskRuntimeDuration(durationMs: number | null, locale: 'zh-CN' | 'en-US' = 'zh-CN'): string {
if (durationMs === null || durationMs < 0) return '-';
if (durationMs < 1000) return locale === 'en-US' ? '<1s' : '<1秒';
@ -65,15 +65,3 @@ export function progressPercent(value: number | null, max: number | null): numbe
if (value === null || max === null || max <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((value / max) * 100)));
}
export function zonePanelClassName(zone: OfficeZoneView): string {
return cn(
'relative min-h-[220px] overflow-hidden rounded-2xl border p-4 shadow-sm',
'before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.9),transparent_40%)]',
zone.tone === 'info' && 'border-[#BCC4CE] bg-[#E4E7EB]/70',
zone.tone === 'warn' && 'border-[#B8AEA8] bg-[#E7E2DE]/70',
zone.tone === 'danger' && 'border-[#B8AEA8] bg-[#E7E2DE]/80',
zone.tone === 'success' && 'border-[#B7C2B5] bg-[#E3E8E2]/75',
zone.tone === 'neutral' && 'border-border bg-card'
);
}

View File

@ -1,9 +1,6 @@
// Nanobot API client single-user direct mode.
// Beaver API client - single-user direct mode.
import type {
AuthzBackendRecord,
AuthzChannelSettings,
AuthzRegisterBackendResponse,
AuthzStatus,
AuthUser,
ActiveTask,
@ -12,11 +9,8 @@ import type {
ChatMessage,
CronJob,
FileAttachment,
Marketplace,
MarketplacePlugin,
NotificationDetail,
NotificationRun,
PluginInfo,
ProviderConfigPayload,
Session,
SessionDetail,
@ -33,7 +27,6 @@ import type {
SkillHubVersionsResponse,
SkillLearningCandidate,
SkillReviewRecord,
SlashCommand,
SessionProcessProjection,
SystemStatus,
TokenResponse,
@ -55,8 +48,8 @@ import { getCurrentAppLocale, pickAppText } from '@/lib/i18n/core';
const API_URL = process.env.NEXT_PUBLIC_API_URL?.trim();
const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.trim();
const DEFAULT_API_URL = 'http://127.0.0.1:18080';
const ACCESS_TOKEN_KEY = 'nanobot_access_token';
const REFRESH_TOKEN_KEY = 'nanobot_refresh_token';
const ACCESS_TOKEN_KEY = 'beaver_access_token';
const REFRESH_TOKEN_KEY = 'beaver_refresh_token';
const REQUEST_TIMEOUT_MS = 8000;
const OUTLOOK_REQUEST_TIMEOUT_MS = 45000;
const SKILL_LEARNING_REQUEST_TIMEOUT_MS = 120000;
@ -636,16 +629,6 @@ export async function updateProviderConfig(
});
}
export async function restartSystem(): Promise<{
ok: boolean;
restarting: boolean;
detail: string;
}> {
return fetchJSON('/api/system/restart', {
method: 'POST',
});
}
// ---------------------------------------------------------------------------
// Cron (proxied)
// ---------------------------------------------------------------------------
@ -861,14 +844,6 @@ export async function rollbackPublishedSkill(
});
}
export async function listCommands(): Promise<SlashCommand[]> {
return fetchJSON('/api/commands');
}
export async function listPlugins(): Promise<PluginInfo[]> {
return fetchJSON('/api/plugins');
}
export async function listAgents(): Promise<UiAgentDescriptor[]> {
return fetchJSON('/api/agents');
}
@ -957,18 +932,6 @@ export async function deleteSubagent(subagentId: string): Promise<void> {
});
}
export async function cancelDelegation(runId: string): Promise<{ ok: boolean; run_id: string }> {
return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/cancel`, {
method: 'POST',
});
}
export async function retryDelegation(runId: string): Promise<{ ok: boolean; run_id: string }> {
return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/retry`, {
method: 'POST',
});
}
export async function listMcpServers(): Promise<UiMcpServerDescriptor[]> {
return fetchJSON('/api/mcp/servers');
}
@ -1022,122 +985,6 @@ export async function getAuthzStatus(): Promise<AuthzStatus> {
return fetchJSON('/api/authz/status');
}
export async function bindLocalBackendIdentity(payload: {
backend_id: string;
client_id: string;
client_secret: string;
name?: string;
public_base_url?: string;
authz_base_url?: string;
authz_enabled?: boolean;
}): Promise<Record<string, unknown>> {
return fetchJSON('/api/authz/local-backend/bind', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function listAuthzBackends(): Promise<AuthzBackendRecord[]> {
return fetchJSON('/api/authz/backends');
}
export async function registerAuthzBackend(payload: {
name?: string;
backend_id?: string;
base_url?: string;
save_to_backend?: boolean;
authz_base_url?: string;
}): Promise<AuthzRegisterBackendResponse> {
return fetchJSON('/api/authz/backends/register', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function getAuthzBackend(backendId: string): Promise<AuthzBackendRecord> {
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}`);
}
export async function enableAuthzBackend(backendId: string): Promise<AuthzBackendRecord> {
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/enable`, {
method: 'POST',
});
}
export async function disableAuthzBackend(backendId: string): Promise<AuthzBackendRecord> {
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/disable`, {
method: 'POST',
});
}
export async function rotateAuthzBackendSecret(backendId: string): Promise<Record<string, unknown>> {
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/rotate-secret`, {
method: 'POST',
});
}
export async function getAuthzBackendPermissions(backendId: string): Promise<Record<string, unknown>> {
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/permissions`);
}
export async function setAuthzBackendPermissions(
backendId: string,
payload: Record<string, unknown>
): Promise<Record<string, unknown>> {
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/permissions`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function getAuthzBackendOutlookSettings(backendId: string): Promise<Record<string, unknown>> {
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/settings/outlook`);
}
export async function setAuthzBackendOutlookSettings(
backendId: string,
payload: Record<string, unknown>
): Promise<Record<string, unknown>> {
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/settings/outlook`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function deleteAuthzBackendOutlookSettings(backendId: string): Promise<Record<string, unknown>> {
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/settings/outlook`, {
method: 'DELETE',
});
}
export async function listAuthzChannelSettings(): Promise<Record<string, AuthzChannelSettings>> {
return fetchJSON('/api/authz/channel-settings');
}
export async function getAuthzChannelSettings(channelId: string): Promise<AuthzChannelSettings> {
return fetchJSON(`/api/authz/channel-settings/${encodeURIComponent(channelId)}`);
}
export async function setAuthzChannelSettings(
channelId: string,
payload: {
configured?: boolean;
config?: Record<string, unknown>;
secrets?: Record<string, unknown>;
}
): Promise<AuthzChannelSettings> {
return fetchJSON(`/api/authz/channel-settings/${encodeURIComponent(channelId)}`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function deleteAuthzChannelSettings(channelId: string): Promise<Record<string, unknown>> {
return fetchJSON(`/api/authz/channel-settings/${encodeURIComponent(channelId)}`, {
method: 'DELETE',
});
}
export async function testMcpServer(serverId: string): Promise<Record<string, unknown>> {
return fetchJSON(`/api/mcp/servers/${encodeURIComponent(serverId)}/test`, {
method: 'POST',
@ -1282,10 +1129,6 @@ export async function uploadSkill(file: File): Promise<Skill> {
return res.json();
}
export async function migrateSkills(): Promise<{ included: Array<Record<string, unknown>>; skipped: Array<Record<string, unknown>> }> {
return fetchJSON('/api/skills/migrate', { method: 'POST', timeoutMs: 45000 });
}
// ---------------------------------------------------------------------------
// SkillHub marketplace
// ---------------------------------------------------------------------------
@ -1356,50 +1199,6 @@ export async function installSkillHubSkill(
);
}
// ---------------------------------------------------------------------------
// Marketplace (proxied)
// ---------------------------------------------------------------------------
export async function listMarketplaces(): Promise<Marketplace[]> {
return fetchJSON('/api/marketplaces');
}
export async function addMarketplace(source: string): Promise<Marketplace> {
return fetchJSON('/api/marketplaces', {
method: 'POST',
body: JSON.stringify({ source }),
});
}
export async function removeMarketplace(name: string): Promise<void> {
await fetchJSON(`/api/marketplaces/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
}
export async function updateMarketplace(name: string): Promise<Marketplace> {
return fetchJSON(`/api/marketplaces/${encodeURIComponent(name)}/update`, {
method: 'POST',
});
}
export async function listMarketplacePlugins(name: string): Promise<MarketplacePlugin[]> {
return fetchJSON(`/api/marketplaces/${encodeURIComponent(name)}/plugins`);
}
export async function installMarketplacePlugin(marketplaceName: string, pluginName: string): Promise<void> {
await fetchJSON(
`/api/marketplaces/${encodeURIComponent(marketplaceName)}/plugins/${encodeURIComponent(pluginName)}/install`,
{ method: 'POST' }
);
}
export async function uninstallPlugin(pluginName: string): Promise<void> {
await fetchJSON(`/api/plugins/${encodeURIComponent(pluginName)}`, {
method: 'DELETE',
});
}
// ---------------------------------------------------------------------------
// Files (proxied)
// ---------------------------------------------------------------------------

View File

@ -1,10 +1,10 @@
import type { OfficeTaskStatus } from '@/lib/office';
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
import type { ProcessArtifact, ProcessRun } from '@/types';
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
import type { WsStatus } from '@/lib/api';
export function appStatusLabel(
status: ProcessRun['status'] | OfficeTaskStatus | string,
status: ProcessRun['status'] | TaskRuntimeStatus | string,
locale: AppLocale = getCurrentAppLocale()
): string {
if (status === 'queued') return pickAppText(locale, '排队中', 'Queued');
@ -63,12 +63,12 @@ export function appArtifactPreview(artifact: ProcessArtifact, locale: AppLocale
export function appConnectionStatusLabel(
wsStatus: WsStatus,
nanobotReady: boolean | null,
beaverReady: boolean | null,
locale: AppLocale = getCurrentAppLocale()
): string {
const isOnline = wsStatus === 'connected' && nanobotReady === true;
const isChecking = wsStatus === 'connected' && nanobotReady === null;
const isOffline = wsStatus === 'disconnected' || (wsStatus === 'connected' && nanobotReady === false);
const isOnline = wsStatus === 'connected' && beaverReady === true;
const isChecking = wsStatus === 'connected' && beaverReady === null;
const isOffline = wsStatus === 'disconnected' || (wsStatus === 'connected' && beaverReady === false);
if (isOnline) return pickAppText(locale, '已连接', 'Connected');
if (isChecking) return pickAppText(locale, '检查中', 'Checking');

View File

@ -1,5 +1,5 @@
export const APP_LOCALE_COOKIE = 'nanobot_locale';
export const APP_LOCALE_STORAGE_KEY = 'nanobot_locale';
export const APP_LOCALE_COOKIE = 'beaver_locale';
export const APP_LOCALE_STORAGE_KEY = 'beaver_locale';
export const APP_LOCALES = ['zh-CN', 'en-US'] as const;

View File

@ -14,7 +14,7 @@ import type {
} from '@/types';
import type { WsStatus } from '@/lib/api';
const ACTIVE_SESSION_STORAGE_KEY = 'nanobot_active_session_id';
const ACTIVE_SESSION_STORAGE_KEY = 'beaver_active_session_id';
function getInitialSessionId(): string {
if (typeof window === 'undefined') {
@ -40,7 +40,7 @@ interface ChatStore {
streamingContent: string;
wsStatus: WsStatus;
isThinking: boolean;
nanobotReady: boolean | null;
beaverReady: boolean | null;
sessions: Session[];
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
@ -68,7 +68,7 @@ interface ChatStore {
clearMessages: () => void;
setWsStatus: (status: WsStatus) => void;
setIsThinking: (thinking: boolean) => void;
setNanobotReady: (ready: boolean | null) => void;
setBeaverReady: (ready: boolean | null) => void;
resetProcessState: () => void;
ingestProcessEvent: (event: ProcessWsEvent) => void;
setSessionProcess: (sessionId: string, projection: SessionProcessProjection) => void;
@ -135,7 +135,7 @@ export const useChatStore = create<ChatStore>((set) => ({
streamingContent: '',
wsStatus: 'disconnected',
isThinking: false,
nanobotReady: null,
beaverReady: null,
sessions: [],
processRuns: [],
processEvents: [],
@ -175,7 +175,7 @@ export const useChatStore = create<ChatStore>((set) => ({
clearMessages: () => set({ messages: [], streamingContent: '' }),
setWsStatus: (status) => set({ wsStatus: status }),
setIsThinking: (thinking) => set({ isThinking: thinking }),
setNanobotReady: (ready) => set({ nanobotReady: ready }),
setBeaverReady: (ready) => set({ beaverReady: ready }),
resetProcessState: () =>
set({
processRuns: [],

View File

@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildOfficeTaskList, buildOfficeView } from '@/lib/office';
import { buildTaskRuntimeView } from '@/lib/task-runtime';
import type { ProcessArtifact, ProcessEvent, ProcessRun, Session } from '@/types';
describe('office view builders', () => {
describe('runtime view builders', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-24T12:00:00.000Z'));
@ -13,7 +13,7 @@ describe('office view builders', () => {
vi.useRealTimers();
});
it('builds an office view from a root run tree', () => {
it('builds a runtime view from a root run tree', () => {
const sessions: Session[] = [
{
key: 'web:default',
@ -118,26 +118,21 @@ describe('office view builders', () => {
},
];
const office = buildOfficeView('run-root', {
const runtime = buildTaskRuntimeView('run-root', {
sessions,
processRuns,
processEvents,
processArtifacts,
});
expect(office).not.toBeNull();
expect(office?.taskId).toBe('run-root');
expect(office?.title).toBe('整理竞品研究并给出结论');
expect(office?.sourceSessionLabel).toBe('需求讨论');
expect(office?.members).toHaveLength(3);
expect(office?.tasks).toHaveLength(3);
expect(office?.assignments).toHaveLength(1);
expect(office?.progress.label).toBe('已完成子任务 1 / 3');
expect(office?.currentStageLabel).toBe('分析结果');
expect(office?.stats.artifactCount).toBe(1);
expect(office?.zones.find((zone) => zone.id === 'workspace')?.memberIds).toContain('main-agent');
expect(office?.zones.find((zone) => zone.id === 'collab')?.memberIds).toContain('research-agent');
expect(office?.zones.find((zone) => zone.id === 'research')?.memberIds).toContain('search-mcp');
expect(runtime).not.toBeNull();
expect(runtime?.taskId).toBe('run-root');
expect(runtime?.title).toBe('整理竞品研究并给出结论');
expect(runtime?.sourceSessionLabel).toBe('需求讨论');
expect(runtime?.tasks).toHaveLength(3);
expect(runtime?.progress.label).toBe('已完成子任务 1 / 3');
expect(runtime?.stats.artifactCount).toBe(1);
expect(runtime?.stats.alertCount).toBe(0);
});
it('marks stale waiting tasks as blocked and emits alerts', () => {
@ -155,109 +150,14 @@ describe('office view builders', () => {
},
];
const office = buildOfficeView('run-blocked', {
const runtime = buildTaskRuntimeView('run-blocked', {
sessions: [],
processRuns,
processEvents: [],
processArtifacts: [],
});
expect(office?.status).toBe('blocked');
expect(office?.alerts).toHaveLength(1);
expect(office?.alerts[0].level).toBe('warn');
expect(office?.members[0].zoneId).toBe('collab');
});
it('builds a filtered task list and sorts active tasks ahead of finished ones', () => {
const sessions: Session[] = [
{ key: 'web:alpha', path: 'Alpha Session' },
{ key: 'web:beta', path: 'Beta Session' },
];
const processRuns: ProcessRun[] = [
{
run_id: 'run-active',
parent_run_id: null,
session_id: 'web:alpha',
actor_type: 'agent',
actor_id: 'agent-a',
actor_name: 'Agent A',
title: '执行活跃任务',
status: 'running',
started_at: '2026-03-24T11:20:00.000Z',
},
{
run_id: 'run-done',
parent_run_id: null,
session_id: 'web:alpha',
actor_type: 'agent',
actor_id: 'agent-b',
actor_name: 'Agent B',
title: '已结束任务',
status: 'done',
started_at: '2026-03-24T10:00:00.000Z',
finished_at: '2026-03-24T10:08:00.000Z',
},
{
run_id: 'run-other-session',
parent_run_id: null,
session_id: 'web:beta',
actor_type: 'agent',
actor_id: 'agent-c',
actor_name: 'Agent C',
title: '其他会话任务',
status: 'running',
started_at: '2026-03-24T11:00:00.000Z',
},
];
const tasks = buildOfficeTaskList({
sessionId: 'web:alpha',
sessions,
processRuns,
processEvents: [],
processArtifacts: [],
});
expect(tasks).toHaveLength(2);
expect(tasks[0].taskId).toBe('run-active');
expect(tasks[1].taskId).toBe('run-done');
expect(tasks[0].sessionLabel).toBe('Alpha Session');
});
it('keeps office tasks visible when the root run inherits session from descendants', () => {
const tasks = buildOfficeTaskList({
sessionId: 'web:alpha',
sessions: [{ key: 'web:alpha', path: 'Alpha Session' }],
processRuns: [
{
run_id: 'run-root-no-session',
parent_run_id: null,
actor_type: 'agent',
actor_id: 'agent-a',
actor_name: 'Agent A',
title: '根任务缺少会话字段',
status: 'running',
started_at: '2026-03-24T11:20:00.000Z',
},
{
run_id: 'run-child-with-session',
parent_run_id: 'run-root-no-session',
session_id: 'web:alpha',
actor_type: 'agent',
actor_id: 'agent-b',
actor_name: 'Agent B',
title: '子任务仍带着会话字段',
status: 'running',
started_at: '2026-03-24T11:21:00.000Z',
},
],
processEvents: [],
processArtifacts: [],
});
expect(tasks).toHaveLength(1);
expect(tasks[0].taskId).toBe('run-root-no-session');
expect(tasks[0].sessionId).toBe('web:alpha');
expect(runtime?.status).toBe('blocked');
expect(runtime?.stats.alertCount).toBe(1);
});
});

View File

@ -1,5 +1,4 @@
import type {
ProcessActorType,
ProcessArtifact,
ProcessEvent,
ProcessRun,
@ -8,108 +7,42 @@ import type {
} from '@/types';
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
const TERMINAL_STATUSES = new Set<OfficeTaskStatus>(['done', 'error', 'cancelled']);
const TERMINAL_STATUSES = new Set<TaskRuntimeStatus>(['done', 'error', 'cancelled']);
const STALE_WAITING_MS = 2 * 60 * 1000;
export type OfficeTaskStatus = ProcessRunStatus | 'blocked';
export type TaskRuntimeStatus = ProcessRunStatus | 'blocked';
export type OfficeZoneId =
| 'reception'
| 'workspace'
| 'collab'
| 'research'
| 'alert'
| 'done';
export interface OfficeProgressView {
mode: 'stage' | 'ratio' | 'status';
export interface TaskRuntimeProgressView {
label: string;
value: number | null;
max: number | null;
stageLabel: string | null;
}
export interface OfficeStatsView {
export interface TaskRuntimeStatsView {
totalRuns: number;
activeRuns: number;
doneRuns: number;
errorRuns: number;
cancelledRuns: number;
memberCount: number;
artifactCount: number;
alertCount: number;
}
export interface OfficeZoneView {
id: OfficeZoneId;
label: string;
memberIds: string[];
taskIds: string[];
tone: 'neutral' | 'info' | 'warn' | 'danger' | 'success';
}
export interface OfficeMemberView {
memberId: string;
actorId: string;
actorName: string;
actorType: ProcessActorType;
status: OfficeTaskStatus;
zoneId: OfficeZoneId;
currentRunId: string;
currentTitle: string;
stageLabel: string | null;
summary: string | null;
startedAt: string | null;
updatedAt: string | null;
finishedAt: string | null;
childRunIds: string[];
artifactCount: number;
isPrimary: boolean;
}
export interface OfficeTaskView {
export interface TaskRuntimeNodeView {
taskId: string;
runId: string;
parentRunId: string | null;
actorId: string;
actorName: string;
actorType: ProcessActorType;
title: string;
status: OfficeTaskStatus;
status: TaskRuntimeStatus;
stageLabel: string | null;
summary: string | null;
startedAt: string;
updatedAt: string;
finishedAt: string | null;
childTaskIds: string[];
artifactCount: number;
errorText: string | null;
isRoot: boolean;
}
export interface OfficeAssignmentView {
ownerRunId: string;
ownerActorName: string;
assigneeRunIds: string[];
assigneeActorNames: string[];
label: string;
}
export interface OfficeAlertView {
id: string;
level: 'info' | 'warn' | 'error';
title: string;
description: string | null;
runId: string | null;
actorId: string | null;
createdAt: string;
}
export interface OfficeView {
officeId: string;
export interface TaskRuntimeView {
taskId: string;
sessionId: string | null;
title: string;
status: OfficeTaskStatus;
status: TaskRuntimeStatus;
createdAt: string;
updatedAt: string;
finishedAt: string | null;
@ -117,38 +50,12 @@ export interface OfficeView {
sourceSessionLabel: string;
rootRunId: string;
rootActorName: string;
currentStageLabel: string | null;
progress: OfficeProgressView;
stats: OfficeStatsView;
alerts: OfficeAlertView[];
zones: OfficeZoneView[];
members: OfficeMemberView[];
tasks: OfficeTaskView[];
assignments: OfficeAssignmentView[];
detailRunIds: string[];
progress: TaskRuntimeProgressView;
stats: TaskRuntimeStatsView;
tasks: TaskRuntimeNodeView[];
}
export interface OfficeTaskListItem {
officeId: string;
taskId: string;
sessionId: string | null;
sessionLabel: string;
title: string;
status: OfficeTaskStatus;
createdAt: string;
updatedAt: string;
finishedAt: string | null;
rootRunId: string;
rootActorName: string;
memberCount: number;
activeRuns: number;
errorCount: number;
artifactCount: number;
currentStageLabel: string | null;
progress: OfficeProgressView;
}
type BuildOfficeInput = {
type BuildTaskRuntimeInput = {
sessions: Session[];
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
@ -235,11 +142,6 @@ function buildChildrenMap(processRuns: ProcessRun[]): Map<string, ProcessRun[]>
return map;
}
function findRootRuns(processRuns: ProcessRun[]): ProcessRun[] {
const runIds = new Set(processRuns.map((run) => run.run_id));
return processRuns.filter((run) => !run.parent_run_id || !runIds.has(run.parent_run_id));
}
function collectRunTree(rootRun: ProcessRun, childrenMap: Map<string, ProcessRun[]>): ProcessRun[] {
const collected: ProcessRun[] = [];
const stack = [rootRun];
@ -279,7 +181,7 @@ function getRunUpdatedAt(
function deriveStageLabel(
run: ProcessRun,
runEvents: ProcessEvent[],
fallbackStatus: OfficeTaskStatus,
fallbackStatus: TaskRuntimeStatus,
locale: AppLocale,
): string | null {
const runMetadataLabel = readMetadataString(run.metadata, [
@ -315,7 +217,7 @@ function deriveRunStatus(
run: ProcessRun,
updatedAt: string,
now: number,
): OfficeTaskStatus {
): TaskRuntimeStatus {
if (run.status !== 'waiting') return run.status;
const updatedTime = toTime(updatedAt);
if (updatedTime !== null && now - updatedTime > STALE_WAITING_MS) {
@ -324,88 +226,16 @@ function deriveRunStatus(
return 'waiting';
}
function mapZoneId(status: OfficeTaskStatus, actorType: ProcessActorType): OfficeZoneId {
if (status === 'queued') return 'reception';
if (status === 'waiting' || status === 'blocked') return actorType === 'mcp' ? 'research' : 'collab';
if (status === 'running') return actorType === 'mcp' ? 'research' : 'workspace';
if (status === 'done') return 'collab';
return 'alert';
}
function zoneLabel(zoneId: OfficeZoneId, locale: AppLocale): string {
if (zoneId === 'reception') return pickAppText(locale, '接待区', 'Reception');
if (zoneId === 'workspace') return pickAppText(locale, '工位区', 'Workspace');
if (zoneId === 'collab') return pickAppText(locale, '协作区', 'Collaboration');
if (zoneId === 'research') return pickAppText(locale, '研究区', 'Research');
if (zoneId === 'alert') return pickAppText(locale, '异常区', 'Alerts');
return pickAppText(locale, '完成区', 'Completed');
}
function zoneTone(zoneId: OfficeZoneId): OfficeZoneView['tone'] {
if (zoneId === 'workspace' || zoneId === 'research') return 'info';
if (zoneId === 'collab' || zoneId === 'reception') return 'warn';
if (zoneId === 'alert') return 'danger';
if (zoneId === 'done') return 'success';
return 'neutral';
}
function taskStatusPriority(status: OfficeTaskStatus): number {
if (status === 'running') return 6;
if (status === 'blocked') return 5;
if (status === 'waiting') return 4;
if (status === 'queued') return 3;
if (status === 'error') return 2;
if (status === 'cancelled') return 1;
return 0;
}
function selectDisplayRun(
runs: ProcessRun[],
eventsByRun: Map<string, ProcessEvent[]>,
artifactsByRun: Map<string, ProcessArtifact[]>,
now: number,
): { run: ProcessRun; status: OfficeTaskStatus; updatedAt: string } {
const sorted = [...runs]
.map((run) => {
const updatedAt = getRunUpdatedAt(run, eventsByRun, artifactsByRun);
const status = deriveRunStatus(run, updatedAt, now);
return { run, status, updatedAt };
})
.sort((a, b) => {
const byStatus = taskStatusPriority(b.status) - taskStatusPriority(a.status);
if (byStatus !== 0) return byStatus;
return compareIsoDesc(a.updatedAt, b.updatedAt);
});
return sorted[0];
}
function deriveErrorText(run: ProcessRun, runEvents: ProcessEvent[], locale: AppLocale): string | null {
if (run.status !== 'error') return null;
const direct = firstString(run.summary);
if (direct) return direct;
const sortedEvents = [...runEvents].sort((a, b) => compareIsoDesc(a.created_at, b.created_at));
for (const event of sortedEvents) {
if (event.status === 'error' && firstString(event.text)) {
return event.text!.trim();
}
}
return pickAppText(locale, '任务执行失败', 'Task execution failed');
}
function deriveProgress(
rootRun: ProcessRun,
taskRuns: ProcessRun[],
taskViews: OfficeTaskView[],
locale: AppLocale,
): OfficeProgressView {
): TaskRuntimeProgressView {
const stageValue = readMetadataNumber(rootRun.metadata, ['stage_index', 'step_index', 'phase_index']);
const stageMax = readMetadataNumber(rootRun.metadata, ['stage_total', 'step_total', 'phase_total']);
const stageLabel = readMetadataString(rootRun.metadata, ['stage_label', 'stage', 'phase_label', 'step_label']);
if (stageValue !== null && stageMax !== null && stageMax > 0) {
return {
mode: 'ratio',
label: pickAppText(
locale,
`阶段 ${Math.min(stageValue, stageMax)} / ${stageMax}`,
@ -413,14 +243,12 @@ function deriveProgress(
),
value: stageValue,
max: stageMax,
stageLabel,
};
}
const doneRuns = taskRuns.filter((run) => run.status === 'done').length;
if (taskRuns.length > 0) {
return {
mode: 'ratio',
label: pickAppText(
locale,
`已完成子任务 ${doneRuns} / ${taskRuns.length}`,
@ -428,97 +256,25 @@ function deriveProgress(
),
value: doneRuns,
max: taskRuns.length,
stageLabel: stageLabel ?? taskViews.find((item) => item.isRoot)?.stageLabel ?? null,
};
}
return {
mode: 'status',
label: pickAppText(locale, '等待任务数据', 'Waiting for task data'),
value: null,
max: null,
stageLabel,
};
}
function buildAlerts(
taskViews: OfficeTaskView[],
now: number,
locale: AppLocale,
): OfficeAlertView[] {
const alerts: OfficeAlertView[] = [];
for (const task of taskViews) {
if (task.status === 'error') {
alerts.push({
id: `error:${task.runId}`,
level: 'error',
title: pickAppText(locale, `${task.actorName} 执行失败`, `${task.actorName} failed`),
description: task.errorText,
runId: task.runId,
actorId: task.actorId,
createdAt: task.updatedAt,
});
} else if (task.status === 'blocked') {
alerts.push({
id: `blocked:${task.runId}`,
level: 'warn',
title: pickAppText(locale, `${task.actorName} 长时间等待`, `${task.actorName} has been waiting for a while`),
description: pickAppText(locale, '该任务长时间无更新,可能存在阻塞。', 'This task has not updated for a while and may be blocked.'),
runId: task.runId,
actorId: task.actorId,
createdAt: task.updatedAt,
});
} else if (task.status === 'waiting') {
const updatedTime = toTime(task.updatedAt);
if (updatedTime !== null && now - updatedTime > STALE_WAITING_MS) {
alerts.push({
id: `stale:${task.runId}`,
level: 'warn',
title: pickAppText(locale, `${task.actorName} 等待时间偏长`, `${task.actorName} has been waiting longer than expected`),
description: pickAppText(locale, '该任务仍处于等待态,建议查看详情确认依赖是否卡住。', 'This task is still waiting. Check the details to confirm whether a dependency is stuck.'),
runId: task.runId,
actorId: task.actorId,
createdAt: task.updatedAt,
});
}
}
}
return alerts.sort((a, b) => compareIsoDesc(a.createdAt, b.createdAt));
function countAlerts(taskViews: TaskRuntimeNodeView[]): number {
return taskViews.filter((task) => task.status === 'error' || task.status === 'blocked').length;
}
function buildZones(members: OfficeMemberView[], tasks: OfficeTaskView[], locale: AppLocale): OfficeZoneView[] {
const ids: OfficeZoneId[] = ['reception', 'workspace', 'collab', 'research', 'alert', 'done'];
return ids.map((id) => ({
id,
label: zoneLabel(id, locale),
memberIds: members.filter((member) => member.zoneId === id).map((member) => member.memberId),
taskIds: tasks.filter((task) => mapZoneId(task.status, task.actorType) === id).map((task) => task.taskId),
tone: zoneTone(id),
}));
}
function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map<string, ProcessRun[]>, locale: AppLocale): OfficeAssignmentView[] {
return taskRuns
.filter((run) => (childrenMap.get(run.run_id) ?? []).length > 0)
.map((run) => {
const children = childrenMap.get(run.run_id) ?? [];
return {
ownerRunId: run.run_id,
ownerActorName: run.actor_name,
assigneeRunIds: children.map((item) => item.run_id),
assigneeActorNames: children.map((item) => item.actor_name),
label: pickAppText(locale, `${run.actor_name} 分派了 ${children.length} 个子任务`, `${run.actor_name} assigned ${children.length} subtasks`),
};
});
}
export function isOfficeTaskTerminal(status: OfficeTaskStatus): boolean {
export function isTaskRuntimeTerminal(status: TaskRuntimeStatus): boolean {
return TERMINAL_STATUSES.has(status);
}
export function officeTaskStatusLabel(status: OfficeTaskStatus, locale: AppLocale = getCurrentAppLocale()): string {
export function taskRuntimeStatusLabel(status: TaskRuntimeStatus, locale: AppLocale = getCurrentAppLocale()): string {
if (status === 'queued') return pickAppText(locale, '排队中', 'Queued');
if (status === 'running') return pickAppText(locale, '进行中', 'In Progress');
if (status === 'waiting') return pickAppText(locale, '等待中', 'Waiting');
@ -528,11 +284,11 @@ export function officeTaskStatusLabel(status: OfficeTaskStatus, locale: AppLocal
return pickAppText(locale, '已取消', 'Cancelled');
}
export function buildOfficeView(
export function buildTaskRuntimeView(
taskId: string,
input: BuildOfficeInput,
input: BuildTaskRuntimeInput,
locale: AppLocale = getCurrentAppLocale(),
): OfficeView | null {
): TaskRuntimeView | null {
const { sessions, processRuns, processEvents, processArtifacts } = input;
const runById = new Map(processRuns.map((run) => [run.run_id, run]));
const rootRun = runById.get(taskId);
@ -547,7 +303,7 @@ export function buildOfficeView(
const artifactsByRun = groupByRunId(taskArtifacts);
const now = Date.now();
const taskViews: OfficeTaskView[] = taskRuns
const taskViews: TaskRuntimeNodeView[] = taskRuns
.map((run) => {
const runEvents = eventsByRun.get(run.run_id) ?? [];
const updatedAt = getRunUpdatedAt(run, eventsByRun, artifactsByRun);
@ -560,72 +316,24 @@ export function buildOfficeView(
return {
taskId: run.run_id,
runId: run.run_id,
parentRunId: run.parent_run_id ?? null,
actorId: run.actor_id,
actorName: run.actor_name,
actorType: run.actor_type,
title: run.title,
status,
stageLabel,
summary: firstString(run.summary),
startedAt: run.started_at,
updatedAt,
finishedAt: run.finished_at ?? null,
childTaskIds,
artifactCount: (artifactsByRun.get(run.run_id) ?? []).length,
errorText: deriveErrorText(run, runEvents, locale),
isRoot: run.run_id === rootRun.run_id,
};
})
.sort((a, b) => {
if (a.isRoot !== b.isRoot) return a.isRoot ? -1 : 1;
if (isOfficeTaskTerminal(a.status) !== isOfficeTaskTerminal(b.status)) {
return isOfficeTaskTerminal(a.status) ? 1 : -1;
if (isTaskRuntimeTerminal(a.status) !== isTaskRuntimeTerminal(b.status)) {
return isTaskRuntimeTerminal(a.status) ? 1 : -1;
}
return compareIsoDesc(a.updatedAt, b.updatedAt);
});
const actorRuns = new Map<string, ProcessRun[]>();
for (const run of taskRuns) {
const collection = actorRuns.get(run.actor_id);
if (collection) {
collection.push(run);
continue;
}
actorRuns.set(run.actor_id, [run]);
}
const members: OfficeMemberView[] = Array.from(actorRuns.entries())
.map(([actorId, runs]) => {
const display = selectDisplayRun(runs, eventsByRun, artifactsByRun, now);
const currentRun = display.run;
const currentTask = taskViews.find((task) => task.runId === currentRun.run_id);
return {
memberId: actorId,
actorId,
actorName: currentRun.actor_name,
actorType: currentRun.actor_type,
status: display.status,
zoneId: mapZoneId(display.status, currentRun.actor_type),
currentRunId: currentRun.run_id,
currentTitle: currentRun.title,
stageLabel: currentTask?.stageLabel ?? null,
summary: currentTask?.summary ?? null,
startedAt: currentRun.started_at ?? null,
updatedAt: display.updatedAt,
finishedAt: currentRun.finished_at ?? null,
childRunIds: (childrenMap.get(currentRun.run_id) ?? []).map((child) => child.run_id),
artifactCount: runs.reduce((count, run) => count + (artifactsByRun.get(run.run_id) ?? []).length, 0),
isPrimary: currentRun.run_id === rootRun.run_id,
};
})
.sort((a, b) => {
if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1;
const byStatus = taskStatusPriority(b.status) - taskStatusPriority(a.status);
if (byStatus !== 0) return byStatus;
return compareIsoDesc(a.updatedAt, b.updatedAt);
});
const sessionId = rootRun.session_id ?? taskRuns.find((run) => run.session_id)?.session_id ?? null;
const updatedAt = latestTimestamp([
...taskViews.map((task) => task.updatedAt),
@ -633,8 +341,8 @@ export function buildOfficeView(
rootRun.started_at,
]) ?? rootRun.started_at;
const derivedRootStatus = deriveRunStatus(rootRun, updatedAt, now);
const alerts = buildAlerts(taskViews, now, locale);
const progress = deriveProgress(rootRun, taskRuns, taskViews, locale);
const alertCount = countAlerts(taskViews);
const progress = deriveProgress(rootRun, taskRuns, locale);
const sourceSessionLabel = getSessionLabel(sessions, sessionId, locale);
const createdAt = rootRun.started_at;
const finishedAt = rootRun.finished_at ?? null;
@ -646,7 +354,6 @@ export function buildOfficeView(
: null;
return {
officeId: rootRun.run_id,
taskId: rootRun.run_id,
sessionId,
title: rootRun.title || pickAppText(locale, `任务 ${rootRun.run_id.slice(0, 8)}`, `Task ${rootRun.run_id.slice(0, 8)}`),
@ -658,60 +365,13 @@ export function buildOfficeView(
sourceSessionLabel,
rootRunId: rootRun.run_id,
rootActorName: rootRun.actor_name,
currentStageLabel: deriveStageLabel(rootRun, eventsByRun.get(rootRun.run_id) ?? [], derivedRootStatus, locale),
progress,
stats: {
totalRuns: taskRuns.length,
activeRuns: taskViews.filter((task) => !isOfficeTaskTerminal(task.status)).length,
doneRuns: taskViews.filter((task) => task.status === 'done').length,
errorRuns: taskViews.filter((task) => task.status === 'error').length,
cancelledRuns: taskViews.filter((task) => task.status === 'cancelled').length,
memberCount: members.length,
activeRuns: taskViews.filter((task) => !isTaskRuntimeTerminal(task.status)).length,
artifactCount: taskArtifacts.length,
alertCount,
},
alerts,
zones: buildZones(members, taskViews, locale),
members,
tasks: taskViews,
assignments: buildAssignments(taskRuns, childrenMap, locale),
detailRunIds: taskViews.map((task) => task.runId),
};
}
export function buildOfficeTaskList(
input: BuildOfficeInput & { sessionId?: string | null },
locale: AppLocale = getCurrentAppLocale(),
): OfficeTaskListItem[] {
const rootRuns = findRootRuns(input.processRuns);
const offices = rootRuns
.map((rootRun) => buildOfficeView(rootRun.run_id, input, locale))
.filter((office): office is OfficeView => office !== null)
.filter((office) => !input.sessionId || office.sessionId === input.sessionId);
return offices
.map((office) => ({
officeId: office.officeId,
taskId: office.taskId,
sessionId: office.sessionId,
sessionLabel: office.sourceSessionLabel,
title: office.title,
status: office.status,
createdAt: office.createdAt,
updatedAt: office.updatedAt,
finishedAt: office.finishedAt,
rootRunId: office.rootRunId,
rootActorName: office.rootActorName,
memberCount: office.members.length,
activeRuns: office.stats.activeRuns,
errorCount: office.stats.errorRuns,
artifactCount: office.stats.artifactCount,
currentStageLabel: office.currentStageLabel,
progress: office.progress,
}))
.sort((a, b) => {
if (isOfficeTaskTerminal(a.status) !== isOfficeTaskTerminal(b.status)) {
return isOfficeTaskTerminal(a.status) ? 1 : -1;
}
return compareIsoDesc(a.updatedAt, b.updatedAt);
});
}

View File

@ -6,14 +6,6 @@ const nextConfig = {
ignoreDuringBuilds: true,
},
images: { unoptimized: true },
webpack: (config) => {
config.resolve = config.resolve || {};
config.resolve.alias = {
...(config.resolve.alias || {}),
phaser3spectorjs: false,
};
return config;
},
};
module.exports = nextConfig;

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "nextjs",
"name": "beaver-app-instance-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
@ -11,86 +11,41 @@
"test": "vitest run"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@next/swc-wasm-nodejs": "13.5.1",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@supabase/supabase-js": "^2.58.0",
"@tailwindcss/typography": "^0.5.19",
"@types/file-saver": "^2.0.7",
"@types/jspdf": "^1.3.3",
"@types/node": "20.6.2",
"@types/react": "18.2.22",
"@types/react-dom": "18.2.7",
"autoprefixer": "10.4.15",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"docx": "^9.5.1",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"embla-carousel-react": "^8.3.0",
"eslint": "8.49.0",
"eslint-config-next": "13.5.1",
"file-saver": "^2.0.5",
"input-otp": "^1.2.4",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"lucide-react": "^0.446.0",
"next": "13.5.1",
"next-themes": "^0.3.0",
"pdfmake": "^0.2.20",
"pdfmake-with-chinese-fonts": "^1.0.16",
"phaser": "^3.90.0",
"postcss": "8.4.30",
"react": "18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "18.2.0",
"react-force-graph-2d": "^1.29.0",
"react-hook-form": "^7.53.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.3",
"react-resize-detector": "^12.3.0",
"recharts": "^2.12.7",
"remark-gfm": "^4.0.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss": "3.3.3",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.2.2",
"vaul": "^0.9.9",
"xlsx": "^0.18.5",
"zod": "^3.23.8",
"zustand": "^5.0.8"
},
"devDependencies": {
"@types/pdfmake": "^0.2.12",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.6.1"
}

View File

@ -1,4 +1,4 @@
// Nanobot frontend types
// Beaver frontend types
export interface AuthUser {
id: string;
@ -222,34 +222,6 @@ export interface SkillDetailResponse {
frontmatter?: Record<string, unknown>;
}
export interface SlashCommand {
name: string;
description: string;
argument_hint: string | null;
plugin_name: string;
}
export interface PluginAgent {
name: string;
description: string;
model: string | null;
}
export interface PluginCommand {
name: string;
description: string;
argument_hint: string | null;
}
export interface PluginInfo {
name: string;
description: string;
source: 'global' | 'workspace';
agents: PluginAgent[];
commands: PluginCommand[];
skills: string[];
}
export interface CronJob {
id: string;
name: string;
@ -380,19 +352,6 @@ export interface ActiveTask {
updated_at: string;
}
export interface Marketplace {
name: string;
source: string;
type: 'local' | 'git';
}
export interface MarketplacePlugin {
name: string;
description: string;
marketplace_name: string;
installed: boolean;
}
export interface SkillHubVersionRef {
id?: number;
version: string;
@ -557,14 +516,6 @@ export interface AuthzLocalBackendStatus {
registered: boolean;
}
export interface AuthzChannelSettings {
configured: boolean;
config?: Record<string, unknown>;
secrets_masked?: boolean;
secret_keys?: string[];
updated_at?: string;
}
export interface AuthzStatus {
enabled: boolean;
base_url: string;
@ -573,35 +524,10 @@ export interface AuthzStatus {
backend?: Record<string, unknown>;
permissions?: Record<string, unknown>;
outlook?: Record<string, unknown>;
channel_settings?: Record<string, AuthzChannelSettings>;
channel_settings?: Record<string, unknown>;
error?: string;
}
export interface AuthzBackendRecord {
backend_id: string;
name: string;
base_url: string;
frontend_base_url?: string | null;
status: string;
created_at: string;
updated_at: string;
is_local_backend?: boolean;
}
export interface AuthzRegisterBackendResponse {
backend_id: string;
client_id: string;
client_secret: string;
created_at: string;
saved_to_backend: boolean;
local_backend?: AuthzLocalBackendStatus & {
authz?: {
enabled: boolean;
base_url: string;
};
};
}
export interface OutlookMailboxAddress {
emailAddress?: {
name?: string | null;

View File

@ -43,8 +43,6 @@ def _normalize_record(record: dict[str, Any]) -> dict[str, Any]:
"api_base_url",
):
normalized[key] = str(record.get(key, "") or "")
if not normalized["beaver_home"]:
normalized["beaver_home"] = str(record.get("nanobot_home", "") or "")
return normalized

View File

@ -20,7 +20,7 @@ function errorDetail(error: unknown): string {
export async function POST(request: NextRequest) {
const locale = normalizePortalLocale(
request.cookies.get('nanobot_locale')?.value ||
request.cookies.get('beaver_locale')?.value ||
request.headers.get('accept-language')
);

View File

@ -20,7 +20,7 @@ function errorDetail(error: unknown): string {
export async function POST(request: NextRequest) {
const locale = normalizePortalLocale(
request.cookies.get('nanobot_locale')?.value ||
request.cookies.get('beaver_locale')?.value ||
request.headers.get('accept-language')
);

View File

@ -1,5 +1,5 @@
export const PORTAL_LOCALE_COOKIE = 'nanobot_locale';
export const PORTAL_LOCALE_STORAGE_KEY = 'nanobot_locale';
export const PORTAL_LOCALE_COOKIE = 'beaver_locale';
export const PORTAL_LOCALE_STORAGE_KEY = 'beaver_locale';
export const PORTAL_LOCALES = ['zh-CN', 'en-US'] as const;

View File

@ -1,11 +1,11 @@
{
"name": "nanobot-auth-portal",
"name": "beaver-auth-portal",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanobot-auth-portal",
"name": "beaver-auth-portal",
"version": "0.1.0",
"dependencies": {
"next": "13.5.1",

View File

@ -1,5 +1,5 @@
{
"name": "nanobot-auth-portal",
"name": "beaver-auth-portal",
"version": "0.1.0",
"private": true,
"scripts": {
@ -20,4 +20,3 @@
"typescript": "5.2.2"
}
}

View File

@ -51,7 +51,7 @@ curl http://127.0.0.1:19090/.well-known/jwks.json
- 例如 `https://authz.example.com`
- 如果要让 AuthZ 负责编排实例注册,还要设置 `DEPLOY_API_BASE_URL`
- 如果 deploy-control 开了鉴权,还要设置 `DEPLOY_API_TOKEN`
- 不要`src/data/` 里的本地示例或真实数据直接拿去打镜像
- 不要提交本地运行产生的 `runtime/data/` 内容
## API 说明

View File

@ -307,7 +307,7 @@ def _upsert_registry_record(record: dict[str, Any]) -> dict[str, Any]:
"--instance-root",
str(record.get("instance_root", "") or "").strip(),
"--beaver-home",
str(record.get("beaver_home", "") or record.get("nanobot_home", "") or "").strip(),
str(record.get("beaver_home", "") or "").strip(),
"--config-path",
str(record.get("config_path", "") or "").strip(),
"--auth-users-path",