koishi-plugin-best-cave 2.7.18 → 2.7.20

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,95 @@
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
+ */
22
+ export declare class AIManager {
23
+ private ctx;
24
+ private config;
25
+ private logger;
26
+ private fileManager;
27
+ private http;
28
+ private readonly ANALYSIS_SYSTEM_PROMPT;
29
+ private readonly DUPLICATE_CHECK_SYSTEM_PROMPT;
30
+ /**
31
+ * @constructor
32
+ * @param {Context} ctx - Koishi 的上下文对象,用于访问核心服务如数据库和 HTTP 客户端。
33
+ * @param {Config} config - 插件的配置对象。
34
+ * @param {Logger} logger - 日志记录器实例。
35
+ * @param {FileManager} fileManager - 文件管理器实例,用于处理媒体文件。
36
+ */
37
+ constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
38
+ /**
39
+ * @description 注册所有与 AIManager 功能相关的 Koishi 命令,包括 AI 分析和内容比较。
40
+ * @param {any} cave - 主命令的实例,用于挂载子命令。
41
+ */
42
+ registerCommands(cave: any): void;
43
+ /**
44
+ * @description 对新提交的内容执行 AI 驱动的查重检查。
45
+ * @param {StoredElement[]} newElements - 新提交的内容元素数组。
46
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 与内容关联的媒体文件缓存。
47
+ * @returns {Promise<{ duplicate: boolean; ids?: number[] }>} 一个对象,包含查重结果和(如果重复)重复的回声洞 ID 数组。
48
+ * @throws {Error} 当 AI 分析或比较过程中发生严重错误时抛出。
49
+ */
50
+ checkForDuplicates(newElements: StoredElement[], mediaBuffers?: {
51
+ fileName: string;
52
+ buffer: Buffer;
53
+ }[]): Promise<{
54
+ duplicate: boolean;
55
+ ids?: number[];
56
+ }>;
57
+ /**
58
+ * @description 对单个或批量回声洞执行内容分析,提取关键词、生成描述并评分。
59
+ * @param {CaveObject[]} caves - 需要分析的回声洞对象数组。
60
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 预加载的媒体文件缓存,以避免重复读取。
61
+ * @returns {Promise<CaveMetaObject[]>} 一个 Promise,解析为包含分析结果的 `CaveMetaObject` 对象数组。
62
+ */
63
+ analyze(caves: CaveObject[], mediaBuffers?: {
64
+ fileName: string;
65
+ buffer: Buffer;
66
+ }[]): Promise<CaveMetaObject[]>;
67
+ /**
68
+ * @description 调用 AI 判断两个回声洞内容是否在语义上重复或高度相似。
69
+ * @param {CaveObject} caveA - 第一个回声洞对象。
70
+ * @param {CaveObject} caveB - 第二个回声洞对象。
71
+ * @returns {Promise<boolean>} 如果内容被 AI 判断为重复,则返回 true,否则返回 false。
72
+ * @throws {Error} 当 AI 请求失败时抛出。
73
+ * @private
74
+ */
75
+ private isContentDuplicateAI;
76
+ /**
77
+ * @description 计算两组关键词之间的 Jaccard 相似度。
78
+ * Jaccard 相似度 = (交集大小 / 并集大小)。
79
+ * @param {string[]} keywordsA -第一组关键词。
80
+ * @param {string[]} keywordsB - 第二组关键词。
81
+ * @returns {number} 返回 0 到 100 之间的相似度得分。
82
+ * @private
83
+ */
84
+ private calculateKeywordSimilarity;
85
+ /**
86
+ * @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
87
+ * @template T - 期望从 AI 响应的 JSON 中解析出的数据类型。
88
+ * @param {any[]} messages - 发送给 AI 的消息数组,通常包含用户消息。
89
+ * @param {string} systemPrompt - 指导 AI 行为的系统级指令。
90
+ * @returns {Promise<T>} 一个 Promise,解析为从 AI 响应中提取并解析的 JSON 对象。
91
+ * @throws {Error} 当网络请求失败、AI 未返回有效内容或 JSON 解析失败时抛出。
92
+ * @private
93
+ */
94
+ private requestAI;
95
+ }
@@ -0,0 +1,80 @@
1
+ import { Context, Logger } from 'koishi';
2
+ import { Config } from './index';
3
+ import { FileManager } from './FileManager';
4
+ /**
5
+ * @description 数据库 `cave_hash` 表的完整对象模型。
6
+ */
7
+ export interface CaveHashObject {
8
+ cave: number;
9
+ hash: string;
10
+ type: 'text' | 'image';
11
+ }
12
+ /**
13
+ * @class HashManager
14
+ * @description 负责生成、存储和比较文本与图片的哈希值。
15
+ * 实现了基于 Simhash 的文本查重和基于 DCT 感知哈希 (pHash) 的图片查重方案。
16
+ */
17
+ export declare class HashManager {
18
+ private ctx;
19
+ private config;
20
+ private logger;
21
+ private fileManager;
22
+ /**
23
+ * @constructor
24
+ * @param ctx - Koishi 上下文,用于数据库操作。
25
+ * @param config - 插件配置,用于获取相似度阈值等。
26
+ * @param logger - 日志记录器实例。
27
+ * @param fileManager - 文件管理器实例,用于读取图片文件。
28
+ */
29
+ constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
30
+ /**
31
+ * @description 注册与哈希功能相关的子命令。
32
+ * @param cave - 主 `cave` 命令实例。
33
+ */
34
+ registerCommands(cave: any): void;
35
+ /**
36
+ * @description 扫描并修复单个图片 Buffer,移除文件结束符之后的多余数据。
37
+ * @param imageBuffer - 原始的图片 Buffer。
38
+ * @returns 修复后的图片 Buffer。如果无需修复,则返回原始 Buffer。
39
+ */
40
+ sanitizeImageBuffer(imageBuffer: Buffer): Buffer;
41
+ /**
42
+ * @description 执行一维离散余弦变换 (DCT-II) 的方法。
43
+ * @param input - 输入的数字数组。
44
+ * @returns DCT 变换后的数组。
45
+ */
46
+ private dct1D;
47
+ /**
48
+ * @description 执行二维离散余弦变换 (DCT-II) 的方法。
49
+ * 通过对行和列分别应用一维 DCT 来实现。
50
+ * @param matrix - 输入的 N x N 像素亮度矩阵。
51
+ * @returns DCT 变换后的 N x N 系数矩阵。
52
+ */
53
+ private dct2D;
54
+ /**
55
+ * @description pHash 算法核心实现,使用 Jimp 和自定义 DCT。
56
+ * @param imageBuffer - 图片的 Buffer。
57
+ * @returns 64位十六进制 pHash 字符串。
58
+ */
59
+ generatePHash(imageBuffer: Buffer): Promise<string>;
60
+ /**
61
+ * @description 计算两个十六进制哈希字符串之间的汉明距离 (不同位的数量)。
62
+ * @param hex1 - 第一个哈希。
63
+ * @param hex2 - 第二个哈希。
64
+ * @returns 汉明距离。
65
+ */
66
+ calculateHammingDistance(hex1: string, hex2: string): number;
67
+ /**
68
+ * @description 根据汉明距离计算相似度百分比。
69
+ * @param hex1 - 第一个哈希。
70
+ * @param hex2 - 第二个哈希。
71
+ * @returns 相似度 (0-100)。
72
+ */
73
+ calculateSimilarity(hex1: string, hex2: string): number;
74
+ /**
75
+ * @description 为文本生成 64 位 Simhash 字符串。
76
+ * @param text - 需要处理的文本。
77
+ * @returns 16位十六进制 Simhash 字符串。
78
+ */
79
+ generateTextSimhash(text: string): string;
80
+ }
package/lib/Utils.d.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { Context, h, Logger, Session } from 'koishi';
2
+ import { CaveObject, Config, StoredElement } from './index';
3
+ import { FileManager } from './FileManager';
4
+ import { HashManager, CaveHashObject } from './HashManager';
5
+ /**
6
+ * @description 构建一条用于发送的完整回声洞消息,处理不同存储后端的资源链接。
7
+ * @param cave 回声洞对象。
8
+ * @param config 插件配置。
9
+ * @param fileManager 文件管理器实例。
10
+ * @param logger 日志记录器实例。
11
+ * @param platform 目标平台名称 (e.g., 'onebot')。
12
+ * @param prefix 可选的消息前缀 (e.g., '已删除', '待审核')。
13
+ * @returns 包含多条消息的数组,每条消息是一个 (string | h)[] 数组。
14
+ */
15
+ export declare function buildCaveMessage(cave: CaveObject, config: Config, fileManager: FileManager, logger: Logger, platform?: string, prefix?: string): Promise<(string | h)[][]>;
16
+ /**
17
+ * @description 清理数据库中标记为 'delete' 状态的回声洞及其关联文件和哈希。
18
+ * @param ctx Koishi 上下文。
19
+ * @param fileManager 文件管理器实例。
20
+ * @param logger 日志记录器实例。
21
+ * @param reusableIds 可复用 ID 的内存缓存。
22
+ */
23
+ export declare function cleanupPendingDeletions(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reusableIds: Set<number>): Promise<void>;
24
+ /**
25
+ * @description 根据配置和会话,生成数据库查询的范围条件。
26
+ * @param session 当前会话。
27
+ * @param config 插件配置。
28
+ * @param includeStatus 是否包含 status: 'active' 条件,默认为 true。
29
+ * @returns 数据库查询条件对象。
30
+ */
31
+ export declare function getScopeQuery(session: Session, config: Config, includeStatus?: boolean): object;
32
+ /**
33
+ * @description 获取下一个可用的回声洞 ID,采用“回收ID > 扫描空缺 > 最大ID+1”策略。
34
+ * @param ctx Koishi 上下文。
35
+ * @param reusableIds 可复用 ID 的内存缓存。
36
+ * @returns 可用的新 ID。
37
+ */
38
+ export declare function getNextCaveId(ctx: Context, reusableIds: Set<number>): Promise<number>;
39
+ /**
40
+ * @description 解析消息元素,分离出文本和待下载的媒体文件。
41
+ * @param sourceElements 原始的 Koishi 消息元素数组。
42
+ * @param newId 这条回声洞的新 ID。
43
+ * @param session 触发操作的会话。
44
+ * @param config 插件配置。
45
+ * @param logger 日志实例。
46
+ * @param creationTime 统一的创建时间戳,用于生成文件名。
47
+ * @returns 包含数据库元素和待保存媒体列表的对象。
48
+ */
49
+ export declare function processMessageElements(sourceElements: h[], newId: number, session: Session, creationTime: Date): Promise<{
50
+ finalElementsForDb: StoredElement[];
51
+ mediaToSave: {
52
+ sourceUrl: string;
53
+ fileName: string;
54
+ }[];
55
+ }>;
56
+ /**
57
+ * @description 执行文本 (Simhash) 和图片 (pHash) 相似度查重。
58
+ * @returns 一个对象,指示是否发现重复项;如果未发现,则返回生成的哈希。
59
+ */
60
+ export declare function performSimilarityChecks(ctx: Context, config: Config, hashManager: HashManager, logger: Logger, finalElementsForDb: StoredElement[], downloadedMedia: {
61
+ fileName: string;
62
+ buffer: Buffer;
63
+ }[]): Promise<{
64
+ duplicate: boolean;
65
+ message?: string;
66
+ textHashesToStore?: Omit<CaveHashObject, 'cave'>[];
67
+ imageHashesToStore?: Omit<CaveHashObject, 'cave'>[];
68
+ }>;
69
+ /**
70
+ * @description 校验会话是否来自指定的管理群组。
71
+ * @param session 当前会话。
72
+ * @param config 插件配置。
73
+ * @returns 如果校验不通过,返回错误信息字符串;如果通过,返回 null。
74
+ */
75
+ export declare function requireAdmin(session: Session, config: Config): string | null;
76
+ /**
77
+ * @class DSU
78
+ * @description 一个通用的并查集(Disjoint Set Union)数据结构,用于高效地处理集合的合并与查找。
79
+ * 非常适合用于将相似或重复的项进行聚类。
80
+ */
81
+ export declare class DSU {
82
+ private parent;
83
+ /**
84
+ * 查找元素的根节点,并进行路径压缩优化。
85
+ * @param i - 要查找的元素 ID。
86
+ * @returns 元素的根节点 ID。
87
+ */
88
+ find(i: number): number;
89
+ /**
90
+ * 合并两个元素所在的集合。
91
+ * @param i - 第一个元素。
92
+ * @param j - 第二个元素。
93
+ */
94
+ union(i: number, j: number): void;
95
+ }
96
+ /**
97
+ * @description 通用的 LSH (局部敏感哈希) 候选对生成器。
98
+ * @param items 要处理的项目数组。
99
+ * @param getBucketInfo 一个函数,接收单个项目,并返回其唯一 ID 和一个桶键数组。
100
+ * @returns 一个 Set,包含所有候选对的字符串键 (e.g., "123-456")。
101
+ */
102
+ export declare function generateFromLSH<T>(items: T[], getBucketInfo: (item: T) => {
103
+ id: number;
104
+ keys: string[];
105
+ }): Set<string>;
package/lib/index.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { Context, Schema } from 'koishi';
2
+ import { CaveHashObject } from './HashManager';
3
+ import { CaveMetaObject } from './AIManager';
4
+ export declare const name = "best-cave";
5
+ export declare const inject: string[];
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";
7
+ /**
8
+ * @description 存储在合并转发中的单个节点的数据结构。
9
+ */
10
+ export interface ForwardNode {
11
+ userId: string;
12
+ userName: string;
13
+ elements: StoredElement[];
14
+ }
15
+ /**
16
+ * @description 存储在数据库中的单个消息元素。
17
+ */
18
+ export interface StoredElement {
19
+ type: 'text' | 'image' | 'video' | 'audio' | 'file' | 'at' | 'forward' | 'reply' | 'face';
20
+ content?: string | ForwardNode[];
21
+ file?: string;
22
+ }
23
+ /**
24
+ * @description 数据库 `cave` 表的完整对象模型。
25
+ */
26
+ export interface CaveObject {
27
+ id: number;
28
+ elements: StoredElement[];
29
+ channelId: string;
30
+ userId: string;
31
+ userName: string;
32
+ status: 'active' | 'delete' | 'pending' | 'preload';
33
+ time: Date;
34
+ }
35
+ declare module 'koishi' {
36
+ interface Tables {
37
+ cave: CaveObject;
38
+ cave_hash: CaveHashObject;
39
+ cave_meta: CaveMetaObject;
40
+ }
41
+ }
42
+ export interface Config {
43
+ perChannel: boolean;
44
+ adminChannel: string;
45
+ enableName: boolean;
46
+ enableIO: boolean;
47
+ enablePend: boolean;
48
+ caveFormat: string;
49
+ enableSimilarity: boolean;
50
+ textThreshold: number;
51
+ imageThreshold: number;
52
+ localPath?: string;
53
+ enableS3: boolean;
54
+ endpoint?: string;
55
+ region?: string;
56
+ accessKeyId?: string;
57
+ secretAccessKey?: string;
58
+ bucket?: string;
59
+ publicUrl?: string;
60
+ enableAI: boolean;
61
+ aiEndpoint?: string;
62
+ aiApiKey?: string;
63
+ aiModel?: string;
64
+ }
65
+ export declare const Config: Schema<Config>;
66
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -487,25 +487,67 @@ async function performSimilarityChecks(ctx, config, hashManager, logger2, finalE
487
487
  }
