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 +2 -0
- package/app.py +63 -0
- package/package.json +1 -1
- package/web/index.html +7 -0
- package/web/main.js +101 -2
- package/web/styles.css +90 -0
- package/web_app.py +7 -0
package/README.md
CHANGED
|
@@ -72,6 +72,8 @@ API 토큰을 아직 넣고 싶지 않다면 `API 없이 체험하기`를 켜둔
|
|
|
72
72
|
|
|
73
73
|

|
|
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
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)
|
|
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)
|