koishi-plugin-chatluna-think-viewer 1.0.17 → 1.0.18

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.
Files changed (3) hide show
  1. package/README.md +43 -38
  2. package/index.js +145 -38
  3. package/package.json +9 -6
package/README.md CHANGED
@@ -1,39 +1,44 @@
1
- # koishi-plugin-chatluna-think-viewer
2
-
3
- 通过命令或快捷关键词查看 `chatluna-character` 最近一次回复中的 `<think>` 思考内容,便于调试和复盘。
4
-
5
- ## 特性
6
- - 复用 `chatluna-character` 的内存,不额外占用数据库。
7
- - 支持命令与无前缀关键词触发。
8
- - 群聊可用,默认禁止私聊(可配置)。
9
-
10
- ## 安装
11
- ```bash
12
- # Koishi 控制台市场搜索「chatluna-think-viewer」安装
13
- # 或者 npm/yarn 安装
14
- npm install koishi-plugin-chatluna-think-viewer
15
- # yarn add koishi-plugin-chatluna-think-viewer
16
- ```
17
-
18
- ## 配置示例 (koishi.yml)
19
- ```yaml
20
- plugins:
21
- chatluna-character: {}
22
- chatluna-think-viewer:
23
- command: think
24
- keywords:
25
- - 查看思考
26
- - 上次思考
27
- allowPrivate: false
28
- emptyMessage: 暂时没有可用的思考记录。
29
- ```
30
-
31
- ## 使用
32
- - 在群聊中发送 `think`(根据你的命令前缀)或关键词 “查看思考 / 上次思考”,返回上一条回复的 `<think>` 内容。
33
-
34
- ## 依赖
35
- - koishi >= 4.18.0
36
- - koishi-plugin-chatluna-character >= 0.0.180
37
-
38
- ## 协议
1
+ # koishi-plugin-chatluna-think-viewer
2
+
3
+ ????/????? `chatluna-character` ??????? `<think>` ???????????????????????
4
+
5
+ ## ??
6
+ - ?? `chatluna-character` ??????????????????????
7
+ - ??????????????????
8
+ - **????????**?? bot ???????????????? `<think>` ????? JSON ???????????????????
9
+
10
+ ## ??
11
+ ```bash
12
+ # Koishi ??????? chatluna-think-viewer ??
13
+ npm install koishi-plugin-chatluna-think-viewer
14
+ ```
15
+
16
+ ## ???? (koishi.yml)
17
+ ```yaml
18
+ plugins:
19
+ chatluna-character: {}
20
+ chatluna-think-viewer:
21
+ command: think
22
+ keywords:
23
+ - ????
24
+ - ????
25
+ allowPrivate: false
26
+ emptyMessage: ????????????
27
+ # ??????
28
+ guardEnabled: true
29
+ guardMode: recall # recall | block
30
+ guardForbiddenPatterns:
31
+ - '<think>[\s\S]*?<\/think>'
32
+ - '"role"\s*:\s*"assistant"'
33
+ ```
34
+
35
+ ## ??
36
+ - ????? `think` ????????????? `<think>` ???`think 2` ????? 2 ??
37
+ - ???????????? bot ???????????????????????????????
38
+
39
+ ## ??
40
+ - koishi >= 4.18.0
41
+ - koishi-plugin-chatluna-character >= 0.0.180
42
+
43
+ ## ??
39
44
  MIT
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
- const { Schema } = require('koishi');
1
+ const { Schema, h } = require('koishi');
2
+ const { Bot } = require('@satorijs/core');
2
3
 
3
4
  const name = 'chatluna-think-viewer';
4
5
 
@@ -7,37 +8,59 @@ const inject = {
7
8
  chatluna: { required: false },
8
9
  };
9
10
 
