nai-aclab 1.2.0 → 1.2.1

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/README.md CHANGED
@@ -72,6 +72,8 @@ API 토큰을 아직 넣고 싶지 않다면 `API 없이 체험하기`를 켜둔
72
72
 
73
73
  ![API 설정 메뉴](docs/screenshots/07-api-settings.png)
74
74
 
75
+ 왼쪽 아래의 `NAI V5 할당량` 패널에서는 현재 V5 사용 한도와 Image Anlas를 확인할 수 있습니다. 약 1분마다 자동으로 갱신되며, `↻` 버튼을 누르면 바로 새로고침됩니다. API 토큰을 바꾸거나 이미지 생성이 끝난 뒤에도 자동으로 최신 정보가 반영됩니다.
76
+
75
77
  ## 기본 사용 순서
76
78
 
77
79
  ### 1. 작가 태그 등록
package/app.py CHANGED
@@ -850,6 +850,69 @@ class NovelAIClient:
850
850
  else:
851
851
  raise RuntimeError(f"알 수 없는 API 응답 형식: {maybe_json[:300]}")
852
852
 
853
+ def subscription_quota(self) -> dict:
854
+ """Return a safe, UI-ready subset of the NovelAI subscription response."""
855
+ if self.settings.mock_mode:
856
+ return {"available": False, "reason": "체험 모드에서는 할당량을 확인할 수 없습니다."}
857
+ token = self.settings.token.strip()
858
+ if not token:
859
+ return {"available": False, "reason": "API 토큰을 입력하면 할당량을 확인합니다."}
860
+
861
+ headers = {
862
+ "Authorization": f"Bearer {token}",
863
+ "User-Agent": self.settings.user_agent.strip() or DEFAULT_USER_AGENT,
864
+ "Accept": "application/json",
865
+ "Origin": "https://novelai.net",
866
+ "Referer": "https://novelai.net/",
867
+ }
868
+ errors: list[str] = []
869
+ # Subscription requests never follow a user-configured image endpoint.
870
+ # The token is sent only to NovelAI's official hosts.
871
+ for endpoint in (
872
+ "https://image.novelai.net/user/subscription",
873
+ "https://api.novelai.net/user/subscription",
874
+ ):
875
+ request = urllib.request.Request(endpoint, headers=headers, method="GET")
876
+ try:
877
+ with urllib.request.urlopen(request, timeout=20) as response:
878
+ data = json.loads(response.read().decode("utf-8"))
879
+ break
880
+ except urllib.error.HTTPError as exc:
881
+ errors.append(str(exc.code))
882
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
883
+ errors.append(type(exc).__name__)
884
+ else:
885
+ status = errors[-1] if errors else "unknown"
886
+ return {"available": False, "reason": f"할당량 정보를 불러오지 못했습니다. ({status})"}
887
+
888
+ steps = data.get("trainingStepsLeft") or {}
889
+ fixed_anlas = int(steps.get("fixedTrainingStepsLeft") or 0)
890
+ purchased_anlas = int(steps.get("purchasedTrainingSteps") or 0)
891
+ usage = data.get("usage") or data.get("imageGenerationUsage") or {}
892
+ if not isinstance(usage, dict):
893
+ usage = {}
894
+ percent = usage.get("percent")
895
+ try:
896
+ percent = float(percent) if percent is not None else None
897
+ except (TypeError, ValueError):
898
+ percent = None
899
+ next_percent = usage.get("timeUntilNextPercent")
900
+ try:
901
+ next_percent = float(next_percent) if next_percent is not None else None
902
+ except (TypeError, ValueError):
903
+ next_percent = None
904
+ return {
905
+ "available": True,
906
+ "tier": int(data.get("tier") or 0),
907
+ "active": bool(data.get("active")),
908
+ "subscription_anlas": fixed_anlas,
909
+ "paid_anlas": purchased_anlas,
910
+ "total_anlas": fixed_anlas + purchased_anlas,
911
+ "v5_percent": percent,
912
+ "v5_is_negative": bool(usage.get("isNegative")),
913
+ "v5_next_percent_seconds": next_percent,
914
+ }
915
+
853
916
 
