dsh-qq-tools 0.7.0 → 0.8.1
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.md +34 -11
- package/cordis.patch.yml +7 -1
- package/lib/client.js +328 -0
- package/lib/index.js +13 -7
- package/lib/shared.js +348 -55
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
给 DSH(DeepSeek Harness)里的大肥鱼会话用的 QQ 群/私聊工具,走 OneBot 11 HTTP + WebSocket。系统表情在 SnowLuma / NapCat 上都能发;收藏表情用 SnowLuma 的自定义表情扩展接口,和 QQ 客户端里的收藏夹是同一份数据。
|
|
4
4
|
|
|
5
|
-
8 个工具各自是一个独立的 cordis
|
|
5
|
+
8 个工具各自是一个独立的 cordis 组件,外加一个负责选择挂载会话的设置组件,在 DSH 插件页的「包含的组件」里可以逐个开关:
|
|
6
6
|
|
|
7
7
|
| 组件 | 工具 | 说明 |
|
|
8
8
|
| --- | --- | --- |
|
|
@@ -14,15 +14,18 @@
|
|
|
14
14
|
| 收藏表情 | `qq_list_faces` / `qq_favorite_face` | 看/管理 QQ 账号收藏夹(SnowLuma):收藏图片、取消收藏、改备注、移到最前 |
|
|
15
15
|
| 私聊 | `qq_list_friends` / `qq_get_private_history` / `qq_send_private_message` | 好友列表、私聊记录、发私聊消息;同时负责私聊消息唤醒 |
|
|
16
16
|
| 戳一戳 | `qq_send_poke` | 戳人;同时负责接收戳一戳事件 |
|
|
17
|
+
| 挂载会话 | — | 插件页里的「配置」页面:下拉选择 QQ 消息唤醒哪个 DSH 会话 |
|
|
17
18
|
|
|
18
|
-
关掉「发群消息」会同时停掉 @
|
|
19
|
+
关掉「发群消息」会同时停掉 @ 提醒轮询,关掉「私聊」会同时停掉私聊消息唤醒,关掉「戳一戳」会同时停掉戳一戳通知;三个只读工具互不影响。关掉「挂载会话」只会让配置页面不可用,已经写进配置的 `targetSession` 仍然生效。
|
|
19
20
|
|
|
20
21
|
## 目录结构
|
|
21
22
|
|
|
22
23
|
```
|
|
23
|
-
cordis.patch.yml
|
|
24
|
+
cordis.patch.yml 9 个组件行
|
|
24
25
|
lib/shared.js 共享核心:配置、OneBot、挂载、@ 轮询、私聊唤醒、戳一戳、表情、观察窗
|
|
25
|
-
lib/
|
|
26
|
+
lib/client.js 浏览器半侧:插件页里的挂载会话下拉框
|
|
27
|
+
lib/index.js 包根 carrier(承载浏览器半侧和挂载会话接口),也是旧的全量入口
|
|
28
|
+
locale/ 包根行在插件页显示的标题和说明
|
|
26
29
|
tools/qq-list-groups/ 组件入口 + locale
|
|
27
30
|
tools/qq-get-group-history/
|
|
28
31
|
tools/qq-get-group-members/
|
|
@@ -33,7 +36,19 @@ tools/qq-private-chat/
|
|
|
33
36
|
tools/qq-send-poke/
|
|
34
37
|
```
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
「挂载会话」用的是包根行(模块名 `dsh-qq-tools`,所以插件卡片名仍是 `dsh-qq-tools`):DSH 只从包根发现 `dsh.client`,子路径行不会扫描;`config.carrier: true` 让它只承载设置接口,不会重复挂一遍工具。
|
|
40
|
+
|
|
41
|
+
每个 `tools/<组件>/index.js` 只做一件事:把自己的工具挂到目标会话。`locale/{en,zh}.json` 提供插件页显示的标题和说明;左侧栏入口和插件行配置页在 `lib/client.js` 里注册。
|
|
42
|
+
|
|
43
|
+
## 选择挂载会话
|
|
44
|
+
|
|
45
|
+
左侧栏的「挂载会话」入口(也可以在「插件 → dsh-qq-tools → 挂载会话 → 配置」里打开)会列出当前 DSH 里的会话(标题 + 短 ID + 运行状态)。选中后保存,配置写回 `~/.dsh/qq-tools-config.json` 的 `targetSession`,各组件在 3 秒内重新挂到这个会话。
|
|
46
|
+
|
|
47
|
+
- 归档会话和子会话不会出现在列表里;如果当前目标后来被归档,列表会把它标成「当前目标(已归档)」。
|
|
48
|
+
- 工具只注册在选中的会话上,其他会话看不到 QQ 工具,也不会被 QQ 消息唤醒。
|
|
49
|
+
- 选「不挂载」或留空会卸下所有工具并停止唤醒。
|
|
50
|
+
- 保存时不校验会话是否存活:会话暂时关掉不会把配置清掉,等它重新打开后会自动挂上。
|
|
51
|
+
- 这个页面依赖「挂载会话」组件;关掉该组件后接口不可用,但已保存的 `targetSession` 仍然生效,也可以直接改配置文件。
|
|
37
52
|
|
|
38
53
|
## 配置
|
|
39
54
|
|
|
@@ -59,7 +74,7 @@ tools/qq-send-poke/
|
|
|
59
74
|
```
|
|
60
75
|
|
|
61
76
|
- `napCat` 是 OneBot 11 HTTP 地址(SnowLuma 默认 `http://127.0.0.1:3000`,NapCat 也一样);`wsUrl` 是 WebSocket 事件地址(默认 `ws://127.0.0.1:3001`)。
|
|
62
|
-
- `targetSession` 是目标会话 ID
|
|
77
|
+
- `targetSession` 是目标会话 ID;可以在插件页「挂载会话 → 配置」里下拉选择,也可以直接改这个文件;留空时工具不会挂到任何会话。
|
|
63
78
|
- `groups` 是允许看/发的群;空数组表示不限制。
|
|
64
79
|
- `mentionEnabled` / `pokeEnabled` / `privateEnabled` 分别是 @ 轮询、戳一戳事件、私聊唤醒的总闸,对应组件关掉时它们也不会运行。
|
|
65
80
|
- `privateUsers` 是允许唤醒和私聊工具的 QQ 白名单;空数组表示不限制。非空时,不在列表里的私聊消息不会唤醒,`qq_list_friends` 也不会列出他们。
|
|
@@ -84,7 +99,7 @@ tools/qq-send-poke/
|
|
|
84
99
|
|
|
85
100
|
私聊和群聊在程序里是分开的两条路径:群聊靠轮询 @,私聊靠 WebSocket 的 `message_type=private` 事件。私聊没有观察窗,每收到一条私聊消息都会直接唤醒目标会话。
|
|
86
101
|
|
|
87
|
-
- 唤醒提示里会带发送者昵称、QQ 号、`message_id`
|
|
102
|
+
- 唤醒提示里会带发送者昵称、QQ 号、`message_id` 和文字;如果消息里有图片,也会把图片一起给模型看(GIF 会拆成首/中/尾最多 3 帧,和群聊记录的处理一致)。
|
|
88
103
|
- 模型用 `qq_get_private_history` 看和对方的最近记录,用 `qq_send_private_message` 回文字,用 `qq_send_face` 回表情。
|
|
89
104
|
- `qq_list_friends` 列好友,可以按昵称、备注或 QQ 号搜;`privateUsers` 非空时只列白名单里的好友。
|
|
90
105
|
- `qq_get_private_history` 的消息带 `#message_id`,可以传给 `qq_send_private_message` / `qq_send_face` 的 `reply_to` 做引用回复。
|
|
@@ -102,12 +117,20 @@ dsh plugin --profile <profile> add github:cyhddyx/dsh-qq-tools
|
|
|
102
117
|
|
|
103
118
|
本插件是纯 JavaScript,没有构建步骤,所以不需要 `prepare` 脚本,也不需要给 pnpm 加 `allowBuilds` 授权。安装完成后重启 DSH,插件页里应该能看到 8 个组件,每个带开关。
|
|
104
119
|
|
|
105
|
-
### 从 npm
|
|
120
|
+
### 从 npm 安装(推荐)
|
|
121
|
+
|
|
122
|
+
已发布到 npm,装最新版:
|
|
106
123
|
|
|
107
124
|
```sh
|
|
108
125
|
dsh plugin --profile <profile> add dsh-qq-tools
|
|
109
126
|
```
|
|
110
127
|
|
|
128
|
+
需要固定版本时:
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
dsh plugin --profile <profile> add dsh-qq-tools@0.8.1
|
|
132
|
+
```
|
|
133
|
+
|
|
111
134
|
### 本地开发 / 自己改代码
|
|
112
135
|
|
|
113
136
|
```sh
|
|
@@ -118,7 +141,7 @@ dsh plugin --profile <profile> add link:/path/to/dsh-qq-tools
|
|
|
118
141
|
|
|
119
142
|
```sh
|
|
120
143
|
pnpm pack
|
|
121
|
-
dsh plugin --profile <profile> add ./dsh-qq-tools-0.
|
|
144
|
+
dsh plugin --profile <profile> add ./dsh-qq-tools-0.8.1.tgz
|
|
122
145
|
```
|
|
123
146
|
|
|
124
147
|
`dsh plugin` 会在安装后自动把这个包追加进 profile 的 `dsh.profile.bundles`,不用手改 `package.json`。卸载用:
|
|
@@ -132,8 +155,8 @@ dsh plugin --profile <profile> remove dsh-qq-tools
|
|
|
132
155
|
- DSH 0.2.x(cordis 插件体系)
|
|
133
156
|
- Node.js 22+(内置 `fetch` / `WebSocket`)
|
|
134
157
|
- SnowLuma 提供 OneBot 11 HTTP 接口和 WebSocket 事件(收藏表情需要 SnowLuma;系统表情 NapCat 也能发)
|
|
135
|
-
- 可选:`sharp`(GIF
|
|
158
|
+
- 可选:`sharp`(GIF 帧拆分、超过 4MiB 的图片缩放、其他格式转码)。会自动使用插件自己装的、`SHARP_PATH` 指定的,或 DSH 宿主自带的 sharp;都找不到时 GIF 按静图处理,大图直接交给 DSH 归一化(DSH 默认单张源图上限 20MiB)。
|
|
136
159
|
|
|
137
160
|
---
|
|
138
161
|
|
|
139
|
-
版本:0.
|
|
162
|
+
版本:0.8.1
|
package/cordis.patch.yml
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
# DSH 大肥鱼 QQ 群/私聊工具:
|
|
1
|
+
# DSH 大肥鱼 QQ 群/私聊工具:9 个组件,插件页里可以逐个开关
|
|
2
2
|
# · 列出 QQ 群 / 看群聊天记录 / 看群成员 —— 只读工具
|
|
3
3
|
# · 发群消息 —— 同时负责 @ 提醒与群后续轮询
|
|
4
4
|
# · 发表情 —— 在群里或私聊里发系统表情或 QQ 收藏夹里的自定义表情
|
|
5
5
|
# · 收藏表情 —— QQ 收藏夹(SnowLuma):qq_list_faces + qq_favorite_face
|
|
6
6
|
# · 私聊 —— 好友列表 / 私聊记录 / 发私聊消息,同时负责私聊消息唤醒
|
|
7
7
|
# · 戳一戳 —— 同时负责接收戳一戳事件
|
|
8
|
+
# · 挂载会话 —— 插件页里的「配置」页面,选择 QQ 消息唤醒哪个 DSH 会话
|
|
8
9
|
- insert:
|
|
9
10
|
- id: qq-tools-list-groups
|
|
10
11
|
name: 'dsh-qq-tools/tools/qq-list-groups'
|
|
@@ -22,3 +23,8 @@
|
|
|
22
23
|
name: 'dsh-qq-tools/tools/qq-private-chat'
|
|
23
24
|
- id: qq-tools-send-poke
|
|
24
25
|
name: 'dsh-qq-tools/tools/qq-send-poke'
|
|
26
|
+
# 包根行:DSH 只从包根发现 dsh.client,浏览器半侧和挂载会话接口都挂在这里。
|
|
27
|
+
- id: qq-tools-root
|
|
28
|
+
name: 'dsh-qq-tools'
|
|
29
|
+
config:
|
|
30
|
+
carrier: true
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// dsh-qq-tools 的浏览器半侧:左侧栏「挂载会话」入口 + 插件行配置页。
|
|
2
|
+
// 会话列表来自 slot 自带的 useSessions,归档集合来自 useWorkspaces;
|
|
3
|
+
// 读写走宿主半侧的 /api/dsh-qq-tools/target-session,最终仍然落在
|
|
4
|
+
// ~/.dsh/qq-tools-config.json 的 targetSession 字段上。
|
|
5
|
+
window.__ModuleLoader__.load({
|
|
6
|
+
id: 'dsh-qq-tools',
|
|
7
|
+
factory: (require) => {
|
|
8
|
+
var module = { exports: {} }
|
|
9
|
+
var exports = module.exports
|
|
10
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
11
|
+
|
|
12
|
+
const react = require('react')
|
|
13
|
+
const h = react.createElement
|
|
14
|
+
|
|
15
|
+
const NS = 'qq-tools'
|
|
16
|
+
const API = '/api/dsh-qq-tools/target-session'
|
|
17
|
+
const SLOT_KEY = 'dsh-qq-tools#qq-tools-root'
|
|
18
|
+
const PANEL_ID = 'qq-tools-target-session'
|
|
19
|
+
const PANEL_ORDER = 50
|
|
20
|
+
const EMPTY_IDS = []
|
|
21
|
+
|
|
22
|
+
const zh = {
|
|
23
|
+
summary: 'QQ 消息挂载会话',
|
|
24
|
+
sidebarLabel: '挂载会话',
|
|
25
|
+
panelTitle: '挂载会话',
|
|
26
|
+
description: '只有这里选中的 DSH 会话会收到 QQ 群 @、群后续、私聊和戳一戳;QQ 工具也只挂在这个会话上。保存后约 3 秒生效。',
|
|
27
|
+
none: '不挂载(关闭 QQ 唤醒)',
|
|
28
|
+
untitled: '未命名会话',
|
|
29
|
+
running: '(运行中)',
|
|
30
|
+
missing: '当前目标(会话未运行)',
|
|
31
|
+
archived: '当前目标(已归档)',
|
|
32
|
+
refresh: '刷新',
|
|
33
|
+
save: '保存',
|
|
34
|
+
saving: '保存中…',
|
|
35
|
+
saved: '已保存,约 3 秒内生效',
|
|
36
|
+
loading: '读取中…',
|
|
37
|
+
failed: '操作失败:',
|
|
38
|
+
current: '当前挂载:',
|
|
39
|
+
empty: '当前没有挂载任何会话。',
|
|
40
|
+
unavailable: '接口不可用。请先在插件页启用「挂载会话」组件。',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const en = {
|
|
44
|
+
summary: 'QQ target session',
|
|
45
|
+
sidebarLabel: 'Target session',
|
|
46
|
+
panelTitle: 'Target session',
|
|
47
|
+
description: 'Only the selected DSH session receives QQ mentions, follow-ups, private messages and pokes; QQ tools are mounted there too. Changes apply within about 3 seconds.',
|
|
48
|
+
none: 'Do not mount (disable QQ wake-ups)',
|
|
49
|
+
untitled: 'Untitled session',
|
|
50
|
+
running: ' (running)',
|
|
51
|
+
missing: 'Current target (not running)',
|
|
52
|
+
archived: 'Current target (archived)',
|
|
53
|
+
refresh: 'Refresh',
|
|
54
|
+
save: 'Save',
|
|
55
|
+
saving: 'Saving…',
|
|
56
|
+
saved: 'Saved; applies within about 3 seconds',
|
|
57
|
+
loading: 'Loading…',
|
|
58
|
+
failed: 'Request failed: ',
|
|
59
|
+
current: 'Mounted to: ',
|
|
60
|
+
empty: 'No session is mounted.',
|
|
61
|
+
unavailable: 'Endpoint unavailable. Enable the "Target session" component on the plugin page first.',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const inject = ['slots', 'locale']
|
|
65
|
+
|
|
66
|
+
function shortId(id) {
|
|
67
|
+
const s = String(id || '')
|
|
68
|
+
return s.length > 12 ? s.slice(0, 8) + '…' : s
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 宿主在插件行未启用时返回 404/HTML;先把文本读出来再解析,避免 r.json() 直接抛。
|
|
72
|
+
function readJsonResponse(response) {
|
|
73
|
+
return response.text().then((text) => {
|
|
74
|
+
let body = {}
|
|
75
|
+
try { body = text ? JSON.parse(text) : {} } catch (error) {}
|
|
76
|
+
return { ok: response.ok, body }
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function TargetSessionCard(props) {
|
|
81
|
+
const t = props.t
|
|
82
|
+
const list = props.useSessions((state) => state)
|
|
83
|
+
const useWorkspaces = props.useWorkspaces || (() => EMPTY_IDS)
|
|
84
|
+
const archivedSessionIds = useWorkspaces((state) => (state && state.archivedSessionIds) || EMPTY_IDS) || EMPTY_IDS
|
|
85
|
+
const archived = react.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
|
|
86
|
+
const [current, setCurrent] = react.useState('')
|
|
87
|
+
const [selected, setSelected] = react.useState('')
|
|
88
|
+
const [liveIds, setLiveIds] = react.useState([])
|
|
89
|
+
const [phase, setPhase] = react.useState('loading')
|
|
90
|
+
const [detail, setDetail] = react.useState('')
|
|
91
|
+
const [loaded, setLoaded] = react.useState(false)
|
|
92
|
+
|
|
93
|
+
const load = react.useCallback(() => {
|
|
94
|
+
setPhase('loading')
|
|
95
|
+
setDetail('')
|
|
96
|
+
fetch(API, { headers: { accept: 'application/json' } })
|
|
97
|
+
.then(readJsonResponse)
|
|
98
|
+
.then(({ ok, body }) => {
|
|
99
|
+
if (!ok || !body || body.ok !== true) {
|
|
100
|
+
throw new Error((body && body.error) || t('unavailable'))
|
|
101
|
+
}
|
|
102
|
+
const value = typeof body.targetSession === 'string' ? body.targetSession : ''
|
|
103
|
+
setCurrent(value)
|
|
104
|
+
setSelected(value)
|
|
105
|
+
setLiveIds(Array.isArray(body.liveSessionIds) ? body.liveSessionIds : [])
|
|
106
|
+
setLoaded(true)
|
|
107
|
+
setPhase('ready')
|
|
108
|
+
})
|
|
109
|
+
.catch((error) => {
|
|
110
|
+
setDetail(String((error && error.message) || error))
|
|
111
|
+
setPhase('error')
|
|
112
|
+
})
|
|
113
|
+
}, [t])
|
|
114
|
+
|
|
115
|
+
react.useEffect(() => {
|
|
116
|
+
load()
|
|
117
|
+
}, [load])
|
|
118
|
+
|
|
119
|
+
const save = react.useCallback(() => {
|
|
120
|
+
setPhase('saving')
|
|
121
|
+
setDetail('')
|
|
122
|
+
fetch(API, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
125
|
+
body: JSON.stringify({ targetSession: selected }),
|
|
126
|
+
})
|
|
127
|
+
.then(readJsonResponse)
|
|
128
|
+
.then(({ ok, body }) => {
|
|
129
|
+
if (!ok || !body || body.ok !== true) {
|
|
130
|
+
throw new Error((body && body.error) || 'save failed')
|
|
131
|
+
}
|
|
132
|
+
const value = typeof body.targetSession === 'string' ? body.targetSession : ''
|
|
133
|
+
setCurrent(value)
|
|
134
|
+
setSelected(value)
|
|
135
|
+
setPhase('saved')
|
|
136
|
+
})
|
|
137
|
+
.catch((error) => {
|
|
138
|
+
setDetail(String((error && error.message) || error))
|
|
139
|
+
setPhase('error')
|
|
140
|
+
})
|
|
141
|
+
}, [selected])
|
|
142
|
+
|
|
143
|
+
const sessions = react.useMemo(() => {
|
|
144
|
+
const ids = Array.isArray(list && list.ids) ? list.ids : []
|
|
145
|
+
const byId = (list && list.byId) || {}
|
|
146
|
+
return ids.map((id) => byId[id]).filter(Boolean)
|
|
147
|
+
}, [list])
|
|
148
|
+
|
|
149
|
+
const options = [h('option', { key: '__none', value: '' }, t('none'))]
|
|
150
|
+
let selectedInList = false
|
|
151
|
+
for (const session of sessions) {
|
|
152
|
+
if (!session || !session.id) continue
|
|
153
|
+
if (session.origin === 'subagent') continue
|
|
154
|
+
if (archived.has(session.id)) continue
|
|
155
|
+
if (session.id === selected) selectedInList = true
|
|
156
|
+
const title = String(session.title || '').trim() || t('untitled')
|
|
157
|
+
const running = session.running === true || liveIds.indexOf(session.id) >= 0
|
|
158
|
+
options.push(h(
|
|
159
|
+
'option',
|
|
160
|
+
{ key: session.id, value: session.id },
|
|
161
|
+
title + ' · ' + shortId(session.id) + (running ? t('running') : ''),
|
|
162
|
+
))
|
|
163
|
+
}
|
|
164
|
+
if (selected && !selectedInList) {
|
|
165
|
+
options.push(h(
|
|
166
|
+
'option',
|
|
167
|
+
{ key: '__current', value: selected },
|
|
168
|
+
(archived.has(selected) ? t('archived') : t('missing')) + ' · ' + shortId(selected),
|
|
169
|
+
))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (props.view === 'summary') {
|
|
173
|
+
return h('span', null, current ? t('current') + shortId(current) : t('empty'))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let statusText = ''
|
|
177
|
+
let statusColor = 'var(--dsw-alias-label-tertiary)'
|
|
178
|
+
if (phase === 'loading') statusText = t('loading')
|
|
179
|
+
else if (phase === 'saving') statusText = t('saving')
|
|
180
|
+
else if (phase === 'saved') {
|
|
181
|
+
statusText = t('saved')
|
|
182
|
+
statusColor = 'var(--dsw-alias-state-success-primary, #2f9e44)'
|
|
183
|
+
} else if (phase === 'error') {
|
|
184
|
+
statusText = t('failed') + (detail || t('unavailable'))
|
|
185
|
+
statusColor = 'var(--dsw-alias-state-error-primary, #d64545)'
|
|
186
|
+
} else {
|
|
187
|
+
statusText = current ? t('current') + shortId(current) : t('empty')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const disabled = !loaded || phase === 'saving'
|
|
191
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } }, [
|
|
192
|
+
h('p', {
|
|
193
|
+
key: 'desc',
|
|
194
|
+
style: { margin: 0, fontSize: 13, lineHeight: '20px', color: 'var(--dsw-alias-label-secondary)' },
|
|
195
|
+
}, t('description')),
|
|
196
|
+
h('div', { key: 'row', style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } }, [
|
|
197
|
+
h('select', {
|
|
198
|
+
key: 'select',
|
|
199
|
+
value: selected,
|
|
200
|
+
disabled,
|
|
201
|
+
onChange: (event) => {
|
|
202
|
+
setSelected(event.target.value)
|
|
203
|
+
if (phase === 'saved' || phase === 'error') setPhase('ready')
|
|
204
|
+
},
|
|
205
|
+
style: {
|
|
206
|
+
minWidth: 280,
|
|
207
|
+
maxWidth: '100%',
|
|
208
|
+
height: 36,
|
|
209
|
+
padding: '0 10px',
|
|
210
|
+
borderRadius: 8,
|
|
211
|
+
border: '0.5px solid var(--dsw-alias-border-l4)',
|
|
212
|
+
background: 'var(--dsw-alias-bg-layer-3)',
|
|
213
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
214
|
+
font: 'inherit',
|
|
215
|
+
fontSize: 13,
|
|
216
|
+
outline: 'none',
|
|
217
|
+
},
|
|
218
|
+
}, options),
|
|
219
|
+
h('button', {
|
|
220
|
+
key: 'refresh',
|
|
221
|
+
type: 'button',
|
|
222
|
+
disabled: phase === 'loading' || phase === 'saving',
|
|
223
|
+
onClick: load,
|
|
224
|
+
style: {
|
|
225
|
+
height: 36,
|
|
226
|
+
padding: '0 14px',
|
|
227
|
+
borderRadius: 8,
|
|
228
|
+
border: '0.5px solid var(--dsw-alias-border-l4)',
|
|
229
|
+
background: 'var(--dsw-alias-bg-layer-3)',
|
|
230
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
231
|
+
font: 'inherit',
|
|
232
|
+
fontSize: 13,
|
|
233
|
+
cursor: 'pointer',
|
|
234
|
+
},
|
|
235
|
+
}, t('refresh')),
|
|
236
|
+
h('button', {
|
|
237
|
+
key: 'save',
|
|
238
|
+
type: 'button',
|
|
239
|
+
disabled,
|
|
240
|
+
onClick: save,
|
|
241
|
+
style: {
|
|
242
|
+
height: 36,
|
|
243
|
+
padding: '0 16px',
|
|
244
|
+
borderRadius: 8,
|
|
245
|
+
border: 'none',
|
|
246
|
+
background: 'var(--dsw-alias-state-business-primary, #4d6bfe)',
|
|
247
|
+
color: '#fff',
|
|
248
|
+
font: 'inherit',
|
|
249
|
+
fontSize: 13,
|
|
250
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
251
|
+
opacity: disabled ? 0.6 : 1,
|
|
252
|
+
},
|
|
253
|
+
}, phase === 'saving' ? t('saving') : t('save')),
|
|
254
|
+
]),
|
|
255
|
+
h('p', {
|
|
256
|
+
key: 'status',
|
|
257
|
+
style: { margin: 0, fontSize: 12, lineHeight: '18px', color: statusColor },
|
|
258
|
+
}, statusText),
|
|
259
|
+
])
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function TargetSessionIcon(props) {
|
|
263
|
+
const size = Number(props && props.size) || 16
|
|
264
|
+
return h('svg', {
|
|
265
|
+
viewBox: '0 0 16 16',
|
|
266
|
+
width: size,
|
|
267
|
+
height: size,
|
|
268
|
+
fill: 'none',
|
|
269
|
+
stroke: 'currentColor',
|
|
270
|
+
strokeWidth: 1.5,
|
|
271
|
+
strokeLinecap: 'round',
|
|
272
|
+
strokeLinejoin: 'round',
|
|
273
|
+
'aria-hidden': 'true',
|
|
274
|
+
'data-dsh-panel-entry': PANEL_ID,
|
|
275
|
+
}, [
|
|
276
|
+
h('path', {
|
|
277
|
+
key: 'bubble',
|
|
278
|
+
d: 'M2.5 2.75h9a1.5 1.5 0 0 1 1.5 1.5v3.5a1.5 1.5 0 0 1-1.5 1.5H6.75L4 12.25V9.25H2.5a1.5 1.5 0 0 1-1.5-1.5v-3.5a1.5 1.5 0 0 1 1.5-1.5Z',
|
|
279
|
+
}),
|
|
280
|
+
h('path', { key: 'line1', d: 'M4.25 5.5h5.5' }),
|
|
281
|
+
h('path', { key: 'line2', d: 'M4.25 7.75h3.25' }),
|
|
282
|
+
])
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function TargetSessionPage(props) {
|
|
286
|
+
const t = props.t
|
|
287
|
+
return h('div', {
|
|
288
|
+
style: {
|
|
289
|
+
height: '100%',
|
|
290
|
+
boxSizing: 'border-box',
|
|
291
|
+
overflow: 'auto',
|
|
292
|
+
padding: '28px 36px 48px',
|
|
293
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
294
|
+
},
|
|
295
|
+
}, [
|
|
296
|
+
h('h1', {
|
|
297
|
+
key: 'title',
|
|
298
|
+
style: { margin: '0 0 18px', fontSize: 20, fontWeight: 600, lineHeight: '28px' },
|
|
299
|
+
}, t('panelTitle')),
|
|
300
|
+
h(TargetSessionCard, { key: 'card', ...props, view: 'page' }),
|
|
301
|
+
])
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function apply(ctx) {
|
|
305
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'qq-tools: dictionaries')
|
|
306
|
+
const t = ctx.locale.bind(NS)
|
|
307
|
+
ctx.effect(() => ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
308
|
+
name: 'sidebar.panellist',
|
|
309
|
+
id: PANEL_ID,
|
|
310
|
+
order: PANEL_ORDER,
|
|
311
|
+
label: () => t('sidebarLabel'),
|
|
312
|
+
}, TargetSessionIcon)), 'qq-tools: sidebar entry')
|
|
313
|
+
ctx.effect(() => ctx.slots.inject('main', () => ctx.slots.register({
|
|
314
|
+
name: 'main',
|
|
315
|
+
key: PANEL_ID,
|
|
316
|
+
}, (props) => h(TargetSessionPage, { ...props, t }))), 'qq-tools: main panel')
|
|
317
|
+
ctx.effect(() => ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
318
|
+
name: 'plugins.row.config',
|
|
319
|
+
key: SLOT_KEY,
|
|
320
|
+
locale: NS,
|
|
321
|
+
}, TargetSessionCard)), 'qq-tools: target session page')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
exports.apply = apply
|
|
325
|
+
exports.inject = inject
|
|
326
|
+
return module.exports
|
|
327
|
+
},
|
|
328
|
+
})
|
package/lib/index.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
// dsh-qq-tools ——
|
|
1
|
+
// dsh-qq-tools —— 包根入口。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
3
|
+
// 两用:
|
|
4
|
+
// · cordis.patch.yml 里的包根行用 config.carrier = true 启动它,只承载
|
|
5
|
+
// 「挂载会话」的 HTTP 接口;浏览器半侧(lib/client.js)也只能从包根行
|
|
6
|
+
// 被发现,子路径行不会被 dsh-client-modules 扫描到。
|
|
7
|
+
// · 旧配置直接引用 `dsh-qq-tools` 时没有 carrier 标记,仍然挂全部工具。
|
|
8
|
+
import { mountAll, mountTargetSessionRoute } from './shared.js'
|
|
7
9
|
|
|
8
10
|
export * from './shared.js'
|
|
9
11
|
|
|
10
12
|
export const name = 'qq-tools'
|
|
11
|
-
export const inject = ['agents', 'timer', 'tools', 'attachments']
|
|
13
|
+
export const inject = ['agents', 'timer', 'tools', 'attachments', 'webServer']
|
|
12
14
|
|
|
13
|
-
export function apply(ctx) {
|
|
15
|
+
export function apply(ctx, config) {
|
|
16
|
+
if (config && config.carrier === true) {
|
|
17
|
+
mountTargetSessionRoute(ctx)
|
|
18
|
+
return {}
|
|
19
|
+
}
|
|
14
20
|
return mountAll(ctx)
|
|
15
21
|
}
|
package/lib/shared.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
//
|
|
35
35
|
// 工具本身的开关在 DSH 插件页的组件列表里:关掉某个组件,它就不再注册对应工具。
|
|
36
36
|
|
|
37
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs'
|
|
37
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync } from 'node:fs'
|
|
38
38
|
import { join } from 'node:path'
|
|
39
39
|
import { homedir } from 'node:os'
|
|
40
40
|
import { randomUUID } from 'node:crypto'
|
|
@@ -47,21 +47,36 @@ const DIAG_PATH = join(DSH_HOME, 'qq-tools-diag.log')
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
const requireFromHere = createRequire(import.meta.url)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
]
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
50
|
+
|
|
51
|
+
// sharp 是可选的,按这个顺序找:SHARP_PATH → 插件自己装的 → 宿主进程能解析到的。
|
|
52
|
+
// DSH 桌面版把 sharp 打在 resources/app.asar/dsh/node_modules 下,CLI 版跟着
|
|
53
|
+
// @deepseek-ai/dsh 的依赖走,所以从宿主的启动入口解析最稳,不用写死安装路径。
|
|
54
|
+
function loadSharp() {
|
|
55
|
+
const probes = []
|
|
56
|
+
if (process.env.SHARP_PATH) probes.push(() => requireFromHere(process.env.SHARP_PATH))
|
|
57
|
+
probes.push(() => requireFromHere('sharp'))
|
|
58
|
+
if (process.argv[1]) probes.push(() => createRequire(process.argv[1])('sharp'))
|
|
59
|
+
if (process.resourcesPath) {
|
|
60
|
+
probes.push(() => createRequire(join(process.resourcesPath, 'app.asar', 'dsh', 'package.json'))('sharp'))
|
|
61
|
+
}
|
|
62
|
+
for (const p of [
|
|
63
|
+
'/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/sharp',
|
|
64
|
+
'/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/sharp',
|
|
65
|
+
]) {
|
|
66
|
+
probes.push(() => requireFromHere(p))
|
|
67
|
+
}
|
|
68
|
+
for (const probe of probes) {
|
|
69
|
+
try {
|
|
70
|
+
const s = probe()
|
|
71
|
+
if (s) return s
|
|
72
|
+
} catch (error) {}
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
63
75
|
}
|
|
64
76
|
|
|
77
|
+
const sharp = loadSharp()
|
|
78
|
+
let gifWarned = false
|
|
79
|
+
|
|
65
80
|
const DEFAULT_GROUPS = []
|
|
66
81
|
const DEFAULT_CFG = {
|
|
67
82
|
napCat: 'http://127.0.0.1:3000',
|
|
@@ -122,6 +137,28 @@ function loadCfg() {
|
|
|
122
137
|
}
|
|
123
138
|
}
|
|
124
139
|
|
|
140
|
+
// UI 保存挂载会话时只覆盖传进来的字段,其余配置原样保留;先写临时文件再换名,
|
|
141
|
+
// 避免写一半留下坏 JSON 让后面所有工具都退回默认配置。
|
|
142
|
+
export function saveCfg(patch) {
|
|
143
|
+
ensureCfg()
|
|
144
|
+
let current = {}
|
|
145
|
+
try {
|
|
146
|
+
const raw = JSON.parse(readFileSync(CFG_PATH, 'utf8'))
|
|
147
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) current = raw
|
|
148
|
+
} catch (error) {}
|
|
149
|
+
const next = { ...current, ...(patch && typeof patch === 'object' ? patch : {}) }
|
|
150
|
+
try {
|
|
151
|
+
mkdirSync(DSH_HOME, { recursive: true })
|
|
152
|
+
const tmp = CFG_PATH + '.tmp'
|
|
153
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf8')
|
|
154
|
+
renameSync(tmp, CFG_PATH)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
diag('保存配置失败:' + String((error && error.message) || error))
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
return next
|
|
160
|
+
}
|
|
161
|
+
|
|
125
162
|
function targetSessionId() {
|
|
126
163
|
const cfg = loadCfg()
|
|
127
164
|
return String(cfg.targetSession || '')
|
|
@@ -157,6 +194,103 @@ function findAgent(agents, id) {
|
|
|
157
194
|
return undefined
|
|
158
195
|
}
|
|
159
196
|
|
|
197
|
+
|
|
198
|
+
// ---------- 目标会话 UI 接口(由「挂载会话」组件启动) ----------
|
|
199
|
+
// 浏览器半侧只读写 targetSession:GET 返回当前值和活着的会话,POST 写回配置文件;
|
|
200
|
+
// 其他组件仍然每 3 秒 loadCfg(),所以保存后不需要重启。
|
|
201
|
+
const TARGET_SESSION_ROUTE = '/api/dsh-qq-tools/target-session'
|
|
202
|
+
|
|
203
|
+
function replyJson(res, status, body) {
|
|
204
|
+
const text = JSON.stringify(body)
|
|
205
|
+
try {
|
|
206
|
+
res.writeHead(status, {
|
|
207
|
+
'content-type': 'application/json; charset=utf-8',
|
|
208
|
+
'cache-control': 'no-store',
|
|
209
|
+
})
|
|
210
|
+
res.end(text)
|
|
211
|
+
} catch (error) {}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function readRequestJson(req, max = 64 * 1024) {
|
|
215
|
+
const chunks = []
|
|
216
|
+
let size = 0
|
|
217
|
+
for await (const chunk of req) {
|
|
218
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
219
|
+
size += buf.length
|
|
220
|
+
if (size > max) throw new Error('body too large')
|
|
221
|
+
chunks.push(buf)
|
|
222
|
+
}
|
|
223
|
+
if (!chunks.length) return {}
|
|
224
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sameOriginRequest(req) {
|
|
228
|
+
const origin = String((req.headers && req.headers.origin) || '')
|
|
229
|
+
if (!origin) return true
|
|
230
|
+
try {
|
|
231
|
+
return new URL(origin).host === String((req.headers && req.headers.host) || '')
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function mountTargetSessionRoute(ctx) {
|
|
238
|
+
const webServer = ctx.get('webServer')
|
|
239
|
+
if (!webServer || typeof webServer.register !== 'function') {
|
|
240
|
+
diag('挂载会话:宿主没有 webServer,设置页接口不可用')
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
ctx.effect(() => {
|
|
244
|
+
const dispose = webServer.register({
|
|
245
|
+
kind: 'exact',
|
|
246
|
+
path: TARGET_SESSION_ROUTE,
|
|
247
|
+
handler: async (req, res) => {
|
|
248
|
+
if (!sameOriginRequest(req)) {
|
|
249
|
+
replyJson(res, 403, { ok: false, error: 'forbidden' })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
const method = String(req.method || 'GET').toUpperCase()
|
|
253
|
+
if (method === 'GET') {
|
|
254
|
+
const liveSessionIds = []
|
|
255
|
+
try {
|
|
256
|
+
const agents = ctx.get('agents') || ctx.agents
|
|
257
|
+
for (const agent of (agents && agents.list()) || []) {
|
|
258
|
+
const sid = sessionIdOf(agent)
|
|
259
|
+
if (sid) liveSessionIds.push(sid)
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {}
|
|
262
|
+
replyJson(res, 200, { ok: true, targetSession: targetSessionId(), liveSessionIds })
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (method !== 'POST') {
|
|
266
|
+
replyJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
let body
|
|
270
|
+
try {
|
|
271
|
+
body = await readRequestJson(req)
|
|
272
|
+
} catch (error) {
|
|
273
|
+
replyJson(res, 400, { ok: false, error: 'invalid body' })
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
const raw = body && typeof body.targetSession === 'string' ? body.targetSession : ''
|
|
277
|
+
const targetSession = clean(raw).slice(0, 200)
|
|
278
|
+
try {
|
|
279
|
+
saveCfg({ targetSession })
|
|
280
|
+
} catch (error) {
|
|
281
|
+
replyJson(res, 500, { ok: false, error: 'save failed' })
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
diag('目标会话已更新:' + (targetSession || '(不挂载)'))
|
|
285
|
+
replyJson(res, 200, { ok: true, targetSession })
|
|
286
|
+
},
|
|
287
|
+
})
|
|
288
|
+
return () => {
|
|
289
|
+
try { dispose() } catch (error) {}
|
|
290
|
+
}
|
|
291
|
+
}, 'qq-tools: target session route')
|
|
292
|
+
}
|
|
293
|
+
|
|
160
294
|
function allowedGroup(groupId, cfg) {
|
|
161
295
|
if (!Array.isArray(cfg.groups) || cfg.groups.length === 0) return true
|
|
162
296
|
return cfg.groups.includes(Number(groupId))
|
|
@@ -220,6 +354,16 @@ function segText(segs) {
|
|
|
220
354
|
.join(''))
|
|
221
355
|
}
|
|
222
356
|
|
|
357
|
+
// 这里只做「优先展示动图」的排序提示,不决定 mediaType。
|
|
358
|
+
// QQ 的 [动画表情] 摘要经常贴在静态 PNG/JPEG 上,所以别再信 summary 里的「动画」;
|
|
359
|
+
// 真正是不是 GIF 由后面按字节嗅探,只有 mface 和 .gif 扩展名算动图候选。
|
|
360
|
+
function animatedSeg(seg) {
|
|
361
|
+
if (!seg || !seg.data) return false
|
|
362
|
+
if (seg.type === 'mface') return true
|
|
363
|
+
const d = seg.data
|
|
364
|
+
return /\.gif(?:[?#]|$)/i.test(String(d.file || '')) || /\.gif(?:[?#]|$)/i.test(String(d.url || ''))
|
|
365
|
+
}
|
|
366
|
+
|
|
223
367
|
function fmtTime(sec) {
|
|
224
368
|
const t = Number(sec)
|
|
225
369
|
if (!Number.isFinite(t) || t <= 0) return ''
|
|
@@ -323,13 +467,80 @@ export function makeListTool() {
|
|
|
323
467
|
}
|
|
324
468
|
|
|
325
469
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
470
|
+
// ---------- 图片类型与尺寸 ----------
|
|
471
|
+
// DSH 附件层按真实字节复核 mediaType(IMAGE_TYPE_MISMATCH),文件名和 QQ 的
|
|
472
|
+
// animated 提示都可能撒谎,所以只信字节。DSH 默认单张源图上限 20MiB,
|
|
473
|
+
// 归一化目标 4MiB;源图超过 4MiB 时本地先缩一下,避免白白走一遍宿主重编码。
|
|
474
|
+
const ADMIT_IMAGE_LIMIT = 6
|
|
475
|
+
const IMAGE_SOFT_LIMIT = 4 * 1024 * 1024
|
|
476
|
+
const IMAGE_MEDIA_TYPES = {
|
|
477
|
+
png: 'image/png',
|
|
478
|
+
jpeg: 'image/jpeg',
|
|
479
|
+
webp: 'image/webp',
|
|
480
|
+
gif: 'image/gif',
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function sniffImageType(bytes) {
|
|
484
|
+
if (!bytes || bytes.length < 12) return ''
|
|
485
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
|
|
486
|
+
bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) return 'image/png'
|
|
487
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
|
|
488
|
+
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 &&
|
|
489
|
+
(bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) return 'image/gif'
|
|
490
|
+
if (bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
|
|
491
|
+
return ''
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function sharpImageType(bytes) {
|
|
495
|
+
if (!sharp) return ''
|
|
496
|
+
try {
|
|
497
|
+
const meta = await sharp(bytes, { animated: true }).metadata()
|
|
498
|
+
return IMAGE_MEDIA_TYPES[String((meta && meta.format) || '').toLowerCase()] || ''
|
|
499
|
+
} catch (error) {
|
|
500
|
+
return ''
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// DSH 只收 png/jpeg/webp/gif;sharp 能解的其他格式(TIFF 等)先转成它认识的格式。
|
|
505
|
+
async function convertImage(bytes) {
|
|
506
|
+
if (!sharp) return null
|
|
507
|
+
try {
|
|
508
|
+
const meta = await sharp(bytes).metadata()
|
|
509
|
+
const hasAlpha = !!meta.hasAlpha
|
|
510
|
+
const out = hasAlpha
|
|
511
|
+
? await sharp(bytes).webp({ quality: 82 }).toBuffer()
|
|
512
|
+
: await sharp(bytes).jpeg({ quality: 82 }).toBuffer()
|
|
513
|
+
return { bytes: out, mediaType: hasAlpha ? 'image/webp' : 'image/jpeg' }
|
|
514
|
+
} catch (error) {
|
|
515
|
+
return null
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 大图不丢:超过 DSH 归一化目标就先缩到 2048 长边并重编码,通常能压到 1MiB 以内。
|
|
520
|
+
async function shrinkImage(bytes) {
|
|
521
|
+
if (!sharp || !bytes || !bytes.length) return null
|
|
522
|
+
try {
|
|
523
|
+
const meta = await sharp(bytes, { animated: false }).metadata()
|
|
524
|
+
const width = Number(meta && meta.width) || 0
|
|
525
|
+
const height = Number(meta && meta.height) || 0
|
|
526
|
+
if (!width || !height) return null
|
|
527
|
+
const hasAlpha = !!meta.hasAlpha
|
|
528
|
+
const scale = Math.min(1, 2048 / Math.max(width, height))
|
|
529
|
+
const targetWidth = Math.max(1, Math.round(width * scale))
|
|
530
|
+
const targetHeight = Math.max(1, Math.round(height * scale))
|
|
531
|
+
for (const quality of [82, 70, 58]) {
|
|
532
|
+
const pipeline = sharp(bytes, { animated: false })
|
|
533
|
+
.rotate()
|
|
534
|
+
.resize({ width: targetWidth, height: targetHeight, fit: 'inside', withoutEnlargement: true })
|
|
535
|
+
const out = hasAlpha
|
|
536
|
+
? await pipeline.webp({ quality }).toBuffer()
|
|
537
|
+
: await pipeline.jpeg({ quality }).toBuffer()
|
|
538
|
+
if (out.length <= IMAGE_SOFT_LIMIT) {
|
|
539
|
+
return { bytes: out, mediaType: hasAlpha ? 'image/webp' : 'image/jpeg' }
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} catch (error) {}
|
|
543
|
+
return null
|
|
333
544
|
}
|
|
334
545
|
|
|
335
546
|
|
|
@@ -402,47 +613,133 @@ async function imageBytes(data, cfg) {
|
|
|
402
613
|
return null
|
|
403
614
|
}
|
|
404
615
|
|
|
616
|
+
function admitErrorText(error) {
|
|
617
|
+
if (!error) return '未知错误'
|
|
618
|
+
return (error.code ? String(error.code) + ':' : '') + String(error.message || error)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// 单张被拒时可以重编码再试;批量/存储层错误重试没有意义。
|
|
622
|
+
const RETRYABLE_IMAGE_ERRORS = new Set([
|
|
623
|
+
'IMAGE_TYPE_MISMATCH',
|
|
624
|
+
'INVALID_IMAGE',
|
|
625
|
+
'UNSUPPORTED_IMAGE_TYPE',
|
|
626
|
+
'IMAGE_TOO_LARGE',
|
|
627
|
+
'IMAGE_TOO_MANY_PIXELS',
|
|
628
|
+
'IMAGE_DIMENSION_TOO_LARGE',
|
|
629
|
+
])
|
|
630
|
+
|
|
631
|
+
async function admitOneImage(attachments, block) {
|
|
632
|
+
try {
|
|
633
|
+
const out = await attachments.admitPromptContent([block])
|
|
634
|
+
const hit = (Array.isArray(out) ? out : []).find((x) => x && x.type === 'image')
|
|
635
|
+
return hit ? { block: hit } : { error: new Error('附件层没有返回图片') }
|
|
636
|
+
} catch (error) {
|
|
637
|
+
return { error }
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function retryImageBlock(block) {
|
|
642
|
+
let bytes
|
|
643
|
+
try {
|
|
644
|
+
bytes = Buffer.from(String((block && block.data) || ''), 'base64')
|
|
645
|
+
} catch (error) {
|
|
646
|
+
return null
|
|
647
|
+
}
|
|
648
|
+
if (!bytes || !bytes.length) return null
|
|
649
|
+
const type = sniffImageType(bytes)
|
|
650
|
+
const fixed = type ? await shrinkImage(bytes) : await convertImage(bytes)
|
|
651
|
+
if (!fixed) return null
|
|
652
|
+
return { ...block, data: fixed.bytes.toString('base64'), mediaType: fixed.mediaType }
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// 一张坏图不能拖垮整批:先整批送,失败后退回逐张,逐张失败的再重编码试一次。
|
|
656
|
+
async function admitPreparedImages(attachments, prepared) {
|
|
657
|
+
const blocks = prepared.map((x) => x.block)
|
|
658
|
+
try {
|
|
659
|
+
const out = await attachments.admitPromptContent(blocks)
|
|
660
|
+
const images = (Array.isArray(out) ? out : []).filter((x) => x && x.type === 'image')
|
|
661
|
+
if (images.length === blocks.length) {
|
|
662
|
+
return images.map((block, i) => ({ caption: prepared[i].caption, block }))
|
|
663
|
+
}
|
|
664
|
+
diag('图片 admit 返回数量不符(' + images.length + '/' + blocks.length + '),改为逐张处理')
|
|
665
|
+
} catch (error) {
|
|
666
|
+
diag('图片批量 admit 失败,改为逐张重试:' + admitErrorText(error))
|
|
667
|
+
}
|
|
668
|
+
const admitted = []
|
|
669
|
+
let firstError = ''
|
|
670
|
+
for (const item of prepared) {
|
|
671
|
+
let result = await admitOneImage(attachments, item.block)
|
|
672
|
+
if (result.error && RETRYABLE_IMAGE_ERRORS.has(String(result.error.code || ''))) {
|
|
673
|
+
const retried = await retryImageBlock(item.block)
|
|
674
|
+
if (retried) result = await admitOneImage(attachments, retried)
|
|
675
|
+
}
|
|
676
|
+
if (result.block) admitted.push({ caption: item.caption, block: result.block })
|
|
677
|
+
else if (!firstError) firstError = admitErrorText(result.error)
|
|
678
|
+
}
|
|
679
|
+
if (firstError && admitted.length < prepared.length) {
|
|
680
|
+
diag('有图片没能进来(已跳过,其余保留):' + firstError)
|
|
681
|
+
}
|
|
682
|
+
return admitted
|
|
683
|
+
}
|
|
684
|
+
|
|
405
685
|
export function admitImagesFor(ctx) {
|
|
406
686
|
return async function admitImages(list) {
|
|
407
687
|
const attachments = ctx.attachments || (typeof ctx.get === 'function' ? ctx.get('attachments') : null)
|
|
408
688
|
if (!attachments || typeof attachments.admitPromptContent !== 'function') return []
|
|
409
689
|
const prepared = []
|
|
410
690
|
for (const x of Array.isArray(list) ? list : []) {
|
|
411
|
-
|
|
691
|
+
if (prepared.length >= ADMIT_IMAGE_LIMIT) break
|
|
692
|
+
let bytes = await imageBytes(x.data, x.cfg || loadCfg())
|
|
412
693
|
if (!bytes || !bytes.length) continue
|
|
413
|
-
if (bytes.length > 5 * 1024 * 1024) continue
|
|
414
694
|
const name = String(x.name || 'qq-image.jpg')
|
|
415
|
-
|
|
416
|
-
|
|
695
|
+
// animated 只是提示,type 一律按字节来;猜错会被 DSH 按 IMAGE_TYPE_MISMATCH 整批拒掉。
|
|
696
|
+
let mediaType = sniffImageType(bytes) || (await sharpImageType(bytes))
|
|
697
|
+
if (!mediaType) {
|
|
698
|
+
const converted = await convertImage(bytes)
|
|
699
|
+
if (!converted) {
|
|
700
|
+
diag('图片跳过:认不出格式(' + name + ')')
|
|
701
|
+
continue
|
|
702
|
+
}
|
|
703
|
+
bytes = converted.bytes
|
|
704
|
+
mediaType = converted.mediaType
|
|
705
|
+
}
|
|
706
|
+
if (mediaType === 'image/gif') {
|
|
417
707
|
const frames = await gifFrames(bytes, 3)
|
|
418
708
|
if (frames) {
|
|
419
709
|
for (const f of frames) {
|
|
420
|
-
const frameName = name.replace(/\.[^.]+$/, '') + `-gif-${f.page}.png`
|
|
421
710
|
prepared.push({
|
|
422
711
|
caption: String(x.caption || '') + `(GIF 第 ${f.page}/${f.pages} 帧)`,
|
|
423
|
-
block: {
|
|
712
|
+
block: {
|
|
713
|
+
type: 'image',
|
|
714
|
+
data: f.bytes.toString('base64'),
|
|
715
|
+
mediaType: 'image/png',
|
|
716
|
+
name: name.replace(/\.[^.]+$/, '') + `-gif-${f.page}.png`,
|
|
717
|
+
},
|
|
424
718
|
})
|
|
425
|
-
if (prepared.length >=
|
|
719
|
+
if (prepared.length >= ADMIT_IMAGE_LIMIT) break
|
|
426
720
|
}
|
|
427
|
-
if (prepared.length >= 6) break
|
|
428
721
|
continue
|
|
429
722
|
}
|
|
723
|
+
if (!sharp && !gifWarned) {
|
|
724
|
+
gifWarned = true
|
|
725
|
+
diag('GIF 帧拆分不可用:没有加载到 sharp,动图会按静图处理')
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
// 以前这里直接丢弃 >5MiB 的图;DSH 实际能收 20MiB 并自动归一化,所以改成先缩放。
|
|
729
|
+
if (bytes.length > IMAGE_SOFT_LIMIT) {
|
|
730
|
+
const shrunk = await shrinkImage(bytes)
|
|
731
|
+
if (shrunk) {
|
|
732
|
+
bytes = shrunk.bytes
|
|
733
|
+
mediaType = shrunk.mediaType
|
|
734
|
+
}
|
|
430
735
|
}
|
|
431
736
|
prepared.push({
|
|
432
737
|
caption: String(x.caption || ''),
|
|
433
|
-
block: { type: 'image', data: bytes.toString('base64'), mediaType
|
|
738
|
+
block: { type: 'image', data: bytes.toString('base64'), mediaType, name },
|
|
434
739
|
})
|
|
435
|
-
if (prepared.length >= 6) break
|
|
436
740
|
}
|
|
437
741
|
if (!prepared.length) return []
|
|
438
|
-
|
|
439
|
-
const out = await attachments.admitPromptContent(prepared.map((x) => x.block))
|
|
440
|
-
const blocks = (out || []).filter((x) => x && x.type === 'image')
|
|
441
|
-
return blocks.map((block, i) => ({ caption: prepared[i] ? prepared[i].caption : '', block }))
|
|
442
|
-
} catch (error) {
|
|
443
|
-
diag('图片 admit 失败:' + String((error && error.message) || error))
|
|
444
|
-
return []
|
|
445
|
-
}
|
|
742
|
+
return admitPreparedImages(attachments, prepared)
|
|
446
743
|
}
|
|
447
744
|
}
|
|
448
745
|
|
|
@@ -491,11 +788,7 @@ export function makeHistoryTool(admitImages) {
|
|
|
491
788
|
const segs = Array.isArray(m.message) ? m.message : []
|
|
492
789
|
for (const seg of segs) {
|
|
493
790
|
if (!seg || (seg.type !== 'image' && seg.type !== 'mface') || !seg.data) continue
|
|
494
|
-
|
|
495
|
-
const animated =
|
|
496
|
-
seg.type === 'mface' ||
|
|
497
|
-
/gif|动画/i.test(String(d.summary || '') + ' ' + String(d.file || '') + ' ' + String(d.url || ''))
|
|
498
|
-
picked.push({ m, seg, animated })
|
|
791
|
+
picked.push({ m, seg, animated: animatedSeg(seg) })
|
|
499
792
|
}
|
|
500
793
|
}
|
|
501
794
|
const anim = picked.filter((x) => x.animated).slice(-2)
|
|
@@ -708,10 +1001,7 @@ export function makePrivateHistoryTool(admitImages) {
|
|
|
708
1001
|
const segs = Array.isArray(m.message) ? m.message : []
|
|
709
1002
|
for (const seg of segs) {
|
|
710
1003
|
if (!seg || (seg.type !== 'image' && seg.type !== 'mface') || !seg.data) continue
|
|
711
|
-
|
|
712
|
-
seg.type === 'mface' ||
|
|
713
|
-
/gif|动画/i.test(String(seg.data.summary || '') + ' ' + String(seg.data.file || '') + ' ' + String(seg.data.url || ''))
|
|
714
|
-
picked.push({ m, seg, animated })
|
|
1004
|
+
picked.push({ m, seg, animated: animatedSeg(seg) })
|
|
715
1005
|
}
|
|
716
1006
|
}
|
|
717
1007
|
const anim = picked.filter((x) => x.animated).slice(-2)
|
|
@@ -1594,19 +1884,22 @@ async function handlePrivateMessage(ctx, ev, admitImages) {
|
|
|
1594
1884
|
'不想回就安静待着;不要用 Markdown,不要提工具,短一点。'
|
|
1595
1885
|
const content = [{ type: 'text', text: prompt }]
|
|
1596
1886
|
if (typeof admitImages === 'function') {
|
|
1597
|
-
|
|
1887
|
+
// 和群/私聊记录一样:先保证最近两张动图能进来,剩下的名额给静图。
|
|
1888
|
+
const anim = []
|
|
1889
|
+
const stat = []
|
|
1598
1890
|
for (const seg of segs) {
|
|
1599
1891
|
if (!seg || (seg.type !== 'image' && seg.type !== 'mface') || !seg.data) continue
|
|
1600
|
-
|
|
1601
|
-
if (
|
|
1892
|
+
const item = { seg, animated: animatedSeg(seg) }
|
|
1893
|
+
if (item.animated) anim.push(item)
|
|
1894
|
+
else stat.push(item)
|
|
1602
1895
|
}
|
|
1896
|
+
const animPick = anim.slice(-2)
|
|
1897
|
+
const picked = [...animPick, ...stat.slice(-(4 - animPick.length))]
|
|
1603
1898
|
if (picked.length) {
|
|
1604
|
-
const images = await admitImages(picked.map((seg, i) => ({
|
|
1899
|
+
const images = await admitImages(picked.map(({ seg, animated }, i) => ({
|
|
1605
1900
|
data: seg.data,
|
|
1606
1901
|
cfg,
|
|
1607
|
-
animated
|
|
1608
|
-
seg.type === 'mface' ||
|
|
1609
|
-
/gif|动画/i.test(String(seg.data.summary || '') + ' ' + String(seg.data.file || '') + ' ' + String(seg.data.url || '')),
|
|
1902
|
+
animated,
|
|
1610
1903
|
name: (seg.data && seg.data.file) || `qq-private-${ev.message_id || 'x'}-${i}.jpg`,
|
|
1611
1904
|
caption: `私聊 ${name} 发的图片(消息 ${ev.message_id || '?'})`,
|
|
1612
1905
|
})))
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-qq-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群记录、发群消息、发表情、收藏表情(SnowLuma
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群记录、发群消息、发表情、收藏表情(SnowLuma 收藏夹)、好友/私聊记录、发私聊消息、私聊唤醒、群成员、戳一戳感知/戳回去、挂载会话选择。8 个工具加 1 个设置组件,可在插件页逐个开关。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"license": "MIT",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"files": [
|
|
27
27
|
"lib",
|
|
28
28
|
"tools",
|
|
29
|
+
"locale",
|
|
29
30
|
"cordis.patch.yml",
|
|
30
31
|
"README.md",
|
|
31
32
|
"LICENSE"
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
},
|
|
36
37
|
"exports": {
|
|
37
38
|
".": "./lib/index.js",
|
|
39
|
+
"./client": "./lib/client.js",
|
|
38
40
|
"./tools/qq-list-groups": "./tools/qq-list-groups/index.js",
|
|
39
41
|
"./tools/qq-list-groups/locale/en.json": "./tools/qq-list-groups/locale/en.json",
|
|
40
42
|
"./tools/qq-list-groups/locale/zh.json": "./tools/qq-list-groups/locale/zh.json",
|
|
@@ -59,12 +61,22 @@
|
|
|
59
61
|
"./tools/qq-send-poke": "./tools/qq-send-poke/index.js",
|
|
60
62
|
"./tools/qq-send-poke/locale/en.json": "./tools/qq-send-poke/locale/en.json",
|
|
61
63
|
"./tools/qq-send-poke/locale/zh.json": "./tools/qq-send-poke/locale/zh.json",
|
|
64
|
+
"./locale/en.json": "./locale/en.json",
|
|
65
|
+
"./locale/zh.json": "./locale/zh.json",
|
|
62
66
|
"./package.json": "./package.json"
|
|
63
67
|
},
|
|
64
|
-
"description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群记录、发群消息、发表情、收藏表情(SnowLuma 收藏夹)、好友/私聊记录、发私聊消息、私聊唤醒、群成员、戳一戳感知/戳回去。8 个工具各是一个独立组件,可在插件页逐个开关。",
|
|
65
68
|
"dsh": {
|
|
66
69
|
"bundle": {
|
|
67
70
|
"patch": "./cordis.patch.yml"
|
|
71
|
+
},
|
|
72
|
+
"client": {
|
|
73
|
+
"inject": [
|
|
74
|
+
"@deepseek-ai/dsh-client-locale",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager"
|
|
78
|
+
],
|
|
79
|
+
"platform": "web"
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
}
|