SAkizuki commited on
Commit
47a0902
·
verified ·
1 Parent(s): 928b8a2

Auto-sync from GitHub Actions

Browse files
Files changed (2) hide show
  1. core/engine.py +41 -6
  2. ui_nicegui.py +88 -14
core/engine.py CHANGED
@@ -18,6 +18,7 @@ import os
18
  import re
19
  import time
20
  from collections import OrderedDict
 
21
  from pathlib import Path
22
  from datetime import datetime
23
  from typing import Any, Optional
@@ -203,6 +204,9 @@ class DanbooruTagger:
203
  # 进程级 CPU 并发闸门:串行化所有 CPU 密集型操作(search / get_related /
204
  # get_group_candidates),避免并发抢占 CPU 而拖垮 asyncio 事件循环。
205
  _cpu_sem: Optional[asyncio.Semaphore] = None
 
 
 
206
 
207
  @classmethod
208
  def is_ready(cls) -> bool:
@@ -636,9 +640,40 @@ class DanbooruTagger:
636
  @classmethod
637
  def _get_cpu_sem(cls) -> asyncio.Semaphore:
638
  if cls._cpu_sem is None:
639
- cls._cpu_sem = asyncio.Semaphore(_resolve_cpu_sem_limit())
 
640
  return cls._cpu_sem
641
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
  async def search_async(self, request: SearchRequest) -> SearchResponse:
643
  """search() 的并发安全异步封装:共享闸门串行化 + 线程池执行。
644
 
@@ -646,7 +681,7 @@ class DanbooruTagger:
646
  asyncio.to_thread(self.search),以共享同一个 CPU 并发闸门。
647
  包含 60 秒超时,防止异常卡死导致信号量永久泄漏。
648
  """
649
- async with self._get_cpu_sem():
650
  return await asyncio.wait_for(
651
  asyncio.to_thread(self.search, request),
652
  timeout=120.0,
@@ -661,7 +696,7 @@ class DanbooruTagger:
661
  target_categories: set[str] | None = None,
662
  ) -> list:
663
  """get_related() 的并发安全异步封装,共享同一个 CPU 闸门。"""
664
- async with self._get_cpu_sem():
665
  return await asyncio.to_thread(
666
  self.get_related, seed_tags, exclude, limit, show_nsfw, target_categories,
667
  )
@@ -672,7 +707,7 @@ class DanbooruTagger:
672
  show_nsfw: bool = True,
673
  ) -> list[dict]:
674
  """get_group_candidates() 的并发安全异步封装,共享同一个 CPU 闸门。"""
675
- async with self._get_cpu_sem():
676
  return await asyncio.to_thread(
677
  self.get_group_candidates, selected_tags, show_nsfw,
678
  )
@@ -684,7 +719,7 @@ class DanbooruTagger:
684
  min_cooc: int = 5,
685
  ) -> list:
686
  """search_artists_by_tags() 的并发安全异步封装。"""
687
- async with self._get_cpu_sem():
688
  return await asyncio.to_thread(
689
  self.search_artists_by_tags, tags, limit, min_cooc,
690
  )
@@ -698,7 +733,7 @@ class DanbooruTagger:
698
  target_categories: list[str] | None = None,
699
  ) -> tuple:
700
  """search_artists_pipeline() 的并发安全异步封装。"""
