From 07a922fa36b7110c22ad9bd03c15ce7f90184ab8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Thu, 10 Sep 2026 22:06:43 +0800
Subject: [PATCH] =?UTF-8?q?feat(tools):=20=E6=96=B0=E5=A2=9E=E6=9C=AC?=
=?UTF-8?q?=E5=9C=B0=E5=AE=A2=E6=9C=8D=E6=8E=A7=E5=88=B6=E5=8F=B0=EF=BC=88?=
=?UTF-8?q?=E5=8F=AF=E8=81=8A=E5=A4=A9=E7=9A=84=E8=B0=83=E8=AF=95=E5=89=8D?=
=?UTF-8?q?=E7=AB=AF=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
为什么做成独立进程而不是给底座加接口:底座目前没有登录接口(按计划推迟)。
任何让浏览器直接拿到令牌的做法——无论是一个 dev token 端点还是把私钥下发前端——
都等于把"任意身份"开放给任何能访问服务的人。控制台把令牌签发与调用全部留在
服务端进程内(私钥不出进程),底座代码零改动、也没有新增任何后门路由。
- GET /:返回聊天页;POST /api/chat:受理 agent-run 并驱动本进程执行
- 页面展示识别出的意图与是否引导人工,便于观察路由结果
- 同一 session_id 连续对话,用于验证短期记忆(多轮指代)生效
---
tools/chat_console.py | 231 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 231 insertions(+)
create mode 100644 tools/chat_console.py
diff --git a/tools/chat_console.py b/tools/chat_console.py
new file mode 100644
index 0000000..9efe74f
--- /dev/null
+++ b/tools/chat_console.py
@@ -0,0 +1,231 @@
+"""本地客服控制台:一个能直接聊天的最小调试前端。
+
+**为什么是独立进程,而不是给底座加接口**:底座目前没有登录接口(按计划推迟)。任何
+"让浏览器直接拿令牌"的做法——无论是一个 dev token 端点还是把私钥给前端——都等于把
+"任意身份"开放给任何能访问服务的人。这里把令牌签发与调用全部留在**本进程内**
+(私钥不出进程),前端只跟本控制台说话,底座代码一行不改、也没有新增任何后门路由。
+
+跑法:
+
+```powershell
+D:\\conda\\envs\\jr_py313\\python.exe tools\\chat_console.py
+# 浏览器打开 http://127.0.0.1:8098
+```
+
+**运行前请停掉常驻 Worker**:本控制台自己驱动这一条 run 执行(`WorkerRuntime().execute`),
+而常驻 Worker 与它共享 `agent_run` 队列,会抢走任务,导致页面一直转圈。
+"""
+
+import argparse
+import datetime as dt
+import sys
+import uuid
+from pathlib import Path
+from typing import Any
+
+import httpx
+import jwt
+import uvicorn
+from fastapi import FastAPI
+from fastapi.responses import HTMLResponse
+
+from app.core.config import get_settings
+from app.main import create_app
+from app.worker.runtime import WorkerRuntime
+
+sys.stdout.reconfigure(errors="replace")
+
+AGENT_TYPE = "customer_service"
+FALLBACK_MARK = "客服热线"
+
+PAGE = """
+
+
+
+
+南方科技 · 智能客服(本地调试)
+
+
+
+
+
南方科技 · 智能客服 本地调试控制台(回答全部来自公司资料库)
+
+
可以直接试:基金赎回几天到账 · 季季盈90天起投多少 ·
+ C1 客户能买什么 · 然后再问 那它风险高吗(试试多轮指代)
+
+
+
+
+
+"""
+
+
+def token(subject: str) -> str:
+ settings = get_settings()
+ private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8")
+ now = dt.datetime.now(dt.UTC)
+ return jwt.encode(
+ {
+ "sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience,
+ "exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5),
+ "jti": str(uuid.uuid4()),
+ },
+ private_key,
+ algorithm="RS256",
+ )
+
+
+def build_console(user_id: str) -> FastAPI:
+ console = FastAPI(title="客服本地控制台")
+ # 底座以 in-process 方式挂载:不经过网络、不新增任何路由,私钥只在签发时用到
+ base_app = create_app()
+ auth = {"Authorization": f"Bearer {token(user_id)}"}
+
+ @console.get("/", response_class=HTMLResponse)
+ async def index() -> HTMLResponse:
+ return HTMLResponse(PAGE)
+
+ @console.post("/api/chat")
+ async def chat(payload: dict[str, Any]) -> dict[str, Any]:
+ message = str(payload.get("message") or "").strip()
+ session_id = str(payload.get("session_id") or f"web-{uuid.uuid4().hex[:8]}")
+ if not message:
+ return {"answer": "请先输入内容。", "intent": "", "guided": False}
+
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=base_app), base_url="http://base", timeout=120
+ ) as client:
+ accepted = await client.post(
+ "/api/v1/agent-runs",
+ json={
+ "agent_type": AGENT_TYPE, "message": message,
+ "session_id": session_id, "idempotency_key": uuid.uuid4().hex,
+ },
+ headers=auth,
+ )
+ if accepted.status_code != 202:
+ body = accepted.text[:200]
+ return {"answer": f"受理失败({accepted.status_code}):{body}",
+ "intent": "", "guided": False}
+ run_id = accepted.json()["data"]["run_id"]
+ # 本控制台自己驱动这一条 run;若常驻 Worker 同时在跑会与它抢队列
+ await WorkerRuntime().execute(run_id)
+ detail = (await client.get(
+ f"/api/v1/agent-runs/{run_id}", headers=auth
+ )).json()["data"]
+
+ result = detail.get("result") or {}
+ answer = str(result.get("content") or "")
+ if detail.get("status") != "succeeded":
+ answer = answer or f"运行未成功:{detail.get('status')} {detail.get('error_code')}"
+ # intent 在结果里可能是 {"intent": ..., "confidence": ...},也可能已是字符串
+ raw_intent = result.get("intent")
+ intent = raw_intent.get("intent") if isinstance(raw_intent, dict) else raw_intent
+ return {
+ "answer": answer,
+ "intent": intent or "",
+ "guided": FALLBACK_MARK in answer,
+ }
+
+ return console
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="客服本地控制台")
+ parser.add_argument("--port", type=int, default=8098)
+ parser.add_argument("--user", default="9001", help="以哪个客户身份对话(默认 9001)")
+ args = parser.parse_args()
+ print(f"控制台:http://127.0.0.1:{args.port} 身份={args.user}")
+ print("提示:运行前请停掉常驻 Worker,否则它会抢走任务、页面会一直转圈。")
+ uvicorn.run(build_console(args.user), host="127.0.0.1", port=args.port, log_level="warning")
+
+
+if __name__ == "__main__":
+ main()