dsh-plugin-image-workbench 1.1.10 → 1.1.12

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.10",
3
+ "version": "1.1.12",
4
4
  "type": "module",
5
5
  "description": "Bluxe手绘工坊 · 生图工作台(图文 / 摄影):DSH 侧栏图像生成工作台,默认使用内置本地账号池,亦支持任意 OpenAI 兼容接口(Base URL / API Key / 模型);可自定义工作台名称与图片存储目录。",
6
6
  "main": "./host.js",
package/server/app.py CHANGED
@@ -517,6 +517,20 @@ async def api_pool_refresh():
517
517
  return JSONResponse(POOL.snapshot())
518
518
 
519
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
+
520
534
  @app.get("/api/pool/fails")
521
535
  async def api_pool_fails(days: int = 7):
522
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,80 @@ 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
+ 「正在出图」也是有效账号,不能因为它在忙就从上限里扣掉:否则一边出图
609
+ 一边上限往下掉,用户刚设好的并行数会被自己压回去。
610
+ 限流 / 已下线 / 未绑定 / 待验证 / 异常这些才算无效。
611
+ """
612
+ if self.accounts:
613
+ return sum(1 for a in self.accounts
614
+ if a.get("status") in ("ready", "busy"))
615
+ # 账号明细拿不到时退回号池自报的可用数
616
+ try:
617
+ return int(self.pool_stat.get("active") or 0)
618
+ except Exception:
619
+ return 0
620
+
621
+ def workers_cap(self) -> int:
622
+ """并行上限:不超过有效账号数(一个号同时只出一张图)。"""
623
+ n = self.valid_accounts()
624
+ if n <= 0:
625
+ # 账号明细暂时读不到时不收紧,免得一次探测失败就把用户的设置判成非法
626
+ return max(WORKERS_DEFAULT, self.workers)
627
+ return max(1, min(n, WORKERS_ABS_MAX))
628
+
629
+ async def set_workers(self, n: int) -> dict:
630
+ """改并行数:多的立刻拉起来,少的发「下班令牌」等它把手上的图出完再退。"""
631
+ async with self._workers_lock:
632
+ cap = self.workers_cap()
633
+ try:
634
+ want = int(n)
635
+ except Exception:
636
+ return {"ok": False, "workers": self.workers, "max": cap,
637
+ "message": "并行数必须是整数"}
638
+ if want < 1:
639
+ return {"ok": False, "workers": self.workers, "max": cap,
640
+ "message": "并行数至少 1"}
641
+ target = min(want, cap)
642
+ old = self.workers
643
+ # 已经退出的 worker 任务从列表里清掉,别让列表越堆越长
644
+ self._workers = [t for t in self._workers if not t.done()]
645
+ if target > old:
646
+ for _ in range(target - old):
647
+ self._spawn_worker()
648
+ elif target < old:
649
+ for _ in range(old - target):
650
+ self._retire_pending += 1
651
+ await self.queue.put(RETIRE_TOKEN)
652
+ self.workers = target
653
+ save_config({"workers": target})
654
+ if target != old:
655
+ log(f"并行数 {old} → {target}"
656
+ + (f"(请求 {want},按有效账号 {cap} 收紧)" if target != want else ""))
657
+ msg = f"并行上限已设为 {target}"
658
+ if want > cap:
659
+ msg = f"最多 {cap} 个:并行数不能超过有效账号数,已按 {cap} 执行"
660
+ return {"ok": True, "workers": target, "max": cap, "requested": want,
661
+ "prev": old, "clamped": want > cap, "message": msg}
662
+
663
+
549
664
  # ---------- 任务落盘 / 恢复 ----------
550
665
  def _persist(self) -> None:
551
666
  """把「还没结束」的任务写进磁盘。写完原子替换,进程被杀也不会写出半个文件。"""
@@ -868,7 +983,7 @@ class PoolManager:
868
983
 
869
984
  账号状态只在这时候才会变,监控和后台同步据此决定刷多快。
870
985
  """
871
- return bool(self.running) or self.queue.qsize() > 0
986
+ return bool(self.running) or (self.queue.qsize() - self._retire_pending) > 0
872
987
 
873
988
  async def refresh_accounts_throttled(self, busy_interval: float = 3.0,
874
989
  idle_interval: float = 20.0) -> None:
@@ -1003,8 +1118,12 @@ class PoolManager:
1003
1118
  "busy": len(self.running),
1004
1119
  # 「现在有活干」:正在出图或还有排队。别和 pool_stat 里的 active(可用账号数)搞混
1005
1120
  "running_now": self.is_busy(),
