koishi-plugin-best-cave 2.7.10 → 2.7.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/lib/AIManager.d.ts +40 -27
- package/lib/HashManager.d.ts +1 -7
- package/lib/index.js +179 -185
- package/package.json +1 -1
package/lib/AIManager.d.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { Context, Logger } from 'koishi';
|
|
|
2
2
|
import { Config, CaveObject, StoredElement } from './index';
|
|
3
3
|
import { FileManager } from './FileManager';
|
|
4
4
|
/**
|
|
5
|
-
* @interface CaveMetaObject
|
|
6
5
|
* @description 定义了数据库 `cave_meta` 表的结构模型。
|
|
7
6
|
* @property {number} cave - 关联的回声洞 `id`,作为外键和主键。
|
|
8
7
|
* @property {string[]} keywords - AI 从回声洞内容中提取的核心关键词数组。
|
|
@@ -22,7 +21,7 @@ declare module 'koishi' {
|
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
23
|
* @class AIManager
|
|
25
|
-
* @description AI
|
|
24
|
+
* @description AI 管理器,连接 AI 服务与回声洞功能的核心模块。
|
|
26
25
|
*/
|
|
27
26
|
export declare class AIManager {
|
|
28
27
|
private ctx;
|
|
@@ -34,25 +33,24 @@ export declare class AIManager {
|
|
|
34
33
|
private rateLimitResetTime;
|
|
35
34
|
/**
|
|
36
35
|
* @constructor
|
|
37
|
-
* @
|
|
36
|
+
* @param {Context} ctx - Koishi 的上下文对象,提供框架核心功能。
|
|
37
|
+
* @param {Config} config - 插件的配置对象。
|
|
38
|
+
* @param {Logger} logger - 日志记录器实例,用于输出日志。
|
|
39
|
+
* @param {FileManager} fileManager - 文件管理器实例,用于处理媒体文件。
|
|
38
40
|
*/
|
|
39
41
|
constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
|
|
40
42
|
/**
|
|
41
43
|
* @description 注册所有与 AIManager 功能相关的 Koishi 命令。
|
|
42
|
-
* @param {any} cave -
|
|
44
|
+
* @param {any} cave - Koishi 命令实例,用于挂载子命令。
|
|
43
45
|
*/
|
|
44
46
|
registerCommands(cave: any): void;
|
|
45
47
|
/**
|
|
46
48
|
* @description 对新提交的内容执行 AI 驱动的查重检查。
|
|
47
|
-
* @param {StoredElement[]} newElements -
|
|
48
|
-
* @param {{
|
|
49
|
-
* @
|
|
50
|
-
* @returns {Promise<{ duplicate: boolean; id?: number }>} 一个包含查重结果的对象。
|
|
49
|
+
* @param {StoredElement[]} newElements - 新提交的内容元素数组。
|
|
50
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓冲区数组。
|
|
51
|
+
* @returns {Promise<{ duplicate: boolean; id?: number }>} 一个 Promise,解析为一个对象,指示内容是否重复以及重复的回声洞 ID(如果存在)。
|
|
51
52
|
*/
|
|
52
|
-
checkForDuplicates(newElements: StoredElement[],
|
|
53
|
-
sourceUrl: string;
|
|
54
|
-
fileName: string;
|
|
55
|
-
}[], mediaBuffers?: {
|
|
53
|
+
checkForDuplicates(newElements: StoredElement[], mediaBuffers?: {
|
|
56
54
|
fileName: string;
|
|
57
55
|
buffer: Buffer;
|
|
58
56
|
}[]): Promise<{
|
|
@@ -61,30 +59,45 @@ export declare class AIManager {
|
|
|
61
59
|
}>;
|
|
62
60
|
/**
|
|
63
61
|
* @description 对单个回声洞对象执行完整的分析和存储流程。
|
|
64
|
-
* @param {CaveObject} cave -
|
|
65
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] -
|
|
66
|
-
* @returns {Promise<void>}
|
|
67
|
-
* @throws {Error} 如果在分析或数据库存储过程中发生错误,则会向上抛出异常。
|
|
62
|
+
* @param {CaveObject} cave - 要分析的回声洞对象。
|
|
63
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓冲区数组,用于新提交内容的分析。
|
|
64
|
+
* @returns {Promise<void>} 分析和存储操作完成后解析的 Promise。
|
|
68
65
|
*/
|
|
69
66
|
analyzeAndStore(cave: CaveObject, mediaBuffers?: {
|
|
70
67
|
fileName: string;
|
|
71
68
|
buffer: Buffer;
|
|
72
69
|
}[]): Promise<void>;
|
|
73
70
|
/**
|
|
74
|
-
* @description
|
|
75
|
-
* @param {
|
|
76
|
-
* @
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
* @description 对一批回声洞执行分析并存储结果。
|
|
72
|
+
* @param {CaveObject[]} caves - 要分析的回声洞对象数组。
|
|
73
|
+
* @returns {Promise<number>} 一个 Promise,解析为成功分析和存储的条目数。
|
|
74
|
+
*/
|
|
75
|
+
private analyzeAndStoreBatch;
|
|
76
|
+
/**
|
|
77
|
+
* @description 根据新内容的关键词,查找并返回可能重复的回声洞。
|
|
78
|
+
* @param {string[]} newKeywords - 新内容的关键词数组。
|
|
79
|
+
* @returns {Promise<CaveObject[]>} 一个 Promise,解析为可能重复的回声洞对象数组。
|
|
80
|
+
*/
|
|
81
|
+
private findPotentialDuplicates;
|
|
82
|
+
/**
|
|
83
|
+
* @description 为一批回声洞准备内容,并向 AI 发送单个请求以获取所有分析结果。
|
|
84
|
+
* @param {CaveObject[]} caves - 要分析的回声洞对象数组。
|
|
85
|
+
* @param {Map<string, Buffer>} [mediaBufferMap] - 可选的媒体文件名到其缓冲区的映射。
|
|
86
|
+
* @returns {Promise<any[]>} 一个 Promise,解析为 AI 返回的分析结果数组。
|
|
87
|
+
*/
|
|
88
|
+
private getAnalyses;
|
|
89
|
+
/**
|
|
90
|
+
* @description 确保请求不会超过设定的速率限制(RPM)。如果需要,会延迟执行。
|
|
91
|
+
* @returns {Promise<void>} 当可以继续发送请求时解析的 Promise。
|
|
79
92
|
*/
|
|
80
|
-
private
|
|
93
|
+
private ensureRateLimit;
|
|
81
94
|
/**
|
|
82
95
|
* @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
|
|
83
|
-
* @param {any[]} messages -
|
|
84
|
-
* @param {string} systemPrompt -
|
|
85
|
-
* @param {string} schemaString -
|
|
86
|
-
* @returns {Promise<any>} AI
|
|
87
|
-
* @throws {Error} 当
|
|
96
|
+
* @param {any[]} messages - 发送给 AI 的消息数组,遵循 OpenAI 格式。
|
|
97
|
+
* @param {string} systemPrompt - 系统提示词,用于指导 AI 的行为。
|
|
98
|
+
* @param {string} schemaString - 定义期望响应格式的 JSON Schema 字符串。
|
|
99
|
+
* @returns {Promise<any>} 一个 Promise,解析为从 AI 接收到的、解析后的 JSON 对象。
|
|
100
|
+
* @throws {Error} 当 AI 返回空或无效内容时抛出错误。
|
|
88
101
|
*/
|
|
89
102
|
private requestAI;
|
|
90
103
|
}
|
package/lib/HashManager.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Context, Logger } from 'koishi';
|
|
2
|
-
import { Config
|
|
2
|
+
import { Config } from './index';
|
|
3
3
|
import { FileManager } from './FileManager';
|
|
4
4
|
/**
|
|
5
5
|
* @description 数据库 `cave_hash` 表的完整对象模型。
|
|
@@ -32,12 +32,6 @@ export declare class HashManager {
|
|
|
32
32
|
* @param cave - 主 `cave` 命令实例。
|
|
33
33
|
*/
|
|
34
34
|
registerCommands(cave: any): void;
|
|
35
|
-
/**
|
|
36
|
-
* @description 为单个回声洞对象生成所有类型的哈希(文本+图片)。
|
|
37
|
-
* @param cave - 回声洞对象。
|
|
38
|
-
* @returns 生成的哈希对象数组。
|
|
39
|
-
*/
|
|
40
|
-
generateAllHashesForCave(cave: Pick<CaveObject, 'id' | 'elements'>): Promise<CaveHashObject[]>;
|
|
41
35
|
/**
|
|
42
36
|
* @description 执行一维离散余弦变换 (DCT-II) 的方法。
|
|
43
37
|
* @param input - 输入的数字数组。
|
package/lib/index.js
CHANGED
|
@@ -741,10 +741,27 @@ var HashManager = class {
|
|
|
741
741
|
for (const cave2 of cavesToProcess) {
|
|
742
742
|
processedCaveCount++;
|
|
743
743
|
try {
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
744
|
+
const tempHashes = [];
|
|
745
|
+
const uniqueHashTracker = /* @__PURE__ */ new Set();
|
|
746
|
+
const addUniqueHash = /* @__PURE__ */ __name((hashObj) => {
|
|
747
|
+
const key = `${hashObj.hash}-${hashObj.type}`;
|
|
748
|
+
if (!uniqueHashTracker.has(key)) {
|
|
749
|
+
tempHashes.push(hashObj);
|
|
750
|
+
uniqueHashTracker.add(key);
|
|
751
|
+
}
|
|
752
|
+
}, "addUniqueHash");
|
|
753
|
+
const combinedText = cave2.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
|
|
754
|
+
if (combinedText) {
|
|
755
|
+
const textHash = this.generateTextSimhash(combinedText);
|
|
756
|
+
if (textHash) addUniqueHash({ cave: cave2.id, hash: textHash, type: "text" });
|
|
757
|
+
}
|
|
758
|
+
for (const el of cave2.elements.filter((el2) => el2.type === "image" && el2.file)) {
|
|
759
|
+
const imageBuffer = await this.fileManager.readFile(el.file);
|
|
760
|
+
const imageHash = await this.generatePHash(imageBuffer);
|
|
761
|
+
addUniqueHash({ cave: cave2.id, hash: imageHash, type: "image" });
|
|
747
762
|
}
|
|
763
|
+
const newHashesForCave = tempHashes;
|
|
764
|
+
if (newHashesForCave.length > 0) hashesToInsert.push(...newHashesForCave);
|
|
748
765
|
if (hashesToInsert.length >= 100) await flushBatch();
|
|
749
766
|
} catch (error) {
|
|
750
767
|
errorCount++;
|
|
@@ -878,38 +895,6 @@ var HashManager = class {
|
|
|
878
895
|
}
|
|
879
896
|
});
|
|
880
897
|
}
|
|
881
|
-
/**
|
|
882
|
-
* @description 为单个回声洞对象生成所有类型的哈希(文本+图片)。
|
|
883
|
-
* @param cave - 回声洞对象。
|
|
884
|
-
* @returns 生成的哈希对象数组。
|
|
885
|
-
*/
|
|
886
|
-
async generateAllHashesForCave(cave) {
|
|
887
|
-
const tempHashes = [];
|
|
888
|
-
const uniqueHashTracker = /* @__PURE__ */ new Set();
|
|
889
|
-
const addUniqueHash = /* @__PURE__ */ __name((hashObj) => {
|
|
890
|
-
const key = `${hashObj.hash}-${hashObj.type}`;
|
|
891
|
-
if (!uniqueHashTracker.has(key)) {
|
|
892
|
-
tempHashes.push(hashObj);
|
|
893
|
-
uniqueHashTracker.add(key);
|
|
894
|
-
}
|
|
895
|
-
}, "addUniqueHash");
|
|
896
|
-
const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
|
|
897
|
-
if (combinedText) {
|
|
898
|
-
const textHash = this.generateTextSimhash(combinedText);
|
|
899
|
-
if (textHash) addUniqueHash({ cave: cave.id, hash: textHash, type: "text" });
|
|
900
|
-
}
|
|
901
|
-
for (const el of cave.elements.filter((el2) => el2.type === "image" && el2.file)) {
|
|
902
|
-
try {
|
|
903
|
-
const imageBuffer = await this.fileManager.readFile(el.file);
|
|
904
|
-
const imageHash = await this.generatePHash(imageBuffer);
|
|
905
|
-
addUniqueHash({ cave: cave.id, hash: imageHash, type: "image" });
|
|
906
|
-
} catch (error) {
|
|
907
|
-
this.logger.warn(`无法为回声洞(${cave.id})的图片(${el.file})生成哈希:`, error);
|
|
908
|
-
throw error;
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
return tempHashes;
|
|
912
|
-
}
|
|
913
898
|
/**
|
|
914
899
|
* @description 执行一维离散余弦变换 (DCT-II) 的方法。
|
|
915
900
|
* @param input - 输入的数字数组。
|
|
@@ -1016,7 +1001,10 @@ var path3 = __toESM(require("path"));
|
|
|
1016
1001
|
var AIManager = class {
|
|
1017
1002
|
/**
|
|
1018
1003
|
* @constructor
|
|
1019
|
-
* @
|
|
1004
|
+
* @param {Context} ctx - Koishi 的上下文对象,提供框架核心功能。
|
|
1005
|
+
* @param {Config} config - 插件的配置对象。
|
|
1006
|
+
* @param {Logger} logger - 日志记录器实例,用于输出日志。
|
|
1007
|
+
* @param {FileManager} fileManager - 文件管理器实例,用于处理媒体文件。
|
|
1020
1008
|
*/
|
|
1021
1009
|
constructor(ctx, config, logger2, fileManager) {
|
|
1022
1010
|
this.ctx = ctx;
|
|
@@ -1041,28 +1029,26 @@ var AIManager = class {
|
|
|
1041
1029
|
rateLimitResetTime = 0;
|
|
1042
1030
|
/**
|
|
1043
1031
|
* @description 注册所有与 AIManager 功能相关的 Koishi 命令。
|
|
1044
|
-
* @param {any} cave -
|
|
1032
|
+
* @param {any} cave - Koishi 命令实例,用于挂载子命令。
|
|
1045
1033
|
*/
|
|
1046
1034
|
registerCommands(cave) {
|
|
1047
1035
|
cave.subcommand(".ai", "分析回声洞", { hidden: true, authority: 4 }).usage("分析尚未分析的回声洞,补全回声洞记录。").action(async ({ session }) => {
|
|
1048
1036
|
if (requireAdmin(session, this.config)) return requireAdmin(session, this.config);
|
|
1049
1037
|
try {
|
|
1050
1038
|
const allCaves = await this.ctx.database.get("cave", { status: "active" });
|
|
1051
|
-
const analyzedCaveIds = new Set((await this.ctx.database.get("cave_meta", {})).map((meta) => meta.cave));
|
|
1039
|
+
const analyzedCaveIds = new Set((await this.ctx.database.get("cave_meta", {}, { fields: ["cave"] })).map((meta) => meta.cave));
|
|
1052
1040
|
const cavesToAnalyze = allCaves.filter((cave2) => !analyzedCaveIds.has(cave2.id));
|
|
1053
1041
|
if (cavesToAnalyze.length === 0) return "无需分析回声洞";
|
|
1054
1042
|
await session.send(`开始分析 ${cavesToAnalyze.length} 个回声洞...`);
|
|
1055
|
-
let
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
return `分析回声洞(${cave2.id})时出错: ${error.message}`;
|
|
1063
|
-
}
|
|
1043
|
+
let totalSuccessCount = 0;
|
|
1044
|
+
const batchSize = 10;
|
|
1045
|
+
for (let i = 0; i < cavesToAnalyze.length; i += batchSize) {
|
|
1046
|
+
const batch = cavesToAnalyze.slice(i, i + batchSize);
|
|
1047
|
+
this.logger.info(`[${i + 1}/${cavesToAnalyze.length}] 正在分析 ${batch.length} 条回声洞...`);
|
|
1048
|
+
const successCountInBatch = await this.analyzeAndStoreBatch(batch);
|
|
1049
|
+
totalSuccessCount += successCountInBatch;
|
|
1064
1050
|
}
|
|
1065
|
-
return `已分析 ${
|
|
1051
|
+
return `已分析 ${totalSuccessCount} 个回声洞`;
|
|
1066
1052
|
} catch (error) {
|
|
1067
1053
|
this.logger.error("分析回声洞失败:", error);
|
|
1068
1054
|
return `操作失败: ${error.message}`;
|
|
@@ -1071,41 +1057,25 @@ var AIManager = class {
|
|
|
1071
1057
|
}
|
|
1072
1058
|
/**
|
|
1073
1059
|
* @description 对新提交的内容执行 AI 驱动的查重检查。
|
|
1074
|
-
* @param {StoredElement[]} newElements -
|
|
1075
|
-
* @param {{
|
|
1076
|
-
* @
|
|
1077
|
-
* @returns {Promise<{ duplicate: boolean; id?: number }>} 一个包含查重结果的对象。
|
|
1060
|
+
* @param {StoredElement[]} newElements - 新提交的内容元素数组。
|
|
1061
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓冲区数组。
|
|
1062
|
+
* @returns {Promise<{ duplicate: boolean; id?: number }>} 一个 Promise,解析为一个对象,指示内容是否重复以及重复的回声洞 ID(如果存在)。
|
|
1078
1063
|
*/
|
|
1079
|
-
async checkForDuplicates(newElements,
|
|
1064
|
+
async checkForDuplicates(newElements, mediaBuffers) {
|
|
1080
1065
|
try {
|
|
1081
|
-
const
|
|
1082
|
-
|
|
1083
|
-
const
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
const setB = new Set(meta.keywords);
|
|
1087
|
-
let similarity = 0;
|
|
1088
|
-
if (setA.size > 0 && setB.size > 0) {
|
|
1089
|
-
const intersection = new Set([...setA].filter((x) => setB.has(x)));
|
|
1090
|
-
const union = /* @__PURE__ */ new Set([...setA, ...setB]);
|
|
1091
|
-
similarity = intersection.size / union.size;
|
|
1092
|
-
}
|
|
1093
|
-
if (similarity * 100 >= 80) {
|
|
1094
|
-
const [cave] = await this.ctx.database.get("cave", { id: meta.cave });
|
|
1095
|
-
return cave;
|
|
1096
|
-
}
|
|
1097
|
-
}))).filter(Boolean);
|
|
1066
|
+
const dummyCave = { id: 0, elements: newElements, channelId: "", userId: "", userName: "", status: "preload", time: /* @__PURE__ */ new Date() };
|
|
1067
|
+
const mediaMap = mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0;
|
|
1068
|
+
const [newAnalysis] = await this.getAnalyses([dummyCave], mediaMap);
|
|
1069
|
+
if (!newAnalysis?.keywords?.length) return { duplicate: false };
|
|
1070
|
+
const potentialDuplicates = await this.findPotentialDuplicates(newAnalysis.keywords);
|
|
1098
1071
|
if (potentialDuplicates.length === 0) return { duplicate: false };
|
|
1099
1072
|
const formatContent = /* @__PURE__ */ __name((elements) => elements.filter((el) => el.type === "text").map((el) => el.content).join(" "), "formatContent");
|
|
1100
1073
|
const userMessage = {
|
|
1101
1074
|
role: "user",
|
|
1102
|
-
content:
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
existing_contents: potentialDuplicates.map((cave) => ({ id: cave.id, text: formatContent(cave.elements) }))
|
|
1107
|
-
})
|
|
1108
|
-
}]
|
|
1075
|
+
content: JSON.stringify({
|
|
1076
|
+
new_content: { text: formatContent(newElements) },
|
|
1077
|
+
existing_contents: potentialDuplicates.map((cave) => ({ id: cave.id, text: formatContent(cave.elements) }))
|
|
1078
|
+
})
|
|
1109
1079
|
};
|
|
1110
1080
|
const response = await this.requestAI([userMessage], this.config.aiCheckPrompt, this.config.aiCheckSchema);
|
|
1111
1081
|
return {
|
|
@@ -1119,74 +1089,92 @@ var AIManager = class {
|
|
|
1119
1089
|
}
|
|
1120
1090
|
/**
|
|
1121
1091
|
* @description 对单个回声洞对象执行完整的分析和存储流程。
|
|
1122
|
-
* @param {CaveObject} cave -
|
|
1123
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] -
|
|
1124
|
-
* @returns {Promise<void>}
|
|
1125
|
-
* @throws {Error} 如果在分析或数据库存储过程中发生错误,则会向上抛出异常。
|
|
1092
|
+
* @param {CaveObject} cave - 要分析的回声洞对象。
|
|
1093
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓冲区数组,用于新提交内容的分析。
|
|
1094
|
+
* @returns {Promise<void>} 分析和存储操作完成后解析的 Promise。
|
|
1126
1095
|
*/
|
|
1127
1096
|
async analyzeAndStore(cave, mediaBuffers) {
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
}
|
|
1137
|
-
} catch (error) {
|
|
1138
|
-
this.logger.error(`分析回声洞(${cave.id})失败:`, error);
|
|
1139
|
-
throw error;
|
|
1097
|
+
const mediaMap = mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0;
|
|
1098
|
+
const [result] = await this.getAnalyses([cave], mediaMap);
|
|
1099
|
+
if (result) {
|
|
1100
|
+
await this.ctx.database.upsert("cave_meta", [{
|
|
1101
|
+
cave: cave.id,
|
|
1102
|
+
keywords: result.keywords || [],
|
|
1103
|
+
description: result.description || "",
|
|
1104
|
+
rating: Math.max(0, Math.min(100, result.rating || 0))
|
|
1105
|
+
}]);
|
|
1140
1106
|
}
|
|
1141
1107
|
}
|
|
1142
1108
|
/**
|
|
1143
|
-
* @description
|
|
1144
|
-
* @param {
|
|
1145
|
-
* @
|
|
1146
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)已存在于内存中的媒体文件 Buffer。
|
|
1147
|
-
* @returns {Promise<Omit<CaveMetaObject, 'cave'>>} 返回一个不含 `cave` 字段的分析结果对象。如果内容为空或无法处理,则返回 `null`。
|
|
1109
|
+
* @description 对一批回声洞执行分析并存储结果。
|
|
1110
|
+
* @param {CaveObject[]} caves - 要分析的回声洞对象数组。
|
|
1111
|
+
* @returns {Promise<number>} 一个 Promise,解析为成功分析和存储的条目数。
|
|
1148
1112
|
*/
|
|
1149
|
-
async
|
|
1150
|
-
const
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
} else if (mediaToSave) {
|
|
1161
|
-
const item = mediaToSave.find((m) => m.fileName === el.file);
|
|
1162
|
-
if (item) buffer = Buffer.from(await this.ctx.http.get(item.sourceUrl, { responseType: "arraybuffer" }));
|
|
1163
|
-
} else {
|
|
1164
|
-
buffer = await this.fileManager.readFile(el.file);
|
|
1165
|
-
}
|
|
1166
|
-
if (buffer) {
|
|
1167
|
-
const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
|
|
1168
|
-
userContent.push({
|
|
1169
|
-
type: "input_image",
|
|
1170
|
-
image_url: `data:${mimeType};base64,${buffer.toString("base64")}`
|
|
1171
|
-
});
|
|
1172
|
-
}
|
|
1173
|
-
} catch (error) {
|
|
1174
|
-
this.logger.warn(`分析内容(${el.file})失败:`, error);
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
if (userContent.length === 0) return null;
|
|
1178
|
-
const userMessage = { role: "user", content: userContent };
|
|
1179
|
-
return await this.requestAI([userMessage], this.config.AnalysePrompt, this.config.aiAnalyseSchema);
|
|
1113
|
+
async analyzeAndStoreBatch(caves) {
|
|
1114
|
+
const results = await this.getAnalyses(caves);
|
|
1115
|
+
if (!results?.length) return 0;
|
|
1116
|
+
const caveMetaObjects = results.map((res) => ({
|
|
1117
|
+
cave: res.id,
|
|
1118
|
+
keywords: res.keywords || [],
|
|
1119
|
+
description: res.description || "",
|
|
1120
|
+
rating: Math.max(0, Math.min(100, res.rating || 0))
|
|
1121
|
+
}));
|
|
1122
|
+
await this.ctx.database.upsert("cave_meta", caveMetaObjects);
|
|
1123
|
+
return caveMetaObjects.length;
|
|
1180
1124
|
}
|
|
1181
1125
|
/**
|
|
1182
|
-
* @description
|
|
1183
|
-
* @param {
|
|
1184
|
-
* @
|
|
1185
|
-
* @param {string} schemaString - 一个 JSON 字符串,定义了期望 AI 返回的 JSON 对象的结构。
|
|
1186
|
-
* @returns {Promise<any>} AI 返回的、经过 JSON 解析的响应体。
|
|
1187
|
-
* @throws {Error} 当 JSON Schema 解析失败、网络请求失败或 AI 返回错误时,抛出异常。
|
|
1126
|
+
* @description 根据新内容的关键词,查找并返回可能重复的回声洞。
|
|
1127
|
+
* @param {string[]} newKeywords - 新内容的关键词数组。
|
|
1128
|
+
* @returns {Promise<CaveObject[]>} 一个 Promise,解析为可能重复的回声洞对象数组。
|
|
1188
1129
|
*/
|
|
1189
|
-
async
|
|
1130
|
+
async findPotentialDuplicates(newKeywords) {
|
|
1131
|
+
const allMeta = await this.ctx.database.get("cave_meta", {}, { fields: ["cave", "keywords"] });
|
|
1132
|
+
const newKeywordsSet = new Set(newKeywords);
|
|
1133
|
+
const similarCaveIds = allMeta.filter((meta) => {
|
|
1134
|
+
if (!meta.keywords?.length) return false;
|
|
1135
|
+
const existingKeywordsSet = new Set(meta.keywords);
|
|
1136
|
+
const intersection = new Set([...newKeywordsSet].filter((x) => existingKeywordsSet.has(x)));
|
|
1137
|
+
const union = /* @__PURE__ */ new Set([...newKeywordsSet, ...existingKeywordsSet]);
|
|
1138
|
+
const similarity = union.size > 0 ? intersection.size / union.size : 0;
|
|
1139
|
+
return similarity * 100 >= 80;
|
|
1140
|
+
}).map((meta) => meta.cave);
|
|
1141
|
+
if (similarCaveIds.length === 0) return [];
|
|
1142
|
+
return this.ctx.database.get("cave", { id: { $in: similarCaveIds } });
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* @description 为一批回声洞准备内容,并向 AI 发送单个请求以获取所有分析结果。
|
|
1146
|
+
* @param {CaveObject[]} caves - 要分析的回声洞对象数组。
|
|
1147
|
+
* @param {Map<string, Buffer>} [mediaBufferMap] - 可选的媒体文件名到其缓冲区的映射。
|
|
1148
|
+
* @returns {Promise<any[]>} 一个 Promise,解析为 AI 返回的分析结果数组。
|
|
1149
|
+
*/
|
|
1150
|
+
async getAnalyses(caves, mediaBufferMap) {
|
|
1151
|
+
const batchPayload = await Promise.all(caves.map(async (cave) => {
|
|
1152
|
+
const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
|
|
1153
|
+
const imagesBase64 = (await Promise.all(
|
|
1154
|
+
cave.elements.filter((el) => el.type === "image" && el.file).map(async (el) => {
|
|
1155
|
+
try {
|
|
1156
|
+
const buffer = mediaBufferMap?.get(el.file) ?? await this.fileManager.readFile(el.file);
|
|
1157
|
+
const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
|
|
1158
|
+
return `data:${mimeType};base64,${buffer.toString("base64")}`;
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
this.logger.warn(`读取文件(${el.file})失败:`, error);
|
|
1161
|
+
return null;
|
|
1162
|
+
}
|
|
1163
|
+
})
|
|
1164
|
+
)).filter(Boolean);
|
|
1165
|
+
return { id: cave.id, text: combinedText, images: imagesBase64 };
|
|
1166
|
+
}));
|
|
1167
|
+
const nonEmptyPayload = batchPayload.filter((p) => p.text.trim() || p.images.length > 0);
|
|
1168
|
+
if (nonEmptyPayload.length === 0) return [];
|
|
1169
|
+
const userMessage = { role: "user", content: JSON.stringify(nonEmptyPayload) };
|
|
1170
|
+
const response = await this.requestAI([userMessage], this.config.AnalysePrompt, this.config.aiAnalyseSchema);
|
|
1171
|
+
return response.analyses || [];
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* @description 确保请求不会超过设定的速率限制(RPM)。如果需要,会延迟执行。
|
|
1175
|
+
* @returns {Promise<void>} 当可以继续发送请求时解析的 Promise。
|
|
1176
|
+
*/
|
|
1177
|
+
async ensureRateLimit() {
|
|
1190
1178
|
const now = Date.now();
|
|
1191
1179
|
if (now > this.rateLimitResetTime) {
|
|
1192
1180
|
this.rateLimitResetTime = now + 6e4;
|
|
@@ -1194,51 +1182,43 @@ var AIManager = class {
|
|
|
1194
1182
|
}
|
|
1195
1183
|
if (this.requestCount >= this.config.aiRPM) {
|
|
1196
1184
|
const delay = this.rateLimitResetTime - now;
|
|
1197
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1185
|
+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1198
1186
|
this.rateLimitResetTime = Date.now() + 6e4;
|
|
1199
1187
|
this.requestCount = 0;
|
|
1200
1188
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
|
|
1192
|
+
* @param {any[]} messages - 发送给 AI 的消息数组,遵循 OpenAI 格式。
|
|
1193
|
+
* @param {string} systemPrompt - 系统提示词,用于指导 AI 的行为。
|
|
1194
|
+
* @param {string} schemaString - 定义期望响应格式的 JSON Schema 字符串。
|
|
1195
|
+
* @returns {Promise<any>} 一个 Promise,解析为从 AI 接收到的、解析后的 JSON 对象。
|
|
1196
|
+
* @throws {Error} 当 AI 返回空或无效内容时抛出错误。
|
|
1197
|
+
*/
|
|
1198
|
+
async requestAI(messages, systemPrompt, schemaString) {
|
|
1199
|
+
await this.ensureRateLimit();
|
|
1208
1200
|
const payload = {
|
|
1209
1201
|
model: this.config.aiModel,
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
name: "extracted_data",
|
|
1218
|
-
schema
|
|
1202
|
+
messages: [{ role: "system", content: systemPrompt }, ...messages],
|
|
1203
|
+
response_format: {
|
|
1204
|
+
type: "json_schema",
|
|
1205
|
+
json_schema: {
|
|
1206
|
+
name: "extract_data",
|
|
1207
|
+
description: "根据提供的内容提取或分析信息。",
|
|
1208
|
+
schema: JSON.parse(schemaString)
|
|
1219
1209
|
}
|
|
1220
1210
|
}
|
|
1221
1211
|
};
|
|
1222
|
-
const fullUrl = `${this.config.aiEndpoint.replace(/\/$/, "")}/
|
|
1212
|
+
const fullUrl = `${this.config.aiEndpoint.replace(/\/$/, "")}/chat/completions`;
|
|
1223
1213
|
const headers = {
|
|
1224
1214
|
"Content-Type": "application/json",
|
|
1225
1215
|
"Authorization": `Bearer ${this.config.aiApiKey}`
|
|
1226
1216
|
};
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
return JSON.parse(responseText);
|
|
1233
|
-
} else {
|
|
1234
|
-
this.logger.error("AI 响应格式不正确:", JSON.stringify(response));
|
|
1235
|
-
throw new Error("AI 响应格式不正确");
|
|
1236
|
-
}
|
|
1237
|
-
} catch (error) {
|
|
1238
|
-
const errorMessage = error.response ? JSON.stringify(error.response.data) : error.message;
|
|
1239
|
-
this.logger.error(`请求 API 失败: ${errorMessage}`);
|
|
1240
|
-
throw error;
|
|
1241
|
-
}
|
|
1217
|
+
this.requestCount++;
|
|
1218
|
+
const response = await this.http.post(fullUrl, payload, { headers, timeout: 9e4 });
|
|
1219
|
+
const content = response.choices?.[0]?.message?.content;
|
|
1220
|
+
if (typeof content === "string" && content.trim()) return JSON.parse(content);
|
|
1221
|
+
throw new Error("响应无效");
|
|
1242
1222
|
}
|
|
1243
1223
|
};
|
|
1244
1224
|
|
|
@@ -1278,28 +1258,42 @@ var Config = import_koishi3.Schema.intersect([
|
|
|
1278
1258
|
aiApiKey: import_koishi3.Schema.string().description("密钥 (Key)").role("secret"),
|
|
1279
1259
|
aiModel: import_koishi3.Schema.string().description("模型 (Model)").default("gemini-2.5-flash"),
|
|
1280
1260
|
aiRPM: import_koishi3.Schema.number().description("每分钟请求数 (RPM)").default(60),
|
|
1281
|
-
AnalysePrompt: import_koishi3.Schema.string().role("textarea").default(
|
|
1261
|
+
AnalysePrompt: import_koishi3.Schema.string().role("textarea").default(`你是一位内容分析专家。请分析我以JSON格式提供的一组内容(每项包含ID、文本和图片),为每一项内容总结关键词、概括内容并评分。你需要返回一个包含所有分析结果的JSON对象。`).description("分析 Prompt"),
|
|
1282
1262
|
aiAnalyseSchema: import_koishi3.Schema.string().role("textarea").default(
|
|
1283
1263
|
`{
|
|
1284
1264
|
"type": "object",
|
|
1285
1265
|
"properties": {
|
|
1286
|
-
"
|
|
1266
|
+
"analyses": {
|
|
1287
1267
|
"type": "array",
|
|
1288
|
-
"
|
|
1289
|
-
"
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1268
|
+
"description": "分析结果的数组",
|
|
1269
|
+
"items": {
|
|
1270
|
+
"type": "object",
|
|
1271
|
+
"properties": {
|
|
1272
|
+
"id": {
|
|
1273
|
+
"type": "integer",
|
|
1274
|
+
"description": "内容的唯一ID"
|
|
1275
|
+
},
|
|
1276
|
+
"keywords": {
|
|
1277
|
+
"type": "array",
|
|
1278
|
+
"items": { "type": "string" },
|
|
1279
|
+
"description": "使用尽可能多的关键词准确形容内容"
|
|
1280
|
+
},
|
|
1281
|
+
"description": {
|
|
1282
|
+
"type": "string",
|
|
1283
|
+
"description": "概括或描述这部分内容"
|
|
1284
|
+
},
|
|
1285
|
+
"rating": {
|
|
1286
|
+
"type": "integer",
|
|
1287
|
+
"description": "对内容的综合质量进行评分",
|
|
1288
|
+
"minimum": 0,
|
|
1289
|
+
"maximum": 100
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1292
|
+
"required": ["id", "keywords", "description", "rating"]
|
|
1293
|
+
}
|
|
1300
1294
|
}
|
|
1301
1295
|
},
|
|
1302
|
-
"required": ["
|
|
1296
|
+
"required": ["analyses"]
|
|
1303
1297
|
}`
|
|
1304
1298
|
).description("分析 JSON Schema"),
|
|
1305
1299
|
aiCheckPrompt: import_koishi3.Schema.string().role("textarea").default(`你是一位内容查重专家。请判断我提供的"新内容"是否与"已有内容"重复或高度相似。`).description("查重 Prompt"),
|
|
@@ -1415,7 +1409,7 @@ function apply(ctx, config) {
|
|
|
1415
1409
|
imageHashesToStore = checkResult.imageHashesToStore;
|
|
1416
1410
|
}
|
|
1417
1411
|
if (aiManager) {
|
|
1418
|
-
const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb,
|
|
1412
|
+
const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
|
|
1419
1413
|
if (duplicateResult && duplicateResult.duplicate) return `内容与回声洞(${duplicateResult.id})重复`;
|
|
1420
1414
|
}
|
|
1421
1415
|
const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
|