koishi-plugin-emoji-recall 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/index.js +166 -0
  4. package/package.json +48 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 妖祀
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # koishi-plugin-emoji-recall
2
+
3
+ > 按轮语义召回表情包。给 [chatluna](https://github.com/ChatLunaLab/chatluna) 的角色对话注册一个
4
+ > `{emojis_smart}` 函数变量:每次生成回复时,用**当前对话内容**向量检索 [emojiluna](https://github.com/koishijs)
5
+ > 的表情库,只把**最相关的 K 张**表情塞进提示词。
6
+
7
+ 库越大也不会让每轮成本失控,而且 bot 发的图贴合当下话题(在聊猫,就浮现猫的表情)。
8
+
9
+ 完整的安装 / 配置 / 预设接入(含「以髭切为例」的可复制片段)见仓库的
10
+ [**安装与使用指南**](https://github.com/ningningningning0420-ui/koishi-chatluna-plugins/blob/main/docs/emoji-recall-安装与使用指南.md)。这里只放速查。
11
+
12
+ ## 它解决什么
13
+
14
+ emojiluna 原生的 `{emojis}` 会把**全部表情**(每张:名称 + 完整 URL + 分类 + 标签)塞进**每一轮**回复;
15
+ 库越大越贵,而且与当前对话无关。本插件改成「按当前对话语义,只挑最相关的几张」。
16
+
17
+ 机制与 livingmemory 的 `{living_memory}` 完全一致:都是 chatluna 的 function-provider,
18
+ 能在渲染时拿到 `configurable.session`(当前消息)作查询。
19
+
20
+ ## 依赖
21
+
22
+ - `koishi` ^4.18
23
+ - `chatluna`(核心,提供 promptRenderer 与 embeddings)
24
+ - `chatluna-character`(角色对话,渲染预设;非本插件 service 硬依赖,但角色场景需要)
25
+ - `emojiluna`(表情库与 `/get` `/tags` 端点)
26
+ - 一个 chatluna 能调用的 **embeddings 向量模型**(默认 `ollama/bge-m3:latest`)
27
+
28
+ ## 三步启用
29
+
30
+ 1. 安装:在 Koishi 控制台的**插件市场**搜 `emoji-recall` 直接安装;或命令行
31
+ ```bash
32
+ npm i koishi-plugin-emoji-recall
33
+ ```
34
+ (从源码用:把本目录放到 koishi app 的 `external/` 下,再 `npm i ./external/koishi-plugin-emoji-recall`。)
35
+ 2. `koishi.yml` 启用(`selfUrl` 必须 = 本 bot 自己的端口,与 emojiluna 一致):
36
+ ```yaml
37
+ emoji-recall:main:
38
+ selfUrl: http://127.0.0.1:5140
39
+ backendPath: /emojiluna
40
+ topK: 6
41
+ embeddingModel: ollama/bge-m3:latest
42
+ minScore: 0
43
+ useReranker: false
44
+ fallbackToRecent: true
45
+ debug: true # 首次开,日志看命中表情+分数,稳了再关
46
+ ```
47
+ 3. 改预设:把输出用的 `{emojis}` 换成 `{emojis_smart}`,`{if emojis}…{/if}` 外壳保留当「库里有没有图」的门。重启 koishi 生效。
48
+
49
+ ## 配置项
50
+
51
+ | 配置 | 默认 | 说明 |
52
+ |---|---|---|
53
+ | `selfUrl` | `http://127.0.0.1:5140` | 本 bot 服务器地址,**必须 = 本 bot 端口**,与 emojiluna.selfUrl 一致 |
54
+ | `backendPath` | `/emojiluna` | emojiluna 后端路径,与 emojiluna.backendPath 一致 |
55
+ | `topK` | `6` | 每轮注入多少张最相关的表情 |
56
+ | `functionName` | `emojis_smart` | 注册的函数变量名,预设里用 `{该名}` 引用 |
57
+ | `embeddingModel` | `ollama/bge-m3:latest` | 向量模型 id,`platform/model`,需是 chatluna 能调用的 embeddings |
58
+ | `minScore` | `0` | 相似度下限;`0` = 永远取 topK,调高可在「没够相关的图」时少注入 |
59
+ | `useReranker` | `false` | 是否再用 reranker 精排(多一次网络调用) |
60
+ | `rerankModel` | `siliconflow/BAAI/bge-reranker-v2-m3` | reranker 模型 id(`useReranker` 开时用) |
61
+ | `maxQueryChars` | `200` | 查询文本最大长度(取当前消息尾部) |
62
+ | `fallbackToRecent` | `true` | 无对话文本 / embeddings 不可用时,退化为注入最近 K 张(关 = 注入空) |
63
+ | `debug` | `false` | 打印每轮的查询与命中表情 + 分数 |
64
+
65
+ `{emojis_smart(8)}` 可临时指定数量;无参则用配置的 `topK`。
66
+
67
+ ## 验证
68
+
69
+ 启动日志应出现:`emoji-recall ready (function {emojis_smart}, base=…)`。
70
+ 开 `debug: true` 后,每轮日志:`query="…" → 名称(分数), …`。
71
+
72
+ ## 许可
73
+
74
+ MIT
package/index.js ADDED
@@ -0,0 +1,166 @@
1
+ const { Schema } = require('koishi')
2
+
3
+ exports.name = 'emoji-recall'
4
+
5
+ // 按轮语义召回:每次生成回复、渲染预设时,用当前对话文本做查询,
6
+ // 经 embeddings(默认 ollama bge-m3)对 emojiluna 表情库做向量相似度检索,
7
+ // 只注入最相关的 K 张表情。
8
+ // 用 chatluna 的 function-provider 实现 {emojis_smart}(与 livingmemory 的 {living_memory} 同机制),
9
+ // 每轮即时计算。token 只占 K 张、且与当前对话相关。
10
+ //
11
+ // 不在 koishi.yml 启用 + 预设不引用 {emojis_smart} = 完全不生效、零影响。
12
+ // 安装/配置/使用见同目录 README.md 或发布包根目录的《安装与使用指南.md》。
13
+
14
+ exports.Config = Schema.intersect([
15
+ Schema.object({
16
+ selfUrl: Schema.string()
17
+ .default('http://127.0.0.1:5140')
18
+ .description('本 bot 的服务器地址,必须 = 本 bot 自己的端口,且与该 bot 的 emojiluna.selfUrl 完全一致'),
19
+ backendPath: Schema.string().default('/emojiluna').description('emojiluna 后端路径,与 emojiluna.backendPath 一致'),
20
+ topK: Schema.number().min(1).max(30).default(6).description('每轮注入多少张最相关的表情'),
21
+ functionName: Schema.string()
22
+ .default('emojis_smart')
23
+ .description('注册的函数变量名;预设里用 {函数名} 引用(无参用配置的 topK)')
24
+ }).description('基础'),
25
+ Schema.object({
26
+ embeddingModel: Schema.string()
27
+ .default('ollama/bge-m3:latest')
28
+ .description('向量模型 id,格式 platform/model;需是你的 chatluna 能调用的 embeddings(建议与 livingmemory 用同一个)'),
29
+ minScore: Schema.number()
30
+ .min(0).max(1).role('slider').step(0.01).default(0)
31
+ .description('相似度下限。0=永远取 topK;调高可在"没有够相关的图"时少注入甚至不注入'),
32
+ useReranker: Schema.boolean().default(false).description('是否再用 reranker 精排(多一次网络调用,默认关)'),
33
+ rerankModel: Schema.string()
34
+ .default('siliconflow/BAAI/bge-reranker-v2-m3')
35
+ .description('reranker 模型 id(useReranker 开时用)')
36
+ }).description('检索模型'),
37
+ Schema.object({
38
+ maxQueryChars: Schema.number().min(20).max(2000).default(200).description('查询文本最大长度(取当前消息尾部)'),
39
+ fallbackToRecent: Schema.boolean()
40
+ .default(true)
41
+ .description('当无对话文本(如定时触发)或 embeddings 不可用时,是否退化为注入最近 K 张(关=注入空)'),
42
+ debug: Schema.boolean().default(false).description('打印每轮的查询与命中表情+分数')
43
+ }).description('其它')
44
+ ])
45
+
46
+ function cosineSimilarity(a, b) {
47
+ if (!a || !b || a.length !== b.length) return 0
48
+ let dot = 0, na = 0, nb = 0
49
+ for (let i = 0; i < a.length; i++) {
50
+ dot += a[i] * b[i]
51
+ na += a[i] * a[i]
52
+ nb += b[i] * b[i]
53
+ }
54
+ if (na === 0 || nb === 0) return 0
55
+ return dot / (Math.sqrt(na) * Math.sqrt(nb))
56
+ }
57
+
58
+ exports.apply = (ctx, config) => {
59
+ const logger = ctx.logger('emoji-recall')
60
+
61
+ ctx.inject(['chatluna', 'emojiluna'], (ctx2) => {
62
+ const base = config.selfUrl.replace(/\/+$/, '') + config.backendPath
63
+
64
+ // 过滤记账标签(自动获取 / 来自群:xxx),它们是噪声,不进向量、不展示
65
+ const isNoiseTag = (t) => t === '自动获取' || (typeof t === 'string' && t.startsWith('来自群'))
66
+ const cleanTags = (tags) => (tags || []).filter((t) => t && !isNoiseTag(t))
67
+
68
+ // 表情向量缓存:id -> { hash, vector }。仅在表情文本变化时重算。
69
+ const cache = new Map()
70
+ const emojiText = (e) => [e.name, e.category, cleanTags(e.tags).join(' ')].filter(Boolean).join(' ')
71
+ const textHash = (s) => {
72
+ let h = 0
73
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
74
+ return h
75
+ }
76
+ const escapeMd = (t) => String(t).replace(/([[\]()])/g, '\\$1')
77
+ const fmtList = (emojis) =>
78
+ emojis
79
+ .map(
80
+ (e) =>
81
+ `- [${escapeMd(e.name)}](${base}/get/${encodeURIComponent(e.id)}) - 分类: ${e.category}, 标签: ${cleanTags(e.tags).join(', ')}`
82
+ )
83
+ .join('\n')
84
+
85
+ ctx2.on('emojiluna/emoji-deleted', (id) => cache.delete(id))
86
+ ctx2.on('emojiluna/emoji-updated', (e) => e && cache.delete(e.id))
87
+
88
+ const ensureVectors = async (embeddings, emojis) => {
89
+ const missing = []
90
+ for (const e of emojis) {
91
+ const t = emojiText(e)
92
+ const h = textHash(t)
93
+ const c = cache.get(e.id)
94
+ if (!c || c.hash !== h) missing.push({ id: e.id, t, h })
95
+ }
96
+ if (!missing.length) return
97
+ const vectors = await embeddings.embedDocuments(missing.map((m) => m.t))
98
+ missing.forEach((m, i) => cache.set(m.id, { hash: m.h, vector: vectors[i] }))
99
+ }
100
+
101
+ ctx2.effect(() =>
102
+ ctx2.chatluna.promptRenderer.registerFunctionProvider(
103
+ config.functionName,
104
+ async (args, _variables, configurable) => {
105
+ try {
106
+ const K = Math.max(1, parseInt(args && args[0], 10) || config.topK)
107
+ const emojis = await ctx2.emojiluna.getEmojiList()
108
+ if (!emojis.length) return ''
109
+
110
+ // 查询文本 = 当前消息(去掉 <at/>、<img/> 等元素噪声与 URL,取尾部 maxQueryChars)
111
+ const session = configurable && configurable.session
112
+ let query = ((session && session.content) || '')
113
+ .replace(/<[^>]*>/g, ' ')
114
+ .replace(/https?:\/\/\S+/g, ' ')
115
+ .replace(/\s+/g, ' ')
116
+ .trim()
117
+ if (query.length > config.maxQueryChars) query = query.slice(-config.maxQueryChars)
118
+
119
+ const embeddings = await ctx2.chatluna.createEmbeddings(config.embeddingModel)
120
+ const embOk = embeddings && embeddings.value != null
121
+
122
+ // 无查询文本 / embeddings 不可用 → 优雅降级
123
+ if (!query || !embOk) {
124
+ if (config.debug) logger.info(`fallback (query=${!!query}, emb=${embOk}) → ${config.fallbackToRecent ? 'recent ' + K : 'empty'}`)
125
+ return config.fallbackToRecent ? fmtList(emojis.slice(0, K)) : ''
126
+ }
127
+
128
+ await ensureVectors(embeddings.value, emojis)
129
+ const queryVector = await embeddings.value.embedQuery(query)
130
+
131
+ let scored = emojis
132
+ .map((e) => ({ e, score: cosineSimilarity(queryVector, (cache.get(e.id) || {}).vector) }))
133
+ .sort((a, b) => b.score - a.score)
134
+ if (config.minScore > 0) scored = scored.filter((s) => s.score >= config.minScore)
135
+
136
+ let top = scored.slice(0, config.useReranker ? K * 3 : K)
137
+
138
+ if (config.useReranker && top.length > 1) {
139
+ try {
140
+ const reranker = await ctx2.chatluna.createReranker(config.rerankModel)
141
+ if (reranker && reranker.value != null) {
142
+ const rr = await reranker.value.rerank(top.map((s) => emojiText(s.e)), query, { topN: K })
143
+ top = rr.map((r) => top[r.index]).filter(Boolean)
144
+ }
145
+ } catch (err) {
146
+ logger.warn(`rerank failed: ${err.message}`)
147
+ }
148
+ }
149
+ top = top.slice(0, K)
150
+
151
+ if (config.debug) {
152
+ logger.info(`query="${query.slice(0, 30)}" → ${top.map((s) => `${s.e.name}(${(s.score || 0).toFixed(2)})`).join(', ') || '(空)'}`)
153
+ }
154
+ if (!top.length) return ''
155
+ return fmtList(top.map((s) => s.e))
156
+ } catch (e) {
157
+ logger.warn(`emoji recall failed: ${e.message}`)
158
+ return ''
159
+ }
160
+ }
161
+ )
162
+ )
163
+
164
+ logger.info(`emoji-recall ready (function {${config.functionName}}, base=${base})`)
165
+ })
166
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "koishi-plugin-emoji-recall",
3
+ "description": "按轮语义召回:用当前对话向量检索 emojiluna 表情库,每轮只注入最相关的 K 张(token 少且贴合上下文),经 {emojis_smart} 注入 chatluna 预设",
4
+ "version": "0.1.0",
5
+ "main": "index.js",
6
+ "license": "MIT",
7
+ "author": "妖祀",
8
+ "homepage": "https://github.com/ningningningning0420-ui/koishi-chatluna-plugins/tree/main/packages/koishi-plugin-emoji-recall#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ningningningning0420-ui/koishi-chatluna-plugins.git",
12
+ "directory": "packages/koishi-plugin-emoji-recall"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/ningningningning0420-ui/koishi-chatluna-plugins/issues"
16
+ },
17
+ "files": [
18
+ "index.js",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "keywords": [
23
+ "koishi",
24
+ "plugin",
25
+ "chatluna",
26
+ "emojiluna",
27
+ "emoji",
28
+ "embedding",
29
+ "rag"
30
+ ],
31
+ "peerDependencies": {
32
+ "koishi": "^4.18.0",
33
+ "koishi-plugin-chatluna": "*",
34
+ "koishi-plugin-emojiluna": "*"
35
+ },
36
+ "koishi": {
37
+ "description": {
38
+ "zh": "按轮语义召回表情包:向量检索当前对话最相关的 K 张,经 {emojis_smart} 注入",
39
+ "en": "Per-turn semantic recall of emojiluna stickers: vector-search the K most relevant stickers for the current conversation and inject them into chatluna presets via {emojis_smart}."
40
+ },
41
+ "service": {
42
+ "required": [
43
+ "chatluna",
44
+ "emojiluna"
45
+ ]
46
+ }
47
+ }
48
+ }