mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 07:20:44 +08:00
Load the latest 80 messages first and fetch older pages on scroll-up via before_id, instead of pulling thousands of rows up front. Co-authored-by: Cursor <cursoragent@cursor.com>
2333 lines
99 KiB
Python
2333 lines
99 KiB
Python
"""Admin-mounted chat REST API (non-Streamlit). Uses same bearer auth as other /admin/api routes."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import queue
|
||
import threading
|
||
import os
|
||
import re
|
||
import time
|
||
from collections.abc import Callable, Iterator
|
||
from typing import Any
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Body, File, Header, HTTPException, Query, UploadFile
|
||
from fastapi.responses import Response, StreamingResponse
|
||
|
||
from runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
|
||
from runtime.agents.specialists import specialist_ids
|
||
from runtime.agents.factory import build_gateway_executor
|
||
from runtime.chat.agent import GenerationInterrupted
|
||
from svc.config.paths import db_path
|
||
from svc.files.attachment_assets import AttachmentAssetStore
|
||
from svc.files.file_attachments import (
|
||
DEFAULT_TABULAR_CELL_CHARS,
|
||
DEFAULT_TABULAR_COLUMNS,
|
||
DEFAULT_MAX_EXCEL_SHEETS,
|
||
DEFAULT_TABULAR_ROWS_READ,
|
||
clear_attachment_limits_cache,
|
||
)
|
||
from interfaces.ws.common import normalize_ws_attachments
|
||
from svc.files.session_export import export_session_json, export_session_markdown
|
||
from svc.persistence.sqlite_store import SqliteStore
|
||
from svc.persistence.assistant_store import get_assistant_store
|
||
from runtime.gateway import OclawGateway
|
||
from runtime.types import StandardMessage, normalize_interaction_mode, normalize_requested_specialist
|
||
from runtime.chat.history_tool_result_compact import compact_tool_results_in_session_history
|
||
from runtime.chat.persist_terminal_fallback import persist_assistant_text_if_turn_missing
|
||
|
||
|
||
def _oclaw_config_path() -> Path:
|
||
raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip()
|
||
if raw:
|
||
p = Path(raw)
|
||
return p if p.is_absolute() else p.resolve()
|
||
# repo_root/src/admin/chat_api.py -> repo_root
|
||
return Path(__file__).resolve().parents[2] / "oclaw.json"
|
||
|
||
|
||
def _wiki_root_from_config() -> Path | None:
|
||
cfg_path = _oclaw_config_path()
|
||
if not cfg_path.exists():
|
||
return None
|
||
try:
|
||
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return None
|
||
plugins = cfg.get("plugins") if isinstance(cfg, dict) else {}
|
||
entries = plugins.get("entries") if isinstance(plugins, dict) else {}
|
||
wiki_entry = entries.get("memory-wiki") if isinstance(entries, dict) else {}
|
||
root_cfg = str(wiki_entry.get("wiki_root") or "").strip()
|
||
if not root_cfg:
|
||
return None
|
||
root = Path(root_cfg)
|
||
if not root.is_absolute():
|
||
root = (Path(__file__).resolve().parents[2] / root).resolve()
|
||
return root
|
||
|
||
# Default window for chat UI / admin: recent messages only (scroll-up loads older via before_id).
|
||
_CHAT_MSG_LIMIT = 80
|
||
_SESSION_TITLE_MAX_LEN = 120
|
||
_AVATAR_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
||
_AVATAR_MIMES = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"})
|
||
_ATTACHMENT_ID_RE = re.compile(r"^[a-f0-9]{64}$")
|
||
_ATT_DOWNLOAD_BUCKET: dict[str, tuple[float, float]] = {}
|
||
_ATT_DOWNLOAD_LOCK = threading.Lock()
|
||
_ATT_DOWNLOAD_RATE_PER_SEC = 1.0 # 60/min
|
||
_ATT_DOWNLOAD_BURST = 20.0
|
||
|
||
_CHAT_STOP_EVENTS: dict[str, threading.Event] = {}
|
||
_CHAT_STOP_LOCK = threading.Lock()
|
||
_DISPATCH_REASON_LABELS: dict[str, dict[str, str]] = {
|
||
"manager_no_specialist_fallback": {
|
||
"zh": "无可用专家,回退通用",
|
||
"en": "No specialist; fallback to generalist",
|
||
},
|
||
"dynamic_agent_build_failed": {
|
||
"zh": "动态专家构建失败",
|
||
"en": "Dynamic agent build failed",
|
||
},
|
||
"manager_factory_failed": {
|
||
"zh": "专家执行器创建失败",
|
||
"en": "Specialist executor factory failed",
|
||
},
|
||
"manager_route_missing": {
|
||
"zh": "全能者路由缺失",
|
||
"en": "Manager route missing",
|
||
},
|
||
"manager_select_failed": {
|
||
"zh": "全能者决策失败",
|
||
"en": "Manager selection failed",
|
||
},
|
||
"manager_model_missing": {
|
||
"zh": "全能者模型不可用",
|
||
"en": "Manager model missing",
|
||
},
|
||
"dynamic_agent_selected": {
|
||
"zh": "动态专家命中",
|
||
"en": "Dynamic agent selected",
|
||
},
|
||
}
|
||
_DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
|
||
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
|
||
_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode."
|
||
_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist."
|
||
_CHANNEL_DISPATCH_LANG_KEY_PREFIX = "channel.dispatch.lang."
|
||
|
||
|
||
def _channel_dispatch_interaction_key(channel: str) -> str:
|
||
return f"{_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||
|
||
|
||
def _channel_dispatch_specialist_key(channel: str) -> str:
|
||
return f"{_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||
|
||
def _channel_dispatch_lang_key(channel: str) -> str:
|
||
return f"{_CHANNEL_DISPATCH_LANG_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||
|
||
|
||
def _normalize_channel_dispatch_lang(raw: Any) -> str:
|
||
v = str(raw or "").strip().lower()
|
||
if v in {"auto", "zh", "en"}:
|
||
return v
|
||
return "auto"
|
||
|
||
|
||
def _normalize_channel_dispatch_channel(raw: Any) -> str:
|
||
ch = str(raw or "").strip().lower()
|
||
if ch not in {"weixin", "whatsapp"}:
|
||
raise HTTPException(status_code=400, detail="invalid_channel")
|
||
return ch
|
||
def _chat_specialist_ids() -> tuple[str, ...]:
|
||
return tuple(str(x) for x in specialist_ids() if str(x).strip())
|
||
DEFAULT_TABULAR_SQL_TIMEOUT_MS = 8_000
|
||
DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS = 5_000
|
||
DEFAULT_TEXT_INLINE_MAX_CHARS = 12_000
|
||
DEFAULT_TEXT_CHUNK_SIZE = 1_600
|
||
DEFAULT_TEXT_CHUNK_OVERLAP = 200
|
||
DEFAULT_TEXT_QUERY_TOP_K = 5
|
||
DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS = 4_000
|
||
DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS = 4_000
|
||
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE = 1_600
|
||
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP = 200
|
||
|
||
|
||
def _safe_rel_avatar_name(name: str) -> str:
|
||
raw = (name or "").replace("\\", "/").split("/")[-1].strip()
|
||
return raw or "avatar.png"
|
||
|
||
|
||
def _api_lang(store: SqliteStore, *, user_text: str | None = None) -> str:
|
||
from runtime.lang import resolve_runtime_lang
|
||
|
||
return resolve_runtime_lang(store=store, user_text=user_text)
|
||
|
||
|
||
def _init_gateway_executor(
|
||
store: SqliteStore,
|
||
ctx: dict[str, Any],
|
||
*,
|
||
lang: str,
|
||
specialist: str | None,
|
||
policy_session_id: str | None,
|
||
) -> Any:
|
||
uid = str(ctx.get("user_id") or "").strip()
|
||
uname = str(ctx.get("username") or "").strip()
|
||
tid = str(ctx.get("tenant_id") or "").strip()
|
||
return build_gateway_executor(
|
||
store,
|
||
lang=lang,
|
||
specialist=specialist,
|
||
profile_id=None,
|
||
openai_api_key=None,
|
||
llm_mode=None,
|
||
model=None,
|
||
base_url=None,
|
||
viewer_user_id=uid or None,
|
||
viewer_username=uname or None,
|
||
viewer_tenant_id=tid or None,
|
||
policy_session_id=policy_session_id,
|
||
path_policy_tenant_id=tid or None,
|
||
path_policy_user_id=uid or None,
|
||
)
|
||
|
||
|
||
def _normalize_attachments_for_api(raw: Any) -> list[dict[str, Any]] | None:
|
||
"""DB 存 JSON 字符串;部分历史数据为单对象。统一成 list 便于前端直接使用。"""
|
||
if raw is None:
|
||
return None
|
||
if isinstance(raw, (bytes, bytearray)):
|
||
try:
|
||
raw = raw.decode("utf-8")
|
||
except Exception:
|
||
return None
|
||
if isinstance(raw, list):
|
||
return [x for x in raw if isinstance(x, dict)]
|
||
if isinstance(raw, dict):
|
||
return [raw]
|
||
if not isinstance(raw, str):
|
||
return None
|
||
s = raw.strip()
|
||
if not s or s == "null":
|
||
return None
|
||
try:
|
||
v = json.loads(s)
|
||
except Exception:
|
||
return None
|
||
if isinstance(v, list):
|
||
return [x for x in v if isinstance(x, dict)]
|
||
if isinstance(v, dict):
|
||
return [v]
|
||
return None
|
||
|
||
|
||
def _serialize_message(m: Any) -> dict[str, Any]:
|
||
return {
|
||
"id": int(getattr(m, "id", 0) or 0),
|
||
"session_id": str(getattr(m, "session_id", "") or ""),
|
||
"turn_uuid": str(getattr(m, "turn_uuid", "") or ""),
|
||
"event_type": str(getattr(m, "event_type", "") or ""),
|
||
"event_payload": _safe_json_object(getattr(m, "event_payload", None)),
|
||
"role": str(getattr(m, "role", "") or ""),
|
||
"content": str(getattr(m, "content", "") or ""),
|
||
"tool_calls": getattr(m, "tool_calls", None),
|
||
"attachments": _normalize_attachments_for_api(getattr(m, "attachments", None)),
|
||
"timestamp": str(getattr(m, "timestamp", "") or ""),
|
||
}
|
||
|
||
|
||
def _extract_manager_instruction_text(reasoning_content: str) -> str | None:
|
||
text = str(reasoning_content or "")
|
||
marker = "instruction:\n"
|
||
idx = text.find(marker)
|
||
if idx < 0:
|
||
return None
|
||
instr = text[idx + len(marker) :].strip()
|
||
return instr or None
|
||
|
||
|
||
def _instruction_from_dispatch_assignment_block(text: str) -> str | None:
|
||
"""Extract manager instruction only from **comprehensive dispatch** reasoning blocks.
|
||
|
||
Without this guard, any assistant ``reasoning_content`` that contains ``instruction:\\n`` followed
|
||
by the user's literal question (common in thinking traces) would register as a duplicate
|
||
``instruction_text`` and :func:`_filter_internal_instruction_user_messages` would drop the real
|
||
``user_text`` row — after a failed assistant persist the API could return **zero** messages on refresh.
|
||
"""
|
||
if not text or "instruction:\n" not in text:
|
||
return None
|
||
s = str(text)
|
||
tl = s.lower()
|
||
if "任务分配" not in s and "task assignment" not in tl:
|
||
return None
|
||
return _extract_manager_instruction_text(s)
|
||
|
||
|
||
def _filter_internal_instruction_user_messages(msgs: list[Any]) -> list[Any]:
|
||
"""Hide legacy polluted user rows that equal manager dispatch instruction text."""
|
||
instruction_texts: set[str] = set()
|
||
for m in msgs:
|
||
role = str(getattr(m, "role", "") or "")
|
||
if role != "assistant":
|
||
continue
|
||
event_type = str(getattr(m, "event_type", "") or "")
|
||
candidates: list[str] = []
|
||
if event_type == "reasoning":
|
||
candidates.append(str(getattr(m, "content", "") or ""))
|
||
ep = _safe_json_object(getattr(m, "event_payload", None))
|
||
rc = str(ep.get("reasoning_content") or "").strip()
|
||
if rc:
|
||
candidates.append(rc)
|
||
for text in candidates:
|
||
instr = _instruction_from_dispatch_assignment_block(text)
|
||
if instr:
|
||
instruction_texts.add(instr)
|
||
if not instruction_texts:
|
||
return msgs
|
||
filtered: list[Any] = []
|
||
for m in msgs:
|
||
role = str(getattr(m, "role", "") or "")
|
||
event_type = str(getattr(m, "event_type", "") or "")
|
||
content = str(getattr(m, "content", "") or "").strip()
|
||
if role == "user" and event_type == "user_text" and content in instruction_texts:
|
||
continue
|
||
filtered.append(m)
|
||
return filtered
|
||
|
||
|
||
def _register_stop(session_id: str) -> threading.Event:
|
||
with _CHAT_STOP_LOCK:
|
||
ev = threading.Event()
|
||
_CHAT_STOP_EVENTS[str(session_id)] = ev
|
||
return ev
|
||
|
||
|
||
def _clear_stop(session_id: str) -> None:
|
||
with _CHAT_STOP_LOCK:
|
||
_CHAT_STOP_EVENTS.pop(str(session_id), None)
|
||
|
||
|
||
def _parse_attachments_payload(raw: Any) -> list[dict[str, Any]] | None:
|
||
"""Match WebSocket :func:`normalize_ws_attachments`: base64 uploads plus pass-through typed refs."""
|
||
out = normalize_ws_attachments(raw)
|
||
return out if out else None
|
||
|
||
|
||
def _is_valid_attachment_id(raw: str) -> bool:
|
||
return bool(_ATTACHMENT_ID_RE.fullmatch(str(raw or "").strip().lower()))
|
||
|
||
|
||
def _can_access_attachment(store: SqliteStore, ctx: dict[str, Any], *, attachment_id: str) -> bool:
|
||
aid = str(attachment_id or "").strip().lower()
|
||
if not _is_valid_attachment_id(aid):
|
||
return False
|
||
tenant_id = str(ctx.get("tenant_id") or "").strip()
|
||
user_id = str(ctx.get("user_id") or "").strip()
|
||
if not tenant_id:
|
||
return False
|
||
if _is_administrator_chat_viewer(ctx):
|
||
if store.attachment_acl_allows_tenant(tenant_id=tenant_id, attachment_id=aid):
|
||
return True
|
||
if (not _attachment_acl_strict()) and store.attachment_referenced_in_tenant(tenant_id=tenant_id, attachment_id=aid):
|
||
return True
|
||
# Keep profile/avatar behavior compatible for administrator account.
|
||
admin = store.get_user_by_username(tenant_id=tenant_id, username="administrator") or {}
|
||
return str(admin.get("avatar_attachment_id") or "").strip().lower() == aid
|
||
if user_id and store.attachment_acl_allows_user(tenant_id=tenant_id, user_id=user_id, attachment_id=aid):
|
||
return True
|
||
if (not _attachment_acl_strict()) and user_id and store.attachment_referenced_by_user(tenant_id=tenant_id, user_id=user_id, attachment_id=aid):
|
||
return True
|
||
user = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id) if user_id else None
|
||
return str((user or {}).get("avatar_attachment_id") or "").strip().lower() == aid
|
||
|
||
|
||
def _rate_limit_attachment_download(*, actor_tenant_id: str, actor_user_id: str) -> bool:
|
||
tid = str(actor_tenant_id or "").strip()
|
||
uid = str(actor_user_id or "").strip()
|
||
# Require tenant; allow downloads when user id is unexpectedly empty (rate bucket is tenant-scoped).
|
||
if not tid:
|
||
return False
|
||
key = f"{tid}:{uid or '__actor__'}"
|
||
now = time.time()
|
||
with _ATT_DOWNLOAD_LOCK:
|
||
tokens, last = _ATT_DOWNLOAD_BUCKET.get(key, (_ATT_DOWNLOAD_BURST, now))
|
||
dt = max(0.0, now - float(last or now))
|
||
tokens = min(_ATT_DOWNLOAD_BURST, float(tokens) + dt * _ATT_DOWNLOAD_RATE_PER_SEC)
|
||
if tokens < 1.0:
|
||
_ATT_DOWNLOAD_BUCKET[key] = (tokens, now)
|
||
return False
|
||
tokens -= 1.0
|
||
_ATT_DOWNLOAD_BUCKET[key] = (tokens, now)
|
||
return True
|
||
|
||
|
||
def _attachment_acl_strict() -> bool:
|
||
raw = str(os.getenv("AIA_ATTACHMENT_ACL_STRICT") or "").strip().lower()
|
||
return raw in {"1", "true", "yes", "on"}
|
||
|
||
|
||
def _chat_username(ctx: dict[str, Any]) -> str:
|
||
return str(ctx.get("username") or "").strip().lower()
|
||
|
||
|
||
def _is_administrator_chat_viewer(ctx: dict[str, Any]) -> bool:
|
||
"""``administrator`` 账户:可跨租户列出/打开本人名下所有 ``ui_session_owner`` 会话(PG 迁移后历史可能在 smoke/Team 等多租户)。"""
|
||
return _chat_username(ctx) == "administrator"
|
||
|
||
|
||
def _require_administrator_chat_viewer(ctx: dict[str, Any]) -> None:
|
||
if not _is_administrator_chat_viewer(ctx):
|
||
raise HTTPException(status_code=403, detail="administrator_only")
|
||
|
||
|
||
def _resolve_chat_session(store: SqliteStore, ctx: dict[str, Any], session_id: str):
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
sid = str(session_id or "").strip()
|
||
if _is_administrator_chat_viewer(ctx):
|
||
sess = store.get_session_for_administrator_chat_view(
|
||
session_id=sid,
|
||
username=_chat_username(ctx),
|
||
tenant_id=tenant_id,
|
||
)
|
||
if sess is not None:
|
||
return sess
|
||
return store.get_session_in_tenant(session_id=sid, tenant_id=tenant_id)
|
||
return store.get_session_for_user(session_id=sid, tenant_id=tenant_id, user_id=user_id)
|
||
|
||
|
||
def _resolve_chat_session_allow_claim_orphan(
|
||
store: SqliteStore, ctx: dict[str, Any], session_id: str
|
||
):
|
||
"""Like :func:`_resolve_chat_session`, but if ``chat_session`` exists with **no** ``ui_session_owner`` row
|
||
(common after SQLite→PG imports that only copied ``chat_session`` / ``chat_message``), attach the current
|
||
user once so ``get_session_for_user`` JOIN succeeds. Does **not** override an existing owner (wrong user).
|
||
"""
|
||
resolve = _resolve_chat_session
|
||
sess = resolve(store, ctx, session_id)
|
||
if sess is not None:
|
||
return sess
|
||
if _is_administrator_chat_viewer(ctx):
|
||
return None
|
||
sid = str(session_id or "").strip()
|
||
tenant_id = str(ctx.get("tenant_id") or "").strip()
|
||
user_id = str(ctx.get("user_id") or "").strip()
|
||
if not sid or not tenant_id or not user_id:
|
||
return None
|
||
if store.get_session(sid) is None:
|
||
return None
|
||
if store.get_ui_session_owner(session_id=sid) is not None:
|
||
return None
|
||
store.ensure_ui_session_owner(session_id=sid, tenant_id=tenant_id, user_id=user_id)
|
||
return resolve(store, ctx, session_id)
|
||
|
||
|
||
def _effective_user_text(*, text: str, attachments: list[dict[str, Any]] | None, store: SqliteStore) -> str:
|
||
"""Streamlit 等价:仅有附件时也要落库一条用户消息,否则模型侧无输入。"""
|
||
t = (text or "").strip()
|
||
if t:
|
||
return t
|
||
if attachments:
|
||
return "(已上传附件)" if _api_lang(store) == "zh" else "(attachment uploaded)"
|
||
return ""
|
||
|
||
|
||
def _safe_json_object(raw: Any) -> dict[str, Any]:
|
||
if isinstance(raw, dict):
|
||
return raw
|
||
if isinstance(raw, str):
|
||
s = raw.strip()
|
||
if not s:
|
||
return {}
|
||
try:
|
||
obj = json.loads(s)
|
||
except Exception:
|
||
return {}
|
||
return obj if isinstance(obj, dict) else {}
|
||
return {}
|
||
|
||
|
||
def _normalize_chat_mode(payload: dict[str, Any] | None) -> tuple[str, str]:
|
||
body = payload or {}
|
||
# Backward compatibility:
|
||
# - chat_mode=composite|specialist
|
||
# - interaction_mode=comprehensive|expert
|
||
raw_interaction = str(body.get("interaction_mode") or "").strip()
|
||
if not raw_interaction:
|
||
legacy_mode = str(body.get("chat_mode") or "").strip().lower()
|
||
if legacy_mode == "specialist":
|
||
raw_interaction = "expert"
|
||
elif legacy_mode == "composite":
|
||
raw_interaction = "comprehensive"
|
||
mode = normalize_interaction_mode(raw_interaction)
|
||
specialist = normalize_requested_specialist(body.get("specialist"))
|
||
return mode, specialist
|
||
|
||
|
||
def _normalize_memory_mode(payload: dict[str, Any] | None) -> str:
|
||
body = payload or {}
|
||
raw = str(body.get("memory_mode") or "").strip().lower()
|
||
return raw if raw in {"default", "store_only"} else "default"
|
||
|
||
|
||
def _specialist_flags_with_overrides(store: SqliteStore) -> dict[str, bool]:
|
||
flags: dict[str, bool] = {sid: True for sid in _chat_specialist_ids()}
|
||
raw = str(store.get_setting(_SPECIALIST_FLAGS_SETTING_KEY) or "").strip()
|
||
if not raw:
|
||
return flags
|
||
try:
|
||
obj = json.loads(raw)
|
||
except Exception:
|
||
return flags
|
||
if not isinstance(obj, dict):
|
||
return flags
|
||
for sid in _chat_specialist_ids():
|
||
if sid in obj:
|
||
flags[sid] = bool(obj.get(sid))
|
||
# keep generalist always on for safety
|
||
flags["generalist"] = True
|
||
return flags
|
||
|
||
|
||
def _apply_specialist_flags(store: SqliteStore, specialist: str) -> str:
|
||
sid = str(specialist or "generalist").strip().lower() or "generalist"
|
||
flags = _specialist_flags_with_overrides(store)
|
||
if not flags.get(sid, True):
|
||
return "generalist"
|
||
return sid
|
||
|
||
|
||
def _dispatch_reason_label(reason: str, *, lang: str) -> str:
|
||
key = str(reason or "").strip()
|
||
if not key:
|
||
return "-"
|
||
row = _DISPATCH_REASON_LABELS.get(key)
|
||
if not isinstance(row, dict):
|
||
return key
|
||
l = "en" if str(lang or "").startswith("en") else "zh"
|
||
return str(row.get(l) or row.get("en") or key)
|
||
|
||
|
||
def _dispatch_reason_labels_with_overrides(store: SqliteStore) -> dict[str, dict[str, str]]:
|
||
out: dict[str, dict[str, str]] = dict(_DISPATCH_REASON_LABELS)
|
||
raw = str(store.get_setting(_DISPATCH_REASON_LABELS_SETTING_KEY) or "").strip()
|
||
if not raw:
|
||
return out
|
||
try:
|
||
obj = json.loads(raw)
|
||
except Exception:
|
||
return out
|
||
if not isinstance(obj, dict):
|
||
return out
|
||
for rk, rv in obj.items():
|
||
key = str(rk or "").strip()
|
||
if not key or not isinstance(rv, dict):
|
||
continue
|
||
zh = str(rv.get("zh") or "").strip()
|
||
en = str(rv.get("en") or "").strip()
|
||
if not zh and not en:
|
||
continue
|
||
base = out.get(key, {})
|
||
out[key] = {
|
||
"zh": zh or str(base.get("zh") or ""),
|
||
"en": en or str(base.get("en") or ""),
|
||
}
|
||
return out
|
||
|
||
|
||
def _dispatch_reason_label_from_map(reason: str, *, lang: str, labels: dict[str, dict[str, str]]) -> str:
|
||
key = str(reason or "").strip()
|
||
if not key:
|
||
return "-"
|
||
row = labels.get(key)
|
||
if not isinstance(row, dict):
|
||
return key
|
||
l = "en" if str(lang or "").startswith("en") else "zh"
|
||
return str(row.get(l) or row.get("en") or key)
|
||
|
||
|
||
def _deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
||
out: dict[str, Any] = dict(base or {})
|
||
for k, v in (patch or {}).items():
|
||
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
||
out[k] = _deep_merge_dict(dict(out.get(k) or {}), v)
|
||
else:
|
||
out[k] = v
|
||
return out
|
||
|
||
|
||
def _safe_int(raw: Any, default: int, *, min_value: int = 1, max_value: int = 2_000_000) -> int:
|
||
try:
|
||
value = int(raw)
|
||
except Exception:
|
||
return default
|
||
if value < min_value:
|
||
return default
|
||
return min(value, max_value)
|
||
|
||
|
||
def _safe_timeout_ms(raw: Any, default: int = DEFAULT_TABULAR_SQL_TIMEOUT_MS) -> int:
|
||
return _safe_int(raw, default, min_value=100, max_value=120_000)
|
||
|
||
|
||
def _tabular_limits_from_oclaw_config() -> dict[str, int]:
|
||
cfg_path = _oclaw_config_path()
|
||
obj: dict[str, Any] = {}
|
||
try:
|
||
if cfg_path.exists() and cfg_path.is_file():
|
||
loaded = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||
if isinstance(loaded, dict):
|
||
obj = loaded
|
||
except Exception:
|
||
obj = {}
|
||
tabular = (
|
||
(((obj.get("plugins") or {}).get("entries") or {}).get("memory-wiki") or {})
|
||
.get("auto", {})
|
||
.get("attachments", {})
|
||
.get("tabular", {})
|
||
)
|
||
if not isinstance(tabular, dict):
|
||
tabular = {}
|
||
return {
|
||
"max_rows_read": _safe_int(tabular.get("max_rows_read"), DEFAULT_TABULAR_ROWS_READ),
|
||
"max_columns": _safe_int(tabular.get("max_columns"), DEFAULT_TABULAR_COLUMNS),
|
||
"max_cell_chars": _safe_int(tabular.get("max_cell_chars"), DEFAULT_TABULAR_CELL_CHARS),
|
||
"max_excel_sheets": _safe_int(tabular.get("max_excel_sheets"), DEFAULT_MAX_EXCEL_SHEETS, max_value=500),
|
||
"large_table_preview_rows": _safe_int(tabular.get("large_table_preview_rows"), 20, max_value=500),
|
||
"tool_mode_enabled": bool(tabular.get("tool_mode_enabled", True)),
|
||
"tool_mode_min_rows": _safe_int(tabular.get("tool_mode_min_rows"), DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS),
|
||
"tool_mode_max_bytes": _safe_int(tabular.get("tool_mode_max_bytes"), 30 * 1024 * 1024),
|
||
"sql_timeout_ms": _safe_timeout_ms(tabular.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS),
|
||
"text_inline_max_chars": _safe_int(tabular.get("text_inline_max_chars"), DEFAULT_TEXT_INLINE_MAX_CHARS, max_value=200_000),
|
||
"text_chunk_size": _safe_int(tabular.get("text_chunk_size"), DEFAULT_TEXT_CHUNK_SIZE, max_value=8_000),
|
||
"text_chunk_overlap": _safe_int(tabular.get("text_chunk_overlap"), DEFAULT_TEXT_CHUNK_OVERLAP, max_value=4_000),
|
||
"text_query_top_k": _safe_int(tabular.get("text_query_top_k"), DEFAULT_TEXT_QUERY_TOP_K, max_value=50),
|
||
"image_result_replay_cap_chars": _safe_int(
|
||
tabular.get("image_result_replay_cap_chars"), DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
|
||
),
|
||
"video_result_replay_cap_chars": _safe_int(
|
||
tabular.get("video_result_replay_cap_chars"), DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
|
||
),
|
||
"video_transcript_chunk_size": _safe_int(
|
||
tabular.get("video_transcript_chunk_size"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE, max_value=8_000
|
||
),
|
||
"video_transcript_chunk_overlap": _safe_int(
|
||
tabular.get("video_transcript_chunk_overlap"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP, max_value=4_000
|
||
),
|
||
"archive_max_depth": _safe_int(tabular.get("archive_max_depth"), 2, max_value=10),
|
||
"archive_max_file_count": _safe_int(tabular.get("archive_max_file_count"), 200, max_value=20_000),
|
||
"archive_max_entry_bytes": _safe_int(tabular.get("archive_max_entry_bytes"), 10 * 1024 * 1024, max_value=2_000_000_000),
|
||
"archive_max_total_uncompressed_bytes": _safe_int(
|
||
tabular.get("archive_max_total_uncompressed_bytes"), 50 * 1024 * 1024, max_value=5_000_000_000
|
||
),
|
||
}
|
||
|
||
|
||
def _set_tabular_limits_into_oclaw_config(limits: dict[str, int]) -> dict[str, int]:
|
||
cfg_path = _oclaw_config_path()
|
||
cfg_obj: dict[str, Any] = {}
|
||
try:
|
||
if cfg_path.exists() and cfg_path.is_file():
|
||
loaded = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||
if isinstance(loaded, dict):
|
||
cfg_obj = loaded
|
||
except Exception:
|
||
cfg_obj = {}
|
||
patch = {
|
||
"plugins": {
|
||
"entries": {
|
||
"memory-wiki": {
|
||
"auto": {
|
||
"attachments": {
|
||
"tabular": {
|
||
"max_rows_read": int(limits.get("max_rows_read") or DEFAULT_TABULAR_ROWS_READ),
|
||
"max_columns": int(limits.get("max_columns") or DEFAULT_TABULAR_COLUMNS),
|
||
"max_cell_chars": int(limits.get("max_cell_chars") or DEFAULT_TABULAR_CELL_CHARS),
|
||
"max_excel_sheets": int(limits.get("max_excel_sheets") or DEFAULT_MAX_EXCEL_SHEETS),
|
||
"large_table_preview_rows": int(limits.get("large_table_preview_rows") or 20),
|
||
"tool_mode_enabled": bool(limits.get("tool_mode_enabled", True)),
|
||
"tool_mode_min_rows": int(limits.get("tool_mode_min_rows") or DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS),
|
||
"tool_mode_max_bytes": int(limits.get("tool_mode_max_bytes") or (30 * 1024 * 1024)),
|
||
"sql_timeout_ms": int(
|
||
_safe_timeout_ms(limits.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS)
|
||
),
|
||
"text_inline_max_chars": int(
|
||
_safe_int(limits.get("text_inline_max_chars"), DEFAULT_TEXT_INLINE_MAX_CHARS, max_value=200_000)
|
||
),
|
||
"text_chunk_size": int(
|
||
_safe_int(limits.get("text_chunk_size"), DEFAULT_TEXT_CHUNK_SIZE, max_value=8_000)
|
||
),
|
||
"text_chunk_overlap": int(
|
||
_safe_int(limits.get("text_chunk_overlap"), DEFAULT_TEXT_CHUNK_OVERLAP, max_value=4_000)
|
||
),
|
||
"text_query_top_k": int(
|
||
_safe_int(limits.get("text_query_top_k"), DEFAULT_TEXT_QUERY_TOP_K, max_value=50)
|
||
),
|
||
"image_result_replay_cap_chars": int(
|
||
_safe_int(
|
||
limits.get("image_result_replay_cap_chars"),
|
||
DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS,
|
||
min_value=600,
|
||
max_value=30_000,
|
||
)
|
||
),
|
||
"video_result_replay_cap_chars": int(
|
||
_safe_int(
|
||
limits.get("video_result_replay_cap_chars"),
|
||
DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS,
|
||
min_value=600,
|
||
max_value=30_000,
|
||
)
|
||
),
|
||
"video_transcript_chunk_size": int(
|
||
_safe_int(
|
||
limits.get("video_transcript_chunk_size"),
|
||
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE,
|
||
max_value=8_000,
|
||
)
|
||
),
|
||
"video_transcript_chunk_overlap": int(
|
||
_safe_int(
|
||
limits.get("video_transcript_chunk_overlap"),
|
||
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP,
|
||
max_value=4_000,
|
||
)
|
||
),
|
||
"archive_max_depth": int(_safe_int(limits.get("archive_max_depth"), 2, max_value=10)),
|
||
"archive_max_file_count": int(
|
||
_safe_int(limits.get("archive_max_file_count"), 200, max_value=20_000)
|
||
),
|
||
"archive_max_entry_bytes": int(
|
||
_safe_int(
|
||
limits.get("archive_max_entry_bytes"), 10 * 1024 * 1024, max_value=2_000_000_000
|
||
)
|
||
),
|
||
"archive_max_total_uncompressed_bytes": int(
|
||
_safe_int(
|
||
limits.get("archive_max_total_uncompressed_bytes"),
|
||
50 * 1024 * 1024,
|
||
max_value=5_000_000_000,
|
||
)
|
||
),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
merged = _deep_merge_dict(cfg_obj, patch)
|
||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||
cfg_path.write_text(json.dumps(merged, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
clear_attachment_limits_cache()
|
||
return _tabular_limits_from_oclaw_config()
|
||
|
||
|
||
def _chat_session_mode_setting_key(*, tenant_id: str, user_id: str, session_id: str, field: str) -> str:
|
||
return f"chat.session.mode.{tenant_id}.{user_id}.{session_id}.{field}"
|
||
|
||
|
||
def _chat_user_mode_setting_key(*, tenant_id: str, user_id: str, field: str) -> str:
|
||
return f"chat.user.mode.{tenant_id}.{user_id}.{field}"
|
||
|
||
|
||
def _normalize_execution_mode(payload: dict[str, Any] | None) -> str:
|
||
body = payload or {}
|
||
raw = str(body.get("execution_mode") or "").strip().lower()
|
||
return raw if raw in {"agent", "plan"} else "agent"
|
||
|
||
|
||
def _resolve_user_menu_chat_settings(
|
||
*,
|
||
store: SqliteStore,
|
||
tenant_id: str,
|
||
user_id: str,
|
||
) -> tuple[str, str]:
|
||
"""User-wide settings (⋯ menu): mode + specialist — all sessions share these keys."""
|
||
user_mode_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="interaction_mode")
|
||
user_specialist_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="specialist")
|
||
mode_raw = str(store.get_setting(user_mode_key) or "").strip()
|
||
specialist_raw = str(store.get_setting(user_specialist_key) or "").strip()
|
||
interaction_mode = normalize_interaction_mode(mode_raw or "expert")
|
||
specialist = normalize_requested_specialist(specialist_raw or "generalist")
|
||
specialist = _apply_specialist_flags(store, specialist)
|
||
return interaction_mode, specialist
|
||
|
||
|
||
def _persist_user_menu_chat_settings(
|
||
*,
|
||
store: SqliteStore,
|
||
tenant_id: str,
|
||
user_id: str,
|
||
interaction_mode: str,
|
||
specialist: str,
|
||
) -> None:
|
||
store.set_setting(
|
||
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="interaction_mode"),
|
||
interaction_mode,
|
||
)
|
||
store.set_setting(
|
||
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="specialist"),
|
||
specialist,
|
||
)
|
||
|
||
|
||
def _resolve_session_dialog_chat_settings(
|
||
*,
|
||
store: SqliteStore,
|
||
tenant_id: str,
|
||
user_id: str,
|
||
session_id: str,
|
||
) -> tuple[str, str]:
|
||
"""Per-session dialog only: memory_mode + execution_mode (session keys). Mode/specialist are user-global."""
|
||
session_memory_mode_key = _chat_session_mode_setting_key(
|
||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"
|
||
)
|
||
session_execution_mode_key = _chat_session_mode_setting_key(
|
||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="execution_mode"
|
||
)
|
||
memory_raw = str(store.get_setting(session_memory_mode_key) or "").strip()
|
||
execution_raw = str(store.get_setting(session_execution_mode_key) or "").strip()
|
||
memory_mode = _normalize_memory_mode({"memory_mode": (memory_raw or "default")})
|
||
execution_mode = _normalize_execution_mode({"execution_mode": (execution_raw or "agent")})
|
||
return memory_mode, execution_mode
|
||
|
||
|
||
def _persist_session_dialog_chat_settings(
|
||
*,
|
||
store: SqliteStore,
|
||
tenant_id: str,
|
||
user_id: str,
|
||
session_id: str,
|
||
memory_mode: str,
|
||
execution_mode: str,
|
||
) -> None:
|
||
store.set_setting(
|
||
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"),
|
||
memory_mode,
|
||
)
|
||
store.set_setting(
|
||
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="execution_mode"),
|
||
execution_mode,
|
||
)
|
||
|
||
|
||
def _resolve_mode_settings(
|
||
*,
|
||
store: SqliteStore,
|
||
tenant_id: str,
|
||
user_id: str,
|
||
session_id: str,
|
||
) -> tuple[str, str, str, str]:
|
||
"""User-wide mode/specialist + session memory/exec for gateway + REST send."""
|
||
u_im, u_sp = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id)
|
||
s_mm, s_em = _resolve_session_dialog_chat_settings(
|
||
store=store, tenant_id=tenant_id, user_id=user_id, session_id=str(session_id)
|
||
)
|
||
return u_im, u_sp, s_mm, s_em
|
||
|
||
|
||
def _seed_new_session_dialog_from_user_defaults(
|
||
*,
|
||
store: SqliteStore,
|
||
tenant_id: str,
|
||
user_id: str,
|
||
session_id: str,
|
||
) -> None:
|
||
"""New session: default memory + execution only (mode/specialist come from user menu at resolve time)."""
|
||
_persist_session_dialog_chat_settings(
|
||
store=store,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
memory_mode=_normalize_memory_mode({"memory_mode": "default"}),
|
||
execution_mode=_normalize_execution_mode({"execution_mode": "agent"}),
|
||
)
|
||
|
||
|
||
def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]]) -> None:
|
||
chat = APIRouter(prefix="/admin/api/chat", tags=["chat"])
|
||
|
||
@chat.get("/sessions")
|
||
def api_chat_sessions(
|
||
limit: int = Query(default=50, ge=1, le=200),
|
||
offset: int = Query(default=0, ge=0),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
if _is_administrator_chat_viewer(ctx):
|
||
uname = _chat_username(ctx)
|
||
meta = store.get_sessions_list_meta_for_administrator_chat_view(
|
||
username=uname, tenant_id=tenant_id
|
||
)
|
||
rows = store.list_sessions_for_administrator_chat_view(
|
||
username=uname, tenant_id=tenant_id, limit=limit, offset=offset
|
||
)
|
||
else:
|
||
meta = store.get_sessions_list_meta_for_user(tenant_id=tenant_id, user_id=user_id)
|
||
rows = store.list_sessions_for_user(
|
||
tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"total": int(meta.session_count or 0),
|
||
"sessions": [
|
||
{
|
||
"id": s.id,
|
||
"title": s.title,
|
||
"created_at": s.created_at,
|
||
"last_message_at": s.last_message_at,
|
||
}
|
||
for s in rows
|
||
],
|
||
}
|
||
|
||
@chat.post("/sessions")
|
||
def api_chat_create_session(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
title = str(payload.get("title") or "").strip() or ("新会话" if _api_lang(store) == "zh" else "New Chat")
|
||
s = store.create_session_for_user(title=title, tenant_id=tenant_id, user_id=user_id)
|
||
_seed_new_session_dialog_from_user_defaults(
|
||
store=store, tenant_id=tenant_id, user_id=user_id, session_id=str(s.id)
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"session": {
|
||
"id": s.id,
|
||
"title": s.title,
|
||
"created_at": s.created_at,
|
||
"last_message_at": s.last_message_at,
|
||
},
|
||
}
|
||
|
||
@chat.patch("/sessions/{session_id}")
|
||
def api_chat_rename_session(
|
||
session_id: str,
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
title = str(payload.get("title") or "").strip()[:_SESSION_TITLE_MAX_LEN] or (
|
||
"新会话" if _api_lang(store) == "zh" else "New Chat"
|
||
)
|
||
store.rename_session(session_id, title)
|
||
s = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
return {
|
||
"ok": True,
|
||
"session": {
|
||
"id": s.id,
|
||
"title": s.title,
|
||
"created_at": s.created_at,
|
||
"last_message_at": s.last_message_at,
|
||
},
|
||
}
|
||
|
||
@chat.delete("/sessions/{session_id}")
|
||
def api_chat_delete_session(
|
||
session_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
if _is_administrator_chat_viewer(ctx):
|
||
deleted = store.delete_session_for_administrator_chat_view(
|
||
session_id=session_id,
|
||
username=_chat_username(ctx),
|
||
tenant_id=tenant_id,
|
||
)
|
||
else:
|
||
deleted = store.delete_session_for_user(
|
||
session_id=session_id, tenant_id=tenant_id, user_id=user_id
|
||
)
|
||
if not deleted:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
if _is_administrator_chat_viewer(ctx):
|
||
remaining = store.list_sessions_for_administrator_chat_view(
|
||
username=_chat_username(ctx),
|
||
tenant_id=tenant_id,
|
||
limit=1,
|
||
offset=0,
|
||
)
|
||
else:
|
||
remaining = store.list_sessions_for_user(
|
||
tenant_id=tenant_id, user_id=user_id, limit=1, offset=0
|
||
)
|
||
next_id = str(remaining[0].id) if remaining else ""
|
||
if not next_id:
|
||
lang = _api_lang(store)
|
||
ns = store.create_session_for_user(
|
||
title=("新会话" if lang == "zh" else "New Chat"), tenant_id=tenant_id, user_id=user_id
|
||
)
|
||
next_id = str(ns.id)
|
||
_seed_new_session_dialog_from_user_defaults(
|
||
store=store, tenant_id=tenant_id, user_id=user_id, session_id=next_id
|
||
)
|
||
return {"ok": True, "next_session_id": next_id}
|
||
|
||
@chat.post("/sessions/{session_id}/fork")
|
||
def api_chat_fork_session(
|
||
session_id: str,
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
last_id = store.get_last_message_id(session_id)
|
||
if last_id is None:
|
||
raise HTTPException(status_code=400, detail="empty_session")
|
||
base_title = (sess.title or "")[:80] or session_id[:8]
|
||
dup_title = str(payload.get("title") or "").strip() or (
|
||
(f"{base_title} (copy)" if _api_lang(store) == "en" else f"{base_title} (副本)")
|
||
)[:_SESSION_TITLE_MAX_LEN]
|
||
try:
|
||
ns = store.fork_session(session_id, last_id, dup_title)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||
store.ensure_ui_session_owner(session_id=str(ns.id), tenant_id=tenant_id, user_id=user_id)
|
||
return {
|
||
"ok": True,
|
||
"session": {
|
||
"id": ns.id,
|
||
"title": ns.title,
|
||
"created_at": ns.created_at,
|
||
"last_message_at": ns.last_message_at,
|
||
},
|
||
}
|
||
|
||
@chat.get("/sessions/{session_id}/export")
|
||
def api_chat_export_session(
|
||
session_id: str,
|
||
format: str = Query(default="md", description="md or json"),
|
||
authorization: str | None = Header(default=None),
|
||
) -> Response:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
fmt = (format or "md").strip().lower()
|
||
if fmt in ("json", "application/json"):
|
||
body = export_session_json(store, session_id)
|
||
return Response(
|
||
content=body.encode("utf-8"),
|
||
media_type="application/json; charset=utf-8",
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="chat-{session_id[:8]}.json"',
|
||
},
|
||
)
|
||
body = export_session_markdown(store, session_id)
|
||
return Response(
|
||
content=body.encode("utf-8"),
|
||
media_type="text/markdown; charset=utf-8",
|
||
headers={"Content-Disposition": f'attachment; filename="chat-{session_id[:8]}.md"'},
|
||
)
|
||
|
||
@chat.get("/sessions/{session_id}/messages")
|
||
def api_chat_messages(
|
||
session_id: str,
|
||
limit: int = Query(default=_CHAT_MSG_LIMIT, ge=1, le=20000),
|
||
before_id: int | None = Query(default=None, ge=1),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
meta = store.get_session_messages_meta(session_id)
|
||
lim = int(limit)
|
||
if before_id is not None:
|
||
msgs = store.get_messages_before_id(
|
||
session_id=session_id, before_id=int(before_id), limit=lim
|
||
)
|
||
else:
|
||
msgs = store.get_messages(session_id=session_id, limit=lim)
|
||
msgs = _filter_internal_instruction_user_messages(msgs)
|
||
oldest_id = int(getattr(msgs[0], "id", 0) or 0) if msgs else 0
|
||
has_more = bool(oldest_id) and store.exists_message_before_id(
|
||
session_id=session_id, before_id=oldest_id
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"message_count": int(meta.message_count or 0),
|
||
"returned_count": len(msgs),
|
||
"has_more": has_more,
|
||
"before_id": int(before_id) if before_id is not None else None,
|
||
"messages": [_serialize_message(m) for m in msgs],
|
||
}
|
||
|
||
@chat.post("/sessions/{session_id}/compress-history")
|
||
def api_chat_compress_history(
|
||
session_id: str,
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
"""Rewrite overlarge tool_result history using the same replay-guard strategy.
|
||
|
||
This is intended for operators to clean up historical polluted sessions so UI/export
|
||
performance remains stable. It only touches `role=tool` chat_message rows.
|
||
"""
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
limit_messages = int(payload.get("limit_messages") or 5000)
|
||
raw_cap = payload.get("cap_chars")
|
||
cap_chars: int | None
|
||
try:
|
||
cap_chars = int(raw_cap) if raw_cap is not None else None
|
||
except Exception:
|
||
cap_chars = None
|
||
out = compact_tool_results_in_session_history(
|
||
store=store,
|
||
session_id=str(session_id),
|
||
cap_chars=cap_chars,
|
||
limit_messages=limit_messages,
|
||
)
|
||
return {
|
||
"ok": bool(out.ok),
|
||
"result": {
|
||
"session_id": out.session_id,
|
||
"scanned_tool_messages": int(out.scanned_tool_messages),
|
||
"compacted_tool_messages": int(out.compacted_tool_messages),
|
||
"rewritten_all_tool_messages": int(out.rewritten_all_tool_messages),
|
||
"skipped_already_guarded": int(out.skipped_already_guarded),
|
||
"max_original_chars_seen": int(out.max_original_chars_seen),
|
||
"cap_chars": int(out.cap_chars),
|
||
"detail": str(out.detail or ""),
|
||
},
|
||
}
|
||
|
||
@chat.delete("/sessions/{session_id}/messages/{message_id}")
|
||
def api_chat_delete_message(
|
||
session_id: str,
|
||
message_id: int,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
ok = store.delete_message(session_id=str(session_id), message_id=int(message_id))
|
||
if not ok:
|
||
raise HTTPException(status_code=404, detail="message_not_found")
|
||
return {"ok": True, "deleted": 1}
|
||
|
||
@chat.get("/sessions/{session_id}/wiki-events")
|
||
def api_chat_wiki_events(
|
||
session_id: str,
|
||
after: str | None = Query(default=None, description="Return events with finished_at > after (ISO)."),
|
||
limit: int = Query(default=20, ge=1, le=200),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
after_iso = str(after or "").strip()
|
||
rows = store.oclaw_task_list(tenant_id=tenant_id, session_id=str(session_id), limit=max(50, int(limit) * 5))
|
||
events: list[dict[str, Any]] = []
|
||
for t in rows:
|
||
if str(t.task_type or "") != "wiki_capture":
|
||
continue
|
||
if str(t.status or "") not in {"done", "failed"}:
|
||
continue
|
||
fin = str(t.finished_at or "").strip()
|
||
if not fin:
|
||
continue
|
||
if after_iso and fin <= after_iso:
|
||
continue
|
||
try:
|
||
result_obj = json.loads(str(t.result or "{}"))
|
||
except Exception:
|
||
result_obj = {}
|
||
events.append(
|
||
{
|
||
"task_id": str(t.id),
|
||
"status": str(t.status),
|
||
"finished_at": fin,
|
||
"ok": bool(isinstance(result_obj, dict) and result_obj.get("ok", False)),
|
||
"result": result_obj if isinstance(result_obj, dict) else {},
|
||
}
|
||
)
|
||
events.sort(key=lambda e: str(e.get("finished_at") or ""))
|
||
return {
|
||
"ok": True,
|
||
"session_id": str(session_id),
|
||
"viewer_user_id": str(user_id),
|
||
"events": events[-max(1, min(int(limit), 200)) :],
|
||
}
|
||
|
||
@chat.get("/sessions/{session_id}/wiki-file")
|
||
def api_chat_wiki_file(
|
||
session_id: str,
|
||
path: str = Query(..., description="Relative wiki path, e.g. inbox/merged-turns.md"),
|
||
max_chars: int = Query(default=80_000, ge=1_000, le=200_000),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
_ = str(ctx.get("user_id") or "")
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
wiki_root = _wiki_root_from_config()
|
||
if wiki_root is None:
|
||
raise HTTPException(status_code=404, detail="wiki_root_missing")
|
||
rel = str(path or "").replace("\\", "/").strip().lstrip("/")
|
||
if not rel or ".." in rel.split("/"):
|
||
raise HTTPException(status_code=400, detail="invalid_path")
|
||
fp = (wiki_root / rel).resolve()
|
||
try:
|
||
fp.relative_to(wiki_root.resolve())
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="path_outside_wiki_root") from None
|
||
if not fp.exists() or not fp.is_file():
|
||
raise HTTPException(status_code=404, detail="file_not_found")
|
||
try:
|
||
content = fp.read_text(encoding="utf-8")
|
||
except Exception:
|
||
raise HTTPException(status_code=500, detail="read_failed") from None
|
||
return {
|
||
"ok": True,
|
||
"tenant_id": str(tenant_id),
|
||
"session_id": str(session_id),
|
||
"path": rel,
|
||
"truncated": len(content) > int(max_chars),
|
||
"content": content[: int(max_chars)],
|
||
}
|
||
|
||
@chat.get("/sessions/{session_id}/mode")
|
||
def api_chat_session_mode_get(
|
||
session_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
s_mm, s_em = _resolve_session_dialog_chat_settings(
|
||
store=store, tenant_id=tenant_id, user_id=user_id, session_id=str(session_id)
|
||
)
|
||
u_im, u_sp = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id)
|
||
return {
|
||
"ok": True,
|
||
"interaction_mode": u_im,
|
||
"specialist": u_sp,
|
||
"memory_mode": s_mm,
|
||
"execution_mode": s_em,
|
||
"global_menu": {
|
||
"interaction_mode": u_im,
|
||
"specialist": u_sp,
|
||
},
|
||
}
|
||
|
||
@chat.post("/sessions/{session_id}/mode")
|
||
def api_chat_session_mode_set(
|
||
session_id: str,
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
memory_mode = _normalize_memory_mode(payload)
|
||
execution_mode = _normalize_execution_mode(payload)
|
||
_persist_session_dialog_chat_settings(
|
||
store=store,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
memory_mode=memory_mode,
|
||
execution_mode=execution_mode,
|
||
)
|
||
s_mm, s_em = _resolve_session_dialog_chat_settings(
|
||
store=store, tenant_id=tenant_id, user_id=user_id, session_id=str(session_id)
|
||
)
|
||
u_im, u_sp = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id)
|
||
return {
|
||
"ok": True,
|
||
"interaction_mode": u_im,
|
||
"specialist": u_sp,
|
||
"memory_mode": s_mm,
|
||
"execution_mode": s_em,
|
||
"global_menu": {
|
||
"interaction_mode": u_im,
|
||
"specialist": u_sp,
|
||
},
|
||
}
|
||
|
||
@chat.get("/user-mode")
|
||
def api_chat_user_mode_get(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
u_im, u_sp = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id)
|
||
return {
|
||
"ok": True,
|
||
"interaction_mode": u_im,
|
||
"specialist": u_sp,
|
||
}
|
||
|
||
@chat.post("/user-mode")
|
||
def api_chat_user_mode_set(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
interaction_mode = normalize_interaction_mode(payload.get("interaction_mode"))
|
||
specialist = normalize_requested_specialist(payload.get("specialist"))
|
||
specialist = _apply_specialist_flags(store, specialist)
|
||
_persist_user_menu_chat_settings(
|
||
store=store,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
interaction_mode=interaction_mode,
|
||
specialist=specialist,
|
||
)
|
||
u_im, u_sp = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id)
|
||
return {
|
||
"ok": True,
|
||
"interaction_mode": u_im,
|
||
"specialist": u_sp,
|
||
}
|
||
|
||
@chat.get("/admin/user-stats")
|
||
def api_chat_admin_user_stats(
|
||
q: str | None = Query(default=None),
|
||
limit: int = Query(default=100, ge=1, le=500),
|
||
offset: int = Query(default=0, ge=0),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
total, users, totals = store.list_admin_user_stats(
|
||
tenant_id=tenant_id,
|
||
q=q,
|
||
active_window_minutes=30,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
return {"ok": True, "total": int(total), "totals": totals, "users": users}
|
||
|
||
@chat.get("/admin/sessions")
|
||
def api_chat_admin_sessions(
|
||
user_id: str | None = Query(default=None),
|
||
q: str | None = Query(default=None),
|
||
active_only: bool = Query(default=False),
|
||
limit: int = Query(default=100, ge=1, le=500),
|
||
offset: int = Query(default=0, ge=0),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
total, rows = store.list_admin_sessions(
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
q=q,
|
||
active_only=bool(active_only),
|
||
active_window_minutes=30,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
return {"ok": True, "total": int(total), "sessions": rows}
|
||
|
||
@chat.get("/admin/dynamic-expert-stats")
|
||
def api_chat_admin_dynamic_expert_stats(
|
||
limit: int = Query(default=200, ge=20, le=1000),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
lang = _api_lang(store)
|
||
labels = _dispatch_reason_labels_with_overrides(store)
|
||
_require_administrator_chat_viewer(ctx)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
rows = store.oclaw_task_list(tenant_id=tenant_id, limit=limit)
|
||
total = len(rows)
|
||
dynamic_used = 0
|
||
fallback_generalist = 0
|
||
reasons: dict[str, int] = {}
|
||
specialist_counts: dict[str, int] = {}
|
||
for t in rows:
|
||
payload = _safe_json_object(t.payload)
|
||
if str(payload.get("interaction_mode") or "") != "comprehensive":
|
||
continue
|
||
reason = str(payload.get("dispatch_reason") or "").strip() or "unknown"
|
||
reasons[reason] = int(reasons.get(reason, 0)) + 1
|
||
sel = str(payload.get("manager_selected_specialist") or "generalist").strip() or "generalist"
|
||
specialist_counts[sel] = int(specialist_counts.get(sel, 0)) + 1
|
||
if bool(payload.get("dynamic_agent_used")):
|
||
dynamic_used += 1
|
||
if reason in {"manager_no_specialist_fallback", "dynamic_agent_build_failed"}:
|
||
fallback_generalist += 1
|
||
return {
|
||
"ok": True,
|
||
"window_task_count": int(total),
|
||
"dynamic_used_count": int(dynamic_used),
|
||
"fallback_generalist_count": int(fallback_generalist),
|
||
"dynamic_used_rate": (float(dynamic_used) / float(total)) if total > 0 else 0.0,
|
||
"dispatch_reasons": reasons,
|
||
"dispatch_reason_labels": {k: _dispatch_reason_label_from_map(k, lang=lang, labels=labels) for k in reasons.keys()},
|
||
"selected_specialists": specialist_counts,
|
||
}
|
||
|
||
@chat.get("/settings/ui-lang")
|
||
def api_chat_get_ui_lang(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
_ = resolve_auth(store, authorization)
|
||
return {"ok": True, "lang": _api_lang(store)}
|
||
|
||
@chat.post("/settings/ui-lang")
|
||
def api_chat_set_ui_lang(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
_ = resolve_auth(store, authorization)
|
||
lang = str(payload.get("lang") or "").strip().lower()
|
||
if lang not in ("zh", "en"):
|
||
raise HTTPException(status_code=400, detail="invalid_lang")
|
||
store.set_setting("ui_lang", lang)
|
||
return {"ok": True, "lang": lang}
|
||
|
||
@chat.get("/settings/dispatch-reason-labels")
|
||
def api_chat_get_dispatch_reason_labels(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
raw = str(store.get_setting(_DISPATCH_REASON_LABELS_SETTING_KEY) or "").strip()
|
||
overrides: dict[str, Any] = {}
|
||
if raw:
|
||
try:
|
||
obj = json.loads(raw)
|
||
if isinstance(obj, dict):
|
||
overrides = obj
|
||
except Exception:
|
||
overrides = {}
|
||
return {
|
||
"ok": True,
|
||
"setting_key": _DISPATCH_REASON_LABELS_SETTING_KEY,
|
||
"overrides": overrides,
|
||
"effective": _dispatch_reason_labels_with_overrides(store),
|
||
}
|
||
|
||
@chat.post("/settings/dispatch-reason-labels")
|
||
def api_chat_set_dispatch_reason_labels(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
body = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
overrides = body.get("overrides")
|
||
if overrides is None:
|
||
store.set_setting(_DISPATCH_REASON_LABELS_SETTING_KEY, "")
|
||
return {"ok": True, "cleared": True, "effective": _dispatch_reason_labels_with_overrides(store)}
|
||
if not isinstance(overrides, dict):
|
||
raise HTTPException(status_code=400, detail="invalid_overrides")
|
||
clean: dict[str, dict[str, str]] = {}
|
||
for rk, rv in overrides.items():
|
||
reason = str(rk or "").strip()
|
||
if not reason or not isinstance(rv, dict):
|
||
continue
|
||
zh = str(rv.get("zh") or "").strip()
|
||
en = str(rv.get("en") or "").strip()
|
||
if not zh and not en:
|
||
continue
|
||
clean[reason] = {"zh": zh, "en": en}
|
||
store.set_setting(_DISPATCH_REASON_LABELS_SETTING_KEY, json.dumps(clean, ensure_ascii=False))
|
||
return {"ok": True, "saved": clean, "effective": _dispatch_reason_labels_with_overrides(store)}
|
||
|
||
@chat.get("/settings/specialist-flags")
|
||
def api_chat_get_specialist_flags(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
flags = _specialist_flags_with_overrides(store)
|
||
available = [sid for sid in _chat_specialist_ids() if bool(flags.get(sid, True))]
|
||
return {
|
||
"ok": True,
|
||
"setting_key": _SPECIALIST_FLAGS_SETTING_KEY,
|
||
"flags": flags,
|
||
"available_specialists": available,
|
||
}
|
||
|
||
@chat.post("/settings/specialist-flags")
|
||
def api_chat_set_specialist_flags(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
body = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
raw_flags = body.get("flags")
|
||
if raw_flags is None:
|
||
store.set_setting(_SPECIALIST_FLAGS_SETTING_KEY, "")
|
||
flags = _specialist_flags_with_overrides(store)
|
||
return {"ok": True, "cleared": True, "flags": flags}
|
||
if not isinstance(raw_flags, dict):
|
||
raise HTTPException(status_code=400, detail="invalid_flags")
|
||
clean: dict[str, bool] = {}
|
||
for sid in _chat_specialist_ids():
|
||
if sid in raw_flags:
|
||
clean[sid] = bool(raw_flags.get(sid))
|
||
clean["generalist"] = True
|
||
store.set_setting(_SPECIALIST_FLAGS_SETTING_KEY, json.dumps(clean, ensure_ascii=False))
|
||
flags = _specialist_flags_with_overrides(store)
|
||
return {"ok": True, "saved": clean, "flags": flags}
|
||
|
||
@chat.get("/settings/channel-dispatch/{channel}")
|
||
def api_chat_get_channel_dispatch(
|
||
channel: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
ch = _normalize_channel_dispatch_channel(channel)
|
||
default_lang = "en" if ch == "whatsapp" else "auto"
|
||
interaction_mode = normalize_interaction_mode(
|
||
store.get_setting(_channel_dispatch_interaction_key(ch)) or "expert"
|
||
)
|
||
specialist = normalize_requested_specialist(
|
||
store.get_setting(_channel_dispatch_specialist_key(ch)) or "generalist"
|
||
)
|
||
lang = _normalize_channel_dispatch_lang(
|
||
store.get_setting(_channel_dispatch_lang_key(ch)) or default_lang
|
||
)
|
||
specialist = _apply_specialist_flags(store, specialist)
|
||
return {
|
||
"ok": True,
|
||
"channel": ch,
|
||
"interaction_mode": interaction_mode,
|
||
"specialist": specialist,
|
||
"lang": lang,
|
||
"available_specialists": [sid for sid in _chat_specialist_ids() if bool(_specialist_flags_with_overrides(store).get(sid, True))],
|
||
}
|
||
|
||
@chat.post("/settings/channel-dispatch/{channel}")
|
||
def api_chat_set_channel_dispatch(
|
||
channel: str,
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
body = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
ch = _normalize_channel_dispatch_channel(channel)
|
||
interaction_mode = normalize_interaction_mode(body.get("interaction_mode") or "expert")
|
||
specialist = normalize_requested_specialist(body.get("specialist") or "generalist")
|
||
lang = _normalize_channel_dispatch_lang(body.get("lang") or "auto")
|
||
specialist = _apply_specialist_flags(store, specialist)
|
||
store.set_setting(_channel_dispatch_interaction_key(ch), interaction_mode)
|
||
store.set_setting(_channel_dispatch_specialist_key(ch), specialist)
|
||
store.set_setting(_channel_dispatch_lang_key(ch), lang)
|
||
return {
|
||
"ok": True,
|
||
"channel": ch,
|
||
"interaction_mode": interaction_mode,
|
||
"specialist": specialist,
|
||
"lang": lang,
|
||
}
|
||
|
||
@chat.get("/settings/attachment-limits")
|
||
def api_chat_get_attachment_limits(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
limits = _tabular_limits_from_oclaw_config()
|
||
return {"ok": True, "limits": limits}
|
||
|
||
@chat.post("/settings/attachment-limits")
|
||
def api_chat_set_attachment_limits(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
body = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
raw = body.get("limits")
|
||
if raw is None:
|
||
limits = {
|
||
"max_rows_read": DEFAULT_TABULAR_ROWS_READ,
|
||
"max_columns": DEFAULT_TABULAR_COLUMNS,
|
||
"max_cell_chars": DEFAULT_TABULAR_CELL_CHARS,
|
||
"max_excel_sheets": DEFAULT_MAX_EXCEL_SHEETS,
|
||
"large_table_preview_rows": 20,
|
||
"tool_mode_enabled": True,
|
||
"tool_mode_min_rows": DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS,
|
||
"tool_mode_max_bytes": 30 * 1024 * 1024,
|
||
"sql_timeout_ms": DEFAULT_TABULAR_SQL_TIMEOUT_MS,
|
||
"text_inline_max_chars": DEFAULT_TEXT_INLINE_MAX_CHARS,
|
||
"text_chunk_size": DEFAULT_TEXT_CHUNK_SIZE,
|
||
"text_chunk_overlap": DEFAULT_TEXT_CHUNK_OVERLAP,
|
||
"text_query_top_k": DEFAULT_TEXT_QUERY_TOP_K,
|
||
"image_result_replay_cap_chars": DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS,
|
||
"video_result_replay_cap_chars": DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS,
|
||
"video_transcript_chunk_size": DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE,
|
||
"video_transcript_chunk_overlap": DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP,
|
||
"archive_max_depth": 2,
|
||
"archive_max_file_count": 200,
|
||
"archive_max_entry_bytes": 10 * 1024 * 1024,
|
||
"archive_max_total_uncompressed_bytes": 50 * 1024 * 1024,
|
||
}
|
||
saved = _set_tabular_limits_into_oclaw_config(limits)
|
||
return {"ok": True, "cleared": True, "limits": saved}
|
||
if not isinstance(raw, dict):
|
||
raise HTTPException(status_code=400, detail="invalid_limits")
|
||
next_limits = {
|
||
"max_rows_read": _safe_int(raw.get("max_rows_read"), DEFAULT_TABULAR_ROWS_READ),
|
||
"max_columns": _safe_int(raw.get("max_columns"), DEFAULT_TABULAR_COLUMNS),
|
||
"max_cell_chars": _safe_int(raw.get("max_cell_chars"), DEFAULT_TABULAR_CELL_CHARS),
|
||
"max_excel_sheets": _safe_int(raw.get("max_excel_sheets"), DEFAULT_MAX_EXCEL_SHEETS, max_value=500),
|
||
"large_table_preview_rows": _safe_int(raw.get("large_table_preview_rows"), 20, max_value=500),
|
||
"tool_mode_enabled": bool(raw.get("tool_mode_enabled", True)),
|
||
"tool_mode_min_rows": _safe_int(raw.get("tool_mode_min_rows"), DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS),
|
||
"tool_mode_max_bytes": _safe_int(raw.get("tool_mode_max_bytes"), 30 * 1024 * 1024),
|
||
"sql_timeout_ms": _safe_timeout_ms(raw.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS),
|
||
"text_inline_max_chars": _safe_int(raw.get("text_inline_max_chars"), DEFAULT_TEXT_INLINE_MAX_CHARS, max_value=200_000),
|
||
"text_chunk_size": _safe_int(raw.get("text_chunk_size"), DEFAULT_TEXT_CHUNK_SIZE, max_value=8_000),
|
||
"text_chunk_overlap": _safe_int(raw.get("text_chunk_overlap"), DEFAULT_TEXT_CHUNK_OVERLAP, max_value=4_000),
|
||
"text_query_top_k": _safe_int(raw.get("text_query_top_k"), DEFAULT_TEXT_QUERY_TOP_K, max_value=50),
|
||
"image_result_replay_cap_chars": _safe_int(
|
||
raw.get("image_result_replay_cap_chars"), DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
|
||
),
|
||
"video_result_replay_cap_chars": _safe_int(
|
||
raw.get("video_result_replay_cap_chars"), DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
|
||
),
|
||
"video_transcript_chunk_size": _safe_int(
|
||
raw.get("video_transcript_chunk_size"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE, max_value=8_000
|
||
),
|
||
"video_transcript_chunk_overlap": _safe_int(
|
||
raw.get("video_transcript_chunk_overlap"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP, max_value=4_000
|
||
),
|
||
"archive_max_depth": _safe_int(raw.get("archive_max_depth"), 2, max_value=10),
|
||
"archive_max_file_count": _safe_int(raw.get("archive_max_file_count"), 200, max_value=20_000),
|
||
"archive_max_entry_bytes": _safe_int(
|
||
raw.get("archive_max_entry_bytes"), 10 * 1024 * 1024, max_value=2_000_000_000
|
||
),
|
||
"archive_max_total_uncompressed_bytes": _safe_int(
|
||
raw.get("archive_max_total_uncompressed_bytes"), 50 * 1024 * 1024, max_value=5_000_000_000
|
||
),
|
||
}
|
||
saved = _set_tabular_limits_into_oclaw_config(next_limits)
|
||
return {"ok": True, "limits": saved}
|
||
|
||
@chat.get("/profile")
|
||
def api_chat_profile_get(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "").strip()
|
||
user_id = str(ctx.get("user_id") or "").strip()
|
||
if not tenant_id or not user_id:
|
||
raise HTTPException(status_code=401, detail="auth_required")
|
||
row = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id)
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="user_not_found")
|
||
aid = str(row.get("avatar_attachment_id") or "").strip()
|
||
avatar_url = f"/admin/api/chat/attachments/{aid}" if aid else ""
|
||
return {
|
||
"ok": True,
|
||
"profile": {
|
||
"id": str(row.get("id") or ""),
|
||
"tenant_id": str(row.get("tenant_id") or ""),
|
||
"username": str(row.get("username") or ""),
|
||
"display_name": str(row.get("display_name") or ""),
|
||
"role": str(row.get("role") or ""),
|
||
"is_active": bool(row.get("is_active")),
|
||
"created_at": str(row.get("created_at") or ""),
|
||
"avatar_attachment_id": aid or None,
|
||
"avatar_url": avatar_url or None,
|
||
},
|
||
}
|
||
|
||
@chat.patch("/profile")
|
||
def api_chat_profile_patch(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "").strip()
|
||
user_id = str(ctx.get("user_id") or "").strip()
|
||
if not tenant_id or not user_id:
|
||
raise HTTPException(status_code=401, detail="auth_required")
|
||
if "display_name" in payload:
|
||
dn = str(payload.get("display_name") or "").strip()[:120] or str(ctx.get("username") or "User").strip() or "User"
|
||
store.update_user_account(tenant_id=tenant_id, user_id=user_id, display_name=dn)
|
||
row = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id) or {}
|
||
aid = str(row.get("avatar_attachment_id") or "").strip()
|
||
return {
|
||
"ok": True,
|
||
"profile": {
|
||
"id": str(row.get("id") or ""),
|
||
"tenant_id": str(row.get("tenant_id") or ""),
|
||
"username": str(row.get("username") or ""),
|
||
"display_name": str(row.get("display_name") or ""),
|
||
"role": str(row.get("role") or ""),
|
||
"is_active": bool(row.get("is_active")),
|
||
"created_at": str(row.get("created_at") or ""),
|
||
"avatar_attachment_id": aid or None,
|
||
"avatar_url": (f"/admin/api/chat/attachments/{aid}" if aid else None),
|
||
},
|
||
}
|
||
|
||
@chat.post("/profile/avatar")
|
||
async def api_chat_profile_avatar_upload(
|
||
authorization: str | None = Header(default=None),
|
||
file: UploadFile = File(...),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "").strip()
|
||
user_id = str(ctx.get("user_id") or "").strip()
|
||
if not tenant_id or not user_id:
|
||
raise HTTPException(status_code=401, detail="auth_required")
|
||
raw = await file.read()
|
||
if not raw:
|
||
raise HTTPException(status_code=400, detail="empty_file")
|
||
if len(raw) > _AVATAR_UPLOAD_MAX_BYTES:
|
||
raise HTTPException(status_code=413, detail="avatar_too_large")
|
||
mime = (str(file.content_type or "").split(";", 1)[0]).strip().lower()
|
||
if mime == "image/jpg":
|
||
mime = "image/jpeg"
|
||
if mime not in _AVATAR_MIMES:
|
||
raise HTTPException(status_code=415, detail="avatar_invalid_mime")
|
||
fname = _safe_rel_avatar_name(str(file.filename or "avatar"))
|
||
ast = AttachmentAssetStore()
|
||
meta = ast.save_bytes(raw, filename=fname, mime=mime)
|
||
aid = str(meta.attachment_id or "").strip()
|
||
if not aid:
|
||
raise HTTPException(status_code=500, detail="avatar_save_failed")
|
||
store.update_user_account(tenant_id=tenant_id, user_id=user_id, avatar_attachment_id=aid)
|
||
return {
|
||
"ok": True,
|
||
"avatar_attachment_id": aid,
|
||
"avatar_url": f"/admin/api/chat/attachments/{aid}",
|
||
}
|
||
|
||
@chat.delete("/profile/avatar")
|
||
def api_chat_profile_avatar_delete(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "").strip()
|
||
user_id = str(ctx.get("user_id") or "").strip()
|
||
if not tenant_id or not user_id:
|
||
raise HTTPException(status_code=401, detail="auth_required")
|
||
store.update_user_account(tenant_id=tenant_id, user_id=user_id, avatar_attachment_id="")
|
||
return {"ok": True, "avatar_attachment_id": None, "avatar_url": None}
|
||
|
||
@chat.post("/sessions/{session_id}/stop")
|
||
def api_chat_stop_turn(
|
||
session_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
sid = str(session_id)
|
||
with _CHAT_STOP_LOCK:
|
||
ev = _CHAT_STOP_EVENTS.get(sid)
|
||
if ev:
|
||
ev.set()
|
||
return {"ok": True}
|
||
|
||
@chat.get("/attachments/{attachment_id}")
|
||
def api_chat_attachment_bytes(
|
||
attachment_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> Response:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "").strip()
|
||
user_id = str(ctx.get("user_id") or "").strip()
|
||
aid = str(attachment_id or "").strip().lower()
|
||
if not aid:
|
||
raise HTTPException(status_code=400, detail="attachment_id_required")
|
||
if not _is_valid_attachment_id(aid):
|
||
raise HTTPException(status_code=400, detail="attachment_id_invalid")
|
||
if not _rate_limit_attachment_download(actor_tenant_id=tenant_id, actor_user_id=user_id):
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=tenant_id,
|
||
actor_user_id=user_id,
|
||
action="chat_attachment_download",
|
||
target_type="attachment",
|
||
target_id=aid,
|
||
status="rate_limited",
|
||
detail={"path": "chat_api.attachment_bytes"},
|
||
)
|
||
raise HTTPException(status_code=429, detail="rate_limited")
|
||
if not _can_access_attachment(store, ctx, attachment_id=aid):
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=tenant_id,
|
||
actor_user_id=user_id,
|
||
action="chat_attachment_download",
|
||
target_type="attachment",
|
||
target_id=aid,
|
||
status="forbidden",
|
||
detail={"path": "chat_api.attachment_bytes"},
|
||
)
|
||
raise HTTPException(status_code=403, detail="attachment_forbidden")
|
||
ast = AttachmentAssetStore()
|
||
try:
|
||
blob, meta = ast.load_bytes(aid)
|
||
except Exception:
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=tenant_id,
|
||
actor_user_id=user_id,
|
||
action="chat_attachment_download",
|
||
target_type="attachment",
|
||
target_id=aid,
|
||
status="not_found",
|
||
detail={"path": "chat_api.attachment_bytes"},
|
||
)
|
||
raise HTTPException(status_code=404, detail="attachment_not_found") from None
|
||
mime = (meta.mime if meta else None) or "application/octet-stream"
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=tenant_id,
|
||
actor_user_id=user_id,
|
||
action="chat_attachment_download",
|
||
target_type="attachment",
|
||
target_id=aid,
|
||
status="ok",
|
||
detail={"path": "chat_api.attachment_bytes", "mime": mime, "bytes": int(meta.bytes if meta else len(blob))},
|
||
)
|
||
return Response(content=blob, media_type=mime)
|
||
|
||
@chat.post("/admin/attachments/acl/backfill")
|
||
def api_chat_admin_backfill_attachment_acl(
|
||
limit_messages: int = Query(default=50_000, ge=1, le=500_000),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
_require_administrator_chat_viewer(ctx)
|
||
tenant_id = str(ctx.get("tenant_id") or "").strip()
|
||
user_id = str(ctx.get("user_id") or "").strip()
|
||
res = store.backfill_attachment_acl_from_messages(tenant_id=tenant_id, limit_messages=int(limit_messages))
|
||
if bool(res.get("ok")):
|
||
res.setdefault("recommended_next", "Set AIA_ATTACHMENT_ACL_STRICT=1 after verifying downloads.")
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=tenant_id,
|
||
actor_user_id=user_id,
|
||
action="attachment_acl_backfill",
|
||
target_type="tenant",
|
||
target_id=tenant_id,
|
||
status="ok" if bool(res.get("ok")) else "error",
|
||
detail=res,
|
||
)
|
||
return res
|
||
|
||
@chat.post("/sessions/{session_id}/messages")
|
||
def api_chat_send(
|
||
session_id: str,
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
role = str(ctx.get("role") or "member")
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
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)
|
||
execution_mode = _normalize_execution_mode(payload)
|
||
if "interaction_mode" not in payload and "chat_mode" not in payload:
|
||
interaction_mode, _, _, _ = _resolve_mode_settings(
|
||
store=store,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
)
|
||
if "specialist" not in payload:
|
||
_, selected_specialist, _, _ = _resolve_mode_settings(
|
||
store=store,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
)
|
||
if "memory_mode" not in payload:
|
||
_, _, memory_mode, _ = _resolve_mode_settings(
|
||
store=store,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
)
|
||
if "execution_mode" not in payload:
|
||
_, _, _, execution_mode = _resolve_mode_settings(
|
||
store=store,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
)
|
||
selected_specialist = _apply_specialist_flags(store, selected_specialist)
|
||
if not text_raw and not attachments:
|
||
raise HTTPException(status_code=400, detail="text_or_attachments_required")
|
||
text = _effective_user_text(text=text_raw, attachments=attachments, store=store)
|
||
if not _is_administrator_chat_viewer(ctx) and tenant_id and user_id:
|
||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||
# Record attachment ownership for access control (download endpoint).
|
||
if attachments and tenant_id and user_id:
|
||
for a in attachments:
|
||
if not isinstance(a, dict):
|
||
continue
|
||
aid = str(a.get("attachment_id") or "").strip().lower()
|
||
if aid and _is_valid_attachment_id(aid):
|
||
store.link_attachment_acl(
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
attachment_id=aid,
|
||
source="user_upload",
|
||
)
|
||
lang = _api_lang(store, user_text=text)
|
||
apply_gateway_mcp_env_to_os()
|
||
manager_agent = _init_gateway_executor(
|
||
store,
|
||
ctx,
|
||
lang=lang,
|
||
specialist="generalist",
|
||
policy_session_id=str(session_id),
|
||
)
|
||
specialist_factory = lambda sid: _init_gateway_executor(
|
||
store,
|
||
ctx,
|
||
lang=lang,
|
||
specialist=sid,
|
||
policy_session_id=str(session_id),
|
||
)
|
||
try:
|
||
gw = OclawGateway(store=store)
|
||
msg = StandardMessage(
|
||
session_id=str(session_id),
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
role=role,
|
||
channel="admin_chat",
|
||
text=str(text or ""),
|
||
attachments=list(attachments or []),
|
||
metadata={
|
||
"tenant_id": tenant_id,
|
||
"user_id": user_id,
|
||
"role": role,
|
||
"channel": "admin_chat",
|
||
"interaction_mode": interaction_mode,
|
||
"selected_specialist": selected_specialist,
|
||
"memory_mode": memory_mode,
|
||
"execution_mode": execution_mode,
|
||
},
|
||
)
|
||
gw_result = gw.handle_turn(
|
||
msg=msg,
|
||
lang=lang,
|
||
executor=manager_agent,
|
||
run_id=(idempotency_key or None),
|
||
specialist_executor_factory=specialist_factory,
|
||
)
|
||
reply = gw_result.reply_text
|
||
persist_assistant_text_if_turn_missing(
|
||
store=store,
|
||
session_id=str(session_id),
|
||
turn_uuid=str(getattr(gw_result, "turn_uuid", "") or ""),
|
||
final_text=str(reply or ""),
|
||
log_prefix="admin_chat_http_text_fallback_persisted",
|
||
)
|
||
except GenerationInterrupted:
|
||
msg = "已中断回答。" if lang == "zh" else "Response stopped."
|
||
store.add_message(session_id=session_id, role="assistant", content=msg)
|
||
return {"ok": True, "reply": msg, "interrupted": True}
|
||
return {
|
||
"ok": True,
|
||
"reply": str(reply or ""),
|
||
"mode": str(getattr(gw_result, "mode", "sync_direct") or "sync_direct"),
|
||
"task_id": str(getattr(gw_result, "task_id", "") or ""),
|
||
"interaction_mode": str(getattr(gw_result, "interaction_mode", interaction_mode) or interaction_mode),
|
||
"selected_specialist": str(getattr(gw_result, "selected_specialist", selected_specialist) or selected_specialist),
|
||
}
|
||
|
||
@chat.post("/sessions/{session_id}/messages/stream")
|
||
def api_chat_send_stream(
|
||
session_id: str,
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> StreamingResponse:
|
||
"""Server-Sent Events: token deltas + progress + tool_ui, then done (assistant persisted by run_turn)."""
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = resolve_auth(store, authorization)
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "")
|
||
role = str(ctx.get("role") or "member")
|
||
sess = _resolve_chat_session_allow_claim_orphan(store, ctx, session_id)
|
||
if not sess:
|
||
raise HTTPException(status_code=404, detail="session_not_found")
|
||
text_raw = str(payload.get("text") or "").strip()
|
||
attachments = _parse_attachments_payload(payload.get("attachments"))
|
||
interaction_mode, selected_specialist = _normalize_chat_mode(payload)
|
||
memory_mode = _normalize_memory_mode(payload)
|
||
execution_mode = _normalize_execution_mode(payload)
|
||
if "interaction_mode" not in payload and "chat_mode" not in payload:
|
||
interaction_mode, _, _, _ = _resolve_mode_settings(
|
||
store=store,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
)
|
||
if "specialist" not in payload:
|
||
_, selected_specialist, _, _ = _resolve_mode_settings(
|
||
store=store,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
)
|
||
if "memory_mode" not in payload:
|
||
_, _, memory_mode, _ = _resolve_mode_settings(
|
||
store=store,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
)
|
||
if "execution_mode" not in payload:
|
||
_, _, _, execution_mode = _resolve_mode_settings(
|
||
store=store,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
)
|
||
selected_specialist = _apply_specialist_flags(store, selected_specialist)
|
||
if not text_raw and not attachments:
|
||
raise HTTPException(status_code=400, detail="text_or_attachments_required")
|
||
text = _effective_user_text(text=text_raw, attachments=attachments, store=store)
|
||
if not _is_administrator_chat_viewer(ctx) and tenant_id and user_id:
|
||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||
if attachments and tenant_id and user_id:
|
||
for a in attachments:
|
||
if not isinstance(a, dict):
|
||
continue
|
||
aid = str(a.get("attachment_id") or "").strip().lower()
|
||
if aid and _is_valid_attachment_id(aid):
|
||
store.link_attachment_acl(
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
session_id=str(session_id),
|
||
attachment_id=aid,
|
||
source="user_upload",
|
||
)
|
||
lang = _api_lang(store, user_text=text)
|
||
apply_gateway_mcp_env_to_os()
|
||
manager_agent = _init_gateway_executor(
|
||
store,
|
||
ctx,
|
||
lang=lang,
|
||
specialist="generalist",
|
||
policy_session_id=str(session_id),
|
||
)
|
||
specialist_factory = lambda sid: _init_gateway_executor(
|
||
store,
|
||
ctx,
|
||
lang=lang,
|
||
specialist=sid,
|
||
policy_session_id=str(session_id),
|
||
)
|
||
meta = {
|
||
"tenant_id": tenant_id,
|
||
"user_id": user_id,
|
||
"role": role,
|
||
"channel": "admin_chat",
|
||
"interaction_mode": interaction_mode,
|
||
"selected_specialist": selected_specialist,
|
||
"memory_mode": memory_mode,
|
||
"execution_mode": execution_mode,
|
||
}
|
||
|
||
_DONE = object()
|
||
max_q = str(store.get_setting("AIA_SSE_QUEUE_MAXSIZE") or "").strip()
|
||
if not max_q:
|
||
max_q = str(os.getenv("AIA_SSE_QUEUE_MAXSIZE") or "").strip()
|
||
q_maxsize = 2000
|
||
if max_q.isdigit():
|
||
q_maxsize = max(200, min(int(max_q), 50_000))
|
||
q: queue.Queue[tuple[str, Any] | object] = queue.Queue(maxsize=q_maxsize)
|
||
sid = str(session_id)
|
||
stop_ev = _register_stop(sid)
|
||
dropped = {"count": 0}
|
||
|
||
def _emit(kind: str, data: Any) -> None:
|
||
item = (kind, data)
|
||
try:
|
||
q.put_nowait(item)
|
||
return
|
||
except Exception:
|
||
pass
|
||
# Backpressure: if queue is full, drop low-priority events.
|
||
if kind in ("token", "progress", "tool_ui"):
|
||
dropped["count"] += 1
|
||
return
|
||
# Always try to deliver terminal events.
|
||
q.put(item)
|
||
|
||
def _worker() -> None:
|
||
try:
|
||
|
||
def should_stop() -> bool:
|
||
return stop_ev.is_set()
|
||
|
||
def on_tool_ui(event: str, pl: dict[str, Any]) -> None:
|
||
ev_name = str(event or "")
|
||
kind = "skill_ui" if ev_name.startswith("skill_") else "tool_ui"
|
||
_emit(kind, {"event": ev_name, "payload": pl})
|
||
|
||
gw = OclawGateway(store=store)
|
||
msg = StandardMessage(
|
||
session_id=str(session_id),
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
role=role,
|
||
channel="admin_chat",
|
||
text=str(text or ""),
|
||
attachments=list(attachments or []),
|
||
metadata=dict(meta),
|
||
)
|
||
gw_result = gw.handle_turn(
|
||
msg=msg,
|
||
lang=lang,
|
||
executor=manager_agent,
|
||
specialist_executor_factory=specialist_factory,
|
||
on_token=lambda s: _emit("token", s),
|
||
on_progress=lambda s: _emit("progress", s),
|
||
on_tool_ui=on_tool_ui,
|
||
should_stop=should_stop,
|
||
)
|
||
reply = gw_result.reply_text
|
||
_emit(
|
||
"done",
|
||
{
|
||
"reply": str(reply or ""),
|
||
"mode": str(getattr(gw_result, "mode", "sync_direct") or "sync_direct"),
|
||
"task_id": str(getattr(gw_result, "task_id", "") or ""),
|
||
"interaction_mode": str(
|
||
getattr(gw_result, "interaction_mode", interaction_mode) or interaction_mode
|
||
),
|
||
"selected_specialist": str(
|
||
getattr(gw_result, "selected_specialist", selected_specialist) or selected_specialist
|
||
),
|
||
},
|
||
)
|
||
except GenerationInterrupted:
|
||
msg = "已中断回答。" if lang == "zh" else "Response stopped."
|
||
try:
|
||
store.add_message(session_id=session_id, role="assistant", content=msg)
|
||
except Exception:
|
||
pass
|
||
_emit("done", {"reply": msg, "interrupted": True})
|
||
except Exception as e:
|
||
_emit("error", str(e))
|
||
finally:
|
||
_clear_stop(sid)
|
||
q.put(_DONE)
|
||
|
||
threading.Thread(target=_worker, name="chat_stream", daemon=True).start()
|
||
|
||
def event_iter() -> Iterator[str]:
|
||
while True:
|
||
item = q.get()
|
||
if item is _DONE:
|
||
break
|
||
kind, data = item # type: ignore[misc]
|
||
if kind == "token":
|
||
line = json.dumps({"type": "token", "delta": data}, ensure_ascii=False)
|
||
elif kind == "progress":
|
||
line = json.dumps({"type": "progress", "message": data}, ensure_ascii=False)
|
||
elif kind == "tool_ui":
|
||
line = json.dumps({"type": "tool_ui", **(data if isinstance(data, dict) else {})}, ensure_ascii=False)
|
||
elif kind == "skill_ui":
|
||
line = json.dumps({"type": "skill_ui", **(data if isinstance(data, dict) else {})}, ensure_ascii=False)
|
||
elif kind == "done":
|
||
line = json.dumps(
|
||
{
|
||
"type": "done",
|
||
"reply": (data or {}).get("reply", ""),
|
||
"interrupted": bool((data or {}).get("interrupted")),
|
||
"mode": str((data or {}).get("mode") or "sync_direct"),
|
||
"task_id": str((data or {}).get("task_id") or ""),
|
||
"interaction_mode": str((data or {}).get("interaction_mode") or interaction_mode),
|
||
"selected_specialist": str((data or {}).get("selected_specialist") or selected_specialist),
|
||
"dropped_events": int(dropped["count"] or 0),
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
elif kind == "error":
|
||
line = json.dumps({"type": "error", "message": str(data)}, ensure_ascii=False)
|
||
else:
|
||
continue
|
||
yield f"data: {line}\n\n"
|
||
|
||
return StreamingResponse(
|
||
event_iter(),
|
||
media_type="text/event-stream",
|
||
headers={
|
||
"Cache-Control": "no-cache",
|
||
"Connection": "keep-alive",
|
||
"X-Accel-Buffering": "no",
|
||
},
|
||
)
|
||
|
||
@chat.get("/jobs")
|
||
def api_chat_list_jobs(
|
||
limit: int = Query(default=50, ge=1, le=100),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
resolve_auth(store, authorization)
|
||
from svc.jobs.background_jobs import get_job_store
|
||
|
||
return get_job_store().list_jobs(limit=limit)
|
||
|
||
@chat.get("/jobs/{job_id}")
|
||
def api_chat_get_job(
|
||
job_id: str,
|
||
log_tail_chars: int = Query(default=4000, ge=200, le=50000),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
resolve_auth(store, authorization)
|
||
from svc.jobs.background_jobs import get_job_store
|
||
|
||
out = get_job_store().get(job_id, log_tail_chars=log_tail_chars)
|
||
if not out.get("ok") and out.get("error") == "job_not_found":
|
||
raise HTTPException(status_code=404, detail="job_not_found")
|
||
return out
|
||
|
||
@chat.post("/jobs/{job_id}/cancel")
|
||
def api_chat_cancel_job(
|
||
job_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
resolve_auth(store, authorization)
|
||
from svc.jobs.background_jobs import get_job_store
|
||
|
||
out = get_job_store().cancel(job_id)
|
||
if not out.get("ok") and out.get("error") == "job_not_found":
|
||
raise HTTPException(status_code=404, detail="job_not_found")
|
||
return out
|
||
|
||
@chat.post("/jobs/cancel-running")
|
||
def api_chat_cancel_all_running_jobs(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
resolve_auth(store, authorization)
|
||
from svc.jobs.background_jobs import get_job_store
|
||
|
||
return get_job_store().cancel_all_running()
|
||
|
||
@chat.delete("/jobs/{job_id}")
|
||
def api_chat_purge_job(
|
||
job_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
resolve_auth(store, authorization)
|
||
from svc.jobs.background_jobs import get_job_store
|
||
|
||
out = get_job_store().purge(job_id)
|
||
if not out.get("ok") and out.get("error") == "job_not_found":
|
||
raise HTTPException(status_code=404, detail="job_not_found")
|
||
if not out.get("ok") and out.get("error") == "job_still_running":
|
||
raise HTTPException(status_code=409, detail="job_still_running")
|
||
return out
|
||
|
||
router.include_router(chat)
|
||
|
||
|
||
__all__ = ["include_chat_routes"]
|