dsh-milestone 0.2.1 → 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 +10 -3
- package/lib/client.js +544 -106
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
## 好用在哪?
|
|
31
31
|
|
|
32
32
|
- **一键定位** —— 点击任意圆点,平滑滚动到那条消息,不用再手动翻几百行。
|
|
33
|
+
- **站内搜索** —— 搜索框过滤圆点,匹配的是**完整消息内容**(不是 80 字摘要),实时显示命中数 N/M,回车跳到下一个匹配,Esc 一键清空。
|
|
34
|
+
- **当前位置高亮** —— 滚动会话时,离你视口最近的那条提问会亮起白环,永远知道「读到哪了」。
|
|
35
|
+
- **加载更早** —— 历史没加载完时,顶部出现「···」按钮,点一下继续加载,并提示当前已显示多少条。
|
|
33
36
|
- **固定间距** —— 圆点**等距排列**,不随对话长度挤压变形,永远点得准。
|
|
34
37
|
- **蓝色渐变** —— 最新最深、最早最浅,一眼看清提问的先后顺序,像 Git 提交图。
|
|
35
38
|
- **滚轮滑动** —— 长会话圆点超出可视区时,鼠标在里程碑条上滚轮即可滑动选点。
|
|
@@ -51,9 +54,12 @@
|
|
|
51
54
|
## 快速开始
|
|
52
55
|
|
|
53
56
|
```sh
|
|
54
|
-
#
|
|
57
|
+
# 从 npm 安装(推荐)
|
|
55
58
|
dsh plugin --profile demo add dsh-milestone
|
|
56
59
|
|
|
60
|
+
# 或从 GitHub 源码安装
|
|
61
|
+
dsh plugin --profile demo add "github:SnowCrescenter-tech/dsh-milestone#main"
|
|
62
|
+
|
|
57
63
|
# 启动 Web UI
|
|
58
64
|
npx @deepseek-ai/dsh web # → http://127.0.0.1:3080
|
|
59
65
|
```
|
|
@@ -73,12 +79,13 @@ shell.overlay (root scope)
|
|
|
73
79
|
```
|
|
74
80
|
|
|
75
81
|
- **注入点**:`shell.overlay` —— 全框架浮动层,附加式、点击穿透,不影响任何现有 UI。
|
|
76
|
-
- **数据源**:`chat.order` + `chat.nodes`(user 消息)+ `chat.timeline`(turn
|
|
82
|
+
- **数据源**:`chat.order` + `chat.nodes`(user 消息)+ `chat.timeline`(turn 元数据)+ `hasMore`/`loadingOlder`(分页状态)+ `loadOlder`(经 inject face 注入)。
|
|
77
83
|
- **跳转**:DOM 锚点 `data-chat-anchor-key`,`scrollIntoView` 平滑定位。
|
|
84
|
+
- **纯函数**:搜索过滤 / 位置计算 / 圆点状态都在 `rail-logic.ts` 纯函数里,单测覆盖。
|
|
78
85
|
|
|
79
86
|
## 已知限制
|
|
80
87
|
|
|
81
|
-
-
|
|
88
|
+
- 搜索范围 = 当前已加载的消息窗口(初始 50 条;点顶部「···」加载更早,更早的历史需先加载进来才能被搜到)。
|
|
82
89
|
- TTFT / tokens/秒 依赖 turn 位置数据,窗口外或未完成的 turn 不显示(自动隐藏)。
|
|
83
90
|
|
|
84
91
|
## License
|
package/lib/client.js
CHANGED
|
@@ -18,6 +18,283 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
20
|
//#endregion
|
|
21
|
+
//#region src/client/rail-logic.ts
|
|
22
|
+
/**
|
|
23
|
+
* Extract the FULL plain text of a ContentBlock[] payload: the `text` of every
|
|
24
|
+
* `{ type: 'text', text: string }` block, joined with a single space and
|
|
25
|
+
* trimmed. Unlike the rail's hover preview this is NOT truncated — callers use
|
|
26
|
+
* it for search matching, so the entire message must be searchable.
|
|
27
|
+
* @param content - untrusted payload; anything that is not an array yields ''.
|
|
28
|
+
*/
|
|
29
|
+
function extractText(content) {
|
|
30
|
+
if (!Array.isArray(content)) return "";
|
|
31
|
+
const parts = [];
|
|
32
|
+
for (const block of content) if (block !== null && typeof block === "object" && block.type === "text") {
|
|
33
|
+
const text = block.text;
|
|
34
|
+
if (typeof text === "string") parts.push(text);
|
|
35
|
+
}
|
|
36
|
+
return parts.join(" ").trim();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Case-insensitive substring filter over mark texts.
|
|
40
|
+
* @param marks - marks in rail order.
|
|
41
|
+
* @param query - the search query; empty/whitespace matches everything.
|
|
42
|
+
* @returns `matches` (ascending indices whose text includes the lowercased
|
|
43
|
+
* query; all indices when the query is blank) and `active` (the first match
|
|
44
|
+
* index, or -1 when the query is blank or nothing matches).
|
|
45
|
+
*/
|
|
46
|
+
function filterMarks(marks, query) {
|
|
47
|
+
const q = query.trim();
|
|
48
|
+
if (q === "") return {
|
|
49
|
+
matches: marks.map((_, i) => i),
|
|
50
|
+
active: -1
|
|
51
|
+
};
|
|
52
|
+
const lower = q.toLowerCase();
|
|
53
|
+
const matches = marks.reduce((acc, mark, i) => {
|
|
54
|
+
if (mark.text.toLowerCase().includes(lower)) acc.push(i);
|
|
55
|
+
return acc;
|
|
56
|
+
}, []);
|
|
57
|
+
return {
|
|
58
|
+
matches,
|
|
59
|
+
active: matches.length > 0 ? matches[0] : -1
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Wrap-around match navigation.
|
|
64
|
+
* @param current - the currently active match index (any number; used raw).
|
|
65
|
+
* @param count - number of matches; `<= 0` yields -1.
|
|
66
|
+
* @param delta - +1 to advance, -1 to go back.
|
|
67
|
+
* @returns `(current + delta + count) % count`, or -1 when count <= 0.
|
|
68
|
+
*/
|
|
69
|
+
function nextMatchIndex(current, count, delta) {
|
|
70
|
+
if (count <= 0) return -1;
|
|
71
|
+
return (current + delta + count) % count;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Key of the row the viewport top currently sits in: the last row whose top is
|
|
75
|
+
* at or just above the viewport top (within a 0.5px epsilon).
|
|
76
|
+
* @param rows - rows in document order (ascending top).
|
|
77
|
+
* @param viewportTop - scrollport's current scroll offset.
|
|
78
|
+
* @returns that row's key; the first row's key when every row is below the
|
|
79
|
+
* viewport; undefined when there are no rows.
|
|
80
|
+
*/
|
|
81
|
+
function currentIndexOf(rows, viewportTop) {
|
|
82
|
+
if (rows.length === 0) return void 0;
|
|
83
|
+
let current = rows[0];
|
|
84
|
+
for (const row of rows) if (row.top <= viewportTop + .5) current = row;
|
|
85
|
+
else break;
|
|
86
|
+
return current.key;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Compute a mark's visual state from search + position signals.
|
|
90
|
+
* Precedence: `current` (row at viewport top) > `active` (first query match) >
|
|
91
|
+
* `match` (any query match) > `dimmed` (query active, not a match) > `normal`.
|
|
92
|
+
* @param opts - the mark's signals (key is kept for caller symmetry).
|
|
93
|
+
*/
|
|
94
|
+
function markState(opts) {
|
|
95
|
+
if (opts.isCurrent) return "current";
|
|
96
|
+
if (opts.isActive) return "active";
|
|
97
|
+
if (opts.isMatch) return "match";
|
|
98
|
+
if (opts.hasQuery) return "dimmed";
|
|
99
|
+
return "normal";
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Blue gradient dot color, reproduced exactly from MilestoneRail: newest
|
|
103
|
+
* (highest index) is deepest, oldest is lightest. 72% lightness fading to 45%.
|
|
104
|
+
* @param index - dot position in the rail (0 = oldest).
|
|
105
|
+
* @param total - number of dots.
|
|
106
|
+
*/
|
|
107
|
+
function dotColor(index, total) {
|
|
108
|
+
return `hsl(218, 88%, ${72 - (total <= 1 ? 0 : index / (total - 1)) * 27}%)`;
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/client/MilestoneRailSearch.tsx
|
|
112
|
+
/** Dot diameter (px) — matches the rail's DOT_HIT so the toggle aligns. */
|
|
113
|
+
const DOT_HIT$1 = 22;
|
|
114
|
+
/**
|
|
115
|
+
* @param props - the search state slice plus the rail's event handlers.
|
|
116
|
+
*/
|
|
117
|
+
function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear }) {
|
|
118
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
119
|
+
type: "button",
|
|
120
|
+
"data-search-toggle": true,
|
|
121
|
+
"aria-label": "搜索消息",
|
|
122
|
+
"aria-pressed": panelOpen,
|
|
123
|
+
onClick: onToggle,
|
|
124
|
+
style: {
|
|
125
|
+
width: DOT_HIT$1,
|
|
126
|
+
height: DOT_HIT$1,
|
|
127
|
+
flexShrink: 0,
|
|
128
|
+
display: "flex",
|
|
129
|
+
alignItems: "center",
|
|
130
|
+
justifyContent: "center",
|
|
131
|
+
background: panelOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
|
|
132
|
+
border: "none",
|
|
133
|
+
padding: 0,
|
|
134
|
+
cursor: "pointer",
|
|
135
|
+
color: panelOpen ? "#9db8ff" : "#8b96ab"
|
|
136
|
+
},
|
|
137
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
138
|
+
width: "13",
|
|
139
|
+
height: "13",
|
|
140
|
+
viewBox: "0 0 24 24",
|
|
141
|
+
fill: "none",
|
|
142
|
+
stroke: "currentColor",
|
|
143
|
+
strokeWidth: "2.5",
|
|
144
|
+
strokeLinecap: "round",
|
|
145
|
+
"aria-hidden": "true",
|
|
146
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
147
|
+
cx: "11",
|
|
148
|
+
cy: "11",
|
|
149
|
+
r: "7"
|
|
150
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m21 21-4.3-4.3" })]
|
|
151
|
+
})
|
|
152
|
+
}), panelOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
153
|
+
style: {
|
|
154
|
+
position: "fixed",
|
|
155
|
+
top: panelTop,
|
|
156
|
+
right: panelRight,
|
|
157
|
+
width: 220,
|
|
158
|
+
padding: "10px 12px",
|
|
159
|
+
background: "rgba(20, 24, 32, 0.97)",
|
|
160
|
+
color: "#e6e8ee",
|
|
161
|
+
borderRadius: 8,
|
|
162
|
+
boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
|
|
163
|
+
zIndex: 102
|
|
164
|
+
},
|
|
165
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
166
|
+
style: {
|
|
167
|
+
display: "flex",
|
|
168
|
+
alignItems: "center",
|
|
169
|
+
gap: 6
|
|
170
|
+
},
|
|
171
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
172
|
+
"data-rail-search": true,
|
|
173
|
+
"aria-label": "搜索消息",
|
|
174
|
+
placeholder: "搜索消息内容",
|
|
175
|
+
value: query,
|
|
176
|
+
onChange: (e) => onQueryChange(e.target.value),
|
|
177
|
+
onKeyDown: onSearchKeyDown,
|
|
178
|
+
autoFocus: true,
|
|
179
|
+
style: {
|
|
180
|
+
flex: 1,
|
|
181
|
+
minWidth: 0,
|
|
182
|
+
padding: "5px 8px",
|
|
183
|
+
fontSize: 12,
|
|
184
|
+
color: "#e6e8ee",
|
|
185
|
+
background: "rgba(255, 255, 255, 0.08)",
|
|
186
|
+
border: "1px solid rgba(255, 255, 255, 0.16)",
|
|
187
|
+
borderRadius: 6,
|
|
188
|
+
outline: "none"
|
|
189
|
+
}
|
|
190
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
191
|
+
type: "button",
|
|
192
|
+
"data-search-clear": true,
|
|
193
|
+
"aria-label": "清空搜索",
|
|
194
|
+
onClick: onClear,
|
|
195
|
+
style: {
|
|
196
|
+
width: 22,
|
|
197
|
+
height: 22,
|
|
198
|
+
flexShrink: 0,
|
|
199
|
+
display: "flex",
|
|
200
|
+
alignItems: "center",
|
|
201
|
+
justifyContent: "center",
|
|
202
|
+
background: "transparent",
|
|
203
|
+
border: "none",
|
|
204
|
+
padding: 0,
|
|
205
|
+
cursor: "pointer",
|
|
206
|
+
color: "#8b96ab"
|
|
207
|
+
},
|
|
208
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
209
|
+
width: "10",
|
|
210
|
+
height: "10",
|
|
211
|
+
viewBox: "0 0 24 24",
|
|
212
|
+
fill: "none",
|
|
213
|
+
stroke: "currentColor",
|
|
214
|
+
strokeWidth: "3",
|
|
215
|
+
strokeLinecap: "round",
|
|
216
|
+
"aria-hidden": "true",
|
|
217
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })]
|
|
218
|
+
})
|
|
219
|
+
})]
|
|
220
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
221
|
+
"data-match-count": true,
|
|
222
|
+
style: {
|
|
223
|
+
marginTop: 6,
|
|
224
|
+
fontSize: 11,
|
|
225
|
+
color: "#8b96ab"
|
|
226
|
+
},
|
|
227
|
+
children: [
|
|
228
|
+
matches,
|
|
229
|
+
"/",
|
|
230
|
+
total
|
|
231
|
+
]
|
|
232
|
+
})]
|
|
233
|
+
})] });
|
|
234
|
+
}
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/client/useCurrentAnchor.ts
|
|
237
|
+
/**
|
|
238
|
+
* useCurrentAnchor: tracks which user-message row sits at/just above the
|
|
239
|
+
* conversation scrollport's top, so the rail can light the corresponding dot
|
|
240
|
+
* (F2 current-position highlight).
|
|
241
|
+
*
|
|
242
|
+
* Resolves the harness DOM shape (`[data-conversation-scroll]` containing
|
|
243
|
+
* `[data-chat-anchor-key]` rows), computes each row's offset top within the
|
|
244
|
+
* scrollport, and feeds them to `rail-logic.currentIndexOf(rows, 0)` — the
|
|
245
|
+
* viewport top is 0 in scrollport-relative coordinates.
|
|
246
|
+
*
|
|
247
|
+
* Recomputes on scrollport `scroll` events; when `IntersectionObserver` exists
|
|
248
|
+
* (real browsers) rows are also observed (root = scrollport, threshold 0) so
|
|
249
|
+
* layout changes that move a row across the top without a scroll event still
|
|
250
|
+
* refresh. In jsdom tests the observer stub is a no-op, so the scroll listener
|
|
251
|
+
* is the path component tests drive.
|
|
252
|
+
*
|
|
253
|
+
* Pure observation: no timers, no polling; geometry is read only on events.
|
|
254
|
+
*/
|
|
255
|
+
/**
|
|
256
|
+
* @param order - the ordered chat node keys; a change re-resolves the DOM
|
|
257
|
+
* rows (new messages appended, load-older prepends, ...).
|
|
258
|
+
* @returns the anchor key of the row at/just above the scrollport top, or
|
|
259
|
+
* undefined when the scrollport is missing or has no rows.
|
|
260
|
+
*/
|
|
261
|
+
function useCurrentAnchor(order) {
|
|
262
|
+
const [current, setCurrent] = (0, react.useState)(void 0);
|
|
263
|
+
(0, react.useEffect)(() => {
|
|
264
|
+
const scrollport = document.querySelector("[data-conversation-scroll]");
|
|
265
|
+
if (scrollport === null) {
|
|
266
|
+
setCurrent(void 0);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const rows = [...scrollport.querySelectorAll("[data-chat-anchor-key]")];
|
|
270
|
+
const compute = () => {
|
|
271
|
+
const viewportTop = scrollport.getBoundingClientRect().top;
|
|
272
|
+
const positioned = rows.map((row) => ({
|
|
273
|
+
key: row.dataset.chatAnchorKey ?? "",
|
|
274
|
+
top: row.getBoundingClientRect().top - viewportTop
|
|
275
|
+
}));
|
|
276
|
+
setCurrent(currentIndexOf(positioned, 0));
|
|
277
|
+
};
|
|
278
|
+
compute();
|
|
279
|
+
scrollport.addEventListener("scroll", compute, { passive: true });
|
|
280
|
+
if (typeof IntersectionObserver !== "undefined") {
|
|
281
|
+
const observer = new IntersectionObserver(compute, {
|
|
282
|
+
root: scrollport,
|
|
283
|
+
threshold: [0]
|
|
284
|
+
});
|
|
285
|
+
for (const row of rows) observer.observe(row);
|
|
286
|
+
return () => {
|
|
287
|
+
observer.disconnect();
|
|
288
|
+
scrollport.removeEventListener("scroll", compute);
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
return () => {
|
|
292
|
+
scrollport.removeEventListener("scroll", compute);
|
|
293
|
+
};
|
|
294
|
+
}, [order]);
|
|
295
|
+
return current;
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
21
298
|
//#region src/client/MilestoneRail.tsx
|
|
22
299
|
/**
|
|
23
300
|
* MilestoneRail: the milestone.rail entry (session scope). Renders a fixed
|
|
@@ -35,6 +312,22 @@ window.__ModuleLoader__.load({
|
|
|
35
312
|
*
|
|
36
313
|
* Positioning: the rail hugs the conversation scrollport's right edge (offset a
|
|
37
314
|
* little inward so it clears the native scrollbar and sits near the prose).
|
|
315
|
+
*
|
|
316
|
+
* In-rail search (F1): a magnifier toggle at the rail top opens a compact
|
|
317
|
+
* panel to the rail's left with a message-text search input; matches light up
|
|
318
|
+
* the dots (non-matches dim), Enter cycles the active match (wrapping) and
|
|
319
|
+
* jumps to it, Escape clears and closes. Matching runs over the FULL message
|
|
320
|
+
* text (`text` from rail-logic.extractText), not the truncated hover preview.
|
|
321
|
+
*
|
|
322
|
+
* Current-position highlight (F2): the dot for the user message at/just above
|
|
323
|
+
* the conversation viewport top carries a white ring (`useCurrentAnchor`
|
|
324
|
+
* observes the scrollport, no polling).
|
|
325
|
+
*
|
|
326
|
+
* Load-older + window coverage (F3): when the session still has earlier pages
|
|
327
|
+
* (`hasMore`) a slim `···` button sits at the rail top and triggers the
|
|
328
|
+
* injected `loadOlder` action (disabled + `data-loading-older` while
|
|
329
|
+
* `loadingOlder`), and a compact hint to the rail's left states how many
|
|
330
|
+
* messages the current window covers.
|
|
38
331
|
*/
|
|
39
332
|
/** Minimum user messages before the rail adds value. */
|
|
40
333
|
const MIN_MARKS = 2;
|
|
@@ -55,19 +348,9 @@ window.__ModuleLoader__.load({
|
|
|
55
348
|
for (const row of document.querySelectorAll("[data-chat-anchor-key]")) if (row.dataset.chatAnchorKey === key) return row;
|
|
56
349
|
return null;
|
|
57
350
|
}
|
|
58
|
-
/** Extract a plain-text preview from a
|
|
351
|
+
/** Extract a plain-text hover preview (first 80 chars) from a ContentBlock[]. */
|
|
59
352
|
function extractPreview(content) {
|
|
60
|
-
|
|
61
|
-
let text = "";
|
|
62
|
-
for (const block of content) if (block !== null && typeof block === "object" && block.type === "text") {
|
|
63
|
-
const t = block.text;
|
|
64
|
-
if (typeof t === "string") text += (text === "" ? "" : " ") + t;
|
|
65
|
-
}
|
|
66
|
-
return text.trim().slice(0, PREVIEW_LENGTH);
|
|
67
|
-
}
|
|
68
|
-
/** Blue gradient: newest (last) dots are deepest, oldest are lightest. */
|
|
69
|
-
function dotColor(index, total) {
|
|
70
|
-
return `hsl(218, 88%, ${72 - (total <= 1 ? 0 : index / (total - 1)) * 27}%)`;
|
|
353
|
+
return extractText(content).slice(0, PREVIEW_LENGTH);
|
|
71
354
|
}
|
|
72
355
|
/** Relative wall-clock label for a Unix-epoch-ms timestamp. */
|
|
73
356
|
function formatRelativeTime(time) {
|
|
@@ -104,10 +387,12 @@ window.__ModuleLoader__.load({
|
|
|
104
387
|
/**
|
|
105
388
|
* @param props - session standard kit (useSession, sessionId, useProjection).
|
|
106
389
|
*/
|
|
107
|
-
function MilestoneRail({ useSession }) {
|
|
390
|
+
function MilestoneRail({ useSession, loadOlder }) {
|
|
108
391
|
const order = useSession((s) => s.chat.order);
|
|
109
392
|
const nodes = useSession((s) => s.chat.nodes);
|
|
110
393
|
const timeline = useSession((s) => s.chat.timeline);
|
|
394
|
+
const hasMore = useSession((s) => s.hasMore);
|
|
395
|
+
const loadingOlder = useSession((s) => s.loadingOlder);
|
|
111
396
|
const marks = (0, react.useMemo)(() => {
|
|
112
397
|
const result = [];
|
|
113
398
|
for (const key of order) {
|
|
@@ -120,6 +405,7 @@ window.__ModuleLoader__.load({
|
|
|
120
405
|
turn,
|
|
121
406
|
seq: data.seq ?? 0,
|
|
122
407
|
time: data.time ?? 0,
|
|
408
|
+
text: extractText(data.content),
|
|
123
409
|
preview: extractPreview(data.content)
|
|
124
410
|
});
|
|
125
411
|
}
|
|
@@ -127,6 +413,15 @@ window.__ModuleLoader__.load({
|
|
|
127
413
|
}, [order, nodes]);
|
|
128
414
|
const [railBox, setRailBox] = (0, react.useState)(null);
|
|
129
415
|
const [hover, setHover] = (0, react.useState)(null);
|
|
416
|
+
const [search, setSearch] = (0, react.useState)({
|
|
417
|
+
query: "",
|
|
418
|
+
activePos: 0,
|
|
419
|
+
panelOpen: false
|
|
420
|
+
});
|
|
421
|
+
const currentKey = useCurrentAnchor(order);
|
|
422
|
+
const { matches } = (0, react.useMemo)(() => filterMarks(marks, search.query), [marks, search.query]);
|
|
423
|
+
const hasQuery = search.query.trim() !== "";
|
|
424
|
+
const activeMarkIndex = hasQuery && matches.length > 0 ? matches[Math.min(search.activePos, matches.length - 1)] : -1;
|
|
130
425
|
(0, react.useLayoutEffect)(() => {
|
|
131
426
|
if (marks.length < MIN_MARKS) {
|
|
132
427
|
setRailBox(null);
|
|
@@ -158,6 +453,41 @@ window.__ModuleLoader__.load({
|
|
|
158
453
|
block: "start"
|
|
159
454
|
});
|
|
160
455
|
};
|
|
456
|
+
const updateQuery = (query) => {
|
|
457
|
+
setSearch({
|
|
458
|
+
query,
|
|
459
|
+
activePos: 0,
|
|
460
|
+
panelOpen: true
|
|
461
|
+
});
|
|
462
|
+
};
|
|
463
|
+
const clearSearch = () => {
|
|
464
|
+
setSearch((s) => ({
|
|
465
|
+
...s,
|
|
466
|
+
query: "",
|
|
467
|
+
activePos: 0
|
|
468
|
+
}));
|
|
469
|
+
};
|
|
470
|
+
const closeSearch = () => {
|
|
471
|
+
setSearch({
|
|
472
|
+
query: "",
|
|
473
|
+
activePos: 0,
|
|
474
|
+
panelOpen: false
|
|
475
|
+
});
|
|
476
|
+
};
|
|
477
|
+
/** Enter: cycle to the next match (wrapping) and jump to that dot's row. */
|
|
478
|
+
const advanceMatch = () => {
|
|
479
|
+
if (matches.length === 0) return;
|
|
480
|
+
const next = nextMatchIndex(search.activePos, matches.length, 1);
|
|
481
|
+
setSearch((s) => ({
|
|
482
|
+
...s,
|
|
483
|
+
activePos: next
|
|
484
|
+
}));
|
|
485
|
+
jump(marks[matches[next]].key);
|
|
486
|
+
};
|
|
487
|
+
const onSearchKeyDown = (e) => {
|
|
488
|
+
if (e.key === "Enter") advanceMatch();
|
|
489
|
+
if (e.key === "Escape") closeSearch();
|
|
490
|
+
};
|
|
161
491
|
const buildHover = (mark, index) => {
|
|
162
492
|
const turn = mark.turn !== void 0 ? timeline.turns.get(mark.turn) : void 0;
|
|
163
493
|
let durationLabel = null;
|
|
@@ -187,6 +517,7 @@ window.__ModuleLoader__.load({
|
|
|
187
517
|
tpsLabel
|
|
188
518
|
};
|
|
189
519
|
};
|
|
520
|
+
const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
|
|
190
521
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
191
522
|
style: {
|
|
192
523
|
position: "fixed",
|
|
@@ -195,22 +526,22 @@ window.__ModuleLoader__.load({
|
|
|
195
526
|
height: railBox.height,
|
|
196
527
|
width: DOT_HIT,
|
|
197
528
|
pointerEvents: "auto",
|
|
198
|
-
zIndex: 100
|
|
529
|
+
zIndex: 100,
|
|
530
|
+
display: "flex",
|
|
531
|
+
flexDirection: "column"
|
|
199
532
|
},
|
|
200
533
|
"aria-label": "会话里程碑",
|
|
201
|
-
children: [
|
|
202
|
-
|
|
203
|
-
height: "100%",
|
|
204
|
-
overflowY: "auto",
|
|
205
|
-
display: "flex",
|
|
206
|
-
flexDirection: "column",
|
|
207
|
-
alignItems: "center",
|
|
208
|
-
gap: DOT_GAP,
|
|
209
|
-
padding: "6px 0",
|
|
210
|
-
scrollbarWidth: "none"
|
|
211
|
-
},
|
|
212
|
-
children: marks.map((mark, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
534
|
+
children: [
|
|
535
|
+
showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
213
536
|
type: "button",
|
|
537
|
+
"data-load-older": true,
|
|
538
|
+
"data-loading-older": loadingOlder ? "true" : void 0,
|
|
539
|
+
title: "加载更早消息",
|
|
540
|
+
"aria-label": "加载更早消息",
|
|
541
|
+
disabled: loadingOlder,
|
|
542
|
+
onClick: () => {
|
|
543
|
+
loadOlder();
|
|
544
|
+
},
|
|
214
545
|
style: {
|
|
215
546
|
width: DOT_HIT,
|
|
216
547
|
height: DOT_HIT,
|
|
@@ -221,91 +552,195 @@ window.__ModuleLoader__.load({
|
|
|
221
552
|
background: "transparent",
|
|
222
553
|
border: "none",
|
|
223
554
|
padding: 0,
|
|
224
|
-
cursor: "pointer"
|
|
555
|
+
cursor: loadingOlder ? "default" : "pointer",
|
|
556
|
+
color: loadingOlder ? "#5a6375" : "#8b96ab",
|
|
557
|
+
fontSize: 11,
|
|
558
|
+
lineHeight: 1,
|
|
559
|
+
letterSpacing: 1
|
|
225
560
|
},
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
561
|
+
children: "···"
|
|
562
|
+
}),
|
|
563
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
|
|
564
|
+
panelTop: railBox.top,
|
|
565
|
+
panelRight: railBox.right + DOT_HIT + 8,
|
|
566
|
+
query: search.query,
|
|
567
|
+
panelOpen: search.panelOpen,
|
|
568
|
+
matches: matches.length,
|
|
569
|
+
total: marks.length,
|
|
570
|
+
onToggle: () => setSearch((s) => ({
|
|
571
|
+
...s,
|
|
572
|
+
panelOpen: !s.panelOpen
|
|
573
|
+
})),
|
|
574
|
+
onQueryChange: updateQuery,
|
|
575
|
+
onSearchKeyDown,
|
|
576
|
+
onClear: clearSearch
|
|
577
|
+
}),
|
|
578
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
579
|
+
style: {
|
|
580
|
+
flex: 1,
|
|
581
|
+
minHeight: 0,
|
|
582
|
+
overflowY: "auto",
|
|
583
|
+
display: "flex",
|
|
584
|
+
flexDirection: "column",
|
|
585
|
+
alignItems: "center",
|
|
586
|
+
gap: DOT_GAP,
|
|
587
|
+
padding: "6px 0",
|
|
588
|
+
scrollbarWidth: "none"
|
|
232
589
|
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
] }), hover.turnLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.turnLabel })]
|
|
282
|
-
}),
|
|
283
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
284
|
-
style: { color: "#c7cede" },
|
|
285
|
-
children: hover.mark.preview !== "" ? hover.mark.preview : "(无文本)"
|
|
286
|
-
}),
|
|
287
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
288
|
-
style: {
|
|
289
|
-
display: "flex",
|
|
290
|
-
flexWrap: "wrap",
|
|
291
|
-
gap: 8,
|
|
292
|
-
color: "#8b96ab",
|
|
293
|
-
fontSize: 11,
|
|
294
|
-
marginTop: 4
|
|
295
|
-
},
|
|
296
|
-
children: [
|
|
297
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatRelativeTime(hover.mark.time) }),
|
|
298
|
-
hover.durationLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["用时 ", hover.durationLabel] }),
|
|
299
|
-
hover.reasonLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.reasonLabel }),
|
|
300
|
-
hover.ttftLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["首字 ", hover.ttftLabel] }),
|
|
301
|
-
hover.tpsLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.tpsLabel })
|
|
302
|
-
]
|
|
590
|
+
children: marks.map((mark, i) => {
|
|
591
|
+
const dotState = markState({
|
|
592
|
+
key: mark.key,
|
|
593
|
+
hasQuery,
|
|
594
|
+
isMatch: matches.includes(i),
|
|
595
|
+
isActive: i === activeMarkIndex,
|
|
596
|
+
isCurrent: !hasQuery && mark.key === currentKey
|
|
597
|
+
});
|
|
598
|
+
const isHovered = hover?.mark.key === mark.key;
|
|
599
|
+
const boxShadow = isHovered ? "0 0 0 3px rgba(77, 124, 254, 0.35)" : dotState === "active" ? "0 0 0 3px rgba(255, 255, 255, 0.9)" : dotState === "current" ? "0 0 0 3px rgba(255, 255, 255, 0.75)" : "none";
|
|
600
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
601
|
+
type: "button",
|
|
602
|
+
style: {
|
|
603
|
+
width: DOT_HIT,
|
|
604
|
+
height: DOT_HIT,
|
|
605
|
+
flexShrink: 0,
|
|
606
|
+
display: "flex",
|
|
607
|
+
alignItems: "center",
|
|
608
|
+
justifyContent: "center",
|
|
609
|
+
background: "transparent",
|
|
610
|
+
border: "none",
|
|
611
|
+
padding: 0,
|
|
612
|
+
cursor: "pointer"
|
|
613
|
+
},
|
|
614
|
+
onMouseEnter: (e) => {
|
|
615
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
616
|
+
setHover({
|
|
617
|
+
...buildHover(mark, i),
|
|
618
|
+
top: rect.top + rect.height / 2
|
|
619
|
+
});
|
|
620
|
+
},
|
|
621
|
+
onMouseLeave: () => setHover(null),
|
|
622
|
+
onClick: () => jump(mark.key),
|
|
623
|
+
"aria-label": `跳转到第 ${i + 1} 条消息`,
|
|
624
|
+
"aria-current": dotState === "active" ? "true" : void 0,
|
|
625
|
+
"data-current": dotState === "current" ? "true" : void 0,
|
|
626
|
+
"data-dimmed": dotState === "dimmed" ? "true" : void 0,
|
|
627
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
|
|
628
|
+
width: DOT_SIZE,
|
|
629
|
+
height: DOT_SIZE,
|
|
630
|
+
borderRadius: "50%",
|
|
631
|
+
background: dotColor(i, marks.length),
|
|
632
|
+
boxShadow,
|
|
633
|
+
transition: "transform 120ms ease, opacity 120ms ease",
|
|
634
|
+
transform: `scale(${isHovered ? 1.35 : dotState === "active" || dotState === "current" ? 1.25 : 1})`,
|
|
635
|
+
opacity: isHovered || dotState !== "dimmed" ? 1 : .22
|
|
636
|
+
} })
|
|
637
|
+
}, mark.key);
|
|
303
638
|
})
|
|
304
|
-
|
|
305
|
-
|
|
639
|
+
}),
|
|
640
|
+
hover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
641
|
+
style: {
|
|
642
|
+
position: "fixed",
|
|
643
|
+
right: railBox.right + DOT_HIT + 8,
|
|
644
|
+
top: hover.top,
|
|
645
|
+
transform: "translateY(-50%)",
|
|
646
|
+
maxWidth: 300,
|
|
647
|
+
minWidth: 180,
|
|
648
|
+
padding: "8px 12px",
|
|
649
|
+
background: "rgba(20, 24, 32, 0.96)",
|
|
650
|
+
color: "#e6e8ee",
|
|
651
|
+
borderRadius: 8,
|
|
652
|
+
fontSize: 12,
|
|
653
|
+
lineHeight: 1.6,
|
|
654
|
+
whiteSpace: "pre-wrap",
|
|
655
|
+
wordBreak: "break-word",
|
|
656
|
+
boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
|
|
657
|
+
zIndex: 101,
|
|
658
|
+
pointerEvents: "none"
|
|
659
|
+
},
|
|
660
|
+
children: [
|
|
661
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
662
|
+
style: {
|
|
663
|
+
display: "flex",
|
|
664
|
+
gap: 8,
|
|
665
|
+
color: "#9aa4b8",
|
|
666
|
+
fontSize: 11,
|
|
667
|
+
marginBottom: 4
|
|
668
|
+
},
|
|
669
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
|
|
670
|
+
"第 ",
|
|
671
|
+
hover.index + 1,
|
|
672
|
+
" / ",
|
|
673
|
+
hover.total,
|
|
674
|
+
" 条"
|
|
675
|
+
] }), hover.turnLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.turnLabel })]
|
|
676
|
+
}),
|
|
677
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
678
|
+
style: { color: "#c7cede" },
|
|
679
|
+
children: hover.mark.preview !== "" ? hover.mark.preview : "(无文本)"
|
|
680
|
+
}),
|
|
681
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
682
|
+
style: {
|
|
683
|
+
display: "flex",
|
|
684
|
+
flexWrap: "wrap",
|
|
685
|
+
gap: 8,
|
|
686
|
+
color: "#8b96ab",
|
|
687
|
+
fontSize: 11,
|
|
688
|
+
marginTop: 4
|
|
689
|
+
},
|
|
690
|
+
children: [
|
|
691
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatRelativeTime(hover.mark.time) }),
|
|
692
|
+
hover.durationLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["用时 ", hover.durationLabel] }),
|
|
693
|
+
hover.reasonLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.reasonLabel }),
|
|
694
|
+
hover.ttftLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["首字 ", hover.ttftLabel] }),
|
|
695
|
+
hover.tpsLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.tpsLabel })
|
|
696
|
+
]
|
|
697
|
+
})
|
|
698
|
+
]
|
|
699
|
+
}),
|
|
700
|
+
showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
701
|
+
"data-window-hint": true,
|
|
702
|
+
style: {
|
|
703
|
+
position: "absolute",
|
|
704
|
+
bottom: 6,
|
|
705
|
+
right: "100%",
|
|
706
|
+
marginRight: 8,
|
|
707
|
+
whiteSpace: "nowrap",
|
|
708
|
+
fontSize: 10,
|
|
709
|
+
lineHeight: 1,
|
|
710
|
+
color: "rgba(139, 150, 171, 0.9)",
|
|
711
|
+
pointerEvents: "none",
|
|
712
|
+
userSelect: "none"
|
|
713
|
+
},
|
|
714
|
+
children: [
|
|
715
|
+
"已显示 ",
|
|
716
|
+
marks.length,
|
|
717
|
+
" 条 · 还有更早"
|
|
718
|
+
]
|
|
719
|
+
})
|
|
720
|
+
]
|
|
306
721
|
});
|
|
307
722
|
}
|
|
308
723
|
//#endregion
|
|
724
|
+
//#region src/client/railInject.ts
|
|
725
|
+
/**
|
|
726
|
+
* Wrap a session-bound `loadOlder` call into a safe action closure.
|
|
727
|
+
*
|
|
728
|
+
* - Missing binding: resolves (never throws on an unlisted/unscoped session).
|
|
729
|
+
* - Bound session: delegates to `session.loadOlder()`; a rejection propagates
|
|
730
|
+
* unchanged so callers can surface the transport error.
|
|
731
|
+
*
|
|
732
|
+
* @param sessions - the injected sessions service (`ctx.sessions`).
|
|
733
|
+
* @param sessionId - the session the rail is scoped to.
|
|
734
|
+
* @returns an action that loads the previous message page for that session.
|
|
735
|
+
*/
|
|
736
|
+
function createLoadOlder(sessions, sessionId) {
|
|
737
|
+
return async () => {
|
|
738
|
+
const binding = sessions.binding(sessionId);
|
|
739
|
+
if (binding === void 0) return;
|
|
740
|
+
await binding.session.loadOlder();
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
//#endregion
|
|
309
744
|
//#region src/client/index.ts
|
|
310
745
|
/** Required services (cordis fiber inject). */
|
|
311
746
|
const inject = ["slots"];
|
|
@@ -326,7 +761,10 @@ window.__ModuleLoader__.load({
|
|
|
326
761
|
scope: "session"
|
|
327
762
|
} }
|
|
328
763
|
}, MilestoneOverlay));
|
|
329
|
-
ctx.slots.inject("milestone.rail", () => ctx.slots.register({
|
|
764
|
+
ctx.slots.inject("milestone.rail", () => ctx.slots.register({
|
|
765
|
+
name: "milestone.rail",
|
|
766
|
+
inject: (sessionId) => ({ loadOlder: createLoadOlder(ctx.sessions, sessionId) })
|
|
767
|
+
}, MilestoneRail));
|
|
330
768
|
}
|
|
331
769
|
//#endregion
|
|
332
770
|
exports.apply = apply;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-milestone",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Git-style milestone timeline for DeepSeek Harness:
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Git-style milestone timeline for DeepSeek Harness: hover for metadata, click to jump to any message. 会话里程碑导航条:圆点时间线,定位并跳转到每条提问。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"build": "tsdown",
|
|
27
27
|
"watch": "tsdown --watch",
|
|
28
28
|
"typecheck": "tsc --noEmit",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"test:surface": "playwright test",
|
|
29
32
|
"prepublishOnly": "pnpm run build"
|
|
30
33
|
},
|
|
31
34
|
"license": "MIT",
|
|
@@ -53,9 +56,16 @@
|
|
|
53
56
|
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
54
57
|
"@deepseek-ai/dsh-client-ui-layout": "^0.1.0-rc.6",
|
|
55
58
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
59
|
+
"@playwright/test": "^1.62.1",
|
|
60
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
61
|
+
"@testing-library/react": "^16.3.2",
|
|
62
|
+
"@testing-library/user-event": "^14.6.4",
|
|
63
|
+
"@types/node": "^26.2.0",
|
|
56
64
|
"@types/react": "~18.3.1",
|
|
65
|
+
"jsdom": "^30.0.1",
|
|
57
66
|
"react": "^18.2.0",
|
|
58
67
|
"tsdown": "0.22.2",
|
|
59
|
-
"typescript": "^6.0.3"
|
|
68
|
+
"typescript": "^6.0.3",
|
|
69
|
+
"vitest": "^4.1.10"
|
|
60
70
|
}
|
|
61
71
|
}
|