dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
划词助手:任意 Windows 应用里选中文字,弹出解释 / 总结 / 翻译 / 朗读。DeepSeek Harness 插件。
SelectAssist · 包名 dsh-select-assist · for DeepSeek Harness
划词即问:在电脑上任意应用里选中文字,用 DeepSeek Harness 自己的模型解释、总结或翻译,或者直接朗读出来,结果落在可拖动、可缩放的置顶浮窗里。
Select text in any Windows app — the Harness model explains, summarizes, or translates it, or reads it aloud, in a native, draggable, always-on-top overlay.
— 项目基于 fyisgod/dsh-selection-tools(MIT)扩展, 新增了总结能力、直连引擎、朗读与四段划词菜单,并修复了划词菜单偶发不弹、回答窗位置固定的问题。 原项目的版权与许可声明保留在 LICENSE 中。
🌐 English — an official-form DSH bundle plugin for Windows: select text in any app and explain, summarize, translate, or read it aloud with DeepSeek Harness. The menu pops the moment you select — selecting never touches your clipboard: the selected text and its surrounding paragraph are read with UI Automation, and the clipboard (one injected Ctrl+C) is only used as a fallback after you click a menu item. The answer renders in a native GDI+ always-on-top window — no browser, no extra process, no runtime dependency. Quickstart:
dsh plugin --profile web add "github:germyGao/dsh-select-assist"Restart
dsh webafterwards. The rest of this README is in Chinese.
解释 / 总结 / 翻译 / 朗读 四段)→ 点击后弹出置顶悬浮窗,流式显示回答。DSH_SELECTION_CONFIRM=0 可关掉。shell.overlay 槽位里;系统级能力不可用时(没装 koffi、被显式关闭)自动接管,DSH 页面内划词照常可用。页内这条路同样"一份选区只弹一次"(用选区的起点/终点位置 + 文本当签名,selectionchange 一变就作废)。DSH_HOME/dsh-select-assist.json)。朗读参数由伴生进程直接读同一个设置文件,改完下一句就按新语速念,不用重启。engine 切换):
direct(默认,快):插件直接用宿主的 llm 服务流式调一次模型,不建 DSH 会话。首字延迟约 1.2–1.3 秒、整段完成 1.5–4.5 秒,且是真流式(首字到达即渲染,实测一段 490 delta 均匀分布在 1.3–4.4 秒)。代价:没有系统提示、没有工具、划词记录不进侧栏,不能点进去追问。agent(慢但可追问):用 ctx.agents 跑一轮真实会话回合(同一套 agent loop、系统提示、工具、模型 route),每次划词都是一条全新会话,跑完不归档,会话出现在侧栏「未分组」里,可以点进去追问。代价是首字要等整轮跑完(4.3–5.5 秒)。系统级浮层(原生窗口,GDI+ 自绘):划词菜单 / 回答窗口
| 划词菜单(锚点第四象限) | 词/短语翻译(词典式) | 回答窗口(结合上下文) | 鼠标拖动改大小后 |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
设置页(DSH 设置 → 划词工具):

