qwen2api commited on
Commit
5815428
·
verified ·
1 Parent(s): e5f68c9

Upload 13 files

Browse files
Files changed (9) hide show
  1. data/tasks.json +0 -0
  2. executor.js +228 -26
  3. logger.js +1 -1
  4. package-lock.json +49 -0
  5. package.json +5 -2
  6. server.js +38 -4
  7. test-api.js +81 -0
  8. test-token-rotation.js +42 -0
  9. token-consumer.html +38 -8
data/tasks.json ADDED
The diff for this file is too large to render. See raw diff
 
executor.js CHANGED
@@ -2,6 +2,81 @@ const https = require('https');
2
  const http = require('http');
3
  const log = require('./logger');
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  function buildUrl(baseUrl, endpoint) {
6
  let url = baseUrl.trim().replace(/\/ $/, '');
7
  if (!url) throw new Error('Base URL 不能为空');
@@ -60,13 +135,16 @@ function makeRequest(url, options, body, timeout = 60000) {
60
  const isHttps = urlObj.protocol === 'https:';
61
  const lib = isHttps ? https : http;
62
 
 
 
63
  const reqOptions = {
64
  hostname: urlObj.hostname,
65
  port: urlObj.port || (isHttps ? 443 : 80),
66
  path: urlObj.pathname + urlObj.search,
67
  method: options.method || 'GET',
68
- headers: options.headers || {},
69
- timeout
 
70
  };
71
 
72
  const req = lib.request(reqOptions, (res) => {
@@ -89,9 +167,14 @@ async function fetchModels(baseUrl, token) {
89
  try {
90
  log.debug('Fetching models from:', baseUrl);
91
  const url = buildUrl(baseUrl, '/models');
 
 
 
 
 
92
  const resp = await makeRequest(url, {
93
  method: 'GET',
94
- headers: { 'Authorization': 'Bearer ' + token }
95
  });
96
  const data = JSON.parse(resp.body);
97
  if (resp.status !== 200) {
@@ -109,9 +192,18 @@ async function fetchModels(baseUrl, token) {
109
  }
110
  }
111
 
112
- async function requestOnce(config, signal) {
113
  const { prompt, tag, marker } = buildUserPrompt(config.usr, config.randOn, config.markerLen);
114
 
 
 
 
 
 
 
 
 
 
115
  const body = {
116
  model: config.model,
117
  messages: [
@@ -127,7 +219,7 @@ async function requestOnce(config, signal) {
127
  const resp = await makeRequest(url, {
128
  method: 'POST',
129
  headers: {
130
- 'Authorization': 'Bearer ' + config.token,
131
  'Content-Type': 'application/json'
132
  }
133
  }, JSON.stringify(body), config.timeout);
@@ -156,12 +248,13 @@ async function requestOnce(config, signal) {
156
  }
157
 
158
  const content = data?.choices?.[0]?.message?.content || '';
159
- const usage = usageWithFallback(data?.usage, prompt, config.sys, content);
 
160
 
161
- return { content, usage, finish_reason: data?.choices?.[0]?.finish_reason, tag, marker, prompt };
162
  }
163
 
164
- async function streamRequest(config, onChunk, signal) {
165
  const { prompt, tag, marker } = buildUserPrompt(config.usr, config.randOn, config.markerLen);
166
 
167
  const body = {
@@ -181,6 +274,15 @@ async function streamRequest(config, onChunk, signal) {
181
  const isHttps = urlObj.protocol === 'https:';
182
  const lib = isHttps ? https : http;
183
 
 
 
 
 
 
 
 
 
 
184
  return new Promise((resolve, reject) => {
185
  // Check if already aborted before starting
186
  if (signal?.aborted) {
@@ -194,13 +296,16 @@ async function streamRequest(config, onChunk, signal) {
194
  path: urlObj.pathname,
195
  method: 'POST',
196
  headers: {
197
- 'Authorization': 'Bearer ' + config.token,
 
198
  'Content-Type': 'application/json'
199
  },
200
- timeout: config.timeout || 60000
 
201
  };
202
 
203
  let fullContent = '';
 
204
  let usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
205
  let finishReason = null;
206
  let aborted = false;
@@ -248,10 +353,23 @@ async function streamRequest(config, onChunk, signal) {
248
  const json = JSON.parse(payload);
249
  if (json.error) throw new Error(json.error.message);
250
 
251
- const delta = json.choices?.[0]?.delta?.content;
252
- if (delta) {
253
- fullContent += delta;
254
- if (onChunk) onChunk(delta, fullContent);
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  }
256
 
257
  if (json.usage) usage = json.usage;
@@ -271,9 +389,10 @@ async function streamRequest(config, onChunk, signal) {
271
 
272
  res.on('end', () => {
273
  cleanup();
274
- const finalUsage = usageWithFallback(usage, prompt, config.sys, fullContent);
275
  resolve({
276
  content: fullContent,
 
277
  usage: finalUsage,
278
  finish_reason: finishReason,
279
  tag,
@@ -327,6 +446,11 @@ class TaskExecutor {
327
  this.notifyTimer = null;
328
  this.notifyInterval = 500; // ms between notifications (increased for high concurrency)
329
 
 
 
 
 
 
330
  // Load from saved progress or initialize fresh
331
  if (savedProgress) {
332
  this.stats = savedProgress.stats || {
@@ -389,8 +513,8 @@ class TaskExecutor {
389
  thread: threadId,
390
  loop: i,
391
  status: 'running',
392
- startTime: Date.now(),
393
- message: ''
394
  };
395
 
396
  this.threadLogs[threadId] = threadLog;
@@ -401,21 +525,24 @@ class TaskExecutor {
401
  const result = this.config.streamOn
402
  ? await streamRequest(
403
  { ...this.config, markerLen: this.markerLen },
404
- (delta, full) => {
405
  threadLog.status = 'streaming';
406
- threadLog.content = full;
407
  // notifyUpdate already throttled, so this is fine
408
  this.notifyUpdate();
409
  },
410
- this.controller.signal
 
411
  )
412
  : await requestOnce(
413
  { ...this.config, markerLen: this.markerLen },
414
- this.controller.signal
 
415
  );
416
 
417
  this.stats.completed++;
418
  this.stats.success++;
 
419
  this.stats.promptTokens += result.usage.prompt_tokens;
420
  this.stats.completionTokens += result.usage.completion_tokens;
421
  this.stats.totalTokens += result.usage.total_tokens;
@@ -430,7 +557,15 @@ class TaskExecutor {
430
  log.info('Task', this.taskId, 'reached token limit:', this.stats.totalTokens);
431
  } else {
432
  threadLog.status = 'success';
433
- threadLog.content = result.content?.slice(-500);
 
 
 
 
 
 
 
 
434
  }
435
 
436
  // Adjust marker for ratio
@@ -442,13 +577,16 @@ class TaskExecutor {
442
  if (this.config.randOn) {
443
  const diff = liveRatio - this.ratio.target;
444
  if (Math.abs(diff) >= 0.02) {
445
- const step = Math.max(1, Math.round(Math.abs(diff) * 50));
446
- if (diff < 0) this.markerLen = Math.min(4000, this.markerLen + step);
447
  else this.markerLen = Math.max(0, this.markerLen - step);
448
  }
449
  this.ratio.markerLen = this.markerLen;
450
  }
451
 
 
 
 
452
  } catch (e) {
453
  const isAborted = e.message === 'Aborted' || e.name === 'AbortError';
454
 
@@ -479,6 +617,30 @@ class TaskExecutor {
479
  log.debug('Thread', threadId, 'loop', i, 'aborted');
480
  }
481
  } else {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  this.stats.failed++;
483
  threadLog.status = 'error';
484
  threadLog.error = e.message || '请求失败';
@@ -486,7 +648,6 @@ class TaskExecutor {
486
  }
487
  }
488
 
489
- threadLog.endTime = Date.now();
490
  this.notifyUpdate(true); // Force update after each request completes
491
 
492
  // Wait between loops (not after the last loop)
@@ -531,6 +692,9 @@ class TaskExecutor {
531
  }
532
 
533
  this.notifyUpdate();
 
 
 
534
 
535
  // Run workers in parallel
536
  const workers = [];
@@ -541,6 +705,7 @@ class TaskExecutor {
541
  await Promise.all(workers);
542
 
543
  this.running = false;
 
544
  log.info('TaskExecutor finished:', this.taskId, 'success:', this.stats.success, 'failed:', this.stats.failed, 'tokens:', this.stats.totalTokens);
545
  this.notifyUpdate(true); // Force final update
546
 
@@ -552,6 +717,7 @@ class TaskExecutor {
552
  if (!this.running || this.paused) return;
553
  log.info('TaskExecutor pausing:', this.taskId);
554
  this.paused = true;
 
555
  if (this.startTime) {
556
  this.elapsedBefore += Date.now() - this.startTime;
557
  this.startTime = null;
@@ -565,6 +731,8 @@ class TaskExecutor {
565
  if (!this.running || !this.paused) return;
566
  log.info('TaskExecutor resuming:', this.taskId);
567
  this.paused = false;
 
 
568
  // Create new AbortController for resumed execution
569
  this.controller = new AbortController();
570
  this.startTime = Date.now();
@@ -575,9 +743,43 @@ class TaskExecutor {
575
  log.info('TaskExecutor stopping:', this.taskId);
576
  this.stopped = true;
577
  this.paused = false;
 
578
  this.controller.abort();
579
  }
580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
  getStatus() {
582
  let elapsed = this.elapsedBefore;
583
  if (this.startTime && !this.paused) {
@@ -642,4 +844,4 @@ class TaskExecutor {
642
  }
643
  }
644
 
645
- module.exports = { TaskExecutor, fetchModels };
 
2
  const http = require('http');
3
  const log = require('./logger');
4
 
5
+ const CHERRY_STUDIO_USER_AGENT = 'cherry studio/1.0(secure client)';
6
+
7
+ // 全局 HTTP Agent 配置 - 支持高并发连接复用
8
+ const httpsAgent = new https.Agent({
9
+ keepAlive: true, // 启用连接复用
10
+ maxSockets: 600, // 每个主机最大连接数(略高于线程数)
11
+ maxFreeSockets: 100, // 空闲连接池大小
12
+ keepAliveMsecs: 30000, // keepAlive 探测间隔
13
+ timeout: 10000, // 连接建立超时
14
+ });
15
+
16
+ const httpAgent = new http.Agent({
17
+ keepAlive: true,
18
+ maxSockets: 600,
19
+ maxFreeSockets: 100,
20
+ keepAliveMsecs: 30000,
21
+ timeout: 10000,
22
+ });
23
+
24
+ // 最大重试次数
25
+ const MAX_RETRIES = 3;
26
+
27
+ // 停滞自动暂停配置:连续多久没有成功就自动暂停(毫秒)
28
+ // 默认 1 小时 = 3600000ms,可通过环境变量 STALL_TIMEOUT_MS 配置
29
+ const STALL_TIMEOUT_MS = Number(process.env.STALL_TIMEOUT_MS) || 3600000;
30
+
31
+ // 判断是否是可重试的网络错误(TLS 握手失败、连接重置等)
32
+ function isRetryableError(err) {
33
+ if (!err) return false;
34
+ const msg = err.message || '';
35
+ const code = err.code || '';
36
+
37
+ // TLS 握手错误
38
+ if (msg.includes('TLS') || msg.includes('socket disconnected before secure')) return true;
39
+
40
+ // 连接相关错误
41
+ if (['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EHOSTUNREACH'].includes(code)) return true;
42
+ if (msg.includes('ECONNRESET') || msg.includes('ETIMEDOUT') || msg.includes('socket hang up')) return true;
43
+
44
+ return false;
45
+ }
46
+
47
+ // Max length of content to store in thread logs (for frontend display)
48
+ const LOG_CONTENT_MAX_LENGTH = 20;
49
+
50
+ // Multiplier for marker length step calculation
51
+ const RATIO_STEP_MULTIPLIER = 100;
52
+
53
+ // Token rotation counter
54
+ const tokenCounters = new Map();
55
+
56
+ // Get next token from rotation, returns both token and index
57
+ function getNextToken(config) {
58
+ // Split tokens by comma and trim whitespace
59
+ const tokens = String(config.token || '').split(',').map(t => t.trim()).filter(Boolean);
60
+
61
+ // If only one token, return it directly
62
+ if (tokens.length <= 1) {
63
+ return { token: tokens[0] || config.token, index: 0, total: tokens.length || 1 };
64
+ }
65
+
66
+ // Get current counter for this config (using a hash of token string as key)
67
+ const tokenKey = tokens.join(',');
68
+ let counter = tokenCounters.get(tokenKey) || 0;
69
+
70
+ // Get current token
71
+ const index = counter % tokens.length;
72
+ const token = tokens[index];
73
+
74
+ // Increment counter
75
+ tokenCounters.set(tokenKey, counter + 1);
76
+
77
+ return { token, index, total: tokens.length };
78
+ }
79
+
80
  function buildUrl(baseUrl, endpoint) {
81
  let url = baseUrl.trim().replace(/\/ $/, '');
82
  if (!url) throw new Error('Base URL 不能为空');
 
135
  const isHttps = urlObj.protocol === 'https:';
136
  const lib = isHttps ? https : http;
137
 
138
+ const mergedHeaders = { 'User-Agent': CHERRY_STUDIO_USER_AGENT, ...(options.headers || {}) };
139
+
140
  const reqOptions = {
141
  hostname: urlObj.hostname,
142
  port: urlObj.port || (isHttps ? 443 : 80),
143
  path: urlObj.pathname + urlObj.search,
144
  method: options.method || 'GET',
145
+ headers: mergedHeaders,
146
+ timeout,
147
+ agent: isHttps ? httpsAgent : httpAgent
148
  };
149
 
150
  const req = lib.request(reqOptions, (res) => {
 
167
  try {
168
  log.debug('Fetching models from:', baseUrl);
169
  const url = buildUrl(baseUrl, '/models');
170
+
171
+ // Handle token rotation for model fetching
172
+ const tokens = String(token || '').split(',').map(t => t.trim()).filter(Boolean);
173
+ const useToken = tokens.length > 0 ? tokens[0] : token;
174
+
175
  const resp = await makeRequest(url, {
176
  method: 'GET',
177
+ headers: { 'Authorization': 'Bearer ' + useToken }
178
  });
179
  const data = JSON.parse(resp.body);
180
  if (resp.status !== 200) {
 
192
  }
193
  }
194
 
195
+ async function requestOnce(config, signal, threadLog) {
196
  const { prompt, tag, marker } = buildUserPrompt(config.usr, config.randOn, config.markerLen);
197
 
198
+ // Get next token from rotation
199
+ const { token, index, total } = getNextToken(config);
200
+
201
+ // Record token index in thread log
202
+ if (threadLog) {
203
+ threadLog.tokenIndex = index;
204
+ threadLog.tokenTotal = total;
205
+ }
206
+
207
  const body = {
208
  model: config.model,
209
  messages: [
 
219
  const resp = await makeRequest(url, {
220
  method: 'POST',
221
  headers: {
222
+ 'Authorization': 'Bearer ' + token,
223
  'Content-Type': 'application/json'
224
  }
225
  }, JSON.stringify(body), config.timeout);
 
248
  }
249
 
250
  const content = data?.choices?.[0]?.message?.content || '';
251
+ const reasoning = data?.choices?.[0]?.message?.reasoning_content || '';
252
+ const usage = usageWithFallback(data?.usage, prompt, config.sys, content + reasoning);
253
 
254
+ return { content, reasoning, usage, finish_reason: data?.choices?.[0]?.finish_reason, tag, marker, prompt };
255
  }
256
 
257
+ async function streamRequest(config, onChunk, signal, threadLog) {
258
  const { prompt, tag, marker } = buildUserPrompt(config.usr, config.randOn, config.markerLen);
259
 
260
  const body = {
 
274
  const isHttps = urlObj.protocol === 'https:';
275
  const lib = isHttps ? https : http;
276
 
277
+ // Get next token from rotation
278
+ const { token, index, total } = getNextToken(config);
279
+
280
+ // Record token index in thread log
281
+ if (threadLog) {
282
+ threadLog.tokenIndex = index;
283
+ threadLog.tokenTotal = total;
284
+ }
285
+
286
  return new Promise((resolve, reject) => {
287
  // Check if already aborted before starting
288
  if (signal?.aborted) {
 
296
  path: urlObj.pathname,
297
  method: 'POST',
298
  headers: {
299
+ 'User-Agent': CHERRY_STUDIO_USER_AGENT,
300
+ 'Authorization': 'Bearer ' + token,
301
  'Content-Type': 'application/json'
302
  },
303
+ timeout: config.timeout || 60000,
304
+ agent: isHttps ? httpsAgent : httpAgent
305
  };
306
 
307
  let fullContent = '';
308
+ let fullReasoning = '';
309
  let usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
310
  let finishReason = null;
311
  let aborted = false;
 
353
  const json = JSON.parse(payload);
354
  if (json.error) throw new Error(json.error.message);
355
 
356
+ const delta = json.choices?.[0]?.delta;
357
+ if (delta?.content) {
358
+ fullContent += delta.content;
359
+ }
360
+ if (delta?.reasoning_content) {
361
+ fullReasoning += delta.reasoning_content;
362
+ }
363
+ // Call onChunk with combined output
364
+ if (onChunk && (delta?.content || delta?.reasoning_content)) {
365
+ let fullOutput = '';
366
+ if (fullReasoning) {
367
+ fullOutput += '💭 ' + fullReasoning + '\n---\n';
368
+ }
369
+ if (fullContent) {
370
+ fullOutput += fullContent;
371
+ }
372
+ onChunk(delta?.content || delta?.reasoning_content, fullOutput);
373
  }
374
 
375
  if (json.usage) usage = json.usage;
 
389
 
390
  res.on('end', () => {
391
  cleanup();
392
+ const finalUsage = usageWithFallback(usage, prompt, config.sys, fullContent + fullReasoning);
393
  resolve({
394
  content: fullContent,
395
+ reasoning: fullReasoning,
396
  usage: finalUsage,
397
  finish_reason: finishReason,
398
  tag,
 
446
  this.notifyTimer = null;
447
  this.notifyInterval = 500; // ms between notifications (increased for high concurrency)
448
 
449
+ // 停滞自动暂停:跟踪最后一次成功时间
450
+ this.lastSuccessTime = Date.now();
451
+ this.stallCheckTimer = null;
452
+ this.stallCheckInterval = 60000; // 每分钟检查一次
453
+
454
  // Load from saved progress or initialize fresh
455
  if (savedProgress) {
456
  this.stats = savedProgress.stats || {
 
513
  thread: threadId,
514
  loop: i,
515
  status: 'running',
516
+ message: '',
517
+ markerLen: this.markerLen
518
  };
519
 
520
  this.threadLogs[threadId] = threadLog;
 
525
  const result = this.config.streamOn
526
  ? await streamRequest(
527
  { ...this.config, markerLen: this.markerLen },
528
+ (delta, fullOutput) => {
529
  threadLog.status = 'streaming';
530
+ threadLog.content = fullOutput?.slice(-LOG_CONTENT_MAX_LENGTH);
531
  // notifyUpdate already throttled, so this is fine
532
  this.notifyUpdate();
533
  },
534
+ this.controller.signal,
535
+ threadLog
536
  )
537
  : await requestOnce(
538
  { ...this.config, markerLen: this.markerLen },
539
+ this.controller.signal,
540
+ threadLog
541
  );
542
 
543
  this.stats.completed++;
544
  this.stats.success++;
545
+ this.lastSuccessTime = Date.now(); // 更新最后成功时间
546
  this.stats.promptTokens += result.usage.prompt_tokens;
547
  this.stats.completionTokens += result.usage.completion_tokens;
548
  this.stats.totalTokens += result.usage.total_tokens;
 
557
  log.info('Task', this.taskId, 'reached token limit:', this.stats.totalTokens);
558
  } else {
559
  threadLog.status = 'success';
560
+ // Combine reasoning and content, then slice
561
+ let fullOutput = '';
562
+ if (result.reasoning) {
563
+ fullOutput += '💭 ' + result.reasoning + '\n---\n';
564
+ }
565
+ if (result.content) {
566
+ fullOutput += result.content;
567
+ }
568
+ threadLog.content = fullOutput.slice(-LOG_CONTENT_MAX_LENGTH);
569
  }
570
 
571
  // Adjust marker for ratio
 
577
  if (this.config.randOn) {
578
  const diff = liveRatio - this.ratio.target;
579
  if (Math.abs(diff) >= 0.02) {
580
+ const step = Math.max(1, Math.round(Math.abs(diff) * RATIO_STEP_MULTIPLIER));
581
+ if (diff < 0) this.markerLen = Math.min(100000, this.markerLen + step);
582
  else this.markerLen = Math.max(0, this.markerLen - step);
583
  }
584
  this.ratio.markerLen = this.markerLen;
585
  }
586
 
587
+ // Add markerLen info to thread log
588
+ threadLog.markerLen = this.markerLen;
589
+
590
  } catch (e) {
591
  const isAborted = e.message === 'Aborted' || e.name === 'AbortError';
592
 
 
617
  log.debug('Thread', threadId, 'loop', i, 'aborted');
618
  }
619
  } else {
620
+ // 检查是否是可重试的网络错误(TLS 握手失败等)
621
+ if (isRetryableError(e)) {
622
+ // 获取或初始化重试计数
623
+ const retryKey = `${threadId}-${i}`;
624
+ if (!this._retryCount) this._retryCount = {};
625
+ this._retryCount[retryKey] = (this._retryCount[retryKey] || 0) + 1;
626
+
627
+ if (this._retryCount[retryKey] <= MAX_RETRIES) {
628
+ threadLog.status = 'retrying';
629
+ threadLog.message = `网络错误,重试 ${this._retryCount[retryKey]}/${MAX_RETRIES}...`;
630
+ log.warn('Thread', threadId, 'loop', i, 'retryable error:', e.message, 'retry', this._retryCount[retryKey]);
631
+ this.notifyUpdate(true);
632
+
633
+ // 指数退避等待
634
+ await new Promise(resolve => setTimeout(resolve, 100 * this._retryCount[retryKey]));
635
+
636
+ // 重试当前循环
637
+ i--;
638
+ continue;
639
+ }
640
+ // 重试次数用尽,清理计数
641
+ delete this._retryCount[retryKey];
642
+ }
643
+
644
  this.stats.failed++;
645
  threadLog.status = 'error';
646
  threadLog.error = e.message || '请求失败';
 
648
  }
649
  }
650
 
 
651
  this.notifyUpdate(true); // Force update after each request completes
652
 
653
  // Wait between loops (not after the last loop)
 
692
  }
693
 
694
  this.notifyUpdate();
695
+
696
+ // 启动停滞检查定时器
697
+ this.startStallCheck();
698
 
699
  // Run workers in parallel
700
  const workers = [];
 
705
  await Promise.all(workers);
706
 
707
  this.running = false;
708
+ this.stopStallCheck(); // 停止停滞检查
709
  log.info('TaskExecutor finished:', this.taskId, 'success:', this.stats.success, 'failed:', this.stats.failed, 'tokens:', this.stats.totalTokens);
710
  this.notifyUpdate(true); // Force final update
711
 
 
717
  if (!this.running || this.paused) return;
718
  log.info('TaskExecutor pausing:', this.taskId);
719
  this.paused = true;
720
+ this.stopStallCheck(); // 停止停滞检查
721
  if (this.startTime) {
722
  this.elapsedBefore += Date.now() - this.startTime;
723
  this.startTime = null;
 
731
  if (!this.running || !this.paused) return;
732
  log.info('TaskExecutor resuming:', this.taskId);
733
  this.paused = false;
734
+ this.lastSuccessTime = Date.now(); // 重置成功时间
735
+ this.startStallCheck(); // 重启停滞检查
736
  // Create new AbortController for resumed execution
737
  this.controller = new AbortController();
738
  this.startTime = Date.now();
 
743
  log.info('TaskExecutor stopping:', this.taskId);
744
  this.stopped = true;
745
  this.paused = false;
746
+ this.stopStallCheck(); // 停止停滞检查
747
  this.controller.abort();
748
  }
749
 
750
+ // 启动停滞检查定时器
751
+ startStallCheck() {
752
+ if (this.stallCheckTimer) return;
753
+ this.lastSuccessTime = Date.now();
754
+ this.stallCheckTimer = setInterval(() => this.checkStall(), this.stallCheckInterval);
755
+ }
756
+
757
+ // 停止停滞检查定时器
758
+ stopStallCheck() {
759
+ if (this.stallCheckTimer) {
760
+ clearInterval(this.stallCheckTimer);
761
+ this.stallCheckTimer = null;
762
+ }
763
+ }
764
+
765
+ // 检查是否停滞,如果超时则自动暂停
766
+ checkStall() {
767
+ if (this.paused || this.stopped) return;
768
+
769
+ const timeSinceLastSuccess = Date.now() - this.lastSuccessTime;
770
+ if (timeSinceLastSuccess >= STALL_TIMEOUT_MS) {
771
+ log.warn('Task', this.taskId, '停滞超时,自动暂停。最后成功时间:', new Date(this.lastSuccessTime).toISOString());
772
+ this.pause();
773
+ // 通知前端
774
+ if (this.onUpdate) {
775
+ const status = this.getStatus();
776
+ status.stallAutoPaused = true;
777
+ status.stallMessage = `连续 ${Math.round(STALL_TIMEOUT_MS / 60000)} 分钟无成功请求,已自动暂停`;
778
+ this.onUpdate(status);
779
+ }
780
+ }
781
+ }
782
+
783
  getStatus() {
784
  let elapsed = this.elapsedBefore;
785
  if (this.startTime && !this.paused) {
 
844
  }
845
  }
846
 
847
+ module.exports = { TaskExecutor, fetchModels, getNextToken };
logger.js CHANGED
@@ -2,7 +2,7 @@
2
  // LOG_LEVEL: debug, info, warn, error, none (default: info)
3
 
4
  const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, none: 4 };
5
- const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL?.toLowerCase()] ?? LOG_LEVELS.info;
6
 
7
  const ts = () => new Date().toISOString();
8
 
 
2
  // LOG_LEVEL: debug, info, warn, error, none (default: info)
3
 
4
  const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, none: 4 };
5
+ const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL?.toLowerCase()] ?? LOG_LEVELS.error;
6
 
7
  const ts = () => new Date().toISOString();
8
 
package-lock.json CHANGED
@@ -8,6 +8,7 @@
8
  "name": "token-consumer",
9
  "version": "1.0.0",
10
  "dependencies": {
 
11
  "cors": "^2.8.5",
12
  "express": "^4.18.2",
13
  "uuid": "^9.0.0"
@@ -186,6 +187,45 @@
186
  "fsevents": "~2.3.2"
187
  }
188
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  "node_modules/content-disposition": {
190
  "version": "0.5.4",
191
  "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -894,6 +934,15 @@
894
  "node": ">= 0.8"
895
  }
896
  },
 
 
 
 
 
 
 
 
 
897
  "node_modules/parseurl": {
898
  "version": "1.3.3",
899
  "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
 
8
  "name": "token-consumer",
9
  "version": "1.0.0",
10
  "dependencies": {
11
+ "compression": "^1.8.1",
12
  "cors": "^2.8.5",
13
  "express": "^4.18.2",
14
  "uuid": "^9.0.0"
 
187
  "fsevents": "~2.3.2"
188
  }
189
  },
190
+ "node_modules/compressible": {
191
+ "version": "2.0.18",
192
+ "resolved": "https://registry.npmmirror.com/compressible/-/compressible-2.0.18.tgz",
193
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
194
+ "license": "MIT",
195
+ "dependencies": {
196
+ "mime-db": ">= 1.43.0 < 2"
197
+ },
198
+ "engines": {
199
+ "node": ">= 0.6"
200
+ }
201
+ },
202
+ "node_modules/compression": {
203
+ "version": "1.8.1",
204
+ "resolved": "https://registry.npmmirror.com/compression/-/compression-1.8.1.tgz",
205
+ "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
206
+ "license": "MIT",
207
+ "dependencies": {
208
+ "bytes": "3.1.2",
209
+ "compressible": "~2.0.18",
210
+ "debug": "2.6.9",
211
+ "negotiator": "~0.6.4",
212
+ "on-headers": "~1.1.0",
213
+ "safe-buffer": "5.2.1",
214
+ "vary": "~1.1.2"
215
+ },
216
+ "engines": {
217
+ "node": ">= 0.8.0"
218
+ }
219
+ },
220
+ "node_modules/compression/node_modules/negotiator": {
221
+ "version": "0.6.4",
222
+ "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz",
223
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
224
+ "license": "MIT",
225
+ "engines": {
226
+ "node": ">= 0.6"
227
+ }
228
+ },
229
  "node_modules/content-disposition": {
230
  "version": "0.5.4",
231
  "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
 
934
  "node": ">= 0.8"
935
  }
936
  },
937
+ "node_modules/on-headers": {
938
+ "version": "1.1.0",
939
+ "resolved": "https://registry.npmmirror.com/on-headers/-/on-headers-1.1.0.tgz",
940
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
941
+ "license": "MIT",
942
+ "engines": {
943
+ "node": ">= 0.8"
944
+ }
945
+ },
946
  "node_modules/parseurl": {
947
  "version": "1.3.3",
948
  "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
package.json CHANGED
@@ -8,11 +8,14 @@
8
  "dev": "cross-env LOG_LEVEL=debug nodemon server.js"
9
  },
10
  "nodemonConfig": {
11
- "ignore": ["data/*"]
 
 
12
  },
13
  "dependencies": {
14
- "express": "^4.18.2",
15
  "cors": "^2.8.5",
 
16
  "uuid": "^9.0.0"
17
  },
18
  "devDependencies": {
 
8
  "dev": "cross-env LOG_LEVEL=debug nodemon server.js"
9
  },
10
  "nodemonConfig": {
11
+ "ignore": [
12
+ "data/*"
13
+ ]
14
  },
15
  "dependencies": {
16
+ "compression": "^1.8.1",
17
  "cors": "^2.8.5",
18
+ "express": "^4.18.2",
19
  "uuid": "^9.0.0"
20
  },
21
  "devDependencies": {
server.js CHANGED
@@ -1,15 +1,25 @@
1
  const express = require('express');
2
  const cors = require('cors');
 
3
  const { v4: uuidv4 } = require('uuid');
4
  const path = require('path');
5
  const os = require('os');
 
 
6
  const storage = require('./storage');
7
  const { TaskExecutor, fetchModels } = require('./executor');
8
  const log = require('./logger');
9
 
 
 
 
 
10
  const app = express();
11
  const PORT = process.env.PORT || 51730;
12
 
 
 
 
13
  // Auth configuration
14
  const AUTH_USER = process.env.AUTH_USER || 'admin';
15
  const AUTH_PASS = process.env.AUTH_PASS || 'admin';
@@ -48,8 +58,9 @@ function rateLimiter(req, res, next) {
48
  }
49
 
50
  // Middleware
 
51
  app.use(cors());
52
- app.use(express.json());
53
  app.use(rateLimiter);
54
 
55
  // HTTP Basic Auth middleware for /api routes
@@ -115,7 +126,7 @@ app.post('/api/tasks', (req, res) => {
115
  sys: config.sys || '',
116
  usr: config.usr,
117
  loop: Math.max(1, Math.min(10000, Number(config.loop) || 10)),
118
- threads: Math.max(1, Math.min(200, Number(config.threads) || 3)),
119
  max: Math.max(1, Math.min(32768, Number(config.max) || 1024)),
120
  temp: Math.max(0, Math.min(2, Number(config.temp) || 1)),
121
  timeout: Math.max(5000, Number(config.timeout) || 60000),
@@ -160,10 +171,23 @@ app.get('/api/tasks', (req, res) => {
160
  if (!t.stats.total && t.config) {
161
  t.stats.total = (t.config.loop || 10) * (t.config.threads || 3);
162
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  return {
164
- ...t,
165
  running: executor?.running || false,
166
- currentStats: executor?.getStatus() || null
167
  };
168
  });
169
  log.debug('GET /api/tasks, count:', result.length);
@@ -473,6 +497,16 @@ app.get('/', (req, res) => {
473
  res.sendFile(path.join(__dirname, 'token-consumer.html'));
474
  });
475
 
 
 
 
 
 
 
 
 
 
 
476
  // Resume running tasks on startup
477
  function resumeRunningTasks() {
478
  const tasks = storage.getAllTasks();
 
1
  const express = require('express');
2
  const cors = require('cors');
3
+ const compression = require('compression');
4
  const { v4: uuidv4 } = require('uuid');
5
  const path = require('path');
6
  const os = require('os');
7
+ const http = require('http');
8
+ const https = require('https');
9
  const storage = require('./storage');
10
  const { TaskExecutor, fetchModels } = require('./executor');
11
  const log = require('./logger');
12
 
13
+ // 设置全局 HTTP Agent 的最大连接数,支持高并发
14
+ http.globalAgent.maxSockets = 1000;
15
+ https.globalAgent.maxSockets = 1000;
16
+
17
  const app = express();
18
  const PORT = process.env.PORT || 51730;
19
 
20
+ // Configuration
21
+ const MAX_THREADS = 1000;
22
+
23
  // Auth configuration
24
  const AUTH_USER = process.env.AUTH_USER || 'admin';
25
  const AUTH_PASS = process.env.AUTH_PASS || 'admin';
 
58
  }
59
 
60
  // Middleware
61
+ app.use(compression());
62
  app.use(cors());
63
+ app.use(express.json({ limit: '50mb' }));
64
  app.use(rateLimiter);
65
 
66
  // HTTP Basic Auth middleware for /api routes
 
126
  sys: config.sys || '',
127
  usr: config.usr,
128
  loop: Math.max(1, Math.min(10000, Number(config.loop) || 10)),
129
+ threads: Math.max(1, Math.min(MAX_THREADS, Number(config.threads) || 3)),
130
  max: Math.max(1, Math.min(32768, Number(config.max) || 1024)),
131
  temp: Math.max(0, Math.min(2, Number(config.temp) || 1)),
132
  timeout: Math.max(5000, Number(config.timeout) || 60000),
 
171
  if (!t.stats.total && t.config) {
172
  t.stats.total = (t.config.loop || 10) * (t.config.threads || 3);
173
  }
174
+ const status = executor?.getStatus() || null;
175
+ // Remove threadLogs and threadProgress from list view to reduce payload
176
+ if (status) {
177
+ delete status.threadLogs;
178
+ delete status.threadProgress;
179
+ }
180
+ // Create shallow copy and remove threadLogs/threadProgress from progress
181
+ const taskCopy = { ...t };
182
+ if (taskCopy.progress) {
183
+ taskCopy.progress = { ...taskCopy.progress };
184
+ delete taskCopy.progress.threadLogs;
185
+ delete taskCopy.progress.threadProgress;
186
+ }
187
  return {
188
+ ...taskCopy,
189
  running: executor?.running || false,
190
+ currentStats: status
191
  };
192
  });
193
  log.debug('GET /api/tasks, count:', result.length);
 
497
  res.sendFile(path.join(__dirname, 'token-consumer.html'));
498
  });
499
 
500
+ // Global error handler - must be after all routes
501
+ app.use((err, req, res, next) => {
502
+ log.error('Unhandled error:', err.message);
503
+ // Ensure JSON response for API routes
504
+ if (req.path.startsWith('/api')) {
505
+ return res.status(500).json({ error: err.message || '服务器内部错误' });
506
+ }
507
+ res.status(500).send('服务器内部错误');
508
+ });
509
+
510
  // Resume running tasks on startup
511
  function resumeRunningTasks() {
512
  const tasks = storage.getAllTasks();
test-api.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const https = require('https');
2
+
3
+ const BASE_URL = 'https://s2a.bbx.qzz.io';
4
+ const TOKEN = 'sk-b2ff52c85b4a654733d25bd7fc85f52d70fe7900613ae681a070b5e5ed880e47';
5
+
6
+ function testRequest(headers) {
7
+ return new Promise((resolve, reject) => {
8
+ const urlObj = new URL('/v1/models', BASE_URL);
9
+ const options = {
10
+ hostname: urlObj.hostname,
11
+ port: 443,
12
+ path: urlObj.pathname,
13
+ method: 'GET',
14
+ headers: {
15
+ 'Authorization': 'Bearer ' + TOKEN,
16
+ ...headers
17
+ }
18
+ };
19
+
20
+ console.log('Testing with headers:', JSON.stringify(headers, null, 2));
21
+
22
+ const req = https.request(options, (res) => {
23
+ let data = '';
24
+ res.on('data', chunk => data += chunk);
25
+ res.on('end', () => {
26
+ console.log(`Status: ${res.statusCode}`);
27
+ console.log('Response headers:', res.headers);
28
+ if (data) {
29
+ console.log('Response body:', data.slice(0, 500));
30
+ }
31
+ resolve({ status: res.statusCode, headers: res.headers, body: data });
32
+ });
33
+ });
34
+
35
+ req.on('error', reject);
36
+ req.end();
37
+ });
38
+ }
39
+
40
+ async function runTests() {
41
+ console.log('=== Test 1: Cherry Studio User-Agent only ===');
42
+ await testRequest({
43
+ 'User-Agent': 'cherry studio/1.0(secure client)'
44
+ });
45
+
46
+ console.log('\n=== Test 2: Modern Chrome browser ===');
47
+ await testRequest({
48
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
49
+ 'Accept': 'application/json, text/plain, */*',
50
+ 'Accept-Language': 'en-US,en;q=0.9',
51
+ 'Accept-Encoding': 'gzip, deflate, br',
52
+ 'Referer': 'https://chat.openai.com/',
53
+ 'Origin': 'https://chat.openai.com',
54
+ 'Sec-Fetch-Dest': 'empty',
55
+ 'Sec-Fetch-Mode': 'cors',
56
+ 'Sec-Fetch-Site': 'same-origin',
57
+ 'Connection': 'keep-alive',
58
+ 'Cache-Control': 'no-cache'
59
+ });
60
+
61
+ console.log('\n=== Test 3: More complete browser headers ===');
62
+ await testRequest({
63
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
64
+ 'Accept': 'application/json, text/plain, */*',
65
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
66
+ 'Accept-Encoding': 'gzip, deflate, br',
67
+ 'Referer': 'https://platform.openai.com/',
68
+ 'Origin': 'https://platform.openai.com',
69
+ 'Sec-Ch-Ua': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
70
+ 'Sec-Ch-Ua-Mobile': '?0',
71
+ 'Sec-Ch-Ua-Platform': '"Windows"',
72
+ 'Sec-Fetch-Dest': 'empty',
73
+ 'Sec-Fetch-Mode': 'cors',
74
+ 'Sec-Fetch-Site': 'same-site',
75
+ 'Connection': 'keep-alive',
76
+ 'Cache-Control': 'no-cache',
77
+ 'Pragma': 'no-cache'
78
+ });
79
+ }
80
+
81
+ runTests().catch(console.error);
test-token-rotation.js ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { getNextToken } = require('./executor');
2
+
3
+ // Test token rotation
4
+ function testTokenRotation() {
5
+ console.log('Testing token rotation...');
6
+
7
+ // Test with multiple tokens
8
+ const config = {
9
+ token: 'token1, token2, token3'
10
+ };
11
+
12
+ console.log('Testing with tokens:', config.token);
13
+
14
+ // Get tokens multiple times to test rotation
15
+ for (let i = 0; i < 10; i++) {
16
+ const { token, index, total } = getNextToken(config);
17
+ console.log(`Request ${i+1}: Using token: ${token} (Key: ${index+1}/${total})`);
18
+ }
19
+
20
+ // Test with single token
21
+ const singleTokenConfig = {
22
+ token: 'single-token'
23
+ };
24
+
25
+ console.log('\nTesting with single token:', singleTokenConfig.token);
26
+
27
+ for (let i = 0; i < 3; i++) {
28
+ const { token, index, total } = getNextToken(singleTokenConfig);
29
+ console.log(`Request ${i+1}: Using token: ${token} (Key: ${index+1}/${total})`);
30
+ }
31
+
32
+ // Test with empty token
33
+ const emptyTokenConfig = {
34
+ token: ''
35
+ };
36
+
37
+ console.log('\nTesting with empty token:');
38
+ const { token, index, total } = getNextToken(emptyTokenConfig);
39
+ console.log(`Using token: ${token} (Key: ${index+1}/${total})`);
40
+ }
41
+
42
+ testTokenRotation();
token-consumer.html CHANGED
@@ -38,7 +38,7 @@ button:disabled{opacity:.5;cursor:not-allowed;transform:none}
38
  .box label{font-size:11px;color:#64748b;margin-bottom:4px}
39
  .box b{display:block;font-size:22px;color:#60a5fa;margin-top:4px}
40
  .logs{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}
41
- .log{border:1px solid #334155;border-radius:10px;background:linear-gradient(145deg,#1e293b,#0f172a);padding:10px;transition:all 0.2s}
42
  .log.waiting{border-color:#475569}
43
  .log.running{border-color:#3b82f6;border-width:2px;box-shadow:0 0 15px rgba(59,130,246,0.3)}
44
  .log.success{border-color:#22c55e;box-shadow:0 0 10px rgba(34,197,94,0.2)}
@@ -48,7 +48,7 @@ button:disabled{opacity:.5;cursor:not-allowed;transform:none}
48
  .log.paused{border-color:#f59e0b;box-shadow:0 0 10px rgba(245,158,11,0.2)}
49
  .t{display:flex;justify-content:space-between;font-size:13px;font-weight:600;margin-bottom:6px;color:#e2e8f0}
50
  .m{font-size:11px;color:#64748b;margin-bottom:6px}
51
- pre{margin:0;background:#0f172a;color:#93c5fd;border-radius:6px;padding:8px;min-height:80px;max-height:180px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:11px}
52
  pre::-webkit-scrollbar{width:5px;height:5px}
53
  pre::-webkit-scrollbar-track{background:#1e293b;border-radius:3px}
54
  pre::-webkit-scrollbar-thumb{background:#475569;border-radius:3px}
@@ -238,6 +238,10 @@ pre::-webkit-scrollbar-thumb{background:#475569;border-radius:3px}
238
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
239
  <span id="bannerTime">0s</span>
240
  </div>
 
 
 
 
241
  <div class="meta-item">
242
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
243
  <span>成功 <b id="detailSuccess" style="color:#4ade80">0</b></span>
@@ -286,7 +290,7 @@ pre::-webkit-scrollbar-thumb{background:#475569;border-radius:3px}
286
  </div>
287
  <div class="g3" style="margin-top:8px">
288
  <label>每线程循环次数<input id="loop" type="number" min="1" value="10"></label>
289
- <label>并发线程数<input id="thr" type="number" min="1" value="3"></label>
290
  <label>max_tokens<input id="max" type="number" min="1" value="1024"></label>
291
  <label>temperature<input id="temp" type="number" min="0" max="2" step="0.1" value="1"></label>
292
  <label>单次超时秒数<input id="to" type="number" min="5" value="600"></label>
@@ -377,9 +381,20 @@ const getLocal=()=>{try{return JSON.parse(localStorage.getItem(LOCAL_KEY)||'{}')
377
  const saveLocal=d=>{try{localStorage.setItem(LOCAL_KEY,JSON.stringify(d))}catch{}};
378
 
379
  // API functions
380
- async function apiGet(url){const r=await fetch(API+url);return r.json()}
381
- async function apiPost(url,data){const r=await fetch(API+url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});return r.json()}
382
- async function apiDelete(url){const r=await fetch(API+url,{method:'DELETE'});return r.json()}
 
 
 
 
 
 
 
 
 
 
 
383
 
384
  // Load tasks
385
  async function loadTasks(){
@@ -480,6 +495,10 @@ function renderTaskList(){
480
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
481
  <span>${elapsedStr||'-'}</span>
482
  </div>
 
 
 
 
483
  <div class="meta-item">
484
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
485
  <span>成功 <b style="color:#4ade80">${stats.success||0}</b></span>
@@ -577,7 +596,7 @@ function getFormData(){
577
  sys:$('sys').value.trim(),
578
  usr:$('usr').value.trim(),
579
  loop:n($('loop').value,10,1,10000),
580
- threads:n($('thr').value,3,1,200),
581
  max:n($('max').value,1024,1,32768),
582
  temp:n($('temp').value,1,0,2),
583
  timeout:n($('to').value,600,5,3600)*1000,
@@ -767,6 +786,7 @@ function updateStats(s){
767
  $('bannerProgress').textContent=`${completed}/${total}`;
768
  $('bannerTokens').textContent=`${fmt(totalTokens)}/${fmt(maxTokens)}`;
769
  $('bannerTime').textContent=fmtTime(s.elapsed||0);
 
770
  $('detailSuccess').textContent=s.stats?.success||0;
771
  $('detailFailed').textContent=s.stats?.failed||0;
772
  $('detailAborted').textContent=s.stats?.aborted||0;
@@ -798,10 +818,18 @@ function updateLogs(threadLogs){
798
  }else if(log.message){
799
  displayContent=log.message;
800
  }
 
 
 
 
 
 
 
 
801
  return`
802
  <div class="log ${statusClass}">
803
  <div class="t"><span>线程 #${id}</span><span>${log.status}</span></div>
804
- <div class="m">循环:${log.loop||'-'}</div>
805
  <pre>${displayContent.slice(-500)}</pre>
806
  </div>
807
  `;
@@ -841,6 +869,7 @@ function updateTaskHeader(task){
841
  $('detailBadge').className='task-badge '+status;
842
  $('detailBadge').textContent=statusText;
843
  $('bannerLabel').textContent=statusText;
 
844
 
845
  updateButtons(task.running, task.currentStats?.paused, task.stopped);
846
  }
@@ -1050,6 +1079,7 @@ function newTask(){
1050
  $('bannerProgress').textContent='0/0';
1051
  $('bannerTokens').textContent='0/0';
1052
  $('bannerTime').textContent='0s';
 
1053
  $('detailSuccess').textContent='0';
1054
  $('detailFailed').textContent='0';
1055
  $('detailAborted').textContent='0';
 
38
  .box label{font-size:11px;color:#64748b;margin-bottom:4px}
39
  .box b{display:block;font-size:22px;color:#60a5fa;margin-top:4px}
40
  .logs{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}
41
+ .log{border:1px solid #334155;border-radius:10px;background:linear-gradient(145deg,#1e293b,#0f172a);padding:10px;transition:all 0.2s;height:180px;display:flex;flex-direction:column}
42
  .log.waiting{border-color:#475569}
43
  .log.running{border-color:#3b82f6;border-width:2px;box-shadow:0 0 15px rgba(59,130,246,0.3)}
44
  .log.success{border-color:#22c55e;box-shadow:0 0 10px rgba(34,197,94,0.2)}
 
48
  .log.paused{border-color:#f59e0b;box-shadow:0 0 10px rgba(245,158,11,0.2)}
49
  .t{display:flex;justify-content:space-between;font-size:13px;font-weight:600;margin-bottom:6px;color:#e2e8f0}
50
  .m{font-size:11px;color:#64748b;margin-bottom:6px}
51
+ pre{margin:0;background:#0f172a;color:#93c5fd;border-radius:6px;padding:8px;flex:1;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:11px}
52
  pre::-webkit-scrollbar{width:5px;height:5px}
53
  pre::-webkit-scrollbar-track{background:#1e293b;border-radius:3px}
54
  pre::-webkit-scrollbar-thumb{background:#475569;border-radius:3px}
 
238
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
239
  <span id="bannerTime">0s</span>
240
  </div>
241
+ <div class="meta-item">
242
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
243
+ <span>线程 <b id="detailThreads">0</b></span>
244
+ </div>
245
  <div class="meta-item">
246
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
247
  <span>成功 <b id="detailSuccess" style="color:#4ade80">0</b></span>
 
290
  </div>
291
  <div class="g3" style="margin-top:8px">
292
  <label>每线程循环次数<input id="loop" type="number" min="1" value="10"></label>
293
+ <label>并发线程数<input id="thr" type="number" min="1" max="1000" value="3"></label>
294
  <label>max_tokens<input id="max" type="number" min="1" value="1024"></label>
295
  <label>temperature<input id="temp" type="number" min="0" max="2" step="0.1" value="1"></label>
296
  <label>单次超时秒数<input id="to" type="number" min="5" value="600"></label>
 
381
  const saveLocal=d=>{try{localStorage.setItem(LOCAL_KEY,JSON.stringify(d))}catch{}};
382
 
383
  // API functions
384
+ async function apiRequest(url,options={}){
385
+ const r=await fetch(API+url,options);
386
+ const ct=r.headers.get('content-type')||'';
387
+ if(!ct.includes('application/json')){
388
+ const text=await r.text();
389
+ throw new Error(r.status===401?'需要认证,请刷新页面重新登录':'服务器返回非JSON响应: '+text.slice(0,100));
390
+ }
391
+ const data=await r.json();
392
+ if(!r.ok)throw new Error(data.error||'请求失败');
393
+ return data;
394
+ }
395
+ async function apiGet(url){return apiRequest(url)}
396
+ async function apiPost(url,data){return apiRequest(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})}
397
+ async function apiDelete(url){return apiRequest(url,{method:'DELETE'})}
398
 
399
  // Load tasks
400
  async function loadTasks(){
 
495
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
496
  <span>${elapsedStr||'-'}</span>
497
  </div>
498
+ <div class="meta-item">
499
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
500
+ <span>线程 <b>${t.config?.threads||0}</b></span>
501
+ </div>
502
  <div class="meta-item">
503
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
504
  <span>成功 <b style="color:#4ade80">${stats.success||0}</b></span>
 
596
  sys:$('sys').value.trim(),
597
  usr:$('usr').value.trim(),
598
  loop:n($('loop').value,10,1,10000),
599
+ threads:n($('thr').value,3,1,1000),
600
  max:n($('max').value,1024,1,32768),
601
  temp:n($('temp').value,1,0,2),
602
  timeout:n($('to').value,600,5,3600)*1000,
 
786
  $('bannerProgress').textContent=`${completed}/${total}`;
787
  $('bannerTokens').textContent=`${fmt(totalTokens)}/${fmt(maxTokens)}`;
788
  $('bannerTime').textContent=fmtTime(s.elapsed||0);
789
+ $('detailThreads').textContent=$('thr').value||0;
790
  $('detailSuccess').textContent=s.stats?.success||0;
791
  $('detailFailed').textContent=s.stats?.failed||0;
792
  $('detailAborted').textContent=s.stats?.aborted||0;
 
818
  }else if(log.message){
819
  displayContent=log.message;
820
  }
821
+ // Build info line
822
+ let infoLine=`循环:${log.loop||'-'}`;
823
+ if(log.markerLen!==undefined){
824
+ infoLine+=` | 标记:${log.markerLen}`;
825
+ }
826
+ if(log.tokenTotal!==undefined && log.tokenTotal>1){
827
+ infoLine+=` | Key:${log.tokenIndex+1}/${log.tokenTotal}`;
828
+ }
829
  return`
830
  <div class="log ${statusClass}">
831
  <div class="t"><span>线程 #${id}</span><span>${log.status}</span></div>
832
+ <div class="m">${infoLine}</div>
833
  <pre>${displayContent.slice(-500)}</pre>
834
  </div>
835
  `;
 
869
  $('detailBadge').className='task-badge '+status;
870
  $('detailBadge').textContent=statusText;
871
  $('bannerLabel').textContent=statusText;
872
+ $('detailThreads').textContent=$('thr').value||0;
873
 
874
  updateButtons(task.running, task.currentStats?.paused, task.stopped);
875
  }
 
1079
  $('bannerProgress').textContent='0/0';
1080
  $('bannerTokens').textContent='0/0';
1081
  $('bannerTime').textContent='0s';
1082
+ $('detailThreads').textContent=$('thr').value||'0';
1083
  $('detailSuccess').textContent='0';
1084
  $('detailFailed').textContent='0';
1085
  $('detailAborted').textContent='0';