koishi-plugin-ll-ziyong 1.0.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.
package/lib/index.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * koishi-plugin-ll-ziyong
3
+ * AI 生图插件 — 接入 OpenAI 通用模型(Gemini 等),积分制
4
+ */
5
+ import { Context, Schema } from 'koishi';
6
+ declare module 'koishi' {
7
+ interface Tables {
8
+ ll_points: LLPoints;
9
+ }
10
+ }
11
+ interface LLPoints {
12
+ userId: string;
13
+ points: number;
14
+ totalSpent: number;
15
+ }
16
+ export interface Config {
17
+ apiUrl: string;
18
+ apiKey: string;
19
+ model: string;
20
+ keywords: string[];
21
+ cost: number;
22
+ prompt: string;
23
+ imgPrompt: string;
24
+ commandName: string;
25
+ cooldownSeconds: number;
26
+ }
27
+ export declare const Config: Schema<Config>;
28
+ export declare const name = "ll-ziyong";
29
+ export declare const inject: {
30
+ required: string[];
31
+ };
32
+ export declare function apply(ctx: Context, config: Config): void;
33
+ export {};
package/lib/index.js ADDED
@@ -0,0 +1,304 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.inject = exports.name = exports.Config = void 0;
4
+ exports.apply = apply;
5
+ /**
6
+ * koishi-plugin-ll-ziyong
7
+ * AI 生图插件 — 接入 OpenAI 通用模型(Gemini 等),积分制
8
+ */
9
+ const koishi_1 = require("koishi");
10
+ exports.Config = koishi_1.Schema.object({
11
+ apiUrl: koishi_1.Schema.string()
12
+ .description('OpenAI 兼容 API 地址(如 Gemini OpenAI 兼容端点)')
13
+ .default('https://generativelanguage.googleapis.com/v1beta/openai'),
14
+ apiKey: koishi_1.Schema.string().role('secret')
15
+ .description('API Key').required(),
16
+ model: koishi_1.Schema.string()
17
+ .description('模型名称')
18
+ .default('gemini-2.5-flash-image'),
19
+ keywords: koishi_1.Schema.array(koishi_1.Schema.string())
20
+ .role('table')
21
+ .description('触发关键词(消息包含这些词时自动生图,支持回复图片 / 发图 / @头像)')
22
+ .default(['手办化', '动漫化', '生成', '生图']),
23
+ cost: koishi_1.Schema.number()
24
+ .description('每次生图消耗积分数')
25
+ .min(1).max(10000).step(1).default(10),
26
+ prompt: koishi_1.Schema.string().role('textarea')
27
+ .description('文生图风格提示词(附加在用户输入前面)')
28
+ .default('将以下内容转化为精美动漫手办风格图片,高画质,细节丰富,柔和光影:'),
29
+ imgPrompt: koishi_1.Schema.string().role('textarea')
30
+ .description('图生图风格提示词(有参考图片时使用)')
31
+ .default('将这张图片转化为精美动漫手办风格图片,高画质,细节丰富,柔和光影,保留原图主体特征'),
32
+ commandName: koishi_1.Schema.string()
33
+ .description('生图命令名').default('shengtu'),
34
+ cooldownSeconds: koishi_1.Schema.number()
35
+ .description('冷却时间(秒)')
36
+ .min(0).max(300).step(1).default(10),
37
+ });
38
+ exports.name = 'll-ziyong';
39
+ exports.inject = { required: ['database'] };
40
+ function apply(ctx, config) {
41
+ const logger = new koishi_1.Logger('[ll-ziyong]');
42
+ ctx.model.extend('ll_points', {
43
+ userId: 'string',
44
+ points: 'unsigned',
45
+ totalSpent: 'unsigned',
46
+ });
47
+ const cooldowns = new Map();
48
+ /* ── 积分 ── */
49
+ async function getPoints(uid) {
50
+ const rows = await ctx.database.get('ll_points', { userId: uid });
51
+ return rows.length ? rows[0].points : 0;
52
+ }
53
+ async function addPoints(uid, amount, by) {
54
+ const rows = await ctx.database.get('ll_points', { userId: uid });
55
+ if (rows.length) {
56
+ const cur = rows[0].points + amount;
57
+ await ctx.database.set('ll_points', { userId: uid }, { points: cur });
58
+ logger.info(`${by} → ${uid} +${amount},余额 ${cur}`);
59
+ return cur;
60
+ }
61
+ await ctx.database.create('ll_points', { userId: uid, points: amount, totalSpent: 0 });
62
+ return amount;
63
+ }
64
+ async function spendPoints(uid, amount) {
65
+ const rows = await ctx.database.get('ll_points', { userId: uid });
66
+ if (!rows.length || rows[0].points < amount)
67
+ return false;
68
+ await ctx.database.set('ll_points', { userId: uid }, {
69
+ points: rows[0].points - amount,
70
+ totalSpent: (rows[0].totalSpent || 0) + amount,
71
+ });
72
+ return true;
73
+ }
74
+ function uidOf(session) {
75
+ return String(session.user?.id ?? session.author?.id ?? 'unknown');
76
+ }
77
+ /* ── 下载图片 ── */
78
+ async function downloadImage(url) {
79
+ try {
80
+ const data = await ctx.http.get(url, { responseType: 'arraybuffer', timeout: 30000 });
81
+ return Buffer.from(data);
82
+ }
83
+ catch (e) {
84
+ logger.debug('下载图片失败:', url, e);
85
+ return null;
86
+ }
87
+ }
88
+ /* ── 从 Session 提取参考图片(回复 / 附件 / @头像) ── */
89
+ async function extractRefImage(session) {
90
+ // 1) 回复消息中的图片
91
+ if (session.quote?.elements) {
92
+ const imgs = koishi_1.h.select(session.quote.elements, 'img');
93
+ const src = imgs[0]?.attrs?.src;
94
+ if (src) {
95
+ const buf = await downloadImage(src);
96
+ if (buf)
97
+ return { buffer: buf, source: 'reply' };
98
+ }
99
+ }
100
+ // 2) 当前消息中的图片
101
+ if (session.elements) {
102
+ const imgs = koishi_1.h.select(session.elements, 'img');
103
+ const src = imgs[0]?.attrs?.src;
104
+ if (src) {
105
+ const buf = await downloadImage(src);
106
+ if (buf)
107
+ return { buffer: buf, source: 'attachment' };
108
+ }
109
+ }
110
+ // 3) @某人的头像
111
+ if (session.elements) {
112
+ const ats = koishi_1.h.select(session.elements, 'at');
113
+ const atId = ats[0]?.attrs?.id;
114
+ if (atId) {
115
+ try {
116
+ const user = await session.bot.getUser(atId, session.guildId);
117
+ const avatarUrl = user?.avatar;
118
+ if (avatarUrl) {
119
+ const buf = await downloadImage(avatarUrl);
120
+ if (buf)
121
+ return { buffer: buf, source: `@${user.name || atId}` };
122
+ }
123
+ }
124
+ catch (e) {
125
+ logger.debug('获取用户头像失败:', atId, e);
126
+ }
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ /* ── 获取纯文本(去 @mention) ── */
132
+ function cleanText(session) {
133
+ let text = session.content || '';
134
+ if (typeof text !== 'string')
135
+ return '';
136
+ // 跳过命令前缀
137
+ if (/^[\/!!#]/.test(text.trim()))
138
+ return '';
139
+ // 去 @mention 文本
140
+ if (session.elements) {
141
+ for (const at of koishi_1.h.select(session.elements, 'at')) {
142
+ const name = at.attrs?.name || at.attrs?.id || '';
143
+ text = text.replace(`@${name}`, '').trim();
144
+ }
145
+ }
146
+ return text.trim();
147
+ }
148
+ /* ── 检查是否命中关键词 ── */
149
+ function matchKeyword(text) {
150
+ if (!text)
151
+ return null;
152
+ for (const kw of config.keywords) {
153
+ if (text.includes(kw))
154
+ return kw;
155
+ }
156
+ return null;
157
+ }
158
+ /* ── 生图(支持文本 / 图片参考) ── */
159
+ async function generateImage(prompt, refBuf, refMime) {
160
+ try {
161
+ const mime = refMime || 'image/png';
162
+ const b64 = refBuf ? refBuf.toString('base64') : null;
163
+ const userContent = refBuf
164
+ ? [
165
+ { type: 'text', text: `${config.imgPrompt}\n${prompt}` },
166
+ { type: 'image_url', image_url: { url: `data:${mime};base64,${b64}` } },
167
+ ]
168
+ : `${config.prompt}\n${prompt}`;
169
+ const res = await ctx.http.post(`${config.apiUrl}/chat/completions`, {
170
+ model: config.model,
171
+ messages: [{ role: 'user', content: userContent }],
172
+ }, {
173
+ headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' },
174
+ timeout: 120000,
175
+ });
176
+ const content = res?.choices?.[0]?.message?.content;
177
+ if (!content) {
178
+ logger.warn('API 无内容');
179
+ return null;
180
+ }
181
+ // 尝试多种图片提取格式
182
+ const b64Match = content.match(/data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)/);
183
+ if (b64Match)
184
+ return Buffer.from(b64Match[1], 'base64');
185
+ const md = content.match(/!\[.*?\]\((https?:\/\/[^\s)]+)\)/);
186
+ const url = md?.[1] || content.match(/(https?:\/\/[^\s"']+\.(?:png|jpg|jpeg|gif|webp))/i)?.[1];
187
+ if (url) {
188
+ const img = await ctx.http.get(url, { responseType: 'arraybuffer', timeout: 60000 });
189
+ return Buffer.from(img);
190
+ }
191
+ logger.warn('未找到图片:', content.slice(0, 200));
192
+ return null;
193
+ }
194
+ catch (e) {
195
+ logger.error('生图异常:', e);
196
+ return null;
197
+ }
198
+ }
199
+ /* ── 生图执行流程(命令 & 关键词共用) ── */
200
+ async function doGenerate(session, prompt, refImg) {
201
+ const uid = uidOf(session);
202
+ const s = session;
203
+ const name = s.user?.name || s.author?.name || uid;
204
+ const now = Date.now();
205
+ const last = cooldowns.get(uid) || 0;
206
+ const left = config.cooldownSeconds - (now - last) / 1000;
207
+ if (left > 0)
208
+ return `⏳ 冷却中,${left.toFixed(0)} 秒后再试`;
209
+ if (!(await spendPoints(uid, config.cost))) {
210
+ return `❌ 积分不足!需 ${config.cost},你当前 ${await getPoints(uid)} 积分`;
211
+ }
212
+ cooldowns.set(uid, now);
213
+ const tip = refImg
214
+ ? `🎨 ${name} 正在「${refImg.source}」→ 手办化...(-${config.cost} 积分)`
215
+ : `🎨 ${name} 正在生图「${prompt.slice(0, 40)}」...(-${config.cost} 积分)`;
216
+ await session.send(tip);
217
+ const buf = await generateImage(prompt, refImg?.buffer);
218
+ if (!buf) {
219
+ await addPoints(uid, config.cost, 'system');
220
+ return '❌ 生图失败,积分已退还';
221
+ }
222
+ return koishi_1.h.image(buf, 'image/png');
223
+ }
224
+ /* ── 查积分 ── */
225
+ ctx.command(`${config.commandName}.points`, '查看积分')
226
+ .userFields(['id'])
227
+ .action(async ({ session }) => {
228
+ const uid = uidOf(session);
229
+ const pts = await getPoints(uid);
230
+ const rows = await ctx.database.get('ll_points', { userId: uid });
231
+ const spent = rows.length ? rows[0].totalSpent || 0 : 0;
232
+ return `💰 积分:${pts} | 已消费:${spent}`;
233
+ });
234
+ /* ── 管理员加积分 ── */
235
+ ctx.command(`${config.commandName}.add <target:text> <amount:number>`, '给用户加积分(仅管理)')
236
+ .userFields(['id', 'authority'])
237
+ .action(async ({ session }, target, amount) => {
238
+ if (session.user?.authority < 3)
239
+ return '❌ 仅管理员可用';
240
+ if (!amount || amount <= 0 || amount > 100000)
241
+ return '积分范围 1~100000';
242
+ let uid = target;
243
+ if (uid.startsWith('<@') && uid.endsWith('>'))
244
+ uid = uid.slice(2, -1);
245
+ else if (uid.startsWith('@'))
246
+ uid = uid.slice(1);
247
+ const adminId = uidOf(session);
248
+ const cur = await addPoints(uid, amount, adminId);
249
+ return `✅ 已为 ${target} 添加 ${amount} 积分 → 余额 ${cur}`;
250
+ });
251
+ /* ── 主命令:生图(支持回复图片 / 发图 + 命令) ── */
252
+ ctx.command(`${config.commandName} [prompt:text]`, 'AI 生图(消耗积分),可回复图片或发图 + 命令')
253
+ .userFields(['id', 'name'])
254
+ .action(async ({ session }, prompt) => {
255
+ const refImg = await extractRefImage(session);
256
+ const text = prompt?.trim() || cleanText(session) || (refImg ? '手办化' : '');
257
+ if (!refImg && !text) {
258
+ return `用法:/${config.commandName} <描述>,如 /${config.commandName} 一只可爱的猫
259
+ 也可回复图片 / 发图后用 /${config.commandName} 生图`;
260
+ }
261
+ return doGenerate(session, text, refImg || undefined);
262
+ });
263
+ /* ── 关键词触发(支持回复图片 / 发图 / @头像) ── */
264
+ if (config.keywords?.length) {
265
+ ctx.middleware(async (session, next) => {
266
+ const text = cleanText(session);
267
+ if (!matchKeyword(text))
268
+ return next();
269
+ const uid = uidOf(session);
270
+ // 冷却检查
271
+ const now = Date.now();
272
+ if ((now - (cooldowns.get(uid) || 0)) / 1000 < config.cooldownSeconds)
273
+ return next();
274
+ // 提取参考图片(回复 / 附件 / @头像)
275
+ const refImg = await extractRefImage(session);
276
+ // 构造 prompt:去除关键词
277
+ let prompt = text;
278
+ for (const kw of config.keywords)
279
+ prompt = prompt.replace(kw, '').trim();
280
+ if (!prompt)
281
+ prompt = refImg ? '手办化' : text;
282
+ if (!(await spendPoints(uid, config.cost))) {
283
+ await session.send(`❌ 积分不足!需 ${config.cost},你当前 ${await getPoints(uid)} 积分`);
284
+ return next();
285
+ }
286
+ cooldowns.set(uid, now);
287
+ const s = session;
288
+ const name = s.user?.name || s.author?.name || uid;
289
+ const tip = refImg
290
+ ? `🎨 ${name} 正在「${refImg.source}」→ 手办化...(-${config.cost} 积分)`
291
+ : `🎨 ${name} 正在生图「${prompt.slice(0, 40)}」...(-${config.cost} 积分)`;
292
+ await session.send(tip);
293
+ const buf = await generateImage(prompt, refImg?.buffer);
294
+ if (!buf) {
295
+ await addPoints(uid, config.cost, 'system');
296
+ await session.send('❌ 生图失败,积分已退还');
297
+ return next();
298
+ }
299
+ await session.send(koishi_1.h.image(buf, 'image/png'));
300
+ return next();
301
+ });
302
+ }
303
+ logger.info('已启动');
304
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "koishi-plugin-ll-ziyong",
3
+ "version": "1.0.0",
4
+ "description": "AI 生图插件:接入 OpenAI 通用模型(Gemini 等),支持积分系统,可生成动漫化/手办化图片",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
7
+ "files": ["lib"],
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "clean": "rimraf lib"
11
+ },
12
+ "license": "MIT",
13
+ "keywords": ["koishi", "koishi-plugin", "image-generation", "ai", "gemini"],
14
+ "koishi": {
15
+ "description": {
16
+ "zh": "AI 生图插件:接入 OpenAI 通用模型(Gemini 等),可设置关键词触发,含积分系统(仅管理员可添加积分)"
17
+ },
18
+ "service": {
19
+ "required": ["database"]
20
+ }
21
+ },
22
+ "peerDependencies": {
23
+ "koishi": "^4.18.0"
24
+ },
25
+ "devDependencies": {
26
+ "koishi": "^4.18.0",
27
+ "typescript": "^5.4.0",
28
+ "rimraf": "^5.0.0"
29
+ }
30
+ }