From c77aea3f2d33714d763547754f6194e341e2fc7b Mon Sep 17 00:00:00 2001 From: palxex Date: Thu, 1 Oct 2026 15:41:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(mobile):=20=E6=96=87=E4=BB=B6=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E8=B1=81=E5=85=8D=E8=8F=9C=E5=8D=95/=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=20UI=EF=BC=8C=E6=A8=A1=E5=9E=8B=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E4=B8=8D=E5=86=8D=E8=A2=AB=E8=AF=AF=E6=8B=A6=E5=B9=B6?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E5=A4=8D=E5=88=B6=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型选择器(dsh-client-ui-model-selection)的触发按钮带 aria-haspopup="menu", 各行是 role="menuitemradio"/"menuitem",行文案就是模型 ID。形如 z-ai/glm-5.3、cl/cline-free/deepseek-v4.1-flash 的 ID 会命中 looksLikeFilePath 的 `xxx/xxx.yyy` 模式,在窄屏(手机/PWA)下被误判成 文件链接,于是: 1. 捕获阶段 click 拦截吞掉模型行的点选,误弹「手机上无法直接打开 电脑上的文件」——路径形态的模型(含 / 和 . 后缀)全部无法选中; 2. MutationObserver 给每个误判的模型行旁注入一个「复制」按钮。 文本上模型 ID 与相对路径(lib/proxy.mjs)无法区分,改为按 ARIA 上下文 豁免:凡位于 [role="menu"]、[role="menuitem"]、[role="menuitemradio"]、 [role="menuitemcheckbox"]、[role="listbox"]、[role="option"] 内,或自身 带 [aria-haspopup="menu"],一律不算文件链接(不拦截、不注入复制按钮)。 聊天消息里的文件链接不在任何菜单内,守卫行为不变。 已在真实环境验证(窄屏视口、守卫激活):模型列表 101 行中 25 个路径形态 行不再被拦、复制按钮注入数为 0;对照文件链接 lib/proxy.mjs 仍被正常 拦截并弹提示、注入复制按钮。 - client/mobile/fileGuard.ts:新增 MENU_UI_SELECTOR/isMenuUi,点击拦截 与复制按钮注入两处先豁免菜单 UI - client/client.js:node client/build.mjs 重建(esbuild 0.25.9) - test/file-guard.test.js:新增菜单豁免断言(源码结构 + 打包产物) --- client/client.js | 14 ++++++++++++++ client/mobile/fileGuard.ts | 22 ++++++++++++++++++++++ test/file-guard.test.js | 16 ++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/client/client.js b/client/client.js index d614db0..9b1d033 100644 --- a/client/client.js +++ b/client/client.js @@ -419,6 +419,18 @@ function looksLikeFilePath(text) { if (/[\w.\-]+\/[\w.\-]+\.\w{1,12}/.test(t)) return true; return false; } +var MENU_UI_SELECTOR = [ + '[role="menu"]', + '[role="menuitem"]', + '[role="menuitemradio"]', + '[role="menuitemcheckbox"]', + '[role="listbox"]', + '[role="option"]', + '[aria-haspopup="menu"]' +].join(","); +function isMenuUi(el) { + return el.closest(MENU_UI_SELECTOR) !== null; +} async function copyText(text) { try { if (navigator.clipboard?.writeText) { @@ -486,6 +498,7 @@ function startFileGuard(readFile) { if (target === null) return; const el = target.closest("button, a"); if (el === null) return; + if (isMenuUi(el)) return; if (!looksLikeFilePath(el.textContent)) return; event.preventDefault(); event.stopImmediatePropagation(); @@ -496,6 +509,7 @@ function startFileGuard(readFile) { const links = document.querySelectorAll("button, a"); links.forEach((el) => { if (el.getAttribute("data-mobile-nav-copy") === "1") return; + if (isMenuUi(el)) return; const txt = (el.textContent ?? "").trim(); if (!looksLikeFilePath(txt)) return; el.setAttribute("data-mobile-nav-copy", "1"); diff --git a/client/mobile/fileGuard.ts b/client/mobile/fileGuard.ts index dc2adbd..f4a548f 100644 --- a/client/mobile/fileGuard.ts +++ b/client/mobile/fileGuard.ts @@ -33,6 +33,26 @@ function looksLikeFilePath(text: string | null): boolean { return false } +// 菜单 / 下拉 / 选项列表类 UI 一律不算文件链接:模型选择器触发按钮带 +// aria-haspopup="menu",各行是 role="menuitemradio"/"menuitem"(面板 +// role="menu"/"group"),而行文案就是模型 ID(如 z-ai/glm-5.3)——会被 +// looksLikeFilePath 误判成文件路径,导致模型点选被拦截、旁边被注入 +// 「复制」按钮。凡位于这类 UI 内的元素直接跳过。 +const MENU_UI_SELECTOR = [ + '[role="menu"]', + '[role="menuitem"]', + '[role="menuitemradio"]', + '[role="menuitemcheckbox"]', + '[role="listbox"]', + '[role="option"]', + '[aria-haspopup="menu"]', +].join(',') + +/** 元素是否位于菜单 / 选择器 UI 内(模型选择器、推理力度菜单等)。 */ +function isMenuUi(el: Element): boolean { + return el.closest(MENU_UI_SELECTOR) !== null +} + /** 写剪贴板:优先 navigator.clipboard,非安全上下文(局域网 http)回退 execCommand。 */ async function copyText(text: string): Promise { try { @@ -107,6 +127,7 @@ export function startFileGuard( if (target === null) return const el = target.closest('button, a') as HTMLElement | null if (el === null) return + if (isMenuUi(el)) return if (!looksLikeFilePath(el.textContent)) return event.preventDefault() event.stopImmediatePropagation() @@ -121,6 +142,7 @@ export function startFileGuard( const links = document.querySelectorAll('button, a') links.forEach((el) => { if (el.getAttribute('data-mobile-nav-copy') === '1') return + if (isMenuUi(el)) return const txt = (el.textContent ?? '').trim() if (!looksLikeFilePath(txt)) return el.setAttribute('data-mobile-nav-copy', '1') diff --git a/test/file-guard.test.js b/test/file-guard.test.js index f6247e0..869b6ed 100644 --- a/test/file-guard.test.js +++ b/test/file-guard.test.js @@ -17,6 +17,20 @@ test('fileGuard 只依赖稳定结构(button/a + 路径文案),不依赖 h assert.ok(!/\[class[*^$]?=/.test(src), 'fileGuard.ts 的检测不能出现 class 属性选择器(hash 类名每次构建都变)'); }); +test('fileGuard 豁免菜单/选择器 UI:模型行文案是模型 ID,不能被当文件链接', () => { + // 模型选择器(dsh-client-ui-model-selection)触发按钮带 aria-haspopup="menu", + // 各行是 role="menuitemradio"/"menuitem",文案就是模型 ID(如 z-ai/glm-5.3)—— + // 与文件路径(lib/proxy.mjs)在文本上无法区分,必须按 ARIA 上下文豁免, + // 否则模型点选被拦截(误弹「手机上无法直接打开电脑上的文件」)、行旁被注入复制按钮。 + assert.ok(src.includes('function isMenuUi'), '必须先判断元素是否位于菜单/选择器 UI 内'); + assert.ok(src.includes('[role="menuitemradio"]'), '豁免必须覆盖菜单单选项(模型行、推理力度行)'); + assert.ok(src.includes('[role="menuitem"]'), '豁免必须覆盖菜单项(根面板单元格)'); + assert.ok(src.includes('[aria-haspopup="menu"]'), '豁免必须覆盖菜单触发按钮'); + // 点击拦截与复制按钮注入都要先过 isMenuUi,再看文本像不像路径 + assert.ok(/if \(isMenuUi\(el\)\) return\n if \(!looksLikeFilePath/.test(src), '点击拦截必须先豁免菜单 UI'); + assert.ok(/if \(isMenuUi\(el\)\) return\n const txt/.test(src), '复制按钮注入必须先豁免菜单 UI'); +}); + test('mobile-apply 已接线 startFileGuard(窄屏生效、传入 readFile,且不再接 fileCopy)', () => { assert.ok(apply.includes("import { startFileGuard } from './fileGuard.ts'"), '必须 import 模块'); assert.ok(apply.includes('startFileGuard(readFile)'), '必须调用 startFileGuard 并传入 readFile 回调'); @@ -43,6 +57,8 @@ test('打包产物含守卫 + 复制按钮结构标记', () => { // 复制按钮:注入标记 + 经 RPC 读文件(data-mobile-nav-copy 标记已处理链接,避免重复注入) assert.ok(bundle.includes('copy-file'), '产物必须含复制按钮标记 data-mobile-nav="copy-file"'); assert.ok(bundle.includes('data-mobile-nav-copy'), '产物必须用标记避免重复注入复制按钮'); + // 菜单 UI 豁免:模型选择器等菜单里的按钮文案是模型 ID,不能被当文件链接 + assert.ok(bundle.includes('isMenuUi'), '产物必须含菜单 UI 豁免逻辑——先跑 node client/build.mjs'); }); test('CSS 隐藏「添加工作区」图标 + 复制按钮样式(窄屏)', () => {