@fonlan/dsh-gitmemo 0.3.2 → 0.4.1
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/README.md +105 -9
- package/README.zh.md +77 -9
- package/lib/client.js +721 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +158 -15
- package/lib/index.js +414 -17
- package/lib/mem.d.ts +75 -5
- package/lib/mem.js +182 -18
- package/lib/migrate.js +2 -1
- package/lib/systemone.d.ts +213 -0
- package/lib/systemone.js +283 -0
- package/package.json +33 -5
package/lib/client.js
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@fonlan/dsh-gitmemo",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
8
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
|
+
//#region src/client/card.tsx
|
|
10
|
+
/**
|
|
11
|
+
* The gitmemo settings page's card: the gate's master switch, the System-one
|
|
12
|
+
* endpoint, its model, and the API key — written through the credentials domain,
|
|
13
|
+
* never into the settings section, so the literal never rides a response.
|
|
14
|
+
*
|
|
15
|
+
* The form chrome (read-only notice, unavailable notice, save, and the staged
|
|
16
|
+
* save semantics) is the shared `SettingsForm`/`SettingsValueField`/
|
|
17
|
+
* `SettingsSecretField` kit, so this page behaves exactly like a shipped one.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Render one labelled boolean field.
|
|
21
|
+
* @param props - the field's copy, its staged state, and the edit actions.
|
|
22
|
+
* @returns the labelled switch.
|
|
23
|
+
*/
|
|
24
|
+
function SettingsSwitchField(props) {
|
|
25
|
+
const { id, label, hint, checked, overridden, overriddenLabel, resetLabel, disabled, onEdit, onReset } = props;
|
|
26
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
27
|
+
style: {
|
|
28
|
+
display: "flex",
|
|
29
|
+
flexDirection: "column",
|
|
30
|
+
gap: 6
|
|
31
|
+
},
|
|
32
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
33
|
+
style: {
|
|
34
|
+
display: "flex",
|
|
35
|
+
alignItems: "center",
|
|
36
|
+
justifyContent: "space-between",
|
|
37
|
+
gap: 12
|
|
38
|
+
},
|
|
39
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
40
|
+
style: {
|
|
41
|
+
fontSize: 13,
|
|
42
|
+
fontWeight: 500,
|
|
43
|
+
color: "var(--dsw-alias-label-primary)"
|
|
44
|
+
},
|
|
45
|
+
children: label
|
|
46
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
47
|
+
style: {
|
|
48
|
+
display: "flex",
|
|
49
|
+
alignItems: "center",
|
|
50
|
+
gap: 8
|
|
51
|
+
},
|
|
52
|
+
children: [overridden ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
|
|
53
|
+
tone: "neutral",
|
|
54
|
+
children: overriddenLabel
|
|
55
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
56
|
+
variant: "ghost",
|
|
57
|
+
size: "sm",
|
|
58
|
+
type: "button",
|
|
59
|
+
disabled,
|
|
60
|
+
onClick: onReset,
|
|
61
|
+
children: resetLabel
|
|
62
|
+
})] }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Switch, {
|
|
63
|
+
checked,
|
|
64
|
+
onChange: onEdit,
|
|
65
|
+
label,
|
|
66
|
+
disabled
|
|
67
|
+
})]
|
|
68
|
+
})]
|
|
69
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
70
|
+
id: `${id}-hint`,
|
|
71
|
+
style: {
|
|
72
|
+
margin: 0,
|
|
73
|
+
fontSize: 12,
|
|
74
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
75
|
+
},
|
|
76
|
+
children: hint
|
|
77
|
+
})]
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The form frame's copy, read from this page's dictionary.
|
|
82
|
+
* @param t - the page's locale reader.
|
|
83
|
+
* @returns the labels the shared settings form renders.
|
|
84
|
+
*/
|
|
85
|
+
function formLabels(t) {
|
|
86
|
+
return {
|
|
87
|
+
unavailable: t("unavailable"),
|
|
88
|
+
readOnly: t("readOnly"),
|
|
89
|
+
saveFailed: t("saveFailed"),
|
|
90
|
+
save: t("save"),
|
|
91
|
+
saving: t("saving")
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Render the gitmemo settings page.
|
|
96
|
+
* @param props - locale copy, the staged form state, and its actions.
|
|
97
|
+
* @returns the settings form.
|
|
98
|
+
*/
|
|
99
|
+
function GitMemoSettingsSection(props) {
|
|
100
|
+
const { t, useGitMemoSettings, edit, resetField, save, discard } = props;
|
|
101
|
+
const state = useGitMemoSettings((snapshot) => snapshot);
|
|
102
|
+
const sectionDisabled = !state.writable;
|
|
103
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.SettingsForm, {
|
|
104
|
+
labels: formLabels(t),
|
|
105
|
+
state,
|
|
106
|
+
onSave: save,
|
|
107
|
+
onDiscard: discard,
|
|
108
|
+
children: [
|
|
109
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingsSwitchField, {
|
|
110
|
+
id: "dsh-gitmemo-systemone-enabled",
|
|
111
|
+
label: t("enabled"),
|
|
112
|
+
hint: t("enabledHint"),
|
|
113
|
+
overriddenLabel: t("overridden"),
|
|
114
|
+
resetLabel: t("reset"),
|
|
115
|
+
disabled: sectionDisabled,
|
|
116
|
+
checked: state.enabled.text === "true",
|
|
117
|
+
overridden: state.enabled.overridden,
|
|
118
|
+
onEdit: (next) => {
|
|
119
|
+
edit("enabled", String(next));
|
|
120
|
+
},
|
|
121
|
+
onReset: () => {
|
|
122
|
+
resetField("enabled");
|
|
123
|
+
}
|
|
124
|
+
}),
|
|
125
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.SettingsValueField, {
|
|
126
|
+
id: "dsh-gitmemo-systemone-endpoint",
|
|
127
|
+
label: t("endpoint"),
|
|
128
|
+
hint: t("endpointHint"),
|
|
129
|
+
overriddenLabel: t("overridden"),
|
|
130
|
+
resetLabel: t("reset"),
|
|
131
|
+
invalidLabel: t("invalidText"),
|
|
132
|
+
disabled: sectionDisabled,
|
|
133
|
+
...state.endpoint,
|
|
134
|
+
onEdit: (text) => {
|
|
135
|
+
edit("endpoint", text);
|
|
136
|
+
},
|
|
137
|
+
onReset: () => {
|
|
138
|
+
resetField("endpoint");
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.SettingsValueField, {
|
|
142
|
+
id: "dsh-gitmemo-systemone-model",
|
|
143
|
+
label: t("model"),
|
|
144
|
+
hint: t("modelHint"),
|
|
145
|
+
overriddenLabel: t("overridden"),
|
|
146
|
+
resetLabel: t("reset"),
|
|
147
|
+
invalidLabel: t("invalidText"),
|
|
148
|
+
disabled: sectionDisabled,
|
|
149
|
+
...state.model,
|
|
150
|
+
onEdit: (text) => {
|
|
151
|
+
edit("model", text);
|
|
152
|
+
},
|
|
153
|
+
onReset: () => {
|
|
154
|
+
resetField("model");
|
|
155
|
+
}
|
|
156
|
+
}),
|
|
157
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.SettingsSecretField, {
|
|
158
|
+
id: "dsh-gitmemo-systemone-apikey",
|
|
159
|
+
label: t("apiKey"),
|
|
160
|
+
hint: t("apiKeyHint"),
|
|
161
|
+
disabled: !state.apiKeyWritable,
|
|
162
|
+
text: state.apiKey.text,
|
|
163
|
+
configured: state.apiKeyConfigured,
|
|
164
|
+
stateLabel: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"),
|
|
165
|
+
onEdit: (text) => {
|
|
166
|
+
edit("apiKey", text);
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
]
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/client/form-model.tsx
|
|
174
|
+
/**
|
|
175
|
+
* A free-text field. An empty draft clears the field, so emptying the control
|
|
176
|
+
* and saving is the same gesture as resetting it.
|
|
177
|
+
* @param field - key addressing this control inside the card's form.
|
|
178
|
+
* @param path - path of the field inside the namespace section.
|
|
179
|
+
* @returns the field's conversion spec.
|
|
180
|
+
*/
|
|
181
|
+
function pathTextField(field, path) {
|
|
182
|
+
return {
|
|
183
|
+
field,
|
|
184
|
+
path,
|
|
185
|
+
format: (value) => typeof value === "string" ? value : "",
|
|
186
|
+
parse: (text) => {
|
|
187
|
+
const trimmed = text.trim();
|
|
188
|
+
return trimmed === "" ? { kind: "clear" } : {
|
|
189
|
+
kind: "set",
|
|
190
|
+
value: trimmed
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Draft text a boolean field renders while the section carries no value.
|
|
197
|
+
*
|
|
198
|
+
* A missing boolean is not "unknown": `src/index.ts` declares
|
|
199
|
+
* `systemOne.enabled` with `default(true)` and reads it back through
|
|
200
|
+
* `liveValue(section.enabled, true)`, so an absent value means ENABLED. Rendering
|
|
201
|
+
* an absent boolean as the empty string would show a disabled switch for a gate
|
|
202
|
+
* that is actually running, so the draft is seeded with the declared default.
|
|
203
|
+
*/
|
|
204
|
+
const ABSENT_BOOLEAN_TEXT = "true";
|
|
205
|
+
/**
|
|
206
|
+
* A boolean field, staged as the `'true'`/`'false'` draft the card's switch
|
|
207
|
+
* renders. An empty draft clears the field (re-inheriting the declared
|
|
208
|
+
* default); any other draft that is not one of those two words blocks the save.
|
|
209
|
+
* @param field - key addressing this control inside the card's form.
|
|
210
|
+
* @param path - path of the field inside the namespace section.
|
|
211
|
+
* @returns the field's conversion spec.
|
|
212
|
+
*/
|
|
213
|
+
function pathBooleanField(field, path) {
|
|
214
|
+
return {
|
|
215
|
+
field,
|
|
216
|
+
path,
|
|
217
|
+
format: (value) => typeof value === "boolean" ? String(value) : ABSENT_BOOLEAN_TEXT,
|
|
218
|
+
parse: (text) => {
|
|
219
|
+
const trimmed = text.trim();
|
|
220
|
+
if (trimmed === "") return { kind: "clear" };
|
|
221
|
+
if (trimmed === "true") return {
|
|
222
|
+
kind: "set",
|
|
223
|
+
value: true
|
|
224
|
+
};
|
|
225
|
+
if (trimmed === "false") return {
|
|
226
|
+
kind: "set",
|
|
227
|
+
value: false
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/** Read one path inside a JSON-shaped layer. */
|
|
233
|
+
function member(value, path) {
|
|
234
|
+
let node = value;
|
|
235
|
+
for (const key of path) {
|
|
236
|
+
if (node === null || typeof node !== "object") return void 0;
|
|
237
|
+
node = node[key];
|
|
238
|
+
}
|
|
239
|
+
return node;
|
|
240
|
+
}
|
|
241
|
+
/** Whether the user layer actually carries this path (presence, not value, marks an override). */
|
|
242
|
+
function carries(value, path) {
|
|
243
|
+
const last = path[path.length - 1];
|
|
244
|
+
if (last === void 0) return false;
|
|
245
|
+
const parent = member(value, path.slice(0, -1));
|
|
246
|
+
return parent !== null && typeof parent === "object" && Object.hasOwn(parent, last);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Stages one card's edits over one settings namespace and writes them on save.
|
|
250
|
+
*/
|
|
251
|
+
var GitMemoFormModel = class {
|
|
252
|
+
scope;
|
|
253
|
+
specs;
|
|
254
|
+
secretSpecs;
|
|
255
|
+
staged = /* @__PURE__ */ new Map();
|
|
256
|
+
listeners = /* @__PURE__ */ new Set();
|
|
257
|
+
unsubscribe;
|
|
258
|
+
baseline;
|
|
259
|
+
saving = false;
|
|
260
|
+
failed = false;
|
|
261
|
+
/**
|
|
262
|
+
* @param scope - the shared configuration form for this card's namespace.
|
|
263
|
+
* @param specs - the section fields this card edits.
|
|
264
|
+
* @param secrets - the card's write-only controls, written outside the section.
|
|
265
|
+
*/
|
|
266
|
+
constructor(scope, specs, secrets = []) {
|
|
267
|
+
this.scope = scope;
|
|
268
|
+
this.specs = new Map(specs.map((spec) => [spec.field, spec]));
|
|
269
|
+
this.secretSpecs = new Map(secrets.map((spec) => [spec.field, spec]));
|
|
270
|
+
this.unsubscribe = scope.subscribe(() => {
|
|
271
|
+
this.publish();
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/** Observe draft or host-state changes. */
|
|
275
|
+
subscribe(listener) {
|
|
276
|
+
this.listeners.add(listener);
|
|
277
|
+
return () => {
|
|
278
|
+
this.listeners.delete(listener);
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/** Read the card-level state: what the Host serves, and what a save would do. */
|
|
282
|
+
shell() {
|
|
283
|
+
const snapshot = this.scope.getSnapshot();
|
|
284
|
+
const plan = this.plan();
|
|
285
|
+
return {
|
|
286
|
+
available: snapshot.status === "ready",
|
|
287
|
+
writable: snapshot.writable,
|
|
288
|
+
dirty: plan.length > 0,
|
|
289
|
+
invalid: plan.some((item) => item.invalid === true),
|
|
290
|
+
saving: this.saving,
|
|
291
|
+
failed: this.failed
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Read one control's state.
|
|
296
|
+
* @param field - key of a section field or of a write-only control.
|
|
297
|
+
* @returns the draft text, whether a save would leave an override, and whether it is invalid.
|
|
298
|
+
*/
|
|
299
|
+
field(field) {
|
|
300
|
+
const staged = this.staged.get(field);
|
|
301
|
+
if (this.secretSpecs.has(field)) return {
|
|
302
|
+
text: staged?.text ?? "",
|
|
303
|
+
overridden: false,
|
|
304
|
+
invalid: false
|
|
305
|
+
};
|
|
306
|
+
const spec = this.spec(field);
|
|
307
|
+
if (staged === void 0) return {
|
|
308
|
+
text: spec.format(this.sectionValue(spec.path)),
|
|
309
|
+
overridden: this.stored(spec.path),
|
|
310
|
+
invalid: false
|
|
311
|
+
};
|
|
312
|
+
const write = staged.clear ? { kind: "clear" } : spec.parse(staged.text);
|
|
313
|
+
return {
|
|
314
|
+
text: staged.text,
|
|
315
|
+
overridden: write?.kind === "set",
|
|
316
|
+
invalid: write === void 0
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Build the edit, reset, save, and discard actions bound to this form. */
|
|
320
|
+
actions() {
|
|
321
|
+
return {
|
|
322
|
+
edit: (field, text) => {
|
|
323
|
+
this.stage(field, {
|
|
324
|
+
text,
|
|
325
|
+
clear: false
|
|
326
|
+
});
|
|
327
|
+
},
|
|
328
|
+
resetField: (field) => {
|
|
329
|
+
const spec = this.spec(field);
|
|
330
|
+
this.stage(field, {
|
|
331
|
+
text: spec.format(this.baseValue(spec.path)),
|
|
332
|
+
clear: true
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
save: () => {
|
|
336
|
+
this.save();
|
|
337
|
+
},
|
|
338
|
+
discard: () => {
|
|
339
|
+
if (this.staged.size === 0 && !this.failed) return;
|
|
340
|
+
this.staged.clear();
|
|
341
|
+
this.baseline = void 0;
|
|
342
|
+
this.failed = false;
|
|
343
|
+
this.publish();
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Write every staged edit, then re-seed from what the Host accepted.
|
|
349
|
+
*
|
|
350
|
+
* The Host is the only authority on whether a value was accepted, so the
|
|
351
|
+
* outcome is read back from the section rather than predicted here. A save
|
|
352
|
+
* that did not land keeps its drafts, so the user can correct them instead of
|
|
353
|
+
* retyping.
|
|
354
|
+
*/
|
|
355
|
+
async save() {
|
|
356
|
+
const plan = this.plan();
|
|
357
|
+
if (plan.length === 0 || this.saving || !this.scope.getSnapshot().writable || plan.some((item) => item.invalid === true)) return;
|
|
358
|
+
this.saving = true;
|
|
359
|
+
this.failed = false;
|
|
360
|
+
this.publish();
|
|
361
|
+
try {
|
|
362
|
+
const ops = plan.flatMap((item) => item.op === void 0 ? [] : [item.op]);
|
|
363
|
+
let landed = ops.length === 0 || await this.scope.mutate(ops, this.baseline?.revision);
|
|
364
|
+
if (!landed) {
|
|
365
|
+
this.failed = true;
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
for (const item of plan) if (item.run !== void 0) landed = await item.run() && landed;
|
|
369
|
+
if (landed) {
|
|
370
|
+
this.staged.clear();
|
|
371
|
+
this.baseline = void 0;
|
|
372
|
+
}
|
|
373
|
+
this.failed = !landed;
|
|
374
|
+
} catch {
|
|
375
|
+
this.failed = true;
|
|
376
|
+
} finally {
|
|
377
|
+
this.saving = false;
|
|
378
|
+
this.publish();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** Release the form's accepted-value subscription. */
|
|
382
|
+
dispose() {
|
|
383
|
+
this.unsubscribe();
|
|
384
|
+
this.listeners.clear();
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Every staged edit a save would write, in staging order. An entry whose
|
|
388
|
+
* draft is not a value its field accepts carries no write: the form is still
|
|
389
|
+
* dirty, and the save refuses rather than dropping the edit.
|
|
390
|
+
*/
|
|
391
|
+
plan() {
|
|
392
|
+
const plan = [];
|
|
393
|
+
for (const [field, staged] of this.staged) {
|
|
394
|
+
const secret = this.secretSpecs.get(field);
|
|
395
|
+
if (secret !== void 0) {
|
|
396
|
+
const value = staged.text.trim();
|
|
397
|
+
if (value !== "") plan.push({
|
|
398
|
+
field,
|
|
399
|
+
run: () => secret.write(value)
|
|
400
|
+
});
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const spec = this.spec(field);
|
|
404
|
+
if (staged.clear) {
|
|
405
|
+
if (this.stored(spec.path)) plan.push({
|
|
406
|
+
field,
|
|
407
|
+
op: {
|
|
408
|
+
op: "unset",
|
|
409
|
+
path: [...spec.path]
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (staged.text === spec.format(this.sectionValue(spec.path))) continue;
|
|
415
|
+
const write = spec.parse(staged.text);
|
|
416
|
+
if (write === void 0) plan.push({
|
|
417
|
+
field,
|
|
418
|
+
invalid: true
|
|
419
|
+
});
|
|
420
|
+
else if (write.kind === "clear") plan.push({
|
|
421
|
+
field,
|
|
422
|
+
op: {
|
|
423
|
+
op: "unset",
|
|
424
|
+
path: [...spec.path]
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
else plan.push({
|
|
428
|
+
field,
|
|
429
|
+
op: {
|
|
430
|
+
op: "set",
|
|
431
|
+
path: [...spec.path],
|
|
432
|
+
value: write.value
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return plan;
|
|
437
|
+
}
|
|
438
|
+
stage(field, edit) {
|
|
439
|
+
this.baseline ??= this.scope.getSnapshot();
|
|
440
|
+
this.staged.set(field, edit);
|
|
441
|
+
this.failed = false;
|
|
442
|
+
this.publish();
|
|
443
|
+
}
|
|
444
|
+
spec(field) {
|
|
445
|
+
const spec = this.specs.get(field);
|
|
446
|
+
if (spec === void 0) throw new Error(`dsh-gitmemo settings card has no field ${field}`);
|
|
447
|
+
return spec;
|
|
448
|
+
}
|
|
449
|
+
sectionValue(path) {
|
|
450
|
+
return member(this.scope.getSnapshot().value, path);
|
|
451
|
+
}
|
|
452
|
+
baseValue(path) {
|
|
453
|
+
return member(this.scope.getSnapshot().base, path);
|
|
454
|
+
}
|
|
455
|
+
stored(path) {
|
|
456
|
+
return carries(this.scope.getSnapshot().user, path);
|
|
457
|
+
}
|
|
458
|
+
publish() {
|
|
459
|
+
for (const listener of [...this.listeners]) listener();
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/client/controller.tsx
|
|
464
|
+
/**
|
|
465
|
+
* Settings namespace of this plugin. Spelled here rather than imported: a
|
|
466
|
+
* client half must not depend on a host half, and the id is the profile entry
|
|
467
|
+
* id the settings plane keys forms by.
|
|
468
|
+
*/
|
|
469
|
+
const GITMEMO_NS = "dsh-gitmemo";
|
|
470
|
+
/** Credential reference the host resolves when the section names none. */
|
|
471
|
+
const DEFAULT_API_KEY_REF = "TYPESAFE_API_KEY";
|
|
472
|
+
/** Form field the credential control stages under. */
|
|
473
|
+
const API_KEY_FIELD = "apiKey";
|
|
474
|
+
/** Bridges the `dsh-gitmemo` scope and the credentials domain onto the page. */
|
|
475
|
+
var GitMemoSettingsController = class {
|
|
476
|
+
scope;
|
|
477
|
+
ctx;
|
|
478
|
+
form;
|
|
479
|
+
listeners = /* @__PURE__ */ new Set();
|
|
480
|
+
unsubscribe;
|
|
481
|
+
unsubscribeForm;
|
|
482
|
+
snapshot;
|
|
483
|
+
credential = {
|
|
484
|
+
ref: "",
|
|
485
|
+
configured: false,
|
|
486
|
+
writable: true
|
|
487
|
+
};
|
|
488
|
+
/**
|
|
489
|
+
* @param scope - the bound settings scope for the `dsh-gitmemo` namespace.
|
|
490
|
+
* @param ctx - the page plugin's context, whose `remote.credentials` namespace
|
|
491
|
+
* answers for the credential the section references.
|
|
492
|
+
*/
|
|
493
|
+
constructor(scope, ctx) {
|
|
494
|
+
this.scope = scope;
|
|
495
|
+
this.ctx = ctx;
|
|
496
|
+
this.form = new GitMemoFormModel(scope, [
|
|
497
|
+
pathBooleanField("enabled", ["systemOne", "enabled"]),
|
|
498
|
+
pathTextField("endpoint", ["systemOne", "endpoint"]),
|
|
499
|
+
pathTextField("model", ["systemOne", "model"])
|
|
500
|
+
], [{
|
|
501
|
+
field: API_KEY_FIELD,
|
|
502
|
+
write: (text) => this.writeKey(text)
|
|
503
|
+
}]);
|
|
504
|
+
this.snapshot = this.projection();
|
|
505
|
+
this.unsubscribe = scope.subscribe(() => {
|
|
506
|
+
this.readCredential();
|
|
507
|
+
this.publish();
|
|
508
|
+
});
|
|
509
|
+
this.unsubscribeForm = this.form.subscribe(() => {
|
|
510
|
+
this.publish();
|
|
511
|
+
});
|
|
512
|
+
this.readCredential();
|
|
513
|
+
}
|
|
514
|
+
/** Read the current page snapshot (stable reference until the next change). */
|
|
515
|
+
getSnapshot = () => this.snapshot;
|
|
516
|
+
/** Observe page-snapshot replacements. */
|
|
517
|
+
subscribe = (listener) => {
|
|
518
|
+
this.listeners.add(listener);
|
|
519
|
+
return () => {
|
|
520
|
+
this.listeners.delete(listener);
|
|
521
|
+
};
|
|
522
|
+
};
|
|
523
|
+
/**
|
|
524
|
+
* Re-read after the Host reports a change to the reference this page watches.
|
|
525
|
+
* A key can be written from somewhere else, and the settings section does not
|
|
526
|
+
* change when it is.
|
|
527
|
+
* @param ref - the reference the Host reports as changed.
|
|
528
|
+
*/
|
|
529
|
+
refreshCredential(ref) {
|
|
530
|
+
if (ref !== this.credential.ref) return;
|
|
531
|
+
this.readCredential();
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Build the face the page's slot registration injects.
|
|
535
|
+
* @returns the page's snapshot and its form actions.
|
|
536
|
+
*/
|
|
537
|
+
inject() {
|
|
538
|
+
return {
|
|
539
|
+
hooks: { gitMemoSettings: {
|
|
540
|
+
getSnapshot: this.getSnapshot,
|
|
541
|
+
subscribe: this.subscribe
|
|
542
|
+
} },
|
|
543
|
+
...this.form.actions()
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/** Release configuration subscriptions. */
|
|
547
|
+
dispose() {
|
|
548
|
+
this.unsubscribe();
|
|
549
|
+
this.unsubscribeForm();
|
|
550
|
+
this.form.dispose();
|
|
551
|
+
this.listeners.clear();
|
|
552
|
+
}
|
|
553
|
+
projection() {
|
|
554
|
+
return {
|
|
555
|
+
...this.form.shell(),
|
|
556
|
+
enabled: this.form.field("enabled"),
|
|
557
|
+
endpoint: this.form.field("endpoint"),
|
|
558
|
+
model: this.form.field("model"),
|
|
559
|
+
apiKey: this.form.field(API_KEY_FIELD),
|
|
560
|
+
apiKeyConfigured: this.credential.configured,
|
|
561
|
+
apiKeyWritable: this.credential.writable
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
publish() {
|
|
565
|
+
this.snapshot = this.projection();
|
|
566
|
+
for (const listener of [...this.listeners]) listener();
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Ask the credentials domain about the reference the section currently names.
|
|
570
|
+
*
|
|
571
|
+
* The answer is stored with the reference it describes: `apiKeyEnv` can change
|
|
572
|
+
* between the request and its response, so a response is published only while
|
|
573
|
+
* it still answers for the reference in force.
|
|
574
|
+
*/
|
|
575
|
+
async readCredential() {
|
|
576
|
+
const ref = refOf(this.scope.getSnapshot());
|
|
577
|
+
if (ref !== this.credential.ref) {
|
|
578
|
+
this.credential = {
|
|
579
|
+
ref,
|
|
580
|
+
configured: false,
|
|
581
|
+
writable: true
|
|
582
|
+
};
|
|
583
|
+
this.publish();
|
|
584
|
+
}
|
|
585
|
+
const response = await this.ctx.remote.credentials.describe([ref]);
|
|
586
|
+
if (!response.ok || ref !== refOf(this.scope.getSnapshot())) return;
|
|
587
|
+
const view = response.value[ref];
|
|
588
|
+
const next = {
|
|
589
|
+
ref,
|
|
590
|
+
configured: view?.configured ?? false,
|
|
591
|
+
writable: view?.writable ?? true
|
|
592
|
+
};
|
|
593
|
+
if (next.configured === this.credential.configured && next.writable === this.credential.writable) return;
|
|
594
|
+
this.credential = next;
|
|
595
|
+
this.publish();
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Write the staged key, then re-read whether the Host now holds one.
|
|
599
|
+
* @param value - the staged credential literal.
|
|
600
|
+
* @returns whether the Host reports a configured credential afterwards.
|
|
601
|
+
*/
|
|
602
|
+
async writeKey(value) {
|
|
603
|
+
const response = await this.ctx.remote.credentials.set(refOf(this.scope.getSnapshot()), value);
|
|
604
|
+
await this.readCredential();
|
|
605
|
+
return response.ok && this.credential.configured;
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
/**
|
|
609
|
+
* The credential reference the section names, or the host's default.
|
|
610
|
+
* @param snapshot - the current scope snapshot.
|
|
611
|
+
* @returns the reference to address.
|
|
612
|
+
*/
|
|
613
|
+
function refOf(snapshot) {
|
|
614
|
+
const declared = snapshot.value?.systemOne?.apiKeyEnv;
|
|
615
|
+
return declared !== void 0 && declared.length > 0 ? declared : DEFAULT_API_KEY_REF;
|
|
616
|
+
}
|
|
617
|
+
//#endregion
|
|
618
|
+
//#region src/client/locales.tsx
|
|
619
|
+
/**
|
|
620
|
+
* Locale dictionaries for the gitmemo settings page.
|
|
621
|
+
*
|
|
622
|
+
* The namespace is the plugin's own cordis id (kebab), which is also the
|
|
623
|
+
* settings namespace and the profile entry id; the scoped npm package name is
|
|
624
|
+
* reserved for package-bound identifiers (the module-loader id, the bundle
|
|
625
|
+
* route base). zh is the key source of truth — en is typed against it, and the
|
|
626
|
+
* locale registry re-checks the key sets at registration time.
|
|
627
|
+
*/
|
|
628
|
+
/** Dictionary namespace owned by this page. */
|
|
629
|
+
const LOCALE_NS = "dsh-gitmemo";
|
|
630
|
+
/** Simplified Chinese copy. */
|
|
631
|
+
const zh = {
|
|
632
|
+
settingsTitle: "GitMemo",
|
|
633
|
+
settingsSub: "Git 长期记忆(.mem)中「系统一召回闸门」的配置。",
|
|
634
|
+
enabled: "启用 System-one 召回闸门",
|
|
635
|
+
enabledHint: "关闭后 mem_search 不再调用 System-one 模型,召回行为与门控存在前逐字节一致;此外仍需已配置密钥。",
|
|
636
|
+
endpoint: "System-one 接口地址",
|
|
637
|
+
endpointHint: "接受 System-one 请求契约的 HTTP 端点;留空表示恢复默认地址。",
|
|
638
|
+
model: "模型",
|
|
639
|
+
modelHint: "请求体中携带的模型标识;留空表示恢复默认模型。",
|
|
640
|
+
apiKey: "API Key",
|
|
641
|
+
apiKeyHint: "凭据不写入设置文件。留空表示保持当前密钥。",
|
|
642
|
+
apiKeySet: "已配置密钥。",
|
|
643
|
+
apiKeyUnset: "未配置密钥;配置之前召回闸门不生效。",
|
|
644
|
+
overridden: "已覆盖",
|
|
645
|
+
reset: "恢复默认",
|
|
646
|
+
invalidText: "该字段不接受这个值。",
|
|
647
|
+
readOnly: "本部署的设置为只读。",
|
|
648
|
+
unavailable: "该插件当前未加载,暂时无法配置。",
|
|
649
|
+
save: "保存",
|
|
650
|
+
saving: "保存中…",
|
|
651
|
+
saveFailed: "本部署没有接受这些值,已保留供你修改。"
|
|
652
|
+
};
|
|
653
|
+
/** English copy; the key set must match {@link zh} exactly. */
|
|
654
|
+
const en = {
|
|
655
|
+
settingsTitle: "GitMemo",
|
|
656
|
+
settingsSub: "Configuration of the System-one recall gate over the Git-backed .mem memory.",
|
|
657
|
+
enabled: "Enable the System-one recall gate",
|
|
658
|
+
enabledHint: "When off, mem_search never calls the System-one model and recall behaves byte-for-byte as it did before the gate existed; a credential is still required for it to run.",
|
|
659
|
+
endpoint: "System-one endpoint",
|
|
660
|
+
endpointHint: "HTTP endpoint accepting the System-one request contract; blank restores the default.",
|
|
661
|
+
model: "Model",
|
|
662
|
+
modelHint: "Model identifier sent in the request body; blank restores the default.",
|
|
663
|
+
apiKey: "API key",
|
|
664
|
+
apiKeyHint: "Stored outside the settings file. Leave blank to keep the current key.",
|
|
665
|
+
apiKeySet: "A key is configured.",
|
|
666
|
+
apiKeyUnset: "No key is configured; the recall gate stays a no-op until one is.",
|
|
667
|
+
overridden: "Overridden",
|
|
668
|
+
reset: "Reset to default",
|
|
669
|
+
invalidText: "This field does not accept that value.",
|
|
670
|
+
readOnly: "This deployment stores settings read-only.",
|
|
671
|
+
unavailable: "This plugin is not loaded, so it cannot be configured right now.",
|
|
672
|
+
save: "Save",
|
|
673
|
+
saving: "Saving…",
|
|
674
|
+
saveFailed: "The deployment did not accept these values; they were left for you to correct."
|
|
675
|
+
};
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/client/index.tsx
|
|
678
|
+
/** The settings sidebar entry this plugin's page owns (must stay stable). */
|
|
679
|
+
const SETTINGS_SECTION_ID = "dsh-gitmemo";
|
|
680
|
+
/** Services required before mounting (provided by the client runtime). */
|
|
681
|
+
const inject = [
|
|
682
|
+
"slots",
|
|
683
|
+
"locale",
|
|
684
|
+
"remote",
|
|
685
|
+
"remote.credentials",
|
|
686
|
+
"configForms"
|
|
687
|
+
];
|
|
688
|
+
/**
|
|
689
|
+
* Mount the gitmemo settings page while the Host serves its namespace.
|
|
690
|
+
* @param ctx - the browser plugin context.
|
|
691
|
+
*/
|
|
692
|
+
function apply(ctx) {
|
|
693
|
+
const t = ctx.locale.bind(LOCALE_NS);
|
|
694
|
+
ctx.effect(() => ctx.locale.register(LOCALE_NS, {
|
|
695
|
+
zh,
|
|
696
|
+
en
|
|
697
|
+
}), "dsh-gitmemo: dictionaries");
|
|
698
|
+
const controller = new GitMemoSettingsController(ctx.configForms.get(GITMEMO_NS), ctx);
|
|
699
|
+
ctx.effect(() => () => {
|
|
700
|
+
controller.dispose();
|
|
701
|
+
}, "dsh-gitmemo: settings form");
|
|
702
|
+
ctx.effect(() => ctx.remote.$on("credentials/reference-updated", (ref) => {
|
|
703
|
+
controller.refreshCredential(ref);
|
|
704
|
+
}), "dsh-gitmemo: credential invalidations");
|
|
705
|
+
ctx.effect(() => ctx.configForms.whileServed([GITMEMO_NS], () => ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
706
|
+
name: "settings.section",
|
|
707
|
+
id: SETTINGS_SECTION_ID,
|
|
708
|
+
order: 200,
|
|
709
|
+
label: () => t("settingsTitle"),
|
|
710
|
+
locale: LOCALE_NS,
|
|
711
|
+
inject: () => controller.inject()
|
|
712
|
+
}, GitMemoSettingsSection))), "dsh-gitmemo: settings page");
|
|
713
|
+
}
|
|
714
|
+
//#endregion
|
|
715
|
+
exports.apply = apply;
|
|
716
|
+
exports.inject = inject;
|
|
717
|
+
return module.exports;
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
//# sourceMappingURL=client.js.map
|