@swimmingliu/autovpn 1.6.1 → 1.6.3

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.
@@ -71,6 +71,24 @@ function emitEvent(callback, eventType, payload) {
71
71
  callback(eventType, payload);
72
72
  }
73
73
  }
74
+ async function mapWithConcurrency(items, concurrency, worker, onComplete) {
75
+ const limit = Math.max(1, Math.trunc(Number(concurrency) || 1));
76
+ const results = new Array(items.length);
77
+ let nextIndex = 0;
78
+ let completed = 0;
79
+ async function runWorker() {
80
+ while (nextIndex < items.length) {
81
+ const index = nextIndex;
82
+ nextIndex += 1;
83
+ const result = await worker(items[index], index);
84
+ results[index] = result;
85
+ completed += 1;
86
+ onComplete?.(result, index, completed);
87
+ }
88
+ }
89
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runWorker()));
90
+ return results;
91
+ }
74
92
  function defaultNow() {
75
93
  return performance.now();
76
94
  }
@@ -84,26 +102,52 @@ async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
84
102
  clearTimeout(timer);
85
103
  }
86
104
  }
87
- async function readResponseBytes(response, maxBytes) {
105
+ async function withBodyTimeout(operation, timeoutMs) {
106
+ let timer;
107
+ try {
108
+ return await Promise.race([
109
+ operation(),
110
+ new Promise((_resolve, reject) => {
111
+ timer = setTimeout(() => reject(new Error(`response body timed out after ${timeoutMs}ms`)), timeoutMs);
112
+ })
113
+ ]);
114
+ }
115
+ finally {
116
+ if (timer) {
117
+ clearTimeout(timer);
118
+ }
119
+ }
120
+ }
121
+ async function readResponseBytes(response, maxBytes, timeoutMs) {
88
122
  if (response.body?.getReader) {
89
123
  const reader = response.body.getReader();
90
- let total = 0;
124
+ let timedOut = false;
91
125
  try {
92
- while (total < maxBytes) {
93
- const { done, value } = await reader.read();
94
- if (done) {
95
- break;
126
+ return await withBodyTimeout(async () => {
127
+ let total = 0;
128
+ while (total < maxBytes) {
129
+ const { done, value } = await reader.read();
130
+ if (done) {
131
+ break;
132
+ }
133
+ total += Math.min(value.byteLength, maxBytes - total);
96
134
  }
97
- total += Math.min(value.byteLength, maxBytes - total);
98
- }
135
+ return total;
136
+ }, timeoutMs);
137
+ }
138
+ catch (error) {
139
+ timedOut = error instanceof Error && error.message.includes('response body timed out');
140
+ throw error;
99
141
  }
100
142
  finally {
143
+ if (timedOut) {
144
+ await reader.cancel().catch(() => { });
145
+ }
101
146
  reader.releaseLock();
102
147
  }
103
- return total;
104
148
  }
105
149
  if (response.arrayBuffer) {
106
- return Math.min((await response.arrayBuffer()).byteLength, maxBytes);
150
+ return await withBodyTimeout(async () => Math.min((await response.arrayBuffer()).byteLength, maxBytes), timeoutMs);
107
151
  }
108
152
  return 0;
109
153
  }
@@ -301,26 +345,23 @@ async function probeLinksDirect(links, config, options) {
301
345
  }
302
346
  const now = options.now ?? defaultNow;
303
347
  const timeoutMs = Math.max(1, Number(config.timeout_seconds)) * 1000;
304
- const results = [];
305
- for (const link of links) {
348
+ return mapWithConcurrency(links, config.concurrency, async (link) => {
306
349
  const started = now();
307
350
  try {
308
351
  const response = await fetchWithTimeout(fetchImpl, config.probe_url, timeoutMs);
309
352
  const elapsed = Math.max(now() - started, 1);
310
353
  requireOkResponse(response, new Set([200, 204]));
311
- results.push({ link, reachable: true, latency_ms: Math.max(Math.round(elapsed), 1), error: '' });
354
+ return { link, reachable: true, latency_ms: Math.max(Math.round(elapsed), 1), error: '' };
312
355
  }
313
356
  catch (error) {
314
- results.push({ link, reachable: false, latency_ms: 0, error: error instanceof Error ? error.message : String(error) });
357
+ return { link, reachable: false, latency_ms: 0, error: error instanceof Error ? error.message : String(error) };
315
358
  }
316
- }
317
- return results;
359
+ });
318
360
  }
319
361
  async function probeLinksMihomo(links, config, runtimePath, options) {
320
362
  const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
321
363
  const probeDelay = options.probeMihomoProxyDelay ?? defaultProbeMihomoProxyDelay;
322
- const results = [];
323
- for (const link of links) {
364
+ return mapWithConcurrency(links, config.concurrency, async (link) => {
324
365
  let runtime;
325
366
  try {
326
367
  runtime = await openRuntime(link, {
@@ -329,16 +370,15 @@ async function probeLinksMihomo(links, config, runtimePath, options) {
329
370
  env: options.env
330
371
  });
331
372
  const latencyMs = await probeDelay(runtime.controllerUrl, runtime.proxyName, config.probe_url, config.timeout_seconds);
332
- results.push({ link, reachable: true, latency_ms: latencyMs, error: '' });
373
+ return { link, reachable: true, latency_ms: latencyMs, error: '' };
333
374
  }
334
375
  catch (error) {
335
- results.push({ link, reachable: false, latency_ms: 0, error: error instanceof Error ? error.message : String(error) });
376
+ return { link, reachable: false, latency_ms: 0, error: error instanceof Error ? error.message : String(error) };
336
377
  }
337
378
  finally {
338
379
  await runtime?.close();
339
380
  }
340
- }
341
- return results;
381
+ });
342
382
  }
343
383
  async function testLinkDirect(link, config, options) {
344
384
  const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
@@ -354,7 +394,7 @@ async function testLinkDirect(link, config, options) {
354
394
  try {
355
395
  const response = await fetchWithTimeout(fetchImpl, url, timeoutMs);
356
396
  requireOkResponse(response, new Set([200]));
357
- const total = await readResponseBytes(response, Math.max(1, Number(config.max_download_bytes)));
397
+ const total = await readResponseBytes(response, Math.max(1, Number(config.max_download_bytes)), timeoutMs);
358
398
  const elapsedSeconds = Math.max((now() - started) / 1000, 0.001);
359
399
  speedValues.push(total / elapsedSeconds / 1024 / 1024);
360
400
  }
@@ -440,7 +480,7 @@ async function speedtestInNode(input, options) {
440
480
  const config = normalizeSpeedTestConfig(input.config);
441
481
  const runtimePath = input.runtime_path ?? '';
442
482
  const requestedRuntime = String((options.env ?? process.env).AUTOVPN_SPEEDTEST_RUNTIME ?? '').trim().toLowerCase();
443
- const useMihomoRuntime = requestedRuntime === 'mihomo';
483
+ const useMihomoRuntime = requestedRuntime !== 'direct';
444
484
  const probeLinks = options.probeLinks ?? ((links) => (useMihomoRuntime
445
485
  ? probeLinksMihomo(links, config, runtimePath, options)
446
486
  : probeLinksDirect(links, config, options)));
@@ -474,13 +514,10 @@ async function speedtestInNode(input, options) {
474
514
  latency_ms: probe.latency_ms,
475
515
  error: probe.error ?? ''
476
516
  }));
477
- for (let index = 0; index < candidateLinks.length; index += 1) {
478
- const result = await testLink(candidateLinks[index], config, { runtime_path: runtimePath });
517
+ const testedResults = await mapWithConcurrency(candidateLinks, config.concurrency, async (link) => (testLink(link, config, { runtime_path: runtimePath })), (result, _index, completed) => {
479
518
  if (result.reachable && result.latency_ms <= 0) {
480
519
  result.latency_ms = probeByLink.get(result.link)?.latency_ms ?? 0;
481
520
  }
482
- results.push(result);
483
- const completed = index + 1;
484
521
  options.progressCallback?.(`[speedtest] ${completed}/${candidateSet.size} reachable=${result.reachable} speed=${result.average_download_mb_s}MB/s`);
485
522
  emitEvent(options.eventCallback, 'speedtest_result', {
486
523
  completed,
@@ -492,15 +529,14 @@ async function speedtestInNode(input, options) {
492
529
  passed_threshold: result.reachable && result.average_download_mb_s >= config.min_download_mb_s,
493
530
  error: result.error ?? ''
494
531
  });
495
- }
532
+ });
533
+ results.push(...testedResults);
496
534
  return results;
497
535
  }
498
536
  export function selectPipelineStageBackend(stage, env = process.env) {
499
- const stageKey = `AUTOVPN_STAGE_BACKEND_${stage.toUpperCase()}`;
500
- const stageOverride = String(env[stageKey] ?? '').trim().toLowerCase();
501
- const pipelineOverride = String(env.AUTOVPN_PIPELINE_BACKEND ?? '').trim().toLowerCase();
502
- const selected = stageOverride || pipelineOverride || 'node';
503
- return selected === 'python' ? 'python' : 'node';
537
+ void stage;
538
+ void env;
539
+ return 'node';
504
540
  }
505
541
  async function defaultResolvePythonCli(env) {
506
542
  // @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
@@ -561,7 +597,7 @@ export async function probeSpeedtestLinksInNode(input, options = {}) {
561
597
  const config = normalizeSpeedTestConfig(input.config);
562
598
  const runtimePath = input.runtime_path ?? '';
563
599
  const requestedRuntime = String((options.env ?? process.env).AUTOVPN_SPEEDTEST_RUNTIME ?? '').trim().toLowerCase();
564
- const useMihomoRuntime = requestedRuntime === 'mihomo';
600
+ const useMihomoRuntime = requestedRuntime !== 'direct';
565
601
  const probeLinks = options.probeLinks ?? ((links) => (useMihomoRuntime
566
602
  ? probeLinksMihomo(links, config, runtimePath, options)
567
603
  : probeLinksDirect(links, config, options)));
@@ -571,7 +607,7 @@ export async function testSpeedtestLinkInNode(input, options = {}) {
571
607
  const config = normalizeSpeedTestConfig(input.config);
572
608
  const runtimePath = input.runtime_path ?? '';
573
609
  const requestedRuntime = String((options.env ?? process.env).AUTOVPN_SPEEDTEST_RUNTIME ?? '').trim().toLowerCase();
574
- const useMihomoRuntime = requestedRuntime === 'mihomo';
610
+ const useMihomoRuntime = requestedRuntime !== 'direct';
575
611
  const testLink = options.testLink ?? ((link) => (useMihomoRuntime
576
612
  ? testLinkMihomo(link, config, runtimePath, options)
577
613
  : testLinkDirect(link, config, options)));
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { access, mkdir } from 'node:fs/promises';
4
4
  import { constants } from 'node:fs';
5
5
  import { resolveUserRuntimeRoot } from './paths.js';
6
+ const DEFAULT_MANAGED_NPM_REGISTRY = 'https://registry.npmmirror.com';
6
7
  export class ManagedToolError extends Error {
7
8
  code;
8
9
  constructor(message, code = 'MANAGED_TOOL_ERROR') {
@@ -98,9 +99,12 @@ async function installManagedTool(packageName, version, installDir, runCommand)
98
99
  await mkdir(installDir, { recursive: true });
99
100
  let result;
100
101
  try {
101
- result = await runCommand(['npm', 'install', '--no-save', '--no-audit', '--no-fund', `${packageName}@${version}`], {
102
+ result = await runCommand(['npm', '--prefix', installDir, 'install', '--no-save', '--no-audit', '--no-fund', `${packageName}@${version}`], {
102
103
  cwd: installDir,
103
- env: { NPM_CONFIG_YES: 'true' }
104
+ env: {
105
+ NPM_CONFIG_YES: 'true',
106
+ NPM_CONFIG_REGISTRY: resolveManagedNpmRegistry()
107
+ }
104
108
  });
105
109
  }
106
110
  catch (error) {
@@ -110,6 +114,9 @@ async function installManagedTool(packageName, version, installDir, runCommand)
110
114
  throw new ManagedToolError(`npm install failed for ${packageName}@${version}: ${safeCommandMessage(result)}`, 'MANAGED_TOOL_INSTALL_FAILED');
111
115
  }
112
116
  }
117
+ function resolveManagedNpmRegistry(env = process.env) {
118
+ return String(env.NPM_CONFIG_REGISTRY || env.npm_config_registry || DEFAULT_MANAGED_NPM_REGISTRY);
119
+ }
113
120
  async function verifyToolVersion(binaryPath, cwd, runCommand, source) {
114
121
  let result;
115
122
  try {
@@ -241,14 +241,17 @@ export async function createAutoVpnServer(options) {
241
241
  const originHost = options.host === '0.0.0.0' ? '127.0.0.1' : options.host;
242
242
  return {
243
243
  origin: `http://${originHost}:${port}`,
244
- close: () => new Promise((resolve, reject) => {
245
- server.close((error) => {
246
- if (error) {
247
- reject(error);
248
- return;
249
- }
250
- resolve();
244
+ close: async () => {
245
+ await options.runtime.close?.();
246
+ await new Promise((resolve, reject) => {
247
+ server.close((error) => {
248
+ if (error) {
249
+ reject(error);
250
+ return;
251
+ }
252
+ resolve();
253
+ });
251
254
  });
252
- })
255
+ }
253
256
  };
254
257
  }
@@ -136,6 +136,18 @@ function latestJob(projectRoot, env) {
136
136
  return undefined;
137
137
  }
138
138
  }
139
+ function latestJobEvents(job, maxEvents = 1000) {
140
+ const eventLog = String(job?.event_log ?? '');
141
+ if (!eventLog || !fs.existsSync(eventLog))
142
+ return [];
143
+ try {
144
+ const lines = fs.readFileSync(eventLog, 'utf8').split(/\r?\n/).filter(Boolean);
145
+ return lines.slice(-maxEvents).map((line) => parseJsonLine(line)).filter(Boolean);
146
+ }
147
+ catch {
148
+ return [];
149
+ }
150
+ }
139
151
  function terminalRunStateFromLatestJob(job, nowMs, ttlMs) {
140
152
  const status = String(job?.status ?? '');
141
153
  if (status === 'running' || status === 'stopping')
@@ -163,6 +175,16 @@ function terminalRunStateFromArtifact(artifact, nowMs, ttlMs) {
163
175
  return undefined;
164
176
  return status;
165
177
  }
178
+ function runStateFromJobStatus(job) {
179
+ const status = String(job?.status ?? '');
180
+ if (status === 'failed')
181
+ return 'failed';
182
+ if (status === 'success')
183
+ return 'success';
184
+ if (status === 'stopped')
185
+ return 'idle';
186
+ return undefined;
187
+ }
166
188
  function jobsRoot(projectRoot, env) {
167
189
  return path.join(path.dirname(resolveProfilePath(projectRoot, env)), 'jobs');
168
190
  }
@@ -303,8 +325,11 @@ export function createServerRuntime(options) {
303
325
  }
304
326
  }
305
327
  if (!cancelled && runState === 'running') {
306
- runState = 'success';
307
- publish({ type: 'server_state', run_state: runState });
328
+ const reconciledState = runStateFromJobStatus(latestJob(options.projectRoot, options.env ?? process.env));
329
+ if (reconciledState) {
330
+ runState = reconciledState;
331
+ publish({ type: 'server_state', run_state: runState });
332
+ }
308
333
  }
309
334
  }
310
335
  catch (error) {
@@ -344,12 +369,14 @@ export function createServerRuntime(options) {
344
369
  const artifact = normalizeLatestArtifact(options.projectRoot, options.env ?? process.env);
345
370
  restoreRunState(artifact);
346
371
  const retries = artifactList(options.projectRoot, options.env ?? process.env);
372
+ const job = latestJob(options.projectRoot, options.env ?? process.env);
347
373
  return {
348
374
  profile: sanitizeProfileForServer(profilePayload(options.projectRoot, options.env)),
349
375
  runState,
350
376
  artifact,
351
377
  retryArtifacts: Array.isArray(retries.items) ? retries.items : [],
352
- deployment: (artifact?.deployment ?? {})
378
+ deployment: (artifact?.deployment ?? {}),
379
+ logEvents: latestJobEvents(job)
353
380
  };
354
381
  },
355
382
  async saveProfile(profile) {
@@ -436,6 +463,16 @@ export function createServerRuntime(options) {
436
463
  subscribe(handler) {
437
464
  subscribers.add(handler);
438
465
  return () => subscribers.delete(handler);
466
+ },
467
+ async close() {
468
+ unsubscribeLogs?.();
469
+ if (!activeJobId) {
470
+ const artifact = normalizeLatestArtifact(options.projectRoot, options.env ?? process.env);
471
+ restoreRunState(artifact);
472
+ }
473
+ if ((runState === 'running' || runState === 'stopping') && activeJobId) {
474
+ await this.stopRun?.();
475
+ }
439
476
  }
440
477
  };
441
478
  }
@@ -111,9 +111,11 @@ const state = {
111
111
  logFilter: '全部',
112
112
  settingsDrawer: null,
113
113
  isDemo: false,
114
+ runtime: document.body.dataset.runtime === 'web' ? 'web' : 'electron',
114
115
  runState: 'idle',
115
116
  runResult: 'idle',
116
117
  logEntries: [],
118
+ extractDedupedFingerprints: new Set(),
117
119
  artifactDir: '',
118
120
  retryArtifacts: [],
119
121
  selectedRetryArtifactDir: '',
@@ -163,6 +165,7 @@ async function bootstrap() {
163
165
  ? await window.vpnAutomation.loadState()
164
166
  : { profile: await window.vpnAutomation.loadProfile() };
165
167
  hydrateInitialRuntimeState(loadedState);
168
+ hydrateHistoricalEvents(loadedState?.logEvents);
166
169
  touchUpdate();
167
170
  await refreshQrCode();
168
171
  if (!loadedState?.retryArtifacts) {
@@ -196,6 +199,19 @@ function hydrateInitialRuntimeState(loadedState = {}) {
196
199
  }
197
200
  }
198
201
 
202
+ function hydrateHistoricalEvents(events = []) {
203
+ if (!Array.isArray(events) || events.length === 0) {
204
+ return;
205
+ }
206
+ const previousRunState = state.runState;
207
+ const previousRunResult = state.runResult;
208
+ for (const event of events) {
209
+ handlePipelineEvent(event, { historical: true });
210
+ }
211
+ state.runState = previousRunState;
212
+ state.runResult = previousRunState === 'idle' ? previousRunResult : previousRunState;
213
+ }
214
+
199
215
  function bindActions() {
200
216
  document.addEventListener('click', handleDocumentClick);
201
217
  document.addEventListener('input', handleDocumentInput);
@@ -299,7 +315,7 @@ function runTone() {
299
315
  return 'neutral';
300
316
  }
301
317
 
302
- function handleDocumentClick(event) {
318
+ async function handleDocumentClick(event) {
303
319
  const navButton = event.target.closest('[data-page-target]');
304
320
  if (navButton) {
305
321
  state.activePage = navButton.dataset.pageTarget;
@@ -422,7 +438,7 @@ function handleDocumentClick(event) {
422
438
  }
423
439
 
424
440
  if (event.target.closest('[data-drawer-save="save"]')) {
425
- saveSettingsDrawer();
441
+ await saveSettingsDrawer();
426
442
  }
427
443
  }
428
444
 
@@ -548,6 +564,7 @@ function buildDeployDraft(deploy = {}) {
548
564
  function sanitizeDeployDraft(draft = {}) {
549
565
  const sanitizedDraft = structuredClone(draft);
550
566
  delete sanitizedDraft.__autoLinkedPagesProjectUrl;
567
+ sanitizedDraft.min_final_links = Math.max(0, Math.trunc(Number(sanitizedDraft.min_final_links ?? 10) || 0));
551
568
  return sanitizedDraft;
552
569
  }
553
570
 
@@ -932,6 +949,7 @@ async function runPipeline() {
932
949
  state.stageStatus = {};
933
950
  state.counts = {};
934
951
  state.sourceCounts = {};
952
+ state.extractDedupedFingerprints = new Set();
935
953
  state.logEntries = [];
936
954
  state.artifactDir = '';
937
955
  state.outputFiles = [];
@@ -981,6 +999,7 @@ async function retryStage() {
981
999
  state.stageStatus = {};
982
1000
  state.counts = {};
983
1001
  state.sourceCounts = {};
1002
+ state.extractDedupedFingerprints = new Set();
984
1003
  state.logEntries = [];
985
1004
  state.artifactDir = '';
986
1005
  state.outputFiles = [];
@@ -1131,7 +1150,8 @@ function finishRun(result = {}) {
1131
1150
  void hydrateRetryArtifacts();
1132
1151
  }
1133
1152
 
1134
- function handlePipelineEvent(event) {
1153
+ function handlePipelineEvent(event, options = {}) {
1154
+ const historical = Boolean(options.historical);
1135
1155
  if (event.type === 'server_state') {
1136
1156
  const nextRunState = String(event.run_state ?? '');
1137
1157
  if (['idle', 'running', 'stopping', 'failed', 'success'].includes(nextRunState)) {
@@ -1151,6 +1171,10 @@ function handlePipelineEvent(event) {
1151
1171
  }
1152
1172
 
1153
1173
  if (event.type === 'run_failed') {
1174
+ if (historical) {
1175
+ appendLog(`[run_failed] ${event.error ?? ''}`);
1176
+ return;
1177
+ }
1154
1178
  if (state.runState !== 'idle' || state.runResult !== 'failed') {
1155
1179
  finishRun({ ok: false, error: event.error });
1156
1180
  }
@@ -1196,16 +1220,34 @@ function handlePipelineEvent(event) {
1196
1220
  appendLog(`[summary] artifacts: ${event.artifact_dir}`);
1197
1221
  const runStatus = String(event.run_status ?? '');
1198
1222
  if (runStatus === 'failed') {
1223
+ if (historical) {
1224
+ state.runResult = 'failed';
1225
+ touchUpdate();
1226
+ renderAll();
1227
+ return;
1228
+ }
1199
1229
  finishRun({ ok: false, error: event.error });
1200
1230
  hydrateArtifactPreview();
1201
1231
  return;
1202
1232
  }
1203
1233
  if (runStatus === 'success') {
1234
+ if (historical) {
1235
+ state.runResult = 'success';
1236
+ touchUpdate();
1237
+ renderAll();
1238
+ return;
1239
+ }
1204
1240
  finishRun({ ok: true, code: 0 });
1205
1241
  hydrateArtifactPreview();
1206
1242
  return;
1207
1243
  }
1208
1244
  if (runStatus === 'stopped') {
1245
+ if (historical) {
1246
+ state.runResult = 'idle';
1247
+ touchUpdate();
1248
+ renderAll();
1249
+ return;
1250
+ }
1209
1251
  finishRun({ stopped: true });
1210
1252
  hydrateArtifactPreview();
1211
1253
  return;
@@ -1327,15 +1369,30 @@ function updateExtractMetrics(event) {
1327
1369
 
1328
1370
  const previous = state.sourceCounts[sourceName] ?? {};
1329
1371
  const rawLinks = Number(event.total_links ?? previous.raw_links ?? 0);
1372
+ const sourceDedupedLinks = Number(event.deduped_links ?? previous.deduped_links ?? rawLinks);
1373
+ if (Array.isArray(event.new_item_fingerprints)) {
1374
+ for (const fingerprint of event.new_item_fingerprints) {
1375
+ if (fingerprint) {
1376
+ state.extractDedupedFingerprints.add(String(fingerprint));
1377
+ }
1378
+ }
1379
+ }
1330
1380
  state.sourceCounts = {
1331
1381
  ...state.sourceCounts,
1332
1382
  [sourceName]: {
1333
1383
  ...previous,
1334
- raw_links: rawLinks
1384
+ raw_links: rawLinks,
1385
+ deduped_links: sourceDedupedLinks
1335
1386
  }
1336
1387
  };
1337
1388
  state.counts.raw_links = Object.values(state.sourceCounts)
1338
1389
  .reduce((total, item) => total + Number(item?.raw_links ?? 0), 0);
1390
+ if (state.extractDedupedFingerprints.size > 0) {
1391
+ state.counts.deduped_links = state.extractDedupedFingerprints.size;
1392
+ } else {
1393
+ state.counts.deduped_links = Object.values(state.sourceCounts)
1394
+ .reduce((total, item) => total + Number(item?.deduped_links ?? 0), 0);
1395
+ }
1339
1396
  }
1340
1397
 
1341
1398
  async function hydrateArtifactPreview() {
@@ -1362,6 +1419,7 @@ function hydrateArtifactState(result) {
1362
1419
  state.artifactDir = result.artifact_dir;
1363
1420
  state.counts = normalizeCounts(result.counts ?? {});
1364
1421
  state.sourceCounts = normalizeSourceCounts(result.source_counts ?? {});
1422
+ state.extractDedupedFingerprints = new Set();
1365
1423
  state.outputFiles = result.outputFiles ?? [];
1366
1424
  state.nodeRows = result.nodeRows ?? [];
1367
1425
  state.retryContext = result.retry_context ?? {};
@@ -5,7 +5,7 @@ const ZH_MESSAGES = {
5
5
  locale: 'zh-CN',
6
6
  appTitle: 'AutoVPN',
7
7
  sidebarTitle: 'AutoVPN',
8
- sidebarVersion: 'v.1.6.1',
8
+ sidebarVersion: 'v.1.6.3',
9
9
  brandSubtitle: '概览、运行、结果、订阅、日志、设置统一管理',
10
10
  languageLabel: '',
11
11
  saveButton: '保存配置',