@wendongfly/myhi 1.3.184 → 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
@@ -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 打开)-->
@@ -1106,9 +1112,11 @@
1106
1112
  const ansi = new AnsiUp();
1107
1113
  ansi.use_classes = false;
1108
1114
 
1115
+ let _myRole = 'admin'; // 成员(operator)@ 只能浏览本项目/绑定目录,不提示项目外用法
1109
1116
  fetch('/api/me').then(r => r.json()).then(data => {
1110
1117
  // 服务端没有桌面(headless 部署):「打开当前文件夹」必然无效,直接藏掉,只留文件面板
1111
1118
  if (data?.desktop === false) document.body.classList.add('no-desktop');
1119
+ _myRole = data?.role || 'admin';
1112
1120
  // 打包默认快捷栏预设:用户没自定义过勾选(skVisible 为 null)时采用服务端 config.json 的 skMode
1113
1121
  if (data?.skMode && settings.skVisible == null) {
1114
1122
  settings.skVisible = data.skMode === 'normal' ? skPresetNormal() : skPresetAll();
@@ -2318,6 +2326,51 @@
2318
2326
  return ok;
2319
2327
  }
2320
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
+
2321
2374
  // 工具输出局部/全部显示。默认跟设置 rawClip;用户点过按钮的块记在 dataset.open,内容追加时保持。
2322
2375
  // 只有内容真的超出 8 行高度才出按钮;回放时元素可能还在 fragment 里没上屏,等上屏再量。
2323
2376
  function rawClipSync(msg, tries = 0) {
@@ -2329,13 +2382,7 @@
2329
2382
  cp.className = 'raw-copy';
2330
2383
  cp.textContent = '复制';
2331
2384
  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
- };
2385
+ cp.onclick = async () => copyFlash(cp, await copyText(block.textContent.replace(/\n+$/, '')));
2339
2386
  msg.appendChild(cp);
2340
2387
  }
2341
2388
  const clip = msg.dataset.open ? msg.dataset.open !== '1' : settings.rawClip !== false;
@@ -2911,9 +2958,12 @@
2911
2958
 
2912
2959
  chatArea.addEventListener('scroll', () => {
2913
2960
  // 用户主动往上翻 → 禁用自动 trim,避免被删
2914
- if (chatArea.scrollTop < chatArea.scrollHeight - chatArea.clientHeight - 100) {
2961
+ const dist = chatArea.scrollHeight - chatArea.clientHeight - chatArea.scrollTop;
2962
+ if (dist > 100) {
2915
2963
  _userScrolledUp = true;
2916
- } else if (chatArea.scrollTop > chatArea.scrollHeight - chatArea.clientHeight - 20) {
2964
+ // 离底部超过 300px(翻看历史 / ☰ 定位跳到旧消息)→ 露出「↓ 回到底部」,一键回来
2965
+ if (dist > 300) setBottomBtn(true);
2966
+ } else if (dist < 20) {
2917
2967
  // 回到接近底部 → 恢复 trim + 隐藏新消息提示
2918
2968
  _userScrolledUp = false;
2919
2969
  hideNewMsgHint();
@@ -3207,7 +3257,7 @@
3207
3257
  // 输入框里打出 @词(@ 后无空白)时浮出文件搜索层:递归搜会话目录、
3208
3258
  // 最近修改优先;选中把 @词 替换成真实相对路径。「让 AI 读某个文件」
3209
3259
  // 不再需要手打文件名。也可在 📄 文件面板点「引用」达到同样效果。
3210
- const _mention = { open: false, start: -1, items: [], idx: 0, browse: null, seq: 0 };
3260
+ const _mention = { open: false, start: -1, items: [], idx: 0, browse: null, seq: 0, lock: '', token: null };
3211
3261
  const _mentionBox = document.createElement('div');
3212
3262
  _mentionBox.id = 'mention-box';
3213
3263
  document.body.appendChild(_mentionBox);
@@ -3217,6 +3267,10 @@
3217
3267
  function mentionToken() {
3218
3268
  const pos = cmdInput.selectionStart ?? cmdInput.value.length;
3219
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 };
3220
3274
  const from = val.lastIndexOf('@', Math.max(0, pos - 1));
3221
3275
  if (from < 0) return null;
3222
3276
  if (from > 0 && /[A-Za-z0-9._%-]/.test(val[from - 1])) return null; // 邮箱等普通文本不触发
@@ -3228,12 +3282,21 @@
3228
3282
  _mention.open = false;
3229
3283
  _mention.browse = null;
3230
3284
  _mention.items = [];
3285
+ _mention.lock = '';
3286
+ _mention.token = null;
3231
3287
  _mentionBox.style.display = 'none';
3232
3288
  }
3233
3289
 
3234
- // 两种模式:只打 @ = 分层浏览(_mention.browse 记当前层,点文件夹进入下一层);
3235
- // @ 后打了字 = 全局递归搜索(服务端已排好:本项目在前、项目外在后,匹配度优先)
3236
- const MENTION_GROUP = { own: '本项目', ext: '项目外' };
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
+
3237
3300
  function mentionRowHTML(f, i) {
3238
3301
  const on = i === _mention.idx ? 'on' : '';
3239
3302
  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 +3305,29 @@
3242
3305
  const cut = f.label ? -1 : path.lastIndexOf('/');
3243
3306
  const dir = cut >= 0 ? path.slice(0, cut + 1) : '';
3244
3307
  const name = cut >= 0 ? path.slice(cut + 1) : path;
3245
- const ico = f.dir ? '📁' : (isEditableFile(name) ? '📝' : '📄');
3308
+ const ico = f.dir ? (f.drive ? '💽' : '📁') : (isEditableFile(name) ? '📝' : '📄');
3246
3309
  const hint = f.nav ? '进入 ›' : f.dir ? (f.abs ? '项目外目录' : '文件夹') : (f.abs ? '项目外' : '引用');
3247
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>`;
3248
3311
  }
3249
3312
 
3250
3313
  function mentionRender() {
3251
3314
  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>';
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
+ }
3260
3331
  _mentionBox.innerHTML = html;
3261
3332
  const r = cmdInput.getBoundingClientRect();
3262
3333
  _mentionBox.style.left = Math.max(8, r.left) + 'px';
@@ -3269,77 +3340,94 @@
3269
3340
 
3270
3341
  function mentionSearch(token) {
3271
3342
  clearTimeout(_mentionTimer);
3343
+ const seq = _mention.seq;
3272
3344
  _mentionTimer = setTimeout(async () => {
3273
3345
  try {
3274
3346
  const r = await fetch(`/api/files/${SESSION_ID}/search?q=${encodeURIComponent(token)}`);
3275
3347
  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' }));
3348
+ if (seq !== _mention.seq || !_mention.open || !d.ok) return;
3349
+ _mention.items = d.files || [];
3278
3350
  _mention.idx = 0;
3279
3351
  mentionRender();
3280
3352
  } catch { mentionClose(); }
3281
3353
  }, 140);
3282
3354
  }
3283
3355
 
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(); }
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);
3308
3381
  }
3309
3382
 
3310
3383
  function mentionUpdate() {
3311
3384
  const t = mentionToken();
3312
3385
  if (!t) return mentionClose();
3313
3386
  const token = cmdInput.value.slice(t.start + 1, cmdInput.selectionStart ?? cmdInput.value.length);
3314
- const wasOpen = _mention.open && _mention.start === t.start;
3387
+ const same = _mention.open && _mention.start === t.start && _mention.token === token;
3388
+ if (_mention.start !== t.start) _mention.lock = '';
3315
3389
  _mention.open = true;
3316
3390
  _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;
3391
+ if (same) return; // keyup 等事件会反复进来,token 没变不重复请求
3392
+ _mention.token = token;
3323
3393
  _mention.seq++;
3394
+ const pp = token ? mentionPathParts(token) : { dir: '', q: '' };
3395
+ if (pp) return mentionList(pp.dir, pp.q);
3396
+ _mention.browse = null;
3324
3397
  mentionSearch(token);
3325
3398
  }
3326
3399
 
3327
- // 回车/点击:文件夹(浏览模式)进入、上一级返回、其余引用;Tab = 文件夹也直接引用
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 = 文件夹也直接引用
3328
3413
  function mentionActivate(f, pickDir) {
3329
3414
  if (!f) return;
3415
+ const b = _mention.browse;
3330
3416
  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;
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(/\/+$/, '') + '/');
3335
3423
  }
3336
- if (f.nav && !pickDir) return mentionBrowse(f.root, f.rel);
3424
+ if (f.nav && !pickDir) return mentionGoto(f.typed);
3337
3425
  mentionPick(f);
3338
3426
  }
3339
3427
 
3340
3428
  function mentionPick(f) {
3341
3429
  // 目录带尾斜杠插入(「迁移到 docs/」一眼看出是文件夹),文件带空格结尾
3342
- const path = f.name + (f.dir ? '/' : '');
3430
+ const path = f.name.replace(/\/+$/, '') + (f.dir ? '/' : '');
3343
3431
  const pos = cmdInput.selectionStart ?? cmdInput.value.length;
3344
3432
  const before = cmdInput.value.slice(0, _mention.start);
3345
3433
  const after = cmdInput.value.slice(pos);
@@ -3458,10 +3546,10 @@
3458
3546
  box.innerHTML = `
3459
3547
  <div style="font-size:1rem;font-weight:700;margin-bottom:0.5rem">💬 会话怎么用</div>
3460
3548
  <b>发消息</b>:像聊天一样把需求说清楚即可;触屏回车=换行,用发送键发送。<br>
3461
- <b>让它读文件</b>:打 <b>@</b> 弹文件搜索(含设置里配的项目外目录),选中插路径;或点「文件」面板的 <b>@</b> 引用。<br>
3549
+ <b>让它读文件</b>:打 <b>@</b> 从本项目逐层浏览、打字搜索;项目外打 <b>@D:</b>、<b>@/</b>、<b>@~/</b> 按路径逐层浏览;选中插路径;或点「文件」面板的 <b>@</b> 引用。<br>
3462
3550
  <b>工具/思考</b>:AI 干活的过程默认折叠,点「🔧 N 个工具调用」展开看命令与输出。<br>
3463
3551
  <b>AI 忙时</b>:输入框变「存草稿」,先记着干完取回;勾自动接力会自动发出。<br>
3464
- <b>定位旧消息</b>:电脑上鼠标移到聊天区,右下角 ☰ 可列出你发过的消息点击跳转。<br>
3552
+ <b>定位旧消息</b>:电脑上鼠标移到聊天区,右下角 ☰ 可列出你发过的消息点击跳转;看完点底部「↓ 回到底部」一键回来。<br>
3465
3553
  <b>常用按钮</b>:模型/计划/压缩/恢复/清除/密钥/护栏/提交——普通用户可在设置→快捷栏精简。<br>
3466
3554
  <span style="color:var(--muted)">更多说明见首页顶栏 ❓ 帮助。</span>`;
3467
3555
  const closeBtn = document.createElement('button');
@@ -4139,8 +4227,16 @@
4139
4227
  if (_userScrolledUp) { showNewMsgHint(); return; }
4140
4228
  requestAnimationFrame(() => { chatArea.scrollTop = chatArea.scrollHeight; });
4141
4229
  }
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'); }
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); }
4144
4240
  window.jumpToBottom = function() { _userScrolledUp = false; hideNewMsgHint(); requestAnimationFrame(() => { chatArea.scrollTop = chatArea.scrollHeight; }); };
4145
4241
  function trimMessages() {
4146
4242
  if (_userScrolledUp) return; // 用户在往上翻历史,禁用自动删