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