Spaces:
Paused
Paused
Update app.py
Browse files
app.py
CHANGED
|
@@ -7,13 +7,9 @@ import termios
|
|
| 7 |
import asyncio
|
| 8 |
import json
|
| 9 |
import base64
|
| 10 |
-
import glob
|
| 11 |
import logging
|
| 12 |
import shutil
|
| 13 |
import subprocess
|
| 14 |
-
import tarfile
|
| 15 |
-
import zipfile
|
| 16 |
-
from pathlib import Path
|
| 17 |
|
| 18 |
import spaces
|
| 19 |
import httpx
|
|
@@ -38,8 +34,6 @@ LLAMA_SERVER_BIN = os.path.join(LLAMA_BIN_DIR, "llama-server")
|
|
| 38 |
LLAMA_RELEASE_TAG = "b10536"
|
| 39 |
LLAMA_CPP_URL = f"https://github.com/ggml-org/llama.cpp/releases/download/{LLAMA_RELEASE_TAG}/llama-{LLAMA_RELEASE_TAG}-bin-ubuntu-x64.tar.gz"
|
| 40 |
|
| 41 |
-
# 改用「無 MTP」版本的權重 —— MTP 分支在部分環境下不穩定,改走一般解碼路徑,
|
| 42 |
-
# 靠其他手段(flash-attn、連續批次、量化 KV cache、GPU 全卸載等)把 token 速度衝高。
|
| 43 |
MODEL_DIR = os.path.join(HOME, "models")
|
| 44 |
MODEL_PATH = os.path.join(MODEL_DIR, "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf")
|
| 45 |
MODEL_URL = "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/resolve/main/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"
|
|
@@ -49,13 +43,10 @@ MMPROJ_URL = "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/resolve/main/m
|
|
| 49 |
LLAMA_PORT = 8081
|
| 50 |
LLAMA_LOG = os.path.join(HOME, "llama-server.log")
|
| 51 |
|
| 52 |
-
# pi coding agent(earendil-works/pi):終端機 AI 編碼助手,設定後透過本機 llama.cpp
|
| 53 |
-
# 的 OpenAI 相容 API 運作,完全離線、免金鑰。
|
| 54 |
PI_MODELS_JSON = os.path.join(HOME, ".pi", "agent", "models.json")
|
| 55 |
PI_PROVIDER_ID = "llama-cpp-local"
|
| 56 |
PI_MODEL_ID = "qwen3.6-35b-a3b"
|
| 57 |
|
| 58 |
-
llama_process: "asyncio.subprocess.Process | None" = None
|
| 59 |
llama_ready = asyncio.Event()
|
| 60 |
llm_status = {"stage": "not_started", "detail": ""}
|
| 61 |
|
|
@@ -122,7 +113,6 @@ function wsUrl() {
|
|
| 122 |
return `${p}//${location.host}/ws`;
|
| 123 |
}
|
| 124 |
|
| 125 |
-
// 每個 tab 自行連線;連線意外中斷時會自動重連,不會拖累其他分頁
|
| 126 |
function connect(tabObj) {
|
| 127 |
const ws = new WebSocket(wsUrl());
|
| 128 |
tabObj.ws = ws;
|
|
@@ -227,7 +217,7 @@ function closeTab(id) {
|
|
| 227 |
const i = tabs.findIndex(t => t.id === id);
|
| 228 |
if (i < 0) return;
|
| 229 |
const t = tabs[i];
|
| 230 |
-
t.closed = true;
|
| 231 |
if (t.ws) t.ws.close();
|
| 232 |
t.term.dispose();
|
| 233 |
t.el.remove();
|
|
@@ -291,8 +281,6 @@ fileInput.onchange = (e) => {
|
|
| 291 |
fileInput.value = '';
|
| 292 |
};
|
| 293 |
|
| 294 |
-
// 📥 下載:改走原生 HTTP 串流下載(不再塞進 WebSocket + base64),
|
| 295 |
-
// 大檔案(例如模型檔)也不會卡住整個伺服器或造成無法重新連線。
|
| 296 |
downloadBtn.onclick = () => {
|
| 297 |
const t = activeTab();
|
| 298 |
if (!t) return;
|
|
@@ -362,12 +350,9 @@ async def download_route(path: str):
|
|
| 362 |
return JSONResponse({"error": "path not allowed"}, status_code=403)
|
| 363 |
if not os.path.isfile(safe):
|
| 364 |
return JSONResponse({"error": "file not found"}, status_code=404)
|
| 365 |
-
# FileResponse 會以串流方式送出,不會把整個檔案讀進記憶體或卡住 event loop,
|
| 366 |
-
# 即使是幾十 GB 的模型檔也可以正常下載。
|
| 367 |
return FileResponse(safe, filename=os.path.basename(safe))
|
| 368 |
|
| 369 |
|
| 370 |
-
# ── 代理 llama-server 的 OpenAI-相容 API,讓外部只需打這個 Space 的網址即可 ──
|
| 371 |
@app.api_route("/v1/{path:path}", methods=["GET", "POST", "OPTIONS"])
|
| 372 |
async def llm_proxy(path: str, request: Request):
|
| 373 |
if not llama_ready.is_set():
|
|
@@ -385,15 +370,13 @@ async def llm_proxy(path: str, request: Request):
|
|
| 385 |
async for chunk in resp.aiter_bytes():
|
| 386 |
yield chunk
|
| 387 |
|
| 388 |
-
# 先用一次非串流探測狀態碼/標頭比較麻煩,這裡直接假設 200,串流轉發內容即可涵蓋
|
| 389 |
-
# /v1/chat/completions?stream=true 的 SSE 情境。
|
| 390 |
return StreamingResponse(relay(), media_type="text/event-stream")
|
| 391 |
|
| 392 |
|
| 393 |
# ──────────────────────────── 背景安裝流程 ────────────────────────────
|
| 394 |
|
| 395 |
async def _run(cmd, cwd=None, env=None):
|
| 396 |
-
"""非阻塞地
|
| 397 |
proc = await asyncio.create_subprocess_shell(
|
| 398 |
cmd, cwd=cwd, env=env,
|
| 399 |
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
@@ -403,52 +386,53 @@ async def _run(cmd, cwd=None, env=None):
|
|
| 403 |
|
| 404 |
|
| 405 |
async def install_opencode():
|
| 406 |
-
log.info("正在背景安
|
| 407 |
rc, out, err = await _run("curl -fsSL https://opencode.ai/install | bash")
|
| 408 |
if rc == 0:
|
| 409 |
-
log.info("✅ opencode 安
|
| 410 |
else:
|
| 411 |
-
log.warning("⚠️ opencode 安
|
| 412 |
|
| 413 |
|
| 414 |
async def install_editors():
|
| 415 |
-
"""安
|
| 416 |
-
log.info("正在安
|
| 417 |
for prefix in ("", "sudo "):
|
| 418 |
rc, out, err = await _run(f"{prefix}apt-get update -y && {prefix}apt-get install -y vim neovim")
|
| 419 |
if rc == 0:
|
| 420 |
-
log.info("✅ vim / neovim 安
|
| 421 |
await _run("update-alternatives --set editor /usr/bin/vim.basic || true")
|
| 422 |
with open(os.path.join(HOME, ".bashrc"), "a") as f:
|
| 423 |
f.write('\nexport EDITOR=vim\nalias vi="vim"\n')
|
| 424 |
return
|
| 425 |
-
log.error("❌ vim / neovim 安
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
async def install_pi_agent():
|
| 429 |
-
"""安裝 pi coding agent (earendil-works/pi) 並設定使用本機 llama.cpp 的 OpenAI API。"""
|
| 430 |
-
log.info("正在安裝 pi coding agent ...")
|
| 431 |
-
rc, out, err = await _run("npm install -g --ignore-scripts @earendil-works/pi-coding-agent")
|
| 432 |
-
if rc != 0:
|
| 433 |
-
log.warning("npm 安裝 pi 失敗,改用官方安裝腳本備援: %s", err[-300:])
|
| 434 |
-
rc, out, err = await _run("curl -fsSL https://pi.dev/install.sh | sh")
|
| 435 |
-
if rc != 0:
|
| 436 |
-
log.error("❌ pi coding agent 安裝失敗: %s", err[-500:])
|
| 437 |
-
return
|
| 438 |
|
| 439 |
-
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
"providers": {
|
| 442 |
PI_PROVIDER_ID: {
|
| 443 |
"baseUrl": f"http://127.0.0.1:{LLAMA_PORT}/v1",
|
| 444 |
"api": "openai-completions",
|
| 445 |
-
"apiKey": "not-needed",
|
| 446 |
"models": [
|
| 447 |
{
|
| 448 |
"id": PI_MODEL_ID,
|
| 449 |
-
"name": "Qwen3.6 35B A3B (本
|
| 450 |
"reasoning": False,
|
| 451 |
-
"input": ["text", "image"]
|
| 452 |
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
|
| 453 |
"contextWindow": 16384,
|
| 454 |
"maxTokens": 4096,
|
|
@@ -457,215 +441,116 @@ async def install_pi_agent():
|
|
| 457 |
}
|
| 458 |
}
|
| 459 |
}
|
| 460 |
-
|
| 461 |
-
json.dump(models_config, f, ensure_ascii=False, indent=2)
|
| 462 |
|
| 463 |
-
|
| 464 |
-
f.write(f'\nalias pi-local="pi --provider {PI_PROVIDER_ID} --model {PI_MODEL_ID}"\n')
|
| 465 |
|
| 466 |
-
|
| 467 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
|
| 469 |
-
|
| 470 |
-
for path in glob.glob(os.path.join(root, "**", name), recursive=True):
|
| 471 |
-
if os.access(path, os.X_OK) or path.endswith(name):
|
| 472 |
-
return path
|
| 473 |
-
return None
|
| 474 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
|
| 476 |
-
|
| 477 |
-
os.makedirs(dest, exist_ok=True)
|
| 478 |
-
if archive_path.endswith(".zip"):
|
| 479 |
-
with zipfile.ZipFile(archive_path) as z:
|
| 480 |
-
z.extractall(dest)
|
| 481 |
-
else:
|
| 482 |
-
with tarfile.open(archive_path) as t:
|
| 483 |
-
t.extractall(dest)
|
| 484 |
|
|
|
|
| 485 |
|
| 486 |
-
|
| 487 |
-
global LLAMA_SERVER_BIN
|
| 488 |
-
if os.path.isfile(LLAMA_SERVER_BIN) and os.access(LLAMA_SERVER_BIN, os.X_OK):
|
| 489 |
-
return True
|
| 490 |
|
| 491 |
-
|
| 492 |
-
archive = os.path.join(LLAMA_DIR, "llama-cpp-release.archive")
|
| 493 |
|
| 494 |
-
|
| 495 |
-
log.info("下載 llama.cpp: %s", LLAMA_CPP_URL)
|
| 496 |
-
rc, out, err = await _run(f"curl -fL --retry 3 -o {archive} '{LLAMA_CPP_URL}'")
|
| 497 |
|
| 498 |
-
|
| 499 |
-
log.warning("指定版本下載失敗,改用 GitHub API 尋找可用的 ubuntu-x64 資產作為備援")
|
| 500 |
-
llm_status.update(stage="downloading_llama_cpp_fallback", detail="")
|
| 501 |
-
rc2, out2, err2 = await _run(
|
| 502 |
-
f"curl -fsSL https://api.github.com/repos/ggml-org/llama.cpp/releases/tags/{LLAMA_RELEASE_TAG}"
|
| 503 |
-
)
|
| 504 |
-
asset_url = None
|
| 505 |
-
if rc2 == 0:
|
| 506 |
-
try:
|
| 507 |
-
data = json.loads(out2)
|
| 508 |
-
for a in data.get("assets", []):
|
| 509 |
-
n = a.get("name", "")
|
| 510 |
-
if "ubuntu-x64" in n and ("cpu" in n.lower() or "vulkan" not in n.lower()) and (n.endswith(".tar.gz") or n.endswith(".zip")):
|
| 511 |
-
asset_url = a.get("browser_download_url")
|
| 512 |
-
break
|
| 513 |
-
if not asset_url:
|
| 514 |
-
for a in data.get("assets", []):
|
| 515 |
-
n = a.get("name", "")
|
| 516 |
-
if "ubuntu-x64" in n and (n.endswith(".tar.gz") or n.endswith(".zip")):
|
| 517 |
-
asset_url = a.get("browser_download_url")
|
| 518 |
-
break
|
| 519 |
-
except Exception as e:
|
| 520 |
-
log.error("解析 GitHub release JSON 失敗: %s", e)
|
| 521 |
-
if not asset_url:
|
| 522 |
-
log.error("❌ 找不到可用的 llama.cpp ubuntu-x64 release,安裝終止")
|
| 523 |
-
llm_status.update(stage="failed", detail="no matching llama.cpp release asset")
|
| 524 |
-
return False
|
| 525 |
-
archive = os.path.join(LLAMA_DIR, os.path.basename(asset_url))
|
| 526 |
-
rc3, out3, err3 = await _run(f"curl -fL --retry 3 -o {archive} '{asset_url}'")
|
| 527 |
-
if rc3 != 0:
|
| 528 |
-
log.error("❌ 備援下載也失敗: %s", err3[-500:])
|
| 529 |
-
llm_status.update(stage="failed", detail="llama.cpp download failed")
|
| 530 |
-
return False
|
| 531 |
-
|
| 532 |
-
log.info("解壓縮 llama.cpp ...")
|
| 533 |
-
try:
|
| 534 |
-
await _extract_archive(archive, LLAMA_DIR)
|
| 535 |
-
except Exception as e:
|
| 536 |
-
log.error("❌ 解壓縮失敗: %s", e)
|
| 537 |
-
llm_status.update(stage="failed", detail=f"extract failed: {e}")
|
| 538 |
-
return False
|
| 539 |
|
| 540 |
-
|
| 541 |
-
if not found:
|
| 542 |
-
log.error("❌ 解壓後找不到 llama-server 執行檔")
|
| 543 |
-
llm_status.update(stage="failed", detail="llama-server binary not found after extract")
|
| 544 |
-
return False
|
| 545 |
|
| 546 |
-
LLAMA_SERVER_BIN = found
|
| 547 |
-
os.chmod(LLAMA_SERVER_BIN, 0o755)
|
| 548 |
-
# 讓同目錄下的 .so 也可被找到
|
| 549 |
-
libdir = os.path.dirname(LLAMA_SERVER_BIN)
|
| 550 |
-
env_file = os.path.join(HOME, ".bashrc")
|
| 551 |
-
with open(env_file, "a") as f:
|
| 552 |
-
f.write(f'\nexport LD_LIBRARY_PATH="{libdir}:$LD_LIBRARY_PATH"\n')
|
| 553 |
-
log.info("✅ llama.cpp 就緒: %s", LLAMA_SERVER_BIN)
|
| 554 |
-
return True
|
| 555 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
llm_status.update(stage="failed", detail=f"{label} download failed: {err[-300:]}")
|
| 567 |
return False
|
| 568 |
-
log.info("✅ %s 下載完成", label)
|
| 569 |
-
return True
|
| 570 |
-
|
| 571 |
|
| 572 |
-
|
| 573 |
-
os.makedirs(MODEL_DIR, exist_ok=True)
|
| 574 |
-
llm_status.update(stage="downloading_model", detail=MODEL_URL)
|
| 575 |
-
if not await _download_with_resume(MODEL_URL, MODEL_PATH, "主模型 (GGUF)"):
|
| 576 |
-
return False
|
| 577 |
-
llm_status.update(stage="downloading_mmproj", detail=MMPROJ_URL)
|
| 578 |
-
if not await _download_with_resume(MMPROJ_URL, MMPROJ_PATH, "mmproj (視覺投影層)"):
|
| 579 |
-
# mmproj 失敗不致命,純文字推論仍可運作,記錄警告即可繼續
|
| 580 |
-
log.warning("⚠️ mmproj 下載失敗,將以純文字模式啟動(不支援圖片輸入)")
|
| 581 |
-
llm_status.update(stage="downloading_model", detail="mmproj failed, continuing text-only")
|
| 582 |
-
return True
|
| 583 |
|
|
|
|
|
|
|
| 584 |
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
["nvidia-smi"], capture_output=True
|
| 588 |
-
).returncode == 0
|
| 589 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
|
|
|
| 595 |
|
| 596 |
-
cmd = [
|
| 597 |
-
LLAMA_SERVER_BIN,
|
| 598 |
-
"--model", MODEL_PATH,
|
| 599 |
-
"--host", "127.0.0.1",
|
| 600 |
-
"--port", str(LLAMA_PORT),
|
| 601 |
-
"--mlock", # 要求鎖住模型於實體記憶體,避免被 swap 拖慢速度
|
| 602 |
-
"--ctx-size", "32767",
|
| 603 |
-
"--batch-size", "2048", # 提高 prompt processing 吞吐量
|
| 604 |
-
"--ubatch-size", "512",
|
| 605 |
-
"--threads", str(cpu_count),
|
| 606 |
-
"--threads-batch", str(cpu_count),
|
| 607 |
-
"--jinja", # 使用內建 chat template
|
| 608 |
-
"--cache-type-k", "q8_0", # 量化 KV cache:省記憶體、可撐更長 context 而不掉太多品質
|
| 609 |
-
"--cache-type-v", "q8_0",
|
| 610 |
-
"--parallel", "2", # 已不用 MTP,開放併發 slot 拉高整體吞吐量
|
| 611 |
-
"--no-mmap", # 搭配 --mlock 使用,避免 mmap 造成的延遲抖動
|
| 612 |
-
]
|
| 613 |
-
cmd += ["--n-gpu-layers", "99" if gpu else "0"]
|
| 614 |
-
if os.path.isfile(MMPROJ_PATH):
|
| 615 |
-
cmd += ["--mmproj", MMPROJ_PATH]
|
| 616 |
-
if gpu:
|
| 617 |
-
cmd += ["--mmproj-offload"]
|
| 618 |
-
|
| 619 |
-
log.info("啟動 llama-server (GPU=%s): %s", gpu, " ".join(cmd))
|
| 620 |
-
llm_status.update(stage="starting_server", detail="")
|
| 621 |
-
|
| 622 |
-
logf = open(LLAMA_LOG, "ab", buffering=0)
|
| 623 |
-
llama_process = await asyncio.create_subprocess_exec(
|
| 624 |
-
*cmd, stdout=logf, stderr=subprocess.STDOUT,
|
| 625 |
-
cwd=os.path.dirname(LLAMA_SERVER_BIN),
|
| 626 |
-
)
|
| 627 |
|
| 628 |
-
|
|
|
|
|
|
|
|
|
|
| 629 |
async with httpx.AsyncClient() as client:
|
| 630 |
-
for _ in range(
|
| 631 |
-
if llama_process.returncode is not None:
|
| 632 |
-
log.error("❌ llama-server 提前結束,returncode=%s,詳見 %s", llama_process.returncode, LLAMA_LOG)
|
| 633 |
-
llm_status.update(stage="failed", detail="llama-server exited early")
|
| 634 |
-
return
|
| 635 |
try:
|
| 636 |
-
r = await client.get(f"http://127.0.0.1:{LLAMA_PORT}/health", timeout=
|
| 637 |
if r.status_code == 200:
|
| 638 |
-
log.info("✅ llama-server 已就
|
| 639 |
llm_status.update(stage="ready", detail=f"gpu={gpu}")
|
| 640 |
llama_ready.set()
|
| 641 |
return
|
| 642 |
except Exception:
|
| 643 |
pass
|
| 644 |
-
await asyncio.sleep(
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
llm_status.update(stage="failed", detail="startup timeout")
|
| 648 |
|
| 649 |
|
| 650 |
async def setup_llm_pipeline():
|
| 651 |
try:
|
| 652 |
-
|
| 653 |
-
ok = await install_llama_cpp()
|
| 654 |
if not ok:
|
| 655 |
return
|
| 656 |
-
|
| 657 |
-
if not ok:
|
| 658 |
-
return
|
| 659 |
-
asyncio.create_task(install_pi_agent()) # 不需等待 server 啟動即可設定
|
| 660 |
-
await start_llama_server()
|
| 661 |
except Exception:
|
| 662 |
-
log.exception("LLM 安
|
| 663 |
llm_status.update(stage="failed", detail="unexpected exception, see server log")
|
| 664 |
|
| 665 |
|
| 666 |
@spaces.GPU(duration=1, queue=False)
|
| 667 |
def dummy_gpu():
|
| 668 |
-
"""空函
|
| 669 |
return
|
| 670 |
|
| 671 |
|
|
@@ -679,23 +564,25 @@ async def startup_event():
|
|
| 679 |
|
| 680 |
@app.on_event("shutdown")
|
| 681 |
async def shutdown_event():
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
|
|
|
|
|
|
| 687 |
|
| 688 |
|
| 689 |
# ──────────────────────────── Terminal WebSocket ──────────────────────────────
|
| 690 |
|
| 691 |
async def _reap_process(pid: int, loop: asyncio.AbstractEventLoop):
|
| 692 |
-
"""非阻塞地
|
| 693 |
for sig in (signal.SIGTERM, signal.SIGKILL):
|
| 694 |
try:
|
| 695 |
os.kill(pid, sig)
|
| 696 |
except ProcessLookupError:
|
| 697 |
return
|
| 698 |
-
for _ in range(20):
|
| 699 |
await asyncio.sleep(0.1)
|
| 700 |
try:
|
| 701 |
wpid, _status = os.waitpid(pid, os.WNOHANG)
|
|
@@ -703,7 +590,6 @@ async def _reap_process(pid: int, loop: asyncio.AbstractEventLoop):
|
|
| 703 |
return
|
| 704 |
except ChildProcessError:
|
| 705 |
return
|
| 706 |
-
# 最後手段:拿到獨立執行緒去做阻塞式 waitpid,不會拖住主 event loop
|
| 707 |
try:
|
| 708 |
await loop.run_in_executor(None, os.waitpid, pid, 0)
|
| 709 |
except Exception:
|
|
@@ -717,10 +603,9 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 717 |
try:
|
| 718 |
pid, fd = pty.fork()
|
| 719 |
except OSError as e:
|
| 720 |
-
# 例如 pty/行程數已達上限;明確告知使用者而不是讓連線悄悄失敗
|
| 721 |
await websocket.send_text(json.dumps({
|
| 722 |
"type": "system",
|
| 723 |
-
"data": f"\r\n\x1b[31m[❌
|
| 724 |
}))
|
| 725 |
await websocket.close()
|
| 726 |
return
|
|
@@ -735,10 +620,9 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 735 |
if opencode_bin not in current_path:
|
| 736 |
os.environ["PATH"] = f"{current_path}:{opencode_bin}"
|
| 737 |
|
| 738 |
-
# 智能目錄追蹤:每次顯示提示符時透過 OSC 0 序列回報目前目錄
|
| 739 |
banner = (
|
| 740 |
-
'echo -e "\\033[36m
|
| 741 |
-
f'本
|
| 742 |
)
|
| 743 |
bash_cmd = (
|
| 744 |
f"{banner} "
|
|
@@ -814,29 +698,27 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 814 |
with open(path, "wb") as f:
|
| 815 |
f.write(data)
|
| 816 |
|
| 817 |
-
# 搬到執行緒池執行,避免大檔案上傳時卡住整個 event loop
|
| 818 |
await asyncio.to_thread(_write)
|
| 819 |
await websocket.send_text(json.dumps({
|
| 820 |
"type": "system",
|
| 821 |
-
"data": f"\r\n\x1b[32m[✅
|
| 822 |
}))
|
| 823 |
except Exception as e:
|
| 824 |
await websocket.send_text(json.dumps({
|
| 825 |
"type": "system",
|
| 826 |
-
"data": f"\r\n\x1b[31m[❌ 上
|
| 827 |
}))
|
| 828 |
|
| 829 |
except (WebSocketDisconnect, RuntimeError):
|
| 830 |
pass
|
| 831 |
except Exception:
|
| 832 |
-
log.exception("terminal_ws
|
| 833 |
finally:
|
| 834 |
try:
|
| 835 |
loop.remove_reader(fd)
|
| 836 |
except Exception:
|
| 837 |
pass
|
| 838 |
sender.cancel()
|
| 839 |
-
# ✅ 非阻塞回收子行程:修正「關閉分頁後新分頁無法使用」的核心 bug
|
| 840 |
await _reap_process(pid, loop)
|
| 841 |
try:
|
| 842 |
os.close(fd)
|
|
|
|
| 7 |
import asyncio
|
| 8 |
import json
|
| 9 |
import base64
|
|
|
|
| 10 |
import logging
|
| 11 |
import shutil
|
| 12 |
import subprocess
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
import spaces
|
| 15 |
import httpx
|
|
|
|
| 34 |
LLAMA_RELEASE_TAG = "b10536"
|
| 35 |
LLAMA_CPP_URL = f"https://github.com/ggml-org/llama.cpp/releases/download/{LLAMA_RELEASE_TAG}/llama-{LLAMA_RELEASE_TAG}-bin-ubuntu-x64.tar.gz"
|
| 36 |
|
|
|
|
|
|
|
| 37 |
MODEL_DIR = os.path.join(HOME, "models")
|
| 38 |
MODEL_PATH = os.path.join(MODEL_DIR, "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf")
|
| 39 |
MODEL_URL = "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/resolve/main/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"
|
|
|
|
| 43 |
LLAMA_PORT = 8081
|
| 44 |
LLAMA_LOG = os.path.join(HOME, "llama-server.log")
|
| 45 |
|
|
|
|
|
|
|
| 46 |
PI_MODELS_JSON = os.path.join(HOME, ".pi", "agent", "models.json")
|
| 47 |
PI_PROVIDER_ID = "llama-cpp-local"
|
| 48 |
PI_MODEL_ID = "qwen3.6-35b-a3b"
|
| 49 |
|
|
|
|
| 50 |
llama_ready = asyncio.Event()
|
| 51 |
llm_status = {"stage": "not_started", "detail": ""}
|
| 52 |
|
|
|
|
| 113 |
return `${p}//${location.host}/ws`;
|
| 114 |
}
|
| 115 |
|
|
|
|
| 116 |
function connect(tabObj) {
|
| 117 |
const ws = new WebSocket(wsUrl());
|
| 118 |
tabObj.ws = ws;
|
|
|
|
| 217 |
const i = tabs.findIndex(t => t.id === id);
|
| 218 |
if (i < 0) return;
|
| 219 |
const t = tabs[i];
|
| 220 |
+
t.closed = true;
|
| 221 |
if (t.ws) t.ws.close();
|
| 222 |
t.term.dispose();
|
| 223 |
t.el.remove();
|
|
|
|
| 281 |
fileInput.value = '';
|
| 282 |
};
|
| 283 |
|
|
|
|
|
|
|
| 284 |
downloadBtn.onclick = () => {
|
| 285 |
const t = activeTab();
|
| 286 |
if (!t) return;
|
|
|
|
| 350 |
return JSONResponse({"error": "path not allowed"}, status_code=403)
|
| 351 |
if not os.path.isfile(safe):
|
| 352 |
return JSONResponse({"error": "file not found"}, status_code=404)
|
|
|
|
|
|
|
| 353 |
return FileResponse(safe, filename=os.path.basename(safe))
|
| 354 |
|
| 355 |
|
|
|
|
| 356 |
@app.api_route("/v1/{path:path}", methods=["GET", "POST", "OPTIONS"])
|
| 357 |
async def llm_proxy(path: str, request: Request):
|
| 358 |
if not llama_ready.is_set():
|
|
|
|
| 370 |
async for chunk in resp.aiter_bytes():
|
| 371 |
yield chunk
|
| 372 |
|
|
|
|
|
|
|
| 373 |
return StreamingResponse(relay(), media_type="text/event-stream")
|
| 374 |
|
| 375 |
|
| 376 |
# ──────────────────────────── 背景安裝流程 ────────────────────────────
|
| 377 |
|
| 378 |
async def _run(cmd, cwd=None, env=None):
|
| 379 |
+
"""非阻塞地执行一个 shell 指令,回传 (returncode, stdout, stderr)。"""
|
| 380 |
proc = await asyncio.create_subprocess_shell(
|
| 381 |
cmd, cwd=cwd, env=env,
|
| 382 |
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
|
|
| 386 |
|
| 387 |
|
| 388 |
async def install_opencode():
|
| 389 |
+
log.info("正在背景安装 opencode...")
|
| 390 |
rc, out, err = await _run("curl -fsSL https://opencode.ai/install | bash")
|
| 391 |
if rc == 0:
|
| 392 |
+
log.info("✅ opencode 安装成功")
|
| 393 |
else:
|
| 394 |
+
log.warning("⚠️ opencode 安装失败或已存在: %s", err[-500:])
|
| 395 |
|
| 396 |
|
| 397 |
async def install_editors():
|
| 398 |
+
"""安装 vim / neovim。"""
|
| 399 |
+
log.info("正在安装 vim / neovim ...")
|
| 400 |
for prefix in ("", "sudo "):
|
| 401 |
rc, out, err = await _run(f"{prefix}apt-get update -y && {prefix}apt-get install -y vim neovim")
|
| 402 |
if rc == 0:
|
| 403 |
+
log.info("✅ vim / neovim 安装成功 (prefix=%r)", prefix)
|
| 404 |
await _run("update-alternatives --set editor /usr/bin/vim.basic || true")
|
| 405 |
with open(os.path.join(HOME, ".bashrc"), "a") as f:
|
| 406 |
f.write('\nexport EDITOR=vim\nalias vi="vim"\n')
|
| 407 |
return
|
| 408 |
+
log.error("❌ vim / neovim 安装失败(apt 与 sudo apt 皆失败),请检查容器权限与套件来源")
|
| 409 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
|
| 411 |
+
def _has_gpu() -> bool:
|
| 412 |
+
return shutil.which("nvidia-smi") is not None and subprocess.run(
|
| 413 |
+
["nvidia-smi"], capture_output=True
|
| 414 |
+
).returncode == 0
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
OPENCODE_WORKDIR = os.path.join(HOME, "opencode-llm-setup")
|
| 418 |
+
OPENCODE_LOG = os.path.join(HOME, "opencode-llm-setup.log")
|
| 419 |
+
LLAMA_PID_FILE = os.path.join(LLAMA_DIR, "llama-server.pid")
|
| 420 |
+
# OPENCODE_MODEL = "opencode/grok-code" # 已移除,改用预设模型
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def _build_install_task_prompt() -> str:
|
| 424 |
+
pi_models_config = {
|
| 425 |
"providers": {
|
| 426 |
PI_PROVIDER_ID: {
|
| 427 |
"baseUrl": f"http://127.0.0.1:{LLAMA_PORT}/v1",
|
| 428 |
"api": "openai-completions",
|
| 429 |
+
"apiKey": "not-needed",
|
| 430 |
"models": [
|
| 431 |
{
|
| 432 |
"id": PI_MODEL_ID,
|
| 433 |
+
"name": "Qwen3.6 35B A3B (本机 llama.cpp)",
|
| 434 |
"reasoning": False,
|
| 435 |
+
"input": ["text", "image"],
|
| 436 |
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
|
| 437 |
"contextWindow": 16384,
|
| 438 |
"maxTokens": 4096,
|
|
|
|
| 441 |
}
|
| 442 |
}
|
| 443 |
}
|
| 444 |
+
pi_models_json_str = json.dumps(pi_models_config, ensure_ascii=False, indent=2)
|
|
|
|
| 445 |
|
| 446 |
+
return f"""你是系统维运助手,请在这台 Linux 主机上完成以下目标。过程中遇到任何错误(如文件不存在、路径不同、缺函数库、权限不足等),请自行判断原因并尝试修正、改用替代方案或重试,直到成功为止。不要因为单次失败就放弃。
|
|
|
|
| 447 |
|
| 448 |
+
**目标清单:**
|
| 449 |
|
| 450 |
+
1. **安装并启动 llama.cpp**:让 llama-server 在端口 {LLAMA_PORT} 上运行,并提供 OpenAI 兼容 API。
|
| 451 |
+
- 你可以从官方 release 下载,或从源码编译,或使用 apt/pip 等其他方式,只要能成功运行即可。
|
| 452 |
+
- 模型文件请从 {MODEL_URL} 下载(支持断点续传),保存到 {MODEL_PATH}。
|
| 453 |
+
- 如果下载失败,可以尝试其他镜像源或使用其他兼容模型。
|
| 454 |
+
- mmproj 文件 {MMPROJ_URL} 可选,如果下载失败不影响纯文本功能。
|
| 455 |
+
- 启动参数请根据本机硬件(CPU/内存/GPU)自行优化,确保服务稳定运行。
|
| 456 |
+
- 服务启动后,轮询 http://127.0.0.1:{LLAMA_PORT}/health 直到返回 200。
|
| 457 |
|
| 458 |
+
2. **安装 pi coding agent**:执行 `curl -fsSL https://pi.dev/install.sh | sh` 安装。
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
|
| 460 |
+
3. **配置 pi 使用本机 llama.cpp**:将以下配置写入 {PI_MODELS_JSON}(如果文件已存在,请合并而非覆盖):
|
| 461 |
+
```json
|
| 462 |
+
{pi_models_json_str}
|
| 463 |
+
添加便捷别名:在 ~/.bashrc 中添加 alias pi-local="pi --provider {PI_PROVIDER_ID} --model {PI_MODEL_ID}"(如果尚未存在)。
|
| 464 |
|
| 465 |
+
约束条件:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
|
| 467 |
+
llama-server 必须以后台守护进程方式运行(使用 nohup、systemd 或 screen 等方式),不依附于当前 session。
|
| 468 |
|
| 469 |
+
服务进程的 PID 请写入 {LLAMA_PID_FILE},方便后续管理。
|
|
|
|
|
|
|
|
|
|
| 470 |
|
| 471 |
+
日志输出到 {LLAMA_LOG} 以便调试。
|
|
|
|
| 472 |
|
| 473 |
+
完成标准:
|
|
|
|
|
|
|
| 474 |
|
| 475 |
+
所有目标都已达成。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
|
| 477 |
+
请用简短的条列式总结每项目标的最终状态(成功/失败及原因)。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
|
| 480 |
+
async def run_opencode_install_task() -> bool:
|
| 481 |
+
"""委派 opencode agent 自动完成 llama.cpp + pi 的安装与设定(使用预设模型与 --auto 模式)。"""
|
| 482 |
+
llm_status.update(stage="waiting_for_opencode", detail="")
|
| 483 |
+
opencode_bin_dir = os.path.expanduser("~/.opencode/bin")
|
| 484 |
+
env = {**os.environ, "PATH": f"{os.environ.get('PATH', '')}:{opencode_bin_dir}"}
|
| 485 |
|
| 486 |
+
# 等待 opencode 安装完成(最长 10 分钟)
|
| 487 |
+
for _ in range(120):
|
| 488 |
+
rc, _out, _err = await _run("command -v opencode", env=env)
|
| 489 |
+
if rc == 0:
|
| 490 |
+
break
|
| 491 |
+
await asyncio.sleep(5)
|
| 492 |
+
else:
|
| 493 |
+
log.error("❌ opencode 尚未安装完成,无法委派安装任务")
|
| 494 |
+
llm_status.update(stage="failed", detail="opencode not available")
|
|
|
|
| 495 |
return False
|
|
|
|
|
|
|
|
|
|
| 496 |
|
| 497 |
+
# 不再写入配置文件,依靠 --auto 实现自动权限
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
|
| 499 |
+
os.makedirs(OPENCODE_WORKDIR, exist_ok=True)
|
| 500 |
+
prompt = _build_install_task_prompt()
|
| 501 |
|
| 502 |
+
log.info("委派 opencode 安装 llama.cpp / pi ,过程请见 %s", OPENCODE_LOG)
|
| 503 |
+
llm_status.update(stage="opencode_installing", detail=f"log: {OPENCODE_LOG}")
|
|
|
|
|
|
|
| 504 |
|
| 505 |
+
with open(OPENCODE_LOG, "ab", buffering=0) as logf:
|
| 506 |
+
proc = await asyncio.create_subprocess_exec(
|
| 507 |
+
"opencode", "run", "--auto", prompt, # 使用预设模型,启用 --auto
|
| 508 |
+
cwd=OPENCODE_WORKDIR, env=env,
|
| 509 |
+
stdout=logf, stderr=subprocess.STDOUT,
|
| 510 |
+
)
|
| 511 |
+
await proc.wait()
|
| 512 |
|
| 513 |
+
if proc.returncode != 0:
|
| 514 |
+
log.warning("opencode 安装任务结束,returncode=%s(不一定失败,详请见 %s)", proc.returncode, OPENCODE_LOG)
|
| 515 |
+
else:
|
| 516 |
+
log.info("✅ opencode 安装任务执行完毕,详请见 %s", OPENCODE_LOG)
|
| 517 |
+
return True
|
| 518 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
|
| 520 |
+
async def wait_for_llama_ready(timeout_seconds: int = 3600):
|
| 521 |
+
"""不管 llama-server 是被谁启动的,这里只负责轮询 /health 直到就绪或逾时。"""
|
| 522 |
+
llm_status.update(stage="waiting_for_llama_server", detail="")
|
| 523 |
+
gpu = _has_gpu()
|
| 524 |
async with httpx.AsyncClient() as client:
|
| 525 |
+
for _ in range(timeout_seconds // 5):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
try:
|
| 527 |
+
r = await client.get(f"http://127.0.0.1:{LLAMA_PORT}/health", timeout=3)
|
| 528 |
if r.status_code == 200:
|
| 529 |
+
log.info("✅ llama-server 已就绪,OpenAI API: http://127.0.0.1:%s/v1", LLAMA_PORT)
|
| 530 |
llm_status.update(stage="ready", detail=f"gpu={gpu}")
|
| 531 |
llama_ready.set()
|
| 532 |
return
|
| 533 |
except Exception:
|
| 534 |
pass
|
| 535 |
+
await asyncio.sleep(5)
|
| 536 |
+
log.error("❌ 等待 llama-server 就绪逾时,请检查 %s 与 %s 的内容", OPENCODE_LOG, LLAMA_LOG)
|
| 537 |
+
llm_status.update(stage="failed", detail="llama-server did not become ready in time; check logs")
|
|
|
|
| 538 |
|
| 539 |
|
| 540 |
async def setup_llm_pipeline():
|
| 541 |
try:
|
| 542 |
+
ok = await run_opencode_install_task()
|
|
|
|
| 543 |
if not ok:
|
| 544 |
return
|
| 545 |
+
await wait_for_llama_ready()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
except Exception:
|
| 547 |
+
log.exception("LLM 安装/启动流程发生未预期错误")
|
| 548 |
llm_status.update(stage="failed", detail="unexpected exception, see server log")
|
| 549 |
|
| 550 |
|
| 551 |
@spaces.GPU(duration=1, queue=False)
|
| 552 |
def dummy_gpu():
|
| 553 |
+
"""空函数,仅用于让平台识别到 GPU 装饰器,满足 Hugging Face 要求"""
|
| 554 |
return
|
| 555 |
|
| 556 |
|
|
|
|
| 564 |
|
| 565 |
@app.on_event("shutdown")
|
| 566 |
async def shutdown_event():
|
| 567 |
+
try:
|
| 568 |
+
if os.path.isfile(LLAMA_PID_FILE):
|
| 569 |
+
with open(LLAMA_PID_FILE) as f:
|
| 570 |
+
pid = int(f.read().strip())
|
| 571 |
+
os.kill(pid, signal.SIGTERM)
|
| 572 |
+
except Exception:
|
| 573 |
+
pass
|
| 574 |
|
| 575 |
|
| 576 |
# ──────────────────────────── Terminal WebSocket ──────────────────────────────
|
| 577 |
|
| 578 |
async def _reap_process(pid: int, loop: asyncio.AbstractEventLoop):
|
| 579 |
+
"""非阻塞地终止并回收子行程,避免卡住整个 event loop。"""
|
| 580 |
for sig in (signal.SIGTERM, signal.SIGKILL):
|
| 581 |
try:
|
| 582 |
os.kill(pid, sig)
|
| 583 |
except ProcessLookupError:
|
| 584 |
return
|
| 585 |
+
for _ in range(20):
|
| 586 |
await asyncio.sleep(0.1)
|
| 587 |
try:
|
| 588 |
wpid, _status = os.waitpid(pid, os.WNOHANG)
|
|
|
|
| 590 |
return
|
| 591 |
except ChildProcessError:
|
| 592 |
return
|
|
|
|
| 593 |
try:
|
| 594 |
await loop.run_in_executor(None, os.waitpid, pid, 0)
|
| 595 |
except Exception:
|
|
|
|
| 603 |
try:
|
| 604 |
pid, fd = pty.fork()
|
| 605 |
except OSError as e:
|
|
|
|
| 606 |
await websocket.send_text(json.dumps({
|
| 607 |
"type": "system",
|
| 608 |
+
"data": f"\r\n\x1b[31m[❌ 无法建立新终端机 (pty.fork 失败: {e}),请稍后再试或关闭其他分页]\x1b[0m\r\n",
|
| 609 |
}))
|
| 610 |
await websocket.close()
|
| 611 |
return
|
|
|
|
| 620 |
if opencode_bin not in current_path:
|
| 621 |
os.environ["PATH"] = f"{current_path}:{opencode_bin}"
|
| 622 |
|
|
|
|
| 623 |
banner = (
|
| 624 |
+
'echo -e "\\033[36m欢迎使用 Web Terminal|vim/nvim 已安装|'
|
| 625 |
+
f'本机 LLM OpenAI API: /v1 (llama.cpp, 就绪后可用)|输入 pi-local 启动 pi coding agent\\033[0m";'
|
| 626 |
)
|
| 627 |
bash_cmd = (
|
| 628 |
f"{banner} "
|
|
|
|
| 698 |
with open(path, "wb") as f:
|
| 699 |
f.write(data)
|
| 700 |
|
|
|
|
| 701 |
await asyncio.to_thread(_write)
|
| 702 |
await websocket.send_text(json.dumps({
|
| 703 |
"type": "system",
|
| 704 |
+
"data": f"\r\n\x1b[32m[✅ 档案已成功上传至: {filepath}]\x1b[0m\r\n",
|
| 705 |
}))
|
| 706 |
except Exception as e:
|
| 707 |
await websocket.send_text(json.dumps({
|
| 708 |
"type": "system",
|
| 709 |
+
"data": f"\r\n\x1b[31m[❌ 上传失败: {e}]\x1b[0m\r\n",
|
| 710 |
}))
|
| 711 |
|
| 712 |
except (WebSocketDisconnect, RuntimeError):
|
| 713 |
pass
|
| 714 |
except Exception:
|
| 715 |
+
log.exception("terminal_ws 循环发生未预期错误")
|
| 716 |
finally:
|
| 717 |
try:
|
| 718 |
loop.remove_reader(fd)
|
| 719 |
except Exception:
|
| 720 |
pass
|
| 721 |
sender.cancel()
|
|
|
|
| 722 |
await _reap_process(pid, loop)
|
| 723 |
try:
|
| 724 |
os.close(fd)
|