dsh-qqchat 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +276 -0
- package/README.md +211 -0
- package/client-src/plugin.cts +15 -0
- package/client-src/settings.cts +216 -0
- package/client-src/shared.cts +104 -0
- package/client-src/workspace.cts +164 -0
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.en.md +346 -0
- package/docs/ARCHITECTURE.md +411 -0
- package/docs/DEVELOPMENT.md +69 -0
- package/docs/MEDIA_AND_QUOTES.md +644 -0
- package/docs/MEMORY.md +99 -0
- package/docs/MEMORY_CONTEXT.md +254 -0
- package/docs/QUOTES.md +355 -0
- package/docs/README.md +22 -0
- package/docs/SECURITY.md +62 -0
- package/docs/STREAMING.md +347 -0
- package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
- package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
- package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
- package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
- package/docs/devlog.md +24 -0
- package/lib/client.js +433 -0
- package/lib/commands/dispatch.d.ts +16 -0
- package/lib/commands/dispatch.js +48 -0
- package/lib/config.d.ts +3 -0
- package/lib/config.js +63 -0
- package/lib/gateway/api.d.ts +30 -0
- package/lib/gateway/api.js +202 -0
- package/lib/gateway/auth.d.ts +35 -0
- package/lib/gateway/auth.js +80 -0
- package/lib/gateway/crypto.d.ts +2 -0
- package/lib/gateway/crypto.js +17 -0
- package/lib/gateway/gateway.d.ts +32 -0
- package/lib/gateway/gateway.js +219 -0
- package/lib/gateway/normalize.d.ts +6 -0
- package/lib/gateway/normalize.js +248 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +35 -0
- package/lib/media/image-tool.d.ts +4 -0
- package/lib/media/image-tool.js +62 -0
- package/lib/media/media-tools.d.ts +3 -0
- package/lib/media/media-tools.js +68 -0
- package/lib/media/store.d.ts +14 -0
- package/lib/media/store.js +176 -0
- package/lib/session/agent-bridge.d.ts +56 -0
- package/lib/session/agent-bridge.js +613 -0
- package/lib/session/memory-snapshot.d.ts +23 -0
- package/lib/session/memory-snapshot.js +27 -0
- package/lib/session/runtime.d.ts +41 -0
- package/lib/session/runtime.js +395 -0
- package/lib/shared/augmentations.d.ts +40 -0
- package/lib/shared/augmentations.js +1 -0
- package/lib/shared/logging.d.ts +14 -0
- package/lib/shared/logging.js +41 -0
- package/lib/storage/db.d.ts +82 -0
- package/lib/storage/db.js +669 -0
- package/lib/storage/memory.d.ts +50 -0
- package/lib/storage/memory.js +566 -0
- package/lib/transport/rpc.d.ts +18 -0
- package/lib/transport/rpc.js +318 -0
- package/lib/types.d.ts +416 -0
- package/lib/types.js +1 -0
- package/package.json +115 -0
- package/scripts/wrap-client.mjs +20 -0
- package/src/commands/dispatch.ts +75 -0
- package/src/config.ts +70 -0
- package/src/gateway/api.ts +260 -0
- package/src/gateway/auth.ts +99 -0
- package/src/gateway/crypto.ts +16 -0
- package/src/gateway/gateway.ts +207 -0
- package/src/gateway/normalize.ts +236 -0
- package/src/index.ts +46 -0
- package/src/media/image-tool.ts +62 -0
- package/src/media/media-tools.ts +66 -0
- package/src/media/store.ts +157 -0
- package/src/session/agent-bridge.ts +624 -0
- package/src/session/memory-snapshot.ts +41 -0
- package/src/session/runtime.ts +368 -0
- package/src/shared/augmentations.ts +45 -0
- package/src/shared/logging.ts +36 -0
- package/src/storage/db.ts +760 -0
- package/src/storage/memory.ts +581 -0
- package/src/transport/rpc.ts +316 -0
- package/src/types.ts +456 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.client.json +14 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import '../shared/augmentations.js';
|
|
3
|
+
import type { QQChatDatabase } from '../storage/db.js';
|
|
4
|
+
import type { MemoryEngine } from '../storage/memory.js';
|
|
5
|
+
import type { ChatType, GroupRow, LoggerLike, MemberRow, StoredAttachmentSummary, QQChatConfig, QQChatDisplayEvent, QQNormalizedMessage } from '../types.js';
|
|
6
|
+
export declare class DshQQBridge {
|
|
7
|
+
private readonly ctx;
|
|
8
|
+
private readonly db;
|
|
9
|
+
private readonly memory;
|
|
10
|
+
private readonly config;
|
|
11
|
+
private readonly logger;
|
|
12
|
+
private readonly handles;
|
|
13
|
+
private readonly locks;
|
|
14
|
+
private readonly pending;
|
|
15
|
+
private readonly routes;
|
|
16
|
+
private readonly selections;
|
|
17
|
+
private readonly pendingResets;
|
|
18
|
+
private readonly activeActors;
|
|
19
|
+
private readonly activeAttachments;
|
|
20
|
+
private readonly activeMediaReadable;
|
|
21
|
+
private readonly memorySnapshots;
|
|
22
|
+
private readonly disposeEvent;
|
|
23
|
+
private readonly disposeToolGate;
|
|
24
|
+
private readonly disposeBootstrapGate;
|
|
25
|
+
private readonly disposeCommands;
|
|
26
|
+
private readonly disposeImageTool;
|
|
27
|
+
private readonly disposeMediaTools;
|
|
28
|
+
constructor(ctx: Context, db: QQChatDatabase, memory: MemoryEngine, config: QQChatConfig, logger?: LoggerLike);
|
|
29
|
+
dispose(): Promise<void>;
|
|
30
|
+
ensureChatSession(chatType: ChatType, row: GroupRow | MemberRow): Promise<string>;
|
|
31
|
+
recordTranscript(event: QQChatDisplayEvent, row: GroupRow | MemberRow, createSession?: boolean): Promise<string | undefined>;
|
|
32
|
+
reply(message: QQNormalizedMessage, group: GroupRow | undefined, member: MemberRow, attachments?: StoredAttachmentSummary[], onTextDelta?: (delta: string) => void, isOwner?: boolean): Promise<string>;
|
|
33
|
+
private onSessionEvent;
|
|
34
|
+
private injectMemorySnapshot;
|
|
35
|
+
private restoreMemorySnapshot;
|
|
36
|
+
private ensureAgent;
|
|
37
|
+
private isArchived;
|
|
38
|
+
private rememberRoute;
|
|
39
|
+
private ensureVisible;
|
|
40
|
+
private appendDisplayIfMissing;
|
|
41
|
+
private appendOwnerMessageIfMissing;
|
|
42
|
+
private ensureTitle;
|
|
43
|
+
private tryResume;
|
|
44
|
+
private agentOptions;
|
|
45
|
+
private imageRouteAvailable;
|
|
46
|
+
private ensureSelection;
|
|
47
|
+
private selection;
|
|
48
|
+
private requestReset;
|
|
49
|
+
private resetSession;
|
|
50
|
+
private commandHelp;
|
|
51
|
+
private registerCommands;
|
|
52
|
+
private modelCommand;
|
|
53
|
+
private statusCommand;
|
|
54
|
+
private composition;
|
|
55
|
+
private serial;
|
|
56
|
+
}
|
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
4
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
5
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
6
|
+
import '../shared/augmentations.js';
|
|
7
|
+
import { defaultRuntimeSettings } from '../config.js';
|
|
8
|
+
import { registerQQImageTool } from '../media/image-tool.js';
|
|
9
|
+
import { registerQQMediaTools } from '../media/media-tools.js';
|
|
10
|
+
import { dispatchQQCommand, qqCommandText } from '../commands/dispatch.js';
|
|
11
|
+
import { MEMORY_SNAPSHOT_TTL_MS, memorySnapshotHash, restoreMemorySnapshotState, shouldRefreshMemorySnapshot } from './memory-snapshot.js';
|
|
12
|
+
const DSH_RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt';
|
|
13
|
+
const MEDIA_TOOL_NAMES = new Set(['qqchat_describe_image', 'qqchat_read_file', 'qqchat_media_info']);
|
|
14
|
+
const packageMetadata = createRequire(import.meta.url)('../../package.json');
|
|
15
|
+
const PLUGIN_VERSION = packageMetadata.version || 'unknown';
|
|
16
|
+
export class DshQQBridge {
|
|
17
|
+
ctx;
|
|
18
|
+
db;
|
|
19
|
+
memory;
|
|
20
|
+
config;
|
|
21
|
+
logger;
|
|
22
|
+
handles = new Map();
|
|
23
|
+
locks = new Map();
|
|
24
|
+
pending = new Map();
|
|
25
|
+
routes = new Map();
|
|
26
|
+
selections = new Map();
|
|
27
|
+
pendingResets = new Set();
|
|
28
|
+
activeActors = new Map();
|
|
29
|
+
activeAttachments = new Map();
|
|
30
|
+
activeMediaReadable = new Map();
|
|
31
|
+
memorySnapshots = new Map();
|
|
32
|
+
disposeEvent;
|
|
33
|
+
disposeToolGate;
|
|
34
|
+
disposeBootstrapGate;
|
|
35
|
+
disposeCommands;
|
|
36
|
+
disposeImageTool;
|
|
37
|
+
disposeMediaTools;
|
|
38
|
+
constructor(ctx, db, memory, config, logger = console) {
|
|
39
|
+
this.ctx = ctx;
|
|
40
|
+
this.db = db;
|
|
41
|
+
this.memory = memory;
|
|
42
|
+
this.config = config;
|
|
43
|
+
this.logger = logger;
|
|
44
|
+
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
|
+
this.disposeToolGate = ctx.on('tools/pre-execute', async (exec, next) => {
|
|
51
|
+
const agent = exec.agent;
|
|
52
|
+
if (!agent)
|
|
53
|
+
return next();
|
|
54
|
+
const actor = this.activeActors.get(String(agent.id));
|
|
55
|
+
if (!actor || actor.chatType !== 'group')
|
|
56
|
+
return next();
|
|
57
|
+
const settings = this.db.runtimeSettings(defaultRuntimeSettings(this.config));
|
|
58
|
+
if (settings.groupMembersCanUseTools)
|
|
59
|
+
return next();
|
|
60
|
+
if (settings.ownerUserId && settings.ownerUserId === actor.senderId)
|
|
61
|
+
return next();
|
|
62
|
+
if (MEDIA_TOOL_NAMES.has(exec.name) && settings.groupMembersCanReadMedia)
|
|
63
|
+
return next();
|
|
64
|
+
return {
|
|
65
|
+
kind: 'deny',
|
|
66
|
+
reason: settings.ownerUserId
|
|
67
|
+
? (MEDIA_TOOL_NAMES.has(exec.name) ? '当前 QQ 群已关闭群成员媒体读取权限。' : '当前 QQ 群只允许 Owner 使用工具。')
|
|
68
|
+
: (MEDIA_TOOL_NAMES.has(exec.name) ? '当前 QQ 群已关闭群成员媒体读取权限。' : '当前 QQ 群已关闭群成员工具权限,但尚未设置 Owner stable ID。'),
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
const canReadMedia = (agentId, attachmentId) => this.activeMediaReadable.get(agentId) === true && this.activeAttachments.get(agentId)?.has(attachmentId) === true;
|
|
72
|
+
this.disposeImageTool = registerQQImageTool(ctx, db, canReadMedia);
|
|
73
|
+
this.disposeMediaTools = registerQQMediaTools(ctx, db, canReadMedia);
|
|
74
|
+
this.disposeCommands = this.registerCommands();
|
|
75
|
+
}
|
|
76
|
+
async dispose() {
|
|
77
|
+
this.disposeEvent();
|
|
78
|
+
this.disposeBootstrapGate();
|
|
79
|
+
this.disposeToolGate();
|
|
80
|
+
this.disposeImageTool();
|
|
81
|
+
this.disposeMediaTools();
|
|
82
|
+
this.disposeCommands();
|
|
83
|
+
for (const handle of this.handles.values()) {
|
|
84
|
+
try {
|
|
85
|
+
await handle.dispose();
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
}
|
|
89
|
+
this.handles.clear();
|
|
90
|
+
for (const selection of this.selections.values())
|
|
91
|
+
selection.dispose();
|
|
92
|
+
this.selections.clear();
|
|
93
|
+
this.pendingResets.clear();
|
|
94
|
+
this.activeActors.clear();
|
|
95
|
+
this.activeAttachments.clear();
|
|
96
|
+
this.activeMediaReadable.clear();
|
|
97
|
+
this.memorySnapshots.clear();
|
|
98
|
+
}
|
|
99
|
+
async ensureChatSession(chatType, row) {
|
|
100
|
+
const { sessionId } = await this.ensureAgent(chatType, row);
|
|
101
|
+
return sessionId;
|
|
102
|
+
}
|
|
103
|
+
async recordTranscript(event, row, createSession = false) {
|
|
104
|
+
const existing = this.db.getChatSession(event.chatType, Number(row.id));
|
|
105
|
+
if (!existing && !createSession)
|
|
106
|
+
return undefined;
|
|
107
|
+
const { agent, sessionId } = await this.ensureAgent(event.chatType, row);
|
|
108
|
+
event.sessionId = sessionId;
|
|
109
|
+
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);
|
|
115
|
+
return sessionId;
|
|
116
|
+
}
|
|
117
|
+
this.appendDisplayIfMissing(agent.session, event);
|
|
118
|
+
return sessionId;
|
|
119
|
+
}
|
|
120
|
+
async reply(message, group, member, attachments = [], onTextDelta, isOwner = message.chatType === 'c2c') {
|
|
121
|
+
if (message.chatType === 'group' && !group)
|
|
122
|
+
throw new Error('群消息缺少群上下文');
|
|
123
|
+
const key = message.chatType === 'group' ? `g:${group.id}` : `u:${member.id}`;
|
|
124
|
+
return this.serial(key, async () => {
|
|
125
|
+
const row = message.chatType === 'group' ? group : member;
|
|
126
|
+
const { agent, sessionId } = await this.ensureAgent(message.chatType, row);
|
|
127
|
+
this.activeActors.set(String(sessionId), { chatType: message.chatType, senderId: message.senderId });
|
|
128
|
+
try {
|
|
129
|
+
const commands = this.ctx.commands;
|
|
130
|
+
const commandText = qqCommandText(message.text, message.mentioned);
|
|
131
|
+
if (commandText !== undefined) {
|
|
132
|
+
if (!commands)
|
|
133
|
+
return '当前 DSH profile 未加载命令系统,请重启并确认使用了最新插件。';
|
|
134
|
+
const command = await dispatchQQCommand(commands, agent, commandText, { chatType: message.chatType, isOwner });
|
|
135
|
+
if (command.handled) {
|
|
136
|
+
if (this.pendingResets.delete(String(sessionId)))
|
|
137
|
+
await this.resetSession(message.chatType, row);
|
|
138
|
+
return command.text || '';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
this.activeActors.delete(String(sessionId));
|
|
144
|
+
}
|
|
145
|
+
await agent.whenIdle();
|
|
146
|
+
if (this.db.runtimeSettings(defaultRuntimeSettings(this.config)).memoryEnabled) {
|
|
147
|
+
const contextText = message.chatType === 'group'
|
|
148
|
+
? this.memory.contextForGroup(group, member)
|
|
149
|
+
: this.memory.contextForMember(member);
|
|
150
|
+
this.injectMemorySnapshot(agent, sessionId, contextText);
|
|
151
|
+
}
|
|
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 current = createUserMessage({
|
|
162
|
+
source: {
|
|
163
|
+
kind: 'qq-chat', botId: String(message.accountId), chatType: message.chatType,
|
|
164
|
+
chatId: message.chatId, senderId: message.senderId,
|
|
165
|
+
...(message.senderName ? { senderName: message.senderName } : {}),
|
|
166
|
+
messageId: message.messageId || '', mentioned: Boolean(message.mentioned),
|
|
167
|
+
form: 'notice',
|
|
168
|
+
summary: `QQ ${message.chatType === 'group' ? '群聊' : '私聊'}消息 · ${message.senderName || shortId(message.senderId)}`,
|
|
169
|
+
},
|
|
170
|
+
content: currentContent,
|
|
171
|
+
});
|
|
172
|
+
this.pending.set(String(sessionId), { text: '', onTextDelta });
|
|
173
|
+
this.activeAttachments.set(String(sessionId), new Set(attachments.map(item => item.id)));
|
|
174
|
+
const settings = this.db.runtimeSettings(defaultRuntimeSettings(this.config));
|
|
175
|
+
this.activeMediaReadable.set(String(sessionId), message.chatType !== 'group'
|
|
176
|
+
|| (settings.ownerUserId !== '' && settings.ownerUserId === message.senderId)
|
|
177
|
+
|| settings.groupMembersCanReadMedia);
|
|
178
|
+
this.activeActors.set(String(sessionId), { chatType: message.chatType, senderId: message.senderId });
|
|
179
|
+
try {
|
|
180
|
+
agent.followup(current);
|
|
181
|
+
await agent.whenIdle();
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
this.activeActors.delete(String(sessionId));
|
|
185
|
+
this.activeAttachments.delete(String(sessionId));
|
|
186
|
+
this.activeMediaReadable.delete(String(sessionId));
|
|
187
|
+
}
|
|
188
|
+
const pending = this.pending.get(String(sessionId));
|
|
189
|
+
this.pending.delete(String(sessionId));
|
|
190
|
+
const route = this.routes.get(String(sessionId)) || { provider: agent.options.provider, model: agent.options.model };
|
|
191
|
+
if (message.chatType === 'group' && route.provider && route.model) {
|
|
192
|
+
this.memory.setRoute(Number(group.id), route.provider, route.model, String(sessionId));
|
|
193
|
+
}
|
|
194
|
+
else if (message.chatType === 'c2c' && route.provider && route.model) {
|
|
195
|
+
this.memory.setMemberRoute(Number(member.id), route.provider, route.model, String(sessionId));
|
|
196
|
+
}
|
|
197
|
+
const text = pending?.text.trim();
|
|
198
|
+
if (!text)
|
|
199
|
+
throw new Error('DSH Agent 本轮没有产生可发送的文本回复');
|
|
200
|
+
return text;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
onSessionEvent(session, event) {
|
|
204
|
+
const id = String(session.id);
|
|
205
|
+
if (event.type === 'assistant/message') {
|
|
206
|
+
const pending = this.pending.get(id);
|
|
207
|
+
if (!pending)
|
|
208
|
+
return;
|
|
209
|
+
const text = extractText(event.data.message.content);
|
|
210
|
+
if (text.trim())
|
|
211
|
+
pending.text = text;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (event.type === 'assistant/chunk') {
|
|
215
|
+
const pending = this.pending.get(id);
|
|
216
|
+
const chunk = event.data.chunk;
|
|
217
|
+
if (pending?.onTextDelta && chunk.type === 'text-delta' && chunk.text)
|
|
218
|
+
pending.onTextDelta(chunk.text);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (event.type === 'request/header') {
|
|
222
|
+
const { config } = event.data.header;
|
|
223
|
+
if (config.provider && config.model)
|
|
224
|
+
this.routes.set(id, { provider: config.provider, model: config.model });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (event.type === 'compaction/end') {
|
|
228
|
+
const state = this.memorySnapshots.get(id);
|
|
229
|
+
if (state)
|
|
230
|
+
state.stale = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
injectMemorySnapshot(agent, sessionId, contextText) {
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
const hash = memorySnapshotHash(contextText);
|
|
236
|
+
const previous = this.memorySnapshots.get(sessionId) || this.restoreMemorySnapshot(agent.session);
|
|
237
|
+
if (shouldRefreshMemorySnapshot(previous, contextText, now, MEMORY_SNAPSHOT_TTL_MS)) {
|
|
238
|
+
agent.inject(createUserMessage({
|
|
239
|
+
source: { kind: 'plugin', plugin: DSH_RUNTIME_CONTEXT_SOURCE, form: 'snapshot', sections: [{ name: 'qq-chat-context', text: contextText }] },
|
|
240
|
+
content: [{ type: 'text', text: contextText }],
|
|
241
|
+
}));
|
|
242
|
+
this.memorySnapshots.set(sessionId, { hash, lastInjectedAt: now, stale: false });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!previous)
|
|
246
|
+
return;
|
|
247
|
+
// Keep the cache fresh without writing another runtime-context event.
|
|
248
|
+
previous.lastInjectedAt = now;
|
|
249
|
+
this.memorySnapshots.set(sessionId, previous);
|
|
250
|
+
}
|
|
251
|
+
restoreMemorySnapshot(session) {
|
|
252
|
+
return restoreMemorySnapshotState(session.events, DSH_RUNTIME_CONTEXT_SOURCE);
|
|
253
|
+
}
|
|
254
|
+
async ensureAgent(chatType, row) {
|
|
255
|
+
let sessionId = this.db.getChatSession(chatType, Number(row.id));
|
|
256
|
+
if (sessionId) {
|
|
257
|
+
if (this.isArchived(sessionId)) {
|
|
258
|
+
const old = this.handles.get(sessionId);
|
|
259
|
+
if (old) {
|
|
260
|
+
try {
|
|
261
|
+
await old.dispose();
|
|
262
|
+
}
|
|
263
|
+
catch { }
|
|
264
|
+
this.handles.delete(sessionId);
|
|
265
|
+
}
|
|
266
|
+
this.logger.info?.(`[dsh-qqchat] QQ session ${sessionId} 已归档,将创建新 session`);
|
|
267
|
+
sessionId = null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (sessionId) {
|
|
271
|
+
const live = this.ctx.agents.get(SessionId(sessionId));
|
|
272
|
+
if (live) {
|
|
273
|
+
this.ensureSelection(live);
|
|
274
|
+
this.ensureTitle(live.session, chatType, row);
|
|
275
|
+
this.rememberRoute(chatType, row, live, sessionId);
|
|
276
|
+
await this.ensureVisible(live);
|
|
277
|
+
return { agent: live, sessionId };
|
|
278
|
+
}
|
|
279
|
+
const resumed = await this.tryResume(sessionId);
|
|
280
|
+
if (resumed) {
|
|
281
|
+
this.ensureSelection(resumed.agent);
|
|
282
|
+
this.ensureTitle(resumed.agent.session, chatType, row);
|
|
283
|
+
this.rememberRoute(chatType, row, resumed.agent, sessionId);
|
|
284
|
+
await this.ensureVisible(resumed.agent);
|
|
285
|
+
return { agent: resumed.agent, sessionId };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
sessionId = `qqchat-${randomUUID()}`;
|
|
289
|
+
const composition = await this.composition();
|
|
290
|
+
const handle = await this.ctx.agents.create({
|
|
291
|
+
sessionId: SessionId(sessionId),
|
|
292
|
+
meta: {
|
|
293
|
+
cwd: process.cwd(),
|
|
294
|
+
...(composition.presetId ? { agentPreset: composition.presetId } : {}),
|
|
295
|
+
},
|
|
296
|
+
agentOptions: this.agentOptions(), setup: composition.setup,
|
|
297
|
+
});
|
|
298
|
+
this.handles.set(sessionId, handle);
|
|
299
|
+
this.ensureSelection(handle.agent);
|
|
300
|
+
this.db.setChatSession(chatType, Number(row.id), sessionId);
|
|
301
|
+
this.ensureTitle(handle.agent.session, chatType, row);
|
|
302
|
+
this.rememberRoute(chatType, row, handle.agent, sessionId);
|
|
303
|
+
await this.ensureVisible(handle.agent);
|
|
304
|
+
return { agent: handle.agent, sessionId };
|
|
305
|
+
}
|
|
306
|
+
isArchived(sessionId) {
|
|
307
|
+
const registry = this.ctx.workspaceRegistry;
|
|
308
|
+
return registry?.archivedSessionIds.includes(sessionId) ?? false;
|
|
309
|
+
}
|
|
310
|
+
rememberRoute(chatType, row, agent, sessionId) {
|
|
311
|
+
const provider = agent.options.provider || this.config.provider;
|
|
312
|
+
const model = agent.options.model || this.config.model;
|
|
313
|
+
if (!provider || !model)
|
|
314
|
+
return;
|
|
315
|
+
if (chatType === 'group')
|
|
316
|
+
this.memory.setRoute(Number(row.id), provider, model, sessionId);
|
|
317
|
+
else
|
|
318
|
+
this.memory.setMemberRoute(Number(row.id), provider, model, sessionId);
|
|
319
|
+
}
|
|
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
|
+
appendDisplayIfMissing(session, event) {
|
|
327
|
+
if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId))
|
|
328
|
+
return;
|
|
329
|
+
session.append('qqchat/message', displayEventPayload(event));
|
|
330
|
+
}
|
|
331
|
+
appendOwnerMessageIfMissing(session, event) {
|
|
332
|
+
if (session.events.some(item => {
|
|
333
|
+
if (item.type !== 'user/message')
|
|
334
|
+
return false;
|
|
335
|
+
const source = item.data.source;
|
|
336
|
+
return source.messageId === event.messageId;
|
|
337
|
+
}))
|
|
338
|
+
return;
|
|
339
|
+
session.append('user/message', createUserMessage({
|
|
340
|
+
source: {
|
|
341
|
+
kind: 'user', channel: 'qq', botId: 'qqchat', chatType: event.chatType,
|
|
342
|
+
chatId: event.chatId, senderId: event.senderId,
|
|
343
|
+
...(event.senderName ? { senderName: event.senderName } : {}),
|
|
344
|
+
messageId: event.messageId, mentioned: event.mentioned,
|
|
345
|
+
},
|
|
346
|
+
content: transcriptContent(event, this.db),
|
|
347
|
+
}), { surfaceOp: 'append' });
|
|
348
|
+
}
|
|
349
|
+
ensureTitle(session, chatType, row) {
|
|
350
|
+
try {
|
|
351
|
+
const getService = this.ctx.get;
|
|
352
|
+
const titles = getService?.call(this.ctx, 'sessionTitle');
|
|
353
|
+
if (!titles || titles.get(session))
|
|
354
|
+
return;
|
|
355
|
+
const fallback = chatType === 'group'
|
|
356
|
+
? `QQ群 · ${row.name || shortId(row.platform_group_id)}`
|
|
357
|
+
: `QQ私聊 · ${row.display_name || shortId(row.platform_user_id)}`;
|
|
358
|
+
titles.rename(session, fallback);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
this.logger.debug?.(`[dsh-qqchat] session title skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async tryResume(sessionId) {
|
|
365
|
+
try {
|
|
366
|
+
const composition = await this.composition();
|
|
367
|
+
const handle = await this.ctx.agents.resume({ resumeSessionId: SessionId(sessionId), agentOptions: this.agentOptions(), setup: composition.setup });
|
|
368
|
+
if (!handle.agent.session.header.cwd) {
|
|
369
|
+
await handle.dispose();
|
|
370
|
+
this.logger.warn?.(`[dsh-qqchat] QQ session ${sessionId} 缺少 cwd 元数据,将创建新 session`);
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
this.handles.set(sessionId, handle);
|
|
374
|
+
return handle;
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
this.logger.warn?.(`[dsh-qqchat] 无法恢复 QQ session ${sessionId},将创建新 session: ${error instanceof Error ? error.message : String(error)}`);
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
agentOptions() {
|
|
382
|
+
const defaults = this.ctx.agentDefaultModel.currentSelection();
|
|
383
|
+
const options = {
|
|
384
|
+
provider: this.config.provider || defaults.provider,
|
|
385
|
+
model: this.config.model || defaults.model,
|
|
386
|
+
};
|
|
387
|
+
if (this.config.maxTokens)
|
|
388
|
+
options.maxTokens = this.config.maxTokens;
|
|
389
|
+
return options;
|
|
390
|
+
}
|
|
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
|
+
ensureSelection(agent) {
|
|
404
|
+
const id = String(agent.id);
|
|
405
|
+
const existing = this.selections.get(id);
|
|
406
|
+
if (existing)
|
|
407
|
+
return existing.ref;
|
|
408
|
+
const logged = agent.session.requestHeader()?.config;
|
|
409
|
+
const route = this.routes.get(id)
|
|
410
|
+
|| (logged?.provider && logged.model ? { provider: logged.provider, model: logged.model } : undefined)
|
|
411
|
+
|| (agent.options.provider && agent.options.model ? { provider: agent.options.provider, model: agent.options.model } : undefined);
|
|
412
|
+
if (!route)
|
|
413
|
+
return undefined;
|
|
414
|
+
const ref = { current: route, assembled: undefined };
|
|
415
|
+
this.selections.set(id, { ref, dispose: installModelSelection(agent.ctx, ref) });
|
|
416
|
+
return ref;
|
|
417
|
+
}
|
|
418
|
+
selection(agent) {
|
|
419
|
+
return this.ensureSelection(agent);
|
|
420
|
+
}
|
|
421
|
+
requestReset(sessionId) {
|
|
422
|
+
this.pendingResets.add(sessionId);
|
|
423
|
+
}
|
|
424
|
+
async resetSession(chatType, row) {
|
|
425
|
+
const sessionId = this.db.getChatSession(chatType, Number(row.id));
|
|
426
|
+
if (!sessionId)
|
|
427
|
+
return;
|
|
428
|
+
const handle = this.handles.get(sessionId);
|
|
429
|
+
if (handle) {
|
|
430
|
+
try {
|
|
431
|
+
await handle.dispose();
|
|
432
|
+
}
|
|
433
|
+
catch { }
|
|
434
|
+
this.handles.delete(sessionId);
|
|
435
|
+
}
|
|
436
|
+
const selection = this.selections.get(sessionId);
|
|
437
|
+
if (selection) {
|
|
438
|
+
selection.dispose();
|
|
439
|
+
this.selections.delete(sessionId);
|
|
440
|
+
}
|
|
441
|
+
this.routes.delete(sessionId);
|
|
442
|
+
this.db.setChatSession(chatType, Number(row.id), '');
|
|
443
|
+
}
|
|
444
|
+
commandHelp(agent) {
|
|
445
|
+
const commands = this.ctx.commands.list(agent);
|
|
446
|
+
return [
|
|
447
|
+
'🤖 QQChat / DSH 命令',
|
|
448
|
+
'',
|
|
449
|
+
'通用能力',
|
|
450
|
+
...commands.filter(command => ['compact', 'goal', 'plan', 'permission', 'feedback'].includes(command.name))
|
|
451
|
+
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
452
|
+
'',
|
|
453
|
+
'QQChat 会话',
|
|
454
|
+
...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqstop', 'qqstatus'].includes(command.name))
|
|
455
|
+
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
456
|
+
'',
|
|
457
|
+
'QQChat 工具',
|
|
458
|
+
...commands.filter(command => ['qqping', 'qqversion', 'qqhelp', 'qqcommands'].includes(command.name))
|
|
459
|
+
.map(command => `/${command.name} — ${command.description}`),
|
|
460
|
+
].join('\n');
|
|
461
|
+
}
|
|
462
|
+
registerCommands() {
|
|
463
|
+
const register = (name, description, handler, input) => this.ctx.commands.register({ name, description, ...(input ? { input: { hint: input } } : {}), handler });
|
|
464
|
+
const disposers = [
|
|
465
|
+
register('qqhelp', '查看 QQChat 和 DSH 命令', invocation => ({ kind: 'success', text: this.commandHelp(invocation.agent) })),
|
|
466
|
+
register('qqcommands', '查看 QQChat 和 DSH 命令', invocation => ({ kind: 'success', text: this.commandHelp(invocation.agent) })),
|
|
467
|
+
register('qqnew', '开始新会话(保留旧 Session)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' }; }),
|
|
468
|
+
register('qqreset', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' }; }),
|
|
469
|
+
register('qqclear', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' }; }),
|
|
470
|
+
register('qqmodel', '查看或切换当前会话模型', invocation => this.modelCommand(invocation), '[provider/]model'),
|
|
471
|
+
register('qqstop', '中止当前生成', invocation => {
|
|
472
|
+
if (invocation.agent.status !== 'running')
|
|
473
|
+
return { kind: 'success', text: '当前没有进行中的生成。' };
|
|
474
|
+
invocation.agent.cancel({ kind: 'user' });
|
|
475
|
+
return { kind: 'success', text: '已中止当前生成。' };
|
|
476
|
+
}),
|
|
477
|
+
register('qqping', '测试 QQChat 连通性', () => ({ kind: 'success', text: 'pong 🏓' })),
|
|
478
|
+
register('qqversion', '查看 QQChat 版本', () => ({ kind: 'success', text: `dsh-qqchat v${PLUGIN_VERSION}` })),
|
|
479
|
+
register('qqstatus', '查看当前会话状态', invocation => this.statusCommand(invocation.agent)),
|
|
480
|
+
];
|
|
481
|
+
return () => { for (const dispose of disposers.reverse())
|
|
482
|
+
dispose(); };
|
|
483
|
+
}
|
|
484
|
+
async modelCommand(invocation) {
|
|
485
|
+
const agent = invocation.agent;
|
|
486
|
+
const ref = this.selection(agent);
|
|
487
|
+
if (!ref)
|
|
488
|
+
return { kind: 'error', text: '当前 Session 尚未确定模型路由。' };
|
|
489
|
+
const args = invocation.rawInput.trim();
|
|
490
|
+
if (!args) {
|
|
491
|
+
const providers = this.ctx.llm.listProviders();
|
|
492
|
+
const models = await this.ctx.llm.listModels(ref.current?.provider || '');
|
|
493
|
+
return {
|
|
494
|
+
kind: 'success',
|
|
495
|
+
text: [
|
|
496
|
+
`当前模型:${ref.current?.provider}/${ref.current?.model}`,
|
|
497
|
+
providers.length ? `可用提供方:${providers.map(provider => provider.id).join(', ')}` : '',
|
|
498
|
+
models.length ? `当前提供方模型:${models.slice(0, 20).map(model => `${model.id}${model.name !== model.id ? `(${model.name})` : ''}`).join(', ')}` : '',
|
|
499
|
+
'切换用法:/qqmodel provider/model 或 /qqmodel model',
|
|
500
|
+
].filter(Boolean).join('\n'),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
const separator = args.indexOf('/');
|
|
504
|
+
const provider = separator > 0 ? args.slice(0, separator).trim() : ref.current?.provider || '';
|
|
505
|
+
const model = separator > 0 ? args.slice(separator + 1).trim() : args;
|
|
506
|
+
if (!provider || !model)
|
|
507
|
+
return { kind: 'error', text: '用法:/qqmodel provider/model' };
|
|
508
|
+
if (!this.ctx.llm.listProviders().some(item => item.id === provider)) {
|
|
509
|
+
return { kind: 'error', text: `未找到提供方:${provider}` };
|
|
510
|
+
}
|
|
511
|
+
ref.current = { provider, model };
|
|
512
|
+
this.routes.set(String(agent.id), { provider, model });
|
|
513
|
+
return { kind: 'success', text: `模型已切换:${provider}/${model}\n对话上下文保留,下一轮生效。` };
|
|
514
|
+
}
|
|
515
|
+
statusCommand(agent) {
|
|
516
|
+
const current = this.selection(agent)?.current;
|
|
517
|
+
const messages = agent.session.events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length;
|
|
518
|
+
const last = agent.session.events.at(-1);
|
|
519
|
+
return {
|
|
520
|
+
kind: 'success',
|
|
521
|
+
text: ['📊 Session 状态', `Session:${String(agent.id)}`, `状态:${agent.status === 'running' ? '生成中' : '空闲'}`, `模型:${current ? `${current.provider}/${current.model}` : '未知'}`, `消息数:${messages}`, `最后事件:${last?.type || '无'}`].join('\n'),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
async composition() {
|
|
525
|
+
const getService = this.ctx.get;
|
|
526
|
+
const presets = getService?.call(this.ctx, 'agentPresets');
|
|
527
|
+
if (!presets)
|
|
528
|
+
return {};
|
|
529
|
+
const preset = await presets.resolve(this.config.agentPreset);
|
|
530
|
+
return { presetId: preset.id, setup: async (agentCtx) => { await presets.mount(agentCtx, preset.id); } };
|
|
531
|
+
}
|
|
532
|
+
serial(key, task) {
|
|
533
|
+
const previous = this.locks.get(key) || Promise.resolve();
|
|
534
|
+
const current = previous.catch(() => undefined).then(task);
|
|
535
|
+
this.locks.set(key, current);
|
|
536
|
+
return current.finally(() => { if (this.locks.get(key) === current)
|
|
537
|
+
this.locks.delete(key); });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function mediaPrompt(attachments) {
|
|
541
|
+
if (!attachments.length)
|
|
542
|
+
return '';
|
|
543
|
+
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 查看。`;
|
|
545
|
+
}
|
|
546
|
+
function transcriptContent(event, db) {
|
|
547
|
+
const attachments = event.attachments || [];
|
|
548
|
+
const renderableImages = attachments.filter(attachment => attachment.kind === 'image' && db.attachmentById(attachment.id)?.imageRef);
|
|
549
|
+
const imagePlaceholderLines = new Set(renderableImages.map(attachment => `[图片] ${attachment.filename}`));
|
|
550
|
+
const quote = event.quote ? formatDisplayQuote(event) : '';
|
|
551
|
+
const body = event.content.split('\n').filter(line => !imagePlaceholderLines.has(line)).join('\n').trim();
|
|
552
|
+
const text = [quote, body].filter(Boolean).join('\n\n');
|
|
553
|
+
const content = [{ type: 'text', text }];
|
|
554
|
+
for (const attachment of attachments) {
|
|
555
|
+
const imageRef = attachment.kind === 'image' ? db.attachmentById(attachment.id)?.imageRef : undefined;
|
|
556
|
+
if (imageRef) {
|
|
557
|
+
content.push({ type: 'image', attachment: imageRef });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return content;
|
|
561
|
+
}
|
|
562
|
+
function formatDisplayQuote(event) {
|
|
563
|
+
const quote = event.quote;
|
|
564
|
+
if (!quote)
|
|
565
|
+
return '';
|
|
566
|
+
return [
|
|
567
|
+
'[引用消息]',
|
|
568
|
+
quote.senderId ? `发送人ID=${quote.senderId}` : '',
|
|
569
|
+
quote.senderName ? `显示名=${quote.senderName}` : '',
|
|
570
|
+
quote.messageId ? `消息ID=${quote.messageId}` : '',
|
|
571
|
+
`正文=${quote.text || '(空消息)'}`,
|
|
572
|
+
quote.attachments.length ? `附件=${quote.attachments.map(attachment => `${attachment.kind || 'file'}:${attachment.filename}${attachment.attachmentId ? ` (attachment_id=${attachment.attachmentId})` : ''}`).join('、')}` : '',
|
|
573
|
+
].filter(Boolean).join('\n');
|
|
574
|
+
}
|
|
575
|
+
function displayEventPayload(event) {
|
|
576
|
+
const attachments = (event.attachments || []).map(attachment => ({
|
|
577
|
+
id: String(attachment.id), kind: attachment.kind, filename: String(attachment.filename),
|
|
578
|
+
...(attachment.contentType ? { contentType: String(attachment.contentType) } : {}),
|
|
579
|
+
sizeBytes: Number(attachment.sizeBytes || 0), quoted: Boolean(attachment.quoted),
|
|
580
|
+
}));
|
|
581
|
+
const quote = event.quote ? {
|
|
582
|
+
...(event.quote.messageId ? { messageId: String(event.quote.messageId) } : {}),
|
|
583
|
+
...(event.quote.senderId ? { senderId: String(event.quote.senderId) } : {}),
|
|
584
|
+
...(event.quote.senderName ? { senderName: String(event.quote.senderName) } : {}),
|
|
585
|
+
text: String(event.quote.text || ''),
|
|
586
|
+
attachments: event.quote.attachments.map(attachment => ({
|
|
587
|
+
...(attachment.attachmentId ? { id: String(attachment.attachmentId) } : {}),
|
|
588
|
+
filename: String(attachment.filename),
|
|
589
|
+
...(attachment.contentType ? { contentType: String(attachment.contentType) } : {}),
|
|
590
|
+
...(attachment.size !== undefined ? { size: Number(attachment.size) } : {}),
|
|
591
|
+
...(attachment.width !== undefined ? { width: Number(attachment.width) } : {}),
|
|
592
|
+
...(attachment.height !== undefined ? { height: Number(attachment.height) } : {}),
|
|
593
|
+
...(attachment.durationMs !== undefined ? { durationMs: Number(attachment.durationMs) } : {}),
|
|
594
|
+
...(attachment.platformFileId ? { platformFileId: String(attachment.platformFileId) } : {}),
|
|
595
|
+
...(attachment.size !== undefined ? { sizeBytes: Number(attachment.size) } : {}),
|
|
596
|
+
...(attachment.quoted !== undefined ? { quoted: Boolean(attachment.quoted) } : {}),
|
|
597
|
+
...(attachment.kind ? { kind: attachment.kind } : {}),
|
|
598
|
+
})),
|
|
599
|
+
} : undefined;
|
|
600
|
+
return {
|
|
601
|
+
messageId: String(event.messageId), chatType: event.chatType, chatId: String(event.chatId),
|
|
602
|
+
direction: event.direction, senderId: String(event.senderId), senderName: String(event.senderName),
|
|
603
|
+
...(event.isOwner !== undefined ? { isOwner: Boolean(event.isOwner) } : {}),
|
|
604
|
+
content: String(event.content), quotedText: String(event.quotedText), mentioned: Boolean(event.mentioned),
|
|
605
|
+
createdAt: Number(event.createdAt), ...(event.sessionId ? { sessionId: String(event.sessionId) } : {}),
|
|
606
|
+
attachments, ...(quote ? { quote } : {}),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function extractText(content) {
|
|
610
|
+
return content.filter((block) => isRecord(block) && block.type === 'text' && typeof block.text === 'string').map(block => block.text).join('\n');
|
|
611
|
+
}
|
|
612
|
+
function shortId(value) { return value.length > 10 ? `…${value.slice(-10)}` : value; }
|
|
613
|
+
function isRecord(value) { return typeof value === 'object' && value !== null; }
|