dsh-approval-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/LICENSE +15 -0
- package/README-zh.md +240 -0
- package/README.md +333 -0
- package/cordis.patch.yml +127 -0
- package/lib/client.js +1014 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +321 -0
- package/lib/index.js +2214 -0
- package/package.json +103 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-approval-review",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
|
+
/**
|
|
10
|
+
* Reset the nearest scrollable ancestor that actually overflows.
|
|
11
|
+
*
|
|
12
|
+
* "Actually overflows" matters: an ancestor with `overflow-y: auto` but no
|
|
13
|
+
* overflow cannot be scrolled, so resetting it is a no-op that would hide the
|
|
14
|
+
* real scroller further up.
|
|
15
|
+
* @param from - the element whose ancestry to search (usually the view root).
|
|
16
|
+
* @param overflowYOf - computed `overflow-y` accessor for one node.
|
|
17
|
+
* @param maxDepth - stop after this many ancestors.
|
|
18
|
+
* @returns the node that was reset, or undefined when none qualified.
|
|
19
|
+
*/
|
|
20
|
+
function resetScrollableAncestorToTop(from, overflowYOf, maxDepth = 12) {
|
|
21
|
+
let node = from?.parentElement ?? null;
|
|
22
|
+
for (let depth = 0; node !== null && depth < maxDepth; depth += 1, node = node.parentElement) {
|
|
23
|
+
const overflowY = overflowYOf(node);
|
|
24
|
+
if (overflowY !== "auto" && overflowY !== "scroll") continue;
|
|
25
|
+
if (node.scrollHeight <= node.clientHeight) continue;
|
|
26
|
+
node.scrollTop = 0;
|
|
27
|
+
return node;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/client/model-choices.ts
|
|
32
|
+
/**
|
|
33
|
+
* Reviewer-route choices for the Approvals tab's model picker.
|
|
34
|
+
*
|
|
35
|
+
* The reviewer runs as a subagent, so the routes a deployment actually offers it
|
|
36
|
+
* are already published as session projections — this module just reads them
|
|
37
|
+
* instead of hardcoding a model list that would go stale:
|
|
38
|
+
*
|
|
39
|
+
* - `subagentModelSelectionPolicy`: the deployment's allowed subagent routes
|
|
40
|
+
* (`subagent-model-selection.allowedModels` in `settings.yaml`);
|
|
41
|
+
* - `modelSelection.lastUsed`: the session's own route, i.e. what "inherit"
|
|
42
|
+
* resolves to.
|
|
43
|
+
*
|
|
44
|
+
* Everything is read structurally and defensively: a projection this host does
|
|
45
|
+
* not publish, or an entry with a non-string half, contributes nothing rather
|
|
46
|
+
* than breaking the picker.
|
|
47
|
+
* @module dsh-approval-review/client/model-choices
|
|
48
|
+
*/
|
|
49
|
+
/** One `{provider, model}` route, when both halves are strings. */
|
|
50
|
+
function routeOf(value) {
|
|
51
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
52
|
+
const entry = value;
|
|
53
|
+
if (typeof entry.provider !== "string" || typeof entry.model !== "string") return void 0;
|
|
54
|
+
if (entry.provider.length === 0 || entry.model.length === 0) return void 0;
|
|
55
|
+
return `${entry.provider}/${entry.model}`;
|
|
56
|
+
}
|
|
57
|
+
/** Every valid route in a projection that carries a list of them. */
|
|
58
|
+
function routesOf(value) {
|
|
59
|
+
if (!Array.isArray(value)) return [];
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const entry of value) {
|
|
62
|
+
const route = routeOf(entry);
|
|
63
|
+
if (route !== void 0) out.push(route);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Every route in a client model-directory snapshot (`modelDirectories` service).
|
|
69
|
+
*
|
|
70
|
+
* This is the SAME catalog the composer's model seat and the `/model` picker
|
|
71
|
+
* read, so the reviewer picker offers exactly the models the deployment
|
|
72
|
+
* configures locally — minus anything the catalog failed to load, which it
|
|
73
|
+
* reports separately and which we deliberately do not guess at.
|
|
74
|
+
* @param value - the directory state returned by `directoryFor(session).load()`.
|
|
75
|
+
* @returns distinct `provider/model` labels in catalog order.
|
|
76
|
+
*/
|
|
77
|
+
function routesFromDirectory(value) {
|
|
78
|
+
if (typeof value !== "object" || value === null) return [];
|
|
79
|
+
const groups = value.groups;
|
|
80
|
+
if (!Array.isArray(groups)) return [];
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const group of groups) {
|
|
83
|
+
if (typeof group !== "object" || group === null) continue;
|
|
84
|
+
const id = group.id;
|
|
85
|
+
const models = group.models;
|
|
86
|
+
if (typeof id !== "string" || id.length === 0 || !Array.isArray(models)) continue;
|
|
87
|
+
for (const model of models) {
|
|
88
|
+
if (typeof model !== "object" || model === null) continue;
|
|
89
|
+
const modelId = model.id;
|
|
90
|
+
if (typeof modelId !== "string" || modelId.length === 0) continue;
|
|
91
|
+
const route = `${id}/${modelId}`;
|
|
92
|
+
if (!out.includes(route)) out.push(route);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Build the picker's option list.
|
|
99
|
+
*
|
|
100
|
+
* Order is deliberate: the session override in force first (so a route chosen
|
|
101
|
+
* outside the deployment's list still shows as the current selection), then the
|
|
102
|
+
* session's own model, then the deployment's allowed subagent routes. Duplicates
|
|
103
|
+
* collapse, so a model that is both the session default and an allowed route
|
|
104
|
+
* appears once.
|
|
105
|
+
* @param input - the projections' raw values plus the override in force.
|
|
106
|
+
* @returns distinct `provider/model` labels, in display order.
|
|
107
|
+
*/
|
|
108
|
+
function reviewerRouteChoices(input) {
|
|
109
|
+
const out = [];
|
|
110
|
+
const push = (route) => {
|
|
111
|
+
if (route === void 0 || route.length === 0 || out.includes(route)) return;
|
|
112
|
+
out.push(route);
|
|
113
|
+
};
|
|
114
|
+
push(input.current);
|
|
115
|
+
push(routeOf(typeof input.sessionDefault === "object" && input.sessionDefault !== null ? input.sessionDefault.lastUsed : void 0));
|
|
116
|
+
for (const route of routesOf(input.allowed)) push(route);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Filter routes for the picker's list.
|
|
121
|
+
*
|
|
122
|
+
* Matching is a case-insensitive substring over the whole `provider/model`
|
|
123
|
+
* label, so typing `luna` and typing `codex/luna` both narrow to the same row.
|
|
124
|
+
* An empty (or whitespace-only) query keeps the whole list.
|
|
125
|
+
* @param routes - candidate labels.
|
|
126
|
+
* @param query - what the operator typed.
|
|
127
|
+
* @returns the matching labels, in input order.
|
|
128
|
+
*/
|
|
129
|
+
function filterRoutes(routes, query) {
|
|
130
|
+
const needle = query.trim().toLowerCase();
|
|
131
|
+
if (needle.length === 0) return routes;
|
|
132
|
+
return routes.filter((route) => route.toLowerCase().includes(needle));
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/client/ModelPicker.tsx
|
|
136
|
+
/**
|
|
137
|
+
* The reviewer-model picker.
|
|
138
|
+
*
|
|
139
|
+
* A native `<datalist>` (or `<select>`) popup is drawn by the browser, not the
|
|
140
|
+
* page: its font, weight, and width ignore CSS entirely, which made the list
|
|
141
|
+
* read as a different, much louder control than the tab it sits in. This is the
|
|
142
|
+
* plugin's own listbox instead, styled with the ledger's own type scale, with a
|
|
143
|
+
* free-text field on top so an id the catalog no longer advertises stays
|
|
144
|
+
* reachable (catalog membership is advisory).
|
|
145
|
+
* @module dsh-approval-review/client/ModelPicker
|
|
146
|
+
*/
|
|
147
|
+
const TEXT$1 = "var(--dsw-alias-label-primary, #e6edf3)";
|
|
148
|
+
const MUTED$1 = "var(--dsw-alias-label-tertiary, #8b949e)";
|
|
149
|
+
const BORDER$1 = "var(--dsw-alias-border-l2, #30363d)";
|
|
150
|
+
const PANEL$1 = "var(--dsw-alias-bg-layer-2, #161b22)";
|
|
151
|
+
const HOVER = "var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.08))";
|
|
152
|
+
const CODE$1 = "var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace)";
|
|
153
|
+
/** The picker's field and its plugin-rendered list. */
|
|
154
|
+
function ModelPicker({ choices, runCommand, loadModels, zh, onChoicesLoaded }) {
|
|
155
|
+
const [draft, setDraft] = (0, react.useState)("");
|
|
156
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
157
|
+
const [highlight, setHighlight] = (0, react.useState)(0);
|
|
158
|
+
const rootRef = (0, react.useRef)(null);
|
|
159
|
+
const requestedRef = (0, react.useRef)(false);
|
|
160
|
+
const matches = (0, react.useMemo)(() => filterRoutes(choices, draft), [choices, draft]);
|
|
161
|
+
/** Fetch the catalog once, on first interaction. */
|
|
162
|
+
const loadOnce = () => {
|
|
163
|
+
if (requestedRef.current || loadModels === void 0) return;
|
|
164
|
+
requestedRef.current = true;
|
|
165
|
+
loadModels().then((routes) => onChoicesLoaded(routes)).catch(() => onChoicesLoaded([]));
|
|
166
|
+
};
|
|
167
|
+
(0, react.useEffect)(() => {
|
|
168
|
+
if (!open) return;
|
|
169
|
+
const onPointerDown = (event) => {
|
|
170
|
+
if (rootRef.current?.contains(event.target) === true) return;
|
|
171
|
+
setOpen(false);
|
|
172
|
+
};
|
|
173
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
174
|
+
return () => {
|
|
175
|
+
document.removeEventListener("mousedown", onPointerDown);
|
|
176
|
+
};
|
|
177
|
+
}, [open]);
|
|
178
|
+
const apply = (route) => {
|
|
179
|
+
const value = route.trim();
|
|
180
|
+
if (value.length === 0) return;
|
|
181
|
+
runCommand(`/approval-review model ${value}`);
|
|
182
|
+
setDraft("");
|
|
183
|
+
setOpen(false);
|
|
184
|
+
};
|
|
185
|
+
const fieldStyle = {
|
|
186
|
+
fontFamily: CODE$1,
|
|
187
|
+
fontSize: 11,
|
|
188
|
+
lineHeight: "16px",
|
|
189
|
+
padding: "2px 6px",
|
|
190
|
+
width: 190,
|
|
191
|
+
borderRadius: 6,
|
|
192
|
+
border: `1px solid ${BORDER$1}`,
|
|
193
|
+
background: "transparent",
|
|
194
|
+
color: TEXT$1,
|
|
195
|
+
outline: "none"
|
|
196
|
+
};
|
|
197
|
+
const itemStyle = {
|
|
198
|
+
display: "block",
|
|
199
|
+
width: "100%",
|
|
200
|
+
textAlign: "left",
|
|
201
|
+
background: "transparent",
|
|
202
|
+
border: "none",
|
|
203
|
+
borderRadius: 4,
|
|
204
|
+
cursor: "pointer",
|
|
205
|
+
fontFamily: CODE$1,
|
|
206
|
+
fontSize: 11,
|
|
207
|
+
fontWeight: 400,
|
|
208
|
+
lineHeight: "16px",
|
|
209
|
+
padding: "3px 8px",
|
|
210
|
+
color: TEXT$1
|
|
211
|
+
};
|
|
212
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
213
|
+
ref: rootRef,
|
|
214
|
+
style: {
|
|
215
|
+
position: "relative",
|
|
216
|
+
display: "inline-flex"
|
|
217
|
+
},
|
|
218
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
219
|
+
value: draft,
|
|
220
|
+
role: "combobox",
|
|
221
|
+
"aria-expanded": open,
|
|
222
|
+
"aria-label": zh ? "复核模型" : "reviewer model",
|
|
223
|
+
placeholder: zh ? "选择或输入模型" : "pick or type a model",
|
|
224
|
+
style: fieldStyle,
|
|
225
|
+
onFocus: () => {
|
|
226
|
+
loadOnce();
|
|
227
|
+
setOpen(true);
|
|
228
|
+
},
|
|
229
|
+
onClick: () => {
|
|
230
|
+
loadOnce();
|
|
231
|
+
setOpen(true);
|
|
232
|
+
},
|
|
233
|
+
onChange: (event) => {
|
|
234
|
+
setDraft(event.target.value);
|
|
235
|
+
setHighlight(0);
|
|
236
|
+
setOpen(true);
|
|
237
|
+
},
|
|
238
|
+
onKeyDown: (event) => {
|
|
239
|
+
if (event.key === "Escape") {
|
|
240
|
+
setOpen(false);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
if (matches.length === 0) return;
|
|
246
|
+
setOpen(true);
|
|
247
|
+
setHighlight((current) => {
|
|
248
|
+
return ((event.key === "ArrowDown" ? current + 1 : current - 1) + matches.length) % matches.length;
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (event.key !== "Enter") return;
|
|
253
|
+
const picked = open ? matches[highlight] : void 0;
|
|
254
|
+
apply(picked ?? draft);
|
|
255
|
+
}
|
|
256
|
+
}), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
257
|
+
role: "listbox",
|
|
258
|
+
style: {
|
|
259
|
+
position: "absolute",
|
|
260
|
+
top: "calc(100% + 4px)",
|
|
261
|
+
left: 0,
|
|
262
|
+
zIndex: 60,
|
|
263
|
+
minWidth: "100%",
|
|
264
|
+
maxWidth: 320,
|
|
265
|
+
maxHeight: 220,
|
|
266
|
+
overflowY: "auto",
|
|
267
|
+
background: PANEL$1,
|
|
268
|
+
border: `1px solid ${BORDER$1}`,
|
|
269
|
+
borderRadius: 8,
|
|
270
|
+
padding: 4,
|
|
271
|
+
boxShadow: "0 8px 24px rgba(0,0,0,.45)"
|
|
272
|
+
},
|
|
273
|
+
children: matches.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
274
|
+
style: {
|
|
275
|
+
...itemStyle,
|
|
276
|
+
color: MUTED$1,
|
|
277
|
+
cursor: "default"
|
|
278
|
+
},
|
|
279
|
+
children: choices.length === 0 ? zh ? "没有可选模型,直接输入 id 后回车" : "no models to pick from — type an id and press Enter" : zh ? "没有匹配的模型" : "no matching model"
|
|
280
|
+
}) : matches.map((route, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
281
|
+
type: "button",
|
|
282
|
+
role: "option",
|
|
283
|
+
"aria-selected": index === highlight,
|
|
284
|
+
style: {
|
|
285
|
+
...itemStyle,
|
|
286
|
+
background: index === highlight ? HOVER : "transparent"
|
|
287
|
+
},
|
|
288
|
+
onMouseEnter: () => setHighlight(index),
|
|
289
|
+
onClick: () => apply(route),
|
|
290
|
+
children: route
|
|
291
|
+
}, route))
|
|
292
|
+
}) : null]
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region src/client/LedgerView.tsx
|
|
297
|
+
/**
|
|
298
|
+
* The approval ledger as a full conversation tab.
|
|
299
|
+
*
|
|
300
|
+
* This is the "look at everything that was reviewed" surface: one row per
|
|
301
|
+
* approval request with the action, the verdict, the reviewer's full rationale,
|
|
302
|
+
* the safer alternative it suggested, the risk grade, which rule routed it, the
|
|
303
|
+
* reviewer route and timing, and the expandable arguments. The header card is the
|
|
304
|
+
* at-a-glance control; this tab is the audit record.
|
|
305
|
+
*
|
|
306
|
+
* It reads the same `approvalReview` projection as the card, so the two can never
|
|
307
|
+
* disagree, and it holds no state of its own.
|
|
308
|
+
* @module dsh-approval-review/client/LedgerView
|
|
309
|
+
*/
|
|
310
|
+
const TEXT = "var(--dsw-alias-label-primary, #e6edf3)";
|
|
311
|
+
const MUTED = "var(--dsw-alias-label-tertiary, #8b949e)";
|
|
312
|
+
const BORDER = "var(--dsw-alias-border-l2, #30363d)";
|
|
313
|
+
const PANEL = "var(--dsw-alias-bg-layer-2, #161b22)";
|
|
314
|
+
const ROW = "var(--dsw-alias-bg-layer-1, #0d1117)";
|
|
315
|
+
const ALLOWED = "var(--dsw-alias-state-success-primary, #2ea043)";
|
|
316
|
+
const REFUSED = "var(--dsw-alias-state-error-primary, #f85149)";
|
|
317
|
+
const WARN = "var(--dsw-alias-state-warn-primary, #d29922)";
|
|
318
|
+
const CODE = "var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace)";
|
|
319
|
+
/** Risk tone; unknown risk is neutral rather than reassuring. */
|
|
320
|
+
function riskTone(risk) {
|
|
321
|
+
if (risk === "low") return ALLOWED;
|
|
322
|
+
if (risk === "medium") return WARN;
|
|
323
|
+
if (risk === "high" || risk === "critical") return REFUSED;
|
|
324
|
+
return MUTED;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Whether the plugin was even responsible for one row, from the policy that
|
|
328
|
+
* routed it. A `human` or `never` row sits on the card because the user asked
|
|
329
|
+
* for a record of every approval, NOT because a reviewer judged it.
|
|
330
|
+
*/
|
|
331
|
+
function routingTag(record, zh) {
|
|
332
|
+
if (record.policy === "never") return zh ? "硬禁用" : "hard-disabled";
|
|
333
|
+
if (record.policy === "human") return zh ? "交还人工" : "delegated";
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The rationale line, told truthfully.
|
|
337
|
+
*
|
|
338
|
+
* A missing rationale has three very different causes and the row must not
|
|
339
|
+
* blame the wrong one: a `never` row never ran a reviewer, a `human` row was
|
|
340
|
+
* handed back to the human answerer, and an `ai` row either never reached the
|
|
341
|
+
* reviewer or completed with the allow rationale left unpersisted
|
|
342
|
+
* (`recordAllowedVerdicts: false`, or a value-projection accept).
|
|
343
|
+
*/
|
|
344
|
+
function rationaleText(record, zh) {
|
|
345
|
+
if (record.reason !== void 0) return record.reason;
|
|
346
|
+
if (record.policy === "never") return zh ? "按 never 策略硬禁用,没有经过复核模型。" : "Hard-disabled by the never policy; no reviewer ran.";
|
|
347
|
+
if (record.policy === "human") return zh ? "已交还人工应答者,本插件没有裁决这一次。" : "Delegated to the human answerer; this plugin did not decide it.";
|
|
348
|
+
return record.refused ? zh ? "被否决,但本行没有留下理由记录。" : "Refused, but no rationale was recorded." : zh ? "已放行;本行没有留下理由记录(该请求未走到复核模型,或核可理由未落盘)。" : "Allowed, but no rationale was recorded (the request never reached the reviewer, or its allow rationale was not persisted).";
|
|
349
|
+
}
|
|
350
|
+
/** Short wall-clock stamp. */
|
|
351
|
+
function stamp(epochMs) {
|
|
352
|
+
const d = new Date(epochMs);
|
|
353
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
354
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
355
|
+
}
|
|
356
|
+
/** A labelled field row. */
|
|
357
|
+
function Field({ label, children, mono }) {
|
|
358
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
359
|
+
style: {
|
|
360
|
+
display: "flex",
|
|
361
|
+
gap: 10,
|
|
362
|
+
alignItems: "baseline"
|
|
363
|
+
},
|
|
364
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
365
|
+
style: {
|
|
366
|
+
color: MUTED,
|
|
367
|
+
flex: "0 0 auto",
|
|
368
|
+
width: 76,
|
|
369
|
+
fontSize: 11
|
|
370
|
+
},
|
|
371
|
+
children: label
|
|
372
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
373
|
+
style: {
|
|
374
|
+
color: TEXT,
|
|
375
|
+
fontSize: mono === true ? 11 : 12,
|
|
376
|
+
fontFamily: mono === true ? CODE : void 0,
|
|
377
|
+
whiteSpace: "pre-wrap",
|
|
378
|
+
wordBreak: "break-word",
|
|
379
|
+
flex: "1 1 auto"
|
|
380
|
+
},
|
|
381
|
+
children
|
|
382
|
+
})]
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
/** Merge the base list with the loaded catalog, keeping the base order first. */
|
|
386
|
+
function reviewerRoutesMerge(base, loaded) {
|
|
387
|
+
const out = [...base];
|
|
388
|
+
for (const route of loaded) if (!out.includes(route)) out.push(route);
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
/** One ledger entry, expanded. */
|
|
392
|
+
function Entry({ record, zh, onApprove, deniedIndex }) {
|
|
393
|
+
const [showArgs, setShowArgs] = (0, react.useState)(false);
|
|
394
|
+
const pending = record.outcome === void 0;
|
|
395
|
+
const verdict = pending ? zh ? "进行中" : "pending" : record.refused ? zh ? "否决" : "refused" : record.outcome === "allowed-once" ? zh ? "放行" : "allowed" : zh ? "转人工" : "delegated";
|
|
396
|
+
const tone = pending ? MUTED : record.refused ? REFUSED : ALLOWED;
|
|
397
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
398
|
+
style: {
|
|
399
|
+
border: `1px solid ${BORDER}`,
|
|
400
|
+
borderLeft: `3px solid ${tone}`,
|
|
401
|
+
borderRadius: 8,
|
|
402
|
+
background: ROW,
|
|
403
|
+
padding: "12px 14px",
|
|
404
|
+
display: "flex",
|
|
405
|
+
flexDirection: "column",
|
|
406
|
+
gap: 8
|
|
407
|
+
},
|
|
408
|
+
children: [
|
|
409
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
410
|
+
style: {
|
|
411
|
+
display: "flex",
|
|
412
|
+
gap: 8,
|
|
413
|
+
alignItems: "center",
|
|
414
|
+
flexWrap: "wrap"
|
|
415
|
+
},
|
|
416
|
+
children: [
|
|
417
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
418
|
+
style: {
|
|
419
|
+
fontFamily: CODE,
|
|
420
|
+
fontSize: 13,
|
|
421
|
+
fontWeight: 600,
|
|
422
|
+
color: TEXT
|
|
423
|
+
},
|
|
424
|
+
children: record.toolName
|
|
425
|
+
}),
|
|
426
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
427
|
+
style: {
|
|
428
|
+
fontSize: 11,
|
|
429
|
+
color: tone,
|
|
430
|
+
border: `1px solid ${tone}`,
|
|
431
|
+
borderRadius: 999,
|
|
432
|
+
padding: "1px 7px"
|
|
433
|
+
},
|
|
434
|
+
children: verdict
|
|
435
|
+
}),
|
|
436
|
+
routingTag(record, zh) === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
437
|
+
style: {
|
|
438
|
+
fontSize: 11,
|
|
439
|
+
color: MUTED,
|
|
440
|
+
border: `1px solid ${BORDER}`,
|
|
441
|
+
borderRadius: 999,
|
|
442
|
+
padding: "1px 7px"
|
|
443
|
+
},
|
|
444
|
+
children: routingTag(record, zh)
|
|
445
|
+
}),
|
|
446
|
+
record.risk === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
447
|
+
style: {
|
|
448
|
+
fontSize: 11,
|
|
449
|
+
color: riskTone(record.risk)
|
|
450
|
+
},
|
|
451
|
+
children: [
|
|
452
|
+
zh ? "风险" : "risk",
|
|
453
|
+
" ",
|
|
454
|
+
record.risk
|
|
455
|
+
]
|
|
456
|
+
}),
|
|
457
|
+
record.overridden ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
458
|
+
style: {
|
|
459
|
+
fontSize: 11,
|
|
460
|
+
color: WARN
|
|
461
|
+
},
|
|
462
|
+
children: zh ? "含人工一次性授权" : "human override"
|
|
463
|
+
}) : null,
|
|
464
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
465
|
+
style: {
|
|
466
|
+
marginLeft: "auto",
|
|
467
|
+
fontSize: 11,
|
|
468
|
+
color: MUTED
|
|
469
|
+
},
|
|
470
|
+
children: [
|
|
471
|
+
stamp(record.startedAt),
|
|
472
|
+
" · T",
|
|
473
|
+
record.turn,
|
|
474
|
+
"/S",
|
|
475
|
+
record.step,
|
|
476
|
+
record.durationMs === void 0 ? "" : ` · ${record.durationMs} ms`
|
|
477
|
+
]
|
|
478
|
+
})
|
|
479
|
+
]
|
|
480
|
+
}),
|
|
481
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
|
|
482
|
+
label: zh ? "裁决理由" : "rationale",
|
|
483
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
484
|
+
style: { color: record.reason === void 0 ? MUTED : TEXT },
|
|
485
|
+
children: rationaleText(record, zh)
|
|
486
|
+
})
|
|
487
|
+
}),
|
|
488
|
+
record.suggestion === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
|
|
489
|
+
label: zh ? "更安全的做法" : "safer path",
|
|
490
|
+
children: record.suggestion
|
|
491
|
+
}),
|
|
492
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Field, {
|
|
493
|
+
label: zh ? "路由策略" : "routing",
|
|
494
|
+
mono: true,
|
|
495
|
+
children: [
|
|
496
|
+
record.policy,
|
|
497
|
+
" · ",
|
|
498
|
+
record.policySource
|
|
499
|
+
]
|
|
500
|
+
}),
|
|
501
|
+
record.askReason === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
|
|
502
|
+
label: zh ? "申请理由" : "asked why",
|
|
503
|
+
children: record.askReason
|
|
504
|
+
}),
|
|
505
|
+
record.reviewerRoute === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Field, {
|
|
506
|
+
label: zh ? "复核模型" : "reviewer",
|
|
507
|
+
mono: true,
|
|
508
|
+
children: [record.reviewerRoute, record.uncertain ? ` · ${zh ? "不确定" : "uncertain"}` : ""]
|
|
509
|
+
}),
|
|
510
|
+
record.argumentsPreview === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
511
|
+
type: "button",
|
|
512
|
+
onClick: () => setShowArgs((v) => !v),
|
|
513
|
+
style: {
|
|
514
|
+
background: "none",
|
|
515
|
+
border: "none",
|
|
516
|
+
padding: 0,
|
|
517
|
+
cursor: "pointer",
|
|
518
|
+
color: "var(--dsw-alias-link, #58a6ff)",
|
|
519
|
+
fontSize: 11
|
|
520
|
+
},
|
|
521
|
+
children: showArgs ? zh ? "收起参数" : "hide arguments" : zh ? "查看参数" : "show arguments"
|
|
522
|
+
}), showArgs ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
|
|
523
|
+
style: {
|
|
524
|
+
margin: "6px 0 0",
|
|
525
|
+
padding: 10,
|
|
526
|
+
borderRadius: 6,
|
|
527
|
+
background: PANEL,
|
|
528
|
+
fontFamily: CODE,
|
|
529
|
+
fontSize: 11,
|
|
530
|
+
color: TEXT,
|
|
531
|
+
whiteSpace: "pre-wrap",
|
|
532
|
+
wordBreak: "break-word",
|
|
533
|
+
maxHeight: 320,
|
|
534
|
+
overflow: "auto"
|
|
535
|
+
},
|
|
536
|
+
children: record.argumentsPreview
|
|
537
|
+
}) : null] }),
|
|
538
|
+
record.refused && record.outcome === "rejected" && onApprove !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
539
|
+
type: "button",
|
|
540
|
+
onClick: () => onApprove(record, deniedIndex),
|
|
541
|
+
style: {
|
|
542
|
+
alignSelf: "flex-start",
|
|
543
|
+
cursor: "pointer",
|
|
544
|
+
fontSize: 11,
|
|
545
|
+
padding: "3px 9px",
|
|
546
|
+
borderRadius: 6,
|
|
547
|
+
border: `1px solid ${BORDER}`,
|
|
548
|
+
background: "transparent",
|
|
549
|
+
color: TEXT
|
|
550
|
+
},
|
|
551
|
+
children: zh ? `授权重试第 ${deniedIndex} 条否决` : `approve denial #${deniedIndex} for one retry`
|
|
552
|
+
}) : null
|
|
553
|
+
]
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
/** The full ledger tab. */
|
|
557
|
+
/**
|
|
558
|
+
* Start a freshly opened ledger at its top.
|
|
559
|
+
*
|
|
560
|
+
* The tab renders inside the conversation's resident scrollport, which the
|
|
561
|
+
* transcript keeps pinned to its newest line, so a ledger mounted under it would
|
|
562
|
+
* show its own BOTTOM — while the ledger lists the newest decision FIRST. The
|
|
563
|
+
* walk that finds the box to reset lives in `./scroll.ts`.
|
|
564
|
+
* @param root - the ledger's root element.
|
|
565
|
+
*/
|
|
566
|
+
function useStartAtTop(root) {
|
|
567
|
+
(0, react.useEffect)(() => {
|
|
568
|
+
const element = root.current;
|
|
569
|
+
if (element === null) return;
|
|
570
|
+
element.scrollTop = 0;
|
|
571
|
+
if (typeof window === "undefined") return;
|
|
572
|
+
resetScrollableAncestorToTop(element, (node) => window.getComputedStyle(node).overflowY);
|
|
573
|
+
}, [root]);
|
|
574
|
+
}
|
|
575
|
+
function LedgerView({ view, zh, runCommand, modelChoices, loadModels }) {
|
|
576
|
+
const rootRef = (0, react.useRef)(null);
|
|
577
|
+
useStartAtTop(rootRef);
|
|
578
|
+
const [loadedChoices, setLoadedChoices] = (0, react.useState)(void 0);
|
|
579
|
+
const choices = loadedChoices ?? modelChoices ?? [];
|
|
580
|
+
const records = view?.records ?? [];
|
|
581
|
+
const denials = (0, react.useMemo)(() => records.filter((r) => r.refused), [records]);
|
|
582
|
+
const reviewedCount = (0, react.useMemo)(() => records.filter((r) => r.policy === "ai").length, [records]);
|
|
583
|
+
const deniedIndexOf = (record) => denials.findIndex((d) => d.reviewId === record.reviewId) + 1;
|
|
584
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
585
|
+
ref: rootRef,
|
|
586
|
+
style: {
|
|
587
|
+
padding: "14px 16px",
|
|
588
|
+
overflow: "auto",
|
|
589
|
+
height: "100%",
|
|
590
|
+
fontFamily: "inherit"
|
|
591
|
+
},
|
|
592
|
+
children: [
|
|
593
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
594
|
+
style: {
|
|
595
|
+
display: "flex",
|
|
596
|
+
gap: 12,
|
|
597
|
+
alignItems: "center",
|
|
598
|
+
flexWrap: "wrap",
|
|
599
|
+
padding: "0 2px 10px",
|
|
600
|
+
borderBottom: `1px solid ${BORDER}`,
|
|
601
|
+
marginBottom: 12
|
|
602
|
+
},
|
|
603
|
+
children: [
|
|
604
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
|
|
605
|
+
style: {
|
|
606
|
+
fontSize: 14,
|
|
607
|
+
color: TEXT
|
|
608
|
+
},
|
|
609
|
+
children: zh ? "审批审计" : "Approval audit"
|
|
610
|
+
}),
|
|
611
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
612
|
+
style: {
|
|
613
|
+
fontSize: 12,
|
|
614
|
+
color: view?.enabled === false ? MUTED : ALLOWED
|
|
615
|
+
},
|
|
616
|
+
children: view === void 0 ? zh ? "尚无数据" : "no data" : view.enabled ? zh ? "自动审批已开启" : "auto-approval on" : zh ? "自动审批已关闭" : "auto-approval off"
|
|
617
|
+
}),
|
|
618
|
+
runCommand === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
619
|
+
type: "button",
|
|
620
|
+
disabled: view?.enabled === true,
|
|
621
|
+
onClick: () => runCommand("/approval-review on"),
|
|
622
|
+
style: {
|
|
623
|
+
fontSize: 11,
|
|
624
|
+
padding: "3px 9px",
|
|
625
|
+
borderRadius: 6,
|
|
626
|
+
cursor: view?.enabled === true ? "default" : "pointer",
|
|
627
|
+
border: `1px solid ${BORDER}`,
|
|
628
|
+
color: view?.enabled === true ? MUTED : TEXT,
|
|
629
|
+
background: "transparent"
|
|
630
|
+
},
|
|
631
|
+
children: zh ? "开启" : "on"
|
|
632
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
633
|
+
type: "button",
|
|
634
|
+
disabled: view?.enabled === false,
|
|
635
|
+
onClick: () => runCommand("/approval-review off"),
|
|
636
|
+
style: {
|
|
637
|
+
fontSize: 11,
|
|
638
|
+
padding: "3px 9px",
|
|
639
|
+
borderRadius: 6,
|
|
640
|
+
cursor: view?.enabled === false ? "default" : "pointer",
|
|
641
|
+
border: `1px solid ${BORDER}`,
|
|
642
|
+
color: view?.enabled === false ? MUTED : TEXT,
|
|
643
|
+
background: "transparent"
|
|
644
|
+
},
|
|
645
|
+
children: zh ? "关闭" : "off"
|
|
646
|
+
})] }),
|
|
647
|
+
view === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
648
|
+
style: {
|
|
649
|
+
fontSize: 11,
|
|
650
|
+
color: MUTED,
|
|
651
|
+
fontFamily: CODE
|
|
652
|
+
},
|
|
653
|
+
children: [zh ? "复核模型 " : "reviewer ", view.reviewerModel.length > 0 ? `${view.reviewerProvider.length > 0 ? `${view.reviewerProvider}/` : ""}${view.reviewerModel}` : zh ? "继承会话" : "inherit session"]
|
|
654
|
+
}),
|
|
655
|
+
runCommand === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
656
|
+
style: {
|
|
657
|
+
display: "flex",
|
|
658
|
+
gap: 4,
|
|
659
|
+
alignItems: "center"
|
|
660
|
+
},
|
|
661
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelPicker, {
|
|
662
|
+
choices,
|
|
663
|
+
runCommand,
|
|
664
|
+
loadModels,
|
|
665
|
+
zh,
|
|
666
|
+
onChoicesLoaded: (routes) => {
|
|
667
|
+
setLoadedChoices(routes.length === 0 ? modelChoices ?? [] : reviewerRoutesMerge(modelChoices ?? [], routes));
|
|
668
|
+
}
|
|
669
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
670
|
+
type: "button",
|
|
671
|
+
onClick: () => runCommand("/approval-review model default"),
|
|
672
|
+
style: {
|
|
673
|
+
fontSize: 11,
|
|
674
|
+
padding: "3px 9px",
|
|
675
|
+
borderRadius: 6,
|
|
676
|
+
cursor: "pointer",
|
|
677
|
+
border: `1px solid ${BORDER}`,
|
|
678
|
+
color: TEXT,
|
|
679
|
+
background: "transparent"
|
|
680
|
+
},
|
|
681
|
+
children: zh ? "继承" : "inherit"
|
|
682
|
+
})]
|
|
683
|
+
}),
|
|
684
|
+
view === void 0 || view.total === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
685
|
+
style: {
|
|
686
|
+
fontSize: 11,
|
|
687
|
+
color: MUTED
|
|
688
|
+
},
|
|
689
|
+
children: zh ? `共 ${view.total} 次 · 本插件裁决 ${reviewedCount} · 已否决 ${view.refused} · 本回合复审 ${view.reviewsThisTurn}/${view.maxReviewsPerTurn} · 连续否决 ${view.consecutiveDenials}` : `${view.total} total · ${reviewedCount} routed to the reviewer · ${view.refused} refused · this turn ${view.reviewsThisTurn}/${view.maxReviewsPerTurn} · streak ${view.consecutiveDenials}`
|
|
690
|
+
})
|
|
691
|
+
]
|
|
692
|
+
}),
|
|
693
|
+
view?.circuitOpen === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
694
|
+
style: {
|
|
695
|
+
fontSize: 12,
|
|
696
|
+
color: REFUSED,
|
|
697
|
+
marginBottom: 10
|
|
698
|
+
},
|
|
699
|
+
children: zh ? "否决熔断已触发:本回合后续请求转人工审批。" : "Rejection breaker is open: later requests in this turn go to the human chain."
|
|
700
|
+
}) : null,
|
|
701
|
+
records.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
702
|
+
style: {
|
|
703
|
+
fontSize: 13,
|
|
704
|
+
color: MUTED,
|
|
705
|
+
padding: "24px 4px",
|
|
706
|
+
lineHeight: 1.7
|
|
707
|
+
},
|
|
708
|
+
children: zh ? "本会话还没有审批记录。当某个动作需要越过沙箱边界时,这里会留下完整的裁决理由、风险等级与更安全的替代做法。" : "No approvals recorded in this session yet. When an action needs to cross the sandbox boundary, its full rationale, risk grade, and safer alternative land here."
|
|
709
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
710
|
+
style: {
|
|
711
|
+
display: "flex",
|
|
712
|
+
flexDirection: "column",
|
|
713
|
+
gap: 10
|
|
714
|
+
},
|
|
715
|
+
children: records.map((record) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Entry, {
|
|
716
|
+
record,
|
|
717
|
+
zh,
|
|
718
|
+
deniedIndex: deniedIndexOf(record),
|
|
719
|
+
onApprove: runCommand === void 0 ? void 0 : (_r, index) => runCommand(`/approval-review approve ${index}`)
|
|
720
|
+
}, record.reviewId))
|
|
721
|
+
})
|
|
722
|
+
]
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/client/access-mode-glyph.ts
|
|
727
|
+
/**
|
|
728
|
+
* The "approve for me" access-mode glyph, installed from this plugin.
|
|
729
|
+
*
|
|
730
|
+
* Background: the composer's access-mode menu draws a shield glyph for each
|
|
731
|
+
* permission preset, and that glyph table is a CLOSED design set inside
|
|
732
|
+
* `@deepseek-ai/dsh-client-ui-conversation` — a preset key outside it renders
|
|
733
|
+
* with no icon at all, and the host cannot be asked for one (the `permissions`
|
|
734
|
+
* projection carries value/name/description only). So a plugin that adds a
|
|
735
|
+
* fourth preset gets a fourth menu row with no picture next to it.
|
|
736
|
+
*
|
|
737
|
+
* Two ways out exist. Editing the harness package is the tidy one, but it only
|
|
738
|
+
* takes effect after that package is rebuilt, and it couples the harness to a
|
|
739
|
+
* plugin-specific key. This module is the other one: the plugin decorates the
|
|
740
|
+
* two buttons the menu renders, from the outside, with the same shield+eye mark.
|
|
741
|
+
*
|
|
742
|
+
* Why it decorates by ATTRIBUTE and not by inserting nodes: these buttons belong
|
|
743
|
+
* to React. Inserting a child would put an unknown node where React expects its
|
|
744
|
+
* own child list and would make a later re-render reconcile against DOM it never
|
|
745
|
+
* produced. Setting a `data-*` attribute and drawing the glyph from a
|
|
746
|
+
* plugin-owned stylesheet via `::before` leaves React's tree untouched — React
|
|
747
|
+
* does not remove attributes it never set, and a remount simply loses the mark
|
|
748
|
+
* until the next pass, which the observer re-applies.
|
|
749
|
+
*
|
|
750
|
+
* The shim YIELDS to the built-in glyph: when the harness glyph table already
|
|
751
|
+
* covers this key (i.e. the package was rebuilt with it), the mark is removed
|
|
752
|
+
* and the stylesheet draws nothing, so a rebuild never produces a double icon.
|
|
753
|
+
*
|
|
754
|
+
* Naming coupling: the preset key comes from `Config.reviewerPreset` and the
|
|
755
|
+
* bundle patch's `permission.presets` entry, and the display name from that same
|
|
756
|
+
* entry's `name`. The DOM exposes the NAME (the trigger's `aria-label`, a menu
|
|
757
|
+
* row's text), never the key, so the label list below is what this shim matches.
|
|
758
|
+
* Rename the preset and the glyph simply does not appear; the menu keeps
|
|
759
|
+
* working, which is why this is a progressive enhancement and not a dependency.
|
|
760
|
+
* @module dsh-approval-review/client/access-mode-glyph
|
|
761
|
+
*/
|
|
762
|
+
/**
|
|
763
|
+
* Display names the preset may carry, matched against the access-mode trigger's
|
|
764
|
+
* `aria-label` ("访问模式,当前:替我审批" / "Access mode, current: Approve for
|
|
765
|
+
* me") and against a menu row's own text. The bundle patch ships the Chinese
|
|
766
|
+
* name; the English form is accepted so an English deployment still gets its
|
|
767
|
+
* glyph.
|
|
768
|
+
*/
|
|
769
|
+
const PRESET_LABELS = ["替我审批", "Approve for me"];
|
|
770
|
+
/** Marks a decorated button; also the selector the stylesheet hangs off. */
|
|
771
|
+
const MARK_ATTRIBUTE = "data-dsh-approval-review-glyph";
|
|
772
|
+
/** The style element's identity, so a re-install replaces its own node. */
|
|
773
|
+
const STYLE_ATTRIBUTE = "data-dsh-approval-review-glyph-style";
|
|
774
|
+
/**
|
|
775
|
+
* The glyph itself: the same shield as the built-in modes — the boundary is
|
|
776
|
+
* unchanged — carrying an eye, because the reviewer looks at the action before
|
|
777
|
+
* it crosses. Rendered as a MASK, so the mark takes `currentColor` from the
|
|
778
|
+
* button exactly like the built-in `currentColor` SVGs do.
|
|
779
|
+
*/
|
|
780
|
+
const GLYPH_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none"><path d="M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z" stroke="#000" stroke-width="1.31831" stroke-linejoin="round"/><path d="M5.348 6.58C6.224 7.773 7.029 8.375 8.2 8.375C9.371 8.375 10.176 7.773 11.052 6.58" stroke="#000" stroke-width="1.31831" stroke-linecap="round"/><path d="M5.348 6.58C6.224 5.387 7.029 4.785 8.2 4.785C9.371 4.785 10.176 5.387 11.052 6.58" stroke="#000" stroke-width="1.31831" stroke-linecap="round"/><circle cx="8.2" cy="6.58" r="0.95" fill="#000"/></svg>`;
|
|
781
|
+
/**
|
|
782
|
+
* The one selector every pass and every mutation check uses.
|
|
783
|
+
*
|
|
784
|
+
* It is deliberately narrow: the trigger is addressed by SUBSTRING on the
|
|
785
|
+
* `aria-label` (a native attribute test, not a JavaScript scan of every button
|
|
786
|
+
* in the document), menu rows by role, and already-marked buttons — whichever
|
|
787
|
+
* they are — by the mark itself, so a mode switch clears the old one.
|
|
788
|
+
*/
|
|
789
|
+
const TARGET_SELECTOR = [
|
|
790
|
+
...PRESET_LABELS.map((label) => `button[aria-label*="${label}"]`),
|
|
791
|
+
"button[role=\"menuitem\"]",
|
|
792
|
+
`[${MARK_ATTRIBUTE}]`
|
|
793
|
+
].join(", ");
|
|
794
|
+
/** Build the stylesheet that draws the mark on a decorated button. */
|
|
795
|
+
function stylesheet() {
|
|
796
|
+
const mask = `url("data:image/svg+xml,${encodeURIComponent(GLYPH_SVG)}")`;
|
|
797
|
+
return `
|
|
798
|
+
[${MARK_ATTRIBUTE}]::before{
|
|
799
|
+
content:"";
|
|
800
|
+
display:inline-block;
|
|
801
|
+
flex:none;
|
|
802
|
+
width:16px;
|
|
803
|
+
height:16px;
|
|
804
|
+
background-color:currentColor;
|
|
805
|
+
-webkit-mask-image:${mask};
|
|
806
|
+
mask-image:${mask};
|
|
807
|
+
-webkit-mask-repeat:no-repeat;
|
|
808
|
+
mask-repeat:no-repeat;
|
|
809
|
+
-webkit-mask-position:center;
|
|
810
|
+
mask-position:center;
|
|
811
|
+
-webkit-mask-size:contain;
|
|
812
|
+
mask-size:contain;
|
|
813
|
+
}
|
|
814
|
+
/* The composer trigger sizes its icons at 14px. */
|
|
815
|
+
button[aria-label][${MARK_ATTRIBUTE}]::before{
|
|
816
|
+
width:14px;
|
|
817
|
+
height:14px;
|
|
818
|
+
}
|
|
819
|
+
`;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Whether an element already carries the harness's own glyph.
|
|
823
|
+
*
|
|
824
|
+
* Both surfaces render the built-in icon as a leading `<span>` holding an
|
|
825
|
+
* `<svg>`; the trailing chevron is a later sibling, so only the first child
|
|
826
|
+
* counts. When this is true the shim steps aside.
|
|
827
|
+
*/
|
|
828
|
+
function hasBuiltInGlyph(button) {
|
|
829
|
+
const first = button.firstElementChild;
|
|
830
|
+
return first instanceof HTMLElement && first.tagName === "SPAN" && first.querySelector("svg") !== null;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* The pure matching rule behind the shim.
|
|
834
|
+
*
|
|
835
|
+
* Extracted from the DOM pass so the rule that can actually go wrong — which
|
|
836
|
+
* button is this plugin's preset, and does it already have a built-in glyph —
|
|
837
|
+
* is testable without a browser. The DOM *plumbing* around it is decoration and
|
|
838
|
+
* degrades to "no glyph", never to a broken menu.
|
|
839
|
+
* @param facts - the element facts the shim matches on.
|
|
840
|
+
* @returns `'mark'` when this plugin should draw the glyph, `'skip'` otherwise.
|
|
841
|
+
*/
|
|
842
|
+
function accessModeGlyphDecision(facts) {
|
|
843
|
+
const label = facts.ariaLabel ?? "";
|
|
844
|
+
if (!(PRESET_LABELS.some((candidate) => label.includes(candidate)) || facts.role === "menuitem" && PRESET_LABELS.includes((facts.text ?? "").trim()))) return "skip";
|
|
845
|
+
return facts.hasBuiltInGlyph === true ? "skip" : "mark";
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Install the access-mode glyph decoration.
|
|
849
|
+
*
|
|
850
|
+
* The observer is deliberately cheap: it inspects only MUTATED subtrees for the
|
|
851
|
+
* target selector and coalesces every hit into one animation frame, so a
|
|
852
|
+
* streaming conversation (which appends text nodes constantly) never becomes a
|
|
853
|
+
* per-token query over the document.
|
|
854
|
+
* @param root - document to decorate; injectable for tests.
|
|
855
|
+
* @returns the disposer that removes the stylesheet, the marks, and the observer.
|
|
856
|
+
*/
|
|
857
|
+
function installAccessModeGlyph(root = document) {
|
|
858
|
+
if (root.querySelector(`style[${STYLE_ATTRIBUTE}]`) === null) {
|
|
859
|
+
const style = root.createElement("style");
|
|
860
|
+
style.setAttribute(STYLE_ATTRIBUTE, "1");
|
|
861
|
+
style.textContent = stylesheet();
|
|
862
|
+
root.head.appendChild(style);
|
|
863
|
+
}
|
|
864
|
+
const decorate = () => {
|
|
865
|
+
for (const button of root.querySelectorAll(TARGET_SELECTOR)) if (accessModeGlyphDecision({
|
|
866
|
+
ariaLabel: button.getAttribute("aria-label"),
|
|
867
|
+
role: button.getAttribute("role"),
|
|
868
|
+
text: button.textContent ?? "",
|
|
869
|
+
hasBuiltInGlyph: hasBuiltInGlyph(button)
|
|
870
|
+
}) === "mark") {
|
|
871
|
+
if (!button.hasAttribute(MARK_ATTRIBUTE)) button.setAttribute(MARK_ATTRIBUTE, "1");
|
|
872
|
+
} else if (button.hasAttribute(MARK_ATTRIBUTE)) button.removeAttribute(MARK_ATTRIBUTE);
|
|
873
|
+
};
|
|
874
|
+
/** True when a mutation could have produced one of the decorated buttons. */
|
|
875
|
+
const mightMatter = (node) => {
|
|
876
|
+
if (!(node instanceof Element)) return false;
|
|
877
|
+
if (node.matches(TARGET_SELECTOR)) return true;
|
|
878
|
+
return node.querySelector(TARGET_SELECTOR) !== null;
|
|
879
|
+
};
|
|
880
|
+
let frame;
|
|
881
|
+
const schedule = () => {
|
|
882
|
+
if (frame !== void 0) return;
|
|
883
|
+
frame = root.defaultView?.requestAnimationFrame(() => {
|
|
884
|
+
frame = void 0;
|
|
885
|
+
decorate();
|
|
886
|
+
});
|
|
887
|
+
};
|
|
888
|
+
const observer = new MutationObserver((records) => {
|
|
889
|
+
for (const record of records) {
|
|
890
|
+
const target = record.target;
|
|
891
|
+
if (target instanceof Element && target.closest(TARGET_SELECTOR) !== null) {
|
|
892
|
+
schedule();
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
for (const node of record.addedNodes) if (mightMatter(node)) {
|
|
896
|
+
schedule();
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
if (root.body !== null) observer.observe(root.body, {
|
|
902
|
+
childList: true,
|
|
903
|
+
subtree: true
|
|
904
|
+
});
|
|
905
|
+
decorate();
|
|
906
|
+
return () => {
|
|
907
|
+
observer.disconnect();
|
|
908
|
+
if (frame !== void 0) root.defaultView?.cancelAnimationFrame(frame);
|
|
909
|
+
for (const button of root.querySelectorAll(`[${MARK_ATTRIBUTE}]`)) button.removeAttribute(MARK_ATTRIBUTE);
|
|
910
|
+
root.querySelector(`style[${STYLE_ATTRIBUTE}]`)?.remove();
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
//#endregion
|
|
914
|
+
//#region src/client/index.tsx
|
|
915
|
+
/** Slot entry id; stable so a redeploy replaces its own row. */
|
|
916
|
+
const VIEW_SLOT_ID = "approval-review-ledger";
|
|
917
|
+
/** The conversation tab strip, beside 轨迹 / 上下文 / 费用. */
|
|
918
|
+
const VIEW_SLOT = "conversation.view";
|
|
919
|
+
/**
|
|
920
|
+
* Required client services.
|
|
921
|
+
*
|
|
922
|
+
* `remote.commands` is a named remote SERVICE, not a plain property: the client
|
|
923
|
+
* remote facade throws `cannot get property "remote.commands" without inject`
|
|
924
|
+
* unless the key is declared here. Declaring `remote` alone is not enough, which
|
|
925
|
+
* is exactly the bug this cost once.
|
|
926
|
+
*/
|
|
927
|
+
const inject = [
|
|
928
|
+
"slots",
|
|
929
|
+
"remote",
|
|
930
|
+
"remote.commands"
|
|
931
|
+
];
|
|
932
|
+
/** Whether copy should be Chinese, from the browser language. */
|
|
933
|
+
function preferZh() {
|
|
934
|
+
return typeof navigator !== "undefined" && navigator.language.toLowerCase().startsWith("zh");
|
|
935
|
+
}
|
|
936
|
+
/** Reviewer routes this deployment offers, read from its own projections. */
|
|
937
|
+
function reviewerChoices(props, current) {
|
|
938
|
+
return reviewerRouteChoices({
|
|
939
|
+
current,
|
|
940
|
+
sessionDefault: props.useProjection("modelSelection"),
|
|
941
|
+
allowed: props.useProjection("subagentModelSelectionPolicy")
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
/** Render the full ledger tab. */
|
|
945
|
+
function ApprovalReviewLedger(props) {
|
|
946
|
+
const view = props.useProjection("approvalReview");
|
|
947
|
+
const current = view === void 0 || view.reviewerModel.length === 0 ? void 0 : `${view.reviewerProvider.length > 0 ? `${view.reviewerProvider}/` : ""}${view.reviewerModel}`;
|
|
948
|
+
return LedgerView({
|
|
949
|
+
view,
|
|
950
|
+
zh: preferZh(),
|
|
951
|
+
runCommand: props.runCommand,
|
|
952
|
+
modelChoices: reviewerChoices(props, current),
|
|
953
|
+
loadModels: props.loadModels
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Register the ledger tab and the access-mode glyph.
|
|
958
|
+
* @param ctx - client Cordis context.
|
|
959
|
+
*/
|
|
960
|
+
function apply(ctx) {
|
|
961
|
+
ctx.effect(() => installAccessModeGlyph(), "approval-review: access-mode glyph");
|
|
962
|
+
/**
|
|
963
|
+
* The remote is resolved LAZILY, per call. Capturing `ctx.remote` in the
|
|
964
|
+
* `apply` closure is wrong: `apply` can run before the remote facade finishes
|
|
965
|
+
* mounting, and a captured `undefined` turns every click into a silent no-op
|
|
966
|
+
* on a control that still LOOKS enabled. That was a real bug here.
|
|
967
|
+
*/
|
|
968
|
+
const remoteOf = () => ctx.remote?.commands;
|
|
969
|
+
/**
|
|
970
|
+
* The model directory, resolved LAZILY for the same reason the command remote
|
|
971
|
+
* is: this client half mounts before every service it may use is up, and a
|
|
972
|
+
* captured `undefined` would permanently disable the picker.
|
|
973
|
+
*/
|
|
974
|
+
const directoriesOf = () => ctx.get?.("modelDirectories");
|
|
975
|
+
/** The per-session business face the tab's seat uses. */
|
|
976
|
+
const inject = (rawSessionId) => ({
|
|
977
|
+
loadModels: async () => {
|
|
978
|
+
const directories = directoriesOf();
|
|
979
|
+
if (directories === void 0) return [];
|
|
980
|
+
try {
|
|
981
|
+
return routesFromDirectory(await directories.directoryFor(rawSessionId).load());
|
|
982
|
+
} catch {
|
|
983
|
+
return [];
|
|
984
|
+
}
|
|
985
|
+
},
|
|
986
|
+
runCommand: async (line) => {
|
|
987
|
+
const commands = remoteOf();
|
|
988
|
+
if (commands === void 0) return "the command remote is not mounted in this client";
|
|
989
|
+
const sessionId = rawSessionId;
|
|
990
|
+
try {
|
|
991
|
+
if (!(await commands.execute(sessionId, line)).ok) return `the host refused "${line}"`;
|
|
992
|
+
return null;
|
|
993
|
+
} catch (error) {
|
|
994
|
+
return `"${line}" failed: ${String(error)}`;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
ctx.slots.inject(VIEW_SLOT, () => ctx.slots.register({
|
|
999
|
+
name: VIEW_SLOT,
|
|
1000
|
+
id: VIEW_SLOT_ID,
|
|
1001
|
+
order: 40,
|
|
1002
|
+
label: () => preferZh() ? "审批" : "Approvals",
|
|
1003
|
+
inject
|
|
1004
|
+
}, ApprovalReviewLedger));
|
|
1005
|
+
}
|
|
1006
|
+
//#endregion
|
|
1007
|
+
exports.VIEW_SLOT_ID = VIEW_SLOT_ID;
|
|
1008
|
+
exports.apply = apply;
|
|
1009
|
+
exports.inject = inject;
|
|
1010
|
+
return module.exports;
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
//# sourceMappingURL=client.js.map
|