koishi-plugin-best-cave 2.6.10 → 2.7.1

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.
@@ -0,0 +1,113 @@
1
+ import { Context, Logger } from 'koishi';
2
+ import { Config, CaveObject, StoredElement } from './index';
3
+ import { FileManager } from './FileManager';
4
+ /**
5
+ * @description 数据库 `cave_meta` 表的完整对象模型。
6
+ */
7
+ export interface CaveMetaObject {
8
+ cave: number;
9
+ keywords: string[];
10
+ description: string;
11
+ rating: number;
12
+ }
13
+ declare module 'koishi' {
14
+ interface Tables {
15
+ cave_meta: CaveMetaObject;
16
+ }
17
+ }
18
+ /**
19
+ * @class AIManager
20
+ * @description 负责 AI 分析(描述、评分、关键词)和 AI 查重。
21
+ * 通过与外部 AI 服务接口交互,实现对回声洞内容的深度分析和重复性检查。
22
+ */
23
+ export declare class AIManager {
24
+ private ctx;
25
+ private config;
26
+ private logger;
27
+ private fileManager;
28
+ private http;
29
+ /**
30
+ * @constructor
31
+ * @param {Context} ctx - Koishi 的上下文对象。
32
+ * @param {Config} config - 插件的配置信息。
33
+ * @param {Logger} logger - 日志记录器实例。
34
+ * @param {FileManager} fileManager - 文件管理器实例。
35
+ */
36
+ constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
37
+ /**
38
+ * @description 注册与 AI 功能相关的 `.ai` 子命令。
39
+ * @param {any} cave - 主 `cave` 命令实例。
40
+ */
41
+ registerCommands(cave: any): void;
42
+ /**
43
+ * @description 对新内容进行两阶段 AI 查重。
44
+ * @param {StoredElement[]} newElements - 新内容的元素数组。
45
+ * @param {{ sourceUrl: string, fileName: string }[]} newMediaToSave - 新内容中待上传的媒体文件信息。
46
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
47
+ * @returns {Promise<{ duplicate: boolean; id?: number }>} - 返回 AI 判断结果。
48
+ */
49
+ checkForDuplicates(newElements: StoredElement[], newMediaToSave: {
50
+ sourceUrl: string;
51
+ fileName: string;
52
+ }[], mediaBuffers?: {
53
+ fileName: string;
54
+ buffer: Buffer;
55
+ }[]): Promise<{
56
+ duplicate: boolean;
57
+ id?: number;
58
+ }>;
59
+ /**
60
+ * @description 分析单个回声洞,并将分析结果存入数据库。
61
+ * @param {CaveObject} cave - 需要分析的回声洞对象。
62
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
63
+ * @returns {Promise<void>}
64
+ */
65
+ analyzeAndStore(cave: CaveObject, mediaBuffers?: {
66
+ fileName: string;
67
+ buffer: Buffer;
68
+ }[]): Promise<void>;
69
+ /**
70
+ * @description 调用 AI 模型获取内容的分析结果。
71
+ * @param {StoredElement[]} elements - 内容的元素数组。
72
+ * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选) 待保存的媒体文件信息。
73
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
74
+ * @returns {Promise<Omit<CaveMetaObject, 'cave'>>} - 返回分析结果对象。
75
+ */
76
+ private getAnalysis;
77
+ /**
78
+ * @description 使用 Jaccard 相似度系数计算两组关键词的相似度。
79
+ * @param {Set<string>} setA - 第一组关键词集合。
80
+ * @param {Set<string>} setB - 第二组关键词集合。
81
+ * @returns {number} - 返回 0 到 1 之间的相似度值。
82
+ */
83
+ private calculateKeywordSimilarity;
84
+ /**
85
+ * @description 准备发送给 AI 模型的请求体(Payload)。
86
+ * @param {string} prompt - 系统提示词。
87
+ * @param {string} schemaString - JSON Schema 字符串。
88
+ * @param {StoredElement[]} elements - 内容的元素数组。
89
+ * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选) 待保存的媒体文件信息。
90
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
91
+ * @returns {Promise<{ payload: any }>} - 返回包含请求体的对象。
92
+ */
93
+ private preparePayload;
94
+ /**
95
+ * @description 准备用于 AI 精准查重的请求体(Payload)。
96
+ * @param {StoredElement[]} newElements - 新内容的元素。
97
+ * @param {CaveObject[]} existingCaves - 经过初筛的疑似重复的旧内容。
98
+ * @returns {Promise<{ payload: any }>} - 返回适用于查重场景的请求体。
99
+ */
100
+ private prepareDedupePayload;
101
+ /**
102
+ * @description 解析 AI 返回的分析响应。
103
+ * @param {any} response - AI 服务的原始响应对象。
104
+ * @returns {Omit<CaveMetaObject, 'cave'>} - 返回结构化的分析结果。
105
+ */
106
+ private parseAnalysisResponse;
107
+ /**
108
+ * @description 解析 AI 返回的查重响应。
109
+ * @param {any} response - AI 服务的原始响应对象。
110
+ * @returns {{ duplicate: boolean; id?: number }} - 返回查重结果。
111
+ */
112
+ private parseDedupeResponse;
113
+ }
package/lib/Utils.d.ts CHANGED
@@ -3,6 +3,7 @@ import { CaveObject, Config, StoredElement } from './index';
3
3
  import { FileManager } from './FileManager';
4
4
  import { HashManager, CaveHashObject } from './HashManager';
5
5
  import { PendManager } from './PendManager';
6
+ import { AIManager } from './AIManager';
6
7
  /**
7
8
  * @description 构建一条用于发送的完整回声洞消息,处理不同存储后端的资源链接。
8
9
  * @param cave 回声洞对象。
@@ -47,7 +48,7 @@ export declare function getNextCaveId(ctx: Context, reusableIds: Set<number>): P
47
48
  * @param creationTime 统一的创建时间戳,用于生成文件名。
48
49
  * @returns 包含数据库元素和待保存媒体列表的对象。
49
50
  */
