dsh-plugin-image-workbench 1.1.9 → 1.1.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-image-workbench",
3
- "version": "1.1.9",
3
+ "version": "1.1.11",
4
4
  "type": "module",
5
5
  "description": "Bluxe手绘工坊 · 生图工作台(图文 / 摄影):DSH 侧栏图像生成工作台,默认使用内置本地账号池,亦支持任意 OpenAI 兼容接口(Base URL / API Key / 模型);可自定义工作台名称与图片存储目录。",
6
6
  "main": "./host.js",
package/server/app.py CHANGED
@@ -420,6 +420,43 @@ async def health():
420
420
  return {"ok": True, "version": VERSION}
421
421
 
422
422
 
423
+ def _ver_key(v: str) -> tuple:
424
+ """把 x.y.z 解析成可比较的元组,非数字段按 0 处理。"""
425
+ out = []
426
+ for seg in (v or "").split("."):
427
+ out.append(int(seg) if seg.isdigit() else 0)
428
+ return tuple(out)
429
+
430
+
431
+ _UPGRADE_CACHE: dict = {"at": 0.0, "data": None}
432
+
433
+
434
+ @app.get("/api/upgrade")
435
+ async def api_upgrade():
436
+ """对比 npm 上的最新版本,供左下角出「有新版本」提示。
437
+
438
+ 走 npmmirror 直连(国内稳),10 分钟缓存;网络不通就当没有新版本,
439
+ 绝不因为查版本失败打扰使用。trust_env=False:不吃代理环境变量,避免被带偏。
440
+ """
441
+ now = time.time()
442
+ if _UPGRADE_CACHE["data"] is not None and now - _UPGRADE_CACHE["at"] < 600:
443
+ return JSONResponse(_UPGRADE_CACHE["data"])
444
+ data = {"current": VERSION, "latest": "", "has_new": False}
445
+ try:
446
+ import httpx
447
+ async with httpx.AsyncClient(trust_env=False, timeout=6) as c:
448
+ r = await c.get("https://registry.npmmirror.com/dsh-plugin-image-workbench/latest")
449
+ if r.status_code == 200:
450
+ lat = str(r.json().get("version") or "").strip()
451
+ data["latest"] = lat
452
+ data["has_new"] = bool(lat) and _ver_key(lat) > _ver_key(VERSION)
453
+ except Exception:
454
+ pass
455
+ _UPGRADE_CACHE["at"] = now
456
+ _UPGRADE_CACHE["data"] = data
457
+ return JSONResponse(data)
458
+
459
+
423
460
  @app.get("/api/catalog")
424
461
  async def api_catalog():
425
462
  return JSONResponse(CATALOG)
@@ -480,6 +517,20 @@ async def api_pool_refresh():
480
517
  return JSONResponse(POOL.snapshot())
481
518
 
482
519
 
520
+ # 并行数:默认 3,可调,上限 = 可用账号数。调完立即生效(不重启)
521
+ class WorkersBody(BaseModel):
522
+ workers: int = 0
523
+
524
+
525
+ @app.post("/api/pool/workers")
526
+ async def api_pool_workers(body: WorkersBody):
527
+ res = await POOL.set_workers(body.workers)
528
+ # 带一份最新快照回去,前端不用再等下一轮轮询
529
+ res["snapshot"] = {"workers": POOL.workers, "workers_max": POOL.workers_cap(),
530
+ "queued": POOL.snapshot()["queued"]}
531
+ return JSONResponse(res)
532
+
533
+
483
534
  @app.get("/api/pool/fails")
484
535
  async def api_pool_fails(days: int = 7):
