mioku-service-webui 4.4.4 → 4.5.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 (3) hide show
  1. package/database.ts +42 -0
  2. package/package.json +1 -1
  3. package/routes/ai.ts +274 -19
package/database.ts CHANGED
@@ -5,6 +5,14 @@ import { CHAT_DATA_DIR, readJsonFile, writeJsonFile } from "./utils";
5
5
 
6
6
  const DB_PATH = path.join(CHAT_DATA_DIR, "chat.db");
7
7
  const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
+ const STICKER_FILE_PATTERN = /\.(png|jpg|jpeg|gif|webp)$/i;
9
+
10
+ export interface StickerCatalogItem {
11
+ id: string;
12
+ character: string;
13
+ label: string;
14
+ url: string;
15
+ }
8
16
 
9
17
  function openDb(): any {
10
18
  if (!fs.existsSync(DB_PATH)) {
@@ -299,6 +307,40 @@ export function listMemeTree(): Record<string, Record<string, string[]>> {
299
307
  return tree;
300
308
  }
301
309
 
310
+ export function listStickerCatalog(): StickerCatalogItem[] {
311
+ const root = path.join(CHAT_DATA_DIR, "meme");
312
+ if (!fs.existsSync(root)) return [];
313
+
314
+ const items: StickerCatalogItem[] = [];
315
+ const visit = (dir: string, character: string) => {
316
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
317
+ const entryPath = path.join(dir, entry.name);
318
+ if (entry.isDirectory()) {
319
+ visit(entryPath, character);
320
+ continue;
321
+ }
322
+ if (!entry.isFile() || !STICKER_FILE_PATTERN.test(entry.name)) continue;
323
+
324
+ const id = path.relative(root, entryPath).split(path.sep).join("/");
325
+ const label = path
326
+ .basename(entry.name, path.extname(entry.name))
327
+ .replace(/[_-]+/g, " ")
328
+ .trim();
329
+ const url = `/meme/${id
330
+ .split("/")
331
+ .map((segment) => encodeURIComponent(segment))
332
+ .join("/")}`;
333
+ items.push({ id, character, label, url });
334
+ }
335
+ };
336
+
337
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
338
+ if (entry.isDirectory()) visit(path.join(root, entry.name), entry.name);
339
+ }
340
+
341
+ return items.sort((a, b) => a.id.localeCompare(b.id));
342
+ }
343
+
302
344
  export function savePluginConfig(pluginName: string, fileName: string, value: any): void {
303
345
  const filePath = path.join(process.cwd(), "config", pluginName, `${fileName}.json`);
304
346
  writeJsonFile(filePath, value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-service-webui",
3
- "version": "4.4.4",
3
+ "version": "4.5.0",
4
4
  "description": "Mioku WebUI 服务",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/routes/ai.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { Hono } from "hono";
2
2
  import { logger } from "mioki";
3
3
  import { getChatConfig, updateChatConfig } from "../system";
4
+ import { listStickerCatalog } from "../database";
4
5
  import aiService from "mioku-service-ai";
5
- import type { AIUsageRange } from "mioku-service-ai/usage/types";
6
+ import type { AIModelRole, AIProtocol, AIUsageRange } from "mioku";
6
7
 
7
8
  const DEFAULT_MEMORY_GROUP_HISTORY_LIMIT = 300;
8
9
  const DEFAULT_MEMORY_USER_HISTORY_LIMIT = 100;
@@ -11,6 +12,17 @@ const DEFAULT_TOPIC_HISTORY_WINDOW_COUNT = 3;
11
12
  const DEFAULT_EXPRESSION_LEARN_AFTER_MESSAGES = 100;
12
13
  const DEFAULT_EXPRESSION_SAMPLE_SIZE = 8;
13
14
  const usageRanges = new Set<AIUsageRange>(["today", "7d", "30d"]);
15
+ const protocols = new Set<AIProtocol>([
16
+ "openai-chat",
17
+ "openai-response",
18
+ "anthropic",
19
+ "gemini",
20
+ ]);
21
+ const roles = new Set<AIModelRole>(["main", "working", "vision"]);
22
+
23
+ function getAIApi() {
24
+ return aiService?.api as any;
25
+ }
14
26
 
15
27
  function normalizePersonalizationConfig(input: any): any {
16
28
  const data =
@@ -96,17 +108,73 @@ function normalizePersonalizationConfig(input: any): any {
96
108
  delete rawExpression.maxExpressions;
97
109
 
98
110
  data.expression = rawExpression;
111
+
112
+ const rawEmoji =
113
+ data.emoji && typeof data.emoji === "object" && !Array.isArray(data.emoji)
114
+ ? { ...data.emoji }
115
+ : {};
116
+ rawEmoji.enabled =
117
+ typeof rawEmoji.enabled === "boolean" ? rawEmoji.enabled : true;
118
+ rawEmoji.characters = Array.isArray(rawEmoji.characters)
119
+ ? rawEmoji.characters
120
+ .map(String)
121
+ .map((value: string) => value.trim())
122
+ .filter(Boolean)
123
+ : [];
124
+ rawEmoji.stickers = Array.isArray(rawEmoji.stickers)
125
+ ? rawEmoji.stickers
126
+ .map(String)
127
+ .map((value: string) => value.trim())
128
+ .filter(Boolean)
129
+ : [];
130
+ delete rawEmoji.useAISelection;
131
+ data.emoji = rawEmoji;
132
+
99
133
  return data;
100
134
  }
101
135
 
136
+ function sanitizeBaseForSave(body: any): any {
137
+ if (!body || typeof body !== "object") return body;
138
+ const next = { ...body };
139
+ delete next.apiUrl;
140
+ delete next.apiKey;
141
+ delete next.model;
142
+ delete next.workingModel;
143
+ delete next.multimodalWorkingModel;
144
+ delete next.isMultimodal;
145
+ return next;
146
+ }
147
+
102
148
  export function createAIRoutes() {
103
149
  const app = new Hono();
104
150
 
105
- app.get("/base", (c) =>
106
- c.json({ ok: true, data: getChatConfig("base.json") }),
107
- );
151
+ app.get("/base", (c) => {
152
+ const data = getChatConfig("base.json") || {};
153
+ const api = getAIApi();
154
+ const rolesData = api?.getRoleBindings?.() ?? {};
155
+ const models = api?.listModels?.() ?? [];
156
+ const pickModelId = (full?: string) => {
157
+ if (!full) return "";
158
+ const idx = full.indexOf("/");
159
+ return idx > 0 ? full.slice(idx + 1) : full;
160
+ };
161
+ const vision = models.find((item: any) => item.id === rolesData.vision);
162
+ return c.json({
163
+ ok: true,
164
+ data: {
165
+ ...data,
166
+ model: pickModelId(rolesData.main) || data.model || "",
167
+ workingModel: pickModelId(rolesData.working) || data.workingModel || "",
168
+ multimodalWorkingModel:
169
+ pickModelId(rolesData.vision) || data.multimodalWorkingModel || "",
170
+ isMultimodal: vision?.capabilities?.includes?.("vision") ?? true,
171
+ providersManaged: true,
172
+ },
173
+ });
174
+ });
175
+
108
176
  app.put("/base", async (c) => {
109
- const body = await c.req.json();
177
+ const body = sanitizeBaseForSave(await c.req.json());
110
178
  logger.info(`[webui-action] ai.base.update`);
111
179
  return c.json({ ok: true, data: updateChatConfig("base.json", body) });
112
180
  });
@@ -138,48 +206,230 @@ export function createAIRoutes() {
138
206
  });
139
207
  });
140
208
 
209
+ app.get("/providers", (c) => {
210
+ const api = getAIApi();
211
+ if (!api?.listProviders) {
212
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
213
+ }
214
+ const providers = api.listProviders().map((item: any) => ({
215
+ ...item,
216
+ apiKey: item.apiKey ? "***" : "",
217
+ hasApiKey: Boolean(item.apiKey),
218
+ }));
219
+ return c.json({ ok: true, data: providers });
220
+ });
221
+
222
+ app.post("/providers", async (c) => {
223
+ const api = getAIApi();
224
+ if (!api?.createProvider) {
225
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
226
+ }
227
+ const body = await c.req.json();
228
+ const protocol = String(body?.protocol || "openai-chat") as AIProtocol;
229
+ if (!protocols.has(protocol)) {
230
+ return c.json({ ok: false, error: "INVALID_PROTOCOL" }, 400);
231
+ }
232
+ logger.info(`[webui-action] ai.provider.create`, {
233
+ name: body?.name,
234
+ protocol,
235
+ });
236
+ const created = await api.createProvider({
237
+ id: body?.id,
238
+ name: body?.name,
239
+ protocol,
240
+ apiUrl: body?.apiUrl,
241
+ apiKey: body?.apiKey,
242
+ enabled: body?.enabled !== false,
243
+ headers: body?.headers,
244
+ });
245
+ return c.json({
246
+ ok: true,
247
+ data: {
248
+ ...created,
249
+ apiKey: created.apiKey ? "***" : "",
250
+ hasApiKey: Boolean(created.apiKey),
251
+ },
252
+ });
253
+ });
254
+
255
+ app.put("/providers/:id", async (c) => {
256
+ const api = getAIApi();
257
+ if (!api?.updateProvider) {
258
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
259
+ }
260
+ const id = c.req.param("id");
261
+ const body = await c.req.json();
262
+ logger.info(`[webui-action] ai.provider.update`, { id });
263
+ const patch: Record<string, unknown> = { ...body };
264
+ if (patch.apiKey === "***" || patch.apiKey === "") delete patch.apiKey;
265
+ if (patch.protocol && !protocols.has(patch.protocol as AIProtocol)) {
266
+ return c.json({ ok: false, error: "INVALID_PROTOCOL" }, 400);
267
+ }
268
+ const updated = await api.updateProvider(id, patch);
269
+ return c.json({
270
+ ok: true,
271
+ data: {
272
+ ...updated,
273
+ apiKey: updated.apiKey ? "***" : "",
274
+ hasApiKey: Boolean(updated.apiKey),
275
+ },
276
+ });
277
+ });
278
+
279
+ app.delete("/providers/:id", (c) => {
280
+ const api = getAIApi();
281
+ if (!api?.removeProvider) {
282
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
283
+ }
284
+ const id = c.req.param("id");
285
+ logger.info(`[webui-action] ai.provider.remove`, { id });
286
+ return c.json({ ok: Boolean(api.removeProvider(id)) });
287
+ });
288
+
289
+ app.post("/providers/:id/test", async (c) => {
290
+ const api = getAIApi();
291
+ if (!api?.testProvider) {
292
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
293
+ }
294
+ const id = c.req.param("id");
295
+ logger.info(`[webui-action] ai.provider.test`, { id });
296
+ const result = await api.testProvider(id);
297
+ return c.json({ ok: true, data: result });
298
+ });
299
+
300
+ app.post("/providers/:id/models/refresh", async (c) => {
301
+ const api = getAIApi();
302
+ if (!api?.refreshModels) {
303
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
304
+ }
305
+ const id = c.req.param("id");
306
+ logger.info(`[webui-action] ai.models.refresh`, { id });
307
+ const models = await api.refreshModels(id);
308
+ return c.json({ ok: true, data: models });
309
+ });
310
+
311
+ app.get("/models", (c) => {
312
+ const api = getAIApi();
313
+ if (!api?.listModels) {
314
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
315
+ }
316
+ const providerId = c.req.query("providerId") || undefined;
317
+ return c.json({ ok: true, data: api.listModels(providerId) });
318
+ });
319
+
320
+ app.post("/models", async (c) => {
321
+ const api = getAIApi();
322
+ if (!api?.registerCustomModel) {
323
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
324
+ }
325
+ const body = await c.req.json();
326
+ logger.info(`[webui-action] ai.model.create`, {
327
+ providerId: body?.providerId,
328
+ modelId: body?.modelId,
329
+ });
330
+ const model = api.registerCustomModel({
331
+ providerId: body?.providerId,
332
+ modelId: body?.modelId,
333
+ name: body?.name,
334
+ capabilities: body?.capabilities,
335
+ });
336
+ return c.json({ ok: true, data: model });
337
+ });
338
+
339
+ app.delete("/models/:id", (c) => {
340
+ const api = getAIApi();
341
+ if (!api?.removeCustomModel) {
342
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
343
+ }
344
+ const id = decodeURIComponent(c.req.param("id"));
345
+ logger.info(`[webui-action] ai.model.remove`, { id });
346
+ return c.json({ ok: Boolean(api.removeCustomModel(id)) });
347
+ });
348
+
349
+ app.get("/roles", (c) => {
350
+ const api = getAIApi();
351
+ if (!api?.getRoleBindings) {
352
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
353
+ }
354
+ return c.json({ ok: true, data: api.getRoleBindings() });
355
+ });
356
+
357
+ app.put("/roles", async (c) => {
358
+ const api = getAIApi();
359
+ if (!api?.setRoleBinding) {
360
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
361
+ }
362
+ const body = await c.req.json();
363
+ logger.info(`[webui-action] ai.roles.update`, body);
364
+ for (const role of ["main", "working", "vision"] as AIModelRole[]) {
365
+ if (!(role in (body || {}))) continue;
366
+ const value = body[role];
367
+ if (value === null || value === undefined || value === "") {
368
+ api.setRoleBinding(role, undefined);
369
+ } else {
370
+ api.setRoleBinding(role, String(value));
371
+ }
372
+ }
373
+ return c.json({ ok: true, data: api.getRoleBindings() });
374
+ });
375
+
141
376
  app.get("/instances", (c) => {
142
- const names = aiService?.api?.list?.() ?? [];
143
- return c.json({ ok: true, data: names });
377
+ const api = getAIApi();
378
+ if (api?.listInstances) {
379
+ return c.json({ ok: true, data: api.listInstances() });
380
+ }
381
+ const names = api?.list?.() ?? [];
382
+ return c.json({ ok: true, data: names.map((name: string) => ({ name })) });
144
383
  });
145
384
 
146
385
  app.post("/instances", async (c) => {
147
386
  const body = await c.req.json();
148
387
  logger.info(`[webui-action] ai.instance.create`, {
149
388
  name: body?.name,
150
- apiUrl: body?.apiUrl,
151
- modelType: body?.modelType,
389
+ providerId: body?.providerId,
390
+ modelId: body?.modelId,
152
391
  });
153
- if (!aiService?.api?.create) {
392
+ const api = getAIApi();
393
+ if (api?.createInstance && body?.providerId && body?.modelId) {
394
+ await api.createInstance({
395
+ name: body.name,
396
+ providerId: body.providerId,
397
+ modelId: body.modelId,
398
+ role: roles.has(body.role) ? body.role : undefined,
399
+ });
400
+ return c.json({ ok: true, data: api.listInstances?.() ?? api.list?.() });
401
+ }
402
+ if (!api?.create) {
154
403
  return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
155
404
  }
156
-
157
- await aiService.api.create({
405
+ await api.create({
158
406
  name: body.name,
159
407
  apiUrl: body.apiUrl,
160
408
  apiKey: body.apiKey,
161
409
  modelType: body.modelType || "text",
410
+ model: body.model,
162
411
  });
163
- return c.json({ ok: true, data: aiService.api.list() });
412
+ return c.json({ ok: true, data: api.list() });
164
413
  });
165
414
 
166
415
  app.delete("/instances/:name", (c) => {
167
416
  const name = c.req.param("name");
168
417
  logger.info(`[webui-action] ai.instance.remove`, { name });
169
- const ok = aiService?.api?.remove?.(name);
418
+ const ok = getAIApi()?.remove?.(name);
170
419
  return c.json({ ok: Boolean(ok) });
171
420
  });
172
421
 
173
422
  app.post("/default/:name", (c) => {
174
423
  const name = c.req.param("name");
175
424
  logger.info(`[webui-action] ai.instance.set-default`, { name });
176
- const ok = aiService?.api?.setDefault?.(name);
425
+ const ok = getAIApi()?.setDefault?.(name);
177
426
  return c.json({ ok: Boolean(ok) });
178
427
  });
179
428
 
180
429
  app.get("/skills", (c) => {
181
- const skills = aiService?.api?.getAllSkills?.();
182
- const tools = aiService?.api?.getAllTools?.();
430
+ const api = getAIApi();
431
+ const skills = api?.getAllSkills?.();
432
+ const tools = api?.getAllTools?.();
183
433
  return c.json({
184
434
  ok: true,
185
435
  data: {
@@ -189,8 +439,13 @@ export function createAIRoutes() {
189
439
  });
190
440
  });
191
441
 
442
+ app.get("/stickers", (c) =>
443
+ c.json({ ok: true, data: listStickerCatalog() }),
444
+ );
445
+
192
446
  app.get("/usage", (c) => {
193
- if (!aiService?.api?.getUsageSummary) {
447
+ const api = getAIApi();
448
+ if (!api?.getUsageSummary) {
194
449
  return c.json({ ok: false, error: "AI_USAGE_UNAVAILABLE" }, 503);
195
450
  }
196
451
 
@@ -209,7 +464,7 @@ export function createAIRoutes() {
209
464
 
210
465
  return c.json({
211
466
  ok: true,
212
- data: aiService.api.getUsageSummary({ range, botId }),
467
+ data: api.getUsageSummary({ range, botId }),
213
468
  });
214
469
  });
215
470