diff --git a/README.md b/README.md index 5a4bc1c..5a54c5a 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ python scripts/serve.py - 分步端点(聊天底层复用):`POST .../start-parse`、`POST .../confirm-parse`、`POST .../start-impact`、 `POST .../confirm-impact`、`POST .../generate`(body `{"output_language":"auto|zh|ja"}`)、`POST .../run-qa` - 结果:`GET .../result/preview|download|impact-report|qa-report` + - 进度流:`GET /api/sessions/{id}/ws`(WebSocket,实时推送各步骤进度事件;运行依赖 `websockets>=12`,已包含在 `pip install -e ".[dev]"` 中) - **既有系统**以代码库目录(项目配置)或 `.zip`(会话上传)提供(追加/改修场景);不传则影响调查跳过 - 部署到公网后登记 `service_url`(格式 `http://<域名或公网IP>:<端口>`)供评审系统 B 阶段黑盒冒烟 diff --git a/pyproject.toml b/pyproject.toml index 360f0c4..5a7a51a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "fastapi>=0.115", "uvicorn>=0.30", "python-multipart>=0.0.9", + "websockets>=12", ] [project.optional-dependencies] diff --git a/src/genesis/server/app.py b/src/genesis/server/app.py index 7db5d7c..bb1bd28 100644 --- a/src/genesis/server/app.py +++ b/src/genesis/server/app.py @@ -11,14 +11,16 @@ """ from __future__ import annotations +import asyncio from pathlib import Path from typing import Any -from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, HTMLResponse from pydantic import BaseModel from genesis.inference.factory import build_inference_engine +from genesis.server.hub import hub from genesis.server.service import FileTypeError, GenesisService, ServiceStepError from genesis.server.store import ( ProjectConfigError, ProjectsStore, SessionNotFoundError, SessionStore, @@ -302,6 +304,22 @@ def create_app( raise _error(404, "RESULT_NOT_FOUND", "QA 报告不存在") return FileResponse(rec.qa_report_path, media_type="application/json", filename="qa-report.json") + # ---------- 进度流(WebSocket) ---------- + + @app.websocket("/api/sessions/{sid}/ws") + async def session_progress_ws(ws: WebSocket, sid: str): + await ws.accept() + hub.register_loop(asyncio.get_running_loop()) + q = hub.subscribe(sid) + try: + while True: + event = await q.get() + await ws.send_json(event) + except WebSocketDisconnect: + pass + finally: + hub.unsubscribe(sid, q) + # ---------- 聊天 ---------- @app.post("/api/chat/{sid}/messages") diff --git a/tests/test_progress_ws.py b/tests/test_progress_ws.py new file mode 100644 index 0000000..a8a0311 --- /dev/null +++ b/tests/test_progress_ws.py @@ -0,0 +1,24 @@ +import threading +from fastapi.testclient import TestClient +from genesis.server.app import create_app +from genesis.server.hub import hub +from genesis.server.store import SessionStore + + +def _client(tmp_path): + return TestClient(create_app( + store=SessionStore(db_path=str(tmp_path / "s.db")), + data_root=str(tmp_path / "data"), engine="fake")) + + +def test_ws_streams_progress(tmp_path): + c = _client(tmp_path) + + def trigger(): + hub.emit("ws-s1", {"type": "progress", "step": "gen", "status": "ok", "detail": "生成中"}) + + with c.websocket_connect("/api/sessions/ws-s1/ws") as ws: + threading.Thread(target=trigger).start() + data = ws.receive_json() + assert data["type"] == "progress" + assert data["step"] == "gen"