dsh-router-laya 2.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,436 @@
1
+ /**
2
+ * Tier chip — browser half of `dsh-router-laya` (spec: docs/frontend-auto-tier-spec.md).
3
+ *
4
+ * Packaged as the client module system's lazy-CJS bundle: executing this file only REGISTERS a factory,
5
+ * the module body runs at first materialization, and every side effect (including the stylesheet) lives
6
+ * inside the factory closure. `require("react")` resolves against the shell's frozen platform seed.
7
+ *
8
+ * All colours, sizes, copy and timings are the spec's, copied verbatim; the two places where the spec's
9
+ * literal value cannot be used as written are marked ADAPTED below with the reason.
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: "dsh-router-laya",
13
+ factory: (require) => {
14
+ var module = { exports: {} }
15
+ var exports = module.exports
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" })
17
+
18
+ const React = require("react")
19
+
20
+ // ── spec §2/§3/§4/§5 constants ────────────────────────────────────────────────────────────────
21
+ const STATE_URL = "http://127.0.0.1:8765/state"
22
+ // Same-origin, unlike /state: a static client bundle has no `host.call` and no `ctx.remote.settings`,
23
+ // so the host half serves this route to read and switch the mode.
24
+ const MODE_URL = "/router-laya/mode"
25
+ const POLL_MS = 3000
26
+ const POLL_FAILURES_BEFORE_OFFLINE = 2
27
+ const TOAST_MS = 2000
28
+ const TOAST_GAP_PX = 8
29
+ const HOVER_SHOW_MS = 300
30
+ const HOVER_HIDE_MS = 150
31
+ const POPOVER_LIMIT = 20
32
+ const TASK_CHARS = 30
33
+
34
+ const TIER_ORDER = { low: 0, high: 1, max: 2 }
35
+ const TIER_COLOR = { low: "#3fb950", high: "#d29922", max: "#f85149" }
36
+ const TIER_TEXT = { low: "AUTO · 低", high: "AUTO · 高", max: "AUTO · MAX" }
37
+ const TIER_SHORT = { low: "低", high: "高", max: "MAX" }
38
+ const OFFLINE_COLOR = "#8b949e"
39
+ const OFFLINE_TEXT = "AUTO · 离线"
40
+ const OFFLINE_TIP = "判定服务未启动 · 已落 low(powershell -File routing/start_router.ps1)"
41
+ // The spec's five states do not cover "the service is healthy but nothing has been judged yet", which
42
+ // is the real state on every fresh load: /state's log is in-memory and empty until this session's
43
+ // first turn is judged. Showing the offline copy there claims a failure that is not happening.
44
+ const PENDING_TEXT = "AUTO · 待判定"
45
+ const PENDING_TIP = "自动 · 等待本轮判定"
46
+ const MANUAL_TEXT = "手动"
47
+
48
+ const TOAST_TEXT = {
49
+ escalate_regenerate: "⤴ 升档 · 检测到重试",
50
+ intent_force: "⚡ 用户指定",
51
+ intent_inherit: "↻ 保持上轮",
52
+ intent_exclude: "按你的要求排除",
53
+ }
54
+ const REASON_TEXT = {
55
+ laya: "内容判断",
56
+ escalate_regenerate: "检测到重试·升档",
57
+ intent_force: "用户指定",
58
+ intent_inherit: "保持上轮",
59
+ intent_exclude: "用户排除",
60
+ }
61
+
62
+ const CSS = `
63
+ @keyframes routerLayaTierPop{0%{transform:scale(1)}40%{transform:scale(1.08)}100%{transform:scale(1)}}
64
+ @keyframes routerLayaBreathe{0%,100%{opacity:.85}50%{opacity:1}}
65
+ @keyframes routerLayaToastIn{0%{opacity:0;transform:translateY(4px)}100%{opacity:1;transform:translateY(0)}}
66
+ @keyframes routerLayaToastOut{0%{opacity:1}100%{opacity:0}}
67
+ .routerLayaChip{display:inline-flex;align-items:center;justify-content:center;gap:6px;cursor:pointer;
68
+ user-select:none;box-sizing:border-box;background:transparent;
69
+ transition:background-color .16s ease-out,border-color .16s ease-out,color .3s ease}
70
+ .routerLayaChip:hover{background:var(--dsw-alias-interactive-bg-hover)}
71
+ .routerLayaDot{width:6px;height:6px;border-radius:999px;flex:none;transition:background-color .3s ease}
72
+ .routerLayaPop{animation:routerLayaTierPop 300ms ease-out}
73
+ .routerLayaMax{animation:routerLayaBreathe 2.4s ease-in-out infinite}
74
+ .routerLayaToast{position:absolute;bottom:calc(100% + ${TOAST_GAP_PX}px);right:0;white-space:nowrap;
75
+ padding:4px 9px;border-radius:7px;font-size:11px;line-height:16px;pointer-events:none;
76
+ background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-3,#fff));
77
+ border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);
78
+ box-shadow:0 6px 18px rgba(0,0,0,.14);animation:routerLayaToastIn .18s ease-out}
79
+ .routerLayaToastOut{animation:routerLayaToastOut .3s ease-in forwards}
80
+ .routerLayaPopover{position:absolute;bottom:calc(100% + 8px);right:0;width:330px;max-height:320px;
81
+ overflow-y:auto;padding:6px;border-radius:9px;text-align:left;z-index:30;
82
+ background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-3,#fff));
83
+ border:1px solid var(--dsw-alias-border-l1);box-shadow:0 10px 28px rgba(0,0,0,.18)}
84
+ .routerLayaRow{display:flex;align-items:center;gap:7px;padding:3px 6px;border-radius:5px;font-size:11px;line-height:16px}
85
+ .routerLayaRow:hover{background:var(--dsw-alias-interactive-bg-hover)}
86
+ .routerLayaTime{opacity:.55;font-variant-numeric:tabular-nums;flex:none}
87
+ .routerLayaTask{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}
88
+ .routerLayaWhy{opacity:.6;flex:none}
89
+ `
90
+
91
+ // ── shared poll state: server owns the truth (spec §6 constraint 2) ──────────────────────────
92
+ const store = { data: null, offline: false, failures: 0, mode: null, listeners: new Set() }
93
+ let poller = null
94
+ let styleEl = null
95
+
96
+ function notify() {
97
+ for (const fn of Array.from(store.listeners)) {
98
+ try { fn() } catch (error) { /* one bad subscriber must not stop the rest */ }
99
+ }
100
+ }
101
+
102
+ function sessionsList() {
103
+ const data = store.data
104
+ if (data === null || typeof data !== "object" || data === null) return null
105
+ const sessions = data.sessions
106
+ return sessions !== null && typeof sessions === "object" ? sessions : null
107
+ }
108
+
109
+ /** Session match (spec §2): by id when known, otherwise the newest ts across all sessions. */
110
+ function rowsFor(sessionId) {
111
+ const sessions = sessionsList()
112
+ if (sessions === null) return []
113
+ if (sessionId !== undefined && sessionId !== null && Array.isArray(sessions[sessionId])) {
114
+ return sessions[sessionId]
115
+ }
116
+ let best = null
117
+ let bestTs = -Infinity
118
+ for (const key of Object.keys(sessions)) {
119
+ const rows = sessions[key]
120
+ if (!Array.isArray(rows) || rows.length === 0) continue
121
+ const tail = rows[rows.length - 1]
122
+ const ts = tail !== undefined && typeof tail.ts === "number" ? tail.ts : -Infinity
123
+ if (ts > bestTs) { bestTs = ts; best = rows }
124
+ }
125
+ return best === null ? [] : best
126
+ }
127
+
128
+ function pollOnce() {
129
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return
130
+ fetch(STATE_URL, { cache: "no-store" })
131
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error("http " + String(res.status)))))
132
+ .then((json) => {
133
+ store.data = json
134
+ store.failures = 0
135
+ if (store.offline) store.offline = false
136
+ notify()
137
+ })
138
+ .catch(() => {
139
+ store.failures += 1
140
+ if (store.failures >= POLL_FAILURES_BEFORE_OFFLINE && !store.offline) {
141
+ store.offline = true
142
+ notify()
143
+ }
144
+ })
145
+ fetch(MODE_URL, { cache: "no-store" })
146
+ .then((res) => (res.ok ? res.json() : null))
147
+ .then((json) => {
148
+ if (json !== null && (json.mode === "auto" || json.mode === "manual") && json.mode !== store.mode) {
149
+ store.mode = json.mode
150
+ notify()
151
+ }
152
+ })
153
+ .catch(() => { /* the chip keeps its last known mode */ })
154
+ }
155
+
156
+ /** Switch mode; the response is authoritative, so a refused write cannot desync the chip. */
157
+ function writeMode(next) {
158
+ return fetch(MODE_URL, {
159
+ method: "POST",
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify({ mode: next }),
162
+ })
163
+ .then((res) => (res.ok ? res.json() : null))
164
+ .then((json) => {
165
+ if (json !== null && (json.mode === "auto" || json.mode === "manual")) {
166
+ store.mode = json.mode
167
+ notify()
168
+ }
169
+ })
170
+ .catch(() => { /* leave the displayed mode as it was */ })
171
+ }
172
+
173
+ function startPolling() {
174
+ if (poller !== null) return
175
+ pollOnce()
176
+ poller = setInterval(pollOnce, POLL_MS)
177
+ }
178
+
179
+ // ── spec §4 toast copy ───────────────────────────────────────────────────────────────────────
180
+ function toastText(triggeredBy, nextTier, prevTier) {
181
+ if (Object.prototype.hasOwnProperty.call(TOAST_TEXT, triggeredBy)) return TOAST_TEXT[triggeredBy]
182
+ if (triggeredBy === "laya") {
183
+ const next = TIER_ORDER[nextTier]
184
+ const prev = TIER_ORDER[prevTier]
185
+ if (next === undefined || prev === undefined) return null
186
+ if (next > prev) return "⤴ 升档 · 内容判断"
187
+ if (next < prev) return "↩ 降回 · 新任务"
188
+ return null
189
+ }
190
+ return null
191
+ }
192
+
193
+ function reasonText(triggeredBy) {
194
+ return Object.prototype.hasOwnProperty.call(REASON_TEXT, triggeredBy)
195
+ ? REASON_TEXT[triggeredBy]
196
+ : String(triggeredBy === undefined || triggeredBy === null ? "" : triggeredBy)
197
+ }
198
+
199
+ function clockOf(ts) {
200
+ if (typeof ts !== "number" || !isFinite(ts)) return "--:--"
201
+ const d = new Date(ts * 1000)
202
+ const hh = String(d.getHours()).padStart(2, "0")
203
+ const mm = String(d.getMinutes()).padStart(2, "0")
204
+ return hh + ":" + mm
205
+ }
206
+
207
+ function clip(text) {
208
+ const s = typeof text === "string" ? text : ""
209
+ return s.length > TASK_CHARS ? s.slice(0, TASK_CHARS) + "…" : s
210
+ }
211
+
212
+ // ── spec §3 geometry: sit beside the model selector, copying its own shape ───────────────────
213
+ //
214
+ // ADAPTED (1 of 2): the spec gives a literal `translateX(-8px)`. In this composer the chip's cell
215
+ // renders BEFORE the model selector, so a negative offset moves it further away, not closer, and the
216
+ // acceptance item it serves is "间距 8px" (spec §7.1). A fixed number cannot satisfy that because the
217
+ // row's gap is not fixed, so the offset is measured: the chip is nudged by exactly
218
+ // (current gap - 8px). The spec's intent -- an 8px gap -- is what is implemented.
219
+ function measureGap(chipEl) {
220
+ try {
221
+ const effort = document.querySelector('[class*="triggerEffort"]')
222
+ if (effort === null) return null
223
+ const trigger = effort.closest("button") || effort.parentElement
224
+ if (trigger === null) return null
225
+ const chip = chipEl.getBoundingClientRect()
226
+ const target = trigger.getBoundingClientRect()
227
+ if (chip.width === 0 || target.width === 0) return null
228
+ return { delta: target.left - chip.right - 8, trigger: trigger }
229
+ } catch (error) {
230
+ return null
231
+ }
232
+ }
233
+
234
+ /** ADAPTED (2 of 2): "读它 computed style 复制" -- radius/height/font copied from the neighbour. */
235
+ function copyNeighbourShape(trigger) {
236
+ try {
237
+ const cs = getComputedStyle(trigger)
238
+ const shape = {}
239
+ if (cs.borderRadius && cs.borderRadius !== "0px") shape.borderRadius = cs.borderRadius
240
+ const h = parseFloat(cs.height)
241
+ if (isFinite(h) && h >= 16 && h <= 40) shape.height = Math.round(h) + "px"
242
+ const fs = parseFloat(cs.fontSize)
243
+ if (isFinite(fs) && fs >= 10 && fs <= 18) shape.fontSize = Math.round(fs) + "px"
244
+ return shape
245
+ } catch (error) {
246
+ return {}
247
+ }
248
+ }
249
+
250
+ function useStore() {
251
+ const tick = React.useState(0)
252
+ React.useEffect(() => {
253
+ startPolling()
254
+ const fn = () => tick[1]((n) => n + 1)
255
+ store.listeners.add(fn)
256
+ return () => { store.listeners.delete(fn) }
257
+ }, [])
258
+ return store
259
+ }
260
+
261
+ function Chip(props) {
262
+ const state = useStore()
263
+ const chipRef = React.useState(null)
264
+ const offset = React.useState(0)
265
+ const shape = React.useState(null)
266
+ const pop = React.useState(0)
267
+ const toast = React.useState(null)
268
+ const toastOut = React.useState(false)
269
+ const popover = React.useState(false)
270
+ const previous = React.useState(null)
271
+
272
+ const sessionId = props === undefined || props === null ? undefined : props.sessionId
273
+ const rows = rowsFor(sessionId)
274
+ const latest = rows.length === 0 ? null : rows[rows.length - 1]
275
+ const offline = state.offline
276
+ const tier = latest !== null && typeof latest.tier === "string" ? latest.tier : null
277
+
278
+ const manual = state.mode === "manual"
279
+
280
+ // spec §4.3: first render is silent; only an adjacent-poll change on the same session animates.
281
+ React.useEffect(() => {
282
+ if (latest === null) return
283
+ const prev = previous[0]
284
+ previous[1]({ tier: latest.tier, triggeredBy: latest.triggered_by, sessionId: sessionId })
285
+ if (prev === null) return
286
+ if (prev.sessionId !== sessionId) return
287
+ if (prev.tier === latest.tier) return
288
+ pop[1]((n) => n + 1)
289
+ const text = toastText(latest.triggered_by, latest.tier, prev.tier)
290
+ if (text !== null) {
291
+ toast[1](text)
292
+ toastOut[1](false)
293
+ }
294
+ }, [latest === null ? null : latest.ts, sessionId])
295
+
296
+ React.useEffect(() => {
297
+ if (toast[0] === null) return undefined
298
+ const hide = setTimeout(() => toastOut[1](true), TOAST_MS)
299
+ const clear = setTimeout(() => { toast[1](null); toastOut[1](false) }, TOAST_MS + 320)
300
+ return () => { clearTimeout(hide); clearTimeout(clear) }
301
+ }, [toast[0]])
302
+
303
+ // Geometry: measure once after layout settles, then keep the neighbour's shape.
304
+ React.useEffect(() => {
305
+ const node = chipRef[0]
306
+ if (node === null) return undefined
307
+ let raf = 0
308
+ const apply = () => {
309
+ const found = measureGap(node)
310
+ if (found === null) return
311
+ offset[1](found.delta)
312
+ const copied = copyNeighbourShape(found.trigger)
313
+ if (Object.keys(copied).length > 0) shape[1](copied)
314
+ }
315
+ raf = setTimeout(apply, 300)
316
+ window.addEventListener("resize", apply)
317
+ return () => { clearTimeout(raf); window.removeEventListener("resize", apply) }
318
+ }, [props === undefined ? null : props.sessionId])
319
+
320
+ // Hover timing per spec §5: 300ms to show, 150ms to hide.
321
+ const hoverTimer = React.useState(null)
322
+ const onEnter = () => {
323
+ if (hoverTimer[0] !== null) clearTimeout(hoverTimer[0])
324
+ hoverTimer[1](setTimeout(() => popover[1](true), HOVER_SHOW_MS))
325
+ }
326
+ const onLeave = () => {
327
+ if (hoverTimer[0] !== null) clearTimeout(hoverTimer[0])
328
+ hoverTimer[1](setTimeout(() => popover[1](false), HOVER_HIDE_MS))
329
+ }
330
+ React.useEffect(() => () => { if (hoverTimer[0] !== null) clearTimeout(hoverTimer[0]) }, [])
331
+
332
+ // spec §3: manual shows 手动 with the pinned tier when one is known; colour returns to the default
333
+ // foreground (no tier colour is painted in manual).
334
+ const manualText = tier === null ? MANUAL_TEXT : MANUAL_TEXT + " · " + TIER_SHORT[tier]
335
+ const nothingYet = !offline && tier === null
336
+ const color = offline ? OFFLINE_COLOR : (manual || nothingYet ? null : TIER_COLOR[tier])
337
+ const text = offline
338
+ ? OFFLINE_TEXT
339
+ : (manual ? manualText : (nothingYet ? PENDING_TEXT : TIER_TEXT[tier]))
340
+ const title = offline
341
+ ? OFFLINE_TIP
342
+ : (manual ? "手动 · 点击恢复 Auto" : (nothingYet ? PENDING_TIP : "自动 · 点击切到手动"))
343
+
344
+ const style = {
345
+ height: "22px",
346
+ padding: "0 9px",
347
+ borderRadius: "999px",
348
+ fontSize: "11px",
349
+ lineHeight: "1",
350
+ border: "1px solid var(--dsw-alias-border-l1)",
351
+ color: offline || tier === null
352
+ ? OFFLINE_COLOR
353
+ : (manual ? "var(--dsw-alias-label-primary)" : color),
354
+ fontWeight: manual ? 400 : 600,
355
+ transform: "translateX(" + String(offset[0]) + "px)",
356
+ }
357
+ if (shape[0] !== null) {
358
+ if (shape[0].height !== undefined) style.height = shape[0].height
359
+ if (shape[0].borderRadius !== undefined) style.borderRadius = shape[0].borderRadius
360
+ if (shape[0].fontSize !== undefined) style.fontSize = shape[0].fontSize
361
+ }
362
+
363
+ const dot = React.createElement("span", {
364
+ className: "routerLayaDot",
365
+ style: { background: color === null ? "var(--dsw-alias-label-dimmed)" : color },
366
+ })
367
+
368
+ const classes = ["routerLayaChip"]
369
+ if (pop[0] !== 0) classes.push("routerLayaPop")
370
+ if (!offline && !manual && tier === "max") classes.push("routerLayaMax")
371
+
372
+ const chip = React.createElement("div", {
373
+ ref: (node) => chipRef[1](node),
374
+ className: classes.join(" "),
375
+ style: style,
376
+ title: title,
377
+ onClick: () => { writeMode(manual ? "auto" : "manual") },
378
+ onMouseEnter: onEnter,
379
+ onMouseLeave: onLeave,
380
+ }, dot, React.createElement("span", null, text))
381
+
382
+ const toastNode = toast[0] === null
383
+ ? null
384
+ : React.createElement("div", {
385
+ className: "routerLayaToast" + (toastOut[0] ? " routerLayaToastOut" : ""),
386
+ }, toast[0])
387
+
388
+ const popoverNode = popover[0] && rows.length > 0
389
+ ? React.createElement("div", { className: "routerLayaPopover" },
390
+ rows.slice(-POPOVER_LIMIT).reverse().map((row, index) => React.createElement("div", {
391
+ key: String(row.ts) + ":" + String(index),
392
+ className: "routerLayaRow",
393
+ },
394
+ React.createElement("span", { className: "routerLayaTime" }, clockOf(row.ts)),
395
+ React.createElement("span", {
396
+ className: "routerLayaDot",
397
+ style: { background: TIER_COLOR[row.tier] || OFFLINE_COLOR },
398
+ }),
399
+ React.createElement("span", { className: "routerLayaTask" }, clip(row.task)),
400
+ React.createElement("span", { className: "routerLayaWhy" },
401
+ reasonText(row.triggered_by) + (row.regenerate === true ? " ↻" : "")),
402
+ )))
403
+ : null
404
+
405
+ return React.createElement("div", {
406
+ style: { position: "relative", display: "inline-flex", alignItems: "center" },
407
+ }, chip, toastNode, popoverNode)
408
+ }
409
+
410
+ function apply(ctx) {
411
+ if (styleEl === null) {
412
+ styleEl = document.createElement("style")
413
+ styleEl.setAttribute("data-router-laya", "tier-chip")
414
+ styleEl.textContent = CSS
415
+ document.head.appendChild(styleEl)
416
+ }
417
+ startPolling()
418
+ ctx.effect(() => () => {
419
+ if (poller !== null) { clearInterval(poller); poller = null }
420
+ if (styleEl !== null && styleEl.parentNode !== null) styleEl.parentNode.removeChild(styleEl)
421
+ styleEl = null
422
+ })
423
+ ctx.slots.inject("conversation.input.right", () => ctx.slots.register(
424
+ { name: "conversation.input.right", id: "router-laya-tier", order: 5 },
425
+ (slotProps) => React.createElement(Chip, {
426
+ sessionId: slotProps === undefined || slotProps === null ? undefined : slotProps.sessionId,
427
+ }),
428
+ ))
429
+ }
430
+
431
+ exports.name = "router-laya-client"
432
+ exports.inject = ["slots"]
433
+ exports.apply = apply
434
+ return module.exports
435
+ },
436
+ })
@@ -0,0 +1,9 @@
1
+ # dsh bundle patch: inserts this plugin's row into a profile's layer stack.
2
+ #
3
+ # NOTE for the current dev install: the web profile's own cordis.patch.yml already inserts a row with
4
+ # this id (`router-laya`), because that is how the plugin was mounted before this package existed. Do NOT
5
+ # list this package in `dsh.profile.bundles` while that hand-written row is still there -- Cordis refuses
6
+ # a tree with a duplicate loader entry id, and the profile would not boot.
7
+ - insert:
8
+ - id: router-laya
9
+ name: 'dsh-router-laya'