@xiaopb/dsh-mobile-compose 0.0.0-stage → 0.6.4
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/CHANGELOG.md +121 -0
- package/LICENSE +21 -0
- package/README.md +164 -2
- package/cordis.patch.yml +7 -0
- package/lib/capabilities.js +109 -0
- package/lib/client.js +1380 -0
- package/lib/config.js +93 -0
- package/lib/index.js +97 -0
- package/lib/settings.js +46 -0
- package/package.json +75 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobile-compose 浏览器端(client half)。
|
|
3
|
+
*
|
|
4
|
+
* 产物形状与 dsh-hardssh 的 tsdown client bundle 完全一致:__ModuleLoader__
|
|
5
|
+
* closure factory —— `load({id, factory(require)})`,外部依赖经注入的 require
|
|
6
|
+
* 从平台模块表解析(react / react-dom / 平台包不在 bundle 内)。本文件为手写
|
|
7
|
+
* 产物(规模不需要 TS 工具链),与官方加载器契约逐字段一致。
|
|
8
|
+
*
|
|
9
|
+
* 职责(v0.3.0,按桌面端真机反馈修正):
|
|
10
|
+
* 1. 「+」入口:注册在官方插槽 `conversation.input.left`,并用 flex order
|
|
11
|
+
* 站到**原生两键的原位置**(工具行最左)——不是后增在行尾。
|
|
12
|
+
* 2. 动作面板(QQ 式宫格)双形态,由「+」入口组件内渲染(**不依赖
|
|
13
|
+
* composer.dock 插槽**——该插槽在 hero 态(新建/空会话)下官方不渲染):
|
|
14
|
+
* - 桌面(宽窗口 / 细指针):portal 到官方 `[data-composer-dock]` 容器
|
|
15
|
+
* —— **插在对话框卡片正下方的文档流内**(hero / composer 两态都有该容器);
|
|
16
|
+
* - 手机(窄窗口 / 粗指针):底部动作面板(蒙层 + 上滑动画 + 返回键关闭)。
|
|
17
|
+
* 3. 动作注册表(文件来源细分,为安卓专门路由留缝):
|
|
18
|
+
* - pick-image 图片选择器(显式图片后缀过滤 → 桌面原始文件打开器按类型过滤)
|
|
19
|
+
* - take-photo 拍照(input[capture=environment],仅能力协商放行时展示)
|
|
20
|
+
* - pick-file 通用文件(直接复用原生隐藏 input[type=file] 的官方路径)
|
|
21
|
+
* - command 调用指令(程序化触发原生指令菜单按钮)
|
|
22
|
+
* 4. 文件回流:自选 input 拿到 File 后,经 DataTransfer 注入官方隐藏
|
|
23
|
+
* input[type=file] 并派发 change —— 官方 onPickFiles → addFiles 校验、
|
|
24
|
+
* 上传、附件预览全链路原样生效,本插件不造上传。
|
|
25
|
+
* 5. 能力协商:装载时 + 每次打开面板时拉取 GET /api/dsh-mobile-compose/
|
|
26
|
+
* capabilities(DSH 设置页改动实时生效),失败走内置默认集。
|
|
27
|
+
* 6. hideNativeButtons(可协商关闭):作用域样式隐藏原生「添加文件 / 调用
|
|
28
|
+
* 指令」两个小按钮 —— 回滚即恢复,零 DOM 改动。
|
|
29
|
+
*
|
|
30
|
+
* 失败策略:与 hardssh 客户端一致 —— DOM 查找失败只 console.warn,绝不上抛
|
|
31
|
+
* (web shell 会在插件 apply 抛错时令整个启动失败)。
|
|
32
|
+
*/
|
|
33
|
+
window.__ModuleLoader__.load({
|
|
34
|
+
id: '@xiaopb/dsh-mobile-compose',
|
|
35
|
+
factory: (require) => {
|
|
36
|
+
var module = { exports: {} };
|
|
37
|
+
var exports = module.exports;
|
|
38
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
39
|
+
|
|
40
|
+
const react = require('react');
|
|
41
|
+
const { createElement: h, useCallback, useEffect, useRef, useState } = react;
|
|
42
|
+
const { createPortal } = require('react-dom');
|
|
43
|
+
|
|
44
|
+
const PLUGIN_ID = 'dsh-mobile-compose';
|
|
45
|
+
const SLOT_ENTRY = 'conversation.input.left';
|
|
46
|
+
const ENTRY_ID = 'dsh-mobile-compose-plus';
|
|
47
|
+
const CAPS_PATH = '/api/dsh-mobile-compose/capabilities';
|
|
48
|
+
|
|
49
|
+
/** 蒙层/底部面板层级:高于 shell 常规层级。 */
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------
|
|
52
|
+
// 能力协商(模块级轻量 pub/sub)
|
|
53
|
+
// ---------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 与内置动作同义的指令名 —— 默认从指令列表里剔除。
|
|
57
|
+
* `/file` 是官方 ui-conversation 注册的客户端指令(label 文件,run →
|
|
58
|
+
* pickFiles),语义与「图片 / 文件」两个瓦片完全重叠;不剔除就会在面板里
|
|
59
|
+
* 出现同名重复项。可用设置页 hiddenCommands 覆盖(逗号分隔的指令名)。
|
|
60
|
+
*/
|
|
61
|
+
const DEFAULT_HIDDEN_COMMANDS = ['file'];
|
|
62
|
+
|
|
63
|
+
const caps = {
|
|
64
|
+
ready: false,
|
|
65
|
+
android: false,
|
|
66
|
+
hideNativeButtons: true,
|
|
67
|
+
enabled: new Set(['pick-image', 'take-photo', 'pick-file', 'command']),
|
|
68
|
+
hiddenCommands: new Set(DEFAULT_HIDDEN_COMMANDS),
|
|
69
|
+
};
|
|
70
|
+
const capsListeners = new Set();
|
|
71
|
+
|
|
72
|
+
function setCaps(next) {
|
|
73
|
+
Object.assign(caps, next);
|
|
74
|
+
caps.ready = true;
|
|
75
|
+
// 订阅是持久的(syncHide / 组件 tick),只通知、不清空;单个订阅者
|
|
76
|
+
// 异常互不影响。
|
|
77
|
+
for (const listener of Array.from(capsListeners)) {
|
|
78
|
+
try { listener(); } catch (_) { /* 订阅者异常互不影响 */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeHidden(list, fallback) {
|
|
83
|
+
const out = new Set();
|
|
84
|
+
const source = Array.isArray(list)
|
|
85
|
+
? list
|
|
86
|
+
: typeof list === 'string'
|
|
87
|
+
? list.split(/[,\s]+/)
|
|
88
|
+
: null;
|
|
89
|
+
if (!source) return new Set(fallback);
|
|
90
|
+
for (const item of source) {
|
|
91
|
+
if (typeof item !== 'string') continue;
|
|
92
|
+
const name = item.trim().replace(/^\//, '').toLowerCase();
|
|
93
|
+
if (name) out.add(name);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fetchCaps() {
|
|
99
|
+
fetch(CAPS_PATH)
|
|
100
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
101
|
+
.then((body) => {
|
|
102
|
+
if (!body || typeof body !== 'object') return;
|
|
103
|
+
const enabled = new Set(caps.enabled);
|
|
104
|
+
if (Array.isArray(body.actions)) {
|
|
105
|
+
for (const a of body.actions) {
|
|
106
|
+
if (a && typeof a.id === 'string') {
|
|
107
|
+
if (a.enabled === false) enabled.delete(a.id); else enabled.add(a.id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
setCaps({
|
|
112
|
+
ready: true,
|
|
113
|
+
android: body.platform?.android === true,
|
|
114
|
+
hideNativeButtons: body.client?.hideNativeButtons !== false,
|
|
115
|
+
enabled,
|
|
116
|
+
hiddenCommands: normalizeHidden(body.commands?.hidden, DEFAULT_HIDDEN_COMMANDS),
|
|
117
|
+
});
|
|
118
|
+
})
|
|
119
|
+
.catch(() => { /* 拉取失败:保持内置默认集 */ });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------
|
|
123
|
+
// 面板开合状态(「+」入口与 dock 面板两个插槽组件共享)
|
|
124
|
+
// ---------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const panelState = {
|
|
127
|
+
open: false,
|
|
128
|
+
listeners: new Set(),
|
|
129
|
+
triggerEl: null,
|
|
130
|
+
sessionId: null, // 官方 slot props 提供,用于 remote.commands.list
|
|
131
|
+
pageIndex: 0, // 0 起:单一列表分页
|
|
132
|
+
commands: null, // [{name, label, desc}];null=未加载
|
|
133
|
+
skills: null, // [{name, desc, userOnly}];null=未加载
|
|
134
|
+
commandsFromRemote: false,
|
|
135
|
+
loadingCommands: false,
|
|
136
|
+
labelsEnriched: false,// 是否已用原生菜单补全过中文标签
|
|
137
|
+
harvesting: false, // 采集进行中(抑制 outside-click 关闭与重复触发)
|
|
138
|
+
swallowClickUntil: 0, // 手势翻页后的 click 抑制截止时间(吞掉误触)
|
|
139
|
+
inputActions: null, // 官方 session 标准件(setDraft/submit),dock 组件挂载时登记
|
|
140
|
+
ownInputs: null, // 面板自带的 input[type=file](面板挂载时登记,点击由动作触发)
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
function setPanelOpen(open) {
|
|
144
|
+
if (panelState.open === open) return;
|
|
145
|
+
panelState.open = open;
|
|
146
|
+
for (const listener of Array.from(panelState.listeners)) {
|
|
147
|
+
try { listener(); } catch (_) { /* 订阅者异常互不影响 */ }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function subscribePanel(listener) {
|
|
152
|
+
panelState.listeners.add(listener);
|
|
153
|
+
return () => { panelState.listeners.delete(listener); };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 订阅 caps + 面板开合的小 hook(两个插槽组件共用)。 */
|
|
157
|
+
function useExternalState() {
|
|
158
|
+
const [, forceTick] = useState(0);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const onCaps = () => forceTick((n) => n + 1);
|
|
161
|
+
const onPanel = () => forceTick((n) => n + 1);
|
|
162
|
+
capsListeners.add(onCaps);
|
|
163
|
+
const off = subscribePanel(onPanel);
|
|
164
|
+
return () => {
|
|
165
|
+
capsListeners.delete(onCaps);
|
|
166
|
+
off();
|
|
167
|
+
};
|
|
168
|
+
}, []);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------
|
|
172
|
+
// 动作注册表(文件来源细分 —— 安卓专门路由的缝)
|
|
173
|
+
// ---------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
// 桌面端策略:**图片 / 文件 都是同一个 file 输入的两个视图**——
|
|
176
|
+
// pick-image 只处理图片后缀(accept 显式列出图片 mime + 后缀,桌面
|
|
177
|
+
// Chromium / 安卓系统选择器都按此过滤);
|
|
178
|
+
// pick-file 不限后缀(accept 留空 = 全部文件),即通用的 file。
|
|
179
|
+
// 两者拿到 File 后走同一条回流通道(DataTransfer → 官方隐藏 input)。
|
|
180
|
+
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif';
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* kind:
|
|
184
|
+
* 'own-input' 用本面板自带的 input[accept][capture] 选择文件,随后经
|
|
185
|
+
* DataTransfer 注入官方隐藏 input(桌面 + 当前安卓 WebView)。
|
|
186
|
+
* 'command' 本瓦片不直接执行,走指令表(runCommand / runSkill)。
|
|
187
|
+
* 未来安卓原生路由:cap 响应里 routed:'native' 时,exec 改为调 App 桥,
|
|
188
|
+
* 本注册表形状不变(见 docs/android-bridge.md)。
|
|
189
|
+
*
|
|
190
|
+
* accept 空字符串 = 不限类型(= 原始 file,处理全部后缀)。
|
|
191
|
+
*/
|
|
192
|
+
const ACTIONS = [
|
|
193
|
+
{ id: 'pick-image', source: 'gallery', kind: 'own-input', accept: IMAGE_ACCEPT, multiple: true, accent: '#378ADD' },
|
|
194
|
+
{ id: 'take-photo', source: 'camera', kind: 'own-input', accept: IMAGE_ACCEPT, capture: 'environment', multiple: false, accent: '#639922' },
|
|
195
|
+
{ id: 'pick-file', source: 'document', kind: 'own-input', accept: '', multiple: true, accent: '#BA7517' },
|
|
196
|
+
{ id: 'command', source: 'internal', kind: 'command', accent: '#7F77DD' },
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------
|
|
200
|
+
// DOM 查找与文件回流(官方通道)
|
|
201
|
+
// ---------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const OFFICIAL_FILE_INPUT = 'input[type="file"][multiple][hidden]:not([data-dmc-input])';
|
|
204
|
+
|
|
205
|
+
/** 从「+」按钮向上找包含官方隐藏 input 的容器(工具行);兜底 document。 */
|
|
206
|
+
function composerScope(triggerEl) {
|
|
207
|
+
let el = triggerEl;
|
|
208
|
+
for (let hop = 0; hop < 8 && el && el !== document; hop++) {
|
|
209
|
+
if (el.querySelector && el.querySelector(OFFICIAL_FILE_INPUT)) return el;
|
|
210
|
+
el = el.parentElement;
|
|
211
|
+
}
|
|
212
|
+
return document;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function findNativeFileInput(scope) {
|
|
216
|
+
return (scope || document).querySelector(OFFICIAL_FILE_INPUT);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function findCommandButton(scope) {
|
|
220
|
+
return (scope || document).querySelector('button[aria-haspopup="listbox"]');
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 把 File 列表注入官方隐藏 input 并派发 change(React 合成 onChange 会
|
|
224
|
+
* 收到)。置空 _valueTracker 规避 React 值追踪对同值 change 的去重。
|
|
225
|
+
*/
|
|
226
|
+
function injectFilesIntoNativeInput(input, files) {
|
|
227
|
+
if (!input || typeof DataTransfer === 'undefined') return false;
|
|
228
|
+
try {
|
|
229
|
+
const dt = new DataTransfer();
|
|
230
|
+
for (const f of files) dt.items.add(f);
|
|
231
|
+
input.files = dt.files;
|
|
232
|
+
const tracker = input._valueTracker;
|
|
233
|
+
if (tracker && typeof tracker.setValue === 'function') tracker.setValue('');
|
|
234
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
235
|
+
return true;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
console.warn('[dsh-mobile-compose] 文件注入官方通道失败:', error);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function warnMissing(what) {
|
|
243
|
+
console.warn(`[dsh-mobile-compose] 未找到${what}(DOM 结构随官方版本漂移时需更新选择器)`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------
|
|
247
|
+
// 样式(作用域前缀 dmc-;hideNative 由 body 类开关)
|
|
248
|
+
// ---------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
const CSS_TEXT = `
|
|
251
|
+
/* 「+」入口:32px 触摸区(比原生 26px 更好按);展开时图标转 45° 变成 × */
|
|
252
|
+
.dmc-entry-btn{order:-1;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border:none;border-radius:8px;background:transparent;color:inherit;cursor:pointer;padding:0;flex:none;-webkit-tap-highlight-color:transparent}
|
|
253
|
+
.dmc-entry-btn:hover{background:rgba(127,127,127,.18)}
|
|
254
|
+
.dmc-entry-btn:active{background:rgba(127,127,127,.28)}
|
|
255
|
+
.dmc-entry-btn svg{display:block;transition:transform .18s ease}
|
|
256
|
+
.dmc-entry-btn.dmc-open svg{transform:rotate(45deg)}
|
|
257
|
+
.dmc-swipe{touch-action:pan-y}
|
|
258
|
+
.dmc-dock-slot{order:-1;display:flex;justify-content:center;width:100%;flex-basis:100%;min-width:0;max-width:100%}
|
|
259
|
+
.dmc-dock{box-sizing:border-box;margin-top:8px;padding:10px 12px 8px;background:#fff;color:#1f2329;border:.5px solid rgba(0,0,0,.08);border-radius:16px;box-shadow:0 6px 24px rgba(0,0,0,.10);animation:dmc-drop .16s ease-out;flex:0 0 auto}
|
|
260
|
+
.dmc-pager{display:flex;gap:6px;justify-content:center;align-items:center;margin:0 0 8px;min-height:8px}
|
|
261
|
+
.dmc-loading{display:flex;justify-content:center;align-items:center;margin:0 0 8px;min-height:8px}
|
|
262
|
+
.dmc-loading::after{content:'';width:6px;height:6px;border-radius:3px;background:rgba(127,127,127,.55);animation:dmc-pulse 1s ease-in-out infinite}
|
|
263
|
+
@keyframes dmc-pulse{0%,100%{opacity:.25}50%{opacity:1}}
|
|
264
|
+
.dmc-dot{width:6px;height:6px;padding:0;border:none;border-radius:3px;background:rgba(127,127,127,.35);cursor:pointer;transition:width .15s ease,background .15s ease}
|
|
265
|
+
.dmc-dot.dmc-active{width:14px;background:currentColor}
|
|
266
|
+
/* 宫格:固定 2 行,列数由面板宽度与条目数决定(行内样式注入) */
|
|
267
|
+
.dmc-grid{display:grid;gap:14px 8px;margin:0;padding:0;list-style:none}
|
|
268
|
+
.dmc-tile{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:6px;background:transparent;border:none;padding:0;cursor:pointer;min-width:44px;height:72px;font:inherit;max-width:100%}
|
|
269
|
+
.dmc-tile:active .dmc-tile-box{transform:scale(.94)}
|
|
270
|
+
.dmc-tile-box{display:flex;align-items:center;justify-content:center;width:48px;height:48px;border-radius:13px;background:#f1f2f4;transition:transform .12s ease;flex:none}
|
|
271
|
+
.dmc-tile-label{font-size:12px;line-height:1.2;color:#1f2329;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:center}
|
|
272
|
+
/* 底部面板形态(手机宽度):fixed 底部滑出 */
|
|
273
|
+
@keyframes dmc-drop{from{transform:translateY(-6px);opacity:.4}to{transform:translateY(0);opacity:1}}
|
|
274
|
+
@media (prefers-reduced-motion: reduce){.dmc-dock{animation:none}}
|
|
275
|
+
@media (prefers-color-scheme: dark){
|
|
276
|
+
.dmc-dock{background:#26282e;color:#e8eaed;border-color:rgba(255,255,255,.08)}
|
|
277
|
+
.dmc-tile-box{background:#33363d}
|
|
278
|
+
.dmc-tile-label{color:#e8eaed}
|
|
279
|
+
}
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
/** 隐藏原生小按钮的规则(body.dmc-hide-native 开关,capabilities 可关)。 */
|
|
283
|
+
const HIDE_CSS = `
|
|
284
|
+
body.dmc-hide-native button[aria-haspopup="listbox"]{display:none!important}
|
|
285
|
+
body.dmc-hide-native button:has(+ input[type="file"][multiple][hidden]:not([data-dmc-input])){display:none!important}
|
|
286
|
+
`;
|
|
287
|
+
|
|
288
|
+
function ensureStyleTag(id, text) {
|
|
289
|
+
let tag = document.getElementById(id);
|
|
290
|
+
if (tag) return tag;
|
|
291
|
+
tag = document.createElement('style');
|
|
292
|
+
tag.id = id;
|
|
293
|
+
tag.setAttribute('data-plugin', PLUGIN_ID);
|
|
294
|
+
tag.textContent = text;
|
|
295
|
+
document.head.appendChild(tag);
|
|
296
|
+
return tag;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------
|
|
300
|
+
// 文案:跟随 DSH 语言(locale 服务 snapshot.active)。
|
|
301
|
+
// 插件自有文案(动作名 / 提示 / 无障碍标签)走本表;指令标签来自官方
|
|
302
|
+
// locale + 原生菜单采集,本身已是当前语言,不再经过本表。
|
|
303
|
+
// ---------------------------------------------------------------------
|
|
304
|
+
let uiLang = 'zh';
|
|
305
|
+
|
|
306
|
+
const TEXT = {
|
|
307
|
+
'entry.label': { zh: '添加内容', en: 'Add content' },
|
|
308
|
+
'entry.title': { zh: '添加内容(图片 / 文件 / 指令)', en: 'Add content (images / files / commands)' },
|
|
309
|
+
'panel.label': { zh: '添加内容', en: 'Add content' },
|
|
310
|
+
'action.pick-image': { zh: '图片', en: 'Images' },
|
|
311
|
+
'action.take-photo': { zh: '拍照', en: 'Camera' },
|
|
312
|
+
'action.pick-file': { zh: '文件', en: 'Files' },
|
|
313
|
+
'action.command': { zh: '指令', en: 'Commands' },
|
|
314
|
+
'page.label': { zh: '第 {n} 页', en: 'Page {n}' },
|
|
315
|
+
'tile.reload': { zh: '重读指令', en: 'Reload' },
|
|
316
|
+
'hint.loading': { zh: '正在读取指令…', en: 'Loading commands…' },
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
function t(key, vars) {
|
|
320
|
+
const entry = TEXT[key];
|
|
321
|
+
let text = entry ? (entry[uiLang] || entry.zh) : key;
|
|
322
|
+
if (vars) {
|
|
323
|
+
for (const name of Object.keys(vars)) text = text.split('{' + name + '}').join(String(vars[name]));
|
|
324
|
+
}
|
|
325
|
+
return text;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 当前语言:locale 服务快照优先,其次 <html lang>,最后 zh。 */
|
|
329
|
+
function detectLang(locale) {
|
|
330
|
+
let active = '';
|
|
331
|
+
try {
|
|
332
|
+
const snap = (locale && typeof locale.getSnapshot === 'function' && locale.getSnapshot())
|
|
333
|
+
|| (locale && typeof locale.getLocale === 'function' && locale.getLocale());
|
|
334
|
+
if (snap && typeof snap.active === 'string') active = snap.active;
|
|
335
|
+
} catch (_) { /* 静默 */ }
|
|
336
|
+
if (!active && typeof document !== 'undefined' && document.documentElement) active = document.documentElement.lang || '';
|
|
337
|
+
return /^zh/i.test(active) ? 'zh' : (active ? 'en' : 'zh');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function haptic(ms) {
|
|
341
|
+
try {
|
|
342
|
+
if (typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function') navigator.vibrate(ms);
|
|
343
|
+
} catch (_) { /* 静默 */ }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------
|
|
347
|
+
// 图标(16 viewBox 内联 SVG)
|
|
348
|
+
// ---------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
function icon(paths, opts) {
|
|
351
|
+
const { size = 22, color = 'currentColor', fill = 'none' } = opts || {};
|
|
352
|
+
return h('svg', {
|
|
353
|
+
viewBox: '0 0 16 16', width: size, height: size, fill,
|
|
354
|
+
stroke: color, strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
355
|
+
'aria-hidden': true,
|
|
356
|
+
}, paths.map((d, i) => h('path', { d, key: i })));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const ICONS = {
|
|
360
|
+
plus: () => icon(['M8 3.5v9', 'M3.5 8h9'], { size: 18 }),
|
|
361
|
+
image: (accent) => icon([
|
|
362
|
+
'M2.5 3.5h11v9h-11z', 'M2.5 10.5l3-3 3.5 3.5', 'M9 9.5l2-2 2.5 2.5',
|
|
363
|
+
'M6 6.2a.7.7 0 1 1-.01 0', 'M2.5 12.5h11',
|
|
364
|
+
], { color: accent }),
|
|
365
|
+
camera: (accent) => icon([
|
|
366
|
+
'M2.5 5.5h2l1.2-1.6h4.6L11.5 5.5h2v7.5h-11z', 'M8 7a2.4 2.4 0 1 1-.01 0',
|
|
367
|
+
], { color: accent }),
|
|
368
|
+
file: (accent) => icon([
|
|
369
|
+
'M4 2.5h5l3 3v8H4z', 'M9 2.5v3h3', 'M6 8.5h4', 'M6 11h4',
|
|
370
|
+
], { color: accent }),
|
|
371
|
+
command: (accent) => icon([
|
|
372
|
+
'M2.5 3.5h11v9h-11z', 'M5 6.5L7 8l-2 1.5', 'M8.5 10h3',
|
|
373
|
+
], { color: accent }),
|
|
374
|
+
skill: (accent) => icon([
|
|
375
|
+
'M3 12.5l6-6', 'M9.5 4.2l2.3 2.3', 'M4.6 9.9l1.5 1.5', 'M11 2.6l2.4 2.4',
|
|
376
|
+
'M2.6 13.4l1.2-1.2',
|
|
377
|
+
], { color: accent }),
|
|
378
|
+
// ---- 指令专属图标(按指令名映射,未知指令回退 command)----
|
|
379
|
+
target: (accent) => icon(['M8 2.5a5.5 5.5 0 1 1-.01 0', 'M8 5.5a2.5 2.5 0 1 1-.01 0', 'M8 8v0.01'], { color: accent }),
|
|
380
|
+
plan: (accent) => icon(['M3 4.2h2', 'M3 8h2', 'M3 11.8h2', 'M7 4.2h6', 'M7 8h6', 'M7 11.8h4'], { color: accent }),
|
|
381
|
+
feedback: (accent) => icon(['M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z', 'M5.5 7h5'], { color: accent }),
|
|
382
|
+
compact: (accent) => icon(['M4.5 2.5v11', 'M11.5 2.5v11', 'M6.5 6L8 7.5 9.5 6', 'M6.5 10L8 8.5 9.5 10'], { color: accent }),
|
|
383
|
+
shield: (accent) => icon(['M8 2.5l5 2v4c0 3-2.2 4.8-5 5.5-2.8-.7-5-2.5-5-5.5v-4z', 'M6 8l1.5 1.5L10.5 6.5'], { color: accent }),
|
|
384
|
+
model: (accent) => icon(['M4.5 4.5h7v7h-7z', 'M6.5 2.5v2', 'M9.5 2.5v2', 'M6.5 11.5v2', 'M9.5 11.5v2', 'M2.5 6.5h2', 'M2.5 9.5h2', 'M11.5 6.5h2', 'M11.5 9.5h2'], { color: accent }),
|
|
385
|
+
download: (accent) => icon(['M8 2.5v7.5', 'M5 7l3 3 3-3', 'M3 12.5h10'], { color: accent }),
|
|
386
|
+
reload: (accent) => icon(['M12.8 6.4A5 5 0 1 0 13 9.5', 'M13.4 3.2v3.4h-3.4'], { color: accent }),
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/** 指令名 → 专属图标 key(未知指令回退通用 command 图标)。 */
|
|
390
|
+
const CMD_ICON = {
|
|
391
|
+
file: 'file',
|
|
392
|
+
goal: 'target',
|
|
393
|
+
plan: 'plan',
|
|
394
|
+
feedback: 'feedback',
|
|
395
|
+
compact: 'compact',
|
|
396
|
+
permission: 'shield',
|
|
397
|
+
model: 'model',
|
|
398
|
+
export: 'download',
|
|
399
|
+
reload: 'reload',
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------
|
|
403
|
+
// 动作执行(「+」入口与 dock 面板共用)
|
|
404
|
+
// ---------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
function closePanel() {
|
|
407
|
+
setPanelOpen(false);
|
|
408
|
+
// 关闭后回到「常用」页(下次打开的默认落点);面板随开随卸,
|
|
409
|
+
// 重置必须放在模块级状态上而不是组件卸载 effect 里。
|
|
410
|
+
panelState.pageIndex = 0;
|
|
411
|
+
try {
|
|
412
|
+
// 返回键手势占用的 history 位只在还有效时回退。
|
|
413
|
+
if (window.history.state && window.history.state['dsh-mobile-compose']) window.history.back();
|
|
414
|
+
} catch (_) { /* 静默 */ }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function openPanel() {
|
|
418
|
+
haptic(8);
|
|
419
|
+
// 每次打开都重拉能力协商:DSH 设置页改动(动作开关 / 隐藏原生按钮)
|
|
420
|
+
// 无需刷新页面即可生效;拉取失败不影响本次打开(沿用上次/默认集)。
|
|
421
|
+
fetchCaps();
|
|
422
|
+
setPanelOpen(true);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** 手势翻页后的短窗口内忽略派生 click(防止翻页顺手触发瓦片)。 */
|
|
426
|
+
function swallowDerivedClick() {
|
|
427
|
+
return panelState.swallowClickUntil > Date.now();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function runAction(action) {
|
|
431
|
+
if (swallowDerivedClick()) return;
|
|
432
|
+
haptic(8);
|
|
433
|
+
if (action.kind !== 'own-input') return; // 指令瓦片走 runCommand / runSkill
|
|
434
|
+
const input = panelState.ownInputs ? panelState.ownInputs[action.id] : null;
|
|
435
|
+
if (!input) {
|
|
436
|
+
warnMissing('面板文件 input');
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// 面板保持挂载直到 onChange:选择器打开期间卸载 input 会让部分浏览器
|
|
440
|
+
// 直接取消文件对话框(关闭动作放在 ownInputChange 里)。
|
|
441
|
+
try {
|
|
442
|
+
input.value = '';
|
|
443
|
+
input.click();
|
|
444
|
+
} catch (error) {
|
|
445
|
+
console.warn('[dsh-mobile-compose] 打开文件选择器失败:', error);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function ownInputChange(event) {
|
|
450
|
+
const inputEl = event.currentTarget;
|
|
451
|
+
const files = Array.from(inputEl.files || []);
|
|
452
|
+
inputEl.value = '';
|
|
453
|
+
if (files.length === 0) return;
|
|
454
|
+
const native = findNativeFileInput(composerScope(panelState.triggerEl));
|
|
455
|
+
if (!native) { warnMissing('官方文件选择 input'); return; }
|
|
456
|
+
if (!injectFilesIntoNativeInput(native, files)) return;
|
|
457
|
+
haptic(12);
|
|
458
|
+
closePanel();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function visibleActions() {
|
|
462
|
+
// 拍照动作仅安卓运行时展示(桌面 input[capture] 会被忽略、退化为普通
|
|
463
|
+
// 文件选择,徒增困惑);能力协商未就绪时按内置默认展示。
|
|
464
|
+
const photoVisible = !caps.ready || caps.android === true;
|
|
465
|
+
return ACTIONS.filter((a) => caps.enabled.has(a.id) && (a.id !== 'take-photo' || photoVisible));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------
|
|
469
|
+
// 指令接管:从原生指令菜单采集列表 + 官方裁决通道执行
|
|
470
|
+
// ---------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
function findNativePopup() {
|
|
473
|
+
// 0.2.1 的指令菜单根是 [role="listbox"] 且内含 [role="option"];
|
|
474
|
+
// 页面上可能有其他空 listbox(选择器/树组件),取**含 option 项**的那个。
|
|
475
|
+
const boxes = document.querySelectorAll('[role="listbox"]');
|
|
476
|
+
for (const lb of boxes) {
|
|
477
|
+
if (lb.querySelector('[role="option"]')) return lb;
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* 从原生菜单 option 元素解析 {name, label, desc}。
|
|
484
|
+
* 0.2.1 option 结构:span(svg) + span(中文标签) + span(别名) [+ span(描述)],
|
|
485
|
+
* 例如 压缩/compact/压缩以上对话内容。name 取斜杠别名(/compact),label
|
|
486
|
+
* 取中文标签,desc 取描述。
|
|
487
|
+
*/
|
|
488
|
+
function parseCommandOption(item) {
|
|
489
|
+
const spans = Array.from(item.querySelectorAll('span'))
|
|
490
|
+
.map((s) => (s.textContent || '').trim())
|
|
491
|
+
.filter(Boolean);
|
|
492
|
+
if (spans.length === 0) return null;
|
|
493
|
+
const aliasRe = /^[a-z0-9_:-]+$/i;
|
|
494
|
+
const label = spans[0];
|
|
495
|
+
let name = label;
|
|
496
|
+
let desc = '';
|
|
497
|
+
if (spans.length >= 2 && aliasRe.test(spans[1])) {
|
|
498
|
+
name = spans[1];
|
|
499
|
+
desc = spans.length >= 3 ? spans[spans.length - 1] : '';
|
|
500
|
+
} else {
|
|
501
|
+
desc = spans.length >= 2 ? spans[spans.length - 1] : '';
|
|
502
|
+
}
|
|
503
|
+
if (!desc || desc === name) desc = label !== name ? label : '';
|
|
504
|
+
return { name, label, desc };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* 采集后收尾:恢复可见性并关掉原生菜单。注意顺序——**必须等原生菜单
|
|
509
|
+
* 关闭动作全部派发完才把 harvesting 置回 false**,否则我们派发的 Escape
|
|
510
|
+
* 会被自己的面板 handler 收到导致面板关闭。
|
|
511
|
+
*/
|
|
512
|
+
function dismissNativeMenu(popupEl, btn) {
|
|
513
|
+
try {
|
|
514
|
+
if (popupEl) popupEl.style.visibility = '';
|
|
515
|
+
} catch (_) { /* 静默 */ }
|
|
516
|
+
try {
|
|
517
|
+
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
518
|
+
} catch (_) { /* 静默 */ }
|
|
519
|
+
setTimeout(() => {
|
|
520
|
+
try {
|
|
521
|
+
if (findNativePopup()) {
|
|
522
|
+
// Escape 未生效(焦点不在菜单)→ 再点一次触发按钮做 toggle 关闭。
|
|
523
|
+
if (btn && document.contains(btn)) btn.click();
|
|
524
|
+
}
|
|
525
|
+
} catch (_) { /* 静默 */ }
|
|
526
|
+
setTimeout(() => {
|
|
527
|
+
try {
|
|
528
|
+
const rest = findNativePopup();
|
|
529
|
+
if (rest) {
|
|
530
|
+
// 兜底:直接藏掉浮层(官方菜单自身状态可能已收起,仅残留 DOM)。
|
|
531
|
+
const wrapper = rest.closest('[data-radix-popper-content-wrapper]') || rest;
|
|
532
|
+
wrapper.style.display = 'none';
|
|
533
|
+
}
|
|
534
|
+
} catch (_) { /* 静默 */ }
|
|
535
|
+
panelState.harvesting = false;
|
|
536
|
+
notifyPanel();
|
|
537
|
+
}, 140);
|
|
538
|
+
}, 60);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* 采集指令表:程序化打开原生菜单 → 轮询等浮层渲染 → 隐蔽读取 option
|
|
543
|
+
* 列表 → 关掉原生菜单。采集期间 panelState.harvesting=true(抑制面板的
|
|
544
|
+
* outside-click / Escape 关闭)。
|
|
545
|
+
*/
|
|
546
|
+
function harvestCommands() {
|
|
547
|
+
if (panelState.harvesting) return;
|
|
548
|
+
const btn = findCommandButton(document);
|
|
549
|
+
if (!btn) {
|
|
550
|
+
warnMissing('官方调用指令按钮');
|
|
551
|
+
// 已有表(通常来自 RPC 全量)不覆写;仅在从未加载过时标记为空。
|
|
552
|
+
if (!Array.isArray(panelState.commands)) panelState.commands = [];
|
|
553
|
+
notifyPanel();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
panelState.harvesting = true;
|
|
557
|
+
notifyPanel();
|
|
558
|
+
let popupEl = null;
|
|
559
|
+
try {
|
|
560
|
+
btn.click();
|
|
561
|
+
} catch (error) {
|
|
562
|
+
console.warn('[dsh-mobile-compose] 打开原生指令菜单失败:', error);
|
|
563
|
+
panelState.harvesting = false;
|
|
564
|
+
notifyPanel();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const finishRead = (popup) => {
|
|
568
|
+
try {
|
|
569
|
+
const items = (popup || document).querySelectorAll('[role="option"]');
|
|
570
|
+
const collected = [];
|
|
571
|
+
for (const item of items) {
|
|
572
|
+
const parsed = parseCommandOption(item);
|
|
573
|
+
if (parsed && parsed.name) collected.push(parsed);
|
|
574
|
+
}
|
|
575
|
+
if (Array.isArray(panelState.commands) && panelState.commands.length > 0) {
|
|
576
|
+
// RPC 全量已在场:用 DOM 顺序(官方菜单顺序)+ 中文标签重排;
|
|
577
|
+
// RPC 里有、菜单里没有的条目追加在后。
|
|
578
|
+
const byName = new Map(panelState.commands.map((c) => [c.name, c]));
|
|
579
|
+
const ordered = [];
|
|
580
|
+
for (const c of collected) {
|
|
581
|
+
const hit = byName.get(c.name);
|
|
582
|
+
if (hit) {
|
|
583
|
+
// locale 已有权威标签(内置指令)时不覆写;其余用菜单里的中文。
|
|
584
|
+
if (c.label && c.label !== c.name && !localizedCommandLabel(c.name)) hit.label = c.label;
|
|
585
|
+
if (c.desc) hit.desc = c.desc;
|
|
586
|
+
ordered.push(hit);
|
|
587
|
+
byName.delete(c.name);
|
|
588
|
+
} else {
|
|
589
|
+
ordered.push(c);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
panelState.commands = ordered.concat(Array.from(byName.values()));
|
|
593
|
+
panelState.labelsEnriched = true;
|
|
594
|
+
} else {
|
|
595
|
+
panelState.commands = collected;
|
|
596
|
+
}
|
|
597
|
+
} catch (error) {
|
|
598
|
+
console.warn('[dsh-mobile-compose] 指令列表采集失败:', error);
|
|
599
|
+
if (!Array.isArray(panelState.commands)) panelState.commands = null;
|
|
600
|
+
}
|
|
601
|
+
dismissNativeMenu(popupEl, btn);
|
|
602
|
+
};
|
|
603
|
+
// popup 渲染是异步的:轮询至多 1.6s,出现含 option 的 listbox 即读。
|
|
604
|
+
const started = Date.now();
|
|
605
|
+
const poll = () => {
|
|
606
|
+
const popup = findNativePopup();
|
|
607
|
+
if (popup) {
|
|
608
|
+
if (popupEl !== popup) {
|
|
609
|
+
popupEl = popup;
|
|
610
|
+
try { popup.style.visibility = 'hidden'; } catch (_) { /* 静默 */ } // 采集不闪原生菜单
|
|
611
|
+
}
|
|
612
|
+
if (popup.querySelectorAll('[role="option"]').length > 0) { finishRead(popup); return; }
|
|
613
|
+
}
|
|
614
|
+
if (Date.now() - started < 1600) setTimeout(poll, 80);
|
|
615
|
+
else finishRead(popupEl);
|
|
616
|
+
};
|
|
617
|
+
setTimeout(poll, 50);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function notifyPanel() {
|
|
621
|
+
for (const listener of Array.from(panelState.listeners)) {
|
|
622
|
+
try { listener(); } catch (_) { /* 静默 */ }
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---------------------------------------------------------------------
|
|
627
|
+
// 指令表来源:官方 RPC remote.commands.list(sessionId) 为全量权威来源;
|
|
628
|
+
// 原生菜单 DOM 采集仅作兜底 + 中文标签补全(菜单是上下文过滤的 UI,
|
|
629
|
+
// 只能看到前若干项)。
|
|
630
|
+
// ---------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
let clientCtxRef = null;
|
|
633
|
+
let remoteCommandsRef = null;
|
|
634
|
+
let remoteSkillsRef = null;
|
|
635
|
+
let sessionsRef = null;
|
|
636
|
+
|
|
637
|
+
// ---------------------------------------------------------------------
|
|
638
|
+
// 中文标签:官方客户端 locale(`command` namespace)是内置指令标签的权威
|
|
639
|
+
// 来源(label.goal → 目标 / label.compact → 压缩 …)。RPC `commands.list`
|
|
640
|
+
// 只给英文 description,没有 label,所以缺了 locale 就只能显示斜杠别名。
|
|
641
|
+
// 非内置指令(/model、插件贡献的指令)由原生菜单采集补全。
|
|
642
|
+
// ---------------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
let commandLabelT = null;
|
|
645
|
+
|
|
646
|
+
function localizedCommandLabel(name) {
|
|
647
|
+
if (!commandLabelT || typeof name !== 'string' || !name) return '';
|
|
648
|
+
try {
|
|
649
|
+
const key = `label.${name}`;
|
|
650
|
+
const text = commandLabelT(key);
|
|
651
|
+
return typeof text === 'string' && text !== key ? text : '';
|
|
652
|
+
} catch (_) {
|
|
653
|
+
return '';
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** 用 locale 覆盖已知的内置指令标签(locale 切换 / 注入就绪时幂等重跑)。 */
|
|
658
|
+
function relabelCommands() {
|
|
659
|
+
const list = panelState.commands;
|
|
660
|
+
if (!Array.isArray(list) || list.length === 0) return;
|
|
661
|
+
let changed = false;
|
|
662
|
+
for (const cmd of list) {
|
|
663
|
+
const label = localizedCommandLabel(cmd.name);
|
|
664
|
+
if (label && cmd.label !== label) {
|
|
665
|
+
cmd.label = label;
|
|
666
|
+
changed = true;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (changed) notifyPanel();
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* 官方 RPC 需在会话保留上下文内调用(与官方 ui-commands / ui-skill 一致:
|
|
674
|
+
* sessions.using(sessionId, {source}, fn))。服务缺席时直接调用。
|
|
675
|
+
*/
|
|
676
|
+
async function withSession(sessionId, source, fn) {
|
|
677
|
+
const sessions = sessionsRef;
|
|
678
|
+
if (!sessions || typeof sessions.using !== 'function') return fn();
|
|
679
|
+
return sessions.using(sessionId, { source }, async (reference) => {
|
|
680
|
+
const state = reference && reference.binding && reference.binding.session.getSnapshot();
|
|
681
|
+
if (state && state.openState !== 'open') throw state.openError || new Error('session not open');
|
|
682
|
+
return fn();
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** 拉全量指令表。成功返回 true 并写入 panelState.commands。 */
|
|
687
|
+
async function fetchCommandsFromRemote(sessionId) {
|
|
688
|
+
const commands = remoteCommandsRef;
|
|
689
|
+
if (!commands || typeof commands.list !== 'function' || !sessionId) return false;
|
|
690
|
+
try {
|
|
691
|
+
const result = await withSession(sessionId, 'mobileComposeCommands', () => commands.list(sessionId));
|
|
692
|
+
if (!result || result.ok !== true || !Array.isArray(result.value)) return false;
|
|
693
|
+
const prev = new Map((panelState.commands || []).map((c) => [c.name, c]));
|
|
694
|
+
panelState.commands = result.value.map((c) => {
|
|
695
|
+
const hit = prev.get(c.name);
|
|
696
|
+
return {
|
|
697
|
+
name: c.name,
|
|
698
|
+
label: localizedCommandLabel(c.name) || (hit && hit.label) || c.label || c.name,
|
|
699
|
+
desc: (hit && hit.desc) || c.description || '',
|
|
700
|
+
// 官方 dispatch 决策表的依据:descriptor.input 存在 → 菜单点选走
|
|
701
|
+
// claim(写草稿等参数);缺失 → 裸指令(Enter 通道分离执行)。
|
|
702
|
+
hasInput: c.input !== void 0,
|
|
703
|
+
hint: (c.input && c.input.hint) || '',
|
|
704
|
+
};
|
|
705
|
+
});
|
|
706
|
+
panelState.commandsFromRemote = true;
|
|
707
|
+
notifyPanel();
|
|
708
|
+
return true;
|
|
709
|
+
} catch (_) {
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** 技能目录:remote.skills.list({sessionId}) → [{name, description, modelInvocable}]。 */
|
|
715
|
+
async function fetchSkillsFromRemote(sessionId) {
|
|
716
|
+
const skills = remoteSkillsRef;
|
|
717
|
+
if (!skills || typeof skills.list !== 'function' || !sessionId) return false;
|
|
718
|
+
try {
|
|
719
|
+
const result = await withSession(sessionId, 'mobileComposeSkills', () => skills.list({ sessionId }));
|
|
720
|
+
if (!result || result.ok !== true || !result.value || !Array.isArray(result.value.skills)) return false;
|
|
721
|
+
panelState.skills = result.value.skills.map((s) => ({
|
|
722
|
+
name: s.name,
|
|
723
|
+
desc: s.description || '',
|
|
724
|
+
userOnly: s.modelInvocable === false,
|
|
725
|
+
}));
|
|
726
|
+
notifyPanel();
|
|
727
|
+
return true;
|
|
728
|
+
} catch (_) {
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/** 指令 + 技能加载入口:RPC 优先,失败降级 DOM 采集。 */
|
|
733
|
+
function loadCommands(sessionId) {
|
|
734
|
+
const sid = sessionId || panelState.sessionId;
|
|
735
|
+
// sessionId 尚未由 slot props 带来:等面板挂载时再加载。
|
|
736
|
+
if (!sid) return;
|
|
737
|
+
if (panelState.loadingCommands) return;
|
|
738
|
+
panelState.loadingCommands = true;
|
|
739
|
+
Promise.all([
|
|
740
|
+
fetchCommandsFromRemote(sid),
|
|
741
|
+
fetchSkillsFromRemote(sid),
|
|
742
|
+
]).then(([ok]) => {
|
|
743
|
+
panelState.loadingCommands = false;
|
|
744
|
+
// 两者都拿不到(老版本/子会话)→ DOM 采集兜底。
|
|
745
|
+
const anyRemote = ok || Array.isArray(panelState.skills);
|
|
746
|
+
if (!ok && panelState.commands === null && !panelState.harvesting) harvestCommands();
|
|
747
|
+
// RPC 成功时仍做一次隐蔽采集,仅用于补全中文标签(不改变列表)。
|
|
748
|
+
else if (anyRemote && !panelState.harvesting && !panelState.labelsEnriched) harvestCommands();
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** 官方 claim 用的本地化 token(token.goal → 目标 …);无翻译时回退指令名。 */
|
|
753
|
+
function commandToken(name) {
|
|
754
|
+
if (!commandLabelT || typeof name !== 'string' || !name) return name;
|
|
755
|
+
try {
|
|
756
|
+
const key = `token.${name}`;
|
|
757
|
+
const text = commandLabelT(key);
|
|
758
|
+
return typeof text === 'string' && text !== key ? text : name;
|
|
759
|
+
} catch (_) {
|
|
760
|
+
return name;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* 执行指令 —— 与官方「/ 菜单点选」决策表保持一致(ui-commands dispatch):
|
|
766
|
+
* - 带 input 的宿主指令 → claim:只把 `/token `(本地化拼写)写进草稿,
|
|
767
|
+
* 用户补完参数后自行发送(**绝不代发**);
|
|
768
|
+
* - 裸指令 / 客户端贡献(popupSelect 等)→ Enter 通道:setDraft('/x') +
|
|
769
|
+
* submit(),由官方输入机按裸 token 裁决(分离执行 / 打开弹层)。
|
|
770
|
+
* 表来源缺失(纯 DOM 采集)时一律按 claim 处理,宁可让用户手动发送。
|
|
771
|
+
*/
|
|
772
|
+
function runCommand(cmd) {
|
|
773
|
+
if (swallowDerivedClick()) return;
|
|
774
|
+
haptic(8);
|
|
775
|
+
const ia = panelState.inputActions;
|
|
776
|
+
if (cmd.hasInput !== false) {
|
|
777
|
+
closePanel();
|
|
778
|
+
if (ia && typeof ia.setDraft === 'function') {
|
|
779
|
+
try {
|
|
780
|
+
ia.setDraft('/' + commandToken(cmd.name) + ' ');
|
|
781
|
+
return;
|
|
782
|
+
} catch (error) {
|
|
783
|
+
console.warn('[dsh-mobile-compose] 指令写入草稿失败:', error);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
warnMissing('官方草稿通道(setDraft)');
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (ia && typeof ia.setDraft === 'function' && typeof ia.submit === 'function') {
|
|
790
|
+
closePanel();
|
|
791
|
+
try {
|
|
792
|
+
ia.setDraft('/' + cmd.name);
|
|
793
|
+
ia.submit();
|
|
794
|
+
} catch (error) {
|
|
795
|
+
console.warn('[dsh-mobile-compose] 指令提交失败:', error);
|
|
796
|
+
}
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
fallbackNativeCommand(cmd);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* 执行技能:与官方 `/` 菜单一致 —— 只把 `/name ` 写进草稿(不提交),
|
|
804
|
+
* 用户可继续补参数后自行发送。
|
|
805
|
+
*/
|
|
806
|
+
function runSkill(skill) {
|
|
807
|
+
if (swallowDerivedClick()) return;
|
|
808
|
+
haptic(8);
|
|
809
|
+
const ia = panelState.inputActions;
|
|
810
|
+
closePanel();
|
|
811
|
+
if (ia && typeof ia.setDraft === 'function') {
|
|
812
|
+
try {
|
|
813
|
+
ia.setDraft('/' + skill.name + ' ');
|
|
814
|
+
return;
|
|
815
|
+
} catch (error) {
|
|
816
|
+
console.warn('[dsh-mobile-compose] 技能写入草稿失败:', error);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
warnMissing('官方草稿通道(setDraft)');
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** 回退:原生菜单点选(开菜单 → 找到同名 option → 点击)。 */
|
|
823
|
+
function fallbackNativeCommand(cmd) {
|
|
824
|
+
const btn = findCommandButton(document);
|
|
825
|
+
if (!btn) { warnMissing('官方调用指令按钮'); return; }
|
|
826
|
+
closePanel();
|
|
827
|
+
try {
|
|
828
|
+
btn.click();
|
|
829
|
+
} catch (_) { return; }
|
|
830
|
+
setTimeout(() => {
|
|
831
|
+
try {
|
|
832
|
+
const items = document.querySelectorAll('[role="option"]');
|
|
833
|
+
for (const item of items) {
|
|
834
|
+
const parsed = parseCommandOption(item);
|
|
835
|
+
if (parsed && parsed.name === cmd.name) {
|
|
836
|
+
item.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
841
|
+
} catch (_) { /* 静默 */ }
|
|
842
|
+
}, 60);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** ⌈n/2⌉:固定 2 行时所需的列数。 */
|
|
846
|
+
function ceil2(n) { return Math.ceil(n / 2); }
|
|
847
|
+
|
|
848
|
+
/** 动作 id → 图标 key(历史命名差异:pick-image vs image 等)。 */
|
|
849
|
+
const ICON_KEY = { 'pick-image': 'image', 'take-photo': 'camera', 'pick-file': 'file', command: 'command' };
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* 面板自带的三个 file 输入 —— 都是同一个 file 通道的不同视图:
|
|
853
|
+
* pick-image 仅图片后缀(accept 过滤)
|
|
854
|
+
* take-photo 拍照(capture=environment,仅安卓展示)
|
|
855
|
+
* pick-file 全部后缀(accept 留空)
|
|
856
|
+
* 选择结果统一由 ownInputChange 注入官方隐藏 input。
|
|
857
|
+
*/
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* accept 按端适配(v1.4.27 安卓 host 适配)。
|
|
861
|
+
*
|
|
862
|
+
* DSH Android v1.4.27 起,host 的 `onShowFileChooser` 对「accept 全为
|
|
863
|
+
* 图片 MIME」的请求改走**系统 Photo Picker**(上游 issue #54:缩略图
|
|
864
|
+
* 网格、原生多选、免存储权限)。判据是 `acceptTypes` 每个条目都以
|
|
865
|
+
* `image/` 开头——扩展名条目(`.jpg` 等)会使判定失败。
|
|
866
|
+
*
|
|
867
|
+
* - pick-image(安卓):accept 收敛为纯 MIME `'image/*'` → 命中
|
|
868
|
+
* Photo Picker(多选由 host 按 multiple 模式放行);旧版 host 与
|
|
869
|
+
* 桌面则走 `createIntent()` / 原生对话框,`image/*` 过滤同样成立,
|
|
870
|
+
* 无行为回归。
|
|
871
|
+
* - take-photo(安卓):**保留** MIME+扩展名混写 —— 混入扩展名让 host
|
|
872
|
+
* 的全图片判定失败、回落 `createIntent()`,`capture` 属性得以保留
|
|
873
|
+
* (照常拉起相机,而不是被 Photo Picker 抢走)。
|
|
874
|
+
* - 桌面端维持原样(图片 mime + 后缀,供原生文件对话框按后缀过滤)。
|
|
875
|
+
*/
|
|
876
|
+
function inputAccept(id) {
|
|
877
|
+
if (caps.android === true && id === 'pick-image') return 'image/*';
|
|
878
|
+
const action = ACTIONS.find((a) => a.id === id) || {};
|
|
879
|
+
return action.accept || undefined;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function renderOwnInputs(refs) {
|
|
883
|
+
const make = (id, extra) => h('input', {
|
|
884
|
+
key: id,
|
|
885
|
+
ref: refs[id],
|
|
886
|
+
type: 'file',
|
|
887
|
+
accept: inputAccept(id),
|
|
888
|
+
multiple: ACTIONS.find((a) => a.id === id)?.multiple !== false,
|
|
889
|
+
hidden: true,
|
|
890
|
+
'data-dmc-input': 'true',
|
|
891
|
+
'data-dmc-role': id,
|
|
892
|
+
onChange: ownInputChange,
|
|
893
|
+
...extra,
|
|
894
|
+
});
|
|
895
|
+
return [
|
|
896
|
+
make('pick-image'),
|
|
897
|
+
make('take-photo', { multiple: false, capture: 'environment' }),
|
|
898
|
+
make('pick-file'),
|
|
899
|
+
];
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ---------------------------------------------------------------------
|
|
903
|
+
// 插槽组件 1:「+」入口(conversation.input.left,占原生两键原位置)
|
|
904
|
+
// ---------------------------------------------------------------------
|
|
905
|
+
|
|
906
|
+
function PlusEntry(props) {
|
|
907
|
+
useExternalState();
|
|
908
|
+
const triggerRef = useRef(null);
|
|
909
|
+
|
|
910
|
+
// 把触发元素登记到模块级状态:dock 面板的「点击外部关闭」用它排除
|
|
911
|
+
// 点击自己;文件回流的作用域查找也从这里出发。
|
|
912
|
+
useEffect(() => {
|
|
913
|
+
panelState.triggerEl = triggerRef.current;
|
|
914
|
+
if (props && props.sessionId) panelState.sessionId = props.sessionId;
|
|
915
|
+
return () => {
|
|
916
|
+
if (panelState.triggerEl === triggerRef.current) panelState.triggerEl = null;
|
|
917
|
+
};
|
|
918
|
+
}, []);
|
|
919
|
+
|
|
920
|
+
return h(react.Fragment, null,
|
|
921
|
+
h('button', {
|
|
922
|
+
ref: triggerRef,
|
|
923
|
+
type: 'button',
|
|
924
|
+
className: 'dmc-entry-btn' + (panelState.open ? ' dmc-open' : ''),
|
|
925
|
+
'aria-label': t('entry.label'),
|
|
926
|
+
'data-dmc-entry': 'true',
|
|
927
|
+
'aria-haspopup': 'menu',
|
|
928
|
+
'aria-expanded': panelState.open,
|
|
929
|
+
title: t('entry.title'),
|
|
930
|
+
onClick: () => (panelState.open ? closePanel() : openPanel()),
|
|
931
|
+
}, ICONS.plus()),
|
|
932
|
+
// 面板由入口组件自己渲染(见 ComposeDockEntry 注释:不依赖
|
|
933
|
+
// composer.dock 插槽——hero 态下该插槽不渲染)。官方标准 props
|
|
934
|
+
// (inputActions / useInput)透传给面板用于指令执行。
|
|
935
|
+
panelState.open ? h(ComposeDockEntry, props) : null,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// ---------------------------------------------------------------------
|
|
940
|
+
// 插槽组件 2:动作面板(由「+」入口组件内渲染;不依赖 composer.dock 插槽——
|
|
941
|
+
// 该插槽只在 variant==="composer" 时渲染,新建/空会话的 hero 态下官方不渲染,
|
|
942
|
+
// 会导致点「+」无反应。dock 形态 portal 到官方 [data-composer-dock] 容器,
|
|
943
|
+
// 该容器两种 variant 都存在,且天然在文档流内、位于对话框正下方。)
|
|
944
|
+
// ---------------------------------------------------------------------
|
|
945
|
+
|
|
946
|
+
function ComposeDockEntry(props) {
|
|
947
|
+
useExternalState();
|
|
948
|
+
const dockRef = useRef(null);
|
|
949
|
+
const imgInputRef = useRef(null);
|
|
950
|
+
const photoInputRef = useRef(null);
|
|
951
|
+
const fileInputRef = useRef(null);
|
|
952
|
+
const ownInputs = { 'pick-image': imgInputRef, 'take-photo': photoInputRef, 'pick-file': fileInputRef };
|
|
953
|
+
const [page, setPage] = useState(panelState.pageIndex);
|
|
954
|
+
const touchStart = useRef(null);
|
|
955
|
+
// 桌面端滚轮:累积增量 + 冷却锁,避免一次滚动连翻多页。
|
|
956
|
+
const wheelState = useRef({ acc: 0, lockUntil: 0, node: null });
|
|
957
|
+
const wheelHandler = useRef(null);
|
|
958
|
+
// 稳定的原生监听函数(每次 render 更新 handler,监听器身份不变)。
|
|
959
|
+
const wheelListener = useRef((event) => {
|
|
960
|
+
const fn = wheelHandler.current;
|
|
961
|
+
if (fn) fn(event);
|
|
962
|
+
});
|
|
963
|
+
// 仅用于「输入框尺寸变化(窗口缩放 / 侧栏开合 / 切换会话)」时强制重算
|
|
964
|
+
// 面板宽度,保证与输入框始终等宽。
|
|
965
|
+
const [layoutTick, setLayoutTick] = useState(0);
|
|
966
|
+
|
|
967
|
+
// 登记官方 session 标准件 + sessionId,并加载全量指令表
|
|
968
|
+
// (RPC 优先,DOM 采集兜底/补标签)。
|
|
969
|
+
useEffect(() => {
|
|
970
|
+
if (props && props.inputActions) panelState.inputActions = props.inputActions;
|
|
971
|
+
if (props && props.sessionId) panelState.sessionId = props.sessionId;
|
|
972
|
+
loadCommands(panelState.sessionId);
|
|
973
|
+
// 自带 file 输入登记到模块级状态:动作瓦片点击时按 id 取对应 input
|
|
974
|
+
// (面板随开随卸,卸载即注销)。
|
|
975
|
+
panelState.ownInputs = {
|
|
976
|
+
'pick-image': imgInputRef.current,
|
|
977
|
+
'take-photo': photoInputRef.current,
|
|
978
|
+
'pick-file': fileInputRef.current,
|
|
979
|
+
};
|
|
980
|
+
return () => { panelState.ownInputs = null; };
|
|
981
|
+
}, []);
|
|
982
|
+
|
|
983
|
+
const switchPage = useCallback((next) => {
|
|
984
|
+
panelState.pageIndex = next;
|
|
985
|
+
setPage(next);
|
|
986
|
+
}, []);
|
|
987
|
+
|
|
988
|
+
const open = panelState.open;
|
|
989
|
+
|
|
990
|
+
// 关闭路径在 closePanel() 统一重置页码(面板随开随卸,卸载后 effect 不跑)。
|
|
991
|
+
|
|
992
|
+
// 开合期副作用:返回键关闭 + Esc / 点击面板外关闭。采集期间不因 outside-click 误关。
|
|
993
|
+
useEffect(() => {
|
|
994
|
+
if (!open) return;
|
|
995
|
+
try { window.history.pushState({ 'dsh-mobile-compose': Date.now() }, ''); } catch (_) { /* 静默 */ }
|
|
996
|
+
const onPop = () => setPanelOpen(false);
|
|
997
|
+
window.addEventListener('popstate', onPop);
|
|
998
|
+
return () => window.removeEventListener('popstate', onPop);
|
|
999
|
+
}, [open]);
|
|
1000
|
+
|
|
1001
|
+
useEffect(() => {
|
|
1002
|
+
if (!open) return;
|
|
1003
|
+
const bump = () => setLayoutTick((n) => n + 1);
|
|
1004
|
+
window.addEventListener('resize', bump);
|
|
1005
|
+
let ro = null;
|
|
1006
|
+
const trigger = panelState.triggerEl;
|
|
1007
|
+
const card = trigger && typeof trigger.closest === 'function'
|
|
1008
|
+
? trigger.closest('[data-composer-card]')
|
|
1009
|
+
: null;
|
|
1010
|
+
if (card && typeof ResizeObserver === 'function') {
|
|
1011
|
+
ro = new ResizeObserver(bump);
|
|
1012
|
+
try { ro.observe(card); } catch (_) { /* 静默 */ }
|
|
1013
|
+
}
|
|
1014
|
+
return () => {
|
|
1015
|
+
window.removeEventListener('resize', bump);
|
|
1016
|
+
if (ro) { try { ro.disconnect(); } catch (_) { /* 静默 */ } }
|
|
1017
|
+
};
|
|
1018
|
+
}, [open]);
|
|
1019
|
+
|
|
1020
|
+
useEffect(() => {
|
|
1021
|
+
if (!open) return;
|
|
1022
|
+
// 官方 dock 容器是 flex row:会话进度等其他插件条目会和本面板挤在
|
|
1023
|
+
// 同一行互相压挤。开启期间让容器允许换行 + 面板独占一行(flex-basis
|
|
1024
|
+
// 100%、order -1 排最前)→ 面板在第一行,进度等条目换行到最后一行。
|
|
1025
|
+
const host = document.querySelector('[data-composer-dock]');
|
|
1026
|
+
if (!host) return;
|
|
1027
|
+
const prevWrap = host.style.flexWrap || '';
|
|
1028
|
+
host.style.flexWrap = 'wrap';
|
|
1029
|
+
return () => { host.style.flexWrap = prevWrap; };
|
|
1030
|
+
}, [open]);
|
|
1031
|
+
|
|
1032
|
+
useEffect(() => {
|
|
1033
|
+
if (!open) return;
|
|
1034
|
+
const onDown = (event) => {
|
|
1035
|
+
if (panelState.harvesting) return;
|
|
1036
|
+
const target = event.target;
|
|
1037
|
+
if (dockRef.current?.contains?.(target)) return;
|
|
1038
|
+
if (panelState.triggerEl?.contains?.(target)) return;
|
|
1039
|
+
closePanel();
|
|
1040
|
+
};
|
|
1041
|
+
const onKey = (event) => { if (event.key === 'Escape' && !panelState.harvesting) closePanel(); };
|
|
1042
|
+
document.addEventListener('mousedown', onDown, true);
|
|
1043
|
+
document.addEventListener('keydown', onKey);
|
|
1044
|
+
return () => {
|
|
1045
|
+
document.removeEventListener('mousedown', onDown, true);
|
|
1046
|
+
document.removeEventListener('keydown', onKey);
|
|
1047
|
+
};
|
|
1048
|
+
}, [open]);
|
|
1049
|
+
|
|
1050
|
+
if (!open) return null;
|
|
1051
|
+
|
|
1052
|
+
// ---- 布局:面板与聊天框等宽,固定 2 行,列数按宽度自动计算 ----
|
|
1053
|
+
|
|
1054
|
+
// 聊天框宽度:**以官方输入卡片 `[data-composer-card]` 的外框为准**,
|
|
1055
|
+
// 保证面板与输入框严格等宽、左右边缘对齐(而不是量某个祖先容器——那个
|
|
1056
|
+
// 容器含 side-clearance 内边距,比卡片宽,会把面板撑大)。
|
|
1057
|
+
// 兜底:向上找一个宽度合理的祖先;再兜底给定值。
|
|
1058
|
+
function composerWidth() {
|
|
1059
|
+
const trigger = panelState.triggerEl;
|
|
1060
|
+
const card = trigger && typeof trigger.closest === 'function'
|
|
1061
|
+
? trigger.closest('[data-composer-card]')
|
|
1062
|
+
: null;
|
|
1063
|
+
let w = 0;
|
|
1064
|
+
if (card) {
|
|
1065
|
+
const rect = card.getBoundingClientRect();
|
|
1066
|
+
if (rect.width >= 200) w = rect.width;
|
|
1067
|
+
}
|
|
1068
|
+
if (!w) {
|
|
1069
|
+
let el = trigger;
|
|
1070
|
+
for (let hop = 0; el && hop < 10; hop++) {
|
|
1071
|
+
if (el.clientWidth >= 320 && el.clientWidth <= window.innerWidth - 32) { w = el.clientWidth; break; }
|
|
1072
|
+
el = el.parentElement;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
if (!w) {
|
|
1076
|
+
const host = document.querySelector('[data-composer-dock]');
|
|
1077
|
+
w = host && host.clientWidth >= 320 ? host.clientWidth : Math.min(560, window.innerWidth - 24);
|
|
1078
|
+
}
|
|
1079
|
+
// 只做窗口级兜底:**不能**用 dock 容器的宽度做上限——容器的宽度本身
|
|
1080
|
+
// 由面板内容撑开(flex 收缩到内容宽),拿它当上限会和面板宽度互相
|
|
1081
|
+
// 锁死(窗口放大后面板永远停在旧宽度)。
|
|
1082
|
+
return Math.round(Math.max(200, Math.min(w, window.innerWidth - 16)));
|
|
1083
|
+
}
|
|
1084
|
+
void layoutTick; // 尺寸变化的重渲染触发点
|
|
1085
|
+
const width = composerWidth();
|
|
1086
|
+
// 单列最小 88px:按面板宽度算出列数上限(2-6 列)。
|
|
1087
|
+
const maxCols = width > 0 ? Math.max(2, Math.min(6, Math.floor(width / 88))) : 4;
|
|
1088
|
+
|
|
1089
|
+
// ---- 单一列表:常用动作在前,指令紧随其后;超出容量自动分页 ----
|
|
1090
|
+
const mains = visibleActions().filter((a) => a.kind !== 'command');
|
|
1091
|
+
const commandsOn = caps.enabled.has('command');
|
|
1092
|
+
const allCommands = commandsOn && Array.isArray(panelState.commands) ? panelState.commands : [];
|
|
1093
|
+
// 剔除与内置动作同义的指令(默认 /file —— 图片 / 文件 已覆盖)。
|
|
1094
|
+
const commands = allCommands.filter((c) => !caps.hiddenCommands.has(c.name));
|
|
1095
|
+
// 单页容量 = maxCols × 2 行(固定 2 行)。
|
|
1096
|
+
const capacity = maxCols * 2;
|
|
1097
|
+
const entries = [
|
|
1098
|
+
...mains.map((a) => ({ kind: 'action', key: a.id, action: a })),
|
|
1099
|
+
...commands.map((c) => ({ kind: 'command', key: 'cmd:' + c.name, cmd: c })),
|
|
1100
|
+
...(commandsOn && Array.isArray(panelState.skills)
|
|
1101
|
+
? panelState.skills.map((s) => ({ kind: 'skill', key: 'skill:' + s.name, skill: s }))
|
|
1102
|
+
: []),
|
|
1103
|
+
];
|
|
1104
|
+
// 采集未完成/为空时,末尾给一个「读取/重读指令」入口(不占满整页)。
|
|
1105
|
+
if (commandsOn && !panelState.harvesting && panelState.commands !== null && commands.length === 0) {
|
|
1106
|
+
entries.push({ kind: 'reload', key: 'cmd:reload' });
|
|
1107
|
+
}
|
|
1108
|
+
const pages = [];
|
|
1109
|
+
for (let i = 0; i < Math.max(1, entries.length); i += capacity) {
|
|
1110
|
+
pages.push(entries.slice(i, i + capacity));
|
|
1111
|
+
}
|
|
1112
|
+
const safePage = Math.min(page, pages.length - 1);
|
|
1113
|
+
const pageEntries = pages[safePage] || [];
|
|
1114
|
+
|
|
1115
|
+
// ---- 换页交互:移动端左右滑,桌面端滚轮 ----
|
|
1116
|
+
// 移动端:横向位移 ≥40px 且明显大于纵向位移(斜向滚动不误翻页)。
|
|
1117
|
+
// 纵向手势不接管:`.dmc-swipe{touch-action:pan-y}` 把纵向留给页面滚动。
|
|
1118
|
+
const SWIPE_X = 40;
|
|
1119
|
+
const onTouchStart = (event) => {
|
|
1120
|
+
const touch = event.touches && event.touches[0];
|
|
1121
|
+
touchStart.current = touch ? { x: touch.clientX, y: touch.clientY } : null;
|
|
1122
|
+
};
|
|
1123
|
+
const onTouchCancel = () => { touchStart.current = null; };
|
|
1124
|
+
const onTouchEnd = (event) => {
|
|
1125
|
+
const start = touchStart.current;
|
|
1126
|
+
touchStart.current = null;
|
|
1127
|
+
const touch = event.changedTouches && event.changedTouches[0];
|
|
1128
|
+
if (!start || !touch) return;
|
|
1129
|
+
const dx = touch.clientX - start.x;
|
|
1130
|
+
const dy = touch.clientY - start.y;
|
|
1131
|
+
const absX = Math.abs(dx);
|
|
1132
|
+
if (absX < SWIPE_X || absX <= Math.abs(dy)) return;
|
|
1133
|
+
const next = safePage + (dx < 0 ? 1 : -1);
|
|
1134
|
+
if (next < 0 || next >= pages.length) return;
|
|
1135
|
+
// 同一次触摸可能在 touchend 后继续派发 click(部分 WebView):
|
|
1136
|
+
// 短窗口内吞掉瓦片点击,避免「翻页顺手执行了一条指令」。
|
|
1137
|
+
panelState.swallowClickUntil = Date.now() + 350;
|
|
1138
|
+
haptic(6);
|
|
1139
|
+
switchPage(next);
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
// 桌面端:滚轮向下 = 下一页,向上 = 上一页;触控板横向 deltaX 也认。
|
|
1143
|
+
// React 的 onWheel 是 passive,拦不住页面滚动,所以走 ref 回调绑的原生
|
|
1144
|
+
// 非 passive wheel(bindWheel)。累积 40 像素翻一页,翻页后冷却 260ms。
|
|
1145
|
+
const WHEEL_STEP = 40;
|
|
1146
|
+
wheelHandler.current = (event) => {
|
|
1147
|
+
if (pages.length <= 1) return;
|
|
1148
|
+
event.preventDefault();
|
|
1149
|
+
const now = Date.now();
|
|
1150
|
+
if (now < wheelState.current.lockUntil) return;
|
|
1151
|
+
// deltaMode:0=像素,1=行,2=页 → 统一折算成像素。
|
|
1152
|
+
const unit = event.deltaMode === 1 ? 40 : event.deltaMode === 2 ? 400 : 1;
|
|
1153
|
+
const dx = (event.deltaX || 0) * unit;
|
|
1154
|
+
const dy = (event.deltaY || 0) * unit;
|
|
1155
|
+
const raw = Math.abs(dx) > Math.abs(dy) ? dx : dy;
|
|
1156
|
+
if (!raw) return;
|
|
1157
|
+
wheelState.current.acc += raw;
|
|
1158
|
+
if (Math.abs(wheelState.current.acc) < WHEEL_STEP) return;
|
|
1159
|
+
const next = safePage + (wheelState.current.acc > 0 ? 1 : -1);
|
|
1160
|
+
wheelState.current.acc = 0;
|
|
1161
|
+
wheelState.current.lockUntil = now + 260;
|
|
1162
|
+
if (next < 0 || next >= pages.length) return;
|
|
1163
|
+
panelState.swallowClickUntil = Date.now() + 250;
|
|
1164
|
+
switchPage(next);
|
|
1165
|
+
};
|
|
1166
|
+
const bindWheel = (node) => {
|
|
1167
|
+
const prev = wheelState.current.node;
|
|
1168
|
+
if (prev && prev !== node) prev.removeEventListener('wheel', wheelListener.current);
|
|
1169
|
+
if (node && node !== prev) node.addEventListener('wheel', wheelListener.current, { passive: false });
|
|
1170
|
+
wheelState.current.node = node || null;
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
// 固定 2 行:列数 = min(⌈条目数/2⌉, 宽度允许的上限),瓦片水平居中。
|
|
1174
|
+
const cols = Math.max(2, Math.min(ceil2(pageEntries.length), maxCols));
|
|
1175
|
+
const gridStyle = {
|
|
1176
|
+
display: 'grid',
|
|
1177
|
+
gridTemplateColumns: `repeat(${cols}, minmax(72px, 96px))`,
|
|
1178
|
+
gridTemplateRows: 'repeat(2, auto)',
|
|
1179
|
+
justifyContent: 'center',
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
// 分页指示(仅多页时出现;不区分常用/指令,只标页码)。
|
|
1183
|
+
const loadingBar = panelState.harvesting
|
|
1184
|
+
? h('div', { className: 'dmc-loading', role: 'status', 'aria-label': t('hint.loading') })
|
|
1185
|
+
: null;
|
|
1186
|
+
|
|
1187
|
+
const tabsBar = pages.length > 1
|
|
1188
|
+
? h('div', { className: 'dmc-pager', role: 'tablist' },
|
|
1189
|
+
pages.map((_, i) => h('button', {
|
|
1190
|
+
key: 'page-' + i,
|
|
1191
|
+
type: 'button',
|
|
1192
|
+
role: 'tab',
|
|
1193
|
+
'aria-selected': i === safePage,
|
|
1194
|
+
'aria-label': t('page.label', { n: i + 1 }),
|
|
1195
|
+
className: 'dmc-dot' + (i === safePage ? ' dmc-active' : ''),
|
|
1196
|
+
onClick: () => switchPage(i),
|
|
1197
|
+
})),
|
|
1198
|
+
)
|
|
1199
|
+
: null;
|
|
1200
|
+
|
|
1201
|
+
const renderEntry = (entry, idx) => {
|
|
1202
|
+
if (entry.kind === 'action') {
|
|
1203
|
+
const action = entry.action;
|
|
1204
|
+
return h('button', {
|
|
1205
|
+
key: entry.key,
|
|
1206
|
+
type: 'button',
|
|
1207
|
+
className: 'dmc-tile',
|
|
1208
|
+
'data-dmc-action': action.id,
|
|
1209
|
+
'data-dmc-source': action.source,
|
|
1210
|
+
onClick: () => runAction(action),
|
|
1211
|
+
},
|
|
1212
|
+
h('span', { className: 'dmc-tile-box' }, ICONS[ICON_KEY[action.id]] ? ICONS[ICON_KEY[action.id]](action.accent) : null),
|
|
1213
|
+
h('span', { className: 'dmc-tile-label' }, t('action.' + action.id)),
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
if (entry.kind === 'reload') {
|
|
1217
|
+
return h('button', {
|
|
1218
|
+
key: entry.key,
|
|
1219
|
+
type: 'button',
|
|
1220
|
+
className: 'dmc-tile',
|
|
1221
|
+
'data-dmc-command': '__reload',
|
|
1222
|
+
onClick: () => { if (swallowDerivedClick()) return; panelState.commands = null; harvestCommands(); },
|
|
1223
|
+
},
|
|
1224
|
+
h('span', { className: 'dmc-tile-box' }, ICONS.reload('#8a8f99')),
|
|
1225
|
+
h('span', { className: 'dmc-tile-label' }, t('tile.reload')),
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
if (entry.kind === 'skill') {
|
|
1229
|
+
const skill = entry.skill;
|
|
1230
|
+
return h('button', {
|
|
1231
|
+
key: entry.key,
|
|
1232
|
+
type: 'button',
|
|
1233
|
+
className: 'dmc-tile dmc-tile-cmd',
|
|
1234
|
+
'data-dmc-skill': skill.name,
|
|
1235
|
+
title: '/' + skill.name + (skill.desc ? ' — ' + skill.desc : ''),
|
|
1236
|
+
onClick: () => runSkill(skill),
|
|
1237
|
+
},
|
|
1238
|
+
h('span', { className: 'dmc-tile-box' }, ICONS.skill('#1E9E6A')),
|
|
1239
|
+
h('span', { className: 'dmc-tile-label' }, skill.name),
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
const cmd = entry.cmd;
|
|
1243
|
+
// 单行标签:当前语言下的名字(官方 locale / 菜单采集已是当前语言),
|
|
1244
|
+
// 取不到就退回指令名。斜杠别名与描述只留在 title 里(不占行、不撑高)。
|
|
1245
|
+
const label = cmd.label && cmd.label !== cmd.name ? cmd.label : cmd.name;
|
|
1246
|
+
return h('button', {
|
|
1247
|
+
key: entry.key,
|
|
1248
|
+
type: 'button',
|
|
1249
|
+
className: 'dmc-tile dmc-tile-cmd',
|
|
1250
|
+
'data-dmc-command': cmd.name,
|
|
1251
|
+
title: '/' + cmd.name + (label !== cmd.name ? ' · ' + label : '') + (cmd.desc ? ' — ' + cmd.desc : ''),
|
|
1252
|
+
onClick: () => runCommand(cmd),
|
|
1253
|
+
},
|
|
1254
|
+
h('span', { className: 'dmc-tile-box' }, (ICONS[CMD_ICON[cmd.name]] || ICONS.command)('#7F77DD')),
|
|
1255
|
+
h('span', { className: 'dmc-tile-label' }, label),
|
|
1256
|
+
);
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
// 卡片正文只有宫格:底部不再放提示文案(降低卡片高度)。
|
|
1260
|
+
const body = h('div', { className: 'dmc-grid', style: gridStyle }, pageEntries.map(renderEntry));
|
|
1261
|
+
|
|
1262
|
+
// dock 形态(唯一形态,所有宽度一致):portal 到官方 dock 容器(对话框
|
|
1263
|
+
// 正下方、文档流内),宽度与聊天框等宽。hero 态与 composer 态该容器都
|
|
1264
|
+
// 存在;意外缺席时兜底 body。
|
|
1265
|
+
const dockHost = document.querySelector('[data-composer-dock]') || document.body;
|
|
1266
|
+
// 外层 slot 占满一整行(强制换行,进度等条目落到下一行),内层卡片用
|
|
1267
|
+
// **输入框的实测宽度**(不是容器的 100%——宽窗口下容器比卡片宽,会让
|
|
1268
|
+
// 面板比输入框大)。
|
|
1269
|
+
return createPortal(
|
|
1270
|
+
h('div', { className: 'dmc-dock-slot' },
|
|
1271
|
+
h('div', { ref: (node) => { dockRef.current = node; bindWheel(node); }, className: 'dmc-dock' + (pages.length > 1 ? ' dmc-swipe' : ''), role: 'menu', 'aria-label': t('panel.label'), onTouchStart, onTouchEnd, onTouchCancel, style: { width: width > 0 ? width + 'px' : '100%', maxWidth: '100%' } },
|
|
1272
|
+
loadingBar,
|
|
1273
|
+
tabsBar,
|
|
1274
|
+
body,
|
|
1275
|
+
renderOwnInputs(ownInputs),
|
|
1276
|
+
),
|
|
1277
|
+
),
|
|
1278
|
+
dockHost,
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// ---------------------------------------------------------------------
|
|
1283
|
+
// apply:注册两个插槽入口 + 样式 + 能力协商
|
|
1284
|
+
// ---------------------------------------------------------------------
|
|
1285
|
+
|
|
1286
|
+
function apply(ctx) {
|
|
1287
|
+
clientCtxRef = ctx;
|
|
1288
|
+
const styleTag = ensureStyleTag('dsh-mobile-compose-style', CSS_TEXT);
|
|
1289
|
+
const hideTag = ensureStyleTag('dsh-mobile-compose-hide', HIDE_CSS);
|
|
1290
|
+
|
|
1291
|
+
const syncHide = () => {
|
|
1292
|
+
try {
|
|
1293
|
+
document.body.classList.toggle('dmc-hide-native', caps.hideNativeButtons === true);
|
|
1294
|
+
} catch (_) { /* 静默 */ }
|
|
1295
|
+
};
|
|
1296
|
+
capsListeners.add(syncHide);
|
|
1297
|
+
syncHide();
|
|
1298
|
+
fetchCaps();
|
|
1299
|
+
|
|
1300
|
+
// 内置指令的中文标签 / claim token 来自官方 locale 的 `command`
|
|
1301
|
+
// namespace(与原生菜单同源):注入就绪即覆盖 RPC 拿到的英文名;
|
|
1302
|
+
// locale 切换时幂等重算。
|
|
1303
|
+
try {
|
|
1304
|
+
ctx.inject(['locale'], (scoped) => {
|
|
1305
|
+
const locale = scoped && scoped.locale;
|
|
1306
|
+
if (!locale) return;
|
|
1307
|
+
const applyLocale = () => {
|
|
1308
|
+
const next = detectLang(locale);
|
|
1309
|
+
const langChanged = next !== uiLang;
|
|
1310
|
+
uiLang = next;
|
|
1311
|
+
if (typeof locale.bind === 'function') commandLabelT = locale.bind('command');
|
|
1312
|
+
relabelCommands();
|
|
1313
|
+
if (langChanged) notifyPanel();
|
|
1314
|
+
};
|
|
1315
|
+
applyLocale();
|
|
1316
|
+
if (typeof scoped.on === 'function') scoped.on('locale/change', applyLocale);
|
|
1317
|
+
});
|
|
1318
|
+
} catch (_) { /* locale 不可用:退回原生菜单采集的中文标签 */ }
|
|
1319
|
+
|
|
1320
|
+
// 指令表变更(插件注册/注销指令)→ 失效缓存并重拉。 // 客户端 cordis 要求先声明注入才能访问 remote.* 命名空间(否则
|
|
1321
|
+
// "cannot get property remote.commands without inject")。
|
|
1322
|
+
try {
|
|
1323
|
+
ctx.inject(['remote', 'remote.commands', 'sessions'], (scoped) => {
|
|
1324
|
+
remoteCommandsRef = scoped.remote.commands;
|
|
1325
|
+
sessionsRef = scoped.sessions;
|
|
1326
|
+
if (panelState.sessionId) loadCommands(panelState.sessionId);
|
|
1327
|
+
});
|
|
1328
|
+
} catch (_) { /* 注入通道不可用:保留 DOM 采集兜底 */ }
|
|
1329
|
+
try {
|
|
1330
|
+
ctx.inject(['remote', 'remote.skills', 'sessions'], (scoped) => {
|
|
1331
|
+
remoteSkillsRef = scoped.remote.skills;
|
|
1332
|
+
sessionsRef = scoped.sessions;
|
|
1333
|
+
if (panelState.sessionId) loadCommands(panelState.sessionId);
|
|
1334
|
+
});
|
|
1335
|
+
} catch (_) { /* 技能通道不可用:面板只显示指令 */ }
|
|
1336
|
+
try {
|
|
1337
|
+
ctx.inject(['remote'], (scoped) => {
|
|
1338
|
+
if (typeof scoped.remote.$on !== 'function') return;
|
|
1339
|
+
const refresh = () => {
|
|
1340
|
+
panelState.commands = null;
|
|
1341
|
+
panelState.skills = null;
|
|
1342
|
+
panelState.labelsEnriched = false;
|
|
1343
|
+
if (panelState.sessionId) loadCommands(panelState.sessionId);
|
|
1344
|
+
else notifyPanel();
|
|
1345
|
+
};
|
|
1346
|
+
scoped.remote.$on('commands/change', refresh);
|
|
1347
|
+
scoped.remote.$on('plugin-manager/changed', refresh);
|
|
1348
|
+
});
|
|
1349
|
+
} catch (_) { /* 事件通道不可用:忽略 */ }
|
|
1350
|
+
|
|
1351
|
+
let registered = 0;
|
|
1352
|
+
try {
|
|
1353
|
+
ctx.slots.inject(SLOT_ENTRY, () => ctx.slots.register(
|
|
1354
|
+
{ name: SLOT_ENTRY, id: ENTRY_ID, order: 20 },
|
|
1355
|
+
PlusEntry,
|
|
1356
|
+
));
|
|
1357
|
+
registered += 1;
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
console.warn('[dsh-mobile-compose] 入口插槽注册失败(面板不可用,原生按钮不受影响):', error);
|
|
1360
|
+
}
|
|
1361
|
+
// 面板不再注册 composer.dock 插槽:该插槽在 hero 态(新建/空会话)下
|
|
1362
|
+
// 官方不渲染,会导致点「+」无反应。面板由 PlusEntry 内渲染并 portal
|
|
1363
|
+
// 到官方 [data-composer-dock] 容器(两种 variant 都存在)。
|
|
1364
|
+
|
|
1365
|
+
ctx.effect(() => () => {
|
|
1366
|
+
capsListeners.delete(syncHide);
|
|
1367
|
+
try { document.body.classList.remove('dmc-hide-native'); } catch (_) { /* 静默 */ }
|
|
1368
|
+
for (const tag of [styleTag, hideTag]) {
|
|
1369
|
+
try { tag.remove(); } catch (_) { /* 静默 */ }
|
|
1370
|
+
}
|
|
1371
|
+
}, 'dsh-mobile-compose: wiring');
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const inject = ['slots'];
|
|
1375
|
+
|
|
1376
|
+
exports.apply = apply;
|
|
1377
|
+
exports.inject = inject;
|
|
1378
|
+
return module.exports;
|
|
1379
|
+
},
|
|
1380
|
+
});
|