mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 07:13:17 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
|
|
@ -15,6 +15,7 @@ from fastapi import APIRouter, Body, File, Header, HTTPException, Query, UploadF
|
|||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
|
||||
from oclaw.runtime.agents.specialists import SPECIALIST_IDS
|
||||
from oclaw.runtime.agents.factory import build_gateway_executor
|
||||
from oclaw.runtime.chat.agent import GenerationInterrupted
|
||||
from oclaw.platform.config.paths import db_path
|
||||
|
|
@ -82,15 +83,15 @@ _DISPATCH_REASON_LABELS: dict[str, dict[str, str]] = {
|
|||
"en": "Specialist executor factory failed",
|
||||
},
|
||||
"manager_route_missing": {
|
||||
"zh": "总控路由缺失",
|
||||
"zh": "全能者路由缺失",
|
||||
"en": "Manager route missing",
|
||||
},
|
||||
"manager_select_failed": {
|
||||
"zh": "总控决策失败",
|
||||
"zh": "全能者决策失败",
|
||||
"en": "Manager selection failed",
|
||||
},
|
||||
"manager_model_missing": {
|
||||
"zh": "总控模型不可用",
|
||||
"zh": "全能者模型不可用",
|
||||
"en": "Manager model missing",
|
||||
},
|
||||
"dynamic_agent_selected": {
|
||||
|
|
@ -100,7 +101,7 @@ _DISPATCH_REASON_LABELS: dict[str, dict[str, str]] = {
|
|||
}
|
||||
_DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
|
||||
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
|
||||
_CHAT_SPECIALIST_IDS: tuple[str, ...] = ("generalist", "ops", "image", "memory_curator")
|
||||
_CHAT_SPECIALIST_IDS: tuple[str, ...] = tuple(str(x) for x in SPECIALIST_IDS if str(x).strip())
|
||||
DEFAULT_TABULAR_SQL_TIMEOUT_MS = 8_000
|
||||
|
||||
|
||||
|
|
@ -1247,6 +1248,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
if not sess:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
text_raw = str(payload.get("text") or "").strip()
|
||||
idempotency_key = str(payload.get("idempotency_key") or payload.get("idempotencyKey") or "").strip()
|
||||
attachments = _parse_attachments_payload(payload.get("attachments"))
|
||||
interaction_mode, selected_specialist = _normalize_chat_mode(payload)
|
||||
memory_mode = _normalize_memory_mode(payload)
|
||||
|
|
@ -1311,6 +1313,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
msg=msg,
|
||||
lang=lang,
|
||||
executor=manager_agent,
|
||||
run_id=(idempotency_key or None),
|
||||
specialist_executor_factory=specialist_factory,
|
||||
)
|
||||
reply = gw_result.reply_text
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from pathlib import Path
|
|||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
|
@ -14,13 +15,20 @@ from fastapi.responses import HTMLResponse
|
|||
|
||||
from oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
|
||||
from oclaw.runtime.operations.providers.registry import build_channel_registry
|
||||
from oclaw.runtime.operations.runtime import cleanup_orphan_service_processes, detect_orphan_service_processes, status_services
|
||||
from oclaw.runtime.operations.runtime import (
|
||||
cleanup_service_processes_by_pid,
|
||||
detect_orphan_service_processes,
|
||||
is_pid_running,
|
||||
list_listen_ports_for_pid,
|
||||
list_service_process_pids,
|
||||
status_services,
|
||||
)
|
||||
from oclaw.runtime.operations.stack import cmd_stack_down, cmd_stack_status, cmd_stack_up
|
||||
from oclaw.runtime.orchestration.vector_store import read_vector_memory_runtime
|
||||
from oclaw.platform.config.paths import PROJECT_ROOT, db_path
|
||||
from oclaw.platform.config.passwords import load_expected_password
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.runtime.agents.specialists import SPECIALISTS
|
||||
from oclaw.runtime.agents.specialists import discover_specialist_ids
|
||||
from oclaw.runtime.tools.mcp.installer import (
|
||||
_safe_server_id,
|
||||
detect_local_dependencies,
|
||||
|
|
@ -44,6 +52,8 @@ from oclaw.runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor
|
|||
from oclaw.platform.llm.chat_models import LLMToolCall
|
||||
from oclaw.runtime.agent_core_attempt import ALL_ATTEMPT_ERROR_CODES
|
||||
from oclaw.runtime.agent_core_run import DEFAULT_RETRYABLE_ERROR_CODES, resolve_retryable_error_codes
|
||||
from oclaw.runtime.prompt_prebuild import run_runtime_prewarm, runtime_prewarm_prompts_snapshot, runtime_prewarm_status
|
||||
from oclaw.runtime.workspaces.experts import create_expert, delete_expert, list_experts, normalize_expert_id, update_expert_files
|
||||
|
||||
_WECOM_CLEAR_KEYS = [
|
||||
"wecom_mode",
|
||||
|
|
@ -230,7 +240,7 @@ def build_admin_router() -> APIRouter:
|
|||
raise HTTPException(status_code=403, detail="cross_tenant_forbidden")
|
||||
|
||||
def _ordered_specialists() -> list[str]:
|
||||
base = [str(k).strip().lower() for k in SPECIALISTS.keys() if str(k).strip()]
|
||||
base = [str(k).strip().lower() for k in discover_specialist_ids() if str(k).strip()]
|
||||
preferred = [x for x in ("generalist", "ops", "image") if x in set(base)]
|
||||
return preferred + [x for x in base if x not in set(preferred)]
|
||||
|
||||
|
|
@ -243,6 +253,30 @@ def build_admin_router() -> APIRouter:
|
|||
specs = _ordered_specialists()
|
||||
return ["manager", *[x for x in specs if x != "manager"]]
|
||||
|
||||
_EXPERT_ALLOWED_FILES = {"SOUL.md", "ROLE_SYSTEM.md"}
|
||||
|
||||
def _serialize_expert_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
files = row.get("files") if isinstance(row, dict) else {}
|
||||
obj = files if isinstance(files, dict) else {}
|
||||
return {
|
||||
"id": str(row.get("id") or ""),
|
||||
"builtin": bool(row.get("builtin")),
|
||||
"has_required_soul": bool(row.get("has_required_soul")),
|
||||
"path": str(row.get("path") or ""),
|
||||
"files": {
|
||||
"SOUL.md": str(obj.get("SOUL.md") or ""),
|
||||
"ROLE_SYSTEM.md": str(obj.get("ROLE_SYSTEM.md") or ""),
|
||||
},
|
||||
}
|
||||
|
||||
def _sanitize_expert_files_payload(raw: Any) -> dict[str, str]:
|
||||
files_raw = raw if isinstance(raw, dict) else {}
|
||||
files = {str(k): str(v or "") for k, v in files_raw.items()}
|
||||
bad = [k for k in files.keys() if k not in _EXPERT_ALLOWED_FILES]
|
||||
if bad:
|
||||
raise ValueError("unsupported_file_name")
|
||||
return files
|
||||
|
||||
@router.post("/admin/api/tools/internal/reload")
|
||||
def api_internal_tools_reload(
|
||||
authorization: str | None = Header(default=None),
|
||||
|
|
@ -543,7 +577,50 @@ def build_admin_router() -> APIRouter:
|
|||
_require_permission(ctx, "admin:read")
|
||||
items = []
|
||||
for s in status_services():
|
||||
items.append({"name": s.name, "pid": s.pid, "running": bool(s.running)})
|
||||
all_pids = list_service_process_pids(str(s.name))
|
||||
pid_ports: dict[str, list[int]] = {}
|
||||
pid_running: dict[str, bool] = {}
|
||||
ports_set: set[int] = set()
|
||||
for p in all_pids:
|
||||
pp = list_listen_ports_for_pid(int(p))
|
||||
pid_ports[str(int(p))] = [int(x) for x in pp]
|
||||
pid_running[str(int(p))] = bool(is_pid_running(int(p)))
|
||||
for x in pp:
|
||||
ports_set.add(int(x))
|
||||
running_any = any(bool(v) for v in pid_running.values()) if pid_running else bool(s.running)
|
||||
# Choose primary pid from the current snapshot (do not "stick" to state pid).
|
||||
live = sorted([int(k) for k, v in pid_running.items() if v])
|
||||
primary_pid = 0
|
||||
if live:
|
||||
name = str(getattr(s, "name", "") or "")
|
||||
# Prefer gateway PID that actually binds the gateway port.
|
||||
if name == "gateway":
|
||||
prefer_port = 8787
|
||||
candidates = [p for p in live if int(prefer_port) in set(pid_ports.get(str(p), []) or [])]
|
||||
if candidates:
|
||||
primary_pid = int(candidates[0])
|
||||
if not primary_pid:
|
||||
# Prefer PID(s) with any listening ports.
|
||||
with_ports = [p for p in live if (pid_ports.get(str(p), []) or [])]
|
||||
if with_ports:
|
||||
# Choose the one with most ports, tie-breaker: smallest pid
|
||||
with_ports.sort(key=lambda p: (-len(pid_ports.get(str(p), []) or []), int(p)))
|
||||
primary_pid = int(with_ports[0])
|
||||
if not primary_pid:
|
||||
# Fall back to smallest live pid for stability.
|
||||
primary_pid = int(live[0])
|
||||
items.append(
|
||||
{
|
||||
"name": s.name,
|
||||
"pid": int(primary_pid or 0),
|
||||
"running": bool(running_any),
|
||||
"all_pids": sorted(int(x) for x in all_pids if int(x) > 0),
|
||||
"ports": sorted(ports_set),
|
||||
"pid_ports": pid_ports,
|
||||
"pid_running": pid_running,
|
||||
"duplicate_count": max(0, len(set(int(x) for x in all_pids if int(x) > 0)) - 1),
|
||||
}
|
||||
)
|
||||
return {"ok": True, "items": items}
|
||||
|
||||
@router.post("/admin/api/stack/up")
|
||||
|
|
@ -577,15 +654,35 @@ def build_admin_router() -> APIRouter:
|
|||
cmd_stack_down(ns)
|
||||
return api_stack_status()
|
||||
|
||||
@router.get("/admin/api/runtime/anomalies")
|
||||
def api_runtime_anomalies(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
def _collect_runtime_anomalies(store: SqliteStore) -> dict[str, Any]:
|
||||
items = []
|
||||
running = status_services()
|
||||
for s in running:
|
||||
if not bool(getattr(s, "running", False)):
|
||||
# Duplicate worker checks only make sense when a primary service
|
||||
# is currently running. Otherwise this creates noisy conflicts
|
||||
# with "missing service" alerts.
|
||||
continue
|
||||
keep = {int(s.pid)} if s.running and int(s.pid or 0) > 0 else set()
|
||||
all_pids = list_service_process_pids(s.name)
|
||||
if (not keep) and len(all_pids) > 1:
|
||||
# Ambiguous primary process: do not guess and do not auto-clean.
|
||||
# Require explicit stack restart to re-elect primary PID safely.
|
||||
items.append(
|
||||
{
|
||||
"type": "duplicate_process_ambiguous",
|
||||
"service": s.name,
|
||||
"expected_pid": 0,
|
||||
"orphan_pids": sorted(int(x) for x in all_pids),
|
||||
"severity": "critical",
|
||||
"message": (
|
||||
f"{s.name} has multiple workers but no stable primary pid in runtime state: "
|
||||
f"{', '.join(str(x) for x in sorted(all_pids))}. "
|
||||
"Please restart stack (down/up)."
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
orphans = detect_orphan_service_processes(s.name, keep_pids=keep)
|
||||
if orphans:
|
||||
items.append(
|
||||
|
|
@ -624,19 +721,187 @@ def build_admin_router() -> APIRouter:
|
|||
must_cleanup = any(str(x.get("type") or "") == "duplicate_process" for x in items)
|
||||
return {"ok": True, "must_cleanup": must_cleanup, "items": items}
|
||||
|
||||
@router.get("/admin/api/runtime/anomalies")
|
||||
def api_runtime_anomalies(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
return _collect_runtime_anomalies(store)
|
||||
|
||||
@router.post("/admin/api/runtime/cleanup")
|
||||
def api_runtime_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
ctx = _resolve_auth(SqliteStore(db_path()), authorization)
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:runtime:write")
|
||||
killed: list[dict[str, Any]] = []
|
||||
# Cleanup rule:
|
||||
# - show all PIDs to user; cleanup should be conservative.
|
||||
# - for a service with multiple workers, kill those with NO listening ports.
|
||||
# - never delete the last remaining worker for a service.
|
||||
for s in status_services():
|
||||
keep = {int(s.pid)} if s.running and int(s.pid or 0) > 0 else set()
|
||||
dead = cleanup_orphan_service_processes(s.name, keep_pids=keep)
|
||||
service = str(getattr(s, "name", "") or "").strip()
|
||||
if not service:
|
||||
continue
|
||||
all_pids = [int(x) for x in list_service_process_pids(service) if int(x) > 0]
|
||||
if len(all_pids) <= 1:
|
||||
continue
|
||||
pid_ports: dict[int, list[int]] = {int(p): list_listen_ports_for_pid(int(p)) for p in all_pids}
|
||||
with_ports = [p for p in all_pids if pid_ports.get(int(p))]
|
||||
without_ports = [p for p in all_pids if not pid_ports.get(int(p))]
|
||||
victims: list[int] = []
|
||||
if with_ports:
|
||||
victims = list(without_ports)
|
||||
else:
|
||||
# If none are listening (e.g. long-polling channel workers),
|
||||
# keep the recorded primary pid when possible, otherwise keep the first.
|
||||
primary = int(getattr(s, "pid", 0) or 0)
|
||||
keep = primary if primary in all_pids else int(all_pids[0])
|
||||
victims = [p for p in all_pids if int(p) != int(keep)]
|
||||
if victims and len(victims) >= len(all_pids):
|
||||
victims = victims[: max(0, len(all_pids) - 1)]
|
||||
dead = cleanup_service_processes_by_pid(service, victims)
|
||||
if dead:
|
||||
killed.append({"service": s.name, "killed_pids": sorted(int(x) for x in dead)})
|
||||
anomalies = api_runtime_anomalies()
|
||||
killed.append({"service": service, "killed_pids": sorted(int(x) for x in dead)})
|
||||
anomalies = _collect_runtime_anomalies(store)
|
||||
return {"ok": True, "killed": killed, "anomalies": anomalies}
|
||||
|
||||
@router.get("/admin/api/runtime/prewarm/status")
|
||||
def api_runtime_prewarm_status(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
return runtime_prewarm_status(store=store)
|
||||
|
||||
@router.post("/admin/api/runtime/prewarm")
|
||||
def api_runtime_prewarm(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:runtime:write")
|
||||
body = payload or {}
|
||||
mode = str(body.get("mode") or "async").strip().lower()
|
||||
reason = str(body.get("reason") or "admin_manual").strip() or "admin_manual"
|
||||
if mode == "sync":
|
||||
return run_runtime_prewarm(reason=reason, store=store)
|
||||
import threading
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
_ = run_runtime_prewarm(reason=reason, store=SqliteStore(db_path()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
th = threading.Thread(target=_run, name="oclaw-admin-prewarm", daemon=True)
|
||||
th.start()
|
||||
return {"ok": True, "accepted": True, "mode": "async", "status": runtime_prewarm_status(store=store)}
|
||||
|
||||
@router.get("/admin/api/runtime/prewarm/prompts")
|
||||
def api_runtime_prewarm_prompts(
|
||||
role: str | None = Query(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
return runtime_prewarm_prompts_snapshot(store=store, role=str(role or "").strip().lower() or None)
|
||||
|
||||
@router.get("/admin/api/runtime/scan-artifacts")
|
||||
def api_runtime_scan_artifacts(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
ctx = _resolve_auth(SqliteStore(db_path()), authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve()
|
||||
allowed_prefixes = ("history_entries_", "state_scan_")
|
||||
items: list[dict[str, Any]] = []
|
||||
if root.exists() and root.is_dir():
|
||||
for p in sorted(root.glob("*.json"), key=lambda x: x.name.lower()):
|
||||
if not p.name.startswith(allowed_prefixes):
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
items.append(
|
||||
{
|
||||
"name": p.name,
|
||||
"path": str(p),
|
||||
"bytes": int(st.st_size),
|
||||
"modified_at": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
return {"ok": True, "dir": str(root), "count": len(items), "items": items}
|
||||
|
||||
@router.post("/admin/api/runtime/scan-artifacts/cleanup")
|
||||
def api_runtime_scan_artifacts_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
ctx = _resolve_auth(SqliteStore(db_path()), authorization)
|
||||
_require_permission(ctx, "admin:runtime:write")
|
||||
root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve()
|
||||
allowed_prefixes = ("history_entries_", "state_scan_")
|
||||
removed = 0
|
||||
if root.exists() and root.is_dir():
|
||||
for p in root.glob("*.json"):
|
||||
if not p.name.startswith(allowed_prefixes):
|
||||
continue
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
except Exception:
|
||||
continue
|
||||
return {"ok": True, "dir": str(root), "removed": int(removed)}
|
||||
|
||||
@router.post("/admin/api/runtime/scan-artifacts/prune")
|
||||
def api_runtime_scan_artifacts_prune(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
body = payload or {}
|
||||
ctx = _resolve_auth(SqliteStore(db_path()), authorization)
|
||||
_require_permission(ctx, "admin:runtime:write")
|
||||
root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve()
|
||||
try:
|
||||
keep_latest = int(body.get("keep_latest", 20))
|
||||
except Exception:
|
||||
keep_latest = 20
|
||||
try:
|
||||
max_age_days = int(body.get("max_age_days", 7))
|
||||
except Exception:
|
||||
max_age_days = 7
|
||||
keep_latest = max(0, min(keep_latest, 500))
|
||||
max_age_days = max(0, min(max_age_days, 3650))
|
||||
allowed_prefixes = ("history_entries_", "state_scan_")
|
||||
rows: list[tuple[Path, float]] = []
|
||||
if root.exists() and root.is_dir():
|
||||
for p in root.glob("*.json"):
|
||||
if not p.name.startswith(allowed_prefixes):
|
||||
continue
|
||||
try:
|
||||
rows.append((p, float(p.stat().st_mtime)))
|
||||
except Exception:
|
||||
continue
|
||||
rows.sort(key=lambda x: x[1], reverse=True)
|
||||
cutoff_ts = None
|
||||
if max_age_days > 0:
|
||||
cutoff_ts = (_now_utc() - timedelta(days=max_age_days)).timestamp()
|
||||
removed = 0
|
||||
for idx, (p, mtime) in enumerate(rows):
|
||||
remove_by_rank = keep_latest >= 0 and idx >= keep_latest
|
||||
remove_by_age = cutoff_ts is not None and mtime < cutoff_ts
|
||||
if not remove_by_rank and not remove_by_age:
|
||||
continue
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
except Exception:
|
||||
continue
|
||||
return {
|
||||
"ok": True,
|
||||
"dir": str(root),
|
||||
"removed": int(removed),
|
||||
"keep_latest": int(keep_latest),
|
||||
"max_age_days": int(max_age_days),
|
||||
}
|
||||
|
||||
@router.get("/admin/api/tenants")
|
||||
def api_tenants(
|
||||
scope: str | None = Query(default=None),
|
||||
|
|
@ -1417,12 +1682,80 @@ def build_admin_router() -> APIRouter:
|
|||
trace_rows = store.list_trace_events_for_trace(session_id=sid, trace_id=tid, limit=800)
|
||||
start_ts, end_ts = store.get_turn_time_window(session_id=sid, trace_id=tid)
|
||||
msgs = store.list_messages_in_time_window(session_id=sid, start_ts=start_ts, end_ts=end_ts, limit=1200)
|
||||
def _to_ms(v: Any) -> int | None:
|
||||
s = str(v or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
return int(datetime.fromisoformat(s).timestamp() * 1000)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
ws_accepted_ms: int | None = None
|
||||
gateway_received_ms: int | None = None
|
||||
model_chat_start_ms: int | None = None
|
||||
ws_first_token_ms: int | None = None
|
||||
for row in trace_rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
event_type = str(row.get("event_type") or "").strip()
|
||||
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
|
||||
row_ts = _to_ms(row.get("timestamp"))
|
||||
if event_type == "gateway_received":
|
||||
gateway_received_ms = row_ts if row_ts is not None else gateway_received_ms
|
||||
if ws_accepted_ms is None:
|
||||
try:
|
||||
v = payload.get("ws_client_send_ms")
|
||||
if v is not None:
|
||||
ws_accepted_ms = int(v)
|
||||
except Exception:
|
||||
pass
|
||||
elif event_type == "model_chat_start":
|
||||
model_chat_start_ms = row_ts if row_ts is not None else model_chat_start_ms
|
||||
elif event_type == "ws_first_token":
|
||||
if ws_first_token_ms is None:
|
||||
try:
|
||||
vft = payload.get("ws_first_token_ms")
|
||||
if vft is not None:
|
||||
ws_first_token_ms = int(vft)
|
||||
except Exception:
|
||||
pass
|
||||
if ws_accepted_ms is None:
|
||||
try:
|
||||
va = payload.get("ws_accepted_ms")
|
||||
if va is not None:
|
||||
ws_accepted_ms = int(va)
|
||||
except Exception:
|
||||
pass
|
||||
if ws_first_token_ms is None:
|
||||
ws_first_token_ms = row_ts if row_ts is not None else ws_first_token_ms
|
||||
|
||||
def _delta(a: int | None, b: int | None) -> int | None:
|
||||
if a is None or b is None:
|
||||
return None
|
||||
d = int(b - a)
|
||||
return d if d >= 0 else None
|
||||
|
||||
ttft = {
|
||||
"ws_accepted_ms": ws_accepted_ms,
|
||||
"gateway_received_ms": gateway_received_ms,
|
||||
"model_chat_start_ms": model_chat_start_ms,
|
||||
"ws_first_token_ms": ws_first_token_ms,
|
||||
"accepted_to_gateway_ms": _delta(ws_accepted_ms, gateway_received_ms),
|
||||
"gateway_to_model_start_ms": _delta(gateway_received_ms, model_chat_start_ms),
|
||||
"model_start_to_first_token_ms": _delta(model_chat_start_ms, ws_first_token_ms),
|
||||
"gateway_to_first_token_ms": _delta(gateway_received_ms, ws_first_token_ms),
|
||||
"accepted_to_first_token_ms": _delta(ws_accepted_ms, ws_first_token_ms),
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"session_id": sid,
|
||||
"trace_id": tid,
|
||||
"start_ts": start_ts,
|
||||
"end_ts": end_ts,
|
||||
"ttft": ttft,
|
||||
"trace": trace_rows,
|
||||
"messages": msgs,
|
||||
}
|
||||
|
|
@ -1463,6 +1796,13 @@ def build_admin_router() -> APIRouter:
|
|||
enable_mcp_tools = emcp_raw not in ("0", "false", "no", "off")
|
||||
epl_raw = str(store.get_setting("AIA_ENABLE_PLUGIN_TOOLS") or "").strip().lower()
|
||||
enable_plugin_tools = epl_raw in ("1", "true", "yes", "on")
|
||||
erc_raw = str(store.get_setting("AIA_ENABLE_RUN_COMMAND") or "").strip().lower()
|
||||
enable_run_command = erc_raw not in ("0", "false", "no", "off")
|
||||
tctx_raw = str(store.get_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED") or "").strip().lower()
|
||||
tool_context_truncate_enabled = tctx_raw not in ("0", "false", "no", "off")
|
||||
ttft_raw = str(store.get_setting("AIA_CHAT_SHOW_TTFT_DEBUG") or "").strip().lower()
|
||||
# default OFF (release-safe): only enabled when explicitly set truthy.
|
||||
chat_show_ttft_debug = ttft_raw in ("1", "true", "yes", "on")
|
||||
skl_raw = str(store.get_setting("AIA_SKILL_RUNTIME_ENABLED") or "").strip().lower()
|
||||
skill_runtime_enabled = skl_raw not in ("0", "false", "no", "off")
|
||||
sai_raw = str(store.get_setting("AIA_SKILL_AUTO_INSTALL_ENABLED") or "").strip().lower()
|
||||
|
|
@ -1503,6 +1843,9 @@ def build_admin_router() -> APIRouter:
|
|||
"tool_log_max_chars": tool_log_max_chars,
|
||||
"enable_mcp_tools": enable_mcp_tools,
|
||||
"enable_plugin_tools": enable_plugin_tools,
|
||||
"enable_run_command": enable_run_command,
|
||||
"tool_context_truncate_enabled": tool_context_truncate_enabled,
|
||||
"chat_show_ttft_debug": chat_show_ttft_debug,
|
||||
"skill_runtime_enabled": skill_runtime_enabled,
|
||||
"skill_auto_install_enabled": skill_auto_install_enabled,
|
||||
"tool_llm_message_max_chars": tool_llm_message_max_chars,
|
||||
|
|
@ -1557,6 +1900,9 @@ def build_admin_router() -> APIRouter:
|
|||
tl_cap_n = 200_000
|
||||
emcp = bool(payload.get("enable_mcp_tools", True))
|
||||
epl = bool(payload.get("enable_plugin_tools", False))
|
||||
erc = bool(payload.get("enable_run_command", True))
|
||||
tctx = bool(payload.get("tool_context_truncate_enabled", True))
|
||||
ttft_debug = bool(payload.get("chat_show_ttft_debug", False))
|
||||
skl_rt = bool(payload.get("skill_runtime_enabled", True))
|
||||
skl_ai = bool(payload.get("skill_auto_install_enabled", True))
|
||||
tmsg = payload.get("tool_llm_message_max_chars", 0)
|
||||
|
|
@ -1626,6 +1972,11 @@ def build_admin_router() -> APIRouter:
|
|||
store.set_setting("AIA_TOOL_LOG_MAX_CHARS", str(tl_cap_n))
|
||||
store.set_setting("AIA_ENABLE_MCP_TOOLS", "1" if emcp else "0")
|
||||
store.set_setting("AIA_ENABLE_PLUGIN_TOOLS", "1" if epl else "0")
|
||||
store.set_setting("AIA_ENABLE_RUN_COMMAND", "1" if erc else "0")
|
||||
# Keep runtime gate aligned with Admin toggle immediately.
|
||||
os.environ["AIA_ENABLE_RUN_COMMAND"] = "1" if erc else "0"
|
||||
store.set_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED", "1" if tctx else "0")
|
||||
store.set_setting("AIA_CHAT_SHOW_TTFT_DEBUG", "1" if ttft_debug else "0")
|
||||
store.set_setting("AIA_SKILL_RUNTIME_ENABLED", "1" if skl_rt else "0")
|
||||
store.set_setting("AIA_SKILL_AUTO_INSTALL_ENABLED", "1" if skl_ai else "0")
|
||||
store.set_setting("AIA_TOOL_LLM_MESSAGE_MAX_CHARS", str(tmsg_n))
|
||||
|
|
@ -1642,7 +1993,7 @@ def build_admin_router() -> APIRouter:
|
|||
actor_user_id=ctx["user_id"],
|
||||
action="tool_policy_update",
|
||||
target_type="tool_policy",
|
||||
target_id="AIA_TURN_MAX_TOOL_WORKERS,AIA_TURN_MAX_TOOL_ROUNDS,AIA_TURN_MAX_CONTEXT_MESSAGES,AIA_SSE_QUEUE_MAXSIZE,AIA_TOOL_LOG_MAX_CHARS,AIA_ENABLE_MCP_TOOLS,AIA_ENABLE_PLUGIN_TOOLS,AIA_SKILL_RUNTIME_ENABLED,AIA_SKILL_AUTO_INSTALL_ENABLED,AIA_TOOL_LLM_MESSAGE_MAX_CHARS,AIA_MCP_FILESYSTEM_EXTRA_ROOTS,AIA_MCP_ENV_ALLOWLIST,AIA_OCLAW_RETRYABLE_ERROR_CODES,AIA_OCLAW_RETRY_CODES_STRICT_MODE,AIA_WECOM_LONGCONN_WORKERS,AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE",
|
||||
target_id="AIA_TURN_MAX_TOOL_WORKERS,AIA_TURN_MAX_TOOL_ROUNDS,AIA_TURN_MAX_CONTEXT_MESSAGES,AIA_SSE_QUEUE_MAXSIZE,AIA_TOOL_LOG_MAX_CHARS,AIA_ENABLE_MCP_TOOLS,AIA_ENABLE_PLUGIN_TOOLS,AIA_ENABLE_RUN_COMMAND,AIA_TOOL_CONTEXT_TRUNCATE_ENABLED,AIA_CHAT_SHOW_TTFT_DEBUG,AIA_SKILL_RUNTIME_ENABLED,AIA_SKILL_AUTO_INSTALL_ENABLED,AIA_TOOL_LLM_MESSAGE_MAX_CHARS,AIA_MCP_FILESYSTEM_EXTRA_ROOTS,AIA_MCP_ENV_ALLOWLIST,AIA_OCLAW_RETRYABLE_ERROR_CODES,AIA_OCLAW_RETRY_CODES_STRICT_MODE,AIA_WECOM_LONGCONN_WORKERS,AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE",
|
||||
status="ok",
|
||||
detail={
|
||||
"disable_tool_confirm": disable_confirm,
|
||||
|
|
@ -1658,6 +2009,9 @@ def build_admin_router() -> APIRouter:
|
|||
"tool_log_max_chars": tl_cap_n,
|
||||
"enable_mcp_tools": emcp,
|
||||
"enable_plugin_tools": epl,
|
||||
"enable_run_command": erc,
|
||||
"tool_context_truncate_enabled": tctx,
|
||||
"chat_show_ttft_debug": ttft_debug,
|
||||
"skill_runtime_enabled": skl_rt,
|
||||
"skill_auto_install_enabled": skl_ai,
|
||||
"tool_llm_message_max_chars": tmsg_n,
|
||||
|
|
@ -1685,6 +2039,9 @@ def build_admin_router() -> APIRouter:
|
|||
"tool_log_max_chars": tl_cap_n,
|
||||
"enable_mcp_tools": emcp,
|
||||
"enable_plugin_tools": epl,
|
||||
"enable_run_command": erc,
|
||||
"tool_context_truncate_enabled": tctx,
|
||||
"chat_show_ttft_debug": ttft_debug,
|
||||
"skill_runtime_enabled": skl_rt,
|
||||
"skill_auto_install_enabled": skl_ai,
|
||||
"tool_llm_message_max_chars": tmsg_n,
|
||||
|
|
@ -2162,6 +2519,93 @@ def build_admin_router() -> APIRouter:
|
|||
)
|
||||
return {"ok": True, "available_specialists": available, "allowed_specialists": ordered}
|
||||
|
||||
@router.get("/admin/api/experts")
|
||||
def api_experts_list(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
items = [_serialize_expert_row(x) for x in list_experts()]
|
||||
return {"ok": True, "items": items}
|
||||
|
||||
@router.post("/admin/api/experts")
|
||||
def api_experts_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_permission(ctx, "admin:tenant:write")
|
||||
expert_id = normalize_expert_id(payload.get("id"))
|
||||
files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {}
|
||||
try:
|
||||
files = _sanitize_expert_files_payload(files_raw)
|
||||
created = create_expert(expert_id=expert_id, files=files)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=ctx["tenant_id"],
|
||||
actor_user_id=ctx["user_id"],
|
||||
action="expert_create",
|
||||
target_type="expert",
|
||||
target_id=str(created.get("id") or ""),
|
||||
status="ok",
|
||||
detail={"id": str(created.get("id") or "")},
|
||||
)
|
||||
items = [_serialize_expert_row(x) for x in list_experts()]
|
||||
return {"ok": True, "created": str(created.get("id") or ""), "items": items}
|
||||
|
||||
@router.patch("/admin/api/experts/{expert_id}")
|
||||
def api_experts_update(
|
||||
expert_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_permission(ctx, "admin:tenant:write")
|
||||
files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {}
|
||||
eid = normalize_expert_id(expert_id)
|
||||
try:
|
||||
files = _sanitize_expert_files_payload(files_raw)
|
||||
update_expert_files(expert_id=eid, files=files)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=ctx["tenant_id"],
|
||||
actor_user_id=ctx["user_id"],
|
||||
action="expert_update",
|
||||
target_type="expert",
|
||||
target_id=eid,
|
||||
status="ok",
|
||||
detail={"id": eid, "updated_files": sorted(list(files.keys()))[:10]},
|
||||
)
|
||||
items = [_serialize_expert_row(x) for x in list_experts()]
|
||||
return {"ok": True, "updated": eid, "items": items}
|
||||
|
||||
@router.delete("/admin/api/experts/{expert_id}")
|
||||
def api_experts_delete(expert_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
eid = normalize_expert_id(expert_id)
|
||||
try:
|
||||
delete_expert(eid)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=ctx["tenant_id"],
|
||||
actor_user_id=ctx["user_id"],
|
||||
action="expert_delete",
|
||||
target_type="expert",
|
||||
target_id=eid,
|
||||
status="ok",
|
||||
detail={"id": eid},
|
||||
)
|
||||
items = [_serialize_expert_row(x) for x in list_experts()]
|
||||
return {"ok": True, "deleted": eid, "items": items}
|
||||
|
||||
@router.post("/admin/api/mcp/install")
|
||||
def api_mcp_install(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException
|
||||
|
|
@ -10,14 +11,17 @@ from oclaw.runtime.agents.factory import build_gateway_executor
|
|||
from oclaw.runtime.skill_installer import (
|
||||
auto_install_skill_from_payload,
|
||||
create_skill_from_template,
|
||||
create_workspace_skill,
|
||||
install_skill_from_local_dir,
|
||||
install_skill_from_registry_archive,
|
||||
list_skills_with_status,
|
||||
set_skill_enabled,
|
||||
uninstall_skill,
|
||||
)
|
||||
from oclaw.runtime.skill_role_binding import (
|
||||
SKILL_ROLE_BINDING_ENABLED_SETTING,
|
||||
SKILL_ROLE_BINDING_KEY,
|
||||
SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING,
|
||||
load_skill_role_binding_dict,
|
||||
normalize_skill_role_binding,
|
||||
ordered_binding_roles,
|
||||
|
|
@ -25,10 +29,10 @@ from oclaw.runtime.skill_role_binding import (
|
|||
)
|
||||
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.runtime.skills_market import get_market_adapter
|
||||
from oclaw.runtime.tools.skills_runtime.subprocess_exec import run_skill_runtime_entry
|
||||
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(
|
||||
|
|
@ -82,6 +86,44 @@ def include_skill_routes(
|
|||
items = list_skills_with_status(store=store)
|
||||
return {"ok": True, "items": items}
|
||||
|
||||
@sk.get("/mode")
|
||||
def api_skills_mode_get(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
raw_prompt = str(store.get_setting("AIA_SKILLS_PROMPT_IN_SYSTEM") or "").strip().lower()
|
||||
raw_toolcall = str(store.get_setting("AIA_SKILL_TOOLCALL_ENABLED") or "").strip().lower()
|
||||
prompt_in_system = raw_prompt not in {"0", "false", "no", "off"}
|
||||
toolcall_enabled = raw_toolcall in {"1", "true", "yes", "on"}
|
||||
return {"ok": True, "prompt_in_system": bool(prompt_in_system), "toolcall_enabled": bool(toolcall_enabled)}
|
||||
|
||||
@sk.post("/mode")
|
||||
def api_skills_mode_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_admin(ctx)
|
||||
if "prompt_in_system" in payload:
|
||||
store.set_setting("AIA_SKILLS_PROMPT_IN_SYSTEM", "1" if bool(payload.get("prompt_in_system")) else "0")
|
||||
if "toolcall_enabled" in payload:
|
||||
store.set_setting("AIA_SKILL_TOOLCALL_ENABLED", "1" if bool(payload.get("toolcall_enabled")) else "0")
|
||||
raw_prompt = str(store.get_setting("AIA_SKILLS_PROMPT_IN_SYSTEM") or "").strip().lower()
|
||||
raw_toolcall = str(store.get_setting("AIA_SKILL_TOOLCALL_ENABLED") or "").strip().lower()
|
||||
prompt_in_system = raw_prompt not in {"0", "false", "no", "off"}
|
||||
toolcall_enabled = raw_toolcall in {"1", "true", "yes", "on"}
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_mode_update",
|
||||
target_id="skill_mode",
|
||||
status="ok",
|
||||
detail={"prompt_in_system": bool(prompt_in_system), "toolcall_enabled": bool(toolcall_enabled)},
|
||||
)
|
||||
return {"ok": True, "prompt_in_system": bool(prompt_in_system), "toolcall_enabled": bool(toolcall_enabled)}
|
||||
|
||||
@sk.post("/install")
|
||||
def api_skills_install(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
|
|
@ -162,7 +204,8 @@ def include_skill_routes(
|
|||
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)
|
||||
provider = str(store.get_setting("AIA_SKILL_MARKET_PROVIDER") or "clawhub").strip()
|
||||
items = get_market_adapter(provider).search(query, limit=lim)
|
||||
return {"ok": True, "items": items}
|
||||
|
||||
@sk.get("/market/detail")
|
||||
|
|
@ -176,7 +219,8 @@ def include_skill_routes(
|
|||
s = str(slug or "").strip()
|
||||
if not s:
|
||||
raise HTTPException(status_code=400, detail="slug_required")
|
||||
detail = clawhub_get_skill_detail(s)
|
||||
provider = str(store.get_setting("AIA_SKILL_MARKET_PROVIDER") or "clawhub").strip()
|
||||
detail = get_market_adapter(provider).detail(s)
|
||||
return {"ok": True, "detail": detail}
|
||||
|
||||
@sk.post("/market/install")
|
||||
|
|
@ -194,18 +238,9 @@ def include_skill_routes(
|
|||
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
|
||||
provider = str(store.get_setting("AIA_SKILL_MARKET_PROVIDER") or "clawhub").strip()
|
||||
adapter = get_market_adapter(provider)
|
||||
archive_url, chosen_version = adapter.resolve_archive_url(slug=s, version=requested_version or None)
|
||||
|
||||
if not archive_url:
|
||||
raise HTTPException(status_code=400, detail="archive_url_unavailable")
|
||||
|
|
@ -216,7 +251,7 @@ def include_skill_routes(
|
|||
action="skill_install_started",
|
||||
target_id=archive_url,
|
||||
status="start",
|
||||
detail={"source": "clawhub", "slug": s, "version": chosen_version, "input_target": s},
|
||||
detail={"source": provider, "slug": s, "version": chosen_version, "input_target": s},
|
||||
)
|
||||
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
|
||||
_audit(
|
||||
|
|
@ -228,7 +263,7 @@ def include_skill_routes(
|
|||
detail={
|
||||
"detail": out.detail,
|
||||
"target_dir": out.target_dir,
|
||||
"source": "clawhub",
|
||||
"source": provider,
|
||||
"slug": s,
|
||||
"version": chosen_version,
|
||||
"input_target": archive_url,
|
||||
|
|
@ -245,6 +280,36 @@ def include_skill_routes(
|
|||
},
|
||||
}
|
||||
|
||||
@sk.post("/create-workspace")
|
||||
def api_skills_create_workspace(
|
||||
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() or f"{name} workspace skill"
|
||||
runtime_type = str(payload.get("runtime_type") or "python").strip().lower()
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
out = create_workspace_skill(
|
||||
store=store,
|
||||
name=name,
|
||||
description=desc,
|
||||
runtime_type=runtime_type,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_create_workspace",
|
||||
target_id=out.name or name,
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={"runtime_type": runtime_type, "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.get("/binding")
|
||||
def api_skills_binding_get(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
|
|
@ -255,6 +320,7 @@ def include_skill_routes(
|
|||
return {
|
||||
"ok": True,
|
||||
"enabled": bool(skill_role_binding_enabled(store=store)),
|
||||
"manager_inherit": str(store.get_setting(SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING) or "").strip() not in {"0", "false", "False"},
|
||||
"available_roles": roles,
|
||||
"installed_skills": items,
|
||||
"mapping": mapping,
|
||||
|
|
@ -271,6 +337,8 @@ def include_skill_routes(
|
|||
_require_tenant_write(ctx)
|
||||
if "enabled" in payload:
|
||||
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1" if bool(payload.get("enabled")) else "0")
|
||||
if "manager_inherit" in payload:
|
||||
store.set_setting(SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING, "1" if bool(payload.get("manager_inherit")) 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(
|
||||
|
|
@ -290,6 +358,7 @@ def include_skill_routes(
|
|||
return {
|
||||
"ok": True,
|
||||
"enabled": bool(skill_role_binding_enabled(store=store)),
|
||||
"manager_inherit": str(store.get_setting(SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING) or "").strip() not in {"0", "false", "False"},
|
||||
"available_roles": roles,
|
||||
"mapping": mapping,
|
||||
}
|
||||
|
|
@ -455,6 +524,38 @@ def include_skill_routes(
|
|||
_audit(store, ctx, action="skill_disable", target_id=name, status="ok")
|
||||
return {"ok": True}
|
||||
|
||||
@sk.post("/uninstall")
|
||||
def api_skills_uninstall(
|
||||
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")
|
||||
out = uninstall_skill(store=store, skill_name=name)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_uninstall_finished" if out.ok else "skill_uninstall_failed",
|
||||
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("/auto-install")
|
||||
def api_skills_auto_install(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
|
|
@ -581,11 +682,24 @@ def include_skill_routes(
|
|||
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}"}
|
||||
# Skill prompt mode: toolcall may be disabled, fallback to direct runtime execution from manifest.
|
||||
mf = next((m for m in discover_workspace_skill_manifests() if str(m.name or "").strip() == name), None)
|
||||
if mf is None:
|
||||
raise HTTPException(status_code=404, detail="tool_not_found")
|
||||
runtime = dict(mf.runtime or {}) if isinstance(mf.runtime, dict) else {}
|
||||
if not runtime:
|
||||
raise HTTPException(status_code=400, detail="skill_not_executable")
|
||||
result = run_skill_runtime_entry(
|
||||
skill_name=str(mf.name or ""),
|
||||
skill_dir=str(mf.skill_dir or ""),
|
||||
runtime=runtime,
|
||||
args=dict(args),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
result = spec.handler(dict(args))
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "error_code": "exception", "error": f"{type(exc).__name__}:{exc}"}
|
||||
|
||||
_audit(
|
||||
store,
|
||||
|
|
@ -597,6 +711,61 @@ def include_skill_routes(
|
|||
)
|
||||
return {"ok": True, "name": name, "result": result}
|
||||
|
||||
@sk.get("/self-check")
|
||||
def api_skills_self_check(
|
||||
include_execution: bool = False,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
manifests = discover_workspace_skill_manifests()
|
||||
executable = [m for m in manifests if isinstance(m.runtime, dict) and bool(m.runtime)]
|
||||
invalid_entries = []
|
||||
execution_checks = []
|
||||
classification_counts: dict[str, int] = {}
|
||||
|
||||
def _bump(code: str) -> None:
|
||||
k = str(code or "unknown").strip() or "unknown"
|
||||
classification_counts[k] = int(classification_counts.get(k) or 0) + 1
|
||||
|
||||
for m in executable:
|
||||
entry = str((m.runtime or {}).get("entry") or "").strip().replace("\\", "/")
|
||||
if not entry:
|
||||
invalid_entries.append({"name": m.name, "error": "runtime_entry_missing"})
|
||||
_bump("runtime_entry_missing")
|
||||
continue
|
||||
p = (Path(m.skill_dir) / entry).resolve()
|
||||
if not p.exists():
|
||||
invalid_entries.append({"name": m.name, "error": f"runtime_entry_not_found:{entry}"})
|
||||
_bump("runtime_entry_not_found")
|
||||
continue
|
||||
if include_execution:
|
||||
rr = run_skill_runtime_entry(
|
||||
skill_name=str(m.name or ""),
|
||||
skill_dir=str(m.skill_dir or ""),
|
||||
runtime=dict(m.runtime or {}),
|
||||
args={},
|
||||
)
|
||||
row = {"name": m.name, "ok": bool(rr.get("ok")), "error_code": str(rr.get("error_code") or "")}
|
||||
if bool(rr.get("stdout_truncated")) or bool(rr.get("stderr_truncated")):
|
||||
row["output_truncated"] = True
|
||||
_bump("output_truncated")
|
||||
if not bool(rr.get("ok")):
|
||||
_bump(str(rr.get("error_code") or "runtime_error"))
|
||||
execution_checks.append(row)
|
||||
return {
|
||||
"ok": True,
|
||||
"skills_total": len(manifests),
|
||||
"executable_total": len(executable),
|
||||
"invalid_runtime_entries": invalid_entries,
|
||||
"execution_checked": bool(include_execution),
|
||||
"execution_checked_total": len(execution_checks),
|
||||
"execution_checks": execution_checks,
|
||||
"classification_counts": classification_counts,
|
||||
"market_provider": str(store.get_setting("AIA_SKILL_MARKET_PROVIDER") or "clawhub"),
|
||||
}
|
||||
|
||||
router.include_router(sk)
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
BIN
interfaces/admin/static/assets/fonts/Outfit-latin-500-600.woff2
Normal file
BIN
interfaces/admin/static/assets/fonts/Outfit-latin-500-600.woff2
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -4,12 +4,7 @@
|
|||
<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"
|
||||
/>
|
||||
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
|
||||
<link rel="stylesheet" href="/admin/assets/styles.css" />
|
||||
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
|
||||
<style>
|
||||
|
|
@ -443,13 +438,75 @@
|
|||
line-height: 1.45;
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
}
|
||||
/* First paint before chat.js runs (desktop / slow network): avoid empty #app “black hole”. */
|
||||
.chat-boot-splash {
|
||||
flex: 1;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chat-boot-splash__card {
|
||||
width: min(520px, 92vw);
|
||||
padding: 22px 20px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.chat-boot-splash__title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.chat-boot-splash__spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: rgba(255, 255, 255, 0.75);
|
||||
border-radius: 999px;
|
||||
animation: chat-boot-spin 1s linear infinite;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@keyframes chat-boot-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.chat-boot-splash__muted {
|
||||
opacity: 0.78;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</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>
|
||||
<!-- Keep markdown helpers non-blocking. chat.js already falls back when they are unavailable. -->
|
||||
<script async src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js" crossorigin="anonymous"></script>
|
||||
<script async src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" crossorigin="anonymous"></script>
|
||||
<!-- Cache-bust for desktop webview: avoid stale chat.js -->
|
||||
<script
|
||||
defer
|
||||
src="/admin/assets/chat.js?v=20260425-4"
|
||||
onerror="(function(){var s=document.getElementById('chat-boot-splash');if(s){s.querySelector('.chat-boot-splash__title span:last-child').textContent='无法加载 chat.js';s.querySelector('.chat-boot-splash__muted').textContent='请确认网关已启动且 /admin/assets/chat.js 可访问。';}})()"
|
||||
></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>
|
||||
<div id="app">
|
||||
<div id="chat-boot-splash" class="chat-boot-splash" role="status" aria-live="polite">
|
||||
<div class="chat-boot-splash__card">
|
||||
<div class="chat-boot-splash__title">
|
||||
<span class="chat-boot-splash__spinner" aria-hidden="true"></span>
|
||||
<span>正在加载登录界面…</span>
|
||||
</div>
|
||||
<div class="chat-boot-splash__muted">
|
||||
若长时间停留在此,请检查网络或稍后重试;桌面端可查看日志目录中的 desktop.log / backend.log。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ const I18N = {
|
|||
"chat.specialistGeneralist": "通用",
|
||||
"chat.specialistOps": "运维",
|
||||
"chat.specialistImage": "图像",
|
||||
"chat.specialistMemoryCurator": "记忆整理",
|
||||
"chat.specialistMemory": "记忆",
|
||||
"chat.memoryModeLabel": "记忆模式",
|
||||
"chat.memoryModeDefault": "默认(可注入)",
|
||||
"chat.memoryModeStoreOnly": "仅记录不注入",
|
||||
|
|
@ -80,8 +80,8 @@ const I18N = {
|
|||
"chat.wikiPreviewTitle": "Wiki 预览:{path}",
|
||||
"chat.wikiPreviewClose": "关闭",
|
||||
"chat.specialistManage": "专家开关",
|
||||
"chat.specialistManagePromptOn": "是否启用 memory_curator 专家?",
|
||||
"chat.specialistManagePromptOff": "是否关闭 memory_curator 专家?",
|
||||
"chat.specialistManagePromptOn": "是否启用 memory 专家?",
|
||||
"chat.specialistManagePromptOff": "是否关闭 memory 专家?",
|
||||
"chat.specialistManageDenied": "无权限修改专家开关",
|
||||
"chat.attachmentLimits": "附件阈值",
|
||||
"chat.attachmentLimitsPrompt": "编辑附件阈值 JSON(留空恢复默认)",
|
||||
|
|
@ -127,16 +127,17 @@ const I18N = {
|
|||
"chat.reason.manager_no_specialist_fallback": "无可用专家,回退通用",
|
||||
"chat.reason.dynamic_agent_build_failed": "动态专家构建失败",
|
||||
"chat.reason.manager_factory_failed": "专家执行器创建失败",
|
||||
"chat.reason.manager_route_missing": "总控路由缺失",
|
||||
"chat.reason.manager_select_failed": "总控决策失败",
|
||||
"chat.reason.manager_model_missing": "总控模型不可用",
|
||||
"chat.reason.manager_route_missing": "全能者路由缺失",
|
||||
"chat.reason.manager_select_failed": "全能者决策失败",
|
||||
"chat.reason.manager_model_missing": "全能者模型不可用",
|
||||
"chat.reason.dynamic_agent_selected": "动态专家命中",
|
||||
"chat.modeComprehensiveShort": "综合",
|
||||
"chat.modeExpertShort": "专家",
|
||||
"chat.specialistGeneralistShort": "通用",
|
||||
"chat.specialistOpsShort": "运维",
|
||||
"chat.specialistImageShort": "图像",
|
||||
"chat.specialistMemoryCuratorShort": "记忆整理",
|
||||
"chat.specialistMemoryShort": "记忆",
|
||||
"chat.specialistManagerSelfShort": "全能者",
|
||||
},
|
||||
en: {
|
||||
"chat.pageTitle": "oliver",
|
||||
|
|
@ -201,7 +202,7 @@ const I18N = {
|
|||
"chat.specialistGeneralist": "Generalist",
|
||||
"chat.specialistOps": "Ops",
|
||||
"chat.specialistImage": "Image",
|
||||
"chat.specialistMemoryCurator": "Memory Curator",
|
||||
"chat.specialistMemory": "Memory",
|
||||
"chat.memoryModeLabel": "Memory Mode",
|
||||
"chat.memoryModeDefault": "Default (inject allowed)",
|
||||
"chat.memoryModeStoreOnly": "Store only (no inject)",
|
||||
|
|
@ -215,8 +216,8 @@ const I18N = {
|
|||
"chat.wikiPreviewTitle": "Wiki preview: {path}",
|
||||
"chat.wikiPreviewClose": "Close",
|
||||
"chat.specialistManage": "Specialist Toggle",
|
||||
"chat.specialistManagePromptOn": "Enable memory_curator specialist?",
|
||||
"chat.specialistManagePromptOff": "Disable memory_curator specialist?",
|
||||
"chat.specialistManagePromptOn": "Enable memory specialist?",
|
||||
"chat.specialistManagePromptOff": "Disable memory specialist?",
|
||||
"chat.specialistManageDenied": "Not authorized to change specialist flags",
|
||||
"chat.attachmentLimits": "Attachment limits",
|
||||
"chat.attachmentLimitsPrompt": "Edit attachment limits JSON (empty to restore defaults)",
|
||||
|
|
@ -271,7 +272,8 @@ const I18N = {
|
|||
"chat.specialistGeneralistShort": "Generalist",
|
||||
"chat.specialistOpsShort": "Ops",
|
||||
"chat.specialistImageShort": "Image",
|
||||
"chat.specialistMemoryCuratorShort": "Memory Curator",
|
||||
"chat.specialistMemoryShort": "Memory",
|
||||
"chat.specialistManagerSelfShort": "Manager",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -388,6 +390,12 @@ function _buildAggregatedAssistantBubble(tsIso, items) {
|
|||
}
|
||||
inner.insertBefore(det, inner.firstChild);
|
||||
}
|
||||
const hasVisible =
|
||||
!!inner.querySelector(".chat-msg__md, .chat-msg__reasoning, .chat-msg__wiki") ||
|
||||
String(inner.textContent || "").trim().length > 0;
|
||||
if (!hasVisible) {
|
||||
inner.appendChild(el("div", { class: "muted", text: t("chat.tools.hidden") }));
|
||||
}
|
||||
return wrapAssistantMessage(inner, tsIso);
|
||||
}
|
||||
|
||||
|
|
@ -480,9 +488,11 @@ function interactionModeLabel(mode) {
|
|||
|
||||
function specialistLabel(specialist) {
|
||||
const s = String(specialist || "").toLowerCase();
|
||||
if (s === "manager") return currentLang === "zh" ? "主控" : "Manager";
|
||||
if (s === "manager_self") return t("chat.specialistManagerSelfShort");
|
||||
if (s === "ops") return t("chat.specialistOpsShort");
|
||||
if (s === "image") return t("chat.specialistImageShort");
|
||||
if (s === "memory_curator") return t("chat.specialistMemoryCuratorShort");
|
||||
if (s === "memory") return t("chat.specialistMemoryShort");
|
||||
return t("chat.specialistGeneralistShort");
|
||||
}
|
||||
|
||||
|
|
@ -1197,6 +1207,12 @@ function createStreamStitcher() {
|
|||
}
|
||||
|
||||
function formatToolPanelText(name, payload) {
|
||||
const truncateToolPanel = (s) => {
|
||||
const raw = String(s || "").trim();
|
||||
const maxChars = 50;
|
||||
if (raw.length <= maxChars) return raw;
|
||||
return `${raw.slice(0, maxChars)}…(详情请重新阅读)`;
|
||||
};
|
||||
const n = String(name || "").trim() || "tool";
|
||||
const p = payload && typeof payload === "object" ? payload : {};
|
||||
const r = p.result && typeof p.result === "object" ? p.result : p;
|
||||
|
|
@ -1219,7 +1235,7 @@ function formatToolPanelText(name, payload) {
|
|||
`engine=${String(r.engine || "")}`,
|
||||
`rows_returned=${String(r.rows_returned != null ? r.rows_returned : "")}`,
|
||||
];
|
||||
return lines.join("\n").trim();
|
||||
return truncateToolPanel(lines.join("\n").trim());
|
||||
}
|
||||
let body = "";
|
||||
// Prefer human-readable text from tool content blocks when available.
|
||||
|
|
@ -1237,7 +1253,7 @@ function formatToolPanelText(name, payload) {
|
|||
}
|
||||
}
|
||||
body = normalizeStreamText(body).replace(/\\n/g, "").replace(/\n/g, "");
|
||||
return `${n}\n${body}`.trim();
|
||||
return `${n}\n${truncateToolPanel(body)}`.trim();
|
||||
}
|
||||
|
||||
function extractSqlAuditPayload(payload) {
|
||||
|
|
@ -1464,6 +1480,12 @@ async function buildMessageBubble(role, content, tsIso) {
|
|||
inner.insertBefore(det, inner.firstChild);
|
||||
}
|
||||
}
|
||||
const hasVisible =
|
||||
!!inner.querySelector(".chat-msg__md, .chat-msg__reasoning, .chat-msg__wiki") ||
|
||||
String(inner.textContent || "").trim().length > 0;
|
||||
if (!hasVisible) {
|
||||
inner.appendChild(el("div", { class: "muted", text: t("chat.tools.hidden") }));
|
||||
}
|
||||
return wrapAssistantMessage(inner, tsIso);
|
||||
}
|
||||
|
||||
|
|
@ -1772,6 +1794,31 @@ async function renderChatUi() {
|
|||
const loadMoreWrap = el("div", { class: "chat-load-more" });
|
||||
const messagesEl = el("div", { class: "chat-messages" });
|
||||
bindChatImageViewer(messagesEl);
|
||||
const AUTO_SCROLL_BOTTOM_GAP_PX = 56;
|
||||
let shouldFollowMessages = true;
|
||||
const isNearBottom = () => {
|
||||
const remaining = messagesEl.scrollHeight - (messagesEl.scrollTop + messagesEl.clientHeight);
|
||||
return remaining <= AUTO_SCROLL_BOTTOM_GAP_PX;
|
||||
};
|
||||
const scrollMessagesToBottom = (force = false) => {
|
||||
if (!force && !shouldFollowMessages) return;
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
if (force || shouldFollowMessages) messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
});
|
||||
};
|
||||
messagesEl.addEventListener("scroll", () => {
|
||||
shouldFollowMessages = isNearBottom();
|
||||
});
|
||||
messagesEl.addEventListener(
|
||||
"load",
|
||||
(ev) => {
|
||||
const target = ev && ev.target;
|
||||
if (!target || target.tagName !== "IMG") return;
|
||||
scrollMessagesToBottom();
|
||||
},
|
||||
true,
|
||||
);
|
||||
const textarea = el("textarea", {
|
||||
class: "chat-composer__field",
|
||||
rows: "1",
|
||||
|
|
@ -1815,7 +1862,7 @@ async function renderChatUi() {
|
|||
generalist: true,
|
||||
ops: true,
|
||||
image: true,
|
||||
memory_curator: true,
|
||||
memory: true,
|
||||
};
|
||||
const modeSelect = el("select", {
|
||||
class: "input",
|
||||
|
|
@ -1837,7 +1884,7 @@ async function renderChatUi() {
|
|||
{ value: "generalist", text: t("chat.specialistGeneralist"), enabled: true },
|
||||
{ value: "ops", text: t("chat.specialistOps"), enabled: !!specialistFlags.ops },
|
||||
{ value: "image", text: t("chat.specialistImage"), enabled: !!specialistFlags.image },
|
||||
{ value: "memory_curator", text: t("chat.specialistMemoryCurator"), enabled: !!specialistFlags.memory_curator },
|
||||
{ value: "memory", text: t("chat.specialistMemory"), enabled: !!specialistFlags.memory },
|
||||
];
|
||||
for (const op of opts) {
|
||||
if (!op.enabled) continue;
|
||||
|
|
@ -1865,7 +1912,7 @@ async function renderChatUi() {
|
|||
applySpecialistOptions();
|
||||
};
|
||||
const _sp = String(localStorage.getItem(CHAT_SPECIALIST_PREF_KEY) || "generalist").toLowerCase();
|
||||
specialistSelect.value = ["generalist", "ops", "image", "memory_curator"].includes(_sp) ? _sp : "generalist";
|
||||
specialistSelect.value = ["generalist", "ops", "image", "memory"].includes(_sp) ? _sp : "generalist";
|
||||
await loadSpecialistFlags();
|
||||
specialistSelect.addEventListener("change", () => {
|
||||
localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist"));
|
||||
|
|
@ -1892,7 +1939,7 @@ async function renderChatUi() {
|
|||
const s = String((resp && resp.specialist) || "").toLowerCase();
|
||||
const mm = String((resp && resp.memory_mode) || "").toLowerCase();
|
||||
if (["comprehensive", "expert"].includes(m)) modeSelect.value = m;
|
||||
if (["generalist", "ops", "image", "memory_curator"].includes(s)) specialistSelect.value = s;
|
||||
if (["generalist", "ops", "image", "memory"].includes(s)) specialistSelect.value = s;
|
||||
if (["default", "store_only"].includes(mm)) localStorage.setItem(CHAT_MEMORY_MODE_KEY, mm);
|
||||
if (String(modeSelect.value || "").toLowerCase() !== "expert") {
|
||||
specialistSelect.value = "generalist";
|
||||
|
|
@ -2125,6 +2172,14 @@ async function renderChatUi() {
|
|||
showToolOutput = !showToolOutput;
|
||||
adminChatShowToolOutput = showToolOutput;
|
||||
syncToolToggleBtn();
|
||||
const isStreaming = composerShell.classList.contains("chat-composer-shell--busy");
|
||||
if (isStreaming) {
|
||||
// Avoid clearing active stream bubble mid-turn (loadMessagesForActive() resets messagesEl).
|
||||
statusBar.textContent = `${showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden")} · ${
|
||||
currentLang === "zh" ? "本轮结束后应用到历史消息" : "applies to history after current turn"
|
||||
}`;
|
||||
return;
|
||||
}
|
||||
statusBar.textContent = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden");
|
||||
loadMessagesForActive().catch(() => {});
|
||||
});
|
||||
|
|
@ -2315,8 +2370,11 @@ async function renderChatUi() {
|
|||
}
|
||||
|
||||
const loadMessagesForActive = async () => {
|
||||
messagesEl.innerHTML = "";
|
||||
loadMessagesForActive._rid = (loadMessagesForActive._rid || 0) + 1;
|
||||
const rid = loadMessagesForActive._rid;
|
||||
shouldFollowMessages = true;
|
||||
if (!activeId) {
|
||||
messagesEl.innerHTML = "";
|
||||
statusBar.textContent = sessions.length ? "" : t("chat.noSessions");
|
||||
if (!sessions.length) messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
|
||||
return;
|
||||
|
|
@ -2324,9 +2382,11 @@ async function renderChatUi() {
|
|||
statusBar.textContent = t("chat.loading");
|
||||
try {
|
||||
const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages`);
|
||||
if (rid !== loadMessagesForActive._rid) return;
|
||||
const msgs = Array.isArray(resp.messages) ? resp.messages : [];
|
||||
const renderRows = _buildRenderRows(msgs);
|
||||
const total = intOr(resp.message_count, msgs.length);
|
||||
messagesEl.innerHTML = "";
|
||||
if (total > msgs.length) {
|
||||
messagesEl.appendChild(
|
||||
el("div", {
|
||||
|
|
@ -2343,8 +2403,10 @@ async function renderChatUi() {
|
|||
}
|
||||
}
|
||||
statusBar.textContent = "";
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
scrollMessagesToBottom(true);
|
||||
} catch (e) {
|
||||
// Keep existing message list on reload failure (e.g. toggle reload races),
|
||||
// so users don't perceive "messages disappeared".
|
||||
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
|
||||
}
|
||||
};
|
||||
|
|
@ -2539,7 +2601,7 @@ async function renderChatUi() {
|
|||
this.ws = null;
|
||||
}
|
||||
}
|
||||
async sendSessionSend({ sessionId, text, attachments, interactionMode, specialist, memoryMode, signal, onEvent }) {
|
||||
async sendSessionSend({ sessionId, text, attachments, interactionMode, specialist, memoryMode, idempotencyKey, signal, onEvent }) {
|
||||
await this._openAndHandshake();
|
||||
const reqId = this._nextReqId();
|
||||
const req = {
|
||||
|
|
@ -2550,7 +2612,7 @@ async function renderChatUi() {
|
|||
sessionKey: String(sessionId || ""),
|
||||
message: String(text || ""),
|
||||
attachments: Array.isArray(attachments) ? attachments : [],
|
||||
idempotencyKey: `idem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
idempotencyKey: String(idempotencyKey || `idem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`),
|
||||
thinking: "default",
|
||||
interaction_mode: String(interactionMode || "comprehensive"),
|
||||
specialist: String(specialist || "generalist"),
|
||||
|
|
@ -2569,7 +2631,8 @@ async function renderChatUi() {
|
|||
msg.event === "session.message" ||
|
||||
msg.event === "agent.event" ||
|
||||
msg.event === "session.tool" ||
|
||||
msg.event === "session.marker") &&
|
||||
msg.event === "session.marker" ||
|
||||
msg.event === "session.turn_started") &&
|
||||
typeof onEvent === "function"
|
||||
) {
|
||||
const payload = msg.payload || {};
|
||||
|
|
@ -2602,6 +2665,7 @@ async function renderChatUi() {
|
|||
relay_ttl_turn_count: Number(p.relayTtlTurnCount || 0) || 0,
|
||||
relay_ttl_session_count: Number(p.relayTtlSessionCount || 0) || 0,
|
||||
relay_ttl_keep_count: Number(p.relayTtlKeepCount || 0) || 0,
|
||||
ttft: p.ttft && typeof p.ttft === "object" ? p.ttft : null,
|
||||
reply: String(p.reply || ""),
|
||||
};
|
||||
if (String(p.status || "") === "started") {
|
||||
|
|
@ -2646,6 +2710,7 @@ async function renderChatUi() {
|
|||
let currentStreamAbortController = null;
|
||||
let currentWsTransport = null;
|
||||
let currentAbortMeta = { sessionId: "", runId: "" };
|
||||
const WS_CHAT_SEND_TIMEOUT_MS = 60000;
|
||||
const isAbortError = (err) => {
|
||||
const name = String(err && err.name ? err.name : "");
|
||||
const msg = String(err && err.message ? err.message : err || "");
|
||||
|
|
@ -2664,8 +2729,9 @@ async function renderChatUi() {
|
|||
return m;
|
||||
};
|
||||
|
||||
const sendMessageStream = async (userText, attachmentPayload) => {
|
||||
const sendMessageStream = async (userText, attachmentPayload, turnId) => {
|
||||
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
|
||||
const turnStartedAtMs = Date.now();
|
||||
let streamBubble = null;
|
||||
let streamRow = null;
|
||||
let doneMeta = null;
|
||||
|
|
@ -2674,6 +2740,7 @@ async function renderChatUi() {
|
|||
let chatStreamSegments = [];
|
||||
let renderRafId = null;
|
||||
let renderPending = false;
|
||||
let transportUsed = "ws";
|
||||
let typingRafId = null;
|
||||
let typingTimerId = null;
|
||||
let streamStatusTimerId = null;
|
||||
|
|
@ -2682,7 +2749,41 @@ async function renderChatUi() {
|
|||
let streamDisplayShown = "";
|
||||
let toolSeq = 0;
|
||||
let hasRealStreamText = false;
|
||||
let sawWsChatEvent = false;
|
||||
let sawWsTerminalEvent = false;
|
||||
let markerState = { p: 0, e: false, ep: 0, t: 0, s: 0, k: 0, reclaimed: 0 };
|
||||
let turnAcceptedAtMs = null;
|
||||
let phaseRunningAtMs = null;
|
||||
let firstDeltaAtMs = null;
|
||||
const _numOrNull = (v) => {
|
||||
if (v === null || v === undefined) return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
const _sleep = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, Math.max(0, Number(ms) || 0));
|
||||
});
|
||||
const _recoverLatestAssistantFromHistory = async () => {
|
||||
const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages`);
|
||||
const msgs = Array.isArray(resp && resp.messages) ? resp.messages : [];
|
||||
if (!msgs.length) return false;
|
||||
let last = null;
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const m = msgs[i];
|
||||
if (String((m && m.role) || "").toLowerCase() === "assistant") {
|
||||
last = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!last) return false;
|
||||
const tsRaw = String((last && last.timestamp) || "");
|
||||
const tsMs = tsRaw ? Date.parse(tsRaw) : NaN;
|
||||
const fresh = Number.isFinite(tsMs) ? tsMs >= turnStartedAtMs - 1500 : true;
|
||||
if (!fresh) return false;
|
||||
await loadMessagesForActive();
|
||||
return true;
|
||||
};
|
||||
const ensureStreamBubble = () => {
|
||||
if (streamBubble) return streamBubble;
|
||||
const inner = el("div", { class: "chat-msg chat-msg--assistant" });
|
||||
|
|
@ -2726,7 +2827,10 @@ async function renderChatUi() {
|
|||
const statusEl = bubble.querySelector(".chat-msg__stream-status");
|
||||
if (!statusEl) return;
|
||||
const dots = ".".repeat((Math.floor(Date.now() / 400) % 3) + 1);
|
||||
statusEl.textContent = `${_statusLabel(streamStatusBase)}${dots}`;
|
||||
const baseLabel = _statusLabel(streamStatusBase);
|
||||
const elapsedMs = turnAcceptedAtMs ? Math.max(0, Date.now() - Number(turnAcceptedAtMs || 0)) : 0;
|
||||
const elapsedText = elapsedMs > 0 ? ` ${Math.round(elapsedMs / 100) / 10}s` : "";
|
||||
statusEl.textContent = `${baseLabel}${elapsedText}${dots}`;
|
||||
};
|
||||
const _startDynamicStreamStatus = () => {
|
||||
_setDynamicStreamStatus();
|
||||
|
|
@ -2839,7 +2943,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
}
|
||||
md.innerHTML = blocks.join("");
|
||||
}
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
scrollMessagesToBottom();
|
||||
};
|
||||
const renderStreamComposite = () => {
|
||||
if (renderPending) return;
|
||||
|
|
@ -2913,7 +3017,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
if (last) {
|
||||
if (streamRow && streamRow.parentNode) streamRow.remove();
|
||||
await appendMessageRow(messagesEl, last);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
scrollMessagesToBottom(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -2925,7 +3029,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
content: decodeEscapedNewlines(t0),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
scrollMessagesToBottom(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -2945,11 +3049,12 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
// Immediate assistant placeholder bubble with dynamic status.
|
||||
ensureStreamBubble();
|
||||
_startDynamicStreamStatus();
|
||||
doneMeta = await transport.sendSessionSend({
|
||||
const wsSendPromise = transport.sendSessionSend({
|
||||
sessionId: activeId,
|
||||
text: userText,
|
||||
attachments: attachmentPayload,
|
||||
interactionMode: String(modeSelect.value || "comprehensive"),
|
||||
idempotencyKey: String(turnId || ""),
|
||||
specialist:
|
||||
String(modeSelect.value || "comprehensive").toLowerCase() === "expert"
|
||||
? String(specialistSelect.value || "generalist")
|
||||
|
|
@ -2960,6 +3065,19 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
const eventName = String((frame && frame.event) || "");
|
||||
const payload = (frame && frame.payload) || {};
|
||||
if (eventName === "agent.event") {
|
||||
try {
|
||||
const stream = String((payload && payload.stream) || "");
|
||||
const data = (payload && payload.data) || {};
|
||||
const phase = String((data && data.phase) || "").toLowerCase();
|
||||
if (stream === "lifecycle") {
|
||||
if (phase === "start" && !turnAcceptedAtMs) {
|
||||
turnAcceptedAtMs = Date.now();
|
||||
}
|
||||
if (phase === "running" && !phaseRunningAtMs) {
|
||||
phaseRunningAtMs = Date.now();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
_setStreamStatusBase((payload && (payload.stream || payload.event || payload.type)) || "agent");
|
||||
return;
|
||||
}
|
||||
|
|
@ -2980,6 +3098,11 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
renderStreamComposite();
|
||||
return;
|
||||
}
|
||||
if (eventName === "session.turn_started") {
|
||||
turnAcceptedAtMs = Number(payload.acceptedAt || Date.now()) || Date.now();
|
||||
_setStreamStatusBase("start");
|
||||
return;
|
||||
}
|
||||
if (eventName === "session.tool") {
|
||||
_setStreamStatusBase("tool");
|
||||
// Keep strict order: tool card appears exactly where tool event arrives.
|
||||
|
|
@ -2989,12 +3112,14 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
return;
|
||||
}
|
||||
if (eventName !== "chat") return;
|
||||
sawWsChatEvent = true;
|
||||
const state = String(payload.state || "");
|
||||
if (state) _setStreamStatusBase(state);
|
||||
if (payload.runId && !chatRunId) chatRunId = String(payload.runId);
|
||||
if (chatRunId) currentAbortMeta = { sessionId: String(activeId || ""), runId: String(chatRunId || "") };
|
||||
if (chatRunId && payload.runId && String(payload.runId) !== chatRunId && state !== "final") return;
|
||||
if (state === "delta") {
|
||||
if (!firstDeltaAtMs) firstDeltaAtMs = Date.now();
|
||||
const next = extractWsAssistantText(payload.message);
|
||||
const d = String(payload.delta || "");
|
||||
if (d && !_isSilentReplyStream(d)) {
|
||||
|
|
@ -3011,6 +3136,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
return;
|
||||
}
|
||||
if (state === "final") {
|
||||
sawWsTerminalEvent = true;
|
||||
_stopDynamicStreamStatus();
|
||||
streamDisplayShown = streamDisplayTarget;
|
||||
const ok = await appendFinalAssistant(payload.message, chatStream || extractWsAssistantText(payload.message || {}));
|
||||
|
|
@ -3022,6 +3148,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
return;
|
||||
}
|
||||
if (state === "aborted") {
|
||||
sawWsTerminalEvent = true;
|
||||
_stopDynamicStreamStatus();
|
||||
streamDisplayShown = streamDisplayTarget;
|
||||
const ok = await appendFinalAssistant(payload.message, chatStream);
|
||||
|
|
@ -3032,6 +3159,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
return;
|
||||
}
|
||||
if (state === "error") {
|
||||
sawWsTerminalEvent = true;
|
||||
_stopDynamicStreamStatus();
|
||||
_markStreamTerminal("error", String(payload.errorMessage || "chat error"));
|
||||
chatStream = "";
|
||||
|
|
@ -3041,6 +3169,21 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
}
|
||||
},
|
||||
});
|
||||
doneMeta = await Promise.race([
|
||||
wsSendPromise,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`ws_send_timeout:${WS_CHAT_SEND_TIMEOUT_MS}`)), WS_CHAT_SEND_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if (doneMeta && typeof doneMeta === "object") doneMeta.__transport = "ws";
|
||||
if (doneMeta && typeof doneMeta === "object") {
|
||||
const startToRunning =
|
||||
turnAcceptedAtMs && phaseRunningAtMs ? Math.max(0, Number(phaseRunningAtMs) - Number(turnAcceptedAtMs)) : null;
|
||||
const runningToFirst =
|
||||
phaseRunningAtMs && firstDeltaAtMs ? Math.max(0, Number(firstDeltaAtMs) - Number(phaseRunningAtMs)) : null;
|
||||
doneMeta.__phase_start_to_running_ms = Number.isFinite(Number(startToRunning)) ? Number(startToRunning) : null;
|
||||
doneMeta.__phase_running_to_first_ms = Number.isFinite(Number(runningToFirst)) ? Number(runningToFirst) : null;
|
||||
}
|
||||
} finally {
|
||||
transport.close();
|
||||
if (currentWsTransport === transport) currentWsTransport = null;
|
||||
|
|
@ -3051,6 +3194,38 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
return doneMeta;
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return doneMeta;
|
||||
const emsg = String(e && e.message ? e.message : e || "").toLowerCase();
|
||||
const wsLikeFailure =
|
||||
emsg.includes("ws_") ||
|
||||
emsg.includes("websocket") ||
|
||||
emsg.includes("receive_failed") ||
|
||||
emsg.includes("closed") ||
|
||||
emsg.includes("timeout");
|
||||
if (wsLikeFailure) {
|
||||
const wsLikelyAlreadyProducedReply = sawWsTerminalEvent || hasRealStreamText || sawWsChatEvent;
|
||||
if (wsLikelyAlreadyProducedReply) {
|
||||
setTimeout(() => {
|
||||
loadMessagesForActive().catch(() => {});
|
||||
}, 350);
|
||||
return doneMeta;
|
||||
}
|
||||
// WS may time out/close while backend persists final message slightly later.
|
||||
// Poll history briefly before surfacing hard failure.
|
||||
try {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (await _recoverLatestAssistantFromHistory()) {
|
||||
statusBar.textContent =
|
||||
currentLang === "zh"
|
||||
? "WS流式中断,已从历史补齐本轮结果。"
|
||||
: "WS stream interrupted; recovered this turn from history.";
|
||||
return doneMeta || { __transport: "ws_history_recovered" };
|
||||
}
|
||||
await _sleep(500 + i * 350);
|
||||
}
|
||||
} catch (_) {
|
||||
// fall through to original error
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
if (typingRafId != null) {
|
||||
|
|
@ -3120,10 +3295,8 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
statusBar.textContent = t("chat.sending");
|
||||
let attPayload = null;
|
||||
if (filesSnapshot.length) {
|
||||
attPayload = [];
|
||||
for (const f of filesSnapshot) {
|
||||
attPayload.push(await fileToPayloadEntry(f));
|
||||
}
|
||||
statusBar.textContent = currentLang === "zh" ? "准备附件中…" : "Preparing attachments…";
|
||||
attPayload = await Promise.all(filesSnapshot.map((f) => fileToPayloadEntry(f)));
|
||||
pendingFiles = [];
|
||||
syncPendingUi();
|
||||
}
|
||||
|
|
@ -3154,15 +3327,18 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
}
|
||||
const userWrap = wrapUserMessage(innerUser, new Date().toISOString());
|
||||
messagesEl.appendChild(userWrap);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
scrollMessagesToBottom(true);
|
||||
fitComposerTextarea();
|
||||
const doneMeta = await sendMessageStream(userText, attPayload);
|
||||
const turnId = `idem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
statusBar.textContent = currentLang === "zh" ? "连接中…" : "Connecting…";
|
||||
const doneMeta = await sendMessageStream(userText, attPayload, turnId);
|
||||
const transportTag = (doneMeta && doneMeta.__transport) || "";
|
||||
const mRaw = String((doneMeta && doneMeta.interaction_mode) || "").toLowerCase();
|
||||
const m = interactionModeLabel(mRaw);
|
||||
const s = specialistLabel(
|
||||
mRaw === "expert"
|
||||
? (doneMeta && doneMeta.selected_specialist)
|
||||
: (doneMeta && doneMeta.requested_specialist),
|
||||
: (doneMeta && (doneMeta.manager_selected_specialist || doneMeta.selected_specialist || doneMeta.requested_specialist)),
|
||||
);
|
||||
const baseText = t("chat.execApplied", { mode: m, specialist: s });
|
||||
const memoryModeNow = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
|
||||
|
|
@ -3182,6 +3358,49 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
k: String(Number(doneMeta.relay_ttl_keep_count || 0) || 0),
|
||||
})}`
|
||||
: "";
|
||||
const ttftObj = doneMeta && doneMeta.ttft && typeof doneMeta.ttft === "object" ? doneMeta.ttft : null;
|
||||
const localStartToRunningRaw = doneMeta && doneMeta.__phase_start_to_running_ms;
|
||||
const localRunningToFirstRaw = doneMeta && doneMeta.__phase_running_to_first_ms;
|
||||
const localStartToRunning =
|
||||
typeof localStartToRunningRaw === "number"
|
||||
? localStartToRunningRaw
|
||||
: (() => {
|
||||
const n = Number(localStartToRunningRaw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
})();
|
||||
const localRunningToFirst =
|
||||
typeof localRunningToFirstRaw === "number"
|
||||
? localRunningToFirstRaw
|
||||
: (() => {
|
||||
const n = Number(localRunningToFirstRaw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
})();
|
||||
const segA2G = ttftObj && ttftObj.accepted_to_gateway_ms != null ? Number(ttftObj.accepted_to_gateway_ms) : null;
|
||||
const segG2M =
|
||||
ttftObj && ttftObj.gateway_to_model_start_ms != null
|
||||
? Number(ttftObj.gateway_to_model_start_ms)
|
||||
: (Number.isFinite(localStartToRunning) ? localStartToRunning : null);
|
||||
const segM2F =
|
||||
ttftObj && ttftObj.model_start_to_first_token_ms != null
|
||||
? Number(ttftObj.model_start_to_first_token_ms)
|
||||
: (Number.isFinite(localRunningToFirst) ? localRunningToFirst : null);
|
||||
const ttftTotal =
|
||||
ttftObj && ttftObj.accepted_to_first_token_ms != null
|
||||
? Number(ttftObj.accepted_to_first_token_ms)
|
||||
: (Number.isFinite(segG2M) && Number.isFinite(segM2F) ? Number(segG2M) + Number(segM2F) : null);
|
||||
const ttftText =
|
||||
Number.isFinite(ttftTotal) || Number.isFinite(segA2G) || Number.isFinite(segG2M) || Number.isFinite(segM2F)
|
||||
? ` · 阶段(A→G:${Number.isFinite(segA2G) ? `${segA2G}ms` : "-"} / G→M:${Number.isFinite(segG2M) ? `${segG2M}ms` : "-"} / M→F:${Number.isFinite(segM2F) ? `${segM2F}ms` : "-"})` +
|
||||
`${Number.isFinite(ttftTotal) ? ` · TTFT=${ttftTotal}ms` : ""}`
|
||||
: "";
|
||||
const phaseText =
|
||||
Number.isFinite(localStartToRunning)
|
||||
? (() => {
|
||||
return Number.isFinite(localRunningToFirst)
|
||||
? ` · 阶段耗时(启动→执行)=${localStartToRunning}ms (执行→首字)=${localRunningToFirst}ms`
|
||||
: ` · 阶段耗时(启动→执行)=${localStartToRunning}ms`;
|
||||
})()
|
||||
: "";
|
||||
const stats = await fetchDynamicExpertStats();
|
||||
if (stats) {
|
||||
let topReason = "-";
|
||||
|
|
@ -3205,10 +3424,10 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
const topReasonLabel = String((stats.reasonLabels && stats.reasonLabels[topReason]) || reasonLabel(topReason));
|
||||
const topReasonText = t("chat.dynamicStatsDetail", { rate: ratePct, reason: topReasonLabel });
|
||||
const topMixText = t("chat.dynamicTopReasons", { items: top5 || "-" });
|
||||
statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText} · ${t("chat.dynamicStats", { dynamic: stats.dynamic, fallback: stats.fallback })} · ${topReasonText} · ${topMixText} · ${t("chat.dynamicAllReasons")}`;
|
||||
statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText}${ttftText}${phaseText} · ${t("chat.dynamicStats", { dynamic: stats.dynamic, fallback: stats.fallback })} · ${topReasonText} · ${topMixText} · ${t("chat.dynamicAllReasons")}${transportTag ? ` · transport=${transportTag}` : ""}`;
|
||||
} else {
|
||||
_statusReasonPairs = [];
|
||||
statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText}`;
|
||||
statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText}${ttftText}${phaseText}${transportTag ? ` · transport=${transportTag}` : ""}`;
|
||||
}
|
||||
} catch (e) {
|
||||
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
|
||||
|
|
|
|||
|
|
@ -4,14 +4,47 @@
|
|||
<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"
|
||||
/>
|
||||
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
|
||||
<link rel="stylesheet" href="/admin/assets/styles.css" />
|
||||
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
|
||||
<style>
|
||||
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
|
||||
.admin-boot-splash {
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
line-height: 1.5;
|
||||
max-width: 520px;
|
||||
}
|
||||
.admin-boot-splash__title {
|
||||
font-weight: 650;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.admin-boot-splash__spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: rgba(255, 255, 255, 0.75);
|
||||
border-radius: 999px;
|
||||
animation: admin-boot-spin 1s linear infinite;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@keyframes admin-boot-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.admin-boot-splash__muted {
|
||||
opacity: 0.78;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="theme-ds-body">
|
||||
<div class="layout">
|
||||
|
|
@ -51,9 +84,21 @@
|
|||
<button id="btnRefresh" class="btn" data-i18n="action.refresh">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<section id="content" class="content"></section>
|
||||
<section id="content" class="content">
|
||||
<div id="admin-boot-splash" class="admin-boot-splash" role="status" aria-live="polite">
|
||||
<div class="admin-boot-splash__title">
|
||||
<span class="admin-boot-splash__spinner" aria-hidden="true"></span>
|
||||
<span>正在加载管理台…</span>
|
||||
</div>
|
||||
<div class="admin-boot-splash__muted">若长时间停留在此,请检查网络/代理是否拦截外部资源。</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/admin/assets/app.js"></script>
|
||||
<script
|
||||
defer
|
||||
src="/admin/assets/app.js?v=20260425-1"
|
||||
onerror="(function(){var s=document.getElementById('admin-boot-splash');if(s){s.querySelector('.admin-boot-splash__title span:last-child').textContent='无法加载 app.js';s.querySelector('.admin-boot-splash__muted').textContent='请确认 /admin/assets/app.js 可访问。';}})()"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,39 @@
|
|||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
|
||||
/* Local Outfit font (no external network dependency). */
|
||||
@font-face {
|
||||
font-family: "Outfit";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/Outfit-latin-500-600.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Outfit";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/Outfit-latinext-500-600.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Outfit";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/Outfit-latin-500-600.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Outfit";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/Outfit-latinext-500-600.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
|
||||
|
|
|
|||
|
|
@ -7,11 +7,9 @@ from .validation import error_shape
|
|||
|
||||
DEFAULT_AGENT_ID = "main"
|
||||
ALLOWED_FILE_NAMES = {
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"ROLE_SYSTEM.md",
|
||||
"HEARTBEAT.md",
|
||||
"BOOTSTRAP.md",
|
||||
"memory/README.md",
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
import threading
|
||||
|
||||
from fastapi import FastAPI, WebSocket
|
||||
from fastapi import Request
|
||||
|
|
@ -22,9 +24,11 @@ from oclaw.runtime.hooks_runtime import (
|
|||
trigger_hook_event,
|
||||
)
|
||||
from oclaw.runtime.skills import skill_runtime_diagnostics
|
||||
from oclaw.runtime.prompt_prebuild import run_runtime_prewarm
|
||||
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
|
||||
from oclaw.runtime.workspaces.experts import warm_expert_workspace_cache
|
||||
|
||||
|
||||
def _resolve_startup_workspace_dir(cfg: dict[str, Any]) -> str:
|
||||
|
|
@ -36,7 +40,7 @@ def _resolve_startup_workspace_dir(cfg: dict[str, Any]) -> str:
|
|||
return ws_text
|
||||
except Exception:
|
||||
pass
|
||||
return str((PROJECT_ROOT / "oclaw" / "runtime" / "assets" / "agent_workspaces" / "workspace-main").resolve())
|
||||
return str((PROJECT_ROOT / "oclaw" / "runtime" / "workspaces" / "main").resolve())
|
||||
|
||||
|
||||
def _resolve_startup_workspace_dirs(cfg: dict[str, Any]) -> list[tuple[str, str]]:
|
||||
|
|
@ -67,6 +71,76 @@ def _resolve_startup_workspace_dirs(cfg: dict[str, Any]) -> list[tuple[str, str]
|
|||
return [("default", _resolve_startup_workspace_dir(cfg))]
|
||||
|
||||
|
||||
def _relocate_root_scan_artifacts() -> None:
|
||||
"""Keep generated scan artifacts out of repo root.
|
||||
|
||||
Some helper scripts write scan snapshots to the current working directory.
|
||||
Normalize them into runtime/data/scan on gateway startup.
|
||||
"""
|
||||
root = PROJECT_ROOT.resolve()
|
||||
target_dir = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve()
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
patterns = ("history_entries_*.json", "state_scan_*.json")
|
||||
for pat in patterns:
|
||||
for src in root.glob(pat):
|
||||
try:
|
||||
if not src.is_file():
|
||||
continue
|
||||
dst = target_dir / src.name
|
||||
if dst.exists():
|
||||
# Keep newest artifact when both paths exist.
|
||||
src_mtime = src.stat().st_mtime
|
||||
dst_mtime = dst.stat().st_mtime
|
||||
if src_mtime <= dst_mtime:
|
||||
src.unlink(missing_ok=True)
|
||||
continue
|
||||
dst.unlink(missing_ok=True)
|
||||
shutil.move(str(src), str(dst))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
_PREWARM_SCHEDULER_LOCK = threading.Lock()
|
||||
_PREWARM_SCHEDULER_RUNNING = False
|
||||
|
||||
|
||||
def _prewarm_interval_seconds() -> int:
|
||||
raw = str(os.getenv("AIA_PREWARM_INTERVAL_SECONDS") or "").strip()
|
||||
if raw.isdigit():
|
||||
return max(60, min(int(raw), 86_400))
|
||||
return 600
|
||||
|
||||
|
||||
def _spawn_periodic_prewarm_loop() -> None:
|
||||
global _PREWARM_SCHEDULER_RUNNING
|
||||
with _PREWARM_SCHEDULER_LOCK:
|
||||
if _PREWARM_SCHEDULER_RUNNING:
|
||||
return
|
||||
_PREWARM_SCHEDULER_RUNNING = True
|
||||
|
||||
def _runner() -> None:
|
||||
global _PREWARM_SCHEDULER_RUNNING
|
||||
interval = _prewarm_interval_seconds()
|
||||
while True:
|
||||
try:
|
||||
out = run_runtime_prewarm(reason="scheduler")
|
||||
_ = out
|
||||
except Exception:
|
||||
pass
|
||||
time_to_sleep = max(30, interval)
|
||||
try:
|
||||
import time
|
||||
|
||||
time.sleep(time_to_sleep)
|
||||
except Exception:
|
||||
break
|
||||
with _PREWARM_SCHEDULER_LOCK:
|
||||
_PREWARM_SCHEDULER_RUNNING = False
|
||||
|
||||
th = threading.Thread(target=_runner, name="oclaw-prewarm-scheduler", daemon=True)
|
||||
th.start()
|
||||
|
||||
|
||||
def _run_startup_hooks(app: FastAPI) -> None:
|
||||
def _log_info(message: str) -> None:
|
||||
print(message)
|
||||
|
|
@ -113,6 +187,26 @@ def _run_startup_hooks(app: FastAPI) -> None:
|
|||
_log_info(f"[skills] root={diag.get('skills_root')} total={diag.get('skills_total')}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
warm_expert_workspace_cache()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
prewarm = run_runtime_prewarm(reason="startup")
|
||||
_log_info(
|
||||
"[startup-prebuild] "
|
||||
f"ok={bool(prewarm.get('ok'))} "
|
||||
f"elapsed_ms={int(prewarm.get('elapsed_ms') or 0)} "
|
||||
f"freeze={bool(((prewarm.get('freeze') or {}).get('frozen')))}"
|
||||
)
|
||||
except Exception as exc:
|
||||
_log_warn(f"[startup-prebuild] failed: {exc}")
|
||||
try:
|
||||
_spawn_periodic_prewarm_loop()
|
||||
_log_info(f"[startup-prebuild] scheduler_started interval_s={_prewarm_interval_seconds()}")
|
||||
except Exception as exc:
|
||||
_log_warn(f"[startup-prebuild] scheduler_start_failed: {exc}")
|
||||
_relocate_root_scan_artifacts()
|
||||
startup_targets = _resolve_startup_workspace_dirs(cfg)
|
||||
initialize_hooks_runtime(cfg=cfg, workspace_dir=startup_targets[0][1])
|
||||
for agent_id, ws_dir in startup_targets:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,33 @@ from __future__ import annotations
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
|
||||
def _ws_is_disconnected(conn: Any) -> bool:
|
||||
ws = getattr(conn, "ws", None)
|
||||
if ws is None:
|
||||
return True
|
||||
state = getattr(ws, "application_state", None)
|
||||
if state == WebSocketState.DISCONNECTED:
|
||||
return True
|
||||
state = getattr(ws, "client_state", None)
|
||||
if state == WebSocketState.DISCONNECTED:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _safe_send_text(conn: Any, text: str) -> None:
|
||||
if _ws_is_disconnected(conn):
|
||||
return
|
||||
try:
|
||||
await conn.ws.send_text(text)
|
||||
except Exception as e:
|
||||
msg = str(e or "")
|
||||
if "Unexpected ASGI message 'websocket.send'" in msg or "response already completed" in msg:
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
async def send_res(conn: Any, req_id: str, *, ok: bool, payload: Any | None = None, error: Any | None = None) -> None:
|
||||
frame: dict[str, Any] = {"type": "res", "id": str(req_id or "invalid"), "ok": bool(ok)}
|
||||
|
|
@ -10,15 +37,17 @@ async def send_res(conn: Any, req_id: str, *, ok: bool, payload: Any | None = No
|
|||
frame["payload"] = payload
|
||||
if error is not None:
|
||||
frame["error"] = error
|
||||
await conn.ws.send_text(json.dumps(frame, ensure_ascii=False))
|
||||
await _safe_send_text(conn, json.dumps(frame, ensure_ascii=False))
|
||||
|
||||
|
||||
async def send_event(conn: Any, event: str, payload: Any | None = None) -> None:
|
||||
if _ws_is_disconnected(conn):
|
||||
return
|
||||
conn.seq += 1
|
||||
frame: dict[str, Any] = {"type": "event", "event": str(event or "event"), "seq": int(conn.seq)}
|
||||
if payload is not None:
|
||||
frame["payload"] = payload
|
||||
await conn.ws.send_text(json.dumps(frame, ensure_ascii=False))
|
||||
await _safe_send_text(conn, json.dumps(frame, ensure_ascii=False))
|
||||
|
||||
|
||||
async def emit_agent_event(conn: Any, *, run_id: str, stream: str, data: dict[str, Any], now_ms: int) -> None:
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ def build_gateway_context(
|
|||
if not sid or not txt:
|
||||
return False
|
||||
rid = str(run_id or "").strip() or uuid.uuid4().hex
|
||||
try:
|
||||
existing = store.oclaw_run_get(run_id=rid)
|
||||
if existing is not None:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
with abort_lock:
|
||||
active_run_session[rid] = sid
|
||||
p = dict(params or {})
|
||||
|
|
@ -61,7 +67,11 @@ def build_gateway_context(
|
|||
"acp_child_run_id": str(p.get("acp_child_run_id") or ""),
|
||||
"runId": rid,
|
||||
}
|
||||
asyncio.create_task(run_agent_turn("server_methods.chat.send", agent_params, session_id=sid, send_response=False))
|
||||
async def _delayed() -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
await run_agent_turn("server_methods.chat.send", agent_params, session_id=sid, send_response=False)
|
||||
|
||||
asyncio.create_task(_delayed())
|
||||
return True
|
||||
|
||||
def _run_agent(params: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -89,7 +99,11 @@ def build_gateway_context(
|
|||
}
|
||||
with abort_lock:
|
||||
active_run_session[str(run_id)] = str(session_id)
|
||||
asyncio.create_task(run_agent_turn("server_methods.agent", agent_params, session_id=session_id, send_response=False))
|
||||
async def _delayed() -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
await run_agent_turn("server_methods.agent", agent_params, session_id=session_id, send_response=False)
|
||||
|
||||
asyncio.create_task(_delayed())
|
||||
return {"runId": run_id, "status": "started"}
|
||||
|
||||
def _enqueue_session_send(session_key: str, message: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
from oclaw.runtime.agents.factory import build_gateway_executor
|
||||
|
|
@ -24,6 +25,68 @@ async def run_agent_turn_via_bridge(
|
|||
now_ms: Callable[[], int],
|
||||
error_shape: Callable[[str, str], dict[str, Any]],
|
||||
) -> None:
|
||||
def _to_ms(v: Any) -> int | None:
|
||||
s = str(v or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
return int(datetime.fromisoformat(s).timestamp() * 1000)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _delta(a: int | None, b: int | None) -> int | None:
|
||||
if a is None or b is None:
|
||||
return None
|
||||
d = int(b - a)
|
||||
return d if d >= 0 else None
|
||||
|
||||
def _compute_ttft(trace_rows: list[dict[str, Any]], *, accepted_ms: int | None) -> dict[str, Any]:
|
||||
ws_accepted_ms = accepted_ms
|
||||
gateway_received_ms = None
|
||||
model_chat_start_ms = None
|
||||
ws_first_token_ms = None
|
||||
for row in trace_rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
et = str(row.get("event_type") or "").strip()
|
||||
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
|
||||
rts = _to_ms(row.get("timestamp"))
|
||||
if et == "gateway_received":
|
||||
gateway_received_ms = rts if rts is not None else gateway_received_ms
|
||||
if ws_accepted_ms is None:
|
||||
try:
|
||||
vv = payload.get("ws_client_send_ms")
|
||||
if vv is not None:
|
||||
ws_accepted_ms = int(vv)
|
||||
except Exception:
|
||||
pass
|
||||
elif et == "model_chat_start":
|
||||
model_chat_start_ms = rts if rts is not None else model_chat_start_ms
|
||||
elif et == "ws_first_token":
|
||||
if ws_first_token_ms is None:
|
||||
try:
|
||||
vf = payload.get("ws_first_token_ms")
|
||||
if vf is not None:
|
||||
ws_first_token_ms = int(vf)
|
||||
except Exception:
|
||||
pass
|
||||
if ws_first_token_ms is None:
|
||||
ws_first_token_ms = rts if rts is not None else ws_first_token_ms
|
||||
return {
|
||||
"ws_accepted_ms": ws_accepted_ms,
|
||||
"gateway_received_ms": gateway_received_ms,
|
||||
"model_chat_start_ms": model_chat_start_ms,
|
||||
"ws_first_token_ms": ws_first_token_ms,
|
||||
"accepted_to_gateway_ms": _delta(ws_accepted_ms, gateway_received_ms),
|
||||
"gateway_to_model_start_ms": _delta(gateway_received_ms, model_chat_start_ms),
|
||||
"model_start_to_first_token_ms": _delta(model_chat_start_ms, ws_first_token_ms),
|
||||
"gateway_to_first_token_ms": _delta(gateway_received_ms, ws_first_token_ms),
|
||||
"accepted_to_first_token_ms": _delta(ws_accepted_ms, ws_first_token_ms),
|
||||
}
|
||||
|
||||
accepted_ms = now_ms()
|
||||
msg_text = str(p.get("message") or "").strip()
|
||||
attachments = list(p.get("attachments") or [])
|
||||
store = SqliteStore(db_path())
|
||||
|
|
@ -66,6 +129,7 @@ async def run_agent_turn_via_bridge(
|
|||
|
||||
buf_lock = threading.Lock()
|
||||
token_chunks: list[str] = []
|
||||
first_token_ms_holder: dict[str, int | None] = {"ms": None}
|
||||
marker_turn_count = 0
|
||||
marker_session_count = 0
|
||||
marker_keep_count = 0
|
||||
|
|
@ -79,6 +143,8 @@ async def run_agent_turn_via_bridge(
|
|||
def on_token(tok: str) -> None:
|
||||
token_text = str(tok)
|
||||
rid = run_id_holder.get("run_id") or ""
|
||||
if first_token_ms_holder["ms"] is None and token_text:
|
||||
first_token_ms_holder["ms"] = now_ms()
|
||||
if rid:
|
||||
with conn._abort_lock:
|
||||
if rid in conn._aborted_run_ids:
|
||||
|
|
@ -129,49 +195,58 @@ async def run_agent_turn_via_bridge(
|
|||
else {},
|
||||
"acp_parent_run_id": str(p.get("acp_parent_run_id") or ""),
|
||||
"acp_child_run_id": str(p.get("acp_child_run_id") or ""),
|
||||
"ws_accepted_ms": int(accepted_ms),
|
||||
},
|
||||
)
|
||||
|
||||
await conn.emit_agent_event(run_id=run_id_holder["run_id"], stream="lifecycle", data={"phase": "start", "status": "accepted"})
|
||||
try:
|
||||
relay_env = p.get("relay_share_envelope") if isinstance(p.get("relay_share_envelope"), dict) else {}
|
||||
relay_pointers = 0
|
||||
relay_turn = 0
|
||||
relay_session = 0
|
||||
relay_keep = 0
|
||||
if isinstance(relay_env, dict):
|
||||
ad = relay_env.get("attachments") if isinstance(relay_env.get("attachments"), dict) else {}
|
||||
ps = ad.get("pointers") if isinstance(ad.get("pointers"), list) else []
|
||||
relay_pointers = len(ps)
|
||||
for item in ps:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
ttl = str(item.get("ttl_policy") or "").strip().lower()
|
||||
if ttl == "turn":
|
||||
relay_turn += 1
|
||||
elif ttl == "session":
|
||||
relay_session += 1
|
||||
elif ttl == "keep":
|
||||
relay_keep += 1
|
||||
marker_turn_count = int(relay_turn)
|
||||
marker_session_count = int(relay_session)
|
||||
marker_keep_count = int(relay_keep)
|
||||
await conn.send_event(
|
||||
"session.marker",
|
||||
{
|
||||
"runId": run_id_holder["run_id"],
|
||||
"sessionKey": str(session_id),
|
||||
"action": "ingress",
|
||||
"relayPointerCount": int(relay_pointers),
|
||||
"relayEnvelopePresent": bool(isinstance(relay_env, dict) and bool(relay_env)),
|
||||
"relayEnvelopePointerCount": int(relay_pointers),
|
||||
"relayTtlTurnCount": int(relay_turn),
|
||||
"relayTtlSessionCount": int(relay_session),
|
||||
"relayTtlKeepCount": int(relay_keep),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if send_response:
|
||||
try:
|
||||
await conn.send_event(
|
||||
"session.turn_started",
|
||||
{"runId": run_id_holder["run_id"], "sessionKey": str(session_id), "acceptedAt": int(accepted_ms)},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
relay_env = p.get("relay_share_envelope") if isinstance(p.get("relay_share_envelope"), dict) else {}
|
||||
relay_pointers = 0
|
||||
relay_turn = 0
|
||||
relay_session = 0
|
||||
relay_keep = 0
|
||||
if isinstance(relay_env, dict):
|
||||
ad = relay_env.get("attachments") if isinstance(relay_env.get("attachments"), dict) else {}
|
||||
ps = ad.get("pointers") if isinstance(ad.get("pointers"), list) else []
|
||||
relay_pointers = len(ps)
|
||||
for item in ps:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
ttl = str(item.get("ttl_policy") or "").strip().lower()
|
||||
if ttl == "turn":
|
||||
relay_turn += 1
|
||||
elif ttl == "session":
|
||||
relay_session += 1
|
||||
elif ttl == "keep":
|
||||
relay_keep += 1
|
||||
marker_turn_count = int(relay_turn)
|
||||
marker_session_count = int(relay_session)
|
||||
marker_keep_count = int(relay_keep)
|
||||
await conn.send_event(
|
||||
"session.marker",
|
||||
{
|
||||
"runId": run_id_holder["run_id"],
|
||||
"sessionKey": str(session_id),
|
||||
"action": "ingress",
|
||||
"relayPointerCount": int(relay_pointers),
|
||||
"relayEnvelopePresent": bool(isinstance(relay_env, dict) and bool(relay_env)),
|
||||
"relayEnvelopePointerCount": int(relay_pointers),
|
||||
"relayTtlTurnCount": int(relay_turn),
|
||||
"relayTtlSessionCount": int(relay_session),
|
||||
"relayTtlKeepCount": int(relay_keep),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _run_turn_sync() -> Any:
|
||||
return gw.handle_turn(
|
||||
|
|
@ -193,7 +268,8 @@ async def run_agent_turn_via_bridge(
|
|||
await conn.emit_agent_event(
|
||||
run_id=run_id_holder.get("run_id") or "",
|
||||
stream="lifecycle",
|
||||
data={"phase": "error", "status": "error", "error": str(exc or "agent_failed")},
|
||||
# Always emit a terminal lifecycle event even on failure.
|
||||
data={"phase": "end", "status": "error", "error": str(exc or "agent_failed")},
|
||||
)
|
||||
await conn.emit_chat_event(run_id=run_id_holder.get("run_id") or "", state="error", error=str(exc or "agent_failed"))
|
||||
try:
|
||||
|
|
@ -220,6 +296,29 @@ async def run_agent_turn_via_bridge(
|
|||
conn._aborted_run_ids.discard(_rid0)
|
||||
|
||||
rid = str(getattr(result, "run_id", "") or run_id_holder.get("run_id") or "")
|
||||
ttft_payload: dict[str, Any] | None = None
|
||||
try:
|
||||
ft = first_token_ms_holder.get("ms")
|
||||
tid = str(getattr(result, "trace_id", "") or "")
|
||||
if ft is not None and tid:
|
||||
store.add_trace_event(
|
||||
session_id=str(session_id),
|
||||
trace_id=tid,
|
||||
span_id=uuid.uuid4().hex,
|
||||
parent_span_id=None,
|
||||
event_type="ws_first_token",
|
||||
payload={"run_id": str(rid), "ws_first_token_ms": int(ft), "ws_accepted_ms": int(accepted_ms)},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
show_ttft = str(store.get_setting("AIA_CHAT_SHOW_TTFT_DEBUG") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
tid = str(getattr(result, "trace_id", "") or "")
|
||||
if show_ttft and tid:
|
||||
rows = store.list_trace_events_for_trace(session_id=str(session_id), trace_id=tid, limit=400)
|
||||
ttft_payload = _compute_ttft(rows, accepted_ms=int(accepted_ms))
|
||||
except Exception:
|
||||
ttft_payload = None
|
||||
with conn._abort_lock:
|
||||
if rid and rid in conn._aborted_run_ids:
|
||||
try:
|
||||
|
|
@ -262,6 +361,7 @@ async def run_agent_turn_via_bridge(
|
|||
"relayTtlTurnCount": int(getattr(result, "relay_ttl_turn_count", 0) or 0),
|
||||
"relayTtlSessionCount": int(getattr(result, "relay_ttl_session_count", 0) or 0),
|
||||
"relayTtlKeepCount": int(getattr(result, "relay_ttl_keep_count", 0) or 0),
|
||||
"ttft": ttft_payload if isinstance(ttft_payload, dict) else None,
|
||||
},
|
||||
)
|
||||
await conn.emit_agent_event(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue