mioku-service-webui 4.5.0 → 4.6.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/database.ts CHANGED
@@ -300,7 +300,9 @@ export function listMemeTree(): Record<string, Record<string, string[]>> {
300
300
  tree[character][emotion] = fs
301
301
  .readdirSync(emotionDir)
302
302
  .filter((name) => /\.(png|jpg|jpeg|gif|webp)$/i.test(name))
303
- .map((name) => path.join("data", "chat", "meme", character, emotion, name));
303
+ .map((name) =>
304
+ path.posix.join("data", "chat", "meme", character, emotion, name),
305
+ );
304
306
  }
305
307
  }
306
308
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-service-webui",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "Mioku WebUI 服务",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/port-utils.ts CHANGED
@@ -41,9 +41,33 @@ function parsePids(raw: string): number[] {
41
41
  return Array.from(pids);
42
42
  }
43
43
 
44
+ function parseNetstatPids(raw: string, port: number): number[] {
45
+ const pids = new Set<number>();
46
+ for (const line of raw.split(/\r?\n/)) {
47
+ const cols = line.trim().split(/\s+/);
48
+ if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue;
49
+ if (!/^LISTENING$/i.test(cols[3])) continue;
50
+ // Local address is host:port — IPv6 forms look like [::]:3339.
51
+ const localPort = cols[1].slice(cols[1].lastIndexOf(":") + 1);
52
+ if (Number(localPort) !== port) continue;
53
+ const pid = Number(cols[4]);
54
+ if (Number.isInteger(pid) && pid > 0) pids.add(pid);
55
+ }
56
+ return Array.from(pids);
57
+ }
58
+
44
59
  async function listListeningPids(
45
60
  port: number,
46
61
  ): Promise<{ pids: number[]; lsofAvailable: boolean }> {
62
+ if (process.platform === "win32") {
63
+ try {
64
+ const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "TCP"]);
65
+ return { pids: parseNetstatPids(stdout, port), lsofAvailable: true };
66
+ } catch (err: any) {
67
+ return { pids: [], lsofAvailable: err?.code !== "ENOENT" };
68
+ }
69
+ }
70
+
47
71
  try {
48
72
  const { stdout } = await execFileAsync("lsof", [
49
73
  "-nP",
@@ -84,8 +108,9 @@ export async function freePort(
84
108
  const initial = await listListeningPids(port);
85
109
 
86
110
  if (!initial.lsofAvailable) {
111
+ const missing = process.platform === "win32" ? "netstat" : "lsof";
87
112
  log.warn(
88
- `未检测到 lsof 命令,无法定位端口 ${port} 的占用进程,建议安装 lsof`,
113
+ `未检测到 ${missing} 命令,无法定位端口 ${port} 的占用进程,建议安装 ${missing}`,
89
114
  );
90
115
  return {
91
116
  port,
package/routes/config.ts CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  getMiokuConfig,
5
5
  updateMiokuConfig,
6
6
  getAvailablePlugins,
7
+ getPluginStatusList,
8
+ togglePlugin,
7
9
  checkMiokuReleaseUpdate,
8
10
  updateMiokuFromMain,
9
11
  } from "../system";
@@ -48,5 +50,20 @@ export function createConfigRoutes() {
48
50
  return c.json({ ok: true, data });
49
51
  });
50
52
 
53
+ app.get("/plugins", (c) => {
54
+ const data = getPluginStatusList();
55
+ return c.json({ ok: true, data });
56
+ });
57
+
58
+ app.post("/plugins/toggle", async (c) => {
59
+ const body = (await c.req.json()) as { name: string; enabled: boolean };
60
+ const data = togglePlugin(body.name, body.enabled);
61
+ logger.info(`[webui-action] config.plugins.toggle`, {
62
+ name: body.name,
63
+ enabled: body.enabled,
64
+ });
65
+ return c.json({ ok: true, data });
66
+ });
67
+
51
68
  return app;
52
69
  }
package/routes/db.ts CHANGED
@@ -231,7 +231,10 @@ export function createMemeRoutes() {
231
231
  const buffer = Buffer.from(await file.arrayBuffer());
232
232
  fs.writeFileSync(filePath, buffer);
233
233
 
234
- return c.json({ ok: true, path: path.relative(process.cwd(), filePath) });
234
+ return c.json({
235
+ ok: true,
236
+ path: path.relative(process.cwd(), filePath).split(path.sep).join("/"),
237
+ });
235
238
  });
236
239
 
237
240
  app.delete("/", async (c) => {
package/system.ts CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  WEBUI_DIST,
34
34
  writeJsonFile,
35
35
  } from "./utils";
36
- import { spawn } from "node:child_process";
36
+ import * as fsp from "node:fs/promises";
37
37
 
38
38
  async function fetchJson(url: string): Promise<any> {
39
39
  const res = await fetch(url, {
@@ -553,44 +553,6 @@ function getRepositoryFromPackage(pkg: any): string {
553
553
  return "";
554
554
  }
555
555
 
556
- async function commandExists(cmd: string): Promise<boolean> {
557
- const result = await runCommand("which", [cmd], process.cwd());
558
- return result.code === 0;
559
- }
560
-
561
- async function ensureUnzipCommand(): Promise<void> {
562
- if (await commandExists("unzip")) {
563
- return;
564
- }
565
-
566
- const platform = os.platform();
567
- if (platform === "darwin") {
568
- const installRes = await runCommand(
569
- "brew",
570
- ["install", "unzip"],
571
- process.cwd(),
572
- );
573
- if (installRes.code !== 0) {
574
- throw new Error(
575
- `未找到 unzip 且自动安装失败: ${installRes.stderr || installRes.stdout}。\n` +
576
- `请手动运行: brew install unzip`,
577
- );
578
- }
579
- } else {
580
- const installRes = await runCommand(
581
- "sudo",
582
- ["apt", "install", "-y", "unzip"],
583
- process.cwd(),
584
- );
585
- if (installRes.code !== 0) {
586
- throw new Error(
587
- `未找到 unzip 且自动安装失败: ${installRes.stderr || installRes.stdout}。\n` +
588
- `请手动运行: sudo apt install unzip`,
589
- );
590
- }
591
- }
592
- }
593
-
594
556
  async function getGitOriginUrl(dir: string): Promise<string> {
595
557
  const res = await runCommand("git", ["remote", "get-url", "origin"], dir);
596
558
  if (res.code !== 0) return "";
@@ -1251,9 +1213,46 @@ export async function removeManagedPackage(
1251
1213
  throw new Error("系统服务不可卸载");
1252
1214
  }
1253
1215
 
1254
- const dir = resolveManagedDir(input.target, input.name);
1216
+ // construct full package name from short name
1217
+ const fullName = input.name.startsWith("mioku-plugin-") || input.name.startsWith("mioku-service-")
1218
+ ? input.name
1219
+ : input.target === "plugin"
1220
+ ? `mioku-plugin-${input.name}`
1221
+ : `mioku-service-${input.name}`;
1222
+
1223
+ const installCmd = getInstallCommand();
1224
+ const removeArgs = installCmd.args.map((a) =>
1225
+ a === "add" ? "remove" : a,
1226
+ );
1227
+
1228
+ const result = await runCommand(
1229
+ installCmd.cmd,
1230
+ [...removeArgs, fullName],
1231
+ process.cwd(),
1232
+ );
1255
1233
 
1256
- fs.rmSync(dir, { recursive: true, force: true });
1234
+ if (result.code !== 0) {
1235
+ throw new Error(`bun remove 失败: ${result.stderr || result.stdout}`);
1236
+ }
1237
+
1238
+ // clean up mioki.plugins list for plugins
1239
+ if (input.target === "plugin") {
1240
+ const pkgPath = ROOT_PACKAGE_PATH;
1241
+ if (fs.existsSync(pkgPath)) {
1242
+ try {
1243
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
1244
+ const mioki = pkg.mioki ?? {};
1245
+ const plugins: string[] = Array.isArray(mioki.plugins) ? mioki.plugins : [];
1246
+ const shortName = fullName.replace(/^mioku-plugin-/, "");
1247
+ if (plugins.includes(shortName)) {
1248
+ pkg.mioki = { ...mioki, plugins: plugins.filter((p: string) => p !== shortName) };
1249
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
1250
+ }
1251
+ } catch {
1252
+ // silently ignore package.json parse errors
1253
+ }
1254
+ }
1255
+ }
1257
1256
 
1258
1257
  managedPackageUpdateCache.delete(
1259
1258
  makeManagedUpdateCacheKey(input.target, input.name),
@@ -1912,58 +1911,31 @@ export async function updateWebUIDistFromRelease(): Promise<
1912
1911
  try {
1913
1912
  logger.info(`[webui] 正在下载 WebUI dist...`);
1914
1913
 
1915
- await new Promise<void>((resolve, reject) => {
1916
- const curl = spawn(
1917
- "curl",
1918
- [
1919
- "-L",
1920
- "-o",
1921
- zipPath,
1922
- "-A",
1923
- "mioku-webui-updater/1.0",
1924
- "--silent",
1925
- "--show-error",
1926
- downloadUrl,
1927
- ],
1928
- {
1929
- stdio: ["ignore", "pipe", "pipe"],
1930
- },
1931
- );
1932
-
1933
- let stderr = "";
1934
- curl.stderr.on("data", (chunk) => {
1935
- stderr += String(chunk);
1936
- });
1914
+ const res = await fetch(downloadUrl, {
1915
+ headers: { "User-Agent": "mioku-webui-updater/1.0" },
1916
+ redirect: "follow",
1917
+ });
1918
+ if (!res.ok || !res.body) {
1919
+ throw new Error(`下载失败: HTTP ${res.status}`);
1920
+ }
1937
1921
 
1938
- curl.on("close", (code) => {
1939
- if (code === 0) {
1940
- resolve();
1941
- } else {
1942
- reject(new Error(`curl 下载失败 (code ${code}): ${stderr}`));
1943
- }
1944
- });
1945
-
1946
- curl.on("error", (err) => {
1947
- reject(new Error(`curl 进程异常: ${err.message}`));
1948
- });
1949
-
1950
- // Periodically log progress by checking file size
1951
- const progressTimer = setInterval(() => {
1952
- if (fs.existsSync(zipPath)) {
1953
- const stat = fs.statSync(zipPath);
1954
- const now = Date.now();
1955
- if (now - lastLogTime >= LOG_INTERVAL_MS) {
1956
- logger.info(
1957
- `[webui] 正在下载 WebUI dist... ${(stat.size / 1024 / 1024).toFixed(1)} MB 已下载`,
1958
- );
1959
- lastLogTime = now;
1960
- }
1922
+ let downloaded = 0;
1923
+ const handle = await fsp.open(zipPath, "w");
1924
+ try {
1925
+ for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
1926
+ await handle.write(chunk);
1927
+ downloaded += chunk.byteLength;
1928
+ const now = Date.now();
1929
+ if (now - lastLogTime >= LOG_INTERVAL_MS) {
1930
+ logger.info(
1931
+ `[webui] 正在下载 WebUI dist... ${(downloaded / 1024 / 1024).toFixed(1)} MB 已下载`,
1932
+ );
1933
+ lastLogTime = now;
1961
1934
  }
1962
- }, 1000);
1963
-
1964
- // Clear timer when done
1965
- curl.on("close", () => clearInterval(progressTimer));
1966
- });
1935
+ }
1936
+ } finally {
1937
+ await handle.close();
1938
+ }
1967
1939
 
1968
1940
  const zipStat = fs.statSync(zipPath);
1969
1941
  logger.info(
@@ -2414,3 +2386,53 @@ export function getAvailablePlugins(): string[] {
2414
2386
  }
2415
2387
  return plugins.sort((a, b) => a.localeCompare(b));
2416
2388
  }
2389
+
2390
+ export interface PluginStatusItem {
2391
+ name: string;
2392
+ enabled: boolean;
2393
+ system: boolean;
2394
+ description: string;
2395
+ }
2396
+
2397
+ export function getPluginStatusList(): PluginStatusItem[] {
2398
+ const allPlugins = getAvailablePlugins();
2399
+ const miokuConfig = getMiokuConfig();
2400
+ const enabledPlugins = new Set(miokuConfig.plugins || []);
2401
+
2402
+ return allPlugins.map((name) => {
2403
+ const fullPath = path.join(NODE_MODULES_DIR, `mioku-plugin-${name}`);
2404
+ const pkg = readPackageJson(fullPath);
2405
+ return {
2406
+ name,
2407
+ enabled: enabledPlugins.has(name),
2408
+ system: SYSTEM_PLUGIN_NAMES.has(name.toLowerCase()),
2409
+ description: pkg?.description ?? "",
2410
+ };
2411
+ });
2412
+ }
2413
+
2414
+ export function togglePlugin(name: string, enabled: boolean): PluginStatusItem {
2415
+ const safeName = String(name || "").trim();
2416
+ if (!safeName) throw new Error("插件名称为空");
2417
+
2418
+ const config = getMiokuConfig();
2419
+ const plugins = new Set(config.plugins || []);
2420
+
2421
+ if (enabled) {
2422
+ plugins.add(safeName);
2423
+ } else {
2424
+ if (safeName.toLowerCase() === "boot") {
2425
+ throw new Error("boot 插件不可关闭");
2426
+ }
2427
+ plugins.delete(safeName);
2428
+ }
2429
+
2430
+ updateMiokuConfig({ ...config, plugins: Array.from(plugins) });
2431
+
2432
+ return {
2433
+ name: safeName,
2434
+ enabled: plugins.has(safeName),
2435
+ system: SYSTEM_PLUGIN_NAMES.has(safeName.toLowerCase()),
2436
+ description: "",
2437
+ };
2438
+ }
package/utils.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { spawn } from "node:child_process";
5
4
  import type { PackageManager, WebUISettings } from "./types";
6
5
  import {
7
6
  getServiceConfigDir,
8
7
  getServiceDataDir,
9
8
  getConfigDir,
10
9
  getDataDir,
10
+ runCommand as runMiokuCommand,
11
11
  } from "mioku";
12
12
 
13
13
  const __filename = fileURLToPath(import.meta.url);
@@ -86,34 +86,7 @@ export async function runCommand(
86
86
  args: string[],
87
87
  cwd: string,
88
88
  ): Promise<{ stdout: string; stderr: string; code: number }> {
89
- return new Promise((resolve) => {
90
- const child = spawn(command, args, {
91
- cwd,
92
- stdio: ["ignore", "pipe", "pipe"],
93
- });
94
- let stdout = "";
95
- let stderr = "";
96
-
97
- child.stdout.on("data", (chunk) => {
98
- stdout += String(chunk);
99
- });
100
-
101
- child.stderr.on("data", (chunk) => {
102
- stderr += String(chunk);
103
- });
104
-
105
- child.on("close", (code) => {
106
- resolve({ stdout, stderr, code: code ?? 1 });
107
- });
108
-
109
- child.on("error", (error) => {
110
- resolve({
111
- stdout,
112
- stderr: `${stderr}\n${error.message}`.trim(),
113
- code: 1,
114
- });
115
- });
116
- });
89
+ return runMiokuCommand(command, args, { cwd });
117
90
  }
118
91
 
119
92
  export function getInstallCommand(): { cmd: string; args: string[] } {