dsh-study-ask 0.0.0-stage → 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 P1gG0dG0d
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 CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
1
+ # 学习提问 · dsh-study-ask
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 在 DSH 主对话里**选中一段文字 → 点「提问」**,右侧栏开出一个「提问对话」,与主学习对话**分屏并排**;提问对话**不会出现在左侧会话列表里**,也不再是「每次提问新建一个会话」。
4
+
5
+ > 适用:DSH `0.2.1-rc.3` 桌面版 / Web(原生右侧栏 `@deepseek-ai/dsh-client-ui-sidebar-right`)。纯客户端插件,无宿主逻辑、无构建链。
6
+
7
+ ## 为什么做它
8
+
9
+ 用 DSH 学习时,一段长回答里总有想追问的细节,但直接在主对话里问会把正在学的内容顶走。已有插件(如 `dsh-sidebar-qa`)能划词追问,但**每问一次就在左栏新建一个 `❓` 会话**,很快就堆满列表。
10
+
11
+ 本插件把提问会话收进侧边栏:
12
+
13
+ | | 行为 |
14
+ |---|---|
15
+ | 每个主对话 | 只有**一个**提问会话,第一次提问时创建,之后一直复用(同一话题的追问累积在同一线程里) |
16
+ | 左侧列表 | 提问会话**不显示**(`display:none`,不删除数据) |
17
+ | 右侧栏 | 提问对话以原生对话形态出现:流式回答、Markdown、输入框、模型选择、停止、审批全部复用 DSH 本体 |
18
+ | 主对话 | 完全不动:不新增消息、不滚动、不打断 |
19
+
20
+ ## 用法
21
+
22
+ 1. 在主对话里选中一段文字 → 浮出「提问」按钮 → 点击。
23
+ 2. 右侧栏出现「提问对话」,引文已按 `> 引用` 格式填进输入框。
24
+ 3. 接着写你的问题,回车发送;继续追问就在同一个提问对话里接着问。
25
+ 4. 想再开提问对话:点输入框左侧的「❓ 提问对话」(当前主对话没有提问会话时会自动创建)。
26
+ 5. 关掉右侧栏 tab 不影响数据;下次点「提问对话」会重新打开同一个会话。
27
+
28
+ ## 实现要点
29
+
30
+ ```text
31
+ 选中文字
32
+ → 浮层「提问」按钮(只认 [data-slot="conversation.session"] 里的选区,排除输入框和右栏)
33
+ → ensureQuestionSession(mainId)
34
+ · 先算确定性 id:session-<uuidv5(mainId)>,存在就直接复用(重启后也能认回)
35
+ · 再查 Cookie 映射(Cookie 按 host 隔离、不按端口,而桌面版每次 --port 0 随机端口)
36
+ · 都没有才创建:ctx.sessions.create({ sessionId: 确定性id, workspaceId | cwd })
37
+ → ctx.sidebarRight.openResource('dsh-resource://studyask/session/<id>')
38
+ → 本插件注册的 studyask 资源 provider(ctx.sessions.retain)
39
+ → `sidebar.right.pane.tab` 席位 + `sidebar.right.pane.tab.title`
40
+ → 内嵌 `conversation.content`(variant: embedded),与官方 subagent 侧栏同构
41
+ → conversation.input.shell(questionId).actions.setDraft() 写入引文
42
+ ```
43
+
44
+ ### 为什么不用 localStorage 记「主对话 → 提问会话」
45
+
46
+ 桌面版宿主固定用 `--port 0` 启动(见 `dsh-desktop-host` 的 `args: ["--port","0"]`),每次启动端口都不同;而 `localStorage` 按 origin(含端口)隔离,重启即丢。所以映射改由**两层持久化**保证:
47
+
48
+ 1. **确定性会话 id**:提问会话的 id 是 `session-<uuidv5(主会话id)>`,创建时向宿主申请这个 id;重启后遍历会话列表重算一遍就能认回,不依赖任何存储。
49
+ 2. **Cookie 兜底**:`dsh_study_ask_map`,按 host 隔离、不随端口变化,用于宿主没接受申请 id 的情况。
50
+
51
+ 两条路都失效时才会新建提问会话;旧提问会话仍留在数据里(只是左栏隐藏)。
52
+
53
+ ### 选区里的公式
54
+
55
+ KaTeX 每个公式都会同时渲染一份视觉 HTML 和一份视觉隐藏的 MathML,所以直接用 `selection.toString()` 取引文会得到「每个符号一行 + 再重复一遍」的乱码。本插件改为从 DOM 克隆选区再重写:
56
+
57
+ - 选区落在某个公式内部 → 整个公式以 `$TeX$` 引用;
58
+ - 选区跨过公式 → 每个 `.katex` 节点替换成它的 `annotation`(TeX 源码),再取文本,公式只出现一次。
59
+
60
+ 例如选中上面那段会得到:
61
+
62
+ ```text
63
+ > $y(x,t) = A\cos\left[\omega t + \varphi_p - k\,(x - x_p)\right] \quad(\text{沿 } +x)$
64
+ >
65
+ > (沿 −x 传播则把减号换加号。)操作要点:先确定参考点在传播方向的上游还是下游……
66
+ ```
67
+
68
+ - **隐藏左栏行**:注入一张样式表,规则为 `[data-row-key="session:<id>"]{display:none!important}`,只覆盖本插件登记的提问会话;会话数据、日志、上下文全部保留。
69
+ - **入口**:`conversation.input.left` 的「❓ 提问对话」按钮 + 选区浮层。
70
+ - **重复打开**:同一提问会话在右栏按 `contentId` 去重,聚焦已有 tab。
71
+
72
+ ## 安装
73
+
74
+ ```bash
75
+ # 本地目录(开发)
76
+ dsh plugin --profile desktop add "link:D:/Projects/PluginsCreation/dsh-study-ask"
77
+
78
+ # 装完重启 DeepSeek Harness(宿主侧要重新组合 profile)
79
+ ```
80
+
81
+ 卸载:
82
+
83
+ ```bash
84
+ dsh plugin --profile desktop remove dsh-study-ask
85
+ ```
86
+
87
+ ## 已知限制
88
+
89
+ - 提问会话是**真实 DSH 会话**,只是被 CSS 隐藏在左栏;`data-row-key` 这一 DOM 约定变化时需要同步(已随 0.2.1-rc.3 实测)。
90
+ - 工作区分组的「展开其余 N 个会话」计数可能仍把它算进去(仅数字,不显示行)。
91
+ - 不做「把回答回填主对话」「学习笔记导出」——需要的话可以在这套结构上继续加。
92
+ - 与 `dsh-sidebar-qa` 同时启用会出现两个划词入口,二选一。
93
+
94
+ ## 文件
95
+
96
+ ```
97
+ dsh-study-ask/
98
+ ├── package.json # dsh.bundle.patch + dsh.client(platform: web) + exports["./client"]
99
+ ├── cordis.patch.yml # 把插件作为一行插进 profile 的 bundle 栈
100
+ ├── lib/index.js # 宿主半区:空插件(只为让 client 半区被服务)
101
+ └── lib/client.js # 浏览器半区:资源协议 / tab / 内嵌对话 / 选区浮层 / 隐藏列表
102
+ ```
103
+
104
+ MIT
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: study-ask
3
+ name: dsh-study-ask
package/lib/client.js ADDED
@@ -0,0 +1,1112 @@
1
+ /**
2
+ * Browser half of dsh-study-ask (学习提问).
3
+ *
4
+ * Select text in the main conversation, click “提问”, and a dedicated question
5
+ * conversation opens in the native right sidebar, side by side with the main
6
+ * study conversation. The question conversation is a real DSH session, but it
7
+ * is owned by this plugin: it is created once per main session, remembered in
8
+ * localStorage, and hidden from the left session list so it never competes
9
+ * with the conversations you actually browse.
10
+ *
11
+ * Mechanism (same shape as the shipped subagent sidebar chat):
12
+ *
13
+ * selection in the main transcript
14
+ * -> floating “提问” button
15
+ * -> ensureSideSession(mainSessionId) (create once, remember, hide row)
16
+ * -> ctx.sidebarRight.openResource('dsh-resource://studyask/session/<id>')
17
+ * -> our `studyask` resource provider (sessions.retain)
18
+ * -> `sidebar.right.pane.tab` seat rendering the shared conversation
19
+ * -> the quoted excerpt is pushed into that session's composer draft
20
+ *
21
+ * The question session keeps its own history, so follow-up questions about the
22
+ * same topic stay in one thread instead of spawning a new `❓` session each
23
+ * time.
24
+ *
25
+ * @module dsh-study-ask/client
26
+ */
27
+ window.__ModuleLoader__.load({
28
+ id: 'dsh-study-ask',
29
+ factory: (require) => {
30
+ var module = { exports: {} }
31
+ var exports = module.exports
32
+ var React = require('react')
33
+ var h = React.createElement
34
+
35
+ /** Client root context, captured by apply() so handlers can resolve services lazily. */
36
+ var pluginCtx = null
37
+
38
+ /** Cordis plugin name / client module id: must equal the package name. */
39
+ var NAME = 'dsh-study-ask'
40
+ /** Locale dictionary namespace owned by this plugin. */
41
+ var NS = 'studyAsk'
42
+ /** Right-sidebar tab kind registered by this plugin. */
43
+ var KIND = 'studyask'
44
+ /** Identity shared by the tab type and the body/title slot cells. */
45
+ var TAB_ID = 'dsh-study-ask'
46
+ /** Resource protocol (the `dsh-resource://` URL host). */
47
+ var PROTOCOL = 'studyask'
48
+ /** Canonical address prefix for an embedded question conversation. */
49
+ var ADDRESS_PREFIX = 'dsh-resource://studyask/session/'
50
+ /** Session-scoped slot carrying the conversation inside the tab body. */
51
+ var SLOT_CONVERSATION = 'studyask.conversation'
52
+ /** Ref source tag; the main view uses `mainView`, so keep ours distinct. */
53
+ var RESOURCE_SOURCE = 'studyAsk'
54
+ /** localStorage key: main session id -> question session id. */
55
+ var MAP_KEY = 'dsh-study-ask:map.v1'
56
+
57
+ /** Simplified Chinese dictionary (key-set source of truth). */
58
+ var zh = {
59
+ 'popover.ask': '提问',
60
+ 'popover.hint': '就选中的内容在右侧提问',
61
+ 'entry.ask': '提问对话',
62
+ 'entry.hint': '打开这一段学习对话的提问对话(选中的文字会自动带过去)',
63
+ 'tab.title': '提问对话',
64
+ 'duplicate.title': '该会话已在主区打开',
65
+ 'duplicate.body': '主区和右侧栏不能同时编辑同一个会话。把主区切到别的会话,或关闭此标签。',
66
+ 'open.failed.title': '无法打开提问对话',
67
+ 'open.failed.body': '它可能已被归档或删除。关闭此标签后重新点「提问对话」即可重建。',
68
+ 'toast.opened': '已在右侧打开提问对话',
69
+ 'toast.created': '已新建提问对话',
70
+ 'toast.noMain': '当前没有打开的对话',
71
+ 'toast.noSelection': '没有选中内容',
72
+ 'toast.createFailed': '创建提问对话失败',
73
+ 'toast.openFailed': '无法在右侧打开提问对话',
74
+ 'toast.quoteMissed': '提问对话已打开,但引文没有填进输入框'
75
+ }
76
+ /** English dictionary, key-identical to the Chinese source of truth. */
77
+ var en = {
78
+ 'popover.ask': 'Ask',
79
+ 'popover.hint': 'Ask about the selected text in the sidebar',
80
+ 'entry.ask': 'Question chat',
81
+ 'entry.hint': 'Open this conversation’s question chat (selected text comes along)',
82
+ 'tab.title': 'Question chat',
83
+ 'duplicate.title': 'This session is open in the main view',
84
+ 'duplicate.body': 'The main view and the sidebar cannot edit the same session. Switch the main view, or close this tab.',
85
+ 'open.failed.title': 'This question chat could not be opened',
86
+ 'open.failed.body': 'It may have been archived or deleted. Close this tab and use “Question chat” to rebuild it.',
87
+ 'toast.opened': 'Question chat opened in the sidebar',
88
+ 'toast.created': 'Question chat created',
89
+ 'toast.noMain': 'There is no open conversation',
90
+ 'toast.noSelection': 'Nothing is selected',
91
+ 'toast.createFailed': 'Could not create the question chat',
92
+ 'toast.openFailed': 'Could not open the question chat in the sidebar',
93
+ 'toast.quoteMissed': 'Question chat opened, but the quote did not reach the composer'
94
+ }
95
+
96
+ //#region address
97
+
98
+ /**
99
+ * Canonical resource address for one question conversation.
100
+ *
101
+ * @param sessionId - the Session to address.
102
+ * @returns the `dsh-resource://studyask/session/<id>` address.
103
+ */
104
+ function addressOf(sessionId) {
105
+ return ADDRESS_PREFIX + encodeURIComponent(String(sessionId))
106
+ }
107
+
108
+ /**
109
+ * Parse one canonical question-conversation resource address.
110
+ *
111
+ * @param value - possible question-chat resource address.
112
+ * @returns the encoded Session id, or undefined for another or malformed resource.
113
+ */
114
+ function parseAddress(value) {
115
+ if (typeof value !== 'string' || value === '') return undefined
116
+ var url
117
+ try {
118
+ url = new URL(value)
119
+ } catch (_invalidUrl) {
120
+ return undefined
121
+ }
122
+ if (url.protocol !== 'dsh-resource:' || url.hostname !== PROTOCOL) return undefined
123
+ var parts = url.pathname.split('/').filter(Boolean)
124
+ if (parts.length !== 2 || parts[0] !== 'session') return undefined
125
+ var sessionId
126
+ try {
127
+ sessionId = decodeURIComponent(parts[1])
128
+ } catch (_invalidEncoding) {
129
+ return undefined
130
+ }
131
+ return sessionId === '' ? undefined : sessionId
132
+ }
133
+
134
+ /**
135
+ * Settle when the resource stream's signal aborts.
136
+ *
137
+ * @param signal - the resource record's lifetime signal.
138
+ * @returns a promise resolved on abort (or already resolved).
139
+ */
140
+ function waitAbort(signal) {
141
+ if (signal.aborted) return Promise.resolve()
142
+ return new Promise(function (resolve) {
143
+ signal.addEventListener('abort', resolve, { once: true })
144
+ })
145
+ }
146
+ //#endregion
147
+
148
+ //#region store: main session -> owned question session
149
+
150
+ /**
151
+ * The Desktop shell serves the UI on `--port 0` (a fresh port every launch),
152
+ * so `localStorage` — which is keyed by origin, and origin includes the port
153
+ * — is wiped on every restart. Two durable devices replace it:
154
+ *
155
+ * 1. a deterministic session id: `session-<uuidv5(mainId)>` requested when
156
+ * the question session is created, so the mapping can be recomputed;
157
+ * 2. a cookie, which is scoped by host (not port) and therefore survives
158
+ * the port change when the Host declines the requested id.
159
+ */
160
+ var COOKIE_NAME = 'dsh_study_ask_map'
161
+ /** In-memory set of the question session ids this plugin owns. */
162
+ var ownedIds = {}
163
+ /** UUIDv5 namespace (RFC 4122 DNS namespace bytes). */
164
+ var UUID_NAMESPACE = [0x6b, 0xa7, 0xb8, 0x10, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8]
165
+
166
+ /** Read one cookie value (URL-decoded), or null. */
167
+ function readCookie(name) {
168
+ if (typeof document === 'undefined') return null
169
+ try {
170
+ var parts = String(document.cookie || '').split(';')
171
+ for (var i = 0; i < parts.length; i += 1) {
172
+ var part = parts[i].trim()
173
+ var eq = part.indexOf('=')
174
+ if (eq < 0) continue
175
+ if (part.slice(0, eq) !== name) continue
176
+ return decodeURIComponent(part.slice(eq + 1))
177
+ }
178
+ } catch (_cookieError) {
179
+ // Cookie access can be blocked; the deterministic id still works.
180
+ }
181
+ return null
182
+ }
183
+
184
+ /** Write one long-lived first-party cookie. */
185
+ function writeCookie(name, value) {
186
+ if (typeof document === 'undefined') return
187
+ try {
188
+ document.cookie =
189
+ name + '=' + encodeURIComponent(value) + '; Path=/; Max-Age=31536000; SameSite=Lax'
190
+ } catch (_cookieError) {
191
+ // Ignore: the deterministic id remains the primary mechanism.
192
+ }
193
+ }
194
+
195
+ /** Read the persisted main->question map (cookie first, then localStorage). */
196
+ function loadMap() {
197
+ if (typeof window === 'undefined') return {}
198
+ var raw = readCookie(COOKIE_NAME)
199
+ if (raw === null) {
200
+ try {
201
+ raw = window.localStorage.getItem(MAP_KEY)
202
+ } catch (_mapError) {
203
+ raw = null
204
+ }
205
+ }
206
+ if (raw === null || raw === '') return {}
207
+ try {
208
+ var parsed = JSON.parse(raw)
209
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
210
+ } catch (_parseError) {
211
+ return {}
212
+ }
213
+ }
214
+
215
+ /** Persist the main->question map in both devices (best effort). */
216
+ function saveMap(map) {
217
+ if (typeof window === 'undefined') return
218
+ var raw
219
+ try {
220
+ raw = JSON.stringify(map)
221
+ } catch (_serializeError) {
222
+ return
223
+ }
224
+ writeCookie(COOKIE_NAME, raw)
225
+ try {
226
+ window.localStorage.setItem(MAP_KEY, raw)
227
+ } catch (_mapError) {
228
+ // A full or blocked localStorage must not break the ask flow.
229
+ }
230
+ }
231
+
232
+ /** Every question session id this plugin knows about. */
233
+ function ownedSessionIds() {
234
+ var map = loadMap()
235
+ var out = []
236
+ var seen = {}
237
+ for (var key in map) {
238
+ if (typeof map[key] === 'string' && map[key] !== '' && seen[map[key]] !== true) {
239
+ seen[map[key]] = true
240
+ out.push(map[key])
241
+ }
242
+ }
243
+ for (var owned in ownedIds) {
244
+ if (seen[owned] !== true) {
245
+ seen[owned] = true
246
+ out.push(owned)
247
+ }
248
+ }
249
+ return out
250
+ }
251
+
252
+ /** SHA-1 over a byte array. */
253
+ async function sha1Bytes(bytes) {
254
+ var digest = await crypto.subtle.digest('SHA-1', bytes)
255
+ return new Uint8Array(digest)
256
+ }
257
+
258
+ /** Format 16 bytes as a version-5 UUID string. */
259
+ function uuidFromBytes(bytes) {
260
+ var copy = new Uint8Array(bytes)
261
+ copy[6] = (copy[6] & 0x0f) | 0x50
262
+ copy[8] = (copy[8] & 0x3f) | 0x80
263
+ var hex = ''
264
+ for (var i = 0; i < 16; i += 1) hex += (copy[i] + 0x100).toString(16).slice(1)
265
+ return (
266
+ hex.slice(0, 8) +
267
+ '-' +
268
+ hex.slice(8, 12) +
269
+ '-' +
270
+ hex.slice(12, 16) +
271
+ '-' +
272
+ hex.slice(16, 20) +
273
+ '-' +
274
+ hex.slice(20)
275
+ )
276
+ }
277
+
278
+ /**
279
+ * The deterministic question-session id for one main session. Requesting it
280
+ * on create makes the mapping recomputable after any restart, with no
281
+ * storage at all.
282
+ *
283
+ * @param mainId - the main (study) session id.
284
+ * @returns `session-<uuidv5>`, or undefined when WebCrypto is unavailable.
285
+ */
286
+ async function deterministicId(mainId) {
287
+ if (typeof crypto === 'undefined' || crypto.subtle === undefined) return undefined
288
+ try {
289
+ var name = new TextEncoder().encode(String(mainId))
290
+ var bytes = new Uint8Array(UUID_NAMESPACE.length + name.length)
291
+ bytes.set(UUID_NAMESPACE, 0)
292
+ bytes.set(name, UUID_NAMESPACE.length)
293
+ var digest = await sha1Bytes(bytes)
294
+ return 'session-' + uuidFromBytes(digest)
295
+ } catch (_hashError) {
296
+ return undefined
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Recompute which sessions this plugin owns: for every known session id,
302
+ * its deterministic question id either exists (we created it earlier) or
303
+ * does not. This recovers the mapping without any persisted state.
304
+ */
305
+ async function rebuildOwned(ctx) {
306
+ var rows = sessionRows(ctx)
307
+ var ids = Object.keys(rows)
308
+ var map = loadMap()
309
+ var changed = false
310
+ for (var i = 0; i < ids.length; i += 1) {
311
+ var mainId = ids[i]
312
+ var det = await deterministicId(mainId)
313
+ if (det === undefined || rows[det] === undefined) continue
314
+ if (ownedIds[det] !== true) {
315
+ ownedIds[det] = true
316
+ changed = true
317
+ }
318
+ if (map[mainId] !== det) {
319
+ map[mainId] = det
320
+ changed = true
321
+ }
322
+ }
323
+ for (var key in map) {
324
+ if (rows[map[key]] !== undefined) ownedIds[map[key]] = true
325
+ }
326
+ if (changed) saveMap(map)
327
+ return changed
328
+ }
329
+
330
+ /** The session list rows the client currently knows about. */
331
+ function sessionRows(ctx) {
332
+ try {
333
+ return ctx.sessions.list.getSnapshot().byId || {}
334
+ } catch (_rowsError) {
335
+ return {}
336
+ }
337
+ }
338
+
339
+ /** Whether a session id still exists in the client's session list. */
340
+ function sessionExists(ctx, sessionId) {
341
+ return sessionRows(ctx)[sessionId] !== undefined
342
+ }
343
+
344
+ //#endregion
345
+
346
+ //#region hide owned sessions from the left list
347
+
348
+ var HIDDEN_CSS_ID = 'dsh-study-ask/hidden.css'
349
+
350
+ /**
351
+ * Keep a stylesheet that hides every owned question session from the left
352
+ * session tree. The tree renders one row per session with
353
+ * `data-row-key="session:<id>"`, so a static rule keeps hiding rows that
354
+ * appear later (e.g. after the question session's first message).
355
+ */
356
+ function syncHiddenCss() {
357
+ if (typeof document === 'undefined') return
358
+ var rules = ''
359
+ var ids = ownedSessionIds()
360
+ for (var i = 0; i < ids.length; i += 1) {
361
+ var id = ids[i]
362
+ if (!/^[A-Za-z0-9._:-]+$/.test(id)) continue
363
+ rules += '[data-row-key="session:' + id + '"]{display:none!important}'
364
+ }
365
+ var tag = document.getElementById(HIDDEN_CSS_ID)
366
+ if (tag === null) {
367
+ tag = document.createElement('style')
368
+ tag.id = HIDDEN_CSS_ID
369
+ tag.dataset.plugin = NAME
370
+ ;(document.head || document.documentElement).appendChild(tag)
371
+ }
372
+ if (tag.textContent !== rules) tag.textContent = rules
373
+ }
374
+ //#endregion
375
+
376
+ //#region resource provider
377
+
378
+ /**
379
+ * The `studyask` resource provider: retain one Session for as long as a
380
+ * record holds the address, and hand the reference to the tab body.
381
+ *
382
+ * @param sessions - the client Sessions service.
383
+ * @returns the provider to register with `ctx.resources`.
384
+ */
385
+ function questionResourceProvider(sessions) {
386
+ return {
387
+ protocol: PROTOCOL,
388
+ async *open(resourceAddress, options) {
389
+ var signal = options && options.signal !== undefined ? options.signal : new AbortController().signal
390
+ var sessionId = parseAddress(resourceAddress)
391
+ if (sessionId === undefined) throw new Error('dsh-study-ask: invalid resource address "' + resourceAddress + '"')
392
+ if (signal.aborted) return
393
+ var reference
394
+ try {
395
+ reference = sessions.retain(sessionId, { source: RESOURCE_SOURCE, signal: signal })
396
+ } catch (error) {
397
+ // A removed/archived session is a normal dead tab, not a bundle fault.
398
+ yield { ok: false, error: error }
399
+ return
400
+ }
401
+ try {
402
+ yield { ok: true, value: { sessionId: sessionId, reference: reference } }
403
+ await waitAbort(signal)
404
+ } finally {
405
+ reference.release()
406
+ }
407
+ }
408
+ }
409
+ }
410
+ //#endregion
411
+
412
+ //#region ask flow
413
+
414
+ /** The main Session the conversation currently fills the main column with. */
415
+ function mainSessionId(ctx) {
416
+ try {
417
+ var mounted = ctx.sidebarRight === undefined ? undefined : ctx.sidebarRight.mounted
418
+ var onScreen = mounted === undefined ? undefined : mounted.getSnapshot()
419
+ if (onScreen !== undefined) return onScreen
420
+ } catch (_mountedError) {
421
+ // Fall through to the retained-row scan.
422
+ }
423
+ try {
424
+ var byId = ctx.sessions.list.getSnapshot().byId || {}
425
+ for (var key in byId) {
426
+ var row = byId[key]
427
+ if (row !== undefined && row.retainedBy !== undefined && row.retainedBy.mainView > 0) return key
428
+ }
429
+ } catch (_mainError) {
430
+ // Unknown until the session list hydrates.
431
+ }
432
+ return undefined
433
+ }
434
+
435
+ /** The workspace owning a session, or undefined when ungrouped. */
436
+ function workspaceIdOf(ctx, sessionId) {
437
+ try {
438
+ var workspaces = typeof ctx.get === 'function' ? ctx.get('workspaces') : undefined
439
+ var snapshot = workspaces === undefined ? undefined : workspaces.list.getSnapshot()
440
+ var items = snapshot === undefined ? [] : snapshot.items || []
441
+ for (var i = 0; i < items.length; i += 1) {
442
+ var ids = items[i].sessionIds || []
443
+ for (var j = 0; j < ids.length; j += 1) {
444
+ if (ids[j] === sessionId) return items[i].workspaceId
445
+ }
446
+ }
447
+ } catch (_workspaceError) {
448
+ // Ungrouped sessions are still creatable from their cwd.
449
+ }
450
+ return undefined
451
+ }
452
+
453
+ /**
454
+ * Resolve (or lazily create) the question conversation owned by one main
455
+ * session. Creation is one-off: later asks reuse the same session, so the
456
+ * question thread accumulates instead of spawning a new `❓` session.
457
+ *
458
+ * @param ctx - client root context.
459
+ * @param mainId - the main (study) session the question belongs to.
460
+ * @returns the question session id.
461
+ */
462
+ async function ensureQuestionSession(ctx, mainId) {
463
+ var rows = sessionRows(ctx)
464
+ // 1) The deterministic id: survives restarts with no storage at all.
465
+ var det = await deterministicId(mainId)
466
+ if (det !== undefined && rows[det] !== undefined) {
467
+ ownedIds[det] = true
468
+ return det
469
+ }
470
+ // 2) The persisted map (cookie, then same-origin localStorage).
471
+ var map = loadMap()
472
+ var existing = map[mainId]
473
+ if (typeof existing === 'string' && existing !== '' && rows[existing] !== undefined) {
474
+ ownedIds[existing] = true
475
+ return existing
476
+ }
477
+ if (existing !== undefined) {
478
+ delete map[mainId]
479
+ saveMap(map)
480
+ syncHiddenCss()
481
+ }
482
+ // 3) Create once, asking for the deterministic id so later lookups are free.
483
+ var row = rows[mainId]
484
+ var request = {}
485
+ if (det !== undefined) request.sessionId = det
486
+ var workspaceId = workspaceIdOf(ctx, mainId)
487
+ if (workspaceId !== undefined) request.workspaceId = workspaceId
488
+ else if (row !== undefined && typeof row.cwd === 'string' && row.cwd !== '') request.cwd = row.cwd
489
+ var created = await ctx.sessions.create(request)
490
+ ownedIds[created] = true
491
+ map[mainId] = created
492
+ saveMap(map)
493
+ syncHiddenCss()
494
+ try {
495
+ var title = row !== undefined && typeof row.title === 'string' && row.title !== '' ? row.title : ''
496
+ await ctx.sessions.rename(created, title === '' ? '提问对话' : '提问 · ' + title)
497
+ } catch (_renameError) {
498
+ // The tab title is ours anyway; the durable title is cosmetic.
499
+ }
500
+ return created
501
+ }
502
+
503
+ /**
504
+ * Open one question conversation in the right sidebar (creating it first
505
+ * when needed), then push the quoted excerpt into its composer draft.
506
+ *
507
+ * @param mainId - the main session the ask belongs to.
508
+ * @param quote - selected excerpt, or null to just open the conversation.
509
+ */
510
+ async function openQuestionChat(mainId, quote) {
511
+ var ctx = pluginCtx
512
+ var t = ctx.locale.bind(NS)
513
+ if (typeof mainId !== 'string' || mainId === '') {
514
+ toast(t('toast.noMain'))
515
+ return
516
+ }
517
+ var questionId
518
+ try {
519
+ questionId = await ensureQuestionSession(ctx, mainId)
520
+ } catch (error) {
521
+ console.error('dsh-study-ask: could not create the question session', error)
522
+ toast(t('toast.createFailed'))
523
+ return
524
+ }
525
+ try {
526
+ ctx.sidebarRight.openResource(addressOf(questionId), { kind: KIND, preferNewPane: false })
527
+ } catch (error) {
528
+ console.error('dsh-study-ask: could not open the question chat', error)
529
+ toast(t('toast.openFailed'))
530
+ return
531
+ }
532
+ if (typeof quote === 'string' && quote.trim() !== '') {
533
+ var filled = await prefillQuote(questionId, quote)
534
+ toast(filled ? t('toast.opened') : t('toast.quoteMissed'))
535
+ } else {
536
+ toast(t('toast.opened'))
537
+ }
538
+ }
539
+
540
+ /** Format one excerpt as a Markdown blockquote draft. */
541
+ function quoteDraft(text) {
542
+ var lines = String(text).replace(/\r\n/g, '\n').split('\n')
543
+ var out = []
544
+ for (var i = 0; i < lines.length; i += 1) out.push('> ' + lines[i])
545
+ return out.join('\n') + '\n\n'
546
+ }
547
+
548
+ /**
549
+ * Push the excerpt into one session's composer draft, appending to what the
550
+ * user already typed. The tab has to mount first, so retry briefly.
551
+ *
552
+ * @param sessionId - the question session whose composer receives the quote.
553
+ * @param text - the selected excerpt.
554
+ * @returns whether the draft was written.
555
+ */
556
+ async function prefillQuote(sessionId, text) {
557
+ var draftText = quoteDraft(text)
558
+ for (var attempt = 0; attempt < 40; attempt += 1) {
559
+ try {
560
+ var conversation = typeof pluginCtx.get === 'function' ? pluginCtx.get('conversation') : undefined
561
+ var input = conversation === undefined || conversation === null ? undefined : conversation.input
562
+ if (input !== undefined && typeof input.shell === 'function') {
563
+ var shell = input.shell(sessionId)
564
+ var state = shell.state.getSnapshot()
565
+ var draft = typeof state.draft === 'string' ? state.draft : ''
566
+ var next = draft.trim() === '' ? draftText : draft.replace(/\s+$/, '') + '\n\n' + draftText
567
+ shell.actions.setDraft(next)
568
+ return true
569
+ }
570
+ } catch (_prefillError) {
571
+ // The shell is not mounted yet; retry.
572
+ }
573
+ await new Promise(function (resolve) {
574
+ setTimeout(resolve, 100)
575
+ })
576
+ }
577
+ return false
578
+ }
579
+ //#endregion
580
+
581
+ //#region selection popover
582
+
583
+ var popoverElement = null
584
+ var popoverText = ''
585
+ var popoverFrame = 0
586
+
587
+ /** Show a short-lived confirmation toast without depending on a UI framework. */
588
+ function toast(message) {
589
+ if (typeof document === 'undefined' || document.body === undefined || document.body === null) return
590
+ var element = document.createElement('div')
591
+ element.className = 'dsa-toast'
592
+ element.textContent = message
593
+ document.body.appendChild(element)
594
+ setTimeout(function () {
595
+ element.className = 'dsa-toast dsa-toast-out'
596
+ }, 1500)
597
+ setTimeout(function () {
598
+ element.remove()
599
+ }, 2000)
600
+ }
601
+
602
+ /** The lazily created floating “提问” button. */
603
+ function ensurePopover() {
604
+ if (popoverElement !== null) return popoverElement
605
+ popoverElement = document.createElement('button')
606
+ popoverElement.type = 'button'
607
+ popoverElement.className = 'dsa-pop'
608
+ popoverElement.setAttribute('data-dsa-popover', '')
609
+ popoverElement.textContent = '提问'
610
+ popoverElement.addEventListener('mousedown', function (event) {
611
+ // Keep the selection alive while the button is pressed.
612
+ event.preventDefault()
613
+ event.stopPropagation()
614
+ })
615
+ popoverElement.addEventListener('click', function (event) {
616
+ event.preventDefault()
617
+ event.stopPropagation()
618
+ var text = popoverText
619
+ hidePopover()
620
+ var mainId = mainSessionId(pluginCtx)
621
+ if (text.trim() === '') {
622
+ toast(pluginCtx.locale.bind(NS)('toast.noSelection'))
623
+ return
624
+ }
625
+ openQuestionChat(mainId, text)
626
+ })
627
+ document.body.appendChild(popoverElement)
628
+ return popoverElement
629
+ }
630
+
631
+ /** Hide the floating button and forget the pending excerpt. */
632
+ function hidePopover() {
633
+ if (popoverElement !== null) popoverElement.removeAttribute('data-dsa-open')
634
+ popoverText = ''
635
+ }
636
+
637
+ /** Place the floating button above one selection rectangle. */
638
+ function showPopover(rect) {
639
+ var element = ensurePopover()
640
+ if (pluginCtx !== null && pluginCtx.locale !== undefined) {
641
+ element.textContent = pluginCtx.locale.bind(NS)('popover.ask')
642
+ }
643
+ var width = 64
644
+ var left = rect.left + rect.width / 2 - width / 2
645
+ if (left < 8) left = 8
646
+ if (left + width > window.innerWidth - 8) left = window.innerWidth - 8 - width
647
+ var top = rect.top - 38
648
+ if (top < 8) top = rect.bottom + 8
649
+ element.style.left = Math.round(left) + 'px'
650
+ element.style.top = Math.round(top) + 'px'
651
+ element.setAttribute('data-dsa-open', '')
652
+ }
653
+
654
+ /** The TeX source of one KaTeX node, or ''. */
655
+ function texOfKatex(node) {
656
+ try {
657
+ var annotation = node.querySelector('annotation[encoding="application/x-tex"]')
658
+ if (annotation === null) annotation = node.querySelector('annotation')
659
+ if (annotation === null) return ''
660
+ return String(annotation.textContent || '').trim()
661
+ } catch (_texError) {
662
+ return ''
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Build the excerpt from the DOM instead of `selection.toString()`: KaTeX
668
+ * keeps a visually hidden MathML copy next to the visual HTML, so plain
669
+ * selection text contains every formula twice and as scattered glyphs.
670
+ * A formula contributes its TeX source as `$…$` instead.
671
+ *
672
+ * @param range - the selected range.
673
+ * @returns the excerpt to quote.
674
+ */
675
+ function excerptOfRange(range) {
676
+ var startElement =
677
+ range.startContainer.nodeType === 3 ? range.startContainer.parentElement : range.startContainer
678
+ var startKatex =
679
+ startElement !== null && startElement !== undefined && typeof startElement.closest === 'function'
680
+ ? startElement.closest('.katex')
681
+ : null
682
+ // A selection inside one formula quotes that whole formula as TeX.
683
+ if (startKatex !== null && startKatex !== undefined && startKatex.contains(range.endContainer)) {
684
+ var innerTex = texOfKatex(startKatex)
685
+ if (innerTex !== '') return '$' + innerTex + '$'
686
+ }
687
+ var holder
688
+ try {
689
+ holder = document.createElement('div')
690
+ holder.appendChild(range.cloneContents())
691
+ } catch (_cloneError) {
692
+ return String(window.getSelection() || '').trim()
693
+ }
694
+ var maths = holder.querySelectorAll('.katex')
695
+ for (var i = 0; i < maths.length; i += 1) {
696
+ var node = maths[i]
697
+ if (node.parentNode === null) continue
698
+ var tex = texOfKatex(node)
699
+ var replacement = document.createElement('span')
700
+ replacement.textContent = tex === '' ? '' : '$' + tex + '$'
701
+ node.parentNode.replaceChild(replacement, node)
702
+ }
703
+ var text = String(holder.textContent || '')
704
+ return text
705
+ .replace(/[ \t]+\n/g, '\n')
706
+ .replace(/\n{3,}/g, '\n\n')
707
+ .replace(/[ \t]{2,}/g, ' ')
708
+ .trim()
709
+ }
710
+
711
+ /**
712
+ * Recompute the popover from the current selection: only selections inside
713
+ * the main conversation transcript, outside the composer and the right
714
+ * sidebar, qualify.
715
+ */
716
+ function computeSelection() {
717
+ if (typeof window === 'undefined' || typeof document === 'undefined') return
718
+ var selection = window.getSelection()
719
+ if (selection === null || selection.isCollapsed === true || selection.rangeCount === 0) {
720
+ hidePopover()
721
+ return
722
+ }
723
+ var range = selection.getRangeAt(0)
724
+ var anchor = range.startContainer
725
+ var element = anchor.nodeType === 3 ? anchor.parentElement : anchor
726
+ if (element === null || element === undefined || typeof element.closest !== 'function') {
727
+ hidePopover()
728
+ return
729
+ }
730
+ if (element.closest('[data-sidebar-right-panel]') !== null) {
731
+ hidePopover()
732
+ return
733
+ }
734
+ if (element.closest('[data-slot="conversation.composer.bar"]') !== null) {
735
+ hidePopover()
736
+ return
737
+ }
738
+ if (element.closest('[data-slot="conversation.session"]') === null) {
739
+ hidePopover()
740
+ return
741
+ }
742
+ var rect = range.getBoundingClientRect()
743
+ if (rect.width === 0 && rect.height === 0) {
744
+ hidePopover()
745
+ return
746
+ }
747
+ var text = excerptOfRange(range)
748
+ if (text === '' || text.length > 20000) {
749
+ hidePopover()
750
+ return
751
+ }
752
+ popoverText = text
753
+ showPopover(rect)
754
+ }
755
+
756
+ /** Debounced recompute: selectionchange fires continuously while dragging. */
757
+ function scheduleSelection() {
758
+ if (popoverFrame !== 0) return
759
+ popoverFrame = setTimeout(function () {
760
+ popoverFrame = 0
761
+ computeSelection()
762
+ }, 160)
763
+ }
764
+
765
+ /**
766
+ * Install the selection watcher.
767
+ *
768
+ * @returns disposer removing every listener and the floating node.
769
+ */
770
+ function startSelectionWatcher() {
771
+ if (typeof document === 'undefined') return function () {}
772
+ var onMouseDown = function (event) {
773
+ if (popoverElement !== null && popoverElement.contains(event.target)) return
774
+ hidePopover()
775
+ }
776
+ var onScroll = function () {
777
+ hidePopover()
778
+ }
779
+ document.addEventListener('mouseup', scheduleSelection, true)
780
+ document.addEventListener('keyup', scheduleSelection, true)
781
+ document.addEventListener('selectionchange', scheduleSelection)
782
+ document.addEventListener('mousedown', onMouseDown, true)
783
+ window.addEventListener('scroll', onScroll, true)
784
+ return function () {
785
+ document.removeEventListener('mouseup', scheduleSelection, true)
786
+ document.removeEventListener('keyup', scheduleSelection, true)
787
+ document.removeEventListener('selectionchange', scheduleSelection)
788
+ document.removeEventListener('mousedown', onMouseDown, true)
789
+ window.removeEventListener('scroll', onScroll, true)
790
+ if (popoverFrame !== 0) clearTimeout(popoverFrame)
791
+ if (popoverElement !== null) {
792
+ popoverElement.remove()
793
+ popoverElement = null
794
+ }
795
+ }
796
+ }
797
+ //#endregion
798
+
799
+ //#region components
800
+
801
+ /** Fixed Chat selection used by an embedded Conversation occurrence. */
802
+ function FixedChatView(props) {
803
+ return h(React.Fragment, null, props.renderSlot('conversation.session', { view: 'chat' }))
804
+ }
805
+
806
+ /**
807
+ * The conversation inside the right-sidebar tab: the shared Conversation
808
+ * content bound to the question session, plus the duplicate-open notice
809
+ * while the main view also retains it.
810
+ */
811
+ function EmbeddedConversation(props) {
812
+ var sessionId = props.sessionId
813
+ var t = props.t
814
+ var session = props.useSession(function (value) {
815
+ return value
816
+ })
817
+ var useSessionRetainInfo = props.useSessionRetainInfo
818
+ var useSessions = props.useSessions
819
+ var mainCount
820
+ if (useSessionRetainInfo !== undefined) {
821
+ mainCount = useSessionRetainInfo(function (info) {
822
+ return info === undefined ? 0 : ((info.retainedBy && info.retainedBy.mainView) || 0)
823
+ })
824
+ } else {
825
+ mainCount = useSessions(function (state) {
826
+ var row = state.byId[sessionId]
827
+ return row === undefined || row.retainedBy === undefined ? 0 : row.retainedBy.mainView || 0
828
+ })
829
+ }
830
+ var activeTargets = props.useConversation(function (value) {
831
+ return value.activeTargets
832
+ })
833
+ var summaryBlank = useSessions(function (state) {
834
+ var row = state.byId[sessionId]
835
+ return row === undefined ? undefined : row.blank
836
+ })
837
+
838
+ if (mainCount > 0) {
839
+ return h(
840
+ 'div',
841
+ { className: 'dsa-notice', role: 'status' },
842
+ h('div', { className: 'dsa-notice-title' }, t('duplicate.title')),
843
+ h('div', { className: 'dsa-notice-body' }, t('duplicate.body'))
844
+ )
845
+ }
846
+
847
+ var shellPhase =
848
+ activeTargets !== undefined && activeTargets.size > 0
849
+ ? 'active'
850
+ : (!session.blank && !session.awaitingFirstTurn) || session.running
851
+ ? 'active'
852
+ : session.promptAttempted
853
+ ? 'engaging'
854
+ : 'blank'
855
+ var settling = shellPhase === 'blank' && session.openState === 'loading' && summaryBlank !== true
856
+ var hero = shellPhase === 'blank' && (session.openState === 'open' || summaryBlank === true)
857
+ return props.renderFactorySlot(
858
+ 'conversation.content',
859
+ { variant: 'embedded', phase: settling ? 'settling' : hero ? 'hero' : 'active', hero: hero },
860
+ { slots: { views: FixedChatView } }
861
+ )
862
+ }
863
+
864
+ /** One right-sidebar tab body: bind the retained Session around our slot. */
865
+ function QuestionTab(props) {
866
+ var useResource = props.useResource
867
+ var useTabInfo = props.useTabInfo
868
+ var SessionProvider = props.SessionProvider
869
+ var renderSlot = props.renderSlot
870
+ var t = props.t
871
+ var tab
872
+ try {
873
+ tab = useTabInfo().tab
874
+ } catch (_tabNotCommitted) {
875
+ tab = undefined
876
+ }
877
+ var resource = useResource(tab === undefined ? '' : tab.contentId)
878
+ var children = null
879
+ if (resource !== undefined && resource.value !== undefined) {
880
+ children = h(SessionProvider, { session: resource.value.reference }, renderSlot(SLOT_CONVERSATION, {}))
881
+ } else if (resource !== undefined && resource.status === 'failed') {
882
+ children = h(
883
+ 'div',
884
+ { className: 'dsa-notice', role: 'status' },
885
+ h('div', { className: 'dsa-notice-title' }, t('open.failed.title')),
886
+ h('div', { className: 'dsa-notice-body' }, t('open.failed.body'))
887
+ )
888
+ }
889
+ var sessionId = resource !== undefined && resource.value !== undefined ? resource.value.sessionId : undefined
890
+ return h(
891
+ 'div',
892
+ {
893
+ className: 'dsa-root',
894
+ 'data-study-ask': '',
895
+ 'data-study-ask-session': sessionId === undefined ? undefined : sessionId
896
+ },
897
+ children
898
+ )
899
+ }
900
+
901
+ /** The tab chip title. */
902
+ function QuestionTabTitle(props) {
903
+ return props.t('tab.title')
904
+ }
905
+
906
+ /**
907
+ * Composer control: open this conversation's question chat in the sidebar
908
+ * (creating it on first use, no quote).
909
+ */
910
+ function AskEntryButton(props) {
911
+ return h(
912
+ 'button',
913
+ {
914
+ type: 'button',
915
+ className: 'dsa-entry',
916
+ 'data-study-ask-entry': '',
917
+ title: props.t('entry.hint'),
918
+ 'aria-label': props.t('entry.ask'),
919
+ onClick: function () {
920
+ openQuestionChat(props.sessionId, null)
921
+ }
922
+ },
923
+ h('span', { className: 'dsa-entry-label' }, props.t('entry.ask'))
924
+ )
925
+ }
926
+ //#endregion
927
+
928
+ //#region css
929
+
930
+ var CSS_ID = 'dsh-study-ask/style.css'
931
+ var CSS =
932
+ '.dsa-root{width:100%;min-width:0;height:100%;min-height:0;display:flex;flex-direction:column}' +
933
+ '.dsa-notice{padding:24px 20px;display:flex;flex-direction:column;gap:8px;font-size:13px;' +
934
+ 'line-height:20px;color:var(--dsw-alias-label-secondary,#666);height:100%;box-sizing:border-box}' +
935
+ '.dsa-notice-title{font-weight:600;color:var(--dsw-alias-label-primary,#111)}' +
936
+ '.dsa-notice-body{max-width:44em}' +
937
+ '.dsa-pop{position:fixed;z-index:9998;display:none;align-items:center;justify-content:center;' +
938
+ 'height:30px;min-width:64px;padding:0 12px;border-radius:999px;cursor:pointer;' +
939
+ 'font-family:inherit;font-size:12px;line-height:30px;font-weight:500;white-space:nowrap;' +
940
+ 'background-color:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111);' +
941
+ 'background-image:linear-gradient(var(--dsw-specific-menu,rgba(248,249,250,.58)),' +
942
+ 'var(--dsw-specific-menu,rgba(248,249,250,.58)));' +
943
+ 'border:1px solid var(--dsw-alias-border-1,rgba(0,0,0,.10));' +
944
+ 'box-shadow:var(--dsw-elevation-prominent,0 6px 20px rgba(0,0,0,.18))}' +
945
+ '.dsa-pop[data-dsa-open]{display:inline-flex}' +
946
+ '.dsa-pop:hover{color:var(--dsw-alias-brand-primary,#4d6bfe)}' +
947
+ '.dsa-entry{display:inline-flex;align-items:center;gap:4px;height:26px;padding:0 8px;border:0;' +
948
+ 'border-radius:6px;background:none;cursor:pointer;font:inherit;font-size:12px;' +
949
+ 'color:var(--dsw-alias-label-tertiary,#999);flex:none}' +
950
+ '.dsa-entry:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));' +
951
+ 'color:var(--dsw-alias-label-primary,#111)}' +
952
+ '.dsa-toast{position:fixed;left:50%;bottom:72px;transform:translateX(-50%);z-index:9999;' +
953
+ 'padding:6px 12px;border-radius:8px;font-size:12px;line-height:18px;pointer-events:none;' +
954
+ 'background:var(--dsw-alias-bg-layer-3,#2b2b2e);color:var(--dsw-alias-label-primary,#fff);' +
955
+ 'box-shadow:var(--dsw-elevation-prominent,0 6px 20px rgba(0,0,0,.28));opacity:1;' +
956
+ 'transition:opacity .3s ease}' +
957
+ '.dsa-toast-out{opacity:0}'
958
+
959
+ /** Inject the stylesheet once; HMR remounts must refresh it, not stack it. */
960
+ function injectCss() {
961
+ if (typeof document === 'undefined') return
962
+ var existing = document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_ID) + ']')
963
+ if (existing !== null) {
964
+ // A hot-reloaded bundle keeps the old tag: refresh its rules.
965
+ if (existing.textContent !== CSS) existing.textContent = CSS
966
+ return
967
+ }
968
+ var tag = document.createElement('style')
969
+ tag.dataset.plugin = NAME
970
+ tag.dataset.pluginCss = CSS_ID
971
+ tag.textContent = CSS
972
+ document.head.appendChild(tag)
973
+ }
974
+ //#endregion
975
+
976
+ //#region plugin body
977
+
978
+ /**
979
+ * Client plugin body: inject copy and style, hide owned sessions, then
980
+ * contribute the resource protocol, the tab type/body/title, the embedded
981
+ * conversation, the selection watcher, and the composer entry point.
982
+ *
983
+ * @param ctx - the client Cordis context.
984
+ */
985
+ function apply(ctx) {
986
+ pluginCtx = ctx
987
+ injectCss()
988
+ syncHiddenCss()
989
+ ctx.effect(function () {
990
+ return ctx.locale.register(NS, { zh: zh, en: en })
991
+ }, 'dsh-study-ask: dictionaries')
992
+
993
+ ctx.effect(function () {
994
+ return startSelectionWatcher()
995
+ }, 'dsh-study-ask: selection watcher')
996
+
997
+ // Keep the left list clean: recover owned ids (deterministic mapping) and
998
+ // refresh the hiding stylesheet whenever the session set changes.
999
+ ctx.effect(function () {
1000
+ var lastCount = -1
1001
+ var busy = false
1002
+ var tick = function () {
1003
+ if (busy) return
1004
+ busy = true
1005
+ var count = -1
1006
+ try {
1007
+ count = Object.keys(sessionRows(ctx)).length
1008
+ } catch (_countError) {
1009
+ count = -1
1010
+ }
1011
+ if (count === lastCount) {
1012
+ busy = false
1013
+ return
1014
+ }
1015
+ lastCount = count
1016
+ rebuildOwned(ctx)
1017
+ .catch(function () {})
1018
+ .then(function () {
1019
+ busy = false
1020
+ syncHiddenCss()
1021
+ })
1022
+ }
1023
+ tick()
1024
+ var timer = setInterval(tick, 3000)
1025
+ return function () {
1026
+ clearInterval(timer)
1027
+ }
1028
+ }, 'dsh-study-ask: hidden rows')
1029
+
1030
+ var conversationChildren = {}
1031
+ conversationChildren[SLOT_CONVERSATION] = { kind: 'single', scope: 'session' }
1032
+
1033
+ ctx.inject(['resources', 'sidebarRightTabs'], function (scope) {
1034
+ scope.effect(function () {
1035
+ return scope.resources.register(questionResourceProvider(ctx.sessions))
1036
+ }, 'dsh-study-ask: resource provider')
1037
+ scope.effect(function () {
1038
+ return scope.sidebarRightTabs.register({
1039
+ id: TAB_ID,
1040
+ kind: KIND,
1041
+ patterns: [ADDRESS_PREFIX + '**'],
1042
+ priority: 'extension',
1043
+ canOpen: function (address) {
1044
+ return parseAddress(address) !== undefined
1045
+ },
1046
+ title: function () {
1047
+ return ctx.locale.bind(NS)('tab.title')
1048
+ }
1049
+ })
1050
+ }, 'dsh-study-ask: tab type')
1051
+ scope.effect(function () {
1052
+ return scope.slots.inject('sidebar.right.pane.tab', function () {
1053
+ return scope.slots.register(
1054
+ { name: 'sidebar.right.pane.tab', key: TAB_ID, locale: NS, children: conversationChildren },
1055
+ QuestionTab
1056
+ )
1057
+ })
1058
+ }, 'dsh-study-ask: tab body')
1059
+ scope.effect(function () {
1060
+ return scope.slots.inject('sidebar.right.pane.tab.title', function () {
1061
+ return scope.slots.register({ name: 'sidebar.right.pane.tab.title', key: TAB_ID, locale: NS }, QuestionTabTitle)
1062
+ })
1063
+ }, 'dsh-study-ask: tab title')
1064
+ scope.effect(function () {
1065
+ return scope.slots.inject(SLOT_CONVERSATION, function () {
1066
+ return scope.slots.register({ name: SLOT_CONVERSATION, locale: NS }, EmbeddedConversation)
1067
+ })
1068
+ }, 'dsh-study-ask: conversation')
1069
+ })
1070
+
1071
+ ctx.slots.inject('conversation.input.left', function () {
1072
+ return ctx.slots.register(
1073
+ { name: 'conversation.input.left', id: 'study-ask.entry', order: 850, locale: NS },
1074
+ AskEntryButton
1075
+ )
1076
+ })
1077
+
1078
+ // Debug handle for manual verification from the DevTools console.
1079
+ if (typeof window !== 'undefined') {
1080
+ window.__dshStudyAsk = {
1081
+ map: loadMap,
1082
+ owned: ownedSessionIds,
1083
+ detId: function (mainId) {
1084
+ return deterministicId(mainId)
1085
+ },
1086
+ rebuild: function () {
1087
+ return rebuildOwned(ctx)
1088
+ },
1089
+ createWithId: function (sessionId) {
1090
+ return ctx.sessions.create({ sessionId: sessionId })
1091
+ },
1092
+ open: function (mainId) {
1093
+ return openQuestionChat(mainId === undefined ? mainSessionId(ctx) : mainId, null)
1094
+ },
1095
+ clear: function () {
1096
+ saveMap({})
1097
+ ownedIds = {}
1098
+ syncHiddenCss()
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+ //#endregion
1104
+
1105
+ module.exports = {
1106
+ name: NAME,
1107
+ inject: ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight'],
1108
+ apply: apply
1109
+ }
1110
+ return module.exports
1111
+ }
1112
+ })
package/lib/index.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Host half of dsh-study-ask: no Host services are needed.
3
+ *
4
+ * The plugin exists to keep its Loader entry mounted so the client half
5
+ * (`./client`) is composed into the Web boot graph and served under
6
+ * `/plugins`; every behaviour lives in `lib/client.js`.
7
+ *
8
+ * @module dsh-study-ask
9
+ */
10
+
11
+ /** Cordis plugin name; must equal the package name so the client bundle id matches. */
12
+ export const name = 'dsh-study-ask'
13
+
14
+ /** Does nothing: the whole feature is browser-side. */
15
+ export function apply() {}
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "dsh-study-ask",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "author": "P1gG0dG0d",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/P1gG0dG0d/dsh-study-ask.git"
9
+ },
10
+ "homepage": "https://github.com/P1gG0dG0d/dsh-study-ask#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/P1gG0dG0d/dsh-study-ask/issues"
13
+ },
14
+ "description": "学习提问 (Study Ask): select text in a DSH conversation and question it in a dedicated right-sidebar chat that never clutters the session list.",
15
+ "license": "MIT",
16
+ "keywords": [
17
+ "dsh",
18
+ "dsh-plugin",
19
+ "deepseek-harness",
20
+ "study",
21
+ "sidebar",
22
+ "question"
23
+ ],
24
+ "exports": {
25
+ ".": "./lib/index.js",
26
+ "./client": "./lib/client.js",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "lib/",
31
+ "cordis.patch.yml",
32
+ "README.md"
33
+ ],
34
+ "engines": {
35
+ "node": "^22.19.0 || >=24.0.0"
36
+ },
37
+ "dsh": {
38
+ "bundle": {
39
+ "patch": "./cordis.patch.yml"
40
+ },
41
+ "client": {
42
+ "platform": "web"
43
+ }
44
+ },
45
+ "publishConfig": {
46
+ "registry": "https://registry.npmjs.org/",
47
+ "access": "public"
48
+ }
49
+ }