@wendongfly/myhi 1.3.205 → 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); }
@@ -417,6 +439,24 @@
417
439
  #fp-sel-bar .fp-btn { padding: 0.3rem 0.45rem; white-space: nowrap; }
418
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; }
419
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; }
420
460
  /* 行 / 工具 ⋯ 菜单:电脑上是贴着鼠标的小菜单,手机上是底部抽屉 */
421
461
  #fp-menu-ov { position: fixed; inset: 0; z-index: 90; }
422
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; }
@@ -782,6 +822,11 @@
782
822
  <input type="file" id="file-input" style="display:none" onchange="uploadTempFile(this.files[0]); this.value='';">
783
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>
784
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>
785
830
  <button class="tb-btn" id="voice-btn" onclick="openVaSheet()" title="语音输入 / 需求助手">
786
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>
787
832
  </button>
@@ -824,7 +869,6 @@
824
869
  <div id="sk-claude-agent" style="display:none">
825
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>
826
871
  <button class="sk sk-claude" data-sk="folder" onclick="fpOpenFolder()" style="color:var(--blue2)">📂 文件夹</button>
827
- <button class="sk sk-claude" data-sk="model" onclick="openModelSheet()">模型</button>
828
872
  <button class="sk sk-claude" id="btn-plan" data-sk="plan" onclick="togglePlanMode()">计划</button>
829
873
  <button class="sk sk-claude" data-sk="compact" onclick="doSlashCmd('/compact')">压缩</button>
830
874
  <button class="sk sk-claude" data-sk="resume" onclick="openResumeSheet()">恢复</button>
@@ -883,6 +927,19 @@
883
927
  <input type="file" id="fp-file-input" multiple style="display:none">
884
928
  </div>
885
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
+
886
943
  <!-- 设置面板 -->
887
944
  <div id="settings-backdrop" onclick="closeSettings()"></div>
888
945
  <div id="settings-panel">
@@ -925,7 +982,6 @@
925
982
  <div class="setting-group" data-stab="task" style="display:none">
926
983
  <div class="set-task-hint">这些设置只对当前任务(或它所在的项目)生效,快捷栏里隐藏了的也能从这里打开。</div>
927
984
  <div class="set-task-grid">
928
- <button onclick="closeSettings();openModelSheet()">🤖 模型</button>
929
985
  <button onclick="closeSettings();openGuardSheet()">🛡 隐私护栏</button>
930
986
  <button onclick="closeSettings();openSecretsSheet()">🔑 密钥</button>
931
987
  <button onclick="closeSettings();openFollowUpSheet()">🔄 自动跟进</button>
@@ -961,6 +1017,14 @@
961
1017
  </div>
962
1018
  </div>
963
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>
964
1028
  <div id="model-sheet" class="action-sheet">
965
1029
  <div class="action-sheet-backdrop" onclick="closeModelSheet()"></div>
966
1030
  <div class="action-sheet-box">
@@ -1311,6 +1375,7 @@
1311
1375
  <button class="pri" onmousedown="event.preventDefault()" onclick="askFromSelection(false)">💡 解释</button>
1312
1376
  <button onmousedown="event.preventDefault()" onclick="askFromSelection(true)">❓ 问一问…</button>
1313
1377
  <button onmousedown="event.preventDefault()" onclick="askCopySelection()">📋 复制</button>
1378
+ <button onmousedown="event.preventDefault()" onclick="askSendToAI()" title="把选中的内容引用到输入框,接着写要求发给当前任务的 AI">💬 发给 AI</button>
1314
1379
  </div>
1315
1380
  <div id="ask-panel" role="dialog" aria-label="问一问">
1316
1381
  <div class="ask-head">
@@ -3085,6 +3150,8 @@
3085
3150
  updateControlUI(session.controlHolder === socket.id, session.controlHolderName);
3086
3151
 
3087
3152
  claudeSession = isClaudeSession(session) || session.mode === 'agent';
3153
+ document.getElementById('model-chip').style.display = session.mode === 'agent' ? '' : 'none';
3154
+ if (session.mode === 'agent') refreshModelChip();
3088
3155
  updateShortcutBar();
3089
3156
  updatePlanButton();
3090
3157
  ctxUpdate(session.contextTokens || 0);
@@ -3671,6 +3738,11 @@
3671
3738
  saveDraftFromInput('busy');
3672
3739
  return;
3673
3740
  }
