dsh-dock 0.20.0 → 0.21.0

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.
@@ -260,10 +260,10 @@ function chipBalanceText(p) {
260
260
  const b = p && p.balance;
261
261
  if (!b) return "余额 …";
262
262
  if (b.status !== "ok") {
263
- if (b.status === "login-required") return "余额·需登录";
264
- if (b.status === "no-credential") return "余额·无密钥";
265
- if (b.status === "unsupported") return "余额·不支持";
266
- return "余额·查询失败";
263
+ if (b.status === "login-required") return "余额 需登录";
264
+ if (b.status === "no-credential") return "余额 无密钥";
265
+ if (b.status === "unsupported") return "余额 不支持";
266
+ return "余额 查询失败";
267
267
  }
268
268
  if (b.kind === "quota") {
269
269
  const v = Number(b.remaining);
@@ -289,11 +289,12 @@ function BalanceChip(props) {
289
289
  const def = selProvider || (data && data.default ? data.default.provider : null);
290
290
  const cur = def ? providers.find((p) => p.id === def) : null;
291
291
  const accent = cur ? accentOf(cur.id) : "#64748b";
292
- const text = snap.error ? "余额·失败" : (!data ? (snap.loading ? "余额 …" : "余额 —") : (cur ? chipBalanceText(cur) : "余额 —"));
292
+ const text = snap.error ? "余额 失败" : (!data ? (snap.loading ? "余额 …" : "余额 —") : (cur ? chipBalanceText(cur) : "余额 —"));
293
293
  const selModel = sel && (sel.model || (sel.selected && sel.selected.model)) || null;
294
294
  const title = cur
295
295
  ? (cur.displayName || cur.id) + (selModel ? " · 当前模型 " + selModel : "") + " · 点击在功能坞查看余额详情"
296
296
  : "点击打开功能坞 · 模型余额";
297
+ const mm = /^(余额 )([\s\S]*)$/.exec(text);
297
298
  return react.createElement("button", {
298
299
  type: "button",
299
300
  className: "dockchip" + (snap.error || (cur && cur.balance && cur.balance.status === "error") ? " err" : ""),
@@ -302,7 +303,8 @@ function BalanceChip(props) {
302
303
  onClick: () => openPanel("balance", def ? { provider: def } : null),
303
304
  },
304
305
  react.createElement("span", { className: "dockchip-dot", style: { background: accent } }),
305
- react.createElement("span", null, text));
306
+ mm ? react.createElement("span", { className: "dockchip-pre" }, mm[1]) : null,
307
+ react.createElement("span", null, mm ? mm[2] : text));
306
308
  }
307
309
 
308
310
  export const feature = {
@@ -0,0 +1,22 @@
1
+ // dsh-dock · 功能模块【手机适配】· 宿主半部(开关占位)
2
+ //
3
+ // 手机适配的实现全部在客户端半部(features/mobile/view.js):样式与行为在 Overlay
4
+ // 挂载时注入、卸载时移除,所以这个宿主半部**什么都不注入**,只保留一个 feature 描述符,
5
+ // 让功能开关能照常持久化(settings 的 features 表按 id 记开关,路由 /dsh-dock/features
6
+ // 用 FEATURES 列表校验 id 是否存在)。
7
+ //
8
+ // 为什么不再经 webServer.tapIndex 内联注入 index.html:
9
+ // · 旧版这样做必须重启 dsh web 才能彻底摘除注入的 <style>/<script>;用户要求
10
+ // 「关闭就要关闭得彻底、不能干扰原来的」。改成客户端运行时注入 + 卸载清理后,
11
+ // 关闭开关的瞬间样式、body 属性、遮罩/按钮、监听器、观察器、定时器全部消失,
12
+ // index.html 里一个标记都不残留,也不动 <meta viewport>。
13
+ export const feature = {
14
+ id: 'mobile',
15
+ name: '手机适配',
16
+ description: '手机/窄屏体验优化(客户端运行时注入,关闭后零残留):16px 输入防缩放、触控热区、代码块滚动、会话 chip 收起、设置弹层单列与旧版抽屉兜底',
17
+ defaultEnabled: false,
18
+ setup() {
19
+ // 无宿主副作用;返回空清理函数以匹配生命周期约定。
20
+ return () => {}
21
+ },
22
+ }
@@ -0,0 +1,345 @@
1
+ // dsh-dock · 功能模块【手机适配】· 客户端视图(唯一实现)
2
+ //
3
+ // 这一版刻意「做减法」:手机适配的全部逻辑都放在客户端这一半,宿主半部
4
+ // (features/mobile/host.js)只剩一个开关占位。原因有三:
5
+ //
6
+ // 1) 关闭要彻底。旧版把排版样式与行为脚本经 webServer.tapIndex 内联注入 index.html,
7
+ // 必须重启 dsh web 才能彻底摘除;现在改成「Overlay 挂载时才注入、卸载时立刻移除」,
8
+ // 关闭开关 = Overlay 卸载 = <style> 元素、body 属性、遮罩/按钮、监听器、观察器、
9
+ // 定时器全部一并消失,页面 <head> 不残留任何标记,也不动 <meta viewport>。
10
+ // 2) 不与 dsh 原生布局打架。dsh 自身(<768px)已是抽屉模式、设置弹层已是单列、
11
+ // 账号卡片排版正常。旧版强行 position:fixed + 100vw + flex-direction 覆盖,
12
+ // 反而把好好的原生排版改坏(账号名一字一行、查询用量/充值被裁)。
13
+ // 这一版只补原生没做的事,且用「行为检测」决定是否兜底旧版:
14
+ // · 设置弹层:仅当实测仍是「左导航 + 右内容」两栏时才加 data-dsh-mobile-stack
15
+ // 改为纵向堆叠(现代 dsh 已单列 → 检测为已堆叠 → 不加,原生排版不动);
16
+ // · 侧栏:仅当 <768 却仍是静态轨道(旧版 dsh 无抽屉模式)时才浮层化 + 注入
17
+ // 汉堡/遮罩(现代 dsh 有 data-sidebar-drawer → 不动,用原生抽屉)。
18
+ // 3) 单一来源。样式只此一份(MOBILE_CSS),不存在「宿主一份、客户端一份」对不齐。
19
+ //
20
+ // 只保留真正常见的手机痛点优化:
21
+ // · 输入类控件字号 16px —— iOS 聚焦 <16px 输入框会强制整页放大;
22
+ // · 触控基础:去点按高亮、touch-action:manipulation(消双击缩放判定延迟);
23
+ // · 原生侧栏开关点击热区扩到 44×44(图标位置视觉不变);
24
+ // · 代码块横向滚动、行内代码换行;
25
+ // · 会话输入行上 dsh-dock 的随身小控件(余额/用量 chip)在手机上整组收起——
26
+ // 窄屏下它们被压成「余额…」既读不出数值又白占输入行;
27
+ // · 设置弹层内容区补安全区内边距、滚动体补底部安全区。
28
+ //
29
+ // 命中:窗口宽 ≤767px,或主指针为粗触且无悬停(真机/平板)。
30
+ import react from "react";
31
+ import { useEffect, useState } from "react";
32
+
33
+ /** 与 dsh 原生窄屏断点对齐(SIDEBAR_DRAWER_BREAKPOINT=768):<768 才做抽屉/单列兜底。 */
34
+ const MOBILE_QUERY = "(max-width: 767px)";
35
+ /** 粗指针且无悬停:真机/平板(不含桌面鼠标);用于「不分宽度的触控优化」。 */
36
+ const COARSE_QUERY = "(pointer: coarse) and (hover: none)";
37
+
38
+ /** 当前视口是否命中手机形态(SSR/无 window 时恒 false)。 */
39
+ export function mobileActive() {
40
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
41
+ try {
42
+ return window.matchMedia(MOBILE_QUERY).matches || window.matchMedia(COARSE_QUERY).matches;
43
+ } catch { return false; }
44
+ }
45
+
46
+ // —— 运行时样式(挂载时注入、卸载时移除;全部选择器再以 body[data-dk-mobile="1"] 兜底,
47
+ // 即便某帧 body 属性先被摘掉也不会误伤原生排版) ——
48
+ const MOBILE_CSS = [
49
+ '/* 触控与输入:粗指针真机(不分宽度) */',
50
+ '@media (pointer: coarse) and (hover: none){',
51
+ ' body[data-dk-mobile="1"] :is(textarea,input:not([type]),input[type="text"],input[type="search"],input[type="url"],input[type="password"],input[type="number"],input[type="email"],[contenteditable="true"]){font-size:16px !important;}',
52
+ ' body[data-dk-mobile="1"] :is(button,[role="button"]){-webkit-tap-highlight-color:transparent;touch-action:manipulation;}',
53
+ '}',
54
+ '@media (max-width:767px){',
55
+ ' body[data-dk-mobile="1"] :is(textarea,input:not([type]),input[type="text"],input[type="search"],input[type="url"],input[type="password"],input[type="number"],input[type="email"],[contenteditable="true"]){font-size:16px !important;}',
56
+ ' body[data-dk-mobile="1"] :is(button,[role="button"]){-webkit-tap-highlight-color:transparent;touch-action:manipulation;}',
57
+ ' body[data-dk-mobile="1"] pre{max-width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;}',
58
+ ' body[data-dk-mobile="1"] code{overflow-wrap:anywhere;}',
59
+ ' body[data-dk-mobile="1"] pre code{overflow-wrap:normal;}',
60
+ ' body[data-dk-mobile="1"] .dockchip-row{display:none;}',
61
+ ' body[data-dk-mobile="1"] [data-sidebar-opener]{position:relative;}',
62
+ ' body[data-dk-mobile="1"] [data-sidebar-opener]::after{content:"";position:absolute;left:50%;top:50%;width:44px;height:44px;transform:translate(-50%,-50%);}',
63
+ ' body[data-dk-mobile="1"] [role="dialog"][class*="panel"] [class*="options"]{padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));}',
64
+ ' body[data-dk-mobile="1"] [class*="scrollBody"]{padding-bottom:env(safe-area-inset-bottom,0px) !important;}',
65
+ ' /* 功能坞面板(.dockm-*)挂在 .overlayLayer(z20) 内,而手机抽屉 .sidebarCol 是 z71——面板虽是全屏却被抽屉盖住。把 overlayLayer 抬到抽屉之上,功能坞/全局浮层才能全屏盖住抽屉。 */',
66
+ ' body[data-dk-mobile="1"] [class*="overlayLayer"]{z-index:100 !important;}',
67
+ ' body[data-dk-mobile="1"] [role="dialog"][class*="panel"][data-dsh-mobile-stack]{display:flex !important;flex-direction:column !important;width:100vw !important;max-width:100vw !important;height:100dvh !important;max-height:none !important;border-radius:0 !important;overflow:hidden !important;}',
68
+ ' body[data-dk-mobile="1"] [role="dialog"][class*="panel"][data-dsh-mobile-stack] > [class*="header"]{order:0;flex:none;width:auto !important;height:auto !important;padding:8px 12px !important;justify-content:flex-end !important;}',
69
+ ' body[data-dk-mobile="1"] [role="dialog"][class*="panel"][data-dsh-mobile-stack] > [class*="navTitle"]{order:1;flex:none;width:auto !important;padding:2px 16px 8px !important;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
70
+ ' body[data-dk-mobile="1"] [role="dialog"][class*="panel"][data-dsh-mobile-stack] > nav{order:2;flex:none;width:auto !important;height:auto !important;padding:0 8px 8px !important;overflow-x:auto;overflow-y:hidden;}',
71
+ ' body[data-dk-mobile="1"] [role="dialog"][class*="panel"][data-dsh-mobile-stack] > nav [class*="navList"]{flex-direction:row !important;}',
72
+ ' body[data-dk-mobile="1"] [role="dialog"][class*="panel"][data-dsh-mobile-stack] > [class*="options"]{order:3;flex:1 1 auto !important;min-height:0 !important;width:auto !important;overflow-y:auto !important;padding-bottom:max(16px,env(safe-area-inset-bottom,0px)) !important;}',
73
+ ' body[data-dk-mobile="1"][data-dsh-mobile-legacy] [class$="frame"]{grid-template-columns:0px minmax(0,1fr) 0px !important;}',
74
+ ' body[data-dk-mobile="1"][data-dsh-mobile-legacy] [class$="frame"] [class*="sidebarCol"]{position:fixed !important;top:0 !important;bottom:0 !important;left:0 !important;width:min(86vw,300px) !important;z-index:80 !important;box-shadow:0 12px 48px rgba(0,0,0,.45);transform:none !important;visibility:visible !important;transition:transform .22s ease !important;}',
75
+ ' body[data-dk-mobile="1"][data-dsh-mobile-legacy] [class$="frame"][data-sidebar-collapsed] [class*="sidebarCol"]{transform:translateX(-105%) !important;visibility:hidden !important;transition:transform .22s ease, visibility 0s linear .22s !important;}',
76
+ ' body[data-dk-mobile="1"] .dsh-mobile-scrim{position:fixed;inset:0;z-index:75;background:rgba(8,10,14,.45);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);touch-action:none;}',
77
+ ' body[data-dk-mobile="1"] .dsh-mobile-burger{position:fixed;left:calc(4px + env(safe-area-inset-left,0px));top:calc(4px + env(safe-area-inset-top,0px));z-index:66;box-sizing:border-box;width:44px;height:44px;display:flex;align-items:center;justify-content:center;padding:0;border:0;background:transparent;color:var(--dsw-alias-label-primary,#e6eaf2);-webkit-tap-highlight-color:transparent;touch-action:manipulation;}',
78
+ ' body[data-dk-mobile="1"] .dsh-mobile-burger svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;}',
79
+ ' body[data-dk-mobile="1"] .dsh-mobile-burger:active{background:color-mix(in srgb,currentColor 12%,transparent);}',
80
+ ' body[data-dk-mobile="1"][data-dsh-mobile-legacy] [class$="frame"] header:has([class*="titleRow"]){padding-left:calc(48px + env(safe-area-inset-left,0px));}',
81
+ ' html.dsh-dock-kbd body[data-dk-mobile="1"] .dsh-mobile-burger{display:none !important;}',
82
+ '}',
83
+ ].join('\n');
84
+
85
+ const STYLE_ATTR = 'data-dsh-mobile-runtime';
86
+ const BODY_ATTR = 'data-dk-mobile';
87
+ const LEGACY_ATTR = 'data-dsh-mobile-legacy';
88
+
89
+ // 运行时装配:把样式注入 <head>、给 body 打属性、按需兜底旧版 dsh 的弹层/抽屉,
90
+ // 并返回一个「连一点痕迹都不留」的清理函数。挂载即生效、卸载即还原,无需重启 dsh web。
91
+ // 单例:重复挂载(React StrictMode 双调 effect、或多半部误触)时先清理上一份,保证全程只有一套。
92
+ let currentDispose = null;
93
+ function applyRuntime() {
94
+ if (typeof window === 'undefined' || typeof document === 'undefined') return function () {};
95
+ const doc = document;
96
+ const body = doc.body;
97
+ if (!body) return function () {};
98
+ if (currentDispose) { try { currentDispose(); } catch (e) {} currentDispose = null; }
99
+
100
+ body.setAttribute(BODY_ATTR, '1');
101
+ let styleEl = doc.querySelector('style[' + STYLE_ATTR + ']');
102
+ if (!styleEl) {
103
+ styleEl = doc.createElement('style');
104
+ styleEl.setAttribute(STYLE_ATTR, '');
105
+ styleEl.textContent = MOBILE_CSS;
106
+ doc.head.appendChild(styleEl);
107
+ }
108
+
109
+ const mql = window.matchMedia ? window.matchMedia(MOBILE_QUERY) : null;
110
+ const vv = window.visualViewport || null;
111
+ const phone = function () { return !!(mql && mql.matches); };
112
+ const frame = function () { return doc.querySelector('[class$="frame"]'); };
113
+ const isCollapsed = function () { const f = frame(); return !!(f && f.hasAttribute('data-sidebar-collapsed')); };
114
+ let scrim = null;
115
+ let burger = null;
116
+ let observer = null;
117
+ let timer = 0;
118
+
119
+ function sidebarOpenToggle() {
120
+ const b = doc.querySelector('[data-sidebar-opener]') || doc.querySelector('[data-sidebar-toggle]');
121
+ if (b && typeof b.click === 'function') b.click();
122
+ }
123
+ function clearStack() {
124
+ const panels = doc.querySelectorAll('[role="dialog"][class*="panel"][data-dsh-mobile-stack]');
125
+ for (let i = 0; i < panels.length; i++) panels[i].removeAttribute('data-dsh-mobile-stack');
126
+ }
127
+ // 设置弹层:只在「左导航与右内容并排」时兜底成纵向堆叠;已是单列(现代 dsh)则什么都不做。
128
+ function dialogStackSync() {
129
+ const panel = doc.querySelector('[role="dialog"][class*="panel"]');
130
+ if (!panel) return;
131
+ let nav = null;
132
+ let opt = null;
133
+ const kids = panel.children;
134
+ for (let i = 0; i < kids.length; i++) {
135
+ const k = kids[i];
136
+ if (!nav && k.tagName === 'NAV') nav = k;
137
+ if (!opt && k.className && String(k.className).indexOf('options') >= 0) opt = k;
138
+ }
139
+ if (!nav || !opt) { panel.removeAttribute('data-dsh-mobile-stack'); return; }
140
+ const a = nav.getBoundingClientRect();
141
+ const b = opt.getBoundingClientRect();
142
+ if (!a.width || !b.width) return;
143
+ const sideBySide = b.left >= a.right - 8 && b.top < a.bottom - 4 && b.bottom > a.top + 4;
144
+ if (sideBySide) panel.setAttribute('data-dsh-mobile-stack', '1');
145
+ else panel.removeAttribute('data-dsh-mobile-stack');
146
+ }
147
+ function removeChrome() {
148
+ if (scrim) { scrim.remove(); scrim = null; }
149
+ if (burger) { burger.remove(); burger = null; }
150
+ body.removeAttribute(LEGACY_ATTR);
151
+ }
152
+ function ensureChrome() {
153
+ if (!scrim) {
154
+ scrim = doc.createElement('div');
155
+ scrim.className = 'dsh-mobile-scrim';
156
+ scrim.style.display = 'none';
157
+ scrim.addEventListener('click', function () { if (!isCollapsed()) sidebarOpenToggle(); });
158
+ body.appendChild(scrim);
159
+ }
160
+ if (!burger) {
161
+ burger = doc.createElement('button');
162
+ burger.type = 'button';
163
+ burger.className = 'dsh-mobile-burger';
164
+ burger.setAttribute('aria-label', '打开会话列表');
165
+ burger.setAttribute('aria-expanded', 'false');
166
+ burger.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>';
167
+ burger.addEventListener('click', function () { sidebarOpenToggle(); });
168
+ body.appendChild(burger);
169
+ }
170
+ }
171
+ // 旧版 dsh(<768 无 data-sidebar-drawer,侧栏仍是静态轨道)才浮层化 + 注入汉堡/遮罩。
172
+ function legacySync() {
173
+ const f = frame();
174
+ const legacy = !!f && phone() && !f.hasAttribute('data-sidebar-drawer');
175
+ if (!legacy) { removeChrome(); return; }
176
+ body.setAttribute(LEGACY_ATTR, '1');
177
+ ensureChrome();
178
+ const collapsed = isCollapsed();
179
+ if (scrim) scrim.style.display = collapsed ? 'none' : '';
180
+ if (burger) {
181
+ burger.style.display = '';
182
+ burger.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
183
+ }
184
+ }
185
+ function kbdSync() {
186
+ if (!vv) return;
187
+ const kbd = vv.height > 0 && (window.innerHeight - vv.height) > 120;
188
+ const de = doc.documentElement;
189
+ if (kbd) de.classList.add('dsh-dock-kbd');
190
+ else de.classList.remove('dsh-dock-kbd');
191
+ }
192
+ let dockAutoClosed = false;
193
+ function dockOpen() { return !!doc.querySelector('.dockm-dialog'); }
194
+ function tick() {
195
+ try {
196
+ kbdSync();
197
+ if (phone()) {
198
+ // 功能坞面板打开时自动收起抽屉,否则抽屉会盖在面板左侧、且中列处于 inert。
199
+ const dopen = dockOpen();
200
+ if (dopen && !isCollapsed() && !dockAutoClosed) { dockAutoClosed = true; sidebarOpenToggle(); }
201
+ if (!dopen) dockAutoClosed = false;
202
+ dialogStackSync();
203
+ legacySync();
204
+ }
205
+ else { clearStack(); removeChrome(); dockAutoClosed = false; }
206
+ } catch (e) { /* 任何一步失败都不影响页面 */ }
207
+ }
208
+
209
+ tick();
210
+ if (typeof MutationObserver !== 'undefined') {
211
+ observer = new MutationObserver(function () { tick(); });
212
+ try {
213
+ observer.observe(doc.documentElement, { attributes: true, attributeFilter: ['data-sidebar-collapsed', 'data-sidebar-drawer'], subtree: true });
214
+ observer.observe(body, { childList: true, subtree: true });
215
+ } catch (e) { /* observe 失败就只靠定时器 */ }
216
+ }
217
+ timer = window.setInterval(tick, 1200);
218
+ const onResize = function () { tick(); };
219
+ window.addEventListener('resize', onResize);
220
+ if (vv && vv.addEventListener) vv.addEventListener('resize', kbdSync);
221
+ if (mql && mql.addEventListener) mql.addEventListener('change', tick);
222
+ else if (mql && mql.addListener) mql.addListener(tick);
223
+
224
+ const dispose = function () {
225
+ if (currentDispose === dispose) currentDispose = null;
226
+ try { if (observer) observer.disconnect(); } catch (e) {}
227
+ if (timer) window.clearInterval(timer);
228
+ window.removeEventListener('resize', onResize);
229
+ if (vv && vv.removeEventListener) vv.removeEventListener('resize', kbdSync);
230
+ if (mql && mql.removeEventListener) mql.removeEventListener('change', tick);
231
+ else if (mql && mql.removeListener) mql.removeListener(tick);
232
+ try { doc.documentElement.classList.remove('dsh-dock-kbd'); } catch (e) {}
233
+ try { clearStack(); } catch (e) {}
234
+ removeChrome();
235
+ try { body.removeAttribute(BODY_ATTR); } catch (e) {}
236
+ try { if (styleEl && styleEl.parentNode) styleEl.parentNode.removeChild(styleEl); } catch (e) {}
237
+ };
238
+ currentDispose = dispose;
239
+ return dispose;
240
+ }
241
+
242
+ // —— Overlay:功能启用期间常驻;active 时挂载运行时、非 active 时什么也不做 ——
243
+ export function MobileOverlay() {
244
+ const [active, setActive] = useState(function () { return mobileActive(); });
245
+ useEffect(function () {
246
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
247
+ let mqs = [];
248
+ try { mqs = [window.matchMedia(MOBILE_QUERY), window.matchMedia(COARSE_QUERY)]; } catch (e) { return undefined; }
249
+ const update = function () { setActive(mqs.some(function (m) { return m.matches; })); };
250
+ update();
251
+ for (let i = 0; i < mqs.length; i++) {
252
+ const m = mqs[i];
253
+ if (m.addEventListener) m.addEventListener('change', update);
254
+ else if (m.addListener) m.addListener(update);
255
+ }
256
+ return function () {
257
+ for (let i = 0; i < mqs.length; i++) {
258
+ const m = mqs[i];
259
+ if (m.removeEventListener) m.removeEventListener('change', update);
260
+ else if (m.removeListener) m.removeListener(update);
261
+ }
262
+ };
263
+ }, []);
264
+ useEffect(function () {
265
+ if (!active) return undefined;
266
+ return applyRuntime();
267
+ }, [active]);
268
+ return null;
269
+ }
270
+
271
+ // —— 功能页:一行说清当前命中状态与兜底对象 ——
272
+ function useViewport() {
273
+ const [size, setSize] = useState(function () {
274
+ return typeof window === 'undefined' ? null : { w: window.innerWidth, h: window.innerHeight };
275
+ });
276
+ useEffect(function () {
277
+ if (typeof window === 'undefined') return undefined;
278
+ const update = function () { setSize({ w: window.innerWidth, h: window.innerHeight }); };
279
+ window.addEventListener('resize', update);
280
+ return function () { window.removeEventListener('resize', update); };
281
+ }, []);
282
+ return size;
283
+ }
284
+
285
+ function MobileView() {
286
+ const size = useViewport();
287
+ const [active, setActive] = useState(function () { return mobileActive(); });
288
+ const [drawer, setDrawer] = useState(false);
289
+ useEffect(function () {
290
+ if (typeof window === 'undefined') return undefined;
291
+ const tick = function () {
292
+ setActive(mobileActive());
293
+ const f = document.querySelector('[class$="frame"]');
294
+ setDrawer(!!(f && f.hasAttribute('data-sidebar-drawer')));
295
+ };
296
+ tick();
297
+ const t = setInterval(tick, 1000);
298
+ return function () { clearInterval(t); };
299
+ }, []);
300
+ const mode = !active ? '未命中' : (window.matchMedia && window.matchMedia(MOBILE_QUERY).matches ? (drawer ? '抽屉模式(原生)' : '旧版兜底(浮层抽屉)') : '仅触控优化');
301
+ return react.createElement('div', { className: 'dkmob-root' },
302
+ react.createElement('div', { className: 'dkmob-row' },
303
+ react.createElement('span', { className: 'dkmob-label' }, '手机模式'),
304
+ react.createElement('span', { className: 'dkmob-state' + (active ? ' on' : '') }, active ? '已命中' : '未命中'),
305
+ react.createElement('span', { className: 'dkmob-dim' }, size ? size.w + '×' + size.h + 'px' : '')),
306
+ react.createElement('div', { className: 'dkmob-row' },
307
+ react.createElement('span', { className: 'dkmob-label' }, '当前形态'),
308
+ react.createElement('span', { className: 'dkmob-state' + (active ? ' on' : '') }, mode)),
309
+ react.createElement('div', { className: 'dkmob-note' },
310
+ '生效中的优化:输入类控件字号 16px(防 iOS 聚焦整页缩放)· 原生侧栏开关热区扩到 44×44 · ',
311
+ '去点按高亮与双击缩放判定 · 代码块横向滚动 / 行内代码换行 · 会话输入行的余额/用量 chip 收起 · ',
312
+ '设置弹层内容安全区内边距。旧版 dsh(<768 仍是两栏弹层或静态图标轨道)会自动兜底为单列 / 浮层抽屉。',
313
+ '关闭本功能即全部还原——运行时样式、body 属性、遮罩/按钮与所有监听器一并移除,页面不残留任何注入。'));
314
+ }
315
+
316
+ function MobileStat() {
317
+ const active = mobileActive();
318
+ if (!active) return react.createElement('span', null, '当前视口未命中手机形态,未启用任何优化');
319
+ return react.createElement('span', null, '手机模式生效中(16px 输入 / 触控热区 / 代码滚动 / 弹层兜底)');
320
+ }
321
+
322
+ // —— 样式:模块自身仅样式为空(运行时样式由 Overlay 注入/卸载,关闭后零残留);
323
+ // View 自身的小样式随全局 <style> 注入,命名空间 dkmob- 避免与其他模块冲突。 ——
324
+ const css = [
325
+ '.dkmob-root{display:flex;flex-direction:column;gap:10px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.6;}',
326
+ '.dkmob-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}',
327
+ '.dkmob-label{font-weight:600;flex:none;}',
328
+ '.dkmob-state{flex:none;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 10px;font-size:11px;}',
329
+ '.dkmob-state.on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);}',
330
+ '.dkmob-dim{color:var(--dsw-alias-label-secondary);font-size:12px;}',
331
+ '.dkmob-note{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.7;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px;}',
332
+ ].join('\n');
333
+
334
+ export const feature = {
335
+ id: 'mobile',
336
+ name: '手机适配',
337
+ order: 170,
338
+ accent: '#34d399',
339
+ description: '手机/窄屏体验优化:16px 输入防缩放、触控热区、代码块滚动、会话 chip 收起、设置弹层单列与旧版抽屉兜底;关闭后零残留',
340
+ defaultEnabled: false,
341
+ css,
342
+ Overlay: MobileOverlay,
343
+ View: MobileView,
344
+ HomeStat: MobileStat,
345
+ };
@@ -50,6 +50,11 @@ function defaultConfig() {
50
50
  voiceLang: '',
51
51
  // 指定音色(speechSynthesis voiceURI,设备相关;空 = 按语言自动挑,本机没有时客户端回落)
52
52
  voiceURI: '',
53
+ // 事件 × 渠道矩阵(扁平键 card:complete / sound:error / system:confirm / ...;
54
+ // 未设置的键回退旧字段,升级不改变既有行为)
55
+ eventMatrix: {},
56
+ // 群机器人消息口径三挡:brief / medium / detailed
57
+ pushMode: 'detailed',
53
58
  // 迁移标记(schema 默认 false):settings 不可用时的兜底取 true——迁移由
54
59
  // src/host-core.js migrateNotifyConfig 统一负责,这里只是原样带回,不影响通知行为。
55
60
  migratedFromAnimation: true,
@@ -69,11 +74,21 @@ function readConfig(ctx) {
69
74
  if (!SOUND_EFFECTS.includes(cfg.soundEffect)) cfg.soundEffect = 'chime'
70
75
  if (cfg.voiceMode !== 'brief' && cfg.voiceMode !== 'medium' && cfg.voiceMode !== 'detailed') cfg.voiceMode = 'brief'
71
76
  if (cfg.cardMode !== 'brief' && cfg.cardMode !== 'medium' && cfg.cardMode !== 'detailed') cfg.cardMode = 'detailed'
77
+ if (cfg.pushMode !== 'brief' && cfg.pushMode !== 'medium' && cfg.pushMode !== 'detailed') cfg.pushMode = 'detailed'
78
+ if (!cfg.eventMatrix || typeof cfg.eventMatrix !== 'object' || Array.isArray(cfg.eventMatrix)) cfg.eventMatrix = {}
72
79
  }
73
80
  } catch { /* settings 未挂载,用默认 */ }
74
81
  return cfg
75
82
  }
76
83
 
84
+ /** 事件 × 渠道开关:矩阵里显式设置过就用它,否则回退到旧字段(fallback)。 */
85
+ function matrixEnabled(cfg, channel, event, fallback) {
86
+ const key = channel + ':' + event
87
+ const matrix = cfg && cfg.eventMatrix
88
+ if (matrix && typeof matrix === 'object' && typeof matrix[key] === 'boolean') return matrix[key]
89
+ return fallback !== false
90
+ }
91
+
77
92
  function truncate(str, max = 160) {
78
93
  if (!str) return ''
79
94
  return String(str).length > max ? String(str).slice(0, max) + '…' : str
@@ -106,19 +121,23 @@ const END_REASON_LABELS = {
106
121
  }
107
122
 
108
123
  // 钉钉与飞书共用的消息正文(各端再按各自 markdown 方言拼接)
109
- function buildMessageLines(t) {
124
+ // mode: brief = 任务与结果;medium = 加模型/耗时;detailed = 再加回合、Token 与摘要。
125
+ function buildMessageLines(t, mode) {
110
126
  const success = !t.endReason || t.endReason === 'completed'
111
127
  const reasonLabel = END_REASON_LABELS[t.endReason] || '✅ 完成'
112
128
  const models = t.models.length > 0 ? t.models.join(', ') : '未知'
129
+ const tone = mode === 'brief' || mode === 'medium' ? mode : 'detailed'
113
130
  const lines = []
114
131
  lines.push(`### ${success ? '✅' : '📢'} dsh 任务${success ? '完成' : '结束'}`)
115
132
  lines.push(`**任务**: ${t.title}`)
116
- lines.push(`**模型**: ${models}${t.provider ? `(${t.provider})` : ''}`)
117
- lines.push(`**耗时**: ${fmtDurCn(t.duration)}(${fmtClockOf(t.startTime)} → ${fmtClockOf(t.endTime)})`)
118
- lines.push(`**回合**: ${t.turns} · 步骤 ${t.steps}${t.toolCalls ? ` · 工具 ${t.toolCalls} 次` : ''}`)
119
- lines.push(`**Token**: 输入 ${t.inputTokens.toLocaleString()} / 输出 ${t.outputTokens.toLocaleString()} / 总计 ${t.totalTokens.toLocaleString()}`)
133
+ if (tone !== 'brief') lines.push(`**模型**: ${models}${t.provider ? `(${t.provider})` : ''}`)
134
+ lines.push(`**耗时**: ${fmtDurCn(t.duration)}${tone === 'brief' ? '' : `(${fmtClockOf(t.startTime)} → ${fmtClockOf(t.endTime)})`}`)
135
+ if (tone === 'detailed') {
136
+ lines.push(`**回合**: ${t.turns} · 步骤 ${t.steps}${t.toolCalls ? ` · 工具 ${t.toolCalls} 次` : ''}`)
137
+ lines.push(`**Token**: 输入 ${t.inputTokens.toLocaleString()} / 输出 ${t.outputTokens.toLocaleString()} / 总计 ${t.totalTokens.toLocaleString()}`)
138
+ }
120
139
  lines.push(`**结果**: ${reasonLabel}${t.errorMessage ? `:${truncate(t.errorMessage, 120)}` : ''}`)
121
- if (t.lastText) {
140
+ if (tone !== 'brief' && t.lastText) {
122
141
  lines.push(`> ${truncate(t.lastText, 200)}`)
123
142
  }
124
143
  lines.push('---')
@@ -130,8 +149,8 @@ function buildMessageLines(t) {
130
149
  }
131
150
  }
132
151
 
133
- function buildDingtalkMessage(t) {
134
- const { title, lines } = buildMessageLines(t)
152
+ function buildDingtalkMessage(t, mode) {
153
+ const { title, lines } = buildMessageLines(t, mode)
135
154
  return {
136
155
  title,
137
156
  // 钉钉 markdown 不渲染单个 \n,行之间必须用空行分隔才能换行
@@ -140,8 +159,8 @@ function buildDingtalkMessage(t) {
140
159
  }
141
160
 
142
161
  // 飞书 interactive 卡片:标题进 header,正文走 lark_md(支持 **加粗** 与 \n 换行,不支持 ### 标题)
143
- function buildFeishuMessage(t) {
144
- const { title, success, lines } = buildMessageLines(t)
162
+ function buildFeishuMessage(t, mode) {
163
+ const { title, success, lines } = buildMessageLines(t, mode)
145
164
  const body = lines.filter((l) => !l.startsWith('### ')).join('\n')
146
165
  return {
147
166
  title,
@@ -223,14 +242,15 @@ export const feature = {
223
242
  disposers.push(() => lease.release())
224
243
  const tracker = lease.tracker
225
244
 
226
- // ===== 群机器人推送(宿主侧直发,异步不阻塞;事件筛选跟随 notifyOnComplete/notifyOnError) =====
245
+ // ===== 群机器人推送(宿主侧直发,异步不阻塞;事件筛选走 eventMatrix,缺省回退 notifyOn*) =====
227
246
  async function pushDingtalkIfNeeded(record) {
228
247
  try {
229
248
  const cfg = readConfig(ctx)
230
249
  if (!cfg.dingtalkEnabled || !cfg.dingtalkWebhook) return
231
250
  const success = !record.endReason || record.endReason === 'completed'
232
- if (success ? !cfg.notifyOnComplete : !cfg.notifyOnError) return
233
- const { title, text } = buildDingtalkMessage(record)
251
+ const event = success ? 'complete' : 'error'
252
+ if (!matrixEnabled(cfg, 'dingtalk', event, success ? cfg.notifyOnComplete : cfg.notifyOnError)) return
253
+ const { title, text } = buildDingtalkMessage(record, cfg.pushMode)
234
254
  const r = await sendDingtalk(cfg.dingtalkWebhook, title, text)
235
255
  if (r.ok) console.log('[dsh-dock] notify dingtalk pushed:', title)
236
256
  else console.error('[dsh-dock] notify dingtalk push failed:', r.error)
@@ -244,8 +264,9 @@ export const feature = {
244
264
  const cfg = readConfig(ctx)
245
265
  if (!cfg.feishuEnabled || !cfg.feishuWebhook) return
246
266
  const success = !record.endReason || record.endReason === 'completed'
247
- if (success ? !cfg.notifyOnComplete : !cfg.notifyOnError) return
248
- const { title, card } = buildFeishuMessage(record)
267
+ const event = success ? 'complete' : 'error'
268
+ if (!matrixEnabled(cfg, 'feishu', event, success ? cfg.notifyOnComplete : cfg.notifyOnError)) return
269
+ const { title, card } = buildFeishuMessage(record, cfg.pushMode)
249
270
  const r = await sendFeishu(cfg.feishuWebhook, title, card)
250
271
  if (r.ok) console.log('[dsh-dock] notify feishu pushed:', title)
251
272
  else console.error('[dsh-dock] notify feishu push failed:', r.error)
@@ -298,6 +319,20 @@ export const feature = {
298
319
  if (typeof p.systemNotify === 'boolean') cfg.systemNotify = p.systemNotify
299
320
  if (typeof p.soundNotify === 'boolean') cfg.soundNotify = p.soundNotify
300
321
  if (typeof p.soundEffect === 'string' && SOUND_EFFECTS.includes(p.soundEffect)) cfg.soundEffect = p.soundEffect
322
+ // 群机器人消息口径三挡
323
+ if (typeof p.pushMode === 'string' && (p.pushMode === 'brief' || p.pushMode === 'medium' || p.pushMode === 'detailed')) {
324
+ cfg.pushMode = p.pushMode
325
+ }
326
+ // 事件 × 渠道矩阵:只接受白名单键 + 布尔值,合并写回
327
+ if (p.eventMatrix && typeof p.eventMatrix === 'object' && !Array.isArray(p.eventMatrix)) {
328
+ const matrix = Object.assign({}, cfg.eventMatrix || {})
329
+ for (const [key, value] of Object.entries(p.eventMatrix)) {
330
+ if (typeof value === 'boolean' && /^(card|sound|system|voice|dingtalk|feishu):(complete|error|confirm|stuck)$/.test(key)) {
331
+ matrix[key] = value
332
+ }
333
+ }
334
+ cfg.eventMatrix = matrix
335
+ }
301
336
  if (typeof p.dingtalkEnabled === 'boolean') cfg.dingtalkEnabled = p.dingtalkEnabled
302
337
  if (typeof p.dingtalkWebhook === 'string') {
303
338
  const hook = p.dingtalkWebhook.trim()