dsh-qqchat 0.2.2 → 0.2.4
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 +5 -2
- package/README.md +5 -2
- package/client-src/plugin.cts +2 -1
- package/client-src/shared.cts +41 -0
- package/docs/ARCHITECTURE.md +5 -0
- package/docs/DEVELOPMENT.md +26 -1
- package/docs/MEDIA_AND_QUOTES.md +9 -8
- package/lib/client.js +45 -0
- package/lib/index.js +9 -1
- package/lib/media/image-tool.d.ts +1 -1
- package/lib/media/image-tool.js +2 -2
- package/lib/session/agent-bridge.d.ts +7 -1
- package/lib/session/agent-bridge.js +58 -28
- package/lib/session/registration.d.ts +14 -0
- package/lib/session/registration.js +94 -0
- package/package.json +18 -16
- package/src/index.ts +10 -1
- package/src/media/image-tool.ts +2 -2
- package/src/session/agent-bridge.ts +62 -24
- package/src/session/registration.ts +102 -0
package/README.en.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
<p>
|
|
8
8
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a>
|
|
9
|
-
<img src="https://img.shields.io/badge/DSH-
|
|
9
|
+
<img src="https://img.shields.io/badge/DSH-0.1.1--rc.2-6f42c1.svg" alt="DSH 0.1.1-rc.2">
|
|
10
|
+
<img src="https://img.shields.io/badge/QQChat-0.2.3-2ea44f.svg" alt="QQChat 0.2.3">
|
|
10
11
|
<img src="https://img.shields.io/badge/TypeScript-5.x-3178c6.svg" alt="TypeScript">
|
|
11
12
|
</p>
|
|
12
13
|
|
|
@@ -17,6 +18,8 @@
|
|
|
17
18
|
|
|
18
19
|
<p><em>Chat in QQ, then view the complete Session, memory and tool activity in DSH Web.</em></p>
|
|
19
20
|
|
|
21
|
+
<p>This release is verified against DSH <code>0.1.1-rc.2</code>. Install the plugin as-is; no DSH source patch or rebuild is required.</p>
|
|
22
|
+
|
|
20
23
|
`dsh-qqchat` is an official QQ Bot plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness). It maps QQ groups and direct chats to ordinary DSH Sessions and uses DSH's native Conversation, Agent, command and tool surfaces.
|
|
21
24
|
|
|
22
25
|
> This is a Vibe Coding project, so code quality and engineering completeness may need improvement. If you find a problem or have a better implementation idea, please open an [Issue](https://github.com/Coffeiz/dsh-qqchat/issues) or submit a [Pull Request](https://github.com/Coffeiz/dsh-qqchat/pulls).
|
|
@@ -217,7 +220,7 @@ QQ Sessions use DSH's native Agent Preset mechanism. The preset configured for t
|
|
|
217
220
|
## Notes
|
|
218
221
|
|
|
219
222
|
- An official QQ Bot is required; personal QQ account login is not supported.
|
|
220
|
-
- `0.2.
|
|
223
|
+
- `0.2.2` is still being refined; test with a dedicated Bot and group first.
|
|
221
224
|
- Rich media, full QQ emoji handling and some complex message formats may require compatibility mode.
|
|
222
225
|
- Uninstalling the plugin does not automatically delete saved chat or memory data.
|
|
223
226
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
<p>
|
|
8
8
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a>
|
|
9
|
-
<img src="https://img.shields.io/badge/DSH-
|
|
9
|
+
<img src="https://img.shields.io/badge/DSH-0.1.1--rc.2-6f42c1.svg" alt="DSH 0.1.1-rc.2">
|
|
10
|
+
<img src="https://img.shields.io/badge/QQChat-0.2.3-2ea44f.svg" alt="QQChat 0.2.3">
|
|
10
11
|
<img src="https://img.shields.io/badge/TypeScript-5.x-3178c6.svg" alt="TypeScript">
|
|
11
12
|
</p>
|
|
12
13
|
|
|
@@ -18,6 +19,8 @@
|
|
|
18
19
|
|
|
19
20
|
<p><em>dsh-qqchat 是 DeepSeek Harness(DSH)的 QQ Bot 插件,支持 QQ 群聊、私聊、会话隔离、记忆系统和 DSH 原生命令。</em></p>
|
|
20
21
|
|
|
22
|
+
<p>当前版本基于 DSH <code>0.1.1-rc.2</code> 验证,安装插件即可使用,无需修改或重新构建 DSH。</p>
|
|
23
|
+
|
|
21
24
|
> 本项目是一个 Vibe Coding 项目,代码质量和工程完整性可能需要改进。如果你发现问题或有更好的实现建议,欢迎提交 [Issue](https://github.com/Coffeiz/dsh-qqchat/issues) 或 [Pull Request](https://github.com/Coffeiz/dsh-qqchat/pulls)。
|
|
22
25
|
|
|
23
26
|
</div>
|
|
@@ -199,7 +202,7 @@ QQ Session 使用 DSH 原生的 Agent Preset 机制。插件配置中的 preset
|
|
|
199
202
|
## 注意事项
|
|
200
203
|
|
|
201
204
|
- 需要 QQ 官方 Bot,不支持个人 QQ 账号登录。
|
|
202
|
-
- `0.2.
|
|
205
|
+
- `0.2.2` 仍在持续完善,建议先在测试 Bot 和测试群中使用。
|
|
203
206
|
- 富媒体、完整 QQ 表情和部分复杂消息格式可能需要使用兼容模式。
|
|
204
207
|
- 卸载插件不会自动删除已经保存的聊天和记忆数据。
|
|
205
208
|
|
package/client-src/plugin.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { installStyles, setQQRpc } from './shared.cjs'
|
|
1
|
+
import { installQQSettingsIcon, installStyles, setQQRpc } from './shared.cjs'
|
|
2
2
|
import type { Ctx } from './shared.cjs'
|
|
3
3
|
import { QQSettings } from './settings.cjs'
|
|
4
4
|
import { QQSessionUtility, QQTranscriptNode, createQQMessageDefinition } from './workspace.cjs'
|
|
@@ -7,6 +7,7 @@ exports.inject = ['slots', 'connection', 'conversationEvents'] as const
|
|
|
7
7
|
exports.apply = function apply(ctx: Ctx) {
|
|
8
8
|
setQQRpc(ctx.connection.rpc)
|
|
9
9
|
ctx.effect(installStyles, 'dsh-qqchat: client styles')
|
|
10
|
+
ctx.effect(installQQSettingsIcon, 'dsh-qqchat: QQ settings icon')
|
|
10
11
|
ctx.conversationEvents.register(createQQMessageDefinition())
|
|
11
12
|
|
|
12
13
|
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'qqchat', order: 35, label: () => 'QQ Chat', inject: () => ({ rpc: ctx.connection.rpc }) }, QQSettings))
|
package/client-src/shared.cts
CHANGED
|
@@ -55,6 +55,47 @@ export interface SidebarActionProps { wide: boolean; rpc: Rpc; sessions: Session
|
|
|
55
55
|
export const CHANNEL = '/qqchat'
|
|
56
56
|
const STYLE_ID = 'dsh-qqchat/client-ui-v2'
|
|
57
57
|
|
|
58
|
+
const QQ_ICON_PATH = 'M17.5359 12.5144L16.8402 10.7175C16.8408 10.6968 16.8494 10.3429 16.8494 10.1604C16.8494 7.08792 15.448 4.0003 12.0012 4C8.55459 4.0003 7.15292 7.08792 7.15292 10.1604C7.15292 10.3429 7.16151 10.6968 7.16209 10.7175L6.4667 12.5144C6.27608 13.0285 6.08776 13.564 5.94988 14.0232C5.29262 16.2126 5.50559 17.1186 5.66783 17.139C6.01581 17.1823 7.02221 15.4908 7.02221 15.4908C7.02221 16.4704 7.5095 17.7487 8.56405 18.6719C8.16963 18.7976 7.68635 18.9911 7.37564 19.2284C7.09645 19.442 7.13142 19.6594 7.18158 19.7473C7.40258 20.1329 10.9713 19.9935 12.0017 19.8733C13.0319 19.9935 16.6009 20.1329 16.8216 19.7473C16.872 19.6594 16.9067 19.442 16.6275 19.2284C16.3168 18.9911 15.8333 18.7976 15.4386 18.6716C16.4928 17.7487 16.9801 16.4704 16.9801 15.4908C16.9801 15.4908 17.9868 17.1823 18.3348 17.139C18.4967 17.1186 18.7131 16.2108 18.0524 14.0232C17.9125 13.56 17.7265 13.0285 17.5359 12.5144ZM18.5574 20.7407C18.1843 21.3926 17.7237 21.6334 17.1187 21.7981C16.8792 21.8633 16.621 21.9056 16.325 21.936C15.8844 21.9814 15.3392 22.001 14.712 22C13.786 21.9985 12.693 21.9491 12.0017 21.884C11.3103 21.9491 10.2173 21.9985 9.29129 22C8.66414 22.001 8.11889 21.9814 7.67832 21.936C7.38236 21.9056 7.12409 21.8633 6.88467 21.7981C6.27994 21.6335 5.81954 21.393 5.44496 20.7393C5.15165 20.2258 5.07747 19.6406 5.20612 19.0866C4.61376 18.9546 4.20483 18.6045 3.92733 18.1757C3.77911 17.9466 3.68408 17.7127 3.61845 17.4663C3.53001 17.1344 3.49486 16.7666 3.50184 16.3601C3.51532 15.5749 3.68902 14.5984 4.03435 13.4481C4.17427 12.9821 4.3614 12.4396 4.6015 11.7926L5.15467 10.3632C5.1536 10.287 5.15292 10.2154 5.15292 10.1604C5.15292 5.6047 7.58875 2.00038 12.0013 2C16.4138 2.00038 18.8494 5.60454 18.8494 10.1604C18.8494 10.2154 18.8487 10.2869 18.8477 10.3631L19.401 11.7923L19.4112 11.8191C19.636 12.4254 19.8242 12.9722 19.967 13.445C20.3145 14.5956 20.4889 15.5735 20.5018 16.361C20.5085 16.768 20.4728 17.1365 20.3837 17.4689C20.3178 17.7148 20.2228 17.9483 20.0746 18.1768C19.7976 18.6041 19.3905 18.9532 18.7974 19.0862C18.9266 19.6411 18.8523 20.2274 18.5574 20.7407Z'
|
|
59
|
+
|
|
60
|
+
/** Temporarily replaces DSH's fallback settings glyph for the QQ Chat row. */
|
|
61
|
+
export function installQQSettingsIcon(): () => void {
|
|
62
|
+
const originals = new Map<HTMLButtonElement, SVGElement>()
|
|
63
|
+
const apply = () => {
|
|
64
|
+
for (const button of Array.from(document.querySelectorAll('button'))) {
|
|
65
|
+
const label = button.querySelector('span[class$="_navLabel"]')
|
|
66
|
+
if (label?.textContent?.trim() !== 'QQ Chat') continue
|
|
67
|
+
if (button.querySelector('[data-dsh-qqchat-icon]')) continue
|
|
68
|
+
const icon = button.querySelector('svg[class$="_navIcon"]')
|
|
69
|
+
if (!icon) continue
|
|
70
|
+
originals.set(button, icon.cloneNode(true) as SVGElement)
|
|
71
|
+
icon.remove()
|
|
72
|
+
const replacement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
73
|
+
replacement.setAttribute('width', '16')
|
|
74
|
+
replacement.setAttribute('height', '16')
|
|
75
|
+
replacement.setAttribute('viewBox', '0 0 24 24')
|
|
76
|
+
replacement.setAttribute('fill', 'currentColor')
|
|
77
|
+
replacement.setAttribute('aria-hidden', 'true')
|
|
78
|
+
replacement.setAttribute('data-dsh-qqchat-icon', 'true')
|
|
79
|
+
replacement.classList.add('dsh-qqchat-nav-icon')
|
|
80
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
81
|
+
path.setAttribute('d', QQ_ICON_PATH)
|
|
82
|
+
replacement.append(path)
|
|
83
|
+
button.insertBefore(replacement, label)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const observer = new MutationObserver(apply)
|
|
87
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
88
|
+
apply()
|
|
89
|
+
return () => {
|
|
90
|
+
observer.disconnect()
|
|
91
|
+
for (const [button, original] of originals) {
|
|
92
|
+
const replacement = button.querySelector('[data-dsh-qqchat-icon]')
|
|
93
|
+
if (replacement) replacement.replaceWith(original)
|
|
94
|
+
}
|
|
95
|
+
originals.clear()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
58
99
|
const css = `
|
|
59
100
|
.qqs{display:flex;flex-direction:column;gap:18px;color:var(--dsw-alias-label-primary);font:inherit}.qqs *{box-sizing:border-box}
|
|
60
101
|
.qqsHead{display:flex;align-items:center;justify-content:space-between;gap:12px}.qqsBrand{display:flex;align-items:center;gap:10px}.qqsLogo{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);font-weight:700}.qqsTitle{font-size:15px;font-weight:620}.qqMuted{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.55}.qqStatus{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--dsw-alias-label-secondary)}.qqDot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-warn-primary)}.qqDot.online{background:var(--dsw-alias-state-success-primary)}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -127,6 +127,11 @@ session.append('qqchat/message', ...)
|
|
|
127
127
|
- 不会唤醒 Agent;
|
|
128
128
|
- 不会调用 LLM。
|
|
129
129
|
|
|
130
|
+
官方 DSH 当前不会为 out-of-tree 插件事件提供正式注册 API。QQChat 在插件加载时
|
|
131
|
+
按兼容约定把 `qqchat/message` 注册到可达的 DSH `KNOWN_SESSION_EVENT_TYPES` 副本,
|
|
132
|
+
因此正常安装后旧 Session 可以继续恢复;如果某个特殊打包环境无法解析到宿主使用的
|
|
133
|
+
事件表,QQChat 会捕获恢复失败并创建新的 QQ Session,完整 QQ 历史仍保存在 SQLite。
|
|
134
|
+
|
|
130
135
|
插件不再从 SQLite 重放整段历史到 DSH Session。DSH Session 保存它实际经历过的 display/Agent events,完整 QQ 世界历史始终由 SQLite 负责。
|
|
131
136
|
|
|
132
137
|
## Host / Client
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -10,7 +10,32 @@ QQChat 是 TypeScript 插件,Host 和 Client 一起构建。日常开发不需
|
|
|
10
10
|
- 可用的 DSH workspace
|
|
11
11
|
- 需要真实网关验证时,准备 QQ 官方 Bot 凭据;凭据只放在本地 Host 配置中
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
仓库提交 `package-lock.json`,使用 `npm install` 安装依赖并保持发布安装结果一致。
|
|
14
|
+
|
|
15
|
+
## DSH 兼容要求
|
|
16
|
+
|
|
17
|
+
插件以官方发布版 DSH 为运行基线。安装者不需要修改 DSH 源码、锁文件或构建流程;插件自身也不能依赖本地 DSH patch 或未发布的私有接口。
|
|
18
|
+
|
|
19
|
+
验证插件兼容性时,应在干净的官方 DSH 工作区中检查:
|
|
20
|
+
|
|
21
|
+
1. DSH 本地工作区没有插件补丁或未提交修改。
|
|
22
|
+
2. 插件依赖的 DSH 包版本与官方发布版本一致。
|
|
23
|
+
3. 插件 `npm run check` 能独立通过。
|
|
24
|
+
4. 启动 DSH Web 后,插件 Host、Client 和设置页都能正常加载。
|
|
25
|
+
|
|
26
|
+
当前已知的宿主兼容边界见[架构说明](./ARCHITECTURE.md)和[媒体与引用方案](./MEDIA_AND_QUOTES.md):自定义 `qqchat/message` 展示事件不属于官方 DSH 的标准 Agent 事件,插件会在加载时自动注册事件类型;若特殊打包环境无法找到宿主事件表,才会触发新建 Session 的降级,QQ 完整历史仍由自身 SQLite 保存。该兼容层不要求用户修改 DSH。
|
|
27
|
+
|
|
28
|
+
## 当前功能兼容矩阵
|
|
29
|
+
|
|
30
|
+
| 功能 | DSH 原生支持情况 | 插件行为 |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| QQ 文本消息与独立 Session | 无 QQ 协议支持 | 转换为 DSH 标准 user message,并保留 QQ 身份元数据 |
|
|
33
|
+
| 群友静默记录、未 @ 消息展示 | 无 QQ 自定义展示事件 | 使用插件 Client 渲染 `qqchat/message`;不送入模型 |
|
|
34
|
+
| 图片输入 | 取决于当前 DSH 模型路由和附件服务 | 使用 DSH 原生图片块;不支持视觉时使用稳定文本降级和读取工具 |
|
|
35
|
+
| 音频、视频和普通文件 | DSH 不提供统一的 QQ 媒体理解能力 | 保存附件并提供类型提示、文件工具或媒体信息工具 |
|
|
36
|
+
| QQ 私聊流式回复 | DSH 不负责 QQ Gateway 流式协议 | 插件调用 QQ 官方接口;失败时回退普通发送,纯文本兼容模式自动关闭 |
|
|
37
|
+
| QQ 引用、@和气泡 | DSH 不认识 QQ 协议字段 | 插件把引用和 @ 转为可读文本/元数据,并由 Client 做展示 |
|
|
38
|
+
| DSH 原生命令 | DSH 只处理 DSH Web 自己收到的命令 | 插件将 `/qq...` 命令解析后转交或执行,避免与 DSH 原命令冲突 |
|
|
14
39
|
|
|
15
40
|
## 本地安装与检查
|
|
16
41
|
|
package/docs/MEDIA_AND_QUOTES.md
CHANGED
|
@@ -280,20 +280,21 @@ QQ dispatch
|
|
|
280
280
|
|
|
281
281
|
## 模型输入策略
|
|
282
282
|
|
|
283
|
-
###
|
|
283
|
+
### 兼容与降级路径
|
|
284
284
|
|
|
285
|
-
|
|
285
|
+
当前以 DSH 原生图片输入为主,无法使用原生视觉输入时由 DSH 统一降级:
|
|
286
286
|
|
|
287
287
|
- 图片、视频、文件先保存为附件;
|
|
288
|
-
- Agent
|
|
289
|
-
-
|
|
288
|
+
- Agent 文本上下文保留媒体类型、名称和附件 ID;
|
|
289
|
+
- 图片优先作为 DSH `ImageBlock` 输入;纯文本模型收到 DSH 生成的稳定图片占位文本;
|
|
290
|
+
- `qqchat_describe_image` 仅用于模型主动按 attachment ID 重新读取图片;
|
|
290
291
|
- 视频交给 `ffmpeg` 和文件工具;
|
|
291
292
|
- 文本文件可以直接读取内容;
|
|
292
293
|
- 无能力处理的媒体只提供结构化提示。
|
|
293
294
|
|
|
294
|
-
|
|
295
|
+
优点是沿用 DSH 的统一模型能力判断、图片预算和附件处理,不需要插件复制一套模型适配逻辑。
|
|
295
296
|
|
|
296
|
-
###
|
|
297
|
+
### 图片原生输入
|
|
297
298
|
|
|
298
299
|
当当前模型明确声明支持 image modality 时,可以将图片附件转换为 DSH 官方 `ImageAttachmentRef`,构造 `ImageBlock` 发送给视觉模型。
|
|
299
300
|
|
|
@@ -395,7 +396,7 @@ createUserMessage({
|
|
|
395
396
|
})
|
|
396
397
|
```
|
|
397
398
|
|
|
398
|
-
这样图片由 DSH Web
|
|
399
|
+
这样图片由 DSH Web 原生消息渲染,同时可以进入视觉模型。文本模型会由 DSH 统一投影为稳定的图片占位文本;`qqchat_describe_image` 仅作为按 attachment_id 主动重新读取图片的工具,不是正常视觉输入的必经路径。
|
|
399
400
|
|
|
400
401
|
#### 静默消息:最小适配的附件读取 RPC
|
|
401
402
|
|
|
@@ -500,7 +501,7 @@ createUserMessage({
|
|
|
500
501
|
- [x] 当前模型支持图片时,将 QQ 图片保存为 DSH `ImageAttachmentRef`;
|
|
501
502
|
- [x] 在同一条 DSH `user/message` 中加入文本块和 `ImageBlock`;
|
|
502
503
|
- [x] 让 DSH Web 原生显示 Agent 图片,同时让视觉模型直接接收图片;
|
|
503
|
-
- [x]
|
|
504
|
+
- [x] 当前模型不支持图片时,由 DSH 统一投影为稳定图片占位文本;主动重新读取图片仍可使用 `qqchat_describe_image`;
|
|
504
505
|
- [x] 附件服务不可用、图片损坏或图片超限时,保留文本提示/工具 fallback,不让整轮媒体失败;
|
|
505
506
|
- [x] 保证所有 `ImageBlock` 在 Session event 发布前已经持久提交;
|
|
506
507
|
- [x] 保证 Session event 中不存在 Buffer、Blob、本地路径、SDK 原始对象或循环引用。
|
package/lib/client.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.inject = ['slots', 'connection', 'conversationEvents'];
|
|
|
10
10
|
exports.apply = function apply(ctx) {
|
|
11
11
|
(0, shared_cjs_1.setQQRpc)(ctx.connection.rpc);
|
|
12
12
|
ctx.effect(shared_cjs_1.installStyles, 'dsh-qqchat: client styles');
|
|
13
|
+
ctx.effect(shared_cjs_1.installQQSettingsIcon, 'dsh-qqchat: QQ settings icon');
|
|
13
14
|
ctx.conversationEvents.register((0, workspace_cjs_1.createQQMessageDefinition)());
|
|
14
15
|
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'qqchat', order: 35, label: () => 'QQ Chat', inject: () => ({ rpc: ctx.connection.rpc }) }, settings_cjs_1.QQSettings));
|
|
15
16
|
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'qqchat-message' }, workspace_cjs_1.QQTranscriptNode));
|
|
@@ -249,6 +250,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
249
250
|
exports.dateTime = exports.time = exports.initials = exports.short = exports.CHANNEL = exports.h = exports.useSyncExternalStore = exports.useState = exports.useMemo = exports.useEffect = exports.useCallback = exports.Menu = exports.Input = exports.Button = exports.React = void 0;
|
|
250
251
|
exports.setQQRpc = setQQRpc;
|
|
251
252
|
exports.getQQRpc = getQQRpc;
|
|
253
|
+
exports.installQQSettingsIcon = installQQSettingsIcon;
|
|
252
254
|
exports.installStyles = installStyles;
|
|
253
255
|
exports.call = call;
|
|
254
256
|
exports.Modal = Modal;
|
|
@@ -265,6 +267,49 @@ function setQQRpc(rpc) { qqRpc = rpc; }
|
|
|
265
267
|
function getQQRpc() { return qqRpc; }
|
|
266
268
|
exports.CHANNEL = '/qqchat';
|
|
267
269
|
const STYLE_ID = 'dsh-qqchat/client-ui-v2';
|
|
270
|
+
const QQ_ICON_PATH = 'M17.5359 12.5144L16.8402 10.7175C16.8408 10.6968 16.8494 10.3429 16.8494 10.1604C16.8494 7.08792 15.448 4.0003 12.0012 4C8.55459 4.0003 7.15292 7.08792 7.15292 10.1604C7.15292 10.3429 7.16151 10.6968 7.16209 10.7175L6.4667 12.5144C6.27608 13.0285 6.08776 13.564 5.94988 14.0232C5.29262 16.2126 5.50559 17.1186 5.66783 17.139C6.01581 17.1823 7.02221 15.4908 7.02221 15.4908C7.02221 16.4704 7.5095 17.7487 8.56405 18.6719C8.16963 18.7976 7.68635 18.9911 7.37564 19.2284C7.09645 19.442 7.13142 19.6594 7.18158 19.7473C7.40258 20.1329 10.9713 19.9935 12.0017 19.8733C13.0319 19.9935 16.6009 20.1329 16.8216 19.7473C16.872 19.6594 16.9067 19.442 16.6275 19.2284C16.3168 18.9911 15.8333 18.7976 15.4386 18.6716C16.4928 17.7487 16.9801 16.4704 16.9801 15.4908C16.9801 15.4908 17.9868 17.1823 18.3348 17.139C18.4967 17.1186 18.7131 16.2108 18.0524 14.0232C17.9125 13.56 17.7265 13.0285 17.5359 12.5144ZM18.5574 20.7407C18.1843 21.3926 17.7237 21.6334 17.1187 21.7981C16.8792 21.8633 16.621 21.9056 16.325 21.936C15.8844 21.9814 15.3392 22.001 14.712 22C13.786 21.9985 12.693 21.9491 12.0017 21.884C11.3103 21.9491 10.2173 21.9985 9.29129 22C8.66414 22.001 8.11889 21.9814 7.67832 21.936C7.38236 21.9056 7.12409 21.8633 6.88467 21.7981C6.27994 21.6335 5.81954 21.393 5.44496 20.7393C5.15165 20.2258 5.07747 19.6406 5.20612 19.0866C4.61376 18.9546 4.20483 18.6045 3.92733 18.1757C3.77911 17.9466 3.68408 17.7127 3.61845 17.4663C3.53001 17.1344 3.49486 16.7666 3.50184 16.3601C3.51532 15.5749 3.68902 14.5984 4.03435 13.4481C4.17427 12.9821 4.3614 12.4396 4.6015 11.7926L5.15467 10.3632C5.1536 10.287 5.15292 10.2154 5.15292 10.1604C5.15292 5.6047 7.58875 2.00038 12.0013 2C16.4138 2.00038 18.8494 5.60454 18.8494 10.1604C18.8494 10.2154 18.8487 10.2869 18.8477 10.3631L19.401 11.7923L19.4112 11.8191C19.636 12.4254 19.8242 12.9722 19.967 13.445C20.3145 14.5956 20.4889 15.5735 20.5018 16.361C20.5085 16.768 20.4728 17.1365 20.3837 17.4689C20.3178 17.7148 20.2228 17.9483 20.0746 18.1768C19.7976 18.6041 19.3905 18.9532 18.7974 19.0862C18.9266 19.6411 18.8523 20.2274 18.5574 20.7407Z';
|
|
271
|
+
/** Temporarily replaces DSH's fallback settings glyph for the QQ Chat row. */
|
|
272
|
+
function installQQSettingsIcon() {
|
|
273
|
+
const originals = new Map();
|
|
274
|
+
const apply = () => {
|
|
275
|
+
for (const button of Array.from(document.querySelectorAll('button'))) {
|
|
276
|
+
const label = button.querySelector('span[class$="_navLabel"]');
|
|
277
|
+
if (label?.textContent?.trim() !== 'QQ Chat')
|
|
278
|
+
continue;
|
|
279
|
+
if (button.querySelector('[data-dsh-qqchat-icon]'))
|
|
280
|
+
continue;
|
|
281
|
+
const icon = button.querySelector('svg[class$="_navIcon"]');
|
|
282
|
+
if (!icon)
|
|
283
|
+
continue;
|
|
284
|
+
originals.set(button, icon.cloneNode(true));
|
|
285
|
+
icon.remove();
|
|
286
|
+
const replacement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
287
|
+
replacement.setAttribute('width', '16');
|
|
288
|
+
replacement.setAttribute('height', '16');
|
|
289
|
+
replacement.setAttribute('viewBox', '0 0 24 24');
|
|
290
|
+
replacement.setAttribute('fill', 'currentColor');
|
|
291
|
+
replacement.setAttribute('aria-hidden', 'true');
|
|
292
|
+
replacement.setAttribute('data-dsh-qqchat-icon', 'true');
|
|
293
|
+
replacement.classList.add('dsh-qqchat-nav-icon');
|
|
294
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
295
|
+
path.setAttribute('d', QQ_ICON_PATH);
|
|
296
|
+
replacement.append(path);
|
|
297
|
+
button.insertBefore(replacement, label);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
const observer = new MutationObserver(apply);
|
|
301
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
302
|
+
apply();
|
|
303
|
+
return () => {
|
|
304
|
+
observer.disconnect();
|
|
305
|
+
for (const [button, original] of originals) {
|
|
306
|
+
const replacement = button.querySelector('[data-dsh-qqchat-icon]');
|
|
307
|
+
if (replacement)
|
|
308
|
+
replacement.replaceWith(original);
|
|
309
|
+
}
|
|
310
|
+
originals.clear();
|
|
311
|
+
};
|
|
312
|
+
}
|
|
268
313
|
const css = `
|
|
269
314
|
.qqs{display:flex;flex-direction:column;gap:18px;color:var(--dsw-alias-label-primary);font:inherit}.qqs *{box-sizing:border-box}
|
|
270
315
|
.qqsHead{display:flex;align-items:center;justify-content:space-between;gap:12px}.qqsBrand{display:flex;align-items:center;gap:10px}.qqsLogo{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);font-weight:700}.qqsTitle{font-size:15px;font-weight:620}.qqMuted{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.55}.qqStatus{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--dsw-alias-label-secondary)}.qqDot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-warn-primary)}.qqDot.online{background:var(--dsw-alias-state-success-primary)}
|
package/lib/index.js
CHANGED
|
@@ -10,6 +10,8 @@ import { QQApiClient } from './gateway/api.js';
|
|
|
10
10
|
import { QQBindService } from './gateway/auth.js';
|
|
11
11
|
import { createQQChatRpc } from './transport/rpc.js';
|
|
12
12
|
import { QQChatRuntime } from './session/runtime.js';
|
|
13
|
+
import { registerQQChatSessionEventType } from './session/registration.js';
|
|
14
|
+
const qqChatSessionEventRegistrations = await registerQQChatSessionEventType();
|
|
13
15
|
export const name = 'dsh-qqchat';
|
|
14
16
|
export const inject = ['connection', 'agents', 'agentDefaultModel', 'commands', 'llm', 'tools', 'workspaceRegistry'];
|
|
15
17
|
export function apply(ctx, inputConfig = {}) {
|
|
@@ -18,6 +20,9 @@ export function apply(ctx, inputConfig = {}) {
|
|
|
18
20
|
const db = new QQChatDatabase(join(config.dataDir, 'qqchat.sqlite'));
|
|
19
21
|
const baseLogger = ctx.logger || console;
|
|
20
22
|
const logger = new QQChatLogger(baseLogger);
|
|
23
|
+
if (qqChatSessionEventRegistrations === 0) {
|
|
24
|
+
logger.warn?.('[dsh-qqchat] 未找到可注册的 DSH Session 事件表,旧 QQChat Session 恢复可能需要新建 Session');
|
|
25
|
+
}
|
|
21
26
|
const api = new QQApiClient(db, config);
|
|
22
27
|
const auth = new QQBindService(db, config);
|
|
23
28
|
const memory = new MemoryEngine(ctx, db, config, logger);
|
|
@@ -26,7 +31,10 @@ export function apply(ctx, inputConfig = {}) {
|
|
|
26
31
|
const runtime = new QQChatRuntime(ctx, db, api, auth, memory, bridge, config, logger, media);
|
|
27
32
|
ctx.connection.rpc.handle('/qqchat', createQQChatRpc(runtime), { authority: 'loopback' });
|
|
28
33
|
ctx.effect(() => {
|
|
29
|
-
void
|
|
34
|
+
void (async () => {
|
|
35
|
+
await bridge.ensureWorkspaceMembership();
|
|
36
|
+
await runtime.start();
|
|
37
|
+
})().catch(error => logger.error?.(error));
|
|
30
38
|
return () => runtime.stop();
|
|
31
39
|
}, 'dsh-qqchat runtime');
|
|
32
40
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
2
|
import type { QQChatDatabase } from '../storage/db.js';
|
|
3
|
-
/** Exposes stored QQ images
|
|
3
|
+
/** Exposes stored QQ images by attachment ID when a model explicitly requests one. */
|
|
4
4
|
export declare function registerQQImageTool(ctx: Context, db: QQChatDatabase, isAllowed?: (agentId: string, attachmentId: string) => boolean): () => void;
|
package/lib/media/image-tool.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
-
/** Exposes stored QQ images
|
|
2
|
+
/** Exposes stored QQ images by attachment ID when a model explicitly requests one. */
|
|
3
3
|
export function registerQQImageTool(ctx, db, isAllowed = () => true) {
|
|
4
4
|
const definition = defineTool({
|
|
5
5
|
name: 'qqchat_describe_image',
|
|
6
|
-
description: '
|
|
6
|
+
description: '按 attachment_id 主动重新读取 QQ 消息中的图片。正常图片消息由 DSH 原生视觉输入链路直接处理;本工具用于模型需要再次读取指定图片的场景。',
|
|
7
7
|
parameters: {
|
|
8
8
|
attachment_id: { type: 'string', required: true, description: 'QQChat 图片附件 ID' },
|
|
9
9
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm';
|
|
2
3
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
|
|
3
4
|
import '../shared/augmentations.js';
|
|
4
5
|
import type { QQChatDatabase } from '../storage/db.js';
|
|
@@ -21,6 +22,7 @@ export declare class DshQQBridge {
|
|
|
21
22
|
private readonly activeAttachments;
|
|
22
23
|
private readonly activeMediaReadable;
|
|
23
24
|
private readonly memorySnapshots;
|
|
25
|
+
private qqWorkspace?;
|
|
24
26
|
private readonly disposeEvent;
|
|
25
27
|
private readonly disposeToolGate;
|
|
26
28
|
private readonly disposeCommands;
|
|
@@ -29,12 +31,15 @@ export declare class DshQQBridge {
|
|
|
29
31
|
constructor(ctx: Context, db: QQChatDatabase, memory: MemoryEngine, config: QQChatConfig, logger?: LoggerLike);
|
|
30
32
|
dispose(): Promise<void>;
|
|
31
33
|
ensureChatSession(chatType: ChatType, row: GroupRow | MemberRow): Promise<string>;
|
|
34
|
+
ensureWorkspaceMembership(): Promise<void>;
|
|
32
35
|
recordTranscript(event: QQChatDisplayEvent, row: GroupRow | MemberRow, createSession?: boolean): Promise<string | undefined>;
|
|
33
36
|
reply(message: QQNormalizedMessage, group: GroupRow | undefined, member: MemberRow, attachments?: StoredAttachmentSummary[], onTextDelta?: (delta: string) => void, isOwner?: boolean): Promise<string>;
|
|
34
37
|
private onSessionEvent;
|
|
35
38
|
private injectMemorySnapshot;
|
|
36
39
|
private restoreMemorySnapshot;
|
|
37
40
|
private ensureAgent;
|
|
41
|
+
/** Keep all QQ sessions together in one DSH Workspace using the host API. */
|
|
42
|
+
private attachToQQWorkspace;
|
|
38
43
|
private isArchived;
|
|
39
44
|
private rememberRoute;
|
|
40
45
|
private appendDisplayIfMissing;
|
|
@@ -42,7 +47,6 @@ export declare class DshQQBridge {
|
|
|
42
47
|
private ensureTitle;
|
|
43
48
|
private tryResume;
|
|
44
49
|
private agentOptions;
|
|
45
|
-
private imageRouteAvailable;
|
|
46
50
|
private ensureSelection;
|
|
47
51
|
private selection;
|
|
48
52
|
private requestReset;
|
|
@@ -56,3 +60,5 @@ export declare class DshQQBridge {
|
|
|
56
60
|
private persistedPreset;
|
|
57
61
|
private serial;
|
|
58
62
|
}
|
|
63
|
+
/** Build the durable DSH content for one QQ turn without pre-flighting the model. */
|
|
64
|
+
export declare function qqMessageContent(text: string, attachments: readonly StoredAttachmentSummary[]): ContentBlock[];
|
|
@@ -33,6 +33,7 @@ export class DshQQBridge {
|
|
|
33
33
|
activeAttachments = new Map();
|
|
34
34
|
activeMediaReadable = new Map();
|
|
35
35
|
memorySnapshots = new Map();
|
|
36
|
+
qqWorkspace;
|
|
36
37
|
disposeEvent;
|
|
37
38
|
disposeToolGate;
|
|
38
39
|
disposeCommands;
|
|
@@ -97,6 +98,16 @@ export class DshQQBridge {
|
|
|
97
98
|
const { sessionId } = await this.ensureAgent(chatType, row);
|
|
98
99
|
return sessionId;
|
|
99
100
|
}
|
|
101
|
+
async ensureWorkspaceMembership() {
|
|
102
|
+
const sessionIds = [
|
|
103
|
+
...this.db.listGroups().map(row => row.dsh_session_id),
|
|
104
|
+
...this.db.listDirectChats().map(row => row.dsh_session_id),
|
|
105
|
+
];
|
|
106
|
+
for (const sessionId of sessionIds) {
|
|
107
|
+
if (sessionId)
|
|
108
|
+
await this.attachToQQWorkspace(sessionId);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
100
111
|
async recordTranscript(event, row, createSession = false) {
|
|
101
112
|
const existing = this.db.getChatSession(event.chatType, Number(row.id));
|
|
102
113
|
if (!existing && !createSession)
|
|
@@ -104,11 +115,12 @@ export class DshQQBridge {
|
|
|
104
115
|
const { agent, sessionId } = await this.ensureAgent(event.chatType, row);
|
|
105
116
|
event.sessionId = sessionId;
|
|
106
117
|
if (event.isOwner) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
// Keep owner messages as native user/message events even when they carry
|
|
119
|
+
// images. DSH now owns the image projection: visual routes receive the
|
|
120
|
+
// durable image block, while text-only routes receive its stable text
|
|
121
|
+
// placeholder. Falling back to a display-only event here would discard
|
|
122
|
+
// the owner's message semantics before DSH can apply that policy.
|
|
123
|
+
this.appendOwnerMessageIfMissing(agent.session, event);
|
|
112
124
|
return sessionId;
|
|
113
125
|
}
|
|
114
126
|
this.appendDisplayIfMissing(agent.session, event);
|
|
@@ -157,15 +169,7 @@ export class DshQQBridge {
|
|
|
157
169
|
: this.memory.contextForMember(member);
|
|
158
170
|
this.injectMemorySnapshot(agent, sessionId, contextText);
|
|
159
171
|
}
|
|
160
|
-
const
|
|
161
|
-
const currentContent = [{ type: 'text', text: currentText }];
|
|
162
|
-
if (attachments.length && await this.imageRouteAvailable(agent)) {
|
|
163
|
-
for (const attachment of attachments) {
|
|
164
|
-
if (attachment.kind === 'image' && attachment.imageRef) {
|
|
165
|
-
currentContent.push({ type: 'image', attachment: attachment.imageRef });
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
172
|
+
const currentContent = qqMessageContent(this.memory.currentMessageText(message), attachments);
|
|
169
173
|
const current = createUserMessage({
|
|
170
174
|
source: {
|
|
171
175
|
kind: 'qq-chat', botId: String(message.accountId), chatType: message.chatType,
|
|
@@ -281,6 +285,7 @@ export class DshQQBridge {
|
|
|
281
285
|
this.ensureSelection(live);
|
|
282
286
|
this.ensureTitle(live.session, chatType, row);
|
|
283
287
|
this.rememberRoute(chatType, row, live, sessionId);
|
|
288
|
+
await this.attachToQQWorkspace(sessionId);
|
|
284
289
|
return { agent: live, sessionId };
|
|
285
290
|
}
|
|
286
291
|
const resumed = await this.tryResume(sessionId);
|
|
@@ -288,6 +293,7 @@ export class DshQQBridge {
|
|
|
288
293
|
this.ensureSelection(resumed.agent);
|
|
289
294
|
this.ensureTitle(resumed.agent.session, chatType, row);
|
|
290
295
|
this.rememberRoute(chatType, row, resumed.agent, sessionId);
|
|
296
|
+
await this.attachToQQWorkspace(sessionId);
|
|
291
297
|
return { agent: resumed.agent, sessionId };
|
|
292
298
|
}
|
|
293
299
|
}
|
|
@@ -306,8 +312,30 @@ export class DshQQBridge {
|
|
|
306
312
|
this.db.setChatSession(chatType, Number(row.id), sessionId);
|
|
307
313
|
this.ensureTitle(handle.agent.session, chatType, row);
|
|
308
314
|
this.rememberRoute(chatType, row, handle.agent, sessionId);
|
|
315
|
+
await this.attachToQQWorkspace(sessionId);
|
|
309
316
|
return { agent: handle.agent, sessionId };
|
|
310
317
|
}
|
|
318
|
+
/** Keep all QQ sessions together in one DSH Workspace using the host API. */
|
|
319
|
+
async attachToQQWorkspace(sessionId) {
|
|
320
|
+
const registry = this.ctx.workspaceRegistry;
|
|
321
|
+
if (!registry)
|
|
322
|
+
return;
|
|
323
|
+
if (!this.qqWorkspace) {
|
|
324
|
+
this.qqWorkspace = registry.create(process.cwd(), 'QQ Chat').catch(error => {
|
|
325
|
+
this.logger.warn?.(`[dsh-qqchat] QQ Workspace 创建失败,将保留在未分组: ${error instanceof Error ? error.message : String(error)}`);
|
|
326
|
+
return undefined;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
const workspace = await this.qqWorkspace;
|
|
330
|
+
if (!workspace)
|
|
331
|
+
return;
|
|
332
|
+
try {
|
|
333
|
+
await workspace.attachSession(SessionId(sessionId));
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
this.logger.warn?.(`[dsh-qqchat] QQ session ${sessionId} 加入 Workspace 失败,将保留在未分组: ${error instanceof Error ? error.message : String(error)}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
311
339
|
isArchived(sessionId) {
|
|
312
340
|
const registry = this.ctx.workspaceRegistry;
|
|
313
341
|
return registry?.archivedSessionIds.includes(sessionId) ?? false;
|
|
@@ -325,7 +353,11 @@ export class DshQQBridge {
|
|
|
325
353
|
appendDisplayIfMissing(session, event) {
|
|
326
354
|
if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId))
|
|
327
355
|
return;
|
|
328
|
-
|
|
356
|
+
// Keep this on the public Session.append signature. Official DSH releases
|
|
357
|
+
// do not yet expose the optional ignorable envelope marker; if an older
|
|
358
|
+
// DSH cannot restore this plugin-only event, ensureAgent falls back to a
|
|
359
|
+
// fresh Session while SQLite remains the QQ history source of truth.
|
|
360
|
+
session.append('qqchat/message', displayEventPayload(event));
|
|
329
361
|
}
|
|
330
362
|
appendOwnerMessageIfMissing(session, event) {
|
|
331
363
|
if (session.events.some(item => {
|
|
@@ -387,18 +419,6 @@ export class DshQQBridge {
|
|
|
387
419
|
options.maxTokens = this.config.maxTokens;
|
|
388
420
|
return options;
|
|
389
421
|
}
|
|
390
|
-
async imageRouteAvailable(agent) {
|
|
391
|
-
const route = this.selection(agent)?.current || { provider: agent.options.provider, model: agent.options.model };
|
|
392
|
-
if (!route.provider || !route.model)
|
|
393
|
-
return false;
|
|
394
|
-
try {
|
|
395
|
-
const info = await this.ctx.llm.resolveModelInfo(route.provider, route.model);
|
|
396
|
-
return info.inputModalities?.includes('image') === true;
|
|
397
|
-
}
|
|
398
|
-
catch {
|
|
399
|
-
return false;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
422
|
ensureSelection(agent) {
|
|
403
423
|
const id = String(agent.id);
|
|
404
424
|
const existing = this.selections.get(id);
|
|
@@ -593,7 +613,17 @@ function mediaPrompt(attachments) {
|
|
|
593
613
|
if (!attachments.length)
|
|
594
614
|
return '';
|
|
595
615
|
const lines = attachments.map(item => `- ${item.kind}: ${item.filename} (attachment_id=${item.id}${item.quoted ? ', 引用消息附件' : ''})`);
|
|
596
|
-
return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n
|
|
616
|
+
return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片由 DSH 根据当前模型能力处理;视觉模型可直接理解图片。`;
|
|
617
|
+
}
|
|
618
|
+
/** Build the durable DSH content for one QQ turn without pre-flighting the model. */
|
|
619
|
+
export function qqMessageContent(text, attachments) {
|
|
620
|
+
const content = [{ type: 'text', text: text + mediaPrompt([...attachments]) }];
|
|
621
|
+
for (const attachment of attachments) {
|
|
622
|
+
if (attachment.kind === 'image' && attachment.imageRef) {
|
|
623
|
+
content.push({ type: 'image', attachment: attachment.imageRef });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return content;
|
|
597
627
|
}
|
|
598
628
|
function transcriptContent(event, db) {
|
|
599
629
|
const attachments = event.attachments || [];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface SessionEventRegistrationOptions {
|
|
2
|
+
readonly argv?: readonly string[];
|
|
3
|
+
readonly dshHome?: string;
|
|
4
|
+
readonly anchors?: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function profileNameFromArgv(argv: readonly string[]): string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Register the plugin's log-only event in every reachable dsh-session copy.
|
|
9
|
+
*
|
|
10
|
+
* DSH does not expose a public downstream event registration API yet. This
|
|
11
|
+
* small compatibility shim follows the ecosystem convention used by other
|
|
12
|
+
* DSH plugins and is intentionally idempotent and best-effort.
|
|
13
|
+
*/
|
|
14
|
+
export declare function registerQQChatSessionEventType(options?: SessionEventRegistrationOptions): Promise<number>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
const QQCHAT_SESSION_EVENT_TYPE = 'qqchat/message';
|
|
7
|
+
export function profileNameFromArgv(argv) {
|
|
8
|
+
const profileIndex = argv.indexOf('--profile');
|
|
9
|
+
if (profileIndex >= 0) {
|
|
10
|
+
const value = argv[profileIndex + 1];
|
|
11
|
+
if (value && !value.startsWith('-'))
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
const inline = argv.find(value => value.startsWith('--profile='));
|
|
15
|
+
const value = inline?.slice('--profile='.length);
|
|
16
|
+
return value && !value.startsWith('-') ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Register the plugin's log-only event in every reachable dsh-session copy.
|
|
20
|
+
*
|
|
21
|
+
* DSH does not expose a public downstream event registration API yet. This
|
|
22
|
+
* small compatibility shim follows the ecosystem convention used by other
|
|
23
|
+
* DSH plugins and is intentionally idempotent and best-effort.
|
|
24
|
+
*/
|
|
25
|
+
export async function registerQQChatSessionEventType(options = {}) {
|
|
26
|
+
const argv = options.argv || process.argv;
|
|
27
|
+
const profileName = profileNameFromArgv(argv);
|
|
28
|
+
const dshHome = options.dshHome || process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
29
|
+
const profileAnchors = profileName
|
|
30
|
+
? [
|
|
31
|
+
join(dshHome, 'profiles', profileName, 'package.json'),
|
|
32
|
+
join(dshHome, 'profiles', 'package.json'),
|
|
33
|
+
]
|
|
34
|
+
: [];
|
|
35
|
+
const anchors = [...(options.anchors || [import.meta.url, argv[1]]), ...profileAnchors].filter((anchor) => typeof anchor === 'string' && anchor.length > 0);
|
|
36
|
+
const registeredPaths = new Set();
|
|
37
|
+
let registered = 0;
|
|
38
|
+
for (const anchor of anchors) {
|
|
39
|
+
try {
|
|
40
|
+
const require = createRequire(anchor);
|
|
41
|
+
registered += await registerSessionCopy(require, registeredPaths);
|
|
42
|
+
// Persistence may resolve its own peer dependency from a different
|
|
43
|
+
// package tree. Register the copy selected by that validator too.
|
|
44
|
+
try {
|
|
45
|
+
const persistence = require.resolve('@deepseek-ai/dsh-session-persistence');
|
|
46
|
+
registered += await registerSessionCopy(createRequire(persistence), registeredPaths);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Some minimal profiles do not ship the persistence package.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// A profile may not expose this package from one of the anchors. The
|
|
54
|
+
// other anchor can still reach the validator used by the running host.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return registered;
|
|
58
|
+
}
|
|
59
|
+
async function registerSessionCopy(require, registeredPaths) {
|
|
60
|
+
const resolved = require.resolve('@deepseek-ai/dsh-session');
|
|
61
|
+
const candidates = [resolved];
|
|
62
|
+
// The DSH development CLI can resolve workspace source through tsx while
|
|
63
|
+
// the published package resolves lib/. Register both when source exists.
|
|
64
|
+
const packageRoot = dirname(dirname(resolved));
|
|
65
|
+
candidates.push(join(packageRoot, 'src', 'index.ts'));
|
|
66
|
+
candidates.push(join(packageRoot, 'src', 'index.js'));
|
|
67
|
+
let registered = 0;
|
|
68
|
+
for (const candidate of candidates) {
|
|
69
|
+
let physical;
|
|
70
|
+
try {
|
|
71
|
+
physical = realpathSync(candidate);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (registeredPaths.has(physical))
|
|
77
|
+
continue;
|
|
78
|
+
registeredPaths.add(physical);
|
|
79
|
+
try {
|
|
80
|
+
// DSH publishes ESM builds. Dynamic import also handles a CJS build and
|
|
81
|
+
// preserves the actual module instance selected by the host anchor.
|
|
82
|
+
const module = await import(pathToFileURL(physical).href);
|
|
83
|
+
if (module.KNOWN_SESSION_EVENT_TYPES?.add) {
|
|
84
|
+
module.KNOWN_SESSION_EVENT_TYPES.add(QQCHAT_SESSION_EVENT_TYPE);
|
|
85
|
+
registered += 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// A published package may not ship its source entry; the lib entry above
|
|
90
|
+
// remains the normal registration target.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return registered;
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-qqchat",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "DeepSeek Harness 的 QQ 私聊/群聊插件,支持扫码授权、原生 Session、群记忆与工具权限。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,17 +46,18 @@
|
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
49
|
-
"@deepseek-ai/dsh-
|
|
50
|
-
"@deepseek-ai/dsh-agent
|
|
51
|
-
"@deepseek-ai/dsh-
|
|
52
|
-
"@deepseek-ai/dsh-
|
|
53
|
-
"@deepseek-ai/dsh-client-
|
|
54
|
-
"@deepseek-ai/dsh-client-
|
|
55
|
-
"@deepseek-ai/dsh-client-ui-
|
|
56
|
-
"@deepseek-ai/dsh-client-ui-
|
|
57
|
-
"@deepseek-ai/dsh-
|
|
58
|
-
"@deepseek-ai/dsh-
|
|
59
|
-
"@deepseek-ai/dsh-
|
|
49
|
+
"@deepseek-ai/dsh-attachment": "^0.1.1-rc.2",
|
|
50
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
|
|
51
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.1-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-session": "^0.1.1-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
|
|
60
61
|
},
|
|
61
62
|
"peerDependenciesMeta": {
|
|
62
63
|
"@deepseek-ai/dsh-client-connection": {
|
|
@@ -73,10 +74,11 @@
|
|
|
73
74
|
}
|
|
74
75
|
},
|
|
75
76
|
"devDependencies": {
|
|
76
|
-
"@deepseek-ai/dsh-
|
|
77
|
-
"@deepseek-ai/dsh-agent-
|
|
78
|
-
"@deepseek-ai/dsh-
|
|
79
|
-
"@deepseek-ai/dsh-
|
|
77
|
+
"@deepseek-ai/dsh-attachment": "^0.1.1-rc.2",
|
|
78
|
+
"@deepseek-ai/dsh-agent-default-model": "^0.1.1-rc.2",
|
|
79
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.1-rc.2",
|
|
80
|
+
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
81
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
|
|
80
82
|
"@types/node": "^22.18.0",
|
|
81
83
|
"@types/qrcode": "^1.5.5",
|
|
82
84
|
"@types/react": "^19.1.0",
|
package/src/index.ts
CHANGED
|
@@ -14,8 +14,11 @@ import { QQApiClient } from './gateway/api.js'
|
|
|
14
14
|
import { QQBindService } from './gateway/auth.js'
|
|
15
15
|
import { createQQChatRpc } from './transport/rpc.js'
|
|
16
16
|
import { QQChatRuntime } from './session/runtime.js'
|
|
17
|
+
import { registerQQChatSessionEventType } from './session/registration.js'
|
|
17
18
|
import type { LoggerLike, QQChatConfigInput } from './types.js'
|
|
18
19
|
|
|
20
|
+
const qqChatSessionEventRegistrations = await registerQQChatSessionEventType()
|
|
21
|
+
|
|
19
22
|
export const name = 'dsh-qqchat'
|
|
20
23
|
export const inject = ['connection', 'agents', 'agentDefaultModel', 'commands', 'llm', 'tools', 'workspaceRegistry'] as const
|
|
21
24
|
|
|
@@ -25,6 +28,9 @@ export function apply(ctx: Context, inputConfig: QQChatConfigInput = {}): void {
|
|
|
25
28
|
const db = new QQChatDatabase(join(config.dataDir, 'qqchat.sqlite'))
|
|
26
29
|
const baseLogger = (ctx as unknown as { logger?: LoggerLike }).logger || console
|
|
27
30
|
const logger = new QQChatLogger(baseLogger)
|
|
31
|
+
if (qqChatSessionEventRegistrations === 0) {
|
|
32
|
+
logger.warn?.('[dsh-qqchat] 未找到可注册的 DSH Session 事件表,旧 QQChat Session 恢复可能需要新建 Session')
|
|
33
|
+
}
|
|
28
34
|
const api = new QQApiClient(db, config)
|
|
29
35
|
const auth = new QQBindService(db, config)
|
|
30
36
|
const memory = new MemoryEngine(ctx, db, config, logger)
|
|
@@ -34,7 +40,10 @@ export function apply(ctx: Context, inputConfig: QQChatConfigInput = {}): void {
|
|
|
34
40
|
|
|
35
41
|
ctx.connection.rpc.handle('/qqchat', createQQChatRpc(runtime), { authority: 'loopback' })
|
|
36
42
|
ctx.effect(() => {
|
|
37
|
-
void
|
|
43
|
+
void (async () => {
|
|
44
|
+
await bridge.ensureWorkspaceMembership()
|
|
45
|
+
await runtime.start()
|
|
46
|
+
})().catch(error => logger.error?.(error))
|
|
38
47
|
return () => runtime.stop()
|
|
39
48
|
}, 'dsh-qqchat runtime')
|
|
40
49
|
}
|
package/src/media/image-tool.ts
CHANGED
|
@@ -2,11 +2,11 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
2
2
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
3
3
|
import type { QQChatDatabase } from '../storage/db.js'
|
|
4
4
|
|
|
5
|
-
/** Exposes stored QQ images
|
|
5
|
+
/** Exposes stored QQ images by attachment ID when a model explicitly requests one. */
|
|
6
6
|
export function registerQQImageTool(ctx: Context, db: QQChatDatabase, isAllowed: (agentId: string, attachmentId: string) => boolean = () => true): () => void {
|
|
7
7
|
const definition = defineTool({
|
|
8
8
|
name: 'qqchat_describe_image',
|
|
9
|
-
description: '
|
|
9
|
+
description: '按 attachment_id 主动重新读取 QQ 消息中的图片。正常图片消息由 DSH 原生视觉输入链路直接处理;本工具用于模型需要再次读取指定图片的场景。',
|
|
10
10
|
parameters: {
|
|
11
11
|
attachment_id: { type: 'string', required: true, description: 'QQChat 图片附件 ID' },
|
|
12
12
|
},
|
|
@@ -39,7 +39,11 @@ interface AgentPreset { id: string }
|
|
|
39
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
40
|
interface SessionPersistenceService { inspect(id: SessionId): Promise<{ meta: Session['header']; events: readonly SessionEvent[] }> }
|
|
41
41
|
interface SessionTitleService { get(session: Session): unknown; rename(session: Session, title: string): unknown }
|
|
42
|
-
interface
|
|
42
|
+
interface WorkspaceEntity { attachSession(sessionId: SessionId): Promise<void> }
|
|
43
|
+
interface WorkspaceRegistryService {
|
|
44
|
+
archivedSessionIds: readonly string[]
|
|
45
|
+
create(path: string, title?: string): Promise<WorkspaceEntity>
|
|
46
|
+
}
|
|
43
47
|
interface Composition { presetId?: string; setup?: AgentSetup }
|
|
44
48
|
interface ActiveActor { chatType: ChatType; senderId: string }
|
|
45
49
|
|
|
@@ -62,6 +66,7 @@ export class DshQQBridge {
|
|
|
62
66
|
private readonly activeAttachments = new Map<string, Set<string>>()
|
|
63
67
|
private readonly activeMediaReadable = new Map<string, boolean>()
|
|
64
68
|
private readonly memorySnapshots = new Map<string, MemorySnapshotState>()
|
|
69
|
+
private qqWorkspace?: Promise<WorkspaceEntity | undefined>
|
|
65
70
|
private readonly disposeEvent: () => void
|
|
66
71
|
private readonly disposeToolGate: () => void
|
|
67
72
|
private readonly disposeCommands: () => void
|
|
@@ -122,15 +127,28 @@ export class DshQQBridge {
|
|
|
122
127
|
return sessionId
|
|
123
128
|
}
|
|
124
129
|
|
|
130
|
+
async ensureWorkspaceMembership(): Promise<void> {
|
|
131
|
+
const sessionIds = [
|
|
132
|
+
...this.db.listGroups().map(row => row.dsh_session_id),
|
|
133
|
+
...this.db.listDirectChats().map(row => row.dsh_session_id),
|
|
134
|
+
]
|
|
135
|
+
for (const sessionId of sessionIds) {
|
|
136
|
+
if (sessionId) await this.attachToQQWorkspace(sessionId)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
125
140
|
async recordTranscript(event: QQChatDisplayEvent, row: GroupRow | MemberRow, createSession = false): Promise<string | undefined> {
|
|
126
141
|
const existing = this.db.getChatSession(event.chatType, Number(row.id))
|
|
127
142
|
if (!existing && !createSession) return undefined
|
|
128
143
|
const { agent, sessionId } = await this.ensureAgent(event.chatType, row)
|
|
129
144
|
event.sessionId = sessionId
|
|
130
145
|
if (event.isOwner) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
146
|
+
// Keep owner messages as native user/message events even when they carry
|
|
147
|
+
// images. DSH now owns the image projection: visual routes receive the
|
|
148
|
+
// durable image block, while text-only routes receive its stable text
|
|
149
|
+
// placeholder. Falling back to a display-only event here would discard
|
|
150
|
+
// the owner's message semantics before DSH can apply that policy.
|
|
151
|
+
this.appendOwnerMessageIfMissing(agent.session, event)
|
|
134
152
|
return sessionId
|
|
135
153
|
}
|
|
136
154
|
this.appendDisplayIfMissing(agent.session, event)
|
|
@@ -178,15 +196,7 @@ export class DshQQBridge {
|
|
|
178
196
|
this.injectMemorySnapshot(agent, sessionId, contextText)
|
|
179
197
|
}
|
|
180
198
|
|
|
181
|
-
const
|
|
182
|
-
const currentContent: ContentBlock[] = [{ type: 'text', text: currentText }]
|
|
183
|
-
if (attachments.length && await this.imageRouteAvailable(agent)) {
|
|
184
|
-
for (const attachment of attachments) {
|
|
185
|
-
if (attachment.kind === 'image' && attachment.imageRef) {
|
|
186
|
-
currentContent.push({ type: 'image', attachment: attachment.imageRef })
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
199
|
+
const currentContent = qqMessageContent(this.memory.currentMessageText(message), attachments)
|
|
190
200
|
const current = createUserMessage({
|
|
191
201
|
source: {
|
|
192
202
|
kind: 'qq-chat', botId: String(message.accountId), chatType: message.chatType,
|
|
@@ -296,6 +306,7 @@ export class DshQQBridge {
|
|
|
296
306
|
this.ensureSelection(live)
|
|
297
307
|
this.ensureTitle(live.session, chatType, row)
|
|
298
308
|
this.rememberRoute(chatType, row, live, sessionId)
|
|
309
|
+
await this.attachToQQWorkspace(sessionId)
|
|
299
310
|
return { agent: live, sessionId }
|
|
300
311
|
}
|
|
301
312
|
const resumed = await this.tryResume(sessionId)
|
|
@@ -303,6 +314,7 @@ export class DshQQBridge {
|
|
|
303
314
|
this.ensureSelection(resumed.agent)
|
|
304
315
|
this.ensureTitle(resumed.agent.session, chatType, row)
|
|
305
316
|
this.rememberRoute(chatType, row, resumed.agent, sessionId)
|
|
317
|
+
await this.attachToQQWorkspace(sessionId)
|
|
306
318
|
return { agent: resumed.agent, sessionId }
|
|
307
319
|
}
|
|
308
320
|
}
|
|
@@ -322,9 +334,29 @@ export class DshQQBridge {
|
|
|
322
334
|
this.db.setChatSession(chatType, Number(row.id), sessionId)
|
|
323
335
|
this.ensureTitle(handle.agent.session, chatType, row)
|
|
324
336
|
this.rememberRoute(chatType, row, handle.agent, sessionId)
|
|
337
|
+
await this.attachToQQWorkspace(sessionId)
|
|
325
338
|
return { agent: handle.agent, sessionId }
|
|
326
339
|
}
|
|
327
340
|
|
|
341
|
+
/** Keep all QQ sessions together in one DSH Workspace using the host API. */
|
|
342
|
+
private async attachToQQWorkspace(sessionId: string): Promise<void> {
|
|
343
|
+
const registry = (this.ctx as unknown as { workspaceRegistry?: WorkspaceRegistryService }).workspaceRegistry
|
|
344
|
+
if (!registry) return
|
|
345
|
+
if (!this.qqWorkspace) {
|
|
346
|
+
this.qqWorkspace = registry.create(process.cwd(), 'QQ Chat').catch(error => {
|
|
347
|
+
this.logger.warn?.(`[dsh-qqchat] QQ Workspace 创建失败,将保留在未分组: ${error instanceof Error ? error.message : String(error)}`)
|
|
348
|
+
return undefined
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
const workspace = await this.qqWorkspace
|
|
352
|
+
if (!workspace) return
|
|
353
|
+
try {
|
|
354
|
+
await workspace.attachSession(SessionId(sessionId))
|
|
355
|
+
} catch (error) {
|
|
356
|
+
this.logger.warn?.(`[dsh-qqchat] QQ session ${sessionId} 加入 Workspace 失败,将保留在未分组: ${error instanceof Error ? error.message : String(error)}`)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
328
360
|
private isArchived(sessionId: string): boolean {
|
|
329
361
|
const registry = (this.ctx as unknown as { workspaceRegistry?: WorkspaceRegistryService }).workspaceRegistry
|
|
330
362
|
return registry?.archivedSessionIds.includes(sessionId) ?? false
|
|
@@ -340,7 +372,11 @@ export class DshQQBridge {
|
|
|
340
372
|
|
|
341
373
|
private appendDisplayIfMissing(session: Session, event: QQChatDisplayEvent): void {
|
|
342
374
|
if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId)) return
|
|
343
|
-
|
|
375
|
+
// Keep this on the public Session.append signature. Official DSH releases
|
|
376
|
+
// do not yet expose the optional ignorable envelope marker; if an older
|
|
377
|
+
// DSH cannot restore this plugin-only event, ensureAgent falls back to a
|
|
378
|
+
// fresh Session while SQLite remains the QQ history source of truth.
|
|
379
|
+
session.append('qqchat/message', displayEventPayload(event))
|
|
344
380
|
}
|
|
345
381
|
|
|
346
382
|
private appendOwnerMessageIfMissing(session: Session, event: QQChatDisplayEvent): void {
|
|
@@ -401,15 +437,6 @@ export class DshQQBridge {
|
|
|
401
437
|
return options
|
|
402
438
|
}
|
|
403
439
|
|
|
404
|
-
private async imageRouteAvailable(agent: AgentHandle['agent']): Promise<boolean> {
|
|
405
|
-
const route = this.selection(agent)?.current || { provider: agent.options.provider, model: agent.options.model }
|
|
406
|
-
if (!route.provider || !route.model) return false
|
|
407
|
-
try {
|
|
408
|
-
const info = await this.ctx.llm.resolveModelInfo(route.provider, route.model)
|
|
409
|
-
return info.inputModalities?.includes('image') === true
|
|
410
|
-
} catch { return false }
|
|
411
|
-
}
|
|
412
|
-
|
|
413
440
|
private ensureSelection(agent: AgentHandle['agent']): ModelSelectionRef | undefined {
|
|
414
441
|
const id = String(agent.id)
|
|
415
442
|
const existing = this.selections.get(id)
|
|
@@ -598,7 +625,18 @@ export class DshQQBridge {
|
|
|
598
625
|
function mediaPrompt(attachments: StoredAttachmentSummary[]): string {
|
|
599
626
|
if (!attachments.length) return ''
|
|
600
627
|
const lines = attachments.map(item => `- ${item.kind}: ${item.filename} (attachment_id=${item.id}${item.quoted ? ', 引用消息附件' : ''})`)
|
|
601
|
-
return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n
|
|
628
|
+
return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片由 DSH 根据当前模型能力处理;视觉模型可直接理解图片。`
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Build the durable DSH content for one QQ turn without pre-flighting the model. */
|
|
632
|
+
export function qqMessageContent(text: string, attachments: readonly StoredAttachmentSummary[]): ContentBlock[] {
|
|
633
|
+
const content: ContentBlock[] = [{ type: 'text', text: text + mediaPrompt([...attachments]) }]
|
|
634
|
+
for (const attachment of attachments) {
|
|
635
|
+
if (attachment.kind === 'image' && attachment.imageRef) {
|
|
636
|
+
content.push({ type: 'image', attachment: attachment.imageRef })
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return content
|
|
602
640
|
}
|
|
603
641
|
|
|
604
642
|
function transcriptContent(event: QQChatDisplayEvent, db: QQChatDatabase): ContentBlock[] {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { realpathSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const QQCHAT_SESSION_EVENT_TYPE = 'qqchat/message'
|
|
8
|
+
|
|
9
|
+
interface KnownEventTypesModule {
|
|
10
|
+
KNOWN_SESSION_EVENT_TYPES?: { add(type: string): unknown }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SessionEventRegistrationOptions {
|
|
14
|
+
readonly argv?: readonly string[]
|
|
15
|
+
readonly dshHome?: string
|
|
16
|
+
readonly anchors?: readonly string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function profileNameFromArgv(argv: readonly string[]): string | undefined {
|
|
20
|
+
const profileIndex = argv.indexOf('--profile')
|
|
21
|
+
if (profileIndex >= 0) {
|
|
22
|
+
const value = argv[profileIndex + 1]
|
|
23
|
+
if (value && !value.startsWith('-')) return value
|
|
24
|
+
}
|
|
25
|
+
const inline = argv.find(value => value.startsWith('--profile='))
|
|
26
|
+
const value = inline?.slice('--profile='.length)
|
|
27
|
+
return value && !value.startsWith('-') ? value : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register the plugin's log-only event in every reachable dsh-session copy.
|
|
32
|
+
*
|
|
33
|
+
* DSH does not expose a public downstream event registration API yet. This
|
|
34
|
+
* small compatibility shim follows the ecosystem convention used by other
|
|
35
|
+
* DSH plugins and is intentionally idempotent and best-effort.
|
|
36
|
+
*/
|
|
37
|
+
export async function registerQQChatSessionEventType(options: SessionEventRegistrationOptions = {}): Promise<number> {
|
|
38
|
+
const argv = options.argv || process.argv
|
|
39
|
+
const profileName = profileNameFromArgv(argv)
|
|
40
|
+
const dshHome = options.dshHome || process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
41
|
+
const profileAnchors = profileName
|
|
42
|
+
? [
|
|
43
|
+
join(dshHome, 'profiles', profileName, 'package.json'),
|
|
44
|
+
join(dshHome, 'profiles', 'package.json'),
|
|
45
|
+
]
|
|
46
|
+
: []
|
|
47
|
+
const anchors = [...(options.anchors || [import.meta.url, argv[1]]), ...profileAnchors].filter(
|
|
48
|
+
(anchor): anchor is string => typeof anchor === 'string' && anchor.length > 0,
|
|
49
|
+
)
|
|
50
|
+
const registeredPaths = new Set<string>()
|
|
51
|
+
let registered = 0
|
|
52
|
+
|
|
53
|
+
for (const anchor of anchors) {
|
|
54
|
+
try {
|
|
55
|
+
const require = createRequire(anchor)
|
|
56
|
+
registered += await registerSessionCopy(require, registeredPaths)
|
|
57
|
+
// Persistence may resolve its own peer dependency from a different
|
|
58
|
+
// package tree. Register the copy selected by that validator too.
|
|
59
|
+
try {
|
|
60
|
+
const persistence = require.resolve('@deepseek-ai/dsh-session-persistence')
|
|
61
|
+
registered += await registerSessionCopy(createRequire(persistence), registeredPaths)
|
|
62
|
+
} catch {
|
|
63
|
+
// Some minimal profiles do not ship the persistence package.
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// A profile may not expose this package from one of the anchors. The
|
|
67
|
+
// other anchor can still reach the validator used by the running host.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return registered
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function registerSessionCopy(require: NodeRequire, registeredPaths: Set<string>): Promise<number> {
|
|
75
|
+
const resolved = require.resolve('@deepseek-ai/dsh-session')
|
|
76
|
+
const candidates = [resolved]
|
|
77
|
+
// The DSH development CLI can resolve workspace source through tsx while
|
|
78
|
+
// the published package resolves lib/. Register both when source exists.
|
|
79
|
+
const packageRoot = dirname(dirname(resolved))
|
|
80
|
+
candidates.push(join(packageRoot, 'src', 'index.ts'))
|
|
81
|
+
candidates.push(join(packageRoot, 'src', 'index.js'))
|
|
82
|
+
let registered = 0
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
let physical: string
|
|
85
|
+
try { physical = realpathSync(candidate) } catch { continue }
|
|
86
|
+
if (registeredPaths.has(physical)) continue
|
|
87
|
+
registeredPaths.add(physical)
|
|
88
|
+
try {
|
|
89
|
+
// DSH publishes ESM builds. Dynamic import also handles a CJS build and
|
|
90
|
+
// preserves the actual module instance selected by the host anchor.
|
|
91
|
+
const module = await import(pathToFileURL(physical).href) as KnownEventTypesModule
|
|
92
|
+
if (module.KNOWN_SESSION_EVENT_TYPES?.add) {
|
|
93
|
+
module.KNOWN_SESSION_EVENT_TYPES.add(QQCHAT_SESSION_EVENT_TYPE)
|
|
94
|
+
registered += 1
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// A published package may not ship its source entry; the lib entry above
|
|
98
|
+
// remains the normal registration target.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return registered
|
|
102
|
+
}
|