@luziyang2026/dsh-question-nav 0.1.0 → 0.3.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/README.md +28 -18
- package/README.zh.md +24 -15
- package/lib/client.js +344 -72
- package/lib/client.js.map +1 -1
- package/lib/types/client/QuestionNavStrip.d.ts +5 -2
- package/lib/types/client/index.d.ts +10 -3
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/core/history-index.d.ts +99 -0
- package/lib/types/core/load-all.d.ts +53 -0
- package/lib/types/core/nodes.d.ts +7 -0
- package/package.json +1 -1
- package/src/client/QuestionNavStrip.tsx +105 -9
- package/src/client/index.ts +53 -4
- package/src/client/locales.ts +6 -0
- package/src/client/question-nav.module.css +24 -0
- package/src/core/history-index.ts +176 -0
- package/src/core/nodes.ts +14 -0
package/lib/client.js
CHANGED
|
@@ -7,8 +7,64 @@ window.__ModuleLoader__.load({
|
|
|
7
7
|
let react = require("react");
|
|
8
8
|
let react_dom = require("react-dom");
|
|
9
9
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
10
|
+
//#region src/core/nodes.ts
|
|
11
|
+
/** Kinds counted as a user question (turn-opening and steering admissions). */
|
|
12
|
+
const QUESTION_KINDS = ["user", "steering"];
|
|
13
|
+
function userData(data) {
|
|
14
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
15
|
+
return data;
|
|
16
|
+
}
|
|
17
|
+
/** First text block of a user message; falls back to the raw first block. */
|
|
18
|
+
function messageText(content) {
|
|
19
|
+
if (content === void 0 || content.length === 0) return "";
|
|
20
|
+
const first = content[0];
|
|
21
|
+
if (typeof first?.text === "string") return first.text;
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
/** Extract the user questions from a chat-node window, ordered by anchorSeq. */
|
|
25
|
+
function extractQuestions(nodes) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const node of nodes) {
|
|
28
|
+
if (!QUESTION_KINDS.includes(node.kind)) continue;
|
|
29
|
+
const payload = userData(node.data);
|
|
30
|
+
out.push({
|
|
31
|
+
key: node.key,
|
|
32
|
+
anchorSeq: node.anchorSeq,
|
|
33
|
+
seq: payload?.seq ?? -1,
|
|
34
|
+
time: payload?.time ?? 0,
|
|
35
|
+
text: messageText(payload?.content)
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
out.sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */
|
|
42
|
+
function nearestRenderable(nodes, excludeKey) {
|
|
43
|
+
let best = null;
|
|
44
|
+
for (const node of nodes) {
|
|
45
|
+
if (node.visibility === "hidden") continue;
|
|
46
|
+
if (node.key === excludeKey) continue;
|
|
47
|
+
if (best === null || node.anchorSeq < best.anchorSeq) best = {
|
|
48
|
+
key: node.key,
|
|
49
|
+
anchorSeq: node.anchorSeq
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return best;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Merge two question sets (full-history index + live loaded window) into one
|
|
56
|
+
* deduplicated, anchorSeq-ascending list. The window may hold questions that
|
|
57
|
+
* arrived after the index was built; the index may hold questions the window
|
|
58
|
+
* has not loaded yet — union on `key`, newest live copy wins per key.
|
|
59
|
+
*/
|
|
60
|
+
function mergeQuestions(...sources) {
|
|
61
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
62
|
+
for (const source of sources) for (const node of source) byKey.set(node.key, node);
|
|
63
|
+
return [...byKey.values()].sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
10
66
|
//#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
|
|
11
|
-
const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_list{scrollbar-width:thin;flex-direction:column;flex:1;align-items:center;gap:6px;width:100%;min-height:0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list>:first-child{margin-top:auto}.TWf_pa_list>:last-child{margin-bottom:auto}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover{background:var(--dsw-alias-brand-primary);transform:scale(2)}.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary);transform:scale(1.6)}.TWf_pa_count{color:var(--dsw-alias-label-tertiary);user-select:none;flex:none;font-size:10px;font-weight:600;line-height:1}.TWf_pa_dots{flex-direction:column;align-items:center;gap:6px;display:flex}.TWf_pa_empty{color:var(--dsw-alias-label-tertiary);text-align:center;word-break:break-word;padding:10px 4px;font-size:11px}.TWf_pa_tooltip{z-index:20;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);max-width:420px;color:var(--dsw-alias-label-primary);white-space:normal;word-break:break-word;pointer-events:none;border-radius:8px;padding:8px 12px;font-size:13px;line-height:18px;position:fixed;box-shadow:0 4px 16px #0003}";
|
|
67
|
+
const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_list{scrollbar-width:thin;flex-direction:column;flex:1;align-items:center;gap:6px;width:100%;min-height:0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list>:first-child{margin-top:auto}.TWf_pa_list>:last-child{margin-bottom:auto}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover{background:var(--dsw-alias-brand-primary);transform:scale(2)}.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary);transform:scale(1.6)}.TWf_pa_moreDot{border:1px dashed var(--dsw-alias-border-l3);background:0 0}.TWf_pa_moreDot:hover{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}.TWf_pa_count{color:var(--dsw-alias-label-tertiary);user-select:none;flex:none;font-size:10px;font-weight:600;line-height:1}.TWf_pa_countLoading{color:var(--dsw-alias-brand-primary);margin-left:2px;font-weight:400;animation:1.2s ease-in-out infinite TWf_pa_qnPulse}@keyframes TWf_pa_qnPulse{0%,to{opacity:.4}50%{opacity:1}}.TWf_pa_dots{flex-direction:column;align-items:center;gap:6px;display:flex}.TWf_pa_empty{color:var(--dsw-alias-label-tertiary);text-align:center;word-break:break-word;padding:10px 4px;font-size:11px}.TWf_pa_tooltip{z-index:20;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);max-width:420px;color:var(--dsw-alias-label-primary);white-space:normal;word-break:break-word;pointer-events:none;border-radius:8px;padding:8px 12px;font-size:13px;line-height:18px;position:fixed;box-shadow:0 4px 16px #0003}";
|
|
12
68
|
const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
|
|
13
69
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
14
70
|
const tag = document.createElement("style");
|
|
@@ -20,10 +76,13 @@ window.__ModuleLoader__.load({
|
|
|
20
76
|
var question_nav_module_css_default = {
|
|
21
77
|
"active": "TWf_pa_active",
|
|
22
78
|
"count": "TWf_pa_count",
|
|
79
|
+
"countLoading": "TWf_pa_countLoading",
|
|
23
80
|
"dot": "TWf_pa_dot",
|
|
24
81
|
"dots": "TWf_pa_dots",
|
|
25
82
|
"empty": "TWf_pa_empty",
|
|
26
83
|
"list": "TWf_pa_list",
|
|
84
|
+
"moreDot": "TWf_pa_moreDot",
|
|
85
|
+
"qnPulse": "TWf_pa_qnPulse",
|
|
27
86
|
"rail": "TWf_pa_rail",
|
|
28
87
|
"tooltip": "TWf_pa_tooltip"
|
|
29
88
|
};
|
|
@@ -37,9 +96,19 @@ window.__ModuleLoader__.load({
|
|
|
37
96
|
* no native-title delay) shows the question's full text; clicking a dot scrolls
|
|
38
97
|
* the chat to that question.
|
|
39
98
|
*
|
|
99
|
+
* Index strategy (no render-window expansion): the dots cover the WHOLE
|
|
100
|
+
* session history. The index is built from the raw `session.history` RPC via
|
|
101
|
+
* the injected `fetchQuestionIndex` — the conversation's paged window is
|
|
102
|
+
* untouched, so DSH's memory economy is preserved. The loaded window's live
|
|
103
|
+
* questions are merged on top (for new messages arriving after the index was
|
|
104
|
+
* built). Clicking a dot jumps through the existing paging loop, which calls
|
|
105
|
+
* `loadOlder()` only until that specific page is in the window. If the index
|
|
106
|
+
* safety budget is exhausted, a dimmed dashed "load earlier" dot appears above
|
|
107
|
+
* the oldest question and continues the index on click.
|
|
108
|
+
*
|
|
40
109
|
* Data arrives through the four props shares: the framework `useSessions`
|
|
41
|
-
* hook (current session), the registrant inject face (read/subscribe/jump
|
|
42
|
-
* and the bound locale translator.
|
|
110
|
+
* hook (current session), the registrant inject face (read/subscribe/jump/
|
|
111
|
+
* fetch-index), and the bound locale translator.
|
|
43
112
|
*/
|
|
44
113
|
const FAILURE_HINTS = {
|
|
45
114
|
VIEW_INACTIVE: "jump.inactive",
|
|
@@ -58,8 +127,18 @@ window.__ModuleLoader__.load({
|
|
|
58
127
|
const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
|
|
59
128
|
const [hint, setHint] = (0, react.useState)(null);
|
|
60
129
|
const [tooltip, setTooltip] = (0, react.useState)(null);
|
|
130
|
+
const [loadingIndex, setLoadingIndex] = (0, react.useState)(false);
|
|
131
|
+
const [moreAvailable, setMoreAvailable] = (0, react.useState)(false);
|
|
61
132
|
const panelRef = (0, react.useRef)(null);
|
|
62
133
|
const hintTimerRef = (0, react.useRef)(null);
|
|
134
|
+
/** Full-history index from the raw RPC (per current session). */
|
|
135
|
+
const indexRef = (0, react.useRef)([]);
|
|
136
|
+
/** Next beforeSeq to resume from when the index budget was exhausted. */
|
|
137
|
+
const nextBeforeSeqRef = (0, react.useRef)(void 0);
|
|
138
|
+
/** Abort controller for the in-flight index build. */
|
|
139
|
+
const indexAbortRef = (0, react.useRef)(null);
|
|
140
|
+
/** Session whose index build is in flight, to avoid duplicate loops. */
|
|
141
|
+
const buildingSessionRef = (0, react.useRef)(null);
|
|
63
142
|
const showHint = (message) => {
|
|
64
143
|
setHint(message);
|
|
65
144
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
@@ -67,14 +146,53 @@ window.__ModuleLoader__.load({
|
|
|
67
146
|
};
|
|
68
147
|
(0, react.useEffect)(() => {
|
|
69
148
|
if (!visible || current === void 0) {
|
|
149
|
+
indexRef.current = [];
|
|
150
|
+
nextBeforeSeqRef.current = void 0;
|
|
151
|
+
indexAbortRef.current?.abort();
|
|
152
|
+
indexAbortRef.current = null;
|
|
153
|
+
buildingSessionRef.current = null;
|
|
70
154
|
setQuestions([]);
|
|
155
|
+
setLoadingIndex(false);
|
|
156
|
+
setMoreAvailable(false);
|
|
71
157
|
return;
|
|
72
158
|
}
|
|
73
|
-
const
|
|
159
|
+
const sessionId = current;
|
|
160
|
+
indexRef.current = [];
|
|
161
|
+
nextBeforeSeqRef.current = void 0;
|
|
162
|
+
const refresh = () => {
|
|
163
|
+
const windowQuestions = props.readQuestions(sessionId);
|
|
164
|
+
setQuestions(mergeQuestions(indexRef.current, windowQuestions));
|
|
165
|
+
};
|
|
166
|
+
const startBuild = (options) => {
|
|
167
|
+
buildingSessionRef.current = sessionId;
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
indexAbortRef.current = controller;
|
|
170
|
+
setLoadingIndex(true);
|
|
171
|
+
setMoreAvailable(false);
|
|
172
|
+
props.fetchQuestionIndex(sessionId, {
|
|
173
|
+
...options,
|
|
174
|
+
signal: controller.signal
|
|
175
|
+
}).then((result) => {
|
|
176
|
+
if (buildingSessionRef.current !== sessionId) return;
|
|
177
|
+
indexRef.current = mergeQuestions(result.questions, indexRef.current);
|
|
178
|
+
nextBeforeSeqRef.current = result.nextBeforeSeq;
|
|
179
|
+
setMoreAvailable(result.code === "BUDGET" && result.nextBeforeSeq !== void 0);
|
|
180
|
+
refresh();
|
|
181
|
+
}).finally(() => {
|
|
182
|
+
if (buildingSessionRef.current === sessionId) {
|
|
183
|
+
setLoadingIndex(false);
|
|
184
|
+
if (indexAbortRef.current === controller) indexAbortRef.current = null;
|
|
185
|
+
buildingSessionRef.current = null;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
};
|
|
74
189
|
refresh();
|
|
75
|
-
|
|
190
|
+
startBuild();
|
|
191
|
+
const unsubContent = props.subscribeContent(sessionId, refresh);
|
|
76
192
|
const unsubList = props.subscribeList(refresh);
|
|
77
193
|
return () => {
|
|
194
|
+
indexAbortRef.current?.abort();
|
|
195
|
+
indexAbortRef.current = null;
|
|
78
196
|
unsubContent();
|
|
79
197
|
unsubList();
|
|
80
198
|
};
|
|
@@ -137,6 +255,18 @@ window.__ModuleLoader__.load({
|
|
|
137
255
|
props.jump(current, node.key);
|
|
138
256
|
window.setTimeout(() => setJumpingKey((k) => k === node.key ? null : k), 600);
|
|
139
257
|
};
|
|
258
|
+
const onLoadMore = () => {
|
|
259
|
+
if (current === void 0 || nextBeforeSeqRef.current === void 0) return;
|
|
260
|
+
setMoreAvailable(false);
|
|
261
|
+
setLoadingIndex(true);
|
|
262
|
+
props.fetchQuestionIndex(current, { startBeforeSeq: nextBeforeSeqRef.current }).then((result) => {
|
|
263
|
+
if (current === void 0) return;
|
|
264
|
+
indexRef.current = mergeQuestions(result.questions, indexRef.current);
|
|
265
|
+
nextBeforeSeqRef.current = result.nextBeforeSeq;
|
|
266
|
+
setMoreAvailable(result.code === "BUDGET" && result.nextBeforeSeq !== void 0);
|
|
267
|
+
setQuestions(mergeQuestions(indexRef.current, props.readQuestions(current)));
|
|
268
|
+
}).finally(() => setLoadingIndex(false));
|
|
269
|
+
};
|
|
140
270
|
const t = props.t;
|
|
141
271
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
142
272
|
ref: panelRef,
|
|
@@ -152,26 +282,47 @@ window.__ModuleLoader__.load({
|
|
|
152
282
|
className: question_nav_module_css_default.list,
|
|
153
283
|
children: questions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
154
284
|
className: question_nav_module_css_default.empty,
|
|
155
|
-
children: t("strip.empty")
|
|
285
|
+
children: loadingIndex ? t("strip.loadingAll") : t("strip.empty")
|
|
156
286
|
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
157
287
|
className: question_nav_module_css_default.dots,
|
|
158
|
-
children: [
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
288
|
+
children: [
|
|
289
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
290
|
+
className: question_nav_module_css_default.count,
|
|
291
|
+
children: [questions.length, loadingIndex ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
292
|
+
className: question_nav_module_css_default.countLoading,
|
|
293
|
+
children: t("strip.loadingSuffix")
|
|
294
|
+
}) : null]
|
|
295
|
+
}),
|
|
296
|
+
moreAvailable && !loadingIndex ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
297
|
+
className: `${question_nav_module_css_default.dot} ${question_nav_module_css_default.moreDot}`,
|
|
298
|
+
"aria-label": t("strip.loadEarlier"),
|
|
299
|
+
title: t("strip.loadEarlier"),
|
|
300
|
+
onMouseEnter: (e) => {
|
|
301
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
302
|
+
setTooltip({
|
|
303
|
+
text: t("strip.loadEarlier"),
|
|
304
|
+
left: r.right + 10,
|
|
305
|
+
top: r.top
|
|
306
|
+
});
|
|
307
|
+
},
|
|
308
|
+
onMouseLeave: () => setTooltip(null),
|
|
309
|
+
onClick: onLoadMore
|
|
310
|
+
}) : null,
|
|
311
|
+
questions.map((node) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
312
|
+
className: jumpingKey === node.key ? `${question_nav_module_css_default.dot} ${question_nav_module_css_default.active}` : question_nav_module_css_default.dot,
|
|
313
|
+
"aria-label": node.text,
|
|
314
|
+
onMouseEnter: (e) => {
|
|
315
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
316
|
+
setTooltip({
|
|
317
|
+
text: node.text,
|
|
318
|
+
left: r.right + 10,
|
|
319
|
+
top: r.top
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
onMouseLeave: () => setTooltip(null),
|
|
323
|
+
onClick: () => onJump(node)
|
|
324
|
+
}, node.key))
|
|
325
|
+
]
|
|
175
326
|
})
|
|
176
327
|
}),
|
|
177
328
|
tooltip !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -193,6 +344,9 @@ window.__ModuleLoader__.load({
|
|
|
193
344
|
*/
|
|
194
345
|
const zh = {
|
|
195
346
|
"strip.empty": "本会话还没有提问",
|
|
347
|
+
"strip.loadingAll": "正在加载全部历史…",
|
|
348
|
+
"strip.loadingSuffix": "…",
|
|
349
|
+
"strip.loadEarlier": "加载更早的问题",
|
|
196
350
|
"jump.inactive": "聊天视图未激活",
|
|
197
351
|
"jump.hidden": "目标无独立气泡,已定位到邻近内容",
|
|
198
352
|
"jump.notfound": "目标未加载或不存在(可能已压缩)",
|
|
@@ -200,57 +354,15 @@ window.__ModuleLoader__.load({
|
|
|
200
354
|
};
|
|
201
355
|
const en = {
|
|
202
356
|
"strip.empty": "No questions in this session yet",
|
|
357
|
+
"strip.loadingAll": "Loading full history…",
|
|
358
|
+
"strip.loadingSuffix": "…",
|
|
359
|
+
"strip.loadEarlier": "Load earlier questions",
|
|
203
360
|
"jump.inactive": "Chat view is not active",
|
|
204
361
|
"jump.hidden": "No dedicated bubble; landed on nearby content",
|
|
205
362
|
"jump.notfound": "Target not loaded or missing (maybe compacted)",
|
|
206
363
|
"jump.timeout": "Timed out loading history; retry"
|
|
207
364
|
};
|
|
208
365
|
//#endregion
|
|
209
|
-
//#region src/core/nodes.ts
|
|
210
|
-
/** Kinds counted as a user question (turn-opening and steering admissions). */
|
|
211
|
-
const QUESTION_KINDS = ["user", "steering"];
|
|
212
|
-
function userData(data) {
|
|
213
|
-
if (typeof data !== "object" || data === null) return void 0;
|
|
214
|
-
return data;
|
|
215
|
-
}
|
|
216
|
-
/** First text block of a user message; falls back to the raw first block. */
|
|
217
|
-
function messageText(content) {
|
|
218
|
-
if (content === void 0 || content.length === 0) return "";
|
|
219
|
-
const first = content[0];
|
|
220
|
-
if (typeof first?.text === "string") return first.text;
|
|
221
|
-
return "";
|
|
222
|
-
}
|
|
223
|
-
/** Extract the user questions from a chat-node window, ordered by anchorSeq. */
|
|
224
|
-
function extractQuestions(nodes) {
|
|
225
|
-
const out = [];
|
|
226
|
-
for (const node of nodes) {
|
|
227
|
-
if (!QUESTION_KINDS.includes(node.kind)) continue;
|
|
228
|
-
const payload = userData(node.data);
|
|
229
|
-
out.push({
|
|
230
|
-
key: node.key,
|
|
231
|
-
anchorSeq: node.anchorSeq,
|
|
232
|
-
seq: payload?.seq ?? -1,
|
|
233
|
-
time: payload?.time ?? 0,
|
|
234
|
-
text: messageText(payload?.content)
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
out.sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
238
|
-
return out;
|
|
239
|
-
}
|
|
240
|
-
/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */
|
|
241
|
-
function nearestRenderable(nodes, excludeKey) {
|
|
242
|
-
let best = null;
|
|
243
|
-
for (const node of nodes) {
|
|
244
|
-
if (node.visibility === "hidden") continue;
|
|
245
|
-
if (node.key === excludeKey) continue;
|
|
246
|
-
if (best === null || node.anchorSeq < best.anchorSeq) best = {
|
|
247
|
-
key: node.key,
|
|
248
|
-
anchorSeq: node.anchorSeq
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
return best;
|
|
252
|
-
}
|
|
253
|
-
//#endregion
|
|
254
366
|
//#region src/core/jump.ts
|
|
255
367
|
/**
|
|
256
368
|
* Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot
|
|
@@ -258,7 +370,7 @@ window.__ModuleLoader__.load({
|
|
|
258
370
|
* paging/timeout/fallback loop is unit-testable without a real browser or
|
|
259
371
|
* session. The browser half wires these ports to ctx.sessions + the DOM.
|
|
260
372
|
*/
|
|
261
|
-
const DEFAULTS = {
|
|
373
|
+
const DEFAULTS$1 = {
|
|
262
374
|
totalTimeoutMs: 15e3,
|
|
263
375
|
maxPages: 100,
|
|
264
376
|
rowWaitMs: 8e3,
|
|
@@ -287,7 +399,7 @@ window.__ModuleLoader__.load({
|
|
|
287
399
|
*/
|
|
288
400
|
async function jumpToQuestion(ports, key, options = {}) {
|
|
289
401
|
const cfg = {
|
|
290
|
-
...DEFAULTS,
|
|
402
|
+
...DEFAULTS$1,
|
|
291
403
|
...options
|
|
292
404
|
};
|
|
293
405
|
const fail = (code, fallback = false) => {
|
|
@@ -353,6 +465,133 @@ window.__ModuleLoader__.load({
|
|
|
353
465
|
return fail("TARGET_HIDDEN", false);
|
|
354
466
|
}
|
|
355
467
|
//#endregion
|
|
468
|
+
//#region src/core/history-index.ts
|
|
469
|
+
/** The conversation Definition kind whose key a user question node uses. */
|
|
470
|
+
const MESSAGE_DEFINITION_KIND = "input-message";
|
|
471
|
+
/**
|
|
472
|
+
* The engine-owned stable chat key for a user question event — mirrors
|
|
473
|
+
* `conversationContextKey('input-message', String(id))` from the DSH runtime
|
|
474
|
+
* (verified against it in the unit test).
|
|
475
|
+
*/
|
|
476
|
+
function questionKey(id) {
|
|
477
|
+
return `13:${MESSAGE_DEFINITION_KIND}${String(id)}`;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Whether a raw event is one user question the strip should index.
|
|
481
|
+
* Mirrors the DSH `messageDefinition` match + `start` classification:
|
|
482
|
+
* an append-origin `user/message` with a human (`user`) source. Replacement
|
|
483
|
+
* copies (compaction checkpoints, `source.kind === 'plugin'`) and injected
|
|
484
|
+
* context (`source.kind !== 'user'`) are excluded.
|
|
485
|
+
*/
|
|
486
|
+
function isQuestionEvent(event) {
|
|
487
|
+
if (event.type !== "user/message") return false;
|
|
488
|
+
if (event.surfaceOp !== "append") return false;
|
|
489
|
+
return event.data?.source?.kind === "user";
|
|
490
|
+
}
|
|
491
|
+
/** Map one raw question event to a strip question node, or null when not one. */
|
|
492
|
+
function questionFromEvent(event) {
|
|
493
|
+
if (!isQuestionEvent(event)) return null;
|
|
494
|
+
return {
|
|
495
|
+
key: questionKey(event.data?.id),
|
|
496
|
+
anchorSeq: event.seq,
|
|
497
|
+
seq: event.seq,
|
|
498
|
+
time: event.time,
|
|
499
|
+
text: messageText(event.data?.content)
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const DEFAULTS = {
|
|
503
|
+
maxMessages: 100,
|
|
504
|
+
maxPages: 200,
|
|
505
|
+
totalTimeoutMs: 3e4
|
|
506
|
+
};
|
|
507
|
+
function minSeq(events) {
|
|
508
|
+
let min;
|
|
509
|
+
for (const { event } of events) if (min === void 0 || event.seq < min) min = event.seq;
|
|
510
|
+
return min;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Page the raw session history backward, collecting every user question into a
|
|
514
|
+
* lightweight index. Never touches the render window.
|
|
515
|
+
*/
|
|
516
|
+
async function buildQuestionIndex(ports, options = {}) {
|
|
517
|
+
const cfg = {
|
|
518
|
+
...DEFAULTS,
|
|
519
|
+
...options
|
|
520
|
+
};
|
|
521
|
+
const deadline = ports.now() + cfg.totalTimeoutMs;
|
|
522
|
+
const questions = [];
|
|
523
|
+
let beforeSeq = cfg.startBeforeSeq;
|
|
524
|
+
let pages = 0;
|
|
525
|
+
const cancelled = () => cfg.signal?.aborted === true;
|
|
526
|
+
while (true) {
|
|
527
|
+
if (cancelled()) return {
|
|
528
|
+
ok: false,
|
|
529
|
+
code: "CANCELLED",
|
|
530
|
+
questions,
|
|
531
|
+
pages,
|
|
532
|
+
nextBeforeSeq: beforeSeq
|
|
533
|
+
};
|
|
534
|
+
if (ports.now() > deadline) return {
|
|
535
|
+
ok: false,
|
|
536
|
+
code: "TIMEOUT",
|
|
537
|
+
questions,
|
|
538
|
+
pages,
|
|
539
|
+
nextBeforeSeq: beforeSeq
|
|
540
|
+
};
|
|
541
|
+
if (pages >= cfg.maxPages) return {
|
|
542
|
+
ok: false,
|
|
543
|
+
code: "BUDGET",
|
|
544
|
+
questions,
|
|
545
|
+
pages,
|
|
546
|
+
nextBeforeSeq: beforeSeq
|
|
547
|
+
};
|
|
548
|
+
const page = await ports.history(beforeSeq, cfg.maxMessages);
|
|
549
|
+
if (page === void 0) {
|
|
550
|
+
if (pages === 0) return {
|
|
551
|
+
ok: false,
|
|
552
|
+
code: "UNAVAILABLE",
|
|
553
|
+
questions,
|
|
554
|
+
pages,
|
|
555
|
+
nextBeforeSeq: beforeSeq
|
|
556
|
+
};
|
|
557
|
+
return {
|
|
558
|
+
ok: true,
|
|
559
|
+
code: "COMPLETE",
|
|
560
|
+
questions,
|
|
561
|
+
pages,
|
|
562
|
+
nextBeforeSeq: void 0
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
for (const { event } of page.events) {
|
|
566
|
+
const question = questionFromEvent(event);
|
|
567
|
+
if (question !== null) questions.push(question);
|
|
568
|
+
}
|
|
569
|
+
if (!page.hasMore) {
|
|
570
|
+
questions.sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
571
|
+
return {
|
|
572
|
+
ok: true,
|
|
573
|
+
code: "COMPLETE",
|
|
574
|
+
questions,
|
|
575
|
+
pages,
|
|
576
|
+
nextBeforeSeq: void 0
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const next = minSeq(page.events);
|
|
580
|
+
if (next === void 0) {
|
|
581
|
+
questions.sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
582
|
+
return {
|
|
583
|
+
ok: true,
|
|
584
|
+
code: "COMPLETE",
|
|
585
|
+
questions,
|
|
586
|
+
pages,
|
|
587
|
+
nextBeforeSeq: void 0
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
beforeSeq = next;
|
|
591
|
+
pages += 1;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
//#endregion
|
|
356
595
|
//#region src/client/index.ts
|
|
357
596
|
/** Locale namespace this plugin owns. */
|
|
358
597
|
const NS = "question-nav";
|
|
@@ -360,7 +599,8 @@ window.__ModuleLoader__.load({
|
|
|
360
599
|
const inject = [
|
|
361
600
|
"slots",
|
|
362
601
|
"locale",
|
|
363
|
-
"sessions"
|
|
602
|
+
"sessions",
|
|
603
|
+
"connection"
|
|
364
604
|
];
|
|
365
605
|
function claimApply() {
|
|
366
606
|
if (globalThis.__dshQuestionNavApplied === true) return false;
|
|
@@ -398,6 +638,37 @@ window.__ModuleLoader__.load({
|
|
|
398
638
|
sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))
|
|
399
639
|
};
|
|
400
640
|
}
|
|
641
|
+
/** Resolve the connection handle (shared API client) as other DSH plugins do. */
|
|
642
|
+
function connectionOf(ctx) {
|
|
643
|
+
return ctx.get("connection");
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* One raw history page, mapped to the pure `buildQuestionIndex` port shape.
|
|
647
|
+
* `beforeSeq` is exclusive; `undefined` reads the newest page. Returns
|
|
648
|
+
* undefined when the page is unavailable so the builder stops cleanly. The
|
|
649
|
+
* SDK's `SessionEvent` is cast to the structural `RawEventLike` at this
|
|
650
|
+
* boundary (the index reader only touches type/seq/time/surfaceOp/data).
|
|
651
|
+
*/
|
|
652
|
+
async function rawHistoryPage(ctx, sessionId, beforeSeq, maxMessages) {
|
|
653
|
+
const { api } = connectionOf(ctx);
|
|
654
|
+
const { result } = await api.sessions.history({
|
|
655
|
+
sessionId,
|
|
656
|
+
beforeSeq,
|
|
657
|
+
maxMessages
|
|
658
|
+
});
|
|
659
|
+
if (!result.ok) return void 0;
|
|
660
|
+
return {
|
|
661
|
+
events: result.value.events.map((entry) => ({ event: entry.event })),
|
|
662
|
+
hasMore: result.value.hasMore
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/** Build the full-session question index from the raw history RPC (no render). */
|
|
666
|
+
function buildIndexFor(ctx, sessionId, options = {}) {
|
|
667
|
+
return buildQuestionIndex({
|
|
668
|
+
history: (beforeSeq, maxMessages) => rawHistoryPage(ctx, sessionId, beforeSeq, maxMessages),
|
|
669
|
+
now: () => Date.now()
|
|
670
|
+
}, options);
|
|
671
|
+
}
|
|
401
672
|
function createInject(ctx) {
|
|
402
673
|
return {
|
|
403
674
|
readQuestions: (sessionId) => {
|
|
@@ -417,7 +688,8 @@ window.__ModuleLoader__.load({
|
|
|
417
688
|
window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
|
|
418
689
|
};
|
|
419
690
|
jumpToQuestion(ports, key);
|
|
420
|
-
}
|
|
691
|
+
},
|
|
692
|
+
fetchQuestionIndex: (sessionId, options) => buildIndexFor(ctx, sessionId, options)
|
|
421
693
|
};
|
|
422
694
|
}
|
|
423
695
|
/**
|