dsh-plugin-weapp-gateway 0.8.13 → 0.8.23
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/PROTOCOL.md +168 -1
- package/README.md +63 -14
- package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -0
- package/bin/setup-ip.mjs +0 -0
- package/lib/account-bridge.mjs +57 -0
- package/lib/client.js +5 -5
- package/lib/context-bridge.mjs +1715 -0
- package/lib/cost-meter-bridge.mjs +794 -0
- package/lib/cost-meter-dashboard-bridge.mjs +1449 -0
- package/lib/dsh-host-adapter.mjs +1 -1
- package/lib/index.mjs +111 -15
- package/lib/plugin-updates.mjs +103 -10
- package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -0
- package/lib/vendor/cost-meter/coding-plans.mjs +1668 -0
- package/lib/vendor/cost-meter/credentials-shim.mjs +8 -0
- package/lib/vendor/cost-meter/custom-balance.mjs +281 -0
- package/lib/vendor/cost-meter/factory-quota.mjs +69 -0
- package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -0
- package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -0
- package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -0
- package/lib/vendor/cost-meter/net.mjs +183 -0
- package/lib/vendor/cost-meter/opendesign.mjs +204 -0
- package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -0
- package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -0
- package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -0
- package/lib/vendor/cost-meter/workbuddy.mjs +356 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -54
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -136,7 +136,7 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
136
136
|
if (payload.beforeSeq !== undefined) {
|
|
137
137
|
const page = requireRecord(await invoke('session', 'page', requestArgs({
|
|
138
138
|
address: request.address,
|
|
139
|
-
throughSeq: snapshot.cursor,
|
|
139
|
+
throughSeq: Number.isSafeInteger(payload.throughSeq) ? payload.throughSeq : snapshot.cursor,
|
|
140
140
|
beforeSeq: payload.beforeSeq,
|
|
141
141
|
...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
142
142
|
}), signal), 'session/page')
|
package/lib/index.mjs
CHANGED
|
@@ -135,6 +135,10 @@ import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh
|
|
|
135
135
|
import { createSpeechInputManager, SPEECH_REQUEST_TYPES } from './speech-input.mjs'
|
|
136
136
|
import { handlePluginQuery, PLUGIN_REQUEST_TYPES } from './plugin-management.mjs'
|
|
137
137
|
import { handleWorkspaceChanges, WORKSPACE_CHANGES_TYPES } from './workspace-changes-bridge.mjs'
|
|
138
|
+
import { handleCostMeter, COST_METER_REQUEST_TYPES, registerCostMeterProjection, registerCostMeterServices } from './cost-meter-bridge.mjs'
|
|
139
|
+
import { handleCostMeterWithDashboard } from './cost-meter-dashboard-bridge.mjs'
|
|
140
|
+
import { handleContextQuery, handleContextFleet, handleContextContent, registerContextProjection, registerContextServices, CONTEXT_REQUEST_TYPES } from './context-bridge.mjs'
|
|
141
|
+
import { handleAccount, ACCOUNT_REQUEST_TYPES } from './account-bridge.mjs'
|
|
138
142
|
import { createSessionFollower } from './session-follower.mjs'
|
|
139
143
|
import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
140
144
|
import { createRelayState, PAIRING_MODES } from './relay-state.mjs'
|
|
@@ -289,6 +293,44 @@ function imagesOf(blocks) {
|
|
|
289
293
|
return images
|
|
290
294
|
}
|
|
291
295
|
|
|
296
|
+
function attachmentsOf(blocks) {
|
|
297
|
+
const attachments = []
|
|
298
|
+
for (const block of blocks || []) {
|
|
299
|
+
if (!block || typeof block !== 'object') continue
|
|
300
|
+
if (block.type === 'image' && block.attachment) {
|
|
301
|
+
attachments.push({ kind: 'image', ...imagesOf([block])[0] })
|
|
302
|
+
} else if (block.type === 'file') {
|
|
303
|
+
const ref = block.attachment || block.file || block
|
|
304
|
+
const item = {
|
|
305
|
+
kind: 'file',
|
|
306
|
+
...(typeof block.receiptId === 'string' ? { receiptId: block.receiptId } : {}),
|
|
307
|
+
...(typeof ref?.attachmentId === 'string' ? { attachmentId: ref.attachmentId } : {}),
|
|
308
|
+
...(typeof ref?.name === 'string' ? { name: ref.name } : {}),
|
|
309
|
+
...(Number.isSafeInteger(ref?.bytes) ? { bytes: ref.bytes } : {}),
|
|
310
|
+
...(typeof ref?.mediaType === 'string' ? { mediaType: ref.mediaType } : {}),
|
|
311
|
+
}
|
|
312
|
+
if (item.receiptId || item.attachmentId || item.name) attachments.push(item)
|
|
313
|
+
} else if (block.type === 'directory' || block.type === 'folder') {
|
|
314
|
+
const path = block.path || block.name
|
|
315
|
+
if (typeof path === 'string' && path) attachments.push({ kind: 'directory', path, name: block.name || path })
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return attachments
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function workspaceAttachmentsOfText(text) {
|
|
322
|
+
if (typeof text !== 'string') return []
|
|
323
|
+
const attachments = []
|
|
324
|
+
const pattern = /^\[工作区(文件|目录):\s*([^\]\n]+)\]$/gm
|
|
325
|
+
let match
|
|
326
|
+
while ((match = pattern.exec(text))) {
|
|
327
|
+
const pathValue = match[2].trim()
|
|
328
|
+
if (!pathValue) continue
|
|
329
|
+
attachments.push({ kind: match[1] === '目录' ? 'directory' : 'file', path: pathValue, name: pathValue.split('/').pop() || pathValue })
|
|
330
|
+
}
|
|
331
|
+
return attachments
|
|
332
|
+
}
|
|
333
|
+
|
|
292
334
|
// Build the small, owned JSON wire record for one session event. Reads only
|
|
293
335
|
// leaf fields of the live SessionEvent — never serializes live objects.
|
|
294
336
|
function buildWireEvent(session, event) {
|
|
@@ -300,13 +342,16 @@ function buildWireEvent(session, event) {
|
|
|
300
342
|
switch (event.type) {
|
|
301
343
|
case 'user/message': {
|
|
302
344
|
const images = imagesOf(d.content || [])
|
|
345
|
+
const text = textOf(d.content || [])
|
|
346
|
+
const attachments = [...attachmentsOf(d.content || []), ...workspaceAttachmentsOfText(text)]
|
|
303
347
|
return Object.assign(base, {
|
|
304
348
|
event: {
|
|
305
349
|
type: 'user/message',
|
|
306
|
-
text
|
|
350
|
+
text,
|
|
307
351
|
source: d.source && d.source.kind,
|
|
308
352
|
...(typeof d.id === 'string' && d.id ? { raw: { id: d.id } } : {}),
|
|
309
353
|
...(images.length ? { images } : {}),
|
|
354
|
+
...(attachments.length ? { attachments } : {}),
|
|
310
355
|
},
|
|
311
356
|
})
|
|
312
357
|
}
|
|
@@ -316,6 +361,7 @@ function buildWireEvent(session, event) {
|
|
|
316
361
|
let reasoning = ''
|
|
317
362
|
const toolCalls = []
|
|
318
363
|
const images = imagesOf(blocks)
|
|
364
|
+
const attachments = attachmentsOf(blocks)
|
|
319
365
|
for (const block of blocks) {
|
|
320
366
|
if (!block) continue
|
|
321
367
|
if (block.type === 'text' && typeof block.text === 'string') text += block.text
|
|
@@ -327,6 +373,7 @@ function buildWireEvent(session, event) {
|
|
|
327
373
|
...(d.interrupted === true ? { interrupted: true } : {}),
|
|
328
374
|
...(d.usage === undefined ? {} : { usage: d.usage }),
|
|
329
375
|
...(images.length ? { images } : {}),
|
|
376
|
+
...(attachments.length ? { attachments } : {}),
|
|
330
377
|
},
|
|
331
378
|
})
|
|
332
379
|
}
|
|
@@ -416,15 +463,15 @@ function buildWireEvent(session, event) {
|
|
|
416
463
|
...(typeof d.error === 'string' ? { error: d.error } : {}),
|
|
417
464
|
},
|
|
418
465
|
})
|
|
419
|
-
// Keep live file declarations equivalent to durable history.
|
|
420
|
-
case 'workspace/changes':
|
|
421
|
-
return Object.assign(base, { event: { type: event.type, turn: d.turn } })
|
|
422
|
-
case 'deliverables/presented':
|
|
423
|
-
return Object.assign(base, { event: { type: event.type, turn: d.turn, callId: d.callId,
|
|
424
|
-
files: (Array.isArray(d.files) ? d.files : []).filter(file => file && typeof file.path === 'string').map(file => ({
|
|
425
|
-
path: file.path, ...(typeof file.description === 'string' ? { description: file.description } : {}),
|
|
426
|
-
})),
|
|
427
|
-
} })
|
|
466
|
+
// Keep live file declarations equivalent to durable history.
|
|
467
|
+
case 'workspace/changes':
|
|
468
|
+
return Object.assign(base, { event: { type: event.type, turn: d.turn } })
|
|
469
|
+
case 'deliverables/presented':
|
|
470
|
+
return Object.assign(base, { event: { type: event.type, turn: d.turn, callId: d.callId,
|
|
471
|
+
files: (Array.isArray(d.files) ? d.files : []).filter(file => file && typeof file.path === 'string').map(file => ({
|
|
472
|
+
path: file.path, ...(typeof file.description === 'string' ? { description: file.description } : {}),
|
|
473
|
+
})),
|
|
474
|
+
} })
|
|
428
475
|
case 'turn/start':
|
|
429
476
|
case 'turn/end':
|
|
430
477
|
case 'step/start':
|
|
@@ -470,6 +517,25 @@ function parseWireImages(rawImages, requestType) {
|
|
|
470
517
|
return { value: imageParts }
|
|
471
518
|
}
|
|
472
519
|
|
|
520
|
+
function parseWireAttachments(rawAttachments, requestType) {
|
|
521
|
+
if (rawAttachments === undefined) return { value: [] }
|
|
522
|
+
if (!Array.isArray(rawAttachments) || rawAttachments.length > 6) {
|
|
523
|
+
return { error: { kind: 'error', code: 'bad-request', message: 'attachments must be an array with at most 6 items', requestType } }
|
|
524
|
+
}
|
|
525
|
+
const value = []
|
|
526
|
+
for (let index = 0; index < rawAttachments.length; index++) {
|
|
527
|
+
const item = rawAttachments[index]
|
|
528
|
+
if (!item || typeof item !== 'object' || !['file', 'directory'].includes(item.kind)
|
|
529
|
+
|| typeof item.path !== 'string' || !item.path.trim() || item.path.includes('\0')
|
|
530
|
+
|| path.isAbsolute(item.path) || item.path.split(/[\\/]+/).includes('..')) {
|
|
531
|
+
return { error: { kind: 'error', code: 'bad-request', message: `attachments[${index}] must be a workspace-relative file or directory path`, requestType } }
|
|
532
|
+
}
|
|
533
|
+
const cleanPath = item.path.trim().replace(/[\\]+/g, '/')
|
|
534
|
+
value.push({ kind: item.kind, path: cleanPath, ...(typeof item.name === 'string' && item.name ? { name: item.name.slice(0, 255) } : {}) })
|
|
535
|
+
}
|
|
536
|
+
return { value }
|
|
537
|
+
}
|
|
538
|
+
|
|
473
539
|
function commandNameOf(line) {
|
|
474
540
|
const trimmed = line.trim()
|
|
475
541
|
if (!trimmed.startsWith('/')) return null
|
|
@@ -545,7 +611,12 @@ async function admitMessage(api, msg, notificationTracker) {
|
|
|
545
611
|
const parsedImages = parseWireImages(msg.images, 'message')
|
|
546
612
|
if (parsedImages.error) return parsedImages.error
|
|
547
613
|
const imageParts = parsedImages.value
|
|
548
|
-
|
|
614
|
+
const parsedAttachments = parseWireAttachments(msg.attachments, 'message')
|
|
615
|
+
if (parsedAttachments.error) return parsedAttachments.error
|
|
616
|
+
const attachmentRefs = parsedAttachments.value
|
|
617
|
+
const attachmentText = attachmentRefs.map((item) => `[${item.kind === 'directory' ? '工作区目录' : '工作区文件'}: ${item.path}]`).join('\n')
|
|
618
|
+
const promptText = [text, attachmentText].filter(Boolean).join('\n')
|
|
619
|
+
if (!promptText && imageParts.length === 0) {
|
|
549
620
|
return { kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' }
|
|
550
621
|
}
|
|
551
622
|
const mode = msg.mode === 'steer' ? 'steer' : 'queue'
|
|
@@ -578,10 +649,10 @@ async function admitMessage(api, msg, notificationTracker) {
|
|
|
578
649
|
sessionId,
|
|
579
650
|
mode,
|
|
580
651
|
// Match the official WebUI ordering: images first, optional text last.
|
|
581
|
-
content: [...imageParts, ...(
|
|
652
|
+
content: [...imageParts, ...(promptText ? [{ type: 'text', text: promptText }] : [])],
|
|
582
653
|
...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
|
|
583
654
|
})
|
|
584
|
-
log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${
|
|
655
|
+
log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} attachments=${attachmentRefs.length} text="${promptText.slice(0, 60)}"`)
|
|
585
656
|
return {
|
|
586
657
|
kind: 'sent',
|
|
587
658
|
sessionId,
|
|
@@ -1457,7 +1528,12 @@ async function handleClawbotControl(clawbotService, msg) {
|
|
|
1457
1528
|
// Dispatch one mobile query frame; returns the wire frame to send back.
|
|
1458
1529
|
async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
|
|
1459
1530
|
if (msg.type === 'clawbot-control') return handleClawbotControl(pluginContext?.clawbotService, msg)
|
|
1531
|
+
if (COST_METER_REQUEST_TYPES.has(msg.type)) return handleCostMeterWithDashboard(api, msg, { hostContext: pluginContext?.hostContext })
|
|
1460
1532
|
if (WORKSPACE_CHANGES_TYPES.has(msg.type)) return handleWorkspaceChanges(api, msg)
|
|
1533
|
+
if (msg.type === 'context-fleet') return handleContextFleet(msg)
|
|
1534
|
+
if (msg.type === 'context-content') return handleContextContent(api, msg)
|
|
1535
|
+
if (CONTEXT_REQUEST_TYPES.has(msg.type)) return handleContextQuery(api, msg)
|
|
1536
|
+
if (ACCOUNT_REQUEST_TYPES.has(msg.type)) return handleAccount(api, msg)
|
|
1461
1537
|
if (PLUGIN_REQUEST_TYPES.has(msg.type)) return handlePluginQuery(api, msg, pluginContext)
|
|
1462
1538
|
if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
|
|
1463
1539
|
const session = requireSessionId(msg)
|
|
@@ -2257,6 +2333,19 @@ const plugin = {
|
|
|
2257
2333
|
apply(ctx, config) {
|
|
2258
2334
|
const webServer = ctx.webServer
|
|
2259
2335
|
const api = createDshHostAdapter(ctx.typertGateway)
|
|
2336
|
+
// Optional lifecycle-bound services: hosts without session projections keep working.
|
|
2337
|
+
registerContextProjection(ctx)
|
|
2338
|
+
registerContextServices(ctx)
|
|
2339
|
+
// Cost meter is standalone: it registers its own `costMeter` session
|
|
2340
|
+
// projection (so the host folds usage into the projection cache) and reads
|
|
2341
|
+
// that back in-process — no `dsh-cost-meter` plugin required.
|
|
2342
|
+
registerCostMeterProjection(ctx)
|
|
2343
|
+
registerCostMeterServices(ctx)
|
|
2344
|
+
// Optional lifecycle-bound service: hosts without the DeepSeek account bundle keep working.
|
|
2345
|
+
ctx.inject(['accountController'], accountCtx => {
|
|
2346
|
+
api.account = accountCtx.accountController
|
|
2347
|
+
accountCtx.effect(() => () => { api.account = undefined })
|
|
2348
|
+
})
|
|
2260
2349
|
const clawbotService = createClawbotService(ctx)
|
|
2261
2350
|
ctx.effect(() => () => clawbotService.dispose())
|
|
2262
2351
|
// Optional lifecycle-bound service: old hosts retain all existing gateway features.
|
|
@@ -2821,6 +2910,7 @@ const plugin = {
|
|
|
2821
2910
|
api,
|
|
2822
2911
|
currentVersion: PLUGIN_VERSION,
|
|
2823
2912
|
requestId: crypto.randomUUID(),
|
|
2913
|
+
profileName: ctx.get?.('profileContext')?.name,
|
|
2824
2914
|
})
|
|
2825
2915
|
sendJson(res, 200, result)
|
|
2826
2916
|
} finally {
|
|
@@ -3224,8 +3314,8 @@ const plugin = {
|
|
|
3224
3314
|
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear' ||
|
|
3225
3315
|
msg.type === 'schedule-catalog' || msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
|
|
3226
3316
|
msg.type === 'schedule-update' || msg.type === 'schedule-delete' ||
|
|
3227
|
-
WORKSPACE_CHANGES_TYPES.has(msg.type) || PLUGIN_REQUEST_TYPES.has(msg.type)) {
|
|
3228
|
-
if ((WORKSPACE_CHANGES_TYPES.has(msg.type) || PLUGIN_REQUEST_TYPES.has(msg.type) || msg.type === 'clawbot-control') && !ws.deviceId) {
|
|
3317
|
+
ACCOUNT_REQUEST_TYPES.has(msg.type) || COST_METER_REQUEST_TYPES.has(msg.type) || WORKSPACE_CHANGES_TYPES.has(msg.type) || CONTEXT_REQUEST_TYPES.has(msg.type) || PLUGIN_REQUEST_TYPES.has(msg.type)) {
|
|
3318
|
+
if ((ACCOUNT_REQUEST_TYPES.has(msg.type) || COST_METER_REQUEST_TYPES.has(msg.type) || WORKSPACE_CHANGES_TYPES.has(msg.type) || CONTEXT_REQUEST_TYPES.has(msg.type) || PLUGIN_REQUEST_TYPES.has(msg.type) || msg.type === 'clawbot-control') && !ws.deviceId) {
|
|
3229
3319
|
sendFrame({ kind: 'error', code: 'authentication-required', requestType: msg.type,
|
|
3230
3320
|
...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
|
|
3231
3321
|
message: msg.type === 'clawbot-control'
|
|
@@ -3242,6 +3332,7 @@ const plugin = {
|
|
|
3242
3332
|
onInstallStart: (requestId) => pluginInstallOwners.set(requestId, ws),
|
|
3243
3333
|
onInstallEnd: (requestId) => pluginInstallOwners.delete(requestId),
|
|
3244
3334
|
clawbotService,
|
|
3335
|
+
hostContext: ctx,
|
|
3245
3336
|
}).then((frame) => {
|
|
3246
3337
|
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
3247
3338
|
})
|
|
@@ -3298,6 +3389,11 @@ const plugin = {
|
|
|
3298
3389
|
'session-rename',
|
|
3299
3390
|
...(options.fileDownloadsEnabled ? ['file-downloads'] : []),
|
|
3300
3391
|
...(api.workspaceChanges ? ['workspace-changes-v1'] : []),
|
|
3392
|
+
...(api.account ? ['account-v1'] : []),
|
|
3393
|
+
'cost-meter-v1',
|
|
3394
|
+
'context-dashboard-v1',
|
|
3395
|
+
'context-fleet-v1',
|
|
3396
|
+
'context-content-v1',
|
|
3301
3397
|
],
|
|
3302
3398
|
port: transport.port || webServer.port,
|
|
3303
3399
|
clients: clients.size,
|
package/lib/plugin-updates.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const PLUGIN_PACKAGE_NAME = 'dsh-plugin-weapp-gateway'
|
|
2
2
|
const REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`
|
|
3
|
+
const FAILED_INSTALL_STATES = new Set(['failed', 'cancelled', 'refused'])
|
|
3
4
|
|
|
4
5
|
function errorField(value) {
|
|
5
6
|
return typeof value === 'string' && value.trim() ? value.trim() : ''
|
|
@@ -22,6 +23,7 @@ export function formatPluginInstallError(error, depth = 0) {
|
|
|
22
23
|
if (detail) return `${code && code !== detail ? `${code}: ` : ''}${detail}`.slice(0, 1000)
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
if (code) return code
|
|
25
27
|
try {
|
|
26
28
|
const serialized = JSON.stringify(error, (key, value) => {
|
|
27
29
|
if (/stack|authorization|cookie|password|secret|token|credential|api.?key/i.test(key)) return undefined
|
|
@@ -29,7 +31,28 @@ export function formatPluginInstallError(error, depth = 0) {
|
|
|
29
31
|
})
|
|
30
32
|
if (serialized && serialized !== '{}') return serialized.slice(0, 1000)
|
|
31
33
|
} catch { /* use a generic message for cyclic or non-serializable errors */ }
|
|
32
|
-
return
|
|
34
|
+
return 'The plugin manager did not provide an error message'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function operationErrorCode(error, depth = 0) {
|
|
38
|
+
if (!error || typeof error !== 'object' || depth > 4) return ''
|
|
39
|
+
if (typeof error.code === 'string') return error.code
|
|
40
|
+
for (const nested of [error.error, error.cause, error.details]) {
|
|
41
|
+
const code = operationErrorCode(nested, depth + 1)
|
|
42
|
+
if (code) return code
|
|
43
|
+
}
|
|
44
|
+
return ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function failedOperation(operation) {
|
|
48
|
+
return FAILED_INSTALL_STATES.has(operation?.application) || Boolean(operation?.error)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function operationMessage(operation) {
|
|
52
|
+
if (operation?.error) return formatPluginInstallError(operation.error)
|
|
53
|
+
return typeof operation?.message === 'string' && operation.message.trim()
|
|
54
|
+
? operation.message.trim()
|
|
55
|
+
: `Plugin manager reported ${operation?.application || 'an unknown failure'}`
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
function parseVersion(value) {
|
|
@@ -97,11 +120,14 @@ export async function checkPluginUpdate({ currentVersion, fetchImpl = globalThis
|
|
|
97
120
|
}
|
|
98
121
|
}
|
|
99
122
|
|
|
100
|
-
export async function installLatestPlugin({ api, currentVersion, fetchImpl = globalThis.fetch, requestId }) {
|
|
123
|
+
export async function installLatestPlugin({ api, currentVersion, fetchImpl = globalThis.fetch, requestId, profileName }) {
|
|
101
124
|
const update = await checkPluginUpdate({ currentVersion, fetchImpl })
|
|
102
125
|
if (!update.updateAvailable) return { ...update, installed: false, reason: 'already-current' }
|
|
103
126
|
|
|
104
|
-
const
|
|
127
|
+
const profile = typeof profileName === 'string' && /^[A-Za-z0-9._-]{1,80}$/.test(profileName)
|
|
128
|
+
? profileName
|
|
129
|
+
: 'web'
|
|
130
|
+
const command = `dsh plugin --profile ${profile} add ${PLUGIN_PACKAGE_NAME}@${update.latestVersion}`
|
|
105
131
|
let inventory
|
|
106
132
|
try {
|
|
107
133
|
inventory = await api.plugins.inventory()
|
|
@@ -112,16 +138,83 @@ export async function installLatestPlugin({ api, currentVersion, fetchImpl = glo
|
|
|
112
138
|
return { ...update, installed: false, reason: 'plugin-manager-unavailable', command }
|
|
113
139
|
}
|
|
114
140
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
)
|
|
119
|
-
|
|
141
|
+
let bundles
|
|
142
|
+
try {
|
|
143
|
+
bundles = await api.plugins.listBundles()
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return { ...update, installed: false, reason: 'plugin-manager-unavailable', command, detail: error?.message }
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(bundles)) {
|
|
148
|
+
return { ...update, installed: false, reason: 'plugin-manager-unavailable', command }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const existing = bundles.find(bundle => bundle?.name === PLUGIN_PACKAGE_NAME && bundle.installed === true)
|
|
152
|
+
const installSpec = `${PLUGIN_PACKAGE_NAME}@${update.latestVersion}`
|
|
153
|
+
const installOptions = {
|
|
154
|
+
requestId,
|
|
155
|
+
saveExact: true,
|
|
156
|
+
...(typeof existing?.enabled === 'boolean' ? { enabled: existing.enabled } : {}),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let removed = false
|
|
160
|
+
if (existing) {
|
|
161
|
+
if (typeof api.plugins.removeBundle !== 'function') {
|
|
162
|
+
return { ...update, installed: false, reason: 'plugin-manager-unavailable', command }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let removal
|
|
166
|
+
try {
|
|
167
|
+
removal = await api.plugins.removeBundle(PLUGIN_PACKAGE_NAME)
|
|
168
|
+
} catch (error) {
|
|
169
|
+
removal = { application: 'failed', error }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Older hosts cannot hot-remove a running bundle; their package manager can still replace it for restart.
|
|
173
|
+
if (failedOperation(removal) && operationErrorCode(removal) !== 'stop-profile') {
|
|
174
|
+
return {
|
|
175
|
+
...update,
|
|
176
|
+
installed: true,
|
|
177
|
+
installation: {
|
|
178
|
+
...removal,
|
|
179
|
+
application: FAILED_INSTALL_STATES.has(removal?.application) ? removal.application : 'failed',
|
|
180
|
+
errorMessage: operationMessage(removal),
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
removed = !failedOperation(removal)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let installation
|
|
188
|
+
try {
|
|
189
|
+
installation = await api.plugins.installBundle(installSpec, installOptions)
|
|
190
|
+
} catch (error) {
|
|
191
|
+
installation = { application: 'failed', error }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let errorMessage = ''
|
|
195
|
+
if (failedOperation(installation) && removed) {
|
|
196
|
+
const previousSpec = typeof existing.source === 'string' && existing.source
|
|
197
|
+
? existing.source
|
|
198
|
+
: `${PLUGIN_PACKAGE_NAME}@${currentVersion}`
|
|
199
|
+
try {
|
|
200
|
+
const recovery = await api.plugins.installBundle(previousSpec, {
|
|
201
|
+
...(typeof existing.enabled === 'boolean' ? { enabled: existing.enabled } : {}),
|
|
202
|
+
})
|
|
203
|
+
errorMessage = failedOperation(recovery)
|
|
204
|
+
? `Update failed: ${operationMessage(installation)}. Restoring the previous version also failed: ${operationMessage(recovery)}`
|
|
205
|
+
: `Update failed: ${operationMessage(installation)}. The previous version was restored.`
|
|
206
|
+
installation = { ...installation, recovery }
|
|
207
|
+
} catch (error) {
|
|
208
|
+
errorMessage = `Update failed: ${operationMessage(installation)}. Restoring the previous version also failed: ${formatPluginInstallError(error)}`
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const detail = errorMessage || (installation?.error ? formatPluginInstallError(installation.error) : '')
|
|
120
213
|
return {
|
|
121
214
|
...update,
|
|
122
215
|
installed: true,
|
|
123
|
-
installation:
|
|
124
|
-
? { ...installation, errorMessage }
|
|
216
|
+
installation: detail && installation && typeof installation === 'object'
|
|
217
|
+
? { ...installation, errorMessage: detail }
|
|
125
218
|
: installation,
|
|
126
219
|
}
|
|
127
220
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** 千问 / 阿里云资金账户可用金(issue #98)。凭据仅用于服务端 ACS3 签名。 */
|
|
2
|
+
import { createHash, createHmac, randomUUID } from 'node:crypto'
|
|
3
|
+
import { credentialRef } from './credentials-shim.mjs'
|
|
4
|
+
import { fetchWithRetry } from './net.mjs'
|
|
5
|
+
|
|
6
|
+
// API shape/endpoint: aliyun/alibabacloud-typescript-sdk, bssopenapi-20230930.
|
|
7
|
+
// Signature: https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature
|
|
8
|
+
export const ALIYUN_BALANCE_URL = 'https://business.aliyuncs.com/'
|
|
9
|
+
export const ALIYUN_BALANCE_CREDENTIAL_VARS = [
|
|
10
|
+
'ALIBABA_CLOUD_ACCESS_KEY_ID',
|
|
11
|
+
'ALIBABA_CLOUD_ACCESS_KEY_SECRET',
|
|
12
|
+
'ALIBABA_CLOUD_SECURITY_TOKEN',
|
|
13
|
+
]
|
|
14
|
+
const MAX_BYTES = 262144
|
|
15
|
+
const hash = value => createHash('sha256').update(value).digest('hex')
|
|
16
|
+
|
|
17
|
+
export function signAliyunBalanceRequest(accessKeyId, accessKeySecret, securityToken = '', { now = new Date(), nonce = randomUUID() } = {}) {
|
|
18
|
+
const headers = {
|
|
19
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
20
|
+
host: 'business.aliyuncs.com',
|
|
21
|
+
'x-acs-action': 'GetFundAccountAvailableAmount',
|
|
22
|
+
'x-acs-content-sha256': hash(''),
|
|
23
|
+
'x-acs-date': now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
24
|
+
'x-acs-signature-nonce': nonce,
|
|
25
|
+
'x-acs-version': '2023-09-30',
|
|
26
|
+
}
|
|
27
|
+
if (securityToken) headers['x-acs-security-token'] = securityToken
|
|
28
|
+
const keys = Object.keys(headers).sort()
|
|
29
|
+
const signedHeaders = keys.join(';')
|
|
30
|
+
const canonicalHeaders = keys.map(key => `${key}:${headers[key].trim()}\n`).join('')
|
|
31
|
+
const canonical = ['POST', '/', '', canonicalHeaders, signedHeaders, hash('')].join('\n')
|
|
32
|
+
const signature = createHmac('sha256', accessKeySecret).update(`ACS3-HMAC-SHA256\n${hash(canonical)}`).digest('hex')
|
|
33
|
+
headers.Authorization = `ACS3-HMAC-SHA256 Credential=${accessKeyId},SignedHeaders=${signedHeaders},Signature=${signature}`
|
|
34
|
+
headers.Accept = 'application/json'
|
|
35
|
+
return { method: 'POST', headers, body: '', redirect: 'manual' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const messages = {
|
|
39
|
+
zh: {
|
|
40
|
+
missing: '请配置阿里云 RAM AccessKey ID 和 AccessKey Secret(不是千问模型 API Key)',
|
|
41
|
+
network: '阿里云余额网络请求失败,请稍后重试',
|
|
42
|
+
redirect: '阿里云余额接口发生重定向,已拒绝转发凭据',
|
|
43
|
+
permission: '阿里云余额鉴权失败,请检查 AccessKey、STS 有效期及 RAM 权限 bss:DescribeBillingAccount',
|
|
44
|
+
http: '阿里云余额接口返回 HTTP {status}',
|
|
45
|
+
payload: '阿里云余额响应无效或过大,请稍后重试',
|
|
46
|
+
amount: '阿里云余额响应缺少有效的 AvailableAmount,未将其视为零余额',
|
|
47
|
+
currency: '阿里云余额响应缺少受支持的币种(CNY / USD / EUR)',
|
|
48
|
+
},
|
|
49
|
+
en: {
|
|
50
|
+
missing: 'Configure Alibaba Cloud RAM AccessKey ID and AccessKey Secret (not a Qianwen model API key)',
|
|
51
|
+
network: 'Alibaba Cloud balance network request failed; please retry later',
|
|
52
|
+
redirect: 'Alibaba Cloud balance redirect refused to protect credentials',
|
|
53
|
+
permission: 'Alibaba Cloud balance authentication failed; check AccessKey, STS expiry and RAM permission bss:DescribeBillingAccount',
|
|
54
|
+
http: 'Alibaba Cloud balance returned HTTP {status}',
|
|
55
|
+
payload: 'Alibaba Cloud balance response is invalid or too large; please retry later',
|
|
56
|
+
amount: 'Alibaba Cloud balance response has no valid AvailableAmount; it was not treated as zero',
|
|
57
|
+
currency: 'Alibaba Cloud balance response has no supported currency (CNY / USD / EUR)',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
const failure = (locale, key, status = '') => new Error(messages[locale === 'en' ? 'en' : 'zh'][key].replace('{status}', String(status)))
|
|
61
|
+
|
|
62
|
+
export function parseAliyunBalance(data, locale = 'zh') {
|
|
63
|
+
const raw = data?.AvailableAmount
|
|
64
|
+
const text = typeof raw === 'string' ? raw.trim() : ''
|
|
65
|
+
const amount = typeof raw === 'number' ? raw : /^[+-]?\d+(\.\d+)?$/.test(text) ? Number(text) : NaN
|
|
66
|
+
if (!Number.isFinite(amount)) throw failure(locale, 'amount')
|
|
67
|
+
if (!['CNY', 'USD', 'EUR'].includes(data?.Currency)) throw failure(locale, 'currency')
|
|
68
|
+
// AvailableAmount 是可用金,包含信控/未结清款等因素;不能把它当成现金余额或消费额。
|
|
69
|
+
return { remaining: amount, unit: data.Currency, maxBudget: null, spend: null }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function resolveCredential(ctx, name) {
|
|
73
|
+
try {
|
|
74
|
+
const hit = await ctx?.get?.('credentials')?.resolve(credentialRef(name))
|
|
75
|
+
if (typeof hit?.value === 'string' && hit.value.trim()) return hit.value.trim()
|
|
76
|
+
} catch { /* 与现有余额 adapter 一致,回落环境变量。 */ }
|
|
77
|
+
return String(process.env[name] ?? '').trim()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function readPayload(response, locale) {
|
|
81
|
+
if (Number(response.headers.get('content-length')) > MAX_BYTES) {
|
|
82
|
+
await response.body?.cancel().catch(() => {})
|
|
83
|
+
throw failure(locale, 'payload')
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const parts = []
|
|
87
|
+
let bytes = 0
|
|
88
|
+
for await (const part of response.body) {
|
|
89
|
+
bytes += part.length
|
|
90
|
+
if (bytes > MAX_BYTES) throw failure(locale, 'payload')
|
|
91
|
+
parts.push(part)
|
|
92
|
+
}
|
|
93
|
+
return JSON.parse(Buffer.concat(parts).toString('utf8'))
|
|
94
|
+
} catch { throw failure(locale, 'payload') }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function queryAliyunBalance(ctx, config, { fetchImpl = fetch, signal } = {}) {
|
|
98
|
+
signal?.throwIfAborted()
|
|
99
|
+
const locale = config?.locale
|
|
100
|
+
const [ak, sk, token] = await Promise.all(ALIYUN_BALANCE_CREDENTIAL_VARS.map(name => resolveCredential(ctx, name)))
|
|
101
|
+
if (!ak || !sk) throw Object.assign(failure(locale, 'missing'), { soft: true })
|
|
102
|
+
let response
|
|
103
|
+
try {
|
|
104
|
+
// 每次网络重试重新生成时间戳与 nonce;URL、Action、body 均固定为只读账户查询。
|
|
105
|
+
response = await fetchWithRetry(ALIYUN_BALANCE_URL, { signal }, {
|
|
106
|
+
attempts: 2, timeoutMs: 15000,
|
|
107
|
+
fetchImpl: (url, init) => fetchImpl(url, { ...signAliyunBalanceRequest(ak, sk, token), signal: init.signal }),
|
|
108
|
+
})
|
|
109
|
+
} catch {
|
|
110
|
+
signal?.throwIfAborted()
|
|
111
|
+
throw failure(locale, 'network')
|
|
112
|
+
}
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
await response.body?.cancel().catch(() => {})
|
|
115
|
+
if (response.status >= 300 && response.status < 400) throw failure(locale, 'redirect')
|
|
116
|
+
if (response.status === 401 || response.status === 403) throw failure(locale, 'permission')
|
|
117
|
+
throw failure(locale, 'http', response.status)
|
|
118
|
+
}
|
|
119
|
+
const data = await readPayload(response, locale)
|
|
120
|
+
// 不把上游 Message/RequestId/账户身份下发浏览器;即使错误响应夹带余额也不能记作成功。
|
|
121
|
+
if ((data?.Code && data.Code !== 'Success') || data?.Success === false) throw failure(locale, 'permission')
|
|
122
|
+
return { label: config?.customBalance?.label || '千问 / 阿里云', ...parseAliyunBalance(data, locale) }
|
|
123
|
+
}
|