koishi-plugin-best-cave 2.7.29 → 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 -39
- package/lib/index.d.ts +6 -4
- package/lib/index.js +161 -156
- 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,70 +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 {any[]} messages - 发送给 AI
|
|
88
|
-
* @param {string} systemPrompt -
|
|
89
|
-
* @returns {Promise<T>} 一个 Promise,解析为从 AI
|
|
90
|
-
* @throws {Error}
|
|
91
|
-
* @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,则抛出错误。
|
|
92
99
|
*/
|
|
93
100
|
private requestAI;
|
|
94
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;
|
|
@@ -58,9 +58,11 @@ export interface Config {
|
|
|
58
58
|
bucket?: string;
|
|
59
59
|
publicUrl?: string;
|
|
60
60
|
enableAI: boolean;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
endpoints?: {
|
|
62
|
+
url: string;
|
|
63
|
+
key: string;
|
|
64
|
+
model: string;
|
|
65
|
+
}[];
|
|
64
66
|
}
|
|
65
67
|
export declare const Config: Schema<Config>;
|
|
66
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1114
|
+
endpointIndex = 0;
|
|
1115
|
+
/**
|
|
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],若无重复,则返回[]。`;
|
|
1143
1147
|
/**
|
|
1144
|
-
* @description
|
|
1145
|
-
* @param {any} cave -
|
|
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
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([userMessage], this.
|
|
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,39 +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 {any[]} messages - 发送给 AI
|
|
1358
|
-
* @param {string} systemPrompt -
|
|
1359
|
-
* @returns {Promise<T>} 一个 Promise,解析为从 AI
|
|
1360
|
-
* @throws {Error}
|
|
1361
|
-
* @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,则抛出错误。
|
|
1362
1370
|
*/
|
|
1363
1371
|
async requestAI(messages, systemPrompt) {
|
|
1364
|
-
const
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
}
|
|
1368
|
-
const
|
|
1369
|
-
const headers = {
|
|
1370
|
-
"Content-Type": "application/json",
|
|
1371
|
-
"Authorization": `Bearer ${this.config.aiApiKey}`
|
|
1372
|
-
};
|
|
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] };
|
|
1375
|
+
const fullUrl = `${endpointConfig.url.replace(/\/$/, "")}/chat/completions`;
|
|
1376
|
+
const headers = { "Content-Type": "application/json", "Authorization": `Bearer ${endpointConfig.key}` };
|
|
1373
1377
|
const response = await this.http.post(fullUrl, payload, { headers, timeout: 6e5 });
|
|
1374
1378
|
const content = response?.choices?.[0]?.message?.content;
|
|
1375
|
-
if (!content?.trim()) throw new Error();
|
|
1376
|
-
const candidates = [];
|
|
1379
|
+
if (!content?.trim()) throw new Error("响应为空");
|
|
1377
1380
|
const jsonBlockMatch = content.match(/```json\s*([\s\S]*?)\s*```/i);
|
|
1378
|
-
if (jsonBlockMatch && jsonBlockMatch[1])
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
} catch (parseError) {
|
|
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) {
|
|
1386
1388
|
}
|
|
1387
1389
|
this.logger.error("原始响应:", JSON.stringify(response, null, 2));
|
|
1390
|
+
throw new Error();
|
|
1388
1391
|
}
|
|
1389
1392
|
};
|
|
1390
1393
|
|
|
@@ -1420,9 +1423,11 @@ var Config = import_koishi3.Schema.intersect([
|
|
|
1420
1423
|
}).description("复核配置"),
|
|
1421
1424
|
import_koishi3.Schema.object({
|
|
1422
1425
|
enableAI: import_koishi3.Schema.boolean().default(false).description("启用 AI"),
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
+
endpoints: import_koishi3.Schema.array(import_koishi3.Schema.object({
|
|
1427
|
+
url: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link").required(),
|
|
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")
|
|
1426
1431
|
}).description("模型配置"),
|
|
1427
1432
|
import_koishi3.Schema.object({
|
|
1428
1433
|
localPath: import_koishi3.Schema.string().description("文件映射路径"),
|
|
@@ -1539,9 +1544,9 @@ function apply(ctx, config) {
|
|
|
1539
1544
|
imageHashesToStore = checkResult.imageHashesToStore;
|
|
1540
1545
|
}
|
|
1541
1546
|
if (aiManager) {
|
|
1542
|
-
const
|
|
1543
|
-
if (
|
|
1544
|
-
await session.send(`回声洞(${newId})添加失败:内容与回声洞(${
|
|
1547
|
+
const duplicateIds = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
|
|
1548
|
+
if (duplicateIds?.length > 0) {
|
|
1549
|
+
await session.send(`回声洞(${newId})添加失败:内容与回声洞(${duplicateIds.join("|")})重复`);
|
|
1545
1550
|
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
|
|
1546
1551
|
await cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
|
|
1547
1552
|
return;
|