nai-aclab 1.0.12 → 1.1.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
@@ -66,7 +66,7 @@ nai-aclab
66
66
 
67
67
  API 토큰을 아직 넣고 싶지 않다면 `목업 모드 사용`을 켜둔 채로 UI만 먼저 테스트할 수 있습니다. 목업 모드에서는 실제 NovelAI API를 쓰지 않고 테스트용 이미지를 만듭니다.
68
68
 
69
- 대부분의 사용자는 `Endpoint`, `Model`, `Sampler`, `Noise Schedule` 같은 값은 기본값 그대로 두면 됩니다.
69
+ 대부분의 사용자는 `Endpoint`, `Model`, `Sampler`, `Noise Schedule` 같은 값은 기본값 그대로 두면 됩니다. 처음 설치하면 이미지 모델은 `NAI Diffusion V5 Full`로 설정됩니다.
70
70
 
71
71
  ![API 설정 메뉴](docs/screenshots/07-api-settings.png)
72
72
 
@@ -103,7 +103,8 @@ artist:sample_a
103
103
  - 퀄리티 프롬프트: 품질 관련 태그
104
104
  - 캐릭터 프롬프트: 캐릭터별 외형과 행동
105
105
  - 네거티브 프롬프트: 피하고 싶은 요소
106
- - UC 프롬프트: NovelAI의 legacy UC 입력값
106
+
107
+ 공통 네거티브 프롬프트는 NovelAI의 UC에도 같은 내용으로 자동 전송됩니다.
107
108
 
108
109
  최종 프롬프트는 아래 순서로 조립됩니다.
109
110
 
@@ -232,7 +233,7 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
232
233
 
233
234
  API 토큰은 일반 설정 파일에 저장하지 않고 별도 secret store에 저장합니다. 앱 화면으로 다시 내려보내지도 않습니다.
234
235
 
235
- 생성 히스토리에는 이미지 표시와 작가 가중치 비교에 필요한 최소 정보만 저장합니다. 최종 프롬프트, 네거티브 프롬프트, UC 프롬프트, 생성 요청 JSON, 로컬 절대 경로는 별도로 저장하지 않습니다.
236
+ 생성 히스토리에는 이미지 표시와 작가 가중치 비교에 필요한 최소 정보만 저장합니다. 최종 프롬프트, 네거티브 프롬프트, 생성 요청 JSON, 로컬 절대 경로는 별도로 저장하지 않습니다.
236
237
 
237
238
  NovelAI가 이미지 파일 안에 저장하는 생성 정보는 그대로 남을 수 있습니다. 이 정보는 이미지 자체의 메타데이터입니다.
238
239
 
package/app.py CHANGED
@@ -131,6 +131,7 @@ def cleanup_saved_request_jsons() -> None:
131
131
 
132
132
  def state_dict_without_secrets(state: AppState | dict) -> dict:
133
133
  data = asdict(state) if not isinstance(state, dict) else dict(state)
134
+ data.pop("uc_prompt", None)
134
135
  api = dict(data.get("api", {}) or {})
135
136
  api["token"] = ""
136
137
  data["api"] = api
@@ -286,7 +287,7 @@ class ApiSettings:
286
287
  token: str = ""
287
288
  endpoint: str = "https://image.novelai.net/ai/generate-image"
288
289
  user_agent: str = DEFAULT_USER_AGENT
289
- model: str = "nai-diffusion-4-5-curated"
290
+ model: str = "nai-diffusion-5-full"
290
291
  width: int = 832
291
292
  height: int = 1216
292
293
  steps: int = 28
@@ -327,7 +328,6 @@ class AppState:
327
328
  character_presets: list[CharacterPreset] = field(default_factory=list)
328
329
  quality_override_prompt: str = ""
329
330
  negative_prompt: str = "lowres, bad anatomy, bad hands, text, error, missing fingers"
330
- uc_prompt: str = ""
331
331
  api: ApiSettings = field(default_factory=ApiSettings)
332
332
  generation: GenerationSettings = field(default_factory=GenerationSettings)
333
333
  batting_scenes: list[BattingScene] = field(default_factory=list)
