feat(v3): T29 token 级流式输出(SSE 流式解析 + delta 事件 + 打字机渲染,D10)

- OpenAICompatChat 默认 stream=True:httpx 流式解析 OpenAI chunk,
  tool_calls 碎片按 index 组装(不在正文展示),include_usage 计量;
  失败自动回退非流式一次(已有部分增量输出则如实抛出);
  补 raise_for_status(修复流式 4xx 误入回退的缺陷)
- ToolLoop 增 on_delta(签名探测兼容 2/3 参 chat_fn);_loads_json_object 宽松解析
- DeltaThrottle >=48 字符节流落 delta 事件;单模型与两级模式(含规划者)接线
- 前端:streamText 打字机渲染 + 光标动画;工具/阶段事件到达时清空归档
- 配套修复:AgentView 闭包持有 push 前原始对象导致响应式丢失、过程事件不渲染
- 测试 +7(SSE 解析/碎片组装/回退/部分失败抛出/on_delta/节流/service delta 事件),
  全量 296 passed
This commit is contained in:
tzt
2026-09-01 23:41:45 +08:00
parent ca8add02e2
commit ddef8bec1c
18 changed files with 786 additions and 62 deletions
+80 -5
View File
@@ -8,7 +8,8 @@ v2 端点(《实现方案_v2》5.3):
GET /review/queue、POST /review/{id} 人工检验
GET /metrics 含 v2 token/快路径统计
启动:uvicorn gateway.api:app --host 0.0.0.0 --port 8000
启动:uvicorn gateway.api:app --host 127.0.0.1 --port 8000
(默认仅回环;如需局域网访问改 --host 0.0.0.0 并设置 GATEWAY_TRUSTED_HOSTS
"""
from __future__ import annotations
@@ -22,6 +23,7 @@ if _dotenv_path.exists():
load_dotenv(_dotenv_path)
import asyncio
import re
from pathlib import Path
from typing import List, Optional
@@ -38,6 +40,10 @@ from gateway.model_pool import (
get_pool,
)
# 路径参数 ID 白名单(runs/agent/sessions 的 ID 都由此系统生成;
# 拒绝任意其他字符可一并杀灭 Windows 反斜杠穿越 ../..%5C 等变体)
_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
# ---- v2 依赖(惰性导入,缺依赖时降级提示) ----
try:
from router_system.architect import build_architect
@@ -179,6 +185,19 @@ try:
version="2.0.0",
)
# Web 信任围栏(dsh browser-auth 同款思路):只信任本机/显式放行的 Host,
# 防 DNS rebinding 把浏览器请求打到本网关。GATEWAY_TRUSTED_HOSTS 可覆盖("*" = 放行全部)。
_trusted = _os.environ.get(
"GATEWAY_TRUSTED_HOSTS",
"localhost,127.0.0.1,0.0.0.0,[::1],testserver,testclient",
)
try:
from starlette.middleware.trustedhost import TrustedHostMiddleware
app.add_middleware(TrustedHostMiddleware,
allowed_hosts=[h.strip() for h in _trusted.split(",")])
except ImportError: # pragma: no cover
pass
# Vue SPA 静态资源(html=True:对不存在的路径 fallback 到 index.html,支持 SPA 路由)
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static")
@@ -189,6 +208,12 @@ try:
return HTMLResponse(_INDEX_PATH.read_text(encoding="utf-8"))
return HTMLResponse("<h1>端云协同 LLM 系统</h1><p>请先构建前端:cd webapp && npm run build</p>")
def _check_id(value: str, what: str = "ID") -> str:
"""校验路径参数 ID(防目录穿越/注入:仅允许系统生成的字符集)。"""
if not _ID_RE.fullmatch(value or ""):
raise HTTPException(status_code=400, detail=f"非法 {what}: {value!r}")
return value
@app.get("/health", response_model=HealthResponse, tags=["system"])
async def health():
return get_router().health()
@@ -265,6 +290,7 @@ try:
@app.get("/runs/{request_id}/status", tags=["v2"])
async def get_run_status(request_id: str):
"""查询任务当前状态(pending / running / done / failed)。"""
_check_id(request_id, "request_id")
info = get_job_store().get(request_id)
if info is None:
raise HTTPException(status_code=404, detail=f"任务 {request_id} 不存在")
@@ -302,6 +328,7 @@ try:
@app.get("/runs/{request_id}/stream", tags=["v2"])
async def stream_run(request_id: str):
"""SSE 端点:实时推送 workspace.json 状态变化(供前端协作可视化)。"""
_check_id(request_id, "request_id")
from fastapi.responses import StreamingResponse
async def event_generator():
@@ -354,6 +381,7 @@ try:
@app.get("/traces/{request_id}", tags=["system"])
async def get_trace(request_id: str):
_check_id(request_id, "request_id")
trace = get_router().trace_store.get(request_id)
if trace is None:
raise HTTPException(status_code=404, detail=f"未找到请求 {request_id} 的推理链")
@@ -362,6 +390,7 @@ try:
# ---------------- v2workspace / artifacts ----------------
@app.get("/runs/{request_id}/workspace", tags=["v2"])
async def get_workspace(request_id: str):
_check_id(request_id, "request_id")
p = Path("runs") / request_id / "workspace.json"
if not p.exists():
raise HTTPException(status_code=404, detail=f"未找到运行 {request_id}")
@@ -370,7 +399,12 @@ try:
@app.get("/runs/{request_id}/artifacts/{name}", tags=["v2"])
async def get_artifact(request_id: str, name: str):
p = Path("runs") / request_id / "artifacts" / name
_check_id(request_id, "request_id")
d = (Path("runs") / request_id / "artifacts").resolve()
# 工件名关押:解析后必须仍在 artifacts 目录内(防 ..\ 与绝对路径逃逸)
p = (d / name).resolve()
if p != d and d not in p.parents:
raise HTTPException(status_code=400, detail=f"非法工件名: {name!r}")
if not p.exists():
raise HTTPException(status_code=404, detail=f"未找到工件 {name}")
return {"name": name, "content": p.read_text(encoding="utf-8")}
@@ -599,6 +633,7 @@ try:
token_cap=int(agent_cfg.get("token_cap", 20000)),
allow_shell=bool(agent_cfg.get("allow_shell", False)),
shell_timeout_s=int(agent_cfg.get("shell_timeout_s", 20)),
allow_net=bool(agent_cfg.get("allow_net", True)),
executor_chat=executor_chat,
max_handoffs=int(agent_cfg.get("max_handoffs", 2)),
session=session,
@@ -625,6 +660,7 @@ try:
@app.post("/agent/{request_id}/cancel", tags=["agent"])
async def agent_cancel(request_id: str):
"""停止运行中的智能体任务。"""
_check_id(request_id, "request_id")
from gateway.agent import get_agent_service
service = get_agent_service()
info = service.get(request_id)
@@ -643,6 +679,7 @@ try:
@app.post("/agent/{request_id}/approve", tags=["agent"])
async def agent_approve(request_id: str, req: dict):
"""裁决待审批操作:{"approval_id", "allowed"}dsh 式 allow-once / deny)。"""
_check_id(request_id, "request_id")
from gateway.agent import get_agent_service
info = get_agent_service().get(request_id)
if info is None:
@@ -678,14 +715,30 @@ try:
@app.get("/agent/sessions/{sid}", tags=["agent"])
async def agent_session_detail(sid: str):
"""会话详情(含轮次)。"""
_check_id(sid, "会话 ID")
from gateway.agent import get_session_store
sess = get_session_store().get(sid)
if sess is None:
raise HTTPException(status_code=404, detail=f"会话不存在: {sid}")
return sess.view()
@app.patch("/agent/sessions/{sid}", tags=["agent"])
async def agent_session_rename(sid: str, req: dict = None):
"""重命名会话:{"title"}dsh session.rename 对齐)。"""
_check_id(sid, "会话 ID")
from gateway.agent import get_session_store
title = str((req or {}).get("title") or "").strip()
if not title:
raise HTTPException(status_code=400, detail="title 不能为空")
sess = get_session_store().rename(sid, title)
if sess is None:
raise HTTPException(status_code=404, detail=f"会话不存在: {sid}")
return sess.view()
@app.delete("/agent/sessions/{sid}", tags=["agent"])
async def agent_session_delete(sid: str):
_check_id(sid, "会话 ID")
from gateway.agent import get_session_store
return {"ok": get_session_store().delete(sid)}
@@ -733,6 +786,7 @@ try:
@app.get("/agent/{request_id}/status", tags=["agent"])
async def agent_status(request_id: str):
_check_id(request_id, "request_id")
from gateway.agent import get_agent_service
info = get_agent_service().get(request_id)
if info is None:
@@ -747,12 +801,14 @@ try:
@app.get("/agent/{request_id}/events", tags=["agent"])
async def agent_events(request_id: str):
"""完整事件列表(JSON,刷新后恢复用)。"""
_check_id(request_id, "request_id")
from gateway.agent import get_agent_service
return get_agent_service().read_events(request_id)
@app.get("/agent/{request_id}/stream", tags=["agent"])
async def agent_stream(request_id: str):
"""SSE:实时推送智能体过程事件(round/tool_call/tool_result/usage/final)。"""
_check_id(request_id, "request_id")
from fastapi.responses import StreamingResponse
from gateway.agent import get_agent_service
@@ -811,19 +867,36 @@ try:
# ---------------- 模型设置(用户可调整) ----------------
@app.get("/config", tags=["settings"])
async def get_config():
"""读取当前可调整设置(小模型 / 大模型 / 管线)。"""
return settings_store().to_dict()
"""读取当前可调整设置(小模型 / 大模型 / 管线)。
architect.api_key 打码返回(api_key_set + 前 6 位,对齐 D2 池条目语义);
修改时留空/不传 = 保留服务端已存值。
"""
out = settings_store().to_dict()
arch = out.get("architect") or {}
key = arch.get("api_key") or ""
arch["api_key_set"] = bool(key)
arch["api_key"] = (key[:6] + "") if key else ""
return out
@app.put("/config", tags=["settings"])
async def put_config(patch: dict):
"""部分更新设置并重建管线。示例:
{"worker": {"backend": "openai", "base_url": "http://127.0.0.1:11434/v1", "temperature": 0.4}}
architect.api_key 传空串 = 保留原值(与打码返回配套)。
"""
arch_patch = (patch or {}).get("architect")
if isinstance(arch_patch, dict) and not str(arch_patch.get("api_key") or "").strip():
arch_patch.pop("api_key", None)
try:
merged = settings_store().update(patch)
except Exception as e:
raise HTTPException(status_code=400, detail=f"设置非法: {e}")
rebuild_pipeline()
key = merged.get("architect", {}).get("api_key") or ""
merged["architect"]["api_key_set"] = bool(key)
merged["architect"]["api_key"] = (key[:6] + "") if key else ""
return merged
@app.post("/config/reset", tags=["settings"])
@@ -1110,4 +1183,6 @@ def _maybe_enqueue(result) -> None:
if __name__ == "__main__":
import uvicorn
uvicorn.run("gateway.api:app", host="0.0.0.0", port=8000, reload=False)
# 默认只绑定回环地址:网关能读写工作区文件/执行命令,不宜默认暴露到局域网
# (需要局域网访问时显式 --host 0.0.0.0,并设置 GATEWAY_TRUSTED_HOSTS 放行对应主机名)
uvicorn.run("gateway.api:app", host="127.0.0.1", port=8000, reload=False)