@wendongfly/myhi 1.3.190 → 1.3.192

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.
package/dist/index.html CHANGED
@@ -11,9 +11,11 @@
11
11
  <link rel="apple-touch-icon" href="/icon.png">
12
12
  <title>myhi</title>
13
13
  <script src="/lib/machine.js"></script>
14
+ <script src="/lib/prefs.js"></script>
14
15
  <script src="/sio-client/socket.io.min.js"></script>
16
+ <script src="/theme.js"></script>
15
17
  <script>
16
- (function(){try{var t=localStorage.getItem('myhi_theme')||'dark',f=localStorage.getItem('myhi_font')||'medium',r=document.documentElement;if(t&&t!=='dark')r.setAttribute('data-theme',t);r.setAttribute('data-font',f);}catch(e){}})();
18
+ (function(){try{var t=localStorage.getItem('myhi_theme')||window.MYHI_DEFAULT_THEME||'dark',f=localStorage.getItem('myhi_font')||'medium',r=document.documentElement;if(t&&t!=='dark')r.setAttribute('data-theme',t);r.setAttribute('data-font',f);}catch(e){}})();
17
19
  </script>
18
20
  <style>
19
21
  :root {
@@ -35,8 +37,15 @@
35
37
  html[data-font="small"]{font-size:15px;} html[data-font="medium"]{font-size:17px;} html[data-font="large"]{font-size:19.5px;}
36
38
  * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
37
39
 
40
+ /* 整页滚动(原来只有中间 640px 的列表在滚:大屏上鼠标在两侧空白处滚不动,系统白色粗滚动条还贴着卡片)。
41
+ 顶栏 + 搜索区吸顶,滚动条回到浏览器最右,改成细的深色样式 */
42
+ html { scrollbar-width: thin; scrollbar-color: var(--border2) transparent; }
43
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
44
+ ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
45
+ ::-webkit-scrollbar-thumb:hover { background: var(--muted); }
46
+ ::-webkit-scrollbar-track { background: transparent; }
38
47
  html, body {
39
- height: 100%;
48
+ min-height: 100%;
40
49
  background: var(--bg);
41
50
  color: var(--text);
42
51
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
@@ -47,10 +56,11 @@
47
56
  #app {
48
57
  display: flex;
49
58
  flex-direction: column;
50
- height: 100%;
59
+ min-height: 100vh;
51
60
  max-width: 640px;
52
61
  margin: 0 auto;
53
62
  }
63
+ #view-controls { position: sticky; top: var(--hd-h, 57px); z-index: 9; background: var(--bg); padding-bottom: 0.4rem !important; }
54
64
 
55
65
  /* ── Header ── */
56
66
  #header {
@@ -98,6 +108,8 @@
98
108
  width: auto; white-space: nowrap; flex-shrink: 0;
99
109
  }
100
110
  .hd-btn:hover { color: var(--text); border-color: var(--border2); }
111
+ #settings-btn { position: relative; }
112
+ #settings-btn.has-dot::after { content: ''; position: absolute; top: -3px; right: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 2px var(--bg); }
101
113
  #user-name { display: inline-block; max-width: 7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; margin-left: 0.2rem; }
102
114
  /* 用户菜单:顶栏 👤 点开,修改密码 / 退出登录 */
103
115
  #user-menu { display: none; position: fixed; z-index: 40; min-width: 11rem; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,0.4); padding: 0.3rem; }
@@ -159,6 +171,87 @@
159
171
  .set-link:hover { background: var(--surface); }
160
172
  .set-link small { color: var(--muted); font-size: 0.7rem; }
161
173
  .set-link::after { content: '›'; margin-left: auto; color: var(--muted); font-size: 1rem; }
