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.
@@ -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 定义了数据库 `cave_meta` 表的结构模型。
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
- private readonly DUPLICATE_CHECK_SYSTEM_PROMPT;
33
+ /**
34
+ * @description 用于查重的 AI 系统提示词。
35
+ */
36
+ private readonly DUPLICATE_SYSTEM_PROMPT;
30
37
  /**
31
38
  * @constructor
32
- * @param {Context} ctx - Koishi 的上下文对象,用于访问核心服务如数据库和 HTTP 客户端。
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 注册所有与 AIManager 功能相关的 Koishi 命令,包括 AI 分析和内容比较。
40
- * @param {any} cave - 主命令的实例,用于挂载子命令。
47
+ * @description 注册与 AI 功能相关的管理命令。
48
+ * @param {any} cave - \`cave\` 命令的实例,用于挂载子命令。
41
49
  */
42
50
  registerCommands(cave: any): void;
43
51
  /**
44
- * @description 对新提交的内容执行 AI 驱动的查重检查。
45
- * @param {StoredElement[]} newElements - 新提交的内容元素数组。
46
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 与内容关联的媒体文件缓存。
47
- * @returns {Promise<{ duplicate: boolean; ids?: number[] }>} 一个对象,包含查重结果和(如果重复)重复的回声洞 ID 数组。
48
- * @throws {Error} 当 AI 分析或比较过程中发生严重错误时抛出。
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,解析为包含分析结果的 `CaveMetaObject` 对象数组。
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 调用 AI 判断两个回声洞内容是否在语义上重复或高度相似。
69
- * @param {CaveObject} caveA - 第一个回声洞对象。
70
- * @param {CaveObject} caveB - 第二个回声洞对象。
71
- * @returns {Promise<boolean>} 如果内容被 AI 判断为重复,则返回 true,否则返回 false
72
- * @private
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 isContentDuplicateAI;
84
+ private IsDuplicate;
75
85
  /**
76
- * @description 计算两组关键词之间的 Jaccard 相似度。
77
- * Jaccard 相似度 = (交集大小 / 并集大小)。
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 calculateKeywordSimilarity;
91
+ private calculateSimilarity;
84
92
  /**
85
- * @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
86
- * @template T - 期望从 AI 响应的 JSON 中解析出的数据类型。
87
- * @param {string} modelIdentifier - 模型的完整标识符,格式为 `端点名称/模型名称`。
88
- * @param {any[]} messages - 发送给 AI 的消息数组,通常包含用户消息。
89
- * @param {string} systemPrompt - 指导 AI 行为的系统级指令。
90
- * @returns {Promise<T>} 一个 Promise,解析为从 AI 响应中提取并解析的 JSON 对象。
91
- * @throws {Error} 当网络请求失败、AI 未返回有效内容或 JSON 解析失败时抛出。
92
- * @private
93
+ * @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 数据库 `cave` 表的完整对象模型。
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
- analysisModel?: string;
67
- duplicateCheckModel?: string;
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
- * @param {Context} ctx - Koishi 的上下文对象,用于访问核心服务如数据库和 HTTP 客户端。
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
- keywords: "json",
1103
- description: "text",
1104
- rating: "unsigned"
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
- ANALYSIS_SYSTEM_PROMPT = `你是一位专业的“数字人类学家”和“迷因(Meme)专家”,擅长分析解读网络社群“回声洞”(一种消息存档)中的内容。这些内容通常是笑话、网络梗、游戏截图、或有趣的引言。你的任务是分析用户提供的内容(可能包含文本和图片),并以严格的 JSON 格式返回分析结果。
1114
-
1115
- 请严格遵循以下规则和格式:
1116
-
1117
- 1. **角色定位**:将自己视为熟悉网络流行文化、游戏、动漫和各类“梗”的专家。
1118
- 2. **语言要求**:\`keywords\` \`description\` 的内容必须全部为中文。
1119
- 3. **分析与输出**:你的回复**必须且只能**是一个包裹在 \`\`\`json ... \`\`\` 代码块中的 JSON 对象,不包含任何解释性文字。该 JSON 对象必须包含以下三个键:
1120
-
1121
- * \`"keywords"\` (字符串数组): 提取一组全面的中文标签 (tags),这组标签的组合应能**精准地定义和分类**该内容,便于未来搜索。不需要限制数量,但追求准确和全面,应包含具体的人名、作品名、游戏名、事件名、或网络梗的专有名词。
1122
-
1123
- * \`"description"\` (字符串): 用一句简洁的中文**概括内容的核心思想或解释其“梗”的来源和用法**。
1124
-
1125
- * \`"rating"\` (0-100的整数): 根据以下**细化评分标准**进行综合评分:
1126
- * **创意与原创性 (0-10分)**:是否为原创或独特的二次创作?常见的截图或转发应酌情减分。
1127
- * **趣味性与信息量 (0-40分)**:内容是否有趣、引人发笑或包含有价值的信息?
1128
- * **文化价值与传播潜力 (0-30分)**:是否属于经典“梗”或具有成为新流行“梗”的潜力?
1129
- * **内容质量与清晰度 (0-20分)**:对于图片,是否清晰、无过多水印或压缩痕迹?对于文本,是否排版清晰、易于阅读?**图片模糊、带有严重水印应在此项大幅扣分**。`;
1130
- DUPLICATE_CHECK_SYSTEM_PROMPT = `你是一位严谨的“网络文化内容查重专家”,尤其擅长识别网络梗、Copypasta(定型文)和笑话的变体。你的任务是比较用户提供的两段内容(content_a 和 content_b),判断它们在**语义上或作为“梗”的本质上是否表达了相同或高度相似的核心思想**。
1131
-
1132
- 请严格遵循以下规则:
1133
-
1134
- 1. **重复的核心定义**:专注于核心含义,忽略无关紧要的格式、标点符号、错别字或语气差异。只要两段内容指向**同一个梗、同一个笑话、同一个句式模板或同一个核心事件**,就应视为重复。
1135
- 2. **常见的重复类型包括**:
1136
- * **文字变体**:用词略有不同,但表达完全相同的意思。
1137
- * **句式模板应用**:使用相同的“梗”句式,即使替换了其中的主体。
1138
- * **核心思想转述**:用不同的话复述了同一个意思或笑话。
1139
- * **跨语言相同梗**:同一个梗的不同语言或音译版本。
1140
- 3. **非重复的界定**:主题相似但**核心信息、笑点或结论不同**,则不应视为重复。
1141
- 4. **严格的JSON输出**:你的回复**必须且只能**是一个包裹在 \`\`\`json ... \`\`\` 代码块中的 JSON 对象。
1142
- 5. **唯一的输出键**:该 JSON 对象必须仅包含一个布尔类型的键 \`"duplicate"\`。如果内容重复或高度相似,值为 \`true\`,否则为 \`false\`。`;
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 注册所有与 AIManager 功能相关的 Koishi 命令,包括 AI 分析和内容比较。
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 analysisPromises = batch.map((cave2) => this.analyze([cave2]));
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(`分析回声洞(${cave2.id})失败:`, result.reason);
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 candidatePairs = generateFromLSH(allMeta, (meta) => ({ id: meta.cave, keys: meta.keywords }));
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 idsToCompare = /* @__PURE__ */ new Set();
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
- const cave1 = allCaves.get(id1);
1202
- const cave2 = allCaves.get(id2);
1203
- if (cave1 && cave2 && await this.isContentDuplicateAI(cave1, cave2)) return { id1, id2 };
1204
- return null;
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 results = await Promise.all(comparisonPromises);
1207
- const duplicatePairs = results.filter(Boolean);
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 allIds = /* @__PURE__ */ new Set();
1196
+ const finalIds = /* @__PURE__ */ new Set();
1211
1197
  duplicatePairs.forEach((p) => {
1212
1198
  dsu.union(p.id1, p.id2);
1213
- allIds.add(p.id1);
1214
- allIds.add(p.id2);
1199
+ finalIds.add(p.id1);
1200
+ finalIds.add(p.id2);
1215
1201
  });
