From b4b03234b173a60d2f176a47b8860f01d0a494f1 Mon Sep 17 00:00:00 2001 From: Cat-bl <32174@qq.com> Date: Mon, 10 Aug 2026 10:55:00 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E8=A1=A8=E6=83=85=E5=8C=85=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=96=B0=E5=A2=9E#=E8=A1=A8=E6=83=85=E5=8C=85?= =?UTF-8?q?=E5=85=A5=E5=BA=93=20=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 24 ++++- apps/EmojiPackImport.js | 150 +++++++++++++++++++++++++++++--- config_default/message.yaml | 1 + tests/emoji-import-name.test.js | 56 ++++++++++++ utils/EmojiPackManager.js | 33 +++++-- utils/emoji/importName.js | 23 +++++ 6 files changed, 261 insertions(+), 26 deletions(-) create mode 100644 tests/emoji-import-name.test.js create mode 100644 utils/emoji/importName.js diff --git a/README.md b/README.md index 2abe56a..e8ef86c 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ pnpm install ### 表情包管理(仅主人) ```text #表情包导入 — 引用一条带图消息或自带图,导入到本地表情包库 +#表情包入库 — 扫描 database/emoji_import 目录批量入库;文件名即元数据:描述文字[tag1,tag2].png(成功/重复删源文件,失败保留) #表情包列表 [页码] — 分页查看,每条带 8 位 hash、标签、使用次数;含 [封禁]/[缺文件] 标记 #表情包预览 [hash前缀] — 预览图片本体 + 元数据;不带参数时引用一张图直接预览该图元数据 #表情包删除 [hash前缀] — 物理删除(hash 前缀至少 4 位);不带参数时引用一张图直接删除该图 @@ -525,6 +526,9 @@ MCP 管理命令: | `enabled` | boolean | `false` | **系统主开关**。关闭时 `sendLocalEmojiTool` 不暴露给 LLM、autoCollect 不工作、维护循环不启动 | | `dbPath` | string | `plugins/bl-chat-plugin/database/emoji-packs.ndjson` | 元数据 ndjson 文件路径,相对路径相对 Yunzai 根目录 | | `storeDir` | string | `plugins/bl-chat-plugin/database/emoji_files` | 表情包图片本体存放目录 | +| `importDir` | string | `plugins/bl-chat-plugin/database/emoji_import` | 批量导入扫描目录。把改名后的图片丢进来发 `#表情包入库` 即可 | + +> **批量入库(`#表情包入库`)**:文件名即元数据,格式 `描述文字[tag1,tag2].png`——tags 可省略,支持全角 `【】` 与 `,`、`、` 分隔,描述截断 300 字,描述与 tags 皆空的文件会被拒绝。批量入库**跳过 VLM 内容审查与打标**(零 VLM 成本,描述/tag 完全由文件名决定),但仍会用描述生成 embedding(需配好 `embeddingAiConfig`),语义召回只认描述,建议务必写描述。成功/重复的源文件会被删除,失败的保留供修正后重跑。注意:库满且 `doReplace: true` 时每张图都会触发一次 LLM 替换决策,批量导入前建议先确认容量。 #### 容量管理 @@ -538,7 +542,7 @@ MCP 管理命令: |--------|------|--------|------| | `autoCollect` | boolean | `false` | **是否自动收集群里图片入库**。默认关,避免广告/截图/自拍混入。开启时建议同时开 `contentFiltration` 过滤 | -> 自动收集的内置过滤:SHA-256 去重、1KB-5MB 大小限制、图片格式校验(jpg/png/gif/webp/bmp) +> 自动收集的内置过滤:SHA-256 去重、1KB-10MB 大小限制、图片格式校验(jpg/png/gif/webp/bmp) #### VLM 打标 + 内容审查 @@ -592,14 +596,14 @@ MCP 管理命令: | `rateLimitMaxPerWindow` | int | `3` | 窗口内最多发送次数。设 0 视为不限 | > **重要提示**: -> - 开启表情包系统至少需要配置 `analysisAiConfig`(VLM 打标/审查) +> - 开启表情包系统至少需要配置 `analysisAiConfig`(VLM 打标/审查;只用 `#表情包入库` 批量导入时可不配) > - 启用 `useEmbedding` 还需配置 `embeddingAiConfig` > - 启用 `doReplace` 还需配置 `toolsAiConfig` > - 默认全套关闭,需手动 `enabled: true` 并在 `oneapi_tools` 加入 `sendLocalEmojiTool` **工作流程**: - **触发**:LLM 在情绪/玩笑/共鸣场景主动调用 `sendLocalEmojiTool`,可选传 `followUpText` 实现"文字 + 表情"组合发送 -- **入库 4 层防御**:L0 sharp 物理预检(尺寸 96-1500px、纵横比 ≤3、1KB-5MB)→ L1 VLM 内容审查(fail-closed)→ L2 VLM 详细打标(40-120 字 description + 3-5 个 ≤4 字情绪 tag)→ L3 tag 黑名单复查("截图/风景/广告/真人"等 17 词直接 reject) +- **入库 4 层防御**:L0 sharp 物理预检(尺寸 96-1500px、纵横比 ≤3、1KB-10MB)→ L1 VLM 内容审查(fail-closed)→ L2 VLM 详细打标(40-120 字 description + 3-5 个 ≤4 字情绪 tag)→ L3 tag 黑名单复查("截图/风景/广告/真人"等 17 词直接 reject) - **选图算法(v2)**:L1 embedding 召回(基于 description)+ L2 全库加权兜底;统一公式 `score³ × usageFactor_capped × cooldownPenalty(lastUsedAt)` —— 硬相关性门只让 top-tier 入选,三档冷却(<30min ×0.2、<60min ×0.5、<3h ×0.8)保证长尾轮转 - **反重复**:按群隔离记忆最近 N 次发过的 hash,仅按 hash 排除(tag 太粗易清空候选池) - **软限流**:1 分钟超 3 次返回 `error: 近期发送过频,请改用文字`,LLM 自动改用文字 @@ -619,9 +623,21 @@ MCP 管理命令: 1. 配置好 `analysisAiConfig`(推荐 Gemini Pro Vision / GPT-4o / Claude Sonnet 等多模态模型) 2. 改 `config/message.yaml`:`emojiSystem.enabled: true` 3. `oneapi_tools` 列表追加 `sendLocalEmojiTool` -4. bot 内引用一张表情包图发 `#表情包导入`,导入 5-10 张作为基础库 +4. bot 内引用一张表情包图发 `#表情包导入`,导入 5-10 张作为基础库(或把改名后的图片放入 `database/emoji_import` 后发 `#表情包入库` 批量导入) 5. 和 bot 正常对话,让它在合适场景自然调用 +**批量导入使用步骤(`#表情包入库`)**: +1. 在电脑上挑好表情包图片:支持 jpg/jpeg/png/gif/webp/bmp,1KB-10MB,尺寸 96-1500px,纵横比 ≤3 +2. 给每张图改名,文件名即元数据:`描述文字[tag1,tag2].png` + - 描述建议 10-40 字,写清"画面 + 情绪 + 使用场景",例:`无语翻白眼的猫适合吐槽离谱发言[无语,猫猫].gif`。语义召回只认描述,写得越具体选图越准 + - tags 可省略(`开心到飞起.png` 也合法);分隔符支持 `,` `,` `、`,括号支持全角 `【】` +3. 把改名后的图片放入 `plugins/bl-chat-plugin/database/emoji_import/`(首次可先发一遍 `#表情包入库`,目录会自动创建并回复使用说明) +4. bot 内发 `#表情包入库`(主人权限),等待合并转发的结果报告 +5. 按报告善后:✅ 成功 / ⚠️ 已存在的源文件已自动删除;❌ 失败的源文件保留,按提示原因(改名/换图)修正后重发 `#表情包入库` 即可只重跑失败部分;行尾提示 embedding 生成失败时,检查 `embeddingAiConfig` 后用 `#表情包删除` 删掉该图重新入库 +6. `#表情包预览 ` 抽查描述/tags/embedding,`#表情包统计` 看整体覆盖情况 + +> 批量入库全程不调 VLM——只走这条路径时可以不配 `analysisAiConfig`,但 `embeddingAiConfig` 建议必配,否则图片无法被语义召回、只能随机兜底。 + --- ## 触发机制 diff --git a/apps/EmojiPackImport.js b/apps/EmojiPackImport.js index dac3aa0..a3c4898 100644 --- a/apps/EmojiPackImport.js +++ b/apps/EmojiPackImport.js @@ -1,15 +1,38 @@ import { emojiPackManager } from "../utils/EmojiPackManager.js" +import { parseImportFileName } from "../utils/emoji/importName.js" import { TakeImages } from "../utils/fileUtils.js" import common from "../../../lib/common/common.js" import fs from "fs" import path from "path" +// #表情包入库 并发守卫:两次入库并发会互删源文件产生假失败 +let importDirRunning = false + +// 批量入库支持的图片扩展名(与 detectExtFromBuffer 支持的格式一致) +const IMPORT_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]) + async function fetchImageBuffer(url, timeoutMs = 15000) { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }) if (!response.ok) throw new Error(`HTTP ${response.status}`) return Buffer.from(await response.arrayBuffer()) } +const hashShort = h => (h ? String(h).slice(0, 8) : "????????") + +const rejectReasonText = (r) => ({ + too_tiny: "文件过小 (<1KB)", + too_large: "文件过大 (>10MB)", + too_small: `图片尺寸过小 (${r.width}×${r.height}<96px)`, + too_large_dim: `图片尺寸过大 (${r.width}×${r.height}>1500px)`, + extreme_aspect: `极端纵横比 (${r.ratio})`, + metadata_failed: `图片解析失败: ${r.error || ""}`, + content_filtered: `内容审查拒绝: ${r.filterReason || ""}`, + content_filter_error: `内容审查异常: ${r.error || ""}`, + tag_failed: `VLM 打标失败: ${r.error || ""}`, + tag_blacklist: `tag 命中黑名单 [${(r.hitTags || []).join(",")}]`, + unsupported_format: "不支持的图片格式" +}[r.reason] || `未知原因: ${r.reason}`) + async function sendForward(e, msgs, title = "表情包") { try { const forwardMsg = await common.makeForwardMsg(e, msgs, title) @@ -28,6 +51,7 @@ export class EmojiPackPlugin extends plugin { priority: 500, rule: [ { reg: "^#表情包导入$", fnc: "importEmoji", permission: "master" }, + { reg: "^#表情包入库$", fnc: "importFromDir", permission: "master" }, { reg: "^#表情包列表(\\s+\\d+)?$", fnc: "listEmoji", permission: "master" }, // hash 前缀可选:传了走前缀匹配;不传则从当前/引用消息提取图片用全 hash 精确匹配 { reg: "^#表情包删除(\\s+\\S+)?$", fnc: "deleteEmoji", permission: "master" }, @@ -85,20 +109,6 @@ export class EmojiPackPlugin extends plugin { e.reply(`正在导入 ${urls.length} 张图片...`) const results = [] - const hashShort = h => (h ? String(h).slice(0, 8) : "????????") - const rejectReasonText = (r) => ({ - too_tiny: "文件过小 (<1KB)", - too_large: "文件过大 (>5MB)", - too_small: `图片尺寸过小 (${r.width}×${r.height}<96px)`, - too_large_dim: `图片尺寸过大 (${r.width}×${r.height}>1500px)`, - extreme_aspect: `极端纵横比 (${r.ratio})`, - metadata_failed: `图片解析失败: ${r.error || ""}`, - content_filtered: `内容审查拒绝: ${r.filterReason || ""}`, - content_filter_error: `内容审查异常: ${r.error || ""}`, - tag_failed: `VLM 打标失败: ${r.error || ""}`, - tag_blacklist: `tag 命中黑名单 [${(r.hitTags || []).join(",")}]`, - unsupported_format: "不支持的图片格式" - }[r.reason] || `未知原因: ${r.reason}`) for (const url of urls) { try { @@ -124,6 +134,118 @@ export class EmojiPackPlugin extends plugin { return true } + /** + * #表情包入库:扫描 importDir 目录批量入库。 + * 文件名即元数据(描述文字[tag1,tag2].png),跳过 VLM 审查与打标,用文件名描述生成 embedding。 + * 成功或重复的源文件删除,失败的保留供修正后重跑。 + */ + async importFromDir(e) { + emojiPackManager.refreshConfig() + if (!emojiPackManager.config?.enabled) { + return e.reply("表情包系统未启用,请先在 config/message.yaml 将 emojiSystem.enabled 设为 true") + } + if (importDirRunning) return e.reply("已有一次入库任务在进行中,请稍后再试") + importDirRunning = true + try { + const dir = emojiPackManager.importDir + let entries + try { + await fs.promises.mkdir(dir, { recursive: true }) + entries = await fs.promises.readdir(dir, { withFileTypes: true }) + } catch (err) { + return e.reply(`读取导入目录失败: ${err.message}`) + } + const files = entries + .filter(d => d.isFile() && IMPORT_EXTS.has(path.extname(d.name).toLowerCase())) + .map(d => d.name) + .sort() + + if (!files.length) { + return e.reply([ + "导入目录为空。使用方法:", + `1. 把表情包图片放入目录:${dir}`, + "2. 按「描述文字[tag1,tag2].png」格式命名(tags 可省略,也支持全角【】和,、分隔)", + " 例:无语翻白眼的猫[无语,猫猫].gif", + " 建议务必填写描述文字,语义召回只认描述", + "3. 再次发送 #表情包入库", + "支持 jpg/jpeg/png/gif/webp/bmp;成功或重复的源文件会被删除,失败的保留" + ].join("\n")) + } + + e.reply(`开始入库 ${files.length} 个文件...`) + + let added = 0, dup = 0, failed = 0, embedWarn = false + const lines = [] + for (let i = 0; i < files.length; i++) { + const f = files[i] + const meta = parseImportFileName(path.parse(f).name) + if (!meta.description && !meta.tags.length) { + lines.push(`❌ ${f} 文件名未含描述或标签(源文件保留)`) + failed++ + continue + } + + const absFile = path.join(dir, f) + let buffer + try { + buffer = await fs.promises.readFile(absFile) + } catch (err) { + lines.push(`❌ ${f} 读取失败: ${err.message}(源文件保留)`) + failed++ + continue + } + + let result + try { + result = await emojiPackManager.addFromBuffer(buffer, { + source: "user", + autoTag: false, + skipContentFilter: true, + presetDescription: meta.description, + presetTags: meta.tags + }) + } catch (err) { + lines.push(`❌ ${f} 处理失败: ${err.message}(源文件保留)`) + failed++ + continue + } + + if (result.added) { + await fs.promises.unlink(absFile).catch(() => {}) + let line = `✅ ${result.item.hash.slice(0, 8)} ${f} [${meta.tags.join(",") || "无标签"}]` + if (emojiPackManager.config.useEmbedding !== false && meta.description && !result.item.embedding) { + line += "(⚠️ embedding 生成失败)" + embedWarn = true + } + lines.push(line) + added++ + } else if (result.reason === "duplicate") { + await fs.promises.unlink(absFile).catch(() => {}) + lines.push(`⚠️ ${f} 已存在(${hashShort(result.item?.hash)}),源文件已删除`) + dup++ + } else if (result.reason === "full") { + lines.push(`❌ 库已满(${emojiPackManager.config.maxItems} 张),剩余 ${files.length - i} 个文件已保留`) + break + } else { + lines.push(`❌ ${f} ${rejectReasonText(result)}(源文件保留)`) + failed++ + } + } + + const msgs = [`表情包入库完成:成功 ${added} | 重复 ${dup} | 失败 ${failed}(共 ${files.length} 个文件)`] + if (embedWarn) { + msgs.push("⚠️ 部分表情 embedding 生成失败,将无法被语义召回:请检查 embeddingAiConfig 配置后,用 #表情包删除 删除对应表情并重新入库") + } + for (let i = 0; i < lines.length; i += 20) { + msgs.push(lines.slice(i, i + 20).join("\n")) + } + await sendForward(e, msgs, "表情包入库") + return true + } finally { + importDirRunning = false + } + } + async listEmoji(e) { const items = await emojiPackManager.loadItems(true) if (!items.length) return e.reply("本地表情包库为空") diff --git a/config_default/message.yaml b/config_default/message.yaml index 7810460..5080ada 100644 --- a/config_default/message.yaml +++ b/config_default/message.yaml @@ -256,6 +256,7 @@ pluginSettings: enabled: false dbPath: plugins/bl-chat-plugin/database/emoji-packs.ndjson storeDir: plugins/bl-chat-plugin/database/emoji_files + importDir: plugins/bl-chat-plugin/database/emoji_import maxItems: 200 autoCollect: false visionTagOnAdd: true diff --git a/tests/emoji-import-name.test.js b/tests/emoji-import-name.test.js new file mode 100644 index 0000000..af8c3d0 --- /dev/null +++ b/tests/emoji-import-name.test.js @@ -0,0 +1,56 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { parseImportFileName } from "../utils/emoji/importName.js" + +test("基本形态:描述[tag,tag]", () => { + assert.deepEqual(parseImportFileName("开心猫猫[开心,猫]"), { description: "开心猫猫", tags: ["开心", "猫"] }) +}) + +test("全角括号 + 混合分隔符", () => { + assert.deepEqual(parseImportFileName("震惊【惊讶,震惊、离谱】"), { description: "震惊", tags: ["惊讶", "震惊", "离谱"] }) +}) + +test("无标签组:整体为描述", () => { + assert.deepEqual(parseImportFileName("笑死"), { description: "笑死", tags: [] }) +}) + +test("空标签组:[] 不产生 tags", () => { + assert.deepEqual(parseImportFileName("笑死[]"), { description: "笑死", tags: [] }) +}) + +test("仅标签无描述", () => { + assert.deepEqual(parseImportFileName("[开心]"), { description: "", tags: ["开心"] }) +}) + +test("多余逗号与空项过滤", () => { + assert.deepEqual(parseImportFileName("x[a,,、b,]"), { description: "x", tags: ["a", "b"] }) +}) + +test("tag 去重与 trim", () => { + assert.deepEqual(parseImportFileName("x[ a , a ]"), { description: "x", tags: ["a"] }) +}) + +test("仅空格描述 + 空 tags:两者皆空", () => { + assert.deepEqual(parseImportFileName(" [ , ]"), { description: "", tags: [] }) +}) + +test("空串 / null / undefined 不抛错", () => { + assert.deepEqual(parseImportFileName(""), { description: "", tags: [] }) + assert.deepEqual(parseImportFileName(null), { description: "", tags: [] }) + assert.deepEqual(parseImportFileName(undefined), { description: "", tags: [] }) +}) + +test("中间括号不算标签组,只取结尾", () => { + assert.deepEqual(parseImportFileName("a[b]c"), { description: "a[b]c", tags: [] }) + assert.deepEqual(parseImportFileName("a[b]c[d]"), { description: "a[b]c", tags: ["d"] }) +}) + +test("括号风格不成对:整体为描述", () => { + assert.deepEqual(parseImportFileName("desc【a]"), { description: "desc【a]", tags: [] }) +}) + +test("描述截断 300 字", () => { + const long = "长".repeat(301) + const { description } = parseImportFileName(long) + assert.equal(description.length, 300) +}) diff --git a/utils/EmojiPackManager.js b/utils/EmojiPackManager.js index c66672b..45df213 100644 --- a/utils/EmojiPackManager.js +++ b/utils/EmojiPackManager.js @@ -16,6 +16,7 @@ const DEFAULT_EMOJI_CONFIG = { enabled: false, dbPath: "plugins/bl-chat-plugin/database/emoji-packs.ndjson", storeDir: "plugins/bl-chat-plugin/database/emoji_files", + importDir: "plugins/bl-chat-plugin/database/emoji_import", maxItems: 200, autoCollect: false, visionTagOnAdd: true, @@ -109,6 +110,11 @@ export class EmojiPackManager { return path.isAbsolute(p) ? p : path.join(_path, p) } + get importDir() { + const p = this.config.importDir || DEFAULT_EMOJI_CONFIG.importDir + return path.isAbsolute(p) ? p : path.join(_path, p) + } + async ensureDirs() { await fsp.mkdir(path.dirname(this.dbPath), { recursive: true }) await fsp.mkdir(this.storeDir, { recursive: true }) @@ -177,7 +183,10 @@ export class EmojiPackManager { return result } - async _addFromBufferInternal(buffer, { source = "manual", autoTag = true, autoEmbed = true } = {}) { + async _addFromBufferInternal(buffer, { + source = "manual", autoTag = true, autoEmbed = true, + skipContentFilter = false, presetTags = null, presetDescription = "" + } = {}) { this.refreshConfig() await this.ensureDirs() @@ -190,7 +199,7 @@ export class EmojiPackManager { if (ext === ".bin") return { added: false, reason: "unsupported_format" } // L0 物理预检查:尺寸 / 纵横比 / 文件大小(零成本,必拦明显非表情包) - if (buffer.length > 5 * 1024 * 1024) { + if (buffer.length > 10 * 1024 * 1024) { return { added: false, reason: "too_large", size: buffer.length } } if (buffer.length < 1024) { @@ -223,7 +232,7 @@ export class EmojiPackManager { return { added: false, reason: "full" } } - if (this.config.contentFiltration) { + if (this.config.contentFiltration && !skipContentFilter) { try { const verdict = await this.contentFilterWithVLM(buffer, ext) if (!verdict.isEmoji) { @@ -276,6 +285,12 @@ export class EmojiPackManager { isBanned: false } + // 批量导入预置元数据:文件名解析出的描述/标签,autoTag=false 时即最终元数据 + if (presetDescription) record.description = String(presetDescription) + if (Array.isArray(presetTags) && presetTags.length) { + record.tags = presetTags.map(t => String(t).trim()).filter(Boolean) + } + if (autoTag && this.config.visionTagOnAdd !== false) { try { const tagResult = await this.tagWithVLM(buffer, ext) @@ -508,14 +523,16 @@ export class EmojiPackManager { logWarn(`维护扫描目录失败: ${err.message}`) } - // 3. 清理无标签无向量的「残废」记录(含磁盘文件) - // 仅在启用 VLM 打标时执行,避免误删用户主动关闭打标时的合法无标签项 + // 3. 清理无标签、无向量、且无描述的「残废」记录(含磁盘文件) + // 仅在启用 VLM 打标时执行,避免误删用户主动关闭打标时的合法无标签项; + // description 非空说明是批量导入的用户元数据(embedding 可能只是暂时生成失败),豁免 if (this.config.visionTagOnAdd !== false) { for (let i = items.length - 1; i >= 0; i--) { const item = items[i] const hasTag = Array.isArray(item.tags) && item.tags.length > 0 const hasEmbed = Array.isArray(item.embedding) && item.embedding.length > 0 - if (!hasTag && !hasEmbed) { + const hasDesc = typeof item.description === "string" && item.description.trim().length > 0 + if (!hasTag && !hasEmbed && !hasDesc) { const abs = path.join(this.storeDir, path.basename(item.file)) try { await fsp.unlink(abs) } catch {} items.splice(i, 1) @@ -523,7 +540,7 @@ export class EmojiPackManager { changed = true } } - if (report.cleanedUntagged) logInfo(`维护:清理 ${report.cleanedUntagged} 个无标签无向量的残废记录(含磁盘文件)`) + if (report.cleanedUntagged) logInfo(`维护:清理 ${report.cleanedUntagged} 个无标签无向量无描述的残废记录(含磁盘文件)`) } if (changed) await this.saveItems(items) @@ -556,7 +573,7 @@ export class EmojiPackManager { const response = await fetch(seg.url, { signal: AbortSignal.timeout(10000) }) if (!response.ok) continue const buffer = Buffer.from(await response.arrayBuffer()) - if (buffer.length < 1024 || buffer.length > 5 * 1024 * 1024) continue + if (buffer.length < 1024 || buffer.length > 10 * 1024 * 1024) continue await this.addFromBuffer(buffer, { source: "auto" }) } catch (err) { logWarn(`autoCollect 失败: ${err.message}`) diff --git a/utils/emoji/importName.js b/utils/emoji/importName.js new file mode 100644 index 0000000..bca1452 --- /dev/null +++ b/utils/emoji/importName.js @@ -0,0 +1,23 @@ +// 表情包批量导入文件名解析:「描述文字[tag1,tag2]」→ { description, tags }。 +// 纯函数无 IO,配套单测 tests/emoji-import-name.test.js。 + +const MAX_DESC_LEN = 300 +// 只识别「结尾处」成对的半角 [] 或全角 【】 标签组,不跨风格匹配 +const TRAILING_TAGS_RE = /(?:\[([^\][]*)\]|【([^【】]*)】)\s*$/ + +/** + * @param {string} baseName 文件名去掉扩展名后的部分(调用方用 path.parse(f).name 取得) + * @returns {{ description: string, tags: string[] }} 两者皆空时由调用方拒绝该文件 + */ +export function parseImportFileName(baseName) { + const raw = String(baseName ?? "").trim() + let description = raw + let tags = [] + const m = raw.match(TRAILING_TAGS_RE) + if (m) { + description = raw.slice(0, m.index).trim() + const tagStr = m[1] ?? m[2] ?? "" + tags = [...new Set(tagStr.split(/[,,、]/).map(t => t.trim()).filter(Boolean))] + } + return { description: description.slice(0, MAX_DESC_LEN), tags } +} From 3999404bd8526d78590318d06c96b12b8a7b21c2 Mon Sep 17 00:00:00 2001 From: Cat-bl <32174@qq.com> Date: Mon, 10 Aug 2026 11:10:34 +0800 Subject: [PATCH 2/3] =?UTF-8?q?#=E8=A1=A8=E6=83=85=E5=8C=85=E5=85=A5?= =?UTF-8?q?=E5=BA=93=20=E6=8C=87=E4=BB=A4=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- utils/EmojiPackManager.js | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e8ef86c..edc3c85 100644 --- a/README.md +++ b/README.md @@ -526,7 +526,7 @@ MCP 管理命令: | `enabled` | boolean | `false` | **系统主开关**。关闭时 `sendLocalEmojiTool` 不暴露给 LLM、autoCollect 不工作、维护循环不启动 | | `dbPath` | string | `plugins/bl-chat-plugin/database/emoji-packs.ndjson` | 元数据 ndjson 文件路径,相对路径相对 Yunzai 根目录 | | `storeDir` | string | `plugins/bl-chat-plugin/database/emoji_files` | 表情包图片本体存放目录 | -| `importDir` | string | `plugins/bl-chat-plugin/database/emoji_import` | 批量导入扫描目录。把改名后的图片丢进来发 `#表情包入库` 即可 | +| `importDir` | string | `plugins/bl-chat-plugin/database/emoji_import` | 批量导入扫描目录,`emojiSystem.enabled: true` 时自动创建。把改名后的图片丢进来发 `#表情包入库` 即可 | > **批量入库(`#表情包入库`)**:文件名即元数据,格式 `描述文字[tag1,tag2].png`——tags 可省略,支持全角 `【】` 与 `,`、`、` 分隔,描述截断 300 字,描述与 tags 皆空的文件会被拒绝。批量入库**跳过 VLM 内容审查与打标**(零 VLM 成本,描述/tag 完全由文件名决定),但仍会用描述生成 embedding(需配好 `embeddingAiConfig`),语义召回只认描述,建议务必写描述。成功/重复的源文件会被删除,失败的保留供修正后重跑。注意:库满且 `doReplace: true` 时每张图都会触发一次 LLM 替换决策,批量导入前建议先确认容量。 @@ -631,7 +631,7 @@ MCP 管理命令: 2. 给每张图改名,文件名即元数据:`描述文字[tag1,tag2].png` - 描述建议 10-40 字,写清"画面 + 情绪 + 使用场景",例:`无语翻白眼的猫适合吐槽离谱发言[无语,猫猫].gif`。语义召回只认描述,写得越具体选图越准 - tags 可省略(`开心到飞起.png` 也合法);分隔符支持 `,` `,` `、`,括号支持全角 `【】` -3. 把改名后的图片放入 `plugins/bl-chat-plugin/database/emoji_import/`(首次可先发一遍 `#表情包入库`,目录会自动创建并回复使用说明) +3. 把改名后的图片放入 `plugins/bl-chat-plugin/database/emoji_import/`(启用表情包系统后该目录自动创建;也可先发一遍 `#表情包入库` 查看完整路径和说明) 4. bot 内发 `#表情包入库`(主人权限),等待合并转发的结果报告 5. 按报告善后:✅ 成功 / ⚠️ 已存在的源文件已自动删除;❌ 失败的源文件保留,按提示原因(改名/换图)修正后重发 `#表情包入库` 即可只重跑失败部分;行尾提示 embedding 生成失败时,检查 `embeddingAiConfig` 后用 `#表情包删除` 删掉该图重新入库 6. `#表情包预览 ` 抽查描述/tags/embedding,`#表情包统计` 看整体覆盖情况 diff --git a/utils/EmojiPackManager.js b/utils/EmojiPackManager.js index 45df213..1504142 100644 --- a/utils/EmojiPackManager.js +++ b/utils/EmojiPackManager.js @@ -94,6 +94,10 @@ export class EmojiPackManager { this.embeddingAiConfig = settings.embeddingAiConfig || {} this.toolsAiConfig = settings.toolsAiConfig || {} this.configMtimeMs = stat.mtimeMs + // 启用时自动建批量导入目录,用户重启/热开启后即可直接丢图,无需先发一次 #表情包入库 + if (this.config.enabled) { + try { fs.mkdirSync(this.importDir, { recursive: true }) } catch {} + } this.ensureMaintenanceRunning() } catch (err) { logWarn(`刷新配置失败: ${err.message}`) From aa50715a74182876469c5e1d7a170ae0e93c690f Mon Sep 17 00:00:00 2001 From: Cat-bl <32174@qq.com> Date: Mon, 10 Aug 2026 11:22:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?#=E8=A1=A8=E6=83=85=E5=8C=85=E5=85=A5?= =?UTF-8?q?=E5=BA=93=E6=8C=87=E4=BB=A4=E6=96=B0=E5=A2=9E=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++ apps/EmojiPackImport.js | 65 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index edc3c85..5e23751 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ pnpm install ```text #表情包导入 — 引用一条带图消息或自带图,导入到本地表情包库 #表情包入库 — 扫描 database/emoji_import 目录批量入库;文件名即元数据:描述文字[tag1,tag2].png(成功/重复删源文件,失败保留) +#表情包入库 描述[tag1,tag2] — 引用带图消息或自带图,用参数作元数据单张直接入库(同样跳过 VLM 审查/打标;多图只取第一张) #表情包列表 [页码] — 分页查看,每条带 8 位 hash、标签、使用次数;含 [封禁]/[缺文件] 标记 #表情包预览 [hash前缀] — 预览图片本体 + 元数据;不带参数时引用一张图直接预览该图元数据 #表情包删除 [hash前缀] — 物理删除(hash 前缀至少 4 位);不带参数时引用一张图直接删除该图 @@ -636,6 +637,8 @@ MCP 管理命令: 5. 按报告善后:✅ 成功 / ⚠️ 已存在的源文件已自动删除;❌ 失败的源文件保留,按提示原因(改名/换图)修正后重发 `#表情包入库` 即可只重跑失败部分;行尾提示 embedding 生成失败时,检查 `embeddingAiConfig` 后用 `#表情包删除` 删掉该图重新入库 6. `#表情包预览 ` 抽查描述/tags/embedding,`#表情包统计` 看整体覆盖情况 +**单张入库(QQ 不让保存表情图片时)**:群里看到想收的表情,引用那条消息发 `#表情包入库 描述文字[tag1,tag2]` 即可直接入库,无需经过文件夹;元数据语法与文件名一致(tags 可省略)。引用/自带多张图时只取第一张。 + > 批量入库全程不调 VLM——只走这条路径时可以不配 `analysisAiConfig`,但 `embeddingAiConfig` 建议必配,否则图片无法被语义召回、只能随机兜底。 --- diff --git a/apps/EmojiPackImport.js b/apps/EmojiPackImport.js index a3c4898..75c6350 100644 --- a/apps/EmojiPackImport.js +++ b/apps/EmojiPackImport.js @@ -52,6 +52,8 @@ export class EmojiPackPlugin extends plugin { rule: [ { reg: "^#表情包导入$", fnc: "importEmoji", permission: "master" }, { reg: "^#表情包入库$", fnc: "importFromDir", permission: "master" }, + // 带参数:参数即元数据(描述文字[tag1,tag2]),从引用/当前消息取一张图直接入库 + { reg: "^#表情包入库\\s+[\\s\\S]+$", fnc: "importWithMeta", permission: "master" }, { reg: "^#表情包列表(\\s+\\d+)?$", fnc: "listEmoji", permission: "master" }, // hash 前缀可选:传了走前缀匹配;不传则从当前/引用消息提取图片用全 hash 精确匹配 { reg: "^#表情包删除(\\s+\\S+)?$", fnc: "deleteEmoji", permission: "master" }, @@ -168,7 +170,8 @@ export class EmojiPackPlugin extends plugin { " 例:无语翻白眼的猫[无语,猫猫].gif", " 建议务必填写描述文字,语义召回只认描述", "3. 再次发送 #表情包入库", - "支持 jpg/jpeg/png/gif/webp/bmp;成功或重复的源文件会被删除,失败的保留" + "支持 jpg/jpeg/png/gif/webp/bmp;成功或重复的源文件会被删除,失败的保留", + "单张入库:引用带图消息(或自带图)发 #表情包入库 描述文字[tag1,tag2]" ].join("\n")) } @@ -246,6 +249,66 @@ export class EmojiPackPlugin extends plugin { } } + /** + * #表情包入库 描述文字[tag1,tag2]:从引用/当前消息取第一张图,用参数作元数据直接入库。 + * 与文件夹批量入库共用元数据语法与入库通道(跳过 VLM 审查/打标)。 + */ + async importWithMeta(e) { + emojiPackManager.refreshConfig() + if (!emojiPackManager.config?.enabled) { + return e.reply("表情包系统未启用,请先在 config/message.yaml 将 emojiSystem.enabled 设为 true") + } + + const metaText = String(e.msg || "").replace(/^#表情包入库/, "").trim() + const meta = parseImportFileName(metaText) + if (!meta.description && !meta.tags.length) { + return e.reply("请在指令后写描述,例:#表情包入库 无语翻白眼的猫[无语,猫猫]") + } + + const urls = await TakeImages(e) + if (!urls?.length) { + return e.reply("请引用一条带图消息,或在当前消息附带图片后再发送") + } + + let buffer + try { + buffer = await fetchImageBuffer(urls[0]) + } catch (err) { + return e.reply(`图片下载失败: ${err.message}`) + } + + let result + try { + result = await emojiPackManager.addFromBuffer(buffer, { + source: "user", + autoTag: false, + skipContentFilter: true, + presetDescription: meta.description, + presetTags: meta.tags + }) + } catch (err) { + return e.reply(`处理失败: ${err.message}`) + } + + if (result.added) { + const parts = [`✅ 已入库 ${result.item.hash.slice(0, 8)} [${meta.tags.join(",") || "无标签"}]`] + if (emojiPackManager.config.useEmbedding !== false && meta.description && !result.item.embedding) { + parts.push("⚠️ embedding 生成失败,无法被语义召回,请检查 embeddingAiConfig 后用 #表情包删除 删掉重新入库") + } + if (urls.length > 1) { + parts.push(`(检测到 ${urls.length} 张图,只入库了第一张)`) + } + return e.reply(parts.join("\n")) + } + if (result.reason === "duplicate") { + return e.reply(`⚠️ 该图已存在(${hashShort(result.item?.hash)}),未修改。如需更新描述请先 #表情包删除 再重新入库`) + } + if (result.reason === "full") { + return e.reply(`❌ 库已满(${emojiPackManager.config.maxItems} 张)`) + } + return e.reply(`❌ ${rejectReasonText(result)}`) + } + async listEmoji(e) { const items = await emojiPackManager.loadItems(true) if (!items.length) return e.reply("本地表情包库为空")