@wendongfly/myhi 1.3.205 → 1.3.208

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,13 @@
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
+ <a id="model-pop-cfg" href="/?settings=1" target="_top">⚙ 配置模型</a>
1025
+ </div>
1026
+ </div>
964
1027
  <div id="model-sheet" class="action-sheet">
965
1028
  <div class="action-sheet-backdrop" onclick="closeModelSheet()"></div>
966
1029
  <div class="action-sheet-box">
@@ -1311,6 +1374,7 @@
1311
1374
  <button class="pri" onmousedown="event.preventDefault()" onclick="askFromSelection(false)">💡 解释</button>
1312
1375
  <button onmousedown="event.preventDefault()" onclick="askFromSelection(true)">❓ 问一问…</button>
1313
1376
  <button onmousedown="event.preventDefault()" onclick="askCopySelection()">📋 复制</button>
1377
+ <button onmousedown="event.preventDefault()" onclick="askSendToAI()" title="把选中的内容引用到输入框,接着写要求发给当前任务的 AI">💬 发给 AI</button>
1314
1378
  </div>
1315
1379
  <div id="ask-panel" role="dialog" aria-label="问一问">
1316
1380
  <div class="ask-head">
@@ -2508,7 +2572,22 @@
2508
2572
  return null;
2509
2573
  }
2510
2574
 
2575
+ // 看图转文字的结果:折叠块,点开能看到主模型实际拿到的图片描述(描述可能漏细节,给用户核对)
2576
+ function visionEl(msg) {
2577
+ const d = document.createElement('details');
2578
+ d.className = 'msg msg-vision';
2579
+ d.style.cssText = 'margin:2px auto 6px;max-width:92%;font-size:0.8rem;color:var(--muted);border:1px solid var(--border);border-radius:8px;padding:4px 10px';
2580
+ d.innerHTML = `<summary style="cursor:pointer">🖼 看图模型 ${escHtml(msg.vision.model || '')} 转成的文字(点开核对)</summary>` +
2581
+ (msg.vision.items || []).map(it => `<div style="margin:6px 0 2px;word-break:break-all">${escHtml(it.path || '')}</div>` +
2582
+ `<pre style="white-space:pre-wrap;word-break:break-word;margin:0;font-size:0.78rem;color:var(--text)">${escHtml(it.text || ('识别失败:' + (it.error || '')))}</pre>`).join('');
2583
+ return d;
2584
+ }
2585
+
2511
2586
  function renderHistoryMessage(msg, parent) {
2587
+ if (msg.type === 'system' && msg.vision) {
2588
+ const st = document.createElement('div'); st.className = 'msg msg-status'; st.textContent = '— ' + msg.message + ' —';
2589
+ parent.appendChild(st); parent.appendChild(visionEl(msg)); return;
2590
+ }
2512
2591
  if (msg.type === 'assistant' && Array.isArray(msg.message?.content)) {
2513
2592
  for (const b of msg.message.content) { const k = blockKey(b, msg.message.id); if (k) _replayedBlocks.add(k); }
2514
2593
  } else if (msg.type === 'tool_result' && msg.id) _replayedBlocks.add('tr:' + msg.id);
@@ -2541,11 +2620,21 @@
2541
2620
  const sel = buildSyntheticUserEl(msg.content);
2542
2621
  if (sel) { parent.appendChild(sel); return; }
2543
2622
  if (syntheticUserKind(msg.content)) return;
2623
+ // 看图转文字后发给模型的消息(对话记录里就是改写后的全文):气泡只显示原话,图片描述收进折叠块
2624
+ let content = msg.content, vis = null;
2625
+ const vi = typeof content === 'string' ? content.indexOf('\n\n(注意:你当前用的模型不能直接看图') : -1;
2626
+ if (vi > 0) {
2627
+ const tail = content.slice(vi);
2628
+ vis = { message: '', vision: { model: (tail.match(/看图模型 (\S+) 把图片/) || [])[1] || '',
2629
+ items: [...tail.matchAll(/【图片 ([^】]+)】\n([\s\S]*?)(?=\n\n【图片 |$)/g)].map(m => ({ path: m[1], text: m[2] })) } };
2630
+ content = content.slice(0, vi);
2631
+ }
2544
2632
  const el = document.createElement('div');
2545
2633
  el.className = 'msg msg-input' + (msg.inject ? ' msg-inject' : '');
2546
2634
  const full = msg.timestamp ? new Date(msg.timestamp).toLocaleString('zh-CN') : '';
2547
- el.innerHTML = `<div><div class="bubble">${escHtml(msg.content)}</div><div class="meta" title="${escHtml(full)}">${injectTagHtml(msg.inject)}${msg.timestamp ? formatTime(new Date(msg.timestamp)) : ''}</div></div>`;
2635
+ el.innerHTML = `<div><div class="bubble">${escHtml(content)}</div><div class="meta" title="${escHtml(full)}">${injectTagHtml(msg.inject)}${msg.timestamp ? formatTime(new Date(msg.timestamp)) : ''}</div></div>`;
2548
2636
  parent.appendChild(el);
2637
+ if (vis) parent.appendChild(visionEl(vis));
2549
2638
  return;
2550
2639
  }
