feat(v3): Web 应用化基线(异步任务/SSE/llama-server 管理/Vue SPA 四页 + 设置页整页滚动修复)

This commit is contained in:
tzt
2026-09-01 08:31:47 +08:00
parent 8d36eeec59
commit 3bbdcb7cc7
60 changed files with 7236 additions and 1160 deletions
+328
View File
@@ -0,0 +1,328 @@
"""异步后台任务注册表(T1:后端异步化核心)。
设计原则(对齐 v3 方案 D1-D6):
- asyncio 原生,无 Celery/Redis/外部队列
- 每个 request_id -> TaskInfo(状态/开始时间/结果或错误)
- 任务写 runs/{id}/workspace.jsonSSE 生成器只读该文件(D3:不改 pipeline)
- 定期清理已完成任务(防内存泄漏)
"""
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, AsyncGenerator, Dict, Optional
# runs/ 目录(与 pipeline.py 默认一致)
RUNS_DIR = Path("runs")
# 任务状态
STATE_PENDING = "pending"
STATE_RUNNING = "running"
STATE_DONE = "done"
STATE_FAILED = "failed"
@dataclass
class TaskInfo:
"""一个后台任务的状态快照。"""
request_id: str
state: str = STATE_PENDING # pending | running | done | failed
started_at: float = 0.0 # time.time()
finished_at: float = 0.0 # time.time()done/failed 时)
error: Optional[str] = None # failed 时错误信息
# PipelineResult 字段(done 时填充)
response: Optional[str] = None
status: Optional[str] = None # done | fast_path | escalated | failed
fast_path: bool = False
rounds_used: int = 0
api_input_tokens: int = 0
api_output_tokens: int = 0
cost_est: float = 0.0
model_used: Optional[str] = None
latency_ms: float = 0.0
workspace_path: Optional[str] = None
error_detail: Optional[str] = None
route: list = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"request_id": self.request_id,
"state": self.state,
"started_at": self.started_at,
"finished_at": self.finished_at,
"error": self.error,
"response": self.response,
"status": self.status,
"fast_path": self.fast_path,
"rounds_used": self.rounds_used,
"api_input_tokens": self.api_input_tokens,
"api_output_tokens": self.api_output_tokens,
"cost_est": self.cost_est,
"model_used": self.model_used,
"latency_ms": round(self.latency_ms, 2),
"workspace_path": self.workspace_path,
"route": self.route,
"error_detail": self.error_detail,
}
def to_result_event(self) -> Dict[str, Any]:
"""终态 SSE result 事件 payload。"""
return {
"type": "result",
"request_id": self.request_id,
"response": self.response or "",
"status": self.status,
"fast_path": self.fast_path,
"rounds_used": self.rounds_used,
"api_input_tokens": self.api_input_tokens,
"api_output_tokens": self.api_output_tokens,
"cost_est": self.cost_est,
"model_used": self.model_used,
"latency_ms": round(self.latency_ms, 2),
"route": self.route,
"error": self.error,
}
class JobStore:
"""asyncio 后台任务注册表。
线程安全(asyncio 事件循环单线程,不需要额外锁)。
任务由 register() 注册、由 _task_done() 填充结果。
SSE 生成器调用 watch_file() 轮询 workspace.json 变化。
"""
def __init__(self, max_age_seconds: float = 3600.0, max_running: int = 50):
"""
Args:
max_age_seconds: 已完成任务在内存中保留时间(秒),超时后自动清理
max_running: 最大同时运行任务数,超出后拒绝新任务
"""
self._tasks: Dict[str, TaskInfo] = {}
self._asyncio_tasks: Dict[str, "asyncio.Task[None]"] = {} # request_id -> asyncio.Task
self._cancel_events: Dict[str, asyncio.Event] = {} # request_id -> cancel Event
self.max_age_seconds = max_age_seconds
self.max_running = max_running
self._poll_interval = 0.3 # workspace.json 轮询间隔(秒)
# ─────────────────────────────────────────────────────────────────
# 公共 API
# ─────────────────────────────────────────────────────────────────
def register(self, request_id: str) -> tuple[bool, str]:
"""注册一个 pending 任务。返回 (True, "") 成功,(False, reason) 容量满。"""
running = [t for t in self._tasks.values() if t.state == STATE_RUNNING]
if len(running) >= self.max_running:
return False, f"同时运行任务已达上限 {self.max_running},请稍后重试"
if request_id in self._tasks:
return False, f"任务 {request_id} 已存在"
self._tasks[request_id] = TaskInfo(
request_id=request_id,
state=STATE_PENDING,
started_at=time.time(),
)
return True, ""
def get(self, request_id: str) -> Optional[TaskInfo]:
"""查询任务状态。"""
return self._tasks.get(request_id)
def list_all(self) -> Dict[str, TaskInfo]:
"""列出所有任务(含已完成)。"""
return dict(self._tasks)
def list_running(self) -> Dict[str, TaskInfo]:
"""只列出运行中任务。"""
return {k: v for k, v in self._tasks.items() if v.state == STATE_RUNNING}
def submit(
self,
request_id: str,
coro, # type: asyncio.coroutine
) -> "asyncio.Task[None]":
"""提交协程到后台运行;内部注册 asyncio.Task。"""
task = asyncio.create_task(self._run_wrapper(request_id, coro))
self._asyncio_tasks[request_id] = task
return task
def new_cancel(self, request_id: str) -> asyncio.Event:
"""为 SSE 连接创建一个取消事件。"""
evt = asyncio.Event()
self._cancel_events[request_id] = evt
return evt
def get_cancel(self, request_id: str) -> Optional[asyncio.Event]:
"""获取已有取消事件。"""
return self._cancel_events.get(request_id)
# ─────────────────────────────────────────────────────────────────
# 内部:任务运行包装
# ─────────────────────────────────────────────────────────────────
async def _run_wrapper(self, request_id: str, coro):
"""把用户协程包装成可追踪的任务:更新状态、捕获异常、清理。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_RUNNING
try:
await coro
except Exception as exc: # pragma: no cover
if info:
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = f"unhandled:{type(exc).__name__}:{exc}"
finally:
# 清理 asyncio task 引用
self._asyncio_tasks.pop(request_id, None)
# 定期 GC 已完成任务
self._cleanup_aged()
def _task_done(
self,
request_id: str,
result: "PipelineResult", # from pipeline.PipelineResult
) -> None:
"""任务正常完成时由调用方调用,写入结果。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_DONE
info.finished_at = time.time()
info.response = result.response
info.status = result.status
info.fast_path = result.fast_path
info.rounds_used = result.rounds_used
info.api_input_tokens = result.api_input_tokens
info.api_output_tokens = result.api_output_tokens
info.cost_est = result.cost_est
info.model_used = result.model_used
info.latency_ms = result.latency_ms
info.workspace_path = result.workspace_path
info.route = list(result.route) if result.route else []
if result.error:
info.error_detail = result.error
def _task_failed(
self,
request_id: str,
error: str,
detail: Optional[str] = None,
) -> None:
"""任务异常结束时调用。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = error
info.error_detail = detail
# ─────────────────────────────────────────────────────────────────
# SSE 专用:workspace.json 文件轮询
# ─────────────────────────────────────────────────────────────────
async def watch_workspace(
self,
request_id: str,
cancel_event: asyncio.Event,
) -> AsyncGenerator[Dict[str, Any], None]:
"""监视 runs/{id}/workspace.jsonyield 单条事件。
事件类型:
- {"type": "status", "value": <state>, "request_id": <id>}
- {"type": "workspace", "version": <n>, "workspace": <dict>}
- {"type": "error", "detail": <str>}
当任务进入 done/failed 状态或 cancel_event 被 set 时停止。
"""
ws_path = RUNS_DIR / request_id / "workspace.json"
seen_mtime: float = 0.0
seen_size: int = 0
while not cancel_event.is_set():
info = self.get(request_id)
# 检查终态
if info and info.state in (STATE_DONE, STATE_FAILED):
if info.state == STATE_FAILED:
yield {"type": "error", "detail": info.error or "任务失败"}
# result 由 /runs/{id}/status 提供,此处只推送终态 status
yield {"type": "status", "value": info.state, "request_id": request_id}
break
# 读文件变化(mtime + size 双检)
if ws_path.exists():
try:
stat = ws_path.stat()
if stat.st_mtime != seen_mtime or stat.st_size != seen_size:
raw = ws_path.read_text(encoding="utf-8")
data = json.loads(raw)
version = int(data.get("meta", {}).get("round", 0))
seen_mtime = stat.st_mtime
seen_size = stat.st_size
yield {
"type": "workspace",
"version": version,
"state": info.state if info else STATE_RUNNING,
"request_id": request_id,
"workspace": data,
}
except (json.JSONDecodeError, OSError):
pass # 文件正在写入,忽略
await asyncio.sleep(self._poll_interval)
# 连接关闭前最后推一次终态
info = self.get(request_id)
if info:
yield {"type": "status", "value": info.state, "request_id": request_id}
# ─────────────────────────────────────────────────────────────────
# 内存清理
# ─────────────────────────────────────────────────────────────────
def _cleanup_aged(self) -> None:
"""删除超过 max_age_seconds 的已完成任务引用。"""
now = time.time()
to_remove = [
rid for rid, info in self._tasks.items()
if info.state in (STATE_DONE, STATE_FAILED)
and (now - info.finished_at) > self.max_age_seconds
]
for rid in to_remove:
self._tasks.pop(rid, None)
def cancel(self, request_id: str) -> bool:
"""取消运行中的任务。返回 True 找到并取消,False 未找到。"""
task = self._asyncio_tasks.get(request_id)
if task is None:
return False
task.cancel()
info = self.get(request_id)
if info:
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = "cancelled_by_user"
return True
# ─────────────────────────────────────────────────────────────────────────────
# 全局单例(gateway 进程内共享)
# ─────────────────────────────────────────────────────────────────────────────
_store: Optional[JobStore] = None
def get_job_store() -> JobStore:
global _store
if _store is None:
_store = JobStore(max_age_seconds=3600.0, max_running=50)
return _store
def reset_job_store() -> None:
"""测试用:重置全局注册表。"""
global _store
_store = None