feat: 完成 issue #77 [Ti-1] 前端对话组件集成
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
# 前端对话组件(Chat Widget)
|
||||||
|
|
||||||
|
对应 EPIC #11「④ LLM 报警解释 / 交接班 / NL 查询」子任务 **#77**(0.5d):
|
||||||
|
驾驶舱/移动端**对话组件**及配套后端 API(标准库实现,无框架依赖)。
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
```
|
||||||
|
web/chat/
|
||||||
|
├── chat_api.py 对话后端 API(http.server):场景分发 + 统一 JSON
|
||||||
|
├── chat_widget.html 前端对话组件(内联 HTML/CSS/JS,深色主题对齐驾驶舱)
|
||||||
|
├── tests/
|
||||||
|
│ └── test_chat_api.py 场景分发 / 端点 / 错误处理测试
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动对话 API 服务(默认 127.0.0.1:8080;runner 未注入时仅返回健康/页面)
|
||||||
|
python chat_api.py --host 127.0.0.1 --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器打开 `http://127.0.0.1:8080/` 即见对话组件。
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/` | 对话组件页面(chat_widget.html) |
|
||||||
|
| GET | `/api/health` | 健康检查 |
|
||||||
|
| POST | `/api/chat` | 对话接口(见下) |
|
||||||
|
|
||||||
|
`POST /api/chat` 请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "question": "氯气流量最近1小时趋势", "scenario": "nl_query", "confidence": 1.0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`scenario`:`alarm_explain`(报警解释)/ `shift_handover`(交接班摘要)/
|
||||||
|
`nl_query`(NL 查询,缺省);未知场景降级 `nl_query`。
|
||||||
|
|
||||||
|
响应(统一 JSON):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "answer": "...", "route": "local", "answer_id": "...",
|
||||||
|
"scenario": "nl_query", "needs_human": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与场景层集成
|
||||||
|
|
||||||
|
`chat_api.dispatch(runner, request)` 按场景调用 runner 的
|
||||||
|
`explain_alarm / generate_handover / query_cockpit`(与
|
||||||
|
`templates/ti-cl4/llm-scenarios.TiScenarioRunner` 对接);
|
||||||
|
runner 可注入(`make_server(host, port, runner=...)`),便于联调与替换实现。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unittest discover -s tests -p "test_*.py"
|
||||||
|
```
|
||||||
|
覆盖:三场景分发、空 question、未知场景降级、异常 → error JSON、
|
||||||
|
GET / 与 /api/health、POST /api/chat、非法 JSON 400、404。
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""前端对话组件后端 API —— issue #77(Template-Ti 一期)。
|
||||||
|
|
||||||
|
父 Issue #11「④ LLM 报警解释 / 交接班 / NL 查询」子任务:
|
||||||
|
为驾驶舱/移动端提供对话组件所需的后端 API:
|
||||||
|
|
||||||
|
- `GET /`:返回对话组件页面(chat_widget.html,静态);
|
||||||
|
- `GET /api/health`:服务健康;
|
||||||
|
- `POST /api/chat`:对话接口——按 `scenario` 分发到 Ti 场景
|
||||||
|
(alarm_explain / shift_handover / nl_query),返回统一 JSON:
|
||||||
|
`{answer, route, answer_id, scenario}`。
|
||||||
|
|
||||||
|
纯标准库实现(http.server,无框架依赖),便于联调/内嵌;
|
||||||
|
runner 可注入(测试/替换实现均解耦)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
#: 组件页面路径(相对本模块)
|
||||||
|
WIDGET_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"chat_widget.html")
|
||||||
|
|
||||||
|
#: 场景 → runner 方法映射
|
||||||
|
SCENARIO_METHODS = {
|
||||||
|
"alarm_explain": "explain_alarm",
|
||||||
|
"shift_handover": "generate_handover",
|
||||||
|
"nl_query": "query_cockpit",
|
||||||
|
"default": "query_cockpit",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(runner, request: dict) -> dict:
|
||||||
|
"""按请求分发到场景 runner,返回统一响应。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
runner: 提供 explain_alarm / generate_handover / query_cockpit 的对象;
|
||||||
|
request: `{question, scenario, confidence?}`。
|
||||||
|
Returns:
|
||||||
|
统一 JSON 字典;场景未知 → 降级 nl_query;异常 → error 响应。
|
||||||
|
"""
|
||||||
|
question = str(request.get("question", "")).strip()
|
||||||
|
scenario = str(request.get("scenario", "default"))
|
||||||
|
confidence = float(request.get("confidence", 1.0))
|
||||||
|
if not question:
|
||||||
|
return {"error": "question 不能为空"}
|
||||||
|
|
||||||
|
method_name = SCENARIO_METHODS.get(
|
||||||
|
scenario, SCENARIO_METHODS["default"])
|
||||||
|
resolved = scenario if scenario in SCENARIO_METHODS else "default"
|
||||||
|
method = getattr(runner, method_name, None)
|
||||||
|
if method is None:
|
||||||
|
return {"error": f"场景 {scenario!r} 未实现"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = method(question, confidence=confidence)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 统一异常 → error JSON
|
||||||
|
return {"error": f"处理失败: {exc}"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"answer": result.answer,
|
||||||
|
"route": getattr(result.route, "target", ""),
|
||||||
|
"answer_id": getattr(result, "answer_id", ""),
|
||||||
|
"scenario": resolved,
|
||||||
|
"needs_human": bool(getattr(result, "needs_human", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ChatHandler(BaseHTTPRequestHandler):
|
||||||
|
"""对话 API HTTP 处理(GET 组件页 / POST /api/chat)。"""
|
||||||
|
|
||||||
|
#: 场景 runner(由 make_server 注入)
|
||||||
|
runner = None
|
||||||
|
|
||||||
|
def log_message(self, *args): # 静默访问日志
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/api/health":
|
||||||
|
self._json(200, {"status": "ok"})
|
||||||
|
return
|
||||||
|
if path in ("/", "/index.html", "/chat_widget.html"):
|
||||||
|
self._html(WIDGET_PATH)
|
||||||
|
return
|
||||||
|
self._json(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if urlparse(self.path).path != "/api/chat":
|
||||||
|
self._json(404, {"error": "not found"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
request = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
self._json(400, {"error": "请求体不是合法 JSON"})
|
||||||
|
return
|
||||||
|
self._json(200, dispatch(self.runner, request))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _json(self, code: int, payload: dict) -> None:
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _html(self, path: str) -> None:
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
self._json(404, {"error": "widget 页面缺失"})
|
||||||
|
return
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
body = fh.read()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def make_server(host: str, port: int, runner=None) -> ThreadingHTTPServer:
|
||||||
|
"""构建对话 API 服务器(runner 可注入,便于测试/替换)。"""
|
||||||
|
ChatHandler.runner = runner
|
||||||
|
return ThreadingHTTPServer((host, port), ChatHandler)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="对话 API 服务(issue #77)")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8080)
|
||||||
|
args = parser.parse_args()
|
||||||
|
print(f"对话 API 服务:http://{args.host}:{args.port}/(组件页)")
|
||||||
|
make_server(args.host, args.port).serve_forever()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>iAOP 对话助手</title>
|
||||||
|
<style>
|
||||||
|
/* iAOP 对话组件(issue #77):深色主题,对齐驾驶舱 */
|
||||||
|
body { margin:0; font-family: "Microsoft YaHei", sans-serif; background:#0f172a; color:#e2e8f0; }
|
||||||
|
#app { max-width: 720px; margin: 0 auto; padding: 16px; }
|
||||||
|
h1 { font-size: 18px; color:#38bdf8; }
|
||||||
|
#messages { height: 420px; overflow-y:auto; border:1px solid #1e293b; border-radius:8px;
|
||||||
|
padding:12px; background:#111c33; }
|
||||||
|
.msg { margin: 8px 0; }
|
||||||
|
.msg .who { font-size:12px; color:#94a3b8; }
|
||||||
|
.msg .text { display:inline-block; max-width:85%; padding:8px 12px; border-radius:8px; white-space:pre-wrap; }
|
||||||
|
.user .text { background:#1d4ed8; }
|
||||||
|
.bot .text { background:#1e293b; }
|
||||||
|
.meta { font-size:11px; color:#64748b; margin-top:2px; }
|
||||||
|
#panel { margin-top:10px; display:flex; gap:8px; }
|
||||||
|
select, input { padding:8px; border-radius:6px; border:1px solid #334155; background:#0b1526; color:#e2e8f0; }
|
||||||
|
input { flex:1; }
|
||||||
|
button { padding:8px 16px; border:none; border-radius:6px; background:#0ea5e9; color:#fff; cursor:pointer; }
|
||||||
|
button:disabled { opacity:.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<h1>iAOP 对话助手(Template-Ti 一期)</h1>
|
||||||
|
<div id="messages"></div>
|
||||||
|
<div id="panel">
|
||||||
|
<select id="scenario">
|
||||||
|
<option value="nl_query">自然语言查询</option>
|
||||||
|
<option value="alarm_explain">报警解释</option>
|
||||||
|
<option value="shift_handover">交接班摘要</option>
|
||||||
|
</select>
|
||||||
|
<input id="question" placeholder="请输入问题,如:氯气流量最近1小时趋势">
|
||||||
|
<button id="send" onclick="send()">发送</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const el = id => document.getElementById(id);
|
||||||
|
function addMsg(who, text, meta) {
|
||||||
|
const box = el('messages');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'msg ' + who;
|
||||||
|
div.innerHTML = '<div class="who">' + (who === 'user' ? '我' : 'iAOP') + '</div>' +
|
||||||
|
'<div class="text"></div>' + (meta ? '<div class="meta">' + meta + '</div>' : '');
|
||||||
|
div.querySelector('.text').textContent = text;
|
||||||
|
box.appendChild(div);
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
async function send() {
|
||||||
|
const question = el('question').value.trim();
|
||||||
|
if (!question) return;
|
||||||
|
const scenario = el('scenario').value;
|
||||||
|
addMsg('user', question);
|
||||||
|
el('question').value = ''; el('send').disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ question, scenario })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) addMsg('bot', '错误:' + data.error);
|
||||||
|
else addMsg('bot', data.answer,
|
||||||
|
'路由: ' + data.route + ' | 场景: ' + data.scenario +
|
||||||
|
(data.needs_human ? ' | ⚠ 转人工确认' : ''));
|
||||||
|
} catch (e) {
|
||||||
|
addMsg('bot', '请求失败:' + e);
|
||||||
|
} finally {
|
||||||
|
el('send').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el('question').addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""对话 API 测试(issue #77)。
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
1. dispatch 场景分发:alarm_explain / shift_handover / nl_query / default;
|
||||||
|
2. 请求校验:空 question、非法场景、异常处理 → error JSON;
|
||||||
|
3. HTTP 端点冒烟(mock runner):GET /、/api/health、POST /api/chat、404。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from chat_api import ChatHandler, dispatch # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, answer="ok", route_target="local", answer_id="a1",
|
||||||
|
needs_human=False):
|
||||||
|
self.answer = answer
|
||||||
|
self.route = mock.MagicMock()
|
||||||
|
self.route.target = route_target
|
||||||
|
self.answer_id = answer_id
|
||||||
|
self.needs_human = needs_human
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRunner:
|
||||||
|
"""模拟 TiScenarioRunner 三场景方法。"""
|
||||||
|
|
||||||
|
def explain_alarm(self, q, confidence=1.0):
|
||||||
|
return _FakeResult(answer=f"报警解释: {q}", route_target="local")
|
||||||
|
|
||||||
|
def generate_handover(self, q, confidence=1.0):
|
||||||
|
return _FakeResult(answer=f"交接班: {q}", route_target="local")
|
||||||
|
|
||||||
|
def query_cockpit(self, q, confidence=1.0):
|
||||||
|
return _FakeResult(answer=f"查询: {q}", route_target="local")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDispatch(unittest.TestCase):
|
||||||
|
"""场景分发与统一响应。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.runner = _FakeRunner()
|
||||||
|
|
||||||
|
def test_nl_query_default(self):
|
||||||
|
resp = dispatch(self.runner, {"question": "氯气流量趋势"})
|
||||||
|
self.assertEqual(resp["scenario"], "default")
|
||||||
|
self.assertTrue(resp["answer"].startswith("查询"))
|
||||||
|
|
||||||
|
def test_alarm_explain(self):
|
||||||
|
resp = dispatch(self.runner, {"question": "炉温报警",
|
||||||
|
"scenario": "alarm_explain"})
|
||||||
|
self.assertTrue(resp["answer"].startswith("报警解释"))
|
||||||
|
|
||||||
|
def test_shift_handover(self):
|
||||||
|
resp = dispatch(self.runner, {"question": "甲班交接",
|
||||||
|
"scenario": "shift_handover"})
|
||||||
|
self.assertTrue(resp["answer"].startswith("交接班"))
|
||||||
|
|
||||||
|
def test_empty_question(self):
|
||||||
|
resp = dispatch(self.runner, {"question": " "})
|
||||||
|
self.assertIn("error", resp)
|
||||||
|
|
||||||
|
def test_unknown_scenario_falls_back(self):
|
||||||
|
resp = dispatch(self.runner, {"question": "x",
|
||||||
|
"scenario": "no_such"})
|
||||||
|
self.assertEqual(resp["scenario"], "default")
|
||||||
|
|
||||||
|
def test_runner_exception_to_error(self):
|
||||||
|
bad = mock.MagicMock()
|
||||||
|
bad.query_cockpit.side_effect = RuntimeError("boom")
|
||||||
|
resp = dispatch(bad, {"question": "x"})
|
||||||
|
self.assertIn("处理失败", resp["error"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestHTTPEndpoints(unittest.TestCase):
|
||||||
|
"""HTTP 端点冒烟(直接用 handler 方法 + mock runner)。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = ChatHandler.__new__(ChatHandler)
|
||||||
|
self.handler.runner = _FakeRunner()
|
||||||
|
self.handler.wfile = mock.MagicMock()
|
||||||
|
self.handler.send_response = mock.MagicMock()
|
||||||
|
self.handler.send_header = mock.MagicMock()
|
||||||
|
self.handler.end_headers = mock.MagicMock()
|
||||||
|
|
||||||
|
def _json_response(self, code, payload):
|
||||||
|
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
def test_get_health(self):
|
||||||
|
self.handler.path = "/api/health"
|
||||||
|
self.handler.do_GET()
|
||||||
|
sent = self.handler.wfile.write.call_args[0][0]
|
||||||
|
self.assertIn(b'"status": "ok"', sent)
|
||||||
|
|
||||||
|
def test_get_widget(self):
|
||||||
|
self.handler.path = "/"
|
||||||
|
with mock.patch("chat_api.WIDGET_PATH",
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"..", "chat_widget.html")):
|
||||||
|
self.handler.do_GET()
|
||||||
|
sent = self.handler.wfile.write.call_args[0][0]
|
||||||
|
self.assertIn(b"iAOP", sent)
|
||||||
|
|
||||||
|
def test_post_chat(self):
|
||||||
|
self.handler.path = "/api/chat"
|
||||||
|
self.handler.headers = {"Content-Length": str(
|
||||||
|
len('{"question":"炉温报警","scenario":"alarm_explain"}'))}
|
||||||
|
self.handler.rfile = mock.MagicMock()
|
||||||
|
self.handler.rfile.read.return_value = (
|
||||||
|
'{"question":"炉温报警","scenario":"alarm_explain"}'.encode())
|
||||||
|
self.handler.do_POST()
|
||||||
|
sent = self.handler.wfile.write.call_args[0][0]
|
||||||
|
self.assertIn("报警解释", json.loads(sent.decode())["answer"])
|
||||||
|
|
||||||
|
def test_post_bad_json(self):
|
||||||
|
self.handler.path = "/api/chat"
|
||||||
|
self.handler.headers = {"Content-Length": "3"}
|
||||||
|
self.handler.rfile = mock.MagicMock()
|
||||||
|
self.handler.rfile.read.return_value = b"not json"
|
||||||
|
self.handler.do_POST()
|
||||||
|
sent = self.handler.wfile.write.call_args[0][0]
|
||||||
|
self.assertIn("合法 JSON", sent.decode())
|
||||||
|
|
||||||
|
def test_404(self):
|
||||||
|
self.handler.path = "/nope"
|
||||||
|
self.handler.do_GET()
|
||||||
|
sent = self.handler.wfile.write.call_args[0][0]
|
||||||
|
self.assertIn(b"not found", sent)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user