mioku-service-webui 4.13.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/database.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { getConfigDefaults, withConfigDefaults } from "mioku";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { Database } from "bun:sqlite";
@@ -359,7 +360,12 @@ export function getPluginConfigs(pluginName: string): Record<string, any> {
359
360
 
360
361
  for (const file of files) {
361
362
  const filePath = path.join(pluginDir, file);
362
- result[path.basename(file, ".json")] = readJsonFile(filePath, {});
363
+ const configName = path.basename(file, ".json");
364
+ // 磁盘值覆盖插件登记的默认值,缺字段时页面也能拿到完整配置
365
+ result[configName] = withConfigDefaults(
366
+ readJsonFile(filePath, {}),
367
+ getConfigDefaults("plugin", pluginName, configName),
368
+ );
363
369
  }
364
370
 
365
371
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-service-webui",
3
- "version": "4.13.0",
3
+ "version": "4.15.0",
4
4
  "description": "Mioku WebUI 服务",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/routes/agent.ts CHANGED
@@ -31,78 +31,44 @@ function sanitizeAccess(input: any): any {
31
31
  };
32
32
  }
33
33
 
34
+ /**
35
+ * 保存时只做「不该写进这个文件」的剔除,不补默认值。
36
+ *
37
+ * 默认值由 agent 插件在 `registerConfig` 时登记、读取时深合并
38
+ * (`mergeAgentConfig`)——这里再抄一份,加字段时就会两边漂移。
39
+ */
34
40
  function sanitizeBaseForSave(body: any): any {
35
41
  if (!body || typeof body !== "object") return body;
36
42
  const next = { ...body };
37
43
  delete next.persona;
38
44
  delete next.roleBindings;
39
- next.access = sanitizeAccess(next.access);
40
- next.workspaceDir = String(next.workspaceDir ?? "").trim();
41
- next.permissionLevel = PERMISSION_LEVELS.has(next.permissionLevel)
42
- ? next.permissionLevel
43
- : "workspace-write";
44
- next.model = String(next.model ?? "").trim();
45
+ if (next.access !== undefined) next.access = sanitizeAccess(next.access);
46
+ if (typeof next.workspaceDir === "string") {
47
+ next.workspaceDir = next.workspaceDir.trim();
48
+ }
49
+ if (typeof next.model === "string") next.model = next.model.trim();
50
+ if (!PERMISSION_LEVELS.has(next.permissionLevel)) delete next.permissionLevel;
45
51
  return next;
46
52
  }
47
53
 
