@wendongfly/myhi 1.3.184 → 1.3.186

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
@@ -157,7 +157,7 @@
157
157
  .sub-act .k { color: var(--muted); }
158
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; }
159
159
 
160
- /* 往上翻看历史时来了新消息 → 底部浮动「↓ 新消息」按钮,点击回到底部 */
160
+ /* 往上翻看历史(含 ☰ 定位跳转后)→ 底部浮动「↓ 回到底部」,期间来了新消息变「↓ 新消息」;点击回到底部 */
161
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; }
162
162
  #new-msg-btn.show { display: block; }
163
163
  #new-msg-btn:active { opacity: 0.8; }
@@ -198,10 +198,16 @@
198
198
  .raw-toggle:hover { color: var(--text); border-color: var(--blue); }
199
199
  /* 工具输出复制:右上角,桌面端悬停才现身,触屏常显 */
200
200
  .msg-raw { position: relative; }
201
- .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; }
202
202
  .msg-raw:hover .raw-copy, .raw-copy.done { opacity: 1; }
203
- .raw-copy:hover { color: var(--text); border-color: var(--blue); }
203
+ .raw-copy:hover, .cp-btn:hover { color: var(--text); border-color: var(--blue); }
204
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; } }
205
211
 
206
212
  /* 历史和状态 */
207
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; }
@@ -667,7 +673,7 @@
667
673
  </div>
668
674
  <div class="setting-group" data-stab="more" style="display:none">
669
675
  <h4>全局设置</h4>
670
- <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>
671
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>
672
678
  </div>
673
679
  <div class="setting-group" data-stab="more" style="display:none">
@@ -685,8 +691,8 @@
685
691
  </div>
686
692
  </div>
687
693
 
688
- <!-- 往上翻历史时来新消息的浮动提示,点击回到底部 -->
689
- <button id="new-msg-btn" onclick="jumpToBottom()">↓ 新消息</button>
694
+ <!-- 往上翻历史时的浮动按钮(来了新消息时提示「新消息」),点击回到底部 -->
695
+ <button id="new-msg-btn" onclick="jumpToBottom()">↓ 回到底部</button>
690
696
 
691
697
  <!-- 模型选择面板 -->
692
698
  <!-- 会话数据面板:上下文水位 + 本会话成本/token 明细(点顶栏水位 chip 打开)-->
@@ -2318,6 +2324,51 @@
2318
2324
  return ok;
2319
2325
  }
2320
2326
 