50
- export declare function processMessageElements(sourceElements: h[], newId: number, session: Session, config: Config, logger: Logger, creationTime: Date): Promise<{
51
+ export declare function processMessageElements(sourceElements: h[], newId: number, session: Session, creationTime: Date): Promise<{
51
52
  finalElementsForDb: StoredElement[];
52
53
  mediaToSave: {
53
54
  sourceUrl: string;
@@ -55,20 +56,35 @@ export declare function processMessageElements(sourceElements: h[], newId: numbe
55
56
  }[];
56
57
  }>;
57
58
  /**
58
- * @description 异步处理文件上传、查重和状态更新的后台任务。
59
+ * @description 执行文本 (Simhash) 和图片 (pHash) 相似度查重。
60
+ * @returns 一个对象,指示是否发现重复项;如果未发现,则返回生成的哈希。
61
+ */
62
+ export declare function performSimilarityChecks(ctx: Context, config: Config, hashManager: HashManager, finalElementsForDb: StoredElement[], downloadedMedia: {
63
+ fileName: string;
64
+ buffer: Buffer;
65
+ }[]): Promise<{
66
+ duplicate: boolean;
67
+ message?: string;
68
+ textHashesToStore?: Omit<CaveHashObject, 'cave'>[];
69
+ imageHashesToStore?: Omit<CaveHashObject, 'cave'>[];
70
+ }>;
71
+ /**
72
+ * @description 异步处理文件上传和状态更新的后台任务。
59
73
  * @param ctx - Koishi 上下文。
60
74
  * @param config - 插件配置。
61
75
  * @param fileManager - FileManager 实例,用于保存文件。
62
76
  * @param logger - 日志记录器实例。
63
77
  * @param reviewManager - ReviewManager 实例,用于提交审核。
64
78
  * @param cave - 刚刚在数据库中创建的 `preload` 状态的回声洞对象。
65
- * @param mediaToSave - 需要下载和处理的媒体文件列表。
79
+ * @param downloadedMedia - 需要保存的媒体文件及其 Buffer。
66
80
  * @param reusableIds - 可复用 ID 的内存缓存。
67
81
  * @param session - 触发此操作的用户会话,用于发送反馈。
68
82
  * @param hashManager - HashManager 实例,如果启用则用于哈希计算和比较。
69
83
  * @param textHashesToStore - 已预先计算好的、待存入数据库的文本哈希对象数组。
84
+ * @param imageHashesToStore - 已预先计算好的、待存入数据库的图片哈希对象数组。
85
+ * @param aiManager - AIManager 实例,如果启用则用于 AI 分析。
70
86
  */
71
- export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: PendManager, cave: CaveObject, mediaToToSave: {
72
- sourceUrl: string;
87
+ export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: PendManager, cave: CaveObject, downloadedMedia: {
73
88
  fileName: string;
74
- }[], reusableIds: Set<number>, session: Session, hashManager: HashManager, textHashesToStore: Omit<CaveHashObject, 'cave'>[]): Promise<void>;
89
+ buffer: Buffer;
90
+ }[], reusableIds: Set<number>, session: Session, hashManager: HashManager, textHashesToStore: Omit<CaveHashObject, 'cave'>[], imageHashesToStore: Omit<CaveHashObject, 'cave'>[], aiManager: AIManager | null): Promise<void>;
package/lib/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Context, Schema } from 'koishi';
2
2
  import { CaveHashObject } from './HashManager';
3
+ import { CaveMetaObject } from './AIManager';
3
4
  export declare const name = "best-cave";
4
5
  export declare const inject: string[];
5
6
  export declare const usage = "\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #4a6ee0;\">\uD83D\uDCCC \u63D2\u4EF6\u8BF4\u660E</h2>\n <p>\uD83D\uDCD6 <strong>\u4F7F\u7528\u6587\u6863</strong>\uFF1A\u8BF7\u70B9\u51FB\u5DE6\u4E0A\u89D2\u7684 <strong>\u63D2\u4EF6\u4E3B\u9875</strong> \u67E5\u770B\u63D2\u4EF6\u4F7F\u7528\u6587\u6863</p>\n <p>\uD83D\uDD0D <strong>\u66F4\u591A\u63D2\u4EF6</strong>\uFF1A\u53EF\u8BBF\u95EE <a href=\"https://github.com/YisRime\" style=\"color:#4a6ee0;text-decoration:none;\">\u82E1\u6DDE\u7684 GitHub</a> \u67E5\u770B\u672C\u4EBA\u7684\u6240\u6709\u63D2\u4EF6</p>\n</div>\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #e0574a;\">\u2764\uFE0F \u652F\u6301\u4E0E\u53CD\u9988</h2>\n <p>\uD83C\uDF1F \u559C\u6B22\u8FD9\u4E2A\u63D2\u4EF6\uFF1F\u8BF7\u5728 <a href=\"https://github.com/YisRime\" style=\"color:#e0574a;text-decoration:none;\">GitHub</a> \u4E0A\u7ED9\u6211\u4E00\u4E2A Star\uFF01</p>\n <p>\uD83D\uDC1B \u9047\u5230\u95EE\u9898\uFF1F\u8BF7\u901A\u8FC7 <strong>Issues</strong> \u63D0\u4EA4\u53CD\u9988\uFF0C\u6216\u52A0\u5165 QQ \u7FA4 <a href=\"https://qm.qq.com/q/PdLMx9Jowq\" style=\"color:#e0574a;text-decoration:none;\"><strong>855571375</strong></a> \u8FDB\u884C\u4EA4\u6D41</p>\n</div>\n";
@@ -35,6 +36,7 @@ declare module 'koishi' {
35
36
  interface Tables {
36
37
  cave: CaveObject;
37
38
  cave_hash: CaveHashObject;
39
+ cave_meta: CaveMetaObject;
38
40
  }
39
41
  }
