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.
- package/README.en.md +24 -14
- package/README.md +18 -7
- package/client-src/settings.cts +1 -1
- package/docs/ARCHITECTURE.en.md +5 -2
- package/docs/ARCHITECTURE.md +7 -7
- package/docs/DEVELOPMENT.md +26 -1
- package/docs/MEDIA_AND_QUOTES.md +20 -20
- package/docs/STREAMING.md +22 -19
- package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
- package/lib/client.js +1 -1
- package/lib/gateway/api.d.ts +1 -1
- package/lib/gateway/api.js +5 -1
- package/lib/gateway/gateway.js +5 -1
- package/lib/index.js +5 -0
- package/lib/media/image-tool.d.ts +1 -1
- package/lib/media/image-tool.js +2 -2
- package/lib/session/agent-bridge.d.ts +7 -3
- package/lib/session/agent-bridge.js +95 -48
- package/lib/session/registration.d.ts +14 -0
- package/lib/session/registration.js +94 -0
- package/lib/session/runtime.js +3 -0
- package/lib/shared/augmentations.d.ts +0 -4
- package/package.json +18 -14
- package/src/gateway/api.ts +5 -1
- package/src/gateway/gateway.ts +5 -1
- package/src/index.ts +6 -0
- package/src/media/image-tool.ts +2 -2
- package/src/session/agent-bridge.ts +95 -44
- package/src/session/registration.ts +102 -0
- package/src/session/runtime.ts +3 -0
- package/src/shared/augmentations.ts +0 -4
|
@@ -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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
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();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-qqchat",
|
|
3
|
-
"version": "0.2.
|
|
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-
|
|
50
|
-
"@deepseek-ai/dsh-
|
|
51
|
-
"@deepseek-ai/dsh-
|
|
52
|
-
"@deepseek-ai/dsh-
|
|
53
|
-
"@deepseek-ai/dsh-client-
|
|
54
|
-
"@deepseek-ai/dsh-client-
|
|
55
|
-
"@deepseek-ai/dsh-client-ui-
|
|
56
|
-
"@deepseek-ai/dsh-
|
|
57
|
-
"@deepseek-ai/dsh-
|
|
58
|
-
"@deepseek-ai/dsh-
|
|
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-
|
|
76
|
-
"@deepseek-ai/dsh-
|
|
77
|
-
"@deepseek-ai/dsh-
|
|
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",
|
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
|
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)
|
package/src/media/image-tool.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
9
|
+
description: '按 attachment_id 主动重新读取 QQ 消息中的图片。正常图片消息由 DSH 原生视觉输入链路直接处理;本工具用于模型需要再次读取指定图片的场景。',
|
|
10
10
|
parameters: {
|
|
11
11
|
attachment_id: { type: 'string', required: true, description: 'QQChat 图片附件 ID' },
|
|
12
12
|
},
|