@luziyang2026/dsh-question-nav 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/LICENSE +29 -0
- package/README.md +76 -0
- package/README.zh.md +67 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +450 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +5 -0
- package/lib/types/client/QuestionNavStrip.d.ts +17 -0
- package/lib/types/client/index.d.ts +30 -0
- package/lib/types/client/locales.d.ts +19 -0
- package/lib/types/core/jump.d.ts +59 -0
- package/lib/types/core/nodes.d.ts +49 -0
- package/lib/types/index.d.ts +9 -0
- package/package.json +88 -0
- package/src/client/QuestionNavStrip.tsx +190 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.ts +128 -0
- package/src/client/locales.ts +21 -0
- package/src/client/question-nav.module.css +105 -0
- package/src/core/jump.ts +153 -0
- package/src/core/nodes.ts +98 -0
- package/src/index.ts +10 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Abel Keith Sun. All rights reserved.
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
this list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from this
|
|
17
|
+
software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
22
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
23
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
24
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
25
|
+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
26
|
+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
27
|
+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
28
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
29
|
+
POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# dsh-question-nav
|
|
2
|
+
|
|
3
|
+
In-session question navigator for the [DeepSeek Harness (DSH) Web GUI][dsh]: a
|
|
4
|
+
vertical column of small round dots overlaid on the **left edge** of the
|
|
5
|
+
conversation column — one dot per user question. Hover a dot to enlarge it and
|
|
6
|
+
see the question's **full text** immediately; click a dot to scroll the chat to
|
|
7
|
+
that question.
|
|
8
|
+
|
|
9
|
+
It is an external plugin bundle with zero runtime dependencies — the browser
|
|
10
|
+
half (`lib/client.js`) externalizes everything to the DSH shell, so it adds
|
|
11
|
+
only a small bundle to the GUI at load time.
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
- **Left-edge dot minimap** (embedded, not reserving any width).
|
|
16
|
+
- **Vertically centered** in the conversation column.
|
|
17
|
+
- **One dot = one user question**, with a small count above the dot column.
|
|
18
|
+
- **Hover**: the dot enlarges and an instant tooltip (portal-rendered, no
|
|
19
|
+
native-title delay) shows the question's **full text**.
|
|
20
|
+
- **Click**: jumps to that question, paging older history when the target is
|
|
21
|
+
not yet in the loaded window (with a nearest-row fallback).
|
|
22
|
+
- Empty/left areas of the rail pass pointer events through to the conversation
|
|
23
|
+
(it never blocks the chat).
|
|
24
|
+
|
|
25
|
+
## Behavior notes
|
|
26
|
+
|
|
27
|
+
- Only the current session is indexed; the question list reflects the loaded
|
|
28
|
+
chat window and expands as history is paged in.
|
|
29
|
+
- `user` and `steering` user messages count as questions.
|
|
30
|
+
|
|
31
|
+
## Install (development / local checkout)
|
|
32
|
+
|
|
33
|
+
This repository is a standalone plugin project — build it, then add it to a
|
|
34
|
+
profile as an installable [bundle][bundle].
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pnpm install # install devDependencies (DSH SDK peers, tsdown, vitest)
|
|
38
|
+
pnpm build # tsc declarations + tsdown client bundle -> lib/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Add it to a profile (the `dsh` CLI is required):
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
dsh plugin --profile web add ./dsh-question-nav
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or, once published, install the prebuilt package from npm:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
dsh plugin --profile web add @luziyang2026/dsh-question-nav
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Restart the DSH Web GUI to load the new bundle.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
pnpm typecheck # tsc --noEmit
|
|
59
|
+
pnpm test # vitest run
|
|
60
|
+
pnpm build # build host lib/index.js + client lib/client.js
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## How it is built
|
|
64
|
+
|
|
65
|
+
The client half uses the shared DSH client-bundle preset
|
|
66
|
+
([`tsdown.client.ts`](tsdown.client.ts), vendored into this repo), which emits
|
|
67
|
+
a `window.__ModuleLoader__.load({ id, factory })` closure-factory
|
|
68
|
+
artifact with CSS Modules inlined and externals resolved through the loader
|
|
69
|
+
module table.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
BSD-3-Clause.
|
|
74
|
+
|
|
75
|
+
[dsh]: https://github.com/deepseek-harness/deepseek-harness
|
|
76
|
+
[bundle]: https://github.com/deepseek-harness/deepseek-harness
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# dsh-question-nav
|
|
2
|
+
|
|
3
|
+
[DeepSeek Harness (DSH) Web GUI][dsh] 的**会话内提问导航**插件:在对话栏**左侧**
|
|
4
|
+
内嵌一列竖排的圆点小按钮 —— 每个圆点对应一个用户提问。鼠标悬停圆点会放大
|
|
5
|
+
并**立即**显示该提问的**全文**;点击圆点则把对话滚动跳到那一提问的位置。
|
|
6
|
+
|
|
7
|
+
它是外部插件 bundle,**零运行时依赖** —— 浏览器半区(`lib/client.js`)把一切
|
|
8
|
+
外部化给 DSH 外壳,加载时只往 GUI 里加一个很小的 bundle。
|
|
9
|
+
|
|
10
|
+
## 功能
|
|
11
|
+
|
|
12
|
+
- **左缘圆点迷你地图**:内嵌,**不占任何宽度**。
|
|
13
|
+
- **垂直居中**在对话栏中。
|
|
14
|
+
- **一个圆点 = 一个用户提问**,圆点列上方有提问数量。
|
|
15
|
+
- **悬停**:圆点放大 + 即时提示框(portal 渲染,无原生 `title` 延迟)显示**全文**。
|
|
16
|
+
- **点击**:跳转到该提问,目标尚未加载时自动 `loadOlder` 扩窗(并落到最近可见行兜底)。
|
|
17
|
+
- 圆点列空白区域**点穿**到对话内容,不会挡住聊天。
|
|
18
|
+
|
|
19
|
+
## 行为说明
|
|
20
|
+
|
|
21
|
+
- 只索引当前会话;问题列表反映已加载的对话窗口,随历史扩窗补全。
|
|
22
|
+
- `user` 与 `steering` 用户消息计为提问。
|
|
23
|
+
|
|
24
|
+
## 安装(开发 / 本地 checkout)
|
|
25
|
+
|
|
26
|
+
本仓库是独立插件工程 —— 先构建,再作为可安装的 [bundle][bundle] 加进 profile。
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pnpm install # 安装 devDependencies(DSH SDK peers、tsdown、vitest)
|
|
30
|
+
pnpm build # tsc 声明 + tsdown client bundle -> lib/
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
加进 profile(需要 `dsh` CLI):
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
dsh plugin --profile web add ./dsh-question-nav
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
发布后也可直接从 npm 装预构建产物:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
dsh plugin --profile web add @luziyang2026/dsh-question-nav
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
重启 DSH Web GUI 以加载新 bundle。
|
|
46
|
+
|
|
47
|
+
## 开发
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
pnpm typecheck # tsc --noEmit
|
|
51
|
+
pnpm test # vitest run
|
|
52
|
+
pnpm build # 构建 host lib/index.js + client lib/client.js
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 构建方式
|
|
56
|
+
|
|
57
|
+
client 半区使用共享的 DSH client-bundle 预设([`tsdown.client.ts`](tsdown.client.ts),
|
|
58
|
+
vendored 到本项目内),产出
|
|
59
|
+
`window.__ModuleLoader__.load({ id, factory })` 闭包工厂产物,CSS Modules 内联,
|
|
60
|
+
externals 经加载器模块表解析。
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
BSD-3-Clause。
|
|
65
|
+
|
|
66
|
+
[dsh]: https://github.com/deepseek-harness/deepseek-harness
|
|
67
|
+
[bundle]: https://github.com/deepseek-harness/deepseek-harness
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# dsh-question-nav bundle patch: inserts its plugin row into the web profile
|
|
2
|
+
# roster. Applied as a profile bundle layer (the `dsh.bundle.patch` manifest
|
|
3
|
+
# field); install with
|
|
4
|
+
# `dsh plugin --profile web add ./dsh-question-nav` (from a local checkout) or
|
|
5
|
+
# `dsh plugin --profile web add <npm-package>` once published.
|
|
6
|
+
- insert:
|
|
7
|
+
- id: question-nav
|
|
8
|
+
name: '@luziyang2026/dsh-question-nav'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@luziyang2026/dsh-question-nav",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let react_dom = require("react-dom");
|
|
9
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
10
|
+
//#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}";
|
|
12
|
+
const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
|
|
13
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
14
|
+
const tag = document.createElement("style");
|
|
15
|
+
tag.dataset.plugin = "@luziyang2026/dsh-question-nav";
|
|
16
|
+
tag.dataset.pluginCss = tagId;
|
|
17
|
+
tag.textContent = css;
|
|
18
|
+
document.head.appendChild(tag);
|
|
19
|
+
}
|
|
20
|
+
var question_nav_module_css_default = {
|
|
21
|
+
"active": "TWf_pa_active",
|
|
22
|
+
"count": "TWf_pa_count",
|
|
23
|
+
"dot": "TWf_pa_dot",
|
|
24
|
+
"dots": "TWf_pa_dots",
|
|
25
|
+
"empty": "TWf_pa_empty",
|
|
26
|
+
"list": "TWf_pa_list",
|
|
27
|
+
"rail": "TWf_pa_rail",
|
|
28
|
+
"tooltip": "TWf_pa_tooltip"
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/client/QuestionNavStrip.tsx
|
|
32
|
+
/**
|
|
33
|
+
* Question-nav minimap. Renders a vertical column of small round dots overlaid
|
|
34
|
+
* on the LEFT edge of the conversation column (via the frame-wide
|
|
35
|
+
* `shell.overlay` floating layer), vertically centered: one dot per user
|
|
36
|
+
* question, enlarge on hover. The instant tooltip (a portal-rendered overlay,
|
|
37
|
+
* no native-title delay) shows the question's full text; clicking a dot scrolls
|
|
38
|
+
* the chat to that question.
|
|
39
|
+
*
|
|
40
|
+
* 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.
|
|
43
|
+
*/
|
|
44
|
+
const FAILURE_HINTS = {
|
|
45
|
+
VIEW_INACTIVE: "jump.inactive",
|
|
46
|
+
TARGET_HIDDEN: "jump.hidden",
|
|
47
|
+
NOT_FOUND: "jump.notfound",
|
|
48
|
+
TIMEOUT: "jump.timeout"
|
|
49
|
+
};
|
|
50
|
+
function findConvRoot() {
|
|
51
|
+
return document.querySelector("[data-slot=\"conversation\"] > div[data-phase]");
|
|
52
|
+
}
|
|
53
|
+
function QuestionNavStrip(props) {
|
|
54
|
+
const current = props.useSessions((s) => s.current);
|
|
55
|
+
const summary = props.useSessions((s) => s.current === void 0 ? void 0 : s.byId[s.current]);
|
|
56
|
+
const visible = current !== void 0 && summary !== void 0 && summary.blank !== true;
|
|
57
|
+
const [questions, setQuestions] = (0, react.useState)([]);
|
|
58
|
+
const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
|
|
59
|
+
const [hint, setHint] = (0, react.useState)(null);
|
|
60
|
+
const [tooltip, setTooltip] = (0, react.useState)(null);
|
|
61
|
+
const panelRef = (0, react.useRef)(null);
|
|
62
|
+
const hintTimerRef = (0, react.useRef)(null);
|
|
63
|
+
const showHint = (message) => {
|
|
64
|
+
setHint(message);
|
|
65
|
+
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
66
|
+
hintTimerRef.current = window.setTimeout(() => setHint(null), 1800);
|
|
67
|
+
};
|
|
68
|
+
(0, react.useEffect)(() => {
|
|
69
|
+
if (!visible || current === void 0) {
|
|
70
|
+
setQuestions([]);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const refresh = () => setQuestions(props.readQuestions(current));
|
|
74
|
+
refresh();
|
|
75
|
+
const unsubContent = props.subscribeContent(current, refresh);
|
|
76
|
+
const unsubList = props.subscribeList(refresh);
|
|
77
|
+
return () => {
|
|
78
|
+
unsubContent();
|
|
79
|
+
unsubList();
|
|
80
|
+
};
|
|
81
|
+
}, [
|
|
82
|
+
visible,
|
|
83
|
+
current,
|
|
84
|
+
props
|
|
85
|
+
]);
|
|
86
|
+
(0, react.useEffect)(() => {
|
|
87
|
+
const onJumpFailed = (event) => {
|
|
88
|
+
const code = event.detail;
|
|
89
|
+
showHint(props.t(FAILURE_HINTS[code] ?? "jump.timeout"));
|
|
90
|
+
};
|
|
91
|
+
window.addEventListener("question-nav:jump-failed", onJumpFailed);
|
|
92
|
+
return () => window.removeEventListener("question-nav:jump-failed", onJumpFailed);
|
|
93
|
+
}, [props]);
|
|
94
|
+
(0, react.useLayoutEffect)(() => {
|
|
95
|
+
if (!visible) return;
|
|
96
|
+
let raf = 0;
|
|
97
|
+
let retries = 0;
|
|
98
|
+
const applyLayout = () => {
|
|
99
|
+
const panel = panelRef.current;
|
|
100
|
+
if (panel === null) return;
|
|
101
|
+
const frame = panel.closest("[data-shell-overlay]")?.parentElement ?? null;
|
|
102
|
+
const convRoot = findConvRoot();
|
|
103
|
+
if (frame === null || convRoot === null) return;
|
|
104
|
+
const frameRect = frame.getBoundingClientRect();
|
|
105
|
+
const convRect = convRoot.getBoundingClientRect();
|
|
106
|
+
if (convRect.height <= 0) {
|
|
107
|
+
if (retries < 20) {
|
|
108
|
+
retries += 1;
|
|
109
|
+
raf = requestAnimationFrame(applyLayout);
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
retries = 0;
|
|
114
|
+
panel.style.top = `${convRect.top - frameRect.top}px`;
|
|
115
|
+
panel.style.height = `${convRect.height}px`;
|
|
116
|
+
panel.style.left = `${convRect.left - frameRect.left}px`;
|
|
117
|
+
};
|
|
118
|
+
applyLayout();
|
|
119
|
+
raf = requestAnimationFrame(applyLayout);
|
|
120
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(applyLayout);
|
|
121
|
+
const convRoot = findConvRoot();
|
|
122
|
+
observer?.observe(convRoot ?? document.body, { box: "border-box" });
|
|
123
|
+
window.addEventListener("resize", applyLayout);
|
|
124
|
+
return () => {
|
|
125
|
+
if (raf !== 0) cancelAnimationFrame(raf);
|
|
126
|
+
observer?.disconnect();
|
|
127
|
+
window.removeEventListener("resize", applyLayout);
|
|
128
|
+
};
|
|
129
|
+
}, [visible]);
|
|
130
|
+
(0, react.useEffect)(() => () => {
|
|
131
|
+
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
132
|
+
}, []);
|
|
133
|
+
if (!visible) return null;
|
|
134
|
+
const onJump = (node) => {
|
|
135
|
+
if (current === void 0) return;
|
|
136
|
+
setJumpingKey(node.key);
|
|
137
|
+
props.jump(current, node.key);
|
|
138
|
+
window.setTimeout(() => setJumpingKey((k) => k === node.key ? null : k), 600);
|
|
139
|
+
};
|
|
140
|
+
const t = props.t;
|
|
141
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
142
|
+
ref: panelRef,
|
|
143
|
+
className: question_nav_module_css_default.rail,
|
|
144
|
+
"data-question-nav": "rail",
|
|
145
|
+
children: [
|
|
146
|
+
hint !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
147
|
+
className: question_nav_module_css_default.hint,
|
|
148
|
+
role: "status",
|
|
149
|
+
children: hint
|
|
150
|
+
}) : null,
|
|
151
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
152
|
+
className: question_nav_module_css_default.list,
|
|
153
|
+
children: questions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
154
|
+
className: question_nav_module_css_default.empty,
|
|
155
|
+
children: t("strip.empty")
|
|
156
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
157
|
+
className: question_nav_module_css_default.dots,
|
|
158
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
159
|
+
className: question_nav_module_css_default.count,
|
|
160
|
+
children: questions.length
|
|
161
|
+
}), questions.map((node) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
162
|
+
className: jumpingKey === node.key ? `${question_nav_module_css_default.dot} ${question_nav_module_css_default.active}` : question_nav_module_css_default.dot,
|
|
163
|
+
"aria-label": node.text,
|
|
164
|
+
onMouseEnter: (e) => {
|
|
165
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
166
|
+
setTooltip({
|
|
167
|
+
text: node.text,
|
|
168
|
+
left: r.right + 10,
|
|
169
|
+
top: r.top
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
onMouseLeave: () => setTooltip(null),
|
|
173
|
+
onClick: () => onJump(node)
|
|
174
|
+
}, node.key))]
|
|
175
|
+
})
|
|
176
|
+
}),
|
|
177
|
+
tooltip !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
178
|
+
className: question_nav_module_css_default.tooltip,
|
|
179
|
+
style: {
|
|
180
|
+
left: tooltip.left,
|
|
181
|
+
top: tooltip.top
|
|
182
|
+
},
|
|
183
|
+
children: tooltip.text
|
|
184
|
+
}), document.body) : null
|
|
185
|
+
]
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/client/locales.ts
|
|
190
|
+
/**
|
|
191
|
+
* Locale dictionaries for the question-nav surface (zh/en). Registered under
|
|
192
|
+
* the `question-nav` namespace; keys are consumed through the bound translator.
|
|
193
|
+
*/
|
|
194
|
+
const zh = {
|
|
195
|
+
"strip.empty": "本会话还没有提问",
|
|
196
|
+
"jump.inactive": "聊天视图未激活",
|
|
197
|
+
"jump.hidden": "目标无独立气泡,已定位到邻近内容",
|
|
198
|
+
"jump.notfound": "目标未加载或不存在(可能已压缩)",
|
|
199
|
+
"jump.timeout": "加载历史超时,可重试"
|
|
200
|
+
};
|
|
201
|
+
const en = {
|
|
202
|
+
"strip.empty": "No questions in this session yet",
|
|
203
|
+
"jump.inactive": "Chat view is not active",
|
|
204
|
+
"jump.hidden": "No dedicated bubble; landed on nearby content",
|
|
205
|
+
"jump.notfound": "Target not loaded or missing (maybe compacted)",
|
|
206
|
+
"jump.timeout": "Timed out loading history; retry"
|
|
207
|
+
};
|
|
208
|
+
//#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
|
+
//#region src/core/jump.ts
|
|
255
|
+
/**
|
|
256
|
+
* Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot
|
|
257
|
+
* read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the
|
|
258
|
+
* paging/timeout/fallback loop is unit-testable without a real browser or
|
|
259
|
+
* session. The browser half wires these ports to ctx.sessions + the DOM.
|
|
260
|
+
*/
|
|
261
|
+
const DEFAULTS = {
|
|
262
|
+
totalTimeoutMs: 15e3,
|
|
263
|
+
maxPages: 100,
|
|
264
|
+
rowWaitMs: 8e3,
|
|
265
|
+
pollMs: 60
|
|
266
|
+
};
|
|
267
|
+
function minAnchorSeq(rows) {
|
|
268
|
+
let min = null;
|
|
269
|
+
for (const row of rows) if (min === null || row.anchorSeq < min) min = row.anchorSeq;
|
|
270
|
+
return min;
|
|
271
|
+
}
|
|
272
|
+
function renderable(rows) {
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const row of rows) {
|
|
275
|
+
if (row.visibility === "hidden") continue;
|
|
276
|
+
out.push({
|
|
277
|
+
key: row.key,
|
|
278
|
+
anchorSeq: row.anchorSeq
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Jump to the row for `key`, paging older content until it is rendered (or the
|
|
285
|
+
* budget is exhausted). Falls back to the nearest renderable row when the
|
|
286
|
+
* exact row is hidden/absent.
|
|
287
|
+
*/
|
|
288
|
+
async function jumpToQuestion(ports, key, options = {}) {
|
|
289
|
+
const cfg = {
|
|
290
|
+
...DEFAULTS,
|
|
291
|
+
...options
|
|
292
|
+
};
|
|
293
|
+
const fail = (code, fallback = false) => {
|
|
294
|
+
ports.report?.(code, fallback);
|
|
295
|
+
return fallback ? {
|
|
296
|
+
ok: false,
|
|
297
|
+
code,
|
|
298
|
+
fallback: true
|
|
299
|
+
} : {
|
|
300
|
+
ok: false,
|
|
301
|
+
code
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
if (!ports.isViewActive()) return fail("VIEW_INACTIVE");
|
|
305
|
+
const deadline = ports.now() + cfg.totalTimeoutMs;
|
|
306
|
+
let pages = 0;
|
|
307
|
+
while (true) {
|
|
308
|
+
const snap = ports.snapshot();
|
|
309
|
+
if (snap === void 0) return fail("VIEW_INACTIVE");
|
|
310
|
+
const rows = renderable(snap.rows);
|
|
311
|
+
if (rows.some((r) => r.key === key)) break;
|
|
312
|
+
if (snap.openState !== "open") {
|
|
313
|
+
if (snap.openState === "error" || ports.now() > deadline) return fail(snap.openState === "error" ? "VIEW_INACTIVE" : "TIMEOUT");
|
|
314
|
+
await ports.sleep(cfg.pollMs);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (snap.hasMore !== true) return fail("NOT_FOUND");
|
|
318
|
+
if (pages >= cfg.maxPages || ports.now() > deadline) return fail("TIMEOUT");
|
|
319
|
+
if (snap.loadingOlder) {
|
|
320
|
+
await ports.sleep(cfg.pollMs);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const before = minAnchorSeq(rows);
|
|
324
|
+
await ports.loadOlder();
|
|
325
|
+
pages += 1;
|
|
326
|
+
const afterSnap = ports.snapshot();
|
|
327
|
+
const after = minAnchorSeq(afterSnap === void 0 ? [] : afterSnap.rows);
|
|
328
|
+
if (after === null || before !== null && after >= before) return fail("NOT_FOUND");
|
|
329
|
+
}
|
|
330
|
+
const waitedFor = async (rowKey) => {
|
|
331
|
+
for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {
|
|
332
|
+
if (!ports.isViewActive()) return null;
|
|
333
|
+
const row = ports.findRow(rowKey);
|
|
334
|
+
if (row !== null) return row;
|
|
335
|
+
await ports.sleep(cfg.pollMs);
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
};
|
|
339
|
+
const row = await waitedFor(key);
|
|
340
|
+
if (row !== null) {
|
|
341
|
+
ports.scrollIntoView(row);
|
|
342
|
+
return { ok: true };
|
|
343
|
+
}
|
|
344
|
+
const snap = ports.snapshot();
|
|
345
|
+
const fallback = nearestRenderable(snap === void 0 ? [] : snap.rows, key);
|
|
346
|
+
if (fallback !== null) {
|
|
347
|
+
const fbRow = await waitedFor(fallback.key);
|
|
348
|
+
if (fbRow !== null) {
|
|
349
|
+
ports.scrollIntoView(fbRow);
|
|
350
|
+
return fail("TARGET_HIDDEN", true);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return fail("TARGET_HIDDEN", false);
|
|
354
|
+
}
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/client/index.ts
|
|
357
|
+
/** Locale namespace this plugin owns. */
|
|
358
|
+
const NS = "question-nav";
|
|
359
|
+
/** Services required by this plugin. */
|
|
360
|
+
const inject = [
|
|
361
|
+
"slots",
|
|
362
|
+
"locale",
|
|
363
|
+
"sessions"
|
|
364
|
+
];
|
|
365
|
+
function claimApply() {
|
|
366
|
+
if (globalThis.__dshQuestionNavApplied === true) return false;
|
|
367
|
+
globalThis.__dshQuestionNavApplied = true;
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
function releaseApply() {
|
|
371
|
+
globalThis.__dshQuestionNavApplied = void 0;
|
|
372
|
+
}
|
|
373
|
+
/** Map the session snapshot to the jump-loop port surface. */
|
|
374
|
+
function jumpPortsFor(ctx, sessionId) {
|
|
375
|
+
return {
|
|
376
|
+
snapshot: () => {
|
|
377
|
+
const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot();
|
|
378
|
+
if (snap === void 0) return void 0;
|
|
379
|
+
return {
|
|
380
|
+
openState: snap.openState,
|
|
381
|
+
hasMore: snap.hasMore,
|
|
382
|
+
loadingOlder: snap.loadingOlder,
|
|
383
|
+
rows: snap.chat.nodes.values()
|
|
384
|
+
};
|
|
385
|
+
},
|
|
386
|
+
loadOlder: async () => {
|
|
387
|
+
const binding = ctx.sessions.binding(sessionId);
|
|
388
|
+
if (binding === void 0) throw new Error("session unavailable");
|
|
389
|
+
await binding.session.loadOlder();
|
|
390
|
+
},
|
|
391
|
+
isViewActive: () => document.querySelector("[data-chat-flow]") !== null,
|
|
392
|
+
findRow: (key) => {
|
|
393
|
+
for (const candidate of Array.from(document.querySelectorAll("[data-chat-anchor-key]"))) if (candidate.dataset.chatAnchorKey === key) return candidate;
|
|
394
|
+
return null;
|
|
395
|
+
},
|
|
396
|
+
scrollIntoView: (row) => row.scrollIntoView({ block: "start" }),
|
|
397
|
+
now: () => Date.now(),
|
|
398
|
+
sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function createInject(ctx) {
|
|
402
|
+
return {
|
|
403
|
+
readQuestions: (sessionId) => {
|
|
404
|
+
const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot();
|
|
405
|
+
if (snap === void 0) return [];
|
|
406
|
+
return extractQuestions(snap.chat.nodes.values());
|
|
407
|
+
},
|
|
408
|
+
subscribeList: (cb) => ctx.sessions.list.subscribe(cb),
|
|
409
|
+
subscribeContent: (sessionId, cb) => {
|
|
410
|
+
const binding = ctx.sessions.binding(sessionId);
|
|
411
|
+
if (binding === void 0) return () => {};
|
|
412
|
+
return binding.session.subscribe(cb);
|
|
413
|
+
},
|
|
414
|
+
jump: (sessionId, key) => {
|
|
415
|
+
const ports = jumpPortsFor(ctx, sessionId);
|
|
416
|
+
ports.report = (code) => {
|
|
417
|
+
window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
|
|
418
|
+
};
|
|
419
|
+
jumpToQuestion(ports, key);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Register the question-nav surface.
|
|
425
|
+
* @param ctx - client root context.
|
|
426
|
+
*/
|
|
427
|
+
function apply(ctx) {
|
|
428
|
+
if (!claimApply()) return;
|
|
429
|
+
ctx.effect(() => releaseApply, "question-nav: apply claim");
|
|
430
|
+
ctx.effect(() => ctx.locale.register(NS, {
|
|
431
|
+
zh,
|
|
432
|
+
en
|
|
433
|
+
}), "question-nav: dictionaries");
|
|
434
|
+
const injected = createInject(ctx);
|
|
435
|
+
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
436
|
+
name: "shell.overlay",
|
|
437
|
+
id: "question-nav",
|
|
438
|
+
order: 900,
|
|
439
|
+
locale: NS,
|
|
440
|
+
inject: () => injected
|
|
441
|
+
}, QuestionNavStrip));
|
|
442
|
+
}
|
|
443
|
+
//#endregion
|
|
444
|
+
exports.apply = apply;
|
|
445
|
+
exports.inject = inject;
|
|
446
|
+
return module.exports;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
//# sourceMappingURL=client.js.map
|