HelloSun commited on
Commit
e3a7f3d
·
verified ·
1 Parent(s): 8f34023

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +121 -22
app.py CHANGED
@@ -10,6 +10,7 @@ import asyncio
10
  import json
11
  import base64
12
  import logging
 
13
  import uuid
14
 
15
  import spaces
@@ -40,10 +41,18 @@ ALLOWED_BASES = [os.path.abspath(p) for p in (
40
  "/app", "/home/user", "/workspace", "/home/app", HOME
41
  )]
42
 
43
- # 斷線後 session 保留的閒置時間(沒有任何分頁接回就會被清除)
44
- SESSION_IDLE_TIMEOUT = 30 * 60 # 30 分鐘
 
 
 
 
 
 
45
  # 每個 session 保留的畫面回放緩衝區大小上限(bytes)
46
  SESSION_BUFFER_MAX = 300_000
 
 
47
  # ──────────────────────────── Frontend ────────────────────────────
48
  HTML_PAGE = r"""<!DOCTYPE html>
49
  <html lang="zh-TW">
@@ -227,6 +236,9 @@ function connect(tabObj) {
227
  if (m.type === 'session') {
228
  // 伺服器告訴我們這條連線對應的 session id:記住它,下次斷線/重整才能接回同一個終端機
229
  tabObj.sessionId = m.id;
 
 
 
230
  if (m.reattached) setAppStatus(`終端機 ${tabObj.id} 已接回原工作階段`, 'success');
231
  saveTabs();
232
  } else if (m.type === 'output') {
@@ -259,13 +271,13 @@ function connect(tabObj) {
259
  ws.onerror = () => ws.close();
260
  }
261
 
262
- function createTab(type = 'terminal', sessionId = null) {
263
  const id = ++cnt;
264
 
265
  const el = document.createElement('div');
266
  el.className = 'tab';
267
  el.dataset.id = id;
268
- el.innerHTML = `<span class="status-dot"></span><span>終端機 ${id}</span><span class="x">&times;</span>`;
269
  bar.insertBefore(el, copyBtn);
270
 
271
  const wrap = document.createElement('div');
@@ -287,7 +299,7 @@ function createTab(type = 'terminal', sessionId = null) {
287
  term.open(wrap);
288
  fit.fit();
289
 
290
- const obj = {id, type: 'terminal', el, wrap, term, fit, ws: null, closed: false, currentDir: '~', sessionId: sessionId || null};
291
  tabs.push(obj);
292
  connect(obj);
293
  saveTabs();
@@ -339,7 +351,7 @@ function closeTab(id) {
339
  if (activeId === id) activate(tabs[Math.min(i, tabs.length - 1)].id);
340
  }
341
 
342
- addBtn.onclick = () => createTab();
343
 
344
  copyBtn.onclick = async () => {
345
  const t = activeTab();
@@ -563,7 +575,7 @@ window.addEventListener('resize', () => tabs.forEach(t => {
563
  }));
564
 
565
  document.addEventListener('keydown', e => {
566
- if (e.ctrlKey && e.shiftKey && e.key === 'T') { e.preventDefault(); createTab(); }
567
  if (e.ctrlKey && e.shiftKey && e.key === 'W') { e.preventDefault(); if (activeId) closeTab(activeId); }
568
  });
569
 
@@ -572,8 +584,16 @@ document.addEventListener('keydown', e => {
572
  const saved = loadTabs();
573
  const savedCnt = parseInt(localStorage.getItem('webterm_cnt') || '0', 10);
574
  if (savedCnt) cnt = savedCnt;
575
- const savedTerminal = saved && saved[0];
576
- createTab('terminal', savedTerminal ? savedTerminal.sessionId : null);
 
 
 
 
 
 
 
 
577
  })();
578
  </script>
579
  </body>
@@ -634,6 +654,27 @@ async def download_route(path: str):
634
  return FileResponse(safe, filename=os.path.basename(safe))
635
 
636
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637
  # ──────────────────────────── 背景安裝任務 ────────────────────────────
638
 
639
  async def _run(cmd, cwd=None, env=None):
@@ -821,6 +862,9 @@ def dummy_gpu():
821
  @app.on_event("startup")
822
  async def startup_event():
823
  dummy_gpu()
 
 
 
824
  # 安裝 OpenCode 後清除使用者層級設定,使用 OpenCode 預設值。
825
  await install_opencode()
826
  reset_opencode_config()
@@ -841,18 +885,23 @@ SESSIONS_LOCK = asyncio.Lock()
841
 
842
 
843
  class TerminalSession:
844
- def __init__(self, session_id: str, pid: int, fd: int, cols: int = 120, rows: int = 30):
 
845
  self.id = session_id
846
  self.pid = pid
847
  self.fd = fd
848
  self.cols = cols
849
  self.rows = rows
 
 
 
850
  self.buffer = bytearray() # 畫面回放緩衝區,供重連時還原終端機畫面
851
  self.subscribers: dict[int, asyncio.Queue] = {}
852
  self._next_sub_id = 0
853
  self.cleanup_task: asyncio.Task | None = None
854
 
855
  def add_subscriber(self):
 
856
  sid = self._next_sub_id
857
  self._next_sub_id += 1
858
  q: asyncio.Queue = asyncio.Queue()
@@ -864,15 +913,18 @@ class TerminalSession:
864
 
865
  def remove_subscriber(self, sid: int, loop: asyncio.AbstractEventLoop):
866
  self.subscribers.pop(sid, None)
867
- if not self.subscribers:
 
868
  self.cleanup_task = loop.create_task(self._schedule_cleanup(loop))
869
 
870
  async def _schedule_cleanup(self, loop: asyncio.AbstractEventLoop):
 
 
871
  try:
872
  await asyncio.sleep(SESSION_IDLE_TIMEOUT)
873
  except asyncio.CancelledError:
874
  return
875
- if not self.subscribers:
876
  log.info("session %s 閒置超過 %ds 無人接回,清除", self.id, SESSION_IDLE_TIMEOUT)
877
  await kill_session(self.id, loop)
878
 
@@ -918,11 +970,12 @@ def _fork_shell():
918
  return pid, fd
919
 
920
 
921
- async def create_session(cols: int = 120, rows: int = 30) -> TerminalSession:
 
922
  pid, fd = _fork_shell()
923
 
924
- session_id = uuid.uuid4().hex[:12]
925
- sess = TerminalSession(session_id, pid, fd, cols, rows)
926
 
927
  _set_winsize(fd, cols, rows)
928
  fl = fcntl.fcntl(fd, fcntl.F_GETFL)
@@ -952,6 +1005,20 @@ async def create_session(cols: int = 120, rows: int = 30) -> TerminalSession:
952
  return sess
953
 
954
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
955
  async def _reap_process(pid: int, loop: asyncio.AbstractEventLoop):
956
  """非阻塞地終止並回收子行程。"""
957
  for sig in (signal.SIGTERM, signal.SIGKILL):
@@ -1010,8 +1077,21 @@ async def terminal_ws(websocket: WebSocket):
1010
  except ValueError:
1011
  cols, rows = 120, 30
1012
 
1013
- sess = SESSIONS.get(requested_id) if requested_id else None
1014
- reattached = sess is not None
 
 
 
 
 
 
 
 
 
 
 
 
 
1015
 
1016
  if sess is None:
1017
  try:
@@ -1025,14 +1105,18 @@ async def terminal_ws(websocket: WebSocket):
1025
  return
1026
 
1027
  # 告知前端這次連線對應的 session id,讓前端存起來,日後斷線重連 / 重新整理都能接回同一個
1028
- await websocket.send_text(json.dumps({"type": "session", "id": sess.id, "reattached": reattached}))
 
 
 
1029
 
1030
  if reattached:
1031
  if sess.buffer:
1032
  try:
 
1033
  await websocket.send_text(json.dumps({
1034
  "type": "output",
1035
- "data": bytes(sess.buffer).decode("utf-8", errors="replace"),
1036
  }))
1037
  except Exception:
1038
  pass
@@ -1043,7 +1127,7 @@ async def terminal_ws(websocket: WebSocket):
1043
  }))
1044
  except Exception:
1045
  pass
1046
- elif requested_id:
1047
  # 前端要求接回某個 session,但伺服器已找不到(逾時被清除或伺服器重啟過)
1048
  try:
1049
  await websocket.send_text(json.dumps({
@@ -1055,6 +1139,14 @@ async def terminal_ws(websocket: WebSocket):
1055
 
1056
  sub_id, queue = sess.add_subscriber()
1057
 
 
 
 
 
 
 
 
 
1058
  async def _pty_to_ws():
1059
  while True:
1060
  chunk = await queue.get()
@@ -1116,8 +1208,15 @@ async def terminal_ws(websocket: WebSocket):
1116
  }))
1117
 
1118
  elif mtype == "kill":
1119
- # 使用者主動關閉這個分頁:真正終止此 session(不再保留供重連)
1120
- await kill_session(sess.id, loop)
 
 
 
 
 
 
 
1121
  break
1122
 
1123
  except (WebSocketDisconnect, RuntimeError):
 
10
  import json
11
  import base64
12
  import logging
13
+ import time
14
  import uuid
15
 
16
  import spaces
 
41
  "/app", "/home/user", "/workspace", "/home/app", HOME
42
  )]
43
 
44
+ # 共用主 session:所有裝置/瀏覽器預設都接到這一個,且永不被閒置清除。
45
+ # 沒有帶 session_id 的 /ws 連線一律接這裡;前端第一頁固定用這個,所以
46
+ # 不同裝置、斷線數小時後都會回到同一個終端機(opencode 也還在原地跑)。
47
+ PRIMARY_SESSION_ID = "primary"
48
+ # 前端按「+」開新分頁時送這個 id,代表「給我一個新的獨立 session」
49
+ NEW_SESSION_ID = "__new__"
50
+ # 非共用分頁斷線後保留的閒置時間(沒有任何分頁接回就會被清除)
51
+ SESSION_IDLE_TIMEOUT = int(os.environ.get("SESSION_IDLE_TIMEOUT", str(30 * 60)))
52
  # 每個 session 保留的畫面回放緩衝區大小上限(bytes)
53
  SESSION_BUFFER_MAX = 300_000
54
+ # 本機測試時可跳過 opencode / 編輯器安裝
55
+ SKIP_INSTALL = os.environ.get("WEBTERM_SKIP_INSTALL", "") == "1"
56
  # ──────────────────────────── Frontend ────────────────────────────
57
  HTML_PAGE = r"""<!DOCTYPE html>
