UNPKG

koishi-plugin-emojiluna

Version:

Smart emoji management plugin with AI categorization

1,533 lines (1,524 loc) 120 kB
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