claude-opencode-viewer 2.6.11 → 2.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/index-pc.html +10 -83
  2. package/index.html +171 -96
  3. package/package.json +1 -1
  4. package/server.js +76 -19
package/index-pc.html CHANGED
@@ -395,58 +395,6 @@
395
395
  display: block;
396
396
  }
397
397
 
398
- #copy-popup {
399
- display: none;
400
- position: fixed;
401
- top: 50%;
402
- left: 50%;
403
- transform: translate(-50%, -50%);
404
- background: #1a1a1a;
405
- border: 1px solid #444;
406
- border-radius: 8px;
407
- padding: 16px;
408
- z-index: 10000;
409
- max-width: 80vw;
410
- max-height: 60vh;
411
- }
412
- #copy-popup.show {
413
- display: flex;
414
- flex-direction: column;
415
- gap: 10px;
416
- }
417
- #copy-popup textarea {
418
- width: 500px;
419
- max-width: 70vw;
420
- height: 120px;
421
- background: #0a0a0a;
422
- color: #ccc;
423
- border: 1px solid #333;
424
- border-radius: 4px;
425
- padding: 8px;
426
- font-family: Menlo, Monaco, monospace;
427
- font-size: 12px;
428
- resize: vertical;
429
- }
430
- #copy-popup .popup-actions {
431
- display: flex;
432
- justify-content: flex-end;
433
- gap: 8px;
434
- }
435
- #copy-popup button {
436
- padding: 6px 16px;
437
- border: none;
438
- border-radius: 4px;
439
- cursor: pointer;
440
- font-size: 13px;
441
- }
442
- #copy-popup .btn-copy {
443
- background: #2563eb;
444
- color: #fff;
445
- }
446
- #copy-popup .btn-close {
447
- background: #333;
448
- color: #ccc;
449
- }
450
398
 
451
399
  /* Git Diff 面板 */
452
400
  #git-diff-bar {
@@ -786,14 +734,6 @@
786
734
  </div>
787
735
 
788
736
  <div id="copy-toast">已复制</div>
789
- <div id="copy-popup">
790
- <div style="color:#ccc;font-size:13px;">剪贴板写入需要 HTTPS,请手动复制:</div>
791
- <textarea id="copy-popup-text" readonly></textarea>
792
- <div class="popup-actions">
793
- <button class="btn-copy" onclick="document.getElementById('copy-popup-text').select();document.execCommand('copy');document.getElementById('copy-popup').classList.remove('show');">选中并复制</button>
794
- <button class="btn-close" onclick="document.getElementById('copy-popup').classList.remove('show');">关闭</button>
795
- </div>
796
- </div>
797
737
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
798
738
  <script>
799
739
  (function() {
@@ -842,19 +782,15 @@
842
782
  try {
843
783
  var bytes = Uint8Array.from(atob(b64), function(c) { return c.charCodeAt(0); });
844
784
  var text = new TextDecoder().decode(bytes);
845
- if (navigator.clipboard && navigator.clipboard.writeText) {
846
- navigator.clipboard.writeText(text).then(function() {
847
- showCopyToast();
848
- }).catch(function() {
849
- showCopyPopup(text);
850
- });
851
- } else {
852
- showCopyPopup(text);
853
- }
785
+ copyToClipboard(text);
854
786
  } catch (e) {}
855
787
  return true;
856
788
  });
857
789
 
790
+ // 自动检测反向代理子路径,确保 API/WS 请求带正确前缀
791
+ var basePath = location.pathname.replace(/\/[^/]*$/, '');
792
+ if (basePath === '' || basePath === '/') basePath = '';
793
+
858
794
  var modeSelect = document.getElementById('mode-select');
859
795
  var terminalEl = document.getElementById('terminal');
860
796
  var ws = null;
@@ -1270,7 +1206,7 @@
1270
1206
 
