nai-aclab 1.0.1 → 1.0.3

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
@@ -121,6 +121,10 @@ 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
+
124
128
  ## 저장 위치
125
129
 
126
130
  앱 상태, 생성 이미지, 요청 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
- return default_state()
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(asdict(state), ensure_ascii=False, indent=2),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nai-aclab",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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
@@ -388,10 +388,6 @@
388
388
  <strong>작가 가중치</strong>
389
389
  <div id="modalArtists" class="artist-weight-list"></div>
390
390
  </div>
391
- <div class="modal-section">
392
- <strong>Prompt</strong>
393
- <pre id="modalPrompt"></pre>
394
- </div>
395
391
  </aside>
396
392
  </section>
397
393
  </div>
package/web/main.js CHANGED
@@ -534,8 +534,13 @@ function renderSettings() {
534
534
  const wrap = document.createElement("label");
535
535
  wrap.innerHTML = `<span>${label}</span><input id="api_${key}" type="${type}" />`;
536
536
  form.appendChild(wrap);
537
- $(`#api_${key}`).value = state.api[key] ?? "";
538
- if (type === "number") $(`#api_${key}`).step = "any";
537
+ const input = $(`#api_${key}`);
538
+ input.value = key === "token" ? "" : state.api[key] ?? "";
539
+ if (key === "token") {
540
+ input.placeholder = state.api.token_saved ? "저장된 토큰을 사용합니다" : "API 토큰을 입력하세요";
541
+ input.autocomplete = "off";
542
+ }
543
+ if (type === "number") input.step = "any";
539
544
  }
540
545
  $("#mockMode").checked = !!state.api.mock_mode;
541
546
  $("#negativePrompt").value = state.negative_prompt || "";
