@chentyit/dsh-chatbot-panel 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 chentyit
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,151 @@
1
- # Temporary Holding Version
1
+ # ChatBot capability panel
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
+ A **DeepSeek Harness** bundle that turns the composer's web capabilities into an explicit,
4
+ per-session choice, and keeps the usage honest.
5
+
6
+ It adds a small **ChatBot** chip right of the composer's permission chip («Workspace Write»).
7
+ Clicking the chip opens an upward panel; enabling a capability there starts it for the current
8
+ session. While a capability is off, its tools are not "politely denied" — they leave the model's
9
+ tool catalog entirely.
10
+
11
+ ## Features
12
+
13
+ - **Per-session capability switch.** Click the chip to open/close the panel. The chip itself never
14
+ toggles a capability; enabling anything in the panel marks it active (tinted bot icon).
15
+ - **Web search** — exposes the built-in `web_search` / `web_fetch` tools only while enabled.
16
+ - **Deep thinking search** — adds `chatbot_deep_search`, which runs DeepSeek's server-side
17
+ `deepseek_web_search` tool with thinking enabled: the search model plans several queries,
18
+ searches, opens and reads relevant pages over multiple steps, then answers with sources.
19
+ - **Default off.** A fresh session starts with no web tools declared; turning a capability off
20
+ restores that state.
21
+ - **Durable state.** The switch is stored in the session log through the `/chatbot` command
22
+ lifecycle and read back from a session projection, so it survives page reloads, resume and forks.
23
+ - **Usage and estimated cost.** The panel footer shows search calls / queries / sources, fetch
24
+ calls, deep-search calls and tool calls, deep-search tokens and failures, per-category fees and
25
+ the estimated total, with a link to the DeepSeek platform usage page.
26
+ - **Theme-native, borderless.** The chip uses the host theme tokens and matches the permission
27
+ chip's 28 px text-chip look; no border, no fill, hover tints the whole chip.
28
+
29
+ ## Install
30
+
31
+ Requires DeepSeek Harness `0.2.2-rc.3` or another compatible `0.2.x` runtime (`engines.dsh`).
32
+
33
+ After the package is published to a registry:
34
+
35
+ 1. **Settings → Plugins → install** and enter `@chentyit/dsh-chatbot-panel@0.1.0`, or
36
+ 2. use the plugin manager tool: `install_bundle` with target `@chentyit/dsh-chatbot-panel@0.1.0`.
37
+
38
+ From a local checkout (development):
39
+
40
+ ```bash
41
+ # a local directory is linked into the profile; edits need a bundle toggle or restart
42
+ plugin_manager install_bundle target=/path/to/dsh-chatbot-panel
43
+ ```
44
+
45
+ The Web page reloads the client half after activation; if the Plugins page asks for a restart,
46
+ restart the app. Uninstall from Settings → Plugins (or remove the bundle).
47
+
48
+ ## Usage
49
+
50
+ 1. Open a session. The **ChatBot** chip sits right of the permission chip.
51
+ 2. Click it; the panel opens upward. Tick **Web search** (and optionally **Deep thinking search**).
52
+ 3. Ask the model a question that needs current information — it now has the matching tools.
53
+ 4. Untick everything, and the tools disappear from the model's catalog again.
54
+ 5. The panel footer reports this session's usage and estimated cost.
55
+
56
+ You can also drive the same state with the slash command:
57
+
58
+ ```text
59
+ /chatbot web on | web off
60
+ /chatbot deep on | deep off
61
+ /chatbot reset
62
+ ```
63
+
64
+ ## Usage and billing
65
+
66
+ The official price list bills web search and page fetch separately from model tokens:
67
+ **CNY 5 per 1,000 successful requests** (≈ ¥0.005 per call; failed or empty results are not
68
+ billed).
69
+
70
+ | Row in the panel | What is counted |
71
+ |---|---|
72
+ | Search | one successful `web_search` tool call fans out one request per query, so a successful call counts `queries.length` billed searches |
73
+ | Fetch | successful `web_fetch` calls; anonymous HTTP fetch is free, so no fee is added unless the fetch provider is the DeepSeek Fetch API (`countFetchCost: true`) |
74
+ | Deep | deep-search tool calls plus the billed `tool_search` / `tool_fetch` / `tool_image_search` counters from the provider and the auxiliary model's input/output tokens |
75
+ | Failed | calls that ended in an error; a failed multi-query search may already have billed some requests |
76
+
77
+ The displayed amount is an **estimate**: the API returns counts, not money, so the panel multiplies
78
+ counts by the configured unit prices. The DeepSeek platform usage page remains authoritative, and
79
+ the panel links to it.
80
+
81
+ ## Configuration
82
+
83
+ Override the row's `config` in the profile patch (`~/.dsh/profiles/<profile>/cordis.patch.yml`):
84
+
85
+ ```yaml
86
+ - id: chatbot-panel
87
+ name: '@chentyit/dsh-chatbot-panel'
88
+ config:
89
+ manageBuiltinWebTools: true # false = the panel no longer hides/enables built-in web tools
90
+ pricePerThousand: 5 # CNY per 1,000 successful web-tool requests (estimate only)
91
+ countFetchCost: false # true only when the fetch provider is the DeepSeek Fetch API
92
+ deepSearch:
93
+ baseURL: https://api.deepseek.com/anthropic/v1
94
+ apiKeyEnv: DEEPSEEK_API_KEY
95
+ model: deepseek-flash
96
+ maxTokens: 4096
97
+ timeoutMs: 180000
98
+ tokenPrice: # auxiliary deep-search turn, deepseek-flash idle prices
99
+ inputPerMillion: 1
100
+ outputPerMillion: 4
101
+ ```
102
+
103
+ Deep search authenticates with the signed-in DeepSeek account when the session runs on the
104
+ account route (`x-dsh-auth-token`), otherwise with the API key named by `deepSearch.apiKeyEnv`
105
+ (`$DSH_HOME/.env` or the process environment). Without either it fails with a clear message.
106
+
107
+ ## Limitations
108
+
109
+ - Deep search needs a DeepSeek account sign-in or an API key.
110
+ - Toggling a capability changes the request's tool catalog, so that session's DeepSeek prompt
111
+ cache is invalidated once.
112
+ - With plan mode active, the plan chip sits between the permission chip and this chip (official
113
+ single-slot order).
114
+ - Usage is folded from the session log; billing counts from failed multi-query searches are not
115
+ recoverable, so they are reported as failures only.
116
+ - Requires a DeepSeek model route that serves the official Messages API; other providers keep
117
+ their own web tools untouched.
118
+
119
+ ## Development
120
+
121
+ The package is plain JavaScript — no build step, no runtime dependencies:
122
+
123
+ ```bash
124
+ npm run check # syntax + metadata validation
125
+ npm pack --dry-run # inspect the published file list
126
+ npm publish --access public
127
+ ```
128
+
129
+ ## License
130
+
131
+ MIT. Copyright (c) 2026 chentyit.
132
+
133
+ ---
134
+
135
+ ## 中文说明
136
+
137
+ **DeepSeek Harness 插件(bundle)**:把 composer 里的联网能力变成按会话的显式开关,并如实记录用量。
138
+
139
+ 在输入框「工作区内修改」权限按钮右侧加一个 **ChatBot** 小按钮(无边框、无底色,与权限 chip 同款视觉)。
140
+ 点击按钮展开向上弹出的能力面板;在面板里勾选能力,才对本会话生效。**关闭时不是"拒绝调用",而是把对应工具从模型的工具目录里彻底移除。**
141
+
142
+ - **联网搜索**:仅在开启时向模型暴露内置 `web_search` / `web_fetch`。
143
+ - **深度思考搜索**:额外提供 `chatbot_deep_search`,走 DeepSeek 服务端 `deepseek_web_search`(thinking 开启)多步搜索、打开网页后带来源作答。
144
+ - **默认关闭**;开关按会话写入会话日志,刷新、恢复、分叉后仍有效。
145
+ - **用量与费用**:面板底部按官方口径记录搜索次数/查询条数/来源数、抓取次数、深搜次数与工具调用、深搜 token、失败次数、各项费用与估算合计,并链接开放平台用量页。
146
+
147
+ 安装:`Settings → Plugins` 输入 `@chentyit/dsh-chatbot-panel@0.1.0`,或用 `plugin_manager` 的 `install_bundle`。
148
+
149
+ 计费口径:联网搜索/抓取官方价 **5 元 / 1000 次成功请求**(失败或零结果不计费);一次 `web_search` 按查询词数拆成多次请求,成功计 `queries.length` 次。面板金额是**估算**(API 只返回次数),以平台账单为准。
150
+
151
+ 配置项与限制见上方英文段落(`manageBuiltinWebTools` / `pricePerThousand` / `countFetchCost` / `deepSearch` / `tokenPrice`)。
package/client.js ADDED
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Browser half of the ChatBot capability panel bundle.
3
+ *
4
+ * Renders a split capsule immediately right of the permission chip in the
5
+ * composer tool row: the left segment toggles the session's web capability and
6
+ * the right segment opens an upward panel listing the capabilities plus this
7
+ * session's usage and estimated cost. Capability state and usage are Host
8
+ * projections; this file only reads them and dispatches `/chatbot ...` lines.
9
+ */
10
+ window.__ModuleLoader__.load({
11
+ id: '@chentyit/dsh-chatbot-panel',
12
+ factory(require) {
13
+ const React = require('react')
14
+ const { createPortal } = require('react-dom')
15
+ const h = React.createElement
16
+
17
+ /** Locale namespace for the capsule copy and the registration's `t`. */
18
+ const NS = 'chatbot-panel'
19
+ /** Official platform usage page; the Host projection carries the same link. */
20
+ const USAGE_URL = 'https://platform.deepseek.com/usage'
21
+ /** Panel width in CSS pixels, used to keep it inside the viewport. */
22
+ const PANEL_WIDTH = 328
23
+
24
+ const zh = {
25
+ label: 'ChatBot',
26
+ openPanel: '展开 ChatBot 能力',
27
+ panelTitle: 'ChatBot 能力',
28
+ scope: '本会话',
29
+ groupWeb: '联网工具',
30
+ web: '联网搜索',
31
+ webHint: '按成功查询计费 ¥5 / 千次',
32
+ deep: '深度思考搜索',
33
+ deepHint: '多步推理后联网,更慢、更耗 token',
34
+ deepNeedsWeb: '先开启联网搜索',
35
+ pending: '应用中…',
36
+ usageTitle: '本会话用量',
37
+ search: '搜索',
38
+ fetch: '抓取',
39
+ deepUsage: '深搜',
40
+ failed: '失败',
41
+ times: '次',
42
+ queries: '条查询',
43
+ sources: '个来源',
44
+ toolCalls: '次工具调用',
45
+ input: '输入',
46
+ output: '输出',
47
+ feeTitle: '费用估算',
48
+ searchFee: '搜索费',
49
+ fetchFee: '抓取费',
50
+ deepToolFee: '深搜工具费',
51
+ deepTokenFee: '深搜 token 费',
52
+ totalFee: '合计',
53
+ fetchFree: '匿名抓取不计费',
54
+ feeNote: '按官方价估算,实际以平台账单为准',
55
+ usageLink: '查看官方用量 ↗',
56
+ errorUnknown: '命令未生效',
57
+ errorPrefix: '操作失败:',
58
+ }
59
+
60
+ const en = {
61
+ label: 'ChatBot',
62
+ openPanel: 'Open ChatBot capabilities',
63
+ panelTitle: 'ChatBot capabilities',
64
+ scope: 'this session',
65
+ groupWeb: 'Web tools',
66
+ web: 'Web search',
67
+ webHint: 'Billed per successful query, ¥5 / 1,000',
68
+ deep: 'Deep thinking search',
69
+ deepHint: 'Searches while reasoning; slower and token-heavier',
70
+ deepNeedsWeb: 'Turn on web search first',
71
+ pending: 'Applying…',
72
+ usageTitle: 'Session usage',
73
+ search: 'Search',
74
+ fetch: 'Fetch',
75
+ deepUsage: 'Deep',
76
+ failed: 'Failed',
77
+ times: '',
78
+ queries: 'queries',
79
+ sources: 'sources',
80
+ toolCalls: 'tool calls',
81
+ input: 'in',
82
+ output: 'out',
83
+ feeTitle: 'Estimated cost',
84
+ searchFee: 'Search',
85
+ fetchFee: 'Fetch',
86
+ deepToolFee: 'Deep tools',
87
+ deepTokenFee: 'Deep tokens',
88
+ totalFee: 'Total',
89
+ fetchFree: 'Anonymous fetch is free',
90
+ feeNote: 'Estimated from official prices; the platform bill is authoritative',
91
+ usageLink: 'Open platform usage ↗',
92
+ errorUnknown: 'The command did not apply',
93
+ errorPrefix: 'Failed: ',
94
+ }
95
+
96
+ const CSS = [
97
+ /* Chip chrome copied from the permission trigger: transparent, 28px,
98
+ label-secondary, hover-only tint, small glyph and chevron. No border,
99
+ no fill, so it reads as another composer tool next to 「工作区内修改」. */
100
+ '.cbp-capsule{display:inline-flex;align-items:center;height:28px;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family,inherit);font-size:13px;line-height:20px;font-weight:500;vertical-align:middle}',
101
+ '.cbp-main{display:inline-flex;align-items:center;gap:4px;min-width:0;max-width:220px;height:28px;box-sizing:border-box;padding:0 4px 0 8px;border:none;border-radius:var(--dsw-radius-sm);outline:none;background:transparent;color:inherit;font:inherit;cursor:pointer}',
102
+ /* The chip is one control: clicking anywhere on it opens or closes the
103
+ capability panel. Hover tints the whole chip, arrow included. */
104
+ '@media (hover: hover) {',
105
+ '.cbp-capsule:hover{background:var(--dsw-alias-interactive-bg-hover)}',
106
+ '}',
107
+ '.cbp-main:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
108
+ '.cbp-icon{display:inline-flex;flex:0 0 auto;color:var(--dsw-alias-label-caption);transition:color .12s ease}',
109
+ '.cbp-icon svg{display:block;width:14px;height:14px}',
110
+ '.cbp-capsule[data-on="true"] .cbp-icon{color:var(--dsw-alias-state-success-primary)}',
111
+ '.cbp-capsule[data-on="true"] .cbp-label{color:var(--dsw-alias-label-primary)}',
112
+ '.cbp-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
113
+ '.cbp-arrow{display:inline-flex;flex:0 0 auto;margin-left:1px;color:var(--dsw-alias-label-caption)}',
114
+ '.cbp-arrow svg{display:block;width:12px;height:12px;transition:transform 120ms ease}',
115
+ '.cbp-main[data-open="true"] .cbp-arrow svg{transform:rotate(180deg)}',
116
+ '.cbp-panel{position:fixed;z-index:1200;box-sizing:border-box;display:flex;flex-direction:column;padding:6px;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#fff));backdrop-filter:var(--dsw-menu-backdrop-filter,none);box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.18));color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit)}',
117
+ '.cbp-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;padding:6px 8px 2px}',
118
+ '.cbp-head-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}',
119
+ '.cbp-head-scope{font-size:11px;color:var(--dsw-alias-label-caption)}',
120
+ '.cbp-group{padding:8px 8px 2px;font-size:11px;font-weight:600;letter-spacing:.03em;color:var(--dsw-alias-label-tertiary)}',
121
+ '.cbp-row{display:flex;align-items:center;gap:10px;width:100%;box-sizing:border-box;padding:7px 8px;border:0;border-radius:var(--dsw-radius-md,8px);background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;text-align:left;cursor:pointer}',
122
+ '.cbp-row:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
123
+ '.cbp-row:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-1px}',
124
+ '.cbp-row:disabled{cursor:default;opacity:.55}',
125
+ '.cbp-row-sub{padding-left:22px}',
126
+ '.cbp-row-text{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}',
127
+ '.cbp-row-label{font-size:13px;line-height:18px}',
128
+ '.cbp-row-hint{font-size:11px;line-height:15px;color:var(--dsw-alias-label-secondary)}',
129
+ '.cbp-check{display:inline-flex;align-items:center;justify-content:center;flex:none;width:18px;height:18px;border-radius:var(--dsw-radius-sm,6px);border:1.5px solid var(--dsw-alias-border-l2,currentColor);box-sizing:border-box;color:transparent}',
130
+ '.cbp-check[data-checked="true"]{border-color:transparent;background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-inverted)}',
131
+ '.cbp-check svg{display:block;width:12px;height:12px}',
132
+ '.cbp-sep{height:1px;margin:6px 6px;background:var(--dsw-alias-border-l1)}',
133
+ '.cbp-usage{display:flex;flex-direction:column;gap:2px;padding:2px 8px 4px;font-size:11px;line-height:17px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
134
+ '.cbp-usage-title{font-size:11px;font-weight:600;letter-spacing:.03em;color:var(--dsw-alias-label-tertiary);padding:0 0 2px}',
135
+ '.cbp-usage-line{display:flex;justify-content:space-between;gap:10px}',
136
+ '.cbp-usage-line b{font-weight:600;color:var(--dsw-alias-label-primary)}',
137
+ '.cbp-fee{border-top:1px dashed var(--dsw-alias-border-l1);margin-top:4px;padding-top:4px}',
138
+ '.cbp-total{display:flex;justify-content:space-between;gap:10px;font-size:12px;color:var(--dsw-alias-label-primary);font-weight:600}',
139
+ '.cbp-note{font-size:10px;line-height:15px;color:var(--dsw-alias-label-caption)}',
140
+ '.cbp-link{font-size:11px;line-height:17px;color:var(--dsw-alias-link);text-decoration:none}',
141
+ '.cbp-link:hover{text-decoration:underline}',
142
+ '.cbp-error{margin:4px 6px 2px;padding:5px 8px;border-radius:var(--dsw-radius-sm,6px);background:var(--dsw-alias-state-error-tertiary,rgba(220,38,38,.12));color:var(--dsw-alias-state-error-primary,#dc2626);font-size:11px;line-height:15px}',
143
+ ].join('')
144
+
145
+ /** The capsule owns its stylesheet, removed with the plugin's activation. */
146
+ function insertStyles() {
147
+ if (typeof document === 'undefined') return () => {}
148
+ const tag = document.createElement('style')
149
+ tag.setAttribute('data-plugin', '@chentyit/dsh-chatbot-panel')
150
+ tag.textContent = CSS
151
+ document.head.appendChild(tag)
152
+ return () => { tag.remove() }
153
+ }
154
+
155
+ /** Format a yuan amount for the detail rows. */
156
+ function yuan(value) {
157
+ const amount = typeof value === 'number' && Number.isFinite(value) ? value : 0
158
+ return `¥${amount.toFixed(4)}`
159
+ }
160
+
161
+ /** Format a token count compactly. */
162
+ function tokens(value) {
163
+ const amount = typeof value === 'number' && Number.isFinite(value) ? value : 0
164
+ return amount >= 1000 ? `${(amount / 1000).toFixed(1)}k` : String(amount)
165
+ }
166
+
167
+ /** A small check mark drawn in the theme's inverted label color. */
168
+ function CheckGlyph() {
169
+ return h('svg', { viewBox: '0 0 12 12', 'aria-hidden': true },
170
+ h('path', {
171
+ d: 'M2.2 6.3 4.6 8.7 9.8 3.4',
172
+ fill: 'none',
173
+ stroke: 'currentColor',
174
+ strokeWidth: '1.8',
175
+ strokeLinecap: 'round',
176
+ strokeLinejoin: 'round',
177
+ }))
178
+ }
179
+
180
+ /** Chevron pointing up while the panel is closed, down while it is open. */
181
+ /** A small bot-head mark (antenna, head, eyes) matching the 14px chip glyphs. */
182
+ function BotGlyph() {
183
+ // Optically centered against the 13px label: text ink sits ~0.35px above
184
+ // the flex-line center, so the glyph's ink bounds (outer top 1.17 … outer
185
+ // bottom 12.12, stroke included) center on y≈6.65 instead of 7. Measured
186
+ // from a rendered chip screenshot, this lands the icon level with the text.
187
+ return h('svg', { viewBox: '0 0 14 14', 'aria-hidden': true },
188
+ h('path', {
189
+ d: 'M7 3.67v1.2',
190
+ fill: 'none',
191
+ stroke: 'currentColor',
192
+ strokeWidth: '1.2',
193
+ strokeLinecap: 'round',
194
+ }),
195
+ h('circle', { cx: 7, cy: 2.72, r: 0.95, fill: 'currentColor' }),
196
+ h('rect', {
197
+ x: 2.1,
198
+ y: 4.92,
199
+ width: 9.8,
200
+ height: 6.6,
201
+ rx: 2.2,
202
+ fill: 'none',
203
+ stroke: 'currentColor',
204
+ strokeWidth: '1.2',
205
+ }),
206
+ h('circle', { cx: 5.3, cy: 7.27, r: 0.85, fill: 'currentColor' }),
207
+ h('circle', { cx: 8.7, cy: 7.27, r: 0.85, fill: 'currentColor' }),
208
+ h('path', {
209
+ d: 'M5.6 9.37h2.8',
210
+ fill: 'none',
211
+ stroke: 'currentColor',
212
+ strokeWidth: '1.1',
213
+ strokeLinecap: 'round',
214
+ }))
215
+ }
216
+
217
+ /** Chevron pointing down; the open state rotates it in CSS. */
218
+ function ChevronGlyph() {
219
+ return h('svg', { viewBox: '0 0 12 12', 'aria-hidden': true },
220
+ h('path', {
221
+ d: 'M2.4 4.4 6 8 9.6 4.4',
222
+ fill: 'none',
223
+ stroke: 'currentColor',
224
+ strokeWidth: '1.6',
225
+ strokeLinecap: 'round',
226
+ strokeLinejoin: 'round',
227
+ }))
228
+ }
229
+
230
+ /** One multi-select capability row. */
231
+ function CapabilityRow(props) {
232
+ const { t, checked, label, hint, sub, disabled, onToggle } = props
233
+ return h('button', {
234
+ type: 'button',
235
+ role: 'menuitemcheckbox',
236
+ 'aria-checked': checked ? 'true' : 'false',
237
+ disabled,
238
+ className: sub === true ? 'cbp-row cbp-row-sub' : 'cbp-row',
239
+ onClick: onToggle,
240
+ },
241
+ h('span', { className: 'cbp-row-text' },
242
+ h('span', { className: 'cbp-row-label' }, label),
243
+ typeof hint === 'string' && hint.length > 0 ? h('span', { className: 'cbp-row-hint' }, hint) : null),
244
+ h('span', { className: 'cbp-check', 'data-checked': checked ? 'true' : 'false', 'aria-hidden': true },
245
+ checked ? h(CheckGlyph) : null))
246
+ }
247
+
248
+ /** Detailed usage and estimate block. */
249
+ function UsageBlock(props) {
250
+ const { t, usage } = props
251
+ if (usage === undefined || usage === null) {
252
+ return h('div', { className: 'cbp-usage' },
253
+ h('div', { className: 'cbp-usage-title' }, t('usageTitle')),
254
+ h('div', { className: 'cbp-usage-line' }, h('span', null, t('search')), h('b', null, `0 ${t('times')}`)))
255
+ }
256
+ const search = usage.search || { calls: 0, queries: 0, sources: 0, failed: 0, failedQueries: 0 }
257
+ const fetch = usage.fetch || { calls: 0, failed: 0 }
258
+ const deep = usage.deep || { calls: 0, failed: 0, toolSearch: 0, toolFetch: 0, imageSearch: 0, inputTokens: 0, outputTokens: 0, sources: 0 }
259
+ const fees = usage.fees || { search: 0, fetch: 0, deepTools: 0, deepTokens: 0, total: 0 }
260
+ const deepTools = (deep.toolSearch || 0) + (deep.toolFetch || 0) + (deep.imageSearch || 0)
261
+ const failures = (search.failed || 0) + (fetch.failed || 0) + (deep.failed || 0)
262
+ const lines = []
263
+ lines.push(h('div', { key: 'title', className: 'cbp-usage-title' }, t('usageTitle')))
264
+ lines.push(h('div', { key: 'search', className: 'cbp-usage-line' },
265
+ h('span', null, t('search')),
266
+ h('span', null,
267
+ h('b', null, `${search.calls} ${t('times')}`),
268
+ ` · ${search.queries} ${t('queries')} · ${search.sources} ${t('sources')}`)))
269
+ lines.push(h('div', { key: 'fetch', className: 'cbp-usage-line' },
270
+ h('span', null, t('fetch')),
271
+ h('span', null,
272
+ h('b', null, `${fetch.calls} ${t('times')}`),
273
+ usage.countFetchCost === true ? '' : ` · ${t('fetchFree')}`)))
274
+ lines.push(h('div', { key: 'deep', className: 'cbp-usage-line' },
275
+ h('span', null, t('deepUsage')),
276
+ h('span', null,
277
+ h('b', null, `${deep.calls} ${t('times')}`),
278
+ ` · ${deepTools} ${t('toolCalls')} · ${tokens(deep.inputTokens)}/${tokens(deep.outputTokens)} tok`)))
279
+ if (failures > 0) {
280
+ lines.push(h('div', { key: 'failed', className: 'cbp-usage-line' },
281
+ h('span', null, t('failed')),
282
+ h('span', null, `${failures} ${t('times')}`)))
283
+ }
284
+ lines.push(h('div', { key: 'sep', className: 'cbp-sep' }))
285
+ lines.push(h('div', { key: 'fee-title', className: 'cbp-usage-title' }, t('feeTitle')))
286
+ lines.push(h('div', { key: 'fee-search', className: 'cbp-usage-line' },
287
+ h('span', null, t('searchFee')), h('span', null, yuan(fees.search))))
288
+ lines.push(h('div', { key: 'fee-fetch', className: 'cbp-usage-line' },
289
+ h('span', null, t('fetchFee')), h('span', null, yuan(fees.fetch))))
290
+ lines.push(h('div', { key: 'fee-deep-tools', className: 'cbp-usage-line' },
291
+ h('span', null, t('deepToolFee')), h('span', null, yuan(fees.deepTools))))
292
+ lines.push(h('div', { key: 'fee-deep-tokens', className: 'cbp-usage-line' },
293
+ h('span', null, t('deepTokenFee')), h('span', null, yuan(fees.deepTokens))))
294
+ lines.push(h('div', { key: 'total', className: 'cbp-total' },
295
+ h('span', null, t('totalFee')), h('span', null, `≈ ${yuan(fees.total)}`)))
296
+ lines.push(h('div', { key: 'note', className: 'cbp-note' }, t('feeNote')))
297
+ lines.push(h('a', {
298
+ key: 'link',
299
+ className: 'cbp-link',
300
+ href: typeof usage.usageUrl === 'string' && usage.usageUrl.length > 0 ? usage.usageUrl : USAGE_URL,
301
+ target: '_blank',
302
+ rel: 'noopener noreferrer',
303
+ }, t('usageLink')))
304
+ return h('div', { className: 'cbp-usage' }, ...lines)
305
+ }
306
+
307
+ /** The composer control: split capsule plus the upward panel. */
308
+ function ChatBotControl(props) {
309
+ const t = typeof props.t === 'function' ? props.t : key => key
310
+ const caps = props.useProjection('chatbotCapabilities')
311
+ const usage = props.useProjection('chatbotUsage')
312
+ const web = caps !== undefined && caps !== null && caps.web === true
313
+ const deep = caps !== undefined && caps !== null && caps.deep === true
314
+ const pending = caps !== undefined && caps !== null && caps.pending === true
315
+ const [open, setOpen] = React.useState(false)
316
+ const [busy, setBusy] = React.useState(false)
317
+ const [error, setError] = React.useState(null)
318
+ const [position, setPosition] = React.useState(null)
319
+ const wrapRef = React.useRef(null)
320
+ const panelRef = React.useRef(null)
321
+
322
+ const submit = typeof props.submit === 'function' ? props.submit : async () => undefined
323
+ const sessionId = props.sessionId
324
+ const run = React.useCallback((line) => {
325
+ setBusy(true)
326
+ setError(null)
327
+ Promise.resolve(submit(sessionId, line))
328
+ .then((result) => {
329
+ if (result === undefined || result === null) { setError(t('errorUnknown')); return }
330
+ if (result.ok !== true) {
331
+ const message = result.error && typeof result.error.message === 'string' ? result.error.message : t('errorUnknown')
332
+ setError(message)
333
+ return
334
+ }
335
+ const value = result.value
336
+ if (value === undefined) { setError(t('errorUnknown')); return }
337
+ if (value !== null && typeof value === 'object' && value.kind === 'error') {
338
+ setError(typeof value.text === 'string' ? value.text : t('errorUnknown'))
339
+ }
340
+ })
341
+ .catch((failure) => { setError(`${t('errorPrefix')}${String(failure)}`) })
342
+ .finally(() => { setBusy(false) })
343
+ }, [submit, sessionId, t])
344
+
345
+ const place = React.useCallback(() => {
346
+ const element = wrapRef.current
347
+ if (element === null || typeof window === 'undefined') return
348
+ const rect = element.getBoundingClientRect()
349
+ const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - PANEL_WIDTH - 8))
350
+ const bottom = Math.max(8, window.innerHeight - rect.top + 8)
351
+ setPosition({ left, bottom })
352
+ }, [])
353
+
354
+ React.useLayoutEffect(() => {
355
+ if (!open) return undefined
356
+ place()
357
+ const onResize = () => { place() }
358
+ window.addEventListener('resize', onResize)
359
+ return () => { window.removeEventListener('resize', onResize) }
360
+ }, [open, place])
361
+
362
+ React.useEffect(() => {
363
+ if (!open) return undefined
364
+ const onPointerDown = (event) => {
365
+ if (wrapRef.current !== null && wrapRef.current.contains(event.target)) return
366
+ if (panelRef.current !== null && panelRef.current.contains(event.target)) return
367
+ setOpen(false)
368
+ }
369
+ const onKeyDown = (event) => {
370
+ if (event.key !== 'Escape') return
371
+ setOpen(false)
372
+ const trigger = wrapRef.current === null ? null : wrapRef.current.querySelector('button')
373
+ if (trigger !== null && typeof trigger.focus === 'function') trigger.focus()
374
+ }
375
+ document.addEventListener('pointerdown', onPointerDown, true)
376
+ document.addEventListener('keydown', onKeyDown, true)
377
+ return () => {
378
+ document.removeEventListener('pointerdown', onPointerDown, true)
379
+ document.removeEventListener('keydown', onKeyDown, true)
380
+ }
381
+ }, [open])
382
+
383
+ const disabled = busy || pending
384
+
385
+ const active = web || deep
386
+
387
+ const capsule = h('span', {
388
+ ref: wrapRef,
389
+ className: 'cbp-capsule',
390
+ 'data-on': active ? 'true' : 'false',
391
+ },
392
+ h('button', {
393
+ type: 'button',
394
+ className: 'cbp-main',
395
+ 'data-open': open ? 'true' : 'false',
396
+ 'aria-haspopup': 'menu',
397
+ 'aria-expanded': open ? 'true' : 'false',
398
+ 'aria-label': t('openPanel'),
399
+ title: t('openPanel'),
400
+ onClick: () => { setOpen(value => !value) },
401
+ },
402
+ h('span', { className: 'cbp-icon', 'aria-hidden': true }, h(BotGlyph)),
403
+ h('span', { className: 'cbp-label' }, t('label')),
404
+ h('span', { className: 'cbp-arrow', 'aria-hidden': true }, h(ChevronGlyph))))
405
+
406
+ let panel = null
407
+ if (open && typeof document !== 'undefined') {
408
+ panel = createPortal(h('div', {
409
+ ref: panelRef,
410
+ className: 'cbp-panel',
411
+ role: 'menu',
412
+ 'aria-label': t('panelTitle'),
413
+ style: {
414
+ left: position === null ? 0 : `${position.left}px`,
415
+ bottom: position === null ? 12 : `${position.bottom}px`,
416
+ width: `${PANEL_WIDTH}px`,
417
+ },
418
+ },
419
+ h('div', { className: 'cbp-head' },
420
+ h('span', { className: 'cbp-head-title' }, t('panelTitle')),
421
+ h('span', { className: 'cbp-head-scope' }, t('scope'))),
422
+ h('div', { className: 'cbp-group' }, t('groupWeb')),
423
+ h(CapabilityRow, {
424
+ t,
425
+ checked: web,
426
+ label: t('web'),
427
+ hint: t('webHint'),
428
+ disabled,
429
+ onToggle: () => { run(web ? 'web off' : 'web on') },
430
+ }),
431
+ h(CapabilityRow, {
432
+ t,
433
+ checked: deep,
434
+ label: t('deep'),
435
+ hint: web ? t('deepHint') : t('deepNeedsWeb'),
436
+ sub: true,
437
+ disabled: disabled || !web,
438
+ onToggle: () => { run(deep ? 'deep off' : 'deep on') },
439
+ }),
440
+ h('div', { className: 'cbp-sep' }),
441
+ h(UsageBlock, { t, usage }),
442
+ pending ? h('div', { className: 'cbp-note' }, t('pending')) : null,
443
+ error === null ? null : h('div', { className: 'cbp-error', role: 'alert' }, error)),
444
+ document.body)
445
+ }
446
+
447
+ return h(React.Fragment, null, capsule, panel)
448
+ }
449
+
450
+ return {
451
+ inject: ['slots', 'locale', 'remote', 'remote.commands'],
452
+
453
+ apply(ctx) {
454
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'chatbot-panel: dictionaries')
455
+ ctx.effect(insertStyles, 'chatbot-panel: styles')
456
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
457
+ name: 'conversation.input.left',
458
+ id: 'chatbot-panel',
459
+ order: 0,
460
+ locale: NS,
461
+ inject: () => ({
462
+ submit: (sessionId, line) => ctx.remote.commands.execute(sessionId, `/chatbot ${line}`, []),
463
+ }),
464
+ }, ChatBotControl))
465
+ },
466
+ }
467
+ },
468
+ })
@@ -0,0 +1,23 @@
1
+ # Profile patch for the ChatBot capability panel bundle: one Host row whose
2
+ # package also carries the browser half (`dsh.client` in package.json).
3
+ - insert:
4
+ - id: chatbot-panel
5
+ name: '@chentyit/dsh-chatbot-panel'
6
+ config:
7
+ # New sessions start with every capability off; the panel turns them on.
8
+ manageBuiltinWebTools: true
9
+ # Official web-tool price: 5 CNY per 1,000 successful requests.
10
+ pricePerThousand: 5
11
+ # Anonymous HTTP fetch is free; enable only when the fetch provider is
12
+ # the DeepSeek Fetch API.
13
+ countFetchCost: false
14
+ deepSearch:
15
+ baseURL: https://api.deepseek.com/anthropic/v1
16
+ apiKeyEnv: DEEPSEEK_API_KEY
17
+ model: deepseek-flash
18
+ maxTokens: 4096
19
+ timeoutMs: 180000
20
+ tokenPrice:
21
+ # deepseek-flash off-peak prices; peak hours are double.
22
+ inputPerMillion: 1
23
+ outputPerMillion: 4