58
  <html lang="zh-TW">
 
236
  if (m.type === 'session') {
237
  // 伺服器告訴我們這條連線對應的 session id:記住它,下次斷線/重整才能接回同一個終端機
238
  tabObj.sessionId = m.id;
239
+ tabObj.shared = !!m.primary;
240
+ const titleEl = tabObj.el.querySelector('.tab-title');
241
+ if (titleEl) titleEl.textContent = `終端機 ${tabObj.id}${tabObj.shared ? '(共用)' : ''}`;
242
  if (m.reattached) setAppStatus(`終端機 ${tabObj.id} 已接回原工作階段`, 'success');
243
  saveTabs();
244
  } else if (m.type === 'output') {
 
271
  ws.onerror = () => ws.close();
272
  }
273
 
274
+ function createTab(type = 'terminal', sessionId = null, shared = false) {
275
  const id = ++cnt;
276
 
277
  const el = document.createElement('div');
278
  el.className = 'tab';
279
  el.dataset.id = id;
280
+ el.innerHTML = `<span class="status-dot"></span><span class="tab-title">終端機 ${id}${shared ? '(共用)' : ''}</span><span class="x">&times;</span>`;
281
  bar.insertBefore(el, copyBtn);
282
 
283
  const wrap = document.createElement('div');
 
299
  term.open(wrap);
300
  fit.fit();
301
 
302
+ const obj = {id, type: 'terminal', el, wrap, term, fit, ws: null, closed: false, currentDir: '~', sessionId: sessionId || null, shared: !!shared};
303
  tabs.push(obj);
304
  connect(obj);
305
  saveTabs();
 
351
  if (activeId === id) activate(tabs[Math.min(i, tabs.length - 1)].id);
352
  }