701
- async with self._get_cpu_sem():
702
  return await asyncio.wait_for(
703
  asyncio.to_thread(
704
  self.search_artists_pipeline,
 
18
  import re
19
  import time
20
  from collections import OrderedDict
21
+ from contextlib import asynccontextmanager
22
  from pathlib import Path
23
  from datetime import datetime
24
  from typing import Any, Optional
 
204
  # 进程级 CPU 并发闸门:串行化所有 CPU 密集型操作(search / get_related /
205
  # get_group_candidates),避免并发抢占 CPU 而拖垮 asyncio 事件循环。
206
  _cpu_sem: Optional[asyncio.Semaphore] = None
207
+ _cpu_sem_limit: Optional[int] = None
208
+ _cpu_active_tasks: int = 0
209
+ _cpu_waiting_tasks: int = 0
210
 
211
  @classmethod
212
  def is_ready(cls) -> bool:
 
640
  @classmethod
641
  def _get_cpu_sem(cls) -> asyncio.Semaphore:
642
  if cls._cpu_sem is None:
643
+ cls._cpu_sem_limit = _resolve_cpu_sem_limit()
644
+ cls._cpu_sem = asyncio.Semaphore(cls._cpu_sem_limit)
645
  return cls._cpu_sem
646
 
647
+ @classmethod
648
+ @asynccontextmanager
649
+ async def _cpu_slot(cls):
650
+ """申请共享 CPU 计算槽,并维护可公开展示的进程内负载计数。"""
651
+ sem = cls._get_cpu_sem()
652
+ acquired = False
653
+ cls._cpu_waiting_tasks += 1
654
+ try:
655
+ await sem.acquire()
656
+ acquired = True
657
+ cls._cpu_waiting_tasks = max(0, cls._cpu_waiting_tasks - 1)
658
+ cls._cpu_active_tasks += 1
659
+ yield
660
+ finally:
661
+ if acquired:
662
+ cls._cpu_active_tasks = max(0, cls._cpu_active_tasks - 1)
663
+ sem.release()
664
+ else:
665
+ cls._cpu_waiting_tasks = max(0, cls._cpu_waiting_tasks - 1)
666
+
667
+ @classmethod
668
+ def get_load_snapshot(cls) -> dict[str, int]:
669
+ """返回当前进程的 CPU 计算任务负载快照。"""
670
+ capacity = cls._cpu_sem_limit or _resolve_cpu_sem_limit()
671
+ return {
672
+ 'active': cls._cpu_active_tasks,
673
+ 'waiting': cls._cpu_waiting_tasks,
674
+ 'capacity': capacity,
675
+ }
676
+
677
  async def search_async(self, request: SearchRequest) -> SearchResponse:
678
  """search() 的并发安全异步封装:共享闸门串行化 + 线程池执行。
679
 
 
681
  asyncio.to_thread(self.search),以共享同一个 CPU 并发闸门。
682
  包含 60 秒超时,防止异常卡死导致信号量永久泄漏。
683
  """
684
+ async with self._cpu_slot():
685
  return await asyncio.wait_for(
686
  asyncio.to_thread(self.search, request),
687
  timeout=120.0,
 
696
  target_categories: set[str] | None = None,
697
  ) -> list:
698
  """get_related() 的并发安全异步封装,共享同一个 CPU 闸门。"""
699
+ async with self._cpu_slot():
700
  return await asyncio.to_thread(
701
  self.get_related, seed_tags, exclude, limit, show_nsfw, target_categories,
702
  )
 
707
  show_nsfw: bool = True,
708
  ) -> list[dict]:
709
  """get_group_candidates() 的并发安全异步封装,共享同一个 CPU 闸门。"""
710
+ async with self._cpu_slot():
711
  return await asyncio.to_thread(
712
  self.get_group_candidates, selected_tags, show_nsfw,
713
  )
 
719
  min_cooc: int = 5,
720
  ) -> list:
721
  """search_artists_by_tags() 的并发安全异步封装。"""
722
+ async with self._cpu_slot():
723
  return await asyncio.to_thread(
724
  self.search_artists_by_tags, tags, limit, min_cooc,
725
  )
 
733
  target_categories: list[str] | None = None,
734
  ) -> tuple:
735
  """search_artists_pipeline() 的并发安全异步封装。"""
