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.
@@ -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 preset = await presets.resolve(this.config.agentPreset);
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);
@@ -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;
@@ -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)
@@ -25,10 +25,6 @@ declare module '@deepseek-ai/dsh-llm' {
25
25
  form: 'notice';
26
26
  summary: string;
27
27
  };
28
- 'qq-chat-bootstrap': {
29
- kind: 'qq-chat-bootstrap';
30
- plugin: 'dsh-qqchat';
31
- };
32
28
  }
33
29
  }
34
30
  declare module '@deepseek-ai/dsh-session/types' {
@@ -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.0",
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",
@@ -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
@@ -71,7 +71,11 @@ export class QQGateway {
71
71
  }
72
72
 
73
73
  private async connectOnce(): Promise<void> {
74
- const [url, token] = await Promise.all([this.api.gatewayUrl(this.account), this.api.token(this.account)])
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, PreStepDecision as AgentPreStepDecision } from '@deepseek-ai/dsh-agent'
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 preset = await presets.resolve(this.config.agentPreset)
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)
@@ -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
@@ -28,10 +28,6 @@ declare module '@deepseek-ai/dsh-llm' {
28
28
  form: 'notice'
29
29
  summary: string
30
30
  }
31
- 'qq-chat-bootstrap': {
32
- kind: 'qq-chat-bootstrap'
33
- plugin: 'dsh-qqchat'
34
- }
35
31
  }
36
32
  }
37
33