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 +21 -0
- package/README.md +103 -2
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1112 -0
- package/lib/index.js +15 -0
- package/package.json +47 -4
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
|
-
#
|
|
1
|
+
# 学习提问 · dsh-study-ask
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/cordis.patch.yml
ADDED
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|