353
 
354
+ addBtn.onclick = () => createTab('terminal', '__new__');
355
 
356
  copyBtn.onclick = async () => {
357
  const t = activeTab();
 
575
  }));
576
 
577
  document.addEventListener('keydown', e => {
578
+ if (e.ctrlKey && e.shiftKey && e.key === 'T') { e.preventDefault(); createTab('terminal', '__new__'); }
579
  if (e.ctrlKey && e.shiftKey && e.key === 'W') { e.preventDefault(); if (activeId) closeTab(activeId); }
580
  });
581
 
 
584
  const saved = loadTabs();
585
  const savedCnt = parseInt(localStorage.getItem('webterm_cnt') || '0', 10);
586
  if (savedCnt) cnt = savedCnt;
587
+ // 第一頁固定不帶 session_id:伺服器會接到「共用主 session」。
588
+ // 如此一來不同裝置/不同瀏覽器、甚至斷線數小時後,都會回到同一個終端機,
589
+ // 不會再開一個新的 bash 把原本的 opencode 留在背景。
590
+ createTab('terminal', null, true);
591
+ // 這台裝置額外開的分頁才用 localStorage 還原(非共用)。
592
+ if (saved && saved.length > 1) {
593
+ saved.slice(1).forEach(t => {
594
+ if (t && t.type === 'terminal') createTab('terminal', t.sessionId || null, false);
595
+ });
596
+ }
597
  })();