488
488
  }
489
489
  __name(performSimilarityChecks, "performSimilarityChecks");
490
- async function handleFileUploads(ctx, config, fileManager, logger2, cave, downloadedMedia, reusableIds, needsReview) {
491
- try {
492
- await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
493
- const finalStatus = needsReview ? "pending" : "active";
494
- await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
495
- return finalStatus;
496
- } catch (fileProcessingError) {
497
- logger2.error(`回声洞(${cave.id})文件处理失败:`, fileProcessingError);
498
- await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
499
- cleanupPendingDeletions(ctx, config, fileManager, logger2, reusableIds);
500
- throw fileProcessingError;
501
- }
502
- }
503
- __name(handleFileUploads, "handleFileUploads");
504
490
  function requireAdmin(session, config) {
505
491
  if (session.cid !== config.adminChannel) return "此指令仅限在管理群组中使用";
506
492
  return null;
507
493
  }
508
494
  __name(requireAdmin, "requireAdmin");
495
+ var DSU = class {
496
+ static {
497
+ __name(this, "DSU");
498
+ }
499
+ parent = /* @__PURE__ */ new Map();
500
+ /**
501
+ * 查找元素的根节点,并进行路径压缩优化。
502
+ * @param i - 要查找的元素 ID。
503
+ * @returns 元素的根节点 ID。
504
+ */
505
+ find(i) {
506
+ if (!this.parent.has(i)) {
507
+ this.parent.set(i, i);
508
+ return i;
509
+ }
510
+ if (this.parent.get(i) === i) return i;
511
+ const root = this.find(this.parent.get(i));
512
+ this.parent.set(i, root);
513
+ return root;
514
+ }
515
+ /**
516
+ * 合并两个元素所在的集合。
517
+ * @param i - 第一个元素。
518
+ * @param j - 第二个元素。
519
+ */
520
+ union(i, j) {
521
+ const rootI = this.find(i);
522
+ const rootJ = this.find(j);
523
+ if (rootI !== rootJ) this.parent.set(rootI, rootJ);
524
+ }
525
+ };
526
+ function generateFromLSH(items, getBucketInfo) {
527
+ const buckets = /* @__PURE__ */ new Map();
528
+ items.forEach((item) => {
529
+ const { id, keys } = getBucketInfo(item);
530
+ if (!id || !keys || keys.length === 0) return;
531
+ keys.forEach((key) => {
532
+ if (!buckets.has(key)) buckets.set(key, []);
533
+ buckets.get(key).push(id);
534
+ });
535
+ });
536
+ const candidatePairs = /* @__PURE__ */ new Set();
537
+ for (const ids of buckets.values()) {
538
+ if (ids.length < 2) continue;
539
+ const uniqueIds = [...new Set(ids)].sort((a, b) => a - b);
540
+ if (uniqueIds.length < 2) continue;
541
+ for (let i = 0; i < uniqueIds.length; i++) {
542
+ for (let j = i + 1; j < uniqueIds.length; j++) {
543
+ const pairKey = `${uniqueIds[i]}-${uniqueIds[j]}`;
544
+ candidatePairs.add(pairKey);
545
+ }
546
+ }
547
+ }
548
+ return candidatePairs;
549
+ }
550
+ __name(generateFromLSH, "generateFromLSH");
509
551
 
510
552
  // src/DataManager.ts
