@cmach_socket/opencode-qq-bot 0.2.0 → 0.3.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 +54 -22
- package/package.json +2 -2
- package/src/config.ts +6 -1
- package/src/index.ts +19 -13
- package/src/naplink/client.ts +78 -1
- package/src/naplink/media.ts +9 -1
- package/src/naplink/render.ts +7 -2
- package/src/session/prompt.ts +0 -44
- package/src/session/soul.ts +180 -0
- package/src/tools/ask-guard.ts +32 -0
- /package/{SOUL.txt → souls/default.txt} +0 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
- 群聊 / 私聊消息 → OpenCode 会话 → 模型回复 → 对应聊天
|
|
7
7
|
- 每个聊天固定对应一个会话,会话忙碌时新消息先缓存,空闲后合并成一轮再投进去;绑定写入插件存储,重启后自动沿用原会话
|
|
8
8
|
- 语音自动转写、图片落盘后以文件引用进入会话、引用与合并转发展开成文本块
|
|
9
|
-
- 角色设定放在 `
|
|
9
|
+
- 角色设定放在 `souls/` 目录,可放多份,按文件名切换,改动后下一轮立即生效
|
|
10
10
|
|
|
11
11
|
## 环境要求
|
|
12
12
|
|
|
@@ -54,26 +54,27 @@ bun install
|
|
|
54
54
|
插件不会读取项目 `.env`。如果选择用环境变量配置,必须把变量注入 OpenCode 服务进程本身,
|
|
55
55
|
可参考 `.env.example`;`cp .env.example .env` 不会生效。
|
|
56
56
|
|
|
57
|
-
| 选项 | 环境变量 | 默认值
|
|
58
|
-
| ----------------------------------- | ------------------------------- |
|
|
59
|
-
| `napcat.url` | `NAPCAT_WS_URL` | 无(必填)
|
|
60
|
-
| `napcat.token` | `NAPCAT_WS_TOKEN` | 无
|
|
61
|
-
| `groups` | `QQ_TARGET_GROUPS` | `[]`
|
|
62
|
-
| `users` | `QQ_TARGET_USERS` | `[]`
|
|
63
|
-
| `soulPath` | `QQ_BOT_SOUL_PATH` | 包内 `
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `proactive.
|
|
72
|
-
| `proactive.
|
|
73
|
-
| `proactive.
|
|
74
|
-
| `proactive.
|
|
75
|
-
| `
|
|
76
|
-
| `reminder.
|
|
57
|
+
| 选项 | 环境变量 | 默认值 | 说明 |
|
|
58
|
+
| ----------------------------------- | ------------------------------- | ------------- | ----------------------------------------------- |
|
|
59
|
+
| `napcat.url` | `NAPCAT_WS_URL` | 无(必填) | NapCat 正向 WebSocket 地址 |
|
|
60
|
+
| `napcat.token` | `NAPCAT_WS_TOKEN` | 无 | NapCat 访问令牌 |
|
|
61
|
+
| `groups` | `QQ_TARGET_GROUPS` | `[]` | 参与对话的群号,逗号分隔 |
|
|
62
|
+
| `users` | `QQ_TARGET_USERS` | `[]` | 参与对话的私聊 QQ 号,逗号分隔 |
|
|
63
|
+
| `soulPath` | `QQ_BOT_SOUL_PATH` | 包内 `souls/` | 角色设定文件或目录路径 |
|
|
64
|
+
| `soulName` | `QQ_BOT_SOUL_NAME` | `default.txt` | `souls` 目录里的角色设定文件名 |
|
|
65
|
+
| `agent` | `QQ_BOT_AGENT` | OpenCode 默认 | 会话使用的 agent |
|
|
66
|
+
| `model` | `QQ_BOT_MODEL` | OpenCode 默认 | 会话使用的模型,格式 `provider/model[/variant]` |
|
|
67
|
+
| `sendDelayMs` | `QQ_SEND_DELAY_MS` | `1000` | 超长消息分段发送时每段之间的间隔 |
|
|
68
|
+
| `warnLength` | `QQ_WARN_LENGTH` | `20` | 一轮回复总长度超过该值时告警 |
|
|
69
|
+
| `reuseSession` | `QQ_REUSE_SESSION` | `false` | 没有保存的绑定时,按会话标记找回上一次的会话 |
|
|
70
|
+
| `busyTimeoutMs` | `QQ_BUSY_TIMEOUT_MS` | `300000` | 会话超过该时长未结束视为卡死,强制放行缓存消息 |
|
|
71
|
+
| `proactive.enabled` | `QQ_PROACTIVE_ENABLED` | `true` | 是否启用主动说话 |
|
|
72
|
+
| `proactive.silenceThresholdSeconds` | `QQ_PROACTIVE_SILENCE_SECONDS` | `10800` | 沉默多久后才开始计算主动说话概率 |
|
|
73
|
+
| `proactive.startProbability` | `QQ_PROACTIVE_P_START` | `0.01` | 刚跨过阈值时的概率 |
|
|
74
|
+
| `proactive.probabilityPerHour` | `QQ_PROACTIVE_P_SLOPE_PER_HOUR` | `0.05` | 每多沉默一小时概率的增量 |
|
|
75
|
+
| `proactive.tickSeconds` | `QQ_PROACTIVE_TICK_SECONDS` | `3600` | 心跳间隔,必须大于 0 |
|
|
76
|
+
| `reminder.enabled` | `QQ_REMINDER_ENABLED` | `true` | 模型一轮没调用发送工具时是否提醒 |
|
|
77
|
+
| `reminder.max` | `QQ_REMINDER_MAX` | `2` | 连续提醒次数上限 |
|
|
77
78
|
|
|
78
79
|
没配置 `groups` 和 `users` 时插件不会响应任何消息(默认全部关闭)。
|
|
79
80
|
|
|
@@ -81,6 +82,34 @@ bun install
|
|
|
81
82
|
写错的配置项会输出告警并回退到默认值,不会静默失效。
|
|
82
83
|
取值规则与图片内联限制见 [docs/configuration.md](docs/configuration.md)。
|
|
83
84
|
|
|
85
|
+
### 角色设定
|
|
86
|
+
|
|
87
|
+
角色设定文件放在 `souls/` 目录,一个目录里可以放多份,用 `soulName` 选生效的那份:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
souls/
|
|
91
|
+
default.txt 默认角色
|
|
92
|
+
白羽桃.txt 另一份角色
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```jsonc
|
|
96
|
+
{
|
|
97
|
+
"plugins": [
|
|
98
|
+
{
|
|
99
|
+
"package": "./",
|
|
100
|
+
"options": {
|
|
101
|
+
"soulName": "白羽桃.txt",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- `soulName` 填 `souls` 目录里的文件名,默认 `default.txt`。
|
|
109
|
+
- `soulPath` 可以直接给文件路径(相对路径按进程工作目录解析),也可以给目录,目录时同样用 `soulName` 选文件。
|
|
110
|
+
- 生效文件的内容改动会被监听,下一轮就用新设定,不需要重启插件。
|
|
111
|
+
- 改 `soulName` 换角色:写进 `opencode.json(c)` 保存后 OpenCode 会重载插件,随即生效。
|
|
112
|
+
|
|
84
113
|
## 工具
|
|
85
114
|
|
|
86
115
|
模型只能操作当前会话绑定的群聊或私聊,工具都带 `qq_` 前缀:
|
|
@@ -158,6 +187,7 @@ bun run format # prettier --write .
|
|
|
158
187
|
## 目录
|
|
159
188
|
|
|
160
189
|
```
|
|
190
|
+
souls/ 角色设定文件(可多份,按文件名切换)
|
|
161
191
|
src/
|
|
162
192
|
config.ts 配置合并与校验
|
|
163
193
|
index.ts 插件入口
|
|
@@ -165,8 +195,10 @@ src/
|
|
|
165
195
|
logger.ts 日志
|
|
166
196
|
naplink/client.ts NapLink 连接与 API 门面
|
|
167
197
|
naplink/render.ts 消息段 → 文本与图片附件
|
|
168
|
-
session/prompt.ts
|
|
198
|
+
session/prompt.ts 固定守则与系统提示
|
|
199
|
+
session/soul.ts 角色设定定位、读取与热更新
|
|
169
200
|
session/manager.ts 群聊 / 私聊 ↔ 会话映射、消息缓存
|
|
201
|
+
tools/ask-guard.ts 拦截内置 question 工具
|
|
170
202
|
tools/qq.ts QQ 工具注册
|
|
171
203
|
docs/
|
|
172
204
|
configuration.md 配置取值规则与限制
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cmach_socket/opencode-qq-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "OpenCode V2 plugin bridging a QQ group chat (NapCat via NapLink) to an OpenCode session.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"src",
|
|
16
|
-
"
|
|
16
|
+
"souls",
|
|
17
17
|
"README.md"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
package/src/config.ts
CHANGED
|
@@ -52,8 +52,10 @@ export interface QQBotConfig {
|
|
|
52
52
|
groups: number[]
|
|
53
53
|
/** 参与 AI 对话的私聊 QQ 号。 */
|
|
54
54
|
users: number[]
|
|
55
|
-
/** 角色设定文件路径,默认使用插件包内的
|
|
55
|
+
/** 角色设定文件路径,默认使用插件包内的 souls 目录。 */
|
|
56
56
|
soulPath?: string
|
|
57
|
+
/** souls 目录里的角色设定文件名,默认 default.txt。 */
|
|
58
|
+
soulName?: string
|
|
57
59
|
/** QQ 会话使用的 OpenCode agent。 */
|
|
58
60
|
agent?: string
|
|
59
61
|
/** QQ 会话使用的模型。 */
|
|
@@ -279,6 +281,7 @@ function createConfigSchema(
|
|
|
279
281
|
groups,
|
|
280
282
|
users,
|
|
281
283
|
soulPath: text("soulPath", defaults.soulPath),
|
|
284
|
+
soulName: text("soulName", defaults.soulName),
|
|
282
285
|
agent: text("agent", defaults.agent),
|
|
283
286
|
model,
|
|
284
287
|
sendDelayMs: nonNegative("sendDelayMs", defaults.sendDelayMs),
|
|
@@ -320,6 +323,7 @@ const ENV_VARS = {
|
|
|
320
323
|
groups: "QQ_TARGET_GROUPS",
|
|
321
324
|
users: "QQ_TARGET_USERS",
|
|
322
325
|
soulPath: "QQ_BOT_SOUL_PATH",
|
|
326
|
+
soulName: "QQ_BOT_SOUL_NAME",
|
|
323
327
|
agent: "QQ_BOT_AGENT",
|
|
324
328
|
model: "QQ_BOT_MODEL",
|
|
325
329
|
sendDelayMs: "QQ_SEND_DELAY_MS",
|
|
@@ -340,6 +344,7 @@ const OPTION_KEYS = new Set([
|
|
|
340
344
|
"groups",
|
|
341
345
|
"users",
|
|
342
346
|
"soulPath",
|
|
347
|
+
"soulName",
|
|
343
348
|
"agent",
|
|
344
349
|
"model",
|
|
345
350
|
"sendDelayMs",
|
package/src/index.ts
CHANGED
|
@@ -8,15 +8,10 @@ import { QQClient } from "./naplink/client"
|
|
|
8
8
|
import { createMediaStore } from "./naplink/media"
|
|
9
9
|
import { mentionsUser, renderMessage, resolveUserName, shouldProcess } from "./naplink/render"
|
|
10
10
|
import { SessionManager } from "./session/manager"
|
|
11
|
-
import {
|
|
12
|
-
buildSystemPrompt,
|
|
13
|
-
currentTimeText,
|
|
14
|
-
loadSoul,
|
|
15
|
-
PROACTIVE_HINT,
|
|
16
|
-
resolveSoulPath,
|
|
17
|
-
packageDirectory,
|
|
18
|
-
} from "./session/prompt"
|
|
11
|
+
import { currentTimeText, PROACTIVE_HINT } from "./session/prompt"
|
|
19
12
|
import { proactiveTickProbability } from "./session/proactive"
|
|
13
|
+
import { packageDirectory, SoulStore } from "./session/soul"
|
|
14
|
+
import { registerAskGuard } from "./tools/ask-guard"
|
|
20
15
|
import { registerTools } from "./tools/qq"
|
|
21
16
|
|
|
22
17
|
const RETRY_DELAY_MS = 1000
|
|
@@ -53,10 +48,17 @@ export default Plugin.define({
|
|
|
53
48
|
}
|
|
54
49
|
}
|
|
55
50
|
|
|
56
|
-
|
|
57
|
-
const soul =
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
// 角色设定:读取生效文件并监听改动,改完下一轮即生效。
|
|
52
|
+
const soul = new SoulStore(
|
|
53
|
+
{
|
|
54
|
+
soulPath: config.soulPath,
|
|
55
|
+
soulName: config.soulName,
|
|
56
|
+
moduleDir: packageDirectory(),
|
|
57
|
+
cwd: process.cwd(),
|
|
58
|
+
},
|
|
59
|
+
logger,
|
|
60
|
+
)
|
|
61
|
+
await soul.load()
|
|
60
62
|
|
|
61
63
|
const client = new QQClient(config.napcat, logger)
|
|
62
64
|
const manager = new SessionManager(ctx, config, logger)
|
|
@@ -71,13 +73,15 @@ export default Plugin.define({
|
|
|
71
73
|
const mediaHandle = mediaCleanup as { unref?: () => void }
|
|
72
74
|
mediaHandle.unref?.()
|
|
73
75
|
const toolRegistration = await registerTools({ ctx, config, logger, manager, client })
|
|
76
|
+
// QQ 会话里禁止模型调用内置 question 工具,改用 qq_send_message 询问用户。
|
|
77
|
+
const askGuardRegistration = await registerAskGuard({ ctx, logger, manager })
|
|
74
78
|
const nameCache = new Map<string, { name: string; expires: number }>()
|
|
75
79
|
|
|
76
80
|
// 系统提示:角色设定、当前时间、本轮是否必须回复。
|
|
77
81
|
const contextRegistration = await ctx.session.hook("context", (event) => {
|
|
78
82
|
const target = manager.targetForSession(event.sessionID)
|
|
79
83
|
if (target === undefined) return
|
|
80
|
-
event.system.push({ type: "text", text:
|
|
84
|
+
event.system.push({ type: "text", text: soul.prompt })
|
|
81
85
|
event.system.push({ type: "text", text: `当前时间:${currentTimeText()}` })
|
|
82
86
|
const turn = manager.currentTurn(event.sessionID)
|
|
83
87
|
const destination = target.kind === "group" ? "群里" : "私聊里"
|
|
@@ -232,7 +236,9 @@ export default Plugin.define({
|
|
|
232
236
|
clearInterval(mediaCleanup)
|
|
233
237
|
idleAbort.abort()
|
|
234
238
|
await manager.flush()
|
|
239
|
+
soul.dispose()
|
|
235
240
|
await toolRegistration.dispose()
|
|
241
|
+
await askGuardRegistration.dispose()
|
|
236
242
|
await contextRegistration.dispose()
|
|
237
243
|
await idleRegistration.dispose()
|
|
238
244
|
client.dispose()
|
package/src/naplink/client.ts
CHANGED
|
@@ -8,6 +8,83 @@ import {
|
|
|
8
8
|
import type { Logger } from "../logger"
|
|
9
9
|
import type { NapcatConfig } from "../config"
|
|
10
10
|
|
|
11
|
+
const MEDIA_SEGMENT_TYPES = ["image", "video", "record", "audio", "file"] as const
|
|
12
|
+
|
|
13
|
+
type MediaSegmentType = (typeof MEDIA_SEGMENT_TYPES)[number]
|
|
14
|
+
|
|
15
|
+
interface MediaSegmentData {
|
|
16
|
+
file?: string
|
|
17
|
+
file_id?: string
|
|
18
|
+
url?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface MediaFileLike {
|
|
22
|
+
file?: unknown
|
|
23
|
+
url?: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isMediaSegmentType(type: string): type is MediaSegmentType {
|
|
27
|
+
return (MEDIA_SEGMENT_TYPES as readonly string[]).includes(type)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 取第一个可下载的 http(s) 直链,忽略本地路径与 base64。 */
|
|
31
|
+
export function pickRemoteUrl(...values: unknown[]): string | undefined {
|
|
32
|
+
for (const value of values) {
|
|
33
|
+
if (typeof value === "string" && /^https?:\/\//i.test(value)) return value
|
|
34
|
+
}
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 补全单个媒体段的远程直链。
|
|
40
|
+
*
|
|
41
|
+
* NapCat 同时返回本地路径(`file`)和 CDN 直链(`url`),SDK 自带的补全逻辑
|
|
42
|
+
* 会优先选用本地路径,导致插件无法下载。这里只写入 `url`,且优先使用 http(s) 地址。
|
|
43
|
+
*/
|
|
44
|
+
export async function hydrateSegment(
|
|
45
|
+
api: OneBotApi,
|
|
46
|
+
segment: MessageSegment,
|
|
47
|
+
logger: Logger,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
if (!isMediaSegmentType(segment.type)) return
|
|
50
|
+
const data = segment.data as MediaSegmentData
|
|
51
|
+
// 已有可用的远程直链时不要覆盖。
|
|
52
|
+
if (pickRemoteUrl(data.url)) return
|
|
53
|
+
const fileId = data.file ?? data.file_id
|
|
54
|
+
if (typeof fileId !== "string" || fileId.length === 0) return
|
|
55
|
+
if (/^(file|base64|data):/i.test(fileId)) return
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const file = (await api.getFile(fileId)) as MediaFileLike | undefined
|
|
59
|
+
const direct = pickRemoteUrl(file?.url, file?.file)
|
|
60
|
+
if (direct) {
|
|
61
|
+
data.url = direct
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (segment.type === "image") {
|
|
65
|
+
const image = (await api.getImage(fileId)) as MediaFileLike | undefined
|
|
66
|
+
const imageUrl = pickRemoteUrl(image?.url, image?.file)
|
|
67
|
+
if (imageUrl) data.url = imageUrl
|
|
68
|
+
} else if (segment.type === "record" || segment.type === "audio") {
|
|
69
|
+
const record = (await api.getRecord(fileId, "mp3")) as MediaFileLike | undefined
|
|
70
|
+
const recordUrl = pickRemoteUrl(record?.url, record?.file)
|
|
71
|
+
if (recordUrl) data.url = recordUrl
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
logger.debug(`补全媒体直链失败 type=${segment.type} file=${fileId}`, error)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 补全一条消息里所有媒体段的远程直链。 */
|
|
79
|
+
export async function hydrateMedia(
|
|
80
|
+
api: OneBotApi,
|
|
81
|
+
message: MessageSegment[],
|
|
82
|
+
logger: Logger,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
if (!Array.isArray(message)) return
|
|
85
|
+
await Promise.all(message.map((segment) => hydrateSegment(api, segment, logger)))
|
|
86
|
+
}
|
|
87
|
+
|
|
11
88
|
/** NapCat 的连接与 API 门面,屏蔽 SDK 细节。 */
|
|
12
89
|
export class QQClient {
|
|
13
90
|
private readonly link: NapLink
|
|
@@ -105,7 +182,7 @@ export class QQClient {
|
|
|
105
182
|
/** 补全消息里媒体段的直链,便于下载图片。 */
|
|
106
183
|
async hydrate(message: MessageSegment[]): Promise<void> {
|
|
107
184
|
try {
|
|
108
|
-
await this.
|
|
185
|
+
await hydrateMedia(this.api, message, this.logger)
|
|
109
186
|
} catch (error) {
|
|
110
187
|
this.logger.warn("补全媒体直链失败", error)
|
|
111
188
|
}
|
package/src/naplink/media.ts
CHANGED
|
@@ -83,6 +83,11 @@ function truncateCodePoints(text: string, max: number): string {
|
|
|
83
83
|
return Array.from(text).slice(0, max).join("")
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** 截断过长的地址,避免日志被超长 URL 淹没。 */
|
|
87
|
+
function truncateForLog(value: string, max = 120): string {
|
|
88
|
+
return value.length > max ? `${value.slice(0, max)}…` : value
|
|
89
|
+
}
|
|
90
|
+
|
|
86
91
|
/** 判断主机名是否指向本机或内网,避免下载请求打内网服务。 */
|
|
87
92
|
export function isPrivateHost(hostname: string): boolean {
|
|
88
93
|
let host = hostname.trim().toLowerCase()
|
|
@@ -199,7 +204,10 @@ export class MediaStore {
|
|
|
199
204
|
return { bytes, mime }
|
|
200
205
|
}
|
|
201
206
|
if (url.startsWith("data:")) return parseDataUrl(url)
|
|
202
|
-
if (!url.startsWith("http://") && !url.startsWith("https://"))
|
|
207
|
+
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
208
|
+
this.logger.warn(`不支持的图片地址,跳过下载 url=${truncateForLog(url)}`)
|
|
209
|
+
return undefined
|
|
210
|
+
}
|
|
203
211
|
|
|
204
212
|
let parsed: URL
|
|
205
213
|
try {
|
package/src/naplink/render.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { MessageEvent, MessageSegment, OneBotApi } from "@naplink/naplink"
|
|
|
2
2
|
import type { ChatTarget } from "../chat"
|
|
3
3
|
import { targetKey } from "../chat"
|
|
4
4
|
import type { Logger } from "../logger"
|
|
5
|
+
import { hydrateSegment } from "./client"
|
|
5
6
|
import {
|
|
6
7
|
MAX_IMAGE_BYTES,
|
|
7
8
|
inlineImageBytes,
|
|
@@ -174,8 +175,10 @@ async function renderForwardSegments(
|
|
|
174
175
|
break
|
|
175
176
|
case "image": {
|
|
176
177
|
parts.push("[图片]")
|
|
178
|
+
if (budget.images <= 0) break
|
|
179
|
+
await hydrateSegment(ctx.api, segment, ctx.logger)
|
|
177
180
|
const url = segment.data.url || segment.data.file
|
|
178
|
-
if (!url
|
|
181
|
+
if (!url) break
|
|
179
182
|
const stored = await ctx.media.storeImage(url)
|
|
180
183
|
if (!stored || !takeImage(budget, stored)) break
|
|
181
184
|
images.push(stored)
|
|
@@ -363,8 +366,10 @@ export async function renderMessage(
|
|
|
363
366
|
break
|
|
364
367
|
case "image": {
|
|
365
368
|
textBuffer.push("[图片] ")
|
|
369
|
+
if (budget.images <= 0) break
|
|
370
|
+
await hydrateSegment(ctx.api, segment, ctx.logger)
|
|
366
371
|
const url = segment.data.url || segment.data.file
|
|
367
|
-
if (!url
|
|
372
|
+
if (!url) break
|
|
368
373
|
const stored = await ctx.media.storeImage(url)
|
|
369
374
|
if (!stored || !takeImage(budget, stored)) break
|
|
370
375
|
imageIndex += 1
|
package/src/session/prompt.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises"
|
|
2
|
-
import { existsSync } from "node:fs"
|
|
3
|
-
import { dirname, isAbsolute, join, resolve } from "node:path"
|
|
4
|
-
|
|
5
1
|
/** 发消息用的工具名(命名空间 qq 下的 send_message)。 */
|
|
6
2
|
export const SEND_MESSAGE_TOOL = "qq_send_message"
|
|
7
3
|
|
|
@@ -23,41 +19,6 @@ export const PROACTIVE_HINT = `
|
|
|
23
19
|
你需要调用 ${SEND_MESSAGE_TOOL} 工具把话题发到当前聊天。
|
|
24
20
|
`
|
|
25
21
|
|
|
26
|
-
/** 从模块所在目录向上查找 SOUL.txt,兼容 src/ 与 dist/ 两种布局。 */
|
|
27
|
-
export function findSoulFile(startDir: string, maxDepth = 5): string | undefined {
|
|
28
|
-
let current = resolve(startDir)
|
|
29
|
-
for (let depth = 0; depth <= maxDepth; depth += 1) {
|
|
30
|
-
const candidate = join(current, "SOUL.txt")
|
|
31
|
-
if (existsSync(candidate)) return candidate
|
|
32
|
-
const parent = dirname(current)
|
|
33
|
-
if (parent === current) break
|
|
34
|
-
current = parent
|
|
35
|
-
}
|
|
36
|
-
return undefined
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** 定位角色设定文件:显式路径优先,否则用包内默认。 */
|
|
40
|
-
export function resolveSoulPath(
|
|
41
|
-
explicitPath: string | undefined,
|
|
42
|
-
moduleDir: string,
|
|
43
|
-
cwd: string,
|
|
44
|
-
): string | undefined {
|
|
45
|
-
if (explicitPath) {
|
|
46
|
-
return isAbsolute(explicitPath) ? explicitPath : resolve(cwd, explicitPath)
|
|
47
|
-
}
|
|
48
|
-
return findSoulFile(moduleDir)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** 读取角色设定,失败时返回空字符串。 */
|
|
52
|
-
export async function loadSoul(path: string | undefined): Promise<string> {
|
|
53
|
-
if (!path) return ""
|
|
54
|
-
try {
|
|
55
|
-
return await readFile(path, "utf-8")
|
|
56
|
-
} catch {
|
|
57
|
-
return ""
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
22
|
/** 组装完整的系统提示词。 */
|
|
62
23
|
export function buildSystemPrompt(soul: string): string {
|
|
63
24
|
const base = soul.trim()
|
|
@@ -78,8 +39,3 @@ const TIME_FORMAT = new Intl.DateTimeFormat("sv-SE", {
|
|
|
78
39
|
export function currentTimeText(now = new Date()): string {
|
|
79
40
|
return TIME_FORMAT.format(now)
|
|
80
41
|
}
|
|
81
|
-
|
|
82
|
-
/** 供测试与其它模块定位包内 SOUL.txt。 */
|
|
83
|
-
export function packageDirectory(): string {
|
|
84
|
-
return import.meta.dirname
|
|
85
|
-
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import { existsSync, statSync, watch, type FSWatcher } from "node:fs"
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path"
|
|
4
|
+
import type { Logger } from "../logger"
|
|
5
|
+
import { buildSystemPrompt } from "./prompt"
|
|
6
|
+
|
|
7
|
+
/** 角色设定目录名。 */
|
|
8
|
+
export const SOUL_DIR = "souls"
|
|
9
|
+
|
|
10
|
+
/** 默认角色设定文件名。 */
|
|
11
|
+
export const DEFAULT_SOUL_NAME = "default.txt"
|
|
12
|
+
|
|
13
|
+
/** 文件变化后的合并等待时间,避免编辑器连续写盘时重复读取。 */
|
|
14
|
+
const RELOAD_DEBOUNCE_MS = 150
|
|
15
|
+
|
|
16
|
+
export interface SoulOptions {
|
|
17
|
+
/** 显式指定的角色设定文件或目录,优先于 soulName。 */
|
|
18
|
+
soulPath?: string
|
|
19
|
+
/** souls 目录里的文件名。 */
|
|
20
|
+
soulName?: string
|
|
21
|
+
/** 插件所在目录,用于向上查找包内 souls 目录。 */
|
|
22
|
+
moduleDir: string
|
|
23
|
+
/** 相对路径的基准目录。 */
|
|
24
|
+
cwd: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 从 startDir 向上逐级查找 souls/<name>。 */
|
|
28
|
+
export function findSoulFile(
|
|
29
|
+
startDir: string,
|
|
30
|
+
name: string = DEFAULT_SOUL_NAME,
|
|
31
|
+
maxDepth = 5,
|
|
32
|
+
): string | undefined {
|
|
33
|
+
let current = resolve(startDir)
|
|
34
|
+
for (let depth = 0; depth <= maxDepth; depth += 1) {
|
|
35
|
+
const candidate = join(current, SOUL_DIR, name)
|
|
36
|
+
if (existsSync(candidate)) return candidate
|
|
37
|
+
const parent = dirname(current)
|
|
38
|
+
if (parent === current) break
|
|
39
|
+
current = parent
|
|
40
|
+
}
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 找不到文件时给出预期路径,文件补齐后可被监听器捡到。 */
|
|
45
|
+
export function expectedSoulPath(startDir: string, name: string, maxDepth = 5): string {
|
|
46
|
+
let current = resolve(startDir)
|
|
47
|
+
for (let depth = 0; depth <= maxDepth; depth += 1) {
|
|
48
|
+
const directory = join(current, SOUL_DIR)
|
|
49
|
+
if (existsSync(directory)) return join(directory, name)
|
|
50
|
+
const parent = dirname(current)
|
|
51
|
+
if (parent === current) break
|
|
52
|
+
current = parent
|
|
53
|
+
}
|
|
54
|
+
return join(resolve(startDir), SOUL_DIR, name)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 定位角色设定文件:显式路径优先,其次 souls 目录里的指定文件。 */
|
|
58
|
+
export function resolveSoulPath(options: SoulOptions): string {
|
|
59
|
+
const name = options.soulName?.trim() || DEFAULT_SOUL_NAME
|
|
60
|
+
const explicit = options.soulPath?.trim()
|
|
61
|
+
if (!explicit) {
|
|
62
|
+
return findSoulFile(options.moduleDir, name) ?? expectedSoulPath(options.moduleDir, name)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const target = isAbsolute(explicit) ? explicit : resolve(options.cwd, explicit)
|
|
66
|
+
if (existsSync(target) && statSync(target).isDirectory()) return join(target, name)
|
|
67
|
+
return target
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 读取角色设定,失败时返回空字符串。 */
|
|
71
|
+
export async function loadSoul(path: string | undefined): Promise<string> {
|
|
72
|
+
if (!path) return ""
|
|
73
|
+
try {
|
|
74
|
+
return await readFile(path, "utf-8")
|
|
75
|
+
} catch {
|
|
76
|
+
return ""
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 角色设定持有者:读取生效的设定文件,并在文件变化后热更新。
|
|
82
|
+
*
|
|
83
|
+
* 提示词在每轮组装时读取当前内容,所以文件改动会在下一轮立即生效。
|
|
84
|
+
*/
|
|
85
|
+
export class SoulStore {
|
|
86
|
+
#path: string | undefined
|
|
87
|
+
#text = ""
|
|
88
|
+
#watcher: FSWatcher | undefined
|
|
89
|
+
#timer: ReturnType<typeof setTimeout> | undefined
|
|
90
|
+
#logger: Logger
|
|
91
|
+
|
|
92
|
+
constructor(
|
|
93
|
+
private readonly options: SoulOptions,
|
|
94
|
+
logger: Logger,
|
|
95
|
+
) {
|
|
96
|
+
this.#logger = logger
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 生效的角色设定文件路径。 */
|
|
100
|
+
get path(): string | undefined {
|
|
101
|
+
return this.#path
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 当前角色设定文本。 */
|
|
105
|
+
get text(): string {
|
|
106
|
+
return this.#text
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 角色设定与固定守则组装出的系统提示词。 */
|
|
110
|
+
get prompt(): string {
|
|
111
|
+
return buildSystemPrompt(this.#text)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 解析路径、读取内容并开始监听文件变化。 */
|
|
115
|
+
async load(): Promise<void> {
|
|
116
|
+
this.#path = resolveSoulPath(this.options)
|
|
117
|
+
this.#text = await loadSoul(this.#path)
|
|
118
|
+
if (!this.#text) this.#logger.warn(`角色设定读取失败:${this.#path}`)
|
|
119
|
+
else this.#logger.info(`角色设定已加载:${this.#path}`)
|
|
120
|
+
this.#watch()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 重新读取角色设定文件,内容有变化时返回 true。 */
|
|
124
|
+
async reload(): Promise<boolean> {
|
|
125
|
+
if (!this.#path) return false
|
|
126
|
+
const text = await loadSoul(this.#path)
|
|
127
|
+
if (text === this.#text) return false
|
|
128
|
+
this.#text = text
|
|
129
|
+
this.#logger.info(`角色设定已更新:${this.#path}`)
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
dispose(): void {
|
|
134
|
+
if (this.#timer) {
|
|
135
|
+
clearTimeout(this.#timer)
|
|
136
|
+
this.#timer = undefined
|
|
137
|
+
}
|
|
138
|
+
this.#watcher?.close()
|
|
139
|
+
this.#watcher = undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 监听所在目录:编辑器多为替换文件,只盯目录才能捕捉到。 */
|
|
143
|
+
#watch(): void {
|
|
144
|
+
if (!this.#path) return
|
|
145
|
+
const directory = dirname(this.#path)
|
|
146
|
+
if (!existsSync(directory)) return
|
|
147
|
+
try {
|
|
148
|
+
this.#watcher = watch(directory, (_event, filename) => {
|
|
149
|
+
const name = filename ? String(filename) : undefined
|
|
150
|
+
if (name && !this.#matches(name, directory)) return
|
|
151
|
+
this.#scheduleReload()
|
|
152
|
+
})
|
|
153
|
+
} catch (error) {
|
|
154
|
+
this.#logger.warn("角色设定监听失败,改动后需要重载插件", error)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
const handle = this.#watcher as { unref?: () => void }
|
|
158
|
+
handle.unref?.()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#matches(filename: string, directory: string): boolean {
|
|
162
|
+
if (!this.#path) return false
|
|
163
|
+
return resolve(directory, filename) === this.#path
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#scheduleReload(): void {
|
|
167
|
+
if (this.#timer) clearTimeout(this.#timer)
|
|
168
|
+
this.#timer = setTimeout(() => {
|
|
169
|
+
this.#timer = undefined
|
|
170
|
+
void this.reload()
|
|
171
|
+
}, RELOAD_DEBOUNCE_MS)
|
|
172
|
+
const handle = this.#timer as { unref?: () => void }
|
|
173
|
+
handle.unref?.()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 供测试与其它模块定位插件包目录。 */
|
|
178
|
+
export function packageDirectory(): string {
|
|
179
|
+
return import.meta.dirname
|
|
180
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
import { targetLabel } from "../chat"
|
|
3
|
+
import type { Logger } from "../logger"
|
|
4
|
+
import type { SessionManager } from "../session/manager"
|
|
5
|
+
|
|
6
|
+
/** OpenCode 内置的“向用户提问”工具 id。 */
|
|
7
|
+
export const QUESTION_TOOL = "question"
|
|
8
|
+
|
|
9
|
+
/** 拒绝提问工具时回给模型的说明。 */
|
|
10
|
+
export const ASK_REFUSAL =
|
|
11
|
+
"不允许调用 question 工具:QQ 会话里请直接用 qq_send_message 询问用户(Code Mode 下调用方式为 tools.qq.send_message)。"
|
|
12
|
+
|
|
13
|
+
export interface AskGuardDeps {
|
|
14
|
+
ctx: Plugin.Context
|
|
15
|
+
logger: Logger
|
|
16
|
+
manager: SessionManager
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 在 QQ 会话里拦截内置 question 工具,要求模型改用 qq_send_message 询问用户。
|
|
21
|
+
* 非 QQ 会话不受影响。
|
|
22
|
+
*/
|
|
23
|
+
export async function registerAskGuard(deps: AskGuardDeps): Promise<{ dispose(): Promise<void> }> {
|
|
24
|
+
const { ctx, logger, manager } = deps
|
|
25
|
+
return await ctx.tool.hook("execute.before", async (event) => {
|
|
26
|
+
if (event.tool !== QUESTION_TOOL) return
|
|
27
|
+
const target = manager.targetForSession(event.sessionID)
|
|
28
|
+
if (!target) return
|
|
29
|
+
logger.warn(`${targetLabel(target)} 会话里模型尝试调用 ${QUESTION_TOOL} 工具,已拒绝`)
|
|
30
|
+
throw new Error(ASK_REFUSAL)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
File without changes
|