48
54
  function sanitizeSettingsForSave(body: any): any {
49
55
  if (!body || typeof body !== "object") return body;
50
56
  const next = { ...body };
51
- const positive = (value: unknown, fallback: number) => {
52
- const num = Math.floor(Number(value));
53
- return Number.isFinite(num) && num > 0 ? num : fallback;
54
- };
55
- const nonNegative = (value: unknown, fallback: number) => {
56
- const num = Math.floor(Number(value));
57
- return Number.isFinite(num) && num >= 0 ? num : fallback;
58
- };
57
+ // 这两个属于 chat 插件的 base 配置,老版本 UI 可能还会带上来
59
58
  delete next.temperature;
60
59
  delete next.enableMediaRecognition;
61
- next.maxIterations = positive(next.maxIterations, 500);
62
- next.maxContextTokens = positive(next.maxContextTokens, 524288);
63
- next.stream = next.stream !== false;
64
- next.enableMarkdownScreenshot = next.enableMarkdownScreenshot !== false;
65
- next.compaction = {
66
- enabled: next.compaction?.enabled !== false,
67
- keepRecentMessages: positive(next.compaction?.keepRecentMessages, 20),
68
- };
69
- next.webSearch = {
70
- enabled: next.webSearch?.enabled !== false,
71
- baseUrl: String(next.webSearch?.baseUrl ?? "").trim(),
72
- timeoutMs: positive(next.webSearch?.timeoutMs, 8000),
73
- defaultLimit: positive(next.webSearch?.defaultLimit, 5),
74
- maxLimit: positive(next.webSearch?.maxLimit, 8),
75
- maxSearchCount: positive(next.webSearch?.maxSearchCount, 50),
76
- };
77
- next.webFetch = {
78
- enabled: next.webFetch?.enabled !== false,
79
- timeoutMs: positive(next.webFetch?.timeoutMs, 15000),
80
- maxChars: positive(next.webFetch?.maxChars, 12000),
81
- };
82
- next.bash = {
83
- enabled: next.bash?.enabled !== false,
84
- timeoutMs: positive(next.bash?.timeoutMs, 120000),
85
- approvalTimeoutMs: positive(next.bash?.approvalTimeoutMs, 300000),
86
- };
87
- next.skills = {
88
- enabled: next.skills?.enabled !== false,
89
- extraRoots: Array.isArray(next.skills?.extraRoots)
90
- ? next.skills.extraRoots
91
- .map((item: unknown) => String(item ?? "").trim())
92
- .filter(Boolean)
93
- : [],
94
- maxInPrompt: positive(next.skills?.maxInPrompt, 50),
95
- descriptionMaxLength: positive(next.skills?.descriptionMaxLength, 500),
96
- activationTtlMs: positive(next.skills?.activationTtlMs, 3_600_000),
97
- rescanIntervalMs: nonNegative(next.skills?.rescanIntervalMs, 2000),
98
- maxResourceFiles: positive(next.skills?.maxResourceFiles, 200),
99
- };
100
- next.dataCollection = { enabled: next.dataCollection?.enabled !== false };
101
- next.mcp = {
102
- enabled: next.mcp?.enabled !== false,
103
- maxTools: nonNegative(next.mcp?.maxTools, 48),
104
- };
105
- next.debug = Boolean(next.debug);
60
+ if (
61
+ next.skills &&
62
+ typeof next.skills === "object" &&
63
+ Array.isArray(next.skills.extraRoots)
64
+ ) {
65
+ next.skills = {
66
+ ...next.skills,
67
+ extraRoots: next.skills.extraRoots
68
+ .map((item: unknown) => String(item ?? "").trim())
69
+ .filter(Boolean),
70
+ };
71
+ }
106
72
  return next;
107
73
  }
108
74
 
@@ -127,6 +93,20 @@ export function createAgentRoutes() {
127
93
  return c.json({ ok: true, data: updateAgentConfig("base.json", body) });
128
94
  });
129
95
 
96
+ // 记忆配置由 agent 插件自己归一化,这里只做读写
97
+ app.get("/memory", (c) =>
98
+ c.json({ ok: true, data: getAgentConfig("memory.json") }),
99
+ );
100
+
101
+ app.put("/memory", async (c) => {
102
+ const body = await c.req.json();
103
+ logger.info(`[webui-action] agent.memory.update`, {
104
+ enabled: body?.enabled,
105
+ retentionMonths: body?.retentionMonths,
106
+ });
107
+ return c.json({ ok: true, data: updateAgentConfig("memory.json", body) });
108
+ });
109
+
130
110
  app.get("/settings", (c) =>
131
111
  c.json({ ok: true, data: getAgentConfig("settings.json") }),
132
112
  );
package/routes/ai.ts CHANGED
@@ -2,15 +2,14 @@ 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
 
8
- const DEFAULT_MEMORY_GROUP_HISTORY_LIMIT = 300;
9
- const DEFAULT_MEMORY_USER_HISTORY_LIMIT = 100;
10
- const DEFAULT_TOPIC_WINDOW_HOURS = 5;
11
- const DEFAULT_TOPIC_HISTORY_WINDOW_COUNT = 3;
12
- const DEFAULT_EXPRESSION_LEARN_AFTER_MESSAGES = 100;
13
- const DEFAULT_EXPRESSION_SAMPLE_SIZE = 8;
14
13
  const usageRanges = new Set<AIUsageRange>(["today", "7d", "30d"]);
