dsh-qqchat 0.2.0 → 0.2.2
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/README.en.md +25 -15
- package/README.md +22 -11
- package/client-src/settings.cts +32 -2
- package/docs/ARCHITECTURE.en.md +5 -2
- package/docs/ARCHITECTURE.md +3 -8
- package/docs/MEDIA_AND_QUOTES.md +11 -12
- package/docs/SECURITY.md +1 -1
- package/docs/STREAMING.md +22 -19
- package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
- package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
- package/lib/client.js +26 -3
- package/lib/gateway/api.d.ts +1 -1
- package/lib/gateway/api.js +5 -1
- package/lib/gateway/gateway.js +5 -1
- package/lib/session/agent-bridge.d.ts +4 -2
- package/lib/session/agent-bridge.js +74 -22
- package/lib/session/runtime.d.ts +1 -0
- package/lib/session/runtime.js +26 -0
- package/lib/shared/augmentations.d.ts +0 -4
- package/lib/transport/rpc.js +5 -0
- package/package.json +3 -1
- package/src/gateway/api.ts +5 -1
- package/src/gateway/gateway.ts +5 -1
- package/src/session/agent-bridge.ts +73 -23
- package/src/session/runtime.ts +24 -0
- package/src/shared/augmentations.ts +0 -4
- package/src/transport/rpc.ts +9 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
|
|
2
3
|
import '../shared/augmentations.js';
|
|
3
4
|
import type { QQChatDatabase } from '../storage/db.js';
|
|
4
5
|
import type { MemoryEngine } from '../storage/memory.js';
|
|
5
6
|
import type { ChatType, GroupRow, LoggerLike, MemberRow, StoredAttachmentSummary, QQChatConfig, QQChatDisplayEvent, QQNormalizedMessage } from '../types.js';
|
|
7
|
+
export declare function resolveQQSessionPreset(header: Session['header'], events: readonly SessionEvent[]): string | undefined;
|
|
6
8
|
export declare class DshQQBridge {
|
|
7
9
|
private readonly ctx;
|
|
8
10
|
private readonly db;
|
|
@@ -21,7 +23,6 @@ export declare class DshQQBridge {
|
|
|
21
23
|
private readonly memorySnapshots;
|
|
22
24
|
private readonly disposeEvent;
|
|
23
25
|
private readonly disposeToolGate;
|
|
24
|
-
private readonly disposeBootstrapGate;
|
|
25
26
|
private readonly disposeCommands;
|
|
26
27
|
private readonly disposeImageTool;
|
|
27
28
|
private readonly disposeMediaTools;
|
|
@@ -36,7 +37,6 @@ export declare class DshQQBridge {
|
|
|
36
37
|
private ensureAgent;
|
|
37
38
|
private isArchived;
|
|
38
39
|
private rememberRoute;
|
|
39
|
-
private ensureVisible;
|
|
40
40
|
private appendDisplayIfMissing;
|
|
41
41
|
private appendOwnerMessageIfMissing;
|
|
42
42
|
private ensureTitle;
|
|
@@ -50,7 +50,9 @@ export declare class DshQQBridge {
|
|
|
50
50
|
private commandHelp;
|
|
51
51
|
private registerCommands;
|
|
52
52
|
private modelCommand;
|
|
53
|
+
private presetCommand;
|
|
53
54
|
private statusCommand;
|
|
54
55
|
private composition;
|
|
56
|
+
private persistedPreset;
|
|
55
57
|
private serial;
|
|
56
58
|
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
4
|
+
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets';
|
|
4
5
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
5
6
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
6
7
|
import '../shared/augmentations.js';
|
|
7
8
|
import { defaultRuntimeSettings } from '../config.js';
|
|
8
9
|
import { registerQQImageTool } from '../media/image-tool.js';
|
|
9
10
|
import { registerQQMediaTools } from '../media/media-tools.js';
|
|
10
|
-
import { dispatchQQCommand, qqCommandText } from '../commands/dispatch.js';
|
|
11
|
+
import { dispatchQQCommand, qqCommandText, slashCommandName } from '../commands/dispatch.js';
|
|
11
12
|
import { MEMORY_SNAPSHOT_TTL_MS, memorySnapshotHash, restoreMemorySnapshotState, shouldRefreshMemorySnapshot } from './memory-snapshot.js';
|
|
12
13
|
const DSH_RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt';
|
|
14
|
+
export function resolveQQSessionPreset(header, events) {
|
|
15
|
+
return resolveSessionPreset({ header, events });
|
|
16
|
+
}
|
|
13
17
|
const MEDIA_TOOL_NAMES = new Set(['qqchat_describe_image', 'qqchat_read_file', 'qqchat_media_info']);
|
|
14
18
|
const packageMetadata = createRequire(import.meta.url)('../../package.json');
|
|
15
19
|
const PLUGIN_VERSION = packageMetadata.version || 'unknown';
|
|
@@ -31,7 +35,6 @@ export class DshQQBridge {
|
|
|
31
35
|
memorySnapshots = new Map();
|
|
32
36
|
disposeEvent;
|
|
33
37
|
disposeToolGate;
|
|
34
|
-
disposeBootstrapGate;
|
|
35
38
|
disposeCommands;
|
|
36
39
|
disposeImageTool;
|
|
37
40
|
disposeMediaTools;
|
|
@@ -42,11 +45,6 @@ export class DshQQBridge {
|
|
|
42
45
|
this.config = config;
|
|
43
46
|
this.logger = logger;
|
|
44
47
|
this.disposeEvent = ctx.on('session/event', (session, event) => this.onSessionEvent(session, event));
|
|
45
|
-
this.disposeBootstrapGate = ctx.on('agent/pre-step', async ({ messages }, next) => {
|
|
46
|
-
if (messages.some(message => message.source.kind === 'qq-chat-bootstrap'))
|
|
47
|
-
return { kind: 'reject' };
|
|
48
|
-
return next();
|
|
49
|
-
});
|
|
50
48
|
this.disposeToolGate = ctx.on('tools/pre-execute', async (exec, next) => {
|
|
51
49
|
const agent = exec.agent;
|
|
52
50
|
if (!agent)
|
|
@@ -75,7 +73,6 @@ export class DshQQBridge {
|
|
|
75
73
|
}
|
|
76
74
|
async dispose() {
|
|
77
75
|
this.disposeEvent();
|
|
78
|
-
this.disposeBootstrapGate();
|
|
79
76
|
this.disposeToolGate();
|
|
80
77
|
this.disposeImageTool();
|
|
81
78
|
this.disposeMediaTools();
|
|
@@ -131,6 +128,17 @@ export class DshQQBridge {
|
|
|
131
128
|
if (commandText !== undefined) {
|
|
132
129
|
if (!commands)
|
|
133
130
|
return '当前 DSH profile 未加载命令系统,请重启并确认使用了最新插件。';
|
|
131
|
+
// Preset discovery/selection is a pre-session operation. Running it
|
|
132
|
+
// through DSH's native command executor would append command/run and
|
|
133
|
+
// make the blank Session ineligible for the very preset it lists.
|
|
134
|
+
// Keep the native registration for Web, but handle QQ directly.
|
|
135
|
+
if (slashCommandName(commandText) === 'qqpreset' && (message.chatType !== 'group' || isOwner)) {
|
|
136
|
+
const prefix = '/qqpreset';
|
|
137
|
+
const result = await this.presetCommand(agent, commandText.slice(prefix.length).trim());
|
|
138
|
+
if (this.pendingResets.delete(String(sessionId)))
|
|
139
|
+
await this.resetSession(message.chatType, row);
|
|
140
|
+
return result.text || '';
|
|
141
|
+
}
|
|
134
142
|
const command = await dispatchQQCommand(commands, agent, commandText, { chatType: message.chatType, isOwner });
|
|
135
143
|
if (command.handled) {
|
|
136
144
|
if (this.pendingResets.delete(String(sessionId)))
|
|
@@ -273,7 +281,6 @@ export class DshQQBridge {
|
|
|
273
281
|
this.ensureSelection(live);
|
|
274
282
|
this.ensureTitle(live.session, chatType, row);
|
|
275
283
|
this.rememberRoute(chatType, row, live, sessionId);
|
|
276
|
-
await this.ensureVisible(live);
|
|
277
284
|
return { agent: live, sessionId };
|
|
278
285
|
}
|
|
279
286
|
const resumed = await this.tryResume(sessionId);
|
|
@@ -281,7 +288,6 @@ export class DshQQBridge {
|
|
|
281
288
|
this.ensureSelection(resumed.agent);
|
|
282
289
|
this.ensureTitle(resumed.agent.session, chatType, row);
|
|
283
290
|
this.rememberRoute(chatType, row, resumed.agent, sessionId);
|
|
284
|
-
await this.ensureVisible(resumed.agent);
|
|
285
291
|
return { agent: resumed.agent, sessionId };
|
|
286
292
|
}
|
|
287
293
|
}
|
|
@@ -300,7 +306,6 @@ export class DshQQBridge {
|
|
|
300
306
|
this.db.setChatSession(chatType, Number(row.id), sessionId);
|
|
301
307
|
this.ensureTitle(handle.agent.session, chatType, row);
|
|
302
308
|
this.rememberRoute(chatType, row, handle.agent, sessionId);
|
|
303
|
-
await this.ensureVisible(handle.agent);
|
|
304
309
|
return { agent: handle.agent, sessionId };
|
|
305
310
|
}
|
|
306
311
|
isArchived(sessionId) {
|
|
@@ -317,16 +322,10 @@ export class DshQQBridge {
|
|
|
317
322
|
else
|
|
318
323
|
this.memory.setMemberRoute(Number(row.id), provider, model, sessionId);
|
|
319
324
|
}
|
|
320
|
-
async ensureVisible(agent) {
|
|
321
|
-
if (agent.session.events.some(event => event.type === 'turn/start'))
|
|
322
|
-
return;
|
|
323
|
-
agent.followup(createUserMessage({ source: { kind: 'qq-chat-bootstrap', plugin: 'dsh-qqchat' }, content: [{ type: 'text', text: 'Activate QQ Chat workspace session.' }] }));
|
|
324
|
-
await agent.whenIdle();
|
|
325
|
-
}
|
|
326
325
|
appendDisplayIfMissing(session, event) {
|
|
327
326
|
if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId))
|
|
328
327
|
return;
|
|
329
|
-
session.append('qqchat/message', displayEventPayload(event));
|
|
328
|
+
session.append('qqchat/message', displayEventPayload(event), { ignorable: true });
|
|
330
329
|
}
|
|
331
330
|
appendOwnerMessageIfMissing(session, event) {
|
|
332
331
|
if (session.events.some(item => {
|
|
@@ -363,7 +362,7 @@ export class DshQQBridge {
|
|
|
363
362
|
}
|
|
364
363
|
async tryResume(sessionId) {
|
|
365
364
|
try {
|
|
366
|
-
const composition = await this.composition();
|
|
365
|
+
const composition = await this.composition(sessionId);
|
|
367
366
|
const handle = await this.ctx.agents.resume({ resumeSessionId: SessionId(sessionId), agentOptions: this.agentOptions(), setup: composition.setup });
|
|
368
367
|
if (!handle.agent.session.header.cwd) {
|
|
369
368
|
await handle.dispose();
|
|
@@ -451,7 +450,7 @@ export class DshQQBridge {
|
|
|
451
450
|
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
452
451
|
'',
|
|
453
452
|
'QQChat 会话',
|
|
454
|
-
...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqstop', 'qqstatus'].includes(command.name))
|
|
453
|
+
...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqpreset', 'qqstop', 'qqstatus'].includes(command.name))
|
|
455
454
|
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
456
455
|
'',
|
|
457
456
|
'QQChat 工具',
|
|
@@ -468,6 +467,7 @@ export class DshQQBridge {
|
|
|
468
467
|
register('qqreset', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' }; }),
|
|
469
468
|
register('qqclear', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' }; }),
|
|
470
469
|
register('qqmodel', '查看或切换当前会话模型', invocation => this.modelCommand(invocation), '[provider/]model'),
|
|
470
|
+
register('qqpreset', '查看或切换当前 Session 的 Agent Preset', invocation => this.presetCommand(invocation.agent, invocation.rawInput.trim()), 'preset'),
|
|
471
471
|
register('qqstop', '中止当前生成', invocation => {
|
|
472
472
|
if (invocation.agent.status !== 'running')
|
|
473
473
|
return { kind: 'success', text: '当前没有进行中的生成。' };
|
|
@@ -512,6 +512,31 @@ export class DshQQBridge {
|
|
|
512
512
|
this.routes.set(String(agent.id), { provider, model });
|
|
513
513
|
return { kind: 'success', text: `模型已切换:${provider}/${model}\n对话上下文保留,下一轮生效。` };
|
|
514
514
|
}
|
|
515
|
+
async presetCommand(agent, requested) {
|
|
516
|
+
const getService = this.ctx.get;
|
|
517
|
+
const presets = getService?.call(this.ctx, 'agentPresets');
|
|
518
|
+
if (!presets)
|
|
519
|
+
return { kind: 'error', text: '当前 DSH profile 未加载 Agent Preset 系统。' };
|
|
520
|
+
if (!requested) {
|
|
521
|
+
const available = await presets.list();
|
|
522
|
+
return { kind: 'success', text: available.length ? `可用 Agent Preset:\n${available.map(preset => `/qqpreset ${preset.id}`).join('\n')}` : '当前没有可用 Agent Preset。' };
|
|
523
|
+
}
|
|
524
|
+
if (agent.session.events.some(event => event.type === 'turn/start')) {
|
|
525
|
+
return { kind: 'error', text: '当前 Session 已经开始对话,DSH 不允许更换 Agent Preset。请先使用 /qqnew 开启新 Session。' };
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
const preset = await presets.recompose(agent.ctx, requested);
|
|
529
|
+
agent.session.append('agent-preset/selected', { agentPreset: preset.id });
|
|
530
|
+
return { kind: 'success', text: `当前 Session 已切换到 Agent Preset:${preset.id}` };
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
const available = await presets.list().catch(() => []);
|
|
534
|
+
const availableText = available.length
|
|
535
|
+
? `当前可用:${available.map(preset => preset.id).join('、')}`
|
|
536
|
+
: '当前没有可用 Agent Preset。';
|
|
537
|
+
return { kind: 'error', text: `无法切换 Agent Preset「${requested}」:该 preset 不存在、已删除或格式无效。\n${availableText}` };
|
|
538
|
+
}
|
|
539
|
+
}
|
|
515
540
|
statusCommand(agent) {
|
|
516
541
|
const current = this.selection(agent)?.current;
|
|
517
542
|
const messages = agent.session.events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length;
|
|
@@ -521,14 +546,41 @@ export class DshQQBridge {
|
|
|
521
546
|
text: ['📊 Session 状态', `Session:${String(agent.id)}`, `状态:${agent.status === 'running' ? '生成中' : '空闲'}`, `模型:${current ? `${current.provider}/${current.model}` : '未知'}`, `消息数:${messages}`, `最后事件:${last?.type || '无'}`].join('\n'),
|
|
522
547
|
};
|
|
523
548
|
}
|
|
524
|
-
async composition() {
|
|
549
|
+
async composition(sessionId) {
|
|
525
550
|
const getService = this.ctx.get;
|
|
526
551
|
const presets = getService?.call(this.ctx, 'agentPresets');
|
|
527
552
|
if (!presets)
|
|
528
553
|
return {};
|
|
529
|
-
const
|
|
554
|
+
const persisted = sessionId ? await this.persistedPreset(sessionId) : undefined;
|
|
555
|
+
const requested = persisted || this.config.agentPreset;
|
|
556
|
+
let preset;
|
|
557
|
+
try {
|
|
558
|
+
preset = await presets.resolve(requested);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
const available = await presets.list();
|
|
562
|
+
const fallback = available[0];
|
|
563
|
+
if (!fallback)
|
|
564
|
+
throw error;
|
|
565
|
+
this.logger.warn?.(`[dsh-qqchat] Agent Preset「${requested || '默认'}」不可用,已降级到「${fallback.id}」`);
|
|
566
|
+
preset = await presets.resolve(fallback.id);
|
|
567
|
+
}
|
|
530
568
|
return { presetId: preset.id, setup: async (agentCtx) => { await presets.mount(agentCtx, preset.id); } };
|
|
531
569
|
}
|
|
570
|
+
async persistedPreset(sessionId) {
|
|
571
|
+
const getService = this.ctx.get;
|
|
572
|
+
const persistence = getService?.call(this.ctx, 'sessionPersistence');
|
|
573
|
+
if (!persistence)
|
|
574
|
+
return undefined;
|
|
575
|
+
try {
|
|
576
|
+
const inspection = await persistence.inspect(SessionId(sessionId));
|
|
577
|
+
return resolveQQSessionPreset(inspection.meta, inspection.events);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
this.logger.debug?.(`[dsh-qqchat] persisted preset lookup skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
532
584
|
serial(key, task) {
|
|
533
585
|
const previous = this.locks.get(key) || Promise.resolve();
|
|
534
586
|
const current = previous.catch(() => undefined).then(task);
|
package/lib/session/runtime.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export declare class QQChatRuntime {
|
|
|
32
32
|
readAttachment(sessionId: string, attachmentId: string): Promise<Record<string, unknown>>;
|
|
33
33
|
startGateway(account: AccountRow | undefined): void;
|
|
34
34
|
restartGateway(accountId: number): Promise<void>;
|
|
35
|
+
connectManual(appId: string, appSecret: string, sandbox?: boolean): Promise<AccountRow>;
|
|
35
36
|
private onIncoming;
|
|
36
37
|
private chatRow;
|
|
37
38
|
private flushOutbox;
|
package/lib/session/runtime.js
CHANGED
|
@@ -163,6 +163,9 @@ export class QQChatRuntime {
|
|
|
163
163
|
const id = Number(account.id);
|
|
164
164
|
if (this.gateways.has(id))
|
|
165
165
|
return;
|
|
166
|
+
// QR binding can replace the secret for an existing account id. Never
|
|
167
|
+
// carry a token obtained with the previous secret into the new Gateway.
|
|
168
|
+
this.api.clearToken(id);
|
|
166
169
|
const gateway = new QQGateway(account, this.db, this.api, message => this.onIncoming(message), this.logger);
|
|
167
170
|
this.gateways.set(id, gateway);
|
|
168
171
|
gateway.start();
|
|
@@ -176,6 +179,29 @@ export class QQChatRuntime {
|
|
|
176
179
|
if (account?.enabled)
|
|
177
180
|
this.startGateway(account);
|
|
178
181
|
}
|
|
182
|
+
async connectManual(appId, appSecret, sandbox = this.config.sandbox) {
|
|
183
|
+
const normalizedAppId = appId.trim();
|
|
184
|
+
const normalizedSecret = appSecret.trim();
|
|
185
|
+
if (!normalizedAppId || !normalizedSecret)
|
|
186
|
+
throw new Error('AppID 和 AppSecret 不能为空');
|
|
187
|
+
if (normalizedAppId.length > 256 || normalizedSecret.length > 512)
|
|
188
|
+
throw new Error('QQ Bot 凭据长度无效');
|
|
189
|
+
const existing = this.db.accountByAppId(normalizedAppId);
|
|
190
|
+
const candidate = {
|
|
191
|
+
...(existing || { id: 0, bot_user_id: null, enabled: 1, gateway_status: 'offline', gateway_last_error: null, created_at: 0, updated_at: 0 }),
|
|
192
|
+
app_id: normalizedAppId, app_secret: normalizedSecret, sandbox: sandbox ? 1 : 0,
|
|
193
|
+
};
|
|
194
|
+
this.api.clearToken(Number(candidate.id));
|
|
195
|
+
try {
|
|
196
|
+
await this.api.gatewayUrl(candidate);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
throw new Error('QQ Bot 凭据验证失败,请检查 AppID、AppSecret 和沙箱环境设置');
|
|
200
|
+
}
|
|
201
|
+
const account = this.db.upsertAccount(normalizedAppId, normalizedSecret, sandbox);
|
|
202
|
+
await this.restartGateway(Number(account.id));
|
|
203
|
+
return account;
|
|
204
|
+
}
|
|
179
205
|
async onIncoming(message) {
|
|
180
206
|
const account = this.db.accountById(message.accountId);
|
|
181
207
|
if (!account || !account.enabled)
|
package/lib/transport/rpc.js
CHANGED
|
@@ -27,6 +27,11 @@ export function createQQChatRpc(runtime) {
|
|
|
27
27
|
}
|
|
28
28
|
return ok(result);
|
|
29
29
|
}
|
|
30
|
+
case 'account/manual-connect': {
|
|
31
|
+
const record = asRecord(payload);
|
|
32
|
+
const account = await runtime.connectManual(requireString(payload, 'appId'), requireString(payload, 'appSecret'), record?.sandbox === true);
|
|
33
|
+
return ok({ account: publicAccount(account) });
|
|
34
|
+
}
|
|
30
35
|
case 'account/reconnect': {
|
|
31
36
|
const id = requireNumber(payload, 'accountId');
|
|
32
37
|
runtime.db.setAccountEnabled(id, true);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-qqchat",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "DeepSeek Harness 的 QQ 私聊/群聊插件,支持扫码授权、原生 Session、群记忆与工具权限。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
49
49
|
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
|
|
50
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.8",
|
|
50
51
|
"@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
|
|
51
52
|
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8",
|
|
52
53
|
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
|
|
@@ -73,6 +74,7 @@
|
|
|
73
74
|
},
|
|
74
75
|
"devDependencies": {
|
|
75
76
|
"@deepseek-ai/dsh-agent-default-model": "^0.1.0-rc.8",
|
|
77
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.8",
|
|
76
78
|
"@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
|
|
77
79
|
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8",
|
|
78
80
|
"@types/node": "^22.18.0",
|
package/src/gateway/api.ts
CHANGED
|
@@ -75,12 +75,16 @@ export class QQApiClient {
|
|
|
75
75
|
return data.access_token
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
async gatewayUrl(account: AccountRow): Promise<string> {
|
|
78
|
+
async gatewayUrl(account: AccountRow, retry = true): Promise<string> {
|
|
79
79
|
const token = await this.token(account)
|
|
80
80
|
const base = account.sandbox ? SANDBOX_API_BASE : API_BASE
|
|
81
81
|
const response = await fetch(`${base}/gateway`, {
|
|
82
82
|
headers: { Authorization: `QQBot ${token}` }, signal: AbortSignal.timeout(15_000),
|
|
83
83
|
})
|
|
84
|
+
if (response.status === 401 && retry) {
|
|
85
|
+
this.clearToken(account.id)
|
|
86
|
+
return this.gatewayUrl(account, false)
|
|
87
|
+
}
|
|
84
88
|
const data = await safeJson<QQGatewayPayload>(response)
|
|
85
89
|
if (!response.ok || !data?.url) throw new Error(`QQ gateway 获取失败: HTTP ${response.status}`)
|
|
86
90
|
return data.url
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -71,7 +71,11 @@ export class QQGateway {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
private async connectOnce(): Promise<void> {
|
|
74
|
-
|
|
74
|
+
// Resolve the URL first. gatewayUrl may invalidate and refresh a stale
|
|
75
|
+
// cached token after a 401; fetching the identify token in parallel could
|
|
76
|
+
// otherwise retain the pre-refresh token for the WebSocket handshake.
|
|
77
|
+
const url = await this.api.gatewayUrl(this.account)
|
|
78
|
+
const token = await this.api.token(this.account)
|
|
75
79
|
await new Promise<void>((resolve, reject) => {
|
|
76
80
|
const ws = new WebSocket(url)
|
|
77
81
|
this.ws = ws
|
|
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
3
|
import type { Context } from '@deepseek-ai/cordis'
|
|
4
4
|
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
5
|
-
import type { AgentHandle, AgentOptions, AgentSetup, ModelSelectionRef
|
|
5
|
+
import type { AgentHandle, AgentOptions, AgentSetup, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
6
|
+
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
|
|
6
7
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
7
8
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
8
9
|
import type { CommandInvocation, CommandRuntime, CommandResult } from '@deepseek-ai/dsh-commands'
|
|
@@ -15,7 +16,7 @@ import '../shared/augmentations.js'
|
|
|
15
16
|
import { defaultRuntimeSettings } from '../config.js'
|
|
16
17
|
import { registerQQImageTool } from '../media/image-tool.js'
|
|
17
18
|
import { registerQQMediaTools } from '../media/media-tools.js'
|
|
18
|
-
import { dispatchQQCommand, qqCommandText } from '../commands/dispatch.js'
|
|
19
|
+
import { dispatchQQCommand, qqCommandText, slashCommandName } from '../commands/dispatch.js'
|
|
19
20
|
import type { QQChatDatabase } from '../storage/db.js'
|
|
20
21
|
import type { MemoryEngine } from '../storage/memory.js'
|
|
21
22
|
import type {
|
|
@@ -35,12 +36,17 @@ import type { MemorySnapshotState } from './memory-snapshot.js'
|
|
|
35
36
|
const DSH_RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
|
|
36
37
|
|
|
37
38
|
interface AgentPreset { id: string }
|
|
38
|
-
interface AgentPresetService { resolve(id?: string): Promise<AgentPreset>; mount(ctx: Context, id: string): Promise<unknown> }
|
|
39
|
+
interface AgentPresetService { resolve(id?: string): Promise<AgentPreset>; list(): Promise<AgentPreset[]>; mount(ctx: Context, id: string): Promise<unknown>; recompose(ctx: Context, id: string): Promise<AgentPreset> }
|
|
40
|
+
interface SessionPersistenceService { inspect(id: SessionId): Promise<{ meta: Session['header']; events: readonly SessionEvent[] }> }
|
|
39
41
|
interface SessionTitleService { get(session: Session): unknown; rename(session: Session, title: string): unknown }
|
|
40
42
|
interface WorkspaceRegistryService { archivedSessionIds: readonly string[] }
|
|
41
43
|
interface Composition { presetId?: string; setup?: AgentSetup }
|
|
42
44
|
interface ActiveActor { chatType: ChatType; senderId: string }
|
|
43
45
|
|
|
46
|
+
export function resolveQQSessionPreset(header: Session['header'], events: readonly SessionEvent[]): string | undefined {
|
|
47
|
+
return resolveSessionPreset({ header, events })
|
|
48
|
+
}
|
|
49
|
+
|
|
44
50
|
const MEDIA_TOOL_NAMES = new Set(['qqchat_describe_image', 'qqchat_read_file', 'qqchat_media_info'])
|
|
45
51
|
const packageMetadata = createRequire(import.meta.url)('../../package.json') as { version?: string }
|
|
46
52
|
const PLUGIN_VERSION = packageMetadata.version || 'unknown'
|
|
@@ -58,7 +64,6 @@ export class DshQQBridge {
|
|
|
58
64
|
private readonly memorySnapshots = new Map<string, MemorySnapshotState>()
|
|
59
65
|
private readonly disposeEvent: () => void
|
|
60
66
|
private readonly disposeToolGate: () => void
|
|
61
|
-
private readonly disposeBootstrapGate: () => void
|
|
62
67
|
private readonly disposeCommands: () => void
|
|
63
68
|
private readonly disposeImageTool: () => void
|
|
64
69
|
private readonly disposeMediaTools: () => void
|
|
@@ -71,10 +76,6 @@ export class DshQQBridge {
|
|
|
71
76
|
private readonly logger: LoggerLike = console,
|
|
72
77
|
) {
|
|
73
78
|
this.disposeEvent = ctx.on('session/event', (session, event) => this.onSessionEvent(session, event))
|
|
74
|
-
this.disposeBootstrapGate = ctx.on('agent/pre-step', async ({ messages }, next): Promise<AgentPreStepDecision> => {
|
|
75
|
-
if (messages.some(message => message.source.kind === 'qq-chat-bootstrap')) return { kind: 'reject' }
|
|
76
|
-
return next()
|
|
77
|
-
})
|
|
78
79
|
this.disposeToolGate = ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
|
79
80
|
const agent = exec.agent
|
|
80
81
|
if (!agent) return next()
|
|
@@ -99,7 +100,6 @@ export class DshQQBridge {
|
|
|
99
100
|
|
|
100
101
|
async dispose(): Promise<void> {
|
|
101
102
|
this.disposeEvent()
|
|
102
|
-
this.disposeBootstrapGate()
|
|
103
103
|
this.disposeToolGate()
|
|
104
104
|
this.disposeImageTool()
|
|
105
105
|
this.disposeMediaTools()
|
|
@@ -149,6 +149,16 @@ export class DshQQBridge {
|
|
|
149
149
|
const commandText = qqCommandText(message.text, message.mentioned)
|
|
150
150
|
if (commandText !== undefined) {
|
|
151
151
|
if (!commands) return '当前 DSH profile 未加载命令系统,请重启并确认使用了最新插件。'
|
|
152
|
+
// Preset discovery/selection is a pre-session operation. Running it
|
|
153
|
+
// through DSH's native command executor would append command/run and
|
|
154
|
+
// make the blank Session ineligible for the very preset it lists.
|
|
155
|
+
// Keep the native registration for Web, but handle QQ directly.
|
|
156
|
+
if (slashCommandName(commandText) === 'qqpreset' && (message.chatType !== 'group' || isOwner)) {
|
|
157
|
+
const prefix = '/qqpreset'
|
|
158
|
+
const result = await this.presetCommand(agent, commandText.slice(prefix.length).trim())
|
|
159
|
+
if (this.pendingResets.delete(String(sessionId))) await this.resetSession(message.chatType, row)
|
|
160
|
+
return result.text || ''
|
|
161
|
+
}
|
|
152
162
|
const command = await dispatchQQCommand(commands, agent, commandText, { chatType: message.chatType, isOwner })
|
|
153
163
|
if (command.handled) {
|
|
154
164
|
if (this.pendingResets.delete(String(sessionId))) await this.resetSession(message.chatType, row)
|
|
@@ -286,7 +296,6 @@ export class DshQQBridge {
|
|
|
286
296
|
this.ensureSelection(live)
|
|
287
297
|
this.ensureTitle(live.session, chatType, row)
|
|
288
298
|
this.rememberRoute(chatType, row, live, sessionId)
|
|
289
|
-
await this.ensureVisible(live)
|
|
290
299
|
return { agent: live, sessionId }
|
|
291
300
|
}
|
|
292
301
|
const resumed = await this.tryResume(sessionId)
|
|
@@ -294,7 +303,6 @@ export class DshQQBridge {
|
|
|
294
303
|
this.ensureSelection(resumed.agent)
|
|
295
304
|
this.ensureTitle(resumed.agent.session, chatType, row)
|
|
296
305
|
this.rememberRoute(chatType, row, resumed.agent, sessionId)
|
|
297
|
-
await this.ensureVisible(resumed.agent)
|
|
298
306
|
return { agent: resumed.agent, sessionId }
|
|
299
307
|
}
|
|
300
308
|
}
|
|
@@ -314,7 +322,6 @@ export class DshQQBridge {
|
|
|
314
322
|
this.db.setChatSession(chatType, Number(row.id), sessionId)
|
|
315
323
|
this.ensureTitle(handle.agent.session, chatType, row)
|
|
316
324
|
this.rememberRoute(chatType, row, handle.agent, sessionId)
|
|
317
|
-
await this.ensureVisible(handle.agent)
|
|
318
325
|
return { agent: handle.agent, sessionId }
|
|
319
326
|
}
|
|
320
327
|
|
|
@@ -331,15 +338,9 @@ export class DshQQBridge {
|
|
|
331
338
|
else this.memory.setMemberRoute(Number((row as MemberRow).id), provider, model, sessionId)
|
|
332
339
|
}
|
|
333
340
|
|
|
334
|
-
private async ensureVisible(agent: AgentHandle['agent']): Promise<void> {
|
|
335
|
-
if (agent.session.events.some(event => event.type === 'turn/start')) return
|
|
336
|
-
agent.followup(createUserMessage({ source: { kind: 'qq-chat-bootstrap', plugin: 'dsh-qqchat' }, content: [{ type: 'text', text: 'Activate QQ Chat workspace session.' }] }))
|
|
337
|
-
await agent.whenIdle()
|
|
338
|
-
}
|
|
339
|
-
|
|
340
341
|
private appendDisplayIfMissing(session: Session, event: QQChatDisplayEvent): void {
|
|
341
342
|
if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId)) return
|
|
342
|
-
session.append('qqchat/message', displayEventPayload(event))
|
|
343
|
+
session.append('qqchat/message', displayEventPayload(event), { ignorable: true })
|
|
343
344
|
}
|
|
344
345
|
|
|
345
346
|
private appendOwnerMessageIfMissing(session: Session, event: QQChatDisplayEvent): void {
|
|
@@ -375,7 +376,7 @@ export class DshQQBridge {
|
|
|
375
376
|
|
|
376
377
|
private async tryResume(sessionId: string): Promise<AgentHandle | undefined> {
|
|
377
378
|
try {
|
|
378
|
-
const composition = await this.composition()
|
|
379
|
+
const composition = await this.composition(sessionId)
|
|
379
380
|
const handle = await this.ctx.agents.resume({ resumeSessionId: SessionId(sessionId), agentOptions: this.agentOptions(), setup: composition.setup })
|
|
380
381
|
if (!handle.agent.session.header.cwd) {
|
|
381
382
|
await handle.dispose()
|
|
@@ -458,7 +459,7 @@ export class DshQQBridge {
|
|
|
458
459
|
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
459
460
|
'',
|
|
460
461
|
'QQChat 会话',
|
|
461
|
-
...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqstop', 'qqstatus'].includes(command.name))
|
|
462
|
+
...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqpreset', 'qqstop', 'qqstatus'].includes(command.name))
|
|
462
463
|
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
463
464
|
'',
|
|
464
465
|
'QQChat 工具',
|
|
@@ -477,6 +478,7 @@ export class DshQQBridge {
|
|
|
477
478
|
register('qqreset', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' } }),
|
|
478
479
|
register('qqclear', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' } }),
|
|
479
480
|
register('qqmodel', '查看或切换当前会话模型', invocation => this.modelCommand(invocation), '[provider/]model'),
|
|
481
|
+
register('qqpreset', '查看或切换当前 Session 的 Agent Preset', invocation => this.presetCommand(invocation.agent as AgentHandle['agent'], invocation.rawInput.trim()), 'preset'),
|
|
480
482
|
register('qqstop', '中止当前生成', invocation => {
|
|
481
483
|
if (invocation.agent.status !== 'running') return { kind: 'success', text: '当前没有进行中的生成。' }
|
|
482
484
|
invocation.agent.cancel({ kind: 'user' })
|
|
@@ -519,6 +521,30 @@ export class DshQQBridge {
|
|
|
519
521
|
return { kind: 'success', text: `模型已切换:${provider}/${model}\n对话上下文保留,下一轮生效。` }
|
|
520
522
|
}
|
|
521
523
|
|
|
524
|
+
private async presetCommand(agent: AgentHandle['agent'], requested: string): Promise<CommandResult> {
|
|
525
|
+
const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
|
|
526
|
+
const presets = getService?.call(this.ctx, 'agentPresets') as AgentPresetService | undefined
|
|
527
|
+
if (!presets) return { kind: 'error', text: '当前 DSH profile 未加载 Agent Preset 系统。' }
|
|
528
|
+
if (!requested) {
|
|
529
|
+
const available = await presets.list()
|
|
530
|
+
return { kind: 'success', text: available.length ? `可用 Agent Preset:\n${available.map(preset => `/qqpreset ${preset.id}`).join('\n')}` : '当前没有可用 Agent Preset。' }
|
|
531
|
+
}
|
|
532
|
+
if (agent.session.events.some(event => event.type === 'turn/start')) {
|
|
533
|
+
return { kind: 'error', text: '当前 Session 已经开始对话,DSH 不允许更换 Agent Preset。请先使用 /qqnew 开启新 Session。' }
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const preset = await presets.recompose(agent.ctx, requested)
|
|
537
|
+
agent.session.append('agent-preset/selected', { agentPreset: preset.id })
|
|
538
|
+
return { kind: 'success', text: `当前 Session 已切换到 Agent Preset:${preset.id}` }
|
|
539
|
+
} catch {
|
|
540
|
+
const available = await presets.list().catch(() => [])
|
|
541
|
+
const availableText = available.length
|
|
542
|
+
? `当前可用:${available.map(preset => preset.id).join('、')}`
|
|
543
|
+
: '当前没有可用 Agent Preset。'
|
|
544
|
+
return { kind: 'error', text: `无法切换 Agent Preset「${requested}」:该 preset 不存在、已删除或格式无效。\n${availableText}` }
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
522
548
|
private statusCommand(agent: AgentHandle['agent']): CommandResult {
|
|
523
549
|
const current = this.selection(agent)?.current
|
|
524
550
|
const messages = agent.session.events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length
|
|
@@ -529,14 +555,38 @@ export class DshQQBridge {
|
|
|
529
555
|
}
|
|
530
556
|
}
|
|
531
557
|
|
|
532
|
-
private async composition(): Promise<Composition> {
|
|
558
|
+
private async composition(sessionId?: string): Promise<Composition> {
|
|
533
559
|
const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
|
|
534
560
|
const presets = getService?.call(this.ctx, 'agentPresets') as AgentPresetService | undefined
|
|
535
561
|
if (!presets) return {}
|
|
536
|
-
const
|
|
562
|
+
const persisted = sessionId ? await this.persistedPreset(sessionId) : undefined
|
|
563
|
+
const requested = persisted || this.config.agentPreset
|
|
564
|
+
let preset: AgentPreset
|
|
565
|
+
try {
|
|
566
|
+
preset = await presets.resolve(requested)
|
|
567
|
+
} catch (error) {
|
|
568
|
+
const available = await presets.list()
|
|
569
|
+
const fallback = available[0]
|
|
570
|
+
if (!fallback) throw error
|
|
571
|
+
this.logger.warn?.(`[dsh-qqchat] Agent Preset「${requested || '默认'}」不可用,已降级到「${fallback.id}」`)
|
|
572
|
+
preset = await presets.resolve(fallback.id)
|
|
573
|
+
}
|
|
537
574
|
return { presetId: preset.id, setup: async agentCtx => { await presets.mount(agentCtx, preset.id) } }
|
|
538
575
|
}
|
|
539
576
|
|
|
577
|
+
private async persistedPreset(sessionId: string): Promise<string | undefined> {
|
|
578
|
+
const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
|
|
579
|
+
const persistence = getService?.call(this.ctx, 'sessionPersistence') as SessionPersistenceService | undefined
|
|
580
|
+
if (!persistence) return undefined
|
|
581
|
+
try {
|
|
582
|
+
const inspection = await persistence.inspect(SessionId(sessionId))
|
|
583
|
+
return resolveQQSessionPreset(inspection.meta, inspection.events)
|
|
584
|
+
} catch (error) {
|
|
585
|
+
this.logger.debug?.(`[dsh-qqchat] persisted preset lookup skipped: ${error instanceof Error ? error.message : String(error)}`)
|
|
586
|
+
return undefined
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
540
590
|
private serial<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
541
591
|
const previous = this.locks.get(key) || Promise.resolve()
|
|
542
592
|
const current = previous.catch(() => undefined).then(task)
|
package/src/session/runtime.ts
CHANGED
|
@@ -147,6 +147,9 @@ export class QQChatRuntime {
|
|
|
147
147
|
if (!account) return
|
|
148
148
|
const id = Number(account.id)
|
|
149
149
|
if (this.gateways.has(id)) return
|
|
150
|
+
// QR binding can replace the secret for an existing account id. Never
|
|
151
|
+
// carry a token obtained with the previous secret into the new Gateway.
|
|
152
|
+
this.api.clearToken(id)
|
|
150
153
|
const gateway = new QQGateway(account, this.db, this.api, message => this.onIncoming(message), this.logger)
|
|
151
154
|
this.gateways.set(id, gateway)
|
|
152
155
|
gateway.start()
|
|
@@ -160,6 +163,27 @@ export class QQChatRuntime {
|
|
|
160
163
|
if (account?.enabled) this.startGateway(account)
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
async connectManual(appId: string, appSecret: string, sandbox = this.config.sandbox): Promise<AccountRow> {
|
|
167
|
+
const normalizedAppId = appId.trim()
|
|
168
|
+
const normalizedSecret = appSecret.trim()
|
|
169
|
+
if (!normalizedAppId || !normalizedSecret) throw new Error('AppID 和 AppSecret 不能为空')
|
|
170
|
+
if (normalizedAppId.length > 256 || normalizedSecret.length > 512) throw new Error('QQ Bot 凭据长度无效')
|
|
171
|
+
const existing = this.db.accountByAppId(normalizedAppId)
|
|
172
|
+
const candidate = {
|
|
173
|
+
...(existing || { id: 0, bot_user_id: null, enabled: 1 as const, gateway_status: 'offline' as const, gateway_last_error: null, created_at: 0, updated_at: 0 }),
|
|
174
|
+
app_id: normalizedAppId, app_secret: normalizedSecret, sandbox: sandbox ? 1 as const : 0 as const,
|
|
175
|
+
}
|
|
176
|
+
this.api.clearToken(Number(candidate.id))
|
|
177
|
+
try {
|
|
178
|
+
await this.api.gatewayUrl(candidate)
|
|
179
|
+
} catch {
|
|
180
|
+
throw new Error('QQ Bot 凭据验证失败,请检查 AppID、AppSecret 和沙箱环境设置')
|
|
181
|
+
}
|
|
182
|
+
const account = this.db.upsertAccount(normalizedAppId, normalizedSecret, sandbox)
|
|
183
|
+
await this.restartGateway(Number(account.id))
|
|
184
|
+
return account
|
|
185
|
+
}
|
|
186
|
+
|
|
163
187
|
private async onIncoming(message: QQNormalizedMessage): Promise<void> {
|
|
164
188
|
const account = this.db.accountById(message.accountId)
|
|
165
189
|
if (!account || !account.enabled) return
|