@wendongfly/myhi 1.3.204 → 1.3.206

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/chat.html CHANGED
@@ -324,6 +324,28 @@
324
324
  @media (pointer: coarse) { #cmd-input { font-size: 1rem; padding: 0.75rem 0.75rem 0.35rem; line-height: 1.55; } }
325
325
  #input-toolbar { display: flex; align-items: center; padding: 0.25rem 0.4rem 0.4rem; gap: 0.15rem; }
326
326
  #input-toolbar .spacer { flex: 1; }
327
+ #model-chip { display: inline-flex; align-items: center; gap: 0.25rem; height: 28px; max-width: 11rem; padding: 0 0.55rem; margin-right: 0.15rem; border-radius: 14px; border: 1px solid var(--border); background: var(--surface2); color: var(--text2, var(--text)); font-size: 0.74rem; cursor: pointer; flex-shrink: 1; min-width: 0; }
328
+ #model-chip:hover { border-color: var(--purple); }
329
+ #model-chip svg { width: 14px; height: 14px; flex-shrink: 0; }
330
+ #model-chip #model-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
331
+ #model-chip .mc-caret { font-size: 0.7rem; color: var(--muted); flex-shrink: 0; }
332
+ @media (max-width: 480px) { #model-chip { max-width: 7.5rem; } }
333
+ #model-pop-ov { position: fixed; inset: 0; z-index: 80; display: none; }
334
+ #model-pop { position: fixed; z-index: 81; display: none; flex-direction: column; width: min(92vw, 340px); max-height: min(60vh, 460px); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.3); }
335
+ #model-pop.open, #model-pop-ov.open { display: flex; }
336
+ #model-pop-list { overflow-y: auto; padding: 0.3rem 0; flex: 1; min-height: 0; }
337
+ #model-pop .mp-h { padding: 0.45rem 0.85rem 0.15rem; font-size: 0.68rem; color: var(--muted); }
338
+ #model-pop .mp-i { display: flex; align-items: center; gap: 0.45rem; padding: 0.45rem 0.85rem; cursor: pointer; font-size: 0.82rem; color: var(--text); }
339
+ #model-pop .mp-i:hover { background: var(--surface2); }
340
+ #model-pop .mp-i.on { background: var(--surface2); font-weight: 600; }
341
+ #model-pop .mp-n { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
342
+ #model-pop .mp-sub { display: block; font-size: 0.66rem; color: var(--muted); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
343
+ #model-pop .mp-tag { font-size: 0.62rem; padding: 0 0.35rem; border-radius: 8px; border: 1px solid; white-space: nowrap; flex-shrink: 0; font-weight: 400; }
344
+ #model-pop .mp-r { font-size: 0.68rem; color: var(--muted); white-space: nowrap; flex-shrink: 0; }
345
+ #model-pop .mp-ck { color: var(--green); flex-shrink: 0; }
346
+ #model-pop .mp-foot { border-top: 1px solid var(--surface2); padding: 0.45rem 0.6rem; display: flex; gap: 0.5rem; align-items: center; }
347
+ #model-pop .mp-foot input { flex: 1; min-width: 0; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 7px; font-size: 0.74rem; padding: 0.3rem 0.5rem; outline: none; }
348
+ #model-pop .mp-foot a { font-size: 0.74rem; color: var(--text2, var(--text)); text-decoration: none; white-space: nowrap; }
327
349
  .inj-btn { flex-shrink: 0; height: 30px; border: 1px solid var(--yellow); background: rgba(210,153,34,0.12); color: var(--yellow); border-radius: 8px; padding: 0 0.6rem; font-size: 0.76rem; font-weight: 600; cursor: pointer; white-space: nowrap; align-self: center; }
328
350
  .inj-btn:hover { background: rgba(210,153,34,0.22); }
329
351
  .inj-tag { font-size: 0.66rem; margin-right: 0.35rem; color: var(--yellow); }
@@ -406,7 +428,44 @@
406
428
  .fp-header h3 { flex: 1; font-size: 0.9rem; margin: 0; color: var(--text); }
407
429
  .fp-btn { background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 0.3rem 0.6rem; font-size: 0.75rem; cursor: pointer; }
408
430
  .fp-btn:active { background: var(--border); }
409
- .fp-path { padding: 0.4rem 0.75rem; font-size: 0.7rem; color: var(--muted); border-bottom: 1px solid var(--surface2); word-break: break-all; flex-shrink: 0; }
431
+ .fp-path { padding: 0.4rem 0.75rem; font-size: 0.74rem; color: var(--muted); border-bottom: 1px solid var(--surface2); word-break: break-all; flex-shrink: 0; line-height: 1.6; }
432
+ .fp-path .crumb { color: var(--blue); cursor: pointer; }
433
+ .fp-path .crumb:hover { text-decoration: underline; }
434
+ .fp-path .crumb.cur { color: var(--text); cursor: default; text-decoration: none; font-weight: 600; }
435
+ .fp-path .sep { margin: 0 0.25rem; color: var(--muted); }
436
+ .fp-x { padding: 0.25rem 0.55rem; }
437
+ #fp-tools { display: flex; gap: 0.4rem; align-items: center; padding: 0.35rem 0.75rem; border-bottom: 1px solid var(--surface2); flex-shrink: 0; }
438
+ #fp-sel-bar { display: none; align-items: center; gap: 0.3rem; padding: 0.45rem 0.6rem; background: var(--surface2); font-size: 0.76rem; border-top: 1px solid var(--border); flex-shrink: 0; }
439
+ #fp-sel-bar .fp-btn { padding: 0.3rem 0.45rem; white-space: nowrap; }
440
+ .fp-item .fp-more { color: var(--muted); padding: 0 0.45rem; font-size: 1rem; line-height: 1; letter-spacing: 1px; flex-shrink: 0; align-self: flex-start; border-radius: 6px; cursor: pointer; }
441
+ .fp-item .fp-more:hover { background: var(--surface2); color: var(--text); }
442
+ #fp-viewer { position: fixed; inset: 0; z-index: 95; background: rgba(0,0,0,0.92); display: none; flex-direction: column; }
443
+ #fp-viewer.open { display: flex; }
444
+ #fp-viewer .fv-head { display: flex; align-items: center; gap: 0.4rem; padding: 0.55rem 0.75rem; padding-top: max(0.55rem, env(safe-area-inset-top)); color: #eee; flex-shrink: 0; }
445
+ #fp-viewer .fv-title { flex: 1; min-width: 0; display: flex; flex-direction: column; font-size: 0.85rem; }
446
+ #fp-viewer .fv-title b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
447
+ #fp-viewer .fv-title span { font-size: 0.68rem; color: #aaa; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'SF Mono', Consolas, monospace; }
448
+ #fp-viewer .fp-btn { background: rgba(255,255,255,0.12); color: #fff; border-color: rgba(255,255,255,0.2); white-space: nowrap; }
449
+ #fp-viewer .fv-body { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 0 0.5rem max(0.5rem, env(safe-area-inset-bottom)); }
450
+ #fp-viewer .fv-body img { max-width: 100%; max-height: 100%; object-fit: contain; cursor: zoom-in; background: repeating-conic-gradient(#ccc 0 25%, #fff 0 50%) 0 0 / 16px 16px; }
451
+ #fp-viewer .fv-body img.zoom { max-width: none; max-height: none; cursor: zoom-out; }
452
+ #fp-viewer .fv-body.zoomed { align-items: flex-start; justify-content: flex-start; }
453
+ #fp-viewer .fv-body video { max-width: 100%; max-height: 100%; }
454
+ #fp-viewer .fv-body audio { width: min(90%, 32rem); }
455
+ #fp-viewer .fv-body iframe { width: 100%; height: 100%; border: 0; background: #fff; border-radius: 6px; }
456
+ #fp-viewer .fv-msg { color: #ddd; text-align: center; font-size: 0.88rem; line-height: 1.7; }
457
+ #fp-viewer .fv-nav { position: absolute; top: 50%; transform: translateY(-50%); width: 2.6rem; height: 3.6rem; border: none; border-radius: 8px; background: rgba(255,255,255,0.14); color: #fff; font-size: 1.8rem; cursor: pointer; display: none; }
458
+ #fp-viewer .fv-nav.prev { left: 0.5rem; } #fp-viewer .fv-nav.next { right: 0.5rem; }
459
+ #fp-viewer.multi .fv-nav { display: block; }
460
+ /* 行 / 工具 ⋯ 菜单:电脑上是贴着鼠标的小菜单,手机上是底部抽屉 */
461
+ #fp-menu-ov { position: fixed; inset: 0; z-index: 90; }
462
+ #fp-menu { position: fixed; z-index: 91; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.3); padding: 0.3rem 0; min-width: 12rem; max-width: 18rem; }
463
+ #fp-menu.sheet { left: 0 !important; right: 0; bottom: 0; top: auto !important; max-width: none; border-radius: 16px 16px 0 0; padding-bottom: max(0.6rem, env(safe-area-inset-bottom)); }
464
+ #fp-menu .mh { padding: 0.4rem 0.9rem 0.3rem; font-size: 0.7rem; color: var(--muted); word-break: break-all; border-bottom: 1px solid var(--surface2); }
465
+ #fp-menu button { display: block; width: 100%; text-align: left; background: none; border: none; color: var(--text); font-size: 0.84rem; padding: 0.55rem 0.9rem; cursor: pointer; }
466
+ #fp-menu.sheet button { padding: 0.8rem 1.1rem; font-size: 0.9rem; }
467
+ #fp-menu button:hover { background: var(--surface2); }
468
+ #fp-menu button small { color: var(--muted); font-size: 0.7rem; margin-left: 0.3rem; }
410
469
  .fp-list { flex: 1; overflow-y: auto; padding: 0.25rem 0; }
411
470
  .fp-item { display: flex; align-items: center; padding: 0.5rem 0.75rem; gap: 0.5rem; cursor: pointer; font-size: 0.82rem; color: var(--text); }
412
471
  .fp-item:active { background: var(--surface2); }
@@ -419,12 +478,15 @@
419
478
  #fp-multi-btn.on { border-color: var(--purple); color: var(--purple); }
420
479
  .fp-item .fp-dl { color: var(--blue); font-size: 0.78rem; text-decoration: none; padding: 0.2rem 0.3rem; flex-shrink: 0; align-self: flex-start; }
421
480
  .fp-empty { text-align: center; color: var(--muted); padding: 2rem 0.75rem; font-size: 0.85rem; }
481
+ /* 拖拽上传区:平时不占位置,电脑上把文件拖进抽屉时才出现(手机没法拖拽) */
422
482
  .fp-drop-zone {
483
+ display: none;
423
484
  margin: 0.5rem 0.75rem; padding: 1.5rem; border: 2px dashed var(--border); border-radius: 8px;
424
485
  text-align: center; color: var(--muted); font-size: 0.8rem; flex-shrink: 0;
425
486
  transition: border-color 0.2s, background 0.2s;
426
487
  }
427
488
  .fp-drop-zone.dragover { border-color: var(--blue); background: rgba(88,166,255,0.08); }
489
+ #file-panel.dragging .fp-drop-zone { display: block; border-color: var(--blue); background: rgba(88,166,255,0.08); color: var(--blue); }
428
490
  .fp-progress { padding: 0.4rem 0.75rem; font-size: 0.75rem; color: var(--yellow); flex-shrink: 0; display: none; }
429
491
  .fp-sync-bar { display: flex; gap: 0.4rem; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--surface2); flex-shrink: 0; align-items: center; flex-wrap: wrap; }
430
492
  .fp-sync-bar .fp-btn { font-size: 0.7rem; padding: 0.25rem 0.5rem; }
@@ -760,6 +822,11 @@
760
822
  <input type="file" id="file-input" style="display:none" onchange="uploadTempFile(this.files[0]); this.value='';">
761
823
  <span id="enter-hint" style="display:none;font-size:0.68rem;color:var(--muted);white-space:nowrap;align-self:center;margin-left:0.15rem"></span>
762
824
  <div class="spacer"></div>
825
+ <!-- 模型:当前会话用的模型,点开在上方选(读 myhi 本地场景配置 + 网关模型目录) -->
826
+ <button id="model-chip" style="display:none" onclick="openModelPop()" title="切换模型">
827
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
828
+ <span id="model-chip-name">默认</span><span class="mc-caret">⌃</span>
829
+ </button>
763
830
  <button class="tb-btn" id="voice-btn" onclick="openVaSheet()" title="语音输入 / 需求助手">
764
831
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
765
832
  </button>
@@ -802,7 +869,6 @@
802
869
  <div id="sk-claude-agent" style="display:none">
803
870
  <button class="sk sk-claude" id="sk-inbox" data-sk="draft" onclick="openInboxSheet()" style="color:var(--yellow)">📥 草稿<span id="inbox-badge" style="display:none;margin-left:0.2rem;background:var(--yellow);color:#000;border-radius:8px;padding:0 0.32rem;font-size:0.66rem;font-weight:700"></span></button>
804
871
  <button class="sk sk-claude" data-sk="folder" onclick="fpOpenFolder()" style="color:var(--blue2)">📂 文件夹</button>
805
- <button class="sk sk-claude" data-sk="model" onclick="openModelSheet()">模型</button>
806
872
  <button class="sk sk-claude" id="btn-plan" data-sk="plan" onclick="togglePlanMode()">计划</button>
807
873
  <button class="sk sk-claude" data-sk="compact" onclick="doSlashCmd('/compact')">压缩</button>
808
874
  <button class="sk sk-claude" data-sk="resume" onclick="openResumeSheet()">恢复</button>
@@ -826,10 +892,8 @@
826
892
  <div id="file-panel-backdrop" onclick="closeFilePanel()"></div>
827
893
  <div id="file-panel">
828
894
  <div class="fp-header">
829
- <h3 id="fp-title">文件管理</h3>
830
- <button class="fp-btn" onclick="fpUploadClick()">上传</button>
831
- <button class="fp-btn" onclick="fpStartSync()">同步</button>
832
- <button class="fp-btn" onclick="closeFilePanel()">关闭</button>
895
+ <h3 id="fp-title">文件</h3>
896
+ <button class="fp-btn fp-x" onclick="closeFilePanel()" title="关闭">✕</button>
833
897
  </div>
834
898
  <div class="fp-sync-bar" id="fp-sync-bar" style="display:none">
835
899
  <span class="sync-dir" id="fp-sync-dir"></span>
@@ -837,29 +901,45 @@
837
901
  <button class="fp-btn" onclick="fpExitSync()">退出同步</button>
838
902
  </div>
839
903
  <div class="fp-path" id="fp-path"></div>
840
- <div id="fp-tools" style="display:flex;gap:0.4rem;align-items:center;padding:0.3rem 0.75rem;flex-wrap:wrap">
841
- <button class="fp-btn" onclick="fpOpenFolder()" title="在本机桌面打开该任务的文件夹——仅当浏览器与 myhi 服务在同一台机器时有效;远程访问不会有效果,请用下方列表浏览/下载">📂 打开当前文件夹(不支持远程)</button>
904
+ <!-- 工具栏只留常用的两个;同步 / 排序 / 本机打开文件夹收进 ⋯ -->
905
+ <div id="fp-tools">
906
+ <button class="fp-btn" onclick="fpUploadClick()" title="上传文件到当前目录(电脑上也可以直接把文件拖进来)">⬆ 上传</button>
907
+ <button class="fp-btn" id="fp-multi-btn" onclick="fpToggleMulti()" title="勾选多个文件 / 文件夹:打包下载,或一次引用给 AI">☑ 选择</button>
842
908
  <span style="flex:1"></span>
843
- <button class="fp-btn" id="fp-multi-btn" onclick="fpToggleMulti()" title="勾选多个文件 / 文件夹,打包成一个 zip 下载">☑ 多选</button>
844
- <button class="fp-btn" id="fp-sort-btn" onclick="fpToggleSort()" title="切换排序">排序: 名称</button>
909
+ <button class="fp-btn" onclick="fpToolsMenu(event)" title="更多:同步到本地、排序、在本机打开文件夹">⋯</button>
845
910
  </div>
846
- <div id="fp-sel-bar" style="display:none;align-items:center;gap:0.4rem;padding:0.35rem 0.75rem;background:var(--surface2);font-size:0.78rem;flex-wrap:wrap">
847
- <span id="fp-sel-count" style="color:var(--muted)">已选 0 项</span>
911
+ <div class="fp-list" id="fp-list"></div>
912
+ <!-- 选择模式的操作栏放底部:手指够得着 -->
913
+ <div id="fp-sel-bar">
914
+ <span id="fp-sel-count" style="color:var(--muted);white-space:nowrap">已选0</span>
848
915
  <span style="flex:1"></span>
849
916
  <button class="fp-btn" onclick="fpSelAll()">全选</button>
917
+ <button class="fp-btn" id="fp-sel-ref" onclick="fpReferSelected()" disabled title="把选中的文件路径一起插进消息框,让 AI 读这些文件">@ 引用</button>
850
918
  <button class="fp-btn fp-btn-primary" id="fp-sel-dl" onclick="fpZipSelected()" disabled>⬇ 打包下载</button>
851
- <button class="fp-btn" onclick="fpToggleMulti(false)">取消</button>
919
+ <button class="fp-btn" onclick="fpToggleMulti(false)" title="取消选择">✕</button>
852
920
  </div>
853
- <div class="fp-list" id="fp-list"></div>
854
921
  <div class="fp-diff-actions" id="fp-diff-actions" style="display:none">
855
922
  <button class="fp-btn fp-btn-primary" onclick="fpSyncToLocal()">下载到本地</button>
856
923
  <button class="fp-btn fp-btn-warn" onclick="fpSyncToServer()">上传到服务器</button>
857
924
  </div>
858
925
  <div class="fp-progress" id="fp-progress"></div>
859
- <div class="fp-drop-zone" id="fp-drop">拖拽文件到此处上传,或点击上方按钮</div>
926
+ <div class="fp-drop-zone" id="fp-drop">松开鼠标,上传到当前目录</div>
860
927
  <input type="file" id="fp-file-input" multiple style="display:none">
861
928
  </div>
862
929
 
930
+ <!-- 文件预览:图片 / PDF / 音视频(浏览器原生能显示的),文件面板和聊天里的文件链接共用 -->
931
+ <div id="fp-viewer">
932
+ <div class="fv-head">
933
+ <div class="fv-title"><b id="fv-name"></b><span id="fv-path"></span></div>
934
+ <button class="fp-btn" id="fv-open" title="在浏览器新标签页里打开">↗ 新标签</button>
935
+ <button class="fp-btn" id="fv-dl" title="下载">⬇</button>
936
+ <button class="fp-btn" onclick="fpClosePreview()" title="关闭 (Esc)">✕</button>
937
+ </div>
938
+ <div class="fv-body" id="fv-body"></div>
939
+ <button class="fv-nav prev" id="fv-prev" onclick="fpPreviewStep(-1)" title="上一张 (←)">‹</button>
940
+ <button class="fv-nav next" id="fv-next" onclick="fpPreviewStep(1)" title="下一张 (→)">›</button>
941
+ </div>
942
+
863
943
  <!-- 设置面板 -->
864
944
  <div id="settings-backdrop" onclick="closeSettings()"></div>
865
945
  <div id="settings-panel">
@@ -902,7 +982,6 @@
902
982
  <div class="setting-group" data-stab="task" style="display:none">
903
983
  <div class="set-task-hint">这些设置只对当前任务(或它所在的项目)生效,快捷栏里隐藏了的也能从这里打开。</div>
904
984
  <div class="set-task-grid">
905
- <button onclick="closeSettings();openModelSheet()">🤖 模型</button>
906
985
  <button onclick="closeSettings();openGuardSheet()">🛡 隐私护栏</button>
907
986
  <button onclick="closeSettings();openSecretsSheet()">🔑 密钥</button>
908
987
  <button onclick="closeSettings();openFollowUpSheet()">🔄 自动跟进</button>
@@ -938,6 +1017,14 @@
938
1017
  </div>
939
1018
  </div>
940
1019
 
1020
+ <div id="model-pop-ov" onclick="closeModelPop()"></div>
1021
+ <div id="model-pop">
1022
+ <div id="model-pop-list"></div>
1023
+ <div class="mp-foot">
1024
+ <input id="model-pop-custom" placeholder="其他模型:输入名字后回车" autocomplete="off" spellcheck="false" onkeydown="if(event.key==='Enter'&&this.value.trim()){closeModelPop();selectModel({id:this.value.trim(),label:this.value.trim()});this.value=''}">
1025
+ <a id="model-pop-cfg" href="/?settings=1" target="_top">⚙ 配置模型</a>
1026
+ </div>
1027
+ </div>
941
1028
  <div id="model-sheet" class="action-sheet">
942
1029
  <div class="action-sheet-backdrop" onclick="closeModelSheet()"></div>
943
1030
  <div class="action-sheet-box">
@@ -1288,6 +1375,7 @@
1288
1375
  <button class="pri" onmousedown="event.preventDefault()" onclick="askFromSelection(false)">💡 解释</button>
1289
1376
  <button onmousedown="event.preventDefault()" onclick="askFromSelection(true)">❓ 问一问…</button>
1290
1377
  <button onmousedown="event.preventDefault()" onclick="askCopySelection()">📋 复制</button>
1378
+ <button onmousedown="event.preventDefault()" onclick="askSendToAI()" title="把选中的内容引用到输入框,接着写要求发给当前任务的 AI">💬 发给 AI</button>
1291
1379
  </div>
1292
1380
  <div id="ask-panel" role="dialog" aria-label="问一问">
1293
1381
  <div class="ask-head">
@@ -3062,6 +3150,8 @@
3062
3150
  updateControlUI(session.controlHolder === socket.id, session.controlHolderName);
3063
3151
 
3064
3152
  claudeSession = isClaudeSession(session) || session.mode === 'agent';
3153
+ document.getElementById('model-chip').style.display = session.mode === 'agent' ? '' : 'none';
3154
+ if (session.mode === 'agent') refreshModelChip();
3065
3155
  updateShortcutBar();
3066
3156
  updatePlanButton();
3067
3157
  ctxUpdate(session.contextTokens || 0);
@@ -3648,6 +3738,11 @@
3648
3738
  saveDraftFromInput('busy');
3649
3739
  return;
3650
3740
  }
