mioku-plugin-chat 3.1.0 → 3.1.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/core/chat-engine.ts +8 -4
- package/core/chat-turn.ts +79 -11
- package/core/media/history-media.ts +1 -1
- package/core/media/image-compress.ts +38 -27
- package/core/tools/info.ts +5 -3
- package/index.ts +22 -6
- package/package.json +1 -1
- package/utils/index.ts +2 -0
- package/utils/message.ts +93 -28
- package/utils/model.ts +31 -0
package/core/chat-engine.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type StructuredUserInput,
|
|
30
30
|
} from "../manage/group-structured-history";
|
|
31
31
|
import { prepareImageUrlsForModel } from "./media/image-compress";
|
|
32
|
+
import { mainModelSupportsVision } from "../utils/model";
|
|
32
33
|
|
|
33
34
|
interface StructuredHistoryRunContext {
|
|
34
35
|
manager: GroupStructuredHistoryManager;
|
|
@@ -116,7 +117,10 @@ export async function runChat(
|
|
|
116
117
|
const currentUserMessages = hasStructuredHistory
|
|
117
118
|
? buildStructuredUserMessages(structuredHistory!.currentUserInputs)
|
|
118
119
|
: [];
|
|
119
|
-
const directImageUrls =
|
|
120
|
+
const directImageUrls = mainModelSupportsVision(
|
|
121
|
+
toolCtx.aiService,
|
|
122
|
+
toolCtx.config.model,
|
|
123
|
+
)
|
|
120
124
|
? toolCtx.pendingImageUrls
|
|
121
125
|
: undefined;
|
|
122
126
|
const webSearchState = { count: 0 };
|
|
@@ -762,7 +766,7 @@ function sanitizeBrackets(text: string): string {
|
|
|
762
766
|
let working = text.replace(FUNCTIONAL_BRACKET_PREFIX, (match) => {
|
|
763
767
|
const idx = placeholders.length;
|
|
764
768
|
placeholders.push(match);
|
|
765
|
-
return
|
|
769
|
+
return `\u0000${idx}\u0000`;
|
|
766
770
|
});
|
|
767
771
|
|
|
768
772
|
let result = "";
|
|
@@ -772,8 +776,8 @@ function sanitizeBrackets(text: string): string {
|
|
|
772
776
|
while (i < working.length) {
|
|
773
777
|
const ch = working[i];
|
|
774
778
|
|
|
775
|
-
if (ch === "
|
|
776
|
-
const endIdx = working.indexOf("
|
|
779
|
+
if (ch === "\u0000") {
|
|
780
|
+
const endIdx = working.indexOf("\u0000", i + 1);
|
|
777
781
|
if (endIdx > i) {
|
|
778
782
|
const idx = parseInt(working.slice(i + 1, endIdx), 10);
|
|
779
783
|
if (!Number.isNaN(idx) && placeholders[idx] !== undefined) {
|
package/core/chat-turn.ts
CHANGED
|
@@ -2,7 +2,12 @@ import type { MiokuContext } from "mioku";
|
|
|
2
2
|
import type { AITool, Bot, ChatRuntimePromptInjection } from "mioku";
|
|
3
3
|
import type { ChatPluginContext, ChatRuntimeState } from "../context";
|
|
4
4
|
import type { ChatConfig, ChatMessage, TargetMessage } from "../types";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getGroupHistory,
|
|
7
|
+
getBotRole,
|
|
8
|
+
getQuotedContent,
|
|
9
|
+
mainModelSupportsVision,
|
|
10
|
+
} from "../utils";
|
|
6
11
|
import { buildStructuredUserInputFromTarget } from "../manage/group-structured-history";
|
|
7
12
|
|
|
8
13
|
export type RuntimeReplyContextType =
|
|
@@ -43,6 +48,53 @@ interface ResolvedRuntimeContext {
|
|
|
43
48
|
const NO_NEW_MESSAGE =
|
|
44
49
|
"[No new user message in this turn. Reply naturally based on the runtime instruction and recent context.]";
|
|
45
50
|
|
|
51
|
+
/**
|
|
52
|
+
* 主模型不支持视觉时,引用消息里的图片交给视觉工作模型描述(结果按内容哈希入库复用)。
|
|
53
|
+
* 主模型支持视觉时返回 undefined:图片会直接附到请求里,不必重复描述。
|
|
54
|
+
*/
|
|
55
|
+
async function describeQuotedImage(
|
|
56
|
+
pluginCtx: ChatPluginContext,
|
|
57
|
+
cfg: ChatConfig,
|
|
58
|
+
imageUrl: string | undefined,
|
|
59
|
+
userId: string,
|
|
60
|
+
groupId: string | undefined,
|
|
61
|
+
): Promise<string | undefined> {
|
|
62
|
+
if (
|
|
63
|
+
!imageUrl ||
|
|
64
|
+
mainModelSupportsVision(pluginCtx.aiService, cfg.model)
|
|
65
|
+
) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const visionAI =
|
|
69
|
+
pluginCtx.visionAIInstance ?? pluginCtx.aiService.getDefault();
|
|
70
|
+
if (!visionAI) return undefined;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const { processImage } = await import("./media/image-analyzer");
|
|
74
|
+
const record = await processImage(
|
|
75
|
+
visionAI,
|
|
76
|
+
imageUrl,
|
|
77
|
+
cfg.multimodalWorkingModel,
|
|
78
|
+
pluginCtx.db,
|
|
79
|
+
{
|
|
80
|
+
runAIRequest: (request) =>
|
|
81
|
+
pluginCtx.runWithRateLimitGuard(request, {
|
|
82
|
+
userId,
|
|
83
|
+
groupId,
|
|
84
|
+
label: "quoted-image",
|
|
85
|
+
skipRetryOnRateLimit: true,
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
return record?.description?.trim() || undefined;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
pluginCtx.ctx.logger.error(
|
|
92
|
+
`[chat-turn] Failed to describe quoted image: ${err}`,
|
|
93
|
+
);
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
46
98
|
function buildRuntimeTargetMessageContent(
|
|
47
99
|
ctx: MiokuContext,
|
|
48
100
|
event: any,
|
|
@@ -289,16 +341,14 @@ export async function processChat(
|
|
|
289
341
|
}
|
|
290
342
|
if (quotedInfo?.imageUrl) imageUrls.push(quotedInfo.imageUrl);
|
|
291
343
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
if (extraContext) messageContent = extraContext + " " + messageContent;
|
|
344
|
+
// 与历史拉取并行,省掉视觉描述带来的额外等待
|
|
345
|
+
const quotedImageNotePromise = describeQuotedImage(
|
|
346
|
+
pluginCtx,
|
|
347
|
+
cfg,
|
|
348
|
+
quotedInfo?.imageUrl,
|
|
349
|
+
userId,
|
|
350
|
+
groupId,
|
|
351
|
+
);
|
|
302
352
|
|
|
303
353
|
const rawHistory = groupId
|
|
304
354
|
? await getGroupHistory(
|
|
@@ -350,6 +400,24 @@ export async function processChat(
|
|
|
350
400
|
userId,
|
|
351
401
|
);
|
|
352
402
|
|
|
403
|
+
const quotedImageNote = await quotedImageNotePromise;
|
|
404
|
+
let messageContent = ctx.text(e) || "";
|
|
405
|
+
if (quotedInfo) {
|
|
406
|
+
const quotedText =
|
|
407
|
+
quotedInfo.content || (quotedInfo.imageUrl ? "[image]" : "");
|
|
408
|
+
const parts = [
|
|
409
|
+
`[Quoted message #${quotedInfo.messageId} from ${quotedInfo.senderName}: ${quotedText}]`,
|
|
410
|
+
];
|
|
411
|
+
if (quotedInfo.imageUrl) {
|
|
412
|
+
parts.push(
|
|
413
|
+
quotedImageNote
|
|
414
|
+
? `[Quoted message image: ${quotedImageNote}]`
|
|
415
|
+
: "[Quoted message contains an image]",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
messageContent = `${parts.join(" ")} ${messageContent}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
353
421
|
const targetMessage: TargetMessage = {
|
|
354
422
|
userName: senderName,
|
|
355
423
|
userId,
|
|
@@ -556,7 +556,7 @@ async function extractVideoFrames(
|
|
|
556
556
|
{ timeout: 30_000 },
|
|
557
557
|
);
|
|
558
558
|
const buffer = await fs.readFile(outputPath);
|
|
559
|
-
frames.push(`data:image/jpeg
|
|
559
|
+
frames.push(`data:image/jpeg;base64,${buffer.toString("base64")}`);
|
|
560
560
|
}
|
|
561
561
|
|
|
562
562
|
return frames;
|
|
@@ -5,6 +5,13 @@ const IMAGE_MAX_BYTES = 1 * 1024 * 1024;
|
|
|
5
5
|
const COMPRESS_MAX_WIDTH = 1280;
|
|
6
6
|
const COMPRESS_JPEG_QUALITY = 80;
|
|
7
7
|
|
|
8
|
+
const INLINE_MIME_TYPES = new Set([
|
|
9
|
+
"image/jpeg",
|
|
10
|
+
"image/png",
|
|
11
|
+
"image/webp",
|
|
12
|
+
"image/gif",
|
|
13
|
+
]);
|
|
14
|
+
|
|
8
15
|
export const QQ_IMAGE_FETCH_HEADERS = {
|
|
9
16
|
"User-Agent":
|
|
10
17
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
@@ -13,26 +20,24 @@ export const QQ_IMAGE_FETCH_HEADERS = {
|
|
|
13
20
|
|
|
14
21
|
const FETCH_HEADERS = QQ_IMAGE_FETCH_HEADERS;
|
|
15
22
|
|
|
16
|
-
/**
|
|
17
|
-
* 准备发给模型的图片 URL:体积超过 1MB 时压缩为 JPEG data URL,否则原样返回。
|
|
18
|
-
* data URL(如 GIF 抽帧结果)与非 http(s) 链接直接放行。
|
|
19
|
-
*/
|
|
20
23
|
export async function prepareImageUrlForModel(url: string): Promise<string> {
|
|
21
24
|
if (url.startsWith("data:")) return url;
|
|
22
25
|
if (!/^https?:\/\//i.test(url)) return url;
|
|
23
26
|
|
|
24
|
-
const size = await probeImageSize(url);
|
|
25
|
-
if (size !== null && size <= IMAGE_MAX_BYTES) return url;
|
|
26
|
-
|
|
27
27
|
let buffer: Buffer;
|
|
28
|
+
let mimeType: string;
|
|
28
29
|
try {
|
|
29
|
-
|
|
30
|
+
const downloaded = await downloadImage(url);
|
|
31
|
+
buffer = downloaded.buffer;
|
|
32
|
+
mimeType = downloaded.mimeType;
|
|
30
33
|
} catch (err) {
|
|
31
34
|
logger.warn(`[image-compress] download failed, using original: ${err}`);
|
|
32
35
|
return url;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
if (buffer.length <= IMAGE_MAX_BYTES)
|
|
38
|
+
if (buffer.length <= IMAGE_MAX_BYTES && INLINE_MIME_TYPES.has(mimeType)) {
|
|
39
|
+
return `data:${mimeType};base64,${buffer.toString("base64")}`;
|
|
40
|
+
}
|
|
36
41
|
|
|
37
42
|
try {
|
|
38
43
|
const compressed = await sharp(buffer)
|
|
@@ -44,8 +49,8 @@ export async function prepareImageUrlForModel(url: string): Promise<string> {
|
|
|
44
49
|
);
|
|
45
50
|
return `data:image/jpeg;base64,${compressed.toString("base64")}`;
|
|
46
51
|
} catch (err) {
|
|
47
|
-
logger.warn(`[image-compress] compress failed,
|
|
48
|
-
return
|
|
52
|
+
logger.warn(`[image-compress] compress failed, inlining original: ${err}`);
|
|
53
|
+
return `data:${mimeType};base64,${buffer.toString("base64")}`;
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
|
|
@@ -55,23 +60,29 @@ export async function prepareImageUrlsForModel(
|
|
|
55
60
|
return Promise.all(urls.map(prepareImageUrlForModel));
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
async function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (!head.ok) return null;
|
|
62
|
-
const len = head.headers.get("content-length");
|
|
63
|
-
if (!len) return null;
|
|
64
|
-
const n = Number(len);
|
|
65
|
-
return Number.isFinite(n) ? n : null;
|
|
66
|
-
} catch {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function downloadImageBuffer(url: string): Promise<Buffer> {
|
|
63
|
+
async function downloadImage(
|
|
64
|
+
url: string,
|
|
65
|
+
): Promise<{ buffer: Buffer; mimeType: string }> {
|
|
72
66
|
const response = await fetch(url, { headers: FETCH_HEADERS });
|
|
73
67
|
if (!response.ok) {
|
|
74
|
-
throw new Error(
|
|
68
|
+
throw new Error(
|
|
69
|
+
`download failed: ${response.status} ${response.statusText}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const mimeType = normalizeImageMimeType(
|
|
74
|
+
response.headers.get("content-type") || "",
|
|
75
|
+
);
|
|
76
|
+
if (!mimeType) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`not an image response: ${response.headers.get("content-type") || "unknown"}`,
|
|
79
|
+
);
|
|
75
80
|
}
|
|
76
|
-
|
|
81
|
+
|
|
82
|
+
return { buffer: Buffer.from(await response.arrayBuffer()), mimeType };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeImageMimeType(contentType: string): string | null {
|
|
86
|
+
const mimeType = contentType.split(";")[0]?.trim().toLowerCase() || "";
|
|
87
|
+
return mimeType.startsWith("image/") ? mimeType : null;
|
|
77
88
|
}
|
package/core/tools/info.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {logger} from "mioku";
|
|
|
2
2
|
import type { AITool } from "mioku";
|
|
3
3
|
import { TOOL_RESULT_FOLLOWUP_KEY } from "mioku";
|
|
4
4
|
import type { ToolContext } from "../../types";
|
|
5
|
+
import { mainModelSupportsVision } from "../../utils/model";
|
|
5
6
|
|
|
6
7
|
async function createImageFollowupResult(
|
|
7
8
|
imageUrl: string,
|
|
@@ -158,7 +159,7 @@ export function createInfoTools(toolCtx: ToolContext): AITool[] {
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
if (media.kind === "image") {
|
|
161
|
-
if (toolCtx.config.
|
|
162
|
+
if (mainModelSupportsVision(toolCtx.aiService, toolCtx.config.model)) {
|
|
162
163
|
return await createImageFollowupResult(
|
|
163
164
|
media.url,
|
|
164
165
|
`The image from message #${args.message_id} is attached. Inspect it directly and answer the user's question from the visual content.`,
|
|
@@ -205,7 +206,8 @@ export function createInfoTools(toolCtx: ToolContext): AITool[] {
|
|
|
205
206
|
|
|
206
207
|
try {
|
|
207
208
|
const attachFullVideo =
|
|
208
|
-
toolCtx.config.
|
|
209
|
+
mainModelSupportsVision(toolCtx.aiService, toolCtx.config.model) &&
|
|
210
|
+
videoFile.byteSize <= VIDEO_FULL_UPLOAD_MAX_BYTES;
|
|
209
211
|
|
|
210
212
|
if (attachFullVideo) {
|
|
211
213
|
const fs = await import("fs/promises");
|
|
@@ -266,7 +268,7 @@ export function createInfoTools(toolCtx: ToolContext): AITool[] {
|
|
|
266
268
|
const avatarUrl = `https://q1.qlogo.cn/g?b=qq&nk=${args.user_id}&s=640`;
|
|
267
269
|
logger.info(`[view_member_avatar] Analyzing avatar: ${avatarUrl}`);
|
|
268
270
|
|
|
269
|
-
if (toolCtx.config.
|
|
271
|
+
if (mainModelSupportsVision(toolCtx.aiService, toolCtx.config.model)) {
|
|
270
272
|
return await createImageFollowupResult(
|
|
271
273
|
avatarUrl,
|
|
272
274
|
`User ${args.user_id}'s QQ avatar is attached. Inspect it directly and answer the user's question from the visual content.`,
|
package/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { buildHistoryMediaOptions } from "./core/media/segment";
|
|
|
48
48
|
import type { ChatConfigProvider } from "./humanize";
|
|
49
49
|
import type { ChatPluginContext, ChatHandlerState } from "./context";
|
|
50
50
|
import { mergeGroupOverrides } from "./utils/group-config";
|
|
51
|
+
import { mainModelSupportsVision } from "./utils/model";
|
|
51
52
|
import {
|
|
52
53
|
mergeChatConfig,
|
|
53
54
|
normalizeIdList,
|
|
@@ -95,9 +96,20 @@ function resolveRoleInstances(aiService: AIService): {
|
|
|
95
96
|
const visionModel = findModel("vision", vision) || workingModel;
|
|
96
97
|
|
|
97
98
|
const models = aiService.listModels?.() ?? [];
|
|
98
|
-
const
|
|
99
|
-
models.find((item) => item.id === bindings
|
|
100
|
-
|
|
99
|
+
const findModelDesc = (role: AIModelRole, modelId: string) => {
|
|
100
|
+
const exact = models.find((item) => item.id === bindings[role]);
|
|
101
|
+
if (exact) return exact;
|
|
102
|
+
const providerId = instances.find(
|
|
103
|
+
(info) => info.role === role || info.name === role,
|
|
104
|
+
)?.providerId;
|
|
105
|
+
return (
|
|
106
|
+
models.find(
|
|
107
|
+
(item) => item.modelId === modelId && item.providerId === providerId,
|
|
108
|
+
) || models.find((item) => item.modelId === modelId)
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const visionDesc = findModelDesc("vision", visionModel);
|
|
101
113
|
const isMultimodal =
|
|
102
114
|
visionDesc?.capabilities?.includes("vision") ?? Boolean(visionModel);
|
|
103
115
|
|
|
@@ -341,7 +353,7 @@ export default definePlugin({
|
|
|
341
353
|
return;
|
|
342
354
|
}
|
|
343
355
|
|
|
344
|
-
roleModels = resolved.models;
|
|
356
|
+
roleModels = resolved.models;
|
|
345
357
|
roleIsMultimodal = resolved.isMultimodal;
|
|
346
358
|
await refreshBaseCache();
|
|
347
359
|
|
|
@@ -567,7 +579,11 @@ roleModels = resolved.models;
|
|
|
567
579
|
usage: ".tts <文本>",
|
|
568
580
|
handler: async ({ event, match }) => {
|
|
569
581
|
if (String(event.user_id ?? "") === String(event.self_id ?? "")) return;
|
|
570
|
-
await handleTtsCommand(
|
|
582
|
+
await handleTtsCommand(
|
|
583
|
+
pluginCtx,
|
|
584
|
+
event,
|
|
585
|
+
String(match?.[1] ?? "").trim(),
|
|
586
|
+
);
|
|
571
587
|
},
|
|
572
588
|
});
|
|
573
589
|
|
|
@@ -575,7 +591,7 @@ roleModels = resolved.models;
|
|
|
575
591
|
ctx.handle("notice.group.poke", createPokeHandler(pluginCtx, handlerState));
|
|
576
592
|
|
|
577
593
|
ctx.logger.info(
|
|
578
|
-
`聊天插件加载成功 (main=${roleModels.main || "?"}, work=${roleModels.working || "?"}, vision=${roleModels.vision || "?"})`,
|
|
594
|
+
`聊天插件加载成功 (main=${roleModels.main || "?"}${mainModelSupportsVision(aiService, roleModels.main) ? "" : " 无视觉,图片交给视觉模型描述"}, work=${roleModels.working || "?"}, vision=${roleModels.vision || "?"})`,
|
|
579
595
|
);
|
|
580
596
|
|
|
581
597
|
return () => {
|
package/package.json
CHANGED
package/utils/index.ts
CHANGED
package/utils/message.ts
CHANGED
|
@@ -283,10 +283,17 @@ function extractQuotedText(messageSegs: any): string {
|
|
|
283
283
|
return parts.join(" ").trim();
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
|
|
286
|
+
/** 引用关系:id 必有,发送者与原文可能缺失(例如 onebot 历史只给 id) */
|
|
287
|
+
interface ReplyRef {
|
|
288
|
+
id: string;
|
|
289
|
+
name?: string;
|
|
290
|
+
text?: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function extractReplyRef(
|
|
287
294
|
source: any,
|
|
288
295
|
ctx: HistoryFormatContext,
|
|
289
|
-
):
|
|
296
|
+
): ReplyRef | null {
|
|
290
297
|
if (!source || typeof source !== "object") return null;
|
|
291
298
|
const sourceId = source.id ?? source.message_id ?? source.message_seq;
|
|
292
299
|
const sourceUserId = source.user_id;
|
|
@@ -294,18 +301,38 @@ function buildReplyAnnotation(
|
|
|
294
301
|
source.sender?.card || source.sender?.nickname || source.nickname;
|
|
295
302
|
const sourceText = extractQuotedText(source.message);
|
|
296
303
|
|
|
297
|
-
if (
|
|
304
|
+
if (
|
|
305
|
+
sourceId == null &&
|
|
306
|
+
sourceUserId == null &&
|
|
307
|
+
!sourceText &&
|
|
308
|
+
!sourceNickname
|
|
309
|
+
) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
298
312
|
|
|
299
|
-
|
|
300
|
-
let displayName: string | undefined;
|
|
313
|
+
let name: string | undefined;
|
|
301
314
|
if (sourceUserId != null) {
|
|
302
|
-
|
|
303
|
-
ctx.memberNameCache.get(String(sourceUserId)) || String(sourceUserId);
|
|
315
|
+
name = ctx.memberNameCache.get(String(sourceUserId)) || String(sourceUserId);
|
|
304
316
|
}
|
|
305
|
-
if (!
|
|
317
|
+
if (!name) name = sourceNickname || undefined;
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
id: sourceId != null ? String(sourceId) : "",
|
|
321
|
+
name,
|
|
322
|
+
text: sourceText || undefined,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function renderReplyRef(ref: ReplyRef): string {
|
|
327
|
+
const head = `↪ reply to #${ref.id || "?"}`;
|
|
328
|
+
if (!ref.name && !ref.text) return head;
|
|
329
|
+
if (!ref.text) return `${head} ${ref.name}`;
|
|
330
|
+
return ref.name ? `${head} ${ref.name}: "${ref.text}"` : `${head}: "${ref.text}"`;
|
|
331
|
+
}
|
|
306
332
|
|
|
307
|
-
|
|
308
|
-
|
|
333
|
+
function summarizeQuotedContent(content: string): string {
|
|
334
|
+
const flat = content.replace(/\s+/g, " ").trim();
|
|
335
|
+
return flat.length > 80 ? `${flat.slice(0, 80)}…` : flat;
|
|
309
336
|
}
|
|
310
337
|
|
|
311
338
|
async function getImageTagWithHashCache(
|
|
@@ -345,12 +372,14 @@ async function getImageTagWithHashCache(
|
|
|
345
372
|
async function formatHistoryMessage(
|
|
346
373
|
msg: HistoryMessage,
|
|
347
374
|
ctx: HistoryFormatContext,
|
|
348
|
-
): Promise<FormattedHistoryMessage | null> {
|
|
375
|
+
): Promise<{ formatted: FormattedHistoryMessage; reply: ReplyRef | null } | null> {
|
|
349
376
|
// 自己或本运行时其它 bot 的消息不作为用户历史,
|
|
350
377
|
// 否则多 bot 同群时会把彼此的消息当作用户接话,形成循环触发
|
|
351
378
|
if (String(msg.user_id) === String(ctx.botUin)) return null;
|
|
352
379
|
if (ctx.botIds.has(String(msg.user_id))) return null;
|
|
353
380
|
|
|
381
|
+
const reply = extractReplyRef((msg as { source?: unknown }).source, ctx);
|
|
382
|
+
|
|
354
383
|
let content = "";
|
|
355
384
|
try {
|
|
356
385
|
content = await buildMessageContent(msg, ctx);
|
|
@@ -359,19 +388,22 @@ async function formatHistoryMessage(
|
|
|
359
388
|
return null;
|
|
360
389
|
}
|
|
361
390
|
|
|
362
|
-
if (!content.trim()) return null;
|
|
391
|
+
if (!content.trim() && !reply) return null;
|
|
363
392
|
|
|
364
393
|
return {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
394
|
+
formatted: {
|
|
395
|
+
userId: String(msg.user_id ?? "").trim(),
|
|
396
|
+
userName: msg.nickname || String(msg.user_id ?? "unknown"),
|
|
397
|
+
userRole: "member",
|
|
398
|
+
content,
|
|
399
|
+
messageId:
|
|
400
|
+
msg.message_id != null && msg.message_id !== ""
|
|
401
|
+
? String(msg.message_id)
|
|
402
|
+
: "",
|
|
403
|
+
timestamp: msg.time ?? Date.now(),
|
|
404
|
+
role: "user",
|
|
405
|
+
},
|
|
406
|
+
reply,
|
|
375
407
|
};
|
|
376
408
|
}
|
|
377
409
|
|
|
@@ -385,9 +417,6 @@ async function buildMessageContent(
|
|
|
385
417
|
const { db, historyMediaOptions } = ctx;
|
|
386
418
|
const parts: string[] = [];
|
|
387
419
|
|
|
388
|
-
const replyAnnotation = buildReplyAnnotation(msg.source, ctx);
|
|
389
|
-
if (replyAnnotation) parts.push(replyAnnotation);
|
|
390
|
-
|
|
391
420
|
const textSegs = msg.message.filter((seg: any) => seg.type === "text");
|
|
392
421
|
const textContent = textSegs
|
|
393
422
|
.map((seg: any) => seg.data?.text || "")
|
|
@@ -623,15 +652,51 @@ export async function getGroupHistory(
|
|
|
623
652
|
hashLookupCache,
|
|
624
653
|
};
|
|
625
654
|
|
|
626
|
-
const
|
|
655
|
+
const processed = await mapWithConcurrency(
|
|
627
656
|
messages,
|
|
628
657
|
HISTORY_MEDIA_CONCURRENCY,
|
|
629
658
|
(msg: any) => formatHistoryMessage(msg, formatCtx),
|
|
630
659
|
);
|
|
631
|
-
const
|
|
632
|
-
(
|
|
660
|
+
const entries = processed.filter(
|
|
661
|
+
(
|
|
662
|
+
item,
|
|
663
|
+
): item is {
|
|
664
|
+
formatted: FormattedHistoryMessage;
|
|
665
|
+
reply: ReplyRef | null;
|
|
666
|
+
} => Boolean(item),
|
|
633
667
|
);
|
|
634
668
|
|
|
669
|
+
// 引用关系单独渲染:被引用的消息若也在本批历史里,直接补上发送者与原文
|
|
670
|
+
const byMessageId = new Map<string, FormattedHistoryMessage>();
|
|
671
|
+
for (const item of entries) {
|
|
672
|
+
if (item.formatted.messageId) {
|
|
673
|
+
byMessageId.set(item.formatted.messageId, item.formatted);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
for (const botMessage of botMessages) {
|
|
677
|
+
if (botMessage.messageId && !byMessageId.has(botMessage.messageId)) {
|
|
678
|
+
byMessageId.set(botMessage.messageId, botMessage);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const formatted = entries.map(({ formatted: message, reply }) => {
|
|
683
|
+
if (!reply) return message;
|
|
684
|
+
const quoted = reply.id ? byMessageId.get(reply.id) : undefined;
|
|
685
|
+
const annotation = renderReplyRef({
|
|
686
|
+
...reply,
|
|
687
|
+
name: reply.name ?? quoted?.userName,
|
|
688
|
+
text:
|
|
689
|
+
reply.text ??
|
|
690
|
+
(quoted ? summarizeQuotedContent(quoted.content) || undefined : undefined),
|
|
691
|
+
});
|
|
692
|
+
return {
|
|
693
|
+
...message,
|
|
694
|
+
content: message.content
|
|
695
|
+
? `${annotation} ${message.content}`
|
|
696
|
+
: annotation,
|
|
697
|
+
};
|
|
698
|
+
});
|
|
699
|
+
|
|
635
700
|
// 合并 bot 消息
|
|
636
701
|
const allMessages = [...botMessages, ...formatted];
|
|
637
702
|
allMessages.sort((a, b) => a.timestamp - b.timestamp);
|
package/utils/model.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AIModelDescriptor, AIService } from "mioku";
|
|
2
|
+
|
|
3
|
+
const supportsVision = (model?: AIModelDescriptor): boolean =>
|
|
4
|
+
Boolean(model?.capabilities?.includes("vision"));
|
|
5
|
+
|
|
6
|
+
export function mainModelSupportsVision(
|
|
7
|
+
aiService: AIService | undefined,
|
|
8
|
+
fallbackModelId?: string,
|
|
9
|
+
): boolean {
|
|
10
|
+
if (!aiService) return false;
|
|
11
|
+
|
|
12
|
+
const models = aiService.listModels?.() ?? [];
|
|
13
|
+
const boundId = aiService.getRoleBindings?.()?.main;
|
|
14
|
+
if (boundId) {
|
|
15
|
+
const bound = models.find((item) => item.id === boundId);
|
|
16
|
+
if (bound) return supportsVision(bound);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const instance = (aiService.listInstances?.() ?? []).find(
|
|
20
|
+
(item) => item.role === "main" || item.name === "main",
|
|
21
|
+
);
|
|
22
|
+
const modelId = fallbackModelId || instance?.modelId || "";
|
|
23
|
+
if (!modelId) return false;
|
|
24
|
+
|
|
25
|
+
return supportsVision(
|
|
26
|
+
models.find(
|
|
27
|
+
(item) =>
|
|
28
|
+
item.modelId === modelId && item.providerId === instance?.providerId,
|
|
29
|
+
) ?? models.find((item) => item.modelId === modelId),
|
|
30
|
+
);
|
|
31
|
+
}
|