@kirkchinese/dsh-citeciter 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CiteCiter contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # `@kirkchinese/dsh-citeciter`
2
+
3
+ CiteCiter is a browser plugin for DeepSeek Harness (DSH) Web. Select text in a completed assistant reply, choose `Citer!` from the context menu, and read a context-aware explanation in the resizable DSH details column.
4
+
5
+ [中文说明](https://github.com/kirkchinese/CiteCiter/blob/main/packages/citeciter/README.zh.md) · [GitHub](https://github.com/kirkchinese/CiteCiter) · [Issues](https://github.com/kirkchinese/CiteCiter/issues)
6
+
7
+ > **Early development:** v0.1.0 implements the first usable path, but many features still need work. APIs, compatibility, and installation details may change. Issues and pull requests are welcome.
8
+
9
+ ## Install
10
+
11
+ CiteCiter requires Node.js `^22.19.0 || >=24.0.0`, DSH Web, and a configured model provider. It is developed and tested against the DSH `0.1.0-rc.6` client packages.
12
+
13
+ ```sh
14
+ dsh plugin --profile web add @kirkchinese/dsh-citeciter
15
+ ```
16
+
17
+ Restart the corresponding DSH Web process and refresh the page. Select text inside a completed assistant reply and right-click to open `Citer!`.
18
+
19
+ ## Behavior
20
+
21
+ - The plugin accepts selections only inside an `assistant-step` conversation node.
22
+ - It resolves the DOM anchor key through the active parent-session snapshot and forks at that node's real `anchorSeq`. It never treats the numeric prefix of a conversation key as an event sequence.
23
+ - The child opens without becoming the current session. CiteCiter runs `/permission read-only` and sends the explanation prompt only when DSH reports both command success and a matched command.
24
+ - A child is reused only while both the parent session and resolved anchor remain unchanged. Repeated requests baseline existing assistant nodes so an earlier answer cannot be shown as the new result.
25
+ - The parent session is never written by CiteCiter. The child log owns the prompt, model response, cancellation, and errors.
26
+ - The panel streams Markdown, KaTeX, and code. Complete safe `svg` fences become inert data-URI images. Complete `html` fences render in a script-free, network-free sandboxed iframe. Rejected or incomplete fences remain Markdown code blocks.
27
+
28
+ Forked explanation sessions are durable DSH sessions. Closing the panel or unloading the plugin detaches CiteCiter's subscription but does not delete or cancel an already-created child.
29
+
30
+ ## Compatibility
31
+
32
+ The peer range is `^0.1.0-rc.6` for the required DSH client packages and `^4.0.1` for Cordis. The `0.1.0-rc.6` packages were verified as published on the public npm registry and are the current test baseline. Other DSH prerelease versions have not received the same validation.
33
+
34
+ ## Development
35
+
36
+ From the repository root:
37
+
38
+ ```sh
39
+ pnpm install
40
+ pnpm --filter @kirkchinese/dsh-citeciter typecheck
41
+ pnpm --filter @kirkchinese/dsh-citeciter test
42
+ pnpm --filter @kirkchinese/dsh-citeciter build
43
+ ```
44
+
45
+ The test script rebuilds declaration modules before running the Node test suite. Browser fixture, smoke, and client-HMR instructions are maintained in the repository's [verification guide](https://github.com/kirkchinese/CiteCiter/blob/main/docs/implementation-milestones.md).
46
+
47
+ ## Contributing and license
48
+
49
+ CiteCiter is intentionally being published early so the community can help shape it. Please open an [issue](https://github.com/kirkchinese/CiteCiter/issues) or submit a pull request. Contributor rules are in the repository [AGENTS.md](https://github.com/kirkchinese/CiteCiter/blob/main/AGENTS.md).
50
+
51
+ MIT © CiteCiter contributors
package/README.zh.md ADDED
@@ -0,0 +1,79 @@
1
+ # `@kirkchinese/dsh-citeciter`
2
+
3
+ CiteCiter 是 DeepSeek Harness(DSH)Web 的浏览器插件。选中已经完成的助手回复,右键点击 `Citer!`,即可在可调宽的 DSH `details` 右栏中查看基于当时会话上下文生成的解释。
4
+
5
+ [English](README.md) · [GitHub](https://github.com/kirkchinese/CiteCiter) · [问题反馈](https://github.com/kirkchinese/CiteCiter/issues)
6
+
7
+ > **开发状态:** v0.1.0 实现了第一条可用通路,但当前仍处于早期开发阶段,很多功能仍然需要完善。API、兼容范围和安装方式可能发生变化,欢迎提 Issue 或提交 PR 共同开发。
8
+
9
+ ## 安装
10
+
11
+ 需要 Node.js `^22.19.0 || >=24.0.0`、DSH Web 和已经配置好的模型提供方。当前开发与验证基准为 DSH `0.1.0-rc.6` client 包。
12
+
13
+ ```sh
14
+ dsh plugin --profile web add @kirkchinese/dsh-citeciter
15
+ ```
16
+
17
+ 重新启动对应的 DSH Web 进程并刷新页面。然后在一条已经完成的助手回复中选择文本,右键点击 `Citer!`。
18
+
19
+ ## 行为
20
+
21
+ - 只处理 `assistant-step` 会话节点内的选区。
22
+ - 先用 DOM anchor key 在父会话 snapshot 中查找节点,再用该节点真实的 `anchorSeq` 在已完成轮次边界 fork;不会把会话 key 开头的 kind 长度误当成事件序号。
23
+ - 子会话打开但不会成为当前会话。只有 `/permission read-only` 执行成功且 DSH 确认命令确实匹配后,插件才发送解释提示词。
24
+ - 仅当父会话与解析后的 anchor 都没有变化时才复用子会话。重复请求会先记录已有助手节点,避免把旧回答误当成新结果。
25
+ - 插件不向父会话写入内容。提示词、模型回复、取消动作和错误都属于解释子会话日志。
26
+ - 面板流式渲染 Markdown、KaTeX 和代码。完整且安全的 `svg` 围栏作为惰性 data-URI 图片渲染;完整 `html` 围栏在禁脚本、禁网络的 sandbox iframe 中渲染。被拒绝或未闭合的围栏仍按 Markdown 代码块显示。
27
+
28
+ 解释子会话是持久 DSH 会话。关闭面板或卸载插件只会解除 CiteCiter 的本地订阅,不会删除或取消已经创建的子会话。
29
+
30
+ ## 兼容性与限制
31
+
32
+ DSH client peer range 为 `^0.1.0-rc.6`,Cordis peer range 为 `^4.0.1`。发布前已确认所需 `0.1.0-rc.6` 包真实存在于公共 npm registry,并以该版本完成验证;其他 DSH 预发布版本尚未获得相同程度的验证。
33
+
34
+ 当前只支持 DSH Web 和助手回复节点。插件尚无设置 UI、自动子会话清理、移动端专项适配或跨平台浏览器 CI;DSH 预发布 API 的变化也可能要求同步升级 CiteCiter。
35
+
36
+ ## 构建与测试
37
+
38
+ 在仓库根目录运行:
39
+
40
+ ```sh
41
+ pnpm install
42
+ pnpm --filter @kirkchinese/dsh-citeciter typecheck
43
+ pnpm --filter @kirkchinese/dsh-citeciter test
44
+ pnpm --filter @kirkchinese/dsh-citeciter build
45
+ ```
46
+
47
+ ## 本地浏览器验证(临时 DSH_HOME)
48
+
49
+ 先为 Playwright 安装 Chromium,再建立临时 profile:
50
+
51
+ ```sh
52
+ pnpm --filter @kirkchinese/dsh-citeciter exec playwright install chromium
53
+ mkdir -p /tmp/citeciter-dsh-home/profiles/node_modules/@kirkchinese
54
+ ln -sfn "$(pwd)/packages/citeciter" \
55
+ /tmp/citeciter-dsh-home/profiles/node_modules/@kirkchinese/dsh-citeciter
56
+ node packages/citeciter/dev/seed-smoke-session.mjs /tmp/citeciter-dsh-home "$(pwd)"
57
+ DSH_HOME=/tmp/citeciter-dsh-home dsh --profile web \
58
+ --patch "$(pwd)/packages/citeciter/dev/patch.yml" --port 3907
59
+ node packages/citeciter/dev/smoke.mjs http://127.0.0.1:3907 'CiteCiter' \
60
+ /tmp/citeciter-dsh-home/citeciter-smoke.json
61
+ ```
62
+
63
+ seed 脚本写入一个包含真实 `14:assistant-step1:1` anchor 的完整已结算轮次。浏览器 smoke 从实际渲染的会话节点创建选区,并验证右键菜单、侧栏开关、父日志文件 revision 和浏览器错误。
64
+
65
+ ## 实时浏览器开发
66
+
67
+ 1. 按上面的方式把包链接到临时 Web profile,并通过 `dev/patch.yml` 挂载。
68
+ 2. 启动 profile 并至少打开一次 URL;DSH Web 会挂载 Cordis client-HMR 的 Host 与浏览器插件。
69
+ 3. 运行 `pnpm --filter @kirkchinese/dsh-citeciter dev`,同时监听 declaration 模块与浏览器 bundle。
70
+
71
+ DSH Host 检测 bundle 变化后会发送 `/plugins/events` rebuild 帧,浏览器随后替换 CiteCiter 的 Cordis fiber。插件内 React 和面板状态会重置,DSH 持有的会话数据不会重置。只有修改 DSH 自有 client package 时,完整 DSH 源码检出目录才需要运行自己的 `pnpm run dev:web`。
72
+
73
+ 开发服务器已挂载插件时,可运行 `node packages/citeciter/dev/hmr-smoke.mjs http://127.0.0.1:3907`。脚本会原子修改并还原 bundle,验证 rebuild 帧、旧 fiber 回收、新 fiber 交互和浏览器错误。
74
+
75
+ ## 贡献与许可证
76
+
77
+ 请到 [GitHub](https://github.com/kirkchinese/CiteCiter) 提 Issue 或 Pull Request,并先阅读仓库 [`AGENTS.md`](https://github.com/kirkchinese/CiteCiter/blob/main/AGENTS.md)。
78
+
79
+ MIT © CiteCiter contributors
package/lib/client.js ADDED
@@ -0,0 +1,775 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@kirkchinese/dsh-citeciter",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react_jsx_runtime = require("react/jsx-runtime");
8
+ let react = require("react");
9
+ let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
10
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
11
+ //#region \0dsh-css:src/client/components/CiteCiter.module.css.mjs
12
+ const css = "._1Fxyxa_menu{z-index:9999;max-width:min(520px,100vw - 32px);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-module,#fff);border:1px solid var(--dsw-alias-border-l1,#ddd);box-shadow:var(--dsw-shadow-lv2,0 8px 24px #0000001f);pointer-events:auto;border-radius:10px;align-items:center;gap:8px;padding:8px 10px;display:flex;position:fixed}._1Fxyxa_menuPreview{text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:18px;overflow:hidden}._1Fxyxa_menuButton{color:#fff;cursor:pointer;background:var(--dsw-static-deepseek-500,#4d6bfe);border:none;border-radius:8px;flex:none;padding:4px 10px;font-size:13px;line-height:18px}._1Fxyxa_panel{flex-direction:column;height:100%;padding:12px;display:flex;overflow-y:auto}._1Fxyxa_panelHeader{flex:none;justify-content:space-between;align-items:center;display:flex}._1Fxyxa_panelTitle{font-size:14px;font-weight:500}._1Fxyxa_closeButton{width:28px;height:28px;color:var(--dsw-alias-label-secondary,#555);cursor:pointer;background:0 0;border:none;border-radius:999px;font-size:18px;line-height:1}._1Fxyxa_closeButton:hover{background:var(--dsw-alias-interactive-bg-hover,#0000000a)}._1Fxyxa_panelBody{flex-direction:column;gap:12px;padding-top:12px;display:flex}._1Fxyxa_panelHint{color:var(--dsw-alias-label-tertiary,#888);font-size:13px;line-height:20px}._1Fxyxa_quote{overflow-wrap:anywhere;background:var(--dsw-specific-bubble,#f5f5f5);border-radius:8px;margin:0;padding:8px 12px;font-size:14px;line-height:22px}._1Fxyxa_meta{color:var(--dsw-alias-label-secondary,#666);grid-template-columns:auto 1fr;gap:4px 12px;margin:0;font-size:12px;line-height:18px;display:grid}._1Fxyxa_meta dt{color:var(--dsw-alias-label-tertiary,#999)}._1Fxyxa_meta dd{overflow-wrap:anywhere;min-width:0;margin:0}._1Fxyxa_panelNote{color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}._1Fxyxa_panelError{color:var(--dsw-alias-state-error-primary,#d53f3f);overflow-wrap:anywhere;font-size:12px;line-height:18px}._1Fxyxa_panelAnswer{overflow-wrap:anywhere}._1Fxyxa_richAnswer{flex-direction:column;gap:12px;display:flex}._1Fxyxa_richFigure{border:1px solid var(--dsw-alias-border-l1,#ddd);background:var(--dsw-alias-bg-module,#fff);border-radius:8px;margin:0;overflow:hidden}._1Fxyxa_richSvg{object-fit:contain;width:100%;min-height:96px;max-height:360px;display:block}._1Fxyxa_richHtml{background:var(--dsw-alias-bg-module,#fff);border:0;width:100%;min-height:180px;display:block}._1Fxyxa_panelActions{align-items:center;gap:8px;display:flex}._1Fxyxa_actionButton{color:var(--dsw-alias-label-primary,#222);cursor:pointer;background:var(--dsw-alias-interactive-bg-hover-solid,#eee);border:none;border-radius:8px;padding:4px 12px;font-size:13px;line-height:20px}";
13
+ const tagId = "@kirkchinese/dsh-citeciter/CiteCiter.module.css";
14
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
15
+ const tag = document.createElement("style");
16
+ tag.dataset.plugin = "@kirkchinese/dsh-citeciter";
17
+ tag.dataset.pluginCss = tagId;
18
+ tag.textContent = css;
19
+ document.head.appendChild(tag);
20
+ }
21
+ var CiteCiter_module_css_default = {
22
+ "actionButton": "_1Fxyxa_actionButton",
23
+ "closeButton": "_1Fxyxa_closeButton",
24
+ "menu": "_1Fxyxa_menu",
25
+ "menuButton": "_1Fxyxa_menuButton",
26
+ "menuPreview": "_1Fxyxa_menuPreview",
27
+ "meta": "_1Fxyxa_meta",
28
+ "panel": "_1Fxyxa_panel",
29
+ "panelActions": "_1Fxyxa_panelActions",
30
+ "panelAnswer": "_1Fxyxa_panelAnswer",
31
+ "panelBody": "_1Fxyxa_panelBody",
32
+ "panelError": "_1Fxyxa_panelError",
33
+ "panelHeader": "_1Fxyxa_panelHeader",
34
+ "panelHint": "_1Fxyxa_panelHint",
35
+ "panelNote": "_1Fxyxa_panelNote",
36
+ "panelTitle": "_1Fxyxa_panelTitle",
37
+ "quote": "_1Fxyxa_quote",
38
+ "richAnswer": "_1Fxyxa_richAnswer",
39
+ "richFigure": "_1Fxyxa_richFigure",
40
+ "richHtml": "_1Fxyxa_richHtml",
41
+ "richSvg": "_1Fxyxa_richSvg"
42
+ };
43
+ //#endregion
44
+ //#region lib/types/client/components/SelectionMenu.js
45
+ const PREVIEW_LIMIT = 64;
46
+ /**
47
+ * Render the floating `Citer!` menu in the shell overlay.
48
+ * @param props - shared selection bus and panel opener.
49
+ * @returns menu element while a valid selection exists, otherwise null.
50
+ */
51
+ function SelectionMenu({ bus, openPanel }) {
52
+ const [selection, setSelection] = (0, react.useState)(() => bus.getMenuSelection());
53
+ (0, react.useEffect)(() => bus.subscribe(() => {
54
+ setSelection(bus.getMenuSelection());
55
+ }), [bus]);
56
+ if (selection === null) return null;
57
+ const preview = selection.text.length > PREVIEW_LIMIT ? `${selection.text.slice(0, PREVIEW_LIMIT)}…` : selection.text;
58
+ return (0, react_jsx_runtime.jsxs)("div", {
59
+ className: CiteCiter_module_css_default.menu,
60
+ "data-citeciter-menu": true,
61
+ style: {
62
+ left: selection.x,
63
+ top: selection.y
64
+ },
65
+ role: "menu",
66
+ children: [(0, react_jsx_runtime.jsx)("span", {
67
+ className: CiteCiter_module_css_default.menuPreview,
68
+ title: selection.text,
69
+ children: preview
70
+ }), (0, react_jsx_runtime.jsx)("button", {
71
+ className: CiteCiter_module_css_default.menuButton,
72
+ type: "button",
73
+ role: "menuitem",
74
+ onClick: () => {
75
+ openPanel(selection);
76
+ bus.setMenuSelection(null);
77
+ },
78
+ children: "Citer!"
79
+ })]
80
+ });
81
+ }
82
+ //#endregion
83
+ //#region lib/types/client/answer.js
84
+ /**
85
+ * Read visible text from one assistant-step payload without retaining its live node.
86
+ * @param data - assistant-step payload from the conversation snapshot.
87
+ * @returns visible text and status, or null when no displayable answer exists.
88
+ */
89
+ function readAssistantAnswer(data) {
90
+ if (data === null || typeof data !== "object") return null;
91
+ const record = data;
92
+ if (record.status !== "running" && record.status !== "settled" && record.status !== "interrupted") return null;
93
+ let text = "";
94
+ for (const block of record.blocks ?? []) {
95
+ if (typeof block !== "object" || block === null) continue;
96
+ const candidate = block;
97
+ if (candidate.kind === "text" && typeof candidate.text === "string") text += candidate.text;
98
+ }
99
+ return text === "" ? null : {
100
+ status: record.status,
101
+ text
102
+ };
103
+ }
104
+ //#endregion
105
+ //#region lib/types/client/prompt.js
106
+ /**
107
+ * Build the explanation prompt recorded into the forked child session.
108
+ * @param selection - quoted assistant text and its parent-log anchor.
109
+ * @returns model-visible prompt text.
110
+ */
111
+ function buildPrompt(selection) {
112
+ return [
113
+ "你是 CiteCiter 解释器。只解释下面引用的内容,不执行任务、不修改任何文件,不要请求提升沙箱权限。",
114
+ `[引用自主会话 anchor=${selection.anchorKey}]`,
115
+ "<<<",
116
+ selection.text,
117
+ ">>>",
118
+ "要求:先给一句话直觉解释,再展开原理。数学用 $...$;代码使用带语言围栏;如需图,输出一个 ```svg 围栏(不要 script/foreignObject)。不要输出与引用无关的内容。"
119
+ ].join("\n\n");
120
+ }
121
+ //#endregion
122
+ //#region lib/types/client/explainer-controller.js
123
+ /**
124
+ * Bind the explanation state machine to a supplied snapshot store.
125
+ *
126
+ * A parent or anchor change detaches the old child and forks a correctly scoped
127
+ * one. Work is serialized so repeated selections cannot create parallel children, and disposal
128
+ * invalidates every in-flight await before it can install another subscription.
129
+ *
130
+ * @param sessions - DSH browser session service.
131
+ * @param store - plugin-owned observable state store.
132
+ * @returns observable explainer state and lifecycle actions.
133
+ */
134
+ function createExplainerController(sessions, store) {
135
+ let child = null;
136
+ let parentId = null;
137
+ let forkSeq = null;
138
+ let unsubscribeChild = null;
139
+ let disposed = false;
140
+ let epoch = 0;
141
+ let startQueue = Promise.resolve();
142
+ let stopQueue = Promise.resolve();
143
+ const baselineAssistantKeys = /* @__PURE__ */ new Set();
144
+ const update = (mutator) => {
145
+ if (!disposed) store.update(mutator);
146
+ };
147
+ const fail = (error) => {
148
+ update((draft) => {
149
+ draft.phase = "error";
150
+ draft.error = error instanceof Error ? error.message : String(error);
151
+ });
152
+ };
153
+ const isActive = (operationEpoch) => !disposed && operationEpoch === epoch;
154
+ const detachChild = () => {
155
+ epoch++;
156
+ unsubscribeChild?.();
157
+ unsubscribeChild = null;
158
+ child = null;
159
+ parentId = null;
160
+ forkSeq = null;
161
+ baselineAssistantKeys.clear();
162
+ };
163
+ const updateFromChild = () => {
164
+ const session = child;
165
+ if (session === null || disposed) return;
166
+ const snapshot = session.getSnapshot();
167
+ let answer = null;
168
+ for (const node of snapshot.chat.nodes.values()) {
169
+ if (node.kind !== "assistant-step" || baselineAssistantKeys.has(node.key)) continue;
170
+ const candidate = readAssistantAnswer(node.data);
171
+ if (candidate !== null && (answer === null || node.anchorSeq >= answer.anchorSeq)) answer = {
172
+ ...candidate,
173
+ anchorSeq: node.anchorSeq
174
+ };
175
+ }
176
+ if (answer !== null) {
177
+ update((draft) => {
178
+ draft.phase = answer.status === "running" ? "running" : "settled";
179
+ draft.answerText = answer.text;
180
+ draft.error = null;
181
+ });
182
+ return;
183
+ }
184
+ if (snapshot.promptError !== null) {
185
+ fail(snapshot.promptError.error.message);
186
+ return;
187
+ }
188
+ if (snapshot.lastAgentError !== null) {
189
+ fail(snapshot.lastAgentError);
190
+ return;
191
+ }
192
+ if (snapshot.running) update((draft) => {
193
+ draft.phase = "running";
194
+ });
195
+ };
196
+ const rememberAssistantKeys = (session) => {
197
+ for (const node of session.getSnapshot().chat.nodes.values()) if (node.kind === "assistant-step") baselineAssistantKeys.add(node.key);
198
+ };
199
+ const attachChild = (session, sourceId, atSeq) => {
200
+ child = session;
201
+ parentId = sourceId;
202
+ forkSeq = atSeq;
203
+ baselineAssistantKeys.clear();
204
+ rememberAssistantKeys(session);
205
+ unsubscribeChild = session.subscribe(updateFromChild);
206
+ };
207
+ const prompt = async (selection, operationEpoch) => {
208
+ const session = child;
209
+ if (session === null || !isActive(operationEpoch)) return;
210
+ rememberAssistantKeys(session);
211
+ update((draft) => {
212
+ draft.phase = "running";
213
+ draft.selection = selection;
214
+ draft.answerText = null;
215
+ draft.error = null;
216
+ });
217
+ let result;
218
+ try {
219
+ result = await session.prompt([{
220
+ type: "text",
221
+ text: buildPrompt(selection)
222
+ }], "queue");
223
+ } catch (error) {
224
+ if (isActive(operationEpoch)) fail(error);
225
+ return;
226
+ }
227
+ if (!isActive(operationEpoch)) return;
228
+ if (!result.ok) {
229
+ fail(result.error.message);
230
+ return;
231
+ }
232
+ updateFromChild();
233
+ };
234
+ const runStart = async (selection) => {
235
+ if (disposed) return;
236
+ const current = sessions.list.getSnapshot().current;
237
+ if (current === void 0) {
238
+ fail("no current session");
239
+ return;
240
+ }
241
+ update((draft) => {
242
+ draft.selection = selection;
243
+ draft.error = null;
244
+ });
245
+ const sourceBinding = sessions.binding(current);
246
+ if (sourceBinding === void 0) {
247
+ fail(`current session "${current}" is not locally addressable`);
248
+ return;
249
+ }
250
+ const sourceNode = sourceBinding.session.getSnapshot().chat.nodes.get(selection.anchorKey);
251
+ if (sourceNode === void 0 || sourceNode.kind !== "assistant-step") {
252
+ fail("selected assistant context is no longer available");
253
+ return;
254
+ }
255
+ const sourceAnswer = readAssistantAnswer(sourceNode.data);
256
+ if (sourceAnswer === null || sourceAnswer.status === "running") {
257
+ fail("selected assistant response is not complete");
258
+ return;
259
+ }
260
+ if (sourceNode.location.kind !== "step" || sourceNode.location.turn.status !== "closed") {
261
+ fail("selected assistant turn is not complete");
262
+ return;
263
+ }
264
+ const atSeq = sourceNode.anchorSeq;
265
+ if (child !== null && parentId === current && forkSeq === atSeq) {
266
+ await prompt(selection, epoch);
267
+ return;
268
+ }
269
+ if (child !== null) {
270
+ detachChild();
271
+ update((draft) => {
272
+ draft.phase = "idle";
273
+ draft.childId = null;
274
+ draft.answerText = null;
275
+ });
276
+ }
277
+ update((draft) => {
278
+ draft.phase = "creating";
279
+ });
280
+ const operationEpoch = epoch;
281
+ let childId;
282
+ try {
283
+ childId = await sessions.fork({
284
+ sessionId: current,
285
+ atSeq
286
+ });
287
+ } catch (error) {
288
+ if (isActive(operationEpoch)) fail(error);
289
+ return;
290
+ }
291
+ if (!isActive(operationEpoch)) return;
292
+ const binding = sessions.binding(childId);
293
+ if (binding === void 0) {
294
+ fail(`fork child "${childId}" is not locally addressable`);
295
+ return;
296
+ }
297
+ const session = binding.session;
298
+ try {
299
+ await session.open();
300
+ } catch (error) {
301
+ if (isActive(operationEpoch)) fail(error);
302
+ return;
303
+ }
304
+ if (!isActive(operationEpoch)) return;
305
+ attachChild(session, current, atSeq);
306
+ update((draft) => {
307
+ draft.childId = childId;
308
+ draft.phase = "ready";
309
+ });
310
+ let permission;
311
+ try {
312
+ permission = await session.command("/permission read-only");
313
+ } catch (error) {
314
+ if (isActive(operationEpoch)) fail(error);
315
+ return;
316
+ }
317
+ if (!isActive(operationEpoch)) return;
318
+ if (!permission.ok) {
319
+ fail(`read-only switch failed: ${permission.error.message}`);
320
+ return;
321
+ }
322
+ if (!permission.value.matched) {
323
+ fail("read-only switch failed: permission command was not recognized");
324
+ return;
325
+ }
326
+ await prompt(selection, operationEpoch);
327
+ };
328
+ const start = (selection) => {
329
+ if (disposed) return Promise.resolve();
330
+ const task = startQueue.then(async () => {
331
+ try {
332
+ await runStart(selection);
333
+ } catch (error) {
334
+ fail(error);
335
+ }
336
+ });
337
+ startQueue = task;
338
+ return task;
339
+ };
340
+ const runStop = async () => {
341
+ const session = child;
342
+ const operationEpoch = epoch;
343
+ if (session === null || !isActive(operationEpoch)) return;
344
+ try {
345
+ const result = await session.cancel();
346
+ if (!isActive(operationEpoch)) return;
347
+ if (!result.ok) {
348
+ fail(result.error.message);
349
+ return;
350
+ }
351
+ } catch (error) {
352
+ if (isActive(operationEpoch)) fail(error);
353
+ return;
354
+ }
355
+ update((draft) => {
356
+ draft.phase = "ready";
357
+ });
358
+ };
359
+ const stop = () => {
360
+ if (disposed) return Promise.resolve();
361
+ const task = stopQueue.then(async () => {
362
+ try {
363
+ await runStop();
364
+ } catch (error) {
365
+ fail(error);
366
+ }
367
+ });
368
+ stopQueue = task;
369
+ return task;
370
+ };
371
+ const dispose = async () => {
372
+ if (!disposed) {
373
+ detachChild();
374
+ disposed = true;
375
+ }
376
+ await Promise.all([startQueue, stopQueue]);
377
+ };
378
+ return {
379
+ getSnapshot: store.getSnapshot,
380
+ subscribe: store.subscribe,
381
+ start,
382
+ stop,
383
+ dispose
384
+ };
385
+ }
386
+ //#endregion
387
+ //#region lib/types/client/explainer.js
388
+ const EMPTY = {
389
+ phase: "idle",
390
+ childId: null,
391
+ selection: null,
392
+ answerText: null,
393
+ error: null
394
+ };
395
+ /**
396
+ * Create an explainer and its plugin-owned DSH snapshot store.
397
+ * @param sessions - DSH browser session service.
398
+ * @returns observable explainer state and lifecycle actions.
399
+ */
400
+ function createExplainer(sessions) {
401
+ return createExplainerController(sessions, (0, _deepseek_ai_dsh_client_runtime_client.createSnapshotStore)(EMPTY));
402
+ }
403
+ //#endregion
404
+ //#region lib/types/client/rich-content.js
405
+ const MAX_RICH_SOURCE_LENGTH = 2e5;
406
+ const RICH_FENCE = /^```(?<kind>svg|html)[ \t]*\r?\n(?<source>[\s\S]*?)^```[ \t]*$/gimu;
407
+ const HTML_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:";
408
+ /**
409
+ * Split complete safe rich fences from Markdown while preserving all prose.
410
+ * @param text - current assistant response text.
411
+ * @returns ordered Markdown and isolated rich-preview segments.
412
+ */
413
+ function splitRichContent(text) {
414
+ const segments = [];
415
+ let cursor = 0;
416
+ for (const match of text.matchAll(RICH_FENCE)) {
417
+ const index = match.index ?? 0;
418
+ if (index > cursor) pushMarkdown(segments, text.slice(cursor, index));
419
+ const wholeFence = match[0];
420
+ const kind = match.groups?.kind?.toLowerCase();
421
+ const source = match.groups?.source ?? "";
422
+ if (kind === "svg" && isSafeSvg(source)) {
423
+ const normalized = source.trim();
424
+ segments.push({
425
+ kind: "svg",
426
+ source: normalized,
427
+ dataUrl: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(normalized)}`
428
+ });
429
+ } else if (kind === "html" && isPreviewableHtml(source)) segments.push({
430
+ kind: "html",
431
+ document: isolatedHtmlDocument(source.trim())
432
+ });
433
+ else pushMarkdown(segments, wholeFence);
434
+ cursor = index + wholeFence.length;
435
+ }
436
+ if (cursor < text.length || segments.length === 0) pushMarkdown(segments, text.slice(cursor));
437
+ return segments;
438
+ }
439
+ /**
440
+ * Check whether SVG markup is self-contained and non-active.
441
+ * @param source - SVG fence body.
442
+ * @returns whether the source may be rendered as an inert image preview.
443
+ */
444
+ function isSafeSvg(source) {
445
+ const svg = source.trim();
446
+ if (svg.length === 0 || svg.length > MAX_RICH_SOURCE_LENGTH) return false;
447
+ if (!/^<svg(?:\s|>)/iu.test(svg) || !/<\/svg\s*>$/iu.test(svg)) return false;
448
+ if (/<\/?(?:script|foreignobject|iframe|object|embed|audio|video|canvas)\b/iu.test(svg)) return false;
449
+ if (/\son[a-z][a-z0-9:_-]*\s*=/iu.test(svg)) return false;
450
+ if (/\b(?:href|xlink:href)\s*=/iu.test(svg)) return false;
451
+ if (/\b(?:javascript|vbscript)\s*:/iu.test(svg)) return false;
452
+ if (/url\(\s*(?:['"]?\s*)?(?!#)/iu.test(svg)) return false;
453
+ return true;
454
+ }
455
+ /**
456
+ * Build a network-free, script-free iframe document for an HTML fence.
457
+ * @param source - HTML fence body.
458
+ * @returns complete iframe `srcDoc` markup with restrictive CSP metadata.
459
+ */
460
+ function isolatedHtmlDocument(source) {
461
+ return [
462
+ "<!doctype html><html><head>",
463
+ `<meta http-equiv="Content-Security-Policy" content="${HTML_CSP}">`,
464
+ "<meta name=\"referrer\" content=\"no-referrer\">",
465
+ "<style>html{color-scheme:light dark}body{margin:0;padding:12px;font:14px/1.5 system-ui,sans-serif;overflow-wrap:anywhere}</style>",
466
+ "</head><body>",
467
+ source,
468
+ "</body></html>"
469
+ ].join("");
470
+ }
471
+ function isPreviewableHtml(source) {
472
+ return source.trim().length > 0 && source.length <= MAX_RICH_SOURCE_LENGTH;
473
+ }
474
+ function pushMarkdown(segments, text) {
475
+ if (text !== "") segments.push({
476
+ kind: "markdown",
477
+ text
478
+ });
479
+ }
480
+ //#endregion
481
+ //#region lib/types/client/components/RichAnswer.js
482
+ /**
483
+ * Render model Markdown plus safe SVG and sandboxed HTML fence previews.
484
+ * @param props - response text and streaming flag.
485
+ * @returns isolated rich-answer element.
486
+ */
487
+ function RichAnswer({ text, streaming }) {
488
+ const segments = (0, react.useMemo)(() => splitRichContent(text), [text]);
489
+ return (0, react_jsx_runtime.jsx)("div", {
490
+ className: CiteCiter_module_css_default.richAnswer,
491
+ "data-citeciter-answer": true,
492
+ children: segments.map((segment, index) => {
493
+ const key = `${segment.kind}:${index}`;
494
+ if (segment.kind === "svg") return (0, react_jsx_runtime.jsx)("figure", {
495
+ className: CiteCiter_module_css_default.richFigure,
496
+ "data-citeciter-svg": true,
497
+ children: (0, react_jsx_runtime.jsx)("img", {
498
+ className: CiteCiter_module_css_default.richSvg,
499
+ src: segment.dataUrl,
500
+ alt: "CiteCiter SVG explanation"
501
+ })
502
+ }, key);
503
+ if (segment.kind === "html") return (0, react_jsx_runtime.jsx)("figure", {
504
+ className: CiteCiter_module_css_default.richFigure,
505
+ "data-citeciter-html": true,
506
+ children: (0, react_jsx_runtime.jsx)("iframe", {
507
+ className: CiteCiter_module_css_default.richHtml,
508
+ title: "CiteCiter HTML explanation",
509
+ sandbox: "",
510
+ referrerPolicy: "no-referrer",
511
+ srcDoc: segment.document
512
+ })
513
+ }, key);
514
+ return (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
515
+ text: segment.text,
516
+ streaming
517
+ }, key);
518
+ })
519
+ });
520
+ }
521
+ //#endregion
522
+ //#region lib/types/client/components/CitePanel.js
523
+ const PHASE_LABEL = {
524
+ idle: "空闲",
525
+ creating: "正在创建解释会话…",
526
+ ready: "解释会话已就绪",
527
+ running: "正在解释…",
528
+ settled: "解释完成",
529
+ error: "解释失败"
530
+ };
531
+ /**
532
+ * Render the right details-column explanation panel.
533
+ * @param props - selection state, close action, and explainer face.
534
+ * @returns panel element with current status and response.
535
+ */
536
+ function CitePanel({ bus, close, explainer }) {
537
+ const subscribeBus = (0, react.useCallback)((onStoreChange) => bus.subscribe(onStoreChange), [bus]);
538
+ const subscribeExplainer = (0, react.useCallback)((onStoreChange) => explainer.subscribe(onStoreChange), [explainer]);
539
+ const selection = (0, react.useSyncExternalStore)(subscribeBus, () => bus.getPanelSelection());
540
+ const snapshot = (0, react.useSyncExternalStore)(subscribeExplainer, () => explainer.getSnapshot());
541
+ return (0, react_jsx_runtime.jsxs)("div", {
542
+ className: CiteCiter_module_css_default.panel,
543
+ "data-citeciter-panel": true,
544
+ children: [(0, react_jsx_runtime.jsxs)("header", {
545
+ className: CiteCiter_module_css_default.panelHeader,
546
+ children: [(0, react_jsx_runtime.jsx)("span", {
547
+ className: CiteCiter_module_css_default.panelTitle,
548
+ children: "CiteCiter"
549
+ }), (0, react_jsx_runtime.jsx)("button", {
550
+ className: CiteCiter_module_css_default.closeButton,
551
+ type: "button",
552
+ "aria-label": "Close",
553
+ onClick: close,
554
+ children: "×"
555
+ })]
556
+ }), selection === null && snapshot.selection === null ? (0, react_jsx_runtime.jsx)("p", {
557
+ className: CiteCiter_module_css_default.panelHint,
558
+ children: "选中助手回复中的一段文字,右键选择 Citer!。"
559
+ }) : (0, react_jsx_runtime.jsxs)("div", {
560
+ className: CiteCiter_module_css_default.panelBody,
561
+ children: [
562
+ (0, react_jsx_runtime.jsx)("blockquote", {
563
+ className: CiteCiter_module_css_default.quote,
564
+ children: (selection ?? snapshot.selection)?.text
565
+ }),
566
+ (0, react_jsx_runtime.jsxs)("dl", {
567
+ className: CiteCiter_module_css_default.meta,
568
+ children: [
569
+ (0, react_jsx_runtime.jsx)("dt", { children: "anchor" }),
570
+ (0, react_jsx_runtime.jsx)("dd", { children: (selection ?? snapshot.selection)?.anchorKey }),
571
+ (0, react_jsx_runtime.jsx)("dt", { children: "child" }),
572
+ (0, react_jsx_runtime.jsx)("dd", { children: snapshot.childId ?? "—" }),
573
+ (0, react_jsx_runtime.jsx)("dt", { children: "status" }),
574
+ (0, react_jsx_runtime.jsx)("dd", { children: PHASE_LABEL[snapshot.phase] })
575
+ ]
576
+ }),
577
+ snapshot.error !== null && (0, react_jsx_runtime.jsx)("p", {
578
+ className: CiteCiter_module_css_default.panelError,
579
+ "data-citeciter-error": true,
580
+ children: snapshot.error
581
+ }),
582
+ snapshot.answerText !== null && snapshot.answerText !== "" && (0, react_jsx_runtime.jsx)("div", {
583
+ className: CiteCiter_module_css_default.panelAnswer,
584
+ children: (0, react_jsx_runtime.jsx)(RichAnswer, {
585
+ text: snapshot.answerText,
586
+ streaming: snapshot.phase === "running"
587
+ })
588
+ }),
589
+ (0, react_jsx_runtime.jsxs)("div", {
590
+ className: CiteCiter_module_css_default.panelActions,
591
+ children: [snapshot.phase === "running" && (0, react_jsx_runtime.jsx)("button", {
592
+ className: CiteCiter_module_css_default.actionButton,
593
+ type: "button",
594
+ onClick: () => {
595
+ explainer.stop();
596
+ },
597
+ children: "停止"
598
+ }), (0, react_jsx_runtime.jsx)("span", {
599
+ className: CiteCiter_module_css_default.panelNote,
600
+ children: "解释会话独立运行,不写入主会话。"
601
+ })]
602
+ })
603
+ ]
604
+ })]
605
+ });
606
+ }
607
+ //#endregion
608
+ //#region lib/types/client/selection.js
609
+ /**
610
+ * Resolve the current DOM selection into a CiteSelection.
611
+ * Returns null for collapsed or empty selections, selections outside a
612
+ * conversation flow node, and selections outside an assistant step.
613
+ * @param event - context-menu event whose pointer position anchors the menu.
614
+ * @returns validated selection metadata, or null when CiteCiter should ignore it.
615
+ */
616
+ function readSelection(event) {
617
+ const selection = window.getSelection();
618
+ if (selection === null || selection.isCollapsed || selection.rangeCount === 0) return null;
619
+ const text = selection.toString().trim();
620
+ if (text === "") return null;
621
+ const start = selection.getRangeAt(0).commonAncestorContainer;
622
+ const flow = (start.nodeType === Node.ELEMENT_NODE ? start : start.parentElement)?.closest("[data-chat-flow-kind]");
623
+ if (flow === null || flow === void 0) return null;
624
+ const kind = flow.dataset.chatFlowKind;
625
+ const anchorKey = flow.dataset.chatAnchorKey;
626
+ if (kind !== "assistant-step" || anchorKey === void 0 || anchorKey === "") return null;
627
+ return {
628
+ text,
629
+ kind,
630
+ anchorKey,
631
+ x: event.clientX,
632
+ y: event.clientY
633
+ };
634
+ }
635
+ //#endregion
636
+ //#region lib/types/client/types.js
637
+ /** Observable selection state shared by the overlay and details panel. */
638
+ var CiteBus = class {
639
+ reportListenerError;
640
+ menuSelection = null;
641
+ panelSelection = null;
642
+ listeners = /* @__PURE__ */ new Set();
643
+ /** @param reportListenerError - isolates and reports one failed subscriber. */
644
+ constructor(reportListenerError) {
645
+ this.reportListenerError = reportListenerError;
646
+ }
647
+ /** @returns current context-menu selection, or null while hidden. */
648
+ getMenuSelection() {
649
+ return this.menuSelection;
650
+ }
651
+ /** @returns selection currently explained in the details panel. */
652
+ getPanelSelection() {
653
+ return this.panelSelection;
654
+ }
655
+ /** @param selection - next context-menu selection, or null to hide it. */
656
+ setMenuSelection(selection) {
657
+ if (this.menuSelection === selection) return;
658
+ this.menuSelection = selection;
659
+ this.notify();
660
+ }
661
+ /** @param selection - next details-panel selection, or null when closed. */
662
+ setPanelSelection(selection) {
663
+ if (this.panelSelection === selection) return;
664
+ this.panelSelection = selection;
665
+ this.notify();
666
+ }
667
+ /**
668
+ * Subscribe to either selection value.
669
+ * @param listener - callback invoked after a value changes.
670
+ * @returns disposer for this subscription.
671
+ */
672
+ subscribe(listener) {
673
+ this.listeners.add(listener);
674
+ return () => {
675
+ this.listeners.delete(listener);
676
+ };
677
+ }
678
+ notify() {
679
+ for (const listener of [...this.listeners]) try {
680
+ listener();
681
+ } catch (error) {
682
+ this.reportListenerError(error);
683
+ }
684
+ }
685
+ };
686
+ //#endregion
687
+ //#region lib/types/client/index.js
688
+ /** Cordis identity for the CiteCiter browser plugin. */
689
+ const name = "@kirkchinese/dsh-citeciter";
690
+ /** Hard dependencies whose appearance activates the browser fiber. */
691
+ const inject = [
692
+ "layout",
693
+ "slots",
694
+ "sessions"
695
+ ];
696
+ /**
697
+ * Register the selection listener, overlay entry, and details-panel lifecycle.
698
+ * @param ctx - Cordis browser context with layout, slots, and sessions services.
699
+ */
700
+ function apply(ctx) {
701
+ const { layout, sessions, slots } = ctx;
702
+ const bus = new CiteBus((error) => ctx.logger.warn("citeciter selection listener failed", error));
703
+ const explainer = createExplainer(sessions);
704
+ let detailsInjectController = null;
705
+ let detailsDisposer = null;
706
+ let panelOpen = false;
707
+ ctx.effect(() => {
708
+ const onContextMenu = (event) => {
709
+ const selection = readSelection(event);
710
+ if (selection === null) return;
711
+ event.preventDefault();
712
+ bus.setMenuSelection(selection);
713
+ };
714
+ const onPointerDown = (event) => {
715
+ const target = event.target;
716
+ if (!(target instanceof Element) || target.closest("[data-citeciter-menu]") === null) bus.setMenuSelection(null);
717
+ };
718
+ document.addEventListener("contextmenu", onContextMenu);
719
+ document.addEventListener("pointerdown", onPointerDown);
720
+ return () => {
721
+ document.removeEventListener("contextmenu", onContextMenu);
722
+ document.removeEventListener("pointerdown", onPointerDown);
723
+ };
724
+ });
725
+ const openPanel = (selection) => {
726
+ bus.setPanelSelection(selection);
727
+ panelOpen = true;
728
+ layout.openDetails();
729
+ if (detailsInjectController === null) detailsInjectController = slots.inject("details", () => {
730
+ detailsDisposer = slots.register({
731
+ name: "details",
732
+ priority: Number.MIN_SAFE_INTEGER,
733
+ inject: () => ({
734
+ bus,
735
+ close: closePanel,
736
+ explainer
737
+ })
738
+ }, CitePanel);
739
+ return () => {
740
+ detailsDisposer?.();
741
+ detailsDisposer = null;
742
+ };
743
+ });
744
+ explainer.start(selection);
745
+ };
746
+ const closePanel = () => {
747
+ const wasOpen = panelOpen;
748
+ panelOpen = false;
749
+ detailsDisposer?.();
750
+ detailsDisposer = null;
751
+ detailsInjectController?.();
752
+ detailsInjectController = null;
753
+ bus.setPanelSelection(null);
754
+ if (wasOpen) layout.closeDetails();
755
+ };
756
+ ctx.effect(() => async () => {
757
+ closePanel();
758
+ await explainer.dispose();
759
+ }, "citeciter: explainer lifecycle");
760
+ slots.inject("shell.overlay", () => slots.register({
761
+ name: "shell.overlay",
762
+ id: "citeciter.menu",
763
+ inject: () => ({
764
+ bus,
765
+ openPanel
766
+ })
767
+ }, SelectionMenu));
768
+ }
769
+ //#endregion
770
+ exports.apply = apply;
771
+ exports.inject = inject;
772
+ exports.name = name;
773
+ return module.exports;
774
+ }
775
+ });
package/lib/index.js ADDED
@@ -0,0 +1,15 @@
1
+ //#region lib/types/index.js
2
+ /**
3
+ * Host loader entry for the browser-only CiteCiter plugin.
4
+ * Deliberately no-op: the browser half (`./client`) owns the whole feature,
5
+ * and the plugin registers no process-level Host service (the known
6
+ * Cordis duplicate-service trap therefore cannot trigger).
7
+ */
8
+ /** Cordis plugin identity shared with the browser bundle. */
9
+ const name = "@kirkchinese/dsh-citeciter";
10
+ /** CiteCiter has no Host service dependencies. */
11
+ const inject = [];
12
+ /** Register no Host effects; all behavior belongs to the browser plugin. */
13
+ function apply() {}
14
+ //#endregion
15
+ export { apply, inject, name };
@@ -0,0 +1,11 @@
1
+ /** Visible assistant text and its stream lifecycle. */
2
+ export type AssistantAnswer = {
3
+ readonly status: 'running' | 'settled' | 'interrupted';
4
+ readonly text: string;
5
+ };
6
+ /**
7
+ * Read visible text from one assistant-step payload without retaining its live node.
8
+ * @param data - assistant-step payload from the conversation snapshot.
9
+ * @returns visible text and status, or null when no displayable answer exists.
10
+ */
11
+ export declare function readAssistantAnswer(data: unknown): AssistantAnswer | null;
@@ -0,0 +1,14 @@
1
+ import type { CiteBus } from '../types.ts';
2
+ import type { ExplainFace } from '../explainer.ts';
3
+ /** Dependencies injected into the session-scoped details entry. */
4
+ export interface CitePanelProps {
5
+ readonly bus: CiteBus;
6
+ readonly close: () => void;
7
+ readonly explainer: ExplainFace;
8
+ }
9
+ /**
10
+ * Render the right details-column explanation panel.
11
+ * @param props - selection state, close action, and explainer face.
12
+ * @returns panel element with current status and response.
13
+ */
14
+ export declare function CitePanel({ bus, close, explainer }: CitePanelProps): import("react").JSX.Element;
@@ -0,0 +1,11 @@
1
+ /** Assistant response text and its current stream state. */
2
+ export interface RichAnswerProps {
3
+ readonly text: string;
4
+ readonly streaming: boolean;
5
+ }
6
+ /**
7
+ * Render model Markdown plus safe SVG and sandboxed HTML fence previews.
8
+ * @param props - response text and streaming flag.
9
+ * @returns isolated rich-answer element.
10
+ */
11
+ export declare function RichAnswer({ text, streaming }: RichAnswerProps): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import type { CiteBus, CiteSelection } from '../types.ts';
2
+ /** Dependencies injected into the root overlay entry. */
3
+ export interface SelectionMenuProps {
4
+ readonly bus: CiteBus;
5
+ readonly openPanel: (selection: CiteSelection) => void;
6
+ }
7
+ /**
8
+ * Render the floating `Citer!` menu in the shell overlay.
9
+ * @param props - shared selection bus and panel opener.
10
+ * @returns menu element while a valid selection exists, otherwise null.
11
+ */
12
+ export declare function SelectionMenu({ bus, openPanel }: SelectionMenuProps): import("react").JSX.Element | null;
@@ -0,0 +1,32 @@
1
+ import type { ISessions, SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
2
+ import type { CiteSelection } from './types.ts';
3
+ /** User-visible lifecycle state of one explanation request. */
4
+ export type ExplainPhase = 'idle' | 'creating' | 'ready' | 'running' | 'settled' | 'error';
5
+ /** Immutable value observed by the CiteCiter panel. */
6
+ export interface ExplainSnapshot {
7
+ phase: ExplainPhase;
8
+ childId: SessionId | null;
9
+ selection: CiteSelection | null;
10
+ answerText: string | null;
11
+ error: string | null;
12
+ }
13
+ /** Observable actions owned by one CiteCiter plugin fiber. */
14
+ export interface ExplainFace {
15
+ getSnapshot(): ExplainSnapshot;
16
+ subscribe(listener: () => void): () => void;
17
+ start(selection: CiteSelection): Promise<void>;
18
+ stop(): Promise<void>;
19
+ dispose(): Promise<void>;
20
+ }
21
+ /**
22
+ * Bind the explanation state machine to a supplied snapshot store.
23
+ *
24
+ * A parent or anchor change detaches the old child and forks a correctly scoped
25
+ * one. Work is serialized so repeated selections cannot create parallel children, and disposal
26
+ * invalidates every in-flight await before it can install another subscription.
27
+ *
28
+ * @param sessions - DSH browser session service.
29
+ * @param store - plugin-owned observable state store.
30
+ * @returns observable explainer state and lifecycle actions.
31
+ */
32
+ export declare function createExplainerController(sessions: ISessions, store: SnapshotStore<ExplainSnapshot>): ExplainFace;
@@ -0,0 +1,9 @@
1
+ import { type ISessions } from '@deepseek-ai/dsh-client-runtime/client';
2
+ import { type ExplainFace } from './explainer-controller.ts';
3
+ export type { ExplainFace, ExplainPhase, ExplainSnapshot } from './explainer-controller.ts';
4
+ /**
5
+ * Create an explainer and its plugin-owned DSH snapshot store.
6
+ * @param sessions - DSH browser session service.
7
+ * @returns observable explainer state and lifecycle actions.
8
+ */
9
+ export declare function createExplainer(sessions: ISessions): ExplainFace;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * CiteCiter browser plugin.
3
+ *
4
+ * Browser interaction path: assistant-text selection → right-click `Citer!`
5
+ * menu (shell.overlay) → resizable right details panel → an isolated,
6
+ * read-only forked explainer session.
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ /** Cordis identity for the CiteCiter browser plugin. */
10
+ export declare const name = "@kirkchinese/dsh-citeciter";
11
+ /** Hard dependencies whose appearance activates the browser fiber. */
12
+ export declare const inject: string[];
13
+ /**
14
+ * Register the selection listener, overlay entry, and details-panel lifecycle.
15
+ * @param ctx - Cordis browser context with layout, slots, and sessions services.
16
+ */
17
+ export declare function apply(ctx: Context): void;
@@ -0,0 +1,7 @@
1
+ import type { CiteSelection } from './types.ts';
2
+ /**
3
+ * Build the explanation prompt recorded into the forked child session.
4
+ * @param selection - quoted assistant text and its parent-log anchor.
5
+ * @returns model-visible prompt text.
6
+ */
7
+ export declare function buildPrompt(selection: CiteSelection): string;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Safe, presentation-only extraction of CiteCiter rich fences.
3
+ *
4
+ * Markdown remains the default renderer. Only complete `svg` and `html`
5
+ * fences become dedicated, isolated views; malformed, unsafe, or oversized
6
+ * content is intentionally returned to MarkdownText as an ordinary code
7
+ * fence.
8
+ */
9
+ /** One presentation segment derived from an assistant Markdown response. */
10
+ export type RichSegment = {
11
+ readonly kind: 'markdown';
12
+ readonly text: string;
13
+ } | {
14
+ readonly kind: 'svg';
15
+ readonly source: string;
16
+ readonly dataUrl: string;
17
+ } | {
18
+ readonly kind: 'html';
19
+ readonly document: string;
20
+ };
21
+ /**
22
+ * Split complete safe rich fences from Markdown while preserving all prose.
23
+ * @param text - current assistant response text.
24
+ * @returns ordered Markdown and isolated rich-preview segments.
25
+ */
26
+ export declare function splitRichContent(text: string): readonly RichSegment[];
27
+ /**
28
+ * Check whether SVG markup is self-contained and non-active.
29
+ * @param source - SVG fence body.
30
+ * @returns whether the source may be rendered as an inert image preview.
31
+ */
32
+ export declare function isSafeSvg(source: string): boolean;
33
+ /**
34
+ * Build a network-free, script-free iframe document for an HTML fence.
35
+ * @param source - HTML fence body.
36
+ * @returns complete iframe `srcDoc` markup with restrictive CSP metadata.
37
+ */
38
+ export declare function isolatedHtmlDocument(source: string): string;
@@ -0,0 +1,9 @@
1
+ import type { CiteSelection } from './types.ts';
2
+ /**
3
+ * Resolve the current DOM selection into a CiteSelection.
4
+ * Returns null for collapsed or empty selections, selections outside a
5
+ * conversation flow node, and selections outside an assistant step.
6
+ * @param event - context-menu event whose pointer position anchors the menu.
7
+ * @returns validated selection metadata, or null when CiteCiter should ignore it.
8
+ */
9
+ export declare function readSelection(event: MouseEvent): CiteSelection | null;
@@ -0,0 +1,39 @@
1
+ /** One validated right-click selection inside a conversation flow node. */
2
+ export interface CiteSelection {
3
+ /** Selected plain text (already trimmed; display may truncate separately). */
4
+ readonly text: string;
5
+ /** Flow kind of the DOM node that owns the selection. */
6
+ readonly kind: 'assistant-step';
7
+ /** Conversation snapshot node key (`data-chat-anchor-key`). */
8
+ readonly anchorKey: string;
9
+ /** Horizontal pointer position for the floating menu, in client coordinates. */
10
+ readonly x: number;
11
+ /** Vertical pointer position for the floating menu, in client coordinates. */
12
+ readonly y: number;
13
+ }
14
+ /** Minimal observable bus shared by the menu and the side panel. */
15
+ export type CiteBusListener = () => void;
16
+ /** Observable selection state shared by the overlay and details panel. */
17
+ export declare class CiteBus {
18
+ private readonly reportListenerError;
19
+ private menuSelection;
20
+ private panelSelection;
21
+ private readonly listeners;
22
+ /** @param reportListenerError - isolates and reports one failed subscriber. */
23
+ constructor(reportListenerError: (error: unknown) => void);
24
+ /** @returns current context-menu selection, or null while hidden. */
25
+ getMenuSelection(): CiteSelection | null;
26
+ /** @returns selection currently explained in the details panel. */
27
+ getPanelSelection(): CiteSelection | null;
28
+ /** @param selection - next context-menu selection, or null to hide it. */
29
+ setMenuSelection(selection: CiteSelection | null): void;
30
+ /** @param selection - next details-panel selection, or null when closed. */
31
+ setPanelSelection(selection: CiteSelection | null): void;
32
+ /**
33
+ * Subscribe to either selection value.
34
+ * @param listener - callback invoked after a value changes.
35
+ * @returns disposer for this subscription.
36
+ */
37
+ subscribe(listener: CiteBusListener): () => void;
38
+ private notify;
39
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Host loader entry for the browser-only CiteCiter plugin.
3
+ * Deliberately no-op: the browser half (`./client`) owns the whole feature,
4
+ * and the plugin registers no process-level Host service (the known
5
+ * Cordis duplicate-service trap therefore cannot trigger).
6
+ */
7
+ /** Cordis plugin identity shared with the browser bundle. */
8
+ export declare const name = "@kirkchinese/dsh-citeciter";
9
+ /** CiteCiter has no Host service dependencies. */
10
+ export declare const inject: readonly [];
11
+ /** Register no Host effects; all behavior belongs to the browser plugin. */
12
+ export declare function apply(): void;
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "name": "@kirkchinese/dsh-citeciter",
3
+ "version": "0.1.0",
4
+ "description": "Selection-scoped explanation sidebar for DeepSeek Harness conversations",
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "cordis",
10
+ "explanation",
11
+ "sidebar"
12
+ ],
13
+ "homepage": "https://github.com/kirkchinese/CiteCiter#readme",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/kirkchinese/CiteCiter.git",
17
+ "directory": "packages/citeciter"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/kirkchinese/CiteCiter/issues"
21
+ },
22
+ "license": "MIT",
23
+ "author": "kirkchinese",
24
+ "type": "module",
25
+ "engines": {
26
+ "node": "^22.19.0 || >=24.0.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "main": "lib/index.js",
32
+ "types": "lib/types/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./lib/types/index.d.ts",
36
+ "default": "./lib/index.js"
37
+ },
38
+ "./client": {
39
+ "types": "./lib/types/client/index.d.ts",
40
+ "default": "./lib/client.js"
41
+ },
42
+ "./package.json": "./package.json"
43
+ },
44
+ "dsh": {
45
+ "client": {
46
+ "inject": [
47
+ "@deepseek-ai/dsh-client-runtime",
48
+ "@deepseek-ai/dsh-client-ui-layout",
49
+ "@deepseek-ai/dsh-client-ui-slots",
50
+ "@deepseek-ai/dsh-client-ui-primitives"
51
+ ],
52
+ "platform": "web"
53
+ }
54
+ },
55
+ "files": [
56
+ "lib/index.js",
57
+ "lib/client.js",
58
+ "lib/types/**/*.d.ts",
59
+ "README.zh.md"
60
+ ],
61
+ "scripts": {
62
+ "build": "npm run build:lib && npm run build:bundle",
63
+ "build:lib": "tsc -p tsconfig.json",
64
+ "build:bundle": "tsdown --env.DSH_BUILD_FACE client",
65
+ "dev": "npm run build && node dev/watch.mjs",
66
+ "smoke:seed": "node dev/seed-smoke-session.mjs",
67
+ "smoke:hmr": "node dev/hmr-smoke.mjs",
68
+ "typecheck": "tsc -p tsconfig.json --noEmit",
69
+ "test": "npm run build:lib && node --test tests/*.test.mjs"
70
+ },
71
+ "peerDependencies": {
72
+ "@deepseek-ai/cordis": "^4.0.1",
73
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
74
+ "@deepseek-ai/dsh-client-ui-layout": "^0.1.0-rc.6",
75
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
76
+ "react": "^18.2.0",
77
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6"
78
+ },
79
+ "devDependencies": {
80
+ "@deepseek-ai/cordis": "^4.0.1",
81
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
82
+ "@deepseek-ai/dsh-client-ui-layout": "^0.1.0-rc.6",
83
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
84
+ "@types/react": "~18.3.1",
85
+ "lightningcss": "^1.32.0",
86
+ "playwright": "^1.58.2",
87
+ "react": "^18.2.0",
88
+ "tsdown": "^0.22.2",
89
+ "typescript": "^6.0.3",
90
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6"
91
+ }
92
+ }