dsh-qqchat 0.2.6 → 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 CHANGED
@@ -7,7 +7,7 @@
7
7
  <p>
8
8
  <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a>
9
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.6-2ea44f.svg" alt="QQChat 0.2.6">
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
 
@@ -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 | Smart, Markdown or Plain compatibility | Group and direct-chat formats are configured separately; groups default to Plain compatibility |
40
- | Direct-chat streaming | Optionally uses QQ's official streaming message API for private chats | Private chats only; Plain compatibility disables it automatically; some clients may not display 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 |
@@ -146,7 +146,7 @@ Configure **Settings -> QQ Chat -> Message reception**:
146
146
  | Mention only | All messages are stored, but only messages mentioning the Bot trigger a reply |
147
147
  | Silent record | Stores messages and memory without actively replying |
148
148
 
149
- Groups default to Plain compatibility format. Group and direct-chat formats can be selected independently. Plain compatibility for direct chats automatically disables streaming.
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.
150
150
 
151
151
  ## Commands
152
152
 
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  <p>
8
8
  <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a>
9
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.6-2ea44f.svg" alt="QQChat 0.2.6">
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
 
@@ -34,8 +34,8 @@
34
34
  | 手动连接 | 在设置页填写 AppID 和 AppSecret 连接 QQ Bot | 适合无手机、服务器或扫码失败场景;密钥只提交到 Host |
35
35
  | 断线重连 | QQ Gateway 断线后自动恢复连接 | 无需重复扫码 |
36
36
  | 群聊回复模式 | 自动回应、@回复、静默记录 | 可在设置页随时切换 |
37
- | 消息格式兼容 | 智能兼容、Markdown、纯文本兼容 | 可兼容旧版本QQ的群友们! |
38
- | 私聊流式回复 | 私聊可选用 QQ 官方流式消息接口逐步更新回复 | 仅适用于私聊;纯文本兼容模式自动关闭;部分客户端可能不可见 |
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 客户端。私聊和群聊可以分别选择智能兼容、Markdown 或纯文本兼容。私聊使用纯文本兼容时会自动关闭流式传输。
124
+ 群聊默认使用纯文本兼容格式,适合不同 QQ 客户端。私聊和群聊可以分别选择 Markdown 或纯文本兼容。私聊选择 Markdown 时默认使用 QQ 官方流式传输;选择纯文本兼容时使用普通发送,不再提供单独的流式开关。
125
125
 
126
126
  ## 命令
127
127
 
@@ -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 === 'markdown' ? 'Markdown' : value === 'compat' ? '纯文本兼容' : '智能兼容'
120
- const formatItems = [{ id: 'smart', label: '智能兼容' }, { id: 'markdown', label: 'Markdown' }, { id: 'compat', label: '纯文本兼容' }]
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' }, '私聊消息兼容格式'), h('div', { className: 'qqSettingHelp' }, '仅控制发送到 QQ 私聊时使用的消息格式。')),
199
- h('div', { className: 'qqControl' }, directReplyPicker)),
200
- h('div', { className: 'qqSetting' },
201
- h('label', { className: 'qqCheckRow' },
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, '记忆系统'),
@@ -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 = 'smart' | 'markdown' | 'compat'
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>> }
@@ -38,7 +38,7 @@ export interface Ctx {
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; directStreamingEnabled: boolean; groupMembersCanUseTools: boolean; groupMembersCanReceiveMedia: boolean; groupMembersCanReadMedia: boolean; ownerUserId: string }
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
@@ -6,7 +6,6 @@
6
6
  config:
7
7
  dataDir: !!js dshHomePath('plugins/dsh-qqchat')
8
8
 
9
- # DSH 0.1.6's Connection RPC registry owns route lifetimes on the
10
- # connection fiber, so that fiber must also receive the Web server service.
9
+ # Connection RPC 需要 Web 启动配置和 Web Server 来配置 trusted hosts、注册 Host routes。
11
10
  - id: connection
12
- inject: [webRuntime, webServer]
11
+ inject: [webStartup, webServer]
@@ -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 流式协议 | 插件调用 QQ 官方接口;失败时回退普通发送,纯文本兼容模式自动关闭 |
36
+ | QQ 私聊流式回复 | DSH 不负责 QQ Gateway 流式协议 | Markdown 格式自动使用 QQ 官方流式接口;纯文本兼容格式使用普通发送;失败时按已发送帧安全回退 |
37
37
  | QQ 引用、@和气泡 | DSH 不认识 QQ 协议字段 | 插件把引用和 @ 转为可读文本/元数据,并由 Client 做展示 |
38
38
  | DSH 原生命令 | DSH 只处理 DSH Web 自己收到的命令 | 插件将 `/qq...` 命令解析后转交或执行,避免与 DSH 原命令冲突 |
39
39
 
