mioku-service-webui 2.0.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/index.ts ADDED
@@ -0,0 +1,231 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { logger } from "mioki";
4
+ import { Hono } from "hono";
5
+ import { serve } from "@hono/node-server";
6
+ import { serveStatic } from "@hono/node-server/serve-static";
7
+ import { createNodeWebSocket } from "@hono/node-ws";
8
+ import type { MiokuService } from "../../core/types";
9
+ import type { WebUISettings } from "./types";
10
+ import { ensureAuthConfig, loginWithToken, requireAuth } from "./auth";
11
+ import { getWebUISettings, getSystemOverview, getSaying } from "./system";
12
+ import { CHAT_CONFIG_DIR, CHAT_DATA_DIR, LOGS_DIR, WEBUI_DIST } from "./utils";
13
+ import { initBuiltinDatasources } from "./datasources";
14
+ import {
15
+ createConfigRoutes,
16
+ createWebUISettingsRoutes,
17
+ createAIRoutes,
18
+ createDBRoutes,
19
+ createPluginConfigRoutes,
20
+ createMemeRoutes,
21
+ createManageRoutes,
22
+ createStoreRoutes,
23
+ } from "./routes";
24
+
25
+ export interface WebUIServiceAPI {
26
+ getSettings(): WebUISettings;
27
+ }
28
+
29
+ class WebUIRuntime {
30
+ private app = new Hono();
31
+ private server: ReturnType<typeof serve> | null = null;
32
+
33
+ private logAction(action: string, payload?: unknown): void {
34
+ const text = payload ? ` | ${JSON.stringify(payload)}` : "";
35
+ logger.info(`[webui-action] ${action}${text}`);
36
+ }
37
+
38
+ public initRoutes(): void {
39
+ const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({
40
+ app: this.app,
41
+ });
42
+
43
+ this.app.onError((err, c) => {
44
+ logger.error(`webui-service API error: ${err.message}`);
45
+ return c.json(
46
+ {
47
+ ok: false,
48
+ error: err.message || "INTERNAL_SERVER_ERROR",
49
+ },
50
+ 500,
51
+ );
52
+ });
53
+
54
+ this.app.notFound((c) => {
55
+ if (c.req.path.startsWith("/api/")) {
56
+ return c.json({ ok: false, error: "API_NOT_FOUND" }, 404);
57
+ }
58
+ return c.text("Not Found", 404);
59
+ });
60
+
61
+ this.app.get("/api/health", (c) => c.json({ ok: true, service: "webui" }));
62
+
63
+ this.app.post("/api/auth/login", async (c) => {
64
+ const body = await c.req.json().catch(() => ({}));
65
+ this.logAction("auth.login.attempt");
66
+ const result = loginWithToken(String(body?.token || ""));
67
+ if (!result.ok) {
68
+ this.logAction("auth.login.failed");
69
+ return c.json({ ok: false, error: "TOKEN_INVALID" }, 401);
70
+ }
71
+ this.logAction("auth.login.success", { expiresAt: result.expiresAt });
72
+ return c.json({ ok: true, expiresAt: result.expiresAt });
73
+ });
74
+
75
+ this.app.use("/api/*", async (c, next) => {
76
+ if (c.req.path === "/api/health" || c.req.path === "/api/auth/login") {
77
+ return next();
78
+ }
79
+ return requireAuth(c, next);
80
+ });
81
+
82
+ this.app.get("/api/overview", async (c) => {
83
+ const data = await getSystemOverview();
84
+ return c.json({ ok: true, data });
85
+ });
86
+
87
+ this.app.get("/api/saying", async (c) => {
88
+ this.logAction("saying.fetch");
89
+ const data = await getSaying();
90
+ return c.json({ ok: true, data });
91
+ });
92
+
93
+ this.app.route("/api/config", createConfigRoutes());
94
+ this.app.route("/api/settings", createWebUISettingsRoutes());
95
+ this.app.route("/api/ai", createAIRoutes());
96
+ this.app.route("/api/manage", createManageRoutes());
97
+ this.app.route("/api/store", createStoreRoutes());
98
+ this.app.route("/api/db", createDBRoutes());
99
+ this.app.route("/api/plugin-config", createPluginConfigRoutes());
100
+ this.app.route("/api/meme", createMemeRoutes());
101
+
102
+ this.app.get(
103
+ "/api/ws/logs",
104
+ upgradeWebSocket((c) => {
105
+ let timer: NodeJS.Timeout | null = null;
106
+ let lastPayload = "";
107
+
108
+ return {
109
+ onOpen: (event, ws) => {
110
+ const latest = this.readLatestLogs(50);
111
+ lastPayload = JSON.stringify(latest);
112
+ ws.send(JSON.stringify({ type: "init", data: latest }));
113
+
114
+ timer = setInterval(() => {
115
+ const next = this.readLatestLogs(50);
116
+ const payload = JSON.stringify(next);
117
+ if (payload !== lastPayload) {
118
+ lastPayload = payload;
119
+ ws.send(JSON.stringify({ type: "update", data: next }));
120
+ }
121
+ }, 2000);
122
+ },
123
+ onMessage: (_event, ws) => {
124
+ ws.send(JSON.stringify({ type: "heartbeat", at: Date.now() }));
125
+ },
126
+ onClose: () => {
127
+ if (timer) {
128
+ clearInterval(timer);
129
+ timer = null;
130
+ }
131
+ },
132
+ };
133
+ }),
134
+ );
135
+
136
+ this.app.get(
137
+ "/meme/*",
138
+ serveStatic({
139
+ root: process.cwd(),
140
+ rewriteRequestPath: (p) => p.replace(/^\/meme\//, "data/chat/meme/"),
141
+ }),
142
+ );
143
+
144
+ this.app.use("/assets/*", serveStatic({ root: WEBUI_DIST }));
145
+ this.app.use("/favicon.ico", serveStatic({ root: WEBUI_DIST }));
146
+ this.app.use("/miku-logo.png", serveStatic({ root: WEBUI_DIST }));
147
+ this.app.use("/about-hero.jpg", serveStatic({ root: WEBUI_DIST }));
148
+ this.app.use("/about-hero-dark.jpg", serveStatic({ root: WEBUI_DIST }));
149
+ this.app.get("*", async (c) => {
150
+ const indexPath = path.join(WEBUI_DIST, "index.html");
151
+ if (!fs.existsSync(indexPath)) {
152
+ return c.text(
153
+ "WebUI frontend not built yet. Please run: npm run webui:build",
154
+ 503,
155
+ );
156
+ }
157
+ const content = await fs.promises.readFile(indexPath, "utf-8");
158
+ return c.html(content);
159
+ });
160
+
161
+ const settings = getWebUISettings();
162
+ const server = serve({
163
+ fetch: this.app.fetch,
164
+ port: settings.port,
165
+ hostname: settings.host,
166
+ });
167
+ this.server = server;
168
+ injectWebSocket(server);
169
+ logger.info(
170
+ `webui-service 已启动: http://${settings.host}:${settings.port}`,
171
+ );
172
+ }
173
+
174
+ public readLatestLogs(count: number): string[] {
175
+ if (!fs.existsSync(LOGS_DIR)) {
176
+ return [];
177
+ }
178
+
179
+ const files = fs
180
+ .readdirSync(LOGS_DIR)
181
+ .map((name) => ({
182
+ name,
183
+ fullPath: path.join(LOGS_DIR, name),
184
+ mtimeMs: fs.statSync(path.join(LOGS_DIR, name)).mtimeMs,
185
+ }))
186
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
187
+
188
+ const file = files[0];
189
+ if (!file) {
190
+ return [];
191
+ }
192
+
193
+ const lines = fs
194
+ .readFileSync(file.fullPath, "utf-8")
195
+ .split(/\r?\n/)
196
+ .filter(Boolean);
197
+ return lines.slice(-count);
198
+ }
199
+
200
+ public stop(): void {
201
+ if (this.server) {
202
+ this.server.close();
203
+ this.server = null;
204
+ }
205
+ }
206
+ }
207
+
208
+ const runtime = new WebUIRuntime();
209
+
210
+ const webUIService: MiokuService = {
211
+ name: "webui",
212
+ version: "2.0.0",
213
+ description: "Mioku WebUI 管理服务",
214
+ api: {
215
+ getSettings: () => getWebUISettings(),
216
+ } as WebUIServiceAPI,
217
+
218
+ async init() {
219
+ initBuiltinDatasources();
220
+ ensureAuthConfig();
221
+ fs.mkdirSync(CHAT_CONFIG_DIR, { recursive: true });
222
+ fs.mkdirSync(CHAT_DATA_DIR, { recursive: true });
223
+ runtime.initRoutes();
224
+ },
225
+
226
+ async dispose() {
227
+ runtime.stop();
228
+ },
229
+ };
230
+
231
+ export default webUIService;
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "mioku-service-webui",
3
+ "version": "2.0.0",
4
+ "description": "Mioku WebUI 服务",
5
+ "main": "index.ts",
6
+ "keywords": [
7
+ "mioku"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/mioku-lab/mioku-service-webui.git"
12
+ },
13
+ "dependencies": {
14
+ "@hono/node-server": "1.19.13",
15
+ "@hono/node-ws": "^1.2.0",
16
+ "better-sqlite3": "^11.8.1",
17
+ "gray-matter": "^4.0.3",
18
+ "hono": "4.12.14"
19
+ }
20
+ }
package/routes/ai.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { Hono } from "hono";
2
+ import { logger } from "mioki";
3
+ import { getChatConfig, updateChatConfig } from "../system";
4
+ import aiService from "../../ai";
5
+
6
+ const DEFAULT_MEMORY_GROUP_HISTORY_LIMIT = 300;
7
+ const DEFAULT_MEMORY_USER_HISTORY_LIMIT = 100;
8
+ const DEFAULT_TOPIC_WINDOW_HOURS = 5;
9
+ const DEFAULT_TOPIC_HISTORY_WINDOW_COUNT = 3;
10
+ const DEFAULT_EXPRESSION_LEARN_AFTER_MESSAGES = 100;
11
+ const DEFAULT_EXPRESSION_SAMPLE_SIZE = 8;
12
+
13
+ function normalizePersonalizationConfig(input: any): any {
14
+ const data =
15
+ input && typeof input === "object" && !Array.isArray(input)
16
+ ? { ...input }
17
+ : {};
18
+
19
+ const rawMemory =
20
+ data.memory && typeof data.memory === "object" && !Array.isArray(data.memory)
21
+ ? { ...data.memory }
22
+ : {};
23
+
24
+ delete rawMemory.maxIterations;
25
+ delete rawMemory.timeoutMs;
26
+
27
+ const groupHistoryLimit = Number(rawMemory.groupHistoryLimit);
28
+ const userHistoryLimit = Number(rawMemory.userHistoryLimit);
29
+
30
+ rawMemory.groupHistoryLimit =
31
+ Number.isFinite(groupHistoryLimit) && groupHistoryLimit > 0
32
+ ? Math.floor(groupHistoryLimit)
33
+ : DEFAULT_MEMORY_GROUP_HISTORY_LIMIT;
34
+ rawMemory.userHistoryLimit =
35
+ Number.isFinite(userHistoryLimit) && userHistoryLimit > 0
36
+ ? Math.floor(userHistoryLimit)
37
+ : DEFAULT_MEMORY_USER_HISTORY_LIMIT;
38
+ rawMemory.enabled =
39
+ typeof rawMemory.enabled === "boolean" ? rawMemory.enabled : true;
40
+
41
+ data.memory = rawMemory;
42
+
43
+ const rawTopic =
44
+ data.topic && typeof data.topic === "object" && !Array.isArray(data.topic)
45
+ ? { ...data.topic }
46
+ : {};
47
+
48
+ const windowHours = Number(rawTopic.windowHours);
49
+ const legacyTimeThresholdMs = Number(rawTopic.timeThresholdMs);
50
+ const historyWindowCount = Number(rawTopic.historyWindowCount);
51
+
52
+ rawTopic.enabled =
53
+ typeof rawTopic.enabled === "boolean" ? rawTopic.enabled : true;
54
+ rawTopic.windowHours =
55
+ Number.isFinite(windowHours) && windowHours > 0
56
+ ? Math.floor(windowHours)
57
+ : Number.isFinite(legacyTimeThresholdMs) && legacyTimeThresholdMs > 0
58
+ ? Math.max(1, Math.floor(legacyTimeThresholdMs / 3600_000))
59
+ : DEFAULT_TOPIC_WINDOW_HOURS;
60
+ rawTopic.historyWindowCount =
61
+ Number.isFinite(historyWindowCount) && historyWindowCount > 0
62
+ ? Math.floor(historyWindowCount)
63
+ : DEFAULT_TOPIC_HISTORY_WINDOW_COUNT;
64
+
65
+ delete rawTopic.messageThreshold;
66
+ delete rawTopic.timeThresholdMs;
67
+ delete rawTopic.maxTopicsPerSession;
68
+
69
+ data.topic = rawTopic;
70
+
71
+ const rawExpression =
72
+ data.expression &&
73
+ typeof data.expression === "object" &&
74
+ !Array.isArray(data.expression)
75
+ ? { ...data.expression }
76
+ : {};
77
+
78
+ const learnAfterMessages = Number(rawExpression.learnAfterMessages);
79
+ const legacyMaxExpressions = Number(rawExpression.maxExpressions);
80
+ const sampleSize = Number(rawExpression.sampleSize);
81
+
82
+ rawExpression.enabled =
83
+ typeof rawExpression.enabled === "boolean" ? rawExpression.enabled : true;
84
+ rawExpression.learnAfterMessages =
85
+ Number.isFinite(learnAfterMessages) && learnAfterMessages > 0
86
+ ? Math.floor(learnAfterMessages)
87
+ : Number.isFinite(legacyMaxExpressions) && legacyMaxExpressions > 0
88
+ ? Math.floor(legacyMaxExpressions)
89
+ : DEFAULT_EXPRESSION_LEARN_AFTER_MESSAGES;
90
+ rawExpression.sampleSize =
91
+ Number.isFinite(sampleSize) && sampleSize > 0
92
+ ? Math.floor(sampleSize)
93
+ : DEFAULT_EXPRESSION_SAMPLE_SIZE;
94
+ delete rawExpression.maxExpressions;
95
+
96
+ data.expression = rawExpression;
97
+ return data;
98
+ }
99
+
100
+ export function createAIRoutes() {
101
+ const app = new Hono();
102
+
103
+ app.get("/base", (c) =>
104
+ c.json({ ok: true, data: getChatConfig("base.json") }),
105
+ );
106
+ app.put("/base", async (c) => {
107
+ const body = await c.req.json();
108
+ logger.info(`[webui-action] ai.base.update`);
109
+ return c.json({ ok: true, data: updateChatConfig("base.json", body) });
110
+ });
111
+
112
+ app.get("/personalization", (c) => {
113
+ const data = normalizePersonalizationConfig(
114
+ getChatConfig("personalization.json"),
115
+ );
116
+ return c.json({ ok: true, data });
117
+ });
118
+ app.put("/personalization", async (c) => {
119
+ const body = normalizePersonalizationConfig(await c.req.json());
120
+ logger.info(`[webui-action] ai.personalization.update`);
121
+ return c.json({
122
+ ok: true,
123
+ data: updateChatConfig("personalization.json", body),
124
+ });
125
+ });
126
+
127
+ app.get("/settings", (c) =>
128
+ c.json({ ok: true, data: getChatConfig("settings.json") }),
129
+ );
130
+ app.put("/settings", async (c) => {
131
+ const body = await c.req.json();
132
+ logger.info(`[webui-action] ai.settings.update`);
133
+ return c.json({
134
+ ok: true,
135
+ data: updateChatConfig("settings.json", body),
136
+ });
137
+ });
138
+
139
+ app.get("/instances", (c) => {
140
+ const names = aiService?.api?.list?.() ?? [];
141
+ return c.json({ ok: true, data: names });
142
+ });
143
+
144
+ app.post("/instances", async (c) => {
145
+ const body = await c.req.json();
146
+ logger.info(`[webui-action] ai.instance.create`, {
147
+ name: body?.name,
148
+ apiUrl: body?.apiUrl,
149
+ modelType: body?.modelType,
150
+ });
151
+ if (!aiService?.api?.create) {
152
+ return c.json({ ok: false, error: "AI_SERVICE_UNAVAILABLE" }, 503);
153
+ }
154
+
155
+ await aiService.api.create({
156
+ name: body.name,
157
+ apiUrl: body.apiUrl,
158
+ apiKey: body.apiKey,
159
+ modelType: body.modelType || "text",
160
+ });
161
+ return c.json({ ok: true, data: aiService.api.list() });
162
+ });
163
+
164
+ app.delete("/instances/:name", (c) => {
165
+ const name = c.req.param("name");
166
+ logger.info(`[webui-action] ai.instance.remove`, { name });
167
+ const ok = aiService?.api?.remove?.(name);
168
+ return c.json({ ok: Boolean(ok) });
169
+ });
170
+
171
+ app.post("/default/:name", (c) => {
172
+ const name = c.req.param("name");
173
+ logger.info(`[webui-action] ai.instance.set-default`, { name });
174
+ const ok = aiService?.api?.setDefault?.(name);
175
+ return c.json({ ok: Boolean(ok) });
176
+ });
177
+
178
+ app.get("/skills", (c) => {
179
+ const skills = aiService?.api?.getAllSkills?.();
180
+ const tools = aiService?.api?.getAllTools?.();
181
+ return c.json({
182
+ ok: true,
183
+ data: {
184
+ skills: skills ? Array.from(skills.keys()) : [],
185
+ tools: tools ? Array.from(tools.keys()) : [],
186
+ },
187
+ });
188
+ });
189
+
190
+ return app;
191
+ }
@@ -0,0 +1,52 @@
1
+ import { Hono } from "hono";
2
+ import { logger } from "mioki";
3
+ import {
4
+ getMiokuConfig,
5
+ updateMiokuConfig,
6
+ getAvailablePlugins,
7
+ checkMiokuReleaseUpdate,
8
+ updateMiokuFromMain,
9
+ } from "../system";
10
+
11
+ export function createConfigRoutes() {
12
+ const app = new Hono();
13
+
14
+ app.get("/mioku", (c) => {
15
+ const data = getMiokuConfig();
16
+ return c.json({ ok: true, data });
17
+ });
18
+
19
+ app.put("/mioku", async (c) => {
20
+ const body = await c.req.json();
21
+ logger.info(`[webui-action] config.mioku.update`, {
22
+ owners: body?.owners?.length,
23
+ admins: body?.admins?.length,
24
+ napcat: body?.napcat?.length,
25
+ plugins: body?.plugins?.length,
26
+ bootConfigured: Boolean(body?.boot),
27
+ });
28
+ const data = updateMiokuConfig(body);
29
+ return c.json({ ok: true, data });
30
+ });
31
+
32
+ app.get("/mioku/update/check", async (c) => {
33
+ const force = c.req.query("force") === "1";
34
+ const data = await checkMiokuReleaseUpdate(force);
35
+ return c.json({ ok: true, data });
36
+ });
37
+
38
+ app.post("/mioku/update/apply", async (c) => {
39
+ logger.info(`[webui-action] config.mioku.update.apply`, {
40
+ targetRef: "origin/unknown",
41
+ });
42
+ const data = await updateMiokuFromMain();
43
+ return c.json({ ok: true, data });
44
+ });
45
+
46
+ app.get("/plugins/available", (c) => {
47
+ const data = getAvailablePlugins();
48
+ return c.json({ ok: true, data });
49
+ });
50
+
51
+ return app;
52
+ }