10
- const Config = Schema.object({
11
- command: Schema.string().default('think').description('命令名。'),
12
- keywords: Schema.array(Schema.string()).default(['查看思考', '上次思考']).description('可无前缀触发的关键词。'),
13
- allowPrivate: Schema.boolean().default(false).description('是否允许在私聊中使用。'),
14
- emptyMessage: Schema.string().default('暂时没有可用的思考记录。').description('没有记录时的提示文本。'),
15
- renderImage: Schema.boolean().default(false).description('是否用 ChatLuna 的 image renderer 将思考渲染为图片,失败时回退文本。'),
16
- });
11
+ const defaultForbidden = [
12
+ '<think>[\\s\\S]*?<\\/think>',
13
+ '```\\s*think[\\s\\S]*?```',
14
+ '"role"\\s*:\\s*"assistant"',
15
+ '"analysis"\\s*:',
16
+ '"thought"\\s*:',
17
+ ];
18
+
19
+ const Config = Schema.intersect([
20
+ Schema.object({
21
+ command: Schema.string().default('think').description('�鿴˼�����ݵ�ָ����'),
22
+ keywords: Schema.array(Schema.string()).default(['�鿴˼��', '�ϴ�˼��']).description('����ǰ��ָ��Ĺؼ���'),
23
+ allowPrivate: Schema.boolean().default(false).description('�Ƿ�������˽��ʹ��'),
24
+ emptyMessage: Schema.string().default('��ʱû�п��õ�˼����¼��').description('û�м�¼ʱ����ʾ�ı�'),
25
+ renderImage: Schema.boolean().default(false).description('�Ƿ�ͨ�� ChatLuna �� image renderer ��˼����ȾΪͼƬ��ʧ��ʱ�����ı�'),
26
+ }).description('˼���鿴����'),
27
+ Schema.object({
28
+ guardEnabled: Schema.boolean().default(true).description('����쳣��ʽ���Զ�����/����'),
29
+ guardMode: Schema.union(['recall', 'block']).default('recall').description('recall=�ȷ��󳷻أ�block=ֱ����ֹ����'),
30
+ guardDelay: Schema.number().default(1).min(0).max(60).description('�����ӳ٣��룩'),
31
+ guardAllowPrivate: Schema.boolean().default(true).description('�Ƿ���˽���������쳣���'),
32
+ guardGroups: Schema.array(Schema.string()).default([]).description('ֻ����ЩȺ���ã���ձ�ʾȫ��'),
33
+ guardForbiddenPatterns: Schema.array(Schema.string())
34
+ .default(defaultForbidden)
35
+ .description('��������������Ϊ�쳣������˼����й©������ JSON ��'),
36
+ guardAllowedPatterns: Schema.array(Schema.string())
37
+ .default(['[\\s\\S]+'])
38
+ .description('��ѡ�İ�����������������һ������Ϊ����'),
39
+ guardLog: Schema.boolean().default(true).description('�Ƿ�����־�м�¼�쳣���ݺ�ԭ��'),
40
+ guardContentPreview: Schema.number().default(80).min(10).max(500).description('��־�ﱣ�������Ԥ���ַ���'),
41
+ }).description('�쳣��ʽ�Զ�����'),
42
+ ]);
17
43
 
