feat(v2): Web 界面(对话/协作过程/人工检验/指标)+ worker 优雅降级

This commit is contained in:
tzt
2026-08-30 21:26:16 +08:00
parent 0ec7a9d034
commit 5ba3cc778b
5 changed files with 202 additions and 10 deletions
+3 -1
View File
@@ -20,7 +20,8 @@
-**人工检验队列**`review.py`):sqlite 队列、抽样 + safety 强制入队、verdict/correction 回写 -**人工检验队列**`review.py`):sqlite 队列、抽样 + safety 强制入队、verdict/correction 回写
-**打包分发**`scripts/setup_runtime.py`):llama-server + GGUF 下载(断点续传/大小校验) -**打包分发**`scripts/setup_runtime.py`):llama-server + GGUF 下载(断点续传/大小校验)
-**E1 token 经济学实验**`scripts/bench_tokens.py``research/v2_experiments/` -**E1 token 经济学实验**`scripts/bench_tokens.py``research/v2_experiments/`
-**测试****228 项全绿**(含 v1 legacy 126 项 + v2 新模块) -**Web 界面**gateway/static/index.htmlFastAPI 托管,无构建):对话 / 协作过程(交流文本可视化)/ 人工检验 / 指标 四视图
-**测试****229 项全绿**(含 v1 legacy 126 项 + v2 新模块)
--- ---
@@ -66,6 +67,7 @@ C:\Python314\python.exe -m venv .venv
# 4. 启动网关(v2 /chat 默认走 mock worker,无需 API key/模型即可演示) # 4. 启动网关(v2 /chat 默认走 mock worker,无需 API key/模型即可演示)
.venv\Scripts\python.exe scripts/serve.py --port 8000 .venv\Scripts\python.exe scripts/serve.py --port 8000
# 浏览器打开 http://127.0.0.1:8000/ 使用 Web 界面(对话 / 协作过程 / 人工检验 / 指标)
# 5. 调用 # 5. 调用
curl http://127.0.0.1:8000/health curl http://127.0.0.1:8000/health
+10
View File
@@ -107,6 +107,9 @@ class HealthResponse(BaseModel):
# ---- FastAPI 应用 ---- # ---- FastAPI 应用 ----
try: try:
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
_INDEX_PATH = Path(__file__).resolve().parent / "static" / "index.html"
app = FastAPI( app = FastAPI(
title="端云协同 LLM 协作系统", title="端云协同 LLM 协作系统",
@@ -118,6 +121,13 @@ try:
async def health(): async def health():
return get_router().health() return get_router().health()
@app.get("/", response_class=HTMLResponse, tags=["ui"])
async def index():
"""端云协同 Web 界面(单文件前端,无需构建)。"""
if _INDEX_PATH.exists():
return HTMLResponse(_INDEX_PATH.read_text(encoding="utf-8"))
return HTMLResponse("<h1>Web 界面未生成</h1><p>缺少 gateway/static/index.html</p>")
# ---------------- v2/chat ---------------- # ---------------- v2/chat ----------------
@app.post("/chat", tags=["chat"]) @app.post("/chat", tags=["chat"])
async def chat(req: QueryRequest): async def chat(req: QueryRequest):
+167
View File
@@ -0,0 +1,167 @@
<!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}
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>
</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>
</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();});
// ---- 健康状态 ----
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)];}
$("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>
+14 -9
View File
@@ -190,14 +190,19 @@ def _make_llama_generate(cfg: Dict[str, Any]) -> Callable[[str], Awaitable[str]]
timeout_s = float(cfg.get("per_step_timeout_s", 300)) timeout_s = float(cfg.get("per_step_timeout_s", 300))
async def _gen(prompt: str) -> str: async def _gen(prompt: str) -> str:
import httpx try:
async with httpx.AsyncClient(timeout=timeout_s) as client: import httpx
resp = await client.post( async with httpx.AsyncClient(timeout=timeout_s) as client:
f"{base_url}/chat/completions", resp = await client.post(
json={"model": model, "messages": [{"role": "user", "content": prompt}], f"{base_url}/chat/completions",
"temperature": temperature, "max_tokens": 4096}, json={"model": model, "messages": [{"role": "user", "content": prompt}],
) "temperature": temperature, "max_tokens": 4096},
resp.raise_for_status() )
return resp.json()["choices"][0]["message"]["content"] resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
except Exception as e: # noqa: BLE001
# 连不上本地模型 -> 优雅降级(不抛 500),提示用户准备运行时
return ("(本地降级)无法连接本地 llama-server,未能生成该步骤内容。"
f"请先运行 scripts/setup_runtime.py 并启动模型。错误:{type(e).__name__}")
return _gen return _gen
+8
View File
@@ -71,6 +71,14 @@ def test_workspace_not_found(client):
assert resp.status_code == 404 assert resp.status_code == 404
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
def test_review_flow(client): def test_review_flow(client):
q = ga.get_review() q = ga.get_review()
rid = q.enqueue("req-x", "q", "ans", tags=["safety"], reason="test") rid = q.enqueue("req-x", "q", "ans", tags=["safety"], reason="test")