2026-09-10 22:06:43 +08:00
|
|
|
"""本地客服控制台:一个能直接聊天的最小调试前端。
|
|
|
|
|
|
|
|
|
|
**为什么是独立进程,而不是给底座加接口**:底座目前没有登录接口(按计划推迟)。任何
|
|
|
|
|
"让浏览器直接拿令牌"的做法——无论是一个 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"
|
2026-09-10 22:42:17 +08:00
|
|
|
# 兜底话术的固定开头。不能用"客服热线"这类词判断——正常的适当性回答里也会建议客户
|
|
|
|
|
# 拨打客服热线,那样会把"已经给出了结论"误报成"已引导人工"。
|
|
|
|
|
FALLBACK_MARK = "抱歉,这个问题我暂时无法给出准确答复"
|
2026-09-10 22:06:43 +08:00
|
|
|
|
|
|
|
|
PAGE = """<!DOCTYPE html>
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<title>南方科技 · 智能客服(本地调试)</title>
|
|
|
|
|
<style>
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
body { margin: 0; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
|
|
|
background: #f2f4f7; display: flex; justify-content: center; height: 100vh; }
|
|
|
|
|
.wrap { width: min(760px, 100%); display: flex; flex-direction: column; height: 100vh; }
|
|
|
|
|
header { padding: 14px 18px; background: #1f2d3d; color: #fff; font-size: 15px; }
|
|
|
|
|
header small { opacity: .65; margin-left: 8px; font-size: 12px; }
|
|
|
|
|
#log { flex: 1; overflow-y: auto; padding: 18px; }
|
|
|
|
|
.row { display: flex; margin-bottom: 14px; }
|
|
|
|
|
.row.user { justify-content: flex-end; }
|
|
|
|
|
.bubble { max-width: 78%; padding: 11px 14px; border-radius: 10px; line-height: 1.6;
|
|
|
|
|
white-space: pre-wrap; word-break: break-word; font-size: 14px; }
|
|
|
|
|
.user .bubble { background: #2f6fed; color: #fff; border-bottom-right-radius: 3px; }
|
|
|
|
|
.bot .bubble { background: #fff; color: #1f2d3d; border-bottom-left-radius: 3px;
|
|
|
|
|
box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
|
|
|
|
.meta { font-size: 11px; color: #8894a4; margin: 5px 2px 0; }
|
|
|
|
|
.meta .tag { display: inline-block; padding: 1px 6px; border-radius: 4px;
|
|
|
|
|
background: #e6ebf2; margin-right: 6px; }
|
|
|
|
|
.meta .tag.warn { background: #fde8e8; color: #b42318; }
|
|
|
|
|
footer { padding: 12px 18px 18px; background: #fff; border-top: 1px solid #e4e9f0;
|
|
|
|
|
display: flex; gap: 10px; }
|
|
|
|
|
input { flex: 1; padding: 12px 14px; border: 1px solid #d5dbe4; border-radius: 8px;
|
|
|
|
|
font-size: 14px; outline: none; }
|
|
|
|
|
input:focus { border-color: #2f6fed; }
|
|
|
|
|
button { padding: 12px 22px; border: 0; border-radius: 8px; background: #2f6fed; color: #fff;
|
|
|
|
|
font-size: 14px; cursor: pointer; }
|
|
|
|
|
button:disabled { background: #a9bbe0; cursor: default; }
|
|
|
|
|
.hint { font-size: 12px; color: #8894a4; padding: 0 18px 8px; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="wrap">
|
|
|
|
|
<header>南方科技 · 智能客服 <small>本地调试控制台(回答全部来自公司资料库)</small></header>
|
|
|
|
|
<div id="log"></div>
|
|
|
|
|
<div class="hint">可以直接试:<em>基金赎回几天到账</em> · <em>季季盈90天起投多少</em> ·
|
|
|
|
|
<em>C1 客户能买什么</em> · 然后再问 <em>那它风险高吗</em>(试试多轮指代)</div>
|
|
|
|
|
<footer>
|
|
|
|
|
<input id="box" placeholder="说点什么…" autocomplete="off">
|
|
|
|
|
<button id="send">发送</button>
|
|
|
|
|
</footer>
|
|
|
|
|
</div>
|
|
|
|
|
<script>
|
|
|
|
|
const log = document.getElementById('log');
|
|
|
|
|
const box = document.getElementById('box');
|
|
|
|
|
const send = document.getElementById('send');
|
|
|
|
|
const sessionId = 'web-' + Math.random().toString(36).slice(2, 10);
|
|
|
|
|
let busy = false;
|
|
|
|
|
|
|
|
|
|
function add(cls, text, meta) {
|
|
|
|
|
const row = document.createElement('div');
|
|
|
|
|
row.className = 'row ' + cls;
|
|
|
|
|
const bubble = document.createElement('div');
|
|
|
|
|
bubble.className = 'bubble';
|
|
|
|
|
bubble.textContent = text;
|
|
|
|
|
const holder = document.createElement('div');
|
|
|
|
|
holder.appendChild(bubble);
|
|
|
|
|
if (meta) {
|
|
|
|
|
const m = document.createElement('div');
|
|
|
|
|
m.className = 'meta';
|
|
|
|
|
m.innerHTML = meta;
|
|
|
|
|
holder.appendChild(m);
|
|
|
|
|
}
|
|
|
|
|
row.appendChild(holder);
|
|
|
|
|
log.appendChild(row);
|
|
|
|
|
log.scrollTop = log.scrollHeight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ask() {
|
|
|
|
|
const message = box.value.trim();
|
|
|
|
|
if (!message || busy) return;
|
|
|
|
|
busy = true; send.disabled = true;
|
|
|
|
|
add('user', message);
|
|
|
|
|
box.value = '';
|
|
|
|
|
add('bot', '正在查询公司资料…');
|
|
|
|
|
const pending = log.lastElementChild;
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/chat', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ message, session_id: sessionId }),
|
|
|
|
|
});
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
pending.remove();
|
|
|
|
|
const tagIntent = '<span class="tag">意图 ' + (data.intent || '?') + '</span>';
|
|
|
|
|
const tagGuide = data.guided ? '<span class="tag warn">已引导人工</span>' : '';
|
|
|
|
|
add('bot', data.answer || '(空回答)', tagIntent + tagGuide);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
pending.remove();
|
|
|
|
|
add('bot', '控制台出错:' + err, '');
|
|
|
|
|
} finally {
|
|
|
|
|
busy = false; send.disabled = false; box.focus();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
send.onclick = ask;
|
|
|
|
|
box.addEventListener('keydown', (e) => { if (e.key === 'Enter') ask(); });
|
|
|
|
|
box.focus();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|