feat(v3): Web 应用化基线(异步任务/SSE/llama-server 管理/Vue SPA 四页 + 设置页整页滚动修复)
@@ -88,8 +88,8 @@ runtime:
|
||||
cpu: {ngl: 0, ctx: 8192}
|
||||
|
||||
architect: # 大模型(API)
|
||||
model: deepseek-chat
|
||||
base_url: https://api.deepseek.com/v1
|
||||
model: deepseek-v4-flash
|
||||
base_url: https://api.deepseek.com
|
||||
api_key_env: DEEPSEEK_API_KEY
|
||||
temperature: 0.2
|
||||
timeout_s: 60
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""异步后台任务注册表(T1:后端异步化核心)。
|
||||
|
||||
设计原则(对齐 v3 方案 D1-D6):
|
||||
- asyncio 原生,无 Celery/Redis/外部队列
|
||||
- 每个 request_id -> TaskInfo(状态/开始时间/结果或错误)
|
||||
- 任务写 runs/{id}/workspace.json,SSE 生成器只读该文件(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.json,yield 单条事件。
|
||||
|
||||
事件类型:
|
||||
- {"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
|
||||
@@ -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
|
||||
@@ -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};
|
||||
@@ -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}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
@@ -1,224 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>端云协同 LLM 协作系统</title>
|
||||
<style>
|
||||
: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}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
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}
|
||||
header{display:flex;align-items:center;gap:16px;padding:12px 20px;background:var(--panel);border-bottom:1px solid var(--line)}
|
||||
header h1{font-size:16px;font-weight:600}
|
||||
header .sub{color:var(--muted);font-size:12px}
|
||||
.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/api(Ollama / vLLM 等兼容端点)</option>
|
||||
<option value="mock">mock(演示,零运行时)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>模型路径/名称 <input id="set-worker-model" type="text" placeholder="models/xxx.gguf 或模型名"></label>
|
||||
<label>端点 base_url(openai 用) <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>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webapp</title>
|
||||
<script type="module" crossorigin src="/static/assets/index-BnlAnYlB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-G8RWfJ2Y.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -59,7 +59,7 @@ class ArchitectClient:
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
base_url: str = "https://api.deepseek.com/v1",
|
||||
base_url: str = "https://api.deepseek.com",
|
||||
api_key: Optional[str] = None,
|
||||
temperature: float = 0.2,
|
||||
timeout_s: float = 60.0,
|
||||
@@ -220,8 +220,8 @@ def build_architect(cfg: Dict[str, Any],
|
||||
_env = get_env or os.environ.get
|
||||
key = cfg.get("api_key") or _env(cfg.get("api_key_env", "DEEPSEEK_API_KEY"))
|
||||
return ArchitectClient(
|
||||
model=cfg.get("model", "deepseek-chat"),
|
||||
base_url=cfg.get("base_url", "https://api.deepseek.com/v1"),
|
||||
model=cfg.get("model", "deepseek-v4-flash"),
|
||||
base_url=cfg.get("base_url", "https://api.deepseek.com"),
|
||||
api_key=key,
|
||||
temperature=float(cfg.get("temperature", 0.2)),
|
||||
timeout_s=float(cfg.get("timeout_s", 60)),
|
||||
|
||||
@@ -10,7 +10,7 @@ confidence = 1 - exp(-s),保证 s=1 -> 0.63,s=2 -> 0.86,s=3 -> 0.95。
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Dict, List, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from .difficulty import estimate_difficulty
|
||||
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),
|
||||
("仲裁", 0.9), ("劳动法", 1.0), ("刑法", 1.0), ("民法典", 1.0),
|
||||
("法规", 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),
|
||||
("litigation", 1.0), ("copyright", 0.9), ("patent", 0.9), ("trademark", 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), ("手术", 0.9), ("患者", 0.9),
|
||||
("吃药", 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),
|
||||
("diagnosis", 1.0), ("treatment", 0.8), ("prescription", 1.0),
|
||||
("dosage", 0.9), ("side effect", 0.9), ("hypertension", 1.0),
|
||||
("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": [
|
||||
("总结", 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),
|
||||
("推荐", 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 = {
|
||||
@@ -92,11 +140,21 @@ class 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.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]]]:
|
||||
q = query.lower()
|
||||
@@ -159,7 +217,7 @@ class HuggingFaceClassifier(BaseClassifier):
|
||||
仅当安装 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:
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
except ImportError as e:
|
||||
@@ -170,7 +228,8 @@ class HuggingFaceClassifier(BaseClassifier):
|
||||
self.model = AutoModelForSequenceClassification.from_pretrained(
|
||||
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
|
||||
|
||||
def classify(self, query: str) -> Classification:
|
||||
|
||||
@@ -1,100 +1,118 @@
|
||||
"""配置加载:优先 YAML(若安装了 pyyaml),否则回退 JSON。
|
||||
|
||||
设计原则:router_system 核心零依赖,因此 pyyaml 是"可选"的。
|
||||
默认 config/config.yaml 存在;若 pyyaml 不可用,可提供同名 .json。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "config.yaml"
|
||||
|
||||
_DEFAULTS: Dict[str, Any] = {
|
||||
"system": {"name": "multi-expert-router", "version": "0.1.0"},
|
||||
"router": {
|
||||
"low_confidence_threshold": 0.60, # 分类置信度低于此值 -> 直接走大模型
|
||||
"judge_fallback_threshold": 0.70, # Judge 质量分低于此值 -> 升级大模型
|
||||
"default_temperature": 0.2,
|
||||
},
|
||||
"classifier": {"type": "rule", "model": "Qwen/Qwen3-0.6B", "confidence_floor": 0.55},
|
||||
"domains": ["code", "math", "legal", "medical", "general"],
|
||||
"experts": {
|
||||
"code": {"type": "mock", "model": "Qwen/Qwen2.5-Coder-7B-Instruct"},
|
||||
"math": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
|
||||
"legal": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
|
||||
"medical": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
|
||||
"general": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
|
||||
},
|
||||
"fallback": {
|
||||
"type": "mock",
|
||||
"model": "deepseek-chat",
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
},
|
||||
"judge": {"type": "rule", "model": "Qwen/Qwen3-1.7B-Instruct"},
|
||||
"cache": {
|
||||
"enabled": True,
|
||||
"semantic_enabled": True,
|
||||
"similarity_threshold": 0.88,
|
||||
"promote_frequency": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def load_defaults() -> Dict[str, Any]:
|
||||
return _DEFAULTS
|
||||
|
||||
|
||||
def _try_load_yaml(path: Path) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _try_load_json(path: Path) -> Optional[Dict[str, Any]]:
|
||||
json_path = path.with_suffix(".json")
|
||||
if not json_path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _merge_defaults(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""将用户配置与内置默认配置做一层合并(用户优先)。"""
|
||||
merged = dict(_DEFAULTS)
|
||||
for k, v in data.items():
|
||||
if isinstance(v, dict) and isinstance(merged.get(k), dict):
|
||||
merged[k] = {**merged[k], **v}
|
||||
else:
|
||||
merged[k] = v
|
||||
return merged
|
||||
|
||||
|
||||
def load_config(path: Optional[Path | str] = None) -> Dict[str, Any]:
|
||||
"""加载配置,返回 dict。文件不存在或解析失败时返回内置默认配置。"""
|
||||
cfg_path = Path(path) if path else DEFAULT_CONFIG_PATH
|
||||
if cfg_path.exists():
|
||||
data = _try_load_yaml(cfg_path) or _try_load_json(cfg_path)
|
||||
if data is not None:
|
||||
return _merge_defaults(data)
|
||||
return dict(_DEFAULTS)
|
||||
|
||||
|
||||
def get_api_key(cfg: Dict[str, Any]) -> Optional[str]:
|
||||
"""从环境变量读取 API Key(用于 api 类型后端)。"""
|
||||
env_name = cfg.get("api_key_env") or "API_KEY"
|
||||
return os.environ.get(env_name) or None␍
|
||||
"""配置加载:优先 YAML(若安装了 pyyaml),否则回退 JSON。
|
||||
|
||||
设计原则:router_system 核心零依赖,因此 pyyaml 是"可选"的。
|
||||
默认 config/config.yaml 存在;若 pyyaml 不可用,可提供同名 .json。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "config.yaml"
|
||||
|
||||
_DEFAULTS: Dict[str, Any] = {
|
||||
"system": {"name": "multi-expert-router", "version": "0.1.0"},
|
||||
"router": {
|
||||
"low_confidence_threshold": 0.60, # 分类置信度低于此值 -> 直接走最后处理者
|
||||
"judge_fallback_threshold": 0.70, # Judge 质量分低于此值 -> 升级最后处理者
|
||||
"default_temperature": 0.2,
|
||||
},
|
||||
"execution": {
|
||||
"mode": "rule", # rule(L0 零参数)| 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},
|
||||
"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": {
|
||||
"code": {"type": "mock", "model": "Qwen/Qwen2.5-Coder-7B-Instruct"},
|
||||
"math": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
|
||||
"legal": {"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"},
|
||||
},
|
||||
"fallback": {
|
||||
"type": "mock",
|
||||
"model": "deepseek-v4-flash",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
},
|
||||
"judge": {"type": "rule", "model": "Qwen/Qwen3-1.7B-Instruct"},
|
||||
"cache": {
|
||||
"enabled": True,
|
||||
"semantic_enabled": True,
|
||||
"similarity_threshold": 0.88,
|
||||
"promote_frequency": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def load_defaults() -> Dict[str, Any]:
|
||||
return _DEFAULTS
|
||||
|
||||
|
||||
def _try_load_yaml(path: Path) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _try_load_json(path: Path) -> Optional[Dict[str, Any]]:
|
||||
json_path = path.with_suffix(".json")
|
||||
if not json_path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _merge_defaults(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""将用户配置与内置默认配置做一层合并(用户优先)。"""
|
||||
merged = dict(_DEFAULTS)
|
||||
for k, v in data.items():
|
||||
if isinstance(v, dict) and isinstance(merged.get(k), dict):
|
||||
merged[k] = {**merged[k], **v}
|
||||
else:
|
||||
merged[k] = v
|
||||
return merged
|
||||
|
||||
|
||||
def load_config(path: Optional[Path | str] = None) -> Dict[str, Any]:
|
||||
"""加载配置,返回 dict。文件不存在或解析失败时返回内置默认配置。"""
|
||||
cfg_path = Path(path) if path else DEFAULT_CONFIG_PATH
|
||||
if cfg_path.exists():
|
||||
data = _try_load_yaml(cfg_path) or _try_load_json(cfg_path)
|
||||
if data is not None:
|
||||
return _merge_defaults(data)
|
||||
return dict(_DEFAULTS)
|
||||
|
||||
|
||||
def get_api_key(cfg: Dict[str, Any]) -> Optional[str]:
|
||||
"""从环境变量读取 API Key(用于 api 类型后端)。"""
|
||||
env_name = cfg.get("api_key_env") or "API_KEY"
|
||||
return os.environ.get(env_name) or None
|
||||
|
||||
@@ -1,99 +1,185 @@
|
||||
"""大模型回退层:Mock 与 OpenAI 兼容 API 两种后端。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .models import ExpertResponse
|
||||
|
||||
|
||||
class FallbackProvider:
|
||||
name: str = "fallback"
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MockFallback(FallbackProvider):
|
||||
"""确定性 mock 大模型:标识为 fallback,便于测试升级路径。"""
|
||||
|
||||
def __init__(self, model: str = "mock-large"):
|
||||
self.model = model
|
||||
self.name = f"fallback-{model}"
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
await asyncio.sleep(0.002)
|
||||
body = (
|
||||
f"(大模型回退)「{query}」\n\n"
|
||||
"这是一条来自大模型回退路径的完整回答。\n"
|
||||
"要点:\n"
|
||||
"1. 对复杂/跨域任务给出综合推理\n"
|
||||
"2. 补充领域专家未覆盖的上下文\n"
|
||||
"3. 给出可执行的后续建议\n"
|
||||
)
|
||||
return ExpertResponse(
|
||||
text=body,
|
||||
model_used=self.model,
|
||||
latency_ms=2.0,
|
||||
tokens=120,
|
||||
cost_est=2.0 * 120 / 1_000_000,
|
||||
)
|
||||
|
||||
|
||||
class APIFallback(FallbackProvider):
|
||||
"""OpenAI 兼容大模型 API(如 DeepSeek / OpenAI / 本地 vLLM)。"""
|
||||
|
||||
def __init__(self, model: str, base_url: str, api_key: str):
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.name = f"fallback-{model}"
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
import httpx
|
||||
self._client = httpx.AsyncClient(timeout=90.0)
|
||||
return self._client
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
client = self._get_client()
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
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=2.0 * tokens / 1_000_000,
|
||||
)
|
||||
|
||||
|
||||
def build_fallback(cfg: Dict) -> FallbackProvider:
|
||||
"""cfg 为 fallback 段配置。"""
|
||||
ftype = cfg.get("type", "mock")
|
||||
model = cfg.get("model", "deepseek-chat")
|
||||
if ftype == "mock":
|
||||
return MockFallback(model=model)
|
||||
if ftype == "api":
|
||||
import os
|
||||
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
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)
|
||||
raise ValueError(f"未知 fallback 类型: {ftype}(支持 mock | api)")␍
|
||||
"""大模型回退层(最后处理者):Mock / 降级模板 / 本地模型 / OpenAI 兼容 API。
|
||||
|
||||
- NoneFallback :降级模板(零参数,明确告知超出知识库范围)—— L0 最小可用
|
||||
- MockFallback :确定性 mock 大模型(零参数,测试升级路径用)
|
||||
- LocalFallback :本地小模型(≤8B,Q4 量化,OpenAI 兼容端点如 Ollama/vLLM)—— 按需加载
|
||||
- APIFallback :远程 OpenAI 兼容 API(可选,默认关闭)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .models import ExpertResponse
|
||||
|
||||
|
||||
class FallbackProvider:
|
||||
name: str = "fallback"
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
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):
|
||||
"""确定性 mock 大模型:标识为 fallback,便于测试升级路径。"""
|
||||
|
||||
def __init__(self, model: str = "mock-large"):
|
||||
self.model = model
|
||||
self.name = f"fallback-{model}"
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
await asyncio.sleep(0.002)
|
||||
body = (
|
||||
f"(大模型回退)「{query}」\n\n"
|
||||
"这是一条来自大模型回退路径的完整回答。\n"
|
||||
"要点:\n"
|
||||
"1. 对复杂/跨域任务给出综合推理\n"
|
||||
"2. 补充领域专家未覆盖的上下文\n"
|
||||
"3. 给出可执行的后续建议\n"
|
||||
)
|
||||
return ExpertResponse(
|
||||
text=body,
|
||||
model_used=self.model,
|
||||
latency_ms=2.0,
|
||||
tokens=120,
|
||||
cost_est=2.0 * 120 / 1_000_000,
|
||||
)
|
||||
|
||||
|
||||
class LocalFallback(FallbackProvider):
|
||||
"""本地小模型最后处理者(≤8B,如 DeepSeek-R1-Distill-Qwen-7B Q4)。
|
||||
|
||||
通过 OpenAI 兼容端点调用(Ollama 默认 11434/v1,vLLM 默认 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):
|
||||
"""OpenAI 兼容大模型 API(如 DeepSeek / OpenAI / 本地 vLLM)。"""
|
||||
|
||||
def __init__(self, model: str, base_url: str, api_key: str):
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.name = f"fallback-{model}"
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
import httpx
|
||||
self._client = httpx.AsyncClient(timeout=90.0)
|
||||
return self._client
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
client = self._get_client()
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
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=2.0 * tokens / 1_000_000,
|
||||
)
|
||||
|
||||
|
||||
def build_fallback(cfg: Dict) -> FallbackProvider:
|
||||
"""cfg 为 fallback 段配置。"""
|
||||
ftype = cfg.get("type", "mock")
|
||||
model = cfg.get("model", "deepseek-v4-flash")
|
||||
if ftype == "none":
|
||||
return NoneFallback(model=model)
|
||||
if ftype == "mock":
|
||||
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":
|
||||
import os
|
||||
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
f"APIFallback 缺少 API Key:请设置环境变量 {cfg.get('api_key_env')} 或配置 api_key"
|
||||
)
|
||||
return APIFallback(model, cfg.get("base_url", "https://api.deepseek.com"), api_key)
|
||||
raise ValueError(f"未知 fallback 类型: {ftype}(支持 none | mock | local | api)")
|
||||
|
||||
@@ -1,174 +1,193 @@
|
||||
"""质量控制器(Judge):评估专家输出,决定是否升级大模型。
|
||||
|
||||
- RuleJudge:零依赖启发式(内容覆盖度 / 长度充分性 / 领域格式 / 安全提示),
|
||||
稳定可测,适合 MVP 与离线演示。
|
||||
- LLMJudge:可选,基于 transformers 小模型或 API 的 LLM-as-Judge。
|
||||
|
||||
设计对齐实现方案:overall_score < judge_fallback_threshold -> 升级大模型。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
|
||||
from .experts import extract_content_terms
|
||||
|
||||
# 各领域期望的响应长度范围(字符数)
|
||||
_EXPECTED_LEN = {
|
||||
"code": (60, 2000),
|
||||
"math": (60, 2000),
|
||||
"legal": (80, 3000),
|
||||
"medical": (80, 3000),
|
||||
"general": (40, 2000),
|
||||
}
|
||||
|
||||
# 领域格式检查:响应应包含的标记
|
||||
_DOMAIN_FORMAT_HINTS = {
|
||||
"code": ["```", "def ", "function", "class "],
|
||||
"math": ["步骤", "推导", "=", "解", "step"],
|
||||
"legal": ["⚠", "法律", "意见", "合规", "contract", "law"],
|
||||
"medical": ["⚠", "就医", "医生", "症状", "诊断", "symptom"],
|
||||
"general": [],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityEvaluation:
|
||||
overall_score: float
|
||||
scores: Dict[str, float] = field(default_factory=dict)
|
||||
needs_fallback: bool = False
|
||||
reasons: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"overall_score": round(self.overall_score, 4),
|
||||
"scores": {k: round(v, 4) for k, v in self.scores.items()},
|
||||
"needs_fallback": self.needs_fallback,
|
||||
"reasons": self.reasons,
|
||||
}
|
||||
|
||||
|
||||
class BaseJudge:
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class RuleJudge(BaseJudge):
|
||||
"""启发式质量评估(零依赖)。"""
|
||||
|
||||
def __init__(self, fallback_threshold: float = 0.70):
|
||||
self.fallback_threshold = fallback_threshold
|
||||
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
scores: Dict[str, float] = {}
|
||||
reasons: List[str] = []
|
||||
|
||||
# 1) 内容覆盖度:查询中的内容词有多少出现在响应里
|
||||
terms = extract_content_terms(query)
|
||||
if terms:
|
||||
hit = sum(1 for t in terms if t in response.lower())
|
||||
coverage = hit / len(terms)
|
||||
scores["coverage"] = coverage
|
||||
if coverage < 0.4:
|
||||
reasons.append(f"内容覆盖度低 ({coverage:.0%})")
|
||||
else:
|
||||
scores["coverage"] = 1.0
|
||||
|
||||
# 2) 长度充分性
|
||||
lo, hi = _EXPECTED_LEN.get(domain, (40, 2000))
|
||||
n = len(response)
|
||||
if n < lo:
|
||||
scores["length"] = max(0.0, n / lo)
|
||||
reasons.append(f"响应过短 ({n} 字符)")
|
||||
elif n > hi:
|
||||
scores["length"] = 0.8
|
||||
reasons.append(f"响应过长 ({n} 字符)")
|
||||
else:
|
||||
scores["length"] = 1.0
|
||||
|
||||
# 3) 领域格式检查
|
||||
hints = _DOMAIN_FORMAT_HINTS.get(domain, [])
|
||||
if hints:
|
||||
hit_hints = sum(1 for h in hints if h in response)
|
||||
scores["format"] = min(1.0, 0.4 + 0.2 * hit_hints)
|
||||
if hit_hints == 0:
|
||||
reasons.append("缺少领域格式特征")
|
||||
else:
|
||||
scores["format"] = 1.0
|
||||
|
||||
# 4) 安全/免责提示(法律、医疗领域应有警示语)
|
||||
if domain in ("legal", "medical") and ("⚠" not in response and "提示" not in response):
|
||||
scores["safety"] = 0.6
|
||||
reasons.append("缺少免责提示")
|
||||
else:
|
||||
scores["safety"] = 1.0
|
||||
|
||||
weights = {"coverage": 0.4, "length": 0.2, "format": 0.2, "safety": 0.2}
|
||||
overall = sum(scores.get(k, 0.0) * w for k, w in weights.items())
|
||||
needs = overall < self.fallback_threshold
|
||||
if needs:
|
||||
reasons.append("质量分低于阈值,建议升级大模型")
|
||||
return QualityEvaluation(
|
||||
overall_score=round(overall, 4),
|
||||
scores=scores,
|
||||
needs_fallback=needs,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
|
||||
class LLMJudge(BaseJudge):
|
||||
"""可选:LLM-as-Judge(API 后端)。"""
|
||||
|
||||
def __init__(self, model: str, base_url: str, api_key: str, fallback_threshold: float = 0.70):
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.fallback_threshold = fallback_threshold
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
import httpx
|
||||
self._client = httpx.AsyncClient(timeout=60.0)
|
||||
return self._client
|
||||
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
client = self._get_client()
|
||||
prompt = (
|
||||
f"你是质量评审员。评估以下回答对查询的满足程度,输出 0-1 分(相关性/正确性/完整性)。\n"
|
||||
f"查询: {query}\n领域: {domain}\n回答: {response[:2000]}\n"
|
||||
f"只输出一个 0 到 1 之间的数字。"
|
||||
)
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={"model": self.model, "messages": [{"role": "user", "content": prompt}]},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
score = float(resp.json()["choices"][0]["message"]["content"].strip())
|
||||
score = max(0.0, min(1.0, score))
|
||||
except Exception:
|
||||
score = 0.5
|
||||
return QualityEvaluation(
|
||||
overall_score=score,
|
||||
scores={"llm_judge": score},
|
||||
needs_fallback=score < self.fallback_threshold,
|
||||
)
|
||||
|
||||
|
||||
def build_judge(cfg: Dict, fallback_threshold: float = 0.70) -> BaseJudge:
|
||||
"""cfg 为 judge 段配置。"""
|
||||
jtype = cfg.get("type", "rule")
|
||||
if jtype == "rule":
|
||||
return RuleJudge(fallback_threshold=fallback_threshold)
|
||||
if jtype == "llm":
|
||||
import os
|
||||
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
|
||||
return LLMJudge(
|
||||
cfg.get("model", "deepseek-chat"),
|
||||
cfg.get("base_url", "https://api.deepseek.com/v1"),
|
||||
api_key or "",
|
||||
fallback_threshold=fallback_threshold,
|
||||
)
|
||||
raise ValueError(f"未知 judge 类型: {jtype}(支持 rule | llm)")␍
|
||||
"""质量控制器(Judge):评估专家输出,决定是否升级大模型。
|
||||
|
||||
- RuleJudge:零依赖启发式(内容覆盖度 / 长度充分性 / 领域格式 / 安全提示),
|
||||
稳定可测,适合 MVP 与离线演示。
|
||||
- LLMJudge:可选,基于 transformers 小模型或 API 的 LLM-as-Judge。
|
||||
|
||||
设计对齐实现方案:overall_score < judge_fallback_threshold -> 升级大模型。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
|
||||
from .experts import extract_content_terms
|
||||
|
||||
# 各领域期望的响应长度范围(字符数)
|
||||
_EXPECTED_LEN = {
|
||||
"code": (60, 2000),
|
||||
"math": (60, 2000),
|
||||
"legal": (80, 3000),
|
||||
"medical": (80, 3000),
|
||||
"general": (40, 2000),
|
||||
}
|
||||
|
||||
# 领域格式检查:响应应包含的标记
|
||||
_DOMAIN_FORMAT_HINTS = {
|
||||
"code": ["```", "def ", "function", "class "],
|
||||
"math": ["步骤", "推导", "=", "解", "step"],
|
||||
"legal": ["⚠", "法律", "意见", "合规", "contract", "law"],
|
||||
"medical": ["⚠", "就医", "医生", "症状", "诊断", "symptom"],
|
||||
"general": [],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityEvaluation:
|
||||
overall_score: float
|
||||
scores: Dict[str, float] = field(default_factory=dict)
|
||||
needs_fallback: bool = False
|
||||
reasons: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"overall_score": round(self.overall_score, 4),
|
||||
"scores": {k: round(v, 4) for k, v in self.scores.items()},
|
||||
"needs_fallback": self.needs_fallback,
|
||||
"reasons": self.reasons,
|
||||
}
|
||||
|
||||
|
||||
class BaseJudge:
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class RuleJudge(BaseJudge):
|
||||
"""启发式质量评估(零依赖)。"""
|
||||
|
||||
def __init__(self, fallback_threshold: float = 0.70, kb=None):
|
||||
self.fallback_threshold = fallback_threshold
|
||||
self.kb = kb # 可选知识库:facts 维度校验用
|
||||
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
scores: Dict[str, float] = {}
|
||||
reasons: List[str] = []
|
||||
|
||||
# 1) 内容覆盖度:查询中的内容词有多少出现在响应里
|
||||
terms = extract_content_terms(query)
|
||||
if terms:
|
||||
hit = sum(1 for t in terms if t in response.lower())
|
||||
coverage = hit / len(terms)
|
||||
scores["coverage"] = coverage
|
||||
if coverage < 0.4:
|
||||
reasons.append(f"内容覆盖度低 ({coverage:.0%})")
|
||||
else:
|
||||
scores["coverage"] = 1.0
|
||||
|
||||
# 2) 长度充分性
|
||||
lo, hi = _EXPECTED_LEN.get(domain, (40, 2000))
|
||||
n = len(response)
|
||||
if n < lo:
|
||||
scores["length"] = max(0.0, n / lo)
|
||||
reasons.append(f"响应过短 ({n} 字符)")
|
||||
elif n > hi:
|
||||
scores["length"] = 0.8
|
||||
reasons.append(f"响应过长 ({n} 字符)")
|
||||
else:
|
||||
scores["length"] = 1.0
|
||||
|
||||
# 3) 领域格式检查
|
||||
hints = _DOMAIN_FORMAT_HINTS.get(domain, [])
|
||||
if hints:
|
||||
hit_hints = sum(1 for h in hints if h in response)
|
||||
scores["format"] = min(1.0, 0.4 + 0.2 * hit_hints)
|
||||
if hit_hints == 0:
|
||||
reasons.append("缺少领域格式特征")
|
||||
else:
|
||||
scores["format"] = 1.0
|
||||
|
||||
# 4) 安全/免责提示(法律、医疗领域应有警示语)
|
||||
if domain in ("legal", "medical") and ("⚠" not in response and "提示" not in response):
|
||||
scores["safety"] = 0.6
|
||||
reasons.append("缺少免责提示")
|
||||
else:
|
||||
scores["safety"] = 1.0
|
||||
|
||||
# 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())
|
||||
needs = overall < self.fallback_threshold
|
||||
if needs:
|
||||
reasons.append("质量分低于阈值,建议升级大模型")
|
||||
return QualityEvaluation(
|
||||
overall_score=round(overall, 4),
|
||||
scores=scores,
|
||||
needs_fallback=needs,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
|
||||
class LLMJudge(BaseJudge):
|
||||
"""可选:LLM-as-Judge(API 后端)。"""
|
||||
|
||||
def __init__(self, model: str, base_url: str, api_key: str, fallback_threshold: float = 0.70):
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.fallback_threshold = fallback_threshold
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
import httpx
|
||||
self._client = httpx.AsyncClient(timeout=60.0)
|
||||
return self._client
|
||||
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
client = self._get_client()
|
||||
prompt = (
|
||||
f"你是质量评审员。评估以下回答对查询的满足程度,输出 0-1 分(相关性/正确性/完整性)。\n"
|
||||
f"查询: {query}\n领域: {domain}\n回答: {response[:2000]}\n"
|
||||
f"只输出一个 0 到 1 之间的数字。"
|
||||
)
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={"model": self.model, "messages": [{"role": "user", "content": prompt}]},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
score = float(resp.json()["choices"][0]["message"]["content"].strip())
|
||||
score = max(0.0, min(1.0, score))
|
||||
except Exception:
|
||||
score = 0.5
|
||||
return QualityEvaluation(
|
||||
overall_score=score,
|
||||
scores={"llm_judge": score},
|
||||
needs_fallback=score < self.fallback_threshold,
|
||||
)
|
||||
|
||||
|
||||
def build_judge(cfg: Dict, fallback_threshold: float = 0.70, kb=None) -> BaseJudge:
|
||||
"""cfg 为 judge 段配置。"""
|
||||
jtype = cfg.get("type", "rule")
|
||||
if jtype == "rule":
|
||||
return RuleJudge(fallback_threshold=fallback_threshold, kb=kb)
|
||||
if jtype == "llm":
|
||||
import os
|
||||
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
|
||||
return LLMJudge(
|
||||
cfg.get("model", "deepseek-v4-flash"),
|
||||
cfg.get("base_url", "https://api.deepseek.com"),
|
||||
api_key or "",
|
||||
fallback_threshold=fallback_threshold,
|
||||
)
|
||||
raise ValueError(f"未知 judge 类型: {jtype}(支持 rule | llm)")
|
||||
|
||||
@@ -1,68 +1,76 @@
|
||||
"""核心数据模型(纯标准库,无外部依赖)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Classification:
|
||||
"""分类器输出:领域 + 置信度 + 难度"""
|
||||
domain: str
|
||||
confidence: float
|
||||
difficulty: str # easy | medium | hard
|
||||
difficulty_score: float = 0.5
|
||||
raw_scores: Dict[str, float] = field(default_factory=dict)
|
||||
matched_rules: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpertResponse:
|
||||
"""专家模型输出"""
|
||||
text: str
|
||||
model_used: str
|
||||
latency_ms: float = 0.0
|
||||
tokens: int = 0
|
||||
cost_est: float = 0.0 # 相对成本估计(美元,近似)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouterResult:
|
||||
"""一次路由的完整结果"""
|
||||
query: str
|
||||
response: str
|
||||
domain: str
|
||||
difficulty: str
|
||||
confidence: float
|
||||
upgraded: bool # 是否升级到大模型
|
||||
quality_score: float
|
||||
model_used: str
|
||||
route: List[str] = field(default_factory=list) # 路由决策轨迹
|
||||
latency_ms: float = 0.0
|
||||
cache_hit: bool = False
|
||||
cache_level: Optional[str] = None # exact | semantic
|
||||
cost_est: float = 0.0
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"query": self.query,
|
||||
"response": self.response,
|
||||
"domain": self.domain,
|
||||
"difficulty": self.difficulty,
|
||||
"confidence": round(self.confidence, 4),
|
||||
"upgraded": self.upgraded,
|
||||
"quality_score": round(self.quality_score, 4),
|
||||
"model_used": self.model_used,
|
||||
"route": self.route,
|
||||
"latency_ms": round(self.latency_ms, 2),
|
||||
"cache_hit": self.cache_hit,
|
||||
"cache_level": self.cache_level,
|
||||
"cost_est": round(self.cost_est, 6),
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
def now_ms() -> float:
|
||||
return time.perf_counter() * 1000.0␍
|
||||
"""核心数据模型(纯标准库,无外部依赖)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Classification:
|
||||
"""分类器输出:领域 + 置信度 + 难度"""
|
||||
domain: str
|
||||
confidence: float
|
||||
difficulty: str # easy | medium | hard
|
||||
difficulty_score: float = 0.5
|
||||
raw_scores: Dict[str, float] = field(default_factory=dict)
|
||||
matched_rules: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpertResponse:
|
||||
"""专家模型输出"""
|
||||
text: str
|
||||
model_used: str
|
||||
latency_ms: float = 0.0
|
||||
tokens: int = 0
|
||||
cost_est: float = 0.0 # 相对成本估计(美元,近似)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouterResult:
|
||||
"""一次路由的完整结果"""
|
||||
query: str
|
||||
response: str
|
||||
domain: str
|
||||
difficulty: str
|
||||
confidence: float
|
||||
upgraded: bool # 是否升级到大模型
|
||||
quality_score: float
|
||||
model_used: str
|
||||
route: List[str] = field(default_factory=list) # 路由决策轨迹
|
||||
latency_ms: float = 0.0
|
||||
cache_hit: bool = False
|
||||
cache_level: Optional[str] = None # exact | semantic
|
||||
cost_est: float = 0.0
|
||||
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]:
|
||||
return {
|
||||
"query": self.query,
|
||||
"response": self.response,
|
||||
"domain": self.domain,
|
||||
"difficulty": self.difficulty,
|
||||
"confidence": round(self.confidence, 4),
|
||||
"upgraded": self.upgraded,
|
||||
"quality_score": round(self.quality_score, 4),
|
||||
"model_used": self.model_used,
|
||||
"route": self.route,
|
||||
"latency_ms": round(self.latency_ms, 2),
|
||||
"cache_hit": self.cache_hit,
|
||||
"cache_level": self.cache_level,
|
||||
"cost_est": round(self.cost_est, 6),
|
||||
"error": self.error,
|
||||
"subdomain": self.subdomain,
|
||||
"subdomain2": self.subdomain2,
|
||||
"domain_group": self.domain_group,
|
||||
"request_id": self.request_id,
|
||||
}
|
||||
|
||||
|
||||
def now_ms() -> float:
|
||||
return time.perf_counter() * 1000.0
|
||||
|
||||
@@ -68,8 +68,9 @@ class CollaborativePipeline:
|
||||
# ---------------------------------------------------------------
|
||||
# 入口
|
||||
# ---------------------------------------------------------------
|
||||
async def run(self, query: str) -> PipelineResult:
|
||||
request_id = uuid.uuid4().hex[:12]
|
||||
async def run(self, query: str, request_id: Optional[str] = None) -> PipelineResult:
|
||||
if request_id is None:
|
||||
request_id = uuid.uuid4().hex[:12]
|
||||
ws = Workspace.new(request_id, query, self.api_token_cap, self.rounds_cap)
|
||||
route: List[str] = ["v2"]
|
||||
t0 = time.perf_counter() * 1000.0
|
||||
@@ -100,11 +101,15 @@ class CollaborativePipeline:
|
||||
route.append("loop")
|
||||
plan = brief.get("plan") or []
|
||||
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():
|
||||
progressed = False
|
||||
for sid in list(pending):
|
||||
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
|
||||
existing = self._read_artifact(request_id, self._artifact_name(sid, ws))
|
||||
outcome = await self.worker.run_step(ws, sid, existing_artifact=existing,
|
||||
@@ -115,6 +120,8 @@ class CollaborativePipeline:
|
||||
ws.rollup()
|
||||
route.append(f"step:{sid}:done")
|
||||
progressed = True
|
||||
if not pending: # 所有步骤完成,退出循环
|
||||
break
|
||||
else: # issue -> Architect 裁决
|
||||
route.append(f"step:{sid}:issue")
|
||||
try:
|
||||
@@ -227,7 +234,17 @@ class CollaborativePipeline:
|
||||
return artifact_name_for(sid, domain)
|
||||
|
||||
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"}
|
||||
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)
|
||||
|
||||
def _last_decision_for(self, ws: Workspace, sid: str) -> str:
|
||||
|
||||
@@ -1,217 +1,415 @@
|
||||
"""主路由器:协调 缓存 -> 分类 -> 专家 -> Judge -> 大模型回退 的完整链路。
|
||||
|
||||
流程(对齐实现方案):
|
||||
1. 检查缓存(L1 精确 / L2 语义)
|
||||
2. 低置信度查询直接走大模型(should_fallback)
|
||||
3. 分类器输出领域 + 难度
|
||||
4. 选择专家模型生成
|
||||
5. Judge 评估质量
|
||||
6. 质量不达标 -> 升级大模型
|
||||
7. 记录指标、写缓存、返回结果
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .cache import RouterCache
|
||||
from .classifier import BaseClassifier, build_classifier
|
||||
from .config import load_config
|
||||
from .experts import Expert, build_expert_pool
|
||||
from .fallback import FallbackProvider, build_fallback
|
||||
from .judge import BaseJudge, build_judge
|
||||
from .models import Classification, ExpertResponse, RouterResult, now_ms
|
||||
from .stats import Stats
|
||||
|
||||
|
||||
class Router:
|
||||
def __init__(
|
||||
self,
|
||||
classifier: BaseClassifier,
|
||||
experts: Dict[str, Expert],
|
||||
judge: BaseJudge,
|
||||
fallback: FallbackProvider,
|
||||
cache: Optional[RouterCache] = None,
|
||||
stats: Optional[Stats] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
self.classifier = classifier
|
||||
self.experts = experts
|
||||
self.judge = judge
|
||||
self.fallback = fallback
|
||||
self.cache = cache or RouterCache()
|
||||
self.stats = stats or Stats()
|
||||
cfg = config or {}
|
||||
rcfg = cfg.get("router", {})
|
||||
self.low_confidence_threshold = rcfg.get("low_confidence_threshold", 0.60)
|
||||
self.judge_fallback_threshold = rcfg.get("judge_fallback_threshold", 0.70)
|
||||
self.cache_enabled = cfg.get("cache", {}).get("enabled", True)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
async def route(self, query: str) -> RouterResult:
|
||||
start = now_ms()
|
||||
route: list = []
|
||||
|
||||
# ---- Step 1: 缓存 ----
|
||||
if self.cache_enabled:
|
||||
hit = self.cache.get(query)
|
||||
if hit is not None:
|
||||
level, cached = hit
|
||||
latency = now_ms() - start
|
||||
result = RouterResult(
|
||||
query=query,
|
||||
response=cached.get("response", ""),
|
||||
domain=cached.get("domain", "general"),
|
||||
difficulty=cached.get("difficulty", "medium"),
|
||||
confidence=cached.get("confidence", 0.0),
|
||||
upgraded=False,
|
||||
quality_score=cached.get("quality_score", 0.0),
|
||||
model_used=cached.get("model_used", ""),
|
||||
route=["cache:" + level],
|
||||
latency_ms=latency,
|
||||
cache_hit=True,
|
||||
cache_level=level,
|
||||
cost_est=0.0,
|
||||
)
|
||||
self.stats.record(latency, result.domain, result.difficulty, False, True, level, 0.0, result.model_used)
|
||||
return result
|
||||
route.append("cache:miss")
|
||||
|
||||
# ---- Step 2: 分类 ----
|
||||
classification = self.classifier.classify(query)
|
||||
route.append(f"classify:{classification.domain}@{classification.confidence:.2f}/{classification.difficulty}")
|
||||
|
||||
# 低置信度 -> 直接走大模型
|
||||
if self.classifier.should_fallback(classification, self.low_confidence_threshold):
|
||||
route.append("direct_fallback")
|
||||
fb = await self._call_fallback(query)
|
||||
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,
|
||||
upgraded=True, route=route, latency_ms=latency,
|
||||
model_used=fb.model_used, cost_est=fb.cost_est,
|
||||
error=str(e))
|
||||
self._record(result, latency)
|
||||
return result
|
||||
|
||||
# ---- Step 5: Judge 评估 ----
|
||||
try:
|
||||
evaluation = await self.judge.evaluate(query, expert_resp.text, domain)
|
||||
except Exception:
|
||||
evaluation = None
|
||||
route.append("judge_error")
|
||||
|
||||
quality_score = evaluation.overall_score if evaluation else 0.0
|
||||
route.append(f"judge:{quality_score:.2f}")
|
||||
|
||||
upgraded = False
|
||||
final_resp = expert_resp
|
||||
if evaluation is not None and evaluation.needs_fallback:
|
||||
route.append("upgrade")
|
||||
final_resp = await self._call_fallback(query)
|
||||
upgraded = True
|
||||
|
||||
latency = now_ms() - start
|
||||
result = self._finalize(query, classification, final_resp, quality_score=quality_score,
|
||||
upgraded=upgraded, route=route, latency_ms=latency,
|
||||
model_used=final_resp.model_used, cost_est=final_resp.cost_est)
|
||||
self._record(result, latency)
|
||||
|
||||
# 未升级的结果写缓存
|
||||
if self.cache_enabled and not upgraded and result.response:
|
||||
self.cache.put(query, result.to_dict())
|
||||
|
||||
return result
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
async def _call_fallback(self, query: str) -> ExpertResponse:
|
||||
try:
|
||||
return await self.fallback.generate(query)
|
||||
except Exception as e:
|
||||
# 回退也失败:返回错误占位响应
|
||||
return ExpertResponse(
|
||||
text=f"[系统错误] 专家与大模型回退均失败:{type(e).__name__}: {e}",
|
||||
model_used=f"error:{self.fallback.name}",
|
||||
cost_est=0.0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _finalize(query: str, classification: Classification, resp: ExpertResponse,
|
||||
quality_score: float, upgraded: bool, route: list,
|
||||
latency_ms: float, model_used: str, cost_est: float,
|
||||
error: Optional[str] = None) -> RouterResult:
|
||||
return RouterResult(
|
||||
query=query,
|
||||
response=resp.text,
|
||||
domain=classification.domain,
|
||||
difficulty=classification.difficulty,
|
||||
confidence=classification.confidence,
|
||||
upgraded=upgraded,
|
||||
quality_score=quality_score,
|
||||
model_used=model_used,
|
||||
route=route,
|
||||
latency_ms=latency_ms,
|
||||
cache_hit=False,
|
||||
cost_est=cost_est,
|
||||
error=error,
|
||||
)
|
||||
|
||||
def _record(self, result: RouterResult, latency_ms: float):
|
||||
self.stats.record(
|
||||
latency_ms,
|
||||
result.domain,
|
||||
result.difficulty,
|
||||
result.upgraded,
|
||||
result.cache_hit,
|
||||
result.cache_level,
|
||||
result.cost_est,
|
||||
result.model_used,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
def health(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"domains": list(self.experts.keys()),
|
||||
"classifier": type(self.classifier).__name__,
|
||||
"judge": type(self.judge).__name__,
|
||||
"fallback": type(self.fallback).__name__,
|
||||
}
|
||||
|
||||
|
||||
def build_router(config_path: Optional[str] = None) -> Router:
|
||||
"""从配置构建完整 Router(默认 mock 全链路,零依赖可跑)。"""
|
||||
config = load_config(config_path)
|
||||
classifier = build_classifier(config.get("classifier", {}))
|
||||
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))
|
||||
fallback = build_fallback(config.get("fallback", {}))
|
||||
cache_cfg = config.get("cache", {})
|
||||
cache = RouterCache(
|
||||
semantic_enabled=cache_cfg.get("semantic_enabled", True),
|
||||
similarity_threshold=cache_cfg.get("similarity_threshold", 0.88),
|
||||
promote_frequency=cache_cfg.get("promote_frequency", 5),
|
||||
)
|
||||
stats = Stats()
|
||||
return Router(classifier, experts, judge, fallback, cache, stats, config)␍
|
||||
"""主路由器:两级路由(大领域组 → 组内路由模型 → 专业执行器)专家系统编排。
|
||||
|
||||
两级体系(对齐用户架构决策):
|
||||
第一级:用户通过接口指定大领域组(domain_group: tech/professional/lifestyle/general),
|
||||
或系统自动检测(8 领域分类 → 映射到组)
|
||||
第二级:组内路由模型(RuleClassifier(domains=组内领域) + 组内知识/模板)识别具体
|
||||
领域、子领域、拆解子任务 → 组内专业小模型/规则执行器
|
||||
组内路由模型只认识本组领域:体积与匹配开销约为统一路由模型的 1/4,
|
||||
且未来 L2 模型层可每组一个更小的路由模型,按需加载不常驻。
|
||||
|
||||
链路:缓存 → 组路由(分类/子领域/拆解) → 黑板+前向链 → DAG 执行 → 合并
|
||||
→ Judge 校验 → (不达标)最后处理者升级 → 缓存/指标
|
||||
|
||||
L0 模式(默认):规则分类 + 规则拆解 + 规则执行器 + 规则 Judge —— 零模型参数、零 API。
|
||||
L2 模式(可选):execution.expert_backend = hf/api 时,子任务改由专家池小模型执行
|
||||
(≤8B,按需加载),其余流程不变。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .cache import RouterCache
|
||||
from .classifier import BaseClassifier, RuleClassifier, build_classifier
|
||||
from .config import load_config
|
||||
from .executors import NodeExecutor, build_node_executor
|
||||
from .experts import Expert, build_expert_pool
|
||||
from .fallback import FallbackProvider, build_fallback
|
||||
from .inference import InferenceEngine
|
||||
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 .planner import Planner
|
||||
from .stats import Stats
|
||||
from .trace import TraceStore
|
||||
|
||||
|
||||
class Router:
|
||||
def __init__(
|
||||
self,
|
||||
classifier: BaseClassifier,
|
||||
experts: Dict[str, Expert],
|
||||
judge: BaseJudge,
|
||||
fallback: FallbackProvider,
|
||||
cache: Optional[RouterCache] = None,
|
||||
stats: Optional[Stats] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
kb: Optional[KnowledgeBase] = None,
|
||||
planner: Optional[Planner] = None,
|
||||
):
|
||||
self.classifier = classifier
|
||||
self.experts = experts
|
||||
self.judge = judge
|
||||
self.fallback = fallback
|
||||
self.cache = cache or RouterCache()
|
||||
self.stats = stats or Stats()
|
||||
cfg = config or {}
|
||||
rcfg = cfg.get("router", {})
|
||||
self.low_confidence_threshold = rcfg.get("low_confidence_threshold", 0.60)
|
||||
self.judge_fallback_threshold = rcfg.get("judge_fallback_threshold", 0.70)
|
||||
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, domain_group: Optional[str] = None) -> RouterResult:
|
||||
"""两级路由入口。
|
||||
|
||||
domain_group 指定时:跳过 8 领域统一分类器,直接用组内路由模型
|
||||
(RuleClassifier(domains=组内领域))识别组内领域 —— 更小更专。
|
||||
未指定时:统一分类器识别领域 → 自动映射到大领域组(向后兼容)。
|
||||
"""
|
||||
start = now_ms()
|
||||
route: list = []
|
||||
request_id = uuid.uuid4().hex[:12]
|
||||
|
||||
# ---- Step 1: 缓存 ----
|
||||
if self.cache_enabled:
|
||||
hit = self.cache.get(query)
|
||||
if hit is not None:
|
||||
level, cached = hit
|
||||
latency = now_ms() - start
|
||||
result = RouterResult(
|
||||
query=query,
|
||||
response=cached.get("response", ""),
|
||||
domain=cached.get("domain", "general"),
|
||||
difficulty=cached.get("difficulty", "medium"),
|
||||
confidence=cached.get("confidence", 0.0),
|
||||
upgraded=False,
|
||||
quality_score=cached.get("quality_score", 0.0),
|
||||
model_used=cached.get("model_used", ""),
|
||||
route=["cache:" + level],
|
||||
latency_ms=latency,
|
||||
cache_hit=True,
|
||||
cache_level=level,
|
||||
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)
|
||||
return result
|
||||
route.append("cache:miss")
|
||||
|
||||
# ---- Step 2: 组路由(两级第一级)→ 组内分类(两级第二级) ----
|
||||
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}")
|
||||
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 classifier.should_fallback(classification, self.low_confidence_threshold):
|
||||
route.append("direct_fallback")
|
||||
fb = await self._call_fallback(query)
|
||||
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,
|
||||
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)
|
||||
return result
|
||||
|
||||
# ---- 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:
|
||||
evaluation = await self.judge.evaluate(query, response, classification.domain)
|
||||
except Exception:
|
||||
evaluation = None
|
||||
route.append("judge_error")
|
||||
quality_score = evaluation.overall_score if evaluation else 0.0
|
||||
route.append(f"judge:{quality_score:.2f}")
|
||||
|
||||
upgraded = False
|
||||
if evaluation is not None and evaluation.needs_fallback:
|
||||
route.append("upgrade")
|
||||
fb = await self._call_fallback(query)
|
||||
response = fb.text
|
||||
last_model = fb.model_used
|
||||
upgraded = True
|
||||
|
||||
latency = now_ms() - start
|
||||
result = self._finalize(query, classification, ExpertResponse(
|
||||
text=response, model_used=last_model, latency_ms=latency,
|
||||
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)
|
||||
|
||||
# 未升级的结果写缓存
|
||||
if self.cache_enabled and not upgraded and result.response:
|
||||
self.cache.put(query, result.to_dict())
|
||||
|
||||
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:
|
||||
try:
|
||||
return await self.fallback.generate(query)
|
||||
except Exception as e:
|
||||
# 回退也失败:返回错误占位响应
|
||||
return ExpertResponse(
|
||||
text=f"[系统错误] 专家与最后处理者均失败:{type(e).__name__}: {e}",
|
||||
model_used=f"error:{self.fallback.name}",
|
||||
cost_est=0.0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _finalize(query: str, classification: Classification, resp: ExpertResponse,
|
||||
quality_score: float, upgraded: bool, route: list,
|
||||
latency_ms: float, model_used: str, cost_est: float,
|
||||
error: Optional[str] = None,
|
||||
subdomain: Optional[str] = None,
|
||||
subdomain2: Optional[str] = None,
|
||||
domain_group: Optional[str] = None) -> RouterResult:
|
||||
return RouterResult(
|
||||
query=query,
|
||||
response=resp.text,
|
||||
domain=classification.domain,
|
||||
difficulty=classification.difficulty,
|
||||
confidence=classification.confidence,
|
||||
upgraded=upgraded,
|
||||
quality_score=quality_score,
|
||||
model_used=model_used,
|
||||
route=route,
|
||||
latency_ms=latency_ms,
|
||||
cache_hit=False,
|
||||
cost_est=cost_est,
|
||||
error=error,
|
||||
subdomain=subdomain,
|
||||
subdomain2=subdomain2,
|
||||
domain_group=domain_group,
|
||||
)
|
||||
|
||||
def _record(self, result: RouterResult, latency_ms: float):
|
||||
self.stats.record(
|
||||
latency_ms,
|
||||
result.domain,
|
||||
result.difficulty,
|
||||
result.upgraded,
|
||||
result.cache_hit,
|
||||
result.cache_level,
|
||||
result.cost_est,
|
||||
result.model_used,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
def health(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"domains": list(self.experts.keys()),
|
||||
"domain_groups": self.domain_groups,
|
||||
"classifier": type(self.classifier).__name__,
|
||||
"judge": type(self.judge).__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:
|
||||
"""从配置构建完整 Router(默认 L0 专家系统模式:零参数可跑)。"""
|
||||
config = load_config(config_path)
|
||||
kb = KnowledgeBase()
|
||||
classifier = build_classifier(config.get("classifier", {}))
|
||||
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),
|
||||
kb=kb)
|
||||
fallback = build_fallback(config.get("fallback", {}))
|
||||
cache_cfg = config.get("cache", {})
|
||||
cache = RouterCache(
|
||||
semantic_enabled=cache_cfg.get("semantic_enabled", True),
|
||||
similarity_threshold=cache_cfg.get("similarity_threshold", 0.88),
|
||||
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()
|
||||
return Router(classifier, experts, judge, fallback, cache, stats, config,
|
||||
kb=kb, planner=planner)
|
||||
|
||||
@@ -39,7 +39,7 @@ LIMITS = {
|
||||
|
||||
# 允许的领域标签(4.2 brief.tags;仅用于安全标记与验证接地,不做路由 D3)
|
||||
ALLOWED_TAGS = {"code", "math", "legal", "medical", "finance",
|
||||
"life", "education", "general", "safety"}
|
||||
"life", "education", "general", "safety", "science"}
|
||||
|
||||
STATUS_FLOW = {
|
||||
"draft": {"in_progress"},
|
||||
|
||||
@@ -1,65 +1,75 @@
|
||||
"""CLI 演示:构建 mock 全链路路由系统,跑一组样例查询并打印结果。
|
||||
|
||||
用法:
|
||||
python scripts/demo.py [--query "自定义查询"] [--batch]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from router_system.router import build_router
|
||||
|
||||
SAMPLE_QUERIES = [
|
||||
"用 Python 写一个快速排序函数,并解释时间复杂度",
|
||||
"求解方程 x^2 - 5x + 6 = 0",
|
||||
"劳动合同里约定离职后两年内不得从事同行业,是否有效?",
|
||||
"高血压患者日常饮食需要注意什么?",
|
||||
"给我总结一下深度学习中注意力机制的优缺点",
|
||||
"为什么天空是蓝色的?",
|
||||
"帮我调试这段代码:def f(x): return x + 1 报 TypeError",
|
||||
"求 ∫ x^2 dx 从 0 到 1 的定积分是多少?",
|
||||
]
|
||||
|
||||
|
||||
async def run_demo(router, queries, verbose: bool = False):
|
||||
for q in queries:
|
||||
r = await router.route(q)
|
||||
print("=" * 72)
|
||||
print(f"Q: {q}")
|
||||
print(f" domain={r.domain} difficulty={r.difficulty} conf={r.confidence:.2f} "
|
||||
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}")
|
||||
print(f" route: {' -> '.join(r.route)}")
|
||||
if verbose:
|
||||
print(f" --- response ---\n{r.response[:400]}")
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="多专家路由系统 CLI 演示")
|
||||
parser.add_argument("--query", type=str, default=None, help="单条查询(覆盖默认样例)")
|
||||
parser.add_argument("--batch", action="store_true", help="批量模式(打印全部响应)")
|
||||
parser.add_argument("--verbose", action="store_true", help="打印响应正文")
|
||||
args = parser.parse_args()
|
||||
|
||||
router = build_router()
|
||||
print("系统组件:", router.health())
|
||||
print()
|
||||
|
||||
if args.query:
|
||||
await run_demo(router, [args.query], verbose=True)
|
||||
else:
|
||||
await run_demo(router, SAMPLE_QUERIES, verbose=args.verbose)
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("运行指标:", router.stats.summary())
|
||||
print("缓存统计:", router.cache.stats())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())␍
|
||||
"""CLI 演示:构建专家系统内核路由(默认 L0 零参数模式),跑样例查询并打印推理链。
|
||||
|
||||
用法:
|
||||
python scripts/demo.py [--query "自定义查询"] [--batch] [--trace] [--verbose]
|
||||
--trace 打印完整推理链(分类 → 拆解 DAG → 规则轨迹 → 子任务执行 → Judge)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
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))
|
||||
|
||||
from router_system.router import build_router
|
||||
|
||||
SAMPLE_QUERIES = [
|
||||
"用 Python 写一个快速排序函数,并解释时间复杂度",
|
||||
"求解方程 x^2 - 5x + 6 = 0",
|
||||
"劳动合同里约定离职后两年内不得从事同行业,是否有效?",
|
||||
"高血压患者日常饮食需要注意什么?",
|
||||
"给我总结一下深度学习中注意力机制的优缺点",
|
||||
"为什么天空是蓝色的?",
|
||||
"帮我调试这段代码:def f(x): return x + 1 报 TypeError",
|
||||
"求 ∫ x^2 dx 从 0 到 1 的定积分是多少?",
|
||||
]
|
||||
|
||||
|
||||
async def run_demo(router, queries, verbose: bool = False, trace: bool = False):
|
||||
for q in queries:
|
||||
r = await router.route(q)
|
||||
print("=" * 72)
|
||||
print(f"Q: {q}")
|
||||
print(f" domain={r.domain} difficulty={r.difficulty} conf={r.confidence:.2f} "
|
||||
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}")
|
||||
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:
|
||||
print(f" --- response ---\n{r.response[:600]}")
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="多专家路由系统(专家系统内核)CLI 演示")
|
||||
parser.add_argument("--query", type=str, default=None, help="单条查询(覆盖默认样例)")
|
||||
parser.add_argument("--batch", action="store_true", help="批量模式(打印全部响应)")
|
||||
parser.add_argument("--verbose", action="store_true", help="打印响应正文")
|
||||
parser.add_argument("--trace", action="store_true", help="打印完整推理链")
|
||||
args = parser.parse_args()
|
||||
|
||||
router = build_router()
|
||||
print("系统组件:", router.health())
|
||||
print()
|
||||
|
||||
if args.query:
|
||||
await run_demo(router, [args.query], verbose=True, trace=True)
|
||||
else:
|
||||
await run_demo(router, SAMPLE_QUERIES, verbose=args.verbose, trace=args.trace)
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("运行指标:", router.stats.summary())
|
||||
print("缓存统计:", router.cache.stats())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -1,80 +1,121 @@
|
||||
"""迷你评估:对带标注的样例查询评估分类准确率、升级率、成本。
|
||||
|
||||
用法:
|
||||
python scripts/eval.py [--repeat 2] [--config path]
|
||||
--repeat 用于把样例跑 N 遍,验证语义缓存命中与降本效果。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from router_system.router import build_router
|
||||
|
||||
# (query, 期望领域)
|
||||
BENCH = [
|
||||
("用 Python 实现二分查找", "code"),
|
||||
("这段 JavaScript 为什么报错:undefined is not a function", "code"),
|
||||
("帮我优化这个 SQL 查询的索引", "code"),
|
||||
("求解一元二次方程 ax^2+bx+c=0 的求根公式", "math"),
|
||||
("证明勾股定理", "math"),
|
||||
("计算 3x + 5 = 20,x 等于多少", "math"),
|
||||
("劳动合同到期不续签,公司需要支付经济补偿吗", "legal"),
|
||||
("在合同中约定违约金上限 30%,是否合规", "legal"),
|
||||
("专利申请的流程和费用大概是多少", "legal"),
|
||||
("高血压患者可以吃哪些降压药,副作用是什么", "medical"),
|
||||
("感冒发烧 38.5 度,需要吃退烧药吗", "medical"),
|
||||
("糖尿病患者的日常饮食建议", "medical"),
|
||||
("介绍一下 Transformer 架构", "general"),
|
||||
("写一封请假邮件", "general"),
|
||||
("为什么天空是蓝色的", "general"),
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repeat", type=int, default=2, help="重复轮数(验证缓存)")
|
||||
parser.add_argument("--config", type=str, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
router = build_router(args.config)
|
||||
correct = Counter()
|
||||
total = 0
|
||||
upgraded = 0
|
||||
cache_hits = 0
|
||||
|
||||
for round_i in range(args.repeat):
|
||||
for q, expected in BENCH:
|
||||
r = await router.route(q)
|
||||
total += 1
|
||||
if r.domain == expected:
|
||||
correct["total"] += 1
|
||||
else:
|
||||
correct[f"misclass->{r.domain}"] += 1
|
||||
if r.upgraded:
|
||||
upgraded += 1
|
||||
if r.cache_hit:
|
||||
cache_hits += 1
|
||||
|
||||
acc = correct["total"] / total
|
||||
print(f"样例数: {len(BENCH)} x {args.repeat} 轮 = {total} 次请求")
|
||||
print(f"分类准确率: {acc:.1%} ({correct['total']}/{total})")
|
||||
print(f"升级率: {upgraded/total:.1%} ({upgraded}/{total})")
|
||||
print(f"缓存命中率: {cache_hits/total:.1%} ({cache_hits}/{total})")
|
||||
print()
|
||||
print("运行指标:", router.stats.summary())
|
||||
print("缓存统计:", router.cache.stats())
|
||||
print()
|
||||
if acc < 0.8:
|
||||
print("⚠️ 准确率低于 80%,请检查分类规则。")
|
||||
else:
|
||||
print("✅ 分类准确率达标(≥80%)。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())␍
|
||||
"""迷你评估:对带标注的样例查询评估分类准确率、升级率、成本。
|
||||
|
||||
用法:
|
||||
python scripts/eval.py [--repeat 2] [--config path]
|
||||
--repeat 用于把样例跑 N 遍,验证语义缓存命中与降本效果。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from collections import Counter
|
||||
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))
|
||||
|
||||
from router_system.router import build_router
|
||||
|
||||
# (query, 期望领域, 期望大领域组, 期望子领域)
|
||||
# 覆盖 8 领域 × 3 条 = 24 条 + 两级路由/三级子领域指标
|
||||
BENCH = [
|
||||
# ---- tech:code / math ----
|
||||
("用 Python 实现二分查找", "code", "tech", "algorithm"),
|
||||
("这段 JavaScript 为什么报错:undefined is not a function", "code", "tech", "debugging"),
|
||||
("帮我优化这个 SQL 查询的索引", "code", "tech", "database"),
|
||||
("求解一元二次方程 ax^2+bx+c=0 的求根公式", "math", "tech", "algebra"),
|
||||
("证明勾股定理", "math", "tech", "geometry"),
|
||||
("计算 3x + 5 = 20,x 等于多少", "math", "tech", "algebra"),
|
||||
# ---- professional:legal / medical / finance ----
|
||||
("劳动合同到期不续签,公司需要支付经济补偿吗", "legal", "professional", "labor"),
|
||||
("在合同中约定违约金上限 30%,是否合规", "legal", "professional", "contract"),
|
||||
("加班费怎么计算", "legal", "professional", "labor"),
|
||||
("高血压患者可以吃哪些降压药,副作用是什么", "medical", "professional", "medication"),
|
||||
("感冒发烧 38.5 度,需要吃退烧药吗", "medical", "professional", "medication"),
|
||||
("烫伤后怎么处理", "medical", "professional", "firstaid"),
|
||||
("基金定投的收益率怎么计算", "finance", "professional", "investing"),
|
||||
("信用卡逾期了怎么办", "finance", "professional", "credit"),
|
||||
("房贷利率是 LPR 加多少", "finance", "professional", "loan"),
|
||||
# ---- lifestyle:life / 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"),
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repeat", type=int, default=2, help="重复轮数(验证缓存)")
|
||||
parser.add_argument("--config", type=str, default=None)
|
||||
parser.add_argument("--group", type=str, default=None,
|
||||
help="指定大领域组测试两级路由(如 tech);默认自动检测")
|
||||
args = parser.parse_args()
|
||||
|
||||
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()
|
||||
group_correct = 0
|
||||
subdomain_correct = 0
|
||||
total = 0
|
||||
upgraded = 0
|
||||
cache_hits = 0
|
||||
decomposed = 0 # 被 Planner 拆解为多子任务的请求数
|
||||
|
||||
for round_i in range(args.repeat):
|
||||
for q, expected, expected_group, expected_sub in bench:
|
||||
r = await router.route(q, domain_group=args.group)
|
||||
total += 1
|
||||
if r.domain == expected:
|
||||
correct["total"] += 1
|
||||
else:
|
||||
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:
|
||||
upgraded += 1
|
||||
if r.cache_hit:
|
||||
cache_hits += 1
|
||||
if any("plan:multi" in s for s in r.route):
|
||||
decomposed += 1
|
||||
|
||||
acc = correct["total"] / total
|
||||
print(f"样例数: {len(bench)} x {args.repeat} 轮 = {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"缓存命中率: {cache_hits/total:.1%} ({cache_hits}/{total})")
|
||||
print(f"任务拆解率: {decomposed/total:.1%} ({decomposed}/{total})")
|
||||
print()
|
||||
print("运行指标:", router.stats.summary())
|
||||
print("缓存统计:", router.cache.stats())
|
||||
print()
|
||||
if acc < 0.8:
|
||||
print("⚠️ 准确率低于 80%,请检查分类规则。")
|
||||
else:
|
||||
print("✅ 分类准确率达标(≥80%)。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""启动路由网关服务(后台、无窗口)。
|
||||
"""启动路由网关服务(后台、无窗口)。
|
||||
|
||||
用法:
|
||||
python scripts/serve.py [--port 8000] [--stop]
|
||||
@@ -41,11 +41,16 @@ def stop():
|
||||
return
|
||||
pid = int(PID_FILE.read_text().strip())
|
||||
try:
|
||||
import signal
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
print(f"已发送终止信号 pid={pid}")
|
||||
except ProcessLookupError:
|
||||
print(f"进程 {pid} 不存在,清理 pid 文件。")
|
||||
# Windows 下 SIGTERM 对 detached 进程不可靠,改用 taskkill 强制结束进程树
|
||||
subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
print(f"已终止服务 pid={pid}")
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"终止超时 pid={pid},请手动结束进程。")
|
||||
except Exception as e:
|
||||
print(f"终止失败(进程可能不存在): {e}")
|
||||
PID_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ def test_exact_hit():
|
||||
|
||||
def test_semantic_hit():
|
||||
c = RouterCache(semantic_enabled=True, similarity_threshold=0.5)
|
||||
c.put("?python?????", {"response": "code", "domain": "code"})
|
||||
# ?????????? L2
|
||||
hit = c.get("?python????????")
|
||||
c.put("用python写一个快速排序", {"response": "code", "domain": "code"})
|
||||
# 相似改写查询命中 L2 语义缓存
|
||||
hit = c.get("用python写一个快速排序算法")
|
||||
assert hit is not None
|
||||
assert hit[0] == "semantic"
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_promote_to_exact():
|
||||
c = RouterCache(promote_frequency=3)
|
||||
result = {"response": "x", "domain": "general"}
|
||||
c.put("query", result)
|
||||
# ?????? 3 ? ? ???????
|
||||
# 语义命中 3 次后提升为精确缓存
|
||||
for _ in range(3):
|
||||
hit = c.get("query")
|
||||
assert hit is not None
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
"""分类器单元测试。"""
|
||||
from router_system.classifier import RuleClassifier
|
||||
|
||||
|
||||
def test_code_classification():
|
||||
clf = RuleClassifier()
|
||||
r = clf.classify("用 Python 写一个快速排序函数")
|
||||
assert r.domain == "code"
|
||||
assert r.confidence > 0.7
|
||||
|
||||
|
||||
def test_math_classification():
|
||||
clf = RuleClassifier()
|
||||
r = clf.classify("求解方程 x^2 - 5x + 6 = 0")
|
||||
assert r.domain == "math"
|
||||
assert r.confidence > 0.7
|
||||
|
||||
|
||||
def test_legal_classification():
|
||||
clf = RuleClassifier()
|
||||
r = clf.classify("劳动合同到期不续签需要支付经济补偿吗")
|
||||
assert r.domain == "legal"
|
||||
|
||||
|
||||
def test_medical_classification():
|
||||
clf = RuleClassifier()
|
||||
r = clf.classify("高血压患者日常饮食需要注意什么")
|
||||
assert r.domain == "medical"
|
||||
|
||||
|
||||
def test_general_low_confidence():
|
||||
clf = RuleClassifier()
|
||||
r = clf.classify("今天天气怎么样")
|
||||
# 未命中任何领域 -> 低置信度,触发 should_fallback
|
||||
assert r.domain == "general"
|
||||
assert clf.should_fallback(r, 0.6) is True
|
||||
|
||||
|
||||
def test_difficulty_estimation():
|
||||
clf = RuleClassifier()
|
||||
easy = clf.classify("1 + 1 = ?")
|
||||
hard = clf.classify("证明费马大定理并推导其推论,给出详细步骤")
|
||||
assert hard.difficulty in ("medium", "hard")
|
||||
assert easy.difficulty == "easy"␍
|
||||
"""分类器单元测试。"""
|
||||
from router_system.classifier import RuleClassifier
|
||||
|
||||
|
||||
def test_code_classification():
|
||||
clf = RuleClassifier()
|
||||
r = clf.classify("用 Python 写一个快速排序函数")
|
||||
assert r.domain == "code"
|
||||
assert r.confidence > 0.7
|
||||
|
||||
|
||||
def test_math_classification():
|
||||
clf = RuleClassifier()
|
||||
r = clf.classify("求解方程 x^2 - 5x + 6 = 0")
|
||||
assert r.domain == "math"
|
||||
assert r.confidence > 0.7
|
||||
|
||||
|
||||
def test_legal_classification():
|
||||
clf = RuleClassifier()
|
||||
r = clf.classify("劳动合同到期不续签需要支付经济补偿吗")
|
||||
assert r.domain == "legal"
|
||||
|
||||
|
||||
def test_medical_classification():
|
||||
clf = RuleClassifier()
|
||||
r = clf.classify("高血压患者日常饮食需要注意什么")
|
||||
assert r.domain == "medical"
|
||||
|
||||
|
||||
def test_general_low_confidence():
|
||||
clf = RuleClassifier()
|
||||
r = clf.classify("你好呀")
|
||||
# 未命中任何领域 -> general,低置信度,触发 should_fallback
|
||||
assert r.domain == "general"
|
||||
assert clf.should_fallback(r, 0.6) is True
|
||||
|
||||
|
||||
def test_difficulty_estimation():
|
||||
clf = RuleClassifier()
|
||||
easy = clf.classify("1 + 1 = ?")
|
||||
hard = clf.classify("证明费马大定理并推导其推论,给出详细步骤")
|
||||
assert hard.difficulty in ("medium", "hard")
|
||||
assert easy.difficulty == "easy"
|
||||
|
||||
@@ -41,14 +41,30 @@ def test_chat_legacy(client):
|
||||
|
||||
|
||||
def test_chat_v2(v2_client):
|
||||
# POST /chat 立即返回 request_id(异步协议)
|
||||
resp = v2_client.post("/chat", json={"query": "请介绍快速排序算法"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["response"]
|
||||
assert data["request_id"]
|
||||
assert "fast_path" in data
|
||||
assert "route" in data
|
||||
assert data["status"] in ("fast_path", "done", "escalated", "failed")
|
||||
assert "request_id" in data
|
||||
assert data["status"] == "pending"
|
||||
|
||||
# 轮询 /runs/{id}/status 直到完成
|
||||
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):
|
||||
@@ -93,8 +109,9 @@ def test_web_ui_served(client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/html")
|
||||
assert "端云协同" in resp.text
|
||||
assert "发送" in resp.text
|
||||
# Vue SPA:由 Vite 生成,特征是 <div id="app"> 和 /static/assets/ 引用
|
||||
assert '<div id="app">' in resp.text
|
||||
assert '/static/assets/' in resp.text
|
||||
|
||||
|
||||
def test_review_flow(client):
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
"""路由主流程单元测试。"""
|
||||
import pytest
|
||||
|
||||
from router_system.router import build_router
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_flow_code(router):
|
||||
r = await router.route("用 Python 写一个快速排序函数")
|
||||
assert r.domain == "code"
|
||||
assert r.response
|
||||
assert r.model_used
|
||||
assert r.latency_ms >= 0
|
||||
assert "expert" in r.route[1] or "classify" in r.route[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_low_confidence_direct_fallback(router):
|
||||
r = await router.route("今天天气怎么样")
|
||||
assert r.upgraded is True
|
||||
assert "direct_fallback" in r.route
|
||||
assert r.model_used == router.fallback.model
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_second_round(router):
|
||||
q = "用 Python 写一个快速排序函数"
|
||||
r1 = await router.route(q)
|
||||
assert r1.cache_hit is False
|
||||
r2 = await router.route(q)
|
||||
assert r2.cache_hit is True
|
||||
assert r2.cache_level in ("exact", "semantic")
|
||||
assert r2.response == r1.response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_judge_route_present(router):
|
||||
r = await router.route("高血压患者日常饮食需要注意什么")
|
||||
assert any(step.startswith("judge:") for step in r.route)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats_recorded(router):
|
||||
await router.route("写一个 python 函数")
|
||||
await router.route("写一个 python 函数")
|
||||
s = router.stats.summary()
|
||||
assert s["total_requests"] == 2
|
||||
assert s["domain_distribution"]["code"] == 2
|
||||
assert s["cache_hit_rate"] > 0
|
||||
|
||||
|
||||
def test_health(router):
|
||||
h = router.health()
|
||||
assert h["status"] == "ok"
|
||||
assert "code" in h["domains"]
|
||||
assert "math" in h["domains"]␍
|
||||
"""路由主流程单元测试。"""
|
||||
import pytest
|
||||
|
||||
from router_system.router import build_router
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_flow_code(router):
|
||||
r = await router.route("用 Python 写一个快速排序函数")
|
||||
assert r.domain == "code"
|
||||
assert r.response
|
||||
assert r.model_used
|
||||
assert r.latency_ms >= 0
|
||||
assert any("classify:" in s for s in r.route)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_low_confidence_direct_fallback(router):
|
||||
r = await router.route("今天天气怎么样")
|
||||
assert r.upgraded is True
|
||||
assert "direct_fallback" in r.route
|
||||
assert r.model_used == router.fallback.model
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_second_round(router):
|
||||
q = "用 Python 写一个快速排序函数"
|
||||
r1 = await router.route(q)
|
||||
assert r1.cache_hit is False
|
||||
r2 = await router.route(q)
|
||||
assert r2.cache_hit is True
|
||||
assert r2.cache_level in ("exact", "semantic")
|
||||
assert r2.response == r1.response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_judge_route_present(router):
|
||||
r = await router.route("高血压患者日常饮食需要注意什么")
|
||||
assert any(step.startswith("judge:") for step in r.route)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats_recorded(router):
|
||||
await router.route("写一个 python 函数")
|
||||
await router.route("写一个 python 函数")
|
||||
s = router.stats.summary()
|
||||
assert s["total_requests"] == 2
|
||||
assert s["domain_distribution"]["code"] == 2
|
||||
assert s["cache_hit_rate"] > 0
|
||||
|
||||
|
||||
def test_health(router):
|
||||
h = router.health()
|
||||
assert h["status"] == "ok"
|
||||
assert "code" in h["domains"]
|
||||
assert "math" in h["domains"]
|
||||
|
||||
@@ -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?
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
@@ -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>
|
||||
@@ -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')
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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 |
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 & 裁决</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -81,3 +81,4 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
|
||||
| T12 | 实验脚本 bench_tokens.py + 数据集 | ✅ 完成 | T12 |
|
||||
| T13 | E1 本地跑数完成(E2–E5 待 live 接入) | ✅ 完成 | T13 |
|
||||
| T14 | 文档收口(README v2 改写) | ✅ 完成 | T14 |
|
||||
| T15 | Pipeline 死锁修复(_deps_done 依赖过滤 + pending-empty break)+ /metrics SPA 路由冲突修复 | ✅ 完成 | T15 |
|
||||
|
||||