@co0ontty/wand 1.15.0 → 1.17.2

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/dist/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import compression from "compression";
2
3
  import express from "express";
3
4
  import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs";
4
5
  import { mkdir, readdir, readFile, stat } from "node:fs/promises";
@@ -13,10 +14,12 @@ import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
13
14
  import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
14
15
  import { ensureCertificates } from "./cert.js";
15
16
  import { isExecutionMode, normalizeCardDefaults, resolveConfigDir, saveConfig } from "./config.js";
17
+ import { getCachedModels, refreshModels } from "./models.js";
16
18
  import { ProcessManager } from "./process-manager.js";
17
19
  import { StructuredSessionManager } from "./structured-session-manager.js";
18
20
  import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
19
21
  import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
22
+ import { registerUploadRoutes } from "./upload-routes.js";
20
23
  import { resolveDatabasePath, WandStorage } from "./storage.js";
21
24
  import { renderApp } from "./web-ui/index.js";
22
25
  import { WsBroadcastManager } from "./ws-broadcast.js";
@@ -485,9 +488,12 @@ export async function startServer(config, configPath) {
485
488
  const protocol = useHttps ? "https" : "http";
486
489
  const nodeModulesDir = path.join(RUNTIME_ROOT_DIR, "node_modules");
487
490
  app.use(express.json({ limit: "1mb" }));
488
- app.use("/vendor/xterm", express.static(path.join(nodeModulesDir, "@xterm", "xterm")));
489
- app.use("/vendor/xterm-addon-fit", express.static(path.join(nodeModulesDir, "@xterm", "addon-fit")));
490
- app.use("/vendor/xterm-addon-serialize", express.static(path.join(nodeModulesDir, "@xterm", "addon-serialize")));
491
+ app.use(compression({ threshold: 1024 }));
492
+ const vendorCacheOpts = { maxAge: "7d", immutable: true };
493
+ const contentDir = existsSync(path.join(SERVER_MODULE_DIR, "web-ui", "content"))
494
+ ? path.join(SERVER_MODULE_DIR, "web-ui", "content")
495
+ : path.join(RUNTIME_ROOT_DIR, "src", "web-ui", "content");
496
+ app.use("/vendor/wterm", express.static(path.join(contentDir, "vendor", "wterm"), vendorCacheOpts));
491
497
  // ── Web UI and PWA endpoints ──
492
498
  app.get("/", (_req, res) => {
493
499
  res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -691,6 +697,10 @@ export async function startServer(config, configPath) {
691
697
  hasCert: existsSync(certPaths.keyPath) && existsSync(certPaths.certPath),
692
698
  updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
693
699
  latestVersion: cachedUpdateInfo?.latest ?? null,
700
+ autoUpdate: {
701
+ web: storage.getConfigValue("autoUpdateWeb") === "true",
702
+ apk: storage.getConfigValue("autoUpdateApk") === "true",
703
+ },
694
704
  androidApk: {
695
705
  enabled: config.android?.enabled === true,
696
706
  apkDir,
@@ -742,7 +752,7 @@ export async function startServer(config, configPath) {
742
752
  });
743
753
  app.post("/api/settings/config", async (req, res) => {
744
754
  const body = req.body;
745
- const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell", "language"];
755
+ const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell", "language", "defaultModel"];
746
756
  let changed = false;
747
757
  for (const field of allowedFields) {
748
758
  if (field in body && body[field] !== undefined) {
@@ -776,6 +786,9 @@ export async function startServer(config, configPath) {
776
786
  else if (field === "language") {
777
787
  config.language = typeof body.language === "string" ? body.language.trim() : "";
778
788
  }
789
+ else if (field === "defaultModel") {
790
+ config.defaultModel = typeof body.defaultModel === "string" ? body.defaultModel.trim() : "";
791
+ }
779
792
  changed = true;
780
793
  }
781
794
  }
@@ -799,6 +812,29 @@ export async function startServer(config, configPath) {
799
812
  res.status(500).json({ error: getErrorMessage(error, "保存配置失败。") });
800
813
  }
801
814
  });
815
+ app.get("/api/models", (_req, res) => {
816
+ const cached = getCachedModels();
817
+ res.json({
818
+ models: cached.models,
819
+ claudeVersion: cached.claudeVersion,
820
+ refreshedAt: cached.refreshedAt,
821
+ defaultModel: config.defaultModel ?? "",
822
+ });
823
+ });
824
+ app.post("/api/models/refresh", async (_req, res) => {
825
+ try {
826
+ const refreshed = await refreshModels();
827
+ res.json({
828
+ models: refreshed.models,
829
+ claudeVersion: refreshed.claudeVersion,
830
+ refreshedAt: refreshed.refreshedAt,
831
+ defaultModel: config.defaultModel ?? "",
832
+ });
833
+ }
834
+ catch (error) {
835
+ res.status(500).json({ error: getErrorMessage(error, "刷新模型列表失败。") });
836
+ }
837
+ });
802
838
  app.post("/api/settings/upload-cert", async (req, res) => {
803
839
  const { key, cert } = req.body;
804
840
  if (!key || !cert) {
@@ -854,6 +890,7 @@ export async function startServer(config, configPath) {
854
890
  });
855
891
  registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode);
856
892
  registerClaudeHistoryRoutes(app, processes, storage);
893
+ registerUploadRoutes(app, processes);
857
894
  // ── Path suggestion ──
858
895
  app.get("/api/path-suggestions", async (req, res) => {
859
896
  const query = typeof req.query.q === "string" ? req.query.q : "";
@@ -869,10 +906,9 @@ export async function startServer(config, configPath) {
869
906
  app.get("/api/directory", async (req, res) => {
870
907
  const q = typeof req.query.q === "string" ? req.query.q : "";
871
908
  const includeGitStatus = req.query.gitStatus === "true";
872
- const targetPath = path.resolve(process.cwd(), q);
873
- const allowedBase = process.cwd();
874
- if (!isPathWithinBase(targetPath, allowedBase)) {
875
- res.status(403).json({ error: "访问被拒绝:路径必须在项目目录内。" });
909
+ const targetPath = path.resolve(q || config.defaultCwd);
910
+ if (isBlockedFolderPath(targetPath)) {
911
+ res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
876
912
  return;
877
913
  }
878
914
  try {
@@ -908,8 +944,7 @@ export async function startServer(config, configPath) {
908
944
  return;
909
945
  }
910
946
  const resolvedPath = path.resolve(filePath);
911
- const allowedBase = process.cwd();
912
- if (!isPathWithinBase(resolvedPath, allowedBase)) {
947
+ if (isBlockedFolderPath(resolvedPath)) {
913
948
  res.status(403).json({ error: "Access denied" });
914
949
  return;
915
950
  }
@@ -1125,9 +1160,12 @@ export async function startServer(config, configPath) {
1125
1160
  }
1126
1161
  const initialInput = body.initialInput?.trim();
1127
1162
  try {
1163
+ const rawModel = typeof body.model === "string" ? body.model.trim() : "";
1164
+ const effectiveModel = rawModel || (config.defaultModel ?? "").trim() || undefined;
1128
1165
  const snapshot = processes.start(body.command, body.cwd, normalizeMode(body.mode, config.defaultMode), initialInput || undefined, {
1129
1166
  worktreeEnabled: body.worktreeEnabled === true,
1130
1167
  provider: body.provider,
1168
+ model: effectiveModel,
1131
1169
  });
1132
1170
  res.status(201).json(snapshot);
1133
1171
  }
@@ -1142,7 +1180,15 @@ export async function startServer(config, configPath) {
1142
1180
  return createHttpsServer({ key: ssl.key, cert: ssl.cert }, app);
1143
1181
  })()
1144
1182
  : createHttpServer(app);
1145
- const wss = new WebSocketServer({ server, path: "/ws" });
1183
+ const wss = new WebSocketServer({
1184
+ server,
1185
+ path: "/ws",
1186
+ perMessageDeflate: {
1187
+ zlibDeflateOptions: { level: 1 },
1188
+ threshold: 512,
1189
+ concurrencyLimit: 10,
1190
+ },
1191
+ });
1146
1192
  const wsManager = new WsBroadcastManager(wss, () => config.cardDefaults ?? {});
1147
1193
  wsManager.setup((id) => structuredSessions.get(id) ?? processes.get(id));
1148
1194
  // Wire process events to WebSocket broadcast
@@ -1196,17 +1242,80 @@ export async function startServer(config, configPath) {
1196
1242
  }
1197
1243
  // Start configured background sessions after the server is already reachable.
1198
1244
  processes.runStartupCommands();
1199
- // Background update check on startup
1200
- checkNpmLatestVersion().then((info) => {
1245
+ // ── Auto-update endpoints ──
1246
+ app.get("/api/auto-update", (_req, res) => {
1247
+ const web = storage.getConfigValue("autoUpdateWeb") === "true";
1248
+ const apk = storage.getConfigValue("autoUpdateApk") === "true";
1249
+ res.json({ web, apk });
1250
+ });
1251
+ app.post("/api/auto-update", express.json(), (req, res) => {
1252
+ const { web, apk } = req.body;
1253
+ if (typeof web === "boolean") {
1254
+ storage.setConfigValue("autoUpdateWeb", String(web));
1255
+ }
1256
+ if (typeof apk === "boolean") {
1257
+ storage.setConfigValue("autoUpdateApk", String(apk));
1258
+ }
1259
+ res.json({
1260
+ web: storage.getConfigValue("autoUpdateWeb") === "true",
1261
+ apk: storage.getConfigValue("autoUpdateApk") === "true",
1262
+ });
1263
+ });
1264
+ // ── Auto-update logic ──
1265
+ async function performAutoUpdate() {
1266
+ const info = await checkNpmLatestVersion(true);
1201
1267
  cachedUpdateInfo = info;
1202
- if (info.updateAvailable) {
1268
+ if (!info.updateAvailable)
1269
+ return;
1270
+ const autoEnabled = storage.getConfigValue("autoUpdateWeb") === "true";
1271
+ if (!autoEnabled) {
1272
+ // Not auto-updating, just notify
1203
1273
  process.stdout.write(`[wand] 发现新版本 ${info.latest}(当前 ${info.current})。运行 npm install -g ${PKG_NAME}@latest 进行更新。\n`);
1204
- // Broadcast update notification to all connected WS clients
1205
1274
  wsManager.emitEvent({
1206
1275
  type: "notification",
1207
1276
  sessionId: "__system__",
1208
1277
  data: { kind: "update", current: info.current, latest: info.latest },
1209
1278
  });
1279
+ return;
1210
1280
  }
1211
- }).catch(() => { });
1281
+ // Auto-update: install and restart
1282
+ process.stdout.write(`[wand] 自动更新:正在从 ${info.current} 更新到 ${info.latest}...\n`);
1283
+ wsManager.emitEvent({
1284
+ type: "notification",
1285
+ sessionId: "__system__",
1286
+ data: { kind: "auto-update-start", current: info.current, latest: info.latest },
1287
+ });
1288
+ try {
1289
+ await execAsync(`npm install -g ${PKG_NAME}@latest`, { timeout: 120000 });
1290
+ process.stdout.write(`[wand] 自动更新完成,正在重启...\n`);
1291
+ wsManager.emitEvent({
1292
+ type: "notification",
1293
+ sessionId: "__system__",
1294
+ data: { kind: "auto-update-restart", current: info.current, latest: info.latest },
1295
+ });
1296
+ // Restart after a brief delay
1297
+ setTimeout(() => {
1298
+ wss.clients.forEach((client) => client.close());
1299
+ server.close(() => {
1300
+ spawn(process.execPath, process.argv.slice(1), {
1301
+ detached: true,
1302
+ stdio: "inherit",
1303
+ cwd: process.cwd(),
1304
+ env: process.env,
1305
+ }).unref();
1306
+ process.exit(0);
1307
+ });
1308
+ setTimeout(() => process.exit(0), 5000);
1309
+ }, 1000);
1310
+ }
1311
+ catch (error) {
1312
+ process.stdout.write(`[wand] 自动更新失败: ${getErrorMessage(error, "未知错误")}\n`);
1313
+ }
1314
+ }
1315
+ // Background update check on startup
1316
+ performAutoUpdate().catch(() => { });
1317
+ // Periodic update check (every 30 minutes)
1318
+ setInterval(() => {
1319
+ performAutoUpdate().catch(() => { });
1320
+ }, 30 * 60 * 1000);
1212
1321
  }
@@ -6,6 +6,8 @@ interface CreateStructuredSessionOptions {
6
6
  prompt?: string;
7
7
  runner?: SessionRunner;
8
8
  worktreeEnabled?: boolean;
9
+ /** 用户指定的 Claude 模型(别名或完整 ID)。留空则 spawn 时不加 --model。 */
10
+ model?: string;
9
11
  }
10
12
  export declare class StructuredSessionManager {
11
13
  private readonly storage;
@@ -25,6 +27,8 @@ export declare class StructuredSessionManager {
25
27
  approvePermission(sessionId: string): SessionSnapshot;
26
28
  /** Deny a pending permission request. */
27
29
  denyPermission(sessionId: string): SessionSnapshot;
30
+ /** Update the selected model for a structured session. Takes effect on the next spawn. */
31
+ setSessionModel(sessionId: string, model: string | null): SessionSnapshot;
28
32
  /** Toggle auto-approve for the session. */
29
33
  toggleAutoApprove(sessionId: string): SessionSnapshot;
30
34
  /** Resolve a specific escalation by requestId. */
@@ -35,6 +35,17 @@ function buildStructuredOutputPayload(snapshot) {
35
35
  structuredState: snapshot.structuredState,
36
36
  };
37
37
  }
38
+ function buildIncrementalStructuredPayload(snapshot) {
39
+ const messages = snapshot.messages ?? [];
40
+ return {
41
+ incremental: true,
42
+ queuedMessages: snapshot.queuedMessages,
43
+ sessionKind: "structured",
44
+ structuredState: snapshot.structuredState,
45
+ lastMessage: messages.length > 0 ? messages[messages.length - 1] : undefined,
46
+ messageCount: messages.length,
47
+ };
48
+ }
38
49
  export class StructuredSessionManager {
39
50
  storage;
40
51
  config;
@@ -62,11 +73,12 @@ export class StructuredSessionManager {
62
73
  structuredState: {
63
74
  provider: snapshot.structuredState?.provider ?? snapshot.provider ?? "claude",
64
75
  runner: snapshot.runner ?? "claude-cli-print",
65
- model: snapshot.structuredState?.model,
76
+ model: snapshot.structuredState?.model ?? snapshot.selectedModel ?? undefined,
66
77
  lastError: snapshot.structuredState?.lastError ?? null,
67
78
  inFlight: false,
68
79
  activeRequestId: null,
69
80
  },
81
+ selectedModel: snapshot.selectedModel ?? null,
70
82
  };
71
83
  this.sessions.set(restored.id, restored);
72
84
  this.storage.saveSession(restored);
@@ -101,6 +113,7 @@ export class StructuredSessionManager {
101
113
  const worktreeSetup = options.worktreeEnabled
102
114
  ? prepareSessionWorktree({ cwd: options.cwd, sessionId: id })
103
115
  : null;
116
+ const selectedModel = options.model?.trim() || null;
104
117
  const snapshot = {
105
118
  id,
106
119
  sessionKind: "structured",
@@ -124,6 +137,7 @@ export class StructuredSessionManager {
124
137
  structuredState: {
125
138
  provider: "claude",
126
139
  runner: options.runner ?? "claude-cli-print",
140
+ model: selectedModel ?? undefined,
127
141
  inFlight: false,
128
142
  activeRequestId: null,
129
143
  lastError: null,
@@ -131,6 +145,7 @@ export class StructuredSessionManager {
131
145
  autoRecovered: false,
132
146
  autoApprovePermissions: shouldAutoApproveForMode(options.mode),
133
147
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
148
+ selectedModel,
134
149
  };
135
150
  this.sessions.set(id, snapshot);
136
151
  this.storage.saveSession(snapshot);
@@ -251,6 +266,27 @@ export class StructuredSessionManager {
251
266
  denyPermission(sessionId) {
252
267
  return this.resolvePermission(sessionId, false);
253
268
  }
269
+ /** Update the selected model for a structured session. Takes effect on the next spawn. */
270
+ setSessionModel(sessionId, model) {
271
+ const session = this.requireSession(sessionId);
272
+ const normalized = model?.trim() || null;
273
+ const updated = {
274
+ ...session,
275
+ selectedModel: normalized,
276
+ structuredState: {
277
+ ...(session.structuredState ?? { provider: "claude", runner: "claude-cli-print", inFlight: false, activeRequestId: null, lastError: null }),
278
+ model: normalized ?? undefined,
279
+ },
280
+ };
281
+ this.sessions.set(sessionId, updated);
282
+ this.storage.saveSession(updated);
283
+ this.emit({
284
+ type: "status",
285
+ sessionId,
286
+ data: { sessionKind: "structured", selectedModel: normalized, structuredState: updated.structuredState },
287
+ });
288
+ return updated;
289
+ }
254
290
  /** Toggle auto-approve for the session. */
255
291
  toggleAutoApprove(sessionId) {
256
292
  const session = this.requireSession(sessionId);
@@ -474,14 +510,24 @@ export class StructuredSessionManager {
474
510
  // Add permission args based on mode + autoApprovePermissions toggle
475
511
  const permArgs = this.buildPermissionArgs(session.mode, session.autoApprovePermissions ?? false);
476
512
  args.push(...permArgs);
513
+ // Append language-aware system prompts
514
+ const language = this.config.language?.trim();
515
+ const isChinese = language === "中文";
477
516
  // In managed mode, append autonomous system prompt
478
517
  if (session.mode === "managed") {
479
- args.push("--append-system-prompt", "You are running in a fully managed, autonomous mode. The user may not be available to respond to questions or confirmations in a timely manner. You MUST make all decisions independently — choose the best approach yourself instead of asking the user for preferences, confirmations, or clarifications. If multiple approaches are viable, pick the one you judge most appropriate and proceed. Never block on user input unless the task is fundamentally ambiguous and cannot be reasonably inferred. Be decisive and self-directed.");
518
+ args.push("--append-system-prompt", isChinese
519
+ ? "你正在完全托管的自主模式下运行。用户可能无法及时回复问题或确认。你必须独立做出所有决策——自行选择最佳方案,而不是向用户询问偏好、确认或澄清。如果有多种可行方案,选择你认为最合适的并继续执行。除非任务本身存在根本性的歧义且无法合理推断,否则不要等待用户输入。果断行动,自主决策。"
520
+ : "You are running in a fully managed, autonomous mode. The user may not be available to respond to questions or confirmations in a timely manner. You MUST make all decisions independently — choose the best approach yourself instead of asking the user for preferences, confirmations, or clarifications. If multiple approaches are viable, pick the one you judge most appropriate and proceed. Never block on user input unless the task is fundamentally ambiguous and cannot be reasonably inferred. Be decisive and self-directed.");
480
521
  }
481
522
  // Append language preference if configured
482
- const language = this.config.language?.trim();
483
523
  if (language) {
484
- args.push("--append-system-prompt", `Please respond in ${language}. Use ${language} for all your explanations, comments, and conversational text.`);
524
+ args.push("--append-system-prompt", isChinese
525
+ ? "请使用中文回复。所有解释、注释和对话文本都使用中文。"
526
+ : `Please respond in ${language}. Use ${language} for all your explanations, comments, and conversational text.`);
527
+ }
528
+ const modelChoice = session.selectedModel?.trim();
529
+ if (modelChoice && modelChoice !== "default") {
530
+ args.push("--model", modelChoice);
485
531
  }
486
532
  if (session.claudeSessionId) {
487
533
  args.push("--resume", session.claudeSessionId);
@@ -516,7 +562,7 @@ export class StructuredSessionManager {
516
562
  this.emit({
517
563
  type: "output",
518
564
  sessionId,
519
- data: buildStructuredOutputPayload(current),
565
+ data: buildIncrementalStructuredPayload(current),
520
566
  });
521
567
  };
522
568
  const scheduleEmit = () => {
package/dist/types.d.ts CHANGED
@@ -86,6 +86,18 @@ export interface WandConfig {
86
86
  android?: AndroidApkConfig;
87
87
  /** Default expand/collapse state for card types in structured chat view */
88
88
  cardDefaults?: CardExpandDefaults;
89
+ /** 新建会话时默认使用的 Claude 模型(别名或完整 ID)。留空则不传 --model,由 claude 自行决定。 */
90
+ defaultModel?: string;
91
+ }
92
+ export interface ClaudeModelInfo {
93
+ /** 传给 --model 的值(别名或完整模型 ID) */
94
+ id: string;
95
+ /** UI 显示的友好标签 */
96
+ label: string;
97
+ /** 可选备注:例如 "当前默认"、"最新" */
98
+ note?: string;
99
+ /** 是否为别名(opus/sonnet 等);完整 ID 为 false */
100
+ alias?: boolean;
89
101
  }
90
102
  interface WorktreeInfo {
91
103
  branch: string;
@@ -130,6 +142,8 @@ export interface CommandRequest {
130
142
  mode?: ExecutionMode;
131
143
  initialInput?: string;
132
144
  worktreeEnabled?: boolean;
145
+ /** Claude 模型(别名或完整 ID)。仅对 claude provider 生效。留空则回落到 config.defaultModel。 */
146
+ model?: string;
133
147
  }
134
148
  export interface InputRequest {
135
149
  input?: string;
@@ -273,6 +287,8 @@ export interface SessionSnapshot {
273
287
  summary?: string;
274
288
  /** 当前正在执行的任务标题(用于会话列表展示) */
275
289
  currentTaskTitle?: string;
290
+ /** 用户为此会话选定的 Claude 模型(别名或完整 ID)。结构化会话下次 spawn 时使用;PTY 会话仅用于展示。 */
291
+ selectedModel?: string | null;
276
292
  }
277
293
  export type SessionLifecycleState = "initializing" | "running" | "idle" | "thinking" | "waiting-input" | "archived";
278
294
  export interface SessionLifecycle {
@@ -0,0 +1,3 @@
1
+ import type { Express } from "express";
2
+ import type { ProcessManager } from "./process-manager.js";
3
+ export declare function registerUploadRoutes(app: Express, processes: ProcessManager): void;
@@ -0,0 +1,53 @@
1
+ import { mkdirSync, existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import multer from "multer";
5
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
6
+ const MAX_FILES = 5;
7
+ function sanitizeFilename(name) {
8
+ return name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 200);
9
+ }
10
+ export function registerUploadRoutes(app, processes) {
11
+ const storage = multer.diskStorage({
12
+ destination(_req, _file, cb) {
13
+ const sessionId = _req.params?.id;
14
+ const session = sessionId ? processes.get(sessionId) : null;
15
+ const cwd = session?.cwd || "/tmp";
16
+ const uploadDir = path.join(cwd, ".wand-uploads");
17
+ if (!existsSync(uploadDir)) {
18
+ mkdirSync(uploadDir, { recursive: true });
19
+ }
20
+ cb(null, uploadDir);
21
+ },
22
+ filename(_req, file, cb) {
23
+ const ts = Date.now();
24
+ const rand = randomBytes(4).toString("hex");
25
+ const safe = sanitizeFilename(file.originalname);
26
+ cb(null, `${ts}-${rand}-${safe}`);
27
+ },
28
+ });
29
+ const upload = multer({
30
+ storage,
31
+ limits: { fileSize: MAX_FILE_SIZE, files: MAX_FILES },
32
+ });
33
+ app.post("/api/sessions/:id/upload", upload.array("files", MAX_FILES), (req, res) => {
34
+ const sessionId = req.params.id;
35
+ const session = processes.get(sessionId);
36
+ if (!session) {
37
+ res.status(404).json({ error: "会话不存在。" });
38
+ return;
39
+ }
40
+ const files = req.files || [];
41
+ if (files.length === 0) {
42
+ res.status(400).json({ error: "未收到文件。" });
43
+ return;
44
+ }
45
+ const result = files.map((f) => ({
46
+ originalName: f.originalname,
47
+ savedPath: f.path,
48
+ size: f.size,
49
+ mimeType: f.mimetype,
50
+ }));
51
+ res.json({ files: result });
52
+ });
53
+ }