3741
+ // 网关积分用尽:发出去大概率被网关拒,先提醒一句(本地 / 斜杠命令不算)
3742
+ if (currentSession?.mode === 'agent' && !text.startsWith('/')) {
3743
+ const q = quotaState();
3744
+ if (q.out && !confirm(q.msg + '\n\n发出去大概率会被网关拒绝。仍要发送吗?')) return;
3745
+ }
3651
3746
 
3652
3747
  if (!isController) {
3653
3748
  if (canTakeControl()) { socket.emit('take-control', { sessionId: SESSION_ID }); }
@@ -4056,7 +4151,7 @@
4056
4151
  <b>让它读文件</b>:打 <b>@</b> 从本项目逐层浏览、打字搜索;项目外打 <b>@D:</b>、<b>@/</b>、<b>@~/</b> 按路径逐层浏览;选中插路径;或点「文件」面板的 <b>@</b> 引用。<br>
4057
4152
  <b>工具/思考</b>:AI 干活的过程默认折叠,点「🔧 N 个工具调用」展开看命令与输出。<br>
4058
4153
  <b>AI 忙时</b>:想补充或提醒,点输入框右边「⚡ 插话」——不打断,AI 下一步就会看到并调整(气泡上显示「AI 已看到」);不急的就回车「存草稿」排队,草稿里可排顺序、编辑、合并,开「依次自动发送」后每干完一轮自动发下一条,AI 忙时也能把草稿「⚡ 插入」当前任务。<br>
4059
- <b>看不懂就问</b>:在聊天里<b>选中</b>看不懂的词或一段话(手机长按选中),点「💡 解释」或「❓ 问一问」,答案在旁边小窗里,可以继续追问;这些问答<b>不会打扰正在干活的 AI</b>,想让它知道就点「👉 告诉 AI」。<br>
4154
+ <b>看不懂就问</b>:在聊天里<b>选中</b>看不懂的词或一段话(手机长按选中),点「💡 解释」或「❓ 问一问」,答案在旁边小窗里,可以继续追问;这些问答<b>不会打扰正在干活的 AI</b>,想让它知道就点「👉 告诉 AI」。想直接拿这段内容跟 AI 说事,点「💬 发给 AI」:内容会引用到输入框,接着写要求再发送。<br>
4060
4155
  <b>AI 提问 / 交方案</b>:AI 需要你拿主意时会弹出选项,选一个即可,也可「跳过,让 AI 自己决定」;收起后输入框上方「❓ AI 在等你回答」可再打开。<br>
4061
4156
  <b>定位旧消息</b>:电脑上鼠标移到聊天区,右下角 ☰ 可列出你发过的消息点击跳转;看完点底部「↓ 回到底部」一键回来。
4062
4157
  ${sec('顶栏')}
@@ -4134,7 +4229,7 @@
4134
4229
  const r = await fetch(`/api/files/${SESSION_ID}?path=${encodeURIComponent(fpCurrentPath)}`);
4135
4230
  const data = await r.json();
4136
4231
  if (!data.ok) { list.innerHTML = `<div class="fp-empty">${escHtml(data.error)}</div>`; return; }
4137
- document.getElementById('fp-path').textContent = data.cwd + (data.path ? '/' + data.path : '');
4232
+ fpRenderCrumbs(data.cwd, data.path || '');
4138
4233
  if (!data.entries.length && data.parent == null) {
4139
4234
  list.innerHTML = '<div class="fp-empty">空目录</div>';
4140
4235
  return;
@@ -4156,19 +4251,15 @@
4156
4251
  html += `<div class="fp-item${on ? ' sel' : ''}" onclick="fpToggleSel('${escAttr(rel)}')"><input type="checkbox" class="fp-chk" ${on ? 'checked' : ''} tabindex="-1" style="pointer-events:none"><span class="fp-icon">${e.isDir ? '📁' : '📄'}</span><span class="fp-name" title="${escAttr(rel)}">${escHtml(e.name)}</span>${e.isDir ? '' : `<span class="fp-size">${fpFormatSize(e.size)}</span>`}</div>`;
4157
4252
  continue;
4158
4253
  }
4159
- if (e.isDir) {
4160
- html += `<div class="fp-item" onclick="fpNav('${escHtml(rel)}')"><span class="fp-icon">📁</span><span class="fp-name" title="${escAttr(rel)}">${escHtml(e.name)}</span><span class="fp-dl" onclick="event.stopPropagation();fpRefer('${escAttr(rel)}/')" title="引用:把目录路径插入消息框">@</span><span class="fp-dl" onclick="event.stopPropagation();fpZip(['${escAttr(rel)}'])" title="打包下载这个文件夹(zip)">⬇</span></div>`;
4161
- } else {
4162
- const editable = isEditableFile(e.name);
4163
- html += `<div class="fp-item" ${editable ? `style="cursor:pointer" onclick="openFileEditor('${escAttr(rel)}')" title="点击在线编辑"` : ''}>
4164
- <span class="fp-icon">${editable ? '📝' : '📄'}</span>
4165
- <span class="fp-name" title="${escAttr(rel)}">${escHtml(e.name)}</span>
4166
- <span class="fp-size">${fpFormatSize(e.size)}</span>
4167
- <span class="fp-dl" onclick="event.stopPropagation();fpRefer('${escAttr(rel)}')" title="引用:把文件路径插入消息框,让 AI 读这个文件">@</span>
4168
- ${editable ? `<span class="fp-dl" onclick="event.stopPropagation();openFileEditor('${escAttr(rel)}')" title="在线编辑">✏️</span>` : ''}
4169
- <a class="fp-dl" href="/api/files/${SESSION_ID}/download?path=${encodeURIComponent(rel)}" download title="下载" onclick="event.stopPropagation()">⬇</a>
4170
- </div>`;
4171
- }
4254
+ // 点整行 = 打开(文件夹进入 / 文件预览或编辑);其余操作都在行尾 ⋯(电脑上右键也行)
4255
+ const kind = e.isDir ? 'd' : isEditableFile(e.name) ? 'e' : previewKind(e.name) ? 'p' : 'f';
4256
+ const a = `'${escAttr(rel)}','${kind}'`;
4257
+ html += `<div class="fp-item" onclick="fpOpen(${a})" oncontextmenu="fpItemMenu(event,${a})" title="${kind === 'f' ? '点击查看可用操作' : '点击打开'}">
4258
+ <span class="fp-icon">${e.isDir ? '📁' : kind === 'e' ? '📝' : kind === 'p' ? ({ img: '🖼', pdf: '📕', video: '🎬', audio: '🎵' })[previewKind(e.name)] : '📄'}</span>
4259
+ <span class="fp-name">${escHtml(e.name)}</span>
4260
+ ${e.isDir ? '' : `<span class="fp-size">${fpFormatSize(e.size)}</span>`}
4261
+ <span class="fp-more" onclick="event.stopPropagation();fpItemMenu(event,${a})" title="更多操作">⋯</span>
4262
+ </div>`;
4172
4263
  }
