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.
- package/lib/AIManager.d.ts +46 -40
- package/lib/index.d.ts +2 -4
- package/lib/index.js +160 -174
- package/package.json +1 -1
package/lib/AIManager.d.ts
CHANGED
|
@@ -2,13 +2,13 @@ import { Context, Logger } from 'koishi';
|
|
|
2
2
|
import { Config, CaveObject, StoredElement } from './index';
|
|
3
3
|
import { FileManager } from './FileManager';
|
|
4
4
|
/**
|
|
5
|
-
* @description 定义了数据库
|
|
5
|
+
* @description 定义了数据库 \`cave_meta\` 表的结构模型。
|
|
6
6
|
*/
|
|
7
7
|
export interface CaveMetaObject {
|
|
8
8
|
cave: number;
|
|
9
|
-
keywords: string[];
|
|
10
|
-
description: string;
|
|
11
9
|
rating: number;
|
|
10
|
+
type: string;
|
|
11
|
+
keywords: string[];
|
|
12
12
|
}
|
|
13
13
|
declare module 'koishi' {
|
|
14
14
|
interface Tables {
|
|
@@ -25,71 +25,77 @@ export declare class AIManager {
|
|
|
25
25
|
private logger;
|
|
26
26
|
private fileManager;
|
|
27
27
|
private http;
|
|
28
|
+
private endpointIndex;
|
|
29
|
+
/**
|
|
30
|
+
* @description AI 分析系统提示词,使用 'type' 作为主分类字段。
|
|
31
|
+
*/
|
|
28
32
|
private readonly ANALYSIS_SYSTEM_PROMPT;
|
|
29
|
-
|
|
33
|
+
/**
|
|
34
|
+
* @description 用于查重的 AI 系统提示词。
|
|
35
|
+
*/
|
|
36
|
+
private readonly DUPLICATE_SYSTEM_PROMPT;
|
|
30
37
|
/**
|
|
31
38
|
* @constructor
|
|
32
|
-
* @
|
|
39
|
+
* @description AIManager 的构造函数。
|
|
40
|
+
* @param {Context} ctx - Koishi 的上下文对象。
|
|
33
41
|
* @param {Config} config - 插件的配置对象。
|
|
34
42
|
* @param {Logger} logger - 日志记录器实例。
|
|
35
|
-
* @param {FileManager} fileManager -
|
|
43
|
+
* @param {FileManager} fileManager - 文件管理器实例。
|
|
36
44
|
*/
|
|
37
45
|
constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
|
|
38
46
|
/**
|
|
39
|
-
* @description
|
|
40
|
-
* @param {any} cave -
|
|
47
|
+
* @description 注册与 AI 功能相关的管理命令。
|
|
48
|
+
* @param {any} cave - \`cave\` 命令的实例,用于挂载子命令。
|
|
41
49
|
*/
|
|
42
50
|
registerCommands(cave: any): void;
|
|
43
51
|
/**
|
|
44
|
-
* @description
|
|
45
|
-
* @param {StoredElement[]} newElements -
|
|
46
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] -
|
|
47
|
-
* @returns {Promise<
|
|
48
|
-
* @throws {Error} 当 AI 分析或比较过程中发生严重错误时抛出。
|
|
52
|
+
* @description 检查新内容是否与数据库中已存在的回声洞重复。
|
|
53
|
+
* @param {StoredElement[]} newElements - 待检查的新内容的元素数组。
|
|
54
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓存,用于加速处理。
|
|
55
|
+
* @returns {Promise<number[]>} - 一个 Promise,解析为重复的回声洞 ID 数组。如果不重复,则为空数组。
|
|
49
56
|
*/
|
|
50
57
|
checkForDuplicates(newElements: StoredElement[], mediaBuffers?: {
|
|
51
58
|
fileName: string;
|
|
52
59
|
buffer: Buffer;
|
|
53
|
-
}[]): Promise<
|
|
54
|
-
duplicate: boolean;
|
|
55
|
-
ids?: number[];
|
|
56
|
-
}>;
|
|
60
|
+
}[]): Promise<number[]>;
|
|
57
61
|
/**
|
|
58
|
-
* @description
|
|
62
|
+
* @description 对一个或多个回声洞内容进行 AI 分析。
|
|
59
63
|
* @param {CaveObject[]} caves - 需要分析的回声洞对象数组。
|
|
60
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] -
|
|
61
|
-
* @returns {Promise<CaveMetaObject[]>} 一个 Promise
|
|
64
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓存。
|
|
65
|
+
* @returns {Promise<CaveMetaObject[]>} - 一个 Promise,解析为分析结果(\`CaveMetaObject\`)的数组。
|
|
62
66
|
*/
|
|
63
67
|
analyze(caves: CaveObject[], mediaBuffers?: {
|
|
64
68
|
fileName: string;
|
|
65
69
|
buffer: Buffer;
|
|
66
70
|
}[]): Promise<CaveMetaObject[]>;
|
|
67
71
|
/**
|
|
68
|
-
* @description
|
|
69
|
-
* @param {CaveObject}
|
|
70
|
-
* @param {
|
|
71
|
-
* @returns {Promise<
|
|
72
|
-
|
|
72
|
+
* @description 准备单个回声洞的内容(文本和图片)以供 AI 模型处理。
|
|
73
|
+
* @param {CaveObject} cave - 要处理的回声洞对象。
|
|
74
|
+
* @param {Map<string, Buffer>} [mediaMap] - 媒体文件的缓存 Map。
|
|
75
|
+
* @returns {Promise<any[] | null>} - 一个 Promise,解析为适合 AI 请求的 content 数组,如果回声洞为空则返回 null。
|
|
76
|
+
*/
|
|
77
|
+
private prepareContent;
|
|
78
|
+
/**
|
|
79
|
+
* @description 使用 AI 批量判断一个主要回声洞是否与一组候选回声洞中的任何一个重复。
|
|
80
|
+
* @param {CaveObject} mainCave - 主要的回声洞。
|
|
81
|
+
* @param {CaveObject[]} candidateCaves - 用于比较的候选回声洞数组。
|
|
82
|
+
* @returns {Promise<number[]>} - 一个 Promise,解析为重复的回声洞 ID 数组。如果不重复,则为空数组。
|
|
73
83
|
*/
|
|
74
|
-
private
|
|
84
|
+
private IsDuplicate;
|
|
75
85
|
/**
|
|
76
|
-
* @description
|
|
77
|
-
*
|
|
78
|
-
* @param {string[]} keywordsA -第一组关键词。
|
|
86
|
+
* @description 计算两组关键词之间的相似度(Jaccard 相似系数)。
|
|
87
|
+
* @param {string[]} keywordsA - 第一组关键词。
|
|
79
88
|
* @param {string[]} keywordsB - 第二组关键词。
|
|
80
|
-
* @returns {number} 返回 0 到 100
|
|
81
|
-
* @private
|
|
89
|
+
* @returns {number} - 返回 0 到 100 之间的相似度百分比。
|
|
82
90
|
*/
|
|
83
|
-
private
|
|
91
|
+
private calculateSimilarity;
|
|
84
92
|
/**
|
|
85
|
-
* @description
|
|
86
|
-
* @template T - 期望从 AI
|
|
87
|
-
* @param {
|
|
88
|
-
* @param {
|
|
89
|
-
* @
|
|
90
|
-
* @
|
|
91
|
-
* @throws {Error} 当网络请求失败、AI 未返回有效内容或 JSON 解析失败时抛出。
|
|
92
|
-
* @private
|
|
93
|
+
* @description 向配置的 AI 服务端点发送请求的通用方法。
|
|
94
|
+
* @template T - 期望从 AI 响应中解析出的 JSON 对象的类型。
|
|
95
|
+
* @param {any[]} messages - 发送给 AI 的消息数组。
|
|
96
|
+
* @param {string} systemPrompt - 系统提示词。
|
|
97
|
+
* @returns {Promise<T>} - 一个 Promise,解析为从 AI 响应中解析出的 JSON 对象。
|
|
98
|
+
* @throws {Error} - 如果 AI 响应为空或无法解析为 JSON,则抛出错误。
|
|
93
99
|
*/
|
|
94
100
|
private requestAI;
|
|
95
101
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface StoredElement {
|
|
|
21
21
|
file?: string;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* @description 数据库
|
|
24
|
+
* @description 数据库 \`cave\` 表的完整对象模型。
|
|
25
25
|
*/
|
|
26
26
|
export interface CaveObject {
|
|
27
27
|
id: number;
|
|
@@ -59,12 +59,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
|
-
* @
|
|
1089
|
+
* @description AIManager 的构造函数。
|
|
1090
|
+
* @param {Context} ctx - Koishi 的上下文对象。
|
|
1090
1091
|
* @param {Config} config - 插件的配置对象。
|
|
1091
1092
|
* @param {Logger} logger - 日志记录器实例。
|
|
1092
|
-
* @param {FileManager} fileManager -
|
|
1093
|
+
* @param {FileManager} fileManager - 文件管理器实例。
|
|
1093
1094
|
*/
|
|
1094
1095
|
constructor(ctx, config, logger2, fileManager) {
|
|
1095
1096
|
this.ctx = ctx;
|
|
@@ -1099,9 +1100,9 @@ var AIManager = class {
|
|
|
1099
1100
|
this.http = ctx.http;
|
|
1100
1101
|
this.ctx.model.extend("cave_meta", {
|
|
1101
1102
|
cave: "unsigned",
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1103
|
+
rating: "unsigned",
|
|
1104
|
+
type: "string",
|
|
1105
|
+
keywords: "json"
|
|
1105
1106
|
}, {
|
|
1106
1107
|
primary: "cave"
|
|
1107
1108
|
});
|
|
@@ -1110,39 +1111,42 @@ var AIManager = class {
|
|
|
1110
1111
|
__name(this, "AIManager");
|
|
1111
1112
|
}
|
|
1112
1113
|
http;
|
|
1113
|
-
|
|
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
|
|
1145
|
-
|
|
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
|
|
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(`分析回声洞(${
|
|
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
|
|
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
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
|
1207
|
-
const
|
|
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
|
|
1218
|
+
const finalIds = /* @__PURE__ */ new Set();
|
|
1211
1219
|
duplicatePairs.forEach((p) => {
|
|
1212
1220
|
dsu.union(p.id1, p.id2);
|
|
1213
|
-
|
|
1214
|
-
|
|
1221
|
+
finalIds.add(p.id1);
|
|
1222
|
+
finalIds.add(p.id2);
|
|
1215
1223
|
});
|
|
1216
1224
|
const clusters = /* @__PURE__ */ new Map();
|
|
1217
|
-
|
|
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
|
-
- ${
|
|
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
|
|
1239
|
-
* @param {StoredElement[]} newElements -
|
|
1240
|
-
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] -
|
|
1241
|
-
* @returns {Promise<
|
|
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
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
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
|
|
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
|
|
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
|
|
1275
|
-
|
|
1276
|
-
cave.elements.filter((el) => el.type === "image" && el.file).map(async (el) => {
|
|
1277
|
-
try {
|
|
1278
|
-
const buffer = mediaMap?.get(el.file) ?? await this.fileManager.readFile(el.file);
|
|
1279
|
-
const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
|
|
1280
|
-
return {
|
|
1281
|
-
type: "image_url",
|
|
1282
|
-
image_url: { url: `data:${mimeType};base64,${buffer.toString("base64")}` }
|
|
1283
|
-
};
|
|
1284
|
-
} catch (error) {
|
|
1285
|
-
this.logger.warn(`读取文件(${el.file})失败:`, error);
|
|
1286
|
-
return null;
|
|
1287
|
-
}
|
|
1288
|
-
})
|
|
1289
|
-
);
|
|
1290
|
-
const images = imageElements.filter(Boolean);
|
|
1291
|
-
if (!combinedText.trim() && images.length === 0) return null;
|
|
1292
|
-
const contentForAI = [];
|
|
1293
|
-
if (combinedText.trim()) contentForAI.push({ type: "text", text: `请分析以下内容:
|
|
1294
|
-
|
|
1295
|
-
${combinedText}` });
|
|
1296
|
-
contentForAI.push(...images);
|
|
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(
|
|
1283
|
+
const response = await this.requestAI([userMessage], this.ANALYSIS_SYSTEM_PROMPT);
|
|
1299
1284
|
if (response) {
|
|
1300
1285
|
return {
|
|
1301
1286
|
cave: cave.id,
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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
|
|
1318
|
-
* @param {CaveObject}
|
|
1319
|
-
* @param {
|
|
1320
|
-
* @returns {Promise<
|
|
1321
|
-
|
|
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
|
|
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
|
-
|
|
1328
|
-
|
|
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(
|
|
1332
|
-
return response
|
|
1342
|
+
const response = await this.requestAI([userMessage], this.DUPLICATE_SYSTEM_PROMPT);
|
|
1343
|
+
return response || [];
|
|
1333
1344
|
} catch (error) {
|
|
1334
|
-
this.logger.error(`比较回声洞(${
|
|
1335
|
-
return
|
|
1345
|
+
this.logger.error(`比较回声洞(${mainCave.id})失败:`, error);
|
|
1346
|
+
return [];
|
|
1336
1347
|
}
|
|
1337
1348
|
}
|
|
1338
1349
|
/**
|
|
1339
|
-
* @description
|
|
1340
|
-
*
|
|
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
|
-
|
|
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
|
|
1356
|
-
* @template T - 期望从 AI
|
|
1357
|
-
* @param {
|
|
1358
|
-
* @param {
|
|
1359
|
-
* @
|
|
1360
|
-
* @
|
|
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(
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
}
|
|
1368
|
-
const [name2, modelName] = modelIdentifier.split("/");
|
|
1369
|
-
if (!name2 || !modelName) {
|
|
1370
|
-
throw new Error(`无效的模型标识符: ${modelIdentifier}。格式应为 '端点名称/模型名称'`);
|
|
1371
|
-
}
|
|
1372
|
-
const endpointConfig = this.config.endpoints?.find((e) => e.name === name2);
|
|
1373
|
-
if (!endpointConfig) {
|
|
1374
|
-
throw new Error(`未在配置中找到名为 '${name2}' 的端点`);
|
|
1375
|
-
}
|
|
1376
|
-
const payload = {
|
|
1377
|
-
model: modelName,
|
|
1378
|
-
messages: [{ role: "system", content: systemPrompt }, ...messages]
|
|
1379
|
-
};
|
|
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("
|
|
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])
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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(
|
|
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
|
-
|
|
1443
|
-
|
|
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
|
|
1562
|
-
if (
|
|
1563
|
-
await session.send(`回声洞(${newId})添加失败:内容与回声洞(${
|
|
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;
|