@lanbaolu/dsh-wechat-bridge 0.1.0 → 0.2.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/cordis.patch.yml +1 -1
- package/docs/2026-08-18-wechat-bridge-panel-noop-review.md +46 -0
- package/docs/feasibility-plan.md +3 -3
- package/docs/screenshots/README.md +5 -0
- package/lib/bridge/commands/handlers.js +94 -0
- package/lib/bridge/commands/handlers.js.map +1 -1
- package/lib/bridge/commands/router.js +8 -2
- package/lib/bridge/commands/router.js.map +1 -1
- package/lib/bridge/dsh-client.js +37 -0
- package/lib/bridge/dsh-client.js.map +1 -1
- package/lib/bridge/main.js +5 -1
- package/lib/bridge/main.js.map +1 -1
- package/lib/client/Panel.js +79 -5
- package/lib/client/Panel.js.map +1 -1
- package/lib/client.js +143 -4
- package/lib/client.js.map +1 -1
- package/lib/index.js +455 -9
- package/lib/index.js.map +1 -1
- package/lib/types/bridge/commands/handlers.d.ts +2 -0
- package/lib/types/bridge/commands/router.d.ts +6 -1
- package/lib/types/bridge/dsh-client.d.ts +12 -0
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { createServer } from 'node:http';
|
|
13
13
|
import { randomBytes } from 'node:crypto';
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync, unlinkSync, chmodSync } from 'node:fs';
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync, statSync, unlinkSync, chmodSync } from 'node:fs';
|
|
16
16
|
import { join, dirname, resolve } from 'node:path';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import { homedir } from 'node:os';
|
|
@@ -21,6 +21,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
21
21
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
22
22
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
23
23
|
import { startQrLogin, checkQrStatus } from './bridge/wechat/login.js';
|
|
24
|
+
import { loadJson, saveJson, validateAccountId } from './bridge/store.js';
|
|
24
25
|
export const name = '@lanbaolu/dsh-wechat-bridge';
|
|
25
26
|
/** Host services the plugin needs. `webServer` is optional (headless profiles). */
|
|
26
27
|
export const inject = ['tools', 'agents', 'agentDefaultModel'];
|
|
@@ -28,7 +29,7 @@ export const Config = z.object({
|
|
|
28
29
|
dataDir: z.string().default(''),
|
|
29
30
|
host: z.string().default('127.0.0.1'),
|
|
30
31
|
port: z.number().min(0).max(65535).default(0),
|
|
31
|
-
autoStart: z.boolean().default(
|
|
32
|
+
autoStart: z.boolean().default(true),
|
|
32
33
|
provider: z.string().default(''),
|
|
33
34
|
model: z.string().default(''),
|
|
34
35
|
workingDirectory: z.string().default(''),
|
|
@@ -62,6 +63,8 @@ export function apply(ctx, config) {
|
|
|
62
63
|
const activeSessionIds = new Set();
|
|
63
64
|
const creating = new Map();
|
|
64
65
|
const streamClients = new Map();
|
|
66
|
+
/** Accounts waiting to switch to a selected project after the current turn ends. */
|
|
67
|
+
const pendingProjectSwitches = new Set();
|
|
65
68
|
let bridgeChild;
|
|
66
69
|
let bridgeStartedAt;
|
|
67
70
|
let internalServer;
|
|
@@ -96,6 +99,37 @@ export function apply(ctx, config) {
|
|
|
96
99
|
saveSessionIdMap(map);
|
|
97
100
|
}
|
|
98
101
|
}
|
|
102
|
+
// -------------------------------------------------------------------------
|
|
103
|
+
// Explicit project-conversation binding (Web panel selection)
|
|
104
|
+
// -------------------------------------------------------------------------
|
|
105
|
+
const selectedSessionIdPath = join(dataDir, 'selected-sessions.json');
|
|
106
|
+
function loadSelectedSessionIds() {
|
|
107
|
+
try {
|
|
108
|
+
const raw = JSON.parse(readFileSync(selectedSessionIdPath, 'utf8'));
|
|
109
|
+
return raw && typeof raw === 'object' ? raw : {};
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function saveSelectedSessionIds(map) {
|
|
116
|
+
mkdirSync(dataDir, { recursive: true });
|
|
117
|
+
writeFileSync(selectedSessionIdPath, JSON.stringify(map, null, 2) + '\n', 'utf8');
|
|
118
|
+
}
|
|
119
|
+
function persistSelectedSessionId(accountId, dshSessionId) {
|
|
120
|
+
validateAccountId(accountId);
|
|
121
|
+
const map = loadSelectedSessionIds();
|
|
122
|
+
map[accountId] = dshSessionId;
|
|
123
|
+
saveSelectedSessionIds(map);
|
|
124
|
+
}
|
|
125
|
+
function removeSelectedSessionId(accountId) {
|
|
126
|
+
const map = loadSelectedSessionIds();
|
|
127
|
+
if (accountId in map) {
|
|
128
|
+
delete map[accountId];
|
|
129
|
+
saveSelectedSessionIds(map);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const selectedSessionIds = new Map(Object.entries(loadSelectedSessionIds()));
|
|
99
133
|
function newDshSessionId(accountId) {
|
|
100
134
|
return `wb-${accountId}-${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
101
135
|
}
|
|
@@ -103,6 +137,14 @@ export function apply(ctx, config) {
|
|
|
103
137
|
// Agent management
|
|
104
138
|
// -------------------------------------------------------------------------
|
|
105
139
|
async function ensureAgent(accountId, input) {
|
|
140
|
+
// If the model just selected a project from inside a WeChat turn, finish
|
|
141
|
+
// tearing down the old bridge agent before accepting the next message.
|
|
142
|
+
if (pendingProjectSwitches.has(accountId)) {
|
|
143
|
+
pendingProjectSwitches.delete(accountId);
|
|
144
|
+
if (agents.has(accountId)) {
|
|
145
|
+
await disposeAgent(accountId, { preserveSelection: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
106
148
|
const existing = agents.get(accountId);
|
|
107
149
|
if (existing)
|
|
108
150
|
return existing;
|
|
@@ -121,14 +163,21 @@ export function apply(ctx, config) {
|
|
|
121
163
|
};
|
|
122
164
|
// Resume the persisted DSH session when possible; create a fresh one only
|
|
123
165
|
// when there is no mapping, the old log is gone/corrupt, or the requested
|
|
124
|
-
// workspace differs from the persisted session's cwd
|
|
166
|
+
// workspace differs from the persisted session's cwd (unless the user
|
|
167
|
+
// explicitly bound the bridge to a project conversation).
|
|
168
|
+
const selectedSessionId = selectedSessionIds.get(accountId);
|
|
125
169
|
let dshSessionId = sessionIds.get(accountId);
|
|
126
170
|
let handle;
|
|
127
171
|
let resumed = false;
|
|
172
|
+
let isSelected = false;
|
|
173
|
+
let selectedCwd;
|
|
174
|
+
if (!dshSessionId)
|
|
175
|
+
dshSessionId = selectedSessionId;
|
|
128
176
|
if (!dshSessionId) {
|
|
129
177
|
const sessionMap = loadSessionIdMap();
|
|
130
178
|
dshSessionId = sessionMap[accountId] || undefined;
|
|
131
179
|
}
|
|
180
|
+
isSelected = !!selectedSessionId && dshSessionId === selectedSessionId;
|
|
132
181
|
if (dshSessionId) {
|
|
133
182
|
try {
|
|
134
183
|
const candidate = await ctx.agents.resume({
|
|
@@ -136,7 +185,9 @@ export function apply(ctx, config) {
|
|
|
136
185
|
agentOptions,
|
|
137
186
|
});
|
|
138
187
|
const persistedCwd = candidate.agent.session.header.cwd;
|
|
139
|
-
|
|
188
|
+
selectedCwd = persistedCwd || undefined;
|
|
189
|
+
const cwdMismatch = input?.cwd && persistedCwd && resolve(input.cwd) !== resolve(persistedCwd);
|
|
190
|
+
if (cwdMismatch && !isSelected) {
|
|
140
191
|
debugLog('resume cwd mismatch, create new', {
|
|
141
192
|
accountId,
|
|
142
193
|
dshSessionId,
|
|
@@ -161,8 +212,17 @@ export function apply(ctx, config) {
|
|
|
161
212
|
}
|
|
162
213
|
}
|
|
163
214
|
if (!handle) {
|
|
215
|
+
if (isSelected) {
|
|
216
|
+
debugLog('selected project session resume failed, clearing binding', {
|
|
217
|
+
accountId,
|
|
218
|
+
dshSessionId,
|
|
219
|
+
});
|
|
220
|
+
selectedSessionIds.delete(accountId);
|
|
221
|
+
removeSelectedSessionId(accountId);
|
|
222
|
+
isSelected = false;
|
|
223
|
+
}
|
|
164
224
|
dshSessionId = newDshSessionId(accountId);
|
|
165
|
-
debugLog('ensureAgent create', { accountId, dshSessionId, provider, model, selection, resumed: false });
|
|
225
|
+
debugLog('ensureAgent create', { accountId, dshSessionId, provider, model, selection, resumed: false, selected: false });
|
|
166
226
|
handle = await ctx.agents.create({
|
|
167
227
|
sessionId: SessionId(dshSessionId),
|
|
168
228
|
meta: input?.cwd ? { cwd: resolve(input.cwd) } : undefined,
|
|
@@ -170,16 +230,27 @@ export function apply(ctx, config) {
|
|
|
170
230
|
});
|
|
171
231
|
}
|
|
172
232
|
else {
|
|
173
|
-
debugLog('ensureAgent resume', { accountId, dshSessionId, provider, model, selection, resumed: true });
|
|
233
|
+
debugLog('ensureAgent resume', { accountId, dshSessionId, provider, model, selection, resumed: true, selected: isSelected });
|
|
174
234
|
}
|
|
175
235
|
const finalSessionId = dshSessionId;
|
|
176
236
|
sessionIds.set(accountId, finalSessionId);
|
|
177
237
|
persistSessionId(accountId, finalSessionId);
|
|
238
|
+
if (isSelected) {
|
|
239
|
+
persistSelectedSessionId(accountId, finalSessionId);
|
|
240
|
+
}
|
|
241
|
+
else if (selectedSessionIds.has(accountId)) {
|
|
242
|
+
selectedSessionIds.delete(accountId);
|
|
243
|
+
removeSelectedSessionId(accountId);
|
|
244
|
+
}
|
|
178
245
|
agents.set(accountId, handle);
|
|
179
246
|
activeSessionIds.add(finalSessionId);
|
|
180
|
-
debugLog('agent ready', { accountId, dshSessionId: finalSessionId, provider, model, selection, resumed });
|
|
181
|
-
if (
|
|
247
|
+
debugLog('agent ready', { accountId, dshSessionId: finalSessionId, provider, model, selection, resumed, selected: isSelected });
|
|
248
|
+
if (isSelected && selectedCwd) {
|
|
249
|
+
await attachSessionToWorkspace(finalSessionId, selectedCwd);
|
|
250
|
+
}
|
|
251
|
+
else if (input?.cwd) {
|
|
182
252
|
await attachSessionToWorkspace(finalSessionId, resolve(input.cwd));
|
|
253
|
+
}
|
|
183
254
|
return handle;
|
|
184
255
|
})();
|
|
185
256
|
creating.set(accountId, task);
|
|
@@ -190,7 +261,7 @@ export function apply(ctx, config) {
|
|
|
190
261
|
creating.delete(accountId);
|
|
191
262
|
}
|
|
192
263
|
}
|
|
193
|
-
async function disposeAgent(accountId) {
|
|
264
|
+
async function disposeAgent(accountId, options) {
|
|
194
265
|
const handle = agents.get(accountId);
|
|
195
266
|
const dshSessionId = sessionIds.get(accountId);
|
|
196
267
|
if (dshSessionId)
|
|
@@ -198,6 +269,10 @@ export function apply(ctx, config) {
|
|
|
198
269
|
sessionIds.delete(accountId);
|
|
199
270
|
agents.delete(accountId);
|
|
200
271
|
removePersistedSessionId(accountId);
|
|
272
|
+
if (!options?.preserveSelection && selectedSessionIds.has(accountId)) {
|
|
273
|
+
selectedSessionIds.delete(accountId);
|
|
274
|
+
removeSelectedSessionId(accountId);
|
|
275
|
+
}
|
|
201
276
|
if (handle)
|
|
202
277
|
await handle.dispose();
|
|
203
278
|
closeStreams(accountId);
|
|
@@ -223,6 +298,217 @@ export function apply(ctx, config) {
|
|
|
223
298
|
}
|
|
224
299
|
}
|
|
225
300
|
// -------------------------------------------------------------------------
|
|
301
|
+
// Project conversation selection (Web panel)
|
|
302
|
+
// -------------------------------------------------------------------------
|
|
303
|
+
function latestAccountId() {
|
|
304
|
+
try {
|
|
305
|
+
const accountsDir = join(dataDir, 'accounts');
|
|
306
|
+
const files = readdirSync(accountsDir).filter((file) => file.endsWith('.json'));
|
|
307
|
+
if (files.length === 0)
|
|
308
|
+
return undefined;
|
|
309
|
+
let latestFile = files[0];
|
|
310
|
+
let latestMtime = 0;
|
|
311
|
+
for (const file of files) {
|
|
312
|
+
const stat = statSync(join(accountsDir, file));
|
|
313
|
+
if (stat.mtimeMs > latestMtime) {
|
|
314
|
+
latestMtime = stat.mtimeMs;
|
|
315
|
+
latestFile = file;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return latestFile.replace(/\.json$/, '');
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function listProjectSessions() {
|
|
325
|
+
const registry = ctx.get('workspaceRegistry');
|
|
326
|
+
if (!registry?.list)
|
|
327
|
+
return [];
|
|
328
|
+
const sessionsService = ctx.get('sessions');
|
|
329
|
+
const persistence = ctx.get('sessionPersistence');
|
|
330
|
+
const headerById = new Map();
|
|
331
|
+
const liveIds = new Set();
|
|
332
|
+
for (const session of sessionsService?.list() ?? []) {
|
|
333
|
+
const id = String(session.id ?? session.header.id);
|
|
334
|
+
if (!id)
|
|
335
|
+
continue;
|
|
336
|
+
liveIds.add(id);
|
|
337
|
+
headerById.set(id, {
|
|
338
|
+
cwd: session.header.cwd,
|
|
339
|
+
createdAt: session.header.createdAt,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (persistence?.listSnapshots) {
|
|
343
|
+
try {
|
|
344
|
+
for (const snap of await persistence.listSnapshots()) {
|
|
345
|
+
const id = String(snap.header.id);
|
|
346
|
+
if (!id)
|
|
347
|
+
continue;
|
|
348
|
+
if (!headerById.has(id)) {
|
|
349
|
+
headerById.set(id, {
|
|
350
|
+
cwd: snap.header.cwd,
|
|
351
|
+
createdAt: snap.header.createdAt,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
debugLog('listProjectSessions snapshots failed', { error: err instanceof Error ? err.message : String(err) });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const items = [];
|
|
361
|
+
for (const ws of registry.list()) {
|
|
362
|
+
for (const sid of ws.sessionIds) {
|
|
363
|
+
const sessionId = String(sid);
|
|
364
|
+
const header = headerById.get(sessionId);
|
|
365
|
+
items.push({
|
|
366
|
+
sessionId,
|
|
367
|
+
workspaceId: ws.id,
|
|
368
|
+
workspaceTitle: ws.title,
|
|
369
|
+
path: ws.path,
|
|
370
|
+
cwd: header?.cwd || ws.path,
|
|
371
|
+
createdAt: header?.createdAt || ws.createdAt,
|
|
372
|
+
live: liveIds.has(sessionId),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return items;
|
|
377
|
+
}
|
|
378
|
+
async function selectedProjectPayload(accountId) {
|
|
379
|
+
const target = accountId || latestAccountId();
|
|
380
|
+
if (!target)
|
|
381
|
+
return null;
|
|
382
|
+
const selectedId = selectedSessionIds.get(target);
|
|
383
|
+
if (!selectedId)
|
|
384
|
+
return null;
|
|
385
|
+
const items = await listProjectSessions();
|
|
386
|
+
const item = items.find((candidate) => candidate.sessionId === selectedId);
|
|
387
|
+
if (!item)
|
|
388
|
+
return null;
|
|
389
|
+
return {
|
|
390
|
+
sessionId: selectedId,
|
|
391
|
+
workspaceId: item.workspaceId,
|
|
392
|
+
workspaceTitle: item.workspaceTitle,
|
|
393
|
+
path: item.path,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function accountIdForAgent(agent) {
|
|
397
|
+
if (!agent?.session?.id)
|
|
398
|
+
return undefined;
|
|
399
|
+
const sid = String(agent.session.id);
|
|
400
|
+
for (const [accountId, dshSessionId] of sessionIds) {
|
|
401
|
+
if (dshSessionId === sid)
|
|
402
|
+
return accountId;
|
|
403
|
+
}
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
async function selectProjectFromAgent(agent, sessionId) {
|
|
407
|
+
const accountId = accountIdForAgent(agent);
|
|
408
|
+
if (!accountId) {
|
|
409
|
+
return { ok: false, error: '当前不是微信桥接会话,无法切换项目。' };
|
|
410
|
+
}
|
|
411
|
+
const items = await listProjectSessions();
|
|
412
|
+
const item = items.find((candidate) => candidate.sessionId === sessionId);
|
|
413
|
+
if (!item) {
|
|
414
|
+
return { ok: false, error: '未找到该项目会话,请先使用 wechat_bridge_list_projects 查看可绑定项目。' };
|
|
415
|
+
}
|
|
416
|
+
const currentDshSessionId = sessionIds.get(accountId);
|
|
417
|
+
if (sessionId === currentDshSessionId) {
|
|
418
|
+
return {
|
|
419
|
+
ok: true,
|
|
420
|
+
accountId,
|
|
421
|
+
selectedSessionId: sessionId,
|
|
422
|
+
project: item,
|
|
423
|
+
message: `已经在项目 ${item.workspaceTitle} 中。`,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const sessionsService = ctx.get('sessions');
|
|
427
|
+
if (sessionsService?.get(SessionId(sessionId))) {
|
|
428
|
+
return { ok: false, error: '该项目会话当前正在 DSH 中打开,请先在 DSH 中关闭该会话后再进入。' };
|
|
429
|
+
}
|
|
430
|
+
selectedSessionIds.set(accountId, sessionId);
|
|
431
|
+
persistSelectedSessionId(accountId, sessionId);
|
|
432
|
+
resetBridgeAccountSession(accountId, item.path);
|
|
433
|
+
pendingProjectSwitches.add(accountId);
|
|
434
|
+
return {
|
|
435
|
+
ok: true,
|
|
436
|
+
accountId,
|
|
437
|
+
selectedSessionId: sessionId,
|
|
438
|
+
project: item,
|
|
439
|
+
message: `已进入项目 ${item.workspaceTitle}(${item.path}),后续对话会记录到这个项目。`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
function readBridgeAccountSession(accountId) {
|
|
443
|
+
validateAccountId(accountId);
|
|
444
|
+
return loadJson(join(dataDir, 'sessions', `${accountId}.json`), {});
|
|
445
|
+
}
|
|
446
|
+
function writeBridgeAccountSession(accountId, session) {
|
|
447
|
+
validateAccountId(accountId);
|
|
448
|
+
saveJson(join(dataDir, 'sessions', `${accountId}.json`), session);
|
|
449
|
+
}
|
|
450
|
+
function resetBridgeAccountSession(accountId, cwd) {
|
|
451
|
+
const session = readBridgeAccountSession(accountId);
|
|
452
|
+
session.workingDirectory = cwd;
|
|
453
|
+
session.state = 'idle';
|
|
454
|
+
session.chatHistory = [];
|
|
455
|
+
writeBridgeAccountSession(accountId, session);
|
|
456
|
+
}
|
|
457
|
+
async function selectProjectSession(dshSessionId, accountId) {
|
|
458
|
+
const target = accountId || latestAccountId();
|
|
459
|
+
if (!target)
|
|
460
|
+
return { ok: false, error: '没有已绑定的微信账号,请先扫码绑定。' };
|
|
461
|
+
const items = await listProjectSessions();
|
|
462
|
+
const item = items.find((candidate) => candidate.sessionId === dshSessionId);
|
|
463
|
+
if (!item)
|
|
464
|
+
return { ok: false, error: '指定的会话不存在或不属于任何项目。' };
|
|
465
|
+
// If the user is re-selecting the conversation the bridge already owns,
|
|
466
|
+
// treat it as a no-op instead of disposing the live agent.
|
|
467
|
+
const currentDshSessionId = sessionIds.get(target);
|
|
468
|
+
if (dshSessionId === currentDshSessionId) {
|
|
469
|
+
return {
|
|
470
|
+
ok: true,
|
|
471
|
+
accountId: target,
|
|
472
|
+
selectedSessionId: dshSessionId,
|
|
473
|
+
project: item,
|
|
474
|
+
daemon: '已经绑定到该项目会话。',
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
// A live DSH session can only be owned by one agent loop. If the selected
|
|
478
|
+
// conversation is currently open in the DSH UI, refuse before touching the
|
|
479
|
+
// current bridge agent instead of silently falling back on the next message.
|
|
480
|
+
const sessionsService = ctx.get('sessions');
|
|
481
|
+
if (sessionsService?.get(SessionId(dshSessionId))) {
|
|
482
|
+
return { ok: false, error: '该会话当前正在 DSH 中打开,请先在 DSH 中关闭该会话后再绑定。' };
|
|
483
|
+
}
|
|
484
|
+
// Drop the current bridge-owned agent so the next message resumes the
|
|
485
|
+
// selected project conversation instead of the previous bridge session.
|
|
486
|
+
if (agents.has(target) || sessionIds.has(target)) {
|
|
487
|
+
await disposeAgent(target);
|
|
488
|
+
}
|
|
489
|
+
selectedSessionIds.set(target, dshSessionId);
|
|
490
|
+
persistSelectedSessionId(target, dshSessionId);
|
|
491
|
+
resetBridgeAccountSession(target, item.path);
|
|
492
|
+
const daemonResult = daemonRunning() ? await restartDaemon() : { ok: true, message: '守护进程未运行,绑定将在下次启动时生效。' };
|
|
493
|
+
return {
|
|
494
|
+
ok: true,
|
|
495
|
+
accountId: target,
|
|
496
|
+
selectedSessionId: dshSessionId,
|
|
497
|
+
project: item,
|
|
498
|
+
daemon: daemonResult.message,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
async function detachProjectSession(accountId) {
|
|
502
|
+
const target = accountId || latestAccountId();
|
|
503
|
+
if (!target)
|
|
504
|
+
return { ok: false, error: '没有已绑定的微信账号。' };
|
|
505
|
+
await disposeAgent(target);
|
|
506
|
+
const config = readBridgeConfig();
|
|
507
|
+
resetBridgeAccountSession(target, config.workingDirectory);
|
|
508
|
+
const daemonResult = daemonRunning() ? await restartDaemon() : { ok: true, message: '守护进程未运行,解除绑定将在下次启动时生效。' };
|
|
509
|
+
return { ok: true, accountId: target, daemon: daemonResult.message };
|
|
510
|
+
}
|
|
511
|
+
// -------------------------------------------------------------------------
|
|
226
512
|
// SSE broadcast
|
|
227
513
|
// -------------------------------------------------------------------------
|
|
228
514
|
function broadcast(sessionId, event) {
|
|
@@ -274,6 +560,15 @@ export function apply(ctx, config) {
|
|
|
274
560
|
else if (event.type === 'turn/end') {
|
|
275
561
|
debugLog('session turn/end', { accountId, sessionId: sid, reason: event.data.reason });
|
|
276
562
|
broadcast(accountId, { type: 'done', turn: event.data.turn, message: 'turn ended' });
|
|
563
|
+
if (pendingProjectSwitches.has(accountId)) {
|
|
564
|
+
pendingProjectSwitches.delete(accountId);
|
|
565
|
+
void disposeAgent(accountId, { preserveSelection: true }).catch((err) => {
|
|
566
|
+
debugLog('pending project switch dispose failed', {
|
|
567
|
+
accountId,
|
|
568
|
+
error: err instanceof Error ? err.message : String(err),
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
}
|
|
277
572
|
}
|
|
278
573
|
});
|
|
279
574
|
// -------------------------------------------------------------------------
|
|
@@ -307,6 +602,22 @@ export function apply(ctx, config) {
|
|
|
307
602
|
sendJson(res, 200, await statusPayload());
|
|
308
603
|
return;
|
|
309
604
|
}
|
|
605
|
+
if (req.method === 'GET' && url.pathname === '/api/projects') {
|
|
606
|
+
sendJson(res, 200, { ok: true, items: await listProjectSessions() });
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (req.method === 'POST' && url.pathname === '/api/projects/select') {
|
|
610
|
+
const body = await readBody(req);
|
|
611
|
+
const sessionId = String(body.sessionId || '');
|
|
612
|
+
const result = await selectProjectSession(sessionId);
|
|
613
|
+
sendJson(res, result.ok ? 200 : 400, result);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (req.method === 'POST' && url.pathname === '/api/projects/detach') {
|
|
617
|
+
const result = await detachProjectSession();
|
|
618
|
+
sendJson(res, result.ok ? 200 : 400, result);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
310
621
|
if (req.method === 'POST' && url.pathname === '/api/prompt') {
|
|
311
622
|
const body = await readBody(req);
|
|
312
623
|
const sessionId = String(body.sessionId || 'default');
|
|
@@ -551,11 +862,16 @@ export function apply(ctx, config) {
|
|
|
551
862
|
config.workingDirectory = pendingSetup.workingDirectory;
|
|
552
863
|
saveBridgeConfig(config);
|
|
553
864
|
pendingSetup = undefined;
|
|
865
|
+
// A newly bound account only takes effect after the daemon reloads the
|
|
866
|
+
// latest account file. Restart when running, otherwise start it so the
|
|
867
|
+
// user does not have to manually restart after every re-scan.
|
|
868
|
+
const daemonResult = daemonRunning() ? await restartDaemon() : await startDaemon();
|
|
554
869
|
return {
|
|
555
870
|
ok: true,
|
|
556
871
|
status: 'confirmed',
|
|
557
872
|
accountId: result.account.accountId,
|
|
558
873
|
workingDirectory: config.workingDirectory,
|
|
874
|
+
daemon: daemonResult.message,
|
|
559
875
|
};
|
|
560
876
|
}
|
|
561
877
|
if (result.status === 'expired') {
|
|
@@ -604,6 +920,7 @@ export function apply(ctx, config) {
|
|
|
604
920
|
workingDirectory: readBridgeConfig().workingDirectory,
|
|
605
921
|
accounts: accountFiles,
|
|
606
922
|
sessions: [...sessionIds.keys()],
|
|
923
|
+
selectedProject: await selectedProjectPayload(),
|
|
607
924
|
};
|
|
608
925
|
}
|
|
609
926
|
// -------------------------------------------------------------------------
|
|
@@ -643,6 +960,52 @@ export function apply(ctx, config) {
|
|
|
643
960
|
res.end(readDaemonLogs(200));
|
|
644
961
|
},
|
|
645
962
|
}));
|
|
963
|
+
disposers.push(webServer.register({
|
|
964
|
+
kind: 'exact',
|
|
965
|
+
path: '/@lanbaolu/dsh-wechat-bridge/projects',
|
|
966
|
+
handler: async (_req, res) => {
|
|
967
|
+
const result = await listProjectSessions();
|
|
968
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
969
|
+
res.end(JSON.stringify({ ok: true, items: result }));
|
|
970
|
+
},
|
|
971
|
+
}));
|
|
972
|
+
disposers.push(webServer.register({
|
|
973
|
+
kind: 'exact',
|
|
974
|
+
path: '/@lanbaolu/dsh-wechat-bridge/projects/select',
|
|
975
|
+
handler: async (req, res) => {
|
|
976
|
+
try {
|
|
977
|
+
const body = await readBody(req);
|
|
978
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
|
|
979
|
+
const accountId = typeof body.accountId === 'string' && body.accountId ? body.accountId : undefined;
|
|
980
|
+
const result = await selectProjectSession(sessionId, accountId);
|
|
981
|
+
res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
982
|
+
res.end(JSON.stringify(result));
|
|
983
|
+
}
|
|
984
|
+
catch (err) {
|
|
985
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
986
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
987
|
+
res.end(JSON.stringify({ ok: false, error: message }));
|
|
988
|
+
}
|
|
989
|
+
},
|
|
990
|
+
}));
|
|
991
|
+
disposers.push(webServer.register({
|
|
992
|
+
kind: 'exact',
|
|
993
|
+
path: '/@lanbaolu/dsh-wechat-bridge/projects/detach',
|
|
994
|
+
handler: async (req, res) => {
|
|
995
|
+
try {
|
|
996
|
+
const body = await readBody(req).catch(() => ({}));
|
|
997
|
+
const accountId = body && typeof body.accountId === 'string' && body.accountId ? body.accountId : undefined;
|
|
998
|
+
const result = await detachProjectSession(accountId);
|
|
999
|
+
res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
1000
|
+
res.end(JSON.stringify(result));
|
|
1001
|
+
}
|
|
1002
|
+
catch (err) {
|
|
1003
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1004
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1005
|
+
res.end(JSON.stringify({ ok: false, error: message }));
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
1008
|
+
}));
|
|
646
1009
|
disposers.push(webServer.register({
|
|
647
1010
|
kind: 'exact',
|
|
648
1011
|
path: '/@lanbaolu/dsh-wechat-bridge/setup/start',
|
|
@@ -704,6 +1067,72 @@ export function apply(ctx, config) {
|
|
|
704
1067
|
};
|
|
705
1068
|
},
|
|
706
1069
|
}));
|
|
1070
|
+
ctx.tools.register(defineTool({
|
|
1071
|
+
name: 'wechat_bridge_list_projects',
|
|
1072
|
+
description: '列出 DSH 中可进入/可绑定的项目会话(含项目名、路径、会话 ID)。当用户询问“有哪些项目”“看看我有什么项目”“我要看下项目”“我在哪个项目”“想继续某个项目”“有个任务想做”等意图,或用户描述内容可能对应某个项目时,都应调用此工具查看项目,不要要求用户使用固定句式。',
|
|
1073
|
+
parameters: {},
|
|
1074
|
+
output: {
|
|
1075
|
+
schema: {
|
|
1076
|
+
type: 'object',
|
|
1077
|
+
additionalProperties: false,
|
|
1078
|
+
properties: {
|
|
1079
|
+
ok: { type: 'boolean', required: true },
|
|
1080
|
+
message: { type: 'string', required: true },
|
|
1081
|
+
projects: {
|
|
1082
|
+
type: 'array',
|
|
1083
|
+
required: true,
|
|
1084
|
+
items: {
|
|
1085
|
+
type: 'object',
|
|
1086
|
+
additionalProperties: false,
|
|
1087
|
+
properties: {
|
|
1088
|
+
sessionId: { type: 'string', required: true },
|
|
1089
|
+
workspaceTitle: { type: 'string', required: true },
|
|
1090
|
+
path: { type: 'string', required: true },
|
|
1091
|
+
live: { type: 'boolean', required: true },
|
|
1092
|
+
},
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
1096
|
+
},
|
|
1097
|
+
render: (_args, value) => [{
|
|
1098
|
+
type: 'text',
|
|
1099
|
+
text: value.projects.length === 0
|
|
1100
|
+
? '当前没有可绑定的项目会话。'
|
|
1101
|
+
: `📁 可进入的项目会话(${value.projects.length} 个):\n` + value.projects.map((p, i) => `${i + 1}. ${p.workspaceTitle} · ${p.path} · ${p.sessionId.slice(-8)}`).join('\n'),
|
|
1102
|
+
}],
|
|
1103
|
+
},
|
|
1104
|
+
execute: async () => {
|
|
1105
|
+
const items = await listProjectSessions();
|
|
1106
|
+
return {
|
|
1107
|
+
ok: true,
|
|
1108
|
+
message: `共 ${items.length} 个项目会话`,
|
|
1109
|
+
projects: items.map((item) => ({
|
|
1110
|
+
sessionId: item.sessionId,
|
|
1111
|
+
workspaceTitle: item.workspaceTitle,
|
|
1112
|
+
path: item.path,
|
|
1113
|
+
live: item.live,
|
|
1114
|
+
})),
|
|
1115
|
+
};
|
|
1116
|
+
},
|
|
1117
|
+
}));
|
|
1118
|
+
ctx.tools.register(defineTool({
|
|
1119
|
+
name: 'wechat_bridge_select_project',
|
|
1120
|
+
description: '进入一个 DSH 项目会话。微信桥接会话中,模型应先调用 wechat_bridge_list_projects 获取 sessionId,再调用本工具切换到对应项目;切换后后续微信对话会记录到该项目。支持用户自然语言模糊指代,例如“进入某某项目”“去这个项目”“继续在 XXX 里做”“我现在要做 XXX”等,模型应根据上下文/项目列表选择对应 sessionId。',
|
|
1121
|
+
parameters: {
|
|
1122
|
+
sessionId: { type: 'string', description: '要进入的项目会话 ID,来自 wechat_bridge_list_projects 返回的 sessionId。' },
|
|
1123
|
+
},
|
|
1124
|
+
output: simpleOutput,
|
|
1125
|
+
execute: async (args, exec) => {
|
|
1126
|
+
const result = await selectProjectFromAgent(exec?.agent, args.sessionId);
|
|
1127
|
+
if (!result.ok) {
|
|
1128
|
+
throw new Error(String(result.error || '进入项目失败'));
|
|
1129
|
+
}
|
|
1130
|
+
return {
|
|
1131
|
+
ok: true,
|
|
1132
|
+
message: String(result.message || '已进入项目。'),
|
|
1133
|
+
};
|
|
1134
|
+
},
|
|
1135
|
+
}));
|
|
707
1136
|
ctx.tools.register(defineTool({
|
|
708
1137
|
name: 'wechat_bridge_start',
|
|
709
1138
|
description: '启动 DSH 微信桥接守护进程。需要先完成微信扫码绑定(wechat_bridge_setup 或 node lib/bridge/main.js setup)。',
|
|
@@ -804,7 +1233,24 @@ export function apply(ctx, config) {
|
|
|
804
1233
|
internalServer = server;
|
|
805
1234
|
registerTools();
|
|
806
1235
|
const webDisposers = registerWebRoutes();
|
|
1236
|
+
// Watchdog: after sleep/wake or an unexpected daemon exit, automatically
|
|
1237
|
+
// bring the bridge back instead of requiring the user to click Start.
|
|
1238
|
+
let healthTimer;
|
|
1239
|
+
if (config.autoStart) {
|
|
1240
|
+
healthTimer = setInterval(() => {
|
|
1241
|
+
if (daemonRunning())
|
|
1242
|
+
return;
|
|
1243
|
+
startDaemon().then(result => {
|
|
1244
|
+
ctx.logger?.info?.('[dsh-wechat-bridge] watchdog auto-start', result);
|
|
1245
|
+
}).catch(err => {
|
|
1246
|
+
ctx.logger?.warn?.('[dsh-wechat-bridge] watchdog auto-start failed', { error: String(err) });
|
|
1247
|
+
});
|
|
1248
|
+
}, 15_000);
|
|
1249
|
+
healthTimer.unref?.();
|
|
1250
|
+
}
|
|
807
1251
|
return () => {
|
|
1252
|
+
if (healthTimer)
|
|
1253
|
+
clearInterval(healthTimer);
|
|
808
1254
|
for (const dispose of webDisposers)
|
|
809
1255
|
dispose();
|
|
810
1256
|
for (const handle of agents.values()) {
|