@wenbin_wb/dsh-bridge 2.8.5 → 2.8.6
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/CHANGELOG.md +19 -0
- package/client/client.js +302 -32
- package/client/index.js +319 -39
- package/docs/fix-tunnel-sse-and-session-list.md +134 -0
- package/lib/cloudflared-manager.mjs +75 -12
- package/lib/index.js +65 -9
- package/lib/platform/conversation-bridge.js +153 -128
- package/lib/tunnel-client.mjs +402 -289
- package/package.json +2 -2
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
-
import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
|
1
|
+
import { spawn, execSync } from 'node:child_process';
|
|
2
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
5
3
|
import { chmod, stat, unlink, rename } from 'node:fs/promises';
|
|
6
4
|
import { homedir, platform, arch } from 'node:os';
|
|
7
5
|
import { join } from 'node:path';
|
|
@@ -73,6 +71,27 @@ async function downloadFile(url, dest, onProgress) {
|
|
|
73
71
|
});
|
|
74
72
|
}
|
|
75
73
|
|
|
74
|
+
function findSystemCloudflared() {
|
|
75
|
+
const isWin = platform() === 'win32';
|
|
76
|
+
const candidates = [];
|
|
77
|
+
if (isWin) {
|
|
78
|
+
candidates.push('cloudflared.exe', 'cloudflared', 'C:\\Program Files (x86)\\cloudflared\\cloudflared.exe', 'C:\\Program Files\\cloudflared\\cloudflared.exe');
|
|
79
|
+
} else {
|
|
80
|
+
candidates.push('cloudflared', '/opt/homebrew/bin/cloudflared', '/usr/local/bin/cloudflared', '/usr/bin/cloudflared', '/bin/cloudflared');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const bin of candidates) {
|
|
84
|
+
try {
|
|
85
|
+
if (bin.includes('/') || bin.includes('\\')) {
|
|
86
|
+
if (!existsSync(bin)) continue;
|
|
87
|
+
}
|
|
88
|
+
execSync(`"${bin}" --version`, { stdio: 'ignore', timeout: 3000 });
|
|
89
|
+
return bin;
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
export class CloudflaredManager {
|
|
77
96
|
constructor({ port, home, token, hostname, onStateChange, logger }) {
|
|
78
97
|
this.port = port;
|
|
@@ -104,19 +123,47 @@ export class CloudflaredManager {
|
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
async _ensureBinary() {
|
|
126
|
+
// 1. 优先使用系统环境变量或 Homebrew / 包管理器已安装的全局二进制
|
|
127
|
+
const systemBin = findSystemCloudflared();
|
|
128
|
+
if (systemBin) {
|
|
129
|
+
this.binaryPath = systemBin;
|
|
130
|
+
this.logger?.info('优先使用系统全局 cloudflared: %s', systemBin);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
107
134
|
const { url, name } = getCloudflaredInfo();
|
|
108
135
|
const binDir = join(this.home, 'bin');
|
|
109
136
|
const binPath = join(binDir, name);
|
|
110
137
|
this.binaryPath = binPath;
|
|
111
138
|
|
|
139
|
+
// 2. 检查本地 ~/.dsh-bridge/bin/cloudflared 是否已存在且可用
|
|
112
140
|
if (existsSync(binPath)) {
|
|
113
141
|
try {
|
|
114
142
|
const s = await stat(binPath);
|
|
115
143
|
if (s.size > MIN_BINARY_SIZE) { // >5MB 才视为有效二进制
|
|
116
|
-
|
|
117
|
-
|
|
144
|
+
// 检查是否为历史残留未解压的 gzip 压缩包 (0x1f 0x8b)
|
|
145
|
+
const fd = readFileSync(binPath);
|
|
146
|
+
const isGzip = fd.length >= 2 && fd[0] === 0x1f && fd[1] === 0x8b;
|
|
147
|
+
if (isGzip) {
|
|
148
|
+
this.logger?.warn('检测到历史残留的未解压 cloudflared.tgz 压缩包,正在清理重新准备...');
|
|
149
|
+
await unlink(binPath).catch(() => {});
|
|
150
|
+
} else {
|
|
151
|
+
// macOS / Linux 赋予可执行权限并清除 Gatekeeper 隔离属性
|
|
152
|
+
if (platform() !== 'win32') {
|
|
153
|
+
await chmod(binPath, 0o755).catch(() => {});
|
|
154
|
+
if (platform() === 'darwin') {
|
|
155
|
+
try { execSync(`xattr -d com.apple.quarantine "${binPath}"`, { stdio: 'ignore' }); } catch {}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// 执行一次 --version 验证是否能正常 spawn
|
|
159
|
+
execSync(`"${binPath}" --version`, { stdio: 'ignore', timeout: 3000 });
|
|
160
|
+
this.logger?.info('cloudflared 已存在且验证通过: %s', binPath);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
118
163
|
}
|
|
119
|
-
} catch {
|
|
164
|
+
} catch (verifyErr) {
|
|
165
|
+
this.logger?.warn('现有 cloudflared 二进制验证失败 (%s),准备重新下载', verifyErr.message);
|
|
166
|
+
}
|
|
120
167
|
// 损坏文件,删掉重下
|
|
121
168
|
await unlink(binPath).catch(() => {});
|
|
122
169
|
}
|
|
@@ -135,16 +182,32 @@ export class CloudflaredManager {
|
|
|
135
182
|
this._setState('downloading', `下载 cloudflared: ${mb}/${totalMb} MB (${percent}%)`);
|
|
136
183
|
});
|
|
137
184
|
|
|
138
|
-
if (
|
|
139
|
-
|
|
185
|
+
if (url.endsWith('.tgz') || url.endsWith('.tar.gz')) {
|
|
186
|
+
try {
|
|
187
|
+
execSync(`tar -xzf "${tempPath}" -C "${binDir}"`);
|
|
188
|
+
await unlink(tempPath).catch(() => {});
|
|
189
|
+
} catch (tarErr) {
|
|
190
|
+
this.logger?.error('解压 cloudflared 压缩包失败: %s', tarErr.message);
|
|
191
|
+
throw new Error(`解压 cloudflared 失败: ${tarErr.message}`);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
if (existsSync(binPath)) await unlink(binPath).catch(() => {});
|
|
195
|
+
await rename(tempPath, binPath);
|
|
196
|
+
}
|
|
140
197
|
|
|
141
198
|
if (platform() !== 'win32') {
|
|
142
|
-
await chmod(binPath, 0o755);
|
|
199
|
+
await chmod(binPath, 0o755).catch(() => {});
|
|
200
|
+
if (platform() === 'darwin') {
|
|
201
|
+
try { execSync(`xattr -d com.apple.quarantine "${binPath}"`, { stdio: 'ignore' }); } catch {}
|
|
202
|
+
}
|
|
143
203
|
}
|
|
144
|
-
|
|
204
|
+
|
|
205
|
+
// 执行 --version 最终确认
|
|
206
|
+
execSync(`"${binPath}" --version`, { stdio: 'ignore', timeout: 3000 });
|
|
207
|
+
this.logger?.info('cloudflared 下载并准备完成');
|
|
145
208
|
} catch (err) {
|
|
146
209
|
await unlink(tempPath).catch(() => {});
|
|
147
|
-
throw new Error(
|
|
210
|
+
throw new Error(`准备 cloudflared 失败: ${err.message}`);
|
|
148
211
|
}
|
|
149
212
|
}
|
|
150
213
|
|
package/lib/index.js
CHANGED
|
@@ -10,9 +10,10 @@ import { get as httpsGet } from 'node:https';
|
|
|
10
10
|
import { networkInterfaces, homedir, totalmem, freemem, cpus, loadavg, platform, arch, release, hostname, uptime } from 'node:os';
|
|
11
11
|
import { join, dirname, basename, resolve, normalize } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
14
|
import { readFile, writeFile, mkdir, unlink, readdir, stat, access } from 'node:fs/promises';
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
16
17
|
import QRCode from 'qrcode';
|
|
17
18
|
import { installBridgeRpc } from './bridge-rpc.js';
|
|
18
19
|
import { CustomTunnelClient } from './tunnel-client.mjs';
|
|
@@ -42,23 +43,34 @@ function selectLanIPv4() {
|
|
|
42
43
|
let best = null;
|
|
43
44
|
let bestScore = -1;
|
|
44
45
|
|
|
46
|
+
const VIRTUAL_KEYWORDS = [
|
|
47
|
+
'vethernet', 'wsl', 'hyper-v', 'virtual', 'vmware', 'vbox', 'docker',
|
|
48
|
+
'tailscale', 'zerotier', 'tap', 'tun', 'utun', 'wireguard', 'loopback', 'bridge',
|
|
49
|
+
];
|
|
50
|
+
|
|
45
51
|
for (const [ifname, addrs] of Object.entries(interfaces)) {
|
|
46
52
|
if (!addrs) continue;
|
|
53
|
+
const lower = ifname.toLowerCase();
|
|
54
|
+
const isVirtual = VIRTUAL_KEYWORDS.some((kw) => lower.includes(kw));
|
|
47
55
|
|
|
48
56
|
for (const addr of addrs) {
|
|
49
57
|
if (addr.family !== 'IPv4' || addr.internal) continue;
|
|
50
58
|
|
|
51
59
|
let score = 0;
|
|
60
|
+
// 1. IP 网段优先(家庭/企业物理局域网最常用网段)
|
|
52
61
|
if (addr.address.startsWith('192.168.')) score += 100;
|
|
53
62
|
else if (addr.address.startsWith('10.')) score += 90;
|
|
54
|
-
else if (addr.address.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) score +=
|
|
63
|
+
else if (addr.address.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) score += 70;
|
|
64
|
+
else score += 10;
|
|
55
65
|
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
// 2. 物理网卡与名称特征优先
|
|
67
|
+
if (isVirtual) {
|
|
68
|
+
score -= 200; // 虚拟网卡大幅降权
|
|
69
|
+
} else {
|
|
70
|
+
score += 100;
|
|
71
|
+
if (lower.includes('wi-fi') || lower.includes('wlan') || lower.includes('wireless')) score += 50;
|
|
72
|
+
else if (lower.includes('ethernet') || lower.includes('以太网') || lower.includes('eth') || lower.includes('en')) score += 40;
|
|
73
|
+
}
|
|
62
74
|
|
|
63
75
|
if (score > bestScore) {
|
|
64
76
|
bestScore = score;
|
|
@@ -161,6 +173,37 @@ function isCompressed(headers) {
|
|
|
161
173
|
return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
|
|
162
174
|
}
|
|
163
175
|
|
|
176
|
+
function encodeBase64Url(value) {
|
|
177
|
+
return Buffer.from(value).toString('base64url');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 读取 DSH 本地凭证并生成 loopback dsh-auth 认证签名 Cookie (适配 DSH 新版原生认证)
|
|
182
|
+
*/
|
|
183
|
+
function getDshLoopbackCookie(targetPort) {
|
|
184
|
+
try {
|
|
185
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
186
|
+
const credPath = join(dshHome, '.credentials.yaml');
|
|
187
|
+
if (!existsSync(credPath)) return '';
|
|
188
|
+
const content = readFileSync(credPath, 'utf8');
|
|
189
|
+
const match = content.match(/secret:\s*([A-Za-z0-9_-]+)/);
|
|
190
|
+
if (!match) return '';
|
|
191
|
+
const secret = Buffer.from(match[1], 'base64url');
|
|
192
|
+
|
|
193
|
+
const authority = `127.0.0.1:${targetPort}`;
|
|
194
|
+
const name = 'dsh-auth-' + encodeBase64Url(createHash('sha256').update(authority).digest());
|
|
195
|
+
const issuedAt = Date.now() - 1000;
|
|
196
|
+
const expiresAt = issuedAt + 30 * 24 * 3600 * 1000;
|
|
197
|
+
const body = encodeBase64Url(Buffer.from(JSON.stringify({
|
|
198
|
+
version: 1, authority, issuedAt, expiresAt,
|
|
199
|
+
}), 'utf8'));
|
|
200
|
+
const sig = encodeBase64Url(createHmac('sha256', secret).update(body).digest());
|
|
201
|
+
return `${name}=v1.${body}.${sig}`;
|
|
202
|
+
} catch {
|
|
203
|
+
return '';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
164
207
|
/** 把请求头中的 Host 和 Origin 改写成 loopback,让 DSH 的安全栅栏放行 */
|
|
165
208
|
function loopbackHeaders(headers, targetPort) {
|
|
166
209
|
const authority = `127.0.0.1:${targetPort}`;
|
|
@@ -168,6 +211,19 @@ function loopbackHeaders(headers, targetPort) {
|
|
|
168
211
|
out['host'] = authority;
|
|
169
212
|
if (out['origin']) out['origin'] = `http://${authority}`;
|
|
170
213
|
if (out['Origin']) out['Origin'] = `http://${authority}`;
|
|
214
|
+
|
|
215
|
+
// 1. 注入 DSH 本地认证签名(若有)
|
|
216
|
+
const dshCookie = getDshLoopbackCookie(targetPort);
|
|
217
|
+
if (dshCookie) {
|
|
218
|
+
const existing = out['cookie'] || out['Cookie'] || '';
|
|
219
|
+
out['cookie'] = existing ? `${existing}; ${dshCookie}` : dshCookie;
|
|
220
|
+
delete out['Cookie'];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 2. 禁用内部代理流量压缩,确保代理层拿到未压缩 HTML 以稳定注入 ownsHost 和 Polyfill
|
|
224
|
+
delete out['accept-encoding'];
|
|
225
|
+
delete out['Accept-Encoding'];
|
|
226
|
+
|
|
171
227
|
return out;
|
|
172
228
|
}
|
|
173
229
|
|
|
@@ -1254,7 +1310,7 @@ class BridgeService {
|
|
|
1254
1310
|
*/
|
|
1255
1311
|
function apply(ctx, config = {}) {
|
|
1256
1312
|
const logger = ctx.logger(name);
|
|
1257
|
-
const dshPort = ctx.webServer.
|
|
1313
|
+
const dshPort = ctx.webServer?.port ?? config.targetPort ?? 3080;
|
|
1258
1314
|
|
|
1259
1315
|
if (!dshPort) {
|
|
1260
1316
|
logger.error('webServer port unavailable');
|
|
@@ -360,7 +360,9 @@ export class ConversationBridge {
|
|
|
360
360
|
// 使用 DSH 原生格式 session-${uuid},与 ctx.sessions 持久化系统兼容
|
|
361
361
|
const sessionId = `session-${randomUUID()}`
|
|
362
362
|
try {
|
|
363
|
-
const
|
|
363
|
+
const workspaces = await listWorkspaces(this)
|
|
364
|
+
const defaultWsPath = workspaces.length > 0 ? workspaces[0].path : process.cwd()
|
|
365
|
+
const cwd = cwdOverride || this.config.cwd || defaultWsPath
|
|
364
366
|
// 校验指定目录存在且是目录,防止路径遍历
|
|
365
367
|
if (cwdOverride) {
|
|
366
368
|
const validation = await validateWorkspacePath(this, cwdOverride)
|
|
@@ -388,13 +390,25 @@ export class ConversationBridge {
|
|
|
388
390
|
// 注意:不预创建 ctx.sessions——agents.create 会自己 prepare+enter session
|
|
389
391
|
const handle = await this.ctx.agents.create({ sessionId, meta, agentOptions })
|
|
390
392
|
this.setActiveSession(handle.agent.session)
|
|
393
|
+
|
|
394
|
+
// 同步挂载进 DSH 工作区账本 (workspaceRegistry),使新会话在 Web 侧边栏和 /list 中精准归组
|
|
395
|
+
try {
|
|
396
|
+
const reg = this.ctx.workspaceRegistry
|
|
397
|
+
const entities = reg?.list ? (await reg.list()) : []
|
|
398
|
+
const normCwd = normalize(cwd).toLowerCase()
|
|
399
|
+
const match = (entities || []).find((ws) => ws?.path && normalize(ws.path).toLowerCase() === normCwd)
|
|
400
|
+
if (match?.attachSession) await match.attachSession(sessionId)
|
|
401
|
+
} catch { /* 账本写入失败不影响会话创建 */ }
|
|
402
|
+
|
|
391
403
|
if (prompt) {
|
|
404
|
+
const platformName = this.platform?.name || 'IM客户端'
|
|
405
|
+
const promptWithContext = `${prompt}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${platformName}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
|
|
392
406
|
handle.agent.followup(createUserMessage({
|
|
393
|
-
content: [{ type: 'text', text:
|
|
407
|
+
content: [{ type: 'text', text: promptWithContext }],
|
|
394
408
|
source: { kind: 'user' },
|
|
395
409
|
}))
|
|
396
410
|
}
|
|
397
|
-
const wsDetail =
|
|
411
|
+
const wsDetail = cwd ? `\n- **工作区**:\`${cwd}\`` : ''
|
|
398
412
|
const hint = prompt ? '' : '\n\n> 💡 发送任意消息即可直接与 Agent 对话。'
|
|
399
413
|
await this.sendText(`✓ **已创建新会话**\n- **会话 ID**:\`${fmtSessionId(handle.agent.session.id)}\`${wsDetail}${hint}`)
|
|
400
414
|
} catch (error) {
|
|
@@ -489,146 +503,147 @@ export class ConversationBridge {
|
|
|
489
503
|
// 'ignored' 消息被忽略(未授权/群消息/空消息)
|
|
490
504
|
// 'routed' 消息已路由到 agent
|
|
491
505
|
async handleInbound({ senderId, text, isGroup = false }) {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
506
|
+
try {
|
|
507
|
+
// 等待配置恢复完成(防止启动时竞态)
|
|
508
|
+
if (this._restoringConfig) {
|
|
509
|
+
await this._restoringConfig
|
|
510
|
+
}
|
|
496
511
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
512
|
+
const sender = String(senderId ?? '').trim()
|
|
513
|
+
if (!sender) return 'ignored'
|
|
514
|
+
|
|
515
|
+
if (!this.isAllowed(sender)) {
|
|
516
|
+
// 自动授权:
|
|
517
|
+
// - 单聊:白名单为空时,首个发消息的真实用户自动纳入白名单
|
|
518
|
+
// - 群聊:首次 @机器人 的群自动纳入(群维度授权,群内成员均可使用)
|
|
519
|
+
// 这是"登录后第一条消息/首次被 @即完成授权"的一步到位体验。
|
|
520
|
+
const shouldAutoApprove = Boolean(text?.trim()) && (
|
|
521
|
+
this.config.allowFrom.length === 0 || // 白名单为空:单聊/群聊都自动授权
|
|
522
|
+
isGroup // 群聊:始终自动授权群
|
|
523
|
+
)
|
|
524
|
+
if (shouldAutoApprove) {
|
|
525
|
+
this.config.allowFrom = Array.from(new Set([...this.config.allowFrom, sender]))
|
|
526
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto-approved ${isGroup ? 'group' : 'sender'} ${sender} into allowlist`)
|
|
527
|
+
try {
|
|
528
|
+
await this.onFirstSender?.(sender)
|
|
529
|
+
} catch (err) {
|
|
530
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to persist first sender: ${err instanceof Error ? err.message : String(err)}`)
|
|
531
|
+
}
|
|
532
|
+
} else {
|
|
533
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] media-only first message from ${sender} not auto-approved (waiting for text)`)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 如果仍未通过白名单,拒绝处理(防止绕过白名单)
|
|
537
|
+
if (!this.isAllowed(sender)) {
|
|
538
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
|
|
539
|
+
return 'ignored'
|
|
516
540
|
}
|
|
517
|
-
} else {
|
|
518
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] media-only first message from ${sender} not auto-approved (waiting for text)`)
|
|
519
541
|
}
|
|
520
542
|
|
|
521
|
-
//
|
|
522
|
-
if (!this.
|
|
523
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore message from
|
|
543
|
+
// 仅在不支持群聊的平台忽略群消息(QQ 等支持群聊的平台放行)
|
|
544
|
+
if (isGroup && !this.platform?.capabilities?.supportsGroup) {
|
|
545
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore group message from ${sender} (no group support)`)
|
|
524
546
|
return 'ignored'
|
|
525
547
|
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// 仅在不支持群聊的平台忽略群消息(QQ 等支持群聊的平台放行)
|
|
529
|
-
if (isGroup && !this.platform?.capabilities?.supportsGroup) {
|
|
530
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore group message from ${sender} (no group support)`)
|
|
531
|
-
return 'ignored'
|
|
532
|
-
}
|
|
533
548
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
549
|
+
const fullText = text?.trim() ?? ''
|
|
550
|
+
if (!fullText) {
|
|
551
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore empty message from ${sender}`)
|
|
552
|
+
return 'ignored'
|
|
553
|
+
}
|
|
539
554
|
|
|
540
|
-
|
|
555
|
+
this.peerId = sender
|
|
541
556
|
|
|
542
|
-
|
|
557
|
+
if (await routeCommand(this, fullText)) return 'routed'
|
|
543
558
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
try {
|
|
549
|
-
await this._restoringSessionMap.get(sessionId)
|
|
550
|
-
} catch { /* 错误已在原始 Promise 中捕获 */ }
|
|
551
|
-
agent = this.activeAgent()
|
|
552
|
-
} else {
|
|
553
|
-
const restorePromise = (async () => {
|
|
554
|
-
// agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
|
|
559
|
+
let agent = this.activeAgent()
|
|
560
|
+
if (!agent && this.activeSessionId) {
|
|
561
|
+
const sessionId = this.activeSessionId
|
|
562
|
+
if (this._restoringSessionMap.has(sessionId)) {
|
|
555
563
|
try {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
if (def) {
|
|
563
|
-
if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
|
|
564
|
-
if (!agentOptions.model && def.model) agentOptions.model = def.model
|
|
565
|
-
}
|
|
566
|
-
} catch { /* ignore */ }
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// 判断该 session 是否已持久化:已持久化 → agents.resume(从持久化加载历史恢复,
|
|
570
|
-
// 避免 agents.create 用空 seed 与已持久化事件冲突);未持久化 → agents.create。
|
|
571
|
-
let persisted = false
|
|
564
|
+
await this._restoringSessionMap.get(sessionId)
|
|
565
|
+
} catch { /* 错误已在原始 Promise 中捕获 */ }
|
|
566
|
+
agent = this.activeAgent()
|
|
567
|
+
} else {
|
|
568
|
+
const restorePromise = (async () => {
|
|
569
|
+
// agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
|
|
572
570
|
try {
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
// 读取持久化会话的 cwd 做 fallback(新建会话时用)
|
|
585
|
-
let sessionCwd = this.config.cwd || process.cwd()
|
|
586
|
-
const meta = {
|
|
587
|
-
cwd: sessionCwd,
|
|
588
|
-
agentPreset: this.config.agentPreset || 'routing-suite',
|
|
571
|
+
const agentOptions = {}
|
|
572
|
+
if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
|
|
573
|
+
if (this.config.agentModel) agentOptions.model = this.config.agentModel
|
|
574
|
+
if (!agentOptions.provider || !agentOptions.model) {
|
|
575
|
+
try {
|
|
576
|
+
const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
|
|
577
|
+
if (def) {
|
|
578
|
+
if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
|
|
579
|
+
if (!agentOptions.model && def.model) agentOptions.model = def.model
|
|
580
|
+
}
|
|
581
|
+
} catch { /* ignore */ }
|
|
589
582
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
583
|
+
|
|
584
|
+
// 判断该 session 是否已持久化:已持久化 → agents.resume(从持久化加载历史恢复,
|
|
585
|
+
// 避免 agents.create 用空 seed 与已持久化事件冲突);未持久化 → agents.create。
|
|
586
|
+
let persisted = false
|
|
587
|
+
try {
|
|
588
|
+
const headers = await this.ctx.sessionPersistence?.list?.()
|
|
589
|
+
persisted = Array.isArray(headers) && headers.some((h) => h?.id === sessionId)
|
|
590
|
+
} catch { /* 读取失败则按未持久化处理 */ }
|
|
591
|
+
|
|
592
|
+
let handle
|
|
593
|
+
if (persisted) {
|
|
594
|
+
handle = await this.ctx.agents.resume({
|
|
595
|
+
resumeSessionId: sessionId,
|
|
596
|
+
agentOptions,
|
|
597
|
+
})
|
|
598
|
+
} else {
|
|
599
|
+
handle = await this.ctx.agents.create({
|
|
600
|
+
sessionId,
|
|
601
|
+
meta: { cwd: this.config.cwd || process.cwd() },
|
|
602
|
+
agentOptions,
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
const resumedAgent = handle?.agent
|
|
606
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] re-attached agent to session ${sessionId} (${persisted ? 'resume' : 'create'})`)
|
|
607
|
+
return resumedAgent
|
|
608
|
+
} catch (err) {
|
|
609
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
610
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to re-attach agent: ${reason}`)
|
|
611
|
+
// 清除失效的 activeSessionId,避免用户误以为还在旧会话中
|
|
612
|
+
if (this.activeSessionId === sessionId) {
|
|
613
|
+
this.activeSessionId = null
|
|
614
|
+
}
|
|
615
|
+
await this.sendText(`❌ **恢复会话失败**:${reason}\n\n> 发送 \`/new <提示词>\` 可新建一个会话。`)
|
|
616
|
+
return null
|
|
605
617
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
}
|
|
609
|
-
})().finally(() => {
|
|
610
|
-
this._restoringSessionMap.delete(sessionId)
|
|
611
|
-
})
|
|
618
|
+
})().finally(() => {
|
|
619
|
+
this._restoringSessionMap.delete(sessionId)
|
|
620
|
+
})
|
|
612
621
|
|
|
613
|
-
|
|
614
|
-
|
|
622
|
+
this._restoringSessionMap.set(sessionId, restorePromise)
|
|
623
|
+
agent = await restorePromise
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (!agent) {
|
|
627
|
+
await this.sendText(`> 💤 **当前没有活动会话**\n> 发送 \`/new <提示词>\` 开始新会话,或发送 \`/sessions\` 查看已有会话。`)
|
|
628
|
+
return 'routed'
|
|
615
629
|
}
|
|
616
|
-
}
|
|
617
|
-
if (!agent) {
|
|
618
|
-
await this.sendText(`> 💤 **当前没有活动会话**\n> 发送 \`/new <提示词>\` 开始新会话,或发送 \`/sessions\` 查看已有会话。`)
|
|
619
|
-
return 'routed'
|
|
620
|
-
}
|
|
621
630
|
|
|
622
|
-
|
|
623
|
-
|
|
631
|
+
// 针对微信/IM客户端用户,注入上下文提示,规范 Agent 仅在需要向用户发送文件附件时输出 [SEND_FILE: <文件绝对路径>]
|
|
632
|
+
const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
|
|
624
633
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
634
|
+
const messageValue = createUserMessage({
|
|
635
|
+
content: [{ type: 'text', text: promptWithContext }],
|
|
636
|
+
source: { kind: 'user' },
|
|
637
|
+
})
|
|
638
|
+
agent.followup(messageValue)
|
|
639
|
+
await this.sendTyping(1).catch(() => {})
|
|
640
|
+
return 'routed'
|
|
641
|
+
} catch (err) {
|
|
642
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
643
|
+
this.logger?.error?.(`[dsh-bridge ${this.platform.id}] unhandled error in handleInbound: ${errMsg}`)
|
|
644
|
+
await this.sendText(`❌ **执行出错**:${errMsg}`).catch(() => {})
|
|
645
|
+
return 'ignored'
|
|
646
|
+
}
|
|
632
647
|
}
|
|
633
648
|
|
|
634
649
|
// ---- 发送(子类必须实现)----
|
|
@@ -1027,6 +1042,15 @@ function isSubagentSession(cacheRow, liveSession) {
|
|
|
1027
1042
|
return false
|
|
1028
1043
|
}
|
|
1029
1044
|
|
|
1045
|
+
function formatGoalTitle(raw) {
|
|
1046
|
+
if (typeof raw === 'string' && raw) return raw
|
|
1047
|
+
if (raw && typeof raw === 'object') {
|
|
1048
|
+
const obj = raw.objective ?? raw.goal?.objective ?? raw.title
|
|
1049
|
+
if (typeof obj === 'string' && obj) return obj
|
|
1050
|
+
}
|
|
1051
|
+
return ''
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1030
1054
|
// 列出会话:严格对齐 DSH Web 端侧边栏会话树逻辑。
|
|
1031
1055
|
// 1. 过滤已归档会话 (archivedSessionIds)
|
|
1032
1056
|
// 2. 过滤未发起提问的空白会话 (blank: true)
|
|
@@ -1060,7 +1084,7 @@ async function listSessions(node) {
|
|
|
1060
1084
|
let title = s.title || (s.events ? foldTitle(s.events) : '')
|
|
1061
1085
|
if (!title) {
|
|
1062
1086
|
const cache = projCache[s.id]
|
|
1063
|
-
title = cache?.rows?.title?.val || cache?.rows?.goal?.val
|
|
1087
|
+
title = cache?.rows?.title?.val || formatGoalTitle(cache?.rows?.goal?.val)
|
|
1064
1088
|
}
|
|
1065
1089
|
result.push({
|
|
1066
1090
|
id: s.id,
|
|
@@ -1090,7 +1114,7 @@ async function listSessions(node) {
|
|
|
1090
1114
|
continue
|
|
1091
1115
|
}
|
|
1092
1116
|
|
|
1093
|
-
let title = cache?.rows?.title?.val || cache?.rows?.goal?.val
|
|
1117
|
+
let title = cache?.rows?.title?.val || formatGoalTitle(cache?.rows?.goal?.val)
|
|
1094
1118
|
let createdAt = cache?.identity?.createdAt || 0
|
|
1095
1119
|
let cwd = ws.path
|
|
1096
1120
|
|
|
@@ -1453,7 +1477,8 @@ async function renderSessions(node) {
|
|
|
1453
1477
|
const isActive = session.id === node.activeSessionId
|
|
1454
1478
|
const statusTag = isActive ? '`[当前]`' : '-'
|
|
1455
1479
|
const rawTitle = session.title || (session.events ? sessionLabel(session) : '')
|
|
1456
|
-
const
|
|
1480
|
+
const titleText = formatGoalTitle(rawTitle) || (typeof rawTitle === 'string' ? rawTitle : '') || '新会话'
|
|
1481
|
+
const safeTitle = String(titleText).replace(/\|/g, '|').replace(/\r?\n/g, ' ')
|
|
1457
1482
|
const when = session.createdAt ? fmtTime(session.createdAt) : '-'
|
|
1458
1483
|
parts.push(`| **#${idx}** | ${safeTitle} | ${when} | ${statusTag} |`)
|
|
1459
1484
|
}
|