4173
4264
  list.innerHTML = html;
4174
4265
  } catch (e) {
@@ -4186,8 +4277,9 @@
4186
4277
  bar.style.display = fpSel ? 'flex' : 'none';
4187
4278
  document.getElementById('fp-multi-btn').classList.toggle('on', !!fpSel);
4188
4279
  if (!fpSel) return;
4189
- document.getElementById('fp-sel-count').textContent = `已选 ${fpSel.size} 项`;
4280
+ document.getElementById('fp-sel-count').textContent = `已选${fpSel.size}`;
4190
4281
  document.getElementById('fp-sel-dl').disabled = !fpSel.size;
4282
+ document.getElementById('fp-sel-ref').disabled = !fpSel.size;
4191
4283
  }
4192
4284
  window.fpToggleMulti = function(on) {
4193
4285
  fpSel = (on ?? !fpSel) ? new Set() : null;
@@ -4205,6 +4297,149 @@
4205
4297
  fpSyncSelBar(); fpLoadFiles();
4206
4298
  };
4207
4299
  window.fpZipSelected = function() { if (fpSel?.size) fpZip([...fpSel]); };
4300
+ // 选中的一次引用给 AI:路径用空格连起来插进消息框
4301
+ window.fpReferSelected = function() {
4302
+ if (!fpSel?.size) return;
4303
+ const list = [...fpSel];
4304
+ fpToggleMulti(false);
4305
+ fpRefer(list.join(' '));
4306
+ };
4307
+
4308
+ // 面包屑:项目名 › 子目录 › …,点哪级回哪级
4309
+ function fpRenderCrumbs(cwd, path) {
4310
+ const el = document.getElementById('fp-path');
4311
+ const root = String(cwd || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '项目';
4312
+ const segs = path ? path.split('/') : [];
4313
+ const parts = [{ name: root, rel: '' }, ...segs.map((n, i) => ({ name: n, rel: segs.slice(0, i + 1).join('/') }))];
4314
+ el.innerHTML = parts.map((p, i) => i === parts.length - 1
4315
+ ? `<span class="crumb cur">${escHtml(p.name)}</span>`
4316
+ : `<span class="crumb" onclick="fpNav('${escAttr(p.rel)}')">${escHtml(p.name)}</span><span class="sep">›</span>`).join('');
4317
+ el.title = String(cwd || '') + (path ? '/' + path : '');
4318
+ }
4319
+
4320
+ // 点整行:文件夹进入;能在线看 / 改的打开(html / md 默认预览);其他文件(图片、压缩包…)弹出操作菜单
4321
+ window.fpOpen = function(rel, kind) {
4322
+ if (fpSel) return fpToggleSel(rel);
4323
+ if (kind === 'd') return fpNav(rel);
4324
+ if (kind === 'e') return openFileEditor(rel);
4325
+ if (kind === 'p') return fpPreview(rel);
4326
+ fpItemMenu(null, rel, kind);
4327
+ };
4328
+ window.fpCopyPath = function(rel) {
4329
+ const t = typeof feditAbs === 'function' ? feditAbs(rel) : rel;
4330
+ const ok = () => addStatusMessage('已复制路径:' + t);
4331
+ (navigator.clipboard?.writeText(t) || Promise.reject()).then(ok, () => {
4332
+ const x = document.createElement('textarea'); x.value = t; document.body.appendChild(x); x.select();
4333
+ try { document.execCommand('copy'); ok(); } catch {} x.remove();
4334
+ });
4335
+ };
4336
+ function fpDownload(rel) {
4337
+ const a = document.createElement('a');
4338
+ a.href = `/api/files/${SESSION_ID}/download?path=${encodeURIComponent(rel)}`; a.download = '';
4339
+ document.body.appendChild(a); a.click(); a.remove();
4340
+ }
4341
+
4342
+ // 通用小菜单:电脑(鼠标)贴着点击位置弹,手机从底部弹
4343
+ function fpMenu(ev, title, items) {
4344
+ document.getElementById('fp-menu-ov')?.remove(); document.getElementById('fp-menu')?.remove();
4345
+ const ov = document.createElement('div'); ov.id = 'fp-menu-ov';
4346
+ const m = document.createElement('div'); m.id = 'fp-menu';
4347
+ const sheet = !ev || !matchMedia('(pointer: fine)').matches;
4348
+ if (sheet) { m.classList.add('sheet'); ov.style.background = 'rgba(0,0,0,0.45)'; }
4349
+ m.innerHTML = (title ? `<div class="mh">${escHtml(title)}</div>` : '') +
4350
+ items.map((it, i) => `<button data-i="${i}">${it[0]}${it[2] ? `<small>${escHtml(it[2])}</small>` : ''}</button>`).join('');
4351
+ const close = () => { ov.remove(); m.remove(); };
4352
+ ov.onclick = close; ov.oncontextmenu = (e) => { e.preventDefault(); close(); };
4353
+ m.onclick = (e) => { const b = e.target.closest('button[data-i]'); if (!b) return; close(); items[+b.dataset.i][1](); };
4354
+ document.body.appendChild(ov); document.body.appendChild(m);
4355
+ if (!sheet) {
4356
+ const r = m.getBoundingClientRect();
4357
+ m.style.left = Math.max(8, Math.min(ev.clientX, innerWidth - r.width - 8)) + 'px';
4358
+ m.style.top = Math.max(8, Math.min(ev.clientY, innerHeight - r.height - 8)) + 'px';
4359
+ }
4360
+ }
4361
+ window.fpItemMenu = function(ev, rel, kind) {
4362
+ if (ev) { ev.preventDefault(); ev.stopPropagation(); }
4363
+ if (fpSel) return;
4364
+ const items = [];
4365
+ if (kind === 'd') items.push(['📂 打开', () => fpNav(rel)], ['⬇ 打包下载', () => fpZip([rel]), 'zip']);
4366
+ else {
4367
+ if (kind === 'e') items.push([/\.(html?|md|markdown)$/i.test(rel) ? '👁 查看 / 编辑' : '✏️ 编辑', () => openFileEditor(rel)]);
4368
+ if (kind === 'p') items.push(['👁 预览', () => fpPreview(rel)]);
4369
+ items.push(['⬇ 下载', () => fpDownload(rel)]);
4370
+ }
4371
+ items.push(['@ 引用到消息', () => fpRefer(kind === 'd' ? rel + '/' : rel), '让 AI 读它'], ['📋 复制路径', () => fpCopyPath(rel)]);
4372
+ fpMenu(ev, rel, items);
4373
+ };
4374
+ // ── 文件预览:图片 / PDF / 音视频 —— 浏览器自己能显示的直接看,不用先下载 ──────────
4375
+ window.previewKind = function(name) {
4376
+ const e = (String(name).toLowerCase().match(/\.([a-z0-9]+)$/) || [])[1] || '';
4377
+ if (/^(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/.test(e)) return 'img';
4378
+ if (e === 'pdf') return 'pdf';
4379
+ if (/^(mp4|webm|ogv|mov|m4v)$/.test(e)) return 'video';
4380
+ if (/^(mp3|wav|ogg|oga|m4a|flac|aac|opus)$/.test(e)) return 'audio';
4381
+ return null;
4382
+ };
4383
+ let _fvList = [], _fvIdx = 0;
4384
+ const fvUrl = (rel, inline) => `/api/files/${SESSION_ID}/download?path=${encodeURIComponent(rel)}${inline ? '&inline=1' : ''}`;
4385
+ window.fpPreview = function(rel) {
4386
+ // 同一目录的图片可以左右翻(从文件面板打开时)
4387
+ const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '';
4388
+ const sameDirImgs = previewKind(rel) === 'img' && dir === fpCurrentPath ? fpVisible.filter(r => previewKind(r) === 'img') : [];
4389
+ _fvList = sameDirImgs.length > 1 ? sameDirImgs : [rel];
4390
+ _fvIdx = Math.max(0, _fvList.indexOf(rel));
4391
+ document.getElementById('fp-viewer').classList.add('open');
4392
+ fvShow();
4393
+ };
4394
+ function fvShow() {
4395
+ const rel = _fvList[_fvIdx], kind = previewKind(rel), url = fvUrl(rel, true);
4396
+ const v = document.getElementById('fp-viewer'), body = document.getElementById('fv-body');
4397
+ v.classList.toggle('multi', _fvList.length > 1);
4398
+ document.getElementById('fv-name').textContent = rel.split('/').pop() + (_fvList.length > 1 ? ` (${_fvIdx + 1}/${_fvList.length})` : '');
4399
+ document.getElementById('fv-path').textContent = typeof feditAbs === 'function' ? feditAbs(rel) : rel;
4400
+ document.getElementById('fv-open').onclick = () => window.open(url, '_blank');
4401
+ document.getElementById('fv-dl').onclick = () => fpDownload(rel);
4402
+ body.classList.remove('zoomed');
4403
+ if (kind === 'img') {
4404
+ body.innerHTML = `<img src="${escAttr(url)}" alt="">`;
4405
+ const img = body.querySelector('img');
4406
+ img.onclick = () => { img.classList.toggle('zoom'); body.classList.toggle('zoomed', img.classList.contains('zoom')); }; // 点击看原尺寸
4407
+ img.onerror = () => { body.innerHTML = '<div class="fv-msg">图片打不开(可能已被删除或格式损坏)</div>'; };
4408
+ } else if (kind === 'video') {
4409
+ body.innerHTML = `<video src="${escAttr(url)}" controls autoplay playsinline></video>`;
4410
+ } else if (kind === 'audio') {
4411
+ body.innerHTML = `<audio src="${escAttr(url)}" controls autoplay></audio>`;
4412
+ } else if (kind === 'pdf') {
4413
+ // 手机浏览器大多不能把 PDF 嵌在页面里(安卓会直接下载):给按钮在新标签打开
4414
+ if (matchMedia('(pointer: coarse)').matches) {
4415
+ body.innerHTML = `<div class="fv-msg">手机浏览器不能在页面里直接显示 PDF<br><button class="fp-btn" style="margin-top:0.8rem;padding:0.5rem 1rem" onclick="window.open('${escAttr(url)}','_blank')">↗ 在新标签打开</button></div>`;
4416
+ } else body.innerHTML = `<iframe src="${escAttr(url)}"></iframe>`;
4417
+ }
4418
+ }
4419
+ window.fpPreviewStep = function(d) {
4420
+ if (_fvList.length < 2) return;
4421
+ _fvIdx = (_fvIdx + d + _fvList.length) % _fvList.length; fvShow();
4422
+ };
4423
+ window.fpClosePreview = function() {
4424
+ const v = document.getElementById('fp-viewer');
4425
+ v.classList.remove('open');
4426
+ document.getElementById('fv-body').innerHTML = ''; // 停掉正在播放的音视频
4427
+ };
4428
+ document.addEventListener('keydown', (e) => {
4429
+ if (!document.getElementById('fp-viewer').classList.contains('open')) return;
4430
+ if (e.key === 'Escape') fpClosePreview();
4431
+ else if (e.key === 'ArrowLeft') fpPreviewStep(-1);
4432
+ else if (e.key === 'ArrowRight') fpPreviewStep(1);
4433
+ });
4434
+
4435
+ window.fpToolsMenu = function(ev) {
4436
+ const items = [
4437
+ ['🔄 同步到本地文件夹', () => fpStartSync(), 'Chrome / Edge'],
4438
+ [fpSort === 'recent' ? '🔤 按名称排序' : '🕘 按最近修改排序', () => fpToggleSort()],
4439
+ ];
4440
+ if (!document.body.classList.contains('no-desktop')) items.push(['📂 在这台电脑上打开文件夹', () => fpOpenFolder(), '远程访问无效']);
4441
+ fpMenu(ev, '', items);
4442
+ };
4208
4443
  window.fpZip = async function(paths) {
4209
4444
  const prog = document.getElementById('fp-progress');
4210
4445
  prog.textContent = '正在统计要打包的文件…'; prog.style.display = 'block';
@@ -4285,13 +4520,16 @@
4285
4520
  setTimeout(() => { progress.style.display = 'none'; }, 3000);
4286
4521
  }
4287
4522
 
4288
- // 拖拽上传
4289
- const fpDrop = document.getElementById('fp-drop');
4290
- fpDrop.addEventListener('dragover', (e) => { e.preventDefault(); fpDrop.classList.add('dragover'); });
4291
- fpDrop.addEventListener('dragleave', () => fpDrop.classList.remove('dragover'));
4292
- fpDrop.addEventListener('drop', (e) => {
4293
- e.preventDefault();
4294
- fpDrop.classList.remove('dragover');
4523
+ // 拖拽上传:把文件拖到抽屉任意位置,出现提示区,松开即上传到当前目录
4524
+ const fpPanel = document.getElementById('file-panel');
4525
+ let fpDragDepth = 0;
4526
+ const isFileDrag = (e) => [...(e.dataTransfer?.types || [])].includes('Files');
4527
+ fpPanel.addEventListener('dragenter', (e) => { if (!isFileDrag(e) || syncMode) return; e.preventDefault(); fpDragDepth++; fpPanel.classList.add('dragging'); });
4528
+ fpPanel.addEventListener('dragover', (e) => { if (isFileDrag(e) && !syncMode) e.preventDefault(); });
4529
+ fpPanel.addEventListener('dragleave', () => { if (--fpDragDepth <= 0) { fpDragDepth = 0; fpPanel.classList.remove('dragging'); } });
4530
+ fpPanel.addEventListener('drop', (e) => {
4531
+ if (!isFileDrag(e) || syncMode) return;
4532
+ e.preventDefault(); fpDragDepth = 0; fpPanel.classList.remove('dragging');
4295
4533
  if (e.dataTransfer.files.length) fpUploadFiles(e.dataTransfer.files);
4296
4534
  });
4297
4535
 
@@ -4330,7 +4568,13 @@
4330
4568
  const rel = feditNormPath(String(rawPath || '').trim());
4331
4569
  if (!rel) { addStatusMessage('路径为空'); return; }
4332
4570
  if (!isEditableFile(rel)) {
4333
- addStatusMessage('「' + rel.split('/').pop() + '」不是可编辑的文本类型,请用下载');
4571
+ // 图片 / PDF / 音视频:浏览器能直接显示,打开预览(AI 只写文件名时在任务目录里按名字找)
4572
+ if (previewKind(rel)) {
4573
+ let p = rel;
4574
+ if (!rel.includes('/')) { const hit = await feditFindByName(rel); if (hit) p = hit; }
4575
+ return fpPreview(p);
4576
+ }
4577
+ addStatusMessage('「' + rel.split('/').pop() + '」浏览器打不开这种文件,请在文件面板里下载');
4334
4578
  return;
4335
4579
  }
4336
4580
  const sheet = document.getElementById('fedit-sheet');
@@ -4357,14 +4601,15 @@
4357
4601
  return;
4358
4602
  }
4359
4603
  const html = /\.html?$/i.test(d.name || d.path || '');
4360
- _fedit = { path: d.path, name: d.name, mtime: d.mtime, md: !!d.md, html, mode: 'edit', loaded: d.content, dirty: false };
4604
+ const svg = /\.svg$/i.test(d.name || d.path || '');
4605
+ _fedit = { path: d.path, name: d.name, mtime: d.mtime, md: !!d.md, html, svg, mode: 'edit', loaded: d.content, dirty: false };
4361
4606
  document.getElementById('fedit-path').textContent = feditAbs(d.path);
4362
4607
  document.getElementById('fedit-ta').value = d.content;
4363
4608
  document.getElementById('fedit-loading').style.display = 'none';
4364
- document.getElementById('fedit-tab-preview').style.display = (_fedit.md || html) ? '' : 'none';
4609
+ document.getElementById('fedit-tab-preview').style.display = (_fedit.md || html || svg) ? '' : 'none';
4365
4610
  document.getElementById('fedit-newtab').style.display = html ? '' : 'none';
4366
4611
  // 文档类(md / html)默认「看」,要改再点 ✏️ 编辑
4367
- feditSetMode(_fedit.md || html ? 'preview' : 'edit');
4612
+ feditSetMode(_fedit.md || html || svg ? 'preview' : 'edit');
4368
4613
  } catch (e) {
4369
4614
  addStatusMessage('打开出错: ' + e.message);
4370
4615
  sheet.classList.remove('open');
@@ -4423,15 +4668,21 @@
4423
4668
 
4424
4669
  window.feditSetMode = function(mode) {
4425
4670
  if (!_fedit) return;
4426
- if (mode === 'preview' && !_fedit.md && !_fedit.html) mode = 'edit';
4671
+ if (mode === 'preview' && !_fedit.md && !_fedit.html && !_fedit.svg) mode = 'edit';
4427
4672
  _fedit.mode = mode;
4428
4673
  const ta = document.getElementById('fedit-ta');
4429
4674
  const pv = document.getElementById('fedit-preview');
4430
4675
  ta.style.display = mode === 'edit' ? 'block' : 'none';
4431
4676
  pv.style.display = mode === 'preview' ? 'block' : 'none';
4432
- pv.classList.toggle('html', mode === 'preview' && !!_fedit.html);
4677
+ pv.classList.toggle('html', mode === 'preview' && !!(_fedit.html || _fedit.svg));
4433
4678
  document.getElementById('fedit-icon').textContent = mode === 'preview' ? '📄' : '✏️';
4434
- if (mode === 'preview') { if (_fedit.html) feditRenderHtml(pv, ta.value); else pv.innerHTML = renderMarkdown(ta.value); }
4679
+ if (mode === 'preview') {
4680
+ if (_fedit.html) feditRenderHtml(pv, ta.value);
4681
+ else if (_fedit.svg) { // SVG 用 <img> 渲染:图片上下文里内嵌脚本不执行;用编辑框内容,没保存的修改也能看
4682
+ const u = URL.createObjectURL(new Blob([ta.value], { type: 'image/svg+xml' }));
4683
+ pv.innerHTML = `<div style="height:100%;display:flex;align-items:center;justify-content:center;padding:0.5rem;box-sizing:border-box"><img src="${u}" style="max-width:100%;max-height:100%;object-fit:contain"></div>`;
4684
+ } else pv.innerHTML = renderMarkdown(ta.value);
4685
+ }
4435
4686
  document.getElementById('fedit-tab-edit').classList.toggle('on', mode === 'edit');
4436
4687
  document.getElementById('fedit-tab-preview').classList.toggle('on', mode === 'preview');
4437
4688
  };
@@ -5348,19 +5599,103 @@
5348
5599
  if (e.key === 'Enter') { e.preventDefault(); submitAskReply(); }
5349
5600
  });
5350
5601
 
5351
- window.openModelSheet = function() {
5352
- const list = document.getElementById('model-list');
5353
- list.innerHTML = '';
5354
- MODELS.forEach(m => {
5355
- const item = document.createElement('div');
5356
- item.className = 'action-sheet-item';
5357
- const isCurrent = currentModelId && m.id.includes(currentModelId);
5358
- item.innerHTML = `<div><div>${m.label}</div><div class="desc">${m.desc}</div></div>${isCurrent ? '<span class="check">✓</span>' : ''}`;
5359
- item.onclick = () => selectModel(m);
5360
- list.appendChild(item);
5602
+ // ── 模型:聊天框里的「模型」按钮 + 上方弹出列表 ─────────────────────────────
5603
+ // 列表 = myhi 本地配置(场景模型)+ 网关模型目录(这个 token 能用的,按厂商分组),当前会话的打勾
5604
+ const CAP_ICON = { vision: '🖼', function_calling: '🧰', reasoning: '🧠' };
5605
+ const TAG_LABEL = { recommended: ['推荐', 'var(--purple)'], cheap: ['便宜', 'var(--green)'], vision: ['看图', 'var(--blue)'], fast: ['快', 'var(--yellow)'], experimental: ['实验', 'var(--muted)'], deprecated: ['将下线', 'var(--red)'] };
5606
+ let _modelInfo = null; // /api/models 的结果(服务端缓存 10 分钟)
5607
+ async function loadModelInfo(force) {
5608
+ if (_modelInfo && !force) return _modelInfo;
5609
+ try { _modelInfo = await (await fetch('/api/models')).json(); } catch { _modelInfo = _modelInfo || {}; }
5610
+ return _modelInfo;
5611
+ }
5612
+ const modelName = (id) => { const m = (_modelInfo?.models || []).find(x => x.id === id); return m && m.kind !== 'alias' ? m.name : id; };
5613
+ // 网关积分(新网关的 /v1/models?scope=token 才带;网关按积分计费,1 积分 = $0.01):已用 / 上限,用尽时发消息前提醒
5614
+ function quotaState() {
5615
+ const t = _modelInfo?.token;
5616
+ if (!t) return { text: '', out: false };
5617
+ const fmt = (u, l) => `${u ?? '?'} / ${l} 积分`;
5618
+ const parts = [];
5619
+ if (t.dailyLimit != null) parts.push('今日已用 ' + fmt(t.dailyUsed, t.dailyLimit));
5620
+ if (t.monthlyLimit != null) parts.push('本月已用 ' + fmt(t.monthlyUsed, t.monthlyLimit));
5621
+ const dayOut = t.dailyLimit != null && t.dailyUsed != null && t.dailyUsed >= t.dailyLimit;
5622
+ const monOut = t.monthlyLimit != null && t.monthlyUsed != null && t.monthlyUsed >= t.monthlyLimit;
5623
+ const out = dayOut || monOut;
5624
+ return { text: parts.length ? '积分:' + parts.join(' · ') : '', out,
5625
+ msg: out ? `网关 token${t.name ? '「' + t.name + '」' : ''}${dayOut ? '今日' : '本月'}积分已用完(${dayOut ? fmt(t.dailyUsed, t.dailyLimit) : fmt(t.monthlyUsed, t.monthlyLimit)})` : '' };
5626
+ }
5627
+ // 额度会变:每 5 分钟后台刷新一次(服务端本身也缓存 10 分钟)
5628
+ setInterval(() => { if (currentSession?.mode === 'agent' && !document.hidden) loadModelInfo(true); }, 5 * 60_000);
5629
+ window.refreshModelChip = async function() {
5630
+ await loadModelInfo();
5631
+ const cur = currentSession?.modelId || '';
5632
+ const main = _modelInfo?.scenes?.main || '';
5633
+ const el = document.getElementById('model-chip-name');
5634
+ el.textContent = cur ? modelName(cur) : main ? modelName(main) : '默认';
5635
+ const chip = document.getElementById('model-chip'), q = quotaState();
5636
+ chip.title = '切换模型 · 当前:' + (cur || (main ? main + '(主会话默认)' : 'Claude Code 默认')) + (q.text ? '\n' + (q.out ? q.msg : q.text) : '');
5637
+ chip.style.borderColor = q.out ? 'var(--red)' : ''; // 积分用尽:按钮描红
5638
+ };
5639
+ window.openModelPop = async function() {
5640
+ const pop = document.getElementById('model-pop'), ov = document.getElementById('model-pop-ov'), list = document.getElementById('model-pop-list');
5641
+ list.innerHTML = '<div class="mp-h">加载模型列表…</div>';
5642
+ pop.classList.add('open'); ov.classList.add('open');
5643
+ placeModelPop();
5644
+ const d = await loadModelInfo(true);
5645
+ const cur = currentSession?.modelId || '';
5646
+ const main = d.scenes?.main || '';
5647
+ // unfit:不适合当主会话模型的原因(不支持工具等)→ 变灰 + 说明,点了先确认
5648
+ const row = (id, name, sub, right, tags, on, unfit, tip) => `<div class="mp-i${on ? ' on' : ''}" data-model="${escAttr(id)}" data-label="${escAttr(name)}"${unfit ? ` data-unfit="${escAttr(unfit)}" style="opacity:0.55"` : ''}${tip ? ` title="${escAttr(tip)}"` : ''}><span class="mp-n">${escHtml(name)}${unfit ? `<span class="mp-sub" style="color:var(--yellow)">⚠ ${escHtml(unfit)}</span>` : sub ? `<span class="mp-sub">${escHtml(sub)}</span>` : ''}</span>${(tags || []).map(t => TAG_LABEL[t] ? `<span class="mp-tag" style="color:${TAG_LABEL[t][1]};border-color:${TAG_LABEL[t][1]}">${TAG_LABEL[t][0]}</span>` : '').join('')}${right ? `<span class="mp-r">${escHtml(right)}</span>` : ''}${on ? '<span class="mp-ck">✓</span>' : ''}</div>`;
5649
+ const q = quotaState();
5650
+ let html = q.text ? `<div class="mp-h" style="${q.out ? 'color:var(--red);font-weight:600' : ''}">${escHtml(q.out ? q.msg : q.text)}</div>` : '';
5651
+ html += row('default', '默认', main ? '主会话默认:' + main : '跟随 Claude Code 默认', '', [], !cur);
5652
+ const gm = (d.models || []).filter(m => !m.id.includes('*')); // 通配别名(claude-*)不能直接选
5653
+ const listed = new Set(['default']);
5654
+ if (gm.length) {
5655
+ const by = {};
5656
+ for (const m of gm) (by[m.provider || '其他'] ||= []).push(m);
5657
+ for (const [prov, ms] of Object.entries(by)) {
5658
+ html += `<div class="mp-h">${escHtml(prov)}${d.scoped ? '' : ' · 网关未按 token 过滤'}</div>`;
5659
+ for (const m of ms) {
5660
+ listed.add(m.id);
5661
+ const sub = [m.kind === 'alias' && m.target ? '→ ' + m.target : '', m.caps.map(c => CAP_ICON[c]).filter(Boolean).join(' ')].filter(Boolean).join(' · ');
5662
+ // 右侧:有渠道系数就显示倍率(如 0.51x),否则显示单价;单价放悬停提示里
5663
+ const price = m.inputCost != null ? `$${m.inputCost}/$${m.outputCost} 每百万 token` : '';
5664
+ const right = m.multiplier != null ? `${m.multiplier}x` : (m.inputCost != null ? `$${m.inputCost}/$${m.outputCost}` : '');
5665
+ html += row(m.id, m.kind === 'alias' ? m.id : m.name, sub, right, m.tags, cur === m.id, m.fit?.main || '', [price, m.multiplier != null ? `渠道系数 ${m.multiplier}` : ''].filter(Boolean).join(' · '));
5666
+ }
5667
+ }
5668
+ } else if (d.gateway && d.error) html += `<div class="mp-h">网关模型列表读取失败:${escHtml(d.error)}</div>`;
5669
+ // 自定义:本地场景配置里填过、或本会话正在用、但不在网关列表里的名字
5670
+ const custom = [...new Set([cur, ...Object.values(d.scenes || {})].filter(x => x && !listed.has(x)))];
5671
+ if (custom.length) { html += '<div class="mp-h">自定义模型</div>'; for (const id of custom) { listed.add(id); html += row(id, id, '', '', [], cur === id); } }
5672
+ html += `<div class="mp-h">Claude 别名${d.gateway ? ' · 经网关映射' : ''}</div>`;
5673
+ for (const m of MODELS.filter(x => x.id !== 'default' && !listed.has(x.id))) html += row(m.id, m.label, m.desc, '', [], cur === m.id);
5674
+ list.innerHTML = html;
5675
+ list.querySelectorAll('[data-model]').forEach(el => el.onclick = () => {
5676
+ if (el.dataset.unfit && !confirm(`「${el.dataset.label}」${el.dataset.unfit}。\n\n用它当主会话模型,AI 很可能干不了活。仍要切换吗?`)) return;
5677
+ closeModelPop(); selectModel({ id: el.dataset.model, label: el.dataset.label });
5361
5678
  });
5362
- document.getElementById('model-sheet').classList.add('open');
5363
- };
5679
+ // 只有管理员能改场景模型:成员隐藏「配置模型」
5680
+ document.getElementById('model-pop-cfg').style.display = myRole === 'admin' ? '' : 'none';
5681
+ list.querySelector('.mp-i.on')?.scrollIntoView({ block: 'nearest' });
5682
+ placeModelPop();
5683
+ };
5684
+ // 贴着按钮弹在上方、右对齐;按钮不可见(PTY 会话 / 从设置菜单打开)时居中
5685
+ function placeModelPop() {
5686
+ const pop = document.getElementById('model-pop'), chip = document.getElementById('model-chip');
5687
+ const r = chip.getBoundingClientRect(), w = pop.offsetWidth, h = pop.offsetHeight;
5688
+ if (chip.offsetParent) {
5689
+ pop.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + 'px';
5690
+ pop.style.top = Math.max(8, r.top - h - 6) + 'px';
5691
+ } else { pop.style.left = Math.max(8, (innerWidth - w) / 2) + 'px'; pop.style.top = Math.max(8, (innerHeight - h) / 2) + 'px'; }
5692
+ }
5693
+ window.closeModelPop = function() {
5694
+ document.getElementById('model-pop').classList.remove('open');
5695
+ document.getElementById('model-pop-ov').classList.remove('open');
5696
+ };
5697
+ // 旧入口(终端会话快捷栏「模型」、设置菜单)都改走新弹层
5698
+ window.openModelSheet = function() { openModelPop(); };
5364
5699
  window.closeModelSheet = function() {
5365
5700
  document.getElementById('model-sheet').classList.remove('open');
5366
5701
  };
@@ -5380,7 +5715,8 @@
5380
5715
  }, 100);
5381
5716
  }
