koishi-plugin-best-cave 2.7.19 → 2.7.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/HashManager.d.ts +80 -0
- package/lib/Utils.d.ts +105 -0
- package/lib/index.js +227 -129
- package/package.json +2 -2
- package/readme.md +39 -24
|
@@ -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.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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
if (
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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);
|
|
@@ -1082,7 +1154,7 @@ var AIManager = class {
|
|
|
1082
1154
|
if (cavesToAnalyze.length === 0) return "无需分析回声洞";
|
|
1083
1155
|
await session.send(`开始分析 ${cavesToAnalyze.length} 个回声洞...`);
|
|
1084
1156
|
let successCount = 0;
|
|
1085
|
-
const batchSize =
|
|
1157
|
+
const batchSize = 25;
|
|
1086
1158
|
for (let i = 0; i < cavesToAnalyze.length; i += batchSize) {
|
|
1087
1159
|
const batch = cavesToAnalyze.slice(i, i + batchSize);
|
|
1088
1160
|
this.logger.info(`[${i + 1}/${cavesToAnalyze.length}] 正在分析 ${batch.length} 条回声洞...`);
|
|
@@ -1104,27 +1176,42 @@ var AIManager = class {
|
|
|
1104
1176
|
try {
|
|
1105
1177
|
const allMeta = await this.ctx.database.get("cave_meta", {});
|
|
1106
1178
|
if (allMeta.length < 2) return "无可比较数据";
|
|
1179
|
+
const candidatePairs = generateFromLSH(allMeta, (meta) => ({ id: meta.cave, keys: meta.keywords }));
|
|
1180
|
+
if (candidatePairs.size === 0) return "未发现相似内容";
|
|
1107
1181
|
const allCaves = new Map((await this.ctx.database.get("cave", { status: "active" })).map((c) => [c.id, c]));
|
|
1108
|
-
const
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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;
|
|
1128
1215
|
} catch (error) {
|
|
1129
1216
|
this.logger.error("检查重复性失败:", error);
|
|
1130
1217
|
return `检查失败: ${error.message}`;
|
|
@@ -1258,7 +1345,7 @@ ${combinedText}` }, ...images];
|
|
|
1258
1345
|
"Content-Type": "application/json",
|
|
1259
1346
|
"Authorization": `Bearer ${this.config.aiApiKey}`
|
|
1260
1347
|
};
|
|
1261
|
-
const response = await this.http.post(fullUrl, payload, { headers, timeout:
|
|
1348
|
+
const response = await this.http.post(fullUrl, payload, { headers, timeout: 18e4 });
|
|
1262
1349
|
const content = response?.choices?.[0]?.message?.content;
|
|
1263
1350
|
if (typeof content !== "string" || !content.trim()) throw new Error();
|
|
1264
1351
|
try {
|
|
@@ -1371,58 +1458,68 @@ function apply(ctx, config) {
|
|
|
1371
1458
|
}
|
|
1372
1459
|
});
|
|
1373
1460
|
cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可直接发送内容,也可回复或引用消息。").action(async ({ session }, content) => {
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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;
|
|
1396
1512
|
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
if (aiManager) {
|
|
1408
|
-
const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
|
|
1409
|
-
if (duplicateResult?.duplicate && duplicateResult.ids?.length > 0) return `内容与回声洞(${duplicateResult.ids.join("|")})重复`;
|
|
1410
|
-
}
|
|
1411
|
-
const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
|
|
1412
|
-
const needsReview = config.enablePend && session.cid !== config.adminChannel;
|
|
1413
|
-
let finalStatus = hasMedia ? "preload" : needsReview ? "pending" : "active";
|
|
1414
|
-
const newCave = await ctx.database.create("cave", {
|
|
1415
|
-
id: newId,
|
|
1416
|
-
elements: finalElementsForDb,
|
|
1417
|
-
channelId: session.channelId,
|
|
1418
|
-
userId: session.userId,
|
|
1419
|
-
userName,
|
|
1420
|
-
status: finalStatus,
|
|
1421
|
-
time: creationTime
|
|
1422
|
-
});
|
|
1423
|
-
if (hasMedia) finalStatus = await handleFileUploads(ctx, config, fileManager, logger, newCave, downloadedMedia, reusableIds, needsReview);
|
|
1424
|
-
if (finalStatus !== "preload") {
|
|
1425
|
-
newCave.status = finalStatus;
|
|
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)));
|
|
1426
1523
|
if (aiManager) {
|
|
1427
1524
|
const analyses = await aiManager.analyze([newCave], downloadedMedia);
|
|
1428
1525
|
if (analyses.length > 0) await ctx.database.upsert("cave_meta", analyses);
|
|
@@ -1432,12 +1529,13 @@ function apply(ctx, config) {
|
|
|
1432
1529
|
if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert);
|
|
1433
1530
|
}
|
|
1434
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}`);
|
|
1435
1537
|
}
|
|
1436
|
-
|
|
1437
|
-
} catch (error) {
|
|
1438
|
-
logger.error("添加回声洞失败:", error);
|
|
1439
|
-
return "添加失败,请稍后再试";
|
|
1440
|
-
}
|
|
1538
|
+
})();
|
|
1441
1539
|
});
|
|
1442
1540
|
cave.subcommand(".view <id:posint>", "查看指定回声洞").action(async ({ session }, id) => {
|
|
1443
1541
|
if (!id) return "请输入要查看的回声洞序号";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-best-cave",
|
|
3
|
-
"description": "
|
|
4
|
-
"version": "2.7.
|
|
3
|
+
"description": "功能强大、高度可定制的回声洞插件。支持丰富的媒体类型、内容查重、AI分析、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
|
|
4
|
+
"version": "2.7.21",
|
|
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
|
[](https://www.npmjs.com/package/koishi-plugin-best-cave)
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
-
|
|
17
|
+
- **AI 智能分析**:(可选) 启用后,插件能调用大语言模型对投稿内容进行智能分析,自动提取**关键词**、生成**一句话描述**并进行**趣味性评分**,为未来的内容检索和管理提供支持。
|
|
18
|
+
- **高级内容查重**:(可选) 插件提供两种查重模式:
|
|
19
|
+
- **哈希查重**:在添加时自动计算文本 (Simhash) 和图片 (pHash) 的哈希值。对于相似度过高的投稿将直接拒绝,有效防止重复。
|
|
20
|
+
- **AI 语义查重**:在添加时利用 AI 模型判断新内容与现有内容在**语义或“梗”的本质上**是否重复,能识别各类变体和转述。
|
|
17
21
|
- **完善的审核机制**:(可选) 启用后,所有新投稿都将进入待审核状态,并通知管理群组。只有管理员审核通过后,内容才会对用户可见,确保内容质量。
|
|
18
22
|
- **精细的作用域**:通过 `perChannel` 配置,可设定回声洞是在所有群聊中共享(全局模式),还是在每个群聊中独立(分群模式)。
|
|
19
23
|
- **专属用户昵称**:(可选) 用户可以为自己在回声洞中的发言设置一个专属昵称,增加趣味性。
|
|
20
24
|
- **便捷的数据管理**:(可选) 管理员可通过指令轻松地将所有回声洞数据导出为 `JSON` 文件备份,或从文件中恢复数据,迁移无忧。
|
|
21
|
-
-
|
|
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 [内容]` |
|
|
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` | **(
|
|
47
|
-
| `cave.pend <序号>` | `enablePend: true` | **(
|
|
48
|
-
| `cave.pend.Y [...序号]` | `enablePend: true` | **(
|
|
49
|
-
| `cave.pend.N [...序号]` | `enablePend: true` | **(
|
|
50
|
-
| `cave.export` | `enableIO: true` | **(
|
|
51
|
-
| `cave.import` | `enableIO: true` | **(
|
|
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})\|—— {
|
|
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` | `
|
|
74
|
-
| `imageThreshold` | `number` | `
|
|
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.
|
|
97
|
-
4.
|
|
98
|
-
5.
|
|
111
|
+
3. **异步处理**:投稿、删除等操作涉及文件下载、哈希计算、AI 分析和文件清理,这些耗时操作均在后台异步执行,以避免阻塞当前指令。您可能会先收到“添加成功”的消息,稍后才会收到查重失败或处理失败的提示。
|
|
112
|
+
4. **数据迁移**:导入/导出功能均使用插件数据目录下的 `cave.json` 文件。导入时,若遇到与数据库中现有 ID 冲突的条目,该条目会被分配一个新的、当前最大的 ID 加一的序号,不会覆盖或修改老数据。
|
|
113
|
+
5. **查重机制**:投稿时的查重是实时进行的,若相似度超过阈值会直接拒绝。而 `.check` 和 `.compare` 指令则用于对整个数据库进行批量扫描,生成全面的相似度/重复性报告,帮助管理员发现更复杂或历史遗留的重复内容。
|