mioku-service-webui 4.14.0 → 4.15.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.
@@ -51,7 +51,7 @@ export function loadAdapterConfigPage(adapterName: string): ConfigPageManifest |
51
51
  }
52
52
 
53
53
  /** 从机器人根目录按 node_modules 依赖解析包真实目录(pnpm / 提升安装等布局) */
54
- function resolvePackageDir(root: string, packageName: string): string | null {
54
+ export function resolvePackageDir(root: string, packageName: string): string | null {
55
55
  const require = createRequire(path.join(root, "package.json"));
56
56
  try {
57
57
  return path.dirname(require.resolve(`${packageName}/package.json`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-service-webui",
3
- "version": "4.14.0",
3
+ "version": "4.15.0",
4
4
  "description": "Mioku WebUI 服务",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/routes/ai.ts CHANGED
@@ -2,6 +2,11 @@ import { Hono } from "hono";
2
2
  import { logger } from "mioku";
3
3
  import { getChatConfig, updateChatConfig } from "../system";
4
4
  import { listStickerCatalog } from "../database";
5
+ import {
6
+ readThemeCatalog,
7
+ updateThemeSelection,
8
+ writeCustomThemes,
9
+ } from "../theme-packs";
5
10
  import aiService from "mioku-service-ai";
6
11
  import type { AIModelRole, AIProtocol, AIUsageRange } from "mioku";
7
12
 
@@ -113,6 +118,23 @@ export function createAIRoutes() {
113
118
  return c.json({ ok: true, data: updateChatConfig("base.json", body) });
114
119
  });
115
120
 
121
+ app.get("/themes", (c) => {
122
+ return c.json({ ok: true, data: readThemeCatalog() });
123
+ });
124
+
125
+ app.put("/themes", async (c) => {
126
+ logger.info(`[webui-action] ai.themes.update`);
127
+ return c.json({ ok: true, data: writeCustomThemes(await c.req.json()) });
128
+ });
129
+
130
+ app.put("/themes/selection", async (c) => {
131
+ logger.info(`[webui-action] ai.themes.select`);
132
+ return c.json({
133
+ ok: true,
134
+ data: updateThemeSelection(await c.req.json()),
135
+ });
136
+ });
137
+
116
138
  app.get("/personalization", (c) => {
117
139
  const data = stripDeadPersonalizationKeys(
118
140
  getChatConfig("personalization.json"),
package/theme-packs.ts ADDED
@@ -0,0 +1,311 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { resolvePackageDir } from "./config-page-loader";
4
+ import { getChatConfig, updateChatConfig } from "./system";
5
+ import { projectRoot } from "./utils";
6
+
7
+ const BUILTIN_PREFIX = "builtin:";
8
+ const CUSTOM_PREFIX = "custom:";
9
+ const MAX_THEMES_PER_KIND = 100;
10
+ const MAX_NAME = 40;
11
+ const MAX_TEXT = 20_000;
12
+ const MAX_STYLES = 40;
13
+ const MAX_EXAMPLES = 200;
14
+ const MAX_EXAMPLE = 500;
15
+
16
+ export interface ReplyStyleTheme {
17
+ id: string;
18
+ name: string;
19
+ persona: string;
20
+ baseStyle: string;
21
+ multipleStyles: string[];
22
+ multipleProbability: number;
23
+ builtin: boolean;
24
+ }
25
+
26
+ export interface EmotionTheme {
27
+ id: string;
28
+ name: string;
29
+ defaultEmotion: string;
30
+ updateIntervalMs: number;
31
+ emotions: Record<string, { examples: string[] }>;
32
+ builtin: boolean;
33
+ }
34
+
35
+ export interface ThemeCatalog {
36
+ reply: ReplyStyleTheme[];
37
+ emotion: EmotionTheme[];
38
+ }
39
+
40
+ export interface ThemeSelection {
41
+ replyStyleTheme: string;
42
+ emotionTheme: string;
43
+ }
44
+
45
+ interface RawReplyTheme {
46
+ id: string;
47
+ name: string;
48
+ persona: string;
49
+ baseStyle: string;
50
+ multipleStyles: string[];
51
+ multipleProbability: number;
52
+ }
53
+
54
+ interface RawEmotionTheme {
55
+ id: string;
56
+ name: string;
57
+ defaultEmotion: string;
58
+ updateIntervalMs: number;
59
+ emotions: Record<string, { examples: string[] }>;
60
+ }
61
+
62
+ function resolveChatPluginDir(): string | null {
63
+ const root = projectRoot();
64
+ const local = path.join(root, "plugins", "chat");
65
+ if (fs.existsSync(path.join(local, "themes", "builtin.json"))) return local;
66
+ return resolvePackageDir(root, "mioku-plugin-chat");
67
+ }
68
+
69
+ let builtinCache: {
70
+ key: string;
71
+ reply: RawReplyTheme[];
72
+ emotion: RawEmotionTheme[];
73
+ } | null = null;
74
+
75
+ function emptyBuiltin(): {
76
+ reply: RawReplyTheme[];
77
+ emotion: RawEmotionTheme[];
78
+ } {
79
+ return { reply: [], emotion: [] };
80
+ }
81
+
82
+ function readBuiltinPacks(): {
83
+ reply: RawReplyTheme[];
84
+ emotion: RawEmotionTheme[];
85
+ } {
86
+ const dir = resolveChatPluginDir();
87
+ if (!dir) return emptyBuiltin();
88
+ const file = path.join(dir, "themes", "builtin.json");
89
+ if (!fs.existsSync(file)) return emptyBuiltin();
90
+
91
+ try {
92
+ const stat = fs.statSync(file);
93
+ const key = `${file}:${stat.mtimeMs}:${stat.size}`;
94
+ if (builtinCache?.key === key) {
95
+ return builtinCache;
96
+ }
97
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
98
+ const stats = {
99
+ key,
100
+ reply: normalizeReplyList(parsed?.reply, { allowBuiltin: true }),
101
+ emotion: normalizeEmotionList(parsed?.emotion, { allowBuiltin: true }),
102
+ };
103
+ builtinCache = stats;
104
+ return stats;
105
+ } catch {
106
+ return emptyBuiltin();
107
+ }
108
+ }
109
+
110
+ export function readThemeCatalog(): ThemeCatalog {
111
+ const builtin = readBuiltinPacks();
112
+ const custom = getChatConfig("themes.json") || {};
113
+ return {
114
+ reply: [
115
+ ...builtin.reply.map((theme) => ({ ...theme, builtin: true })),
116
+ ...normalizeReplyList(custom.reply, { allowBuiltin: false }).map(
117
+ (theme) => ({ ...theme, builtin: false }),
118
+ ),
119
+ ],
120
+ emotion: [
121
+ ...builtin.emotion.map((theme) => ({ ...theme, builtin: true })),
122
+ ...normalizeEmotionList(custom.emotion, { allowBuiltin: false }).map(
123
+ (theme) => ({ ...theme, builtin: false }),
124
+ ),
125
+ ],
126
+ };
127
+ }
128
+
129
+ /** 只写用户主题包;内置主题永远来自插件代码 */
130
+ export function writeCustomThemes(body: unknown): ThemeCatalog {
131
+ const source = isRecord(body) ? body : {};
132
+ const next = {
133
+ reply: normalizeReplyList(source.reply, { allowBuiltin: false }),
134
+ emotion: normalizeEmotionList(source.emotion, { allowBuiltin: false }),
135
+ };
136
+ updateChatConfig("themes.json", next);
137
+ return readThemeCatalog();
138
+ }
139
+
140
+ export function updateThemeSelection(body: unknown): ThemeSelection {
141
+ const source = isRecord(body) ? body : {};
142
+ const catalog = readThemeCatalog();
143
+ const current = getChatConfig("personalization.json") || {};
144
+
145
+ const next: Record<string, unknown> = { ...current };
146
+ if ("replyStyleTheme" in source) {
147
+ next.replyStyleTheme = resolveSelection(
148
+ source.replyStyleTheme,
149
+ catalog.reply,
150
+ );
151
+ }
152
+ if ("emotionTheme" in source) {
153
+ next.emotionTheme = resolveSelection(source.emotionTheme, catalog.emotion);
154
+ }
155
+ updateChatConfig("personalization.json", next);
156
+
157
+ return {
158
+ replyStyleTheme: normalizeId(next.replyStyleTheme),
159
+ emotionTheme: normalizeId(next.emotionTheme),
160
+ };
161
+ }
162
+
163
+ function resolveSelection(
164
+ value: unknown,
165
+ themes: Array<{ id: string }>,
166
+ ): string {
167
+ const id = normalizeId(value);
168
+ if (!id) return "";
169
+ return themes.some((theme) => theme.id === id) ? id : "";
170
+ }
171
+
172
+ function normalizeId(value: unknown): string {
173
+ if (typeof value !== "string") return "";
174
+ const id = value.trim();
175
+ if (!id || id.length > 100) return "";
176
+ if (!id.startsWith(BUILTIN_PREFIX) && !id.startsWith(CUSTOM_PREFIX))
177
+ return "";
178
+ return id;
179
+ }
180
+
181
+ function normalizeReplyList(
182
+ raw: unknown,
183
+ options: { allowBuiltin: boolean },
184
+ ): RawReplyTheme[] {
185
+ if (!Array.isArray(raw)) return [];
186
+ const result: RawReplyTheme[] = [];
187
+ const seen = new Set<string>();
188
+ for (const item of raw) {
189
+ if (!isRecord(item)) continue;
190
+ const id = normalizeId(item.id);
191
+ if (!isThemeIdAllowed(id, options.allowBuiltin) || seen.has(id)) continue;
192
+ const name = readText(item.name, MAX_NAME);
193
+ if (!name) continue;
194
+ seen.add(id);
195
+ result.push({
196
+ id,
197
+ name,
198
+ persona: readText(item.persona, MAX_TEXT),
199
+ baseStyle: readText(item.baseStyle, MAX_TEXT),
200
+ multipleStyles: readTextList(item.multipleStyles, MAX_STYLES, MAX_TEXT),
201
+ multipleProbability: readProbability(item.multipleProbability),
202
+ });
203
+ if (result.length >= MAX_THEMES_PER_KIND) break;
204
+ }
205
+ return result;
206
+ }
207
+
208
+ function normalizeEmotionList(
209
+ raw: unknown,
210
+ options: { allowBuiltin: boolean },
211
+ ): RawEmotionTheme[] {
212
+ if (!Array.isArray(raw)) return [];
213
+ const result: RawEmotionTheme[] = [];
214
+ const seen = new Set<string>();
215
+ for (const item of raw) {
216
+ if (!isRecord(item)) continue;
217
+ const id = normalizeId(item.id);
218
+ if (!isThemeIdAllowed(id, options.allowBuiltin) || seen.has(id)) continue;
219
+ const name = readText(item.name, MAX_NAME);
220
+ if (!name) continue;
221
+ const emotions = normalizeEmotions(item.emotions);
222
+ seen.add(id);
223
+ result.push({
224
+ id,
225
+ name,
226
+ emotions,
227
+ defaultEmotion: pickDefaultEmotion(item.defaultEmotion, emotions),
228
+ updateIntervalMs: readInterval(item.updateIntervalMs),
229
+ });
230
+ if (result.length >= MAX_THEMES_PER_KIND) break;
231
+ }
232
+ return result;
233
+ }
234
+
235
+ function isThemeIdAllowed(id: string, allowBuiltin: boolean): boolean {
236
+ if (!id) return false;
237
+ return allowBuiltin || !id.startsWith(BUILTIN_PREFIX);
238
+ }
239
+
240
+ function normalizeEmotions(
241
+ raw: unknown,
242
+ ): Record<string, { examples: string[] }> {
243
+ const emotions: Record<string, { examples: string[] }> = {};
244
+ if (isRecord(raw)) {
245
+ for (const [rawName, value] of Object.entries(raw)) {
246
+ const name = readEmotionName(rawName);
247
+ if (!name || emotions[name]) continue;
248
+ emotions[name] = {
249
+ examples: readTextList(
250
+ isRecord(value) ? value.examples : undefined,
251
+ MAX_EXAMPLES,
252
+ MAX_EXAMPLE,
253
+ ),
254
+ };
255
+ }
256
+ }
257
+ if (!emotions.default) emotions.default = { examples: [] };
258
+ return emotions;
259
+ }
260
+
261
+ function readEmotionName(value: unknown): string {
262
+ if (typeof value !== "string") return "";
263
+ return value.trim().toLowerCase().slice(0, 20);
264
+ }
265
+
266
+ function pickDefaultEmotion(
267
+ raw: unknown,
268
+ emotions: Record<string, { examples: string[] }>,
269
+ ): string {
270
+ const name = readEmotionName(raw);
271
+ return name && emotions[name] ? name : "default";
272
+ }
273
+
274
+ function readInterval(value: unknown): number {
275
+ const num = Number(value);
276
+ if (!Number.isFinite(num)) return 60 * 60_000;
277
+ return Math.min(30 * 24 * 60 * 60_000, Math.max(60_000, Math.round(num)));
278
+ }
279
+
280
+ function readProbability(value: unknown): number {
281
+ const num = Number(value);
282
+ if (!Number.isFinite(num)) return 0.2;
283
+ return Math.min(1, Math.max(0, num));
284
+ }
285
+
286
+ function readText(value: unknown, maxLength: number): string {
287
+ return typeof value === "string" ? value.slice(0, maxLength) : "";
288
+ }
289
+
290
+ function readTextList(
291
+ value: unknown,
292
+ maxItems: number,
293
+ maxLength: number,
294
+ ): string[] {
295
+ if (!Array.isArray(value)) return [];
296
+ const result: string[] = [];
297
+ const seen = new Set<string>();
298
+ for (const item of value) {
299
+ if (typeof item !== "string") continue;
300
+ const text = item.trim();
301
+ if (!text || seen.has(text)) continue;
302
+ seen.add(text);
303
+ result.push(text.slice(0, maxLength));
304
+ if (result.length >= maxItems) break;
305
+ }
306
+ return result;
307
+ }
308
+
309
+ function isRecord(value: unknown): value is Record<string, any> {
310
+ return typeof value === "object" && value !== null && !Array.isArray(value);
311
+ }