@jayyuen66/dsh-lesson-loop 0.0.0-stage → 0.1.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/client.js ADDED
@@ -0,0 +1,920 @@
1
+ // GENERATED by buildClient() in build-client.mjs — edit src/*.ts, then run `node build-client.mjs`.
2
+ window.__ModuleLoader__.load({
3
+ id: '@jayyuen66/dsh-lesson-loop',
4
+ factory: (require) => {
5
+ var module = { exports: {} };
6
+ var exports = module.exports;
7
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
8
+ let react = require("react");
9
+ //#region src/ui-messages.ts
10
+ const UI_MESSAGES = {
11
+ zh: {
12
+ cardTitle: "lesson-loop 自进化环",
13
+ cardDescription: "教训总线 + 规则评审(候选 {candidates} · 生效 {armed} · 教训 {lessons} 条)",
14
+ statusReadOnly: "当前作用域只读",
15
+ statusDirty: "有未保存的修改,点「保存」生效",
16
+ statusClean: "无未保存的修改",
17
+ save: "保存",
18
+ saving: "保存中…",
19
+ revert: "撤销",
20
+ saveFailed: "保存失败:",
21
+ statsUnavailable: "stats 不可用",
22
+ actionFailed: "操作失败",
23
+ enabledLabel: "启用闭环",
24
+ enabledHint: "关闭后上报被丢弃、不注入、常驻段与 /lessons-digest 一并停用",
25
+ reportEnabledLabel: "教训落盘",
26
+ reportEnabledHint: "danger-guard / quality-gate / session-rescue 的失败事件写入 cache/lesson-loop/events.jsonl 并归并规则卡",
27
+ injectEnabledLabel: "会话开始注入规则",
28
+ injectEnabledHint: "agent/session-start 按项目注入 armed 规则全文(agent.inject,不唤醒)",
29
+ sectionEnabledLabel: "常驻系统提示段",
30
+ sectionEnabledHint: "systemPrompt 固定段:告知模型闭环存在与守卫拒绝的正确应对",
31
+ promoteThresholdLabel: "候选升格门槛(次)",
32
+ promoteThresholdHint: "同 (项目,分类,签名) 教训达此次数 → 候选卡标记待确认(1-20,默认 3)",
33
+ demoteThresholdLabel: "复发降级门槛(次)",
34
+ demoteThresholdHint: "armed 后复发达此次数且复发率达标 → 自动降级待人审(1-50,默认 3)",
35
+ demoteMinSamplesLabel: "降级最小证据样本(个)",
36
+ demoteMinSamplesHint: "降级判定所需的最小真实证据数 violation+suppressed(暴露 samples 不计入;1-100,默认 5;不足不降级)",
37
+ demoteRatioLabel: "降级复发率",
38
+ demoteRatioHint: "violation/(violation+suppressed) ≥ 此值 且真实证据足 → 自动降级(0.05-1,默认 0.5)",
39
+ reviveThresholdLabel: "拒绝后复活门槛(次)",
40
+ reviveThresholdHint: "rejected 规则同签名再次出现在此次数 → 自动转回候选待人工重审(1-20,默认 3)",
41
+ decayDaysLabel: "不可判定阈值(天)",
42
+ decayDaysHint: "armed 超此天数却既无复发也无一次遵守(从没被测到)→ 卡片标记「不可判定」交人工停用/归档(不再自动归档)(1-365,默认 30)",
43
+ maxLessonsBytesLabel: "events.jsonl 磁盘保险丝(字节)",
44
+ maxLessonsBytesHint: "0 = 不设上限(默认,内容零截断);仅在磁盘需要保护时设置",
45
+ sectionHeading: "{title}({count})",
46
+ sectionCandidates: "待确认候选(人工升格)",
47
+ sectionDemoted: "降级待审",
48
+ sectionArmed: "生效中的规则(复发/遵守/暴露 实时度量;不可判定者请人工处理)",
49
+ sectionRetired: "已拒绝/归档",
50
+ showRetired: "收起已拒绝/归档",
51
+ emptyGroup: "暂无",
52
+ metaObservations: "观察 {count} 次 · {day}",
53
+ metaArmed: "复发 {violation} / 遵守 {suppressed} / 暴露 {samples}",
54
+ metaUndeterminable: "不可判定:armed 至今既无复发也无一次遵守,机器无从裁定,请人工停用或归档",
55
+ metaRevive: "拒绝后又出现 {count} 次(达阈值自动回候选)",
56
+ metaFromDigest: "来自 /lessons-digest",
57
+ btnDemote: "停用待审",
58
+ btnArchive: "归档",
59
+ btnArmConfirm: "确认生效",
60
+ btnArmConfirmEditing: "确认生效(使用下方文本)",
61
+ btnReject: "拒绝",
62
+ btnDismiss: "不采纳",
63
+ btnRevive: "重新候选",
64
+ digestHint: "蒸馏新规则:会话内运行 /lessons-digest(把本会话人工差评归纳为候选规则)。"
65
+ },
66
+ en: {
67
+ cardTitle: "lesson-loop self-evolution loop",
68
+ cardDescription: "Lesson bus + rule review ({candidates} candidates · {armed} armed · {lessons} lessons)",
69
+ statusReadOnly: "This scope is read-only",
70
+ statusDirty: "Unsaved changes — press Save to apply",
71
+ statusClean: "No unsaved changes",
72
+ save: "Save",
73
+ saving: "Saving…",
74
+ revert: "Revert",
75
+ saveFailed: "save failed: ",
76
+ statsUnavailable: "stats unavailable",
77
+ actionFailed: "action failed",
78
+ enabledLabel: "Enable the loop",
79
+ enabledHint: "When off: reports are dropped, nothing is injected, the standing prompt section and /lessons-digest are both disabled",
80
+ reportEnabledLabel: "Persist lessons",
81
+ reportEnabledHint: "Failure events from danger-guard / quality-gate / session-rescue are written into cache/lesson-loop/events.jsonl and merged into rule cards",
82
+ injectEnabledLabel: "Inject rules at session start",
83
+ injectEnabledHint: "agent/session-start injects full armed rules per project (agent.inject, without waking the driver)",
84
+ sectionEnabledLabel: "Standing system prompt section",
85
+ sectionEnabledHint: "Fixed systemPrompt section: tells the model the loop exists and how to react to guard denials",
86
+ promoteThresholdLabel: "Promotion threshold (times)",
87
+ promoteThresholdHint: "Lessons with the same (project, category, signature) reaching this count mark the candidate card as pending review (1-20, default 3)",
88
+ demoteThresholdLabel: "Demotion threshold (times)",
89
+ demoteThresholdHint: "After arming, this many violations plus a qualifying recurrence rate demotes the rule for human review (1-50, default 3)",
90
+ demoteMinSamplesLabel: "Minimum evidence samples for demotion",
91
+ demoteMinSamplesHint: "Smallest amount of real evidence violation+suppressed required to demote (exposure samples never count; 1-100, default 5; below it never demote)",
92
+ demoteRatioLabel: "Demotion recurrence ratio",
93
+ demoteRatioHint: "violation/(violation+suppressed) ≥ this value with enough real evidence demotes automatically (0.05-1, default 0.5)",
94
+ reviveThresholdLabel: "Revive threshold after rejection (times)",
95
+ reviveThresholdHint: "A rejected rule whose signature reappears this many times goes back to candidates for human re-review (1-20, default 3)",
96
+ decayDaysLabel: "Undeterminable threshold (days)",
97
+ decayDaysHint: "Armed longer than this with neither a violation nor a single follow-through marks the card as undeterminable for a human to disable/archive (no more silent archiving) (1-365, default 30)",
98
+ maxLessonsBytesLabel: "events.jsonl disk fuse (bytes)",
99
+ maxLessonsBytesHint: "0 = no cap (default, content never truncated); set only to protect the disk",
100
+ sectionHeading: "{title} ({count})",
101
+ sectionCandidates: "Pending candidates (human promotion)",
102
+ sectionDemoted: "Demoted, awaiting review",
103
+ sectionArmed: "Armed rules (live violation/followed/exposure metrics; handle undeterminable ones by hand)",
104
+ sectionRetired: "Rejected / archived",
105
+ showRetired: "Hide rejected / archived",
106
+ emptyGroup: "none",
107
+ metaObservations: "{count} observations · {day}",
108
+ metaArmed: "{violation} violations / {suppressed} followed / {samples} exposed",
109
+ metaUndeterminable: "Undeterminable: armed this long with neither a violation nor a single follow-through — no way for the machine to judge, please disable or archive it",
110
+ metaRevive: "Reappeared {count} time(s) after rejection (returns to candidates at the threshold)",
111
+ metaFromDigest: "from /lessons-digest",
112
+ btnDemote: "Demote for review",
113
+ btnArchive: "Archive",
114
+ btnArmConfirm: "Confirm and arm",
115
+ btnArmConfirmEditing: "Confirm and arm (text below)",
116
+ btnReject: "Reject",
117
+ btnDismiss: "Dismiss",
118
+ btnRevive: "Back to candidates",
119
+ digestHint: "To distil new rules: run /lessons-digest inside a session (it turns this session's negative feedback into candidate rules)."
120
+ }
121
+ };
122
+ //#endregion
123
+ //#region node_modules/.pnpm/@jayyuen66+dsh-plugin-shared@0.1.0_@deepseek-ai+cordis@4.0.4_eslint-plugin-sonarjs@4.2._a5b0027b68963132c312048d042510c0/node_modules/@jayyuen66/dsh-plugin-shared/dist/record.js
124
+ /** 对象类型守卫:非 `null` 的 `object` 且非数组(`Record` 索引签名投影的基础)。 */
125
+ function isRecord(value) {
126
+ return typeof value === "object" && value !== null && !Array.isArray(value);
127
+ }
128
+ /** 从 `unknown` 投影单个字段;非 `Record` → `undefined`。 */
129
+ function fieldOf(value, key) {
130
+ return isRecord(value) ? value[key] : void 0;
131
+ }
132
+ //#endregion
133
+ //#region src/client-entry.ts
134
+ /** 本包 `NS` 的第三重身份:官方 locale 的命名空间(前两重 = loader 条目 id 与 settings
135
+ * 命名空间 = `configForms.get(NS)` 的入参,见下面那段「两个不同的标识」)。这一重必须
136
+ * 等于 src/ui-messages.ts 里 merge 进 `LocaleNamespaceMap` 的那枚键:两边分叉时下面
137
+ * `LocaleCatalog` 的 `LocaleDictOf<typeof NS>` 在编译期就红(不在官方表里的串不满足该类型
138
+ * 的 `N extends keyof LocaleNamespaceMap & string` 约束),不需要运行时比对。 */
139
+ const NS = "lesson-loop";
140
+ const BUNDLE_PKG = "@jayyuen66/dsh-lesson-loop";
141
+ const STATS_PATH = "/_dsh/lesson-loop/stats";
142
+ const RULE_ACTION_PATH = "/_dsh/lesson-loop/rule-action";
143
+ const PATH_KEY = "d";
144
+ /** 规则卡状态白名单:与 host lib/lesson-store 的 RULE_STATUSES 同集合。 */
145
+ const RULE_STATUSES = /* @__PURE__ */ new Set([
146
+ "candidate",
147
+ "armed",
148
+ "demoted",
149
+ "rejected",
150
+ "archived"
151
+ ]);
152
+ function isRuleStatus(value) {
153
+ return typeof value === "string" && RULE_STATUSES.has(value);
154
+ }
155
+ /** 字符串字段归一:非字符串(服务端字段漂移)退兜底,不整张卡丢弃。 */
156
+ function strField(value, fallback) {
157
+ return typeof value === "string" ? value : fallback;
158
+ }
159
+ /** 数值字段读取:只认 number 与数字字符串两种形态,其余视为漂移。 */
160
+ function numericOf(value) {
161
+ let result;
162
+ if (typeof value === "number" || typeof value === "string") {
163
+ const parsed = Number(value);
164
+ if (Number.isFinite(parsed)) result = parsed;
165
+ }
166
+ return result;
167
+ }
168
+ /** 必填数值字段归一:漂移退兜底(卡片按数字渲染计数,undefined 会渲染成空白)。 */
169
+ function numField(value, fallback) {
170
+ return numericOf(value) ?? fallback;
171
+ }
172
+ /**
173
+ * 规则卡逐字段归一(/stats 投影)。旧实现查 id+status 就断言整张卡,服务端
174
+ * 新增或改名字段会让下游按 string/number 用的字段拿到 undefined。
175
+ * 只有 id 是硬要求(动作按钮按 id 回填),其余补默认;status 漂移归 archived
176
+ * (与 host 读取同一取向:归到"退役"分组里等人处理,不当活跃规则渲染)。
177
+ */
178
+ function normalizeRuleCardView(value) {
179
+ const id = fieldOf(value, "id");
180
+ if (typeof id !== "string" || id === "") return null;
181
+ const statusRaw = fieldOf(value, "status");
182
+ const card = {
183
+ id,
184
+ project: strField(fieldOf(value, "project"), "default"),
185
+ category: strField(fieldOf(value, "category"), "unknown"),
186
+ signature: strField(fieldOf(value, "signature"), ""),
187
+ statement: strField(fieldOf(value, "statement"), ""),
188
+ status: isRuleStatus(statusRaw) ? statusRaw : "archived",
189
+ createdAt: numField(fieldOf(value, "createdAt"), 0),
190
+ occurrences: numField(fieldOf(value, "occurrences"), 0),
191
+ violation: numField(fieldOf(value, "violation"), 0),
192
+ suppressed: numField(fieldOf(value, "suppressed"), 0),
193
+ samples: numField(fieldOf(value, "samples"), 0),
194
+ undeterminable: fieldOf(value, "undeterminable") === true,
195
+ origin: strField(fieldOf(value, "origin"), "")
196
+ };
197
+ const recurrences = numericOf(fieldOf(value, "recurrences"));
198
+ if (recurrences !== void 0) card.recurrences = recurrences;
199
+ return card;
200
+ }
201
+ /** 未知数组:非数组退空表(不经 any 扩散,逐元素仍是 unknown)。 */
202
+ function unknownArray(value) {
203
+ return Array.isArray(value) ? value : [];
204
+ }
205
+ /** 规则列表投影:整字段不是数组时不给卡片(undefined 与空列表语义不同)。 */
206
+ function rulesField(value) {
207
+ let result;
208
+ if (Array.isArray(value)) {
209
+ const rules = [];
210
+ for (const item of unknownArray(value)) {
211
+ const card = normalizeRuleCardView(item);
212
+ if (card !== null) rules.push(card);
213
+ }
214
+ result = rules;
215
+ }
216
+ return result;
217
+ }
218
+ /** /stats 载荷投影:逐字段守卫,坏形状成员缺省。 */
219
+ function parseStatsValue(parsed) {
220
+ if (!isRecord(parsed)) return {};
221
+ const { ok, csrf, rules, lessonsCount, error } = parsed;
222
+ const ruleViews = rulesField(rules);
223
+ return {
224
+ ...ok === true ? { ok: true } : {},
225
+ ...typeof csrf === "string" ? { csrf } : {},
226
+ ...ruleViews === void 0 ? {} : { rules: ruleViews },
227
+ ...typeof lessonsCount === "number" ? { lessonsCount } : {},
228
+ ...typeof error === "string" ? { error } : {}
229
+ };
230
+ }
231
+ /** POST 回执投影:只需 ok/error。 */
232
+ function parseActionResultValue(parsed) {
233
+ if (!isRecord(parsed)) return {};
234
+ const { ok, error } = parsed;
235
+ return {
236
+ ...ok === true ? { ok: true } : {},
237
+ ...typeof error === "string" ? { error } : {}
238
+ };
239
+ }
240
+ /**
241
+ * 规则卡动作按钮那一行的 class —— armed / candidate|demoted / rejected|archived 三种
242
+ * 状态各渲染一行,同一枚 class 三次落到 `className` 上(样式定义见下面 CARD_CSS 的
243
+ * `.llc-actions` 那条)。
244
+ */
245
+ const ACTIONS_ROW_CLASS = "llc-actions";
246
+ const CARD_CSS = [
247
+ ".llc-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}",
248
+ ".llc-card:hover{border-color:var(--dsw-alias-label-dimmed)}",
249
+ ".llc-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}",
250
+ ".llc-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:transparent;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}",
251
+ ".llc-head{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}",
252
+ ".llc-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}",
253
+ ".llc-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}",
254
+ ".llc-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}",
255
+ ".llc-chevron-open{transform:rotate(180deg)}",
256
+ ".llc-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:8px 0 12px}",
257
+ ".llc-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:12px;padding:9px 0}",
258
+ ".llc-label{font-size:13px;color:var(--dsw-alias-label-primary,inherit)}",
259
+ ".llc-hint{font-size:12px;color:var(--dsw-alias-label-tertiary,#8a8f99);line-height:1.5;margin-top:2px}",
260
+ ".llc-switch{appearance:none;position:relative;width:34px;height:20px;border-radius:10px;background:var(--dsw-alias-fill-primary,#d8dbe2);transition:background .16s;cursor:pointer;border:0;flex:none}",
261
+ ".llc-switch::after{content:\"\";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-bg-layer-1,#fff);transition:left .16s}",
262
+ ".llc-switch-on{background:var(--dsw-alias-brand-primary,#e07856)}",
263
+ ".llc-switch-on::after{left:16px}",
264
+ ".llc-switch:disabled{cursor:not-allowed;opacity:.6}",
265
+ ".llc-section{margin:10px 0 4px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#5c626e)}",
266
+ ".llc-rule{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 10px;margin:6px 0;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-primary,inherit)}",
267
+ ".llc-rule-meta{color:var(--dsw-alias-label-tertiary,#8a8f99);margin-top:4px}",
268
+ ".llc-statement{white-space:pre-wrap;word-break:break-word}",
269
+ ".llc-actions{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}",
270
+ ".llc-btn{appearance:none;font:inherit;font-size:12px;cursor:pointer;border-radius:6px;padding:4px 10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-primary,inherit)}",
271
+ ".llc-btn-primary{background:var(--dsw-alias-brand-primary,#e07856);border-color:var(--dsw-alias-brand-primary,#e07856);color:var(--dsw-alias-label-primary-foreground,#fff)}",
272
+ ".llc-input{width:100%;box-sizing:border-box;font:inherit;font-size:12px;color:var(--dsw-alias-label-primary,inherit);background:var(--dsw-alias-bg-layer-1,transparent);border:1px solid var(--dsw-alias-border-l2,transparent);border-radius:8px;padding:6px 8px;margin-top:6px}",
273
+ ".llc-empty{color:var(--dsw-alias-label-tertiary,#8a8f99);font-size:12px;padding:6px 0}",
274
+ ".llc-err{color:#c4483f;font-size:12px;padding:6px 0;white-space:pre-wrap}",
275
+ ".llc-num{width:96px;box-sizing:border-box;font:inherit;font-size:12px;color:var(--dsw-alias-label-primary,inherit);background:var(--dsw-alias-bg-layer-1,transparent);border:1px solid var(--dsw-alias-border-l2,transparent);border-radius:8px;padding:6px 8px;text-align:center}",
276
+ ".llc-savebar{display:flex;gap:8px;align-items:center;padding:10px 0 2px;border-top:1px dashed var(--dsw-alias-border-l2);margin-top:6px;flex-wrap:wrap}",
277
+ ".llc-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#e07856);outline-offset:1px}",
278
+ ".llc-btn:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}",
279
+ ".llc-btn:disabled{opacity:.5;cursor:not-allowed}",
280
+ ".llc-dirty{font-size:12px;color:var(--dsw-alias-label-tertiary,#8a8f99)}",
281
+ ".llc-saveerr{font-size:12px;color:#c4483f}"
282
+ ].join("\n");
283
+ async function fetchStats() {
284
+ return parseStatsValue(await (await fetch(STATS_PATH, { headers: { accept: "application/json" } })).json());
285
+ }
286
+ async function postAction(csrf, id, action, statement) {
287
+ return parseActionResultValue(await (await fetch(RULE_ACTION_PATH, {
288
+ method: "POST",
289
+ headers: {
290
+ "content-type": "application/json",
291
+ "x-lesson-csrf": csrf
292
+ },
293
+ body: JSON.stringify({
294
+ id,
295
+ action,
296
+ ...statement === void 0 ? {} : { statement }
297
+ })
298
+ })).json());
299
+ }
300
+ function ToggleRow(props) {
301
+ return (0, react.createElement)("div", { className: "llc-row" }, (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "llc-label" }, props.label), (0, react.createElement)("div", { className: "llc-hint" }, props.hint)), (0, react.createElement)("button", {
302
+ type: "button",
303
+ className: `llc-switch${props.checked ? " llc-switch-on" : ""}`,
304
+ role: "switch",
305
+ "aria-checked": props.checked,
306
+ "data-field": props.field,
307
+ disabled: props.disabled === true,
308
+ onClick: props.onToggle
309
+ }));
310
+ }
311
+ const fmtDay = (ts) => typeof ts === "number" && ts > 0 ? new Date(ts).toISOString().slice(0, 10) : "?";
312
+ /**
313
+ * 单条规则卡的元信息行。首段是纯数据(分类 · 项目桶),其余是包自己的文案:
314
+ * 提成模块级函数既避开 createElement 的四层嵌套(unicorn/max-nested-calls),
315
+ * 也让两语走同一条拼装路径——切换语言只是换 translator。
316
+ */
317
+ function ruleMetaLines(t, rule) {
318
+ const lines = [`${rule.category} · ${rule.project}`, t("metaObservations", {
319
+ count: rule.occurrences,
320
+ day: fmtDay(rule.createdAt)
321
+ })];
322
+ if (rule.status === "armed") {
323
+ lines.push(t("metaArmed", {
324
+ violation: rule.violation,
325
+ suppressed: rule.suppressed,
326
+ samples: rule.samples
327
+ }));
328
+ if (rule.undeterminable) lines.push(t("metaUndeterminable"));
329
+ }
330
+ if (rule.status === "rejected" && typeof rule.recurrences === "number" && rule.recurrences > 0) lines.push(t("metaRevive", { count: rule.recurrences }));
331
+ if (rule.origin === "lessons-digest") lines.push(t("metaFromDigest"));
332
+ return lines;
333
+ }
334
+ /** armed 卡的动作行:降级与归档(它已在生效,没有"确认升格"那一步)。 */
335
+ function armedActionsRow(deps) {
336
+ const { busy, labels, act } = deps;
337
+ return (0, react.createElement)("div", { className: ACTIONS_ROW_CLASS }, (0, react.createElement)("button", {
338
+ className: "llc-btn",
339
+ disabled: busy,
340
+ onClick: () => {
341
+ act("demote");
342
+ }
343
+ }, labels.demote), (0, react.createElement)("button", {
344
+ className: "llc-btn",
345
+ disabled: busy,
346
+ onClick: () => {
347
+ act("archive");
348
+ }
349
+ }, labels.archive));
350
+ }
351
+ /** candidate / demoted 卡的动作行:确认升格(未进编辑态时先开草稿框)+ 驳回 + 归档。 */
352
+ function candidateActionsRow(deps) {
353
+ const { busy, editing, draft, labels, act, setEditing } = deps;
354
+ return (0, react.createElement)("div", { className: ACTIONS_ROW_CLASS }, (0, react.createElement)("button", {
355
+ className: "llc-btn llc-btn-primary",
356
+ disabled: busy,
357
+ onClick: () => {
358
+ if (editing) act("arm", draft);
359
+ else setEditing(true);
360
+ }
361
+ }, labels.confirm), (0, react.createElement)("button", {
362
+ className: "llc-btn",
363
+ disabled: busy,
364
+ onClick: () => {
365
+ act("reject");
366
+ }
367
+ }, labels.reject), (0, react.createElement)("button", {
368
+ className: "llc-btn",
369
+ disabled: busy,
370
+ onClick: () => {
371
+ act("archive");
372
+ }
373
+ }, labels.archive));
374
+ }
375
+ /** rejected / archived 卡的动作行:只有一条"转回候选重审"。 */
376
+ function retiredActionsRow(deps) {
377
+ const { busy, labels, act } = deps;
378
+ return (0, react.createElement)("div", { className: ACTIONS_ROW_CLASS }, (0, react.createElement)("button", {
379
+ className: "llc-btn",
380
+ disabled: busy,
381
+ onClick: () => {
382
+ act("revive");
383
+ }
384
+ }, labels.revive));
385
+ }
386
+ /** 升格前的 statement 改写框:只在编辑态出现,草稿初值由 RuleItem 的 state 给。 */
387
+ function statementEditorRow(deps) {
388
+ const { draft, setDraft } = deps;
389
+ return (0, react.createElement)("textarea", {
390
+ className: "llc-input",
391
+ rows: 3,
392
+ value: draft,
393
+ onChange: (event) => {
394
+ setDraft(event.target.value);
395
+ }
396
+ });
397
+ }
398
+ /** 单条规则卡:状态着色 + 动作按钮(arm 带 statement 改写框)。 */
399
+ function RuleItem(props) {
400
+ const { t, rule } = props;
401
+ const [editing, setEditing] = (0, react.useState)(false);
402
+ const [draft, setDraft] = (0, react.useState)(rule.statement);
403
+ const act = async (action, statement) => {
404
+ props.setBusy(true);
405
+ try {
406
+ const result = await postAction(props.csrf, rule.id, action, statement);
407
+ props.setBusy(false);
408
+ props.onDone(result.ok === true, result.ok === true ? void 0 : result.error ?? t("actionFailed"));
409
+ } catch (error) {
410
+ props.setBusy(false);
411
+ props.onDone(false, String(error instanceof Error ? error.message : error));
412
+ }
413
+ };
414
+ const meta = ruleMetaLines(t, rule).join(" · ");
415
+ const labels = {
416
+ demote: t("btnDemote"),
417
+ archive: t("btnArchive"),
418
+ reject: rule.status === "demoted" ? t("btnReject") : t("btnDismiss"),
419
+ revive: t("btnRevive"),
420
+ confirm: t(editing ? "btnArmConfirmEditing" : "btnArmConfirm")
421
+ };
422
+ const actions = {
423
+ busy: props.busy,
424
+ editing,
425
+ draft,
426
+ labels,
427
+ act,
428
+ setEditing,
429
+ setDraft
430
+ };
431
+ return (0, react.createElement)("li", { className: "llc-rule" }, (0, react.createElement)("div", { className: "llc-statement" }, rule.statement), (0, react.createElement)("div", { className: "llc-rule-meta" }, meta), rule.status === "armed" ? armedActionsRow(actions) : null, rule.status === "candidate" || rule.status === "demoted" ? candidateActionsRow(actions) : null, rule.status === "rejected" || rule.status === "archived" ? retiredActionsRow(actions) : null, editing && (rule.status === "candidate" || rule.status === "demoted") ? statementEditorRow(actions) : null);
432
+ }
433
+ /** touched 层与快照的差异字段(值语义比较;undefined 与缺失等价)。 */
434
+ function diffTouched(touched, value) {
435
+ const out = [];
436
+ for (const key of Object.keys(touched)) if (JSON.stringify(touched[key] ?? null) !== JSON.stringify(value[key] ?? null)) out.push(key);
437
+ return out;
438
+ }
439
+ /** 保存条的状态文本(三态各一条;提成模块级函数以免嵌进 createElement)。 */
440
+ function saveBarStatus(t, state) {
441
+ if (!state.writable) return t("statusReadOnly");
442
+ return state.dirty ? t("statusDirty") : t("statusClean");
443
+ }
444
+ function SaveBar(props) {
445
+ const { t } = props;
446
+ const dis = !props.writable || props.busy;
447
+ const statusText = saveBarStatus(t, props);
448
+ const saveLabel = t("save");
449
+ const savingLabel = t("saving");
450
+ const revertLabel = t("revert");
451
+ const statusNode = props.error === null ? (0, react.createElement)("span", { className: "llc-dirty" }, statusText) : (0, react.createElement)("span", { className: "llc-saveerr" }, props.error);
452
+ return (0, react.createElement)("div", { className: "llc-savebar" }, (0, react.createElement)("button", {
453
+ type: "button",
454
+ className: "llc-btn llc-btn-primary",
455
+ "data-field": "save",
456
+ disabled: dis || !props.dirty,
457
+ onClick: props.onSave
458
+ }, props.busy ? savingLabel : saveLabel), (0, react.createElement)("button", {
459
+ type: "button",
460
+ className: "llc-btn",
461
+ "data-field": "discard",
462
+ disabled: dis || !props.dirty,
463
+ onClick: props.onDiscard
464
+ }, revertLabel), statusNode);
465
+ }
466
+ /** 数字输入行:settings 数字字段 ↔ UI 字符串(ctx-observe 同款契约)。 */
467
+ function NumberInputRow(props) {
468
+ const num = typeof props.value === "number" && Number.isFinite(props.value) ? String(props.value) : "";
469
+ const [text, setText] = (0, react.useState)(num);
470
+ (0, react.useEffect)(() => {
471
+ setText(num);
472
+ }, [num]);
473
+ const onEntry = (event) => {
474
+ const next = event.target.value;
475
+ setText(next);
476
+ if (next === "") {
477
+ props.onEmpty(props.field);
478
+ return;
479
+ }
480
+ const parsed = Number(next);
481
+ if (!Number.isFinite(parsed)) return;
482
+ const lower = props.min ?? -Infinity;
483
+ const upper = props.max ?? Infinity;
484
+ if (parsed >= lower && parsed <= upper) props.onChange(props.field, parsed);
485
+ };
486
+ return (0, react.createElement)("div", { className: "llc-row" }, (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "llc-label" }, props.label), (0, react.createElement)("div", { className: "llc-hint" }, props.hint), (0, react.createElement)("input", {
487
+ type: "number",
488
+ className: "llc-num",
489
+ "data-field": props.field,
490
+ value: text,
491
+ disabled: props.disabled === true,
492
+ onChange: onEntry
493
+ })));
494
+ }
495
+ /**
496
+ * 配置行区块(开关 + 阈值输入),从 LlcCard 抽出控制函数体(max-lines)。
497
+ * 文案取自本包字典(`<字段>Label` / `<字段>Hint`):加一个设置字段就得在两语里各补
498
+ * 一条,漏一条在 tsc 阶段即红——键名写死成字面量而不是拼字符串,正是为了这一点。
499
+ * 设置字段名同样走 `field: "<字面量>"` props(ToggleRowProps.field 注释里那条
500
+ * 「schema 门禁/测试按字段定位控件」的稳定锚点):test/schema-coverage.ts 的卡片覆盖
501
+ * 门禁就是按这个写法认绑定的,把键名塞进变量再传进去等于让门禁看不见这一行。
502
+ */
503
+ function buildConfigRows(deps) {
504
+ const { t, writable, eff, setField, clearField } = deps;
505
+ const toggle = (row) => (0, react.createElement)(ToggleRow, {
506
+ label: row.label,
507
+ hint: row.hint,
508
+ field: row.field,
509
+ checked: eff(row.field) !== false,
510
+ disabled: !writable,
511
+ onToggle: () => {
512
+ setField(row.field, eff(row.field) === false);
513
+ }
514
+ });
515
+ const num = (row) => (0, react.createElement)(NumberInputRow, {
516
+ label: row.label,
517
+ hint: row.hint,
518
+ field: row.field,
519
+ value: eff(row.field),
520
+ disabled: !writable,
521
+ onChange: setField,
522
+ onEmpty: clearField,
523
+ min: row.min,
524
+ max: row.max
525
+ });
526
+ return [
527
+ toggle({
528
+ field: "enabled",
529
+ label: t("enabledLabel"),
530
+ hint: t("enabledHint")
531
+ }),
532
+ toggle({
533
+ field: "reportEnabled",
534
+ label: t("reportEnabledLabel"),
535
+ hint: t("reportEnabledHint")
536
+ }),
537
+ toggle({
538
+ field: "injectEnabled",
539
+ label: t("injectEnabledLabel"),
540
+ hint: t("injectEnabledHint")
541
+ }),
542
+ toggle({
543
+ field: "sectionEnabled",
544
+ label: t("sectionEnabledLabel"),
545
+ hint: t("sectionEnabledHint")
546
+ }),
547
+ num({
548
+ field: "promoteThreshold",
549
+ label: t("promoteThresholdLabel"),
550
+ hint: t("promoteThresholdHint"),
551
+ min: 1,
552
+ max: 20
553
+ }),
554
+ num({
555
+ field: "demoteThreshold",
556
+ label: t("demoteThresholdLabel"),
557
+ hint: t("demoteThresholdHint"),
558
+ min: 1,
559
+ max: 50
560
+ }),
561
+ num({
562
+ field: "demoteMinSamples",
563
+ label: t("demoteMinSamplesLabel"),
564
+ hint: t("demoteMinSamplesHint"),
565
+ min: 1,
566
+ max: 100
567
+ }),
568
+ num({
569
+ field: "demoteRatio",
570
+ label: t("demoteRatioLabel"),
571
+ hint: t("demoteRatioHint"),
572
+ min: .05,
573
+ max: 1
574
+ }),
575
+ num({
576
+ field: "reviveThreshold",
577
+ label: t("reviveThresholdLabel"),
578
+ hint: t("reviveThresholdHint"),
579
+ min: 1,
580
+ max: 20
581
+ }),
582
+ num({
583
+ field: "decayDays",
584
+ label: t("decayDaysLabel"),
585
+ hint: t("decayDaysHint"),
586
+ min: 1,
587
+ max: 365
588
+ }),
589
+ num({
590
+ field: "maxLessonsBytes",
591
+ label: t("maxLessonsBytesLabel"),
592
+ hint: t("maxLessonsBytesHint"),
593
+ min: 0,
594
+ max: 1073741824
595
+ })
596
+ ];
597
+ }
598
+ /** 分节标题:标题本身由调用点从字典取好,这里只拼计数(中/英括号形态在字典里)。 */
599
+ function sectionHeadingText(t, title, count) {
600
+ return t("sectionHeading", {
601
+ title,
602
+ count
603
+ });
604
+ }
605
+ /** 「已拒绝/归档」按钮:折叠态显示分组标题 + 条数,展开态显示"收起"。 */
606
+ function retiredToggleText(t, showAll, count) {
607
+ return showAll ? t("showRetired") : sectionHeadingText(t, t("sectionRetired"), count);
608
+ }
609
+ /** 卡片副标题(含实时计数;lessonsCount 未到达时显式占位,不虚报 0)。 */
610
+ function cardDescriptionText(t, counts) {
611
+ return t("cardDescription", {
612
+ candidates: counts.candidates,
613
+ armed: counts.armed,
614
+ lessons: counts.lessons ?? "…"
615
+ });
616
+ }
617
+ /** 按 status 分四组(顺序与拆出前一致:候选 → armed → 降级 → 已拒绝/归档)。 */
618
+ function groupRules(rules) {
619
+ return {
620
+ candidates: rules.filter((row) => row.status === "candidate"),
621
+ armed: rules.filter((row) => row.status === "armed"),
622
+ demoted: rules.filter((row) => row.status === "demoted"),
623
+ retired: rules.filter((row) => row.status === "rejected" || row.status === "archived")
624
+ };
625
+ }
626
+ /** 折叠箭头:`d` 属性走计算键(PATH_KEY)以避开短名检查。 */
627
+ function renderChevron(open) {
628
+ return (0, react.createElement)("svg", {
629
+ width: 14,
630
+ height: 14,
631
+ viewBox: "0 0 14 14",
632
+ "aria-hidden": true,
633
+ className: `llc-chevron${open ? " llc-chevron-open" : ""}`
634
+ }, (0, react.createElement)("path", {
635
+ [PATH_KEY]: "M3 5l4 4 4-4",
636
+ fill: "none",
637
+ stroke: "currentColor",
638
+ strokeWidth: 1.5,
639
+ strokeLinecap: "round",
640
+ strokeLinejoin: "round"
641
+ }));
642
+ }
643
+ /** 一个评审分节:标题带计数,空组显式占位,非空逐条出 RuleItem。 */
644
+ function renderRuleSection(view, title, list) {
645
+ const { t } = view;
646
+ const heading = (0, react.createElement)("div", { className: "llc-section" }, sectionHeadingText(t, title, list.length));
647
+ const emptyLabel = t("emptyGroup");
648
+ const body = list.length === 0 ? (0, react.createElement)("div", { className: "llc-empty" }, emptyLabel) : (0, react.createElement)("ul", { style: {
649
+ listStyle: "none",
650
+ margin: "0",
651
+ padding: "0"
652
+ } }, list.map((row) => (0, react.createElement)(RuleItem, {
653
+ key: row.id,
654
+ t,
655
+ rule: row,
656
+ csrf: view.csrf,
657
+ busy: view.ruleBusy,
658
+ onDone: view.onDone,
659
+ setBusy: view.setRuleBusy
660
+ })));
661
+ return (0, react.createElement)("div", null, heading, body);
662
+ }
663
+ /** 卡片头:标题 + 实时计数副标题 + 折叠箭头。 */
664
+ function renderCardHeader(view) {
665
+ const { t, open } = view;
666
+ const headTitle = t("cardTitle");
667
+ const headDesc = cardDescriptionText(t, {
668
+ candidates: view.groups.candidates.length,
669
+ armed: view.groups.armed.length,
670
+ lessons: view.lessonsCount
671
+ });
672
+ return (0, react.createElement)("button", {
673
+ type: "button",
674
+ className: "llc-header",
675
+ "aria-expanded": open,
676
+ onClick: view.onToggle
677
+ }, (0, react.createElement)("div", { className: "llc-head" }, (0, react.createElement)("div", { className: "llc-name" }, headTitle), (0, react.createElement)("div", { className: "llc-desc" }, headDesc)), renderChevron(open));
678
+ }
679
+ /** 「已拒绝/归档」区块:折叠态只有一条按钮,展开态才出分节。 */
680
+ function renderRetiredBlock(view) {
681
+ const { t, showAll } = view;
682
+ const { retired } = view.groups;
683
+ const retiredTitle = t("sectionRetired");
684
+ const retiredLabel = retiredToggleText(t, showAll, retired.length);
685
+ return retired.length > 0 ? (0, react.createElement)("div", null, (0, react.createElement)("button", {
686
+ className: "llc-btn",
687
+ onClick: () => {
688
+ view.setShowAll(!showAll);
689
+ }
690
+ }, retiredLabel), showAll ? renderRuleSection(view, retiredTitle, retired) : null) : null;
691
+ }
692
+ /** 展开态的卡片体:错误条 + 配置行 + 保存条 + 三个评审分节 + 「已拒绝/归档」+ 蒸馏提示。 */
693
+ function renderCardBody(view) {
694
+ const { t } = view;
695
+ const retiredToggle = renderRetiredBlock(view);
696
+ const hintLabel = t("digestHint");
697
+ const candidatesTitle = t("sectionCandidates");
698
+ const demotedTitle = t("sectionDemoted");
699
+ const armedTitle = t("sectionArmed");
700
+ return (0, react.createElement)("ul", { className: "llc-body" }, view.statsError === null ? null : (0, react.createElement)("div", { className: "llc-err" }, view.statsError), ...buildConfigRows({
701
+ t,
702
+ writable: view.writable,
703
+ eff: view.eff,
704
+ setField: view.setField,
705
+ clearField: view.clearField
706
+ }), (0, react.createElement)(SaveBar, {
707
+ t,
708
+ dirty: view.dirty,
709
+ writable: view.writable,
710
+ busy: view.busy,
711
+ error: view.saveError,
712
+ onSave: view.onSave,
713
+ onDiscard: view.onDiscard
714
+ }), renderRuleSection(view, candidatesTitle, view.groups.candidates), renderRuleSection(view, demotedTitle, view.groups.demoted), renderRuleSection(view, armedTitle, view.groups.armed), retiredToggle, (0, react.createElement)("div", {
715
+ className: "llc-hint",
716
+ style: { marginTop: "8px" }
717
+ }, hintLabel));
718
+ }
719
+ /** ok 才换 stats;否则只写错误条,旧数据留在屏上(轮询失败不该把卡片清空)。 */
720
+ function settleStats(result, sink) {
721
+ const { t, setStats, setStatsError } = sink;
722
+ if (result.ok === true) {
723
+ setStats(result);
724
+ setStatsError(null);
725
+ } else setStatsError(result.error ?? t("statsUnavailable"));
726
+ }
727
+ /** 拉取统计并落进 sink。模块层函数:effect 与 save/onDone 共用,组件里不为此引入任何 React hook。 */
728
+ async function reloadStats(sink) {
729
+ try {
730
+ settleStats(await fetchStats(), sink);
731
+ } catch (error) {
732
+ sink.setStatsError(String(error instanceof Error ? error.message : error));
733
+ }
734
+ }
735
+ /** 差异字段逐个写回共享表单:undefined 走 unset(= 恢复默认),其余走 set。 */
736
+ async function writeTouchedFields(props, touched, keys) {
737
+ return Promise.all(keys.map((key) => {
738
+ const fieldValue = touched[key];
739
+ return fieldValue === void 0 ? props.unset(key) : props.set(key, fieldValue);
740
+ }));
741
+ }
742
+ function LlcCard(props) {
743
+ const { t } = props;
744
+ const [open, setOpen] = (0, react.useState)(props.initialOpen === true);
745
+ const snap = props.useCard((snapshot) => snapshot);
746
+ const { value } = snap;
747
+ const writable = snap.status === "ready" && snap.writable;
748
+ const [touched, setTouched] = (0, react.useState)({});
749
+ const [busy, setBusy] = (0, react.useState)(false);
750
+ const [saveError, setSaveError] = (0, react.useState)(null);
751
+ const eff = (field) => field in touched ? touched[field] : value[field];
752
+ const dirty = diffTouched(touched, value).length > 0;
753
+ const setField = (field, nextValue) => {
754
+ setTouched((entry) => ({
755
+ ...entry,
756
+ [field]: nextValue
757
+ }));
758
+ };
759
+ const clearField = (field) => {
760
+ setTouched((entry) => ({
761
+ ...entry,
762
+ [field]: void 0
763
+ }));
764
+ };
765
+ const [stats, setStats] = (0, react.useState)(null);
766
+ const [statsError, setStatsError] = (0, react.useState)(null);
767
+ const [ruleBusy, setRuleBusy] = (0, react.useState)(false);
768
+ const [showAll, setShowAll] = (0, react.useState)(false);
769
+ (0, react.useEffect)(() => {
770
+ if (!open) return;
771
+ reloadStats({
772
+ t,
773
+ setStats,
774
+ setStatsError
775
+ });
776
+ }, [
777
+ open,
778
+ setStats,
779
+ setStatsError,
780
+ t
781
+ ]);
782
+ const save = async () => {
783
+ const keys = diffTouched(touched, value);
784
+ if (keys.length === 0) return;
785
+ setBusy(true);
786
+ setSaveError(null);
787
+ try {
788
+ await writeTouchedFields(props, touched, keys);
789
+ setBusy(false);
790
+ setTouched({});
791
+ reloadStats({
792
+ t,
793
+ setStats,
794
+ setStatsError
795
+ });
796
+ } catch (error) {
797
+ setBusy(false);
798
+ setSaveError(`${t("saveFailed")}${String(error instanceof Error ? error.message : error)}`);
799
+ console.error("[lesson-loop] save failed:", error);
800
+ }
801
+ };
802
+ const discard = () => {
803
+ setTouched({});
804
+ setSaveError(null);
805
+ };
806
+ const onDone = (ok, message) => {
807
+ if (ok) reloadStats({
808
+ t,
809
+ setStats,
810
+ setStatsError
811
+ });
812
+ else setStatsError(message ?? t("actionFailed"));
813
+ };
814
+ const toggleOpen = () => {
815
+ setOpen(!open);
816
+ };
817
+ const view = {
818
+ t,
819
+ open,
820
+ writable,
821
+ statsError,
822
+ saveError,
823
+ busy,
824
+ dirty,
825
+ csrf: stats?.csrf ?? "",
826
+ ruleBusy,
827
+ showAll,
828
+ groups: groupRules(stats?.rules ?? []),
829
+ lessonsCount: stats?.lessonsCount,
830
+ eff,
831
+ setField,
832
+ clearField,
833
+ onDone,
834
+ setRuleBusy,
835
+ setShowAll,
836
+ onSave: () => {
837
+ save();
838
+ },
839
+ onDiscard: discard,
840
+ onToggle: toggleOpen
841
+ };
842
+ return (0, react.createElement)("li", { className: `llc-card${open ? " llc-card-open" : ""}` }, renderCardHeader(view), open ? renderCardBody(view) : null);
843
+ }
844
+ function cardStore(scope) {
845
+ let cachedSnap = null;
846
+ let cachedView = null;
847
+ const EMPTY_SNAPSHOT = {
848
+ status: "loading",
849
+ writable: false,
850
+ value: {}
851
+ };
852
+ return {
853
+ getSnapshot() {
854
+ const snap = scope.getSnapshot();
855
+ if (snap !== cachedSnap) {
856
+ cachedSnap = snap;
857
+ cachedView = {
858
+ status: snap.status,
859
+ writable: snap.writable,
860
+ value: snap.value ?? {}
861
+ };
862
+ }
863
+ return cachedView ?? EMPTY_SNAPSHOT;
864
+ },
865
+ subscribe(listener) {
866
+ return scope.subscribe(listener);
867
+ }
868
+ };
869
+ }
870
+ const inject = [
871
+ "slots",
872
+ "configForms",
873
+ "locale"
874
+ ];
875
+ function apply(ctx) {
876
+ ctx.effect(() => {
877
+ const tag = document.createElement("style");
878
+ tag.id = "lesson-loop-card-css";
879
+ tag.textContent = CARD_CSS;
880
+ document.head.append(tag);
881
+ return () => {
882
+ tag.remove();
883
+ };
884
+ }, "lesson-loop-card: styles");
885
+ const scope = ctx.configForms.get(NS);
886
+ const store = cardStore(scope);
887
+ ctx.effect(() => ctx.locale.register(NS, UI_MESSAGES), "lesson-loop-card: locale dictionaries");
888
+ const t = ctx.locale.bind(NS);
889
+ ctx.slots.inject("plugins.bundle.config", () => {
890
+ return ctx.slots.register({
891
+ name: "plugins.bundle.config",
892
+ key: BUNDLE_PKG,
893
+ inject: () => ({
894
+ t,
895
+ hooks: { card: store },
896
+ set: (field, value) => scope.set(field, value),
897
+ unset: (field) => scope.unset(field)
898
+ })
899
+ }, LlcCard);
900
+ });
901
+ }
902
+ //#endregion
903
+ exports.LlcCard = LlcCard;
904
+ exports.NumberInputRow = NumberInputRow;
905
+ exports.RuleItem = RuleItem;
906
+ exports.SaveBar = SaveBar;
907
+ exports.ToggleRow = ToggleRow;
908
+ exports.apply = apply;
909
+ exports.buildConfigRows = buildConfigRows;
910
+ exports.cardDescriptionText = cardDescriptionText;
911
+ exports.cardStore = cardStore;
912
+ exports.diffTouched = diffTouched;
913
+ exports.inject = inject;
914
+ exports.retiredToggleText = retiredToggleText;
915
+ exports.ruleMetaLines = ruleMetaLines;
916
+ exports.saveBarStatus = saveBarStatus;
917
+ exports.sectionHeadingText = sectionHeadingText;
918
+ return module.exports;
919
+ },
920
+ });