package/docs/STREAMING.md CHANGED
@@ -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
- ```ts
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
- | 设置关闭 | 不访问 `stream_messages` |
326
+ | 私聊纯文本兼容 | 不访问 `stream_messages` |
337
327
  | 群聊 | 不访问 `stream_messages` |
338
328
 
339
329
  ## 当前实现位置
package/lib/client.js CHANGED
@@ -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 === 'markdown' ? 'Markdown' : value === 'compat' ? '纯文本兼容' : '智能兼容';
186
- const formatItems = [{ id: 'smart', label: '智能兼容' }, { id: 'markdown', label: 'Markdown' }, { id: 'compat', label: '纯文本兼容' }];
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' }, '私聊消息兼容格式'), (0, shared_cjs_1.h)('div', { className: 'qqSettingHelp' }, '仅控制发送到 QQ 私聊时使用的消息格式。')), (0, shared_cjs_1.h)('div', { className: 'qqControl' }, directReplyPicker)), (0, shared_cjs_1.h)('div', { className: 'qqSetting' }, (0, shared_cjs_1.h)('label', { className: 'qqCheckRow' }, (0, shared_cjs_1.h)('input', {
239
- type: 'checkbox',
240
- checked: settings.directStreamingEnabled && settings.directReplyFormat !== 'compat',
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: 'smart',
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 = ['smart', 'markdown', 'compat'].includes(input.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,
@@ -1,5 +1,5 @@
1
1
  import type { QQChatDatabase } from '../storage/db.js';
2
- import type { AccountRow, QQChatConfig, QQSendOptions, ReplyFormat } from '../types.js';
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, format }?: QQSendOptions): QQPrivateTextStream;
28
+ createPrivateTextStream(account: AccountRow, targetId: string, { messageId }?: Pick<QQSendOptions, 'messageId'>): QQPrivateTextStream;
29
29
  }
30
- export declare function isReplyFormat(value: string): value is ReplyFormat;
@@ -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' || (format === 'smart' && looksLikeMarkdown(text));
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, format = this.config.replyFormat } = {}) {
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: contentType,
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
- }
@@ -42,7 +42,6 @@ export declare class DshQQBridge {
42
42
  private attachToWorkspace;
43
43
  private rememberRoute;
44
44
  private appendDisplayIfMissing;
45
- private appendOwnerMessageIfMissing;
46
45
  private ensureTitle;
47
46
  private tryResume;
48
47
  private agentOptions;
@@ -123,15 +123,6 @@ export class DshQQBridge {
123
123
  return undefined;
124
124
  const { agent, sessionId } = await this.ensureAgent(event.chatType, row);
125
125
  event.sessionId = sessionId;
126
- if (event.isOwner) {
127
- // Keep owner messages as native user/message events even when they carry
128
- // images. DSH now owns the image projection: visual routes receive the
129
- // durable image block, while text-only routes receive its stable text
130
- // placeholder. Falling back to a display-only event here would discard
131
- // the owner's message semantics before DSH can apply that policy.
132
- this.appendOwnerMessageIfMissing(agent.session, event);
133
- return sessionId;
134
- }
135
126
  this.appendDisplayIfMissing(agent.session, event);
136
127
  return sessionId;
137
128
  }
@@ -352,24 +343,6 @@ export class DshQQBridge {
352
343
  // fresh Session while SQLite remains the QQ history source of truth.
353
344
  session.append('qqchat/message', displayEventPayload(event));
354
345
  }
355
- appendOwnerMessageIfMissing(session, event) {
356
- if (session.snapshotEvents().some(item => {
357
- if (item.type !== 'user/message')
358
- return false;
359
- const source = item.data.source;
360
- return source.messageId === event.messageId;
361
- }))
362
- return;
363
- session.append('user/message', createUserMessage({
364
- source: {
365
- kind: 'user', channel: 'qq', botId: 'qqchat', chatType: event.chatType,
366
- chatId: event.chatId, senderId: event.senderId,
367
- ...(event.senderName ? { senderName: event.senderName } : {}),
368
- messageId: event.messageId, mentioned: event.mentioned,
369
- },
370
- content: transcriptContent(event, this.db),
371
- }), { surfaceOp: 'append' });
372
- }
373
346
  ensureTitle(session, chatType, row) {
374
347
  try {
375
348
  const getService = this.ctx.get;
@@ -619,35 +592,6 @@ export function qqMessageContent(text, attachments) {
619
592
  }
620
593
  return content;
621
594
  }
622
- function transcriptContent(event, db) {
623
- const attachments = event.attachments || [];
624
- const renderableImages = attachments.filter(attachment => attachment.kind === 'image' && db.attachmentById(attachment.id)?.imageRef);
625
- const imagePlaceholderLines = new Set(renderableImages.map(attachment => `[图片] ${attachment.filename}`));
626
- const quote = event.quote ? formatDisplayQuote(event) : '';
627
- const body = event.content.split('\n').filter(line => !imagePlaceholderLines.has(line)).join('\n').trim();
628
- const text = [quote, body].filter(Boolean).join('\n\n');
629
- const content = [{ type: 'text', text }];
630
- for (const attachment of attachments) {
631
- const imageRef = attachment.kind === 'image' ? db.attachmentById(attachment.id)?.imageRef : undefined;
632
- if (imageRef) {
633
- content.push({ type: 'image', attachment: imageRef });
634
- }
635
- }
636
- return content;
637
- }
638
- function formatDisplayQuote(event) {
639
- const quote = event.quote;
640
- if (!quote)
641
- return '';
642
- return [
643
- '[引用消息]',
644
- quote.senderId ? `发送人ID=${quote.senderId}` : '',
645
- quote.senderName ? `显示名=${quote.senderName}` : '',
646
- quote.messageId ? `消息ID=${quote.messageId}` : '',
647
- `正文=${quote.text || '(空消息)'}`,
648
- quote.attachments.length ? `附件=${quote.attachments.map(attachment => `${attachment.kind || 'file'}:${attachment.filename}${attachment.attachmentId ? ` (attachment_id=${attachment.attachmentId})` : ''}`).join('、')}` : '',
649
- ].filter(Boolean).join('\n');
650
- }
651
595
  function displayEventPayload(event) {
652
596
  const attachments = (event.attachments || []).map(attachment => ({
653
597
  id: String(attachment.id), kind: attachment.kind, filename: String(attachment.filename),
@@ -38,5 +38,7 @@ export declare class QQChatRuntime {
38
38
  private flushOutbox;
39
39
  }
40
40
  export declare function shouldReplyToGroup(mode: GroupReceiveMode, group: Pick<GroupRow, 'enabled' | 'read_enabled'>, mentioned: boolean): boolean;
41
+ /** Record messages that do not enter the Agent turn, plus QQ commands handled outside Agent.followup. */
42
+ export declare function shouldRecordQQTranscript(shouldReply: boolean, text: string, mentioned: boolean): boolean;
41
43
  export declare function mergeIndexedQuote(message: QQNormalizedMessage, indexed: import('../types.js').QQQuoteIndexRow | undefined): QQNormalizedMessage;
42
44
  export declare function indexAttachments(inputs: QQAttachmentInput[], stored: StoredAttachmentSummary[]): QQAttachmentInput[];
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { defaultRuntimeSettings } from '../config.js';
3
3
  import { QQGateway } from '../gateway/gateway.js';
4
4
  import { renderQQMentionNames } from '../gateway/normalize.js';
5
+ import { qqCommandText } from '../commands/dispatch.js';
5
6
  const QUOTE_INDEX_TTL_MS = 7 * 24 * 60 * 60 * 1000;
6
7
  export class QQChatRuntime {
7
8
  ctx;
@@ -52,10 +53,7 @@ export class QQChatRuntime {
52
53
  this.db.close();
53
54
  }
54
55
  settings() {
55
- const settings = this.db.runtimeSettings(defaultRuntimeSettings(this.config));
56
- if (settings.directReplyFormat === 'compat')
57
- settings.directStreamingEnabled = false;
58
- return settings;
56
+ return this.db.runtimeSettings(defaultRuntimeSettings(this.config));
59
57
  }
60
58
  updateSettings(patch) {
61
59
  if (patch.memoryEnabled !== undefined)
@@ -76,11 +74,7 @@ export class QQChatRuntime {
76
74
  if (!isReplyFormat(patch.directReplyFormat))
77
75
  throw new Error('无效的私聊消息兼容格式');
78
76
  this.db.setSetting('directReplyFormat', patch.directReplyFormat);
79
- if (patch.directReplyFormat === 'compat')
80
- this.db.setSetting('directStreamingEnabled', false);
81
77
  }
82
- if (patch.directStreamingEnabled !== undefined)
83
- this.db.setSetting('directStreamingEnabled', Boolean(patch.directStreamingEnabled));
84
78
  if (patch.groupMembersCanUseTools !== undefined)
85
79
  this.db.setSetting('groupMembersCanUseTools', Boolean(patch.groupMembersCanUseTools));
86
80
  if (patch.groupMembersCanReceiveMedia !== undefined)
@@ -273,21 +267,20 @@ export class QQChatRuntime {
273
267
  this.memory.scheduleGroupMembers(Number(group.id));
274
268
  }
275
269
  }
276
- if (!shouldReply) {
270
+ if (shouldRecordQQTranscript(shouldReply, message.text, message.mentioned)) {
277
271
  await this.bridge.recordTranscript(displayEvent, row, true);
272
+ }
273
+ if (!shouldReply) {
278
274
  return;
279
275
  }
280
276
  try {
281
- // Keep the incoming QQ message visible in Web independently from the
282
- // notice-shaped message used to drive the Agent turn.
283
- await this.bridge.recordTranscript(displayEvent, row, true);
284
277
  const targetId = message.chatType === 'group' ? message.groupOpenid : message.senderId;
285
278
  const sendOptions = {
286
279
  group: message.chatType === 'group', messageId: message.messageId || null,
287
280
  format: message.chatType === 'group' ? settings.groupReplyFormat : settings.directReplyFormat,
288
281
  };
289
- const stream = message.chatType === 'c2c' && settings.directReplyFormat !== 'compat' && settings.directStreamingEnabled
290
- ? this.api.createPrivateTextStream(account, targetId, sendOptions)
282
+ const stream = message.chatType === 'c2c' && settings.directReplyFormat === 'markdown'
283
+ ? this.api.createPrivateTextStream(account, targetId, { messageId: sendOptions.messageId })
291
284
  : undefined;
292
285
  const reply = await this.bridge.reply(message, group, member, storedAttachments, stream ? delta => stream.push(delta) : undefined, isOwner);
293
286
  if (stream) {
@@ -345,12 +338,16 @@ export class QQChatRuntime {
345
338
  }
346
339
  }
347
340
  function isGroupReceiveMode(value) { return value === 'auto' || value === 'mention' || value === 'silent'; }
348
- function isReplyFormat(value) { return value === 'smart' || value === 'markdown' || value === 'compat'; }
341
+ function isReplyFormat(value) { return value === 'markdown' || value === 'compat'; }
349
342
  export function shouldReplyToGroup(mode, group, mentioned) {
350
343
  if (group.enabled !== 1 || group.read_enabled !== 1)
351
344
  return false;
352
345
  return mode === 'auto' || (mode === 'mention' && mentioned);
353
346
  }
347
+ /** Record messages that do not enter the Agent turn, plus QQ commands handled outside Agent.followup. */
348
+ export function shouldRecordQQTranscript(shouldReply, text, mentioned) {
349
+ return !shouldReply || qqCommandText(text, mentioned) !== undefined;
350
+ }
354
351
  function shortId(value) { return value.length > 10 ? `…${value.slice(-10)}` : value; }
355
352
  function publicAttachments(attachments) {
356
353
  return attachments.map(attachment => ({
package/lib/storage/db.js CHANGED
@@ -239,6 +239,9 @@ export class QQChatDatabase {
239
239
  error TEXT
240
240
  );
241
241
  `);
