koishi-plugin-best-cave 2.7.30 → 2.7.32
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 +46 -40
- package/lib/index.d.ts +5 -4
- package/lib/index.js +178 -178
- package/package.json +1 -1
package/lib/AIManager.d.ts
CHANGED
|
@@ -2,13 +2,13 @@ import { Context, Logger } from 'koishi';
|
|
|
2
2
|
import { Config, CaveObject, StoredElement } from './index';
|
|
3
3
|
import { FileManager } from './FileManager';
|
|
4
4
|
/**
|
|
5
|
-
* @description 定义了数据库
|
|
5
|
+
* @description 定义了数据库 \`cave_meta\` 表的结构模型。
|
|
6
6
|
*/
|
|
7
7
|
export interface CaveMetaObject {
|
|
8
8
|
cave: number;
|
|
9
|
-
keywords: string[];
|
|
10
|
-
description: string;
|
|
11
9
|
rating: number;
|
|
10
|
+
type: string;
|
|
11
|
+
keywords: string[];
|
|
12
12
|
}
|
|
13
13
|
declare module 'koishi' {
|
|
14
14
|
interface Tables {
|
|
@@ -25,71 +25,77 @@ export declare class AIManager {
|
|
|
25
25
|
private logger;
|
|
26
26
|
private fileManager;
|
|
27
27
|
private http;
|
|
28
|
+
private endpointIndex;
|
|
29
|
+
/**
|
|
30
|
+
* @description 用于分析的 AI 系统提示词。
|
|
31
|
+
*/
|
|
28
32
|
private readonly ANALYSIS_SYSTEM_PROMPT;
|
|
29
|
-
|
|
33
|
+
/**
|
|
34
|
+
* @description 用于查重的 AI 系统提示词。
|
|
35
|
+
*/
|
|
36
|
+
private readonly DUPLICATE_SYSTEM_PROMPT;
|
|
30
37
|
/**
|
|
31
38
|
* @constructor
|
|
32
|
-
* @
|
|
39
|
+
* @description AIManager 的构造函数。
|
|
40
|
+
* @param {Context} ctx - Koishi 的上下文对象。
|
|
33
41
|
* @param {Config} config - 插件的配置对象。
|
|
34
42
|
* @param {Logger} logger - 日志记录器实例。
|
|
35
|
-
* @param {FileManager} fileManager -
|
|
43
|
+
* @param {FileManager} fileManager - 文件管理器实例。
|
|
36
44
|
*/
|
|
37
45
|
constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
|
|
38
46
|
/**
|
|
39
|
-
* @description
|
|
40
|
-
* @param {any} cave -
|
|
47
|
+
* @description 注册与 AI 功能相关的管理命令。
|
|
48
|
+
* @param {any} cave - \`cave\` 命令的实例,用于挂载子命令。
|
|
41
49
|
*/
|
|
42
50
|
registerCommands(cave: any): void;
|
|
43
51
|
/**
|
|
44
|
-
* @description
|
|
45
|
-
* @param {StoredElement[]} newElements -
|
|
46
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] -
|
|
47
|
-
* @returns {Promise<
|
|
48
|
-
* @throws {Error} 当 AI 分析或比较过程中发生严重错误时抛出。
|
|
52
|
+
* @description 检查新内容是否与数据库中已存在的回声洞重复。
|
|
53
|
+
* @param {StoredElement[]} newElements - 待检查的新内容的元素数组。
|
|
54
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓存,用于加速处理。
|
|
55
|
+
* @returns {Promise<number[]>} - 一个 Promise,解析为重复的回声洞 ID 数组。如果不重复,则为空数组。
|
|
49
56
|
*/
|
|
50
57
|
checkForDuplicates(newElements: StoredElement[], mediaBuffers?: {
|
|
51
58
|
fileName: string;
|
|
52
59
|
buffer: Buffer;
|
|
53
|
-
}[]): Promise<
|
|
54
|
-
duplicate: boolean;
|
|
55
|
-
ids?: number[];
|
|
56
|
-
}>;
|
|
60
|
+
}[]): Promise<number[]>;
|
|
57
61
|
/**
|
|
58
|
-
* @description
|
|
62
|
+
* @description 对一个或多个回声洞内容进行 AI 分析。
|
|
59
63
|
* @param {CaveObject[]} caves - 需要分析的回声洞对象数组。
|
|
60
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] -
|
|
61
|
-
* @returns {Promise<CaveMetaObject[]>} 一个 Promise
|
|
64
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓存。
|
|
65
|
+
* @returns {Promise<CaveMetaObject[]>} - 一个 Promise,解析为分析结果(\`CaveMetaObject\`)的数组。
|
|
62
66
|
*/
|
|
63
67
|
analyze(caves: CaveObject[], mediaBuffers?: {
|
|
64
68
|
fileName: string;
|
|
65
69
|
buffer: Buffer;
|
|
66
70
|
}[]): Promise<CaveMetaObject[]>;
|
|
67
71
|
/**
|
|
68
|
-
* @description
|
|
69
|
-
* @param {CaveObject}
|
|
70
|
-
* @param {
|
|
71
|
-
* @returns {Promise<
|
|
72
|
-
|
|
72
|
+
* @description 准备单个回声洞的内容(文本和图片)以供 AI 模型处理。
|
|
73
|
+
* @param {CaveObject} cave - 要处理的回声洞对象。
|
|
74
|
+
* @param {Map<string, Buffer>} [mediaMap] - 媒体文件的缓存 Map。
|
|
75
|
+
* @returns {Promise<any[] | null>} - 一个 Promise,解析为适合 AI 请求的 content 数组,如果回声洞为空则返回 null。
|
|
76
|
+
*/
|
|
77
|
+
private prepareContent;
|
|
78
|
+
/**
|
|
79
|
+
* @description 使用 AI 批量判断一个主要回声洞是否与一组候选回声洞中的任何一个重复。
|
|
80
|
+
* @param {CaveObject} mainCave - 主要的回声洞。
|
|
81
|
+
* @param {CaveObject[]} candidateCaves - 用于比较的候选回声洞数组。
|
|
82
|
+
* @returns {Promise<number[]>} - 一个 Promise,解析为重复的回声洞 ID 数组。如果不重复,则为空数组。
|
|
73
83
|
*/
|
|
74
|
-
private
|
|
84
|
+
private IsDuplicate;
|
|
75
85
|
/**
|
|
76
|
-
* @description
|
|
77
|
-
*
|
|
78
|
-
* @param {string[]} keywordsA -第一组关键词。
|
|
86
|
+
* @description 计算两组关键词之间的相似度(Jaccard 相似系数)。
|
|
87
|
+
* @param {string[]} keywordsA - 第一组关键词。
|
|
79
88
|
* @param {string[]} keywordsB - 第二组关键词。
|
|
80
|
-
* @returns {number} 返回 0 到 100
|
|
81
|
-
* @private
|
|
89
|
+
* @returns {number} - 返回 0 到 100 之间的相似度百分比。
|
|
82
90
|
*/
|
|
83
|
-
private
|
|
91
|
+
private calculateSimilarity;
|
|
84
92
|
/**
|
|
85
|
-
* @description
|
|
86
|
-
* @template T - 期望从 AI
|
|
87
|
-
* @param {
|
|
88
|
-
* @param {
|
|
89
|
-
* @
|
|
90
|
-
* @
|
|
91
|
-
* @throws {Error} 当网络请求失败、AI 未返回有效内容或 JSON 解析失败时抛出。
|
|
92
|
-
* @private
|
|
93
|
+
* @description 向配置的 AI 服务端点发送请求的通用方法。
|
|
94
|
+
* @template T - 期望从 AI 响应中解析出的 JSON 对象的类型。
|
|
95
|
+
* @param {any[]} messages - 发送给 AI 的消息数组。
|
|
96
|
+
* @param {string} systemPrompt - 系统提示词。
|
|
97
|
+
* @returns {Promise<T>} - 一个 Promise,解析为从 AI 响应中解析出的 JSON 对象。
|
|
98
|
+
* @throws {Error} - 如果 AI 响应为空或无法解析为 JSON,则抛出错误。
|
|
93
99
|
*/
|
|
94
100
|
private requestAI;
|
|
95
101
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface StoredElement {
|
|
|
21
21
|
file?: string;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* @description 数据库
|
|
24
|
+
* @description 数据库 \`cave\` 表的完整对象模型。
|
|
25
25
|
*/
|
|
26
26
|
export interface CaveObject {
|
|
27
27
|
id: number;
|
|
@@ -59,12 +59,13 @@ export interface Config {
|
|
|
59
59
|
publicUrl?: string;
|
|
60
60
|
enableAI: boolean;
|
|
61
61
|
endpoints?: {
|
|
62
|
-
name: string;
|
|
63
62
|
url: string;
|
|
64
63
|
key: string;
|
|
64
|
+
model: string;
|
|
65
65
|
}[];
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
enableApprove: boolean;
|
|
67
|
+
approveThreshold: number;
|
|
68
|
+
systemPrompt: string;
|
|
68
69
|
}
|
|
69
70
|
export declare const Config: Schema<Config>;
|
|
70
71
|
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
CHANGED
|
@@ -1086,10 +1086,11 @@ var path3 = __toESM(require("path"));
|
|
|
1086
1086
|
var AIManager = class {
|
|
1087
1087
|
/**
|
|
1088
1088
|
* @constructor
|
|
1089
|
-
* @
|
|
1089
|
+
* @description AIManager 的构造函数。
|
|
1090
|
+
* @param {Context} ctx - Koishi 的上下文对象。
|
|
1090
1091
|
* @param {Config} config - 插件的配置对象。
|
|
1091
1092
|
* @param {Logger} logger - 日志记录器实例。
|
|
1092
|
-
* @param {FileManager} fileManager -
|
|
1093
|
+
* @param {FileManager} fileManager - 文件管理器实例。
|
|
1093
1094
|
*/
|
|
1094
1095
|
constructor(ctx, config, logger2, fileManager) {
|
|
1095
1096
|
this.ctx = ctx;
|
|
@@ -1099,9 +1100,9 @@ var AIManager = class {
|
|
|
1099
1100
|
this.http = ctx.http;
|
|
1100
1101
|
this.ctx.model.extend("cave_meta", {
|
|
1101
1102
|
cave: "unsigned",
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1103
|
+
rating: "unsigned",
|
|
1104
|
+
type: "string",
|
|
1105
|
+
keywords: "json"
|
|
1105
1106
|
}, {
|
|
1106
1107
|
primary: "cave"
|
|
1107
1108
|
});
|
|
@@ -1110,39 +1111,20 @@ var AIManager = class {
|
|
|
1110
1111
|
__name(this, "AIManager");
|
|
1111
1112
|
}
|
|
1112
1113
|
http;
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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\`。`;
|
|
1114
|
+
endpointIndex = 0;
|
|
1115
|
+
/**
|
|
1116
|
+
* @description 用于分析的 AI 系统提示词。
|
|
1117
|
+
*/
|
|
1118
|
+
ANALYSIS_SYSTEM_PROMPT = `你需要分析给定的内容,并按照以下规则进行评分、分类和提取内容中的关键词。
|
|
1119
|
+
你的回复必须且只能是一个JSON对象,禁止含有任何其他内容,例如{"rating": 88,"type": "Game","keywords": ["Minecraft", "Nether"]}。`;
|
|
1120
|
+
/**
|
|
1121
|
+
* @description 用于查重的 AI 系统提示词。
|
|
1122
|
+
*/
|
|
1123
|
+
DUPLICATE_SYSTEM_PROMPT = `你需要比较给定的“新内容”与“候选内容”,识别内容语义或核心思想重复的候选内容。
|
|
1124
|
+
你的回复必须且只能是一个JSON数组,禁止含有任何其他内容,只包含重复项ID,例如[1, 2],若无重复,则返回[]。`;
|
|
1143
1125
|
/**
|
|
1144
|
-
* @description
|
|
1145
|
-
* @param {any} cave -
|
|
1126
|
+
* @description 注册与 AI 功能相关的管理命令。
|
|
1127
|
+
* @param {any} cave - \`cave\` 命令的实例,用于挂载子命令。
|
|
1146
1128
|
*/
|
|
1147
1129
|
registerCommands(cave) {
|
|
1148
1130
|
cave.subcommand(".ai", "分析回声洞", { hidden: true, authority: 4 }).usage("分析尚未分析的回声洞,补全回声洞记录。").action(async ({ session }) => {
|
|
@@ -1158,17 +1140,15 @@ var AIManager = class {
|
|
|
1158
1140
|
for (let i = 0; i < cavesToAnalyze.length; i += 25) {
|
|
1159
1141
|
const batch = cavesToAnalyze.slice(i, i + 25);
|
|
1160
1142
|
this.logger.info(`[${i + 1}/${cavesToAnalyze.length}] 正在分析 ${batch.length} 个回声洞...`);
|
|
1161
|
-
const
|
|
1162
|
-
const results = await Promise.allSettled(analysisPromises);
|
|
1143
|
+
const results = await Promise.allSettled(batch.map((cave2) => this.analyze([cave2])));
|
|
1163
1144
|
const successfulAnalyses = [];
|
|
1164
1145
|
for (let j = 0; j < results.length; j++) {
|
|
1165
1146
|
const result = results[j];
|
|
1166
|
-
const cave2 = batch[j];
|
|
1167
1147
|
if (result.status === "fulfilled" && result.value.length > 0) {
|
|
1168
1148
|
successfulAnalyses.push(result.value[0]);
|
|
1169
1149
|
} else {
|
|
1170
1150
|
failedCount++;
|
|
1171
|
-
if (result.status === "rejected") this.logger.error(`分析回声洞(${
|
|
1151
|
+
if (result.status === "rejected") this.logger.error(`分析回声洞(${batch[j].id})失败:`, result.reason);
|
|
1172
1152
|
}
|
|
1173
1153
|
}
|
|
1174
1154
|
if (successfulAnalyses.length > 0) {
|
|
@@ -1188,33 +1168,39 @@ var AIManager = class {
|
|
|
1188
1168
|
try {
|
|
1189
1169
|
const allMeta = await this.ctx.database.get("cave_meta", {});
|
|
1190
1170
|
if (allMeta.length < 2) return "无可比较数据";
|
|
1191
|
-
const
|
|
1171
|
+
const combinedTags = /* @__PURE__ */ __name((meta) => [meta.type, ...meta.keywords || []].filter(Boolean), "combinedTags");
|
|
1172
|
+
const candidatePairs = generateFromLSH(allMeta, (meta) => ({ id: meta.cave, keys: combinedTags(meta) }));
|
|
1192
1173
|
if (candidatePairs.size === 0) return "未发现相似内容";
|
|
1193
|
-
const
|
|
1174
|
+
const groupedCandidates = /* @__PURE__ */ new Map();
|
|
1175
|
+
const allCaveIds = /* @__PURE__ */ new Set();
|
|
1194
1176
|
candidatePairs.forEach((pairKey) => {
|
|
1195
|
-
pairKey.split("-").map(Number).forEach((id) => idsToCompare.add(id));
|
|
1196
|
-
});
|
|
1197
|
-
const caveData = await this.ctx.database.get("cave", { id: { $in: Array.from(idsToCompare) }, status: "active" });
|
|
1198
|
-
const allCaves = new Map(caveData.map((c) => [c.id, c]));
|
|
1199
|
-
const comparisonPromises = Array.from(candidatePairs).map(async (pairKey) => {
|
|
1200
1177
|
const [id1, id2] = pairKey.split("-").map(Number);
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1178
|
+
if (!groupedCandidates.has(id1)) groupedCandidates.set(id1, /* @__PURE__ */ new Set());
|
|
1179
|
+
groupedCandidates.get(id1).add(id2);
|
|
1180
|
+
allCaveIds.add(id1);
|
|
1181
|
+
allCaveIds.add(id2);
|
|
1205
1182
|
});
|
|
1206
|
-
const
|
|
1207
|
-
const
|
|
1183
|
+
const caveData = await this.ctx.database.get("cave", { id: { $in: Array.from(allCaveIds) }, status: "active" });
|
|
1184
|
+
const allCaves = new Map(caveData.map((c) => [c.id, c]));
|
|
1185
|
+
const duplicatePairs = [];
|
|
1186
|
+
for (const [mainId, candidateIdsSet] of groupedCandidates.entries()) {
|
|
1187
|
+
const mainCave = allCaves.get(mainId);
|
|
1188
|
+
const candidateCaves = Array.from(candidateIdsSet).map((id) => allCaves.get(id)).filter((c) => !!c);
|
|
1189
|
+
if (mainCave && candidateCaves.length > 0) {
|
|
1190
|
+
const duplicateIds = await this.IsDuplicate(mainCave, candidateCaves);
|
|
1191
|
+
if (duplicateIds && duplicateIds.length > 0) duplicateIds.forEach((candidateId) => duplicatePairs.push({ id1: mainId, id2: candidateId }));
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1208
1194
|
if (duplicatePairs.length === 0) return "未发现高重复性的内容";
|
|
1209
1195
|
const dsu = new DSU();
|
|
1210
|
-
const
|
|
1196
|
+
const finalIds = /* @__PURE__ */ new Set();
|
|
1211
1197
|
duplicatePairs.forEach((p) => {
|
|
1212
1198
|
dsu.union(p.id1, p.id2);
|
|
1213
|
-
|
|
1214
|
-
|
|
1199
|
+
finalIds.add(p.id1);
|
|
1200
|
+
finalIds.add(p.id2);
|
|
1215
1201
|
});
|
|
1216
1202
|
const clusters = /* @__PURE__ */ new Map();
|
|
1217
|
-
|
|
1203
|
+
finalIds.forEach((id) => {
|
|
1218
1204
|
const root = dsu.find(id);
|
|
1219
1205
|
if (!clusters.has(root)) clusters.set(root, []);
|
|
1220
1206
|
clusters.get(root).push(id);
|
|
@@ -1223,9 +1209,8 @@ var AIManager = class {
|
|
|
1223
1209
|
if (validClusters.length === 0) return "未发现高重复性的内容";
|
|
1224
1210
|
let report = `共发现 ${validClusters.length} 组高重复性的内容:`;
|
|
1225
1211
|
validClusters.forEach((cluster) => {
|
|
1226
|
-
const sortedCluster = cluster.sort((a, b) => a - b);
|
|
1227
1212
|
report += `
|
|
1228
|
-
- ${
|
|
1213
|
+
- ${cluster.sort((a, b) => a - b).join("|")}`;
|
|
1229
1214
|
});
|
|
1230
1215
|
return report;
|
|
1231
1216
|
} catch (error) {
|
|
@@ -1235,73 +1220,52 @@ var AIManager = class {
|
|
|
1235
1220
|
});
|
|
1236
1221
|
}
|
|
1237
1222
|
/**
|
|
1238
|
-
* @description
|
|
1239
|
-
* @param {StoredElement[]} newElements -
|
|
1240
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] -
|
|
1241
|
-
* @returns {Promise<
|
|
1242
|
-
* @throws {Error} 当 AI 分析或比较过程中发生严重错误时抛出。
|
|
1223
|
+
* @description 检查新内容是否与数据库中已存在的回声洞重复。
|
|
1224
|
+
* @param {StoredElement[]} newElements - 待检查的新内容的元素数组。
|
|
1225
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓存,用于加速处理。
|
|
1226
|
+
* @returns {Promise<number[]>} - 一个 Promise,解析为重复的回声洞 ID 数组。如果不重复,则为空数组。
|
|
1243
1227
|
*/
|
|
1244
1228
|
async checkForDuplicates(newElements, mediaBuffers) {
|
|
1245
1229
|
try {
|
|
1246
1230
|
const dummyCave = { id: 0, elements: newElements, channelId: "", userId: "", userName: "", status: "preload", time: /* @__PURE__ */ new Date() };
|
|
1247
1231
|
const [newAnalysis] = await this.analyze([dummyCave], mediaBuffers);
|
|
1248
|
-
if (!newAnalysis
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1232
|
+
if (!newAnalysis || !newAnalysis.type) return [];
|
|
1233
|
+
const allNewTags = [newAnalysis.type, ...newAnalysis.keywords || []];
|
|
1234
|
+
if (allNewTags.length === 1 && !allNewTags[0]) return [];
|
|
1235
|
+
const allMeta = await this.ctx.database.get("cave_meta", { type: newAnalysis.type }, { fields: ["cave", "type", "keywords"] });
|
|
1236
|
+
const similarCaveIds = allMeta.filter((meta) => {
|
|
1237
|
+
const existingTags = [meta.type, ...meta.keywords || []];
|
|
1238
|
+
return this.calculateSimilarity(allNewTags, existingTags) >= 80;
|
|
1239
|
+
}).map((meta) => meta.cave);
|
|
1240
|
+
if (similarCaveIds.length === 0) return [];
|
|
1252
1241
|
const potentialDuplicates = await this.ctx.database.get("cave", { id: { $in: similarCaveIds } });
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
return null;
|
|
1256
|
-
});
|
|
1257
|
-
const duplicateIds = (await Promise.all(comparisonPromises)).filter((id) => id !== null);
|
|
1258
|
-
return { duplicate: duplicateIds.length > 0, ids: duplicateIds };
|
|
1242
|
+
if (potentialDuplicates.length === 0) return [];
|
|
1243
|
+
return await this.IsDuplicate(dummyCave, potentialDuplicates);
|
|
1259
1244
|
} catch (error) {
|
|
1260
1245
|
this.logger.error("查重回声洞出错:", error);
|
|
1261
|
-
return
|
|
1246
|
+
return [];
|
|
1262
1247
|
}
|
|
1263
1248
|
}
|
|
1264
1249
|
/**
|
|
1265
|
-
* @description
|
|
1250
|
+
* @description 对一个或多个回声洞内容进行 AI 分析。
|
|
1266
1251
|
* @param {CaveObject[]} caves - 需要分析的回声洞对象数组。
|
|
1267
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] -
|
|
1268
|
-
* @returns {Promise<CaveMetaObject[]>} 一个 Promise
|
|
1252
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓存。
|
|
1253
|
+
* @returns {Promise<CaveMetaObject[]>} - 一个 Promise,解析为分析结果(\`CaveMetaObject\`)的数组。
|
|
1269
1254
|
*/
|
|
1270
1255
|
async analyze(caves, mediaBuffers) {
|
|
1271
|
-
const mediaMap = mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0;
|
|
1272
1256
|
const analysisPromises = caves.map(async (cave) => {
|
|
1273
1257
|
try {
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
cave.elements.filter((el) => el.type === "image" && el.file).map(async (el) => {
|
|
1277
|
-
try {
|
|
1278
|
-
const buffer = mediaMap?.get(el.file) ?? await this.fileManager.readFile(el.file);
|
|
1279
|
-
const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
|
|
1280
|
-
return {
|
|
1281
|
-
type: "image_url",
|
|
1282
|
-
image_url: { url: `data:${mimeType};base64,${buffer.toString("base64")}` }
|
|
1283
|
-
};
|
|
1284
|
-
} catch (error) {
|
|
1285
|
-
this.logger.warn(`读取文件(${el.file})失败:`, error);
|
|
1286
|
-
return null;
|
|
1287
|
-
}
|
|
1288
|
-
})
|
|
1289
|
-
);
|
|
1290
|
-
const images = imageElements.filter(Boolean);
|
|
1291
|
-
if (!combinedText.trim() && images.length === 0) return null;
|
|
1292
|
-
const contentForAI = [];
|
|
1293
|
-
if (combinedText.trim()) contentForAI.push({ type: "text", text: `请分析以下内容:
|
|
1294
|
-
|
|
1295
|
-
${combinedText}` });
|
|
1296
|
-
contentForAI.push(...images);
|
|
1258
|
+
const contentForAI = await this.prepareContent(cave, mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0);
|
|
1259
|
+
if (!contentForAI) return null;
|
|
1297
1260
|
const userMessage = { role: "user", content: contentForAI };
|
|
1298
|
-
const response = await this.requestAI(
|
|
1261
|
+
const response = await this.requestAI([userMessage], `${this.ANALYSIS_SYSTEM_PROMPT}
|
|
1262
|
+
${this.config.systemPrompt}`);
|
|
1299
1263
|
if (response) {
|
|
1300
1264
|
return {
|
|
1301
1265
|
cave: cave.id,
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1266
|
+
rating: Math.max(0, Math.min(100, response.rating || 0)),
|
|
1267
|
+
type: response.type || "",
|
|
1268
|
+
keywords: response.keywords || []
|
|
1305
1269
|
};
|
|
1306
1270
|
}
|
|
1307
1271
|
return null;
|
|
@@ -1314,36 +1278,62 @@ ${combinedText}` });
|
|
|
1314
1278
|
return results.filter((result) => !!result);
|
|
1315
1279
|
}
|
|
1316
1280
|
/**
|
|
1317
|
-
* @description
|
|
1318
|
-
* @param {CaveObject}
|
|
1319
|
-
* @param {
|
|
1320
|
-
* @returns {Promise<
|
|
1321
|
-
|
|
1281
|
+
* @description 准备单个回声洞的内容(文本和图片)以供 AI 模型处理。
|
|
1282
|
+
* @param {CaveObject} cave - 要处理的回声洞对象。
|
|
1283
|
+
* @param {Map<string, Buffer>} [mediaMap] - 媒体文件的缓存 Map。
|
|
1284
|
+
* @returns {Promise<any[] | null>} - 一个 Promise,解析为适合 AI 请求的 content 数组,如果回声洞为空则返回 null。
|
|
1285
|
+
*/
|
|
1286
|
+
async prepareContent(cave, mediaMap) {
|
|
1287
|
+
const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
|
|
1288
|
+
const imageElements = await Promise.all(
|
|
1289
|
+
cave.elements.filter((el) => el.type === "image" && el.file).map(async (el) => {
|
|
1290
|
+
try {
|
|
1291
|
+
const buffer = mediaMap?.get(el.file) ?? await this.fileManager.readFile(el.file);
|
|
1292
|
+
const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
|
|
1293
|
+
return { type: "image_url", image_url: { url: `data:${mimeType};base64,${buffer.toString("base64")}` } };
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
this.logger.warn(`读取文件(${el.file})失败:`, error);
|
|
1296
|
+
return null;
|
|
1297
|
+
}
|
|
1298
|
+
})
|
|
1299
|
+
);
|
|
1300
|
+
const images = imageElements.filter(Boolean);
|
|
1301
|
+
if (!combinedText.trim() && images.length === 0) return null;
|
|
1302
|
+
const contentForAI = [];
|
|
1303
|
+
if (combinedText.trim()) contentForAI.push({ type: "text", text: `${combinedText}` });
|
|
1304
|
+
contentForAI.push(...images);
|
|
1305
|
+
return contentForAI;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* @description 使用 AI 批量判断一个主要回声洞是否与一组候选回声洞中的任何一个重复。
|
|
1309
|
+
* @param {CaveObject} mainCave - 主要的回声洞。
|
|
1310
|
+
* @param {CaveObject[]} candidateCaves - 用于比较的候选回声洞数组。
|
|
1311
|
+
* @returns {Promise<number[]>} - 一个 Promise,解析为重复的回声洞 ID 数组。如果不重复,则为空数组。
|
|
1322
1312
|
*/
|
|
1323
|
-
async
|
|
1313
|
+
async IsDuplicate(mainCave, candidateCaves) {
|
|
1324
1314
|
try {
|
|
1325
1315
|
const formatContent = /* @__PURE__ */ __name((elements) => elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" "), "formatContent");
|
|
1326
|
-
const
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1316
|
+
const newContentText = formatContent(mainCave.elements);
|
|
1317
|
+
const candidatesText = candidateCaves.map((cave) => `{"id": ${cave.id}, "text": "${formatContent(cave.elements).replace(/"/g, '\\"')}"}`).join("\n");
|
|
1318
|
+
const userMessageContent = `新内容:
|
|
1319
|
+
${newContentText}
|
|
1320
|
+
候选内容:
|
|
1321
|
+
${candidatesText}`;
|
|
1330
1322
|
const userMessage = { role: "user", content: JSON.stringify(userMessageContent) };
|
|
1331
|
-
const response = await this.requestAI(
|
|
1332
|
-
return response
|
|
1323
|
+
const response = await this.requestAI([userMessage], this.DUPLICATE_SYSTEM_PROMPT);
|
|
1324
|
+
return response || [];
|
|
1333
1325
|
} catch (error) {
|
|
1334
|
-
this.logger.error(`比较回声洞(${
|
|
1335
|
-
return
|
|
1326
|
+
this.logger.error(`比较回声洞(${mainCave.id})失败:`, error);
|
|
1327
|
+
return [];
|
|
1336
1328
|
}
|
|
1337
1329
|
}
|
|
1338
1330
|
/**
|
|
1339
|
-
* @description
|
|
1340
|
-
*
|
|
1341
|
-
* @param {string[]} keywordsA -第一组关键词。
|
|
1331
|
+
* @description 计算两组关键词之间的相似度(Jaccard 相似系数)。
|
|
1332
|
+
* @param {string[]} keywordsA - 第一组关键词。
|
|
1342
1333
|
* @param {string[]} keywordsB - 第二组关键词。
|
|
1343
|
-
* @returns {number} 返回 0 到 100
|
|
1344
|
-
* @private
|
|
1334
|
+
* @returns {number} - 返回 0 到 100 之间的相似度百分比。
|
|
1345
1335
|
*/
|
|
1346
|
-
|
|
1336
|
+
calculateSimilarity(keywordsA, keywordsB) {
|
|
1347
1337
|
if (!keywordsA?.length || !keywordsB?.length) return 0;
|
|
1348
1338
|
const setA = new Set(keywordsA);
|
|
1349
1339
|
const setB = new Set(keywordsB);
|
|
@@ -1352,54 +1342,33 @@ ${combinedText}` });
|
|
|
1352
1342
|
return union.size > 0 ? intersection.size / union.size * 100 : 0;
|
|
1353
1343
|
}
|
|
1354
1344
|
/**
|
|
1355
|
-
* @description
|
|
1356
|
-
* @template T - 期望从 AI
|
|
1357
|
-
* @param {
|
|
1358
|
-
* @param {
|
|
1359
|
-
* @
|
|
1360
|
-
* @
|
|
1361
|
-
* @throws {Error} 当网络请求失败、AI 未返回有效内容或 JSON 解析失败时抛出。
|
|
1362
|
-
* @private
|
|
1345
|
+
* @description 向配置的 AI 服务端点发送请求的通用方法。
|
|
1346
|
+
* @template T - 期望从 AI 响应中解析出的 JSON 对象的类型。
|
|
1347
|
+
* @param {any[]} messages - 发送给 AI 的消息数组。
|
|
1348
|
+
* @param {string} systemPrompt - 系统提示词。
|
|
1349
|
+
* @returns {Promise<T>} - 一个 Promise,解析为从 AI 响应中解析出的 JSON 对象。
|
|
1350
|
+
* @throws {Error} - 如果 AI 响应为空或无法解析为 JSON,则抛出错误。
|
|
1363
1351
|
*/
|
|
1364
|
-
async requestAI(
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
}
|
|
1368
|
-
const [name2, modelName] = modelIdentifier.split("/");
|
|
1369
|
-
if (!name2 || !modelName) {
|
|
1370
|
-
throw new Error(`无效的模型标识符: ${modelIdentifier}。格式应为 '端点名称/模型名称'`);
|
|
1371
|
-
}
|
|
1372
|
-
const endpointConfig = this.config.endpoints?.find((e) => e.name === name2);
|
|
1373
|
-
if (!endpointConfig) {
|
|
1374
|
-
throw new Error(`未在配置中找到名为 '${name2}' 的端点`);
|
|
1375
|
-
}
|
|
1376
|
-
const payload = {
|
|
1377
|
-
model: modelName,
|
|
1378
|
-
messages: [{ role: "system", content: systemPrompt }, ...messages]
|
|
1379
|
-
};
|
|
1352
|
+
async requestAI(messages, systemPrompt) {
|
|
1353
|
+
const endpointConfig = this.config.endpoints[this.endpointIndex];
|
|
1354
|
+
this.endpointIndex = (this.endpointIndex + 1) % this.config.endpoints.length;
|
|
1355
|
+
const payload = { model: endpointConfig.model, messages: [{ role: "system", content: systemPrompt }, ...messages] };
|
|
1380
1356
|
const fullUrl = `${endpointConfig.url.replace(/\/$/, "")}/chat/completions`;
|
|
1381
|
-
const headers = {
|
|
1382
|
-
"Content-Type": "application/json",
|
|
1383
|
-
"Authorization": `Bearer ${endpointConfig.key}`
|
|
1384
|
-
};
|
|
1357
|
+
const headers = { "Content-Type": "application/json", "Authorization": `Bearer ${endpointConfig.key}` };
|
|
1385
1358
|
const response = await this.http.post(fullUrl, payload, { headers, timeout: 6e5 });
|
|
1386
1359
|
const content = response?.choices?.[0]?.message?.content;
|
|
1387
|
-
if (!content?.trim()) throw new Error("
|
|
1388
|
-
const candidates = [];
|
|
1360
|
+
if (!content?.trim()) throw new Error("响应为空");
|
|
1389
1361
|
const jsonBlockMatch = content.match(/```json\s*([\s\S]*?)\s*```/i);
|
|
1390
|
-
if (jsonBlockMatch && jsonBlockMatch[1])
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
return JSON.parse(candidate);
|
|
1398
|
-
} catch (parseError) {
|
|
1399
|
-
}
|
|
1362
|
+
if (jsonBlockMatch && jsonBlockMatch[1]) try {
|
|
1363
|
+
return JSON.parse(jsonBlockMatch[1]);
|
|
1364
|
+
} catch (e) {
|
|
1365
|
+
}
|
|
1366
|
+
try {
|
|
1367
|
+
return JSON.parse(content);
|
|
1368
|
+
} catch (e) {
|
|
1400
1369
|
}
|
|
1401
1370
|
this.logger.error("原始响应:", JSON.stringify(response, null, 2));
|
|
1402
|
-
throw new Error(
|
|
1371
|
+
throw new Error();
|
|
1403
1372
|
}
|
|
1404
1373
|
};
|
|
1405
1374
|
|
|
@@ -1418,6 +1387,28 @@ var usage = `
|
|
|
1418
1387
|
<p>🐛 遇到问题?请通过 <strong>Issues</strong> 提交反馈,或加入 QQ 群 <a href="https://qm.qq.com/q/PdLMx9Jowq" style="color:#e0574a;text-decoration:none;"><strong>855571375</strong></a> 进行交流</p>
|
|
1419
1388
|
</div>
|
|
1420
1389
|
`;
|
|
1390
|
+
var DEFAULT_PROMPT = `1."rating" (整数, 0-100): 对内容进行严格且有区分度的评分,以下为评分标准:
|
|
1391
|
+
- 基础分: 50
|
|
1392
|
+
+10至+20: 高原创性、创意或艺术性。
|
|
1393
|
+
+10至+20: 非常搞笑、幽默或有很强的笑点。
|
|
1394
|
+
+10至+20: 引人深思、感人或有强烈的共鸣。
|
|
1395
|
+
+5至+15: 玩梗巧妙或二创质量高,能识别出具体梗/文化背景。
|
|
1396
|
+
-10至-20: 内容质量低下(如图片模糊、有压缩痕迹、文字错别字)。
|
|
1397
|
+
-10至-20: 内容意义不明或非常无聊。
|
|
1398
|
+
-5至-15: 简单或低创意的烂梗、过时流行语。
|
|
1399
|
+
-20至-30: 几乎没有信息量的内容。
|
|
1400
|
+
2."type" (字符串): 对内容进行严格且标准的分类,以下为分类标准:
|
|
1401
|
+
- Game: 与电子游戏直接相关或源自于电子游戏的内容。
|
|
1402
|
+
- ACG: 与动漫、漫画及广义二次元文化紧密相关的内容。
|
|
1403
|
+
- Internet: 源于互联网的通用流行文化、迷因(Meme)或社群现象。
|
|
1404
|
+
- Reality: 取材于现实世界的日常经验和场景的内容。
|
|
1405
|
+
- Creative: 具有独特的原创性、艺术性或巧妙构思的内容。
|
|
1406
|
+
- Other: 不适合归入以上任何一类的无关内容或小众内容。
|
|
1407
|
+
3."keywords" (字符串数组): 从内容中提取全面且细分的关键词,以下为提取准则:
|
|
1408
|
+
- 直接提取: 优先从文字内容中直接提取核心词汇,而不是进行归纳或总结。提取图片中可辨识的对象、场景或文字。
|
|
1409
|
+
- 简洁规范: 关键词必须简短且为规范化、普遍使用的词语。例如,使用“明日方舟”而非“粥”,使用“梗”而非“梗图”。
|
|
1410
|
+
- 全面细分: 提取多个不同维度的关键词,包括但不限于:人物/对象、场景/地点、事件/行为、特定梗/文化元素。
|
|
1411
|
+
- 避免宽泛: 确保关键词具体且相关,避免使用过于宽泛或模糊的术语,避免近似关键词,所有词应完整定义内容。`;
|
|
1421
1412
|
var logger = new import_koishi3.Logger("best-cave");
|
|
1422
1413
|
var Config = import_koishi3.Schema.intersect([
|
|
1423
1414
|
import_koishi3.Schema.object({
|
|
@@ -1435,13 +1426,14 @@ var Config = import_koishi3.Schema.intersect([
|
|
|
1435
1426
|
}).description("复核配置"),
|
|
1436
1427
|
import_koishi3.Schema.object({
|
|
1437
1428
|
enableAI: import_koishi3.Schema.boolean().default(false).description("启用 AI"),
|
|
1429
|
+
enableApprove: import_koishi3.Schema.boolean().default(false).description("启用自动审核"),
|
|
1430
|
+
approveThreshold: import_koishi3.Schema.number().min(0).max(100).step(1).default(80).description("评分阈值"),
|
|
1438
1431
|
endpoints: import_koishi3.Schema.array(import_koishi3.Schema.object({
|
|
1439
|
-
name: import_koishi3.Schema.string().description("名称").required(),
|
|
1440
1432
|
url: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link").required(),
|
|
1441
|
-
key: import_koishi3.Schema.string().description("密钥 (API Key)").role("secret")
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1433
|
+
key: import_koishi3.Schema.string().description("密钥 (API Key)").role("secret"),
|
|
1434
|
+
model: import_koishi3.Schema.string().description("模型 (Model)").required()
|
|
1435
|
+
})).description("端点列表").role("table"),
|
|
1436
|
+
systemPrompt: import_koishi3.Schema.string().role("textarea").default(DEFAULT_PROMPT).description("系统提示词")
|
|
1445
1437
|
}).description("模型配置"),
|
|
1446
1438
|
import_koishi3.Schema.object({
|
|
1447
1439
|
localPath: import_koishi3.Schema.string().description("文件映射路径"),
|
|
@@ -1558,24 +1550,32 @@ function apply(ctx, config) {
|
|
|
1558
1550
|
imageHashesToStore = checkResult.imageHashesToStore;
|
|
1559
1551
|
}
|
|
1560
1552
|
if (aiManager) {
|
|
1561
|
-
const
|
|
1562
|
-
if (
|
|
1563
|
-
await session.send(`回声洞(${newId})添加失败:内容与回声洞(${
|
|
1553
|
+
const duplicateIds = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
|
|
1554
|
+
if (duplicateIds?.length > 0) {
|
|
1555
|
+
await session.send(`回声洞(${newId})添加失败:内容与回声洞(${duplicateIds.join("|")})重复`);
|
|
1564
1556
|
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
|
|
1565
1557
|
await cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
|
|
1566
1558
|
return;
|
|
1567
1559
|
}
|
|
1568
1560
|
}
|
|
1569
1561
|
if (hasMedia) await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
|
|
1562
|
+
let analysisResult;
|
|
1570
1563
|
if (aiManager) {
|
|
1571
1564
|
const analyses = await aiManager.analyze([newCave], downloadedMedia);
|
|
1572
|
-
if (analyses.length > 0)
|
|
1565
|
+
if (analyses.length > 0) {
|
|
1566
|
+
analysisResult = analyses[0];
|
|
1567
|
+
await ctx.database.upsert("cave_meta", analyses);
|
|
1568
|
+
}
|
|
1573
1569
|
}
|
|
1574
1570
|
if (hashManager) {
|
|
1575
1571
|
const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: newCave.id }));
|
|
1576
1572
|
if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert);
|
|
1577
1573
|
}
|
|
1578
|
-
if (finalStatus === "pending" && reviewManager)
|
|
1574
|
+
if (finalStatus === "pending" && reviewManager) {
|
|
1575
|
+
if (analysisResult && config.enableApprove && analysisResult.rating >= config.approveThreshold) {
|
|
1576
|
+
await ctx.database.upsert("cave", [{ id: newCave.id, status: "active" }]);
|
|
1577
|
+
} else reviewManager.sendForPend(newCave);
|
|
1578
|
+
}
|
|
1579
1579
|
} catch (error) {
|
|
1580
1580
|
logger.error(`回声洞(${newId})处理失败:`, error);
|
|
1581
1581
|
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
|