854
917
  class ScrollFrame(ttk.Frame if ttk else object):
855
918
  def __init__(self, parent):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nai-aclab",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Local web UI for experimenting with NovelAI artist tag weight combinations.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/web/index.html CHANGED
@@ -26,6 +26,13 @@
26
26
  <button class="nav-item" data-tab="history">◈ 생성 기록</button>
27
27
  </nav>
28
28
  <div class="sidebar-tools">
29
+ <section class="quota-widget" id="quotaWidget" aria-live="polite">
30
+ <div class="quota-widget-header">
31
+ <span>NAI V5 할당량</span>
32
+ <button class="quota-refresh" id="quotaRefreshButton" type="button" title="할당량 새로고침" aria-label="할당량 새로고침">↻</button>
33
+ </div>
34
+ <div class="quota-widget-content" id="quotaWidgetContent">할당량을 확인하는 중입니다.</div>
35
+ </section>
29
36
  <button class="nav-item settings-nav-item" data-tab="settings">⚙ API 설정</button>
30
37
  </div>
31
38
  <div class="sidebar-footer">
package/web/main.js CHANGED
@@ -28,9 +28,12 @@ let modalZoom = 1;
28
28
  let modalPanX = 0;
29
29
  let modalPanY = 0;
30
30
  let modalDragging = false;
31
- let modalDragStart = { x: 0, y: 0, panX: 0, panY: 0 };
31
+ let modalDragStart = { x: 0, y: 0, panX: 0, panY: 0 };
32
32
  let presetPickerKind = null;
33
33
  let activeJobs = { generate: null, batting: null };
34
+ let quotaData = null;
35
+ let quotaRefreshInFlight = false;
36
+ let quotaPollingStarted = false;
34
37
  const pendingJobId = "__pending__";
35
38
 