18
44
  function extractText(content) {
19
- if (typeof content === 'string') return content;
20
- if (Array.isArray(content)) {
21
- return content
22
- .map((part) => {
23
- if (typeof part === 'string') return part;
24
- if (part && typeof part === 'object') {
25
- return part.text || part.content || part.value || '';
26
- }
27
- return '';
28
- })
29
- .join('');
30
- }
31
- if (content && typeof content === 'object') {
32
- if (typeof content.text === 'string') return content.text;
33
- if (typeof content.content === 'string') return content.content;
34
- if (typeof content.value === 'string') return content.value;
45
+ if (content == null) return '';
46
+ const normalized = h.normalize(content);
47
+ const parts = [];
48
+ for (const el of normalized) {
49
+ if (typeof el === 'string') {
50
+ parts.push(el);
51
+ continue;
52
+ }
53
+ if (Array.isArray(el.children) && el.children.length) {
54
+ parts.push(extractText(el.children));
55
+ }
56
+ const textLike = el.attrs?.content ?? el.attrs?.text ?? el.children?.join?.('') ?? '';
57
+ if (textLike) parts.push(textLike);
35
58
  }
36
- return '';
59
+ return parts.join('');
37
60
  }
38
61
 
39
62
  function extractThink(text) {
40
- // 某些模型/中间件会在同一条消息里多次出现 <think>,取最后一次出现的片段
63
+ // ijЩģ��/�м������ͬһ����Ϣ���γ��� <think>��ȡ���һ�γ��ֵ�Ƭ��
41
64
  let last = '';
42
65
  const regex = /<think>([\s\S]*?)<\/think>/gi;
43
66
  let m;
@@ -49,12 +72,11 @@ function extractThink(text) {
49
72
 
50
73
  function formatThink(text) {
51
74
  if (!text) return text;
52
- // 尝试格式化 JSON,失败则做基础去空行/缩进美化
75
+ // ���Ը�ʽ�� JSON��ʧ�����ȥ��β/�ϲ�����
53
76
  try {
54
77
  const parsed = JSON.parse(text);
55
78
  return JSON.stringify(parsed, null, 2);
56
79
  } catch {
57
- // 保留原文,去掉多余空行并统一缩进
58
80
  const lines = text.split('\n').map((l) => l.trimEnd());
59
81
  const filtered = lines.filter((l, idx, arr) => !(l === '' && arr[idx - 1] === ''));
60
82
  const nonEmpty = filtered.filter((l) => l.trim().length > 0);
@@ -122,10 +144,93 @@ function getLatestRawThink(temp) {
122
144
  return '';
123
145
  }
124
146
 
147
+ function compileRegex(list) {
148
+ return (list || []).map((p) => {
149
+ try {
150
+ return new RegExp(p, 'i');
151
+ } catch (err) {
152
+ return null;
153
+ }
154
+ }).filter(Boolean);
155
+ }
156
+
157
+ function detectAbnormal(text, forbidden, allowed) {
158
+ if (!text) return null;
159
+ for (const re of forbidden) {
160
+ if (re.test(text)) return `���н�ֹ����: /${re.source}/`;
161
+ }
162
+ if (allowed.length && !allowed.some((re) => re.test(text))) {
163
+ return 'δ�����κΰ���������';
164
+ }
165
+ return null;
166
+ }
167
+
168
+ function shorten(text, limit = 80) {
169
+ if (!text) return '';
170
+ if (text.length <= limit) return text;
171
+ return `${text.slice(0, limit)}...(${text.length} chars)`;
172
+ }
173
+
174
+ function shouldGuard(config, options) {
175
+ const session = options?.session;
176
+ if (!session) return false;
177
+ const guildId = session.guildId || session.event?.guild?.id;
178
+ const isGroup = !!guildId;
179
+ if (!config.guardAllowPrivate && !isGroup) return false;
180
+ if (config.guardGroups?.length && isGroup && !config.guardGroups.includes(guildId)) return false;
181
+ return true;
182
+ }
183
+
184
+ function applyGuard(ctx, config) {
185
+ if (!config.guardEnabled) return;
186
+ const logger = ctx.logger(`${name}:guard`);
187
+ const forbidden = compileRegex(config.guardForbiddenPatterns);
188
+ const allowed = compileRegex(config.guardAllowedPatterns);
189
+ const original = Bot.prototype.sendMessage;
190
+
191
+ Bot.prototype.sendMessage = async function patched(channelId, content, referrer, options = {}) {
192
+ if (!shouldGuard(config, options)) {
193
+ return original.call(this, channelId, content, referrer, options);
194
+ }
195
+
196
+ const text = extractText(content);
197
+ const reason = detectAbnormal(text, forbidden, allowed);
198
+
199
+ if (!reason) {
200
+ return original.call(this, channelId, content, referrer, options);
201
+ }
202
+
203
+ const preview = shorten(text, config.guardContentPreview);
204
+ if (config.guardMode === 'block') {
205
+ if (config.guardLog) logger.warn(`[block] ${reason} | content: ${preview}`);
206
+ return [];
207
+ }
208
+
209
+ const ids = await original.call(this, channelId, content, referrer, options);
210
+ if (config.guardLog) logger.warn(`[recall] ${reason} | content: ${preview}`);
211
+ const delay = Math.max(0, config.guardDelay) * 1000;
212
+ if (Array.isArray(ids) && ids.length && typeof this.deleteMessage === 'function') {
213
+ setTimeout(() => {
214
+ for (const id of ids) {
215
+ this.deleteMessage(channelId, id).catch((err) => {
216
+ logger.warn(`[recall-failed] id=${id} reason=${err?.message || err}`);
217
+ });
218
+ }
219
+ }, delay);
220
+ }
221
+ return ids;
222
+ };
223
+
224
+ ctx.on('dispose', () => {
225
+ Bot.prototype.sendMessage = original;
226
+ });
227
+ }
228
+
125
229
  function apply(ctx, config) {
230
+ // ˼���鿴����
126
231
  const cmd = ctx
127
- .command(`${config.command} [index:string]`, '读取上一条回复里的 <think> 内容,可指定倒数第 N ')
128
- .usage('不带参数默认读取最新一条;示例:think 2 读取倒数第 2 AI 回复的思考');
232
+ .command(`${config.command} [index:string]`, '��ȡ���һ�ΰ��� <think> �����ݣ���ָ���� N ��')
233
+ .usage('���� think 2 �ɲ鿴������ 2 �� AI �ظ���˼������');
129
234
 
130
235
  for (const keyword of config.keywords || []) {
131
236
  cmd.shortcut(keyword, { prefix: false });
@@ -133,23 +238,23 @@ function apply(ctx, config) {
133
238
 
134
239
  cmd.action(async ({ session, args }, rawIndex) => {
135
240
  if (!config.allowPrivate && !session.guildId) {
136
- return '不支持在私聊中查询。';
241
+ return '��֧����˽�����ѯ��';
137
242
  }
138
243
 
139
244
  const service = ctx.chatluna_character;
140
- if (!service) return 'chatluna-character 未加载。';
245
+ if (!service) return 'chatluna-character δ���ء�';
141
246
 
142
247
  const temp = await service.getTemp(session);
143
248
  const targetIndex = parseIndex(rawIndex ?? args?.[0]);
144
249
 
145
- // 1) 优先读取最新一次原始响应(通常仍含 <think>),只对第 1 条有效
250
+ // 1) �ȶ����һ��ԭʼ��Ӧ��ͨ��ֻ�Ե� 1 ����Ч��
146
251
  const thinkFromRaw = targetIndex === 1 ? getLatestRawThink(temp) : '';
147
252
 
148
- // 2) 历史 completionMessages 中真正带 <think> AI 消息
253
+ // 2) ����ʷ completionMessages �в��Ұ��� <think> �� AI ��Ϣ
149
254
  const messages = temp?.completionMessages || [];
150
255
  const thinkFromHistory = thinkFromRaw ? '' : getNthThink(messages, targetIndex);
151
256
 
152
- // 3) 回退:第 N AI 消息再尝试抽取
257
+ // 3) ���ף�ȡ�� N �� AI ��Ϣ�ٴ�����ȡ <think>
153
258
  const fallbackMsg = thinkFromRaw || thinkFromHistory ? null : getNthAiMessage(messages, targetIndex);
154
259
  const think = thinkFromRaw || thinkFromHistory || extractThink(extractText(fallbackMsg?.content));
155
260
  const formatted = formatThink(think);
@@ -157,11 +262,10 @@ function apply(ctx, config) {
157
262
 
158
263
  if (config.renderImage && ctx.chatluna?.renderer) {
159
264
  try {
160
- const title = `### 上一条思考(倒数第 ${targetIndex} 条)`;
265
+ const title = `### ���˼�����ݣ������� ${targetIndex} ����`;
161
266
  const markdown = `<div align="center">\n${title}\n</div>\n\n<div align="left">\n${formatted}\n</div>`;
162
267
  const rendered = await ctx.chatluna.renderer.render(
163
268
  {
164
- // 居中标题、左对齐正文,保持 renderer 兼容
165
269
  content: [{ type: 'text', text: markdown }],
166
270
  },
167
271
  { type: 'image', session },
@@ -172,8 +276,11 @@ function apply(ctx, config) {
172
276
  }
173
277
  }
174
278
 
175
- return `上一条思考(倒数第 ${targetIndex} 条)\n${formatted}`;
279
+ return `���˼�����ݣ������� ${targetIndex} ����\n${formatted}`;
176
280
  });
281
+
282
+ // �쳣��ʽ�Զ�����
283
+ applyGuard(ctx, config);
177
284
  }
178
285
 
179
286
  module.exports = {
package/package.json CHANGED
@@ -1,14 +1,17 @@
1
- {
1
+ {
2
2
  "name": "koishi-plugin-chatluna-think-viewer",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "main": "index.js",
5
- "description": "Expose a command/shortcut to read the latest <think> block from chatluna-character.",
5
+ "description": "View chatluna <think> blocks and auto recall abnormal formatted replies.",
6
6
  "license": "MIT",
7
7
  "keywords": [
8
8
  "koishi",
9
9
  "chatluna",
10
10
  "character",
11
- "think"
11
+ "think",
12
+ "recall",
13
+ "guard",
14
+ "moderation"
12
15
  ],
13
16
  "homepage": "https://github.com/sCR0WN-s/koishi-plugin-chatluna-think-viewer",
14
17
  "repository": {
@@ -24,8 +27,8 @@
24
27
  },
25
28
  "koishi": {
26
29
  "description": {
27
- "zh": "通过命令/关键词查看 chatluna-character 最近一次回复的 <think> 思考内容。",
28
- "en": "Expose a command/shortcut to read the last <think> block from chatluna-character."
30
+ "zh": "?? chatluna-character ? <think> ??????????????????/???",
31
+ "en": "View <think> blocks from chatluna-character and auto recall malformed replies."
29
32
  },
30
33
  "service": {
31
34
  "required": [