@@ -337,8 +337,8 @@ class AppState:
337
337
  def default_state() -> AppState:
338
338
  return AppState(
339
339
  categories=[
340
- Category("메인 그림체 작가", ["artist:example_main", "artist:sample_a"], 1.0, 1.4, 0.05, 0),
341
- Category("그림체 안정화 작가", ["artist:example_stable", "artist:sample_b"], 0.4, 0.9, 0.1, 0),
340
+ Category("메인 그림체 작가", [], 1.0, 1.4, 0.05, 0),
341
+ Category("그림체 안정화 작가", [], 0.4, 0.9, 0.1, 0),
342
342
  ],
343
343
  base_presets=[
344
344
  PromptPreset("기본", "", "masterpiece, best quality, very aesthetic, detailed illustration"),
@@ -392,7 +392,6 @@ def load_state() -> AppState:
392
392
  character_presets=[CharacterPreset(**item) for item in data.get("character_presets", [])],
393
393
  quality_override_prompt=quality_override,
394
394
  negative_prompt=data.get("negative_prompt", ""),
395
- uc_prompt=data.get("uc_prompt", data.get("negative_prompt", "")),
396
395
  api=ApiSettings(**api_data),
397
396
  generation=GenerationSettings(**data.get("generation", {})),
398
397
  batting_scenes=[BattingScene(**item) for item in data.get("batting_scenes", [])],
@@ -412,7 +411,7 @@ class NovelAIClient:
412
411
  def __init__(self, settings: ApiSettings):
413
412
  self.settings = settings
414
413
 
415
- def build_payload(self, prompt: str, negative_prompt: str, uc_prompt: str, seed: int | None = None) -> dict:
414
+ def build_payload(self, prompt: str, negative_prompt: str, seed: int | None = None) -> dict:
416
415
  actual_seed = seed if seed is not None else (
417
416
  self.settings.seed if self.settings.seed >= 0 else random.randint(0, 2**32 - 1)
418
417
  )
@@ -468,18 +467,18 @@ class NovelAIClient:
468
467
  "wonky_vibe_correlation": True,
469
468
  "stream": "none",
470
469
  "version": 1,
471
- "uc": uc_prompt,
472
- "negative_prompt": uc_prompt,
470
+ "uc": negative_prompt,
471
+ "negative_prompt": negative_prompt,
473
472
  "request_type": "PromptGenerateRequest",
474
473
  },
475
474
  }
476
475
 
477
- def generate(self, prompt: str, negative_prompt: str, uc_prompt: str, output_path: Path) -> None:
476
+ def generate(self, prompt: str, negative_prompt: str, output_path: Path) -> None:
478
477
  if self.settings.mock_mode or not self.settings.token.strip():
479
478
  write_placeholder_png(output_path, prompt, self.settings.width, self.settings.height)
480
479
  return
481
480
 
