dsh-escalation-review 0.2.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.
package/lib/client.js ADDED
@@ -0,0 +1,1672 @@
1
+ /**
2
+ * 客户端半边:把插件配置渲染成「插件」页里的一页。
3
+ *
4
+ * 依据 = 官方文档与官方实现(都已从 asar 抽出核对,见 plugins/_reference/dsh-docs/):
5
+ * - `dsh-client-ui-settings/README.md`
6
+ * · `ctx.configForms.get(entryId)`:该 Host 条目的表单(快照含 value/base/user/revision/writable/mode;
7
+ * 写操作 set/unset/mutate 走共享写队列)
8
+ * · `ctx.configForms.whileServed(namespaces, register)`:命名空间被 Host 服务时注册,返回 disposer
9
+ * - `dsh-client-ui-plugin-manager/README.md`
10
+ * · `plugins.item`:官方插件列表(按 label 进 Official 组)
11
+ * · `plugins.bundle.config`(key = **bundle 包名**):显示在 **bundle 详情页**、描述与组件行之间
12
+ * · `plugins.row.config`(key = `<包名>#<行 id>`):给该行一个 Configure 入口
13
+ * - `dsh-client-ui-settings-shell/lib/client.js`:现成范例(官方四页之一)
14
+ * - `dsh-client-ui-primitives/README.md`:SettingsForm / SettingsValueField / SettingsFormModel +
15
+ * settingsTextField / settingsNumberField(**只有文本与数字两种字段**)
16
+ *
17
+ * ⚠️ 三条铁律(都踩过):
18
+ * 1) factory 里必须自己声明 `var module = { exports: {} }; var exports = module.exports;`
19
+ * —— 否则报 `import failed: exports is not defined`,entry 激活失败、整个 app 起不来(crash 原文)。
20
+ * 2) `exports.inject` 只能写**确定存在**的服务名(0.1.7 实有:slots / locale / configForms)。
21
+ * 写错一个 → entry 永久 pending →「web boot: 1 entry did not activate」。
22
+ * 3) 加载期与 apply 期都不能抛:先给安全默认(空 inject + 空 apply),再用 try 包住实现。
23
+ */
24
+ window.__ModuleLoader__.load({
25
+ id: 'dsh-escalation-review',
26
+ factory: (require) => {
27
+ var module = { exports: {} }
28
+ var exports = module.exports
29
+
30
+ // 兜底:即使下面任何一步抛错,这个 entry 仍然"可激活且无事发生",绝不拖垮启动。
31
+ exports.inject = []
32
+ exports.apply = () => {}
33
+
34
+ try {
35
+ const React = require('react')
36
+ const createElement = React.createElement
37
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
38
+
39
+ /** 本插件在宿主侧的条目 id —— 同时就是它的设置命名空间。 */
40
+ const ENTRY = 'escalation-review'
41
+ /** 本包(bundle)名 —— `plugins.bundle.config` 的 key。 */
42
+ const BUNDLE = 'dsh-escalation-review'
43
+ /** 字典命名空间(本页面自己的文案)。 */
44
+ const NS = 'settings.escalationReview'
45
+ /** discoverModels 需要一个 settings 命名空间(本插件自己的条目 id 即可)。 */
46
+ const NAMESPACE_FOR_DISCOVERY = 'escalation-review'
47
+
48
+ // ── 词典(中英双语)
49
+ const zh = {
50
+ summary: '只审需要越界的调用;其余按沙箱默认走。',
51
+ mode: '运行模式',
52
+ modeHint: '由上面的开关决定是否介入(与权限预设无关)。观察:只评审记录,照旧弹窗问你;自动批准:评审 allow 就直接放行、deny 就直接拒绝,理由回传模型。本项是调试项,默认不显示。',
53
+ enabledText: '介入越界审批',
54
+ enabledTextHint: '打开后接管**所有**沙箱越界调用的审批(与权限预设无关)。关闭 = 零介入、零模型调用。',
55
+ selfTestText: '策略自检',
56
+ selfTestTextHint: '填 true 会在第一次越界后用一批「伪造动作」跑一遍评审策略(绝不执行工具);留空不改动',
57
+ allowedHostsText: '低风险主机白名单',
58
+ allowedHostsTextHint: '逗号或换行分隔;填了就以它为准,留空沿用文件配置',
59
+ policyExtra: '附加策略',
60
+ policyExtraHint: '追加到评审策略末尾的自定义规则(自然语言)',
61
+ provider: '评审用 provider',
62
+ reviewerModel: '评审用模型',
63
+ reviewerModelHint: '点开可选 provider / model / 思考强度;「跟随会话(留空)」表示三项都不覆盖',
64
+ cellModel: '模型',
65
+ effortDefault: '跟随模型默认',
66
+ effortNeedsModel: '「跟随会话」时不指定档位;先在上面选一个模型,才有具体档位可选',
67
+ paneEffort: '推理等级',
68
+ back: '‹ 返回',
69
+ reasoningEffort: '思考强度',
70
+ model: '评审用 model',
71
+ timeoutMs: '单次命令总超时(毫秒)',
72
+ timeoutMsHint: '整个评审的总预算(含重试);用完即按「评审失败」处理',
73
+ failMode: '评审失败时',
74
+ failModeHint: 'deny:直接拒绝(fail-closed);ask:交回人工',
75
+ probeRunner: '只读探针执行方式',
76
+ probeRunnerHint: 'inproc:进程内(默认,不 spawn)| shell:沙箱内只读命令',
77
+ attemptTimeoutMs: '单次请求超时(毫秒)',
78
+ retryDelayMs: '重试间隔(毫秒)',
79
+ attemptTimeoutMsHint: '单次尝试的上限;超时后会按下面的重试间隔再试',
80
+ retryDelayMsHint: '两次尝试之间的等待(生产评审与策略自检共用)',
81
+ minAttemptMs: '最小重试预算(毫秒)',
82
+ minAttemptMsHint: '扣掉等待后剩余预算低于此值就不再重试(避免白打一次注定失败的请求)',
83
+ denyMode: '判为拒绝时',
84
+ denyModeHint: 'deny:直接拒绝;ask:交回人工',
85
+ overridden: '已覆盖',
86
+ reset: '恢复默认',
87
+ autoSaveNote: '改动约 0.4 秒后自动保存、约 2 秒后生效,无需手动保存,也不用重启。',
88
+ optGateOff: '关闭',
89
+ optGateOn: '打开',
90
+ optObserve: '观察',
91
+ optEnforce: '自动批准',
92
+ optProbeInproc: '进程内(默认)',
93
+ optProbeShell: '沙箱内命令',
94
+ followSession: '跟随会话(留空)',
95
+ dropdownUnavailable: '(下拉数据源不可用,已退回手填)',
96
+ loading: '加载中…',
97
+ noOptions: '没有可选项(可直接手填)',
98
+ optDeny: '直接拒绝',
99
+ optAsk: '交回人工',
100
+ readOnly: '本部署的设置为只读。',
101
+ unavailable: '该插件当前未加载,暂时无法配置。',
102
+ save: '保存',
103
+ saveFailed: '本部署没有接受这些值,已保留供你修改。',
104
+ invalidNumber: '请填数字;留空表示使用默认值。',
105
+ }
106
+ const en = {
107
+ autoSaveNote: 'Changes save automatically (about 0.4s) and apply within a couple of seconds — no manual save, no restart.',
108
+ summary: 'Reviews sandbox escalations only; everything else keeps the sandbox default.',
109
+ mode: 'Mode',
110
+ modeHint: 'Only meaningful once the switch above is on (it is independent of any permission preset). Observe: review and log only, you are still asked. Auto-approve: an allow verdict proceeds, a deny verdict refuses and returns the reason to the model. A debugging field, hidden unless devMode is on.',
111
+ enabledText: 'Take over escalations',
112
+ enabledTextHint: 'When on, this plugin answers every sandbox-escalation approval (independent of permission presets). Off = zero intervention, zero model calls.',
113
+ selfTestText: 'Policy self-test',
114
+ selfTestTextHint: 'Set "true" to run the fabricated-action self-test once; blank leaves it unchanged',
115
+ allowedHostsText: 'Low-risk host allowlist',
116
+ allowedHostsTextHint: 'Comma or newline separated; blank keeps the file configuration',
117
+ policyExtra: 'Extra policy',
118
+ policyExtraHint: 'Free-form rules appended to the reviewer policy',
119
+ provider: 'Reviewer provider',
120
+ reviewerModel: 'Reviewer model',
121
+ reviewerModelHint: 'Opens a picker for provider / model / reasoning effort; "Follow the session" leaves all three unset',
122
+ cellModel: 'Model',
123
+ effortDefault: 'Model default',
124
+ effortNeedsModel: 'While following the session no explicit reasoning level applies; pick a model first to choose one',
125
+ paneEffort: 'Reasoning effort',
126
+ back: '‹ Back',
127
+ reasoningEffort: 'Reasoning effort',
128
+ model: 'Reviewer model',
129
+ timeoutMs: 'Total timeout for one command (ms)',
130
+ timeoutMsHint: 'Overall budget for one review (retries included); exhausting it counts as review failure',
131
+ attemptTimeoutMs: 'Per-request timeout (ms)',
132
+ attemptTimeoutMsHint: 'Cap for one attempt; a timeout retries after the delay below',
133
+ retryDelayMs: 'Retry delay (ms)',
134
+ retryDelayMsHint: 'Wait between attempts (shared by reviews and the policy self-test)',
135
+ minAttemptMs: 'Minimum retry budget (ms)',
136
+ minAttemptMsHint: 'Skip the retry when the remaining budget after the delay is below this (avoids a doomed request)',
137
+ probeRunner: 'Probe runner',
138
+ probeRunnerHint: 'inproc: in-process (default, no spawn) | shell: read-only command inside the sandbox',
139
+ failMode: 'On review failure',
140
+ failModeHint: 'deny: refuse (fail-closed); ask: hand back to the user',
141
+ denyMode: 'On a deny verdict',
142
+ denyModeHint: 'deny: refuse outright; ask: hand back to the user',
143
+ overridden: 'Overridden',
144
+ reset: 'Reset to default',
145
+ optGateOff: 'Off',
146
+ optGateOn: 'On',
147
+ optObserve: 'Observe',
148
+ optEnforce: 'Auto-approve',
149
+ followSession: 'Follow the session (empty)',
150
+ dropdownUnavailable: ' (model list unavailable — typing instead)',
151
+ loading: 'Loading…',
152
+ noOptions: 'No choices available (type it instead)',
153
+ optDeny: 'Deny',
154
+ optProbeInproc: 'In-process',
155
+ optProbeShell: 'Sandboxed command',
156
+ optAsk: 'Ask the user',
157
+ readOnly: 'This deployment stores settings read-only.',
158
+ unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
159
+ save: 'Save',
160
+ saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
161
+ invalidNumber: 'Enter a number, or leave blank to use the default.',
162
+ }
163
+
164
+ /**
165
+ * 字段 → 控件形态。
166
+ * 官方的字段辅助只有 `settingsTextField` / `settingsNumberField`,且 `SettingsValueField`
167
+ * 内部就是单行 `<input>`(不支持多行),所以:
168
+ * · 互斥选择(观察/强制、拒绝/交回人工)→ `SegmentedControl`(官方 Models 页 همین 用它)
169
+ * · 布尔(自检)→ `Switch`
170
+ * · 长文本(附加策略、主机白名单)→ 自写多行框,视觉与官方字段框一致
171
+ * · 其余 → `SettingsValueField`(文本 / 数字)
172
+ * 暂存值仍走同一个 `SettingsFormModel`(文本/数字字段),控件只负责把选中值 `edit()` 进去。
173
+ */
174
+ const FIELDS = [
175
+ // 总开关置顶:它是这张卡上唯一决定"插件介不介入"的字段
176
+ {
177
+ key: 'enabledText',
178
+ kind: 'segment',
179
+ prominent: true,
180
+ fallback: 'false',
181
+ options: [
182
+ { value: 'false', label: 'optGateOff' },
183
+ { value: 'true', label: 'optGateOn' },
184
+ ],
185
+ },
186
+ // ── 最常动的一项:用哪个模型评审
187
+ // ── 最常动的两项:用哪个模型评审、给评审追加什么策略
188
+ // provider / model / 思考强度 合成一个选择按钮(ModelPicker,内部写三个配置键)
189
+ { key: 'reviewerModel', kind: 'reviewerModel' },
190
+ // ── 处置口径:评审失败/超时怎么办、政策判定拒绝怎么办
191
+ // ── 处置口径:评审失败/超时怎么办、政策判定拒绝怎么办
192
+ {
193
+ key: 'failMode',
194
+ kind: 'segment',
195
+ fallback: 'deny',
196
+ options: [
197
+ { value: 'deny', label: 'optDeny' },
198
+ { value: 'ask', label: 'optAsk' },
199
+ ],
200
+ },
201
+ {
202
+ key: 'denyMode',
203
+ kind: 'segment',
204
+ fallback: 'deny',
205
+ options: [
206
+ { value: 'deny', label: 'optDeny' },
207
+ { value: 'ask', label: 'optAsk' },
208
+ ],
209
+ },
210
+ // ── 技术项:只读探针在哪儿跑(默认进程内)
211
+ // ── 只读探针的执行方式(技术项,默认进程内)
212
+ {
213
+ key: 'probeRunner',
214
+ kind: 'segment',
215
+ fallback: 'inproc',
216
+ options: [
217
+ { value: 'inproc', label: 'optProbeInproc' },
218
+ { value: 'shell', label: 'optProbeShell' },
219
+ ],
220
+ },
221
+ // ── 评审策略:附加规则 + 低风险主机白名单
222
+ { key: 'policyExtra', kind: 'textarea', rows: 6 },
223
+ { key: 'allowedHostsText', kind: 'textarea', rows: 4 },
224
+ // ── 超时与重试(默认值一般够用)
225
+ // ── 超时与重试(默认值一般够用)
226
+ { key: 'timeoutMs', kind: 'number', numeric: true },
227
+ { key: 'attemptTimeoutMs', kind: 'number', numeric: true },
228
+ { key: 'retryDelayMs', kind: 'number', numeric: true },
229
+ { key: 'minAttemptMs', kind: 'number', numeric: true },
230
+ // ── 调试项(advanced):默认不渲染,本机配置写 devMode=true 才显示
231
+ // ── 调试项(advanced):默认不渲染,本机配置写 devMode=true 才显示
232
+ // 调试项(advanced):默认不渲染,只有本机配置里写 devMode=true 才显示
233
+ {
234
+ key: 'mode',
235
+ kind: 'segment',
236
+ advanced: true,
237
+ fallback: 'enforce',
238
+ options: [
239
+ { value: 'observe', label: 'optObserve' },
240
+ { value: 'enforce', label: 'optEnforce' },
241
+ ],
242
+ },
243
+ { key: 'selfTestText', kind: 'switch', advanced: true },
244
+ ]
245
+
246
+ /** 渲染一页:summary 视图给一行说明,否则给完整表单。 */
247
+ /** 自写控件用的行内样式(与官方字段框的视觉语言对齐:小号标签、淡色提示、淡色徽标)。 */
248
+ // ── 内联样式
249
+ const styles = {
250
+ field: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0 14px' },
251
+ fieldFirst: { display: 'flex', flexDirection: 'column', gap: 6, padding: '0 0 14px' },
252
+ divider: { borderTop: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.22))' },
253
+ masterCard: {
254
+ border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.28))',
255
+ borderRadius: 10,
256
+ padding: '12px 14px 14px',
257
+ background: 'var(--dsw-alias-bg-module-platform, rgba(128,128,128,0.06))',
258
+ },
259
+ labelStrong: { fontSize: 14, fontWeight: 600 },
260
+ head: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 },
261
+ label: { fontSize: 13, fontWeight: 500 },
262
+ hint: { margin: 0, fontSize: 12, opacity: 0.7, lineHeight: 1.5 },
263
+ badges: { display: 'inline-flex', alignItems: 'center', gap: 8 },
264
+ badge: {
265
+ fontSize: 11,
266
+ padding: '1px 6px',
267
+ borderRadius: 999,
268
+ border: '1px solid currentColor',
269
+ opacity: 0.7,
270
+ },
271
+ reset: {
272
+ fontSize: 12,
273
+ background: 'none',
274
+ border: 'none',
275
+ padding: 0,
276
+ cursor: 'pointer',
277
+ color: 'inherit',
278
+ textDecoration: 'underline',
279
+ opacity: 0.8,
280
+ },
281
+ textarea: {
282
+ width: '100%',
283
+ boxSizing: 'border-box',
284
+ padding: '8px 10px',
285
+ borderRadius: 8,
286
+ border: '1px solid currentColor',
287
+ background: 'transparent',
288
+ color: 'inherit',
289
+ font: 'inherit',
290
+ fontSize: 13,
291
+ lineHeight: 1.6,
292
+ resize: 'vertical',
293
+ },
294
+ form: { display: 'flex', flexDirection: 'column' },
295
+ hintNote: { margin: '0 0 10px', fontSize: 12, lineHeight: '18px', opacity: 0.7 },
296
+ notice: {
297
+ margin: '0 0 12px',
298
+ padding: '8px 10px',
299
+ borderRadius: 8,
300
+ border: '1px solid #e5484d',
301
+ background: 'rgba(229, 72, 77, 0.08)',
302
+ color: '#e5484d',
303
+ fontSize: 12.5,
304
+ lineHeight: 1.6,
305
+ },
306
+ }
307
+
308
+ /** 自写控件的字段框:标签 + 已覆盖徽标 + 恢复默认 + 控件 + 提示。 */
309
+ /**
310
+ * 表单**管理**的字段 = 界面渲染字段(去掉合并后的 reviewerModel 行)+ 被合并进去的三个真实键。
311
+ * ⚠️ SettingsFormModel 只认 spec 里声明的字段:漏掉谁,edit('谁', …) 就会抛
312
+ * "plugin card has no field 谁",同时 dirty 不会置上、保存按钮不亮(本次踩的坑)。
313
+ */
314
+ const FORM_FIELDS = [
315
+ ...FIELDS.filter((field) => field.kind !== 'reviewerModel'),
316
+ { key: 'provider', kind: 'text' },
317
+ { key: 'model', kind: 'text' },
318
+ { key: 'reasoningEffort', kind: 'text' },
319
+ // devMode:只进表单清单、不进渲染清单。用户在自己的配置文件里写 "devMode": true
320
+ // 就能看到调试项(mode / selfTestText);普通用户看不到。
321
+ { key: 'devMode', kind: 'text' },
322
+ ]
323
+
324
+ /**
325
+ * 安全的 useRef:真实 React 一定有 useRef;取不到时退化为普通对象。
326
+ * 工厂作用域只判断一次,不涉及 hooks 规则问题。
327
+ */
328
+ const useRefSafe = (initial) => (typeof React.useRef === 'function' ? React.useRef(initial) : { current: initial })
329
+
330
+ // ── 字段容器
331
+ function FieldShell(props) {
332
+ const shellStyle = props.prominent
333
+ ? { ...(props.first ? styles.fieldFirst : styles.field), ...styles.masterCard }
334
+ : { ...(props.first ? styles.fieldFirst : styles.field), ...(props.first ? {} : styles.divider) }
335
+ return createElement('div', { style: shellStyle }, [
336
+ createElement('div', { key: 'head', style: styles.head }, [
337
+ createElement('label', { key: 'label', htmlFor: props.id, style: props.prominent ? styles.labelStrong : styles.label }, props.label),
338
+ props.overridden
339
+ ? createElement('span', { key: 'badges', style: styles.badges }, [
340
+ createElement('span', { key: 'badge', style: styles.badge }, props.overriddenLabel),
341
+ createElement(
342
+ 'button',
343
+ { key: 'reset', type: 'button', style: styles.reset, disabled: props.disabled, onClick: props.onReset },
344
+ props.resetLabel,
345
+ ),
346
+ ])
347
+ : null,
348
+ ]),
349
+ createElement('div', { key: 'control' }, props.children),
350
+ props.hint === undefined ? null : createElement('p', { key: 'hint', style: styles.hint }, props.hint),
351
+ ])
352
+ }
353
+
354
+ /** 样式:逐条照抄官方 ModelSelect.module.css(同一个哈希模块的规则与主题变量)。 */
355
+ const CHOICE_STYLE_ID = 'dsh-escalation-review-choice-style'
356
+ const ensureStyle = () => {
357
+ try {
358
+ const doc = globalThis.document
359
+ if (doc === undefined || typeof doc.createElement !== 'function') return
360
+ if (typeof doc.querySelector === 'function' && doc.querySelector('style[data-plugin-css="dsh-escalation-review"]') !== null) return
361
+ const tag = doc.createElement('style')
362
+ tag.dataset.plugin = 'dsh-escalation-review'
363
+ tag.dataset.pluginCss = CHOICE_STYLE_ID
364
+ tag.textContent = [
365
+ // 触发器按"设置表单控件"来(与同页的 Switch / 分段控件同一套 token),
366
+ // 不再用 Composer 工具栏那套透明小尺寸样式(放表单里看不出是下拉 ✗)
367
+ '.er-root{min-width:0;width:100%;position:relative}',
368
+ '.er-trigger{box-sizing:border-box;width:auto;min-width:220px;max-width:100%;min-height:36px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-module-platform);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);cursor:pointer;outline:none;align-items:center;justify-content:space-between;gap:12px;padding:0 14px;font:inherit;font-size:14px;line-height:22px;display:flex}',
369
+ '.er-trigger:hover:not(:disabled){border-color:var(--dsw-alias-border-l1);background:var(--dsw-alias-interactive-bg-hover)}',
370
+ '.er-trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
371
+ '.er-trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
372
+ '.er-triggerLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}',
373
+ '.er-triggerEffort{color:var(--dsw-alias-label-caption);flex:none}',
374
+ '.er-chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s}',
375
+ '.er-chevronOpen{transform:rotate(180deg)}',
376
+ '.er-menu{--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);width:max-content;min-width:min(240px,100vw - 32px);max-width:min(420px,100vw - 32px);max-height:min(360px,100vh - 96px);color:var(--dsw-alias-label-primary);border:0;flex-direction:column;padding:4px;display:flex;overflow:hidden}',
377
+ '.er-status{color:var(--dsw-alias-label-tertiary);padding:8px;font-size:12px;line-height:18px}',
378
+ '.er-groups{min-height:0;overflow-y:auto}',
379
+ '.er-group+.er-group{margin-top:3px}',
380
+ '.er-groupTitle{z-index:1;background:var(--dsw-specific-menu);color:var(--dsw-alias-label-tertiary);padding:4px 7px 2px;font-size:11px;font-weight:500;line-height:16px;position:sticky;top:0}',
381
+ '.er-option{box-sizing:border-box;border-radius:var(--dsw-radius-md);width:auto;min-width:100%;min-height:34px;color:inherit;text-align:left;cursor:pointer;background:0 0;border:none;outline:none;align-items:center;gap:6px;padding:5px 7px;display:flex}',
382
+ '.er-option:hover:not(:disabled),.er-option:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}',
383
+ '.er-option:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
384
+ '.er-optionCopy{flex-direction:column;flex:1;min-width:0;display:flex}',
385
+ '.er-modelName{color:inherit;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:500;line-height:18px;overflow:hidden}',
386
+ '.er-check{color:var(--dsw-alias-label-primary);flex:0 0 14px;place-items:center;display:grid}',
387
+ '.er-cell{box-sizing:border-box;border-radius:var(--dsw-radius-md);width:auto;min-width:100%;height:34px;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left;background:0 0;border:none;align-items:center;gap:6px;padding:0 8px;font-size:13px;line-height:20px;display:flex}',
388
+ '.er-cell:hover{background:var(--dsw-alias-interactive-bg-hover)}',
389
+ '.er-cellLabel{white-space:nowrap;flex:none}',
390
+ '.er-cellValue{text-overflow:ellipsis;white-space:nowrap;text-align:right;min-width:0;color:var(--dsw-alias-label-tertiary);flex:auto;overflow:hidden}',
391
+ ].join('')
392
+ doc.head.appendChild(tag)
393
+ } catch {
394
+ /* 宿主环境可能没有 document */
395
+ }
396
+ }
397
+
398
+ /**
399
+ * 评审模型选择器:**两层菜单**,完全照官方 Composer 那个选择器。
400
+ * 第一层:模型 › 推理等级 ›(各带当前值)
401
+ * 第二层:模型列表(provider 分组标题 + 模型行 + 勾)/ 推理等级列表(+ 勾),顶部有返回行
402
+ * 外层用官方 `Menu`(锚定/portal/定位/键盘),行样式照抄官方 ModelSelect 的类规则。
403
+ */
404
+ /**
405
+ * 评审模型选择器:全部走官方 `Menu` 的**数据行 API**(items/selectedId/onSelect)。
406
+ * 之前自己写行 → 点击无效(Menu 在 pointerdown/mousedown 阶段就把浮层关了,行在 click 前被卸载 ✗);
407
+ * 现在点击完全由 Menu 派发,稳。
408
+ * 层级:root(模型 › / 推理等级 › / 跟随会话)→ providers → models → efforts。
409
+ * 档位:选了模型就用该模型的档位;没选模型则用所有模型档位的**并集**(否则这里是空的 ✗)。
410
+ */
411
+ /**
412
+ * 评审模型选择器:**照官方 ModelSelect 的做法**。
413
+ * · 浮层只有 MenuSurface;位置自己给(绝对定位挂在相对容器下)
414
+ * · 行是普通 <button role="menuitem"> + onClick(官方同款)
415
+ * · document 上监听 **click 冒泡**,用 closest('.er-root,.er-menu,.er-trigger') 判内外:
416
+ * MenuSurface 可能把内容 portal 到 body(ref.contains 会失效 ✗),而 pointerdown 阶段关闭会
417
+ * 在行收到 click 之前就卸载它 ✗ —— 这两点合起来曾让"菜单行永远点不动"
418
+ * 两层:root(模型 › / 推理等级 › / 跟随会话)→ providers → models(分组标题)→ efforts。
419
+ */
420
+ // ── 官方 MenuSurface 选择器:provider / model / 思考强度
421
+ function ModelPicker(props) {
422
+ const tr = typeof props.t === 'function' ? props.t : (key) => key
423
+ const [open, setOpen] = React.useState(false)
424
+ const [pane, setPane] = React.useState('root')
425
+ const [paneProvider, setPaneProvider] = React.useState('')
426
+ const [groups, setGroups] = React.useState(null)
427
+ const [busy, setBusy] = React.useState(false)
428
+ const rootRef = useRefSafe(null)
429
+ const panelRef = useRefSafe(null)
430
+ const catalog = props.catalog
431
+ const provider = typeof props.provider === 'string' ? props.provider : ''
432
+ const model = typeof props.model === 'string' ? props.model : ''
433
+ const effort = typeof props.effort === 'string' ? props.effort : ''
434
+
435
+ // 外面点击才关闭,且**只在 click 冒泡阶段**判定:
436
+ // · 行的 onClick 先执行 → 不可能被"提前关闭"吞掉(pointerdown 阶段关就会吞掉 ✗)
437
+ // · 内外判定用 closest(...) 而不是 ref.contains(...) → MenuSurface 走 portal 也成立
438
+ React.useEffect(() => {
439
+ if (!open) return undefined
440
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return undefined
441
+ const onDocumentClick = (event) => {
442
+ const target = event?.target
443
+ // 行/触发器都是我自己的元素(data-er 标记在行上、.er-trigger 在触发器上),
444
+ // 不依赖 MenuSurface 是否转发 className/ref(它走 portal,class 可能丢 ✗)
445
+ const canClosest = target !== null && target !== undefined && typeof target.closest === 'function'
446
+ const inside = !canClosest || target.closest('[data-er="1"], .er-trigger, .er-menu, .er-root') !== null
447
+ if (inside) return
448
+ setOpen(false)
449
+ }
450
+ document.addEventListener('click', onDocumentClick)
451
+ return () => document.removeEventListener('click', onDocumentClick)
452
+ }, [open])
453
+
454
+ const load = () => {
455
+ if (catalog === undefined || catalog === null) return
456
+ setBusy(true)
457
+ // 有的数据源只有 listProviders/listModels(remote.llm 那一路)→ 自建分组,
458
+ // 否则「模型 ›」进去是空的("没有新项目" 就是这个 ✗)
459
+ const all =
460
+ typeof catalog.listAllModels === 'function'
461
+ ? catalog.listAllModels()
462
+ : Promise.resolve()
463
+ .then(() => (typeof catalog.listProviders === 'function' ? catalog.listProviders() : []))
464
+ .then(async (providers) => {
465
+ const groups = []
466
+ for (const item of Array.isArray(providers) ? providers : []) {
467
+ let models = []
468
+ try {
469
+ models = typeof catalog.listModels === 'function' ? await catalog.listModels(String(item.value)) : []
470
+ } catch {
471
+ models = []
472
+ }
473
+ groups.push({
474
+ provider: String(item.value),
475
+ label: String(item.label ?? item.value),
476
+ models: (Array.isArray(models) ? models : []).map((entry) => ({
477
+ value: String(entry.value),
478
+ label: String(entry.label ?? entry.value),
479
+ efforts: [],
480
+ })),
481
+ })
482
+ }
483
+ return groups
484
+ })
485
+ Promise.resolve(all)
486
+ .then((list) => setGroups(Array.isArray(list) ? list : []))
487
+ .catch(() => setGroups([]))
488
+ .finally(() => setBusy(false))
489
+ }
490
+ const toggle = () => {
491
+ if (open) {
492
+ setOpen(false)
493
+ return
494
+ }
495
+ setOpen(true)
496
+ setPane('root')
497
+ if (groups === null) load()
498
+ }
499
+ const choose = (patch) => {
500
+ // ⚠️ 写回可能抛(那就只写了前几个字段、并且菜单因为没走到 setOpen 而不关闭 ✗)
501
+ // 所以:整体 try/finally —— 无论成败,菜单一定收起;失败原因交给上面的 writeField 探针记录。
502
+ const nextProvider = patch.provider === undefined ? provider : patch.provider
503
+ const nextModel = patch.model === undefined ? model : patch.model
504
+ // 「跟随会话」不该带具体档位(语义矛盾 ✗)→ 只要 provider/model 都空,就强制清空 effort
505
+ const nextEffort = nextProvider === '' && nextModel === '' ? '' : patch.effort === undefined ? effort : patch.effort
506
+ try {
507
+ props.onPick({ provider: nextProvider, model: nextModel, effort: nextEffort })
508
+ } catch {
509
+ /* 写回失败由 writeField 的调用方与表单提示负责 */
510
+ } finally {
511
+ setOpen(false)
512
+ }
513
+ }
514
+
515
+ const list = groups ?? []
516
+ const currentGroup = list.find((group) => String(group.provider) === provider)
517
+ const currentModel = (Array.isArray(currentGroup?.models) ? currentGroup.models : []).find(
518
+ (entry) => String(entry.value) === model,
519
+ )
520
+ const modelLabel = String(currentModel?.label ?? model)
521
+ const levels = (() => {
522
+ const source =
523
+ model !== '' && Array.isArray(currentModel?.efforts) && currentModel.efforts.length > 0
524
+ ? currentModel.efforts
525
+ : list.flatMap((group) =>
526
+ (Array.isArray(group.models) ? group.models : []).flatMap((entry) => (Array.isArray(entry.efforts) ? entry.efforts : [])),
527
+ )
528
+ const seen = new Set()
529
+ return source.filter((level) => {
530
+ const id = String(level?.value ?? '')
531
+ if (id === '' || seen.has(id)) return false
532
+ seen.add(id)
533
+ return true
534
+ })
535
+ })()
536
+ const effortLabel = (() => {
537
+ const found = levels.find((level) => String(level.value) === effort)
538
+ return found === undefined ? effort : String(found.label ?? effort)
539
+ })()
540
+
541
+ const cell = (key, label, value, onClick) =>
542
+ createElement('button', {
543
+ key,
544
+ type: 'button',
545
+ role: 'menuitem',
546
+ className: 'er-cell',
547
+ 'data-er': '1',
548
+ onClick,
549
+ }, [
550
+ createElement('span', { key: 'label', className: 'er-cellLabel' }, label),
551
+ createElement('span', { key: 'value', className: 'er-cellValue' }, value),
552
+ createElement(
553
+ 'span',
554
+ { key: 'chevron', className: 'er-chevron' },
555
+ primitives.IconChevronRightOutlineRegular !== undefined && primitives.IconChevronRightOutlineRegular !== null
556
+ ? createElement(primitives.IconChevronRightOutlineRegular, { size: 12 })
557
+ : '›',
558
+ ),
559
+ ])
560
+ const option = (key, label, selected, onClick) =>
561
+ createElement('button', {
562
+ key,
563
+ type: 'button',
564
+ role: 'menuitem',
565
+ className: 'er-option',
566
+ 'data-er': '1',
567
+ onClick,
568
+ }, [
569
+ createElement('span', { key: 'name', className: 'er-modelName' }, label),
570
+ selected && primitives.IconCheckOutlineRegular !== undefined && primitives.IconCheckOutlineRegular !== null
571
+ ? createElement('span', { key: 'check', className: 'er-check' }, createElement(primitives.IconCheckOutlineRegular, { size: 14 }))
572
+ : null,
573
+ ])
574
+ const groupTitle = (key, label) => createElement('div', { key, className: 'er-groupTitle' }, label)
575
+
576
+ let body
577
+ if (pane === 'root') {
578
+ body = [
579
+ cell('__model__', tr('cellModel'), model === '' ? tr('followSession') : modelLabel, () => {
580
+ setPane('providers')
581
+ if (groups === null) load()
582
+ }),
583
+ cell('__effort__', tr('paneEffort'), effort === '' ? tr('effortDefault') : effortLabel, () => setPane('efforts')),
584
+ option('__follow__', tr('followSession'), provider === '' && model === '' && effort === '', () =>
585
+ choose({ provider: '', model: '', effort: '' }),
586
+ ),
587
+ ]
588
+ } else if (pane === 'providers') {
589
+ body = [
590
+ option('__back__', tr('back'), false, () => setPane('root')),
591
+ busy ? createElement('div', { key: '__busy__', className: 'er-status' }, tr('loading')) : null,
592
+ !busy && list.length === 0 ? createElement('div', { key: '__empty__', className: 'er-status' }, tr('noOptions')) : null,
593
+ createElement(
594
+ 'div',
595
+ { key: '__groups__', className: 'er-groups' },
596
+ list.map((group) =>
597
+ createElement('div', { key: 'g:' + String(group.provider), className: 'er-group' }, [
598
+ groupTitle('t:' + String(group.provider), String(group.label ?? group.provider)),
599
+ ...(Array.isArray(group.models) ? group.models : []).map((entry) =>
600
+ option(
601
+ 'm:' + String(group.provider) + ':' + String(entry.value),
602
+ String(entry.label ?? entry.value),
603
+ String(entry.value) === model && String(group.provider) === provider,
604
+ () => choose({ provider: String(group.provider), model: String(entry.value), effort: '' }),
605
+ ),
606
+ ),
607
+ ]),
608
+ ),
609
+ ),
610
+ ]
611
+ } else {
612
+ const followingSession = provider === '' && model === ''
613
+ body = [
614
+ option('__back__', tr('back'), false, () => setPane('root')),
615
+ option('__default__', tr('effortDefault'), effort === '', () => choose({ effort: '' })),
616
+ followingSession
617
+ ? createElement('div', { key: '__need_model__', className: 'er-status' }, tr('effortNeedsModel'))
618
+ : null,
619
+ followingSession
620
+ ? null
621
+ : busy
622
+ ? createElement('div', { key: '__busy__', className: 'er-status' }, tr('loading'))
623
+ : null,
624
+ ...(followingSession
625
+ ? []
626
+ : levels.map((level) =>
627
+ option('e:' + String(level.value), String(level.label ?? level.value), String(level.value) === effort, () =>
628
+ choose({ effort: String(level.value) }),
629
+ ),
630
+ )),
631
+ ]
632
+ }
633
+
634
+ const trigger = createElement(
635
+ 'button',
636
+ { type: 'button', className: 'er-trigger', 'data-er': '1', disabled: props.disabled, 'aria-haspopup': 'menu', 'aria-expanded': open, onClick: toggle },
637
+ [
638
+ createElement(
639
+ 'span',
640
+ { key: 'label', className: 'er-triggerLabel' },
641
+ provider === '' && model === '' ? tr('followSession') : modelLabel,
642
+ ),
643
+ effort === '' ? null : createElement('span', { key: 'effort', className: 'er-triggerEffort' }, effortLabel),
644
+ primitives.IconChevronDownOutlineRegular !== undefined && primitives.IconChevronDownOutlineRegular !== null
645
+ ? createElement(primitives.IconChevronDownOutlineRegular, {
646
+ key: 'chevron',
647
+ className: open ? 'er-chevron er-chevronOpen' : 'er-chevron',
648
+ })
649
+ : null,
650
+ ],
651
+ )
652
+ const surface =
653
+ primitives.MenuSurface !== undefined && primitives.MenuSurface !== null
654
+ ? createElement(
655
+ primitives.MenuSurface,
656
+ {
657
+ ref: panelRef,
658
+ className: 'er-menu',
659
+ role: 'menu',
660
+ style: { position: 'absolute', top: '100%', left: 0, zIndex: 1100 },
661
+ },
662
+ body,
663
+ )
664
+ : createElement(
665
+ 'div',
666
+ {
667
+ ref: panelRef,
668
+ className: 'er-menu',
669
+ role: 'menu',
670
+ style: { position: 'absolute', top: '100%', left: 0, zIndex: 1100, background: 'Canvas' },
671
+ },
672
+ body,
673
+ )
674
+
675
+ return createElement(
676
+ FieldShell,
677
+ {
678
+ id: props.id,
679
+ label: props.label,
680
+ hint: props.hint,
681
+ overridden: props.overridden === true,
682
+ overriddenLabel: tr('overridden'),
683
+ resetLabel: tr('reset'),
684
+ disabled: props.disabled,
685
+ onReset: props.onReset,
686
+ },
687
+ createElement('div', { ref: rootRef, className: 'er-root' }, [trigger, open ? surface : null]),
688
+ )
689
+ }
690
+
691
+ /** 下拉数据源的诊断轨迹(显示在字段说明里,便于一次定位)。 */
692
+ /**
693
+ * 模型目录(provider / model 下拉的数据源)。
694
+ *
695
+ * ⚠️ 教训:数据源是**异步**到手的(作用域注入回调、catalog.load()),而 Card 首次渲染时若
696
+ * catalog 仍是 undefined 就会把字段渲染成输入框,之后数据到了**不会自动重渲染** ✗
697
+ * → 用户永远看到输入框。所以:写入即通知订阅者,且 Card 挂载时再 ensureCatalog() 一次。
698
+ */
699
+ let pluginCtx = null
700
+ let activeStore = null
701
+ const catalogDiag = []
702
+ const catalogListeners = new Set()
703
+ /** 诊断已收敛为空实现(保留调用点,避免大面积改动带来行为风险)。 */
704
+ const diag = () => {}
705
+ const notifyCatalog = () => {
706
+ for (const listener of [...catalogListeners]) {
707
+ try {
708
+ listener()
709
+ } catch {
710
+ /* 订阅者出错不影响其它订阅者 */
711
+ }
712
+ }
713
+ }
714
+ const subscribeCatalog = (listener) => {
715
+ catalogListeners.add(listener)
716
+ return () => catalogListeners.delete(listener)
717
+ }
718
+ const setCatalog = (catalog) => {
719
+ if (activeStore === null) return
720
+ activeStore.catalog = catalog
721
+ notifyCatalog()
722
+ }
723
+ let catalogRequested = false
724
+
725
+ /**
726
+ * 安全取服务。⚠️ cordis 里访问**未声明**的服务属性会**抛异常**
727
+ *(实测:cannot get property "remote" without inject),所以:
728
+ * 先试 get()(不存在时返回 undefined),再退化到属性访问;两次都各自 try。
729
+ */
730
+ const readService = (holder, name) => {
731
+ if (holder === undefined || holder === null) return undefined
732
+ try {
733
+ if (typeof holder.get === 'function') {
734
+ const viaGet = holder.get(name)
735
+ if (viaGet !== undefined && viaGet !== null) return viaGet
736
+ }
737
+ } catch {
738
+ /* get 也可能抛(服务不可用) */
739
+ }
740
+ try {
741
+ return name
742
+ .split('.')
743
+ .reduce((value, key) => (value === undefined || value === null ? undefined : value[key]), holder)
744
+ } catch {
745
+ return undefined
746
+ }
747
+ }
748
+
749
+ /** 官方菜单的 provider 排序:deepseek-account → deepseek-official → 其余按 id。 */
750
+ const orderProviderGroups = (groups) => {
751
+ const priority = { 'deepseek-account': 0, 'deepseek-official': 1 }
752
+ return [...groups].sort((left, right) => {
753
+ const a = priority[String(left?.id ?? '')] ?? 2
754
+ const b = priority[String(right?.id ?? '')] ?? 2
755
+ if (a !== b) return a - b
756
+ return String(left?.id ?? '').localeCompare(String(right?.id ?? ''))
757
+ })
758
+ }
759
+
760
+ /** 数据源 ①:官方模型目录(modelDirectories.catalog),形状与 Composer 菜单一致。 */
761
+ const adoptCatalogFromDirectories = (service) => {
762
+ const catalog = service === undefined || service === null ? undefined : service.catalog
763
+ if (catalog === undefined || catalog === null || typeof catalog.store?.getSnapshot !== 'function') return false
764
+ const readGroups = async () => {
765
+ let value = catalog.store.getSnapshot()?.value
766
+ if (value === undefined || !Array.isArray(value?.groups)) {
767
+ try {
768
+ await catalog.load()
769
+ } catch (error) {
770
+ diag('catalog.load 失败: ' + String(error?.message ?? error))
771
+ }
772
+ value = catalog.store.getSnapshot()?.value
773
+ }
774
+ return orderProviderGroups(Array.isArray(value?.groups) ? value.groups : [])
775
+ }
776
+ setCatalog({
777
+ listProviders: async () =>
778
+ (await readGroups()).map((group) => ({
779
+ value: String(group?.id ?? ''),
780
+ label: String(group?.name ?? group?.id ?? ''),
781
+ })),
782
+ listModels: async (provider) => {
783
+ if (provider === '') return []
784
+ const group = (await readGroups()).find((item) => String(item?.id ?? '') === provider)
785
+ const models = Array.isArray(group?.models) ? group.models : []
786
+ return models
787
+ .map((model) => ({ value: String(model?.id ?? ''), label: String(model?.name ?? model?.id ?? '') }))
788
+ .filter((item) => item.value !== '')
789
+ },
790
+ /** 思考强度档位来自模型自带的元数据:model.reasoning.efforts = [{ id, name }] */
791
+ listEfforts: async (provider, model) => {
792
+ if (provider === '' || model === '') return []
793
+ const group = (await readGroups()).find((item) => String(item?.id ?? '') === provider)
794
+ const found = (Array.isArray(group?.models) ? group.models : []).find((item) => String(item?.id ?? '') === model)
795
+ const efforts = Array.isArray(found?.reasoning?.efforts) ? found.reasoning.efforts : []
796
+ return efforts
797
+ .map((effort) => ({ value: String(effort?.id ?? ''), label: String(effort?.name ?? effort?.id ?? '') }))
798
+ .filter((item) => item.value !== '')
799
+ },
800
+ /** 一次返回所有分组与组内模型(同一份快照,避免逐 provider 往返)。 */
801
+ listAllModels: async () =>
802
+ (await readGroups()).map((group) => ({
803
+ provider: String(group?.id ?? ''),
804
+ label: String(group?.name ?? group?.id ?? ''),
805
+ models: (Array.isArray(group?.models) ? group.models : [])
806
+ .map((item) => ({
807
+ value: String(item?.id ?? ''),
808
+ label: String(item?.name ?? item?.id ?? ''),
809
+ // 档位元数据随模型一起带出来(选模型后推理等级用它;没选模型时取并集)
810
+ efforts: (Array.isArray(item?.reasoning?.efforts) ? item.reasoning.efforts : [])
811
+ .map((level) => ({ value: String(level?.id ?? ''), label: String(level?.name ?? level?.id ?? '') }))
812
+ .filter((level) => level.value !== ''),
813
+ }))
814
+ .filter((item) => item.value !== ''),
815
+ })),
816
+ })
817
+ return true
818
+ }
819
+
820
+ /** 数据源 ②(兜底):remote.llm 的 listProviders / discoverModels。 */
821
+ const adoptCatalogFromRemote = (llmRemote) => {
822
+ if (llmRemote === undefined || llmRemote === null) return false
823
+ try {
824
+ setCatalog({
825
+ listProviders: async () => {
826
+ const rawList = await llmRemote.listProviders()
827
+ const list =
828
+ rawList !== null && typeof rawList === 'object' && rawList.ok === false ? [] : (rawList?.value ?? rawList)
829
+ return (Array.isArray(list) ? list : []).map((item) => ({
830
+ value: String(item?.id ?? ''),
831
+ label: String(item?.name ?? item?.id ?? ''),
832
+ }))
833
+ },
834
+ listModels: async (provider) => {
835
+ if (provider === '') return []
836
+ const rawResult = await llmRemote.discoverModels(NAMESPACE_FOR_DISCOVERY, { provider })
837
+ const result =
838
+ rawResult !== null && typeof rawResult === 'object' && rawResult.ok === false
839
+ ? undefined
840
+ : (rawResult?.value ?? rawResult)
841
+ const models = Array.isArray(result?.models) ? result.models : Array.isArray(result) ? result : []
842
+ return models
843
+ .map((item) =>
844
+ typeof item === 'string'
845
+ ? { value: item, label: item }
846
+ : {
847
+ value: String(item?.id ?? item?.model ?? ''),
848
+ label: String(item?.name ?? item?.id ?? item?.model ?? ''),
849
+ },
850
+ )
851
+ .filter((item) => item.value !== '')
852
+ },
853
+ })
854
+ return true
855
+ } catch (error) {
856
+ diag('构建 remote.llm 目录失败: ' + String(error?.message ?? error))
857
+ return false
858
+ }
859
+ }
860
+
861
+ /**
862
+ * 确保有数据源:先用 ctx 上现成的,再注册作用域注入(不阻塞 entry 激活)。
863
+ * apply 时调一次、Card 挂载时再调一次(那时服务一定就绪)。
864
+ */
865
+ /**
866
+ * 确保有数据源:先读 ctx 上现成的服务,再注册作用域注入(不阻塞 entry 激活)。
867
+ * 三段各自 try:任何一段失败都不能影响后面(曾经因为第一段抛异常导致后面从未执行 ✗)。
868
+ */
869
+ const ensureCatalog = () => {
870
+ if (activeStore === null || activeStore.catalog !== undefined) return
871
+ const ctx = pluginCtx
872
+ if (ctx === null) return
873
+
874
+ try {
875
+ const directories = readService(ctx, 'modelDirectories')
876
+ if (adoptCatalogFromDirectories(directories)) {
877
+ diag('数据源: modelDirectories(ctx 直读)')
878
+ return
879
+ }
880
+ } catch (error) {
881
+ diag('直读 modelDirectories 失败: ' + String(error?.message ?? error))
882
+ }
883
+
884
+ try {
885
+ const llmRemote = readService(readService(ctx, 'remote'), 'llm')
886
+ if (adoptCatalogFromRemote(llmRemote)) {
887
+ diag('数据源: remote.llm(ctx 直读)')
888
+ return
889
+ }
890
+ } catch (error) {
891
+ diag('直读 remote.llm 失败: ' + String(error?.message ?? error))
892
+ }
893
+
894
+ if (typeof ctx.inject !== 'function') {
895
+ diag('ctx.inject 不可用')
896
+ return
897
+ }
898
+ if (catalogRequested) return
899
+ catalogRequested = true
900
+ try {
901
+ diag('已注册作用域注入 modelDirectories')
902
+ ctx.inject(['modelDirectories'], (scope) => {
903
+ try {
904
+ const service = readService(scope, 'modelDirectories')
905
+ const adopted = adoptCatalogFromDirectories(service)
906
+ diag('modelDirectories 回调: ' + (adopted ? '拿到' : service === undefined || service === null ? '服务为空' : '形状不符'))
907
+ if (!adopted) {
908
+ const got = readService(readService(scope, 'remote'), 'llm')
909
+ diag('remote.llm 兜底: ' + (got === undefined || got === null ? '仍为空' : '拿到'))
910
+ adoptCatalogFromRemote(got)
911
+ }
912
+ } catch (error) {
913
+ diag('作用域回调异常: ' + String(error?.message ?? error))
914
+ }
915
+ })
916
+ } catch (error) {
917
+ diag('注册作用域注入失败: ' + String(error?.message ?? error))
918
+ }
919
+ }
920
+
921
+ // ── 配置卡:字段渲染 + 自动保存
922
+ function Card(props) {
923
+ const t = props.t
924
+ const store = props.formStore
925
+ // ① 组件自己持有"乐观本地状态":不管宿主 store 通不通知,点击/输入都立刻可见。
926
+ const [local, setLocal] = React.useState({})
927
+ const [, forceRender] = React.useReducer((n) => n + 1, 0)
928
+ // ② 同时订阅宿主 store:外部改动(保存/冲突刷新)也能反映进来。
929
+ React.useEffect(() => {
930
+ if (store === undefined || typeof store.subscribe !== 'function') return undefined
931
+ try {
932
+ const dispose = store.subscribe(() => forceRender())
933
+ return typeof dispose === 'function' ? dispose : undefined
934
+ } catch {
935
+ return undefined
936
+ }
937
+ }, [store])
938
+
939
+ // 模型目录是异步到手的:挂载时再试一次,并订阅它到达的事件(否则字段会永远停在输入框)
940
+ // ⚠️ effect 里**绝不能抛**:一旦抛出,React 会把整块设置面板卸载 → 表现为"界面没了" ✗
941
+ React.useEffect(() => {
942
+ try {
943
+ ensureCatalog()
944
+ return subscribeCatalog(() => forceRender())
945
+ } catch (error) {
946
+ return undefined
947
+ }
948
+ }, [])
949
+
950
+ if (props.view === 'summary') return t('summary')
951
+
952
+ const state = (store !== undefined && typeof store.getSnapshot === 'function' ? store.getSnapshot() : undefined) ?? {}
953
+ const readOnly = state.writable === false
954
+ const readField = (key) => {
955
+ if (Object.hasOwn(local, key)) return local[key]
956
+ const own = state[key]
957
+ // ⚠️ SettingsFormModel.field(field) 返回的是 `{ text, overridden, invalid }`
958
+ // —— 字段文本叫 **text**,不是 value(读错就会"保存后弹回兜底默认值")。
959
+ return own === undefined || own === null ? '' : String(own.text ?? '')
960
+ }
961
+ // 自动保存:编辑后防抖 400ms 再 save()(连续输入合并成一次写入;卸载时清掉定时器)
962
+ const saveTimer = useRefSafe(null)
963
+ const scheduleSave = () => {
964
+ try {
965
+ if (saveTimer.current !== null && saveTimer.current !== undefined) clearTimeout(saveTimer.current)
966
+ saveTimer.current = setTimeout(() => {
967
+ saveTimer.current = null
968
+ try {
969
+ if (typeof props.save === 'function') props.save()
970
+ } catch {
971
+ /* 保存失败由表单自身提示(saveFailed) */
972
+ }
973
+ }, 400)
974
+ if (typeof saveTimer.current?.unref === 'function') saveTimer.current.unref()
975
+ } catch {
976
+ /* 没有定时器就算了(不应发生) */
977
+ }
978
+ }
979
+ React.useEffect(
980
+ () => () => {
981
+ try {
982
+ if (saveTimer.current) clearTimeout(saveTimer.current)
983
+ } catch {
984
+ /* 忽略 */
985
+ }
986
+ },
987
+ [],
988
+ )
989
+
990
+ const writeField = (key, value) => {
991
+ setLocal((previous) => ({ ...previous, [key]: value }))
992
+ props.edit(key, value)
993
+ scheduleSave()
994
+ }
995
+ const clearField = (key) => {
996
+ setLocal((previous) => {
997
+ if (!Object.hasOwn(previous, key)) return previous
998
+ const next = { ...previous }
999
+ delete next[key]
1000
+ return next
1001
+ })
1002
+ props.resetField(key)
1003
+ scheduleSave()
1004
+ }
1005
+ // 调试项(advanced: true)默认不渲染:只有用户自己的配置里写了 devMode=true 才显示。
1006
+ // devMode 只在 FORM_FIELDS(表单清单)里,不在 FIELDS(渲染清单)里 —— 它本身不是一个可调项。
1007
+ const devModeOn = state.devMode?.text === 'true'
1008
+ const children = FIELDS.filter((field) => field.advanced !== true || devModeOn).map((field, fieldIndex) => {
1009
+ const own = state[field.key] ?? {}
1010
+ const common = {
1011
+ key: field.key,
1012
+ id: `escalation-review-${field.key}`,
1013
+ label: t(field.key),
1014
+ hint: t(`${field.key}Hint`),
1015
+ overriddenLabel: t('overridden'),
1016
+ resetLabel: t('reset'),
1017
+ invalidLabel: t('invalidNumber'),
1018
+ disabled: readOnly,
1019
+ first: fieldIndex === 0,
1020
+ prominent: field.prominent === true,
1021
+ ...own,
1022
+ }
1023
+ const staged = readField(field.key)
1024
+
1025
+ if (field.kind === 'segment') {
1026
+ return createElement(
1027
+ FieldShell,
1028
+ {
1029
+ ...common,
1030
+ overridden: own.overridden === true,
1031
+ onReset: () => clearField(field.key),
1032
+ },
1033
+ createElement(primitives.SegmentedControl, {
1034
+ id: `escalation-review-${field.key}-seg`,
1035
+ label: t(field.key),
1036
+ value: staged.trim() || field.fallback || field.options[0].value,
1037
+ options: field.options.map((option) => ({ value: option.value, label: t(option.label) })),
1038
+ disabled: readOnly,
1039
+ onChange: (value) => writeField(field.key, value),
1040
+ }),
1041
+ )
1042
+ }
1043
+
1044
+ if (field.kind === 'switch') {
1045
+ return createElement(
1046
+ FieldShell,
1047
+ {
1048
+ ...common,
1049
+ overridden: own.overridden === true,
1050
+ onReset: () => clearField(field.key),
1051
+ },
1052
+ createElement(primitives.Switch, {
1053
+ checked: (staged.trim() || field.fallback || '').toLowerCase() === 'true',
1054
+ label: t(field.key),
1055
+ disabled: readOnly,
1056
+ onChange: (next) => writeField(field.key, next ? 'true' : ''),
1057
+ }),
1058
+ )
1059
+ }
1060
+
1061
+ if (field.kind === 'textarea') {
1062
+ return createElement(
1063
+ FieldShell,
1064
+ {
1065
+ ...common,
1066
+ overridden: own.overridden === true,
1067
+ onReset: () => clearField(field.key),
1068
+ },
1069
+ createElement('textarea', {
1070
+ id: `escalation-review-${field.key}`,
1071
+ rows: field.rows ?? 4,
1072
+ style: styles.textarea,
1073
+ disabled: readOnly,
1074
+ value: staged,
1075
+ onChange: (event) => writeField(field.key, event.target.value),
1076
+ }),
1077
+ )
1078
+ }
1079
+
1080
+ if (field.kind === 'reviewerModel') {
1081
+ // ⚠️ 整段包 try:任何异常都退回输入框,绝不让一个字段把设置面板拖垮(曾经如此 ✗)
1082
+ try {
1083
+ const catalog = props.formStore?.catalog
1084
+ return createElement(ModelPicker, {
1085
+ ...common,
1086
+ t,
1087
+ catalog,
1088
+ provider: String(readField('provider') ?? '').trim(),
1089
+ model: String(readField('model') ?? '').trim(),
1090
+ effort: String(readField('reasoningEffort') ?? '').trim(),
1091
+ onPick: ({ provider: nextProvider, model: nextModel, effort: nextEffort }) => {
1092
+ // 跟随会话(provider/model 都空)时不允许带档位 —— 双保险,防止历史遗留值残留
1093
+ const safeEffort = nextProvider === '' && nextModel === '' ? '' : nextEffort
1094
+ writeField('provider', nextProvider)
1095
+ writeField('model', nextModel)
1096
+ writeField('reasoningEffort', safeEffort)
1097
+ },
1098
+ })
1099
+ } catch (error) {
1100
+ return createElement(primitives.SettingsValueField, {
1101
+ ...common,
1102
+ hint: (common.hint === undefined ? '' : String(common.hint)) + t('dropdownUnavailable'),
1103
+ numeric: false,
1104
+ onEdit: (text) => writeField('model', text),
1105
+ onReset: () => clearField('model'),
1106
+ })
1107
+ }
1108
+ }
1109
+
1110
+ return createElement(primitives.SettingsValueField, {
1111
+ ...common,
1112
+ numeric: field.numeric === true,
1113
+ onEdit: (text) => writeField(field.key, text),
1114
+ onReset: () => props.resetField(field.key),
1115
+ })
1116
+ })
1117
+ // 自己搭框(不再用 SettingsForm):所有字段自动保存,保留"保存"按钮只会让人困惑。
1118
+ // 保留两种真正有用的提示:只读部署、以及保存被拒(saveFailed)。
1119
+ return createElement('div', { key: 'frame', style: styles.form }, [
1120
+ state.writable === false
1121
+ ? createElement('p', { key: 'readonly', style: styles.hintNote }, t('readOnly'))
1122
+ : null,
1123
+ state.failed === true
1124
+ ? createElement('p', { key: 'save-failed', role: 'status', style: styles.notice }, t('saveFailed'))
1125
+ : null,
1126
+ createElement('p', { key: 'autosave-note', style: styles.hintNote }, t('autoSaveNote')),
1127
+ ...children,
1128
+ ])
1129
+ }
1130
+
1131
+ /**
1132
+ * 挂载配置页:Host 服务该命名空间时,往「插件」页的 bundle 配置槽位注册一页。
1133
+ * @param ctx - 浏览器插件上下文。
1134
+ */
1135
+ // ── 插件入口:词典 / 表单 / 槽位注册
1136
+ // ── 审核卡片(官方扩展路径:ConversationNodeDefinition + conversation.chat.node)────────
1137
+ // 数据只从**已有事件**推导(tool/call 的 sandbox_permissions)。第三方插件无法给自定义事件打
1138
+ // ignorable 标记(append 第三个参数只对 surface 事件开放),写进去会让别的构建拒收整份会话,
1139
+ // 所以这里绝不写事件。判定与理由需要宿主→客户端通道。
1140
+ const REVIEW_CARD_KIND = 'escalation-review'
1141
+
1142
+ /**
1143
+ * 取 `tool/call` 的命令参数。
1144
+ * ⚠️ 会话日志里 `data.arguments` 是 **JSON 字符串**(2026-09-28 实测 session.v4.jsonl:
1145
+ * `typeof data.arguments === "string"`),不是对象 —— 当对象读会永远匹配不上,卡片永不出现。
1146
+ */
1147
+ function reviewCardArguments(data) {
1148
+ const raw = data?.arguments
1149
+ if (raw !== null && raw !== undefined && typeof raw === 'object') return raw
1150
+ if (typeof raw !== 'string') return undefined
1151
+ try {
1152
+ const parsed = JSON.parse(raw)
1153
+ return parsed !== null && typeof parsed === 'object' ? parsed : undefined
1154
+ } catch {
1155
+ return undefined
1156
+ }
1157
+ }
1158
+
1159
+ /** 卡片状态文案(协议值 → 中文短语)。 */
1160
+ const REVIEW_CARD_STATUS = {
1161
+ running: '评审中',
1162
+ approval: '待审批',
1163
+ ran: '已放行',
1164
+ denied: '已拒绝',
1165
+ ranFailed: '已执行(工具报错)',
1166
+ }
1167
+
1168
+ /**
1169
+ * 状态语义色(官方 ui-theme 的 token,design-platform.css):
1170
+ * ran=绿(success) / denied=红(error,官方叫 error 不叫 danger) / ranFailed=黄(warn) / 其余中性。
1171
+ * 一律写 `var(--token, inherit)` —— token 万一不存在也不会变成透明。
1172
+ */
1173
+ const REVIEW_CARD_STATUS_COLOR = {
1174
+ running: 'var(--dsw-alias-label-tertiary, inherit)',
1175
+ approval: 'var(--dsw-alias-state-idle-primary, inherit)',
1176
+ ran: 'var(--dsw-alias-state-success-primary, inherit)',
1177
+ denied: 'var(--dsw-alias-state-error-primary, inherit)',
1178
+ ranFailed: 'var(--dsw-alias-state-warn-primary, inherit)',
1179
+ }
1180
+
1181
+ /**
1182
+ * 从审批请求的 reason 里只取**评审自己那段**理由。
1183
+ * 宿主记录的 reason 形如:`<工具话术> | escalation-review: risk=low decision=allow — <rationale>`,
1184
+ * 卡片只需要最后那段 rationale(把工具话术糊上去正是上一版难看的原因)。
1185
+ */
1186
+ function reviewCardReviewNote(raw) {
1187
+ if (typeof raw !== 'string' || raw.length === 0) return ''
1188
+ const marker = raw.indexOf('escalation-review:')
1189
+ // 没有标记就**不是**评审写的理由(实测:approval/asked.reason 里只有工具那句
1190
+ // "escalate sandbox to danger-full-access: ...")→ 宁可不显示,也不能冒充评审理由
1191
+ if (marker < 0) return ''
1192
+ const tail = raw.slice(marker + 'escalation-review:'.length)
1193
+ const dash = tail.indexOf(' — ')
1194
+ const body = dash >= 0 ? tail.slice(dash + 3) : tail
1195
+ return reviewCardReason(body)
1196
+ }
1197
+
1198
+ /**
1199
+ * 取这条调用对应的评审判定(投影送来的)。
1200
+ * 客户端拿到 `projectionValues` 的合法来源不止一个(官方槽位给的是会话对象,
1201
+ * 而 summary/管理器才带 projectionValues),所以依次探测、拿不到就返回 undefined:
1202
+ * 1) props.session.projectionValues(有些视图直接给会话)
1203
+ * 2) props.useSession().projectionValues(standing seat)
1204
+ * 3) props.sessions.projectionValues(sessionId)(客户端 session 管理器的方法)
1205
+ * 4) props.session.summary.projectionValues / props.sessionSummary.projectionValues
1206
+ */
1207
+ /**
1208
+ * 取这条调用对应的评审判定(宿主用 sessionProjections 送来的)。
1209
+ *
1210
+ * 来源顺序按**实测形状**排定(客户端探针结论):useSessions() 返回
1211
+ * { ids, byId, phase, projectionsBySession };useProjection('escalation-review') 返回判定表。
1212
+ * ⚠️ 必须严格按 callId 取。曾经用"在 props 里随便找一个带 rationale 的对象"当兜底,
1213
+ * 结果把别的调用的理由贴到了评审中的卡片上。
1214
+ * ⚠️ 本函数会提前 return,绝不能在这里调用 hook(seat 值由视图顶层传入)。
1215
+ */
1216
+ function reviewCardProjectedVerdict(props, callId, seats) {
1217
+ if (typeof callId !== 'string' || callId.length === 0) return undefined
1218
+ const KEY = 'escalation-review'
1219
+ const entryOf = (bucket) => {
1220
+ if (bucket === null || bucket === undefined || typeof bucket !== 'object') return undefined
1221
+ const entry = bucket[callId]
1222
+ return entry !== undefined && entry !== null && typeof entry === 'object' ? entry : undefined
1223
+ }
1224
+ // values 形如 { [KEY]: { [callId]: 判定 } }
1225
+ const pick = (values) => entryOf(values === null || values === undefined ? undefined : values[KEY])
1226
+ const sessionId = props?.sessionId ?? props?.session?.id ?? props?.session?.sessionId
1227
+ const sources = [
1228
+ () => {
1229
+ const bySession = seats?.sessionsState?.projectionsBySession
1230
+ if (bySession === null || bySession === undefined || sessionId === undefined) return undefined
1231
+ const values = bySession[sessionId]
1232
+ return entryOf(values === null || values === undefined ? undefined : values[KEY]) ?? entryOf(values)
1233
+ },
1234
+ // 会话对象上的投影值(useSession() 本机实测会抛错,但若将来可用,这里就能直接吃)
1235
+ () => pick(seats?.sessionValue?.projectionValues),
1236
+ () => pick(seats?.sessionValue?.summary?.projectionValues),
1237
+ // state.byId[sessionId] 形状(诊断显示 state 里确实有 byId)
1238
+ () => pick(seats?.sessionsState?.byId?.[sessionId]?.projectionValues),
1239
+ () => pick(props?.sessionSummary?.projectionValues),
1240
+ () => pick(props?.session?.projectionValues),
1241
+ () => pick(props?.session?.summary?.projectionValues),
1242
+ () => entryOf(seats?.projectionValues),
1243
+ () => pick(seats?.projectionValues),
1244
+ () => pick(seats?.sessionsState?.projectionValues),
1245
+ () => pick(seats?.sessionsState?.summary?.projectionValues),
1246
+ // 客户端 sessions 管理器(当前版本实测不是方法,保留为未来形状的兜底)
1247
+ () => {
1248
+ const manager = props?.sessions
1249
+ if (manager === null || manager === undefined || typeof manager.projectionValues !== 'function') return undefined
1250
+ return sessionId === undefined ? undefined : pick(manager.projectionValues(sessionId))
1251
+ },
1252
+ ]
1253
+ for (const source of sources) {
1254
+ try {
1255
+ const hit = source()
1256
+ if (hit !== undefined) return hit
1257
+ } catch {
1258
+ /* 单个来源失败不影响其他来源 */
1259
+ }
1260
+ }
1261
+ return undefined
1262
+ }
1263
+
1264
+ /** 盾牌图标(官方 `ui-primitives` 的 IconShieldOutlineRegular);假模块表里可能没有 → 能力判断。 */
1265
+ const REVIEW_CARD_SHIELD =
1266
+ primitives !== null && primitives !== undefined && typeof primitives.IconShieldOutlineRegular === 'function'
1267
+ ? primitives.IconShieldOutlineRegular
1268
+ : null
1269
+
1270
+ /** useState 的安全取用:调用方的 React 实现可能没有 hooks。 */
1271
+ const useStateSafe = (initial) => (typeof React.useState === 'function' ? React.useState(initial) : [initial, () => {}])
1272
+
1273
+ /** 把工具结果(内容块 / 错误)拍平成文本。 */
1274
+ function reviewCardResultText(data) {
1275
+ const blocks = Array.isArray(data?.content) ? data.content : []
1276
+ const parts = []
1277
+ for (const block of blocks) {
1278
+ if (block !== null && typeof block === 'object' && typeof block.text === 'string') parts.push(block.text)
1279
+ }
1280
+ if (typeof data?.error?.message === 'string') parts.push(data.error.message)
1281
+ else if (typeof data?.error === 'string') parts.push(data.error)
1282
+ return parts.join('\n')
1283
+ }
1284
+
1285
+ /** 从结果文本里抽理由:优先取插件拒绝文案里的 "-- reason:" 之后那段。 */
1286
+ function reviewCardReason(text) {
1287
+ if (typeof text !== 'string' || text.length === 0) return ''
1288
+ const marker = text.indexOf('-- reason:')
1289
+ const raw = marker >= 0 ? text.slice(marker + '-- reason:'.length) : text
1290
+ const oneLine = raw.replace(/\s+/g, ' ').trim()
1291
+ return oneLine.length > 240 ? oneLine.slice(0, 240) + '…' : oneLine
1292
+ }
1293
+
1294
+ /** 只认请求越界到 danger-full-access 的调用。 */
1295
+ function makeReviewCardDefinition() {
1296
+ return {
1297
+ kind: REVIEW_CARD_KIND,
1298
+ target: 'chat',
1299
+ match: (event) => {
1300
+ if (event === null || event === undefined) return null
1301
+ const data = event.data ?? {}
1302
+ if (event.type === 'tool/call') {
1303
+ const callId = typeof data.callId === 'string' && data.callId.length > 0 ? data.callId : undefined
1304
+ if (callId === undefined) return null
1305
+ const args = reviewCardArguments(data)
1306
+ if (args === undefined || args.sandbox_permissions !== 'danger-full-access') return null
1307
+ return { id: callId, role: 'start' }
1308
+ }
1309
+ // 结果与审批只作 update;引擎对"只有 update、还没看到 start"的记录会挂起,不会乱画。
1310
+ // ⚠️ tool/result **没有** data.callId(实测类型:{ turn, step, message, error? }),
1311
+ // 关联键在 data.message.toolCallId;审批事件则用 data.callId。
1312
+ if (event.type === 'tool/result') {
1313
+ const id = data.message?.toolCallId
1314
+ return typeof id === 'string' && id.length > 0 ? { id, role: 'update' } : null
1315
+ }
1316
+ if (event.type === 'approval/asked') {
1317
+ const id = data.callId
1318
+ return typeof id === 'string' && id.length > 0 ? { id, role: 'update' } : null
1319
+ }
1320
+ return null
1321
+ },
1322
+ start: (_context, match) => {
1323
+ const data = match.event.data ?? {}
1324
+ const args = reviewCardArguments(data)
1325
+ return {
1326
+ tool: typeof data.name === 'string' ? data.name : 'tool',
1327
+ requested: typeof args?.sandbox_permissions === 'string' ? args.sandbox_permissions : 'danger-full-access',
1328
+ status: 'running',
1329
+ reason: undefined,
1330
+ }
1331
+ },
1332
+ update: (context, match) => {
1333
+ const state = context.state
1334
+ const type = match?.event?.type
1335
+ const data = match?.event?.data ?? {}
1336
+ if (state === undefined || state === null) return state
1337
+ if (type === 'approval/asked') {
1338
+ // 宿主把"评审结论 + 理由"并进了审批请求的 reason(放行路径)
1339
+ const note = reviewCardReviewNote(data.reason)
1340
+ return {
1341
+ ...state,
1342
+ status: state.status === 'running' || state.status === undefined ? 'approval' : state.status,
1343
+ approvalReason: note.length > 0 ? note : state.approvalReason,
1344
+ }
1345
+ }
1346
+ if (type === 'tool/result') {
1347
+ const isError = data.message?.isError === true || (data.error !== undefined && data.error !== null)
1348
+ // 只有**本插件的拒绝**才算"已拒绝";工具自己执行失败不能冤枉评审
1349
+ const errorName = typeof data.error?.name === 'string' ? data.error.name : ''
1350
+ const errorCode = typeof data.error?.code === 'string' ? data.error.code : ''
1351
+ const deniedByPlugin = errorName === 'EscalationReviewDeniedError' || errorCode === 'ESCALATION_REVIEW_DENIED'
1352
+ // 拒绝理由:优先取 error.reason(宿主把插件返回的 info.reason 放在这里),
1353
+ // 其次才从结果文本里抠 "-- reason:"
1354
+ const fromError = typeof data.error?.reason === 'string' && data.error.reason.length > 0
1355
+ ? reviewCardReason(data.error.reason)
1356
+ : ''
1357
+ const reason = fromError.length > 0 ? fromError : reviewCardReason(reviewCardResultText(data))
1358
+ const status = isError ? (deniedByPlugin ? 'denied' : 'ranFailed') : 'ran'
1359
+ return { ...state, status, reason }
1360
+ }
1361
+ return state
1362
+ },
1363
+ buildViewNode: (context) => {
1364
+ const state = context.state
1365
+ if (state === undefined || state === null) return null
1366
+ return {
1367
+ key: context.key,
1368
+ kind: REVIEW_CARD_KIND,
1369
+ id: context.id,
1370
+ target: 'chat',
1371
+ anchorSeq: (context.start ?? context.matches[0])?.event?.seq ?? 0,
1372
+ location: (context.start ?? context.matches[0])?.location ?? { kind: 'unresolved' },
1373
+ visibility: 'visible',
1374
+ data: {
1375
+ callId: typeof context.id === 'string' ? context.id : '',
1376
+ tool: state.tool,
1377
+ requested: state.requested,
1378
+ status: state.status,
1379
+ reason: state.reason ?? state.approvalReason,
1380
+ },
1381
+ }
1382
+ },
1383
+ }
1384
+ }
1385
+
1386
+ /**
1387
+ * 取会话相关的两个 standing seat。
1388
+ * ⚠️ 必须在**组件顶层**调用:两处调用顺序恒定,次数不随数据变化(hooks 规则)。
1389
+ */
1390
+ /**
1391
+ * 取会话相关的 seat。只调用**实测可用**的两个,顺序固定。
1392
+ * 实测(客户端探针):useSession() 在本机会抛错("l is not a function"),而渲染期任何抛错
1393
+ * 都会打断整个组件(卡片消失),所以绝不调用它。
1394
+ */
1395
+ function useReviewCardSeats(props) {
1396
+ const projectionValues = typeof props?.useProjection === 'function'
1397
+ ? props.useProjection('escalation-review')
1398
+ : undefined
1399
+ const sessionsState = typeof props?.useSessions === 'function' ? props.useSessions((state) => state) : undefined
1400
+ return { projectionValues, sessionsState }
1401
+ }
1402
+
1403
+ /** 提权卡片视图:一行摘要,"状态:理由"可折叠。 */
1404
+ /** 提权卡片视图:一行摘要,"状态:理由"可折叠。 */
1405
+ function ReviewCardView(props) {
1406
+ const [open, setOpen] = useStateSafe(false)
1407
+ // hooks 只到这里为止;下面全部是纯计算
1408
+ const seats = useReviewCardSeats(props)
1409
+ const data = props?.node?.data ?? {}
1410
+ const tool = typeof data.tool === 'string' ? data.tool : 'tool'
1411
+ const requested = typeof data.requested === 'string' ? data.requested : 'danger-full-access'
1412
+ const status = typeof data.status === 'string' ? data.status : 'running'
1413
+ const label = REVIEW_CARD_STATUS[status] ?? status
1414
+ const statusColor = REVIEW_CARD_STATUS_COLOR[status] ?? 'inherit'
1415
+ const projectedVerdict = reviewCardProjectedVerdict(props, data.callId, seats)
1416
+ const projected = typeof projectedVerdict?.rationale === 'string' && projectedVerdict.rationale.length > 0
1417
+ ? projectedVerdict.rationale
1418
+ : typeof projectedVerdict?.reason === 'string' ? projectedVerdict.reason : ''
1419
+ const direct = typeof data.reason === 'string' ? data.reason : ''
1420
+ const reason = direct.length > 0 ? direct : projected
1421
+ const short = reason.length > 32 ? reason.slice(0, 32) + '…' : reason
1422
+ // 字号/行高/颜色放在外层:行与展开正文共用同一套排版
1423
+ const outerStyle = {
1424
+ minWidth: 0,
1425
+ fontSize: 'var(--dsh-content-font-size-secondary, 13px)',
1426
+ lineHeight: 'calc(24px + var(--dsh-content-font-delta, 0px))',
1427
+ color: 'var(--dsw-alias-label-tertiary)',
1428
+ }
1429
+ const rowStyle = { display: 'flex', alignItems: 'baseline', gap: 6, minWidth: 0 }
1430
+ const headStyle = {
1431
+ display: 'flex',
1432
+ alignItems: 'center',
1433
+ gap: 6,
1434
+ cursor: reason === '' ? 'default' : 'pointer',
1435
+ userSelect: 'none',
1436
+ minWidth: 0,
1437
+ overflow: 'hidden',
1438
+ textOverflow: 'ellipsis',
1439
+ whiteSpace: 'nowrap',
1440
+ }
1441
+ const titleStyle = { fontWeight: 500, color: 'var(--dsw-alias-label-secondary)' }
1442
+ // 状态(上色 + 加粗)与理由分开;外层不设 gap,避免 "已放行 :理由" 多出空隙
1443
+ const statusItem = createElement('span', { key: 'st', style: { display: 'inline-flex', alignItems: 'center', minWidth: 0 } }, [
1444
+ createElement('span', { key: 'label', style: { fontWeight: 600, color: statusColor } }, label),
1445
+ reason === '' ? null : createElement('span', { key: 'reason' }, ':' + (open ? '' : short) + (open ? '' : ' ▸')),
1446
+ ])
1447
+ const body = open
1448
+ ? [
1449
+ createElement('div', { key: 'why', style: { paddingTop: 2, whiteSpace: 'pre-wrap', color: 'var(--dsw-alias-label-secondary)' } }, reason),
1450
+ createElement('div', { key: 'meta', style: { paddingTop: 2, color: 'var(--dsw-alias-label-caption)' } }, tool + ' → ' + requested),
1451
+ ]
1452
+ : []
1453
+ try {
1454
+ // 外层必须是块级容器:rowStyle 是 flex,若把展开正文塞进它会变成并排的 flex 项
1455
+ return createElement('div', { style: outerStyle }, [
1456
+ createElement('div', { key: 'row', style: rowStyle }, [
1457
+ createElement('span', { key: 'head', style: headStyle, onClick: reason === '' ? undefined : () => setOpen(!open) }, [
1458
+ REVIEW_CARD_SHIELD === null ? null : createElement(REVIEW_CARD_SHIELD, { key: 'icon', size: 14 }),
1459
+ createElement('span', { key: 'title', style: titleStyle }, '越界提权审查'),
1460
+ createElement('span', { key: 'a', style: { color: 'var(--dsw-alias-label-caption)' } }, '→'),
1461
+ statusItem,
1462
+ ]),
1463
+ ]),
1464
+ ].concat(open ? body : []))
1465
+ } catch {
1466
+ // 兜底:宁可退化成一行纯文字,也不能让卡片整张消失
1467
+ return createElement('div', { style: outerStyle }, [
1468
+ createElement('span', { key: 'fallback', style: { fontWeight: 500 } }, '越界提权审查'),
1469
+ createElement('span', { key: 'st' }, ' → ' + label),
1470
+ ])
1471
+ }
1472
+ }
1473
+
1474
+ function apply(ctx) {
1475
+ ensureStyle()
1476
+ pluginCtx = ctx
1477
+ try {
1478
+ const t = ctx.locale.bind(NS)
1479
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'escalation-review: dictionaries')
1480
+
1481
+ const scope = ctx.configForms.get(ENTRY)
1482
+ const spec = FORM_FIELDS.map((field) =>
1483
+ field.numeric ? primitives.settingsNumberField(field.key) : primitives.settingsTextField(field.key),
1484
+ )
1485
+ const form = new primitives.SettingsFormModel(scope, spec)
1486
+ const project = () => {
1487
+ const frame = typeof form.shell === 'function' ? form.shell() : {}
1488
+ const fields = {}
1489
+ for (const field of FORM_FIELDS) fields[field.key] = form.field(field.key)
1490
+ return { ...frame, ...fields }
1491
+ }
1492
+ const inner = form.bind(project)
1493
+ // ⚠️ 自己的稳定 store:**不依赖槽位的 hooks 约定**(那个槽位不一定转发 hooks,
1494
+ // 转发了组件也拿不到 → 表现为"点了内部有改动、界面不动")。直接作为普通 prop 传进去,
1495
+ // 组件用 React.useSyncExternalStore 订阅(props.formStore 的引用在 apply 里固定,稳定 ✓)。
1496
+ const formStore = {
1497
+ getSnapshot: () => {
1498
+ try {
1499
+ return typeof inner?.getSnapshot === 'function' ? inner.getSnapshot() : project()
1500
+ } catch {
1501
+ return project()
1502
+ }
1503
+ },
1504
+ subscribe: (listener) => {
1505
+ try {
1506
+ return typeof inner?.subscribe === 'function' ? inner.subscribe(listener) : () => {}
1507
+ } catch {
1508
+ return () => {}
1509
+ }
1510
+ },
1511
+ }
1512
+ // 模型目录:只登记 store;**探测放在页面注册完成之后**(避免探测异常影响页面注册)
1513
+ activeStore = formStore
1514
+
1515
+ ctx.effect(
1516
+ () => () => {
1517
+ try {
1518
+ form.dispose()
1519
+ } catch {
1520
+ /* 释放失败不影响卸载 */
1521
+ }
1522
+ },
1523
+ 'escalation-review: form subscription',
1524
+ )
1525
+
1526
+ // 槽位选型(官方 ui-plugin-manager README):
1527
+ // plugins.item —— **官方插件**列表(安装自带的那批,按 label 进 Official 组)
1528
+ // plugins.bundle.config —— 按 **bundle 包名**,显示在 bundle 详情页描述与组件行之间(bundle 自己的配置)
1529
+ // plugins.row.config —— 按 `<包名>#<行 id>`,**给那一行一个 Configure 入口** → 正是本插件的情形
1530
+ // (配置命名空间属于 bundle 里的那一行 `escalation-review`,不是 bundle 自身)
1531
+ // 三个都注册:页面按 subject/key 只渲染匹配的那个,注册本身不冲突。
1532
+ const ROW_KEY = `${BUNDLE}#${ENTRY}`
1533
+ ctx.effect(
1534
+ () =>
1535
+ ctx.configForms.whileServed([ENTRY], () => {
1536
+ return ctx.slots.inject('plugins.row.config', () =>
1537
+ ctx.slots.register(
1538
+ {
1539
+ name: 'plugins.row.config',
1540
+ key: ROW_KEY,
1541
+ locale: NS,
1542
+ inject: () => ({
1543
+ formStore,
1544
+ ...form.actions(),
1545
+ }),
1546
+ },
1547
+ Card,
1548
+ ),
1549
+ )
1550
+ }),
1551
+ 'escalation-review: row configuration page',
1552
+ )
1553
+ // 再注册一份到 **bundle 详情页**(key = bundle 包名):那里正是"描述与组件行之间",
1554
+ // 也是用户最自然的寻找位置。两处都注册 → 页面按 subject 只渲染匹配的那个,不冲突。
1555
+ ctx.effect(
1556
+ () =>
1557
+ ctx.configForms.whileServed([ENTRY], () =>
1558
+ ctx.slots.inject('plugins.bundle.config', () =>
1559
+ ctx.slots.register(
1560
+ {
1561
+ name: 'plugins.bundle.config',
1562
+ key: BUNDLE,
1563
+ locale: NS,
1564
+ inject: () => ({
1565
+ formStore,
1566
+ ...form.actions(),
1567
+ }),
1568
+ },
1569
+ Card,
1570
+ ),
1571
+ ),
1572
+ ),
1573
+ 'escalation-review: bundle configuration page',
1574
+ )
1575
+ console.info(`[escalation-review] 配置页已注册(plugins.row.config key=${ROW_KEY} + plugins.bundle.config key=${BUNDLE})`)
1576
+ void t
1577
+ // 页面已注册完毕,这时再探测模型目录(出问题也只会退回手填)
1578
+ try {
1579
+ ensureCatalog()
1580
+ } catch (error) {
1581
+ diag('ensureCatalog 异常: ' + String(error?.message ?? error))
1582
+ }
1583
+ } catch (error) {
1584
+ console.error('[escalation-review] 客户端 apply 失败', error)
1585
+ }
1586
+ // 一次性清理:删掉早期定位客户端形状时写进 localStorage 的诊断键
1587
+ try {
1588
+ if (typeof localStorage !== 'undefined') {
1589
+ for (const staleKey of ['er-diag', 'er-diag2', 'er-diag3', 'er-diag4']) localStorage.removeItem(staleKey)
1590
+ }
1591
+ } catch {
1592
+ /* 清理失败无所谓 */
1593
+ }
1594
+ // ── 审核卡片:把"这次调用是越界提权"作为会话里的一行 ──────────────────────
1595
+ // ⚠️ uiConversation 用运行时 ctx.get 取,不写进声明式 inject:服务缺席时声明式注入会让整个
1596
+ // 客户端 entry 永久 pending(GUI 不挂载),这个坑踩过两次。
1597
+ // ⚠️ 用**作用域注入**等 uiConversation(官方做法:ctx.inject([...], scope => …)):
1598
+ // · 不能用 ctx.get —— 服务由 ui-conversation 包提供,可能在本 entry apply 之后才就绪,
1599
+ // 取不到就永久跳过(第一版就是这个毛病);
1600
+ // · 不能写进声明式 inject —— 服务缺席时 entry 永久 pending,整个 GUI 不挂载。
1601
+ // 作用域注入两者都避开:服务到达即回调,且不阻塞本 entry 激活。
1602
+ try {
1603
+ ctx.inject(['uiConversation'], (scope) => {
1604
+ try {
1605
+ // 取值走 get(name):cordis 里访问未声明属性会抛,property proxy 又对装配顺序敏感
1606
+ const uiConversation = typeof scope.get === 'function' ? scope.get('uiConversation') : undefined
1607
+ if (uiConversation === undefined || uiConversation === null
1608
+ || uiConversation.events === undefined || uiConversation.events === null
1609
+ || typeof uiConversation.events.register !== 'function') {
1610
+ console.info('[escalation-review] uiConversation 已就绪但没有 events.register,跳过审核卡片')
1611
+ return
1612
+ }
1613
+ scope.effect(
1614
+ () => uiConversation.events.register(makeReviewCardDefinition()),
1615
+ 'escalation-review: conversation node',
1616
+ )
1617
+ // 把客户端 sessions 服务通过 inject 面交给视图(官方规矩:inject 返回纯数据与回调)
1618
+ let clientSessions
1619
+ try {
1620
+ clientSessions = typeof scope.get === 'function' ? scope.get('sessions') : undefined
1621
+ } catch {
1622
+ clientSessions = undefined
1623
+ }
1624
+ scope.slots.inject('conversation.chat.node', () =>
1625
+ scope.slots.register({
1626
+ name: 'conversation.chat.node',
1627
+ key: REVIEW_CARD_KIND,
1628
+ inject: () => ({ sessions: clientSessions }),
1629
+ locale: NS,
1630
+ }, ReviewCardView),
1631
+ )
1632
+ console.info('[escalation-review] 审核卡片已注册(conversation.chat.node key=' + REVIEW_CARD_KIND + ')')
1633
+ } catch (error) {
1634
+ console.info('[escalation-review] 审核卡片注册失败(不影响配置页): ' + String(error?.message ?? error))
1635
+ }
1636
+ })
1637
+ } catch (error) {
1638
+ console.info('[escalation-review] 审核卡片作用域注入失败(不影响配置页): ' + String(error?.message ?? error))
1639
+ }
1640
+
1641
+ }
1642
+
1643
+ // 覆盖兜底值(服务名必须是 0.1.7 实有的:slots / locale / configForms)
1644
+ exports.NS = NS
1645
+ exports.ENTRY = ENTRY
1646
+ exports.apply = apply
1647
+ // ⚠️ 定论(实测两次):声明式 inject 里写 'remote' / 'remote.llm' 会让本 entry **永久 pending**
1648
+ // → 客户端界面(配置页)整个不注册。其它插件能这么写,不代表本插件可以
1649
+ // (激活顺序 / 远程连接时机不同)。命名空间一律走**作用域注入**(不阻塞 entry 激活)。
1650
+ exports.inject = ['slots', 'locale', 'configForms']
1651
+ // 诊断钩子:验证"数据源晚到 → 通知订阅者"
1652
+ try {
1653
+ exports.__diagnostics = {
1654
+ ensureCatalog,
1655
+ subscribeCatalog,
1656
+ catalogDiag: () => [...catalogDiag],
1657
+ makeReviewCardDefinition,
1658
+ ReviewCardView,
1659
+ reviewCardProjectedVerdict,
1660
+ REVIEW_CARD_STATUS_COLOR,
1661
+ REVIEW_CARD_STATUS,
1662
+ }
1663
+ } catch {
1664
+ /* 诊断钩子失败不影响插件 */
1665
+ }
1666
+ } catch (error) {
1667
+ console.error('[escalation-review] 客户端模块加载失败(已降级为无事发生的插件)', error)
1668
+ }
1669
+
1670
+ return module.exports
1671
+ },
1672
+ })