koishi-plugin-best-cave 2.7.30 → 2.7.31

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 分析系统提示词,使用 'type' 作为主分类字段。
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,10 @@ 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;
68
66
  }
69
67
  export declare const Config: Schema<Config>;
70
68
  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,42 @@ 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;
1143
1115
  /**
1144
- * @description 注册所有与 AIManager 功能相关的 Koishi 命令,包括 AI 分析和内容比较。
1145
- * @param {any} cave - 主命令的实例,用于挂载子命令。
1116
+ * @description AI 分析系统提示词,使用 'type' 作为主分类字段。
1117
+ */
1118
+ ANALYSIS_SYSTEM_PROMPT = `你需要分析给定的内容,并按照以下规则进行评分、分类和提取内容中的关键词。
1119
+ 你的回复必须且只能是一个JSON对象,禁止含有任何其他内容,例如{"rating": 88,"type": "Game","keywords": ["Minecraft", "Nether"]}。
1120
+ 1."rating" (整数, 0-100): 对内容进行严格且有区分度的评分,以下为评分标准:
1121
+ - 基础分: 50
1122
+ +10至+20: 高原创性、创意或艺术性。
1123
+ +10至+20: 非常搞笑、幽默或有很强的笑点。
1124
+ +10至+20: 引人深思、感人或有强烈的共鸣。
1125
+ +5至+15: 玩梗巧妙或二创质量高,能识别出具体梗/文化背景。
1126
+ -10至-20: 内容质量低下(如图片模糊、有压缩痕迹、文字错别字)。
1127
+ -10至-20: 内容意义不明或非常无聊。
1128
+ -5至-15: 简单或低创意的烂梗、过时流行语。
1129
+ -20至-30: 几乎没有信息量的内容。
1130
+ 2."type" (字符串): 对内容进行严格且标准的分类,以下为分类标准:
1131
+ - Game: 与电子游戏直接相关或源自于电子游戏的内容。
1132
+ - ACG: 与动漫、漫画及广义二次元文化紧密相关的内容。
1133
+ - Internet: 源于互联网的通用流行文化、迷因(Meme)或社群现象。
1134
+ - Reality: 取材于现实世界的日常经验和场景的内容。
1135
+ - Creative: 具有独特的原创性、艺术性或巧妙构思的内容。
1136
+ - Other: 不适合归入以上任何一类的无关内容或小众内容。
1137
+ 3."keywords" (字符串数组): 从内容中提取全面且细分的关键词,以下为提取准则:
1138
+ - 直接提取: 优先从文字内容中直接提取核心词汇,而不是进行归纳或总结。提取图片中可辨识的对象、场景或文字。
1139
+ - 简洁规范: 关键词必须简短且为规范化、普遍使用的词语。例如,使用“明日方舟”而非“粥”,使用“梗”而非“梗图”。
1140
+ - 全面细分: 提取多个不同维度的关键词,包括但不限于:人物/对象、场景/地点、事件/行为、特定梗/文化元素。
1141
+ - 避免宽泛: 确保关键词具体且相关,避免使用过于宽泛或模糊的术语,避免近似关键词,所有词应完整定义内容。`;
1142
+ /**
1143
+ * @description 用于查重的 AI 系统提示词。
1144
+ */
1145
+ DUPLICATE_SYSTEM_PROMPT = `你需要比较给定的“新内容”(content_new)与“候选内容”(candidate_contents),识别内容语义或核心思想重复的候选内容。
1146
+ 你的回复必须且只能是一个JSON数组,禁止含有任何其他内容,只包含重复项ID,例如[1, 2],若无重复,则返回[]。`;
1147
+ /**
1148
+ * @description 注册与 AI 功能相关的管理命令。
1149
+ * @param {any} cave - \`cave\` 命令的实例,用于挂载子命令。
1146
1150
  */