40
42
  export interface Config {
@@ -55,6 +57,14 @@ export interface Config {
55
57
  secretAccessKey?: string;
56
58
  bucket?: string;
57
59
  publicUrl?: string;
60
+ enableAI: boolean;
61
+ aiEndpoint?: string;
62
+ aiApiKey?: string;
63
+ aiModel?: string;
64
+ AnalysePrompt?: string;
65
+ aiCheckPrompt?: string;
66
+ aiAnalyseSchema?: string;
67
+ aiCheckSchema?: string;
58
68
  }
59
69
  export declare const Config: Schema<Config>;
60
70
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -37,7 +37,7 @@ __export(src_exports, {
37
37
  usage: () => usage
38
38
  });
39
39
  module.exports = __toCommonJS(src_exports);
40
- var import_koishi3 = require("koishi");
40
+ var import_koishi4 = require("koishi");
41
41
 
42
42
  // src/FileManager.ts
43
43
  var import_client_s3 = require("@aws-sdk/client-s3");
@@ -289,7 +289,7 @@ var DataManager = class {
289
289
  const newCavesFromConflicts = conflictingCaves.map((cave) => {
290
290
  maxId++;
291
291
  this.logger.info(`回声洞(${cave.id})已转移至(${maxId})`);
292
- return { ...cave, maxId, status: "active" };
292
+ return { ...cave, id: maxId, status: "active" };
293
293
  });
294
294
  const finalCavesToUpsert = [...nonConflictingCaves, ...newCavesFromConflicts];
295
295
  if (finalCavesToUpsert.length > 0) await this.ctx.database.upsert("cave", finalCavesToUpsert);
@@ -425,6 +425,7 @@ async function cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds) {
425
425
  idsToDelete.forEach((id) => reusableIds.add(id));
426
426
  await ctx.database.remove("cave", { id: { $in: idsToDelete } });
427
427
  await ctx.database.remove("cave_hash", { cave: { $in: idsToDelete } });
428
+ await ctx.database.remove("cave_meta", { cave: { $in: idsToDelete } });
428
429
  } catch (error) {
429
430
  logger2.error("清理回声洞时发生错误:", error);
430
431
  }
@@ -457,7 +458,7 @@ async function getNextCaveId(ctx, reusableIds) {
457
458
  return newId;
458
459
  }
459
460
  __name(getNextCaveId, "getNextCaveId");
460
- async function processMessageElements(sourceElements, newId, session, config, logger2, creationTime) {
461
+ async function processMessageElements(sourceElements, newId, session, creationTime) {
461
462
  const mediaToSave = [];
462
463
  let mediaIndex = 0;
463
464
  const typeMap = { "img": "image", "image": "image", "video": "video", "audio": "audio", "file": "file", "text": "text", "at": "at", "forward": "forward", "reply": "reply", "face": "face" };
@@ -543,34 +544,47 @@ async function processMessageElements(sourceElements, newId, session, config, lo
543
544
  return { finalElementsForDb, mediaToSave };
544
545
  }
545
546
  __name(processMessageElements, "processMessageElements");
546
- async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, mediaToToSave, reusableIds, session, hashManager, textHashesToStore) {
547
- try {
548
- const downloadedMedia = [];
549
- const imageHashesToStore = [];
550
- const allExistingImageHashes = hashManager ? await ctx.database.get("cave_hash", { type: "phash" }) : [];
551
- for (const media of mediaToToSave) {
552
- const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
553
- downloadedMedia.push({ fileName: media.fileName, buffer });
554
- if (hashManager && [".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) {
555
- const imageHash = await hashManager.generatePHash(buffer, 256);
547
+ async function performSimilarityChecks(ctx, config, hashManager, finalElementsForDb, downloadedMedia) {
548
+ const textHashesToStore = [];
549
+ const imageHashesToStore = [];
550
+ const combinedText = finalElementsForDb.filter((el) => el.type === "text" && typeof el.content === "string").map((el) => el.content).join(" ");
551
+ if (combinedText) {
552
+ const newSimhash = hashManager.generateTextSimhash(combinedText);
553
+ if (newSimhash) {
554
+ const existingTextHashes = await ctx.database.get("cave_hash", { type: "simhash" });
555
+ for (const existing of existingTextHashes) {
556
+ const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
557
+ if (similarity >= config.textThreshold) return { duplicate: true, message: `文本与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值` };
558
+ }
559
+ textHashesToStore.push({ hash: newSimhash, type: "simhash" });
560
+ }
561
+ }
562
+ if (downloadedMedia.length > 0) {
563
+ const allExistingImageHashes = await ctx.database.get("cave_hash", { type: "phash" });
564
+ for (const media of downloadedMedia) {
565
+ if ([".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) {
566
+ const imageHash = await hashManager.generatePHash(media.buffer, 256);
556
567
  for (const existing of allExistingImageHashes) {
557
568
  const similarity = hashManager.calculateSimilarity(imageHash, existing.hash);
558
- if (similarity >= config.imageThreshold) {
559
- await session.send(`图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`);
560
- await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
561
- cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
562
- return;
563
- }
569
+ if (similarity >= config.imageThreshold) return { duplicate: true, message: `图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值` };
564
570
  }
565
571
  imageHashesToStore.push({ hash: imageHash, type: "phash" });
572
+ allExistingImageHashes.push({ cave: 0, hash: imageHash, type: "phash" });
566
573
  }
567
574
  }
575
+ }
576
+ return { duplicate: false, textHashesToStore, imageHashesToStore };
577
+ }
578
+ __name(performSimilarityChecks, "performSimilarityChecks");
579
+ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, downloadedMedia, reusableIds, session, hashManager, textHashesToStore, imageHashesToStore, aiManager) {
580
+ try {
581
+ if (aiManager) await aiManager.analyzeAndStore(cave, downloadedMedia);
568
582
  await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
569
583
  const needsReview = config.enablePend && session.channelId !== config.adminChannel?.split(":")[1];
570
584
  const finalStatus = needsReview ? "pending" : "active";
571
585
  await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
572
586
  if (hashManager) {
573
- const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: cave.id }));
587
+ const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h5) => ({ ...h5, cave: cave.id }));
574
588
  if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert);
575
589
  }
576
590
  if (finalStatus === "pending" && reviewManager) {
@@ -745,7 +759,7 @@ var HashManager = class {
745
759
  async generateHashesForHistoricalCaves() {
746
760
  const allCaves = await this.ctx.database.get("cave", { status: "active" });
747
761
  const existingHashes = await this.ctx.database.get("cave_hash", {});
748
- const existingHashSet = new Set(existingHashes.map((h4) => `${h4.cave}-${h4.hash}-${h4.type}`));
762
+ const existingHashSet = new Set(existingHashes.map((h5) => `${h5.cave}-${h5.hash}-${h5.type}`));
749
763
  if (allCaves.length === 0) return "无需补全回声洞哈希";
750
764
  this.logger.info(`开始补全 ${allCaves.length} 个回声洞的哈希...`);
751
765
  let hashesToInsert = [];
@@ -819,7 +833,7 @@ var HashManager = class {
819
833
  const textThreshold = options.textThreshold ?? this.config.textThreshold;
820
834
  const imageThreshold = options.imageThreshold ?? this.config.imageThreshold;
821
835
  const allHashes = await this.ctx.database.get("cave_hash", {});
822
- const allCaveIds = [...new Set(allHashes.map((h4) => h4.cave))];
836
+ const allCaveIds = [...new Set(allHashes.map((h5) => h5.cave))];
823
837
  const textHashes = /* @__PURE__ */ new Map();
824
838
  const imageHashes = /* @__PURE__ */ new Map();
825
839
  for (const hash of allHashes) {
@@ -965,6 +979,255 @@ function hexToBinary(hex) {
965
979
  }
966
980
  __name(hexToBinary, "hexToBinary");
967
981
 
982
+ // src/AIManager.ts
983
+ var import_koishi3 = require("koishi");
984
+ var path3 = __toESM(require("path"));
985
+ var AIManager = class {
986
+ /**
987
+ * @constructor
988
+ * @param {Context} ctx - Koishi 的上下文对象。
989
+ * @param {Config} config - 插件的配置信息。
990
+ * @param {Logger} logger - 日志记录器实例。
991
+ * @param {FileManager} fileManager - 文件管理器实例。
992
+ */
993
+ constructor(ctx, config, logger2, fileManager) {
994
+ this.ctx = ctx;
995
+ this.config = config;
996
+ this.logger = logger2;
997
+ this.fileManager = fileManager;
998
+ this.http = ctx.http;
999
+ this.ctx.model.extend("cave_meta", {
1000
+ cave: "unsigned",
1001
+ keywords: "json",
1002
+ description: "text",
1003
+ rating: "unsigned"
1004
+ }, {
1005
+ primary: "cave"
1006
+ });
1007
+ }
1008
+ static {
1009
+ __name(this, "AIManager");
1010
+ }
1011
+ http;
1012
+ /**
1013
+ * @description 注册与 AI 功能相关的 `.ai` 子命令。
1014
+ * @param {any} cave - 主 `cave` 命令实例。
1015
+ */
1016
+ registerCommands(cave) {
1017
+ cave.subcommand(".ai", "分析回声洞", { hidden: true, authority: 4 }).usage("分析尚未分析的回声洞,补全回声洞记录。").action(async ({ session }) => {
1018
+ if (session.channelId !== this.config.adminChannel?.split(":")) return "此指令仅限在管理群组中使用";
1019
+ try {
1020
+ const allCaves = await this.ctx.database.get("cave", { status: "active" });
1021
+ const analyzedCaveIds = new Set((await this.ctx.database.get("cave_meta", {})).map((meta) => meta.cave));
1022
+ const cavesToAnalyze = allCaves.filter((cave2) => !analyzedCaveIds.has(cave2.id));
1023
+ if (cavesToAnalyze.length === 0) return "无需分析回声洞";
1024
+ await session.send(`开始分析 ${cavesToAnalyze.length} 个回声洞...`);
1025
+ let totalSuccessCount = 0;
1026
+ for (let i = 0; i < cavesToAnalyze.length; i += 5) {
1027
+ const batch = cavesToAnalyze.slice(i, i + 5);
1028
+ this.logger.info(`[${totalSuccessCount}/${cavesToAnalyze.length}] 正在分析 ${batch.length} 条回声洞...`);
1029
+ await Promise.all(batch.map((cave2) => this.analyzeAndStore(cave2)));
1030
+ totalSuccessCount += batch.length;
1031
+ }
1032
+ return `已分析 ${totalSuccessCount} 个回声洞`;
1033
+ } catch (error) {
1034
+ this.logger.error("已中断分析回声洞:", error);
1035
+ return `分析回声洞失败:${error.message}`;
1036
+ }
1037
+ });
1038
+ cave.subcommand(".desc <id:posint>", "查询回声洞").action(async ({}, id) => {
1039
+ if (!id) return "请输入要查看的回声洞序号";
1040
+ try {
1041
+ const [meta] = await this.ctx.database.get("cave_meta", { cave: id });
1042
+ if (!meta) return `回声洞(${id})尚未分析`;
1043
+ const keywordsText = meta.keywords.join(", ");
1044
+ const report = [
1045
+ `回声洞(${id})分析结果:`,
1046
+ `描述:${meta.description}`,
1047
+ `关键词:${keywordsText}`,
1048
+ `评分:${meta.rating}/100`
1049
+ ];
1050
+ return import_koishi3.h.text(report.join("\n"));
1051
+ } catch (error) {
1052
+ this.logger.error(`查询回声洞(${id})失败:`, error);
1053
+ return "查询失败,请稍后再试";
1054
+ }
1055
+ });
1056
+ }
1057
+ /**
1058
+ * @description 对新内容进行两阶段 AI 查重。
1059
+ * @param {StoredElement[]} newElements - 新内容的元素数组。
1060
+ * @param {{ sourceUrl: string, fileName: string }[]} newMediaToSave - 新内容中待上传的媒体文件信息。
1061
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
1062
+ * @returns {Promise<{ duplicate: boolean; id?: number }>} - 返回 AI 判断结果。
1063
+ */
1064
+ async checkForDuplicates(newElements, newMediaToSave, mediaBuffers) {
1065
+ try {
1066
+ const newAnalysis = await this.getAnalysis(newElements, newMediaToSave, mediaBuffers);
1067
+ if (!newAnalysis || newAnalysis.keywords.length === 0) return { duplicate: false };
1068
+ const newKeywords = new Set(newAnalysis.keywords);
1069
+ const allMeta = await this.ctx.database.get("cave_meta", {});
1070
+ const potentialDuplicates = [];
1071
+ for (const meta of allMeta) {
1072
+ const existingKeywords = new Set(meta.keywords);
1073
+ const similarity = this.calculateKeywordSimilarity(newKeywords, existingKeywords);
1074
+ if (similarity * 100 >= 80) {
1075
+ const [cave] = await this.ctx.database.get("cave", { id: meta.cave });
1076
+ if (cave) potentialDuplicates.push(cave);
1077
+ }
1078
+ }
1079
+ if (potentialDuplicates.length === 0) return { duplicate: false };
1080
+ const { payload } = await this.prepareDedupePayload(newElements, potentialDuplicates);
1081
+ const response = await this.http.post(`${this.config.aiEndpoint}:generateContent?key=${this.config.aiApiKey}`, payload, { headers: { "Content-Type": "application/json" }, timeout: 9e4 });
1082
+ return this.parseDedupeResponse(response);
1083
+ } catch (error) {
1084
+ this.logger.error("查重回声洞出错:", error);
1085
+ return { duplicate: false };
1086
+ }
1087
+ }
1088
+ /**
1089
+ * @description 分析单个回声洞,并将分析结果存入数据库。
1090
+ * @param {CaveObject} cave - 需要分析的回声洞对象。
1091
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
1092
+ * @returns {Promise<void>}
1093
+ */
1094
+ async analyzeAndStore(cave, mediaBuffers) {
1095
+ try {
1096
+ const analysisResult = await this.getAnalysis(cave.elements, void 0, mediaBuffers);
1097
+ if (analysisResult) await this.ctx.database.upsert("cave_meta", [{ cave: cave.id, ...analysisResult }]);
1098
+ } catch (error) {
1099
+ this.logger.error(`分析回声洞(${cave.id})失败:`, error);
1100
+ }
1101
+ }
1102
+ /**
1103
+ * @description 调用 AI 模型获取内容的分析结果。
1104
+ * @param {StoredElement[]} elements - 内容的元素数组。
1105
+ * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选) 待保存的媒体文件信息。
1106
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
1107
+ * @returns {Promise<Omit<CaveMetaObject, 'cave'>>} - 返回分析结果对象。
1108
+ */
1109
+ async getAnalysis(elements, mediaToSave, mediaBuffers) {
1110
+ const { payload } = await this.preparePayload(this.config.AnalysePrompt, this.config.aiAnalyseSchema, elements, mediaToSave, mediaBuffers);
1111
+ if (!payload.contents) return null;
1112
+ const response = await this.http.post(`${this.config.aiEndpoint}:generateContent?key=${this.config.aiApiKey}`, payload, { headers: { "Content-Type": "application/json" }, timeout: 6e4 });
1113
+ return this.parseAnalysisResponse(response);
1114
+ }
1115
+ /**
1116
+ * @description 使用 Jaccard 相似度系数计算两组关键词的相似度。
1117
+ * @param {Set<string>} setA - 第一组关键词集合。
1118
+ * @param {Set<string>} setB - 第二组关键词集合。
1119
+ * @returns {number} - 返回 0 到 1 之间的相似度值。
1120
+ */
1121
+ calculateKeywordSimilarity(setA, setB) {
1122
+ const intersection = new Set([...setA].filter((x) => setB.has(x)));
1123
+ const union = /* @__PURE__ */ new Set([...setA, ...setB]);
1124
+ return union.size === 0 ? 0 : intersection.size / union.size;
1125
+ }
1126
+ /**
1127
+ * @description 准备发送给 AI 模型的请求体(Payload)。
1128
+ * @param {string} prompt - 系统提示词。
1129
+ * @param {string} schemaString - JSON Schema 字符串。
1130
+ * @param {StoredElement[]} elements - 内容的元素数组。
1131
+ * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选) 待保存的媒体文件信息。
1132
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
1133
+ * @returns {Promise<{ payload: any }>} - 返回包含请求体的对象。
1134
+ */
1135
+ async preparePayload(prompt, schemaString, elements, mediaToSave, mediaBuffers) {
1136
+ const parts = [{ text: prompt }];
1137
+ const combinedText = elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
1138
+ if (combinedText) parts.push({ 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
+ parts.push({ inline_data: { mime_type: mimeType, data: buffer.toString("base64") } });
1155
+ }
1156
+ } catch (error) {
1157
+ this.logger.warn(`分析内容(${el.file})失败:`, error);
1158
+ }
1159
+ }
1160
+ if (parts.length <= 1) return { payload: {} };
1161
+ try {
1162
+ const schema = JSON.parse(schemaString);
1163
+ return { payload: { contents: [{ parts }], generationConfig: { response_schema: schema } } };
1164
+ } catch (error) {
1165
+ this.logger.error("解析JSON Schema失败:", error);
1166
+ return { payload: {} };
1167
+ }
1168
+ }
1169
+ /**
1170
+ * @description 准备用于 AI 精准查重的请求体(Payload)。
1171
+ * @param {StoredElement[]} newElements - 新内容的元素。
1172
+ * @param {CaveObject[]} existingCaves - 经过初筛的疑似重复的旧内容。
1173
+ * @returns {Promise<{ payload: any }>} - 返回适用于查重场景的请求体。
1174
+ */
1175
+ async prepareDedupePayload(newElements, existingCaves) {
1176
+ const formatContent = /* @__PURE__ */ __name((elements) => elements.filter((el) => el.type === "text").map((el) => el.content).join(" "), "formatContent");
1177
+ const payloadContent = JSON.stringify({
1178
+ new_content: { text: formatContent(newElements) },
1179
+ existing_contents: existingCaves.map((cave) => ({ id: cave.id, text: formatContent(cave.elements) }))
1180
+ });
1181
+ const fullPrompt = `${this.config.aiCheckPrompt}
1182
+
1183
+ 以下是需要处理的数据:
1184
+ ${payloadContent}`;
1185
+ try {
1186
+ const schema = JSON.parse(this.config.aiCheckSchema);
1187
+ return { payload: { contents: [{ parts: [{ text: fullPrompt }] }], generationConfig: { response_schema: schema } } };
1188
+ } catch (error) {
1189
+ this.logger.error("解析查重JSON Schema失败:", error);
1190
+ return { payload: {} };
1191
+ }
1192
+ }
1193
+ /**
1194
+ * @description 解析 AI 返回的分析响应。
1195
+ * @param {any} response - AI 服务的原始响应对象。
1196
+ * @returns {Omit<CaveMetaObject, 'cave'>} - 返回结构化的分析结果。
1197
+ */
1198
+ parseAnalysisResponse(response) {
1199
+ try {
1200
+ const content = response.candidates.content.parts.text;
1201
+ const parsed = JSON.parse(content);
1202
+ const keywords = Array.isArray(parsed.keywords) ? parsed.keywords : [];
1203
+ return {
1204
+ keywords,
1205
+ description: parsed.description || "无",
1206
+ rating: Math.max(0, Math.min(100, parsed.rating || 0))
1207
+ };
1208
+ } catch (error) {
1209
+ this.logger.error("分析响应解析失败:", error, "原始响应:", JSON.stringify(response));
1210
+ return { keywords: [], description: "解析失败", rating: 0 };
1211
+ }
1212
+ }
1213
+ /**
1214
+ * @description 解析 AI 返回的查重响应。
1215
+ * @param {any} response - AI 服务的原始响应对象。
1216
+ * @returns {{ duplicate: boolean; id?: number }} - 返回查重结果。
1217
+ */
1218
+ parseDedupeResponse(response) {
1219
+ try {
1220
+ const content = response.candidates.content.parts.text;
1221
+ const parsed = JSON.parse(content);
1222
+ if (parsed.duplicate === true && parsed.id) return { duplicate: true, id: Number(parsed.id) };
1223
+ return { duplicate: false };
1224
+ } catch (error) {
1225
+ this.logger.error("查重响应解析失败:", error, "原始响应:", JSON.stringify(response));
1226
+ return { duplicate: false };
1227
+ }
1228
+ }
1229
+ };
1230
+
968
1231
  // src/index.ts
969
1232
  var name = "best-cave";
970
1233
  var inject = ["database"];
@@ -980,30 +1243,77 @@ var usage = `
980
1243
  <p>🐛 遇到问题?请通过 <strong>Issues</strong> 提交反馈,或加入 QQ 群 <a href="https://qm.qq.com/q/PdLMx9Jowq" style="color:#e0574a;text-decoration:none;"><strong>855571375</strong></a> 进行交流</p>
981
1244
  </div>
982
1245
  `;
983
- var logger = new import_koishi3.Logger("best-cave");
984
- var Config = import_koishi3.Schema.intersect([
985
- import_koishi3.Schema.object({
986
- perChannel: import_koishi3.Schema.boolean().default(false).description("启用分群模式"),
987
- enableName: import_koishi3.Schema.boolean().default(false).description("启用自定义昵称"),
988
- enableIO: import_koishi3.Schema.boolean().default(false).description("启用导入导出"),
989
- adminChannel: import_koishi3.Schema.string().default("onebot:").description("管理群组 ID"),
990
- caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本(参见 README)")
1246
+ var logger = new import_koishi4.Logger("best-cave");
1247
+ var Config = import_koishi4.Schema.intersect([
1248
+ import_koishi4.Schema.object({
1249
+ perChannel: import_koishi4.Schema.boolean().default(false).description("启用分群模式"),
1250
+ enableName: import_koishi4.Schema.boolean().default(false).description("启用自定义昵称"),
1251
+ enableIO: import_koishi4.Schema.boolean().default(false).description("启用导入导出"),
1252
+ adminChannel: import_koishi4.Schema.string().default("onebot:").description("管理群组 ID"),
1253
+ caveFormat: import_koishi4.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本(参见 README)")
991
1254
  }).description("基础配置"),
992
- import_koishi3.Schema.object({
993
- enablePend: import_koishi3.Schema.boolean().default(false).description("启用审核"),
994
- enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
995
- textThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(90).description("文本相似度阈值 (%)"),
996
- imageThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(90).description("图片相似度阈值 (%)")
1255
+ import_koishi4.Schema.object({
1256
+ enablePend: import_koishi4.Schema.boolean().default(false).description("启用审核"),
1257
+ enableSimilarity: import_koishi4.Schema.boolean().default(false).description("启用查重"),
1258
+ textThreshold: import_koishi4.Schema.number().min(0).max(100).step(0.01).default(90).description("文本相似度阈值 (%)"),
1259
+ imageThreshold: import_koishi4.Schema.number().min(0).max(100).step(0.01).default(90).description("图片相似度阈值 (%)")
997
1260
  }).description("复核配置"),
998
- import_koishi3.Schema.object({
999
- localPath: import_koishi3.Schema.string().description("文件映射路径"),
1000
- enableS3: import_koishi3.Schema.boolean().default(false).description("启用 S3 存储"),
1001
- publicUrl: import_koishi3.Schema.string().description("公共访问 URL").role("link"),
1002
- endpoint: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link"),
1003
- bucket: import_koishi3.Schema.string().description("存储桶 (Bucket)"),
1004
- region: import_koishi3.Schema.string().default("auto").description("区域 (Region)"),
1005
- accessKeyId: import_koishi3.Schema.string().description("Access Key ID").role("secret"),
1006
- secretAccessKey: import_koishi3.Schema.string().description("Secret Access Key").role("secret")
1261
+ import_koishi4.Schema.object({
1262
+ enableAI: import_koishi4.Schema.boolean().default(false).description("启用 AI"),
1263
+ aiEndpoint: import_koishi4.Schema.string().description("端点 (Endpoint)").role("link").default("https://generativelanguage.googleapis.com/v1beta"),
1264
+ aiApiKey: import_koishi4.Schema.string().description("密钥 (Key)").role("secret"),
1265
+ aiModel: import_koishi4.Schema.string().description("模型").default("gemini-2.5-flash"),
1266
+ AnalysePrompt: import_koishi4.Schema.string().role("textarea").default(`你是一位内容分析专家。请分析我提供的内容,总结关键词,概括内容并进行评分。`).description("分析提示词 (Prompt)"),
1267
+ aiAnalyseSchema: import_koishi4.Schema.string().role("textarea").default(
1268
+ `{
1269
+ "type": "object",
1270
+ "properties": {
1271
+ "keywords": {
1272
+ "type": "array",
1273
+ "items": { "type": "string" },
1274
+ "description": "使用尽可能多的关键词准确形容内容"
1275
+ },
1276
+ "description": {
1277
+ "type": "string",
1278
+ "description": "概括或描述这部分内容"
1279
+ },
1280
+ "rating": {
1281
+ "type": "integer",
1282
+ "description": "对内容的综合质量进行评分",
1283
+ "minimum": 0,
1284
+ "maximum": 100
1285
+ }
1286
+ },
1287
+ "required": ["keywords", "description", "rating"]
1288
+ }`
1289
+ ).description("分析输出模式 (JSON Schema)"),
1290
+ aiCheckPrompt: import_koishi4.Schema.string().role("textarea").default(`你是一位内容查重专家。请判断我提供的"新内容"是否与"已有内容"重复或高度相似。`).description("查重提示词 (Prompt)"),
1291
+ aiCheckSchema: import_koishi4.Schema.string().role("textarea").default(
1292
+ `{
1293
+ "type": "object",
1294
+ "properties": {
1295
+ "duplicate": {
1296
+ "type": "boolean",
1297
+ "description": "新内容是否与已有内容重复"
1298
+ },
1299
+ "id": {
1300
+ "type": "integer",
1301
+ "description": "如果重复,此为第一个重复的已有内容的ID"
1302
+ }
1303
+ },
1304
+ "required": ["duplicate"]
1305
+ }`
1306
+ ).description("查重输出模式 (JSON Schema)")
1307
+ }).description("模型配置"),
1308
+ import_koishi4.Schema.object({
1309
+ localPath: import_koishi4.Schema.string().description("文件映射路径"),
1310
+ enableS3: import_koishi4.Schema.boolean().default(false).description("启用 S3 存储"),
1311
+ publicUrl: import_koishi4.Schema.string().description("公共访问 URL").role("link"),
1312
+ endpoint: import_koishi4.Schema.string().description("端点 (Endpoint)").role("link"),
1313
+ bucket: import_koishi4.Schema.string().description("存储桶 (Bucket)"),
1314
+ region: import_koishi4.Schema.string().default("auto").description("区域 (Region)"),
1315
+ accessKeyId: import_koishi4.Schema.string().description("Access Key ID").role("secret"),
1316
+ secretAccessKey: import_koishi4.Schema.string().description("Secret Access Key").role("secret")
1007
1317
  }).description("存储配置")
1008
1318
  ]);
1009
1319
  function apply(ctx, config) {
@@ -1025,6 +1335,7 @@ function apply(ctx, config) {
1025
1335
  const reviewManager = config.enablePend ? new PendManager(ctx, config, fileManager, logger, reusableIds) : null;
1026
1336
  const hashManager = config.enableSimilarity ? new HashManager(ctx, config, logger, fileManager) : null;
1027
1337
  const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger) : null;
1338
+ const aiManager = config.enableAI ? new AIManager(ctx, config, logger, fileManager) : null;
1028
1339
  ctx.on("ready", async () => {
1029
1340
  try {
1030
1341
  const staleCaves = await ctx.database.get("cave", { status: "preload" });
@@ -1049,7 +1360,7 @@ function apply(ctx, config) {
1049
1360
  const randomId = candidates[Math.floor(Math.random() * candidates.length)].id;
1050
1361
  const [randomCave] = await ctx.database.get("cave", { ...query, id: randomId });
1051
1362
  const messages = await buildCaveMessage(randomCave, config, fileManager, logger, session.platform);
1052
- for (const message of messages) if (message.length > 0) await session.send(import_koishi3.h.normalize(message));
1363
+ for (const message of messages) if (message.length > 0) await session.send(import_koishi4.h.normalize(message));
1053
1364
  } catch (error) {
1054
1365
  logger.error("随机获取回声洞失败:", error);
1055
1366
  return "随机获取回声洞失败";
@@ -1061,34 +1372,38 @@ function apply(ctx, config) {
1061
1372
  if (session.quote?.elements) {
1062
1373
  sourceElements = session.quote.elements;
1063
1374
  } else if (content?.trim()) {
1064
- sourceElements = import_koishi3.h.parse(content);
1375
+ sourceElements = import_koishi4.h.parse(content);
1065
1376
  } else {
1066
1377
  await session.send("请在一分钟内发送你要添加的内容");
1067
1378
  const reply = await session.prompt(6e4);
1068
1379
  if (!reply) return "等待操作超时";
1069
- sourceElements = import_koishi3.h.parse(reply);
1380
+ sourceElements = import_koishi4.h.parse(reply);
1070
1381
  }
1071
1382
  const newId = await getNextCaveId(ctx, reusableIds);
1072
1383
  const creationTime = /* @__PURE__ */ new Date();
1073
- const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session, config, logger, creationTime);
1384
+ const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session, creationTime);
1074
1385
  if (finalElementsForDb.length === 0) return "无可添加内容";
1075
- const textHashesToStore = [];
1076
- if (hashManager) {
1077
- const combinedText = finalElementsForDb.filter((el) => el.type === "text" && typeof el.content === "string").map((el) => el.content).join(" ");
1078
- if (combinedText) {
1079
- const newSimhash = hashManager.generateTextSimhash(combinedText);
1080
- if (newSimhash) {
1081
- const existingTextHashes = await ctx.database.get("cave_hash", { type: "simhash" });
1082
- for (const existing of existingTextHashes) {
1083
- const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
1084
- if (similarity >= config.textThreshold) return `文本与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`;
1085
- }
1086
- textHashesToStore.push({ hash: newSimhash, type: "simhash" });
1087
- }
1386
+ const hasMedia = mediaToSave.length > 0;
1387
+ const downloadedMedia = [];
1388
+ if (hasMedia) {
1389
+ for (const media of mediaToSave) {
1390
+ const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
1391
+ downloadedMedia.push({ fileName: media.fileName, buffer });
1088
1392
  }
1089
1393
  }
1394
+ let textHashesToStore = [];
1395
+ let imageHashesToStore = [];
1396
+ if (hashManager) {
1397
+ const checkResult = await performSimilarityChecks(ctx, config, hashManager, finalElementsForDb, downloadedMedia);
1398
+ if (checkResult.duplicate) return checkResult.message;
1399
+ textHashesToStore = checkResult.textHashesToStore;
1400
+ imageHashesToStore = checkResult.imageHashesToStore;
1401
+ }
1402
+ if (aiManager) {
1403
+ const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb, mediaToSave, downloadedMedia);
1404
+ if (duplicateResult && duplicateResult.duplicate) return `内容与回声洞(${duplicateResult.id})重复`;
1405
+ }
1090
1406
  const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
1091
- const hasMedia = mediaToSave.length > 0;
1092
1407
  const needsReview = config.enablePend && session.channelId !== config.adminChannel?.split(":")[1];
1093
1408
  const initialStatus = hasMedia ? "preload" : needsReview ? "pending" : "active";
1094
1409
  const newCave = await ctx.database.create("cave", {
@@ -1101,9 +1416,10 @@ function apply(ctx, config) {
1101
1416
  time: creationTime
1102
1417
  });
1103
1418
  if (hasMedia) {
1104
- handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave, reusableIds, session, hashManager, textHashesToStore);
1419
+ handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, downloadedMedia, reusableIds, session, hashManager, textHashesToStore, imageHashesToStore, aiManager);
1105
1420
  } else {
1106
- if (hashManager && textHashesToStore.length > 0) await ctx.database.upsert("cave_hash", textHashesToStore.map((h4) => ({ ...h4, cave: newCave.id })));
1421
+ if (aiManager) await aiManager.analyzeAndStore(newCave);
1422
+ if (hashManager && textHashesToStore.length > 0) await ctx.database.upsert("cave_hash", textHashesToStore.map((h5) => ({ ...h5, cave: newCave.id })));
1107
1423
  if (initialStatus === "pending") reviewManager.sendForPend(newCave);
1108
1424
  }
1109
1425
  return needsReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
@@ -1118,7 +1434,7 @@ function apply(ctx, config) {
1118
1434
  const [targetCave] = await ctx.database.get("cave", { ...getScopeQuery(session, config), id });
1119
1435
  if (!targetCave) return `回声洞(${id})不存在`;
1120
1436
  const messages = await buildCaveMessage(targetCave, config, fileManager, logger, session.platform);
1121
- for (const message of messages) if (message.length > 0) await session.send(import_koishi3.h.normalize(message));
1437
+ for (const message of messages) if (message.length > 0) await session.send(import_koishi4.h.normalize(message));
1122
1438
  } catch (error) {
1123
1439
  logger.error(`查看回声洞(${id})失败:`, error);
1124
1440
  return "查看失败,请稍后再试";
@@ -1135,7 +1451,7 @@ function apply(ctx, config) {
1135
1451
  await ctx.database.upsert("cave", [{ id, status: "delete" }]);
1136
1452
  const caveMessages = await buildCaveMessage(targetCave, config, fileManager, logger, session.platform, "已删除");
1137
1453
  cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
1138
- for (const message of caveMessages) if (message.length > 0) await session.send(import_koishi3.h.normalize(message));
1454
+ for (const message of caveMessages) if (message.length > 0) await session.send(import_koishi4.h.normalize(message));
1139
1455
  } catch (error) {
1140
1456
  logger.error(`标记回声洞(${id})失败:`, error);
1141
1457
  return "删除失败,请稍后再试";
@@ -1146,7 +1462,7 @@ function apply(ctx, config) {
1146
1462
  const adminChannelId = config.adminChannel?.split(":")[1];
1147
1463
  if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用";
1148
1464
  try {
1149
- const aggregatedStats = await ctx.database.select("cave", { status: "active" }).groupBy(["userId", "userName"], { count: /* @__PURE__ */ __name((row) => import_koishi3.$.count(row.id), "count") }).execute();
1465
+ const aggregatedStats = await ctx.database.select("cave", { status: "active" }).groupBy(["userId", "userName"], { count: /* @__PURE__ */ __name((row) => import_koishi4.$.count(row.id), "count") }).execute();
1150
1466
  if (!aggregatedStats.length) return "目前没有回声洞投稿";
1151
1467
  const userStats = /* @__PURE__ */ new Map();
1152
1468
  for (const stat of aggregatedStats) {
@@ -1185,6 +1501,7 @@ ${caveIds}`;
1185
1501
  if (dataManager) dataManager.registerCommands(cave);
1186
1502
  if (reviewManager) reviewManager.registerCommands(cave);
1187
1503
  if (hashManager) hashManager.registerCommands(cave);
1504
+ if (aiManager) aiManager.registerCommands(cave);
1188
1505
  }
1189
1506
  __name(apply, "apply");
1190
1507
  // Annotate the CommonJS export names for ESM import in node:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "功能强大、高度可定制的回声洞。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
- "version": "2.6.10",
4
+ "version": "2.7.1",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],