@wendongfly/myhi 1.3.182 → 1.3.185

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
@@ -22,6 +22,7 @@
22
22
  }catch(e){}})();
23
23
  </script>
24
24
  <style>
25
+ body.no-desktop [onclick="fpOpenFolder()"] { display: none !important; }
25
26
  /* ── 主题 & 字号(默认深色,值=原配色,深色零变化)───────── */
26
27
  :root {
27
28
  --bg:#0d1117; --surface:#161b22; --surface2:#21262d; --surface3:#1c2128; --border:#30363d;
@@ -156,7 +157,7 @@
156
157
  .sub-act .k { color: var(--muted); }
157
158
  .sub-summary { background: var(--surface); border: 1px solid var(--surface2); border-radius: 6px; padding: 0.4rem 0.5rem; margin-top: 0.35rem; font-size: 0.75rem; white-space: pre-wrap; word-break: break-word; max-height: 260px; overflow-y: auto; }
158
159
 
159
- /* 往上翻看历史时来了新消息 → 底部浮动「↓ 新消息」按钮,点击回到底部 */
160
+ /* 往上翻看历史(含 ☰ 定位跳转后)→ 底部浮动「↓ 回到底部」,期间来了新消息变「↓ 新消息」;点击回到底部 */
160
161
  #new-msg-btn { position: fixed; left: 50%; transform: translateX(-50%); bottom: 92px; z-index: 60; background: var(--blue2, #4a9eff); color: #fff; border: none; border-radius: 16px; padding: 0.35rem 0.95rem; font-size: 0.78rem; font-weight: 600; cursor: pointer; box-shadow: 0 3px 10px rgba(0,0,0,0.35); display: none; }
161
162
  #new-msg-btn.show { display: block; }
162
163
  #new-msg-btn:active { opacity: 0.8; }
@@ -197,10 +198,16 @@
197
198
  .raw-toggle:hover { color: var(--text); border-color: var(--blue); }
198
199
  /* 工具输出复制:右上角,桌面端悬停才现身,触屏常显 */
199
200
  .msg-raw { position: relative; }
200
- .raw-copy { position: absolute; top: 0.35rem; right: 0.35rem; z-index: 1; font-size: 0.68rem; color: var(--muted); background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: 0.1rem 0.45rem; cursor: pointer; opacity: 0; transition: opacity 0.15s; }
201
+ .raw-copy, .cp-btn { position: absolute; top: 0.35rem; right: 0.35rem; z-index: 1; font-size: 0.68rem; color: var(--muted); background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: 0.1rem 0.45rem; cursor: pointer; opacity: 0; transition: opacity 0.15s; }
201
202
  .msg-raw:hover .raw-copy, .raw-copy.done { opacity: 1; }
202
- .raw-copy:hover { color: var(--text); border-color: var(--blue); }
203
+ .raw-copy:hover, .cp-btn:hover { color: var(--text); border-color: var(--blue); }
203
204
  @media (hover: none) { .raw-copy { opacity: 0.85; } }
205
+ /* 消息里其他带框内容(代码块/表格/diff/工具详情…)同款复制按钮,见 attachCopyBtns */
206
+ .cpw, .cp-host { position: relative; }
207
+ .cp-btn { user-select: none; }
208
+ .cpw:hover > .cp-btn, .cp-host:hover > .cp-btn, .cp-btn.done { opacity: 1; }
209
+ .tool-body:not(.open) + .cp-btn { display: none; }
210
+ @media (hover: none) { .cp-btn { opacity: 0.85; } }
204
211
 
205
212
  /* 历史和状态 */
206
213
  .msg-history .code-block { background: var(--bg); border: 1px solid var(--surface2); border-radius: 8px; padding: 0.6rem 0.75rem; font-family: 'SF Mono', 'Consolas', monospace; font-size: 0.75rem; line-height: 1.3; white-space: pre-wrap; word-break: break-all; color: var(--muted); max-height: 300px; overflow-y: auto; }
@@ -267,6 +274,8 @@
267
274
  #mention-box .mi .m-name { color: var(--text); white-space: nowrap; }
268
275
  #mention-box .mi .m-hint { color: var(--muted); font-size: 0.66rem; margin-left: auto; padding-left: 0.5rem; flex-shrink: 0; }
269
276
  #mention-box .mi-empty { padding: 0.55rem 0.7rem; color: var(--muted); font-size: 0.75rem; }
277
+ #mention-box .mi-head { padding: 0.4rem 0.6rem 0.15rem; color: var(--muted); font-size: 0.66rem; font-weight: 600; letter-spacing: 0.04em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
278
+ #mention-box .mi-tip { padding: 0.35rem 0.6rem 0.2rem; color: var(--dim, var(--muted)); font-size: 0.64rem; border-top: 1px solid var(--border); margin-top: 0.2rem; }
270
279
  #cmd-input { display: block; width: 100%; background: transparent; color: var(--text); border: none; padding: 0.6rem 0.75rem 0.3rem; font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-size: 0.85rem; outline: none; resize: none; line-height: 1.5; max-height: 140px; overflow-y: auto; }
271
280
  #cmd-input::placeholder { color: var(--dimmer); }
272
281
  @media (pointer: coarse) { #cmd-input { font-size: 1rem; padding: 0.75rem 0.75rem 0.35rem; line-height: 1.55; } }
@@ -664,7 +673,7 @@
664
673
  </div>
665
674
  <div class="setting-group" data-stab="more" style="display:none">
666
675
  <h4>全局设置</h4>
667
- <div style="font-size:0.72rem;color:var(--muted);margin-bottom:0.45rem;line-height:1.5">本机名称、@ 引用范围、语音识别、账户等对所有任务生效的设置,统一放在首页的 ⚙ 设置里。这里的「外观 / 聊天 / 快捷栏」只影响当前这台设备。</div>
676
+ <div style="font-size:0.72rem;color:var(--muted);margin-bottom:0.45rem;line-height:1.5">本机名称、语音识别、账户等对所有任务生效的设置,统一放在首页的 ⚙ 设置里。这里的「外观 / 聊天 / 快捷栏」只影响当前这台设备。</div>
668
677
  <button onclick="location.href='/?settings=1'" style="width:100%;background:var(--blue2);color:#fff;border:none;border-radius:8px;padding:0.55rem;font-size:0.82rem;cursor:pointer">前往首页 ⚙ 设置 →</button>
669
678
  </div>
670
679
  <div class="setting-group" data-stab="more" style="display:none">
@@ -682,8 +691,8 @@
682
691
  </div>
683
692
  </div>
684
693
 
685
- <!-- 往上翻历史时来新消息的浮动提示,点击回到底部 -->
686
- <button id="new-msg-btn" onclick="jumpToBottom()">↓ 新消息</button>
694
+ <!-- 往上翻历史时的浮动按钮(来了新消息时提示「新消息」),点击回到底部 -->
695
+ <button id="new-msg-btn" onclick="jumpToBottom()">↓ 回到底部</button>
687
696
 
688
697
  <!-- 模型选择面板 -->
689
698
  <!-- 会话数据面板:上下文水位 + 本会话成本/token 明细(点顶栏水位 chip 打开)-->
@@ -1103,7 +1112,11 @@
1103
1112
  const ansi = new AnsiUp();
1104
1113
  ansi.use_classes = false;
1105
1114
 
1115
+ let _myRole = 'admin'; // 成员(operator)@ 只能浏览本项目/绑定目录,不提示项目外用法
1106
1116
  fetch('/api/me').then(r => r.json()).then(data => {
1117
+ // 服务端没有桌面(headless 部署):「打开当前文件夹」必然无效,直接藏掉,只留文件面板
1118
+ if (data?.desktop === false) document.body.classList.add('no-desktop');
1119
+ _myRole = data?.role || 'admin';
1107
1120
  // 打包默认快捷栏预设:用户没自定义过勾选(skVisible 为 null)时采用服务端 config.json 的 skMode
1108
1121
  if (data?.skMode && settings.skVisible == null) {
1109
1122
  settings.skVisible = data.skMode === 'normal' ? skPresetNormal() : skPresetAll();
@@ -2313,6 +2326,51 @@
2313
2326
  return ok;
2314
2327
  }
2315
2328
 
2329
+ function copyFlash(btn, ok) {
2330
+ btn.textContent = ok ? '已复制' : '复制失败';
2331
+ btn.classList.add('done');
2332
+ clearTimeout(btn._t);
2333
+ btn._t = setTimeout(() => { btn.textContent = '复制'; btn.classList.remove('done'); }, 1500);
2334
+ }
2335
+
2336
+ // 消息里所有「带框」内容右上角都给复制按钮:代码块、表格(复制成制表符分隔,可直接粘进 Excel)、
2337
+ // diff、工具调用详情、子代理汇总、历史块、压缩摘要。工具原始输出(.msg-raw)有自己的按钮。
2338
+ // 渲染点很多(流式每个 chunk 都重写 innerHTML),统一用 MutationObserver 补挂,不逐处改。
2339
+ const COPY_SEL = '.msg-assistant .content pre, .md-table, .msg-diff, .tool-body, .sub-summary, .msg-synthetic .syn-body, .msg-history .code-block';
2340
+ function copyableText(el) {
2341
+ if (el.tagName === 'TABLE') return [...el.rows].map(r => [...r.cells].map(c => c.innerText.trim()).join('\t')).join('\n');
2342
+ const nodes = [...el.childNodes].filter(n => !n.classList?.contains('cp-btn'));
2343
+ // diff 每行是一个 <div>,按行拼;其余(pre / pre-wrap 文本)textContent 自带换行
2344
+ if (el.classList.contains('msg-diff')) return nodes.map(n => n.textContent).join('\n');
2345
+ return nodes.map(n => n.textContent).join('').replace(/\n+$/, '');
2346
+ }
2347
+ function attachCopyBtns() {
2348
+ chatArea.querySelectorAll(COPY_SEL).forEach(el => {
2349
+ if (el._cpBtn?.isConnected || el.closest('.msg-raw')) return;
2350
+ const btn = document.createElement('button');
2351
+ btn.className = 'cp-btn';
2352
+ btn.textContent = '复制';
2353
+ btn.title = el.tagName === 'TABLE' ? '复制表格(可直接粘贴到 Excel)' : '复制';
2354
+ btn.onclick = async (e) => { e.stopPropagation(); copyFlash(btn, await copyText(copyableText(el))); };
2355
+ if (el.classList.contains('msg')) {
2356
+ // 本身就是一条 .msg(diff):不能套壳,否则 trimMessages / lastElementChild 认不出
2357
+ el.classList.add('cp-host'); el.appendChild(btn);
2358
+ } else {
2359
+ // 套一层壳挂按钮:代码块/工具详情自己会横竖滚动,按钮放里面会跟着滚走
2360
+ const w = document.createElement('div');
2361
+ w.className = 'cpw';
2362
+ el.before(w); w.append(el, btn);
2363
+ }
2364
+ el._cpBtn = btn;
2365
+ });
2366
+ }
2367
+ let _cpPending = false;
2368
+ new MutationObserver(() => {
2369
+ if (_cpPending) return;
2370
+ _cpPending = true;
2371
+ requestAnimationFrame(() => { _cpPending = false; attachCopyBtns(); });
2372
+ }).observe(chatArea, { childList: true, subtree: true });
2373
+
2316
2374
  // 工具输出局部/全部显示。默认跟设置 rawClip;用户点过按钮的块记在 dataset.open,内容追加时保持。
2317
2375
  // 只有内容真的超出 8 行高度才出按钮;回放时元素可能还在 fragment 里没上屏,等上屏再量。
2318
2376
  function rawClipSync(msg, tries = 0) {
@@ -2324,13 +2382,7 @@
2324
2382
  cp.className = 'raw-copy';
2325
2383
  cp.textContent = '复制';
2326
2384
  cp.title = '复制全部输出';
2327
- cp.onclick = async () => {
2328
- const ok = await copyText(block.textContent.replace(/\n+$/, ''));
2329
- cp.textContent = ok ? '已复制' : '复制失败';
2330
- cp.classList.add('done');
2331
- clearTimeout(cp._t);
2332
- cp._t = setTimeout(() => { cp.textContent = '复制'; cp.classList.remove('done'); }, 1500);
2333
- };
2385
+ cp.onclick = async () => copyFlash(cp, await copyText(block.textContent.replace(/\n+$/, '')));
2334
2386
  msg.appendChild(cp);
2335
2387
  }
2336
2388
  const clip = msg.dataset.open ? msg.dataset.open !== '1' : settings.rawClip !== false;
@@ -2906,9 +2958,12 @@
2906
2958
 
2907
2959
  chatArea.addEventListener('scroll', () => {
2908
2960
  // 用户主动往上翻 → 禁用自动 trim,避免被删
2909
- if (chatArea.scrollTop < chatArea.scrollHeight - chatArea.clientHeight - 100) {
2961
+ const dist = chatArea.scrollHeight - chatArea.clientHeight - chatArea.scrollTop;
2962
+ if (dist > 100) {
2910
2963
  _userScrolledUp = true;
2911
- } else if (chatArea.scrollTop > chatArea.scrollHeight - chatArea.clientHeight - 20) {
2964
+ // 离底部超过 300px(翻看历史 / ☰ 定位跳到旧消息)→ 露出「↓ 回到底部」,一键回来
2965
+ if (dist > 300) setBottomBtn(true);
2966
+ } else if (dist < 20) {
2912
2967
  // 回到接近底部 → 恢复 trim + 隐藏新消息提示
2913
2968
  _userScrolledUp = false;
2914
2969
  hideNewMsgHint();
@@ -3202,7 +3257,7 @@
3202
3257
  // 输入框里打出 @词(@ 后无空白)时浮出文件搜索层:递归搜会话目录、
3203
3258
  // 最近修改优先;选中把 @词 替换成真实相对路径。「让 AI 读某个文件」
3204
3259
  // 不再需要手打文件名。也可在 📄 文件面板点「引用」达到同样效果。
3205
- const _mention = { open: false, start: -1, items: [], idx: 0 };
3260
+ const _mention = { open: false, start: -1, items: [], idx: 0, browse: null, seq: 0, lock: '', token: null };
3206
3261
  const _mentionBox = document.createElement('div');
3207
3262
  _mentionBox.id = 'mention-box';
3208
3263
  document.body.appendChild(_mentionBox);
@@ -3212,6 +3267,10 @@
3212
3267
  function mentionToken() {
3213
3268
  const pos = cmdInput.selectionStart ?? cmdInput.value.length;
3214
3269
  const val = cmdInput.value;
3270
+ // 逐层进了带空格的文件夹(@My Docs/):认我们自己写进去的路径前缀,不按空白截断
3271
+ const L = _mention.open && _mention.lock;
3272
+ if (L && val[_mention.start] === '@' && val.startsWith(L, _mention.start + 1) && pos >= _mention.start + 1 + L.length
3273
+ && !/\s/.test(val.slice(_mention.start + 1 + L.length, pos))) return { start: _mention.start };
3215
3274
  const from = val.lastIndexOf('@', Math.max(0, pos - 1));
3216
3275
  if (from < 0) return null;
3217
3276
  if (from > 0 && /[A-Za-z0-9._%-]/.test(val[from - 1])) return null; // 邮箱等普通文本不触发
@@ -3221,24 +3280,55 @@
3221
3280
 
3222
3281
  function mentionClose() {
3223
3282
  _mention.open = false;
3283
+ _mention.browse = null;
3224
3284
  _mention.items = [];
3285
+ _mention.lock = '';
3286
+ _mention.token = null;
3225
3287
  _mentionBox.style.display = 'none';
3226
3288
  }
3227
3289
 
3290
+ // 仿 Claude Code:@ 后是路径形态(含 / 或 \、C:、~)→ 按路径逐层浏览(本机任意目录,成员限本项目/绑定目录);
3291
+ // 只打 @ = 从本项目第一层开始浏览;@ 后打了普通字 = 本项目递归搜索。
3292
+ function mentionPathParts(token) {
3293
+ const t = token.replace(/\\/g, '/');
3294
+ if (/^[A-Za-z]:$/.test(t)) return { dir: t + '/', q: '' };
3295
+ if (t === '~') return { dir: '~/', q: '' };
3296
+ const i = t.lastIndexOf('/');
3297
+ return i < 0 ? null : { dir: t.slice(0, i + 1), q: t.slice(i + 1) };
3298
+ }
3299
+
3228
3300
  function mentionRowHTML(f, i) {
3229
- const path = f.name;
3230
- const cut = path.lastIndexOf('/');
3301
+ const on = i === _mention.idx ? 'on' : '';
3302
+ if (f.act === 'up') return `<div class="mi ${on}" data-i="${i}"><span class="m-ico">⬆</span><span class="m-name">上一级</span></div>`;
3303
+ if (f.act === 'self') return `<div class="mi ${on}" data-i="${i}"><span class="m-ico">✓</span><span class="m-name">引用这个文件夹</span><span class="m-hint">${f.abs ? '项目外目录' : '文件夹'}</span></div>`;
3304
+ const path = f.label || f.name;
3305
+ const cut = f.label ? -1 : path.lastIndexOf('/');
3231
3306
  const dir = cut >= 0 ? path.slice(0, cut + 1) : '';
3232
3307
  const name = cut >= 0 ? path.slice(cut + 1) : path;
3233
- const ico = f.dir ? '📁' : (isEditableFile(name) ? '📝' : '📄');
3234
- const hint = f.dir ? (f.abs ? '项目外目录' : '文件夹') : (f.abs ? '项目外' : '引用');
3235
- return `<div class="mi ${i === _mention.idx ? 'on' : ''}" data-i="${i}"><span class="m-ico">${ico}</span><span class="m-dir">${escHtml(dir)}</span><span class="m-name">${escHtml(name)}</span><span class="m-hint">${hint}</span></div>`;
3308
+ const ico = f.dir ? (f.drive ? '💽' : '📁') : (isEditableFile(name) ? '📝' : '📄');
3309
+ const hint = f.nav ? '进入 ›' : f.dir ? (f.abs ? '项目外目录' : '文件夹') : (f.abs ? '项目外' : '引用');
3310
+ return `<div class="mi ${on}" data-i="${i}"><span class="m-ico">${ico}</span><span class="m-dir">${escHtml(dir)}</span><span class="m-name">${escHtml(name)}</span><span class="m-hint">${hint}</span></div>`;
3236
3311
  }
3237
3312
 
3238
3313
  function mentionRender() {
3239
- _mentionBox.innerHTML = _mention.items.length
3240
- ? _mention.items.map((f, i) => mentionRowHTML(f, i)).join('')
3241
- : '<div class="mi-empty">没有匹配的文件</div>';
3314
+ const b = _mention.browse;
3315
+ let html = '';
3316
+ if (b) {
3317
+ const crumb = b.rel ? '本项目' + (b.dir ? ' / ' + b.dir.replace(/\/+$/, '').split('/').join(' / ') : '')
3318
+ : (b.abs === '/' && b.win ? '本机磁盘' : b.abs || b.dir);
3319
+ html += `<div class="mi-head">📂 ${escHtml(crumb)}</div>`;
3320
+ }
3321
+ _mention.items.forEach((f, i) => { html += mentionRowHTML(f, i); });
3322
+ if (b?.err) html += `<div class="mi-empty" style="color:var(--red)">${escHtml(b.err)}</div>`;
3323
+ else if (!_mention.items.some(f => !f.act)) html += `<div class="mi-empty">${b && !b.q ? '这个文件夹是空的' : '没有匹配的文件'}</div>`;
3324
+ if (b && !b.err) {
3325
+ const tips = [];
3326
+ if (_mention.items.some(f => f.nav)) tips.push('回车进入文件夹 · Tab 直接引用文件夹');
3327
+ if (!b.dir && _myRole !== 'admin') tips.push('打字搜索本项目');
3328
+ else if (!b.dir) tips.push('打字搜索本项目 · 项目外打 ' + (b.win ? '@D: 或 @\\' : '@/') + '、@~/ 逐层浏览');
3329
+ if (tips.length) html += `<div class="mi-tip">${escHtml(tips.join(' · '))}</div>`;
3330
+ }
3331
+ _mentionBox.innerHTML = html;
3242
3332
  const r = cmdInput.getBoundingClientRect();
3243
3333
  _mentionBox.style.left = Math.max(8, r.left) + 'px';
3244
3334
  _mentionBox.style.width = Math.max(r.width, 240) + 'px';
@@ -3250,11 +3340,12 @@
3250
3340
 
3251
3341
  function mentionSearch(token) {
3252
3342
  clearTimeout(_mentionTimer);
3343
+ const seq = _mention.seq;
3253
3344
  _mentionTimer = setTimeout(async () => {
3254
3345
  try {
3255
3346
  const r = await fetch(`/api/files/${SESSION_ID}/search?q=${encodeURIComponent(token)}`);
3256
3347
  const d = await r.json();
3257
- if (!_mention.open || !d.ok) return;
3348
+ if (seq !== _mention.seq || !_mention.open || !d.ok) return;
3258
3349
  _mention.items = d.files || [];
3259
3350
  _mention.idx = 0;
3260
3351
  mentionRender();
@@ -3262,17 +3353,81 @@
3262
3353
  }, 140);
3263
3354
  }
3264
3355
 
3356
+ // 按路径列一层:dir 为 @ 后最后一个斜杠及之前(原样,服务端解析 ~ / C: / 相对路径),q 为最后一段
3357
+ function mentionList(dir, q) {
3358
+ clearTimeout(_mentionTimer);
3359
+ const seq = _mention.seq;
3360
+ _mentionTimer = setTimeout(async () => {
3361
+ try {
3362
+ const r = await fetch(`/api/files/${SESSION_ID}/at-ls?dir=${encodeURIComponent(dir)}&q=${encodeURIComponent(q)}`);
3363
+ const d = await r.json();
3364
+ if (seq !== _mention.seq || !_mention.open) return;
3365
+ if (!d.ok) { _mention.browse = { dir, q, err: d.error || '读取失败' }; _mention.items = []; mentionRender(); return; }
3366
+ // 插入输入框的路径:本项目内用相对路径;项目外一律绝对路径(~ 已由服务端展开),AI 直接按它读
3367
+ const absBase = d.abs === '/' ? '' : d.abs.replace(/\/+$/, '');
3368
+ const pickName = (label) => d.rel ? dir + label : (d.abs === '/' && d.win ? label : absBase + '/' + label);
3369
+ const items = [];
3370
+ if (!q && dir) {
3371
+ items.push({ act: 'up' });
3372
+ if (!(d.abs === '/' && d.win)) items.push({ act: 'self', name: d.rel ? dir.replace(/\/+$/, '') : absBase || '/', dir: true, abs: !d.rel }); // 盘符列表本身不能引用
3373
+ }
3374
+ d.entries.forEach(e => items.push({ label: e.label, name: pickName(e.label), dir: e.dir, nav: e.dir, abs: !d.rel, drive: d.abs === '/' && d.win, typed: dir + e.label + '/' }));
3375
+ _mention.browse = { dir, q, ...d };
3376
+ _mention.items = items;
3377
+ _mention.idx = Math.max(0, items.findIndex(f => !f.act)); // 默认落在第一个条目上,不是「上一级」
3378
+ mentionRender();
3379
+ } catch { mentionClose(); }
3380
+ }, 90);
3381
+ }
3382
+
3265
3383
  function mentionUpdate() {
3266
3384
  const t = mentionToken();
3267
3385
  if (!t) return mentionClose();
3386
+ const token = cmdInput.value.slice(t.start + 1, cmdInput.selectionStart ?? cmdInput.value.length);
3387
+ const same = _mention.open && _mention.start === t.start && _mention.token === token;
3388
+ if (_mention.start !== t.start) _mention.lock = '';
3268
3389
  _mention.open = true;
3269
3390
  _mention.start = t.start;
3270
- mentionSearch(cmdInput.value.slice(t.start + 1, cmdInput.selectionStart ?? cmdInput.value.length));
3391
+ if (same) return; // keyup 等事件会反复进来,token 没变不重复请求
3392
+ _mention.token = token;
3393
+ _mention.seq++;
3394
+ const pp = token ? mentionPathParts(token) : { dir: '', q: '' };
3395
+ if (pp) return mentionList(pp.dir, pp.q);
3396
+ _mention.browse = null;
3397
+ mentionSearch(token);
3398
+ }
3399
+
3400
+ // 把 @ 后面(到光标)替换成新的路径并继续浏览——「进入文件夹 / 上一级」都是改输入框里的路径,和手打等价
3401
+ function mentionGoto(tok) {
3402
+ const pos = cmdInput.selectionStart ?? cmdInput.value.length;
3403
+ const before = cmdInput.value.slice(0, _mention.start + 1);
3404
+ cmdInput.value = before + tok + cmdInput.value.slice(pos);
3405
+ const caret = (before + tok).length;
3406
+ cmdInput.setSelectionRange(caret, caret);
3407
+ _mention.lock = /\s/.test(tok) ? tok : '';
3408
+ cmdInput.focus();
3409
+ mentionUpdate();
3410
+ }
3411
+
3412
+ // 回车/点击:文件夹进入、上一级返回、其余引用;Tab = 文件夹也直接引用
3413
+ function mentionActivate(f, pickDir) {
3414
+ if (!f) return;
3415
+ const b = _mention.browse;
3416
+ if (f.act === 'up') {
3417
+ if (b.rel) {
3418
+ const segs = b.dir.split('/').filter(Boolean);
3419
+ return mentionGoto(!segs.length || segs[segs.length - 1] === '..' ? b.dir + '../' : (segs.pop(), segs.length ? segs.join('/') + '/' : ''));
3420
+ }
3421
+ if (!b.parent) return;
3422
+ return mentionGoto(b.parent === '/' ? '/' : b.parent.replace(/\/+$/, '') + '/');
3423
+ }
3424
+ if (f.nav && !pickDir) return mentionGoto(f.typed);
3425
+ mentionPick(f);
3271
3426
  }
3272
3427
 
3273
3428
  function mentionPick(f) {
3274
3429
  // 目录带尾斜杠插入(「迁移到 docs/」一眼看出是文件夹),文件带空格结尾
3275
- const path = f.name + (f.dir ? '/' : '');
3430
+ const path = f.name.replace(/\/+$/, '') + (f.dir ? '/' : '');
3276
3431
  const pos = cmdInput.selectionStart ?? cmdInput.value.length;
3277
3432
  const before = cmdInput.value.slice(0, _mention.start);
3278
3433
  const after = cmdInput.value.slice(pos);
@@ -3301,7 +3456,7 @@
3301
3456
  if (!d) return;
3302
3457
  if (Math.abs(e.clientX - d.x) >= 8 || Math.abs(e.clientY - d.y) >= 8) return; // 滚动手势
3303
3458
  const row = e.pointerType === 'touch' ? d.row : e.target.closest('.mi[data-i]');
3304
- if (row) mentionPick(_mention.items[Number(row.dataset.i)]);
3459
+ if (row) mentionActivate(_mention.items[Number(row.dataset.i)]);
3305
3460
  });
3306
3461
  _mentionBox.addEventListener('pointercancel', () => { _mentionDown = null; });
3307
3462
  window.addEventListener('resize', () => { if (_mention.open) mentionClose(); });
@@ -3315,7 +3470,7 @@
3315
3470
  e.preventDefault(); _mention.idx = (_mention.idx - 1 + _mention.items.length) % _mention.items.length; mentionRender(); return;
3316
3471
  }
3317
3472
  if ((e.key === 'Enter' || e.key === 'Tab') && _mention.items.length && !e.isComposing && e.keyCode !== 229) {
3318
- e.preventDefault(); mentionPick(_mention.items[_mention.idx]); return; // 补全层开着时回车=选中,不是发送
3473
+ e.preventDefault(); mentionActivate(_mention.items[_mention.idx], e.key === 'Tab'); return; // 补全层开着时回车=选中/进入,不是发送
3319
3474
  }
3320
3475
  if (e.key === 'Escape') { e.preventDefault(); mentionClose(); return; } // 先关补全,别误触中断 AI
3321
3476
  }
@@ -3391,10 +3546,10 @@
3391
3546
  box.innerHTML = `
3392
3547
  <div style="font-size:1rem;font-weight:700;margin-bottom:0.5rem">💬 会话怎么用</div>
3393
3548
  <b>发消息</b>:像聊天一样把需求说清楚即可;触屏回车=换行,用发送键发送。<br>
3394
- <b>让它读文件</b>:打 <b>@</b> 弹文件搜索(含设置里配的项目外目录),选中插路径;或点「文件」面板的 <b>@</b> 引用。<br>
3549
+ <b>让它读文件</b>:打 <b>@</b> 从本项目逐层浏览、打字搜索;项目外打 <b>@D:</b>、<b>@/</b>、<b>@~/</b> 按路径逐层浏览;选中插路径;或点「文件」面板的 <b>@</b> 引用。<br>
3395
3550
  <b>工具/思考</b>:AI 干活的过程默认折叠,点「🔧 N 个工具调用」展开看命令与输出。<br>
3396
3551
  <b>AI 忙时</b>:输入框变「存草稿」,先记着干完取回;勾自动接力会自动发出。<br>
3397
- <b>定位旧消息</b>:电脑上鼠标移到聊天区,右下角 ☰ 可列出你发过的消息点击跳转。<br>
3552
+ <b>定位旧消息</b>:电脑上鼠标移到聊天区,右下角 ☰ 可列出你发过的消息点击跳转;看完点底部「↓ 回到底部」一键回来。<br>
3398
3553
  <b>常用按钮</b>:模型/计划/压缩/恢复/清除/密钥/护栏/提交——普通用户可在设置→快捷栏精简。<br>
3399
3554
  <span style="color:var(--muted)">更多说明见首页顶栏 ❓ 帮助。</span>`;
3400
3555
  const closeBtn = document.createElement('button');
@@ -3434,7 +3589,7 @@
3434
3589
  const d = await r.json();
3435
3590
  if (d.ok) {
3436
3591
  closeFilePanel();
3437
- addStatusMessage('📂 已在本机打开文件夹 ' + (d.cwd || ''));
3592
+ addStatusMessage((d.via === 'task' ? '📂 已在本机桌面打开文件夹(myhi 在后台会话运行,经计划任务打开)' : '📂 已在本机打开文件夹 ') + (d.cwd || ''));
3438
3593
  } else {
3439
3594
  addStatusMessage('打开失败: ' + (d.error || '') + '(若你是远程访问本面板,此按钮本就不生效)');
3440
3595
  }
@@ -4072,8 +4227,16 @@
4072
4227
  if (_userScrolledUp) { showNewMsgHint(); return; }
4073
4228
  requestAnimationFrame(() => { chatArea.scrollTop = chatArea.scrollHeight; });
4074
4229
  }
4075
- function showNewMsgHint() { const b = document.getElementById('new-msg-btn'); if (b) b.classList.add('show'); }
4076
- function hideNewMsgHint() { const b = document.getElementById('new-msg-btn'); if (b) b.classList.remove('show'); }
4230
+ let _newBelow = false; // 翻看历史期间底下来了新消息 → 按钮文案变「↓ 新消息」
4231
+ function setBottomBtn(show) {
4232
+ const b = document.getElementById('new-msg-btn'); if (!b) return;
4233
+ b.textContent = _newBelow ? '↓ 新消息' : '↓ 回到底部';
4234
+ // 贴着聊天区底边上方:输入框/快捷栏高度随设备和内容变,写死 bottom 在手机上会压住输入框
4235
+ if (show) b.style.bottom = Math.max(12, window.innerHeight - chatArea.getBoundingClientRect().bottom + 12) + 'px';
4236
+ b.classList.toggle('show', show);
4237
+ }
4238
+ function showNewMsgHint() { _newBelow = true; setBottomBtn(true); }
4239
+ function hideNewMsgHint() { _newBelow = false; setBottomBtn(false); }
4077
4240
  window.jumpToBottom = function() { _userScrolledUp = false; hideNewMsgHint(); requestAnimationFrame(() => { chatArea.scrollTop = chatArea.scrollHeight; }); };
4078
4241
  function trimMessages() {
4079
4242
  if (_userScrolledUp) return; // 用户在往上翻历史,禁用自动删