koishi-plugin-best-cave 2.7.18 → 2.7.19

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.
@@ -0,0 +1,95 @@
1
+ import { Context, Logger } from 'koishi';
2
+ import { Config, CaveObject, StoredElement } from './index';
3
+ import { FileManager } from './FileManager';
4
+ /**
5
+ * @description 定义了数据库 `cave_meta` 表的结构模型。
6
+ */
7
+ export interface CaveMetaObject {
8
+ cave: number;
9
+ keywords: string[];
10
+ description: string;
11
+ rating: number;
12
+ }
13
+ declare module 'koishi' {
14
+ interface Tables {
15
+ cave_meta: CaveMetaObject;
16
+ }
17
+ }
18
+ /**
19
+ * @class AIManager
20
+ * @description AI 管理器,作为连接 AI 服务与回声洞功能的核心模块。
21
+ */
22
+ export declare class AIManager {
23
+ private ctx;
24
+ private config;
25
+ private logger;
26
+ private fileManager;
27
+ private http;
28
+ private readonly ANALYSIS_SYSTEM_PROMPT;
29
+ private readonly DUPLICATE_CHECK_SYSTEM_PROMPT;
30
+ /**
31
+ * @constructor
32
+ * @param {Context} ctx - Koishi 的上下文对象,用于访问核心服务如数据库和 HTTP 客户端。
33
+ * @param {Config} config - 插件的配置对象。
34
+ * @param {Logger} logger - 日志记录器实例。
35
+ * @param {FileManager} fileManager - 文件管理器实例,用于处理媒体文件。
36
+ */
37
+ constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
38
+ /**
39
+ * @description 注册所有与 AIManager 功能相关的 Koishi 命令,包括 AI 分析和内容比较。
40
+ * @param {any} cave - 主命令的实例,用于挂载子命令。
41
+ */
42
+ registerCommands(cave: any): void;
43
+ /**
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 分析或比较过程中发生严重错误时抛出。
49
+ */
50
+ checkForDuplicates(newElements: StoredElement[], mediaBuffers?: {
51
+ fileName: string;
52
+ buffer: Buffer;
53
+ }[]): Promise<{
54
+ duplicate: boolean;
55
+ ids?: number[];
56
+ }>;
57
+ /**
58
+ * @description 对单个或批量回声洞执行内容分析,提取关键词、生成描述并评分。
59
+ * @param {CaveObject[]} caves - 需要分析的回声洞对象数组。
60
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 预加载的媒体文件缓存,以避免重复读取。
61
+ * @returns {Promise<CaveMetaObject[]>} 一个 Promise,解析为包含分析结果的 `CaveMetaObject` 对象数组。
62
+ */
63
+ analyze(caves: CaveObject[], mediaBuffers?: {
64
+ fileName: string;
65
+ buffer: Buffer;
66
+ }[]): Promise<CaveMetaObject[]>;
67
+ /**
68
+ * @description 调用 AI 判断两个回声洞内容是否在语义上重复或高度相似。
69
+ * @param {CaveObject} caveA - 第一个回声洞对象。
70
+ * @param {CaveObject} caveB - 第二个回声洞对象。
71
+ * @returns {Promise<boolean>} 如果内容被 AI 判断为重复,则返回 true,否则返回 false。
72
+ * @throws {Error} 当 AI 请求失败时抛出。
73
+ * @private
74
+ */
75
+ private isContentDuplicateAI;
76
+ /**
77
+ * @description 计算两组关键词之间的 Jaccard 相似度。
78
+ * Jaccard 相似度 = (交集大小 / 并集大小)。
79
+ * @param {string[]} keywordsA -第一组关键词。
80
+ * @param {string[]} keywordsB - 第二组关键词。
81
+ * @returns {number} 返回 0 到 100 之间的相似度得分。
82
+ * @private
83
+ */
84
+ private calculateKeywordSimilarity;
85
+ /**
86
+ * @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
87
+ * @template T - 期望从 AI 响应的 JSON 中解析出的数据类型。
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
+ */
94
+ private requestAI;
95
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { Context, Schema } from 'koishi';
2
+ import { CaveHashObject } from './HashManager';
3
+ import { CaveMetaObject } from './AIManager';
4
+ export declare const name = "best-cave";
5
+ export declare const inject: string[];
6
+ export declare const usage = "\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #4a6ee0;\">\uD83D\uDCCC \u63D2\u4EF6\u8BF4\u660E</h2>\n <p>\uD83D\uDCD6 <strong>\u4F7F\u7528\u6587\u6863</strong>\uFF1A\u8BF7\u70B9\u51FB\u5DE6\u4E0A\u89D2\u7684 <strong>\u63D2\u4EF6\u4E3B\u9875</strong> \u67E5\u770B\u63D2\u4EF6\u4F7F\u7528\u6587\u6863</p>\n <p>\uD83D\uDD0D <strong>\u66F4\u591A\u63D2\u4EF6</strong>\uFF1A\u53EF\u8BBF\u95EE <a href=\"https://github.com/YisRime\" style=\"color:#4a6ee0;text-decoration:none;\">\u82E1\u6DDE\u7684 GitHub</a> \u67E5\u770B\u672C\u4EBA\u7684\u6240\u6709\u63D2\u4EF6</p>\n</div>\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #e0574a;\">\u2764\uFE0F \u652F\u6301\u4E0E\u53CD\u9988</h2>\n <p>\uD83C\uDF1F \u559C\u6B22\u8FD9\u4E2A\u63D2\u4EF6\uFF1F\u8BF7\u5728 <a href=\"https://github.com/YisRime\" style=\"color:#e0574a;text-decoration:none;\">GitHub</a> \u4E0A\u7ED9\u6211\u4E00\u4E2A Star\uFF01</p>\n <p>\uD83D\uDC1B \u9047\u5230\u95EE\u9898\uFF1F\u8BF7\u901A\u8FC7 <strong>Issues</strong> \u63D0\u4EA4\u53CD\u9988\uFF0C\u6216\u52A0\u5165 QQ \u7FA4 <a href=\"https://qm.qq.com/q/PdLMx9Jowq\" style=\"color:#e0574a;text-decoration:none;\"><strong>855571375</strong></a> \u8FDB\u884C\u4EA4\u6D41</p>\n</div>\n";
7
+ /**
8
+ * @description 存储在合并转发中的单个节点的数据结构。
9
+ */
10
+ export interface ForwardNode {
11
+ userId: string;
12
+ userName: string;
13
+ elements: StoredElement[];
14
+ }
15
+ /**
16
+ * @description 存储在数据库中的单个消息元素。
17
+ */
18
+ export interface StoredElement {
19
+ type: 'text' | 'image' | 'video' | 'audio' | 'file' | 'at' | 'forward' | 'reply' | 'face';
20
+ content?: string | ForwardNode[];
21
+ file?: string;
22
+ }
23
+ /**
24
+ * @description 数据库 `cave` 表的完整对象模型。
25
+ */
26
+ export interface CaveObject {
27
+ id: number;
28
+ elements: StoredElement[];
29
+ channelId: string;
30
+ userId: string;
31
+ userName: string;
32
+ status: 'active' | 'delete' | 'pending' | 'preload';
33
+ time: Date;
34
+ }
35
+ declare module 'koishi' {
36
+ interface Tables {
37
+ cave: CaveObject;
38
+ cave_hash: CaveHashObject;
39
+ cave_meta: CaveMetaObject;
40
+ }
41
+ }
42
+ export interface Config {
43
+ perChannel: boolean;
44
+ adminChannel: string;
45
+ enableName: boolean;
46
+ enableIO: boolean;
47
+ enablePend: boolean;
48
+ caveFormat: string;
49
+ enableSimilarity: boolean;
50
+ textThreshold: number;
51
+ imageThreshold: number;
52
+ localPath?: string;
53
+ enableS3: boolean;
54
+ endpoint?: string;
55
+ region?: string;
56
+ accessKeyId?: string;
57
+ secretAccessKey?: string;
58
+ bucket?: string;
59
+ publicUrl?: string;
60
+ enableAI: boolean;
61
+ aiEndpoint?: string;
62
+ aiApiKey?: string;
63
+ aiModel?: string;
64
+ }
65
+ export declare const Config: Schema<Config>;
66
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -1014,9 +1014,9 @@ var path3 = __toESM(require("path"));
1014
1014
  var AIManager = class {
1015
1015
  /**
1016
1016
  * @constructor
1017
- * @param {Context} ctx - Koishi 的上下文对象,提供框架核心功能。
1017
+ * @param {Context} ctx - Koishi 的上下文对象,用于访问核心服务如数据库和 HTTP 客户端。
1018
1018
  * @param {Config} config - 插件的配置对象。
1019
- * @param {Logger} logger - 日志记录器实例,用于输出日志。
1019
+ * @param {Logger} logger - 日志记录器实例。
1020
1020
  * @param {FileManager} fileManager - 文件管理器实例,用于处理媒体文件。
1021
1021
  */
1022
1022
  constructor(ctx, config, logger2, fileManager) {
@@ -1038,11 +1038,39 @@ var AIManager = class {
1038
1038
  __name(this, "AIManager");
1039
1039
  }
1040
1040
  http;
1041
- requestCount = 0;
1042
- rateLimitResetTime = 0;
1041
+ ANALYSIS_SYSTEM_PROMPT = `你是一位专业的“数字人类学家”和“迷因(Meme)专家”,擅长分析解读网络社群“回声洞”(一种消息存档)中的内容。这些内容通常是笑话、网络梗、游戏截图、或有趣的引言。你的任务是分析用户提供的内容(可能包含文本和图片),并以严格的 JSON 格式返回分析结果。
1042
+
1043
+ 请严格遵循以下规则和格式:
1044
+
1045
+ 1. **角色定位**:将自己视为熟悉网络流行文化、游戏、动漫和各类“梗”的专家。
1046
+ 2. **语言要求**:\`keywords\` 和 \`description\` 的内容必须全部为中文。
1047
+ 3. **分析与输出**:你的回复**必须且只能**是一个包裹在 \`\`\`json ... \`\`\` 代码块中的 JSON 对象,不包含任何解释性文字。该 JSON 对象必须包含以下三个键:
1048
+
1049
+ * \`"keywords"\` (字符串数组): 提取一组全面的中文标签 (tags),这组标签的组合应能**精准地定义和分类**该内容,便于未来搜索。不需要限制数量,但追求准确和全面,应包含具体的人名、作品名、游戏名、事件名、或网络梗的专有名词。
1050
+
1051
+ * \`"description"\` (字符串): 用一句简洁的中文**概括内容的核心思想或解释其“梗”的来源和用法**。
1052
+
1053
+ * \`"rating"\` (0-100的整数): 根据以下**细化评分标准**进行综合评分:
1054
+ * **创意与原创性 (0-10分)**:是否为原创或独特的二次创作?常见的截图或转发应酌情减分。
1055
+ * **趣味性与信息量 (0-40分)**:内容是否有趣、引人发笑或包含有价值的信息?
1056
+ * **文化价值与传播潜力 (0-30分)**:是否属于经典“梗”或具有成为新流行“梗”的潜力?
1057
+ * **内容质量与清晰度 (0-20分)**:对于图片,是否清晰、无过多水印或压缩痕迹?对于文本,是否排版清晰、易于阅读?**图片模糊、带有严重水印应在此项大幅扣分**。`;
1058
+ DUPLICATE_CHECK_SYSTEM_PROMPT = `你是一位严谨的“网络文化内容查重专家”,尤其擅长识别网络梗、Copypasta(定型文)和笑话的变体。你的任务是比较用户提供的两段内容(content_a 和 content_b),判断它们在**语义上或作为“梗”的本质上是否表达了相同或高度相似的核心思想**。
1059
+
1060
+ 请严格遵循以下规则:
1061
+
1062
+ 1. **重复的核心定义**:专注于核心含义,忽略无关紧要的格式、标点符号、错别字或语气差异。只要两段内容指向**同一个梗、同一个笑话、同一个句式模板或同一个核心事件**,就应视为重复。
1063
+ 2. **常见的重复类型包括**:
1064
+ * **文字变体**:用词略有不同,但表达完全相同的意思。
1065
+ * **句式模板应用**:使用相同的“梗”句式,即使替换了其中的主体。
1066
+ * **核心思想转述**:用不同的话复述了同一个意思或笑话。
1067
+ * **跨语言相同梗**:同一个梗的不同语言或音译版本。
1068
+ 3. **非重复的界定**:主题相似但**核心信息、笑点或结论不同**,则不应视为重复。
1069
+ 4. **严格的JSON输出**:你的回复**必须且只能**是一个包裹在 \`\`\`json ... \`\`\` 代码块中的 JSON 对象。
1070
+ 5. **唯一的输出键**:该 JSON 对象必须仅包含一个布尔类型的键 \`"duplicate"\`。如果内容重复或高度相似,值为 \`true\`,否则为 \`false\`。`;
1043
1071
  /**
1044
- * @description 注册所有与 AIManager 功能相关的 Koishi 命令。
1045
- * @param {any} cave - Koishi 命令实例,用于挂载子命令。
1072
+ * @description 注册所有与 AIManager 功能相关的 Koishi 命令,包括 AI 分析和内容比较。
1073
+ * @param {any} cave - 主命令的实例,用于挂载子命令。
1046
1074
  */
1047
1075
  registerCommands(cave) {
1048
1076
  cave.subcommand(".ai", "分析回声洞", { hidden: true, authority: 4 }).usage("分析尚未分析的回声洞,补全回声洞记录。").action(async ({ session }) => {
@@ -1053,15 +1081,18 @@ var AIManager = class {
1053
1081
  const cavesToAnalyze = allCaves.filter((cave2) => !analyzedCaveIds.has(cave2.id));
1054
1082
  if (cavesToAnalyze.length === 0) return "无需分析回声洞";
1055
1083
  await session.send(`开始分析 ${cavesToAnalyze.length} 个回声洞...`);
1056
- let totalSuccessCount = 0;
1084
+ let successCount = 0;
1057
1085
  const batchSize = 10;
1058
1086
  for (let i = 0; i < cavesToAnalyze.length; i += batchSize) {
1059
1087
  const batch = cavesToAnalyze.slice(i, i + batchSize);
1060
1088
  this.logger.info(`[${i + 1}/${cavesToAnalyze.length}] 正在分析 ${batch.length} 条回声洞...`);
1061
- const successCountInBatch = await this.analyzeAndStore(batch);
1062
- totalSuccessCount += successCountInBatch;
1089
+ const analyses = await this.analyze(batch);
1090
+ if (analyses.length > 0) {
1091
+ await this.ctx.database.upsert("cave_meta", analyses);
1092
+ successCount += analyses.length;
1093
+ }
1063
1094
  }
1064
- return `已分析 ${totalSuccessCount} 个回声洞`;
1095
+ return `已分析 ${successCount} 个回声洞`;
1065
1096
  } catch (error) {
1066
1097
  this.logger.error("分析回声洞失败:", error);
1067
1098
  return `操作失败: ${error.message}`;
@@ -1082,12 +1113,8 @@ var AIManager = class {
1082
1113
  const meta2 = allMeta[j];
1083
1114
  const pairKey = [meta1.cave, meta2.cave].sort((a, b) => a - b).join("-");
1084
1115
  if (checkedPairs.has(pairKey)) continue;
1085
- const keywords1 = new Set(meta1.keywords);
1086
- const keywords2 = new Set(meta2.keywords);
1087
- const intersection = new Set([...keywords1].filter((x) => keywords2.has(x)));
1088
- const union = /* @__PURE__ */ new Set([...keywords1, ...keywords2]);
1089
- const similarity = union.size > 0 ? intersection.size / union.size : 0;
1090
- if (similarity * 100 >= 80) {
1116
+ const similarity = this.calculateKeywordSimilarity(meta1.keywords, meta2.keywords);
1117
+ if (similarity >= 80) {
1091
1118
  const cave1 = allCaves.get(meta1.cave);
1092
1119
  const cave2 = allCaves.get(meta2.cave);
1093
1120
  if (cave1 && cave2 && await this.isContentDuplicateAI(cave1, cave2)) foundPairs.add(`${cave1.id} & ${cave2.id}`);
@@ -1095,11 +1122,9 @@ var AIManager = class {
1095
1122
  }
1096
1123
  }
1097
1124
  }
1098
- if (foundPairs.size === 0) return "未发现高重复性的内容";
1099
- let report = `已发现 ${foundPairs.size} 组高重复性的内容:
1100
- `;
1101
- report += [...foundPairs].join("\n");
1102
- return report.trim();
1125
+ if (foundPairs.size === 0) return "检查完成,未发现高重复性的内容。";
1126
+ return `检查完成,共发现 ${foundPairs.size} 组可能重复的内容:
1127
+ ${[...foundPairs].join("\n")}`;
1103
1128
  } catch (error) {
1104
1129
  this.logger.error("检查重复性失败:", error);
1105
1130
  return `检查失败: ${error.message}`;
@@ -1109,143 +1134,121 @@ var AIManager = class {
1109
1134
  /**
1110
1135
  * @description 对新提交的内容执行 AI 驱动的查重检查。
1111
1136
  * @param {StoredElement[]} newElements - 新提交的内容元素数组。
1112
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓冲区数组。
1113
- * @returns {Promise<{ duplicate: boolean; ids?: number[] }>} 一个 Promise,解析为一个对象,指示内容是否重复以及重复的回声洞 ID 数组(如果存在)。
1137
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 与内容关联的媒体文件缓存。
1138
+ * @returns {Promise<{ duplicate: boolean; ids?: number[] }>} 一个对象,包含查重结果和(如果重复)重复的回声洞 ID 数组。
1139
+ * @throws {Error} 当 AI 分析或比较过程中发生严重错误时抛出。
1114
1140
  */
1115
1141
  async checkForDuplicates(newElements, mediaBuffers) {
1116
1142
  try {
1117
1143
  const dummyCave = { id: 0, elements: newElements, channelId: "", userId: "", userName: "", status: "preload", time: /* @__PURE__ */ new Date() };
1118
- const [newAnalysis] = await this.getAnalyses([dummyCave], mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0);
1119
- if (!newAnalysis?.keywords?.length) return { duplicate: false, ids: [] };
1144
+ const [newAnalysis] = await this.analyze([dummyCave], mediaBuffers);
1145
+ if (!newAnalysis?.keywords?.length) return { duplicate: false };
1120
1146
  const allMeta = await this.ctx.database.get("cave_meta", {}, { fields: ["cave", "keywords"] });
1121
- const newKeywordsSet = new Set(newAnalysis.keywords);
1122
- const similarCaveIds = allMeta.filter((meta) => {
1123
- if (!meta.keywords?.length) return false;
1124
- const existingKeywordsSet = new Set(meta.keywords);
1125
- const intersection = new Set([...newKeywordsSet].filter((x) => existingKeywordsSet.has(x)));
1126
- const union = /* @__PURE__ */ new Set([...newKeywordsSet, ...existingKeywordsSet]);
1127
- const similarity = union.size > 0 ? intersection.size / union.size : 0;
1128
- return similarity * 100 >= 80;
1129
- }).map((meta) => meta.cave);
1130
- if (similarCaveIds.length === 0) return { duplicate: false, ids: [] };
1147
+ const similarCaveIds = allMeta.filter((meta) => this.calculateKeywordSimilarity(newAnalysis.keywords, meta.keywords) >= 80).map((meta) => meta.cave);
1148
+ if (similarCaveIds.length === 0) return { duplicate: false };
1131
1149
  const potentialDuplicates = await this.ctx.database.get("cave", { id: { $in: similarCaveIds } });
1132
- const duplicateIds = [];
1133
- for (const existingCave of potentialDuplicates) if (await this.isContentDuplicateAI(dummyCave, existingCave)) duplicateIds.push(existingCave.id);
1150
+ const comparisonPromises = potentialDuplicates.map(async (existingCave) => {
1151
+ if (await this.isContentDuplicateAI(dummyCave, existingCave)) return existingCave.id;
1152
+ return null;
1153
+ });
1154
+ const duplicateIds = (await Promise.all(comparisonPromises)).filter((id) => id !== null);
1134
1155
  return { duplicate: duplicateIds.length > 0, ids: duplicateIds };
1135
1156
  } catch (error) {
1136
1157
  this.logger.error("查重回声洞出错:", error);
1137
- return { duplicate: false, ids: [] };
1158
+ return { duplicate: false };
1138
1159
  }
1139
1160
  }
1140
1161
  /**
1141
- * @description 对单个或批量回声洞执行完整的分析和存储流程。
1142
- * @param {CaveObject[]} caves - 要分析的回声洞对象数组。
1143
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓冲区数组,仅在分析新内容时使用。
1144
- * @returns {Promise<number>} 一个 Promise,解析为成功分析和存储的条目数。
1162
+ * @description 对单个或批量回声洞执行内容分析,提取关键词、生成描述并评分。
1163
+ * @param {CaveObject[]} caves - 需要分析的回声洞对象数组。
1164
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 预加载的媒体文件缓存,以避免重复读取。
1165
+ * @returns {Promise<CaveMetaObject[]>} 一个 Promise,解析为包含分析结果的 `CaveMetaObject` 对象数组。
1145
1166
  */
1146
- async analyzeAndStore(caves, mediaBuffers) {
1167
+ async analyze(caves, mediaBuffers) {
1147
1168
  const mediaMap = mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0;
1148
- const results = await this.getAnalyses(caves, mediaMap);
1149
- if (!results?.length) return 0;
1150
- const caveMetaObjects = results.map((res) => ({
1151
- cave: res.cave,
1152
- keywords: res.keywords || [],
1153
- description: res.description || "",
1154
- rating: Math.max(0, Math.min(100, res.rating || 0))
1155
- }));
1156
- await this.ctx.database.upsert("cave_meta", caveMetaObjects);
1157
- return caveMetaObjects.length;
1169
+ const analysisPromises = caves.map(async (cave) => {
1170
+ const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
1171
+ const imageElements = await Promise.all(
1172
+ cave.elements.filter((el) => el.type === "image" && el.file).map(async (el) => {
1173
+ try {
1174
+ const buffer = mediaMap?.get(el.file) ?? await this.fileManager.readFile(el.file);
1175
+ const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
1176
+ return {
1177
+ type: "image_url",
1178
+ image_url: { url: `data:${mimeType};base64,${buffer.toString("base64")}` }
1179
+ };
1180
+ } catch (error) {
1181
+ this.logger.warn(`读取文件(${el.file})失败:`, error);
1182
+ }
1183
+ })
1184
+ );
1185
+ const images = imageElements.filter(Boolean);
1186
+ if (!combinedText.trim() && images.length === 0) return null;
1187
+ const contentForAI = [{ type: "text", text: `请分析以下内容:
1188
+
1189
+ ${combinedText}` }, ...images];
1190
+ const userMessage = { role: "user", content: contentForAI };
1191
+ const response = await this.requestAI([userMessage], this.ANALYSIS_SYSTEM_PROMPT);
1192
+ if (response) return {
1193
+ cave: cave.id,
1194
+ keywords: response.keywords || [],
1195
+ description: response.description || "",
1196
+ rating: Math.max(0, Math.min(100, response.rating || 0))
1197
+ };
1198
+ return null;
1199
+ });
1200
+ const results = await Promise.all(analysisPromises);
1201
+ return results.filter((result) => !!result);
1158
1202
  }
1159
1203
  /**
1160
- * @description 调用 AI 判断两个回声洞内容是否重复或高度相似。
1204
+ * @description 调用 AI 判断两个回声洞内容是否在语义上重复或高度相似。
1161
1205
  * @param {CaveObject} caveA - 第一个回声洞对象。
1162
1206
  * @param {CaveObject} caveB - 第二个回声洞对象。
1163
- * @returns {Promise<boolean>} 如果内容相似则返回 true,否则返回 false。
1207
+ * @returns {Promise<boolean>} 如果内容被 AI 判断为重复,则返回 true,否则返回 false。
1208
+ * @throws {Error} 当 AI 请求失败时抛出。
1209
+ * @private
1164
1210
  */
1165
1211
  async isContentDuplicateAI(caveA, caveB) {
1166
1212
  try {
1167
1213
  const formatContent = /* @__PURE__ */ __name((elements) => elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" "), "formatContent");
1168
- const userMessage = {
1169
- role: "user",
1170
- content: JSON.stringify({
1171
- content_a: { id: caveA.id, text: formatContent(caveA.elements) },
1172
- content_b: { id: caveB.id, text: formatContent(caveB.elements) }
1173
- })
1214
+ const userMessageContent = {
1215
+ content_a: { id: caveA.id, text: formatContent(caveA.elements) },
1216
+ content_b: { id: caveB.id, text: formatContent(caveB.elements) }
1174
1217
  };
1175
- const prompt = `你是一位内容查重专家。请判断 content_a content_b 是否重复或高度相似。你的回复必须且只能是一个包裹在 \`\`\`json ... \`\`\` 代码块中的 JSON 对象,该对象仅包含一个键 "duplicate" (布尔值)。`;
1176
- const response = await this.requestAI([userMessage], prompt);
1177
- return response.duplicate || false;
1218
+ const userMessage = { role: "user", content: JSON.stringify(userMessageContent) };
1219
+ const response = await this.requestAI([userMessage], this.DUPLICATE_CHECK_SYSTEM_PROMPT);
1220
+ return response?.duplicate || false;
1178
1221
  } catch (error) {
1179
1222
  this.logger.error(`比较回声洞(${caveA.id})与(${caveB.id})失败:`, error);
1180
1223
  return false;
1181
1224
  }
1182
1225
  }
1183
1226
  /**
1184
- * @description 为一批回声洞准备内容,并向 AI 发送单个请求以获取所有分析结果。
1185
- * @param {CaveObject[]} caves - 要分析的回声洞对象数组。
1186
- * @param {Map<string, Buffer>} [mediaBufferMap] - 可选的媒体文件名到其缓冲区的映射。
1187
- * @returns {Promise<CaveMetaObject[]>} 一个 Promise,解析为 AI 返回的分析结果数组。
1227
+ * @description 计算两组关键词之间的 Jaccard 相似度。
1228
+ * Jaccard 相似度 = (交集大小 / 并集大小)。
1229
+ * @param {string[]} keywordsA -第一组关键词。
1230
+ * @param {string[]} keywordsB - 第二组关键词。
1231
+ * @returns {number} 返回 0 到 100 之间的相似度得分。
1232
+ * @private
1188
1233
  */
1189
- async getAnalyses(caves, mediaBufferMap) {
1190
- const results = [];
1191
- for (const cave of caves) {
1192
- try {
1193
- const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
1194
- const imageElements = await Promise.all(
1195
- cave.elements.filter((el) => el.type === "image" && el.file).map(async (el) => {
1196
- try {
1197
- const buffer = mediaBufferMap?.get(el.file) ?? await this.fileManager.readFile(el.file);
1198
- const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
1199
- return {
1200
- type: "image_url",
1201
- image_url: { url: `data:${mimeType};base64,${buffer.toString("base64")}` }
1202
- };
1203
- } catch (error) {
1204
- this.logger.warn(`读取文件(${el.file})失败:`, error);
1205
- return null;
1206
- }
1207
- })
1208
- );
1209
- const validImages = imageElements.filter(Boolean);
1210
- if (!combinedText.trim() && validImages.length === 0) continue;
1211
- const contentForAI = [{ type: "text", text: combinedText }];
1212
- contentForAI.push(...validImages);
1213
- const userMessage = { role: "user", content: contentForAI };
1214
- const analysePrompt = `你是一位内容分析专家。请使用中文,分析我提供的内容(包含文本和可能的图片),并为其总结关键词、概括内容并评分。你的回复必须且只能是一个包裹在 \`\`\`json ... \`\`\` 代码块中的有效 JSON 对象。该对象必须包含 "keywords" (字符串数组), "description" (字符串), 和 "rating" (0-100的整数)。`;
1215
- const response = await this.requestAI([userMessage], analysePrompt);
1216
- if (response) {
1217
- results.push({
1218
- cave: cave.id,
1219
- keywords: response.keywords || [],
1220
- description: response.description || "",
1221
- rating: response.rating || 0
1222
- });
1223
- }
1224
- } catch (error) {
1225
- this.logger.error(`分析回声洞(${cave.id})失败:`, error);
1226
- }
1227
- }
1228
- return results;
1234
+ calculateKeywordSimilarity(keywordsA, keywordsB) {
1235
+ if (!keywordsA?.length || !keywordsB?.length) return 0;
1236
+ const setA = new Set(keywordsA);
1237
+ const setB = new Set(keywordsB);
1238
+ const intersection = new Set([...setA].filter((x) => setB.has(x)));
1239
+ const union = /* @__PURE__ */ new Set([...setA, ...setB]);
1240
+ return union.size > 0 ? intersection.size / union.size * 100 : 0;
1229
1241
  }
1230
1242
  /**
1231
- * @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑,并稳健地解析 JSON 响应。
1232
- * @param {any[]} messages - 发送给 AI 的消息数组,遵循 OpenAI 格式。
1233
- * @param {string} systemPrompt - 系统提示词,用于指导 AI 的行为。
1234
- * @returns {Promise<T>} 一个 Promise,解析为从 AI 接收到的、解析后的 JSON 对象。
1235
- * @throws {Error} AI 返回空或无效内容时抛出错误。
1243
+ * @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
1244
+ * @template T - 期望从 AI 响应的 JSON 中解析出的数据类型。
1245
+ * @param {any[]} messages - 发送给 AI 的消息数组,通常包含用户消息。
1246
+ * @param {string} systemPrompt - 指导 AI 行为的系统级指令。
1247
+ * @returns {Promise<T>} 一个 Promise,解析为从 AI 响应中提取并解析的 JSON 对象。
1248
+ * @throws {Error} 当网络请求失败、AI 未返回有效内容或 JSON 解析失败时抛出。
1249
+ * @private
1236
1250
  */
1237
1251
  async requestAI(messages, systemPrompt) {
1238
- const now = Date.now();
1239
- if (now > this.rateLimitResetTime) {
1240
- this.rateLimitResetTime = now + 6e4;
1241
- this.requestCount = 0;
1242
- }
1243
- if (this.requestCount >= this.config.aiRPM) {
1244
- const delay = this.rateLimitResetTime - now;
1245
- if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
1246
- this.rateLimitResetTime = Date.now() + 6e4;
1247
- this.requestCount = 0;
1248
- }
1249
1252
  const payload = {
1250
1253
  model: this.config.aiModel,
1251
1254
  messages: [{ role: "system", content: systemPrompt }, ...messages]
@@ -1255,26 +1258,17 @@ var AIManager = class {
1255
1258
  "Content-Type": "application/json",
1256
1259
  "Authorization": `Bearer ${this.config.aiApiKey}`
1257
1260
  };
1258
- this.requestCount++;
1259
- const response = await this.http.post(fullUrl, payload, { headers, timeout: 9e4 });
1261
+ const response = await this.http.post(fullUrl, payload, { headers, timeout: 6e4 });
1260
1262
  const content = response?.choices?.[0]?.message?.content;
1261
- if (typeof content !== "string" || !content.trim()) {
1262
- this.logger.error("原始响应:", JSON.stringify(response, null, 2));
1263
- throw new Error("响应无效");
1264
- }
1263
+ if (typeof content !== "string" || !content.trim()) throw new Error();
1265
1264
  try {
1266
1265
  const jsonRegex = /```json\s*([\s\S]*?)\s*```/;
1267
1266
  const match = content.match(jsonRegex);
1268
- let jsonString = "";
1269
- if (match && match[1]) {
1270
- jsonString = match[1];
1271
- } else {
1272
- jsonString = content;
1273
- }
1267
+ const jsonString = match && match[1] ? match[1] : content;
1274
1268
  return JSON.parse(jsonString);
1275
1269
  } catch (error) {
1276
- this.logger.error("解析 JSON 失败:", error);
1277
- throw new Error("解析失败");
1270
+ this.logger.error("解析 AI 响应 JSON 失败:", error, "原始响应:", JSON.stringify(response, null, 2), "内容:", content);
1271
+ throw new error();
1278
1272
  }
1279
1273
  }
1280
1274
  };
@@ -1311,10 +1305,9 @@ var Config = import_koishi3.Schema.intersect([
1311
1305
  }).description("复核配置"),
1312
1306
  import_koishi3.Schema.object({
1313
1307
  enableAI: import_koishi3.Schema.boolean().default(false).description("启用 AI"),
1314
- aiEndpoint: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link").default("https://generativelanguage.googleapis.com/v1beta/openai"),
1308
+ aiEndpoint: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link").default("https://api.siliconflow.cn/v1"),
1315
1309
  aiApiKey: import_koishi3.Schema.string().description("密钥 (Key)").role("secret"),
1316
- aiModel: import_koishi3.Schema.string().description("模型 (Model)").default("gemini-2.5-flash"),
1317
- aiRPM: import_koishi3.Schema.number().description("每分钟请求数 (RPM)").default(60)
1310
+ aiModel: import_koishi3.Schema.string().description("模型 (Model)").default("THUDM/GLM-4.1V-9B-Thinking")
1318
1311
  }).description("模型配置"),
1319
1312
  import_koishi3.Schema.object({
1320
1313
  localPath: import_koishi3.Schema.string().description("文件映射路径"),
@@ -1430,7 +1423,10 @@ function apply(ctx, config) {
1430
1423
  if (hasMedia) finalStatus = await handleFileUploads(ctx, config, fileManager, logger, newCave, downloadedMedia, reusableIds, needsReview);
1431
1424
  if (finalStatus !== "preload") {
1432
1425
  newCave.status = finalStatus;
1433
- if (aiManager) await aiManager.analyzeAndStore([newCave], downloadedMedia);
1426
+ if (aiManager) {
1427
+ const analyses = await aiManager.analyze([newCave], downloadedMedia);
1428
+ if (analyses.length > 0) await ctx.database.upsert("cave_meta", analyses);
1429
+ }
1434
1430
  if (hashManager) {
1435
1431
  const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: newCave.id }));
1436
1432
  if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "功能强大、高度可定制的回声洞。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
- "version": "2.7.18",
4
+ "version": "2.7.19",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],