dsh-qqchat 0.2.2 → 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 +4 -1
- package/README.md +4 -1
- package/docs/ARCHITECTURE.md +5 -0
- package/docs/DEVELOPMENT.md +26 -1
- package/docs/MEDIA_AND_QUOTES.md +9 -8
- 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 +3 -1
- package/lib/session/agent-bridge.js +23 -28
- package/lib/session/registration.d.ts +14 -0
- package/lib/session/registration.js +94 -0
- package/package.json +18 -16
- package/src/index.ts +6 -0
- package/src/media/image-tool.ts +2 -2
- package/src/session/agent-bridge.ts +24 -23
- package/src/session/registration.ts +102 -0
package/README.en.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
<p>
|
|
8
8
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a>
|
|
9
|
-
<img src="https://img.shields.io/badge/DSH-
|
|
9
|
+
<img src="https://img.shields.io/badge/DSH-0.1.1--rc.2-6f42c1.svg" alt="DSH 0.1.1-rc.2">
|
|
10
|
+
<img src="https://img.shields.io/badge/QQChat-0.2.3-2ea44f.svg" alt="QQChat 0.2.3">
|
|
10
11
|
<img src="https://img.shields.io/badge/TypeScript-5.x-3178c6.svg" alt="TypeScript">
|
|
11
12
|
</p>
|
|
12
13
|
|
|
@@ -17,6 +18,8 @@
|
|
|
17
18
|
|
|
18
19
|
<p><em>Chat in QQ, then view the complete Session, memory and tool activity in DSH Web.</em></p>
|
|
19
20
|
|
|
21
|
+
<p>This release is verified against DSH <code>0.1.1-rc.2</code>. Install the plugin as-is; no DSH source patch or rebuild is required.</p>
|
|
22
|
+
|
|
20
23
|
`dsh-qqchat` is an official QQ Bot plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness). It maps QQ groups and direct chats to ordinary DSH Sessions and uses DSH's native Conversation, Agent, command and tool surfaces.
|
|
21
24
|
|
|
22
25
|
> This is a Vibe Coding project, so code quality and engineering completeness may need improvement. If you find a problem or have a better implementation idea, please open an [Issue](https://github.com/Coffeiz/dsh-qqchat/issues) or submit a [Pull Request](https://github.com/Coffeiz/dsh-qqchat/pulls).
|
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
<p>
|
|
8
8
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a>
|
|
9
|
-
<img src="https://img.shields.io/badge/DSH-
|
|
9
|
+
<img src="https://img.shields.io/badge/DSH-0.1.1--rc.2-6f42c1.svg" alt="DSH 0.1.1-rc.2">
|
|
10
|
+
<img src="https://img.shields.io/badge/QQChat-0.2.3-2ea44f.svg" alt="QQChat 0.2.3">
|
|
10
11
|
<img src="https://img.shields.io/badge/TypeScript-5.x-3178c6.svg" alt="TypeScript">
|
|
11
12
|
</p>
|
|
12
13
|
|
|
@@ -18,6 +19,8 @@
|
|
|
18
19
|
|
|
19
20
|
<p><em>dsh-qqchat 是 DeepSeek Harness(DSH)的 QQ Bot 插件,支持 QQ 群聊、私聊、会话隔离、记忆系统和 DSH 原生命令。</em></p>
|
|
20
21
|
|
|
22
|
+
<p>当前版本基于 DSH <code>0.1.1-rc.2</code> 验证,安装插件即可使用,无需修改或重新构建 DSH。</p>
|
|
23
|
+
|
|
21
24
|
> 本项目是一个 Vibe Coding 项目,代码质量和工程完整性可能需要改进。如果你发现问题或有更好的实现建议,欢迎提交 [Issue](https://github.com/Coffeiz/dsh-qqchat/issues) 或 [Pull Request](https://github.com/Coffeiz/dsh-qqchat/pulls)。
|
|
22
25
|
|
|
23
26
|
</div>
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -127,6 +127,11 @@ session.append('qqchat/message', ...)
|
|
|
127
127
|
- 不会唤醒 Agent;
|
|
128
128
|
- 不会调用 LLM。
|
|
129
129
|
|
|
130
|
+
官方 DSH 当前不会为 out-of-tree 插件事件提供正式注册 API。QQChat 在插件加载时
|
|
131
|
+
按兼容约定把 `qqchat/message` 注册到可达的 DSH `KNOWN_SESSION_EVENT_TYPES` 副本,
|
|
132
|
+
因此正常安装后旧 Session 可以继续恢复;如果某个特殊打包环境无法解析到宿主使用的
|
|
133
|
+
事件表,QQChat 会捕获恢复失败并创建新的 QQ Session,完整 QQ 历史仍保存在 SQLite。
|
|
134
|
+
|
|
130
135
|
插件不再从 SQLite 重放整段历史到 DSH Session。DSH Session 保存它实际经历过的 display/Agent events,完整 QQ 世界历史始终由 SQLite 负责。
|
|
131
136
|
|
|
132
137
|
## Host / Client
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -10,7 +10,32 @@ QQChat 是 TypeScript 插件,Host 和 Client 一起构建。日常开发不需
|
|
|
10
10
|
- 可用的 DSH workspace
|
|
11
11
|
- 需要真实网关验证时,准备 QQ 官方 Bot 凭据;凭据只放在本地 Host 配置中
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
仓库提交 `package-lock.json`,使用 `npm install` 安装依赖并保持发布安装结果一致。
|
|
14
|
+
|
|
15
|
+
## DSH 兼容要求
|
|
16
|
+
|
|
17
|
+
插件以官方发布版 DSH 为运行基线。安装者不需要修改 DSH 源码、锁文件或构建流程;插件自身也不能依赖本地 DSH patch 或未发布的私有接口。
|
|
18
|
+
|
|
19
|
+
验证插件兼容性时,应在干净的官方 DSH 工作区中检查:
|
|
20
|
+
|
|
21
|
+
1. DSH 本地工作区没有插件补丁或未提交修改。
|
|
22
|
+
2. 插件依赖的 DSH 包版本与官方发布版本一致。
|
|
23
|
+
3. 插件 `npm run check` 能独立通过。
|
|
24
|
+
4. 启动 DSH Web 后,插件 Host、Client 和设置页都能正常加载。
|
|
25
|
+
|
|
26
|
+
当前已知的宿主兼容边界见[架构说明](./ARCHITECTURE.md)和[媒体与引用方案](./MEDIA_AND_QUOTES.md):自定义 `qqchat/message` 展示事件不属于官方 DSH 的标准 Agent 事件,插件会在加载时自动注册事件类型;若特殊打包环境无法找到宿主事件表,才会触发新建 Session 的降级,QQ 完整历史仍由自身 SQLite 保存。该兼容层不要求用户修改 DSH。
|
|
27
|
+
|
|
28
|
+
## 当前功能兼容矩阵
|
|
29
|
+
|
|
30
|
+
| 功能 | DSH 原生支持情况 | 插件行为 |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| QQ 文本消息与独立 Session | 无 QQ 协议支持 | 转换为 DSH 标准 user message,并保留 QQ 身份元数据 |
|
|
33
|
+
| 群友静默记录、未 @ 消息展示 | 无 QQ 自定义展示事件 | 使用插件 Client 渲染 `qqchat/message`;不送入模型 |
|
|
34
|
+
| 图片输入 | 取决于当前 DSH 模型路由和附件服务 | 使用 DSH 原生图片块;不支持视觉时使用稳定文本降级和读取工具 |
|
|
35
|
+
| 音频、视频和普通文件 | DSH 不提供统一的 QQ 媒体理解能力 | 保存附件并提供类型提示、文件工具或媒体信息工具 |
|
|
36
|
+
| QQ 私聊流式回复 | DSH 不负责 QQ Gateway 流式协议 | 插件调用 QQ 官方接口;失败时回退普通发送,纯文本兼容模式自动关闭 |
|
|
37
|
+
| QQ 引用、@和气泡 | DSH 不认识 QQ 协议字段 | 插件把引用和 @ 转为可读文本/元数据,并由 Client 做展示 |
|
|
38
|
+
| DSH 原生命令 | DSH 只处理 DSH Web 自己收到的命令 | 插件将 `/qq...` 命令解析后转交或执行,避免与 DSH 原命令冲突 |
|
|
14
39
|
|
|
15
40
|
## 本地安装与检查
|
|
16
41
|
|
package/docs/MEDIA_AND_QUOTES.md
CHANGED
|
@@ -280,20 +280,21 @@ QQ dispatch
|
|
|
280
280
|
|
|
281
281
|
## 模型输入策略
|
|
282
282
|
|
|
283
|
-
###
|
|
283
|
+
### 兼容与降级路径
|
|
284
284
|
|
|
285
|
-
|
|
285
|
+
当前以 DSH 原生图片输入为主,无法使用原生视觉输入时由 DSH 统一降级:
|
|
286
286
|
|
|
287
287
|
- 图片、视频、文件先保存为附件;
|
|
288
|
-
- Agent
|
|
289
|
-
-
|
|
288
|
+
- Agent 文本上下文保留媒体类型、名称和附件 ID;
|
|
289
|
+
- 图片优先作为 DSH `ImageBlock` 输入;纯文本模型收到 DSH 生成的稳定图片占位文本;
|
|
290
|
+
- `qqchat_describe_image` 仅用于模型主动按 attachment ID 重新读取图片;
|
|
290
291
|
- 视频交给 `ffmpeg` 和文件工具;
|
|
291
292
|
- 文本文件可以直接读取内容;
|
|
292
293
|
- 无能力处理的媒体只提供结构化提示。
|
|
293
294
|
|
|
294
|
-
|
|
295
|
+
优点是沿用 DSH 的统一模型能力判断、图片预算和附件处理,不需要插件复制一套模型适配逻辑。
|
|
295
296
|
|
|
296
|
-
###
|
|
297
|
+
### 图片原生输入
|
|
297
298
|
|
|
298
299
|
当当前模型明确声明支持 image modality 时,可以将图片附件转换为 DSH 官方 `ImageAttachmentRef`,构造 `ImageBlock` 发送给视觉模型。
|
|
299
300
|
|
|
@@ -395,7 +396,7 @@ createUserMessage({
|
|
|
395
396
|
})
|
|
396
397
|
```
|
|
397
398
|
|
|
398
|
-
这样图片由 DSH Web
|
|
399
|
+
这样图片由 DSH Web 原生消息渲染,同时可以进入视觉模型。文本模型会由 DSH 统一投影为稳定的图片占位文本;`qqchat_describe_image` 仅作为按 attachment_id 主动重新读取图片的工具,不是正常视觉输入的必经路径。
|
|
399
400
|
|
|
400
401
|
#### 静默消息:最小适配的附件读取 RPC
|
|
401
402
|
|
|
@@ -500,7 +501,7 @@ createUserMessage({
|
|
|
500
501
|
- [x] 当前模型支持图片时,将 QQ 图片保存为 DSH `ImageAttachmentRef`;
|
|
501
502
|
- [x] 在同一条 DSH `user/message` 中加入文本块和 `ImageBlock`;
|
|
502
503
|
- [x] 让 DSH Web 原生显示 Agent 图片,同时让视觉模型直接接收图片;
|
|
503
|
-
- [x]
|
|
504
|
+
- [x] 当前模型不支持图片时,由 DSH 统一投影为稳定图片占位文本;主动重新读取图片仍可使用 `qqchat_describe_image`;
|
|
504
505
|
- [x] 附件服务不可用、图片损坏或图片超限时,保留文本提示/工具 fallback,不让整轮媒体失败;
|
|
505
506
|
- [x] 保证所有 `ImageBlock` 在 Session event 发布前已经持久提交;
|
|
506
507
|
- [x] 保证 Session event 中不存在 Buffer、Blob、本地路径、SDK 原始对象或循环引用。
|
package/lib/index.js
CHANGED
|
@@ -10,6 +10,8 @@ import { QQApiClient } from './gateway/api.js';
|
|
|
10
10
|
import { QQBindService } from './gateway/auth.js';
|
|
11
11
|
import { createQQChatRpc } from './transport/rpc.js';
|
|
12
12
|
import { QQChatRuntime } from './session/runtime.js';
|
|
13
|
+
import { registerQQChatSessionEventType } from './session/registration.js';
|
|
14
|
+
const qqChatSessionEventRegistrations = await registerQQChatSessionEventType();
|
|
13
15
|
export const name = 'dsh-qqchat';
|
|
14
16
|
export const inject = ['connection', 'agents', 'agentDefaultModel', 'commands', 'llm', 'tools', 'workspaceRegistry'];
|
|
15
17
|
export function apply(ctx, inputConfig = {}) {
|
|
@@ -18,6 +20,9 @@ export function apply(ctx, inputConfig = {}) {
|
|
|
18
20
|
const db = new QQChatDatabase(join(config.dataDir, 'qqchat.sqlite'));
|
|
19
21
|
const baseLogger = ctx.logger || console;
|
|
20
22
|
const logger = new QQChatLogger(baseLogger);
|
|
23
|
+
if (qqChatSessionEventRegistrations === 0) {
|
|
24
|
+
logger.warn?.('[dsh-qqchat] 未找到可注册的 DSH Session 事件表,旧 QQChat Session 恢复可能需要新建 Session');
|
|
25
|
+
}
|
|
21
26
|
const api = new QQApiClient(db, config);
|
|
22
27
|
const auth = new QQBindService(db, config);
|
|
23
28
|
const memory = new MemoryEngine(ctx, db, config, logger);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
2
|
import type { QQChatDatabase } from '../storage/db.js';
|
|
3
|
-
/** Exposes stored QQ images
|
|
3
|
+
/** Exposes stored QQ images by attachment ID when a model explicitly requests one. */
|
|
4
4
|
export declare function registerQQImageTool(ctx: Context, db: QQChatDatabase, isAllowed?: (agentId: string, attachmentId: string) => boolean): () => void;
|
package/lib/media/image-tool.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
-
/** Exposes stored QQ images
|
|
2
|
+
/** Exposes stored QQ images by attachment ID when a model explicitly requests one. */
|
|
3
3
|
export function registerQQImageTool(ctx, db, isAllowed = () => true) {
|
|
4
4
|
const definition = defineTool({
|
|
5
5
|
name: 'qqchat_describe_image',
|
|
6
|
-
description: '
|
|
6
|
+
description: '按 attachment_id 主动重新读取 QQ 消息中的图片。正常图片消息由 DSH 原生视觉输入链路直接处理;本工具用于模型需要再次读取指定图片的场景。',
|
|
7
7
|
parameters: {
|
|
8
8
|
attachment_id: { type: 'string', required: true, description: 'QQChat 图片附件 ID' },
|
|
9
9
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm';
|
|
2
3
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
|
|
3
4
|
import '../shared/augmentations.js';
|
|
4
5
|
import type { QQChatDatabase } from '../storage/db.js';
|
|
@@ -42,7 +43,6 @@ export declare class DshQQBridge {
|
|
|
42
43
|
private ensureTitle;
|
|
43
44
|
private tryResume;
|
|
44
45
|
private agentOptions;
|
|
45
|
-
private imageRouteAvailable;
|
|
46
46
|
private ensureSelection;
|
|
47
47
|
private selection;
|
|
48
48
|
private requestReset;
|
|
@@ -56,3 +56,5 @@ export declare class DshQQBridge {
|
|
|
56
56
|
private persistedPreset;
|
|
57
57
|
private serial;
|
|
58
58
|
}
|
|
59
|
+
/** Build the durable DSH content for one QQ turn without pre-flighting the model. */
|
|
60
|
+
export declare function qqMessageContent(text: string, attachments: readonly StoredAttachmentSummary[]): ContentBlock[];
|
|
@@ -104,11 +104,12 @@ export class DshQQBridge {
|
|
|
104
104
|
const { agent, sessionId } = await this.ensureAgent(event.chatType, row);
|
|
105
105
|
event.sessionId = sessionId;
|
|
106
106
|
if (event.isOwner) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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);
|
|
112
113
|
return sessionId;
|
|
113
114
|
}
|
|
114
115
|
this.appendDisplayIfMissing(agent.session, event);
|
|
@@ -157,15 +158,7 @@ export class DshQQBridge {
|
|
|
157
158
|
: this.memory.contextForMember(member);
|
|
158
159
|
this.injectMemorySnapshot(agent, sessionId, contextText);
|
|
159
160
|
}
|
|
160
|
-
const
|
|
161
|
-
const currentContent = [{ type: 'text', text: currentText }];
|
|
162
|
-
if (attachments.length && await this.imageRouteAvailable(agent)) {
|
|
163
|
-
for (const attachment of attachments) {
|
|
164
|
-
if (attachment.kind === 'image' && attachment.imageRef) {
|
|
165
|
-
currentContent.push({ type: 'image', attachment: attachment.imageRef });
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
161
|
+
const currentContent = qqMessageContent(this.memory.currentMessageText(message), attachments);
|
|
169
162
|
const current = createUserMessage({
|
|
170
163
|
source: {
|
|
171
164
|
kind: 'qq-chat', botId: String(message.accountId), chatType: message.chatType,
|
|
@@ -325,7 +318,11 @@ export class DshQQBridge {
|
|
|
325
318
|
appendDisplayIfMissing(session, event) {
|
|
326
319
|
if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId))
|
|
327
320
|
return;
|
|
328
|
-
|
|
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.
|
|
325
|
+
session.append('qqchat/message', displayEventPayload(event));
|
|
329
326
|
}
|
|
330
327
|
appendOwnerMessageIfMissing(session, event) {
|
|
331
328
|
if (session.events.some(item => {
|
|
@@ -387,18 +384,6 @@ export class DshQQBridge {
|
|
|
387
384
|
options.maxTokens = this.config.maxTokens;
|
|
388
385
|
return options;
|
|
389
386
|
}
|
|
390
|
-
async imageRouteAvailable(agent) {
|
|
391
|
-
const route = this.selection(agent)?.current || { provider: agent.options.provider, model: agent.options.model };
|
|
392
|
-
if (!route.provider || !route.model)
|
|
393
|
-
return false;
|
|
394
|
-
try {
|
|
395
|
-
const info = await this.ctx.llm.resolveModelInfo(route.provider, route.model);
|
|
396
|
-
return info.inputModalities?.includes('image') === true;
|
|
397
|
-
}
|
|
398
|
-
catch {
|
|
399
|
-
return false;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
387
|
ensureSelection(agent) {
|
|
403
388
|
const id = String(agent.id);
|
|
404
389
|
const existing = this.selections.get(id);
|
|
@@ -593,7 +578,17 @@ function mediaPrompt(attachments) {
|
|
|
593
578
|
if (!attachments.length)
|
|
594
579
|
return '';
|
|
595
580
|
const lines = attachments.map(item => `- ${item.kind}: ${item.filename} (attachment_id=${item.id}${item.quoted ? ', 引用消息附件' : ''})`);
|
|
596
|
-
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;
|
|
597
592
|
}
|
|
598
593
|
function transcriptContent(event, db) {
|
|
599
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/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,17 +46,18 @@
|
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
49
|
-
"@deepseek-ai/dsh-
|
|
50
|
-
"@deepseek-ai/dsh-agent
|
|
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-client-ui-
|
|
57
|
-
"@deepseek-ai/dsh-
|
|
58
|
-
"@deepseek-ai/dsh-
|
|
59
|
-
"@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"
|
|
60
61
|
},
|
|
61
62
|
"peerDependenciesMeta": {
|
|
62
63
|
"@deepseek-ai/dsh-client-connection": {
|
|
@@ -73,10 +74,11 @@
|
|
|
73
74
|
}
|
|
74
75
|
},
|
|
75
76
|
"devDependencies": {
|
|
76
|
-
"@deepseek-ai/dsh-
|
|
77
|
-
"@deepseek-ai/dsh-agent-
|
|
78
|
-
"@deepseek-ai/dsh-
|
|
79
|
-
"@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",
|
|
80
82
|
"@types/node": "^22.18.0",
|
|
81
83
|
"@types/qrcode": "^1.5.5",
|
|
82
84
|
"@types/react": "^19.1.0",
|
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
|
},
|
|
@@ -128,9 +128,12 @@ export class DshQQBridge {
|
|
|
128
128
|
const { agent, sessionId } = await this.ensureAgent(event.chatType, row)
|
|
129
129
|
event.sessionId = sessionId
|
|
130
130
|
if (event.isOwner) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
// Keep owner messages as native user/message events even when they carry
|
|
132
|
+
// images. DSH now owns the image projection: visual routes receive the
|
|
133
|
+
// durable image block, while text-only routes receive its stable text
|
|
134
|
+
// placeholder. Falling back to a display-only event here would discard
|
|
135
|
+
// the owner's message semantics before DSH can apply that policy.
|
|
136
|
+
this.appendOwnerMessageIfMissing(agent.session, event)
|
|
134
137
|
return sessionId
|
|
135
138
|
}
|
|
136
139
|
this.appendDisplayIfMissing(agent.session, event)
|
|
@@ -178,15 +181,7 @@ export class DshQQBridge {
|
|
|
178
181
|
this.injectMemorySnapshot(agent, sessionId, contextText)
|
|
179
182
|
}
|
|
180
183
|
|
|
181
|
-
const
|
|
182
|
-
const currentContent: ContentBlock[] = [{ type: 'text', text: currentText }]
|
|
183
|
-
if (attachments.length && await this.imageRouteAvailable(agent)) {
|
|
184
|
-
for (const attachment of attachments) {
|
|
185
|
-
if (attachment.kind === 'image' && attachment.imageRef) {
|
|
186
|
-
currentContent.push({ type: 'image', attachment: attachment.imageRef })
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
184
|
+
const currentContent = qqMessageContent(this.memory.currentMessageText(message), attachments)
|
|
190
185
|
const current = createUserMessage({
|
|
191
186
|
source: {
|
|
192
187
|
kind: 'qq-chat', botId: String(message.accountId), chatType: message.chatType,
|
|
@@ -340,7 +335,11 @@ export class DshQQBridge {
|
|
|
340
335
|
|
|
341
336
|
private appendDisplayIfMissing(session: Session, event: QQChatDisplayEvent): void {
|
|
342
337
|
if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId)) return
|
|
343
|
-
|
|
338
|
+
// Keep this on the public Session.append signature. Official DSH releases
|
|
339
|
+
// do not yet expose the optional ignorable envelope marker; if an older
|
|
340
|
+
// DSH cannot restore this plugin-only event, ensureAgent falls back to a
|
|
341
|
+
// fresh Session while SQLite remains the QQ history source of truth.
|
|
342
|
+
session.append('qqchat/message', displayEventPayload(event))
|
|
344
343
|
}
|
|
345
344
|
|
|
346
345
|
private appendOwnerMessageIfMissing(session: Session, event: QQChatDisplayEvent): void {
|
|
@@ -401,15 +400,6 @@ export class DshQQBridge {
|
|
|
401
400
|
return options
|
|
402
401
|
}
|
|
403
402
|
|
|
404
|
-
private async imageRouteAvailable(agent: AgentHandle['agent']): Promise<boolean> {
|
|
405
|
-
const route = this.selection(agent)?.current || { provider: agent.options.provider, model: agent.options.model }
|
|
406
|
-
if (!route.provider || !route.model) return false
|
|
407
|
-
try {
|
|
408
|
-
const info = await this.ctx.llm.resolveModelInfo(route.provider, route.model)
|
|
409
|
-
return info.inputModalities?.includes('image') === true
|
|
410
|
-
} catch { return false }
|
|
411
|
-
}
|
|
412
|
-
|
|
413
403
|
private ensureSelection(agent: AgentHandle['agent']): ModelSelectionRef | undefined {
|
|
414
404
|
const id = String(agent.id)
|
|
415
405
|
const existing = this.selections.get(id)
|
|
@@ -598,7 +588,18 @@ export class DshQQBridge {
|
|
|
598
588
|
function mediaPrompt(attachments: StoredAttachmentSummary[]): string {
|
|
599
589
|
if (!attachments.length) return ''
|
|
600
590
|
const lines = attachments.map(item => `- ${item.kind}: ${item.filename} (attachment_id=${item.id}${item.quoted ? ', 引用消息附件' : ''})`)
|
|
601
|
-
return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n
|
|
591
|
+
return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片由 DSH 根据当前模型能力处理;视觉模型可直接理解图片。`
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Build the durable DSH content for one QQ turn without pre-flighting the model. */
|
|
595
|
+
export function qqMessageContent(text: string, attachments: readonly StoredAttachmentSummary[]): ContentBlock[] {
|
|
596
|
+
const content: ContentBlock[] = [{ type: 'text', text: text + mediaPrompt([...attachments]) }]
|
|
597
|
+
for (const attachment of attachments) {
|
|
598
|
+
if (attachment.kind === 'image' && attachment.imageRef) {
|
|
599
|
+
content.push({ type: 'image', attachment: attachment.imageRef })
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return content
|
|
602
603
|
}
|
|
603
604
|
|
|
604
605
|
function transcriptContent(event: QQChatDisplayEvent, db: QQChatDatabase): ContentBlock[] {
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
|
|
7
|
+
const QQCHAT_SESSION_EVENT_TYPE = 'qqchat/message'
|
|
8
|
+
|
|
9
|
+
interface KnownEventTypesModule {
|
|
10
|
+
KNOWN_SESSION_EVENT_TYPES?: { add(type: string): unknown }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SessionEventRegistrationOptions {
|
|
14
|
+
readonly argv?: readonly string[]
|
|
15
|
+
readonly dshHome?: string
|
|
16
|
+
readonly anchors?: readonly string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function profileNameFromArgv(argv: readonly string[]): string | undefined {
|
|
20
|
+
const profileIndex = argv.indexOf('--profile')
|
|
21
|
+
if (profileIndex >= 0) {
|
|
22
|
+
const value = argv[profileIndex + 1]
|
|
23
|
+
if (value && !value.startsWith('-')) return value
|
|
24
|
+
}
|
|
25
|
+
const inline = argv.find(value => value.startsWith('--profile='))
|
|
26
|
+
const value = inline?.slice('--profile='.length)
|
|
27
|
+
return value && !value.startsWith('-') ? value : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register the plugin's log-only event in every reachable dsh-session copy.
|
|
32
|
+
*
|
|
33
|
+
* DSH does not expose a public downstream event registration API yet. This
|
|
34
|
+
* small compatibility shim follows the ecosystem convention used by other
|
|
35
|
+
* DSH plugins and is intentionally idempotent and best-effort.
|
|
36
|
+
*/
|
|
37
|
+
export async function registerQQChatSessionEventType(options: SessionEventRegistrationOptions = {}): Promise<number> {
|
|
38
|
+
const argv = options.argv || process.argv
|
|
39
|
+
const profileName = profileNameFromArgv(argv)
|
|
40
|
+
const dshHome = options.dshHome || process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
41
|
+
const profileAnchors = profileName
|
|
42
|
+
? [
|
|
43
|
+
join(dshHome, 'profiles', profileName, 'package.json'),
|
|
44
|
+
join(dshHome, 'profiles', 'package.json'),
|
|
45
|
+
]
|
|
46
|
+
: []
|
|
47
|
+
const anchors = [...(options.anchors || [import.meta.url, argv[1]]), ...profileAnchors].filter(
|
|
48
|
+
(anchor): anchor is string => typeof anchor === 'string' && anchor.length > 0,
|
|
49
|
+
)
|
|
50
|
+
const registeredPaths = new Set<string>()
|
|
51
|
+
let registered = 0
|
|
52
|
+
|
|
53
|
+
for (const anchor of anchors) {
|
|
54
|
+
try {
|
|
55
|
+
const require = createRequire(anchor)
|
|
56
|
+
registered += await registerSessionCopy(require, registeredPaths)
|
|
57
|
+
// Persistence may resolve its own peer dependency from a different
|
|
58
|
+
// package tree. Register the copy selected by that validator too.
|
|
59
|
+
try {
|
|
60
|
+
const persistence = require.resolve('@deepseek-ai/dsh-session-persistence')
|
|
61
|
+
registered += await registerSessionCopy(createRequire(persistence), registeredPaths)
|
|
62
|
+
} catch {
|
|
63
|
+
// Some minimal profiles do not ship the persistence package.
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// A profile may not expose this package from one of the anchors. The
|
|
67
|
+
// other anchor can still reach the validator used by the running host.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return registered
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function registerSessionCopy(require: NodeRequire, registeredPaths: Set<string>): Promise<number> {
|
|
75
|
+
const resolved = require.resolve('@deepseek-ai/dsh-session')
|
|
76
|
+
const candidates = [resolved]
|
|
77
|
+
// The DSH development CLI can resolve workspace source through tsx while
|
|
78
|
+
// the published package resolves lib/. Register both when source exists.
|
|
79
|
+
const packageRoot = dirname(dirname(resolved))
|
|
80
|
+
candidates.push(join(packageRoot, 'src', 'index.ts'))
|
|
81
|
+
candidates.push(join(packageRoot, 'src', 'index.js'))
|
|
82
|
+
let registered = 0
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
let physical: string
|
|
85
|
+
try { physical = realpathSync(candidate) } catch { continue }
|
|
86
|
+
if (registeredPaths.has(physical)) continue
|
|
87
|
+
registeredPaths.add(physical)
|
|
88
|
+
try {
|
|
89
|
+
// DSH publishes ESM builds. Dynamic import also handles a CJS build and
|
|
90
|
+
// preserves the actual module instance selected by the host anchor.
|
|
91
|
+
const module = await import(pathToFileURL(physical).href) as KnownEventTypesModule
|
|
92
|
+
if (module.KNOWN_SESSION_EVENT_TYPES?.add) {
|
|
93
|
+
module.KNOWN_SESSION_EVENT_TYPES.add(QQCHAT_SESSION_EVENT_TYPE)
|
|
94
|
+
registered += 1
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// A published package may not ship its source entry; the lib entry above
|
|
98
|
+
// remains the normal registration target.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return registered
|
|
102
|
+
}
|