598
  </script>
599
  </body>
 
654
  return FileResponse(safe, filename=os.path.basename(safe))
655
 
656
 
657
+ @app.get("/api/sessions")
658
+ async def api_sessions():
659
+ """列出目前伺服器上的終端機工作階段(除錯/���認跨裝置是否共用)。"""
660
+ now = time.time()
661
+ async with SESSIONS_LOCK:
662
+ items = [{
663
+ "id": s.id,
664
+ "primary": s.id == PRIMARY_SESSION_ID,
665
+ "persistent": s.persistent,
666
+ "pid": s.pid,
667
+ "cols": s.cols,
668
+ "rows": s.rows,
669
+ "subscribers": len(s.subscribers),
670
+ "created_at": round(s.created_at, 3),
671
+ "last_active": round(s.last_active, 3),
672
+ "idle_seconds": round(now - s.last_active, 3),
673
+ "buffer_bytes": len(s.buffer),
674
+ } for s in SESSIONS.values()]
675
+ return {"primary": PRIMARY_SESSION_ID, "idle_timeout": SESSION_IDLE_TIMEOUT, "sessions": items}
676
+
677
+
678
  # ──────────────────────────── 背景安裝任務 ────────────────────────────
679
 
680
  async def _run(cmd, cwd=None, env=None):
 
862
  @app.on_event("startup")
863
  async def startup_event():
864
  dummy_gpu()
865
+ if SKIP_INSTALL:
866
+ log.info("WEBTERM_SKIP_INSTALL=1:跳過 opencode / 編輯器安裝")
867
+ return
868
  # 安裝 OpenCode 後清除使用者層級設定,使用 OpenCode 預設值。
869
  await install_opencode()
870
  reset_opencode_config()
 
885
 
886
 
887
  class TerminalSession:
888
+ def __init__(self, session_id: str, pid: int, fd: int, cols: int = 120, rows: int = 30,
889
+ persistent: bool = False):
890
  self.id = session_id
891
  self.pid = pid
892
  self.fd = fd
893
  self.cols = cols
894
  self.rows = rows
895
+ self.persistent = persistent # 共用主 session:永不被閒置清除
896
+ self.created_at = time.time()
897
+ self.last_active = self.created_at
898
  self.buffer = bytearray() # 畫面回放緩衝區,供重連時還原終端機畫面
899
  self.subscribers: dict[int, asyncio.Queue] = {}
900
  self._next_sub_id = 0
901
  self.cleanup_task: asyncio.Task | None = None
902
 
903
  def add_subscriber(self):
904
+ self.last_active = time.time()
905
  sid = self._next_sub_id
906
  self._next_sub_id += 1
907
  q: asyncio.Queue = asyncio.Queue()
 
913
 
914
  def remove_subscriber(self, sid: int, loop: asyncio.AbstractEventLoop):
915
  self.subscribers.pop(sid, None)
916
+ self.last_active = time.time()
917
+ if not self.subscribers and not self.persistent:
918
  self.cleanup_task = loop.create_task(self._schedule_cleanup(loop))
919
 
920
  async def _schedule_cleanup(self, loop: asyncio.AbstractEventLoop):
921
+ if self.persistent:
922
+ return
923
  try:
924
  await asyncio.sleep(SESSION_IDLE_TIMEOUT)
925
  except asyncio.CancelledError:
926
  return
927
+ if not self.subscribers and not self.persistent:
928
  log.info("session %s 閒置超過 %ds 無人接回,清除", self.id, SESSION_IDLE_TIMEOUT)
929
  await kill_session(self.id, loop)
930
 
 
970
  return pid, fd
971
 
972
 
973
+ async def create_session(cols: int = 120, rows: int = 30, session_id: str | None = None,
974
+ persistent: bool = False) -> TerminalSession:
975
  pid, fd = _fork_shell()
976
 
977
+ session_id = session_id or uuid.uuid4().hex[:12]
978
+ sess = TerminalSession(session_id, pid, fd, cols, rows, persistent=persistent)
979
 
980
  _set_winsize(fd, cols, rows)
981
  fl = fcntl.fcntl(fd, fcntl.F_GETFL)
 
1005
  return sess
1006
 
1007
 
