cortico-world-forward-reader 0.1.6 → 0.1.11
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 +19 -6
- package/describe.json +2 -2
- package/package.json +3 -3
- package/src/config.js +35 -46
- package/src/index.js +81 -5
- package/src/tools.js +4 -3
- package/src/vision.js +7 -19
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
给 [Coopanion](https://github.com/Pal-AI-Lab/Coopanion) 的 Coo 一个工具:把 QQ 的「合并转发 / 聊天记录」一层层拆到底。
|
|
4
4
|
|
|
5
|
-
> **前置:先装「QQ
|
|
5
|
+
> **前置:先装「QQ群聊插件」。** 这个 World 自己不连 QQ,也不存 QQ 的连接信息——它借 QQ群聊插件配好的那套东西干活:
|
|
6
6
|
> SnowLuma 的 OneBot HTTP 接口用来取消息,插件里配的视觉端点用来识图。
|
|
7
7
|
> 没装那个插件,这里两个工具都只会回「连不上」。
|
|
8
8
|
|
|
@@ -28,16 +28,27 @@ Coopanion 自带的消息展开只摊开一层,转发里套着的转发看不
|
|
|
28
28
|
|
|
29
29
|
## 前置
|
|
30
30
|
|
|
31
|
-
- 装了 **QQ
|
|
31
|
+
- 装了 **QQ群聊插件(qqbot)**:SnowLuma 连接、OneBot 地址、视觉端点都在它那儿配。
|
|
32
32
|
- 装了 **SnowLuma**(OneBot v11 实现),HTTP 接口开着,默认 `127.0.0.1:3000`。
|
|
33
33
|
- OneBot 配置文件里有 `accessToken`。默认读 `D:/snowluma/config/onebot_<QQ号>.json`,也可以在配置页手填路径或直接填地址和 token。
|
|
34
34
|
|
|
35
|
-
##
|
|
35
|
+
## 图片(默认当附件交出去)
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
拆到图片时,图会被下载下来、作为**附件**跟回执一起交给模型。
|
|
38
38
|
|
|
39
|
-
-
|
|
40
|
-
-
|
|
39
|
+
- 能看图的模型(主模型支持图片)就直接看到图,不用多花一次调用。
|
|
40
|
+
- 看不见图的模型只会收到一行文字说明(谁、什么时候、链接),不会因为带图出错。
|
|
41
|
+
- 一次最多贴 `attachMax` 张(默认 6),单张超过 `attachMaxMb`(默认 8 MB)就跳过,正文里仍然有链接。
|
|
42
|
+
- 不想贴附件,把「把图片当附件一起交出去」关掉,就退回只给链接。
|
|
43
|
+
|
|
44
|
+
## 识图(只在要文字描述时才跑)
|
|
45
|
+
|
|
46
|
+
附件之外还想要一句**文字描述**时,在调用时传 `captions: true`——**没有开关会自动识图**,因为图已经当附件交给模型了,再识一遍是白花钱。
|
|
47
|
+
|
|
48
|
+
- 端点默认**跟着 QQ群聊插件走**:那边配好了视觉端点、模型、key,这里就自动认得到,不用再填一遍。
|
|
49
|
+
- 配置页里**没有**端点/模型/密钥这几格;那边没配,识图就只会写一句「没找到视觉端点」。
|
|
50
|
+
- 它只对**看不见图的模型**有用(纯文字模型只会读到一行「谁、什么时候、一张图、链接」),能看图的模型用不上。
|
|
51
|
+
- 一张图一次调用,会花钱,所以不传就不跑。
|
|
41
52
|
- 识图失败不会弄坏整份拆解,只会在那张图后面写一句失败原因。
|
|
42
53
|
|
|
43
54
|
|
|
@@ -49,6 +60,8 @@ Coopanion 自带的消息展开只摊开一层,转发里套着的转发看不
|
|
|
49
60
|
- 嵌套层数上限(默认 8)
|
|
50
61
|
- 回执字数上限(默认 12000)
|
|
51
62
|
- 请求超时(默认 15000 ms)
|
|
63
|
+
- 把图片当附件一起交出去(默认开)、一次最多处理几张(默认 6,附件和识图共用)、单张上限 MB(默认 8)
|
|
64
|
+
- 识图:每句描述字数(不传 `captions: true` 就不会跑;端点自动跟着 QQ群聊插件走,不单独配)
|
|
52
65
|
|
|
53
66
|
## 限制
|
|
54
67
|
|
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,由 QQ
|
|
8
|
+
"description": "拆开 QQ 的「合并转发 / 聊天记录」卡片,包括转发里套着的转发。\n什么时候用:收到 `qq.forward` 事件、或者 QQ 消息里写着「[合并转发]」但只摊开了一层、里面还有「又一层」时;想看清某段聊天记录到底是谁说了什么,也用它。\n怎么给编号:QQ 消息事件行首的 `#编号`(如 `#265277450`)就是 message_id,原样填进 ref;带不带 `#`、前后有空格都不要紧。编号可能是负数(如 `#-1175570559`),负号是编号的一部分,要一起填,别去掉。\n它只读,只走本机的 SnowLuma OneBot HTTP 接口(默认 127.0.0.1:3000,由 QQ群聊插件配好),不发消息、不改任何东西;接口没开或 token 不对会直接说清楚哪一步不对。输出过长会截断。\n图片:默认把图下载成附件,跟回执一起交给你——能看图的模型直接就看得到;看不见图的模型只会收到一行文字说明,不会出错。不会自动识图;还想要一句「这张图是什么」的文字描述,再传 `captions: true`(会花钱)。",
|
|
9
9
|
"parameters": {
|
|
10
10
|
"type": "object",
|
|
11
11
|
"properties": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"captions": {
|
|
29
29
|
"type": "boolean",
|
|
30
|
-
"description": "
|
|
30
|
+
"description": "要一句「这张图是什么」的文字描述才传 true——不传就不识图,一次视觉调用一张图,会花钱。"
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"required": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortico-world-forward-reader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "转发拆解 World:把 QQ 的「合并转发」一层层拆到底,嵌套在里面的转发也展开,输出带缩进的人话。只读,不发消息。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"locales": {
|
|
42
42
|
"zh": {
|
|
43
43
|
"displayName": "转发拆解",
|
|
44
|
-
"description": "【需要先装 QQ
|
|
44
|
+
"description": "【需要先装 QQ群聊插件】把 QQ 的「合并转发」一层层拆到底——嵌套在里面的转发也会展开,按人、按时间列成带缩进的文字。只读:借 QQ群聊插件(qqbot)配好的本机 SnowLuma OneBot HTTP 接口取消息,不发消息、不改任何东西。拆到的图片默认作为附件一起交给模型(能看图就直接看到,看不见图只会读到一行文字说明)。"
|
|
45
45
|
},
|
|
46
46
|
"en": {
|
|
47
47
|
"displayName": "Forward Reader",
|
|
48
|
-
"description": "Requires the QQ chat (qqbot) plugin. Expands QQ merged-forward messages recursively, including forwards nested inside forwards, into an indented transcript. Read-only: it reuses the SnowLuma OneBot HTTP API
|
|
48
|
+
"description": "Requires the QQ chat (qqbot) plugin. Expands QQ merged-forward messages recursively, including forwards nested inside forwards, into an indented transcript. Read-only: it reuses the SnowLuma OneBot HTTP API that plugin already set up, and never sends or changes anything. Images are attached to the tool result by default, so a model that accepts images sees them; others just get a one-line text note."
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"describe": "describe.json",
|
package/src/config.js
CHANGED
|
@@ -18,22 +18,26 @@ export const DEFAULTS = {
|
|
|
18
18
|
/** 单次请求超时(毫秒)。 */
|
|
19
19
|
timeoutMs: 15000,
|
|
20
20
|
|
|
21
|
-
// ──
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
// ── 图片附件(默认开)─────────────────────────────────────────────
|
|
22
|
+
/**
|
|
23
|
+
* 拆到的图片下载下来,作为附件跟回执一起交给模型。
|
|
24
|
+
* 能看图的模型直接看到;看不见的模型只会收到 fallbackText,不会出错。
|
|
25
|
+
*/
|
|
26
|
+
attachImages: true,
|
|
27
|
+
/** 一次最多贴几张附件,多出来的只在正文留链接;识图也用这一个上限。 */
|
|
28
|
+
attachMax: 6,
|
|
29
|
+
/** 单张图最大多少 MB,超了就不贴(正文里还有链接)。 */
|
|
30
|
+
attachMaxMb: 8,
|
|
31
|
+
|
|
32
|
+
// ── 识图(只在工具调用时明确要求才跑)─────────────────────────────────
|
|
28
33
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
34
|
+
* 不设开关:图默认当附件交给模型,再自动识一遍是白花钱。
|
|
35
|
+
* 只有 qq_forward_read 传了 captions: true 才会调视觉端点。
|
|
31
36
|
*/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
visionKey: '',
|
|
37
|
+
/** 每句描述最多多少字。 */
|
|
38
|
+
captionChars: 60,
|
|
39
|
+
// 视觉端点不在这配:自己去本机 Coopanion 的配置里找(一般就是 QQ群聊插件那份),
|
|
40
|
+
// 配置页里一个格子都不露——留空格子只会让人以为非得填。
|
|
37
41
|
};
|
|
38
42
|
|
|
39
43
|
export const CONFIG_GROUP = {
|
|
@@ -43,13 +47,14 @@ export const CONFIG_GROUP = {
|
|
|
43
47
|
type: 'object',
|
|
44
48
|
title: '转发拆解',
|
|
45
49
|
description:
|
|
46
|
-
'
|
|
47
|
-
'
|
|
50
|
+
'把 QQ 的「合并转发」一层层拆到底,嵌套在里面的转发也展开,按人、按时间列成带缩进的文字。' +
|
|
51
|
+
'前置:先装 QQ群聊插件(本 World 借它的 OneBot 接口取消息,自己不连 QQ)。' +
|
|
52
|
+
'拆到的图片默认作为附件交给模型,看不见图的模型只会读到一行文字说明。只读,不发消息、不改 QQ 配置。',
|
|
48
53
|
properties: {
|
|
49
54
|
'worlds.forward-reader.snowlumaConfig': {
|
|
50
55
|
type: 'string',
|
|
51
56
|
title: 'SnowLuma 配置路径',
|
|
52
|
-
description: 'OneBot
|
|
57
|
+
description: 'OneBot 配置文件,里面存着 accessToken;换了 QQ 号才需要改。',
|
|
53
58
|
'x-hot': true,
|
|
54
59
|
},
|
|
55
60
|
'worlds.forward-reader.baseUrl': {
|
|
@@ -88,22 +93,28 @@ export const CONFIG_GROUP = {
|
|
|
88
93
|
maximum: 120000,
|
|
89
94
|
'x-hot': true,
|
|
90
95
|
},
|
|
91
|
-
'worlds.forward-reader.
|
|
96
|
+
'worlds.forward-reader.attachImages': {
|
|
92
97
|
type: 'boolean',
|
|
93
|
-
title: '
|
|
98
|
+
title: '把图片当附件一起交出去',
|
|
94
99
|
description:
|
|
95
|
-
'
|
|
96
|
-
'一张图一次调用,会花一点钱,默认关;工具调用时也可以单独指定。',
|
|
100
|
+
'拆到的图片下载下来当附件交给模型:能看图就直接看到,看不见图只会读到一行文字说明。关掉就只留链接。',
|
|
97
101
|
'x-hot': true,
|
|
98
102
|
},
|
|
99
|
-
'worlds.forward-reader.
|
|
103
|
+
'worlds.forward-reader.attachMax': {
|
|
100
104
|
type: 'integer',
|
|
101
|
-
title: '
|
|
102
|
-
description: '默认 6
|
|
105
|
+
title: '一次最多处理几张图',
|
|
106
|
+
description: '默认 6:附件和识图共用这一个上限,多出来的只在正文里留链接。',
|
|
103
107
|
minimum: 0,
|
|
104
108
|
maximum: 30,
|
|
105
109
|
'x-hot': true,
|
|
106
110
|
},
|
|
111
|
+
'worlds.forward-reader.attachMaxMb': {
|
|
112
|
+
type: 'integer',
|
|
113
|
+
title: '单张图大小上限(MB)',
|
|
114
|
+
description: '超过这个大小的图不贴,只在正文里留链接。默认 8。',
|
|
115
|
+
minimum: 1,
|
|
116
|
+
maximum: 32,
|
|
117
|
+
},
|
|
107
118
|
'worlds.forward-reader.captionChars': {
|
|
108
119
|
type: 'integer',
|
|
109
120
|
title: '每句描述字数',
|
|
@@ -111,28 +122,6 @@ export const CONFIG_GROUP = {
|
|
|
111
122
|
minimum: 20,
|
|
112
123
|
maximum: 200,
|
|
113
124
|
},
|
|
114
|
-
'worlds.forward-reader.visionConfig': {
|
|
115
|
-
type: 'string',
|
|
116
|
-
title: '视觉端点配置路径(一般不用填)',
|
|
117
|
-
description:
|
|
118
|
-
'留空就行:留空时会自己在本机 Coopanion 的配置里找已经配好的视觉端点。' +
|
|
119
|
-
'只有想改用另一份配置文件时才填它的路径。',
|
|
120
|
-
},
|
|
121
|
-
'worlds.forward-reader.visionEndpoint': {
|
|
122
|
-
type: 'string',
|
|
123
|
-
title: '视觉端点(可选)',
|
|
124
|
-
description: '填了就忽略上面自动找到的那份,改用这里填的;留空即自动。',
|
|
125
|
-
},
|
|
126
|
-
'worlds.forward-reader.visionModel': {
|
|
127
|
-
type: 'string',
|
|
128
|
-
title: '视觉模型(可选)',
|
|
129
|
-
description: '留空就跟端点自动找到的那份配置走。',
|
|
130
|
-
},
|
|
131
|
-
'worlds.forward-reader.visionKey': {
|
|
132
|
-
type: 'string',
|
|
133
|
-
title: '视觉端点密钥(可选)',
|
|
134
|
-
description: '留空就用找到的那份配置里的密钥;别把 key 写进仓库。',
|
|
135
|
-
},
|
|
136
125
|
},
|
|
137
126
|
},
|
|
138
127
|
};
|
package/src/index.js
CHANGED
|
@@ -36,6 +36,33 @@ function pad(depth) {
|
|
|
36
36
|
return ' '.repeat(depth);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const EXT_BY_MIME = {
|
|
40
|
+
'image/jpeg': 'jpg',
|
|
41
|
+
'image/png': 'png',
|
|
42
|
+
'image/gif': 'gif',
|
|
43
|
+
'image/webp': 'webp',
|
|
44
|
+
'image/bmp': 'bmp',
|
|
45
|
+
'image/avif': 'avif',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const MIME_BY_EXT = {
|
|
49
|
+
jpg: 'image/jpeg',
|
|
50
|
+
jpeg: 'image/jpeg',
|
|
51
|
+
png: 'image/png',
|
|
52
|
+
gif: 'image/gif',
|
|
53
|
+
webp: 'image/webp',
|
|
54
|
+
bmp: 'image/bmp',
|
|
55
|
+
avif: 'image/avif',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** 优先信 Content-Type,其次看链接后缀;都不是图就当 jpeg 试。 */
|
|
59
|
+
function mimeOf(contentType, url) {
|
|
60
|
+
const ct = String(contentType || '').split(';')[0].trim().toLowerCase();
|
|
61
|
+
if (/^image\//.test(ct)) return ct;
|
|
62
|
+
const ext = (String(url || '').split('?')[0].match(/\.([a-z0-9]+)$/i) || [])[1];
|
|
63
|
+
return MIME_BY_EXT[String(ext || '').toLowerCase()] || 'image/jpeg';
|
|
64
|
+
}
|
|
65
|
+
|
|
39
66
|
function senderName(sender) {
|
|
40
67
|
if (!sender) return '?';
|
|
41
68
|
return sender.card || sender.nickname || (sender.user_id ? String(sender.user_id) : '?');
|
|
@@ -166,7 +193,8 @@ export class ForwardReaderWorld {
|
|
|
166
193
|
const whole = foldMessage(segments, { links: true });
|
|
167
194
|
const who = senderName(data?.sender);
|
|
168
195
|
const when = clockOf(data?.time);
|
|
169
|
-
|
|
196
|
+
// 识图只在调用时明确要求才跑:图已经当附件交给模型了,再自动识一遍是白花钱。
|
|
197
|
+
const wantCaption = args.captions === true;
|
|
170
198
|
|
|
171
199
|
if (!whole.nested.length) {
|
|
172
200
|
// 不是合并转发:直接把这条消息本身给人看。
|
|
@@ -176,7 +204,7 @@ export class ForwardReaderWorld {
|
|
|
176
204
|
out.push('');
|
|
177
205
|
out.push('这条不是合并转发卡片,所以没有可拆的层。');
|
|
178
206
|
if (wantCaption) await this.addCaptions(out, whole.images.map((i) => ({ ...i, who, when })));
|
|
179
|
-
return
|
|
207
|
+
return this.finish(out, limit, whole.images.map((i) => ({ ...i, who, when })));
|
|
180
208
|
}
|
|
181
209
|
|
|
182
210
|
out.push(`# 转发拆解 · 消息 ${rawRef}${who !== '?' ? ` · ${who}` : ''}${when ? ` · ${when}` : ''}`);
|
|
@@ -195,18 +223,66 @@ export class ForwardReaderWorld {
|
|
|
195
223
|
if (wantCaption) await this.addCaptions(out, state.images);
|
|
196
224
|
|
|
197
225
|
out.unshift(`共 ${state.levels} 层 / ${state.items} 条${state.truncated ? '(已达条数上限,后面没再往下翻)' : ''}`);
|
|
198
|
-
return
|
|
226
|
+
return this.finish(out, limit, state.images);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 收尾:正文截断,再把图片当附件一起交出去。
|
|
231
|
+
* 能看图的模型直接看到图;看不见的模型只收到 fallbackText,不会报错。
|
|
232
|
+
*/
|
|
233
|
+
async finish(out, limit, images) {
|
|
234
|
+
const blobs = await this.collectBlobs(images || []);
|
|
235
|
+
if (blobs.length) {
|
|
236
|
+
out.push('');
|
|
237
|
+
out.push(
|
|
238
|
+
`—— 有 ${blobs.length} 张图片已经作为附件一起给你了(看不见图的话,正文里的链接还在;想让它转述一句就传 captions: true)——`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const text = cap(out.join('\n'), limit);
|
|
242
|
+
return blobs.length ? { text, blobs } : text;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 把图片下载成附件;太大、下载失败、不是图的,直接跳过(正文里本来就有链接)。 */
|
|
246
|
+
async collectBlobs(images) {
|
|
247
|
+
if (!this.cfg.attachImages) return [];
|
|
248
|
+
const max = clampInt(this.cfg.attachMax, 6, 0, 30);
|
|
249
|
+
if (max <= 0) return [];
|
|
250
|
+
const maxBytes = clampInt(this.cfg.attachMaxMb, 8, 1, 32) * 1024 * 1024;
|
|
251
|
+
const timeoutMs = clampInt(this.cfg.timeoutMs, 15000, 1000, 120000);
|
|
252
|
+
const picked = images.filter((i) => i && i.url).slice(0, max);
|
|
253
|
+
const blobs = [];
|
|
254
|
+
for (let i = 0; i < picked.length; i += 1) {
|
|
255
|
+
const img = picked[i];
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetch(img.url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
258
|
+
if (!res.ok) continue;
|
|
259
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
260
|
+
if (!bytes.length || bytes.length > maxBytes) continue;
|
|
261
|
+
const mime = mimeOf(res.headers.get('content-type'), img.url);
|
|
262
|
+
if (!/^image\//.test(mime)) continue;
|
|
263
|
+
const tag = `${img.who && img.who !== '?' ? img.who + ' ' : ''}${img.when || ''}`.trim();
|
|
264
|
+
blobs.push({
|
|
265
|
+
bytes,
|
|
266
|
+
mime,
|
|
267
|
+
name: `forward-${i + 1}.${EXT_BY_MIME[mime] || 'png'}`,
|
|
268
|
+
fallbackText: `${tag ? '[' + tag + '] ' : ''}图片 ${i + 1}:${img.url}`,
|
|
269
|
+
});
|
|
270
|
+
} catch {
|
|
271
|
+
// 拿不到就当没有这张:不炸整份拆解。
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return blobs;
|
|
199
275
|
}
|
|
200
276
|
|
|
201
277
|
/** 把拆出来的图片送去识图,在结尾单列一节。失败也是一句话,不炸整份拆解。 */
|
|
202
278
|
async addCaptions(out, images) {
|
|
203
279
|
const list = (images || []).filter((i) => i && i.url);
|
|
204
280
|
if (!list.length) return;
|
|
205
|
-
const max = clampInt(this.cfg.
|
|
281
|
+
const max = clampInt(this.cfg.attachMax, 6, 0, 30);
|
|
206
282
|
const chars = clampInt(this.cfg.captionChars, 60, 20, 200);
|
|
207
283
|
const picked = max > 0 ? list.slice(0, max) : [];
|
|
208
284
|
if (!picked.length) return;
|
|
209
|
-
const vision = await resolveVision(
|
|
285
|
+
const vision = await resolveVision();
|
|
210
286
|
out.push('');
|
|
211
287
|
out.push(`—— 图片 ${list.length} 张${list.length > picked.length ? `(只识前 ${picked.length} 张)` : ''} ——`);
|
|
212
288
|
for (const img of picked) {
|
package/src/tools.js
CHANGED
|
@@ -17,9 +17,10 @@ export const TOOL_DECLS = [
|
|
|
17
17
|
'想看清某段聊天记录到底是谁说了什么,也用它。\n' +
|
|
18
18
|
'怎么给编号:QQ 消息事件行首的 `#编号`(如 `#265277450`)就是 message_id,原样填进 ref;带不带 `#`、前后有空格都不要紧。' +
|
|
19
19
|
'编号可能是负数(如 `#-1175570559`),负号是编号的一部分,要一起填,别去掉。\n' +
|
|
20
|
-
'它只读,只走本机的 SnowLuma OneBot HTTP 接口(默认 127.0.0.1:3000,由 QQ
|
|
20
|
+
'它只读,只走本机的 SnowLuma OneBot HTTP 接口(默认 127.0.0.1:3000,由 QQ群聊插件配好),不发消息、不改任何东西;' +
|
|
21
21
|
'接口没开或 token 不对会直接说清楚哪一步不对。输出过长会截断。\n' +
|
|
22
|
-
'
|
|
22
|
+
'图片:默认把图下载成附件,跟回执一起交给你——能看图的模型直接就看得到;看不见图的模型只会收到一行文字说明,不会出错。' +
|
|
23
|
+
'不会自动识图;还想要一句「这张图是什么」的文字描述,再传 `captions: true`(会花钱)。',
|
|
23
24
|
parameters: {
|
|
24
25
|
type: 'object',
|
|
25
26
|
properties: {
|
|
@@ -41,7 +42,7 @@ export const TOOL_DECLS = [
|
|
|
41
42
|
},
|
|
42
43
|
captions: {
|
|
43
44
|
type: 'boolean',
|
|
44
|
-
description: '
|
|
45
|
+
description: '要一句「这张图是什么」的文字描述才传 true——不传就不识图,一次视觉调用一张图,会花钱。',
|
|
45
46
|
},
|
|
46
47
|
},
|
|
47
48
|
required: ['ref'],
|
package/src/vision.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 识图:借本机已经配好的视觉端点,给一张图配一句描述。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 端点从哪来:本机 Coopanion 的配置里找(一般是 QQ 那份 qqbot.vision),找到就用,
|
|
5
|
+
* 找不到就老实说没找到。配置页里不给格子填——留空的格子只会让人以为非得填。
|
|
6
|
+
* 写死不合适的盘符和模型名没有意义——换台机器就跑不通。
|
|
7
7
|
* 端点按 OpenAI 兼容格式 POST /chat/completions,content 里混 text + image_url(data URL 也行)。
|
|
8
8
|
*
|
|
9
9
|
* 踩过的坑:
|
|
@@ -58,21 +58,9 @@ async function readHostVision(configPath) {
|
|
|
58
58
|
return null;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/**
|
|
62
|
-
export async function resolveVision(
|
|
63
|
-
|
|
64
|
-
endpoint: String(cfg.visionEndpoint || '').trim(),
|
|
65
|
-
model: String(cfg.visionModel || '').trim(),
|
|
66
|
-
key: String(cfg.visionKey || '').trim(),
|
|
67
|
-
};
|
|
68
|
-
if (own.endpoint && own.model) return own;
|
|
69
|
-
const host = await readHostVision(cfg.visionConfig);
|
|
70
|
-
if (!host) return null;
|
|
71
|
-
return {
|
|
72
|
-
endpoint: own.endpoint || host.endpoint,
|
|
73
|
-
model: own.model || host.model,
|
|
74
|
-
key: own.key || host.key,
|
|
75
|
-
};
|
|
61
|
+
/** 只认本机 Coopanion 配置里已经配好的视觉端点;找不到就返回 null。 */
|
|
62
|
+
export async function resolveVision() {
|
|
63
|
+
return readHostVision(process.env.COOPANION_CONFIG || '');
|
|
76
64
|
}
|
|
77
65
|
|
|
78
66
|
function guessMime(buf) {
|
|
@@ -104,7 +92,7 @@ async function fetchImage(url, timeoutMs) {
|
|
|
104
92
|
export async function captionImage(vision, url, opts = {}) {
|
|
105
93
|
const timeoutMs = opts.timeoutMs ?? 30000;
|
|
106
94
|
const chars = opts.chars ?? 60;
|
|
107
|
-
if (!vision) return '
|
|
95
|
+
if (!vision) return '(没找到视觉端点:需要先在 QQ群聊插件里把视觉端点配好)';
|
|
108
96
|
let buf;
|
|
109
97
|
try {
|
|
110
98
|
buf = await fetchImage(url, timeoutMs);
|