36
39
  const pageCopy = {
@@ -146,6 +149,96 @@ function setSaveState(text, tone = "success") {
146
149
  root.dataset.tone = tone;
147
150
  }
148
151
 
152
+ function formatQuotaDuration(seconds) {
153
+ const value = Number(seconds);
154
+ if (!Number.isFinite(value) || value < 0) return "";
155
+ // Older responses used milliseconds while the current API uses seconds.
156
+ const normalized = value > 100000 ? value / 1000 : value;
157
+ const minutes = Math.max(1, Math.ceil(normalized / 60));
158
+ if (minutes < 60) return `${minutes}분`;
159
+ const hours = Math.floor(minutes / 60);
160
+ const remainder = minutes % 60;
161
+ return remainder ? `${hours}시간 ${remainder}분` : `${hours}시간`;
162
+ }
163
+
164
+ function renderQuota() {
165
+ const root = $("#quotaWidgetContent");
166
+ if (!root) return;
167
+ root.innerHTML = "";
168
+ root.classList.remove("exhausted");
169
+ if (!quotaData?.available) {
170
+ root.textContent = quotaData?.reason || "API 토큰을 입력하면 할당량을 확인합니다.";
171
+ return;
172
+ }
173
+ const tierNames = ["Paper", "Tablet", "Scroll", "Opus"];
174
+ const percent = Number(quotaData.v5_percent);
175
+ const hasV5Quota = Number.isFinite(percent);
176
+ const isExhausted = Boolean(quotaData.v5_is_negative) || (hasV5Quota && percent <= 0);
177
+ const displayedPercent = hasV5Quota ? Math.max(0, percent) : null;
178
+ const barPercent = displayedPercent === null ? 0 : Math.min(100, displayedPercent);
179
+
180
+ const summary = document.createElement("div");
181
+ summary.className = "quota-summary";
182
+ const title = document.createElement("span");
183
+ title.textContent = hasV5Quota ? "V5 사용 한도" : "이미지 Anlas";
184
+ const value = document.createElement("strong");
185
+ value.textContent = hasV5Quota
186
+ ? (isExhausted ? "소진" : `${displayedPercent.toFixed(displayedPercent % 1 ? 1 : 0)}%`)
187
+ : `${Number(quotaData.total_anlas || 0).toLocaleString()}`;
188
+ summary.append(title, value);
189
+ root.appendChild(summary);
190
+
191
+ if (hasV5Quota) {
192
+ const bar = document.createElement("div");
193
+ bar.className = "quota-bar";
194
+ const fill = document.createElement("span");
195
+ fill.style.width = `${barPercent}%`;
196
+ bar.appendChild(fill);
197
+ root.appendChild(bar);
198
+ }
199
+
200
+ const details = document.createElement("div");
201
+ details.className = "quota-detail";
202
+ const tier = tierNames[Number(quotaData.tier)] || "NovelAI";
203
+ const anlas = `Anlas ${Number(quotaData.total_anlas || 0).toLocaleString()}`;
204
+ const recovery = formatQuotaDuration(quotaData.v5_next_percent_seconds);
205
+ details.textContent = hasV5Quota
206
+ ? `${tier} · ${anlas}${recovery ? ` · 다음 +1% ${recovery}` : ""}`
207
+ : `${tier} · ${anlas}`;
208
+ root.appendChild(details);
209
+ const updated = document.createElement("div");
210
+ updated.className = "quota-updated";
211
+ updated.textContent = "방금 갱신됨";
212
+ root.appendChild(updated);
213
+ root.classList.toggle("exhausted", isExhausted);
214
+ }
215
+
216
+ async function refreshQuota({ silent = false } = {}) {
217
+ if (quotaRefreshInFlight) return;
218
+ quotaRefreshInFlight = true;
219
+ const button = $("#quotaRefreshButton");
220
+ if (button) button.disabled = true;
221
+ try {
222
+ const data = await request("/api/quota");
223
+ quotaData = data.quota || null;
224
+ renderQuota();
225
+ if (!silent && quotaData?.available) showToast("NAI 할당량을 갱신했습니다.", "success");
226
+ } catch (error) {
227
+ quotaData = { available: false, reason: "할당량 정보를 불러오지 못했습니다." };
228
+ renderQuota();
229
+ if (!silent) showToast(`할당량을 갱신하지 못했습니다: ${error.message}`, "error");
230
+ } finally {
231
+ quotaRefreshInFlight = false;
232
+ if (button) button.disabled = false;
233
+ }
234
+ }
235
+
236
+ function startQuotaPolling() {
237
+ if (quotaPollingStarted) return;
238
+ quotaPollingStarted = true;
239
+ setInterval(() => refreshQuota({ silent: true }), 60_000);
240
+ }
241
+
149
242
  function showToast(message, tone = "info") {
150
243
  const region = $("#toastRegion");
151
244
  if (!region) return;
@@ -208,7 +301,10 @@ async function saveNow() {
208
301
  renderGenerate();
209
302
  }
210
303
  setSaveState(hadPendingToken ? "API 토큰 변경 저장됨" : "자동 저장됨", "success");
211
- if (hadPendingToken) showToast("새 API 토큰으로 교체했습니다.", "success");
304
+ if (hadPendingToken) {
305
+ showToast("새 API 토큰으로 교체했습니다.", "success");
306
+ await refreshQuota({ silent: true });
307
+ }
212
308
  } catch (error) {
213
309
  setSaveState("저장 실패", "error");
214
310
  showToast(`저장하지 못했습니다: ${error.message}`, "error");
@@ -2632,6 +2728,8 @@ async function loadState() {
2632
2728
  switchTab(currentTab);
2633
2729
  updateJobActionButtons();
2634
2730
  await previewPrompt();
2731
+ await refreshQuota({ silent: true });
2732
+ startQuotaPolling();
2635
2733
  }
2636
2734
 
2637
2735
  function bindEvents() {
@@ -2641,6 +2739,7 @@ function bindEvents() {
2641
2739
  await loadState();
2642
2740
  showToast("저장된 내용을 다시 불러왔습니다.", "success");
2643
2741
  };
2742
+ $("#quotaRefreshButton").onclick = () => refreshQuota();
2644
2743
  $("#previewButton").onclick = previewPrompt;
2645
2744
  $("#generateButtonInline").onclick = startGeneration;
2646
2745
  $("#stopGenerateButton").onclick = stopGeneration;
package/web/styles.css CHANGED
@@ -117,6 +117,96 @@ button {
117
117
  gap: 8px;
118
118
  }
119
119
 
120
+ .quota-widget {
121
+ border: 1px solid rgba(140, 182, 255, 0.28);
122
+ border-radius: 8px;
123
+ background: rgba(24, 30, 38, 0.84);
124
+ overflow: hidden;
125
+ }
126
+
127
+ .quota-widget-header {
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: space-between;
131
+ gap: 8px;
132
+ padding: 10px 10px 7px 12px;
133
+ color: #dce8ff;
134
+ font-size: 12px;
135
+ font-weight: 750;
136
+ }
137
+
138
+ .quota-refresh {
139
+ width: 24px;
140
+ height: 24px;
141
+ padding: 0;
142
+ border: 1px solid var(--line);
143
+ border-radius: 6px;
144
+ background: var(--surface-2);
145
+ color: var(--muted);
146
+ line-height: 1;
147
+ }
148
+
149
+ .quota-refresh:hover:not(:disabled) {
150
+ color: var(--text);
151
+ border-color: rgba(140, 182, 255, 0.58);
152
+ }
153
+
154
+ .quota-refresh:disabled {
155
+ cursor: wait;
156
+ opacity: 0.62;
157
+ }
158
+
159
+ .quota-widget-content {
160
+ display: grid;
161
+ gap: 8px;
162
+ padding: 0 12px 11px;
163
+ color: var(--muted);
164
+ font-size: 12px;
165
+ line-height: 1.45;
166
+ }
167
+
168
+ .quota-summary {
169
+ display: flex;
170
+ justify-content: space-between;
171
+ gap: 8px;
172
+ color: var(--text);
173
+ }
174
+
175
+ .quota-summary strong {
176
+ color: var(--accent-2);
177
+ font-size: 15px;
178
+ }
179
+
180
+ .quota-bar {
181
+ height: 6px;
182
+ overflow: hidden;
183
+ border-radius: 999px;
184
+ background: rgba(140, 182, 255, 0.15);
185
+ }
186
+
187
+ .quota-bar > span {
188
+ display: block;
189
+ width: 0;
190
+ height: 100%;
191
+ border-radius: inherit;
192
+ background: linear-gradient(90deg, var(--accent), var(--accent-2));
193
+ transition: width 420ms ease;
194
+ }
195
+
196
+ .quota-widget-content.exhausted .quota-bar > span {
197
+ background: var(--danger);
198
+ }
199
+
200
+ .quota-detail,
201
+ .quota-updated {
202
+ color: var(--muted);
203
+ font-size: 11px;
204
+ }
205
+
206
+ .quota-updated {
207
+ color: #718092;
208
+ }
209
+
120
210
  .settings-nav-item {
121
211
  border-color: rgba(94, 224, 184, 0.22);
122
212
  background: rgba(94, 224, 184, 0.06);
package/web_app.py CHANGED
@@ -565,6 +565,13 @@ class Handler(BaseHTTPRequestHandler):
565
565
  state = load_state()
566
566
  prompt, negative, artists = build_prompt(state)
567
567
  self.send_json({"prompt": prompt, "negative": negative, "artists": artists})
568
+ elif route == "/api/quota":
569
+ if not self.authorized_api_request():
570
+ self.send_error(403)
571
+ return
572
+ state = load_state()
573
+ state.api.token = load_api_token()
574
+ self.send_json({"quota": NovelAIClient(state.api).subscription_quota()})
568
575
  elif route == "/api/job":
569
576
  if not self.authorized_api_request():
570
577
  self.send_error(403)