dsh-qqchat 0.2.5 → 0.2.7
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 +8 -7
- package/README.md +6 -6
- package/client-src/plugin.cts +2 -2
- package/client-src/settings.cts +6 -19
- package/client-src/shared.cts +4 -4
- package/cordis.patch.yml +4 -0
- package/docs/ARCHITECTURE.en.md +1 -1
- package/docs/ARCHITECTURE.md +2 -1
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/STREAMING.md +8 -18
- package/lib/client.js +7 -14
- package/lib/config.js +2 -5
- package/lib/gateway/api.d.ts +2 -3
- package/lib/gateway/api.js +3 -10
- package/lib/index.d.ts +2 -2
- package/lib/index.js +2 -2
- package/lib/session/agent-bridge.d.ts +3 -1
- package/lib/session/agent-bridge.js +43 -70
- package/lib/session/runtime.d.ts +2 -0
- package/lib/session/runtime.js +13 -15
- package/lib/storage/db.js +12 -3
- package/lib/transport/rpc.js +2 -4
- package/lib/types.d.ts +1 -3
- package/package.json +25 -24
- package/src/config.ts +2 -5
- package/src/gateway/api.ts +4 -13
- package/src/index.ts +2 -2
- package/src/session/agent-bridge.ts +41 -69
- package/src/session/runtime.ts +13 -12
- package/src/storage/db.ts +15 -3
- package/src/transport/rpc.ts +2 -3
- package/src/types.ts +1 -3
package/README.en.md
CHANGED
|
@@ -6,8 +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-0.1.
|
|
10
|
-
<img src="https://img.shields.io/badge/QQChat-0.2.
|
|
9
|
+
<img src="https://img.shields.io/badge/DSH-0.1.6--alpha.1-6f42c1.svg" alt="DSH 0.1.6-alpha.1">
|
|
10
|
+
<img src="https://img.shields.io/badge/QQChat-0.2.7-2ea44f.svg" alt="QQChat 0.2.7">
|
|
11
11
|
<img src="https://img.shields.io/badge/TypeScript-5.x-3178c6.svg" alt="TypeScript">
|
|
12
12
|
</p>
|
|
13
13
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
<p><em>Chat in QQ, then view the complete Session, memory and tool activity in DSH Web.</em></p>
|
|
20
20
|
|
|
21
|
-
<p>This release is verified against DSH <code>0.1.
|
|
21
|
+
<p>This release is verified against DSH <code>0.1.6-alpha.1</code>. Install the plugin as-is; no DSH source patch or rebuild is required.</p>
|
|
22
22
|
|
|
23
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.
|
|
24
24
|
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
| QR connection | Binds an official QQ Bot from **Settings -> QQ Chat** | Cancel the binding and scan again when needed |
|
|
37
37
|
| Manual connection | Connects with AppID and AppSecret from the settings page | Useful for headless servers or QR failures; the secret is sent only to the Host |
|
|
38
38
|
| Group receive modes | Auto reply, Mention only or Silent record | Silent record stores messages without waking the Agent |
|
|
39
|
-
| Message formats |
|
|
40
|
-
| Direct-chat streaming |
|
|
39
|
+
| Message formats | Markdown or Plain compatibility | Group and direct-chat formats are configured separately; groups default to Plain compatibility |
|
|
40
|
+
| Direct-chat streaming | Markdown format automatically uses QQ's official streaming message API | Plain compatibility uses a regular send; some clients may not display streaming |
|
|
41
41
|
| Quotes and mentions | Preserves quoted messages and resolves QQ mentions to display names | Stable IDs remain in metadata |
|
|
42
42
|
| Memory system | Stores group, member and direct-user memory; user memory can be reused across groups under the same Bot | Group relationships and group events stay scoped; can be disabled |
|
|
43
43
|
| Memory reflection | Converts recent daily notes into long-term memory | Runs asynchronously after idle or batch thresholds |
|
|
@@ -126,7 +126,8 @@ npx @deepseek-ai/dsh plugin --profile qqchat add @deepseek-ai/dsh-web-app dsh-qq
|
|
|
126
126
|
npx @deepseek-ai/dsh --profile qqchat
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
-
Git installs
|
|
129
|
+
Git installs use the committed `lib/` runtime artifacts, so they do not need to run
|
|
130
|
+
an install-time build. For local development:
|
|
130
131
|
|
|
131
132
|
```bash
|
|
132
133
|
git clone git@github.com:Coffeiz/dsh-qqchat.git
|
|
@@ -145,7 +146,7 @@ Configure **Settings -> QQ Chat -> Message reception**:
|
|
|
145
146
|
| Mention only | All messages are stored, but only messages mentioning the Bot trigger a reply |
|
|
146
147
|
| Silent record | Stores messages and memory without actively replying |
|
|
147
148
|
|
|
148
|
-
Groups default to Plain compatibility format. Group and direct-chat formats can be selected independently
|
|
149
|
+
Groups default to Plain compatibility format. Group and direct-chat formats can be selected independently between Markdown and Plain compatibility. Markdown direct chats stream by default; Plain compatibility uses a regular send. There is no separate streaming toggle.
|
|
149
150
|
|
|
150
151
|
## Commands
|
|
151
152
|
|
package/README.md
CHANGED
|
@@ -6,8 +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-0.1.
|
|
10
|
-
<img src="https://img.shields.io/badge/QQChat-0.2.
|
|
9
|
+
<img src="https://img.shields.io/badge/DSH-0.1.6--alpha.1-6f42c1.svg" alt="DSH 0.1.6-alpha.1">
|
|
10
|
+
<img src="https://img.shields.io/badge/QQChat-0.2.7-2ea44f.svg" alt="QQChat 0.2.7">
|
|
11
11
|
<img src="https://img.shields.io/badge/TypeScript-5.x-3178c6.svg" alt="TypeScript">
|
|
12
12
|
</p>
|
|
13
13
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
<p><em>dsh-qqchat 是 DeepSeek Harness(DSH)的 QQ Bot 插件,支持 QQ 群聊、私聊、会话隔离、记忆系统和 DSH 原生命令。</em></p>
|
|
21
21
|
|
|
22
|
-
<p>当前版本基于 DSH <code>0.1.
|
|
22
|
+
<p>当前版本基于 DSH <code>0.1.6-alpha.1</code> 验证,安装插件即可使用,无需修改或重新构建 DSH。</p>
|
|
23
23
|
|
|
24
24
|
> 本项目是一个 Vibe Coding 项目,代码质量和工程完整性可能需要改进。如果你发现问题或有更好的实现建议,欢迎提交 [Issue](https://github.com/Coffeiz/dsh-qqchat/issues) 或 [Pull Request](https://github.com/Coffeiz/dsh-qqchat/pulls)。
|
|
25
25
|
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
| 手动连接 | 在设置页填写 AppID 和 AppSecret 连接 QQ Bot | 适合无手机、服务器或扫码失败场景;密钥只提交到 Host |
|
|
35
35
|
| 断线重连 | QQ Gateway 断线后自动恢复连接 | 无需重复扫码 |
|
|
36
36
|
| 群聊回复模式 | 自动回应、@回复、静默记录 | 可在设置页随时切换 |
|
|
37
|
-
|
|
|
38
|
-
| 私聊流式回复 |
|
|
37
|
+
| 消息格式 | Markdown、纯文本兼容 | 群聊与私聊可分别设置;群聊默认纯文本兼容 |
|
|
38
|
+
| 私聊流式回复 | Markdown 格式自动使用 QQ 官方流式接口 | 纯文本兼容格式使用普通发送;部分客户端可能不可见 |
|
|
39
39
|
| 消息引用与 @ | 支持引用消息和 QQ @消息 | @用户优先显示用户名 |
|
|
40
40
|
| 会话隔离 | QQ 群聊和私聊分别使用独立会话 | 不同群、不同用户的上下文互相隔离 |
|
|
41
41
|
| 记忆系统 | 保存群聊、群成员和私聊用户的独立记忆;同一 Bot 下的用户记忆支持跨群复用 | 群关系和群级事件不会跨群共享;设置中可关闭 |
|
|
@@ -121,7 +121,7 @@ npx @deepseek-ai/dsh --profile qqchat
|
|
|
121
121
|
| @回复 | 所有消息都会记录,但只有 @Bot 的消息会触发回复 |
|
|
122
122
|
| 静默记录 | 只保存消息和记忆,不主动回复 |
|
|
123
123
|
|
|
124
|
-
群聊默认使用纯文本兼容格式,适合不同 QQ
|
|
124
|
+
群聊默认使用纯文本兼容格式,适合不同 QQ 客户端。私聊和群聊可以分别选择 Markdown 或纯文本兼容。私聊选择 Markdown 时默认使用 QQ 官方流式传输;选择纯文本兼容时使用普通发送,不再提供单独的流式开关。
|
|
125
125
|
|
|
126
126
|
## 命令
|
|
127
127
|
|
package/client-src/plugin.cts
CHANGED
|
@@ -3,12 +3,12 @@ import type { Ctx } from './shared.cjs'
|
|
|
3
3
|
import { QQSettings } from './settings.cjs'
|
|
4
4
|
import { QQSessionUtility, QQTranscriptNode, createQQMessageDefinition } from './workspace.cjs'
|
|
5
5
|
|
|
6
|
-
exports.inject = ['slots', 'connection', '
|
|
6
|
+
exports.inject = ['slots', 'connection', 'uiConversation'] as const
|
|
7
7
|
exports.apply = function apply(ctx: Ctx) {
|
|
8
8
|
setQQRpc(ctx.connection.rpc)
|
|
9
9
|
ctx.effect(installStyles, 'dsh-qqchat: client styles')
|
|
10
10
|
ctx.effect(installQQSettingsIcon, 'dsh-qqchat: QQ settings icon')
|
|
11
|
-
ctx.
|
|
11
|
+
ctx.uiConversation.events.register(createQQMessageDefinition())
|
|
12
12
|
|
|
13
13
|
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'qqchat', order: 35, label: () => 'QQ Chat', inject: () => ({ rpc: ctx.connection.rpc }) }, QQSettings))
|
|
14
14
|
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'qqchat-message' }, QQTranscriptNode))
|
package/client-src/settings.cts
CHANGED
|
@@ -116,8 +116,8 @@ export function QQSettings({ rpc }: { rpc: Rpc }) {
|
|
|
116
116
|
align: 'end',
|
|
117
117
|
items: [{ id: '', label: '不设置 Owner' }, ...members.map(member => ({ id: member.platformUserId, label: member.displayName || 'QQ 群友' }))],
|
|
118
118
|
})
|
|
119
|
-
const formatLabel = (value: ReplyFormat | undefined) => value === '
|
|
120
|
-
const formatItems = [{ id: '
|
|
119
|
+
const formatLabel = (value: ReplyFormat | undefined) => value === 'compat' ? '纯文本兼容' : 'Markdown'
|
|
120
|
+
const formatItems = [{ id: 'markdown', label: 'Markdown' }, { id: 'compat', label: '纯文本兼容' }]
|
|
121
121
|
const groupReplyPicker = h(Menu, {
|
|
122
122
|
open: groupReplyMenuOpen,
|
|
123
123
|
onClose: () => setGroupReplyMenuOpen(false),
|
|
@@ -195,23 +195,10 @@ export function QQSettings({ rpc }: { rpc: Rpc }) {
|
|
|
195
195
|
h('div', null, h('div', { className: 'qqSettingTitle' }, '群聊消息兼容格式'), h('div', { className: 'qqSettingHelp' }, '仅控制发送到 QQ 群聊时使用的消息格式。')),
|
|
196
196
|
h('div', { className: 'qqControl' }, groupReplyPicker)),
|
|
197
197
|
h('div', { className: 'qqSetting' },
|
|
198
|
-
h('div', null, h('div', { className: 'qqSettingTitle' }, '
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
h('
|
|
202
|
-
h('input', {
|
|
203
|
-
type: 'checkbox',
|
|
204
|
-
checked: settings.directStreamingEnabled && settings.directReplyFormat !== 'compat',
|
|
205
|
-
disabled: settings.directReplyFormat === 'compat',
|
|
206
|
-
onChange: (event: import('react').ChangeEvent<HTMLInputElement>) => void patch({ directStreamingEnabled: event.target.checked }),
|
|
207
|
-
}),
|
|
208
|
-
h('span', null,
|
|
209
|
-
h('div', { className: 'qqSettingTitle' }, '私聊流式回复'),
|
|
210
|
-
h('div', { className: 'qqSettingHelp' }, settings.directReplyFormat === 'compat'
|
|
211
|
-
? '纯文本兼容模式不支持流式传输,已自动关闭。'
|
|
212
|
-
: settings.directStreamingEnabled
|
|
213
|
-
? '使用 QQ 官方流式接口逐步更新私聊回复;部分 QQ 客户端可能看不到流式效果。'
|
|
214
|
-
: '使用普通一次性消息发送,兼容性更好。'))))
|
|
198
|
+
h('div', null, h('div', { className: 'qqSettingTitle' }, '私聊消息格式'), h('div', { className: 'qqSettingHelp' }, settings.directReplyFormat === 'markdown'
|
|
199
|
+
? 'Markdown 格式默认使用 QQ 官方流式接口逐步更新回复;部分客户端可能看不到流式效果。'
|
|
200
|
+
: '纯文本兼容格式使用普通发送,不启用流式传输。')),
|
|
201
|
+
h('div', { className: 'qqControl' }, directReplyPicker))
|
|
215
202
|
)
|
|
216
203
|
const memoryGroup = h('div', { className: 'qqSettingsGroup' },
|
|
217
204
|
h('h3', null, '记忆系统'),
|
package/client-src/shared.cts
CHANGED
|
@@ -18,7 +18,7 @@ export function getQQRpc(): Rpc | undefined { return qqRpc }
|
|
|
18
18
|
|
|
19
19
|
export type ChatType = 'c2c' | 'group'
|
|
20
20
|
export type GroupReceiveMode = 'auto' | 'mention' | 'silent'
|
|
21
|
-
export type ReplyFormat = '
|
|
21
|
+
export type ReplyFormat = 'markdown' | 'compat'
|
|
22
22
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
|
23
23
|
export type RpcResult<T> = { ok: true; value: T } | { ok: false; error?: { message?: string } }
|
|
24
24
|
export interface Rpc { call(channel: string, endpoint: string, payload: unknown): Promise<RpcResult<unknown>> }
|
|
@@ -29,16 +29,16 @@ export interface SlotRegistry {
|
|
|
29
29
|
inject(name: string, fn: () => unknown): unknown
|
|
30
30
|
register<P>(options: Record<string, unknown>, component: import('react').ComponentType<P>): unknown
|
|
31
31
|
}
|
|
32
|
-
export interface
|
|
32
|
+
export interface UiConversation { events: { register(definition: Record<string, unknown>): unknown } }
|
|
33
33
|
export interface Ctx {
|
|
34
34
|
connection: { rpc: Rpc }
|
|
35
35
|
sessions: SessionsService
|
|
36
|
-
|
|
36
|
+
uiConversation: UiConversation
|
|
37
37
|
effect(fn: () => (() => void) | void, label: string): unknown
|
|
38
38
|
slots: SlotRegistry
|
|
39
39
|
}
|
|
40
40
|
export interface Account { id: number; appId: string; enabled: boolean; gatewayStatus: string; gatewayLastError?: string | null }
|
|
41
|
-
export interface Settings { memoryEnabled: boolean; memoryMemberBatchEnabled: boolean; groupReceiveMode: GroupReceiveMode; groupReplyFormat: ReplyFormat; directReplyFormat: ReplyFormat;
|
|
41
|
+
export interface Settings { memoryEnabled: boolean; memoryMemberBatchEnabled: boolean; groupReceiveMode: GroupReceiveMode; groupReplyFormat: ReplyFormat; directReplyFormat: ReplyFormat; groupMembersCanUseTools: boolean; groupMembersCanReceiveMedia: boolean; groupMembersCanReadMedia: boolean; ownerUserId: string }
|
|
42
42
|
export interface KnownMember { id: number; platformUserId: string; displayName: string; lastSeenAt: number }
|
|
43
43
|
export interface ChatItem { chatType: ChatType; rowId: number; platformId: string; displayName: string; dshSessionId: string | null; lastMessageAt: number | null; messageCount: number }
|
|
44
44
|
export interface Memory { profile: string; summary: string; daily: string; memory: string; pattern: string }
|
package/cordis.patch.yml
CHANGED
package/docs/ARCHITECTURE.en.md
CHANGED
|
@@ -339,7 +339,7 @@ client-src/*.cts
|
|
|
339
339
|
tests/*.test.ts
|
|
340
340
|
```
|
|
341
341
|
|
|
342
|
-
The Host is built with `tsc`. The Client is compiled to temporary CJS and then wrapped by `scripts/wrap-client.mjs` as a DSH Client factory. `lib/` is generated output
|
|
342
|
+
The Host is built with `tsc`. The Client is compiled to temporary CJS and then wrapped by `scripts/wrap-client.mjs` as a DSH Client factory. `lib/` is generated release output committed with fixed revisions so DSH Store can find the runtime entry without executing third-party install scripts; source remains the implementation truth.
|
|
343
343
|
|
|
344
344
|
## Development and security documentation
|
|
345
345
|
|
package/docs/ARCHITECTURE.md
CHANGED
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -33,7 +33,7 @@ QQChat 是 TypeScript 插件,Host 和 Client 一起构建。日常开发不需
|
|
|
33
33
|
| 群友静默记录、未 @ 消息展示 | 无 QQ 自定义展示事件 | 使用插件 Client 渲染 `qqchat/message`;不送入模型 |
|
|
34
34
|
| 图片输入 | 取决于当前 DSH 模型路由和附件服务 | 使用 DSH 原生图片块;不支持视觉时使用稳定文本降级和读取工具 |
|
|
35
35
|
| 音频、视频和普通文件 | DSH 不提供统一的 QQ 媒体理解能力 | 保存附件并提供类型提示、文件工具或媒体信息工具 |
|
|
36
|
-
| QQ 私聊流式回复 | DSH 不负责 QQ Gateway 流式协议 |
|
|
36
|
+
| QQ 私聊流式回复 | DSH 不负责 QQ Gateway 流式协议 | Markdown 格式自动使用 QQ 官方流式接口;纯文本兼容格式使用普通发送;失败时按已发送帧安全回退 |
|
|
37
37
|
| QQ 引用、@和气泡 | DSH 不认识 QQ 协议字段 | 插件把引用和 @ 转为可读文本/元数据,并由 Client 做展示 |
|
|
38
38
|
| DSH 原生命令 | DSH 只处理 DSH Web 自己收到的命令 | 插件将 `/qq...` 命令解析后转交或执行,避免与 DSH 原命令冲突 |
|
|
39
39
|
|
|
@@ -60,7 +60,9 @@ client-src/*.cts -> 临时 CJS Client
|
|
|
60
60
|
临时 CJS -> lib/client.js DSH ModuleLoader factory
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
`lib/`
|
|
63
|
+
`lib/` 是由构建生成的发布产物,同时提交到仓库以支持 DSH Store 从固定 Commit
|
|
64
|
+
直接安装;修改源码后发布前必须重新构建并检查 `lib/` 的变更。临时 Client
|
|
65
|
+
构建目录仍是本地生成物,不提交。
|
|
64
66
|
|
|
65
67
|
## 启动 DSH Web
|
|
66
68
|
|
package/docs/STREAMING.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
推荐采用以下结构:
|
|
12
12
|
|
|
13
13
|
```text
|
|
14
|
-
DSH assistant
|
|
14
|
+
DSH `agent/assistant-stream` 的 chunk frame
|
|
15
15
|
-> StreamingWriter
|
|
16
16
|
-> 累计完整文本
|
|
17
17
|
-> 节流定时器
|
|
@@ -118,7 +118,7 @@ interface StreamingWriterState {
|
|
|
118
118
|
|
|
119
119
|
### 2. 累计全文,不累计分片
|
|
120
120
|
|
|
121
|
-
DSH 的 `assistant
|
|
121
|
+
DSH 的 `agent/assistant-stream` chunk frame 通常携带增量文本:
|
|
122
122
|
|
|
123
123
|
```text
|
|
124
124
|
"你好"
|
|
@@ -207,25 +207,15 @@ abort()
|
|
|
207
207
|
| 已成功发送部分流式内容 | 不发送完整普通消息,避免用户收到重复回复 |
|
|
208
208
|
| DONE 帧失败但已有内容 | 记录错误,不重复发送全文 |
|
|
209
209
|
| 模型没有文本输出 | 不创建流,沿用插件已有的空回复处理 |
|
|
210
|
-
|
|
|
210
|
+
| 纯文本兼容格式 | 完全跳过流式接口,直接使用普通发送 |
|
|
211
211
|
|
|
212
212
|
普通发送也要继续使用已有的 Markdown/纯文本兼容格式和主动发送回退逻辑。
|
|
213
213
|
|
|
214
|
-
##
|
|
214
|
+
## 设置与格式联动
|
|
215
215
|
|
|
216
|
-
|
|
216
|
+
设置页只提供两种发送格式:Markdown 与纯文本兼容,不再提供“智能格式”或独立流式开关。私聊选择 Markdown 时自动使用 QQ 官方流式接口;选择纯文本兼容时跳过流式接口并普通发送。私聊默认 Markdown,群聊默认纯文本兼容;群聊始终普通发送,因为官方 `stream_messages` 只覆盖 C2C 私聊。
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
interface QQChatRuntimeSettings {
|
|
220
|
-
directStreamingEnabled: boolean
|
|
221
|
-
}
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
推荐默认值:`true`,保持官方流式能力;如果目标客户端普遍看不到流式效果,也可以在产品层默认关闭。
|
|
225
|
-
|
|
226
|
-
设置说明应明确:
|
|
227
|
-
|
|
228
|
-
> 私聊流式回复使用 QQ 官方流式接口逐步更新消息;部分 QQ 客户端可能看不到流式效果。关闭后使用普通一次性消息发送。
|
|
218
|
+
历史设置中的 `smart` 会迁移为 Markdown;旧的 `directStreamingEnabled` 设置项会被清理。Markdown 权限不可用时,普通发送仍保留纯文本降级。
|
|
229
219
|
|
|
230
220
|
群聊不应被这个开关影响,因为 QQ 官方 `stream_messages` 方案只覆盖 C2C 私聊。
|
|
231
221
|
|
|
@@ -310,7 +300,7 @@ if (writer.sentFrameCount === 0) {
|
|
|
310
300
|
- [x] 所有请求通过串行队列;
|
|
311
301
|
- [x] 使用节流减少更新频率;
|
|
312
302
|
- [x] 对发送失败区分“尚未发送”和“已经发送部分”;
|
|
313
|
-
- [x]
|
|
303
|
+
- [x] 私聊 Markdown 自动流式,纯文本兼容自动普通发送;
|
|
314
304
|
- [x] 使用模拟 HTTP 测试覆盖首帧、后续帧、结束帧和队列竞态;
|
|
315
305
|
- [ ] 真实 QQ 手机端和桌面端分别验证所有客户端的可见性;
|
|
316
306
|
- [x] 日志只记录 `msg_seq`、`index`、是否有 `stream_msg_id` 等定位信息,不记录完整敏感正文。
|
|
@@ -333,7 +323,7 @@ QQ 会在 `msg_seq` 重复或流式上下文不完整时返回 `40054005`。当
|
|
|
333
323
|
| 中途失败 | 不发送完整重复消息 |
|
|
334
324
|
| 429/50002 | 按退避策略重试,超过次数后降级或报错 |
|
|
335
325
|
| 空回复 | 不发送空流式消息 |
|
|
336
|
-
|
|
|
326
|
+
| 私聊纯文本兼容 | 不访问 `stream_messages` |
|
|
337
327
|
| 群聊 | 不访问 `stream_messages` |
|
|
338
328
|
|
|
339
329
|
## 当前实现位置
|
package/lib/client.js
CHANGED
|
@@ -6,12 +6,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const shared_cjs_1 = require("./shared.cjs");
|
|
7
7
|
const settings_cjs_1 = require("./settings.cjs");
|
|
8
8
|
const workspace_cjs_1 = require("./workspace.cjs");
|
|
9
|
-
exports.inject = ['slots', 'connection', '
|
|
9
|
+
exports.inject = ['slots', 'connection', 'uiConversation'];
|
|
10
10
|
exports.apply = function apply(ctx) {
|
|
11
11
|
(0, shared_cjs_1.setQQRpc)(ctx.connection.rpc);
|
|
12
12
|
ctx.effect(shared_cjs_1.installStyles, 'dsh-qqchat: client styles');
|
|
13
13
|
ctx.effect(shared_cjs_1.installQQSettingsIcon, 'dsh-qqchat: QQ settings icon');
|
|
14
|
-
ctx.
|
|
14
|
+
ctx.uiConversation.events.register((0, workspace_cjs_1.createQQMessageDefinition)());
|
|
15
15
|
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'qqchat', order: 35, label: () => 'QQ Chat', inject: () => ({ rpc: ctx.connection.rpc }) }, settings_cjs_1.QQSettings));
|
|
16
16
|
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'qqchat-message' }, workspace_cjs_1.QQTranscriptNode));
|
|
17
17
|
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({ name: 'conversation.session.header.utilities', id: 'qqchat-memory', order: 30, inject: () => ({ rpc: ctx.connection.rpc }) }, workspace_cjs_1.QQSessionUtility));
|
|
@@ -182,8 +182,8 @@ function QQSettings({ rpc }) {
|
|
|
182
182
|
align: 'end',
|
|
183
183
|
items: [{ id: '', label: '不设置 Owner' }, ...members.map(member => ({ id: member.platformUserId, label: member.displayName || 'QQ 群友' }))],
|
|
184
184
|
});
|
|
185
|
-
const formatLabel = (value) => value === '
|
|
186
|
-
const formatItems = [{ id: '
|
|
185
|
+
const formatLabel = (value) => value === 'compat' ? '纯文本兼容' : 'Markdown';
|
|
186
|
+
const formatItems = [{ id: 'markdown', label: 'Markdown' }, { id: 'compat', label: '纯文本兼容' }];
|
|
187
187
|
const groupReplyPicker = (0, shared_cjs_1.h)(shared_cjs_1.Menu, {
|
|
188
188
|
open: groupReplyMenuOpen,
|
|
189
189
|
onClose: () => setGroupReplyMenuOpen(false),
|
|
@@ -235,16 +235,9 @@ function QQSettings({ rpc }) {
|
|
|
235
235
|
footer: [{ type: 'label', id: 'receive-help-note', text: '未开启时只能收到 @ 消息,非 @ 消息不会进入会话记录' }],
|
|
236
236
|
anchor: (0, shared_cjs_1.h)(shared_cjs_1.Button, { size: 'sm', variant: 'outline', onClick: () => setReceiveHelpOpen(!receiveHelpOpen) }, '设置方法'),
|
|
237
237
|
});
|
|
238
|
-
const receiveGroup = (0, shared_cjs_1.h)('div', { className: 'qqSettingsGroup' }, (0, shared_cjs_1.h)('h3', null, '消息接收'), (0, shared_cjs_1.h)('div', { className: 'qqSetting' }, (0, shared_cjs_1.h)('div', null, (0, shared_cjs_1.h)('div', { className: 'qqSettingTitle' }, '群聊接收方式'), (0, shared_cjs_1.h)('div', { className: 'qqSettingHelp qqHelpRow' }, '自动回应:每条群消息都会触发 Agent;@回复:所有消息都记录,但只有 @Bot 才唤醒 Agent;静默记录:只记录群聊与记忆,不主动回应。', receiveHelp)), (0, shared_cjs_1.h)('div', { className: 'qqControl' }, receiveButtons)), (0, shared_cjs_1.h)('div', { className: 'qqSetting' }, (0, shared_cjs_1.h)('div', null, (0, shared_cjs_1.h)('div', { className: 'qqSettingTitle' }, '群聊消息兼容格式'), (0, shared_cjs_1.h)('div', { className: 'qqSettingHelp' }, '仅控制发送到 QQ 群聊时使用的消息格式。')), (0, shared_cjs_1.h)('div', { className: 'qqControl' }, groupReplyPicker)), (0, shared_cjs_1.h)('div', { className: 'qqSetting' }, (0, shared_cjs_1.h)('div', null, (0, shared_cjs_1.h)('div', { className: 'qqSettingTitle' }, '
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
disabled: settings.directReplyFormat === 'compat',
|
|
242
|
-
onChange: (event) => void patch({ directStreamingEnabled: event.target.checked }),
|
|
243
|
-
}), (0, shared_cjs_1.h)('span', null, (0, shared_cjs_1.h)('div', { className: 'qqSettingTitle' }, '私聊流式回复'), (0, shared_cjs_1.h)('div', { className: 'qqSettingHelp' }, settings.directReplyFormat === 'compat'
|
|
244
|
-
? '纯文本兼容模式不支持流式传输,已自动关闭。'
|
|
245
|
-
: settings.directStreamingEnabled
|
|
246
|
-
? '使用 QQ 官方流式接口逐步更新私聊回复;部分 QQ 客户端可能看不到流式效果。'
|
|
247
|
-
: '使用普通一次性消息发送,兼容性更好。')))));
|
|
238
|
+
const receiveGroup = (0, shared_cjs_1.h)('div', { className: 'qqSettingsGroup' }, (0, shared_cjs_1.h)('h3', null, '消息接收'), (0, shared_cjs_1.h)('div', { className: 'qqSetting' }, (0, shared_cjs_1.h)('div', null, (0, shared_cjs_1.h)('div', { className: 'qqSettingTitle' }, '群聊接收方式'), (0, shared_cjs_1.h)('div', { className: 'qqSettingHelp qqHelpRow' }, '自动回应:每条群消息都会触发 Agent;@回复:所有消息都记录,但只有 @Bot 才唤醒 Agent;静默记录:只记录群聊与记忆,不主动回应。', receiveHelp)), (0, shared_cjs_1.h)('div', { className: 'qqControl' }, receiveButtons)), (0, shared_cjs_1.h)('div', { className: 'qqSetting' }, (0, shared_cjs_1.h)('div', null, (0, shared_cjs_1.h)('div', { className: 'qqSettingTitle' }, '群聊消息兼容格式'), (0, shared_cjs_1.h)('div', { className: 'qqSettingHelp' }, '仅控制发送到 QQ 群聊时使用的消息格式。')), (0, shared_cjs_1.h)('div', { className: 'qqControl' }, groupReplyPicker)), (0, shared_cjs_1.h)('div', { className: 'qqSetting' }, (0, shared_cjs_1.h)('div', null, (0, shared_cjs_1.h)('div', { className: 'qqSettingTitle' }, '私聊消息格式'), (0, shared_cjs_1.h)('div', { className: 'qqSettingHelp' }, settings.directReplyFormat === 'markdown'
|
|
239
|
+
? 'Markdown 格式默认使用 QQ 官方流式接口逐步更新回复;部分客户端可能看不到流式效果。'
|
|
240
|
+
: '纯文本兼容格式使用普通发送,不启用流式传输。')), (0, shared_cjs_1.h)('div', { className: 'qqControl' }, directReplyPicker)));
|
|
248
241
|
const memoryGroup = (0, shared_cjs_1.h)('div', { className: 'qqSettingsGroup' }, (0, shared_cjs_1.h)('h3', null, '记忆系统'), (0, shared_cjs_1.h)('div', { className: 'qqSetting' }, (0, shared_cjs_1.h)('div', null, (0, shared_cjs_1.h)('div', { className: 'qqSettingTitle' }, '启用记忆系统'), (0, shared_cjs_1.h)('div', { className: 'qqSettingHelp' }, '记忆会在新 Session、上下文 TTL 过期、记忆变化或 Session 压缩后刷新,不会每轮重复注入完整上下文;后台会在空闲时整理近期消息。启用记忆可能降低上下文缓存命中率并增加输入 token。关闭后停止记忆注入和后台整理,但不会删除已有记忆。')), (0, shared_cjs_1.h)('div', { className: 'qqControl' }, (0, shared_cjs_1.h)(shared_cjs_1.Button, { size: 'sm', variant: settings.memoryEnabled ? 'primary' : 'outline', onClick: () => void patch({ memoryEnabled: !settings.memoryEnabled }) }, settings.memoryEnabled ? '已启用' : '已关闭'))));
|
|
249
242
|
const ownerSetting = !settings.groupMembersCanUseTools
|
|
250
243
|
? (0, shared_cjs_1.h)('div', { className: 'qqSetting' }, (0, shared_cjs_1.h)('div', null, (0, shared_cjs_1.h)('div', { className: 'qqSettingTitle' }, 'Owner stable ID'), (0, shared_cjs_1.h)('div', { className: 'qqSettingHelp' }, '使用 stable sender ID,不根据昵称判断。')), (0, shared_cjs_1.h)('div', { className: 'qqControl' }, (0, shared_cjs_1.h)(shared_cjs_1.Input, {
|
package/lib/config.js
CHANGED
|
@@ -6,7 +6,7 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
6
6
|
groupChatEnabled: true,
|
|
7
7
|
groupRequiresAt: true,
|
|
8
8
|
groupReadEnabled: true,
|
|
9
|
-
replyFormat: '
|
|
9
|
+
replyFormat: 'markdown',
|
|
10
10
|
recentGroupMessages: 40,
|
|
11
11
|
reflectionIdleMs: 900_000,
|
|
12
12
|
reflectionBatchSize: 20,
|
|
@@ -16,9 +16,7 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
16
16
|
});
|
|
17
17
|
export function resolveConfig(input = {}) {
|
|
18
18
|
const home = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
19
|
-
const replyFormat =
|
|
20
|
-
? input.replyFormat
|
|
21
|
-
: DEFAULT_CONFIG.replyFormat;
|
|
19
|
+
const replyFormat = input.replyFormat === 'compat' ? 'compat' : 'markdown';
|
|
22
20
|
return Object.freeze({
|
|
23
21
|
dataDir: resolve(input.dataDir || join(home, 'plugins', 'dsh-qqchat')),
|
|
24
22
|
source: input.source || DEFAULT_CONFIG.source,
|
|
@@ -49,7 +47,6 @@ export function defaultRuntimeSettings(config) {
|
|
|
49
47
|
groupReceiveMode,
|
|
50
48
|
groupReplyFormat: 'compat',
|
|
51
49
|
directReplyFormat: config.replyFormat,
|
|
52
|
-
directStreamingEnabled: config.replyFormat !== 'compat',
|
|
53
50
|
groupMembersCanUseTools: false,
|
|
54
51
|
groupMembersCanReceiveMedia: true,
|
|
55
52
|
groupMembersCanReadMedia: false,
|
package/lib/gateway/api.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { QQChatDatabase } from '../storage/db.js';
|
|
2
|
-
import type { AccountRow, QQChatConfig, QQSendOptions
|
|
2
|
+
import type { AccountRow, QQChatConfig, QQSendOptions } from '../types.js';
|
|
3
3
|
export interface QQPrivateTextStream {
|
|
4
4
|
push(delta: string): void;
|
|
5
5
|
finish(text: string): Promise<void>;
|
|
@@ -25,6 +25,5 @@ export declare class QQApiClient {
|
|
|
25
25
|
private request;
|
|
26
26
|
sendText(account: AccountRow, targetId: string, text: string, { group, messageId, format }?: QQSendOptions): Promise<Record<string, unknown>>;
|
|
27
27
|
sendReplyWithActiveFallback(account: AccountRow, targetId: string, text: string, options: QQSendOptions): Promise<Record<string, unknown>>;
|
|
28
|
-
createPrivateTextStream(account: AccountRow, targetId: string, { messageId
|
|
28
|
+
createPrivateTextStream(account: AccountRow, targetId: string, { messageId }?: Pick<QQSendOptions, 'messageId'>): QQPrivateTextStream;
|
|
29
29
|
}
|
|
30
|
-
export declare function isReplyFormat(value: string): value is ReplyFormat;
|
package/lib/gateway/api.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
const API_BASE = 'https://api.sgroup.qq.com';
|
|
2
2
|
const SANDBOX_API_BASE = 'https://sandbox.api.sgroup.qq.com';
|
|
3
3
|
const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken';
|
|
4
|
-
function looksLikeMarkdown(text) {
|
|
5
|
-
return /(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s)|\*\*|__|`|\[[^\]]+\]\([^\)]+\)/m.test(text);
|
|
6
|
-
}
|
|
7
4
|
export class QQApiClient {
|
|
8
5
|
db;
|
|
9
6
|
config;
|
|
@@ -86,7 +83,7 @@ export class QQApiClient {
|
|
|
86
83
|
async sendText(account, targetId, text, { group = false, messageId = null, format = this.config.replyFormat } = {}) {
|
|
87
84
|
const path = group ? `/v2/groups/${encodeURIComponent(targetId)}/messages` : `/v2/users/${encodeURIComponent(targetId)}/messages`;
|
|
88
85
|
const seq = this.nextSeq(messageId);
|
|
89
|
-
const useMarkdown = format === 'markdown'
|
|
86
|
+
const useMarkdown = format === 'markdown';
|
|
90
87
|
const makeBody = (markdown) => ({
|
|
91
88
|
msg_type: markdown ? 2 : 0,
|
|
92
89
|
...(markdown ? { markdown: { content: text } } : { content: text }),
|
|
@@ -113,10 +110,9 @@ export class QQApiClient {
|
|
|
113
110
|
return this.sendText(account, targetId, text, { ...options, messageId: null });
|
|
114
111
|
}
|
|
115
112
|
}
|
|
116
|
-
createPrivateTextStream(account, targetId, { messageId = null
|
|
113
|
+
createPrivateTextStream(account, targetId, { messageId = null } = {}) {
|
|
117
114
|
const path = `/v2/users/${encodeURIComponent(targetId)}/stream_messages`;
|
|
118
115
|
const msgSeq = this.nextStreamSeq();
|
|
119
|
-
const contentType = format === 'markdown' ? 'markdown' : 'text';
|
|
120
116
|
let fullText = '';
|
|
121
117
|
let streamMessageId;
|
|
122
118
|
let index = 0;
|
|
@@ -130,7 +126,7 @@ export class QQApiClient {
|
|
|
130
126
|
// Build the request only when the queued task runs. The first
|
|
131
127
|
// response supplies stream_msg_id for all following frames.
|
|
132
128
|
const body = {
|
|
133
|
-
input_mode: 'replace', input_state: inputState, index: index++, content_type:
|
|
129
|
+
input_mode: 'replace', input_state: inputState, index: index++, content_type: 'markdown',
|
|
134
130
|
content_raw: content, msg_seq: msgSeq,
|
|
135
131
|
...(messageId ? { msg_id: messageId, event_id: messageId } : {}),
|
|
136
132
|
...(streamMessageId ? { stream_msg_id: streamMessageId } : {}),
|
|
@@ -201,6 +197,3 @@ async function safeJson(response) {
|
|
|
201
197
|
return undefined;
|
|
202
198
|
}
|
|
203
199
|
}
|
|
204
|
-
export function isReplyFormat(value) {
|
|
205
|
-
return value === 'smart' || value === 'markdown' || value === 'compat';
|
|
206
|
-
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
2
|
import type { QQChatConfigInput } from './types.js';
|
|
3
3
|
export declare const name = "dsh-qqchat";
|
|
4
|
-
export declare const inject: readonly ["connection", "agents", "agentDefaultModel", "commands", "llm", "tools", "workspaceRegistry"];
|
|
4
|
+
export declare const inject: readonly ["connection", "agents", "agentDefaultModel", "commands", "llm", "tools", "webServer", "workspaceRegistry"];
|
|
5
5
|
export declare function apply(ctx: Context, inputConfig?: QQChatConfigInput): void;
|
|
6
6
|
export type { QQChatConfig, QQChatConfigInput } from './types.js';
|
|
7
7
|
export { QQChatDatabase } from './storage/db.js';
|
|
8
8
|
export { normalizeQQDispatch } from './gateway/normalize.js';
|
|
9
9
|
declare const _default: {
|
|
10
10
|
name: string;
|
|
11
|
-
inject: readonly ["connection", "agents", "agentDefaultModel", "commands", "llm", "tools", "workspaceRegistry"];
|
|
11
|
+
inject: readonly ["connection", "agents", "agentDefaultModel", "commands", "llm", "tools", "webServer", "workspaceRegistry"];
|
|
12
12
|
apply: typeof apply;
|
|
13
13
|
};
|
|
14
14
|
export default _default;
|
package/lib/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { QQChatRuntime } from './session/runtime.js';
|
|
|
13
13
|
import { registerQQChatSessionEventType } from './session/registration.js';
|
|
14
14
|
const qqChatSessionEventRegistrations = await registerQQChatSessionEventType();
|
|
15
15
|
export const name = 'dsh-qqchat';
|
|
16
|
-
export const inject = ['connection', 'agents', 'agentDefaultModel', 'commands', 'llm', 'tools', 'workspaceRegistry'];
|
|
16
|
+
export const inject = ['connection', 'agents', 'agentDefaultModel', 'commands', 'llm', 'tools', 'webServer', 'workspaceRegistry'];
|
|
17
17
|
export function apply(ctx, inputConfig = {}) {
|
|
18
18
|
const config = resolveConfig(inputConfig);
|
|
19
19
|
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
|
@@ -29,7 +29,7 @@ export function apply(ctx, inputConfig = {}) {
|
|
|
29
29
|
const media = new QQMediaStore(db, ctx, logger);
|
|
30
30
|
const bridge = new DshQQBridge(ctx, db, memory, config, logger);
|
|
31
31
|
const runtime = new QQChatRuntime(ctx, db, api, auth, memory, bridge, config, logger, media);
|
|
32
|
-
ctx.connection.rpc.handle('/qqchat', createQQChatRpc(runtime)
|
|
32
|
+
ctx.connection.rpc.handle('/qqchat', createQQChatRpc(runtime));
|
|
33
33
|
ctx.effect(() => {
|
|
34
34
|
void (async () => {
|
|
35
35
|
await runtime.start();
|
|
@@ -23,11 +23,13 @@ export declare class DshQQBridge {
|
|
|
23
23
|
private readonly activeMediaReadable;
|
|
24
24
|
private readonly memorySnapshots;
|
|
25
25
|
private readonly disposeEvent;
|
|
26
|
+
private readonly disposeAssistantStream;
|
|
26
27
|
private readonly disposeToolGate;
|
|
27
28
|
private readonly disposeCommands;
|
|
28
29
|
private readonly disposeImageTool;
|
|
29
30
|
private readonly disposeMediaTools;
|
|
30
31
|
constructor(ctx: Context, db: QQChatDatabase, memory: MemoryEngine, config: QQChatConfig, logger?: LoggerLike);
|
|
32
|
+
attachMappedSessionsToWorkspace(): Promise<void>;
|
|
31
33
|
dispose(): Promise<void>;
|
|
32
34
|
ensureChatSession(chatType: ChatType, row: GroupRow | MemberRow): Promise<string>;
|
|
33
35
|
recordTranscript(event: QQChatDisplayEvent, row: GroupRow | MemberRow, createSession?: boolean): Promise<string | undefined>;
|
|
@@ -37,9 +39,9 @@ export declare class DshQQBridge {
|
|
|
37
39
|
private restoreMemorySnapshot;
|
|
38
40
|
private ensureAgent;
|
|
39
41
|
private isArchived;
|
|
42
|
+
private attachToWorkspace;
|
|
40
43
|
private rememberRoute;
|
|
41
44
|
private appendDisplayIfMissing;
|
|
42
|
-
private appendOwnerMessageIfMissing;
|
|
43
45
|
private ensureTitle;
|
|
44
46
|
private tryResume;
|
|
45
47
|
private agentOptions;
|