482
- payload = self.build_payload(prompt, negative_prompt, uc_prompt)
481
+ payload = self.build_payload(prompt, negative_prompt)
483
482
  req = urllib.request.Request(
484
483
  self.settings.endpoint,
485
484
  data=json.dumps(payload).encode("utf-8"),
@@ -794,11 +793,7 @@ class App(tk.Tk if tk else object):
794
793
  neg_box.pack(fill=BOTH, expand=True, pady=12)
795
794
  self.negative_text = tk.Text(neg_box, height=8, wrap="word")
796
795
  self.negative_text.pack(fill=BOTH, expand=True)
797
- uc_box = ttk.LabelFrame(self.settings_tab, text="UC 프롬프트 (parameters.uc)", padding=8)
798
- uc_box.pack(fill=BOTH, expand=True, pady=(0, 12))
799
- self.uc_text = tk.Text(uc_box, height=5, wrap="word")
800
- self.uc_text.pack(fill=BOTH, expand=True)
801
- ttk.Button(uc_box, text="네거티브 / UC 저장", command=self.save_negative).pack(anchor="e", pady=6)
796
+ ttk.Button(neg_box, text="네거티브 프롬프트 저장", command=self.save_negative).pack(anchor="e", pady=6)
802
797
 
803
798
  def refresh_all(self) -> None:
804
799
  self.refresh_categories()
@@ -1171,8 +1166,6 @@ class App(tk.Tk if tk else object):
1171
1166
  var.set(getattr(api, key))
1172
1167
  self.negative_text.delete("1.0", END)
1173
1168
  self.negative_text.insert("1.0", self.state_data.negative_prompt)
1174
- self.uc_text.delete("1.0", END)
1175
- self.uc_text.insert("1.0", self.state_data.uc_prompt)
1176
1169
 
1177
1170
  def save_api_settings(self, show_message: bool = True) -> None:
1178
1171
  api = self.state_data.api
@@ -1184,7 +1177,6 @@ class App(tk.Tk if tk else object):
1184
1177
 
1185
1178
  def save_negative(self) -> None:
1186
1179
  self.state_data.negative_prompt = self.negative_text.get("1.0", END).strip()
1187
- self.state_data.uc_prompt = self.uc_text.get("1.0", END).strip()
1188
1180
  save_state(self.state_data)
1189
1181
 
1190
1182
  def random_artist_tags(self) -> list[dict]:
@@ -1200,7 +1192,7 @@ class App(tk.Tk if tk else object):
1200
1192
  result.append({"category": cat.name, "tag": tag, "weight": weight, "prompt": weight_tag(tag, weight)})
1201
1193
  return result
1202
1194
 
1203
- def build_prompt(self) -> tuple[str, str, str, list[dict]]:
1195
+ def build_prompt(self) -> tuple[str, str, list[dict]]:
1204
1196
  base = self.find_base()
1205
1197
  char = self.find_char()
1206
1198
  artists = self.random_artist_tags()
@@ -1226,11 +1218,10 @@ class App(tk.Tk if tk else object):
1226
1218
  if char:
1227
1219
  negative.extend(n.strip() for n in char.negatives if n.strip())
1228
1220
  negative_prompt = ", ".join(n for n in negative if n)
1229
- uc_prompt = self.state_data.uc_prompt.strip() or negative_prompt
1230
- return " | ".join(prompt_parts), negative_prompt, uc_prompt, artists
1221
+ return " | ".join(prompt_parts), negative_prompt, artists
1231
1222
 
1232
1223
  def preview_prompt(self) -> None:
1233
- prompt, negative, uc_prompt, artists = self.build_prompt()
1224
+ prompt, negative, artists = self.build_prompt()
1234
1225
  base = self.find_base()
1235
1226
  char = self.find_char()
1236
1227
  self.prompt_preview.delete("1.0", END)
@@ -1249,7 +1240,7 @@ class App(tk.Tk if tk else object):
1249
1240
  for idx, char_prompt in enumerate(char.prompts, start=1):
1250
1241
  if char_prompt.strip():
1251
1242
  self.prompt_preview.insert(END, f"{idx}. {char_prompt.strip()}\n")
1252
- self.prompt_preview.insert(END, f"\n[Negative]\n{negative}\n\n[UC]\n{uc_prompt}\n")
1243
+ self.prompt_preview.insert(END, f"\n[Negative / UC]\n{negative}\n")
1253
1244
 
1254
1245
  def start_generation(self) -> None:
1255
1246
  count = max(1, int(self.count_var.get()))
@@ -1274,7 +1265,7 @@ class App(tk.Tk if tk else object):
1274
1265
  client = NovelAIClient(self.state_data.api)
1275
1266
  items = []
1276
1267
  for idx in range(count):
1277
- prompt, negative, uc_prompt, artists = self.build_prompt()
1268
+ prompt, negative, artists = self.build_prompt()
1278
1269
  path = out_dir / f"image_{idx + 1:03}.png"
1279
1270
  metadata = {
1280
1271
  "path": output_ref(str(path)),
@@ -1282,7 +1273,7 @@ class App(tk.Tk if tk else object):
1282
1273
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
1283
1274
  }
1284
1275
  try:
1285
- client.generate(prompt, negative, uc_prompt, path)
1276
+ client.generate(prompt, negative, path)
1286
1277
  items.append(metadata)
1287
1278
  self.after(0, self.log_line, f"{idx + 1}/{count} 완료: {path.name}")
1288
1279
  except Exception as exc:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nai-aclab",
3
- "version": "1.0.12",
3
+ "version": "1.1.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
@@ -11,18 +11,18 @@
11
11
  <aside class="sidebar">
12
12
  <div class="brand">
13
13
  <div class="brand-mark">NAI</div>
14
- <div>
15
- <strong>Artist Lab</strong>
16
- <span>Prompt mixing workspace</span>
14
+ <div>
15
+ <strong>Artist Lab</strong>
16
+ <span>NovelAI 작가 조합 실험실</span>
17
17
  </div>
18
18
  </div>
19
19
  <nav class="nav" aria-label="Main">
20
- <button class="nav-item active" data-tab="generate">⌘ 생성</button>
20
+ <button class="nav-item active" data-tab="generate">⌘ 이미지 생성</button>
21
21
  <button class="nav-item" data-tab="batting">▦ 타율 테스트</button>
22
22
  <button class="nav-item" data-tab="artists"># 작가 태그</button>
23
- <button class="nav-item" data-tab="presets">▣ 프리셋</button>
23
+ <button class="nav-item" data-tab="presets">▣ 프롬프트 프리셋</button>
24
24
  <button class="nav-item" data-tab="compare">≋ 가중치 비교</button>
25
- <button class="nav-item" data-tab="history">◈ 히스토리</button>
25
+ <button class="nav-item" data-tab="history">◈ 생성 기록</button>
26
26
  </nav>
27
27
  <div class="sidebar-tools">
28
28
  <button class="nav-item settings-nav-item" data-tab="settings">⚙ API 설정</button>
@@ -33,18 +33,18 @@
33
33
  </aside>
34
34
 
35
35
  <main class="workspace">
36
- <header class="topbar">
36
+ <header class="topbar">
37
37
  <div>
38
38
  <h1 id="pageTitle">생성</h1>
39
39
  <p id="pageSubtitle">프리셋, 작가 조합, 생성 상태를 한 화면에서 조정합니다.</p>
40
- </div>
40
+ </div>
41
41
  <div class="top-actions">
42
- <button class="ghost-button" id="refreshButton">새로고침</button>
42
+ <button class="icon-button" id="refreshButton" type="button" title="저장된 내용을 다시 불러오기" aria-label="저장된 내용을 다시 불러오기">↻</button>
43
43
  </div>
44
- </header>
44
+ </header>
45
45
 
46
46
  <section id="generate" class="tab-page active">
47
- <section class="panel live-preview-panel">
47
+ <section class="panel live-preview-panel">
48
48
  <div class="panel-header">
49
49
  <div>
50
50
  <h2>실시간 이미지 미리보기</h2>
@@ -53,16 +53,19 @@
53
53
  </div>
54
54
  <div id="liveGallery" class="live-gallery">
55
55
  <p>아직 생성된 이미지가 없습니다.</p>
56
- </div>
57
- </section>
58
-
59
- <div class="grid two-one">
56
+ </div>
57
+ </section>
58
+
59
+ <section id="setupGuide" class="setup-guide" aria-label="생성 준비 상태"></section>
60
+
61
+ <div class="grid two-one">
60
62
  <section class="panel">
61
- <div class="panel-header">
62
- <div>
63
- <h2>생성 옵션</h2>
64
- <p>마지막 선택값은 자동 저장됩니다.</p>
65
- </div>
63
+ <div class="panel-header">
64
+ <div>
65
+ <h2>생성 옵션</h2>
66
+ <p>프리셋과 이미지 수를 고른 뒤 바로 생성할 수 있습니다.</p>
67
+ </div>
68
+ <span class="mode-status" id="generationModeBadge"></span>
66
69
  </div>
67
70
  <div class="form-grid">
68
71
  <label>
@@ -90,9 +93,9 @@
90
93
  <p id="fixedArtistSummary">작가 태그는 생성할 때마다 랜덤으로 선택됩니다.</p>
91
94
  <div id="fixedArtistList" class="artist-weight-list fixed-artist-list"></div>
92
95
  </div>
93
- <div class="button-row">
94
- <button class="ghost-button" id="previewButton">프롬프트 새로 뽑기</button>
95
- <button class="primary-button" id="generateButtonInline">이미지 생성</button>
96
+ <div class="button-row">
97
+ <button class="ghost-button" id="previewButton" type="button" title="이미지는 만들지 않고 랜덤 작가 조합과 최종 프롬프트만 다시 계산합니다.">작가 조합 다시 뽑기</button>
98
+ <button class="primary-button" id="generateButtonInline">이미지 생성</button>
96
99
  <button class="danger-button" id="stopGenerateButton" type="button" disabled>생성 중지</button>
97
100
  </div>
98
101
  <div class="progress-wrap">
@@ -127,14 +130,10 @@
127
130
  <strong>Prompt</strong>
128
131
  <pre id="promptPreview"></pre>
129
132
  </div>
130
- <div>
131
- <strong>Negative</strong>
132
- <pre id="negativePreview"></pre>
133
- </div>
134
- <div>
135
- <strong>UC</strong>
136
- <pre id="ucPreview"></pre>
137
- </div>
133
+ <div>
134
+ <strong>Negative / UC</strong>
135
+ <pre id="negativePreview"></pre>
136
+ </div>
138
137
  </div>
139
138
  </section>
140
139
 
@@ -153,14 +152,16 @@
153
152
  <button class="danger-button" id="stopBattingButton" type="button" disabled>생성 중지</button>
154
153
  </div>
155
154
  </div>
156
- <div class="fixed-artist-panel">
157
- <div class="fixed-artist-head">
158
- <div class="mode-status" id="battingFixedArtistModeLabel">랜덤 가중치 모드</div>
159
- </div>
160
- <p id="battingFixedArtistSummary">히스토리나 가중치 비교에서 마음에 드는 이미지의 가중치를 먼저 불러오세요.</p>
161
- <div id="battingFixedArtistList" class="artist-weight-list fixed-artist-list"></div>
162
- </div>
163
- <div id="battingSceneList" class="batting-scene-list"></div>
155
+ <div class="fixed-artist-panel">
156
+ <div class="fixed-artist-head">
157
+ <div class="mode-status" id="battingFixedArtistModeLabel">랜덤 가중치 모드</div>
158
+ <button class="ghost-button" id="battingLoadWeightsButton" type="button">히스토리에서 가중치 고르기</button>
159
+ </div>
160
+ <p id="battingFixedArtistSummary">히스토리나 가중치 비교에서 마음에 드는 이미지의 가중치를 먼저 불러오세요.</p>
161
+ <div id="battingFixedArtistList" class="artist-weight-list fixed-artist-list"></div>
162
+ </div>
163
+ <div id="battingReadiness" class="inline-notice"></div>
164
+ <div id="battingSceneList" class="batting-scene-list"></div>
164
165
  </section>
165
166
 
166
167
  <section class="panel live-preview-panel">
@@ -219,24 +220,34 @@
219
220
  <p>선택 태그 수가 비어 있으면 모든 태그를 포함합니다.</p>
220
221
  </div>
221
222
  </div>
222
- <div class="form-grid compact">
223
- <label><span>카테고리명</span><input id="catName" /></label>
224
- <label><span>최소 가중치</span><input id="catMin" type="number" step="0.01" /></label>
225
- <label><span>최대 가중치</span><input id="catMax" type="number" step="0.01" /></label>
226
- <label><span>Granule</span><input id="catGranule" type="number" step="0.01" /></label>
227
- <label><span>선택 태그 수</span><input id="catPicks" type="number" min="1" placeholder="전체" /></label>
228
- </div>
229
- <label class="text-label">
230
- <span>태그 목록</span>
231
- <textarea id="catTags" spellcheck="false"></textarea>
232
- </label>
223
+ <div class="form-grid compact">
224
+ <label><span>카테고리명</span><input id="catName" /></label>
225
+ <label><span>최소 가중치</span><input id="catMin" type="number" step="0.01" /></label>
226
+ <label><span>최대 가중치</span><input id="catMax" type="number" step="0.01" /></label>
227
+ <label>
228
+ <span>가중치 간격</span>
229
+ <input id="catGranule" type="number" step="0.01" />
230
+ <small class="field-help">예: 0.05이면 1.00, 1.05, 1.10처럼 선택됩니다.</small>
231
+ </label>
232
+ <label>
233
+ <span>한 번에 사용할 태그 수</span>
234
+ <input id="catPicks" type="number" min="1" placeholder="비워두면 전체" />
235
+ <small class="field-help">비워두면 이 카테고리에 입력한 작가를 모두 사용합니다.</small>
236
+ </label>
237
+ </div>
238
+ <label class="text-label">
239
+ <span>태그 목록</span>
240
+ <textarea id="catTags" spellcheck="false" placeholder="artist:example_name&#10;또는 1.2::artist:example_name ::, artist:another_name"></textarea>
241
+ <small class="field-help">쉼표로 붙여넣거나 한 줄에 하나씩 입력해도 artist:이름 부분만 자동 인식합니다.</small>
242
+ </label>
233
243
  <div class="recognized-box">
234
244
  <div>
235
- <strong>인식된 artist 태그</strong>
236
- <span id="recognizedCount">0개</span>
237
- </div>
238
- <div id="recognizedTags" class="tag-chips"></div>
239
- </div>
245
+ <strong>인식된 artist 태그</strong>
246
+ <span id="recognizedCount">0개</span>
247
+ </div>
248
+ <div id="recognizedTags" class="tag-chips"></div>
249
+ </div>
250
+ <div id="categoryWeightPreview" class="inline-notice compact-notice"></div>
240
251
  <div class="button-row">
241
252
  <button class="danger-button" id="deleteCategoryButton">카테고리 삭제</button>
242
253
  </div>
@@ -256,9 +267,13 @@
256
267
  </div>
257
268
  <div id="baseList" class="item-list horizontal"></div>
258
269
  <label class="text-label short"><span>프리셋 이름</span><input id="baseName" /></label>
259
- <label class="text-label"><span>베이스 프롬프트</span><textarea id="basePrompt" spellcheck="false"></textarea></label>
260
- <label class="text-label"><span>퀄리티 프롬프트</span><textarea id="qualityPrompt" spellcheck="false"></textarea></label>
261
- <label class="text-label"><span>공용 퀄리티 프롬프트 Override</span><textarea id="qualityOverridePrompt" spellcheck="false" placeholder="비워두면 선택한 베이스 + 퀄리티 프리셋의 퀄리티 프롬프트를 사용합니다."></textarea></label>
270
+ <label class="text-label"><span>베이스 프롬프트</span><textarea id="basePrompt" spellcheck="false"></textarea></label>
271
+ <label class="text-label"><span>퀄리티 프롬프트</span><textarea id="qualityPrompt" spellcheck="false"></textarea></label>
272
+ <label class="text-label">
273
+ <span>공용 퀄리티 덮어쓰기 (선택)</span>
274
+ <textarea id="qualityOverridePrompt" spellcheck="false" placeholder="비워두면 각 베이스 프리셋의 퀄리티 프롬프트를 사용합니다."></textarea>
275
+ <small class="field-help">값이 있으면 모든 베이스 프리셋의 퀄리티 프롬프트 대신 이 내용을 사용합니다.</small>
276
+ </label>
262
277
  <button class="danger-button" id="deleteBaseButton">베이스 프리셋 삭제</button>
263
278
  </section>
264
279
  <section class="panel">
@@ -272,35 +287,54 @@
272
287
  <button class="icon-button" id="addCharButton" title="새 캐릭터">+</button>
273
288
  </div>
274
289
  </div>
275
- <div id="charList" class="item-list horizontal"></div>
276
- <label class="text-label short"><span>프리셋 이름</span><input id="charName" /></label>
277
- <div id="characterEditors" class="character-editors"></div>
290
+ <div id="charList" class="item-list horizontal"></div>
291
+ <label class="text-label short"><span>프리셋 이름</span><input id="charName" /></label>
292
+ <div id="charSlotTabs" class="segment-control" role="tablist" aria-label="캐릭터 선택"></div>
293
+ <div id="characterEditors" class="character-editors"></div>
278
294
  <button class="danger-button" id="deleteCharButton">캐릭터 프리셋 삭제</button>
279
295
  </section>
280
296
  </div>
281
297
  </section>
282
298
 
283
- <section id="settings" class="tab-page">
284
- <div class="grid two-one">
285
- <section class="panel">
286
- <div class="panel-header">
287
- <h2>API 설정</h2>
288
- <p>생성 시 자동 저장됩니다.</p>
289
- </div>
290
- <div id="apiForm" class="form-grid compact"></div>
291
- <label class="switch-row">
292
- <input id="mockMode" type="checkbox" />
293
- <span>목업 모드 사용</span>
294
- </label>
295
- <button class="primary-button" id="saveSettingsButton">설정 저장</button>
296
- </section>
297
- <section class="panel">
298
- <div class="panel-header">
299
- <h2>Undesired Content</h2>
300
- <p>V4 negative와 legacy UC를 분리해서 보냅니다.</p>
301
- </div>
302
- <label class="text-label"><span>공통 네거티브 프롬프트</span><textarea id="negativePrompt" spellcheck="false"></textarea></label>
303
- <label class="text-label"><span>UC 프롬프트 (parameters.uc / negative_prompt)</span><textarea id="ucPrompt" spellcheck="false"></textarea></label>
299
+ <section id="settings" class="tab-page">
300
+ <div class="grid two-one">
301
+ <section class="panel">
302
+ <div class="panel-header">
303
+ <div>
304
+ <h2>NovelAI 연결</h2>
305
+ <p>실제 이미지를 만들려면 API 토큰이 필요합니다.</p>
306
+ </div>
307
+ <span id="apiConnectionStatus" class="mode-status"></span>
308
+ </div>
309
+ <div id="apiPrimaryForm" class="form-grid"></div>
310
+ <label class="switch-row feature-switch">
311
+ <input id="mockMode" type="checkbox" />
312
+ <span>
313
+ <strong>API 없이 체험하기</strong>
314
+ <small>NovelAI에 요청하지 않고 색상 목업 이미지를 생성합니다.</small>
315
+ </span>
316
+ </label>
317
+ <div class="button-row">
318
+ <button class="primary-button" id="saveSettingsButton">토큰 및 설정 저장</button>
319
+ </div>
320
+ <details class="advanced-settings">
321
+ <summary>고급 API 설정</summary>
322
+ <p>NovelAI 기본값을 바꿔야 할 때만 열어보세요.</p>
323
+ <div id="apiAdvancedForm" class="form-grid compact"></div>
324
+ </details>
325
+ </section>
326
+ <section class="panel">
327
+ <div class="panel-header">
328
+ <div>
329
+ <h2>제외할 내용</h2>
330
+ <p>이미지에 나오지 않았으면 하는 요소를 입력합니다.</p>
331
+ </div>
332
+ </div>
333
+ <label class="text-label">
334
+ <span>공통 네거티브 프롬프트</span>
335
+ <textarea id="negativePrompt" spellcheck="false"></textarea>
336
+ <small class="field-help">모든 캐릭터에 공통으로 적용되며 NovelAI의 UC에도 같은 내용이 전송됩니다.</small>
337
+ </label>
304
338
  </section>
305
339
  </div>
306
340
  </section>
@@ -393,7 +427,7 @@
393
427
  </aside>
394
428
  </section>
395
429
  </div>
396
- <div id="presetPickerModal" class="image-modal" hidden>
430
+ <div id="presetPickerModal" class="image-modal" hidden>
397
431
  <div class="image-modal-backdrop" id="presetPickerBackdrop"></div>
398
432
  <section class="preset-picker-panel" role="dialog" aria-modal="true" aria-labelledby="presetPickerTitle">
399
433
  <div class="panel-header">
@@ -404,8 +438,9 @@
404
438
  <button class="icon-button" id="presetPickerCloseButton" title="닫기">×</button>
405
439
  </div>
406
440
  <div id="presetPickerList" class="preset-picker-list"></div>
407
- </section>
408
- </div>
409
- <script src="/web/main.js"></script>
441
+ </section>
442
+ </div>
443
+ <div id="toastRegion" class="toast-region" aria-live="polite" aria-atomic="true"></div>
444
+ <script src="/web/main.js"></script>
410
445
  </body>
411
446
  </html>