koishi-plugin-chatluna-think-viewer 2.0.0 → 2.0.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 +2 -2
- package/index.js +313 -313
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
- 依赖 `chatluna-character` 存储的思考上下文,支持命令与前缀关键词调用。
|
|
7
7
|
- 支持群聊使用(可配置是否允许私聊)。
|
|
8
8
|
- **异常格式自动撤回/拦截**:默认检测 `<think>`、`<status>`、`<output>`、`<analysis>`、`<system>` 等块或调试 JSON、think/json/yaml 代码块;命中后可选择先发后撤回(recall)或直接阻止(block)。
|
|
9
|
-
-
|
|
9
|
+
- **严格输出模式(可选)**:仅当开启 `guardStrictOutputOnly` 时,要求 `<output><message>…</message></output>` 结构;@ 仅允许数字 user_id,1~5 条 message。不开启则不做白名单校验,避免正常消息被误撤回。
|
|
10
10
|
|
|
11
11
|
## 安装
|
|
12
12
|
```bash
|
|
@@ -28,7 +28,7 @@ plugins:
|
|
|
28
28
|
guardEnabled: true
|
|
29
29
|
guardMode: recall # recall | block
|
|
30
30
|
guardDelay: 1 # 撤回延迟(秒),block 模式忽略
|
|
31
|
-
guardStrictOutputOnly:
|
|
31
|
+
guardStrictOutputOnly: false # 默认关闭严格模式,避免误撤回
|
|
32
32
|
guardStrictPattern: '^\s*<output>\s*(<message>(?:<at>\d+<\/at>\s*)?(?:<sticker>[^<]*<\/sticker>|[^<]*)<\/message>\s*){1,5}<\/output>\s*$'
|
|
33
33
|
guardForbiddenPatterns:
|
|
34
34
|
- '<think>[\\s\\S]*?<\\/think>'
|
package/index.js
CHANGED
|
@@ -1,313 +1,313 @@
|
|
|
1
|
-
const { Schema, h } = require('koishi');
|
|
2
|
-
const { Bot } = require('@satorijs/core');
|
|
3
|
-
|
|
4
|
-
const name = 'chatluna-think-viewer';
|
|
5
|
-
|
|
6
|
-
const inject = {
|
|
7
|
-
chatluna_character: { required: true },
|
|
8
|
-
chatluna: { required: false },
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
const defaultForbidden = [
|
|
12
|
-
'<think>[\\s\\S]*?<\\/think>',
|
|
13
|
-
'<status>[\\s\\S]*?<\\/status>',
|
|
14
|
-
'<output>[\\s\\S]*?<\\/output>',
|
|
15
|
-
'<analysis>[\\s\\S]*?<\\/analysis>',
|
|
16
|
-
'<system>[\\s\\S]*?<\\/system>',
|
|
17
|
-
'```\\s*think[\\s\\S]*?```',
|
|
18
|
-
'```\\s*(json|yaml|yml)[\\s\\S]*?```',
|
|
19
|
-
'"role"\\s*:\\s*"assistant"',
|
|
20
|
-
'"analysis"\\s*:',
|
|
21
|
-
'"thought"\\s*:',
|
|
22
|
-
'(?:human_relations|人际关系)\\s*[:=]',
|
|
23
|
-
'(?:memory|记忆|记忆点|总结)\\s*[:=]',
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
// 严格 <output><message>... 结构:允许文本 / <at>user_id</at> 文本 / <sticker>url</sticker>
|
|
27
|
-
// 1~5 条 message,@ 仅允许数字 user_id
|
|
28
|
-
const strictOutputPattern =
|
|
29
|
-
'^\\s*<output>\\s*(<message>(?:<at>\\d+<\\/at>\\s*)?(?:<sticker>[^<]*<\\/sticker>|[^<]*)<\\/message>\\s*){1,5}<\\/output>\\s*$';
|
|
30
|
-
|
|
31
|
-
const Config = Schema.intersect([
|
|
32
|
-
Schema.object({
|
|
33
|
-
command: Schema.string().default('think').description('\u67e5\u770b\u601d\u8003\u5185\u5bb9\u7684\u6307\u4ee4\u540d'),
|
|
34
|
-
keywords: Schema.array(Schema.string()).default(['\u67e5\u770b\u601d\u8003', '\u4e0a\u6b21\u601d\u8003']).description('\u53ef\u65e0\u524d\u7f00\u89e6\u53d1\u7684\u5173\u952e\u8bcd'),
|
|
35
|
-
allowPrivate: Schema.boolean().default(false).description('\u662f\u5426\u5141\u8bb8\u5728\u79c1\u804a\u4e2d\u4f7f\u7528'),
|
|
36
|
-
emptyMessage: Schema.string().default('\u6682\u65f6\u6ca1\u6709\u53ef\u7528\u7684\u601d\u8003\u8bb0\u5f55\u3002').description('\u6ca1\u6709\u8bb0\u5f55\u65f6\u7684\u63d0\u793a\u6587\u672c'),
|
|
37
|
-
renderImage: Schema.boolean().default(false).description('\u662f\u5426\u901a\u8fc7\u0020\u0043\u0068\u0061\u0074\u004c\u0075\u006e\u0061\u0020\u0069\u006d\u0061\u0067\u0065\u0020\u0072\u0065\u006e\u0064\u0065\u0072\u0065\u0072\u0020\u5c06\u601d\u8003\u6e32\u67d3\u4e3a\u56fe\u7247\uff0c\u5931\u8d25\u65f6\u56de\u9000\u6587\u672c'),
|
|
38
|
-
}).description('\u601d\u8003\u67e5\u770b\u914d\u7f6e'),
|
|
39
|
-
Schema.object({
|
|
40
|
-
guardEnabled: Schema.boolean().default(true).description('\u5f02\u5e38\u8f93\u51fa\u81ea\u52a8\u62e6\u622a\u5f00\u5173'),
|
|
41
|
-
guardMode: Schema.union(['recall', 'block']).default('recall').description('\u0072\u0065\u0063\u0061\u006c\u006c\u003d\u5148\u53d1\u9001\u540e\u64a4\u56de\uff0c\u0062\u006c\u006f\u0063\u006b\u003d\u76f4\u63a5\u963b\u6b62\u53d1\u9001'),
|
|
42
|
-
guardDelay: Schema.number().default(1).min(0).max(60).description('\u64a4\u56de\u5ef6\u8fdf\uff08\u79d2\uff09'),
|
|
43
|
-
guardAllowPrivate: Schema.boolean().default(true).description('\u662f\u5426\u5728\u79c1\u804a\u4e2d\u4e5f\u542f\u7528\u62e6\u622a'),
|
|
44
|
-
guardGroups: Schema.array(Schema.string()).default([]).description('\u53ea\u5728\u8fd9\u4e9b\u7fa4\u751f\u6548\uff0c\u7559\u7a7a\u8868\u793a\u5168\u90e8'),
|
|
45
|
-
guardForbiddenPatterns: Schema.array(Schema.string())
|
|
46
|
-
.default(defaultForbidden)
|
|
47
|
-
.description('\u547d\u4e2d\u5373\u89c6\u4e3a\u5f02\u5e38\u7684\u6a21\u5f0f\uff0c\u7528\u4e8e\u907f\u514d\u601d\u8003\u6cc4\u9732\u6216\u0020\u004a\u0053\u004f\u004e\u0020\u751f\u51fa'),
|
|
48
|
-
guardAllowedPatterns: Schema.array(Schema.string())
|
|
49
|
-
.default(['[\\s\\S]+'])
|
|
50
|
-
.description('\u53ef\u9009\u767d\u540d\u5355\uff0c\u81f3\u5c11\u5339\u914d\u4e00\u4e2a\u624d\u7b97\u6b63\u5e38'),
|
|
51
|
-
guardStrictOutputOnly: Schema.boolean()
|
|
52
|
-
.default(false)
|
|
53
|
-
.description('\u53ea\u5141\u8bb8\u7b26\u5408 <output><message>\u2026</message></output> \u7ed3\u6784\u7684\u6d88\u606f\uff0c\u4e0d\u7b26\u5408\u5373\u62e6\u622a/\u64a4\u56de'),
|
|
54
|
-
guardStrictPattern: Schema.string()
|
|
55
|
-
.default(strictOutputPattern)
|
|
56
|
-
.description('\u81ea\u5b9a\u4e49\u4e25\u683c\u8f93\u51fa\u6b63\u5219\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u5185\u7f6e\u7684 <output><message>\u2026</message> \u89c4\u5219'),
|
|
57
|
-
guardLog: Schema.boolean().default(true).description('\u662f\u5426\u5728\u65e5\u5fd7\u8bb0\u5f55\u5f02\u5e38\u539f\u56e0\u548c\u5185\u5bb9'),
|
|
58
|
-
guardContentPreview: Schema.number().default(80).min(10).max(500).description('\u65e5\u5fd7\u5185\u5bb9\u9884\u89c8\u957f\u5ea6'),
|
|
59
|
-
}).description('\u5f02\u5e38\u8f93\u51fa\u81ea\u52a8\u5904\u7406'),
|
|
60
|
-
]);
|
|
61
|
-
|
|
62
|
-
function extractText(content) {
|
|
63
|
-
if (content == null) return '';
|
|
64
|
-
const normalized = h.normalize(content);
|
|
65
|
-
const parts = [];
|
|
66
|
-
for (const el of normalized) {
|
|
67
|
-
if (typeof el === 'string') {
|
|
68
|
-
parts.push(el);
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
if (Array.isArray(el.children) && el.children.length) {
|
|
72
|
-
parts.push(extractText(el.children));
|
|
73
|
-
}
|
|
74
|
-
const textLike = el.attrs?.content ?? el.attrs?.text ?? el.children?.join?.('') ?? '';
|
|
75
|
-
if (textLike) parts.push(textLike);
|
|
76
|
-
}
|
|
77
|
-
return parts.join('');
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function extractThink(text) {
|
|
81
|
-
// \u67d0\u4e9b\u6a21\u578b/\u4e2d\u95f4\u4ef6\u4f1a\u5728\u540c\u4e00\u6761\u6d88\u606f\u91cc\u591a\u6b21\u51fa\u73b0 <think>\uff0c\u53d6\u6700\u540e\u4e00\u6b21
|
|
82
|
-
let last = '';
|
|
83
|
-
const regex = /<think>([\s\S]*?)<\/think>/gi;
|
|
84
|
-
let m;
|
|
85
|
-
while ((m = regex.exec(text)) !== null) {
|
|
86
|
-
last = m[1];
|
|
87
|
-
}
|
|
88
|
-
return last.trim();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function formatThink(text) {
|
|
92
|
-
if (!text) return text;
|
|
93
|
-
// \u5c1d\u8bd5\u683c\u5f0f\u5316 JSON\uff0c\u5931\u8d25\u5219\u505a\u57fa\u7840\u53bb\u7a7a\u884c/\u7f29\u8fdb\u7f8e\u5316
|
|
94
|
-
try {
|
|
95
|
-
const parsed = JSON.parse(text);
|
|
96
|
-
return JSON.stringify(parsed, null, 2);
|
|
97
|
-
} catch {
|
|
98
|
-
const lines = text.split('\n').map((l) => l.trimEnd());
|
|
99
|
-
const filtered = lines.filter((l, idx, arr) => !(l === '' && arr[idx - 1] === ''));
|
|
100
|
-
const nonEmpty = filtered.filter((l) => l.trim().length > 0);
|
|
101
|
-
const minIndent = nonEmpty.length
|
|
102
|
-
? Math.min(
|
|
103
|
-
...nonEmpty.map((l) => {
|
|
104
|
-
const m = l.match(/^(\s*)/);
|
|
105
|
-
return m ? m[1].length : 0;
|
|
106
|
-
}),
|
|
107
|
-
)
|
|
108
|
-
: 0;
|
|
109
|
-
return filtered.map((l) => l.slice(minIndent)).join('\n');
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function parseIndex(rawIndex) {
|
|
114
|
-
if (!rawIndex) return 1;
|
|
115
|
-
if (typeof rawIndex === 'number' && Number.isFinite(rawIndex) && rawIndex > 0) return Math.floor(rawIndex);
|
|
116
|
-
const match = String(rawIndex).match(/\d+/);
|
|
117
|
-
if (!match) return 1;
|
|
118
|
-
const num = parseInt(match[0], 10);
|
|
119
|
-
return Number.isFinite(num) && num > 0 ? num : 1;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function getNthAiMessage(messages, n = 1) {
|
|
123
|
-
if (!Array.isArray(messages) || n < 1) return null;
|
|
124
|
-
let count = 0;
|
|
125
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
126
|
-
const msg = messages[i];
|
|
127
|
-
const type = typeof msg?._getType === 'function' ? msg._getType() : msg?.type || msg?.role;
|
|
128
|
-
if (type === 'ai' || type === 'assistant') {
|
|
129
|
-
count += 1;
|
|
130
|
-
if (count === n) return msg;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function getNthThink(messages, n = 1) {
|
|
137
|
-
if (!Array.isArray(messages) || n < 1) return null;
|
|
138
|
-
let count = 0;
|
|
139
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
140
|
-
const msg = messages[i];
|
|
141
|
-
const type = typeof msg?._getType === 'function' ? msg._getType() : msg?.type || msg?.role;
|
|
142
|
-
if (type !== 'ai' && type !== 'assistant') continue;
|
|
143
|
-
const think = extractThink(extractText(msg.content));
|
|
144
|
-
if (!think) continue;
|
|
145
|
-
count += 1;
|
|
146
|
-
if (count === n) return think;
|
|
147
|
-
}
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function getLatestRawThink(temp) {
|
|
152
|
-
if (!temp) return '';
|
|
153
|
-
const candidates = [
|
|
154
|
-
temp?.lastCompletion?.raw?.choices?.[0]?.message?.content,
|
|
155
|
-
temp?.lastCompletion?.raw?.content,
|
|
156
|
-
temp?.lastCompletion?.content,
|
|
157
|
-
];
|
|
158
|
-
for (const c of candidates) {
|
|
159
|
-
const think = extractThink(extractText(c));
|
|
160
|
-
if (think) return think;
|
|
161
|
-
}
|
|
162
|
-
return '';
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function compileRegex(list) {
|
|
166
|
-
return (list || [])
|
|
167
|
-
.map((p) => {
|
|
168
|
-
try {
|
|
169
|
-
return new RegExp(p, 'i');
|
|
170
|
-
} catch (err) {
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
})
|
|
174
|
-
.filter(Boolean);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function detectAbnormal(text, forbidden, allowed) {
|
|
178
|
-
if (!text) return null;
|
|
179
|
-
for (const re of forbidden) {
|
|
180
|
-
if (re.test(text)) return `\u547d\u4e2d\u7981\u6b62\u6a21\u5f0f: /${re.source}/`;
|
|
181
|
-
}
|
|
182
|
-
if (allowed.length && !allowed.some((re) => re.test(text))) {
|
|
183
|
-
return '\u672a\u5339\u914d\u4efb\u4f55\u5141\u8bb8\u6a21\u5f0f';
|
|
184
|
-
}
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function shorten(text, limit = 80) {
|
|
189
|
-
if (!text) return '';
|
|
190
|
-
if (text.length <= limit) return text;
|
|
191
|
-
return `${text.slice(0, limit)}...(${text.length} chars)`;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function shouldGuard(config, options) {
|
|
195
|
-
const session = options?.session;
|
|
196
|
-
if (!session) return false;
|
|
197
|
-
const guildId = session.guildId || session.event?.guild?.id;
|
|
198
|
-
const isGroup = !!guildId;
|
|
199
|
-
if (!config.guardAllowPrivate && !isGroup) return false;
|
|
200
|
-
if (config.guardGroups?.length && isGroup && !config.guardGroups.includes(guildId)) return false;
|
|
201
|
-
return true;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function applyGuard(ctx, config) {
|
|
205
|
-
if (!config.guardEnabled) return;
|
|
206
|
-
const logger = ctx.logger(`${name}:guard`);
|
|
207
|
-
const forbidden = compileRegex(config.guardForbiddenPatterns);
|
|
208
|
-
const allowed = config.guardStrictOutputOnly
|
|
209
|
-
? compileRegex([config.guardStrictPattern || strictOutputPattern])
|
|
210
|
-
: compileRegex(config.guardAllowedPatterns);
|
|
211
|
-
const original = Bot.prototype.sendMessage;
|
|
212
|
-
|
|
213
|
-
Bot.prototype.sendMessage = async function patched(channelId, content, referrer, options = {}) {
|
|
214
|
-
if (!shouldGuard(config, options)) {
|
|
215
|
-
return original.call(this, channelId, content, referrer, options);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const text = extractText(content);
|
|
219
|
-
const reason = detectAbnormal(text, forbidden, allowed);
|
|
220
|
-
|
|
221
|
-
if (!reason) {
|
|
222
|
-
return original.call(this, channelId, content, referrer, options);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const preview = shorten(text, config.guardContentPreview);
|
|
226
|
-
if (config.guardMode === 'block') {
|
|
227
|
-
if (config.guardLog) logger.warn(`[block] ${reason} | content: ${preview}`);
|
|
228
|
-
return [];
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const ids = await original.call(this, channelId, content, referrer, options);
|
|
232
|
-
if (config.guardLog) logger.warn(`[recall] ${reason} | content: ${preview}`);
|
|
233
|
-
const delay = Math.max(0, config.guardDelay) * 1000;
|
|
234
|
-
if (Array.isArray(ids) && ids.length && typeof this.deleteMessage === 'function') {
|
|
235
|
-
setTimeout(() => {
|
|
236
|
-
for (const id of ids) {
|
|
237
|
-
this.deleteMessage(channelId, id).catch((err) => {
|
|
238
|
-
logger.warn(`[recall-failed] id=${id} reason=${err?.message || err}`);
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
}, delay);
|
|
242
|
-
}
|
|
243
|
-
return ids;
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
ctx.on('dispose', () => {
|
|
247
|
-
Bot.prototype.sendMessage = original;
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function apply(ctx, config) {
|
|
252
|
-
// \u601d\u8003\u67e5\u770b\u6307\u4ee4
|
|
253
|
-
const cmd = ctx
|
|
254
|
-
.command(`${config.command} [index:string]`, '\u8bfb\u53d6\u4e0a\u4e00\u6761\u542b <think> \u7684\u5185\u5bb9\uff0c\u53ef\u6307\u5b9a\u5012\u6570\u7b2c N \u6761')
|
|
255
|
-
.usage('\u4e0d\u5e26\u53c2\u6570\u9ed8\u8ba4\u6700\u65b0\uff1b\u793a\u4f8b\uff1athink 2 \u67e5\u8be2\u5012\u6570\u7b2c 2 \u6761 AI \u56de\u590d\u7684\u601d\u8003');
|
|
256
|
-
|
|
257
|
-
for (const keyword of config.keywords || []) {
|
|
258
|
-
cmd.shortcut(keyword, { prefix: false });
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
cmd.action(async ({ session, args }, rawIndex) => {
|
|
262
|
-
if (!config.allowPrivate && !session.guildId) {
|
|
263
|
-
return '\u4e0d\u652f\u6301\u5728\u79c1\u804a\u4e2d\u67e5\u8be2\u3002';
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const service = ctx.chatluna_character;
|
|
267
|
-
if (!service) return 'chatluna-character \u672a\u52a0\u8f7d\u3002';
|
|
268
|
-
|
|
269
|
-
const temp = await service.getTemp(session);
|
|
270
|
-
const targetIndex = parseIndex(rawIndex ?? args?.[0]);
|
|
271
|
-
|
|
272
|
-
// 1) \u4f18\u5148\u8bfb\u53d6\u6700\u65b0\u4e00\u6b21\u539f\u59cb\u54cd\u5e94\uff08\u901a\u5e38\u4ecd\u542b <think>\uff09\uff0c\u4ec5\u5bf9\u7b2c 1 \u6761\u6709\u6548
|
|
273
|
-
const thinkFromRaw = targetIndex === 1 ? getLatestRawThink(temp) : '';
|
|
274
|
-
|
|
275
|
-
// 2) \u5386\u53f2 completionMessages \u4e2d\u771f\u6b63\u5e26 <think> \u7684 AI \u6d88\u606f
|
|
276
|
-
const messages = temp?.completionMessages || [];
|
|
277
|
-
const thinkFromHistory = thinkFromRaw ? '' : getNthThink(messages, targetIndex);
|
|
278
|
-
|
|
279
|
-
// 3) \u56de\u9000\uff1a\u7b2c N \u6761 AI \u6d88\u606f\u518d\u5c1d\u8bd5\u62bd\u53d6 <think>
|
|
280
|
-
const fallbackMsg = thinkFromRaw || thinkFromHistory ? null : getNthAiMessage(messages, targetIndex);
|
|
281
|
-
const think = thinkFromRaw || thinkFromHistory || extractThink(extractText(fallbackMsg?.content));
|
|
282
|
-
const formatted = formatThink(think);
|
|
283
|
-
if (!formatted) return config.emptyMessage;
|
|
284
|
-
|
|
285
|
-
if (config.renderImage && ctx.chatluna?.renderer) {
|
|
286
|
-
try {
|
|
287
|
-
const title = `### \u4e0a\u4e00\u6761\u601d\u8003\uff08\u5012\u6570\u7b2c ${targetIndex} \u6761\uff09`;
|
|
288
|
-
const markdown = `<div align="center">\n${title}\n</div>\n\n<div align="left">\n${formatted}\n</div>`;
|
|
289
|
-
const rendered = await ctx.chatluna.renderer.render(
|
|
290
|
-
{
|
|
291
|
-
content: [{ type: 'text', text: markdown }],
|
|
292
|
-
},
|
|
293
|
-
{ type: 'image', session },
|
|
294
|
-
);
|
|
295
|
-
if (rendered?.length) return rendered.map((r) => r.element);
|
|
296
|
-
} catch (err) {
|
|
297
|
-
ctx.logger?.warn?.('[think-viewer] image render failed, fallback text', err);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return `\u4e0a\u4e00\u6761\u601d\u8003\uff08\u5012\u6570\u7b2c ${targetIndex} \u6761\uff09\n${formatted}`;
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
// \u5f02\u5e38\u8f93\u51fa\u81ea\u52a8\u5904\u7406
|
|
305
|
-
applyGuard(ctx, config);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
module.exports = {
|
|
309
|
-
name,
|
|
310
|
-
apply,
|
|
311
|
-
Config,
|
|
312
|
-
inject,
|
|
313
|
-
};
|
|
1
|
+
const { Schema, h } = require('koishi');
|
|
2
|
+
const { Bot } = require('@satorijs/core');
|
|
3
|
+
|
|
4
|
+
const name = 'chatluna-think-viewer';
|
|
5
|
+
|
|
6
|
+
const inject = {
|
|
7
|
+
chatluna_character: { required: true },
|
|
8
|
+
chatluna: { required: false },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const defaultForbidden = [
|
|
12
|
+
'<think>[\\s\\S]*?<\\/think>',
|
|
13
|
+
'<status>[\\s\\S]*?<\\/status>',
|
|
14
|
+
'<output>[\\s\\S]*?<\\/output>',
|
|
15
|
+
'<analysis>[\\s\\S]*?<\\/analysis>',
|
|
16
|
+
'<system>[\\s\\S]*?<\\/system>',
|
|
17
|
+
'```\\s*think[\\s\\S]*?```',
|
|
18
|
+
'```\\s*(json|yaml|yml)[\\s\\S]*?```',
|
|
19
|
+
'"role"\\s*:\\s*"assistant"',
|
|
20
|
+
'"analysis"\\s*:',
|
|
21
|
+
'"thought"\\s*:',
|
|
22
|
+
'(?:human_relations|人际关系)\\s*[:=]',
|
|
23
|
+
'(?:memory|记忆|记忆点|总结)\\s*[:=]',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// 严格 <output><message>... 结构:允许文本 / <at>user_id</at> 文本 / <sticker>url</sticker>
|
|
27
|
+
// 1~5 条 message,@ 仅允许数字 user_id
|
|
28
|
+
const strictOutputPattern =
|
|
29
|
+
'^\\s*<output>\\s*(<message>(?:<at>\\d+<\\/at>\\s*)?(?:<sticker>[^<]*<\\/sticker>|[^<]*)<\\/message>\\s*){1,5}<\\/output>\\s*$';
|
|
30
|
+
|
|
31
|
+
const Config = Schema.intersect([
|
|
32
|
+
Schema.object({
|
|
33
|
+
command: Schema.string().default('think').description('\u67e5\u770b\u601d\u8003\u5185\u5bb9\u7684\u6307\u4ee4\u540d'),
|
|
34
|
+
keywords: Schema.array(Schema.string()).default(['\u67e5\u770b\u601d\u8003', '\u4e0a\u6b21\u601d\u8003']).description('\u53ef\u65e0\u524d\u7f00\u89e6\u53d1\u7684\u5173\u952e\u8bcd'),
|
|
35
|
+
allowPrivate: Schema.boolean().default(false).description('\u662f\u5426\u5141\u8bb8\u5728\u79c1\u804a\u4e2d\u4f7f\u7528'),
|
|
36
|
+
emptyMessage: Schema.string().default('\u6682\u65f6\u6ca1\u6709\u53ef\u7528\u7684\u601d\u8003\u8bb0\u5f55\u3002').description('\u6ca1\u6709\u8bb0\u5f55\u65f6\u7684\u63d0\u793a\u6587\u672c'),
|
|
37
|
+
renderImage: Schema.boolean().default(false).description('\u662f\u5426\u901a\u8fc7\u0020\u0043\u0068\u0061\u0074\u004c\u0075\u006e\u0061\u0020\u0069\u006d\u0061\u0067\u0065\u0020\u0072\u0065\u006e\u0064\u0065\u0072\u0065\u0072\u0020\u5c06\u601d\u8003\u6e32\u67d3\u4e3a\u56fe\u7247\uff0c\u5931\u8d25\u65f6\u56de\u9000\u6587\u672c'),
|
|
38
|
+
}).description('\u601d\u8003\u67e5\u770b\u914d\u7f6e'),
|
|
39
|
+
Schema.object({
|
|
40
|
+
guardEnabled: Schema.boolean().default(true).description('\u5f02\u5e38\u8f93\u51fa\u81ea\u52a8\u62e6\u622a\u5f00\u5173'),
|
|
41
|
+
guardMode: Schema.union(['recall', 'block']).default('recall').description('\u0072\u0065\u0063\u0061\u006c\u006c\u003d\u5148\u53d1\u9001\u540e\u64a4\u56de\uff0c\u0062\u006c\u006f\u0063\u006b\u003d\u76f4\u63a5\u963b\u6b62\u53d1\u9001'),
|
|
42
|
+
guardDelay: Schema.number().default(1).min(0).max(60).description('\u64a4\u56de\u5ef6\u8fdf\uff08\u79d2\uff09'),
|
|
43
|
+
guardAllowPrivate: Schema.boolean().default(true).description('\u662f\u5426\u5728\u79c1\u804a\u4e2d\u4e5f\u542f\u7528\u62e6\u622a'),
|
|
44
|
+
guardGroups: Schema.array(Schema.string()).default([]).description('\u53ea\u5728\u8fd9\u4e9b\u7fa4\u751f\u6548\uff0c\u7559\u7a7a\u8868\u793a\u5168\u90e8'),
|
|
45
|
+
guardForbiddenPatterns: Schema.array(Schema.string())
|
|
46
|
+
.default(defaultForbidden)
|
|
47
|
+
.description('\u547d\u4e2d\u5373\u89c6\u4e3a\u5f02\u5e38\u7684\u6a21\u5f0f\uff0c\u7528\u4e8e\u907f\u514d\u601d\u8003\u6cc4\u9732\u6216\u0020\u004a\u0053\u004f\u004e\u0020\u751f\u51fa'),
|
|
48
|
+
guardAllowedPatterns: Schema.array(Schema.string())
|
|
49
|
+
.default(['[\\s\\S]+'])
|
|
50
|
+
.description('\u53ef\u9009\u767d\u540d\u5355\uff0c\u81f3\u5c11\u5339\u914d\u4e00\u4e2a\u624d\u7b97\u6b63\u5e38'),
|
|
51
|
+
guardStrictOutputOnly: Schema.boolean()
|
|
52
|
+
.default(false)
|
|
53
|
+
.description('\u53ea\u5141\u8bb8\u7b26\u5408 <output><message>\u2026</message></output> \u7ed3\u6784\u7684\u6d88\u606f\uff0c\u4e0d\u7b26\u5408\u5373\u62e6\u622a/\u64a4\u56de'),
|
|
54
|
+
guardStrictPattern: Schema.string()
|
|
55
|
+
.default(strictOutputPattern)
|
|
56
|
+
.description('\u81ea\u5b9a\u4e49\u4e25\u683c\u8f93\u51fa\u6b63\u5219\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u5185\u7f6e\u7684 <output><message>\u2026</message> \u89c4\u5219'),
|
|
57
|
+
guardLog: Schema.boolean().default(true).description('\u662f\u5426\u5728\u65e5\u5fd7\u8bb0\u5f55\u5f02\u5e38\u539f\u56e0\u548c\u5185\u5bb9'),
|
|
58
|
+
guardContentPreview: Schema.number().default(80).min(10).max(500).description('\u65e5\u5fd7\u5185\u5bb9\u9884\u89c8\u957f\u5ea6'),
|
|
59
|
+
}).description('\u5f02\u5e38\u8f93\u51fa\u81ea\u52a8\u5904\u7406'),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
function extractText(content) {
|
|
63
|
+
if (content == null) return '';
|
|
64
|
+
const normalized = h.normalize(content);
|
|
65
|
+
const parts = [];
|
|
66
|
+
for (const el of normalized) {
|
|
67
|
+
if (typeof el === 'string') {
|
|
68
|
+
parts.push(el);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(el.children) && el.children.length) {
|
|
72
|
+
parts.push(extractText(el.children));
|
|
73
|
+
}
|
|
74
|
+
const textLike = el.attrs?.content ?? el.attrs?.text ?? el.children?.join?.('') ?? '';
|
|
75
|
+
if (textLike) parts.push(textLike);
|
|
76
|
+
}
|
|
77
|
+
return parts.join('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function extractThink(text) {
|
|
81
|
+
// \u67d0\u4e9b\u6a21\u578b/\u4e2d\u95f4\u4ef6\u4f1a\u5728\u540c\u4e00\u6761\u6d88\u606f\u91cc\u591a\u6b21\u51fa\u73b0 <think>\uff0c\u53d6\u6700\u540e\u4e00\u6b21
|
|
82
|
+
let last = '';
|
|
83
|
+
const regex = /<think>([\s\S]*?)<\/think>/gi;
|
|
84
|
+
let m;
|
|
85
|
+
while ((m = regex.exec(text)) !== null) {
|
|
86
|
+
last = m[1];
|
|
87
|
+
}
|
|
88
|
+
return last.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatThink(text) {
|
|
92
|
+
if (!text) return text;
|
|
93
|
+
// \u5c1d\u8bd5\u683c\u5f0f\u5316 JSON\uff0c\u5931\u8d25\u5219\u505a\u57fa\u7840\u53bb\u7a7a\u884c/\u7f29\u8fdb\u7f8e\u5316
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(text);
|
|
96
|
+
return JSON.stringify(parsed, null, 2);
|
|
97
|
+
} catch {
|
|
98
|
+
const lines = text.split('\n').map((l) => l.trimEnd());
|
|
99
|
+
const filtered = lines.filter((l, idx, arr) => !(l === '' && arr[idx - 1] === ''));
|
|
100
|
+
const nonEmpty = filtered.filter((l) => l.trim().length > 0);
|
|
101
|
+
const minIndent = nonEmpty.length
|
|
102
|
+
? Math.min(
|
|
103
|
+
...nonEmpty.map((l) => {
|
|
104
|
+
const m = l.match(/^(\s*)/);
|
|
105
|
+
return m ? m[1].length : 0;
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
: 0;
|
|
109
|
+
return filtered.map((l) => l.slice(minIndent)).join('\n');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseIndex(rawIndex) {
|
|
114
|
+
if (!rawIndex) return 1;
|
|
115
|
+
if (typeof rawIndex === 'number' && Number.isFinite(rawIndex) && rawIndex > 0) return Math.floor(rawIndex);
|
|
116
|
+
const match = String(rawIndex).match(/\d+/);
|
|
117
|
+
if (!match) return 1;
|
|
118
|
+
const num = parseInt(match[0], 10);
|
|
119
|
+
return Number.isFinite(num) && num > 0 ? num : 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getNthAiMessage(messages, n = 1) {
|
|
123
|
+
if (!Array.isArray(messages) || n < 1) return null;
|
|
124
|
+
let count = 0;
|
|
125
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
126
|
+
const msg = messages[i];
|
|
127
|
+
const type = typeof msg?._getType === 'function' ? msg._getType() : msg?.type || msg?.role;
|
|
128
|
+
if (type === 'ai' || type === 'assistant') {
|
|
129
|
+
count += 1;
|
|
130
|
+
if (count === n) return msg;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getNthThink(messages, n = 1) {
|
|
137
|
+
if (!Array.isArray(messages) || n < 1) return null;
|
|
138
|
+
let count = 0;
|
|
139
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
140
|
+
const msg = messages[i];
|
|
141
|
+
const type = typeof msg?._getType === 'function' ? msg._getType() : msg?.type || msg?.role;
|
|
142
|
+
if (type !== 'ai' && type !== 'assistant') continue;
|
|
143
|
+
const think = extractThink(extractText(msg.content));
|
|
144
|
+
if (!think) continue;
|
|
145
|
+
count += 1;
|
|
146
|
+
if (count === n) return think;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getLatestRawThink(temp) {
|
|
152
|
+
if (!temp) return '';
|
|
153
|
+
const candidates = [
|
|
154
|
+
temp?.lastCompletion?.raw?.choices?.[0]?.message?.content,
|
|
155
|
+
temp?.lastCompletion?.raw?.content,
|
|
156
|
+
temp?.lastCompletion?.content,
|
|
157
|
+
];
|
|
158
|
+
for (const c of candidates) {
|
|
159
|
+
const think = extractThink(extractText(c));
|
|
160
|
+
if (think) return think;
|
|
161
|
+
}
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function compileRegex(list) {
|
|
166
|
+
return (list || [])
|
|
167
|
+
.map((p) => {
|
|
168
|
+
try {
|
|
169
|
+
return new RegExp(p, 'i');
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
.filter(Boolean);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function detectAbnormal(text, forbidden, allowed, strictMode = false) {
|
|
178
|
+
if (!text) return null;
|
|
179
|
+
for (const re of forbidden) {
|
|
180
|
+
if (re.test(text)) return `\u547d\u4e2d\u7981\u6b62\u6a21\u5f0f: /${re.source}/`;
|
|
181
|
+
}
|
|
182
|
+
if (allowed.length && !allowed.some((re) => re.test(text))) {
|
|
183
|
+
return '\u672a\u5339\u914d\u4efb\u4f55\u5141\u8bb8\u6a21\u5f0f';
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function shorten(text, limit = 80) {
|
|
189
|
+
if (!text) return '';
|
|
190
|
+
if (text.length <= limit) return text;
|
|
191
|
+
return `${text.slice(0, limit)}...(${text.length} chars)`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function shouldGuard(config, options) {
|
|
195
|
+
const session = options?.session;
|
|
196
|
+
if (!session) return false;
|
|
197
|
+
const guildId = session.guildId || session.event?.guild?.id;
|
|
198
|
+
const isGroup = !!guildId;
|
|
199
|
+
if (!config.guardAllowPrivate && !isGroup) return false;
|
|
200
|
+
if (config.guardGroups?.length && isGroup && !config.guardGroups.includes(guildId)) return false;
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function applyGuard(ctx, config) {
|
|
205
|
+
if (!config.guardEnabled) return;
|
|
206
|
+
const logger = ctx.logger(`${name}:guard`);
|
|
207
|
+
const forbidden = compileRegex(config.guardForbiddenPatterns);
|
|
208
|
+
const allowed = config.guardStrictOutputOnly
|
|
209
|
+
? compileRegex([config.guardStrictPattern || strictOutputPattern])
|
|
210
|
+
: compileRegex(config.guardAllowedPatterns);
|
|
211
|
+
const original = Bot.prototype.sendMessage;
|
|
212
|
+
|
|
213
|
+
Bot.prototype.sendMessage = async function patched(channelId, content, referrer, options = {}) {
|
|
214
|
+
if (!shouldGuard(config, options)) {
|
|
215
|
+
return original.call(this, channelId, content, referrer, options);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const text = extractText(content);
|
|
219
|
+
const reason = detectAbnormal(text, forbidden, allowed);
|
|
220
|
+
|
|
221
|
+
if (!reason) {
|
|
222
|
+
return original.call(this, channelId, content, referrer, options);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const preview = shorten(text, config.guardContentPreview);
|
|
226
|
+
if (config.guardMode === 'block') {
|
|
227
|
+
if (config.guardLog) logger.warn(`[block] ${reason} | content: ${preview}`);
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const ids = await original.call(this, channelId, content, referrer, options);
|
|
232
|
+
if (config.guardLog) logger.warn(`[recall] ${reason} | content: ${preview}`);
|
|
233
|
+
const delay = Math.max(0, config.guardDelay) * 1000;
|
|
234
|
+
if (Array.isArray(ids) && ids.length && typeof this.deleteMessage === 'function') {
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
for (const id of ids) {
|
|
237
|
+
this.deleteMessage(channelId, id).catch((err) => {
|
|
238
|
+
logger.warn(`[recall-failed] id=${id} reason=${err?.message || err}`);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}, delay);
|
|
242
|
+
}
|
|
243
|
+
return ids;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
ctx.on('dispose', () => {
|
|
247
|
+
Bot.prototype.sendMessage = original;
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function apply(ctx, config) {
|
|
252
|
+
// \u601d\u8003\u67e5\u770b\u6307\u4ee4
|
|
253
|
+
const cmd = ctx
|
|
254
|
+
.command(`${config.command} [index:string]`, '\u8bfb\u53d6\u4e0a\u4e00\u6761\u542b <think> \u7684\u5185\u5bb9\uff0c\u53ef\u6307\u5b9a\u5012\u6570\u7b2c N \u6761')
|
|
255
|
+
.usage('\u4e0d\u5e26\u53c2\u6570\u9ed8\u8ba4\u6700\u65b0\uff1b\u793a\u4f8b\uff1athink 2 \u67e5\u8be2\u5012\u6570\u7b2c 2 \u6761 AI \u56de\u590d\u7684\u601d\u8003');
|
|
256
|
+
|
|
257
|
+
for (const keyword of config.keywords || []) {
|
|
258
|
+
cmd.shortcut(keyword, { prefix: false });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
cmd.action(async ({ session, args }, rawIndex) => {
|
|
262
|
+
if (!config.allowPrivate && !session.guildId) {
|
|
263
|
+
return '\u4e0d\u652f\u6301\u5728\u79c1\u804a\u4e2d\u67e5\u8be2\u3002';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const service = ctx.chatluna_character;
|
|
267
|
+
if (!service) return 'chatluna-character \u672a\u52a0\u8f7d\u3002';
|
|
268
|
+
|
|
269
|
+
const temp = await service.getTemp(session);
|
|
270
|
+
const targetIndex = parseIndex(rawIndex ?? args?.[0]);
|
|
271
|
+
|
|
272
|
+
// 1) \u4f18\u5148\u8bfb\u53d6\u6700\u65b0\u4e00\u6b21\u539f\u59cb\u54cd\u5e94\uff08\u901a\u5e38\u4ecd\u542b <think>\uff09\uff0c\u4ec5\u5bf9\u7b2c 1 \u6761\u6709\u6548
|
|
273
|
+
const thinkFromRaw = targetIndex === 1 ? getLatestRawThink(temp) : '';
|
|
274
|
+
|
|
275
|
+
// 2) \u5386\u53f2 completionMessages \u4e2d\u771f\u6b63\u5e26 <think> \u7684 AI \u6d88\u606f
|
|
276
|
+
const messages = temp?.completionMessages || [];
|
|
277
|
+
const thinkFromHistory = thinkFromRaw ? '' : getNthThink(messages, targetIndex);
|
|
278
|
+
|
|
279
|
+
// 3) \u56de\u9000\uff1a\u7b2c N \u6761 AI \u6d88\u606f\u518d\u5c1d\u8bd5\u62bd\u53d6 <think>
|
|
280
|
+
const fallbackMsg = thinkFromRaw || thinkFromHistory ? null : getNthAiMessage(messages, targetIndex);
|
|
281
|
+
const think = thinkFromRaw || thinkFromHistory || extractThink(extractText(fallbackMsg?.content));
|
|
282
|
+
const formatted = formatThink(think);
|
|
283
|
+
if (!formatted) return config.emptyMessage;
|
|
284
|
+
|
|
285
|
+
if (config.renderImage && ctx.chatluna?.renderer) {
|
|
286
|
+
try {
|
|
287
|
+
const title = `### \u4e0a\u4e00\u6761\u601d\u8003\uff08\u5012\u6570\u7b2c ${targetIndex} \u6761\uff09`;
|
|
288
|
+
const markdown = `<div align="center">\n${title}\n</div>\n\n<div align="left">\n${formatted}\n</div>`;
|
|
289
|
+
const rendered = await ctx.chatluna.renderer.render(
|
|
290
|
+
{
|
|
291
|
+
content: [{ type: 'text', text: markdown }],
|
|
292
|
+
},
|
|
293
|
+
{ type: 'image', session },
|
|
294
|
+
);
|
|
295
|
+
if (rendered?.length) return rendered.map((r) => r.element);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
ctx.logger?.warn?.('[think-viewer] image render failed, fallback text', err);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return `\u4e0a\u4e00\u6761\u601d\u8003\uff08\u5012\u6570\u7b2c ${targetIndex} \u6761\uff09\n${formatted}`;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// \u5f02\u5e38\u8f93\u51fa\u81ea\u52a8\u5904\u7406
|
|
305
|
+
applyGuard(ctx, config);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
name,
|
|
310
|
+
apply,
|
|
311
|
+
Config,
|
|
312
|
+
inject,
|
|
313
|
+
};
|