koishi-plugin-best-cave 2.7.3 → 2.7.5

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,7 +2,12 @@ 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
+ * @interface CaveMetaObject
6
+ * @description 定义了数据库 `cave_meta` 表的结构模型。
7
+ * @property {number} cave - 关联的回声洞 `id`,作为外键和主键。
8
+ * @property {string[]} keywords - AI 从回声洞内容中提取的核心关键词数组。
9
+ * @property {string} description - AI 生成的对回声洞内容的简洁摘要或描述。
10
+ * @property {number} rating - AI 对内容质量、趣味性或相关性的综合评分,范围为 0 到 100。
6
11
  */
7
12
  export interface CaveMetaObject {
8
13
  cave: number;
@@ -17,8 +22,7 @@ declare module 'koishi' {
17
22
  }
18
23
  /**
19
24
  * @class AIManager
20
- * @description 负责 AI 分析(描述、评分、关键词)和 AI 查重。
21
- * 通过与外部 AI 服务接口交互,实现对回声洞内容的深度分析和重复性检查。
25
+ * @description AI 管理器,是连接 AI 服务与回声洞功能的核心模块。
22
26
  */
23
27
  export declare class AIManager {
24
28
  private ctx;
@@ -28,23 +32,20 @@ export declare class AIManager {
28
32
  private http;
29
33
  /**
30
34
  * @constructor
31
- * @param {Context} ctx - Koishi 的上下文对象。
32
- * @param {Config} config - 插件的配置信息。
33
- * @param {Logger} logger - 日志记录器实例。
34
- * @param {FileManager} fileManager - 文件管理器实例。
35
+ * @description AIManager 类的构造函数,负责初始化依赖项,并向 Koishi 的数据库模型中注册 `cave_meta` 表。
35
36
  */
36
37
  constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
37
38
  /**
38
- * @description 注册与 AI 功能相关的 `.ai` 子命令。
39
- * @param {any} cave - 主 `cave` 命令实例。
39
+ * @description 注册所有与 AIManager 功能相关的 Koishi 命令。
40
+ * @param {any} cave - 主 `cave` 命令的实例,用于在其下注册子命令。
40
41
  */
41
42
  registerCommands(cave: any): void;
42
43
  /**
43
- * @description 对新内容进行两阶段 AI 查重。
44
- * @param {StoredElement[]} newElements - 新内容的元素数组。
45
- * @param {{ sourceUrl: string, fileName: string }[]} newMediaToSave - 新内容中待上传的媒体文件信息。
46
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer
47
- * @returns {Promise<{ duplicate: boolean; id?: number }>} - 返回 AI 判断结果。
44
+ * @description 对新提交的内容执行 AI 驱动的查重检查。
45
+ * @param {StoredElement[]} newElements - 待检查的新内容的结构化数组(包含文本、图片等)。
46
+ * @param {{ sourceUrl: string, fileName: string }[]} newMediaToSave - 伴随新内容提交的、需要从 URL 下载的媒体文件列表。
47
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)已经加载到内存中的媒体文件 Buffer,可用于优化性能。
48
+ * @returns {Promise<{ duplicate: boolean; id?: number }>} 一个包含查重结果的对象。
48
49
  */
49
50
  checkForDuplicates(newElements: StoredElement[], newMediaToSave: {
50
51
  sourceUrl: string;
@@ -57,57 +58,31 @@ export declare class AIManager {
57
58
  id?: number;
58
59
  }>;
59
60
  /**
60
- * @description 分析单个回声洞,并将分析结果存入数据库。
61
- * @param {CaveObject} cave - 需要分析的回声洞对象。
62
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
63
- * @returns {Promise<void>}
61
+ * @description 对单个回声洞对象执行完整的分析和存储流程。
62
+ * @param {CaveObject} cave - 需要被分析的完整回声洞对象,包含 `id` 和 `elements`。
63
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)与该回声洞相关的、已加载到内存的媒体文件 Buffer。
64
+ * @returns {Promise<void>} 操作完成后 resolve 的 Promise。
65
+ * @throws {Error} 如果在分析或数据库存储过程中发生错误,则会向上抛出异常。
64
66
  */
65
67
  analyzeAndStore(cave: CaveObject, mediaBuffers?: {
66
68
  fileName: string;
67
69
  buffer: Buffer;
68
70
  }[]): Promise<void>;
69
71
  /**
70
- * @description 调用 AI 模型获取内容的分析结果。
71
- * @param {StoredElement[]} elements - 内容的元素数组。
72
- * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选) 待保存的媒体文件信息。
73
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
74
- * @returns {Promise<Omit<CaveMetaObject, 'cave'>>} - 返回分析结果对象。
72
+ * @description 准备并发送内容给 AI 模型以获取分析结果。
73
+ * @param {StoredElement[]} elements - 内容的结构化元素数组。
74
+ * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选)需要从网络下载的媒体文件信息。
75
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)已存在于内存中的媒体文件 Buffer。
76
+ * @returns {Promise<Omit<CaveMetaObject, 'cave'>>} 返回一个不含 `cave` 字段的分析结果对象。如果内容为空或无法处理,则返回 `null`。
75
77
  */
76
78
  private getAnalysis;
77
79
  /**
78
- * @description 使用 Jaccard 相似度系数计算两组关键词的相似度。
79
- * @param {Set<string>} setA - 第一组关键词集合。
80
- * @param {Set<string>} setB - 第二组关键词集合。
81
- * @returns {number} - 返回 0 1 之间的相似度值。
80
+ * @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
81
+ * @param {any[]} messages - 要发送给 AI 的消息数组,格式遵循 OpenAI API 规范。
82
+ * @param {string} systemPrompt - 指导 AI 行为的系统级提示词。
83
+ * @param {string} schemaString - 一个 JSON 字符串,定义了期望 AI 返回的 JSON 对象的结构。
84
+ * @returns {Promise<any>} AI 返回的、经过 JSON 解析的响应体。
85
+ * @throws {Error} 当 JSON Schema 解析失败、网络请求失败或 AI 返回错误时,抛出异常。
82
86
  */
83
- private calculateKeywordSimilarity;
84
- /**
85
- * @description 准备发送给 AI 模型的请求体(Payload)。
86
- * @param {string} prompt - 系统提示词。
87
- * @param {string} schemaString - JSON Schema 字符串。
88
- * @param {StoredElement[]} elements - 内容的元素数组。
89
- * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选) 待保存的媒体文件信息。
90
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
91
- * @returns {Promise<{ payload: any }>} - 返回包含请求体的对象。
92
- */
93
- private preparePayload;
94
- /**
95
- * @description 准备用于 AI 精准查重的请求体(Payload)。
96
- * @param {StoredElement[]} newElements - 新内容的元素。
97
- * @param {CaveObject[]} existingCaves - 经过初筛的疑似重复的旧内容。
98
- * @returns {Promise<{ payload: any }>} - 返回适用于查重场景的请求体。
99
- */
100
- private prepareDedupePayload;
101
- /**
102
- * @description 解析 AI 返回的分析响应。
103
- * @param {any} response - AI 服务的原始响应对象。
104
- * @returns {Omit<CaveMetaObject, 'cave'>} - 返回结构化的分析结果。
105
- */
106
- private parseAnalysisResponse;
107
- /**
108
- * @description 解析 AI 返回的查重响应。
109
- * @param {any} response - AI 服务的原始响应对象。
110
- * @returns {{ duplicate: boolean; id?: number }} - 返回查重结果。
111
- */
112
- private parseDedupeResponse;
87
+ private requestAI;
113
88
  }
package/lib/Utils.d.ts CHANGED
@@ -2,8 +2,6 @@ import { Context, h, Logger, Session } from 'koishi';
2
2
  import { CaveObject, Config, StoredElement } from './index';
3
3
  import { FileManager } from './FileManager';
4
4
  import { HashManager, CaveHashObject } from './HashManager';
5
- import { PendManager } from './PendManager';
6
- import { AIManager } from './AIManager';
7
5
  /**
8
6
  * @description 构建一条用于发送的完整回声洞消息,处理不同存储后端的资源链接。
9
7
  * @param cave 回声洞对象。
@@ -69,25 +67,21 @@ export declare function performSimilarityChecks(ctx: Context, config: Config, ha
69
67
  imageHashesToStore?: Omit<CaveHashObject, 'cave'>[];
70
68
  }>;
71
69
  /**
72
- * @description 异步处理文件上传和状态更新的后台任务。
70
+ * @description 异步处理文件上传,并在成功后更新回声洞状态。
73
71
  * @param ctx - Koishi 上下文。
74
72
  * @param config - 插件配置。
75
73
  * @param fileManager - FileManager 实例,用于保存文件。
76
74
  * @param logger - 日志记录器实例。
77
- * @param reviewManager - ReviewManager 实例,用于提交审核。
78
75
  * @param cave - 刚刚在数据库中创建的 `preload` 状态的回声洞对象。
79
76
  * @param downloadedMedia - 需要保存的媒体文件及其 Buffer。
80
77
  * @param reusableIds - 可复用 ID 的内存缓存。
81
- * @param session - 触发此操作的用户会话,用于发送反馈。
82
- * @param hashManager - HashManager 实例,如果启用则用于哈希计算和比较。
83
- * @param textHashesToStore - 已预先计算好的、待存入数据库的文本哈希对象数组。
84
- * @param imageHashesToStore - 已预先计算好的、待存入数据库的图片哈希对象数组。
85
- * @param aiManager - AIManager 实例,如果启用则用于 AI 分析。
78
+ * @param session - 触发此操作的用户会话。
79
+ * @returns 成功则返回最终状态 ('pending' or 'active'),失败则返回 'delete'。
86
80
  */
87
- export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: PendManager, cave: CaveObject, downloadedMedia: {
81
+ export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, cave: CaveObject, downloadedMedia: {
88
82
  fileName: string;
89
83
  buffer: Buffer;
90
- }[], reusableIds: Set<number>, session: Session, hashManager: HashManager, textHashesToStore: Omit<CaveHashObject, 'cave'>[], imageHashesToStore: Omit<CaveHashObject, 'cave'>[], aiManager: AIManager | null): Promise<void>;
84
+ }[], reusableIds: Set<number>, session: Session): Promise<'pending' | 'active' | 'delete'>;
91
85
  /**
92
86
  * @description 校验会话是否来自指定的管理群组。
93
87
  * @param session 当前会话。
package/lib/index.js CHANGED
@@ -482,25 +482,18 @@ async function performSimilarityChecks(ctx, config, hashManager, finalElementsFo
482
482
  return { duplicate: false, textHashesToStore, imageHashesToStore };
483
483
  }
484
484
  __name(performSimilarityChecks, "performSimilarityChecks");
485
- async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, downloadedMedia, reusableIds, session, hashManager, textHashesToStore, imageHashesToStore, aiManager) {
485
+ async function handleFileUploads(ctx, config, fileManager, logger2, cave, downloadedMedia, reusableIds, session) {
486
486
  try {
487
- if (aiManager) await aiManager.analyzeAndStore(cave, downloadedMedia);
488
487
  await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
489
488
  const needsReview = config.enablePend && session.channelId !== config.adminChannel?.split(":")[1];
490
489
  const finalStatus = needsReview ? "pending" : "active";
491
490
  await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
492
- if (hashManager) {
493
- const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h5) => ({ ...h5, cave: cave.id }));
494
- if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert);
495
- }
496
- if (finalStatus === "pending" && reviewManager) {
497
- const [finalCave] = await ctx.database.get("cave", { id: cave.id });
498
- if (finalCave) reviewManager.sendForPend(finalCave);
499
- }
491
+ return finalStatus;
500
492
  } catch (fileProcessingError) {
501
493
  logger2.error(`回声洞(${cave.id})文件处理失败:`, fileProcessingError);
502
494
  await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
503
495
  cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
496
+ return "delete";
504
497
  }
505
498
  }
506
499
  __name(handleFileUploads, "handleFileUploads");
@@ -985,10 +978,7 @@ var path3 = __toESM(require("path"));
985
978
  var AIManager = class {
986
979
  /**
987
980
  * @constructor
988
- * @param {Context} ctx - Koishi 的上下文对象。
989
- * @param {Config} config - 插件的配置信息。
990
- * @param {Logger} logger - 日志记录器实例。
991
- * @param {FileManager} fileManager - 文件管理器实例。
981
+ * @description AIManager 类的构造函数,负责初始化依赖项,并向 Koishi 的数据库模型中注册 `cave_meta` 表。
992
982
  */
993
983
  constructor(ctx, config, logger2, fileManager) {
994
984
  this.ctx = ctx;
@@ -1010,30 +1000,37 @@ var AIManager = class {
1010
1000
  }
1011
1001
  http;
1012
1002
  /**
1013
- * @description 注册与 AI 功能相关的 `.ai` 子命令。
1014
- * @param {any} cave - 主 `cave` 命令实例。
1003
+ * @description 注册所有与 AIManager 功能相关的 Koishi 命令。
1004
+ * @param {any} cave - 主 `cave` 命令的实例,用于在其下注册子命令。
1015
1005
  */
1016
1006
  registerCommands(cave) {
1017
1007
  cave.subcommand(".ai", "分析回声洞", { hidden: true, authority: 4 }).usage("分析尚未分析的回声洞,补全回声洞记录。").action(async ({ session }) => {
1018
- const adminError = requireAdmin(session, this.config);
1019
- if (adminError) return adminError;
1008
+ if (requireAdmin(session, this.config)) return requireAdmin(session, this.config);
1020
1009
  try {
1021
1010
  const allCaves = await this.ctx.database.get("cave", { status: "active" });
1022
1011
  const analyzedCaveIds = new Set((await this.ctx.database.get("cave_meta", {})).map((meta) => meta.cave));
1023
1012
  const cavesToAnalyze = allCaves.filter((cave2) => !analyzedCaveIds.has(cave2.id));
1024
1013
  if (cavesToAnalyze.length === 0) return "无需分析回声洞";
1025
1014
  await session.send(`开始分析 ${cavesToAnalyze.length} 个回声洞...`);
1026
- let totalSuccessCount = 0;
1027
- for (let i = 0; i < cavesToAnalyze.length; i += 5) {
1028
- const batch = cavesToAnalyze.slice(i, i + 5);
1029
- this.logger.info(`[${totalSuccessCount}/${cavesToAnalyze.length}] 正在分析 ${batch.length} 条回声洞...`);
1030
- await Promise.all(batch.map((cave2) => this.analyzeAndStore(cave2)));
1031
- totalSuccessCount += batch.length;
1015
+ let successCount = 0;
1016
+ const batchSize = 100;
1017
+ for (let i = 0; i < cavesToAnalyze.length; i += batchSize) {
1018
+ const batch = cavesToAnalyze.slice(i, i + batchSize);
1019
+ this.logger.info(`[${i}/${cavesToAnalyze.length}] 正在分析 ${batch.length} 条回声洞...`);
1020
+ for (const cave2 of batch) {
1021
+ try {
1022
+ await this.analyzeAndStore(cave2);
1023
+ successCount++;
1024
+ } catch (error) {
1025
+ this.logger.error(`分析回声洞(${cave2.id})时出错:`, error);
1026
+ return `分析回声洞(${cave2.id})时出错: ${error.message}`;
1027
+ }
1028
+ }
1032
1029
  }
1033
- return `已分析 ${totalSuccessCount} 个回声洞`;
1030
+ return `已分析 ${successCount} 个回声洞`;
1034
1031
  } catch (error) {
1035
- this.logger.error("已中断分析回声洞:", error);
1036
- return `分析回声洞失败:${error.message}`;
1032
+ this.logger.error("分析回声洞失败:", error);
1033
+ return `操作失败: ${error.message}`;
1037
1034
  }
1038
1035
  });
1039
1036
  cave.subcommand(".desc <id:posint>", "查询回声洞").action(async ({}, id) => {
@@ -1041,12 +1038,11 @@ var AIManager = class {
1041
1038
  try {
1042
1039
  const [meta] = await this.ctx.database.get("cave_meta", { cave: id });
1043
1040
  if (!meta) return `回声洞(${id})尚未分析`;
1044
- const keywordsText = meta.keywords.join(", ");
1045
1041
  const report = [
1046
1042
  `回声洞(${id})分析结果:`,
1047
1043
  `描述:${meta.description}`,
1048
- `关键词:${keywordsText}`,
1049
- `评分:${meta.rating}/100`
1044
+ `关键词:${meta.keywords.join(", ")}`,
1045
+ `综合评分:${meta.rating}/100`
1050
1046
  ];
1051
1047
  return import_koishi3.h.text(report.join("\n"));
1052
1048
  } catch (error) {
@@ -1056,90 +1052,83 @@ var AIManager = class {
1056
1052
  });
1057
1053
  }
1058
1054
  /**
1059
- * @description 对新内容进行两阶段 AI 查重。
1060
- * @param {StoredElement[]} newElements - 新内容的元素数组。
1061
- * @param {{ sourceUrl: string, fileName: string }[]} newMediaToSave - 新内容中待上传的媒体文件信息。
1062
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer
1063
- * @returns {Promise<{ duplicate: boolean; id?: number }>} - 返回 AI 判断结果。
1055
+ * @description 对新提交的内容执行 AI 驱动的查重检查。
1056
+ * @param {StoredElement[]} newElements - 待检查的新内容的结构化数组(包含文本、图片等)。
1057
+ * @param {{ sourceUrl: string, fileName: string }[]} newMediaToSave - 伴随新内容提交的、需要从 URL 下载的媒体文件列表。
1058
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)已经加载到内存中的媒体文件 Buffer,可用于优化性能。
1059
+ * @returns {Promise<{ duplicate: boolean; id?: number }>} 一个包含查重结果的对象。
1064
1060
  */
1065
1061
  async checkForDuplicates(newElements, newMediaToSave, mediaBuffers) {
1066
1062
  try {
1067
1063
  const newAnalysis = await this.getAnalysis(newElements, newMediaToSave, mediaBuffers);
1068
1064
  if (!newAnalysis || newAnalysis.keywords.length === 0) return { duplicate: false };
1069
- const newKeywords = new Set(newAnalysis.keywords);
1070
1065
  const allMeta = await this.ctx.database.get("cave_meta", {});
1071
- const potentialDuplicates = [];
1072
- for (const meta of allMeta) {
1073
- const existingKeywords = new Set(meta.keywords);
1074
- const similarity = this.calculateKeywordSimilarity(newKeywords, existingKeywords);
1066
+ const potentialDuplicates = (await Promise.all(allMeta.map(async (meta) => {
1067
+ const setA = new Set(newAnalysis.keywords);
1068
+ const setB = new Set(meta.keywords);
1069
+ let similarity = 0;
1070
+ if (setA.size > 0 && setB.size > 0) {
1071
+ const intersection = new Set([...setA].filter((x) => setB.has(x)));
1072
+ const union = /* @__PURE__ */ new Set([...setA, ...setB]);
1073
+ similarity = intersection.size / union.size;
1074
+ }
1075
1075
  if (similarity * 100 >= 80) {
1076
1076
  const [cave] = await this.ctx.database.get("cave", { id: meta.cave });
1077
- if (cave) potentialDuplicates.push(cave);
1077
+ return cave;
1078
1078
  }
1079
- }
1079
+ }))).filter(Boolean);
1080
1080
  if (potentialDuplicates.length === 0) return { duplicate: false };
1081
- const { payload } = await this.prepareDedupePayload(newElements, potentialDuplicates);
1082
- const fullUrl = `${this.config.aiEndpoint}/models/${this.config.aiModel}:generateContent?key=${this.config.aiApiKey}`;
1083
- const response = await this.http.post(fullUrl, payload, { headers: { "Content-Type": "application/json" }, timeout: 9e4 });
1084
- return this.parseDedupeResponse(response);
1081
+ const formatContent = /* @__PURE__ */ __name((elements) => elements.filter((el) => el.type === "text").map((el) => el.content).join(" "), "formatContent");
1082
+ const userMessage = {
1083
+ role: "user",
1084
+ content: JSON.stringify({
1085
+ new_content: { text: formatContent(newElements) },
1086
+ existing_contents: potentialDuplicates.map((cave) => ({ id: cave.id, text: formatContent(cave.elements) }))
1087
+ })
1088
+ };
1089
+ const response = await this.requestAI([userMessage], this.config.aiCheckPrompt, this.config.aiCheckSchema);
1090
+ return {
1091
+ duplicate: response.duplicate || false,
1092
+ id: response.id ? Number(response.id) : void 0
1093
+ };
1085
1094
  } catch (error) {
1086
1095
  this.logger.error("查重回声洞出错:", error);
1087
1096
  return { duplicate: false };
1088
1097
  }
1089
1098
  }
1090
1099
  /**
1091
- * @description 分析单个回声洞,并将分析结果存入数据库。
1092
- * @param {CaveObject} cave - 需要分析的回声洞对象。
1093
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
1094
- * @returns {Promise<void>}
1100
+ * @description 对单个回声洞对象执行完整的分析和存储流程。
1101
+ * @param {CaveObject} cave - 需要被分析的完整回声洞对象,包含 `id` 和 `elements`。
1102
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)与该回声洞相关的、已加载到内存的媒体文件 Buffer。
1103
+ * @returns {Promise<void>} 操作完成后 resolve 的 Promise。
1104
+ * @throws {Error} 如果在分析或数据库存储过程中发生错误,则会向上抛出异常。
1095
1105
  */
1096
1106
  async analyzeAndStore(cave, mediaBuffers) {
1097
1107
  try {
1098
- const analysisResult = await this.getAnalysis(cave.elements, void 0, mediaBuffers);
1099
- if (analysisResult) await this.ctx.database.upsert("cave_meta", [{ cave: cave.id, ...analysisResult }]);
1108
+ const result = await this.getAnalysis(cave.elements, void 0, mediaBuffers);
1109
+ if (result) {
1110
+ await this.ctx.database.upsert("cave_meta", [{
1111
+ cave: cave.id,
1112
+ ...result,
1113
+ rating: Math.max(0, Math.min(100, result.rating || 0))
1114
+ }]);
1115
+ }
1100
1116
  } catch (error) {
1101
1117
  this.logger.error(`分析回声洞(${cave.id})失败:`, error);
1102
1118
  throw error;
1103
1119
  }
1104
1120
  }
1105
1121
  /**
1106
- * @description 调用 AI 模型获取内容的分析结果。
1107
- * @param {StoredElement[]} elements - 内容的元素数组。
1108
- * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选) 待保存的媒体文件信息。
1109
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
1110
- * @returns {Promise<Omit<CaveMetaObject, 'cave'>>} - 返回分析结果对象。
1122
+ * @description 准备并发送内容给 AI 模型以获取分析结果。
1123
+ * @param {StoredElement[]} elements - 内容的结构化元素数组。
1124
+ * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选)需要从网络下载的媒体文件信息。
1125
+ * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选)已存在于内存中的媒体文件 Buffer。
1126
+ * @returns {Promise<Omit<CaveMetaObject, 'cave'>>} 返回一个不含 `cave` 字段的分析结果对象。如果内容为空或无法处理,则返回 `null`。
1111
1127
  */
1112
1128
  async getAnalysis(elements, mediaToSave, mediaBuffers) {
1113
- const { payload } = await this.preparePayload(this.config.AnalysePrompt, this.config.aiAnalyseSchema, elements, mediaToSave, mediaBuffers);
1114
- if (!payload.contents) return null;
1115
- const fullUrl = `${this.config.aiEndpoint}/models/${this.config.aiModel}:generateContent?key=${this.config.aiApiKey}`;
1116
- const response = await this.http.post(fullUrl, payload, { headers: { "Content-Type": "application/json" }, timeout: 6e4 });
1117
- return this.parseAnalysisResponse(response);
1118
- }
1119
- /**
1120
- * @description 使用 Jaccard 相似度系数计算两组关键词的相似度。
1121
- * @param {Set<string>} setA - 第一组关键词集合。
1122
- * @param {Set<string>} setB - 第二组关键词集合。
1123
- * @returns {number} - 返回 0 到 1 之间的相似度值。
1124
- */
1125
- calculateKeywordSimilarity(setA, setB) {
1126
- const intersection = new Set([...setA].filter((x) => setB.has(x)));
1127
- const union = /* @__PURE__ */ new Set([...setA, ...setB]);
1128
- return union.size === 0 ? 0 : intersection.size / union.size;
1129
- }
1130
- /**
1131
- * @description 准备发送给 AI 模型的请求体(Payload)。
1132
- * @param {string} prompt - 系统提示词。
1133
- * @param {string} schemaString - JSON Schema 字符串。
1134
- * @param {StoredElement[]} elements - 内容的元素数组。
1135
- * @param {{ sourceUrl: string, fileName: string }[]} [mediaToSave] - (可选) 待保存的媒体文件信息。
1136
- * @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - (可选) 已下载的媒体文件 Buffer。
1137
- * @returns {Promise<{ payload: any }>} - 返回包含请求体的对象。
1138
- */
1139
- async preparePayload(prompt, schemaString, elements, mediaToSave, mediaBuffers) {
1140
- const parts = [{ text: prompt }];
1129
+ const userContent = [];
1141
1130
  const combinedText = elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
1142
- if (combinedText) parts.push({ text: combinedText });
1131
+ if (combinedText.trim()) userContent.push({ type: "text", text: combinedText });
1143
1132
  const mediaMap = new Map(mediaBuffers?.map((m) => [m.fileName, m.buffer]));
1144
1133
  const imageElements = elements.filter((el) => el.type === "image" && el.file);
1145
1134
  for (const el of imageElements) {
@@ -1155,79 +1144,46 @@ var AIManager = class {
1155
1144
  }
1156
1145
  if (buffer) {
1157
1146
  const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
1158
- parts.push({ inline_data: { mime_type: mimeType, data: buffer.toString("base64") } });
1147
+ userContent.push({
1148
+ type: "image_url",
1149
+ image_url: { url: `data:${mimeType};base64,${buffer.toString("base64")}` }
1150
+ });
1159
1151
  }
1160
1152
  } catch (error) {
1161
1153
  this.logger.warn(`分析内容(${el.file})失败:`, error);
1162
1154
  }
1163
1155
  }
1164
- if (parts.length <= 1) return { payload: {} };
1165
- try {
1166
- const schema = JSON.parse(schemaString);
1167
- return { payload: { contents: [{ parts }], generationConfig: { response_schema: schema } } };
1168
- } catch (error) {
1169
- this.logger.error("解析JSON Schema失败:", error);
1170
- return { payload: {} };
1171
- }
1172
- }
1173
- /**
1174
- * @description 准备用于 AI 精准查重的请求体(Payload)。
1175
- * @param {StoredElement[]} newElements - 新内容的元素。
1176
- * @param {CaveObject[]} existingCaves - 经过初筛的疑似重复的旧内容。
1177
- * @returns {Promise<{ payload: any }>} - 返回适用于查重场景的请求体。
1178
- */
1179
- async prepareDedupePayload(newElements, existingCaves) {
1180
- const formatContent = /* @__PURE__ */ __name((elements) => elements.filter((el) => el.type === "text").map((el) => el.content).join(" "), "formatContent");
1181
- const payloadContent = JSON.stringify({
1182
- new_content: { text: formatContent(newElements) },
1183
- existing_contents: existingCaves.map((cave) => ({ id: cave.id, text: formatContent(cave.elements) }))
1184
- });
1185
- const fullPrompt = `${this.config.aiCheckPrompt}
1186
-
1187
- 以下是需要处理的数据:
1188
- ${payloadContent}`;
1189
- try {
1190
- const schema = JSON.parse(this.config.aiCheckSchema);
1191
- return { payload: { contents: [{ parts: [{ text: fullPrompt }] }], generationConfig: { response_schema: schema } } };
1192
- } catch (error) {
1193
- this.logger.error("解析查重JSON Schema失败:", error);
1194
- return { payload: {} };
1195
- }
1156
+ if (userContent.length === 0) return null;
1157
+ const userMessage = { role: "user", content: userContent };
1158
+ return await this.requestAI([userMessage], this.config.AnalysePrompt, this.config.aiAnalyseSchema);
1196
1159
  }
1197
1160
  /**
1198
- * @description 解析 AI 返回的分析响应。
1199
- * @param {any} response - AI 服务的原始响应对象。
1200
- * @returns {Omit<CaveMetaObject, 'cave'>} - 返回结构化的分析结果。
1161
+ * @description 封装了向 OpenAI 兼容的 API 发送请求的底层逻辑。
1162
+ * @param {any[]} messages - 要发送给 AI 的消息数组,格式遵循 OpenAI API 规范。
1163
+ * @param {string} systemPrompt - 指导 AI 行为的系统级提示词。
1164
+ * @param {string} schemaString - 一个 JSON 字符串,定义了期望 AI 返回的 JSON 对象的结构。
1165
+ * @returns {Promise<any>} AI 返回的、经过 JSON 解析的响应体。
1166
+ * @throws {Error} 当 JSON Schema 解析失败、网络请求失败或 AI 返回错误时,抛出异常。
1201
1167
  */
1202
- parseAnalysisResponse(response) {
1203
- try {
1204
- const content = response.candidates[0].content.parts[0].text;
1205
- const parsed = JSON.parse(content);
1206
- const keywords = Array.isArray(parsed.keywords) ? parsed.keywords : [];
1207
- return {
1208
- keywords,
1209
- description: parsed.description || "",
1210
- rating: Math.max(0, Math.min(100, parsed.rating || 0))
1211
- };
1212
- } catch (error) {
1213
- this.logger.error("分析响应解析失败:", error, "原始响应:", JSON.stringify(response));
1214
- return { keywords: [], description: "解析失败", rating: 0 };
1215
- }
1216
- }
1217
- /**
1218
- * @description 解析 AI 返回的查重响应。
1219
- * @param {any} response - AI 服务的原始响应对象。
1220
- * @returns {{ duplicate: boolean; id?: number }} - 返回查重结果。
1221
- */
1222
- parseDedupeResponse(response) {
1168
+ async requestAI(messages, systemPrompt, schemaString) {
1169
+ let schema = JSON.parse(schemaString);
1170
+ const payload = {
1171
+ model: this.config.aiModel,
1172
+ messages: [{ role: "system", content: systemPrompt }, ...messages],
1173
+ response_format: { type: "json_schema", strict: true, schema }
1174
+ };
1175
+ const fullUrl = `${this.config.aiEndpoint.replace(/\/$/, "")}/chat/completions`;
1176
+ const headers = {
1177
+ "Content-Type": "application/json",
1178
+ "Authorization": `Bearer ${this.config.aiApiKey}`
1179
+ };
1223
1180
  try {
1224
- const content = response.candidates[0].content.parts[0].text;
1225
- const parsed = JSON.parse(content);
1226
- if (parsed.duplicate === true && parsed.id) return { duplicate: true, id: Number(parsed.id) };
1227
- return { duplicate: false };
1181
+ const response = await this.http.post(fullUrl, payload, { headers, timeout: 9e4 });
1182
+ return JSON.parse(response.choices[0].message.content);
1228
1183
  } catch (error) {
1229
- this.logger.error("查重响应解析失败:", error, "原始响应:", JSON.stringify(response));
1230
- return { duplicate: false };
1184
+ const errorMessage = error.response ? JSON.stringify(error.response.data) : error.message;
1185
+ this.logger.error(`请求 API 失败: ${errorMessage}`);
1186
+ throw error;
1231
1187
  }
1232
1188
  }
1233
1189
  };
@@ -1264,7 +1220,7 @@ var Config = import_koishi4.Schema.intersect([
1264
1220
  }).description("复核配置"),
1265
1221
  import_koishi4.Schema.object({
1266
1222
  enableAI: import_koishi4.Schema.boolean().default(false).description("启用 AI"),
1267
- aiEndpoint: import_koishi4.Schema.string().description("端点 (Endpoint)").role("link").default("https://generativelanguage.googleapis.com/v1beta"),
1223
+ aiEndpoint: import_koishi4.Schema.string().description("端点 (Endpoint)").role("link").default("https://generativelanguage.googleapis.com/v1beta/openai"),
1268
1224
  aiApiKey: import_koishi4.Schema.string().description("密钥 (Key)").role("secret"),
1269
1225
  aiModel: import_koishi4.Schema.string().description("模型").default("gemini-2.5-flash"),
1270
1226
  AnalysePrompt: import_koishi4.Schema.string().role("textarea").default(`你是一位内容分析专家。请分析我提供的内容,总结关键词,概括内容并进行评分。`).description("分析提示词 (Prompt)"),
@@ -1409,22 +1365,25 @@ function apply(ctx, config) {
1409
1365
  }
1410
1366
  const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
1411
1367
  const needsReview = config.enablePend && session.channelId !== config.adminChannel?.split(":")[1];
1412
- const initialStatus = hasMedia ? "preload" : needsReview ? "pending" : "active";
1368
+ let finalStatus = hasMedia ? "preload" : needsReview ? "pending" : "active";
1413
1369
  const newCave = await ctx.database.create("cave", {
1414
1370
  id: newId,
1415
1371
  elements: finalElementsForDb,
1416
1372
  channelId: session.channelId,
1417
1373
  userId: session.userId,
1418
1374
  userName,
1419
- status: initialStatus,
1375
+ status: finalStatus,
1420
1376
  time: creationTime
1421
1377
  });
1422
- if (hasMedia) {
1423
- handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, downloadedMedia, reusableIds, session, hashManager, textHashesToStore, imageHashesToStore, aiManager);
1424
- } else {
1425
- if (aiManager) await aiManager.analyzeAndStore(newCave);
1426
- if (hashManager && textHashesToStore.length > 0) await ctx.database.upsert("cave_hash", textHashesToStore.map((h5) => ({ ...h5, cave: newCave.id })));
1427
- if (initialStatus === "pending") reviewManager.sendForPend(newCave);
1378
+ if (hasMedia) finalStatus = await handleFileUploads(ctx, config, fileManager, logger, newCave, downloadedMedia, reusableIds, session);
1379
+ if (finalStatus !== "preload" && finalStatus !== "delete") {
1380
+ newCave.status = finalStatus;
1381
+ if (aiManager) await aiManager.analyzeAndStore(newCave, downloadedMedia);
1382
+ if (hashManager) {
1383
+ const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h5) => ({ ...h5, cave: newCave.id }));
1384
+ if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert);
1385
+ }
1386
+ if (finalStatus === "pending" && reviewManager) reviewManager.sendForPend(newCave);
1428
1387
  }
1429
1388
  return needsReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
1430
1389
  } catch (error) {
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.3",
4
+ "version": "2.7.5",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],