2327
+ function copyFlash(btn, ok) {
2328
+ btn.textContent = ok ? '已复制' : '复制失败';
2329
+ btn.classList.add('done');
2330
+ clearTimeout(btn._t);
2331
+ btn._t = setTimeout(() => { btn.textContent = '复制'; btn.classList.remove('done'); }, 1500);
2332
+ }
2333
+
2334
+ // 消息里所有「带框」内容右上角都给复制按钮:代码块、表格(复制成制表符分隔,可直接粘进 Excel)、
2335
+ // diff、工具调用详情、子代理汇总、历史块、压缩摘要。工具原始输出(.msg-raw)有自己的按钮。
2336
+ // 渲染点很多(流式每个 chunk 都重写 innerHTML),统一用 MutationObserver 补挂,不逐处改。
2337
+ const COPY_SEL = '.msg-assistant .content pre, .md-table, .msg-diff, .tool-body, .sub-summary, .msg-synthetic .syn-body, .msg-history .code-block';
2338
+ function copyableText(el) {
2339
+ if (el.tagName === 'TABLE') return [...el.rows].map(r => [...r.cells].map(c => c.innerText.trim()).join('\t')).join('\n');
2340
+ const nodes = [...el.childNodes].filter(n => !n.classList?.contains('cp-btn'));
2341
+ // diff 每行是一个 <div>,按行拼;其余(pre / pre-wrap 文本)textContent 自带换行
2342
+ if (el.classList.contains('msg-diff')) return nodes.map(n => n.textContent).join('\n');
2343
+ return nodes.map(n => n.textContent).join('').replace(/\n+$/, '');
2344
+ }
2345
+ function attachCopyBtns() {
2346
+ chatArea.querySelectorAll(COPY_SEL).forEach(el => {
2347
+ if (el._cpBtn?.isConnected || el.closest('.msg-raw')) return;
2348
+ const btn = document.createElement('button');
2349
+ btn.className = 'cp-btn';
2350
+ btn.textContent = '复制';
2351
+ btn.title = el.tagName === 'TABLE' ? '复制表格(可直接粘贴到 Excel)' : '复制';
2352
+ btn.onclick = async (e) => { e.stopPropagation(); copyFlash(btn, await copyText(copyableText(el))); };
2353
+ if (el.classList.contains('msg')) {
2354
+ // 本身就是一条 .msg(diff):不能套壳,否则 trimMessages / lastElementChild 认不出
2355
+ el.classList.add('cp-host'); el.appendChild(btn);
2356
+ } else {
2357
+ // 套一层壳挂按钮:代码块/工具详情自己会横竖滚动,按钮放里面会跟着滚走
2358
+ const w = document.createElement('div');
2359
+ w.className = 'cpw';
2360
+ el.before(w); w.append(el, btn);
2361
+ }
2362
+ el._cpBtn = btn;
2363
+ });
2364
+ }
2365
+ let _cpPending = false;
2366
+ new MutationObserver(() => {
2367
+ if (_cpPending) return;
2368
+ _cpPending = true;
2369
+ requestAnimationFrame(() => { _cpPending = false; attachCopyBtns(); });
2370
+ }).observe(chatArea, { childList: true, subtree: true });
2371
+
2321
2372
  // 工具输出局部/全部显示。默认跟设置 rawClip;用户点过按钮的块记在 dataset.open,内容追加时保持。
2322
2373
  // 只有内容真的超出 8 行高度才出按钮;回放时元素可能还在 fragment 里没上屏,等上屏再量。
2323
2374
  function rawClipSync(msg, tries = 0) {
@@ -2329,13 +2380,7 @@
2329
2380
  cp.className = 'raw-copy';
2330
2381
  cp.textContent = '复制';
2331
2382
  cp.title = '复制全部输出';
2332
- cp.onclick = async () => {
2333
- const ok = await copyText(block.textContent.replace(/\n+$/, ''));
2334
- cp.textContent = ok ? '已复制' : '复制失败';
2335
- cp.classList.add('done');
2336
- clearTimeout(cp._t);
2337
- cp._t = setTimeout(() => { cp.textContent = '复制'; cp.classList.remove('done'); }, 1500);
2338
- };
2383
+ cp.onclick = async () => copyFlash(cp, await copyText(block.textContent.replace(/\n+$/, '')));
2339
2384
  msg.appendChild(cp);
2340
2385
  }
2341
2386
  const clip = msg.dataset.open ? msg.dataset.open !== '1' : settings.rawClip !== false;
@@ -2911,9 +2956,12 @@
2911
2956
 
