@cxx42/dsh-plugin-user-question-nav 1.0.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 +129 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +115 -0
- package/lib/index.js +28 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# dsh-plugin-user-question-nav
|
|
2
|
+
|
|
3
|
+
一个 DeepSeek Harness **桌面 bundle 插件**(客户端 UI 插件):在会话**右侧**渲染一列竖直小圆按钮,从上到下一一对应已加载的每一条**用户提问**;点击某个按钮把会话滚动区平滑滚动到该提问处。
|
|
4
|
+
|
|
5
|
+
> 对标 [chat.deepseek.com](https://chat.deepseek.com/) 官网右侧的“问题定位”导航,解决长会话手动滚动太累的问题。
|
|
6
|
+
|
|
7
|
+
本插件是**纯客户端**,不需要 Host 端能力。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 包结构
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
dsh-plugin-user-question-nav/
|
|
15
|
+
├── package.json # 桌面 bundle 插件清单(dsh.bundle + dsh.client)
|
|
16
|
+
├── cordis.patch.yml # 客户端行插入示意(本机设为 no-op,见下文“加载方式”)
|
|
17
|
+
├── lib/
|
|
18
|
+
│ ├── index.js # Host 半(空 —— 纯客户端插件)
|
|
19
|
+
│ └── client.js # Client 半(浏览器 bundle,实现导航条)
|
|
20
|
+
├── install.ps1 # 注册到 profile 的幂等脚本(写入无 BOM)
|
|
21
|
+
├── README.md
|
|
22
|
+
└── dynamic-form/ # 备选:动态 Cordis 插件形式(client.js / host.js)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 关键字段(package.json)
|
|
26
|
+
|
|
27
|
+
- `dsh.bundle.patch: "./cordis.patch.yml"` —— 桌面 bundle 的补丁声明。
|
|
28
|
+
- `dsh.client: { platform: "web", inject: [...] }` —— 使 `client-modules` 节点把本包扫进 `window.__DSH_BOOT__` 的浏览器清单。
|
|
29
|
+
- `exports["."]` → `./lib/index.js`(Host),`exports["./client"]` → `./lib/client.js`(浏览器 bundle)。
|
|
30
|
+
- `peerDependencies`:`react`(平台种子模块)、`@deepseek-ai/dsh-client-runtime`。
|
|
31
|
+
|
|
32
|
+
### 原理
|
|
33
|
+
|
|
34
|
+
- Client 半读取会话快照 `snapshot.chat`(`order` + `nodes`),筛选 `node.kind === "user"` / `"steering"` 的提问,渲染按钮。
|
|
35
|
+
- 点击按钮用 `[data-conversation-scroll]` 与 `[data-chat-anchor-key="<node.key>"]` 计算偏移并 `scrollTo({behavior:"smooth"})`。
|
|
36
|
+
- 挂载在会话级槽位 `conversation.session.header.utilities`(list / session scope),通过 `ctx.slots.inject` 等待槽位声明,避免加载顺序问题。
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 如何安装(desktop 与 `dsh --port` web 均适用)
|
|
41
|
+
|
|
42
|
+
1. **确认包名**:`dsh-plugin-user-question-nav`,本地路径
|
|
43
|
+
`C:/Users/cxx/Documents/DSHWorkspace/dsh_project2/dsh-plugin-user-question-nav`。
|
|
44
|
+
|
|
45
|
+
2. **编辑 profile 清单** `~/.dsh/profiles/desktop/package.json`:
|
|
46
|
+
- 在 `dependencies` 加:
|
|
47
|
+
```json
|
|
48
|
+
"dsh-plugin-user-question-nav": "file:C:/Users/cxx/Documents/DSHWorkspace/dsh_project2/dsh-plugin-user-question-nav"
|
|
49
|
+
```
|
|
50
|
+
- 在 `dsh.profile.bundles` 加:
|
|
51
|
+
```json
|
|
52
|
+
"dsh-plugin-user-question-nav"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
3. **安装依赖**(在 profile 目录里):
|
|
56
|
+
```pwsh
|
|
57
|
+
Push-Location "$env:USERPROFILE\.dsh\profiles\desktop"
|
|
58
|
+
pnpm install
|
|
59
|
+
Pop-Location
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
4. **在 profile 的用户补丁层 `~/.dsh/profiles/desktop/cordis.patch.yml` 追加客户端行**(见下文“加载方式”,这是生效的**唯一**插入来源):
|
|
63
|
+
```yaml
|
|
64
|
+
- insert:
|
|
65
|
+
- id: ui-user-question-nav
|
|
66
|
+
name: 'dsh-plugin-user-question-nav'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
5. **重启桌面 / 重新运行 `dsh --port <N>`**,让 profile 在启动时重新组装。
|
|
70
|
+
|
|
71
|
+
> 也可直接运行本目录的安装脚本 `install.ps1`。它会幂等完成上面第 2、3 步(清单编辑 + pnpm 安装),并且**以无 BOM 的 UTF-8 写清单**(历史上 PowerShell 5 的 `Set-Content -Encoding UTF8` 会写 BOM,导致桌面 `JSON.parse` 崩溃)。
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 重要:本机加载方式与“副本同步”(已踩过的坑)
|
|
76
|
+
|
|
77
|
+
### 唯一插入来源 = profile 用户补丁层
|
|
78
|
+
|
|
79
|
+
在这台 desktop 上,**profile bundle 的 `- insert:` 不会被可靠地推进到浏览器清单**(证据:同样注册在 profile 里的 `dsh-context`、`@linxin666/dsh-web-ui-all` 也没进 `window.__DSH_BOOT__`;它们走的是 `dsh-market` 热挂载的另一条路)。
|
|
80
|
+
|
|
81
|
+
所以本插件的客户端行**只在** `~/.dsh/profiles/desktop/cordis.patch.yml`(用户补丁层,最后合成)里插入一次;而 **bundle 自己的 `cordis.patch.yml` 被置为 no-op**(仅注释,无 `insert`),避免重复。
|
|
82
|
+
|
|
83
|
+
> 如果你在别的部署里 profile bundle 的 `insert` 能正常进图,可以把 bundle 的 `cordis.patch.yml` 恢复为 `- insert:`,并删掉 profile 用户补丁层那行,改成单一来源。
|
|
84
|
+
|
|
85
|
+
### `duplicate loader entry id: ui-user-question-nav` 的成因
|
|
86
|
+
|
|
87
|
+
如果**同时**存在两处 insert(bundle 的 `cordis.patch.yml` + profile 用户补丁层),`dsh --port 3081` 会报:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
failed to apply loader entry include (cordis:include):
|
|
91
|
+
duplicate loader entry id: ui-user-question-nav
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
修复:**只保留一处**生效插入(本机保留用户补丁层那处)。改完后 `dsh --port 3081` 能通过组合,直到绑定端口(`listen EACCES` 是环境/端口限制,与插件无关)。
|
|
95
|
+
|
|
96
|
+
### `file:` 依赖是复制,不是符号链接 —— 改代码后要重新同步
|
|
97
|
+
|
|
98
|
+
pnpm 对 `file:` 依赖默认**复制**到 `~/.dsh/profiles/desktop/node_modules/dsh-plugin-user-question-nav`(不是链接)。所以改过工作区里的 `lib/client.js`、`package.json`、`cordis.patch.yml` 后,**必须重新同步副本**:
|
|
99
|
+
|
|
100
|
+
```pwsh
|
|
101
|
+
Push-Location "$env:USERPROFILE\.dsh\profiles\desktop"
|
|
102
|
+
pnpm install # 或用 install.ps1,会重装 file: 依赖并重写清单(无 BOM)
|
|
103
|
+
Pop-Location
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
否则桌面/`dsh` 会继续用旧副本。
|
|
107
|
+
|
|
108
|
+
### 禁用请注意
|
|
109
|
+
|
|
110
|
+
bundle 在 `dsh.profile.bundles` 里是可修改项,可在桌面“插件清单”禁用。但因为客户端行来自 profile 用户补丁层(不在 bundle 的补丁里),**禁用该 bundle 不会移除那行 insert**。要真正关掉导航条,需删掉/注释掉 `~/.dsh/profiles/desktop/cordis.patch.yml` 里那行。
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## 备选:动态 Cordis 插件形式
|
|
115
|
+
|
|
116
|
+
`dynamic-form/` 里是同一功能的**动态插件**版本(`code.client` / `code.host`),适合用 `cordis_define` + `cordis_run` 即改即用(无需安装/重启)。两者 Client 逻辑一致;动态版用 `styles.insert(css)` 注入样式,桌面 bundle 版按官方 bundle 规范用 `<style>` 标签注入。
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 依赖与约定
|
|
121
|
+
|
|
122
|
+
- Client 全局/模块:浏览器 module 系统提供 `react`(平台种子),`ctx.slots`(声明 `inject: ["slots"]`)。
|
|
123
|
+
- 样式全部使用 DSH 主题变量(`--dsw-alias-*`),自动适配亮/暗色。
|
|
124
|
+
- 遵守约定:不操作 `document.body` / `window`,仅用平台暴露的功能性数据属性(`[data-conversation-scroll]`、`[data-chat-anchor-key]`)做滚动定位。
|
|
125
|
+
|
|
126
|
+
## 局限
|
|
127
|
+
|
|
128
|
+
- 只对**已加载**的会话节点导航(历史分页更早的消息不在 DOM 中)。
|
|
129
|
+
- 导航条为固定定位、右侧垂直居中;无提问时自动隐藏。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @cxx42/dsh-plugin-user-question-nav bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# Self-activating: a web client row naming this package, so Cordis loads it as a
|
|
4
|
+
# plugin (Host half via ./lib/index.js) and the client-modules node half scans
|
|
5
|
+
# this package (it declares dsh.client) into window.__DSH_BOOT__; the browser
|
|
6
|
+
# then loads ./lib/client.js and renders the rail.
|
|
7
|
+
|
|
8
|
+
- insert:
|
|
9
|
+
- id: ui-user-question-nav
|
|
10
|
+
name: '@cxx42/dsh-plugin-user-question-nav'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @cxx42/dsh-plugin-user-question-nav — Client half (browser bundle).
|
|
2
|
+
//
|
|
3
|
+
// This is a dsh.client bundle: it is served as /plugins/@cxx42/dsh-plugin-user-question-nav/client.js
|
|
4
|
+
// and loaded into the browser module system. It uses `window.__ModuleLoader__.load({id, factory})`,
|
|
5
|
+
// takes `require` (which resolves platform seeds like 'react'), and exports the standard
|
|
6
|
+
// client-plugin face: `inject` (service dependencies) + `apply(ctx)`.
|
|
7
|
+
//
|
|
8
|
+
// Feature: on the right side of a session, render a vertical rail of small buttons,
|
|
9
|
+
// one per user-posed question (in order). Click a button to scroll the conversation
|
|
10
|
+
// scrollport to that user message.node.
|
|
11
|
+
|
|
12
|
+
window.__ModuleLoader__.load({
|
|
13
|
+
id: "@cxx42/dsh-plugin-user-question-nav",
|
|
14
|
+
factory: (require) => {
|
|
15
|
+
var module = { exports: {} };
|
|
16
|
+
var exports = module.exports;
|
|
17
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
18
|
+
let react = require("react");
|
|
19
|
+
|
|
20
|
+
// The conversation message slot this plugin registers into (declared by
|
|
21
|
+
// @deepseek-ai/dsh-client-ui-conversation). Session-scoped list slot.
|
|
22
|
+
const SLOT = "conversation.session.header.utilities";
|
|
23
|
+
|
|
24
|
+
// ── styles (theme variables; kept as compact CSS) ─────────────────────
|
|
25
|
+
const RAIL_CSS = ".qn-rail{position:fixed;right:12px;top:50%;transform:translateY(-50%);z-index:40;display:flex;flex-direction:column;gap:6px;align-items:center;padding:8px 5px;border-radius:999px;pointer-events:auto;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);box-shadow:var(--dsw-shadow-lv2)}.qn-rail-title{writing-mode:vertical-rl;font-family:var(--dsw-font-family);font-size:10px;line-height:1;letter-spacing:1px;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);margin-bottom:2px;user-select:none}.qn-dot{width:22px;height:22px;border:none;border-radius:999px;padding:0;background:var(--dsw-alias-interactive-bg-hover-solid);color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family);font-size:11px;font-weight:500;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s,color .12s,transform .12s}.qn-dot:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);transform:scale(1.1)}.qn-dot:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}";
|
|
26
|
+
const CSS_TAG_ID = "@cxx42/dsh-plugin-user-question-nav/rail.css";
|
|
27
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_TAG_ID) + "]") === null) {
|
|
28
|
+
const tag = document.createElement("style");
|
|
29
|
+
tag.dataset.plugin = "@cxx42/dsh-plugin-user-question-nav";
|
|
30
|
+
tag.dataset.pluginCss = CSS_TAG_ID;
|
|
31
|
+
tag.textContent = RAIL_CSS;
|
|
32
|
+
document.head.appendChild(tag);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── derive the ordered list of user-question nodes from the chat snapshot ──
|
|
36
|
+
function railQuestions(order, nodes) {
|
|
37
|
+
const out = [];
|
|
38
|
+
if (!order) return out;
|
|
39
|
+
for (let i = 0; i < order.length; i++) {
|
|
40
|
+
const key = order[i];
|
|
41
|
+
const node = nodes && nodes.get ? nodes.get(key) : undefined;
|
|
42
|
+
if (!node) continue;
|
|
43
|
+
const kind = node.kind || (node.data && node.data.kind);
|
|
44
|
+
if (kind !== "user" && kind !== "steering") continue;
|
|
45
|
+
const text = node.data && node.data.content != null ? String(node.data.content) : "";
|
|
46
|
+
out.push({ key, text });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── scroll the conversation scrollport to a specific message node ─────
|
|
52
|
+
function railScrollTo(key) {
|
|
53
|
+
const scroller = document.querySelector("[data-conversation-scroll]");
|
|
54
|
+
if (!scroller) return;
|
|
55
|
+
const anchor = scroller.querySelector('[data-chat-anchor-key="' + key + '"]');
|
|
56
|
+
if (!anchor) return;
|
|
57
|
+
const top = anchor.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
|
|
58
|
+
scroller.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const EMPTY_ORDER = [];
|
|
62
|
+
|
|
63
|
+
// ── the rail component (standard session kit gives it `useSession`) ────
|
|
64
|
+
function QuestionNavRail(props) {
|
|
65
|
+
const order = props.useSession((snapshot) => (snapshot.chat && snapshot.chat.order) || EMPTY_ORDER);
|
|
66
|
+
const nodes = props.useSession((snapshot) => snapshot.chat && snapshot.chat.nodes);
|
|
67
|
+
const questions = railQuestions(order, nodes);
|
|
68
|
+
if (questions.length === 0) return null;
|
|
69
|
+
|
|
70
|
+
const buttons = [];
|
|
71
|
+
for (let i = 0; i < questions.length; i++) {
|
|
72
|
+
const q = questions[i];
|
|
73
|
+
buttons.push(react.createElement("button", {
|
|
74
|
+
key: q.key,
|
|
75
|
+
type: "button",
|
|
76
|
+
className: "qn-dot",
|
|
77
|
+
title: q.text || "#" + (i + 1),
|
|
78
|
+
onClick: (function (key) {
|
|
79
|
+
return function () { railScrollTo(key); };
|
|
80
|
+
})(q.key),
|
|
81
|
+
children: String(i + 1)
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return react.createElement("div", {
|
|
86
|
+
className: "qn-rail",
|
|
87
|
+
role: "navigation",
|
|
88
|
+
"aria-label": "问题导航"
|
|
89
|
+
},
|
|
90
|
+
react.createElement("div", { className: "qn-rail-title" }, "问题"),
|
|
91
|
+
buttons
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Services required by the client plugin. */
|
|
96
|
+
const inject = ["slots"];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Mount the client plugin into the conversation's session header utilities
|
|
100
|
+
* slot. `slots.inject` waits for the slot to be declared by the conversation
|
|
101
|
+
* package, so load order does not matter.
|
|
102
|
+
* @param {any} ctx - the client Cordis context.
|
|
103
|
+
*/
|
|
104
|
+
function apply(ctx) {
|
|
105
|
+
ctx.slots.inject(SLOT, () => ctx.slots.register({
|
|
106
|
+
name: SLOT,
|
|
107
|
+
id: "user-question-nav"
|
|
108
|
+
}, QuestionNavRail));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
exports.inject = inject;
|
|
112
|
+
exports.apply = apply;
|
|
113
|
+
return module.exports;
|
|
114
|
+
}
|
|
115
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// dsh-plugin-user-question-nav — Host half.
|
|
2
|
+
//
|
|
3
|
+
// This plugin is a pure client-side UI plugin: it reads the conversation
|
|
4
|
+
// snapshot and scrolls the chat, all on the browser side. It needs no Host
|
|
5
|
+
// capability, so the Host half is empty.
|
|
6
|
+
//
|
|
7
|
+
// Cordis loads this module as a plugin because the bundle's cordis.patch.yml
|
|
8
|
+
// inserts a row naming this package. The client-modules node half separately
|
|
9
|
+
// scans this package (it declares dsh.client) into window.__DSH_BOOT__, so the
|
|
10
|
+
// browser also loads lib/client.js as the client half.
|
|
11
|
+
//
|
|
12
|
+
// Module-plugin contract (same shape as the official dsh-client-ui-* packages):
|
|
13
|
+
// export `name`, `inject`, and `apply`.
|
|
14
|
+
|
|
15
|
+
/** Cordis plugin identity (label only). */
|
|
16
|
+
export const name = '@cxx42/dsh-plugin-user-question-nav';
|
|
17
|
+
|
|
18
|
+
/** Services that must exist before apply() runs. */
|
|
19
|
+
export const inject = [];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mount the Host half. Empty — the client half does all the work.
|
|
23
|
+
* @param {unknown} ctx - the plugin's Cordis context (unused here).
|
|
24
|
+
* @param {unknown} [config] - this row's `config:` block (unused here).
|
|
25
|
+
*/
|
|
26
|
+
export function apply(ctx, config) {
|
|
27
|
+
// intentionally empty
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cxx42/dsh-plugin-user-question-nav",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DeepSeek Harness web plugin: a right-side navigation rail of user-question buttons that scrolls the chat to each user message (like chat.deepseek.com).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib/**",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"dsh",
|
|
20
|
+
"deepseek-harness",
|
|
21
|
+
"plugin",
|
|
22
|
+
"conversation",
|
|
23
|
+
"navigation",
|
|
24
|
+
"client"
|
|
25
|
+
],
|
|
26
|
+
"dsh": {
|
|
27
|
+
"bundle": {
|
|
28
|
+
"patch": "./cordis.patch.yml"
|
|
29
|
+
},
|
|
30
|
+
"client": {
|
|
31
|
+
"platform": "web",
|
|
32
|
+
"inject": [
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"react": "^18.0.0",
|
|
39
|
+
"@deepseek-ai/dsh-client-runtime": "*"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|