@co0ontty/wand 1.15.1 → 1.17.4

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
@@ -14,10 +14,12 @@ import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
14
14
  import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
15
15
  import { ensureCertificates } from "./cert.js";
16
16
  import { isExecutionMode, normalizeCardDefaults, resolveConfigDir, saveConfig } from "./config.js";
17
+ import { getCachedModels, refreshModels } from "./models.js";
17
18
  import { ProcessManager } from "./process-manager.js";
18
19
  import { StructuredSessionManager } from "./structured-session-manager.js";
19
20
  import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
20
21
  import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
22
+ import { registerUploadRoutes } from "./upload-routes.js";
21
23
  import { resolveDatabasePath, WandStorage } from "./storage.js";
22
24
  import { renderApp } from "./web-ui/index.js";
23
25
  import { WsBroadcastManager } from "./ws-broadcast.js";
@@ -488,9 +490,10 @@ export async function startServer(config, configPath) {
488
490
  app.use(express.json({ limit: "1mb" }));
489
491
  app.use(compression({ threshold: 1024 }));
490
492
  const vendorCacheOpts = { maxAge: "7d", immutable: true };
491
- app.use("/vendor/xterm", express.static(path.join(nodeModulesDir, "@xterm", "xterm"), vendorCacheOpts));
492
- app.use("/vendor/xterm-addon-fit", express.static(path.join(nodeModulesDir, "@xterm", "addon-fit"), vendorCacheOpts));
493
- app.use("/vendor/xterm-addon-serialize", express.static(path.join(nodeModulesDir, "@xterm", "addon-serialize"), vendorCacheOpts));
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));
494
497
  // ── Web UI and PWA endpoints ──