2551
2640
 
@@ -3085,6 +3174,9 @@
3085
3174
  updateControlUI(session.controlHolder === socket.id, session.controlHolderName);
3086
3175
 
3087
3176
  claudeSession = isClaudeSession(session) || session.mode === 'agent';
3177
+ // 模型按钮只在配了自定义网关时出现(列表来自网关),由 refreshModelChip 决定显隐
3178
+ if (session.mode !== 'agent') document.getElementById('model-chip').style.display = 'none';
3179
+ if (session.mode === 'agent') refreshModelChip();
3088
3180
  updateShortcutBar();
3089
3181
  updatePlanButton();
3090
3182
  ctxUpdate(session.contextTokens || 0);
@@ -3154,6 +3246,7 @@
3154
3246
  _ctxNudgeLevel = 0; _lastCtxTokens = 0; ctxUpdate(0);
3155
3247
  }
3156
3248
  addStatusMessage(msg.message);
3249
+ if (msg.vision) { chatArea.appendChild(visionEl(msg)); scrollToBottom(); }
3157
3250
  // 同步计划模式状态到按钮
3158
3251
  if (msg.message.includes('进入计划模式')) {
3159
3252
  if (currentSession) currentSession.permissionMode = 'plan';
@@ -3671,6 +3764,11 @@
3671
3764
  saveDraftFromInput('busy');
3672
3765
  return;
3673
3766
  }
3767
+ // 网关积分用尽:发出去大概率被网关拒,先提醒一句(本地 / 斜杠命令不算)
3768
+ if (currentSession?.mode === 'agent' && !text.startsWith('/')) {
3769
+ const q = quotaState();
3770
+ if (q.out && !confirm(q.msg + '\n\n发出去大概率会被网关拒绝。仍要发送吗?')) return;
3771
+ }
3674
3772
 
3675
3773
  if (!isController) {
3676
3774
  if (canTakeControl()) { socket.emit('take-control', { sessionId: SESSION_ID }); }
@@ -4079,7 +4177,7 @@
4079
4177
  <b>让它读文件</b>:打 <b>@</b> 从本项目逐层浏览、打字搜索;项目外打 <b>@D:</b>、<b>@/</b>、<b>@~/</b> 按路径逐层浏览;选中插路径;或点「文件」面板的 <b>@</b> 引用。<br>
4080
4178
  <b>工具/思考</b>:AI 干活的过程默认折叠,点「🔧 N 个工具调用」展开看命令与输出。<br>
4081
4179
  <b>AI 忙时</b>:想补充或提醒,点输入框右边「⚡ 插话」——不打断,AI 下一步就会看到并调整(气泡上显示「AI 已看到」);不急的就回车「存草稿」排队,草稿里可排顺序、编辑、合并,开「依次自动发送」后每干完一轮自动发下一条,AI 忙时也能把草稿「⚡ 插入」当前任务。<br>
4082
- <b>看不懂就问</b>:在聊天里<b>选中</b>看不懂的词或一段话(手机长按选中),点「💡 解释」或「❓ 问一问」,答案在旁边小窗里,可以继续追问;这些问答<b>不会打扰正在干活的 AI</b>,想让它知道就点「👉 告诉 AI」。<br>
4180
+ <b>看不懂就问</b>:在聊天里<b>选中</b>看不懂的词或一段话(手机长按选中),点「💡 解释」或「❓ 问一问」,答案在旁边小窗里,可以继续追问;这些问答<b>不会打扰正在干活的 AI</b>,想让它知道就点「👉 告诉 AI」。想直接拿这段内容跟 AI 说事,点「💬 发给 AI」:内容会引用到输入框,接着写要求再发送。<br>
4083
4181
  <b>AI 提问 / 交方案</b>:AI 需要你拿主意时会弹出选项,选一个即可,也可「跳过,让 AI 自己决定」;收起后输入框上方「❓ AI 在等你回答」可再打开。<br>
4084
4182
  <b>定位旧消息</b>:电脑上鼠标移到聊天区,右下角 ☰ 可列出你发过的消息点击跳转;看完点底部「↓ 回到底部」一键回来。
4085
4183
  ${sec('顶栏')}
@@ -4180,10 +4278,10 @@
4180
4278
  continue;
4181
4279
  }
