@cmach_socket/opencode-qq-bot 0.0.0-stage → 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/LICENSE +443 -0
- package/README.md +210 -2
- package/package.json +47 -4
- package/souls/default.txt +8 -0
- package/src/chat.ts +44 -0
- package/src/config.ts +449 -0
- package/src/index.ts +251 -0
- package/src/instance.ts +60 -0
- package/src/logger.ts +30 -0
- package/src/naplink/client.ts +118 -0
- package/src/naplink/media.ts +311 -0
- package/src/naplink/render.ts +440 -0
- package/src/session/manager.ts +628 -0
- package/src/session/proactive.ts +22 -0
- package/src/session/prompt.ts +41 -0
- package/src/session/soul.ts +180 -0
- package/src/tools/ask-guard.ts +32 -0
- package/src/tools/qq.ts +421 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
import { type ChatTarget, isTargetEnabled, parseTargetKey, targetKey, targetLabel } from "../chat"
|
|
3
|
+
import type { QQBotConfig } from "../config"
|
|
4
|
+
import type { Logger } from "../logger"
|
|
5
|
+
import { limitFiles, type PromptFile } from "../naplink/render"
|
|
6
|
+
|
|
7
|
+
/** 插件存储里保存会话与目标绑定的键。 */
|
|
8
|
+
const BINDINGS_KEY = "bindings"
|
|
9
|
+
/** 插件存储里保存目标最近活跃时间的键。 */
|
|
10
|
+
const ACTIVITY_KEY = "activity"
|
|
11
|
+
/** 单个目标最多缓存的消息条数。 */
|
|
12
|
+
export const MAX_BUFFER_TURNS = 100
|
|
13
|
+
/** 单个目标最多缓存的文本与附件字节数。 */
|
|
14
|
+
export const MAX_BUFFER_BYTES = 32 * 1024 * 1024
|
|
15
|
+
/** 每个目标最多记录的消息 ID 数量。 */
|
|
16
|
+
const MAX_TRACKED_MESSAGES = 200
|
|
17
|
+
|
|
18
|
+
export interface Turn {
|
|
19
|
+
/** 交给模型的文本。 */
|
|
20
|
+
text: string
|
|
21
|
+
/** 图片附件。 */
|
|
22
|
+
files: PromptFile[]
|
|
23
|
+
/** 本轮是否 @ 了机器人。 */
|
|
24
|
+
atMe: boolean
|
|
25
|
+
/** 是否由主动说话心跳发起。 */
|
|
26
|
+
proactive?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ChatState {
|
|
30
|
+
target: ChatTarget
|
|
31
|
+
sessionID?: string
|
|
32
|
+
/** 会话是否正在跑模型(收到消息到 idle 之间)。 */
|
|
33
|
+
busy: boolean
|
|
34
|
+
/** 进入忙碌状态的时间戳(毫秒),用于识别卡死的会话。 */
|
|
35
|
+
busySince: number
|
|
36
|
+
/** 忙碌超时看门狗。 */
|
|
37
|
+
busyTimer?: ReturnType<typeof setTimeout>
|
|
38
|
+
/** 忙碌期间攒下的消息,空闲后合并成一次 prompt。 */
|
|
39
|
+
buffer: Turn[]
|
|
40
|
+
/** 当前轮次的消息,供系统提示注入 @ 提示。 */
|
|
41
|
+
current?: Turn
|
|
42
|
+
/** 当前轮次模型是否还没调用发送工具。 */
|
|
43
|
+
awaitingSend: boolean
|
|
44
|
+
/** 连续提醒次数,防止死循环。 */
|
|
45
|
+
reminders: number
|
|
46
|
+
/** 最近一次真实用户消息的时间戳(毫秒)。 */
|
|
47
|
+
lastUserMessageAt: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type SessionListCursor = NonNullable<Parameters<Plugin.Context["session"]["list"]>[0]>["cursor"]
|
|
51
|
+
|
|
52
|
+
export function mergeTurns(turns: readonly Turn[]): Turn {
|
|
53
|
+
return {
|
|
54
|
+
text: turns
|
|
55
|
+
.map((turn) => turn.text.trim())
|
|
56
|
+
.filter((text) => text.length > 0)
|
|
57
|
+
.join("\n"),
|
|
58
|
+
files: limitFiles(turns.flatMap((turn) => turn.files)),
|
|
59
|
+
atMe: turns.some((turn) => turn.atMe),
|
|
60
|
+
...(turns.some((turn) => turn.proactive) ? { proactive: true } : {}),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function turnBytes(turn: Turn): number {
|
|
65
|
+
return (
|
|
66
|
+
turn.text.length * 2 +
|
|
67
|
+
turn.files.reduce((total, file) => total + (file.bytes ?? file.uri.length), 0)
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 管理 QQ 群聊/私聊与 OpenCode 会话的映射。
|
|
73
|
+
*
|
|
74
|
+
* 一个目标固定对应一个会话,一个会话只能绑定一个目标;
|
|
75
|
+
* 会话忙碌时新到的消息先缓存,等会话真正空闲之后再合并成新的一轮 prompt 发进去。
|
|
76
|
+
*/
|
|
77
|
+
export class SessionManager {
|
|
78
|
+
private readonly states = new Map<string, ChatState>()
|
|
79
|
+
private readonly sessions = new Map<string, ChatState>()
|
|
80
|
+
private readonly ensureRequests = new Map<string, Promise<string | undefined>>()
|
|
81
|
+
private readonly verifiedSessions = new Set<string>()
|
|
82
|
+
private readonly outgoing = new Map<string, Set<string>>()
|
|
83
|
+
private readonly incoming = new Map<string, Set<string>>()
|
|
84
|
+
private activity: Record<string, number> = {}
|
|
85
|
+
private bindingsLoaded: Promise<void> | undefined
|
|
86
|
+
private saveQueue: Promise<void> = Promise.resolve()
|
|
87
|
+
|
|
88
|
+
constructor(
|
|
89
|
+
private readonly ctx: Plugin.Context,
|
|
90
|
+
private readonly config: QQBotConfig,
|
|
91
|
+
private readonly logger: Logger,
|
|
92
|
+
) {}
|
|
93
|
+
|
|
94
|
+
/** 载入上次运行时保存的目标与会话绑定。 */
|
|
95
|
+
async load(): Promise<void> {
|
|
96
|
+
await this.ensureBindings()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private ensureBindings(): Promise<void> {
|
|
100
|
+
this.bindingsLoaded ??= this.readBindings()
|
|
101
|
+
return this.bindingsLoaded
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 从插件存储恢复绑定;读取失败只告警,不影响正常使用。 */
|
|
105
|
+
private async readBindings(): Promise<void> {
|
|
106
|
+
let stored: unknown
|
|
107
|
+
try {
|
|
108
|
+
stored = await this.ctx.storage.get(BINDINGS_KEY)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
this.logger.warn("读取会话绑定失败", error)
|
|
111
|
+
}
|
|
112
|
+
if (stored && typeof stored === "object" && !Array.isArray(stored)) {
|
|
113
|
+
let restored = 0
|
|
114
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
115
|
+
const target = parseTargetKey(key)
|
|
116
|
+
if (!target || typeof value !== "string" || value.length === 0) continue
|
|
117
|
+
// 配置里已经移除的目标不再恢复,避免旧绑定继续获得工具权限。
|
|
118
|
+
if (!isTargetEnabled(this.config, target)) continue
|
|
119
|
+
if (!this.bindSession(this.state(target), value)) continue
|
|
120
|
+
restored += 1
|
|
121
|
+
}
|
|
122
|
+
if (restored > 0) this.logger.debug(`已恢复 ${restored} 个目标的会话绑定`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const activity = await this.ctx.storage.get(ACTIVITY_KEY)
|
|
127
|
+
if (!activity || typeof activity !== "object" || Array.isArray(activity)) return
|
|
128
|
+
for (const [key, value] of Object.entries(activity)) {
|
|
129
|
+
const target = parseTargetKey(key)
|
|
130
|
+
if (!target || typeof value !== "number" || !Number.isFinite(value)) continue
|
|
131
|
+
if (!isTargetEnabled(this.config, target)) continue
|
|
132
|
+
this.activity[targetKey(target)] = value
|
|
133
|
+
const state = this.states.get(targetKey(target))
|
|
134
|
+
if (state) state.lastUserMessageAt = value
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
this.logger.warn("读取目标活跃时间失败", error)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 绑定或活跃时间变化后写回插件存储,串行执行避免互相覆盖。 */
|
|
142
|
+
private scheduleSave(): void {
|
|
143
|
+
this.saveQueue = this.saveQueue.then(() => this.persist())
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async persist(): Promise<void> {
|
|
147
|
+
await this.writeBindings()
|
|
148
|
+
await this.writeActivity()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async writeBindings(): Promise<void> {
|
|
152
|
+
const bindings: Record<string, string> = {}
|
|
153
|
+
for (const state of this.states.values()) {
|
|
154
|
+
if (state.sessionID) bindings[targetKey(state.target)] = state.sessionID
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
await this.ctx.storage.set(BINDINGS_KEY, bindings)
|
|
158
|
+
} catch (error) {
|
|
159
|
+
this.logger.error("保存会话绑定失败", error)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async writeActivity(): Promise<void> {
|
|
164
|
+
try {
|
|
165
|
+
await this.ctx.storage.set(ACTIVITY_KEY, { ...this.activity })
|
|
166
|
+
} catch (error) {
|
|
167
|
+
this.logger.error("保存目标活跃时间失败", error)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 等待所有待写入的绑定落盘。 */
|
|
172
|
+
async flush(): Promise<void> {
|
|
173
|
+
await this.saveQueue
|
|
174
|
+
await this.persist()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private state(target: ChatTarget): ChatState {
|
|
178
|
+
const key = targetKey(target)
|
|
179
|
+
let state = this.states.get(key)
|
|
180
|
+
if (!state) {
|
|
181
|
+
state = {
|
|
182
|
+
target,
|
|
183
|
+
busy: false,
|
|
184
|
+
busySince: 0,
|
|
185
|
+
buffer: [],
|
|
186
|
+
awaitingSend: false,
|
|
187
|
+
reminders: 0,
|
|
188
|
+
lastUserMessageAt: this.activity[key] ?? Date.now(),
|
|
189
|
+
}
|
|
190
|
+
this.states.set(key, state)
|
|
191
|
+
}
|
|
192
|
+
return state
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private beginBusy(state: ChatState): void {
|
|
196
|
+
state.busy = true
|
|
197
|
+
state.busySince = Date.now()
|
|
198
|
+
this.armBusyTimer(state)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private armBusyTimer(state: ChatState): void {
|
|
202
|
+
this.clearBusyTimer(state)
|
|
203
|
+
state.busyTimer = setTimeout(() => {
|
|
204
|
+
void this.recoverTarget(state.target)
|
|
205
|
+
}, this.config.busyTimeoutMs)
|
|
206
|
+
const timer = state.busyTimer as { unref?: () => void }
|
|
207
|
+
timer.unref?.()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private clearBusyTimer(state: ChatState): void {
|
|
211
|
+
if (state.busyTimer === undefined) return
|
|
212
|
+
clearTimeout(state.busyTimer)
|
|
213
|
+
state.busyTimer = undefined
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private endBusy(state: ChatState): void {
|
|
217
|
+
state.busy = false
|
|
218
|
+
state.busySince = 0
|
|
219
|
+
this.clearBusyTimer(state)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 会话 ID 反查目标。 */
|
|
223
|
+
targetForSession(sessionID: string): ChatTarget | undefined {
|
|
224
|
+
return this.sessions.get(sessionID)?.target
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 当前轮次的消息。 */
|
|
228
|
+
currentTurn(sessionID: string): Turn | undefined {
|
|
229
|
+
return this.sessions.get(sessionID)?.current
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 当前是否有 QQ 消息轮次正在执行,用于阻止手动 prompt 调用 QQ 工具。 */
|
|
233
|
+
hasActiveTurn(sessionID: string): boolean {
|
|
234
|
+
return this.sessions.get(sessionID)?.current !== undefined
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 标记模型已经调用过发送工具。 */
|
|
238
|
+
markMessageSent(sessionID: string): void {
|
|
239
|
+
const state = this.sessions.get(sessionID)
|
|
240
|
+
if (!state) return
|
|
241
|
+
state.awaitingSend = false
|
|
242
|
+
state.reminders = 0
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 记录一条机器人发出的消息,撤回工具只能操作这些消息。 */
|
|
246
|
+
rememberOutgoing(target: ChatTarget, messageId: string): void {
|
|
247
|
+
const key = targetKey(target)
|
|
248
|
+
const ids = this.outgoing.get(key) ?? new Set<string>()
|
|
249
|
+
ids.add(messageId)
|
|
250
|
+
while (ids.size > MAX_TRACKED_MESSAGES) {
|
|
251
|
+
const oldest = ids.values().next().value
|
|
252
|
+
if (oldest === undefined) break
|
|
253
|
+
ids.delete(oldest)
|
|
254
|
+
}
|
|
255
|
+
this.outgoing.set(key, ids)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 判断消息是否是机器人发出的。 */
|
|
259
|
+
isOutgoing(target: ChatTarget, messageId: string): boolean {
|
|
260
|
+
return this.outgoing.get(targetKey(target))?.has(messageId) ?? false
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 记录一条收到的消息,语音转写工具只能操作这些消息。 */
|
|
264
|
+
rememberIncoming(target: ChatTarget, messageId: string): void {
|
|
265
|
+
const key = targetKey(target)
|
|
266
|
+
const ids = this.incoming.get(key) ?? new Set<string>()
|
|
267
|
+
ids.add(messageId)
|
|
268
|
+
while (ids.size > MAX_TRACKED_MESSAGES) {
|
|
269
|
+
const oldest = ids.values().next().value
|
|
270
|
+
if (oldest === undefined) break
|
|
271
|
+
ids.delete(oldest)
|
|
272
|
+
}
|
|
273
|
+
this.incoming.set(key, ids)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 判断消息是否属于当前目标最近收到的消息。 */
|
|
277
|
+
isKnownMessage(target: ChatTarget, messageId: string): boolean {
|
|
278
|
+
return (
|
|
279
|
+
this.isOutgoing(target, messageId) ||
|
|
280
|
+
(this.incoming.get(targetKey(target))?.has(messageId) ?? false)
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 记录一次真实用户消息的时间。 */
|
|
285
|
+
touch(target: ChatTarget): void {
|
|
286
|
+
const state = this.state(target)
|
|
287
|
+
state.lastUserMessageAt = Date.now()
|
|
288
|
+
this.activity[targetKey(target)] = state.lastUserMessageAt
|
|
289
|
+
this.scheduleSave()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 距上次用户消息的沉默时长(秒)。 */
|
|
293
|
+
silenceSeconds(target: ChatTarget): number {
|
|
294
|
+
return (Date.now() - this.state(target).lastUserMessageAt) / 1000
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 收到一条消息:空闲则直接投进会话,忙则先缓存。
|
|
299
|
+
*/
|
|
300
|
+
async enqueue(target: ChatTarget, turn: Turn): Promise<void> {
|
|
301
|
+
const state = this.state(target)
|
|
302
|
+
if (state.busy) {
|
|
303
|
+
this.pushBuffered(state, turn)
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
await this.dispatch(state, turn)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private pushBuffered(state: ChatState, turn: Turn): void {
|
|
310
|
+
state.buffer.push(turn)
|
|
311
|
+
let bytes = state.buffer.reduce((total, item) => total + turnBytes(item), 0)
|
|
312
|
+
while (
|
|
313
|
+
state.buffer.length > 1 &&
|
|
314
|
+
(state.buffer.length > MAX_BUFFER_TURNS || bytes > MAX_BUFFER_BYTES)
|
|
315
|
+
) {
|
|
316
|
+
const dropped = state.buffer.shift()
|
|
317
|
+
if (!dropped) break
|
|
318
|
+
bytes -= turnBytes(dropped)
|
|
319
|
+
this.logger.warn(`${targetLabel(state.target)} 缓存已满,丢弃最早的一条消息`)
|
|
320
|
+
}
|
|
321
|
+
if (state.buffer.length === 1 && bytes > MAX_BUFFER_BYTES) {
|
|
322
|
+
this.logger.warn(`${targetLabel(state.target)} 单条消息超过缓存上限,仍保留该消息`)
|
|
323
|
+
}
|
|
324
|
+
this.logger.debug(
|
|
325
|
+
`${targetLabel(state.target)} 忙碌中,消息已缓存(累计 ${state.buffer.length} 条)`,
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 把一轮消息投进 OpenCode 会话。 */
|
|
330
|
+
private async dispatch(state: ChatState, turn: Turn): Promise<void> {
|
|
331
|
+
if (state.busy) return
|
|
332
|
+
// 先同步占位,避免并发消息重复投递
|
|
333
|
+
this.beginBusy(state)
|
|
334
|
+
state.current = turn
|
|
335
|
+
state.awaitingSend = true
|
|
336
|
+
// 新的用户轮次重新给一份提醒预算,否则连续几轮不调工具就再也提醒不了。
|
|
337
|
+
state.reminders = 0
|
|
338
|
+
const label = targetLabel(state.target)
|
|
339
|
+
try {
|
|
340
|
+
const sessionID = await this.ensureSession(state.target)
|
|
341
|
+
if (!sessionID) {
|
|
342
|
+
this.endBusy(state)
|
|
343
|
+
state.current = undefined
|
|
344
|
+
state.awaitingSend = false
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
await this.ctx.session.prompt({
|
|
348
|
+
sessionID,
|
|
349
|
+
text: turn.text,
|
|
350
|
+
...(turn.files.length > 0 ? { files: turn.files } : {}),
|
|
351
|
+
})
|
|
352
|
+
this.logger.debug(`${label} 已投递消息到会话 ${sessionID}`)
|
|
353
|
+
} catch (error) {
|
|
354
|
+
this.endBusy(state)
|
|
355
|
+
state.current = undefined
|
|
356
|
+
state.awaitingSend = false
|
|
357
|
+
this.logger.error(`${label} 投递消息失败`, error)
|
|
358
|
+
const buffered = state.buffer
|
|
359
|
+
state.buffer = []
|
|
360
|
+
if (buffered.length > 0) {
|
|
361
|
+
await this.dispatch(state, mergeTurns(buffered))
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 会话即将空闲:若有缓存消息,保持忙碌等 session.idle 后再投递;
|
|
368
|
+
* 否则清掉忙碌标记,若模型这一轮没调用发送工具则提醒它补一次。
|
|
369
|
+
*
|
|
370
|
+
* `background` 只统计本会话自己的后台任务;子会话遗留的任务不会唤醒本会话,
|
|
371
|
+
* 不能因此让目标一直保持忙碌。
|
|
372
|
+
*/
|
|
373
|
+
async handleIdle(
|
|
374
|
+
sessionID: string,
|
|
375
|
+
background: ReadonlyArray<{ sessionID: string }> = [],
|
|
376
|
+
): Promise<void> {
|
|
377
|
+
const state = this.sessions.get(sessionID)
|
|
378
|
+
if (!state) return
|
|
379
|
+
const label = targetLabel(state.target)
|
|
380
|
+
|
|
381
|
+
if (background.some((task) => task.sessionID === sessionID)) {
|
|
382
|
+
this.logger.debug(`${label} 仍有后台任务,保持忙碌状态`)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (state.buffer.length > 0) {
|
|
387
|
+
// 在 idle 钩子里投递会被并入旧的一轮;保持忙碌,等真正空闲后再开始新一轮。
|
|
388
|
+
this.logger.debug(`${label} 空闲,等待投递 ${state.buffer.length} 条缓存消息`)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
this.endBusy(state)
|
|
393
|
+
state.current = undefined
|
|
394
|
+
|
|
395
|
+
if (
|
|
396
|
+
state.awaitingSend &&
|
|
397
|
+
this.config.reminder.enabled &&
|
|
398
|
+
state.reminders < this.config.reminder.max
|
|
399
|
+
) {
|
|
400
|
+
state.reminders += 1
|
|
401
|
+
// 合成消息会唤醒会话再跑一轮,先占位避免新消息插队。
|
|
402
|
+
this.beginBusy(state)
|
|
403
|
+
state.current = { text: "[提醒]", files: [], atMe: false }
|
|
404
|
+
this.logger.warn(`${label} 模型未调用发送工具,提醒第 ${state.reminders} 次`)
|
|
405
|
+
try {
|
|
406
|
+
await this.ctx.session.synthetic({
|
|
407
|
+
sessionID,
|
|
408
|
+
text: `[提醒] 你这一轮没有调用任何发送工具,直接输出的文字会被丢弃。请调用 qq_send_message 工具:想回复就 reply=true 并给出 content,不想回复就 reply=false;发送图片、语音、转发或戳一戳也视为已回复。`,
|
|
409
|
+
})
|
|
410
|
+
} catch (error) {
|
|
411
|
+
this.endBusy(state)
|
|
412
|
+
state.awaitingSend = false
|
|
413
|
+
this.logger.error(`${label} 发送提醒失败`, error)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* 模型执行失败或被中断:idle 钩子不会触发,必须主动释放忙碌状态并处理缓存。
|
|
420
|
+
*/
|
|
421
|
+
async handleExecutionEnd(sessionID: string, reason: string): Promise<void> {
|
|
422
|
+
const state = this.sessions.get(sessionID)
|
|
423
|
+
if (!state) return
|
|
424
|
+
this.logger.warn(`${targetLabel(state.target)} 会话执行结束(${reason}),释放忙碌状态`)
|
|
425
|
+
this.endBusy(state)
|
|
426
|
+
state.current = undefined
|
|
427
|
+
state.awaitingSend = false
|
|
428
|
+
if (state.buffer.length > 0) {
|
|
429
|
+
await this.flushBuffered(sessionID)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 超时看门狗:会话长时间没有 idle 时强制放行缓存。 */
|
|
434
|
+
async recoverTarget(target: ChatTarget): Promise<void> {
|
|
435
|
+
const state = this.states.get(targetKey(target))
|
|
436
|
+
if (!state?.busy) return
|
|
437
|
+
this.logger.warn(`${targetLabel(target)} 会话超时未结束,强制放行缓存消息`)
|
|
438
|
+
const buffered = state.buffer
|
|
439
|
+
state.buffer = []
|
|
440
|
+
this.endBusy(state)
|
|
441
|
+
state.current = undefined
|
|
442
|
+
state.awaitingSend = false
|
|
443
|
+
if (buffered.length > 0) {
|
|
444
|
+
await this.dispatch(state, mergeTurns(buffered))
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** 检查所有目标是否有超过阈值的忙碌会话。 */
|
|
449
|
+
async recoverStale(): Promise<void> {
|
|
450
|
+
for (const state of this.states.values()) {
|
|
451
|
+
if (!state.busy) continue
|
|
452
|
+
if (Date.now() - state.busySince <= this.config.busyTimeoutMs) continue
|
|
453
|
+
await this.recoverTarget(state.target)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** 会话真正空闲后,把忙碌期间缓存的消息合并成新的一轮投递。 */
|
|
458
|
+
async flushBuffered(sessionID: string): Promise<void> {
|
|
459
|
+
const state = this.sessions.get(sessionID)
|
|
460
|
+
if (!state) return
|
|
461
|
+
if (state.buffer.length === 0) {
|
|
462
|
+
// 控制类工作(例如手动压缩)不会触发 idle 钩子,但仍会发 session.idle。
|
|
463
|
+
// 这里兜底释放可能残留的忙碌状态。
|
|
464
|
+
this.endBusy(state)
|
|
465
|
+
state.current = undefined
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const turns = state.buffer
|
|
470
|
+
state.buffer = []
|
|
471
|
+
this.endBusy(state)
|
|
472
|
+
state.current = undefined
|
|
473
|
+
this.logger.debug(`${targetLabel(state.target)} 空闲,合并 ${turns.length} 条缓存消息`)
|
|
474
|
+
await this.dispatch(state, mergeTurns(turns))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** 取得(必要时创建)目标对应的会话;并发调用复用同一个请求。 */
|
|
478
|
+
ensureSession(target: ChatTarget): Promise<string | undefined> {
|
|
479
|
+
const key = targetKey(target)
|
|
480
|
+
const pending = this.ensureRequests.get(key)
|
|
481
|
+
if (pending) return pending
|
|
482
|
+
const request = this.resolveSession(target).finally(() => {
|
|
483
|
+
if (this.ensureRequests.get(key) === request) this.ensureRequests.delete(key)
|
|
484
|
+
})
|
|
485
|
+
this.ensureRequests.set(key, request)
|
|
486
|
+
return request
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private async resolveSession(target: ChatTarget): Promise<string | undefined> {
|
|
490
|
+
await this.ensureBindings()
|
|
491
|
+
const state = this.state(target)
|
|
492
|
+
const label = targetLabel(target)
|
|
493
|
+
if (state.sessionID) {
|
|
494
|
+
if (await this.sessionExists(state.sessionID)) return state.sessionID
|
|
495
|
+
this.unbind(state)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (this.config.reuseSession) {
|
|
499
|
+
const reused = await this.findExistingSession(target)
|
|
500
|
+
// 会话已经绑给别的目标时不能复用,改为新建一个会话。
|
|
501
|
+
if (reused && this.bindSession(state, reused)) {
|
|
502
|
+
this.scheduleSave()
|
|
503
|
+
this.logger.info(`${label} 复用已有会话 ${reused}`)
|
|
504
|
+
return reused
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const created = await this.ctx.session.create({
|
|
509
|
+
title: target.kind === "group" ? `QQ 群 ${target.id}` : `QQ 私聊 ${target.id}`,
|
|
510
|
+
...(this.config.agent ? { agent: this.config.agent } : {}),
|
|
511
|
+
...(this.config.model ? { model: this.config.model } : {}),
|
|
512
|
+
metadata: {
|
|
513
|
+
qqTargetKind: target.kind,
|
|
514
|
+
qqTargetId: target.id,
|
|
515
|
+
},
|
|
516
|
+
})
|
|
517
|
+
if (!this.bindSession(state, created.id)) {
|
|
518
|
+
this.logger.error(`${label} 新建会话 ${created.id} 已被其他目标占用`)
|
|
519
|
+
await this.ctx.session.remove({ sessionID: created.id }).catch((error: unknown) => {
|
|
520
|
+
this.logger.warn(`清理冲突会话 ${created.id} 失败`, error)
|
|
521
|
+
})
|
|
522
|
+
return undefined
|
|
523
|
+
}
|
|
524
|
+
this.scheduleSave()
|
|
525
|
+
this.logger.info(`${label} 新建会话 ${created.id}`)
|
|
526
|
+
return created.id
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** 确认恢复的会话仍然存在,避免陈旧绑定让每条消息都失败。 */
|
|
530
|
+
private async sessionExists(sessionID: string): Promise<boolean> {
|
|
531
|
+
if (this.verifiedSessions.has(sessionID)) return true
|
|
532
|
+
try {
|
|
533
|
+
await this.ctx.session.get({ sessionID })
|
|
534
|
+
this.verifiedSessions.add(sessionID)
|
|
535
|
+
return true
|
|
536
|
+
} catch (error) {
|
|
537
|
+
this.logger.warn(`会话 ${sessionID} 已不存在,将重新建立绑定`, error)
|
|
538
|
+
return false
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private unbind(state: ChatState): void {
|
|
543
|
+
if (!state.sessionID) return
|
|
544
|
+
this.sessions.delete(state.sessionID)
|
|
545
|
+
this.verifiedSessions.delete(state.sessionID)
|
|
546
|
+
state.sessionID = undefined
|
|
547
|
+
this.scheduleSave()
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** 绑定会话到目标;一个会话只能绑定一个目标,已有绑定不能被覆盖。 */
|
|
551
|
+
private bindSession(state: ChatState, sessionID: string): boolean {
|
|
552
|
+
if (state.sessionID && state.sessionID !== sessionID) {
|
|
553
|
+
this.logger.error(
|
|
554
|
+
`${targetLabel(state.target)} 已绑定会话 ${state.sessionID},拒绝覆盖为 ${sessionID}`,
|
|
555
|
+
)
|
|
556
|
+
return false
|
|
557
|
+
}
|
|
558
|
+
const owner = this.sessions.get(sessionID)
|
|
559
|
+
if (owner && targetKey(owner.target) !== targetKey(state.target)) {
|
|
560
|
+
this.logger.error(
|
|
561
|
+
`会话 ${sessionID} 已绑定 ${targetLabel(owner.target)},不能再次绑定 ${targetLabel(state.target)}`,
|
|
562
|
+
)
|
|
563
|
+
return false
|
|
564
|
+
}
|
|
565
|
+
state.sessionID = sessionID
|
|
566
|
+
this.sessions.set(sessionID, state)
|
|
567
|
+
return true
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** 查找带相同目标标记的历史会话,按最近优先分页查找。 */
|
|
571
|
+
private async findExistingSession(target: ChatTarget): Promise<string | undefined> {
|
|
572
|
+
let cursor: SessionListCursor | undefined
|
|
573
|
+
try {
|
|
574
|
+
for (let page = 0; page < 5; page += 1) {
|
|
575
|
+
const result = await this.ctx.session.list({
|
|
576
|
+
directory: this.ctx.location.directory,
|
|
577
|
+
limit: 100,
|
|
578
|
+
order: "desc",
|
|
579
|
+
...(cursor ? { cursor } : {}),
|
|
580
|
+
})
|
|
581
|
+
const hit = result.data.find((session) => {
|
|
582
|
+
const metadata = session.metadata
|
|
583
|
+
return metadata?.["qqTargetKind"] === target.kind && metadata["qqTargetId"] === target.id
|
|
584
|
+
})
|
|
585
|
+
if (hit) return hit.id
|
|
586
|
+
cursor = result.cursor.next ?? undefined
|
|
587
|
+
if (!cursor) break
|
|
588
|
+
}
|
|
589
|
+
} catch (error) {
|
|
590
|
+
this.logger.warn(`查找历史会话失败 ${targetLabel(target)}`, error)
|
|
591
|
+
}
|
|
592
|
+
return undefined
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** 该目标是否已经建立过会话。 */
|
|
596
|
+
hasSession(target: ChatTarget): boolean {
|
|
597
|
+
return this.states.get(targetKey(target))?.sessionID !== undefined
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** 主动说话:向目标对应的会话投递一条合成消息。 */
|
|
601
|
+
async proactive(target: ChatTarget, text: string): Promise<void> {
|
|
602
|
+
const state = this.state(target)
|
|
603
|
+
if (state.busy) return
|
|
604
|
+
// 在 await 之前占位,避免用户消息或下一次心跳同时投递。
|
|
605
|
+
this.beginBusy(state)
|
|
606
|
+
state.current = { text, files: [], atMe: true, proactive: true }
|
|
607
|
+
state.awaitingSend = true
|
|
608
|
+
state.reminders = 0
|
|
609
|
+
try {
|
|
610
|
+
const sessionID = await this.ensureSession(target)
|
|
611
|
+
if (!sessionID) {
|
|
612
|
+
this.endBusy(state)
|
|
613
|
+
state.current = undefined
|
|
614
|
+
state.awaitingSend = false
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
state.lastUserMessageAt = Date.now()
|
|
618
|
+
this.activity[targetKey(target)] = state.lastUserMessageAt
|
|
619
|
+
this.scheduleSave()
|
|
620
|
+
await this.ctx.session.synthetic({ sessionID, text })
|
|
621
|
+
} catch (error) {
|
|
622
|
+
this.endBusy(state)
|
|
623
|
+
state.current = undefined
|
|
624
|
+
state.awaitingSend = false
|
|
625
|
+
this.logger.error(`${targetLabel(target)} 主动说话失败`, error)
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ProactiveConfig } from "../config"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 把“每小时目标概率”换算成单次心跳的触发概率。
|
|
5
|
+
*
|
|
6
|
+
* 直接在每个 tick 上抽 `startProbability + probabilityPerHour * hours` 会让实际频率
|
|
7
|
+
* 随 tickSeconds 放大;这里按 tick 占一小时的比例做指数换算。
|
|
8
|
+
*/
|
|
9
|
+
export function proactiveTickProbability(
|
|
10
|
+
config: Pick<
|
|
11
|
+
ProactiveConfig,
|
|
12
|
+
"startProbability" | "probabilityPerHour" | "silenceThresholdSeconds" | "tickSeconds"
|
|
13
|
+
>,
|
|
14
|
+
silenceSeconds: number,
|
|
15
|
+
): number {
|
|
16
|
+
const silenceHours = (silenceSeconds - config.silenceThresholdSeconds) / 3600
|
|
17
|
+
if (silenceHours <= 0) return 0
|
|
18
|
+
const target = Math.min(1, config.startProbability + config.probabilityPerHour * silenceHours)
|
|
19
|
+
if (target >= 1) return 1
|
|
20
|
+
const fraction = Math.min(1, config.tickSeconds / 3600)
|
|
21
|
+
return 1 - Math.pow(1 - target, fraction)
|
|
22
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** 发消息用的工具名(命名空间 qq 下的 send_message)。 */
|
|
2
|
+
export const SEND_MESSAGE_TOOL = "qq_send_message"
|
|
3
|
+
|
|
4
|
+
const GUIDELINES = `
|
|
5
|
+
GUIDELINES:
|
|
6
|
+
你在 QQ 群聊或私聊里,你收到的消息不一定是发给你的,你需要根据上下文和语境来判断是否回复。私聊消息必须回复;群聊里只有被 @ 时才必须回复。
|
|
7
|
+
对于一条消息,<quote>...</quote> 里面的内容是引用的消息,通常是你之前发过的消息或者其他人的消息,你需要根据这些引用来判断当前消息的语境。<forward>...</forward> 里面是合并转发消息的内容,每条前面的名字是转发节点的发送者。user_name : message 是用户发给你的消息, 不同的user_name代表不同的用户,你需要根据这些消息来判断每个用户的意图和情感。
|
|
8
|
+
重要!!!发送消息时,**必须**调用发送类工具;任何直接输出的文字都会被**完全丢弃**,不会作为消息内容发送!!!
|
|
9
|
+
文本回复用 ${SEND_MESSAGE_TOOL};图片、语音、合并转发、戳一戳等用对应的 qq_* 工具,调用成功同样算作已回复。
|
|
10
|
+
每轮至少调用一个发送类工具;不想回复时把 ${SEND_MESSAGE_TOOL} 的 reply 设成 false(content 可留空),相当于已读不回。群聊里不需要每一条都回复,像人一样选择性回复就行。
|
|
11
|
+
不要输出markdown、代码块、表格、列表等格式化内容,直接输出纯文本即可。
|
|
12
|
+
`
|
|
13
|
+
|
|
14
|
+
/** 主动发起话题时追加的模式说明。 */
|
|
15
|
+
export const PROACTIVE_HINT = `
|
|
16
|
+
[模式] 主动发起话题。不一定与记忆相关,也不一定与当前聊天的最新消息相关。
|
|
17
|
+
可以是一个问题、一个建议、一个有趣的想法、一个冷知识、一个笑话等。
|
|
18
|
+
注意,不要再重复之前的话题,也不要重复之前的消息内容。
|
|
19
|
+
你需要调用 ${SEND_MESSAGE_TOOL} 工具把话题发到当前聊天。
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
/** 组装完整的系统提示词。 */
|
|
23
|
+
export function buildSystemPrompt(soul: string): string {
|
|
24
|
+
const base = soul.trim()
|
|
25
|
+
return base ? `${base}\n${GUIDELINES}` : GUIDELINES.trim()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TIME_FORMAT = new Intl.DateTimeFormat("sv-SE", {
|
|
29
|
+
year: "numeric",
|
|
30
|
+
month: "2-digit",
|
|
31
|
+
day: "2-digit",
|
|
32
|
+
hour: "2-digit",
|
|
33
|
+
minute: "2-digit",
|
|
34
|
+
second: "2-digit",
|
|
35
|
+
hour12: false,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** 当前时间文本,注入到系统提示里让模型知道现在几点。 */
|
|
39
|
+
export function currentTimeText(now = new Date()): string {
|
|
40
|
+
return TIME_FORMAT.format(now)
|
|
41
|
+
}
|