feat: 将项目从nano重命名为beaver并更新相关配置

- 将所有环境变量前缀从NANO_改为BEAVER_
- 更新README.md文档内容,包括项目介绍、组件说明和快速开始指南
- 修改.gitignore文件,添加auth-portal运行时路径排除规则
- 更新app-instance镜像标签从nano/app-instance改为beaver/app-instance
- 增强技能安全检查器,支持工具前缀白名单功能
- 添加技能草稿重新检查安全性API端点
- 扩展证据选择器,收集工具调用名称用于技能学习
- 改进技能合成器,基于实际调用的工具生成工具提示
- 优化路由超时处理机制,增加重试逻辑
- 更新后端架构文档,添加可视化入口和基础概念说明
- 实现在WebSocket消息中传递工具迭代次数信息
This commit is contained in:
2026-05-20 18:01:06 +08:00
parent 3b0af173cc
commit 9d6cde2d23
63 changed files with 4894 additions and 1596 deletions

View File

@ -34,12 +34,8 @@ PROXY_RELOAD_SCRIPT = Path(
).resolve()
API_TOKEN = os.environ.get("DEPLOY_CONTROL_API_TOKEN", "").strip()
INSTANCE_IMAGE = os.environ.get("APP_INSTANCE_IMAGE", "nano/app-instance:latest").strip()
INSTANCE_NETWORK_NAME = os.environ.get("APP_INSTANCE_NETWORK_NAME", "nano-instance-edge").strip()
DEFAULT_PROVIDER = os.environ.get("APP_INSTANCE_PROVIDER", "openai").strip()
DEFAULT_MODEL = os.environ.get("APP_INSTANCE_MODEL", "openai/gpt-5").strip()
DEFAULT_API_KEY = os.environ.get("APP_INSTANCE_API_KEY", "").strip()
DEFAULT_API_BASE = os.environ.get("APP_INSTANCE_API_BASE", "").strip()
INSTANCE_IMAGE = os.environ.get("APP_INSTANCE_IMAGE", "beaver/app-instance:latest").strip()
INSTANCE_NETWORK_NAME = os.environ.get("APP_INSTANCE_NETWORK_NAME", "beaver-instance-edge").strip()
DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip()
DEFAULT_AUTHZ_OUTLOOK_MCP_URL = os.environ.get("DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "").strip()
DEFAULT_OUTLOOK_MCP_SERVER_ID = os.environ.get("DEFAULT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp").strip() or "outlook_mcp"
@ -53,6 +49,23 @@ HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS",
INSTANCE_INTERNAL_PORT = int(os.environ.get("APP_INSTANCE_INTERNAL_PORT", "8080").strip() or "8080")
SERVER_HOST = os.environ.get("DEPLOY_CONTROL_HOST", "0.0.0.0").strip() or "0.0.0.0"
SERVER_PORT = int(os.environ.get("DEPLOY_CONTROL_PORT", "8090").strip() or "8090")
KNOWN_PROVIDERS = {
"anthropic",
"openai",
"openrouter",
"deepseek",
"groq",
"zhipu",
"dashscope",
"vllm",
"gemini",
"moonshot",
"minimax",
"aihubmix",
"siliconflow",
"volcengine",
}
API_KEY_OPTIONAL_PROVIDERS = {"vllm"}
class ApiError(Exception):
@ -87,6 +100,13 @@ def run_command(args: list[str], *, cwd: Path | None = None, extra_env: dict[str
return completed.stdout.strip()
def write_json_file(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f"{path.name}.tmp")
tmp_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
tmp_path.replace(path)
def load_registry() -> dict[str, Any]:
if not REGISTRY_PATH.exists():
return {"instances": []}
@ -210,10 +230,6 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
ensure_network()
public_host = build_public_host(slug=slug, instance_id=instance_id, username=username)
public_url = build_public_url(public_host)
provider = str(payload.get("provider", "") or DEFAULT_PROVIDER).strip() or DEFAULT_PROVIDER
model = str(payload.get("model", "") or DEFAULT_MODEL).strip() or DEFAULT_MODEL
api_key = str(payload.get("api_key", "") or DEFAULT_API_KEY).strip()
api_base = str(payload.get("api_base", "") or DEFAULT_API_BASE).strip()
authz_base_url = str(payload.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip()
authz_outlook_mcp_url = str(
payload.get("authz_outlook_mcp_url", "") or DEFAULT_AUTHZ_OUTLOOK_MCP_URL
@ -221,9 +237,6 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
backend_name = str(payload.get("backend_name", "") or username).strip() or username
image_name = str(payload.get("image_name", "") or INSTANCE_IMAGE).strip() or INSTANCE_IMAGE
if not api_key:
raise ApiError(HTTPStatus.BAD_REQUEST, "api key is required for new instances")
command = [
str(CREATE_INSTANCE_SCRIPT),
"--image",
@ -238,12 +251,7 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
username,
"--email",
email,
"--provider",
provider,
"--model",
model,
"--api-key",
api_key,
"--skip-provider-config",
"--backend-name",
backend_name,
"--public-url",
@ -253,8 +261,6 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
"--network",
INSTANCE_NETWORK_NAME,
]
if api_base:
command.extend(["--api-base", api_base])
if authz_base_url:
command.extend(["--authz-base-url", authz_base_url])
if authz_outlook_mcp_url:
@ -282,6 +288,109 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
}
def configure_instance_provider(payload: dict[str, Any]) -> dict[str, Any]:
instance_id = str(payload.get("instance_id", "") or "").strip()
username = str(payload.get("username", "") or "").strip()
if not instance_id and not username:
raise ApiError(HTTPStatus.BAD_REQUEST, "instance_id or username is required")
record = None
if instance_id:
record = get_registry_record(instance_id=instance_id)
if record is None and username:
record = get_registry_record(username=username)
if record is None:
raise ApiError(HTTPStatus.NOT_FOUND, "instance not found")
if payload.get("skip") is True:
return {
"configured": False,
"skipped": True,
"instance": record,
"public_url": str(record.get("public_url", "") or ""),
"frontend_base_url": str(record.get("frontend_base_url", "") or record.get("public_url", "") or ""),
"api_base_url": build_internal_api_base_url(record),
}
provider = str(payload.get("provider", "") or "").strip()
model = str(payload.get("model", "") or "").strip()
api_key = str(payload.get("api_key", "") or "").strip()
api_base = str(payload.get("api_base", "") or "").strip()
if provider not in KNOWN_PROVIDERS:
raise ApiError(HTTPStatus.BAD_REQUEST, f"unsupported provider: {provider or '(empty)'}")
if not model:
raise ApiError(HTTPStatus.BAD_REQUEST, "model is required")
if provider not in API_KEY_OPTIONAL_PROVIDERS and not api_key:
raise ApiError(HTTPStatus.BAD_REQUEST, "api key is required")
if provider in API_KEY_OPTIONAL_PROVIDERS and not (api_key or api_base):
raise ApiError(HTTPStatus.BAD_REQUEST, "api key or api base is required")
raw_config_path = str(record.get("config_path", "") or "").strip()
config_path = Path(raw_config_path).expanduser()
if not raw_config_path:
beaver_home = Path(str(record.get("beaver_home", "") or "")).expanduser()
config_path = beaver_home / "config.json"
if not config_path.is_absolute():
config_path = (APP_INSTANCE_DIR / config_path).resolve()
if not config_path.exists():
raise ApiError(HTTPStatus.NOT_FOUND, f"instance config not found: {config_path}")
try:
raw = json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ApiError(HTTPStatus.BAD_GATEWAY, f"invalid instance config: {config_path}") from exc
if not isinstance(raw, dict):
raise ApiError(HTTPStatus.BAD_GATEWAY, f"instance config must be an object: {config_path}")
agents = raw.get("agents")
if not isinstance(agents, dict):
agents = {}
raw["agents"] = agents
defaults = agents.get("defaults")
if not isinstance(defaults, dict):
defaults = {}
agents["defaults"] = defaults
providers = raw.get("providers")
if not isinstance(providers, dict):
providers = {}
raw["providers"] = providers
provider_payload: dict[str, Any] = {}
if api_key:
provider_payload["apiKey"] = api_key
if api_base:
provider_payload["apiBase"] = api_base
providers.clear()
providers[provider] = provider_payload
defaults["workspace"] = str(defaults.get("workspace", "") or "/root/.beaver/workspace")
defaults["provider"] = provider
defaults["model"] = model
write_json_file(config_path, raw)
container_name = str(record.get("container_name", "") or "").strip()
if not container_name:
raise ApiError(HTTPStatus.BAD_GATEWAY, "instance container name is missing")
run_command(["docker", "restart", container_name])
wait_for_backend(record)
ensure_proxy()
updated = get_registry_record(instance_id=str(record.get("instance_id", "") or instance_id))
if updated is None:
updated = record
return {
"configured": True,
"skipped": False,
"provider": provider,
"model": model,
"instance": updated,
"public_url": str(updated.get("public_url", "") or ""),
"frontend_base_url": str(updated.get("frontend_base_url", "") or updated.get("public_url", "") or ""),
"api_base_url": build_internal_api_base_url(updated),
}
def _upsert_registry_record(record: dict[str, Any]) -> dict[str, Any]:
instance_id = str(record.get("instance_id", "") or "").strip()
if not instance_id:
@ -467,6 +576,10 @@ class Handler(BaseHTTPRequestHandler):
payload = self._read_json_body()
self._json_response(HTTPStatus.OK, resolve_instance(payload))
return
if self.path == "/api/instances/configure-provider":
payload = self._read_json_body()
self._json_response(HTTPStatus.OK, configure_instance_provider(payload))
return
raise ApiError(HTTPStatus.NOT_FOUND, "not found")
except ApiError as exc:
self._json_response(exc.status_code, {"detail": exc.detail})