495
498
  app.get("/", (_req, res) => {
496
499
  res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -694,6 +697,10 @@ export async function startServer(config, configPath) {
694
697
  hasCert: existsSync(certPaths.keyPath) && existsSync(certPaths.certPath),
695
698
  updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
696
699
  latestVersion: cachedUpdateInfo?.latest ?? null,
700
+ autoUpdate: {
701
+ web: storage.getConfigValue("autoUpdateWeb") === "true",
702
+ apk: storage.getConfigValue("autoUpdateApk") === "true",
703
+ },
697
704
  androidApk: {
698
705
  enabled: config.android?.enabled === true,
699
706
  apkDir,
@@ -745,7 +752,7 @@ export async function startServer(config, configPath) {
745
752
  });
746
753
  app.post("/api/settings/config", async (req, res) => {
747
754
  const body = req.body;
748
- const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell", "language"];
755
+ const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell", "language", "defaultModel"];
749
756
  let changed = false;
750
757
  for (const field of allowedFields) {
751
758
  if (field in body && body[field] !== undefined) {
@@ -779,6 +786,9 @@ export async function startServer(config, configPath) {
779
786
  else if (field === "language") {
780
787
  config.language = typeof body.language === "string" ? body.language.trim() : "";
781
788
  }
789
+ else if (field === "defaultModel") {
790
+ config.defaultModel = typeof body.defaultModel === "string" ? body.defaultModel.trim() : "";
791
+ }
782
792
  changed = true;
783
793
  }
784
794
  }
@@ -802,6 +812,29 @@ export async function startServer(config, configPath) {
802
812
  res.status(500).json({ error: getErrorMessage(error, "保存配置失败。") });
803
813
  }
804
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
+ });
805
838
  app.post("/api/settings/upload-cert", async (req, res) => {
806
839
  const { key, cert } = req.body;
807
840
  if (!key || !cert) {
@@ -857,6 +890,7 @@ export async function startServer(config, configPath) {
857
890
  });
858
891
  registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode);
859
892
  registerClaudeHistoryRoutes(app, processes, storage);
893
+ registerUploadRoutes(app, processes);
860
894
  // ── Path suggestion ──
861
895
  app.get("/api/path-suggestions", async (req, res) => {
862
896
  const query = typeof req.query.q === "string" ? req.query.q : "";
@@ -872,10 +906,9 @@ export async function startServer(config, configPath) {
872
906
  app.get("/api/directory", async (req, res) => {
873
907
  const q = typeof req.query.q === "string" ? req.query.q : "";
874
908
  const includeGitStatus = req.query.gitStatus === "true";
875
- const targetPath = path.resolve(process.cwd(), q);
876
- const allowedBase = process.cwd();
877
- if (!isPathWithinBase(targetPath, allowedBase)) {
878
- res.status(403).json({ error: "访问被拒绝:路径必须在项目目录内。" });
909
+ const targetPath = path.resolve(q || config.defaultCwd);
910
+ if (isBlockedFolderPath(targetPath)) {
911
+ res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
879
912
  return;
880
913
  }
881
914
  try {
@@ -911,8 +944,7 @@ export async function startServer(config, configPath) {
911
944
  return;
912
945
  }
913
946
  const resolvedPath = path.resolve(filePath);
914
- const allowedBase = process.cwd();
915
- if (!isPathWithinBase(resolvedPath, allowedBase)) {
947
+ if (isBlockedFolderPath(resolvedPath)) {
916
948
  res.status(403).json({ error: "Access denied" });
917
949
  return;
918
950
  }
@@ -1128,9 +1160,12 @@ export async function startServer(config, configPath) {
1128
1160
  }
1129
1161
  const initialInput = body.initialInput?.trim();
1130
1162
  try {
1163
+ const rawModel = typeof body.model === "string" ? body.model.trim() : "";
1164
+ const effectiveModel = rawModel || (config.defaultModel ?? "").trim() || undefined;
1131
1165
  const snapshot = processes.start(body.command, body.cwd, normalizeMode(body.mode, config.defaultMode), initialInput || undefined, {
1132
1166
  worktreeEnabled: body.worktreeEnabled === true,
1133
1167
  provider: body.provider,
1168
+ model: effectiveModel,
1134
1169
  });
1135
1170
  res.status(201).json(snapshot);
1136
1171
  }
@@ -1207,17 +1242,80 @@ export async function startServer(config, configPath) {
1207
1242
  }
1208
1243
  // Start configured background sessions after the server is already reachable.
1209
1244
  processes.runStartupCommands();
1210
- // Background update check on startup
1211
- 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);
1212
1267
  cachedUpdateInfo = info;
1213
- 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
1214
1273
  process.stdout.write(`[wand] 发现新版本 ${info.latest}(当前 ${info.current})。运行 npm install -g ${PKG_NAME}@latest 进行更新。\n`);
1215
- // Broadcast update notification to all connected WS clients
1216
1274
  wsManager.emitEvent({
1217
1275
  type: "notification",
1218
1276
  sessionId: "__system__",
1219
1277
  data: { kind: "update", current: info.current, latest: info.latest },
1220
1278
  });
1279
+ return;
1280
+ }
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`);
1221
1313
  }
1222
- }).catch(() => { });
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);
1223
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. */
@@ -73,11 +73,12 @@ export class StructuredSessionManager {
73
73
  structuredState: {
74
74
  provider: snapshot.structuredState?.provider ?? snapshot.provider ?? "claude",
75
75
  runner: snapshot.runner ?? "claude-cli-print",
76
- model: snapshot.structuredState?.model,
76
+ model: snapshot.structuredState?.model ?? snapshot.selectedModel ?? undefined,
77
77
  lastError: snapshot.structuredState?.lastError ?? null,
78
78
  inFlight: false,
79
79
  activeRequestId: null,
80
80
  },
81
+ selectedModel: snapshot.selectedModel ?? null,
81
82
  };
82
83
  this.sessions.set(restored.id, restored);
83
84
  this.storage.saveSession(restored);
@@ -112,6 +113,7 @@ export class StructuredSessionManager {
112
113
  const worktreeSetup = options.worktreeEnabled
113
114
  ? prepareSessionWorktree({ cwd: options.cwd, sessionId: id })
114
115
  : null;
116
+ const selectedModel = options.model?.trim() || null;
115
117
  const snapshot = {
116
118
  id,
117
119
  sessionKind: "structured",
@@ -135,6 +137,7 @@ export class StructuredSessionManager {
135
137
  structuredState: {
136
138
  provider: "claude",
137
139
  runner: options.runner ?? "claude-cli-print",
140
+ model: selectedModel ?? undefined,
138
141
  inFlight: false,
139
142
  activeRequestId: null,
140
143
  lastError: null,
@@ -142,6 +145,7 @@ export class StructuredSessionManager {
142
145
  autoRecovered: false,
143
146
  autoApprovePermissions: shouldAutoApproveForMode(options.mode),
144
147
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
148
+ selectedModel,
145
149
  };
146
150
  this.sessions.set(id, snapshot);
147
151
  this.storage.saveSession(snapshot);
@@ -262,6 +266,27 @@ export class StructuredSessionManager {
262
266
  denyPermission(sessionId) {
263
267
  return this.resolvePermission(sessionId, false);
264
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
+ }
265
290
  /** Toggle auto-approve for the session. */
266
291
  toggleAutoApprove(sessionId) {
267
292
  const session = this.requireSession(sessionId);
@@ -500,6 +525,10 @@ export class StructuredSessionManager {
500
525
  ? "请使用中文回复。所有解释、注释和对话文本都使用中文。"
501
526
  : `Please respond in ${language}. Use ${language} for all your explanations, comments, and conversational text.`);
502
527
  }
528
+ const modelChoice = session.selectedModel?.trim();
529
+ if (modelChoice && modelChoice !== "default") {
530
+ args.push("--model", modelChoice);
531
+ }
503
532
  if (session.claudeSessionId) {
504
533
  args.push("--resume", session.claudeSessionId);
505
534
  }
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
+ }