@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 +21 -0
- package/README.md +150 -2
- package/client.js +468 -0
- package/cordis.patch.yml +23 -0
- package/icon.svg +15 -0
- package/index.js +651 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
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