485
536
  """账号失败账本:今天每个号失败几次、最近几天各失败几次。
package/server/pool.py CHANGED
@@ -35,9 +35,17 @@ STATE_FILE = BASE / "workbench" / "pending_jobs.json"
35
35
  DEFAULT_POOL_DIR = Path(os.environ.get(
36
36
  "DSH_IMAGE_POOL_DIR", str(Path.home() / ".dsh" / "chatgpt2api-image")))
37
37
 
38
- # 号池 config.json 里 image_account_concurrency=3,本地 worker 与它对齐,
39
- # 再多也不会更快,只会让上游排队。
40
- WORKERS = max(1, min(3, int(os.environ.get("WORKBENCH_WORKERS", "3") or 3)))
38
+ # 并行数(worker 个数)现在由用户自己定:默认 3,写进 config.local.json 的 workers。
39
+ # 上限 = 有效账号数(可用账号),一个号同时只出一张图;账号明细读不到时退回默认值,
40
+ # 不会因为一次探测失败就把用户设置判成非法。绝对硬顶防止手抖填出四位数。
41
+ WORKERS_DEFAULT = 3
42
+ WORKERS_ABS_MAX = 40
43
+ # 兼容旧引用(环境变量仍可给个初值)
44
+ WORKERS = max(1, min(WORKERS_ABS_MAX,
45
+ int(os.environ.get("WORKBENCH_WORKERS", WORKERS_DEFAULT) or WORKERS_DEFAULT)))
46
+ # 队列里的「提前下班」令牌:并行上限下调时塞进队列,worker 取到它就退出。
47
+ # 用令牌而不是 cancel 任务,是为了不打断正在出图的那张(cancel 会连带杀掉在途请求)
48
+ RETIRE_TOKEN = "__wb_retire__"
41
49
  IMAGE_TIMEOUT = 300 # 实测单张 45~62 秒,留足余量
42
50
  TEXT_TIMEOUT = 180
43
51
  # 失败不中断任务:重新排队就是换号(号池按 round-robin 取号),最多换 3 轮。
@@ -333,6 +341,8 @@ def load_config() -> dict:
333
341
  "edits_style": "auto", # auto | json | multipart | off
334
342
  "image_response_format": "auto", # auto | b64_json | url
335
343
  "monitor_mode": "auto", # auto | pool | single
344
+ # 并行出图数(worker 个数):默认 3,上限由「可用账号数」决定
345
+ "workers": WORKERS,
336
346
  }
337
347
  if CONFIG_FILE.exists():
338
348
  try:
@@ -342,6 +352,39 @@ def load_config() -> dict:
342
352
  return cfg
343
353
 
344
354
 
355
+ def save_config(updates: dict) -> dict:
356
+ """把若干键合并写回 config.local.json(原子替换),返回写后的完整配置。
357
+
358
+ 只做浅合并:别的键原样留着,不会因为存一个并行数就把接口配置抹掉。
359
+ """
360
+ cfg: dict = {}
361
+ if CONFIG_FILE.exists():
362
+ try:
363
+ got = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
364
+ if isinstance(got, dict):
365
+ cfg = got
366
+ except Exception as e:
367
+ log(f"config.local.json 读取失败(准备覆盖写): {e}")
368
+ cfg.update(updates or {})
369
+ try:
370
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
371
+ tmp = CONFIG_FILE.with_suffix(".json.tmp")
372
+ tmp.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
373
+ tmp.replace(CONFIG_FILE)
374
+ except Exception as e:
375
+ log(f"config.local.json 写入失败: {mask(e)[:160]}")
376
+ return cfg
377
+
378
+
379
+ def workers_from_cfg() -> int:
380
+ """启动时的并行数:配置里存的值优先,非法值一律退回默认 3。"""
381
+ try:
382
+ n = int(load_config().get("workers") or WORKERS_DEFAULT)
383
+ except Exception:
384
+ n = WORKERS_DEFAULT
385
+ return max(1, min(WORKERS_ABS_MAX, n))
386
+
387
+
345
388
  def _read_pool_user_key(cfg: dict) -> str:
346
389
  """从号池目录里的 user-api-keys.json 读第一个 sk- 用户 Key(仅号池模式用)。"""
347
390
  p = pool_home(cfg) / "user-api-keys.json"
@@ -512,6 +555,11 @@ class PoolManager:
512
555
  self.queue: asyncio.Queue[str] = asyncio.Queue()
513
556
  self.running: dict[str, dict] = {}
514
557
  self._workers: list[asyncio.Task] = []
558
+ # 并行数:用户可调,持久化在 config.local.json 的 workers
559
+ self.workers: int = workers_from_cfg()
560
+ self._wid_seq: int = 0 # worker 编号只增不减,日志里对得上
561
+ self._retire_pending: int = 0 # 队列里还没被取走的「提前下班」令牌数
562
+ self._workers_lock = asyncio.Lock()
515
563
  # 正在执行的单个任务:手动停止时只取消它,不杀 worker,队列才能继续接新活
516
564
  self._job_tasks: dict[str, asyncio.Task] = {}
517
565
  self._lock = asyncio.Lock()
@@ -539,13 +587,77 @@ class PoolManager:
539
587
  await self.refresh_env_ids()
540
588
  await self.refresh_accounts(force=True)
541
589
  self._restore()
542
- for i in range(WORKERS):
543
- self._workers.append(asyncio.create_task(self._worker(i + 1)))
590
+ self.workers = workers_from_cfg()
591
+ for _ in range(self.workers):
592
+ self._spawn_worker()
544
593
  self._acct_task = asyncio.create_task(self._account_refresh_loop())
545
594
  p = provider_cfg()
546
- log(f"本地执行层启动:{WORKERS} 个 worker,接口 {p['base_url']}"
595
+ log(f"本地执行层启动:{self.workers} 个 worker,接口 {p['base_url']}"
547
596
  f"({p['name']}),已同步 {len(self.accounts)} 个账号")
548
597
 
598
+ def _spawn_worker(self) -> asyncio.Task:
599
+ self._wid_seq += 1
600
+ t = asyncio.create_task(self._worker(self._wid_seq))
601
+ self._workers.append(t)
602
+ return t
603
+
604
+ # ---------- 并行数(用户可调) ----------
605
+ def valid_accounts(self) -> int:
606
+ """有效账号数 = 状态为可用(非限流/离线)的号。
607
+
608
+ 明细拿不到时退回号池自报的 active(/health 汇总);都拿不到就是 0。
609
+ """
610
+ ready = sum(1 for a in self.accounts if a.get("status") == "ready")
611
+ if ready:
612
+ return ready
613
+ try:
614
+ return int(self.pool_stat.get("active") or 0)
615
+ except Exception:
616
+ return 0
617
+
618
+ def workers_cap(self) -> int:
619
+ """并行上限:不超过有效账号数(一个号同时只出一张图)。"""
620
+ n = self.valid_accounts()
621
+ if n <= 0:
622
+ # 账号明细暂时读不到时不收紧,免得一次探测失败就把用户的设置判成非法
623
+ return max(WORKERS_DEFAULT, self.workers)
624
+ return max(1, min(n, WORKERS_ABS_MAX))
625
+
626
+ async def set_workers(self, n: int) -> dict:
627
+ """改并行数:多的立刻拉起来,少的发「下班令牌」等它把手上的图出完再退。"""
628
+ async with self._workers_lock:
629
+ cap = self.workers_cap()
630
+ try:
631
+ want = int(n)
632
+ except Exception:
633
+ return {"ok": False, "workers": self.workers, "max": cap,
634
+ "message": "并行数必须是整数"}
635
+ if want < 1:
636
+ return {"ok": False, "workers": self.workers, "max": cap,
637
+ "message": "并行数至少 1"}
638
+ target = min(want, cap)
639
+ old = self.workers
640
+ # 已经退出的 worker 任务从列表里清掉,别让列表越堆越长
641
+ self._workers = [t for t in self._workers if not t.done()]
642
+ if target > old:
643
+ for _ in range(target - old):
644
+ self._spawn_worker()
645
+ elif target < old:
646
+ for _ in range(old - target):
647
+ self._retire_pending += 1
648
+ await self.queue.put(RETIRE_TOKEN)
649
+ self.workers = target
650
+ save_config({"workers": target})
651
+ if target != old:
652
+ log(f"并行数 {old} → {target}"
653
+ + (f"(请求 {want},按可用账号 {cap} 收紧)" if target != want else ""))
654
+ msg = f"并行上限已设为 {target}"
655
+ if want > cap:
656
+ msg = f"最多 {cap} 个:并行数不能超过可用账号数,已按 {cap} 执行"
657
+ return {"ok": True, "workers": target, "max": cap, "requested": want,
658
+ "prev": old, "clamped": want > cap, "message": msg}
659
+
660
+
549
661
  # ---------- 任务落盘 / 恢复 ----------
550
662
  def _persist(self) -> None:
551
663
  """把「还没结束」的任务写进磁盘。写完原子替换,进程被杀也不会写出半个文件。"""
@@ -868,7 +980,7 @@ class PoolManager:
868
980
 
869
981
  账号状态只在这时候才会变,监控和后台同步据此决定刷多快。
870
982
  """
