koishi-plugin-group-analysis-mx 1.0.16 → 1.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.
- package/lib/config.js +48 -0
- package/lib/index.js +14 -1
- package/lib/plugins/command.js +36 -0
- package/lib/service/analysis.js +134 -24
- package/lib/service/drawing.js +62 -0
- package/lib/service/llm.js +35 -0
- package/lib/service/message.js +6 -1
- package/lib/utils.js +22 -0
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -116,6 +116,54 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
116
116
|
.max(2)
|
|
117
117
|
.default(1.5)
|
|
118
118
|
}).description('OpenAI API 设置'),
|
|
119
|
+
koishi_1.Schema.object({
|
|
120
|
+
enableDailyComic: koishi_1.Schema.boolean()
|
|
121
|
+
.description('是否启用每日漫画功能。')
|
|
122
|
+
.default(false),
|
|
123
|
+
comicSchedule: koishi_1.Schema.string()
|
|
124
|
+
.description('每日漫画自动生成的 CRON 表达式。留空则仅手动触发。例如 "0 20 * * *" 表示每天 20:00。')
|
|
125
|
+
.default(''),
|
|
126
|
+
comicDays: koishi_1.Schema.number()
|
|
127
|
+
.description('漫画话题分析回溯的天数。')
|
|
128
|
+
.min(1)
|
|
129
|
+
.max(7)
|
|
130
|
+
.default(1),
|
|
131
|
+
comicMinMessages: koishi_1.Schema.number()
|
|
132
|
+
.description('生成漫画所需的最小消息数量。')
|
|
133
|
+
.min(10)
|
|
134
|
+
.default(50),
|
|
135
|
+
promptComicStoryboard: koishi_1.Schema.string()
|
|
136
|
+
.description('漫画分镜提示词模板。')
|
|
137
|
+
.role('textarea')
|
|
138
|
+
.default(`你是一个资深的漫画分镜师与 AI 绘画提示词专家。请根据以下【群聊每日核心话题列表】,将其改编并设计为一个精彩的多格连环漫画(Comic Strip)全景视觉画图提示词 (Prompt)。
|
|
139
|
+
|
|
140
|
+
【核心规则】:
|
|
141
|
+
1. 每个话题对应一个分格 Panel(共 {topicCount} 个分格),不许遗漏任何话题。
|
|
142
|
+
2. 为每个话题设计一句生动的人物台词或吐槽(控制在 15 个汉字以内),放在角色气泡中(adorable kawaii anime speech bubble, soft rounded cloud-like shape, cute pointer tail pointing to the speaker)。
|
|
143
|
+
3. 每个分格底部设计一条精炼的事件旁白条(控制在 30 字以内,cute pastel-colored kawaii caption strip at the bottom of the panel with soft rounded corners,严禁死板白框)。
|
|
144
|
+
4. 气泡与旁白条内的中文必须显式渲染(指令格式:containing a speech bubble with exact Chinese text "台词" 以及 and a cute caption strip at bottom with exact Chinese text "短标题"),绝对禁止将中文翻译成英文。
|
|
145
|
+
5. 画面整体构图与场景描述使用英文;严禁将长篇上下文写在画面上,仅允许渲染短标题字幕条和气泡台词。
|
|
146
|
+
6. 保持整篇连环画的主角(一个可爱的动漫少女角色)绝对连贯,每个分格都必须出现。
|
|
147
|
+
|
|
148
|
+
【待创作的群聊核心话题列表】:
|
|
149
|
+
{topics}
|
|
150
|
+
|
|
151
|
+
请输出包含 "scene" 字段的 JSON 对象,scene 为完整的英文生图提示词。`)
|
|
152
|
+
}).description('每日漫画设置'),
|
|
153
|
+
koishi_1.Schema.object({
|
|
154
|
+
drawingEndpoint: koishi_1.Schema.string()
|
|
155
|
+
.description('OpenAI 兼容的生图 API 地址(末尾无需 /v1)。支持 OpenAI、SiliconFlow、豆包/火山方舟等。')
|
|
156
|
+
.default(''),
|
|
157
|
+
drawingApiKey: koishi_1.Schema.string()
|
|
158
|
+
.role('secret')
|
|
159
|
+
.description('生图 API Key。'),
|
|
160
|
+
drawingModel: koishi_1.Schema.string()
|
|
161
|
+
.description('生图模型名称,例如 gpt-image-1、dall-e-3、Kolors 等。')
|
|
162
|
+
.default('gpt-image-1'),
|
|
163
|
+
drawingSize: koishi_1.Schema.string()
|
|
164
|
+
.description('生图尺寸。')
|
|
165
|
+
.default('1024x1024')
|
|
166
|
+
}).description('生图 API 设置'),
|
|
119
167
|
koishi_1.Schema.object({
|
|
120
168
|
personaUserFilter: koishi_1.Schema.array(String)
|
|
121
169
|
.role('table')
|
package/lib/index.js
CHANGED
|
@@ -18,6 +18,7 @@ exports.apply = apply;
|
|
|
18
18
|
const analysis_1 = require("./service/analysis");
|
|
19
19
|
const llm_1 = require("./service/llm");
|
|
20
20
|
const renderer_1 = require("./service/renderer");
|
|
21
|
+
const drawing_1 = require("./service/drawing");
|
|
21
22
|
const message_1 = require("./service/message");
|
|
22
23
|
const plugin_1 = require("./plugin");
|
|
23
24
|
const cron_1 = require("./cron");
|
|
@@ -28,15 +29,18 @@ function apply(ctx, config) {
|
|
|
28
29
|
ctx.plugin(llm_1.LLMService, config);
|
|
29
30
|
ctx.plugin(analysis_1.AnalysisService, config);
|
|
30
31
|
ctx.plugin(renderer_1.RendererService, config);
|
|
32
|
+
ctx.plugin(drawing_1.DrawingService, config);
|
|
31
33
|
ctx.inject([
|
|
32
34
|
'group_analysis_message',
|
|
33
35
|
'group_analysis_llm',
|
|
34
|
-
'group_analysis_renderer'
|
|
36
|
+
'group_analysis_renderer',
|
|
37
|
+
'group_analysis_drawing'
|
|
35
38
|
], (ctx) => {
|
|
36
39
|
(0, plugin_1.plugin)(ctx, config);
|
|
37
40
|
});
|
|
38
41
|
ctx.inject(['group_analysis'], (ctx) => {
|
|
39
42
|
ctx.effect(() => scheduleAutoAnalysis(ctx, config));
|
|
43
|
+
ctx.effect(() => scheduleAutoComic(ctx, config));
|
|
40
44
|
});
|
|
41
45
|
}
|
|
42
46
|
function scheduleAutoAnalysis(ctx, config) {
|
|
@@ -48,4 +52,13 @@ function scheduleAutoAnalysis(ctx, config) {
|
|
|
48
52
|
name: 'group-analysis'
|
|
49
53
|
});
|
|
50
54
|
}
|
|
55
|
+
function scheduleAutoComic(ctx, config) {
|
|
56
|
+
if (!config.enableDailyComic || !config.comicSchedule?.trim()) {
|
|
57
|
+
return () => { };
|
|
58
|
+
}
|
|
59
|
+
return (0, cron_1.cron)(ctx, config.comicSchedule, () => ctx.group_analysis.executeDailyComicForEnabledGroups(), {
|
|
60
|
+
cooldown: 30,
|
|
61
|
+
name: 'group-comic'
|
|
62
|
+
});
|
|
63
|
+
}
|
|
51
64
|
//# sourceMappingURL=index.js.map
|
package/lib/plugins/command.js
CHANGED
|
@@ -208,5 +208,41 @@ function apply(ctx, config) {
|
|
|
208
208
|
return '用户画像分析执行失败,请检查日志。';
|
|
209
209
|
}
|
|
210
210
|
});
|
|
211
|
+
ctx.command('每日漫画 [days:number]', '生成今日群聊漫画')
|
|
212
|
+
.alias('daily-comic')
|
|
213
|
+
.alias('群分析.每日漫画')
|
|
214
|
+
.usage('使用方法:/每日漫画 或 /每日漫画 3(回溯 3 天)。需要已配置生图 API。')
|
|
215
|
+
.option('group', '-g <guildId:string> 指定群号', {
|
|
216
|
+
authority: 3
|
|
217
|
+
})
|
|
218
|
+
.option('channel', '-c <channelId:string> 指定频道号', {
|
|
219
|
+
authority: 3
|
|
220
|
+
})
|
|
221
|
+
.action(async ({ session, options }, days) => {
|
|
222
|
+
if (config.personaBlacklist.includes(session.userId)) {
|
|
223
|
+
return '您无权使用本插件。';
|
|
224
|
+
}
|
|
225
|
+
if (session.isDirect && !options.group && !options.channel) {
|
|
226
|
+
return '私聊中请使用 -g 或 -c 指定目标群或频道。';
|
|
227
|
+
}
|
|
228
|
+
const targetGuildId = options.group ?? session.guildId ?? undefined;
|
|
229
|
+
const targetChannelId = options.channel ?? session.channelId ?? undefined;
|
|
230
|
+
if (!targetGuildId && !targetChannelId) {
|
|
231
|
+
return '请使用 -g 或 -c 指定目标群或频道。';
|
|
232
|
+
}
|
|
233
|
+
if (!checkGroup(session, {
|
|
234
|
+
guildId: targetGuildId,
|
|
235
|
+
channelId: targetChannelId
|
|
236
|
+
})) {
|
|
237
|
+
return '目标群未启用分析功能,请使用 群分析.启用 来启用目标群的分析功能。';
|
|
238
|
+
}
|
|
239
|
+
const targetDays = typeof days === 'number' && days >= 1
|
|
240
|
+
? Math.min(days, 7)
|
|
241
|
+
: undefined;
|
|
242
|
+
await ctx.group_analysis.executeDailyComic(session.selfId, {
|
|
243
|
+
guildId: targetGuildId || undefined,
|
|
244
|
+
channelId: targetChannelId || undefined
|
|
245
|
+
}, targetDays);
|
|
246
|
+
});
|
|
211
247
|
}
|
|
212
248
|
//# sourceMappingURL=command.js.map
|
package/lib/service/analysis.js
CHANGED
|
@@ -9,7 +9,8 @@ class AnalysisService extends koishi_1.Service {
|
|
|
9
9
|
static inject = [
|
|
10
10
|
'group_analysis_llm',
|
|
11
11
|
'group_analysis_message',
|
|
12
|
-
'group_analysis_renderer'
|
|
12
|
+
'group_analysis_renderer',
|
|
13
|
+
'group_analysis_drawing'
|
|
13
14
|
];
|
|
14
15
|
personaCache = new Map();
|
|
15
16
|
personaProcessing = new Set();
|
|
@@ -163,21 +164,28 @@ class AnalysisService extends koishi_1.Service {
|
|
|
163
164
|
const results = [];
|
|
164
165
|
const useOnlyCurrentGroup = this.config.personaOnlyCurrentGroup ||
|
|
165
166
|
this.config.enableAllGroupsByDefault;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
platform: sourceGroup?.platform,
|
|
171
|
-
selfId: sourceGroup?.selfId,
|
|
172
|
-
channelId: sourceGroup?.channelId,
|
|
173
|
-
guildId: sourceGroup?.guildId
|
|
174
|
-
}
|
|
175
|
-
].filter((group) => !!group.platform &&
|
|
176
|
-
!!group.selfId &&
|
|
177
|
-
(!!group.channelId || !!group.guildId))
|
|
167
|
+
// 优先匹配配置的监听群;未匹配到时回退到触发会话的当前群,
|
|
168
|
+
// 保证官方 QQ 机器人等平台下画像分析仍能收集到消息。
|
|
169
|
+
let relevantGroups = useOnlyCurrentGroup
|
|
170
|
+
? []
|
|
178
171
|
: this.config.listenerGroups.filter((group) => group.enabled &&
|
|
179
172
|
group.platform === record.platform &&
|
|
180
173
|
group.selfId === record.selfId);
|
|
174
|
+
if (!relevantGroups.length && sourceGroup) {
|
|
175
|
+
const fallbackGroup = {
|
|
176
|
+
enabled: true,
|
|
177
|
+
platform: sourceGroup.platform,
|
|
178
|
+
selfId: sourceGroup.selfId,
|
|
179
|
+
channelId: sourceGroup.channelId,
|
|
180
|
+
guildId: sourceGroup.guildId
|
|
181
|
+
};
|
|
182
|
+
if (fallbackGroup.platform &&
|
|
183
|
+
fallbackGroup.selfId &&
|
|
184
|
+
(fallbackGroup.channelId || fallbackGroup.guildId)) {
|
|
185
|
+
this.ctx.logger.info(`用户 ${record.userId} 画像分析未匹配到配置监听群,回退使用当前群 (${fallbackGroup.channelId || fallbackGroup.guildId})。`);
|
|
186
|
+
relevantGroups = [fallbackGroup];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
181
189
|
if (!relevantGroups.length) {
|
|
182
190
|
const reason = useOnlyCurrentGroup
|
|
183
191
|
? '未提供可用的当前群组信息'
|
|
@@ -185,6 +193,7 @@ class AnalysisService extends koishi_1.Service {
|
|
|
185
193
|
this.ctx.logger.warn(`用户 ${record.userId} 画像分析缺少群组上下文(${reason}),跳过画像分析。`);
|
|
186
194
|
return [];
|
|
187
195
|
}
|
|
196
|
+
this.ctx.logger.info(`用户 ${record.userId} 画像分析将从 ${relevantGroups.length} 个群收集消息。`);
|
|
188
197
|
const totalLimit = this.config.personaMaxMessages;
|
|
189
198
|
for (const group of relevantGroups) {
|
|
190
199
|
const bot = this._getBot(group.selfId);
|
|
@@ -193,18 +202,18 @@ class AnalysisService extends koishi_1.Service {
|
|
|
193
202
|
if (bot.platform === 'onebot') {
|
|
194
203
|
userGroupInfo = await bot.internal.getGroupMemberInfo(group.guildId, record.userId, true);
|
|
195
204
|
}
|
|
196
|
-
else {
|
|
205
|
+
else if (bot?.getGuildMember) {
|
|
197
206
|
userGroupInfo = await bot.getGuildMember(group.channelId || group.guildId, record.userId);
|
|
198
207
|
}
|
|
199
|
-
if (userGroupInfo == null) {
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
208
|
}
|
|
203
209
|
catch (error) {
|
|
204
|
-
|
|
205
|
-
|
|
210
|
+
// 官方 QQ 机器人等平台可能没有获取群成员信息的权限,
|
|
211
|
+
// 不中断画像分析,仅跳过角色信息(roles 保持为空)。
|
|
212
|
+
this.ctx.logger.debug(`获取用户 ${record.userId} 的群组信息失败 (${group.channelId || group.guildId}),将跳过角色信息: ${error}`);
|
|
213
|
+
}
|
|
214
|
+
if (userGroupInfo) {
|
|
215
|
+
record.roles = (userGroupInfo.roles || []).map((r) => (typeof r === 'string' ? r : r.name || r.id || ''));
|
|
206
216
|
}
|
|
207
|
-
record.roles = (userGroupInfo.roles || []).map((r) => (typeof r === 'string' ? r : r.name || r.id || ''));
|
|
208
217
|
const history = await this.ctx.group_analysis_message.getHistoricalMessages({
|
|
209
218
|
guildId: group.guildId,
|
|
210
219
|
channelId: group.channelId,
|
|
@@ -614,13 +623,14 @@ class AnalysisService extends koishi_1.Service {
|
|
|
614
623
|
user = await bot.getGuildMember(session.channelId || session.guildId, userId);
|
|
615
624
|
}
|
|
616
625
|
catch (error) {
|
|
617
|
-
|
|
626
|
+
// 官方 QQ 机器人等平台可能没有获取群成员信息的权限,回退到本地记录
|
|
627
|
+
this.ctx.logger.debug(`获取用户 ${userId} 的成员信息失败,将回退到本地记录: ${error}`);
|
|
618
628
|
}
|
|
619
629
|
const cache = await this.ensurePersonaCache(recordId, {
|
|
620
630
|
platform: session.platform,
|
|
621
631
|
selfId: session.selfId,
|
|
622
632
|
userId,
|
|
623
|
-
username: user?.nick || user?.name || userId
|
|
633
|
+
username: user?.nick || user?.name || user?.user?.name || userId
|
|
624
634
|
});
|
|
625
635
|
let displayName = cache.record.username;
|
|
626
636
|
const cacheExpired = (0, utils_1.isCacheExpiredByDays)(cache.record.lastAnalysisAt, this.config.personaCacheLifetimeDays);
|
|
@@ -648,17 +658,36 @@ class AnalysisService extends koishi_1.Service {
|
|
|
648
658
|
}
|
|
649
659
|
const profile = cache.parsedPersona;
|
|
650
660
|
if (user) {
|
|
651
|
-
|
|
661
|
+
// Koishi Universal.GuildMember 结构:{ user: { id, name, avatar }, name, ... }
|
|
662
|
+
avatar = user.avatar || user.user?.avatar;
|
|
652
663
|
const resolvedName = user.nick ||
|
|
653
|
-
user.name
|
|
664
|
+
user.name ||
|
|
665
|
+
user.user?.name;
|
|
654
666
|
if (resolvedName) {
|
|
655
667
|
displayName = resolvedName;
|
|
656
668
|
cache.record.username = resolvedName;
|
|
657
669
|
}
|
|
658
670
|
}
|
|
671
|
+
// 平台化头像解析:onebot 走 QQ 号接口,官方 QQ 机器人走 openid 模板
|
|
659
672
|
if (session.platform === 'onebot') {
|
|
660
673
|
avatar = (0, utils_1.getAvatarUrl)(userId);
|
|
661
674
|
}
|
|
675
|
+
else if (!avatar) {
|
|
676
|
+
avatar = (0, utils_1.resolveUserAvatar)(bot, userId);
|
|
677
|
+
}
|
|
678
|
+
// 昵称/头像兜底:从数据库已记录的消息中回退用户资料
|
|
679
|
+
if (!displayName || displayName === userId || !avatar) {
|
|
680
|
+
const fallback = await this.lookupUserProfileFromMessages(session.platform, session.selfId, userId);
|
|
681
|
+
if (fallback) {
|
|
682
|
+
if ((!displayName || displayName === userId) && fallback.username) {
|
|
683
|
+
displayName = fallback.username;
|
|
684
|
+
cache.record.username = fallback.username;
|
|
685
|
+
}
|
|
686
|
+
if (!avatar && fallback.avatarUrl) {
|
|
687
|
+
avatar = fallback.avatarUrl;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
662
691
|
profile.analysisDate = cache.record.lastAnalysisAt.toLocaleString();
|
|
663
692
|
const image = await this.ctx.group_analysis_renderer.renderUserPersona(profile, displayName, avatar, this.config);
|
|
664
693
|
message =
|
|
@@ -673,6 +702,87 @@ class AnalysisService extends koishi_1.Service {
|
|
|
673
702
|
}
|
|
674
703
|
await session.send(message);
|
|
675
704
|
}
|
|
705
|
+
async lookupUserProfileFromMessages(platform, selfId, userId) {
|
|
706
|
+
try {
|
|
707
|
+
const records = await this.ctx.database
|
|
708
|
+
.select('group_analysis_messages')
|
|
709
|
+
.where({ platform, selfId, userId })
|
|
710
|
+
.orderBy(($) => $.timestamp, 'desc')
|
|
711
|
+
.limit(1)
|
|
712
|
+
.execute();
|
|
713
|
+
const latest = records[0];
|
|
714
|
+
if (!latest)
|
|
715
|
+
return null;
|
|
716
|
+
return {
|
|
717
|
+
username: latest.username || null,
|
|
718
|
+
avatarUrl: latest.avatarUrl || null
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
catch (error) {
|
|
722
|
+
this.ctx.logger.debug(`从消息记录回退用户资料失败 (${platform}/${userId}): ${error}`);
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
async executeDailyComic(selfId, target, days) {
|
|
727
|
+
const bot = this._getBot(selfId);
|
|
728
|
+
const targetChannel = target.channelId ?? target.guildId;
|
|
729
|
+
if (!targetChannel) {
|
|
730
|
+
this.ctx.logger.warn('执行每日漫画需要提供 channelId 或 guildId。');
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const sendStatus = async (content) => bot?.sendMessage(targetChannel, content, target.guildId);
|
|
734
|
+
const lookbackDays = days ?? this.config.comicDays ?? 1;
|
|
735
|
+
try {
|
|
736
|
+
await sendStatus('正在生成今日群聊漫画,请稍候...');
|
|
737
|
+
const messages = await this._getGroupHistoryFromMessageService(selfId, target, lookbackDays);
|
|
738
|
+
if (messages.length < this.config.comicMinMessages) {
|
|
739
|
+
await sendStatus(`消息数量(${messages.length}/${this.config.comicMinMessages})不足以生成漫画,请稍后再试。`);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
// 1. 提取话题
|
|
743
|
+
const { allMessagesText } = (0, utils_1.calculateBasicStats)(messages);
|
|
744
|
+
const messagesText = allMessagesText.join('\n');
|
|
745
|
+
const topics = await this.ctx.group_analysis_llm.summarizeTopics(messagesText, null);
|
|
746
|
+
if (!topics?.length) {
|
|
747
|
+
await sendStatus('未能从群聊记录中提取到有效话题,无法生成漫画。');
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
// 2. 生成漫画分镜提示词
|
|
751
|
+
const scene = await this.ctx.group_analysis_llm.analyzeComicStoryboards(topics);
|
|
752
|
+
if (!scene) {
|
|
753
|
+
await sendStatus('漫画分镜提示词生成失败,请检查日志。');
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
// 3. 调用生图 API 生成漫画
|
|
757
|
+
const imageBuffer = await this.ctx.group_analysis_drawing.generateImage(scene);
|
|
758
|
+
const mime = imageBuffer[0] === 0x89
|
|
759
|
+
? 'image/png'
|
|
760
|
+
: imageBuffer[0] === 0xff
|
|
761
|
+
? 'image/jpeg'
|
|
762
|
+
: 'image/png';
|
|
763
|
+
await bot?.sendMessage(targetChannel, koishi_1.h.image(imageBuffer, mime), target.guildId);
|
|
764
|
+
this.ctx.logger.info(`每日漫画生成并发送成功 (${target.guildId || target.channelId}),图片大小: ${imageBuffer.length} bytes。`);
|
|
765
|
+
}
|
|
766
|
+
catch (error) {
|
|
767
|
+
this.ctx.logger.error(`为群组 ${target.guildId || target.channelId} 生成每日漫画失败:`, error);
|
|
768
|
+
const errorMessage = error instanceof Error ? error.message : '未知错误。';
|
|
769
|
+
await sendStatus(`漫画生成失败: ${errorMessage}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
async executeDailyComicForEnabledGroups() {
|
|
773
|
+
const enabledGroups = this.config.listenerGroups.filter((group) => group.enabled);
|
|
774
|
+
for (const group of enabledGroups) {
|
|
775
|
+
try {
|
|
776
|
+
await this.executeDailyComic(group.selfId, {
|
|
777
|
+
guildId: group.guildId,
|
|
778
|
+
channelId: group.channelId
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
this.ctx.logger.error(`群 ${group.guildId || group.channelId} 自动漫画生成失败:`, err);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
676
786
|
async analyzeGroupMessages(messages, selfId, target, context) {
|
|
677
787
|
this.ctx.logger.info(`开始分析 ${messages.length} 条消息...`);
|
|
678
788
|
const { userStats, totalChars, totalEmojiCount, allMessagesText } = (0, utils_1.calculateBasicStats)(messages);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DrawingService = void 0;
|
|
4
|
+
const koishi_1 = require("koishi");
|
|
5
|
+
class DrawingService extends koishi_1.Service {
|
|
6
|
+
config;
|
|
7
|
+
constructor(ctx, config) {
|
|
8
|
+
super(ctx, 'group_analysis_drawing', true);
|
|
9
|
+
this.config = config;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* 调用 OpenAI 兼容的图片生成 API(/images/generations)生成一张图片。
|
|
13
|
+
* @param prompt 生图提示词
|
|
14
|
+
* @returns 图片字节(PNG/JPEG);失败时抛出异常
|
|
15
|
+
*/
|
|
16
|
+
async generateImage(prompt) {
|
|
17
|
+
const endpoint = (this.config.drawingEndpoint || '').replace(/\/$/, '');
|
|
18
|
+
if (!endpoint || !this.config.drawingApiKey) {
|
|
19
|
+
throw new Error('未配置生图 API(drawingEndpoint / drawingApiKey)。请在插件配置中填写。');
|
|
20
|
+
}
|
|
21
|
+
const url = `${endpoint}/images/generations`;
|
|
22
|
+
this.ctx.logger.info('正在调用生图 API 生成漫画...');
|
|
23
|
+
let response;
|
|
24
|
+
try {
|
|
25
|
+
response = await this.ctx.http.post(url, {
|
|
26
|
+
model: this.config.drawingModel || 'gpt-image-1',
|
|
27
|
+
prompt,
|
|
28
|
+
n: 1,
|
|
29
|
+
size: this.config.drawingSize || '1024x1024'
|
|
30
|
+
}, {
|
|
31
|
+
headers: {
|
|
32
|
+
'Authorization': `Bearer ${this.config.drawingApiKey}`,
|
|
33
|
+
'Content-Type': 'application/json'
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
this.ctx.logger.error('生图 API 调用失败:', error);
|
|
39
|
+
throw new Error(`生图 API 调用失败: ${error instanceof Error ? error.message : error}`);
|
|
40
|
+
}
|
|
41
|
+
const item = response?.data?.[0];
|
|
42
|
+
if (!item) {
|
|
43
|
+
this.ctx.logger.error(`生图 API 返回空响应: ${JSON.stringify(response)}`);
|
|
44
|
+
throw new Error('生图 API 返回空响应。');
|
|
45
|
+
}
|
|
46
|
+
if (item.b64_json) {
|
|
47
|
+
this.ctx.logger.info('生图 API 返回 base64 图片。');
|
|
48
|
+
return Buffer.from(item.b64_json, 'base64');
|
|
49
|
+
}
|
|
50
|
+
if (item.url) {
|
|
51
|
+
this.ctx.logger.info(`生图 API 返回图片 URL,开始下载: ${item.url}`);
|
|
52
|
+
const imageResponse = await fetch(item.url);
|
|
53
|
+
if (!imageResponse.ok) {
|
|
54
|
+
throw new Error(`下载生图结果失败: ${imageResponse.status} ${imageResponse.statusText}`);
|
|
55
|
+
}
|
|
56
|
+
const arrayBuffer = await imageResponse.arrayBuffer();
|
|
57
|
+
return Buffer.from(arrayBuffer);
|
|
58
|
+
}
|
|
59
|
+
throw new Error('生图 API 响应中既无 b64_json 也无 url。');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.DrawingService = DrawingService;
|
package/lib/service/llm.js
CHANGED
|
@@ -155,6 +155,41 @@ class LLMService extends koishi_1.Service {
|
|
|
155
155
|
.replace('{analysisResult}', promptContext.analysisResult || '');
|
|
156
156
|
return this._callText(prompt, '群分析对话回复');
|
|
157
157
|
}
|
|
158
|
+
async analyzeComicStoryboards(topics) {
|
|
159
|
+
const validTopics = (topics || []).filter((t) => t?.topic);
|
|
160
|
+
const topicsText = validTopics
|
|
161
|
+
.map((t, i) => `${i + 1}. 话题: ${t.topic}\n 详情: ${t.detail || ''}`)
|
|
162
|
+
.join('\n');
|
|
163
|
+
const prompt = this.config.promptComicStoryboard
|
|
164
|
+
.replace('{topics}', topicsText || '(无话题)')
|
|
165
|
+
.replace('{topicCount}', String(Math.max(validTopics.length, 1)));
|
|
166
|
+
this.ctx.logger.info(`正在生成漫画分镜提示词(${validTopics.length} 个话题)...`);
|
|
167
|
+
const rawContent = await this._callOpenAI(prompt, '漫画分镜提示词');
|
|
168
|
+
// 优先解析 JSON(兼容 ```json 代码块),失败时用正则提取 scene 字段
|
|
169
|
+
const jsonMatch = rawContent.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
170
|
+
const candidates = [jsonMatch?.[1], rawContent].filter(Boolean);
|
|
171
|
+
for (const candidate of candidates) {
|
|
172
|
+
try {
|
|
173
|
+
const data = JSON.parse(candidate);
|
|
174
|
+
const scene = data?.scene ||
|
|
175
|
+
(Array.isArray(data?.storyboards)
|
|
176
|
+
? data.storyboards.map((s) => s?.scene).filter(Boolean).join('\n\n')
|
|
177
|
+
: null);
|
|
178
|
+
if (typeof scene === 'string' && scene.trim()) {
|
|
179
|
+
return scene.trim();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
// 继续尝试下一个候选
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const sceneMatch = rawContent.match(/"scene"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
187
|
+
if (sceneMatch) {
|
|
188
|
+
return sceneMatch[1].replace(/\\"/g, '"').trim();
|
|
189
|
+
}
|
|
190
|
+
this.ctx.logger.warn('未能从 LLM 响应中解析出漫画 scene 提示词。');
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
158
193
|
}
|
|
159
194
|
exports.LLMService = LLMService;
|
|
160
195
|
//# sourceMappingURL=llm.js.map
|
package/lib/service/message.js
CHANGED
|
@@ -52,12 +52,17 @@ class MessageService extends koishi_1.Service {
|
|
|
52
52
|
}
|
|
53
53
|
shouldListenToMessage(session) {
|
|
54
54
|
// TODO: private message
|
|
55
|
-
|
|
55
|
+
const result = (0, utils_1.shouldListenToMessage)({
|
|
56
56
|
guildId: session.guildId || undefined,
|
|
57
57
|
channelId: session.channelId || undefined,
|
|
58
58
|
platform: session.platform,
|
|
59
59
|
selfId: session.selfId
|
|
60
60
|
}, this.config.listenerGroups, this.config.enableAllGroupsByDefault);
|
|
61
|
+
if (!result) {
|
|
62
|
+
// 帮助定位"消息未被记录/画像消息为 0"的问题
|
|
63
|
+
this.ctx.logger.debug(`消息未计入统计 (platform=${session.platform}, selfId=${session.selfId}, guildId=${session.guildId}, channelId=${session.channelId}),请检查"监听群组"配置(需与机器人实际平台/ID 一致)。`);
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
61
66
|
}
|
|
62
67
|
async handleMessage(session) {
|
|
63
68
|
// 黑名单用户的消息完全不记录、不参与统计
|
package/lib/utils.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.calculateBasicStats = calculateBasicStats;
|
|
|
4
4
|
exports.buildPersonaRecordId = buildPersonaRecordId;
|
|
5
5
|
exports.generateTextReport = generateTextReport;
|
|
6
6
|
exports.getAvatarUrl = getAvatarUrl;
|
|
7
|
+
exports.resolveUserAvatar = resolveUserAvatar;
|
|
7
8
|
exports.formatUserStats = formatUserStats;
|
|
8
9
|
exports.formatGoldenQuotes = formatGoldenQuotes;
|
|
9
10
|
exports.formatUserTitles = formatUserTitles;
|
|
@@ -154,6 +155,27 @@ function generateTextReport(result) {
|
|
|
154
155
|
function getAvatarUrl(userId) {
|
|
155
156
|
return `http://q1.qlogo.cn/g?b=qq&nk=${userId}&s=640`;
|
|
156
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* 按平台解析用户头像 URL。
|
|
160
|
+
* - onebot: 使用 QQ 号头像接口(q1.qlogo.cn)
|
|
161
|
+
* - qq(官方 QQ 机器人): 使用 openid 头像模板 q.qlogo.cn/qqapp/{appid}/{openid}/640
|
|
162
|
+
* - 其他平台: 返回 null,由调用方回退到事件头像或默认头像
|
|
163
|
+
*/
|
|
164
|
+
function resolveUserAvatar(bot, userId) {
|
|
165
|
+
if (!bot || !userId)
|
|
166
|
+
return null;
|
|
167
|
+
if (bot.platform === 'onebot') {
|
|
168
|
+
return getAvatarUrl(userId);
|
|
169
|
+
}
|
|
170
|
+
if (bot.platform === 'qq') {
|
|
171
|
+
const appid = bot.config?.id;
|
|
172
|
+
if (appid) {
|
|
173
|
+
return `https://q.qlogo.cn/qqapp/${appid}/${userId}/640`;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
157
179
|
function formatUserStats(userStats, skin = 'md3') {
|
|
158
180
|
const renderer = skins_1.skinRegistry.getSafe(skin);
|
|
159
181
|
return renderer.formatUserStats(userStats);
|
package/package.json
CHANGED