1006
- "workers": WORKERS,
1007
- "queued": self.queue.qsize(),
1121
+ "workers": self.workers,
1122
+ "workers_max": self.workers_cap(),
1123
+ "workers_default": WORKERS_DEFAULT,
1124
+ "valid_accounts": self.valid_accounts(),
1125
+ # 队列里混着「下班令牌」,排队数只算真任务
1126
+ "queued": max(0, self.queue.qsize() - self._retire_pending),
1008
1127
  "running": running,
1009
1128
  "current": current,
1010
1129
  "rr_index": self._rr_index,
@@ -1034,11 +1153,17 @@ class PoolManager:
1034
1153
  '''
1035
1154
  stopped: list[str] = []
1036
1155
  dropped = 0
1156
+ retire_lost = 0
1037
1157
  while True:
1038
1158
  try:
1039
1159
  jid = self.queue.get_nowait()
1040
1160
  except asyncio.QueueEmpty:
1041
1161
  break
1162
+ if jid == RETIRE_TOKEN:
1163
+ # 下班令牌不是任务,别当洪水清掉:并行数下调的意图还得兑现
1164
+ retire_lost += 1
1165
+ self.queue.task_done()
1166
+ continue
1042
1167
  job = self.jobs.get(jid)
1043
1168
  if job and job.get("status") in ("queued", "running"):
1044
1169
  job.update({"status": "cancelled", "error": reason,
@@ -1046,6 +1171,8 @@ class PoolManager:
1046
1171
  stopped.append(jid)
1047
1172
  dropped += 1
1048
1173
  self.queue.task_done()
1174
+ for _ in range(retire_lost):
1175
+ await self.queue.put(RETIRE_TOKEN)
1049
1176
  # 先改状态再 cancel:_run_job 的 finally 才不会写历史或安排重试
1050
1177
  running = []
1051
1178
  for jid, task in list(self._job_tasks.items()):
@@ -1083,6 +1210,11 @@ class PoolManager:
1083
1210
  while True:
1084
1211
  job_id = await self.queue.get()
1085
1212
  try:
1213
+ if job_id == RETIRE_TOKEN:
1214
+ # 并行上限被下调:把手上的活干完再走,不打断在途请求
1215
+ self._retire_pending = max(0, self._retire_pending - 1)
1216
+ log(f"worker {wid}: 退出(并行数下调)")
1217
+ return
1086
1218
  job = self.jobs.get(job_id)
1087
1219
  if not job or job.get("status") == "cancelled":
1088
1220
  continue
@@ -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}
@@ -985,6 +1003,16 @@
985
1003
  <button class="btn ghost mini" id="monClose" type="button">关闭</button>
986
1004
  </div>
987
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>
988
1016
  <div class="mon-sec"><b>账号状态</b><span id="monAccHint"></span><span class="spacer"></span><span id="monCheckHint"></span></div>
989
1017
  <div class="mon-box">
990
1018
  <table class="mon">
@@ -3916,8 +3944,92 @@ async function stopAllJobs(){
3916
3944
  const ST_LABEL = {ready:'可用', busy:'出图中', limited:'限流', unhealthy:'已下线', unbound:'未绑定', pending:'待验证', error:'异常'};
3917
3945
  let monTimer = null;
3918
3946
 
3919
- function kpi(label, val, cls){
3920
- 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>`;
3921
4033
  }
3922
4034
 
3923
4035
  function agoText(ts){
@@ -3945,11 +4057,13 @@ function renderMonitor(d){
3945
4057
  const kTotal = accs.length ? accs.filter(a=>a.env_id).length : Number(poolS.total || 0);
3946
4058
  $('#monSum').innerHTML =
3947
4059
  kpi('账号可用', `${kReady}/${kTotal}`, kReady ? 'ok' : 'err')
3948
- + kpi('并行任务', `${run.length}/${d.workers || 1}`, run.length ? 'warn' : '')
4060
+ + kpi('并行任务', `${run.length}/${d.workers || 1}`, run.length ? 'warn' : '',
4061
+ '并行上限可调:改下面那条「并行上限」的 − / + 或直接填数字,最多不超过有效账号数')
3949
4062
  + kpi('排队', d.queued || 0, d.queued ? 'warn' : '')
3950
4063
  + kpi('今日成功', hist.today_done || 0, 'ok')
3951
4064
  + kpi('今日失败', hist.today_error || 0, hist.today_error ? 'err' : '')
3952
4065
  + kpi('历史总量', hist.total || 0, '');
4066
+ syncWorkersCtl(d);
3953
4067
 
3954
4068
  let lastUsedAcc = null;
3955
4069
  for (const a of accs){
@@ -4073,6 +4187,7 @@ async function refreshMonitor(){
4073
4187
  }
4074
4188
 
4075
4189
  async function openMonitor(){
4190
+ bindWorkersCtl();
4076
4191
  $('#monitorDlg').showModal();
4077
4192
  await refreshMonitor();
4078
4193
  scheduleMonitor();