dsh-qqchat 0.2.1 → 0.2.2
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 +20 -13
- package/README.md +14 -6
- package/client-src/settings.cts +1 -1
- package/docs/ARCHITECTURE.en.md +5 -2
- package/docs/ARCHITECTURE.md +3 -8
- package/docs/MEDIA_AND_QUOTES.md +11 -12
- package/docs/STREAMING.md +22 -19
- package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
- package/lib/client.js +1 -1
- package/lib/gateway/api.d.ts +1 -1
- package/lib/gateway/api.js +5 -1
- package/lib/gateway/gateway.js +5 -1
- package/lib/session/agent-bridge.d.ts +4 -2
- package/lib/session/agent-bridge.js +74 -22
- package/lib/session/runtime.js +3 -0
- package/lib/shared/augmentations.d.ts +0 -4
- package/package.json +3 -1
- package/src/gateway/api.ts +5 -1
- package/src/gateway/gateway.ts +5 -1
- package/src/session/agent-bridge.ts +73 -23
- package/src/session/runtime.ts +3 -0
- package/src/shared/augmentations.ts +0 -4
package/README.en.md
CHANGED
|
@@ -36,12 +36,13 @@
|
|
|
36
36
|
| Message formats | Smart, Markdown or Plain compatibility | Group and direct-chat formats are configured separately; groups default to Plain compatibility |
|
|
37
37
|
| 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 |
|
|
38
38
|
| Quotes and mentions | Preserves quoted messages and resolves QQ mentions to display names | Stable IDs remain in metadata |
|
|
39
|
-
| Memory system | Stores group, member and direct-user memory
|
|
39
|
+
| 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 |
|
|
40
40
|
| Memory reflection | Converts recent daily notes into long-term memory | Runs asynchronously after idle or batch thresholds |
|
|
41
41
|
| Media and file permissions | Separately controls receiving media, reading attachments, and regular tools | Owner and direct chats are unrestricted by default |
|
|
42
|
-
|
|
|
42
|
+
| Permission management | Separately controls group-member tools, media reading and file receiving | Owner is matched by stable user ID |
|
|
43
43
|
| Native DSH commands | Runs `/compact`, `/goal`, `/plan`, `/qqmodel`, `/qqstatus` and more directly from QQ | Commands do not go through the model; `/qqhelp` lists the full set |
|
|
44
44
|
| Native DSH UI | Uses the normal Session list, Conversation surface and composer styling | QQ Sessions remain ungrouped in DSH |
|
|
45
|
+
| Session presets | Each QQ Session can use DSH's native Agent Preset mechanism | The plugin default applies only to new Sessions; a Session's own selection is persisted |
|
|
45
46
|
| Diagnostics | Shows plugin logs in Settings | Useful for connection and delivery troubleshooting |
|
|
46
47
|
|
|
47
48
|
## How to use
|
|
@@ -68,7 +69,7 @@
|
|
|
68
69
|
<td width="50%" valign="top">
|
|
69
70
|
<img src="./docs/assets/记忆系统.jpg" width="100%" alt="QQ Chat memory system">
|
|
70
71
|
<h3>Memory system</h3>
|
|
71
|
-
<p>The plugin maintains separate memory for groups, members and direct users. Member memory supports profiles, group nicknames, historical nicknames, behavior patterns and long-term notes; recent messages are organized asynchronously. Memory is refreshed as Session context when needed, and can be disabled without deleting stored data.</p>
|
|
72
|
+
<p>The plugin maintains separate memory for groups, members and direct users. Under the same Bot, user memory is reusable across groups by stable user ID; group relationships and group events remain scoped to their original group. Member memory supports profiles, group nicknames, historical nicknames, behavior patterns and long-term notes; recent messages are organized asynchronously. Memory is refreshed as Session context when needed, and can be disabled without deleting stored data.</p>
|
|
72
73
|
</td>
|
|
73
74
|
</tr>
|
|
74
75
|
</table>
|
|
@@ -145,7 +146,7 @@ Groups default to Plain compatibility format. Group and direct-chat formats can
|
|
|
145
146
|
|
|
146
147
|
## Commands
|
|
147
148
|
|
|
148
|
-
Messages beginning with `/` are handled directly by the command system and are not sent to the model. Native DSH commands keep their original names; QQChat commands use the `qq` prefix. In a group, write `@Bot /
|
|
149
|
+
Messages beginning with `/` are handled directly by the command system and are not sent to the model. Native DSH commands keep their original names; QQChat commands use the `qq` prefix. In a group, write `@Bot /qqhelp`.
|
|
149
150
|
|
|
150
151
|
| Command | Purpose |
|
|
151
152
|
| --- | --- |
|
|
@@ -153,6 +154,7 @@ Messages beginning with `/` are handled directly by the command system and are n
|
|
|
153
154
|
| `/qqnew`, `/qqreset`, `/qqclear` | Keep the old Session and start a new one on the next message |
|
|
154
155
|
| `/compact` | Compact older conversation history |
|
|
155
156
|
| `/qqmodel [provider/]model` | Show or switch the current Session model |
|
|
157
|
+
| `/qqpreset [preset]` | List or switch the Agent Preset for the current blank Session; start a new Session after conversation begins |
|
|
156
158
|
| `/qqstop` | Stop the current generation |
|
|
157
159
|
| `/qqstatus` | Show Session, model and generation status |
|
|
158
160
|
| `/qqping` | Check QQChat connectivity |
|
|
@@ -188,11 +190,15 @@ QQ Chat stores:
|
|
|
188
190
|
|
|
189
191
|
Reflection runs asynchronously after the chat is idle or enough messages have accumulated. The **QQ Memory** action at the top of a QQ Session shows group and member memory. Memory snapshots refresh when a Session starts, the memory context expires, or the Session is compacted; continuous conversation does not repeatedly write the full memory. Enabled memory may reduce context-cache hit rate or increase input tokens, and can be disabled under **Settings -> QQ Chat -> Memory system** without deleting stored data.
|
|
190
192
|
|
|
191
|
-
##
|
|
193
|
+
## Permission management
|
|
192
194
|
|
|
193
|
-
|
|
195
|
+
The settings page keeps media/file permissions separate from regular Agent-tool permissions:
|
|
194
196
|
|
|
195
|
-
|
|
197
|
+
- **Receive group-member media/files** controls whether images, files, audio and video are downloaded, stored and shown in the Session.
|
|
198
|
+
- **Allow group members to read media/files** controls whether a group member's Agent turn may call QQChat media tools for the current attachment.
|
|
199
|
+
- **Group members can use tools** controls whether group members may use regular Agent tools exposed by the current preset.
|
|
200
|
+
|
|
201
|
+
When regular tools or media reading are disabled, group members can still send normal text and receive replies. Owner matching uses a stable QQ user ID, not a nickname.
|
|
196
202
|
|
|
197
203
|
## Message display
|
|
198
204
|
|
|
@@ -204,6 +210,10 @@ QQ conversations use DSH's default Conversation UI:
|
|
|
204
210
|
- QQ `@` mentions are converted to usernames when available.
|
|
205
211
|
- The DSH Session composer can proactively send messages to QQ.
|
|
206
212
|
|
|
213
|
+
## Session presets
|
|
214
|
+
|
|
215
|
+
QQ Sessions use DSH's native Agent Preset mechanism. The preset configured for the plugin is only the default for new QQ Sessions. When a preset is selected for a blank Session in DSH Web, the selection is persisted with that Session and takes priority when the Session is resumed after a restart. You can also send `/qqpreset <preset>` as the first QQ message for that Session. Once a conversation has started, DSH locks the preset; use `/qqnew` to start another Session. The plugin does not copy DSH presets, tools or the AgentLoop.
|
|
216
|
+
|
|
207
217
|
## Notes
|
|
208
218
|
|
|
209
219
|
- An official QQ Bot is required; personal QQ account login is not supported.
|
|
@@ -220,15 +230,12 @@ QR authorization and connection
|
|
|
220
230
|
Group receive mode
|
|
221
231
|
Group and direct-chat reply formats
|
|
222
232
|
Memory system [on/off]
|
|
223
|
-
Media and file permissions and regular tool permissions are independent settings:
|
|
224
|
-
|
|
225
|
-
- **Receive group-member media/files** controls whether images, files, audio, and video are downloaded, stored, and shown in the session.
|
|
226
|
-
- **Allow group members to read media/files** controls whether their Agent turns may call QQChat media tools for the current attachment.
|
|
227
|
-
- **Group members can use tools** controls access to regular Agent tools.
|
|
228
233
|
Owner stable ID
|
|
229
234
|
Diagnostics and logs
|
|
230
235
|
```
|
|
231
236
|
|
|
237
|
+
Media/file permissions and regular tool permissions are independent settings. See [Permission management](#permission-management) for their behavior.
|
|
238
|
+
|
|
232
239
|
The Settings page does not contain chat lists, transcripts or memory browsing. Those remain in the normal DSH Session and Conversation surfaces.
|
|
233
240
|
|
|
234
241
|
## Persistence and memory scopes
|
|
@@ -247,7 +254,7 @@ Memory scopes are isolated as follows:
|
|
|
247
254
|
- **Member:** typed profile, pattern, summary, daily and long-term memory for one stable sender.
|
|
248
255
|
- **Direct chat:** uses the member scope for that QQ user.
|
|
249
256
|
|
|
250
|
-
The same stable sender may retain
|
|
257
|
+
The same stable sender may retain user memory across groups under the same Bot. Group relationships and group-specific facts never cross group scopes, and different Bots keep separate user-memory scopes. Reflection runs after roughly 120 seconds of inactivity or 20 unreflected messages, and can also compress older daily records after the configured thresholds.
|
|
251
258
|
|
|
252
259
|
## Development
|
|
253
260
|
|
package/README.md
CHANGED
|
@@ -35,12 +35,13 @@
|
|
|
35
35
|
| 私聊流式回复 | 私聊可选用 QQ 官方流式消息接口逐步更新回复 | 仅适用于私聊;纯文本兼容模式自动关闭;部分客户端可能不可见 |
|
|
36
36
|
| 消息引用与 @ | 支持引用消息和 QQ @消息 | @用户优先显示用户名 |
|
|
37
37
|
| 会话隔离 | QQ 群聊和私聊分别使用独立会话 | 不同群、不同用户的上下文互相隔离 |
|
|
38
|
-
| 记忆系统 |
|
|
38
|
+
| 记忆系统 | 保存群聊、群成员和私聊用户的独立记忆;同一 Bot 下的用户记忆支持跨群复用 | 群关系和群级事件不会跨群共享;设置中可关闭 |
|
|
39
39
|
| 记忆整理 | 自动将近期记录整理为长期记忆 | 后台执行,不阻塞正常回复 |
|
|
40
40
|
| 媒体与文件权限 | 分别控制群成员媒体接收、媒体读取和普通工具使用 | Owner 与私聊默认不受限制 |
|
|
41
|
-
|
|
|
41
|
+
| 权限管理 | 单独控制群成员是否可以使用普通 Agent 工具、读取媒体和接收文件 | 可指定 Owner,按稳定用户 ID 判断 |
|
|
42
42
|
| DSH / QQChat 命令 | QQ 中直接执行 `/compact`、`/goal`、`/plan`、`/qqmodel`、`/qqstatus` 等命令 | 命令不经过模型;发送 `/qqhelp` 查看完整列表 |
|
|
43
43
|
| DSH 原生界面 | 使用 DSH 默认会话、聊天区和输入框 | 支持从 DSH 主动向 QQ 发送消息 |
|
|
44
|
+
| Session Preset | 每个 QQ Session 可使用 DSH 原生 preset | 新 Session 使用插件默认 preset;Session 自己选择后独立保存 |
|
|
44
45
|
| 日志查看 | 在设置页查看 QQ Chat 运行日志 | 方便排查连接和消息问题 |
|
|
45
46
|
|
|
46
47
|
## 如何使用
|
|
@@ -67,7 +68,7 @@
|
|
|
67
68
|
<td width="50%" valign="top">
|
|
68
69
|
<img src="./docs/assets/记忆系统.jpg" width="100%" alt="QQ Chat 群聊记忆系统">
|
|
69
70
|
<h3>记忆系统</h3>
|
|
70
|
-
<p
|
|
71
|
+
<p>插件会分别维护群聊、群成员和私聊用户的记忆。同一 Bot 下,基于稳定用户 ID 的用户记忆可以跨群复用;群内关系和群级事件只保留在对应群。成员记忆支持资料、群内称呼、历史昵称、行为模式和长期信息;近期消息会在空闲或达到阈值后自动整理。记忆会作为 Session 上下文按需刷新,设置中可以关闭,关闭后不会删除已有数据。</p>
|
|
71
72
|
</td>
|
|
72
73
|
</tr>
|
|
73
74
|
</table>
|
|
@@ -100,7 +101,7 @@ npx @deepseek-ai/dsh --profile qqchat
|
|
|
100
101
|
1. 启动 DSH Web。
|
|
101
102
|
2. 打开 **设置 → QQ Chat**。
|
|
102
103
|
3. 点击 **扫码连接**。
|
|
103
|
-
4. 使用手机 QQ 扫描二维码,并选择要授权的官方 Bot
|
|
104
|
+
4. 使用手机 QQ 扫描二维码,并选择要授权的官方 Bot或手动填写 AppID 和 AppSecret 接入。
|
|
104
105
|
5. 授权完成后,QQ Gateway 会自动连接。
|
|
105
106
|
|
|
106
107
|
如果无法扫码,也可以选择 **手动配置**,填写 QQ 开放平台的 AppID 和 AppSecret,并按需选择沙箱环境。插件会先验证凭据,验证成功后再保存并启动 Gateway。
|
|
@@ -121,7 +122,7 @@ npx @deepseek-ai/dsh --profile qqchat
|
|
|
121
122
|
|
|
122
123
|
## 命令
|
|
123
124
|
|
|
124
|
-
QQ 中以 `/` 开头的消息会交给命令系统处理,不会作为普通消息发送给模型。DSH 原生命令保持原名;QQChat 自有命令使用 `qq` 前缀。群聊中可以发送 `@Bot /
|
|
125
|
+
QQ 中以 `/` 开头的消息会交给命令系统处理,不会作为普通消息发送给模型。DSH 原生命令保持原名;QQChat 自有命令使用 `qq` 前缀。群聊中可以发送 `@Bot /qqhelp`。
|
|
125
126
|
|
|
126
127
|
| 命令 | 用途 |
|
|
127
128
|
| --- | --- |
|
|
@@ -129,6 +130,7 @@ QQ 中以 `/` 开头的消息会交给命令系统处理,不会作为普通消
|
|
|
129
130
|
| `/qqnew`、`/qqreset`、`/qqclear` | 保留旧 Session,下一条消息开始新的 QQ Session |
|
|
130
131
|
| `/compact` | 手动压缩较早的会话历史 |
|
|
131
132
|
| `/qqmodel [provider/]model` | 查看或切换当前群聊/私聊的模型 |
|
|
133
|
+
| `/qqpreset [preset]` | 查看或切换当前空白 Session 的 Agent Preset;对话开始后需新建 Session |
|
|
132
134
|
| `/qqstop` | 中止当前生成 |
|
|
133
135
|
| `/qqstatus` | 查看当前 Session、模型和生成状态 |
|
|
134
136
|
| `/qqping` | 测试 QQChat 连通性 |
|
|
@@ -162,11 +164,13 @@ QQ Chat 会分别保存:
|
|
|
162
164
|
- 群成员记忆:成员的资料、群内称呼、历史昵称、行为特点和长期信息
|
|
163
165
|
- 私聊记忆:与某个 QQ 用户的长期对话信息
|
|
164
166
|
|
|
167
|
+
同一 Bot 下,用户记忆按照稳定用户 ID 跨群连续;群内关系、群级事件和群聊记录不会跨群混入。不同 Bot 的用户记忆相互隔离。
|
|
168
|
+
|
|
165
169
|
记忆会在聊天空闲或积累到一定数量后异步整理,不会阻塞正常回复。可以从 QQ Session 顶部的 **QQ 记忆** 入口查看群和成员记忆。记忆内容会按需进入 Agent 上下文,启用后可能降低上下文缓存命中率并增加输入 token;可在 **设置 → QQ Chat → 记忆系统** 中关闭。
|
|
166
170
|
|
|
167
171
|
记忆快照会在新 Session、记忆上下文过期或 Session 压缩后刷新,连续对话不会重复写入完整记忆。群级记忆和当前群员记忆分别管理,群持续活跃不会替潜水成员刷新成员记忆。
|
|
168
172
|
|
|
169
|
-
##
|
|
173
|
+
## 权限管理
|
|
170
174
|
|
|
171
175
|
在设置中,媒体与文件权限和普通工具权限分别控制:
|
|
172
176
|
|
|
@@ -188,6 +192,10 @@ QQ 对话会使用 DSH 的默认聊天界面显示:
|
|
|
188
192
|
- 支持 QQ @消息转换为用户名
|
|
189
193
|
- 支持从 DSH 会话输入框主动发送 QQ 消息
|
|
190
194
|
|
|
195
|
+
## Session Preset
|
|
196
|
+
|
|
197
|
+
QQ Session 使用 DSH 原生的 Agent Preset 机制。插件配置中的 preset 只作为新建 QQ Session 的默认值;在 DSH Web 中为某个空白 Session 选择 preset 后,选择结果会随该 Session 保存,重启或恢复后仍优先使用该 Session 最近一次选择。也可以在该 Session 的第一条 QQ 消息中发送 `/qqpreset <preset>` 完成选择。对话开始后 DSH 不允许更换 preset,此时请先使用 `/qqnew`。插件不复制 DSH 的 preset、工具或 AgentLoop。
|
|
198
|
+
|
|
191
199
|
## 注意事项
|
|
192
200
|
|
|
193
201
|
- 需要 QQ 官方 Bot,不支持个人 QQ 账号登录。
|
package/client-src/settings.cts
CHANGED
|
@@ -201,7 +201,7 @@ export function QQSettings({ rpc }: { rpc: Rpc }) {
|
|
|
201
201
|
h('div', { className: 'qqSetting' },
|
|
202
202
|
h('div', null,
|
|
203
203
|
h('div', { className: 'qqSettingTitle' }, '启用记忆系统'),
|
|
204
|
-
h('div', { className: 'qqSettingHelp' }, '
|
|
204
|
+
h('div', { className: 'qqSettingHelp' }, '记忆会在新 Session、上下文 TTL 过期、记忆变化或 Session 压缩后刷新,不会每轮重复注入完整上下文;后台会在空闲时整理近期消息。启用记忆可能降低上下文缓存命中率并增加输入 token。关闭后停止记忆注入和后台整理,但不会删除已有记忆。')),
|
|
205
205
|
h('div', { className: 'qqControl' }, h(Button, { size: 'sm', variant: settings.memoryEnabled ? 'primary' : 'outline', onClick: () => void patch({ memoryEnabled: !settings.memoryEnabled }) }, settings.memoryEnabled ? '已启用' : '已关闭'))))
|
|
206
206
|
const ownerSetting = !settings.groupMembersCanUseTools
|
|
207
207
|
? h('div', { className: 'qqSetting' },
|
package/docs/ARCHITECTURE.en.md
CHANGED
|
@@ -47,7 +47,9 @@ QQ Direct · <nickname>
|
|
|
47
47
|
|
|
48
48
|
The Client no longer registers a `sidebar.footer.action` chat picker. QQ Sessions are listed and opened by DSH's normal Session/Workspace Browser.
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
QQChat no longer submits an internal bootstrap wake. A newly created silent Session remains blank according to DSH's own semantics, so the native DSH Agent Preset selector can be used before the first Agent turn. The first QQ message that actually wakes the Agent creates the normal turn boundary.
|
|
51
|
+
|
|
52
|
+
The plugin's `agentPreset` setting applies only to new Sessions. When a preset is selected in DSH Web, DSH records an `agent-preset/selected` event. QQChat reads that event when resuming the Session and restores the Session's most recent preset selection.
|
|
51
53
|
|
|
52
54
|
## Settings boundary
|
|
53
55
|
|
|
@@ -56,8 +58,9 @@ A new silent-only peer would otherwise remain blank because DSH uses `turn/start
|
|
|
56
58
|
```text
|
|
57
59
|
QR authorization / connection
|
|
58
60
|
Auto reply / Mention only / Silent record
|
|
59
|
-
reply
|
|
61
|
+
group and direct-chat reply formats
|
|
60
62
|
memory system [on/off]
|
|
63
|
+
group-member media receive/read permissions
|
|
61
64
|
group-member tool permission
|
|
62
65
|
Owner stable ID
|
|
63
66
|
plugin logs
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -47,14 +47,9 @@ QQ私聊 · <昵称>
|
|
|
47
47
|
|
|
48
48
|
Client 不再注册 `sidebar.footer.action` 会话选择器。QQ Session 直接由 DSH 自己的 Session/Workspace Browser 展示和打开。
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
-
|
|
53
|
-
- Session 能进入 DSH 正常列表;
|
|
54
|
-
- 没有 model step;
|
|
55
|
-
- 不调用 LLM;
|
|
56
|
-
- 不发送 QQ 消息;
|
|
57
|
-
- bootstrap 不进入模型 history。
|
|
50
|
+
QQChat 不再提交内部 bootstrap wake。新建的静默 Session 保持 DSH 原生 blank 状态,因此可以直接使用 DSH 的 Agent Preset 选择器;第一条真正触发 Agent 的 QQ 消息才会创建正常 turn boundary。
|
|
51
|
+
|
|
52
|
+
插件配置中的 `agentPreset` 只用于新 Session。Session 被 DSH Web 选择过 preset 后,DSH 会写入 `agent-preset/selected` 事件;QQChat 恢复 Session 时读取该事件,优先恢复 Session 最近一次选择的 preset。
|
|
58
53
|
|
|
59
54
|
## Settings 边界
|
|
60
55
|
|
package/docs/MEDIA_AND_QUOTES.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
本文是设计与审查文档,不代表所有功能已经实现。
|
|
16
16
|
|
|
17
|
-
当前实现进度:Phase 0–2
|
|
17
|
+
当前实现进度:Phase 0–2 已完成,Phase 3–4 的 Host 侧基础能力已完成,包括媒体接收、元数据工具、引用关系、引用索引和附件复用。已包含标准化媒体/引用类型、QQ 附件解析、SQLite 附件关系、安全下载、媒体清理、图片附件接入、`qqchat_describe_image`、`qqchat_read_file`、`qqchat_media_info` 和 JSON 可序列化的 Web transcript 摘要。Agent 触发消息已经使用 DSH 原生图片块;静默 transcript 的图片目前通过受保护的 RPC data URL 做最小适配。DSH Web 自定义 transcript 的完整原生媒体 renderer、文件/音频/视频播放器、短 TTL 媒体 URL 和真实环境验收仍未完成,因此不能视为完整的 Web 侧 Phase 3–4 交付。
|
|
18
18
|
|
|
19
19
|
Web 渲染后续采用“DSH 原生优先”的方案:触发 Agent 的消息使用 DSH 原生消息内容和 `ImageBlock`;静默记录或未触发 Agent 的 QQ transcript 只增加最小的附件适配,不另建一套气泡、图片预览、文件卡片或媒体主题。这样不会为了显示媒体而改变群聊接收模式或额外唤醒 Agent。
|
|
20
20
|
|
|
@@ -467,9 +467,9 @@ createUserMessage({
|
|
|
467
467
|
网页显示必须复用 DSH 原生消息和附件样式:
|
|
468
468
|
|
|
469
469
|
- 图片显示缩略图和可选的打开动作;
|
|
470
|
-
-
|
|
471
|
-
-
|
|
472
|
-
-
|
|
470
|
+
- 语音、视频和文件目前保存并展示受限的元数据摘要;
|
|
471
|
+
- Agent 侧可通过媒体工具读取允许范围内的文件或媒体;
|
|
472
|
+
- Web 侧完整播放器、下载/保存动作仍等待 DSH 公开 renderer 接口;
|
|
473
473
|
- 引用消息显示独立的引用块,正文和引用附件分开;
|
|
474
474
|
- 不在插件里重新实现 DSH 的气泡、输入框、Modal、图片预览、文件卡片或附件基础样式;
|
|
475
475
|
- 插件 Client 只负责把 QQ 消息、引用和附件转换成 DSH renderer 能消费的结构。
|
|
@@ -479,9 +479,9 @@ createUserMessage({
|
|
|
479
479
|
### A. DSH 原生能力确认
|
|
480
480
|
|
|
481
481
|
- [x] 确认当前 DSH 核心对 `ImageBlock`、`ImageAttachmentRef` 和附件引用的输入边界;
|
|
482
|
-
- [
|
|
483
|
-
- [
|
|
484
|
-
- [
|
|
482
|
+
- [x] 确认 DSH 当前对图片块和附件引用的输入边界;
|
|
483
|
+
- [x] 确认 DSH 尚未向插件公开可直接复用的自定义 transcript 图片、文件、音频、视频 renderer 接口;
|
|
484
|
+
- [x] 确认自定义 `qqchat/message` event 不能直接承载 DSH 原生附件块;
|
|
485
485
|
- [x] 自定义 event 不能直接变成原生 `ImageBlock` 时,记录并采用最小适配边界,不复制 DSH 基础组件。
|
|
486
486
|
|
|
487
487
|
### B. Host 附件读取和权限
|
|
@@ -491,7 +491,7 @@ createUserMessage({
|
|
|
491
491
|
- [x] 校验附件是否已经关联到当前消息、引用消息和会话范围;
|
|
492
492
|
- [x] 校验附件状态、过期时间和图片读取大小;媒体类型由入站媒体归一化和 DSH 图片附件服务共同校验;
|
|
493
493
|
- [x] 图片响应优先带回 DSH 原生 `ImageAttachmentRef`;仅为当前自定义 transcript 的图片预览提供受限 data URL;
|
|
494
|
-
- [
|
|
494
|
+
- [x] 当前实现使用受大小限制的 RPC 响应 data URL;接入 DSH renderer 后应替换为短 TTL 的受保护引用;
|
|
495
495
|
- [x] 不把 QQ 原始签名 URL、AppSecret、Token 或本地文件路径返回给 Client;
|
|
496
496
|
- [x] 无权限、过期、删除和超限附件会被拒绝;损坏文件由 RPC 失败处理,不泄露存储路径。
|
|
497
497
|
|
|
@@ -509,11 +509,10 @@ createUserMessage({
|
|
|
509
509
|
|
|
510
510
|
- [x] 静默消息继续使用 `qqchat/message`,不为了显示媒体改成 `user/message`;
|
|
511
511
|
- [x] event 只携带 JSON 可序列化的附件摘要:ID、类型、文件名、大小、引用标记;
|
|
512
|
-
- [
|
|
513
|
-
- [
|
|
514
|
-
- [ ] 薄适配层只负责附件引用转换和加载状态,不复制气泡、Modal、按钮或预览组件;
|
|
512
|
+
- [x] 已确认当前没有自定义 event 的原生附件入口;
|
|
513
|
+
- [x] 薄适配层只负责附件摘要、受保护读取和加载状态,不复制气泡、Modal、按钮或预览组件;
|
|
515
514
|
- [x] 图片加载通过受保护的附件读取 RPC,保持与 DSH 原生附件引用相同的权限边界;
|
|
516
|
-
- [
|
|
515
|
+
- [x] 文件、语音、视频在当前 transcript 中只保留统一 JSON 元数据摘要;原生播放器待 DSH 暴露公开入口后接入;
|
|
517
516
|
- [x] 加载失败、过期和无权限状态不显示原始 URL 或路径。
|
|
518
517
|
|
|
519
518
|
### E. 引用消息显示
|
package/docs/STREAMING.md
CHANGED
|
@@ -296,25 +296,28 @@ if (writer.sentFrameCount === 0) {
|
|
|
296
296
|
}
|
|
297
297
|
```
|
|
298
298
|
|
|
299
|
-
##
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
- [
|
|
304
|
-
- [
|
|
305
|
-
- [
|
|
306
|
-
- [
|
|
307
|
-
- [
|
|
308
|
-
- [
|
|
309
|
-
- [
|
|
310
|
-
- [
|
|
311
|
-
- [
|
|
312
|
-
- [
|
|
313
|
-
- [
|
|
314
|
-
- [
|
|
315
|
-
- [ ]
|
|
316
|
-
- [
|
|
317
|
-
|
|
299
|
+
## 当前实现核对清单
|
|
300
|
+
|
|
301
|
+
以下是当前 `dsh-qqchat` 对官方方案的落地情况:
|
|
302
|
+
|
|
303
|
+
- [x] 使用 QQ 官方 `stream_messages` 请求和响应字段;
|
|
304
|
+
- [x] 仅为 C2C 私聊创建流式 Writer;
|
|
305
|
+
- [x] 从 DSH assistant text-delta 累计全文;
|
|
306
|
+
- [x] 使用 `replace`,不把 delta 当作独立消息;
|
|
307
|
+
- [x] 为每个流生成新的 `msg_seq`;
|
|
308
|
+
- [x] `index` 从 0 开始严格递增;
|
|
309
|
+
- [x] 首帧响应后再读取和保存 `stream_msg_id`;
|
|
310
|
+
- [x] 所有请求通过串行队列;
|
|
311
|
+
- [x] 使用节流减少更新频率;
|
|
312
|
+
- [x] 对发送失败区分“尚未发送”和“已经发送部分”;
|
|
313
|
+
- [x] 提供流式开关和普通发送回退;
|
|
314
|
+
- [x] 使用模拟 HTTP 测试覆盖首帧、后续帧、结束帧和队列竞态;
|
|
315
|
+
- [ ] 真实 QQ 手机端和桌面端分别验证所有客户端的可见性;
|
|
316
|
+
- [x] 日志只记录 `msg_seq`、`index`、是否有 `stream_msg_id` 等定位信息,不记录完整敏感正文。
|
|
317
|
+
|
|
318
|
+
### 已知故障:QQ `40054005` 去重
|
|
319
|
+
|
|
320
|
+
QQ 会在 `msg_seq` 重复或流式上下文不完整时返回 `40054005`。当前实现为每个流生成新的序列号,并在首帧成功后把返回的 `stream_msg_id` 传给后续帧;如果流式首帧尚未成功,运行时会回退到普通发送,避免用户收不到回复。如果已经成功显示过部分流式内容,插件不会再发送一条完整重复消息,而是记录失败并结束本轮。
|
|
318
321
|
|
|
319
322
|
## 测试矩阵
|
|
320
323
|
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -227,7 +227,7 @@ function QQSettings({ rpc }) {
|
|
|
227
227
|
: settings.directStreamingEnabled
|
|
228
228
|
? '使用 QQ 官方流式接口逐步更新私聊回复;部分 QQ 客户端可能看不到流式效果。'
|
|
229
229
|
: '使用普通一次性消息发送,兼容性更好。')))));
|
|
230
|
-
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' }, '
|
|
230
|
+
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 ? '已启用' : '已关闭'))));
|
|
231
231
|
const ownerSetting = !settings.groupMembersCanUseTools
|
|
232
232
|
? (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, {
|
|
233
233
|
value: settings.ownerUserId,
|
package/lib/gateway/api.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export declare class QQApiClient {
|
|
|
14
14
|
constructor(db: QQChatDatabase, config: QQChatConfig);
|
|
15
15
|
clearToken(accountId: number): void;
|
|
16
16
|
token(account: AccountRow): Promise<string>;
|
|
17
|
-
gatewayUrl(account: AccountRow): Promise<string>;
|
|
17
|
+
gatewayUrl(account: AccountRow, retry?: boolean): Promise<string>;
|
|
18
18
|
private nextSeq;
|
|
19
19
|
/**
|
|
20
20
|
* QQ stream_messages requires a fresh msg_seq for each stream session.
|
package/lib/gateway/api.js
CHANGED
|
@@ -32,12 +32,16 @@ export class QQApiClient {
|
|
|
32
32
|
this.tokens.set(id, { token: data.access_token, expiresAt: Date.now() + Math.max(60, ttl) * 1000 });
|
|
33
33
|
return data.access_token;
|
|
34
34
|
}
|
|
35
|
-
async gatewayUrl(account) {
|
|
35
|
+
async gatewayUrl(account, retry = true) {
|
|
36
36
|
const token = await this.token(account);
|
|
37
37
|
const base = account.sandbox ? SANDBOX_API_BASE : API_BASE;
|
|
38
38
|
const response = await fetch(`${base}/gateway`, {
|
|
39
39
|
headers: { Authorization: `QQBot ${token}` }, signal: AbortSignal.timeout(15_000),
|
|
40
40
|
});
|
|
41
|
+
if (response.status === 401 && retry) {
|
|
42
|
+
this.clearToken(account.id);
|
|
43
|
+
return this.gatewayUrl(account, false);
|
|
44
|
+
}
|
|
41
45
|
const data = await safeJson(response);
|
|
42
46
|
if (!response.ok || !data?.url)
|
|
43
47
|
throw new Error(`QQ gateway 获取失败: HTTP ${response.status}`);
|
package/lib/gateway/gateway.js
CHANGED
|
@@ -72,7 +72,11 @@ export class QQGateway {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
async connectOnce() {
|
|
75
|
-
|
|
75
|
+
// Resolve the URL first. gatewayUrl may invalidate and refresh a stale
|
|
76
|
+
// cached token after a 401; fetching the identify token in parallel could
|
|
77
|
+
// otherwise retain the pre-refresh token for the WebSocket handshake.
|
|
78
|
+
const url = await this.api.gatewayUrl(this.account);
|
|
79
|
+
const token = await this.api.token(this.account);
|
|
76
80
|
await new Promise((resolve, reject) => {
|
|
77
81
|
const ws = new WebSocket(url);
|
|
78
82
|
this.ws = ws;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
|
|
2
3
|
import '../shared/augmentations.js';
|
|
3
4
|
import type { QQChatDatabase } from '../storage/db.js';
|
|
4
5
|
import type { MemoryEngine } from '../storage/memory.js';
|
|
5
6
|
import type { ChatType, GroupRow, LoggerLike, MemberRow, StoredAttachmentSummary, QQChatConfig, QQChatDisplayEvent, QQNormalizedMessage } from '../types.js';
|
|
7
|
+
export declare function resolveQQSessionPreset(header: Session['header'], events: readonly SessionEvent[]): string | undefined;
|
|
6
8
|
export declare class DshQQBridge {
|
|
7
9
|
private readonly ctx;
|
|
8
10
|
private readonly db;
|
|
@@ -21,7 +23,6 @@ export declare class DshQQBridge {
|
|
|
21
23
|
private readonly memorySnapshots;
|
|
22
24
|
private readonly disposeEvent;
|
|
23
25
|
private readonly disposeToolGate;
|
|
24
|
-
private readonly disposeBootstrapGate;
|
|
25
26
|
private readonly disposeCommands;
|
|
26
27
|
private readonly disposeImageTool;
|
|
27
28
|
private readonly disposeMediaTools;
|
|
@@ -36,7 +37,6 @@ export declare class DshQQBridge {
|
|
|
36
37
|
private ensureAgent;
|
|
37
38
|
private isArchived;
|
|
38
39
|
private rememberRoute;
|
|
39
|
-
private ensureVisible;
|
|
40
40
|
private appendDisplayIfMissing;
|
|
41
41
|
private appendOwnerMessageIfMissing;
|
|
42
42
|
private ensureTitle;
|
|
@@ -50,7 +50,9 @@ export declare class DshQQBridge {
|
|
|
50
50
|
private commandHelp;
|
|
51
51
|
private registerCommands;
|
|
52
52
|
private modelCommand;
|
|
53
|
+
private presetCommand;
|
|
53
54
|
private statusCommand;
|
|
54
55
|
private composition;
|
|
56
|
+
private persistedPreset;
|
|
55
57
|
private serial;
|
|
56
58
|
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
4
|
+
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets';
|
|
4
5
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
5
6
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
6
7
|
import '../shared/augmentations.js';
|
|
7
8
|
import { defaultRuntimeSettings } from '../config.js';
|
|
8
9
|
import { registerQQImageTool } from '../media/image-tool.js';
|
|
9
10
|
import { registerQQMediaTools } from '../media/media-tools.js';
|
|
10
|
-
import { dispatchQQCommand, qqCommandText } from '../commands/dispatch.js';
|
|
11
|
+
import { dispatchQQCommand, qqCommandText, slashCommandName } from '../commands/dispatch.js';
|
|
11
12
|
import { MEMORY_SNAPSHOT_TTL_MS, memorySnapshotHash, restoreMemorySnapshotState, shouldRefreshMemorySnapshot } from './memory-snapshot.js';
|
|
12
13
|
const DSH_RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt';
|
|
14
|
+
export function resolveQQSessionPreset(header, events) {
|
|
15
|
+
return resolveSessionPreset({ header, events });
|
|
16
|
+
}
|
|
13
17
|
const MEDIA_TOOL_NAMES = new Set(['qqchat_describe_image', 'qqchat_read_file', 'qqchat_media_info']);
|
|
14
18
|
const packageMetadata = createRequire(import.meta.url)('../../package.json');
|
|
15
19
|
const PLUGIN_VERSION = packageMetadata.version || 'unknown';
|
|
@@ -31,7 +35,6 @@ export class DshQQBridge {
|
|
|
31
35
|
memorySnapshots = new Map();
|
|
32
36
|
disposeEvent;
|
|
33
37
|
disposeToolGate;
|
|
34
|
-
disposeBootstrapGate;
|
|
35
38
|
disposeCommands;
|
|
36
39
|
disposeImageTool;
|
|
37
40
|
disposeMediaTools;
|
|
@@ -42,11 +45,6 @@ export class DshQQBridge {
|
|
|
42
45
|
this.config = config;
|
|
43
46
|
this.logger = logger;
|
|
44
47
|
this.disposeEvent = ctx.on('session/event', (session, event) => this.onSessionEvent(session, event));
|
|
45
|
-
this.disposeBootstrapGate = ctx.on('agent/pre-step', async ({ messages }, next) => {
|
|
46
|
-
if (messages.some(message => message.source.kind === 'qq-chat-bootstrap'))
|
|
47
|
-
return { kind: 'reject' };
|
|
48
|
-
return next();
|
|
49
|
-
});
|
|
50
48
|
this.disposeToolGate = ctx.on('tools/pre-execute', async (exec, next) => {
|
|
51
49
|
const agent = exec.agent;
|
|
52
50
|
if (!agent)
|
|
@@ -75,7 +73,6 @@ export class DshQQBridge {
|
|
|
75
73
|
}
|
|
76
74
|
async dispose() {
|
|
77
75
|
this.disposeEvent();
|
|
78
|
-
this.disposeBootstrapGate();
|
|
79
76
|
this.disposeToolGate();
|
|
80
77
|
this.disposeImageTool();
|
|
81
78
|
this.disposeMediaTools();
|
|
@@ -131,6 +128,17 @@ export class DshQQBridge {
|
|
|
131
128
|
if (commandText !== undefined) {
|
|
132
129
|
if (!commands)
|
|
133
130
|
return '当前 DSH profile 未加载命令系统,请重启并确认使用了最新插件。';
|
|
131
|
+
// Preset discovery/selection is a pre-session operation. Running it
|
|
132
|
+
// through DSH's native command executor would append command/run and
|
|
133
|
+
// make the blank Session ineligible for the very preset it lists.
|
|
134
|
+
// Keep the native registration for Web, but handle QQ directly.
|
|
135
|
+
if (slashCommandName(commandText) === 'qqpreset' && (message.chatType !== 'group' || isOwner)) {
|
|
136
|
+
const prefix = '/qqpreset';
|
|
137
|
+
const result = await this.presetCommand(agent, commandText.slice(prefix.length).trim());
|
|
138
|
+
if (this.pendingResets.delete(String(sessionId)))
|
|
139
|
+
await this.resetSession(message.chatType, row);
|
|
140
|
+
return result.text || '';
|
|
141
|
+
}
|
|
134
142
|
const command = await dispatchQQCommand(commands, agent, commandText, { chatType: message.chatType, isOwner });
|
|
135
143
|
if (command.handled) {
|
|
136
144
|
if (this.pendingResets.delete(String(sessionId)))
|
|
@@ -273,7 +281,6 @@ export class DshQQBridge {
|
|
|
273
281
|
this.ensureSelection(live);
|
|
274
282
|
this.ensureTitle(live.session, chatType, row);
|
|
275
283
|
this.rememberRoute(chatType, row, live, sessionId);
|
|
276
|
-
await this.ensureVisible(live);
|
|
277
284
|
return { agent: live, sessionId };
|
|
278
285
|
}
|
|
279
286
|
const resumed = await this.tryResume(sessionId);
|
|
@@ -281,7 +288,6 @@ export class DshQQBridge {
|
|
|
281
288
|
this.ensureSelection(resumed.agent);
|
|
282
289
|
this.ensureTitle(resumed.agent.session, chatType, row);
|
|
283
290
|
this.rememberRoute(chatType, row, resumed.agent, sessionId);
|
|
284
|
-
await this.ensureVisible(resumed.agent);
|
|
285
291
|
return { agent: resumed.agent, sessionId };
|
|
286
292
|
}
|
|
287
293
|
}
|
|
@@ -300,7 +306,6 @@ export class DshQQBridge {
|
|
|
300
306
|
this.db.setChatSession(chatType, Number(row.id), sessionId);
|
|
301
307
|
this.ensureTitle(handle.agent.session, chatType, row);
|
|
302
308
|
this.rememberRoute(chatType, row, handle.agent, sessionId);
|
|
303
|
-
await this.ensureVisible(handle.agent);
|
|
304
309
|
return { agent: handle.agent, sessionId };
|
|
305
310
|
}
|
|
306
311
|
isArchived(sessionId) {
|
|
@@ -317,16 +322,10 @@ export class DshQQBridge {
|
|
|
317
322
|
else
|
|
318
323
|
this.memory.setMemberRoute(Number(row.id), provider, model, sessionId);
|
|
319
324
|
}
|
|
320
|
-
async ensureVisible(agent) {
|
|
321
|
-
if (agent.session.events.some(event => event.type === 'turn/start'))
|
|
322
|
-
return;
|
|
323
|
-
agent.followup(createUserMessage({ source: { kind: 'qq-chat-bootstrap', plugin: 'dsh-qqchat' }, content: [{ type: 'text', text: 'Activate QQ Chat workspace session.' }] }));
|
|
324
|
-
await agent.whenIdle();
|
|
325
|
-
}
|
|
326
325
|
appendDisplayIfMissing(session, event) {
|
|
327
326
|
if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId))
|
|
328
327
|
return;
|
|
329
|
-
session.append('qqchat/message', displayEventPayload(event));
|
|
328
|
+
session.append('qqchat/message', displayEventPayload(event), { ignorable: true });
|
|
330
329
|
}
|
|
331
330
|
appendOwnerMessageIfMissing(session, event) {
|
|
332
331
|
if (session.events.some(item => {
|
|
@@ -363,7 +362,7 @@ export class DshQQBridge {
|
|
|
363
362
|
}
|
|
364
363
|
async tryResume(sessionId) {
|
|
365
364
|
try {
|
|
366
|
-
const composition = await this.composition();
|
|
365
|
+
const composition = await this.composition(sessionId);
|
|
367
366
|
const handle = await this.ctx.agents.resume({ resumeSessionId: SessionId(sessionId), agentOptions: this.agentOptions(), setup: composition.setup });
|
|
368
367
|
if (!handle.agent.session.header.cwd) {
|
|
369
368
|
await handle.dispose();
|
|
@@ -451,7 +450,7 @@ export class DshQQBridge {
|
|
|
451
450
|
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
452
451
|
'',
|
|
453
452
|
'QQChat 会话',
|
|
454
|
-
...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqstop', 'qqstatus'].includes(command.name))
|
|
453
|
+
...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqpreset', 'qqstop', 'qqstatus'].includes(command.name))
|
|
455
454
|
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
456
455
|
'',
|
|
457
456
|
'QQChat 工具',
|
|
@@ -468,6 +467,7 @@ export class DshQQBridge {
|
|
|
468
467
|
register('qqreset', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' }; }),
|
|
469
468
|
register('qqclear', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' }; }),
|
|
470
469
|
register('qqmodel', '查看或切换当前会话模型', invocation => this.modelCommand(invocation), '[provider/]model'),
|
|
470
|
+
register('qqpreset', '查看或切换当前 Session 的 Agent Preset', invocation => this.presetCommand(invocation.agent, invocation.rawInput.trim()), 'preset'),
|
|
471
471
|
register('qqstop', '中止当前生成', invocation => {
|
|
472
472
|
if (invocation.agent.status !== 'running')
|
|
473
473
|
return { kind: 'success', text: '当前没有进行中的生成。' };
|
|
@@ -512,6 +512,31 @@ export class DshQQBridge {
|
|
|
512
512
|
this.routes.set(String(agent.id), { provider, model });
|
|
513
513
|
return { kind: 'success', text: `模型已切换:${provider}/${model}\n对话上下文保留,下一轮生效。` };
|
|
514
514
|
}
|
|
515
|
+
async presetCommand(agent, requested) {
|
|
516
|
+
const getService = this.ctx.get;
|
|
517
|
+
const presets = getService?.call(this.ctx, 'agentPresets');
|
|
518
|
+
if (!presets)
|
|
519
|
+
return { kind: 'error', text: '当前 DSH profile 未加载 Agent Preset 系统。' };
|
|
520
|
+
if (!requested) {
|
|
521
|
+
const available = await presets.list();
|
|
522
|
+
return { kind: 'success', text: available.length ? `可用 Agent Preset:\n${available.map(preset => `/qqpreset ${preset.id}`).join('\n')}` : '当前没有可用 Agent Preset。' };
|
|
523
|
+
}
|
|
524
|
+
if (agent.session.events.some(event => event.type === 'turn/start')) {
|
|
525
|
+
return { kind: 'error', text: '当前 Session 已经开始对话,DSH 不允许更换 Agent Preset。请先使用 /qqnew 开启新 Session。' };
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
const preset = await presets.recompose(agent.ctx, requested);
|
|
529
|
+
agent.session.append('agent-preset/selected', { agentPreset: preset.id });
|
|
530
|
+
return { kind: 'success', text: `当前 Session 已切换到 Agent Preset:${preset.id}` };
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
const available = await presets.list().catch(() => []);
|
|
534
|
+
const availableText = available.length
|
|
535
|
+
? `当前可用:${available.map(preset => preset.id).join('、')}`
|
|
536
|
+
: '当前没有可用 Agent Preset。';
|
|
537
|
+
return { kind: 'error', text: `无法切换 Agent Preset「${requested}」:该 preset 不存在、已删除或格式无效。\n${availableText}` };
|
|
538
|
+
}
|
|
539
|
+
}
|
|
515
540
|
statusCommand(agent) {
|
|
516
541
|
const current = this.selection(agent)?.current;
|
|
517
542
|
const messages = agent.session.events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length;
|
|
@@ -521,14 +546,41 @@ export class DshQQBridge {
|
|
|
521
546
|
text: ['📊 Session 状态', `Session:${String(agent.id)}`, `状态:${agent.status === 'running' ? '生成中' : '空闲'}`, `模型:${current ? `${current.provider}/${current.model}` : '未知'}`, `消息数:${messages}`, `最后事件:${last?.type || '无'}`].join('\n'),
|
|
522
547
|
};
|
|
523
548
|
}
|
|
524
|
-
async composition() {
|
|
549
|
+
async composition(sessionId) {
|
|
525
550
|
const getService = this.ctx.get;
|
|
526
551
|
const presets = getService?.call(this.ctx, 'agentPresets');
|
|
527
552
|
if (!presets)
|
|
528
553
|
return {};
|
|
529
|
-
const
|
|
554
|
+
const persisted = sessionId ? await this.persistedPreset(sessionId) : undefined;
|
|
555
|
+
const requested = persisted || this.config.agentPreset;
|
|
556
|
+
let preset;
|
|
557
|
+
try {
|
|
558
|
+
preset = await presets.resolve(requested);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
const available = await presets.list();
|
|
562
|
+
const fallback = available[0];
|
|
563
|
+
if (!fallback)
|
|
564
|
+
throw error;
|
|
565
|
+
this.logger.warn?.(`[dsh-qqchat] Agent Preset「${requested || '默认'}」不可用,已降级到「${fallback.id}」`);
|
|
566
|
+
preset = await presets.resolve(fallback.id);
|
|
567
|
+
}
|
|
530
568
|
return { presetId: preset.id, setup: async (agentCtx) => { await presets.mount(agentCtx, preset.id); } };
|
|
531
569
|
}
|
|
570
|
+
async persistedPreset(sessionId) {
|
|
571
|
+
const getService = this.ctx.get;
|
|
572
|
+
const persistence = getService?.call(this.ctx, 'sessionPersistence');
|
|
573
|
+
if (!persistence)
|
|
574
|
+
return undefined;
|
|
575
|
+
try {
|
|
576
|
+
const inspection = await persistence.inspect(SessionId(sessionId));
|
|
577
|
+
return resolveQQSessionPreset(inspection.meta, inspection.events);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
this.logger.debug?.(`[dsh-qqchat] persisted preset lookup skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
532
584
|
serial(key, task) {
|
|
533
585
|
const previous = this.locks.get(key) || Promise.resolve();
|
|
534
586
|
const current = previous.catch(() => undefined).then(task);
|
package/lib/session/runtime.js
CHANGED
|
@@ -163,6 +163,9 @@ export class QQChatRuntime {
|
|
|
163
163
|
const id = Number(account.id);
|
|
164
164
|
if (this.gateways.has(id))
|
|
165
165
|
return;
|
|
166
|
+
// QR binding can replace the secret for an existing account id. Never
|
|
167
|
+
// carry a token obtained with the previous secret into the new Gateway.
|
|
168
|
+
this.api.clearToken(id);
|
|
166
169
|
const gateway = new QQGateway(account, this.db, this.api, message => this.onIncoming(message), this.logger);
|
|
167
170
|
this.gateways.set(id, gateway);
|
|
168
171
|
gateway.start();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-qqchat",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "DeepSeek Harness 的 QQ 私聊/群聊插件,支持扫码授权、原生 Session、群记忆与工具权限。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
49
49
|
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
|
|
50
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.8",
|
|
50
51
|
"@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
|
|
51
52
|
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8",
|
|
52
53
|
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
|
|
@@ -73,6 +74,7 @@
|
|
|
73
74
|
},
|
|
74
75
|
"devDependencies": {
|
|
75
76
|
"@deepseek-ai/dsh-agent-default-model": "^0.1.0-rc.8",
|
|
77
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.8",
|
|
76
78
|
"@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
|
|
77
79
|
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8",
|
|
78
80
|
"@types/node": "^22.18.0",
|
package/src/gateway/api.ts
CHANGED
|
@@ -75,12 +75,16 @@ export class QQApiClient {
|
|
|
75
75
|
return data.access_token
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
async gatewayUrl(account: AccountRow): Promise<string> {
|
|
78
|
+
async gatewayUrl(account: AccountRow, retry = true): Promise<string> {
|
|
79
79
|
const token = await this.token(account)
|
|
80
80
|
const base = account.sandbox ? SANDBOX_API_BASE : API_BASE
|
|
81
81
|
const response = await fetch(`${base}/gateway`, {
|
|
82
82
|
headers: { Authorization: `QQBot ${token}` }, signal: AbortSignal.timeout(15_000),
|
|
83
83
|
})
|
|
84
|
+
if (response.status === 401 && retry) {
|
|
85
|
+
this.clearToken(account.id)
|
|
86
|
+
return this.gatewayUrl(account, false)
|
|
87
|
+
}
|
|
84
88
|
const data = await safeJson<QQGatewayPayload>(response)
|
|
85
89
|
if (!response.ok || !data?.url) throw new Error(`QQ gateway 获取失败: HTTP ${response.status}`)
|
|
86
90
|
return data.url
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -71,7 +71,11 @@ export class QQGateway {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
private async connectOnce(): Promise<void> {
|
|
74
|
-
|
|
74
|
+
// Resolve the URL first. gatewayUrl may invalidate and refresh a stale
|
|
75
|
+
// cached token after a 401; fetching the identify token in parallel could
|
|
76
|
+
// otherwise retain the pre-refresh token for the WebSocket handshake.
|
|
77
|
+
const url = await this.api.gatewayUrl(this.account)
|
|
78
|
+
const token = await this.api.token(this.account)
|
|
75
79
|
await new Promise<void>((resolve, reject) => {
|
|
76
80
|
const ws = new WebSocket(url)
|
|
77
81
|
this.ws = ws
|
|
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
3
|
import type { Context } from '@deepseek-ai/cordis'
|
|
4
4
|
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
5
|
-
import type { AgentHandle, AgentOptions, AgentSetup, ModelSelectionRef
|
|
5
|
+
import type { AgentHandle, AgentOptions, AgentSetup, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
6
|
+
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
|
|
6
7
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
7
8
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
8
9
|
import type { CommandInvocation, CommandRuntime, CommandResult } from '@deepseek-ai/dsh-commands'
|
|
@@ -15,7 +16,7 @@ import '../shared/augmentations.js'
|
|
|
15
16
|
import { defaultRuntimeSettings } from '../config.js'
|
|
16
17
|
import { registerQQImageTool } from '../media/image-tool.js'
|
|
17
18
|
import { registerQQMediaTools } from '../media/media-tools.js'
|
|
18
|
-
import { dispatchQQCommand, qqCommandText } from '../commands/dispatch.js'
|
|
19
|
+
import { dispatchQQCommand, qqCommandText, slashCommandName } from '../commands/dispatch.js'
|
|
19
20
|
import type { QQChatDatabase } from '../storage/db.js'
|
|
20
21
|
import type { MemoryEngine } from '../storage/memory.js'
|
|
21
22
|
import type {
|
|
@@ -35,12 +36,17 @@ import type { MemorySnapshotState } from './memory-snapshot.js'
|
|
|
35
36
|
const DSH_RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
|
|
36
37
|
|
|
37
38
|
interface AgentPreset { id: string }
|
|
38
|
-
interface AgentPresetService { resolve(id?: string): Promise<AgentPreset>; mount(ctx: Context, id: string): Promise<unknown> }
|
|
39
|
+
interface AgentPresetService { resolve(id?: string): Promise<AgentPreset>; list(): Promise<AgentPreset[]>; mount(ctx: Context, id: string): Promise<unknown>; recompose(ctx: Context, id: string): Promise<AgentPreset> }
|
|
40
|
+
interface SessionPersistenceService { inspect(id: SessionId): Promise<{ meta: Session['header']; events: readonly SessionEvent[] }> }
|
|
39
41
|
interface SessionTitleService { get(session: Session): unknown; rename(session: Session, title: string): unknown }
|
|
40
42
|
interface WorkspaceRegistryService { archivedSessionIds: readonly string[] }
|
|
41
43
|
interface Composition { presetId?: string; setup?: AgentSetup }
|
|
42
44
|
interface ActiveActor { chatType: ChatType; senderId: string }
|
|
43
45
|
|
|
46
|
+
export function resolveQQSessionPreset(header: Session['header'], events: readonly SessionEvent[]): string | undefined {
|
|
47
|
+
return resolveSessionPreset({ header, events })
|
|
48
|
+
}
|
|
49
|
+
|
|
44
50
|
const MEDIA_TOOL_NAMES = new Set(['qqchat_describe_image', 'qqchat_read_file', 'qqchat_media_info'])
|
|
45
51
|
const packageMetadata = createRequire(import.meta.url)('../../package.json') as { version?: string }
|
|
46
52
|
const PLUGIN_VERSION = packageMetadata.version || 'unknown'
|
|
@@ -58,7 +64,6 @@ export class DshQQBridge {
|
|
|
58
64
|
private readonly memorySnapshots = new Map<string, MemorySnapshotState>()
|
|
59
65
|
private readonly disposeEvent: () => void
|
|
60
66
|
private readonly disposeToolGate: () => void
|
|
61
|
-
private readonly disposeBootstrapGate: () => void
|
|
62
67
|
private readonly disposeCommands: () => void
|
|
63
68
|
private readonly disposeImageTool: () => void
|
|
64
69
|
private readonly disposeMediaTools: () => void
|
|
@@ -71,10 +76,6 @@ export class DshQQBridge {
|
|
|
71
76
|
private readonly logger: LoggerLike = console,
|
|
72
77
|
) {
|
|
73
78
|
this.disposeEvent = ctx.on('session/event', (session, event) => this.onSessionEvent(session, event))
|
|
74
|
-
this.disposeBootstrapGate = ctx.on('agent/pre-step', async ({ messages }, next): Promise<AgentPreStepDecision> => {
|
|
75
|
-
if (messages.some(message => message.source.kind === 'qq-chat-bootstrap')) return { kind: 'reject' }
|
|
76
|
-
return next()
|
|
77
|
-
})
|
|
78
79
|
this.disposeToolGate = ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
|
79
80
|
const agent = exec.agent
|
|
80
81
|
if (!agent) return next()
|
|
@@ -99,7 +100,6 @@ export class DshQQBridge {
|
|
|
99
100
|
|
|
100
101
|
async dispose(): Promise<void> {
|
|
101
102
|
this.disposeEvent()
|
|
102
|
-
this.disposeBootstrapGate()
|
|
103
103
|
this.disposeToolGate()
|
|
104
104
|
this.disposeImageTool()
|
|
105
105
|
this.disposeMediaTools()
|
|
@@ -149,6 +149,16 @@ export class DshQQBridge {
|
|
|
149
149
|
const commandText = qqCommandText(message.text, message.mentioned)
|
|
150
150
|
if (commandText !== undefined) {
|
|
151
151
|
if (!commands) return '当前 DSH profile 未加载命令系统,请重启并确认使用了最新插件。'
|
|
152
|
+
// Preset discovery/selection is a pre-session operation. Running it
|
|
153
|
+
// through DSH's native command executor would append command/run and
|
|
154
|
+
// make the blank Session ineligible for the very preset it lists.
|
|
155
|
+
// Keep the native registration for Web, but handle QQ directly.
|
|
156
|
+
if (slashCommandName(commandText) === 'qqpreset' && (message.chatType !== 'group' || isOwner)) {
|
|
157
|
+
const prefix = '/qqpreset'
|
|
158
|
+
const result = await this.presetCommand(agent, commandText.slice(prefix.length).trim())
|
|
159
|
+
if (this.pendingResets.delete(String(sessionId))) await this.resetSession(message.chatType, row)
|
|
160
|
+
return result.text || ''
|
|
161
|
+
}
|
|
152
162
|
const command = await dispatchQQCommand(commands, agent, commandText, { chatType: message.chatType, isOwner })
|
|
153
163
|
if (command.handled) {
|
|
154
164
|
if (this.pendingResets.delete(String(sessionId))) await this.resetSession(message.chatType, row)
|
|
@@ -286,7 +296,6 @@ export class DshQQBridge {
|
|
|
286
296
|
this.ensureSelection(live)
|
|
287
297
|
this.ensureTitle(live.session, chatType, row)
|
|
288
298
|
this.rememberRoute(chatType, row, live, sessionId)
|
|
289
|
-
await this.ensureVisible(live)
|
|
290
299
|
return { agent: live, sessionId }
|
|
291
300
|
}
|
|
292
301
|
const resumed = await this.tryResume(sessionId)
|
|
@@ -294,7 +303,6 @@ export class DshQQBridge {
|
|
|
294
303
|
this.ensureSelection(resumed.agent)
|
|
295
304
|
this.ensureTitle(resumed.agent.session, chatType, row)
|
|
296
305
|
this.rememberRoute(chatType, row, resumed.agent, sessionId)
|
|
297
|
-
await this.ensureVisible(resumed.agent)
|
|
298
306
|
return { agent: resumed.agent, sessionId }
|
|
299
307
|
}
|
|
300
308
|
}
|
|
@@ -314,7 +322,6 @@ export class DshQQBridge {
|
|
|
314
322
|
this.db.setChatSession(chatType, Number(row.id), sessionId)
|
|
315
323
|
this.ensureTitle(handle.agent.session, chatType, row)
|
|
316
324
|
this.rememberRoute(chatType, row, handle.agent, sessionId)
|
|
317
|
-
await this.ensureVisible(handle.agent)
|
|
318
325
|
return { agent: handle.agent, sessionId }
|
|
319
326
|
}
|
|
320
327
|
|
|
@@ -331,15 +338,9 @@ export class DshQQBridge {
|
|
|
331
338
|
else this.memory.setMemberRoute(Number((row as MemberRow).id), provider, model, sessionId)
|
|
332
339
|
}
|
|
333
340
|
|
|
334
|
-
private async ensureVisible(agent: AgentHandle['agent']): Promise<void> {
|
|
335
|
-
if (agent.session.events.some(event => event.type === 'turn/start')) return
|
|
336
|
-
agent.followup(createUserMessage({ source: { kind: 'qq-chat-bootstrap', plugin: 'dsh-qqchat' }, content: [{ type: 'text', text: 'Activate QQ Chat workspace session.' }] }))
|
|
337
|
-
await agent.whenIdle()
|
|
338
|
-
}
|
|
339
|
-
|
|
340
341
|
private appendDisplayIfMissing(session: Session, event: QQChatDisplayEvent): void {
|
|
341
342
|
if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId)) return
|
|
342
|
-
session.append('qqchat/message', displayEventPayload(event))
|
|
343
|
+
session.append('qqchat/message', displayEventPayload(event), { ignorable: true })
|
|
343
344
|
}
|
|
344
345
|
|
|
345
346
|
private appendOwnerMessageIfMissing(session: Session, event: QQChatDisplayEvent): void {
|
|
@@ -375,7 +376,7 @@ export class DshQQBridge {
|
|
|
375
376
|
|
|
376
377
|
private async tryResume(sessionId: string): Promise<AgentHandle | undefined> {
|
|
377
378
|
try {
|
|
378
|
-
const composition = await this.composition()
|
|
379
|
+
const composition = await this.composition(sessionId)
|
|
379
380
|
const handle = await this.ctx.agents.resume({ resumeSessionId: SessionId(sessionId), agentOptions: this.agentOptions(), setup: composition.setup })
|
|
380
381
|
if (!handle.agent.session.header.cwd) {
|
|
381
382
|
await handle.dispose()
|
|
@@ -458,7 +459,7 @@ export class DshQQBridge {
|
|
|
458
459
|
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
459
460
|
'',
|
|
460
461
|
'QQChat 会话',
|
|
461
|
-
...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqstop', 'qqstatus'].includes(command.name))
|
|
462
|
+
...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqpreset', 'qqstop', 'qqstatus'].includes(command.name))
|
|
462
463
|
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
463
464
|
'',
|
|
464
465
|
'QQChat 工具',
|
|
@@ -477,6 +478,7 @@ export class DshQQBridge {
|
|
|
477
478
|
register('qqreset', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' } }),
|
|
478
479
|
register('qqclear', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' } }),
|
|
479
480
|
register('qqmodel', '查看或切换当前会话模型', invocation => this.modelCommand(invocation), '[provider/]model'),
|
|
481
|
+
register('qqpreset', '查看或切换当前 Session 的 Agent Preset', invocation => this.presetCommand(invocation.agent as AgentHandle['agent'], invocation.rawInput.trim()), 'preset'),
|
|
480
482
|
register('qqstop', '中止当前生成', invocation => {
|
|
481
483
|
if (invocation.agent.status !== 'running') return { kind: 'success', text: '当前没有进行中的生成。' }
|
|
482
484
|
invocation.agent.cancel({ kind: 'user' })
|
|
@@ -519,6 +521,30 @@ export class DshQQBridge {
|
|
|
519
521
|
return { kind: 'success', text: `模型已切换:${provider}/${model}\n对话上下文保留,下一轮生效。` }
|
|
520
522
|
}
|
|
521
523
|
|
|
524
|
+
private async presetCommand(agent: AgentHandle['agent'], requested: string): Promise<CommandResult> {
|
|
525
|
+
const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
|
|
526
|
+
const presets = getService?.call(this.ctx, 'agentPresets') as AgentPresetService | undefined
|
|
527
|
+
if (!presets) return { kind: 'error', text: '当前 DSH profile 未加载 Agent Preset 系统。' }
|
|
528
|
+
if (!requested) {
|
|
529
|
+
const available = await presets.list()
|
|
530
|
+
return { kind: 'success', text: available.length ? `可用 Agent Preset:\n${available.map(preset => `/qqpreset ${preset.id}`).join('\n')}` : '当前没有可用 Agent Preset。' }
|
|
531
|
+
}
|
|
532
|
+
if (agent.session.events.some(event => event.type === 'turn/start')) {
|
|
533
|
+
return { kind: 'error', text: '当前 Session 已经开始对话,DSH 不允许更换 Agent Preset。请先使用 /qqnew 开启新 Session。' }
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const preset = await presets.recompose(agent.ctx, requested)
|
|
537
|
+
agent.session.append('agent-preset/selected', { agentPreset: preset.id })
|
|
538
|
+
return { kind: 'success', text: `当前 Session 已切换到 Agent Preset:${preset.id}` }
|
|
539
|
+
} catch {
|
|
540
|
+
const available = await presets.list().catch(() => [])
|
|
541
|
+
const availableText = available.length
|
|
542
|
+
? `当前可用:${available.map(preset => preset.id).join('、')}`
|
|
543
|
+
: '当前没有可用 Agent Preset。'
|
|
544
|
+
return { kind: 'error', text: `无法切换 Agent Preset「${requested}」:该 preset 不存在、已删除或格式无效。\n${availableText}` }
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
522
548
|
private statusCommand(agent: AgentHandle['agent']): CommandResult {
|
|
523
549
|
const current = this.selection(agent)?.current
|
|
524
550
|
const messages = agent.session.events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length
|
|
@@ -529,14 +555,38 @@ export class DshQQBridge {
|
|
|
529
555
|
}
|
|
530
556
|
}
|
|
531
557
|
|
|
532
|
-
private async composition(): Promise<Composition> {
|
|
558
|
+
private async composition(sessionId?: string): Promise<Composition> {
|
|
533
559
|
const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
|
|
534
560
|
const presets = getService?.call(this.ctx, 'agentPresets') as AgentPresetService | undefined
|
|
535
561
|
if (!presets) return {}
|
|
536
|
-
const
|
|
562
|
+
const persisted = sessionId ? await this.persistedPreset(sessionId) : undefined
|
|
563
|
+
const requested = persisted || this.config.agentPreset
|
|
564
|
+
let preset: AgentPreset
|
|
565
|
+
try {
|
|
566
|
+
preset = await presets.resolve(requested)
|
|
567
|
+
} catch (error) {
|
|
568
|
+
const available = await presets.list()
|
|
569
|
+
const fallback = available[0]
|
|
570
|
+
if (!fallback) throw error
|
|
571
|
+
this.logger.warn?.(`[dsh-qqchat] Agent Preset「${requested || '默认'}」不可用,已降级到「${fallback.id}」`)
|
|
572
|
+
preset = await presets.resolve(fallback.id)
|
|
573
|
+
}
|
|
537
574
|
return { presetId: preset.id, setup: async agentCtx => { await presets.mount(agentCtx, preset.id) } }
|
|
538
575
|
}
|
|
539
576
|
|
|
577
|
+
private async persistedPreset(sessionId: string): Promise<string | undefined> {
|
|
578
|
+
const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
|
|
579
|
+
const persistence = getService?.call(this.ctx, 'sessionPersistence') as SessionPersistenceService | undefined
|
|
580
|
+
if (!persistence) return undefined
|
|
581
|
+
try {
|
|
582
|
+
const inspection = await persistence.inspect(SessionId(sessionId))
|
|
583
|
+
return resolveQQSessionPreset(inspection.meta, inspection.events)
|
|
584
|
+
} catch (error) {
|
|
585
|
+
this.logger.debug?.(`[dsh-qqchat] persisted preset lookup skipped: ${error instanceof Error ? error.message : String(error)}`)
|
|
586
|
+
return undefined
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
540
590
|
private serial<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
541
591
|
const previous = this.locks.get(key) || Promise.resolve()
|
|
542
592
|
const current = previous.catch(() => undefined).then(task)
|
package/src/session/runtime.ts
CHANGED
|
@@ -147,6 +147,9 @@ export class QQChatRuntime {
|
|
|
147
147
|
if (!account) return
|
|
148
148
|
const id = Number(account.id)
|
|
149
149
|
if (this.gateways.has(id)) return
|
|
150
|
+
// QR binding can replace the secret for an existing account id. Never
|
|
151
|
+
// carry a token obtained with the previous secret into the new Gateway.
|
|
152
|
+
this.api.clearToken(id)
|
|
150
153
|
const gateway = new QQGateway(account, this.db, this.api, message => this.onIncoming(message), this.logger)
|
|
151
154
|
this.gateways.set(id, gateway)
|
|
152
155
|
gateway.start()
|