1008
+ PRIMARY_LOCK = asyncio.Lock()
1009
+
1010
+
1011
+ async def get_or_create_primary(cols: int = 120, rows: int = 30) -> tuple[TerminalSession, bool]:
1012
+ """回傳(共用主 session, 是否為接回)。所有沒指定 session_id 的連線都走這裡。"""
1013
+ async with PRIMARY_LOCK:
1014
+ sess = SESSIONS.get(PRIMARY_SESSION_ID)
1015
+ if sess is not None:
1016
+ return sess, True
1017
+ sess = await create_session(cols, rows, session_id=PRIMARY_SESSION_ID, persistent=True)
1018
+ log.info("已建立共用主 session(id=%s)", PRIMARY_SESSION_ID)
1019
+ return sess, False
1020
+
1021
+
1022
  async def _reap_process(pid: int, loop: asyncio.AbstractEventLoop):
1023
  """非阻塞地終止並回收子行程。"""
1024
  for sig in (signal.SIGTERM, signal.SIGKILL):
 
1077
  except ValueError:
1078
  cols, rows = 120, 30
1079
 
1080
+ # 共用主 session:
1081
+ # * 沒有帶 session_id(新裝置/新瀏覽器)→ 接到 primary
1082
+ # * session_id=__new__(按 + 開新分頁)→ 建立獨立的新 session
1083
+ # * 帶 primary 但伺服器沒有(例如 app 重啟)→ 重建 primary
1084
+ # * 其他 id 且找不到 → 建立一般的暫時 session
1085
+ if not requested_id:
1086
+ sess, reattached = await get_or_create_primary(cols, rows)
1087
+ elif requested_id == NEW_SESSION_ID:
1088
+ sess, reattached = None, False
1089
+ else:
1090
+ sess = SESSIONS.get(requested_id)
1091
+ reattached = sess is not None
1092
+ if sess is None and requested_id == PRIMARY_SESSION_ID:
1093
+ sess = await create_session(cols, rows, session_id=PRIMARY_SESSION_ID, persistent=True)
1094
+ reattached = False
1095
 
1096
  if sess is None:
1097
  try:
 
1105
  return
1106
 
1107
  # 告知前端這次連線對應的 session id,讓前端存起來,日後斷線重連 / 重新整理都能接回同一個
1108
+ await websocket.send_text(json.dumps({
1109
+ "type": "session", "id": sess.id, "reattached": reattached,
1110
+ "primary": sess.id == PRIMARY_SESSION_ID,
1111
+ }))
1112
 
1113
  if reattached:
1114
  if sess.buffer:
1115
  try:
1116
+ # 先清畫面再回放,避免新裝置接到半個畫面
1117
  await websocket.send_text(json.dumps({
1118
  "type": "output",
1119
+ "data": "\x1b[2J\x1b[3J\x1b[H" + bytes(sess.buffer).decode("utf-8", errors="replace"),
1120
  }))
1121
  except Exception:
1122
  pass
 
1127
  }))
1128
  except Exception:
1129
  pass
1130
+ elif requested_id and requested_id not in (PRIMARY_SESSION_ID, NEW_SESSION_ID):
1131
  # 前端要求接回某個 session,但伺服器已找不到(逾時被清除或伺服器重啟過)
1132
  try:
1133
  await websocket.send_text(json.dumps({
 
1139
 
1140
  sub_id, queue = sess.add_subscriber()
1141
 
1142
+ if reattached:
1143
+ # 觸發 SIGWINCH,讓 TUI(opencode 等)重畫目前畫面;新裝置才不會只看到殘缺的回放
1144
+ try:
1145
+ _set_winsize(sess.fd, sess.cols, max(1, sess.rows - 1))
1146
+ _set_winsize(sess.fd, sess.cols, sess.rows)
1147
+ except Exception:
1148
+ pass
1149
+
1150
  async def _pty_to_ws():
1151
  while True:
1152
  chunk = await queue.get()
 
1208
  }))
1209
 
1210
  elif mtype == "kill":
1211
+ # 關閉分頁只終止「非共用」的 session;共用主 session 只解除訂閱,
1212
+ # 這樣其他裝置接回來時 opencode 還在原地跑。
1213
+ if sess.id == PRIMARY_SESSION_ID:
1214
+ await websocket.send_text(json.dumps({
1215
+ "type": "system",
1216
+ "data": "\r\n\x1b[33m[ℹ️ 共用終端機不會因關閉分頁而終止,已保留工作階段]\x1b[0m\r\n",
1217
+ }))
1218
+ else:
1219
+ await kill_session(sess.id, loop)
1220
  break
1221
 
1222
  except (WebSocketDisconnect, RuntimeError):