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()