3741
+ // 网关积分用尽:发出去大概率被网关拒,先提醒一句(本地 / 斜杠命令不算)
3742
+ if (currentSession?.mode === 'agent' && !text.startsWith('/')) {
3743
+ const q = quotaState();
3744
+ if (q.out && !confirm(q.msg + '\n\n发出去大概率会被网关拒绝。仍要发送吗?')) return;
3745
+ }
3674
3746
 
3675
3747
  if (!isController) {
3676
3748
  if (canTakeControl()) { socket.emit('take-control', { sessionId: SESSION_ID }); }
@@ -4079,7 +4151,7 @@
4079
4151
  <b>让它读文件</b>:打 <b>@</b> 从本项目逐层浏览、打字搜索;项目外打 <b>@D:</b>、<b>@/</b>、<b>@~/</b> 按路径逐层浏览;选中插路径;或点「文件」面板的 <b>@</b> 引用。<br>
4080
4152
  <b>工具/思考</b>:AI 干活的过程默认折叠,点「🔧 N 个工具调用」展开看命令与输出。<br>
4081
4153
  <b>AI 忙时</b>:想补充或提醒,点输入框右边「⚡ 插话」——不打断,AI 下一步就会看到并调整(气泡上显示「AI 已看到」);不急的就回车「存草稿」排队,草稿里可排顺序、编辑、合并,开「依次自动发送」后每干完一轮自动发下一条,AI 忙时也能把草稿「⚡ 插入」当前任务。<br>
4082
- <b>看不懂就问</b>:在聊天里<b>选中</b>看不懂的词或一段话(手机长按选中),点「💡 解释」或「❓ 问一问」,答案在旁边小窗里,可以继续追问;这些问答<b>不会打扰正在干活的 AI</b>,想让它知道就点「👉 告诉 AI」。<br>
4154
+ <b>看不懂就问</b>:在聊天里<b>选中</b>看不懂的词或一段话(手机长按选中),点「💡 解释」或「❓ 问一问」,答案在旁边小窗里,可以继续追问;这些问答<b>不会打扰正在干活的 AI</b>,想让它知道就点「👉 告诉 AI」。想直接拿这段内容跟 AI 说事,点「💬 发给 AI」:内容会引用到输入框,接着写要求再发送。<br>
4083
4155
  <b>AI 提问 / 交方案</b>:AI 需要你拿主意时会弹出选项,选一个即可,也可「跳过,让 AI 自己决定」;收起后输入框上方「❓ AI 在等你回答」可再打开。<br>
4084
4156
  <b>定位旧消息</b>:电脑上鼠标移到聊天区,右下角 ☰ 可列出你发过的消息点击跳转;看完点底部「↓ 回到底部」一键回来。
4085
4157
  ${sec('顶栏')}
@@ -4180,10 +4252,10 @@
4180
4252
  continue;
4181
4253
  }
4182
4254
  // 点整行 = 打开(文件夹进入 / 文件预览或编辑);其余操作都在行尾 ⋯(电脑上右键也行)
4183
- const kind = e.isDir ? 'd' : isEditableFile(e.name) ? 'e' : 'f';
4255
+ const kind = e.isDir ? 'd' : isEditableFile(e.name) ? 'e' : previewKind(e.name) ? 'p' : 'f';
4184
4256
  const a = `'${escAttr(rel)}','${kind}'`;
