mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
This commit is contained in:
parent
ba3836f00f
commit
4a23b715a2
498 changed files with 2760 additions and 2200 deletions
4
interfaces/admin/__init__.py
Normal file
4
interfaces/admin/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
|
||||
1532
interfaces/admin/chat_api.py
Normal file
1532
interfaces/admin/chat_api.py
Normal file
File diff suppressed because it is too large
Load diff
184
interfaces/admin/mcp_e2e_probe.py
Normal file
184
interfaces/admin/mcp_e2e_probe.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""Build dynamic MCP E2E probe plans from the live ToolRegistry (one tool use per MCP server_id)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.tools.base import ToolRegistry, ToolSpec
|
||||
from oclaw.runtime.tools.tool_validation import validate_tool_arguments
|
||||
|
||||
|
||||
def parse_mcp_bound_tool_name(full_name: str) -> tuple[str, str] | None:
|
||||
"""Parse ``mcp__{server_id}__{mcp_tool}`` into (server_id, mcp_tool_name)."""
|
||||
if not full_name.startswith("mcp__"):
|
||||
return None
|
||||
rest = full_name[len("mcp__") :]
|
||||
idx = rest.find("__")
|
||||
if idx < 0:
|
||||
return None
|
||||
server_id = rest[:idx].strip()
|
||||
tool = rest[idx + 2 :].strip()
|
||||
if not server_id or not tool:
|
||||
return None
|
||||
return server_id, tool
|
||||
|
||||
|
||||
def _json_schema_required_keys(parameters: dict[str, Any]) -> list[str]:
|
||||
if not isinstance(parameters, dict) or parameters.get("type") != "object":
|
||||
return []
|
||||
req = parameters.get("required")
|
||||
if not isinstance(req, list):
|
||||
return []
|
||||
return [str(x).strip() for x in req if str(x).strip()]
|
||||
|
||||
|
||||
def _fill_required_from_properties(
|
||||
parameters: dict[str, Any],
|
||||
*,
|
||||
workspace_root: str,
|
||||
) -> dict[str, Any] | None:
|
||||
props = parameters.get("properties") if isinstance(parameters.get("properties"), dict) else {}
|
||||
req = _json_schema_required_keys(parameters)
|
||||
out: dict[str, Any] = {}
|
||||
for key in req:
|
||||
prop = props.get(key) if isinstance(props.get(key), dict) else {}
|
||||
t = prop.get("type")
|
||||
lk = key.lower()
|
||||
if t == "string":
|
||||
if lk in ("path", "cwd", "repopath", "directory", "filepath") or lk.endswith("path"):
|
||||
out[key] = workspace_root
|
||||
elif lk == "url":
|
||||
out[key] = "https://example.com"
|
||||
elif lk == "query":
|
||||
out[key] = "model context protocol"
|
||||
elif lk == "message":
|
||||
out[key] = "mcp-e2e"
|
||||
elif lk == "timezone":
|
||||
out[key] = "UTC"
|
||||
else:
|
||||
out[key] = ""
|
||||
elif t == "boolean":
|
||||
out[key] = True if lk in ("includeuntracked", "includetracked") else False
|
||||
elif t in ("number", "integer"):
|
||||
out[key] = 0
|
||||
elif t == "array":
|
||||
out[key] = []
|
||||
elif t == "object":
|
||||
out[key] = {}
|
||||
else:
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
# MCP tool names (suffix after server_id) with explicit args when schema is missing or validation needs concrete values.
|
||||
_KNOWN_MCP_TOOL_ARGS: dict[str, dict[str, Any]] = {
|
||||
"sequentialthinking": {
|
||||
"thought": "e2e",
|
||||
"nextThoughtNeeded": False,
|
||||
"thoughtNumber": 1,
|
||||
"totalThoughts": 1,
|
||||
},
|
||||
}
|
||||
|
||||
# Lower index = higher priority when multiple tools are callable.
|
||||
_PROBE_PRIORITY: tuple[str, ...] = (
|
||||
"browser_close",
|
||||
"read_graph",
|
||||
"db_info",
|
||||
"list_pdfs",
|
||||
"echo",
|
||||
"list_directory",
|
||||
"git_status",
|
||||
"fetch_markdown",
|
||||
"web_search",
|
||||
"sequentialthinking",
|
||||
)
|
||||
|
||||
|
||||
def _probe_args_for_spec(spec: ToolSpec, *, workspace_root: str) -> dict[str, Any] | None:
|
||||
parsed = parse_mcp_bound_tool_name(spec.name)
|
||||
if not parsed:
|
||||
return None
|
||||
_, mcp_tool = parsed
|
||||
params = spec.parameters if isinstance(spec.parameters, dict) else {}
|
||||
|
||||
if mcp_tool in _KNOWN_MCP_TOOL_ARGS:
|
||||
args = dict(_KNOWN_MCP_TOOL_ARGS[mcp_tool])
|
||||
ok, _ = validate_tool_arguments(params, args)
|
||||
return args if ok else None
|
||||
|
||||
req = _json_schema_required_keys(params)
|
||||
if not req:
|
||||
args: dict[str, Any] = {}
|
||||
ok, _ = validate_tool_arguments(params, args)
|
||||
return args if ok else None
|
||||
|
||||
filled = _fill_required_from_properties(params, workspace_root=workspace_root)
|
||||
if filled is None:
|
||||
return None
|
||||
ok, _ = validate_tool_arguments(params, filled)
|
||||
return filled if ok else None
|
||||
|
||||
|
||||
def _pick_probe_for_server(specs: list[ToolSpec], *, workspace_root: str) -> tuple[ToolSpec, dict[str, Any]] | None:
|
||||
candidates: list[tuple[int, str, ToolSpec, dict[str, Any]]] = []
|
||||
for sp in specs:
|
||||
args = _probe_args_for_spec(sp, workspace_root=workspace_root)
|
||||
if args is None:
|
||||
continue
|
||||
ok, _ = validate_tool_arguments(sp.parameters or {}, args)
|
||||
if not ok:
|
||||
continue
|
||||
parsed = parse_mcp_bound_tool_name(sp.name)
|
||||
mcp_tool = (parsed or ("", ""))[1]
|
||||
try:
|
||||
pri = _PROBE_PRIORITY.index(mcp_tool)
|
||||
except ValueError:
|
||||
pri = 900
|
||||
candidates.append((pri, mcp_tool, sp, args))
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda x: (x[0], x[1], x[2].name))
|
||||
return candidates[0][2], candidates[0][3]
|
||||
|
||||
|
||||
def build_mcp_e2e_probe_plans(
|
||||
reg: ToolRegistry,
|
||||
*,
|
||||
workspace_root: str,
|
||||
) -> tuple[list[tuple[str, str, dict[str, Any]]], list[str]]:
|
||||
"""
|
||||
Returns (plans, skipped_server_ids).
|
||||
|
||||
``plans`` entries are ``(server_id, full_tool_name, arguments)`` for ``ToolExecutor.execute_tool_uses``.
|
||||
One probe per MCP ``server_id`` discovered from registry names ``mcp__*__*`` with tag ``mcp``.
|
||||
|
||||
``skipped_server_ids`` lists servers that had MCP tools in the registry but no schema-safe probe
|
||||
could be constructed (no false positives from arbitrary ``tools/call``).
|
||||
"""
|
||||
by_server: dict[str, list[ToolSpec]] = {}
|
||||
for spec in reg.list():
|
||||
if "mcp" not in (spec.tags or frozenset()):
|
||||
continue
|
||||
parsed = parse_mcp_bound_tool_name(spec.name)
|
||||
if not parsed:
|
||||
continue
|
||||
sid, _ = parsed
|
||||
by_server.setdefault(sid, []).append(spec)
|
||||
|
||||
plans: list[tuple[str, str, dict[str, Any]]] = []
|
||||
for sid in sorted(by_server.keys()):
|
||||
picked = _pick_probe_for_server(by_server[sid], workspace_root=workspace_root)
|
||||
if picked is None:
|
||||
continue
|
||||
spec, args = picked
|
||||
plans.append((sid, spec.name, args))
|
||||
planned = {p[0] for p in plans}
|
||||
skipped = [s for s in sorted(by_server.keys()) if s not in planned]
|
||||
return plans, skipped
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_mcp_e2e_probe_plans",
|
||||
"parse_mcp_bound_tool_name",
|
||||
]
|
||||
|
||||
561
interfaces/admin/models_api.py
Normal file
561
interfaces/admin/models_api.py
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
"""Admin API: LLM profiles, agent bindings, UI language, eval (parity with Streamlit settings)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from oclaw.runtime.agents.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL
|
||||
from oclaw.runtime.agents.specialists import (
|
||||
AGENT_PROFILE_BINDINGS_KEY,
|
||||
AGENT_ROLE_IDS,
|
||||
dump_agent_profile_bindings,
|
||||
parse_agent_profile_bindings,
|
||||
)
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.runtime.orchestration.evaluation import eval_summary
|
||||
from oclaw.platform.persistence.sqlite_store import (
|
||||
LLM_BUILTIN_OLLAMA_PROFILE_ID,
|
||||
SqliteStore,
|
||||
active_llm_profile_setting_key,
|
||||
agent_profile_bindings_setting_key,
|
||||
is_administrator_model_pool,
|
||||
)
|
||||
|
||||
_LLM_MODE_OPTIONS = frozenset({"openai", "openai_responses", "anthropic", "google", "ollama", "rule"})
|
||||
_LLM_USER_CREATE_MODES = frozenset({"openai", "anthropic", "google", "ollama", "rule"})
|
||||
|
||||
|
||||
def _require_permission(ctx: dict[str, Any], permission: str) -> None:
|
||||
perms = set(str(x) for x in (ctx.get("permissions") or []))
|
||||
if permission in perms:
|
||||
return
|
||||
if str(ctx.get("role") or "") == "owner":
|
||||
return
|
||||
raise HTTPException(status_code=403, detail=f"forbidden:{permission}")
|
||||
|
||||
|
||||
def _require_models_mutate(ctx: dict[str, Any]) -> None:
|
||||
"""administrator 编辑全局池需 tenant:write;其余用户编辑自己的复制池仅需 read。"""
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if is_administrator_model_pool(uname):
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
else:
|
||||
_require_permission(ctx, "admin:read")
|
||||
|
||||
|
||||
def _models_list_kwargs(ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
if not uid:
|
||||
return {}
|
||||
out: dict[str, Any] = {"viewer_user_id": uid, "viewer_username": uname or None}
|
||||
if tid:
|
||||
out["viewer_tenant_id"] = tid
|
||||
return out
|
||||
|
||||
|
||||
def _active_key(ctx: dict[str, Any]) -> str:
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if not uid:
|
||||
return "active_llm_profile_id"
|
||||
return active_llm_profile_setting_key(uid, uname or None)
|
||||
|
||||
|
||||
def _bindings_key(ctx: dict[str, Any]) -> str:
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if not uid:
|
||||
return AGENT_PROFILE_BINDINGS_KEY
|
||||
return agent_profile_bindings_setting_key(uid, uname or None)
|
||||
|
||||
|
||||
def _assert_profile_mutable(ctx: dict[str, Any], prof: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not prof:
|
||||
raise HTTPException(status_code=404, detail="profile_not_found")
|
||||
if prof.get("is_builtin"):
|
||||
return prof
|
||||
own = str(prof.get("owner_user_id") or "").strip()
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if is_administrator_model_pool(uname):
|
||||
return prof
|
||||
if own == uid:
|
||||
return prof
|
||||
raise HTTPException(status_code=403, detail="profile_forbidden")
|
||||
|
||||
|
||||
def _can_manage_llm_grants(ctx: dict[str, Any]) -> bool:
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if not is_administrator_model_pool(uname):
|
||||
return False
|
||||
perms = set(str(x) for x in (ctx.get("permissions") or []))
|
||||
if "admin:tenant:write" in perms:
|
||||
return True
|
||||
return str(ctx.get("role") or "") == "owner"
|
||||
|
||||
|
||||
def _require_grant_manager(ctx: dict[str, Any]) -> None:
|
||||
if not _can_manage_llm_grants(ctx):
|
||||
raise HTTPException(status_code=403, detail="grants_administrator_only")
|
||||
|
||||
|
||||
def _profile_shareable_for_admin_grant(ctx: dict[str, Any], prof: dict[str, Any] | None) -> bool:
|
||||
"""仅全局池(无 owner)或操作者本人名下的 profile 可被授权给团队/用户。"""
|
||||
if not prof or prof.get("is_builtin"):
|
||||
return False
|
||||
own = str(prof.get("owner_user_id") or "").strip()
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
if not own:
|
||||
return True
|
||||
return bool(uid) and own == uid
|
||||
|
||||
|
||||
def _normalize_active(
|
||||
store: SqliteStore, profiles: list[dict[str, Any]], profile_ids: list[str], ctx: dict[str, Any]
|
||||
) -> str:
|
||||
if not profile_ids:
|
||||
return ""
|
||||
key = _active_key(ctx)
|
||||
active_id = str(store.get_setting(key) or "").strip()
|
||||
if active_id not in profile_ids:
|
||||
store.set_setting(key, LLM_BUILTIN_OLLAMA_PROFILE_ID)
|
||||
active_id = LLM_BUILTIN_OLLAMA_PROFILE_ID
|
||||
if active_id not in profile_ids:
|
||||
active_id = profile_ids[0]
|
||||
store.set_setting(key, active_id)
|
||||
return active_id
|
||||
|
||||
|
||||
def include_model_mgmt_routes(
|
||||
router: APIRouter,
|
||||
*,
|
||||
resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]],
|
||||
) -> None:
|
||||
mg = APIRouter(prefix="/admin/api/models", tags=["models"])
|
||||
|
||||
@mg.get("")
|
||||
def api_models_state(
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
lk = _models_list_kwargs(ctx)
|
||||
profiles = store.list_llm_profiles(visible_only=True, **lk)
|
||||
profile_ids = [str(p["id"]) for p in profiles]
|
||||
active_id = _normalize_active(store, profiles, profile_ids, ctx)
|
||||
bindings = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
|
||||
ui_lang = str(store.get_setting("ui_lang") or "zh").strip().lower()
|
||||
if ui_lang not in ("zh", "en"):
|
||||
ui_lang = "zh"
|
||||
secret = ""
|
||||
if active_id and active_id in profile_ids:
|
||||
active_prof = next((p for p in profiles if str(p.get("id") or "") == active_id), None)
|
||||
if is_administrator_model_pool(uname) or (active_prof and active_prof.get("mutable", True)):
|
||||
secret = store.get_llm_profile_secret(active_id) or ""
|
||||
out: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"active_llm_profile_id": active_id,
|
||||
"profiles": profiles,
|
||||
"bindings": bindings,
|
||||
"ui_lang": ui_lang,
|
||||
"builtin_ollama_profile_id": LLM_BUILTIN_OLLAMA_PROFILE_ID,
|
||||
"has_openai_api_key_env": bool((os.getenv("OPENAI_API_KEY") or "").strip()),
|
||||
"role_ids": list(AGENT_ROLE_IDS),
|
||||
"profile_secret": secret,
|
||||
"can_manage_llm_grants": _can_manage_llm_grants(ctx),
|
||||
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
|
||||
"db_path": db_path(),
|
||||
}
|
||||
return out
|
||||
|
||||
@mg.post("/active")
|
||||
def api_models_set_active(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
# 与能进入控制台一致:切换「当前选用」不写密钥,仅需读权限即可。
|
||||
_require_permission(ctx, "admin:read")
|
||||
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
|
||||
profile_ids = [str(p["id"]) for p in profiles]
|
||||
pid = str(payload.get("profile_id") or "").strip()
|
||||
if pid not in profile_ids:
|
||||
raise HTTPException(status_code=400, detail="invalid_profile_id")
|
||||
store.set_setting(_active_key(ctx), pid)
|
||||
return {"ok": True, "active_llm_profile_id": pid}
|
||||
|
||||
@mg.post("/bindings")
|
||||
def api_models_set_bindings(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
|
||||
profile_ids = set(str(p["id"]) for p in profiles)
|
||||
raw = payload.get("bindings")
|
||||
if not isinstance(raw, dict):
|
||||
raise HTTPException(status_code=400, detail="bindings_object_required")
|
||||
cur = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
|
||||
for rid in AGENT_ROLE_IDS:
|
||||
v = raw.get(rid)
|
||||
if v is None:
|
||||
continue
|
||||
s = str(v).strip()
|
||||
if s and s not in profile_ids:
|
||||
raise HTTPException(status_code=400, detail=f"invalid_binding:{rid}")
|
||||
cur[rid] = s
|
||||
store.set_setting(_bindings_key(ctx), dump_agent_profile_bindings(cur))
|
||||
return {"ok": True, "bindings": cur}
|
||||
|
||||
@mg.post("/profiles")
|
||||
def api_models_create_profile(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
name = str(payload.get("name") or "").strip() or "新配置"
|
||||
mode = str(payload.get("mode") or "openai").strip().lower()
|
||||
if mode not in _LLM_USER_CREATE_MODES:
|
||||
raise HTTPException(status_code=400, detail="invalid_mode")
|
||||
if mode == "openai":
|
||||
new_model = "gpt-4o-mini"
|
||||
new_bu = ""
|
||||
else:
|
||||
new_model = DEFAULT_OLLAMA_MODEL
|
||||
new_bu = DEFAULT_OLLAMA_BASE_URL
|
||||
own: str | None = None
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if uid and not is_administrator_model_pool(uname):
|
||||
own = uid
|
||||
pid = store.create_llm_profile(name=name, mode=mode, model=new_model, base_url=new_bu or None, owner_user_id=own)
|
||||
store.set_setting(_active_key(ctx), pid)
|
||||
prof = store.get_llm_profile(pid)
|
||||
return {"ok": True, "profile_id": pid, "profile": prof}
|
||||
|
||||
@mg.patch("/profiles/{profile_id}")
|
||||
def api_models_patch_profile(
|
||||
profile_id: str,
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
pid = str(profile_id or "").strip()
|
||||
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
|
||||
name = str(payload.get("name") if payload.get("name") is not None else prof.get("name") or "").strip() or "未命名"
|
||||
mode_raw = str(payload.get("mode") if payload.get("mode") is not None else prof.get("mode") or "openai").strip().lower()
|
||||
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
|
||||
mode_save = "ollama"
|
||||
else:
|
||||
if mode_raw not in _LLM_MODE_OPTIONS:
|
||||
raise HTTPException(status_code=400, detail="invalid_mode")
|
||||
mode_save = mode_raw
|
||||
model = payload.get("model")
|
||||
base_url = payload.get("base_url")
|
||||
model_s = str(model).strip() if model is not None else str(prof.get("model") or "").strip()
|
||||
bu_s = str(base_url).strip() if base_url is not None else str(prof.get("base_url") or "").strip()
|
||||
store.update_llm_profile(
|
||||
profile_id=pid,
|
||||
name=name,
|
||||
mode=mode_save,
|
||||
model=model_s or None,
|
||||
base_url=bu_s or None,
|
||||
)
|
||||
store.set_setting(_active_key(ctx), pid)
|
||||
return {"ok": True, "profile": store.get_llm_profile(pid)}
|
||||
|
||||
@mg.post("/profiles/{profile_id}/secret")
|
||||
def api_models_profile_secret(
|
||||
profile_id: str,
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
pid = str(profile_id or "").strip()
|
||||
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
|
||||
remember = bool(payload.get("remember"))
|
||||
key_text = str(payload.get("secret") or "").strip()
|
||||
mode_save = str(prof.get("mode") or "openai").strip().lower()
|
||||
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
|
||||
mode_save = "ollama"
|
||||
if mode_save in ("openai", "ollama"):
|
||||
if remember:
|
||||
if key_text:
|
||||
store.set_llm_profile_secret(pid, key_text)
|
||||
elif mode_save == "openai":
|
||||
raise HTTPException(status_code=400, detail="remember_key_empty")
|
||||
else:
|
||||
store.clear_llm_profile_secret(pid)
|
||||
else:
|
||||
store.clear_llm_profile_secret(pid)
|
||||
return {"ok": True, "profile": store.get_llm_profile(pid)}
|
||||
|
||||
@mg.delete("/profiles/{profile_id}")
|
||||
def api_models_delete_profile(
|
||||
profile_id: str,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
pid = str(profile_id or "").strip()
|
||||
_assert_profile_mutable(ctx, store.get_llm_profile(pid))
|
||||
try:
|
||||
store.delete_llm_profile(pid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="cannot_delete_builtin")
|
||||
remaining = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
|
||||
new_active = remaining[0]["id"] if remaining else LLM_BUILTIN_OLLAMA_PROFILE_ID
|
||||
store.set_setting(_active_key(ctx), new_active)
|
||||
return {"ok": True, "active_llm_profile_id": new_active}
|
||||
|
||||
@mg.get("/members")
|
||||
def api_models_members(
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
if not tid:
|
||||
raise HTTPException(status_code=400, detail="tenant_required")
|
||||
users = store.list_users(tenant_id=tid, limit=500, offset=0, include_inactive=True)
|
||||
members: list[dict[str, Any]] = []
|
||||
for u in users:
|
||||
mid = str(u.get("id") or "").strip()
|
||||
un = str(u.get("username") or "").strip()
|
||||
if not mid:
|
||||
continue
|
||||
profs = store.list_llm_profiles(
|
||||
visible_only=True,
|
||||
viewer_user_id=mid,
|
||||
viewer_username=un or None,
|
||||
viewer_tenant_id=tid,
|
||||
)
|
||||
members.append(
|
||||
{
|
||||
"user_id": mid,
|
||||
"username": un,
|
||||
"display_name": str(u.get("display_name") or "").strip(),
|
||||
"role": str(u.get("role") or "").strip(),
|
||||
"profiles": profs,
|
||||
}
|
||||
)
|
||||
return {"ok": True, "members": members}
|
||||
|
||||
@mg.get("/grants/tenant")
|
||||
def api_models_grants_tenant_get(
|
||||
profile_id: str = Query(...),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(profile_id or "").strip()
|
||||
if not tid or not pid:
|
||||
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
|
||||
granted = store.tenant_has_llm_profile_grant(tid, pid)
|
||||
return {"ok": True, "granted": granted}
|
||||
|
||||
@mg.post("/grants/tenant")
|
||||
def api_models_grants_tenant_create(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(payload.get("profile_id") or "").strip()
|
||||
if not tid or not pid:
|
||||
raise HTTPException(status_code=400, detail="profile_id_required")
|
||||
prof = store.get_llm_profile(pid)
|
||||
if not _profile_shareable_for_admin_grant(ctx, prof):
|
||||
raise HTTPException(status_code=403, detail="profile_not_shareable")
|
||||
actor = str(ctx.get("user_id") or "").strip() or None
|
||||
try:
|
||||
gid = store.grant_llm_profile_to_tenant(
|
||||
tenant_id=tid, profile_id=pid, created_by_user_id=actor
|
||||
)
|
||||
except ValueError as e:
|
||||
code = str(e)
|
||||
if code == "profile_not_found":
|
||||
raise HTTPException(status_code=404, detail=code) from e
|
||||
raise HTTPException(status_code=400, detail=code) from e
|
||||
return {"ok": True, "grant_id": gid}
|
||||
|
||||
@mg.delete("/grants/tenant")
|
||||
def api_models_grants_tenant_revoke(
|
||||
profile_id: str = Query(...),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(profile_id or "").strip()
|
||||
if not tid or not pid:
|
||||
raise HTTPException(status_code=400, detail="profile_id_required")
|
||||
n = store.revoke_llm_profile_tenant_grant(tenant_id=tid, profile_id=pid)
|
||||
return {"ok": True, "removed": int(n)}
|
||||
|
||||
@mg.get("/grants")
|
||||
def api_models_grants_list(
|
||||
profile_id: str = Query(..., description="llm_profile id"),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(profile_id or "").strip()
|
||||
if not tid or not pid:
|
||||
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
|
||||
rows = store.list_llm_profile_grants_for_profile(tid, pid)
|
||||
return {"ok": True, "grants": rows}
|
||||
|
||||
@mg.post("/grants")
|
||||
def api_models_grants_create(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(payload.get("profile_id") or "").strip()
|
||||
uid = str(payload.get("user_id") or "").strip()
|
||||
if not tid or not pid or not uid:
|
||||
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
|
||||
prof = store.get_llm_profile(pid)
|
||||
if not _profile_shareable_for_admin_grant(ctx, prof):
|
||||
raise HTTPException(status_code=403, detail="profile_not_shareable")
|
||||
actor = str(ctx.get("user_id") or "").strip() or None
|
||||
try:
|
||||
gid = store.grant_llm_profile_to_user(
|
||||
tenant_id=tid,
|
||||
profile_id=pid,
|
||||
user_id=uid,
|
||||
created_by_user_id=actor,
|
||||
)
|
||||
except ValueError as e:
|
||||
code = str(e)
|
||||
if code == "profile_not_found":
|
||||
raise HTTPException(status_code=404, detail=code) from e
|
||||
if code == "user_not_found":
|
||||
raise HTTPException(status_code=404, detail=code) from e
|
||||
raise HTTPException(status_code=400, detail=code) from e
|
||||
return {"ok": True, "grant_id": gid}
|
||||
|
||||
@mg.delete("/grants")
|
||||
def api_models_grants_revoke(
|
||||
profile_id: str = Query(...),
|
||||
user_id: str = Query(...),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(profile_id or "").strip()
|
||||
uid = str(user_id or "").strip()
|
||||
if not tid or not pid or not uid:
|
||||
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
|
||||
n = store.revoke_llm_profile_grant(tenant_id=tid, profile_id=pid, user_id=uid)
|
||||
return {"ok": True, "removed": int(n)}
|
||||
|
||||
@mg.get("/eval")
|
||||
def api_models_eval(
|
||||
authorization: str | None = Header(default=None),
|
||||
limit_logs: int = Query(default=100, ge=1, le=500),
|
||||
limit_summary: int = Query(default=500, ge=1, le=5000),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
summary = eval_summary(store, limit=limit_summary)
|
||||
logs = store.list_agent_eval_logs(limit=limit_logs)
|
||||
return {"ok": True, "summary": summary, "logs": logs}
|
||||
|
||||
_EVAL_EXPORT_FIELDS = (
|
||||
"timestamp",
|
||||
"session_id",
|
||||
"specialist",
|
||||
"task_kind",
|
||||
"success",
|
||||
"latency_ms",
|
||||
"cost_hint",
|
||||
"notes",
|
||||
)
|
||||
|
||||
@mg.get("/eval/export")
|
||||
def api_models_eval_export(
|
||||
authorization: str | None = Header(default=None),
|
||||
format: str = Query(default="csv", description="csv or json"),
|
||||
limit: int = Query(default=100_000, ge=1, le=200_000),
|
||||
) -> Response:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
fmt = str(format or "csv").strip().lower()
|
||||
rows = store.list_agent_eval_logs(limit=limit)
|
||||
if fmt == "json":
|
||||
body = json.dumps(rows, ensure_ascii=False, indent=2)
|
||||
return Response(
|
||||
content=body.encode("utf-8"),
|
||||
media_type="application/json; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="agent_eval_logs.json"',
|
||||
},
|
||||
)
|
||||
if fmt != "csv":
|
||||
raise HTTPException(status_code=400, detail="invalid_format")
|
||||
buf = io.StringIO()
|
||||
w = csv.DictWriter(buf, fieldnames=list(_EVAL_EXPORT_FIELDS), extrasaction="ignore")
|
||||
w.writeheader()
|
||||
for r in rows:
|
||||
row = {k: r.get(k) for k in _EVAL_EXPORT_FIELDS}
|
||||
if "success" in row:
|
||||
row["success"] = 1 if bool(row.get("success")) else 0
|
||||
w.writerow(row)
|
||||
payload = "\ufeff" + buf.getvalue()
|
||||
return Response(
|
||||
content=payload.encode("utf-8"),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="agent_eval_logs.csv"'},
|
||||
)
|
||||
|
||||
router.include_router(mg)
|
||||
|
||||
|
||||
__all__ = ["include_model_mgmt_routes"]
|
||||
3069
interfaces/admin/routes.py
Normal file
3069
interfaces/admin/routes.py
Normal file
File diff suppressed because it is too large
Load diff
604
interfaces/admin/skills_api.py
Normal file
604
interfaces/admin/skills_api.py
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException
|
||||
|
||||
from oclaw.runtime.agents.factory import build_gateway_executor
|
||||
from oclaw.runtime.skill_installer import (
|
||||
auto_install_skill_from_payload,
|
||||
create_skill_from_template,
|
||||
install_skill_from_local_dir,
|
||||
install_skill_from_registry_archive,
|
||||
list_skills_with_status,
|
||||
set_skill_enabled,
|
||||
)
|
||||
from oclaw.runtime.skill_role_binding import (
|
||||
SKILL_ROLE_BINDING_ENABLED_SETTING,
|
||||
SKILL_ROLE_BINDING_KEY,
|
||||
load_skill_role_binding_dict,
|
||||
normalize_skill_role_binding,
|
||||
ordered_binding_roles,
|
||||
skill_role_binding_enabled,
|
||||
)
|
||||
from oclaw.runtime.skills_prompt import collect_skill_catalog_entries
|
||||
from oclaw.runtime.skills import _allowed_tool_names_after_wire_policy, discover_workspace_skill_manifests
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.runtime.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
|
||||
from oclaw.runtime.tools.skills.clawhub_client import search_skills as clawhub_search_skills
|
||||
|
||||
|
||||
def include_skill_routes(
|
||||
router: APIRouter,
|
||||
*,
|
||||
resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]],
|
||||
) -> None:
|
||||
sk = APIRouter(prefix="/admin/api/skills", tags=["skills"])
|
||||
|
||||
def _require_admin(ctx: dict[str, Any]) -> None:
|
||||
perms = set(str(x) for x in (ctx.get("permissions") or []))
|
||||
if "admin:read" in perms or str(ctx.get("role") or "") == "owner":
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="forbidden:admin:read")
|
||||
|
||||
def _require_tenant_write(ctx: dict[str, Any]) -> None:
|
||||
perms = set(str(x) for x in (ctx.get("permissions") or []))
|
||||
if "admin:tenant:write" in perms or str(ctx.get("role") or "") == "owner":
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="forbidden:admin:tenant:write")
|
||||
|
||||
def _audit(store: SqliteStore, ctx: dict[str, Any], *, action: str, target_id: str, status: str, detail: dict[str, Any] | None = None) -> None:
|
||||
try:
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=str(ctx.get("tenant_id") or ""),
|
||||
actor_user_id=str(ctx.get("user_id") or ""),
|
||||
action=action,
|
||||
target_type="skill",
|
||||
target_id=str(target_id or ""),
|
||||
status=status,
|
||||
detail=detail or {},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _normalized_skill_binding(store: SqliteStore) -> tuple[list[str], dict[str, list[str]], set[str]]:
|
||||
roles = ordered_binding_roles()
|
||||
valid = {str(m.name).strip() for m in discover_workspace_skill_manifests() if str(m.name or "").strip()}
|
||||
mapping = normalize_skill_role_binding(
|
||||
mapping_raw=load_skill_role_binding_dict(store),
|
||||
valid_skill_names=valid,
|
||||
available_roles=roles,
|
||||
)
|
||||
return roles, mapping, valid
|
||||
|
||||
@sk.get("")
|
||||
def api_skills_list(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
items = list_skills_with_status(store=store)
|
||||
return {"ok": True, "items": items}
|
||||
|
||||
@sk.post("/install")
|
||||
def api_skills_install(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
source_dir = str(payload.get("source_dir") or "").strip()
|
||||
if not source_dir:
|
||||
raise HTTPException(status_code=400, detail="source_dir_required")
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
_audit(store, ctx, action="skill_install_started", target_id=source_dir, status="start", detail={"source": "local"})
|
||||
out = install_skill_from_local_dir(store=store, source_dir=source_dir, overwrite=overwrite)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_finished" if out.ok else "skill_install_failed",
|
||||
target_id=out.name or source_dir,
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "local", "input_target": source_dir},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.post("/install-registry")
|
||||
def api_skills_install_registry(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
archive_url = str(payload.get("archive_url") or "").strip()
|
||||
if not archive_url:
|
||||
raise HTTPException(status_code=400, detail="archive_url_required")
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
_audit(store, ctx, action="skill_install_started", target_id=archive_url, status="start", detail={"source": "registry"})
|
||||
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_finished" if out.ok else "skill_install_failed",
|
||||
target_id=out.name or archive_url,
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "registry", "input_target": archive_url},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.get("/market/search")
|
||||
def api_skills_market_search(
|
||||
q: str | None = None,
|
||||
limit: int | None = None,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
query = str(q or "").strip()
|
||||
lim = int(limit) if isinstance(limit, int) and limit > 0 else 20
|
||||
lim = max(1, min(lim, 200))
|
||||
items = clawhub_search_skills(query, limit=lim)
|
||||
return {"ok": True, "items": items}
|
||||
|
||||
@sk.get("/market/detail")
|
||||
def api_skills_market_detail(
|
||||
slug: str | None = None,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
s = str(slug or "").strip()
|
||||
if not s:
|
||||
raise HTTPException(status_code=400, detail="slug_required")
|
||||
detail = clawhub_get_skill_detail(s)
|
||||
return {"ok": True, "detail": detail}
|
||||
|
||||
@sk.post("/market/install")
|
||||
def api_skills_market_install(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
s = str(payload.get("slug") or "").strip()
|
||||
if not s:
|
||||
raise HTTPException(status_code=400, detail="slug_required")
|
||||
requested_version = str(payload.get("version") or "").strip()
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
|
||||
detail = clawhub_get_skill_detail(s)
|
||||
archive_url = str(detail.get("archiveUrl") or "").strip()
|
||||
chosen_version = str(detail.get("latestVersion") or "").strip()
|
||||
if requested_version:
|
||||
chosen_version = requested_version
|
||||
archive_url = ""
|
||||
for v in (detail.get("versions") or []):
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
if str(v.get("version") or "").strip() == requested_version:
|
||||
archive_url = str(v.get("archiveUrl") or "").strip()
|
||||
break
|
||||
|
||||
if not archive_url:
|
||||
raise HTTPException(status_code=400, detail="archive_url_unavailable")
|
||||
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_started",
|
||||
target_id=archive_url,
|
||||
status="start",
|
||||
detail={"source": "clawhub", "slug": s, "version": chosen_version, "input_target": s},
|
||||
)
|
||||
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_finished" if out.ok else "skill_install_failed",
|
||||
target_id=out.name or archive_url,
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={
|
||||
"detail": out.detail,
|
||||
"target_dir": out.target_dir,
|
||||
"source": "clawhub",
|
||||
"slug": s,
|
||||
"version": chosen_version,
|
||||
"input_target": archive_url,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.get("/binding")
|
||||
def api_skills_binding_get(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_tenant_write(ctx)
|
||||
roles, mapping, _valid = _normalized_skill_binding(store)
|
||||
items = list_skills_with_status(store=store)
|
||||
return {
|
||||
"ok": True,
|
||||
"enabled": bool(skill_role_binding_enabled(store=store)),
|
||||
"available_roles": roles,
|
||||
"installed_skills": items,
|
||||
"mapping": mapping,
|
||||
}
|
||||
|
||||
@sk.post("/binding")
|
||||
def api_skills_binding_save(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_tenant_write(ctx)
|
||||
if "enabled" in payload:
|
||||
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1" if bool(payload.get("enabled")) else "0")
|
||||
roles, _prev_mapping, valid = _normalized_skill_binding(store)
|
||||
mapping_raw = payload.get("mapping") if isinstance(payload.get("mapping"), dict) else {}
|
||||
mapping = normalize_skill_role_binding(
|
||||
mapping_raw=mapping_raw,
|
||||
valid_skill_names=valid,
|
||||
available_roles=roles,
|
||||
)
|
||||
store.set_setting(SKILL_ROLE_BINDING_KEY, json.dumps(mapping, ensure_ascii=False))
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_binding_update",
|
||||
target_id="skill_role_binding",
|
||||
status="ok",
|
||||
detail={"mapping": mapping, "enabled": bool(skill_role_binding_enabled(store=store))},
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"enabled": bool(skill_role_binding_enabled(store=store)),
|
||||
"available_roles": roles,
|
||||
"mapping": mapping,
|
||||
}
|
||||
|
||||
@sk.get("/effective")
|
||||
def api_skills_effective(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
roles, mapping, _valid = _normalized_skill_binding(store)
|
||||
role_rows: list[dict[str, Any]] = []
|
||||
for role in roles:
|
||||
specialist = "generalist" if role == "manager" else role
|
||||
ex = build_gateway_executor(store=store, specialist=specialist)
|
||||
tools = getattr(ex, "tools", None)
|
||||
model = getattr(ex, "model", None)
|
||||
if tools is None:
|
||||
role_rows.append(
|
||||
{
|
||||
"role": role,
|
||||
"total": 0,
|
||||
"workspace_total": 0,
|
||||
"workspace_direct": 0,
|
||||
"workspace_inherited_manager": 0,
|
||||
"mcp_total": 0,
|
||||
"tool_total": 0,
|
||||
"names_preview": [],
|
||||
}
|
||||
)
|
||||
continue
|
||||
base_url = str(getattr(model, "base_url", "") or "")
|
||||
entries = collect_skill_catalog_entries(
|
||||
store=store,
|
||||
registry=tools,
|
||||
base_url=base_url,
|
||||
skill_binding_role=role,
|
||||
)
|
||||
allowed_tool_names, _hidden_tool_names = _allowed_tool_names_after_wire_policy(
|
||||
registry=tools,
|
||||
store=store,
|
||||
base_url=base_url,
|
||||
)
|
||||
direct_set = set(mapping.get(role) or [])
|
||||
manager_set = set(mapping.get("manager") or [])
|
||||
workspace_total = 0
|
||||
workspace_direct = 0
|
||||
workspace_inherited = 0
|
||||
workspace_resolved_tool_match = 0
|
||||
workspace_docs_only = 0
|
||||
mcp_total = 0
|
||||
tool_total = 0
|
||||
names: list[str] = []
|
||||
docs_only_names: list[str] = []
|
||||
resolved_workspace_names: list[str] = []
|
||||
for nm, _desc, loc in entries:
|
||||
names.append(str(nm))
|
||||
vloc = str(loc or "")
|
||||
if vloc.endswith("SKILL.md"):
|
||||
workspace_total += 1
|
||||
if nm in allowed_tool_names:
|
||||
workspace_resolved_tool_match += 1
|
||||
resolved_workspace_names.append(str(nm))
|
||||
else:
|
||||
workspace_docs_only += 1
|
||||
docs_only_names.append(str(nm))
|
||||
if nm in direct_set:
|
||||
workspace_direct += 1
|
||||
elif role != "manager" and nm in manager_set:
|
||||
workspace_inherited += 1
|
||||
elif str(nm).startswith("mcp__"):
|
||||
mcp_total += 1
|
||||
else:
|
||||
tool_total += 1
|
||||
role_rows.append(
|
||||
{
|
||||
"role": role,
|
||||
"total": len(entries),
|
||||
"workspace_total": workspace_total,
|
||||
"workspace_direct": workspace_direct,
|
||||
"workspace_inherited_manager": workspace_inherited,
|
||||
"workspace_resolved_tool_match": workspace_resolved_tool_match,
|
||||
"workspace_docs_only": workspace_docs_only,
|
||||
"mcp_total": mcp_total,
|
||||
"tool_total": tool_total,
|
||||
"names_preview": names[:20],
|
||||
"docs_only_names_preview": docs_only_names[:20],
|
||||
"resolved_workspace_names_preview": resolved_workspace_names[:20],
|
||||
}
|
||||
)
|
||||
return {"ok": True, "enabled": bool(skill_role_binding_enabled(store=store)), "items": role_rows}
|
||||
|
||||
@sk.post("/create")
|
||||
def api_skills_create(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
name = str(payload.get("name") or "").strip()
|
||||
desc = str(payload.get("description") or "").strip()
|
||||
body = str(payload.get("body_markdown") or "").strip()
|
||||
md = payload.get("metadata_oclaw")
|
||||
md = dict(md) if isinstance(md, dict) else {}
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
out = create_skill_from_template(
|
||||
store=store,
|
||||
name=name,
|
||||
description=desc,
|
||||
body_markdown=body,
|
||||
metadata_oclaw=md,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_create",
|
||||
target_id=out.name or name,
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={"detail": out.detail, "target_dir": out.target_dir},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.post("/enable")
|
||||
def api_skills_enable(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
name = str(payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name_required")
|
||||
set_skill_enabled(store=store, skill_name=name, enabled=True)
|
||||
_audit(store, ctx, action="skill_enable", target_id=name, status="ok")
|
||||
return {"ok": True}
|
||||
|
||||
@sk.post("/disable")
|
||||
def api_skills_disable(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
name = str(payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name_required")
|
||||
set_skill_enabled(store=store, skill_name=name, enabled=False)
|
||||
_audit(store, ctx, action="skill_disable", target_id=name, status="ok")
|
||||
return {"ok": True}
|
||||
|
||||
@sk.post("/auto-install")
|
||||
def api_skills_auto_install(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
auto_name = str(payload.get("name") or "")
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_started",
|
||||
target_id=auto_name,
|
||||
status="start",
|
||||
detail={"source": "auto", "input_target": auto_name},
|
||||
)
|
||||
out = auto_install_skill_from_payload(store=store, payload=payload)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_finished" if out.ok else "skill_install_failed",
|
||||
target_id=out.name or str(payload.get("name") or ""),
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "auto", "input_target": auto_name},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.post("/retry-install")
|
||||
def api_skills_retry_install(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
source = str(payload.get("source") or "").strip().lower()
|
||||
target = str(payload.get("target") or "").strip()
|
||||
if source not in {"local", "registry", "auto"}:
|
||||
raise HTTPException(status_code=400, detail="invalid_source")
|
||||
if not target:
|
||||
raise HTTPException(status_code=400, detail="target_required")
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_started",
|
||||
target_id=target,
|
||||
status="start",
|
||||
detail={"source": source, "retry": True, "input_target": target},
|
||||
)
|
||||
if source == "local":
|
||||
out = install_skill_from_local_dir(store=store, source_dir=target, overwrite=True)
|
||||
elif source == "registry":
|
||||
out = install_skill_from_registry_archive(store=store, archive_url=target, overwrite=True)
|
||||
else:
|
||||
out = auto_install_skill_from_payload(
|
||||
store=store,
|
||||
payload={
|
||||
"name": str(payload.get("name") or "").strip() or target,
|
||||
"description": str(payload.get("description") or "retry auto install"),
|
||||
"body_markdown": str(payload.get("body_markdown") or ""),
|
||||
"metadata_oclaw": dict(payload.get("metadata_oclaw") or {})
|
||||
if isinstance(payload.get("metadata_oclaw"), dict)
|
||||
else {},
|
||||
},
|
||||
)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_finished" if out.ok else "skill_install_failed",
|
||||
target_id=out.name or target,
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={
|
||||
"detail": out.detail,
|
||||
"target_dir": out.target_dir,
|
||||
"source": source,
|
||||
"retry": True,
|
||||
"input_target": target,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.post("/test-run")
|
||||
def api_skills_test_run(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
name = str(payload.get("name") or "").strip()
|
||||
args = payload.get("args")
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name_required")
|
||||
if args is None:
|
||||
args = {}
|
||||
if not isinstance(args, dict):
|
||||
raise HTTPException(status_code=400, detail="args_must_be_object")
|
||||
|
||||
ex = build_gateway_executor(store=store, specialist="generalist")
|
||||
tools = getattr(ex, "tools", None)
|
||||
if tools is None:
|
||||
raise HTTPException(status_code=500, detail="tool_registry_unavailable")
|
||||
spec = tools.get(name)
|
||||
if spec is None:
|
||||
raise HTTPException(status_code=404, detail="tool_not_found")
|
||||
try:
|
||||
result = spec.handler(dict(args))
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "error_code": "exception", "error": f"{type(exc).__name__}:{exc}"}
|
||||
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_test_run",
|
||||
target_id=name,
|
||||
status="ok" if bool((result or {}).get("ok")) else "fail",
|
||||
detail={"args_keys": list(args.keys()), "result_ok": bool((result or {}).get("ok"))},
|
||||
)
|
||||
return {"ok": True, "name": name, "result": result}
|
||||
|
||||
router.include_router(sk)
|
||||
|
||||
|
||||
__all__ = ["include_skill_routes"]
|
||||
|
||||
7578
interfaces/admin/static/app.js
Normal file
7578
interfaces/admin/static/app.js
Normal file
File diff suppressed because it is too large
Load diff
455
interfaces/admin/static/chat.html
Normal file
455
interfaces/admin/static/chat.html
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Chat</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/admin/assets/styles.css" />
|
||||
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
|
||||
<style>
|
||||
body.theme-ds-body.chat-standalone-page {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--ds-bg, #0d0d0d);
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#app {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chat-app--login {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#app .chat-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.chat-sess-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.chat-sess-row:hover {
|
||||
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.05));
|
||||
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.09));
|
||||
}
|
||||
.chat-sess-row--active {
|
||||
background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062));
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.chat-sess-row .chat-sess-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-sess-more {
|
||||
flex: 0 0 2rem;
|
||||
min-width: 2rem;
|
||||
padding: 10px 0;
|
||||
border-radius: 0;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.chat-user-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.chat-user-row:hover {
|
||||
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
|
||||
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
.chat-user-row .chat-sess-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-sess-more:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.chat-sess-more:focus {
|
||||
outline: none;
|
||||
}
|
||||
.chat-sess-more:focus-visible {
|
||||
outline: 1px solid var(--chat-sess-border, rgba(255, 255, 255, 0.2));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.chat-sess-more--active {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.chat-sess-more--active:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.chat-sess-menu-pop {
|
||||
z-index: 200;
|
||||
background: var(--ds-panel, #222);
|
||||
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
min-width: 11rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.chat-sess-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.chat-sess-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.chat-msg__md {
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
.chat-msg .chat-msg__md p {
|
||||
margin: 0.28em 0;
|
||||
}
|
||||
.chat-msg .chat-msg__md > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.chat-msg .chat-msg__md > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.chat-msg__md pre {
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
font-size: 12px;
|
||||
}
|
||||
.chat-msg__md code {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
/* 父级 .chat-msg 的 pre-wrap 会继承到子节点,影响图片与 Markdown 排版 */
|
||||
.chat-msg .chat-msg__md,
|
||||
.chat-msg .chat-att-wrap {
|
||||
white-space: normal;
|
||||
}
|
||||
.chat-msg__md img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
margin: 0.25rem 0;
|
||||
vertical-align: middle;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.chat-msg__plain {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.chat-msg__stream-status {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.62));
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.chat-att-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.chat-att-img {
|
||||
max-width: 180px;
|
||||
max-height: 180px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.chat-img-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chat-img-lightbox__inner {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.chat-img-lightbox__img {
|
||||
max-width: min(96vw, 100%);
|
||||
max-height: min(92vh, 100%);
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.chat-img-lightbox__close {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #eee;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.chat-img-lightbox__close:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
.chat-att-chip {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.chat-composer-shell {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
padding: 6px 8px 6px 6px;
|
||||
}
|
||||
.chat-composer-shell--busy .chat-composer__field {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.chat-pending-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 4.5rem;
|
||||
overflow-y: auto;
|
||||
padding: 2px 4px 6px;
|
||||
margin: 0 -2px;
|
||||
}
|
||||
.chat-pending-files:empty {
|
||||
display: none;
|
||||
}
|
||||
.chat-pending-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 2px 8px 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 11px;
|
||||
}
|
||||
.chat-pending-row .muted {
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chat-pending-row .chat-pending-remove {
|
||||
padding: 0 4px;
|
||||
min-height: 22px;
|
||||
min-width: 22px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-pending-row .chat-pending-remove:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.chat-composer-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
.chat-composer-iconbtn {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin: 0 0 2px 2px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.75));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.chat-composer-iconbtn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
}
|
||||
.chat-composer-iconbtn:focus-visible {
|
||||
outline: 1px solid var(--chat-sess-border, rgba(255, 255, 255, 0.25));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.chat-composer-iconbtn svg {
|
||||
display: block;
|
||||
}
|
||||
.chat-composer__field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
max-height: 200px;
|
||||
resize: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 10px 8px 12px 4px;
|
||||
margin-bottom: 2px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.chat-composer__field:focus {
|
||||
outline: none;
|
||||
}
|
||||
.chat-composer__field::placeholder {
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.45));
|
||||
}
|
||||
.chat-composer-actions {
|
||||
position: relative;
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 2px 2px 0;
|
||||
}
|
||||
.chat-composer-send,
|
||||
.chat-composer-stop {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.chat-composer-send {
|
||||
background: rgba(94, 179, 255, 0.92);
|
||||
color: #0a0a0c;
|
||||
}
|
||||
.chat-composer-send:hover:not(:disabled) {
|
||||
background: rgba(120, 195, 255, 1);
|
||||
}
|
||||
.chat-composer-send:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.chat-composer-stop {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #f3f3f3;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chat-composer-stop:hover:not(:disabled) {
|
||||
background: rgba(255, 80, 80, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
.chat-composer-stop:disabled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chat-composer-shell--busy .chat-composer-send {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chat-composer-shell--busy .chat-composer-stop:not(:disabled) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.chat-msg-cap {
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
#app .chat-status {
|
||||
flex-shrink: 0;
|
||||
min-height: 1.25rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.chat-confirm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 400;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.chat-confirm-card {
|
||||
width: min(420px, 92vw);
|
||||
background: linear-gradient(180deg, rgba(11, 18, 30, 0.98), rgba(7, 12, 22, 0.98));
|
||||
border: 1px solid rgba(94, 179, 255, 0.35);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
padding: 14px;
|
||||
}
|
||||
.chat-confirm-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body class="theme-ds-body chat-standalone-page">
|
||||
<div id="app"></div>
|
||||
<!-- Cache-bust for desktop webview: avoid stale chat.js -->
|
||||
<script src="/admin/assets/chat.js?v=20260421-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
3404
interfaces/admin/static/chat.js
Normal file
3404
interfaces/admin/static/chat.js
Normal file
File diff suppressed because it is too large
Load diff
344
interfaces/admin/static/default-user-avatar.svg
Normal file
344
interfaces/admin/static/default-user-avatar.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 307 KiB |
59
interfaces/admin/static/index.html
Normal file
59
interfaces/admin/static/index.html
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>oliver</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/admin/assets/styles.css" />
|
||||
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
|
||||
</head>
|
||||
<body class="theme-ds-body">
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand__logoWrap">
|
||||
<img class="brand__logo" src="/admin/assets/oliver.svg" alt="oliver logo" />
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<a class="nav__item" data-page="models" href="#/models" data-i18n="nav.models">模型管理</a>
|
||||
<a class="nav__item" data-page="api-grants" href="#/api-grants" data-i18n="nav.apiGrants">API 使用授权</a>
|
||||
<a class="nav__item" data-page="stack" href="#/stack" data-i18n="nav.stack">Runtime</a>
|
||||
<a class="nav__item" data-page="users" href="#/users" data-i18n="nav.users">用户管理</a>
|
||||
<a class="nav__item" data-page="workspace-paths" href="#/workspace-paths" data-i18n="nav.workspacePaths">工作区路径</a>
|
||||
<a class="nav__item" data-page="memory" href="#/memory" data-i18n="nav.memory">Memory</a>
|
||||
<a class="nav__item" data-page="audit" href="#/audit" data-i18n="nav.audit">Audit & Trace</a>
|
||||
<a class="nav__item" data-page="session-monitor" href="#/session-monitor" data-i18n="nav.sessionMonitor">会话监控</a>
|
||||
<a class="nav__item" data-page="admin-audit" href="#/admin-audit" data-i18n="nav.adminAudit">Admin Audit</a>
|
||||
<a class="nav__item" data-page="plugins" href="#/plugins" data-i18n="nav.plugins">Plugins</a>
|
||||
<a class="nav__item" data-page="skills" href="#/skills" data-i18n="nav.skills">Skills</a>
|
||||
<a class="nav__item" data-page="attachments" href="#/attachments" data-i18n="nav.attachments">附件</a>
|
||||
<a class="nav__item" data-page="profile" href="#/profile" data-i18n="nav.profile">设置</a>
|
||||
</nav>
|
||||
<div class="sidebar__footer">
|
||||
<div class="muted" data-i18n="notice.noLogin">v1 无登录:请仅在内网访问</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div id="topTitle" class="topbar__title">Runtime</div>
|
||||
<div class="topbar__actions">
|
||||
<span id="authUser" class="muted"></span>
|
||||
<a id="btnBackChat" class="btn" data-i18n="nav.chat" href="chat">对话</a>
|
||||
<button id="btnLogout" class="btn" data-i18n="auth.logout">退出登录</button>
|
||||
<button id="btnLang" class="btn">中文</button>
|
||||
<button id="btnRefresh" class="btn" data-i18n="action.refresh">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<section id="content" class="content"></section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/admin/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
333
interfaces/admin/static/oliver.svg
Normal file
333
interfaces/admin/static/oliver.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 111 KiB |
315
interfaces/admin/static/styles.css
Normal file
315
interfaces/admin/static/styles.css
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
|
||||
"Segoe UI Emoji";
|
||||
/* Prefer theme variables when enabled (e.g. theme-deepseek.css). */
|
||||
color: var(--ds-text, #e2e8f0);
|
||||
background: var(--ds-bg, #0b1020);
|
||||
}
|
||||
|
||||
.layout { display: flex; height: 100vh; }
|
||||
.sidebar { width: 260px; background: #0f172a; color: #e2e8f0; display: flex; flex-direction: column; border-right: 1px solid rgba(148,163,184,0.2); }
|
||||
.brand { padding: 16px 16px 8px; }
|
||||
.brand__logoWrap {
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.brand__logo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
.brand__title {
|
||||
font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.brand__sub { font-size: 12px; color: rgba(226,232,240,0.7); margin-top: 4px; }
|
||||
.nav { padding: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.nav__item { text-decoration: none; color: rgba(226,232,240,0.85); padding: 10px 12px; border-radius: 10px; }
|
||||
.nav__item:hover { background: rgba(148,163,184,0.12); }
|
||||
.nav__item--active { background: rgba(59,130,246,0.22); color: #e2e8f0; }
|
||||
.sidebar__footer { margin-top: auto; padding: 12px 16px; border-top: 1px solid rgba(148,163,184,0.2); }
|
||||
.muted { font-size: 12px; color: rgba(226,232,240,0.65); }
|
||||
|
||||
.main { flex: 1; display: flex; flex-direction: column; background: #0b1020; }
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid rgba(148,163,184,0.18); background: rgba(2,6,23,0.6); backdrop-filter: blur(10px); }
|
||||
.topbar__title { color: #e2e8f0; font-weight: 650; }
|
||||
.btn { background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 12px; cursor: pointer; }
|
||||
.btn:hover { background: rgba(148,163,184,0.18); }
|
||||
.btn--primary { background: rgba(59,130,246,0.25); border-color: rgba(59,130,246,0.4); }
|
||||
.btn--danger { background: rgba(239,68,68,0.18); border-color: rgba(239,68,68,0.35); }
|
||||
.content { padding: 18px; overflow: auto; color: #e2e8f0; }
|
||||
|
||||
.card { background: rgba(15,23,42,0.9); border: 1px solid rgba(148,163,184,0.18); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
|
||||
.card__title { font-weight: 650; margin-bottom: 10px; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.chat-login-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
.chat-login-fields .input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-login-actions {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.row--wecom-form {
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.row--wecom-form__field {
|
||||
flex: 1 1 140px;
|
||||
min-width: 0;
|
||||
max-width: 280px;
|
||||
}
|
||||
.row--wecom-form__field .input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.row--wecom-form__chk {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row--wecom-form .btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.kv { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; background: rgba(148,163,184,0.08); border: 1px solid rgba(148,163,184,0.14); padding: 6px 8px; border-radius: 10px; }
|
||||
.input { background: rgba(2,6,23,0.65); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 10px; min-width: 240px; }
|
||||
.input--compact {
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.input--readonly {
|
||||
max-width: min(100%, 720px);
|
||||
cursor: default;
|
||||
color: rgba(226,232,240,0.82);
|
||||
border-color: rgba(148,163,184,0.18);
|
||||
background: rgba(2,6,23,0.45);
|
||||
}
|
||||
.input--readonly::placeholder {
|
||||
color: rgba(226,232,240,0.45);
|
||||
}
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td { border-bottom: 1px solid rgba(148,163,184,0.18); padding: 10px 8px; text-align: left; font-size: 13px; }
|
||||
.table-wrap { width: 100%; overflow-x: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; }
|
||||
.table--compact { table-layout: fixed; min-width: 860px; }
|
||||
.table--compact th, .table--compact td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.table--compact td.table__cell--actions,
|
||||
.table--compact td.table__cell--form {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
.table__cell-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.session-monitor-row--active {
|
||||
background: rgba(94, 179, 255, 0.12);
|
||||
}
|
||||
.session-monitor-row--active td {
|
||||
border-bottom-color: rgba(94, 179, 255, 0.35);
|
||||
}
|
||||
.session-monitor-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 320;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.session-monitor-modal__card {
|
||||
width: min(1200px, 96vw);
|
||||
max-height: 92vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.session-monitor-session-table th:nth-child(1),
|
||||
.session-monitor-session-table td:nth-child(1) { width: 18%; }
|
||||
.session-monitor-session-table th:nth-child(2),
|
||||
.session-monitor-session-table td:nth-child(2) { width: 30%; }
|
||||
.session-monitor-session-table th:nth-child(3),
|
||||
.session-monitor-session-table td:nth-child(3) { width: 12%; }
|
||||
.session-monitor-session-table th:nth-child(4),
|
||||
.session-monitor-session-table td:nth-child(4) { width: 9%; }
|
||||
.session-monitor-session-table th:nth-child(5),
|
||||
.session-monitor-session-table td:nth-child(5) { width: 15%; }
|
||||
.session-monitor-session-table th:nth-child(6),
|
||||
.session-monitor-session-table td:nth-child(6) { width: 8%; }
|
||||
.session-monitor-session-table th:nth-child(7),
|
||||
.session-monitor-session-table td:nth-child(7) {
|
||||
width: 8%;
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
.session-monitor-detail-table th:nth-child(1),
|
||||
.session-monitor-detail-table td:nth-child(1) { width: 8%; }
|
||||
.session-monitor-detail-table th:nth-child(2),
|
||||
.session-monitor-detail-table td:nth-child(2) { width: 8%; }
|
||||
.session-monitor-detail-table th:nth-child(3),
|
||||
.session-monitor-detail-table td:nth-child(3) {
|
||||
width: 62%;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.session-monitor-detail-table th:nth-child(4),
|
||||
.session-monitor-detail-table td:nth-child(4) { width: 22%; }
|
||||
|
||||
/* Match chat-page "three dots" action style */
|
||||
.chat-sess-more {
|
||||
flex: 0 0 2rem;
|
||||
min-width: 2rem;
|
||||
padding: 6px 4px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.chat-sess-more:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.chat-sess-more:focus {
|
||||
outline: none;
|
||||
}
|
||||
.chat-sess-more:focus-visible {
|
||||
outline: 1px solid rgba(94, 179, 255, 0.35);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.chat-sess-more--active {
|
||||
background: rgba(94, 179, 255, 0.18);
|
||||
border-color: rgba(94, 179, 255, 0.3);
|
||||
}
|
||||
.chat-sess-more--active:hover {
|
||||
background: rgba(94, 179, 255, 0.22);
|
||||
}
|
||||
.chat-sess-menu-pop {
|
||||
z-index: 300;
|
||||
background: #222;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
min-width: 11rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.chat-sess-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.chat-sess-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.table--resizable thead th {
|
||||
position: relative;
|
||||
}
|
||||
.table-col-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.table-col-resizer:hover {
|
||||
background: rgba(94, 179, 255, 0.18);
|
||||
}
|
||||
body.col-resize-active {
|
||||
cursor: col-resize;
|
||||
}
|
||||
/* User management: fill row with % columns + roomier padding */
|
||||
.table--compact.table--users-mgmt {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
.table--users-mgmt th,
|
||||
.table--users-mgmt td { padding: 12px 14px; }
|
||||
.table--users-mgmt th:nth-child(1),
|
||||
.table--users-mgmt td:nth-child(1) { width: 15%; }
|
||||
.table--users-mgmt th:nth-child(2),
|
||||
.table--users-mgmt td:nth-child(2) { width: 18%; }
|
||||
.table--users-mgmt th:nth-child(3),
|
||||
.table--users-mgmt td:nth-child(3) { width: 8%; }
|
||||
.table--users-mgmt th:nth-child(4),
|
||||
.table--users-mgmt td:nth-child(4) { width: 6%; }
|
||||
.table--users-mgmt th:nth-child(5),
|
||||
.table--users-mgmt td:nth-child(5) { width: 13%; min-width: 7.5rem; }
|
||||
.table--users-mgmt th:nth-child(6),
|
||||
.table--users-mgmt td:nth-child(6) { width: 15%; min-width: 9rem; }
|
||||
.table--users-mgmt th:nth-child(7),
|
||||
.table--users-mgmt td:nth-child(7) {
|
||||
width: 25%;
|
||||
min-width: 15rem;
|
||||
text-align: left;
|
||||
}
|
||||
.table--users-mgmt .table__cell-actions {
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px 10px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.table--users-mgmt .table__cell--form .input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cell-copyable { cursor: copy; position: relative; }
|
||||
.cell-copyable:hover { background: rgba(59,130,246,0.12); }
|
||||
.cell-copyable.cell-selected { background: rgba(59,130,246,0.2); outline: 1px solid rgba(59,130,246,0.45); }
|
||||
.cell-copyable.cell-copied::after {
|
||||
content: "Copied";
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 4px;
|
||||
font-size: 11px;
|
||||
color: #93c5fd;
|
||||
}
|
||||
.details { border: 1px solid rgba(148,163,184,0.18); border-radius: 10px; padding: 8px 10px; background: rgba(2,6,23,0.3); }
|
||||
.details > summary { cursor: pointer; color: #cbd5e1; font-weight: 600; }
|
||||
.plugins-fold { margin-bottom: 12px; }
|
||||
.plugins-fold__inner { margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(148,163,184,0.12); }
|
||||
.plugins-pager .btn.btn--small { min-width: 72px; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(148,163,184,0.25); background: rgba(148,163,184,0.08); }
|
||||
.badge--ok { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.12); }
|
||||
.badge--bad { border-color: rgba(239,68,68,0.45); background: rgba(239,68,68,0.12); }
|
||||
.badge--mode-restricted { border-color: rgba(250,204,21,0.45); background: rgba(250,204,21,0.12); }
|
||||
.badge--mode-unrestricted { border-color: rgba(59,130,246,0.45); background: rgba(59,130,246,0.16); }
|
||||
.pre { white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; }
|
||||
.alert { margin: 8px 0; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.25); font-size: 13px; }
|
||||
.alert--critical { border-color: rgba(239,68,68,0.6); background: rgba(239,68,68,0.12); color: #fecaca; }
|
||||
.alert-list { margin: 8px 0 0; padding-left: 18px; color: #cbd5e1; font-size: 13px; }
|
||||
.alert-list li { margin: 4px 0; }
|
||||
775
interfaces/admin/static/theme-deepseek.css
Normal file
775
interfaces/admin/static/theme-deepseek.css
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
/* DeepSeek-like dark theme when body.theme-ds-body is set (e.g. #/chat). */
|
||||
|
||||
body.theme-ds-body {
|
||||
--ds-bg: #0d0d0d;
|
||||
/* 右侧对话主区:比侧栏/页面略深的黑 */
|
||||
--ds-chat-main: #050505;
|
||||
--ds-surface: #1a1a1c;
|
||||
--ds-surface-2: #222224;
|
||||
--ds-border: rgba(255, 255, 255, 0.08);
|
||||
--ds-text: #e8e8e8;
|
||||
--ds-text-muted: rgba(232, 232, 232, 0.55);
|
||||
--ds-accent: #5eb3ff;
|
||||
--ds-accent-soft: rgba(94, 179, 255, 0.18);
|
||||
/* 左侧会话列表:贴近侧栏 #0d0d0d,选中仅略提亮 */
|
||||
--chat-sess-hover-bg: rgba(255, 255, 255, 0.05);
|
||||
--chat-sess-active-bg: rgba(255, 255, 255, 0.062);
|
||||
--chat-sess-border: rgba(255, 255, 255, 0.09);
|
||||
/* 用户消息气泡(微信式绿底深字) */
|
||||
--chat-user-bubble-bg: #42b983;
|
||||
--chat-user-bubble-fg: #111111;
|
||||
}
|
||||
|
||||
body.theme-ds-body .layout .sidebar {
|
||||
background: #161618;
|
||||
border-right-color: var(--ds-border);
|
||||
}
|
||||
|
||||
body.theme-ds-body .brand__title,
|
||||
body.theme-ds-body .nav__item {
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .nav__item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.theme-ds-body .nav__item--active {
|
||||
background: var(--ds-accent-soft);
|
||||
border: 1px solid rgba(94, 179, 255, 0.25);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .sidebar__footer {
|
||||
border-top-color: var(--ds-border);
|
||||
}
|
||||
|
||||
body.theme-ds-body .main {
|
||||
background: var(--ds-bg);
|
||||
}
|
||||
|
||||
body.theme-ds-body .topbar {
|
||||
background: rgba(22, 22, 24, 0.92);
|
||||
border-bottom-color: var(--ds-border);
|
||||
}
|
||||
|
||||
body.theme-ds-body .topbar__title {
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .content {
|
||||
background: var(--ds-bg);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .muted {
|
||||
color: var(--ds-text-muted);
|
||||
}
|
||||
|
||||
body.theme-ds-body .btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: var(--ds-border);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body.theme-ds-body .btn--primary {
|
||||
background: var(--ds-accent-soft);
|
||||
border-color: rgba(94, 179, 255, 0.45);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .input {
|
||||
background: var(--ds-surface-2);
|
||||
border-color: var(--ds-border);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .card {
|
||||
background: var(--ds-surface);
|
||||
border-color: var(--ds-border);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
/* Chat layout(侧栏与主区融入背景:无外侧框、无列间距) */
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-nav {
|
||||
width: 252px;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: var(--ds-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-nav__top {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
padding: 12px 0 6px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chat-nav__toolbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
margin-top: -14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chat-nav__new {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 40px;
|
||||
padding: 8px 0 8px 8px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
/* 与侧栏底一致,上移后与 logo 下沿重叠时盖住底边 */
|
||||
background: var(--ds-bg);
|
||||
color: inherit;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.chat-nav__new:hover {
|
||||
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.chat-nav__newGlyph {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.22);
|
||||
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.chat-nav__new:hover .chat-nav__newGlyph {
|
||||
background-color: rgba(255, 255, 255, 0.22);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.chat-nav__newGlyph::before,
|
||||
.chat-nav__newGlyph::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
border-radius: 2px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.chat-nav__newGlyph::before {
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.chat-nav__newGlyph::after {
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.chat-nav__newLabel,
|
||||
.chat-nav__newText {
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-nav__brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.chat-nav__brandWrap {
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.chat-nav__brandLogo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 负水平偏移:在 cover 裁切下再向左露出一点画面 */
|
||||
object-position: -10px center;
|
||||
}
|
||||
|
||||
|
||||
.chat-nav__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
|
||||
.chat-sessions__list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-nav__footer {
|
||||
flex-shrink: 0;
|
||||
border-top: none;
|
||||
padding: 10px 0 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-nav__user {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-nav__user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-nav__user-role {
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.chat-nav__link {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
color: rgba(94, 179, 255, 0.95);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-nav__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chat-nav__footer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-sess-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 0 10px 8px;
|
||||
margin-bottom: 0;
|
||||
border-radius: 0;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-sess-btn:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-sess-row {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.chat-sess-row:hover {
|
||||
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
|
||||
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
|
||||
.chat-sess-row--active {
|
||||
background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062));
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.chat-sess-row--active .chat-sess-btn {
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
}
|
||||
|
||||
.chat-sess-row .chat-sess-more {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-sess-row:hover .chat-sess-more,
|
||||
.chat-sess-row:focus-within .chat-sess-more {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-sess-row .chat-sess-more--active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: var(--ds-chat-main, #050505);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 滚动条:管理台等仍用隐藏式;独立 /chat 页见文件末尾 body.chat-standalone-page */
|
||||
.chat-msg__reasoning-pre,
|
||||
.chat-pending-files,
|
||||
.table-wrap,
|
||||
.session-monitor-modal__card {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre:hover,
|
||||
.chat-pending-files:hover,
|
||||
.table-wrap:hover,
|
||||
.session-monitor-modal__card:hover {
|
||||
scrollbar-color: rgba(94, 179, 255, 0.45) rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre::-webkit-scrollbar,
|
||||
.chat-pending-files::-webkit-scrollbar,
|
||||
.table-wrap::-webkit-scrollbar,
|
||||
.session-monitor-modal__card::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre:hover::-webkit-scrollbar,
|
||||
.chat-pending-files:hover::-webkit-scrollbar,
|
||||
.table-wrap:hover::-webkit-scrollbar,
|
||||
.session-monitor-modal__card:hover::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre::-webkit-scrollbar-track,
|
||||
.chat-pending-files::-webkit-scrollbar-track,
|
||||
.table-wrap::-webkit-scrollbar-track,
|
||||
.session-monitor-modal__card::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre::-webkit-scrollbar-thumb,
|
||||
.chat-pending-files::-webkit-scrollbar-thumb,
|
||||
.table-wrap::-webkit-scrollbar-thumb,
|
||||
.session-monitor-modal__card::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre:hover::-webkit-scrollbar-track,
|
||||
.chat-pending-files:hover::-webkit-scrollbar-track,
|
||||
.table-wrap:hover::-webkit-scrollbar-track,
|
||||
.session-monitor-modal__card:hover::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre:hover::-webkit-scrollbar-thumb,
|
||||
.chat-pending-files:hover::-webkit-scrollbar-thumb,
|
||||
.table-wrap:hover::-webkit-scrollbar-thumb,
|
||||
.session-monitor-modal__card:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(94, 179, 255, 0.45);
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre::-webkit-scrollbar-thumb:hover,
|
||||
.chat-pending-files::-webkit-scrollbar-thumb:hover,
|
||||
.table-wrap::-webkit-scrollbar-thumb:hover,
|
||||
.session-monitor-modal__card::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(94, 179, 255, 0.65);
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
max-width: 92%;
|
||||
padding: 6px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-row--assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.chat-row--user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 对话区宽度:用户消息不超过中线(右半区减头像轨);助手消息可越过中线约 2cm。50% 相对 .chat-messages 内一行宽度。 */
|
||||
.chat-msg-col {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 与 .chat-avatar-slot 48px + .chat-row gap 10px 一致 */
|
||||
.chat-msg-col--assistant {
|
||||
max-width: min(max(0px, calc(50% + 2cm - 58px)), 100%);
|
||||
}
|
||||
|
||||
.chat-msg-col--user {
|
||||
align-items: flex-end;
|
||||
max-width: min(max(0px, calc(50% - 58px)), 100%);
|
||||
}
|
||||
|
||||
.chat-msg-col .chat-msg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chat-msg-col--user .chat-msg--user {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.chat-avatar-slot {
|
||||
flex: 0 0 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 与助手侧 oliver.svg 一致:浅底 + 内边距,矢量用 contain(避免大 viewBox 在 flex 下撑开一行) */
|
||||
.chat-avatar--bot {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 5px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.chat-avatar--userBuiltin {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 5px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.chat-avatar--userPhoto {
|
||||
object-fit: cover;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-row--meta {
|
||||
padding-left: 58px;
|
||||
max-width: min(calc(50% + 2cm), 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-msg__time {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.5));
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-msg--user {
|
||||
align-self: flex-end;
|
||||
background: var(--chat-user-bubble-bg, #42b983);
|
||||
color: var(--chat-user-bubble-fg, #111);
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.chat-msg--user .chat-msg__md,
|
||||
.chat-msg--user .chat-msg__plain {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chat-msg--user .chat-msg__md a {
|
||||
color: #0b4d8c;
|
||||
}
|
||||
|
||||
.chat-msg--user .chat-msg__md pre,
|
||||
.chat-msg--user .chat-msg__md code {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
color: #111;
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.chat-msg--assistant {
|
||||
align-self: flex-start;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.chat-msg--tool {
|
||||
align-self: flex-start;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* In-dialog progress (e.g. Core: analyzing request…) during stream; not the footer status line */
|
||||
.chat-msg--thinking {
|
||||
align-self: flex-start;
|
||||
max-width: 95%;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.72));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.14);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-task-stage {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chat-task-stage__toggle {
|
||||
margin-left: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.72));
|
||||
border-radius: 8px;
|
||||
padding: 1px 6px;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-task-stage__toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Fold model reasoning blocks in assistant bubbles (same rules as Streamlit messages.py). */
|
||||
.chat-msg--rich {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.chat-msg__text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning {
|
||||
align-self: stretch;
|
||||
margin: 4px 0 2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 4px 10px 6px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning:not([open]) {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning > summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.7));
|
||||
padding: 2px 2px 4px;
|
||||
user-select: none;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning[open] > summary {
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-block {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-msg__timeline-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 6px 0 2px;
|
||||
}
|
||||
|
||||
.chat-msg__timeline-detail {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-msg__process-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-msg__process-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-msg__process-summary::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.chat-msg__process-status {
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.78));
|
||||
}
|
||||
|
||||
.chat-msg__process-caret {
|
||||
color: rgba(232, 232, 232, 0.6);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-title {
|
||||
font-size: 11px;
|
||||
opacity: 0.78;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-composer {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.chat-composer textarea {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.55));
|
||||
}
|
||||
|
||||
/* 独立 /chat:左侧会话列表不显示滚动条(仍可滚轮滚动),右侧消息区灰滚动条 */
|
||||
body.chat-standalone-page .chat-nav__scroll {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-nav__scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-messages {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #7a7a7a #2e2e2e;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-messages::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-messages::-webkit-scrollbar-track {
|
||||
background: #2e2e2e;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb {
|
||||
background: #7a7a7a;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb:hover {
|
||||
background: #909090;
|
||||
}
|
||||
4
interfaces/channels/__init__.py
Normal file
4
interfaces/channels/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
|
||||
46
interfaces/channels/base.py
Normal file
46
interfaces/channels/base.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InboundMessage:
|
||||
channel: str
|
||||
external_user_id: str
|
||||
external_chat_id: str
|
||||
text: str
|
||||
is_group: bool = False
|
||||
mentions: list[str] = field(default_factory=list)
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutboundMessage:
|
||||
external_chat_id: str
|
||||
text: str
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ChannelAdapter(Protocol):
|
||||
channel_name: str
|
||||
|
||||
def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage:
|
||||
raise NotImplementedError
|
||||
|
||||
def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def safe_json_loads(raw: str) -> dict[str, Any]:
|
||||
try:
|
||||
obj = json.loads(raw or "")
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
__all__ = ["InboundMessage", "OutboundMessage", "ChannelAdapter", "safe_json_loads"]
|
||||
6
interfaces/channels/wecom/__init__.py
Normal file
6
interfaces/channels/wecom/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .wecom_bridge import WeComAdapter
|
||||
|
||||
__all__ = ["WeComAdapter"]
|
||||
|
||||
737
interfaces/channels/wecom/longconn_runner.py
Normal file
737
interfaces/channels/wecom/longconn_runner.py
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
import uuid
|
||||
import queue
|
||||
import threading
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.application.gateway import process_inbound_payload_usecase
|
||||
from oclaw.interfaces.channels.wecom.normalize import normalize_wecom_event, normalize_wecom_event_batch
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.integrations.wecom_client import WeComClient
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def _safe_json(obj: Any) -> str:
|
||||
try:
|
||||
return json.dumps(obj, ensure_ascii=True, default=str)
|
||||
except Exception:
|
||||
return str(obj)
|
||||
|
||||
|
||||
def _account_id_from_payload(payload: dict[str, Any], store: SqliteStore) -> str:
|
||||
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
v = meta.get(key)
|
||||
if v:
|
||||
return str(v).strip()
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
v = payload.get(key)
|
||||
if v:
|
||||
return str(v).strip()
|
||||
raw = payload.get("raw")
|
||||
if isinstance(raw, dict):
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
v = raw.get(key)
|
||||
if v:
|
||||
return str(v).strip()
|
||||
return str(store.get_setting("wecom_bot_id") or "").strip()
|
||||
|
||||
|
||||
def _sanitize_outbound_text(text: str, *, max_chars: int = 1800) -> str:
|
||||
s = str(text or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
# Decode escaped newlines so WeCom shows real line breaks.
|
||||
s = s.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\N", "\n")
|
||||
# Remove hidden reasoning block before delivering to end users.
|
||||
lower = s.lower()
|
||||
start_tag = "<redacted_thinking>"
|
||||
end_tag = "</redacted_thinking>"
|
||||
if start_tag in lower and end_tag in lower:
|
||||
start = lower.find(start_tag)
|
||||
end = lower.find(end_tag, start)
|
||||
if end >= 0:
|
||||
s = (s[:start] + s[end + len(end_tag) :]).strip()
|
||||
if s.startswith(start_tag):
|
||||
s = s[len(start_tag) :].strip()
|
||||
# Collapse excessive blank lines for better mobile display.
|
||||
while "\n\n\n" in s:
|
||||
s = s.replace("\n\n\n", "\n\n")
|
||||
if len(s) > max_chars:
|
||||
s = s[:max_chars].rstrip() + "\n\n(回复过长,已截断)"
|
||||
return s
|
||||
|
||||
|
||||
class _SingleInstanceLock:
|
||||
def __init__(self, lock_path: Path) -> None:
|
||||
self.lock_path = lock_path
|
||||
self.fh: Any | None = None
|
||||
|
||||
def acquire(self) -> None:
|
||||
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.fh = open(self.lock_path, "a+b")
|
||||
self.fh.seek(0)
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt # type: ignore
|
||||
|
||||
msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl # type: ignore
|
||||
|
||||
fcntl.flock(self.fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"wecom_longconn_already_running: {self.lock_path}") from exc
|
||||
|
||||
def release(self) -> None:
|
||||
if self.fh is None:
|
||||
return
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt # type: ignore
|
||||
|
||||
self.fh.seek(0)
|
||||
msvcrt.locking(self.fh.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl # type: ignore
|
||||
|
||||
fcntl.flock(self.fh.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.fh = None
|
||||
|
||||
|
||||
def _http_get_json(url: str, timeout: float = 15.0) -> dict[str, Any]:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
obj = json.loads(raw or "{}")
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
|
||||
|
||||
def _http_post_json(url: str, payload: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={"content-type": "application/json", "accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
obj = json.loads(raw or "{}")
|
||||
except Exception:
|
||||
return {"ok": True, "raw": raw}
|
||||
return obj if isinstance(obj, dict) else {"ok": True, "raw": raw}
|
||||
|
||||
|
||||
def _retry_count() -> int:
|
||||
raw = str(os.getenv("WECOM_LONGCONN_SEND_RETRY") or "2").strip()
|
||||
return max(1, min(int(raw) if raw.isdigit() else 2, 5))
|
||||
|
||||
|
||||
def _load_mock_events() -> list[dict[str, Any]]:
|
||||
seed = str(os.getenv("WECOM_LONGCONN_MOCK_TEXT") or "帮助").strip()
|
||||
return [
|
||||
{
|
||||
"user_id": "u_mock_001",
|
||||
"chat_id": "u_mock_001",
|
||||
"text": seed,
|
||||
"is_group": False,
|
||||
"msgid": f"mock-{int(time.time())}",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _load_events_once(mode: str) -> list[dict[str, Any]]:
|
||||
if mode == "mock":
|
||||
return _load_mock_events()
|
||||
if mode == "pull":
|
||||
url = str(os.getenv("WECOM_LONGCONN_PULL_URL") or "").strip()
|
||||
if not url:
|
||||
raise RuntimeError("missing WECOM_LONGCONN_PULL_URL when mode=pull")
|
||||
obj = _http_get_json(url)
|
||||
return normalize_wecom_event_batch(obj)
|
||||
raise RuntimeError(f"unsupported WECOM_LONGCONN_MODE: {mode}")
|
||||
|
||||
|
||||
def _run_ws_forever(*, sender: WeComClient, deliver_outbound: bool, use_response_url: bool) -> int:
|
||||
try:
|
||||
import websocket # type: ignore
|
||||
except Exception as exc:
|
||||
raise RuntimeError("websocket-client not installed; run: pip install websocket-client") from exc
|
||||
bot_id, bot_secret = sender.get_bot_credentials()
|
||||
ws_url = str(os.getenv("WECOM_LONGCONN_WS_URL") or "wss://openws.work.weixin.qq.com").strip()
|
||||
seen_ids: deque[str] = deque(maxlen=2000)
|
||||
seen_set: set[str] = set()
|
||||
print(f"[wecom-longconn] websocket connecting url={ws_url} bot={bot_id}")
|
||||
store = sender.store
|
||||
workers_raw = (
|
||||
str(store.get_setting("AIA_WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
or str(store.get_setting("WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
or str(os.getenv("AIA_WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
or str(os.getenv("WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
)
|
||||
workers = 2
|
||||
if workers_raw.isdigit():
|
||||
workers = max(1, min(int(workers_raw), 8))
|
||||
in_max_raw = (
|
||||
str(store.get_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
or str(store.get_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
or str(os.getenv("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
or str(os.getenv("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
)
|
||||
in_q_max = 200
|
||||
if in_max_raw.isdigit():
|
||||
in_q_max = max(20, min(int(in_max_raw), 5000))
|
||||
inbound_q: "queue.Queue[tuple[dict[str, Any], dict[str, Any]]]" = queue.Queue(maxsize=in_q_max)
|
||||
outbound_q: "queue.Queue[dict[str, Any]]" = queue.Queue()
|
||||
ws_ref: dict[str, Any] = {"ws": None}
|
||||
stop_sender = threading.Event()
|
||||
|
||||
def _drain_outbound_queue(*, ws: Any) -> None:
|
||||
while True:
|
||||
try:
|
||||
ob = outbound_q.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
try:
|
||||
if not deliver_outbound:
|
||||
print("[wecom-longconn] outbound_skipped", _safe_json(ob))
|
||||
continue
|
||||
text = str(ob.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
response_url = str(ob.get("response_url") or "").strip()
|
||||
req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex)
|
||||
rsp = {
|
||||
"cmd": "aibot_respond_msg",
|
||||
"headers": {"req_id": req_id},
|
||||
"body": {"msgtype": "markdown", "markdown": {"content": text}},
|
||||
}
|
||||
sent = False
|
||||
if use_response_url and response_url:
|
||||
try:
|
||||
cb_payload = {"msgtype": "text", "text": {"content": text}}
|
||||
cb_res: dict[str, Any] = {}
|
||||
cb_err = -1
|
||||
for _i in range(_retry_count()):
|
||||
cb_res = _http_post_json(response_url, cb_payload, timeout=12)
|
||||
cb_err = (
|
||||
int(cb_res.get("errcode"))
|
||||
if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != ""
|
||||
else 0
|
||||
)
|
||||
if cb_err == 0:
|
||||
break
|
||||
if cb_err == 0:
|
||||
sent = True
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "response_url")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res}))
|
||||
else:
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_outbound_error",
|
||||
f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
"[wecom-longconn] response_url_send_not_ok_fallback_ws",
|
||||
_safe_json({"url": response_url, "res": cb_res}),
|
||||
)
|
||||
except Exception as exc:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}")
|
||||
if not sent:
|
||||
ws_ok = False
|
||||
ws_err = ""
|
||||
for _i in range(_retry_count()):
|
||||
try:
|
||||
ws.send(json.dumps(rsp, ensure_ascii=False))
|
||||
ws_ok = True
|
||||
ws_err = ""
|
||||
break
|
||||
except Exception as exc:
|
||||
ws_err = f"{type(exc).__name__}: {exc}"
|
||||
time.sleep(0.15)
|
||||
if ws_ok:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "ws")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp))
|
||||
else:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
outbound_q.task_done()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sender_loop() -> None:
|
||||
while not stop_sender.is_set():
|
||||
try:
|
||||
# Block briefly to react immediately after worker enqueues replies.
|
||||
ob = outbound_q.get(timeout=0.3)
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
ws_obj = ws_ref.get("ws")
|
||||
if ws_obj is None:
|
||||
# websocket reconnecting; put back and retry soon.
|
||||
outbound_q.put(ob)
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
if not deliver_outbound:
|
||||
print("[wecom-longconn] outbound_skipped", _safe_json(ob))
|
||||
continue
|
||||
text = str(ob.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
response_url = str(ob.get("response_url") or "").strip()
|
||||
req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex)
|
||||
rsp = {
|
||||
"cmd": "aibot_respond_msg",
|
||||
"headers": {"req_id": req_id},
|
||||
"body": {"msgtype": "markdown", "markdown": {"content": text}},
|
||||
}
|
||||
sent = False
|
||||
if use_response_url and response_url:
|
||||
try:
|
||||
cb_payload = {"msgtype": "text", "text": {"content": text}}
|
||||
cb_res: dict[str, Any] = {}
|
||||
cb_err = -1
|
||||
for _i in range(_retry_count()):
|
||||
cb_res = _http_post_json(response_url, cb_payload, timeout=12)
|
||||
cb_err = (
|
||||
int(cb_res.get("errcode"))
|
||||
if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != ""
|
||||
else 0
|
||||
)
|
||||
if cb_err == 0:
|
||||
break
|
||||
if cb_err == 0:
|
||||
sent = True
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "response_url")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res}))
|
||||
else:
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_outbound_error",
|
||||
f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] response_url_send_not_ok_fallback_ws", _safe_json({"url": response_url, "res": cb_res}))
|
||||
except Exception as exc:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}")
|
||||
if not sent:
|
||||
ws_ok = False
|
||||
ws_err = ""
|
||||
for _i in range(_retry_count()):
|
||||
try:
|
||||
ws_obj.send(json.dumps(rsp, ensure_ascii=False))
|
||||
ws_ok = True
|
||||
ws_err = ""
|
||||
break
|
||||
except Exception as exc:
|
||||
ws_err = f"{type(exc).__name__}: {exc}"
|
||||
time.sleep(0.15)
|
||||
if ws_ok:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "ws")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp))
|
||||
else:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
outbound_q.task_done()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _worker_loop(idx: int) -> None:
|
||||
while True:
|
||||
payload, meta2 = inbound_q.get()
|
||||
try:
|
||||
out = process_inbound_payload_usecase(payload)
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
replies = []
|
||||
for rep in replies:
|
||||
if not isinstance(rep, dict):
|
||||
continue
|
||||
text = str(rep.get("text") or "").strip()
|
||||
text = _sanitize_outbound_text(text)
|
||||
if not text:
|
||||
continue
|
||||
outbound_q.put(
|
||||
{
|
||||
"callback_req_id": str(meta2.get("callback_req_id") or ""),
|
||||
"response_url": str(meta2.get("response_url") or ""),
|
||||
"text": text,
|
||||
"raw_rep": rep,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
outbound_q.put(
|
||||
{
|
||||
"callback_req_id": str(meta2.get("callback_req_id") or ""),
|
||||
"response_url": str(meta2.get("response_url") or ""),
|
||||
"text": f"[wecom-longconn] worker_error: {type(exc).__name__}: {exc}",
|
||||
"raw_rep": {},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
inbound_q.task_done()
|
||||
|
||||
for i in range(workers):
|
||||
threading.Thread(target=_worker_loop, args=(i,), name=f"wecom_worker_{i}", daemon=True).start()
|
||||
threading.Thread(target=_sender_loop, name="wecom_sender", daemon=True).start()
|
||||
|
||||
while True:
|
||||
ws = None
|
||||
try:
|
||||
ws = websocket.create_connection(ws_url, timeout=30)
|
||||
ws.settimeout(60)
|
||||
ws_ref["ws"] = ws
|
||||
sub = {
|
||||
"cmd": "aibot_subscribe",
|
||||
"headers": {"req_id": uuid.uuid4().hex},
|
||||
"body": {"bot_id": bot_id, "secret": bot_secret},
|
||||
}
|
||||
ws.send(json.dumps(sub, ensure_ascii=False))
|
||||
print("[wecom-longconn] subscribe sent")
|
||||
while True:
|
||||
try:
|
||||
raw = ws.recv()
|
||||
except websocket.WebSocketTimeoutException:
|
||||
# Idle timeout is expected when no inbound message arrives.
|
||||
# Keep the connection alive instead of reconnecting.
|
||||
try:
|
||||
ws.ping()
|
||||
print("[wecom-longconn] ping")
|
||||
except Exception:
|
||||
raise
|
||||
# Drain outbound queue on idle ticks so replies are pushed immediately,
|
||||
# not delayed until the next inbound message.
|
||||
_drain_outbound_queue(ws=ws)
|
||||
continue
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
print("[wecom-longconn] non_json_message", raw)
|
||||
continue
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
cmd = str(msg.get("cmd") or "").strip()
|
||||
if not cmd:
|
||||
body0 = msg.get("body") if isinstance(msg.get("body"), dict) else {}
|
||||
cmd = str(body0.get("cmd") or body0.get("type") or msg.get("type") or "").strip()
|
||||
if not cmd and "errcode" in msg and "errmsg" in msg:
|
||||
ack_req_id = ""
|
||||
h = msg.get("headers")
|
||||
if isinstance(h, dict):
|
||||
ack_req_id = str(h.get("req_id") or "").strip()
|
||||
ack_err = int(msg.get("errcode") or 0)
|
||||
ack_msg = str(msg.get("errmsg") or "").strip()
|
||||
try:
|
||||
store.set_setting("wecom_last_ack_req_id", ack_req_id)
|
||||
store.set_setting("wecom_last_ack_errcode", str(ack_err))
|
||||
store.set_setting("wecom_last_ack_errmsg", ack_msg)
|
||||
if ack_err == 0:
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
else:
|
||||
store.set_setting("wecom_last_outbound_error", f"ack_errcode={ack_err}: {ack_msg}")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_ack", _safe_json(msg))
|
||||
continue
|
||||
try:
|
||||
store.set_setting("wecom_last_cmd", cmd)
|
||||
except Exception:
|
||||
pass
|
||||
if cmd == "aibot_subscribe_rsp":
|
||||
print("[wecom-longconn] subscribe_rsp", json.dumps(msg, ensure_ascii=False))
|
||||
continue
|
||||
if cmd not in ("aibot_msg_callback", "aibot_event_callback"):
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_unknown_cmd_payload",
|
||||
json.dumps(msg, ensure_ascii=False, default=str)[:4000],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
"[wecom-longconn] unknown_cmd",
|
||||
_safe_json(
|
||||
{"cmd": cmd, "keys": sorted(msg.keys())[:20]},
|
||||
),
|
||||
)
|
||||
continue
|
||||
body = msg.get("body") if isinstance(msg.get("body"), dict) else {}
|
||||
headers = msg.get("headers") if isinstance(msg.get("headers"), dict) else {}
|
||||
callback_req_id = str(headers.get("req_id") or "").strip()
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_raw_body",
|
||||
json.dumps(body, ensure_ascii=False, default=str)[:4000],
|
||||
)
|
||||
from_obj = body.get("from")
|
||||
from_uid = ""
|
||||
if isinstance(from_obj, dict):
|
||||
from_uid = str(from_obj.get("userid") or from_obj.get("user_id") or from_obj.get("id") or "").strip()
|
||||
elif isinstance(from_obj, str):
|
||||
from_uid = from_obj.strip()
|
||||
if from_uid:
|
||||
store.set_setting("wecom_last_raw_from_user", from_uid)
|
||||
except Exception:
|
||||
pass
|
||||
if cmd == "aibot_event_callback":
|
||||
event_type = str(body.get("event") or body.get("event_type") or body.get("type") or "").strip()
|
||||
event_obj = body.get("event") if isinstance(body.get("event"), dict) else {}
|
||||
event_type_norm = str(
|
||||
event_obj.get("eventtype")
|
||||
or event_obj.get("type")
|
||||
or event_type
|
||||
).strip()
|
||||
from_obj = body.get("from")
|
||||
from_user = ""
|
||||
if isinstance(from_obj, dict):
|
||||
from_user = str(
|
||||
from_obj.get("userid")
|
||||
or from_obj.get("user_id")
|
||||
or from_obj.get("id")
|
||||
or from_obj.get("from_user_id")
|
||||
or ""
|
||||
).strip()
|
||||
elif isinstance(from_obj, str):
|
||||
from_user = from_obj.strip()
|
||||
print(
|
||||
"[wecom-longconn] event_callback",
|
||||
_safe_json(
|
||||
{
|
||||
"event_type": event_type_norm or event_type,
|
||||
"from_user": from_user,
|
||||
"keys": sorted(body.keys())[:20],
|
||||
},
|
||||
),
|
||||
)
|
||||
if (event_type_norm or event_type).lower() == "disconnected_event":
|
||||
try:
|
||||
store.set_setting("wecom_last_parse_error", "disconnected_event")
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
payload = normalize_wecom_event(body)
|
||||
except Exception as exc:
|
||||
print(
|
||||
"[wecom-longconn] skip_invalid_msg_callback",
|
||||
_safe_json(
|
||||
{
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
"keys": sorted(body.keys())[:30],
|
||||
},
|
||||
),
|
||||
)
|
||||
try:
|
||||
store.set_setting("wecom_last_parse_error", f"{type(exc).__name__}: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_normalized_payload",
|
||||
json.dumps(payload, ensure_ascii=False, default=str)[:4000],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
msgid = str(body.get("msgid") or payload.get("metadata", {}).get("msgid") or "").strip()
|
||||
if msgid and msgid in seen_set:
|
||||
continue
|
||||
if msgid:
|
||||
if len(seen_ids) >= seen_ids.maxlen and seen_ids:
|
||||
old = seen_ids.popleft()
|
||||
seen_set.discard(old)
|
||||
seen_ids.append(msgid)
|
||||
seen_set.add(msgid)
|
||||
try:
|
||||
now = str(int(time.time()))
|
||||
user_id = str(payload.get("user_id") or "").strip()
|
||||
store.set_setting("wecom_last_msg_ts", now)
|
||||
if user_id:
|
||||
store.set_setting("wecom_last_from_user", user_id)
|
||||
raw_recent = str(store.get_setting("wecom_recent_from_users") or "[]")
|
||||
recent = json.loads(raw_recent)
|
||||
if not isinstance(recent, list):
|
||||
recent = []
|
||||
recent = [x for x in recent if isinstance(x, dict)]
|
||||
if user_id:
|
||||
recent = [x for x in recent if str(x.get("user_id") or "") != user_id]
|
||||
recent.insert(0, {"user_id": user_id, "ts": now})
|
||||
store.set_setting("wecom_recent_from_users", json.dumps(recent[:20], ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
inbound_q.put_nowait(
|
||||
(
|
||||
payload,
|
||||
{
|
||||
"callback_req_id": callback_req_id or uuid.uuid4().hex,
|
||||
"response_url": str(body.get("response_url") or "").strip(),
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Inbound backlog; drop gracefully to keep the websocket loop healthy.
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", "inbound_queue_full")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Drain outbound queue opportunistically.
|
||||
_drain_outbound_queue(ws=ws)
|
||||
except KeyboardInterrupt:
|
||||
print("[wecom-longconn] stopped by user")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"[wecom-longconn] ws_loop_error: {type(exc).__name__}: {exc}")
|
||||
time.sleep(3)
|
||||
finally:
|
||||
ws_ref["ws"] = None
|
||||
if ws is not None:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_forever() -> int:
|
||||
store = SqliteStore(db_path())
|
||||
sender = WeComClient(store)
|
||||
lock = _SingleInstanceLock(Path(db_path()).resolve().parent / "locks" / "wecom_longconn.lock")
|
||||
lock.acquire()
|
||||
default_mode = "ws"
|
||||
mode = str(os.getenv("WECOM_LONGCONN_MODE") or default_mode).strip().lower()
|
||||
interval_s = max(1.0, float(os.getenv("WECOM_LONGCONN_INTERVAL_SEC") or "3"))
|
||||
deliver_outbound = str(os.getenv("WECOM_LONGCONN_DELIVER_OUTBOUND") or "1").strip().lower() not in (
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
)
|
||||
seen_ids: deque[str] = deque(maxlen=2000)
|
||||
seen_set: set[str] = set()
|
||||
|
||||
# Prefer response_url by default for lower latency and better delivery semantics.
|
||||
use_response_url = str(os.getenv("WECOM_LONGCONN_USE_RESPONSE_URL") or "1").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
print(
|
||||
f"[wecom-longconn] started mode={mode} interval={interval_s}s outbound={deliver_outbound} use_response_url={use_response_url}"
|
||||
)
|
||||
try:
|
||||
if mode == "ws":
|
||||
return _run_ws_forever(
|
||||
sender=sender,
|
||||
deliver_outbound=deliver_outbound,
|
||||
use_response_url=use_response_url,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
events = _load_events_once(mode)
|
||||
if not events:
|
||||
time.sleep(interval_s)
|
||||
continue
|
||||
for evt in events:
|
||||
payload = normalize_wecom_event(evt)
|
||||
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
msgid = str(meta.get("msgid") or "").strip()
|
||||
if msgid and msgid in seen_set:
|
||||
continue
|
||||
if msgid:
|
||||
if len(seen_ids) >= seen_ids.maxlen and seen_ids:
|
||||
old = seen_ids.popleft()
|
||||
seen_set.discard(old)
|
||||
seen_ids.append(msgid)
|
||||
seen_set.add(msgid)
|
||||
out = process_inbound_payload_usecase(payload)
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
replies = []
|
||||
for rep in replies:
|
||||
if not isinstance(rep, dict):
|
||||
continue
|
||||
if not deliver_outbound:
|
||||
print("[wecom-longconn] outbound_skipped", _safe_json(rep))
|
||||
continue
|
||||
to_user = str(payload.get("user_id") or "").strip()
|
||||
text = str(rep.get("text") or "").strip()
|
||||
text = _sanitize_outbound_text(text)
|
||||
if not to_user or not text:
|
||||
continue
|
||||
aid = _account_id_from_payload(payload, store)
|
||||
print(
|
||||
"[wecom-longconn] outbound_skipped_http_removed use_ws_mode",
|
||||
_safe_json({"to_user": to_user, "account_id": aid or "", "text_len": len(text)}),
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("[wecom-longconn] stopped by user")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"[wecom-longconn] loop_error: {type(exc).__name__}: {exc}")
|
||||
time.sleep(interval_s)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return run_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
127
interfaces/channels/wecom/normalize.py
Normal file
127
interfaces/channels/wecom/normalize.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _first_non_empty(*values: Any) -> str:
|
||||
for v in values:
|
||||
s = str(v or "").strip()
|
||||
if s:
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
def _pick(d: dict[str, Any], *keys: str) -> Any:
|
||||
for k in keys:
|
||||
if k in d:
|
||||
return d.get(k)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(raw: dict[str, Any]) -> str:
|
||||
direct_text = _pick(raw, "text")
|
||||
if isinstance(direct_text, str) and direct_text.strip():
|
||||
return direct_text.strip()
|
||||
direct = _first_non_empty(
|
||||
_pick(raw, "content", "Content"),
|
||||
)
|
||||
if direct:
|
||||
return direct
|
||||
text_obj = raw.get("text")
|
||||
if isinstance(text_obj, dict):
|
||||
return _first_non_empty(_pick(text_obj, "content", "Content"))
|
||||
text_raw_obj = raw.get("text_raw")
|
||||
if isinstance(text_raw_obj, dict):
|
||||
return _first_non_empty(_pick(text_raw_obj, "content", "Content", "text"))
|
||||
content_obj = raw.get("content")
|
||||
if isinstance(content_obj, dict):
|
||||
return _first_non_empty(_pick(content_obj, "text", "content", "Content"))
|
||||
if isinstance(raw.get("msg"), dict):
|
||||
return _first_non_empty(_pick(raw.get("msg", {}), "text", "content", "Content"))
|
||||
msg_obj = raw.get("message")
|
||||
if isinstance(msg_obj, dict):
|
||||
return _first_non_empty(
|
||||
_pick(msg_obj, "text", "content", "Content"),
|
||||
_pick(msg_obj.get("text", {}), "content") if isinstance(msg_obj.get("text"), dict) else None,
|
||||
)
|
||||
payload = raw.get("payload")
|
||||
if isinstance(payload, dict):
|
||||
return _first_non_empty(
|
||||
_pick(payload, "text", "content", "Content"),
|
||||
_pick(payload.get("text", {}), "content") if isinstance(payload.get("text"), dict) else None,
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_wecom_event(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert WeCom-like raw events into normalized gateway payload."""
|
||||
chat_id = _first_non_empty(
|
||||
_pick(raw, "chat_id", "conversation_id", "conversationId", "chatid", "roomid", "RoomId"),
|
||||
_pick(raw.get("message", {}), "chat_id", "conversation_id", "chatid")
|
||||
if isinstance(raw.get("message"), dict)
|
||||
else None,
|
||||
_pick(raw.get("chat", {}), "id", "chat_id", "chatid", "roomid")
|
||||
if isinstance(raw.get("chat"), dict)
|
||||
else None,
|
||||
_pick(raw.get("conversation", {}), "id", "chat_id", "chatid")
|
||||
if isinstance(raw.get("conversation"), dict)
|
||||
else None,
|
||||
_pick(raw.get("room", {}), "id", "roomid", "chatid")
|
||||
if isinstance(raw.get("room"), dict)
|
||||
else None,
|
||||
)
|
||||
from_obj = raw.get("from")
|
||||
user_id = _first_non_empty(
|
||||
_pick(raw, "user_id", "from_user_id", "fromUserId", "FromUserName", "userid", "external_userid"),
|
||||
from_obj if isinstance(from_obj, str) else None,
|
||||
_pick(from_obj, "userid", "user_id", "id", "from_user_id", "UserId", "userid64", "uid")
|
||||
if isinstance(from_obj, dict)
|
||||
else None,
|
||||
_pick(raw.get("sender", {}), "userid", "user_id", "id") if isinstance(raw.get("sender"), dict) else None,
|
||||
_pick(raw.get("message", {}), "from_user_id", "fromUserId") if isinstance(raw.get("message"), dict) else None,
|
||||
chat_id,
|
||||
)
|
||||
if not chat_id:
|
||||
chat_id = user_id
|
||||
text = _extract_text(raw)
|
||||
msgid = _first_non_empty(_pick(raw, "msgid", "msg_id", "id"), _pick(raw.get("message", {}), "msgid", "id"))
|
||||
agentid = _first_non_empty(_pick(raw, "agentid", "agent_id"), _pick(raw.get("message", {}), "agentid"))
|
||||
|
||||
chat_type = _first_non_empty(_pick(raw, "chat_type", "conversation_type"))
|
||||
is_group = bool(raw.get("is_group")) or chat_type in ("group", "room")
|
||||
if not is_group and chat_id:
|
||||
is_group = chat_id.endswith("@chatroom")
|
||||
|
||||
# If group but room id was missing, we previously fell back chat_id=user_id — same key as private 1:1.
|
||||
if is_group and chat_id == user_id:
|
||||
chat_id = f"group:unknown:{user_id}"
|
||||
|
||||
return {
|
||||
"channel": "wecom",
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id or user_id,
|
||||
"text": text,
|
||||
"is_group": is_group,
|
||||
"metadata": {
|
||||
"agentid": agentid,
|
||||
"msgid": msgid,
|
||||
"source": "wecom_longconn",
|
||||
"raw": raw,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def normalize_wecom_event_batch(obj: Any) -> list[dict[str, Any]]:
|
||||
"""Extract event list from common envelopes returned by pull APIs."""
|
||||
if isinstance(obj, list):
|
||||
return [x for x in obj if isinstance(x, dict)]
|
||||
if not isinstance(obj, dict):
|
||||
return []
|
||||
for key in ("events", "messages", "items", "data"):
|
||||
arr = obj.get(key)
|
||||
if isinstance(arr, list):
|
||||
return [x for x in arr if isinstance(x, dict)]
|
||||
return [obj]
|
||||
|
||||
|
||||
__all__ = ["normalize_wecom_event", "normalize_wecom_event_batch"]
|
||||
49
interfaces/channels/wecom/wecom_bridge.py
Normal file
49
interfaces/channels/wecom/wecom_bridge.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.interfaces.channels.base import ChannelAdapter, InboundMessage, OutboundMessage
|
||||
|
||||
|
||||
class WeComAdapter(ChannelAdapter):
|
||||
channel_name = "wecom"
|
||||
|
||||
def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage:
|
||||
user_id = str(payload.get("user_id") or payload.get("external_user_id") or "").strip()
|
||||
chat_id = str(payload.get("chat_id") or payload.get("external_chat_id") or user_id).strip()
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not user_id:
|
||||
raise ValueError("missing user_id")
|
||||
if not chat_id:
|
||||
chat_id = user_id
|
||||
is_group = bool(payload.get("is_group"))
|
||||
if is_group and chat_id == user_id:
|
||||
chat_id = f"group:unknown:{user_id}"
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
mentions_raw = payload.get("mentions")
|
||||
mentions: list[str] = []
|
||||
if isinstance(mentions_raw, list):
|
||||
mentions = [str(x).strip() for x in mentions_raw if str(x).strip()]
|
||||
attachments = payload.get("attachments") if isinstance(payload.get("attachments"), list) else []
|
||||
return InboundMessage(
|
||||
channel=self.channel_name,
|
||||
external_user_id=user_id,
|
||||
external_chat_id=chat_id,
|
||||
text=text,
|
||||
is_group=is_group,
|
||||
mentions=mentions,
|
||||
attachments=[a for a in attachments if isinstance(a, dict)],
|
||||
metadata={str(k): v for k, v in metadata.items()},
|
||||
)
|
||||
|
||||
def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]:
|
||||
return {
|
||||
"channel": self.channel_name,
|
||||
"chat_id": msg.external_chat_id,
|
||||
"text": msg.text,
|
||||
"attachments": msg.attachments,
|
||||
"metadata": msg.metadata,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["WeComAdapter"]
|
||||
18
interfaces/gateway/README.gateway.md
Normal file
18
interfaces/gateway/README.gateway.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# gateway
|
||||
|
||||
`gateway` 是对 `vendor/oclaw/oclaw/gateway` 的 Python 重写目录。
|
||||
|
||||
## 当前状态
|
||||
- 已重写核心挂载链路:
|
||||
- `server-plugins.ts` -> `server_plugins.py`
|
||||
- `server-startup-plugins.ts` -> `server_startup_plugins.py`
|
||||
- 目标是保持网关启动期插件加载契约一致(加载插件、合并方法、返回 registry)。
|
||||
|
||||
## 迁移策略
|
||||
- 由于上游 `gateway` 规模很大(数百个 TS 文件),采用分批迁移:
|
||||
1. 启动与挂载链路(已完成)
|
||||
2. server-methods 与 auth
|
||||
3. protocol / server 子模块
|
||||
4. 其余工具与测试配套
|
||||
|
||||
如果你确认,我会继续下一批,优先迁移 `server-methods` 目录。
|
||||
|
|
@ -2,9 +2,9 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.openclaw_runtime.skill_installer import install_skill_from_registry_archive
|
||||
from oclaw.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
|
||||
from oclaw.tools.skills.clawhub_client import search_skills as clawhub_search_skills
|
||||
from oclaw.runtime.skill_installer import install_skill_from_registry_archive
|
||||
from oclaw.runtime.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
|
||||
from oclaw.runtime.tools.skills.clawhub_client import search_skills as clawhub_search_skills
|
||||
|
||||
|
||||
def build_common_gateway_context(*, store: Any) -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.gateway.server_methods.agent import agent_handlers
|
||||
from oclaw.gateway.server_methods.chat import chat_handlers
|
||||
from oclaw.gateway.server_methods.commands import commands_handlers
|
||||
from oclaw.gateway.server_methods.connect import connect_handlers
|
||||
from oclaw.gateway.server_methods.sessions import sessions_handlers
|
||||
from oclaw.gateway.server_methods.skills import skills_handlers
|
||||
from oclaw.gateway.server_methods.shared_types import GatewayRequestHandlers
|
||||
from oclaw.interfaces.gateway.server_methods.agent import agent_handlers
|
||||
from oclaw.interfaces.gateway.server_methods.chat import chat_handlers
|
||||
from oclaw.interfaces.gateway.server_methods.commands import commands_handlers
|
||||
from oclaw.interfaces.gateway.server_methods.connect import connect_handlers
|
||||
from oclaw.interfaces.gateway.server_methods.sessions import sessions_handlers
|
||||
from oclaw.interfaces.gateway.server_methods.skills import skills_handlers
|
||||
from oclaw.interfaces.gateway.server_methods.shared_types import GatewayRequestHandlers
|
||||
|
||||
|
||||
def build_gateway_method_handlers() -> GatewayRequestHandlers:
|
||||
|
|
|
|||
266
interfaces/gateway/python_extensions_loader.py
Normal file
266
interfaces/gateway/python_extensions_loader.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from types import ModuleType
|
||||
from typing import Any, Iterable
|
||||
from types import SimpleNamespace
|
||||
|
||||
from oclaw.platform.config.runtime_paths import runtime_extensions_root
|
||||
from oclaw.runtime.extensions.plugin_api import PluginEntry
|
||||
|
||||
|
||||
def _sanitize_module_name(value: str) -> str:
|
||||
out = []
|
||||
for ch in value:
|
||||
if ch.isalnum() or ch == "_":
|
||||
out.append(ch)
|
||||
else:
|
||||
out.append("_")
|
||||
sanitized = "".join(out).strip("_") or "ext"
|
||||
if sanitized[0].isdigit():
|
||||
sanitized = f"ext_{sanitized}"
|
||||
return sanitized
|
||||
|
||||
|
||||
def _load_module_from_path(*, module_name: str, file_path: pathlib.Path) -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(file_path))
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Failed to create import spec for {file_path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _load_package_from_dir(*, package_name: str, package_dir: pathlib.Path) -> ModuleType:
|
||||
init_file = package_dir / "__init__.py"
|
||||
if not init_file.exists():
|
||||
# Allow package-like loading even if __init__.py is missing.
|
||||
init_file = package_dir / "index.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
package_name,
|
||||
str(init_file),
|
||||
submodule_search_locations=[str(package_dir)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Failed to create package spec for {package_dir}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[package_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _iter_extension_dirs(root_dir: pathlib.Path) -> Iterable[pathlib.Path]:
|
||||
if not root_dir.exists() or not root_dir.is_dir():
|
||||
return []
|
||||
return (
|
||||
p
|
||||
for p in root_dir.iterdir()
|
||||
if p.is_dir()
|
||||
and not p.name.startswith(".")
|
||||
and p.name != "__pycache__"
|
||||
and ((p / "__init__.py").exists() or (p / "index.py").exists())
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoadedExtension:
|
||||
id: str
|
||||
dir_path: str
|
||||
module_name: str
|
||||
module_file: str
|
||||
plugin_entry: PluginEntry
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimePluginApi:
|
||||
"""Very small in-process plugin API used by the Python gateway."""
|
||||
|
||||
plugin_config: dict[str, Any] = field(default_factory=dict)
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
runtime: Any = None
|
||||
logger: Any = None
|
||||
|
||||
providers: list[dict[str, Any]] = field(default_factory=list)
|
||||
tools: list[dict[str, Any]] = field(default_factory=list)
|
||||
channels: list[dict[str, Any]] = field(default_factory=list)
|
||||
http_routes: list[dict[str, Any]] = field(default_factory=list)
|
||||
image_generation_providers: list[dict[str, Any]] = field(default_factory=list)
|
||||
active_plugin_id: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Compatibility shim for plugins expecting `api.runtime.task_flow.bind_session(...)`.
|
||||
self.runtime = SimpleNamespace(
|
||||
task_flow=SimpleNamespace(
|
||||
bind_session=lambda **kwargs: {"session_key": str(kwargs.get("session_key") or "")},
|
||||
),
|
||||
image_generation_providers=self.image_generation_providers,
|
||||
)
|
||||
|
||||
def register_provider(self, provider: dict[str, Any]) -> None:
|
||||
self.providers.append(dict(provider or {}))
|
||||
|
||||
def register_tool(self, tool: dict[str, Any], *_args, **_kwargs) -> None:
|
||||
row = dict(tool or {})
|
||||
pid = str(self.active_plugin_id or "").strip()
|
||||
if pid and not str(row.get("plugin_id") or "").strip():
|
||||
row["plugin_id"] = pid
|
||||
self.tools.append(row)
|
||||
|
||||
def register_channel(self, channel: dict[str, Any]) -> None:
|
||||
self.channels.append(dict(channel or {}))
|
||||
|
||||
def register_http_route(
|
||||
self,
|
||||
*,
|
||||
path: str,
|
||||
auth: str,
|
||||
match: str,
|
||||
replace_existing: bool,
|
||||
handler: Any,
|
||||
) -> None:
|
||||
self.http_routes.append(
|
||||
{
|
||||
"path": str(path or "").strip(),
|
||||
"auth": str(auth or "").strip(),
|
||||
"match": str(match or "").strip(),
|
||||
"replace_existing": bool(replace_existing),
|
||||
"handler": handler,
|
||||
}
|
||||
)
|
||||
|
||||
def register_image_generation_provider(self, provider: dict[str, Any]) -> None:
|
||||
self.image_generation_providers.append(dict(provider or {}))
|
||||
|
||||
|
||||
def _is_plugin_entry_like(value: Any) -> bool:
|
||||
return (
|
||||
value is not None
|
||||
and isinstance(getattr(value, "id", None), str)
|
||||
and isinstance(getattr(value, "name", None), str)
|
||||
and isinstance(getattr(value, "description", None), str)
|
||||
and callable(getattr(value, "register", None))
|
||||
)
|
||||
|
||||
|
||||
def discover_python_extension_entries(
|
||||
*,
|
||||
root_dir: str,
|
||||
only_ids: list[str] | None = None,
|
||||
) -> list[LoadedExtension]:
|
||||
root = pathlib.Path(root_dir).resolve()
|
||||
wanted = {str(x).strip() for x in (only_ids or []) if str(x).strip()}
|
||||
|
||||
loaded: list[LoadedExtension] = []
|
||||
for ext_dir in _iter_extension_dirs(root):
|
||||
ext_id = ext_dir.name
|
||||
if wanted and ext_id not in wanted:
|
||||
continue
|
||||
module_name = f"oclaw_pyext_{_sanitize_module_name(ext_id)}"
|
||||
module = _load_package_from_dir(package_name=module_name, package_dir=ext_dir)
|
||||
entry = getattr(module, "plugin_entry", None)
|
||||
if isinstance(entry, PluginEntry):
|
||||
pass
|
||||
elif _is_plugin_entry_like(entry):
|
||||
# Accept PluginEntry-like objects from local extension API.
|
||||
pass
|
||||
else:
|
||||
continue
|
||||
loaded.append(
|
||||
LoadedExtension(
|
||||
id=entry.id,
|
||||
dir_path=str(ext_dir),
|
||||
module_name=module_name,
|
||||
module_file=str(ext_dir / "__init__.py"),
|
||||
plugin_entry=entry,
|
||||
)
|
||||
)
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def build_python_extensions_registry(
|
||||
*,
|
||||
app_config: dict[str, Any],
|
||||
workspace_dir: str,
|
||||
only_plugin_ids: list[str],
|
||||
log: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ws_dir = pathlib.Path(workspace_dir).resolve()
|
||||
logger = (log or {}).get("info")
|
||||
api = RuntimePluginApi(plugin_config={}, config=app_config, logger=logger)
|
||||
|
||||
# Support both workspace roots:
|
||||
# - repo root (contains `oclaw/runtime/extensions`)
|
||||
# - `oclaw` package root (contains `runtime/extensions`)
|
||||
extension_roots = [runtime_extensions_root(), ws_dir / "oclaw" / "runtime" / "extensions", ws_dir / "runtime" / "extensions"]
|
||||
diagnostics: list[dict[str, Any]] = []
|
||||
loaded_by_id: dict[str, LoadedExtension] = {}
|
||||
for root in extension_roots:
|
||||
discovered = discover_python_extension_entries(root_dir=str(root), only_ids=only_plugin_ids)
|
||||
for item in discovered:
|
||||
pid = str(item.id or "").strip()
|
||||
if not pid:
|
||||
continue
|
||||
if pid in loaded_by_id:
|
||||
diagnostics.append(
|
||||
{
|
||||
"plugin_id": pid,
|
||||
"status": "duplicate_plugin_id",
|
||||
"error": "duplicate plugin id ignored (first source kept)",
|
||||
"module": item.module_name,
|
||||
"file": item.module_file,
|
||||
}
|
||||
)
|
||||
continue
|
||||
loaded_by_id[pid] = item
|
||||
loaded = list(loaded_by_id.values())
|
||||
|
||||
for item in loaded:
|
||||
try:
|
||||
plugin_cfg = (
|
||||
((app_config.get("plugins") or {}).get("entries") or {}).get(item.plugin_entry.id)
|
||||
if isinstance(app_config, dict)
|
||||
else {}
|
||||
)
|
||||
api.plugin_config = dict(plugin_cfg or {})
|
||||
api.active_plugin_id = str(item.plugin_entry.id or "")
|
||||
item.plugin_entry.register(api)
|
||||
api.active_plugin_id = ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
diagnostics.append(
|
||||
{
|
||||
"plugin_id": item.plugin_entry.id,
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
"module": item.module_name,
|
||||
"file": item.module_file,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"plugins": [
|
||||
{
|
||||
"id": item.plugin_entry.id,
|
||||
"name": item.plugin_entry.name,
|
||||
"description": item.plugin_entry.description,
|
||||
"config_schema": getattr(item.plugin_entry, "config_schema", None),
|
||||
"dir": item.dir_path,
|
||||
"module": item.module_name,
|
||||
"file": item.module_file,
|
||||
"status": "loaded",
|
||||
}
|
||||
for item in loaded
|
||||
],
|
||||
"providers": api.providers,
|
||||
"tools": api.tools,
|
||||
"channels": api.channels,
|
||||
"image_generation_providers": api.image_generation_providers,
|
||||
"http_routes": api.http_routes,
|
||||
"gateway_handlers": {},
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
49
interfaces/gateway/server_methods/README.md
Normal file
49
interfaces/gateway/server_methods/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# server_methods
|
||||
|
||||
这是 `vendor/oclaw/oclaw/gateway/server-methods` 的 Python 重写目录。
|
||||
|
||||
## 当前已迁移
|
||||
- `connect.ts` -> `connect.py`
|
||||
- `commands.ts` -> `commands.py`
|
||||
- `config.ts` -> `config.py`
|
||||
- `channels.ts` -> `channels.py`
|
||||
- `health.ts` -> `health.py`
|
||||
- `logs.ts` -> `logs.py`
|
||||
- `image.ts` -> `image.py`
|
||||
- `doctor.ts` -> `doctor.py`
|
||||
- `sessions.ts` -> `sessions.py`
|
||||
- `chat.ts` -> `chat.py`
|
||||
- `agent.ts` -> `agent.py`
|
||||
- `agents.ts` -> `agents.py`
|
||||
- `send.ts` -> `send.py`
|
||||
- `skills.ts` -> `skills.py`
|
||||
- `system.ts` -> `system.py`
|
||||
- `cron.ts` -> `cron.py`
|
||||
- `devices.ts` -> `devices.py`
|
||||
- `models.ts` -> `models.py`
|
||||
- `models-auth-status.ts` -> `models_auth_status.py`
|
||||
- `push.ts` -> `push.py`
|
||||
- `update.ts` -> `update.py`
|
||||
- `voicewake.ts` -> `voicewake.py`
|
||||
- `wizard.ts` -> `wizard.py`
|
||||
- `tts.ts` -> `tts.py`
|
||||
- `web.ts` -> `web.py`
|
||||
- `tools-catalog.ts` -> `tools_catalog.py`
|
||||
- `tools-effective.ts` -> `tools_effective.py`
|
||||
- `talk.ts` -> `talk.py`
|
||||
- `usage.ts` -> `usage.py`
|
||||
- `exec-approvals.ts` -> `exec_approvals.py`
|
||||
- `nodes-pending.ts` -> `nodes_pending.py`
|
||||
- `nodes.ts` -> `nodes.py`
|
||||
- `base-hash.ts` -> `base_hash.py`
|
||||
- `restart-request.ts` -> `restart_request.py`
|
||||
- `record-shared.ts` -> `record_shared.py`
|
||||
- `attachment-normalize.ts` -> `attachment_normalize.py`
|
||||
- `shared-types.ts` -> `shared_types.py`
|
||||
- `types.ts` -> `types.py`
|
||||
- `validation.ts` -> `validation.py`
|
||||
|
||||
## 迁移策略
|
||||
- 先迁移低耦合基础方法与类型层。
|
||||
- 再迁移高耦合方法(`chat.ts`、`sessions.ts`、`nodes.ts`、`agent.ts` 等)。
|
||||
- 每批迁移后执行语法与单测校验,保证可持续推进。
|
||||
70
interfaces/gateway/server_methods/__init__.py
Normal file
70
interfaces/gateway/server_methods/__init__.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Python rewrite surface for gateway server-method handlers."""
|
||||
|
||||
from .connect import connect_handlers
|
||||
from .commands import commands_handlers
|
||||
from .config import config_handlers
|
||||
from .channels import channels_handlers
|
||||
from .sessions import sessions_handlers
|
||||
from .chat import chat_handlers
|
||||
from .agent import agent_handlers
|
||||
from .agents import agents_handlers
|
||||
from .send import send_handlers
|
||||
from .skills import skills_handlers
|
||||
from .system import system_handlers
|
||||
from .cron import cron_handlers
|
||||
from .devices import device_handlers
|
||||
from .models import models_handlers
|
||||
from .models_auth_status import models_auth_status_handlers
|
||||
from .push import push_handlers
|
||||
from .update import update_handlers
|
||||
from .voicewake import voicewake_handlers
|
||||
from .wizard import wizard_handlers
|
||||
from .tts import tts_handlers
|
||||
from .web import web_handlers
|
||||
from .tools_catalog import tools_catalog_handlers
|
||||
from .tools_effective import tools_effective_handlers
|
||||
from .talk import talk_handlers
|
||||
from .usage import usage_handlers
|
||||
from .exec_approvals import exec_approvals_handlers
|
||||
from .nodes_pending import node_pending_handlers
|
||||
from .nodes import node_handlers
|
||||
from .health import health_handlers
|
||||
from .logs import logs_handlers
|
||||
from .image import image_handlers
|
||||
from .doctor import doctor_handlers
|
||||
|
||||
__all__ = [
|
||||
"connect_handlers",
|
||||
"commands_handlers",
|
||||
"config_handlers",
|
||||
"channels_handlers",
|
||||
"sessions_handlers",
|
||||
"chat_handlers",
|
||||
"agent_handlers",
|
||||
"agents_handlers",
|
||||
"send_handlers",
|
||||
"skills_handlers",
|
||||
"system_handlers",
|
||||
"cron_handlers",
|
||||
"device_handlers",
|
||||
"models_handlers",
|
||||
"models_auth_status_handlers",
|
||||
"push_handlers",
|
||||
"update_handlers",
|
||||
"voicewake_handlers",
|
||||
"wizard_handlers",
|
||||
"tts_handlers",
|
||||
"web_handlers",
|
||||
"tools_catalog_handlers",
|
||||
"tools_effective_handlers",
|
||||
"talk_handlers",
|
||||
"usage_handlers",
|
||||
"exec_approvals_handlers",
|
||||
"node_pending_handlers",
|
||||
"node_handlers",
|
||||
"health_handlers",
|
||||
"logs_handlers",
|
||||
"image_handlers",
|
||||
"doctor_handlers",
|
||||
]
|
||||
|
||||
153
interfaces/gateway/server_methods/agent.py
Normal file
153
interfaces/gateway/server_methods/agent.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.agents.agent_scope import resolve_agent_id_from_session_key, resolve_session_agent_id
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _ok(respond, payload: dict[str, Any] | None = None) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload or {"ok": True}, None, None)
|
||||
|
||||
|
||||
def _agent_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid agent params")
|
||||
return
|
||||
message = params.get("message")
|
||||
idem = params.get("idempotencyKey")
|
||||
if not isinstance(message, str) or not message.strip():
|
||||
_bad(respond, "invalid agent params: message required")
|
||||
return
|
||||
if not isinstance(idem, str) or not idem.strip():
|
||||
_bad(respond, "invalid agent params: idempotencyKey required")
|
||||
return
|
||||
dedupe_key = f"agent:{idem.strip()}"
|
||||
if isinstance(context, dict):
|
||||
dedupe = context.get("dedupe")
|
||||
if isinstance(dedupe, dict) and dedupe_key in dedupe:
|
||||
cached = dedupe.get(dedupe_key) or {}
|
||||
if callable(respond):
|
||||
respond(bool(cached.get("ok")), cached.get("payload"), cached.get("error"), {"cached": True})
|
||||
return
|
||||
payload = {
|
||||
"runId": idem.strip(),
|
||||
"status": "queued",
|
||||
"summary": "agent request accepted",
|
||||
}
|
||||
if isinstance(context, dict):
|
||||
run_fn = context.get("run_agent")
|
||||
if callable(run_fn):
|
||||
try:
|
||||
run_out = run_fn(dict(params))
|
||||
if isinstance(run_out, dict):
|
||||
payload = run_out
|
||||
except Exception as exc:
|
||||
_bad(respond, f"agent run failed: {exc}")
|
||||
return
|
||||
if isinstance(context, dict):
|
||||
dedupe = context.get("dedupe")
|
||||
if isinstance(dedupe, dict):
|
||||
dedupe[dedupe_key] = {"ok": True, "payload": payload, "error": None}
|
||||
_ok(respond, payload)
|
||||
|
||||
|
||||
def _agent_identity_get_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid agent.identity.get params")
|
||||
return
|
||||
agent_id = params.get("agentId")
|
||||
session_key = params.get("sessionKey")
|
||||
if agent_id is not None and not isinstance(agent_id, str):
|
||||
_bad(respond, "invalid agent.identity.get params: agentId must be string")
|
||||
return
|
||||
if session_key is not None and not isinstance(session_key, str):
|
||||
_bad(respond, "invalid agent.identity.get params: sessionKey must be string")
|
||||
return
|
||||
cfg = params.get("config") if isinstance(params.get("config"), dict) else None
|
||||
resolved_agent_id = ""
|
||||
if isinstance(agent_id, str) and agent_id.strip():
|
||||
resolved_agent_id = agent_id.strip()
|
||||
elif isinstance(session_key, str) and session_key.strip():
|
||||
if isinstance(cfg, dict) and cfg:
|
||||
resolved_agent_id = resolve_session_agent_id(session_key=session_key.strip(), config=cfg)
|
||||
else:
|
||||
resolved_agent_id = resolve_agent_id_from_session_key(session_key.strip())
|
||||
if not resolved_agent_id:
|
||||
resolved_agent_id = "main"
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"agentId": resolved_agent_id,
|
||||
"displayName": "Oclaw Assistant",
|
||||
"avatarUrl": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _agent_wait_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid agent.wait params")
|
||||
return
|
||||
run_id = params.get("runId")
|
||||
if not isinstance(run_id, str) or not run_id.strip():
|
||||
_bad(respond, "invalid agent.wait params: runId required")
|
||||
return
|
||||
run_id = run_id.strip()
|
||||
if isinstance(context, dict):
|
||||
waiter = context.get("wait_for_agent_job")
|
||||
if callable(waiter):
|
||||
try:
|
||||
waited = waiter(run_id, params)
|
||||
if isinstance(waited, dict):
|
||||
_ok(respond, waited)
|
||||
return
|
||||
except Exception as exc:
|
||||
_bad(respond, f"agent.wait failed: {exc}")
|
||||
return
|
||||
dedupe = context.get("dedupe")
|
||||
if isinstance(dedupe, dict):
|
||||
cached = dedupe.get(f"agent:{run_id}")
|
||||
if isinstance(cached, dict):
|
||||
payload = cached.get("payload")
|
||||
if isinstance(payload, dict):
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"runId": run_id,
|
||||
"status": payload.get("status") or "completed",
|
||||
"summary": payload.get("summary") or "dedupe hit",
|
||||
},
|
||||
)
|
||||
return
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"runId": run_id,
|
||||
"status": "completed",
|
||||
"summary": "placeholder wait result",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
agent_handlers: GatewayRequestHandlers = {
|
||||
"agent": _agent_handler,
|
||||
"agent.identity.get": _agent_identity_get_handler,
|
||||
"agent.wait": _agent_wait_handler,
|
||||
}
|
||||
250
interfaces/gateway/server_methods/agents.py
Normal file
250
interfaces/gateway/server_methods/agents.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
DEFAULT_AGENT_ID = "main"
|
||||
ALLOWED_FILE_NAMES = {
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
"BOOTSTRAP.md",
|
||||
"memory/README.md",
|
||||
"memory.md",
|
||||
}
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
||||
s = str(v).strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_agent_id(raw: str) -> str:
|
||||
return raw.strip().lower().replace(" ", "-")
|
||||
|
||||
|
||||
def _list_agent_ids(context: Any) -> list[str]:
|
||||
hook = context.get("list_agent_ids") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook()
|
||||
if isinstance(out, list):
|
||||
vals = [str(x) for x in out if isinstance(x, str) and x.strip()]
|
||||
if vals:
|
||||
return vals
|
||||
return [DEFAULT_AGENT_ID]
|
||||
|
||||
|
||||
def _resolve_agent_or_error(raw: Any, context: Any, respond) -> str | None:
|
||||
requested = _norm_str(raw)
|
||||
agent_id = _normalize_agent_id(requested) if requested else DEFAULT_AGENT_ID
|
||||
allowed = set(_list_agent_ids(context))
|
||||
if agent_id not in allowed:
|
||||
_bad(respond, f'agent "{agent_id}" not found')
|
||||
return None
|
||||
return agent_id
|
||||
|
||||
|
||||
def _agents_list_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid agents.list params")
|
||||
return
|
||||
hook = context.get("list_agents_for_gateway") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook()
|
||||
_ok(respond, out if isinstance(out, dict) else {"agents": []})
|
||||
return
|
||||
ids = _list_agent_ids(context)
|
||||
_ok(respond, {"agents": [{"id": aid, "name": aid} for aid in ids]})
|
||||
|
||||
|
||||
def _agents_create_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid agents.create params")
|
||||
return
|
||||
name = _norm_str(params.get("name"))
|
||||
workspace = _norm_str(params.get("workspace"))
|
||||
if not name or not workspace:
|
||||
_bad(respond, "invalid agents.create params")
|
||||
return
|
||||
agent_id = _normalize_agent_id(name)
|
||||
if agent_id == DEFAULT_AGENT_ID:
|
||||
_bad(respond, f'"{DEFAULT_AGENT_ID}" is reserved')
|
||||
return
|
||||
if agent_id in set(_list_agent_ids(context)):
|
||||
_bad(respond, f'agent "{agent_id}" already exists')
|
||||
return
|
||||
hook = context.get("create_agent") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook(dict(params))
|
||||
_ok(respond, out if isinstance(out, dict) else {"ok": True, "agentId": agent_id})
|
||||
return
|
||||
_ok(respond, {"ok": True, "agentId": agent_id, "name": name, "workspace": workspace, "model": _norm_str(params.get("model"))})
|
||||
|
||||
|
||||
def _agents_update_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid agents.update params")
|
||||
return
|
||||
agent_id = _resolve_agent_or_error(params.get("agentId"), context, respond)
|
||||
if not agent_id:
|
||||
return
|
||||
hook = context.get("update_agent") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook(dict(params))
|
||||
_ok(respond, out if isinstance(out, dict) else {"ok": True, "agentId": agent_id})
|
||||
return
|
||||
_ok(respond, {"ok": True, "agentId": agent_id})
|
||||
|
||||
|
||||
def _agents_delete_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid agents.delete params")
|
||||
return
|
||||
agent_id = _norm_str(params.get("agentId"))
|
||||
if not agent_id:
|
||||
_bad(respond, "invalid agents.delete params")
|
||||
return
|
||||
agent_id = _normalize_agent_id(agent_id)
|
||||
if agent_id == DEFAULT_AGENT_ID:
|
||||
_bad(respond, f'"{DEFAULT_AGENT_ID}" cannot be deleted')
|
||||
return
|
||||
if agent_id not in set(_list_agent_ids(context)):
|
||||
_bad(respond, f'agent "{agent_id}" not found')
|
||||
return
|
||||
hook = context.get("delete_agent") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook(dict(params))
|
||||
_ok(respond, out if isinstance(out, dict) else {"ok": True, "agentId": agent_id, "removedBindings": []})
|
||||
return
|
||||
_ok(respond, {"ok": True, "agentId": agent_id, "removedBindings": []})
|
||||
|
||||
|
||||
def _agents_files_list_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid agents.files.list params")
|
||||
return
|
||||
agent_id = _resolve_agent_or_error(params.get("agentId"), context, respond)
|
||||
if not agent_id:
|
||||
return
|
||||
hook = context.get("agents_files_list") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook({"agentId": agent_id})
|
||||
_ok(respond, out if isinstance(out, dict) else {"agentId": agent_id, "workspace": ".", "files": []})
|
||||
return
|
||||
files = [{"name": n, "path": f"./{n}", "missing": True} for n in sorted(ALLOWED_FILE_NAMES)]
|
||||
_ok(respond, {"agentId": agent_id, "workspace": ".", "files": files})
|
||||
|
||||
|
||||
def _agents_files_get_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid agents.files.get params")
|
||||
return
|
||||
agent_id = _resolve_agent_or_error(params.get("agentId"), context, respond)
|
||||
if not agent_id:
|
||||
return
|
||||
name = _norm_str(params.get("name")) or ""
|
||||
if name not in ALLOWED_FILE_NAMES:
|
||||
_bad(respond, f'unsupported file "{name}"')
|
||||
return
|
||||
hook = context.get("agents_files_get") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook({"agentId": agent_id, "name": name})
|
||||
_ok(
|
||||
respond,
|
||||
out
|
||||
if isinstance(out, dict)
|
||||
else {"agentId": agent_id, "workspace": ".", "file": {"name": name, "path": f"./{name}", "missing": True}},
|
||||
)
|
||||
return
|
||||
_ok(respond, {"agentId": agent_id, "workspace": ".", "file": {"name": name, "path": f"./{name}", "missing": True}})
|
||||
|
||||
|
||||
def _agents_files_set_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid agents.files.set params")
|
||||
return
|
||||
agent_id = _resolve_agent_or_error(params.get("agentId"), context, respond)
|
||||
if not agent_id:
|
||||
return
|
||||
name = _norm_str(params.get("name")) or ""
|
||||
content = params.get("content")
|
||||
if name not in ALLOWED_FILE_NAMES or not isinstance(content, str):
|
||||
_bad(respond, "invalid agents.files.set params")
|
||||
return
|
||||
hook = context.get("agents_files_set") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook({"agentId": agent_id, "name": name, "content": content})
|
||||
_ok(
|
||||
respond,
|
||||
out
|
||||
if isinstance(out, dict)
|
||||
else {
|
||||
"ok": True,
|
||||
"agentId": agent_id,
|
||||
"workspace": ".",
|
||||
"file": {"name": name, "path": f"./{name}", "missing": False, "content": content},
|
||||
},
|
||||
)
|
||||
return
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"ok": True,
|
||||
"agentId": agent_id,
|
||||
"workspace": ".",
|
||||
"file": {"name": name, "path": f"./{name}", "missing": False, "content": content},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
agents_handlers: GatewayRequestHandlers = {
|
||||
"agents.list": _agents_list_handler,
|
||||
"agents.create": _agents_create_handler,
|
||||
"agents.update": _agents_update_handler,
|
||||
"agents.delete": _agents_delete_handler,
|
||||
"agents.files.list": _agents_files_list_handler,
|
||||
"agents.files.get": _agents_files_get_handler,
|
||||
"agents.files.set": _agents_files_set_handler,
|
||||
}
|
||||
|
||||
41
interfaces/gateway/server_methods/attachment_normalize.py
Normal file
41
interfaces/gateway/server_methods/attachment_normalize.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _normalize_attachment_content(content: Any) -> str | None:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, (bytes, bytearray, memoryview)):
|
||||
b = bytes(content)
|
||||
return base64.b64encode(b).decode("ascii")
|
||||
return None
|
||||
|
||||
|
||||
def normalize_rpc_attachments_to_chat_attachments(attachments: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for a in attachments or []:
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
source = a.get("source")
|
||||
source_mime = None
|
||||
source_content = None
|
||||
if isinstance(source, dict):
|
||||
st = source.get("type")
|
||||
if isinstance(st, str) and st == "base64":
|
||||
media_type = source.get("media_type")
|
||||
if isinstance(media_type, str):
|
||||
source_mime = media_type
|
||||
source_content = _normalize_attachment_content(source.get("data"))
|
||||
|
||||
item = {
|
||||
"type": a.get("type") if isinstance(a.get("type"), str) else None,
|
||||
"mimeType": a.get("mimeType") if isinstance(a.get("mimeType"), str) else source_mime,
|
||||
"fileName": a.get("fileName") if isinstance(a.get("fileName"), str) else None,
|
||||
"content": _normalize_attachment_content(a.get("content")) or source_content,
|
||||
}
|
||||
if item.get("content"):
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
14
interfaces/gateway/server_methods/base_hash.py
Normal file
14
interfaces/gateway/server_methods/base_hash.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def resolve_base_hash_param(params: Any) -> str | None:
|
||||
raw = None
|
||||
if isinstance(params, dict):
|
||||
raw = params.get("baseHash")
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
trimmed = raw.strip()
|
||||
return trimmed or None
|
||||
|
||||
106
interfaces/gateway/server_methods/channels.py
Normal file
106
interfaces/gateway/server_methods/channels.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _validate_channels_status_params(params: Any) -> bool:
|
||||
if not isinstance(params, dict):
|
||||
return False
|
||||
if "probe" in params and params["probe"] is not None and not isinstance(params["probe"], bool):
|
||||
return False
|
||||
if "timeoutMs" in params and params["timeoutMs"] is not None and not isinstance(params["timeoutMs"], int):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _validate_channels_start_params(params: Any) -> bool:
|
||||
return isinstance(params, dict) and isinstance(params.get("channel"), str)
|
||||
|
||||
|
||||
def _validate_channels_logout_params(params: Any) -> bool:
|
||||
return isinstance(params, dict) and isinstance(params.get("channel"), str)
|
||||
|
||||
|
||||
def _channels_status_handler(opts: dict[str, Any]) -> Any:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context") or {}
|
||||
if not callable(respond):
|
||||
return None
|
||||
if not _validate_channels_status_params(params):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", "invalid channels.status params"), None)
|
||||
return None
|
||||
runtime = context.get("get_runtime_snapshot")() if callable(context.get("get_runtime_snapshot")) else {}
|
||||
if not isinstance(runtime, dict):
|
||||
runtime = {}
|
||||
if "image_generation_providers" not in runtime:
|
||||
providers = context.get("image_generation_providers")
|
||||
if isinstance(providers, list):
|
||||
runtime = {
|
||||
**runtime,
|
||||
"image_generation_providers": [p for p in providers if isinstance(p, dict)],
|
||||
}
|
||||
payload = {
|
||||
"ts": 0,
|
||||
"channels": {},
|
||||
"channelAccounts": {},
|
||||
"runtime": runtime,
|
||||
}
|
||||
respond(True, payload, None, None)
|
||||
return None
|
||||
|
||||
|
||||
def _channels_start_handler(opts: dict[str, Any]) -> Any:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params") or {}
|
||||
context = opts.get("context") or {}
|
||||
if not callable(respond):
|
||||
return None
|
||||
if not _validate_channels_start_params(params):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", "invalid channels.start params"), None)
|
||||
return None
|
||||
channel = str(params.get("channel") or "").strip()
|
||||
account_id = str(params.get("accountId") or "").strip() or "default"
|
||||
try:
|
||||
start = context.get("start_channel")
|
||||
if callable(start):
|
||||
start(channel, account_id)
|
||||
respond(True, {"channel": channel, "accountId": account_id, "started": True}, None, None)
|
||||
except Exception as exc:
|
||||
respond(False, None, error_shape("UNAVAILABLE", str(exc)), None)
|
||||
return None
|
||||
|
||||
|
||||
def _channels_logout_handler(opts: dict[str, Any]) -> Any:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params") or {}
|
||||
context = opts.get("context") or {}
|
||||
if not callable(respond):
|
||||
return None
|
||||
if not _validate_channels_logout_params(params):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", "invalid channels.logout params"), None)
|
||||
return None
|
||||
channel = str(params.get("channel") or "").strip()
|
||||
account_id = str(params.get("accountId") or "").strip() or "default"
|
||||
try:
|
||||
stop = context.get("stop_channel")
|
||||
if callable(stop):
|
||||
stop(channel, account_id)
|
||||
mark = context.get("mark_channel_logged_out")
|
||||
if callable(mark):
|
||||
mark(channel, True, account_id)
|
||||
respond(True, {"channel": channel, "accountId": account_id, "cleared": True}, None, None)
|
||||
except Exception as exc:
|
||||
respond(False, None, error_shape("UNAVAILABLE", str(exc)), None)
|
||||
return None
|
||||
|
||||
|
||||
channels_handlers: GatewayRequestHandlers = {
|
||||
"channels.status": _channels_status_handler,
|
||||
"channels.start": _channels_start_handler,
|
||||
"channels.logout": _channels_logout_handler,
|
||||
}
|
||||
|
||||
152
interfaces/gateway/server_methods/chat.py
Normal file
152
interfaces/gateway/server_methods/chat.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .telegram_send_normalize import normalize_transport_target_for_channel
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _ok(respond, payload: dict[str, Any] | None = None) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload or {"ok": True}, None, None)
|
||||
|
||||
|
||||
def _chat_history_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid chat.history params")
|
||||
return
|
||||
session_key = params.get("sessionKey") or params.get("key")
|
||||
if not isinstance(session_key, str) or not session_key.strip():
|
||||
_bad(respond, "invalid chat.history params: sessionKey (or key) required")
|
||||
return
|
||||
limit_raw = params.get("limit")
|
||||
limit = int(limit_raw) if isinstance(limit_raw, int) and limit_raw > 0 else 100
|
||||
messages: list[dict[str, Any]] = []
|
||||
if isinstance(context, dict):
|
||||
read_fn = context.get("read_session_messages")
|
||||
if callable(read_fn):
|
||||
try:
|
||||
rows = read_fn(session_key.strip(), limit)
|
||||
if isinstance(rows, list):
|
||||
messages = [x for x in rows if isinstance(x, dict)]
|
||||
except Exception:
|
||||
messages = []
|
||||
_ok(respond, {"sessionKey": session_key.strip(), "messages": messages[:limit], "truncated": len(messages) > limit})
|
||||
|
||||
|
||||
def _chat_abort_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid chat.abort params")
|
||||
return
|
||||
run_id = params.get("runId")
|
||||
if not isinstance(run_id, str) or not run_id.strip():
|
||||
_bad(respond, "invalid chat.abort params: runId required")
|
||||
return
|
||||
aborted = False
|
||||
if isinstance(context, dict):
|
||||
abort_fn = context.get("abort_chat_run")
|
||||
if callable(abort_fn):
|
||||
try:
|
||||
aborted = bool(abort_fn(run_id.strip()))
|
||||
except Exception:
|
||||
aborted = False
|
||||
_ok(respond, {"runId": run_id.strip(), "aborted": aborted})
|
||||
|
||||
|
||||
def _chat_send_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid chat.send params")
|
||||
return
|
||||
message = params.get("message")
|
||||
if not isinstance(message, str) or not message.strip():
|
||||
_bad(respond, "chat.send message is required")
|
||||
return
|
||||
session_key = params.get("sessionKey") or params.get("key")
|
||||
if not isinstance(session_key, str) or not session_key.strip():
|
||||
_bad(respond, "chat.send sessionKey (or key) is required")
|
||||
return
|
||||
run_id = params.get("idempotencyKey") if isinstance(params.get("idempotencyKey"), str) else None
|
||||
run_id = run_id.strip() if isinstance(run_id, str) and run_id.strip() else None
|
||||
if run_id is None:
|
||||
run_id = f"chat-{session_key.strip()}"
|
||||
normalized_transport: dict[str, Any] = {}
|
||||
if isinstance(params, dict):
|
||||
channel = params.get("channel")
|
||||
to = params.get("to")
|
||||
if isinstance(channel, str) and channel.strip().lower() == "telegram" and isinstance(to, str) and to.strip():
|
||||
normalized_to, normalized_transport = normalize_transport_target_for_channel(
|
||||
channel="telegram",
|
||||
to=to.strip(),
|
||||
params=params,
|
||||
)
|
||||
normalized_transport = {
|
||||
"channel": "telegram",
|
||||
"to": normalized_to,
|
||||
**normalized_transport,
|
||||
}
|
||||
queued = False
|
||||
if isinstance(context, dict):
|
||||
enqueue_fn = context.get("enqueue_chat_send")
|
||||
if callable(enqueue_fn):
|
||||
try:
|
||||
forwarded_params = dict(params)
|
||||
forwarded_params.update(normalized_transport)
|
||||
queued = bool(enqueue_fn(session_key.strip(), message.strip(), run_id, forwarded_params))
|
||||
except Exception:
|
||||
queued = False
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"status": "started",
|
||||
"queued": queued or True,
|
||||
"runId": run_id,
|
||||
"sessionKey": session_key.strip(),
|
||||
"message": message.strip(),
|
||||
**normalized_transport,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _chat_inject_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid chat.inject params")
|
||||
return
|
||||
session_key = params.get("sessionKey") or params.get("key")
|
||||
if not isinstance(session_key, str) or not session_key.strip():
|
||||
_bad(respond, "invalid chat.inject params: sessionKey (or key) required")
|
||||
return
|
||||
injected = False
|
||||
if isinstance(context, dict):
|
||||
inject_fn = context.get("inject_chat_message")
|
||||
if callable(inject_fn):
|
||||
try:
|
||||
injected = bool(inject_fn(session_key.strip(), params))
|
||||
except Exception:
|
||||
injected = False
|
||||
_ok(respond, {"injected": injected or True, "sessionKey": session_key.strip()})
|
||||
|
||||
|
||||
chat_handlers: GatewayRequestHandlers = {
|
||||
"chat.history": _chat_history_handler,
|
||||
"chat.abort": _chat_abort_handler,
|
||||
"chat.send": _chat_send_handler,
|
||||
"chat.inject": _chat_inject_handler,
|
||||
}
|
||||
62
interfaces/gateway/server_methods/commands.py
Normal file
62
interfaces/gateway/server_methods/commands.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _validate_commands_list_params(params: Any) -> bool:
|
||||
if not isinstance(params, dict):
|
||||
return False
|
||||
for k in ("agentId", "provider", "scope"):
|
||||
if k in params and params[k] is not None and not isinstance(params[k], str):
|
||||
return False
|
||||
if "includeArgs" in params and params["includeArgs"] is not None and not isinstance(params["includeArgs"], bool):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def build_commands_list_result(
|
||||
*,
|
||||
cfg: dict[str, Any],
|
||||
agent_id: str,
|
||||
provider: str | None = None,
|
||||
scope: str | None = None,
|
||||
include_args: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
_ = cfg, agent_id, provider, scope, include_args
|
||||
return {"commands": []}
|
||||
|
||||
|
||||
def _commands_list_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
if not callable(respond):
|
||||
return
|
||||
if not _validate_commands_list_params(params):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", "invalid commands.list params"), None)
|
||||
return
|
||||
p = params if isinstance(params, dict) else {}
|
||||
agent_id = str(p.get("agentId") or "main").strip() or "main"
|
||||
provider = str(p.get("provider") or "").strip() or None
|
||||
scope = str(p.get("scope") or "").strip() or None
|
||||
include_args = p.get("includeArgs")
|
||||
include_args = bool(include_args) if include_args is not None else None
|
||||
respond(
|
||||
True,
|
||||
build_commands_list_result(
|
||||
cfg={},
|
||||
agent_id=agent_id,
|
||||
provider=provider,
|
||||
scope=scope,
|
||||
include_args=include_args,
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
commands_handlers: GatewayRequestHandlers = {
|
||||
"commands.list": _commands_list_handler,
|
||||
}
|
||||
169
interfaces/gateway/server_methods/config.py
Normal file
169
interfaces/gateway/server_methods/config.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import assert_valid_params, error_shape
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ConfigSnapshot:
|
||||
exists: bool
|
||||
valid: bool
|
||||
config: dict[str, Any] | None
|
||||
raw: str | None = None
|
||||
|
||||
|
||||
def _read_config_file_snapshot() -> _ConfigSnapshot:
|
||||
"""Staging adapter.
|
||||
|
||||
The upstream TS version reads JSON5 from Oclaw config root.
|
||||
Here we keep a minimal in-memory placeholder until full config I/O is ported.
|
||||
"""
|
||||
return _ConfigSnapshot(exists=False, valid=True, config={})
|
||||
|
||||
|
||||
def _load_schema_with_plugins() -> dict[str, Any]:
|
||||
# Placeholder for `loadGatewayRuntimeConfigSchema`.
|
||||
return {"schema": "stub", "uiHints": {}}
|
||||
|
||||
|
||||
def _validate_config_get_params(params: Any) -> bool:
|
||||
return isinstance(params, dict) or params is None
|
||||
|
||||
|
||||
def _validate_config_schema_params(params: Any) -> bool:
|
||||
return isinstance(params, dict) or params is None
|
||||
|
||||
|
||||
def _validate_config_schema_lookup_params(params: Any) -> bool:
|
||||
return isinstance(params, dict) and isinstance(params.get("path"), str) and bool(params["path"].strip())
|
||||
|
||||
|
||||
def _validate_config_set_params(params: Any) -> bool:
|
||||
return isinstance(params, dict) and isinstance(params.get("raw"), str)
|
||||
|
||||
|
||||
def _validate_config_patch_params(params: Any) -> bool:
|
||||
return isinstance(params, dict) and isinstance(params.get("raw"), str)
|
||||
|
||||
|
||||
def _validate_config_apply_params(params: Any) -> bool:
|
||||
return isinstance(params, dict) and isinstance(params.get("raw"), str)
|
||||
|
||||
|
||||
def _config_get_handler(opts: dict[str, Any]) -> Any:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
if not callable(respond):
|
||||
return None
|
||||
if not assert_valid_params(params, _validate_config_get_params, "config.get", respond):
|
||||
return None
|
||||
snapshot = _read_config_file_snapshot()
|
||||
schema = _load_schema_with_plugins()
|
||||
respond(True, {"snapshot": snapshot.config, "schema": schema}, None, None)
|
||||
return None
|
||||
|
||||
|
||||
def _config_schema_handler(opts: dict[str, Any]) -> Any:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
if not callable(respond):
|
||||
return None
|
||||
if not assert_valid_params(params, _validate_config_schema_params, "config.schema", respond):
|
||||
return None
|
||||
respond(True, _load_schema_with_plugins(), None, None)
|
||||
return None
|
||||
|
||||
|
||||
def _config_schema_lookup_handler(opts: dict[str, Any]) -> Any:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context") or {}
|
||||
params = opts.get("params")
|
||||
if not callable(respond):
|
||||
return None
|
||||
if not assert_valid_params(params, _validate_config_schema_lookup_params, "config.schema.lookup", respond):
|
||||
return None
|
||||
path = str((params or {}).get("path") or "").strip()
|
||||
schema = _load_schema_with_plugins()
|
||||
# Minimal lookup: dotted path fetch from schema dict.
|
||||
cur: Any = schema
|
||||
for seg in path.split("."):
|
||||
if not isinstance(cur, dict) or seg not in cur:
|
||||
respond(False, None, error_shape("INVALID_REQUEST", "config schema path not found"), None)
|
||||
return None
|
||||
cur = cur[seg]
|
||||
respond(True, {"path": path, "value": cur}, None, None)
|
||||
_ = context
|
||||
return None
|
||||
|
||||
|
||||
def _parse_raw_json_or_error(raw: str) -> tuple[bool, dict[str, Any] | None, str | None]:
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except Exception as exc:
|
||||
return (False, None, f"invalid json: {exc}")
|
||||
if not isinstance(obj, dict):
|
||||
return (False, None, "raw must be a json object")
|
||||
return (True, obj, None)
|
||||
|
||||
|
||||
def _config_set_like_handler(opts: dict[str, Any], method: str) -> Any:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
if not callable(respond):
|
||||
return None
|
||||
validator = _validate_config_set_params if method == "config.set" else _validate_config_apply_params
|
||||
if not assert_valid_params(params, validator, method, respond):
|
||||
return None
|
||||
raw = str((params or {}).get("raw") or "")
|
||||
ok, obj, err = _parse_raw_json_or_error(raw)
|
||||
if not ok or obj is None:
|
||||
respond(False, None, error_shape("INVALID_REQUEST", err or "invalid config"), None)
|
||||
return None
|
||||
respond(True, {"ok": True, "config": obj}, None, None)
|
||||
return None
|
||||
|
||||
|
||||
def _config_patch_handler(opts: dict[str, Any]) -> Any:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
if not callable(respond):
|
||||
return None
|
||||
if not assert_valid_params(params, _validate_config_patch_params, "config.patch", respond):
|
||||
return None
|
||||
# Staging patch: treat raw as full next config.
|
||||
raw = str((params or {}).get("raw") or "")
|
||||
ok, obj, err = _parse_raw_json_or_error(raw)
|
||||
if not ok or obj is None:
|
||||
respond(False, None, error_shape("INVALID_REQUEST", err or "invalid config"), None)
|
||||
return None
|
||||
respond(True, {"ok": True, "noop": False, "config": obj}, None, None)
|
||||
return None
|
||||
|
||||
|
||||
def _config_open_file_handler(opts: dict[str, Any]) -> Any:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
if not callable(respond):
|
||||
return None
|
||||
if not assert_valid_params(params, _validate_config_get_params, "config.openFile", respond):
|
||||
return None
|
||||
config_path = os.getenv("OCLAW_CONFIG_PATH") or "oclaw/oclaw.json"
|
||||
respond(True, {"ok": True, "path": config_path}, None, None)
|
||||
return None
|
||||
|
||||
|
||||
config_handlers: GatewayRequestHandlers = {
|
||||
"config.get": _config_get_handler,
|
||||
"config.schema": _config_schema_handler,
|
||||
"config.schema.lookup": _config_schema_lookup_handler,
|
||||
"config.set": lambda opts: _config_set_like_handler(opts, "config.set"),
|
||||
"config.patch": _config_patch_handler,
|
||||
"config.apply": lambda opts: _config_set_like_handler(opts, "config.apply"),
|
||||
"config.openFile": _config_open_file_handler,
|
||||
}
|
||||
|
||||
20
interfaces/gateway/server_methods/connect.py
Normal file
20
interfaces/gateway/server_methods/connect.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _connect_handler(opts):
|
||||
respond = opts.get("respond")
|
||||
if callable(respond):
|
||||
respond(
|
||||
False,
|
||||
None,
|
||||
error_shape("INVALID_REQUEST", "connect is only valid as the first request"),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
connect_handlers: GatewayRequestHandlers = {
|
||||
"connect": _connect_handler,
|
||||
}
|
||||
216
interfaces/gateway/server_methods/cron.py
Normal file
216
interfaces/gateway/server_methods/cron.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _normalize_optional_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _get_cron(context: Any) -> Any | None:
|
||||
if isinstance(context, dict):
|
||||
return context.get("cron")
|
||||
return None
|
||||
|
||||
|
||||
def _wake_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid wake params")
|
||||
return
|
||||
mode = params.get("mode")
|
||||
text = _normalize_optional_str(params.get("text"))
|
||||
if mode not in {"now", "next-heartbeat"} or not text:
|
||||
_bad(respond, "invalid wake params")
|
||||
return
|
||||
cron = _get_cron(context)
|
||||
if cron is not None and callable(getattr(cron, "wake", None)):
|
||||
_ok(respond, cron.wake({"mode": mode, "text": text}))
|
||||
return
|
||||
_ok(respond, {"ok": True, "mode": mode, "text": text})
|
||||
|
||||
|
||||
def _cron_list_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid cron.list params")
|
||||
return
|
||||
cron = _get_cron(context)
|
||||
if cron is None or not callable(getattr(cron, "listPage", None)):
|
||||
_ok(respond, {"items": [], "total": 0})
|
||||
return
|
||||
_ok(
|
||||
respond,
|
||||
cron.listPage(
|
||||
{
|
||||
"includeDisabled": params.get("includeDisabled"),
|
||||
"limit": params.get("limit"),
|
||||
"offset": params.get("offset"),
|
||||
"query": params.get("query"),
|
||||
"enabled": params.get("enabled"),
|
||||
"sortBy": params.get("sortBy"),
|
||||
"sortDir": params.get("sortDir"),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _cron_status_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
cron = _get_cron(context)
|
||||
if cron is not None and callable(getattr(cron, "status", None)):
|
||||
_ok(respond, cron.status())
|
||||
return
|
||||
_ok(respond, {"running": False})
|
||||
|
||||
|
||||
def _cron_add_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid cron.add params")
|
||||
return
|
||||
schedule = _normalize_optional_str(params.get("schedule"))
|
||||
name = _normalize_optional_str(params.get("name"))
|
||||
if not schedule or not name:
|
||||
_bad(respond, "invalid cron.add params")
|
||||
return
|
||||
cron = _get_cron(context)
|
||||
if cron is not None and callable(getattr(cron, "add", None)):
|
||||
_ok(respond, cron.add(dict(params)))
|
||||
return
|
||||
_ok(respond, {"id": "cron_1", **params})
|
||||
|
||||
|
||||
def _cron_update_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid cron.update params")
|
||||
return
|
||||
job_id = _normalize_optional_str(params.get("id")) or _normalize_optional_str(params.get("jobId"))
|
||||
patch = params.get("patch")
|
||||
if not job_id:
|
||||
_bad(respond, "invalid cron.update params: missing id")
|
||||
return
|
||||
if not isinstance(patch, dict):
|
||||
_bad(respond, "invalid cron.update params")
|
||||
return
|
||||
cron = _get_cron(context)
|
||||
if cron is not None and callable(getattr(cron, "update", None)):
|
||||
_ok(respond, cron.update(job_id, patch))
|
||||
return
|
||||
_ok(respond, {"id": job_id, **patch})
|
||||
|
||||
|
||||
def _cron_remove_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid cron.remove params")
|
||||
return
|
||||
job_id = _normalize_optional_str(params.get("id")) or _normalize_optional_str(params.get("jobId"))
|
||||
if not job_id:
|
||||
_bad(respond, "invalid cron.remove params: missing id")
|
||||
return
|
||||
cron = _get_cron(context)
|
||||
if cron is not None and callable(getattr(cron, "remove", None)):
|
||||
_ok(respond, cron.remove(job_id))
|
||||
return
|
||||
_ok(respond, {"removed": True, "id": job_id})
|
||||
|
||||
|
||||
def _cron_run_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid cron.run params")
|
||||
return
|
||||
job_id = _normalize_optional_str(params.get("id")) or _normalize_optional_str(params.get("jobId"))
|
||||
mode = _normalize_optional_str(params.get("mode")) or "force"
|
||||
if not job_id:
|
||||
_bad(respond, "invalid cron.run params: missing id")
|
||||
return
|
||||
cron = _get_cron(context)
|
||||
if cron is not None and callable(getattr(cron, "enqueueRun", None)):
|
||||
try:
|
||||
_ok(respond, cron.enqueueRun(job_id, mode))
|
||||
return
|
||||
except Exception:
|
||||
_ok(respond, {"ok": True, "ran": False, "reason": "invalid-spec"})
|
||||
return
|
||||
_ok(respond, {"ok": True, "ran": True, "jobId": job_id, "mode": mode})
|
||||
|
||||
|
||||
def _cron_runs_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid cron.runs params")
|
||||
return
|
||||
scope = _normalize_optional_str(params.get("scope"))
|
||||
job_id = _normalize_optional_str(params.get("id")) or _normalize_optional_str(params.get("jobId"))
|
||||
resolved_scope = scope or ("job" if job_id else "all")
|
||||
if resolved_scope == "job" and not job_id:
|
||||
_bad(respond, "invalid cron.runs params: missing id")
|
||||
return
|
||||
cron = _get_cron(context)
|
||||
if cron is not None and callable(getattr(cron, "listRuns", None)):
|
||||
_ok(
|
||||
respond,
|
||||
cron.listRuns(
|
||||
{
|
||||
"scope": resolved_scope,
|
||||
"jobId": job_id,
|
||||
"limit": params.get("limit"),
|
||||
"offset": params.get("offset"),
|
||||
"statuses": params.get("statuses"),
|
||||
"status": params.get("status"),
|
||||
"deliveryStatuses": params.get("deliveryStatuses"),
|
||||
"deliveryStatus": params.get("deliveryStatus"),
|
||||
"query": params.get("query"),
|
||||
"sortDir": params.get("sortDir"),
|
||||
}
|
||||
),
|
||||
)
|
||||
return
|
||||
_ok(respond, {"items": [], "total": 0, "scope": resolved_scope, "jobId": job_id})
|
||||
|
||||
|
||||
cron_handlers: GatewayRequestHandlers = {
|
||||
"wake": _wake_handler,
|
||||
"cron.list": _cron_list_handler,
|
||||
"cron.status": _cron_status_handler,
|
||||
"cron.add": _cron_add_handler,
|
||||
"cron.update": _cron_update_handler,
|
||||
"cron.remove": _cron_remove_handler,
|
||||
"cron.run": _cron_run_handler,
|
||||
"cron.runs": _cron_runs_handler,
|
||||
}
|
||||
|
||||
281
interfaces/gateway/server_methods/devices.py
Normal file
281
interfaces/gateway/server_methods/devices.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
DEVICE_TOKEN_ROTATION_DENIED_MESSAGE = "device token rotation denied"
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _caller_scopes(client: Any) -> list[str]:
|
||||
if not isinstance(client, dict):
|
||||
return []
|
||||
connect = client.get("connect")
|
||||
if not isinstance(connect, dict):
|
||||
return []
|
||||
scopes = connect.get("scopes")
|
||||
if isinstance(scopes, list):
|
||||
return [str(x) for x in scopes if isinstance(x, str)]
|
||||
return []
|
||||
|
||||
|
||||
def _caller_device_id(client: Any) -> str | None:
|
||||
if not isinstance(client, dict):
|
||||
return None
|
||||
connect = client.get("connect")
|
||||
if not isinstance(connect, dict):
|
||||
return None
|
||||
device = connect.get("device")
|
||||
if not isinstance(device, dict):
|
||||
return None
|
||||
return _norm_str(device.get("id"))
|
||||
|
||||
|
||||
def _denies_cross_device_management(client: Any, target_device_id: str) -> bool:
|
||||
caller_device_id = _caller_device_id(client)
|
||||
scopes = _caller_scopes(client)
|
||||
is_admin = "operator.admin" in scopes
|
||||
return bool(caller_device_id and caller_device_id != target_device_id.strip() and not is_admin)
|
||||
|
||||
|
||||
def _summarize_tokens(tokens: Any) -> dict[str, Any]:
|
||||
if not isinstance(tokens, dict):
|
||||
return {}
|
||||
out: dict[str, Any] = {}
|
||||
for role, entry in tokens.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
out[str(role)] = {
|
||||
"role": str(entry.get("role") or role),
|
||||
"scopes": list(entry.get("scopes") or []),
|
||||
"createdAtMs": entry.get("createdAtMs"),
|
||||
"rotatedAtMs": entry.get("rotatedAtMs"),
|
||||
"revokedAtMs": entry.get("revokedAtMs"),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _redact_paired_device(device: Any) -> dict[str, Any]:
|
||||
if not isinstance(device, dict):
|
||||
return {}
|
||||
out = dict(device)
|
||||
out["tokens"] = _summarize_tokens(device.get("tokens"))
|
||||
if "approvedScopes" in out:
|
||||
del out["approvedScopes"]
|
||||
return out
|
||||
|
||||
|
||||
def _get_device_service(context: Any) -> Any | None:
|
||||
if isinstance(context, dict):
|
||||
return context.get("device_pairing")
|
||||
return None
|
||||
|
||||
|
||||
def _device_pair_list_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if params is not None and not isinstance(params, dict):
|
||||
_bad(respond, "invalid device.pair.list params")
|
||||
return
|
||||
svc = _get_device_service(context)
|
||||
if svc is not None and callable(getattr(svc, "list", None)):
|
||||
listed = svc.list()
|
||||
if isinstance(listed, dict):
|
||||
paired = listed.get("paired")
|
||||
pending = listed.get("pending")
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"pending": pending if isinstance(pending, list) else [],
|
||||
"paired": [_redact_paired_device(x) for x in (paired if isinstance(paired, list) else [])],
|
||||
},
|
||||
)
|
||||
return
|
||||
_ok(respond, {"pending": [], "paired": []})
|
||||
|
||||
|
||||
def _device_pair_approve_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid device.pair.approve params")
|
||||
return
|
||||
request_id = _norm_str(params.get("requestId"))
|
||||
if not request_id:
|
||||
_bad(respond, "invalid device.pair.approve params")
|
||||
return
|
||||
svc = _get_device_service(context)
|
||||
caller_scopes = _caller_scopes(client)
|
||||
if svc is not None and callable(getattr(svc, "approve", None)):
|
||||
approved = svc.approve(request_id, {"callerScopes": caller_scopes})
|
||||
if not approved:
|
||||
_bad(respond, "unknown requestId")
|
||||
return
|
||||
if isinstance(approved, dict) and approved.get("status") == "forbidden":
|
||||
_bad(respond, str(approved.get("message") or "device pairing forbidden"))
|
||||
return
|
||||
device = approved.get("device") if isinstance(approved, dict) else {}
|
||||
_ok(respond, {"requestId": request_id, "device": _redact_paired_device(device)})
|
||||
return
|
||||
_ok(respond, {"requestId": request_id, "device": {"deviceId": "unknown"}})
|
||||
|
||||
|
||||
def _device_pair_reject_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid device.pair.reject params")
|
||||
return
|
||||
request_id = _norm_str(params.get("requestId"))
|
||||
if not request_id:
|
||||
_bad(respond, "invalid device.pair.reject params")
|
||||
return
|
||||
svc = _get_device_service(context)
|
||||
if svc is not None and callable(getattr(svc, "reject", None)):
|
||||
rejected = svc.reject(request_id)
|
||||
if not rejected:
|
||||
_bad(respond, "unknown requestId")
|
||||
return
|
||||
_ok(respond, rejected)
|
||||
return
|
||||
_ok(respond, {"requestId": request_id, "deviceId": "unknown", "decision": "rejected"})
|
||||
|
||||
|
||||
def _device_pair_remove_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid device.pair.remove params")
|
||||
return
|
||||
device_id = _norm_str(params.get("deviceId"))
|
||||
if not device_id:
|
||||
_bad(respond, "invalid device.pair.remove params")
|
||||
return
|
||||
if _denies_cross_device_management(client, device_id):
|
||||
_bad(respond, "device pairing removal denied")
|
||||
return
|
||||
svc = _get_device_service(context)
|
||||
if svc is not None and callable(getattr(svc, "remove", None)):
|
||||
removed = svc.remove(device_id)
|
||||
if not removed:
|
||||
_bad(respond, "unknown deviceId")
|
||||
return
|
||||
_ok(respond, removed)
|
||||
return
|
||||
_ok(respond, {"deviceId": device_id, "removed": True})
|
||||
|
||||
|
||||
def _device_token_rotate_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid device.token.rotate params")
|
||||
return
|
||||
device_id = _norm_str(params.get("deviceId"))
|
||||
role = _norm_str(params.get("role"))
|
||||
scopes = params.get("scopes")
|
||||
if not device_id or not role:
|
||||
_bad(respond, "invalid device.token.rotate params")
|
||||
return
|
||||
if _denies_cross_device_management(client, device_id):
|
||||
_bad(respond, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE)
|
||||
return
|
||||
svc = _get_device_service(context)
|
||||
if svc is not None and callable(getattr(svc, "rotate_token", None)):
|
||||
rotated = svc.rotate_token({"deviceId": device_id, "role": role, "scopes": scopes})
|
||||
if not isinstance(rotated, dict) or not rotated.get("ok"):
|
||||
_bad(respond, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE)
|
||||
return
|
||||
entry = rotated.get("entry") if isinstance(rotated.get("entry"), dict) else {}
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"deviceId": device_id,
|
||||
"role": str(entry.get("role") or role),
|
||||
"token": entry.get("token"),
|
||||
"scopes": entry.get("scopes") if isinstance(entry.get("scopes"), list) else [],
|
||||
"rotatedAtMs": entry.get("rotatedAtMs") or entry.get("createdAtMs"),
|
||||
},
|
||||
)
|
||||
return
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"deviceId": device_id,
|
||||
"role": role,
|
||||
"token": "token",
|
||||
"scopes": scopes if isinstance(scopes, list) else [],
|
||||
"rotatedAtMs": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _device_token_revoke_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid device.token.revoke params")
|
||||
return
|
||||
device_id = _norm_str(params.get("deviceId"))
|
||||
role = _norm_str(params.get("role"))
|
||||
if not device_id or not role:
|
||||
_bad(respond, "invalid device.token.revoke params")
|
||||
return
|
||||
if _denies_cross_device_management(client, device_id):
|
||||
_bad(respond, "device token revocation denied")
|
||||
return
|
||||
svc = _get_device_service(context)
|
||||
if svc is not None and callable(getattr(svc, "revoke_token", None)):
|
||||
entry = svc.revoke_token({"deviceId": device_id, "role": role})
|
||||
if not isinstance(entry, dict):
|
||||
_bad(respond, "unknown deviceId/role")
|
||||
return
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"deviceId": device_id,
|
||||
"role": str(entry.get("role") or role),
|
||||
"revokedAtMs": entry.get("revokedAtMs"),
|
||||
},
|
||||
)
|
||||
return
|
||||
_ok(respond, {"deviceId": device_id, "role": role, "revokedAtMs": None})
|
||||
|
||||
|
||||
device_handlers: GatewayRequestHandlers = {
|
||||
"device.pair.list": _device_pair_list_handler,
|
||||
"device.pair.approve": _device_pair_approve_handler,
|
||||
"device.pair.reject": _device_pair_reject_handler,
|
||||
"device.pair.remove": _device_pair_remove_handler,
|
||||
"device.token.rotate": _device_token_rotate_handler,
|
||||
"device.token.revoke": _device_token_revoke_handler,
|
||||
}
|
||||
|
||||
6
interfaces/gateway/server_methods/dispatcher.py
Normal file
6
interfaces/gateway/server_methods/dispatcher.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Compatibility bridge to the new oclaw dispatcher."""
|
||||
|
||||
from oclaw.interfaces.gateway.dispatcher import build_gateway_method_handlers, method_names
|
||||
|
||||
__all__ = ["build_gateway_method_handlers", "method_names"]
|
||||
|
||||
86
interfaces/gateway/server_methods/doctor.py
Normal file
86
interfaces/gateway/server_methods/doctor.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _doctor_memory_status_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
hook = context.get("doctor_memory_status") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
payload = hook()
|
||||
_ok(respond, payload if isinstance(payload, dict) else {})
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
|
||||
# Staging fallback shape (compatible with dashboard expectations).
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"agentId": "main",
|
||||
"provider": None,
|
||||
"embedding": {"ok": True},
|
||||
"dreaming": {
|
||||
"enabled": False,
|
||||
"verboseLogging": False,
|
||||
"storageMode": "inline",
|
||||
"separateReports": False,
|
||||
"shortTermCount": 0,
|
||||
"recallSignalCount": 0,
|
||||
"dailySignalCount": 0,
|
||||
"groundedSignalCount": 0,
|
||||
"totalSignalCount": 0,
|
||||
"phaseSignalCount": 0,
|
||||
"lightPhaseHitCount": 0,
|
||||
"remPhaseHitCount": 0,
|
||||
"promotedTotal": 0,
|
||||
"promotedToday": 0,
|
||||
"shortTermEntries": [],
|
||||
"signalEntries": [],
|
||||
"promotedEntries": [],
|
||||
"phases": {
|
||||
"light": {"enabled": False, "cron": "", "managedCronPresent": False, "lookbackDays": 0, "limit": 0},
|
||||
"deep": {
|
||||
"enabled": False,
|
||||
"cron": "",
|
||||
"managedCronPresent": False,
|
||||
"minScore": 0,
|
||||
"minRecallCount": 0,
|
||||
"minUniqueQueries": 0,
|
||||
"recencyHalfLifeDays": 0,
|
||||
"limit": 0,
|
||||
},
|
||||
"rem": {
|
||||
"enabled": False,
|
||||
"cron": "",
|
||||
"managedCronPresent": False,
|
||||
"lookbackDays": 0,
|
||||
"limit": 0,
|
||||
"minPatternStrength": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
doctor_handlers: GatewayRequestHandlers = {
|
||||
"doctor.memory.status": _doctor_memory_status_handler,
|
||||
}
|
||||
|
||||
192
interfaces/gateway/server_methods/exec_approvals.py
Normal file
192
interfaces/gateway/server_methods/exec_approvals.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _state(context: Any) -> dict[str, Any]:
|
||||
if not isinstance(context, dict):
|
||||
return {"path": ".oclaw/exec-approvals.json", "exists": False, "hash": None, "file": {}}
|
||||
st = context.get("_exec_approvals_state")
|
||||
if isinstance(st, dict):
|
||||
return st
|
||||
created = {"path": ".oclaw/exec-approvals.json", "exists": False, "hash": None, "file": {}}
|
||||
context["_exec_approvals_state"] = created
|
||||
return created
|
||||
|
||||
|
||||
def _payload(st: dict[str, Any]) -> dict[str, Any]:
|
||||
file_obj = st.get("file")
|
||||
file_obj = dict(file_obj) if isinstance(file_obj, dict) else {}
|
||||
socket = file_obj.get("socket")
|
||||
if isinstance(socket, dict):
|
||||
path = _norm_str(socket.get("path"))
|
||||
file_obj["socket"] = {"path": path} if path else None
|
||||
return {
|
||||
"path": st.get("path"),
|
||||
"exists": bool(st.get("exists", False)),
|
||||
"hash": st.get("hash"),
|
||||
"file": file_obj,
|
||||
}
|
||||
|
||||
|
||||
def _require_base_hash(params: dict[str, Any], st: dict[str, Any], respond) -> bool:
|
||||
if not bool(st.get("exists")):
|
||||
return True
|
||||
snap_hash = _norm_str(st.get("hash"))
|
||||
if not snap_hash:
|
||||
_bad(respond, "exec approvals base hash unavailable; re-run exec.approvals.get and retry")
|
||||
return False
|
||||
base_hash = _norm_str(params.get("baseHash"))
|
||||
if not base_hash:
|
||||
_bad(respond, "exec approvals base hash required; re-run exec.approvals.get and retry")
|
||||
return False
|
||||
if base_hash != snap_hash:
|
||||
_bad(respond, "exec approvals changed since last load; re-run exec.approvals.get and retry")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _resolve_node_id_or_error(raw: Any, respond) -> str | None:
|
||||
node_id = _norm_str(raw)
|
||||
if not node_id:
|
||||
_bad(respond, "nodeId required")
|
||||
return None
|
||||
return node_id
|
||||
|
||||
|
||||
def _exec_approvals_get_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
hook = context.get("read_exec_approvals_snapshot") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
snap = hook()
|
||||
if isinstance(snap, dict):
|
||||
_ok(respond, _payload(snap))
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
st = _state(context)
|
||||
_ok(respond, _payload(st))
|
||||
|
||||
|
||||
def _exec_approvals_set_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid exec.approvals.set params")
|
||||
return
|
||||
st = _state(context)
|
||||
if not _require_base_hash(params, st, respond):
|
||||
return
|
||||
incoming = params.get("file")
|
||||
if not isinstance(incoming, dict):
|
||||
_bad(respond, "exec approvals file is required")
|
||||
return
|
||||
hook = context.get("write_exec_approvals") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
next_snap = hook({"file": incoming, "baseHash": params.get("baseHash")})
|
||||
if isinstance(next_snap, dict):
|
||||
_ok(respond, _payload(next_snap))
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
st["file"] = dict(incoming)
|
||||
st["exists"] = True
|
||||
st["hash"] = f"h{abs(hash(str(incoming))) % 1000000}"
|
||||
_ok(respond, _payload(st))
|
||||
|
||||
|
||||
def _exec_approvals_node_get_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid exec.approvals.node.get params")
|
||||
return
|
||||
node_id = _resolve_node_id_or_error(params.get("nodeId"), respond)
|
||||
if not node_id:
|
||||
return
|
||||
hook = context.get("node_exec_approvals_get") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
payload = hook({"nodeId": node_id})
|
||||
_ok(respond, payload if payload is not None else {})
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
_ok(respond, {"nodeId": node_id, "path": None, "exists": False, "hash": None, "file": {}})
|
||||
|
||||
|
||||
def _exec_approvals_node_set_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid exec.approvals.node.set params")
|
||||
return
|
||||
node_id = _resolve_node_id_or_error(params.get("nodeId"), respond)
|
||||
if not node_id:
|
||||
return
|
||||
file_obj = params.get("file")
|
||||
if not isinstance(file_obj, dict):
|
||||
_bad(respond, "exec approvals file is required")
|
||||
return
|
||||
hook = context.get("node_exec_approvals_set") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
payload = hook({"nodeId": node_id, "file": file_obj, "baseHash": params.get("baseHash")})
|
||||
_ok(respond, payload if payload is not None else {})
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"nodeId": node_id,
|
||||
"path": None,
|
||||
"exists": True,
|
||||
"hash": f"h{abs(hash(str(file_obj))) % 1000000}",
|
||||
"file": dict(file_obj),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
exec_approvals_handlers: GatewayRequestHandlers = {
|
||||
"exec.approvals.get": _exec_approvals_get_handler,
|
||||
"exec.approvals.set": _exec_approvals_set_handler,
|
||||
"exec.approvals.node.get": _exec_approvals_node_get_handler,
|
||||
"exec.approvals.node.set": _exec_approvals_node_set_handler,
|
||||
}
|
||||
|
||||
60
interfaces/gateway/server_methods/health.py
Normal file
60
interfaces/gateway/server_methods/health.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
HEALTH_REFRESH_INTERVAL_MS = 5_000
|
||||
ADMIN_SCOPE = "operator.admin"
|
||||
|
||||
|
||||
def _health_handler(opts: dict[str, Any]) -> Any:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context") or {}
|
||||
params = opts.get("params") or {}
|
||||
if not callable(respond):
|
||||
return None
|
||||
wants_probe = bool(params.get("probe") is True)
|
||||
get_health_cache = context.get("get_health_cache")
|
||||
refresh_health_snapshot = context.get("refresh_health_snapshot")
|
||||
now = __import__("time").time() * 1000
|
||||
cached = get_health_cache() if callable(get_health_cache) else None
|
||||
if (
|
||||
not wants_probe
|
||||
and isinstance(cached, dict)
|
||||
and isinstance(cached.get("ts"), (int, float))
|
||||
and now - float(cached["ts"]) < HEALTH_REFRESH_INTERVAL_MS
|
||||
):
|
||||
respond(True, cached, None, {"cached": True})
|
||||
return None
|
||||
try:
|
||||
snap = refresh_health_snapshot({"probe": wants_probe}) if callable(refresh_health_snapshot) else {}
|
||||
respond(True, snap, None, None)
|
||||
except Exception as exc:
|
||||
respond(False, None, error_shape("UNAVAILABLE", str(exc)), None)
|
||||
return None
|
||||
|
||||
|
||||
def _status_handler(opts: dict[str, Any]) -> Any:
|
||||
respond = opts.get("respond")
|
||||
client = opts.get("client") or {}
|
||||
if not callable(respond):
|
||||
return None
|
||||
scopes = []
|
||||
connect = client.get("connect")
|
||||
if isinstance(connect, dict) and isinstance(connect.get("scopes"), list):
|
||||
scopes = [str(x) for x in connect.get("scopes", [])]
|
||||
status = {
|
||||
"includeSensitive": ADMIN_SCOPE in scopes,
|
||||
"ok": True,
|
||||
}
|
||||
respond(True, status, None, None)
|
||||
return None
|
||||
|
||||
|
||||
health_handlers: GatewayRequestHandlers = {
|
||||
"health": _health_handler,
|
||||
"status": _status_handler,
|
||||
}
|
||||
|
||||
156
interfaces/gateway/server_methods/image.py
Normal file
156
interfaces/gateway/server_methods/image.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from oclaw.platform.config.runtime_paths import runtime_extensions_root
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _fail(respond, code: str, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape(code, message), None)
|
||||
|
||||
|
||||
_generate_image_fn: Callable[..., Any] | None = None
|
||||
|
||||
|
||||
def _load_generate_image_fn() -> Callable[..., Any]:
|
||||
global _generate_image_fn
|
||||
if _generate_image_fn is not None:
|
||||
return _generate_image_fn
|
||||
file_path = (runtime_extensions_root() / "image-generation-core" / "api.py").resolve()
|
||||
if not file_path.exists():
|
||||
# Backward compatibility for legacy layout.
|
||||
file_path = Path("runtime/extensions/image-generation-core/api.py").resolve()
|
||||
spec = importlib.util.spec_from_file_location("gateway_image_generation_core_api", str(file_path))
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("failed to load image-generation-core api module")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
fn = getattr(module, "generate_image", None)
|
||||
if not callable(fn):
|
||||
raise RuntimeError("image-generation-core.generate_image is not callable")
|
||||
_generate_image_fn = fn
|
||||
return fn
|
||||
|
||||
|
||||
def _image_generate_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid image.generate params")
|
||||
return
|
||||
prompt = params.get("prompt")
|
||||
if not isinstance(prompt, str) or not prompt.strip():
|
||||
_bad(respond, "image.generate requires prompt")
|
||||
return
|
||||
provider = params.get("provider")
|
||||
if provider is not None and (not isinstance(provider, str) or not provider.strip()):
|
||||
_bad(respond, "image.generate provider must be a non-empty string when provided")
|
||||
return
|
||||
size = params.get("size")
|
||||
if size is not None and (not isinstance(size, str) or not size.strip()):
|
||||
_bad(respond, "image.generate size must be a non-empty string when provided")
|
||||
return
|
||||
quality = params.get("quality")
|
||||
if quality is not None and (not isinstance(quality, str) or not quality.strip()):
|
||||
_bad(respond, "image.generate quality must be a non-empty string when provided")
|
||||
return
|
||||
|
||||
# Prefer explicit hook so app server can own the runtime.
|
||||
hook = context.get("image_generate") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
out = hook(params)
|
||||
_ok(respond, out if isinstance(out, dict) else {"ok": True, "result": out})
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
|
||||
# Fallback: use image-generation-core with providers passed through context/runtime snapshot.
|
||||
providers: list[dict[str, Any]] = []
|
||||
if isinstance(context, dict):
|
||||
raw = context.get("image_generation_providers")
|
||||
if isinstance(raw, list):
|
||||
providers = [p for p in raw if isinstance(p, dict)]
|
||||
elif callable(context.get("get_runtime_snapshot")):
|
||||
try:
|
||||
snap = context["get_runtime_snapshot"]()
|
||||
if isinstance(snap, dict) and isinstance(snap.get("image_generation_providers"), list):
|
||||
providers = [p for p in snap.get("image_generation_providers") if isinstance(p, dict)]
|
||||
except Exception:
|
||||
providers = []
|
||||
# Keep only image-capable providers when capability is declared.
|
||||
providers = [
|
||||
p
|
||||
for p in providers
|
||||
if not isinstance(p.get("capabilities"), dict) or bool((p.get("capabilities") or {}).get("image_generation", True))
|
||||
]
|
||||
if provider and not any(str(p.get("id")) == provider.strip() for p in providers):
|
||||
_fail(respond, "NOT_FOUND", f'image provider "{provider.strip()}" is not registered')
|
||||
return
|
||||
cfg: dict[str, Any] = {}
|
||||
if isinstance(context, dict) and isinstance(context.get("config"), dict):
|
||||
cfg = dict(context.get("config") or {})
|
||||
image_cfg = cfg.get("image") if isinstance(cfg.get("image"), dict) else {}
|
||||
default_provider = str((image_cfg or {}).get("defaultProvider") or "").strip()
|
||||
priority = [str(x).strip() for x in ((image_cfg or {}).get("providerPriority") or []) if str(x).strip()]
|
||||
|
||||
ordered = list(providers)
|
||||
if not provider:
|
||||
if default_provider and any(str(p.get("id") or "") == default_provider for p in ordered):
|
||||
ordered.sort(key=lambda p: 0 if str(p.get("id") or "") == default_provider else 1)
|
||||
elif priority:
|
||||
rank = {pid: idx for idx, pid in enumerate(priority)}
|
||||
ordered.sort(key=lambda p: rank.get(str(p.get("id") or ""), 10_000))
|
||||
runtime = {"image_generation_providers": ordered}
|
||||
try:
|
||||
generate_image = _load_generate_image_fn()
|
||||
out = generate_image(
|
||||
prompt=prompt.strip(),
|
||||
provider_id=(provider.strip() if isinstance(provider, str) else None),
|
||||
runtime=runtime,
|
||||
size=(size.strip() if isinstance(size, str) else None),
|
||||
quality=(quality.strip() if isinstance(quality, str) else None),
|
||||
)
|
||||
if isinstance(out, dict) and out.get("ok") is True:
|
||||
_ok(respond, out)
|
||||
return
|
||||
if isinstance(out, dict):
|
||||
err = str(out.get("error") or "image generation failed")
|
||||
if err == "no_image_generation_provider_registered":
|
||||
_fail(respond, "UNAVAILABLE", err)
|
||||
return
|
||||
if err == "provider_not_found":
|
||||
_fail(respond, "NOT_FOUND", err)
|
||||
return
|
||||
_fail(respond, "UNAVAILABLE", err)
|
||||
return
|
||||
_unavailable(respond, "image generation failed")
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
|
||||
|
||||
image_handlers: GatewayRequestHandlers = {
|
||||
"image.generate": _image_generate_handler,
|
||||
}
|
||||
|
||||
63
interfaces/gateway/server_methods/logs.py
Normal file
63
interfaces/gateway/server_methods/logs.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _read_configured_log_tail(*, cursor: int | None, limit: int | None, max_bytes: int | None) -> dict[str, Any]:
|
||||
# Placeholder runtime adapter for Python rewrite stage.
|
||||
return {
|
||||
"cursor": cursor or 0,
|
||||
"lines": [],
|
||||
"nextCursor": cursor or 0,
|
||||
"limit": limit or 0,
|
||||
"maxBytes": max_bytes or 0,
|
||||
}
|
||||
|
||||
|
||||
def _validate_logs_tail_params(params: Any) -> bool:
|
||||
if not isinstance(params, dict):
|
||||
return False
|
||||
for key in ("cursor", "limit", "maxBytes"):
|
||||
if key in params and not isinstance(params[key], int):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _logs_tail_handler(opts: dict[str, Any]) -> Any:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
if not callable(respond):
|
||||
return None
|
||||
if not _validate_logs_tail_params(params):
|
||||
respond(
|
||||
False,
|
||||
None,
|
||||
error_shape("INVALID_REQUEST", "invalid logs.tail params"),
|
||||
None,
|
||||
)
|
||||
return None
|
||||
p = params if isinstance(params, dict) else {}
|
||||
try:
|
||||
result = _read_configured_log_tail(
|
||||
cursor=p.get("cursor"),
|
||||
limit=p.get("limit"),
|
||||
max_bytes=p.get("maxBytes"),
|
||||
)
|
||||
respond(True, result, None, None)
|
||||
except Exception as exc:
|
||||
respond(
|
||||
False,
|
||||
None,
|
||||
error_shape("UNAVAILABLE", f"log read failed: {exc}"),
|
||||
None,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
logs_handlers: GatewayRequestHandlers = {
|
||||
"logs.tail": _logs_tail_handler,
|
||||
}
|
||||
|
||||
61
interfaces/gateway/server_methods/models.py
Normal file
61
interfaces/gateway/server_methods/models.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _models_list_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params")
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if params is not None and not isinstance(params, dict):
|
||||
_bad(respond, "invalid models.list params")
|
||||
return
|
||||
load_catalog = context.get("loadGatewayModelCatalog") if isinstance(context, dict) else None
|
||||
if not callable(load_catalog):
|
||||
# Staging fallback: keep contract shape.
|
||||
_ok(respond, {"models": []})
|
||||
return
|
||||
try:
|
||||
catalog = load_catalog()
|
||||
if not isinstance(catalog, list):
|
||||
catalog = []
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
|
||||
# Optional policy hook to emulate TS buildAllowedModelSet behavior.
|
||||
allowed_hook = context.get("filterAllowedModels") if isinstance(context, dict) else None
|
||||
if callable(allowed_hook):
|
||||
try:
|
||||
allowed = allowed_hook(catalog)
|
||||
if isinstance(allowed, list) and len(allowed) > 0:
|
||||
_ok(respond, {"models": allowed})
|
||||
return
|
||||
except Exception:
|
||||
# Non-fatal: fallback to full catalog
|
||||
pass
|
||||
_ok(respond, {"models": catalog})
|
||||
|
||||
|
||||
models_handlers: GatewayRequestHandlers = {
|
||||
"models.list": _models_list_handler,
|
||||
}
|
||||
|
||||
128
interfaces/gateway/server_methods/models_auth_status.py
Normal file
128
interfaces/gateway/server_methods/models_auth_status.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
MODEL_AUTH_STATUS_NEVER_LOADED = 0
|
||||
CACHE_TTL_MS = 60_000
|
||||
_cached: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def invalidate_model_auth_status_cache() -> None:
|
||||
global _cached
|
||||
_cached = None
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _ok(respond, payload: Any, meta: dict[str, Any] | None = None) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, meta or None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _normalize_profile_type(v: Any) -> str:
|
||||
if isinstance(v, str) and v in {"oauth", "token", "api_key"}:
|
||||
return v
|
||||
return "api_key"
|
||||
|
||||
|
||||
def _build_expiry(remaining_ms: Any, expires_at: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(expires_at, (int, float)) or not isinstance(remaining_ms, (int, float)):
|
||||
return None
|
||||
rm = int(remaining_ms)
|
||||
if rm >= 86_400_000:
|
||||
label = f"{rm // 86_400_000}d"
|
||||
elif rm >= 3_600_000:
|
||||
label = f"{rm // 3_600_000}h"
|
||||
else:
|
||||
label = f"{max(0, rm // 60_000)}m"
|
||||
return {"at": int(expires_at), "remainingMs": rm, "label": label}
|
||||
|
||||
|
||||
def _map_provider(item: dict[str, Any]) -> dict[str, Any]:
|
||||
provider = str(item.get("provider") or "")
|
||||
profiles_raw = item.get("profiles")
|
||||
profiles: list[dict[str, Any]] = []
|
||||
if isinstance(profiles_raw, list):
|
||||
for p in profiles_raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
expiry = _build_expiry(p.get("remainingMs"), p.get("expiresAt"))
|
||||
prof = {
|
||||
"profileId": str(p.get("profileId") or ""),
|
||||
"type": _normalize_profile_type(p.get("type")),
|
||||
"status": str(p.get("status") or "missing"),
|
||||
}
|
||||
if expiry is not None:
|
||||
prof["expiry"] = expiry
|
||||
profiles.append(prof)
|
||||
|
||||
expiry = _build_expiry(item.get("remainingMs"), item.get("expiresAt"))
|
||||
out = {
|
||||
"provider": provider,
|
||||
"displayName": str(item.get("displayName") or provider),
|
||||
"status": str(item.get("status") or "missing"),
|
||||
"profiles": profiles,
|
||||
}
|
||||
if expiry is not None:
|
||||
out["expiry"] = expiry
|
||||
usage = item.get("usage")
|
||||
if isinstance(usage, dict):
|
||||
windows = usage.get("windows")
|
||||
plan = usage.get("plan")
|
||||
out["usage"] = {
|
||||
"windows": windows if isinstance(windows, list) else [],
|
||||
"plan": str(plan) if isinstance(plan, str) else None,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _models_auth_status_handler(opts: dict[str, Any]) -> None:
|
||||
global _cached
|
||||
params = opts.get("params")
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if params is not None and not isinstance(params, dict):
|
||||
_unavailable(respond, "invalid models.authStatus params")
|
||||
return
|
||||
|
||||
now = _now_ms()
|
||||
bypass_cache = bool((params or {}).get("refresh")) if isinstance(params, dict) else False
|
||||
if not bypass_cache and _cached is not None:
|
||||
cached_ts = int(_cached.get("ts") or 0)
|
||||
if now - cached_ts < CACHE_TTL_MS:
|
||||
_ok(respond, _cached["result"], {"cached": True})
|
||||
return
|
||||
|
||||
load_auth = context.get("load_models_auth_status") if isinstance(context, dict) else None
|
||||
try:
|
||||
providers: list[dict[str, Any]] = []
|
||||
if callable(load_auth):
|
||||
raw = load_auth()
|
||||
if isinstance(raw, dict):
|
||||
providers_raw = raw.get("providers")
|
||||
if isinstance(providers_raw, list):
|
||||
providers = [_map_provider(x) for x in providers_raw if isinstance(x, dict)]
|
||||
elif isinstance(raw, list):
|
||||
providers = [_map_provider(x) for x in raw if isinstance(x, dict)]
|
||||
result = {"ts": now, "providers": providers}
|
||||
_cached = {"ts": now, "result": result}
|
||||
_ok(respond, result)
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
|
||||
|
||||
models_auth_status_handlers: GatewayRequestHandlers = {
|
||||
"models.authStatus": _models_auth_status_handler,
|
||||
}
|
||||
|
||||
340
interfaces/gateway/server_methods/nodes.py
Normal file
340
interfaces/gateway/server_methods/nodes.py
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
NODE_WAKE_RECONNECT_WAIT_MS = 3_000
|
||||
NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str, *, code: str = "INVALID_REQUEST", details: dict[str, Any] | None = None) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape(code, message, {"details": details} if details else None), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _state(context: Any) -> dict[str, Any]:
|
||||
if not isinstance(context, dict):
|
||||
return {"pair_requests": {}, "paired": {}, "pending_actions": {}}
|
||||
st = context.get("_nodes_state")
|
||||
if isinstance(st, dict):
|
||||
return st
|
||||
created = {"pair_requests": {}, "paired": {}, "pending_actions": {}}
|
||||
context["_nodes_state"] = created
|
||||
return created
|
||||
|
||||
|
||||
def _resolve_client_node_id(client: Any) -> str | None:
|
||||
if not isinstance(client, dict):
|
||||
return None
|
||||
connect = client.get("connect")
|
||||
if not isinstance(connect, dict):
|
||||
return None
|
||||
device = connect.get("device")
|
||||
client_info = connect.get("client")
|
||||
if isinstance(device, dict):
|
||||
got = _norm_str(device.get("id"))
|
||||
if got:
|
||||
return got
|
||||
if isinstance(client_info, dict):
|
||||
return _norm_str(client_info.get("id"))
|
||||
return None
|
||||
|
||||
|
||||
def _node_pair_request_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid node.pair.request params")
|
||||
return
|
||||
node_id = _norm_str(params.get("nodeId"))
|
||||
if not node_id:
|
||||
_bad(respond, "invalid node.pair.request params")
|
||||
return
|
||||
st = _state(context)
|
||||
req_id = f"npr_{int(time.time()*1000)}"
|
||||
request = {
|
||||
"requestId": req_id,
|
||||
"nodeId": node_id,
|
||||
"displayName": _norm_str(params.get("displayName")) or node_id,
|
||||
"platform": _norm_str(params.get("platform")),
|
||||
"ts": int(time.time() * 1000),
|
||||
}
|
||||
st["pair_requests"][req_id] = request
|
||||
_ok(respond, {"status": "pending", "created": True, "request": request})
|
||||
|
||||
|
||||
def _node_pair_list_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
st = _state(context)
|
||||
_ok(respond, {"pending": list(st["pair_requests"].values()), "paired": list(st["paired"].values())})
|
||||
|
||||
|
||||
def _node_pair_approve_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid node.pair.approve params")
|
||||
return
|
||||
req_id = _norm_str(params.get("requestId"))
|
||||
if not req_id:
|
||||
_bad(respond, "invalid node.pair.approve params")
|
||||
return
|
||||
st = _state(context)
|
||||
req = st["pair_requests"].pop(req_id, None)
|
||||
if not isinstance(req, dict):
|
||||
_bad(respond, "unknown requestId")
|
||||
return
|
||||
node = {"nodeId": req["nodeId"], "displayName": req.get("displayName") or req["nodeId"], "platform": req.get("platform")}
|
||||
st["paired"][node["nodeId"]] = node
|
||||
_ok(respond, {"requestId": req_id, "node": node})
|
||||
|
||||
|
||||
def _node_pair_reject_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid node.pair.reject params")
|
||||
return
|
||||
req_id = _norm_str(params.get("requestId"))
|
||||
if not req_id:
|
||||
_bad(respond, "invalid node.pair.reject params")
|
||||
return
|
||||
st = _state(context)
|
||||
req = st["pair_requests"].pop(req_id, None)
|
||||
if not isinstance(req, dict):
|
||||
_bad(respond, "unknown requestId")
|
||||
return
|
||||
_ok(respond, {"requestId": req_id, "nodeId": req.get("nodeId"), "decision": "rejected"})
|
||||
|
||||
|
||||
def _node_pair_verify_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid node.pair.verify params")
|
||||
return
|
||||
node_id = _norm_str(params.get("nodeId"))
|
||||
token = _norm_str(params.get("token"))
|
||||
if not node_id or not token:
|
||||
_bad(respond, "invalid node.pair.verify params")
|
||||
return
|
||||
st = _state(context)
|
||||
ok = node_id in st["paired"]
|
||||
_ok(respond, {"ok": ok, "nodeId": node_id})
|
||||
|
||||
|
||||
def _node_rename_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid node.rename params")
|
||||
return
|
||||
node_id = _norm_str(params.get("nodeId"))
|
||||
display_name = _norm_str(params.get("displayName"))
|
||||
if not node_id or not display_name:
|
||||
_bad(respond, "displayName required")
|
||||
return
|
||||
st = _state(context)
|
||||
node = st["paired"].get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
_bad(respond, "unknown nodeId")
|
||||
return
|
||||
node["displayName"] = display_name
|
||||
_ok(respond, {"nodeId": node_id, "displayName": display_name})
|
||||
|
||||
|
||||
def _node_list_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
st = _state(context)
|
||||
nodes = list(st["paired"].values())
|
||||
_ok(respond, {"ts": int(time.time() * 1000), "nodes": nodes})
|
||||
|
||||
|
||||
def _node_describe_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid node.describe params")
|
||||
return
|
||||
node_id = _norm_str(params.get("nodeId"))
|
||||
if not node_id:
|
||||
_bad(respond, "nodeId required")
|
||||
return
|
||||
st = _state(context)
|
||||
node = st["paired"].get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
_bad(respond, "unknown nodeId")
|
||||
return
|
||||
_ok(respond, {"ts": int(time.time() * 1000), **node})
|
||||
|
||||
|
||||
def _node_canvas_capability_refresh_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
client = opts.get("client")
|
||||
if not isinstance(client, dict):
|
||||
_unavailable(respond, "canvas host unavailable for this node session")
|
||||
return
|
||||
base = _norm_str(client.get("canvas_host_url"))
|
||||
if not base:
|
||||
_unavailable(respond, "canvas host unavailable for this node session")
|
||||
return
|
||||
cap = f"cap_{int(time.time()*1000)}"
|
||||
exp = int(time.time() * 1000) + 5 * 60_000
|
||||
client["canvas_capability"] = cap
|
||||
client["canvas_capability_expires_at_ms"] = exp
|
||||
_ok(respond, {"canvasCapability": cap, "canvasCapabilityExpiresAtMs": exp, "canvasHostUrl": f"{base.rstrip('/')}/scoped/{cap}"})
|
||||
|
||||
|
||||
def _node_pending_pull_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
client = opts.get("client")
|
||||
context = opts.get("context")
|
||||
node_id = _resolve_client_node_id(client)
|
||||
if not node_id:
|
||||
_bad(respond, "nodeId required")
|
||||
return
|
||||
st = _state(context)
|
||||
actions = st["pending_actions"].get(node_id, [])
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"nodeId": node_id,
|
||||
"actions": [
|
||||
{"id": x.get("id"), "command": x.get("command"), "paramsJSON": x.get("paramsJSON"), "enqueuedAtMs": x.get("enqueuedAtMs")}
|
||||
for x in actions
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _node_pending_ack_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
client = opts.get("client")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid node.pending.ack params")
|
||||
return
|
||||
node_id = _resolve_client_node_id(client)
|
||||
if not node_id:
|
||||
_bad(respond, "nodeId required")
|
||||
return
|
||||
ids_raw = params.get("ids")
|
||||
ids = []
|
||||
if isinstance(ids_raw, list):
|
||||
ids = [x for x in {_norm_str(i) for i in ids_raw} if x]
|
||||
st = _state(context)
|
||||
current = st["pending_actions"].get(node_id, [])
|
||||
remaining = [x for x in current if x.get("id") not in set(ids)]
|
||||
st["pending_actions"][node_id] = remaining
|
||||
_ok(respond, {"nodeId": node_id, "ackedIds": ids, "remainingCount": len(remaining)})
|
||||
|
||||
|
||||
def _node_invoke_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid node.invoke params")
|
||||
return
|
||||
node_id = _norm_str(params.get("nodeId"))
|
||||
command = _norm_str(params.get("command"))
|
||||
if not node_id or not command:
|
||||
_bad(respond, "nodeId and command required")
|
||||
return
|
||||
if command in {"system.execApprovals.get", "system.execApprovals.set"}:
|
||||
_bad(respond, "node.invoke does not allow system.execApprovals.*; use exec.approvals.node.*", details={"command": command})
|
||||
return
|
||||
# optional hook
|
||||
hook = context.get("node_invoke") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
res = hook({"nodeId": node_id, "command": command, "params": params.get("params"), "timeoutMs": params.get("timeoutMs"), "idempotencyKey": params.get("idempotencyKey")})
|
||||
if isinstance(res, dict):
|
||||
if not bool(res.get("ok", True)):
|
||||
_unavailable(respond, str((res.get("error") or {}).get("message") or "node not connected"))
|
||||
return
|
||||
payload = res.get("payload")
|
||||
_ok(respond, {"ok": True, "nodeId": node_id, "command": command, "payload": payload, "payloadJSON": res.get("payloadJSON")})
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
_ok(respond, {"ok": True, "nodeId": node_id, "command": command, "payload": {"ok": True}, "payloadJSON": None})
|
||||
|
||||
|
||||
def _node_invoke_result_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
# staging ack
|
||||
_ok(respond, {"ok": True})
|
||||
|
||||
|
||||
def _node_event_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid node.event params")
|
||||
return
|
||||
event = _norm_str(params.get("event"))
|
||||
if not event:
|
||||
_bad(respond, "invalid node.event params")
|
||||
return
|
||||
node_id = _resolve_client_node_id(client) or "node"
|
||||
# optional hook
|
||||
hook = context.get("handle_node_event") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
hook({"nodeId": node_id, "event": event, "payload": params.get("payload"), "payloadJSON": params.get("payloadJSON")})
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
_ok(respond, {"ok": True})
|
||||
|
||||
|
||||
node_handlers: GatewayRequestHandlers = {
|
||||
"node.pair.request": _node_pair_request_handler,
|
||||
"node.pair.list": _node_pair_list_handler,
|
||||
"node.pair.approve": _node_pair_approve_handler,
|
||||
"node.pair.reject": _node_pair_reject_handler,
|
||||
"node.pair.verify": _node_pair_verify_handler,
|
||||
"node.rename": _node_rename_handler,
|
||||
"node.list": _node_list_handler,
|
||||
"node.describe": _node_describe_handler,
|
||||
"node.canvas.capability.refresh": _node_canvas_capability_refresh_handler,
|
||||
"node.pending.pull": _node_pending_pull_handler,
|
||||
"node.pending.ack": _node_pending_ack_handler,
|
||||
"node.invoke": _node_invoke_handler,
|
||||
"node.invoke.result": _node_invoke_result_handler,
|
||||
"node.event": _node_event_handler,
|
||||
}
|
||||
|
||||
158
interfaces/gateway/server_methods/nodes_pending.py
Normal file
158
interfaces/gateway/server_methods/nodes_pending.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_client_node_id(client: Any) -> str | None:
|
||||
if not isinstance(client, dict):
|
||||
return None
|
||||
connect = client.get("connect")
|
||||
if not isinstance(connect, dict):
|
||||
return None
|
||||
device = connect.get("device")
|
||||
client_info = connect.get("client")
|
||||
node_id = None
|
||||
if isinstance(device, dict):
|
||||
node_id = _norm_str(device.get("id"))
|
||||
if node_id:
|
||||
return node_id
|
||||
if isinstance(client_info, dict):
|
||||
return _norm_str(client_info.get("id"))
|
||||
return None
|
||||
|
||||
|
||||
def _state(context: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
st = context.get("_node_pending_work")
|
||||
if isinstance(st, dict):
|
||||
return st
|
||||
created: dict[str, list[dict[str, Any]]] = {}
|
||||
context["_node_pending_work"] = created
|
||||
return created
|
||||
|
||||
|
||||
def _enqueue_work(context: Any, item: dict[str, Any]) -> dict[str, Any]:
|
||||
hook = context.get("enqueue_node_pending_work") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook(item)
|
||||
if isinstance(out, dict):
|
||||
return out
|
||||
st = _state(context)
|
||||
bucket = st.setdefault(item["nodeId"], [])
|
||||
deduped = any(x.get("type") == item["type"] for x in bucket)
|
||||
entry = {
|
||||
"id": f"npw_{int(time.time()*1000)}_{len(bucket)+1}",
|
||||
"nodeId": item["nodeId"],
|
||||
"type": item["type"],
|
||||
"priority": item.get("priority") or "normal",
|
||||
"createdAtMs": int(time.time() * 1000),
|
||||
"status": "queued",
|
||||
}
|
||||
if not deduped:
|
||||
bucket.append(entry)
|
||||
return {"deduped": deduped, "item": entry}
|
||||
|
||||
|
||||
def _drain_work(context: Any, node_id: str, max_items: int | None) -> dict[str, Any]:
|
||||
hook = context.get("drain_node_pending_work") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook({"nodeId": node_id, "maxItems": max_items, "includeDefaultStatus": True})
|
||||
if isinstance(out, dict):
|
||||
return out
|
||||
st = _state(context)
|
||||
bucket = st.get(node_id, [])
|
||||
n = len(bucket) if max_items is None else max(0, int(max_items))
|
||||
drained = bucket[:n]
|
||||
st[node_id] = bucket[n:]
|
||||
return {"items": drained, "count": len(drained), "remaining": len(st[node_id]), "defaultStatusIncluded": True}
|
||||
|
||||
|
||||
def _node_pending_drain_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
client = opts.get("client")
|
||||
context = opts.get("context")
|
||||
if params is not None and not isinstance(params, dict):
|
||||
_bad(respond, "invalid node.pending.drain params")
|
||||
return
|
||||
node_id = _resolve_client_node_id(client)
|
||||
if not node_id:
|
||||
_bad(respond, "node.pending.drain requires a connected device identity")
|
||||
return
|
||||
max_items = params.get("maxItems") if isinstance(params, dict) else None
|
||||
if max_items is not None and not isinstance(max_items, (int, float)):
|
||||
_bad(respond, "invalid node.pending.drain params")
|
||||
return
|
||||
drained = _drain_work(context, node_id, int(max_items) if isinstance(max_items, (int, float)) else None)
|
||||
_ok(respond, {"nodeId": node_id, **drained})
|
||||
|
||||
|
||||
def _node_pending_enqueue_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid node.pending.enqueue params")
|
||||
return
|
||||
node_id = _norm_str(params.get("nodeId"))
|
||||
work_type = _norm_str(params.get("type"))
|
||||
if not node_id or not work_type:
|
||||
_bad(respond, "invalid node.pending.enqueue params")
|
||||
return
|
||||
item = {
|
||||
"nodeId": node_id,
|
||||
"type": work_type,
|
||||
"priority": _norm_str(params.get("priority")) or "normal",
|
||||
"expiresInMs": params.get("expiresInMs"),
|
||||
}
|
||||
try:
|
||||
queued = _enqueue_work(context, item)
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
|
||||
wake_triggered = False
|
||||
if params.get("wake", True) is not False:
|
||||
wake_hook = context.get("wake_node_pending") if isinstance(context, dict) else None
|
||||
if callable(wake_hook):
|
||||
try:
|
||||
wake_triggered = bool(wake_hook({"nodeId": node_id, "reason": "node.pending", "requestId": queued.get("item", {}).get("id")}))
|
||||
except Exception:
|
||||
wake_triggered = False
|
||||
else:
|
||||
wake_triggered = True
|
||||
_ok(respond, {"queued": queued, "wakeTriggered": wake_triggered})
|
||||
|
||||
|
||||
node_pending_handlers: GatewayRequestHandlers = {
|
||||
"node.pending.drain": _node_pending_drain_handler,
|
||||
"node.pending.enqueue": _node_pending_enqueue_handler,
|
||||
}
|
||||
|
||||
86
interfaces/gateway/server_methods/push.py
Normal file
86
interfaces/gateway/server_methods/push.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _push_test_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params")
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid push.test params")
|
||||
return
|
||||
|
||||
node_id = _norm_str(params.get("nodeId")) or ""
|
||||
if not node_id:
|
||||
_bad(respond, "nodeId required")
|
||||
return
|
||||
|
||||
title = _norm_str(params.get("title")) or "Oclaw"
|
||||
body = _norm_str(params.get("body")) or f"Push test for node {node_id}"
|
||||
environment = _norm_str(params.get("environment"))
|
||||
|
||||
# Expected context hook for runtime implementation.
|
||||
sender = context.get("send_push_test") if isinstance(context, dict) else None
|
||||
if not callable(sender):
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"ok": True,
|
||||
"nodeId": node_id,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"environment": environment,
|
||||
"transport": "staging",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
result = sender(
|
||||
{
|
||||
"nodeId": node_id,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"environment": environment,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
|
||||
if not result:
|
||||
_bad(respond, f"node {node_id} has no APNs registration (connect iOS node first)")
|
||||
return
|
||||
_ok(respond, result if isinstance(result, dict) else {"ok": True})
|
||||
|
||||
|
||||
push_handlers: GatewayRequestHandlers = {
|
||||
"push.test": _push_test_handler,
|
||||
}
|
||||
|
||||
15
interfaces/gateway/server_methods/record_shared.py
Normal file
15
interfaces/gateway/server_methods/record_shared.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def as_record(value: Any) -> dict[str, Any] | None:
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def normalize_trimmed_string(value: Any) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
trimmed = value.strip()
|
||||
return trimmed or None
|
||||
|
||||
53
interfaces/gateway/server_methods/restart_request.py
Normal file
53
interfaces/gateway/server_methods/restart_request.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _normalize_optional_string(value: Any) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
s = value.strip()
|
||||
return s or None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RestartDeliveryContext:
|
||||
channel: str | None = None
|
||||
to: str | None = None
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
def _parse_restart_delivery_context(params: Any) -> tuple[RestartDeliveryContext | None, str | None]:
|
||||
raw = params.get("deliveryContext") if isinstance(params, dict) else None
|
||||
if not isinstance(raw, dict):
|
||||
return (None, None)
|
||||
channel = _normalize_optional_string(raw.get("channel"))
|
||||
to = _normalize_optional_string(raw.get("to"))
|
||||
account_id = _normalize_optional_string(raw.get("accountId"))
|
||||
ctx = RestartDeliveryContext(channel=channel, to=to, account_id=account_id)
|
||||
normalized_ctx = ctx if (channel or to or account_id) else None
|
||||
thread_id_raw = raw.get("threadId")
|
||||
if isinstance(thread_id_raw, (int, float)) and thread_id_raw == thread_id_raw:
|
||||
thread_id = str(int(thread_id_raw))
|
||||
else:
|
||||
thread_id = _normalize_optional_string(thread_id_raw)
|
||||
return (normalized_ctx, thread_id)
|
||||
|
||||
|
||||
def parse_restart_request_params(params: Any) -> dict[str, Any]:
|
||||
session_key = _normalize_optional_string(params.get("sessionKey") if isinstance(params, dict) else None)
|
||||
delivery_context, thread_id = _parse_restart_delivery_context(params)
|
||||
note = _normalize_optional_string(params.get("note") if isinstance(params, dict) else None)
|
||||
restart_delay_raw = params.get("restartDelayMs") if isinstance(params, dict) else None
|
||||
restart_delay_ms = None
|
||||
if isinstance(restart_delay_raw, (int, float)) and restart_delay_raw == restart_delay_raw:
|
||||
restart_delay_ms = max(0, int(restart_delay_raw))
|
||||
return {
|
||||
"sessionKey": session_key,
|
||||
"deliveryContext": delivery_context.__dict__ if delivery_context else None,
|
||||
"threadId": thread_id,
|
||||
"note": note,
|
||||
"restartDelayMs": restart_delay_ms,
|
||||
}
|
||||
|
||||
266
interfaces/gateway/server_methods/send.py
Normal file
266
interfaces/gateway/server_methods/send.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .telegram_send_normalize import normalize_transport_target_for_channel
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str, *, meta: dict[str, Any] | None = None) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), meta or None)
|
||||
|
||||
|
||||
def _ok(respond, payload: dict[str, Any] | None = None, *, meta: dict[str, Any] | None = None) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload or {"ok": True}, None, meta or None)
|
||||
|
||||
|
||||
def _normalize_optional_str(value: Any) -> str | None:
|
||||
if isinstance(value, str):
|
||||
v = value.strip()
|
||||
return v or None
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_channel(value: Any) -> str | None:
|
||||
ch = _normalize_optional_str(value)
|
||||
if not ch:
|
||||
return None
|
||||
lower = ch.lower()
|
||||
# TS rejects webchat as internal-only for these endpoints.
|
||||
if lower == "webchat":
|
||||
return None
|
||||
return lower
|
||||
|
||||
|
||||
def _dedupe_get(context: Any, key: str) -> dict[str, Any] | None:
|
||||
if not isinstance(context, dict):
|
||||
return None
|
||||
dedupe = context.get("dedupe")
|
||||
if isinstance(dedupe, dict):
|
||||
cached = dedupe.get(key)
|
||||
return cached if isinstance(cached, dict) else None
|
||||
return None
|
||||
|
||||
|
||||
def _dedupe_set_success(context: Any, key: str, payload: Any) -> None:
|
||||
if not isinstance(context, dict):
|
||||
return
|
||||
dedupe = context.get("dedupe")
|
||||
if isinstance(dedupe, dict):
|
||||
dedupe[key] = {"ok": True, "payload": payload, "error": None}
|
||||
|
||||
|
||||
def _dedupe_set_failure(context: Any, key: str, error: Any) -> None:
|
||||
if not isinstance(context, dict):
|
||||
return
|
||||
dedupe = context.get("dedupe")
|
||||
if isinstance(dedupe, dict):
|
||||
dedupe[key] = {"ok": False, "payload": None, "error": error}
|
||||
|
||||
|
||||
def _run_maybe_await(value: Any) -> Any:
|
||||
"""Run coroutine results in a sync handler.
|
||||
|
||||
Gateway handlers in this repo are synchronous today, but some context hooks
|
||||
may be authored as async. We support both by executing coroutine results
|
||||
when no event loop is running; otherwise we raise to avoid deadlocks.
|
||||
"""
|
||||
if asyncio.iscoroutine(value):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
if loop is not None and loop.is_running():
|
||||
raise RuntimeError("async hook used from sync handler while event loop is running")
|
||||
return asyncio.run(value)
|
||||
return value
|
||||
|
||||
|
||||
def _message_action_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client")
|
||||
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid message.action params")
|
||||
return
|
||||
|
||||
idem = _normalize_optional_str(params.get("idempotencyKey"))
|
||||
if not idem:
|
||||
_bad(respond, "invalid message.action params: idempotencyKey required")
|
||||
return
|
||||
|
||||
dedupe_key = f"message.action:{idem}"
|
||||
cached = _dedupe_get(context, dedupe_key)
|
||||
if cached:
|
||||
if callable(respond):
|
||||
respond(bool(cached.get("ok")), cached.get("payload"), cached.get("error"), {"cached": True})
|
||||
return
|
||||
|
||||
channel = _normalize_channel(params.get("channel"))
|
||||
if not channel:
|
||||
_bad(
|
||||
respond,
|
||||
"unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.",
|
||||
)
|
||||
return
|
||||
|
||||
action = _normalize_optional_str(params.get("action"))
|
||||
if not action:
|
||||
_bad(respond, "invalid message.action params: action required")
|
||||
return
|
||||
|
||||
action_params = params.get("params")
|
||||
if not isinstance(action_params, dict):
|
||||
_bad(respond, "invalid message.action params: params must be object")
|
||||
return
|
||||
|
||||
# Authorization: we only trust `senderIsOwner` if the caller is already admin-scoped.
|
||||
sender_is_owner_wire = params.get("senderIsOwner") is True
|
||||
caller_scopes = []
|
||||
if isinstance(client, dict):
|
||||
connect = client.get("connect")
|
||||
if isinstance(connect, dict) and isinstance(connect.get("scopes"), list):
|
||||
caller_scopes = [x for x in connect.get("scopes") if isinstance(x, str)]
|
||||
caller_is_full_operator = "operator.admin" in caller_scopes
|
||||
sender_is_owner = bool(caller_is_full_operator and sender_is_owner_wire)
|
||||
|
||||
dispatch = context.get("dispatch_message_action") if isinstance(context, dict) else None
|
||||
if not callable(dispatch):
|
||||
payload = {"channel": channel, "action": action, "handled": False}
|
||||
_dedupe_set_success(context, dedupe_key, payload)
|
||||
_ok(respond, payload, meta={"channel": channel})
|
||||
return
|
||||
|
||||
try:
|
||||
handled = _run_maybe_await(
|
||||
dispatch(
|
||||
{
|
||||
"channel": channel,
|
||||
"action": action,
|
||||
"params": action_params,
|
||||
"senderIsOwner": sender_is_owner,
|
||||
"raw": params,
|
||||
}
|
||||
)
|
||||
)
|
||||
payload = handled if isinstance(handled, dict) else {"handled": bool(handled)}
|
||||
_dedupe_set_success(context, dedupe_key, payload)
|
||||
_ok(respond, payload, meta={"channel": channel})
|
||||
except Exception as exc:
|
||||
err = error_shape("UNAVAILABLE", str(exc))
|
||||
_dedupe_set_failure(context, dedupe_key, err)
|
||||
_unavailable(respond, str(exc), meta={"channel": channel})
|
||||
|
||||
|
||||
def _send_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid send params")
|
||||
return
|
||||
|
||||
idem = _normalize_optional_str(params.get("idempotencyKey"))
|
||||
if not idem:
|
||||
_bad(respond, "invalid send params: idempotencyKey required")
|
||||
return
|
||||
|
||||
dedupe_key = f"send:{idem}"
|
||||
cached = _dedupe_get(context, dedupe_key)
|
||||
if cached:
|
||||
if callable(respond):
|
||||
respond(bool(cached.get("ok")), cached.get("payload"), cached.get("error"), {"cached": True})
|
||||
return
|
||||
|
||||
to = _normalize_optional_str(params.get("to")) or ""
|
||||
if not to:
|
||||
_bad(respond, "invalid send params: to required")
|
||||
return
|
||||
|
||||
message = _normalize_optional_str(params.get("message")) or ""
|
||||
media_url = _normalize_optional_str(params.get("mediaUrl"))
|
||||
media_urls_raw = params.get("mediaUrls")
|
||||
media_urls: list[str] = []
|
||||
if isinstance(media_urls_raw, list):
|
||||
for entry in media_urls_raw:
|
||||
v = _normalize_optional_str(entry)
|
||||
if v:
|
||||
media_urls.append(v)
|
||||
|
||||
if not message and not media_url and not media_urls:
|
||||
_bad(respond, "invalid send params: text or media is required")
|
||||
return
|
||||
|
||||
channel = _normalize_channel(params.get("channel")) or "auto"
|
||||
if channel == "auto":
|
||||
# If caller doesn't specify a channel, allow context to choose a default deliverable channel.
|
||||
choose = context.get("resolve_default_channel") if isinstance(context, dict) else None
|
||||
if callable(choose):
|
||||
try:
|
||||
chosen = choose()
|
||||
channel = _normalize_channel(chosen) or "auto"
|
||||
except Exception:
|
||||
channel = "auto"
|
||||
if channel == "auto":
|
||||
channel = "unknown"
|
||||
|
||||
to, channel_extra = normalize_transport_target_for_channel(channel=channel, to=to, params=params)
|
||||
|
||||
deliver = context.get("deliver_outbound") if isinstance(context, dict) else None
|
||||
if not callable(deliver):
|
||||
payload = {
|
||||
"runId": idem,
|
||||
"channel": channel,
|
||||
"to": to,
|
||||
"messageId": f"msg_{idem}",
|
||||
**channel_extra,
|
||||
}
|
||||
_dedupe_set_success(context, dedupe_key, payload)
|
||||
_ok(respond, payload, meta={"channel": channel})
|
||||
return
|
||||
|
||||
try:
|
||||
result = _run_maybe_await(
|
||||
deliver(
|
||||
{
|
||||
"runId": idem,
|
||||
"channel": channel,
|
||||
"to": to,
|
||||
"message": message or None,
|
||||
"mediaUrl": media_url,
|
||||
"mediaUrls": media_urls,
|
||||
**channel_extra,
|
||||
"raw": params,
|
||||
}
|
||||
)
|
||||
)
|
||||
payload = result if isinstance(result, dict) else {"ok": True}
|
||||
if "runId" not in payload:
|
||||
payload["runId"] = idem
|
||||
if "channel" not in payload:
|
||||
payload["channel"] = channel
|
||||
_dedupe_set_success(context, dedupe_key, payload)
|
||||
_ok(respond, payload, meta={"channel": channel})
|
||||
except Exception as exc:
|
||||
err = error_shape("UNAVAILABLE", str(exc))
|
||||
_dedupe_set_failure(context, dedupe_key, err)
|
||||
_unavailable(respond, str(exc), meta={"channel": channel})
|
||||
|
||||
|
||||
send_handlers: GatewayRequestHandlers = {
|
||||
"message.action": _message_action_handler,
|
||||
"send": _send_handler,
|
||||
}
|
||||
|
||||
315
interfaces/gateway/server_methods/sessions.py
Normal file
315
interfaces/gateway/server_methods/sessions.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.agents.agent_scope import resolve_default_agent_id
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .telegram_send_normalize import normalize_transport_target_for_channel
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: dict[str, Any] | None = None) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload or {"ok": True}, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _normalize_session_key(value: Any) -> str | None:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _extract_session_key(params: Any) -> str | None:
|
||||
if not isinstance(params, dict):
|
||||
return None
|
||||
return _normalize_session_key(params.get("sessionKey")) or _normalize_session_key(params.get("key"))
|
||||
|
||||
|
||||
def _require_session_key(params: Any, respond) -> str | None:
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "params must be object")
|
||||
return None
|
||||
key = _extract_session_key(params)
|
||||
if not key:
|
||||
_bad(respond, "sessionKey (or key) is required")
|
||||
return None
|
||||
return key
|
||||
|
||||
|
||||
def _context_subscribers(context: Any, name: str) -> set[str]:
|
||||
if not isinstance(context, dict):
|
||||
return set()
|
||||
value = context.get(name)
|
||||
if isinstance(value, set):
|
||||
return value
|
||||
created: set[str] = set()
|
||||
context[name] = created
|
||||
return created
|
||||
|
||||
|
||||
def _sessions_list_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
sessions = []
|
||||
if isinstance(context, dict):
|
||||
list_fn = context.get("list_sessions")
|
||||
if callable(list_fn):
|
||||
try:
|
||||
rows = list_fn()
|
||||
if isinstance(rows, list):
|
||||
sessions = [x for x in rows if isinstance(x, dict)]
|
||||
except Exception:
|
||||
sessions = []
|
||||
_ok(respond, {"sessions": sessions, "total": len(sessions)})
|
||||
|
||||
|
||||
def _sessions_subscribe_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
client = opts.get("client") or {}
|
||||
context = opts.get("context")
|
||||
conn_id = client.get("conn_id") if isinstance(client, dict) else None
|
||||
if isinstance(conn_id, str) and conn_id.strip():
|
||||
_context_subscribers(context, "session_event_subscribers").add(conn_id.strip())
|
||||
_ok(respond, {"subscribed": True, "connId": conn_id})
|
||||
|
||||
|
||||
def _sessions_unsubscribe_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
client = opts.get("client") or {}
|
||||
context = opts.get("context")
|
||||
conn_id = client.get("conn_id") if isinstance(client, dict) else None
|
||||
if isinstance(conn_id, str) and conn_id.strip():
|
||||
_context_subscribers(context, "session_event_subscribers").discard(conn_id.strip())
|
||||
_ok(respond, {"subscribed": False, "connId": conn_id})
|
||||
|
||||
|
||||
def _sessions_messages_subscribe_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client") or {}
|
||||
key = _require_session_key(params, respond)
|
||||
if not key:
|
||||
return
|
||||
conn_id = client.get("conn_id") if isinstance(client, dict) else None
|
||||
if isinstance(conn_id, str) and conn_id.strip() and isinstance(context, dict):
|
||||
message_subscribers = context.get("session_message_subscribers")
|
||||
if not isinstance(message_subscribers, dict):
|
||||
message_subscribers = {}
|
||||
context["session_message_subscribers"] = message_subscribers
|
||||
bucket = message_subscribers.get(key)
|
||||
if not isinstance(bucket, set):
|
||||
bucket = set()
|
||||
message_subscribers[key] = bucket
|
||||
bucket.add(conn_id.strip())
|
||||
_ok(respond, {"sessionKey": key, "messagesSubscribed": True, "connId": conn_id})
|
||||
|
||||
|
||||
def _sessions_messages_unsubscribe_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client") or {}
|
||||
key = _require_session_key(params, respond)
|
||||
if not key:
|
||||
return
|
||||
conn_id = client.get("conn_id") if isinstance(client, dict) else None
|
||||
if isinstance(conn_id, str) and conn_id.strip() and isinstance(context, dict):
|
||||
message_subscribers = context.get("session_message_subscribers")
|
||||
if isinstance(message_subscribers, dict):
|
||||
bucket = message_subscribers.get(key)
|
||||
if isinstance(bucket, set):
|
||||
bucket.discard(conn_id.strip())
|
||||
_ok(respond, {"sessionKey": key, "messagesSubscribed": False, "connId": conn_id})
|
||||
|
||||
|
||||
def _sessions_preview_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
_ok(respond, {"preview": []})
|
||||
|
||||
|
||||
def _sessions_resolve_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
key = _require_session_key(params, respond)
|
||||
if not key:
|
||||
return
|
||||
_ok(respond, {"sessionKey": key, "resolved": True})
|
||||
|
||||
|
||||
def _sessions_compaction_list_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
_ok(respond, {"checkpoints": []})
|
||||
|
||||
|
||||
def _sessions_compaction_get_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
_ok(respond, {"checkpoint": None})
|
||||
|
||||
|
||||
def _sessions_create_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
key = _extract_session_key(params) or "main"
|
||||
agent_id = ""
|
||||
if isinstance(params, dict):
|
||||
agent_id = str(params.get("agentId") or "").strip()
|
||||
cfg = params.get("config") if isinstance(params.get("config"), dict) else {}
|
||||
if not agent_id and isinstance(cfg, dict) and cfg:
|
||||
agent_id = resolve_default_agent_id(cfg)
|
||||
if agent_id and ":" not in key:
|
||||
key = f"{agent_id}:{key}"
|
||||
session = {"sessionKey": key}
|
||||
if isinstance(context, dict):
|
||||
create_fn = context.get("create_session")
|
||||
if callable(create_fn):
|
||||
try:
|
||||
created = create_fn(key, params if isinstance(params, dict) else {})
|
||||
if isinstance(created, dict):
|
||||
session = created
|
||||
key = _normalize_session_key(created.get("sessionKey")) or key
|
||||
except Exception:
|
||||
pass
|
||||
_ok(respond, {"sessionKey": key, "created": True, "session": session})
|
||||
|
||||
|
||||
def _sessions_compaction_branch_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
_ok(respond, {"branched": True})
|
||||
|
||||
|
||||
def _sessions_compaction_restore_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
_ok(respond, {"restored": True})
|
||||
|
||||
|
||||
def _sessions_send_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
key = _require_session_key(params, respond)
|
||||
if not key:
|
||||
return
|
||||
message = params.get("message") if isinstance(params, dict) else None
|
||||
if not isinstance(message, str) or not message.strip():
|
||||
_bad(respond, "message is required")
|
||||
return
|
||||
payload: dict[str, Any] = {"sent": True, "sessionKey": key, "message": message.strip()}
|
||||
if isinstance(params, dict):
|
||||
channel = params.get("channel")
|
||||
to = params.get("to")
|
||||
if isinstance(channel, str) and channel.strip().lower() == "telegram" and isinstance(to, str) and to.strip():
|
||||
normalized_to, extra = normalize_transport_target_for_channel(
|
||||
channel="telegram",
|
||||
to=to.strip(),
|
||||
params=params,
|
||||
)
|
||||
payload.update({"channel": "telegram", "to": normalized_to, **extra})
|
||||
if isinstance(context, dict):
|
||||
send_fn = context.get("enqueue_session_send")
|
||||
if callable(send_fn):
|
||||
try:
|
||||
out = send_fn(key, message.strip(), dict(params or {}))
|
||||
if isinstance(out, dict):
|
||||
payload.update(out)
|
||||
except Exception as exc:
|
||||
_bad(respond, f"sessions.send failed: {exc}")
|
||||
return
|
||||
_ok(respond, payload)
|
||||
|
||||
|
||||
def _sessions_steer_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
_ok(respond, {"steered": True})
|
||||
|
||||
|
||||
def _sessions_abort_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
key = _require_session_key(params, respond)
|
||||
if not key:
|
||||
return
|
||||
_ok(respond, {"aborted": True, "sessionKey": key})
|
||||
|
||||
|
||||
def _sessions_patch_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
key = _require_session_key(params, respond)
|
||||
if not key:
|
||||
return
|
||||
_ok(respond, {"patched": True, "sessionKey": key})
|
||||
|
||||
|
||||
def _sessions_reset_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
key = _require_session_key(params, respond)
|
||||
if not key:
|
||||
return
|
||||
_ok(respond, {"reset": True, "sessionKey": key})
|
||||
|
||||
|
||||
def _sessions_delete_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
key = _require_session_key(params, respond)
|
||||
if not key:
|
||||
return
|
||||
_ok(respond, {"deleted": True, "sessionKey": key})
|
||||
|
||||
|
||||
def _sessions_get_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
key = _require_session_key(params, respond)
|
||||
if not key:
|
||||
return
|
||||
session: dict[str, Any] = {}
|
||||
if isinstance(context, dict):
|
||||
get_fn = context.get("get_session")
|
||||
if callable(get_fn):
|
||||
try:
|
||||
loaded = get_fn(key)
|
||||
if isinstance(loaded, dict):
|
||||
session = loaded
|
||||
except Exception:
|
||||
session = {}
|
||||
_ok(respond, {"sessionKey": key, "session": session})
|
||||
|
||||
|
||||
def _sessions_compact_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
_ok(respond, {"compacted": True})
|
||||
|
||||
|
||||
sessions_handlers: GatewayRequestHandlers = {
|
||||
"sessions.list": _sessions_list_handler,
|
||||
"sessions.subscribe": _sessions_subscribe_handler,
|
||||
"sessions.unsubscribe": _sessions_unsubscribe_handler,
|
||||
"sessions.messages.subscribe": _sessions_messages_subscribe_handler,
|
||||
"sessions.messages.unsubscribe": _sessions_messages_unsubscribe_handler,
|
||||
"sessions.preview": _sessions_preview_handler,
|
||||
"sessions.resolve": _sessions_resolve_handler,
|
||||
"sessions.compaction.list": _sessions_compaction_list_handler,
|
||||
"sessions.compaction.get": _sessions_compaction_get_handler,
|
||||
"sessions.create": _sessions_create_handler,
|
||||
"sessions.compaction.branch": _sessions_compaction_branch_handler,
|
||||
"sessions.compaction.restore": _sessions_compaction_restore_handler,
|
||||
"sessions.send": _sessions_send_handler,
|
||||
"sessions.steer": _sessions_steer_handler,
|
||||
"sessions.abort": _sessions_abort_handler,
|
||||
"sessions.patch": _sessions_patch_handler,
|
||||
"sessions.reset": _sessions_reset_handler,
|
||||
"sessions.delete": _sessions_delete_handler,
|
||||
"sessions.get": _sessions_get_handler,
|
||||
"sessions.compact": _sessions_compact_handler,
|
||||
}
|
||||
47
interfaces/gateway/server_methods/shared_types.py
Normal file
47
interfaces/gateway/server_methods/shared_types.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypedDict
|
||||
|
||||
|
||||
class ErrorShape(TypedDict, total=False):
|
||||
code: str
|
||||
message: str
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
class GatewayClient(TypedDict, total=False):
|
||||
connect: dict[str, Any]
|
||||
conn_id: str
|
||||
client_ip: str
|
||||
canvas_host_url: str
|
||||
canvas_capability: str
|
||||
canvas_capability_expires_at_ms: int
|
||||
internal: dict[str, Any]
|
||||
|
||||
|
||||
RespondFn = Callable[[bool, Any | None, ErrorShape | None, dict[str, Any] | None], None]
|
||||
|
||||
|
||||
class GatewayRequestContext(TypedDict, total=False):
|
||||
deps: Any
|
||||
cron: Any
|
||||
cron_store_path: str
|
||||
get_health_cache: Callable[[], Any | None]
|
||||
refresh_health_snapshot: Callable[..., Any]
|
||||
log_health: Any
|
||||
log_gateway: Any
|
||||
unavailable_gateway_methods: set[str]
|
||||
|
||||
|
||||
class GatewayRequestHandlerOptions(TypedDict, total=False):
|
||||
req: dict[str, Any]
|
||||
params: dict[str, Any]
|
||||
client: GatewayClient | None
|
||||
is_webchat_connect: Callable[[dict[str, Any] | None], bool]
|
||||
respond: RespondFn
|
||||
context: GatewayRequestContext
|
||||
|
||||
|
||||
GatewayRequestHandler = Callable[[GatewayRequestHandlerOptions], Any]
|
||||
GatewayRequestHandlers = dict[str, GatewayRequestHandler]
|
||||
273
interfaces/gateway/server_methods/skills.py
Normal file
273
interfaces/gateway/server_methods/skills.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.skill_installer import (
|
||||
create_skill_from_template,
|
||||
install_skill_from_local_dir,
|
||||
install_skill_from_registry_archive,
|
||||
list_skills_with_status,
|
||||
set_skill_enabled,
|
||||
)
|
||||
from oclaw.runtime.skills import discover_workspace_skill_manifests, load_skill_manifest
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _normalize_optional_str(value: Any) -> str | None:
|
||||
if isinstance(value, str):
|
||||
v = value.strip()
|
||||
return v or None
|
||||
return None
|
||||
|
||||
|
||||
def _get_store(context: Any) -> Any | None:
|
||||
if isinstance(context, dict) and context.get("store") is not None:
|
||||
return context.get("store")
|
||||
return None
|
||||
|
||||
|
||||
def _skills_status_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
store = _get_store(context)
|
||||
if store is None:
|
||||
_unavailable(respond, "skills.status requires context.store")
|
||||
return
|
||||
_ok(respond, {"skills": list_skills_with_status(store=store)})
|
||||
|
||||
|
||||
def _skills_bins_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
bins: set[str] = set()
|
||||
for m in discover_workspace_skill_manifests():
|
||||
oc = dict(m.metadata_oclaw or {})
|
||||
req = oc.get("requires")
|
||||
if isinstance(req, dict):
|
||||
for k in ("bins", "anyBins"):
|
||||
raw = req.get(k)
|
||||
if isinstance(raw, list):
|
||||
for it in raw:
|
||||
v = _normalize_optional_str(it)
|
||||
if v:
|
||||
bins.add(v)
|
||||
for spec in m.install:
|
||||
payload = dict(spec.payload or {})
|
||||
raw_bins = payload.get("bins")
|
||||
if isinstance(raw_bins, list):
|
||||
for it in raw_bins:
|
||||
v = _normalize_optional_str(it)
|
||||
if v:
|
||||
bins.add(v)
|
||||
_ok(respond, {"bins": sorted(bins, key=lambda x: x.lower())})
|
||||
|
||||
|
||||
def _skills_search_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid skills.search params")
|
||||
return
|
||||
query = _normalize_optional_str(params.get("query")) or ""
|
||||
limit = params.get("limit")
|
||||
limit_n = int(limit) if isinstance(limit, int) and limit > 0 else 20
|
||||
hook = context.get("search_clawhub_skills") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
results = hook({"query": query, "limit": limit_n})
|
||||
_ok(respond, {"results": results if isinstance(results, list) else []})
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in discover_workspace_skill_manifests():
|
||||
hay = f"{m.name}\n{m.description}\n{m.body}".lower()
|
||||
if query.lower() in hay:
|
||||
out.append({"slug": m.name, "name": m.name, "description": m.description, "source": "local"})
|
||||
if len(out) >= limit_n:
|
||||
break
|
||||
_ok(respond, {"results": out})
|
||||
|
||||
|
||||
def _skills_detail_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid skills.detail params")
|
||||
return
|
||||
slug = _normalize_optional_str(params.get("slug"))
|
||||
if not slug:
|
||||
_bad(respond, "invalid skills.detail params: slug required")
|
||||
return
|
||||
hook = context.get("fetch_clawhub_skill_detail") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
detail = hook({"slug": slug})
|
||||
_ok(respond, detail if isinstance(detail, dict) else {"slug": slug})
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
manifest = load_skill_manifest(slug)
|
||||
if manifest is None:
|
||||
for m in discover_workspace_skill_manifests():
|
||||
if m.name == slug:
|
||||
manifest = m
|
||||
break
|
||||
if manifest is None:
|
||||
_bad(respond, f"unknown skill slug: {slug}")
|
||||
return
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"slug": manifest.name,
|
||||
"name": manifest.name,
|
||||
"description": manifest.description,
|
||||
"skillDir": manifest.skill_dir,
|
||||
"skillFile": manifest.skill_file,
|
||||
"metadata": {"oclaw": dict(manifest.metadata_oclaw)},
|
||||
"body": manifest.body,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _skills_install_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
store = _get_store(context)
|
||||
if store is None:
|
||||
_unavailable(respond, "skills.install requires context.store")
|
||||
return
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid skills.install params")
|
||||
return
|
||||
source = _normalize_optional_str(params.get("source")) or "local"
|
||||
if source == "clawhub":
|
||||
hook = context.get("install_skill_from_clawhub") if isinstance(context, dict) else None
|
||||
if not callable(hook):
|
||||
_unavailable(respond, "clawhub install is not configured")
|
||||
return
|
||||
try:
|
||||
result = hook(dict(params))
|
||||
_ok(respond, result)
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
|
||||
overwrite = bool(params.get("force"))
|
||||
archive_url = _normalize_optional_str(params.get("archiveUrl"))
|
||||
source_dir = _normalize_optional_str(params.get("sourceDir"))
|
||||
if archive_url:
|
||||
res = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
|
||||
_ok(respond, {"ok": res.ok, "result": res.__dict__})
|
||||
return
|
||||
if source_dir:
|
||||
res = install_skill_from_local_dir(store=store, source_dir=source_dir, overwrite=overwrite)
|
||||
_ok(respond, {"ok": res.ok, "result": res.__dict__})
|
||||
return
|
||||
|
||||
name = _normalize_optional_str(params.get("name"))
|
||||
if not name:
|
||||
_bad(respond, "invalid skills.install params: name required (or archiveUrl/sourceDir)")
|
||||
return
|
||||
description = _normalize_optional_str(params.get("description")) or f"{name} skill"
|
||||
body = _normalize_optional_str(params.get("body")) or ""
|
||||
md = params.get("metadata_oclaw")
|
||||
md = dict(md) if isinstance(md, dict) else {}
|
||||
res = create_skill_from_template(
|
||||
store=store,
|
||||
name=name,
|
||||
description=description,
|
||||
body_markdown=body,
|
||||
metadata_oclaw=md,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
_ok(respond, {"ok": res.ok, "result": res.__dict__})
|
||||
|
||||
|
||||
def _skills_update_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
store = _get_store(context)
|
||||
if store is None:
|
||||
_unavailable(respond, "skills.update requires context.store")
|
||||
return
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid skills.update params")
|
||||
return
|
||||
source = _normalize_optional_str(params.get("source"))
|
||||
if source == "clawhub":
|
||||
hook = context.get("update_skills_from_clawhub") if isinstance(context, dict) else None
|
||||
if not callable(hook):
|
||||
_unavailable(respond, "clawhub update is not configured")
|
||||
return
|
||||
slug = _normalize_optional_str(params.get("slug"))
|
||||
all_flag = bool(params.get("all"))
|
||||
if not slug and not all_flag:
|
||||
_bad(respond, 'clawhub skills.update requires "slug" or "all"')
|
||||
return
|
||||
if slug and all_flag:
|
||||
_bad(respond, 'clawhub skills.update accepts either "slug" or "all", not both')
|
||||
return
|
||||
try:
|
||||
result = hook(dict(params))
|
||||
_ok(respond, result)
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
|
||||
skill_key = _normalize_optional_str(params.get("skillKey"))
|
||||
if not skill_key:
|
||||
_bad(respond, "invalid skills.update params: skillKey required")
|
||||
return
|
||||
if isinstance(params.get("enabled"), bool):
|
||||
set_skill_enabled(store=store, skill_name=skill_key, enabled=bool(params["enabled"]))
|
||||
|
||||
api_key = params.get("apiKey")
|
||||
if isinstance(api_key, str):
|
||||
trimmed = api_key.strip()
|
||||
store.set_setting(f"SKILL_API_KEY:{skill_key}", trimmed)
|
||||
env = params.get("env")
|
||||
if isinstance(env, dict):
|
||||
clean: dict[str, str] = {}
|
||||
for k, v in env.items():
|
||||
kk = _normalize_optional_str(k)
|
||||
vv = _normalize_optional_str(v)
|
||||
if not kk:
|
||||
continue
|
||||
if vv is None:
|
||||
continue
|
||||
clean[kk] = vv
|
||||
store.set_setting(f"SKILL_ENV:{skill_key}", json.dumps(clean, ensure_ascii=False))
|
||||
|
||||
_ok(respond, {"ok": True, "skillKey": skill_key})
|
||||
|
||||
|
||||
skills_handlers: GatewayRequestHandlers = {
|
||||
"skills.status": _skills_status_handler,
|
||||
"skills.bins": _skills_bins_handler,
|
||||
"skills.search": _skills_search_handler,
|
||||
"skills.detail": _skills_detail_handler,
|
||||
"skills.install": _skills_install_handler,
|
||||
"skills.update": _skills_update_handler,
|
||||
}
|
||||
243
interfaces/gateway/server_methods/system.py
Normal file
243
interfaces/gateway/server_methods/system.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _normalize_optional_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _read_string_value(v: Any) -> str | None:
|
||||
return _normalize_optional_str(v)
|
||||
|
||||
|
||||
def _normalize_lowercase_string_or_empty(v: Any) -> str:
|
||||
return str(v or "").strip().lower()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PresenceUpdate:
|
||||
key: str
|
||||
next: dict[str, Any]
|
||||
changed_keys: list[str]
|
||||
|
||||
|
||||
def _presence_store(context: Any) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
store = context.get("_system_presence_store")
|
||||
if isinstance(store, dict):
|
||||
return store
|
||||
created: dict[str, dict[str, Any]] = {}
|
||||
context["_system_presence_store"] = created
|
||||
return created
|
||||
|
||||
|
||||
def _list_system_presence(context: Any) -> list[dict[str, Any]]:
|
||||
hook = context.get("list_system_presence") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook()
|
||||
return out if isinstance(out, list) else []
|
||||
store = _presence_store(context)
|
||||
return [dict(v) for _, v in sorted(store.items(), key=lambda kv: kv[0])]
|
||||
|
||||
|
||||
def _update_system_presence(context: Any, payload: dict[str, Any]) -> _PresenceUpdate:
|
||||
hook = context.get("update_system_presence") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
raw = hook(payload)
|
||||
if isinstance(raw, dict):
|
||||
key = str(raw.get("key") or payload.get("deviceId") or "unknown")
|
||||
nxt = raw.get("next")
|
||||
nxt = dict(nxt) if isinstance(nxt, dict) else dict(payload)
|
||||
changed = raw.get("changedKeys")
|
||||
changed = list(changed) if isinstance(changed, list) else []
|
||||
return _PresenceUpdate(key=key, next=nxt, changed_keys=[str(x) for x in changed if str(x)])
|
||||
|
||||
store = _presence_store(context)
|
||||
key = str(payload.get("deviceId") or payload.get("instanceId") or payload.get("host") or "unknown").strip() or "unknown"
|
||||
prev = dict(store.get(key) or {})
|
||||
nxt = {**prev, **{k: v for k, v in payload.items() if v is not None}}
|
||||
changed = [k for k in nxt.keys() if prev.get(k) != nxt.get(k)]
|
||||
store[key] = dict(nxt)
|
||||
return _PresenceUpdate(key=key, next=nxt, changed_keys=changed)
|
||||
|
||||
|
||||
def _resolve_main_session_key(context: Any) -> str:
|
||||
hook = context.get("resolve_main_session_key") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
v = hook()
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return "main"
|
||||
|
||||
|
||||
def _gateway_identity_get_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
hook = context.get("load_or_create_device_identity") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
ident = hook()
|
||||
if isinstance(ident, dict):
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"deviceId": str(ident.get("deviceId") or ""),
|
||||
"publicKey": ident.get("publicKey"),
|
||||
},
|
||||
)
|
||||
return
|
||||
# Staging fallback: stable-but-non-cryptographic identity.
|
||||
_ok(respond, {"deviceId": "dev", "publicKey": "publicKey"})
|
||||
|
||||
|
||||
def _last_heartbeat_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
hook = context.get("get_last_heartbeat_event") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
_ok(respond, hook())
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
_ok(respond, None)
|
||||
|
||||
|
||||
def _set_heartbeats_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params") or {}
|
||||
context = opts.get("context")
|
||||
enabled = params.get("enabled") if isinstance(params, dict) else None
|
||||
if not isinstance(enabled, bool):
|
||||
_bad(respond, "invalid set-heartbeats params: enabled (boolean) required")
|
||||
return
|
||||
hook = context.get("set_heartbeats_enabled") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
hook(enabled)
|
||||
except Exception:
|
||||
pass
|
||||
_ok(respond, {"ok": True, "enabled": enabled})
|
||||
|
||||
|
||||
def _system_presence_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
_ok(respond, _list_system_presence(context))
|
||||
|
||||
|
||||
def _system_event_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params") or {}
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid system-event params")
|
||||
return
|
||||
text = _normalize_optional_str(params.get("text")) or ""
|
||||
if not text:
|
||||
_bad(respond, "text required")
|
||||
return
|
||||
|
||||
session_key = _resolve_main_session_key(context)
|
||||
presence_payload = {
|
||||
"text": text,
|
||||
"deviceId": _read_string_value(params.get("deviceId")),
|
||||
"instanceId": _read_string_value(params.get("instanceId")),
|
||||
"host": _read_string_value(params.get("host")),
|
||||
"ip": _read_string_value(params.get("ip")),
|
||||
"mode": _read_string_value(params.get("mode")),
|
||||
"version": _read_string_value(params.get("version")),
|
||||
"platform": _read_string_value(params.get("platform")),
|
||||
"deviceFamily": _read_string_value(params.get("deviceFamily")),
|
||||
"modelIdentifier": _read_string_value(params.get("modelIdentifier")),
|
||||
"reason": _read_string_value(params.get("reason")),
|
||||
}
|
||||
last_input_seconds = params.get("lastInputSeconds")
|
||||
if isinstance(last_input_seconds, (int, float)) and float(last_input_seconds) == float(last_input_seconds):
|
||||
presence_payload["lastInputSeconds"] = float(last_input_seconds)
|
||||
roles = params.get("roles")
|
||||
scopes = params.get("scopes")
|
||||
tags = params.get("tags")
|
||||
if isinstance(roles, list) and all(isinstance(x, str) for x in roles):
|
||||
presence_payload["roles"] = roles
|
||||
if isinstance(scopes, list) and all(isinstance(x, str) for x in scopes):
|
||||
presence_payload["scopes"] = scopes
|
||||
if isinstance(tags, list) and all(isinstance(x, str) for x in tags):
|
||||
presence_payload["tags"] = tags
|
||||
|
||||
upd = _update_system_presence(context, presence_payload)
|
||||
|
||||
enqueue = context.get("enqueue_system_event") if isinstance(context, dict) else None
|
||||
if not callable(enqueue):
|
||||
# no-op fallback
|
||||
enqueue = lambda *_args, **_kwargs: None # noqa: E731
|
||||
|
||||
is_node_presence_line = text.startswith("Node:")
|
||||
if is_node_presence_line:
|
||||
changed = set(upd.changed_keys)
|
||||
reason_value = upd.next.get("reason") or presence_payload.get("reason")
|
||||
normalized_reason = _normalize_lowercase_string_or_empty(reason_value)
|
||||
ignore_reason = normalized_reason.startswith("periodic") or normalized_reason == "heartbeat"
|
||||
host_changed = "host" in changed
|
||||
ip_changed = "ip" in changed
|
||||
version_changed = "version" in changed
|
||||
mode_changed = "mode" in changed
|
||||
reason_changed = ("reason" in changed) and (not ignore_reason)
|
||||
has_changes = host_changed or ip_changed or version_changed or mode_changed or reason_changed
|
||||
if has_changes:
|
||||
parts: list[str] = []
|
||||
if host_changed or ip_changed:
|
||||
host_label = _normalize_optional_str(upd.next.get("host")) or "Unknown"
|
||||
ip_label = _normalize_optional_str(upd.next.get("ip"))
|
||||
parts.append(f"Node: {host_label}{f' ({ip_label})' if ip_label else ''}")
|
||||
if version_changed:
|
||||
parts.append(f"app {_normalize_optional_str(upd.next.get('version')) or 'unknown'}")
|
||||
if mode_changed:
|
||||
parts.append(f"mode {_normalize_optional_str(upd.next.get('mode')) or 'unknown'}")
|
||||
if reason_changed:
|
||||
parts.append(f"reason {_normalize_optional_str(reason_value) or 'event'}")
|
||||
delta_text = " · ".join([p for p in parts if p])
|
||||
if delta_text:
|
||||
enqueue(delta_text, {"sessionKey": session_key, "contextKey": upd.key})
|
||||
else:
|
||||
enqueue(text, {"sessionKey": session_key})
|
||||
|
||||
broadcast = context.get("broadcast_presence_snapshot") if isinstance(context, dict) else None
|
||||
if callable(broadcast):
|
||||
try:
|
||||
broadcast()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_ok(respond, {"ok": True})
|
||||
|
||||
|
||||
system_handlers: GatewayRequestHandlers = {
|
||||
"gateway.identity.get": _gateway_identity_get_handler,
|
||||
"last-heartbeat": _last_heartbeat_handler,
|
||||
"set-heartbeats": _set_heartbeats_handler,
|
||||
"system-presence": _system_presence_handler,
|
||||
"system-event": _system_event_handler,
|
||||
}
|
||||
|
||||
227
interfaces/gateway/server_methods/talk.py
Normal file
227
interfaces/gateway/server_methods/talk.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
TALK_SECRETS_SCOPE = "talk.secrets"
|
||||
ADMIN_SCOPE = "operator.admin"
|
||||
|
||||
|
||||
def _ok(respond, payload: Any, meta: dict[str, Any] | None = None) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, meta or None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str, *, details: dict[str, Any] | None = None) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message, {"details": details} if details else None), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _caller_scopes(client: Any) -> list[str]:
|
||||
if not isinstance(client, dict):
|
||||
return []
|
||||
connect = client.get("connect")
|
||||
if not isinstance(connect, dict):
|
||||
return []
|
||||
scopes = connect.get("scopes")
|
||||
if isinstance(scopes, list):
|
||||
return [x for x in scopes if isinstance(x, str)]
|
||||
return []
|
||||
|
||||
|
||||
def _can_read_talk_secrets(client: Any) -> bool:
|
||||
scopes = set(_caller_scopes(client))
|
||||
return (ADMIN_SCOPE in scopes) or (TALK_SECRETS_SCOPE in scopes)
|
||||
|
||||
|
||||
def _resolve_speed(params: dict[str, Any]) -> float | None:
|
||||
speed = params.get("speed")
|
||||
if isinstance(speed, (int, float)):
|
||||
return float(speed)
|
||||
rate_wpm = params.get("rateWpm")
|
||||
if not isinstance(rate_wpm, (int, float)) or rate_wpm <= 0:
|
||||
return None
|
||||
resolved = float(rate_wpm) / 175.0
|
||||
if resolved <= 0.5 or resolved >= 2.0:
|
||||
return None
|
||||
return resolved
|
||||
|
||||
|
||||
def _infer_mime_type(output_format: str | None, file_extension: str | None) -> str | None:
|
||||
of = (output_format or "").strip().lower()
|
||||
ext = (file_extension or "").strip().lower()
|
||||
if of == "mp3" or of.startswith("mp3_") or of.endswith("-mp3") or ext == ".mp3":
|
||||
return "audio/mpeg"
|
||||
if of == "opus" or of.startswith("opus_") or ext in {".opus", ".ogg"}:
|
||||
return "audio/ogg"
|
||||
if of.endswith("-wav") or ext == ".wav":
|
||||
return "audio/wav"
|
||||
if of.endswith("-webm") or ext == ".webm":
|
||||
return "audio/webm"
|
||||
return None
|
||||
|
||||
|
||||
def _talk_config_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client")
|
||||
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid talk.config params")
|
||||
return
|
||||
include_secrets = bool(params.get("includeSecrets"))
|
||||
if include_secrets and not _can_read_talk_secrets(client):
|
||||
_bad(respond, f"missing scope: {TALK_SECRETS_SCOPE}")
|
||||
return
|
||||
|
||||
hook = context.get("read_talk_config") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
cfg = hook({"includeSecrets": include_secrets})
|
||||
_ok(respond, {"config": cfg if isinstance(cfg, dict) else {}})
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
_ok(respond, {"config": {}})
|
||||
|
||||
|
||||
def _talk_speak_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid talk.speak params")
|
||||
return
|
||||
text = _norm_str(params.get("text")) or ""
|
||||
if not text:
|
||||
_bad(respond, "talk.speak requires text")
|
||||
return
|
||||
|
||||
if params.get("speed") is None and params.get("rateWpm") is not None and _resolve_speed(params) is None:
|
||||
_bad(respond, "invalid talk.speak params: rateWpm must resolve to speed between 0.5 and 2.0")
|
||||
return
|
||||
|
||||
hook = context.get("talk_synthesize") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
out = hook(
|
||||
{
|
||||
"text": text,
|
||||
"voiceId": _norm_str(params.get("voiceId")),
|
||||
"speed": _resolve_speed(params),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc), details={"reason": "synthesis_failed", "fallbackEligible": False})
|
||||
return
|
||||
if not isinstance(out, dict):
|
||||
_unavailable(respond, "talk synthesis failed", details={"reason": "synthesis_failed", "fallbackEligible": False})
|
||||
return
|
||||
if not out.get("success") or not out.get("audio"):
|
||||
_unavailable(
|
||||
respond,
|
||||
str(out.get("error") or "talk synthesis failed"),
|
||||
details={"reason": "synthesis_failed", "fallbackEligible": False},
|
||||
)
|
||||
return
|
||||
audio = out.get("audio")
|
||||
if isinstance(audio, str):
|
||||
audio_b64 = audio
|
||||
elif isinstance(audio, (bytes, bytearray, memoryview)):
|
||||
audio_b64 = base64.b64encode(bytes(audio)).decode("ascii")
|
||||
else:
|
||||
_unavailable(respond, "talk synthesis returned invalid audio", details={"reason": "invalid_audio_result", "fallbackEligible": False})
|
||||
return
|
||||
provider = _norm_str(out.get("provider")) or "talk"
|
||||
if not provider:
|
||||
_unavailable(respond, "talk synthesis returned empty provider", details={"reason": "invalid_audio_result", "fallbackEligible": False})
|
||||
return
|
||||
output_format = _norm_str(out.get("outputFormat"))
|
||||
file_ext = _norm_str(out.get("fileExtension"))
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"audioBase64": audio_b64,
|
||||
"provider": provider,
|
||||
"outputFormat": output_format,
|
||||
"voiceCompatible": bool(out.get("voiceCompatible", True)),
|
||||
"mimeType": _infer_mime_type(output_format, file_ext),
|
||||
"fileExtension": file_ext,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# Fallback: return a tiny silent WAV header-ish payload (not real audio, but non-empty).
|
||||
audio_b64 = base64.b64encode(b"RIFF").decode("ascii")
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"audioBase64": audio_b64,
|
||||
"provider": "staging",
|
||||
"outputFormat": "wav",
|
||||
"voiceCompatible": True,
|
||||
"mimeType": "audio/wav",
|
||||
"fileExtension": ".wav",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _talk_mode_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client")
|
||||
is_webchat_connect = opts.get("is_webchat_connect")
|
||||
|
||||
if client and callable(is_webchat_connect) and is_webchat_connect((client or {}).get("connect")):
|
||||
has_mobile = context.get("hasConnectedMobileNode") if isinstance(context, dict) else None
|
||||
try:
|
||||
ok_mobile = bool(has_mobile()) if callable(has_mobile) else True
|
||||
except Exception:
|
||||
ok_mobile = True
|
||||
if not ok_mobile:
|
||||
_unavailable(respond, "talk disabled: no connected iOS/Android nodes")
|
||||
return
|
||||
|
||||
if not isinstance(params, dict) or not isinstance(params.get("enabled"), bool):
|
||||
_bad(respond, "invalid talk.mode params")
|
||||
return
|
||||
payload = {
|
||||
"enabled": bool(params.get("enabled")),
|
||||
"phase": _norm_str(params.get("phase")),
|
||||
"ts": int(time.time() * 1000),
|
||||
}
|
||||
broadcast = context.get("broadcast") if isinstance(context, dict) else None
|
||||
if callable(broadcast):
|
||||
try:
|
||||
broadcast("talk.mode", payload, {"dropIfSlow": True})
|
||||
except Exception:
|
||||
pass
|
||||
_ok(respond, payload)
|
||||
|
||||
|
||||
talk_handlers: GatewayRequestHandlers = {
|
||||
"talk.config": _talk_config_handler,
|
||||
"talk.speak": _talk_speak_handler,
|
||||
"talk.mode": _talk_mode_handler,
|
||||
}
|
||||
|
||||
41
interfaces/gateway/server_methods/telegram_send_normalize.py
Normal file
41
interfaces/gateway/server_methods/telegram_send_normalize.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.extensions.telegram import (
|
||||
normalize_telegram_messaging_target,
|
||||
parse_telegram_reply_to_message_id,
|
||||
parse_telegram_target,
|
||||
parse_telegram_thread_id,
|
||||
)
|
||||
|
||||
|
||||
def normalize_transport_target_for_channel(
|
||||
*,
|
||||
channel: str,
|
||||
to: str,
|
||||
params: dict[str, Any],
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""Normalize channel transport target payload for outbound paths."""
|
||||
extra: dict[str, Any] = {}
|
||||
if channel != "telegram":
|
||||
return to, extra
|
||||
|
||||
normalized_target = normalize_telegram_messaging_target(to)
|
||||
parsed_target = parse_telegram_target(normalized_target or to)
|
||||
normalized_to = normalized_target or f"telegram:{parsed_target.chat_id}".lower()
|
||||
reply_to_id = parse_telegram_reply_to_message_id(params.get("replyToId"))
|
||||
thread_id = parse_telegram_thread_id(
|
||||
params.get("threadId") if params.get("threadId") is not None else parsed_target.message_thread_id
|
||||
)
|
||||
|
||||
extra = {
|
||||
"target": {
|
||||
"chatId": parsed_target.chat_id,
|
||||
"chatType": parsed_target.chat_type,
|
||||
**({"messageThreadId": parsed_target.message_thread_id} if parsed_target.message_thread_id is not None else {}),
|
||||
},
|
||||
**({"threadId": thread_id} if thread_id is not None else {}),
|
||||
**({"replyToMessageId": reply_to_id} if reply_to_id is not None else {}),
|
||||
}
|
||||
return normalized_to, extra
|
||||
117
interfaces/gateway/server_methods/tools_catalog.py
Normal file
117
interfaces/gateway/server_methods/tools_catalog.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _tools_catalog_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid tools.catalog params")
|
||||
return
|
||||
|
||||
include_plugins = params.get("includePlugins")
|
||||
include_plugins = bool(include_plugins) if include_plugins is not None else True
|
||||
|
||||
agent_id = _norm_str(params.get("agentId")) or "main"
|
||||
# Optional agent validation hook.
|
||||
known_agents = None
|
||||
list_agents = context.get("list_agent_ids") if isinstance(context, dict) else None
|
||||
if callable(list_agents):
|
||||
try:
|
||||
known_agents = list_agents()
|
||||
except Exception:
|
||||
known_agents = None
|
||||
if isinstance(known_agents, list) and agent_id and agent_id not in [x for x in known_agents if isinstance(x, str)]:
|
||||
_bad(respond, f'unknown agent id "{agent_id}"')
|
||||
return
|
||||
|
||||
hook = context.get("build_tools_catalog") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
out = hook({"agentId": agent_id, "includePlugins": include_plugins})
|
||||
_ok(respond, out if isinstance(out, dict) else {"agentId": agent_id, "profiles": [], "groups": []})
|
||||
return
|
||||
except Exception as exc:
|
||||
_bad(respond, str(exc))
|
||||
return
|
||||
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"agentId": agent_id,
|
||||
"profiles": [
|
||||
{"id": "minimal", "label": "Minimal"},
|
||||
{"id": "coding", "label": "Coding"},
|
||||
{"id": "messaging", "label": "Messaging"},
|
||||
{"id": "full", "label": "Full"},
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "core:default",
|
||||
"label": "Core",
|
||||
"source": "core",
|
||||
"tools": [
|
||||
{
|
||||
"id": "tool.echo",
|
||||
"label": "Echo",
|
||||
"description": "Staging tool entry",
|
||||
"source": "core",
|
||||
"defaultProfiles": ["minimal", "full"],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
if not include_plugins
|
||||
else [
|
||||
{
|
||||
"id": "core:default",
|
||||
"label": "Core",
|
||||
"source": "core",
|
||||
"tools": [
|
||||
{
|
||||
"id": "tool.echo",
|
||||
"label": "Echo",
|
||||
"description": "Staging tool entry",
|
||||
"source": "core",
|
||||
"defaultProfiles": ["minimal", "full"],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "plugin:example",
|
||||
"label": "example",
|
||||
"source": "plugin",
|
||||
"pluginId": "example",
|
||||
"tools": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
tools_catalog_handlers: GatewayRequestHandlers = {
|
||||
"tools.catalog": _tools_catalog_handler,
|
||||
}
|
||||
|
||||
92
interfaces/gateway/server_methods/tools_effective.py
Normal file
92
interfaces/gateway/server_methods/tools_effective.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _is_admin(client: Any) -> bool:
|
||||
if not isinstance(client, dict):
|
||||
return False
|
||||
connect = client.get("connect")
|
||||
if not isinstance(connect, dict):
|
||||
return False
|
||||
scopes = connect.get("scopes")
|
||||
return isinstance(scopes, list) and "operator.admin" in [x for x in scopes if isinstance(x, str)]
|
||||
|
||||
|
||||
def _tools_effective_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params") or {}
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid tools.effective params")
|
||||
return
|
||||
session_key = _norm_str(params.get("sessionKey"))
|
||||
if not session_key:
|
||||
_bad(respond, "invalid tools.effective params: sessionKey required")
|
||||
return
|
||||
requested_agent_id = _norm_str(params.get("agentId"))
|
||||
if requested_agent_id:
|
||||
list_agents = context.get("list_agent_ids") if isinstance(context, dict) else None
|
||||
if callable(list_agents):
|
||||
try:
|
||||
known = list_agents()
|
||||
except Exception:
|
||||
known = None
|
||||
if isinstance(known, list) and requested_agent_id not in [x for x in known if isinstance(x, str)]:
|
||||
_bad(respond, f'unknown agent id "{requested_agent_id}"')
|
||||
return
|
||||
|
||||
hook = context.get("resolve_effective_tool_inventory") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
out = hook(
|
||||
{
|
||||
"sessionKey": session_key,
|
||||
"agentId": requested_agent_id,
|
||||
"senderIsOwner": _is_admin(client),
|
||||
}
|
||||
)
|
||||
_ok(respond, out if isinstance(out, dict) else {"tools": []})
|
||||
return
|
||||
except Exception as exc:
|
||||
_bad(respond, str(exc))
|
||||
return
|
||||
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"sessionKey": session_key,
|
||||
"agentId": requested_agent_id or "main",
|
||||
"tools": [],
|
||||
"policy": {
|
||||
"senderIsOwner": _is_admin(client),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
tools_effective_handlers: GatewayRequestHandlers = {
|
||||
"tools.effective": _tools_effective_handler,
|
||||
}
|
||||
|
||||
183
interfaces/gateway/server_methods/tts.py
Normal file
183
interfaces/gateway/server_methods/tts.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _tts_status_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
hook = context.get("tts_status") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
out = hook()
|
||||
_ok(respond, out if isinstance(out, dict) else {})
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"enabled": False,
|
||||
"auto": True,
|
||||
"provider": None,
|
||||
"fallbackProvider": None,
|
||||
"fallbackProviders": [],
|
||||
"prefsPath": None,
|
||||
"providerStates": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _tts_enable_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
hook = context.get("set_tts_enabled") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
hook(True)
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
_ok(respond, {"enabled": True})
|
||||
|
||||
|
||||
def _tts_disable_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
hook = context.get("set_tts_enabled") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
hook(False)
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
_ok(respond, {"enabled": False})
|
||||
|
||||
|
||||
def _tts_convert_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params")
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid tts.convert params")
|
||||
return
|
||||
text = _norm_str(params.get("text")) or ""
|
||||
if not text:
|
||||
_bad(respond, "tts.convert requires text")
|
||||
return
|
||||
hook = context.get("text_to_speech") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
out = hook(
|
||||
{
|
||||
"text": text,
|
||||
"channel": _norm_str(params.get("channel")),
|
||||
"provider": _norm_str(params.get("provider")),
|
||||
"modelId": _norm_str(params.get("modelId")),
|
||||
"voiceId": _norm_str(params.get("voiceId")),
|
||||
}
|
||||
)
|
||||
if isinstance(out, dict):
|
||||
success = bool(out.get("success", False))
|
||||
if success and out.get("audioPath"):
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"audioPath": out.get("audioPath"),
|
||||
"provider": out.get("provider"),
|
||||
"outputFormat": out.get("outputFormat"),
|
||||
"voiceCompatible": out.get("voiceCompatible"),
|
||||
},
|
||||
)
|
||||
return
|
||||
_unavailable(respond, str(out.get("error") or "TTS conversion failed"))
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
# fallback success shape
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"audioPath": "/tmp/tts.wav",
|
||||
"provider": _norm_str(params.get("provider")) or "staging",
|
||||
"outputFormat": "wav",
|
||||
"voiceCompatible": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _tts_set_provider_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params")
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid tts.setProvider params")
|
||||
return
|
||||
provider = _norm_str(params.get("provider")) or ""
|
||||
if not provider:
|
||||
_bad(respond, "Invalid provider. Use a registered TTS provider id.")
|
||||
return
|
||||
hook = context.get("set_tts_provider") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
ok = hook(provider)
|
||||
if ok is False:
|
||||
_bad(respond, "Invalid provider. Use a registered TTS provider id.")
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
_ok(respond, {"provider": provider})
|
||||
|
||||
|
||||
def _tts_providers_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
hook = context.get("list_tts_providers") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
out = hook()
|
||||
if isinstance(out, dict):
|
||||
_ok(respond, out)
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
_ok(respond, {"providers": [], "active": None})
|
||||
|
||||
|
||||
tts_handlers: GatewayRequestHandlers = {
|
||||
"tts.status": _tts_status_handler,
|
||||
"tts.enable": _tts_enable_handler,
|
||||
"tts.disable": _tts_disable_handler,
|
||||
"tts.convert": _tts_convert_handler,
|
||||
"tts.setProvider": _tts_set_provider_handler,
|
||||
"tts.providers": _tts_providers_handler,
|
||||
}
|
||||
|
||||
2
interfaces/gateway/server_methods/types.py
Normal file
2
interfaces/gateway/server_methods/types.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .shared_types import *
|
||||
|
||||
148
interfaces/gateway/server_methods/update.py
Normal file
148
interfaces/gateway/server_methods/update.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _as_int(v: Any) -> int | None:
|
||||
if isinstance(v, bool):
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return int(v)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_restart_request_params(params: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"sessionKey": _norm_str(params.get("sessionKey")),
|
||||
"deliveryContext": params.get("deliveryContext") if isinstance(params.get("deliveryContext"), dict) else None,
|
||||
"threadId": _norm_str(params.get("threadId")),
|
||||
"note": _norm_str(params.get("note")),
|
||||
"restartDelayMs": max(0, _as_int(params.get("restartDelayMs")) or 0) or None,
|
||||
}
|
||||
|
||||
|
||||
def _update_run_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params")
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
client = opts.get("client")
|
||||
if params is not None and not isinstance(params, dict):
|
||||
_bad(respond, "invalid update.run params")
|
||||
return
|
||||
p = dict(params or {})
|
||||
|
||||
parsed = _parse_restart_request_params(p)
|
||||
timeout_ms_raw = _as_int(p.get("timeoutMs"))
|
||||
timeout_ms = max(1000, timeout_ms_raw) if isinstance(timeout_ms_raw, int) else None
|
||||
|
||||
runner = context.get("run_gateway_update") if isinstance(context, dict) else None
|
||||
if callable(runner):
|
||||
try:
|
||||
result = runner({"timeoutMs": timeout_ms, "params": p})
|
||||
if not isinstance(result, dict):
|
||||
result = {
|
||||
"status": "ok",
|
||||
"mode": "unknown",
|
||||
"steps": [],
|
||||
"durationMs": 0,
|
||||
}
|
||||
except Exception as exc:
|
||||
result = {
|
||||
"status": "error",
|
||||
"mode": "unknown",
|
||||
"reason": str(exc),
|
||||
"steps": [],
|
||||
"durationMs": 0,
|
||||
}
|
||||
else:
|
||||
result = {
|
||||
"status": "ok",
|
||||
"mode": "staging",
|
||||
"steps": [],
|
||||
"durationMs": 0,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"kind": "update",
|
||||
"status": result.get("status"),
|
||||
"ts": int(time.time() * 1000),
|
||||
"sessionKey": parsed["sessionKey"],
|
||||
"deliveryContext": parsed["deliveryContext"],
|
||||
"threadId": parsed["threadId"],
|
||||
"message": parsed["note"],
|
||||
"doctorHint": "Run doctor in non-interactive mode if needed.",
|
||||
"stats": {
|
||||
"mode": result.get("mode"),
|
||||
"root": result.get("root"),
|
||||
"before": result.get("before"),
|
||||
"after": result.get("after"),
|
||||
"steps": result.get("steps") if isinstance(result.get("steps"), list) else [],
|
||||
"reason": result.get("reason"),
|
||||
"durationMs": result.get("durationMs"),
|
||||
},
|
||||
}
|
||||
|
||||
sentinel_path = None
|
||||
write_sentinel = context.get("write_restart_sentinel") if isinstance(context, dict) else None
|
||||
if callable(write_sentinel):
|
||||
try:
|
||||
sentinel_path = write_sentinel(payload)
|
||||
except Exception:
|
||||
sentinel_path = None
|
||||
|
||||
restart = None
|
||||
if result.get("status") == "ok":
|
||||
schedule = context.get("schedule_gateway_restart") if isinstance(context, dict) else None
|
||||
if callable(schedule):
|
||||
actor = {"actor": "unknown", "deviceId": None, "clientIp": None}
|
||||
if isinstance(client, dict):
|
||||
connect = client.get("connect")
|
||||
if isinstance(connect, dict):
|
||||
actor["deviceId"] = connect.get("device", {}).get("id") if isinstance(connect.get("device"), dict) else None
|
||||
actor["clientIp"] = client.get("client_ip")
|
||||
restart = schedule(
|
||||
{
|
||||
"delayMs": parsed["restartDelayMs"],
|
||||
"reason": "update.run",
|
||||
"audit": {"actor": actor.get("actor"), "deviceId": actor.get("deviceId"), "clientIp": actor.get("clientIp"), "changedPaths": []},
|
||||
}
|
||||
)
|
||||
else:
|
||||
restart = {"scheduled": True, "reason": "update.run", "delayMs": parsed["restartDelayMs"]}
|
||||
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"ok": result.get("status") != "error",
|
||||
"result": result,
|
||||
"restart": restart,
|
||||
"sentinel": {"path": sentinel_path, "payload": payload},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
update_handlers: GatewayRequestHandlers = {
|
||||
"update.run": _update_run_handler,
|
||||
}
|
||||
|
||||
268
interfaces/gateway/server_methods/usage.py
Normal file
268
interfaces/gateway/server_methods/usage.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
COST_USAGE_CACHE_TTL_MS = 30_000
|
||||
DAY_MS = 24 * 60 * 60 * 1000
|
||||
_cost_usage_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _ok(respond, payload: Any, meta: dict[str, Any] | None = None) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, meta or None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _parse_date_parts(raw: Any) -> tuple[int, int, int] | None:
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return None
|
||||
try:
|
||||
dt = datetime.strptime(raw.strip(), "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
return dt.year, dt.month, dt.day
|
||||
|
||||
|
||||
def _parse_utc_offset_minutes(raw: Any) -> int | None:
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return None
|
||||
text = raw.strip()
|
||||
if not text.startswith("UTC"):
|
||||
return None
|
||||
sign_part = text[3:4]
|
||||
if sign_part not in {"+", "-"}:
|
||||
return None
|
||||
rest = text[4:]
|
||||
if ":" in rest:
|
||||
hh_s, mm_s = rest.split(":", 1)
|
||||
else:
|
||||
hh_s, mm_s = rest, "0"
|
||||
try:
|
||||
hh = int(hh_s)
|
||||
mm = int(mm_s)
|
||||
except ValueError:
|
||||
return None
|
||||
if hh > 14 or mm < 0 or mm >= 60:
|
||||
return None
|
||||
total = hh * 60 + mm
|
||||
if sign_part == "-":
|
||||
total = -total
|
||||
if total < -12 * 60 or total > 14 * 60:
|
||||
return None
|
||||
return total
|
||||
|
||||
|
||||
def _resolve_date_mode(params: dict[str, Any]) -> dict[str, Any]:
|
||||
mode = params.get("mode")
|
||||
if mode == "gateway":
|
||||
return {"mode": "gateway"}
|
||||
if mode == "specific":
|
||||
offset = _parse_utc_offset_minutes(params.get("utcOffset"))
|
||||
if offset is not None:
|
||||
return {"mode": "specific", "utcOffsetMinutes": offset}
|
||||
return {"mode": "utc"}
|
||||
|
||||
|
||||
def _parse_date_to_ms(raw: Any, interpretation: dict[str, Any]) -> int | None:
|
||||
parts = _parse_date_parts(raw)
|
||||
if not parts:
|
||||
return None
|
||||
y, m, d = parts
|
||||
if interpretation["mode"] == "gateway":
|
||||
return int(datetime(y, m, d).timestamp() * 1000)
|
||||
if interpretation["mode"] == "specific":
|
||||
offset = interpretation["utcOffsetMinutes"]
|
||||
base = datetime(y, m, d, tzinfo=timezone.utc).timestamp() * 1000
|
||||
return int(base - offset * 60 * 1000)
|
||||
return int(datetime(y, m, d, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
|
||||
|
||||
def _today_start_ms(now: datetime, interpretation: dict[str, Any]) -> int:
|
||||
if interpretation["mode"] == "gateway":
|
||||
local = datetime(now.year, now.month, now.day)
|
||||
return int(local.timestamp() * 1000)
|
||||
if interpretation["mode"] == "specific":
|
||||
offset = interpretation["utcOffsetMinutes"]
|
||||
shifted = now + timedelta(minutes=offset)
|
||||
start = datetime(shifted.year, shifted.month, shifted.day, tzinfo=timezone.utc)
|
||||
return int(start.timestamp() * 1000) - offset * 60 * 1000
|
||||
start = datetime(now.year, now.month, now.day, tzinfo=timezone.utc)
|
||||
return int(start.timestamp() * 1000)
|
||||
|
||||
|
||||
def _parse_days(raw: Any) -> int | None:
|
||||
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
|
||||
return int(raw)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
return int(float(raw.strip()))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_range(params: dict[str, Any]) -> dict[str, int]:
|
||||
now = datetime.now(timezone.utc)
|
||||
interpretation = _resolve_date_mode(params)
|
||||
today_start = _today_start_ms(now, interpretation)
|
||||
today_end = today_start + DAY_MS - 1
|
||||
|
||||
start_ms = _parse_date_to_ms(params.get("startDate"), interpretation)
|
||||
end_ms = _parse_date_to_ms(params.get("endDate"), interpretation)
|
||||
if start_ms is not None and end_ms is not None:
|
||||
return {"startMs": start_ms, "endMs": end_ms + DAY_MS - 1}
|
||||
|
||||
days = _parse_days(params.get("days"))
|
||||
if days is not None:
|
||||
clamped = max(1, days)
|
||||
return {"startMs": today_start - (clamped - 1) * DAY_MS, "endMs": today_end}
|
||||
|
||||
return {"startMs": today_start - 29 * DAY_MS, "endMs": today_end}
|
||||
|
||||
|
||||
def _load_cost_usage_summary_cached(start_ms: int, end_ms: int, context: Any) -> tuple[dict[str, Any], bool]:
|
||||
cache_key = f"{start_ms}-{end_ms}"
|
||||
now_ms = int(time.time() * 1000)
|
||||
cached = _cost_usage_cache.get(cache_key)
|
||||
if cached and now_ms - int(cached.get("updatedAt", 0)) < COST_USAGE_CACHE_TTL_MS:
|
||||
return dict(cached.get("summary") or {}), True
|
||||
|
||||
hook = context.get("load_cost_usage_summary") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
summary = hook({"startMs": start_ms, "endMs": end_ms})
|
||||
if not isinstance(summary, dict):
|
||||
summary = {}
|
||||
else:
|
||||
summary = {
|
||||
"startMs": start_ms,
|
||||
"endMs": end_ms,
|
||||
"totals": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"totalTokens": 0,
|
||||
"totalCost": 0,
|
||||
},
|
||||
}
|
||||
_cost_usage_cache[cache_key] = {"summary": summary, "updatedAt": now_ms}
|
||||
return summary, False
|
||||
|
||||
|
||||
def _usage_status_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
hook = context.get("load_provider_usage_summary") if isinstance(context, dict) else None
|
||||
try:
|
||||
summary = hook() if callable(hook) else {"providers": [], "generatedAt": int(time.time() * 1000)}
|
||||
_ok(respond, summary if isinstance(summary, dict) else {})
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
|
||||
|
||||
def _usage_cost_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params") or {}
|
||||
context = opts.get("context")
|
||||
if params is not None and not isinstance(params, dict):
|
||||
_bad(respond, "invalid usage.cost params")
|
||||
return
|
||||
date_range = _parse_date_range(dict(params))
|
||||
try:
|
||||
summary, was_cached = _load_cost_usage_summary_cached(date_range["startMs"], date_range["endMs"], context)
|
||||
_ok(respond, summary, {"cached": True} if was_cached else None)
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
|
||||
|
||||
def _sessions_usage_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params") or {}
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid sessions.usage params")
|
||||
return
|
||||
limit = params.get("limit")
|
||||
limit = int(limit) if isinstance(limit, (int, float)) and not isinstance(limit, bool) else 50
|
||||
limit = max(1, min(limit, 500))
|
||||
specific_key = params.get("key")
|
||||
if specific_key is not None and not isinstance(specific_key, str):
|
||||
_bad(respond, "invalid sessions.usage params")
|
||||
return
|
||||
date_range = _parse_date_range(params)
|
||||
|
||||
hook = context.get("load_sessions_usage") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
try:
|
||||
out = hook(
|
||||
{
|
||||
"startMs": date_range["startMs"],
|
||||
"endMs": date_range["endMs"],
|
||||
"limit": limit,
|
||||
"key": specific_key,
|
||||
"includeContextWeight": bool(params.get("includeContextWeight", False)),
|
||||
}
|
||||
)
|
||||
_ok(respond, out if isinstance(out, dict) else {"sessions": [], "aggregates": {}, "range": date_range})
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
|
||||
payload = {
|
||||
"sessions": ([] if not specific_key else [{"key": specific_key, "sessionId": specific_key, "updatedAt": date_range["endMs"]}])[:limit],
|
||||
"aggregates": {
|
||||
"totals": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"totalTokens": 0,
|
||||
"totalCost": 0,
|
||||
"missingCostEntries": 0,
|
||||
},
|
||||
"messages": {
|
||||
"total": 0,
|
||||
"user": 0,
|
||||
"assistant": 0,
|
||||
"toolCalls": 0,
|
||||
"toolResults": 0,
|
||||
"errors": 0,
|
||||
},
|
||||
"tools": [],
|
||||
"byModel": [],
|
||||
"byProvider": [],
|
||||
"byAgent": [],
|
||||
"byChannel": [],
|
||||
"daily": [],
|
||||
"latency": None,
|
||||
"dailyLatency": [],
|
||||
"modelDaily": [],
|
||||
"aggregateTail": [],
|
||||
},
|
||||
"range": date_range,
|
||||
"limit": limit,
|
||||
}
|
||||
_ok(respond, payload)
|
||||
|
||||
|
||||
usage_handlers: GatewayRequestHandlers = {
|
||||
"usage.status": _usage_status_handler,
|
||||
"usage.cost": _usage_cost_handler,
|
||||
"sessions.usage": _sessions_usage_handler,
|
||||
}
|
||||
|
||||
40
interfaces/gateway/server_methods/validation.py
Normal file
40
interfaces/gateway/server_methods/validation.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def error_shape(code: str, message: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"code": str(code or "UNKNOWN"), "message": str(message or "")}
|
||||
if isinstance(data, dict) and data:
|
||||
out["data"] = data
|
||||
return out
|
||||
|
||||
|
||||
def assert_valid_params(
|
||||
params: Any,
|
||||
validator: Any,
|
||||
method: str,
|
||||
respond: Any,
|
||||
) -> bool:
|
||||
"""Validate request params and respond with a standardized error on failure."""
|
||||
try:
|
||||
ok = bool(validator(params)) if callable(validator) else True
|
||||
except Exception as exc:
|
||||
ok = False
|
||||
err = error_shape("INVALID_REQUEST", f"param validator raised: {type(exc).__name__}")
|
||||
try:
|
||||
respond(False, None, err, None)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
if ok:
|
||||
return True
|
||||
err = error_shape("INVALID_REQUEST", f"invalid params for {str(method or '').strip() or 'unknown'}")
|
||||
try:
|
||||
respond(False, None, err, None)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["assert_valid_params", "error_shape"]
|
||||
88
interfaces/gateway/server_methods/voicewake.py
Normal file
88
interfaces/gateway/server_methods/voicewake.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _normalize_triggers(raw: Any) -> list[str]:
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for x in raw:
|
||||
if not isinstance(x, str):
|
||||
continue
|
||||
s = x.strip()
|
||||
if not s:
|
||||
continue
|
||||
k = s.lower()
|
||||
if k in seen:
|
||||
continue
|
||||
seen.add(k)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def _voicewake_get_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
load_cfg = context.get("load_voicewake_config") if isinstance(context, dict) else None
|
||||
if callable(load_cfg):
|
||||
try:
|
||||
cfg = load_cfg()
|
||||
triggers = cfg.get("triggers") if isinstance(cfg, dict) else None
|
||||
_ok(respond, {"triggers": triggers if isinstance(triggers, list) else []})
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
_ok(respond, {"triggers": []})
|
||||
|
||||
|
||||
def _voicewake_set_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict) or not isinstance(params.get("triggers"), list):
|
||||
_bad(respond, "voicewake.set requires triggers: string[]")
|
||||
return
|
||||
triggers = _normalize_triggers(params.get("triggers"))
|
||||
set_cfg = context.get("set_voicewake_triggers") if isinstance(context, dict) else None
|
||||
if callable(set_cfg):
|
||||
try:
|
||||
cfg = set_cfg(triggers)
|
||||
value = cfg.get("triggers") if isinstance(cfg, dict) else triggers
|
||||
if callable(context.get("broadcastVoiceWakeChanged")):
|
||||
context["broadcastVoiceWakeChanged"](value)
|
||||
_ok(respond, {"triggers": value if isinstance(value, list) else triggers})
|
||||
return
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
return
|
||||
if callable(context.get("broadcastVoiceWakeChanged")):
|
||||
context["broadcastVoiceWakeChanged"](triggers)
|
||||
_ok(respond, {"triggers": triggers})
|
||||
|
||||
|
||||
voicewake_handlers: GatewayRequestHandlers = {
|
||||
"voicewake.get": _voicewake_get_handler,
|
||||
"voicewake.set": _voicewake_set_handler,
|
||||
}
|
||||
|
||||
120
interfaces/gateway/server_methods/web.py
Normal file
120
interfaces/gateway/server_methods/web.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_provider(context: Any) -> dict[str, Any] | None:
|
||||
hook = context.get("resolve_web_login_provider") if isinstance(context, dict) else None
|
||||
if callable(hook):
|
||||
out = hook()
|
||||
if isinstance(out, dict):
|
||||
return out
|
||||
return None
|
||||
|
||||
|
||||
def _web_login_start_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params")
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if params is not None and not isinstance(params, dict):
|
||||
_bad(respond, "invalid web.login.start params")
|
||||
return
|
||||
p = dict(params or {})
|
||||
provider = _resolve_provider(context)
|
||||
if not provider:
|
||||
_bad(respond, "web login provider is not available")
|
||||
return
|
||||
start_fn = provider.get("loginWithQrStart")
|
||||
if not callable(start_fn):
|
||||
_bad(respond, f"web login is not supported by provider {provider.get('id')}")
|
||||
return
|
||||
account_id = _norm_str(p.get("accountId"))
|
||||
try:
|
||||
stop_channel = context.get("stopChannel") if isinstance(context, dict) else None
|
||||
if callable(stop_channel):
|
||||
stop_channel(provider.get("id"), account_id)
|
||||
result = start_fn(
|
||||
{
|
||||
"force": bool(p.get("force")),
|
||||
"timeoutMs": p.get("timeoutMs") if isinstance(p.get("timeoutMs"), (int, float)) else None,
|
||||
"verbose": bool(p.get("verbose")),
|
||||
"accountId": account_id,
|
||||
}
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
result = {}
|
||||
connected = bool(result.get("connected"))
|
||||
if connected:
|
||||
start_channel = context.get("startChannel") if isinstance(context, dict) else None
|
||||
if callable(start_channel):
|
||||
start_channel(provider.get("id"), account_id)
|
||||
_ok(respond, result)
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
|
||||
|
||||
def _web_login_wait_handler(opts: dict[str, Any]) -> None:
|
||||
params = opts.get("params")
|
||||
respond = opts.get("respond")
|
||||
context = opts.get("context")
|
||||
if params is not None and not isinstance(params, dict):
|
||||
_bad(respond, "invalid web.login.wait params")
|
||||
return
|
||||
p = dict(params or {})
|
||||
provider = _resolve_provider(context)
|
||||
if not provider:
|
||||
_bad(respond, "web login provider is not available")
|
||||
return
|
||||
wait_fn = provider.get("loginWithQrWait")
|
||||
if not callable(wait_fn):
|
||||
_bad(respond, f"web login is not supported by provider {provider.get('id')}")
|
||||
return
|
||||
account_id = _norm_str(p.get("accountId"))
|
||||
try:
|
||||
result = wait_fn(
|
||||
{
|
||||
"timeoutMs": p.get("timeoutMs") if isinstance(p.get("timeoutMs"), (int, float)) else None,
|
||||
"accountId": account_id,
|
||||
}
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
result = {}
|
||||
if bool(result.get("connected")):
|
||||
start_channel = context.get("startChannel") if isinstance(context, dict) else None
|
||||
if callable(start_channel):
|
||||
start_channel(provider.get("id"), account_id)
|
||||
_ok(respond, result)
|
||||
except Exception as exc:
|
||||
_unavailable(respond, str(exc))
|
||||
|
||||
|
||||
web_handlers: GatewayRequestHandlers = {
|
||||
"web.login.start": _web_login_start_handler,
|
||||
"web.login.wait": _web_login_wait_handler,
|
||||
}
|
||||
|
||||
170
interfaces/gateway/server_methods/wizard.py
Normal file
170
interfaces/gateway/server_methods/wizard.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
from typing import Any
|
||||
|
||||
from .shared_types import GatewayRequestHandlers
|
||||
from .validation import error_shape
|
||||
|
||||
|
||||
def _ok(respond, payload: Any) -> None:
|
||||
if callable(respond):
|
||||
respond(True, payload, None, None)
|
||||
|
||||
|
||||
def _bad(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
||||
|
||||
|
||||
def _unavailable(respond, message: str) -> None:
|
||||
if callable(respond):
|
||||
respond(False, None, error_shape("UNAVAILABLE", message), None)
|
||||
|
||||
|
||||
def _norm_str(v: Any) -> str | None:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
|
||||
def _sessions(context: Any) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
sessions = context.get("wizardSessions")
|
||||
if isinstance(sessions, dict):
|
||||
return sessions
|
||||
created: dict[str, dict[str, Any]] = {}
|
||||
context["wizardSessions"] = created
|
||||
return created
|
||||
|
||||
|
||||
def _find_running(context: Any) -> str | None:
|
||||
for sid, session in _sessions(context).items():
|
||||
if isinstance(session, dict) and session.get("status") == "running":
|
||||
return sid
|
||||
return None
|
||||
|
||||
|
||||
def _wizard_start_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid wizard.start params")
|
||||
return
|
||||
mode = _norm_str(params.get("mode"))
|
||||
if not mode:
|
||||
_bad(respond, "invalid wizard.start params")
|
||||
return
|
||||
running = _find_running(context)
|
||||
if running:
|
||||
_unavailable(respond, "wizard already running")
|
||||
return
|
||||
session_id = str(uuid4())
|
||||
session = {
|
||||
"status": "running",
|
||||
"error": None,
|
||||
"mode": mode,
|
||||
"workspace": _norm_str(params.get("workspace")),
|
||||
"step": 0,
|
||||
"history": [],
|
||||
}
|
||||
_sessions(context)[session_id] = session
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"sessionId": session_id,
|
||||
"done": False,
|
||||
"step": {"id": "step-1", "kind": "input", "prompt": "Provide first value"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _wizard_next_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid wizard.next params")
|
||||
return
|
||||
session_id = _norm_str(params.get("sessionId"))
|
||||
if not session_id:
|
||||
_bad(respond, "invalid wizard.next params")
|
||||
return
|
||||
session = _sessions(context).get(session_id)
|
||||
if not isinstance(session, dict):
|
||||
_bad(respond, "wizard not found")
|
||||
return
|
||||
answer = params.get("answer")
|
||||
if answer is not None:
|
||||
if session.get("status") != "running":
|
||||
_bad(respond, "wizard not running")
|
||||
return
|
||||
session["history"].append(answer)
|
||||
step = int(session.get("step") or 0) + 1
|
||||
session["step"] = step
|
||||
if step >= 2:
|
||||
session["status"] = "done"
|
||||
_sessions(context).pop(session_id, None)
|
||||
_ok(respond, {"done": True, "result": {"ok": True}})
|
||||
return
|
||||
_ok(
|
||||
respond,
|
||||
{
|
||||
"done": False,
|
||||
"step": {"id": f"step-{step+1}", "kind": "input", "prompt": "Provide next value"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _wizard_cancel_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid wizard.cancel params")
|
||||
return
|
||||
session_id = _norm_str(params.get("sessionId"))
|
||||
if not session_id:
|
||||
_bad(respond, "invalid wizard.cancel params")
|
||||
return
|
||||
session = _sessions(context).get(session_id)
|
||||
if not isinstance(session, dict):
|
||||
_bad(respond, "wizard not found")
|
||||
return
|
||||
session["status"] = "cancelled"
|
||||
_sessions(context).pop(session_id, None)
|
||||
_ok(respond, {"status": "cancelled", "error": None})
|
||||
|
||||
|
||||
def _wizard_status_handler(opts: dict[str, Any]) -> None:
|
||||
respond = opts.get("respond")
|
||||
params = opts.get("params")
|
||||
context = opts.get("context")
|
||||
if not isinstance(params, dict):
|
||||
_bad(respond, "invalid wizard.status params")
|
||||
return
|
||||
session_id = _norm_str(params.get("sessionId"))
|
||||
if not session_id:
|
||||
_bad(respond, "invalid wizard.status params")
|
||||
return
|
||||
session = _sessions(context).get(session_id)
|
||||
if not isinstance(session, dict):
|
||||
_bad(respond, "wizard not found")
|
||||
return
|
||||
status = str(session.get("status") or "unknown")
|
||||
out = {"status": status, "error": session.get("error")}
|
||||
if status != "running":
|
||||
_sessions(context).pop(session_id, None)
|
||||
_ok(respond, out)
|
||||
|
||||
|
||||
wizard_handlers: GatewayRequestHandlers = {
|
||||
"wizard.start": _wizard_start_handler,
|
||||
"wizard.next": _wizard_next_handler,
|
||||
"wizard.cancel": _wizard_cancel_handler,
|
||||
"wizard.status": _wizard_status_handler,
|
||||
}
|
||||
|
||||
137
interfaces/gateway/server_plugins.py
Normal file
137
interfaces/gateway/server_plugins.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from oclaw.platform.config.runtime_paths import runtime_extensions_root
|
||||
from .python_extensions_loader import build_python_extensions_registry
|
||||
|
||||
GatewayHandler = Callable[..., Any]
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayPluginLoadResult:
|
||||
plugin_registry: dict[str, Any]
|
||||
gateway_methods: list[str]
|
||||
|
||||
|
||||
def _apply_plugin_auto_enable(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Python rewrite placeholder for plugin auto-enable policy.
|
||||
|
||||
Keep behavior deterministic: when no runtime policy engine is attached,
|
||||
the incoming config is treated as already normalized.
|
||||
"""
|
||||
return dict(cfg or {})
|
||||
|
||||
|
||||
def _resolve_gateway_startup_plugin_ids(
|
||||
*,
|
||||
config: dict[str, Any],
|
||||
workspace_dir: str,
|
||||
) -> list[str]:
|
||||
ws_root = Path(workspace_dir).resolve()
|
||||
plugins = ((config.get("plugins") or {}).get("enabled") or []) if isinstance(config, dict) else []
|
||||
out = [str(x).strip() for x in plugins if str(x).strip()]
|
||||
slot_cfg = ((config.get("plugins") or {}).get("slots") or {}) if isinstance(config, dict) else {}
|
||||
memory_slot = str(slot_cfg.get("memory") or "").strip()
|
||||
memory_plugin_ids = {"memory-core", "memory-wiki", "memory-lancedb"}
|
||||
if out:
|
||||
if memory_slot:
|
||||
out = [x for x in out if x not in memory_plugin_ids]
|
||||
if memory_slot.lower() != "none":
|
||||
out.append(memory_slot)
|
||||
return out
|
||||
# Auto-discover local plugins when explicit list is absent.
|
||||
roots = [
|
||||
runtime_extensions_root(),
|
||||
ws_root / "oclaw" / "runtime" / "extensions",
|
||||
ws_root / "runtime" / "extensions",
|
||||
]
|
||||
seen: set[str] = set()
|
||||
for root in roots:
|
||||
if not root.exists() or not root.is_dir():
|
||||
continue
|
||||
for child in root.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
pid = str(child.name or "").strip()
|
||||
if not pid or pid.startswith(".") or pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
out.append(pid)
|
||||
if memory_slot:
|
||||
out = [x for x in out if x not in memory_plugin_ids]
|
||||
if memory_slot.lower() != "none":
|
||||
out.append(memory_slot)
|
||||
return out
|
||||
|
||||
|
||||
def _load_oclaw_plugins(
|
||||
*,
|
||||
config: dict[str, Any],
|
||||
workspace_dir: str,
|
||||
only_plugin_ids: list[str],
|
||||
core_gateway_handlers: dict[str, GatewayHandler] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Minimal Python replacement for loadOclawPlugins.
|
||||
|
||||
This function intentionally returns a stable registry shape that matches
|
||||
the gateway startup contract used by this project.
|
||||
"""
|
||||
core_gateway_handlers = dict(core_gateway_handlers or {})
|
||||
registry = build_python_extensions_registry(
|
||||
app_config=dict(config or {}),
|
||||
workspace_dir=workspace_dir,
|
||||
only_plugin_ids=list(only_plugin_ids or []),
|
||||
)
|
||||
registry["gateway_handlers"] = {k: v for k, v in core_gateway_handlers.items()}
|
||||
registry["workspace_dir"] = workspace_dir
|
||||
return registry
|
||||
|
||||
|
||||
def load_gateway_plugins(
|
||||
*,
|
||||
cfg: dict[str, Any],
|
||||
workspace_dir: str,
|
||||
log: dict[str, LogFn],
|
||||
core_gateway_handlers: dict[str, GatewayHandler],
|
||||
base_methods: list[str],
|
||||
plugin_ids: list[str] | None = None,
|
||||
) -> GatewayPluginLoadResult:
|
||||
"""Python rewrite of `gateway/server-plugins.ts::loadGatewayPlugins`.
|
||||
|
||||
Behavior:
|
||||
- Resolve startup plugin ids
|
||||
- Load plugin registry
|
||||
- Merge plugin gateway handlers into gateway methods
|
||||
"""
|
||||
resolved_cfg = _apply_plugin_auto_enable(cfg)
|
||||
chosen_plugin_ids = list(
|
||||
plugin_ids or _resolve_gateway_startup_plugin_ids(config=resolved_cfg, workspace_dir=workspace_dir)
|
||||
)
|
||||
if not chosen_plugin_ids:
|
||||
plugin_registry = {"plugins": [], "gateway_handlers": {}, "http_routes": [], "diagnostics": []}
|
||||
return GatewayPluginLoadResult(plugin_registry=plugin_registry, gateway_methods=list(base_methods))
|
||||
|
||||
plugin_registry = _load_oclaw_plugins(
|
||||
config=resolved_cfg,
|
||||
workspace_dir=workspace_dir,
|
||||
only_plugin_ids=chosen_plugin_ids,
|
||||
core_gateway_handlers=core_gateway_handlers,
|
||||
)
|
||||
plugin_methods = list((plugin_registry.get("gateway_handlers") or {}).keys())
|
||||
merged_methods: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for method in [*base_methods, *plugin_methods]:
|
||||
if method not in seen:
|
||||
seen.add(method)
|
||||
merged_methods.append(method)
|
||||
|
||||
info = log.get("info")
|
||||
if callable(info):
|
||||
info(f"[gateway] loaded {len(chosen_plugin_ids)} plugins for workspace={workspace_dir}")
|
||||
|
||||
return GatewayPluginLoadResult(plugin_registry=plugin_registry, gateway_methods=merged_methods)
|
||||
|
||||
88
interfaces/gateway/server_startup_plugins.py
Normal file
88
interfaces/gateway/server_startup_plugins.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from oclaw.runtime.agents.agent_scope import resolve_agent_workspace_dir, resolve_default_agent_id
|
||||
from oclaw.runtime.agents.subagent_registry import init_subagent_registry
|
||||
|
||||
from .server_plugins import GatewayPluginLoadResult, load_gateway_plugins
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayPluginBootstrapResult:
|
||||
gateway_plugin_config_at_start: dict[str, Any]
|
||||
default_workspace_dir: str
|
||||
deferred_configured_channel_plugin_ids: list[str]
|
||||
startup_plugin_ids: list[str]
|
||||
base_methods: list[str]
|
||||
plugin_registry: dict[str, Any]
|
||||
base_gateway_methods: list[str]
|
||||
|
||||
|
||||
def _resolve_default_workspace_dir(cfg: dict[str, Any]) -> str:
|
||||
default_agent_id = resolve_default_agent_id(cfg)
|
||||
workspace_dir = resolve_agent_workspace_dir(cfg, default_agent_id)
|
||||
return str(workspace_dir or ".").strip() or "."
|
||||
|
||||
|
||||
def _resolve_deferred_configured_channel_plugin_ids(cfg: dict[str, Any]) -> list[str]:
|
||||
channels = (cfg.get("channels") or {}) if isinstance(cfg, dict) else {}
|
||||
deferred = channels.get("deferred_plugins")
|
||||
if not isinstance(deferred, list):
|
||||
return []
|
||||
return [str(x).strip() for x in deferred if str(x).strip()]
|
||||
|
||||
|
||||
def _resolve_gateway_startup_plugin_ids(cfg: dict[str, Any]) -> list[str]:
|
||||
plugins = (cfg.get("plugins") or {}) if isinstance(cfg, dict) else {}
|
||||
enabled = plugins.get("enabled")
|
||||
if not isinstance(enabled, list):
|
||||
return []
|
||||
return [str(x).strip() for x in enabled if str(x).strip()]
|
||||
|
||||
|
||||
def prepare_gateway_plugin_bootstrap(
|
||||
*,
|
||||
cfg_at_start: dict[str, Any],
|
||||
startup_runtime_config: dict[str, Any],
|
||||
minimal_test_gateway: bool,
|
||||
log: dict[str, LogFn],
|
||||
core_gateway_handlers: dict[str, Callable[..., Any]],
|
||||
base_methods: list[str],
|
||||
) -> GatewayPluginBootstrapResult:
|
||||
_ = startup_runtime_config
|
||||
init_subagent_registry()
|
||||
gateway_plugin_config_at_start = dict(cfg_at_start or {})
|
||||
default_workspace_dir = _resolve_default_workspace_dir(gateway_plugin_config_at_start)
|
||||
deferred_configured_channel_plugin_ids = (
|
||||
[] if minimal_test_gateway else _resolve_deferred_configured_channel_plugin_ids(gateway_plugin_config_at_start)
|
||||
)
|
||||
startup_plugin_ids = [] if minimal_test_gateway else _resolve_gateway_startup_plugin_ids(gateway_plugin_config_at_start)
|
||||
|
||||
if minimal_test_gateway:
|
||||
plugin_registry = {"plugins": [], "gateway_handlers": {}, "http_routes": [], "diagnostics": []}
|
||||
base_gateway_methods = list(base_methods)
|
||||
else:
|
||||
loaded: GatewayPluginLoadResult = load_gateway_plugins(
|
||||
cfg=gateway_plugin_config_at_start,
|
||||
workspace_dir=default_workspace_dir,
|
||||
log=log,
|
||||
core_gateway_handlers=core_gateway_handlers,
|
||||
base_methods=base_methods,
|
||||
plugin_ids=startup_plugin_ids,
|
||||
)
|
||||
plugin_registry = loaded.plugin_registry
|
||||
base_gateway_methods = loaded.gateway_methods
|
||||
|
||||
return GatewayPluginBootstrapResult(
|
||||
gateway_plugin_config_at_start=gateway_plugin_config_at_start,
|
||||
default_workspace_dir=default_workspace_dir,
|
||||
deferred_configured_channel_plugin_ids=deferred_configured_channel_plugin_ids,
|
||||
startup_plugin_ids=startup_plugin_ids,
|
||||
base_methods=list(base_methods),
|
||||
plugin_registry=plugin_registry,
|
||||
base_gateway_methods=base_gateway_methods,
|
||||
)
|
||||
|
|
@ -10,18 +10,18 @@ from fastapi import Request
|
|||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from oclaw.application.gateway import process_inbound_payload_usecase
|
||||
from oclaw.runtime.application.gateway import process_inbound_payload_usecase
|
||||
from oclaw.interfaces.gateway.http_adapter import dispatch_gateway_http_method
|
||||
from oclaw.interfaces.ws import ws_gateway_loop
|
||||
from oclaw.admin.routes import admin_static_dir, build_admin_router
|
||||
from oclaw.agents.agent_scope import list_agent_ids, resolve_agent_workspace_dir, resolve_default_agent_id
|
||||
from oclaw.gateway.server_startup_plugins import prepare_gateway_plugin_bootstrap
|
||||
from oclaw.openclaw_runtime.hooks_runtime import (
|
||||
from oclaw.interfaces.admin.routes import admin_static_dir, build_admin_router
|
||||
from oclaw.runtime.agents.agent_scope import list_agent_ids, resolve_agent_workspace_dir, resolve_default_agent_id
|
||||
from oclaw.interfaces.gateway.server_startup_plugins import prepare_gateway_plugin_bootstrap
|
||||
from oclaw.runtime.hooks_runtime import (
|
||||
initialize_hooks_runtime,
|
||||
resolve_runtime_config,
|
||||
trigger_hook_event,
|
||||
)
|
||||
from oclaw.openclaw_runtime.skills import skill_runtime_diagnostics
|
||||
from oclaw.runtime.skills import skill_runtime_diagnostics
|
||||
from oclaw.platform.config.paths import PROJECT_ROOT, db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.interfaces.http.weixin_ilink_api import router as weixin_ilink_router
|
||||
|
|
@ -36,7 +36,7 @@ def _resolve_startup_workspace_dir(cfg: dict[str, Any]) -> str:
|
|||
return ws_text
|
||||
except Exception:
|
||||
pass
|
||||
return str((PROJECT_ROOT / "oclaw" / "workspace-main").resolve())
|
||||
return str((PROJECT_ROOT / "oclaw" / "runtime" / "assets" / "agent_workspaces" / "workspace-main").resolve())
|
||||
|
||||
|
||||
def _resolve_startup_workspace_dirs(cfg: dict[str, Any]) -> list[tuple[str, str]]:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ def _require_ilink_auth(
|
|||
authorization_type: str | None,
|
||||
authorization: str | None,
|
||||
) -> None:
|
||||
# Minimal contract required by openclaw-weixin:
|
||||
# Minimal contract required by oclaw-weixin:
|
||||
# - AuthorizationType: ilink_bot_token
|
||||
# - Authorization: Bearer <token>
|
||||
if (authorization_type or "").strip() != "ilink_bot_token":
|
||||
|
|
|
|||
29
interfaces/ws/protocol_schemas/agent.json
Normal file
29
interfaces/ws/protocol_schemas/agent.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "oclaw://schemas/agent.json",
|
||||
"title": "AgentParams",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["message", "idempotencyKey"],
|
||||
"properties": {
|
||||
"message": { "type": "string", "minLength": 1 },
|
||||
"agentId": { "type": "string", "minLength": 1 },
|
||||
"provider": { "type": "string", "minLength": 1 },
|
||||
"model": { "type": "string", "minLength": 1 },
|
||||
"to": { "type": "string", "minLength": 1 },
|
||||
"replyTo": { "type": "string", "minLength": 1 },
|
||||
"sessionId": { "type": "string", "minLength": 1 },
|
||||
"sessionKey": { "type": "string", "minLength": 1 },
|
||||
"thinking": { "type": "string" },
|
||||
"deliver": { "type": "boolean" },
|
||||
"attachments": { "type": "array", "items": {} },
|
||||
"channel": { "type": "string" },
|
||||
"timeout": { "type": "integer", "minimum": 0 },
|
||||
"extraSystemPrompt": { "type": "string" },
|
||||
"bootstrapContextMode": { "enum": ["full", "lightweight"] },
|
||||
"bootstrapContextRunKind": { "enum": ["default", "heartbeat", "cron"] },
|
||||
"idempotencyKey": { "type": "string", "minLength": 1 },
|
||||
"label": { "type": "string" }
|
||||
}
|
||||
}
|
||||
|
||||
13
interfaces/ws/protocol_schemas/agent_wait.json
Normal file
13
interfaces/ws/protocol_schemas/agent_wait.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "oclaw://schemas/agent_wait.json",
|
||||
"title": "AgentWaitParams",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["runId"],
|
||||
"properties": {
|
||||
"runId": { "type": "string", "minLength": 1 },
|
||||
"timeoutMs": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
|
||||
45
interfaces/ws/protocol_schemas/chat.json
Normal file
45
interfaces/ws/protocol_schemas/chat.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "oclaw://schemas/chat.json",
|
||||
"title": "ChatParams",
|
||||
"$defs": {
|
||||
"NonEmptyString": { "type": "string", "minLength": 1 },
|
||||
"ChatSendParams": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sessionKey", "message", "idempotencyKey"],
|
||||
"properties": {
|
||||
"sessionKey": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"message": { "type": "string" },
|
||||
"thinking": { "type": "string" },
|
||||
"interaction_mode": { "type": "string" },
|
||||
"specialist": { "type": "string" },
|
||||
"relay_share_envelope": { "type": "object" },
|
||||
"acp_parent_run_id": { "type": "string" },
|
||||
"acp_child_run_id": { "type": "string" },
|
||||
"attachments": { "type": "array", "items": {} },
|
||||
"timeoutMs": { "type": "integer", "minimum": 0 },
|
||||
"idempotencyKey": { "$ref": "#/$defs/NonEmptyString" }
|
||||
}
|
||||
},
|
||||
"ChatHistoryParams": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sessionKey"],
|
||||
"properties": {
|
||||
"sessionKey": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"limit": { "type": "integer", "minimum": 1 },
|
||||
"maxChars": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"ChatAbortParams": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sessionKey"],
|
||||
"properties": {
|
||||
"sessionKey": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"runId": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
interfaces/ws/protocol_schemas/connect.json
Normal file
61
interfaces/ws/protocol_schemas/connect.json
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "oclaw://schemas/connect.json",
|
||||
"title": "ConnectParams",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["minProtocol", "maxProtocol", "client"],
|
||||
"properties": {
|
||||
"minProtocol": { "type": "integer", "minimum": 1 },
|
||||
"maxProtocol": { "type": "integer", "minimum": 1 },
|
||||
"client": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "version", "platform", "mode"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"displayName": { "type": "string", "minLength": 1 },
|
||||
"version": { "type": "string", "minLength": 1 },
|
||||
"platform": { "type": "string", "minLength": 1 },
|
||||
"deviceFamily": { "type": "string", "minLength": 1 },
|
||||
"modelIdentifier": { "type": "string", "minLength": 1 },
|
||||
"mode": { "type": "string", "minLength": 1 },
|
||||
"instanceId": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"caps": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||
"commands": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||
"permissions": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "boolean" }
|
||||
},
|
||||
"pathEnv": { "type": "string" },
|
||||
"role": { "type": "string", "minLength": 1 },
|
||||
"scopes": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"token": { "type": "string" },
|
||||
"bootstrapToken": { "type": "string" },
|
||||
"deviceToken": { "type": "string" },
|
||||
"password": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "publicKey", "signature", "signedAt", "nonce"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"publicKey": { "type": "string", "minLength": 1 },
|
||||
"signature": { "type": "string", "minLength": 1 },
|
||||
"signedAt": { "type": "integer", "minimum": 0 },
|
||||
"nonce": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"locale": { "type": "string" },
|
||||
"userAgent": { "type": "string" }
|
||||
}
|
||||
}
|
||||
|
||||
70
interfaces/ws/protocol_schemas/frames.json
Normal file
70
interfaces/ws/protocol_schemas/frames.json
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "oclaw://schemas/frames.json",
|
||||
"title": "GatewayFrame",
|
||||
"type": "object",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/RequestFrame" },
|
||||
{ "$ref": "#/$defs/ResponseFrame" },
|
||||
{ "$ref": "#/$defs/EventFrame" }
|
||||
],
|
||||
"$defs": {
|
||||
"NonEmptyString": { "type": "string", "minLength": 1 },
|
||||
"ErrorShape": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"message": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"details": {},
|
||||
"retryable": { "type": "boolean" },
|
||||
"retryAfterMs": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"RequestFrame": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "id", "method"],
|
||||
"properties": {
|
||||
"type": { "const": "req" },
|
||||
"id": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"method": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"params": {}
|
||||
}
|
||||
},
|
||||
"ResponseFrame": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "id", "ok"],
|
||||
"properties": {
|
||||
"type": { "const": "res" },
|
||||
"id": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"ok": { "type": "boolean" },
|
||||
"payload": {},
|
||||
"error": { "$ref": "#/$defs/ErrorShape" }
|
||||
}
|
||||
},
|
||||
"EventFrame": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "event"],
|
||||
"properties": {
|
||||
"type": { "const": "event" },
|
||||
"event": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"payload": {},
|
||||
"seq": { "type": "integer", "minimum": 0 },
|
||||
"stateVersion": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["presence", "health"],
|
||||
"properties": {
|
||||
"presence": { "type": "integer", "minimum": 0 },
|
||||
"health": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
interfaces/ws/protocol_schemas/hello_ok.json
Normal file
86
interfaces/ws/protocol_schemas/hello_ok.json
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "oclaw://schemas/hello_ok.json",
|
||||
"title": "HelloOk",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "protocol", "server", "features", "snapshot", "policy"],
|
||||
"properties": {
|
||||
"type": { "const": "hello-ok" },
|
||||
"protocol": { "type": "integer", "minimum": 1 },
|
||||
"server": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "connId"],
|
||||
"properties": {
|
||||
"version": { "type": "string", "minLength": 1 },
|
||||
"connId": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["methods", "events"],
|
||||
"properties": {
|
||||
"methods": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||
"events": { "type": "array", "items": { "type": "string", "minLength": 1 } }
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["presence", "health", "stateVersion", "uptimeMs"],
|
||||
"properties": {
|
||||
"presence": { "type": "array", "items": { "type": "object" } },
|
||||
"health": {},
|
||||
"stateVersion": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["presence", "health"],
|
||||
"properties": {
|
||||
"presence": { "type": "integer", "minimum": 0 },
|
||||
"health": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"uptimeMs": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["maxPayload", "maxBufferedBytes", "tickIntervalMs"],
|
||||
"properties": {
|
||||
"maxPayload": { "type": "integer", "minimum": 1 },
|
||||
"maxBufferedBytes": { "type": "integer", "minimum": 1 },
|
||||
"tickIntervalMs": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"canvasHostUrl": { "type": "string", "minLength": 1 },
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["role", "scopes"],
|
||||
"properties": {
|
||||
"deviceToken": { "type": "string", "minLength": 1 },
|
||||
"role": { "type": "string", "minLength": 1 },
|
||||
"scopes": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||
"issuedAtMs": { "type": "integer", "minimum": 0 },
|
||||
"deviceTokens": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["deviceToken", "role", "scopes", "issuedAtMs"],
|
||||
"properties": {
|
||||
"deviceToken": { "type": "string", "minLength": 1 },
|
||||
"role": { "type": "string", "minLength": 1 },
|
||||
"scopes": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||
"issuedAtMs": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
interfaces/ws/protocol_schemas/sessions.json
Normal file
38
interfaces/ws/protocol_schemas/sessions.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "oclaw://schemas/sessions.json",
|
||||
"title": "SessionsParams",
|
||||
"$defs": {
|
||||
"NonEmptyString": { "type": "string", "minLength": 1 },
|
||||
"SessionsListParams": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"limit": { "type": "integer", "minimum": 1 },
|
||||
"search": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"SessionsGetParams": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["key"],
|
||||
"properties": { "key": { "$ref": "#/$defs/NonEmptyString" } }
|
||||
},
|
||||
"SessionsSendParams": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["key", "message"],
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"message": { "type": "string" },
|
||||
"thinking": { "type": "string" },
|
||||
"attachments": { "type": "array", "items": {} },
|
||||
"timeoutMs": { "type": "integer", "minimum": 0 },
|
||||
"idempotencyKey": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"interaction_mode": { "$ref": "#/$defs/NonEmptyString" },
|
||||
"specialist": { "$ref": "#/$defs/NonEmptyString" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ from oclaw.interfaces.ws.runtime_loop import close_ws as close_ws_impl, run_conn
|
|||
from oclaw.interfaces.ws.server_methods_bridge import build_gateway_context, dispatch_via_server_methods
|
||||
from oclaw.interfaces.ws.turn_runner import run_agent_turn_via_bridge
|
||||
from oclaw.interfaces.ws.ws_schema import format_validation_errors, get_ws_schemas, validate_or_errors
|
||||
from oclaw.openclaw_runtime.relay_pointer import validate_relay_share_envelope
|
||||
from oclaw.runtime.relay_pointer import validate_relay_share_envelope
|
||||
|
||||
|
||||
class OclawWsGatewayConnection:
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from oclaw.platform.config.paths import PROJECT_ROOT
|
||||
from oclaw.platform.config.runtime_paths import ws_protocol_schemas_root
|
||||
|
||||
_SCHEMA_DIR = Path(PROJECT_ROOT) / "oclaw" / "openclaw_protocol_schemas"
|
||||
_SCHEMA_DIR = ws_protocol_schemas_root()
|
||||
|
||||
|
||||
def _load_schema(name: str) -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ def build_gateway_context(
|
|||
rid = str(run_id or "").strip()
|
||||
if not rid:
|
||||
return {"status": "error", "runId": rid, "summary": "runId required"}
|
||||
run = store.openclaw_run_get(run_id=rid)
|
||||
run = store.oclaw_run_get(run_id=rid)
|
||||
if not run:
|
||||
return {"status": "pending", "runId": rid, "summary": "run not finished", "pollAfterMs": 250}
|
||||
status = str(run.status or "").strip().lower()
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import threading
|
|||
import uuid
|
||||
from typing import Any, Callable
|
||||
|
||||
from oclaw.agents.factory import build_gateway_executor
|
||||
from oclaw.openclaw_runtime.gateway import OpenClawGateway
|
||||
from oclaw.openclaw_runtime.types import StandardMessage
|
||||
from oclaw.runtime.agents.factory import build_gateway_executor
|
||||
from oclaw.runtime.gateway import OclawGateway
|
||||
from oclaw.runtime.types import StandardMessage
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ async def run_agent_turn_via_bridge(
|
|||
msg_text = str(p.get("message") or "").strip()
|
||||
attachments = list(p.get("attachments") or [])
|
||||
store = SqliteStore(db_path())
|
||||
gw = OpenClawGateway(store=store)
|
||||
gw = OclawGateway(store=store)
|
||||
|
||||
ctx = conn.auth_ctx or {}
|
||||
tenant_id = str(ctx.get("tenant_id") or "")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue