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
+2 -2
View File
@@ -88,8 +88,8 @@ runtime:
cpu: {ngl: 0, ctx: 8192} cpu: {ngl: 0, ctx: 8192}
architect: # 大模型(API architect: # 大模型(API
model: deepseek-chat model: deepseek-v4-flash
base_url: https://api.deepseek.com/v1 base_url: https://api.deepseek.com
api_key_env: DEEPSEEK_API_KEY api_key_env: DEEPSEEK_API_KEY
temperature: 0.2 temperature: 0.2
timeout_s: 60 timeout_s: 60
+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
+453
View File
@@ -0,0 +1,453 @@
"""llama-server 进程管理 & 模型下载。
职责:
- 启动/停止本地 llama-server 子进程(Windows 兼容)
- 探测已有 .gguf 模型文件
- 从 HuggingFace URL 下载模型(支持 huggingface.co 路径别名)
- 下载进度可通过 SSE /llama/download/stream 订阅
用法:
from gateway.llama_manager import get_llama_manager
lm = get_llama_manager()
await lm.start(model="models/qwen3.5-4b-q4_k_m.gguf")
await lm.stop()
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, AsyncGenerator, Optional
# ─────────────────────────────────────────────────────────────────────────────
# 路径配置(与 config.yaml runtime.llama_server 段保持一致)
# ─────────────────────────────────────────────────────────────────────────────
_ROOT = Path(__file__).resolve().parent.parent # E:\projectAIpopular
BIN_DIR = _ROOT / "bin"
MODELS_DIR = _ROOT / "models"
PID_FILE = _ROOT / "data" / "llama-server.pid"
LOG_FILE = _ROOT / "data" / "llama-server.log"
# 确保目录存在
BIN_DIR.mkdir(parents=True, exist_ok=True)
MODELS_DIR.mkdir(parents=True, exist_ok=True)
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
# ─────────────────────────────────────────────────────────────────────────────
# 数据模型
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class LlamaServerStatus:
running: bool
pid: Optional[int] = None
model: Optional[str] = None
port: Optional[int] = None
base_url: Optional[str] = None
started_at: Optional[float] = None
error: Optional[str] = None
@dataclass
class DownloadProgress:
url: str
dest: str
total_bytes: Optional[int] = None
downloaded_bytes: int = 0
progress_pct: float = 0.0
speed: str = ""
eta: str = ""
done: bool = False
error: Optional[str] = None
# ─────────────────────────────────────────────────────────────────────────────
# llama_manager 单例
# ─────────────────────────────────────────────────────────────────────────────
class LlamaManager:
_instance: Optional["LlamaManager"] = None
def __init__(self):
self._proc: Optional[subprocess.Popen] = None
self._pid: Optional[int] = None
self._model: Optional[str] = None
self._port: Optional[int] = None
self._started_at: Optional[float] = None
self._downloading: dict[str, DownloadProgress] = {} # url -> progress
self._dl_lock = threading.Lock()
# 加载已有进程
self._load_pid()
# ── 进程持久化 ─────────────────────────────────────────────────────────
def _load_pid(self) -> None:
"""从 pid 文件恢复进程引用(进程仍在运行时)。"""
if not PID_FILE.exists():
return
try:
pid = int(PID_FILE.read_text().strip())
os.kill(pid, 0) # 检查进程是否存活
# 进程还在,尝试接管(通过 cmdline 判断是否是 llama-server
self._pid = pid
self._proc = self._attach_to_process(pid)
except (ValueError, FileNotFoundError, OSError):
PID_FILE.unlink(missing_ok=True)
def _attach_to_process(self, pid: int) -> Optional[subprocess.Popen]:
"""通过 pid 重新关联到 Popen(仅作状态恢复,不拥有 stdout)。"""
try:
return subprocess.Popen(
[sys.executable, "-c",
f"import os; os.kill({pid}, 0)"], # 存活检查
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
return None
def _write_pid(self, pid: int) -> None:
PID_FILE.write_text(str(pid), encoding="utf-8")
def _clear_pid(self) -> None:
PID_FILE.unlink(missing_ok=True)
# ── 进程管理 ─────────────────────────────────────────────────────────
def find_binary(self) -> Optional[Path]:
"""查找 llama-server 可执行文件。"""
candidates = [
BIN_DIR / "llama-server.exe",
BIN_DIR / "llama-server",
_ROOT / "llama-server.exe",
_ROOT / "llama-server",
]
for p in candidates:
if p.exists():
return p
# PATH 中查找
import shutil
found = shutil.which("llama-server") or shutil.which("llama-server.exe")
if found:
return Path(found)
return None
def status(self) -> LlamaServerStatus:
"""返回当前服务状态。"""
if self._proc is None or self._pid is None:
return LlamaServerStatus(running=False)
try:
# 检查进程是否存活
os.kill(self._pid, 0)
except OSError:
# 进程已死
self._proc = None
self._pid = None
self._model = None
self._port = None
self._started_at = None
self._clear_pid()
return LlamaServerStatus(running=False)
return LlamaServerStatus(
running=True,
pid=self._pid,
model=self._model,
port=self._port,
base_url=f"http://127.0.0.1:{self._port}/v1",
started_at=self._started_at,
)
async def start(
self,
model: str,
port: int = 8901,
ngl: int = 99,
ctx: int = 4096,
extra_args: Optional[list] = None,
) -> LlamaServerStatus:
"""启动 llama-server,阻塞直到监听就绪或超时。"""
if self.status().running:
s = self.status()
if s.model == model and s.port == port:
return s # 已是同一模型,无需重启
await self.stop()
binary = self.find_binary()
if binary is None:
return LlamaServerStatus(
running=False,
error="未找到 llama-server 可执行文件。"
"请将 llama-server.exe 放入 bin/ 目录,"
"或从 https://github.com/ggerganov/llama.cpp/releases 下载。",
)
model_path = Path(model)
if not model_path.is_absolute():
model_path = MODELS_DIR / model
if not model_path.exists():
return LlamaServerStatus(
running=False,
error=f"模型文件不存在:{model_path}"
"请先下载模型,或在设置页填写 HuggingFace URL 下载。",
)
args = [
str(binary),
"-m", str(model_path),
"-c", str(ctx),
"-ngl", str(ngl),
"--port", str(port),
"--host", "127.0.0.1",
]
if extra_args:
args.extend(extra_args)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
log_f = open(LOG_FILE, "w", encoding="utf-8", buffering=1)
try:
self._proc = subprocess.Popen(
args,
stdout=log_f,
stderr=subprocess.STDOUT,
cwd=str(_ROOT),
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
)
except OSError as e:
log_f.close()
return LlamaServerStatus(running=False, error=f"启动失败:{e}")
self._pid = self._proc.pid
self._model = str(model_path)
self._port = port
self._started_at = time.time()
self._write_pid(self._pid)
# 等待服务就绪
ok = await self._wait_until_ready(port, timeout=30)
if not ok:
await self.stop()
return LlamaServerStatus(
running=False,
error=f"llama-server 启动后 {port} 端口在 30 秒内未响应",
)
return self.status()
async def stop(self) -> None:
"""优雅停止 llama-server。"""
if self._pid is None:
self._proc = None
return
try:
if sys.platform == "win32":
# Windows: CTRL_BREAK_EVENT 或 taskkill
subprocess.run(
["taskkill", "/PID", str(self._pid), "/T", "/F"],
capture_output=True,
timeout=10,
)
else:
os.kill(self._pid, 15) # SIGTERM
time.sleep(1)
try:
os.kill(self._pid, 0)
os.kill(self._pid, 9)
except OSError:
pass
except Exception:
pass
finally:
self._proc = None
self._pid = None
self._model = None
self._port = None
self._started_at = None
self._clear_pid()
async def _wait_until_ready(self, port: int, timeout: float = 30) -> bool:
"""轮询检查端口是否开始监听。"""
import httpx
url = f"http://127.0.0.1:{port}/v1/models"
deadline = time.time() + timeout
async with httpx.AsyncClient(timeout=5.0) as client:
while time.time() < deadline:
try:
r = await client.get(url)
if r.status_code < 500:
return True
except (httpx.ConnectError, httpx.ReadTimeout, OSError):
pass
await asyncio.sleep(0.5)
return False
# ── 模型列表 ─────────────────────────────────────────────────────────
def list_local_models(self) -> list[dict[str, str]]:
"""列出 models/ 目录下所有 .gguf 文件。"""
models = []
for p in MODELS_DIR.glob("*.gguf"):
size_mb = p.stat().st_size // (1024 * 1024)
models.append({
"id": p.name,
"name": p.name,
"size_mb": size_mb,
"path": str(p),
})
return sorted(models, key=lambda m: m["name"])
# ── 模型下载 ─────────────────────────────────────────────────────────
async def download_model(
self,
url: str,
dest: Optional[str] = None,
) -> DownloadProgress:
"""从 HuggingFace 或直链下载 .gguf 模型文件。
HuggingFace 路径别名:用户输入 "Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf"
自动转换为 "https://huggingface.co/<repo>/resolve/main/<file>"
支持断点续传(Content-Range)。
返回 DownloadProgress 对象(含当前进度),进度通过 get_download_progress() 查询。
"""
import httpx
# 路径别名转换
if not url.startswith("http"):
url = f"https://huggingface.co/{url}/resolve/main"
# 解析文件名
filename = url.rstrip("/").split("/")[-1]
if not filename.endswith(".gguf"):
filename += ".gguf"
if dest:
dest_path = Path(dest)
else:
dest_path = MODELS_DIR / filename
# 构造 HTTP 头
headers = {}
resume_bytes = 0
if dest_path.exists():
resume_bytes = dest_path.stat().st_size
headers["Range"] = f"bytes={resume_bytes}-"
# 获取文件大小
total_bytes: Optional[int] = None
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0, read=60.0, write=30.0, pool=10.0), follow_redirects=True) as client:
head = await client.head(url, headers={"Range": "bytes=0-0"})
total_raw = head.headers.get("Content-Length")
if total_raw:
total_bytes = int(total_raw)
# Content-Range 响应时 total_bytes 在 Content-Range 头里
cr = head.headers.get("Content-Range", "")
m = re.search(r"/(\d+)", cr)
if m:
total_bytes = int(m.group(1))
except Exception:
pass
prog = DownloadProgress(
url=url,
dest=str(dest_path),
total_bytes=total_bytes,
downloaded_bytes=resume_bytes,
)
with self._dl_lock:
self._downloading[url] = prog
try:
mode = "ab" if resume_bytes > 0 else "wb"
async with httpx.AsyncClient(
timeout=httpx.Timeout(300.0, connect=10.0, read=300.0, write=30.0, pool=10.0),
follow_redirects=True,
) as client:
t0 = time.time()
last_bytes = resume_bytes
async with client.stream("GET", url, headers=headers) as resp:
if resp.status_code not in (200, 206):
raise RuntimeError(f"HTTP {resp.status_code}")
with open(dest_path, mode) as f:
async for chunk in resp.aiter_bytes(chunk_size=8192):
f.write(chunk)
prog.downloaded_bytes += len(chunk)
# 速度 & ETA
elapsed = time.time() - t0
if elapsed > 0.5:
speed_bps = (prog.downloaded_bytes - last_bytes) / elapsed
speed_str = _format_speed(speed_bps)
if prog.total_bytes and speed_bps > 0:
remain = prog.total_bytes - prog.downloaded_bytes
eta_s = remain / speed_bps
prog.eta = _format_eta(eta_s)
else:
prog.eta = ""
prog.speed = speed_str
last_bytes = prog.downloaded_bytes
t0 = time.time()
if prog.total_bytes:
prog.progress_pct = min(prog.downloaded_bytes / prog.total_bytes * 100, 100)
except Exception as e:
prog.error = str(e)
finally:
prog.done = True
with self._dl_lock:
self._downloading[url] = prog
return prog
def get_download_progress(self, url: str) -> Optional[DownloadProgress]:
"""查询下载进度。"""
with self._dl_lock:
return self._downloading.get(url)
def list_downloads(self) -> list[DownloadProgress]:
"""列出所有活跃下载。"""
with self._dl_lock:
return list(self._downloading.values())
def _format_speed(bps: float) -> str:
if bps >= 1e9:
return f"{bps/1e9:.1f} GB/s"
if bps >= 1e6:
return f"{bps/1e6:.1f} MB/s"
if bps >= 1e3:
return f"{bps/1e3:.1f} KB/s"
return f"{bps:.0f} B/s"
def _format_eta(seconds: float) -> str:
if seconds < 60:
return f"{seconds:.0f}s"
if seconds < 3600:
return f"{seconds/60:.0f}m"
return f"{seconds/3600:.1f}h"
# ─────────────────────────────────────────────────────────────────────────────
# 全局单例
# ─────────────────────────────────────────────────────────────────────────────
_lm: Optional[LlamaManager] = None
def get_llama_manager() -> LlamaManager:
global _lm
if _lm is None:
_lm = LlamaManager()
return _lm
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.metrics-view[data-v-51f92136]{height:100%;padding:20px 24px;overflow-y:auto}.metrics-header[data-v-51f92136]{justify-content:space-between;align-items:center;margin-bottom:20px;display:flex}.metrics-header h2[data-v-51f92136]{margin:0;font-size:20px}.refresh[data-v-51f92136]{cursor:pointer;background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:6px 14px}.loading[data-v-51f92136],.error[data-v-51f92136]{text-align:center;color:#9ca3af;padding:40px}.error[data-v-51f92136]{color:#dc2626}.card-grid[data-v-51f92136]{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-bottom:24px;display:grid}.metric-card[data-v-51f92136]{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:16px}.metric-card.highlight[data-v-51f92136]{background:#eff6ff;border-color:#2563eb}.metric-card h3[data-v-51f92136]{color:#374151;margin:0 0 12px;font-size:14px}.kv-list[data-v-51f92136]{grid-template-columns:1fr 1fr;gap:6px 12px;font-size:13px;display:grid}.kv-list span[data-v-51f92136]{color:#6b7280}.kv-list b[data-v-51f92136]{color:#111;text-align:right}.review-card[data-v-51f92136]{grid-column:span 2}.review-stats[data-v-51f92136]{gap:24px;margin-bottom:12px;display:flex}.stat-item[data-v-51f92136]{flex-direction:column;align-items:center;display:flex}.stat-num[data-v-51f92136]{color:#2563eb;font-size:28px;font-weight:700}.stat-label[data-v-51f92136]{color:#6b7280;font-size:12px}.progress-wrap[data-v-51f92136]{background:#e5e7eb;border-radius:99px;height:8px;margin-bottom:6px;overflow:hidden}.reviewed-bar[data-v-51f92136]{background:#16a34a;height:100%;transition:width .5s}.review-rate[data-v-51f92136]{color:#6b7280;margin:0;font-size:13px}.raw-json[data-v-51f92136]{background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px}.raw-json summary[data-v-51f92136]{cursor:pointer;color:#6b7280;padding:10px 14px;font-size:13px}.raw-json pre[data-v-51f92136]{white-space:pre-wrap;border-top:1px solid #e5e7eb;margin:0;padding:10px 14px;font-size:12px}
@@ -0,0 +1 @@
import{A as e,C as t,D as n,F as r,O as i,R as a,T as o,j as s,o as c,t as l,w as u,x as d,z as f}from"./index-BnlAnYlB.js";var p={class:`metrics-view`},m={key:0,class:`loading`},h={key:1,class:`error`},g={class:`card-grid`},_={class:`metric-card`},v={class:`kv-list`},y={class:`metric-card`},b={class:`kv-list`},x={key:0,class:`metric-card highlight`},S={class:`kv-list`},C={key:1,class:`metric-card review-card`},w={class:`review-stats`},T={class:`stat-item`},E={class:`stat-num`},D={class:`stat-item`},O={class:`stat-num`},k={key:0,class:`progress-wrap`},A={class:`review-rate`},j={class:`raw-json`},M=l(n({__name:`MetricsView`,setup(n){let l=r(null),M=r(!1),N=r(``);async function P(){M.value=!0,N.value=``;try{l.value=await c()}catch(e){N.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{M.value=!1}}return i(P),(n,r)=>(e(),o(`div`,p,[t(`header`,{class:`metrics-header`},[r[0]||=t(`h2`,null,`系统指标`,-1),t(`button`,{class:`refresh`,onClick:P},`🔄 刷新`)]),M.value?(e(),o(`div`,m,`加载中…`)):N.value?(e(),o(`div`,h,f(N.value),1)):l.value?(e(),o(d,{key:2},[t(`div`,g,[t(`div`,_,[r[1]||=t(`h3`,null,`路由器(v1`,-1),t(`div`,v,[(e(!0),o(d,null,s(l.value.router,(n,r)=>(e(),o(d,{key:r},[t(`span`,null,f(r),1),t(`b`,null,f(n),1)],64))),128))])]),t(`div`,y,[r[2]||=t(`h3`,null,`缓存`,-1),t(`div`,b,[(e(!0),o(d,null,s(l.value.cache,(n,r)=>(e(),o(d,{key:r},[t(`span`,null,f(r),1),t(`b`,null,f(n),1)],64))),128))])]),l.value.v2?(e(),o(`div`,x,[r[3]||=t(`h3`,null,`协作管线(v2`,-1),t(`div`,S,[(e(!0),o(d,null,s(l.value.v2,(n,r)=>(e(),o(d,{key:r},[t(`span`,null,f(r),1),t(`b`,null,f(n),1)],64))),128))])])):u(``,!0),l.value.review?(e(),o(`div`,C,[r[6]||=t(`h3`,null,`人工检验`,-1),t(`div`,w,[t(`div`,T,[t(`span`,E,f(l.value.review.pending),1),r[4]||=t(`span`,{class:`stat-label`},`待审核`,-1)]),t(`div`,D,[t(`span`,O,f(l.value.review.total),1),r[5]||=t(`span`,{class:`stat-label`},`总提交`,-1)])]),l.value.review.total>0?(e(),o(`div`,k,[t(`div`,{class:`reviewed-bar`,style:a({width:`${(l.value.review.total-l.value.review.pending)/l.value.review.total*100}%`})},null,4)])):u(``,!0),t(`p`,A,` 通过率: `+f(((l.value.review.total-l.value.review.pending)/l.value.review.total*100).toFixed(1))+`% `,1)])):u(``,!0)]),t(`details`,j,[r[7]||=t(`summary`,null,`原始 JSON`,-1),t(`pre`,null,f(JSON.stringify(l.value,null,2)),1)])],64)):u(``,!0)]))}}),[[`__scopeId`,`data-v-51f92136`]]);export{M as default};
@@ -0,0 +1 @@
.review-view[data-v-19c16eff]{height:100%;padding:20px 24px;overflow-y:auto}.review-header[data-v-19c16eff]{justify-content:space-between;align-items:center;margin-bottom:20px;display:flex}.review-header h2[data-v-19c16eff]{margin:0;font-size:20px}.controls[data-v-19c16eff]{gap:8px;display:flex}button[data-v-19c16eff]{cursor:pointer;background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:6px 14px;font-size:13px}button.active[data-v-19c16eff]{color:#fff;background:#2563eb;border-color:#2563eb}.refresh-btn[data-v-19c16eff]{margin-left:auto}.loading[data-v-19c16eff],.error[data-v-19c16eff],.empty[data-v-19c16eff]{text-align:center;color:#9ca3af;padding:40px}.error[data-v-19c16eff]{color:#dc2626}.queue-list[data-v-19c16eff]{flex-direction:column;gap:16px;display:flex}.review-card[data-v-19c16eff]{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:16px}.card-header[data-v-19c16eff]{flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:10px;display:flex}.card-id[data-v-19c16eff]{color:#6b7280;font-family:monospace;font-size:12px}.verdict-badge[data-v-19c16eff]{border-radius:99px;padding:2px 8px;font-size:12px;font-weight:600}.verdict-badge.pending[data-v-19c16eff]{color:#92400e;background:#fef3c7}.verdict-badge.approved[data-v-19c16eff]{color:#16a34a;background:#dcfce7}.verdict-badge.rejected[data-v-19c16eff]{color:#dc2626;background:#fee2e2}.tags[data-v-19c16eff]{gap:4px;display:flex}.tag[data-v-19c16eff]{color:#3730a3;background:#e0e7ff;border-radius:4px;padding:1px 6px;font-size:11px}.date[data-v-19c16eff]{color:#9ca3af;margin-left:auto;font-size:11px}.query-block[data-v-19c16eff],.response-block[data-v-19c16eff]{margin-bottom:8px;font-size:13px;line-height:1.6}.query-block pre[data-v-19c16eff],.response-block pre[data-v-19c16eff]{white-space:pre-wrap;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;margin:4px 0 0;padding:6px 10px;font-size:13px}.actions[data-v-19c16eff]{flex-direction:column;gap:8px;margin-top:10px;display:flex}textarea[data-v-19c16eff]{resize:vertical;box-sizing:border-box;border:1px solid #d1d5db;border-radius:6px;width:100%;padding:8px 10px;font-family:inherit;font-size:13px}.btn-row[data-v-19c16eff]{gap:8px;display:flex}.approve[data-v-19c16eff]{color:#16a34a;background:#dcfce7;border-color:#86efac}.reject[data-v-19c16eff]{color:#dc2626;background:#fee2e2;border-color:#fca5a5}.correction[data-v-19c16eff]{background:#fffbeb;border:1px solid #fcd34d;border-radius:4px;margin-top:8px;padding:6px 10px;font-size:13px}
@@ -0,0 +1 @@
import{A as e,C as t,D as n,E as r,F as i,L as a,N as o,O as s,S as c,T as l,b as u,j as d,l as f,m as p,t as m,w as h,x as g,z as _}from"./index-BnlAnYlB.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=m(n({__name:`ReviewView`,setup(n){let m=i([]),L=i(!1),R=i(``),z=i(`pending`),B=i({}),V=c(()=>z.value===`all`?m.value:m.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{m.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await p(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return s(H),(n,i)=>(e(),l(`div`,v,[t(`header`,y,[i[4]||=t(`h2`,null,`人工检验队列`,-1),t(`div`,b,[t(`button`,{class:a({active:z.value===`all`}),onClick:i[0]||=e=>z.value=`all`},`全部`,2),t(`button`,{class:a({active:z.value===`pending`}),onClick:i[1]||=e=>z.value=`pending`},`待审核`,2),t(`button`,{class:a({active:z.value===`approved`}),onClick:i[2]||=e=>z.value=`approved`},`已通过`,2),t(`button`,{class:a({active:z.value===`rejected`}),onClick:i[3]||=e=>z.value=`rejected`},`已拒绝`,2),t(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(e(),l(`div`,x,`加载中…`)):R.value?(e(),l(`div`,S,_(R.value),1)):(e(),l(`div`,C,[V.value.length?h(``,!0):(e(),l(`div`,w,`队列为空。`)),(e(!0),l(g,null,d(V.value,n=>(e(),l(`div`,{key:n.id,class:`review-card`},[t(`div`,T,[t(`span`,E,`#`+_(n.id),1),t(`span`,{class:a([`verdict-badge`,n.verdict])},_(n.verdict),3),t(`span`,D,[(e(!0),l(g,null,d(n.tags,t=>(e(),l(`span`,{key:t,class:`tag`},_(t),1))),128))]),t(`span`,O,_(n.created_at),1)]),t(`div`,k,[i[5]||=t(`strong`,null,`Query`,-1),r(_(n.query),1)]),t(`div`,A,[i[6]||=t(`strong`,null,`Response`,-1),t(`pre`,null,_(n.response),1)]),n.verdict===`pending`?(e(),l(`div`,j,[o(t(`textarea`,{"onUpdate:modelValue":e=>B.value[n.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[u,B.value[n.id]]]),t(`div`,N,[t(`button`,{class:`approve`,onClick:e=>U(n.id,`approved`)},`✅ 通过`,8,P),t(`button`,{class:`reject`,onClick:e=>U(n.id,`rejected`)},`❌ 拒绝`,8,F)])])):n.correction?(e(),l(`div`,I,[i[7]||=t(`strong`,null,`修正:`,-1),r(_(n.correction),1)])):h(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-19c16eff`]]);export{L as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
*{box-sizing:border-box;margin:0;padding:0}html,body,#app{color:#111;background:#fff;height:100%;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px}.app{flex-direction:column;height:100%;display:flex}.topbar{background:#fff;border-bottom:1px solid #e5e7eb;flex-shrink:0;align-items:center;gap:24px;height:52px;padding:0 24px;display:flex}.brand{color:#1e40af;white-space:nowrap;font-size:16px;font-weight:700}.nav-links{gap:4px;display:flex}.nav-links a{color:#374151;border-radius:6px;padding:6px 14px;font-size:14px;font-weight:500;text-decoration:none;transition:background .15s}.nav-links a:hover{background:#f3f4f6}.nav-links a.router-link-active{color:#2563eb;background:#eff6ff}.content{flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden}.chat-view[data-v-6692005c]{gap:0;height:100%;display:flex}.sidebar[data-v-6692005c]{background:#f9fafb;border-right:1px solid #e5e7eb;flex-direction:column;width:240px;display:flex;overflow:hidden}.sidebar h3[data-v-6692005c]{color:#6b7280;border-bottom:1px solid #e5e7eb;margin:0;padding:12px 16px;font-size:14px}.session-list[data-v-6692005c]{flex:1;margin:0;padding:8px;list-style:none;overflow-y:auto}.session-item[data-v-6692005c]{cursor:pointer;border-radius:6px;flex-direction:column;gap:2px;margin-bottom:4px;padding:8px 10px;font-size:13px;display:flex}.session-item[data-v-6692005c]:hover{background:#e5e7eb}.session-item.active[data-v-6692005c]{background:#dbeafe}.s-query[data-v-6692005c]{color:#111}.s-status[data-v-6692005c]{color:#9ca3af;font-size:11px}.s-status.done[data-v-6692005c]{color:#16a34a}.s-status.failed[data-v-6692005c]{color:#dc2626}.s-status.running[data-v-6692005c]{color:#2563eb}.main[data-v-6692005c]{flex-direction:column;flex:1;gap:12px;padding:20px 24px;display:flex;overflow:hidden}.empty[data-v-6692005c]{color:#9ca3af;flex-direction:column;flex:1;justify-content:center;align-items:center;gap:8px;display:flex}.hint[data-v-6692005c]{font-size:13px}.user-msg[data-v-6692005c],.assistant-msg[data-v-6692005c]{background:#f3f4f6;border-radius:8px;gap:12px;padding:12px 16px;font-size:14px;line-height:1.6;display:flex}.assistant-msg[data-v-6692005c]{background:#eff6ff}.role-label[data-v-6692005c]{color:#6b7280;min-width:32px;font-size:12px;font-weight:700}.user-msg pre[data-v-6692005c],.assistant-msg pre[data-v-6692005c]{white-space:pre-wrap;margin:0;font-family:inherit}.status-bar[data-v-6692005c]{flex-wrap:wrap;align-items:center;gap:12px;display:flex}.badge[data-v-6692005c]{border-radius:99px;padding:3px 10px;font-size:12px;font-weight:600}.badge.pending[data-v-6692005c]{color:#6b7280;background:#f3f4f6}.badge.running[data-v-6692005c]{color:#2563eb;background:#dbeafe}.badge.done[data-v-6692005c]{color:#16a34a;background:#dcfce7}.badge.failed[data-v-6692005c]{color:#dc2626;background:#fee2e2}.route-path[data-v-6692005c]{color:#9ca3af;font-size:12px}.ws-meta[data-v-6692005c]{color:#9ca3af;gap:16px;font-size:12px;display:flex}.ws-preview[data-v-6692005c]{background:#fafafa;border:1px solid #e5e7eb;border-radius:8px;padding:12px;font-size:13px}.brief-block[data-v-6692005c]{margin-bottom:8px}.tags[data-v-6692005c]{color:#6b7280;margin-left:8px;font-size:12px}.plan-list[data-v-6692005c],.progress-list[data-v-6692005c]{margin:4px 0 0;padding:0;list-style:none}.plan-list li[data-v-6692005c],.progress-list li[data-v-6692005c]{gap:6px;padding:2px 0;display:flex}.step-id[data-v-6692005c]{color:#6b7280;min-width:48px;font-family:monospace}.deps[data-v-6692005c]{color:#9ca3af;font-size:12px}.done[data-v-6692005c]{color:#16a34a}.pending[data-v-6692005c]{color:#9ca3af}.running[data-v-6692005c]{color:#2563eb}.error-msg[data-v-6692005c]{color:#dc2626;background:#fee2e2;border-radius:6px;padding:8px 12px;font-size:13px}.input-bar[data-v-6692005c]{border-top:1px solid #e5e7eb;gap:8px;margin-top:auto;padding-top:12px;display:flex}.input-bar input[data-v-6692005c]{border:1px solid #d1d5db;border-radius:8px;outline:none;flex:1;padding:10px 14px;font-size:14px}.input-bar input[data-v-6692005c]:focus{border-color:#2563eb}.input-bar button[data-v-6692005c]{color:#fff;cursor:pointer;background:#2563eb;border:none;border-radius:8px;padding:10px 20px;font-size:14px}.input-bar button[data-v-6692005c]:disabled{cursor:not-allowed;background:#9ca3af}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+13 -223
View File
@@ -1,224 +1,14 @@
<!DOCTYPE html> <!doctype html>
<html lang="zh-CN"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<title>端云协同 LLM 协作系统</title> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style> <title>webapp</title>
:root{--bg:#0f1115;--panel:#171a21;--panel2:#1d212b;--line:#2a2f3a;--txt:#e6e9ef;--muted:#8b93a3;--acc:#4f8cff;--acc2:#23c46d;--warn:#f5a623;--bad:#ff5c5c;--mono:Consolas,"Courier New",monospace} <script type="module" crossorigin src="/static/assets/index-BnlAnYlB.js"></script>
*{box-sizing:border-box;margin:0;padding:0} <link rel="stylesheet" crossorigin href="/static/assets/index-G8RWfJ2Y.css">
body{background:var(--bg);color:var(--txt);font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;height:100vh;display:flex;flex-direction:column} </head>
header{display:flex;align-items:center;gap:16px;padding:12px 20px;background:var(--panel);border-bottom:1px solid var(--line)} <body>
header h1{font-size:16px;font-weight:600} <div id="app"></div>
header .sub{color:var(--muted);font-size:12px} </body>
.pill{font-size:11px;padding:2px 8px;border-radius:10px;border:1px solid var(--line);color:var(--muted)}
nav{display:flex;gap:4px;padding:8px 20px;background:var(--panel);border-bottom:1px solid var(--line)}
nav button{background:none;border:1px solid transparent;color:var(--muted);padding:6px 14px;border-radius:8px;cursor:pointer;font-size:13px}
nav button.active{color:var(--txt);background:var(--panel2);border-color:var(--line)}
nav button:hover{color:var(--txt)}
main{flex:1;overflow:auto;padding:16px 20px}
.panel{display:none;max-width:1200px;margin:0 auto}
.panel.active{display:block}
.chatrow{display:flex;gap:16px;align-items:flex-start}
.chatbox{flex:1}
.prompt{display:flex;gap:8px;margin-bottom:12px}
textarea{flex:1;resize:vertical;min-height:70px;background:var(--panel2);border:1px solid var(--line);color:var(--txt);border-radius:10px;padding:10px;font-size:14px;font-family:inherit}
textarea:focus{outline:none;border-color:var(--acc)}
button.primary{background:var(--acc);color:#fff;border:none;border-radius:10px;padding:10px 20px;font-size:14px;cursor:pointer}
button.primary:disabled{opacity:.5;cursor:not-allowed}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:14px}
.card h3{font-size:13px;color:var(--muted);margin-bottom:10px;font-weight:600;letter-spacing:.5px}
.resp{white-space:pre-wrap;word-break:break-word;font-size:14px}
.badges{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0}
.b{font-size:11px;padding:2px 8px;border-radius:10px}
.b.ok{background:rgba(35,196,109,.15);color:var(--acc2);border:1px solid rgba(35,196,109,.4)}
.b.fast{background:rgba(79,140,255,.15);color:var(--acc);border:1px solid rgba(79,140,255,.4)}
.b.warn{background:rgba(245,166,35,.15);color:var(--warn);border:1px solid rgba(245,166,35,.4)}
.b.err{background:rgba(255,92,92,.15);color:var(--bad);border:1px solid rgba(255,92,92,.4)}
.b.plain{background:var(--panel2);color:var(--muted);border:1px solid var(--line)}
.chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}
.chip{font-size:11px;font-family:var(--mono);background:var(--panel2);color:var(--muted);padding:2px 7px;border-radius:6px;border:1px solid var(--line)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px}
.stat .v{font-size:22px;font-weight:700}
.stat .k{font-size:12px;color:var(--muted);margin-top:2px}
.step{display:flex;gap:8px;align-items:baseline;padding:6px 0;border-bottom:1px dashed var(--line)}
.step:last-child{border:none}
.step .id{font-family:var(--mono);color:var(--acc);font-weight:600}
.step .st{font-size:11px;margin-left:auto}
.done{color:var(--acc2)}.fail{color:var(--bad)}.block{color:var(--warn)}
details{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:8px}
summary{cursor:pointer;font-weight:600;font-size:13px}
pre.trace{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:10px;font-family:var(--mono);font-size:12px;overflow:auto;color:var(--muted)}
.kw{font-family:var(--mono);color:var(--acc)}
.row-btns{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}
button.small{background:var(--panel2);border:1px solid var(--line);color:var(--txt);border-radius:8px;padding:6px 12px;font-size:12px;cursor:pointer}
button.small:hover{border-color:var(--acc)}
input,select{background:var(--panel2);border:1px solid var(--line);color:var(--txt);border-radius:8px;padding:6px 10px;font-size:13px}
.muted{color:var(--muted)}
.history{max-height:220px;overflow:auto;margin-top:8px}
table{width:100%;border-collapse:collapse;font-size:13px}
.form-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.form-grid label{display:flex;flex-direction:column;gap:5px;font-size:12px;color:var(--muted)}
.form-grid input,.form-grid select{width:100%}
th,td{text-align:left;padding:8px;border-bottom:1px solid var(--line)}
th{color:var(--muted);font-weight:600}
footer{padding:8px 20px;color:var(--muted);font-size:11px;border-top:1px solid var(--line);background:var(--panel)}
</style>
</head>
<body>
<header>
<h1>⚙️ 端云协同 LLM 协作系统</h1>
<span class="sub">Architect(大模型 API)+ Worker(本地小模型)+ 交流文本协议</span>
<span class="pill" id="healthPill"></span>
</header>
<nav>
<button data-tab="chat" class="active">💬 对话</button>
<button data-tab="work">🔗 协作过程</button>
<button data-tab="review">🧑‍💻 人工检验</button>
<button data-tab="metrics">📊 指标</button>
<button data-tab="settings">⚙️ 模型设置</button>
</nav>
<main>
<!-- 对话 -->
<section class="panel active" id="panel-chat">
<div class="chatrow">
<div class="chatbox">
<div class="prompt">
<textarea id="query" placeholder="输入你的问题,例如:用 Python 写一个快速排序并分析复杂度…"></textarea>
<button class="primary" id="sendBtn">发送</button>
</div>
<div class="row-btns">
<button class="small" id="legacyBtn">🔁 与 v1 legacy 对照</button>
<button class="small" id="sampleBtn">🎲 示例</button>
</div>
<div id="chatResult"></div>
<div id="legacyResult" style="margin-top:12px"></div>
</div>
<div id="workspacePanel" style="flex:1;max-width:480px"></div>
</div>
</section>
<!-- 协作过程 -->
<section class="panel" id="panel-work">
<div class="row-btns" style="margin-bottom:10px">
<input id="workRid" placeholder="输入 request_id,留空用最近一次" style="width:300px">
<button class="small" id="loadWorkBtn">加载交流文本</button>
</div>
<div id="workResult"></div>
</section>
<!-- 人工检验 -->
<section class="panel" id="panel-review">
<div class="row-btns" style="margin-bottom:10px">
<select id="reviewStatus"><option value="">全部</option><option value="pending">待审</option><option value="reviewed">已审</option></select>
<button class="small" id="refreshReview">刷新</button>
</div>
<div id="reviewResult"></div>
</section>
<!-- 指标 -->
<section class="panel" id="panel-metrics">
<div class="row-btns" style="margin-bottom:10px"><button class="small" id="refreshMetrics">刷新</button></div>
<div id="metricsResult"></div>
</section>
<!-- 模型设置 -->
<section class="panel" id="panel-settings">
<div class="card">
<h3>⚙️ 内置小模型(Worker</h3>
<div class="form-grid">
<label>后端 backend
<select id="set-worker-backend">
<option value="llama_server">llama_server(内置本地模型)</option>
<option value="openai">openai/apiOllama / vLLM 等兼容端点)</option>
<option value="mock">mock(演示,零运行时)</option>
</select>
</label>
<label>模型路径/名称 <input id="set-worker-model" type="text" placeholder="models/xxx.gguf 或模型名"></label>
<label>端点 base_urlopenai 用) <input id="set-worker-baseurl" type="text" placeholder="http://127.0.0.1:11434/v1"></label>
<label>端口(llama_server <input id="set-worker-port" type="number"></label>
<label>temperature <input id="set-worker-temperature" type="number" step="0.1"></label>
<label>max_fix_attempts <input id="set-worker-fix" type="number"></label>
</div>
</div>
<div class="card">
<h3>🧠 大模型(Architect / API</h3>
<div class="form-grid">
<label>model <input id="set-arch-model" type="text"></label>
<label>base_url <input id="set-arch-baseurl" type="text"></label>
<label>api_key_env <input id="set-arch-keyenv" type="text"></label>
</div>
</div>
<div class="card">
<h3>🔀 管线 pipeline</h3>
<div class="form-grid">
<label>fast_path(快路径直答) <input id="set-pipe-fast" type="checkbox"></label>
<label>rounds_cap <input id="set-pipe-rounds" type="number"></label>
<label>api_token_cap <input id="set-pipe-tokens" type="number"></label>
<label>breach_policy
<select id="set-pipe-breach">
<option value="architect_do">architect_do(兜底代做)</option>
<option value="local_only">local_only(本地降级)</option>
</select>
</label>
</div>
</div>
<div class="row-btns">
<button class="primary" id="saveSettingsBtn">保存并生效</button>
<button class="small" id="resetSettingsBtn">恢复默认</button>
<span class="muted" id="settingsMsg"></span>
</div>
</section>
</main>
<footer>端云协同 LLM 协作系统 · v2 · 交流文本协议 · 北极星:token 下降 ≥80%</footer>
<script>
const $=id=>document.getElementById(id);
let lastRequestId=null;
const esc=s=>{const d=document.createElement("div");d.textContent=s==null?"":String(s);return d.innerHTML;};
function el(tag,cls,txt){const n=document.createElement(tag);if(cls)n.className=cls;if(txt!=null)n.textContent=txt;return n;}
function setTab(t){document.querySelectorAll("nav button").forEach(b=>b.classList.toggle("active",b.dataset.tab===t));document.querySelectorAll(".panel").forEach(p=>p.classList.toggle("active",p.id==="panel-"+t));}
document.querySelectorAll("nav button").forEach(b=>b.onclick=()=>{setTab(b.dataset.tab);if(b.dataset.tab==="review")loadReview();if(b.dataset.tab==="metrics")loadMetrics();if(b.dataset.tab==="settings")loadSettings();});
// ---- 健康状态 ----
async function health(){try{const r=await fetch("/health");const d=await r.json();$("healthPill").textContent="健康 · "+d.domains.length+" 领域";}catch(e){$("healthPill").textContent="离线";}}
// ---- 对话 ----
async function sendChat(){const q=$("query").value.trim();if(!q)return;const btn=$("sendBtn");btn.disabled=true;btn.textContent="运行中…";$("chatResult").innerHTML="";$("legacyResult").innerHTML="";$("workspacePanel").innerHTML="";try{const r=await fetch("/chat",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({query:q})});const d=await r.json();lastRequestId=d.request_id||null;renderChatResult(d);if(d.request_id)loadWorkspace(d.request_id);}catch(e){$("chatResult").appendChild(el("div","resp","请求失败:"+e.message));}finally{btn.disabled=false;btn.textContent="发送";}}
function renderChatResult(d){const c=$("chatResult");c.innerHTML="";const card=el("div","card");card.appendChild(el("h3","","响应"));const b=$("badges0")||el("div","badges");b.innerHTML="";const st=(d.status||"done");b.appendChild(chip(st==="fast_path"?"⚡ 快路径":"🤝 协作",st==="fast_path"?"b fast":(st==="done"?"b ok":"b warn")));b.appendChild(chip("status: "+esc(st),"b plain"));b.appendChild(chip("model: "+esc(d.model_used),"b plain"));b.appendChild(chip("输入 "+d.api_input_tokens+" / 输出 "+d.api_output_tokens+" token","b plain"));b.appendChild(chip("耗时 "+d.latency_ms+" ms","b plain"));b.appendChild(chip("rounds "+d.rounds_used,"b plain"));b.id="badges0";card.appendChild(b);const resp=el("div","resp",d.response||"(空)");card.appendChild(resp);if(d.error)card.appendChild(el("div","resp muted","⚠️ "+d.error));const route=el("div","chips");(d.route||[]).forEach(r=>route.appendChild(el("span","chip",r)));card.appendChild(el("h3","","路由轨迹"));card.appendChild(route);card.appendChild(el("div","muted","request_id: "+(d.request_id||"-")+"(见右侧协作过程)"));c.appendChild(card);}
function chip(txt,cls){const s=el("span","b "+cls,txt);return s;}
// ---- 协作过程 / 交流文本 ----
async function loadWorkspace(rid){try{const r=await fetch("/runs/"+rid+"/workspace");if(!r.ok)throw new Error("无 workspace");const ws=await r.json();renderWorkspace($("workspacePanel"),ws);if($("workRid"))$("workRid").value=rid;renderWorkTab(ws);}catch(e){$("workspacePanel").appendChild(el("div","muted","(该请求无交流文本:"+e.message+""));}}
function renderWorkspace(host,ws){host.innerHTML="";const card=el("div","card");card.appendChild(el("h3","","🤝 交流文本 · "+(ws.request_id||"")));const b=ws.brief||{};card.appendChild(el("div","resp","🎯 "+(b.goal||"")));const plan=el("div","");(b.plan||[]).forEach(p=>{const row=el("div","step");row.appendChild(el("span","id",p.id));row.appendChild(el("span","",p.task||""));plan.appendChild(row);});card.appendChild(el("h3","","计划 (plan)"));card.appendChild(plan);card.appendChild(el("h3","","进度 (progress)"));const pg=el("div","");(ws.progress||[]).forEach(p=>{const row=el("div","step");row.appendChild(el("span","id",p.step));row.appendChild(el("span","",p.summary||""));const st=el("span","st "+(p.status==="done"?"done":p.status==="failed"?"fail":"block"),p.status);row.appendChild(st);pg.appendChild(row);});card.appendChild(pg);if((ws.issues||[]).length){card.appendChild(el("h3","","问题 (issues)"));(ws.issues||[]).forEach(i=>{const dt=el("details","");dt.appendChild(el("summary","","#"+(i.id||"")+" · step "+(i.step||"")));dt.appendChild(el("div","resp","observed: "+(i.observed||"")+"\nask: "+(i.ask||"")));card.appendChild(dt);});}if((ws.decisions||[]).length){card.appendChild(el("h3","","裁决 (decisions)"));(ws.decisions||[]).forEach(dc=>{const dt=el("details","");dt.appendChild(el("summary","","#"+esc(dc.ref)+" · 裁决"));dt.appendChild(el("div","resp",dc.reply||""));card.appendChild(dt);});}if((ws.archive||[]).length){card.appendChild(el("h3","","归档 (archive)"));const ar=el("div","resp");(ws.archive||[]).forEach(a=>ar.appendChild(el("div","muted","▸ "+a)));card.appendChild(ar);}host.appendChild(card);}
function renderWorkTab(ws){const host=$("workResult");if(!host)return;host.innerHTML="";renderWorkspace(host,ws);}
async function loadWorkByRid(){const rid=$("workRid").value.trim()||lastRequestId;if(!rid){$("workResult").innerHTML="<div class=muted>没有 request_id</div>";return;}await loadWorkspace(rid);}
// ---- 人工检验 ----
async function loadReview(){const st=$("reviewStatus").value;const r=await fetch("/review/queue?limit=50&status="+st);const rows=await r.json();const host=$("reviewResult");host.innerHTML="";if(!rows.length){host.appendChild(el("div","muted","暂无记录"));return;}rows.forEach(x=>{const card=el("div","card");card.appendChild(el("h3","","#"+(x.id||"")+" · "+(x.request_id||"")+" · "+(x.status||"")));card.appendChild(el("div","resp",x.query||""));card.appendChild(el("div","muted","tags: "+(x.tags||[]).join(", ")+" · reason: "+(x.reason||"")));if(x.verdict)card.appendChild(el("div","muted","verdict: "+x.verdict+(x.correction?" · correction: "+x.correction:"")));if(x.status==="pending"){const btns=el("div","row-btns");["approve","edit","reject"].forEach(v=>{const b=el("button","small",v);b.onclick=()=>submitReview(x.id,v);btns.appendChild(b);});card.appendChild(btns);}host.appendChild(card);});}
async function submitReview(id,verdict){let correction=null;if(verdict==="edit"){correction=prompt("修正内容:");if(correction==null)return;}const r=await fetch("/review/"+id+"?verdict="+verdict+(correction?"&correction="+encodeURIComponent(correction):""),{method:"POST"});const d=await r.json();if(d.ok)loadReview();else alert(d.detail||"提交失败");}
// ---- 指标 ----
async function loadMetrics(){const r=await fetch("/metrics");const d=await r.json();const host=$("metricsResult");host.innerHTML="";const v2=d.v2||{};const grid=el("div","grid");stat(grid,"总请求",v2.total_requests||0);stat(grid,"快路径命中率",((v2.fast_path_rate||0)*100).toFixed(1)+"%");stat(grid,"熔断次数",v2.breach_count||0);stat(grid,"API 输入 token",v2.api_tokens?v2.api_tokens.input:0);stat(grid,"API 输出 token",v2.api_tokens?v2.api_tokens.output:0);stat(grid,"回合数均值",v2.rounds_used?v2.rounds_used.avg:0);host.appendChild(grid);const card=el("div","card");card.appendChild(el("h3","","状态分布"));const tb=el("table","");const tr=el("tr","");["status","count"].forEach(h=>tr.appendChild(el("th","",h)));tb.appendChild(tr);Object.entries(v2.status_distribution||{}).forEach(([k,v])=>{const r=el("tr","");r.appendChild(el("td","",k));r.appendChild(el("td","",v));tb.appendChild(r);});card.appendChild(tb);host.appendChild(card);const rc=el("div","card");rc.appendChild(el("h3","","回合数分布"));const tb2=el("table","");const tr2=el("tr","");["rounds","count"].forEach(h=>tr2.appendChild(el("th","",h)));tb2.appendChild(tr2);Object.entries((v2.rounds_used||{}).distribution||{}).forEach(([k,v])=>{const r=el("tr","");r.appendChild(el("td","",k));r.appendChild(el("td","",v));tb2.appendChild(r);});rc.appendChild(tb2);host.appendChild(rc);}
function stat(grid,v,k){const s=el("div","stat");s.appendChild(el("div","v",v));s.appendChild(el("div","k",k));grid.appendChild(s);}
// ---- v1 legacy 对照 ----
async function sendLegacy(){const q=$("query").value.trim();if(!q)return;const host=$("legacyResult");host.innerHTML="";try{const r=await fetch("/chat/legacy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({query:q})});const d=await r.json();const card=el("div","card");card.appendChild(el("h3","","🔁 v1 legacy(规则路由)"));card.appendChild(el("div","resp",d.response||""));const chips=el("div","chips");(d.route||[]).forEach(x=>chips.appendChild(el("span","chip",x)));card.appendChild(chips);card.appendChild(el("div","muted","domain: "+(d.domain||"")+" · conf "+(d.confidence||"")+" · latency "+(d.latency_ms||0)+" ms"));host.appendChild(card);}catch(e){host.appendChild(el("div","muted","legacy 调用失败:"+e.message));}}
// ---- 示例 ----
const SAMPLES=["用 Python 实现快速排序,并分析时间与空间复杂度","求解方程 x^2 - 5x + 6 = 0","高血压患者日常饮食需要注意什么","解释一下深度学习中的注意力机制"];
function sample(){$("query").value=SAMPLES[Math.floor(Math.random()*SAMPLES.length)];}
// ---- 模型设置 ----
async function loadSettings(){try{const r=await fetch("/config");const s=await r.json();const w=s.worker||{};$("set-worker-backend").value=w.backend||"llama_server";$("set-worker-model").value=w.model||"";$("set-worker-baseurl").value=w.base_url||"";$("set-worker-port").value=w.port||8901;$("set-worker-temperature").value=w.temperature||0.3;$("set-worker-fix").value=w.max_fix_attempts||2;const a=s.architect||{};$("set-arch-model").value=a.model||"";$("set-arch-baseurl").value=a.base_url||"";$("set-arch-keyenv").value=a.api_key_env||"";const p=s.pipeline||{};$("set-pipe-fast").checked=!!p.fast_path;$("set-pipe-rounds").value=p.rounds_cap||6;$("set-pipe-tokens").value=p.api_token_cap||8000;$("set-pipe-breach").value=p.breach_policy||"architect_do";$("settingsMsg").textContent="";}catch(e){$("settingsMsg").textContent="读取失败:"+e.message;}}
async function saveSettings(){const patch={worker:{backend:$("set-worker-backend").value,model:$("set-worker-model").value,base_url:$("set-worker-baseurl").value,port:parseInt($("set-worker-port").value||8901),temperature:parseFloat($("set-worker-temperature").value||0.3),max_fix_attempts:parseInt($("set-worker-fix").value||2)},architect:{model:$("set-arch-model").value,base_url:$("set-arch-baseurl").value,api_key_env:$("set-arch-keyenv").value},pipeline:{fast_path:$("set-pipe-fast").checked,rounds_cap:parseInt($("set-pipe-rounds").value||6),api_token_cap:parseInt($("set-pipe-tokens").value||8000),breach_policy:$("set-pipe-breach").value}};try{const r=await fetch("/config",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(patch)});const d=await r.json();$("settingsMsg").textContent=r.ok?"✅ 已保存并重建管线":"❌ "+(d.detail||"保存失败");}catch(e){$("settingsMsg").textContent="保存失败:"+e.message;}}
async function resetSettings(){try{await fetch("/config/reset",{method:"POST"});$("settingsMsg").textContent="已恢复默认设置";loadSettings();}catch(e){$("settingsMsg").textContent="重置失败:"+e.message;}}
$("saveSettingsBtn").onclick=saveSettings;
$("resetSettingsBtn").onclick=resetSettings;
$("sendBtn").onclick=sendChat;
$("legacyBtn").onclick=sendLegacy;
$("sampleBtn").onclick=sample;
$("loadWorkBtn").onclick=loadWorkByRid;
$("refreshReview").onclick=loadReview;
$("refreshMetrics").onclick=loadMetrics;
$("query").addEventListener("keydown",e=>{if(e.key==="Enter"&&(e.ctrlKey||e.metaKey))sendChat();});
health();loadMetrics();
</script>
</body>
</html> </html>
+3 -3
View File
@@ -59,7 +59,7 @@ class ArchitectClient:
def __init__( def __init__(
self, self,
model: str, model: str,
base_url: str = "https://api.deepseek.com/v1", base_url: str = "https://api.deepseek.com",
api_key: Optional[str] = None, api_key: Optional[str] = None,
temperature: float = 0.2, temperature: float = 0.2,
timeout_s: float = 60.0, timeout_s: float = 60.0,
@@ -220,8 +220,8 @@ def build_architect(cfg: Dict[str, Any],
_env = get_env or os.environ.get _env = get_env or os.environ.get
key = cfg.get("api_key") or _env(cfg.get("api_key_env", "DEEPSEEK_API_KEY")) key = cfg.get("api_key") or _env(cfg.get("api_key_env", "DEEPSEEK_API_KEY"))
return ArchitectClient( return ArchitectClient(
model=cfg.get("model", "deepseek-chat"), model=cfg.get("model", "deepseek-v4-flash"),
base_url=cfg.get("base_url", "https://api.deepseek.com/v1"), base_url=cfg.get("base_url", "https://api.deepseek.com"),
api_key=key, api_key=key,
temperature=float(cfg.get("temperature", 0.2)), temperature=float(cfg.get("temperature", 0.2)),
timeout_s=float(cfg.get("timeout_s", 60)), timeout_s=float(cfg.get("timeout_s", 60)),
+66 -7
View File
@@ -10,7 +10,7 @@ confidence = 1 - exp(-s),保证 s=1 -> 0.63s=2 -> 0.86s=3 -> 0.95。
from __future__ import annotations from __future__ import annotations
import math import math
from typing import Dict, List, Tuple from typing import Dict, List, Optional, Tuple
from .difficulty import estimate_difficulty from .difficulty import estimate_difficulty
from .models import Classification from .models import Classification
@@ -51,6 +51,14 @@ DOMAIN_RULES: Dict[str, List[Tuple[str, float]]] = {
("知识产权", 1.1), ("版权", 0.9), ("专利", 0.9), ("违约", 0.9), ("赔偿", 0.8), ("知识产权", 1.1), ("版权", 0.9), ("专利", 0.9), ("违约", 0.9), ("赔偿", 0.8),
("仲裁", 0.9), ("劳动法", 1.0), ("刑法", 1.0), ("民法典", 1.0), ("仲裁", 0.9), ("劳动法", 1.0), ("刑法", 1.0), ("民法典", 1.0),
("法规", 0.8), ("条款", 0.7), ("律师", 0.8), ("起诉", 0.9), ("判决", 0.9), ("法规", 0.8), ("条款", 0.7), ("律师", 0.8), ("起诉", 0.9), ("判决", 0.9),
# 劳动法
("加班", 0.9), ("加班费", 1.0), ("工资", 0.8), ("辞退", 0.9), ("裁员", 0.9),
("试用期", 0.9), ("社保", 0.8), ("公积金", 0.8), ("年假", 0.9), ("离职", 0.8),
("解除劳动合同", 1.1), ("经济补偿", 1.0), ("竞业", 1.0),
# 房产/婚姻/消费者
("租房", 0.9), ("买房", 0.9), ("购房", 0.9), ("押金", 0.8), ("房贷", 0.9),
("离婚", 1.0), ("继承", 0.9), ("遗产", 0.9), ("抚养权", 0.9), ("遗嘱", 0.9),
("退款", 0.9), ("退货", 0.8), ("消费者", 0.8), ("七天无理由", 1.0), ("维权", 0.8),
("law", 1.0), ("legal", 1.1), ("contract", 1.0), ("compliance", 1.0), ("law", 1.0), ("legal", 1.1), ("contract", 1.0), ("compliance", 1.0),
("litigation", 1.0), ("copyright", 0.9), ("patent", 0.9), ("trademark", 0.9), ("litigation", 1.0), ("copyright", 0.9), ("patent", 0.9), ("trademark", 0.9),
("liability", 0.9), ("regulatory", 0.8), ("jurisdiction", 0.9), ("liability", 0.9), ("regulatory", 0.8), ("jurisdiction", 0.9),
@@ -61,11 +69,17 @@ DOMAIN_RULES: Dict[str, List[Tuple[str, float]]] = {
("医生", 0.9), ("血压", 0.9), ("高血压", 1.0), ("糖尿病", 1.0), ("感冒", 0.9), ("医生", 0.9), ("血压", 0.9), ("高血压", 1.0), ("糖尿病", 1.0), ("感冒", 0.9),
("剂量", 0.9), ("副作用", 0.9), ("手术", 0.9), ("患者", 0.9), ("剂量", 0.9), ("副作用", 0.9), ("手术", 0.9), ("患者", 0.9),
("吃药", 0.9), ("发烧", 1.0), ("疫苗", 0.9), ("感染", 0.9), ("体检", 0.7), ("吃药", 0.9), ("发烧", 1.0), ("疫苗", 0.9), ("感染", 0.9), ("体检", 0.7),
# 急救/消化/心理/营养/儿科
("烫伤", 1.0), ("烧伤", 1.0), ("止血", 0.9), ("扭伤", 0.9), ("中暑", 1.0),
("急救", 0.9), ("腹泻", 0.9), ("拉肚子", 0.9), ("便秘", 0.9), ("", 0.7),
("失眠", 0.9), ("焦虑", 0.9), ("抑郁", 0.9), ("压力", 0.6), ("睡眠", 0.7),
("减肥", 0.8), ("营养", 0.7), ("卡路里", 0.9), ("儿童", 0.8), ("婴儿", 0.9),
("宝宝", 0.8), ("抗生素", 0.9), ("止咳", 0.9),
("medical", 1.0), ("patient", 0.9), ("symptom", 1.0), ("disease", 0.9), ("medical", 1.0), ("patient", 0.9), ("symptom", 1.0), ("disease", 0.9),
("diagnosis", 1.0), ("treatment", 0.8), ("prescription", 1.0), ("diagnosis", 1.0), ("treatment", 0.8), ("prescription", 1.0),
("dosage", 0.9), ("side effect", 0.9), ("hypertension", 1.0), ("dosage", 0.9), ("side effect", 0.9), ("hypertension", 1.0),
("diabetes", 1.0), ("surgery", 0.8), ("clinic", 0.7), ("vaccine", 0.9), ("diabetes", 1.0), ("surgery", 0.8), ("clinic", 0.7), ("vaccine", 0.9),
("infection", 0.9), ("infection", 0.9), ("first aid", 0.9), ("insomnia", 0.9),
], ],
"general": [ "general": [
("总结", 0.4), ("翻译", 0.4), ("介绍", 0.4), ("解释", 0.3), ("总结", 0.4), ("翻译", 0.4), ("介绍", 0.4), ("解释", 0.3),
@@ -74,6 +88,40 @@ DOMAIN_RULES: Dict[str, List[Tuple[str, float]]] = {
("write an essay", 0.4), ("邮件", 0.4), ("email", 0.3), ("write an essay", 0.4), ("邮件", 0.4), ("email", 0.3),
("推荐", 0.3), ("评价", 0.3), ("推荐", 0.3), ("评价", 0.3),
], ],
"finance": [
("理财", 1.0), ("投资", 1.0), ("基金", 1.0), ("股票", 1.0), ("债券", 0.9),
("存款", 0.9), ("储蓄", 0.8), ("利率", 0.8), ("利息", 0.8), ("贷款", 1.0),
("房贷", 1.0), ("月供", 0.9), ("保险", 0.9), ("理赔", 0.9), ("保费", 0.8),
("信用卡", 1.0), ("征信", 0.9), ("逾期", 0.9), ("分期", 0.8), ("记账", 0.7),
("预算", 0.7), ("理财规划", 1.0), ("收益率", 0.9), ("定投", 0.9),
("invest", 0.8), ("fund", 0.8), ("stock", 0.9), ("loan", 0.9),
("mortgage", 0.9), ("insurance", 0.9), ("credit card", 0.9),
("finance", 0.8), ("money", 0.6), ("lpr", 0.9), ("投资理财", 1.1),
],
"life": [
("菜谱", 0.9), ("做饭", 0.8), ("烹饪", 0.9), ("美食", 0.8), ("做法", 0.7),
("旅行", 0.9), ("旅游", 0.9), ("攻略", 0.8), ("机票", 0.8), ("酒店", 0.7),
("签证", 0.9), ("景点", 0.8), ("自驾", 0.8),
("装修", 0.9), ("收纳", 0.8), ("家居", 0.7), ("清洁", 0.7), ("打扫", 0.7),
("宠物", 0.9), ("", 0.7), ("", 0.7), ("猫粮", 0.9), ("驱虫", 0.9),
("健身", 0.9), ("锻炼", 0.8), ("跑步", 0.8), ("增肌", 0.9), ("减脂", 0.9),
("瑜伽", 0.8), ("天气", 0.7), ("气温", 0.7),
("recipe", 0.8), ("travel", 0.9), ("trip", 0.8), ("pet", 0.8),
("workout", 0.9), ("gym", 0.8), ("weather", 0.7), ("cook", 0.8),
],
"education": [
("学习方法", 1.0), ("怎么学", 0.7), ("高效学习", 1.0), ("记忆", 0.6), ("复习", 0.7),
("预习", 0.7), ("笔记", 0.6), ("专注", 0.6), ("拖延", 0.7), ("学习效率", 0.9),
("考试", 0.9), ("备考", 1.0), ("刷题", 0.9), ("模拟考", 0.9), ("中考", 0.9),
("高考", 0.9), ("考研", 0.9), ("考前", 0.7),
("英语", 0.8), ("单词", 0.7), ("口语", 0.8), ("听力", 0.7), ("雅思", 1.0),
("托福", 1.0), ("四级", 0.9), ("六级", 0.9), ("背单词", 0.9),
("选课", 0.9), ("课程", 0.6), ("专业选择", 0.9), ("报班", 0.8), ("网课", 0.7),
("自学", 0.7), ("职业规划", 1.0), ("求职", 0.9), ("面试", 0.8), ("简历", 0.8),
("实习", 0.7), ("跳槽", 0.8), ("转行", 0.9),
("study", 0.8), ("exam", 0.9), ("language", 0.7), ("career", 0.8),
("interview", 0.8), ("education", 0.7), ("learn", 0.6),
],
} }
_STOPWORDS = { _STOPWORDS = {
@@ -92,11 +140,21 @@ class BaseClassifier:
class RuleClassifier(BaseClassifier): class RuleClassifier(BaseClassifier):
"""基于关键词规则的分类器(零依赖)。""" """基于关键词规则的分类器(零依赖)。
def __init__(self, confidence_floor: float = 0.55): domains 参数(可选):限定只对部分领域打分 —— 两级路由中,
每个大领域的组内路由模型用 RuleClassifier(domains=组内领域)
只认识本组领域,体积与匹配开销约为统一分类器的 1/4。
"""
def __init__(self, confidence_floor: float = 0.55,
domains: Optional[List[str]] = None):
self.confidence_floor = confidence_floor self.confidence_floor = confidence_floor
self.rules = DOMAIN_RULES if domains is None:
self.rules = DOMAIN_RULES
else:
self.rules = {d: DOMAIN_RULES[d] for d in domains if d in DOMAIN_RULES}
self.domains = list(self.rules.keys())
def _score(self, query: str) -> Tuple[Dict[str, float], Dict[str, List[str]]]: def _score(self, query: str) -> Tuple[Dict[str, float], Dict[str, List[str]]]:
q = query.lower() q = query.lower()
@@ -159,7 +217,7 @@ class HuggingFaceClassifier(BaseClassifier):
仅当安装 torch+transformers 且模型可加载时可用;否则抛错提示。 仅当安装 torch+transformers 且模型可加载时可用;否则抛错提示。
""" """
def __init__(self, model_name: str, num_labels: int = 5, confidence_floor: float = 0.55): def __init__(self, model_name: str, num_labels: int = 8, confidence_floor: float = 0.55):
try: try:
from transformers import AutoModelForSequenceClassification, AutoTokenizer from transformers import AutoModelForSequenceClassification, AutoTokenizer
except ImportError as e: except ImportError as e:
@@ -170,7 +228,8 @@ class HuggingFaceClassifier(BaseClassifier):
self.model = AutoModelForSequenceClassification.from_pretrained( self.model = AutoModelForSequenceClassification.from_pretrained(
model_name, num_labels=num_labels model_name, num_labels=num_labels
) )
self.labels = ["code", "math", "legal", "medical", "general"] self.labels = ["code", "math", "legal", "medical", "general",
"finance", "life", "education"]
self.confidence_floor = confidence_floor self.confidence_floor = confidence_floor
def classify(self, query: str) -> Classification: def classify(self, query: str) -> Classification:
+23 -5
View File
@@ -15,23 +15,41 @@ DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "confi
_DEFAULTS: Dict[str, Any] = { _DEFAULTS: Dict[str, Any] = {
"system": {"name": "multi-expert-router", "version": "0.1.0"}, "system": {"name": "multi-expert-router", "version": "0.1.0"},
"router": { "router": {
"low_confidence_threshold": 0.60, # 分类置信度低于此值 -> 直接走大模型 "low_confidence_threshold": 0.60, # 分类置信度低于此值 -> 直接走最后处理者
"judge_fallback_threshold": 0.70, # Judge 质量分低于此值 -> 升级大模型 "judge_fallback_threshold": 0.70, # Judge 质量分低于此值 -> 升级最后处理者
"default_temperature": 0.2, "default_temperature": 0.2,
}, },
"execution": {
"mode": "rule", # ruleL0 零参数)| hybrid
"planner": "rule", # rule | hf
"expert_backend": "rule", # rule(规则执行器)| hf | api
"model_level": "L0", # L0 | L1 | L2
"max_plan_depth": 3,
},
"classifier": {"type": "rule", "model": "Qwen/Qwen3-0.6B", "confidence_floor": 0.55}, "classifier": {"type": "rule", "model": "Qwen/Qwen3-0.6B", "confidence_floor": 0.55},
"domains": ["code", "math", "legal", "medical", "general"], "domains": ["code", "math", "legal", "medical", "finance", "life", "education", "general"],
# 两级路由:大领域分组(用户接口指定 group → 组内路由模型 → 组内专业小模型)
# 组内路由模型只识别本组领域,体积约为统一路由模型的 1/4
"domain_groups": {
"tech": ["code", "math"],
"professional": ["legal", "medical", "finance"],
"lifestyle": ["life", "education"],
"general": ["general"],
},
"experts": { "experts": {
"code": {"type": "mock", "model": "Qwen/Qwen2.5-Coder-7B-Instruct"}, "code": {"type": "mock", "model": "Qwen/Qwen2.5-Coder-7B-Instruct"},
"math": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"}, "math": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
"legal": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"}, "legal": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
"medical": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"}, "medical": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
"finance": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
"life": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
"education": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
"general": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"}, "general": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
}, },
"fallback": { "fallback": {
"type": "mock", "type": "mock",
"model": "deepseek-chat", "model": "deepseek-v4-flash",
"base_url": "https://api.deepseek.com/v1", "base_url": "https://api.deepseek.com",
"api_key_env": "DEEPSEEK_API_KEY", "api_key_env": "DEEPSEEK_API_KEY",
}, },
"judge": {"type": "rule", "model": "Qwen/Qwen3-1.7B-Instruct"}, "judge": {"type": "rule", "model": "Qwen/Qwen3-1.7B-Instruct"},
+90 -4
View File
@@ -1,4 +1,10 @@
"""大模型回退层Mock 与 OpenAI 兼容 API 两种后端。""" """大模型回退层(最后处理者):Mock / 降级模板 / 本地模型 / OpenAI 兼容 API
- NoneFallback :降级模板(零参数,明确告知超出知识库范围)—— L0 最小可用
- MockFallback :确定性 mock 大模型(零参数,测试升级路径用)
- LocalFallback :本地小模型(≤8BQ4 量化,OpenAI 兼容端点如 Ollama/vLLM)—— 按需加载
- APIFallback :远程 OpenAI 兼容 API(可选,默认关闭)
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -14,6 +20,30 @@ class FallbackProvider:
raise NotImplementedError raise NotImplementedError
class NoneFallback(FallbackProvider):
"""降级模板:零参数兜底,明确告知查询超出知识库范围。"""
def __init__(self, model: str = "none"):
self.model = model
self.name = "fallback-none"
async def generate(self, query: str) -> ExpertResponse:
body = (
f"(降级响应)「{query}\n\n"
"当前查询超出本地知识库可处理范围(低置信度或质量校验未通过)。\n"
"可选处理:\n"
"1. 换个更明确的问法重试\n"
"2. 启用 L2 本地小模型或配置最后处理者(fallback.type: local\n"
)
return ExpertResponse(
text=body,
model_used=self.model,
latency_ms=0.0,
tokens=80,
cost_est=0.0,
)
class MockFallback(FallbackProvider): class MockFallback(FallbackProvider):
"""确定性 mock 大模型:标识为 fallback,便于测试升级路径。""" """确定性 mock 大模型:标识为 fallback,便于测试升级路径。"""
@@ -40,6 +70,54 @@ class MockFallback(FallbackProvider):
) )
class LocalFallback(FallbackProvider):
"""本地小模型最后处理者(≤8B,如 DeepSeek-R1-Distill-Qwen-7B Q4)。
通过 OpenAI 兼容端点调用(Ollama 默认 11434/v1vLLM 默认 8001/v1),
模型按需加载、用完即卸载(由本地推理服务管理),不常驻显存。
"""
def __init__(self, model: str, base_url: str = "http://127.0.0.1:11434/v1",
api_key: str = ""):
self.model = model
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.name = f"fallback-local-{model}"
self._client = None
def _get_client(self):
if self._client is None:
import httpx
self._client = httpx.AsyncClient(timeout=120.0)
return self._client
async def generate(self, query: str) -> ExpertResponse:
client = self._get_client()
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
resp = await client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json={
"model": self.model,
"messages": [{"role": "user", "content": query}],
"temperature": 0.3,
"max_tokens": 2048,
},
)
resp.raise_for_status()
data = resp.json()
body = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
tokens = usage.get("completion_tokens", int(len(body) / 2.2))
return ExpertResponse(
text=body,
model_used=self.model,
latency_ms=0.0,
tokens=tokens,
cost_est=0.0, # 本地推理成本按电费计,模型层成本记为 0(相对 API)
)
class APIFallback(FallbackProvider): class APIFallback(FallbackProvider):
"""OpenAI 兼容大模型 API(如 DeepSeek / OpenAI / 本地 vLLM)。""" """OpenAI 兼容大模型 API(如 DeepSeek / OpenAI / 本地 vLLM)。"""
@@ -85,9 +163,17 @@ class APIFallback(FallbackProvider):
def build_fallback(cfg: Dict) -> FallbackProvider: def build_fallback(cfg: Dict) -> FallbackProvider:
"""cfg 为 fallback 段配置。""" """cfg 为 fallback 段配置。"""
ftype = cfg.get("type", "mock") ftype = cfg.get("type", "mock")
model = cfg.get("model", "deepseek-chat") model = cfg.get("model", "deepseek-v4-flash")
if ftype == "none":
return NoneFallback(model=model)
if ftype == "mock": if ftype == "mock":
return MockFallback(model=model) return MockFallback(model=model)
if ftype == "local":
return LocalFallback(
model,
cfg.get("base_url", "http://127.0.0.1:11434/v1"),
cfg.get("api_key", ""),
)
if ftype == "api": if ftype == "api":
import os import os
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY")) api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
@@ -95,5 +181,5 @@ def build_fallback(cfg: Dict) -> FallbackProvider:
raise RuntimeError( raise RuntimeError(
f"APIFallback 缺少 API Key:请设置环境变量 {cfg.get('api_key_env')} 或配置 api_key" f"APIFallback 缺少 API Key:请设置环境变量 {cfg.get('api_key_env')} 或配置 api_key"
) )
return APIFallback(model, cfg.get("base_url", "https://api.deepseek.com/v1"), api_key) return APIFallback(model, cfg.get("base_url", "https://api.deepseek.com"), api_key)
raise ValueError(f"未知 fallback 类型: {ftype}(支持 mock | api") raise ValueError(f"未知 fallback 类型: {ftype}(支持 none | mock | local | api")
+25 -6
View File
@@ -56,8 +56,9 @@ class BaseJudge:
class RuleJudge(BaseJudge): class RuleJudge(BaseJudge):
"""启发式质量评估(零依赖)。""" """启发式质量评估(零依赖)。"""
def __init__(self, fallback_threshold: float = 0.70): def __init__(self, fallback_threshold: float = 0.70, kb=None):
self.fallback_threshold = fallback_threshold self.fallback_threshold = fallback_threshold
self.kb = kb # 可选知识库:facts 维度校验用
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation: async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
scores: Dict[str, float] = {} scores: Dict[str, float] = {}
@@ -103,7 +104,25 @@ class RuleJudge(BaseJudge):
else: else:
scores["safety"] = 1.0 scores["safety"] = 1.0
weights = {"coverage": 0.4, "length": 0.2, "format": 0.2, "safety": 0.2} # 5) 领域知识引用(facts 维度):法律/医疗响应应覆盖查询命中的知识条目
if domain in ("legal", "medical") and self.kb is not None:
facts = self.kb.facts(domain)
hit_facts = [f for f in facts if any(k in query for k in f.get("keywords", []))]
if hit_facts:
ok = sum(
1 for f in hit_facts
if any(k in response for k in f.get("keywords", []))
)
scores["facts"] = ok / len(hit_facts)
if scores["facts"] < 0.6:
reasons.append(f"领域知识引用不足 ({scores['facts']:.0%})")
else:
scores["facts"] = 1.0
else:
scores["facts"] = 1.0
weights = {"coverage": 0.35, "length": 0.15, "format": 0.15,
"safety": 0.15, "facts": 0.2}
overall = sum(scores.get(k, 0.0) * w for k, w in weights.items()) overall = sum(scores.get(k, 0.0) * w for k, w in weights.items())
needs = overall < self.fallback_threshold needs = overall < self.fallback_threshold
if needs: if needs:
@@ -157,17 +176,17 @@ class LLMJudge(BaseJudge):
) )
def build_judge(cfg: Dict, fallback_threshold: float = 0.70) -> BaseJudge: def build_judge(cfg: Dict, fallback_threshold: float = 0.70, kb=None) -> BaseJudge:
"""cfg 为 judge 段配置。""" """cfg 为 judge 段配置。"""
jtype = cfg.get("type", "rule") jtype = cfg.get("type", "rule")
if jtype == "rule": if jtype == "rule":
return RuleJudge(fallback_threshold=fallback_threshold) return RuleJudge(fallback_threshold=fallback_threshold, kb=kb)
if jtype == "llm": if jtype == "llm":
import os import os
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY")) api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
return LLMJudge( return LLMJudge(
cfg.get("model", "deepseek-chat"), cfg.get("model", "deepseek-v4-flash"),
cfg.get("base_url", "https://api.deepseek.com/v1"), cfg.get("base_url", "https://api.deepseek.com"),
api_key or "", api_key or "",
fallback_threshold=fallback_threshold, fallback_threshold=fallback_threshold,
) )
+8
View File
@@ -44,6 +44,10 @@ class RouterResult:
cache_level: Optional[str] = None # exact | semantic cache_level: Optional[str] = None # exact | semantic
cost_est: float = 0.0 cost_est: float = 0.0
error: Optional[str] = None error: Optional[str] = None
subdomain: Optional[str] = None # 二级子领域(如 investing/labor
subdomain2: Optional[str] = None # 三级子领域(如 fund/overtime
domain_group: Optional[str] = None # 大领域组(两级路由第一级:tech/professional/...
request_id: Optional[str] = None # 请求 ID(配合 /traces/{id} 查询完整推理链)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {
@@ -61,6 +65,10 @@ class RouterResult:
"cache_level": self.cache_level, "cache_level": self.cache_level,
"cost_est": round(self.cost_est, 6), "cost_est": round(self.cost_est, 6),
"error": self.error, "error": self.error,
"subdomain": self.subdomain,
"subdomain2": self.subdomain2,
"domain_group": self.domain_group,
"request_id": self.request_id,
} }
+20 -3
View File
@@ -68,8 +68,9 @@ class CollaborativePipeline:
# --------------------------------------------------------------- # ---------------------------------------------------------------
# 入口 # 入口
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def run(self, query: str) -> PipelineResult: async def run(self, query: str, request_id: Optional[str] = None) -> PipelineResult:
request_id = uuid.uuid4().hex[:12] if request_id is None:
request_id = uuid.uuid4().hex[:12]
ws = Workspace.new(request_id, query, self.api_token_cap, self.rounds_cap) ws = Workspace.new(request_id, query, self.api_token_cap, self.rounds_cap)
route: List[str] = ["v2"] route: List[str] = ["v2"]
t0 = time.perf_counter() * 1000.0 t0 = time.perf_counter() * 1000.0
@@ -100,11 +101,15 @@ class CollaborativePipeline:
route.append("loop") route.append("loop")
plan = brief.get("plan") or [] plan = brief.get("plan") or []
pending = [p.get("id") for p in plan] pending = [p.get("id") for p in plan]
plan_ids = {p.get("id") for p in plan} # 用于 _deps_done 过滤
while pending and not ws.exhausted(): while pending and not ws.exhausted():
progressed = False progressed = False
for sid in list(pending): for sid in list(pending):
step = next((p for p in plan if p.get("id") == sid), {}) step = next((p for p in plan if p.get("id") == sid), {})
if not self._deps_done(ws, step.get("deps") or []): deps = step.get("deps") or []
# 只检查在 plan 中的依赖;不在 plan 的 ID 视为"不存在"→自动满足
relevant = [d for d in deps if d in plan_ids]
if not self._deps_done(ws, relevant):
continue continue
existing = self._read_artifact(request_id, self._artifact_name(sid, ws)) existing = self._read_artifact(request_id, self._artifact_name(sid, ws))
outcome = await self.worker.run_step(ws, sid, existing_artifact=existing, outcome = await self.worker.run_step(ws, sid, existing_artifact=existing,
@@ -115,6 +120,8 @@ class CollaborativePipeline:
ws.rollup() ws.rollup()
route.append(f"step:{sid}:done") route.append(f"step:{sid}:done")
progressed = True progressed = True
if not pending: # 所有步骤完成,退出循环
break
else: # issue -> Architect 裁决 else: # issue -> Architect 裁决
route.append(f"step:{sid}:issue") route.append(f"step:{sid}:issue")
try: try:
@@ -227,7 +234,17 @@ class CollaborativePipeline:
return artifact_name_for(sid, domain) return artifact_name_for(sid, domain)
def _deps_done(self, ws: Workspace, deps: List[str]) -> bool: def _deps_done(self, ws: Workspace, deps: List[str]) -> bool:
# progress 中 done 的条目;done 条目 rollup 后移到 archive(字符串格式如 "s1: ..."
done = {p["step"] for p in ws.get("progress", []) if p.get("status") == "done"} done = {p["step"] for p in ws.get("progress", []) if p.get("status") == "done"}
for entry in ws.get("archive", []):
if isinstance(entry, dict):
sid = entry.get("step", "")
elif isinstance(entry, str):
sid = entry.split(":")[0].strip() if ":" in entry else ""
else:
sid = ""
if sid in deps:
done.add(sid)
return all(d in done for d in deps) return all(d in done for d in deps)
def _last_decision_for(self, ws: Workspace, sid: str) -> str: def _last_decision_for(self, ws: Workspace, sid: str) -> str:
+251 -53
View File
@@ -1,26 +1,39 @@
"""主路由器:协调 缓存 -> 分类 -> 专家 -> Judge -> 大模型回退 的完整链路 """主路由器:两级路由(大领域组 → 组内路由模型 → 专业执行器)专家系统编排
流程(对齐实现方案): 两级体系(对齐用户架构决策):
1. 检查缓存(L1 精确 / L2 语义) 第一级:用户通过接口指定大领域组(domain_group: tech/professional/lifestyle/general),
2. 低置信度查询直接走大模型(should_fallback 或系统自动检测(8 领域分类 → 映射到组
3. 分类器输出领域 + 难度 第二级:组内路由模型(RuleClassifier(domains=组内领域) + 组内知识/模板)识别具体
4. 选择专家模型生成 领域、子领域、拆解子任务 → 组内专业小模型/规则执行器
5. Judge 评估质量 组内路由模型只认识本组领域:体积与匹配开销约为统一路由模型的 1/4,
6. 质量不达标 -> 升级大模型 且未来 L2 模型层可每组一个更小的路由模型,按需加载不常驻。
7. 记录指标、写缓存、返回结果
链路:缓存 → 组路由(分类/子领域/拆解) → 黑板+前向链 → DAG 执行 → 合并
→ Judge 校验 → (不达标)最后处理者升级 → 缓存/指标
L0 模式(默认):规则分类 + 规则拆解 + 规则执行器 + 规则 Judge —— 零模型参数、零 API。
L2 模式(可选):execution.expert_backend = hf/api 时,子任务改由专家池小模型执行
(≤8B,按需加载),其余流程不变。
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, Optional import uuid
from typing import Any, Dict, List, Optional
from .cache import RouterCache from .cache import RouterCache
from .classifier import BaseClassifier, build_classifier from .classifier import BaseClassifier, RuleClassifier, build_classifier
from .config import load_config from .config import load_config
from .executors import NodeExecutor, build_node_executor
from .experts import Expert, build_expert_pool from .experts import Expert, build_expert_pool
from .fallback import FallbackProvider, build_fallback from .fallback import FallbackProvider, build_fallback
from .inference import InferenceEngine
from .judge import BaseJudge, build_judge from .judge import BaseJudge, build_judge
from .knowledge import KnowledgeBase
from .memory import TaskGraph, TaskNode, WorkingMemory
from .models import Classification, ExpertResponse, RouterResult, now_ms from .models import Classification, ExpertResponse, RouterResult, now_ms
from .planner import Planner
from .stats import Stats from .stats import Stats
from .trace import TraceStore
class Router: class Router:
@@ -33,6 +46,8 @@ class Router:
cache: Optional[RouterCache] = None, cache: Optional[RouterCache] = None,
stats: Optional[Stats] = None, stats: Optional[Stats] = None,
config: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None,
kb: Optional[KnowledgeBase] = None,
planner: Optional[Planner] = None,
): ):
self.classifier = classifier self.classifier = classifier
self.experts = experts self.experts = experts
@@ -45,11 +60,43 @@ class Router:
self.low_confidence_threshold = rcfg.get("low_confidence_threshold", 0.60) self.low_confidence_threshold = rcfg.get("low_confidence_threshold", 0.60)
self.judge_fallback_threshold = rcfg.get("judge_fallback_threshold", 0.70) self.judge_fallback_threshold = rcfg.get("judge_fallback_threshold", 0.70)
self.cache_enabled = cfg.get("cache", {}).get("enabled", True) self.cache_enabled = cfg.get("cache", {}).get("enabled", True)
# ---- 专家系统内核 ----
self.kb = kb or KnowledgeBase()
self.planner = planner or Planner(self.kb)
self.inference = InferenceEngine(self.kb)
ecfg = cfg.get("execution", {})
self.expert_backend = ecfg.get("expert_backend", "rule") # rule | hf | api
# 子任务执行后端(T1 抽象:NodeExecutor 工厂,新增后端无需改 Router)
self.node_executor: NodeExecutor = build_node_executor(
self.expert_backend, kb=self.kb, experts=experts)
# 推理链轨迹存储(T3:可解释性产品化)
self.trace_store = TraceStore()
# ---- 两级路由:大领域分组 + 组内路由模型(更小更专) ----
self.domain_groups: Dict[str, List[str]] = cfg.get("domain_groups", {}) or {}
if not self.domain_groups:
# 兜底:未配置时按单组(全部领域)处理,行为退化为一级路由
self.domain_groups = {"all": list(self.experts.keys())}
self._group_of_domain: Dict[str, str] = {}
for g, domains in self.domain_groups.items():
for d in domains:
self._group_of_domain[d] = g
# 组内路由模型:每组一个轻量分类器(只认识组内领域)
self._group_classifiers: Dict[str, RuleClassifier] = {
g: RuleClassifier(domains=domains)
for g, domains in self.domain_groups.items()
}
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def route(self, query: str) -> RouterResult: async def route(self, query: str, domain_group: Optional[str] = None) -> RouterResult:
"""两级路由入口。
domain_group 指定时:跳过 8 领域统一分类器,直接用组内路由模型
RuleClassifier(domains=组内领域))识别组内领域 —— 更小更专。
未指定时:统一分类器识别领域 → 自动映射到大领域组(向后兼容)。
"""
start = now_ms() start = now_ms()
route: list = [] route: list = []
request_id = uuid.uuid4().hex[:12]
# ---- Step 1: 缓存 ---- # ---- Step 1: 缓存 ----
if self.cache_enabled: if self.cache_enabled:
@@ -71,71 +118,132 @@ class Router:
cache_hit=True, cache_hit=True,
cache_level=level, cache_level=level,
cost_est=0.0, cost_est=0.0,
subdomain=cached.get("subdomain"),
subdomain2=cached.get("subdomain2"),
domain_group=cached.get("domain_group"),
request_id=request_id,
)
self._store_trace(
request_id=request_id, query=query, group=cached.get("domain_group"),
domain=result.domain, difficulty=result.difficulty,
confidence=result.confidence, subdomain=result.subdomain,
subdomain2=result.subdomain2, route=route, quality=result.quality_score,
upgraded=False, model=result.model_used, latency=latency,
cache_hit=True, cache_level=level,
) )
self.stats.record(latency, result.domain, result.difficulty, False, True, level, 0.0, result.model_used) self.stats.record(latency, result.domain, result.difficulty, False, True, level, 0.0, result.model_used)
return result return result
route.append("cache:miss") route.append("cache:miss")
# ---- Step 2: 分类 ---- # ---- Step 2: 组路由(两级第一级)→ 组内分类(两级第二级) ----
classification = self.classifier.classify(query) classifier = self.classifier
group = domain_group
if group is not None:
# 用户指定大领域:校验 + 使用组内路由模型
if group not in self.domain_groups:
raise ValueError(
f"未知大领域组: {group}(可用: {sorted(self.domain_groups)}"
)
classifier = self._group_classifiers[group]
route.append(f"group:{group}@explicit")
classification = classifier.classify(query)
if group is None:
# 自动检测:8 领域分类 → 映射大领域组
group = self._group_of_domain.get(classification.domain, "general")
route.append(f"group:{group}@auto")
route.append(f"classify:{classification.domain}@{classification.confidence:.2f}/{classification.difficulty}") route.append(f"classify:{classification.domain}@{classification.confidence:.2f}/{classification.difficulty}")
subdomain, subdomain2 = self._detect_subdomain(query, classification.domain)
if subdomain:
route.append(f"subdomain:{subdomain}")
if subdomain2:
route.append(f"subdomain2:{subdomain2}")
# 低置信度 -> 直接走大模型 # ---- Step 3: 低置信度 -> 直接走最后处理者 ----
if self.classifier.should_fallback(classification, self.low_confidence_threshold): if classifier.should_fallback(classification, self.low_confidence_threshold):
route.append("direct_fallback") route.append("direct_fallback")
fb = await self._call_fallback(query) fb = await self._call_fallback(query)
latency = now_ms() - start latency = now_ms() - start
result = self._finalize(query, classification, fb, quality_score=0.0,
upgraded=True, route=route, latency_ms=latency,
model_used=fb.model_used, cost_est=fb.cost_est)
self._record(result, latency)
return result
# ---- Step 3: 选择专家 ----
domain = classification.domain
expert = self.experts.get(domain)
if expert is None:
expert = self.experts.get("general")
route.append("expert:fallback-to-general")
else:
route.append(f"expert:{expert.name}")
# ---- Step 4: 生成 ----
try:
expert_resp = await expert.generate(query, classification.difficulty)
except Exception as e:
self.stats.record_error()
route.append(f"expert_error:{type(e).__name__}")
fb = await self._call_fallback(query)
latency = now_ms() - start
result = self._finalize(query, classification, fb, quality_score=0.0, result = self._finalize(query, classification, fb, quality_score=0.0,
upgraded=True, route=route, latency_ms=latency, upgraded=True, route=route, latency_ms=latency,
model_used=fb.model_used, cost_est=fb.cost_est, model_used=fb.model_used, cost_est=fb.cost_est,
error=str(e)) subdomain=subdomain, subdomain2=subdomain2,
domain_group=group)
result.request_id = request_id
self._store_trace(
request_id=request_id, query=query, group=group,
domain=result.domain, difficulty=result.difficulty,
confidence=result.confidence, subdomain=subdomain,
subdomain2=subdomain2, route=route, quality=0.0,
upgraded=True, model=fb.model_used, latency=latency,
)
self._record(result, latency) self._record(result, latency)
return result return result
# ---- Step 5: Judge 评估 ---- # ---- Step 4: Planner 任务拆解(DAG ----
graph = self.planner.plan(query, classification)
route.extend(self.planner.explain_plan(graph))
# ---- Step 5: 黑板初始化 + 前向链(规则轨迹) ----
memory = WorkingMemory()
self.inference.initialize(
query, classification.domain, classification.difficulty,
classification.confidence, memory,
)
fired = self.inference.run(query, classification.domain, memory)
if fired:
route.append(f"rules:{','.join(fired[:5])}")
# ---- Step 6: DAG 顺序执行(拓扑序) ----
order = graph.topo_order()
last_model = f"rule:{classification.domain}"
for node in order:
last_model = await self._execute_node(graph, node, classification, memory, route) or last_model
# ---- Step 7: 黑板合并(节点输出 + 推理机规则产出) ----
response = memory.merge([n.id for n in order])
# 追加推理机规则产出的部分解(带 output 的知识规则,如 git/docker/常识条目)
node_ids = {n.id for n in order}
extra_sections = [sid for sid in memory.sections if sid not in node_ids]
extras = [memory.section(s) for s in extra_sections if memory.section(s)]
if extras:
extra_text = "\n\n".join(extras)
response = (response + "\n\n" + extra_text) if response.strip() else extra_text
if not response.strip():
response = "(规则执行器)未能生成有效回答:任务均未产出内容。"
route.append("merge:empty")
# ---- Step 8: Judge 校验 ----
try: try:
evaluation = await self.judge.evaluate(query, expert_resp.text, domain) evaluation = await self.judge.evaluate(query, response, classification.domain)
except Exception: except Exception:
evaluation = None evaluation = None
route.append("judge_error") route.append("judge_error")
quality_score = evaluation.overall_score if evaluation else 0.0 quality_score = evaluation.overall_score if evaluation else 0.0
route.append(f"judge:{quality_score:.2f}") route.append(f"judge:{quality_score:.2f}")
upgraded = False upgraded = False
final_resp = expert_resp
if evaluation is not None and evaluation.needs_fallback: if evaluation is not None and evaluation.needs_fallback:
route.append("upgrade") route.append("upgrade")
final_resp = await self._call_fallback(query) fb = await self._call_fallback(query)
response = fb.text
last_model = fb.model_used
upgraded = True upgraded = True
latency = now_ms() - start latency = now_ms() - start
result = self._finalize(query, classification, final_resp, quality_score=quality_score, result = self._finalize(query, classification, ExpertResponse(
upgraded=upgraded, route=route, latency_ms=latency, text=response, model_used=last_model, latency_ms=latency,
model_used=final_resp.model_used, cost_est=final_resp.cost_est) tokens=max(8, int(len(response) / 2.2)), cost_est=0.0,
), quality_score=quality_score, upgraded=upgraded, route=route,
latency_ms=latency, model_used=last_model, cost_est=0.0,
subdomain=subdomain, subdomain2=subdomain2, domain_group=group)
result.request_id = request_id
self._store_trace(
request_id=request_id, query=query, group=group,
domain=result.domain, difficulty=result.difficulty,
confidence=result.confidence, subdomain=subdomain,
subdomain2=subdomain2, route=route, quality=quality_score,
upgraded=upgraded, model=last_model, latency=latency,
)
self._record(result, latency) self._record(result, latency)
# 未升级的结果写缓存 # 未升级的结果写缓存
@@ -144,6 +252,80 @@ class Router:
return result return result
# ---------------------------------------------------------------
def _store_trace(self, request_id: str, query: str, group: Optional[str],
domain: str, difficulty: str, confidence: float,
subdomain: Optional[str], subdomain2: Optional[str],
route: list, quality: float, upgraded: bool,
model: str, latency: float,
cache_hit: bool = False, cache_level: Optional[str] = None) -> None:
"""记录完整推理链到轨迹存储(T3:可解释性产品化)。"""
self.trace_store.put(request_id, {
"request_id": request_id,
"query": query,
"domain_group": group,
"domain": domain,
"difficulty": difficulty,
"confidence": round(confidence, 4),
"subdomain": subdomain,
"subdomain2": subdomain2,
"route": list(route),
"quality_score": round(quality, 4),
"upgraded": upgraded,
"model_used": model,
"latency_ms": round(latency, 2),
"cache_hit": cache_hit,
"cache_level": cache_level,
})
# ---------------------------------------------------------------
def _detect_subdomain(self, query: str, domain: str) -> tuple:
"""子领域识别:返回 (二级 subdomain, 三级 subdomain2)。
二级取领域内最高优先级带 subdomain 的命中规则;
三级取最高优先级带 subdomain2 的命中规则(可与二级来自不同规则)。
"""
hits = self.kb.match(query, domain=domain)
sub = None
sub2 = None
for h in hits:
if sub is None and h.subdomain:
sub = h.subdomain
if sub2 is None and h.subdomain2:
sub2 = h.subdomain2
if sub is not None and sub2 is not None:
break
return sub, sub2
# ---------------------------------------------------------------
async def _execute_node(self, graph: TaskGraph, node: TaskNode,
classification: Classification, memory: WorkingMemory,
route: list) -> Optional[str]:
"""执行一个子任务节点;返回使用的 model_used(失败返回 None)。"""
# 依赖检查:依赖失败/跳过 → 本节点跳过
for dep_id in node.deps:
dep = graph.get(dep_id)
if dep is not None and dep.status in ("failed", "skipped"):
node.status = "skipped"
route.append(f"{node.id}:{node.kind}:skip")
return None
node.status = "running"
try:
# NodeExecutor 后端执行(rule 零参数 / model 专家池 ≤8B)
resp = await self.node_executor.execute(
node, classification.domain, classification.difficulty, memory)
node.output = resp.text
node.status = "done"
memory.write_section(node.id, resp.text)
route.append(f"{node.id}:{node.kind}")
return resp.model_used
except Exception as e:
node.status = "failed"
node.error = str(e)
self.stats.record_error()
route.append(f"{node.id}:{node.kind}:error:{type(e).__name__}")
return None
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def _call_fallback(self, query: str) -> ExpertResponse: async def _call_fallback(self, query: str) -> ExpertResponse:
try: try:
@@ -151,7 +333,7 @@ class Router:
except Exception as e: except Exception as e:
# 回退也失败:返回错误占位响应 # 回退也失败:返回错误占位响应
return ExpertResponse( return ExpertResponse(
text=f"[系统错误] 专家与大模型回退均失败:{type(e).__name__}: {e}", text=f"[系统错误] 专家与最后处理者均失败:{type(e).__name__}: {e}",
model_used=f"error:{self.fallback.name}", model_used=f"error:{self.fallback.name}",
cost_est=0.0, cost_est=0.0,
) )
@@ -160,7 +342,10 @@ class Router:
def _finalize(query: str, classification: Classification, resp: ExpertResponse, def _finalize(query: str, classification: Classification, resp: ExpertResponse,
quality_score: float, upgraded: bool, route: list, quality_score: float, upgraded: bool, route: list,
latency_ms: float, model_used: str, cost_est: float, latency_ms: float, model_used: str, cost_est: float,
error: Optional[str] = None) -> RouterResult: error: Optional[str] = None,
subdomain: Optional[str] = None,
subdomain2: Optional[str] = None,
domain_group: Optional[str] = None) -> RouterResult:
return RouterResult( return RouterResult(
query=query, query=query,
response=resp.text, response=resp.text,
@@ -175,6 +360,9 @@ class Router:
cache_hit=False, cache_hit=False,
cost_est=cost_est, cost_est=cost_est,
error=error, error=error,
subdomain=subdomain,
subdomain2=subdomain2,
domain_group=domain_group,
) )
def _record(self, result: RouterResult, latency_ms: float): def _record(self, result: RouterResult, latency_ms: float):
@@ -194,18 +382,25 @@ class Router:
return { return {
"status": "ok", "status": "ok",
"domains": list(self.experts.keys()), "domains": list(self.experts.keys()),
"domain_groups": self.domain_groups,
"classifier": type(self.classifier).__name__, "classifier": type(self.classifier).__name__,
"judge": type(self.judge).__name__, "judge": type(self.judge).__name__,
"fallback": type(self.fallback).__name__, "fallback": type(self.fallback).__name__,
"planner": type(self.planner).__name__,
"execution_mode": self.expert_backend,
"rules": self.kb.rules_count(),
} }
def build_router(config_path: Optional[str] = None) -> Router: def build_router(config_path: Optional[str] = None) -> Router:
"""从配置构建完整 Router(默认 mock 全链路,零依赖可跑)。""" """从配置构建完整 Router(默认 L0 专家系统模式:零参数可跑)。"""
config = load_config(config_path) config = load_config(config_path)
kb = KnowledgeBase()
classifier = build_classifier(config.get("classifier", {})) classifier = build_classifier(config.get("classifier", {}))
experts = build_expert_pool(config.get("experts", {}), config.get("domains", [])) experts = build_expert_pool(config.get("experts", {}), config.get("domains", []))
judge = build_judge(config.get("judge", {}), config.get("router", {}).get("judge_fallback_threshold", 0.70)) judge = build_judge(config.get("judge", {}),
config.get("router", {}).get("judge_fallback_threshold", 0.70),
kb=kb)
fallback = build_fallback(config.get("fallback", {})) fallback = build_fallback(config.get("fallback", {}))
cache_cfg = config.get("cache", {}) cache_cfg = config.get("cache", {})
cache = RouterCache( cache = RouterCache(
@@ -213,5 +408,8 @@ def build_router(config_path: Optional[str] = None) -> Router:
similarity_threshold=cache_cfg.get("similarity_threshold", 0.88), similarity_threshold=cache_cfg.get("similarity_threshold", 0.88),
promote_frequency=cache_cfg.get("promote_frequency", 5), promote_frequency=cache_cfg.get("promote_frequency", 5),
) )
ecfg = config.get("execution", {})
planner = Planner(kb, max_depth=ecfg.get("max_plan_depth", 3))
stats = Stats() stats = Stats()
return Router(classifier, experts, judge, fallback, cache, stats, config) return Router(classifier, experts, judge, fallback, cache, stats, config,
kb=kb, planner=planner)
+1 -1
View File
@@ -39,7 +39,7 @@ LIMITS = {
# 允许的领域标签(4.2 brief.tags;仅用于安全标记与验证接地,不做路由 D3) # 允许的领域标签(4.2 brief.tags;仅用于安全标记与验证接地,不做路由 D3)
ALLOWED_TAGS = {"code", "math", "legal", "medical", "finance", ALLOWED_TAGS = {"code", "math", "legal", "medical", "finance",
"life", "education", "general", "safety"} "life", "education", "general", "safety", "science"}
STATUS_FLOW = { STATUS_FLOW = {
"draft": {"in_progress"}, "draft": {"in_progress"},
+17 -7
View File
@@ -1,7 +1,8 @@
"""CLI 演示:构建 mock 全链路路由系统,跑一组样例查询并打印结果 """CLI 演示:构建专家系统内核路由(默认 L0 零参数模式),跑样例查询并打印推理链
用法: 用法:
python scripts/demo.py [--query "自定义查询"] [--batch] python scripts/demo.py [--query "自定义查询"] [--batch] [--trace] [--verbose]
--trace 打印完整推理链(分类 → 拆解 DAG → 规则轨迹 → 子任务执行 → Judge)
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,6 +11,10 @@ import asyncio
import sys import sys
from pathlib import Path from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from router_system.router import build_router from router_system.router import build_router
@@ -26,7 +31,7 @@ SAMPLE_QUERIES = [
] ]
async def run_demo(router, queries, verbose: bool = False): async def run_demo(router, queries, verbose: bool = False, trace: bool = False):
for q in queries: for q in queries:
r = await router.route(q) r = await router.route(q)
print("=" * 72) print("=" * 72)
@@ -35,15 +40,20 @@ async def run_demo(router, queries, verbose: bool = False):
f"upgraded={r.upgraded} quality={r.quality_score:.2f} model={r.model_used} " f"upgraded={r.upgraded} quality={r.quality_score:.2f} model={r.model_used} "
f"latency={r.latency_ms:.1f}ms cache={r.cache_hit}({r.cache_level}) cost=${r.cost_est:.6f}") f"latency={r.latency_ms:.1f}ms cache={r.cache_hit}({r.cache_level}) cost=${r.cost_est:.6f}")
print(f" route: {' -> '.join(r.route)}") print(f" route: {' -> '.join(r.route)}")
if trace:
# 拆解轨迹从 route 中展开为更可读的形式
plan_steps = [s for s in r.route if s.startswith("plan:") or ":" in s]
print(" 推理链: " + " -> ".join(r.route))
if verbose: if verbose:
print(f" --- response ---\n{r.response[:400]}") print(f" --- response ---\n{r.response[:600]}")
async def main(): async def main():
parser = argparse.ArgumentParser(description="多专家路由系统 CLI 演示") parser = argparse.ArgumentParser(description="多专家路由系统(专家系统内核)CLI 演示")
parser.add_argument("--query", type=str, default=None, help="单条查询(覆盖默认样例)") parser.add_argument("--query", type=str, default=None, help="单条查询(覆盖默认样例)")
parser.add_argument("--batch", action="store_true", help="批量模式(打印全部响应)") parser.add_argument("--batch", action="store_true", help="批量模式(打印全部响应)")
parser.add_argument("--verbose", action="store_true", help="打印响应正文") parser.add_argument("--verbose", action="store_true", help="打印响应正文")
parser.add_argument("--trace", action="store_true", help="打印完整推理链")
args = parser.parse_args() args = parser.parse_args()
router = build_router() router = build_router()
@@ -51,9 +61,9 @@ async def main():
print() print()
if args.query: if args.query:
await run_demo(router, [args.query], verbose=True) await run_demo(router, [args.query], verbose=True, trace=True)
else: else:
await run_demo(router, SAMPLE_QUERIES, verbose=args.verbose) await run_demo(router, SAMPLE_QUERIES, verbose=args.verbose, trace=args.trace)
print() print()
print("=" * 72) print("=" * 72)
+60 -19
View File
@@ -12,27 +12,46 @@ import sys
from collections import Counter from collections import Counter
from pathlib import Path from pathlib import Path
# 兼容 GBK 控制台(中文 Windows 默认编码),避免打印 ✅/⚠️ 时 UnicodeEncodeError
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from router_system.router import build_router from router_system.router import build_router
# (query, 期望领域) # (query, 期望领域, 期望大领域组, 期望子领域)
# 覆盖 8 领域 × 3 条 = 24 条 + 两级路由/三级子领域指标
BENCH = [ BENCH = [
("用 Python 实现二分查找", "code"), # ---- techcode / math ----
("这段 JavaScript 为什么报错:undefined is not a function", "code"), ("用 Python 实现二分查找", "code", "tech", "algorithm"),
("帮我优化这个 SQL 查询的索引", "code"), ("这段 JavaScript 为什么报错:undefined is not a function", "code", "tech", "debugging"),
("求解一元二次方程 ax^2+bx+c=0 的求根公式", "math"), ("帮我优化这个 SQL 查询的索引", "code", "tech", "database"),
("证明勾股定理", "math"), ("求解一元二次方程 ax^2+bx+c=0 的求根公式", "math", "tech", "algebra"),
("计算 3x + 5 = 20x 等于多少", "math"), ("证明勾股定理", "math", "tech", "geometry"),
("劳动合同到期不续签,公司需要支付经济补偿吗", "legal"), ("计算 3x + 5 = 20x 等于多少", "math", "tech", "algebra"),
("在合同中约定违约金上限 30%,是否合规", "legal"), # ---- professionallegal / medical / finance ----
("专利申请的流程和费用大概是多少", "legal"), ("劳动合同到期不续签,公司需要支付经济补偿吗", "legal", "professional", "labor"),
("高血压患者可以吃哪些降压药,副作用是什么", "medical"), ("在合同中约定违约金上限 30%,是否合规", "legal", "professional", "contract"),
("感冒发烧 38.5 度,需要吃退烧药吗", "medical"), ("加班费怎么计算", "legal", "professional", "labor"),
("糖尿病患者的日常饮食建议", "medical"), ("高血压患者可以吃哪些降压药,副作用是什么", "medical", "professional", "medication"),
("介绍一下 Transformer 架构", "general"), ("感冒发烧 38.5 度,需要吃退烧药吗", "medical", "professional", "medication"),
("写一封请假邮件", "general"), ("烫伤后怎么处理", "medical", "professional", "firstaid"),
("为什么天空是蓝色的", "general"), ("基金定投的收益率怎么计算", "finance", "professional", "investing"),
("信用卡逾期了怎么办", "finance", "professional", "credit"),
("房贷利率是 LPR 加多少", "finance", "professional", "loan"),
# ---- lifestylelife / education ----
("日本旅行攻略", "life", "lifestyle", "travel"),
("健身增肌计划怎么安排", "life", "lifestyle", "fitness"),
("家常菜谱推荐", "life", "lifestyle", "food"),
("考研英语怎么备考", "education", "lifestyle", "exam"),
("高效学习方法", "education", "lifestyle", "study"),
("面试技巧有哪些", "education", "lifestyle", "career"),
# ---- general ----
("介绍一下 Transformer 架构", "general", "general", "explain"),
("写一封请假邮件", "general", "general", "writing"),
("为什么天空是蓝色的", "general", "general", "explain"),
] ]
@@ -40,32 +59,54 @@ async def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--repeat", type=int, default=2, help="重复轮数(验证缓存)") parser.add_argument("--repeat", type=int, default=2, help="重复轮数(验证缓存)")
parser.add_argument("--config", type=str, default=None) parser.add_argument("--config", type=str, default=None)
parser.add_argument("--group", type=str, default=None,
help="指定大领域组测试两级路由(如 tech);默认自动检测")
args = parser.parse_args() args = parser.parse_args()
router = build_router(args.config) router = build_router(args.config)
# 指定组时只评测组内样例(组路由只认识本组领域)
bench = BENCH
if args.group is not None:
bench = [b for b in BENCH if b[2] == args.group]
if not bench:
print(f"{args.group} 无评测样例,可用组: {sorted({b[2] for b in BENCH})}")
return
correct = Counter() correct = Counter()
group_correct = 0
subdomain_correct = 0
total = 0 total = 0
upgraded = 0 upgraded = 0
cache_hits = 0 cache_hits = 0
decomposed = 0 # 被 Planner 拆解为多子任务的请求数
for round_i in range(args.repeat): for round_i in range(args.repeat):
for q, expected in BENCH: for q, expected, expected_group, expected_sub in bench:
r = await router.route(q) r = await router.route(q, domain_group=args.group)
total += 1 total += 1
if r.domain == expected: if r.domain == expected:
correct["total"] += 1 correct["total"] += 1
else: else:
correct[f"misclass->{r.domain}"] += 1 correct[f"misclass->{r.domain}"] += 1
if args.group is None and r.domain_group == expected_group:
group_correct += 1
if r.subdomain == expected_sub:
subdomain_correct += 1
if r.upgraded: if r.upgraded:
upgraded += 1 upgraded += 1
if r.cache_hit: if r.cache_hit:
cache_hits += 1 cache_hits += 1
if any("plan:multi" in s for s in r.route):
decomposed += 1
acc = correct["total"] / total acc = correct["total"] / total
print(f"样例数: {len(BENCH)} x {args.repeat} 轮 = {total} 次请求") print(f"样例数: {len(bench)} x {args.repeat} 轮 = {total} 次请求")
print(f"分类准确率: {acc:.1%} ({correct['total']}/{total})") print(f"分类准确率: {acc:.1%} ({correct['total']}/{total})")
if args.group is None:
print(f"大领域组识别准确率: {group_correct/total:.1%} ({group_correct}/{total})")
print(f"子领域识别准确率: {subdomain_correct/total:.1%} ({subdomain_correct}/{total})")
print(f"升级率: {upgraded/total:.1%} ({upgraded}/{total})") print(f"升级率: {upgraded/total:.1%} ({upgraded}/{total})")
print(f"缓存命中率: {cache_hits/total:.1%} ({cache_hits}/{total})") print(f"缓存命中率: {cache_hits/total:.1%} ({cache_hits}/{total})")
print(f"任务拆解率: {decomposed/total:.1%} ({decomposed}/{total})")
print() print()
print("运行指标:", router.stats.summary()) print("运行指标:", router.stats.summary())
print("缓存统计:", router.cache.stats()) print("缓存统计:", router.cache.stats())
+11 -6
View File
@@ -1,4 +1,4 @@
"""启动路由网关服务(后台、无窗口)。 """启动路由网关服务(后台、无窗口)。
用法: 用法:
python scripts/serve.py [--port 8000] [--stop] python scripts/serve.py [--port 8000] [--stop]
@@ -41,11 +41,16 @@ def stop():
return return
pid = int(PID_FILE.read_text().strip()) pid = int(PID_FILE.read_text().strip())
try: try:
import signal # Windows 下 SIGTERM 对 detached 进程不可靠,改用 taskkill 强制结束进程树
os.kill(pid, signal.SIGTERM) subprocess.run(
print(f"已发送终止信号 pid={pid}") ["taskkill", "/PID", str(pid), "/T", "/F"],
except ProcessLookupError: capture_output=True, text=True, timeout=15,
print(f"进程 {pid} 不存在,清理 pid 文件。") )
print(f"已终止服务 pid={pid}")
except subprocess.TimeoutExpired:
print(f"终止超时 pid={pid},请手动结束进程。")
except Exception as e:
print(f"终止失败(进程可能不存在): {e}")
PID_FILE.unlink(missing_ok=True) PID_FILE.unlink(missing_ok=True)
+4 -4
View File
@@ -13,9 +13,9 @@ def test_exact_hit():
def test_semantic_hit(): def test_semantic_hit():
c = RouterCache(semantic_enabled=True, similarity_threshold=0.5) c = RouterCache(semantic_enabled=True, similarity_threshold=0.5)
c.put("?python?????", {"response": "code", "domain": "code"}) c.put("python写一个快速排序", {"response": "code", "domain": "code"})
# ?????????? L2 # 相似改写查询命中 L2 语义缓存
hit = c.get("?python????????") hit = c.get("python写一个快速排序算法")
assert hit is not None assert hit is not None
assert hit[0] == "semantic" assert hit[0] == "semantic"
@@ -24,7 +24,7 @@ def test_promote_to_exact():
c = RouterCache(promote_frequency=3) c = RouterCache(promote_frequency=3)
result = {"response": "x", "domain": "general"} result = {"response": "x", "domain": "general"}
c.put("query", result) c.put("query", result)
# ?????? 3 ? ? ??????? # 语义命中 3 次后提升为精确缓存
for _ in range(3): for _ in range(3):
hit = c.get("query") hit = c.get("query")
assert hit is not None assert hit is not None
+2 -2
View File
@@ -30,8 +30,8 @@ def test_medical_classification():
def test_general_low_confidence(): def test_general_low_confidence():
clf = RuleClassifier() clf = RuleClassifier()
r = clf.classify("今天天气怎么样") r = clf.classify("你好呀")
# 未命中任何领域 -> 低置信度,触发 should_fallback # 未命中任何领域 -> general低置信度,触发 should_fallback
assert r.domain == "general" assert r.domain == "general"
assert clf.should_fallback(r, 0.6) is True assert clf.should_fallback(r, 0.6) is True
+24 -7
View File
@@ -41,14 +41,30 @@ def test_chat_legacy(client):
def test_chat_v2(v2_client): def test_chat_v2(v2_client):
# POST /chat 立即返回 request_id(异步协议)
resp = v2_client.post("/chat", json={"query": "请介绍快速排序算法"}) resp = v2_client.post("/chat", json={"query": "请介绍快速排序算法"})
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["response"] assert "request_id" in data
assert data["request_id"] assert data["status"] == "pending"
assert "fast_path" in data
assert "route" in data # 轮询 /runs/{id}/status 直到完成
assert data["status"] in ("fast_path", "done", "escalated", "failed") import time
for _ in range(50): # 最多 5s
time.sleep(0.1)
status_resp = v2_client.get(f"/runs/{data['request_id']}/status")
assert status_resp.status_code == 200
s = status_resp.json()
if s["status"] in ("done", "failed"):
break
assert s["status"] == "done", f"期望 done,实际 {s['status']}error={s.get('error')}"
assert s["response"]
assert "pipeline_status" in s
assert s["pipeline_status"] in ("fast_path", "done", "escalated")
# fast_path 不写 workspace.json,所以 workspace_path 可能为 None
if s["pipeline_status"] != "fast_path":
assert s["workspace_path"] is not None
def test_chat_empty_query(client): def test_chat_empty_query(client):
@@ -93,8 +109,9 @@ def test_web_ui_served(client):
resp = client.get("/") resp = client.get("/")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html") assert resp.headers["content-type"].startswith("text/html")
assert "端云协同" in resp.text # Vue SPA:由 Vite 生成,特征是 <div id="app"> 和 /static/assets/ 引用
assert "发送" in resp.text assert '<div id="app">' in resp.text
assert '/static/assets/' in resp.text
def test_review_flow(client): def test_review_flow(client):
+1 -1
View File
@@ -11,7 +11,7 @@ async def test_normal_flow_code(router):
assert r.response assert r.response
assert r.model_used assert r.model_used
assert r.latency_ms >= 0 assert r.latency_ms >= 0
assert "expert" in r.route[1] or "classify" in r.route[1] assert any("classify:" in s for s in r.route)
@pytest.mark.asyncio @pytest.mark.asyncio
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webapp</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1648
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "webapp",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^14.4.0",
"axios": "^1.20.0",
"pinia": "^4.0.3",
"vue": "^3.5.41",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.2.2",
"vue-tsc": "^3.3.11"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+86
View File
@@ -0,0 +1,86 @@
<template>
<div class="app">
<!-- 顶部导航栏 -->
<nav class="topbar">
<span class="brand">🤖 端云协同 LLM 系统</span>
<div class="nav-links">
<router-link to="/chat">💬 对话</router-link>
<router-link to="/collaboration">🔄 协作</router-link>
<router-link to="/review">🔍 检验</router-link>
<router-link to="/metrics">📊 指标</router-link>
<router-link to="/settings"> 设置</router-link>
</div>
</nav>
<!-- 路由视图 -->
<router-view class="content" />
</div>
</template>
<script setup lang="ts">
// App.vue — 根布局,导航栏 + 路由出口
</script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #111;
background: #fff;
}
.app {
display: flex;
flex-direction: column;
height: 100%;
}
.topbar {
display: flex;
align-items: center;
gap: 24px;
padding: 0 24px;
height: 52px;
border-bottom: 1px solid #e5e7eb;
background: #fff;
flex-shrink: 0;
}
.brand {
font-size: 16px;
font-weight: 700;
color: #1e40af;
white-space: nowrap;
}
.nav-links {
display: flex;
gap: 4px;
}
.nav-links a {
padding: 6px 14px;
border-radius: 6px;
text-decoration: none;
color: #374151;
font-size: 14px;
font-weight: 500;
transition: background 0.15s;
}
.nav-links a:hover { background: #f3f4f6; }
.nav-links a.router-link-active {
background: #eff6ff;
color: #2563eb;
}
.content {
flex: 1;
overflow: hidden; /* 限制自己高度,不被子内容撑开 */
display: flex;
flex-direction: column;
min-height: 0;
}
</style>
+262
View File
@@ -0,0 +1,262 @@
import axios from 'axios'
import type {
ChatInitResponse,
RunStatus,
SSEEvent,
ReviewItem,
Metrics,
} from '@/types'
const http = axios.create({ timeout: 10_000 })
// ── Chat ─────────────────────────────────────────────────────────────────────
/** POST /chat:异步提交,立即返回 request_id */
export async function chat(query: string, domain_group?: string) {
const { data } = await http.post<ChatInitResponse>('/chat', { query, domain_group })
return data
}
/** GET /runs/{id}/status:查询任务状态 */
export async function getRunStatus(requestId: string) {
const { data } = await http.get<RunStatus>(`/runs/${requestId}/status`)
return data
}
/** GET /runs/{id}/workspace:获取交流文本全文 */
export async function getWorkspace(requestId: string) {
const { data } = await http.get(`/runs/${requestId}/workspace`)
return data
}
/** SSE /runs/{id}/stream:订阅实时协作事件 */
export function watchRun(requestId: string) {
const es = new EventSource(`/runs/${requestId}/stream`)
return {
/** 监听器会在组件卸载时自动断开,调用者只需提供 onXxx 回调 */
subscribe(opts: {
onWorkspace: (ws: import('@/types').Workspace) => void
onStatus: (s: string) => void
onError: (detail: string) => void
onDone: () => void
}) {
es.addEventListener('message', (ev) => {
const d: SSEEvent = JSON.parse(ev.data)
if (d.type === 'workspace') opts.onWorkspace(d.workspace)
else if (d.type === 'status') {
opts.onStatus(d.value)
if (d.value === 'done' || d.value === 'failed') opts.onDone()
}
else if (d.type === 'error') opts.onError(d.detail)
})
es.addEventListener('error', () => {
// EventSource 自己会重连,这里只记录
})
},
close() { es.close() },
}
}
// ── 人工检验 ─────────────────────────────────────────────────────────────────
export async function listReviews(status?: string) {
const params = status ? { status } : {}
const { data } = await http.get<ReviewItem[]>('/review/queue', { params })
return data
}
export async function submitReview(reviewId: number, verdict: string, correction?: string) {
const { data } = await http.post(`/review/${reviewId}`, null, {
params: { verdict, correction },
})
return data
}
// ── 指标 ─────────────────────────────────────────────────────────────────────
export async function getMetrics() {
const { data } = await http.get<Metrics>('/api/metrics')
return data
}
// ── 模型设置 ─────────────────────────────────────────────────────────────────
export interface ModelSettings {
worker: {
backend: string
model: string
base_url: string
port: number
temperature: number
max_fix_attempts: number
code_timeout_s: number
}
architect: {
model: string
base_url: string
api_key: string
}
pipeline: {
fast_path: boolean
rounds_cap: number
api_token_cap: number
breach_policy: string
}
}
/** GET /config:读取当前模型设置 */
export async function getConfig() {
const { data } = await http.get<ModelSettings>('/config')
return data
}
/** PUT /config:更新模型设置(部分更新) */
export async function updateConfig(patch: Partial<ModelSettings>) {
const { data } = await http.put<ModelSettings>('/config', patch)
return data
}
/** POST /config/reset:恢复默认设置 */
export async function resetConfig() {
const { data } = await http.post<ModelSettings>('/config/reset')
return data
}
// ── 模型发现 & 连接验证 ───────────────────────────────────────────────────────
export interface ModelInfo {
id: string
name: string
}
/**
* GET /config/models?backend=llama_server&base_url=...&api_key=...
* 返回 { models: ModelInfo[] } 或 { error: string }
*/
export async function listModels(
backend: string,
base_url: string,
api_key: string,
) {
const params: Record<string, string> = { backend }
if (base_url) params.base_url = base_url
if (api_key) params.api_key = api_key
const { data } = await http.get<{ models?: ModelInfo[]; error?: string }>(
'/config/models', { params },
)
return data
}
/**
* GET /config/ping?backend=...&base_url=...&api_key=...
* 返回 { ok: boolean, detail?: string }
*/
export async function pingBackend(
backend: string,
base_url: string,
api_key: string,
) {
const params: Record<string, string> = { backend }
if (base_url) params.base_url = base_url
if (api_key) params.api_key = api_key
const { data } = await http.get<{ ok: boolean; detail?: string; status_code?: number }>(
'/config/ping', { params },
)
return data
}
// ── llama-server 内置管理 ─────────────────────────────────────────────────────
export interface LlamaStatus {
running: boolean
pid?: number | null
model?: string | null
port?: number | null
base_url?: string | null
started_at?: number | null
error?: string | null
}
export interface LocalModel {
id: string
name: string
size_mb: number
path: string
}
export interface DownloadProgress {
url: string
dest: string
downloaded_bytes: number
total_bytes?: number | null
progress_pct: number
speed: string
eta: string
done: boolean
error?: string | null
}
/** GET /llama/status */
export async function getLlamaStatus() {
const { data } = await http.get<LlamaStatus>('/llama/status')
return data
}
/** GET /llama/models */
export async function listLocalModels() {
const { data } = await http.get<{ models: LocalModel[] }>('/llama/models')
return data.models
}
/** POST /llama/start */
export async function startLlama(model: string, port = 8901, ngl = 99, ctx = 4096) {
const { data } = await http.post<LlamaStatus>('/llama/start', null, {
params: { model, port, ngl, ctx },
})
return data
}
/** POST /llama/stop */
export async function stopLlama() {
const { data } = await http.post<{ running: boolean }>('/llama/stop')
return data
}
/** POST /llama/download */
export async function downloadModel(url: string, dest?: string) {
const params: Record<string, string> = { url }
if (dest) params.dest = dest
const { data } = await http.post<DownloadProgress>('/llama/download', null, { params })
return data
}
/** SSE /llama/download/stream?url=... */
export function watchDownload(url: string) {
const es = new EventSource(`/llama/download/stream?url=${encodeURIComponent(url)}`)
return {
subscribe(opts: { onProgress: (p: DownloadProgress) => void; onDone: (p: DownloadProgress) => void }) {
es.addEventListener('message', (ev) => {
const p: DownloadProgress = JSON.parse(ev.data)
opts.onProgress(p)
if (p.done) opts.onDone(p)
})
es.addEventListener('error', () => { es.close() })
},
close() { es.close() },
}
}
// ── 辅助:轮询直到完成(用于不需要 SSE 的场景)──────────────────────────────────
export async function pollUntilDone(
requestId: string,
{ timeout = 60_000, interval = 500 }: { timeout?: number; interval?: number } = {},
) {
const t0 = Date.now()
while (Date.now() - t0 < timeout) {
const s = await getRunStatus(requestId)
if (s.status === 'done' || s.status === 'failed') return s
await new Promise((r) => setTimeout(r, interval))
}
throw new Error('poll timeout')
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { ref } from 'vue'
import heroImg from '../assets/hero.png'
import viteLogo from '../assets/vite.svg'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+41
View File
@@ -0,0 +1,41 @@
import { createRouter, createWebHistory } from 'vue-router'
import ChatView from '@/views/ChatView.vue'
// Vite 构建时 base=/static/,但 FastAPI 在 /metrics 等路径提供 SPA(不在 /static/ 下),
// 所以 history 固定用 '/',避免 Vue Router 把 /metrics 当作 /static/metrics 解析导致路由不匹配。
const router = createRouter({
history: createWebHistory('/'),
routes: [
{
path: '/',
redirect: '/chat',
},
{
path: '/chat',
name: 'chat',
component: ChatView,
},
{
path: '/collaboration',
name: 'collaboration',
component: () => import('@/views/CollaborationView.vue'),
},
{
path: '/review',
name: 'review',
component: () => import('@/views/ReviewView.vue'),
},
{
path: '/metrics',
name: 'metrics',
component: () => import('@/views/MetricsView.vue'),
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
},
],
})
export default router
+61
View File
@@ -0,0 +1,61 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { chat, getRunStatus, pollUntilDone } from '@/api'
import type { ChatInitResponse, RunStatus, Workspace } from '@/types'
// 单次会话记录
export interface ChatSession {
requestId: string
query: string
init: ChatInitResponse
status: RunStatus | null
workspace: Workspace | null
error: string | null
}
export const useChatStore = defineStore('chat', () => {
const sessions = ref<ChatSession[]>([])
const currentId = ref<string | null>(null)
const current = () => sessions.value.find((s) => s.requestId === currentId.value) ?? null
async function sendQuery(query: string) {
const init = await chat(query)
const session: ChatSession = {
requestId: init.request_id,
query,
init,
status: null,
workspace: null,
error: null,
}
sessions.value.unshift(session)
currentId.value = init.request_id
return session
}
async function pollStatus(requestId: string) {
const s = await getRunStatus(requestId)
const session = sessions.value.find((x) => x.requestId === requestId)
if (session) session.status = s
return s
}
async function waitForDone(requestId: string) {
const s = await pollUntilDone(requestId)
const session = sessions.value.find((x) => x.requestId === requestId)
if (session) session.status = s
return s
}
function updateWorkspace(requestId: string, ws: Workspace) {
const session = sessions.value.find((x) => x.requestId === requestId)
if (session) session.workspace = ws
}
function setCurrent(id: string | null) {
currentId.value = id
}
return { sessions, currentId, current, sendQuery, pollStatus, waitForDone, updateWorkspace, setCurrent }
})
+296
View File
@@ -0,0 +1,296 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+117
View File
@@ -0,0 +1,117 @@
// ── 协作任务状态 ──────────────────────────────────────────────────────────────
/** POST /chat 立即返回 */
export interface ChatInitResponse {
request_id: string
status: 'pending'
}
/** GET /runs/{id}/status 返回 */
export interface RunStatus {
request_id: string
status: 'pending' | 'running' | 'done' | 'failed' // JobStore 任务状态
ws_state: string | null // workspace.json 内 state
started_at: number
finished_at: number
error: string | null
// PipelineResult 等效字段(扁平时 TaskInfo
response: string | null
pipeline_status: string | null // done | fast_path | escalated | failed
fast_path: boolean
rounds_used: number
api_input_tokens: number
api_output_tokens: number
cost_est: number
model_used: string | null
latency_ms: number | null
route: string[]
workspace_path: string | null
}
// ── SSE 事件 ─────────────────────────────────────────────────────────────────
export type SSEEvent =
| { type: 'workspace'; version: number; state: string; request_id: string; workspace: Workspace }
| { type: 'status'; value: string; request_id: string }
| { type: 'error'; detail: string }
// ── 交流文本(workspace.json)────────────────────────────────────────────────
export interface Workspace {
request_id: string
query: string
brief: Brief | null
plan: PlanStep[]
progress: ProgressStep[]
issues: Issue[]
decisions: Decision[]
archive: string[]
meta: WsMeta
status?: string
error?: string
}
export interface Brief {
goal: string
tags: string[]
domain: string
constraints: string[]
}
export interface PlanStep {
id: string
task: string
deps: string[]
status?: string
}
export interface ProgressStep {
step: string
status: 'pending' | 'running' | 'done'
artifact?: string
note?: string
}
export interface Issue {
id: string
step: string
description: string
suggestion?: string
}
export interface Decision {
ref: string
reply: string
patch_plan?: PlanStep[]
}
export interface WsMeta {
state: 'draft' | 'in_progress' | 'reviewing' | 'done' | 'failed'
round: number
rounds_cap: number
version?: number
api_input_tokens: number
api_output_tokens: number
}
// ── 人工检验 ─────────────────────────────────────────────────────────────────
export interface ReviewItem {
id: number
request_id: string
query: string
response: string
verdict: 'pending' | 'approved' | 'rejected'
tags: string[]
created_at: string
correction?: string
}
// ── 指标 ─────────────────────────────────────────────────────────────────────
export interface Metrics {
router: Record<string, unknown>
cache: Record<string, unknown>
v2?: Record<string, unknown>
review?: { pending: number; total: number }
}
+338
View File
@@ -0,0 +1,338 @@
<template>
<div class="chat-view">
<!-- 历史会话侧边栏 -->
<aside class="sidebar">
<h3>会话记录</h3>
<ul class="session-list">
<li
v-for="s in chatStore.sessions"
:key="s.requestId"
:class="['session-item', { active: s.requestId === chatStore.currentId }]"
@click="chatStore.setCurrent(s.requestId)"
>
<span class="s-query">{{ s.query.slice(0, 28) }}{{ s.query.length > 28 ? '…' : '' }}</span>
<span class="s-status" :class="s.status?.status ?? 'pending'">
{{ s.status?.status ?? 'pending' }}
</span>
</li>
</ul>
</aside>
<!-- 主聊天区 -->
<main class="main">
<div v-if="!chatStore.current()" class="empty">
<p>输入问题开启端云协同协作之旅</p>
<p class="hint">结果通过 SSE 实时推送可切换协作页面查看交流文本可视化</p>
</div>
<template v-else>
<!-- 用户问题 -->
<div class="user-msg">
<span class="role-label"></span>
<p>{{ chatStore.current()!.query }}</p>
</div>
<!-- 状态指示器 -->
<div class="status-bar">
<span v-if="runStatus === 'pending'" class="badge pending"> 排队中</span>
<span v-else-if="runStatus === 'running'" class="badge running">
🔄 协作中 ({{ ws?.meta.round ?? 0 }}/{{ ws?.meta.rounds_cap ?? 6 }})
</span>
<span v-else-if="runStatus === 'done'" class="badge done"> 完成</span>
<span v-else-if="runStatus === 'failed'" class="badge failed"> 失败</span>
<span v-if="ws" class="route-path">
路由{{ chatStore.current()!.status?.route?.join(' ') ?? '' }}
</span>
</div>
<!-- 协作元信息 -->
<div v-if="ws" class="ws-meta">
<span>tokens: {{ ws.meta.api_input_tokens }} in / {{ ws.meta.api_output_tokens }} out</span>
<span>延迟: {{ chatStore.current()!.status?.latency_ms?.toFixed(0) }} ms</span>
<span>模型: {{ chatStore.current()!.status?.model_used ?? '—' }}</span>
</div>
<!-- 响应正文 -->
<div v-if="response" class="assistant-msg">
<span class="role-label">系统</span>
<pre>{{ response }}</pre>
</div>
<!-- 交流文本预览紧凑折叠 -->
<details v-if="ws" class="ws-preview">
<summary>📄 交流文本预览</summary>
<div v-if="ws.brief" class="brief-block">
<strong>Brief:</strong> {{ ws.brief.goal }}
<span class="tags">{{ ws.brief.tags.join(', ') }}</span>
</div>
<ul v-if="ws.plan?.length" class="plan-list">
<li v-for="p in ws.plan" :key="p.id" :class="p.status">
<span class="step-id">{{ p.id }}</span>
<span>{{ p.task }}</span>
<span class="deps" v-if="p.deps.length">{{ p.deps.join(',') }}</span>
</li>
</ul>
<ul v-if="ws.progress?.length" class="progress-list">
<li v-for="pg in ws.progress" :key="pg.step" :class="pg.status">
{{ pg.step }}: {{ pg.status }}
</li>
</ul>
</details>
<!-- 错误 -->
<div v-if="chatStore.current()!.error" class="error-msg">
{{ chatStore.current()!.error }}
</div>
</template>
<!-- 输入框 -->
<form class="input-bar" @submit.prevent="handleSend">
<input
v-model="input"
placeholder="输入你的问题…"
:disabled="sending"
autofocus
/>
<button type="submit" :disabled="sending || !input.trim()">
{{ sending ? '发送中…' : '发送' }}
</button>
</form>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import { watchRun } from '@/api'
import type { Workspace } from '@/types'
const chatStore = useChatStore()
const input = ref('')
const sending = ref(false)
const current = computed(() => chatStore.current())
const runStatus = computed(() => current.value?.status?.status ?? 'pending')
const ws = computed(() => current.value?.workspace)
const response = computed(() => current.value?.status?.response ?? null)
// SSE 订阅
let sseHandle: ReturnType<typeof watchRun> | null = null
function subscribeSSE(requestId: string) {
sseHandle?.close()
sseHandle = watchRun(requestId)
sseHandle.subscribe({
onWorkspace(workspace: Workspace) {
chatStore.updateWorkspace(requestId, workspace)
},
onStatus(_state: string) {
chatStore.pollStatus(requestId)
},
onError(detail: string) {
const s = chatStore.sessions.find((x) => x.requestId === requestId)
if (s) s.error = detail
},
onDone() {
chatStore.pollStatus(requestId)
},
})
}
watch(
() => chatStore.currentId,
(id) => {
if (id) {
subscribeSSE(id)
// 若已完成立即拉一次状态
if (current.value?.status?.status === 'done') {
chatStore.pollStatus(id)
}
}
},
{ immediate: true },
)
async function handleSend() {
if (!input.value.trim() || sending.value) return
const query = input.value.trim()
input.value = ''
sending.value = true
try {
const session = await chatStore.sendQuery(query)
subscribeSSE(session.requestId)
} finally {
sending.value = false
}
}
</script>
<style scoped>
.chat-view {
display: flex;
height: 100%;
gap: 0;
}
.sidebar {
width: 240px;
border-right: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar h3 {
padding: 12px 16px;
margin: 0;
font-size: 14px;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
.session-list {
list-style: none;
margin: 0;
padding: 8px;
overflow-y: auto;
flex: 1;
}
.session-item {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
}
.session-item:hover { background: #e5e7eb; }
.session-item.active { background: #dbeafe; }
.s-query { color: #111; }
.s-status { font-size: 11px; color: #9ca3af; }
.s-status.done { color: #16a34a; }
.s-status.failed { color: #dc2626; }
.s-status.running { color: #2563eb; }
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 24px;
gap: 12px;
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9ca3af;
gap: 8px;
}
.hint { font-size: 13px; }
.user-msg, .assistant-msg {
background: #f3f4f6;
border-radius: 8px;
padding: 12px 16px;
display: flex;
gap: 12px;
font-size: 14px;
line-height: 1.6;
}
.assistant-msg { background: #eff6ff; }
.role-label {
font-weight: 700;
font-size: 12px;
color: #6b7280;
min-width: 32px;
}
.user-msg pre, .assistant-msg pre {
margin: 0;
white-space: pre-wrap;
font-family: inherit;
}
.status-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.badge {
padding: 3px 10px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
}
.badge.pending { background: #f3f4f6; color: #6b7280; }
.badge.running { background: #dbeafe; color: #2563eb; }
.badge.done { background: #dcfce7; color: #16a34a; }
.badge.failed { background: #fee2e2; color: #dc2626; }
.route-path { font-size: 12px; color: #9ca3af; }
.ws-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #9ca3af;
}
.ws-preview {
background: #fafafa;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
font-size: 13px;
}
.brief-block { margin-bottom: 8px; }
.tags { margin-left: 8px; color: #6b7280; font-size: 12px; }
.plan-list, .progress-list {
list-style: none;
padding: 0;
margin: 4px 0 0;
}
.plan-list li, .progress-list li {
padding: 2px 0;
display: flex;
gap: 6px;
}
.step-id { font-family: monospace; color: #6b7280; min-width: 48px; }
.deps { color: #9ca3af; font-size: 12px; }
.done { color: #16a34a; }
.pending { color: #9ca3af; }
.running { color: #2563eb; }
.error-msg { color: #dc2626; font-size: 13px; background: #fee2e2; padding: 8px 12px; border-radius: 6px; }
.input-bar {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
margin-top: auto;
}
.input-bar input {
flex: 1;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
}
.input-bar input:focus { border-color: #2563eb; }
.input-bar button {
padding: 10px 20px;
background: #2563eb;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.input-bar button:disabled { background: #9ca3af; cursor: not-allowed; }
</style>
+450
View File
@@ -0,0 +1,450 @@
<template>
<div class="collab-view">
<!-- 左侧会话选择 + 协作流程图 -->
<aside class="collab-sidebar">
<h3>协作会话</h3>
<ul class="session-list">
<li
v-for="s in chatStore.sessions"
:key="s.requestId"
:class="['session-item', { active: s.requestId === activeId }]"
@click="selectSession(s.requestId)"
>
<span>{{ s.query.slice(0, 24) }}{{ s.query.length > 24 ? '…' : '' }}</span>
<span class="meta">
{{ s.status?.rounds_used ?? '?' }}
· {{ s.status?.model_used ?? '—' }}
</span>
</li>
</ul>
<!-- 当前会话统计 -->
<div v-if="currentSession" class="stats">
<h4>运行统计</h4>
<div class="stat-grid">
<span>输入 Token</span><b>{{ currentSession.status?.api_input_tokens ?? 0 }}</b>
<span>输出 Token</span><b>{{ currentSession.status?.api_output_tokens ?? 0 }}</b>
<span>延迟</span><b>{{ currentSession.status?.latency_ms?.toFixed(0) ?? '—' }} ms</b>
<span>快路径</span><b>{{ currentSession.status?.fast_path ? '是' : '否' }}</b>
</div>
<h4>路由路径</h4>
<div class="route-flow">
<span
v-for="(r, i) in currentSession.status?.route ?? []"
:key="i"
class="route-node"
>{{ r }}</span>
</div>
</div>
</aside>
<!-- 右侧交流文本实时可视化 -->
<main class="collab-main">
<div v-if="!ws" class="empty">
<p>从左侧选择一个会话或在对话页发起新提问</p>
<p>协作过程通过 SSE 实时推送无需刷新</p>
</div>
<template v-else>
<!-- Brief 阶段 -->
<section class="section brief-section">
<h2 class="section-title">📋 Brief任务简报</h2>
<div class="brief-card">
<div class="goal">{{ ws.brief?.goal ?? '(未生成)' }}</div>
<div class="tags">
<span v-for="t in ws.brief?.tags ?? []" :key="t" class="tag">{{ t }}</span>
<span v-if="ws.brief?.domain" class="domain">{{ ws.brief.domain }}</span>
</div>
<ul v-if="ws.brief?.constraints?.length" class="constraints">
<li v-for="(c, i) in ws.brief.constraints" :key="i">{{ c }}</li>
</ul>
</div>
</section>
<!-- Plan 阶段 -->
<section class="section plan-section">
<h2 class="section-title">📌 Plan执行计划</h2>
<div class="plan-timeline">
<div
v-for="step in ws.plan ?? []"
:key="step.id"
:class="['plan-step', getStepStatus(step.id)]"
>
<div class="step-dot" />
<div class="step-content">
<div class="step-header">
<span class="step-id">{{ step.id }}</span>
<span class="step-status">{{ getStepStatus(step.id) }}</span>
</div>
<p class="step-task">{{ step.task }}</p>
<div v-if="step.deps.length" class="step-deps">
依赖<span v-for="d in step.deps" :key="d" class="dep">{{ d }}</span>
</div>
<!-- 步骤产出工件 -->
<div v-if="getStepArtifact(step.id)" class="step-artifact">
<details>
<summary>📄 工件内容</summary>
<pre>{{ getStepArtifact(step.id) }}</pre>
</details>
</div>
</div>
</div>
</div>
</section>
<!-- Progress 实时滚动 -->
<section class="section progress-section">
<h2 class="section-title">🔄 进度实时</h2>
<div class="progress-bar-wrap">
<div class="progress-label">
{{ ws.meta.round }} / {{ ws.meta.rounds_cap }}
</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${(ws.meta.round / ws.meta.rounds_cap) * 100}%` }"
/>
</div>
</div>
<div class="progress-steps">
<div
v-for="pg in ws.progress ?? []"
:key="pg.step"
:class="['pg-step', pg.status]"
>
<span class="pg-icon">
{{ pg.status === 'done' ? '✅' : pg.status === 'running' ? '⏳' : '⭕' }}
</span>
<span>{{ pg.step }}</span>
<span v-if="pg.note" class="pg-note">{{ pg.note }}</span>
</div>
</div>
</section>
<!-- Issues + Decisions -->
<section v-if="ws.issues?.length" class="section issues-section">
<h2 class="section-title"> Issues &amp; 裁决</h2>
<div v-for="issue in ws.issues" :key="issue.id" class="issue-card">
<div class="issue-header">
<span class="issue-id">{{ issue.id }}</span>
<span class="issue-step">Step: {{ issue.step }}</span>
</div>
<p>{{ issue.description }}</p>
<div v-if="getDecision(issue.id)" class="decision">
<strong>Architect 裁决</strong>{{ getDecision(issue.id)?.reply }}
</div>
</div>
</section>
<!-- Archive -->
<section v-if="ws.archive?.length" class="section archive-section">
<h2 class="section-title">📦 Archive摘要归档</h2>
<ul class="archive-list">
<li v-for="(item, i) in ws.archive" :key="i">{{ item }}</li>
</ul>
</section>
<!-- 最终交付 -->
<section v-if="ws.meta.state === 'done'" class="section deliver-section">
<h2 class="section-title">🎉 交付</h2>
<div class="response-block">
<pre>{{ currentSession?.status?.response ?? '(无内容)' }}</pre>
</div>
</section>
</template>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import { watchRun } from '@/api'
import type { Workspace } from '@/types'
const chatStore = useChatStore()
const activeId = ref<string | null>(chatStore.currentId)
const liveWs = ref<Workspace | null>(null)
const currentSession = computed(() =>
chatStore.sessions.find((s) => s.requestId === activeId.value) ?? null,
)
const ws = computed(() => liveWs.value ?? currentSession.value?.workspace ?? null)
function selectSession(id: string) {
activeId.value = id
liveWs.value = null
}
// SSE 实时推送
let sse: ReturnType<typeof watchRun> | null = null
function startSSE(requestId: string) {
sse?.close()
sse = watchRun(requestId)
sse.subscribe({
onWorkspace(workspace: Workspace) {
if (workspace.request_id === activeId.value) {
liveWs.value = workspace
chatStore.updateWorkspace(requestId, workspace)
}
},
onStatus() {
chatStore.pollStatus(requestId)
},
onDone() {
chatStore.pollStatus(requestId)
},
onError() {},
})
}
watch(
() => chatStore.sessions,
(sessions) => {
if (activeId.value) {
const found = sessions.find((s) => s.requestId === activeId.value)
if (!found) activeId.value = sessions[0]?.requestId ?? null
}
},
{ deep: true },
)
watch(
activeId,
(id) => {
if (!id) return
const s = chatStore.sessions.find((x) => x.requestId === id)
if (s?.workspace) liveWs.value = s.workspace
startSSE(id)
},
{ immediate: true },
)
function getStepStatus(stepId: string) {
return ws.value?.progress?.find((p: import('@/types').ProgressStep) => p.step === stepId)?.status ?? 'pending'
}
function getStepArtifact(stepId: string) {
return ws.value?.progress?.find((p: import('@/types').ProgressStep) => p.step === stepId)?.artifact ?? null
}
function getDecision(issueId: string) {
return ws.value?.decisions?.find((d: import('@/types').Decision) => d.ref === issueId) ?? null
}
</script>
<style scoped>
.collab-view {
display: flex;
height: 100%;
}
.collab-sidebar {
width: 260px;
border-right: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
flex-direction: column;
overflow: hidden;
}
.collab-sidebar h3 {
padding: 12px 16px;
margin: 0;
font-size: 13px;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
font-weight: 600;
}
.session-list {
list-style: none;
padding: 8px;
overflow-y: auto;
flex: 0 0 auto;
max-height: 35%;
}
.session-item {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
}
.session-item:hover { background: #e5e7eb; }
.session-item.active { background: #dbeafe; }
.meta { font-size: 11px; color: #9ca3af; }
.stats {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
font-size: 12px;
overflow-y: auto;
}
.stats h4 { margin: 0 0 6px; color: #374151; font-size: 12px; }
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 8px;
margin-bottom: 12px;
}
.stat-grid span { color: #6b7280; }
.stat-grid b { color: #111; text-align: right; }
.route-flow {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.route-node {
background: #e0e7ff;
color: #3730a3;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-family: monospace;
}
.collab-main {
flex: 1;
overflow-y: auto;
padding: 20px 28px;
}
.empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9ca3af;
gap: 8px;
}
.section { margin-bottom: 28px; }
.section-title {
font-size: 16px;
color: #111;
margin: 0 0 12px;
padding-bottom: 6px;
border-bottom: 2px solid #2563eb;
}
.brief-card {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
padding: 14px 16px;
}
.goal { font-size: 14px; margin-bottom: 8px; color: #1e40af; font-weight: 600; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { background: #dbeafe; color: #1d4ed8; padding: 2px 8px; border-radius: 99px; font-size: 12px; }
.domain { background: #fce7f3; color: #9d174d; padding: 2px 8px; border-radius: 99px; font-size: 12px; }
.constraints { list-style: disc inside; font-size: 13px; color: #374151; margin-top: 8px; }
/* Plan timeline */
.plan-timeline { display: flex; flex-direction: column; gap: 0; }
.plan-step {
display: flex;
gap: 12px;
padding-bottom: 16px;
position: relative;
}
.plan-step::before {
content: '';
position: absolute;
left: 6px;
top: 16px;
bottom: 0;
width: 2px;
background: #e5e7eb;
}
.plan-step:last-child::before { display: none; }
.step-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #d1d5db;
background: #fff;
flex-shrink: 0;
margin-top: 2px;
z-index: 1;
}
.plan-step.done .step-dot { background: #16a34a; border-color: #16a34a; }
.plan-step.running .step-dot { background: #2563eb; border-color: #2563eb; }
.plan-step.pending .step-dot { background: #fff; }
.step-content { flex: 1; }
.step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.step-id { font-family: monospace; font-size: 12px; color: #6b7280; font-weight: 700; }
.step-status { font-size: 11px; padding: 1px 6px; border-radius: 99px; }
.plan-step.done .step-status { background: #dcfce7; color: #16a34a; }
.plan-step.running .step-status { background: #dbeafe; color: #2563eb; }
.plan-step.pending .step-status { background: #f3f4f6; color: #9ca3af; }
.step-task { margin: 0; font-size: 13px; color: #374151; }
.step-deps { font-size: 11px; color: #9ca3af; margin-top: 2px; }
.dep { background: #f3f4f6; padding: 0 4px; border-radius: 3px; font-family: monospace; margin-right: 4px; }
.step-artifact { margin-top: 6px; }
.step-artifact details { background: #fafafa; border: 1px solid #e5e7eb; border-radius: 4px; }
.step-artifact summary { padding: 4px 8px; cursor: pointer; font-size: 12px; color: #6b7280; }
.step-artifact pre { padding: 6px 10px; font-size: 12px; margin: 0; white-space: pre-wrap; max-height: 120px; overflow-y: auto; }
/* Progress */
.progress-bar-wrap { margin-bottom: 10px; }
.progress-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; }
.progress-bar { height: 6px; background: #e5e7eb; border-radius: 99px; overflow: hidden; }
.progress-fill { height: 100%; background: #2563eb; border-radius: 99px; transition: width 0.4s ease; }
.progress-steps { display: flex; flex-wrap: wrap; gap: 8px; }
.pg-step {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
border: 1px solid #e5e7eb;
}
.pg-step.done { background: #dcfce7; border-color: #bbf7d0; }
.pg-step.running { background: #dbeafe; border-color: #bfdbfe; }
.pg-note { color: #9ca3af; font-size: 11px; }
/* Issues */
.issue-card {
border: 1px solid #fca5a5;
background: #fff5f5;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 10px;
}
.issue-header { display: flex; gap: 8px; margin-bottom: 6px; }
.issue-id { font-family: monospace; font-size: 12px; color: #dc2626; font-weight: 700; }
.issue-step { font-size: 11px; color: #6b7280; }
.decision {
margin-top: 8px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 4px;
padding: 6px 10px;
font-size: 13px;
}
/* Archive */
.archive-list { list-style: disc inside; font-size: 13px; color: #374151; }
/* Deliver */
.response-block {
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: 8px;
padding: 14px 16px;
}
.response-block pre {
margin: 0;
white-space: pre-wrap;
font-size: 14px;
line-height: 1.7;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<template>
<div class="metrics-view">
<header class="metrics-header">
<h2>系统指标</h2>
<button class="refresh" @click="load">🔄 刷新</button>
</header>
<div v-if="loading" class="loading">加载中</div>
<div v-else-if="error" class="error">{{ error }}</div>
<template v-else-if="data">
<!-- 卡片网格 -->
<div class="card-grid">
<div class="metric-card">
<h3>路由器v1</h3>
<div class="kv-list">
<template v-for="(v, k) in data.router" :key="k">
<span>{{ k }}</span><b>{{ v }}</b>
</template>
</div>
</div>
<div class="metric-card">
<h3>缓存</h3>
<div class="kv-list">
<template v-for="(v, k) in data.cache" :key="k">
<span>{{ k }}</span><b>{{ v }}</b>
</template>
</div>
</div>
<div v-if="data.v2" class="metric-card highlight">
<h3>协作管线v2</h3>
<div class="kv-list">
<template v-for="(v, k) in data.v2" :key="k">
<span>{{ k }}</span><b>{{ v }}</b>
</template>
</div>
</div>
<div v-if="data.review" class="metric-card review-card">
<h3>人工检验</h3>
<div class="review-stats">
<div class="stat-item">
<span class="stat-num">{{ data.review.pending }}</span>
<span class="stat-label">待审核</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ data.review.total }}</span>
<span class="stat-label">总提交</span>
</div>
</div>
<div v-if="data.review.total > 0" class="progress-wrap">
<div
class="reviewed-bar"
:style="{
width: `${((data.review.total - data.review.pending) / data.review.total) * 100}%`,
}"
/>
</div>
<p class="review-rate">
通过率
{{ (((data.review.total - data.review.pending) / data.review.total) * 100).toFixed(1) }}%
</p>
</div>
</div>
<!-- 原始 JSON -->
<details class="raw-json">
<summary>原始 JSON</summary>
<pre>{{ JSON.stringify(data, null, 2) }}</pre>
</details>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getMetrics } from '@/api'
import type { Metrics } from '@/types'
const data = ref<Metrics | null>(null)
const loading = ref(false)
const error = ref('')
async function load() {
loading.value = true
error.value = ''
try {
data.value = await getMetrics()
} catch (e: unknown) {
// 网络超时或服务端错误:显示友好错误而不是无限 loading
error.value = e instanceof Error ? e.message : '指标加载失败,请检查后端服务'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.metrics-view { padding: 20px 24px; height: 100%; overflow-y: auto; }
.metrics-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.metrics-header h2 { margin: 0; font-size: 20px; }
.refresh { padding: 6px 14px; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; background: #fff; }
.loading, .error { text-align: center; padding: 40px; color: #9ca3af; }
.error { color: #dc2626; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.metric-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px;
}
.metric-card.highlight { border-color: #2563eb; background: #eff6ff; }
.metric-card h3 { margin: 0 0 12px; font-size: 14px; color: #374151; }
.kv-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
font-size: 13px;
}
.kv-list span { color: #6b7280; }
.kv-list b { color: #111; text-align: right; }
.review-card { grid-column: span 2; }
.review-stats { display: flex; gap: 24px; margin-bottom: 12px; }
.stat-item { display: flex; flex-direction: column; align-items: center; }
.stat-num { font-size: 28px; font-weight: 700; color: #2563eb; }
.stat-label { font-size: 12px; color: #6b7280; }
.progress-wrap {
height: 8px;
background: #e5e7eb;
border-radius: 99px;
overflow: hidden;
margin-bottom: 6px;
}
.reviewed-bar { height: 100%; background: #16a34a; transition: width 0.5s ease; }
.review-rate { font-size: 13px; color: #6b7280; margin: 0; }
.raw-json {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.raw-json summary { padding: 10px 14px; cursor: pointer; font-size: 13px; color: #6b7280; }
.raw-json pre {
padding: 10px 14px;
margin: 0;
font-size: 12px;
white-space: pre-wrap;
border-top: 1px solid #e5e7eb;
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<template>
<div class="review-view">
<header class="review-header">
<h2>人工检验队列</h2>
<div class="controls">
<button :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</button>
<button :class="{ active: filter === 'pending' }" @click="filter = 'pending'">待审核</button>
<button :class="{ active: filter === 'approved' }" @click="filter = 'approved'">已通过</button>
<button :class="{ active: filter === 'rejected' }" @click="filter = 'rejected'">已拒绝</button>
<button class="refresh-btn" @click="load">🔄 刷新</button>
</div>
</header>
<div v-if="loading" class="loading">加载中</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="queue-list">
<div v-if="!filtered.length" class="empty">队列为空</div>
<div v-for="item in filtered" :key="item.id" class="review-card">
<div class="card-header">
<span class="card-id">#{{ item.id }}</span>
<span class="verdict-badge" :class="item.verdict">{{ item.verdict }}</span>
<span class="tags">
<span v-for="t in item.tags" :key="t" class="tag">{{ t }}</span>
</span>
<span class="date">{{ item.created_at }}</span>
</div>
<div class="query-block">
<strong>Query</strong>{{ item.query }}
</div>
<div class="response-block">
<strong>Response</strong>
<pre>{{ item.response }}</pre>
</div>
<div v-if="item.verdict === 'pending'" class="actions">
<textarea
v-model="correctionInputs[item.id]"
placeholder="修正意见(可选)"
rows="2"
/>
<div class="btn-row">
<button class="approve" @click="submit(item.id, 'approved')"> 通过</button>
<button class="reject" @click="submit(item.id, 'rejected')"> 拒绝</button>
</div>
</div>
<div v-else-if="item.correction" class="correction">
<strong>修正</strong>{{ item.correction }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { listReviews, submitReview } from '@/api'
import type { ReviewItem } from '@/types'
const items = ref<ReviewItem[]>([])
const loading = ref(false)
const error = ref('')
const filter = ref<'all' | 'pending' | 'approved' | 'rejected'>('pending')
const correctionInputs = ref<Record<number, string>>({})
const filtered = computed(() =>
filter.value === 'all'
? items.value
: items.value.filter((i) => i.verdict === filter.value),
)
async function load() {
loading.value = true
error.value = ''
try {
items.value = await listReviews()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
async function submit(id: number, verdict: 'approved' | 'rejected') {
try {
await submitReview(id, verdict, correctionInputs.value[id] || undefined)
await load()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e)
}
}
onMounted(load)
</script>
<style scoped>
.review-view { padding: 20px 24px; height: 100%; overflow-y: auto; }
.review-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.review-header h2 { margin: 0; font-size: 20px; }
.controls { display: flex; gap: 8px; }
button {
padding: 6px 14px;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
.refresh-btn { margin-left: auto; }
.loading, .error, .empty {
text-align: center;
padding: 40px;
color: #9ca3af;
}
.error { color: #dc2626; }
.queue-list { display: flex; flex-direction: column; gap: 16px; }
.review-card {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px;
background: #fff;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.card-id { font-family: monospace; font-size: 12px; color: #6b7280; }
.verdict-badge {
padding: 2px 8px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
}
.verdict-badge.pending { background: #fef3c7; color: #92400e; }
.verdict-badge.approved { background: #dcfce7; color: #16a34a; }
.verdict-badge.rejected { background: #fee2e2; color: #dc2626; }
.tags { display: flex; gap: 4px; }
.tag { background: #e0e7ff; color: #3730a3; padding: 1px 6px; border-radius: 4px; font-size: 11px; }
.date { margin-left: auto; font-size: 11px; color: #9ca3af; }
.query-block, .response-block {
font-size: 13px;
margin-bottom: 8px;
line-height: 1.6;
}
.query-block pre, .response-block pre {
margin: 4px 0 0;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 6px 10px;
white-space: pre-wrap;
font-size: 13px;
}
.actions { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
resize: vertical;
box-sizing: border-box;
font-family: inherit;
}
.btn-row { display: flex; gap: 8px; }
.approve { background: #dcfce7; border-color: #86efac; color: #16a34a; }
.reject { background: #fee2e2; border-color: #fca5a5; color: #dc2626; }
.correction {
margin-top: 8px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 4px;
padding: 6px 10px;
font-size: 13px;
}
</style>
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
base: '/static/',
resolve: {
alias: { '@': resolve(__dirname, 'src') },
},
server: {
proxy: {
// 开发时:前端同源请求(无 /api 前缀)代理到 FastAPI
'/chat': { target: 'http://localhost:8000', changeOrigin: true },
'/runs': { target: 'http://localhost:8000', changeOrigin: true },
'/review': { target: 'http://localhost:8000', changeOrigin: true },
'/api/metrics':{ target: 'http://localhost:8000', changeOrigin: true },
'/config': { target: 'http://localhost:8000', changeOrigin: true },
'/health': { target: 'http://localhost:8000', changeOrigin: true },
'/traces':{ target: 'http://localhost:8000', changeOrigin: true },
},
},
build: {
outDir: '../gateway/static',
emptyOutDir: true,
},
})
+1
View File
@@ -81,3 +81,4 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T12 | 实验脚本 bench_tokens.py + 数据集 | ✅ 完成 | T12 | | T12 | 实验脚本 bench_tokens.py + 数据集 | ✅ 完成 | T12 |
| T13 | E1 本地跑数完成(E2E5 待 live 接入) | ✅ 完成 | T13 | | T13 | E1 本地跑数完成(E2E5 待 live 接入) | ✅ 完成 | T13 |
| T14 | 文档收口(README v2 改写) | ✅ 完成 | T14 | | T14 | 文档收口(README v2 改写) | ✅ 完成 | T14 |
| T15 | Pipeline 死锁修复(_deps_done 依赖过滤 + pending-empty break+ /metrics SPA 路由冲突修复 | ✅ 完成 | T15 |