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.
@@ -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 = toolCtx.config.isMultimodal
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 `${idx}`;
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("", i + 1);
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 { getGroupHistory, getBotRole, getQuotedContent } from "../utils";
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
- let messageContent = ctx.text(e) || "";
293
- let extraContext = "";
294
- if (quotedInfo) {
295
- const parts = [
296
- `[Quoted message #${quotedInfo.messageId} from ${quotedInfo.senderName}: ${quotedInfo.content}]`,
297
- ];
298
- if (quotedInfo.imageUrl) parts.push("[Quoted message contains an image]");
299
- extraContext = parts.join(" ");
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);base64,${buffer.toString("base64")}`);
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
- buffer = await downloadImageBuffer(url);
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) return url;
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, using original: ${err}`);
48
- return url;
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 probeImageSize(url: string): Promise<number | null> {
59
- try {
60
- const head = await fetch(url, { method: "HEAD", headers: FETCH_HEADERS });
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(`download failed: ${response.status} ${response.statusText}`);
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
- return Buffer.from(await response.arrayBuffer());
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
  }
@@ -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.isMultimodal) {
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.isMultimodal && videoFile.byteSize <= VIDEO_FULL_UPLOAD_MAX_BYTES;
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.isMultimodal) {
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 visionDesc =
99
- models.find((item) => item.id === bindings.vision) ||
100
- models.find((item) => item.modelId === visionModel);
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(pluginCtx, event, String(match?.[1] ?? "").trim());
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-chat",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "AI 智能聊天插件",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/utils/index.ts CHANGED
@@ -9,3 +9,5 @@ export {
9
9
  buildChatMessageFromEvent,
10
10
  normalizeIdList,
11
11
  } from "./message";
12
+
13
+ export { mainModelSupportsVision } from "./model";
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
- function buildReplyAnnotation(
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
- ): string | null {
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 (sourceUserId == null && !sourceText && !sourceNickname) return null;
304
+ if (
305
+ sourceId == null &&
306
+ sourceUserId == null &&
307
+ !sourceText &&
308
+ !sourceNickname
309
+ ) {
310
+ return null;
311
+ }
298
312
 
299
- const idStr = sourceId != null ? String(sourceId) : "?";
300
- let displayName: string | undefined;
313
+ let name: string | undefined;
301
314
  if (sourceUserId != null) {
302
- displayName =
303
- ctx.memberNameCache.get(String(sourceUserId)) || String(sourceUserId);
315
+ name = ctx.memberNameCache.get(String(sourceUserId)) || String(sourceUserId);
304
316
  }
305
- if (!displayName) displayName = sourceNickname || "unknown";
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
- const text = sourceText || "(empty)";
308
- return `↪ reply to #${idStr} ${displayName}: "${text}"`;
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
- userId: String(msg.user_id ?? "").trim(),
366
- userName: msg.nickname || String(msg.user_id ?? "unknown"),
367
- userRole: "member",
368
- content,
369
- messageId:
370
- msg.message_id != null && msg.message_id !== ""
371
- ? String(msg.message_id)
372
- : "",
373
- timestamp: msg.time ?? Date.now(),
374
- role: "user",
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 formattedResults = await mapWithConcurrency(
655
+ const processed = await mapWithConcurrency(
627
656
  messages,
628
657
  HISTORY_MEDIA_CONCURRENCY,
629
658
  (msg: any) => formatHistoryMessage(msg, formatCtx),
630
659
  );
631
- const formatted = formattedResults.filter(
632
- (msg): msg is FormattedHistoryMessage => Boolean(msg),
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
+ }