@@ -834,7 +839,6 @@ function renderImageModalItem(item) {
834
839
  const position = modalItems.length > 1 ? ` (${modalIndex + 1} / ${modalItems.length})` : "";
835
840
  $("#modalTitle").textContent = `${item.source_label || item.created_at || "이미지"}${position}`;
836
841
  $("#modalSubtitle").textContent = [item.source_base_preset, item.source_character_preset].filter(Boolean).join(" + ");
837
- $("#modalPrompt").textContent = item.prompt || "";
838
842
  const artistList = $("#modalArtists");
839
843
  artistList.innerHTML = "";
840
844
  for (const row of artistWeightRows(item.artists || [])) artistList.appendChild(row);
@@ -1431,6 +1435,14 @@ function deleteChar() {
1431
1435
  function bindAutosaveInputs(root = document) {
1432
1436
  root.querySelectorAll("input, textarea, select").forEach((node) => {
1433
1437
  if (node.id === "compareHistorySelect") return;
1438
+ if (node.id === "api_token") {
1439
+ node.oninput = null;
1440
+ node.onchange = () => {
1441
+ syncEditorsToState();
1442
+ scheduleSave();
1443
+ };
1444
+ return;
1445
+ }
1434
1446
  node.oninput = () => {
1435
1447
  syncEditorsToState();
1436
1448
  if (node.id === "catTags") renderRecognizedTags();
package/web_app.py CHANGED
@@ -25,11 +25,17 @@ from app import (
25
25
  NovelAIClient,
26
26
  PromptPreset,
27
27
  float_range,
28
+ has_saved_api_token,
29
+ load_api_token,
28
30
  load_state,
29
31
  now_id,
32
+ output_ref,
30
33
  parse_artist_tags,
31
34
  safe_path_name,
35
+ save_api_token,
32
36
  save_state,
37
+ sanitize_history_entry,
38
+ sanitize_history_item,
33
39
  weight_tag,
34
40
  )
35
41
 
@@ -72,7 +78,9 @@ def state_from_dict(data: dict) -> AppState:
72
78
 
73
79
  def media_url(path: str) -> str:
74
80
  try:
75
- rel = Path(path).resolve().relative_to(OUTPUT_DIR.resolve())
81
+ raw = str(path or "")
82
+ candidate = Path(raw)
83
+ rel = candidate.resolve().relative_to(OUTPUT_DIR.resolve()) if candidate.is_absolute() else Path(raw)
76
84
  except (ValueError, OSError):
77
85
  return ""
78
86
  return "/media/" + urllib.parse.quote(str(rel).replace("\\", "/"))
@@ -80,19 +88,20 @@ def media_url(path: str) -> str:
80
88
 
81
89
  def state_payload(state: AppState) -> dict:
82
90
  data = asdict(state)
91
+ data.setdefault("api", {})["token"] = ""
92
+ data["api"]["token_saved"] = has_saved_api_token()
93
+ data["history"] = [sanitize_history_entry(item) for item in data.get("history", [])]
83
94
  for category in data.get("categories", []):
84
95
  category["recognized_tags"] = parse_artist_tags(category.get("tags", []))
85
96
  for history in data.get("history", []):
86
97
  for item in history.get("items", []):
87
98
  item["image_url"] = media_url(item.get("path", ""))
88
- item["request_url"] = media_url(item.get("request_path", ""))
89
99
  return data
90
100
 
91
101
 
92
102
  def item_payload(item: dict) -> dict:
93
- data = dict(item)
103
+ data = sanitize_history_item(item)
94
104
  data["image_url"] = media_url(data.get("path", ""))
95
- data["request_url"] = media_url(data.get("request_path", ""))
96
105
  return data
97
106
 
98
107
 
@@ -186,6 +195,12 @@ def build_prompt(state: AppState) -> tuple[str, str, str, list[dict]]:
186
195
 
187
196
  def save_incoming_state(data: dict) -> AppState:
188
197
  state = state_from_dict(data)
198
+ incoming_token = str((data.get("api", {}) or {}).get("token", "") or "").strip()
199
+ if incoming_token:
200
+ save_api_token(incoming_token)
201
+ state.api.token = incoming_token
202
+ else:
203
+ state.api.token = load_api_token()
189
204
  with STATE_LOCK:
190
205
  current = load_state()
191
206
  state.history = current.history
@@ -207,7 +222,8 @@ def delete_history_entries(ids: list[str], delete_files: bool) -> AppState:
207
222
  if not raw_dir:
208
223
  continue
209
224
  try:
210
- target = Path(raw_dir).resolve()
225
+ raw_path = Path(raw_dir)
226
+ target = raw_path.resolve() if raw_path.is_absolute() else (OUTPUT_DIR / raw_path).resolve()
211
227
  except OSError:
212
228
  continue
213
229
  if target == output_root or output_root not in target.parents:
@@ -244,11 +260,7 @@ def run_generation(job_id: str, state: AppState) -> None:
244
260
  prompt, negative, uc_prompt, artists = build_prompt(state)
245
261
  path = out_dir / f"image_{idx + 1:03}.png"
246
262
  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,
263
+ "path": output_ref(str(path)),
252
264
  "artists": artists,
253
265
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
254
266
  }
@@ -266,7 +278,7 @@ def run_generation(job_id: str, state: AppState) -> None:
266
278
  "base_preset": base.name if base else "",
267
279
  "character_preset": character.name if character else "",
268
280
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
269
- "output_dir": str(out_dir),
281
+ "output_dir": output_ref(str(out_dir)),
270
282
  "items": items,
271
283
  }
272
284
  update = {
@@ -346,11 +358,7 @@ def run_batting_test(job_id: str, state: AppState) -> None:
346
358
  prompt, negative, uc_prompt, artists = build_prompt(current)
347
359
  path = scene_dir / f"image_{image_index + 1:03}.png"
348
360
  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,
361
+ "path": output_ref(str(path)),
354
362
  "artists": artists,
355
363
  "scene_name": scene_name,
356
364
  "scene_index": scene_index,
@@ -376,7 +384,7 @@ def run_batting_test(job_id: str, state: AppState) -> None:
376
384
  "base_preset": "타율 테스트",
377
385
  "character_preset": f"{len(scenes)}개 씬",
378
386
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
379
- "output_dir": str(out_dir),
387
+ "output_dir": output_ref(str(out_dir)),
380
388
  "scenes": [asdict(scene) for scene in scenes],
381
389
  "items": items,
382
390
  }