cortico-world-forward-reader 0.1.2 → 0.1.4

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/describe.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "tags": [
6
6
  "read"
7
7
  ],
8
- "description": "拆开 QQ 的「合并转发 / 聊天记录」卡片,包括转发里套着的转发。\n什么时候用:收到 `qq.forward` 事件、或者 QQ 消息里写着「[合并转发]」但只摊开了一层、里面还有「又一层」时;想看清某段聊天记录到底是谁说了什么,也用它。\n怎么给编号:QQ 消息事件行首的 `#编号`(如 `#265277450`)就是 message_id,原样填进 ref;带不带 `#`、前后有空格都不要紧。编号可能是负数(如 `#-1175570559`),负号是编号的一部分,要一起填,别去掉。\n它只读,只走本机的 SnowLuma OneBot HTTP 接口(默认 127.0.0.1:3000),不发消息、不改任何东西;接口没开或 token 不对会直接说清楚哪一步不对。输出过长会截断。",
8
+ "description": "拆开 QQ 的「合并转发 / 聊天记录」卡片,包括转发里套着的转发。\n什么时候用:收到 `qq.forward` 事件、或者 QQ 消息里写着「[合并转发]」但只摊开了一层、里面还有「又一层」时;想看清某段聊天记录到底是谁说了什么,也用它。\n怎么给编号:QQ 消息事件行首的 `#编号`(如 `#265277450`)就是 message_id,原样填进 ref;带不带 `#`、前后有空格都不要紧。编号可能是负数(如 `#-1175570559`),负号是编号的一部分,要一起填,别去掉。\n它只读,只走本机的 SnowLuma OneBot HTTP 接口(默认 127.0.0.1:3000),不发消息、不改任何东西;接口没开或 token 不对会直接说清楚哪一步不对。输出过长会截断。\n图片:默认只给链接;传 `captions: true` 会顺便把图交给视觉端点,在结尾单列一节说每张图是什么(一张图一次调用,按需开)。",
9
9
  "parameters": {
10
10
  "type": "object",
11
11
  "properties": {
@@ -24,6 +24,10 @@
24
24
  "minimum": 1000,
25
25
  "maximum": 60000,
26
26
  "description": "这次回执最多多少字,不填用配置里的默认值(默认 12000)。"
27
+ },
28
+ "captions": {
29
+ "type": "boolean",
30
+ "description": "顺便给图片配描述(默认按配置,通常关)。一张图一次视觉调用,会花一点钱;图多的时候只识前几张。"
27
31
  }
28
32
  },
29
33
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-forward-reader",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "转发拆解 World:把 QQ 的「合并转发」一层层拆到底,嵌套在里面的转发也展开,输出带缩进的人话。只读,不发消息。",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/config.js CHANGED
@@ -17,6 +17,20 @@ export const DEFAULTS = {
17
17
  maxChars: 12000,
18
18
  /** 单次请求超时(毫秒)。 */
19
19
  timeoutMs: 15000,
20
+
21
+ // ── 识图(可选,默认关)────────────────────────────────────────────
22
+ /** 拆到图片时,顺便让视觉端点说一句图里是什么。一百张图就一百次调用,默认关。 */
23
+ caption: false,
24
+ /** 一次拆解最多识几张图,多出来的只留链接。 */
25
+ captionMax: 6,
26
+ /** 每句描述最多多少字。 */
27
+ captionChars: 60,
28
+ /** 从哪儿读视觉端点(Coopanion 主配置里的 qqbot.vision)。 */
29
+ visionConfig: 'D:/Coopanion/data/home/companion/config.json',
30
+ /** 下面三项一般留空:留空就用 visionConfig 里配的值。 */
31
+ visionEndpoint: '',
32
+ visionModel: '',
33
+ visionKey: '',
20
34
  };
21
35
 
