@anxi78/dsh-yuanbao 0.2.1 → 0.3.0
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 +89 -15
- package/README_EN.md +2 -0
- package/client.js +3 -3
- package/cordis.patch.yml +1 -1
- package/dist/chat-history.d.ts +116 -10
- package/dist/chat-history.js +217 -13
- package/dist/config.js +3 -0
- package/dist/inbound.js +162 -4
- package/dist/index.js +4 -0
- package/dist/types.d.ts +2 -0
- package/package.json +2 -2
- package/src/chat-history.ts +281 -13
- package/src/config.ts +3 -0
- package/src/inbound.ts +175 -4
- package/src/index.ts +5 -0
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-yuanbao
|
|
2
2
|
|
|
3
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
4
|
+
|
|
3
5
|
中文 | [English](./README_EN.md)
|
|
4
6
|
|
|
5
7
|
基于 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) 的元宝 IM 通道插件,将元宝开放平台 WebSocket 消息接入 dsh agent runtime。
|
|
@@ -19,7 +21,7 @@ npm: [`@anxi78/dsh-yuanbao`](https://www.npmjs.com/package/@anxi78/dsh-yuanbao)
|
|
|
19
21
|
## 与上游的差异
|
|
20
22
|
|
|
21
23
|
> 对比基准:上游 `9446ebd`(fix: support yuanbao group replies)
|
|
22
|
-
> 统计:**
|
|
24
|
+
> 统计:**23 个文件,+4012 / -115 行**
|
|
23
25
|
|
|
24
26
|
### 一、新增功能
|
|
25
27
|
|
|
@@ -75,6 +77,73 @@ pinnedSessionId: '' # pinned 模式下的目标会话 ID
|
|
|
75
77
|
|
|
76
78
|
**留空自动回退到 per-peer**,不会因为配置错误把消息塞进空会话。
|
|
77
79
|
|
|
80
|
+
#### 5. 群聊上下文
|
|
81
|
+
|
|
82
|
+
**@bot 时能看到群里刚才聊了什么**,而不只是当前这一条。
|
|
83
|
+
|
|
84
|
+
| 项 | 说明 |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `src/chat-history.ts` | 文本历史(每会话 200 条)+ 媒体历史(50 条)+ 最后一条消息追踪 |
|
|
87
|
+
| `src/inbound.ts` | 在 @bot 检查**之前**先记录,没 @bot 的消息也会进历史 |
|
|
88
|
+
|
|
89
|
+
拼进上下文的格式:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
[群聊最近消息(按时间顺序,最后一条最旧→最新)]
|
|
93
|
+
Alice: 刚才那个方案怎么样
|
|
94
|
+
Bob: 我觉得可以
|
|
95
|
+
[以上是 @bot 之前的消息,下面是本次 @bot 的内容]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
回复完成后按 `historyLimit` 清掉已消费的部分,避免下次重复。
|
|
99
|
+
|
|
100
|
+
配置项:
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
historyLimit: 20 # 默认 20 条,0 = 关闭
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**持久化**:历史落盘到 `~/.dsh/.yuanbao-history.json`,重启不再失忆。
|
|
107
|
+
(只存文本,不存媒体 —— 媒体的 url 带签名时效,过期了也用不了。)
|
|
108
|
+
|
|
109
|
+
#### 6. 发图后立刻 @bot,自动带上那张图
|
|
110
|
+
|
|
111
|
+
**场景**:用户先发一张图(这条 @ 不到 bot),紧接着 @bot 问「这张图是什么」。
|
|
112
|
+
|
|
113
|
+
**触发条件(缺一不可):**
|
|
114
|
+
|
|
115
|
+
| 条件 | 说明 |
|
|
116
|
+
|---|---|
|
|
117
|
+
| **同一个人** | 上一条必须是当前发送者发的 |
|
|
118
|
+
| **必须是最后一条** | 中间有任何人的消息都不算,**包括自己发的文字** |
|
|
119
|
+
| **只对图片** | 文件不生效,图+文件混合也不生效 |
|
|
120
|
+
|
|
121
|
+
实现要点:`chatLastMessage` 单独追踪「最后一条消息」——文本历史只记有正文的消息,
|
|
122
|
+
纯图片会被跳过,所以需要独立的记录来判断上一条到底是什么。
|
|
123
|
+
|
|
124
|
+
#### 7. 引用消息(文字 + 媒体)
|
|
125
|
+
|
|
126
|
+
**引用信息不在 `msg_body`,而在消息级的 `cloud_custom_data`:**
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{ "quote": { "desc": "被引用的文字", "type": 2, "id": "msgId" } }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`type`:1 文字 / 2 图片 / 3 文件 / 4 视频 / 5 语音。
|
|
133
|
+
|
|
134
|
+
| 引用类型 | 处理 |
|
|
135
|
+
|---|---|
|
|
136
|
+
| **文字** | 格式化为 `> [引用消息 from 某人]: 内容` |
|
|
137
|
+
| **媒体** | `desc` 为空,按 `id` 回查媒体历史 → 换取真实 COS 地址 → 下载 → 作为附件 |
|
|
138
|
+
|
|
139
|
+
**关键坑**:入站媒体给的是 `hunyuan.tencent.com/api/resource/download?resourceId=xxx`,
|
|
140
|
+
直接 GET 返回 **401**。必须先调 `/api/resource/v1/download` 换取真正的 COS 地址。
|
|
141
|
+
|
|
142
|
+
#### 8. 设置面板持久化
|
|
143
|
+
|
|
144
|
+
设置存到 `~/.dsh/.yuanbao-settings.json`,重启不丢。
|
|
145
|
+
(为什么不写 `cordis.patch.yml`:那是静态配置,改一次要手写 YAML 再重启。)
|
|
146
|
+
|
|
78
147
|
### 二、修复的问题
|
|
79
148
|
|
|
80
149
|
#### 1. 群消息发送失败(`bot_id is required`)
|
|
@@ -144,34 +213,38 @@ Cordis 插件读取未声明的服务会抛错。新增的 `createServiceReader`
|
|
|
144
213
|
|
|
145
214
|
### 四、文件清单
|
|
146
215
|
|
|
147
|
-
**新增(
|
|
216
|
+
**新增(10 个)**
|
|
148
217
|
|
|
149
218
|
```
|
|
150
219
|
assets/builtin-stickers.json 542 行 内置表情库
|
|
151
|
-
client.js
|
|
152
|
-
src/media.ts
|
|
220
|
+
client.js 327 行 设置面板
|
|
221
|
+
src/media.ts 207 行 入站媒体
|
|
153
222
|
src/outbound-media.ts 363 行 出站媒体
|
|
154
223
|
src/services.ts 59 行 服务读取封装
|
|
155
224
|
src/stickers.ts 131 行 表情包
|
|
156
225
|
src/tools.ts 407 行 Agent 工具
|
|
226
|
+
src/chat-history.ts 363 行 文本/媒体历史、持久化
|
|
227
|
+
src/quote.ts 157 行 引用消息解析
|
|
157
228
|
src/dsh-tools.d.ts 13 行 工具类型声明
|
|
158
229
|
```
|
|
159
230
|
|
|
160
|
-
**修改(
|
|
231
|
+
**修改(13 个)**
|
|
161
232
|
|
|
162
233
|
```
|
|
163
|
-
src/
|
|
164
|
-
src/transport/sender.ts
|
|
165
|
-
src/transport/outbound.ts
|
|
166
|
-
src/session/manager.ts
|
|
167
|
-
src/
|
|
168
|
-
src/index.ts
|
|
169
|
-
src/gateway/index.ts
|
|
170
|
-
src/config.ts +
|
|
171
|
-
src/types.ts +
|
|
172
|
-
package.json
|
|
234
|
+
src/inbound.ts 主要改动 @提及判定、媒体提取、历史、自动附图
|
|
235
|
+
src/transport/sender.ts +265 发送结果传播、身份校验、COS 地址换取
|
|
236
|
+
src/transport/outbound.ts +116 媒体消息支持
|
|
237
|
+
src/session/manager.ts +104 会话路由
|
|
238
|
+
src/bridge.ts +71 复用 gateway account、注册工具
|
|
239
|
+
src/index.ts +91 服务注入、配置、历史恢复
|
|
240
|
+
src/gateway/index.ts +33 account 暴露、httpAuth
|
|
241
|
+
src/config.ts +10 会话路由、historyLimit
|
|
242
|
+
src/types.ts +10 类型定义
|
|
243
|
+
package.json +16 client 入口声明
|
|
173
244
|
```
|
|
174
245
|
|
|
246
|
+
**总计**:`23 files changed, 4012 insertions(+), 115 deletions(-)`
|
|
247
|
+
|
|
175
248
|
### 五、同步上游
|
|
176
249
|
|
|
177
250
|
```bash
|
|
@@ -394,6 +467,7 @@ agent-default-model:
|
|
|
394
467
|
| `sessionIdleTimeout` | number | `1800000` | 会话闲置超时,默认 30 分钟 |
|
|
395
468
|
| `sessionMode` | `per-peer` / `pinned` | `per-peer` | **本分支新增**:会话路由模式 |
|
|
396
469
|
| `pinnedSessionId` | string | `''` | **本分支新增**:pinned 模式下的目标会话 ID,留空回退 per-peer |
|
|
470
|
+
| `historyLimit` | number | `20` | **本分支新增**:群聊上下文条数,@bot 时带上最近 N 条;0 = 关闭 |
|
|
397
471
|
| `processingTimeoutMs` | number | `120000` | Agent 处理超时 |
|
|
398
472
|
| `maxReconnectAttempts` | number | `100` | WebSocket 最大重连次数 |
|
|
399
473
|
| `access.c2cMode` | `open` / `allowlist` / `disabled` | `open` | 私聊访问控制 |
|
package/README_EN.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-yuanbao
|
|
2
2
|
|
|
3
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
4
|
+
|
|
3
5
|
[中文](./README.md) | English
|
|
4
6
|
|
|
5
7
|
A Yuanbao IM channel plugin for [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) (dsh). It connects Yuanbao Open Platform WebSocket messages to the dsh agent runtime.
|
package/client.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* slots.inject('settings.section', () => slots.register({...}, () => h(Panel)))
|
|
14
14
|
*/
|
|
15
15
|
window.__ModuleLoader__.load({
|
|
16
|
-
id: 'dsh-yuanbao',
|
|
16
|
+
id: '@anxi78/dsh-yuanbao',
|
|
17
17
|
factory: (require) => {
|
|
18
18
|
const React = require('react')
|
|
19
19
|
const h = React.createElement
|
|
@@ -309,10 +309,10 @@ select.yb-input{font-family:inherit;cursor:pointer;appearance:none;background-im
|
|
|
309
309
|
inject: ['slots', 'sessions'],
|
|
310
310
|
apply(ctx) {
|
|
311
311
|
const style = document.createElement('style')
|
|
312
|
-
style.setAttribute('data-plugin-css', 'dsh-yuanbao')
|
|
312
|
+
style.setAttribute('data-plugin-css', '@anxi78/dsh-yuanbao')
|
|
313
313
|
style.textContent = CSS
|
|
314
314
|
document.head.appendChild(style)
|
|
315
|
-
ctx.effect(() => () => style.remove(), 'dsh-yuanbao: 设置页样式')
|
|
315
|
+
ctx.effect(() => () => style.remove(), '@anxi78/dsh-yuanbao: 设置页样式')
|
|
316
316
|
|
|
317
317
|
const slots = (typeof ctx.get === 'function' ? ctx.get('slots') : undefined) || ctx.slots
|
|
318
318
|
if (!slots) return
|
package/cordis.patch.yml
CHANGED
package/dist/chat-history.d.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* 的 desc 是空的。要还原出真实内容,必须靠本地缓存:
|
|
2
|
+
* 聊天历史
|
|
3
|
+
* =========
|
|
4
|
+
* 两类历史,生命周期不同:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 1. 文本历史(chatTextHistories)
|
|
7
|
+
* 群里每条消息都记下来,@bot 时拼进上下文,让 agent 知道刚才在聊什么。
|
|
8
|
+
* 回复完成后按 limit 清空已消费的部分。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* 2. 媒体历史(chatMediaHistories)
|
|
11
|
+
* 记录消息携带的图片/文件,供「引用消息」按 id 回查。
|
|
12
|
+
* 独立存储:@bot 后文本历史会被清理,但引用随时可能指向更早的图片。
|
|
13
|
+
*
|
|
14
|
+
* 为什么要自己维护文本历史:
|
|
15
|
+
* 元宝只把「@bot 的那条消息」推给插件,群里的其他消息插件根本收不到。
|
|
16
|
+
* 等等——实际上群消息都会推送,只是没有 @ 时被 requireMention 丢掉了。
|
|
17
|
+
* 所以在丢弃之前先记进历史,@bot 时就能带上上下文。
|
|
13
18
|
*/
|
|
14
19
|
import type { InboundMedia } from './media.js';
|
|
15
20
|
/** 一条媒体历史记录 */
|
|
@@ -27,13 +32,80 @@ export type MediaHistoryEntry = {
|
|
|
27
32
|
kind?: 'image' | 'file';
|
|
28
33
|
}>;
|
|
29
34
|
};
|
|
35
|
+
/** 一条文本历史记录 */
|
|
36
|
+
export type TextHistoryEntry = {
|
|
37
|
+
/** 消息 id */
|
|
38
|
+
messageId?: string;
|
|
39
|
+
/** 发送者昵称(优先)或账号 */
|
|
40
|
+
sender: string;
|
|
41
|
+
/** 发送者账号(稳定标识) */
|
|
42
|
+
senderId?: string;
|
|
43
|
+
/** 消息正文(已去除 @bot 的部分) */
|
|
44
|
+
body: string;
|
|
45
|
+
/** 消息时间戳(ms) */
|
|
46
|
+
timestamp: number;
|
|
47
|
+
};
|
|
30
48
|
/** chatKey → 媒体历史 */
|
|
31
49
|
export declare const chatMediaHistories: Map<string, MediaHistoryEntry[]>;
|
|
50
|
+
/** chatKey → 文本历史 */
|
|
51
|
+
export declare const chatTextHistories: Map<string, TextHistoryEntry[]>;
|
|
52
|
+
/**
|
|
53
|
+
* chatKey → 该会话收到的**最后一条消息**的元信息。
|
|
54
|
+
*
|
|
55
|
+
* 和 chatTextHistories 的区别:文本历史只记有正文的消息,
|
|
56
|
+
* 纯图片消息没有正文会被跳过。但「发图后立刻 @bot」需要知道
|
|
57
|
+
* 上一条消息**到底是什么**(哪怕它没有文字),所以单独记一份。
|
|
58
|
+
*/
|
|
59
|
+
export type LastMessageEntry = {
|
|
60
|
+
/** 消息 id */
|
|
61
|
+
messageId?: string;
|
|
62
|
+
/** 发送者账号(稳定标识,用于判断是不是同一个人) */
|
|
63
|
+
senderId?: string;
|
|
64
|
+
/** 发送者昵称 */
|
|
65
|
+
senderNickname?: string;
|
|
66
|
+
/** 这条消息携带的媒体类型(image/file/undefined) */
|
|
67
|
+
mediaKinds: Array<'image' | 'file'>;
|
|
68
|
+
/** 媒体列表(供自动附图使用) */
|
|
69
|
+
medias: Array<{
|
|
70
|
+
url: string;
|
|
71
|
+
name?: string;
|
|
72
|
+
kind?: 'image' | 'file';
|
|
73
|
+
}>;
|
|
74
|
+
/** 记录时间 */
|
|
75
|
+
timestamp: number;
|
|
76
|
+
};
|
|
77
|
+
export declare const chatLastMessage: Map<string, LastMessageEntry>;
|
|
32
78
|
/**
|
|
33
79
|
* 生成会话级缓存键。
|
|
34
80
|
* 群聊用 group:{groupCode},私聊用 direct:{fromAccount}。
|
|
35
81
|
*/
|
|
36
82
|
export declare function deriveChatKey(isGroup: boolean, groupCode?: string, fromAccount?: string): string;
|
|
83
|
+
/**
|
|
84
|
+
* 记录一条群聊文本消息。
|
|
85
|
+
*
|
|
86
|
+
* 空正文(比如纯图片消息)不记 —— 图片走媒体历史,
|
|
87
|
+
* 这里记了也只是空行,反而污染上下文。
|
|
88
|
+
*/
|
|
89
|
+
export declare function rememberText(chatKey: string, entry: {
|
|
90
|
+
messageId?: string;
|
|
91
|
+
sender: string;
|
|
92
|
+
senderId?: string;
|
|
93
|
+
body: string;
|
|
94
|
+
}): void;
|
|
95
|
+
/**
|
|
96
|
+
* 取出最近 limit 条文本历史。
|
|
97
|
+
*
|
|
98
|
+
* 返回的是**当前消息之前**的历史 —— 调用方应在记录当前消息之前调用,
|
|
99
|
+
* 否则当前消息会重复出现(一次在历史里,一次在正文里)。
|
|
100
|
+
*/
|
|
101
|
+
export declare function recentText(chatKey: string, limit: number): TextHistoryEntry[];
|
|
102
|
+
/**
|
|
103
|
+
* 消费掉最近 limit 条历史。
|
|
104
|
+
*
|
|
105
|
+
* 回复完成后调用:这些内容已经进了 agent 的上下文,
|
|
106
|
+
* 下次再拼一遍就是重复。
|
|
107
|
+
*/
|
|
108
|
+
export declare function consumeText(chatKey: string, limit: number): void;
|
|
37
109
|
/**
|
|
38
110
|
* 记录一条消息携带的媒体。
|
|
39
111
|
*
|
|
@@ -50,5 +122,39 @@ export declare function rememberMedia(chatKey: string, entry: {
|
|
|
50
122
|
* 从后往前找 —— 引用通常指向较近的消息。
|
|
51
123
|
*/
|
|
52
124
|
export declare function findMediaByMessageId(chatKey: string, messageId: string | undefined): MediaHistoryEntry | undefined;
|
|
53
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* 记录「这个会话收到的最后一条消息」。
|
|
127
|
+
*
|
|
128
|
+
* 每条消息都要调(包括没有正文的纯图片消息),
|
|
129
|
+
* 这样「发图后立刻 @bot」才能判断上一条到底是什么。
|
|
130
|
+
*/
|
|
131
|
+
export declare function rememberLastMessage(chatKey: string, entry: {
|
|
132
|
+
messageId?: string;
|
|
133
|
+
senderId?: string;
|
|
134
|
+
senderNickname?: string;
|
|
135
|
+
medias: InboundMedia[];
|
|
136
|
+
}): void;
|
|
137
|
+
/** 取这个会话的最后一条消息(没有则 undefined) */
|
|
138
|
+
export declare function lastMessage(chatKey: string): LastMessageEntry | undefined;
|
|
139
|
+
/**
|
|
140
|
+
* 取最近一条媒体记录。
|
|
141
|
+
*
|
|
142
|
+
* 用于「发图后立刻 @bot」的自动附图机制:需要判断上一条消息
|
|
143
|
+
* 是不是同一人发的图片。只看最后一条,不做模糊匹配。
|
|
144
|
+
*/
|
|
145
|
+
export declare function latestMedia(chatKey: string): MediaHistoryEntry | undefined;
|
|
146
|
+
/** 清空某个会话的全部历史(会话结束时调用) */
|
|
54
147
|
export declare function clearChatHistory(chatKey: string): void;
|
|
148
|
+
/** 安排一次防抖写盘(200ms 内的多次改动合并成一次) */
|
|
149
|
+
export declare function schedulePersist(logger?: {
|
|
150
|
+
warn: (m: string) => void;
|
|
151
|
+
}): void;
|
|
152
|
+
/**
|
|
153
|
+
* 启动时从磁盘恢复文本历史。
|
|
154
|
+
*
|
|
155
|
+
* 只恢复最近的条目,过旧的历史带进上下文反而干扰。
|
|
156
|
+
*/
|
|
157
|
+
export declare function loadPersistedHistory(logger?: {
|
|
158
|
+
info: (m: string) => void;
|
|
159
|
+
warn: (m: string) => void;
|
|
160
|
+
}): Promise<void>;
|
package/dist/chat-history.js
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* 的 desc 是空的。要还原出真实内容,必须靠本地缓存:
|
|
2
|
+
* 聊天历史
|
|
3
|
+
* =========
|
|
4
|
+
* 两类历史,生命周期不同:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 1. 文本历史(chatTextHistories)
|
|
7
|
+
* 群里每条消息都记下来,@bot 时拼进上下文,让 agent 知道刚才在聊什么。
|
|
8
|
+
* 回复完成后按 limit 清空已消费的部分。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* 2. 媒体历史(chatMediaHistories)
|
|
11
|
+
* 记录消息携带的图片/文件,供「引用消息」按 id 回查。
|
|
12
|
+
* 独立存储:@bot 后文本历史会被清理,但引用随时可能指向更早的图片。
|
|
13
|
+
*
|
|
14
|
+
* 为什么要自己维护文本历史:
|
|
15
|
+
* 元宝只把「@bot 的那条消息」推给插件,群里的其他消息插件根本收不到。
|
|
16
|
+
* 等等——实际上群消息都会推送,只是没有 @ 时被 requireMention 丢掉了。
|
|
17
|
+
* 所以在丢弃之前先记进历史,@bot 时就能带上上下文。
|
|
13
18
|
*/
|
|
14
|
-
/**
|
|
15
|
-
const
|
|
19
|
+
/** 媒体历史每个会话保留多少条 */
|
|
20
|
+
const MEDIA_MAX_PER_CHAT = 50;
|
|
21
|
+
/** 文本历史每个会话最多保留多少条(防止无限增长) */
|
|
22
|
+
const TEXT_MAX_PER_CHAT = 200;
|
|
16
23
|
/** chatKey → 媒体历史 */
|
|
17
24
|
export const chatMediaHistories = new Map();
|
|
25
|
+
/** chatKey → 文本历史 */
|
|
26
|
+
export const chatTextHistories = new Map();
|
|
27
|
+
export const chatLastMessage = new Map();
|
|
18
28
|
/**
|
|
19
29
|
* 生成会话级缓存键。
|
|
20
30
|
* 群聊用 group:{groupCode},私聊用 direct:{fromAccount}。
|
|
@@ -24,6 +34,68 @@ export function deriveChatKey(isGroup, groupCode, fromAccount) {
|
|
|
24
34
|
return `group:${groupCode}`;
|
|
25
35
|
return `direct:${fromAccount || 'unknown'}`;
|
|
26
36
|
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// 文本历史
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
/**
|
|
41
|
+
* 记录一条群聊文本消息。
|
|
42
|
+
*
|
|
43
|
+
* 空正文(比如纯图片消息)不记 —— 图片走媒体历史,
|
|
44
|
+
* 这里记了也只是空行,反而污染上下文。
|
|
45
|
+
*/
|
|
46
|
+
export function rememberText(chatKey, entry) {
|
|
47
|
+
const body = (entry.body || '').trim();
|
|
48
|
+
if (!body)
|
|
49
|
+
return;
|
|
50
|
+
const list = chatTextHistories.get(chatKey) ?? [];
|
|
51
|
+
list.push({
|
|
52
|
+
messageId: entry.messageId,
|
|
53
|
+
sender: entry.sender,
|
|
54
|
+
senderId: entry.senderId,
|
|
55
|
+
body,
|
|
56
|
+
timestamp: Date.now(),
|
|
57
|
+
});
|
|
58
|
+
while (list.length > TEXT_MAX_PER_CHAT)
|
|
59
|
+
list.shift();
|
|
60
|
+
chatTextHistories.set(chatKey, list);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 取出最近 limit 条文本历史。
|
|
64
|
+
*
|
|
65
|
+
* 返回的是**当前消息之前**的历史 —— 调用方应在记录当前消息之前调用,
|
|
66
|
+
* 否则当前消息会重复出现(一次在历史里,一次在正文里)。
|
|
67
|
+
*/
|
|
68
|
+
export function recentText(chatKey, limit) {
|
|
69
|
+
if (limit <= 0)
|
|
70
|
+
return [];
|
|
71
|
+
const list = chatTextHistories.get(chatKey);
|
|
72
|
+
if (!list || list.length === 0)
|
|
73
|
+
return [];
|
|
74
|
+
return list.slice(-limit);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 消费掉最近 limit 条历史。
|
|
78
|
+
*
|
|
79
|
+
* 回复完成后调用:这些内容已经进了 agent 的上下文,
|
|
80
|
+
* 下次再拼一遍就是重复。
|
|
81
|
+
*/
|
|
82
|
+
export function consumeText(chatKey, limit) {
|
|
83
|
+
if (limit <= 0) {
|
|
84
|
+
chatTextHistories.delete(chatKey);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const list = chatTextHistories.get(chatKey);
|
|
88
|
+
if (!list)
|
|
89
|
+
return;
|
|
90
|
+
if (list.length <= limit) {
|
|
91
|
+
chatTextHistories.delete(chatKey);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
chatTextHistories.set(chatKey, list.slice(0, list.length - limit));
|
|
95
|
+
}
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// 媒体历史
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
27
99
|
/**
|
|
28
100
|
* 记录一条消息携带的媒体。
|
|
29
101
|
*
|
|
@@ -43,7 +115,7 @@ export function rememberMedia(chatKey, entry) {
|
|
|
43
115
|
medias,
|
|
44
116
|
});
|
|
45
117
|
// LRU:超出上限就丢最旧的
|
|
46
|
-
while (list.length >
|
|
118
|
+
while (list.length > MEDIA_MAX_PER_CHAT)
|
|
47
119
|
list.shift();
|
|
48
120
|
chatMediaHistories.set(chatKey, list);
|
|
49
121
|
}
|
|
@@ -65,7 +137,139 @@ export function findMediaByMessageId(chatKey, messageId) {
|
|
|
65
137
|
}
|
|
66
138
|
return undefined;
|
|
67
139
|
}
|
|
68
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* 记录「这个会话收到的最后一条消息」。
|
|
142
|
+
*
|
|
143
|
+
* 每条消息都要调(包括没有正文的纯图片消息),
|
|
144
|
+
* 这样「发图后立刻 @bot」才能判断上一条到底是什么。
|
|
145
|
+
*/
|
|
146
|
+
export function rememberLastMessage(chatKey, entry) {
|
|
147
|
+
const medias = (entry.medias || [])
|
|
148
|
+
.filter(m => m.url)
|
|
149
|
+
.map(m => ({ url: m.url, name: m.name, kind: m.kind }));
|
|
150
|
+
chatLastMessage.set(chatKey, {
|
|
151
|
+
messageId: entry.messageId,
|
|
152
|
+
senderId: entry.senderId,
|
|
153
|
+
senderNickname: entry.senderNickname,
|
|
154
|
+
mediaKinds: medias.map(m => m.kind).filter((k) => Boolean(k)),
|
|
155
|
+
medias,
|
|
156
|
+
timestamp: Date.now(),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** 取这个会话的最后一条消息(没有则 undefined) */
|
|
160
|
+
export function lastMessage(chatKey) {
|
|
161
|
+
return chatLastMessage.get(chatKey);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 取最近一条媒体记录。
|
|
165
|
+
*
|
|
166
|
+
* 用于「发图后立刻 @bot」的自动附图机制:需要判断上一条消息
|
|
167
|
+
* 是不是同一人发的图片。只看最后一条,不做模糊匹配。
|
|
168
|
+
*/
|
|
169
|
+
export function latestMedia(chatKey) {
|
|
170
|
+
const list = chatMediaHistories.get(chatKey);
|
|
171
|
+
if (!list || list.length === 0)
|
|
172
|
+
return undefined;
|
|
173
|
+
return list[list.length - 1];
|
|
174
|
+
}
|
|
175
|
+
/** 清空某个会话的全部历史(会话结束时调用) */
|
|
69
176
|
export function clearChatHistory(chatKey) {
|
|
70
177
|
chatMediaHistories.delete(chatKey);
|
|
178
|
+
chatTextHistories.delete(chatKey);
|
|
179
|
+
chatLastMessage.delete(chatKey);
|
|
180
|
+
}
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// 持久化
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// 历史原本只在内存里,DSH 一重启就全没了。
|
|
185
|
+
// 这里落盘到 ~/.dsh/.yuanbao-history.json,启动时读回。
|
|
186
|
+
//
|
|
187
|
+
// 设计取舍:
|
|
188
|
+
// · 只存文本历史,不存媒体历史 —— 媒体的 url 有签名时效,过期了也用不了
|
|
189
|
+
// · 写入做防抖,避免每条消息都碰一次磁盘
|
|
190
|
+
// · 读取失败静默忽略,历史丢失不能阻断插件启动
|
|
191
|
+
const HISTORY_FILE = '.yuanbao-history.json';
|
|
192
|
+
/** 每个会话最多落盘多少条(比内存上限小,控制文件体积) */
|
|
193
|
+
const PERSIST_MAX_PER_CHAT = 50;
|
|
194
|
+
function historyPath() {
|
|
195
|
+
const home = process.env.DSH_HOME
|
|
196
|
+
|| (process.env.HOME ? `${process.env.HOME}/.dsh` : '');
|
|
197
|
+
if (!home)
|
|
198
|
+
return '';
|
|
199
|
+
return `${home}/${HISTORY_FILE}`;
|
|
200
|
+
}
|
|
201
|
+
let persistTimer;
|
|
202
|
+
let persistPending = false;
|
|
203
|
+
/** 把当前文本历史写入磁盘(异步、防抖、失败只警告) */
|
|
204
|
+
async function flushHistory(logger) {
|
|
205
|
+
const file = historyPath();
|
|
206
|
+
if (!file)
|
|
207
|
+
return;
|
|
208
|
+
const chats = {};
|
|
209
|
+
for (const [key, list] of chatTextHistories) {
|
|
210
|
+
if (list.length === 0)
|
|
211
|
+
continue;
|
|
212
|
+
chats[key] = list.slice(-PERSIST_MAX_PER_CHAT);
|
|
213
|
+
}
|
|
214
|
+
const payload = { version: 1, chats };
|
|
215
|
+
try {
|
|
216
|
+
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
217
|
+
const { dirname } = await import('node:path');
|
|
218
|
+
await mkdir(dirname(file), { recursive: true });
|
|
219
|
+
await writeFile(file, `${JSON.stringify(payload)}\n`, 'utf8');
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
logger?.warn(`history persist failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** 安排一次防抖写盘(200ms 内的多次改动合并成一次) */
|
|
226
|
+
export function schedulePersist(logger) {
|
|
227
|
+
if (!historyPath())
|
|
228
|
+
return;
|
|
229
|
+
persistPending = true;
|
|
230
|
+
if (persistTimer)
|
|
231
|
+
return;
|
|
232
|
+
persistTimer = setTimeout(() => {
|
|
233
|
+
persistTimer = undefined;
|
|
234
|
+
if (!persistPending)
|
|
235
|
+
return;
|
|
236
|
+
persistPending = false;
|
|
237
|
+
void flushHistory(logger);
|
|
238
|
+
}, 200);
|
|
239
|
+
// 不让定时器拖住进程退出
|
|
240
|
+
if (typeof persistTimer === 'object' && persistTimer && 'unref' in persistTimer) {
|
|
241
|
+
persistTimer.unref();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* 启动时从磁盘恢复文本历史。
|
|
246
|
+
*
|
|
247
|
+
* 只恢复最近的条目,过旧的历史带进上下文反而干扰。
|
|
248
|
+
*/
|
|
249
|
+
export async function loadPersistedHistory(logger) {
|
|
250
|
+
const file = historyPath();
|
|
251
|
+
if (!file)
|
|
252
|
+
return;
|
|
253
|
+
try {
|
|
254
|
+
const { readFile } = await import('node:fs/promises');
|
|
255
|
+
const raw = await readFile(file, 'utf8');
|
|
256
|
+
const parsed = JSON.parse(raw);
|
|
257
|
+
if (!parsed || parsed.version !== 1 || !parsed.chats)
|
|
258
|
+
return;
|
|
259
|
+
let restored = 0;
|
|
260
|
+
for (const [key, list] of Object.entries(parsed.chats)) {
|
|
261
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
262
|
+
continue;
|
|
263
|
+
chatTextHistories.set(key, list.slice(-TEXT_MAX_PER_CHAT));
|
|
264
|
+
restored += list.length;
|
|
265
|
+
}
|
|
266
|
+
logger?.info(`history restored: ${restored} entries across ${Object.keys(parsed.chats).length} chats`);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
// 文件不存在是正常的首次启动
|
|
270
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
271
|
+
if (!msg.includes('ENOENT')) {
|
|
272
|
+
logger?.warn(`history load failed: ${msg}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
71
275
|
}
|
package/dist/config.js
CHANGED
|
@@ -26,6 +26,9 @@ const schemaObject = Schema.object({
|
|
|
26
26
|
pinnedSessionId: Schema.string()
|
|
27
27
|
.default('')
|
|
28
28
|
.description('sessionMode=pinned 时使用的会话 ID(在 DSH 会话列表里能看到,形如 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)。留空则回退到 per-peer'),
|
|
29
|
+
historyLimit: Schema.number()
|
|
30
|
+
.default(20)
|
|
31
|
+
.description('群聊上下文条数:@bot 时把最近 N 条群消息一起带给 agent。设为 0 关闭'),
|
|
29
32
|
processingTimeoutMs: Schema.number().default(120000).description('Agent processing timeout in ms'),
|
|
30
33
|
maxReconnectAttempts: Schema.number().default(100).description('Maximum WebSocket reconnect attempts'),
|
|
31
34
|
access: Schema.object({
|