feat(v3): Web 应用化基线(异步任务/SSE/llama-server 管理/Vue SPA 四页 + 设置页整页滚动修复)
This commit is contained in:
+328
@@ -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
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.metrics-view[data-v-51f92136]{height:100%;padding:20px 24px;overflow-y:auto}.metrics-header[data-v-51f92136]{justify-content:space-between;align-items:center;margin-bottom:20px;display:flex}.metrics-header h2[data-v-51f92136]{margin:0;font-size:20px}.refresh[data-v-51f92136]{cursor:pointer;background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:6px 14px}.loading[data-v-51f92136],.error[data-v-51f92136]{text-align:center;color:#9ca3af;padding:40px}.error[data-v-51f92136]{color:#dc2626}.card-grid[data-v-51f92136]{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-bottom:24px;display:grid}.metric-card[data-v-51f92136]{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:16px}.metric-card.highlight[data-v-51f92136]{background:#eff6ff;border-color:#2563eb}.metric-card h3[data-v-51f92136]{color:#374151;margin:0 0 12px;font-size:14px}.kv-list[data-v-51f92136]{grid-template-columns:1fr 1fr;gap:6px 12px;font-size:13px;display:grid}.kv-list span[data-v-51f92136]{color:#6b7280}.kv-list b[data-v-51f92136]{color:#111;text-align:right}.review-card[data-v-51f92136]{grid-column:span 2}.review-stats[data-v-51f92136]{gap:24px;margin-bottom:12px;display:flex}.stat-item[data-v-51f92136]{flex-direction:column;align-items:center;display:flex}.stat-num[data-v-51f92136]{color:#2563eb;font-size:28px;font-weight:700}.stat-label[data-v-51f92136]{color:#6b7280;font-size:12px}.progress-wrap[data-v-51f92136]{background:#e5e7eb;border-radius:99px;height:8px;margin-bottom:6px;overflow:hidden}.reviewed-bar[data-v-51f92136]{background:#16a34a;height:100%;transition:width .5s}.review-rate[data-v-51f92136]{color:#6b7280;margin:0;font-size:13px}.raw-json[data-v-51f92136]{background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px}.raw-json summary[data-v-51f92136]{cursor:pointer;color:#6b7280;padding:10px 14px;font-size:13px}.raw-json pre[data-v-51f92136]{white-space:pre-wrap;border-top:1px solid #e5e7eb;margin:0;padding:10px 14px;font-size:12px}
|
||||
@@ -0,0 +1 @@
|
||||
import{A as e,C as t,D as n,F as r,O as i,R as a,T as o,j as s,o as c,t as l,w as u,x as d,z as f}from"./index-BnlAnYlB.js";var p={class:`metrics-view`},m={key:0,class:`loading`},h={key:1,class:`error`},g={class:`card-grid`},_={class:`metric-card`},v={class:`kv-list`},y={class:`metric-card`},b={class:`kv-list`},x={key:0,class:`metric-card highlight`},S={class:`kv-list`},C={key:1,class:`metric-card review-card`},w={class:`review-stats`},T={class:`stat-item`},E={class:`stat-num`},D={class:`stat-item`},O={class:`stat-num`},k={key:0,class:`progress-wrap`},A={class:`review-rate`},j={class:`raw-json`},M=l(n({__name:`MetricsView`,setup(n){let l=r(null),M=r(!1),N=r(``);async function P(){M.value=!0,N.value=``;try{l.value=await c()}catch(e){N.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{M.value=!1}}return i(P),(n,r)=>(e(),o(`div`,p,[t(`header`,{class:`metrics-header`},[r[0]||=t(`h2`,null,`系统指标`,-1),t(`button`,{class:`refresh`,onClick:P},`🔄 刷新`)]),M.value?(e(),o(`div`,m,`加载中…`)):N.value?(e(),o(`div`,h,f(N.value),1)):l.value?(e(),o(d,{key:2},[t(`div`,g,[t(`div`,_,[r[1]||=t(`h3`,null,`路由器(v1)`,-1),t(`div`,v,[(e(!0),o(d,null,s(l.value.router,(n,r)=>(e(),o(d,{key:r},[t(`span`,null,f(r),1),t(`b`,null,f(n),1)],64))),128))])]),t(`div`,y,[r[2]||=t(`h3`,null,`缓存`,-1),t(`div`,b,[(e(!0),o(d,null,s(l.value.cache,(n,r)=>(e(),o(d,{key:r},[t(`span`,null,f(r),1),t(`b`,null,f(n),1)],64))),128))])]),l.value.v2?(e(),o(`div`,x,[r[3]||=t(`h3`,null,`协作管线(v2)`,-1),t(`div`,S,[(e(!0),o(d,null,s(l.value.v2,(n,r)=>(e(),o(d,{key:r},[t(`span`,null,f(r),1),t(`b`,null,f(n),1)],64))),128))])])):u(``,!0),l.value.review?(e(),o(`div`,C,[r[6]||=t(`h3`,null,`人工检验`,-1),t(`div`,w,[t(`div`,T,[t(`span`,E,f(l.value.review.pending),1),r[4]||=t(`span`,{class:`stat-label`},`待审核`,-1)]),t(`div`,D,[t(`span`,O,f(l.value.review.total),1),r[5]||=t(`span`,{class:`stat-label`},`总提交`,-1)])]),l.value.review.total>0?(e(),o(`div`,k,[t(`div`,{class:`reviewed-bar`,style:a({width:`${(l.value.review.total-l.value.review.pending)/l.value.review.total*100}%`})},null,4)])):u(``,!0),t(`p`,A,` 通过率: `+f(((l.value.review.total-l.value.review.pending)/l.value.review.total*100).toFixed(1))+`% `,1)])):u(``,!0)]),t(`details`,j,[r[7]||=t(`summary`,null,`原始 JSON`,-1),t(`pre`,null,f(JSON.stringify(l.value,null,2)),1)])],64)):u(``,!0)]))}}),[[`__scopeId`,`data-v-51f92136`]]);export{M as default};
|
||||
@@ -0,0 +1 @@
|
||||
.review-view[data-v-19c16eff]{height:100%;padding:20px 24px;overflow-y:auto}.review-header[data-v-19c16eff]{justify-content:space-between;align-items:center;margin-bottom:20px;display:flex}.review-header h2[data-v-19c16eff]{margin:0;font-size:20px}.controls[data-v-19c16eff]{gap:8px;display:flex}button[data-v-19c16eff]{cursor:pointer;background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:6px 14px;font-size:13px}button.active[data-v-19c16eff]{color:#fff;background:#2563eb;border-color:#2563eb}.refresh-btn[data-v-19c16eff]{margin-left:auto}.loading[data-v-19c16eff],.error[data-v-19c16eff],.empty[data-v-19c16eff]{text-align:center;color:#9ca3af;padding:40px}.error[data-v-19c16eff]{color:#dc2626}.queue-list[data-v-19c16eff]{flex-direction:column;gap:16px;display:flex}.review-card[data-v-19c16eff]{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:16px}.card-header[data-v-19c16eff]{flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:10px;display:flex}.card-id[data-v-19c16eff]{color:#6b7280;font-family:monospace;font-size:12px}.verdict-badge[data-v-19c16eff]{border-radius:99px;padding:2px 8px;font-size:12px;font-weight:600}.verdict-badge.pending[data-v-19c16eff]{color:#92400e;background:#fef3c7}.verdict-badge.approved[data-v-19c16eff]{color:#16a34a;background:#dcfce7}.verdict-badge.rejected[data-v-19c16eff]{color:#dc2626;background:#fee2e2}.tags[data-v-19c16eff]{gap:4px;display:flex}.tag[data-v-19c16eff]{color:#3730a3;background:#e0e7ff;border-radius:4px;padding:1px 6px;font-size:11px}.date[data-v-19c16eff]{color:#9ca3af;margin-left:auto;font-size:11px}.query-block[data-v-19c16eff],.response-block[data-v-19c16eff]{margin-bottom:8px;font-size:13px;line-height:1.6}.query-block pre[data-v-19c16eff],.response-block pre[data-v-19c16eff]{white-space:pre-wrap;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;margin:4px 0 0;padding:6px 10px;font-size:13px}.actions[data-v-19c16eff]{flex-direction:column;gap:8px;margin-top:10px;display:flex}textarea[data-v-19c16eff]{resize:vertical;box-sizing:border-box;border:1px solid #d1d5db;border-radius:6px;width:100%;padding:8px 10px;font-family:inherit;font-size:13px}.btn-row[data-v-19c16eff]{gap:8px;display:flex}.approve[data-v-19c16eff]{color:#16a34a;background:#dcfce7;border-color:#86efac}.reject[data-v-19c16eff]{color:#dc2626;background:#fee2e2;border-color:#fca5a5}.correction[data-v-19c16eff]{background:#fffbeb;border:1px solid #fcd34d;border-radius:4px;margin-top:8px;padding:6px 10px;font-size:13px}
|
||||
@@ -0,0 +1 @@
|
||||
import{A as e,C as t,D as n,E as r,F as i,L as a,N as o,O as s,S as c,T as l,b as u,j as d,l as f,m as p,t as m,w as h,x as g,z as _}from"./index-BnlAnYlB.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=m(n({__name:`ReviewView`,setup(n){let m=i([]),L=i(!1),R=i(``),z=i(`pending`),B=i({}),V=c(()=>z.value===`all`?m.value:m.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{m.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await p(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return s(H),(n,i)=>(e(),l(`div`,v,[t(`header`,y,[i[4]||=t(`h2`,null,`人工检验队列`,-1),t(`div`,b,[t(`button`,{class:a({active:z.value===`all`}),onClick:i[0]||=e=>z.value=`all`},`全部`,2),t(`button`,{class:a({active:z.value===`pending`}),onClick:i[1]||=e=>z.value=`pending`},`待审核`,2),t(`button`,{class:a({active:z.value===`approved`}),onClick:i[2]||=e=>z.value=`approved`},`已通过`,2),t(`button`,{class:a({active:z.value===`rejected`}),onClick:i[3]||=e=>z.value=`rejected`},`已拒绝`,2),t(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(e(),l(`div`,x,`加载中…`)):R.value?(e(),l(`div`,S,_(R.value),1)):(e(),l(`div`,C,[V.value.length?h(``,!0):(e(),l(`div`,w,`队列为空。`)),(e(!0),l(g,null,d(V.value,n=>(e(),l(`div`,{key:n.id,class:`review-card`},[t(`div`,T,[t(`span`,E,`#`+_(n.id),1),t(`span`,{class:a([`verdict-badge`,n.verdict])},_(n.verdict),3),t(`span`,D,[(e(!0),l(g,null,d(n.tags,t=>(e(),l(`span`,{key:t,class:`tag`},_(t),1))),128))]),t(`span`,O,_(n.created_at),1)]),t(`div`,k,[i[5]||=t(`strong`,null,`Query:`,-1),r(_(n.query),1)]),t(`div`,A,[i[6]||=t(`strong`,null,`Response:`,-1),t(`pre`,null,_(n.response),1)]),n.verdict===`pending`?(e(),l(`div`,j,[o(t(`textarea`,{"onUpdate:modelValue":e=>B.value[n.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[u,B.value[n.id]]]),t(`div`,N,[t(`button`,{class:`approve`,onClick:e=>U(n.id,`approved`)},`✅ 通过`,8,P),t(`button`,{class:`reject`,onClick:e=>U(n.id,`rejected`)},`❌ 拒绝`,8,F)])])):n.correction?(e(),l(`div`,I,[i[7]||=t(`strong`,null,`修正:`,-1),r(_(n.correction),1)])):h(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-19c16eff`]]);export{L as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
*{box-sizing:border-box;margin:0;padding:0}html,body,#app{color:#111;background:#fff;height:100%;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px}.app{flex-direction:column;height:100%;display:flex}.topbar{background:#fff;border-bottom:1px solid #e5e7eb;flex-shrink:0;align-items:center;gap:24px;height:52px;padding:0 24px;display:flex}.brand{color:#1e40af;white-space:nowrap;font-size:16px;font-weight:700}.nav-links{gap:4px;display:flex}.nav-links a{color:#374151;border-radius:6px;padding:6px 14px;font-size:14px;font-weight:500;text-decoration:none;transition:background .15s}.nav-links a:hover{background:#f3f4f6}.nav-links a.router-link-active{color:#2563eb;background:#eff6ff}.content{flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden}.chat-view[data-v-6692005c]{gap:0;height:100%;display:flex}.sidebar[data-v-6692005c]{background:#f9fafb;border-right:1px solid #e5e7eb;flex-direction:column;width:240px;display:flex;overflow:hidden}.sidebar h3[data-v-6692005c]{color:#6b7280;border-bottom:1px solid #e5e7eb;margin:0;padding:12px 16px;font-size:14px}.session-list[data-v-6692005c]{flex:1;margin:0;padding:8px;list-style:none;overflow-y:auto}.session-item[data-v-6692005c]{cursor:pointer;border-radius:6px;flex-direction:column;gap:2px;margin-bottom:4px;padding:8px 10px;font-size:13px;display:flex}.session-item[data-v-6692005c]:hover{background:#e5e7eb}.session-item.active[data-v-6692005c]{background:#dbeafe}.s-query[data-v-6692005c]{color:#111}.s-status[data-v-6692005c]{color:#9ca3af;font-size:11px}.s-status.done[data-v-6692005c]{color:#16a34a}.s-status.failed[data-v-6692005c]{color:#dc2626}.s-status.running[data-v-6692005c]{color:#2563eb}.main[data-v-6692005c]{flex-direction:column;flex:1;gap:12px;padding:20px 24px;display:flex;overflow:hidden}.empty[data-v-6692005c]{color:#9ca3af;flex-direction:column;flex:1;justify-content:center;align-items:center;gap:8px;display:flex}.hint[data-v-6692005c]{font-size:13px}.user-msg[data-v-6692005c],.assistant-msg[data-v-6692005c]{background:#f3f4f6;border-radius:8px;gap:12px;padding:12px 16px;font-size:14px;line-height:1.6;display:flex}.assistant-msg[data-v-6692005c]{background:#eff6ff}.role-label[data-v-6692005c]{color:#6b7280;min-width:32px;font-size:12px;font-weight:700}.user-msg pre[data-v-6692005c],.assistant-msg pre[data-v-6692005c]{white-space:pre-wrap;margin:0;font-family:inherit}.status-bar[data-v-6692005c]{flex-wrap:wrap;align-items:center;gap:12px;display:flex}.badge[data-v-6692005c]{border-radius:99px;padding:3px 10px;font-size:12px;font-weight:600}.badge.pending[data-v-6692005c]{color:#6b7280;background:#f3f4f6}.badge.running[data-v-6692005c]{color:#2563eb;background:#dbeafe}.badge.done[data-v-6692005c]{color:#16a34a;background:#dcfce7}.badge.failed[data-v-6692005c]{color:#dc2626;background:#fee2e2}.route-path[data-v-6692005c]{color:#9ca3af;font-size:12px}.ws-meta[data-v-6692005c]{color:#9ca3af;gap:16px;font-size:12px;display:flex}.ws-preview[data-v-6692005c]{background:#fafafa;border:1px solid #e5e7eb;border-radius:8px;padding:12px;font-size:13px}.brief-block[data-v-6692005c]{margin-bottom:8px}.tags[data-v-6692005c]{color:#6b7280;margin-left:8px;font-size:12px}.plan-list[data-v-6692005c],.progress-list[data-v-6692005c]{margin:4px 0 0;padding:0;list-style:none}.plan-list li[data-v-6692005c],.progress-list li[data-v-6692005c]{gap:6px;padding:2px 0;display:flex}.step-id[data-v-6692005c]{color:#6b7280;min-width:48px;font-family:monospace}.deps[data-v-6692005c]{color:#9ca3af;font-size:12px}.done[data-v-6692005c]{color:#16a34a}.pending[data-v-6692005c]{color:#9ca3af}.running[data-v-6692005c]{color:#2563eb}.error-msg[data-v-6692005c]{color:#dc2626;background:#fee2e2;border-radius:6px;padding:8px 12px;font-size:13px}.input-bar[data-v-6692005c]{border-top:1px solid #e5e7eb;gap:8px;margin-top:auto;padding-top:12px;display:flex}.input-bar input[data-v-6692005c]{border:1px solid #d1d5db;border-radius:8px;outline:none;flex:1;padding:10px 14px;font-size:14px}.input-bar input[data-v-6692005c]:focus{border-color:#2563eb}.input-bar button[data-v-6692005c]{color:#fff;cursor:pointer;background:#2563eb;border:none;border-radius:8px;padding:10px 20px;font-size:14px}.input-bar button[data-v-6692005c]:disabled{cursor:not-allowed;background:#9ca3af}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
+14
-224
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user