4182
4280
  // 点整行 = 打开(文件夹进入 / 文件预览或编辑);其余操作都在行尾 ⋯(电脑上右键也行)
4183
- const kind = e.isDir ? 'd' : isEditableFile(e.name) ? 'e' : 'f';
4281
+ const kind = e.isDir ? 'd' : isEditableFile(e.name) ? 'e' : previewKind(e.name) ? 'p' : 'f';
4184
4282
  const a = `'${escAttr(rel)}','${kind}'`;
4185
4283
  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>
4284
+ <span class="fp-icon">${e.isDir ? '📁' : kind === 'e' ? '📝' : kind === 'p' ? ({ img: '🖼', pdf: '📕', video: '🎬', audio: '🎵' })[previewKind(e.name)] : '📄'}</span>
4187
4285
  <span class="fp-name">${escHtml(e.name)}</span>
4188
4286
  ${e.isDir ? '' : `<span class="fp-size">${fpFormatSize(e.size)}</span>`}
4189
4287
  <span class="fp-more" onclick="event.stopPropagation();fpItemMenu(event,${a})" title="更多操作">⋯</span>
@@ -4250,6 +4348,7 @@
4250
4348
  if (fpSel) return fpToggleSel(rel);
4251
4349
  if (kind === 'd') return fpNav(rel);
4252
4350
  if (kind === 'e') return openFileEditor(rel);
4351
+ if (kind === 'p') return fpPreview(rel);
4253
4352
  fpItemMenu(null, rel, kind);
4254
4353
  };
4255
4354
  window.fpCopyPath = function(rel) {
@@ -4292,11 +4391,73 @@
4292
4391
  if (kind === 'd') items.push(['📂 打开', () => fpNav(rel)], ['⬇ 打包下载', () => fpZip([rel]), 'zip']);
4293
4392
  else {
4294
4393
  if (kind === 'e') items.push([/\.(html?|md|markdown)$/i.test(rel) ? '👁 查看 / 编辑' : '✏️ 编辑', () => openFileEditor(rel)]);
4394
+ if (kind === 'p') items.push(['👁 预览', () => fpPreview(rel)]);
4295
4395
  items.push(['⬇ 下载', () => fpDownload(rel)]);
4296
4396
  }
4297
4397
  items.push(['@ 引用到消息', () => fpRefer(kind === 'd' ? rel + '/' : rel), '让 AI 读它'], ['📋 复制路径', () => fpCopyPath(rel)]);
4298
4398
  fpMenu(ev, rel, items);
4299
4399
  };
