mioku-plugin-chat 2.7.0 → 2.8.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/configs/base.ts CHANGED
@@ -1,13 +1,7 @@
1
1
  export const BASE_CONFIG = {
2
- apiUrl: "https://api.openai.com/v1",
3
- apiKey: "",
4
- model: "gemini-3.0-flash-preview",
5
- workingModel: "deepseek/deepseek-v3.2-exp", // 工作模型,用于 planner 等轻量任务
6
- multimodalWorkingModel: "doubao-seed-2.0-mini", // 多模态工作模型,用于图片描述等任务
7
- isMultimodal: true,
8
- enableMediaRecognition: true, // 是否用多模态工作模型自动识别聊天记录中的图片/视频等媒体
9
2
  maxContextTokens: 128,
10
3
  temperature: 0.8,
11
- historyCount: 100, // 群聊历史消息数量
12
- maxIterations: 20, // AI 迭代次数限制,-1 表示不限制
4
+ historyCount: 100,
5
+ maxIterations: 20,
6
+ enableMediaRecognition: true,
13
7
  };
package/context.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { MiokiContext } from "mioki";
2
- import type { AIInstance, AIService } from "mioku";
2
+ import type { AIInstance, AIModelRole, AIService } from "mioku";
3
3
  import type { ChatConfig } from "./types";
4
4
  import type { ChatDatabase } from "./db";
5
5
  import type { HumanizeEngine, ChatConfigProvider } from "./humanize";
@@ -33,12 +33,7 @@ import type {
33
33
  } from "mioku";
34
34
  import type { ChatMessage } from "./types";
35
35
 
