nai-aclab 1.0.1 → 1.0.4
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 +6 -0
- package/app.py +145 -11
- package/package.json +1 -1
- package/web/index.html +0 -4
- package/web/main.js +67 -30
- package/web_app.py +77 -22
package/README.md
CHANGED
|
@@ -121,6 +121,12 @@ override가 입력되어 있으면 모든 프리셋 조합에서 override 값을
|
|
|
121
121
|
|
|
122
122
|
토큰을 넣기 전에는 목업 모드로 UI 흐름을 먼저 테스트할 수 있습니다.
|
|
123
123
|
|
|
124
|
+
API 토큰은 `app_state.json`에 저장하거나 `/api/state` 응답으로 다시 내려보내지 않습니다. 한 번 입력한 토큰은 별도 secret store에 저장되어 다음 실행 때 자동으로 사용됩니다.
|
|
125
|
+
|
|
126
|
+
생성 히스토리에는 이미지 표시와 작가 가중치 비교에 필요한 최소 정보만 저장합니다. 최종 프롬프트, 네거티브 프롬프트, UC 프롬프트, 생성 요청 JSON, 로컬 절대 경로는 별도로 저장하지 않습니다. 이전 버전에서 생성된 `*_request.json` 파일은 앱 시작 시 자동으로 제거됩니다.
|
|
127
|
+
|
|
128
|
+
로컬 API는 앱이 시작될 때 생성되는 임시 토큰으로 보호됩니다. 또한 기본 NovelAI endpoint가 아닌 주소로 이미지를 생성하려는 경우, API 토큰이 해당 endpoint로 전송된다는 확인창을 먼저 표시합니다.
|
|
129
|
+
|
|
124
130
|
## 저장 위치
|
|
125
131
|
|
|
126
132
|
앱 상태, 생성 이미지, 요청 JSON은 아래 위치에 저장됩니다.
|
package/app.py
CHANGED
|
@@ -32,6 +32,7 @@ USER_DIR = Path(
|
|
|
32
32
|
DATA_DIR = USER_DIR / "data"
|
|
33
33
|
OUTPUT_DIR = USER_DIR / "outputs"
|
|
34
34
|
STATE_PATH = DATA_DIR / "app_state.json"
|
|
35
|
+
TOKEN_SECRET_PATH = DATA_DIR / ("api_token.dpapi" if os.name == "nt" else "api_token.secret")
|
|
35
36
|
DEFAULT_USER_AGENT = (
|
|
36
37
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
37
38
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
@@ -44,6 +45,127 @@ def ensure_dirs() -> None:
|
|
|
44
45
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _windows_protect(data: bytes) -> bytes:
|
|
49
|
+
import ctypes
|
|
50
|
+
from ctypes import wintypes
|
|
51
|
+
|
|
52
|
+
class DataBlob(ctypes.Structure):
|
|
53
|
+
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
|
|
54
|
+
|
|
55
|
+
crypt32 = ctypes.windll.crypt32
|
|
56
|
+
kernel32 = ctypes.windll.kernel32
|
|
57
|
+
buffer = ctypes.create_string_buffer(data)
|
|
58
|
+
in_blob = DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)))
|
|
59
|
+
out_blob = DataBlob()
|
|
60
|
+
if not crypt32.CryptProtectData(ctypes.byref(in_blob), None, None, None, None, 0, ctypes.byref(out_blob)):
|
|
61
|
+
raise OSError("Failed to protect API token with Windows DPAPI.")
|
|
62
|
+
try:
|
|
63
|
+
return ctypes.string_at(out_blob.pbData, out_blob.cbData)
|
|
64
|
+
finally:
|
|
65
|
+
kernel32.LocalFree(out_blob.pbData)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _windows_unprotect(data: bytes) -> bytes:
|
|
69
|
+
import ctypes
|
|
70
|
+
from ctypes import wintypes
|
|
71
|
+
|
|
72
|
+
class DataBlob(ctypes.Structure):
|
|
73
|
+
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
|
|
74
|
+
|
|
75
|
+
crypt32 = ctypes.windll.crypt32
|
|
76
|
+
kernel32 = ctypes.windll.kernel32
|
|
77
|
+
buffer = ctypes.create_string_buffer(data)
|
|
78
|
+
in_blob = DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)))
|
|
79
|
+
out_blob = DataBlob()
|
|
80
|
+
if not crypt32.CryptUnprotectData(ctypes.byref(in_blob), None, None, None, None, 0, ctypes.byref(out_blob)):
|
|
81
|
+
raise OSError("Failed to unprotect API token with Windows DPAPI.")
|
|
82
|
+
try:
|
|
83
|
+
return ctypes.string_at(out_blob.pbData, out_blob.cbData)
|
|
84
|
+
finally:
|
|
85
|
+
kernel32.LocalFree(out_blob.pbData)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def save_api_token(token: str) -> None:
|
|
89
|
+
token = token.strip()
|
|
90
|
+
if not token:
|
|
91
|
+
return
|
|
92
|
+
ensure_dirs()
|
|
93
|
+
raw = token.encode("utf-8")
|
|
94
|
+
payload = _windows_protect(raw) if os.name == "nt" else raw
|
|
95
|
+
TOKEN_SECRET_PATH.write_bytes(payload)
|
|
96
|
+
if os.name != "nt":
|
|
97
|
+
try:
|
|
98
|
+
TOKEN_SECRET_PATH.chmod(0o600)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_api_token() -> str:
|
|
104
|
+
try:
|
|
105
|
+
payload = TOKEN_SECRET_PATH.read_bytes()
|
|
106
|
+
except OSError:
|
|
107
|
+
return ""
|
|
108
|
+
try:
|
|
109
|
+
raw = _windows_unprotect(payload) if os.name == "nt" else payload
|
|
110
|
+
return raw.decode("utf-8")
|
|
111
|
+
except Exception:
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def has_saved_api_token() -> bool:
|
|
116
|
+
return bool(load_api_token().strip())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cleanup_saved_request_jsons() -> None:
|
|
120
|
+
try:
|
|
121
|
+
for path in OUTPUT_DIR.rglob("*_request.json"):
|
|
122
|
+
if path.is_file():
|
|
123
|
+
path.unlink()
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def state_dict_without_secrets(state: AppState | dict) -> dict:
|
|
129
|
+
data = asdict(state) if not isinstance(state, dict) else dict(state)
|
|
130
|
+
api = dict(data.get("api", {}) or {})
|
|
131
|
+
api["token"] = ""
|
|
132
|
+
data["api"] = api
|
|
133
|
+
data["history"] = [sanitize_history_entry(item) for item in data.get("history", [])]
|
|
134
|
+
return data
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def output_ref(path_value: str) -> str:
|
|
138
|
+
raw = str(path_value or "").strip()
|
|
139
|
+
if not raw:
|
|
140
|
+
return ""
|
|
141
|
+
path = Path(raw)
|
|
142
|
+
try:
|
|
143
|
+
if path.is_absolute():
|
|
144
|
+
return str(path.resolve().relative_to(OUTPUT_DIR.resolve())).replace("\\", "/")
|
|
145
|
+
except (OSError, ValueError):
|
|
146
|
+
return path.name
|
|
147
|
+
return raw.replace("\\", "/")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def sanitize_history_item(item: dict) -> dict:
|
|
151
|
+
clean = {
|
|
152
|
+
key: value
|
|
153
|
+
for key, value in dict(item or {}).items()
|
|
154
|
+
if key not in {"prompt", "negative_prompt", "uc_prompt", "request_path", "request_url"}
|
|
155
|
+
}
|
|
156
|
+
if "path" in clean:
|
|
157
|
+
clean["path"] = output_ref(clean.get("path", ""))
|
|
158
|
+
return clean
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def sanitize_history_entry(history: dict) -> dict:
|
|
162
|
+
clean = dict(history or {})
|
|
163
|
+
if "output_dir" in clean:
|
|
164
|
+
clean["output_dir"] = output_ref(clean.get("output_dir", ""))
|
|
165
|
+
clean["items"] = [sanitize_history_item(item) for item in clean.get("items", [])]
|
|
166
|
+
return clean
|
|
167
|
+
|
|
168
|
+
|
|
47
169
|
def now_id() -> str:
|
|
48
170
|
return time.strftime("%Y%m%d_%H%M%S")
|
|
49
171
|
|
|
@@ -223,8 +345,11 @@ def default_state() -> AppState:
|
|
|
223
345
|
|
|
224
346
|
def load_state() -> AppState:
|
|
225
347
|
ensure_dirs()
|
|
348
|
+
cleanup_saved_request_jsons()
|
|
226
349
|
if not STATE_PATH.exists():
|
|
227
|
-
|
|
350
|
+
state = default_state()
|
|
351
|
+
state.api.token = load_api_token()
|
|
352
|
+
return state
|
|
228
353
|
data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
|
229
354
|
base_data = data.get("base_presets", [])
|
|
230
355
|
quality_override = data.get("quality_override_prompt", "")
|
|
@@ -237,9 +362,24 @@ def load_state() -> AppState:
|
|
|
237
362
|
else next((item for item in base_data if item.get("quality_override_prompt")), None)
|
|
238
363
|
)
|
|
239
364
|
quality_override = (fallback_base or {}).get("quality_override_prompt", "")
|
|
240
|
-
api_data = data.get("api", {})
|
|
365
|
+
api_data = dict(data.get("api", {}) or {})
|
|
366
|
+
legacy_token = str(api_data.pop("token", "") or "").strip()
|
|
367
|
+
if legacy_token and not has_saved_api_token():
|
|
368
|
+
save_api_token(legacy_token)
|
|
369
|
+
data["api"] = api_data
|
|
370
|
+
STATE_PATH.write_text(
|
|
371
|
+
json.dumps(state_dict_without_secrets(data), ensure_ascii=False, indent=2),
|
|
372
|
+
encoding="utf-8",
|
|
373
|
+
)
|
|
241
374
|
if api_data.get("user_agent") in (None, "", "NAIArtistCombination/0.1"):
|
|
242
375
|
api_data["user_agent"] = DEFAULT_USER_AGENT
|
|
376
|
+
sanitized_data = state_dict_without_secrets(data)
|
|
377
|
+
if sanitized_data != data:
|
|
378
|
+
STATE_PATH.write_text(
|
|
379
|
+
json.dumps(sanitized_data, ensure_ascii=False, indent=2),
|
|
380
|
+
encoding="utf-8",
|
|
381
|
+
)
|
|
382
|
+
api_data["token"] = load_api_token()
|
|
243
383
|
return AppState(
|
|
244
384
|
categories=[Category(**item) for item in data.get("categories", [])],
|
|
245
385
|
base_presets=[PromptPreset(**item) for item in base_data],
|
|
@@ -250,14 +390,14 @@ def load_state() -> AppState:
|
|
|
250
390
|
api=ApiSettings(**api_data),
|
|
251
391
|
generation=GenerationSettings(**data.get("generation", {})),
|
|
252
392
|
batting_scenes=[BattingScene(**item) for item in data.get("batting_scenes", [])],
|
|
253
|
-
history=data.get("history", []),
|
|
393
|
+
history=[sanitize_history_entry(item) for item in data.get("history", [])],
|
|
254
394
|
)
|
|
255
395
|
|
|
256
396
|
|
|
257
397
|
def save_state(state: AppState) -> None:
|
|
258
398
|
ensure_dirs()
|
|
259
399
|
STATE_PATH.write_text(
|
|
260
|
-
json.dumps(
|
|
400
|
+
json.dumps(state_dict_without_secrets(state), ensure_ascii=False, indent=2),
|
|
261
401
|
encoding="utf-8",
|
|
262
402
|
)
|
|
263
403
|
|
|
@@ -334,8 +474,6 @@ class NovelAIClient:
|
|
|
334
474
|
return
|
|
335
475
|
|
|
336
476
|
payload = self.build_payload(prompt, negative_prompt, uc_prompt)
|
|
337
|
-
request_path = output_path.with_name(output_path.stem + "_request.json")
|
|
338
|
-
request_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
339
477
|
req = urllib.request.Request(
|
|
340
478
|
self.settings.endpoint,
|
|
341
479
|
data=json.dumps(payload).encode("utf-8"),
|
|
@@ -1133,11 +1271,7 @@ class App(tk.Tk if tk else object):
|
|
|
1133
1271
|
prompt, negative, uc_prompt, artists = self.build_prompt()
|
|
1134
1272
|
path = out_dir / f"image_{idx + 1:03}.png"
|
|
1135
1273
|
metadata = {
|
|
1136
|
-
"path": str(path),
|
|
1137
|
-
"request_path": str(path.with_name(path.stem + "_request.json")),
|
|
1138
|
-
"prompt": prompt,
|
|
1139
|
-
"negative_prompt": negative,
|
|
1140
|
-
"uc_prompt": uc_prompt,
|
|
1274
|
+
"path": output_ref(str(path)),
|
|
1141
1275
|
"artists": artists,
|
|
1142
1276
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
1143
1277
|
}
|
package/package.json
CHANGED
package/web/index.html
CHANGED
package/web/main.js
CHANGED
|
@@ -56,6 +56,7 @@ const imageSizeOptions = {
|
|
|
56
56
|
landscape: { label: "Landscape", width: 1216, height: 832 },
|
|
57
57
|
square: { label: "Square", width: 1024, height: 1024 },
|
|
58
58
|
};
|
|
59
|
+
const defaultNovelAiEndpoint = "https://image.novelai.net/ai/generate-image";
|
|
59
60
|
|
|
60
61
|
function escapeHtml(value) {
|
|
61
62
|
return String(value ?? "")
|
|
@@ -109,9 +110,14 @@ function setSaveState(text) {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
async function request(path, options = {}) {
|
|
113
|
+
const headers = {
|
|
114
|
+
"Content-Type": "application/json",
|
|
115
|
+
"X-NAI-ACLAB-Token": window.__NAI_ACLAB_TOKEN__ || "",
|
|
116
|
+
...(options.headers || {}),
|
|
117
|
+
};
|
|
112
118
|
const response = await fetch(path, {
|
|
113
|
-
headers: { "Content-Type": "application/json" },
|
|
114
119
|
...options,
|
|
120
|
+
headers,
|
|
115
121
|
});
|
|
116
122
|
if (!response.ok) throw new Error(await response.text());
|
|
117
123
|
return response.json();
|
|
@@ -534,8 +540,13 @@ function renderSettings() {
|
|
|
534
540
|
const wrap = document.createElement("label");
|
|
535
541
|
wrap.innerHTML = `<span>${label}</span><input id="api_${key}" type="${type}" />`;
|
|
536
542
|
form.appendChild(wrap);
|
|
537
|
-
$(`#api_${key}`)
|
|
538
|
-
|
|
543
|
+
const input = $(`#api_${key}`);
|
|
544
|
+
input.value = key === "token" ? "" : state.api[key] ?? "";
|
|
545
|
+
if (key === "token") {
|
|
546
|
+
input.placeholder = state.api.token_saved ? "저장된 토큰을 사용합니다" : "API 토큰을 입력하세요";
|
|
547
|
+
input.autocomplete = "off";
|
|
548
|
+
}
|
|
549
|
+
if (type === "number") input.step = "any";
|
|
539
550
|
}
|
|
540
551
|
$("#mockMode").checked = !!state.api.mock_mode;
|
|
541
552
|
$("#negativePrompt").value = state.negative_prompt || "";
|
|
@@ -566,32 +577,30 @@ function renderImageCard(item, context = {}) {
|
|
|
566
577
|
card.className = `image-card ${item.error ? "error" : ""}`;
|
|
567
578
|
const enriched = enrichedImageItem(item, context);
|
|
568
579
|
const modalList = context.modalItems || [enriched];
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
const meta = card.querySelector("div");
|
|
576
|
-
if (meta) {
|
|
577
|
-
const label = meta.textContent;
|
|
578
|
-
meta.className = "image-card-meta";
|
|
579
|
-
meta.textContent = "";
|
|
580
|
-
const labelNode = document.createElement("span");
|
|
581
|
-
labelNode.textContent = label;
|
|
582
|
-
meta.appendChild(labelNode);
|
|
583
|
-
if (canReuseArtists(item)) {
|
|
584
|
-
const button = document.createElement("button");
|
|
585
|
-
button.className = "mini-button artist-reuse-button";
|
|
586
|
-
button.type = "button";
|
|
587
|
-
button.textContent = "가중치 불러오기";
|
|
588
|
-
button.onclick = (event) => {
|
|
589
|
-
event.stopPropagation();
|
|
590
|
-
generateFromImage(enriched);
|
|
591
|
-
};
|
|
592
|
-
meta.appendChild(button);
|
|
593
|
-
}
|
|
580
|
+
if (item.image_url) {
|
|
581
|
+
const img = document.createElement("img");
|
|
582
|
+
img.src = item.image_url;
|
|
583
|
+
img.alt = "generated image";
|
|
584
|
+
img.onclick = () => openImageModal(enriched, modalList);
|
|
585
|
+
card.appendChild(img);
|
|
594
586
|
}
|
|
587
|
+
const meta = document.createElement("div");
|
|
588
|
+
meta.className = "image-card-meta";
|
|
589
|
+
const labelNode = document.createElement("span");
|
|
590
|
+
labelNode.textContent = item.error ? item.error : item.created_at || "생성 완료";
|
|
591
|
+
meta.appendChild(labelNode);
|
|
592
|
+
if (canReuseArtists(item)) {
|
|
593
|
+
const button = document.createElement("button");
|
|
594
|
+
button.className = "mini-button artist-reuse-button";
|
|
595
|
+
button.type = "button";
|
|
596
|
+
button.textContent = "가중치 불러오기";
|
|
597
|
+
button.onclick = (event) => {
|
|
598
|
+
event.stopPropagation();
|
|
599
|
+
generateFromImage(enriched);
|
|
600
|
+
};
|
|
601
|
+
meta.appendChild(button);
|
|
602
|
+
}
|
|
603
|
+
card.appendChild(meta);
|
|
595
604
|
return card;
|
|
596
605
|
}
|
|
597
606
|
|
|
@@ -626,7 +635,12 @@ function renderHistory() {
|
|
|
626
635
|
};
|
|
627
636
|
const button = document.createElement("button");
|
|
628
637
|
button.className = `list-item ${index === historyIndex ? "active" : ""}`;
|
|
629
|
-
|
|
638
|
+
const title = document.createElement("strong");
|
|
639
|
+
title.textContent = `${history.base_preset || ""} + ${history.character_preset || ""}`;
|
|
640
|
+
const br = document.createElement("br");
|
|
641
|
+
const meta = document.createElement("span");
|
|
642
|
+
meta.textContent = `${history.created_at || ""} · ${(history.items || []).length}장`;
|
|
643
|
+
button.append(title, br, meta);
|
|
630
644
|
button.onclick = () => {
|
|
631
645
|
historyIndex = index;
|
|
632
646
|
renderHistory();
|
|
@@ -834,7 +848,6 @@ function renderImageModalItem(item) {
|
|
|
834
848
|
const position = modalItems.length > 1 ? ` (${modalIndex + 1} / ${modalItems.length})` : "";
|
|
835
849
|
$("#modalTitle").textContent = `${item.source_label || item.created_at || "이미지"}${position}`;
|
|
836
850
|
$("#modalSubtitle").textContent = [item.source_base_preset, item.source_character_preset].filter(Boolean).join(" + ");
|
|
837
|
-
$("#modalPrompt").textContent = item.prompt || "";
|
|
838
851
|
const artistList = $("#modalArtists");
|
|
839
852
|
artistList.innerHTML = "";
|
|
840
853
|
for (const row of artistWeightRows(item.artists || [])) artistList.appendChild(row);
|
|
@@ -1041,6 +1054,7 @@ function setBattingRunning(running, cancelling = false) {
|
|
|
1041
1054
|
|
|
1042
1055
|
async function startBattingTest() {
|
|
1043
1056
|
syncEditorsToState();
|
|
1057
|
+
if (!confirmCustomEndpointTokenUse()) return;
|
|
1044
1058
|
if (!currentFixedArtists().length) {
|
|
1045
1059
|
window.alert("타율 테스트는 작가태그 가중치를 고정한 상태에서 실행하는 기능입니다. 먼저 히스토리나 가중치 비교에서 가중치를 불러와 주세요.");
|
|
1046
1060
|
return;
|
|
@@ -1149,8 +1163,23 @@ function generationStateForRequest() {
|
|
|
1149
1163
|
return requestState;
|
|
1150
1164
|
}
|
|
1151
1165
|
|
|
1166
|
+
function shouldConfirmCustomEndpoint() {
|
|
1167
|
+
const endpoint = String(state?.api?.endpoint || "").trim();
|
|
1168
|
+
if (!endpoint || endpoint === defaultNovelAiEndpoint) return false;
|
|
1169
|
+
return Boolean(state?.api?.token_saved || String($("#api_token")?.value || "").trim());
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function confirmCustomEndpointTokenUse() {
|
|
1173
|
+
if (!shouldConfirmCustomEndpoint()) return true;
|
|
1174
|
+
return window.confirm(
|
|
1175
|
+
"기본 NovelAI endpoint가 아닌 주소가 설정되어 있습니다.\n\n" +
|
|
1176
|
+
"이미지 생성 시 API 토큰이 이 endpoint로 전송됩니다. 계속할까요?"
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1152
1180
|
async function startGeneration() {
|
|
1153
1181
|
syncEditorsToState();
|
|
1182
|
+
if (!confirmCustomEndpointTokenUse()) return;
|
|
1154
1183
|
setProgressActive(true);
|
|
1155
1184
|
$("#jobLog").textContent = "생성 작업을 시작합니다...\n";
|
|
1156
1185
|
if (currentFixedArtists().length) $("#jobLog").textContent = "고정 작가가중치로 생성 작업을 시작합니다...\n";
|
|
@@ -1431,6 +1460,14 @@ function deleteChar() {
|
|
|
1431
1460
|
function bindAutosaveInputs(root = document) {
|
|
1432
1461
|
root.querySelectorAll("input, textarea, select").forEach((node) => {
|
|
1433
1462
|
if (node.id === "compareHistorySelect") return;
|
|
1463
|
+
if (node.id === "api_token") {
|
|
1464
|
+
node.oninput = null;
|
|
1465
|
+
node.onchange = () => {
|
|
1466
|
+
syncEditorsToState();
|
|
1467
|
+
scheduleSave();
|
|
1468
|
+
};
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1434
1471
|
node.oninput = () => {
|
|
1435
1472
|
syncEditorsToState();
|
|
1436
1473
|
if (node.id === "catTags") renderRecognizedTags();
|
package/web_app.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import mimetypes
|
|
5
5
|
import random
|
|
6
|
+
import secrets
|
|
6
7
|
import shutil
|
|
7
8
|
import sys
|
|
8
9
|
import threading
|
|
@@ -25,11 +26,17 @@ from app import (
|
|
|
25
26
|
NovelAIClient,
|
|
26
27
|
PromptPreset,
|
|
27
28
|
float_range,
|
|
29
|
+
has_saved_api_token,
|
|
30
|
+
load_api_token,
|
|
28
31
|
load_state,
|
|
29
32
|
now_id,
|
|
33
|
+
output_ref,
|
|
30
34
|
parse_artist_tags,
|
|
31
35
|
safe_path_name,
|
|
36
|
+
save_api_token,
|
|
32
37
|
save_state,
|
|
38
|
+
sanitize_history_entry,
|
|
39
|
+
sanitize_history_item,
|
|
33
40
|
weight_tag,
|
|
34
41
|
)
|
|
35
42
|
|
|
@@ -37,6 +44,7 @@ from app import (
|
|
|
37
44
|
WEB_DIR = APP_DIR / "web"
|
|
38
45
|
JOBS: dict[str, dict] = {}
|
|
39
46
|
STATE_LOCK = threading.Lock()
|
|
47
|
+
LOCAL_API_TOKEN = secrets.token_urlsafe(32)
|
|
40
48
|
|
|
41
49
|
|
|
42
50
|
def dataclass_from_dict(cls, data: dict):
|
|
@@ -72,27 +80,42 @@ def state_from_dict(data: dict) -> AppState:
|
|
|
72
80
|
|
|
73
81
|
def media_url(path: str) -> str:
|
|
74
82
|
try:
|
|
75
|
-
|
|
83
|
+
raw = str(path or "")
|
|
84
|
+
candidate = Path(raw)
|
|
85
|
+
root = OUTPUT_DIR.resolve()
|
|
86
|
+
resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
|
|
87
|
+
rel = resolved.relative_to(root)
|
|
76
88
|
except (ValueError, OSError):
|
|
77
89
|
return ""
|
|
78
90
|
return "/media/" + urllib.parse.quote(str(rel).replace("\\", "/"))
|
|
79
91
|
|
|
80
92
|
|
|
93
|
+
def safe_child_path(root: Path, rel: str) -> Path | None:
|
|
94
|
+
try:
|
|
95
|
+
root_resolved = root.resolve()
|
|
96
|
+
child = (root_resolved / urllib.parse.unquote(rel)).resolve()
|
|
97
|
+
child.relative_to(root_resolved)
|
|
98
|
+
return child
|
|
99
|
+
except (ValueError, OSError):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
81
103
|
def state_payload(state: AppState) -> dict:
|
|
82
104
|
data = asdict(state)
|
|
105
|
+
data.setdefault("api", {})["token"] = ""
|
|
106
|
+
data["api"]["token_saved"] = has_saved_api_token()
|
|
107
|
+
data["history"] = [sanitize_history_entry(item) for item in data.get("history", [])]
|
|
83
108
|
for category in data.get("categories", []):
|
|
84
109
|
category["recognized_tags"] = parse_artist_tags(category.get("tags", []))
|
|
85
110
|
for history in data.get("history", []):
|
|
86
111
|
for item in history.get("items", []):
|
|
87
112
|
item["image_url"] = media_url(item.get("path", ""))
|
|
88
|
-
item["request_url"] = media_url(item.get("request_path", ""))
|
|
89
113
|
return data
|
|
90
114
|
|
|
91
115
|
|
|
92
116
|
def item_payload(item: dict) -> dict:
|
|
93
|
-
data =
|
|
117
|
+
data = sanitize_history_item(item)
|
|
94
118
|
data["image_url"] = media_url(data.get("path", ""))
|
|
95
|
-
data["request_url"] = media_url(data.get("request_path", ""))
|
|
96
119
|
return data
|
|
97
120
|
|
|
98
121
|
|
|
@@ -186,6 +209,12 @@ def build_prompt(state: AppState) -> tuple[str, str, str, list[dict]]:
|
|
|
186
209
|
|
|
187
210
|
def save_incoming_state(data: dict) -> AppState:
|
|
188
211
|
state = state_from_dict(data)
|
|
212
|
+
incoming_token = str((data.get("api", {}) or {}).get("token", "") or "").strip()
|
|
213
|
+
if incoming_token:
|
|
214
|
+
save_api_token(incoming_token)
|
|
215
|
+
state.api.token = incoming_token
|
|
216
|
+
else:
|
|
217
|
+
state.api.token = load_api_token()
|
|
189
218
|
with STATE_LOCK:
|
|
190
219
|
current = load_state()
|
|
191
220
|
state.history = current.history
|
|
@@ -207,7 +236,8 @@ def delete_history_entries(ids: list[str], delete_files: bool) -> AppState:
|
|
|
207
236
|
if not raw_dir:
|
|
208
237
|
continue
|
|
209
238
|
try:
|
|
210
|
-
|
|
239
|
+
raw_path = Path(raw_dir)
|
|
240
|
+
target = raw_path.resolve() if raw_path.is_absolute() else (OUTPUT_DIR / raw_path).resolve()
|
|
211
241
|
except OSError:
|
|
212
242
|
continue
|
|
213
243
|
if target == output_root or output_root not in target.parents:
|
|
@@ -244,11 +274,7 @@ def run_generation(job_id: str, state: AppState) -> None:
|
|
|
244
274
|
prompt, negative, uc_prompt, artists = build_prompt(state)
|
|
245
275
|
path = out_dir / f"image_{idx + 1:03}.png"
|
|
246
276
|
metadata = {
|
|
247
|
-
"path": str(path),
|
|
248
|
-
"request_path": str(path.with_name(path.stem + "_request.json")),
|
|
249
|
-
"prompt": prompt,
|
|
250
|
-
"negative_prompt": negative,
|
|
251
|
-
"uc_prompt": uc_prompt,
|
|
277
|
+
"path": output_ref(str(path)),
|
|
252
278
|
"artists": artists,
|
|
253
279
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
254
280
|
}
|
|
@@ -266,7 +292,7 @@ def run_generation(job_id: str, state: AppState) -> None:
|
|
|
266
292
|
"base_preset": base.name if base else "",
|
|
267
293
|
"character_preset": character.name if character else "",
|
|
268
294
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
269
|
-
"output_dir": str(out_dir),
|
|
295
|
+
"output_dir": output_ref(str(out_dir)),
|
|
270
296
|
"items": items,
|
|
271
297
|
}
|
|
272
298
|
update = {
|
|
@@ -346,11 +372,7 @@ def run_batting_test(job_id: str, state: AppState) -> None:
|
|
|
346
372
|
prompt, negative, uc_prompt, artists = build_prompt(current)
|
|
347
373
|
path = scene_dir / f"image_{image_index + 1:03}.png"
|
|
348
374
|
metadata = {
|
|
349
|
-
"path": str(path),
|
|
350
|
-
"request_path": str(path.with_name(path.stem + "_request.json")),
|
|
351
|
-
"prompt": prompt,
|
|
352
|
-
"negative_prompt": negative,
|
|
353
|
-
"uc_prompt": uc_prompt,
|
|
375
|
+
"path": output_ref(str(path)),
|
|
354
376
|
"artists": artists,
|
|
355
377
|
"scene_name": scene_name,
|
|
356
378
|
"scene_index": scene_index,
|
|
@@ -376,7 +398,7 @@ def run_batting_test(job_id: str, state: AppState) -> None:
|
|
|
376
398
|
"base_preset": "타율 테스트",
|
|
377
399
|
"character_preset": f"{len(scenes)}개 씬",
|
|
378
400
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
379
|
-
"output_dir": str(out_dir),
|
|
401
|
+
"output_dir": output_ref(str(out_dir)),
|
|
380
402
|
"scenes": [asdict(scene) for scene in scenes],
|
|
381
403
|
"items": items,
|
|
382
404
|
}
|
|
@@ -424,27 +446,57 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
424
446
|
self.end_headers()
|
|
425
447
|
self.wfile.write(body)
|
|
426
448
|
|
|
449
|
+
def send_index(self) -> None:
|
|
450
|
+
path = WEB_DIR / "index.html"
|
|
451
|
+
if not path.exists() or not path.is_file():
|
|
452
|
+
self.send_error(404)
|
|
453
|
+
return
|
|
454
|
+
token_script = f"<script>window.__NAI_ACLAB_TOKEN__ = {json.dumps(LOCAL_API_TOKEN)};</script>"
|
|
455
|
+
html = path.read_text(encoding="utf-8").replace("</head>", f" {token_script}\n </head>")
|
|
456
|
+
body = html.encode("utf-8")
|
|
457
|
+
self.send_response(200)
|
|
458
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
459
|
+
self.send_header("Cache-Control", "no-store")
|
|
460
|
+
self.send_header("Content-Length", str(len(body)))
|
|
461
|
+
self.end_headers()
|
|
462
|
+
self.wfile.write(body)
|
|
463
|
+
|
|
464
|
+
def authorized_api_request(self) -> bool:
|
|
465
|
+
return self.headers.get("X-NAI-ACLAB-Token", "") == LOCAL_API_TOKEN
|
|
466
|
+
|
|
427
467
|
def do_GET(self) -> None:
|
|
428
468
|
parsed = urllib.parse.urlparse(self.path)
|
|
429
469
|
route = parsed.path
|
|
430
470
|
if route == "/":
|
|
431
|
-
self.
|
|
471
|
+
self.send_index()
|
|
432
472
|
elif route.startswith("/web/"):
|
|
433
|
-
|
|
473
|
+
path = safe_child_path(WEB_DIR, route.removeprefix("/web/"))
|
|
474
|
+
if not path:
|
|
475
|
+
self.send_error(403)
|
|
476
|
+
return
|
|
477
|
+
self.send_file(path)
|
|
434
478
|
elif route == "/api/state":
|
|
479
|
+
if not self.authorized_api_request():
|
|
480
|
+
self.send_error(403)
|
|
481
|
+
return
|
|
435
482
|
self.send_json({"state": state_payload(load_state())})
|
|
436
483
|
elif route == "/api/preview":
|
|
484
|
+
if not self.authorized_api_request():
|
|
485
|
+
self.send_error(403)
|
|
486
|
+
return
|
|
437
487
|
state = load_state()
|
|
438
488
|
prompt, negative, uc_prompt, artists = build_prompt(state)
|
|
439
489
|
self.send_json({"prompt": prompt, "negative": negative, "uc": uc_prompt, "artists": artists})
|
|
440
490
|
elif route == "/api/job":
|
|
491
|
+
if not self.authorized_api_request():
|
|
492
|
+
self.send_error(403)
|
|
493
|
+
return
|
|
441
494
|
query = urllib.parse.parse_qs(parsed.query)
|
|
442
495
|
job_id = query.get("id", [""])[0]
|
|
443
496
|
self.send_json({"job": JOBS.get(job_id, {"status": "missing"})})
|
|
444
497
|
elif route.startswith("/media/"):
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if not str(path).startswith(str(OUTPUT_DIR.resolve())):
|
|
498
|
+
path = safe_child_path(OUTPUT_DIR, route.removeprefix("/media/"))
|
|
499
|
+
if not path:
|
|
448
500
|
self.send_error(403)
|
|
449
501
|
return
|
|
450
502
|
self.send_file(path)
|
|
@@ -454,6 +506,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
454
506
|
def do_POST(self) -> None:
|
|
455
507
|
parsed = urllib.parse.urlparse(self.path)
|
|
456
508
|
route = parsed.path
|
|
509
|
+
if route.startswith("/api/") and not self.authorized_api_request():
|
|
510
|
+
self.send_error(403)
|
|
511
|
+
return
|
|
457
512
|
data = self.read_json()
|
|
458
513
|
if route == "/api/state":
|
|
459
514
|
state = save_incoming_state(data.get("state", data))
|