dsh-plugin-weapp-gateway 0.8.11 → 0.8.13
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 +6 -0
- package/README.md +13 -2
- package/THIRD-PARTY-NOTICES/clawbot.txt +31 -0
- package/bin/setup-ip.mjs +0 -0
- package/lib/clawbot/contracts.mjs +8 -0
- package/lib/clawbot/dsh-host.mjs +560 -0
- package/lib/clawbot/host-admission.mjs +31 -0
- package/lib/clawbot/ilink-media.mjs +321 -0
- package/lib/clawbot/ilink-types.mjs +13 -0
- package/lib/clawbot/ilink.mjs +489 -0
- package/lib/clawbot/native-peer.mjs +29 -0
- package/lib/clawbot/reply-text.mjs +29 -0
- package/lib/clawbot/runtime.mjs +974 -0
- package/lib/clawbot/secure-file.mjs +86 -0
- package/lib/clawbot/service.mjs +81 -0
- package/lib/clawbot/storage.mjs +108 -0
- package/lib/client.js +7 -1
- package/lib/completion-notifications.mjs +86 -7
- package/lib/dsh-host-adapter.mjs +0 -6
- package/lib/index.mjs +11 -7
- package/lib/plugin-updates.mjs +39 -1
- package/package.json +54 -51
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { userInfo } from 'node:os';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
/** Best-effort owner-only permissions for credentials and stable identities. */
|
|
7
|
+
export function tightenPrivateFile(file) {
|
|
8
|
+
try {
|
|
9
|
+
chmodSync(file, 0o600);
|
|
10
|
+
}
|
|
11
|
+
catch { /* best effort */ }
|
|
12
|
+
if (process.platform !== 'win32')
|
|
13
|
+
return;
|
|
14
|
+
try {
|
|
15
|
+
execFileSync('icacls', [file, '/inheritance:r', '/grant:r', `${userInfo().username}:F`], {
|
|
16
|
+
timeout: 5000,
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
stdio: 'ignore',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch { /* best effort */ }
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Replace a private JSON file without ever truncating the previous valid file.
|
|
25
|
+
* The temporary file lives beside the destination, so rename is same-volume.
|
|
26
|
+
*/
|
|
27
|
+
export function writePrivateJsonAtomic(file, value) {
|
|
28
|
+
publishPrivateJson(file, value, true);
|
|
29
|
+
}
|
|
30
|
+
/** Publish a fully written identity once. Concurrent first starts must read
|
|
31
|
+
* the winner, never overwrite it or observe an empty placeholder file. */
|
|
32
|
+
export function createPrivateJsonAtomic(file, value) {
|
|
33
|
+
return publishPrivateJson(file, value, false);
|
|
34
|
+
}
|
|
35
|
+
function publishPrivateJson(file, value, replace) {
|
|
36
|
+
const parent = dirname(file);
|
|
37
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
38
|
+
const temporary = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
mode: 0o600,
|
|
43
|
+
flag: 'wx',
|
|
44
|
+
});
|
|
45
|
+
const fd = openSync(temporary, 'r+');
|
|
46
|
+
try {
|
|
47
|
+
// Some Windows filesystems reject fsync on ordinary user files. Rename
|
|
48
|
+
// remains atomic; durability flush is an additional best-effort barrier.
|
|
49
|
+
try {
|
|
50
|
+
fsyncSync(fd);
|
|
51
|
+
}
|
|
52
|
+
catch { /* best effort */ }
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
tightenPrivateFile(temporary);
|
|
58
|
+
if (replace)
|
|
59
|
+
renameSync(temporary, file);
|
|
60
|
+
else {
|
|
61
|
+
try {
|
|
62
|
+
linkSync(temporary, file);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === 'EEXIST')
|
|
66
|
+
return false;
|
|
67
|
+
throw error; // Do not replace an existing identity on unsupported storage.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
tightenPrivateFile(file);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
if (existsSync(temporary)) {
|
|
75
|
+
try {
|
|
76
|
+
rmSync(temporary, { force: true });
|
|
77
|
+
}
|
|
78
|
+
catch { /* best effort */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function readPrivateJson(file) {
|
|
83
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
84
|
+
tightenPrivateFile(file);
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ClawbotRuntime } from './runtime.mjs';
|
|
5
|
+
import { DshClawbotHost } from './dsh-host.mjs';
|
|
6
|
+
import { FileClawbotStorage } from './storage.mjs';
|
|
7
|
+
import { ClawbotError } from './contracts.mjs';
|
|
8
|
+
import { ILinkError } from './ilink-types.mjs';
|
|
9
|
+
|
|
10
|
+
function dshProfile(ctx) {
|
|
11
|
+
const profile = ctx?.get?.('profileContext');
|
|
12
|
+
if (profile !== undefined && profile !== null) {
|
|
13
|
+
if (!profile || typeof profile !== 'object'
|
|
14
|
+
|| typeof profile.name !== 'string' || !profile.name || profile.name.length > 80
|
|
15
|
+
|| /[\\/\u0000-\u001f]/.test(profile.name) || ['.', '..', 'node_modules'].includes(profile.name)
|
|
16
|
+
|| typeof profile.home !== 'string' || !path.isAbsolute(profile.home) || profile.home.includes('\0')) {
|
|
17
|
+
throw new Error('DSH 运行实例信息不完整,未使用其他实例的配置');
|
|
18
|
+
}
|
|
19
|
+
return { name: profile.name, home: path.normalize(profile.home) };
|
|
20
|
+
}
|
|
21
|
+
if (process.versions.electron) throw new Error('DSH profile facts are unavailable');
|
|
22
|
+
const configured = String(process.env.DSH_HOME || '').trim();
|
|
23
|
+
const home = configured === '~' ? os.homedir()
|
|
24
|
+
: configured.startsWith('~/') || configured.startsWith('~\\')
|
|
25
|
+
? path.join(os.homedir(), configured.slice(2))
|
|
26
|
+
: configured ? configured : path.join(os.homedir(), '.dsh');
|
|
27
|
+
const explicit = process.argv.find((value, index, argv) => argv[index - 1] === '--profile')
|
|
28
|
+
|| process.argv.find(value => value.startsWith('--profile='))?.slice('--profile='.length);
|
|
29
|
+
const name = explicit || (process.argv[2] === 'web' ? 'web' : 'default');
|
|
30
|
+
return { name, home: path.resolve(home) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function clawbotStorageDirectory(ctx) {
|
|
34
|
+
const profile = dshProfile(ctx);
|
|
35
|
+
const scope = ['web', 'default'].includes(profile.name.toLowerCase()) ? 'web' : profile.name;
|
|
36
|
+
const key = createHash('sha256').update(`deepseek-harness\0${scope}`).digest('hex').slice(0, 24);
|
|
37
|
+
return path.join(profile.home, 'harness-remote', 'instances', key, 'clawbot');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createClawbotService(ctx) {
|
|
41
|
+
let storage;
|
|
42
|
+
try {
|
|
43
|
+
storage = new FileClawbotStorage(clawbotStorageDirectory(ctx));
|
|
44
|
+
const runtime = new ClawbotRuntime(storage, new DshClawbotHost(ctx));
|
|
45
|
+
return {
|
|
46
|
+
runtime,
|
|
47
|
+
error: null,
|
|
48
|
+
async control(request, signal = new AbortController().signal) {
|
|
49
|
+
try {
|
|
50
|
+
return { ok: true, value: await runtime.control(request, signal) };
|
|
51
|
+
} catch (cause) {
|
|
52
|
+
signal.throwIfAborted();
|
|
53
|
+
const known = cause instanceof ClawbotError || cause instanceof ILinkError;
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: known
|
|
57
|
+
? { code: cause.code, message: cause.message }
|
|
58
|
+
: { code: 'clawbot-operation-failed', message: '操作未完成,请刷新核对当前状态后再试。' },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
dispose: () => runtime.dispose(),
|
|
63
|
+
};
|
|
64
|
+
} catch (error) {
|
|
65
|
+
try { storage?.close(); } catch { /* Preserve the original startup error. */ }
|
|
66
|
+
const locked = /another|另一个|管理/u.test(String(error?.message || ''));
|
|
67
|
+
return {
|
|
68
|
+
runtime: null,
|
|
69
|
+
async control() {
|
|
70
|
+
return { ok: false, error: this.error };
|
|
71
|
+
},
|
|
72
|
+
error: {
|
|
73
|
+
code: locked ? 'clawbot-owner-conflict' : 'clawbot-startup-failed',
|
|
74
|
+
message: locked
|
|
75
|
+
? 'ClawBot 本地状态仍由旧服务占用。请先停止旧的私聊接线服务并重启 DSH,再由小程序网关接管。'
|
|
76
|
+
: 'ClawBot 本地状态无法安全读取,原记录未覆盖。请在电脑端核对后重启 DSH。',
|
|
77
|
+
},
|
|
78
|
+
dispose: async () => {},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { writePrivateJsonAtomic } from './secure-file.mjs';
|
|
5
|
+
import { validateMedia, validatePreparedMedia, MEDIA_MAX_ITEMS } from './ilink-media.mjs';
|
|
6
|
+
import { ClawbotError } from './contracts.mjs';
|
|
7
|
+
import { REPLY_TEXT_MAX_BYTES, REPLY_MAX_TEXT_PARTS, REPLY_TOTAL_TEXT_MAX_BYTES, TEXT_PART_MAX_BYTES } from './reply-text.mjs';
|
|
8
|
+
export const emptyClawbotData = () => ({ version: 1, revision: 0, enabled: false, credentials: null, target: null, cursor: '', receipts: [] });
|
|
9
|
+
export function validateClawbotData(data) {
|
|
10
|
+
const validTarget = (target) => target === null || target && typeof target.id === 'string'
|
|
11
|
+
&& target.id.length > 0 && target.id.length <= 256 && typeof target.title === 'string' && target.title.length <= 500
|
|
12
|
+
&& (target.generation === undefined || Number.isSafeInteger(target.generation))
|
|
13
|
+
&& (target.workspaceId === undefined || typeof target.workspaceId === 'string' && target.workspaceId.length <= 256)
|
|
14
|
+
&& (target.workspaceTitle === undefined || typeof target.workspaceTitle === 'string' && target.workspaceTitle.length <= 200);
|
|
15
|
+
if (data.version !== 1 || !Number.isSafeInteger(data.revision) || data.revision < 0 || typeof data.enabled !== 'boolean'
|
|
16
|
+
|| data.accessBinding !== undefined && (!data.accessBinding || ![data.accessBinding.nodeId, data.accessBinding.accountInstance]
|
|
17
|
+
.every(value => typeof value === 'string' && value.length > 0 && value.length <= 256))
|
|
18
|
+
|| data.authExpired !== undefined && typeof data.authExpired !== 'boolean'
|
|
19
|
+
|| typeof data.cursor !== 'string' || data.cursor.length > 65536 || !validTarget(data.target)
|
|
20
|
+
|| !Array.isArray(data.receipts) || data.receipts.length > 1000 || new Set(data.receipts.map(r => r?.id)).size !== data.receipts.length
|
|
21
|
+
|| data.receipts.some(r => !r || typeof r.id !== 'string' || !/^[a-f0-9]{64}$/.test(r.id) || typeof r.userId !== 'string'
|
|
22
|
+
|| typeof r.contextToken !== 'string' || typeof r.input !== 'string' || r.input.length > 8000 || !validTarget(r.target)
|
|
23
|
+
|| r.media !== undefined && (!Array.isArray(r.media) || !r.media.length || r.media.length > MEDIA_MAX_ITEMS || !r.media.every(validateMedia))
|
|
24
|
+
|| !Number.isSafeInteger(r.createdAt) || !Number.isSafeInteger(r.revision)
|
|
25
|
+
|| r.parentId !== undefined && (typeof r.parentId !== 'string' || !/^[a-f0-9]{64}$/.test(r.parentId) || r.parentId === r.id)
|
|
26
|
+
|| !['pending', 'running', 'ready', 'sending', 'done', 'uncertain'].includes(r.state)
|
|
27
|
+
|| r.output !== undefined && (typeof r.output !== 'string' || Buffer.byteLength(r.output) > REPLY_TEXT_MAX_BYTES)
|
|
28
|
+
|| r.parts !== undefined && (!Array.isArray(r.parts) || !r.parts.length || r.parts.length > REPLY_MAX_TEXT_PARTS + MEDIA_MAX_ITEMS
|
|
29
|
+
|| r.parts.some(p => typeof p === 'string' ? !p || Buffer.byteLength(p) > TEXT_PART_MAX_BYTES
|
|
30
|
+
: !validatePreparedMedia(p) || p.userId !== r.userId || p.accountId !== data.credentials?.accountId)
|
|
31
|
+
|| r.parts.reduce((bytes, p) => bytes + (typeof p === 'string' ? Buffer.byteLength(p) : 0), 0) > REPLY_TOTAL_TEXT_MAX_BYTES
|
|
32
|
+
|| r.parts.filter(p => typeof p !== 'string').length > MEDIA_MAX_ITEMS
|
|
33
|
+
|| !Number.isSafeInteger(r.sentParts) || r.sentParts < 0 || r.sentParts >= r.parts.length)
|
|
34
|
+
|| !['done', 'uncertain'].includes(r.state) && r.parts === undefined && r.sentParts !== undefined && r.sentParts !== 0
|
|
35
|
+
|| r.state === 'ready' && r.parts === undefined && (typeof r.output !== 'string' || !r.output)))
|
|
36
|
+
throw new ClawbotError('clawbot-storage-invalid', 'ClawBot 本地记录格式无效,未重置或覆盖。');
|
|
37
|
+
if (data.credentials && ![data.credentials.token, data.credentials.accountId, data.credentials.userId, data.credentials.baseUrl]
|
|
38
|
+
.every(value => typeof value === 'string' && value.length > 0 && value.length <= 8192))
|
|
39
|
+
throw new ClawbotError('clawbot-storage-invalid', 'ClawBot 登录记录不完整,请核对本地文件。');
|
|
40
|
+
if (Buffer.byteLength(JSON.stringify(data)) > 12 * 1024 * 1024)
|
|
41
|
+
throw new ClawbotError('clawbot-capacity', 'ClawBot 本地记录达到空间上限;未丢弃或覆盖待处理消息。');
|
|
42
|
+
}
|
|
43
|
+
/** One owner per native profile. Exact generation filenames make crashed-owner
|
|
44
|
+
* recovery safe against two contenders; no host process is stopped. */
|
|
45
|
+
export class FileClawbotStorage {
|
|
46
|
+
file;
|
|
47
|
+
lock;
|
|
48
|
+
owner;
|
|
49
|
+
closed = false;
|
|
50
|
+
constructor(directory) {
|
|
51
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
52
|
+
this.file = path.join(directory, 'state.json');
|
|
53
|
+
this.lock = path.join(directory, '.owner');
|
|
54
|
+
this.owner = `owner-${process.pid}-${randomUUID()}.json`;
|
|
55
|
+
const staging = path.join(directory, `.pending-${randomUUID()}`);
|
|
56
|
+
mkdirSync(staging, { mode: 0o700 });
|
|
57
|
+
writePrivateJsonAtomic(path.join(staging, this.owner), { pid: process.pid });
|
|
58
|
+
try {
|
|
59
|
+
if (existsSync(this.lock)) {
|
|
60
|
+
const entries = readdirSync(this.lock);
|
|
61
|
+
if (entries.length !== 1 || !/^owner-\d+-[a-f0-9-]+\.json$/.test(entries[0]))
|
|
62
|
+
throw new Error('ClawBot 状态正在被另一个实例使用');
|
|
63
|
+
const previous = path.join(this.lock, entries[0]);
|
|
64
|
+
const { pid } = JSON.parse(readFileSync(previous, 'utf8'));
|
|
65
|
+
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
66
|
+
throw new Error('ClawBot 本地锁需要核对');
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 0);
|
|
69
|
+
throw new Error('ClawBot 已由此节点的另一个实例管理');
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error.code !== 'ESRCH')
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
rmSync(previous);
|
|
76
|
+
rmdirSync(this.lock);
|
|
77
|
+
}
|
|
78
|
+
renameSync(staging, this.lock);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
rmSync(path.join(staging, this.owner), { force: true });
|
|
82
|
+
rmdirSync(staging);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
read() {
|
|
87
|
+
if (!existsSync(this.file))
|
|
88
|
+
return emptyClawbotData();
|
|
89
|
+
if (statSync(this.file).size > 16 * 1024 * 1024)
|
|
90
|
+
throw new Error('ClawBot 本地记录超过上限,未覆盖原记录');
|
|
91
|
+
const data = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
92
|
+
validateClawbotData(data);
|
|
93
|
+
return data;
|
|
94
|
+
}
|
|
95
|
+
write(data) {
|
|
96
|
+
if (this.closed)
|
|
97
|
+
throw new Error('ClawBot 组件已停止');
|
|
98
|
+
validateClawbotData(data);
|
|
99
|
+
writePrivateJsonAtomic(this.file, data);
|
|
100
|
+
}
|
|
101
|
+
close() {
|
|
102
|
+
if (this.closed)
|
|
103
|
+
return;
|
|
104
|
+
this.closed = true;
|
|
105
|
+
rmSync(path.join(this.lock, this.owner));
|
|
106
|
+
rmdirSync(this.lock);
|
|
107
|
+
}
|
|
108
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -578,7 +578,13 @@ window.__ModuleLoader__.load({
|
|
|
578
578
|
setUpdateInfo({ ...result, currentVersion: result.latestVersion, updateAvailable: false })
|
|
579
579
|
setUpdateNotice(`已安装 v${result.latestVersion},需要重启 DSH 后生效。`)
|
|
580
580
|
} else if (application === 'failed' || application === 'cancelled' || application === 'refused') {
|
|
581
|
-
|
|
581
|
+
const installError = result.installation?.error
|
|
582
|
+
const detail = result.installation?.errorMessage
|
|
583
|
+
|| (typeof installError === 'string' ? installError : '')
|
|
584
|
+
|| (typeof installError?.message === 'string' ? installError.message : '')
|
|
585
|
+
|| (typeof installError?.code === 'string' ? installError.code : '')
|
|
586
|
+
|| '请在 DSH 插件管理中查看安装详情。'
|
|
587
|
+
setUpdateNotice(`安装未完成:${application}(${detail})`)
|
|
582
588
|
} else {
|
|
583
589
|
setUpdateInfo({ ...result, currentVersion: result.latestVersion, updateAvailable: false })
|
|
584
590
|
setUpdateNotice(`已安装 v${result.latestVersion}。如界面尚未更新,请重启 DSH。`)
|
|
@@ -7,6 +7,46 @@ function eventTurnKey(event) {
|
|
|
7
7
|
return null
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
function finiteCount(...values) {
|
|
11
|
+
for (const value of values) {
|
|
12
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
|
13
|
+
}
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function addUsage(target, usage) {
|
|
18
|
+
if (!target || !usage || typeof usage !== 'object') return
|
|
19
|
+
const input = finiteCount(usage.inputTokens, usage.promptTokens)
|
|
20
|
+
const uncachedInput = finiteCount(usage.uncachedInputTokens, usage.uncached_input_tokens)
|
|
21
|
+
const output = finiteCount(usage.outputTokens, usage.completionTokens)
|
|
22
|
+
const total = finiteCount(usage.totalTokens)
|
|
23
|
+
const cacheRead = finiteCount(usage.cacheReadTokens, usage.cachedInputTokens, usage.cacheReadInputTokens)
|
|
24
|
+
if (input !== null) { target.inputTokens += input; target.hasInputTokens = true }
|
|
25
|
+
if (uncachedInput !== null) { target.uncachedInputTokens += uncachedInput; target.hasUncachedInputTokens = true }
|
|
26
|
+
if (output !== null) { target.outputTokens += output; target.hasOutputTokens = true }
|
|
27
|
+
if (total !== null) { target.totalTokens += total; target.hasTotalTokens = true }
|
|
28
|
+
if (cacheRead !== null) { target.cacheReadTokens += cacheRead; target.hasCacheReadTokens = true }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function turnMetrics(state, key, endedAt) {
|
|
32
|
+
const usage = state.turnUsage.get(key)
|
|
33
|
+
const start = state.turnStartedAt.get(key)
|
|
34
|
+
const result = {}
|
|
35
|
+
if (state.taskName) result.taskName = state.taskName
|
|
36
|
+
if (start !== undefined && Number.isFinite(endedAt) && endedAt >= start) result.durationMs = endedAt - start
|
|
37
|
+
if (usage && (usage.hasTotalTokens || usage.hasInputTokens || usage.hasOutputTokens)) {
|
|
38
|
+
result.tokens = usage.hasTotalTokens ? usage.totalTokens : usage.inputTokens + usage.outputTokens
|
|
39
|
+
} else if (usage?.hasUncachedInputTokens || usage?.hasCacheReadTokens || usage?.hasOutputTokens) {
|
|
40
|
+
result.tokens = usage.uncachedInputTokens + usage.cacheReadTokens + usage.outputTokens
|
|
41
|
+
}
|
|
42
|
+
if (usage?.hasCacheReadTokens && (usage.hasUncachedInputTokens || usage.hasInputTokens)) {
|
|
43
|
+
const inputBase = usage.hasUncachedInputTokens ? usage.uncachedInputTokens : usage.inputTokens
|
|
44
|
+
const denominator = inputBase + usage.cacheReadTokens
|
|
45
|
+
if (denominator > 0) result.cacheHitRate = Math.min(1, usage.cacheReadTokens / denominator)
|
|
46
|
+
}
|
|
47
|
+
return result
|
|
48
|
+
}
|
|
49
|
+
|
|
10
50
|
function completionUrl(baseUrl) {
|
|
11
51
|
if (!baseUrl) return null
|
|
12
52
|
let url
|
|
@@ -26,7 +66,10 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
|
|
|
26
66
|
function sessionState(sessionId, create = false) {
|
|
27
67
|
let value = sessions.get(sessionId)
|
|
28
68
|
if (!value && create) {
|
|
29
|
-
value = {
|
|
69
|
+
value = {
|
|
70
|
+
pending: [], active: new Map(), currentTurn: null, currentTurnAt: null,
|
|
71
|
+
taskName: null, taskNameAt: null, turnStartedAt: new Map(), turnUsage: new Map(),
|
|
72
|
+
}
|
|
30
73
|
sessions.set(sessionId, value)
|
|
31
74
|
}
|
|
32
75
|
return value
|
|
@@ -45,7 +88,17 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
|
|
|
45
88
|
if (live.length) state.active.set(key, live)
|
|
46
89
|
else state.active.delete(key)
|
|
47
90
|
}
|
|
48
|
-
if (
|
|
91
|
+
if (state.taskNameAt !== null && state.taskNameAt <= cutoff) {
|
|
92
|
+
state.taskName = null
|
|
93
|
+
state.taskNameAt = null
|
|
94
|
+
}
|
|
95
|
+
for (const [key, startedAt] of state.turnStartedAt) {
|
|
96
|
+
if (startedAt <= cutoff) {
|
|
97
|
+
state.turnStartedAt.delete(key)
|
|
98
|
+
state.turnUsage.delete(key)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!state.pending.length && !state.active.size && state.currentTurn === null && state.taskNameAt === null) sessions.delete(sessionId)
|
|
49
102
|
}
|
|
50
103
|
}
|
|
51
104
|
|
|
@@ -75,10 +128,10 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
|
|
|
75
128
|
if (remaining.length) state.active.set(key, remaining)
|
|
76
129
|
else state.active.delete(key)
|
|
77
130
|
}
|
|
78
|
-
if (!state.pending.length && !state.active.size && state.currentTurn === null) sessions.delete(sessionId)
|
|
131
|
+
if (!state.pending.length && !state.active.size && state.currentTurn === null && state.taskNameAt === null) sessions.delete(sessionId)
|
|
79
132
|
}
|
|
80
133
|
|
|
81
|
-
async function deliver(sessionId, records) {
|
|
134
|
+
async function deliver(sessionId, records, metrics) {
|
|
82
135
|
if (!endpoint || !records.length) return
|
|
83
136
|
for (const record of records) {
|
|
84
137
|
let response
|
|
@@ -87,7 +140,7 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
|
|
|
87
140
|
response = await fetchImpl(endpoint, {
|
|
88
141
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
|
|
89
142
|
headers: { 'Content-Type': 'application/json' },
|
|
90
|
-
body: JSON.stringify({ ticket: record.ticket, sessionId, status: 'completed' }),
|
|
143
|
+
body: JSON.stringify({ ticket: record.ticket, sessionId, status: 'completed', ...metrics }),
|
|
91
144
|
})
|
|
92
145
|
if (response.ok) break
|
|
93
146
|
} catch (_) { response = null }
|
|
@@ -100,12 +153,27 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
|
|
|
100
153
|
function onSessionEvent(sessionId, event) {
|
|
101
154
|
if (!endpoint || typeof sessionId !== 'string' || !event) return
|
|
102
155
|
prune()
|
|
156
|
+
if (event.type === 'session/title') {
|
|
157
|
+
const title = typeof event.data?.title === 'string' ? event.data.title.trim().slice(0, 200) : ''
|
|
158
|
+
if (title) {
|
|
159
|
+
const state = sessionState(sessionId, true)
|
|
160
|
+
state.taskName = title
|
|
161
|
+
state.taskNameAt = now()
|
|
162
|
+
}
|
|
163
|
+
return
|
|
164
|
+
}
|
|
103
165
|
if (event.type === 'turn/start') {
|
|
104
166
|
const state = sessionState(sessionId, true)
|
|
105
167
|
const key = eventTurnKey(event) || (Number.isSafeInteger(event.seq) ? `seq:${event.seq}` : null)
|
|
106
168
|
if (key === null) return
|
|
107
169
|
state.currentTurn = key
|
|
108
170
|
state.currentTurnAt = now()
|
|
171
|
+
state.turnStartedAt.set(key, state.currentTurnAt)
|
|
172
|
+
state.turnUsage.set(key, {
|
|
173
|
+
inputTokens: 0, uncachedInputTokens: 0, outputTokens: 0, totalTokens: 0, cacheReadTokens: 0,
|
|
174
|
+
hasInputTokens: false, hasUncachedInputTokens: false, hasOutputTokens: false,
|
|
175
|
+
hasTotalTokens: false, hasCacheReadTokens: false,
|
|
176
|
+
})
|
|
109
177
|
if (!state.active.has(key)) state.active.set(key, [])
|
|
110
178
|
const next = state.pending.shift()
|
|
111
179
|
if (next) {
|
|
@@ -114,6 +182,12 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
|
|
|
114
182
|
}
|
|
115
183
|
return
|
|
116
184
|
}
|
|
185
|
+
if (event.type === 'assistant/message') {
|
|
186
|
+
const state = sessionState(sessionId)
|
|
187
|
+
const key = eventTurnKey(event) || state?.currentTurn
|
|
188
|
+
if (state && key !== null && key !== undefined) addUsage(state.turnUsage.get(key), event.data?.usage)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
117
191
|
if (event.type !== 'turn/end') return
|
|
118
192
|
const state = sessionState(sessionId)
|
|
119
193
|
if (!state) return
|
|
@@ -121,10 +195,15 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
|
|
|
121
195
|
// absent on the end event, close the turn already tracked as current.
|
|
122
196
|
const key = eventTurnKey(event) || state.currentTurn
|
|
123
197
|
const records = key === null ? [] : state.active.get(key) || []
|
|
198
|
+
const metrics = key === null ? {} : turnMetrics(state, key, now())
|
|
124
199
|
if (key !== null) state.active.delete(key)
|
|
200
|
+
if (key !== null) {
|
|
201
|
+
state.turnStartedAt.delete(key)
|
|
202
|
+
state.turnUsage.delete(key)
|
|
203
|
+
}
|
|
125
204
|
if (state.currentTurn === key) { state.currentTurn = null; state.currentTurnAt = null }
|
|
126
|
-
void deliver(sessionId, records).catch(() => log(`completion notification delivery failed for session ${sessionId}`))
|
|
127
|
-
if (!state.pending.length && !state.active.size && state.currentTurn === null) sessions.delete(sessionId)
|
|
205
|
+
void deliver(sessionId, records, metrics).catch(() => log(`completion notification delivery failed for session ${sessionId}`))
|
|
206
|
+
if (!state.pending.length && !state.active.size && state.currentTurn === null && state.taskNameAt === null) sessions.delete(sessionId)
|
|
128
207
|
}
|
|
129
208
|
|
|
130
209
|
return { register, discard, onSessionEvent, clear() { sessions.clear() } }
|
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -325,12 +325,6 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
325
325
|
permissionPresets: {
|
|
326
326
|
catalog: (_payload = {}, signal) => invoke('permissionPresets', 'catalog', {}, signal),
|
|
327
327
|
},
|
|
328
|
-
// This endpoint intentionally forwards only the ClawBot control DTO. It
|
|
329
|
-
// stays behind the paired gateway and the ClawBot service's own access
|
|
330
|
-
// checks; it never exposes the channel credentials to the client.
|
|
331
|
-
clawbot: {
|
|
332
|
-
control: (request, signal) => invoke('wechatClawbot', 'control', request, signal),
|
|
333
|
-
},
|
|
334
328
|
schedule: {
|
|
335
329
|
async catalog(_payload = {}, signal) {
|
|
336
330
|
return { items: requireArray(await invoke('schedule', 'catalog', {}, signal), 'schedule/catalog') }
|
package/lib/index.mjs
CHANGED
|
@@ -144,6 +144,7 @@ import { createCompletionNotifications } from './completion-notifications.mjs'
|
|
|
144
144
|
import { gatewayProbeHeader } from './gateway-probe.mjs'
|
|
145
145
|
import { checkPluginUpdate, installLatestPlugin } from './plugin-updates.mjs'
|
|
146
146
|
import { webRestartAvailable, prepareWebRestart } from './gateway-tools.mjs'
|
|
147
|
+
import { createClawbotService } from './clawbot/service.mjs'
|
|
147
148
|
import QRCode from 'qrcode'
|
|
148
149
|
|
|
149
150
|
const { createRegistry } = devicesModule
|
|
@@ -1378,15 +1379,15 @@ function validClawbotId(value) {
|
|
|
1378
1379
|
&& !/[\u0000-\u001f]/.test(value)
|
|
1379
1380
|
}
|
|
1380
1381
|
|
|
1381
|
-
async function handleClawbotControl(
|
|
1382
|
+
async function handleClawbotControl(clawbotService, msg) {
|
|
1382
1383
|
const type = 'clawbot-control'
|
|
1383
1384
|
const requestId = typeof msg.requestId === 'string' && msg.requestId.trim().length > 0
|
|
1384
1385
|
&& msg.requestId.length <= 128 ? msg.requestId.trim() : null
|
|
1385
1386
|
const error = (code, message) => ({ kind: 'error', code, message, requestType: type, ...(requestId ? { requestId } : {}) })
|
|
1386
1387
|
if (!requestId) return error('bad-request', 'ClawBot control requires a requestId')
|
|
1387
1388
|
if (!CLAWBOT_ACTIONS.has(msg.action)) return error('bad-request', 'Unsupported ClawBot action')
|
|
1388
|
-
if (typeof
|
|
1389
|
-
return error('clawbot-unavailable', '
|
|
1389
|
+
if (typeof clawbotService?.control !== 'function') {
|
|
1390
|
+
return error('clawbot-unavailable', '网关内置的 ClawBot 服务尚未就绪,请检查网关状态后重试。')
|
|
1390
1391
|
}
|
|
1391
1392
|
|
|
1392
1393
|
const request = { action: msg.action }
|
|
@@ -1430,7 +1431,7 @@ async function handleClawbotControl(api, msg) {
|
|
|
1430
1431
|
}
|
|
1431
1432
|
|
|
1432
1433
|
try {
|
|
1433
|
-
const result = await
|
|
1434
|
+
const result = await clawbotService.control(request)
|
|
1434
1435
|
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
1435
1436
|
return error('clawbot-unavailable', 'ClawBot returned an invalid response')
|
|
1436
1437
|
}
|
|
@@ -1449,13 +1450,13 @@ async function handleClawbotControl(api, msg) {
|
|
|
1449
1450
|
} catch (cause) {
|
|
1450
1451
|
// Do not expose arbitrary host/plugin exception details over the mobile API.
|
|
1451
1452
|
log(`ClawBot control unavailable: ${cause?.code || 'unknown error'}`)
|
|
1452
|
-
return error('clawbot-unavailable', 'ClawBot
|
|
1453
|
+
return error('clawbot-unavailable', 'ClawBot 接入暂不可用,请检查网关本地服务状态并刷新重试。')
|
|
1453
1454
|
}
|
|
1454
1455
|
}
|
|
1455
1456
|
|
|
1456
1457
|
// Dispatch one mobile query frame; returns the wire frame to send back.
|
|
1457
1458
|
async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
|
|
1458
|
-
if (msg.type === 'clawbot-control') return handleClawbotControl(
|
|
1459
|
+
if (msg.type === 'clawbot-control') return handleClawbotControl(pluginContext?.clawbotService, msg)
|
|
1459
1460
|
if (WORKSPACE_CHANGES_TYPES.has(msg.type)) return handleWorkspaceChanges(api, msg)
|
|
1460
1461
|
if (PLUGIN_REQUEST_TYPES.has(msg.type)) return handlePluginQuery(api, msg, pluginContext)
|
|
1461
1462
|
if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
|
|
@@ -2256,6 +2257,8 @@ const plugin = {
|
|
|
2256
2257
|
apply(ctx, config) {
|
|
2257
2258
|
const webServer = ctx.webServer
|
|
2258
2259
|
const api = createDshHostAdapter(ctx.typertGateway)
|
|
2260
|
+
const clawbotService = createClawbotService(ctx)
|
|
2261
|
+
ctx.effect(() => () => clawbotService.dispose())
|
|
2259
2262
|
// Optional lifecycle-bound service: old hosts retain all existing gateway features.
|
|
2260
2263
|
ctx.inject(['workspaceChanges'], changesCtx => {
|
|
2261
2264
|
api.workspaceChanges = changesCtx.workspaceChanges
|
|
@@ -3238,6 +3241,7 @@ const plugin = {
|
|
|
3238
3241
|
handleQuery(api, host, agentDefaultModel, msg, {
|
|
3239
3242
|
onInstallStart: (requestId) => pluginInstallOwners.set(requestId, ws),
|
|
3240
3243
|
onInstallEnd: (requestId) => pluginInstallOwners.delete(requestId),
|
|
3244
|
+
clawbotService,
|
|
3241
3245
|
}).then((frame) => {
|
|
3242
3246
|
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
3243
3247
|
})
|
|
@@ -3281,7 +3285,7 @@ const plugin = {
|
|
|
3281
3285
|
'images',
|
|
3282
3286
|
'session-create',
|
|
3283
3287
|
'session-agent-preset',
|
|
3284
|
-
'wechat-clawbot-control-
|
|
3288
|
+
'wechat-clawbot-control-v2',
|
|
3285
3289
|
'commands',
|
|
3286
3290
|
'tasks',
|
|
3287
3291
|
'goals',
|
package/lib/plugin-updates.mjs
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
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
3
|
|
|
4
|
+
function errorField(value) {
|
|
5
|
+
return typeof value === 'string' && value.trim() ? value.trim() : ''
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatPluginInstallError(error, depth = 0) {
|
|
9
|
+
if (typeof error === 'string') return error.trim().slice(0, 1000)
|
|
10
|
+
if (!error || typeof error !== 'object' || depth > 4) return ''
|
|
11
|
+
|
|
12
|
+
const code = errorField(error.code)
|
|
13
|
+
const message = errorField(error.message)
|
|
14
|
+
|| errorField(error.errorMessage)
|
|
15
|
+
|| errorField(error.detail)
|
|
16
|
+
|| errorField(error.reason)
|
|
17
|
+
|| errorField(error.description)
|
|
18
|
+
if (message) return `${code && code !== message ? `${code}: ` : ''}${message}`.slice(0, 1000)
|
|
19
|
+
|
|
20
|
+
for (const nested of [error.error, error.cause, error.details]) {
|
|
21
|
+
const detail = formatPluginInstallError(nested, depth + 1)
|
|
22
|
+
if (detail) return `${code && code !== detail ? `${code}: ` : ''}${detail}`.slice(0, 1000)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const serialized = JSON.stringify(error, (key, value) => {
|
|
27
|
+
if (/stack|authorization|cookie|password|secret|token|credential|api.?key/i.test(key)) return undefined
|
|
28
|
+
return value
|
|
29
|
+
})
|
|
30
|
+
if (serialized && serialized !== '{}') return serialized.slice(0, 1000)
|
|
31
|
+
} catch { /* use a generic message for cyclic or non-serializable errors */ }
|
|
32
|
+
return code || 'The plugin manager did not provide an error message'
|
|
33
|
+
}
|
|
34
|
+
|
|
4
35
|
function parseVersion(value) {
|
|
5
36
|
if (typeof value !== 'string') return null
|
|
6
37
|
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value)
|
|
@@ -85,5 +116,12 @@ export async function installLatestPlugin({ api, currentVersion, fetchImpl = glo
|
|
|
85
116
|
`${PLUGIN_PACKAGE_NAME}@${update.latestVersion}`,
|
|
86
117
|
{ requestId },
|
|
87
118
|
)
|
|
88
|
-
|
|
119
|
+
const errorMessage = formatPluginInstallError(installation?.error)
|
|
120
|
+
return {
|
|
121
|
+
...update,
|
|
122
|
+
installed: true,
|
|
123
|
+
installation: errorMessage && installation && typeof installation === 'object'
|
|
124
|
+
? { ...installation, errorMessage }
|
|
125
|
+
: installation,
|
|
126
|
+
}
|
|
89
127
|
}
|