4185
4257
  html += `<div class="fp-item" onclick="fpOpen(${a})" oncontextmenu="fpItemMenu(event,${a})" title="${kind === 'f' ? '点击查看可用操作' : '点击打开'}">
4186
- <span class="fp-icon">${e.isDir ? '📁' : kind === 'e' ? '📝' : '📄'}</span>
4258
+ <span class="fp-icon">${e.isDir ? '📁' : kind === 'e' ? '📝' : kind === 'p' ? ({ img: '🖼', pdf: '📕', video: '🎬', audio: '🎵' })[previewKind(e.name)] : '📄'}</span>
4187
4259
  <span class="fp-name">${escHtml(e.name)}</span>
4188
4260
  ${e.isDir ? '' : `<span class="fp-size">${fpFormatSize(e.size)}</span>`}
4189
4261
  <span class="fp-more" onclick="event.stopPropagation();fpItemMenu(event,${a})" title="更多操作">⋯</span>
@@ -4250,6 +4322,7 @@
4250
4322
  if (fpSel) return fpToggleSel(rel);
4251
4323
  if (kind === 'd') return fpNav(rel);
4252
4324
  if (kind === 'e') return openFileEditor(rel);
4325
+ if (kind === 'p') return fpPreview(rel);
4253
4326
  fpItemMenu(null, rel, kind);
4254
4327
  };
4255
4328
  window.fpCopyPath = function(rel) {
@@ -4292,11 +4365,73 @@
4292
4365
  if (kind === 'd') items.push(['📂 打开', () => fpNav(rel)], ['⬇ 打包下载', () => fpZip([rel]), 'zip']);
4293
4366
  else {
4294
4367
  if (kind === 'e') items.push([/\.(html?|md|markdown)$/i.test(rel) ? '👁 查看 / 编辑' : '✏️ 编辑', () => openFileEditor(rel)]);
4368
+ if (kind === 'p') items.push(['👁 预览', () => fpPreview(rel)]);
4295
4369
  items.push(['⬇ 下载', () => fpDownload(rel)]);
4296
4370
  }
4297
4371
  items.push(['@ 引用到消息', () => fpRefer(kind === 'd' ? rel + '/' : rel), '让 AI 读它'], ['📋 复制路径', () => fpCopyPath(rel)]);
4298
4372
  fpMenu(ev, rel, items);
4299
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
+
4300
4435
  window.fpToolsMenu = function(ev) {
4301
4436
  const items = [
4302
4437
  ['🔄 同步到本地文件夹', () => fpStartSync(), 'Chrome / Edge'],
@@ -4433,7 +4568,13 @@
4433
4568
  const rel = feditNormPath(String(rawPath || '').trim());
4434
4569
  if (!rel) { addStatusMessage('路径为空'); return; }
4435
4570
  if (!isEditableFile(rel)) {
4436
- 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() + '」浏览器打不开这种文件,请在文件面板里下载');
4437
4578
  return;
4438
4579
  }
4439
4580
  const sheet = document.getElementById('fedit-sheet');
@@ -4460,14 +4601,15 @@
4460
4601
  return;
4461
4602
  }
4462
4603
  const html = /\.html?$/i.test(d.name || d.path || '');
4463
- _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 };
4464
4606
  document.getElementById('fedit-path').textContent = feditAbs(d.path);
4465
4607
  document.getElementById('fedit-ta').value = d.content;
4466
4608
  document.getElementById('fedit-loading').style.display = 'none';
4467
- document.getElementById('fedit-tab-preview').style.display = (_fedit.md || html) ? '' : 'none';
4609
+ document.getElementById('fedit-tab-preview').style.display = (_fedit.md || html || svg) ? '' : 'none';
4468
4610
  document.getElementById('fedit-newtab').style.display = html ? '' : 'none';
4469
4611
  // 文档类(md / html)默认「看」,要改再点 ✏️ 编辑
4470
- feditSetMode(_fedit.md || html ? 'preview' : 'edit');
4612
+ feditSetMode(_fedit.md || html || svg ? 'preview' : 'edit');
4471
4613
  } catch (e) {
4472
4614
  addStatusMessage('打开出错: ' + e.message);
4473
4615
  sheet.classList.remove('open');
@@ -4526,15 +4668,21 @@
4526
4668
 
4527
4669
  window.feditSetMode = function(mode) {
4528
4670
  if (!_fedit) return;
4529
- if (mode === 'preview' && !_fedit.md && !_fedit.html) mode = 'edit';
4671
+ if (mode === 'preview' && !_fedit.md && !_fedit.html && !_fedit.svg) mode = 'edit';
4530
4672
  _fedit.mode = mode;
4531
4673
  const ta = document.getElementById('fedit-ta');
4532
4674
  const pv = document.getElementById('fedit-preview');
4533
4675
  ta.style.display = mode === 'edit' ? 'block' : 'none';
4534
4676
  pv.style.display = mode === 'preview' ? 'block' : 'none';
4535
- pv.classList.toggle('html', mode === 'preview' && !!_fedit.html);
4677
+ pv.classList.toggle('html', mode === 'preview' && !!(_fedit.html || _fedit.svg));
4536
4678
  document.getElementById('fedit-icon').textContent = mode === 'preview' ? '📄' : '✏️';
4537
- 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
+ }
4538
4686
  document.getElementById('fedit-tab-edit').classList.toggle('on', mode === 'edit');
4539
4687
  document.getElementById('fedit-tab-preview').classList.toggle('on', mode === 'preview');
4540
4688
  };
@@ -5451,19 +5599,103 @@
5451
5599
  if (e.key === 'Enter') { e.preventDefault(); submitAskReply(); }
5452
5600
  });
5453
5601
 
5454
- window.openModelSheet = function() {
5455
- const list = document.getElementById('model-list');
5456
- list.innerHTML = '';
5457
- MODELS.forEach(m => {
5458
- const item = document.createElement('div');
5459
- item.className = 'action-sheet-item';
5460
- const isCurrent = currentModelId && m.id.includes(currentModelId);
5461
- item.innerHTML = `<div><div>${m.label}</div><div class="desc">${m.desc}</div></div>${isCurrent ? '<span class="check">✓</span>' : ''}`;
5462
- item.onclick = () => selectModel(m);
5463
- 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 });
5464
5678
  });
5465
- document.getElementById('model-sheet').classList.add('open');
5466
- };
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(); };
5467
5699
  window.closeModelSheet = function() {
5468
5700
  document.getElementById('model-sheet').classList.remove('open');
5469
5701
  };
@@ -5483,7 +5715,8 @@
5483
5715
  }, 100);
5484
5716
  }
5485
5717
  currentModelId = m.id;
5486
- addStatusMessage('模型已切换为 ' + m.label);
5718
+ if (currentSession) currentSession.modelId = m.id === 'default' ? null : m.id; // 服务端会持久化,重启后照样用
5719
+ refreshModelChip();
5487
5720
  }
5488
5721
  window.selectCustomModel = function() {
5489
5722
  const v = (document.getElementById('model-custom').value || '').trim();
@@ -7342,6 +7575,19 @@
7342
7575
  if (custom) { renderAsk(); askInput.focus(); }
7343
7576
  else askSend('');
7344
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
+ };
7345
7591
  window.askCopySelection = function() {
7346
7592
  if (!_askSel) return;
7347
7593
  const t = _askSel.text;
package/dist/chat.html.gz CHANGED
Binary file
package/dist/index.html CHANGED
@@ -181,6 +181,7 @@
181
181
  @media (max-width: 540px) {
182
182
  .set-item.inp { flex-direction: column; align-items: stretch; gap: 0.4rem; }
183
183
  .set-item.inp .set-ctl { flex: none; }
184
+ .set-item.inp .set-seg { flex: none; } /* 访问协议 / 升级下载源:说明长,名称在上、分段条整行(以前被挤成一字一行) */
184
185
  .set-lbl small { display: inline; margin-left: 0.4rem; }
185
186
  .set-seg { flex: 1; }
186
187
  }
@@ -955,6 +956,19 @@
955
956
  <button class="set-link" onclick="closeSettings();showUsage()">📊 用量 <small>费用 / token 统计</small></button>
956
957
  </div>
957
958
 
959
+ <!-- 场景模型:同一个网关 + token,不同场景用不同模型(仅管理员)-->
960
+ <div id="set-models" class="admin-only">
961
+ <div class="set-group-t">模型 · 所有任务生效</div>
962
+ <div class="set-card">
963
+ <div class="set-item" style="display:block;font-size:0.74rem;color:var(--muted);line-height:1.6" id="set-models-src">读取网关模型列表…</div>
964
+ <div id="set-models-rows"></div>
965
+ <div class="set-item" style="justify-content:flex-end"><button class="set-btn" id="set-models-save" onclick="saveSceneModels()">保存</button></div>
966
+ </div>
967
+ <div id="set-models-status" class="set-status"></div>
968
+ <datalist id="set-models-dl-tools"></datalist>
969
+ <datalist id="set-models-dl-all"></datalist>
970
+ </div>
971
+
958
972
  <div id="set-svc">
959
973
  <div class="set-group-t" id="set-svc-t">服务</div>
960
974
  <div class="set-card">
@@ -963,12 +977,12 @@
963
977
  <button id="set-up-btn" class="set-btn dirty" style="display:none" onclick="homeUpgrade()">升级</button>
964
978
  </div>
965
979
  <div id="set-up-status" class="set-status"></div>
966
- <div class="set-item admin-only" id="set-proto-row">
980
+ <div class="set-item inp admin-only" id="set-proto-row">
967
981
  <div class="set-lbl">访问协议<small id="set-proto-sub">HTTPS 才能在手机上用麦克风 / 摄像头;内网只想少点「不安全」提示可用 HTTP</small></div>
968
982
  <div class="set-seg" id="set-proto"><button data-p="https">HTTPS</button><button data-p="http">HTTP</button></div>
969
983
  </div>
970
984
  <div id="set-proto-status" class="set-status"></div>
971
- <div class="set-item admin-only">
985
+ <div class="set-item inp admin-only">
972
986
  <div class="set-lbl">升级下载源<small>myhi 和 Claude 升级从哪下载。自动:先官方源,连不上或装失败自动换国内镜像</small></div>
973
987
  <div class="set-seg" id="set-reg"><button data-r="auto">自动</button><button data-r="official">官方</button><button data-r="mirror">国内镜像</button></div>
974
988
  </div>
@@ -1122,7 +1136,7 @@
1122
1136
  document.getElementById('set-mode').innerHTML = !data.multiUser ? '👤 单用户模式 · 所有功能都可用'
1123
1137
  : _isAdmin ? '👥 多用户模式 · 你是管理员 · <a href="/admin#mode">管理成员 →</a>'
1124
1138
  : `👥 多用户模式 · 你是成员「${esc(data.name || '')}」· 本机显示名、语音等全局设置需管理员修改`;
1125
- if (!_isAdmin) document.querySelectorAll('#set-svc .admin-only').forEach(el => el.remove());
1139
+ if (!_isAdmin) document.querySelectorAll('#set-sheet .admin-only').forEach(el => el.remove());
1126
1140
  checkAiEngine();
1127
1141
  checkHomeVersion();
1128
1142
  if (data.hasUsers && data.member) { document.getElementById('passwd-btn').style.display = ''; document.getElementById('ws-menu-pw').style.display = ''; }
@@ -1315,7 +1329,7 @@
1315
1329
  window.openSettings = function() {
1316
1330
  renderAppearance();
1317
1331
  ['set-machine-status', 'set-asr-status', 'set-restart-status', 'set-up-status', 'set-proto-status'].filter(id => document.getElementById(id)).forEach(id => setStatus(id, ''));
1318
- if (_isAdmin) { loadProto(); loadRegistry(); }
1332
+ if (_isAdmin) { loadProto(); loadRegistry(); loadSceneModels(); }
1319
1333
  document.querySelectorAll('#set-sheet .set-btn').forEach(b => b.classList.remove('dirty'));
1320
1334
  document.getElementById('set-backdrop').classList.add('open');
1321
1335
  document.getElementById('set-sheet').classList.add('open');
@@ -1480,6 +1494,57 @@
1480
1494
  });
1481
1495
  if (d.fromEnv) setStatus('set-proto-status', '启动时设了环境变量 MYHI_NO_HTTPS,固定为 HTTP');
1482
1496
  }
1497
+ // ── 场景模型 ────────────────────────────────────────────
1498
+ // 每个场景一个输入框:可从网关模型列表里选(datalist),也可手填任意模型名 / 别名;留空 = 用默认
1499
+ const SCENE_HINT = {
1500
+ main: '会话里没手动选模型时用它;留空 = Claude Code 默认',
1501
+ subagent: 'AI 把任务拆成子任务时用;留空 = 跟主模型一样',
1502
+ background: '生成标题、摘要等后台小活,建议选便宜的',
1503
+ ask: '「问一问」和解释,建议选便宜快速的;留空 = sonnet',
1504
+ voice: '语音转成书面文字,建议选便宜的;留空 = 同问一问',
1505
+ };
1506
+ async function loadSceneModels() {
1507
+ let d; try { d = await (await fetch('/api/models')).json(); } catch { return; }
1508
+ if (!d.ok) return;
1509
+ const src = document.getElementById('set-models-src');
1510
+ const ms = (d.models || []).filter(m => !m.id.includes('*'));
1511
+ const t = d.token, quota = t && (t.dailyLimit != null || t.monthlyLimit != null)
1512
+ ? ` 积分:${t.dailyLimit != null ? `今日已用 ${t.dailyUsed ?? '?'} / ${t.dailyLimit}` : ''}${t.monthlyLimit != null ? ` 本月已用 ${t.monthlyUsed ?? '?'} / ${t.monthlyLimit}` : ''}。` : '';
1513
+ src.textContent = !d.gateway ? '没有配置大模型网关:可填 Claude 别名(sonnet / opus / haiku)或完整模型名。'
1514
+ : d.error ? '网关模型列表读取失败(' + d.error + '),可以手填模型名。'
1515
+ : `网关上有 ${ms.length} 个模型${d.scoped ? '(这个 token 可用的)' : '(网关是旧版,未按 token 过滤,个别模型可能用不了)'},点输入框可选,也可以手填。` + quota;
1516
+ // 两份候选:主会话 / 子代理只列能用工具干活的;问一问、语音整理、后台小任务全列,便宜的排前面
1517
+ const opt = (m) => `<option value="${esc(m.id)}">${esc([m.name !== m.id ? m.name : '', m.kind === 'alias' && m.target ? '→ ' + m.target : '', (m.tags || []).includes('cheap') ? '便宜' : '', m.multiplier != null ? m.multiplier + 'x' : '', (m.caps || []).includes('vision') ? '能看图' : ''].filter(Boolean).join(' · '))}</option>`;
1518
+ const aliases = ['sonnet', 'opus', 'haiku'].map(a => `<option value="${a}">Claude 别名</option>`).join('');
1519
+ const cheapFirst = [...ms].sort((a, b) => ((b.tags || []).includes('cheap') - (a.tags || []).includes('cheap')));
1520
+ document.getElementById('set-models-dl-tools').innerHTML = ms.filter(m => !m.fit?.main).map(opt).join('') + aliases;
1521
+ document.getElementById('set-models-dl-all').innerHTML = cheapFirst.map(opt).join('') + aliases;
1522
+ const TOOL_SCENES = { main: 1, subagent: 1 };
1523
+ document.getElementById('set-models-rows').innerHTML = Object.entries(d.sceneLabels || {}).map(([k, label]) =>
1524
+ `<div class="set-item inp"><div class="set-lbl">${esc(label)}<small>${esc(SCENE_HINT[k] || '')}</small></div>
1525
+ <div class="set-ctl"><input class="set-inp" data-scene="${esc(k)}" list="${TOOL_SCENES[k] ? 'set-models-dl-tools' : 'set-models-dl-all'}" value="${esc(d.scenes?.[k] || '')}" placeholder="默认" autocomplete="off" spellcheck="false"></div>
1526
+ <div class="set-warn" data-warn="${esc(k)}" style="display:none;font-size:0.72rem;color:var(--yellow,#d29922)"></div></div>`).join('');
1527
+ // 填了不适合这个场景的模型(不支持工具等)→ 输入框下提示原因(不硬拦:网关元数据可能没填全)
1528
+ const byId = Object.fromEntries(ms.map(m => [m.id, m]));
1529
+ const check = (inp) => {
1530
+ const k = inp.dataset.scene, w = document.querySelector(`[data-warn="${k}"]`);
1531
+ const why = TOOL_SCENES[k] && byId[inp.value.trim()]?.fit?.[k];
1532
+ w.textContent = why ? '⚠ ' + why : ''; w.style.display = why ? '' : 'none';
1533
+ };
1534
+ document.querySelectorAll('#set-models-rows [data-scene]').forEach(i => { check(i); i.addEventListener('input', () => check(i)); });
1535
+ // 和其他设置项一样:改了才出现「保存」
1536
+ const btn = document.getElementById('set-models-save');
1537
+ btn.classList.remove('dirty');
1538
+ document.querySelectorAll('#set-models-rows [data-scene]').forEach(i => i.addEventListener('input', () => btn.classList.add('dirty')));
1539
+ }
1540
+ window.saveSceneModels = async function() {
1541
+ const body = {};
1542
+ document.querySelectorAll('#set-models-rows [data-scene]').forEach(i => { body[i.dataset.scene] = i.value.trim(); });
1543
+ let r; try { r = await (await fetch('/api/model-scenes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json(); } catch (e) { r = { ok: false, error: e.message }; }
1544
+ if (r.ok) document.getElementById('set-models-save').classList.remove('dirty');
1545
+ setStatus('set-models-status', r.ok ? '已保存。新开的任务和下一次重启的 AI 进程生效;正在跑的任务在下次重启 AI 后生效' : '保存失败:' + (r.error || ''));
1546
+ };
1547
+
1483
1548
  async function loadRegistry() {
1484
1549
  let d; try { d = await (await fetch('/api/npm-registry')).json(); } catch { return; }
1485
1550
  const seg = document.getElementById('set-reg'); if (!seg || !d.ok) return;
Binary file