koishi-plugin-best-cave 2.7.9 → 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.
@@ -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 管理器,是连接 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
- * @description AIManager 类的构造函数,负责初始化依赖项,并向 Koishi 的数据库模型中注册 `cave_meta` 表。
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 - `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 {{ sourceUrl: string, fileName: string }[]} newMediaToSave - 伴随新内容提交的、需要从 URL 下载的媒体文件列表。
49
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)已经加载到内存中的媒体文件 Buffer,可用于优化性能。
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[], newMediaToSave: {
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 - 需要被分析的完整回声洞对象,包含 `id` 和 `elements`。
65
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)与该回声洞相关的、已加载到内存的媒体文件 Buffer。
66
- * @returns {Promise<void>} 操作完成后 resolve 的 Promise。
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 准备并发送内容给 AI 模型以获取分析结果。
75
- * @param {StoredElement[]} elements - 内容的结构化元素数组。
76
- * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选)需要从网络下载的媒体文件信息。
77
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)已存在于内存中的媒体文件 Buffer。
78
- * @returns {Promise<Omit<CaveMetaObject, 'cave'>>} 返回一个不含 `cave` 字段的分析结果对象。如果内容为空或无法处理,则返回 `null`。
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 getAnalysis;
93
+ private ensureRateLimit;
81
94
  /**
82
95
  * @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
83
- * @param {any[]} messages - 要发送给 AI 的消息数组,格式遵循 OpenAI API 规范。
84
- * @param {string} systemPrompt - 指导 AI 行为的系统级提示词。
85
- * @param {string} schemaString - 一个 JSON 字符串,定义了期望 AI 返回的 JSON 对象的结构。
86
- * @returns {Promise<any>} AI 返回的、经过 JSON 解析的响应体。
87
- * @throws {Error} 当 JSON Schema 解析失败、网络请求失败或 AI 返回错误时,抛出异常。
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
  }
@@ -1,5 +1,5 @@
1
1
  import { Context, Logger } from 'koishi';
2
- import { Config, CaveObject } from './index';
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
@@ -727,22 +727,50 @@ var HashManager = class {
727
727
  const cavesToProcess = allCaves.filter((cave2) => !hashedCaveIds.has(cave2.id));
728
728
  if (cavesToProcess.length === 0) return "无需补全回声洞哈希";
729
729
  await session.send(`开始补全 ${cavesToProcess.length} 个回声洞的哈希...`);
730
- const hashesToInsert = [];
730
+ let hashesToInsert = [];
731
731
  let processedCaveCount = 0;
732
+ let totalHashesGenerated = 0;
732
733
  let errorCount = 0;
734
+ const flushBatch = /* @__PURE__ */ __name(async () => {
735
+ if (hashesToInsert.length === 0) return;
736
+ await this.ctx.database.upsert("cave_hash", hashesToInsert);
737
+ totalHashesGenerated += hashesToInsert.length;
738
+ this.logger.info(`[${processedCaveCount}/${cavesToProcess.length}] 正在导入 ${hashesToInsert.length} 条回声洞哈希...`);
739
+ hashesToInsert = [];
740
+ }, "flushBatch");
733
741
  for (const cave2 of cavesToProcess) {
734
742
  processedCaveCount++;
735
743
  try {
736
- const newHashesForCave = await this.generateAllHashesForCave(cave2);
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" });
762
+ }
763
+ const newHashesForCave = tempHashes;
737
764
  if (newHashesForCave.length > 0) hashesToInsert.push(...newHashesForCave);
765
+ if (hashesToInsert.length >= 100) await flushBatch();
738
766
  } catch (error) {
739
767
  errorCount++;
740
768
  this.logger.warn(`补全回声洞(${cave2.id})哈希时出错: ${error.message}`);
741
769
  }
742
770
  }
743
- if (hashesToInsert.length > 0) await this.ctx.database.upsert("cave_hash", hashesToInsert);
771
+ await flushBatch();
744
772
  const successCount = processedCaveCount - errorCount;
745
- return `已补全 ${successCount} 个回声洞的 ${hashesToInsert.length} 条哈希(失败 ${errorCount} 条)`;
773
+ return `已补全 ${successCount} 个回声洞的 ${totalHashesGenerated} 条哈希(失败 ${errorCount} 条)`;
746
774
  } catch (error) {
747
775
  this.logger.error("补全哈希失败:", error);
748
776
  return `操作失败: ${error.message}`;
@@ -867,38 +895,6 @@ var HashManager = class {
867
895
  }
868
896
  });
869
897
  }
870
- /**
871
- * @description 为单个回声洞对象生成所有类型的哈希(文本+图片)。
872
- * @param cave - 回声洞对象。
873
- * @returns 生成的哈希对象数组。
874
- */
875
- async generateAllHashesForCave(cave) {
876
- const tempHashes = [];
877
- const uniqueHashTracker = /* @__PURE__ */ new Set();
878
- const addUniqueHash = /* @__PURE__ */ __name((hashObj) => {
879
- const key = `${hashObj.hash}-${hashObj.type}`;
880
- if (!uniqueHashTracker.has(key)) {
881
- tempHashes.push(hashObj);
882
- uniqueHashTracker.add(key);
883
- }
884
- }, "addUniqueHash");
885
- const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
886
- if (combinedText) {
887
- const textHash = this.generateTextSimhash(combinedText);
888
- if (textHash) addUniqueHash({ cave: cave.id, hash: textHash, type: "text" });
889
- }
890
- for (const el of cave.elements.filter((el2) => el2.type === "image" && el2.file)) {
891
- try {
892
- const imageBuffer = await this.fileManager.readFile(el.file);
893
- const imageHash = await this.generatePHash(imageBuffer);
894
- addUniqueHash({ cave: cave.id, hash: imageHash, type: "image" });
895
- } catch (error) {
896
- this.logger.warn(`无法为回声洞(${cave.id})的图片(${el.file})生成哈希:`, error);
897
- throw error;
898
- }
899
- }
900
- return tempHashes;
901
- }
902
898
  /**
903
899
  * @description 执行一维离散余弦变换 (DCT-II) 的方法。
904
900
  * @param input - 输入的数字数组。
@@ -1005,7 +1001,10 @@ var path3 = __toESM(require("path"));
1005
1001
  var AIManager = class {
1006
1002
  /**
1007
1003
  * @constructor
1008
- * @description AIManager 类的构造函数,负责初始化依赖项,并向 Koishi 的数据库模型中注册 `cave_meta` 表。
1004
+ * @param {Context} ctx - Koishi 的上下文对象,提供框架核心功能。
1005
+ * @param {Config} config - 插件的配置对象。
1006
+ * @param {Logger} logger - 日志记录器实例,用于输出日志。
1007
+ * @param {FileManager} fileManager - 文件管理器实例,用于处理媒体文件。
1009
1008
  */
1010
1009
  constructor(ctx, config, logger2, fileManager) {
1011
1010
  this.ctx = ctx;
@@ -1030,28 +1029,26 @@ var AIManager = class {
1030
1029
  rateLimitResetTime = 0;
1031
1030
  /**
1032
1031
  * @description 注册所有与 AIManager 功能相关的 Koishi 命令。
1033
- * @param {any} cave - `cave` 命令的实例,用于在其下注册子命令。
1032
+ * @param {any} cave - Koishi 命令实例,用于挂载子命令。
1034
1033
  */
1035
1034
  registerCommands(cave) {
1036
1035
  cave.subcommand(".ai", "分析回声洞", { hidden: true, authority: 4 }).usage("分析尚未分析的回声洞,补全回声洞记录。").action(async ({ session }) => {
1037
1036
  if (requireAdmin(session, this.config)) return requireAdmin(session, this.config);
1038
1037
  try {
1039
1038
  const allCaves = await this.ctx.database.get("cave", { status: "active" });
1040
- 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));
1041
1040
  const cavesToAnalyze = allCaves.filter((cave2) => !analyzedCaveIds.has(cave2.id));
1042
1041
  if (cavesToAnalyze.length === 0) return "无需分析回声洞";
1043
1042
  await session.send(`开始分析 ${cavesToAnalyze.length} 个回声洞...`);
1044
- let successCount = 0;
1045
- for (const [index, cave2] of cavesToAnalyze.entries()) {
1046
- try {
1047
- this.logger.info(`[${index + 1}/${cavesToAnalyze.length}] 正在分析回声洞 (${cave2.id})...`);
1048
- await this.analyzeAndStore(cave2);
1049
- successCount++;
1050
- } catch (error) {
1051
- return `分析回声洞(${cave2.id})时出错: ${error.message}`;
1052
- }
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;
1053
1050
  }
1054
- return `已分析 ${successCount} 个回声洞`;
1051
+ return `已分析 ${totalSuccessCount} 个回声洞`;
1055
1052
  } catch (error) {
1056
1053
  this.logger.error("分析回声洞失败:", error);
1057
1054
  return `操作失败: ${error.message}`;
@@ -1060,30 +1057,17 @@ var AIManager = class {
1060
1057
  }
1061
1058
  /**
1062
1059
  * @description 对新提交的内容执行 AI 驱动的查重检查。
1063
- * @param {StoredElement[]} newElements - 待检查的新内容的结构化数组(包含文本、图片等)。
1064
- * @param {{ sourceUrl: string, fileName: string }[]} newMediaToSave - 伴随新内容提交的、需要从 URL 下载的媒体文件列表。
1065
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)已经加载到内存中的媒体文件 Buffer,可用于优化性能。
1066
- * @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(如果存在)。
1067
1063
  */
1068
- async checkForDuplicates(newElements, newMediaToSave, mediaBuffers) {
1064
+ async checkForDuplicates(newElements, mediaBuffers) {
1069
1065
  try {
1070
- const newAnalysis = await this.getAnalysis(newElements, newMediaToSave, mediaBuffers);
1071
- if (!newAnalysis || newAnalysis.keywords.length === 0) return { duplicate: false };
1072
- const allMeta = await this.ctx.database.get("cave_meta", {});
1073
- const potentialDuplicates = (await Promise.all(allMeta.map(async (meta) => {
1074
- const setA = new Set(newAnalysis.keywords);
1075
- const setB = new Set(meta.keywords);
1076
- let similarity = 0;
1077
- if (setA.size > 0 && setB.size > 0) {
1078
- const intersection = new Set([...setA].filter((x) => setB.has(x)));
1079
- const union = /* @__PURE__ */ new Set([...setA, ...setB]);
1080
- similarity = intersection.size / union.size;
1081
- }
1082
- if (similarity * 100 >= 80) {
1083
- const [cave] = await this.ctx.database.get("cave", { id: meta.cave });
1084
- return cave;
1085
- }
1086
- }))).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);
1087
1071
  if (potentialDuplicates.length === 0) return { duplicate: false };
1088
1072
  const formatContent = /* @__PURE__ */ __name((elements) => elements.filter((el) => el.type === "text").map((el) => el.content).join(" "), "formatContent");
1089
1073
  const userMessage = {
@@ -1105,74 +1089,92 @@ var AIManager = class {
1105
1089
  }
1106
1090
  /**
1107
1091
  * @description 对单个回声洞对象执行完整的分析和存储流程。
1108
- * @param {CaveObject} cave - 需要被分析的完整回声洞对象,包含 `id` 和 `elements`。
1109
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)与该回声洞相关的、已加载到内存的媒体文件 Buffer。
1110
- * @returns {Promise<void>} 操作完成后 resolve 的 Promise。
1111
- * @throws {Error} 如果在分析或数据库存储过程中发生错误,则会向上抛出异常。
1092
+ * @param {CaveObject} cave - 要分析的回声洞对象。
1093
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓冲区数组,用于新提交内容的分析。
1094
+ * @returns {Promise<void>} 分析和存储操作完成后解析的 Promise。
1112
1095
  */
1113
1096
  async analyzeAndStore(cave, mediaBuffers) {
1114
- try {
1115
- const result = await this.getAnalysis(cave.elements, void 0, mediaBuffers);
1116
- if (result) {
1117
- await this.ctx.database.upsert("cave_meta", [{
1118
- cave: cave.id,
1119
- ...result,
1120
- rating: Math.max(0, Math.min(100, result.rating || 0))
1121
- }]);
1122
- }
1123
- } catch (error) {
1124
- this.logger.error(`分析回声洞(${cave.id})失败:`, error);
1125
- 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
+ }]);
1126
1106
  }
1127
1107
  }
1128
1108
  /**
1129
- * @description 准备并发送内容给 AI 模型以获取分析结果。
1130
- * @param {StoredElement[]} elements - 内容的结构化元素数组。
1131
- * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选)需要从网络下载的媒体文件信息。
1132
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)已存在于内存中的媒体文件 Buffer。
1133
- * @returns {Promise<Omit<CaveMetaObject, 'cave'>>} 返回一个不含 `cave` 字段的分析结果对象。如果内容为空或无法处理,则返回 `null`。
1109
+ * @description 对一批回声洞执行分析并存储结果。
1110
+ * @param {CaveObject[]} caves - 要分析的回声洞对象数组。
1111
+ * @returns {Promise<number>} 一个 Promise,解析为成功分析和存储的条目数。
1134
1112
  */
1135
- async getAnalysis(elements, mediaToSave, mediaBuffers) {
1136
- const userContent = [];
1137
- const combinedText = elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
1138
- if (combinedText.trim()) userContent.push({ type: "text", text: combinedText });
1139
- const mediaMap = new Map(mediaBuffers?.map((m) => [m.fileName, m.buffer]));
1140
- const imageElements = elements.filter((el) => el.type === "image" && el.file);
1141
- for (const el of imageElements) {
1142
- try {
1143
- let buffer;
1144
- if (mediaMap.has(el.file)) {
1145
- buffer = mediaMap.get(el.file);
1146
- } else if (mediaToSave) {
1147
- const item = mediaToSave.find((m) => m.fileName === el.file);
1148
- if (item) buffer = Buffer.from(await this.ctx.http.get(item.sourceUrl, { responseType: "arraybuffer" }));
1149
- } else {
1150
- buffer = await this.fileManager.readFile(el.file);
1151
- }
1152
- if (buffer) {
1153
- const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
1154
- userContent.push({
1155
- type: "image_url",
1156
- image_url: { url: `data:${mimeType};base64,${buffer.toString("base64")}` }
1157
- });
1158
- }
1159
- } catch (error) {
1160
- this.logger.warn(`分析内容(${el.file})失败:`, error);
1161
- }
1162
- }
1163
- if (userContent.length === 0) return null;
1164
- const userMessage = { role: "user", content: userContent };
1165
- 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;
1166
1124
  }
1167
1125
  /**
1168
- * @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
1169
- * @param {any[]} messages - 要发送给 AI 的消息数组,格式遵循 OpenAI API 规范。
1170
- * @param {string} systemPrompt - 指导 AI 行为的系统级提示词。
1171
- * @param {string} schemaString - 一个 JSON 字符串,定义了期望 AI 返回的 JSON 对象的结构。
1172
- * @returns {Promise<any>} AI 返回的、经过 JSON 解析的响应体。
1173
- * @throws {Error} 当 JSON Schema 解析失败、网络请求失败或 AI 返回错误时,抛出异常。
1126
+ * @description 根据新内容的关键词,查找并返回可能重复的回声洞。
1127
+ * @param {string[]} newKeywords - 新内容的关键词数组。
1128
+ * @returns {Promise<CaveObject[]>} 一个 Promise,解析为可能重复的回声洞对象数组。
1174
1129
  */
1175
- async requestAI(messages, systemPrompt, schemaString) {
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() {
1176
1178
  const now = Date.now();
1177
1179
  if (now > this.rateLimitResetTime) {
1178
1180
  this.rateLimitResetTime = now + 6e4;
@@ -1180,45 +1182,43 @@ var AIManager = class {
1180
1182
  }
1181
1183
  if (this.requestCount >= this.config.aiRPM) {
1182
1184
  const delay = this.rateLimitResetTime - now;
1183
- await new Promise((resolve) => setTimeout(resolve, delay));
1185
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
1184
1186
  this.rateLimitResetTime = Date.now() + 6e4;
1185
1187
  this.requestCount = 0;
1186
1188
  }
1187
- let schema = JSON.parse(schemaString);
1188
- const toolName = "extract_data";
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();
1189
1200
  const payload = {
1190
1201
  model: this.config.aiModel,
1191
1202
  messages: [{ role: "system", content: systemPrompt }, ...messages],
1192
- tools: [{
1193
- type: "function",
1194
- function: {
1195
- name: toolName,
1203
+ response_format: {
1204
+ type: "json_schema",
1205
+ json_schema: {
1206
+ name: "extract_data",
1196
1207
  description: "根据提供的内容提取或分析信息。",
1197
- parameters: schema
1208
+ schema: JSON.parse(schemaString)
1198
1209
  }
1199
- }],
1200
- tool_choice: { type: "function", function: { name: toolName } }
1210
+ }
1201
1211
  };
1202
1212
  const fullUrl = `${this.config.aiEndpoint.replace(/\/$/, "")}/chat/completions`;
1203
1213
  const headers = {
1204
1214
  "Content-Type": "application/json",
1205
1215
  "Authorization": `Bearer ${this.config.aiApiKey}`
1206
1216
  };
1207
- try {
1208
- this.requestCount++;
1209
- const response = await this.http.post(fullUrl, payload, { headers, timeout: 9e4 });
1210
- const toolCall = response.choices?.[0]?.message?.tool_calls?.[0];
1211
- if (toolCall?.function?.arguments) {
1212
- return JSON.parse(toolCall.function.arguments);
1213
- } else {
1214
- this.logger.error("AI 响应格式不正确:", JSON.stringify(response));
1215
- throw new Error("AI 响应格式不正确");
1216
- }
1217
- } catch (error) {
1218
- const errorMessage = error.response ? JSON.stringify(error.response.data) : error.message;
1219
- this.logger.error(`请求 API 失败: ${errorMessage}`);
1220
- throw error;
1221
- }
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("响应无效");
1222
1222
  }
1223
1223
  };
1224
1224
 
@@ -1249,8 +1249,8 @@ var Config = import_koishi3.Schema.intersect([
1249
1249
  import_koishi3.Schema.object({
1250
1250
  enablePend: import_koishi3.Schema.boolean().default(false).description("启用审核"),
1251
1251
  enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
1252
- textThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(90).description("文本相似度阈值 (%)"),
1253
- imageThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(90).description("图片相似度阈值 (%)")
1252
+ textThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(95).description("文本相似度阈值 (%)"),
1253
+ imageThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(95).description("图片相似度阈值 (%)")
1254
1254
  }).description("复核配置"),
1255
1255
  import_koishi3.Schema.object({
1256
1256
  enableAI: import_koishi3.Schema.boolean().default(false).description("启用 AI"),
@@ -1258,28 +1258,42 @@ var Config = import_koishi3.Schema.intersect([
1258
1258
  aiApiKey: import_koishi3.Schema.string().description("密钥 (Key)").role("secret"),
1259
1259
  aiModel: import_koishi3.Schema.string().description("模型 (Model)").default("gemini-2.5-flash"),
1260
1260
  aiRPM: import_koishi3.Schema.number().description("每分钟请求数 (RPM)").default(60),
1261
- AnalysePrompt: import_koishi3.Schema.string().role("textarea").default(`你是一位内容分析专家。请分析我提供的内容,总结关键词,概括内容并进行评分。`).description("分析 Prompt"),
1261
+ AnalysePrompt: import_koishi3.Schema.string().role("textarea").default(`你是一位内容分析专家。请分析我以JSON格式提供的一组内容(每项包含ID、文本和图片),为每一项内容总结关键词、概括内容并评分。你需要返回一个包含所有分析结果的JSON对象。`).description("分析 Prompt"),
1262
1262
  aiAnalyseSchema: import_koishi3.Schema.string().role("textarea").default(
1263
1263
  `{
1264
1264
  "type": "object",
1265
1265
  "properties": {
1266
- "keywords": {
1266
+ "analyses": {
1267
1267
  "type": "array",
1268
- "items": { "type": "string" },
1269
- "description": "使用尽可能多的关键词准确形容内容"
1270
- },
1271
- "description": {
1272
- "type": "string",
1273
- "description": "概括或描述这部分内容"
1274
- },
1275
- "rating": {
1276
- "type": "integer",
1277
- "description": "对内容的综合质量进行评分",
1278
- "minimum": 0,
1279
- "maximum": 100
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
+ }
1280
1294
  }
1281
1295
  },
1282
- "required": ["keywords", "description", "rating"]
1296
+ "required": ["analyses"]
1283
1297
  }`
1284
1298
  ).description("分析 JSON Schema"),
1285
1299
  aiCheckPrompt: import_koishi3.Schema.string().role("textarea").default(`你是一位内容查重专家。请判断我提供的"新内容"是否与"已有内容"重复或高度相似。`).description("查重 Prompt"),
@@ -1395,7 +1409,7 @@ function apply(ctx, config) {
1395
1409
  imageHashesToStore = checkResult.imageHashesToStore;
1396
1410
  }
1397
1411
  if (aiManager) {
1398
- const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb, mediaToSave, downloadedMedia);
1412
+ const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
1399
1413
  if (duplicateResult && duplicateResult.duplicate) return `内容与回声洞(${duplicateResult.id})重复`;
1400
1414
  }
1401
1415
  const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "功能强大、高度可定制的回声洞。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
- "version": "2.7.9",
4
+ "version": "2.7.11",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],