1271
1207
  function connect() {
1272
1208
  var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1273
- ws = new WebSocket(proto + '//' + location.host + '/ws');
1209
+ ws = new WebSocket(proto + '//' + location.host + basePath + '/ws');
1274
1210
 
1275
1211
  ws.onopen = function() {
1276
1212
  isBufferReplay = true;
@@ -1416,7 +1352,7 @@
1416
1352
  var sessionList = document.getElementById('session-list');
1417
1353
  sessionList.innerHTML = '<div class="session-loading">加载历史会话...</div>';
1418
1354
 
1419
- fetch('/api/sessions')
1355
+ fetch(basePath + '/api/sessions')
1420
1356
  .then(function(response) { return response.json(); })
1421
1357
  .then(function(data) {
1422
1358
  sessions = data;
@@ -1499,7 +1435,7 @@
1499
1435
  itemEl.style.opacity = '0.4';
1500
1436
  itemEl.style.pointerEvents = 'none';
1501
1437
 
1502
- fetch('/api/session/' + sessionId, { method: 'DELETE' })
1438
+ fetch(basePath + '/api/session/' + sessionId, { method: 'DELETE' })
1503
1439
  .then(function(r) { return r.json(); })
1504
1440
  .then(function(data) {
1505
1441
  if (data.ok) {
@@ -1645,15 +1581,6 @@
1645
1581
  showCopyToast();
1646
1582
  }
1647
1583
 
1648
- function showCopyPopup(text) {
1649
- var popup = document.getElementById('copy-popup');
1650
- var ta = document.getElementById('copy-popup-text');
1651
- ta.value = text;
1652
- popup.classList.add('show');
1653
- ta.focus();
1654
- ta.select();
1655
- }
1656
-
1657
1584
  function showCopyToast() {
1658
1585
  var toast = document.getElementById('copy-toast');
1659
1586
  toast.classList.add('show');
@@ -1732,7 +1659,7 @@
1732
1659
  fileList.innerHTML = '<div class="git-diff-loading">加载中...</div>';
1733
1660
  document.getElementById('git-diff-count').textContent = '0';
1734
1661
 
1735
- fetch('/api/git-status')
1662
+ fetch(basePath + '/api/git-status')
1736
1663
  .then(function(r) { return r.json(); })
1737
1664
  .then(function(data) {
1738
1665
  diffChanges = data.changes || [];
@@ -1780,7 +1707,7 @@
1780
1707
  var area = document.getElementById('git-diff-content-area');
1781
1708
  area.innerHTML = '<div class="git-diff-loading">加载 diff...</div>';
1782
1709
 
1783
- fetch('/api/git-diff?files=' + encodeURIComponent(file))
1710
+ fetch(basePath + '/api/git-diff?files=' + encodeURIComponent(file))
1784
1711
  .then(function(r) { return r.json(); })
1785
1712
  .then(function(data) {
1786
1713
  if (!data.diffs || !data.diffs[0]) {
package/index.html CHANGED
@@ -351,71 +351,94 @@
351
351
  color: #fff;
352
352
  }
353
353
 
354
- /* 选择模式:原位文本层 */
355
- #terminal.select-mode .xterm-screen {
356
- visibility: hidden;
357
- }
358
-
359
- #select-text-layer {
354
+ /* 消息查看器 */
355
+ #message-viewer {
360
356
  display: none;
361
- position: absolute;
357
+ position: fixed;
362
358
  top: 0; left: 0; right: 0; bottom: 0;
363
- overflow-y: auto;
364
- -webkit-overflow-scrolling: touch;
365
359
  background: #0a0a0a;
366
- padding: 4px 8px;
367
- touch-action: auto;
368
- z-index: 10;
360
+ z-index: 1000;
361
+ flex-direction: column;
369
362
  }
370
-
371
- #select-text-layer.visible {
372
- display: block;
363
+ #message-viewer.visible {
364
+ display: flex;
373
365
  }
374
-
375
- #select-hint {
376
- position: sticky;
377
- top: 0;
378
- background: rgba(30,30,30,0.95);
379
- color: #888;
380
- font-size: 11px;
381
- text-align: center;
382
- padding: 6px 0;
383
- border-bottom: 1px solid #333;
384
- z-index: 1;
385
- -webkit-user-select: none;
386
- user-select: none;
366
+ #msg-viewer-header {
367
+ display: flex;
368
+ align-items: center;
369
+ justify-content: space-between;
370
+ padding: 10px 14px;
371
+ background: #111;
372
+ border-bottom: 1px solid #222;
373
+ flex-shrink: 0;
387
374
  }
388
-
389
- #select-text-layer pre {
390
- margin: 0;
391
- color: #d4d4d4;
392
- font-family: Menlo, Monaco, "Courier New", monospace;
393
- font-size: 11px;
394
- line-height: 1.4;
395
- white-space: pre-wrap;
396
- word-break: break-all;
397
- -webkit-user-select: text;
398
- user-select: text;
375
+ #msg-viewer-header span {
376
+ color: #ccc;
377
+ font-size: 15px;
378
+ font-weight: 500;
399
379
  }
400
-
401
- #select-mode-close {
402
- position: absolute;
403
- top: 6px;
404
- right: 6px;
405
- z-index: 20;
406
- display: none;
380
+ #msg-viewer-close {
407
381
  background: rgba(50,50,50,0.9);
408
382
  border: 1px solid #555;
409
383
  color: #ccc;
410
- width: 28px;
411
- height: 28px;
384
+ width: 30px;
385
+ height: 30px;
412
386
  border-radius: 50%;
413
- font-size: 14px;
414
- line-height: 26px;
387
+ font-size: 15px;
388
+ line-height: 28px;
415
389
  text-align: center;
416
390
  cursor: pointer;
417
- -webkit-user-select: none;
418
- user-select: none;
391
+ }
392
+ #msg-viewer-content {
393
+ flex: 1;
394
+ overflow-y: auto;
395
+ -webkit-overflow-scrolling: touch;
396
+ padding: 12px;
397
+ }
398
+ .msg-item {
399
+ margin-bottom: 16px;
400
+ padding: 10px 12px;
401
+ border-radius: 8px;
402
+ border: 1px solid #222;
403
+ }
404
+ .msg-user {
405
+ background: #1a2332;
406
+ border-color: #2a4a7c;
407
+ }
408
+ .msg-assistant {
409
+ background: #1a2e1a;
410
+ border-color: #2a5a3a;
411
+ }
412
+ .msg-role {
413
+ font-size: 11px;
414
+ color: #888;
415
+ font-weight: 600;
416
+ text-transform: uppercase;
417
+ margin-bottom: 6px;
418
+ }
419
+ .msg-text {
420
+ color: #ddd;
421
+ font-size: 13px;
422
+ line-height: 1.6;
423
+ white-space: pre-wrap;
424
+ word-break: break-word;
425
+ -webkit-user-select: text;
426
+ user-select: text;
427
+ }
428
+ .msg-tool {
429
+ margin-top: 6px;
430
+ padding: 6px 8px;
431
+ background: #1a1a0a;
432
+ border: 1px solid #333;
433
+ border-radius: 4px;
434
+ font-size: 11px;
435
+ color: #f0ad4e;
436
+ }
437
+ .msg-empty {
438
+ text-align: center;
439
+ padding: 40px 20px;
440
+ color: #666;
441
+ font-size: 13px;
419
442
  }
420
443
 
421
444
  /* 复制成功提示 */
@@ -765,11 +788,6 @@
765
788
  <div id="content">
766
789
  <div id="terminal-container">
767
790
  <div id="terminal">
768
- <div id="select-text-layer">
769
- <div id="select-hint">长按选择文本 · 点右上角 ✕ 返回终端</div>
770
- <pre id="select-text-pre"></pre>
771
- </div>
772
- <button id="select-mode-close">✕</button>
773
791
  </div>
774
792
  <div id="virtual-keybar">
775
793
  <div class="virtual-key" data-key="up">↑</div>
@@ -785,6 +803,16 @@
785
803
  </div>
786
804
  </div>
787
805
 
806
+ <div id="message-viewer">
807
+ <div id="msg-viewer-header">
808
+ <span>会话消息</span>
809
+ <button id="msg-viewer-close">✕</button>
810
+ </div>
811
+ <div id="msg-viewer-content">
812
+ <div class="msg-empty">加载中...</div>
813
+ </div>
814
+ </div>
815
+
788
816
  <div id="copy-toast">已复制</div>
789
817
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
790
818
  <script>
@@ -850,6 +878,10 @@
850
878
  return true;
851
879
  });
852
880
 
881
+ // 自动检测反向代理子路径,确保 API/WS 请求带正确前缀
882
+ var basePath = location.pathname.replace(/\/[^/]*$/, '');
883
+ if (basePath === '' || basePath === '/') basePath = '';
884
+
853
885
  var modeSelect = document.getElementById('mode-select');
854
886
  var terminalEl = document.getElementById('terminal');
855
887
  var ws = null;
@@ -927,7 +959,7 @@
927
959
  onVVChange();
928
960
  }
929
961
 
930
- // 参考 cc-viewer 的 TerminalPanel.jsx 行 410-447: 移动端固定尺寸计算
962
+ // 移动端固定尺寸计算:基于 #terminal 元素实际高度,确保终端与按钮栏齐平
931
963
  function mobileFixedResize() {
932
964
  if (!term) return;
933
965
  var cellDims = getCellDims();
@@ -937,12 +969,9 @@
937
969
  }
938
970
 
939
971
  var padX = 16;
940
- var padY = 8;
941
- var topBarHeight = 40;
942
- var keybarHeight = 52;
943
-
972
+ var termEl = document.getElementById('terminal');
944
973
  var availW = window.innerWidth - padX;
945
- var availH = window.innerHeight - topBarHeight - keybarHeight - padY;
974
+ var availH = termEl ? termEl.clientHeight : 300;
946
975
 
947
976
  var currentFontSize = term.options.fontSize;
948
977
  var currentCharWidth = cellDims.width;
@@ -1091,7 +1120,10 @@
1091
1120
  // 长按检测
1092
1121
  var longPressTimer = null;
1093
1122
  var longPressTriggered = false;
1094
- var LONG_PRESS_DELAY = 550; // ms
1123
+ var LONG_PRESS_DELAY = 800; // ms
1124
+ var LONG_PRESS_MOVE_THRESHOLD = 10; // px,移动超过此距离取消长按
1125
+ var touchStartX = 0;
1126
+ var touchStartY = 0;
1095
1127
 
1096
1128
  function clearLongPress() {
1097
1129
  if (longPressTimer) {
@@ -1112,6 +1144,8 @@
1112
1144
  lastY = e.touches[0].clientY;
1113
1145
  lastTime = performance.now();
1114
1146
  velocitySamples = [];
1147
+ touchStartX = e.touches[0].clientX;
1148
+ touchStartY = e.touches[0].clientY;
1115
1149
 
1116
1150
  // 启动长按计时器
1117
1151
  // 在长按检测期间阻止 xterm textarea 获取焦点,防止弹出键盘
@@ -1123,14 +1157,18 @@
1123
1157
  longPressTimer = setTimeout(function() {
1124
1158
  longPressTriggered = true;
1125
1159
  longPressTimer = null;
1126
- openSelectMode();
1160
+ openMessageViewer();
1127
1161
  }, LONG_PRESS_DELAY);
1128
1162
  }
1129
1163
 
1130
1164
  function handleTouchMove(e) {
1131
1165
  if (e.touches.length !== 1) return;
1132
- // 有移动则取消长按
1133
- clearLongPress();
1166
+ // 移动超过阈值才取消长按(容忍手指微小抖动)
1167
+ var dx = e.touches[0].clientX - touchStartX;
1168
+ var dy2 = e.touches[0].clientY - touchStartY;
1169
+ if (Math.abs(dx) > LONG_PRESS_MOVE_THRESHOLD || Math.abs(dy2) > LONG_PRESS_MOVE_THRESHOLD) {
1170
+ clearLongPress();
1171
+ }
1134
1172
  var y = e.touches[0].clientY;
1135
1173
  var now = performance.now();
1136
1174
  var dt = now - lastTime;
@@ -1327,7 +1365,7 @@
1327
1365
 
1328
1366
  function connect() {
1329
1367
  var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1330
- ws = new WebSocket(proto + '//' + location.host + '/ws');
1368
+ ws = new WebSocket(proto + '//' + location.host + basePath + '/ws');
1331
1369
 
1332
1370
  ws.onopen = function() {
1333
1371
  resize();
@@ -1578,7 +1616,7 @@
1578
1616
  var sessionList = document.getElementById('session-list');
1579
1617
  sessionList.innerHTML = '<div class="session-loading">加载历史会话...</div>';
1580
1618
 
1581
- fetch('/api/sessions')
1619
+ fetch(basePath + '/api/sessions')
1582
1620
  .then(function(response) { return response.json(); })
1583
1621
  .then(function(data) {
1584
1622
  sessions = data;
@@ -1661,7 +1699,7 @@
1661
1699
  itemEl.style.opacity = '0.4';
1662
1700
  itemEl.style.pointerEvents = 'none';
1663
1701
 
1664
- fetch('/api/session/' + sessionId, { method: 'DELETE' })
1702
+ fetch(basePath + '/api/session/' + sessionId, { method: 'DELETE' })
1665
1703
  .then(function(r) { return r.json(); })
1666
1704
  .then(function(data) {
1667
1705
  if (data.ok) {
@@ -1814,47 +1852,84 @@
1814
1852
  }
1815
1853
 
1816
1854
 
1817
- // 方案2: 长按进入选择模式 — 原位显示可选纯文本
1818
- var selectTextLayer = document.getElementById('select-text-layer');
1819
- var selectTextPre = document.getElementById('select-text-pre');
1820
- var selectModeClose = document.getElementById('select-mode-close');
1821
- var inSelectMode = false;
1855
+ // 长按打开消息查看器
1856
+ var messageViewer = document.getElementById('message-viewer');
1857
+ var msgViewerContent = document.getElementById('msg-viewer-content');
1858
+ var msgViewerClose = document.getElementById('msg-viewer-close');
1822
1859
 
1823
- function openSelectMode() {
1824
- if (inSelectMode) return;
1825
- inSelectMode = true;
1860
+ function openMessageViewer() {
1826
1861
  // 收起键盘
1827
1862
  var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1828
1863
  if (xtermTa) xtermTa.blur();
1829
1864
  document.activeElement && document.activeElement.blur();
1830
- var text = getTerminalText();
1831
- selectTextPre.textContent = text || '(终端内容为空)';
1832
- terminalEl.classList.add('select-mode');
1833
- selectTextLayer.classList.add('visible');
1834
- selectModeClose.style.display = 'block';
1865
+
1866
+ messageViewer.classList.add('visible');
1867
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载中...</div>';
1835
1868
  unbindTouchScroll();
1869
+
1870
+ // 获取当前会话 ID
1871
+ fetch(basePath + '/api/current-session')
1872
+ .then(function(r) { return r.json(); })
1873
+ .then(function(data) {
1874
+ if (!data.sessionId) {
1875
+ msgViewerContent.innerHTML = '<div class="msg-empty">暂无活跃会话</div>';
1876
+ return;
1877
+ }
1878
+ return fetch(basePath + '/api/session/' + data.sessionId)
1879
+ .then(function(r) { return r.json(); })
1880
+ .then(function(messages) {
1881
+ renderMessages(messages);
1882
+ });
1883
+ })
1884
+ .catch(function(e) {
1885
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载失败: ' + e.message + '</div>';
1886
+ });
1887
+ }
1888
+
1889
+ function renderMessages(messages) {
1890
+ if (!messages || messages.length === 0) {
1891
+ msgViewerContent.innerHTML = '<div class="msg-empty">暂无消息</div>';
1892
+ return;
1893
+ }
1894
+ var html = '';
1895
+ messages.forEach(function(msg) {
1896
+ var role = msg.role || 'unknown';
1897
+ var roleLabel = role === 'user' ? '用户' : role === 'assistant' ? '助手' : role;
1898
+ var cls = role === 'user' ? 'msg-user' : role === 'assistant' ? 'msg-assistant' : '';
1899
+ html += '<div class="msg-item ' + cls + '">';
1900
+ html += '<div class="msg-role">' + roleLabel + '</div>';
1901
+ if (msg.text) {
1902
+ html += '<div class="msg-text">' + escapeHtml(msg.text) + '</div>';
1903
+ }
1904
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
1905
+ msg.toolCalls.forEach(function(tc) {
1906
+ html += '<div class="msg-tool">🔧 ' + escapeHtml(tc.name || 'tool') + '</div>';
1907
+ });
1908
+ }
1909
+ html += '</div>';
1910
+ });
1911
+ msgViewerContent.innerHTML = html;
1912
+ }
1913
+
1914
+ function escapeHtml(str) {
1915
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1836
1916
  }
1837
1917
 
1838
- function closeSelectMode() {
1839
- if (!inSelectMode) return;
1840
- inSelectMode = false;
1841
- terminalEl.classList.remove('select-mode');
1842
- selectTextLayer.classList.remove('visible');
1843
- selectModeClose.style.display = 'none';
1844
- window.getSelection().removeAllRanges();
1918
+ function closeMessageViewer() {
1919
+ messageViewer.classList.remove('visible');
1845
1920
  rebindTouchScroll();
1846
1921
  }
1847
1922
 
1848
- selectModeClose.addEventListener('click', function(e) {
1923
+ msgViewerClose.addEventListener('click', function(e) {
1849
1924
  e.preventDefault();
1850
1925
  e.stopPropagation();
1851
- closeSelectMode();
1926
+ closeMessageViewer();
1852
1927
  });
1853
1928
 
1854
- selectModeClose.addEventListener('touchend', function(e) {
1929
+ msgViewerClose.addEventListener('touchend', function(e) {
1855
1930
  e.preventDefault();
1856
1931
  e.stopPropagation();
1857
- closeSelectMode();
1932
+ closeMessageViewer();
1858
1933
  });
1859
1934
 
1860
1935
  // ======= Git Diff 功能 =======
@@ -1885,7 +1960,7 @@
1885
1960
  fileList.innerHTML = '<div class="git-diff-loading">加载中...</div>';
1886
1961
  document.getElementById('git-diff-count').textContent = '0';
1887
1962
 
1888
- fetch('/api/git-status')
1963
+ fetch(basePath + '/api/git-status')
1889
1964
  .then(function(r) { return r.json(); })
1890
1965
  .then(function(data) {
1891
1966
  diffChanges = data.changes || [];
@@ -1933,7 +2008,7 @@
1933
2008
  var area = document.getElementById('git-diff-content-area');
1934
2009
  area.innerHTML = '<div class="git-diff-loading">加载 diff...</div>';
1935
2010
 
1936
- fetch('/api/git-diff?files=' + encodeURIComponent(file))
2011
+ fetch(basePath + '/api/git-diff?files=' + encodeURIComponent(file))
1937
2012
  .then(function(r) { return r.json(); })
1938
2013
  .then(function(data) {
1939
2014
  if (!data.diffs || !data.diffs[0]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.6.11",
3
+ "version": "2.6.13",
4
4
  "description": "A unified terminal viewer for Claude Code and OpenCode with seamless switching",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -15,7 +15,7 @@ import Database from 'better-sqlite3';
15
15
  process.title = 'claude-opencode-viewer';
16
16
 
17
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
- const PORT = parseInt(process.argv[2]) || 7008;
18
+ let PORT = parseInt(process.argv[2]) || 7008;
19
19
  const IS_PC = process.argv.includes('--pc');
20
20
  const USE_HTTPS = process.argv.includes('--https');
21
21
  const JSON_OUTPUT = process.argv.includes('--json');
@@ -72,6 +72,7 @@ let lastPtyCols = 120;
72
72
  let lastPtyRows = 30;
73
73
 
74
74
  let activeWs = null;
75
+ let currentSessionId = null;
75
76
  const clientSizes = new Map();
76
77
  const mobileClients = new Set();
77
78
  let currentMode = 'opencode';
@@ -229,6 +230,30 @@ async function spawnProcess(mode, sessionId = null) {
229
230
  } catch {}
230
231
  }
231
232
  opencodeProcess = proc;
233
+
234
+ // 追踪当前会话 ID
235
+ if (sessionId) {
236
+ currentSessionId = sessionId;
237
+ console.log(`[session] 当前会话 ID: ${currentSessionId}`);
238
+ } else {
239
+ // 新建会话:延迟查数据库获取最新 session ID
240
+ currentSessionId = null;
241
+ setTimeout(() => {
242
+ try {
243
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
244
+ const row = db.prepare(
245
+ `SELECT id FROM session WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_created DESC LIMIT 1`
246
+ ).get();
247
+ db.close();
248
+ if (row) {
249
+ currentSessionId = row.id;
250
+ console.log(`[session] 检测到新会话 ID: ${currentSessionId}`);
251
+ }
252
+ } catch (e) {
253
+ console.log('[session] 查询新会话 ID 失败:', e.message);
254
+ }
255
+ }, 3000);
256
+ }
232
257
  }
233
258
 
234
259
  currentProcess = proc;
@@ -479,6 +504,16 @@ const requestHandler = async (req, res) => {
479
504
  return;
480
505
  }
481
506
 
507
+ // API: 获取当前会话 ID
508
+ if (req.url === '/api/current-session') {
509
+ res.writeHead(200, {
510
+ 'Content-Type': 'application/json',
511
+ 'Access-Control-Allow-Origin': '*',
512
+ });
513
+ res.end(JSON.stringify({ sessionId: currentSessionId }));
514
+ return;
515
+ }
516
+
482
517
  // API: 获取 git status
483
518
  if (req.url === '/api/git-status') {
484
519
  res.writeHead(200, {
@@ -795,23 +830,45 @@ wss.on('connection', (ws, req) => {
795
830
  process.on('SIGINT', () => process.exit(0));
796
831
  process.on('SIGTERM', () => process.exit(0));
797
832
 
798
- server.listen(PORT, '0.0.0.0', async () => {
799
- const ip = getLocalIp();
800
- const proto = USE_HTTPS ? 'https' : 'http';
833
+ // PC 模式端口范围,用于端口冲突重试
834
+ const PC_PORT_MIN = 19200;
835
+ const PC_PORT_MAX = 19220;
836
+ const MAX_PORT_RETRIES = 10;
801
837
 
802
- if (JSON_OUTPUT) {
803
- // 输出一行 JSON 供外部程序解析,然后继续运行
804
- console.log(JSON.stringify({ port: PORT, url: `${proto}://127.0.0.1:${PORT}`, ip, proto, pid: process.pid }));
805
- } else {
806
- console.log('\n' + '='.repeat(50));
807
- console.log('✅ Claude OpenCode Viewer 已启动');
808
- console.log('='.repeat(50));
809
- console.log(`🖥️ 本地访问:${proto}://127.0.0.1:${PORT}`);
810
- console.log(`📱 手机访问:${proto}://${ip}:${PORT}`);
811
- if (USE_HTTPS) console.log('🔐 HTTPS 模式(首次访问需信任自签名证书)');
812
- console.log('='.repeat(50));
813
- console.log('\n按 Ctrl+C 停止服务\n');
814
- }
838
+ function startServer(retries = 0) {
839
+ server.listen(PORT, '0.0.0.0', async () => {
840
+ const ip = getLocalIp();
841
+ const proto = USE_HTTPS ? 'https' : 'http';
815
842
 
816
- await spawnProcess('opencode');
817
- });
843
+ if (JSON_OUTPUT) {
844
+ console.log(JSON.stringify({ port: PORT, url: `${proto}://127.0.0.1:${PORT}`, ip, proto, pid: process.pid }));
845
+ } else {
846
+ console.log('\n' + '='.repeat(50));
847
+ console.log('✅ Claude OpenCode Viewer 已启动');
848
+ console.log('='.repeat(50));
849
+ console.log(`🖥️ 本地访问:${proto}://127.0.0.1:${PORT}`);
850
+ console.log(`📱 手机访问:${proto}://${ip}:${PORT}`);
851
+ if (USE_HTTPS) console.log('🔐 HTTPS 模式(首次访问需信任自签名证书)');
852
+ console.log('='.repeat(50));
853
+ console.log('\n按 Ctrl+C 停止服务\n');
854
+ }
855
+
856
+ await spawnProcess('opencode');
857
+ });
858
+
859
+ server.on('error', (err) => {
860
+ if (err.code === 'EADDRINUSE' && IS_PC && retries < MAX_PORT_RETRIES) {
861
+ // PC 模式端口冲突,顺序查找下一个可用端口
862
+ const oldPort = PORT;
863
+ PORT = PORT >= PC_PORT_MAX ? PC_PORT_MIN : PORT + 1;
864
+ console.error(`[port] ${oldPort} 已占用,尝试 ${PORT} (${retries + 1}/${MAX_PORT_RETRIES})`);
865
+ server.removeAllListeners('error');
866
+ startServer(retries + 1);
867
+ } else {
868
+ console.error(`启动失败: ${err.message}`);
869
+ process.exit(1);
870
+ }
871
+ });
872
+ }
873
+
874
+ startServer();