HelloSun commited on
Commit
2f0fff6
·
verified ·
1 Parent(s): 9d595f4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +109 -227
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
- """非阻塞地執行一個 shell 指令,回傳 (returncode, stdout, stderr)。"""
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("正在背景安裝 opencode...")
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 安裝失敗或已存在: %s", err[-500:])
412
 
413
 
414
  async def install_editors():
415
- """安裝 vim / neovim(操作手感與 vim 一致),apt 不可用時記錄清楚原因而非靜默失敗。"""
416
- log.info("正在安裝 vim / neovim ...")
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 安裝成功 (prefix=%r)", prefix)
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 安裝失敗(apt 與 sudo apt 皆失敗),請檢查容器權限與套件來源")
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
- os.makedirs(os.path.dirname(PI_MODELS_JSON), exist_ok=True)
440
- models_config = {
 
 
 
 
 
 
 
 
 
 
 
 
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", # llama-server 預設不驗證金鑰,這裡只是佔位字串
446
  "models": [
447
  {
448
  "id": PI_MODEL_ID,
449
- "name": "Qwen3.6 35B A3B (本機 llama.cpp)",
450
  "reasoning": False,
451
- "input": ["text", "image"] if os.path.isfile(MMPROJ_PATH) else ["text"],
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
- with open(PI_MODELS_JSON, "w") as f:
461
- json.dump(models_config, f, ensure_ascii=False, indent=2)
462
 
463
- with open(os.path.join(HOME, ".bashrc"), "a") as f:
464
- f.write(f'\nalias pi-local="pi --provider {PI_PROVIDER_ID} --model {PI_MODEL_ID}"\n')
465
 
466
- log.info("✅ pi coding agent 已設定完成,可用 `pi-local` 指令連上本機 llama.cpp OpenAI API")
467
 
 
 
 
 
 
 
 
468
 
469
- def _find_binary(root: str, name: str) -> str | None:
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
- async def _extract_archive(archive_path: str, dest: str):
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
- async def install_llama_cpp() -> bool:
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
- os.makedirs(LLAMA_DIR, exist_ok=True)
492
- archive = os.path.join(LLAMA_DIR, "llama-cpp-release.archive")
493
 
494
- llm_status.update(stage="downloading_llama_cpp", detail=LLAMA_CPP_URL)
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
- if rc != 0 or not os.path.exists(archive) or os.path.getsize(archive) < 1024:
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
- found = _find_binary(LLAMA_DIR, "llama-server")
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
- async def _download_with_resume(url: str, dest: str, label: str) -> bool:
558
- if os.path.isfile(dest) and os.path.getsize(dest) > 1024 * 1024:
559
- log.info("%s 已存在,略過下載", label)
560
- return True
561
- log.info("下載 %s(支援中斷續傳): %s", label, url)
562
- # -C - 支援中斷續傳;即使下載中途失敗,重啟後也能接續,不需重頭來過
563
- rc, out, err = await _run(f"curl -fL --retry 5 --retry-delay 5 -C - -o {dest} '{url}'")
564
- if rc != 0:
565
- log.error("❌ %s 下載失敗: %s", label, err[-800:])
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
- async def download_model() -> bool:
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
- def _has_gpu() -> bool:
586
- return shutil.which("nvidia-smi") is not None and subprocess.run(
587
- ["nvidia-smi"], capture_output=True
588
- ).returncode == 0
589
 
 
 
 
 
 
 
 
590
 
591
- async def start_llama_server():
592
- global llama_process
593
- cpu_count = os.cpu_count() or 4
594
- gpu = _has_gpu()
 
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
- # 等待 server 就緒(輪詢 health endpoint),最多等 20 分鐘(大模型載入需要時間)
 
 
 
629
  async with httpx.AsyncClient() as client:
630
- for _ in range(400):
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=2)
637
  if r.status_code == 200:
638
- log.info("✅ llama-server 已就緒,OpenAI API: http://127.0.0.1:%s/v1", LLAMA_PORT)
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(3)
645
-
646
- log.error("❌ llama-server 逾時未就緒")
647
- llm_status.update(stage="failed", detail="startup timeout")
648
 
649
 
650
  async def setup_llm_pipeline():
651
  try:
652
- # 確保代理需要的 httpx 已安裝(一般環境已具備,這裡是保險)
653
- ok = await install_llama_cpp()
654
  if not ok:
655
  return
656
- ok = await download_model()
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
- """空函數,僅用於讓平台識別到 GPU 裝飾器,滿足 Hugging Face 要求"""
669
  return
670
 
671
 
@@ -679,23 +564,25 @@ async def startup_event():
679
 
680
  @app.on_event("shutdown")
681
  async def shutdown_event():
682
- if llama_process and llama_process.returncode is None:
683
- try:
684
- llama_process.terminate()
685
- except Exception:
686
- pass
 
 
687
 
688
 
689
  # ──────────────────────────── Terminal WebSocket ──────────────────────────────
690
 
691
  async def _reap_process(pid: int, loop: asyncio.AbstractEventLoop):
692
- """非阻塞地終止並回收子行程,避免卡住整個 event loop(此為修正 bug1 的關鍵)。"""
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): # 最多輪詢約 2 秒,期間不阻塞其他連線
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[❌ 無法建立新終端機 (pty.fork 失敗: {e}),請稍後再試或關閉其他分頁]\x1b[0m\r\n",
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歡迎使用 Web Terminal|vim/nvim 已安裝|'
741
- f'本機 LLM OpenAI API: /v1 (llama.cpp, 就緒後可用)|輸入 pi-local 啟動 pi coding agent\\033[0m";'
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[✅ 檔案已成功上傳至: {filepath}]\x1b[0m\r\n",
822
  }))
823
  except Exception as e:
824
  await websocket.send_text(json.dumps({
825
  "type": "system",
826
- "data": f"\r\n\x1b[31m[❌ 上傳失敗: {e}]\x1b[0m\r\n",
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)