oclaw/runtime/operations/scripts/live_chat_probe.py
oliver d14e9d3596 feat(persistence): PostgreSQL assistant store, chat persist fixes, gateway scripts
- Add SQLAlchemy Core repos, pg adapter/compat, assistant_store factory, Alembic bootstrap and migration/cutover scripts.

- Harden chat_message writes (NUL scrub for PG), turn_uuid on attempt failure, WS turn_runner fallbacks and gateway executed_turn_uuid init.

- start_gateway: log paths, PS7 stderr handling via cmd, background stdout/stderr redirect; runtime assistant_runtime_log_dir export.

- Ops: clear_all_chat_sessions with PG-only --postgresql and env-gated wipe; clear_postgres_chat_sessions.ps1.

- Tests: SA repos, pg compat, persist fallback, smoke env isolation; CI and docs touch-ups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 09:33:50 +08:00

251 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""对「正在运行」的网关发一条与控制台 Chat 相同的 HTTP 消息,并打印模型 / 会话模式 / 响应 / 库尾行。
本机执行(需网关已启动,且与浏览器访问的是同一 ``--base-url``)::
# 方式一:用密码登录(tenant 可省略,默认取库里第一个团队)
set AIA_LIVE_CHAT_PASSWORD=你的控制台密码
python runtime/operations/scripts/live_chat_probe.py --session-id <浏览器地址栏里的会话id>
# 方式二:从浏览器开发者工具复制 Bearer token
python runtime/operations/scripts/live_chat_probe.py --session-id <id> --token <jwt>
可选:加 ``--dump-db`` 时,会再读 ``_local/system.env`` 里的 ``AIA_ASSISTANT_*``,直连同一 PG/SQLite 打印该会话最近几条 ``chat_message``(用于对照「HTTP 成功但库里没有 assistant」)。
注意:Cursor 里的 AI **不能**替你连本机浏览器或 WebSocket;此脚本等价于你自己在 Network 里手动 POST,只是自动带上当前「选用模型」与会话模式字段。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def _load_local_env_for_db_only() -> None:
root = _repo_root()
p = root / "_local" / "system.env"
if not p.is_file():
return
for raw in p.read_text(encoding="utf-8", errors="replace").splitlines():
s = raw.strip()
if not s or s.startswith("#") or "=" not in s:
continue
k, _, rest = s.partition("=")
key = k.strip()
if not key or key in os.environ:
continue
if not key.startswith("AIA_ASSISTANT_") and key not in (
"OPS_ASSISTANT_DB_BACKEND",
"OPS_ASSISTANT_DATABASE_URL",
"OPS_ASSISTANT_DB_PATH",
):
continue
val = rest.strip()
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
val = val[1:-1]
os.environ[key] = val
def _dump_db_messages(session_id: str, limit: int) -> None:
_load_local_env_for_db_only()
root = _repo_root()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from sqlalchemy import func, select
from svc.persistence.db.engine import clear_assistant_engine_cache, get_assistant_engine
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.db.tables import chat_message
clear_assistant_engine_cache()
reset_assistant_store_singleton()
eng = get_assistant_engine()
print("\n--- DB (same env as _local/system.env assistant store) ---")
print("engine:", str(eng.url).split("@")[-1])
tc_len = func.length(func.coalesce(chat_message.c.tool_calls, ""))
stmt = (
select(
chat_message.c.id,
chat_message.c.role,
chat_message.c.event_type,
func.length(func.coalesce(chat_message.c.content, "")).label("content_len"),
(tc_len > 2).label("has_tc"),
)
.where(chat_message.c.session_id == session_id)
.order_by(chat_message.c.id.desc())
.limit(limit)
)
with eng.connect() as c:
n = c.execute(
select(func.count()).select_from(chat_message).where(chat_message.c.session_id == session_id)
).scalar()
print("chat_message count:", int(n or 0))
rows = list(c.execute(stmt).mappings())
for r in reversed(rows):
print(dict(r))
def _auth_header(token: str) -> dict[str, str]:
t = str(token or "").strip()
if t.lower().startswith("bearer "):
t = t[7:].strip()
return {"authorization": f"Bearer {t}"}
def main() -> int:
ap = argparse.ArgumentParser(description="Live HTTP chat probe against running gateway")
ap.add_argument("--base-url", default=os.getenv("AIA_LIVE_CHAT_BASE_URL", "http://127.0.0.1:8787").rstrip("/"))
ap.add_argument("--session-id", required=True, help="当前 Chat 会话 id(与浏览器一致)")
ap.add_argument("--text", default="请用一句话回复:当前探针在测试落库。", help="发送的正文")
ap.add_argument("--token", default=os.getenv("AIA_LIVE_CHAT_TOKEN", "").strip(), help="Bearer token(可设环境变量)")
ap.add_argument("--tenant-id", default=os.getenv("AIA_LIVE_CHAT_TENANT_ID", "").strip())
ap.add_argument("--username", default=os.getenv("AIA_LIVE_CHAT_USERNAME", "administrator").strip())
ap.add_argument("--password", default=os.getenv("AIA_LIVE_CHAT_PASSWORD", "").strip())
ap.add_argument("--dump-db", action="store_true", help="发送后再读本地 assistant 库该会话消息尾")
ap.add_argument("--db-tail", type=int, default=8, help="--dump-db 时打印最近几条")
args = ap.parse_args()
try:
import httpx
except ImportError:
print("需要 httpx: pip install httpx", file=sys.stderr)
return 2
sid = str(args.session_id).strip()
if not sid:
print("session-id 为空", file=sys.stderr)
return 2
base = str(args.base_url).strip().rstrip("/")
timeout = httpx.Timeout(300.0, connect=15.0)
with httpx.Client(base_url=base, timeout=timeout) as client:
token = str(args.token or "").strip()
if not token:
pw = str(args.password or "").strip()
if not pw:
print(
"未提供 token:请设置 --password 或环境变量 AIA_LIVE_CHAT_PASSWORD,"
"或 --token / AIA_LIVE_CHAT_TOKEN",
file=sys.stderr,
)
return 2
r0 = client.post("/admin/api/auth/bootstrap", json={})
if r0.status_code != 200:
print("bootstrap", r0.status_code, r0.text, file=sys.stderr)
return 1
body: dict[str, Any] = {
"username": str(args.username),
"password": pw,
"purpose": "console",
}
tid = str(args.tenant_id or "").strip()
if tid:
body["tenant_id"] = tid
lr = client.post("/admin/api/auth/login", json=body)
if lr.status_code != 200:
print("login http", lr.status_code, lr.text, file=sys.stderr)
return 1
lj = lr.json()
if not lj.get("ok"):
print("login", lj, file=sys.stderr)
return 1
token = str(lj.get("token") or "").strip()
if not token:
print("login 无 token", lj, file=sys.stderr)
return 1
sess = lj.get("session") or {}
print(
"login ok tenant_id=",
str(sess.get("tenant_id") or ""),
"user_id=",
str(sess.get("user_id") or ""),
"username=",
str(sess.get("username") or ""),
)
h = _auth_header(token)
mr = client.get("/admin/api/models", headers=h)
if mr.status_code != 200:
print("GET /models", mr.status_code, mr.text, file=sys.stderr)
return 1
mj = mr.json()
if not mj.get("ok"):
print("models", mj, file=sys.stderr)
return 1
active = str(mj.get("active_llm_profile_id") or "")
profiles = mj.get("profiles") or []
name = ""
mode = ""
model = ""
for p in profiles:
if isinstance(p, dict) and str(p.get("id") or "") == active:
name = str(p.get("name") or "")
mode = str(p.get("mode") or "")
model = str(p.get("model") or "")
break
print("\n--- 当前选用模型(与控制台一致) ---")
print("active_llm_profile_id:", active)
print("profile:", name, "| mode:", mode, "| model:", model)
dbp = mj.get("db_path")
if dbp is not None:
print("gateway reports db_path:", dbp)
gr = client.get(f"/admin/api/chat/sessions/{sid}/mode", headers=h)
if gr.status_code != 200:
print("GET session/mode", gr.status_code, gr.text, file=sys.stderr)
return 1
gj = gr.json()
if not gj.get("ok"):
print("session/mode", gj, file=sys.stderr)
return 1
print("\n--- 当前会话模式(将原样带入 POST) ---")
print(json.dumps({k: gj.get(k) for k in ("interaction_mode", "specialist", "memory_mode", "execution_mode", "confirm_strategy", "plan_agent_version") if k in gj}, ensure_ascii=False))
payload = {
"text": str(args.text),
"interaction_mode": gj.get("interaction_mode"),
"specialist": gj.get("specialist"),
"memory_mode": gj.get("memory_mode"),
"execution_mode": gj.get("execution_mode"),
}
pr = client.post(f"/admin/api/chat/sessions/{sid}/messages", headers=h, json=payload)
if pr.status_code != 200:
print("POST messages", pr.status_code, pr.text, file=sys.stderr)
return 1
pj = pr.json()
print("\n--- POST /messages 响应 ---")
print(json.dumps(pj, ensure_ascii=False, indent=2)[:8000])
if not pj.get("ok"):
return 1
reply = str(pj.get("reply") or "")
print("\nreply 非空:", bool(reply.strip()), "len=", len(reply))
lr2 = client.get(f"/admin/api/chat/sessions/{sid}/messages?limit=50", headers=h)
if lr2.status_code == 200:
lj2 = lr2.json()
msgs = lj2.get("messages") or []
roles = [str(m.get("role") or "") for m in msgs if isinstance(m, dict)]
print("\n--- GET messages(接口返回,最多 50 条) ---")
print("count=", len(msgs), "roles=", roles[-12:])
if args.dump_db:
try:
_dump_db_messages(sid, max(1, int(args.db_tail)))
except Exception as e:
print("\n--dump-db 失败(可忽略):", type(e).__name__, e, file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())