15
14
  const protocols = new Set<AIProtocol>([
16
15
  "openai-chat",
@@ -24,111 +23,50 @@ function getAIApi() {
24
23
  return aiService?.api as any;
25
24
  }
26
25
 
27
- function normalizePersonalizationConfig(input: any): any {
28
- const data =
29
- input && typeof input === "object" && !Array.isArray(input)
30
- ? { ...input }
31
- : {};
32
-
33
- const rawMemory =
34
- data.memory && typeof data.memory === "object" && !Array.isArray(data.memory)
35
- ? { ...data.memory }
36
- : {};
37
-
38
- delete rawMemory.maxIterations;
39
- delete rawMemory.timeoutMs;
40
-
41
- const groupHistoryLimit = Number(rawMemory.groupHistoryLimit);
42
- const userHistoryLimit = Number(rawMemory.userHistoryLimit);
43
-
44
- rawMemory.groupHistoryLimit =
45
- Number.isFinite(groupHistoryLimit) && groupHistoryLimit > 0
46
- ? Math.floor(groupHistoryLimit)
47
- : DEFAULT_MEMORY_GROUP_HISTORY_LIMIT;
48
- rawMemory.userHistoryLimit =
49
- Number.isFinite(userHistoryLimit) && userHistoryLimit > 0
50
- ? Math.floor(userHistoryLimit)
51
- : DEFAULT_MEMORY_USER_HISTORY_LIMIT;
52
- rawMemory.enabled =
53
- typeof rawMemory.enabled === "boolean" ? rawMemory.enabled : true;
54
-
55
- data.memory = rawMemory;
56
-
57
- const rawTopic =
58
- data.topic && typeof data.topic === "object" && !Array.isArray(data.topic)
59
- ? { ...data.topic }
60
- : {};
61
-
62
- const windowHours = Number(rawTopic.windowHours);
63
- const legacyTimeThresholdMs = Number(rawTopic.timeThresholdMs);
64
- const historyWindowCount = Number(rawTopic.historyWindowCount);
65
-
66
- rawTopic.enabled =
67
- typeof rawTopic.enabled === "boolean" ? rawTopic.enabled : true;
68
- rawTopic.windowHours =
69
- Number.isFinite(windowHours) && windowHours > 0
70
- ? Math.floor(windowHours)
71
- : Number.isFinite(legacyTimeThresholdMs) && legacyTimeThresholdMs > 0
72
- ? Math.max(1, Math.floor(legacyTimeThresholdMs / 3600_000))
73
- : DEFAULT_TOPIC_WINDOW_HOURS;
74
- rawTopic.historyWindowCount =
75
- Number.isFinite(historyWindowCount) && historyWindowCount > 0
76
- ? Math.floor(historyWindowCount)
77
- : DEFAULT_TOPIC_HISTORY_WINDOW_COUNT;
78
-
79
- delete rawTopic.messageThreshold;
80
- delete rawTopic.timeThresholdMs;
81
- delete rawTopic.maxTopicsPerSession;
82
-
83
- data.topic = rawTopic;
84
-
85
- const rawExpression =
26
+ function stripDeadPersonalizationKeys(input: any): any {
27
+ if (!input || typeof input !== "object" || Array.isArray(input)) return {};
28
+ const data: Record<string, any> = { ...input };
29
+
30
+ delete data.memory;
31
+ delete data.planner;
32
+
33
+ if (
34
+ data.topic &&
35
+ typeof data.topic === "object" &&
36
+ !Array.isArray(data.topic)
37
+ ) {
38
+ const topic = { ...data.topic };
39
+ delete topic.timeThresholdMs;
40
+ delete topic.messageThreshold;
41
+ delete topic.maxTopicsPerSession;
42
+ data.topic = topic;
43
+ }
44
+
45
+ if (
86
46
  data.expression &&
87
47
  typeof data.expression === "object" &&
88
48
  !Array.isArray(data.expression)
89
- ? { ...data.expression }
90
- : {};
91
-
92
- const learnAfterMessages = Number(rawExpression.learnAfterMessages);
93
- const legacyMaxExpressions = Number(rawExpression.maxExpressions);
94
- const sampleSize = Number(rawExpression.sampleSize);
95
-
96
- rawExpression.enabled =
97
- typeof rawExpression.enabled === "boolean" ? rawExpression.enabled : true;
98
- rawExpression.learnAfterMessages =
99
- Number.isFinite(learnAfterMessages) && learnAfterMessages > 0
100
- ? Math.floor(learnAfterMessages)
101
- : Number.isFinite(legacyMaxExpressions) && legacyMaxExpressions > 0
102
- ? Math.floor(legacyMaxExpressions)
103
- : DEFAULT_EXPRESSION_LEARN_AFTER_MESSAGES;
104
- rawExpression.sampleSize =
105
- Number.isFinite(sampleSize) && sampleSize > 0
106
- ? Math.floor(sampleSize)
107
- : DEFAULT_EXPRESSION_SAMPLE_SIZE;
108
- delete rawExpression.maxExpressions;
109
-
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;
49
+ ) {
50
+ const expression = { ...data.expression };
51
+ delete expression.maxExpressions;
52
+ data.expression = expression;
53
+ }
54
+
55
+ if (
56
+ data.emoji &&
57
+ typeof data.emoji === "object" &&
58
+ !Array.isArray(data.emoji)
59
+ ) {
60
+ const emoji = { ...data.emoji };
61
+ delete emoji.useAISelection;
62
+ for (const key of ["characters", "stickers"] as const) {
63
+ if (!Array.isArray(emoji[key])) continue;
64
+ emoji[key] = emoji[key]
65
+ .map((item: unknown) => String(item ?? "").trim())
66
+ .filter(Boolean);
67
+ }
68
+ data.emoji = emoji;
69
+ }
132
70
 
133
71
  return data;
134
72
  }
@@ -180,14 +118,31 @@ export function createAIRoutes() {
180
118
  return c.json({ ok: true, data: updateChatConfig("base.json", body) });
181
119
  });
182
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
+
183
138
  app.get("/personalization", (c) => {
184
- const data = normalizePersonalizationConfig(
139
+ const data = stripDeadPersonalizationKeys(
185
140
  getChatConfig("personalization.json"),
186
141
  );
187
142
  return c.json({ ok: true, data });
188
143
  });
189
144
  app.put("/personalization", async (c) => {
190
- const body = normalizePersonalizationConfig(await c.req.json());
145
+ const body = stripDeadPersonalizationKeys(await c.req.json());
191
146
  logger.info(`[webui-action] ai.personalization.update`);
192
147
  return c.json({
193
148
  ok: true,
@@ -195,6 +150,19 @@ export function createAIRoutes() {
195
150
  });
196
151
  });
197
152
 
153
+ // 记忆配置由 chat 插件自己归一化(缺字段/坏值在读取时兜底),这里只做读写
154
+ app.get("/memory", (c) =>
155
+ c.json({ ok: true, data: getChatConfig("memory.json") }),
156
+ );
157
+ app.put("/memory", async (c) => {
158
+ const body = await c.req.json();
159
+ logger.info(`[webui-action] ai.memory.update`, {
160
+ enabled: body?.enabled,
161
+ retentionMonths: body?.retentionMonths,
162
+ });
163
+ return c.json({ ok: true, data: updateChatConfig("memory.json", body) });
164
+ });
165
+
198
166
  app.get("/settings", (c) =>
199
167
  c.json({ ok: true, data: getChatConfig("settings.json") }),
200
168
  );
@@ -405,7 +373,9 @@ export function createAIRoutes() {
405
373
  .filter((id: string) => id.includes("/") && id.length > 1),
406
374
  ),
407
375
  );
408
- logger.info(`[webui-action] ai.fallback.update`, { count: fallback.length });
376
+ logger.info(`[webui-action] ai.fallback.update`, {
377
+ count: fallback.length,
378
+ });
409
379
  api.setMainFallbackChain(fallback);
410
380
  const live = api.getMainFallbackChain?.() ?? fallback;
411
381
  return c.json({ ok: true, data: { fallback: live, liveFallback: live } });
@@ -496,9 +466,7 @@ export function createAIRoutes() {
496
466
  });
497
467
  });