5382
5717
  currentModelId = m.id;
5383
- addStatusMessage('模型已切换为 ' + m.label);
5718
+ if (currentSession) currentSession.modelId = m.id === 'default' ? null : m.id; // 服务端会持久化,重启后照样用
5719
+ refreshModelChip();
5384
5720
  }
5385
5721
  window.selectCustomModel = function() {
5386
5722
  const v = (document.getElementById('model-custom').value || '').trim();
@@ -7239,6 +7575,19 @@
7239
7575
  if (custom) { renderAsk(); askInput.focus(); }
7240
7576
  else askSend('');
7241
7577
  };
7578
+ // 「发给 AI」:选中的内容以引用格式放进输入框,光标停在下面,接着写要求再发(不直接发出去)
7579
+ window.askSendToAI = function() {
7580
+ if (!_askSel) return;
7581
+ const quote = _askSel.text.trim().split('\n').map(l => '> ' + l).join('\n');
7582
+ askHidePop();
7583
+ window.getSelection()?.removeAllRanges();
7584
+ const cur = cmdInput.value.replace(/\s+$/, '');
7585
+ cmdInput.value = (cur ? cur + '\n\n' : '') + quote + '\n\n';
7586
+ cmdInput.focus();
7587
+ cmdInput.setSelectionRange(cmdInput.value.length, cmdInput.value.length);
7588
+ cmdInput.dispatchEvent(new Event('input')); // 让输入框按内容长高
7589
+ cmdInput.scrollTop = cmdInput.scrollHeight;
7590
+ };
7242
7591
  window.askCopySelection = function() {
7243
7592
  if (!_askSel) return;
7244
7593
  const t = _askSel.text;