736
+ async with self._cpu_slot():
737
  return await asyncio.wait_for(
738
  asyncio.to_thread(
739
  self.search_artists_pipeline,
ui_nicegui.py CHANGED
@@ -12,6 +12,7 @@ import sys
12
  sys.stdout.reconfigure(line_buffering=True)
13
  print("[UI] 脚本开始执行", flush=True)
14
  import asyncio
 
15
  import os
16
  import re
17
  import time
@@ -88,7 +89,23 @@ from core.workspace import (
88
  from platform_utils import is_cloud, get_host_port, nsfw_allowed
89
  from mcp_server import mcp
90
 
91
- import logging
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  logging.getLogger("httpx").setLevel(logging.WARNING)
93
  logging.getLogger("huggingface_hub").setLevel(logging.WARNING)
94
  logging.getLogger("mcp").setLevel(logging.WARNING)
@@ -380,6 +397,8 @@ class DanbooruSearchUI:
380
  def __init__(self):
381
  self.search_count_label = None
382
  self.service_status_container = None
 
 
383
  self.current_search_interacted = True
384
  self._telemetry_search_started_at: float | None = None
385
  self._telemetry_selection_recorded = False
@@ -493,7 +512,7 @@ class DanbooruSearchUI:
493
  self._cat_checkboxes: dict[str, ui.checkbox] = {}
494
 
495
  def _update_footer_text(self):
496
- if self.search_count_label is not None:
497
  try:
498
  total = counter.get()
499
  visits = counter.get_visits()
@@ -513,22 +532,47 @@ class DanbooruSearchUI:
513
  pass
514
 
515
  def _update_service_status(self):
516
- if self.service_status_container is None:
517
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  self.service_status_container.clear()
519
  with self.service_status_container:
520
- if DanbooruTagger.is_ready():
521
- with ui.row().classes(
522
- 'w-full items-center gap-2 service-state-panel ready'
523
- ):
524
- ui.icon('check_circle', size='18px', color='positive')
525
- ui.label('服务可用').classes('font-medium')
526
- else:
527
  with ui.row().classes(
528
  'w-full items-center gap-2 service-state-panel loading'
529
  ):
530
  ui.spinner(size='18px', color='primary')
531
  ui.label('引擎初始化中,请稍候…约需 5~10 分钟').classes('font-medium')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
 
533
  def _build_sponsor_dialog(self):
534
  with ui.dialog() as self.sponsor_dialog, ui.card().classes('w-full max-w-sm'):
@@ -872,6 +916,8 @@ class DanbooruSearchUI:
872
 
873
  def _save_config(self):
874
  """将当前控件状态序列化并写入 localStorage。"""
 
 
875
  cfg = self._collect_config_state()
876
  js = _json.dumps(cfg, ensure_ascii=False)
877
  ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});")
@@ -932,7 +978,7 @@ class DanbooruSearchUI:
932
  async def _restore_config(self):
933
  """从 localStorage 读取配置并恢复控件状态。"""
934
  try:
935
- if getattr(ui.context.client, '_deleted', False):
936
  return
937
  raw = await ui.run_javascript(
938
  f"localStorage.getItem('{_CONFIG_LS_KEY}');",
@@ -1015,6 +1061,11 @@ class DanbooruSearchUI:
1015
  border: 1px solid #a7f3d0;
1016
  color: #047857;
1017
  }
 
 
 
 
 
1018
  .query-insight-panel {
1019
  background: #f8fafc;
1020
  border: 1px solid #dbe4ee;
@@ -1342,6 +1393,7 @@ class DanbooruSearchUI:
1342
  with ui.element('div').classes('w-full border-t border-slate-100 pt-3 mt-1'):
1343
  self.service_status_container = ui.column().classes('w-full gap-0')
1344
  self._update_service_status()
 
1345
 
1346
  # ── 工作区工具 ────────────────────────────────────────────────────────
1347
 
@@ -2550,6 +2602,8 @@ class DanbooruSearchUI:
2550
  pass # 事件上下文已销毁(UI 重建中),数据仍在内存里,下次保存时会同步
2551
 
2552
  def _save_history(self):
 
 
2553
  while True:
2554
  try:
2555
  data = dump_collection(self.search_history, label='history')
@@ -2568,6 +2622,8 @@ class DanbooruSearchUI:
2568
  return
2569
 
2570
  def _save_favorites(self):
 
 
2571
  try:
2572
  data = dump_collection(self.favorites, label='favorites')
2573
  ui.run_javascript(
@@ -3024,7 +3080,11 @@ class DanbooruSearchUI:
3024
 
3025
  async def _hide_banner_when_ready(self):
3026
  while not DanbooruTagger.is_ready():
 
 
3027
  await asyncio.sleep(1)
 
 
3028
  if self.init_banner:
3029
  self.init_banner.set_visibility(False)
3030
  self._update_service_status()
@@ -3035,9 +3095,11 @@ class DanbooruSearchUI:
3035
 
3036
  def _client_alive(self) -> bool:
3037
  try:
3038
- _ = self.search_btn.client
3039
- return True
3040
- except RuntimeError:
 
 
3041
  return False
3042
 
3043
  # ── 分词筛选 ──────────────────────────────────────────────────────────
@@ -4175,6 +4237,18 @@ class DanbooruSearchUI:
4175
 
4176
  @ui.page('/')
4177
  async def main_page():
 
 
 
 
 
 
 
 
 
 
 
 
4178
  app_ui = DanbooruSearchUI()
4179
  app_ui.build_page()
4180
 
 
12
  sys.stdout.reconfigure(line_buffering=True)
13
  print("[UI] 脚本开始执行", flush=True)
14
  import asyncio
15
+ import logging
16
  import os
17
  import re
18
  import time
 
89
  from platform_utils import is_cloud, get_host_port, nsfw_allowed
90
  from mcp_server import mcp
91
 
92
+
93
+ # 仅统计当前进程中已建立 Socket.IO 连接的 UI 页面,不等同于唯一用户数。
94
+ _ACTIVE_UI_CLIENT_IDS: set[str] = set()
95
+
96
+
97
+ def _mark_ui_session_active(client_id: str) -> None:
98
+ _ACTIVE_UI_CLIENT_IDS.add(client_id)
99
+
100
+
101
+ def _mark_ui_session_inactive(client_id: str) -> None:
102
+ _ACTIVE_UI_CLIENT_IDS.discard(client_id)
103
+
104
+
105
+ def _get_active_ui_session_count() -> int:
106
+ return len(_ACTIVE_UI_CLIENT_IDS)
107
+
108
+
109
  logging.getLogger("httpx").setLevel(logging.WARNING)
110
  logging.getLogger("huggingface_hub").setLevel(logging.WARNING)
111
  logging.getLogger("mcp").setLevel(logging.WARNING)
 
397
  def __init__(self):
398
  self.search_count_label = None
399
  self.service_status_container = None
400
+ self.service_status_timer = None
401
+ self._last_service_status_key = None
402
  self.current_search_interacted = True
403
  self._telemetry_search_started_at: float | None = None
404
  self._telemetry_selection_recorded = False
 
512
  self._cat_checkboxes: dict[str, ui.checkbox] = {}
513
 
514
  def _update_footer_text(self):
515
+ if self.search_count_label is not None and self._client_alive():
516
  try:
517
  total = counter.get()
518
  visits = counter.get_visits()
 
532
  pass
533
 
534
  def _update_service_status(self):
535
+ if self.service_status_container is None or not self._client_alive():
536
  return
537
+
538
+ ready = DanbooruTagger.is_ready()
539
+ online_sessions = _get_active_ui_session_count()
540
+ load = DanbooruTagger.get_load_snapshot()
541
+ active = load['active']
542
+ waiting = load['waiting']
543
+ capacity = load['capacity']
544
+ busy = ready and (waiting > 0 or active >= capacity)
545
+ status_key = (ready, busy, online_sessions, active, waiting, capacity)
546
+ if status_key == self._last_service_status_key:
547
+ return
548
+ self._last_service_status_key = status_key
549
+
550
  self.service_status_container.clear()
551
  with self.service_status_container:
552
+ if not ready:
 
 
 
 
 
 
553
  with ui.row().classes(
554
  'w-full items-center gap-2 service-state-panel loading'
555
  ):
556
  ui.spinner(size='18px', color='primary')
557
  ui.label('引擎初始化中,请稍候…约需 5~10 分钟').classes('font-medium')
558
+ else:
559
+ with ui.row().classes(
560
+ f'w-full items-center gap-2 service-state-panel {"busy" if busy else "ready"}'
561
+ ):
562
+ ui.icon(
563
+ 'schedule' if busy else 'check_circle',
564
+ size='18px',
565
+ color='warning' if busy else 'positive',
566
+ )
567
+ parts = [
568
+ '服务繁忙' if busy else '服务可用',
569
+ f'{online_sessions} 个在线页面',
570
+ ]
571
+ if active > 0:
572
+ parts.append(f'正在处理 {active} 个任务')
573
+ if waiting > 0:
574
+ parts.append(f'等待 {waiting} 个')
575
+ ui.label(' · '.join(parts)).classes('font-medium')
576
 
577
  def _build_sponsor_dialog(self):
578
  with ui.dialog() as self.sponsor_dialog, ui.card().classes('w-full max-w-sm'):
 
916
 
917
  def _save_config(self):
918
  """将当前控件状态序列化并写入 localStorage。"""
919
+ if not self._client_alive():
920
+ return
921
  cfg = self._collect_config_state()
922
  js = _json.dumps(cfg, ensure_ascii=False)
923
  ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});")
 
978
  async def _restore_config(self):
979
  """从 localStorage 读取配置并恢复控件状态。"""
980
  try:
981
+ if not self._client_alive():
982
  return
983
  raw = await ui.run_javascript(
984
  f"localStorage.getItem('{_CONFIG_LS_KEY}');",
 
1061
  border: 1px solid #a7f3d0;
1062
  color: #047857;
1063
  }
1064
+ .service-state-panel.busy {
1065
+ background: #fff7ed;
1066
+ border: 1px solid #fed7aa;
1067
+ color: #c2410c;
1068
+ }
1069
  .query-insight-panel {
1070
  background: #f8fafc;
1071
  border: 1px solid #dbe4ee;
 
1393
  with ui.element('div').classes('w-full border-t border-slate-100 pt-3 mt-1'):
1394
  self.service_status_container = ui.column().classes('w-full gap-0')
1395
  self._update_service_status()
1396
+ self.service_status_timer = ui.timer(1.0, self._update_service_status)
1397
 
1398
  # ── 工作区工具 ────────────────────────────────────────────────────────
1399
 
 
2602
  pass # 事件上下文已销毁(UI 重建中),数据仍在内存里,下次保存时会同步
2603
 
2604
  def _save_history(self):
2605
+ if not self._client_alive():
2606
+ return
2607
  while True:
2608
  try:
2609
  data = dump_collection(self.search_history, label='history')
 
2622
  return
2623
 
2624
  def _save_favorites(self):
2625
+ if not self._client_alive():
2626
+ return False
2627
  try:
2628
  data = dump_collection(self.favorites, label='favorites')
2629
  ui.run_javascript(
 
3080
 
3081
  async def _hide_banner_when_ready(self):
3082
  while not DanbooruTagger.is_ready():
3083
+ if not self._client_alive():
3084
+ return
3085
  await asyncio.sleep(1)
3086
+ if not self._client_alive():
3087
+ return
3088
  if self.init_banner:
3089
  self.init_banner.set_visibility(False)
3090
  self._update_service_status()
 
3095
 
3096
  def _client_alive(self) -> bool:
3097
  try:
3098
+ client = self.client
3099
+ if client is None and self.search_btn is not None:
3100
+ client = self.search_btn.client
3101
+ return client is not None and not bool(getattr(client, '_deleted', False))
3102
+ except (AttributeError, RuntimeError):
3103
  return False
3104
 
3105
  # ── 分词筛选 ──────────────────────────────────────────────────────────
 
4237
 
4238
  @ui.page('/')
4239
  async def main_page():
4240
+ client = ui.context.client
4241
+ client_id = client.id
4242
+
4243
+ def mark_connected(*_):
4244
+ _mark_ui_session_active(client_id)
4245
+
4246
+ def mark_disconnected(*_):
4247
+ _mark_ui_session_inactive(client_id)
4248
+
4249
+ client.on_connect(mark_connected)
4250
+ client.on_disconnect(mark_disconnected)
4251
+
4252
  app_ui = DanbooruSearchUI()
4253
  app_ui.build_page()
4254