4400
+ // ── 文件预览:图片 / PDF / 音视频 —— 浏览器自己能显示的直接看,不用先下载 ──────────
4401
+ window.previewKind = function(name) {
4402
+ const e = (String(name).toLowerCase().match(/\.([a-z0-9]+)$/) || [])[1] || '';
4403
+ if (/^(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/.test(e)) return 'img';
4404
+ if (e === 'pdf') return 'pdf';
4405
+ if (/^(mp4|webm|ogv|mov|m4v)$/.test(e)) return 'video';
4406
+ if (/^(mp3|wav|ogg|oga|m4a|flac|aac|opus)$/.test(e)) return 'audio';
4407
+ return null;
4408
+ };
4409
+ let _fvList = [], _fvIdx = 0;
4410
+ const fvUrl = (rel, inline) => `/api/files/${SESSION_ID}/download?path=${encodeURIComponent(rel)}${inline ? '&inline=1' : ''}`;
4411
+ window.fpPreview = function(rel) {
4412
+ // 同一目录的图片可以左右翻(从文件面板打开时)
4413
+ const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '';
4414
+ const sameDirImgs = previewKind(rel) === 'img' && dir === fpCurrentPath ? fpVisible.filter(r => previewKind(r) === 'img') : [];
4415
+ _fvList = sameDirImgs.length > 1 ? sameDirImgs : [rel];
4416
+ _fvIdx = Math.max(0, _fvList.indexOf(rel));
4417
+ document.getElementById('fp-viewer').classList.add('open');
4418
+ fvShow();
4419
+ };
4420
+ function fvShow() {
4421
+ const rel = _fvList[_fvIdx], kind = previewKind(rel), url = fvUrl(rel, true);
4422
+ const v = document.getElementById('fp-viewer'), body = document.getElementById('fv-body');
4423
+ v.classList.toggle('multi', _fvList.length > 1);
4424
+ document.getElementById('fv-name').textContent = rel.split('/').pop() + (_fvList.length > 1 ? ` (${_fvIdx + 1}/${_fvList.length})` : '');
4425
+ document.getElementById('fv-path').textContent = typeof feditAbs === 'function' ? feditAbs(rel) : rel;
4426
+ document.getElementById('fv-open').onclick = () => window.open(url, '_blank');
4427
+ document.getElementById('fv-dl').onclick = () => fpDownload(rel);
4428
+ body.classList.remove('zoomed');
4429
+ if (kind === 'img') {
4430
+ body.innerHTML = `<img src="${escAttr(url)}" alt="">`;
4431
+ const img = body.querySelector('img');
4432
+ img.onclick = () => { img.classList.toggle('zoom'); body.classList.toggle('zoomed', img.classList.contains('zoom')); }; // 点击看原尺寸
4433
+ img.onerror = () => { body.innerHTML = '<div class="fv-msg">图片打不开(可能已被删除或格式损坏)</div>'; };
4434
+ } else if (kind === 'video') {
4435
+ body.innerHTML = `<video src="${escAttr(url)}" controls autoplay playsinline></video>`;
4436
+ } else if (kind === 'audio') {
4437
+ body.innerHTML = `<audio src="${escAttr(url)}" controls autoplay></audio>`;
4438
+ } else if (kind === 'pdf') {
4439
+ // 手机浏览器大多不能把 PDF 嵌在页面里(安卓会直接下载):给按钮在新标签打开
4440
+ if (matchMedia('(pointer: coarse)').matches) {
4441
+ 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>`;
4442
+ } else body.innerHTML = `<iframe src="${escAttr(url)}"></iframe>`;
4443
+ }
4444
+ }
4445
+ window.fpPreviewStep = function(d) {
4446
+ if (_fvList.length < 2) return;
4447
+ _fvIdx = (_fvIdx + d + _fvList.length) % _fvList.length; fvShow();
4448
+ };
4449
+ window.fpClosePreview = function() {
4450
+ const v = document.getElementById('fp-viewer');
4451
+ v.classList.remove('open');
4452
+ document.getElementById('fv-body').innerHTML = ''; // 停掉正在播放的音视频
4453
+ };
4454
+ document.addEventListener('keydown', (e) => {
4455
+ if (!document.getElementById('fp-viewer').classList.contains('open')) return;
4456
+ if (e.key === 'Escape') fpClosePreview();
4457
+ else if (e.key === 'ArrowLeft') fpPreviewStep(-1);
4458
+ else if (e.key === 'ArrowRight') fpPreviewStep(1);
4459
+ });
4460
+
4300
4461
  window.fpToolsMenu = function(ev) {
4301
4462
  const items = [
4302
4463
  ['🔄 同步到本地文件夹', () => fpStartSync(), 'Chrome / Edge'],
@@ -4433,7 +4594,13 @@
4433
4594
  const rel = feditNormPath(String(rawPath || '').trim());
4434
4595
  if (!rel) { addStatusMessage('路径为空'); return; }
4435
4596
  if (!isEditableFile(rel)) {
4436
- addStatusMessage('「' + rel.split('/').pop() + '」不是可编辑的文本类型,请用下载');
4597
+ // 图片 / PDF / 音视频:浏览器能直接显示,打开预览(AI 只写文件名时在任务目录里按名字找)
4598
+ if (previewKind(rel)) {
4599
+ let p = rel;
4600
+ if (!rel.includes('/')) { const hit = await feditFindByName(rel); if (hit) p = hit; }
4601
+ return fpPreview(p);
4602
+ }
4603
+ addStatusMessage('「' + rel.split('/').pop() + '」浏览器打不开这种文件,请在文件面板里下载');
4437
4604
  return;
4438
4605
  }
4439
4606
  const sheet = document.getElementById('fedit-sheet');
@@ -4460,14 +4627,15 @@
4460
4627
  return;
4461
4628
  }
4462
4629
  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 };
4630
+ const svg = /\.svg$/i.test(d.name || d.path || '');
4631
+ _fedit = { path: d.path, name: d.name, mtime: d.mtime, md: !!d.md, html, svg, mode: 'edit', loaded: d.content, dirty: false };
4464
4632
  document.getElementById('fedit-path').textContent = feditAbs(d.path);
4465
4633
  document.getElementById('fedit-ta').value = d.content;
4466
4634
  document.getElementById('fedit-loading').style.display = 'none';
4467
- document.getElementById('fedit-tab-preview').style.display = (_fedit.md || html) ? '' : 'none';
4635
+ document.getElementById('fedit-tab-preview').style.display = (_fedit.md || html || svg) ? '' : 'none';
4468
4636
  document.getElementById('fedit-newtab').style.display = html ? '' : 'none';
4469
4637
  // 文档类(md / html)默认「看」,要改再点 ✏️ 编辑
4470
- feditSetMode(_fedit.md || html ? 'preview' : 'edit');
4638
+ feditSetMode(_fedit.md || html || svg ? 'preview' : 'edit');
4471
4639
  } catch (e) {
4472
4640
  addStatusMessage('打开出错: ' + e.message);
4473
4641
  sheet.classList.remove('open');
@@ -4526,15 +4694,21 @@
4526
4694
 
4527
4695
  window.feditSetMode = function(mode) {
4528
4696
  if (!_fedit) return;
4529
- if (mode === 'preview' && !_fedit.md && !_fedit.html) mode = 'edit';
4697
+ if (mode === 'preview' && !_fedit.md && !_fedit.html && !_fedit.svg) mode = 'edit';
4530
4698
  _fedit.mode = mode;
4531
4699
  const ta = document.getElementById('fedit-ta');
4532
4700
  const pv = document.getElementById('fedit-preview');
4533
4701
  ta.style.display = mode === 'edit' ? 'block' : 'none';
4534
4702
  pv.style.display = mode === 'preview' ? 'block' : 'none';
4535
- pv.classList.toggle('html', mode === 'preview' && !!_fedit.html);
4703
+ pv.classList.toggle('html', mode === 'preview' && !!(_fedit.html || _fedit.svg));
4536
4704
  document.getElementById('fedit-icon').textContent = mode === 'preview' ? '📄' : '✏️';
4537
- if (mode === 'preview') { if (_fedit.html) feditRenderHtml(pv, ta.value); else pv.innerHTML = renderMarkdown(ta.value); }
4705
+ if (mode === 'preview') {
4706
+ if (_fedit.html) feditRenderHtml(pv, ta.value);
4707
+ else if (_fedit.svg) { // SVG 用 <img> 渲染:图片上下文里内嵌脚本不执行;用编辑框内容,没保存的修改也能看
4708
+ const u = URL.createObjectURL(new Blob([ta.value], { type: 'image/svg+xml' }));
4709
+ 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>`;
4710
+ } else pv.innerHTML = renderMarkdown(ta.value);
4711
+ }
4538
4712
  document.getElementById('fedit-tab-edit').classList.toggle('on', mode === 'edit');
4539
4713
  document.getElementById('fedit-tab-preview').classList.toggle('on', mode === 'preview');
4540
4714
  };
@@ -5451,19 +5625,109 @@
5451
5625
  if (e.key === 'Enter') { e.preventDefault(); submitAskReply(); }
5452
5626
  });