2912
2957
  chatArea.addEventListener('scroll', () => {
2913
2958
  // 用户主动往上翻 → 禁用自动 trim,避免被删
2914
- if (chatArea.scrollTop < chatArea.scrollHeight - chatArea.clientHeight - 100) {
2959
+ const dist = chatArea.scrollHeight - chatArea.clientHeight - chatArea.scrollTop;
2960
+ if (dist > 100) {
2915
2961
  _userScrolledUp = true;
2916
- } else if (chatArea.scrollTop > chatArea.scrollHeight - chatArea.clientHeight - 20) {
2962
+ // 离底部超过 300px(翻看历史 / ☰ 定位跳到旧消息)→ 露出「↓ 回到底部」,一键回来
2963
+ if (dist > 300) setBottomBtn(true);
2964
+ } else if (dist < 20) {
2917
2965
  // 回到接近底部 → 恢复 trim + 隐藏新消息提示
2918
2966
  _userScrolledUp = false;
2919
2967
  hideNewMsgHint();
@@ -3207,7 +3255,7 @@
3207
3255
  // 输入框里打出 @词(@ 后无空白)时浮出文件搜索层:递归搜会话目录、
3208
3256
  // 最近修改优先;选中把 @词 替换成真实相对路径。「让 AI 读某个文件」
3209
3257
  // 不再需要手打文件名。也可在 📄 文件面板点「引用」达到同样效果。
3210
- const _mention = { open: false, start: -1, items: [], idx: 0, browse: null, seq: 0 };
3258
+ const _mention = { open: false, start: -1, items: [], idx: 0, browse: null, seq: 0, lock: '', token: null };
3211
3259
  const _mentionBox = document.createElement('div');
3212
3260
  _mentionBox.id = 'mention-box';
3213
3261
  document.body.appendChild(_mentionBox);
@@ -3217,6 +3265,10 @@
3217
3265
  function mentionToken() {
3218
3266
  const pos = cmdInput.selectionStart ?? cmdInput.value.length;
3219
3267
  const val = cmdInput.value;
3268
+ // 逐层进了带空格的文件夹(@My Docs/):认我们自己写进去的路径前缀,不按空白截断
3269
+ const L = _mention.open && _mention.lock;
3270
+ if (L && val[_mention.start] === '@' && val.startsWith(L, _mention.start + 1) && pos >= _mention.start + 1 + L.length
3271
+ && !/\s/.test(val.slice(_mention.start + 1 + L.length, pos))) return { start: _mention.start };
3220
3272
  const from = val.lastIndexOf('@', Math.max(0, pos - 1));
3221
3273
  if (from < 0) return null;
3222
3274
  if (from > 0 && /[A-Za-z0-9._%-]/.test(val[from - 1])) return null; // 邮箱等普通文本不触发
@@ -3228,12 +3280,21 @@
3228
3280
  _mention.open = false;
3229
3281
  _mention.browse = null;
3230
3282
  _mention.items = [];
3283
+ _mention.lock = '';
3284
+ _mention.token = null;
3231
3285
  _mentionBox.style.display = 'none';
3232
3286
  }
3233
3287
 
3234
- // 两种模式:只打 @ = 分层浏览(_mention.browse 记当前层,点文件夹进入下一层);
3235
- // @ 后打了字 = 全局递归搜索(服务端已排好:本项目在前、项目外在后,匹配度优先)
3236
- const MENTION_GROUP = { own: '本项目', ext: '项目外' };
3288
+ // 仿 Claude Code:@ 后是路径形态(含 / 或 \、C:、~)→ 按路径逐层浏览(本机任意目录,成员限本项目/绑定目录);
3289
+ // 只打 @ = 从本项目第一层开始浏览;@ 后打了普通字 = 本项目递归搜索。
3290
+ function mentionPathParts(token) {
3291
+ const t = token.replace(/\\/g, '/');
3292
+ if (/^[A-Za-z]:$/.test(t)) return { dir: t + '/', q: '' };
3293
+ if (t === '~') return { dir: '~/', q: '' };
3294
+ const i = t.lastIndexOf('/');
3295
+ return i < 0 ? null : { dir: t.slice(0, i + 1), q: t.slice(i + 1) };
3296
+ }
3297
+
3237
3298
  function mentionRowHTML(f, i) {
3238
3299
  const on = i === _mention.idx ? 'on' : '';
3239
3300
  if (f.act === 'up') return `<div class="mi ${on}" data-i="${i}"><span class="m-ico">⬆</span><span class="m-name">上一级</span></div>`;
@@ -3242,21 +3303,28 @@
3242
3303
  const cut = f.label ? -1 : path.lastIndexOf('/');
3243
3304
  const dir = cut >= 0 ? path.slice(0, cut + 1) : '';
3244
3305
  const name = cut >= 0 ? path.slice(cut + 1) : path;
3245
- const ico = f.dir ? '📁' : (isEditableFile(name) ? '📝' : '📄');
3306
+ const ico = f.dir ? (f.drive ? '💽' : '📁') : (isEditableFile(name) ? '📝' : '📄');
3246
3307
  const hint = f.nav ? '进入 ›' : f.dir ? (f.abs ? '项目外目录' : '文件夹') : (f.abs ? '项目外' : '引用');
3247
3308
  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>`;
3248
3309
  }
3249
3310
 
3250
3311
  function mentionRender() {
3251
3312
  const b = _mention.browse;
3252
- let html = b && b.crumb ? `<div class="mi-head">📂 ${escHtml(b.crumb)}</div>` : '';
3253
- let group = null;
3254
- _mention.items.forEach((f, i) => {
3255
- if (f.group && f.group !== group && !(b && b.crumb)) { group = f.group; html += `<div class="mi-head">${MENTION_GROUP[group]}</div>`; }
3256
- html += mentionRowHTML(f, i);
3257
- });
3258
- if (!_mention.items.some(f => !f.act)) html += `<div class="mi-empty">${b ? '这个文件夹是空的' : '没有匹配的文件'}</div>`;
3259
- if (b && _mention.items.some(f => f.nav)) html += '<div class="mi-tip">回车/点击进入文件夹 · Tab 直接引用文件夹 · 打字可全局搜索</div>';
3313
+ let html = '';
3314
+ if (b) {
3315
+ const crumb = b.rel ? '本项目' + (b.dir ? ' / ' + b.dir.replace(/\/+$/, '').split('/').join(' / ') : '')
3316
+ : (b.abs === '/' && b.win ? '本机磁盘' : b.abs || b.dir);
3317
+ html += `<div class="mi-head">📂 ${escHtml(crumb)}</div>`;
3318
+ }
3319
+ _mention.items.forEach((f, i) => { html += mentionRowHTML(f, i); });
3320
+ if (b?.err) html += `<div class="mi-empty" style="color:var(--red)">${escHtml(b.err)}</div>`;
3321
+ else if (!_mention.items.some(f => !f.act)) html += `<div class="mi-empty">${b && !b.q ? '这个文件夹是空的' : '没有匹配的文件'}</div>`;
3322
+ if (b && !b.err) {
3323
+ const tips = [];
3324
+ if (_mention.items.some(f => f.nav)) tips.push('回车进入文件夹 · Tab 直接引用文件夹');
3325
+ if (!b.dir) tips.push('打字搜索本项目 · 项目外打 ' + (b.win ? '@D: 或 @\\' : '@/') + '、@~/ 逐层浏览');
3326
+ if (tips.length) html += `<div class="mi-tip">${escHtml(tips.join(' · '))}</div>`;
3327
+ }
3260
3328
  _mentionBox.innerHTML = html;
3261
3329
  const r = cmdInput.getBoundingClientRect();
3262
3330
  _mentionBox.style.left = Math.max(8, r.left) + 'px';
@@ -3269,77 +3337,94 @@
3269
3337
 
3270
3338
  function mentionSearch(token) {
3271
3339
  clearTimeout(_mentionTimer);
3340
+ const seq = _mention.seq;
3272
3341
  _mentionTimer = setTimeout(async () => {
3273
3342
  try {
3274
3343
  const r = await fetch(`/api/files/${SESSION_ID}/search?q=${encodeURIComponent(token)}`);
3275
3344
  const d = await r.json();
3276
- if (!_mention.open || _mention.browse || !d.ok) return;
3277
- _mention.items = (d.files || []).map(f => ({ ...f, group: f.abs ? 'ext' : 'own' }));
3345
+ if (seq !== _mention.seq || !_mention.open || !d.ok) return;
3346
+ _mention.items = d.files || [];
3278
3347
  _mention.idx = 0;
3279
3348
  mentionRender();
3280
3349
  } catch { mentionClose(); }
3281
3350
  }, 140);
3282
3351
  }
3283
3352
 
3284
- // 分层浏览:root=null 顶层(本项目第一层 + 项目外根目录),-1 本项目内,i 第 i 个项目外根目录内
3285
- async function mentionBrowse(root, path) {
3286
- const seq = ++_mention.seq;
3287
- try {
3288
- const qs = root === null ? '' : `?root=${root}&path=${encodeURIComponent(path)}`;
3289
- const r = await fetch(`/api/files/${SESSION_ID}/at-browse${qs}`);
3290
- const d = await r.json();
3291
- if (seq !== _mention.seq || !_mention.open || !d.ok) return;
3292
- const items = [];
3293
- let crumb = '';
3294
- if (root !== null) {
3295
- const rootFull = path ? d.self.slice(0, -(path.length + 1)) : d.self;
3296
- const rootLabel = root === -1 ? '本项目' : '项目外 · ' + (rootFull.split('/').pop() || rootFull);
3297
- crumb = rootLabel + (path ? ' / ' + path.split('/').join(' / ') : '');
3298
- items.push({ act: 'up' });
3299
- if (path || root !== -1) items.push({ act: 'self', name: d.self, dir: true, abs: root !== -1 });
3300
- }
3301
- d.entries.forEach(e => items.push({ ...e, root: root === null ? -1 : root, nav: e.dir, group: root === null ? 'own' : null }));
3302
- (d.roots || []).forEach(x => items.push({ name: x.name, dir: true, abs: true, root: x.i, rel: '', nav: true, group: 'ext' }));
3303
- _mention.browse = { root, path, crumb };
3304
- _mention.items = items;
3305
- _mention.idx = Math.max(0, items.findIndex(f => !f.act)); // 默认落在第一个条目上,不是「上一级」
3306
- mentionRender();
3307
- } catch { mentionClose(); }
3353
+ // 按路径列一层:dir 为 @ 后最后一个斜杠及之前(原样,服务端解析 ~ / C: / 相对路径),q 为最后一段
3354
+ function mentionList(dir, q) {
3355
+ clearTimeout(_mentionTimer);
3356
+ const seq = _mention.seq;
3357
+ _mentionTimer = setTimeout(async () => {
3358
+ try {
3359
+ const r = await fetch(`/api/files/${SESSION_ID}/at-ls?dir=${encodeURIComponent(dir)}&q=${encodeURIComponent(q)}`);
3360
+ const d = await r.json();
3361
+ if (seq !== _mention.seq || !_mention.open) return;
3362
+ if (!d.ok) { _mention.browse = { dir, q, err: d.error || '读取失败' }; _mention.items = []; mentionRender(); return; }
3363
+ // 插入输入框的路径:本项目内用相对路径;项目外一律绝对路径(~ 已由服务端展开),AI 直接按它读
3364
+ const absBase = d.abs === '/' ? '' : d.abs.replace(/\/+$/, '');
3365
+ const pickName = (label) => d.rel ? dir + label : (d.abs === '/' && d.win ? label : absBase + '/' + label);
3366
+ const items = [];
3367
+ if (!q && dir) {
3368
+ items.push({ act: 'up' });
3369
+ if (!(d.abs === '/' && d.win)) items.push({ act: 'self', name: d.rel ? dir.replace(/\/+$/, '') : absBase || '/', dir: true, abs: !d.rel }); // 盘符列表本身不能引用
3370
+ }
3371
+ 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 + '/' }));
3372
+ _mention.browse = { dir, q, ...d };
3373
+ _mention.items = items;
3374
+ _mention.idx = Math.max(0, items.findIndex(f => !f.act)); // 默认落在第一个条目上,不是「上一级」
3375
+ mentionRender();
3376
+ } catch { mentionClose(); }
3377
+ }, 90);
3308
3378
  }
3309
3379
 
3310
3380
  function mentionUpdate() {
3311
3381
  const t = mentionToken();
3312
3382
  if (!t) return mentionClose();
3313
3383
  const token = cmdInput.value.slice(t.start + 1, cmdInput.selectionStart ?? cmdInput.value.length);
3314
- const wasOpen = _mention.open && _mention.start === t.start;
3384
+ const same = _mention.open && _mention.start === t.start && _mention.token === token;
3385
+ if (_mention.start !== t.start) _mention.lock = '';
3315
3386
  _mention.open = true;
3316
3387
  _mention.start = t.start;
3317
- if (!token) {
3318
- // 已在浏览某一层就别重置(keyup 等事件会反复进来);新打开或从搜索删回空才回顶层
3319
- if (!(wasOpen && _mention.browse)) { _mention.browse = { root: null, path: '', crumb: '' }; mentionBrowse(null, ''); }
3320
- return;
3321
- }
3322
- _mention.browse = null;
3388
+ if (same) return; // keyup 等事件会反复进来,token 没变不重复请求
3389
+ _mention.token = token;
3323
3390
  _mention.seq++;
3391
+ const pp = token ? mentionPathParts(token) : { dir: '', q: '' };
3392
+ if (pp) return mentionList(pp.dir, pp.q);
3393
+ _mention.browse = null;
3324
3394
  mentionSearch(token);
3325
3395
  }
3326
3396
 
3327
- // 回车/点击:文件夹(浏览模式)进入、上一级返回、其余引用;Tab = 文件夹也直接引用
3397
+ // 把 @ 后面(到光标)替换成新的路径并继续浏览——「进入文件夹 / 上一级」都是改输入框里的路径,和手打等价
3398
+ function mentionGoto(tok) {
3399
+ const pos = cmdInput.selectionStart ?? cmdInput.value.length;
3400
+ const before = cmdInput.value.slice(0, _mention.start + 1);
3401
+ cmdInput.value = before + tok + cmdInput.value.slice(pos);
3402
+ const caret = (before + tok).length;
3403
+ cmdInput.setSelectionRange(caret, caret);
3404
+ _mention.lock = /\s/.test(tok) ? tok : '';
3405
+ cmdInput.focus();
3406
+ mentionUpdate();
3407
+ }
3408
+
3409
+ // 回车/点击:文件夹进入、上一级返回、其余引用;Tab = 文件夹也直接引用
3328
3410
  function mentionActivate(f, pickDir) {
3329
3411
  if (!f) return;
3412
+ const b = _mention.browse;
3330
3413
  if (f.act === 'up') {
3331
- const b = _mention.browse;
3332
- if (b.root !== null && b.path) mentionBrowse(b.root, b.path.includes('/') ? b.path.slice(0, b.path.lastIndexOf('/')) : '');
3333
- else mentionBrowse(null, '');
3334
- return;
3414
+ if (b.rel) {
3415
+ const segs = b.dir.split('/').filter(Boolean);
3416
+ return mentionGoto(!segs.length || segs[segs.length - 1] === '..' ? b.dir + '../' : (segs.pop(), segs.length ? segs.join('/') + '/' : ''));
3417
+ }
3418
+ if (!b.parent) return;
3419
+ return mentionGoto(b.parent === '/' ? '/' : b.parent.replace(/\/+$/, '') + '/');
3335
3420
  }
3336
- if (f.nav && !pickDir) return mentionBrowse(f.root, f.rel);
3421
+ if (f.nav && !pickDir) return mentionGoto(f.typed);
3337
3422
  mentionPick(f);
3338
3423
  }
3339
3424
 
3340
3425
  function mentionPick(f) {
3341
3426
  // 目录带尾斜杠插入(「迁移到 docs/」一眼看出是文件夹),文件带空格结尾
3342
- const path = f.name + (f.dir ? '/' : '');
3427
+ const path = f.name.replace(/\/+$/, '') + (f.dir ? '/' : '');
3343
3428
  const pos = cmdInput.selectionStart ?? cmdInput.value.length;
3344
3429
  const before = cmdInput.value.slice(0, _mention.start);
3345
3430
  const after = cmdInput.value.slice(pos);
@@ -3458,10 +3543,10 @@
3458
3543
  box.innerHTML = `
3459
3544
  <div style="font-size:1rem;font-weight:700;margin-bottom:0.5rem">💬 会话怎么用</div>
3460
3545
  <b>发消息</b>:像聊天一样把需求说清楚即可;触屏回车=换行,用发送键发送。<br>
3461
- <b>让它读文件</b>:打 <b>@</b> 弹文件搜索(含设置里配的项目外目录),选中插路径;或点「文件」面板的 <b>@</b> 引用。<br>
3546
+ <b>让它读文件</b>:打 <b>@</b> 从本项目逐层浏览、打字搜索;项目外打 <b>@D:</b>、<b>@/</b>、<b>@~/</b> 按路径逐层浏览;选中插路径;或点「文件」面板的 <b>@</b> 引用。<br>
3462
3547
  <b>工具/思考</b>:AI 干活的过程默认折叠,点「🔧 N 个工具调用」展开看命令与输出。<br>
3463
3548
  <b>AI 忙时</b>:输入框变「存草稿」,先记着干完取回;勾自动接力会自动发出。<br>
3464
- <b>定位旧消息</b>:电脑上鼠标移到聊天区,右下角 ☰ 可列出你发过的消息点击跳转。<br>
3549
+ <b>定位旧消息</b>:电脑上鼠标移到聊天区,右下角 ☰ 可列出你发过的消息点击跳转;看完点底部「↓ 回到底部」一键回来。<br>
3465
3550
  <b>常用按钮</b>:模型/计划/压缩/恢复/清除/密钥/护栏/提交——普通用户可在设置→快捷栏精简。<br>
3466
3551
  <span style="color:var(--muted)">更多说明见首页顶栏 ❓ 帮助。</span>`;
3467
3552
  const closeBtn = document.createElement('button');
@@ -4139,8 +4224,16 @@
4139
4224
  if (_userScrolledUp) { showNewMsgHint(); return; }
4140
4225
  requestAnimationFrame(() => { chatArea.scrollTop = chatArea.scrollHeight; });
4141
4226
  }
4142
- function showNewMsgHint() { const b = document.getElementById('new-msg-btn'); if (b) b.classList.add('show'); }
4143
- function hideNewMsgHint() { const b = document.getElementById('new-msg-btn'); if (b) b.classList.remove('show'); }
4227
+ let _newBelow = false; // 翻看历史期间底下来了新消息 → 按钮文案变「↓ 新消息」
4228
+ function setBottomBtn(show) {
4229
+ const b = document.getElementById('new-msg-btn'); if (!b) return;
4230
+ b.textContent = _newBelow ? '↓ 新消息' : '↓ 回到底部';
4231
+ // 贴着聊天区底边上方:输入框/快捷栏高度随设备和内容变,写死 bottom 在手机上会压住输入框
4232
+ if (show) b.style.bottom = Math.max(12, window.innerHeight - chatArea.getBoundingClientRect().bottom + 12) + 'px';
4233
+ b.classList.toggle('show', show);
4234
+ }
4235
+ function showNewMsgHint() { _newBelow = true; setBottomBtn(true); }
4236
+ function hideNewMsgHint() { _newBelow = false; setBottomBtn(false); }
4144
4237
  window.jumpToBottom = function() { _userScrolledUp = false; hideNewMsgHint(); requestAnimationFrame(() => { chatArea.scrollTop = chatArea.scrollHeight; }); };
4145
4238
  function trimMessages() {
4146
4239
  if (_userScrolledUp) return; // 用户在往上翻历史,禁用自动删