koishi-plugin-emojiluna
Version:
Smart emoji management plugin with AI categorization
1,533 lines (1,524 loc) • 120 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
var __export = (target, all) => {
for (var name2 in all)
__defProp(target, name2, { get: all[name2], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
Config: () => Config,
DEFAULT_ACCEPTED_IMAGE_TYPES: () => DEFAULT_ACCEPTED_IMAGE_TYPES,
IMAGE_CONTENT_TYPES: () => IMAGE_CONTENT_TYPES,
apply: () => apply,
inject: () => inject,
name: () => name
});
module.exports = __toCommonJS(index_exports);
// src/services/emojiluna.ts
var import_koishi2 = require("koishi");
// src/storage/uploadManager.ts
var import_promises = __toESM(require("fs/promises"));
var import_path = __toESM(require("path"));
var import_crypto2 = require("crypto");
// src/utils.ts
var import_koishi = require("koishi");
var import_crypto = __toESM(require("crypto"));
// src/image/imageProcessor.ts
var import_core = require("@jimp/core");
var import_jimp = require("jimp");
var import_gifuct_js = require("gifuct-js");
var Jimp = (0, import_core.createJimp)({
formats: [...import_jimp.defaultFormats],
plugins: import_jimp.defaultPlugins
});
var clampFrameIndex = /* @__PURE__ */ __name((index, frameCount) => {
if (frameCount <= 1) return 0;
return Math.max(0, Math.min(frameCount - 1, index));
}, "clampFrameIndex");
var toArrayBuffer = /* @__PURE__ */ __name((buffer) => {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
);
}, "toArrayBuffer");
function detectImageFormat(buffer) {
const header = buffer.subarray(0, 12);
if (header[0] === 255 && header[1] === 216) return "jpeg";
if (header[0] === 137 && header[1] === 80 && header[2] === 78 && header[3] === 71)
return "png";
if (header[0] === 71 && header[1] === 73 && header[2] === 70)
return "gif";
if (header[0] === 82 && header[1] === 73 && header[2] === 70 && header[8] === 87 && header[9] === 69 && header[10] === 66 && header[11] === 80)
return "webp";
if (header[0] === 66 && header[1] === 77) return "bmp";
return "unknown";
}
__name(detectImageFormat, "detectImageFormat");
async function getImageMetadata(buffer) {
const format = detectImageFormat(buffer);
if (format === "gif") {
const gif = (0, import_gifuct_js.parseGIF)(toArrayBuffer(buffer));
const frames = (0, import_gifuct_js.decompressFrames)(gif, false);
return {
format,
width: gif.lsd?.width ?? 0,
height: gif.lsd?.height ?? 0,
frameCount: frames.length || 1
};
}
if (format === "webp") {
const frame = await decodeWebpFrame(buffer);
return {
format,
width: frame.width,
height: frame.height,
frameCount: 1
};
}
const image = await Jimp.read(buffer);
return {
format,
width: image.bitmap.width,
height: image.bitmap.height,
frameCount: 1
};
}
__name(getImageMetadata, "getImageMetadata");
function sampleFrameIndices(frameCount, sampleCount) {
const safeFrameCount = Math.max(1, frameCount);
const safeSampleCount = Math.max(1, Math.min(sampleCount, safeFrameCount));
if (safeFrameCount === 1) return [0];
if (safeSampleCount === 1) return [Math.floor(safeFrameCount / 2)];
const indices = /* @__PURE__ */ new Set();
for (let i = 0; i < safeSampleCount; i++) {
const ratio = i / (safeSampleCount - 1);
const index = Math.round((safeFrameCount - 1) * ratio);
indices.add(index);
}
return Array.from(indices).sort((a, b) => a - b);
}
__name(sampleFrameIndices, "sampleFrameIndices");
async function extractSampledFrames(buffer, sampleCount, outputFormat = "png") {
const sourceMetadata = await getImageMetadata(buffer);
const indices = sampleFrameIndices(sourceMetadata.frameCount, sampleCount);
const decodedFrames = await decodeFrames(buffer, sourceMetadata, indices);
const normalizedOutputFormat = sourceMetadata.format === "gif" ? "png" : outputFormat;
const frames = await Promise.all(
decodedFrames.map((frame) => encodeFrame(frame, normalizedOutputFormat))
);
const firstFrame = decodedFrames[0];
const metadata = firstFrame ? {
format: normalizedOutputFormat,
width: firstFrame.width,
height: firstFrame.height,
frameCount: 1
} : {
format: normalizedOutputFormat,
width: sourceMetadata.width,
height: sourceMetadata.height,
frameCount: 1
};
return { frames, metadata };
}
__name(extractSampledFrames, "extractSampledFrames");
async function extractFrameRgba(buffer, metadata, frameIndex) {
const index = clampFrameIndex(frameIndex, metadata.frameCount);
const [frame] = await decodeFrames(buffer, metadata, [index]);
if (!frame) {
throw new Error("Unable to decode frame");
}
return frame;
}
__name(extractFrameRgba, "extractFrameRgba");
async function resizeFrameToGrayscale(frame, targetWidth, targetHeight) {
return resizeToGrayscale(
frame.data,
frame.width,
frame.height,
targetWidth,
targetHeight
);
}
__name(resizeFrameToGrayscale, "resizeFrameToGrayscale");
async function decodeFrames(buffer, metadata, frameIndices) {
if (metadata.frameCount <= 1 || frameIndices.length === 0) {
const frame2 = await decodeStaticFrame(buffer);
return [frame2];
}
if (metadata.format === "gif") {
return decodeGifFrames(buffer, frameIndices);
}
if (metadata.format === "webp") {
const frame2 = await decodeWebpFrame(buffer);
return [frame2];
}
const frame = await decodeStaticFrame(buffer);
return [frame];
}
__name(decodeFrames, "decodeFrames");
async function decodeStaticFrame(buffer) {
const image = await Jimp.read(buffer);
return {
width: image.bitmap.width,
height: image.bitmap.height,
data: new Uint8Array(image.bitmap.data)
};
}
__name(decodeStaticFrame, "decodeStaticFrame");
function decodeGifFrames(buffer, frameIndices) {
const gif = (0, import_gifuct_js.parseGIF)(toArrayBuffer(buffer));
const frames = (0, import_gifuct_js.decompressFrames)(gif, true);
const width = gif.lsd?.width ?? frames[0]?.dims?.width ?? 0;
const height = gif.lsd?.height ?? frames[0]?.dims?.height ?? 0;
const target = new Set(frameIndices);
const output = [];
let canvas = new Uint8Array(width * height * 4);
let restoreCanvas = null;
let previousDisposal = 0;
let previousDims = null;
frames.forEach((frame, index) => {
if (index > 0) {
if (previousDisposal === 2 && previousDims) {
clearRect(canvas, width, previousDims);
} else if (previousDisposal === 3 && restoreCanvas) {
canvas = new Uint8Array(restoreCanvas);
}
}
restoreCanvas = frame.disposalType === 3 ? new Uint8Array(canvas) : null;
drawPatch(canvas, width, new Uint8Array(frame.patch), frame.dims);
if (target.has(index)) {
output.push({
width,
height,
data: new Uint8Array(canvas)
});
}
previousDisposal = frame.disposalType ?? 0;
previousDims = frame.dims ?? null;
});
return output;
}
__name(decodeGifFrames, "decodeGifFrames");
function clearRect(canvas, width, dims) {
const { left, top, width: rectWidth, height: rectHeight } = dims;
for (let y = 0; y < rectHeight; y++) {
const rowStart = (top + y) * width + left;
const rowEnd = rowStart + rectWidth;
for (let x = rowStart; x < rowEnd; x++) {
const offset = x * 4;
canvas[offset] = 0;
canvas[offset + 1] = 0;
canvas[offset + 2] = 0;
canvas[offset + 3] = 0;
}
}
}
__name(clearRect, "clearRect");
function drawPatch(canvas, width, patch, dims) {
const { left, top, width: patchWidth, height: patchHeight } = dims;
for (let y = 0; y < patchHeight; y++) {
for (let x = 0; x < patchWidth; x++) {
const patchOffset = (y * patchWidth + x) * 4;
const alpha = patch[patchOffset + 3];
if (alpha === 0) continue;
const destOffset = ((top + y) * width + (left + x)) * 4;
canvas[destOffset] = patch[patchOffset];
canvas[destOffset + 1] = patch[patchOffset + 1];
canvas[destOffset + 2] = patch[patchOffset + 2];
canvas[destOffset + 3] = alpha;
}
}
}
__name(drawPatch, "drawPatch");
async function decodeWebpFrame(buffer) {
const image = await Jimp.read(buffer);
return {
width: image.bitmap.width,
height: image.bitmap.height,
data: new Uint8Array(image.bitmap.data)
};
}
__name(decodeWebpFrame, "decodeWebpFrame");
async function encodeFrame(frame, format) {
const image = new Jimp({
data: Buffer.from(frame.data),
width: frame.width,
height: frame.height
});
if (format === "jpeg") {
return await image.getBuffer("image/jpeg", { quality: 70 });
}
return await image.getBuffer("image/png");
}
__name(encodeFrame, "encodeFrame");
async function resizeToGrayscale(pixels, width, height, targetWidth, targetHeight) {
const base = new Jimp({
data: Buffer.from(pixels),
width,
height
});
const image = base.resize({ w: targetWidth, h: targetHeight }).greyscale();
const resized = image.bitmap.data;
const grayscale = new Uint8Array(targetWidth * targetHeight);
for (let i = 0, j = 0; i < resized.length; i += 4, j++) {
grayscale[j] = resized[i];
}
return grayscale;
}
__name(resizeToGrayscale, "resizeToGrayscale");
// src/utils.ts
var tryParse = /* @__PURE__ */ __name((text) => {
try {
return JSON.parse(text.trim());
} catch {
return null;
}
}, "tryParse");
var extractors = [
(text) => text.trim(),
(text) => text.replace(/```(?:json|JSON)?\s*/g, "").replace(/```\s*$/g, ""),
(text) => {
const start = text.indexOf("{"), end = text.lastIndexOf("}");
return start !== -1 && end !== -1 && start < end ? text.substring(start, end + 1) : text;
},
(text) => {
const start = text.indexOf("{");
if (start === -1) return text;
let count = 0, end = -1;
for (let i = start; i < text.length; i++) {
if (text[i] === "{") count++;
else if (text[i] === "}" && --count === 0) {
end = i;
break;
}
}
return end !== -1 ? text.substring(start, end + 1) : text;
}
];
async function handleImageUpload(session, content, handler) {
let elements = import_koishi.h.select(session.elements, "img");
if (elements.length === 0) {
elements = import_koishi.h.select(import_koishi.h.parse(content), "img");
}
if (elements.length === 0) {
elements = import_koishi.h.select(session.quote?.elements ?? [], "img");
}
if (elements.length === 0) {
throw new Error("没有找到图片");
}
const images = [];
const readImage = /* @__PURE__ */ __name(async (url) => {
const response = await session.app.http(url, {
responseType: "arraybuffer",
method: "get",
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"
}
});
const buffer = response.data;
images.push(Buffer.from(buffer));
}, "readImage");
for (const element of elements) {
const url = element.attrs.url ?? element.attrs.src;
if (url.startsWith("data:image") && url.includes("base64")) {
const base64 = url.split(",")[1];
images.push(Buffer.from(base64, "base64"));
} else {
try {
await readImage(url);
} catch (error) {
session.app.logger.warn(
`读取图片 ${url} 失败,请检查聊天适配器`,
error
);
}
}
}
return await handler(images);
}
__name(handleImageUpload, "handleImageUpload");
function getImageType(buffer, pure = false) {
const format = detectImageFormat(buffer);
const type = format === "unknown" ? "jpeg" : format;
return pure ? type : `image/${type}`;
}
__name(getImageType, "getImageType");
function formatFileSize(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
__name(formatFileSize, "formatFileSize");
function hashBuffer(buffer) {
return import_crypto.default.createHash("sha256").update(buffer).digest("hex");
}
__name(hashBuffer, "hashBuffer");
// src/storage/uploadManager.ts
var UploadManager = class {
static {
__name(this, "UploadManager");
}
ctx;
config;
emojiHashes = /* @__PURE__ */ new Set();
emojiHashMap = /* @__PURE__ */ new Map();
// hash -> emoji id
aiResultCache = /* @__PURE__ */ new Map();
// hash -> cached result
MAX_CACHE_SIZE = 1e3;
CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
// 7 days
// AI task queue state
_aiPaused = false;
_aiTaskQueue = [];
_processingSet = /* @__PURE__ */ new Set();
_localActiveCount = 0;
_aiTasksMap = /* @__PURE__ */ new Map();
_isDisposed = false;
_taskProcessor = null;
constructor(ctx, config) {
this.ctx = ctx;
this.config = config;
ctx.on("dispose", () => {
this._isDisposed = true;
});
}
/**
* Set the AI task processor callback.
* Must be called before starting the task processor loop.
*/
setTaskProcessor(processor) {
this._taskProcessor = processor;
}
// ─── Hash & Deduplication ───────────────────────────────────────────
/**
* Load existing emoji hashes into memory cache.
* Called during initialization to populate the hash set.
*/
async loadExistingHashes() {
try {
const emojis = await this.ctx.database.get(
"emojiluna_emojis",
{},
{
limit: 1e4,
fields: ["id", "image_hash"]
}
);
if (emojis && emojis.length > 0) {
for (const emoji of emojis) {
if (emoji.image_hash) {
this.emojiHashes.add(emoji.image_hash);
this.emojiHashMap.set(emoji.image_hash, emoji.id);
}
}
}
this.ctx.logger.info(
`UploadManager: Loaded ${this.emojiHashes.size} existing emoji hashes`
);
} catch (err) {
this.ctx.logger.warn(
`UploadManager: Failed to load existing hashes: ${err?.message || err}`
);
}
}
/**
* Validate a new emoji before upload.
* Returns: null if valid, error message if invalid
*/
async validateNewEmoji(imageData, imageHash) {
if (this.emojiHashes.has(imageHash)) {
const existingId = this.emojiHashMap.get(imageHash);
return `Emoji with this content already exists (ID: ${existingId})`;
}
try {
const existing = await this.ctx.database.get("emojiluna_emojis", {
image_hash: imageHash
});
if (existing && existing.length > 0) {
this.emojiHashes.add(imageHash);
this.emojiHashMap.set(imageHash, existing[0].id);
return `Emoji with this content already exists (ID: ${existing[0].id})`;
}
} catch (err) {
this.ctx.logger.warn(
`UploadManager: Database validation failed: ${err?.message || err}`
);
}
const mimeType = getImageType(imageData);
if (!mimeType) {
return "Invalid image format";
}
return null;
}
/**
* Check if a hash already exists in the tracked set.
*/
hasHash(hash) {
return this.emojiHashes.has(hash);
}
/**
* Register an emoji after successful upload.
*/
registerEmoji(id, imageHash) {
this.emojiHashes.add(imageHash);
this.emojiHashMap.set(imageHash, id);
}
// ─── File Management ────────────────────────────────────────────────
/**
* Prepare emoji file for upload.
* Saves buffer to temporary location, returns path.
*/
async prepareUploadFile(imageData) {
const id = (0, import_crypto2.randomUUID)();
const extension = getImageType(imageData, true);
const tempDir = import_path.default.join(this.ctx.baseDir, ".temp");
try {
await import_promises.default.mkdir(tempDir, { recursive: true });
} catch (_) {
}
const tempPath = import_path.default.join(tempDir, `${id}-upload-temp`);
await import_promises.default.writeFile(tempPath, imageData);
return { path: tempPath, id, extension };
}
/**
* Move file from temporary location to final storage.
* Handles cross-device issues (EXDEV).
*/
async finalizeFile(sourcePath, destPath) {
try {
await import_promises.default.rename(sourcePath, destPath);
} catch (error) {
if (error.code === "EXDEV") {
await import_promises.default.copyFile(sourcePath, destPath);
try {
await import_promises.default.unlink(sourcePath);
} catch (_) {
}
} else {
throw error;
}
}
}
/**
* Clean up temporary upload file.
*/
async cleanupTempFile(tempPath) {
try {
await import_promises.default.unlink(tempPath);
} catch (_) {
}
}
/**
* Batch validate multiple emojis.
*/
async validateBatch(emojis) {
const results = [];
for (const emoji of emojis) {
const error = await this.validateNewEmoji(emoji.buffer, emoji.hash);
results.push({ valid: !error, error });
}
return results;
}
// ─── AI Result Cache ────────────────────────────────────────────────
/**
* Cache AI analysis result in memory.
*/
cacheAIResult(hash, result) {
if (this.aiResultCache.size >= this.MAX_CACHE_SIZE) {
const oldestKey = Array.from(this.aiResultCache.entries()).sort(
(a, b) => a[1].timestamp - b[1].timestamp
)[0][0];
this.aiResultCache.delete(oldestKey);
}
this.aiResultCache.set(hash, {
hash,
result,
timestamp: Date.now()
});
}
/**
* Retrieve cached AI analysis result if available and not expired.
*/
getCachedAIResult(hash) {
const cached = this.aiResultCache.get(hash);
if (!cached) return null;
if (Date.now() - cached.timestamp > this.CACHE_TTL_MS) {
this.aiResultCache.delete(hash);
return null;
}
return cached.result;
}
/**
* Clear all memory caches.
*/
clearCache() {
this.emojiHashes.clear();
this.emojiHashMap.clear();
this.aiResultCache.clear();
}
/**
* Get cache statistics.
*/
getCacheStats() {
return {
hashCount: this.emojiHashes.size,
aiResultCacheCount: this.aiResultCache.size
};
}
// ─── AI Task Queue Management ───────────────────────────────────────
/**
* Queue an AI analysis task for background processing.
*/
queueAIAnalysis(emojiId, imagePath, imageHash) {
const id = (0, import_crypto2.randomUUID)();
this._aiTaskQueue.push({
id,
emojiId,
imagePath,
imageHash,
attempts: 0
});
this._aiTasksMap.set(emojiId, {
id,
emojiId,
status: "pending",
createdAt: Date.now()
});
}
/**
* Get current AI task statistics.
*/
getAITaskStats() {
let pending = 0, processing = 0, succeeded = 0, failed = 0;
for (const task of this._aiTasksMap.values()) {
if (task.status === "pending") pending++;
else if (task.status === "processing") processing++;
else if (task.status === "succeeded") succeeded++;
else if (task.status === "failed") failed++;
}
return {
pending,
processing,
succeeded,
failed,
paused: this._aiPaused
};
}
/**
* Get list of emoji IDs that have failed AI analysis.
*/
getFailedAIEmojiIds() {
return Array.from(this._aiTasksMap.values()).filter((t) => t.status === "failed").map((t) => t.emojiId);
}
async getAiTasksAll() {
const result = Array.from(this._aiTasksMap.values());
if (this._taskProcessor) {
for (const item of result) {
if (!item.name) {
const emoji = await this._taskProcessor.getEmojiById(
item.emojiId
);
if (emoji) {
item.name = emoji.name;
}
}
}
}
return result.sort((a, b) => b.createdAt - a.createdAt);
}
deleteAiTask(emojiId) {
this._aiTaskQueue = this._aiTaskQueue.filter(
(t) => t.emojiId !== emojiId
);
this._aiTasksMap.delete(emojiId);
}
async retryAiTask(emojiId) {
await this.reanalyzeBatch([emojiId]);
}
/**
* Pause or resume AI task processing.
*/
setAIPaused(paused) {
this._aiPaused = paused;
this.ctx.logger.info(`AI analysis ${paused ? "paused" : "resumed"}`);
}
/**
* Retry all previously failed AI tasks.
* Returns the number of tasks re-queued.
*/
async retryFailedTasks() {
if (!this._taskProcessor) return 0;
const failedIds = this.getFailedAIEmojiIds();
if (failedIds.length === 0) return 0;
const count = await this.reanalyzeBatch(failedIds);
return count;
}
/**
* Queue a batch of emojis for re-analysis.
* Skips emojis that are already queued or processing.
* Returns the number of tasks queued.
*/
async reanalyzeBatch(ids) {
if (!this._taskProcessor) return 0;
let count = 0;
for (const id of ids) {
const emoji = await this._taskProcessor.getEmojiById(id);
if (!emoji) continue;
if (this._processingSet.has(id)) continue;
if (this._aiTaskQueue.some((t) => t.emojiId === id)) continue;
const buffer = await import_promises.default.readFile(emoji.path);
const hash = hashBuffer(buffer);
const taskId = (0, import_crypto2.randomUUID)();
this._aiTaskQueue.push({
id: taskId,
emojiId: id,
imagePath: emoji.path,
imageHash: hash,
attempts: 0
});
this._aiTasksMap.set(id, {
id: taskId,
emojiId: id,
status: "pending",
name: emoji.name,
createdAt: Date.now()
});
count++;
}
return count;
}
/**
* Process a single AI task.
*/
async processAITask(task) {
if (!task || !task.id || !this._taskProcessor) return;
if (this._processingSet.has(task.emojiId)) {
this.ctx.logger.warn(
`Task ${task.id} already processing locally, skip`
);
return;
}
this._processingSet.add(task.emojiId);
const detail = this._aiTasksMap.get(task.emojiId);
if (detail) detail.status = "processing";
try {
const buffer = await import_promises.default.readFile(task.imagePath);
const base64 = buffer.toString("base64");
const result = await this._taskProcessor.analyzeEmoji(base64);
if (!result) throw new Error("AI Analysis returned null");
if (task.emojiId) {
const emoji = await this._taskProcessor.getEmojiById(
task.emojiId
);
if (emoji) {
const newTags = [
.../* @__PURE__ */ new Set([
...emoji.tags || [],
...result.tags || []
])
];
await this._taskProcessor.updateEmojiInfo(task.emojiId, {
name: result.name || emoji.name,
category: result.category || emoji.category,
tags: newTags
});
}
}
if (task.imageHash) {
this.cacheAIResult(task.imageHash, result);
}
if (detail) {
detail.status = "succeeded";
detail.error = void 0;
}
} catch (err) {
const attempts = (task.attempts || 0) + 1;
if (attempts >= this.config.AIMaxAttempts) {
if (detail) {
detail.status = "failed";
detail.error = err?.message || String(err);
}
this.ctx.logger.warn(
`AI Task ${task.id} permanently failed after ${attempts} attempts: ${err?.message || err}`
);
} else {
const backoff = this.config.AIBackoffBase * Math.pow(2, attempts - 1);
task.attempts = attempts;
task.nextRetryAt = Date.now() + backoff;
this._aiTaskQueue.push(task);
if (detail) detail.status = "pending";
}
this.ctx.logger.warn(
`AI Task ${task.id} failed: ${err?.message || err}`
);
} finally {
this._processingSet.delete(task.emojiId);
this._localActiveCount = Math.max(0, this._localActiveCount - 1);
}
}
/**
* Start the background AI task processor loop.
* Runs until the context is disposed.
*/
async startAITaskProcessor() {
this.ctx.logger.info("AI Task Processor loop started");
while (!this._isDisposed) {
try {
if (this._aiPaused) {
await new Promise((resolve3) => setTimeout(resolve3, 2e3));
continue;
}
const concurrency = this.config.aiConcurrency;
if (this._localActiveCount >= concurrency) {
await new Promise((resolve3) => setTimeout(resolve3, 1e3));
continue;
}
const now = Date.now();
const readyTasks = [];
const remaining = [];
for (const task of this._aiTaskQueue) {
if (task.nextRetryAt && task.nextRetryAt > now) {
remaining.push(task);
} else {
readyTasks.push(task);
}
}
this._aiTaskQueue = remaining;
if (readyTasks.length === 0) {
await new Promise((resolve3) => setTimeout(resolve3, 2e3));
continue;
}
this.ctx.logger.info(
`AI Task Processor: Found ${readyTasks.length} pending tasks`
);
for (const task of readyTasks) {
if (this._aiPaused || this._isDisposed) {
this._aiTaskQueue.push(task);
continue;
}
if (this._localActiveCount >= concurrency) {
this._aiTaskQueue.push(task);
continue;
}
this._localActiveCount++;
this.processAITask(task).catch((err) => {
this.ctx.logger.error(
`Task ${task.id} unexpected error: ${err.message}`
);
});
const delay = this.config.AIBatchDelay;
if (delay > 0) {
await new Promise(
(resolve3) => setTimeout(resolve3, delay)
);
}
}
await new Promise((resolve3) => setTimeout(resolve3, 100));
} catch (err) {
this.ctx.logger.error(`AI Loop error: ${err.message}`);
await new Promise((resolve3) => setTimeout(resolve3, 5e3));
}
}
this.ctx.logger.info("AI Task Processor loop stopped");
}
};
// src/services/aiAnalyzer.ts
var import_messages = require("@langchain/core/messages");
var import_string = require("koishi-plugin-chatluna/utils/string");
var import_count_tokens = require("koishi-plugin-chatluna/llm-core/utils/count_tokens");
var AIAnalyzer = class _AIAnalyzer {
constructor(ctx, config) {
this.ctx = ctx;
this.config = config;
}
static {
__name(this, "AIAnalyzer");
}
static AI_FRAME_SAMPLES = 3;
_model = null;
get model() {
return this._model;
}
async initialize() {
if (!this.config.autoCategorize && !this.config.autoAnalyze) return;
try {
const [platform] = (0, import_count_tokens.parseRawModelName)(this.config.model);
await this.ctx.chatluna.awaitLoadPlatform(platform);
this._model = await this.ctx.chatluna.createChatModel(
this.config.model
);
this.ctx.logger.success("AI模型加载成功");
} catch (error) {
this.ctx.logger.error("AI模型加载失败:", error);
}
}
parseAIResult(result) {
for (const extractor of extractors) {
const extracted = extractor(result);
const parsed = tryParse(extracted);
if (parsed) return parsed;
}
this.ctx.logger.error(`AI结果解析失败: ${result}`);
return null;
}
async buildAIImages(imageBase64) {
try {
const buffer = Buffer.from(imageBase64, "base64");
const metadata = await getImageMetadata(buffer);
if (metadata.frameCount <= 1) {
return [
{ data: imageBase64, mimeType: `image/${metadata.format}` }
];
}
const { frames, metadata: framesMetadata } = await extractSampledFrames(
buffer,
_AIAnalyzer.AI_FRAME_SAMPLES,
metadata.format
);
if (frames.length === 0) {
return [
{ data: imageBase64, mimeType: `image/${metadata.format}` }
];
}
return frames.map((frame) => ({
data: frame.toString("base64"),
mimeType: `image/${framesMetadata.format}`
}));
} catch (error) {
this.ctx.logger.warn(
`AI image preparation failed: ${error.message}`
);
return [{ data: imageBase64, mimeType: "image/png" }];
}
}
async invokeAI(prompt, imageBase64) {
if (!this._model?.value) return null;
const images = await this.buildAIImages(imageBase64);
const result = await this._model.value.invoke([
new import_messages.SystemMessage(prompt),
new import_messages.HumanMessage({
content: [
{ type: "text", text: "Please analyze this emoji" },
...images.map((image) => ({
type: "image_url",
image_url: {
url: `data:${image.mimeType};base64,${image.data}`,
detail: "low"
}
}))
]
})
]);
return this.parseAIResult((0, import_string.getMessageContent)(result.content));
}
async categorize(imageBase64) {
if (!this._model?.value || !this.config.autoCategorize) return null;
try {
const prompt = this.config.categorizePrompt.replaceAll(
"{categories}",
this.config.categories.join(", ")
);
return await this.invokeAI(prompt, imageBase64);
} catch (error) {
this.ctx.logger.error("AI categorization failed:", error);
return null;
}
}
async analyze(imageBase64) {
if (!this._model?.value || !this.config.autoAnalyze) return null;
try {
const prompt = this.config.analyzePrompt.replaceAll(
"{categories}",
this.config.categories.join(", ")
);
return await this.invokeAI(prompt, imageBase64);
} catch (error) {
this.ctx.logger.error("AI analysis failed:", error);
return null;
}
}
async filterImageType(imageBase64) {
if (!this._model?.value || !this.config.enableImageTypeFilter) {
return null;
}
try {
const parsedResult = await this.invokeAI(this.config.imageFilterPrompt, imageBase64);
if (!parsedResult) return null;
const isAcceptable = parsedResult.isUseful && this.config.acceptedImageTypes.includes(parsedResult.imageType);
return {
imageType: parsedResult.imageType,
isAcceptable,
confidence: parsedResult.confidence,
reason: parsedResult.reason
};
} catch (error) {
this.ctx.logger.error("AI图片类型过滤失败:", error);
return null;
}
}
};
// src/services/emojiluna.ts
var import_path2 = __toESM(require("path"));
var import_promises2 = __toESM(require("fs/promises"));
var import_crypto3 = require("crypto");
var EmojiLunaService = class _EmojiLunaService extends import_koishi2.Service {
constructor(ctx, config) {
super(ctx, "emojiluna", true);
this.config = config;
defineDatabase(ctx);
this._uploadManager = new UploadManager(ctx, config);
this._aiAnalyzer = new AIAnalyzer(ctx, config);
this._uploadManager.setTaskProcessor({
analyzeEmoji: /* @__PURE__ */ __name((base64) => this.analyzeEmoji(base64), "analyzeEmoji"),
updateEmojiInfo: /* @__PURE__ */ __name((id, updates) => this.updateEmojiInfo(id, updates), "updateEmojiInfo"),
getEmojiById: /* @__PURE__ */ __name(async (id) => {
const emoji = this._emojiStorage[id];
if (!emoji) return null;
return {
id: emoji.id,
name: emoji.name,
category: emoji.category,
tags: emoji.tags,
path: emoji.path
};
}, "getEmojiById")
});
this._readyPromise = new Promise((resolve3) => {
this._readyResolve = resolve3;
});
ctx.on("ready", async () => {
await this.initializeStorage();
await this._aiAnalyzer.initialize();
this._isInitialized = true;
this._readyResolve();
this._uploadManager.startAITaskProcessor();
});
}
static {
__name(this, "EmojiLunaService");
}
_emojiStorage = {};
_categories = {};
_isInitialized = false;
_readyPromise;
_readyResolve;
_isDisposed = false;
_uploadManager;
_aiAnalyzer;
get ready() {
return this._readyPromise;
}
get uploadManager() {
return this._uploadManager;
}
async initializeStorage() {
const storageDir = import_path2.default.resolve(
this.ctx.baseDir,
this.config.storagePath
);
try {
await import_promises2.default.access(storageDir);
} catch {
await import_promises2.default.mkdir(storageDir, { recursive: true });
}
await this.loadEmojis();
await this.loadCategories();
await this._uploadManager.loadExistingHashes();
this.ctx.on("dispose", () => {
this._isDisposed = true;
this._emojiStorage = {};
this._categories = {};
});
}
get isInitialized() {
return this._isInitialized;
}
// ─── AI Methods (delegate to AIAnalyzer) ────────────────────────────
async categorizeEmoji(imageBase64) {
const result = await this._aiAnalyzer.categorize(imageBase64);
if (result?.newCategory) {
const exists = await this.getCategoryByName(result.newCategory);
if (!exists) {
await this.addCategory(result.newCategory, "AI建议的新分类");
}
result.category = result.newCategory;
}
return result;
}
async analyzeEmoji(imageBase64) {
const result = await this._aiAnalyzer.analyze(imageBase64);
if (result?.newCategory) {
const exists = await this.getCategoryByName(result.newCategory);
if (!exists) {
await this.addCategory(result.newCategory, "AI建议的新分类");
}
result.category = result.newCategory;
}
return result;
}
async filterImageByType(imageBase64) {
return this._aiAnalyzer.filterImageType(imageBase64);
}
// ─── Emoji CRUD ─────────────────────────────────────────────────────
async addEmoji(options, source, aiAnalysis = this.config.autoAnalyze) {
const id = (0, import_crypto3.randomUUID)();
let sourcePath = null;
let imageBuffer;
if ("path" in source) {
sourcePath = source.path;
imageBuffer = await import_promises2.default.readFile(sourcePath);
} else {
imageBuffer = source;
}
const mimeType = getImageType(imageBuffer);
const extension = getImageType(imageBuffer, true);
const imageHash = hashBuffer(imageBuffer);
const validationError = await this._uploadManager.validateNewEmoji(
imageBuffer,
imageHash
);
if (validationError) {
if (sourcePath) {
try {
await import_promises2.default.unlink(sourcePath);
} catch (_) {
}
}
throw new Error(validationError);
}
const storageDir = import_path2.default.resolve(
this.ctx.baseDir,
this.config.storagePath
);
const fileName = `${id}.${extension}`;
const destPath = import_path2.default.join(storageDir, fileName);
await import_promises2.default.mkdir(storageDir, { recursive: true });
if (sourcePath) {
await this._uploadManager.finalizeFile(sourcePath, destPath);
} else {
await import_promises2.default.writeFile(destPath, imageBuffer);
}
this._uploadManager.registerEmoji(id, imageHash);
let finalOptions = { ...options };
if (aiAnalysis) {
const cachedResult = this._uploadManager.getCachedAIResult(imageHash);
if (cachedResult) {
finalOptions = {
name: cachedResult.name || options.name,
category: cachedResult.category || options.category || "Other",
tags: [
.../* @__PURE__ */ new Set([
...options.tags || [],
...cachedResult.tags || []
])
],
description: cachedResult.description
};
} else {
this._uploadManager.queueAIAnalysis(id, destPath, imageHash);
}
} else if (this.config.autoCategorize && !options.category) {
const imageBase64 = imageBuffer.toString("base64");
const categorizeResult = await this.categorizeEmoji(imageBase64);
if (categorizeResult) {
finalOptions.category = categorizeResult.category;
} else {
throw new Error("AI categorization failed, unable to add emoji");
}
}
const emoji = {
id,
name: finalOptions.name,
category: finalOptions.category || "Other",
path: destPath,
size: imageBuffer.length,
mimeType,
createdAt: /* @__PURE__ */ new Date(),
tags: finalOptions.tags || []
};
this._emojiStorage[id] = emoji;
await this.ctx.database.upsert("emojiluna_emojis", [
{
id: emoji.id,
name: emoji.name,
category: emoji.category,
path: emoji.path,
size: emoji.size,
mime_type: emoji.mimeType,
created_at: emoji.createdAt,
tags: JSON.stringify(emoji.tags),
image_hash: imageHash
}
]);
await this.updateCategoryEmojiCount(emoji.category);
this.ctx.logger.success(`Emoji added: ${emoji.name} (${emoji.id})`);
this.ctx.emit("emojiluna/emoji-added", emoji);
return emoji;
}
async addEmojis(emojis, aiAnalysis) {
const createdEmojis = [];
const batchSize = 6;
for (let i = 0; i < emojis.length; i += batchSize) {
const batch = emojis.slice(i, i + batchSize);
const results = await Promise.all(
batch.map(async ({ options, buffer }) => {
try {
return await this.addEmoji(options, buffer, aiAnalysis);
} catch (error) {
this.ctx.logger.error(
`Failed to add emoji ${options.name}:`,
error
);
return null;
}
})
);
for (const emoji of results) {
if (emoji) createdEmojis.push(emoji);
}
}
return createdEmojis;
}
async getEmojiByName(name2) {
return Object.values(this._emojiStorage).find(
(emoji) => emoji.name === name2 || emoji.tags.some((tag) => tag === name2) || emoji.category === name2 || emoji.id === name2
) || null;
}
async getEmojisByName(name2) {
return Object.values(this._emojiStorage).filter(
(emoji) => emoji.name === name2 || emoji.tags.some((tag) => tag === name2) || emoji.category === name2
);
}
async getEmojiById(id) {
return this._emojiStorage[id] || null;
}
filterEmojis(options = {}) {
const { keyword, category, tags } = options;
let emojis = Object.values(this._emojiStorage);
if (keyword?.trim()) {
const normalizedKeyword = keyword.trim();
emojis = emojis.filter(
(emoji) => emoji.name.includes(normalizedKeyword) || emoji.tags.some((tag) => tag.includes(normalizedKeyword))
);
}
if (category) {
emojis = emojis.filter((emoji) => emoji.category === category);
}
if (tags?.length) {
emojis = emojis.filter(
(emoji) => tags.some((tag) => emoji.tags.includes(tag))
);
}
return emojis;
}
async getEmojiList(options = {}) {
const { limit = void 0, offset = 0 } = options;
const emojis = this.filterEmojis(options);
if (!limit) {
return emojis;
}
return emojis.slice(offset, offset + limit);
}
async getEmojiPage(options = {}) {
const limit = Math.max(1, options.limit ?? 50);
const offset = Math.max(0, options.offset ?? 0);
const filtered = this.filterEmojis(options);
return {
items: filtered.slice(offset, offset + limit),
total: filtered.length,
limit,
offset
};
}
async searchEmoji(keyword) {
return this.filterEmojis({ keyword });
}
async deleteEmoji(id) {
const emoji = this._emojiStorage[id];
if (!emoji) return false;
try {
await import_promises2.default.unlink(emoji.path);
delete this._emojiStorage[id];
await this.ctx.database.remove("emojiluna_emojis", { id });
await this.updateCategoryEmojiCount(emoji.category);
this.ctx.emit("emojiluna/emoji-deleted", id);
return true;
} catch (error) {
this.ctx.logger.error(`Failed to delete emoji ${id}:`, error);
return false;
}
}
async deleteAllEmojis() {
try {
const emojis = Object.values(this._emojiStorage);
const concurrency = 4;
for (let i = 0; i < emojis.length; i += concurrency) {
const batch = emojis.slice(i, i + concurrency);
await Promise.all(
batch.map((emoji) => this.deleteEmoji(emoji.id))
);
}
} catch (error) {
this.ctx.logger.error("Failed to delete all emojis:", error);
return false;
}
return true;
}
// ─── Emoji Update (unified) ─────────────────────────────────────────
async updateEmojiInfo(id, updates) {
const emoji = this._emojiStorage[id];
if (!emoji) return false;
const oldCategory = emoji.category;
if (updates.name !== void 0) emoji.name = updates.name;
if (updates.category !== void 0) emoji.category = updates.category;
if (updates.tags !== void 0) emoji.tags = updates.tags;
await this.ctx.database.upsert("emojiluna_emojis", [
{
id: emoji.id,
name: emoji.name,
category: emoji.category,
tags: JSON.stringify(emoji.tags)
}
]);
if (updates.category !== void 0 && updates.category !== oldCategory) {
await this.updateCategoryEmojiCount(oldCategory);
await this.updateCategoryEmojiCount(emoji.category);
}
this.ctx.emit("emojiluna/emoji-updated", emoji);
return true;
}
async updateEmojiName(id, name2) {
const nextName = name2.trim();
if (!nextName) return false;
const emoji = this._emojiStorage[id];
if (!emoji) return false;
if (emoji.name === nextName) return true;
const duplicated = Object.values(this._emojiStorage).some(
(item) => item.id !== id && item.name === nextName
);
if (duplicated) return false;
return this.updateEmojiInfo(id, { name: nextName });
}
async updateEmojiTags(id, tags) {
return this.updateEmojiInfo(id, { tags });
}
async updateEmojiCategory(id, category) {
const nextCategory = category.trim();
if (!nextCategory) return false;
const emoji = this._emojiStorage[id];
if (!emoji) return false;
if (emoji.category === nextCategory) return true;
return this.updateEmojiInfo(id, { category: nextCategory });
}
// ─── Category CRUD ──────────────────────────────────────────────────
async addCategory(name2, description) {
const categoryName = name2.trim();
if (!categoryName) {
throw new Error("分类名称不能为空");
}
const existingCategory = await this.getCategoryByName(categoryName);
if (existingCategory) {
if (description !== void 0 && description !== existingCategory.description) {
existingCategory.description = description;
await this.ctx.database.upsert("emojiluna_categories", [
{
id: existingCategory.id,
name: existingCategory.name,
description: existingCategory.description
}
]);
}
return existingCategory;
}
const id = (0, import_crypto3.randomUUID)();
const category = {
id,
name: categoryName,
description,
emojiCount: 0,
createdAt: /* @__PURE__ */ new Date()
};
this._categories[id] = category;
await this.ctx.database.upsert("emojiluna_categories", [
{
id: category.id,
name: category.name,
description: category.description,
emoji_count: category.emojiCount,
created_at: category.createdAt
}
]);
this.ctx.emit("emojiluna/category-added", category);
return category;
}
async getCategories() {
return Object.values(this._categories);
}
async getCategoriesPage(options = {}) {
const limit = Math.max(1, options.limit ?? 24);
const offset = Math.max(0, options.offset ?? 0);
const keyword = options.keyword?.trim().toLowerCase();
const categories = Object.values(this._categories).filter((cat) => {
if (!keyword) return true;
return cat.name.toLowerCase().includes(keyword) || (cat.description || "").toLowerCase().includes(keyword);
});
return {
items: categories.slice(offset, offset + limit),
total: categories.length,
limit,
offset
};
}
async getCategoryByName(name2) {
return Object.values(this._categories).find((cat) => cat.name === name2) || null;
}
async deleteCategory(id, deleteEmojis = false) {
const category = this._categories[id];
if (!category) return false;
const emojisInCategory = Object.values(this._emojiStorage).filter(
(emoji) => emoji.category === category.name
);
if (emojisInCategory.length > 0) {
if (!deleteEmojis) {
throw new Error(`分类 ${category.name} 中还有表情包,无法删除`);
}
const results = await Promise.all(
emojisInCategory.map((emoji) => this.deleteEmoji(emoji.id))
);
if (results.some((success) => !success)) {
throw new Error(`分类 ${category.name} 下部分表情包删除失败`);
}
}
delete this._categories[id];
await this.ctx.database.remove("emojiluna_categories", { id });
this.ctx.emit("emojiluna/category-deleted", id);
return true;
}
async cleanupEmptyCategories() {
const emptyCategories = Object.values(this._categories).filter(
(category) => category.emojiCount <= 0
);
for (const category of emptyCategories) {
delete this._categories[category.id];
await this.ctx.database.remove("emojiluna_categories", {
id: category.id
});
this.ctx.emit("emojiluna/category-deleted", category.id);
}
return emptyCategories.length;
}
// ─── Tag Operations ─────────────────────────────────────────────────
buildTagUsageMap() {
const tagUsageMap = /* @__PURE__ */ new Map();
Object.values(this._emojiStorage).forEach((emoji) => {
emoji.tags.forEach((tag) => {
const normalizedTag = tag.trim();
if (!normalizedTag) return;
tagUsageMap.set(
normalizedTag,
(tagUsageMap.get(normalizedTag) || 0) + 1
);
});
});
return tagUsageMap;
}
async getAllTags() {
const tags = /* @__PURE__ */ new Set();
Object.values(this._emojiStorage).forEach((emoji) => {
emoji.tags.forEach((tag) => tags.add(tag));
});
return Array.from(tags);
}
async getTagsPage(options = {}) {
const limit = Math.max(1, options.limit ?? 24);
const offset = Math.max(0, options.offset ?? 0);
const keyword = options.keyword?.trim().toLowerCase();
const tagUsageMap = this.buildTagUsageMap();
const tags = Array.from(tagUsageMap.entries()).map(([name2, usage]) => ({ name: name2, usage })).filter((tag) => {
if (!keyword) return true;
return tag.name.toLowerCase().includes(keyword);
}).sort((a, b) => b.usage - a.usage);
return {
items: tags.slice(offset, offset + limit),
total: tags.length,
limit,
offset
};
}
async cleanupEmptyTags() {
const tagUsageMap = this.buildTagUsageMap();
let cleanedCount = 0;
for (const emoji of Object.values(this._emojiStorage)) {
const cleanedTags = emoji.tags.filter((tag) => {
const normalizedTag = tag.trim();
return normalizedTag && (tagUsageMap.get(normalizedTag) || 0) > 0;
});
if (cleanedTags.length !== emoji.tags.length) {
const removedCount = emoji.tags.length - cleanedTags.length;
await this.updateEmojiTags(emoji.id, cleanedTags);
cleanedCount += removedCount;
}
}
return cleanedCount;
}
// ─── Batch AI Operations ────────────────────────────────────────────
async categorizeExistingEmojis() {
if (!this._aiAnalyzer.model || !this.config.autoCategorize) {
return { success: 0, failed: 0 };
}
let success = 0, failed = 0;
for (const emoji of Object.values(this._emojiStorage)) {
try {
const imageBuffer = await import_promises2.default.readFile(emoji.path);
const imageBase64 = imageBuffer.toString("base64");
const result = await this.categorizeEmoji(imageBase64);
if (result && result.category !== emoji.category) {
await this.updateEmojiCategory(emoji.id, result.category);
success++;
}
} catch (error) {
this.ctx.logger.error(`分类表情包 ${emoji.id} 失败:`, error);
failed++;
}
}
return { success, failed };
}
// ─── Folder Import ──────────────────────────────────────────────────
static SUPPORTED_EXTENSIONS = [
".png",
".jpg",
".jpeg",
".gif",
".webp"
];
async scanFolder(folderPath) {
const files = [];
const subfolders = [];
try {
await import_promises2.default.acce