36
- /**
37
- * Core plugin context - bundles all managers and core services.
38
- * This is created once during plugin setup and used throughout the plugin lifetime.
39
- */
40
36
  export interface ChatPluginContext {
41
- // Core context
42
37
  ctx: MiokiContext;
43
38
  defaultConfig: ChatConfig;
44
39
  configProvider: ChatConfigProvider;
@@ -47,10 +42,10 @@ export interface ChatPluginContext {
47
42
  aiInstance: AIInstance;
48
43
  workAIInstance: AIInstance;
49
44
  visionAIInstance: AIInstance;
45
+ getAIInstance: (role?: AIModelRole) => AIInstance | undefined;
50
46
  aiService: AIService;
51
47
  humanize: HumanizeEngine;
52
48
 
53
- // Managers
54
49
  sessionManager: SessionManager;
55
50
  skillManager: SkillSessionManager;
56
51
  rateLimiter: RateLimiter;
@@ -61,10 +56,8 @@ export interface ChatPluginContext {
61
56
  queueProcessor: QueueProcessor;
62
57
  sessionTurnScheduler: SessionTurnScheduler;
63
58
 
64
- // Utility
65
59
  runWithRateLimitGuard: RunRateLimitGuard;
66
60
 
67
- // Services (utility functions)
68
61
  buildHistoryMediaOptions: (
69
62
  ai: AIInstance,
70
63
  config: ChatConfig,
@@ -90,17 +83,11 @@ export interface ChatPluginContext {
90
83
  ) => Promise<void>;
91
84
  }
92
85
 
93
- /**
94
- * Runtime state for rate limiting - passed separately as it's mutable state.
95
- */
96
86
  export interface ChatRuntimeState {
97
87
  isRateLimitBlocked: () => boolean;
98
88
  processingSet: Set<string>;
99
89
  }
100
90
 
101
- /**
102
- * Closure state shared with the extracted message/poke handlers.
103
- */
104
91
  export interface ChatHandlerState {
105
92
  getConfig: (groupId?: number) => Promise<ChatConfig>;
106
93
  matchMessageCommands?: (
@@ -110,10 +97,6 @@ export interface ChatHandlerState {
110
97
  pokeCooldowns: Map<number, number>;
111
98
  }
112
99
 
113
- /**
114
- * Chat runtime implementation for external callers (like boot plugin).
115
- * Provides notice generation and information request capabilities.
116
- */
117
100
  export interface ChatRuntime {
118
101
  generateNotice(
119
102
  options: ChatRuntimeNoticeOptions,
@@ -95,8 +95,17 @@ export function createInfoTools(toolCtx: ToolContext): AITool[] {
95
95
  tools.push({
96
96
  name: "get_group_member_list",
97
97
  description: "Get the list of group members (returns name and role only)",
98
- parameters: { type: "object", properties: {} },
99
- handler: async () => {
98
+ parameters: {
99
+ type: "object",
100
+ properties: {
101
+ limit: {
102
+ type: "number",
103
+ description: "Optional max members to return (server still caps at 50)",
104
+ },
105
+ },
106
+ required: [],
107
+ },
108
+ handler: async (args) => {
100
109
  try {
101
110
  const list = await toolCtx.ctx
102
111
  .pickBot(toolCtx.event.self_id)
@@ -106,7 +115,12 @@ export function createInfoTools(toolCtx: ToolContext): AITool[] {
106
115
  nickname: m.card || m.nickname,
107
116
  role: m.role,
108
117
  }));
109
- return { members: members.slice(0, 50), total: members.length };
118
+ const limitRaw = Math.floor(Number(args?.limit));
119
+ const limit =
120
+ Number.isFinite(limitRaw) && limitRaw > 0
121
+ ? Math.min(limitRaw, 50)
122
+ : 50;
123
+ return { members: members.slice(0, limit), total: members.length };
110
124
  } catch (err) {
111
125
  return { error: `Failed to get member list: ${err}` };
112
126
  }
@@ -36,7 +36,7 @@ export function createMessageHandler(
36
36
  const isGroup = e.message_type === "group";
37
37
  const groupId: number | undefined = isGroup ? e.group_id : undefined;
38
38
  const cfg = await getConfig(groupId);
39
- if (!cfg.apiKey) return;
39
+ if (!cfg.model && !cfg.apiKey) return;
40
40
  if (!e?.message || !Array.isArray(e.message)) return;
41
41
 
42
42
  const text = ctx.text(e) || "";
package/handlers/poke.ts CHANGED
@@ -16,7 +16,7 @@ export function createPokeHandler(
16
16
  if (e.target_id !== e.self_id) return;
17
17
  const groupId = e.group_id;
18
18
  const cfg = groupId ? await getConfig(groupId) : await getConfig();
19
- if (!cfg.apiKey) return;
19
+ if (!cfg.model && !cfg.apiKey) return;
20
20
  if (!groupId || !isGroupAllowed(groupId, cfg)) return;
21
21
 
22
22
  const lastPoke = pokeCooldowns.get(groupId) ?? 0;
package/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { definePlugin } from "mioki";
2
2
  import type { MiokiContext } from "mioki";
3
- import type { AIInstance, AIService, ConfigService, ScreenshotService } from "mioku";
3
+ import type { AIInstance, AIModelRole, AIService, ConfigService, ScreenshotService } from "mioku";
4
4
  import { getPluginRuntimeState } from "mioku";
5
5
  import type { ChatConfig, ChatMessage, ChatGroupsFile, TargetMessage } from "./types";
6
6
  import { initDatabase } from "./db";
@@ -48,9 +48,69 @@ import {
48
48
  normalizeMediaAnalysisBlacklist,
49
49
  } from "./utils/config";
50
50
 
51
+ function resolveRoleInstances(aiService: AIService): {
52
+ main: AIInstance;
53
+ work: AIInstance;
54
+ vision: AIInstance;
55
+ models: { main: string; working: string; vision: string };
56
+ isMultimodal: boolean;
57
+ } | null {
58
+ const getByRole = (role: AIModelRole) =>
59
+ aiService.getInstanceByRole?.(role) ??
60
+ aiService.get?.(role) ??
61
+ undefined;
62
+
63
+ const main =
64
+ getByRole("main") ??
65
+ aiService.getDefault?.() ??
66
+ aiService.get?.("main");
67
+ if (!main) return null;
68
+
69
+ const work = getByRole("working") ?? aiService.get?.("work") ?? main;
70
+ const vision = getByRole("vision") ?? aiService.get?.("vision") ?? work;
71
+
72
+ const bindings = aiService.getRoleBindings?.() ?? {
73
+ main: undefined,
74
+ working: undefined,
75
+ vision: undefined,
76
+ };
77
+ const instances = aiService.listInstances?.() ?? [];
78
+ const findModel = (role: AIModelRole, fallbackInstance: AIInstance) => {
79
+ const full = bindings[role];
80
+ if (full && full.includes("/")) return full.split("/").slice(1).join("/");
81
+ const info = instances.find((item) => item.role === role || item.name === role);
82
+ if (info?.modelId) return info.modelId;
83
+ const anyInfo = instances.find((item) => item.name === (fallbackInstance as any).name);
84
+ return anyInfo?.modelId || "";
85
+ };
86
+
87
+ const mainModel = findModel("main", main);
88
+ const workingModel = findModel("working", work) || mainModel;
89
+ const visionModel = findModel("vision", vision) || workingModel;
90
+
91
+ const models = aiService.listModels?.() ?? [];
92
+ const visionDesc =
93
+ models.find((item) => item.id === bindings.vision) ||
94
+ models.find((item) => item.modelId === visionModel);
95
+ const isMultimodal =
96
+ visionDesc?.capabilities?.includes("vision") ?? Boolean(visionModel);
97
+
98
+ return {
99
+ main,
100
+ work,
101
+ vision,
102
+ models: {
103
+ main: mainModel,
104
+ working: workingModel,
105
+ vision: visionModel,
106
+ },
107
+ isMultimodal,
108
+ };
109
+ }
110
+
51
111
  export default definePlugin({
52
112
  name: "chat",
53
- version: "1.0.0",
113
+ version: "1.1.0",
54
114
  description: "AI 智能聊天插件",
55
115
  async setup(ctx: MiokiContext) {
56
116
  ctx.logger.info("聊天插件正在初始化...");
@@ -69,6 +129,12 @@ export default definePlugin({
69
129
 
70
130
  let cachedBaseConfig: ChatConfig | null = null;
71
131
  let cachedGroupsConfig: ChatGroupsFile | null = null;
132
+ let roleModels = {
133
+ main: "",
134
+ working: "",
135
+ vision: "",
136
+ };
137
+ let roleIsMultimodal = true;
72
138
 
73
139
  const buildGlobalConfig = async (): Promise<ChatConfig> => {
74
140
  if (!configService) {
@@ -115,6 +181,15 @@ export default definePlugin({
115
181
  merged.mediaAnalysisBlacklistUsers =
116
182
  normalizeMediaAnalysisBlacklist(merged as Record<string, any>);
117
183
  delete (merged as any).imageAnalysisBlacklistUsers;
184
+
185
+ merged.model = roleModels.main || merged.model || "";
186
+ merged.workingModel = roleModels.working || merged.workingModel || merged.model;
187
+ merged.multimodalWorkingModel =
188
+ roleModels.vision || merged.multimodalWorkingModel || merged.workingModel;
189
+ merged.isMultimodal = roleIsMultimodal;
190
+ if (!roleIsMultimodal) merged.enableMediaRecognition = false;
191
+ if (!merged.apiKey) merged.apiKey = "__ai_service__";
192
+
118
193
  return merged;
119
194
  };
120
195
 
@@ -173,63 +248,85 @@ export default definePlugin({
173
248
  return configProvider(groupId);
174
249
  };
175
250
 
176
- const defaultConfig = configProvider();
177
- const config = defaultConfig;
178
-
179
- if (!config.apiKey) {
180
- ctx.logger.warn("聊天插件未配置 API Key,请在 config/chat/base.json 中配置");
251
+ if (!aiService) {
252
+ ctx.logger.error("聊天插件需要 AI 服务,但 AI 服务不可用");
181
253
  return;
182
254
  }
183
255
 
184
- const db = await initDatabase();
185
- const sessionManager = new SessionManager(db, config.maxSessions);
186
- const queueManager = new MessageQueueManager();
187
- const rateLimiter = new RateLimiter({
188
- maxTriggersPerWindow: 5,
189
- windowMs: 60_000,
190
- dedupWindowMs: 30_000,
191
- groupCooldownMs: 1_000,
192
- });
193
- rateLimiter.setConfigProvider(configProvider);
194
- const skillManager = new SkillSessionManager();
256
+ let resolved = resolveRoleInstances(aiService);
195
257
 
196
- rateLimiter.setQueueLengthGetter((groupId: number) =>
197
- queueManager.getQueueLength(`group:${groupId}`),
198
- );
258
+ if (!resolved) {
259
+ const earlyConfig = configProvider();
260
+ const legacyApiUrl = String((earlyConfig as any).apiUrl || "").trim();
261
+ const legacyApiKey = String((earlyConfig as any).apiKey || "").trim();
262
+ const legacyModel = String((earlyConfig as any).model || "").trim();
263
+ if (legacyApiUrl && legacyApiKey && legacyApiKey !== "__ai_service__") {
264
+ ctx.logger.warn(
265
+ "未检测到 AI 角色绑定,回退到 chat/base 遗留 apiUrl/apiKey 创建实例",
266
+ );
267
+ await aiService.create({
268
+ name: "main",
269
+ apiUrl: legacyApiUrl,
270
+ apiKey: legacyApiKey,
271
+ modelType: "multimodal",
272
+ model: legacyModel || "default",
273
+ });
274
+ await aiService.create({
275
+ name: "work",
276
+ apiUrl: legacyApiUrl,
277
+ apiKey: legacyApiKey,
278
+ modelType: "text",
279
+ model: String((earlyConfig as any).workingModel || legacyModel || "default"),
280
+ });
281
+ await aiService.create({
282
+ name: "vision",
283
+ apiUrl: legacyApiUrl,
284
+ apiKey: legacyApiKey,
285
+ modelType: "multimodal",
286
+ model: String(
287
+ (earlyConfig as any).multimodalWorkingModel || legacyModel || "default",
288
+ ),
289
+ });
290
+ aiService.setDefault("main");
291
+ aiService.setRoleBinding?.(
292
+ "main",
293
+ `runtime-main/${legacyModel || "default"}`,
294
+ );
295
+ aiService.setRoleBinding?.(
296
+ "working",
297
+ `runtime-work/${String((earlyConfig as any).workingModel || legacyModel || "default")}`,
298
+ );
299
+ aiService.setRoleBinding?.(
300
+ "vision",
301
+ `runtime-vision/${String((earlyConfig as any).multimodalWorkingModel || legacyModel || "default")}`,
302
+ );
303
+ resolved = resolveRoleInstances(aiService);
304
+ }
305
+ }
199
306
 
200
- if (!aiService) {
201
- ctx.logger.error("聊天插件需要 AI 服务,但 AI 服务不可用");
307
+ if (!resolved) {
308
+ ctx.logger.error(
309
+ "未配置 AI 提供商/主模型,请在 WebUI AI 设置中配置提供商并绑定主模型",
310
+ );
202
311
  return;
203
312
  }
204
313
 
205
- const mainAIInstance = await aiService.create({
206
- name: "main",
207
- apiUrl: config.apiUrl,
208
- apiKey: config.apiKey,
209
- modelType: config.isMultimodal ? "multimodal" : "text",
210
- model: config.model,
211
- });
212
- const workAIInstance = await aiService.create({
213
- name: "work",
214
- apiUrl: config.apiUrl,
215
- apiKey: config.apiKey,
216
- modelType: "text",
217
- model: config.workingModel,
218
- });
219
- const visionAIInstance = await aiService.create({
220
- name: "vision",
221
- apiUrl: config.apiUrl,
222
- apiKey: config.apiKey,
223
- modelType: "multimodal",
224
- model: config.multimodalWorkingModel,
225
- });
226
- aiService.setDefault("main");
314
+ roleModels = resolved.models;
315
+ roleIsMultimodal = resolved.isMultimodal;
316
+ await refreshBaseCache();
317
+
318
+ const mainAIInstance = resolved.main;
319
+ const workAIInstance = resolved.work;
320
+ const visionAIInstance = resolved.vision;
321
+ if (!aiService.getDefault()) {
322
+ aiService.setDefault("main");
323
+ }
227
324
 
228
325
  const registerPersona = (persona: string) => {
229
326
  mainAIInstance.registerPrompt("persona", persona);
230
327
  workAIInstance.registerPrompt("persona", persona);
231
328
  };
232
- registerPersona(String(config.persona ?? ""));
329
+ registerPersona(String(configProvider().persona ?? ""));
233
330
  if (configService) {
234
331
  configService.onConfigChange("chat", "personalization", async () => {
235
332
  try {
@@ -241,6 +338,22 @@ export default definePlugin({
241
338
  });
242
339
  }
243
340
 
341
+ const db = await initDatabase();
342
+ const sessionManager = new SessionManager(db, configProvider().maxSessions);
343
+ const queueManager = new MessageQueueManager();
344
+ const rateLimiter = new RateLimiter({
345
+ maxTriggersPerWindow: 5,
346
+ windowMs: 60_000,
347
+ dedupWindowMs: 30_000,
348
+ groupCooldownMs: 1_000,
349
+ });
350
+ rateLimiter.setConfigProvider(configProvider);
351
+ const skillManager = new SkillSessionManager();
352
+
353
+ rateLimiter.setQueueLengthGetter((groupId: number) =>
354
+ queueManager.getQueueLength(`group:${groupId}`),
355
+ );
356
+
244
357
  const humanize = new HumanizeEngine(workAIInstance, db, configProvider);
245
358
  await humanize.init();
246
359
 
@@ -251,10 +364,21 @@ export default definePlugin({
251
364
  const rateLimitGuard = new RateLimitGuard(rateLimiter, ctx.logger);
252
365
  const runWithRateLimitGuard = rateLimitGuard.run.bind(rateLimitGuard);
253
366
 
367
+ const getAIInstance = (role: AIModelRole = "main"): AIInstance | undefined => {
368
+ if (role === "working") {
369
+ return aiService.getInstanceByRole?.("working") ?? workAIInstance;
370
+ }
371
+ if (role === "vision") {
372
+ return aiService.getInstanceByRole?.("vision") ?? visionAIInstance;
373
+ }
374
+ return aiService.getInstanceByRole?.("main") ?? mainAIInstance;
375
+ };
376
+
254
377
  const pluginCtx = {
255
- ctx, defaultConfig, configProvider, getConfig,
378
+ ctx, defaultConfig: configProvider(), configProvider, getConfig,
256
379
  db,
257
380
  aiInstance: mainAIInstance, workAIInstance, visionAIInstance,
381
+ getAIInstance,
258
382
  aiService, humanize,
259
383
  sessionManager, skillManager, rateLimiter, queueManager,
260
384
  groupStructuredHistory,
@@ -301,7 +425,7 @@ export default definePlugin({
301
425
  pluginCtx.queueProcessor = queueProcessor;
302
426
 
303
427
  const cleanupInterval = setInterval(() => skillManager.cleanup(), 10 * 60_000);
304
- const dbCleanup = new ChatDatabaseCleanup(db, config.retention ?? DEFAULT_CLEANUP_CONFIG);
428
+ const dbCleanup = new ChatDatabaseCleanup(db, configProvider().retention ?? DEFAULT_CLEANUP_CONFIG);
305
429
  dbCleanup.start();
306
430
  idleCheckManager.start();
307
431
 
@@ -325,7 +449,9 @@ export default definePlugin({
325
449
  ctx.handle("message", createMessageHandler(pluginCtx, handlerState));
326
450
  ctx.handle("notice.group.poke" as any, createPokeHandler(pluginCtx, handlerState));
327
451
 
328
- ctx.logger.info("聊天插件加载成功");
452
+ ctx.logger.info(
453
+ `聊天插件加载成功 (main=${roleModels.main || "?"}, work=${roleModels.working || "?"}, vision=${roleModels.vision || "?"})`,
454
+ );
329
455
 
330
456
  return () => {
331
457
  idleCheckManager.dispose();
@@ -102,7 +102,7 @@ export class IdleCheckManager {
102
102
  private async tick(): Promise<void> {
103
103
  try {
104
104
  const baseCfg = this.defaultConfig;
105
- if (!baseCfg.apiKey) return;
105
+ if (!baseCfg.model && !baseCfg.apiKey) return;
106
106
 
107
107
  const now = Date.now();
108
108
  const checkInterval = 60_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-chat",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "AI 智能聊天插件",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/types.ts CHANGED
@@ -171,8 +171,10 @@ export interface AIRequestLimitConfig {
171
171
  * 聊天插件配置
172
172
  */
173
173
  export interface ChatConfig {
174
- apiUrl: string;
175
- apiKey: string;
174
+ /** @deprecated AI 连接已迁移到 ai-service providers */
175
+ apiUrl?: string;
176
+ /** @deprecated AI 连接已迁移到 ai-service providers;非空仅作兼容哨兵 */
177
+ apiKey?: string;
176
178
  model: string;
177
179
  workingModel: string;
178
180
  multimodalWorkingModel: string;
@@ -234,6 +236,7 @@ export interface ChatGroupOverrides {
234
236
  dynamicDelay?: { enabled?: boolean };
235
237
  enableMarkdownScreenshot?: boolean;
236
238
  enableMediaRecognition?: boolean;
239
+ allowedExternalSkills?: string[];
237
240
  }
238
241
 
239
242
  export type ChatGroupsFile = {
@@ -18,6 +18,8 @@ const OVERRIDABLE_BOOLEAN_FIELDS = [
18
18
  "enableMediaRecognition",
19
19
  ] as const;
20
20
 
21
+ const OVERRIDABLE_ARRAY_FIELDS = ["allowedExternalSkills"] as const;
22
+
21
23
  /**
22
24
  * 把每个群的覆盖配置浅合并到全局配置上
23
25
  * - 子对象(如 emoji)只覆盖其中存在的字段,未填字段继承全局
@@ -55,6 +57,13 @@ export function mergeGroupOverrides(
55
57
  }
56
58
  }
57
59
 
60
+ for (const key of OVERRIDABLE_ARRAY_FIELDS) {
61
+ const value = overridesMap[key];
62
+ if (Array.isArray(value)) {
63
+ resultMap[key] = value.filter((v) => typeof v === "string");
64
+ }
65
+ }
66
+
58
67
  return result;
59
68
  }
60
69