174
+ .ai-banner { margin: 0.75rem 0.75rem 0; padding: 0.7rem 0.85rem; border-radius: 10px; border: 1px solid rgba(210,153,34,0.45); background: rgba(210,153,34,0.1); font-size: 0.82rem; line-height: 1.6; color: var(--text); }
175
+ .ai-banner b { display: block; margin-bottom: 0.15rem; }
176
+ .ai-banner code { background: var(--bg); padding: 0.05rem 0.35rem; border-radius: 4px; font-size: 0.78rem; }
177
+ .ai-banner button { margin-top: 0.45rem; background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 0.42rem 0.9rem; font-size: 0.8rem; font-weight: 600; cursor: pointer; }
178
+ .ai-banner button:disabled { opacity: 0.6; cursor: default; }
179
+ .ai-banner.ok { border-color: rgba(63,185,80,0.45); background: rgba(63,185,80,0.1); }
180
+ .ai-banner.err { border-color: rgba(248,81,73,0.45); background: rgba(248,81,73,0.08); }
181
+ /* ── 电脑端工作台(≥1024px,body.ws):左侧任务栏 + 右侧嵌入当前任务会话(仿 DeepSeek)。手机/窄屏不变 ── */
182
+ #ws-main, #ws-foot, #ws-collapse, #ws-rail { display: none; }
183
+ body.ws { overflow: hidden; }
184
+ body.ws #app { position: fixed; left: 0; top: 0; bottom: 0; width: 290px; max-width: none; min-height: 0; margin: 0;
185
+ border-right: 1px solid var(--border); background: var(--surface); overflow: hidden; display: flex; flex-direction: column; }
186
+ body.ws #header { background: var(--surface); padding-top: 0.8rem; padding-bottom: 0.6rem; border-bottom: none; }
187
+ body.ws .hd-actions > :not(#conn-dot) { display: none !important; }
188
+ body.ws #ws-collapse { display: block; }
189
+ body.ws #view-controls { position: static; background: var(--surface); padding-top: 0 !important; }
190
+ body.ws #view-controls > div:first-child { flex-direction: column-reverse; align-items: stretch; }
191
+ body.ws #new-btn { display: block !important; width: 100%; padding: 0.55rem; }
192
+ body.ws #search-count { display: none; }
193
+ body.ws #sess-search { flex: none; width: 100%; }
194
+ body.ws #header { justify-content: flex-start; }
195
+ body.ws #ws-collapse { margin-left: auto; }
196
+ body.ws #list { flex: 1; overflow-y: auto; padding: 0.3rem 0.5rem 1rem; scrollbar-width: thin; }
197
+ body.ws .card { padding: 0.45rem 0.55rem; gap: 0.55rem; margin-bottom: 2px; border: 1px solid transparent; background: transparent; border-radius: 8px; }
198
+ body.ws .card:hover { background: var(--bg); }
199
+ body.ws .card.active { background: var(--bg); border-color: var(--border); }
200
+ body.ws .card.active .card-title { color: var(--accent); }
201
+ body.ws .avatar { width: 26px; height: 26px; border-radius: 7px; font-size: 0.78rem; }
202
+ body.ws .card-title { font-size: 0.84rem; font-weight: 500; }
203
+ body.ws .card-sub { font-size: 0.68rem; margin-top: 0.05rem; }
204
+ body.ws .card-gist, body.ws .card-tags { display: none !important; }
205
+ body.ws .group-header { margin: 0.6rem 0.3rem 0.2rem !important; }
206
+ body.ws #fab, body.ws #fab-note, body.ws #fab-tip { display: none !important; }
207
+ body.ws #ws-foot { display: flex; align-items: center; gap: 0.4rem; padding: 0.55rem 0.6rem 0.6rem 0.9rem; }
208
+ #ws-user { flex: 1; min-width: 0; font-size: 0.8rem; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
209
+ #ws-set { position: relative; background: none; border: none; color: var(--text2); font-size: 1.05rem; width: 34px; height: 34px; border-radius: 8px; cursor: pointer; }
210
+ #ws-set:hover, #ws-set.on { background: var(--bg); color: var(--text); }
211
+ #ws-set.has-dot::after, #ws-menu-dot.on { content: ''; position: absolute; top: 6px; right: 6px; width: 7px; height: 7px; border-radius: 50%; background: var(--red); }
212
+ #ws-menu { display: none; position: fixed; z-index: 45; min-width: 12rem; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.4); padding: 0.3rem; }
213
+ #ws-menu.open { display: block; }
214
+ #ws-menu button { position: relative; display: flex; align-items: baseline; gap: 0.5rem; width: 100%; text-align: left; background: none; border: none; color: var(--text); font-size: 0.84rem; padding: 0.5rem 0.6rem; border-radius: 7px; cursor: pointer; }
215
+ #ws-menu button:hover { background: var(--bg); }
216
+ #ws-menu button small { margin-left: auto; font-size: 0.68rem; color: var(--muted); }
217
+ #ws-menu button.danger { color: var(--red); }
218
+ #ws-menu hr { border: none; border-top: 1px solid var(--border); margin: 0.3rem 0.2rem; }
219
+ #ws-menu-dot.on { position: static; display: inline-block; margin-left: 0.3rem; }
220
+ #ws-collapse { background: none; border: none; color: var(--muted); font-size: 1rem; cursor: pointer; padding: 0.2rem 0.4rem; border-radius: 6px; }
221
+ #ws-collapse:hover { background: var(--bg); color: var(--text); }
222
+ body.ws #ws-main { display: block; position: fixed; left: 290px; top: 0; right: 0; bottom: 0; background: var(--bg); }
223
+ #ws-main iframe { width: 100%; height: 100%; border: none; display: block; }
224
+ #ws-welcome { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; padding: 2rem; color: var(--muted); text-align: center; }
225
+ #ws-welcome .logo { font-size: 2.2rem; }
226
+ #ws-welcome .ai-banner { margin: 0; max-width: 520px; text-align: left; }
227
+ #ws-welcome button.ws-new { background: var(--accent); color: #fff; border: none; border-radius: 10px; padding: 0.7rem 1.6rem; font-size: 0.95rem; font-weight: 600; cursor: pointer; }
228
+ /* 收起任务栏 → 留一条 56px 窄栏(仿 VS Code / DeepSeek):展开、新建、最近任务头像(悬停看名字、点了直接切)、设置。
229
+ 原来收起后只剩左下角一个小「» 任务」,还压在快捷栏上,普通用户根本发现不了 */
230
+ body.ws.ws-collapsed #app { display: none; }
231
+ body.ws.ws-collapsed #ws-main { left: 56px; }
232
+ body.ws.ws-collapsed #ws-rail { display: flex; position: fixed; left: 0; top: 0; bottom: 0; width: 56px; flex-direction: column; align-items: center;
233
+ gap: 0.35rem; padding: 0.7rem 0 0.6rem; background: var(--surface); border-right: 1px solid var(--border); z-index: 20; }
234
+ #ws-rail .rb { position: relative; width: 38px; height: 38px; border-radius: 10px; border: none; background: none; color: var(--text2); font-size: 1.05rem;
235
+ cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
236
+ #ws-rail .rb:hover { background: var(--bg); color: var(--text); }
237
+ #ws-rail .rb.new { background: var(--accent); color: #fff; font-size: 1.3rem; }
238
+ #ws-rail .rb.has-dot::after { content: ''; position: absolute; top: 5px; right: 5px; width: 7px; height: 7px; border-radius: 50%; background: var(--red); }
239
+ #ws-rail .rail-sep { width: 28px; border-top: 1px solid var(--border); margin: 0.2rem 0; flex-shrink: 0; }
240
+ #ws-rail .rail-list { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: none; display: flex; flex-direction: column; align-items: center; gap: 0.35rem; padding: 0.1rem 0; }
241
+ #ws-rail .rail-list::-webkit-scrollbar { display: none; }
242
+ #ws-rail .ra { position: relative; width: 34px; height: 34px; border-radius: 9px; color: #fff; font-weight: 700; font-size: 0.85rem; border: 2px solid transparent;
243
+ display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; font-family: 'SF Mono', Consolas, monospace; }
244
+ #ws-rail .ra:hover { filter: brightness(1.15); }
245
+ #ws-rail .ra.active { border-color: var(--text); box-shadow: 0 0 0 2px var(--bg); }
246
+ #ws-rail .ra.busy::after { content: ''; position: absolute; top: -3px; right: -3px; width: 9px; height: 9px; border-radius: 50%; background: var(--accent);
247
+ border: 2px solid var(--surface); animation: stPulse 1.2s ease-in-out infinite; }
248
+ #ws-rail .rail-more { font-size: 0.68rem; color: var(--muted); cursor: pointer; padding: 0.2rem; }
249
+ /* 窄栏悬停提示(原生 title 有延迟,这里即时显示) */
250
+ #ws-tip { position: fixed; z-index: 60; left: 62px; background: var(--text); color: var(--bg); font-size: 0.75rem; padding: 0.3rem 0.55rem; border-radius: 6px;
251
+ white-space: nowrap; pointer-events: none; display: none; max-width: 320px; overflow: hidden; text-overflow: ellipsis; }
252
+ /* 当前使用模式(单用户 / 多用户·管理员 / 多用户·成员),让成员知道为什么有些项是只读 */
253
+ .set-mode { font-size: 0.72rem; color: var(--muted); margin: 0.35rem 0.25rem 0; }
254
+ .set-mode a { color: var(--accent); text-decoration: none; }
162
255
  .set-foot { font-size: 0.7rem; color: var(--muted); line-height: 1.5; margin: 0.9rem 0.25rem 0.2rem; text-align: center; }