511
553
  var DataManager = class {
@@ -718,7 +760,7 @@ var HashManager = class {
718
760
  __name(this, "HashManager");
719
761
  }
720
762
  /**
721
- * @description 注册与哈希功能相关的 `.hash` 和 `.check` 子命令。
763
+ * @description 注册与哈希功能相关的子命令。
722
764
  * @param cave - 主 `cave` 命令实例。
723
765
  */
724
766
  registerCommands(cave) {
@@ -788,48 +830,78 @@ var HashManager = class {
788
830
  const imageThreshold = options.imageThreshold ?? this.config.imageThreshold;
789
831
  const allHashes = await this.ctx.database.get("cave_hash", {});
790
832
  if (allHashes.length < 2) return "无可比较哈希";
791
- const buckets = /* @__PURE__ */ new Map();
792
- const hashLookup = /* @__PURE__ */ new Map();
793
- for (const hashObj of allHashes) {
794
- if (!hashLookup.has(hashObj.cave)) hashLookup.set(hashObj.cave, {});
795
- hashLookup.get(hashObj.cave)[hashObj.type] = hashObj.hash;
833
+ const candidatePairs = generateFromLSH(allHashes, (hashObj) => {
796
834
  const binHash = BigInt("0x" + hashObj.hash).toString(2).padStart(64, "0");
835
+ const keys = [];
797
836
  for (let i = 0; i < 4; i++) {
798
837
  const band = binHash.substring(i * 16, (i + 1) * 16);
799
- const bucketKey = `${hashObj.type}:${i}:${band}`;
800
- if (!buckets.has(bucketKey)) buckets.set(bucketKey, []);
801
- buckets.get(bucketKey).push(hashObj.cave);
838
+ keys.push(`${hashObj.type}:${i}:${band}`);
802
839
  }
803
- }
804
- const candidatePairs = /* @__PURE__ */ new Set();
805
- const similarPairs = { text: /* @__PURE__ */ new Set(), image: /* @__PURE__ */ new Set() };
806
- for (const ids of buckets.values()) {
807
- if (ids.length < 2) continue;
808
- for (let i = 0; i < ids.length; i++) {
809
- for (let j = i + 1; j < ids.length; j++) {
810
- const id1 = ids[i];
811
- const id2 = ids[j];
812
- const pairKey = [id1, id2].sort((a, b) => a - b).join("-");
813
- if (candidatePairs.has(pairKey)) continue;
814
- candidatePairs.add(pairKey);
815
- const cave1Hashes = hashLookup.get(id1);
816
- const cave2Hashes = hashLookup.get(id2);
817
- if (cave1Hashes?.text && cave2Hashes?.text) {
818
- const similarity = this.calculateSimilarity(cave1Hashes.text, cave2Hashes.text);
819
- if (similarity >= textThreshold) similarPairs.text.add(`${id1} & ${id2} = ${similarity.toFixed(2)}%`);
820
- }
821
- if (cave1Hashes?.image && cave2Hashes?.image) {
822
- const similarity = this.calculateSimilarity(cave1Hashes.image, cave2Hashes.image);
823
- if (similarity >= imageThreshold) similarPairs.image.add(`${id1} & ${id2} = ${similarity.toFixed(2)}%`);
824
- }
825
- }
840
+ return { id: hashObj.cave, keys };
841
+ });
842
+ const hashLookup = /* @__PURE__ */ new Map();
843
+ allHashes.forEach((h4) => {
844
+ if (!hashLookup.has(h4.cave)) hashLookup.set(h4.cave, {});
845
+ hashLookup.get(h4.cave)[h4.type] = h4.hash;
846
+ });
847
+ const textPairs = [];
848
+ const imagePairs = [];
849
+ for (const pairKey of candidatePairs) {
850
+ const [id1, id2] = pairKey.split("-").map(Number);
851
+ const cave1Hashes = hashLookup.get(id1);
852
+ const cave2Hashes = hashLookup.get(id2);
853
+ if (cave1Hashes?.text && cave2Hashes?.text) {
854
+ const similarity = this.calculateSimilarity(cave1Hashes.text, cave2Hashes.text);
855
+ if (similarity >= textThreshold) textPairs.push({ id1, id2, similarity });
826
856
  }
857
+ if (cave1Hashes?.image && cave2Hashes?.image) {
858
+ const similarity = this.calculateSimilarity(cave1Hashes.image, cave2Hashes.image);
859
+ if (similarity >= imageThreshold) imagePairs.push({ id1, id2, similarity });
860
+ }
861
+ }
862
+ if (textPairs.length === 0 && imagePairs.length === 0) return "未发现高相似度的内容";
863
+ const generateReportForType = /* @__PURE__ */ __name((pairs) => {
864
+ if (pairs.length === 0) return { reportLines: [], clusters: [] };
865
+ const dsu = new DSU();
866
+ const allIds = /* @__PURE__ */ new Set();
867
+ pairs.forEach((p) => {
868
+ dsu.union(p.id1, p.id2);
869
+ allIds.add(p.id1);
870
+ allIds.add(p.id2);
871
+ });
872
+ const clusterMap = /* @__PURE__ */ new Map();
873
+ allIds.forEach((id) => {
874
+ const root = dsu.find(id);
875
+ if (!clusterMap.has(root)) clusterMap.set(root, []);
876
+ clusterMap.get(root).push(id);
877
+ });
878
+ const validClusters = Array.from(clusterMap.values()).filter((c) => c.length > 1);
879
+ const reportLines = [];
880
+ validClusters.forEach((cluster) => {
881
+ const sortedCluster = cluster.sort((a, b) => a - b);
882
+ const clusterPairs = pairs.filter((p) => cluster.includes(p.id1) && cluster.includes(p.id2)).sort((a, b) => b.similarity - a.similarity);
883
+ const scores = clusterPairs.map((p) => `${p.similarity.toFixed(2)}%`).join("/");
884
+ reportLines.push(`- ${sortedCluster.join("|")} = ${scores}`);
885
+ });
886
+ return { reportLines, clusters: validClusters };
887
+ }, "generateReportForType");
888
+ const textResult = generateReportForType(textPairs);
889
+ const imageResult = generateReportForType(imagePairs);
890
+ const totalClusters = textResult.clusters.length + imageResult.clusters.length;
891
+ if (totalClusters === 0) return "未发现高相似度的内容";
892
+ let report = `共发现 ${totalClusters} 组高相似度的内容:`;
893
+ if (textResult.reportLines.length > 0) {
894
+ report += `
895
+ [文本相似]`;
896
+ report += `
897
+ ${textResult.reportLines.join("\n")}`;
898
+ }
899
+ if (imageResult.reportLines.length > 0) {
900
+ report += `
901
+ [图片相似]`;
902
+ report += `
903
+ ${imageResult.reportLines.join("\n")}`;
827
904
  }
828
- const totalFindings = similarPairs.text.size + similarPairs.image.size;
829
- if (totalFindings === 0) return "未发现高相似度的内容";
830
- let report = `已发现 ${totalFindings} 组高相似度的内容:`;
831
- if (similarPairs.text.size > 0) report += "\n文本内容相似:\n" + [...similarPairs.text].join("\n");
832
- if (similarPairs.image.size > 0) report += "\n图片内容相似:\n" + [...similarPairs.image].join("\n");
833
905
  return report.trim();
834
906
  } catch (error) {
835
907
  this.logger.error("检查相似度失败:", error);
@@ -1014,9 +1086,9 @@ var path3 = __toESM(require("path"));
1014
1086
  var AIManager = class {
1015
1087
  /**
1016
1088
  * @constructor
1017
- * @param {Context} ctx - Koishi 的上下文对象,提供框架核心功能。
1089
+ * @param {Context} ctx - Koishi 的上下文对象,用于访问核心服务如数据库和 HTTP 客户端。
1018
1090
  * @param {Config} config - 插件的配置对象。
1019
- * @param {Logger} logger - 日志记录器实例,用于输出日志。
1091
+ * @param {Logger} logger - 日志记录器实例。
1020
1092
  * @param {FileManager} fileManager - 文件管理器实例,用于处理媒体文件。
1021
1093
  */
1022
1094
  constructor(ctx, config, logger2, fileManager) {
@@ -1038,11 +1110,39 @@ var AIManager = class {
1038
1110
  __name(this, "AIManager");
1039
1111
  }
1040
1112
  http;
1041
- requestCount = 0;
1042
- rateLimitResetTime = 0;
1113
+ ANALYSIS_SYSTEM_PROMPT = `你是一位专业的“数字人类学家”和“迷因(Meme)专家”,擅长分析解读网络社群“回声洞”(一种消息存档)中的内容。这些内容通常是笑话、网络梗、游戏截图、或有趣的引言。你的任务是分析用户提供的内容(可能包含文本和图片),并以严格的 JSON 格式返回分析结果。
1114
+
1115
+ 请严格遵循以下规则和格式:
1116
+
1117
+ 1. **角色定位**:将自己视为熟悉网络流行文化、游戏、动漫和各类“梗”的专家。
1118
+ 2. **语言要求**:\`keywords\` 和 \`description\` 的内容必须全部为中文。
1119
+ 3. **分析与输出**:你的回复**必须且只能**是一个包裹在 \`\`\`json ... \`\`\` 代码块中的 JSON 对象,不包含任何解释性文字。该 JSON 对象必须包含以下三个键:
1120
+
1121
+ * \`"keywords"\` (字符串数组): 提取一组全面的中文标签 (tags),这组标签的组合应能**精准地定义和分类**该内容,便于未来搜索。不需要限制数量,但追求准确和全面,应包含具体的人名、作品名、游戏名、事件名、或网络梗的专有名词。
1122
+
1123
+ * \`"description"\` (字符串): 用一句简洁的中文**概括内容的核心思想或解释其“梗”的来源和用法**。
1124
+
1125
+ * \`"rating"\` (0-100的整数): 根据以下**细化评分标准**进行综合评分:
1126
+ * **创意与原创性 (0-10分)**:是否为原创或独特的二次创作?常见的截图或转发应酌情减分。
1127
+ * **趣味性与信息量 (0-40分)**:内容是否有趣、引人发笑或包含有价值的信息?
1128
+ * **文化价值与传播潜力 (0-30分)**:是否属于经典“梗”或具有成为新流行“梗”的潜力?
1129
+ * **内容质量与清晰度 (0-20分)**:对于图片,是否清晰、无过多水印或压缩痕迹?对于文本,是否排版清晰、易于阅读?**图片模糊、带有严重水印应在此项大幅扣分**。`;
1130
+ DUPLICATE_CHECK_SYSTEM_PROMPT = `你是一位严谨的“网络文化内容查重专家”,尤其擅长识别网络梗、Copypasta(定型文)和笑话的变体。你的任务是比较用户提供的两段内容(content_a 和 content_b),判断它们在**语义上或作为“梗”的本质上是否表达了相同或高度相似的核心思想**。
1131
+
1132
+ 请严格遵循以下规则:
1133
+
1134
+ 1. **重复的核心定义**:专注于核心含义,忽略无关紧要的格式、标点符号、错别字或语气差异。只要两段内容指向**同一个梗、同一个笑话、同一个句式模板或同一个核心事件**,就应视为重复。
1135
+ 2. **常见的重复类型包括**:
1136
+ * **文字变体**:用词略有不同,但表达完全相同的意思。
1137
+ * **句式模板应用**:使用相同的“梗”句式,即使替换了其中的主体。
1138
+ * **核心思想转述**:用不同的话复述了同一个意思或笑话。
1139
+ * **跨语言相同梗**:同一个梗的不同语言或音译版本。
1140
+ 3. **非重复的界定**:主题相似但**核心信息、笑点或结论不同**,则不应视为重复。
1141
+ 4. **严格的JSON输出**:你的回复**必须且只能**是一个包裹在 \`\`\`json ... \`\`\` 代码块中的 JSON 对象。
1142
+ 5. **唯一的输出键**:该 JSON 对象必须仅包含一个布尔类型的键 \`"duplicate"\`。如果内容重复或高度相似,值为 \`true\`,否则为 \`false\`。`;
1043
1143
  /**
1044
- * @description 注册所有与 AIManager 功能相关的 Koishi 命令。
1045
- * @param {any} cave - Koishi 命令实例,用于挂载子命令。
1144
+ * @description 注册所有与 AIManager 功能相关的 Koishi 命令,包括 AI 分析和内容比较。
1145
+ * @param {any} cave - 主命令的实例,用于挂载子命令。
1046
1146
  */
1047
1147
  registerCommands(cave) {
1048
1148
  cave.subcommand(".ai", "分析回声洞", { hidden: true, authority: 4 }).usage("分析尚未分析的回声洞,补全回声洞记录。").action(async ({ session }) => {
@@ -1053,15 +1153,18 @@ var AIManager = class {
1053
1153
  const cavesToAnalyze = allCaves.filter((cave2) => !analyzedCaveIds.has(cave2.id));
1054
1154
  if (cavesToAnalyze.length === 0) return "无需分析回声洞";
1055
1155
  await session.send(`开始分析 ${cavesToAnalyze.length} 个回声洞...`);
1056
- let totalSuccessCount = 0;
1057
- const batchSize = 10;
1156
+ let successCount = 0;
1157
+ const batchSize = 100;
1058
1158
  for (let i = 0; i < cavesToAnalyze.length; i += batchSize) {
1059
1159
  const batch = cavesToAnalyze.slice(i, i + batchSize);
1060
1160
  this.logger.info(`[${i + 1}/${cavesToAnalyze.length}] 正在分析 ${batch.length} 条回声洞...`);
1061
- const successCountInBatch = await this.analyzeAndStore(batch);
1062
- totalSuccessCount += successCountInBatch;
1161
+ const analyses = await this.analyze(batch);
1162
+ if (analyses.length > 0) {
1163
+ await this.ctx.database.upsert("cave_meta", analyses);
1164
+ successCount += analyses.length;
1165
+ }
1063
1166
  }
1064
- return `已分析 ${totalSuccessCount} 个回声洞`;
1167
+ return `已分析 ${successCount} 个回声洞`;
1065
1168
  } catch (error) {
1066
1169
  this.logger.error("分析回声洞失败:", error);
1067
1170
  return `操作失败: ${error.message}`;
@@ -1073,33 +1176,42 @@ var AIManager = class {
1073
1176
  try {
1074
1177
  const allMeta = await this.ctx.database.get("cave_meta", {});
1075
1178
  if (allMeta.length < 2) return "无可比较数据";
1179
+ const candidatePairs = generateFromLSH(allMeta, (meta) => ({ id: meta.cave, keys: meta.keywords }));
1180
+ if (candidatePairs.size === 0) return "未发现相似内容";
1076
1181
  const allCaves = new Map((await this.ctx.database.get("cave", { status: "active" })).map((c) => [c.id, c]));
1077
- const foundPairs = /* @__PURE__ */ new Set();
1078
- const checkedPairs = /* @__PURE__ */ new Set();
1079
- for (let i = 0; i < allMeta.length; i++) {
1080
- for (let j = i + 1; j < allMeta.length; j++) {
1081
- const meta1 = allMeta[i];
1082
- const meta2 = allMeta[j];
1083
- const pairKey = [meta1.cave, meta2.cave].sort((a, b) => a - b).join("-");
1084
- if (checkedPairs.has(pairKey)) continue;
1085
- const keywords1 = new Set(meta1.keywords);
1086
- const keywords2 = new Set(meta2.keywords);
1087
- const intersection = new Set([...keywords1].filter((x) => keywords2.has(x)));
1088
- const union = /* @__PURE__ */ new Set([...keywords1, ...keywords2]);
1089
- const similarity = union.size > 0 ? intersection.size / union.size : 0;
1090
- if (similarity * 100 >= 80) {
1091
- const cave1 = allCaves.get(meta1.cave);
1092
- const cave2 = allCaves.get(meta2.cave);
1093
- if (cave1 && cave2 && await this.isContentDuplicateAI(cave1, cave2)) foundPairs.add(`${cave1.id} & ${cave2.id}`);
1094
- checkedPairs.add(pairKey);
1095
- }
1096
- }
1097
- }
1098
- if (foundPairs.size === 0) return "未发现高重复性的内容";
1099
- let report = `已发现 ${foundPairs.size} 组高重复性的内容:
1100
- `;
1101
- report += [...foundPairs].join("\n");
1102
- return report.trim();
1182
+ const duplicatePairs = [];
1183
+ const comparisonPromises = Array.from(candidatePairs).map(async (pairKey) => {
1184
+ const [id1, id2] = pairKey.split("-").map(Number);
1185
+ const cave1 = allCaves.get(id1);
1186
+ const cave2 = allCaves.get(id2);
1187
+ if (cave1 && cave2 && await this.isContentDuplicateAI(cave1, cave2)) return { id1, id2 };
1188
+ return null;
1189
+ });
1190
+ const results = await Promise.all(comparisonPromises);
1191
+ duplicatePairs.push(...results.filter(Boolean));
1192
+ if (duplicatePairs.length === 0) return "未发现高重复性的内容";
1193
+ const dsu = new DSU();
1194
+ const allIds = /* @__PURE__ */ new Set();
1195
+ duplicatePairs.forEach((p) => {
1196
+ dsu.union(p.id1, p.id2);
1197
+ allIds.add(p.id1);
1198
+ allIds.add(p.id2);
1199
+ });
1200
+ const clusters = /* @__PURE__ */ new Map();
1201
+ allIds.forEach((id) => {
1202
+ const root = dsu.find(id);
1203
+ if (!clusters.has(root)) clusters.set(root, []);
1204
+ clusters.get(root).push(id);
1205
+ });
1206
+ const validClusters = Array.from(clusters.values()).filter((c) => c.length > 1);
1207
+ if (validClusters.length === 0) return "未发现高重复性的内容";
1208
+ let report = `共发现 ${validClusters.length} 组高重复性的内容:`;
1209
+ validClusters.forEach((cluster) => {
1210
+ const sortedCluster = cluster.sort((a, b) => a - b);
1211
+ report += `
1212
+ - ${sortedCluster.join("|")}`;
1213
+ });
1214
+ return report;
1103
1215
  } catch (error) {
1104
1216
  this.logger.error("检查重复性失败:", error);
1105
1217
  return `检查失败: ${error.message}`;
@@ -1109,143 +1221,121 @@ var AIManager = class {
1109
1221
  /**
1110
1222
  * @description 对新提交的内容执行 AI 驱动的查重检查。
1111
1223
  * @param {StoredElement[]} newElements - 新提交的内容元素数组。
1112
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓冲区数组。
1113
- * @returns {Promise<{ duplicate: boolean; ids?: number[] }>} 一个 Promise,解析为一个对象,指示内容是否重复以及重复的回声洞 ID 数组(如果存在)。
1224
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 与内容关联的媒体文件缓存。
1225
+ * @returns {Promise<{ duplicate: boolean; ids?: number[] }>} 一个对象,包含查重结果和(如果重复)重复的回声洞 ID 数组。
1226
+ * @throws {Error} 当 AI 分析或比较过程中发生严重错误时抛出。
1114
1227
  */
1115
1228
  async checkForDuplicates(newElements, mediaBuffers) {
1116
1229
  try {
1117
1230
  const dummyCave = { id: 0, elements: newElements, channelId: "", userId: "", userName: "", status: "preload", time: /* @__PURE__ */ new Date() };
1118
- const [newAnalysis] = await this.getAnalyses([dummyCave], mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0);
1119
- if (!newAnalysis?.keywords?.length) return { duplicate: false, ids: [] };
1231
+ const [newAnalysis] = await this.analyze([dummyCave], mediaBuffers);
1232
+ if (!newAnalysis?.keywords?.length) return { duplicate: false };
1120
1233
  const allMeta = await this.ctx.database.get("cave_meta", {}, { fields: ["cave", "keywords"] });
1121
- const newKeywordsSet = new Set(newAnalysis.keywords);
1122
- const similarCaveIds = allMeta.filter((meta) => {
1123
- if (!meta.keywords?.length) return false;
1124
- const existingKeywordsSet = new Set(meta.keywords);
1125
- const intersection = new Set([...newKeywordsSet].filter((x) => existingKeywordsSet.has(x)));
1126
- const union = /* @__PURE__ */ new Set([...newKeywordsSet, ...existingKeywordsSet]);
1127
- const similarity = union.size > 0 ? intersection.size / union.size : 0;
1128
- return similarity * 100 >= 80;
1129
- }).map((meta) => meta.cave);
1130
- if (similarCaveIds.length === 0) return { duplicate: false, ids: [] };
1234
+ const similarCaveIds = allMeta.filter((meta) => this.calculateKeywordSimilarity(newAnalysis.keywords, meta.keywords) >= 80).map((meta) => meta.cave);
1235
+ if (similarCaveIds.length === 0) return { duplicate: false };
1131
1236
  const potentialDuplicates = await this.ctx.database.get("cave", { id: { $in: similarCaveIds } });
1132
- const duplicateIds = [];
1133
- for (const existingCave of potentialDuplicates) if (await this.isContentDuplicateAI(dummyCave, existingCave)) duplicateIds.push(existingCave.id);
1237
+ const comparisonPromises = potentialDuplicates.map(async (existingCave) => {
1238
+ if (await this.isContentDuplicateAI(dummyCave, existingCave)) return existingCave.id;
1239
+ return null;
1240
+ });
1241
+ const duplicateIds = (await Promise.all(comparisonPromises)).filter((id) => id !== null);
1134
1242
  return { duplicate: duplicateIds.length > 0, ids: duplicateIds };
1135
1243
  } catch (error) {
1136
1244
  this.logger.error("查重回声洞出错:", error);
1137
- return { duplicate: false, ids: [] };
1245
+ return { duplicate: false };
1138
1246
  }
1139
1247
  }
1140
1248
  /**
1141
- * @description 对单个或批量回声洞执行完整的分析和存储流程。
1142
- * @param {CaveObject[]} caves - 要分析的回声洞对象数组。
1143
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓冲区数组,仅在分析新内容时使用。
1144
- * @returns {Promise<number>} 一个 Promise,解析为成功分析和存储的条目数。
1249
+ * @description 对单个或批量回声洞执行内容分析,提取关键词、生成描述并评分。
1250
+ * @param {CaveObject[]} caves - 需要分析的回声洞对象数组。
1251
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 预加载的媒体文件缓存,以避免重复读取。
1252
+ * @returns {Promise<CaveMetaObject[]>} 一个 Promise,解析为包含分析结果的 `CaveMetaObject` 对象数组。
1145
1253
  */
1146
- async analyzeAndStore(caves, mediaBuffers) {
1254
+ async analyze(caves, mediaBuffers) {
1147
1255
  const mediaMap = mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0;
1148
- const results = await this.getAnalyses(caves, mediaMap);
1149
- if (!results?.length) return 0;
1150
- const caveMetaObjects = results.map((res) => ({
1151
- cave: res.cave,
1152
- keywords: res.keywords || [],
1153
- description: res.description || "",
1154
- rating: Math.max(0, Math.min(100, res.rating || 0))
1155
- }));
1156
- await this.ctx.database.upsert("cave_meta", caveMetaObjects);
1157
- return caveMetaObjects.length;
1256
+ const analysisPromises = caves.map(async (cave) => {
1257
+ const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
1258
+ const imageElements = await Promise.all(
1259
+ cave.elements.filter((el) => el.type === "image" && el.file).map(async (el) => {
1260
+ try {
1261
+ const buffer = mediaMap?.get(el.file) ?? await this.fileManager.readFile(el.file);
1262
+ const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
1263
+ return {
1264
+ type: "image_url",
1265
+ image_url: { url: `data:${mimeType};base64,${buffer.toString("base64")}` }
1266
+ };
1267
+ } catch (error) {
1268
+ this.logger.warn(`读取文件(${el.file})失败:`, error);
1269
+ }
1270
+ })
1271
+ );
1272
+ const images = imageElements.filter(Boolean);
1273
+ if (!combinedText.trim() && images.length === 0) return null;
1274
+ const contentForAI = [{ type: "text", text: `请分析以下内容:
1275
+
1276
+ ${combinedText}` }, ...images];
1277
+ const userMessage = { role: "user", content: contentForAI };
1278
+ const response = await this.requestAI([userMessage], this.ANALYSIS_SYSTEM_PROMPT);
1279
+ if (response) return {
1280
+ cave: cave.id,
1281
+ keywords: response.keywords || [],
1282
+ description: response.description || "",
1283
+ rating: Math.max(0, Math.min(100, response.rating || 0))
1284
+ };
1285
+ return null;
1286
+ });
1287
+ const results = await Promise.all(analysisPromises);
1288
+ return results.filter((result) => !!result);
1158
1289
  }
1159
1290
  /**
1160
- * @description 调用 AI 判断两个回声洞内容是否重复或高度相似。
1291
+ * @description 调用 AI 判断两个回声洞内容是否在语义上重复或高度相似。
1161
1292
  * @param {CaveObject} caveA - 第一个回声洞对象。
1162
1293
  * @param {CaveObject} caveB - 第二个回声洞对象。
1163
- * @returns {Promise<boolean>} 如果内容相似则返回 true,否则返回 false。
1294
+ * @returns {Promise<boolean>} 如果内容被 AI 判断为重复,则返回 true,否则返回 false。
1295
+ * @throws {Error} 当 AI 请求失败时抛出。
1296
+ * @private
1164
1297
  */
1165
1298
  async isContentDuplicateAI(caveA, caveB) {
1166
1299
  try {
1167
1300
  const formatContent = /* @__PURE__ */ __name((elements) => elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" "), "formatContent");
1168
- const userMessage = {
1169
- role: "user",
1170
- content: JSON.stringify({
1171
- content_a: { id: caveA.id, text: formatContent(caveA.elements) },
1172
- content_b: { id: caveB.id, text: formatContent(caveB.elements) }
1173
- })
1301
+ const userMessageContent = {
1302
+ content_a: { id: caveA.id, text: formatContent(caveA.elements) },
1303
+ content_b: { id: caveB.id, text: formatContent(caveB.elements) }
1174
1304
  };
1175
- const prompt = `你是一位内容查重专家。请判断 content_a content_b 是否重复或高度相似。你的回复必须且只能是一个包裹在 \`\`\`json ... \`\`\` 代码块中的 JSON 对象,该对象仅包含一个键 "duplicate" (布尔值)。`;
1176
- const response = await this.requestAI([userMessage], prompt);
1177
- return response.duplicate || false;
1305
+ const userMessage = { role: "user", content: JSON.stringify(userMessageContent) };
1306
+ const response = await this.requestAI([userMessage], this.DUPLICATE_CHECK_SYSTEM_PROMPT);
1307
+ return response?.duplicate || false;
1178
1308
  } catch (error) {
1179
1309
  this.logger.error(`比较回声洞(${caveA.id})与(${caveB.id})失败:`, error);
1180
1310
  return false;
1181
1311
  }
1182
1312
  }
1183
1313
  /**
1184
- * @description 为一批回声洞准备内容,并向 AI 发送单个请求以获取所有分析结果。
1185
- * @param {CaveObject[]} caves - 要分析的回声洞对象数组。
1186
- * @param {Map<string, Buffer>} [mediaBufferMap] - 可选的媒体文件名到其缓冲区的映射。
1187
- * @returns {Promise<CaveMetaObject[]>} 一个 Promise,解析为 AI 返回的分析结果数组。
1314
+ * @description 计算两组关键词之间的 Jaccard 相似度。
1315
+ * Jaccard 相似度 = (交集大小 / 并集大小)。
1316
+ * @param {string[]} keywordsA -第一组关键词。
1317
+ * @param {string[]} keywordsB - 第二组关键词。
1318
+ * @returns {number} 返回 0 到 100 之间的相似度得分。
1319
+ * @private
1188
1320
  */
1189
- async getAnalyses(caves, mediaBufferMap) {
1190
- const results = [];
1191
- for (const cave of caves) {
1192
- try {
1193
- const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
1194
- const imageElements = await Promise.all(
1195
- cave.elements.filter((el) => el.type === "image" && el.file).map(async (el) => {
1196
- try {
1197
- const buffer = mediaBufferMap?.get(el.file) ?? await this.fileManager.readFile(el.file);
1198
- const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
1199
- return {
1200
- type: "image_url",
1201
- image_url: { url: `data:${mimeType};base64,${buffer.toString("base64")}` }
1202
- };
1203
- } catch (error) {
1204
- this.logger.warn(`读取文件(${el.file})失败:`, error);
1205
- return null;
1206
- }
1207
- })
1208
- );
1209
- const validImages = imageElements.filter(Boolean);
1210
- if (!combinedText.trim() && validImages.length === 0) continue;
1211
- const contentForAI = [{ type: "text", text: combinedText }];
1212
- contentForAI.push(...validImages);
1213
- const userMessage = { role: "user", content: contentForAI };
1214
- const analysePrompt = `你是一位内容分析专家。请使用中文,分析我提供的内容(包含文本和可能的图片),并为其总结关键词、概括内容并评分。你的回复必须且只能是一个包裹在 \`\`\`json ... \`\`\` 代码块中的有效 JSON 对象。该对象必须包含 "keywords" (字符串数组), "description" (字符串), 和 "rating" (0-100的整数)。`;
1215
- const response = await this.requestAI([userMessage], analysePrompt);
1216
- if (response) {
1217
- results.push({
1218
- cave: cave.id,
1219
- keywords: response.keywords || [],
1220
- description: response.description || "",
1221
- rating: response.rating || 0
1222
- });
1223
- }
1224
- } catch (error) {
1225
- this.logger.error(`分析回声洞(${cave.id})失败:`, error);
1226
- }
1227
- }
1228
- return results;
1321
+ calculateKeywordSimilarity(keywordsA, keywordsB) {
1322
+ if (!keywordsA?.length || !keywordsB?.length) return 0;
1323
+ const setA = new Set(keywordsA);
1324
+ const setB = new Set(keywordsB);
1325
+ const intersection = new Set([...setA].filter((x) => setB.has(x)));
1326
+ const union = /* @__PURE__ */ new Set([...setA, ...setB]);
1327
+ return union.size > 0 ? intersection.size / union.size * 100 : 0;
1229
1328
  }
1230
1329
  /**
1231
- * @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑,并稳健地解析 JSON 响应。
1232
- * @param {any[]} messages - 发送给 AI 的消息数组,遵循 OpenAI 格式。
1233
- * @param {string} systemPrompt - 系统提示词,用于指导 AI 的行为。
1234
- * @returns {Promise<T>} 一个 Promise,解析为从 AI 接收到的、解析后的 JSON 对象。
1235
- * @throws {Error} AI 返回空或无效内容时抛出错误。
1330
+ * @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
1331
+ * @template T - 期望从 AI 响应的 JSON 中解析出的数据类型。
1332
+ * @param {any[]} messages - 发送给 AI 的消息数组,通常包含用户消息。
1333
+ * @param {string} systemPrompt - 指导 AI 行为的系统级指令。
1334
+ * @returns {Promise<T>} 一个 Promise,解析为从 AI 响应中提取并解析的 JSON 对象。
1335
+ * @throws {Error} 当网络请求失败、AI 未返回有效内容或 JSON 解析失败时抛出。
1336
+ * @private
1236
1337
  */
1237
1338
  async requestAI(messages, systemPrompt) {
1238
- const now = Date.now();
1239
- if (now > this.rateLimitResetTime) {
1240
- this.rateLimitResetTime = now + 6e4;
1241
- this.requestCount = 0;
1242
- }
1243
- if (this.requestCount >= this.config.aiRPM) {
1244
- const delay = this.rateLimitResetTime - now;
1245
- if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
1246
- this.rateLimitResetTime = Date.now() + 6e4;
1247
- this.requestCount = 0;
1248
- }
1249
1339
  const payload = {
1250
1340
  model: this.config.aiModel,
1251
1341
  messages: [{ role: "system", content: systemPrompt }, ...messages]
@@ -1255,26 +1345,17 @@ var AIManager = class {
1255
1345
  "Content-Type": "application/json",
1256
1346
  "Authorization": `Bearer ${this.config.aiApiKey}`
1257
1347
  };
1258
- this.requestCount++;
1259
- const response = await this.http.post(fullUrl, payload, { headers, timeout: 9e4 });
1348
+ const response = await this.http.post(fullUrl, payload, { headers, timeout: 18e4 });
1260
1349
  const content = response?.choices?.[0]?.message?.content;
1261
- if (typeof content !== "string" || !content.trim()) {
1262
- this.logger.error("原始响应:", JSON.stringify(response, null, 2));
1263
- throw new Error("响应无效");
1264
- }
1350
+ if (typeof content !== "string" || !content.trim()) throw new Error();
1265
1351
  try {
1266
1352
  const jsonRegex = /```json\s*([\s\S]*?)\s*```/;
1267
1353
  const match = content.match(jsonRegex);
1268
- let jsonString = "";
1269
- if (match && match[1]) {
1270
- jsonString = match[1];
1271
- } else {
1272
- jsonString = content;
1273
- }
1354
+ const jsonString = match && match[1] ? match[1] : content;
1274
1355
  return JSON.parse(jsonString);
1275
1356
  } catch (error) {
1276
- this.logger.error("解析 JSON 失败:", error);
1277
- throw new Error("解析失败");
1357
+ this.logger.error("解析 AI 响应 JSON 失败:", error, "原始响应:", JSON.stringify(response, null, 2), "内容:", content);
1358
+ throw new error();
1278
1359
  }
1279
1360
  }
1280
1361
  };
@@ -1311,10 +1392,9 @@ var Config = import_koishi3.Schema.intersect([
1311
1392
  }).description("复核配置"),
1312
1393
  import_koishi3.Schema.object({
1313
1394
  enableAI: import_koishi3.Schema.boolean().default(false).description("启用 AI"),
1314
- aiEndpoint: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link").default("https://generativelanguage.googleapis.com/v1beta/openai"),
1395
+ aiEndpoint: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link").default("https://api.siliconflow.cn/v1"),
1315
1396
  aiApiKey: import_koishi3.Schema.string().description("密钥 (Key)").role("secret"),
1316
- aiModel: import_koishi3.Schema.string().description("模型 (Model)").default("gemini-2.5-flash"),
1317
- aiRPM: import_koishi3.Schema.number().description("每分钟请求数 (RPM)").default(60)
1397
+ aiModel: import_koishi3.Schema.string().description("模型 (Model)").default("THUDM/GLM-4.1V-9B-Thinking")
1318
1398
  }).description("模型配置"),
1319
1399
  import_koishi3.Schema.object({
1320
1400
  localPath: import_koishi3.Schema.string().description("文件映射路径"),
@@ -1378,70 +1458,84 @@ function apply(ctx, config) {
1378
1458
  }
1379
1459
  });
1380
1460
  cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可直接发送内容,也可回复或引用消息。").action(async ({ session }, content) => {
1381
- try {
1382
- let sourceElements;
1383
- if (session.quote?.elements) {
1384
- sourceElements = session.quote.elements;
1385
- } else if (content?.trim()) {
1386
- sourceElements = import_koishi3.h.parse(content);
1387
- } else {
1388
- await session.send("请在一分钟内发送你要添加的内容");
1389
- const reply = await session.prompt(6e4);
1390
- if (!reply) return "等待操作超时";
1391
- sourceElements = import_koishi3.h.parse(reply);
1392
- }
1393
- const newId = await getNextCaveId(ctx, reusableIds);
1394
- const creationTime = /* @__PURE__ */ new Date();
1395
- const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session, creationTime);
1396
- if (finalElementsForDb.length === 0) return "无可添加内容";
1397
- const hasMedia = mediaToSave.length > 0;
1398
- const downloadedMedia = [];
1399
- if (hasMedia) {
1400
- for (const media of mediaToSave) {
1401
- const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
1402
- downloadedMedia.push({ fileName: media.fileName, buffer });
1461
+ let sourceElements;
1462
+ if (session.quote?.elements) {
1463
+ sourceElements = session.quote.elements;
1464
+ } else if (content?.trim()) {
1465
+ sourceElements = import_koishi3.h.parse(content);
1466
+ } else {
1467
+ await session.send("请在一分钟内发送你要添加的内容");
1468
+ const reply = await session.prompt(6e4);
1469
+ if (!reply) return "等待操作超时";
1470
+ sourceElements = import_koishi3.h.parse(reply);
1471
+ }
1472
+ const newId = await getNextCaveId(ctx, reusableIds);
1473
+ const creationTime = /* @__PURE__ */ new Date();
1474
+ const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session, creationTime);
1475
+ if (finalElementsForDb.length === 0) return "无可添加内容";
1476
+ const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
1477
+ const needsReview = config.enablePend && session.cid !== config.adminChannel;
1478
+ const finalStatus = needsReview ? "pending" : "active";
1479
+ const newCave = await ctx.database.create("cave", {
1480
+ id: newId,
1481
+ elements: finalElementsForDb,
1482
+ channelId: session.channelId,
1483
+ userId: session.userId,
1484
+ userName,
1485
+ status: finalStatus,
1486
+ time: creationTime
1487
+ });
1488
+ session.send(needsReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`);
1489
+ (async () => {
1490
+ try {
1491
+ const hasMedia = mediaToSave.length > 0;
1492
+ const downloadedMedia = [];
1493
+ if (hasMedia) {
1494
+ for (const media of mediaToSave) {
1495
+ const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
1496
+ downloadedMedia.push({ fileName: media.fileName, buffer });
1497
+ }
1498
+ }
1499
+ let textHashesToStore = [];
1500
+ let imageHashesToStore = [];
1501
+ if (hashManager) {
1502
+ for (const media of downloadedMedia) media.buffer = hashManager.sanitizeImageBuffer(media.buffer);
1503
+ const checkResult = await performSimilarityChecks(ctx, config, hashManager, logger, finalElementsForDb, downloadedMedia);
1504
+ if (checkResult.duplicate) {
1505
+ await session.send(`回声洞(${newId})添加失败:${checkResult.message}`);
1506
+ await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
1507
+ await cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
1508
+ return;
1509
+ }
1510
+ textHashesToStore = checkResult.textHashesToStore;
1511
+ imageHashesToStore = checkResult.imageHashesToStore;
1512
+ }
1513
+ if (aiManager) {
1514
+ const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
1515
+ if (duplicateResult?.duplicate && duplicateResult.ids?.length > 0) {
1516
+ await session.send(`回声洞(${newId})添加失败:内容与回声洞(${duplicateResult.ids.join("|")})重复`);
1517
+ await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
1518
+ await cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
1519
+ return;
1520
+ }
1521
+ }
1522
+ if (hasMedia) await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
1523
+ if (aiManager) {
1524
+ const analyses = await aiManager.analyze([newCave], downloadedMedia);
1525
+ if (analyses.length > 0) await ctx.database.upsert("cave_meta", analyses);
1403
1526
  }
1404
- }
1405
- let textHashesToStore = [];
1406
- let imageHashesToStore = [];
1407
- if (hashManager) {
1408
- for (const media of downloadedMedia) media.buffer = hashManager.sanitizeImageBuffer(media.buffer);
1409
- const checkResult = await performSimilarityChecks(ctx, config, hashManager, logger, finalElementsForDb, downloadedMedia);
1410
- if (checkResult.duplicate) return checkResult.message;
1411
- textHashesToStore = checkResult.textHashesToStore;
1412
- imageHashesToStore = checkResult.imageHashesToStore;
1413
- }
1414
- if (aiManager) {
1415
- const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
1416
- if (duplicateResult?.duplicate && duplicateResult.ids?.length > 0) return `内容与回声洞(${duplicateResult.ids.join("|")})重复`;
1417
- }
1418
- const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
1419
- const needsReview = config.enablePend && session.cid !== config.adminChannel;
1420
- let finalStatus = hasMedia ? "preload" : needsReview ? "pending" : "active";
1421
- const newCave = await ctx.database.create("cave", {
1422
- id: newId,
1423
- elements: finalElementsForDb,
1424
- channelId: session.channelId,
1425
- userId: session.userId,
1426
- userName,
1427
- status: finalStatus,
1428
- time: creationTime
1429
- });
1430
- if (hasMedia) finalStatus = await handleFileUploads(ctx, config, fileManager, logger, newCave, downloadedMedia, reusableIds, needsReview);
1431
- if (finalStatus !== "preload") {
1432
- newCave.status = finalStatus;
1433
- if (aiManager) await aiManager.analyzeAndStore([newCave], downloadedMedia);
1434
1527
  if (hashManager) {
1435
1528
  const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: newCave.id }));
1436
1529
  if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert);
1437
1530
  }
1438
1531
  if (finalStatus === "pending" && reviewManager) reviewManager.sendForPend(newCave);
1532
+ } catch (error) {
1533
+ logger.error(`回声洞(${newId})处理失败:`, error);
1534
+ await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
1535
+ await cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
1536
+ await session.send(`回声洞(${newId})处理失败: ${error.message}`);
1439
1537
  }
1440
- return needsReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
1441
- } catch (error) {
1442
- logger.error("添加回声洞失败:", error);
1443
- return "添加失败,请稍后再试";
1444
- }
1538
+ })();
1445
1539
  });
1446
1540
  cave.subcommand(".view <id:posint>", "查看指定回声洞").action(async ({ session }, id) => {
1447
1541
  if (!id) return "请输入要查看的回声洞序号";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
- "description": "功能强大、高度可定制的回声洞。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
- "version": "2.7.18",
3
+ "description": "功能强大、高度可定制的回声洞插件。支持丰富的媒体类型、内容查重、AI分析、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
+ "version": "2.7.20",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
package/readme.md CHANGED
@@ -2,24 +2,27 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/koishi-plugin-best-cave?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-best-cave)
4
4
 
5
- 功能强大、高度可定制的回声洞插件。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。
5
+ 功能强大、高度可定制的回声洞插件。支持丰富的媒体类型、内容查重、AI分析、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。
6
6
 
7
7
  ## 🧩 前置依赖
8
8
 
9
9
  - **数据库 (`koishi-plugin-database`)**: 本插件强依赖数据库来存储数据,请确保您已至少配置了一个数据库插件(如 `koishi-plugin-database-sqlite` 或 `koishi-plugin-database-mysql`)。
10
10
  - **图片处理 (`sharp`)**: 如果您启用了 `enableSimilarity` (内容查重) 功能,插件需要使用 `sharp` 库来处理图片。通常情况下它会自动安装,如遇安装问题,请参考 `sharp` 的官方文档解决。
11
+ - **AI 服务**: 如果您启用了 `enableAI` (AI 功能),则需要一个兼容 OpenAI API 的服务提供商,并获取相应的 **Endpoint** 和 **API Key**。
11
12
 
12
13
  ## ✨ 功能亮点
13
14
 
14
- - **丰富的内容形式**:不止于文本,轻松发布包含图片、视频、音频甚至文件的混合内容。插件能自动解析回复或引用的消息,并将其完整存入回声洞。
15
+ - **丰富的内容形式**:不止于文本,轻松发布包含图片、视频、音频、文件、合并转发的混合内容。插件能自动解析回复或引用的消息,并将其完整存入回声洞。
15
16
  - **灵活的存储后端**:媒体文件可存储在 **本地服务器** (`data/cave` 目录),或配置使用任何 **S3 兼容** 的云端对象存储(如 AWS S3, MinIO, 阿里云 OSS, 腾讯云 COS 等)。支持通过公共URL、本地文件路径或Base64三种方式发送媒体。
16
- - **高级内容查重**:(可选) 启用后,插件会在添加时自动计算文本 (Simhash) 和图片 (pHash) 的哈希值。对于相似度过高的投稿将直接拒绝,有效防止重复。管理员还可通过维护工具找出局部相似或拼接的图片。
17
+ - **AI 智能分析**:(可选) 启用后,插件能调用大语言模型对投稿内容进行智能分析,自动提取**关键词**、生成**一句话描述**并进行**趣味性评分**,为未来的内容检索和管理提供支持。
18
+ - **高级内容查重**:(可选) 插件提供两种查重模式:
19
+ - **哈希查重**:在添加时自动计算文本 (Simhash) 和图片 (pHash) 的哈希值。对于相似度过高的投稿将直接拒绝,有效防止重复。
20
+ - **AI 语义查重**:在添加时利用 AI 模型判断新内容与现有内容在**语义或“梗”的本质上**是否重复,能识别各类变体和转述。
17
21
  - **完善的审核机制**:(可选) 启用后,所有新投稿都将进入待审核状态,并通知管理群组。只有管理员审核通过后,内容才会对用户可见,确保内容质量。
18
22
  - **精细的作用域**:通过 `perChannel` 配置,可设定回声洞是在所有群聊中共享(全局模式),还是在每个群聊中独立(分群模式)。
19
23
  - **专属用户昵称**:(可选) 用户可以为自己在回声洞中的发言设置一个专属昵称,增加趣味性。
20
24
  - **便捷的数据管理**:(可选) 管理员可通过指令轻松地将所有回声洞数据导出为 `JSON` 文件备份,或从文件中恢复数据,迁移无忧。
21
- - **强大的维护工具**:管理员可以通过 `cave.hash` 指令为历史数据批量生成哈希值,并通过 `cave.check` 获取一份所有内容的相似度报告。
22
- - **权限分离**:普通用户可删除自己的投稿。审核、数据管理等高级操作则仅限在指定的 **管理群组** 内由管理员执行。
25
+ - **强大的维护工具**:管理员可以通过一系列指令为历史数据批量生成哈希值 (`cave.hash`)、补全 AI 分析 (`cave.ai`)、获取相似度报告 (`cave.check`) 或 AI 重复性报告 (`cave.compare`)。
23
26
  - **高效的ID管理**:优先复用已删除的ID,并在无可用ID时采用高效的“最大ID+1”策略,确保回声洞序号紧凑,并能高效处理大量数据。
24
27
 
25
28
  ## 📖 指令说明
@@ -29,28 +32,31 @@
29
32
  | 指令 | 别名/选项 | 说明 |
30
33
  | :--- | :--- | :--- |
31
34
  | `cave` | | 随机查看一条回声洞。 |
32
- | `cave.add [内容]` | `cave -a [内容]` | 添加新的回声洞。可以直接跟文字,也可以**回复一条带图片/视频的消息后发送 `cave.add`**,或等待机器人提示后发送。 |
35
+ | `cave.add [内容]` | `cave -a [内容]` | 添加新的回声洞。可以直接跟文字,也可以**回复一条消息后发送 `cave.add`**,或等待机器人提示后发送。 |
33
36
  | `cave.view <序号>` | `cave -g <序号>` | 查看指定序号的回声洞。 |
34
37
  | `cave.del <序号>` | `cave -r <序号>` | 删除指定序号的回声洞。仅投稿人或在管理群组内的管理员可操作。 |
35
38
  | `cave.list` | `cave -l` | 查询并列出自己投稿过的所有回声洞序号及总数。 |
36
39
  | | `-u <用户>` | 查询指定用户(需@或使用ID)投稿的所有回声洞。 |
37
40
  | | `-a` | **(仅限管理群组)** 查看所有用户的投稿数量排行榜。 |
38
41
 
39
- ### 模块化指令
42
+ ### 模块化管理指令
40
43
 
41
- 这些指令只有在配置中启用了相应功能后才可用。
44
+ 这些指令只有在配置中启用了相应功能后才可用,且大部分仅限在**管理群组**中使用。
42
45
 
43
46
  | 指令 | 所需配置 | 说明 |
44
47
  | :--- | :--- | :--- |
45
- | `cave.name [昵称]` | `enableName: true` | 设置你在回声洞中显示的昵称。若不提供昵称,则清除现有设置。 |
46
- | `cave.pend` | `enablePend: true` | **(仅限管理群组)** 列出所有待审核的回声洞ID。 |
47
- | `cave.pend <序号>` | `enablePend: true` | **(仅限管理群组)** 查看指定待审核内容的详情。 |
48
- | `cave.pend.Y [...序号]` | `enablePend: true` | **(仅限管理群组)** 通过审核。若不提供序号,则通过所有待审核内容。 |
49
- | `cave.pend.N [...序号]` | `enablePend: true` | **(仅限管理群组)** 拒绝审核。若不提供序号,则拒绝所有待审核内容。 |
50
- | `cave.export` | `enableIO: true` | **(仅限管理群组)** 将所有`active`状态的回声洞导出到 `data/cave/cave_export.json`。 |
51
- | `cave.import` | `enableIO: true` | **(仅限管理群组)** 从 `data/cave/cave_import.json` 文件中导入数据。 |
52
- | `cave.hash` | `enableSimilarity: true` | **(仅限管理群组)** 校验所有历史数据,为缺失哈希的回声洞补全记录。 |
53
- | `cave.check` | `enableSimilarity: true` | **(仅限管理群组)** 检查所有回声洞的哈希,生成一份关于文本和图片相似度的报告。 |
48
+ | `cave.name [昵称]` | `enableName: true` | **(用户指令)** 设置你在回声洞中显示的昵称。若不提供昵称,则清除现有设置。 |
49
+ | `cave.pend` | `enablePend: true` | **(管理)** 列出所有待审核的回声洞ID。 |
50
+ | `cave.pend <序号>` | `enablePend: true` | **(管理)** 查看指定待审核内容的详情。 |
51
+ | `cave.pend.Y [...序号]` | `enablePend: true` | **(管理)** 通过审核。若不提供序号,则通过所有待审核内容。 |
52
+ | `cave.pend.N [...序号]` | `enablePend: true` | **(管理)** 拒绝审核。若不提供序号,则拒绝所有待审核内容。 |
53
+ | `cave.export` | `enableIO: true` | **(管理)** 将所有`active`状态的回声洞导出到 `data/cave/cave.json`。 |
54
+ | `cave.import` | `enableIO: true` | **(管理)** 从 `data/cave/cave.json` 文件中导入数据。 |
55
+ | `cave.hash` | `enableSimilarity: true` | **(管理)** 校验所有历史数据,为缺失哈希的回声洞补全记录。 |
56
+ | `cave.check` | `enableSimilarity: true` | **(管理)** 检查所有回声洞的哈希,生成一份关于文本和图片相似度的报告。 |
57
+ | `cave.fix [...序号]` | `enableSimilarity: true`| **(管理)** 扫描并修复回声洞中的图片(移除多余数据)。可指定ID或扫描全部。 |
58
+ | `cave.ai` | `enableAI: true` | **(管理)** 分析所有历史数据,为缺失AI元数据的回声洞补全记录。 |
59
+ | `cave.compare` | `enableAI: true` | **(管理)** 检查所有回声洞的AI关键词,生成一份关于内容重复性的报告。 |
54
60
 
55
61
  ## ⚙️ 配置说明
56
62
 
@@ -62,16 +68,25 @@
62
68
  | `enableName` | `boolean` | `false` | 是否启用自定义昵称功能 (`cave.name` 指令)。 |
63
69
  | `enableIO` | `boolean` | `false` | 是否启用数据导入/导出功能 (`cave.export` / `.import` 指令)。 |
64
70
  | `adminChannel` | `string` | `'onebot:'` | **管理群组ID**。格式为 `平台名:群号`,如 `onebot:12345678`。管理指令仅在此群组生效。若配置无效,审核将自动通过。 |
65
- | `caveFormat` | `string` | `'回声洞 ——({id})\|—— {*name}'` | 回声洞消息的显示格式。`\|`为页眉页脚分隔符。支持强大的占位符语法:• **基本**: `{id}`, `{name}`, `{time}`, `{user}`, `{channel}`• **自动打码**: `{*user}` (在占位符前加\*)• **审核可见**: `{/channel}` (在占位符前加/)• **组合**: `{*user/user}` (常规打码/审核时完整) |
71
+ | `caveFormat` | `string` | `'回声洞 ——({id})\|—— {name}'` | 回声洞消息的显示格式。`\|`为页眉页脚分隔符。支持强大的占位符语法:• **基本**: `{id}`, `{name}`, `{time}`, `{user}`, `{channel}`• **自动打码**: `{*user}` (在占位符前加 `*`)• **审核可见**: `{name/}` (在 `/` 后留空,表示常规模式下不显示)• **审核替换**: `{user/name}` (常规模式显示 `user`,审核模式显示 `name`)• **组合**: `{*user/user}` (常规模式下打码显示 `user`,审核时完整显示 `user`) |
66
72
 
67
73
  ### 复核配置 (审核与查重)
68
74
 
69
75
  | 配置项 | 类型 | 默认值 | 说明 |
70
76
  | :--- | :--- | :--- | :--- |
71
77
  | `enablePend` | `boolean` | `false` | 是否启用审核机制。启用后,新投稿将进入`pending`状态。 |
72
- | `enableSimilarity` | `boolean` | `false` | 是否启用内容相似度检查(查重)。 |
73
- | `textThreshold` | `number` | `90` | **文本**相似度阈值 (0-100)。基于Simhash汉明距离计算,超过此值将被拒绝。 |
74
- | `imageThreshold` | `number` | `90` | **图片整体**相似度阈值 (0-100)。基于pHash汉明距离计算,超过此值将被拒绝。 |
78
+ | `enableSimilarity` | `boolean` | `false` | 是否启用基于哈希的内容相似度检查(查重)。 |
79
+ | `textThreshold` | `number` | `95` | **文本**相似度阈值 (0-100)。基于Simhash汉明距离计算,超过此值将被拒绝。 |
80
+ | `imageThreshold` | `number` | `95` | **图片**相似度阈值 (0-100)。基于pHash汉明距离计算,超过此值将被拒绝。 |
81
+
82
+ ### 模型配置 (AI)
83
+
84
+ | 配置项 | 类型 | 默认值 | 说明 |
85
+ | :--- | :--- | :--- | :--- |
86
+ | `enableAI` | `boolean` | `false` | 是否启用 AI 分析与查重功能。 |
87
+ | `aiEndpoint` | `string` | `'https://api.siliconflow.cn/v1'` | **(AI 必填)** 兼容 OpenAI 的 API 端点 (Endpoint)。 |
88
+ | `aiApiKey` | `string` | | **(AI 必填)** API 密钥 (Key)。 |
89
+ | `aiModel` | `string` | `'THUDM/GLM-4.1V-9B-Thinking'` | **(AI 可选)** 使用的语言模型名称 (Model)。需为支持图片理解的多模态模型。 |
75
90
 
76
91
  ### 存储配置
77
92
 
@@ -93,6 +108,6 @@
93
108
  2. **本地文件路径**:如果 `localPath` 已配置。
94
109
  3. **Base64 编码**:如果以上两项均未配置,将文件转为 Base64 发送(可能受平台大小限制或支持问题)。
95
110
  2. **文件存储权限**:若使用本地存储,请确保 Koishi 拥有对 `data/cave` 目录的读写权限。若使用 S3,请确保 Access Key 权限和存储桶策略(如ACL设为`public-read`)配置正确。
96
- 3. **异步删除**:删除操作(`cave.del` 或审核拒绝)会将内容状态标记为 `delete`,然后由后台任务异步清理关联的文件和数据库条目,以避免阻塞当前指令。
97
- 4. **数据迁移**:导入功能会读取插件数据目录下的 `cave_import.json`。导出功能则会生成 `cave_export.json`。请在操作前放置或备份好相应文件。导入时,ID会从现有最大ID开始自增,不会覆盖或修改老数据。
98
- 5. **查重机制**:图片查重在投稿时,仅基于**整图**的相似度进行判断和拒绝。插件还会为图片的四个象限生成哈希,但这些局部哈希仅用于 `cave.check` 指令生成相似度报告,供管理员识别拼接图、裁剪图等情况,**并不会在投稿时直接导致拒绝**。
111
+ 3. **异步处理**:投稿、删除等操作涉及文件下载、哈希计算、AI 分析和文件清理,这些耗时操作均在后台异步执行,以避免阻塞当前指令。您可能会先收到“添加成功”的消息,稍后才会收到查重失败或处理失败的提示。
112
+ 4. **数据迁移**:导入/导出功能均使用插件数据目录下的 `cave.json` 文件。导入时,若遇到与数据库中现有 ID 冲突的条目,该条目会被分配一个新的、当前最大的 ID 加一的序号,不会覆盖或修改老数据。
113
+ 5. **查重机制**:投稿时的查重是实时进行的,若相似度超过阈值会直接拒绝。而 `.check` 和 `.compare` 指令则用于对整个数据库进行批量扫描,生成全面的相似度/重复性报告,帮助管理员发现更复杂或历史遗留的重复内容。