dsh-qqchat 0.2.1 → 0.2.3

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,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();
@@ -107,11 +104,12 @@ export class DshQQBridge {
107
104
  const { agent, sessionId } = await this.ensureAgent(event.chatType, row);
108
105
  event.sessionId = sessionId;
109
106
  if (event.isOwner) {
110
- const hasImages = event.attachments?.some(attachment => attachment.kind === 'image' && this.db.attachmentById(attachment.id)?.imageRef) === true;
111
- if (!hasImages || await this.imageRouteAvailable(agent))
112
- this.appendOwnerMessageIfMissing(agent.session, event);
113
- else
114
- this.appendDisplayIfMissing(agent.session, event);
107
+ // Keep owner messages as native user/message events even when they carry
108
+ // images. DSH now owns the image projection: visual routes receive the
109
+ // durable image block, while text-only routes receive its stable text
110
+ // placeholder. Falling back to a display-only event here would discard
111
+ // the owner's message semantics before DSH can apply that policy.
112
+ this.appendOwnerMessageIfMissing(agent.session, event);
115
113
  return sessionId;
116
114
  }
117
115
  this.appendDisplayIfMissing(agent.session, event);
@@ -131,6 +129,17 @@ export class DshQQBridge {
131
129
  if (commandText !== undefined) {
132
130
  if (!commands)
133
131
  return '当前 DSH profile 未加载命令系统,请重启并确认使用了最新插件。';
132
+ // Preset discovery/selection is a pre-session operation. Running it
133
+ // through DSH's native command executor would append command/run and
134
+ // make the blank Session ineligible for the very preset it lists.
135
+ // Keep the native registration for Web, but handle QQ directly.
136
+ if (slashCommandName(commandText) === 'qqpreset' && (message.chatType !== 'group' || isOwner)) {
137
+ const prefix = '/qqpreset';
138
+ const result = await this.presetCommand(agent, commandText.slice(prefix.length).trim());
139
+ if (this.pendingResets.delete(String(sessionId)))
140
+ await this.resetSession(message.chatType, row);
141
+ return result.text || '';
142
+ }
134
143
  const command = await dispatchQQCommand(commands, agent, commandText, { chatType: message.chatType, isOwner });
135
144
  if (command.handled) {
136
145
  if (this.pendingResets.delete(String(sessionId)))
@@ -149,15 +158,7 @@ export class DshQQBridge {
149
158
  : this.memory.contextForMember(member);
150
159
  this.injectMemorySnapshot(agent, sessionId, contextText);
151
160
  }
152
- const currentText = this.memory.currentMessageText(message) + mediaPrompt(attachments);
153
- const currentContent = [{ type: 'text', text: currentText }];
154
- if (attachments.length && await this.imageRouteAvailable(agent)) {
155
- for (const attachment of attachments) {
156
- if (attachment.kind === 'image' && attachment.imageRef) {
157
- currentContent.push({ type: 'image', attachment: attachment.imageRef });
158
- }
159
- }
160
- }
161
+ const currentContent = qqMessageContent(this.memory.currentMessageText(message), attachments);
161
162
  const current = createUserMessage({
162
163
  source: {
163
164
  kind: 'qq-chat', botId: String(message.accountId), chatType: message.chatType,
@@ -273,7 +274,6 @@ export class DshQQBridge {
273
274
  this.ensureSelection(live);
274
275
  this.ensureTitle(live.session, chatType, row);
275
276
  this.rememberRoute(chatType, row, live, sessionId);
276
- await this.ensureVisible(live);
277
277
  return { agent: live, sessionId };
278
278
  }
279
279
  const resumed = await this.tryResume(sessionId);
@@ -281,7 +281,6 @@ export class DshQQBridge {
281
281
  this.ensureSelection(resumed.agent);
282
282
  this.ensureTitle(resumed.agent.session, chatType, row);
283
283
  this.rememberRoute(chatType, row, resumed.agent, sessionId);
284
- await this.ensureVisible(resumed.agent);
285
284
  return { agent: resumed.agent, sessionId };
286
285
  }
287
286
  }
@@ -300,7 +299,6 @@ export class DshQQBridge {
300
299
  this.db.setChatSession(chatType, Number(row.id), sessionId);
301
300
  this.ensureTitle(handle.agent.session, chatType, row);
302
301
  this.rememberRoute(chatType, row, handle.agent, sessionId);
303
- await this.ensureVisible(handle.agent);
304
302
  return { agent: handle.agent, sessionId };
305
303
  }
306
304
  isArchived(sessionId) {
@@ -317,15 +315,13 @@ export class DshQQBridge {
317
315
  else
318
316
  this.memory.setMemberRoute(Number(row.id), provider, model, sessionId);
319
317
  }
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
318
  appendDisplayIfMissing(session, event) {
327
319
  if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId))
328
320
  return;
321
+ // Keep this on the public Session.append signature. Official DSH releases
322
+ // do not yet expose the optional ignorable envelope marker; if an older
323
+ // DSH cannot restore this plugin-only event, ensureAgent falls back to a
324
+ // fresh Session while SQLite remains the QQ history source of truth.
329
325
  session.append('qqchat/message', displayEventPayload(event));
330
326
  }
331
327
  appendOwnerMessageIfMissing(session, event) {
@@ -363,7 +359,7 @@ export class DshQQBridge {
363
359
  }
364
360
  async tryResume(sessionId) {
365
361
  try {
366
- const composition = await this.composition();
362
+ const composition = await this.composition(sessionId);
367
363
  const handle = await this.ctx.agents.resume({ resumeSessionId: SessionId(sessionId), agentOptions: this.agentOptions(), setup: composition.setup });
368
364
  if (!handle.agent.session.header.cwd) {
369
365
  await handle.dispose();
@@ -388,18 +384,6 @@ export class DshQQBridge {
388
384
  options.maxTokens = this.config.maxTokens;
389
385
  return options;
390
386
  }
391
- async imageRouteAvailable(agent) {
392
- const route = this.selection(agent)?.current || { provider: agent.options.provider, model: agent.options.model };
393
- if (!route.provider || !route.model)
394
- return false;
395
- try {
396
- const info = await this.ctx.llm.resolveModelInfo(route.provider, route.model);
397
- return info.inputModalities?.includes('image') === true;
398
- }
399
- catch {
400
- return false;
401
- }
402
- }
403
387
  ensureSelection(agent) {
404
388
  const id = String(agent.id);
405
389
  const existing = this.selections.get(id);
@@ -451,7 +435,7 @@ export class DshQQBridge {
451
435
  .map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
452
436
  '',
453
437
  'QQChat 会话',
454
- ...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqstop', 'qqstatus'].includes(command.name))
438
+ ...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqpreset', 'qqstop', 'qqstatus'].includes(command.name))
455
439
  .map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
456
440
  '',
457
441
  'QQChat 工具',
@@ -468,6 +452,7 @@ export class DshQQBridge {
468
452
  register('qqreset', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' }; }),
469
453
  register('qqclear', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' }; }),
470
454
  register('qqmodel', '查看或切换当前会话模型', invocation => this.modelCommand(invocation), '[provider/]model'),
455
+ register('qqpreset', '查看或切换当前 Session 的 Agent Preset', invocation => this.presetCommand(invocation.agent, invocation.rawInput.trim()), 'preset'),
471
456
  register('qqstop', '中止当前生成', invocation => {
472
457
  if (invocation.agent.status !== 'running')
473
458
  return { kind: 'success', text: '当前没有进行中的生成。' };
@@ -512,6 +497,31 @@ export class DshQQBridge {
512
497
  this.routes.set(String(agent.id), { provider, model });
513
498
  return { kind: 'success', text: `模型已切换:${provider}/${model}\n对话上下文保留,下一轮生效。` };
514
499
  }
500
+ async presetCommand(agent, requested) {
501
+ const getService = this.ctx.get;
502
+ const presets = getService?.call(this.ctx, 'agentPresets');
503
+ if (!presets)
504
+ return { kind: 'error', text: '当前 DSH profile 未加载 Agent Preset 系统。' };
505
+ if (!requested) {
506
+ const available = await presets.list();
507
+ return { kind: 'success', text: available.length ? `可用 Agent Preset:\n${available.map(preset => `/qqpreset ${preset.id}`).join('\n')}` : '当前没有可用 Agent Preset。' };
508
+ }
509
+ if (agent.session.events.some(event => event.type === 'turn/start')) {
510
+ return { kind: 'error', text: '当前 Session 已经开始对话,DSH 不允许更换 Agent Preset。请先使用 /qqnew 开启新 Session。' };
511
+ }
512
+ try {
513
+ const preset = await presets.recompose(agent.ctx, requested);
514
+ agent.session.append('agent-preset/selected', { agentPreset: preset.id });
515
+ return { kind: 'success', text: `当前 Session 已切换到 Agent Preset:${preset.id}` };
516
+ }
517
+ catch {
518
+ const available = await presets.list().catch(() => []);
519
+ const availableText = available.length
520
+ ? `当前可用:${available.map(preset => preset.id).join('、')}`
521
+ : '当前没有可用 Agent Preset。';
522
+ return { kind: 'error', text: `无法切换 Agent Preset「${requested}」:该 preset 不存在、已删除或格式无效。\n${availableText}` };
523
+ }
524
+ }
515
525
  statusCommand(agent) {
516
526
  const current = this.selection(agent)?.current;
517
527
  const messages = agent.session.events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length;
@@ -521,14 +531,41 @@ export class DshQQBridge {
521
531
  text: ['📊 Session 状态', `Session:${String(agent.id)}`, `状态:${agent.status === 'running' ? '生成中' : '空闲'}`, `模型:${current ? `${current.provider}/${current.model}` : '未知'}`, `消息数:${messages}`, `最后事件:${last?.type || '无'}`].join('\n'),
522
532
  };
523
533
  }
524
- async composition() {
534
+ async composition(sessionId) {
525
535
  const getService = this.ctx.get;
526
536
  const presets = getService?.call(this.ctx, 'agentPresets');
527
537
  if (!presets)
528
538
  return {};
529
- const preset = await presets.resolve(this.config.agentPreset);
539
+ const persisted = sessionId ? await this.persistedPreset(sessionId) : undefined;
540
+ const requested = persisted || this.config.agentPreset;
541
+ let preset;
542
+ try {
543
+ preset = await presets.resolve(requested);
544
+ }
545
+ catch (error) {
546
+ const available = await presets.list();
547
+ const fallback = available[0];
548
+ if (!fallback)
549
+ throw error;
550
+ this.logger.warn?.(`[dsh-qqchat] Agent Preset「${requested || '默认'}」不可用,已降级到「${fallback.id}」`);
551
+ preset = await presets.resolve(fallback.id);
552
+ }
530
553
  return { presetId: preset.id, setup: async (agentCtx) => { await presets.mount(agentCtx, preset.id); } };
531
554
  }
555
+ async persistedPreset(sessionId) {
556
+ const getService = this.ctx.get;
557
+ const persistence = getService?.call(this.ctx, 'sessionPersistence');
558
+ if (!persistence)
559
+ return undefined;
560
+ try {
561
+ const inspection = await persistence.inspect(SessionId(sessionId));
562
+ return resolveQQSessionPreset(inspection.meta, inspection.events);
563
+ }
564
+ catch (error) {
565
+ this.logger.debug?.(`[dsh-qqchat] persisted preset lookup skipped: ${error instanceof Error ? error.message : String(error)}`);
566
+ return undefined;
567
+ }
568
+ }
532
569
  serial(key, task) {
533
570
  const previous = this.locks.get(key) || Promise.resolve();
534
571
  const current = previous.catch(() => undefined).then(task);
@@ -541,7 +578,17 @@ function mediaPrompt(attachments) {
541
578
  if (!attachments.length)
542
579
  return '';
543
580
  const lines = attachments.map(item => `- ${item.kind}: ${item.filename} (attachment_id=${item.id}${item.quoted ? ', 引用消息附件' : ''})`);
544
- return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片可使用 qqchat_describe_image 查看。`;
581
+ return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片由 DSH 根据当前模型能力处理;视觉模型可直接理解图片。`;
582
+ }
583
+ /** Build the durable DSH content for one QQ turn without pre-flighting the model. */
584
+ export function qqMessageContent(text, attachments) {
585
+ const content = [{ type: 'text', text: text + mediaPrompt([...attachments]) }];
586
+ for (const attachment of attachments) {
587
+ if (attachment.kind === 'image' && attachment.imageRef) {
588
+ content.push({ type: 'image', attachment: attachment.imageRef });
589
+ }
590
+ }
591
+ return content;
545
592
  }
546
593
  function transcriptContent(event, db) {
547
594
  const attachments = event.attachments || [];
@@ -0,0 +1,14 @@
1
+ export interface SessionEventRegistrationOptions {
2
+ readonly argv?: readonly string[];
3
+ readonly dshHome?: string;
4
+ readonly anchors?: readonly string[];
5
+ }
6
+ export declare function profileNameFromArgv(argv: readonly string[]): string | undefined;
7
+ /**
8
+ * Register the plugin's log-only event in every reachable dsh-session copy.
9
+ *
10
+ * DSH does not expose a public downstream event registration API yet. This
11
+ * small compatibility shim follows the ecosystem convention used by other
12
+ * DSH plugins and is intentionally idempotent and best-effort.
13
+ */
14
+ export declare function registerQQChatSessionEventType(options?: SessionEventRegistrationOptions): Promise<number>;
@@ -0,0 +1,94 @@
1
+ import { createRequire } from 'node:module';
2
+ import { realpathSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ const QQCHAT_SESSION_EVENT_TYPE = 'qqchat/message';
7
+ export function profileNameFromArgv(argv) {
8
+ const profileIndex = argv.indexOf('--profile');
9
+ if (profileIndex >= 0) {
10
+ const value = argv[profileIndex + 1];
11
+ if (value && !value.startsWith('-'))
12
+ return value;
13
+ }
14
+ const inline = argv.find(value => value.startsWith('--profile='));
15
+ const value = inline?.slice('--profile='.length);
16
+ return value && !value.startsWith('-') ? value : undefined;
17
+ }
18
+ /**
19
+ * Register the plugin's log-only event in every reachable dsh-session copy.
20
+ *
21
+ * DSH does not expose a public downstream event registration API yet. This
22
+ * small compatibility shim follows the ecosystem convention used by other
23
+ * DSH plugins and is intentionally idempotent and best-effort.
24
+ */
25
+ export async function registerQQChatSessionEventType(options = {}) {
26
+ const argv = options.argv || process.argv;
27
+ const profileName = profileNameFromArgv(argv);
28
+ const dshHome = options.dshHome || process.env.DSH_HOME || join(homedir(), '.dsh');
29
+ const profileAnchors = profileName
30
+ ? [
31
+ join(dshHome, 'profiles', profileName, 'package.json'),
32
+ join(dshHome, 'profiles', 'package.json'),
33
+ ]
34
+ : [];
35
+ const anchors = [...(options.anchors || [import.meta.url, argv[1]]), ...profileAnchors].filter((anchor) => typeof anchor === 'string' && anchor.length > 0);
36
+ const registeredPaths = new Set();
37
+ let registered = 0;
38
+ for (const anchor of anchors) {
39
+ try {
40
+ const require = createRequire(anchor);
41
+ registered += await registerSessionCopy(require, registeredPaths);
42
+ // Persistence may resolve its own peer dependency from a different
43
+ // package tree. Register the copy selected by that validator too.
44
+ try {
45
+ const persistence = require.resolve('@deepseek-ai/dsh-session-persistence');
46
+ registered += await registerSessionCopy(createRequire(persistence), registeredPaths);
47
+ }
48
+ catch {
49
+ // Some minimal profiles do not ship the persistence package.
50
+ }
51
+ }
52
+ catch {
53
+ // A profile may not expose this package from one of the anchors. The
54
+ // other anchor can still reach the validator used by the running host.
55
+ }
56
+ }
57
+ return registered;
58
+ }
59
+ async function registerSessionCopy(require, registeredPaths) {
60
+ const resolved = require.resolve('@deepseek-ai/dsh-session');
61
+ const candidates = [resolved];
62
+ // The DSH development CLI can resolve workspace source through tsx while
63
+ // the published package resolves lib/. Register both when source exists.
64
+ const packageRoot = dirname(dirname(resolved));
65
+ candidates.push(join(packageRoot, 'src', 'index.ts'));
66
+ candidates.push(join(packageRoot, 'src', 'index.js'));
67
+ let registered = 0;
68
+ for (const candidate of candidates) {
69
+ let physical;
70
+ try {
71
+ physical = realpathSync(candidate);
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ if (registeredPaths.has(physical))
77
+ continue;
78
+ registeredPaths.add(physical);
79
+ try {
80
+ // DSH publishes ESM builds. Dynamic import also handles a CJS build and
81
+ // preserves the actual module instance selected by the host anchor.
82
+ const module = await import(pathToFileURL(physical).href);
83
+ if (module.KNOWN_SESSION_EVENT_TYPES?.add) {
84
+ module.KNOWN_SESSION_EVENT_TYPES.add(QQCHAT_SESSION_EVENT_TYPE);
85
+ registered += 1;
86
+ }
87
+ }
88
+ catch {
89
+ // A published package may not ship its source entry; the lib entry above
90
+ // remains the normal registration target.
91
+ }
92
+ }
93
+ return registered;
94
+ }
@@ -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();
@@ -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' {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-qqchat",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "DeepSeek Harness 的 QQ 私聊/群聊插件,支持扫码授权、原生 Session、群记忆与工具权限。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,16 +46,18 @@
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@deepseek-ai/cordis": "^4.0.1",
49
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
50
- "@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
51
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8",
52
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
53
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.8",
54
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.8",
55
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
56
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
57
- "@deepseek-ai/dsh-session": "^0.1.0-rc.8",
58
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.8"
49
+ "@deepseek-ai/dsh-attachment": "^0.1.1-rc.2",
50
+ "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
51
+ "@deepseek-ai/dsh-agent-presets": "^0.1.1-rc.2",
52
+ "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
53
+ "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
54
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
55
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
56
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
57
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
58
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
59
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
60
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
59
61
  },
60
62
  "peerDependenciesMeta": {
61
63
  "@deepseek-ai/dsh-client-connection": {
@@ -72,9 +74,11 @@
72
74
  }
73
75
  },
74
76
  "devDependencies": {
75
- "@deepseek-ai/dsh-agent-default-model": "^0.1.0-rc.8",
76
- "@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
77
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8",
77
+ "@deepseek-ai/dsh-attachment": "^0.1.1-rc.2",
78
+ "@deepseek-ai/dsh-agent-default-model": "^0.1.1-rc.2",
79
+ "@deepseek-ai/dsh-agent-presets": "^0.1.1-rc.2",
80
+ "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
81
+ "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
78
82
  "@types/node": "^22.18.0",
79
83
  "@types/qrcode": "^1.5.5",
80
84
  "@types/react": "^19.1.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
package/src/index.ts CHANGED
@@ -14,8 +14,11 @@ import { QQApiClient } from './gateway/api.js'
14
14
  import { QQBindService } from './gateway/auth.js'
15
15
  import { createQQChatRpc } from './transport/rpc.js'
16
16
  import { QQChatRuntime } from './session/runtime.js'
17
+ import { registerQQChatSessionEventType } from './session/registration.js'
17
18
  import type { LoggerLike, QQChatConfigInput } from './types.js'
18
19
 
20
+ const qqChatSessionEventRegistrations = await registerQQChatSessionEventType()
21
+
19
22
  export const name = 'dsh-qqchat'
20
23
  export const inject = ['connection', 'agents', 'agentDefaultModel', 'commands', 'llm', 'tools', 'workspaceRegistry'] as const
21
24
 
@@ -25,6 +28,9 @@ export function apply(ctx: Context, inputConfig: QQChatConfigInput = {}): void {
25
28
  const db = new QQChatDatabase(join(config.dataDir, 'qqchat.sqlite'))
26
29
  const baseLogger = (ctx as unknown as { logger?: LoggerLike }).logger || console
27
30
  const logger = new QQChatLogger(baseLogger)
31
+ if (qqChatSessionEventRegistrations === 0) {
32
+ logger.warn?.('[dsh-qqchat] 未找到可注册的 DSH Session 事件表,旧 QQChat Session 恢复可能需要新建 Session')
33
+ }
28
34
  const api = new QQApiClient(db, config)
29
35
  const auth = new QQBindService(db, config)
30
36
  const memory = new MemoryEngine(ctx, db, config, logger)
@@ -2,11 +2,11 @@ import type { Context } from '@deepseek-ai/cordis'
2
2
  import { defineTool } from '@deepseek-ai/dsh-tools'
3
3
  import type { QQChatDatabase } from '../storage/db.js'
4
4
 
5
- /** Exposes stored QQ images through DSH's durable image-block seam. */
5
+ /** Exposes stored QQ images by attachment ID when a model explicitly requests one. */
6
6
  export function registerQQImageTool(ctx: Context, db: QQChatDatabase, isAllowed: (agentId: string, attachmentId: string) => boolean = () => true): () => void {
7
7
  const definition = defineTool({
8
8
  name: 'qqchat_describe_image',
9
- description: '查看 QQ 消息中的图片。传入 QQChat 提供的 attachment_id,可用于 OCR、截图、界面和照片分析。',
9
+ description: '按 attachment_id 主动重新读取 QQ 消息中的图片。正常图片消息由 DSH 原生视觉输入链路直接处理;本工具用于模型需要再次读取指定图片的场景。',
10
10
  parameters: {
11
11
  attachment_id: { type: 'string', required: true, description: 'QQChat 图片附件 ID' },
12
12
  },