dsh-quick-actions 0.1.0-rc.3
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 +21 -0
- package/README.en.md +199 -0
- package/README.md +199 -0
- package/cordis.patch.yml +29 -0
- package/lib/client.js +3901 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +737 -0
- package/lib/types/client/controller.d.ts +207 -0
- package/lib/types/client/dsh.d.ts +171 -0
- package/lib/types/client/index.d.ts +43 -0
- package/lib/types/client/manager/ActionForm.d.ts +20 -0
- package/lib/types/client/manager/ActionPanel.d.ts +15 -0
- package/lib/types/client/manager/ManagedRow.d.ts +40 -0
- package/lib/types/client/manager/ManagerPanel.d.ts +9 -0
- package/lib/types/client/manager/press.d.ts +38 -0
- package/lib/types/client/manager/search.d.ts +56 -0
- package/lib/types/client/manager/status.d.ts +23 -0
- package/lib/types/client/modal.d.ts +55 -0
- package/lib/types/client/session/ConfirmPanel.d.ts +23 -0
- package/lib/types/client/session/availability.d.ts +19 -0
- package/lib/types/client/session/execution.d.ts +86 -0
- package/lib/types/client/session/guards.d.ts +59 -0
- package/lib/types/client/surfaces/ActionFace.d.ts +21 -0
- package/lib/types/client/surfaces/ErrorBoundary.d.ts +31 -0
- package/lib/types/client/surfaces/QuickActionsSurface.d.ts +17 -0
- package/lib/types/client/surfaces/entries.d.ts +27 -0
- package/lib/types/client/surfaces/layout.d.ts +49 -0
- package/lib/types/client/surfaces/residency.d.ts +65 -0
- package/lib/types/host/config.d.ts +18 -0
- package/lib/types/host/index.d.ts +38 -0
- package/lib/types/host/presets.d.ts +26 -0
- package/lib/types/host/settings.d.ts +113 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/locales/index.d.ts +34 -0
- package/lib/types/model/catalog.d.ts +63 -0
- package/lib/types/model/index.d.ts +13 -0
- package/lib/types/model/json.d.ts +11 -0
- package/lib/types/model/mutations.d.ts +98 -0
- package/lib/types/model/normalize.d.ts +15 -0
- package/lib/types/model/projection.d.ts +49 -0
- package/lib/types/model/settings.d.ts +48 -0
- package/lib/types/model/text.d.ts +28 -0
- package/lib/types/model/types.d.ts +89 -0
- package/lib/types/model/validation.d.ts +40 -0
- package/lib/types/styles/index.d.ts +39 -0
- package/lib/types/types.d.ts +11 -0
- package/lib/types.js +1 -0
- package/package.json +83 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
2
|
+
//#region src/model/types.ts
|
|
3
|
+
/** Every layout value, in the order the management panel offers them. */
|
|
4
|
+
const QUICK_ACTION_LAYOUTS = [
|
|
5
|
+
"ribbon",
|
|
6
|
+
"bar",
|
|
7
|
+
"launcher"
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* The identity of a reference as one comparable string. Source and id together
|
|
11
|
+
* are the identity: the same id under a different source is a different action.
|
|
12
|
+
*/
|
|
13
|
+
function quickActionRefKey(ref) {
|
|
14
|
+
return `${ref.source}:${ref.id}`;
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/model/json.ts
|
|
18
|
+
/**
|
|
19
|
+
* JSON structural comparison shared by the mutation planner and the Host's
|
|
20
|
+
* canonical rewrite.
|
|
21
|
+
*
|
|
22
|
+
* Structural, not canonical-JSON: object key order carries no meaning here.
|
|
23
|
+
* A stored section round-tripped through YAML, and a tombstone written by a
|
|
24
|
+
* higher version, both come back with whatever key order their writer chose —
|
|
25
|
+
* comparing serialized text would report a difference that is not one, and the
|
|
26
|
+
* rewrite would then write on every start instead of being idempotent.
|
|
27
|
+
*/
|
|
28
|
+
function deepEqualJson(left, right) {
|
|
29
|
+
if (left === right) return true;
|
|
30
|
+
if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
|
|
31
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
32
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
33
|
+
return left.every((item, index) => deepEqualJson(item, right[index]));
|
|
34
|
+
}
|
|
35
|
+
const leftKeys = Object.keys(left);
|
|
36
|
+
const rightKeys = Object.keys(right);
|
|
37
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
38
|
+
return leftKeys.every((key) => Object.hasOwn(right, key) && deepEqualJson(left[key], right[key]));
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/model/text.ts
|
|
42
|
+
/**
|
|
43
|
+
* Unicode primitives shared by every Quick Action entry point (spec 4.3).
|
|
44
|
+
* Config loading, the management form, migration and Settings mutations must all
|
|
45
|
+
* measure and trim text through these functions so one rule cannot drift from another.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Reference placeholder code points DSH reserves and strips inside `setDraft`:
|
|
49
|
+
* the reference marker block plus the legacy object replacement character.
|
|
50
|
+
* Static text carrying one of them would submit something other than what the
|
|
51
|
+
* user configured, so the model rejects it as a field error (spec 4.3).
|
|
52
|
+
*
|
|
53
|
+
* Source: `REFERENCE_PLACEHOLDER_RE` in `@deepseek-ai/dsh-client-ui-conversation`
|
|
54
|
+
* (`lib/client.js`, input machine), applied by `setDraft` before it rebuilds the
|
|
55
|
+
* draft. Verified identical in 0.1.1-rc.2, 0.1.2-rc.1 and 0.1.5-rc.1 — the release
|
|
56
|
+
* that renamed `imageIds` left this range untouched (ticket 29). Its tail is
|
|
57
|
+
* published as a named constant, which 0.1.5-rc.1 moved and renamed:
|
|
58
|
+
* `PLACEHOLDER = "\uFFFC"` in `lib/types/client/input/machine.d.ts` became
|
|
59
|
+
* `ATOMIC_CHAR = "\uFFFC"` in `lib/types/client/input/editor/projection.d.ts`.
|
|
60
|
+
* Re-check this range when the supported DSH range moves.
|
|
61
|
+
*/
|
|
62
|
+
const RESERVED_REFERENCE_PLACEHOLDER = /[\u{E100}-\u{E11D}\u{FFFC}]/u;
|
|
63
|
+
/** Unicode code point length — the counting unit for every length limit (spec 4.3). */
|
|
64
|
+
function countCodePoints(value) {
|
|
65
|
+
let count = 0;
|
|
66
|
+
for (const _ of value) count += 1;
|
|
67
|
+
return count;
|
|
68
|
+
}
|
|
69
|
+
/** Whether a text holds no character outside ECMAScript `trim()` whitespace (spec 4.3). */
|
|
70
|
+
function isBlankQuickActionText(text) {
|
|
71
|
+
return text.trim().length === 0;
|
|
72
|
+
}
|
|
73
|
+
/** Whether a text carries a DSH-reserved reference placeholder code point (spec 4.3). */
|
|
74
|
+
function containsReservedReferencePlaceholder(text) {
|
|
75
|
+
return RESERVED_REFERENCE_PLACEHOLDER.test(text);
|
|
76
|
+
}
|
|
77
|
+
/** Code points allowed to appear inside an emoji grapheme cluster. */
|
|
78
|
+
const EMOJI_CLUSTER_MEMBER = /^[\p{Extended_Pictographic}\p{Emoji_Component}\p{Emoji_Modifier}\u{FE0E}\u{FE0F}\u{200D}]+$/u;
|
|
79
|
+
/** A keycap sequence carries no pictographic code point of its own. */
|
|
80
|
+
const KEYCAP_CLUSTER = /^[0-9#*]\u{FE0F}?\u{20E3}$/u;
|
|
81
|
+
/** A flag is a pair of regional indicators, also without a pictographic code point. */
|
|
82
|
+
const FLAG_CLUSTER = /^\p{Regional_Indicator}{2}$/u;
|
|
83
|
+
const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
84
|
+
function isEmojiCluster(cluster) {
|
|
85
|
+
if (!EMOJI_CLUSTER_MEMBER.test(cluster)) return false;
|
|
86
|
+
return /\p{Extended_Pictographic}/u.test(cluster) || KEYCAP_CLUSTER.test(cluster) || FLAG_CLUSTER.test(cluster);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Segment an icon candidate into grapheme clusters and report whether each one
|
|
90
|
+
* is an emoji cluster, so validation can tell `too-long` from `not-emoji`.
|
|
91
|
+
*/
|
|
92
|
+
function scanEmojiClusters(icon) {
|
|
93
|
+
let clusters = 0;
|
|
94
|
+
let emojiOnly = true;
|
|
95
|
+
for (const { segment } of graphemeSegmenter.segment(icon)) {
|
|
96
|
+
clusters += 1;
|
|
97
|
+
if (!isEmojiCluster(segment)) emojiOnly = false;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
clusters,
|
|
101
|
+
emojiOnly
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** Trimmed label, or the reason it cannot be stored. */
|
|
105
|
+
function labelIssue(label) {
|
|
106
|
+
const trimmed = label.trim();
|
|
107
|
+
if (trimmed.length === 0) return {
|
|
108
|
+
field: "label",
|
|
109
|
+
reason: "blank"
|
|
110
|
+
};
|
|
111
|
+
if (countCodePoints(trimmed) > 40) return {
|
|
112
|
+
field: "label",
|
|
113
|
+
reason: "too-long"
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** The reason a static text cannot be stored, if any. */
|
|
117
|
+
function textIssue(text) {
|
|
118
|
+
if (isBlankQuickActionText(text)) return {
|
|
119
|
+
field: "text",
|
|
120
|
+
reason: "blank"
|
|
121
|
+
};
|
|
122
|
+
if (countCodePoints(text) > 4e3) return {
|
|
123
|
+
field: "text",
|
|
124
|
+
reason: "too-long"
|
|
125
|
+
};
|
|
126
|
+
if (containsReservedReferencePlaceholder(text)) return {
|
|
127
|
+
field: "text",
|
|
128
|
+
reason: "reserved-placeholder"
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** The reason a non-empty icon cannot be stored, if any. */
|
|
132
|
+
function iconIssue(icon) {
|
|
133
|
+
const { clusters, emojiOnly } = scanEmojiClusters(icon);
|
|
134
|
+
if (!emojiOnly) return {
|
|
135
|
+
field: "icon",
|
|
136
|
+
reason: "not-emoji"
|
|
137
|
+
};
|
|
138
|
+
if (clusters > 4) return {
|
|
139
|
+
field: "icon",
|
|
140
|
+
reason: "too-long"
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const FNV_OFFSET_BASIS = 144066263297769815596495629667062367629n;
|
|
144
|
+
const FNV_PRIME = 309485009821345068724781371n;
|
|
145
|
+
const FNV_MASK = (1n << 128n) - 1n;
|
|
146
|
+
function fnv1a128(value) {
|
|
147
|
+
let hash = FNV_OFFSET_BASIS;
|
|
148
|
+
for (const byte of new TextEncoder().encode(value)) hash = (hash ^ BigInt(byte)) * FNV_PRIME & FNV_MASK;
|
|
149
|
+
return hash.toString(16).padStart(32, "0");
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Hash the whole catalog projection, field by field and in order, so that any
|
|
153
|
+
* change a client could observe — including a pure reordering — moves the
|
|
154
|
+
* revision, and an unchanged catalog never does (spec 6.2).
|
|
155
|
+
*/
|
|
156
|
+
function revisionOf(presets) {
|
|
157
|
+
return fnv1a128(JSON.stringify([1, presets.map((preset) => [
|
|
158
|
+
preset.id,
|
|
159
|
+
preset.kind,
|
|
160
|
+
preset.label,
|
|
161
|
+
preset.text,
|
|
162
|
+
preset.icon ?? null,
|
|
163
|
+
preset.confirm
|
|
164
|
+
])]));
|
|
165
|
+
}
|
|
166
|
+
function fieldIssue(field, reason) {
|
|
167
|
+
return {
|
|
168
|
+
field,
|
|
169
|
+
reason
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function readId(entry) {
|
|
173
|
+
const { id } = entry;
|
|
174
|
+
if (id === void 0) return { issue: fieldIssue("id", "missing") };
|
|
175
|
+
if (typeof id !== "string") return { issue: fieldIssue("id", "invalid-type") };
|
|
176
|
+
if (id.trim().length === 0) return { issue: fieldIssue("id", "blank") };
|
|
177
|
+
return { id };
|
|
178
|
+
}
|
|
179
|
+
function readPreset(entry, id) {
|
|
180
|
+
if (entry.kind !== void 0 && entry.kind !== "send") return { issue: fieldIssue("kind", "unsupported") };
|
|
181
|
+
const { label, text, icon, confirm } = entry;
|
|
182
|
+
if (typeof label !== "string") return { issue: fieldIssue("label", label === void 0 ? "missing" : "invalid-type") };
|
|
183
|
+
const labelProblem = labelIssue(label);
|
|
184
|
+
if (labelProblem !== void 0) return { issue: labelProblem };
|
|
185
|
+
if (typeof text !== "string") return { issue: fieldIssue("text", text === void 0 ? "missing" : "invalid-type") };
|
|
186
|
+
const textProblem = textIssue(text);
|
|
187
|
+
if (textProblem !== void 0) return { issue: textProblem };
|
|
188
|
+
if (icon !== void 0 && typeof icon !== "string") return { issue: fieldIssue("icon", "invalid-type") };
|
|
189
|
+
if (icon === "") return { issue: fieldIssue("icon", "blank") };
|
|
190
|
+
if (icon !== void 0) {
|
|
191
|
+
const iconProblem = iconIssue(icon);
|
|
192
|
+
if (iconProblem !== void 0) return { issue: iconProblem };
|
|
193
|
+
}
|
|
194
|
+
if (confirm !== void 0 && typeof confirm !== "boolean") return { issue: fieldIssue("confirm", "invalid-type") };
|
|
195
|
+
return { preset: {
|
|
196
|
+
id,
|
|
197
|
+
kind: "send",
|
|
198
|
+
label: label.trim(),
|
|
199
|
+
text,
|
|
200
|
+
...icon === void 0 ? {} : { icon },
|
|
201
|
+
confirm: confirm ?? true
|
|
202
|
+
} };
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Merge and validate the built-in manifest with the Host composition's presets.
|
|
206
|
+
* Nothing is repaired or dropped: the first release refuses to load a catalog an
|
|
207
|
+
* author got wrong, so the mistake surfaces at startup rather than at send time.
|
|
208
|
+
*/
|
|
209
|
+
function buildPresetCatalog(input) {
|
|
210
|
+
const issues = [];
|
|
211
|
+
const presets = [];
|
|
212
|
+
const seen = /* @__PURE__ */ new Set();
|
|
213
|
+
const sources = [["builtin", input.builtins], ["config", input.configured]];
|
|
214
|
+
for (const [source, entries] of sources) for (const [index, entry] of entries.entries()) {
|
|
215
|
+
const at = (issue, id) => ({
|
|
216
|
+
scope: "preset",
|
|
217
|
+
source,
|
|
218
|
+
index,
|
|
219
|
+
id,
|
|
220
|
+
field: issue.field,
|
|
221
|
+
reason: issue.reason
|
|
222
|
+
});
|
|
223
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
224
|
+
issues.push(at(fieldIssue("id", "invalid-type"), void 0));
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const record = entry;
|
|
228
|
+
const identity = readId(record);
|
|
229
|
+
if ("issue" in identity) {
|
|
230
|
+
issues.push(at(identity.issue, void 0));
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (seen.has(identity.id)) {
|
|
234
|
+
issues.push(at(fieldIssue("id", "duplicate"), identity.id));
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const read = readPreset(record, identity.id);
|
|
238
|
+
if ("issue" in read) {
|
|
239
|
+
issues.push(at(read.issue, identity.id));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
seen.add(identity.id);
|
|
243
|
+
presets.push(read.preset);
|
|
244
|
+
}
|
|
245
|
+
const count = input.builtins.length + input.configured.length;
|
|
246
|
+
if (count > 50) issues.push({
|
|
247
|
+
scope: "catalog",
|
|
248
|
+
reason: "too-many",
|
|
249
|
+
count,
|
|
250
|
+
limit: 50
|
|
251
|
+
});
|
|
252
|
+
if (issues.length > 0) return {
|
|
253
|
+
ok: false,
|
|
254
|
+
issues
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
catalog: {
|
|
259
|
+
schemaVersion: 1,
|
|
260
|
+
revision: revisionOf(presets),
|
|
261
|
+
presets
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/model/settings.ts
|
|
267
|
+
/**
|
|
268
|
+
* Settings V1 decoding (spec 4.2, 6.3). Decoding is defensive and lossless: it
|
|
269
|
+
* coerces shapes it recognises, falls back to documented defaults, and preserves
|
|
270
|
+
* anything it cannot render as a tombstone rather than repairing or dropping it.
|
|
271
|
+
* Catalog-aware canonicalization lives in `normalize.ts`.
|
|
272
|
+
*/
|
|
273
|
+
/**
|
|
274
|
+
* The one namespace holding user data (spec 4.2); renaming it orphans every
|
|
275
|
+
* stored section. It lives in the shared model because both faces address it:
|
|
276
|
+
* the Host registers it, the Client binds the same name.
|
|
277
|
+
*/
|
|
278
|
+
const QUICK_ACTIONS_SETTINGS_NAMESPACE = "composer-quick-actions";
|
|
279
|
+
/**
|
|
280
|
+
* The read-only namespace carrying the Preset Catalog to Clients (spec 17.2).
|
|
281
|
+
* The plugin never writes its user layer, so it holds no persisted section —
|
|
282
|
+
* `composer-quick-actions` remains the only persisted namespace (spec 4.2).
|
|
283
|
+
*/
|
|
284
|
+
const QUICK_ACTIONS_CATALOG_NAMESPACE = "composer-quick-actions-catalog";
|
|
285
|
+
/** The state a fresh install starts from (spec 4.2). */
|
|
286
|
+
const DEFAULT_QUICK_ACTION_SETTINGS = Object.freeze({
|
|
287
|
+
schemaVersion: 1,
|
|
288
|
+
layout: "ribbon",
|
|
289
|
+
userActionsById: Object.freeze({}),
|
|
290
|
+
actionOrder: Object.freeze([]),
|
|
291
|
+
presetStateById: Object.freeze({})
|
|
292
|
+
});
|
|
293
|
+
function asRecord(value) {
|
|
294
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Whether a stored entry is a live Send Action this release can render.
|
|
299
|
+
* Everything else is a tombstone: a higher version's `kind`, or a value whose
|
|
300
|
+
* label/text are not readable strings.
|
|
301
|
+
*/
|
|
302
|
+
function isLiveQuickAction(value) {
|
|
303
|
+
return value.kind === "send" && typeof value.label === "string" && typeof value.text === "string";
|
|
304
|
+
}
|
|
305
|
+
function decodeLayout(value) {
|
|
306
|
+
return QUICK_ACTION_LAYOUTS.find((layout) => layout === value) ?? DEFAULT_QUICK_ACTION_SETTINGS.layout;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Read one stored custom action into canonical form. A missing `kind` reads as
|
|
310
|
+
* `'send'` because V1 always writes the tag; anything else marks a tombstone and
|
|
311
|
+
* is handed straight back by identity, so a value written by a higher version
|
|
312
|
+
* survives the round trip intact.
|
|
313
|
+
*
|
|
314
|
+
* A live result always carries `kind`, `confirm` and `enabled` explicitly, which
|
|
315
|
+
* is what spec 4.3 requires of every normalized action. `confirm` is only
|
|
316
|
+
* defaulted when it is absent — never derived from the text.
|
|
317
|
+
*/
|
|
318
|
+
function decodeStoredQuickAction(value) {
|
|
319
|
+
const entry = asRecord(value);
|
|
320
|
+
if (entry === void 0) return void 0;
|
|
321
|
+
if ((entry.kind ?? "send") !== "send" || typeof entry.label !== "string" || typeof entry.text !== "string") return entry;
|
|
322
|
+
const { icon, confirm, enabled, clonedFromPresetId } = entry;
|
|
323
|
+
return {
|
|
324
|
+
kind: "send",
|
|
325
|
+
label: entry.label,
|
|
326
|
+
text: entry.text,
|
|
327
|
+
...typeof icon === "string" && icon !== "" ? { icon } : {},
|
|
328
|
+
confirm: typeof confirm === "boolean" ? confirm : true,
|
|
329
|
+
enabled: typeof enabled === "boolean" ? enabled : true,
|
|
330
|
+
...typeof clonedFromPresetId === "string" ? { clonedFromPresetId } : {}
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function decodeUserActions(value) {
|
|
334
|
+
const entries = asRecord(value);
|
|
335
|
+
if (entries === void 0) return {};
|
|
336
|
+
const decoded = {};
|
|
337
|
+
for (const [id, raw] of Object.entries(entries)) {
|
|
338
|
+
const action = decodeStoredQuickAction(raw);
|
|
339
|
+
if (action !== void 0) decoded[id] = action;
|
|
340
|
+
}
|
|
341
|
+
return decoded;
|
|
342
|
+
}
|
|
343
|
+
function decodeRef(value) {
|
|
344
|
+
const entry = asRecord(value);
|
|
345
|
+
if (entry === void 0) return void 0;
|
|
346
|
+
if (typeof entry.id !== "string" || entry.id === "") return void 0;
|
|
347
|
+
if (entry.source === "preset") return {
|
|
348
|
+
source: "preset",
|
|
349
|
+
id: entry.id
|
|
350
|
+
};
|
|
351
|
+
if (entry.source === "custom") return {
|
|
352
|
+
source: "custom",
|
|
353
|
+
id: entry.id
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function decodeOrder(value) {
|
|
357
|
+
if (!Array.isArray(value)) return [];
|
|
358
|
+
return value.map(decodeRef).filter((ref) => ref !== void 0);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Canonicalize one preset state: `hidden` becomes an explicit boolean or
|
|
362
|
+
* disappears, and every other field is carried through so a higher version's own
|
|
363
|
+
* preference survives a downgrade (spec 5.3).
|
|
364
|
+
*/
|
|
365
|
+
function canonicalPresetState(state) {
|
|
366
|
+
const rest = { ...state };
|
|
367
|
+
delete rest.hidden;
|
|
368
|
+
return state.hidden === true ? {
|
|
369
|
+
...rest,
|
|
370
|
+
hidden: true
|
|
371
|
+
} : rest;
|
|
372
|
+
}
|
|
373
|
+
/** Whether a canonical preset state carries no user preference at all. */
|
|
374
|
+
function isEmptyPresetState(state) {
|
|
375
|
+
return Object.keys(state).length === 0;
|
|
376
|
+
}
|
|
377
|
+
function decodePresetState(value) {
|
|
378
|
+
const entries = asRecord(value);
|
|
379
|
+
if (entries === void 0) return {};
|
|
380
|
+
const decoded = {};
|
|
381
|
+
for (const [id, raw] of Object.entries(entries)) {
|
|
382
|
+
const state = asRecord(raw);
|
|
383
|
+
if (state === void 0) continue;
|
|
384
|
+
decoded[id] = canonicalPresetState(state);
|
|
385
|
+
}
|
|
386
|
+
return decoded;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Decode any published snapshot into V1. The stored `schemaVersion` is not a
|
|
390
|
+
* gate: a snapshot written by a higher version still yields every field this
|
|
391
|
+
* release understands, which is what makes a downgrade round trip lossless.
|
|
392
|
+
*/
|
|
393
|
+
function decodeQuickActionSettings(raw) {
|
|
394
|
+
const stored = asRecord(raw);
|
|
395
|
+
if (stored === void 0) return DEFAULT_QUICK_ACTION_SETTINGS;
|
|
396
|
+
return {
|
|
397
|
+
schemaVersion: 1,
|
|
398
|
+
layout: decodeLayout(stored.layout),
|
|
399
|
+
userActionsById: decodeUserActions(stored.userActionsById),
|
|
400
|
+
actionOrder: decodeOrder(stored.actionOrder),
|
|
401
|
+
presetStateById: decodePresetState(stored.presetStateById)
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region src/model/normalize.ts
|
|
406
|
+
/**
|
|
407
|
+
* Deterministic, idempotent canonicalization of decoded Settings against the
|
|
408
|
+
* current Preset Catalog (spec 5.3). The same input and catalog always produce
|
|
409
|
+
* the same result, and normalizing twice changes nothing — which is what lets the
|
|
410
|
+
* Host rewrite behind a revision fence without fighting its own writes.
|
|
411
|
+
*
|
|
412
|
+
* Normalization repairs references and re-states the discriminant, never content.
|
|
413
|
+
* In particular it never derives `confirm` from the text: that default is
|
|
414
|
+
* initialized when an action is created or cloned, so rewriting it here would undo
|
|
415
|
+
* the user's choice on every load and break idempotence.
|
|
416
|
+
*/
|
|
417
|
+
/**
|
|
418
|
+
* Canonicalize one decoded snapshot against a catalog.
|
|
419
|
+
*
|
|
420
|
+
* Order: existing references keep their positions, minus repeats and minus
|
|
421
|
+
* references to custom actions that are gone; references to presets this catalog
|
|
422
|
+
* no longer carries stay put, because the same Preset Action ID coming back must
|
|
423
|
+
* restore the user's preference. Known actions with no reference are appended —
|
|
424
|
+
* presets in catalog order, then custom actions in stored order. Tombstones are
|
|
425
|
+
* never given a new reference, so a downgrade round trip returns their exact order.
|
|
426
|
+
*/
|
|
427
|
+
function normalizeQuickActionSettings(settings, catalog) {
|
|
428
|
+
const knownPresetIds = new Set(catalog.presets.map((preset) => preset.id));
|
|
429
|
+
const placed = /* @__PURE__ */ new Set();
|
|
430
|
+
const actionOrder = [];
|
|
431
|
+
const place = (ref) => {
|
|
432
|
+
const key = quickActionRefKey(ref);
|
|
433
|
+
if (placed.has(key)) return;
|
|
434
|
+
placed.add(key);
|
|
435
|
+
actionOrder.push(ref);
|
|
436
|
+
};
|
|
437
|
+
const userActionsById = {};
|
|
438
|
+
for (const [id, stored] of Object.entries(settings.userActionsById)) {
|
|
439
|
+
const value = decodeStoredQuickAction(stored);
|
|
440
|
+
if (value !== void 0) userActionsById[id] = value;
|
|
441
|
+
}
|
|
442
|
+
for (const ref of settings.actionOrder) {
|
|
443
|
+
if (ref.source === "custom" && userActionsById[ref.id] === void 0) continue;
|
|
444
|
+
place(ref);
|
|
445
|
+
}
|
|
446
|
+
for (const preset of catalog.presets) place({
|
|
447
|
+
source: "preset",
|
|
448
|
+
id: preset.id
|
|
449
|
+
});
|
|
450
|
+
for (const [id, value] of Object.entries(userActionsById)) if (isLiveQuickAction(value)) place({
|
|
451
|
+
source: "custom",
|
|
452
|
+
id
|
|
453
|
+
});
|
|
454
|
+
const presetStateById = {};
|
|
455
|
+
for (const [id, stored] of Object.entries(settings.presetStateById)) {
|
|
456
|
+
const state = canonicalPresetState(stored);
|
|
457
|
+
if (!knownPresetIds.has(id)) presetStateById[id] = state;
|
|
458
|
+
else if (!isEmptyPresetState(state)) presetStateById[id] = state;
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
schemaVersion: 1,
|
|
462
|
+
layout: settings.layout,
|
|
463
|
+
userActionsById,
|
|
464
|
+
actionOrder,
|
|
465
|
+
presetStateById
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/host/presets.ts
|
|
470
|
+
/**
|
|
471
|
+
* The package's built-in Preset Quick Action manifest — component 1 of the
|
|
472
|
+
* Preset Catalog (spec 5.1), declared ahead of the Host composition's
|
|
473
|
+
* `Config.presets`.
|
|
474
|
+
*
|
|
475
|
+
* These are author-facing product copy in one language; changing a label or a
|
|
476
|
+
* text is a product decision, not an implementation one.
|
|
477
|
+
*
|
|
478
|
+
* Two rules bind any edit here:
|
|
479
|
+
*
|
|
480
|
+
* - A Preset Action ID is permanent. Label, icon and text may change under the
|
|
481
|
+
* same id, but `kind` and `confirm` are the immutable safety behaviour
|
|
482
|
+
* signature — changing either needs a new id (spec 5.1). Editing a text so it
|
|
483
|
+
* crosses the Command Send Action boundary counts as crossing that signature.
|
|
484
|
+
* - Adding an entry is additive: a new id appends to the end of every existing
|
|
485
|
+
* user's order and rewrites no stored data (spec 5.3).
|
|
486
|
+
*
|
|
487
|
+
* `summarize` and `explain` carry new ids rather than reusing
|
|
488
|
+
* `summarize-thread` and `explain-last-change`: those two shipped on the
|
|
489
|
+
* default `confirm: true`, and turning confirmation off is exactly the
|
|
490
|
+
* signature change the first rule reserves a new id for. The old ids simply
|
|
491
|
+
* leave the catalog, which the model already handles — a stored preference for
|
|
492
|
+
* an id no longer in the catalog is kept as a tombstone, neither shown nor
|
|
493
|
+
* counted (spec 5.3).
|
|
494
|
+
*/
|
|
495
|
+
const BUILT_IN_PRESETS = Object.freeze([
|
|
496
|
+
Object.freeze({
|
|
497
|
+
id: "approve",
|
|
498
|
+
label: "确认",
|
|
499
|
+
text: "确认,按你刚才说的做。",
|
|
500
|
+
icon: "✅",
|
|
501
|
+
confirm: false
|
|
502
|
+
}),
|
|
503
|
+
Object.freeze({
|
|
504
|
+
id: "continue",
|
|
505
|
+
label: "继续",
|
|
506
|
+
text: "继续。",
|
|
507
|
+
icon: "▶️",
|
|
508
|
+
confirm: false
|
|
509
|
+
}),
|
|
510
|
+
Object.freeze({
|
|
511
|
+
id: "summarize",
|
|
512
|
+
label: "总结",
|
|
513
|
+
text: "总结当前对话:已确定的结论、仍未决的问题、下一步要做的事。",
|
|
514
|
+
icon: "📝",
|
|
515
|
+
confirm: false
|
|
516
|
+
}),
|
|
517
|
+
Object.freeze({
|
|
518
|
+
id: "explain",
|
|
519
|
+
label: "解释",
|
|
520
|
+
text: "解释你刚才的改动:为什么这样做,考虑过哪些替代方案,取舍是什么。",
|
|
521
|
+
icon: "🔍",
|
|
522
|
+
confirm: false
|
|
523
|
+
}),
|
|
524
|
+
Object.freeze({
|
|
525
|
+
id: "compact-context",
|
|
526
|
+
label: "压缩",
|
|
527
|
+
text: "/compact",
|
|
528
|
+
icon: "🧹"
|
|
529
|
+
})
|
|
530
|
+
]);
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/host/config.ts
|
|
533
|
+
/**
|
|
534
|
+
* Host composition config: the author-facing `presets` list, merged behind the
|
|
535
|
+
* package's built-in manifest into the authoritative Preset Catalog (spec 5.1).
|
|
536
|
+
*
|
|
537
|
+
* The Host owns the catalog, so this fails loudly. An invalid preset, a
|
|
538
|
+
* duplicate Preset Action ID, a `kind` this release cannot run, or a catalog
|
|
539
|
+
* over fifty entries makes plugin config loading fail with a message naming
|
|
540
|
+
* every problem at once — never a silent truncation or a last-one-wins merge.
|
|
541
|
+
*/
|
|
542
|
+
function describeIssue(issue) {
|
|
543
|
+
if (issue.scope === "catalog") return `the preset catalog holds ${String(issue.count)} presets, above the limit of ${String(issue.limit)}`;
|
|
544
|
+
const at = issue.source === "builtin" ? `built-in presets[${String(issue.index)}]` : `presets[${String(issue.index)}]`;
|
|
545
|
+
return `${issue.id === void 0 ? at : `${at} ("${issue.id}")`}: ${issue.field} is ${issue.reason}`;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Read one Host composition config into the authoritative catalog.
|
|
549
|
+
* @param config - the composition entry's config, absent when nothing was declared.
|
|
550
|
+
*/
|
|
551
|
+
function readComposerQuickActionsConfig(config) {
|
|
552
|
+
const declared = config?.presets;
|
|
553
|
+
if (declared !== void 0 && !Array.isArray(declared)) return {
|
|
554
|
+
ok: false,
|
|
555
|
+
message: "composer-quick-actions: `presets` must be a list of preset quick actions"
|
|
556
|
+
};
|
|
557
|
+
const result = buildPresetCatalog({
|
|
558
|
+
builtins: BUILT_IN_PRESETS,
|
|
559
|
+
configured: declared ?? []
|
|
560
|
+
});
|
|
561
|
+
if (result.ok) return {
|
|
562
|
+
ok: true,
|
|
563
|
+
catalog: result.catalog
|
|
564
|
+
};
|
|
565
|
+
return {
|
|
566
|
+
ok: false,
|
|
567
|
+
message: `composer-quick-actions: invalid preset configuration\n${result.issues.map((issue) => ` - ${describeIssue(issue)}`).join("\n")}`
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/host/settings.ts
|
|
572
|
+
/**
|
|
573
|
+
* The single persisted Settings namespace and the Host's canonical rewrite
|
|
574
|
+
* (spec 4.2, 6.1, 6.3).
|
|
575
|
+
*
|
|
576
|
+
* The Host is the sole validation and migration authority, so the registered
|
|
577
|
+
* schema is deliberately permissive: it fixes the shape of the section and its
|
|
578
|
+
* defaults, and nothing else. A strict schema would refuse registration for a
|
|
579
|
+
* section a higher version wrote — which is exactly the data spec 5.3 requires
|
|
580
|
+
* to survive a downgrade untouched. The shared model decodes and normalizes what
|
|
581
|
+
* the schema lets through, and it never rewrites content it cannot render.
|
|
582
|
+
*/
|
|
583
|
+
/**
|
|
584
|
+
* Shape and defaults of the persisted section (spec 4.2). The three collections
|
|
585
|
+
* stay unconstrained on purpose: a stricter schema would refuse registration for
|
|
586
|
+
* a section this release cannot render, and refusing registration is how stored
|
|
587
|
+
* data gets lost. The shared model is the validation gate — see the module note.
|
|
588
|
+
*/
|
|
589
|
+
const quickActionSettingsSchema = Schema.object({
|
|
590
|
+
schemaVersion: Schema.number().default(1),
|
|
591
|
+
layout: Schema.string().default("ribbon"),
|
|
592
|
+
userActionsById: Schema.any().default({}),
|
|
593
|
+
actionOrder: Schema.any().default([]),
|
|
594
|
+
presetStateById: Schema.any().default({})
|
|
595
|
+
});
|
|
596
|
+
/**
|
|
597
|
+
* Shape of the catalog namespace. Its authoritative content is the composition
|
|
598
|
+
* `base` layer the Host declares; a user layer is never written and, if one were
|
|
599
|
+
* hand-written into the document, Clients would still read `base` (spec 17.2).
|
|
600
|
+
*/
|
|
601
|
+
const quickActionCatalogSchema = Schema.object({
|
|
602
|
+
schemaVersion: Schema.number().default(1),
|
|
603
|
+
revision: Schema.string().default(""),
|
|
604
|
+
presets: Schema.any().default([])
|
|
605
|
+
});
|
|
606
|
+
/** Recognize a revision conflict by its stable code, never by prototype (realm copies). */
|
|
607
|
+
function isConflict(error) {
|
|
608
|
+
return typeof error === "object" && error !== null && error.code === "SETTINGS_CONFLICT";
|
|
609
|
+
}
|
|
610
|
+
/** How many times a conflicting rewrite refreshes and tries again before giving up. */
|
|
611
|
+
const REWRITE_ATTEMPTS = 3;
|
|
612
|
+
/**
|
|
613
|
+
* Read the stored section, canonicalize it against the current catalog, and
|
|
614
|
+
* persist it behind the revision it was read at (spec 6.3).
|
|
615
|
+
*
|
|
616
|
+
* Nothing is written when nothing is stored: materializing the defaults into the
|
|
617
|
+
* user layer would shadow the composition `base` and destroy what `replace({})`
|
|
618
|
+
* resets to. Nothing is written for a higher `schemaVersion` either — that data
|
|
619
|
+
* belongs to the version that wrote it.
|
|
620
|
+
*
|
|
621
|
+
* A conflict means another writer won the race, so the rewrite refreshes the
|
|
622
|
+
* authoritative snapshot and recomputes rather than replaying its own stale
|
|
623
|
+
* section; after a bounded number of attempts it reports the conflict instead of
|
|
624
|
+
* overwriting the concurrent write.
|
|
625
|
+
*/
|
|
626
|
+
async function rewriteCanonicalSettings(settings, catalog) {
|
|
627
|
+
if (!settings.writable) return { status: "read-only" };
|
|
628
|
+
for (let attempt = 1; attempt <= REWRITE_ATTEMPTS; attempt += 1) {
|
|
629
|
+
const descriptor = settings.describe().find((candidate) => candidate.ns === QUICK_ACTIONS_SETTINGS_NAMESPACE);
|
|
630
|
+
if (descriptor === void 0) return { status: "unregistered" };
|
|
631
|
+
const stored = descriptor.user;
|
|
632
|
+
if (stored === void 0) return { status: "nothing-stored" };
|
|
633
|
+
const storedVersion = storedSchemaVersion(stored);
|
|
634
|
+
if (storedVersion > 1) return {
|
|
635
|
+
status: "newer-version",
|
|
636
|
+
schemaVersion: storedVersion
|
|
637
|
+
};
|
|
638
|
+
const canonical = canonicalSettings(stored, catalog);
|
|
639
|
+
if (deepEqualJson(stored, canonical)) return { status: "unchanged" };
|
|
640
|
+
try {
|
|
641
|
+
await settings.replace(QUICK_ACTIONS_SETTINGS_NAMESPACE, canonical, descriptor.revision);
|
|
642
|
+
return {
|
|
643
|
+
status: "rewritten",
|
|
644
|
+
revision: descriptor.revision
|
|
645
|
+
};
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if (!isConflict(error)) throw error;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
status: "conflict",
|
|
652
|
+
attempts: REWRITE_ATTEMPTS
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/** The `schemaVersion` a stored section declares; anything unreadable counts as this release's. */
|
|
656
|
+
function storedSchemaVersion(stored) {
|
|
657
|
+
const declared = stored?.schemaVersion;
|
|
658
|
+
return typeof declared === "number" && Number.isFinite(declared) ? declared : 1;
|
|
659
|
+
}
|
|
660
|
+
/** Decode one raw stored section and canonicalize it against the catalog. */
|
|
661
|
+
function canonicalSettings(stored, catalog) {
|
|
662
|
+
return normalizeQuickActionSettings(decodeQuickActionSettings(stored), catalog);
|
|
663
|
+
}
|
|
664
|
+
//#endregion
|
|
665
|
+
//#region src/host/index.ts
|
|
666
|
+
/**
|
|
667
|
+
* Host ownership of the Quick Action state (spec 6.1, 17.2): it merges and
|
|
668
|
+
* validates the Preset Catalog, registers the user-state Settings namespace and
|
|
669
|
+
* the read-only catalog namespace, canonicalizes the stored section behind a
|
|
670
|
+
* revision fence, and serves the authoritative catalog snapshot.
|
|
671
|
+
*
|
|
672
|
+
* The Host never reaches past Settings to a file or a storage backend, and it
|
|
673
|
+
* never substitutes its own storage when the provider is missing — the plugin
|
|
674
|
+
* declares `settings` as a hard dependency and waits.
|
|
675
|
+
*/
|
|
676
|
+
/** A copy nothing can reach the authoritative catalog through. */
|
|
677
|
+
function detachedCatalog(catalog) {
|
|
678
|
+
return JSON.parse(JSON.stringify(catalog));
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Load the catalog, register both namespaces and canonicalize the stored section.
|
|
682
|
+
*
|
|
683
|
+
* An invalid preset configuration throws before anything is registered: the
|
|
684
|
+
* catalog is author-owned, so a mistake fails plugin loading rather than
|
|
685
|
+
* silently shipping a partial catalog (spec 5.1).
|
|
686
|
+
*
|
|
687
|
+
* @param settings - the Host settings provider.
|
|
688
|
+
* @param config - the Host composition entry's config.
|
|
689
|
+
*/
|
|
690
|
+
function startQuickActionsHost(settings, config) {
|
|
691
|
+
const loaded = readComposerQuickActionsConfig(config);
|
|
692
|
+
if (!loaded.ok) throw new Error(loaded.message);
|
|
693
|
+
const { catalog } = loaded;
|
|
694
|
+
settings.register(QUICK_ACTIONS_SETTINGS_NAMESPACE, quickActionSettingsSchema, { applies: "live" });
|
|
695
|
+
settings.register(QUICK_ACTIONS_CATALOG_NAMESPACE, quickActionCatalogSchema, {
|
|
696
|
+
base: detachedCatalog(catalog),
|
|
697
|
+
applies: "restart"
|
|
698
|
+
});
|
|
699
|
+
return {
|
|
700
|
+
ready: rewriteCanonicalSettings(settings, catalog),
|
|
701
|
+
describeCatalog: async () => detachedCatalog(catalog)
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
//#endregion
|
|
705
|
+
//#region src/index.ts
|
|
706
|
+
const name = "composer-quick-actions";
|
|
707
|
+
const inject = ["settings"];
|
|
708
|
+
/**
|
|
709
|
+
* Load the Preset Catalog, register the Settings namespace and canonicalize the
|
|
710
|
+
* stored section. An invalid preset configuration throws here, failing plugin
|
|
711
|
+
* loading loudly rather than shipping a partial catalog (spec 5.1).
|
|
712
|
+
*
|
|
713
|
+
* @returns the running Host, so a composition that embeds this plugin directly
|
|
714
|
+
* can reach the catalog projection; the cordis loader ignores the value.
|
|
715
|
+
*/
|
|
716
|
+
function apply(ctx, config) {
|
|
717
|
+
const settings = ctx.settings;
|
|
718
|
+
const host = startQuickActionsHost(settings, config);
|
|
719
|
+
const logger = ctx.logger("composer-quick-actions");
|
|
720
|
+
ctx.effect(() => {
|
|
721
|
+
let owned = true;
|
|
722
|
+
host.ready.then((outcome) => {
|
|
723
|
+
if (!owned) return;
|
|
724
|
+
if (outcome.status === "conflict") logger.warn("settings kept moving during the canonical rewrite after %d attempts; leaving the stored section as the concurrent writer left it", outcome.attempts);
|
|
725
|
+
else if (outcome.status === "read-only") logger.warn("settings are read-only; the stored section was left untouched");
|
|
726
|
+
else if (outcome.status === "newer-version") logger.warn("the stored section declares schemaVersion %d; leaving it untouched so downgrading loses nothing", outcome.schemaVersion);
|
|
727
|
+
}, (error) => {
|
|
728
|
+
if (owned) logger.error(error);
|
|
729
|
+
});
|
|
730
|
+
return () => {
|
|
731
|
+
owned = false;
|
|
732
|
+
};
|
|
733
|
+
}, "composer-quick-actions: canonical settings rewrite");
|
|
734
|
+
return host;
|
|
735
|
+
}
|
|
736
|
+
//#endregion
|
|
737
|
+
export { apply, inject, name };
|