163
256
  /* 手机(≤540px):藏用户名、文字标签只留图标,一行放下且不憋屈 */
164
257
  @media (max-width: 540px) {
@@ -171,11 +264,10 @@
171
264
  /* ── Session list ── */
172
265
  #list {
173
266
  flex: 1;
174
- overflow-y: auto;
175
- -webkit-overflow-scrolling: touch;
176
- padding: 0.75rem 0.75rem 6rem;
267
+ padding: 0.5rem 0.75rem 6rem;
177
268
  }
178
269
 
270
+
179
271
  .empty-state {
180
272
  display: flex;
181
273
  flex-direction: column;
@@ -200,7 +292,7 @@
200
292
  display: flex;
201
293
  align-items: center;
202
294
  gap: 0.85rem;
203
- padding: 0.85rem 0.9rem;
295
+ padding: 0.7rem 0.85rem;
204
296
  background: var(--surface);
205
297
  border: 1px solid var(--border);
206
298
  border-radius: 12px;
@@ -213,10 +305,10 @@
213
305
  .card:active { background: #1c2230; }
214
306
 
215
307
  .avatar {
216
- width: 44px; height: 44px;
308
+ width: 38px; height: 38px;
217
309
  border-radius: 10px;
218
310
  display: flex; align-items: center; justify-content: center;
219
- font-size: 1.3rem;
311
+ font-size: 1.1rem;
220
312
  font-weight: 700;
221
313
  flex-shrink: 0;
222
314
  font-family: 'SF Mono', Consolas, monospace;
@@ -227,7 +319,10 @@
227
319
  flex: 1;
228
320
  min-width: 0;
229
321
  }
322
+ .card-title-row { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
323
+ .card-title-row .card-tags { display: flex; gap: 0.25rem; flex: 0 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; }
230
324
  .card-title {
325
+ flex: 0 1 auto; min-width: 0;
231
326
  font-size: 0.95rem;
232
327
  font-weight: 600;
233
328
  white-space: nowrap;
@@ -266,6 +361,12 @@
266
361
  }
267
362
  .status-dot.alive { background: var(--green); box-shadow: 0 0 5px var(--green); }
268
363
  .status-dot.dead { background: var(--red); }
364
+ /* 任务状态:执行中(AI 正在跑,跳动)/ 已停止;空闲不显示,免得一排绿点看不出谁在干活 */
365
+ .st { font-size: 0.68rem; white-space: nowrap; display: flex; align-items: center; gap: 0.3rem; }
366
+ .st-busy { color: var(--accent); font-weight: 600; }
367
+ .st-busy::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); animation: stPulse 1.2s ease-in-out infinite; }
368
+ @keyframes stPulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } }
369
+ .st-dead { color: var(--muted); }
269
370
  .viewers {
270
371
  font-size: 0.7rem;
271
372
  color: var(--muted);
@@ -599,6 +700,7 @@
599
700
  <div id="app">
600
701
  <div id="header">
601
702
  <div class="logo">my<span>hi</span></div>
703
+ <button id="ws-collapse" onclick="wsSetCollapsed(true)" title="收起任务栏(收起后左侧保留窄栏)">«</button>
602
704
  <div class="hd-actions">
603
705
  <button id="grid-btn" class="hd-btn" onclick="location.href='/grid'" title="分屏工作台(多任务并排)">▦<span class="hd-txt"> 分屏</span></button>
604
706
  <button id="doctor-btn" class="hd-btn" onclick="openDoctor()" title="会话体检">🩺<span class="hd-txt"> 体检</span></button>
@@ -611,6 +713,8 @@
611
713
  </div>
612
714
 
613
715
  <!-- 升级管理已移至 /admin -->
716
+ <!-- AI 引擎(Claude Code)没装时的提示:npm 装 myhi 不会顺带装它,新机器第一次用最容易卡在这 -->
717
+ <div id="ai-banner" class="ai-banner" style="display:none"></div>
614
718
 
615
719
  <div id="view-controls" style="display:flex;flex-direction:column;gap:0.5rem;padding:0.75rem 0.75rem 0">
616
720
  <!-- 会话搜索:任务多了光看标题认不出,连首条消息/目录/标签一起搜 -->
@@ -640,6 +744,42 @@
640
744
  <small>以后随时点右下角的 + 新建</small>
641
745
  </div>
642
746
  </div>
747
+ <!-- 工作台任务栏底部:平时只留一行「用户 + ⚙」,其余功能收进 ⚙ 菜单(功能再多也不占地方) -->
748
+ <div id="ws-foot">
749
+ <span id="ws-user">👤 本机</span>
750
+ <button id="ws-set" onclick="toggleWsMenu(event)" title="设置与更多">⚙</button>
751
+ </div>
752
+ </div>
753
+
754
+ <!-- 电脑端工作台:任务栏底部功能区 + 右侧会话区(body.ws 时显示) -->
755
+ <div id="ws-main"><div id="ws-welcome">
756
+ <div class="logo">my<span>hi</span></div>
757
+ <div>在左边选一个任务继续,或者新建一个</div>
758
+ <button class="ws-new" onclick="openSheet()">+ 新建任务</button>
759
+ </div></div>
760
+ <!-- 收起后的窄栏 -->
761
+ <div id="ws-rail">
762
+ <button class="rb" onclick="wsSetCollapsed(false)" data-tip="展开任务栏">☰</button>
763
+ <button class="rb new" onclick="openSheet()" data-tip="新建任务">+</button>
764
+ <div class="rail-sep"></div>
765
+ <div class="rail-list" id="rail-list"></div>
766
+ <div class="rail-sep"></div>
767
+ <button class="rb" id="rail-set" onclick="toggleWsMenu(event)" data-tip="设置与更多">⚙</button>
768
+ </div>
769
+ <div id="ws-tip"></div>
770
+ <!-- ⚙ 菜单(工作台底部 / 窄栏的 ⚙ 打开) -->
771
+ <div id="ws-menu">
772
+ <button onclick="wsMenuGo(openSettings)">⚙ 设置<span id="ws-menu-dot"></span></button>
773
+ <button onclick="wsMenuGo(() => location.href = '/grid')">▦ 分屏<small>多任务并排</small></button>
774
+ <button onclick="wsMenuGo(openNoteSheet)">📥 随手记<small>先记进草稿箱</small></button>
775
+ <button onclick="wsMenuGo(openDoctor)">🩺 会话体检</button>
776
+ <button onclick="wsMenuGo(showUsage)">📊 用量</button>
777
+ <button onclick="wsMenuGo(openHelpSheet)">❓ 帮助</button>
778
+ <div id="ws-menu-acct">
779
+ <hr>
780
+ <button id="ws-menu-pw" onclick="wsMenuGo(openPasswdSheet)" style="display:none">🔑 修改密码</button>
781
+ <button class="danger" onclick="wsMenuGo(doLogout)">↩ 退出登录</button>
782
+ </div>
643
783
  </div>
644
784
 
645
785
  <!-- FAB -->
@@ -718,6 +858,7 @@
718
858
  <div class="sheet-handle"></div>
719
859
  <div class="set-head"><b>设置</b><button class="set-x" onclick="closeSettings()" title="关闭 (Esc)">✕</button></div>
720
860
  </div>
861
+ <div id="set-mode" class="set-mode"></div>
721
862
 
722
863
  <div class="set-group-t">本设备</div>
723
864
  <div class="set-card">
@@ -745,12 +886,19 @@
745
886
  <div id="set-asr-status" class="set-status"></div>
746
887
  </div>
747
888
 
748
- <div class="set-group-t">服务</div>
749
- <div class="set-card">
750
- <a id="set-admin-link" class="set-link" href="/admin" style="display:none">🛠 管理后台 <small>用户 · 网关 · 密钥 · 升级</small></a>
751
- <button class="set-link" onclick="restartMyhi()">🔄 重启 myhi</button>
889
+ <div id="set-svc">
890
+ <div class="set-group-t" id="set-svc-t">服务</div>
891
+ <div class="set-card">
892
+ <div class="set-item">
893
+ <div class="set-lbl">myhi 版本<small id="set-ver-sub">检查中…</small></div>
894
+ <button id="set-up-btn" class="set-btn dirty" style="display:none" onclick="homeUpgrade()">升级</button>
895
+ </div>
896
+ <div id="set-up-status" class="set-status"></div>
897
+ <a id="set-admin-link" class="set-link admin-only" href="/admin">🛠 管理后台 <small>用户 · 网关 · 密钥 · Claude 升级</small></a>
898
+ <button class="set-link" onclick="restartMyhi()">🔄 重启 myhi</button>
899
+ </div>
900
+ <div id="set-restart-status" class="set-status" style="padding:0.45rem 0.25rem 0;margin:0"></div>
752
901
  </div>
753
- <div id="set-restart-status" class="set-status" style="padding:0.45rem 0.25rem 0;margin:0"></div>
754
902
  <div class="set-foot">聊天显示、回车键、快捷栏在会话页右上角 ⚙ 设置</div>
755
903
  </div>
756
904
 
@@ -877,17 +1025,27 @@
877
1025
 
878
1026
  // 加载当前用户信息(独占模式显示用户名、退出按钮、锁定目录)
879
1027
  let _userDir = null;
1028
+ let _isAdmin = true;
880
1029
  fetch('/api/me').then(r => r.json()).then(data => {
881
1030
  if ((data.exclusive || data.hasUsers) && data.name) {
882
1031
  document.getElementById('user-name').textContent = data.name;
883
1032
  document.getElementById('um-name').textContent = data.name;
884
1033
  document.getElementById('user-btn').style.display = '';
1034
+ document.getElementById('ws-user').textContent = '👤 ' + data.name;
885
1035
  fitHeader();
886
1036
  }
887
1037
  // 成员用户(users.json 登录)才显示自助改密入口;管理员改密码在 /admin
888
- if (data.role === 'admin') document.getElementById('set-admin-link').style.display = '';
889
- else document.getElementById('set-admin-link').remove(); // 别留个隐藏首项,免得下一项顶部多一道分隔线
890
- if (data.hasUsers && data.role === 'operator') document.getElementById('passwd-btn').style.display = '';
1038
+ // 改全局配置只给管理员(后端同样拦):成员的「服务」组没有管理后台入口,本机显示名与语音只读
1039
+ _isAdmin = (data.role || 'admin') === 'admin';
1040
+ document.getElementById('set-mode').innerHTML = !data.hasUsers ? '👤 单用户模式 · 所有功能都可用'
1041
+ : _isAdmin ? '👥 多用户模式 · 你是管理员 · <a href="/admin#mode">管理成员 →</a>'
1042
+ : `👥 多用户模式 · 你是成员「${esc(data.name || '')}」· 本机显示名、语音等全局设置需管理员修改`;
1043
+ if (!_isAdmin) document.querySelectorAll('#set-svc .admin-only').forEach(el => el.remove());
1044
+ checkAiEngine();
1045
+ checkHomeVersion();
1046
+ if (data.hasUsers && data.role === 'operator') { document.getElementById('passwd-btn').style.display = ''; document.getElementById('ws-menu-pw').style.display = ''; }
1047
+ // 单用户模式没有「登录账号」可退:⚙ 菜单不显示账户段
1048
+ if (!(data.exclusive || data.hasUsers)) document.getElementById('ws-menu-acct').style.display = 'none';
891
1049
  if (data.dir) {
892
1050
  _userDir = data.dir;
893
1051
  // 默认填入绑定目录,隐藏盘符栏,保留最近目录
@@ -996,9 +1154,14 @@
996
1154
  e.stopPropagation();
997
1155
  const m = document.getElementById('user-menu');
998
1156
  if (m.classList.contains('open')) return closeUserMenu();
999
- const r = document.getElementById('user-btn').getBoundingClientRect();
1000
- m.style.top = (r.bottom + 6) + 'px';
1001
- m.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
1157
+ const r = (e.currentTarget || document.getElementById('user-btn')).getBoundingClientRect();
1158
+ if (r.bottom > innerHeight - 220) { // 工作台任务栏底部的用户按钮:往上弹
1159
+ m.style.top = ''; m.style.bottom = (innerHeight - r.top + 6) + 'px';
1160
+ m.style.left = r.left + 'px'; m.style.right = 'auto';
1161
+ } else {
1162
+ m.style.bottom = ''; m.style.top = (r.bottom + 6) + 'px';
1163
+ m.style.left = 'auto'; m.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
1164
+ }
1002
1165
  m.classList.add('open');
1003
1166
  };
1004
1167
  window.closeUserMenu = function() { document.getElementById('user-menu').classList.remove('open'); };
@@ -1017,7 +1180,7 @@
1017
1180
  // ── 外观:主题 & 字号(与会话页共用 localStorage myhi_theme / myhi_font)──
1018
1181
  const THEME_META = { dark: '#0d1117', light: '#ffffff', black: '#000000', system: '#0d1117' };
1019
1182
  function applyAppearance() {
1020
- const t = localStorage.getItem('myhi_theme') || 'dark';
1183
+ const t = localStorage.getItem('myhi_theme') || window.MYHI_DEFAULT_THEME || 'dark';
1021
1184
  const f = localStorage.getItem('myhi_font') || 'medium';
1022
1185
  const r = document.documentElement;
1023
1186
  if (!t || t === 'dark') r.removeAttribute('data-theme'); else r.setAttribute('data-theme', t);
@@ -1029,6 +1192,7 @@
1029
1192
  // 顶栏按钮组折行了就逐级收紧:字号「大」时按钮跟着 rem 变宽,固定断点兜不住
1030
1193
  function fitHeader() {
1031
1194
  const h = document.getElementById('header');
1195
+ requestAnimationFrame(() => document.documentElement.style.setProperty('--hd-h', h.offsetHeight + 'px')); // 搜索区吸顶在顶栏下方
1032
1196
  const acts = h.querySelector('.hd-actions');
1033
1197
  const wraps = () => acts.offsetHeight > document.getElementById('settings-btn').offsetHeight * 1.5;
1034
1198
  h.classList.remove('hd-fit1', 'hd-fit2');
@@ -1042,7 +1206,7 @@
1042
1206
  const THEMES = [['system','跟随系统'],['dark','深色'],['light','浅色'],['black','纯黑']];
1043
1207
  const FONTS = [['small','小'],['medium','中'],['large','大']];
1044
1208
  function renderAppearance() {
1045
- const t = localStorage.getItem('myhi_theme') || 'dark';
1209
+ const t = localStorage.getItem('myhi_theme') || window.MYHI_DEFAULT_THEME || 'dark';
1046
1210
  const f = localStorage.getItem('myhi_font') || 'medium';
1047
1211
  const seg = (key, opts, cur) => opts.map(([v, l]) => `<button data-k="${key}" data-v="${v}" class="${v === cur ? 'on' : ''}">${l}</button>`).join('');
1048
1212
  document.getElementById('set-theme').innerHTML = seg('myhi_theme', THEMES, t);
@@ -1052,6 +1216,7 @@
1052
1216
  const b = e.target.closest('button[data-k]');
1053
1217
  if (!b) return;
1054
1218
  localStorage.setItem(b.dataset.k, b.dataset.v);
1219
+ window.myhiPrefsPush?.(); // 同步到其他设备
1055
1220
  applyAppearance(); renderAppearance();
1056
1221
  });
1057
1222
  function setStatus(id, text, color) {
@@ -1067,7 +1232,7 @@
1067
1232
 
1068
1233
  window.openSettings = function() {
1069
1234
  renderAppearance();
1070
- ['set-machine-status', 'set-asr-status', 'set-restart-status'].forEach(id => setStatus(id, ''));
1235
+ ['set-machine-status', 'set-asr-status', 'set-restart-status', 'set-up-status'].filter(id => document.getElementById(id)).forEach(id => setStatus(id, ''));
1071
1236
  document.querySelectorAll('#set-sheet .set-btn').forEach(b => b.classList.remove('dirty'));
1072
1237
  document.getElementById('set-backdrop').classList.add('open');
1073
1238
  document.getElementById('set-sheet').classList.add('open');
@@ -1075,12 +1240,17 @@
1075
1240
  const inp = document.getElementById('set-machine-name');
1076
1241
  inp.placeholder = d.hostname || '';
1077
1242
  inp.value = d.name === d.hostname ? '' : (d.name || '');
1078
- inp.disabled = !!d.fromEnv;
1243
+ inp.disabled = !!d.fromEnv || !_isAdmin;
1079
1244
  if (d.fromEnv) setStatus('set-machine-status', '已由环境变量 MYHI_NAME 指定,此处不可改');
1245
+ else if (!_isAdmin) setStatus('set-machine-status', '仅管理员可修改');
1080
1246
  }).catch(() => {});
1081
1247
  fetch('/api/server-config').then(readJson).then(d => {
1082
- document.getElementById('set-asr-url').value = d.asrUrl || '';
1248
+ const inp = document.getElementById('set-asr-url');
1249
+ inp.value = d.asrUrl || '';
1250
+ inp.disabled = !_isAdmin;
1251
+ if (!_isAdmin) setStatus('set-asr-status', '仅管理员可修改');
1083
1252
  }).catch(() => {});
1253
+ checkHomeVersion();
1084
1254
  };
1085
1255
  // 输入框改了内容 → 保存按钮亮起;回车 = 保存
1086
1256
  document.querySelectorAll('#set-sheet input[data-save]').forEach(inp => {
@@ -1125,6 +1295,97 @@
1125
1295
  } catch (e) { setStatus('set-asr-status', '保存失败:' + e.message, 'red'); }
1126
1296
  };
1127
1297
 
1298
+ // ── AI 引擎(Claude Code)检测 / 一键安装 ──
1299
+ const AI_MANUAL = '也可以在这台电脑上执行 <code>npm i -g @anthropic-ai/claude-code</code>';
1300
+ const AI_LOGIN = '还差一步:在这台电脑上打开终端,运行一次 <code>claude</code> 登录 Anthropic 账号;或者让管理员在 <a href="/admin">管理后台</a> 配置大模型网关。';
1301
+ function aiBanner(cls, html) {
1302
+ const el = document.getElementById('ai-banner');
1303
+ el.className = 'ai-banner' + (cls ? ' ' + cls : '');
1304
+ el.innerHTML = html;
1305
+ el.style.display = html ? '' : 'none';
1306
+ }
1307
+ async function checkAiEngine() {
1308
+ try {
1309
+ const d = await (await fetch('/api/claude/version', { cache: 'no-store' })).json();
1310
+ if (d.upgrade?.running) return pollAiInstall(Date.now());
1311
+ if (d.current) return aiBanner('', '');
1312
+ aiBanner('', '<b>⚠ AI 引擎还没安装</b>myhi 的 AI 任务靠 Claude Code 运行,这台电脑上还没有,发消息会失败。' +
1313
+ (_isAdmin ? '<br><button id="ai-install-btn" onclick="installAiEngine()">一键安装 AI 引擎</button><br><small>' + AI_MANUAL + '</small>'
1314
+ : '<br>请联系管理员安装。'));
1315
+ } catch {}
1316
+ }
1317
+ window.installAiEngine = async function() {
1318
+ if (!confirm('在这台电脑上安装 Claude Code(myhi 的 AI 引擎)?\n下载约 200MB,需要几分钟,期间可以继续用其他功能。')) return;
1319
+ try {
1320
+ const r = await fetch('/api/claude/update', { method: 'POST' });
1321
+ const d = await r.json().catch(() => ({}));
1322
+ if (!d.ok) return aiBanner('err', '<b>安装没有开始</b>' + esc(d.error || ('HTTP ' + r.status)) + '<br><small>' + AI_MANUAL + '</small>');
1323
+ pollAiInstall(Date.now());
1324
+ } catch (e) { aiBanner('err', '<b>安装没有开始</b>' + esc(e.message)); }
1325
+ };
1326
+ async function pollAiInstall(t0) {
1327
+ aiBanner('', `<b>⏳ 正在安装 AI 引擎…</b>已用 ${Math.round((Date.now() - t0) / 1000)} 秒,装好后这里会提示下一步。`);
1328
+ let s = null;
1329
+ try { s = await (await fetch('/api/claude/update-status', { cache: 'no-store' })).json(); } catch {}
1330
+ if (!s || s.running) return setTimeout(() => pollAiInstall(t0), 4000);
1331
+ if (s.ok) aiBanner('ok', `<b>✓ AI 引擎已安装(v${esc(s.version || '')})</b>` + AI_LOGIN);
1332
+ else aiBanner('err', '<b>安装失败</b><code style="white-space:pre-wrap;display:block;margin:0.3rem 0">' + esc(s.error || '') + '</code>' + AI_MANUAL);
1333
+ }
1334
+
1335
+ // ── 版本 / 升级(成员也可升级):有新版时 ⚙ 按钮挂小红点,设置里一键升级 ──
1336
+ let _homeVer = null;
1337
+ async function checkHomeVersion() {
1338
+ try {
1339
+ const r = await fetch('/api/version', { cache: 'no-store' });
1340
+ _homeVer = await r.json();
1341
+ const sub = document.getElementById('set-ver-sub'), btn = document.getElementById('set-up-btn');
1342
+ if (!sub) return;
1343
+ sub.textContent = `当前 v${_homeVer.current}` + (_homeVer.updateAvailable ? ` · 有新版 v${_homeVer.latest}` : ' · 已是最新');
1344
+ btn.style.display = _homeVer.updateAvailable ? '' : 'none';
1345
+ btn.textContent = '升级到 v' + _homeVer.latest;
1346
+ document.getElementById('settings-btn').classList.toggle('has-dot', !!_homeVer.updateAvailable);
1347
+ document.getElementById('ws-set').classList.toggle('has-dot', !!_homeVer.updateAvailable);
1348
+ document.getElementById('ws-menu-dot').classList.toggle('on', !!_homeVer.updateAvailable);
1349
+ document.getElementById('rail-set').classList.toggle('has-dot', !!_homeVer.updateAvailable);
1350
+ } catch {}
1351
+ }
1352
+ // 等升级落地:轮询 /api/version(重启/停服期间连不上是正常的)。成功 = 版本变了(且等于目标);
1353
+ // 失败 = 服务端报了本次升级的失败原因;10 分钟兜底超时。(会话页 / 管理后台同款)
1354
+ async function waitUpgrade(from, target, onTick) {
1355
+ const t0 = Date.now();
1356
+ while (Date.now() - t0 < 10 * 60 * 1000) {
1357
+ await new Promise(r => setTimeout(r, 3000));
1358
+ let v = null;
1359
+ try { const r = await fetch('/api/version', { cache: 'no-store' }); if (r.ok) v = await r.json(); } catch {}
1360
+ onTick?.(!!v, Math.round((Date.now() - t0) / 1000));
1361
+ if (!v) continue;
1362
+ if (v.current !== from && (!target || v.current === target)) return { ok: true, version: v.current };
1363
+ const last = v.upgrade?.last;
1364
+ if (!v.upgrade?.running && last && !last.ok && last.at >= t0 - 5000) return { ok: false, error: last.error };
1365
+ }
1366
+ return { ok: false, error: '等了 10 分钟仍未完成,请稍后刷新页面查看版本,或到服务器上查看 myhi log' };
1367
+ }
1368
+ window.homeUpgrade = async function(force) {
1369
+ if (!_homeVer?.updateAvailable) return;
1370
+ if (!force && !confirm(`升级 myhi?\n当前 v${_homeVer.current} → 最新 v${_homeVer.latest}\n\n升级后服务会自动重启,期间页面会短暂断开。`)) return;
1371
+ const btn = document.getElementById('set-up-btn');
1372
+ btn.disabled = true;
1373
+ setStatus('set-up-status', '正在提交升级…');
1374
+ try {
1375
+ const r = await fetch('/api/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force: !!force }) });
1376
+ const d = await r.json().catch(() => ({}));
1377
+ if (!d.ok) {
1378
+ btn.disabled = false;
1379
+ if (d.busy?.length && confirm(d.error + '\n\n仍然现在升级吗?')) return homeUpgrade(true);
1380
+ return setStatus('set-up-status', '升级失败:' + (d.error || ('HTTP ' + r.status)), 'red');
1381
+ }
1382
+ setStatus('set-up-status', d.message || '升级中…');
1383
+ const res = await waitUpgrade(d.from, d.target, (up, sec) => setStatus('set-up-status', (up ? '正在安装新版本… ' : '服务重启中… ') + sec + 's'));
1384
+ if (res.ok) { setStatus('set-up-status', `✓ 已升级到 v${res.version},即将刷新`, 'green'); setTimeout(() => location.reload(), 800); }
1385
+ else { btn.disabled = false; setStatus('set-up-status', '升级失败:' + res.error, 'red'); }
1386
+ } catch (e) { btn.disabled = false; setStatus('set-up-status', '升级失败:' + e.message, 'red'); }
1387
+ };
1388
+
1128
1389
  window.restartMyhi = async function() {
1129
1390
  if (!confirm('确认重启 myhi 服务?\n重启期间连接会短暂中断,页面将自动重连。')) return;
1130
1391
  try { await fetch('/api/restart', { method: 'POST' }); } catch {}
@@ -1216,7 +1477,12 @@
1216
1477
 
1217
1478
  // 视图状态:分组方式 + 标签筛选
1218
1479
  let _allSessions = [];
1219
- let _groupMode = localStorage.getItem('myhi_group_mode') || 'time'; // time | project | tag
1480
+ // 电脑端工作台状态(声明放这里:列表渲染会用到,必须先于任何一次渲染)
1481
+ let _wsCur = null; // 右侧当前打开的任务
1482
+ let _wsPending = new URLSearchParams(location.search).get('s'); // 刷新后回到上次的任务
1483
+ const _wsFrames = new Map(); // id → iframe,按最近使用排序(最后一个最新)
1484
+ // 电脑端工作台默认按项目分组(任务多了按项目找最快),手机默认按最近;用户切过就按用户的
1485
+ let _groupMode = localStorage.getItem('myhi_group_mode') || (matchMedia('(min-width: 1024px)').matches ? 'project' : 'time'); // time | project | tag
1220
1486
  let _tagFilter = null; // 仅 tag 模式下:只看某标签
1221
1487
  let _search = ''; // 会话搜索关键词(标题/首条需求/目录/标签)
1222
1488
  let _draftCounts = {}; // { sessionId: 待发草稿数 },给卡片打角标
@@ -1264,17 +1530,17 @@
1264
1530
  ? `<div style="font-size:0.66rem;color:#d29922;margin-top:0.2rem">📥 ${nDraft} 条待发草稿</div>`
1265
1531
  : '';
1266
1532
  return `
1267
- <div class="card" data-id="${safeId}" data-title="${safeTitle}">
1533
+ <div class="card${s.id === _wsCur ? ' active' : ''}" data-id="${safeId}" data-title="${safeTitle}">
1268
1534
  <div class="avatar" style="background:${color}">${letter}</div>
1269
1535
  <div class="card-body">
1270
- <div class="card-title">${safeTitle}</div>
1536
+ <div class="card-title-row"><div class="card-title">${safeTitle}</div>${tags ? `<div class="card-tags">${tags}</div>` : ''}</div>
1271
1537
  <div class="card-sub">${sub}</div>
1272
1538
  ${gist}
1273
1539
  ${draftChip}
1274
- ${tags ? `<div class="card-tags" style="display:flex;flex-wrap:wrap;gap:0.3rem;margin-top:0.3rem">${tags}</div>` : ''}
1275
1540
  </div>
1276
1541
  <div class="card-right">
1277
- <div class="status-dot ${s.alive ? 'alive' : 'dead'}"></div>
1542
+ ${!s.alive ? '<div class="st st-dead" title="会话进程已退出,点进去会自动恢复">已停止</div>'
1543
+ : s.busy ? '<div class="st st-busy" title="AI 正在执行">执行中</div>' : ''}
1278
1544
  ${s.viewers > 1 ? `<div class="viewers">👁 ${s.viewers}</div>` : ''}
1279
1545
  </div>
1280
1546
  </div>`;
@@ -1366,6 +1632,7 @@
1366
1632
  html = items.map(cardHTML).join('');
1367
1633
  }
1368
1634
  list.innerHTML = html;
1635
+ wsAfterRender();
1369
1636
 
1370
1637
  // 组头点击折叠/展开(项目多了列表很长)
1371
1638
  list.querySelectorAll('.group-header[data-gkey]').forEach(h => {
@@ -1452,6 +1719,7 @@
1452
1719
  if (first && !creating) showFabHint();
1453
1720
  };
1454
1721
  function showFabHint() {
1722
+ if (wsOn()) return; // 工作台里「+ 新建任务」就在左上角,不用再指右下角
1455
1723
  const fab = document.getElementById('fab'), tip = document.getElementById('fab-tip');
1456
1724
  fab.classList.add('hint'); tip.classList.add('show');
1457
1725
  const done = () => { fab.classList.remove('hint'); tip.classList.remove('show'); document.removeEventListener('pointerdown', done, true); };
@@ -1538,10 +1806,115 @@
1538
1806
  };
1539
1807
 
1540
1808
  // ── Navigation ──────────────────────────────────────────────
1809
+ // 电脑端工作台:在右侧打开;手机/窄屏:进会话页
1541
1810
  function openSession(id) {
1811
+ if (wsOn()) return wsOpen(id);
1542
1812
  window.location.href = `/terminal/${id}`;
1543
1813
  }
1544
1814
 
1815
+ // ── 电脑端工作台(≥1024px):左侧任务栏 + 右侧嵌入会话页(/terminal/:id?embed=ws)──
1816
+ // 最近打开的 5 个任务的会话页留在后台(隐藏不销毁),来回切换不用重载,滚动位置/没发的输入都还在。
1817
+ const WS_MQ = matchMedia('(min-width: 1024px)');
1818
+ const WS_KEEP = 5;
1819
+ function wsOn() { return document.body.classList.contains('ws'); }
1820
+ function wsOpen(id) {
1821
+ _wsCur = id;
1822
+ let f = _wsFrames.get(id);
1823
+ if (f) _wsFrames.delete(id);
1824
+ else {
1825
+ f = document.createElement('iframe');
1826
+ f.src = '/terminal/' + encodeURIComponent(id) + '?embed=ws';
1827
+ f.setAttribute('allow', 'microphone; camera; clipboard-read; clipboard-write');
1828
+ document.getElementById('ws-main').appendChild(f);
1829
+ }
1830
+ _wsFrames.set(id, f);
1831
+ while (_wsFrames.size > WS_KEEP) { const [oid, of] = _wsFrames.entries().next().value; _wsFrames.delete(oid); of.remove(); }
1832
+ for (const [k, fr] of _wsFrames) fr.style.display = k === id ? '' : 'none';
1833
+ document.getElementById('ws-welcome').style.display = 'none';
1834
+ history.replaceState(null, '', '/?s=' + encodeURIComponent(id));
1835
+ const s = _allSessions.find(x => x.id === id);
1836
+ document.title = (s?.title || 'myhi') + ' — myhi';
1837
+ wsMarkActive();
1838
+ setTimeout(() => f.contentWindow?.focus(), 50);
1839
+ }
1840
+ function wsShowWelcome() {
1841
+ _wsCur = null;
1842
+ for (const fr of _wsFrames.values()) fr.style.display = 'none';
1843
+ document.getElementById('ws-welcome').style.display = '';
1844
+ history.replaceState(null, '', '/');
1845
+ document.title = 'myhi';
1846
+ wsMarkActive();
1847
+ }
1848
+ function wsMarkActive() {
1849
+ document.querySelectorAll('#list .card').forEach(c => c.classList.toggle('active', c.dataset.id === _wsCur));
1850
+ document.querySelectorAll('#rail-list .ra').forEach(c => c.classList.toggle('active', c.dataset.id === _wsCur));
1851
+ }
1852
+ // 收起后的窄栏:最近的任务头像(最多 20 个,按最近活动),执行中带跳点,点了直接切
1853
+ const RAIL_MAX = 20;
1854
+ function renderRail() {
1855
+ const el = document.getElementById('rail-list');
1856
+ if (!el) return;
1857
+ const items = [..._allSessions].sort((a, b) => sessionTime(b) - sessionTime(a));
1858
+ el.innerHTML = items.slice(0, RAIL_MAX).map(s =>
1859
+ `<div class="ra${s.id === _wsCur ? ' active' : ''}${s.busy ? ' busy' : ''}" data-id="${esc(s.id)}" style="background:${avatarColor(s.title)}"` +
1860
+ ` data-tip="${esc(s.title || '')}${s.busy ? ' · 执行中' : !s.alive ? ' · 已停止' : ''}">${esc((s.title || '?')[0].toUpperCase())}</div>`).join('') +
1861
+ (items.length > RAIL_MAX ? `<div class="rail-more" data-tip="展开任务栏看全部 ${items.length} 个任务" onclick="wsSetCollapsed(false)">+${items.length - RAIL_MAX}</div>` : '');
1862
+ el.querySelectorAll('.ra').forEach(a => a.onclick = () => wsOpen(a.dataset.id));
1863
+ }
1864
+ // 窄栏即时悬停提示
1865
+ document.addEventListener('mouseover', (e) => {
1866
+ const t = e.target.closest('#ws-rail [data-tip]'), tip = document.getElementById('ws-tip');
1867
+ if (!t) { tip.style.display = 'none'; return; }
1868
+ const r = t.getBoundingClientRect();
1869
+ tip.textContent = t.dataset.tip;
1870
+ tip.style.top = (r.top + r.height / 2 - 13) + 'px';
1871
+ tip.style.display = 'block';
1872
+ });
1873
+ // 每次列表重绘后:刷新前打开的任务等列表到了再打开;当前任务被删了就回欢迎页、回收它的会话页
1874
+ function wsAfterRender() {
1875
+ if (!wsOn()) return;
1876
+ if (_wsPending && _allSessions.some(x => x.id === _wsPending)) { const id = _wsPending; _wsPending = null; wsOpen(id); return; }
1877
+ for (const [k, fr] of _wsFrames) if (!_allSessions.some(x => x.id === k)) { fr.remove(); _wsFrames.delete(k); if (k === _wsCur) wsShowWelcome(); }
1878
+ renderRail();
1879
+ wsMarkActive();
1880
+ }
1881
+ // ⚙ 菜单:从底部 / 窄栏的 ⚙ 往上弹,点外面或 Esc 关闭
1882
+ window.toggleWsMenu = function(e) {
1883
+ e.stopPropagation();
1884
+ const m = document.getElementById('ws-menu');
1885
+ if (m.classList.contains('open')) return closeWsMenu();
1886
+ const r = e.currentTarget.getBoundingClientRect();
1887
+ m.classList.add('open');
1888
+ // 右边缘对齐 ⚙、往左展开,留在任务栏范围内,不压右侧会话的输入框(窄栏的 ⚙ 在最左,就贴左边)
1889
+ m.style.left = Math.max(8, r.right - m.offsetWidth) + 'px';
1890
+ m.style.bottom = (innerHeight - r.top + 6) + 'px';
1891
+ document.getElementById('ws-set').classList.add('on');
1892
+ };
1893
+ function closeWsMenu() { document.getElementById('ws-menu').classList.remove('open'); document.getElementById('ws-set').classList.remove('on'); }
1894
+ window.wsMenuGo = function(fn) { closeWsMenu(); fn(); };
1895
+ document.addEventListener('click', (e) => { if (!e.target.closest('#ws-menu')) closeWsMenu(); });
1896
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeWsMenu(); });
1897
+
1898
+ window.wsSetCollapsed = function(on) {
1899
+ document.body.classList.toggle('ws-collapsed', on);
1900
+ localStorage.setItem('myhi_ws_collapsed', on ? '1' : '');
1901
+ document.getElementById('ws-tip').style.display = 'none';
1902
+ if (on) renderRail();
1903
+ };
1904
+ function applyWs() {
1905
+ const on = WS_MQ.matches && !new URLSearchParams(location.search).get('embed');
1906
+ document.body.classList.toggle('ws', on);
1907
+ document.body.classList.toggle('ws-collapsed', on && !!localStorage.getItem('myhi_ws_collapsed'));
1908
+ // 「AI 引擎没装」提示:工作台里放到右侧欢迎页,手机上放列表上方
1909
+ const banner = document.getElementById('ai-banner');
1910
+ const target = on ? document.getElementById('ws-welcome') : document.getElementById('view-controls');
1911
+ if (on) target.appendChild(banner); else target.parentNode.insertBefore(banner, target);
1912
+ if (!on && _wsCur) { location.href = '/terminal/' + encodeURIComponent(_wsCur); return; } // 窗口拉窄:直接进当前任务的会话页
1913
+ fitHeader();
1914
+ }
1915
+ WS_MQ.addEventListener('change', applyWs);
1916
+ applyWs();
1917
+
1545
1918
  // ── 会话体检 ────────────────────────────────────────────────
1546
1919
  window.openDoctor = function() {
1547
1920
  document.getElementById('doctor-menu').style.display = 'block';