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