@cmach_socket/opencode-qq-bot 0.2.0 → 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 +54 -22
- package/package.json +2 -2
- package/src/config.ts +6 -1
- package/src/index.ts +19 -13
- 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.0",
|
|
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/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
|