koishi-plugin-best-cave 2.7.10 → 2.7.12
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 +5 -5
- package/lib/Utils.d.ts +2 -2
- package/lib/index.js +242 -230
- 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` 表的完整对象模型。
|
|
@@ -33,11 +33,11 @@ export declare class HashManager {
|
|
|
33
33
|
*/
|
|
34
34
|
registerCommands(cave: any): void;
|
|
35
35
|
/**
|
|
36
|
-
* @description
|
|
37
|
-
* @param
|
|
38
|
-
* @returns
|
|
36
|
+
* @description 扫描并修复单个图片 Buffer,移除文件结束符之后的多余数据。
|
|
37
|
+
* @param imageBuffer - 原始的图片 Buffer。
|
|
38
|
+
* @returns 修复后的图片 Buffer。如果无需修复,则返回原始 Buffer。
|
|
39
39
|
*/
|
|
40
|
-
|
|
40
|
+
sanitizeImageBuffer(imageBuffer: Buffer): Buffer;
|
|
41
41
|
/**
|
|
42
42
|
* @description 执行一维离散余弦变换 (DCT-II) 的方法。
|
|
43
43
|
* @param input - 输入的数字数组。
|
package/lib/Utils.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export declare function processMessageElements(sourceElements: h[], newId: numbe
|
|
|
57
57
|
* @description 执行文本 (Simhash) 和图片 (pHash) 相似度查重。
|
|
58
58
|
* @returns 一个对象,指示是否发现重复项;如果未发现,则返回生成的哈希。
|
|
59
59
|
*/
|
|
60
|
-
export declare function performSimilarityChecks(ctx: Context, config: Config, hashManager: HashManager, finalElementsForDb: StoredElement[], downloadedMedia: {
|
|
60
|
+
export declare function performSimilarityChecks(ctx: Context, config: Config, hashManager: HashManager, logger: Logger, finalElementsForDb: StoredElement[], downloadedMedia: {
|
|
61
61
|
fileName: string;
|
|
62
62
|
buffer: Buffer;
|
|
63
63
|
}[]): Promise<{
|
|
@@ -81,7 +81,7 @@ export declare function performSimilarityChecks(ctx: Context, config: Config, ha
|
|
|
81
81
|
export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, cave: CaveObject, downloadedMedia: {
|
|
82
82
|
fileName: string;
|
|
83
83
|
buffer: Buffer;
|
|
84
|
-
}[], reusableIds: Set<number>,
|
|
84
|
+
}[], reusableIds: Set<number>, needsReview: boolean): Promise<'pending' | 'active'>;
|
|
85
85
|
/**
|
|
86
86
|
* @description 校验会话是否来自指定的管理群组。
|
|
87
87
|
* @param session 当前会话。
|
package/lib/index.js
CHANGED
|
@@ -450,42 +450,46 @@ async function processMessageElements(sourceElements, newId, session, creationTi
|
|
|
450
450
|
return { finalElementsForDb, mediaToSave };
|
|
451
451
|
}
|
|
452
452
|
__name(processMessageElements, "processMessageElements");
|
|
453
|
-
async function performSimilarityChecks(ctx, config, hashManager, finalElementsForDb, downloadedMedia) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
453
|
+
async function performSimilarityChecks(ctx, config, hashManager, logger2, finalElementsForDb, downloadedMedia) {
|
|
454
|
+
try {
|
|
455
|
+
const textHashesToStore = [];
|
|
456
|
+
const imageHashesToStore = [];
|
|
457
|
+
const combinedText = finalElementsForDb.filter((el) => el.type === "text" && typeof el.content === "string").map((el) => el.content).join(" ");
|
|
458
|
+
if (combinedText) {
|
|
459
|
+
const newSimhash = hashManager.generateTextSimhash(combinedText);
|
|
460
|
+
if (newSimhash) {
|
|
461
|
+
const existingTextHashes = await ctx.database.get("cave_hash", { type: "text" });
|
|
462
|
+
for (const existing of existingTextHashes) {
|
|
463
|
+
const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
|
|
464
|
+
if (similarity >= config.textThreshold) return { duplicate: true, message: `文本与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值` };
|
|
465
|
+
}
|
|
466
|
+
textHashesToStore.push({ hash: newSimhash, type: "text" });
|
|
464
467
|
}
|
|
465
|
-
textHashesToStore.push({ hash: newSimhash, type: "text" });
|
|
466
468
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
469
|
+
if (downloadedMedia.length > 0) {
|
|
470
|
+
const allExistingImageHashes = await ctx.database.get("cave_hash", { type: "image" });
|
|
471
|
+
for (const media of downloadedMedia) {
|
|
472
|
+
if ([".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) {
|
|
473
|
+
const imageHash = await hashManager.generatePHash(media.buffer);
|
|
474
|
+
for (const existing of allExistingImageHashes) {
|
|
475
|
+
const similarity = hashManager.calculateSimilarity(imageHash, existing.hash);
|
|
476
|
+
if (similarity >= config.imageThreshold) return { duplicate: true, message: `图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值` };
|
|
477
|
+
}
|
|
478
|
+
imageHashesToStore.push({ hash: imageHash, type: "image" });
|
|
479
|
+
allExistingImageHashes.push({ cave: 0, hash: imageHash, type: "image" });
|
|
476
480
|
}
|
|
477
|
-
imageHashesToStore.push({ hash: imageHash, type: "image" });
|
|
478
|
-
allExistingImageHashes.push({ cave: 0, hash: imageHash, type: "image" });
|
|
479
481
|
}
|
|
480
482
|
}
|
|
483
|
+
return { duplicate: false, textHashesToStore, imageHashesToStore };
|
|
484
|
+
} catch (error) {
|
|
485
|
+
logger2.warn("相似度比较失败:", error);
|
|
486
|
+
return { duplicate: false, textHashesToStore: [], imageHashesToStore: [] };
|
|
481
487
|
}
|
|
482
|
-
return { duplicate: false, textHashesToStore, imageHashesToStore };
|
|
483
488
|
}
|
|
484
489
|
__name(performSimilarityChecks, "performSimilarityChecks");
|
|
485
|
-
async function handleFileUploads(ctx, config, fileManager, logger2, cave, downloadedMedia, reusableIds,
|
|
490
|
+
async function handleFileUploads(ctx, config, fileManager, logger2, cave, downloadedMedia, reusableIds, needsReview) {
|
|
486
491
|
try {
|
|
487
492
|
await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
|
|
488
|
-
const needsReview = config.enablePend && session.channelId !== config.adminChannel?.split(":")[1];
|
|
489
493
|
const finalStatus = needsReview ? "pending" : "active";
|
|
490
494
|
await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
|
|
491
495
|
return finalStatus;
|
|
@@ -741,10 +745,27 @@ var HashManager = class {
|
|
|
741
745
|
for (const cave2 of cavesToProcess) {
|
|
742
746
|
processedCaveCount++;
|
|
743
747
|
try {
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
748
|
+
const tempHashes = [];
|
|
749
|
+
const uniqueHashTracker = /* @__PURE__ */ new Set();
|
|
750
|
+
const addUniqueHash = /* @__PURE__ */ __name((hashObj) => {
|
|
751
|
+
const key = `${hashObj.hash}-${hashObj.type}`;
|
|
752
|
+
if (!uniqueHashTracker.has(key)) {
|
|
753
|
+
tempHashes.push(hashObj);
|
|
754
|
+
uniqueHashTracker.add(key);
|
|
755
|
+
}
|
|
756
|
+
}, "addUniqueHash");
|
|
757
|
+
const combinedText = cave2.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
|
|
758
|
+
if (combinedText) {
|
|
759
|
+
const textHash = this.generateTextSimhash(combinedText);
|
|
760
|
+
if (textHash) addUniqueHash({ cave: cave2.id, hash: textHash, type: "text" });
|
|
761
|
+
}
|
|
762
|
+
for (const el of cave2.elements.filter((el2) => el2.type === "image" && el2.file)) {
|
|
763
|
+
const imageBuffer = await this.fileManager.readFile(el.file);
|
|
764
|
+
const imageHash = await this.generatePHash(imageBuffer);
|
|
765
|
+
addUniqueHash({ cave: cave2.id, hash: imageHash, type: "image" });
|
|
747
766
|
}
|
|
767
|
+
const newHashesForCave = tempHashes;
|
|
768
|
+
if (newHashesForCave.length > 0) hashesToInsert.push(...newHashesForCave);
|
|
748
769
|
if (hashesToInsert.length >= 100) await flushBatch();
|
|
749
770
|
} catch (error) {
|
|
750
771
|
errorCount++;
|
|
@@ -828,37 +849,12 @@ var HashManager = class {
|
|
|
828
849
|
if (!cavesToProcess.length) return "无可修复的回声洞";
|
|
829
850
|
let fixedFiles = 0;
|
|
830
851
|
let errorCount = 0;
|
|
831
|
-
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
832
|
-
const JPEG_SIGNATURE = Buffer.from([255, 216]);
|
|
833
|
-
const GIF_SIGNATURE = Buffer.from("GIF");
|
|
834
852
|
for (const cave2 of cavesToProcess) {
|
|
835
853
|
const imageElements = cave2.elements.filter((el) => el.type === "image" && el.file);
|
|
836
854
|
for (const element of imageElements) {
|
|
837
855
|
try {
|
|
838
856
|
const originalBuffer = await this.fileManager.readFile(element.file);
|
|
839
|
-
|
|
840
|
-
if (originalBuffer.slice(0, 8).equals(PNG_SIGNATURE)) {
|
|
841
|
-
const IEND_CHUNK = Buffer.from("IEND");
|
|
842
|
-
const iendIndex = originalBuffer.lastIndexOf(IEND_CHUNK);
|
|
843
|
-
if (iendIndex !== -1) {
|
|
844
|
-
const endOfPngData = iendIndex + 8;
|
|
845
|
-
if (originalBuffer.length > endOfPngData) sanitizedBuffer = originalBuffer.slice(0, endOfPngData);
|
|
846
|
-
}
|
|
847
|
-
} else if (originalBuffer.slice(0, 2).equals(JPEG_SIGNATURE)) {
|
|
848
|
-
const EOI_MARKER = Buffer.from([255, 217]);
|
|
849
|
-
const eoiIndex = originalBuffer.lastIndexOf(EOI_MARKER);
|
|
850
|
-
if (eoiIndex !== -1) {
|
|
851
|
-
const endOfJpegData = eoiIndex + 2;
|
|
852
|
-
if (originalBuffer.length > endOfJpegData) sanitizedBuffer = originalBuffer.slice(0, endOfJpegData);
|
|
853
|
-
}
|
|
854
|
-
} else if (originalBuffer.slice(0, 3).equals(GIF_SIGNATURE)) {
|
|
855
|
-
const GIF_TERMINATOR = Buffer.from([59]);
|
|
856
|
-
const terminatorIndex = originalBuffer.lastIndexOf(GIF_TERMINATOR);
|
|
857
|
-
if (terminatorIndex !== -1) {
|
|
858
|
-
const endOfGifData = terminatorIndex + 1;
|
|
859
|
-
if (originalBuffer.length > endOfGifData) sanitizedBuffer = originalBuffer.slice(0, endOfGifData);
|
|
860
|
-
}
|
|
861
|
-
}
|
|
857
|
+
const sanitizedBuffer = this.sanitizeImageBuffer(originalBuffer);
|
|
862
858
|
if (!originalBuffer.equals(sanitizedBuffer)) {
|
|
863
859
|
await this.fileManager.saveFile(element.file, sanitizedBuffer);
|
|
864
860
|
fixedFiles++;
|
|
@@ -879,36 +875,38 @@ var HashManager = class {
|
|
|
879
875
|
});
|
|
880
876
|
}
|
|
881
877
|
/**
|
|
882
|
-
* @description
|
|
883
|
-
* @param
|
|
884
|
-
* @returns
|
|
878
|
+
* @description 扫描并修复单个图片 Buffer,移除文件结束符之后的多余数据。
|
|
879
|
+
* @param imageBuffer - 原始的图片 Buffer。
|
|
880
|
+
* @returns 修复后的图片 Buffer。如果无需修复,则返回原始 Buffer。
|
|
885
881
|
*/
|
|
886
|
-
|
|
887
|
-
const
|
|
888
|
-
const
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
882
|
+
sanitizeImageBuffer(imageBuffer) {
|
|
883
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
884
|
+
const JPEG_SIGNATURE = Buffer.from([255, 216]);
|
|
885
|
+
const GIF_SIGNATURE = Buffer.from("GIF");
|
|
886
|
+
let sanitizedBuffer = imageBuffer;
|
|
887
|
+
if (imageBuffer.slice(0, 8).equals(PNG_SIGNATURE)) {
|
|
888
|
+
const IEND_CHUNK = Buffer.from("IEND");
|
|
889
|
+
const iendIndex = imageBuffer.lastIndexOf(IEND_CHUNK);
|
|
890
|
+
if (iendIndex !== -1) {
|
|
891
|
+
const endOfPngData = iendIndex + 8;
|
|
892
|
+
if (imageBuffer.length > endOfPngData) sanitizedBuffer = imageBuffer.slice(0, endOfPngData);
|
|
894
893
|
}
|
|
895
|
-
},
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
throw error;
|
|
894
|
+
} else if (imageBuffer.slice(0, 2).equals(JPEG_SIGNATURE)) {
|
|
895
|
+
const EOI_MARKER = Buffer.from([255, 217]);
|
|
896
|
+
const eoiIndex = imageBuffer.lastIndexOf(EOI_MARKER);
|
|
897
|
+
if (eoiIndex !== -1) {
|
|
898
|
+
const endOfJpegData = eoiIndex + 2;
|
|
899
|
+
if (imageBuffer.length > endOfJpegData) sanitizedBuffer = imageBuffer.slice(0, endOfJpegData);
|
|
900
|
+
}
|
|
901
|
+
} else if (imageBuffer.slice(0, 3).equals(GIF_SIGNATURE)) {
|
|
902
|
+
const GIF_TERMINATOR = Buffer.from([59]);
|
|
903
|
+
const terminatorIndex = imageBuffer.lastIndexOf(GIF_TERMINATOR);
|
|
904
|
+
if (terminatorIndex !== -1) {
|
|
905
|
+
const endOfGifData = terminatorIndex + 1;
|
|
906
|
+
if (imageBuffer.length > endOfGifData) sanitizedBuffer = imageBuffer.slice(0, endOfGifData);
|
|
909
907
|
}
|
|
910
908
|
}
|
|
911
|
-
return
|
|
909
|
+
return sanitizedBuffer;
|
|
912
910
|
}
|
|
913
911
|
/**
|
|
914
912
|
* @description 执行一维离散余弦变换 (DCT-II) 的方法。
|
|
@@ -1016,7 +1014,10 @@ var path3 = __toESM(require("path"));
|
|
|
1016
1014
|
var AIManager = class {
|
|
1017
1015
|
/**
|
|
1018
1016
|
* @constructor
|
|
1019
|
-
* @
|
|
1017
|
+
* @param {Context} ctx - Koishi 的上下文对象,提供框架核心功能。
|
|
1018
|
+
* @param {Config} config - 插件的配置对象。
|
|
1019
|
+
* @param {Logger} logger - 日志记录器实例,用于输出日志。
|
|
1020
|
+
* @param {FileManager} fileManager - 文件管理器实例,用于处理媒体文件。
|
|
1020
1021
|
*/
|
|
1021
1022
|
constructor(ctx, config, logger2, fileManager) {
|
|
1022
1023
|
this.ctx = ctx;
|
|
@@ -1041,28 +1042,26 @@ var AIManager = class {
|
|
|
1041
1042
|
rateLimitResetTime = 0;
|
|
1042
1043
|
/**
|
|
1043
1044
|
* @description 注册所有与 AIManager 功能相关的 Koishi 命令。
|
|
1044
|
-
* @param {any} cave -
|
|
1045
|
+
* @param {any} cave - Koishi 命令实例,用于挂载子命令。
|
|
1045
1046
|
*/
|
|
1046
1047
|
registerCommands(cave) {
|
|
1047
1048
|
cave.subcommand(".ai", "分析回声洞", { hidden: true, authority: 4 }).usage("分析尚未分析的回声洞,补全回声洞记录。").action(async ({ session }) => {
|
|
1048
1049
|
if (requireAdmin(session, this.config)) return requireAdmin(session, this.config);
|
|
1049
1050
|
try {
|
|
1050
1051
|
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));
|
|
1052
|
+
const analyzedCaveIds = new Set((await this.ctx.database.get("cave_meta", {}, { fields: ["cave"] })).map((meta) => meta.cave));
|
|
1052
1053
|
const cavesToAnalyze = allCaves.filter((cave2) => !analyzedCaveIds.has(cave2.id));
|
|
1053
1054
|
if (cavesToAnalyze.length === 0) return "无需分析回声洞";
|
|
1054
1055
|
await session.send(`开始分析 ${cavesToAnalyze.length} 个回声洞...`);
|
|
1055
|
-
let
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
return `分析回声洞(${cave2.id})时出错: ${error.message}`;
|
|
1063
|
-
}
|
|
1056
|
+
let totalSuccessCount = 0;
|
|
1057
|
+
const batchSize = 10;
|
|
1058
|
+
for (let i = 0; i < cavesToAnalyze.length; i += batchSize) {
|
|
1059
|
+
const batch = cavesToAnalyze.slice(i, i + batchSize);
|
|
1060
|
+
this.logger.info(`[${i + 1}/${cavesToAnalyze.length}] 正在分析 ${batch.length} 条回声洞...`);
|
|
1061
|
+
const successCountInBatch = await this.analyzeAndStoreBatch(batch);
|
|
1062
|
+
totalSuccessCount += successCountInBatch;
|
|
1064
1063
|
}
|
|
1065
|
-
return `已分析 ${
|
|
1064
|
+
return `已分析 ${totalSuccessCount} 个回声洞`;
|
|
1066
1065
|
} catch (error) {
|
|
1067
1066
|
this.logger.error("分析回声洞失败:", error);
|
|
1068
1067
|
return `操作失败: ${error.message}`;
|
|
@@ -1071,41 +1070,25 @@ var AIManager = class {
|
|
|
1071
1070
|
}
|
|
1072
1071
|
/**
|
|
1073
1072
|
* @description 对新提交的内容执行 AI 驱动的查重检查。
|
|
1074
|
-
* @param {StoredElement[]} newElements -
|
|
1075
|
-
* @param {{
|
|
1076
|
-
* @
|
|
1077
|
-
* @returns {Promise<{ duplicate: boolean; id?: number }>} 一个包含查重结果的对象。
|
|
1073
|
+
* @param {StoredElement[]} newElements - 新提交的内容元素数组。
|
|
1074
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓冲区数组。
|
|
1075
|
+
* @returns {Promise<{ duplicate: boolean; id?: number }>} 一个 Promise,解析为一个对象,指示内容是否重复以及重复的回声洞 ID(如果存在)。
|
|
1078
1076
|
*/
|
|
1079
|
-
async checkForDuplicates(newElements,
|
|
1077
|
+
async checkForDuplicates(newElements, mediaBuffers) {
|
|
1080
1078
|
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);
|
|
1079
|
+
const dummyCave = { id: 0, elements: newElements, channelId: "", userId: "", userName: "", status: "preload", time: /* @__PURE__ */ new Date() };
|
|
1080
|
+
const mediaMap = mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0;
|
|
1081
|
+
const [newAnalysis] = await this.getAnalyses([dummyCave], mediaMap);
|
|
1082
|
+
if (!newAnalysis?.keywords?.length) return { duplicate: false };
|
|
1083
|
+
const potentialDuplicates = await this.findPotentialDuplicates(newAnalysis.keywords);
|
|
1098
1084
|
if (potentialDuplicates.length === 0) return { duplicate: false };
|
|
1099
1085
|
const formatContent = /* @__PURE__ */ __name((elements) => elements.filter((el) => el.type === "text").map((el) => el.content).join(" "), "formatContent");
|
|
1100
1086
|
const userMessage = {
|
|
1101
1087
|
role: "user",
|
|
1102
|
-
content:
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
existing_contents: potentialDuplicates.map((cave) => ({ id: cave.id, text: formatContent(cave.elements) }))
|
|
1107
|
-
})
|
|
1108
|
-
}]
|
|
1088
|
+
content: JSON.stringify({
|
|
1089
|
+
new_content: { text: formatContent(newElements) },
|
|
1090
|
+
existing_contents: potentialDuplicates.map((cave) => ({ id: cave.id, text: formatContent(cave.elements) }))
|
|
1091
|
+
})
|
|
1109
1092
|
};
|
|
1110
1093
|
const response = await this.requestAI([userMessage], this.config.aiCheckPrompt, this.config.aiCheckSchema);
|
|
1111
1094
|
return {
|
|
@@ -1119,74 +1102,96 @@ var AIManager = class {
|
|
|
1119
1102
|
}
|
|
1120
1103
|
/**
|
|
1121
1104
|
* @description 对单个回声洞对象执行完整的分析和存储流程。
|
|
1122
|
-
* @param {CaveObject} cave -
|
|
1123
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] -
|
|
1124
|
-
* @returns {Promise<void>}
|
|
1125
|
-
* @throws {Error} 如果在分析或数据库存储过程中发生错误,则会向上抛出异常。
|
|
1105
|
+
* @param {CaveObject} cave - 要分析的回声洞对象。
|
|
1106
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓冲区数组,用于新提交内容的分析。
|
|
1107
|
+
* @returns {Promise<void>} 分析和存储操作完成后解析的 Promise。
|
|
1126
1108
|
*/
|
|
1127
1109
|
async analyzeAndStore(cave, mediaBuffers) {
|
|
1128
1110
|
try {
|
|
1129
|
-
const
|
|
1111
|
+
const mediaMap = mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0;
|
|
1112
|
+
const [result] = await this.getAnalyses([cave], mediaMap);
|
|
1130
1113
|
if (result) {
|
|
1131
1114
|
await this.ctx.database.upsert("cave_meta", [{
|
|
1132
1115
|
cave: cave.id,
|
|
1133
|
-
|
|
1116
|
+
keywords: result.keywords || [],
|
|
1117
|
+
description: result.description || "",
|
|
1134
1118
|
rating: Math.max(0, Math.min(100, result.rating || 0))
|
|
1135
1119
|
}]);
|
|
1136
1120
|
}
|
|
1137
1121
|
} catch (error) {
|
|
1138
|
-
this.logger.error(`分析回声洞(${cave.id}
|
|
1139
|
-
throw error;
|
|
1122
|
+
this.logger.error(`分析回声洞(${cave.id})出错:`, error);
|
|
1140
1123
|
}
|
|
1141
1124
|
}
|
|
1142
1125
|
/**
|
|
1143
|
-
* @description
|
|
1144
|
-
* @param {
|
|
1145
|
-
* @
|
|
1146
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)已存在于内存中的媒体文件 Buffer。
|
|
1147
|
-
* @returns {Promise<Omit<CaveMetaObject, 'cave'>>} 返回一个不含 `cave` 字段的分析结果对象。如果内容为空或无法处理,则返回 `null`。
|
|
1126
|
+
* @description 对一批回声洞执行分析并存储结果。
|
|
1127
|
+
* @param {CaveObject[]} caves - 要分析的回声洞对象数组。
|
|
1128
|
+
* @returns {Promise<number>} 一个 Promise,解析为成功分析和存储的条目数。
|
|
1148
1129
|
*/
|
|
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);
|
|
1130
|
+
async analyzeAndStoreBatch(caves) {
|
|
1131
|
+
const results = await this.getAnalyses(caves);
|
|
1132
|
+
if (!results?.length) return 0;
|
|
1133
|
+
const caveMetaObjects = results.map((res) => ({
|
|
1134
|
+
cave: res.id,
|
|
1135
|
+
keywords: res.keywords || [],
|
|
1136
|
+
description: res.description || "",
|
|
1137
|
+
rating: Math.max(0, Math.min(100, res.rating || 0))
|
|
1138
|
+
}));
|
|
1139
|
+
await this.ctx.database.upsert("cave_meta", caveMetaObjects);
|
|
1140
|
+
return caveMetaObjects.length;
|
|
1180
1141
|
}
|
|
1181
1142
|
/**
|
|
1182
|
-
* @description
|
|
1183
|
-
* @param {
|
|
1184
|
-
* @
|
|
1185
|
-
* @param {string} schemaString - 一个 JSON 字符串,定义了期望 AI 返回的 JSON 对象的结构。
|
|
1186
|
-
* @returns {Promise<any>} AI 返回的、经过 JSON 解析的响应体。
|
|
1187
|
-
* @throws {Error} 当 JSON Schema 解析失败、网络请求失败或 AI 返回错误时,抛出异常。
|
|
1143
|
+
* @description 根据新内容的关键词,查找并返回可能重复的回声洞。
|
|
1144
|
+
* @param {string[]} newKeywords - 新内容的关键词数组。
|
|
1145
|
+
* @returns {Promise<CaveObject[]>} 一个 Promise,解析为可能重复的回声洞对象数组。
|
|
1188
1146
|
*/
|
|
1189
|
-
async
|
|
1147
|
+
async findPotentialDuplicates(newKeywords) {
|
|
1148
|
+
const allMeta = await this.ctx.database.get("cave_meta", {}, { fields: ["cave", "keywords"] });
|
|
1149
|
+
const newKeywordsSet = new Set(newKeywords);
|
|
1150
|
+
const similarCaveIds = allMeta.filter((meta) => {
|
|
1151
|
+
if (!meta.keywords?.length) return false;
|
|
1152
|
+
const existingKeywordsSet = new Set(meta.keywords);
|
|
1153
|
+
const intersection = new Set([...newKeywordsSet].filter((x) => existingKeywordsSet.has(x)));
|
|
1154
|
+
const union = /* @__PURE__ */ new Set([...newKeywordsSet, ...existingKeywordsSet]);
|
|
1155
|
+
const similarity = union.size > 0 ? intersection.size / union.size : 0;
|
|
1156
|
+
return similarity * 100 >= 80;
|
|
1157
|
+
}).map((meta) => meta.cave);
|
|
1158
|
+
if (similarCaveIds.length === 0) return [];
|
|
1159
|
+
return this.ctx.database.get("cave", { id: { $in: similarCaveIds } });
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* @description 为一批回声洞准备内容,并向 AI 发送单个请求以获取所有分析结果。
|
|
1163
|
+
* @param {CaveObject[]} caves - 要分析的回声洞对象数组。
|
|
1164
|
+
* @param {Map<string, Buffer>} [mediaBufferMap] - 可选的媒体文件名到其缓冲区的映射。
|
|
1165
|
+
* @returns {Promise<any[]>} 一个 Promise,解析为 AI 返回的分析结果数组。
|
|
1166
|
+
*/
|
|
1167
|
+
async getAnalyses(caves, mediaBufferMap) {
|
|
1168
|
+
const batchPayload = await Promise.all(caves.map(async (cave) => {
|
|
1169
|
+
const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
|
|
1170
|
+
const imagesBase64 = (await Promise.all(
|
|
1171
|
+
cave.elements.filter((el) => el.type === "image" && el.file).map(async (el) => {
|
|
1172
|
+
try {
|
|
1173
|
+
const buffer = mediaBufferMap?.get(el.file) ?? await this.fileManager.readFile(el.file);
|
|
1174
|
+
const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
|
|
1175
|
+
return `data:${mimeType};base64,${buffer.toString("base64")}`;
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
this.logger.warn(`读取文件(${el.file})失败:`, error);
|
|
1178
|
+
return null;
|
|
1179
|
+
}
|
|
1180
|
+
})
|
|
1181
|
+
)).filter(Boolean);
|
|
1182
|
+
return { id: cave.id, text: combinedText, images: imagesBase64 };
|
|
1183
|
+
}));
|
|
1184
|
+
const nonEmptyPayload = batchPayload.filter((p) => p.text.trim() || p.images.length > 0);
|
|
1185
|
+
if (nonEmptyPayload.length === 0) return [];
|
|
1186
|
+
const userMessage = { role: "user", content: JSON.stringify(nonEmptyPayload) };
|
|
1187
|
+
const response = await this.requestAI([userMessage], this.config.AnalysePrompt, this.config.aiAnalyseSchema);
|
|
1188
|
+
return response.analyses || [];
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* @description 确保请求不会超过设定的速率限制(RPM)。如果需要,会延迟执行。
|
|
1192
|
+
* @returns {Promise<void>} 当可以继续发送请求时解析的 Promise。
|
|
1193
|
+
*/
|
|
1194
|
+
async ensureRateLimit() {
|
|
1190
1195
|
const now = Date.now();
|
|
1191
1196
|
if (now > this.rateLimitResetTime) {
|
|
1192
1197
|
this.rateLimitResetTime = now + 6e4;
|
|
@@ -1194,51 +1199,43 @@ var AIManager = class {
|
|
|
1194
1199
|
}
|
|
1195
1200
|
if (this.requestCount >= this.config.aiRPM) {
|
|
1196
1201
|
const delay = this.rateLimitResetTime - now;
|
|
1197
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1202
|
+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1198
1203
|
this.rateLimitResetTime = Date.now() + 6e4;
|
|
1199
1204
|
this.requestCount = 0;
|
|
1200
1205
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
|
|
1209
|
+
* @param {any[]} messages - 发送给 AI 的消息数组,遵循 OpenAI 格式。
|
|
1210
|
+
* @param {string} systemPrompt - 系统提示词,用于指导 AI 的行为。
|
|
1211
|
+
* @param {string} schemaString - 定义期望响应格式的 JSON Schema 字符串。
|
|
1212
|
+
* @returns {Promise<any>} 一个 Promise,解析为从 AI 接收到的、解析后的 JSON 对象。
|
|
1213
|
+
* @throws {Error} 当 AI 返回空或无效内容时抛出错误。
|
|
1214
|
+
*/
|
|
1215
|
+
async requestAI(messages, systemPrompt, schemaString) {
|
|
1216
|
+
await this.ensureRateLimit();
|
|
1208
1217
|
const payload = {
|
|
1209
1218
|
model: this.config.aiModel,
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
name: "extracted_data",
|
|
1218
|
-
schema
|
|
1219
|
+
messages: [{ role: "system", content: systemPrompt }, ...messages],
|
|
1220
|
+
response_format: {
|
|
1221
|
+
type: "json_schema",
|
|
1222
|
+
json_schema: {
|
|
1223
|
+
name: "extract_data",
|
|
1224
|
+
description: "根据提供的内容提取或分析信息。",
|
|
1225
|
+
schema: JSON.parse(schemaString)
|
|
1219
1226
|
}
|
|
1220
1227
|
}
|
|
1221
1228
|
};
|
|
1222
|
-
const fullUrl = `${this.config.aiEndpoint.replace(/\/$/, "")}/
|
|
1229
|
+
const fullUrl = `${this.config.aiEndpoint.replace(/\/$/, "")}/chat/completions`;
|
|
1223
1230
|
const headers = {
|
|
1224
1231
|
"Content-Type": "application/json",
|
|
1225
1232
|
"Authorization": `Bearer ${this.config.aiApiKey}`
|
|
1226
1233
|
};
|
|
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
|
-
}
|
|
1234
|
+
this.requestCount++;
|
|
1235
|
+
const response = await this.http.post(fullUrl, payload, { headers, timeout: 9e4 });
|
|
1236
|
+
const content = response.choices?.[0]?.message?.content;
|
|
1237
|
+
if (typeof content === "string" && content.trim()) return JSON.parse(content);
|
|
1238
|
+
throw new Error("响应无效");
|
|
1242
1239
|
}
|
|
1243
1240
|
};
|
|
1244
1241
|
|
|
@@ -1276,30 +1273,44 @@ var Config = import_koishi3.Schema.intersect([
|
|
|
1276
1273
|
enableAI: import_koishi3.Schema.boolean().default(false).description("启用 AI"),
|
|
1277
1274
|
aiEndpoint: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link").default("https://generativelanguage.googleapis.com/v1beta/openai"),
|
|
1278
1275
|
aiApiKey: import_koishi3.Schema.string().description("密钥 (Key)").role("secret"),
|
|
1279
|
-
aiModel: import_koishi3.Schema.string().description("模型 (Model)").default("gemini-
|
|
1276
|
+
aiModel: import_koishi3.Schema.string().description("模型 (Model)").default("gemini-1.5-flash"),
|
|
1280
1277
|
aiRPM: import_koishi3.Schema.number().description("每分钟请求数 (RPM)").default(60),
|
|
1281
|
-
AnalysePrompt: import_koishi3.Schema.string().role("textarea").default(
|
|
1278
|
+
AnalysePrompt: import_koishi3.Schema.string().role("textarea").default(`你是一位内容分析专家。请分析我以JSON格式提供的一组内容(每项包含ID、文本和图片),为每一项内容总结关键词、概括内容并评分。你需要返回一个包含所有分析结果的JSON对象。`).description("分析 Prompt"),
|
|
1282
1279
|
aiAnalyseSchema: import_koishi3.Schema.string().role("textarea").default(
|
|
1283
1280
|
`{
|
|
1284
1281
|
"type": "object",
|
|
1285
1282
|
"properties": {
|
|
1286
|
-
"
|
|
1283
|
+
"analyses": {
|
|
1287
1284
|
"type": "array",
|
|
1288
|
-
"
|
|
1289
|
-
"
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1285
|
+
"description": "分析结果的数组",
|
|
1286
|
+
"items": {
|
|
1287
|
+
"type": "object",
|
|
1288
|
+
"properties": {
|
|
1289
|
+
"id": {
|
|
1290
|
+
"type": "integer",
|
|
1291
|
+
"description": "内容的唯一ID"
|
|
1292
|
+
},
|
|
1293
|
+
"keywords": {
|
|
1294
|
+
"type": "array",
|
|
1295
|
+
"items": { "type": "string" },
|
|
1296
|
+
"description": "使用尽可能多的关键词准确形容内容"
|
|
1297
|
+
},
|
|
1298
|
+
"description": {
|
|
1299
|
+
"type": "string",
|
|
1300
|
+
"description": "概括或描述这部分内容"
|
|
1301
|
+
},
|
|
1302
|
+
"rating": {
|
|
1303
|
+
"type": "integer",
|
|
1304
|
+
"description": "对内容的综合质量进行评分",
|
|
1305
|
+
"minimum": 0,
|
|
1306
|
+
"maximum": 100
|
|
1307
|
+
}
|
|
1308
|
+
},
|
|
1309
|
+
"required": ["id", "keywords", "description", "rating"]
|
|
1310
|
+
}
|
|
1300
1311
|
}
|
|
1301
1312
|
},
|
|
1302
|
-
"required": ["
|
|
1313
|
+
"required": ["analyses"]
|
|
1303
1314
|
}`
|
|
1304
1315
|
).description("分析 JSON Schema"),
|
|
1305
1316
|
aiCheckPrompt: import_koishi3.Schema.string().role("textarea").default(`你是一位内容查重专家。请判断我提供的"新内容"是否与"已有内容"重复或高度相似。`).description("查重 Prompt"),
|
|
@@ -1409,17 +1420,18 @@ function apply(ctx, config) {
|
|
|
1409
1420
|
let textHashesToStore = [];
|
|
1410
1421
|
let imageHashesToStore = [];
|
|
1411
1422
|
if (hashManager) {
|
|
1412
|
-
const
|
|
1423
|
+
for (const media of downloadedMedia) media.buffer = hashManager.sanitizeImageBuffer(media.buffer);
|
|
1424
|
+
const checkResult = await performSimilarityChecks(ctx, config, hashManager, logger, finalElementsForDb, downloadedMedia);
|
|
1413
1425
|
if (checkResult.duplicate) return checkResult.message;
|
|
1414
1426
|
textHashesToStore = checkResult.textHashesToStore;
|
|
1415
1427
|
imageHashesToStore = checkResult.imageHashesToStore;
|
|
1416
1428
|
}
|
|
1417
1429
|
if (aiManager) {
|
|
1418
|
-
const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb,
|
|
1430
|
+
const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
|
|
1419
1431
|
if (duplicateResult && duplicateResult.duplicate) return `内容与回声洞(${duplicateResult.id})重复`;
|
|
1420
1432
|
}
|
|
1421
1433
|
const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
|
|
1422
|
-
const needsReview = config.enablePend && session.
|
|
1434
|
+
const needsReview = config.enablePend && session.cid !== config.adminChannel;
|
|
1423
1435
|
let finalStatus = hasMedia ? "preload" : needsReview ? "pending" : "active";
|
|
1424
1436
|
const newCave = await ctx.database.create("cave", {
|
|
1425
1437
|
id: newId,
|
|
@@ -1430,7 +1442,7 @@ function apply(ctx, config) {
|
|
|
1430
1442
|
status: finalStatus,
|
|
1431
1443
|
time: creationTime
|
|
1432
1444
|
});
|
|
1433
|
-
if (hasMedia) finalStatus = await handleFileUploads(ctx, config, fileManager, logger, newCave, downloadedMedia, reusableIds,
|
|
1445
|
+
if (hasMedia) finalStatus = await handleFileUploads(ctx, config, fileManager, logger, newCave, downloadedMedia, reusableIds, needsReview);
|
|
1434
1446
|
if (finalStatus !== "preload") {
|
|
1435
1447
|
newCave.status = finalStatus;
|
|
1436
1448
|
if (aiManager) await aiManager.analyzeAndStore(newCave, downloadedMedia);
|
|
@@ -1464,7 +1476,7 @@ function apply(ctx, config) {
|
|
|
1464
1476
|
const [targetCave] = await ctx.database.get("cave", { id, status: "active" });
|
|
1465
1477
|
if (!targetCave) return `回声洞(${id})不存在`;
|
|
1466
1478
|
const isAuthor = targetCave.userId === session.userId;
|
|
1467
|
-
const isAdmin = session.
|
|
1479
|
+
const isAdmin = session.cid === config.adminChannel;
|
|
1468
1480
|
if (!isAuthor && !isAdmin) return "你没有权限删除这条回声洞";
|
|
1469
1481
|
await ctx.database.upsert("cave", [{ id, status: "delete" }]);
|
|
1470
1482
|
const caveMessages = await buildCaveMessage(targetCave, config, fileManager, logger, session.platform, "已删除");
|
|
@@ -1477,8 +1489,8 @@ function apply(ctx, config) {
|
|
|
1477
1489
|
});
|
|
1478
1490
|
cave.subcommand(".list", "查询投稿统计").option("user", "-u <user:user> 指定用户").option("all", "-a 查看排行").action(async ({ session, options }) => {
|
|
1479
1491
|
if (options.all) {
|
|
1480
|
-
const
|
|
1481
|
-
if (
|
|
1492
|
+
const adminError = requireAdmin(session, config);
|
|
1493
|
+
if (adminError) return adminError;
|
|
1482
1494
|
try {
|
|
1483
1495
|
const aggregatedStats = await ctx.database.select("cave", { status: "active" }).groupBy(["userId", "userName"], { count: /* @__PURE__ */ __name((row) => import_koishi3.$.count(row.id), "count") }).execute();
|
|
1484
1496
|
if (!aggregatedStats.length) return "目前没有回声洞投稿";
|