mioku-plugin-chat 2.3.0 → 2.4.0
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/configs/base.ts +1 -0
- package/configs/settings.ts +1 -1
- package/context.ts +17 -0
- package/core/chat-engine.ts +6 -1
- package/core/chat-turn.ts +540 -0
- package/core/media/history-media.ts +215 -70
- package/core/media/image-analyzer.ts +28 -19
- package/core/media/image-compress.ts +75 -0
- package/core/media/segment.ts +91 -0
- package/core/multimodal.ts +24 -12
- package/core/prompt.ts +332 -445
- package/core/tools/info.ts +284 -0
- package/core/tools/load-skill.ts +149 -0
- package/core/tools/web.ts +306 -0
- package/core/tools.ts +11 -822
- package/db.ts +45 -142
- package/handlers/idle-debug.ts +135 -0
- package/handlers/message.ts +316 -0
- package/handlers/poke.ts +165 -0
- package/humanize/emotion-agent.ts +3 -4
- package/humanize/expression.ts +3 -4
- package/humanize/planner.ts +2 -2
- package/humanize/topic.ts +3 -4
- package/index.ts +73 -1705
- package/manage/cooldown.ts +52 -27
- package/manage/group-structured-history.ts +35 -0
- package/manage/idle-check.ts +40 -20
- package/manage/queue-processor.ts +51 -25
- package/manage/rate-limit-guard.ts +70 -0
- package/package.json +1 -1
- package/runtime/chat-runtime.ts +124 -0
- package/types.ts +1 -0
- package/utils/index.ts +2 -0
- package/utils/json.ts +8 -0
- package/utils/message.ts +185 -175
|
@@ -12,14 +12,51 @@ const execFileAsync = promisify(execFile);
|
|
|
12
12
|
const VIDEO_FRAME_COUNT = 5;
|
|
13
13
|
const VIDEO_FRAME_EXTRACTION_FALLBACK =
|
|
14
14
|
"用户发送了一个视频,但未能提取画面内容";
|
|
15
|
+
// 体积阈值:超过此大小(10MB)的视频不再整体上传给多模态工作模型,改用抽帧。
|
|
16
|
+
export const VIDEO_FULL_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
|
15
17
|
|
|
16
18
|
interface SummaryResult {
|
|
17
19
|
summary: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
export interface MediaMessageSegment {
|
|
23
|
+
type?: string;
|
|
24
|
+
file?: string;
|
|
25
|
+
path?: string;
|
|
26
|
+
url?: string;
|
|
27
|
+
id?: string;
|
|
28
|
+
data?: {
|
|
29
|
+
file?: string;
|
|
30
|
+
path?: string;
|
|
31
|
+
url?: string;
|
|
32
|
+
id?: string;
|
|
33
|
+
data?: string;
|
|
34
|
+
xml?: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getSegmentSourceCandidates(seg: MediaMessageSegment): string[] {
|
|
39
|
+
return Array.from(
|
|
40
|
+
new Set(
|
|
41
|
+
[
|
|
42
|
+
seg?.file,
|
|
43
|
+
seg?.data?.file,
|
|
44
|
+
seg?.path,
|
|
45
|
+
seg?.data?.path,
|
|
46
|
+
seg?.url,
|
|
47
|
+
seg?.data?.url,
|
|
48
|
+
]
|
|
49
|
+
.map((v) => (typeof v === "string" ? v.trim() : ""))
|
|
50
|
+
.filter(Boolean),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
20
55
|
export interface MediaSummaryStore {
|
|
21
56
|
getMediaSummary(key: string): MediaSummaryRecord | null;
|
|
22
57
|
saveMediaSummary(summary: MediaSummaryRecord): void;
|
|
58
|
+
getMediaSummaryBySource?(sourceKey: string): MediaSummaryRecord | null;
|
|
59
|
+
saveMediaSummarySource?(sourceKey: string, summaryKey: string): void;
|
|
23
60
|
}
|
|
24
61
|
|
|
25
62
|
export interface HistoryMediaProcessingOptions {
|
|
@@ -71,53 +108,8 @@ export async function summarizeHistoryVideo(
|
|
|
71
108
|
videoFile.source,
|
|
72
109
|
videoFile.contentHash,
|
|
73
110
|
options,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
videoFile.path,
|
|
77
|
-
VIDEO_FRAME_COUNT,
|
|
78
|
-
options,
|
|
79
|
-
);
|
|
80
|
-
if (frames.length === 0) {
|
|
81
|
-
getHistoryMediaLogger(options).warn(
|
|
82
|
-
"[history-media] Video frame extraction returned 0 frames",
|
|
83
|
-
);
|
|
84
|
-
return VIDEO_FRAME_EXTRACTION_FALLBACK;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const content: any[] = [
|
|
88
|
-
{
|
|
89
|
-
type: "text",
|
|
90
|
-
text: `These ${frames.length} frames were sampled evenly from a chat video. Summarize the video's likely content in Chinese for chat history context. Mention visible people/objects/actions/text, and keep it concise.`,
|
|
91
|
-
},
|
|
92
|
-
...frames.map((frame) => ({
|
|
93
|
-
type: "image_url",
|
|
94
|
-
image_url: { url: frame, detail: "auto" },
|
|
95
|
-
})),
|
|
96
|
-
];
|
|
97
|
-
|
|
98
|
-
const response = await runHistoryMediaAIRequest(options, () =>
|
|
99
|
-
options.ai!.complete({
|
|
100
|
-
model: options.multimodalWorkingModel,
|
|
101
|
-
messages: [
|
|
102
|
-
{
|
|
103
|
-
role: "system",
|
|
104
|
-
content:
|
|
105
|
-
"You summarize video frames for a chat history. Be factual and concise. If frames are ambiguous, say so.",
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
role: "user",
|
|
109
|
-
content,
|
|
110
|
-
},
|
|
111
|
-
],
|
|
112
|
-
temperature: 0.3,
|
|
113
|
-
}),
|
|
114
|
-
);
|
|
115
|
-
if (!response) {
|
|
116
|
-
return "";
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return normalizeSummary(response.content);
|
|
120
|
-
},
|
|
111
|
+
() => summarizeVideoContent(videoFile.path, videoFile.byteSize, options),
|
|
112
|
+
sources,
|
|
121
113
|
);
|
|
122
114
|
|
|
123
115
|
logMediaSummary(options, "video", result.summary);
|
|
@@ -127,30 +119,128 @@ export async function summarizeHistoryVideo(
|
|
|
127
119
|
}
|
|
128
120
|
}
|
|
129
121
|
|
|
130
|
-
export async function
|
|
131
|
-
|
|
122
|
+
export async function summarizeVideoContent(
|
|
123
|
+
videoPath: string,
|
|
124
|
+
byteSize: number,
|
|
132
125
|
options: HistoryMediaProcessingOptions,
|
|
133
126
|
): Promise<string> {
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
try {
|
|
137
|
-
const videoFile = await downloadVideoForAnalysis(sources, options);
|
|
127
|
+
// 小视频直接整体喂给多模态工作模型;过大则抽帧避免请求体爆炸。
|
|
128
|
+
if (byteSize <= VIDEO_FULL_UPLOAD_MAX_BYTES) {
|
|
138
129
|
try {
|
|
139
|
-
const summary =
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
130
|
+
const summary = await summarizeVideoByFullVideo(videoPath, options);
|
|
131
|
+
if (summary) return summary;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
getHistoryMediaLogger(options).warn(
|
|
134
|
+
`[history-media] Full-video summarization failed, falling back to frames: ${err}`,
|
|
143
135
|
);
|
|
144
|
-
return summary ? `[video:${summary}]` : "[video]";
|
|
145
|
-
} finally {
|
|
146
|
-
await videoFile.cleanup();
|
|
147
136
|
}
|
|
148
|
-
}
|
|
137
|
+
}
|
|
138
|
+
return summarizeVideoByFrames(videoPath, options);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function summarizeVideoByFullVideo(
|
|
142
|
+
videoPath: string,
|
|
143
|
+
options: HistoryMediaProcessingOptions,
|
|
144
|
+
): Promise<string> {
|
|
145
|
+
const mimeType = await probeVideoMimeType(videoPath);
|
|
146
|
+
const buffer = await fs.readFile(videoPath);
|
|
147
|
+
const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
|
|
148
|
+
|
|
149
|
+
const content: any[] = [
|
|
150
|
+
{
|
|
151
|
+
type: "text",
|
|
152
|
+
text: "This is a video sent by someone in a chat. Summarize the video's content in Chinese for later use as chat history context: describe the visible people/objects/actions/scenes/on-screen text, and briefly explain what the video is about. Stay factual and concise; state uncertainty honestly when unclear.",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
type: "video_url",
|
|
156
|
+
video_url: { url: dataUrl },
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const response = await runHistoryMediaAIRequest(options, () =>
|
|
161
|
+
options.ai!.complete({
|
|
162
|
+
model: options.multimodalWorkingModel,
|
|
163
|
+
messages: [
|
|
164
|
+
{
|
|
165
|
+
role: "system",
|
|
166
|
+
content:
|
|
167
|
+
"You summarize video content for a chat history. Describe only what the video actually shows, objectively and concisely. Do not invent information that is not present.",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
role: "user",
|
|
171
|
+
content,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
temperature: 0.3,
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
if (!response) return "";
|
|
178
|
+
return normalizeSummary(response.content);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function summarizeVideoByFrames(
|
|
182
|
+
videoPath: string,
|
|
183
|
+
options: HistoryMediaProcessingOptions,
|
|
184
|
+
): Promise<string> {
|
|
185
|
+
const frames = await extractVideoFrames(
|
|
186
|
+
videoPath,
|
|
187
|
+
VIDEO_FRAME_COUNT,
|
|
188
|
+
options,
|
|
189
|
+
);
|
|
190
|
+
if (frames.length === 0) {
|
|
149
191
|
getHistoryMediaLogger(options).warn(
|
|
150
|
-
|
|
192
|
+
"[history-media] Video frame extraction returned 0 frames",
|
|
151
193
|
);
|
|
152
|
-
return
|
|
194
|
+
return VIDEO_FRAME_EXTRACTION_FALLBACK;
|
|
153
195
|
}
|
|
196
|
+
|
|
197
|
+
const content: any[] = [
|
|
198
|
+
{
|
|
199
|
+
type: "text",
|
|
200
|
+
text: `These ${frames.length} frames were sampled evenly from a video sent in a chat. Summarize the video's likely content in Chinese for later use as chat history context: note the visible people/objects/actions/on-screen text, and infer what the video is about. Stay factual and concise; if the frames are ambiguous or insufficient, say so honestly.`,
|
|
201
|
+
},
|
|
202
|
+
...frames.map((frame) => ({
|
|
203
|
+
type: "image_url",
|
|
204
|
+
image_url: { url: frame, detail: "auto" },
|
|
205
|
+
})),
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
const response = await runHistoryMediaAIRequest(options, () =>
|
|
209
|
+
options.ai!.complete({
|
|
210
|
+
model: options.multimodalWorkingModel,
|
|
211
|
+
messages: [
|
|
212
|
+
{
|
|
213
|
+
role: "system",
|
|
214
|
+
content:
|
|
215
|
+
"You summarize video content for a chat history from evenly sampled frames. Describe what the frames plausibly show, objectively and concisely. Do not invent information that is not present.",
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
role: "user",
|
|
219
|
+
content,
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
temperature: 0.3,
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
if (!response) return "";
|
|
226
|
+
return normalizeSummary(response.content);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function getCachedHistoryVideoTag(
|
|
230
|
+
videoSource: string | string[],
|
|
231
|
+
options: HistoryMediaProcessingOptions,
|
|
232
|
+
): Promise<string> {
|
|
233
|
+
const sources = normalizeVideoSources(videoSource);
|
|
234
|
+
if (sources.length === 0) return "[video]";
|
|
235
|
+
|
|
236
|
+
for (const source of sources) {
|
|
237
|
+
const cached = options.db?.getMediaSummaryBySource?.(source);
|
|
238
|
+
if (cached?.summary && !isFallbackVideoSummary(cached.summary)) {
|
|
239
|
+
return `[video:${cached.summary}]`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return "[video]";
|
|
154
244
|
}
|
|
155
245
|
|
|
156
246
|
export async function summarizeHistoryForward(
|
|
@@ -315,8 +405,16 @@ async function getOrCreateSummary(
|
|
|
315
405
|
contentHash: string,
|
|
316
406
|
options: HistoryMediaProcessingOptions,
|
|
317
407
|
producer: () => Promise<string>,
|
|
408
|
+
sourceAliases: string[] = [],
|
|
318
409
|
): Promise<SummaryResult> {
|
|
319
410
|
const cacheKey = `${kind}:${contentHash}`;
|
|
411
|
+
const aliases = Array.from(
|
|
412
|
+
new Set(
|
|
413
|
+
[source, ...sourceAliases]
|
|
414
|
+
.map((s) => String(s || "").trim())
|
|
415
|
+
.filter(Boolean),
|
|
416
|
+
),
|
|
417
|
+
);
|
|
320
418
|
const cached = options.db?.getMediaSummary(cacheKey);
|
|
321
419
|
if (cached?.summary) {
|
|
322
420
|
if (kind === "video") {
|
|
@@ -325,9 +423,11 @@ async function getOrCreateSummary(
|
|
|
325
423
|
// Old versions cached this probe failure as a valid summary. Ignore it
|
|
326
424
|
// so the new message can be downloaded and diagnosed again.
|
|
327
425
|
} else {
|
|
426
|
+
writeMediaSummarySources(options, cacheKey, aliases);
|
|
328
427
|
return { summary: cached.summary };
|
|
329
428
|
}
|
|
330
429
|
} else {
|
|
430
|
+
writeMediaSummarySources(options, cacheKey, aliases);
|
|
331
431
|
return { summary: cached.summary };
|
|
332
432
|
}
|
|
333
433
|
}
|
|
@@ -345,6 +445,7 @@ async function getOrCreateSummary(
|
|
|
345
445
|
summary,
|
|
346
446
|
createdAt: Date.now(),
|
|
347
447
|
});
|
|
448
|
+
writeMediaSummarySources(options, cacheKey, aliases);
|
|
348
449
|
return { summary };
|
|
349
450
|
}
|
|
350
451
|
} catch (err) {
|
|
@@ -358,6 +459,17 @@ async function getOrCreateSummary(
|
|
|
358
459
|
};
|
|
359
460
|
}
|
|
360
461
|
|
|
462
|
+
function writeMediaSummarySources(
|
|
463
|
+
options: HistoryMediaProcessingOptions,
|
|
464
|
+
summaryKey: string,
|
|
465
|
+
sources: string[],
|
|
466
|
+
): void {
|
|
467
|
+
if (!options.db?.saveMediaSummarySource) return;
|
|
468
|
+
for (const source of sources) {
|
|
469
|
+
options.db.saveMediaSummarySource!(source, summaryKey);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
361
473
|
function logMediaSummary(
|
|
362
474
|
options: HistoryMediaProcessingOptions,
|
|
363
475
|
kind: MediaSummaryKind,
|
|
@@ -478,6 +590,39 @@ async function probeVideoDuration(videoUrl: string): Promise<number> {
|
|
|
478
590
|
return Number.isFinite(duration) && duration > 0 ? duration : 1;
|
|
479
591
|
}
|
|
480
592
|
|
|
593
|
+
export async function probeVideoMimeType(videoPath: string): Promise<string> {
|
|
594
|
+
try {
|
|
595
|
+
const { stdout } = await execFileAsync(
|
|
596
|
+
"ffprobe",
|
|
597
|
+
[
|
|
598
|
+
"-v",
|
|
599
|
+
"error",
|
|
600
|
+
"-show_entries",
|
|
601
|
+
"format=format_name",
|
|
602
|
+
"-of",
|
|
603
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
604
|
+
videoPath,
|
|
605
|
+
],
|
|
606
|
+
{ timeout: 20_000 },
|
|
607
|
+
);
|
|
608
|
+
return mapFormatNameToMimeType(String(stdout).trim());
|
|
609
|
+
} catch {
|
|
610
|
+
return "video/mp4";
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function mapFormatNameToMimeType(formatName: string): string {
|
|
615
|
+
const names = formatName.split(",").map((s) => s.trim().toLowerCase());
|
|
616
|
+
if (names.some((n) => n === "webm")) return "video/webm";
|
|
617
|
+
if (names.some((n) => n === "matroska" || n === "mkv"))
|
|
618
|
+
return "video/x-matroska";
|
|
619
|
+
if (names.some((n) => n === "avi")) return "video/x-msvideo";
|
|
620
|
+
if (names.some((n) => n === "flv")) return "video/x-flv";
|
|
621
|
+
if (names.some((n) => n === "mov" || n === "mp4" || n === "m4v"))
|
|
622
|
+
return "video/mp4";
|
|
623
|
+
return "video/mp4";
|
|
624
|
+
}
|
|
625
|
+
|
|
481
626
|
function buildFfmpegInputArgs(input: string, args: string[]): string[] {
|
|
482
627
|
if (!/^https?:\/\//i.test(input)) return args;
|
|
483
628
|
const inputIndex = args.findIndex((arg) => arg === input);
|
|
@@ -610,20 +755,23 @@ function truncateText(value: string, maxLength: number): string {
|
|
|
610
755
|
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
|
611
756
|
}
|
|
612
757
|
|
|
613
|
-
async function downloadVideoForAnalysis(
|
|
758
|
+
export async function downloadVideoForAnalysis(
|
|
614
759
|
sources: string | string[],
|
|
615
760
|
options: HistoryMediaProcessingOptions,
|
|
616
761
|
): Promise<{
|
|
617
762
|
path: string;
|
|
618
763
|
source: string;
|
|
619
764
|
contentHash: string;
|
|
765
|
+
byteSize: number;
|
|
620
766
|
cleanup: () => Promise<void>;
|
|
621
767
|
}> {
|
|
622
768
|
const candidates = normalizeVideoSources(sources);
|
|
623
769
|
let lastError: unknown;
|
|
624
770
|
|
|
625
771
|
for (const source of candidates) {
|
|
626
|
-
const tempDir = await fs.mkdtemp(
|
|
772
|
+
const tempDir = await fs.mkdtemp(
|
|
773
|
+
path.join(os.tmpdir(), "mioku-video-src-"),
|
|
774
|
+
);
|
|
627
775
|
const filePath = path.join(tempDir, "video");
|
|
628
776
|
|
|
629
777
|
try {
|
|
@@ -633,6 +781,7 @@ async function downloadVideoForAnalysis(
|
|
|
633
781
|
path: filePath,
|
|
634
782
|
source,
|
|
635
783
|
contentHash: hashSource(buffer),
|
|
784
|
+
byteSize: buffer.length,
|
|
636
785
|
cleanup: () => fs.rm(tempDir, { recursive: true, force: true }),
|
|
637
786
|
};
|
|
638
787
|
} catch (err) {
|
|
@@ -677,11 +826,7 @@ async function readVideoSource(source: string): Promise<Buffer> {
|
|
|
677
826
|
function normalizeVideoSources(input: string | string[]): string[] {
|
|
678
827
|
const raw = Array.isArray(input) ? input : [input];
|
|
679
828
|
return Array.from(
|
|
680
|
-
new Set(
|
|
681
|
-
raw
|
|
682
|
-
.map((source) => String(source || "").trim())
|
|
683
|
-
.filter(Boolean),
|
|
684
|
-
),
|
|
829
|
+
new Set(raw.map((source) => String(source || "").trim()).filter(Boolean)),
|
|
685
830
|
);
|
|
686
831
|
}
|
|
687
832
|
|
|
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, createWriteStream, unlink } from "fs";
|
|
|
9
9
|
import * as https from "https";
|
|
10
10
|
import * as http from "http";
|
|
11
11
|
import { URL } from "url";
|
|
12
|
+
import { prepareImageUrlsForModel } from "./image-compress";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* 图片分析结果
|
|
@@ -74,27 +75,25 @@ const EMOTION_TAGS = [
|
|
|
74
75
|
];
|
|
75
76
|
|
|
76
77
|
/**
|
|
77
|
-
*
|
|
78
|
+
* 计算图片内容的哈希值(仅基于下载到的图片字节;URL 不参与哈希)
|
|
78
79
|
*/
|
|
79
|
-
export async function calculateImageHash(url: string): Promise<string> {
|
|
80
|
+
export async function calculateImageHash(url: string): Promise<string | null> {
|
|
80
81
|
try {
|
|
81
|
-
// 下载图片内容
|
|
82
82
|
const response = await fetch(url);
|
|
83
83
|
if (!response.ok) {
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
logger.warn(
|
|
85
|
+
`[image-analyzer] Failed to download image for hashing: ${response.status} ${response.statusText}`,
|
|
86
|
+
);
|
|
87
|
+
return null;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
89
|
-
|
|
90
|
-
// 基于图片内容计算哈希
|
|
91
91
|
return crypto.createHash("md5").update(buffer).digest("hex");
|
|
92
92
|
} catch (err) {
|
|
93
93
|
logger.warn(
|
|
94
|
-
`[image-analyzer] Failed to
|
|
94
|
+
`[image-analyzer] Failed to download image for hashing: ${err}`,
|
|
95
95
|
);
|
|
96
|
-
|
|
97
|
-
return crypto.createHash("md5").update(url).digest("hex");
|
|
96
|
+
return null;
|
|
98
97
|
}
|
|
99
98
|
}
|
|
100
99
|
|
|
@@ -176,6 +175,8 @@ export async function analyzeImage(
|
|
|
176
175
|
}
|
|
177
176
|
}
|
|
178
177
|
|
|
178
|
+
imageUrls = await prepareImageUrlsForModel(imageUrls);
|
|
179
|
+
|
|
179
180
|
const systemPrompt = `You are an image classification and analysis assistant. Analyze images and return structured information.
|
|
180
181
|
|
|
181
182
|
Instructions:
|
|
@@ -336,6 +337,12 @@ export async function processImage(
|
|
|
336
337
|
try {
|
|
337
338
|
// 计算哈希(基于图片内容)
|
|
338
339
|
const hash = await calculateImageHash(imageUrl);
|
|
340
|
+
if (!hash) {
|
|
341
|
+
logger.warn(
|
|
342
|
+
`[image-analyzer] Skipping analysis, image content unavailable: ${imageUrl}`,
|
|
343
|
+
);
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
339
346
|
|
|
340
347
|
// 检查是否已存在
|
|
341
348
|
const existing = db.getImageByHash(hash);
|
|
@@ -343,7 +350,13 @@ export async function processImage(
|
|
|
343
350
|
logger.info(formatImageRecognitionLog(existing));
|
|
344
351
|
return existing;
|
|
345
352
|
}
|
|
346
|
-
const analysis = await analyzeImage(
|
|
353
|
+
const analysis = await analyzeImage(
|
|
354
|
+
ai,
|
|
355
|
+
imageUrl,
|
|
356
|
+
model,
|
|
357
|
+
undefined,
|
|
358
|
+
options,
|
|
359
|
+
);
|
|
347
360
|
if (!analysis.success || !analysis.type) {
|
|
348
361
|
logger.warn(`[image-analyzer] Analysis failed: ${analysis.error}`);
|
|
349
362
|
return null;
|
|
@@ -436,15 +449,11 @@ export async function processImage(
|
|
|
436
449
|
|
|
437
450
|
/**
|
|
438
451
|
* 从图片 URL 获取描述标签
|
|
439
|
-
*
|
|
440
|
-
*
|
|
452
|
+
* 命中数据库记录时返回 [meme:描述] 或 [image:描述],否则返回 [image]。
|
|
453
|
+
* 收集聊天历史时用它,之前没识别到的图片不会再次触发请求。
|
|
441
454
|
*/
|
|
442
|
-
export
|
|
443
|
-
imageUrl
|
|
444
|
-
db: ChatDatabase,
|
|
445
|
-
): Promise<string> {
|
|
446
|
-
const hash = await calculateImageHash(imageUrl);
|
|
447
|
-
const record = db.getImageByHash(hash);
|
|
455
|
+
export function getImageTag(imageUrl: string, db: ChatDatabase): string {
|
|
456
|
+
const record = db.getImageByUrl(imageUrl);
|
|
448
457
|
|
|
449
458
|
if (record) {
|
|
450
459
|
return `[${record.type}:${record.description}]`;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import { logger } from "mioki";
|
|
3
|
+
|
|
4
|
+
const IMAGE_MAX_BYTES = 1 * 1024 * 1024;
|
|
5
|
+
const COMPRESS_MAX_WIDTH = 1280;
|
|
6
|
+
const COMPRESS_JPEG_QUALITY = 80;
|
|
7
|
+
|
|
8
|
+
const FETCH_HEADERS = {
|
|
9
|
+
"User-Agent":
|
|
10
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
11
|
+
Referer: "https://qq.com/",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 准备发给模型的图片 URL:体积超过 1MB 时压缩为 JPEG data URL,否则原样返回。
|
|
16
|
+
* data URL(如 GIF 抽帧结果)与非 http(s) 链接直接放行。
|
|
17
|
+
*/
|
|
18
|
+
export async function prepareImageUrlForModel(url: string): Promise<string> {
|
|
19
|
+
if (url.startsWith("data:")) return url;
|
|
20
|
+
if (!/^https?:\/\//i.test(url)) return url;
|
|
21
|
+
|
|
22
|
+
const size = await probeImageSize(url);
|
|
23
|
+
if (size !== null && size <= IMAGE_MAX_BYTES) return url;
|
|
24
|
+
|
|
25
|
+
let buffer: Buffer;
|
|
26
|
+
try {
|
|
27
|
+
buffer = await downloadImageBuffer(url);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
logger.warn(`[image-compress] download failed, using original: ${err}`);
|
|
30
|
+
return url;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (buffer.length <= IMAGE_MAX_BYTES) return url;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const compressed = await sharp(buffer)
|
|
37
|
+
.resize({ width: COMPRESS_MAX_WIDTH, withoutEnlargement: true })
|
|
38
|
+
.jpeg({ quality: COMPRESS_JPEG_QUALITY })
|
|
39
|
+
.toBuffer();
|
|
40
|
+
logger.info(
|
|
41
|
+
`[image-compress] compressed ${buffer.length} -> ${compressed.length} bytes`,
|
|
42
|
+
);
|
|
43
|
+
return `data:image/jpeg;base64,${compressed.toString("base64")}`;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
logger.warn(`[image-compress] compress failed, using original: ${err}`);
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function prepareImageUrlsForModel(
|
|
51
|
+
urls: string[],
|
|
52
|
+
): Promise<string[]> {
|
|
53
|
+
return Promise.all(urls.map(prepareImageUrlForModel));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function probeImageSize(url: string): Promise<number | null> {
|
|
57
|
+
try {
|
|
58
|
+
const head = await fetch(url, { method: "HEAD", headers: FETCH_HEADERS });
|
|
59
|
+
if (!head.ok) return null;
|
|
60
|
+
const len = head.headers.get("content-length");
|
|
61
|
+
if (!len) return null;
|
|
62
|
+
const n = Number(len);
|
|
63
|
+
return Number.isFinite(n) ? n : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function downloadImageBuffer(url: string): Promise<Buffer> {
|
|
70
|
+
const response = await fetch(url, { headers: FETCH_HEADERS });
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new Error(`download failed: ${response.status} ${response.statusText}`);
|
|
73
|
+
}
|
|
74
|
+
return Buffer.from(await response.arrayBuffer());
|
|
75
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { AIInstance } from "mioku";
|
|
2
|
+
import type { ChatConfig, MediaSummaryRecord } from "../../types";
|
|
3
|
+
import type { HistoryMediaOptions } from "../../manage/types";
|
|
4
|
+
import {
|
|
5
|
+
getSegmentSourceCandidates,
|
|
6
|
+
type HistoryMediaProcessingOptions,
|
|
7
|
+
type MediaMessageSegment,
|
|
8
|
+
} from "./history-media";
|
|
9
|
+
|
|
10
|
+
export { getSegmentSourceCandidates };
|
|
11
|
+
|
|
12
|
+
export function buildHistoryMediaOptions(
|
|
13
|
+
ai: AIInstance,
|
|
14
|
+
config: ChatConfig,
|
|
15
|
+
): HistoryMediaOptions {
|
|
16
|
+
return {
|
|
17
|
+
ai,
|
|
18
|
+
workingModel: config.workingModel || config.model,
|
|
19
|
+
multimodalWorkingModel: config.multimodalWorkingModel || config.model,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildHistoryMediaProcessingOptions(
|
|
24
|
+
ai: AIInstance,
|
|
25
|
+
config: ChatConfig,
|
|
26
|
+
db: {
|
|
27
|
+
getMediaSummary(key: string): MediaSummaryRecord | null;
|
|
28
|
+
saveMediaSummary(summary: MediaSummaryRecord): void;
|
|
29
|
+
},
|
|
30
|
+
bot: {
|
|
31
|
+
api<T = unknown>(action: string, params?: Record<string, unknown>): Promise<T>;
|
|
32
|
+
},
|
|
33
|
+
groupId: number,
|
|
34
|
+
log: HistoryMediaProcessingOptions["logger"],
|
|
35
|
+
runAIRequest?: <T>(request: () => Promise<T>) => Promise<T | null>,
|
|
36
|
+
): HistoryMediaProcessingOptions {
|
|
37
|
+
return {
|
|
38
|
+
...buildHistoryMediaOptions(ai, config),
|
|
39
|
+
db,
|
|
40
|
+
logger: log,
|
|
41
|
+
bot,
|
|
42
|
+
groupId,
|
|
43
|
+
runAIRequest,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getSegmentUrl(seg: {
|
|
48
|
+
url?: string;
|
|
49
|
+
data?: { url?: string };
|
|
50
|
+
}): string | null {
|
|
51
|
+
const url = seg?.url || seg?.data?.url;
|
|
52
|
+
return typeof url === "string" && url.trim() ? url.trim() : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function getVideoSourceCandidatesFromMessage(
|
|
56
|
+
bot: {
|
|
57
|
+
api(action: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
58
|
+
},
|
|
59
|
+
messageId: number | string | undefined,
|
|
60
|
+
): Promise<string[]> {
|
|
61
|
+
if (messageId == null) return [];
|
|
62
|
+
const result = (await bot.api("get_msg", { message_id: messageId })) as {
|
|
63
|
+
message?: unknown[];
|
|
64
|
+
data?: { message?: unknown[] };
|
|
65
|
+
};
|
|
66
|
+
const segments = result?.message || result?.data?.message || [];
|
|
67
|
+
if (!Array.isArray(segments)) return [];
|
|
68
|
+
const candidates: string[] = [];
|
|
69
|
+
for (const seg of segments as MediaMessageSegment[]) {
|
|
70
|
+
if (seg?.type !== "video") continue;
|
|
71
|
+
candidates.push(...getSegmentSourceCandidates(seg));
|
|
72
|
+
}
|
|
73
|
+
return candidates;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getForwardId(seg: {
|
|
77
|
+
id?: unknown;
|
|
78
|
+
data?: { id?: unknown };
|
|
79
|
+
}): string | null {
|
|
80
|
+
return String(seg?.id || seg?.data?.id || "");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getCardData(seg: { data?: unknown }): string | null {
|
|
84
|
+
const data = seg?.data;
|
|
85
|
+
if (!data) return null;
|
|
86
|
+
return typeof data === "string" ? data : JSON.stringify(data);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isMediaAnalysisBlocked(config: ChatConfig, userId: number): boolean {
|
|
90
|
+
return Boolean(config.mediaAnalysisBlacklistUsers?.includes(userId));
|
|
91
|
+
}
|