22
36
  export const CONFIG_GROUP = {
@@ -71,6 +85,50 @@ export const CONFIG_GROUP = {
71
85
  maximum: 120000,
72
86
  'x-hot': true,
73
87
  },
88
+ 'worlds.forward-reader.caption': {
89
+ type: 'boolean',
90
+ title: '拆到图片时顺手识图',
91
+ description:
92
+ '打开后,拆解里出现的图片会被下载、交给视觉端点,在图后补一句「这张图是什么」。' +
93
+ '一张图一次调用,会花一点钱,默认关;工具调用时也可以单独指定。',
94
+ 'x-hot': true,
95
+ },
96
+ 'worlds.forward-reader.captionMax': {
97
+ type: 'integer',
98
+ title: '一次最多识几张图',
99
+ description: '默认 6;多出来的图只留链接,不送去识图。',
100
+ minimum: 0,
101
+ maximum: 30,
102
+ 'x-hot': true,
103
+ },
104
+ 'worlds.forward-reader.captionChars': {
105
+ type: 'integer',
106
+ title: '每句描述字数',
107
+ description: '每张图的描述最多多少字,默认 60。',
108
+ minimum: 20,
109
+ maximum: 200,
110
+ },
111
+ 'worlds.forward-reader.visionConfig': {
112
+ type: 'string',
113
+ title: '视觉端点配置路径',
114
+ description:
115
+ '从哪个配置文件读视觉端点(默认读 Coopanion 主配置里的 qqbot.vision:endpoint / model / apiKeySecret)。',
116
+ },
117
+ 'worlds.forward-reader.visionEndpoint': {
118
+ type: 'string',
119
+ title: '视觉端点(可选)',
120
+ description: '留空就用上面配置文件里的值;填了就盖过它。',
121
+ },
122
+ 'worlds.forward-reader.visionModel': {
123
+ type: 'string',
124
+ title: '视觉模型(可选)',
125
+ description: '留空就用配置文件里的值。',
126
+ },
127
+ 'worlds.forward-reader.visionKey': {
128
+ type: 'string',
129
+ title: '视觉端点密钥(可选)',
130
+ description: '留空就用配置文件里的值;别把 key 写进仓库。',
131
+ },
74
132
  },
75
133
  },
76
134
  };
package/src/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { CONFIG_GROUP, DEFAULTS, WORLD_ID } from './config.js';
11
11
  import { TOOL_DECLS } from './tools.js';
12
12
  import { callOneBot, describeSource, resolveEndpoint, resolveToken, segmentText } from './onebot.js';
13
+ import { resolveVision, captionImage } from './vision.js';
13
14
 
14
15
  const LABEL = '转发拆解';
15
16
  const MAX_OUTPUT_CHARS = 12000;
@@ -57,20 +58,25 @@ function clockOf(time) {
57
58
  return `${year}${mo}-${dd} ${hh}:${mm}`;
58
59
  }
59
60
 
