@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/icon.svg ADDED
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="ChatBot capability panel">
2
+ <defs>
3
+ <linearGradient id="cbp-bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#101319"/>
5
+ <stop offset="1" stop-color="#2a3342"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="2" y="2" width="60" height="60" rx="16" fill="url(#cbp-bg)"/>
9
+ <path d="M14 22.5c0-3 2.4-5.5 5.4-5.5h25.2c3 0 5.4 2.5 5.4 5.5v14c0 3-2.4 5.5-5.4 5.5H29.6l-8.4 7.2c-.8.7-2 .1-2-1V42h-.8c-2.4 0-4.4-2-4.4-4.5z" fill="#f7f8fb"/>
10
+ <circle cx="24" cy="29.5" r="2.6" fill="#101319"/>
11
+ <circle cx="32" cy="29.5" r="2.6" fill="#101319"/>
12
+ <circle cx="40" cy="29.5" r="2.6" fill="#101319"/>
13
+ <circle cx="47" cy="47" r="9" fill="#4b8bf5"/>
14
+ <path d="M43 47h8M47 43v8" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/>
15
+ </svg>
package/index.js ADDED
@@ -0,0 +1,651 @@
1
+ /**
2
+ * Host half of the ChatBot capability panel bundle.
3
+ *
4
+ * Owns three things:
5
+ * 1. Per-session capability state (`chatbotCapabilities` projection), derived
6
+ * from the `/chatbot` command lifecycle events that the Session log already
7
+ * records — no new Session event type is introduced.
8
+ * 2. Tool gating: while web capability is off, the built-in `web_search` /
9
+ * `web_fetch` tools are restricted away from that agent, so they disappear
10
+ * from the model's tool catalog; the optional deep-thinking search tool is
11
+ * declared only while both capabilities are on.
12
+ * 3. Usage and cost accounting (`chatbotUsage` projection), folded from the
13
+ * `tool/call` + `tool/result` events: successful REST searches are billed
14
+ * per query (one request per query), fetches only when the DeepSeek Fetch
15
+ * provider serves them, and deep searches report their own billed tool
16
+ * counts and auxiliary model tokens.
17
+ *
18
+ * The browser half renders the capsule and the upward panel; it reads both
19
+ * projections through `useProjection` and writes through `/chatbot ...`.
20
+ */
21
+
22
+ /** Capability projection key. */
23
+ const CAP_KEY = 'chatbotCapabilities'
24
+ /** Usage projection key. */
25
+ const USAGE_KEY = 'chatbotUsage'
26
+ /** Built-in tools the panel hides while the web capability is off. */
27
+ const BUILTIN_WEB_TOOLS = ['web_search', 'web_fetch']
28
+ /** Built-in search tool name. */
29
+ const SEARCH_TOOL = 'web_search'
30
+ /** Built-in fetch tool name. */
31
+ const FETCH_TOOL = 'web_fetch'
32
+ /** Panel-owned deep-thinking search tool name. */
33
+ const DEEP_TOOL = 'chatbot_deep_search'
34
+ /** Fallback platform usage page. */
35
+ const USAGE_URL = 'https://platform.deepseek.com/usage'
36
+ /** Anthropic-compatible API version header. */
37
+ const ANTHROPIC_VERSION = '2023-06-01'
38
+ /** Upper bound on remembered open tool calls inside the usage projection. */
39
+ const MAX_OPEN_CALLS = 64
40
+ /** Duck-typed schema: the projection registry only calls `parse`. */
41
+ const passthroughSchema = { parse: value => value }
42
+
43
+ /** Resolve the deployment config from the raw Loader row value. */
44
+ function resolveConfig(raw) {
45
+ const source = raw !== null && typeof raw === 'object' ? raw : {}
46
+ const deepSource = source.deepSearch !== null && typeof source.deepSearch === 'object' ? source.deepSearch : {}
47
+ const tokenSource = source.tokenPrice !== null && typeof source.tokenPrice === 'object' ? source.tokenPrice : {}
48
+ const positive = (value, fallback) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback)
49
+ const nonNegative = (value, fallback) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback)
50
+ const text = (value, fallback) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback)
51
+ return {
52
+ manageBuiltinWebTools: source.manageBuiltinWebTools !== false,
53
+ pricePerThousand: nonNegative(source.pricePerThousand, 5),
54
+ countFetchCost: source.countFetchCost === true,
55
+ deepSearch: {
56
+ baseURL: text(deepSource.baseURL, 'https://api.deepseek.com/anthropic/v1').replace(/\/+$/u, ''),
57
+ apiKeyEnv: text(deepSource.apiKeyEnv, 'DEEPSEEK_API_KEY'),
58
+ model: text(deepSource.model, 'deepseek-flash'),
59
+ maxTokens: positive(deepSource.maxTokens, 4096),
60
+ timeoutMs: positive(deepSource.timeoutMs, 180000),
61
+ },
62
+ tokenPrice: {
63
+ inputPerMillion: nonNegative(tokenSource.inputPerMillion, 1),
64
+ outputPerMillion: nonNegative(tokenSource.outputPerMillion, 4),
65
+ },
66
+ }
67
+ }
68
+
69
+ /** Empty capability state, also the per-Session replay default. */
70
+ function emptyCapabilities() {
71
+ return { web: false, deep: false, pending: null }
72
+ }
73
+
74
+ /** Empty usage state. Every field is additive and JSON-serializable. */
75
+ function emptyUsage() {
76
+ return {
77
+ search: { calls: 0, queries: 0, sources: 0, failed: 0, failedQueries: 0 },
78
+ fetch: { calls: 0, failed: 0 },
79
+ deep: {
80
+ calls: 0, failed: 0, toolSearch: 0, toolFetch: 0, imageSearch: 0,
81
+ inputTokens: 0, outputTokens: 0, sources: 0,
82
+ },
83
+ open: {},
84
+ }
85
+ }
86
+
87
+ /** Parse one `/chatbot` argument string into a state patch. */
88
+ function parseCapabilityInput(rawInput) {
89
+ const text = typeof rawInput === 'string' ? rawInput.trim().toLowerCase() : ''
90
+ if (text.length === 0 || text === 'status' || text === 'show') return { kind: 'status' }
91
+ if (text === 'reset') return { kind: 'patch', patch: { web: false, deep: false } }
92
+ const parts = text.split(/\s+/u)
93
+ if (parts.length !== 2) return undefined
94
+ const [rawKey, rawValue] = parts
95
+ const key = rawKey === 'deepsearch' || rawKey === 'deep-search' ? 'deep' : rawKey
96
+ if (key !== 'web' && key !== 'deep') return undefined
97
+ if (rawValue !== 'on' && rawValue !== 'off') return undefined
98
+ const enabled = rawValue === 'on'
99
+ if (key === 'web') {
100
+ // Turning search off also turns deep search off; deep search needs search.
101
+ return { kind: 'patch', patch: enabled ? { web: true } : { web: false, deep: false } }
102
+ }
103
+ // Deep search implies plain search.
104
+ return { kind: 'patch', patch: enabled ? { web: true, deep: true } : { deep: false } }
105
+ }
106
+
107
+ /** Apply one parsed patch to a capability state (never mutates). */
108
+ function applyCapabilityPatch(state, patch) {
109
+ return {
110
+ web: typeof patch.web === 'boolean' ? patch.web : state.web,
111
+ deep: typeof patch.deep === 'boolean' ? patch.deep : state.deep,
112
+ pending: null,
113
+ }
114
+ }
115
+
116
+ /** Count the queries a `web_search` call will fan out into. */
117
+ function queryCount(argumentsJson) {
118
+ try {
119
+ const parsed = typeof argumentsJson === 'string' ? JSON.parse(argumentsJson) : argumentsJson
120
+ if (parsed === null || typeof parsed !== 'object') return 0
121
+ const queries = parsed.queries
122
+ return Array.isArray(queries) ? queries.filter(item => typeof item === 'string' && item.length > 0).length : 0
123
+ } catch (_malformedArguments) {
124
+ return 0
125
+ }
126
+ }
127
+
128
+ /** Keep the open-call map bounded; object insertion order gives age order. */
129
+ function trimOpenCalls(open) {
130
+ const keys = Object.keys(open)
131
+ if (keys.length <= MAX_OPEN_CALLS) return open
132
+ const next = { ...open }
133
+ for (const key of keys.slice(0, keys.length - MAX_OPEN_CALLS)) delete next[key]
134
+ return next
135
+ }
136
+
137
+ /** Read a finite non-negative number from a provider usage field. */
138
+ function countOf(value) {
139
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
140
+ }
141
+
142
+ /** Read an optional string field. */
143
+ function stringOf(value) {
144
+ return typeof value === 'string' && value.length > 0 ? value : undefined
145
+ }
146
+
147
+ /** Build the auxiliary deep-search instruction for one batch of queries. */
148
+ function deepInstruction(queries) {
149
+ if (queries.length === 1) return `Perform a deep web search for: ${queries[0]}`
150
+ const list = queries.map((query, index) => `${index + 1}. ${query}`).join('\n')
151
+ return `Perform a deep web search for these queries, reading the most relevant pages:\n${list}`
152
+ }
153
+
154
+ /** Render the deep-search tool result for the model. */
155
+ function renderDeepResult(value) {
156
+ const lines = []
157
+ if (typeof value.answer === 'string' && value.answer.length > 0) lines.push(value.answer)
158
+ if (Array.isArray(value.sources) && value.sources.length > 0) {
159
+ lines.push('Sources:')
160
+ for (const source of value.sources.slice(0, 20)) {
161
+ lines.push(`- ${source.title === undefined ? source.url : `${source.title} — ${source.url}`}`)
162
+ }
163
+ }
164
+ if (lines.length === 0) lines.push('The deep search returned no usable content.')
165
+ return lines.join('\n')
166
+ }
167
+
168
+ /** Map one Messages payload from the auxiliary deep-search request. */
169
+ function mapDeepResponse(payload, queries) {
170
+ const content = Array.isArray(payload?.content) ? payload.content : []
171
+ const sources = []
172
+ const seen = new Set()
173
+ const texts = []
174
+ for (const block of content) {
175
+ if (block === null || typeof block !== 'object') continue
176
+ if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
177
+ if (block.type === 'web_search_tool_result' && Array.isArray(block.content)) {
178
+ for (const item of block.content) {
179
+ if (item === null || typeof item !== 'object' || item.type !== 'web_search_result') continue
180
+ const url = stringOf(item.url)
181
+ if (url === undefined || seen.has(url)) continue
182
+ seen.add(url)
183
+ const source = { url }
184
+ const title = stringOf(item.title)
185
+ const snippet = stringOf(item.snippet)
186
+ const site = stringOf(item.site_name)
187
+ const publishedAt = stringOf(item.page_age)
188
+ if (title !== undefined) source.title = title
189
+ if (snippet !== undefined) source.snippet = snippet
190
+ if (site !== undefined) source.site = site
191
+ if (publishedAt !== undefined) source.publishedAt = publishedAt
192
+ sources.push(source)
193
+ }
194
+ }
195
+ if (block.type === 'web_fetch_tool_result' && block.content !== null && typeof block.content === 'object') {
196
+ const fetched = block.content
197
+ if (fetched.type === 'web_fetch_result') {
198
+ const url = stringOf(fetched.url)
199
+ if (url !== undefined && !seen.has(url)) {
200
+ seen.add(url)
201
+ const source = { url }
202
+ const title = stringOf(fetched.content?.title)
203
+ if (title !== undefined) source.title = title
204
+ sources.push(source)
205
+ }
206
+ }
207
+ }
208
+ }
209
+ const usage = payload?.usage !== null && typeof payload?.usage === 'object' ? payload.usage : {}
210
+ const serverTools = usage.server_tool_use !== null && typeof usage.server_tool_use === 'object'
211
+ ? usage.server_tool_use
212
+ : {}
213
+ return {
214
+ answer: texts.join('\n\n').trim(),
215
+ queries,
216
+ sources,
217
+ usage: {
218
+ toolSearch: countOf(serverTools.tool_search),
219
+ toolFetch: countOf(serverTools.tool_fetch),
220
+ imageSearch: countOf(serverTools.tool_image_search),
221
+ inputTokens: countOf(usage.input_tokens),
222
+ outputTokens: countOf(usage.output_tokens),
223
+ },
224
+ }
225
+ }
226
+
227
+ /** Resolve one auxiliary deep-search call's auth headers. */
228
+ async function resolveDeepAuth(ctx, config, endpoint) {
229
+ // The account token is only meaningful for a Session running on the account
230
+ // route; an API-key Session keeps its own key (same rule as the built-in
231
+ // DeepSeek search provider). An unresolved route still prefers the signed-in
232
+ // account, because that is the only credential a sign-in-only profile has.
233
+ const agents = typeof ctx.get === 'function' ? ctx.get('agents') : undefined
234
+ const initiator = agents !== undefined && agents !== null && typeof agents.currentInitiator === 'function'
235
+ ? agents.currentInitiator()
236
+ : undefined
237
+ const route = initiator?.session?.requestContext?.()?.provider
238
+ const account = typeof ctx.get === 'function' ? ctx.get('deepseekAccount') : undefined
239
+ if ((route === undefined || route === 'deepseek-account')
240
+ && account !== undefined && account !== null
241
+ && typeof account.resolveToken === 'function') {
242
+ try {
243
+ const token = await account.resolveToken(endpoint)
244
+ if (typeof token === 'string' && token.length > 0) {
245
+ return {
246
+ kind: 'account',
247
+ headers: { 'x-dsh-auth-token': token },
248
+ reject: async () => {
249
+ if (typeof account.rejectToken === 'function') await account.rejectToken(token)
250
+ },
251
+ }
252
+ }
253
+ } catch (_accountTokenUnavailable) {
254
+ /* Fall through to the API key. */
255
+ }
256
+ }
257
+ const environment = globalThis.process !== undefined ? globalThis.process.env : undefined
258
+ const fromEnvironment = environment === undefined ? undefined : environment[config.deepSearch.apiKeyEnv]
259
+ const key = typeof fromEnvironment === 'string' && fromEnvironment.trim().length > 0
260
+ ? fromEnvironment.trim()
261
+ : undefined
262
+ if (key !== undefined) {
263
+ return { kind: 'api-key', headers: { 'x-api-key': key, authorization: `Bearer ${key}` } }
264
+ }
265
+ throw new Error(
266
+ `ChatBot deep search needs a credential: sign in to a DeepSeek account in DSH, `
267
+ + `or set ${config.deepSearch.apiKeyEnv} in $DSH_HOME/.env.`,
268
+ )
269
+ }
270
+
271
+ /**
272
+ * Host half entry point.
273
+ * @param {import('@deepseek-ai/cordis').Context} ctx
274
+ * @param {unknown} rawConfig
275
+ */
276
+ export const name = 'chatbot-panel'
277
+ /** Services this plugin needs before activation. */
278
+ export const inject = ['agents', 'sessionProjections', 'tools']
279
+
280
+ export async function apply(ctx, rawConfig) {
281
+ const config = resolveConfig(rawConfig)
282
+
283
+ /** Live agents whose built-in web tools this plugin currently restricts. */
284
+ const gates = new Map()
285
+ /** Memoized wire views, so an unchanged state publishes no new object. */
286
+ let capsStateCache
287
+ let capsViewCache
288
+ let usageStateCache
289
+ let usageViewCache
290
+
291
+ const capabilities = {
292
+ key: CAP_KEY,
293
+ stateVersion: 1,
294
+ stateSchema: passthroughSchema,
295
+ init: () => emptyCapabilities(),
296
+ apply: (state, event) => {
297
+ if (event.type === 'command/run') {
298
+ if (event.data.name !== 'chatbot') return state
299
+ const parsed = parseCapabilityInput(event.data.args)
300
+ if (parsed === undefined || parsed.kind !== 'patch') return state
301
+ return { ...state, pending: { commandId: event.data.commandId, patch: parsed.patch } }
302
+ }
303
+ if (event.type === 'command/done' && state.pending !== null && event.data.commandId === state.pending.commandId) {
304
+ if (event.data.kind !== 'success') return { ...state, pending: null }
305
+ return applyCapabilityPatch(state, state.pending.patch)
306
+ }
307
+ return state
308
+ },
309
+ wire: {
310
+ viewSchema: passthroughSchema,
311
+ view: (state) => {
312
+ if (state === capsStateCache) return capsViewCache
313
+ capsStateCache = state
314
+ capsViewCache = { web: state.web === true, deep: state.deep === true, pending: state.pending !== null }
315
+ return capsViewCache
316
+ },
317
+ },
318
+ }
319
+
320
+ const usage = {
321
+ key: USAGE_KEY,
322
+ // v2: fix the tool/result call-id field (message.toolCallId), so earlier
323
+ // zero-count checkpoints must be discarded instead of reused.
324
+ stateVersion: 2,
325
+ stateSchema: passthroughSchema,
326
+ init: () => emptyUsage(),
327
+ apply: (state, event) => {
328
+ if (event.type === 'tool/call') {
329
+ const name = event.data.name
330
+ if (name !== SEARCH_TOOL && name !== FETCH_TOOL && name !== DEEP_TOOL) return state
331
+ const queries = name === SEARCH_TOOL ? queryCount(event.data.arguments) : 0
332
+ return {
333
+ ...state,
334
+ open: trimOpenCalls({ ...state.open, [event.data.callId]: { name, queries } }),
335
+ }
336
+ }
337
+ if (event.type === 'tool/result') {
338
+ // The durable tool result identifies its call through `message.toolCallId`
339
+ // (older/alternate envelopes may expose `callId` or `source.callId`).
340
+ const message = event.data.message
341
+ const callId = typeof message?.toolCallId === 'string'
342
+ ? message.toolCallId
343
+ : typeof message?.callId === 'string'
344
+ ? message.callId
345
+ : typeof message?.source?.callId === 'string' ? message.source.callId : undefined
346
+ const open = callId === undefined ? undefined : state.open[callId]
347
+ if (open === undefined) return state
348
+ const nextOpen = { ...state.open }
349
+ delete nextOpen[callId]
350
+ const failed = event.data.message?.isError === true
351
+ const meta = event.data.meta !== null && typeof event.data.meta === 'object' ? event.data.meta : {}
352
+ if (open.name === SEARCH_TOOL) {
353
+ const search = { ...state.search }
354
+ if (failed) {
355
+ search.failed += 1
356
+ search.failedQueries += open.queries
357
+ } else {
358
+ search.calls += open.queries
359
+ search.queries += open.queries
360
+ search.sources += Array.isArray(meta.sources) ? meta.sources.length : 0
361
+ }
362
+ return { ...state, search, open: nextOpen }
363
+ }
364
+ if (open.name === FETCH_TOOL) {
365
+ const fetch = { ...state.fetch }
366
+ if (failed) fetch.failed += 1
367
+ else fetch.calls += 1
368
+ return { ...state, fetch, open: nextOpen }
369
+ }
370
+ const deep = { ...state.deep }
371
+ if (failed) deep.failed += 1
372
+ else {
373
+ const deepUsage = meta.usage !== null && typeof meta.usage === 'object' ? meta.usage : {}
374
+ deep.calls += 1
375
+ deep.toolSearch += countOf(deepUsage.toolSearch)
376
+ deep.toolFetch += countOf(deepUsage.toolFetch)
377
+ deep.imageSearch += countOf(deepUsage.imageSearch)
378
+ deep.inputTokens += countOf(deepUsage.inputTokens)
379
+ deep.outputTokens += countOf(deepUsage.outputTokens)
380
+ deep.sources += Array.isArray(meta.sources) ? meta.sources.length : 0
381
+ }
382
+ return { ...state, deep, open: nextOpen }
383
+ }
384
+ return state
385
+ },
386
+ wire: {
387
+ viewSchema: passthroughSchema,
388
+ summary: false,
389
+ view: (state) => {
390
+ if (state === usageStateCache) return usageViewCache
391
+ const perCall = config.pricePerThousand / 1000
392
+ const searchFee = state.search.calls * perCall
393
+ const fetchFee = config.countFetchCost ? state.fetch.calls * perCall : 0
394
+ const deepToolCount = state.deep.toolSearch + state.deep.toolFetch + state.deep.imageSearch
395
+ const deepToolFee = deepToolCount * perCall
396
+ const deepTokenFee = (state.deep.inputTokens / 1e6) * config.tokenPrice.inputPerMillion
397
+ + (state.deep.outputTokens / 1e6) * config.tokenPrice.outputPerMillion
398
+ usageStateCache = state
399
+ usageViewCache = {
400
+ search: { ...state.search },
401
+ fetch: { ...state.fetch },
402
+ deep: { ...state.deep },
403
+ fees: {
404
+ search: searchFee,
405
+ fetch: fetchFee,
406
+ deepTools: deepToolFee,
407
+ deepTokens: deepTokenFee,
408
+ total: searchFee + fetchFee + deepToolFee + deepTokenFee,
409
+ },
410
+ countFetchCost: config.countFetchCost,
411
+ pricePerThousand: config.pricePerThousand,
412
+ usageUrl: USAGE_URL,
413
+ }
414
+ return usageViewCache
415
+ },
416
+ },
417
+ }
418
+
419
+ const capabilitiesHandle = await ctx.sessionProjections.register(capabilities)
420
+ await ctx.sessionProjections.register(usage)
421
+
422
+ /** Current capability state for one Session, never undefined. */
423
+ const capabilitiesOf = (session) => {
424
+ const state = capabilitiesHandle.stateOf(session)
425
+ return state === undefined || state === null ? emptyCapabilities() : state
426
+ }
427
+
428
+ /** Drop one agent's restriction, if any. */
429
+ const releaseGate = (agent) => {
430
+ const dispose = gates.get(agent)
431
+ if (dispose === undefined) return
432
+ gates.delete(agent)
433
+ try {
434
+ dispose()
435
+ } catch (error) {
436
+ ctx.logger.warn('chatbot-panel: failed to release the web-tool restriction: %o', error)
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Make one agent's tool visibility match its capability state: while the web
442
+ * capability is off, built-in web tools are restricted away from this agent.
443
+ */
444
+ const syncGate = (agent, state) => {
445
+ if (!config.manageBuiltinWebTools) {
446
+ releaseGate(agent)
447
+ return
448
+ }
449
+ if (state.web === true) {
450
+ releaseGate(agent)
451
+ return
452
+ }
453
+ if (gates.has(agent) || agent.ctx === undefined || agent.ctx.tools === undefined) return
454
+ // A preset without the web tools has no restrictable name; restricting an
455
+ // absent name rejects, so a failure here only means nothing to hide.
456
+ try {
457
+ const dispose = agent.ctx.effect(
458
+ () => agent.ctx.tools.restrict({ deny: BUILTIN_WEB_TOOLS }),
459
+ 'chatbot-panel: hide built-in web tools',
460
+ )
461
+ gates.set(agent, dispose)
462
+ } catch (error) {
463
+ ctx.logger.debug('chatbot-panel: no restrictable built-in web tools for this agent: %o', error)
464
+ }
465
+ }
466
+
467
+ // The deep-thinking search tool exists only while both capabilities are on.
468
+ const deepTool = {
469
+ name: DEEP_TOOL,
470
+ description:
471
+ 'Deep thinking web search. A search model plans several queries, searches, opens and reads the '
472
+ + 'most relevant pages over multiple steps, then answers with sources. Slower, token-costlier and '
473
+ + 'billed per search/fetch than web_search; use it for questions that need several sources. '
474
+ + 'The user enables it in the ChatBot panel.',
475
+ parameters: {
476
+ type: 'object',
477
+ additionalProperties: false,
478
+ required: ['queries'],
479
+ properties: {
480
+ queries: {
481
+ type: 'array',
482
+ minItems: 1,
483
+ maxItems: 3,
484
+ items: { type: 'string' },
485
+ description: '1–3 focused search queries.',
486
+ },
487
+ },
488
+ },
489
+ output: {
490
+ schema: {
491
+ type: 'object',
492
+ additionalProperties: false,
493
+ required: ['answer', 'sources', 'queries', 'usage'],
494
+ properties: {
495
+ answer: { type: 'string' },
496
+ queries: { type: 'array', items: { type: 'string' } },
497
+ sources: {
498
+ type: 'array',
499
+ items: {
500
+ type: 'object',
501
+ additionalProperties: false,
502
+ required: ['url'],
503
+ properties: {
504
+ url: { type: 'string' },
505
+ title: { type: 'string' },
506
+ snippet: { type: 'string' },
507
+ site: { type: 'string' },
508
+ publishedAt: { type: 'string' },
509
+ },
510
+ },
511
+ },
512
+ usage: {
513
+ type: 'object',
514
+ additionalProperties: false,
515
+ required: ['toolSearch', 'toolFetch', 'imageSearch', 'inputTokens', 'outputTokens'],
516
+ properties: {
517
+ toolSearch: { type: 'number' },
518
+ toolFetch: { type: 'number' },
519
+ imageSearch: { type: 'number' },
520
+ inputTokens: { type: 'number' },
521
+ outputTokens: { type: 'number' },
522
+ },
523
+ },
524
+ },
525
+ },
526
+ render: (_args, value) => [{ type: 'text', text: renderDeepResult(value) }],
527
+ presentationMeta: (_args, value) => ({
528
+ card: 'chatbot-deep-search',
529
+ answer: value.answer,
530
+ sources: value.sources,
531
+ usage: value.usage,
532
+ }),
533
+ },
534
+ timeoutMs: config.deepSearch.timeoutMs,
535
+ isConcurrencySafe: () => false,
536
+ async execute(args, exec) {
537
+ const queries = Array.isArray(args?.queries)
538
+ ? args.queries.filter(item => typeof item === 'string' && item.trim().length > 0).map(item => item.trim())
539
+ : []
540
+ if (queries.length === 0 || queries.length > 3) {
541
+ throw new Error('chatbot_deep_search needs 1–3 non-empty queries')
542
+ }
543
+ const endpoint = `${config.deepSearch.baseURL}/messages`
544
+ const auth = await resolveDeepAuth(ctx, config, endpoint)
545
+ const body = {
546
+ model: config.deepSearch.model,
547
+ max_tokens: config.deepSearch.maxTokens,
548
+ thinking: { type: 'enabled' },
549
+ tools: [{ type: 'deepseek_web_search', name: 'deepseek_web_search' }],
550
+ messages: [{ role: 'user', content: [{ type: 'text', text: deepInstruction(queries) }] }],
551
+ }
552
+ let response
553
+ try {
554
+ response = await fetch(endpoint, {
555
+ method: 'POST',
556
+ redirect: 'error',
557
+ headers: {
558
+ ...auth.headers,
559
+ 'anthropic-version': ANTHROPIC_VERSION,
560
+ 'content-type': 'application/json',
561
+ accept: 'application/json',
562
+ 'user-agent': 'dsh-chatbot-panel/0.1.0',
563
+ },
564
+ body: JSON.stringify(body),
565
+ signal: exec.signal,
566
+ })
567
+ } catch (error) {
568
+ if (exec.signal.aborted) throw error
569
+ throw new Error(`ChatBot deep search request failed at ${endpoint}: ${String(error)}`)
570
+ }
571
+ if (!response.ok) {
572
+ let detail = `HTTP ${response.status}`
573
+ try {
574
+ const parsed = await response.json()
575
+ const message = typeof parsed?.error === 'string' ? parsed.error : parsed?.error?.message ?? parsed?.message
576
+ if (typeof message === 'string' && message.length > 0) detail += `: ${message}`
577
+ } catch (_unreadableErrorBody) {
578
+ /* The HTTP status is the actionable part. */
579
+ }
580
+ if (response.status === 401 && auth.kind === 'account' && typeof auth.reject === 'function') {
581
+ try { await auth.reject() } catch (_credentialRemovalFailed) { /* report the original failure */ }
582
+ throw new Error(
583
+ `ChatBot deep search was rejected (${detail}); the DeepSeek account sign-in is no longer valid, `
584
+ + 'sign in again or configure an API key in $DSH_HOME/.env.',
585
+ )
586
+ }
587
+ throw new Error(`ChatBot deep search was rejected (${detail}) at ${endpoint}`)
588
+ }
589
+ let payload
590
+ try {
591
+ payload = await response.json()
592
+ } catch (error) {
593
+ throw new Error(`ChatBot deep search returned an unreadable response: ${String(error)}`)
594
+ }
595
+ return mapDeepResponse(payload, queries)
596
+ },
597
+ }
598
+
599
+ ctx.tools.register(deepTool, {
600
+ when: ({ agent }) => {
601
+ const state = capabilitiesOf(agent.session)
602
+ return state.web === true && state.deep === true
603
+ },
604
+ })
605
+
606
+ // Existing agents (this plugin may activate after sessions started).
607
+ for (const agent of ctx.agents.list()) syncGate(agent, capabilitiesOf(agent.session))
608
+ ctx.on('agent/created', ({ agent }) => { syncGate(agent, capabilitiesOf(agent.session)) })
609
+ ctx.on('agent/disposed', ({ agent }) => { gates.delete(agent) })
610
+ ctx.effect(() => () => {
611
+ for (const dispose of gates.values()) {
612
+ try {
613
+ dispose()
614
+ } catch (_disposeFailure) {
615
+ /* Agent disposal already owns the registration. */
616
+ }
617
+ }
618
+ gates.clear()
619
+ }, 'chatbot-panel: release web-tool restrictions')
620
+
621
+ // The command child activates only when a command registry is composed.
622
+ ctx.inject(['commands'], (commandCtx) => {
623
+ commandCtx.commands.register({
624
+ definitionId: 'chatbot-panel',
625
+ name: 'chatbot',
626
+ description: 'Toggle the ChatBot capabilities of this session: /chatbot web on|off, deep on|off, reset',
627
+ input: { hint: 'web on|off · deep on|off · reset' },
628
+ handler: ({ agent, rawInput }) => {
629
+ const parsed = parseCapabilityInput(rawInput)
630
+ if (parsed === undefined) {
631
+ return {
632
+ kind: 'error',
633
+ text: 'Usage: /chatbot web on|off · /chatbot deep on|off · /chatbot reset',
634
+ }
635
+ }
636
+ const current = capabilitiesOf(agent.session)
637
+ if (parsed.kind === 'status') {
638
+ return {
639
+ kind: 'success',
640
+ text: `ChatBot capabilities: web ${current.web ? 'on' : 'off'}, deep ${current.deep ? 'on' : 'off'}`,
641
+ }
642
+ }
643
+ const next = applyCapabilityPatch(current, parsed.patch)
644
+ // Apply the gating now: the projection picks the same value up from the
645
+ // command events this invocation records.
646
+ syncGate(agent, next)
647
+ return { kind: 'success' }
648
+ },
649
+ })
650
+ })
651
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "ChatBot capability panel",
4
+ "description": "Adds a ChatBot chip right of the composer's permission chip (Workspace Write). Clicking it opens a capability panel; enabling Web search or Deep thinking search starts that capability for the current session, and while a capability is off the matching tools leave the model's tool catalog entirely. The panel footer records search, fetch and deep-search counts plus an estimated cost at the official price (CNY 5 per 1,000 successful web-tool requests), with a link to the platform usage page. Capabilities default to off and the choice is stored per session, surviving reloads and resume."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "ChatBot 能力面板",
4
+ "description": "在输入框「工作区内修改」权限按钮右侧加一个 ChatBot 按钮:点击展开能力面板,勾选「联网搜索」或「深度思考搜索」即在本会话启用对应能力;未启用时相应工具会从模型的工具目录中彻底移除。面板底部按官方计费口径记录搜索、抓取、深搜的次数与估算费用(搜索/抓取 ¥5/千次成功请求),并提供开放平台用量页入口。能力默认关闭,开关按会话保存,刷新页面、恢复会话后仍然有效。"
5
+ }
6
+ }