nai-aclab 1.0.3 → 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 +2 -0
- package/package.json +1 -1
- package/web/main.js +52 -27
- package/web_app.py +53 -6
package/README.md
CHANGED
|
@@ -125,6 +125,8 @@ API 토큰은 `app_state.json`에 저장하거나 `/api/state` 응답으로 다
|
|
|
125
125
|
|
|
126
126
|
생성 히스토리에는 이미지 표시와 작가 가중치 비교에 필요한 최소 정보만 저장합니다. 최종 프롬프트, 네거티브 프롬프트, UC 프롬프트, 생성 요청 JSON, 로컬 절대 경로는 별도로 저장하지 않습니다. 이전 버전에서 생성된 `*_request.json` 파일은 앱 시작 시 자동으로 제거됩니다.
|
|
127
127
|
|
|
128
|
+
로컬 API는 앱이 시작될 때 생성되는 임시 토큰으로 보호됩니다. 또한 기본 NovelAI endpoint가 아닌 주소로 이미지를 생성하려는 경우, API 토큰이 해당 endpoint로 전송된다는 확인창을 먼저 표시합니다.
|
|
129
|
+
|
|
128
130
|
## 저장 위치
|
|
129
131
|
|
|
130
132
|
앱 상태, 생성 이미지, 요청 JSON은 아래 위치에 저장됩니다.
|
package/package.json
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();
|
|
@@ -571,32 +577,30 @@ function renderImageCard(item, context = {}) {
|
|
|
571
577
|
card.className = `image-card ${item.error ? "error" : ""}`;
|
|
572
578
|
const enriched = enrichedImageItem(item, context);
|
|
573
579
|
const modalList = context.modalItems || [enriched];
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
};
|
|
597
|
-
meta.appendChild(button);
|
|
598
|
-
}
|
|
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);
|
|
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);
|
|
599
602
|
}
|
|
603
|
+
card.appendChild(meta);
|
|
600
604
|
return card;
|
|
601
605
|
}
|
|
602
606
|
|
|
@@ -631,7 +635,12 @@ function renderHistory() {
|
|
|
631
635
|
};
|
|
632
636
|
const button = document.createElement("button");
|
|
633
637
|
button.className = `list-item ${index === historyIndex ? "active" : ""}`;
|
|
634
|
-
|
|
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);
|
|
635
644
|
button.onclick = () => {
|
|
636
645
|
historyIndex = index;
|
|
637
646
|
renderHistory();
|
|
@@ -1045,6 +1054,7 @@ function setBattingRunning(running, cancelling = false) {
|
|
|
1045
1054
|
|
|
1046
1055
|
async function startBattingTest() {
|
|
1047
1056
|
syncEditorsToState();
|
|
1057
|
+
if (!confirmCustomEndpointTokenUse()) return;
|
|
1048
1058
|
if (!currentFixedArtists().length) {
|
|
1049
1059
|
window.alert("타율 테스트는 작가태그 가중치를 고정한 상태에서 실행하는 기능입니다. 먼저 히스토리나 가중치 비교에서 가중치를 불러와 주세요.");
|
|
1050
1060
|
return;
|
|
@@ -1153,8 +1163,23 @@ function generationStateForRequest() {
|
|
|
1153
1163
|
return requestState;
|
|
1154
1164
|
}
|
|
1155
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
|
+
|
|
1156
1180
|
async function startGeneration() {
|
|
1157
1181
|
syncEditorsToState();
|
|
1182
|
+
if (!confirmCustomEndpointTokenUse()) return;
|
|
1158
1183
|
setProgressActive(true);
|
|
1159
1184
|
$("#jobLog").textContent = "생성 작업을 시작합니다...\n";
|
|
1160
1185
|
if (currentFixedArtists().length) $("#jobLog").textContent = "고정 작가가중치로 생성 작업을 시작합니다...\n";
|
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
|
|
@@ -43,6 +44,7 @@ from app import (
|
|
|
43
44
|
WEB_DIR = APP_DIR / "web"
|
|
44
45
|
JOBS: dict[str, dict] = {}
|
|
45
46
|
STATE_LOCK = threading.Lock()
|
|
47
|
+
LOCAL_API_TOKEN = secrets.token_urlsafe(32)
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
def dataclass_from_dict(cls, data: dict):
|
|
@@ -80,12 +82,24 @@ def media_url(path: str) -> str:
|
|
|
80
82
|
try:
|
|
81
83
|
raw = str(path or "")
|
|
82
84
|
candidate = Path(raw)
|
|
83
|
-
|
|
85
|
+
root = OUTPUT_DIR.resolve()
|
|
86
|
+
resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
|
|
87
|
+
rel = resolved.relative_to(root)
|
|
84
88
|
except (ValueError, OSError):
|
|
85
89
|
return ""
|
|
86
90
|
return "/media/" + urllib.parse.quote(str(rel).replace("\\", "/"))
|
|
87
91
|
|
|
88
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
|
+
|
|
89
103
|
def state_payload(state: AppState) -> dict:
|
|
90
104
|
data = asdict(state)
|
|
91
105
|
data.setdefault("api", {})["token"] = ""
|
|
@@ -432,27 +446,57 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
432
446
|
self.end_headers()
|
|
433
447
|
self.wfile.write(body)
|
|
434
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
|
+
|
|
435
467
|
def do_GET(self) -> None:
|
|
436
468
|
parsed = urllib.parse.urlparse(self.path)
|
|
437
469
|
route = parsed.path
|
|
438
470
|
if route == "/":
|
|
439
|
-
self.
|
|
471
|
+
self.send_index()
|
|
440
472
|
elif route.startswith("/web/"):
|
|
441
|
-
|
|
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)
|
|
442
478
|
elif route == "/api/state":
|
|
479
|
+
if not self.authorized_api_request():
|
|
480
|
+
self.send_error(403)
|
|
481
|
+
return
|
|
443
482
|
self.send_json({"state": state_payload(load_state())})
|
|
444
483
|
elif route == "/api/preview":
|
|
484
|
+
if not self.authorized_api_request():
|
|
485
|
+
self.send_error(403)
|
|
486
|
+
return
|
|
445
487
|
state = load_state()
|
|
446
488
|
prompt, negative, uc_prompt, artists = build_prompt(state)
|
|
447
489
|
self.send_json({"prompt": prompt, "negative": negative, "uc": uc_prompt, "artists": artists})
|
|
448
490
|
elif route == "/api/job":
|
|
491
|
+
if not self.authorized_api_request():
|
|
492
|
+
self.send_error(403)
|
|
493
|
+
return
|
|
449
494
|
query = urllib.parse.parse_qs(parsed.query)
|
|
450
495
|
job_id = query.get("id", [""])[0]
|
|
451
496
|
self.send_json({"job": JOBS.get(job_id, {"status": "missing"})})
|
|
452
497
|
elif route.startswith("/media/"):
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if not str(path).startswith(str(OUTPUT_DIR.resolve())):
|
|
498
|
+
path = safe_child_path(OUTPUT_DIR, route.removeprefix("/media/"))
|
|
499
|
+
if not path:
|
|
456
500
|
self.send_error(403)
|
|
457
501
|
return
|
|
458
502
|
self.send_file(path)
|
|
@@ -462,6 +506,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
462
506
|
def do_POST(self) -> None:
|
|
463
507
|
parsed = urllib.parse.urlparse(self.path)
|
|
464
508
|
route = parsed.path
|
|
509
|
+
if route.startswith("/api/") and not self.authorized_api_request():
|
|
510
|
+
self.send_error(403)
|
|
511
|
+
return
|
|
465
512
|
data = self.read_json()
|
|
466
513
|
if route == "/api/state":
|
|
467
514
|
state = save_incoming_state(data.get("state", data))
|