1147
1151
  registerCommands(cave) {
1148
1152
  cave.subcommand(".ai", "分析回声洞", { hidden: true, authority: 4 }).usage("分析尚未分析的回声洞,补全回声洞记录。").action(async ({ session }) => {
@@ -1158,17 +1162,15 @@ var AIManager = class {
1158
1162
  for (let i = 0; i < cavesToAnalyze.length; i += 25) {
1159
1163
  const batch = cavesToAnalyze.slice(i, i + 25);
1160
1164
  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);
1165
+ const results = await Promise.allSettled(batch.map((cave2) => this.analyze([cave2])));
1163
1166
  const successfulAnalyses = [];
1164
1167
  for (let j = 0; j < results.length; j++) {
1165
1168
  const result = results[j];
1166
- const cave2 = batch[j];
1167
1169
  if (result.status === "fulfilled" && result.value.length > 0) {
1168
1170
  successfulAnalyses.push(result.value[0]);
1169
1171
  } else {
1170
1172
  failedCount++;
1171
- if (result.status === "rejected") this.logger.error(`分析回声洞(${cave2.id})失败:`, result.reason);
1173
+ if (result.status === "rejected") this.logger.error(`分析回声洞(${batch[j].id})失败:`, result.reason);
1172
1174
  }
1173
1175
  }
1174
1176
  if (successfulAnalyses.length > 0) {
@@ -1188,33 +1190,39 @@ var AIManager = class {
1188
1190
  try {
1189
1191
  const allMeta = await this.ctx.database.get("cave_meta", {});
1190
1192
  if (allMeta.length < 2) return "无可比较数据";
1191
- const candidatePairs = generateFromLSH(allMeta, (meta) => ({ id: meta.cave, keys: meta.keywords }));
1193
+ const combinedTags = /* @__PURE__ */ __name((meta) => [meta.type, ...meta.keywords || []].filter(Boolean), "combinedTags");
1194
+ const candidatePairs = generateFromLSH(allMeta, (meta) => ({ id: meta.cave, keys: combinedTags(meta) }));
1192
1195
  if (candidatePairs.size === 0) return "未发现相似内容";
1193
- const idsToCompare = /* @__PURE__ */ new Set();
1196
+ const groupedCandidates = /* @__PURE__ */ new Map();
1197
+ const allCaveIds = /* @__PURE__ */ new Set();
1194
1198
  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
1199
  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;
1200
+ if (!groupedCandidates.has(id1)) groupedCandidates.set(id1, /* @__PURE__ */ new Set());
1201
+ groupedCandidates.get(id1).add(id2);
1202
+ allCaveIds.add(id1);
1203
+ allCaveIds.add(id2);
1205
1204
  });
1206
- const results = await Promise.all(comparisonPromises);
1207
- const duplicatePairs = results.filter(Boolean);
1205
+ const caveData = await this.ctx.database.get("cave", { id: { $in: Array.from(allCaveIds) }, status: "active" });
1206
+ const allCaves = new Map(caveData.map((c) => [c.id, c]));
1207
+ const duplicatePairs = [];
1208
+ for (const [mainId, candidateIdsSet] of groupedCandidates.entries()) {
1209
+ const mainCave = allCaves.get(mainId);
1210
+ const candidateCaves = Array.from(candidateIdsSet).map((id) => allCaves.get(id)).filter((c) => !!c);
1211
+ if (mainCave && candidateCaves.length > 0) {
1212
+ const duplicateIds = await this.IsDuplicate(mainCave, candidateCaves);
1213
+ if (duplicateIds && duplicateIds.length > 0) duplicateIds.forEach((candidateId) => duplicatePairs.push({ id1: mainId, id2: candidateId }));
1214
+ }
1215
+ }
1208
1216
  if (duplicatePairs.length === 0) return "未发现高重复性的内容";
1209
1217
  const dsu = new DSU();
1210
- const allIds = /* @__PURE__ */ new Set();
1218
+ const finalIds = /* @__PURE__ */ new Set();
1211
1219
  duplicatePairs.forEach((p) => {
1212
1220
  dsu.union(p.id1, p.id2);
1213
- allIds.add(p.id1);
1214
- allIds.add(p.id2);
1221
+ finalIds.add(p.id1);
1222
+ finalIds.add(p.id2);
1215
1223
  });
1216
1224
  const clusters = /* @__PURE__ */ new Map();
1217
- allIds.forEach((id) => {
1225
+ finalIds.forEach((id) => {
1218
1226
  const root = dsu.find(id);
1219
1227
  if (!clusters.has(root)) clusters.set(root, []);
1220
1228
  clusters.get(root).push(id);
@@ -1223,9 +1231,8 @@ var AIManager = class {
1223
1231
  if (validClusters.length === 0) return "未发现高重复性的内容";
1224
1232
  let report = `共发现 ${validClusters.length} 组高重复性的内容:`;
1225
1233
  validClusters.forEach((cluster) => {
1226
- const sortedCluster = cluster.sort((a, b) => a - b);
1227
1234
  report += `
1228
- - ${sortedCluster.join("|")}`;
1235
+ - ${cluster.sort((a, b) => a - b).join("|")}`;
1229
1236
  });
1230
1237
  return report;
1231
1238
  } catch (error) {
@@ -1235,73 +1242,51 @@ var AIManager = class {
1235
1242
  });
1236
1243
  }
1237
1244
  /**
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 分析或比较过程中发生严重错误时抛出。
1245
+ * @description 检查新内容是否与数据库中已存在的回声洞重复。
1246
+ * @param {StoredElement[]} newElements - 待检查的新内容的元素数组。
1247
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓存,用于加速处理。
1248
+ * @returns {Promise<number[]>} - 一个 Promise,解析为重复的回声洞 ID 数组。如果不重复,则为空数组。
1243
1249
  */
1244
1250
  async checkForDuplicates(newElements, mediaBuffers) {
1245
1251
  try {
1246
1252
  const dummyCave = { id: 0, elements: newElements, channelId: "", userId: "", userName: "", status: "preload", time: /* @__PURE__ */ new Date() };
1247
1253
  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 };
1254
+ if (!newAnalysis || !newAnalysis.type) return [];
1255
+ const allNewTags = [newAnalysis.type, ...newAnalysis.keywords || []];
1256
+ if (allNewTags.length === 1 && !allNewTags[0]) return [];
1257
+ const allMeta = await this.ctx.database.get("cave_meta", { type: newAnalysis.type }, { fields: ["cave", "type", "keywords"] });
1258
+ const similarCaveIds = allMeta.filter((meta) => {
1259
+ const existingTags = [meta.type, ...meta.keywords || []];
1260
+ return this.calculateSimilarity(allNewTags, existingTags) >= 80;
1261
+ }).map((meta) => meta.cave);
1262
+ if (similarCaveIds.length === 0) return [];
1252
1263
  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 };
1264
+ if (potentialDuplicates.length === 0) return [];
1265
+ return await this.IsDuplicate(dummyCave, potentialDuplicates);
1259
1266
  } catch (error) {
1260
1267
  this.logger.error("查重回声洞出错:", error);
1261
- return { duplicate: false };
1268
+ return [];
1262
1269
  }
1263
1270
  }
1264
1271
  /**
1265
- * @description 对单个或批量回声洞执行内容分析,提取关键词、生成描述并评分。
1272
+ * @description 对一个或多个回声洞内容进行 AI 分析。
1266
1273
  * @param {CaveObject[]} caves - 需要分析的回声洞对象数组。
1267
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 预加载的媒体文件缓存,以避免重复读取。
1268
- * @returns {Promise<CaveMetaObject[]>} 一个 Promise,解析为包含分析结果的 `CaveMetaObject` 对象数组。
1274
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓存。
1275
+ * @returns {Promise<CaveMetaObject[]>} - 一个 Promise,解析为分析结果(\`CaveMetaObject\`)的数组。
1269
1276
  */
1270
1277
  async analyze(caves, mediaBuffers) {
1271
- const mediaMap = mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0;
1272
1278
  const analysisPromises = caves.map(async (cave) => {
1273
1279
  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);
1280
+ const contentForAI = await this.prepareContent(cave, mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0);
1281
+ if (!contentForAI) return null;
1297
1282
  const userMessage = { role: "user", content: contentForAI };
1298
- const response = await this.requestAI(this.config.analysisModel, [userMessage], this.ANALYSIS_SYSTEM_PROMPT);
1283
+ const response = await this.requestAI([userMessage], this.ANALYSIS_SYSTEM_PROMPT);
1299
1284
  if (response) {
1300
1285
  return {
1301
1286
  cave: cave.id,
1302
- keywords: response.keywords || [],
1303
- description: response.description || "",
1304
- rating: Math.max(0, Math.min(100, response.rating || 0))
1287
+ rating: Math.max(0, Math.min(100, response.rating || 0)),
1288
+ type: response.type || "",
1289
+ keywords: response.keywords || []
1305
1290
  };
1306
1291
  }
1307
1292
  return null;
@@ -1314,36 +1299,60 @@ ${combinedText}` });
1314
1299
  return results.filter((result) => !!result);
1315
1300
  }
1316
1301
  /**
1317
- * @description 调用 AI 判断两个回声洞内容是否在语义上重复或高度相似。
1318
- * @param {CaveObject} caveA - 第一个回声洞对象。
1319
- * @param {CaveObject} caveB - 第二个回声洞对象。
1320
- * @returns {Promise<boolean>} 如果内容被 AI 判断为重复,则返回 true,否则返回 false
1321
- * @private
1302
+ * @description 准备单个回声洞的内容(文本和图片)以供 AI 模型处理。
1303
+ * @param {CaveObject} cave - 要处理的回声洞对象。
1304
+ * @param {Map<string, Buffer>} [mediaMap] - 媒体文件的缓存 Map。
1305
+ * @returns {Promise<any[] | null>} - 一个 Promise,解析为适合 AI 请求的 content 数组,如果回声洞为空则返回 null
1306
+ */
1307
+ async prepareContent(cave, mediaMap) {
1308
+ const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
1309
+ const imageElements = await Promise.all(
1310
+ cave.elements.filter((el) => el.type === "image" && el.file).map(async (el) => {
1311
+ try {
1312
+ const buffer = mediaMap?.get(el.file) ?? await this.fileManager.readFile(el.file);
1313
+ const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
1314
+ return { type: "image_url", image: { url: `data:${mimeType};base64,${buffer.toString("base64")}` } };
1315
+ } catch (error) {
1316
+ this.logger.warn(`读取文件(${el.file})失败:`, error);
1317
+ return null;
1318
+ }
1319
+ })
1320
+ );
1321
+ const images = imageElements.filter(Boolean);
1322
+ if (!combinedText.trim() && images.length === 0) return null;
1323
+ const contentForAI = [];
1324
+ if (combinedText.trim()) contentForAI.push({ type: "text", text: `${combinedText}` });
1325
+ contentForAI.push(...images);
1326
+ return contentForAI;
1327
+ }
1328
+ /**
1329
+ * @description 使用 AI 批量判断一个主要回声洞是否与一组候选回声洞中的任何一个重复。
1330
+ * @param {CaveObject} mainCave - 主要的回声洞。
1331
+ * @param {CaveObject[]} candidateCaves - 用于比较的候选回声洞数组。
1332
+ * @returns {Promise<number[]>} - 一个 Promise,解析为重复的回声洞 ID 数组。如果不重复,则为空数组。
1322
1333
  */
1323
- async isContentDuplicateAI(caveA, caveB) {
1334
+ async IsDuplicate(mainCave, candidateCaves) {
1324
1335
  try {
1325
1336
  const formatContent = /* @__PURE__ */ __name((elements) => elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" "), "formatContent");
1326
1337
  const userMessageContent = {
1327
- content_a: { id: caveA.id, text: formatContent(caveA.elements) },
1328
- content_b: { id: caveB.id, text: formatContent(caveB.elements) }
1338
+ content_new: { text: formatContent(mainCave.elements) },
1339
+ candidate_contents: candidateCaves.map((cave) => ({ id: cave.id, text: formatContent(cave.elements) }))
1329
1340
  };
1330
1341
  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;
1342
+ const response = await this.requestAI([userMessage], this.DUPLICATE_SYSTEM_PROMPT);
1343
+ return response || [];
1333
1344
  } catch (error) {
1334
- this.logger.error(`比较回声洞(${caveA.id})与(${caveB.id})失败:`, error);
1335
- return false;
1345
+ this.logger.error(`比较回声洞(${mainCave.id})失败:`, error);
1346
+ return [];
1336
1347
  }
1337
1348
  }
1338
1349
  /**
1339
- * @description 计算两组关键词之间的 Jaccard 相似度。
1340
- * Jaccard 相似度 = (交集大小 / 并集大小)。
1341
- * @param {string[]} keywordsA -第一组关键词。
1350
+ * @description 计算两组关键词之间的相似度(Jaccard 相似系数)。
1351
+ * @param {string[]} keywordsA - 第一组关键词。
1342
1352
  * @param {string[]} keywordsB - 第二组关键词。
1343
- * @returns {number} 返回 0 到 100 之间的相似度得分。
1344
- * @private
1353
+ * @returns {number} - 返回 0 到 100 之间的相似度百分比。
1345
1354
  */
1346
- calculateKeywordSimilarity(keywordsA, keywordsB) {
1355
+ calculateSimilarity(keywordsA, keywordsB) {
1347
1356
  if (!keywordsA?.length || !keywordsB?.length) return 0;
1348
1357
  const setA = new Set(keywordsA);
1349
1358
  const setB = new Set(keywordsB);
@@ -1352,54 +1361,33 @@ ${combinedText}` });
1352
1361
  return union.size > 0 ? intersection.size / union.size * 100 : 0;
1353
1362
  }
1354
1363
  /**
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
1364
+ * @description 向配置的 AI 服务端点发送请求的通用方法。
1365
+ * @template T - 期望从 AI 响应中解析出的 JSON 对象的类型。
1366
+ * @param {any[]} messages - 发送给 AI 的消息数组。
1367
+ * @param {string} systemPrompt - 系统提示词。
1368
+ * @returns {Promise<T>} - 一个 Promise,解析为从 AI 响应中解析出的 JSON 对象。
1369
+ * @throws {Error} - 如果 AI 响应为空或无法解析为 JSON,则抛出错误。
1363
1370
  */
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
- };
1371
+ async requestAI(messages, systemPrompt) {
1372
+ const endpointConfig = this.config.endpoints[this.endpointIndex];
1373
+ this.endpointIndex = (this.endpointIndex + 1) % this.config.endpoints.length;
1374
+ const payload = { model: endpointConfig.model, messages: [{ role: "system", content: systemPrompt }, ...messages] };
1380
1375
  const fullUrl = `${endpointConfig.url.replace(/\/$/, "")}/chat/completions`;
1381
- const headers = {
1382
- "Content-Type": "application/json",
1383
- "Authorization": `Bearer ${endpointConfig.key}`
1384
- };
1376
+ const headers = { "Content-Type": "application/json", "Authorization": `Bearer ${endpointConfig.key}` };
1385
1377
  const response = await this.http.post(fullUrl, payload, { headers, timeout: 6e5 });
1386
1378
  const content = response?.choices?.[0]?.message?.content;
1387
- if (!content?.trim()) throw new Error("AI 响应内容为空");
1388
- const candidates = [];
1379
+ if (!content?.trim()) throw new Error("响应为空");
1389
1380
  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
- }
1381
+ if (jsonBlockMatch && jsonBlockMatch[1]) try {
1382
+ return JSON.parse(jsonBlockMatch[1]);
1383
+ } catch (e) {
1384
+ }
1385
+ try {
1386
+ return JSON.parse(content);
1387
+ } catch (e) {
1400
1388
  }
1401
1389
  this.logger.error("原始响应:", JSON.stringify(response, null, 2));
1402
- throw new Error("无法从 AI 响应中解析出有效的 JSON");
1390
+ throw new Error();
1403
1391
  }
1404
1392
  };
1405
1393
 
@@ -1436,12 +1424,10 @@ var Config = import_koishi3.Schema.intersect([
1436
1424
  import_koishi3.Schema.object({
1437
1425
  enableAI: import_koishi3.Schema.boolean().default(false).description("启用 AI"),
1438
1426
  endpoints: import_koishi3.Schema.array(import_koishi3.Schema.object({
1439
- name: import_koishi3.Schema.string().description("名称").required(),
1440
1427
  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("查重模型")
1428
+ key: import_koishi3.Schema.string().description("密钥 (API Key)").role("secret").required(),
1429
+ model: import_koishi3.Schema.string().description("模型 (Model)").required()
1430
+ })).description("端点列表").role("table")
1445
1431
  }).description("模型配置"),
1446
1432
  import_koishi3.Schema.object({
1447
1433
  localPath: import_koishi3.Schema.string().description("文件映射路径"),
@@ -1558,9 +1544,9 @@ function apply(ctx, config) {
1558
1544
  imageHashesToStore = checkResult.imageHashesToStore;
1559
1545
  }
1560
1546
  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("|")})重复`);
1547
+ const duplicateIds = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
1548
+ if (duplicateIds?.length > 0) {
1549
+ await session.send(`回声洞(${newId})添加失败:内容与回声洞(${duplicateIds.join("|")})重复`);
1564
1550
  await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
1565
1551
  await cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
1566
1552
  return;
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.31",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],