871
- return bool(self.running) or self.queue.qsize() > 0
983
+ return bool(self.running) or (self.queue.qsize() - self._retire_pending) > 0
872
984
 
873
985
  async def refresh_accounts_throttled(self, busy_interval: float = 3.0,
874
986
  idle_interval: float = 20.0) -> None:
@@ -1003,8 +1115,12 @@ class PoolManager:
1003
1115
  "busy": len(self.running),
1004
1116
  # 「现在有活干」:正在出图或还有排队。别和 pool_stat 里的 active(可用账号数)搞混
1005
1117
  "running_now": self.is_busy(),
1006
- "workers": WORKERS,
1007
- "queued": self.queue.qsize(),
1118
+ "workers": self.workers,
1119
+ "workers_max": self.workers_cap(),
1120
+ "workers_default": WORKERS_DEFAULT,
1121
+ "valid_accounts": self.valid_accounts(),
1122
+ # 队列里混着「下班令牌」,排队数只算真任务
1123
+ "queued": max(0, self.queue.qsize() - self._retire_pending),
1008
1124
  "running": running,
1009
1125
  "current": current,
1010
1126
  "rr_index": self._rr_index,
@@ -1034,11 +1150,17 @@ class PoolManager:
1034
1150
  '''
1035
1151
  stopped: list[str] = []
1036
1152
  dropped = 0
1153
+ retire_lost = 0
1037
1154
  while True:
1038
1155
  try:
1039
1156
  jid = self.queue.get_nowait()
1040
1157
  except asyncio.QueueEmpty:
1041
1158
  break
1159
+ if jid == RETIRE_TOKEN:
1160
+ # 下班令牌不是任务,别当洪水清掉:并行数下调的意图还得兑现
1161
+ retire_lost += 1
1162
+ self.queue.task_done()
1163
+ continue
1042
1164
  job = self.jobs.get(jid)
1043
1165
  if job and job.get("status") in ("queued", "running"):
1044
1166
  job.update({"status": "cancelled", "error": reason,
@@ -1046,6 +1168,8 @@ class PoolManager:
1046
1168
  stopped.append(jid)
1047
1169
  dropped += 1
1048
1170
  self.queue.task_done()
1171
+ for _ in range(retire_lost):
1172
+ await self.queue.put(RETIRE_TOKEN)
1049
1173
  # 先改状态再 cancel:_run_job 的 finally 才不会写历史或安排重试
1050
1174
  running = []
1051
1175
  for jid, task in list(self._job_tasks.items()):
@@ -1083,6 +1207,11 @@ class PoolManager:
1083
1207
  while True:
1084
1208
  job_id = await self.queue.get()
1085
1209
  try:
1210
+ if job_id == RETIRE_TOKEN:
1211
+ # 并行上限被下调:把手上的活干完再走,不打断在途请求
1212
+ self._retire_pending = max(0, self._retire_pending - 1)
1213
+ log(f"worker {wid}: 退出(并行数下调)")
1214
+ return
1086
1215
  job = self.jobs.get(job_id)
1087
1216
  if not job or job.get("status") == "cancelled":
1088
1217
  continue
@@ -288,9 +288,9 @@
288
288
  .pill{display:inline-flex;align-items:center;gap:6px;background:#f7f5ff;border:1px solid var(--line);
289
289
  border-radius:12px;padding:3px 9px;font-size:11px;color:var(--ink-2)}
290
290
  .footer-note{margin-top:12px;font-size:10.5px;color:var(--ink-3);line-height:1.6}
291
- /* 左栏底部版本号:左栏与中/右栏同高,版本钉在卡片左下角 */
292
- .panel.left{align-self:stretch;display:flex;flex-direction:column}
293
- .ver-foot{margin-top:auto;padding-top:12px;font-size:11px;color:var(--ink-3);opacity:.7}
291
+ /* 左栏底部版本号:跟在内容正下方,不与别的栏对齐;有新版本时多一行升级提示 */
292
+ .ver-foot{margin-top:16px;font-size:13px;color:var(--ink-3)}
293
+ .ver-foot .up-new{margin-top:5px;font-size:12px;color:var(--primary);cursor:default}
294
294
  .strip-wrap{display:flex;align-items:center;gap:6px}
295
295
  .strip{position:relative;flex:1;display:flex;gap:8px;overflow-x:auto;padding:6px 2px;min-height:100px;scroll-behavior:smooth}
296
296
  .strip::-webkit-scrollbar{height:8px}
@@ -457,6 +457,24 @@
457
457
  .mon-kpi span{display:block;font-size:10.5px;color:var(--ink-3)}
458
458
  .mon-kpi b{font-size:17px;letter-spacing:.4px}
459
459
  .mon-kpi b.ok{color:var(--ok)} .mon-kpi b.warn{color:var(--warn)} .mon-kpi b.err{color:var(--err)}
460
+ /* 并行上限控制条:夹在 KPI 和账号表之间,一行放下,窄屏自动换行 */
461
+ .mon-wbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;
462
+ border:1px solid var(--line);border-radius:11px;padding:6px 10px;background:#fbfaff;margin:0 0 2px}
463
+ .mon-wbar .wlbl{font-size:11px;color:var(--ink-3)}
464
+ .mon-wbar .wctl{display:flex;align-items:center;gap:1px;border:1px solid var(--line);
465
+ background:#fff;border-radius:9px;padding:2px}
466
+ .mon-wbar .wbtn{width:22px;height:22px;border:0;background:transparent;color:var(--ink-2);
467
+ font-size:14px;line-height:1;border-radius:7px;cursor:pointer;padding:0}
468
+ .mon-wbar .wbtn:hover:not(:disabled){background:var(--chip);color:var(--primary)}
469
+ .mon-wbar .wbtn:disabled{opacity:.35;cursor:not-allowed}
470
+ .mon-wbar #monW{width:42px;height:22px;border:0;background:transparent;text-align:center;
471
+ font:600 13px/1 inherit;color:var(--primary);outline:none;padding:0}
472
+ .mon-wbar #monW::-webkit-outer-spin-button,.mon-wbar #monW::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
473
+ .mon-wbar #monW:focus{background:var(--chip);border-radius:7px}
474
+ .mon-wbar .whint{font-size:10.5px;color:var(--ink-3);line-height:1.5}
475
+ .mon-wbar .whint.ok{color:var(--ok)}
476
+ .mon-wbar .whint.err{color:var(--err)}
477
+ .mon-wbar .whint.warn{color:var(--warn)}
460
478
  .mon-sec{display:flex;align-items:center;gap:8px;margin:12px 0 6px;font-size:12px}
461
479
  .mon-sec span{font-size:11px;color:var(--ink-3)}
462
480
  .mon-sec .spacer{flex:1}
@@ -674,7 +692,10 @@
674
692
  </div>
675
693
  <div class="status" id="splitStatus"></div>
676
694
  <div class="md" id="docRaw" style="display:none"></div>
677
- <div class="ver-foot" id="verFoot" title="当前工作台版本"></div>
695
+ <div class="ver-foot">
696
+ <div id="verFoot" title="当前工作台版本"></div>
697
+ <div class="up-new" id="upNote" hidden></div>
698
+ </div>
678
699
  </section>
679
700
 
680
701
  <!-- 配图工坊 -->
@@ -982,6 +1003,16 @@
982
1003
  <button class="btn ghost mini" id="monClose" type="button">关闭</button>
983
1004
  </div>
984
1005
  <div class="mon-sum" id="monSum"></div>
1006
+ <!-- 并行上限:默认 3,可自行加减。上限跟着可用账号数走,超了自动压回来 -->
1007
+ <div class="mon-wbar">
1008
+ <span class="wlbl">并行上限</span>
1009
+ <div class="wctl">
1010
+ <button type="button" class="wbtn" id="monWDown" title="减 1" aria-label="并行上限减 1">−</button>
1011
+ <input id="monW" type="number" min="1" step="1" inputmode="numeric" aria-label="并行上限">
1012
+ <button type="button" class="wbtn" id="monWUp" title="加 1" aria-label="并行上限加 1">+</button>
1013
+ </div>
1014
+ <span class="whint" id="monWHint">默认 3,最多不超过可用账号数</span>
1015
+ </div>
985
1016
  <div class="mon-sec"><b>账号状态</b><span id="monAccHint"></span><span class="spacer"></span><span id="monCheckHint"></span></div>
986
1017
  <div class="mon-box">
987
1018
  <table class="mon">
@@ -3913,8 +3944,92 @@ async function stopAllJobs(){
3913
3944
  const ST_LABEL = {ready:'可用', busy:'出图中', limited:'限流', unhealthy:'已下线', unbound:'未绑定', pending:'待验证', error:'异常'};
3914
3945
  let monTimer = null;
3915
3946
 
3916
- function kpi(label, val, cls){
3917
- return `<div class="mon-kpi"><span>${label}</span><b class="${cls || ''}">${val}</b></div>`;
3947
+ /* ---------- 并行上限:默认 3,用户可调,封顶 = 可用账号数 ---------- */
3948
+ let _wBusy = false, _wLast = null, _wHintTimer = null;
3949
+
3950
+ function syncWorkersCtl(d){
3951
+ _wLast = d;
3952
+ const inp = $('#monW'); if (!inp) return;
3953
+ const cap = Math.max(1, Number(d.workers_max || d.workers || 3));
3954
+ const cur = Math.max(1, Number(d.workers || 3));
3955
+ const valid = Number(d.valid_accounts || 0);
3956
+ inp.max = cap;
3957
+ // 正在输入或正在提交时不覆盖,免得把用户打到一半的数字顶掉
3958
+ if (document.activeElement !== inp && !_wBusy){
3959
+ inp.value = cur;
3960
+ inp.dataset.saved = cur;
3961
+ }
3962
+ $('#monWDown').disabled = cur <= 1;
3963
+ $('#monWUp').disabled = cur >= cap;
3964
+ if (_wHintTimer) return; // 刚保存过,提示文字留给它自己过期
3965
+ const hint = $('#monWHint');
3966
+ const over = cur > cap;
3967
+ hint.className = 'whint' + (over ? ' warn' : '');
3968
+ hint.textContent = over
3969
+ ? `当前 ${cur} 个,已超过可用账号 ${cap} 个,建议调低`
3970
+ : `默认 3,最多 ${cap} 个${valid ? `(可用账号 ${valid} 个)` : ''}`;
3971
+ }
3972
+
3973
+ function flashWorkersHint(ms){
3974
+ clearTimeout(_wHintTimer);
3975
+ _wHintTimer = setTimeout(()=>{
3976
+ _wHintTimer = null;
3977
+ if (_wLast) syncWorkersCtl(_wLast);
3978
+ }, ms);
3979
+ }
3980
+
3981
+ async function saveWorkers(v){
3982
+ const inp = $('#monW'), hint = $('#monWHint');
3983
+ const n = Math.round(Number(v));
3984
+ if (!Number.isFinite(n) || n < 1){
3985
+ hint.className = 'whint err'; hint.textContent = '并行数至少 1';
3986
+ flashWorkersHint(2600); return;
3987
+ }
3988
+ if (_wBusy) return;
3989
+ _wBusy = true;
3990
+ hint.className = 'whint'; hint.textContent = '保存中…';
3991
+ try{
3992
+ const r = await fetch('/api/pool/workers', {method:'POST',
3993
+ headers:{'Content-Type':'application/json'}, body: JSON.stringify({workers:n})});
3994
+ const d = await r.json();
3995
+ if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
3996
+ inp.value = d.workers;
3997
+ inp.dataset.saved = d.workers;
3998
+ hint.className = 'whint ' + (d.clamped ? 'warn' : 'ok');
3999
+ hint.textContent = d.message || ('并行上限已设为 ' + d.workers);
4000
+ flashWorkersHint(4200);
4001
+ }catch(e){
4002
+ hint.className = 'whint err';
4003
+ hint.textContent = '没存上:' + ((e && e.message) || e);
4004
+ flashWorkersHint(5200);
4005
+ }finally{
4006
+ _wBusy = false;
4007
+ }
4008
+ }
4009
+
4010
+ function bindWorkersCtl(){
4011
+ const inp = $('#monW'); if (!inp || inp._bound) return;
4012
+ inp._bound = 1;
4013
+ const step = (delta) => {
4014
+ const v = Math.round(Number(inp.value) || 0) + delta;
4015
+ inp.value = v;
4016
+ saveWorkers(v);
4017
+ };
4018
+ $('#monWUp').onclick = () => step(1);
4019
+ $('#monWDown').onclick = () => step(-1);
4020
+ inp.addEventListener('change', () => {
4021
+ // 回车已经存过一遍,失焦时别再发一次
4022
+ if (Number(inp.value) === Number(inp.dataset.saved || 0)) return;
4023
+ saveWorkers(inp.value);
4024
+ });
4025
+ inp.addEventListener('keydown', (e) => {
4026
+ if (e.key === 'Enter'){ e.preventDefault(); saveWorkers(inp.value); inp.blur(); }
4027
+ if (e.key === 'Escape'){ e.preventDefault(); inp.blur(); }
4028
+ });
4029
+ }
4030
+
4031
+ function kpi(label, val, cls, tip){
4032
+ return `<div class="mon-kpi"${tip ? ` title="${tip}"` : ''}><span>${label}</span><b class="${cls || ''}">${val}</b></div>`;
3918
4033
  }
3919
4034
 
3920
4035
  function agoText(ts){
@@ -3942,11 +4057,13 @@ function renderMonitor(d){
3942
4057
  const kTotal = accs.length ? accs.filter(a=>a.env_id).length : Number(poolS.total || 0);
3943
4058
  $('#monSum').innerHTML =
3944
4059
  kpi('账号可用', `${kReady}/${kTotal}`, kReady ? 'ok' : 'err')
3945
- + kpi('并行任务', `${run.length}/${d.workers || 1}`, run.length ? 'warn' : '')
4060
+ + kpi('并行任务', `${run.length}/${d.workers || 1}`, run.length ? 'warn' : '',
4061
+ '并行上限可调:改下面那条「并行上限」的 − / + 或直接填数字,最多不超过可用账号数')
3946
4062
  + kpi('排队', d.queued || 0, d.queued ? 'warn' : '')
3947
4063
  + kpi('今日成功', hist.today_done || 0, 'ok')
3948
4064
  + kpi('今日失败', hist.today_error || 0, hist.today_error ? 'err' : '')
3949
4065
  + kpi('历史总量', hist.total || 0, '');
4066
+ syncWorkersCtl(d);
3950
4067
 
3951
4068
  let lastUsedAcc = null;
3952
4069
  for (const a of accs){
@@ -4070,6 +4187,7 @@ async function refreshMonitor(){
4070
4187
  }
4071
4188
 
4072
4189
  async function openMonitor(){
4190
+ bindWorkersCtl();
4073
4191
  $('#monitorDlg').showModal();
4074
4192
  await refreshMonitor();
4075
4193
  scheduleMonitor();
@@ -4744,6 +4862,18 @@ setInterval(refreshPool, 10000);
4744
4862
  if (el && d && d.version) el.textContent = 'v' + d.version;
4745
4863
  })
4746
4864
  .catch(function(){});
4865
+ /* 有新版本就在版本号下面提示一行;查失败就当没有,不打扰使用 */
4866
+ fetch('/api/upgrade', {cache: 'no-store'})
4867
+ .then(function(r){ return r.ok ? r.json() : null; })
4868
+ .then(function(d){
4869
+ var el = document.getElementById('upNote');
4870
+ if (!el || !d || !d.has_new || !d.latest) return;
4871
+ el.hidden = false;
4872
+ el.textContent = '发现新版本 v' + d.latest + ',建议升级 →';
4873
+ el.title = '当前 v' + d.current + '。在 DSH「插件」里更新即可;'
4874
+ + '或在插件目录执行 npm i dsh-plugin-image-workbench@latest 后重跑 node install.mjs,装完重启 DSH。';
4875
+ })
4876
+ .catch(function(){});
4747
4877
  })();
4748
4878
  </script>
4749
4879