上下文的作用(同一段文本 the P,左:带了选区所在的段落,右:只有选区本身):
| 结合上下文 | 只有选区 |
|---|---|
![]() | ![]() |
页内路径(DSH 窗口里划词,用官方 Markdown 原语渲染):
| 对话内划词 | 结果 |
|---|---|
![]() | ![]() |
┌─ DSH 进程 ───────────────────────────────┐ ┌─ 伴生进程(Node + koffi + GDI+) ────────┐
│ dsh-select-assist(Cordis 插件) │ │ 全局鼠标轮询 → 拖选 / 双击手势 │
│ · /api/dsh-select-assist/run (SSE) │◀─────│ UIA 读选区+段落(不碰剪贴板) │
│ · /api/dsh-select-assist/stop │ HTTP │ 原生分层窗口:菜单 / 回答窗口 │
│ │◀─────│ 点菜单项仍读不到才注入 Ctrl+C 兜底 │
│ · /api/dsh-select-assist/system/* │ │ GDI+ 自绘(圆角卡片、字体、markdown-lite)│
│ · direct: ctx.llm 流式 / agent: ctx.agents │ └─────────────────────────────────────────┘
└──────────────────────────────────────────┘
WM_NCHITTEST),圆角与阴影是分层窗口的逐像素透明,比浏览器窗口更贴合 DSH 的观感。| 能力 | 说明 |
|---|---|
| 系统级手势检测(伴生进程) | 轮询左键状态与光标位置识别"拖选(位移 ≥5px)"与"双击选词(420ms/6px 内两次)";落在浮层自己身上的点击忽略。 |
| 取词(伴生进程) | 主路径是 UI Automation:手势落下就一次读完"选中的文字 + 它所在的段落",全程不注入按键、不碰剪贴板。只有 UIA 读不到(应用不暴露文本、超时)才退到剪贴板兜底——那次注入 Ctrl+C 会用剪贴板序号变化判定是否真的复制成功,并核对这次写入不是别的进程写的,随后把用户原来的剪贴板内容还原;用户正按着 Ctrl+C、或用户在取词期间自己复制过,就一个字节都不碰剪贴板;控制台/终端窗口默认跳过(Ctrl+C 在那里是中断)。取词的取舍每次都会记进 /status 的 lastCapture 与 lastResolve。 |
| 原生浮层(伴生进程) | 自己创建 WS_POPUP + `WS_EX_LAYERED |
| 交互 | 标题栏拖动、四边四角缩放(八个方向都能用鼠标拉,指针会变成对应的双箭头)、菜单两项 hover/点击、回答窗口的复制/关闭/停止、滚轮滚动、全局 Esc 收起菜单。 |
| 选区 + 上下文(伴生进程) | 手势落下后用 UI Automation 一次读完选区与选区所在的段落(TextUnit_Paragraph);候选元素按"点上的元素 → 逐级祖先 → 焦点元素 → 逐级祖先"找(浏览器里点上的常常只是外壳),第一次读不到会等 160ms 再读(Chromium 这类应用是被 UIA 问到才打开无障碍树,冷启动第一次必失败——实测 DSH 桌面端自己的 WebView2 窗口也一样);读不到/超时一律按"没有选区"处理,交给点菜单项之后的解析链继续。 |
| 朗读(伴生进程) | 进程内 COM 调 ISpVoice(Speak 异步 + WaitUntilDone(0) 轮询"还在读吗"),读的是选中的原文;面板关闭、开始新一轮、再点一次都会停。语速与音量取自设置页(伴生进程每次开口前重读 dsh-select-assist.json,改完当句就生效)。 |
POST /api/dsh-select-assist/run | 同源 JSON 进、SSE 出(session/delta/done/error)。engine=direct(默认)走 ctx.llm 直连流式,engine=agent 走真实 agent 回合。 |
POST /api/dsh-select-assist/stop | 停止当前一轮:直连请求走 AbortController,agent 请求走官方 agent.cancel({ kind: 'user' })。 |
GET /api/dsh-select-assist/system/status / POST .../system/restart | 伴生进程状态 / 重启(诊断用);重启会等老进程真的退出再拉起新的,不留幽灵进程。 |
| 页内菜单 + 悬浮窗(客户端) | 注册进 shell.overlay;系统级接管时自动让位。 |
不注册任何模型可见的工具,也不改官方源码。
本仓库根目录就是一个 bundle 插件包(package.json#dsh.bundle → cordis.patch.yml),且构建产物已入库(lib/)——git 源安装不需要任何构建步骤。
# GitHub 一行装(推荐)
dsh plugin --profile web add "github:germyGao/dsh-select-assist"
# 本地检出目录
cd <本仓库路径>
dsh plugin --profile web add .
# npm(发布后)
dsh plugin --profile web add dsh-select-assist
装完重启 dsh web(或重启承载它的桌面端)生效——bundle 层栈在启动时合成。卸载 / 更新:
dsh plugin --profile web remove dsh-select-assist
dsh plugin --profile web update
自检:
curl http://127.0.0.1:3080/api/dsh-select-assist/ping
curl http://127.0.0.1:3080/api/dsh-select-assist/system/status # companion.state 应为 running
只改伴生进程/浮层时,不必重启 DSH:
curl -X POST http://127.0.0.1:3080/api/dsh-select-assist/system/restart
DSH_SELECTION_MENU_DECIDE_MS 说明见下),点 解释、总结、翻译 或 朗读(Esc 可收起)。翻译方向自动判定:源文本含中日韩字符 → 译成英文;否则译成中文。
| 变量 | 默认 | 说明 |
|---|---|---|
DSH_SELECTION_DISABLE_SYSTEM | 未设置 | 设为 1 关闭系统级划词(保留页内划词,伴生进程不再启动)。 |
DSH_SELECTION_HOOK | 1 | 伴生进程侧开关:0 = 只起浮层不做全局手势检测。 |
DSH_SELECTION_POLL_MS | 40 | 鼠标轮询间隔(毫秒)。 |
DSH_SELECTION_MAX_TEXT | 12000 | 单次划词字符上限。 |
DSH_SELECTION_MENU_DECIDE_MS | 280 | 手势落下后给 UIA 的决策窗口:这么长时间内读到"有没有选中文字"就按它办(有选区 → 弹;落在桌面/任务栏这类系统外壳上 → 不弹;普通元素报出空选区 → 不表态,交给剪贴板核实);超时就**再宽限三倍时长(上限 1s)**等探针的结论(探针还在跑,只是慢),宽限也用完才按"未知"照弹(0 = 手势一到就弹,不做任何过滤)。 |
DSH_SELECTION_TEXT_SOURCE | auto | 选区文字从哪来:auto = UIA 优先、读不到才用剪贴板;uia = 只用 UIA(读不到就报错,绝不碰剪贴板);clipboard = 直接用剪贴板(某个应用 UIA 读得不准时的逃生舱)。 |
DSH_SELECTION_MENU_PROBE_RETRIES | 3 | 菜单弹出时 UIA 预读的重试次数(Chromium/WebView2 冷启动第一次必失败,重试同时替后面的解析把无障碍树焐热)。 |
DSH_SELECTION_PRESS_PROBE | 1 | 鼠标按下时要不要拍一张"手势之前的选区"快照:只有它能把"拖动一个已选中的元素"(WPS/Office 的 PPT 形状、Excel 单元格)和"拖选文字"分开。0 = 不拍(每次按下少一次 UIA 读取,代价是拖动已选中的元素又会弹菜单)。 |
DSH_SELECTION_PRESS_WAIT_MS | 120 | 等这张按下快照的上限(毫秒):正常早就回来了,只有"按下到松开特别快"的拖拽才会真等;超时按"没拍到"处理。 |
DSH_SELECTION_CONFIRM | 1 | UIA 说不出话、或只报出一个空选区时(WPS/Office 的 Qt 版这类不暴露文档文本的应用),要不要用剪贴板核实"这次手势到底有没有选中文字":注入一次 Ctrl+C,真选中文字就会复制出文字,拖动形状/窗口则什么都复制不出来。核实还挡掉两种"复制出来的是文字、但其实没在选文字"的情形:表格网格里的选区(XLMAIN + EXCEL7,注入 Ctrl+C 之前就挡掉,不让这一下搅乱复制模式)和剪贴板里挂着原生表格格式(WPS Spreadsheets … Format / XML Spreadsheet / Biff*)的复制。0 = 不核实(退回老行为:拖选照弹、双击不弹)。 |
DSH_SELECTION_CLICK_PROBE_RETRIES | 1 | 点菜单项时补读 UIA 的重试次数;再多只是拖延剪贴板兜底。 |
DSH_SELECTION_CLIPBOARD_OWNER | 1 | 取词时是否核对"这次剪贴板写入不是别的进程写的";0 = 退回老行为(只看序号变化)——核对误伤了某个应用、取不到词时才关。 |
DSH_SELECTION_MENU_TIMEOUT_MS | 3600 | 划词菜单的显示时限(毫秒)。 |
DSH_SELECTION_CONTEXT | 1 | UI Automation 开关;0 = 关掉"读选区 + 读段落"(菜单不再过滤手势,选区只能走剪贴板兜底,等于退回老行为)。 |
DSH_SELECTION_CONTEXT_TIMEOUT_MS | 1800 | 单次 UIA 读取的时间预算;超时直接丢掉 worker。 |
DSH_SELECTION_LOCALE | 由插件传入 | 回答语言提示(zh/en)。 |
DSH_SELECTION_DSH_ORIGIN | http://127.0.0.1:3080 | 伴生进程调用 DSH 路由的地址(插件自动传对)。 |
/status 里 lastCapture.reason = timeout,剪贴板序号变了却读不出文字),个别外部应用也偶发。它还会短暂接管用户的剪贴板。现在手势落下就弹菜单,弹出条件里再也没有「能不能取到词」这一项;代价是要自己回答「这次拖拽/双击到底是不是在选文字」,用 UIA 回答(companion/policy.mjs 的 menuDecision):
stale-selection):那是上一次划词留下的旧选区,菜单已经为它弹过一次、也已经收起;shell):拖桌面、双击图标都不该弹;unknown),交给剪贴板核实(原因见下一条):WPS 的探针常常只命中功能区那样的无关文本控件,那里本来就没有选区,就此判成"没在选文字"会把真划词一起误杀——拖窗口、拖滑块最终仍会因为复制不到文字而不弹;unchanged-selection):这次手势压根没改动选区。办公套件里最典型——WPS/Office 的 PPT 里选中一个形状/文本框再拖动它、Excel 里拖一个已选中的单元格/图表,UIA 从按下到松开一路都报着"这个对象被选中"(按下点和松开点当然都落在元素里),只看松开那一刻就会在用户移动元素时冒出菜单。判据是按下那一刻拍的快照(pressProbe,DSH_SELECTION_PRESS_PROBE 可关);open: null),同样交给剪贴板核实(见下一条)。
这几条都有单测钉着(test/policy.test.mjs),决策理由每次写进 /status 的 lastMenuDecision。reason=unknown)——用户看到的就是"选中元素并拖动也弹菜单";双击则落在同一档的另一面(不弹),于是"双击选词也呼不出来"。
另一条进这一档的路是"普通元素报出空选区"(上面的 unknown):WPS 的探针可能只命中了功能区,那里本来就报不出选区,所以"空选区"同样不能当作"没在选文字"。
现在这一档改成 open: null(不表态),由 main.mjs 的 confirmByClipboard 用剪贴板核实一次:注入一次 Ctrl+C——
reason=clipboard),顺手把这文字记进这次选区,点菜单项时不用再复制一遍;XLMAIN、焦点控件是 EXCEL7)→ 连 Ctrl+C 都不注入就判不弹(lastConfirm.reason=not-text-editing):那一下会真的触发一次复制、还会搅乱 WPS/Excel 的复制模式,而网格选区本来就不是"文字编辑";WPS Spreadsheets … Format / XML Spreadsheet / Biff*)→ 不弹(lastConfirm.reason=non-text-selection):单元格区域和嵌入对象也会在剪贴板里挂一份纯文本,光看"有没有复制出文字"分不开,所以改用剪贴板自己的原生对象格式当证据(HTML Format / Rich Text Format 单独出现仍算正常富文本);reason=unconfirmed)——这就是"拖动元素不再弹菜单"的判据;DSH_SELECTION_CONFIRM=0)→ 退回老行为(拖选照弹、双击不弹,见 policy.mjs 的 unknownFallback)。
注意上面前三种"不弹"在决策里都记成 reason=unconfirmed,"到底栽在哪一条"看 /status 的 lastConfirm.reason。
代价与安全边界全交给 selection.mjs:只在前台窗口确实是 UIA 说不出话、或只报空选区的应用里才注入;用户正按 Ctrl+C 就一次都不注入;只认前台窗口自己写的那次(唯一例外见下);读完把用户原来的剪贴板内容放回去。额外一条:这次注入是我们自己发的,所以"一个字都没复制出来"时也要把用户原来的文字放回去(restoreWhenEmpty)——否则拖动一个形状就会把用户的剪贴板悄悄换成那个形状。CLIPBRDWNDCLASS 窗口,两个进程都不在前台窗口那棵窗口树里,严格按"同一个窗口树"判会把它当"别人写的"而丢掉。所以 ownerRelation 放行这一种:前台和写入方都是同一套 WPS 安装目录下的文档进程(wps.exe / et.exe / wpp.exe,按可执行文件所在目录比)。认不出的进程、别的安装目录、以及类名不是 CLIPBRDWNDCLASS 的窗口,一律照旧算 foreign(test/selection.test.mjs 钉着这两面)。unchanged-selection 与剪贴板核实是两条互补的路:前者管"UIA 读得到选区、但用户其实在移动一个已选中的元素"(Office 的 UIA 版、LibreOffice 这类),后者管"UIA 完全读不到、或者只报得出一个说不清来历的空选区,只能靠复制来问"(WPS 的 Qt 版)。两条都在,才既不误弹也不吞划词。gesture.mjs 的 onPress → main.mjs 的 startPressProbe):拖完再读到的已经是被这次手势改过的选区了,问不出“本来是什么”。两个配套细节:
onPress.second):双击要的是这一串点击开始之前的选区(第一下按下时,文本应用会把旧选区收成插入符),第二下时选中的词已经出来了,拿它当“之前”会把双击选词整个判成“没改动”。main.mjs 的 deciding):读取器同一时刻只保留最新的一次请求,决策等结论时再拍一张会把这次决策的探针顶掉(结果变成“读不到”),菜单反而会在随手一拖时冒出来。那一档按“没拍到”处理,判定完全退回改动前的行为。DSH_SELECTION_PRESS_PROBE=0)时的取舍是照弹:宁可偶尔多弹一次,也不能因为多拍一张快照而吞掉正常划词。/status 的 pressProbe 与 recentDecisions[].pressProbe 会说清这张快照拍到了什么。GetBoundingRectangles 拿选区的屏幕矩形(RangeFromPoint 不行——它会把点就近吸附到文本边界上,鼠标按在桌面空白处也照样回一个“选区最后一个字符”的位置,这个判断就永远为真、什么都量不出来)。判定本身抽成纯函数 pointNearRects:余量取 max(8, 矩形高度)——拖选的锚点会吸附到字符边界,字符有多宽误差就能有多大,150% 缩放下一个字 15–30px,写死 8px 会把“从右往左拖”“按在行尾空白处起手”这类正常划词误判成旧选区(用户报的“经常弹不出来”就是这条)。按下点与松开点都要看:它们是选区的两端,用户从文字里起手一路拖到段落外面(或反过来)时,总有一端落在选区里。以及一个坑:GetBoundingRectangles 交出来的矩形是 {left, top, right, bottom},没有 height 字段——早期实现读 rect.height 得到 undefined,Math.max(8, undefined) 是 NaN,比较全 false,于是“手势碰到选区了吗”恒为 false、正常划词全被当成旧选区吃掉(test/policy.test.mjs 现在钉着这条)。 另外两个实现细节:矩形是 SAFEARRAY(double)(每 4 个一组 x/y/w/h),SafeArrayAccessData 之后按 double 数组解码;槽位号必须对着 IDL 数——IUIAutomationTextPattern 里 RangeFromPoint 排在 RangeFromChild 前面(3/4),IUIAutomationTextRange 里 GetBoundingRectangles 是 10,数错一位把调用打到隔壁方法上会直接 ACCESS_VIOLATION 崩掉伴生进程(实测踩过)。DSH_SELECTION_MENU_DECIDE_MS,超时再等它三倍时长(上限 1s)——结论一回来就出菜单,只是比"照弹"更晚一点,而不会把旧选区或没选字的手势也弹出来。/status 的 lastResolve.source 会说清这次用的是哪一条(uia-prefetch / uia-fresh / preset / clipboard-confirm / clipboard)。会碰用户剪贴板的地方一共两处:这里是"点了菜单项之后还读不到文字",另一处是"UIA 说不出话时用剪贴板核实这次手势选没选中文字"(见上,DSH_SELECTION_CONFIRM=0 可关)。
keybd_event 的 bScan 传 0 时,Chromium/WebView2 一类窗口可能不认这次合成按键,剪贴板一动都不动。兜底取词现在用 MapVirtualKeyW 补上扫描码,那条路才真的能兜底。engine=direct 下 llm.stream 的第一个 text-delta 约在 1.2–1.3 秒到达,整段 1.5–4.5 秒完成;一段 490 delta 的输出在 1.3–4.4 秒间均匀分布(按 200ms 分桶每桶 24–44 条),确认是真流式而不是攒批。/stop 打到直连请求时会 abort() 那个 AbortController,SSE 立刻收到 error: Request was aborted。ctx.agents.create + agent.followup + agent.whenIdle,最终文本取自语料权威来源(会话日志里最后一条 assistant/message)。id 与 source.rpcId:缺失会让会话在 DSH 对话视图里报 received more than one start Match。cwd:没有工作目录的会话上跑 agent 会静默无输出。workspaceRegistry.archivedSessionIds 里的会话在所有视图里都不显示(sessionVisible = origin !== 'subagent' && !archived && (!blank || 当前会话));其余会话按"是否被某个 workspace 记录的 sessionIds 记账"分到项目下,没被记账的才是「未分组」。所以"放进未分组" = 不归档 + 不被任何项目 attach:本插件只走 ctx.agents.create(不经网关的 session.create(workspaceId),那条路径会 attachSession),并在会话建好后 best-effort detachSession 兜底。宿主公开 API 只有 archiveSession,没有 unarchive。argv[1] 作为锚点传给伴生进程;插件自身不 import 任何 @deepseek-ai/*,否则以 link:/本地路径安装时会因跨盘符解析失败让整个 dsh web 起不来。koffi.proto 先声明原型(裸字符串会报 Unexpected character '(' in type specifier),且被回调引用的结构体也要先定义;SetWindowPos 的 hWndInsertAfter 声明成 intptr_t(HWND_TOPMOST=-1 传给 void * 会被拒);出参结构体要写 _Out_;CreateCompatibleDC/BitBlt 在 gdi32 不在 user32;koffi 的类型名是全局的,重复定义同名字会报 Duplicate type name(因此所有绑定模块都只建一次)。GetSystemMetrics 谎报 96dpi、虚拟屏幕 3627×1080,真实是 144dpi / 4480×1600),主副屏表现会不一致。现在启动第一件事就是 SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2),尺寸按目标显示器的 GetDpiForMonitor 换算。/system/restart 走 companion.restart()——先送走老进程、等它真的退出再拉起新的。三处细节缺一个就会留下"幽灵伴生进程":两个进程各自钩一次全局鼠标、各自开一个浮层,用户看到两个菜单、两个回答窗口,而插件自己只认得新的那一个。
kill() 偶尔漏杀:宽限期(默认 800ms)内还活着就直接 taskkill /pid <pid> /T /F 补刀。child !== spawned 直接 return):老进程迟到的 exit 会把新进程的引用与状态一起抹掉(表现是重启后状态莫名变 stopped)。stopping 必须在 restart 里复位:它只在 stop() 里置位,不复位看门狗就永久失效(重启之后伴生进程再崩也不会自动起来)。test/companion-manager.test.mjs(注入 spawnChild 起真进程 + 假僵尸,退避与宽限可注入;反证过:把任一条改回去测试立刻红)。^(#{1,4})[ ]+(.*)$),写成 ^#{1,4}... 时 {1,4} 是量词、正文会取到 undefined。contentHeight/stopBox 必须落到调用方持有的同一个对象上,否则滚动条与"停止"命中框永远是空的。companion/selection.mjs):
createLatestQueue(companion/gesture.mjs):同一时刻只跑一个,排队只留最新一次,跑完发现已经有新手势进来就直接丢掉结果(不弹菜单)。state.selection——用户点下去的那一刻可能刚好又有一次划词完成,于是"回答窗口里的输入内容"跟"用户看着菜单点的那段文字"对不上。showMenu 把选区存进 menu.selection,点菜单项(含 /click 验收端点)一律用它。test/selection.test.mjs 用假 Win32 造出"用户自己按 Ctrl+C""别的进程写剪贴板""我们刚读完就被人写""注入没人响应"这些时序,test/gesture.test.mjs 钉队列;反证过:把任一条改回老行为,测试立刻红。showMenu 里任何一次 ui.xxx = … 都会在窗口下一次重绘时生效——早期版本在这里顺手重置了 ui.source/answer/status,于是"新划词后点一下窗口"就变成"显示新选中的文字 + 没有文本输出 + 就绪"。现在弹菜单只写 state.selection,窗口内容只在 startRun 里换(踩过,用户截图就是这个现象)。GetCurrentPatternAs(TextPattern) 拿不到东西(实测:第 0 次 no text pattern,第 1 次开始 selection/段落都正常)。所以 worker 里读失败要等一下重试(2 次 × 160ms),并且别只看点上的元素——点上的往往是外壳,得沿控制视图往上一层层找 TextPattern。ISpVoice 继承 ISpEventSource ← ISpNotifySource ← IUnknown,所以 Speak=20、SetRate=28、SetVolume=30、WaitUntilDone=32(IDL 里每个接口都从 3 开始编号,直接照抄会打错函数)。dsh-select-assist.json 里,跑一轮前现读;模型座位优先级 = 设置里的座位 > 请求带的 provider/model > 部署默认。验证方式很干脆:把座位改成不存在的模型,那一轮必须失败(否则说明设置没被用)。paintPanel/panelLayout/panelButtons/panelHit/panelEdgeZone/panelHitZone 全部要拿实际宽高(物理 ÷ 缩放 = DIP),不能钉死 MODES.panel 的 380×460(踩过:改完大小后正文/页脚还按老尺寸排版,窗口下方留一大片空白,缩放手柄也跑偏)。panelEdgeZone 算出来的纯几何(边 6 DIP / 角 16 DIP),交给 WM_NCHITTEST 的 HT 边框代码,剩下的活由 Windows 自己干(窗口是 WS_POPUP、没有 WS_THICKFRAME,照样能拉)。三处必须一起对,否则"某条边拉不动":
HTCAPTION 拖动(早先版本整条上边都判成拖动区,所以上边"拉不动";把 HTTOP 铺满整个标题栏又会反过来拖不动窗口)。WM_SETCURSOR + RESIZE_CURSORS):类光标是 null,无边框弹窗上别指望 DefWindowProc 给出拉伸指针;"这条边能不能拉"全靠指针告诉用户。test/panel-zone.test.mjs 覆盖八个方向、按钮优先、以及"按实际尺寸算"这几条。WM_SIZE 在创建期也会来:CreateWindowExW 用的是 10×10 占位尺寸,创建期/隐藏期的 WM_SIZE 不能当作用户改过大小——否则会把 10×10 记成"用户尺寸",回答窗口直接以 10×10 打开(踩过)。现在只在可见状态且超过下限时才记录,并顺手同步 panel.rect(命中判断与 /capture 都依赖它)。WM_GETMINMAXINFO 写 ptMinTrackSize(260×200 DIP × 缩放),否则能被拖成一条线,布局就没法看了。koffi.decode(bstr, 'const char16_t *') 会直接把进程打成 ACCESS_VIOLATION(UIA 的 BSTR 出参踩过)。稳定做法是先读 BSTR 头部 4 字节长度、再按 char16_t 数组解。裸 COM 调用靠 koffi.call(函数指针, koffi.proto(...), ...),vtable 槽位序号必须对着 Windows SDK 的 UIAutomationClient.idl 数(ElementFromPoint=7、GetCurrentPatternAs=14、GetSelection=5、ExpandToEnclosingUnit=6、GetText=12)。DSH_SELECTION_CONTEXT_TIMEOUT_MS),超时直接 terminate 重建——主进程的取词/菜单/绘制不受影响。pointInRect 吃的是 {left,right,top,bottom},而窗口用的是 {x,y,width,height}。混用后"点在菜单内吗"恒为 false,于是按下鼠标左键的那一瞬间外层就按"点了别处"把菜单收起(outside-click),菜单项永远等不到 WM_LBUTTONUP——表现就是"点菜单项没反应、菜单还消失了"。现在 main.mjs 自己带一个 inRect(point, rect)(x/y/width/height),命中路径不再跨模块借形状。GdipSetClipRect 的最后一位是 GpCombineMode,Replace=0、Exclude=4。把 Replace 写成 4 时裁剪区变成"这块矩形之外",正文正好整块被裁掉——画面只剩标题、原文框和状态条,正文一片空白,而"正文没有溢到页脚"看起来还像是测试通过(假绿灯)。现在固定传 0,并在返回状态非 0 时直接抛错。paragraph 而不是单行 text:tokenize 才会解析 **粗体** 与反引号行内码,直接 text 会把 ** 原样画出来。分隔线 --- 现在按 hr 块画成一条细分隔线,不再把字符当正文。DSH_SELECTION_TEXT_SOURCE=uia。另一处会碰剪贴板的是"UIA 说不出话、或只报出空选区时用剪贴板核实这次手势"(DSH_SELECTION_CONFIRM=0 关掉),它同样受这几条规则约束,并且会把用户原来的文字放回去。stale-selection,见上);真正读不到任何东西时(应用完全不暴露 UIA 文本)走剪贴板核实(见上),核不出结论才退回老行为(/status 的 lastMenuDecision.reason = unknown 会说清是这一类)。XLMAIN + 焦点控件 EXCEL7),注入后再看剪贴板的原生格式(WPS Spreadsheets … Format / XML Spreadsheet / Biff* 一律判成非文字)。已知还剩下的边界:原生格式不在名单里的对象复制——例如 WPS PPT 里拖一个形状/文本框,剪贴板挂的是 Kingsoft Shapes Tag + Rich Text Format,而同一组格式在真划词时也会出现(test/selection.test.mjs 里有这条正例),所以这一类仍会被判成"选中了文字";网格那一道判据也依赖焦点控件的类名(EXCEL7),换一版 Office/WPS 若改了网格控件类名就会失效。要彻底区分"选中"和"移动",需要应用自己暴露选区变化(UIA TextPattern),Qt 版 WPS 不提供。pressProbe 为空):那一档退回改动前的行为(照弹),所以拖动已选中元素仍可能偶发弹菜单——按下到松开特别快(DSH_SELECTION_PRESS_WAIT_MS 内拍不回来)、应用在按下那一刻不响应 UIA、或者显式关掉了按下探针时。反过来,如果某个应用在按下时不收起旧选区、而用户又恰好又拖选了一模一样的那段文字,这次划词会被判成 unchanged-selection 而不弹;同类的极端情形是"极短又极快的一次拖选"——按下快照拍到的已经是最终那段选区了(/status 里能看到它,DSH_SELECTION_PRESS_PROBE=0 可临时关掉这条判据)。