5453
5627
 
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);
5628
+ // ── 模型:聊天框里的「模型」按钮 + 上方弹出列表 ─────────────────────────────
5629
+ // 列表 = myhi 本地配置(场景模型)+ 网关模型目录(这个 token 能用的,按厂商分组),当前会话的打勾
5630
+ const CAP_ICON = { vision: '🖼', function_calling: '🧰', reasoning: '🧠' };
5631
+ const TAG_LABEL = { recommended: ['推荐', 'var(--purple)'], cheap: ['便宜', 'var(--green)'], vision: ['看图', 'var(--blue)'], fast: ['快', 'var(--yellow)'], experimental: ['实验', 'var(--muted)'], deprecated: ['将下线', 'var(--red)'] };
5632
+ let _modelInfo = null; // /api/models 的结果(服务端缓存 10 分钟)
5633
+ async function loadModelInfo(force) {
5634
+ if (_modelInfo && !force) return _modelInfo;
5635
+ try { _modelInfo = await (await fetch('/api/models')).json(); } catch { _modelInfo = _modelInfo || {}; }
5636
+ return _modelInfo;
5637
+ }
5638
+ const modelName = (id) => { const m = (_modelInfo?.models || []).find(x => x.id === id); return m && m.kind !== 'alias' ? m.name : id; };
5639
+ // 网关积分(新网关的 /v1/models?scope=token 才带;网关按积分计费,1 积分 = $0.01):已用 / 上限,用尽时发消息前提醒
5640
+ function quotaState() {
5641
+ const t = _modelInfo?.token;
5642
+ if (!t) return { text: '', out: false };
5643
+ const fmt = (u, l) => `${u ?? '?'} / ${l} 积分`;
5644
+ const parts = [];
5645
+ if (t.dailyLimit != null) parts.push('今日已用 ' + fmt(t.dailyUsed, t.dailyLimit));
5646
+ if (t.monthlyLimit != null) parts.push('本月已用 ' + fmt(t.monthlyUsed, t.monthlyLimit));
5647
+ const dayOut = t.dailyLimit != null && t.dailyUsed != null && t.dailyUsed >= t.dailyLimit;
5648
+ const monOut = t.monthlyLimit != null && t.monthlyUsed != null && t.monthlyUsed >= t.monthlyLimit;
5649
+ const out = dayOut || monOut;
5650
+ return { text: parts.length ? '积分:' + parts.join(' · ') : '', out,
5651
+ msg: out ? `网关 token${t.name ? '「' + t.name + '」' : ''}${dayOut ? '今日' : '本月'}积分已用完(${dayOut ? fmt(t.dailyUsed, t.dailyLimit) : fmt(t.monthlyUsed, t.monthlyLimit)})` : '' };
5652
+ }
5653
+ // 额度会变:每 5 分钟后台刷新一次(服务端本身也缓存 10 分钟)
5654
+ setInterval(() => { if (currentSession?.mode === 'agent' && !document.hidden) loadModelInfo(true); }, 5 * 60_000);
5655
+ window.refreshModelChip = async function() {
5656
+ await loadModelInfo();
5657
+ const cur = currentSession?.modelId || '';
5658
+ const main = _modelInfo?.scenes?.main || '';
5659
+ // 计划模式且系统设置里配了「计划」模型 → 这段时间实际用它(批准执行后回到主任务模型)
5660
+ const plan = currentSession?.permissionMode === 'plan' ? (_modelInfo?.scenes?.plan || '') : '';
5661
+ const el = document.getElementById('model-chip-name');
5662
+ el.textContent = plan ? '计划·' + modelName(plan) : cur ? modelName(cur) : main ? modelName(main) : '默认';
5663
+ const chip = document.getElementById('model-chip'), q = quotaState();
5664
+ // 没配自定义网关(直连 Anthropic 等):不提供模型选择,跟随 Claude Code 默认
5665
+ chip.style.display = currentSession?.mode === 'agent' && _modelInfo?.gateway ? '' : 'none';
5666
+ chip.title = '切换模型 · 当前:' + (plan ? plan + '(计划模式,系统设置 · 计划)\n执行时:' : '') + (cur || (main ? main + '(主任务默认)' : 'Claude Code 默认')) + (q.text ? '\n' + (q.out ? q.msg : q.text) : '');
5667
+ chip.style.borderColor = q.out ? 'var(--red)' : ''; // 积分用尽:按钮描红
5668
+ };
5669
+ window.openModelPop = async function() {
5670
+ const pop = document.getElementById('model-pop'), ov = document.getElementById('model-pop-ov'), list = document.getElementById('model-pop-list');
5671
+ list.innerHTML = '<div class="mp-h">加载模型列表…</div>';
5672
+ pop.classList.add('open'); ov.classList.add('open');
5673
+ placeModelPop();
5674
+ const d = await loadModelInfo(true);
5675
+ const cur = currentSession?.modelId || '';
5676
+ const main = d.scenes?.main || '';
5677
+ // unfit:不适合当主会话模型的原因(不支持工具等)→ 变灰 + 说明,点了先确认
5678
+ 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>`;
5679
+ const q = quotaState();
5680
+ 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>` : '';
5681
+ html += row('default', '默认', main ? '主任务默认:' + main : '跟随 Claude Code 默认', '', [], !cur);
5682
+ const gm = (d.models || []).filter(m => !m.id.includes('*')); // 通配别名(claude-*)不能直接选
5683
+ const listed = new Set(['default']);
5684
+ if (gm.length) {
5685
+ const by = {};
5686
+ for (const m of gm) (by[m.provider || '其他'] ||= []).push(m);
5687
+ for (const [prov, ms] of Object.entries(by)) {
5688
+ html += `<div class="mp-h">${escHtml(prov)}${d.scoped ? '' : ' · 网关未按 token 过滤'}</div>`;
5689
+ for (const m of ms) {
5690
+ listed.add(m.id);
5691
+ const sub = [m.kind === 'alias' && m.target ? '→ ' + m.target : '', m.caps.map(c => CAP_ICON[c]).filter(Boolean).join(' ')].filter(Boolean).join(' · ');
5692
+ // 右侧:有渠道系数就显示倍率(如 0.51x),否则显示单价;单价放悬停提示里
5693
+ const price = m.inputCost != null ? `$${m.inputCost}/$${m.outputCost} 每百万 token` : '';
5694
+ // 时段价此刻生效(如「闲时 0.5x」)→ 放在右侧倍率前面
5695
+ const tn = m.timeNow ? `${m.timeNow.label}${m.timeNow.multiplier != null ? ' ' + m.timeNow.multiplier + 'x' : ''}` : '';
5696
+ const right = [tn, m.multiplier != null ? `${m.multiplier}x` : (m.inputCost != null ? `$${m.inputCost}/$${m.outputCost}` : '')].filter(Boolean).join(' · ');
5697
+ 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}` : '', tn ? '当前时段:' + tn : ''].filter(Boolean).join(' · '));
5698
+ }
5699
+ }
5700
+ } else if (d.gateway && d.error) html += `<div class="mp-h">网关模型列表读取失败:${escHtml(d.error)}</div>`;
5701
+ // 自定义:本地场景配置里填过、或本会话正在用、但不在网关列表里的名字
5702
+ const custom = [...new Set([cur, ...Object.values(d.scenes || {})].filter(x => x && !listed.has(x)))];
5703
+ if (custom.length) { html += '<div class="mp-h">自定义模型</div>'; for (const id of custom) { listed.add(id); html += row(id, id, '', '', [], cur === id); } }
5704
+ // 模型只从网关目录来;没配自定义网关就不提供选择
5705
+ if (!d.gateway) html = '<div class="mp-h" style="white-space:normal;line-height:1.5">没有配置自定义网关,使用 Claude Code 默认模型。<br>配置网关后,这里会列出网关上这个 token 可用的模型。</div>';
5706
+ list.innerHTML = html;
5707
+ list.querySelectorAll('[data-model]').forEach(el => el.onclick = () => {
5708
+ if (el.dataset.unfit && !confirm(`「${el.dataset.label}」${el.dataset.unfit}。\n\n用它当主会话模型,AI 很可能干不了活。仍要切换吗?`)) return;
5709
+ closeModelPop(); selectModel({ id: el.dataset.model, label: el.dataset.label });
5464
5710
  });
5465
- document.getElementById('model-sheet').classList.add('open');
5466
- };
5711
+ // 只有管理员能改场景模型:成员隐藏「配置模型」
5712
+ document.getElementById('model-pop-cfg').style.display = myRole === 'admin' ? '' : 'none';
5713
+ list.querySelector('.mp-i.on')?.scrollIntoView({ block: 'nearest' });
5714
+ placeModelPop();
5715
+ };
5716
+ // 贴着按钮弹在上方、右对齐;按钮不可见(PTY 会话 / 从设置菜单打开)时居中
5717
+ function placeModelPop() {
5718
+ const pop = document.getElementById('model-pop'), chip = document.getElementById('model-chip');
5719
+ const r = chip.getBoundingClientRect(), w = pop.offsetWidth, h = pop.offsetHeight;
5720
+ if (chip.offsetParent) {
5721
+ pop.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + 'px';
5722
+ pop.style.top = Math.max(8, r.top - h - 6) + 'px';
5723
+ } else { pop.style.left = Math.max(8, (innerWidth - w) / 2) + 'px'; pop.style.top = Math.max(8, (innerHeight - h) / 2) + 'px'; }
5724
+ }
5725
+ window.closeModelPop = function() {
5726
+ document.getElementById('model-pop').classList.remove('open');
5727
+ document.getElementById('model-pop-ov').classList.remove('open');
5728
+ };
5729
+ // 旧入口(终端会话快捷栏「模型」、设置菜单)都改走新弹层
5730
+ window.openModelSheet = function() { openModelPop(); };
5467
5731
  window.closeModelSheet = function() {
5468
5732
  document.getElementById('model-sheet').classList.remove('open');
5469
5733
  };
@@ -5483,7 +5747,8 @@
5483
5747
  }, 100);
5484
5748
  }
5485
5749
  currentModelId = m.id;
5486
- addStatusMessage('模型已切换为 ' + m.label);
5750
+ if (currentSession) currentSession.modelId = m.id === 'default' ? null : m.id; // 服务端会持久化,重启后照样用
5751
+ refreshModelChip();
5487
5752
  }
5488
5753
  window.selectCustomModel = function() {
5489
5754
  const v = (document.getElementById('model-custom').value || '').trim();
@@ -6398,6 +6663,7 @@
6398
6663
  btn.textContent = isPlan ? '退出计划' : '计划';
6399
6664
  btn.style.color = isPlan ? 'var(--orange)' : '';
6400
6665
  btn.style.fontWeight = isPlan ? '600' : '';
6666
+ if (currentSession?.mode === 'agent' && window.refreshModelChip) refreshModelChip(); // 计划模型可能不同
6401
6667
  }
6402
6668
  window.togglePlanMode = function() {
6403
6669
  const isPlan = currentSession?.permissionMode === 'plan';
@@ -7342,6 +7608,19 @@
7342
7608
  if (custom) { renderAsk(); askInput.focus(); }
7343
7609
  else askSend('');
7344
7610
  };
7611
+ // 「发给 AI」:选中的内容以引用格式放进输入框,光标停在下面,接着写要求再发(不直接发出去)
7612
+ window.askSendToAI = function() {
7613
+ if (!_askSel) return;
7614
+ const quote = _askSel.text.trim().split('\n').map(l => '> ' + l).join('\n');
7615
+ askHidePop();
7616
+ window.getSelection()?.removeAllRanges();
7617
+ const cur = cmdInput.value.replace(/\s+$/, '');
7618
+ cmdInput.value = (cur ? cur + '\n\n' : '') + quote + '\n\n';
7619
+ cmdInput.focus();
7620
+ cmdInput.setSelectionRange(cmdInput.value.length, cmdInput.value.length);
7621
+ cmdInput.dispatchEvent(new Event('input')); // 让输入框按内容长高
7622
+ cmdInput.scrollTop = cmdInput.scrollHeight;
7623
+ };
7345
7624
  window.askCopySelection = function() {
7346
7625
  if (!_askSel) return;
7347
7626
  const t = _askSel.text;
package/dist/chat.html.gz CHANGED
Binary file