60
- /** 把一条消息的段合成一行,并挑出里面嵌套的转发段。 */
61
- function foldMessage(segments) {
61
+ /** 把一条消息的段合成一行,并挑出里面嵌套的转发段(和图片,识图时要用)。 */
62
+ function foldMessage(segments, opts = {}) {
62
63
  const parts = [];
63
64
  const nested = [];
65
+ const images = [];
64
66
  for (const seg of segments || []) {
65
67
  if (!seg) continue;
66
68
  if (seg.type === 'forward') {
67
69
  nested.push(seg);
68
70
  continue;
69
71
  }
70
- const piece = segmentText(seg);
72
+ if (seg.type === 'image' && seg.data) {
73
+ const url = seg.data.url || seg.data.path || '';
74
+ if (url) images.push({ url, summary: seg.data.summary || '' });
75
+ }
76
+ const piece = segmentText(seg, opts);
71
77
  if (piece) parts.push(piece);
72
78
  }
73
- return { text: parts.join('').trim(), nested };
79
+ return { text: parts.join('').trim(), nested, images };
74
80
  }
75
81
 
76
82
  export class ForwardReaderWorld {
@@ -155,11 +161,12 @@ export class ForwardReaderWorld {
155
161
  }
156
162
 
157
163
  const out = [];
158
- const state = { items: 0, levels: 0, truncated: false };
164
+ const state = { items: 0, levels: 0, truncated: false, images: [] };
159
165
  const segments = data?.message || [];
160
- const whole = foldMessage(segments);
166
+ const whole = foldMessage(segments, { links: true });
161
167
  const who = senderName(data?.sender);
162
168
  const when = clockOf(data?.time);
169
+ const wantCaption = Boolean(args.captions ?? cfg.caption);
163
170
 
164
171
  if (!whole.nested.length) {
165
172
  // 不是合并转发:直接把这条消息本身给人看。
@@ -168,6 +175,7 @@ export class ForwardReaderWorld {
168
175
  out.push(whole.text || '(没有文字内容)');
169
176
  out.push('');
170
177
  out.push('这条不是合并转发卡片,所以没有可拆的层。');
178
+ if (wantCaption) await this.addCaptions(out, whole.images.map((i) => ({ ...i, who, when })));
171
179
  return cap(out.join('\n'), limit);
172
180
  }
173
181
 
@@ -184,10 +192,30 @@ export class ForwardReaderWorld {
184
192
  await this.walk(seg.data?.id, 0, out, state, maxDepth);
185
193
  }
186
194
 
195
+ if (wantCaption) await this.addCaptions(out, state.images);
196
+
187
197
  out.unshift(`共 ${state.levels} 层 / ${state.items} 条${state.truncated ? '(已达条数上限,后面没再往下翻)' : ''}`);
188
198
  return cap(out.join('\n'), limit);
189
199
  }
190
200
 
201
+ /** 把拆出来的图片送去识图,在结尾单列一节。失败也是一句话,不炸整份拆解。 */
202
+ async addCaptions(out, images) {
203
+ const list = (images || []).filter((i) => i && i.url);
204
+ if (!list.length) return;
205
+ const max = clampInt(this.cfg.captionMax, 6, 0, 30);
206
+ const chars = clampInt(this.cfg.captionChars, 60, 20, 200);
207
+ const picked = max > 0 ? list.slice(0, max) : [];
208
+ if (!picked.length) return;
209
+ const vision = await resolveVision(this.cfg);
210
+ out.push('');
211
+ out.push(`—— 图片 ${list.length} 张${list.length > picked.length ? `(只识前 ${picked.length} 张)` : ''} ——`);
212
+ for (const img of picked) {
213
+ const tag = `${img.who && img.who !== '?' ? img.who + ' ' : ''}${img.when || ''}`.trim();
214
+ const text = await captionImage(vision, img.url, { chars, timeoutMs: this.cfg.timeoutMs ?? 15000 });
215
+ out.push(`${tag ? '[' + tag + '] ' : ''}${text}`);
216
+ }
217
+ }
218
+
191
219
  async walk(forwardId, depth, out, state, maxDepth) {
192
220
  if (!forwardId || state.truncated) return;
193
221
  if (depth >= maxDepth) {
@@ -212,7 +240,8 @@ export class ForwardReaderWorld {
212
240
  }
213
241
  const who = senderName(item?.sender);
214
242
  const when = clockOf(item?.time);
215
- const folded = foldMessage(item?.message);
243
+ const folded = foldMessage(item?.message, { links: true });
244
+ for (const img of folded.images) state.images.push({ ...img, who, when });
216
245
  const head = `${pad(depth)}[${who}${when ? ' ' + when : ''}]`;
217
246
  state.items += 1;
218
247
  out.push(`${head} ${folded.text || '(空)'}`);
package/src/onebot.js CHANGED
@@ -97,15 +97,23 @@ export async function callOneBot(cfg, action, params = {}) {
97
97
  return json.data ?? json;
98
98
  }
99
99
 
100
- /** 一条消息里的某个段 → 一行给人看的小字。 */
101
- export function segmentText(seg) {
100
+ /**
101
+ * 一条消息里的某个段 → 一行给人看的小字。
102
+ * opts.links 为真时,图片后面补上地址(SnowLuma 一般给 url,取不到就退回 file),
103
+ * 这样带图的那条记录也能拿链接去看图,而不是只剩一个「[图片]」。
104
+ */
105
+ export function segmentText(seg, opts = {}) {
102
106
  if (!seg || typeof seg !== 'object') return '';
103
107
  const d = seg.data || {};
104
108
  switch (seg.type) {
105
109
  case 'text':
106
110
  return d.text || '';
107
- case 'image':
108
- return `[图片${d.summary ? ':' + d.summary : ''}]`;
111
+ case 'image': {
112
+ const base = `[图片${d.summary ? ':' + d.summary : ''}]`;
113
+ if (!opts.links) return base;
114
+ const where = d.url || d.path || d.file || '';
115
+ return where ? `${base} ${where}` : base;
116
+ }
109
117
  case 'face':
110
118
  return `[表情${d.id ? ':' + d.id : ''}]`;
111
119
  case 'mface':
package/src/tools.js CHANGED
@@ -18,7 +18,8 @@ export const TOOL_DECLS = [
18
18
  '怎么给编号:QQ 消息事件行首的 `#编号`(如 `#265277450`)就是 message_id,原样填进 ref;带不带 `#`、前后有空格都不要紧。' +
19
19
  '编号可能是负数(如 `#-1175570559`),负号是编号的一部分,要一起填,别去掉。\n' +
20
20
  '它只读,只走本机的 SnowLuma OneBot HTTP 接口(默认 127.0.0.1:3000),不发消息、不改任何东西;' +
21
- '接口没开或 token 不对会直接说清楚哪一步不对。输出过长会截断。',
21
+ '接口没开或 token 不对会直接说清楚哪一步不对。输出过长会截断。\n' +
22
+ '图片:默认只给链接;传 `captions: true` 会顺便把图交给视觉端点,在结尾单列一节说每张图是什么(一张图一次调用,按需开)。',
22
23
  parameters: {
23
24
  type: 'object',
24
25
  properties: {
@@ -38,6 +39,10 @@ export const TOOL_DECLS = [
38
39
  maximum: 60000,
39
40
  description: '这次回执最多多少字,不填用配置里的默认值(默认 12000)。',
40
41
  },
42
+ captions: {
43
+ type: 'boolean',
44
+ description: '顺便给图片配描述(默认按配置,通常关)。一张图一次视觉调用,会花一点钱;图多的时候只识前几张。',
45
+ },
41
46
  },
42
47
  required: ['ref'],
43
48
  },
package/src/vision.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * 识图:拿 Coopanion 里已经配好的视觉端点(qqbot.vision)给一张图配一句描述。
3
+ *
4
+ * 为什么不直接抄一个模型名和 key:主人在设置里换端点/换模型时,这里跟着变,不用改代码。
5
+ * 端点按 OpenAI 兼容格式 POST /chat/completions,content 里混 text + image_url(data URL 也行)。
6
+ *
7
+ * 踩过的坑:
8
+ * 1. max_tokens 给小了(比如 8)会被 reasoning_tokens 吃光,message.content 是空串——至少给 200。
9
+ * 2. 图片要先下载成 buffer 再转 base64,直接把外链丢过去有的端点拉不到(QQ 的图还带 rkey)。
10
+ */
11
+ import { readFile } from 'node:fs/promises';
12
+
13
+ const MAX_BYTES = 6 * 1024 * 1024;
14
+
15
+ /** 从 Coopanion 的配置文件里读 qqbot.vision;读不到就返回 null。 */
16
+ async function readHostVision(configPath) {
17
+ if (!configPath) return null;
18
+ try {
19
+ const raw = await readFile(configPath, 'utf8');
20
+ const json = JSON.parse(raw);
21
+ const v = json?.worlds?.qqbot?.vision ?? json?.qqbot?.vision;
22
+ if (!v || !v.endpoint || !v.model) return null;
23
+ return {
24
+ endpoint: String(v.endpoint),
25
+ model: String(v.model),
26
+ key: String(v.apiKeySecret || v.apiKey || ''),
27
+ };
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /** 配置里的显式值优先,其次读宿主配置。 */
34
+ export async function resolveVision(cfg) {
35
+ const own = {
36
+ endpoint: String(cfg.visionEndpoint || '').trim(),
37
+ model: String(cfg.visionModel || '').trim(),
38
+ key: String(cfg.visionKey || '').trim(),
39
+ };
40
+ if (own.endpoint && own.model) return own;
41
+ const host = await readHostVision(cfg.visionConfig);
42
+ if (!host) return null;
43
+ return {
44
+ endpoint: own.endpoint || host.endpoint,
45
+ model: own.model || host.model,
46
+ key: own.key || host.key,
47
+ };
48
+ }
49
+
50
+ function guessMime(buf) {
51
+ if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50) return 'image/png';
52
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg';
53
+ if (buf.length >= 6 && buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif';
54
+ if (buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[8] === 0x57) return 'image/webp';
55
+ return 'image/jpeg';
56
+ }
57
+
58
+ async function fetchImage(url, timeoutMs) {
59
+ const ctl = new AbortController();
60
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
61
+ try {
62
+ const res = await fetch(url, { signal: ctl.signal });
63
+ if (!res.ok) throw new Error(`下载图片失败:HTTP ${res.status}`);
64
+ const buf = Buffer.from(await res.arrayBuffer());
65
+ if (!buf.length) throw new Error('下载图片失败:拿到空文件');
66
+ if (buf.length > MAX_BYTES) throw new Error(`图片太大(${Math.round(buf.length / 1024)} KB)`);
67
+ return buf;
68
+ } finally {
69
+ clearTimeout(timer);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * 给一张图配一句描述。失败就返回一句人话,不抛——识图失败不该把整份拆解弄坏。
75
+ */
76
+ export async function captionImage(vision, url, opts = {}) {
77
+ const timeoutMs = opts.timeoutMs ?? 30000;
78
+ const chars = opts.chars ?? 60;
79
+ if (!vision) return '(没找到能用的视觉端点)';
80
+ let buf;
81
+ try {
82
+ buf = await fetchImage(url, timeoutMs);
83
+ } catch (err) {
84
+ return `(图取不下来:${err.message})`;
85
+ }
86
+ const dataUrl = `data:${guessMime(buf)};base64,${buf.toString('base64')}`;
87
+ const body = {
88
+ model: vision.model,
89
+ max_tokens: 400,
90
+ messages: [
91
+ {
92
+ role: 'user',
93
+ content: [
94
+ { type: 'text', text: `用中文一句话说清这张图是什么,不超过 ${chars} 字,别铺垫。` },
95
+ { type: 'image_url', image_url: { url: dataUrl } },
96
+ ],
97
+ },
98
+ ],
99
+ };
100
+ const ctl = new AbortController();
101
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
102
+ try {
103
+ const res = await fetch(`${vision.endpoint.replace(/\/+$/, '')}/chat/completions`, {
104
+ method: 'POST',
105
+ signal: ctl.signal,
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ ...(vision.key ? { Authorization: `Bearer ${vision.key}` } : {}),
109
+ },
110
+ body: JSON.stringify(body),
111
+ });
112
+ const text = await res.text();
113
+ if (!res.ok) return `(识图失败:HTTP ${res.status})`;
114
+ const json = JSON.parse(text);
115
+ const out = String(json?.choices?.[0]?.message?.content || '').trim();
116
+ return out || '(端点没给出描述)';
117
+ } catch (err) {
118
+ const why = err?.name === 'AbortError' ? '超时' : err?.message || String(err);
119
+ return `(识图失败:${why})`;
120
+ } finally {
121
+ clearTimeout(timer);
122
+ }
123
+ }