1216
1202
  const clusters = /* @__PURE__ */ new Map();
1217
- allIds.forEach((id) => {
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
- - ${sortedCluster.join("|")}`;
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 对新提交的内容执行 AI 驱动的查重检查。
1239
- * @param {StoredElement[]} newElements - 新提交的内容元素数组。
1240
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 与内容关联的媒体文件缓存。
1241
- * @returns {Promise<{ duplicate: boolean; ids?: number[] }>} 一个对象,包含查重结果和(如果重复)重复的回声洞 ID 数组。
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?.keywords?.length) return { duplicate: false };
1249
- const allMeta = await this.ctx.database.get("cave_meta", {}, { fields: ["cave", "keywords"] });
1250
- const similarCaveIds = allMeta.filter((meta) => this.calculateKeywordSimilarity(newAnalysis.keywords, meta.keywords) >= 80).map((meta) => meta.cave);
1251
- if (similarCaveIds.length === 0) return { duplicate: false };
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
- const comparisonPromises = potentialDuplicates.map(async (existingCave) => {
1254
- if (await this.isContentDuplicateAI(dummyCave, existingCave)) return existingCave.id;
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 { duplicate: false };
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,解析为包含分析结果的 `CaveMetaObject` 对象数组。
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 combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
1275
- const imageElements = await Promise.all(
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(this.config.analysisModel, [userMessage], this.ANALYSIS_SYSTEM_PROMPT);
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
- keywords: response.keywords || [],
1303
- description: response.description || "",
1304
- rating: Math.max(0, Math.min(100, response.rating || 0))
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 调用 AI 判断两个回声洞内容是否在语义上重复或高度相似。
1318
- * @param {CaveObject} caveA - 第一个回声洞对象。
1319
- * @param {CaveObject} caveB - 第二个回声洞对象。
1320
- * @returns {Promise<boolean>} 如果内容被 AI 判断为重复,则返回 true,否则返回 false
1321
- * @private
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 isContentDuplicateAI(caveA, caveB) {
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 userMessageContent = {
1327
- content_a: { id: caveA.id, text: formatContent(caveA.elements) },
1328
- content_b: { id: caveB.id, text: formatContent(caveB.elements) }
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(this.config.duplicateCheckModel, [userMessage], this.DUPLICATE_CHECK_SYSTEM_PROMPT);
1332
- return response?.duplicate || false;
1323
+ const response = await this.requestAI([userMessage], this.DUPLICATE_SYSTEM_PROMPT);
1324
+ return response || [];
1333
1325
  } catch (error) {
1334
- this.logger.error(`比较回声洞(${caveA.id})与(${caveB.id})失败:`, error);
1335
- return false;
1326
+ this.logger.error(`比较回声洞(${mainCave.id})失败:`, error);
1327
+ return [];
1336
1328
  }
1337
1329
  }
1338
1330
  /**
1339
- * @description 计算两组关键词之间的 Jaccard 相似度。
1340
- * Jaccard 相似度 = (交集大小 / 并集大小)。
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
- calculateKeywordSimilarity(keywordsA, keywordsB) {
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 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
1356
- * @template T - 期望从 AI 响应的 JSON 中解析出的数据类型。
1357
- * @param {string} modelIdentifier - 模型的完整标识符,格式为 `端点名称/模型名称`。
1358
- * @param {any[]} messages - 发送给 AI 的消息数组,通常包含用户消息。
1359
- * @param {string} systemPrompt - 指导 AI 行为的系统级指令。
1360
- * @returns {Promise<T>} 一个 Promise,解析为从 AI 响应中提取并解析的 JSON 对象。
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(modelIdentifier, messages, systemPrompt) {
1365
- if (!modelIdentifier || !modelIdentifier.includes("/")) {
1366
- throw new Error(`无效的模型标识符: ${modelIdentifier}。格式应为 '端点名称/模型名称'`);
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("AI 响应内容为空");
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]) candidates.push(jsonBlockMatch[1]);
1391
- candidates.push(content);
1392
- const firstBrace = content.indexOf("{");
1393
- const lastBrace = content.lastIndexOf("}");
1394
- if (firstBrace !== -1 && lastBrace > firstBrace) candidates.push(content.substring(firstBrace, lastBrace + 1));
1395
- for (const candidate of [...new Set(candidates)]) {
1396
- try {
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("无法从 AI 响应中解析出有效的 JSON");
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").required()
1442
- })).description("端点列表"),
1443
- analysisModel: import_koishi3.Schema.string().description("分析模型"),
1444
- duplicateCheckModel: import_koishi3.Schema.string().description("查重模型")
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 duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
1562
- if (duplicateResult?.duplicate && duplicateResult.ids?.length > 0) {
1563
- await session.send(`回声洞(${newId})添加失败:内容与回声洞(${duplicateResult.ids.join("|")})重复`);
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) await ctx.database.upsert("cave_meta", analyses);
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) reviewManager.sendForPend(newCave);
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" }]);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "功能强大、高度可定制的回声洞插件。支持丰富的媒体类型、内容查重、AI分析、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
- "version": "2.7.30",
4
+ "version": "2.7.32",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],