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.
- package/lib/AIManager.d.ts +40 -27
- package/lib/HashManager.d.ts +1 -7
- package/lib/index.js +187 -173
- 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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
771
|
+
await flushBatch();
|
|
744
772
|
const successCount = processedCaveCount - errorCount;
|
|
745
|
-
return `已补全 ${successCount} 个回声洞的 ${
|
|
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
|
-
* @
|
|
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 -
|
|
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
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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 `已分析 ${
|
|
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 {{
|
|
1065
|
-
* @
|
|
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,
|
|
1064
|
+
async checkForDuplicates(newElements, mediaBuffers) {
|
|
1069
1065
|
try {
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1074
|
-
|
|
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 -
|
|
1109
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] -
|
|
1110
|
-
* @returns {Promise<void>}
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
|
1130
|
-
* @param {
|
|
1131
|
-
* @
|
|
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
|
|
1136
|
-
const
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
|
1169
|
-
* @param {
|
|
1170
|
-
* @
|
|
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
|
|
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
|
-
|
|
1188
|
-
|
|
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
|
-
|
|
1193
|
-
type: "
|
|
1194
|
-
|
|
1195
|
-
name:
|
|
1203
|
+
response_format: {
|
|
1204
|
+
type: "json_schema",
|
|
1205
|
+
json_schema: {
|
|
1206
|
+
name: "extract_data",
|
|
1196
1207
|
description: "根据提供的内容提取或分析信息。",
|
|
1197
|
-
|
|
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
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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(
|
|
1253
|
-
imageThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(
|
|
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(
|
|
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
|
-
"
|
|
1266
|
+
"analyses": {
|
|
1267
1267
|
"type": "array",
|
|
1268
|
-
"
|
|
1269
|
-
"
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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": ["
|
|
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,
|
|
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;
|