242
+ this.db.prepare("UPDATE plugin_settings SET value='\"markdown\"',updated_at=? WHERE key IN ('replyFormat','groupReplyFormat','directReplyFormat') AND value='\"smart\"'")
243
+ .run(now());
244
+ this.db.prepare("DELETE FROM plugin_settings WHERE key='directStreamingEnabled'").run();
242
245
  this.db.prepare("UPDATE reflection_tasks SET status='failed',updated_at=? WHERE status='running' AND updated_at<?")
243
246
  .run(now(), now() - REFLECTION_TASK_STALE_MS);
244
247
  const columns = new Set(many(this.db.prepare('PRAGMA table_info(group_members)').all()).map(column => column.name));
@@ -362,13 +365,13 @@ export class QQChatDatabase {
362
365
  .run(key, JSON.stringify(value), now());
363
366
  }
364
367
  runtimeSettings(defaults) {
368
+ const legacyReplyFormat = normalizeReplyFormat(this.getSetting('replyFormat', defaults.directReplyFormat), defaults.directReplyFormat);
365
369
  return {
366
370
  memoryEnabled: this.getSetting('memoryEnabled', defaults.memoryEnabled),
367
371
  memoryMemberBatchEnabled: this.getSetting('memoryMemberBatchEnabled', defaults.memoryMemberBatchEnabled),
368
372
  groupReceiveMode: this.getSetting('groupReceiveMode', defaults.groupReceiveMode),
369
- groupReplyFormat: this.getSetting('groupReplyFormat', defaults.groupReplyFormat),
370
- directReplyFormat: this.getSetting('directReplyFormat', this.getSetting('replyFormat', defaults.directReplyFormat)),
371
- directStreamingEnabled: this.getSetting('directStreamingEnabled', defaults.directStreamingEnabled),
373
+ groupReplyFormat: normalizeReplyFormat(this.getSetting('groupReplyFormat', defaults.groupReplyFormat), defaults.groupReplyFormat),
374
+ directReplyFormat: normalizeReplyFormat(this.getSetting('directReplyFormat', legacyReplyFormat), legacyReplyFormat),
372
375
  groupMembersCanUseTools: this.getSetting('groupMembersCanUseTools', defaults.groupMembersCanUseTools),
373
376
  groupMembersCanReceiveMedia: this.getSetting('groupMembersCanReceiveMedia', defaults.groupMembersCanReceiveMedia),
374
377
  groupMembersCanReadMedia: this.getSetting('groupMembersCanReadMedia', defaults.groupMembersCanReadMedia),
@@ -724,3 +727,9 @@ export class QQChatDatabase {
724
727
  this.db.prepare('UPDATE outbox SET status=?,sent_at=?,error=? WHERE id=?').run(error ? 'failed' : 'sent', now(), error, id);
725
728
  }
726
729
  }
730
+ function normalizeReplyFormat(value, fallback) {
731
+ if (value === 'markdown' || value === 'compat')
732
+ return value;
733
+ // Older releases persisted the automatic “smart” mode. Its explicit replacement is Markdown.
734
+ return value === 'smart' ? 'markdown' : fallback;
735
+ }
@@ -304,12 +304,10 @@ function asSettingsPatch(value) {
304
304
  if (record.groupReceiveMode === 'auto' || record.groupReceiveMode === 'mention' || record.groupReceiveMode === 'silent') {
305
305
  patch.groupReceiveMode = record.groupReceiveMode;
306
306
  }
307
- if (record.groupReplyFormat === 'smart' || record.groupReplyFormat === 'markdown' || record.groupReplyFormat === 'compat')
307
+ if (record.groupReplyFormat === 'markdown' || record.groupReplyFormat === 'compat')
308
308
  patch.groupReplyFormat = record.groupReplyFormat;
309
- if (record.directReplyFormat === 'smart' || record.directReplyFormat === 'markdown' || record.directReplyFormat === 'compat')
309
+ if (record.directReplyFormat === 'markdown' || record.directReplyFormat === 'compat')
310
310
  patch.directReplyFormat = record.directReplyFormat;
311
- if (typeof record.directStreamingEnabled === 'boolean')
312
- patch.directStreamingEnabled = record.directStreamingEnabled;
313
311
  if (typeof record.groupMembersCanUseTools === 'boolean')
314
312
  patch.groupMembersCanUseTools = record.groupMembersCanUseTools;
315
313
  if (typeof record.groupMembersCanReceiveMedia === 'boolean')
package/lib/types.d.ts CHANGED
@@ -13,7 +13,7 @@ export interface DailyEntry {
13
13
  date: string;
14
14
  note: string;
15
15
  }
16
- export type ReplyFormat = 'smart' | 'markdown' | 'compat';
16
+ export type ReplyFormat = 'markdown' | 'compat';
17
17
  export type GroupReceiveMode = 'auto' | 'mention' | 'silent';
18
18
  export type GatewayStatus = 'offline' | 'connecting' | 'online' | 'error';
19
19
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
@@ -58,7 +58,6 @@ export interface QQChatRuntimeSettings {
58
58
  groupReceiveMode: GroupReceiveMode;
59
59
  groupReplyFormat: ReplyFormat;
60
60
  directReplyFormat: ReplyFormat;
61
- directStreamingEnabled: boolean;
62
61
  groupMembersCanUseTools: boolean;
63
62
  groupMembersCanReceiveMedia: boolean;
64
63
  groupMembersCanReadMedia: boolean;
@@ -70,7 +69,6 @@ export interface QQChatRuntimeSettingsPatch {
70
69
  groupReceiveMode?: GroupReceiveMode;
71
70
  groupReplyFormat?: ReplyFormat;
72
71
  directReplyFormat?: ReplyFormat;
73
- directStreamingEnabled?: boolean;
74
72
  groupMembersCanUseTools?: boolean;
75
73
  groupMembersCanReceiveMedia?: boolean;
76
74
  groupMembersCanReadMedia?: boolean;
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "dsh-qqchat",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "DeepSeek Harness 的 QQ 私聊/群聊插件,支持扫码授权、原生 Session、群记忆与工具权限。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "engines": {
8
- "node": "^22.19.0 || >=24.0.0"
8
+ "node": "^22.19.0 || >=24.0.0",
9
+ "dsh": ">=0.1.6-alpha.1"
9
10
  },
10
11
  "main": "./lib/index.js",
11
12
  "types": "./lib/index.d.ts",
package/src/config.ts CHANGED
@@ -8,7 +8,7 @@ const DEFAULT_CONFIG = Object.freeze({
8
8
  groupChatEnabled: true,
9
9
  groupRequiresAt: true,
10
10
  groupReadEnabled: true,
11
- replyFormat: 'smart' as ReplyFormat,
11
+ replyFormat: 'markdown' as ReplyFormat,
12
12
  recentGroupMessages: 40,
13
13
  reflectionIdleMs: 900_000,
14
14
  reflectionBatchSize: 20,
@@ -19,9 +19,7 @@ const DEFAULT_CONFIG = Object.freeze({
19
19
 
20
20
  export function resolveConfig(input: QQChatConfigInput = {}): QQChatConfig {
21
21
  const home = process.env.DSH_HOME || join(homedir(), '.dsh')
22
- const replyFormat: ReplyFormat = ['smart', 'markdown', 'compat'].includes(input.replyFormat ?? '')
23
- ? input.replyFormat as ReplyFormat
24
- : DEFAULT_CONFIG.replyFormat
22
+ const replyFormat: ReplyFormat = input.replyFormat === 'compat' ? 'compat' : 'markdown'
25
23
 
26
24
  return Object.freeze({
27
25
  dataDir: resolve(input.dataDir || join(home, 'plugins', 'dsh-qqchat')),
@@ -54,7 +52,6 @@ export function defaultRuntimeSettings(config: QQChatConfig): QQChatRuntimeSetti
54
52
  groupReceiveMode,
55
53
  groupReplyFormat: 'compat',
56
54
  directReplyFormat: config.replyFormat,
57
- directStreamingEnabled: config.replyFormat !== 'compat',
58
55
  groupMembersCanUseTools: false,
59
56
  groupMembersCanReceiveMedia: true,
60
57
  groupMembersCanReadMedia: false,
@@ -12,10 +12,6 @@ const API_BASE = 'https://api.sgroup.qq.com'
12
12
  const SANDBOX_API_BASE = 'https://sandbox.api.sgroup.qq.com'
13
13
  const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
14
14
 
15
- function looksLikeMarkdown(text: string): boolean {
16
- return /(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s)|\*\*|__|`|\[[^\]]+\]\([^\)]+\)/m.test(text)
17
- }
18
-
19
15
  interface CachedToken {
20
16
  token: string
21
17
  expiresAt: number
@@ -33,7 +29,7 @@ interface QQStreamBody {
33
29
  input_mode: 'replace'
34
30
  input_state: 1 | 10
35
31
  index: number
36
- content_type: 'text' | 'markdown'
32
+ content_type: 'markdown'
37
33
  content_raw: string
38
34
  msg_seq: number
39
35
  event_id?: string
@@ -143,7 +139,7 @@ export class QQApiClient {
143
139
  ): Promise<Record<string, unknown>> {
144
140
  const path = group ? `/v2/groups/${encodeURIComponent(targetId)}/messages` : `/v2/users/${encodeURIComponent(targetId)}/messages`
145
141
  const seq = this.nextSeq(messageId)
146
- const useMarkdown = format === 'markdown' || (format === 'smart' && looksLikeMarkdown(text))
142
+ const useMarkdown = format === 'markdown'
147
143
  const makeBody = (markdown: boolean): QQSendBody => ({
148
144
  msg_type: markdown ? 2 : 0,
149
145
  ...(markdown ? { markdown: { content: text } } : { content: text }),
@@ -177,11 +173,10 @@ export class QQApiClient {
177
173
  createPrivateTextStream(
178
174
  account: AccountRow,
179
175
  targetId: string,
180
- { messageId = null, format = this.config.replyFormat }: QQSendOptions = {},
176
+ { messageId = null }: Pick<QQSendOptions, 'messageId'> = {},
181
177
  ): QQPrivateTextStream {
182
178
  const path = `/v2/users/${encodeURIComponent(targetId)}/stream_messages`
183
179
  const msgSeq = this.nextStreamSeq()
184
- const contentType = format === 'markdown' ? 'markdown' : 'text'
185
180
  let fullText = ''
186
181
  let streamMessageId: string | undefined
187
182
  let index = 0
@@ -196,7 +191,7 @@ export class QQApiClient {
196
191
  // Build the request only when the queued task runs. The first
197
192
  // response supplies stream_msg_id for all following frames.
198
193
  const body: QQStreamBody = {
199
- input_mode: 'replace', input_state: inputState, index: index++, content_type: contentType,
194
+ input_mode: 'replace', input_state: inputState, index: index++, content_type: 'markdown',
200
195
  content_raw: content, msg_seq: msgSeq,
201
196
  ...(messageId ? { msg_id: messageId, event_id: messageId } : {}),
202
197
  ...(streamMessageId ? { stream_msg_id: streamMessageId } : {}),
@@ -258,7 +253,3 @@ export class QQApiClient {
258
253
  async function safeJson<T>(response: Response): Promise<T | undefined> {
259
254
  try { return await response.json() as T } catch { return undefined }
260
255
  }
261
-
262
- export function isReplyFormat(value: string): value is ReplyFormat {
263
- return value === 'smart' || value === 'markdown' || value === 'compat'
264
- }
@@ -148,15 +148,6 @@ export class DshQQBridge {
148
148
  if (!existing && !createSession) return undefined
149
149
  const { agent, sessionId } = await this.ensureAgent(event.chatType, row)
150
150
  event.sessionId = sessionId
151
- if (event.isOwner) {
152
- // Keep owner messages as native user/message events even when they carry
153
- // images. DSH now owns the image projection: visual routes receive the
154
- // durable image block, while text-only routes receive its stable text
155
- // placeholder. Falling back to a display-only event here would discard
156
- // the owner's message semantics before DSH can apply that policy.
157
- this.appendOwnerMessageIfMissing(agent.session, event)
158
- return sessionId
159
- }
160
151
  this.appendDisplayIfMissing(agent.session, event)
161
152
  return sessionId
162
153
  }
@@ -371,23 +362,6 @@ export class DshQQBridge {
371
362
  session.append('qqchat/message', displayEventPayload(event))
372
363
  }
373
364
 
374
- private appendOwnerMessageIfMissing(session: Session, event: QQChatDisplayEvent): void {
375
- if (session.snapshotEvents().some(item => {
376
- if (item.type !== 'user/message') return false
377
- const source = item.data.source as unknown as { messageId?: string }
378
- return source.messageId === event.messageId
379
- })) return
380
- session.append('user/message', createUserMessage({
381
- source: {
382
- kind: 'user', channel: 'qq', botId: 'qqchat', chatType: event.chatType,
383
- chatId: event.chatId, senderId: event.senderId,
384
- ...(event.senderName ? { senderName: event.senderName } : {}),
385
- messageId: event.messageId, mentioned: event.mentioned,
386
- },
387
- content: transcriptContent(event, this.db),
388
- }), { surfaceOp: 'append' })
389
- }
390
-
391
365
  private ensureTitle(session: Session, chatType: ChatType, row: GroupRow | MemberRow): void {
392
366
  try {
393
367
  const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
@@ -632,36 +606,6 @@ export function qqMessageContent(text: string, attachments: readonly StoredAttac
632
606
  return content
633
607
  }
634
608
 
635
- function transcriptContent(event: QQChatDisplayEvent, db: QQChatDatabase): ContentBlock[] {
636
- const attachments = event.attachments || []
637
- const renderableImages = attachments.filter(attachment => attachment.kind === 'image' && db.attachmentById(attachment.id)?.imageRef)
638
- const imagePlaceholderLines = new Set(renderableImages.map(attachment => `[图片] ${attachment.filename}`))
639
- const quote = event.quote ? formatDisplayQuote(event) : ''
640
- const body = event.content.split('\n').filter(line => !imagePlaceholderLines.has(line)).join('\n').trim()
641
- const text = [quote, body].filter(Boolean).join('\n\n')
642
- const content: ContentBlock[] = [{ type: 'text', text }]
643
- for (const attachment of attachments) {
644
- const imageRef = attachment.kind === 'image' ? db.attachmentById(attachment.id)?.imageRef : undefined
645
- if (imageRef) {
646
- content.push({ type: 'image', attachment: imageRef })
647
- }
648
- }
649
- return content
650
- }
651
-
652
- function formatDisplayQuote(event: QQChatDisplayEvent): string {
653
- const quote = event.quote
654
- if (!quote) return ''
655
- return [
656
- '[引用消息]',
657
- quote.senderId ? `发送人ID=${quote.senderId}` : '',
658
- quote.senderName ? `显示名=${quote.senderName}` : '',
659
- quote.messageId ? `消息ID=${quote.messageId}` : '',
660
- `正文=${quote.text || '(空消息)'}`,
661
- quote.attachments.length ? `附件=${quote.attachments.map(attachment => `${attachment.kind || 'file'}:${attachment.filename}${attachment.attachmentId ? ` (attachment_id=${attachment.attachmentId})` : ''}`).join('、')}` : '',
662
- ].filter(Boolean).join('\n')
663
- }
664
-
665
609
  function displayEventPayload(event: QQChatDisplayEvent): QQChatDisplayEvent {
666
610
  const attachments = (event.attachments || []).map(attachment => ({
667
611
  id: String(attachment.id), kind: attachment.kind, filename: String(attachment.filename),
@@ -9,6 +9,7 @@ import type { QQApiClient } from '../gateway/api.js'
9
9
  import type { QQBindService } from '../gateway/auth.js'
10
10
  import { QQGateway } from '../gateway/gateway.js'
11
11
  import { renderQQMentionNames } from '../gateway/normalize.js'
12
+ import { qqCommandText } from '../commands/dispatch.js'
12
13
  import { QQMediaStore } from '../media/store.js'
13
14
  import type {
14
15
  AccountRow, ChatTargetRow, ChatType, GroupReceiveMode, GroupRow, MemberRow, QQAttachmentInput, QQChatConfig,
@@ -52,9 +53,7 @@ export class QQChatRuntime {
52
53
  }
53
54
 
54
55
  settings(): QQChatRuntimeSettings {
55
- const settings = this.db.runtimeSettings(defaultRuntimeSettings(this.config))
56
- if (settings.directReplyFormat === 'compat') settings.directStreamingEnabled = false
57
- return settings
56
+ return this.db.runtimeSettings(defaultRuntimeSettings(this.config))
58
57
  }
59
58
 
60
59
  updateSettings(patch: QQChatRuntimeSettingsPatch): QQChatRuntimeSettings {
@@ -71,9 +70,7 @@ export class QQChatRuntime {
71
70
  if (patch.directReplyFormat !== undefined) {
72
71
  if (!isReplyFormat(patch.directReplyFormat)) throw new Error('无效的私聊消息兼容格式')
73
72
  this.db.setSetting('directReplyFormat', patch.directReplyFormat)
74
- if (patch.directReplyFormat === 'compat') this.db.setSetting('directStreamingEnabled', false)
75
73
  }
76
- if (patch.directStreamingEnabled !== undefined) this.db.setSetting('directStreamingEnabled', Boolean(patch.directStreamingEnabled))
77
74
  if (patch.groupMembersCanUseTools !== undefined) this.db.setSetting('groupMembersCanUseTools', Boolean(patch.groupMembersCanUseTools))
78
75
  if (patch.groupMembersCanReceiveMedia !== undefined) this.db.setSetting('groupMembersCanReceiveMedia', Boolean(patch.groupMembersCanReceiveMedia))
79
76
  if (patch.groupMembersCanReadMedia !== undefined) this.db.setSetting('groupMembersCanReadMedia', Boolean(patch.groupMembersCanReadMedia))
@@ -252,22 +249,21 @@ export class QQChatRuntime {
252
249
  }
253
250
  }
254
251
 
255
- if (!shouldReply) {
252
+ if (shouldRecordQQTranscript(shouldReply, message.text, message.mentioned)) {
256
253
  await this.bridge.recordTranscript(displayEvent, row, true)
254
+ }
255
+ if (!shouldReply) {
257
256
  return
258
257
  }
259
258
 
260
259
  try {
261
- // Keep the incoming QQ message visible in Web independently from the
262
- // notice-shaped message used to drive the Agent turn.
263
- await this.bridge.recordTranscript(displayEvent, row, true)
264
260
  const targetId = message.chatType === 'group' ? message.groupOpenid! : message.senderId
265
261
  const sendOptions = {
266
262
  group: message.chatType === 'group', messageId: message.messageId || null,
267
263
  format: message.chatType === 'group' ? settings.groupReplyFormat : settings.directReplyFormat,
268
264
  } as const
269
- const stream = message.chatType === 'c2c' && settings.directReplyFormat !== 'compat' && settings.directStreamingEnabled
270
- ? this.api.createPrivateTextStream(account, targetId, sendOptions)
265
+ const stream = message.chatType === 'c2c' && settings.directReplyFormat === 'markdown'
266
+ ? this.api.createPrivateTextStream(account, targetId, { messageId: sendOptions.messageId })
271
267
  : undefined
272
268
  const reply = await this.bridge.reply(message, group, member, storedAttachments, stream ? delta => stream.push(delta) : undefined, isOwner)
273
269
  if (stream) {
@@ -320,11 +316,15 @@ export class QQChatRuntime {
320
316
  }
321
317
 
322
318
  function isGroupReceiveMode(value: unknown): value is GroupReceiveMode { return value === 'auto' || value === 'mention' || value === 'silent' }
323
- function isReplyFormat(value: unknown): value is ReplyFormat { return value === 'smart' || value === 'markdown' || value === 'compat' }
319
+ function isReplyFormat(value: unknown): value is ReplyFormat { return value === 'markdown' || value === 'compat' }
324
320
  export function shouldReplyToGroup(mode: GroupReceiveMode, group: Pick<GroupRow, 'enabled' | 'read_enabled'>, mentioned: boolean): boolean {
325
321
  if (group.enabled !== 1 || group.read_enabled !== 1) return false
326
322
  return mode === 'auto' || (mode === 'mention' && mentioned)
327
323
  }
324
+ /** Record messages that do not enter the Agent turn, plus QQ commands handled outside Agent.followup. */
325
+ export function shouldRecordQQTranscript(shouldReply: boolean, text: string, mentioned: boolean): boolean {
326
+ return !shouldReply || qqCommandText(text, mentioned) !== undefined
327
+ }
328
328
  function shortId(value: string): string { return value.length > 10 ? `…${value.slice(-10)}` : value }
329
329
  function publicAttachments(attachments: StoredAttachmentSummary[]): StoredAttachmentSummary[] {
330
330
  return attachments.map(attachment => ({
package/src/storage/db.ts CHANGED
@@ -264,6 +264,9 @@ export class QQChatDatabase {
264
264
  error TEXT
265
265
  );
266
266
  `)
267
+ this.db.prepare("UPDATE plugin_settings SET value='\"markdown\"',updated_at=? WHERE key IN ('replyFormat','groupReplyFormat','directReplyFormat') AND value='\"smart\"'")
268
+ .run(now())
269
+ this.db.prepare("DELETE FROM plugin_settings WHERE key='directStreamingEnabled'").run()
267
270
  this.db.prepare("UPDATE reflection_tasks SET status='failed',updated_at=? WHERE status='running' AND updated_at<?")
268
271
  .run(now(), now() - REFLECTION_TASK_STALE_MS)
269
272
  const columns = new Set(many<{ name: string }>(this.db.prepare('PRAGMA table_info(group_members)').all()).map(column => column.name))
@@ -396,13 +399,16 @@ export class QQChatDatabase {
396
399
  }
397
400
 
398
401
  runtimeSettings(defaults: QQChatRuntimeSettings): QQChatRuntimeSettings {
402
+ const legacyReplyFormat = normalizeReplyFormat(
403
+ this.getSetting<unknown>('replyFormat', defaults.directReplyFormat),
404
+ defaults.directReplyFormat,
405
+ )
399
406
  return {
400
407
  memoryEnabled: this.getSetting('memoryEnabled', defaults.memoryEnabled),
401
408
  memoryMemberBatchEnabled: this.getSetting('memoryMemberBatchEnabled', defaults.memoryMemberBatchEnabled),
402
409
  groupReceiveMode: this.getSetting('groupReceiveMode', defaults.groupReceiveMode),
403
- groupReplyFormat: this.getSetting('groupReplyFormat', defaults.groupReplyFormat),
404
- directReplyFormat: this.getSetting('directReplyFormat', this.getSetting('replyFormat', defaults.directReplyFormat)),
405
- directStreamingEnabled: this.getSetting('directStreamingEnabled', defaults.directStreamingEnabled),
410
+ groupReplyFormat: normalizeReplyFormat(this.getSetting<unknown>('groupReplyFormat', defaults.groupReplyFormat), defaults.groupReplyFormat),
411
+ directReplyFormat: normalizeReplyFormat(this.getSetting<unknown>('directReplyFormat', legacyReplyFormat), legacyReplyFormat),
406
412
  groupMembersCanUseTools: this.getSetting('groupMembersCanUseTools', defaults.groupMembersCanUseTools),
407
413
  groupMembersCanReceiveMedia: this.getSetting('groupMembersCanReceiveMedia', defaults.groupMembersCanReceiveMedia),
408
414
  groupMembersCanReadMedia: this.getSetting('groupMembersCanReadMedia', defaults.groupMembersCanReadMedia),
@@ -828,3 +834,9 @@ export class QQChatDatabase {
828
834
  this.db.prepare('UPDATE outbox SET status=?,sent_at=?,error=? WHERE id=?').run(error ? 'failed' : 'sent', now(), error, id)
829
835
  }
830
836
  }
837
+
838
+ function normalizeReplyFormat(value: unknown, fallback: QQChatRuntimeSettings['directReplyFormat']): QQChatRuntimeSettings['directReplyFormat'] {
839
+ if (value === 'markdown' || value === 'compat') return value
840
+ // Older releases persisted the automatic “smart” mode. Its explicit replacement is Markdown.
841
+ return value === 'smart' ? 'markdown' : fallback
842
+ }
@@ -311,9 +311,8 @@ function asSettingsPatch(value: unknown): QQChatRuntimeSettingsPatch {
311
311
  if (record.groupReceiveMode === 'auto' || record.groupReceiveMode === 'mention' || record.groupReceiveMode === 'silent') {
312
312
  patch.groupReceiveMode = record.groupReceiveMode
313
313
  }
314
- if (record.groupReplyFormat === 'smart' || record.groupReplyFormat === 'markdown' || record.groupReplyFormat === 'compat') patch.groupReplyFormat = record.groupReplyFormat
315
- if (record.directReplyFormat === 'smart' || record.directReplyFormat === 'markdown' || record.directReplyFormat === 'compat') patch.directReplyFormat = record.directReplyFormat
316
- if (typeof record.directStreamingEnabled === 'boolean') patch.directStreamingEnabled = record.directStreamingEnabled
314
+ if (record.groupReplyFormat === 'markdown' || record.groupReplyFormat === 'compat') patch.groupReplyFormat = record.groupReplyFormat
315
+ if (record.directReplyFormat === 'markdown' || record.directReplyFormat === 'compat') patch.directReplyFormat = record.directReplyFormat
317
316
  if (typeof record.groupMembersCanUseTools === 'boolean') patch.groupMembersCanUseTools = record.groupMembersCanUseTools
318
317
  if (typeof record.groupMembersCanReceiveMedia === 'boolean') patch.groupMembersCanReceiveMedia = record.groupMembersCanReceiveMedia
319
318
  if (typeof record.groupMembersCanReadMedia === 'boolean') patch.groupMembersCanReadMedia = record.groupMembersCanReadMedia
package/src/types.ts CHANGED
@@ -16,7 +16,7 @@ export interface DailyEntry {
16
16
  date: string
17
17
  note: string
18
18
  }
19
- export type ReplyFormat = 'smart' | 'markdown' | 'compat'
19
+ export type ReplyFormat = 'markdown' | 'compat'
20
20
  export type GroupReceiveMode = 'auto' | 'mention' | 'silent'
21
21
  export type GatewayStatus = 'offline' | 'connecting' | 'online' | 'error'
22
22
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
@@ -65,7 +65,6 @@ export interface QQChatRuntimeSettings {
65
65
  groupReceiveMode: GroupReceiveMode
66
66
  groupReplyFormat: ReplyFormat
67
67
  directReplyFormat: ReplyFormat
68
- directStreamingEnabled: boolean
69
68
  groupMembersCanUseTools: boolean
70
69
  groupMembersCanReceiveMedia: boolean
71
70
  groupMembersCanReadMedia: boolean
@@ -78,7 +77,6 @@ export interface QQChatRuntimeSettingsPatch {
78
77
  groupReceiveMode?: GroupReceiveMode
79
78
  groupReplyFormat?: ReplyFormat
80
79
  directReplyFormat?: ReplyFormat
81
- directStreamingEnabled?: boolean
82
80
  groupMembersCanUseTools?: boolean
83
81
  groupMembersCanReceiveMedia?: boolean
84
82
  groupMembersCanReadMedia?: boolean