498
468
 
499
- app.get("/stickers", (c) =>
500
- c.json({ ok: true, data: listStickerCatalog() }),
501
- );
469
+ app.get("/stickers", (c) => c.json({ ok: true, data: listStickerCatalog() }));
502
470
 
503
471
  app.get("/usage", (c) => {
504
472
  const api = getAIApi();
@@ -512,8 +480,7 @@ export function createAIRoutes() {
512
480
  ? (rawRange as AIUsageRange)
513
481
  : "today";
514
482
  const rawBotId = c.req.query("botId");
515
- const botId =
516
- rawBotId && rawBotId !== "all" ? Number(rawBotId) : undefined;
483
+ const botId = rawBotId && rawBotId !== "all" ? Number(rawBotId) : undefined;
517
484
 
518
485
  if (botId !== undefined && !Number.isFinite(botId)) {
519
486
  return c.json({ ok: false, error: "INVALID_BOT_ID" }, 400);
package/system.ts CHANGED
@@ -4,7 +4,13 @@ import * as path from "node:path";
4
4
  import process from "node:process";
5
5
  import AdmZip from "adm-zip";
6
6
  import systemInfo from "systeminformation";
7
- import { buildAdapterReport, connectedBots, logger } from "mioku";
7
+ import {
8
+ buildAdapterReport,
9
+ connectedBots,
10
+ getConfigDefaults,
11
+ logger,
12
+ withConfigDefaults,
13
+ } from "mioku";
8
14
  import type { AdapterInstanceStatus, Bot } from "mioku";
9
15
  import type {
10
16
  InstallRequest,
@@ -2389,9 +2395,21 @@ export async function getSaying(): Promise<{ text: string }> {
2389
2395
  return fetchYiyan();
2390
2396
  }
2391
2397
 
2398
+ /**
2399
+ * 读插件配置:磁盘值覆盖默认值。
2400
+ *
2401
+ * 默认值只由插件在 `registerConfig` 时登记一次(见 mioku 的 config-defaults),
2402
+ * WebUI 这边不再手抄字段——以前每加一个配置项,要在这里和插件各改一遍。
2403
+ */
2404
+ function readPluginConfig(dir: string, owner: string, fileName: string): any {
2405
+ const stored = readJsonFile(path.join(dir, fileName), {});
2406
+ const name = fileName.replace(/\.json$/, "");
2407
+ const defaults = getConfigDefaults("plugin", owner, name);
2408
+ return withConfigDefaults(stored, defaults);
2409
+ }
2410
+
2392
2411
  export function getChatConfig(fileName: string): any {
2393
- const filePath = path.join(CHAT_CONFIG_DIR, fileName);
2394
- return readJsonFile(filePath, {});
2412
+ return readPluginConfig(CHAT_CONFIG_DIR, "chat", fileName);
2395
2413
  }
2396
2414
 
2397
2415
  export function updateChatConfig(fileName: string, data: any): any {
@@ -2401,8 +2419,7 @@ export function updateChatConfig(fileName: string, data: any): any {
2401
2419
  }
2402
2420
 
2403
2421
  export function getAgentConfig(fileName: string): any {
2404
- const filePath = path.join(AGENT_CONFIG_DIR, fileName);
2405
- return readJsonFile(filePath, {});
2422
+ return readPluginConfig(AGENT_CONFIG_DIR, "agent", fileName);
2406
2423
  }
2407
2424
 
2408
2425
  export function updateAgentConfig(fileName: string, data: any): any {
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
+ }