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:
+80
-5
@@ -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:
|
||||
# ---------------- v2:workspace / 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)
|
||||
|
||||
Reference in New Issue
Block a user