nai-aclab 1.0.7 → 1.0.8
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 +24 -3
- package/app.py +26 -7
- package/bin/nai-artist-lab.js +4 -4
- package/package.json +1 -1
- package/web/index.html +12 -10
- package/web/main.js +41 -13
- package/web/styles.css +58 -5
- package/web_app.py +49 -9
package/README.md
CHANGED
|
@@ -68,6 +68,8 @@ API 토큰을 아직 넣고 싶지 않다면 `목업 모드 사용`을 켜둔
|
|
|
68
68
|
|
|
69
69
|
대부분의 사용자는 `Endpoint`, `Model`, `Sampler`, `Noise Schedule` 같은 값은 기본값 그대로 두면 됩니다.
|
|
70
70
|
|
|
71
|
+

|
|
72
|
+
|
|
71
73
|
## 기본 사용 순서
|
|
72
74
|
|
|
73
75
|
### 1. 작가 태그 등록
|
|
@@ -91,6 +93,8 @@ artist:sample_a
|
|
|
91
93
|
|
|
92
94
|
`선택 태그 수`를 비워두면 그 카테고리에 적은 작가 태그를 모두 사용합니다. 일부만 랜덤으로 뽑고 싶을 때만 숫자를 입력하면 됩니다.
|
|
93
95
|
|
|
96
|
+

|
|
97
|
+
|
|
94
98
|
### 2. 프리셋 만들기
|
|
95
99
|
|
|
96
100
|
`프리셋` 메뉴에서 자주 쓸 프롬프트를 저장합니다.
|
|
@@ -109,6 +113,8 @@ artist:sample_a
|
|
|
109
113
|
|
|
110
114
|
NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지 않고 `|`로 나눕니다.
|
|
111
115
|
|
|
116
|
+

|
|
117
|
+
|
|
112
118
|
### 3. 이미지 생성
|
|
113
119
|
|
|
114
120
|
`생성` 메뉴에서 사용할 프리셋과 이미지 크기를 고른 뒤 `이미지 생성`을 누릅니다.
|
|
@@ -119,6 +125,8 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
|
|
|
119
125
|
|
|
120
126
|
고정된 가중치를 해제하고 싶으면 생성 메뉴에서 `고정 해제`를 누르면 됩니다.
|
|
121
127
|
|
|
128
|
+

|
|
129
|
+
|
|
122
130
|
## 주요 메뉴 설명
|
|
123
131
|
|
|
124
132
|
### 생성
|
|
@@ -161,12 +169,16 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
|
|
|
161
169
|
- 히스토리 선택 삭제 또는 전체 삭제
|
|
162
170
|
- 이상형 월드컵 실행
|
|
163
171
|
|
|
172
|
+

|
|
173
|
+
|
|
164
174
|
### 가중치 비교
|
|
165
175
|
|
|
166
176
|
한 히스토리 안에서 생성된 이미지들을 옆으로 훑어보며, 각 이미지에 어떤 작가 가중치가 쓰였는지 비교하는 화면입니다.
|
|
167
177
|
|
|
168
178
|
여기서도 마음에 드는 이미지의 `가중치 불러오기`를 누를 수 있습니다.
|
|
169
179
|
|
|
180
|
+

|
|
181
|
+
|
|
170
182
|
### 이상형 월드컵
|
|
171
183
|
|
|
172
184
|
히스토리 이미지 중에서 두 장씩 비교하며 가장 마음에 드는 이미지를 고르는 기능입니다.
|
|
@@ -193,6 +205,8 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
|
|
|
193
205
|
|
|
194
206
|
생성 중 멈추고 싶으면 `생성 중지`를 누르면 됩니다. 현재 처리 중인 이미지가 끝난 뒤 남은 생성을 멈춥니다.
|
|
195
207
|
|
|
208
|
+

|
|
209
|
+
|
|
196
210
|
## 이미지 크기
|
|
197
211
|
|
|
198
212
|
생성 메뉴에서 아래 크기 중 하나를 고를 수 있습니다.
|
|
@@ -205,9 +219,16 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
|
|
|
205
219
|
|
|
206
220
|
앱 설정과 생성 이미지는 내 컴퓨터에 저장됩니다.
|
|
207
221
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
-
|
|
222
|
+
기본적으로 운영체제의 사진/그림 폴더 아래에 앱 폴더를 만들고, 그 안에 설정과 생성 이미지를 함께 저장합니다.
|
|
223
|
+
|
|
224
|
+
- Windows: `Pictures\NAI Artist Combination Lab`
|
|
225
|
+
- macOS: `~/Pictures/NAI Artist Combination Lab`
|
|
226
|
+
- Linux: `XDG_PICTURES_DIR/NAI Artist Combination Lab`
|
|
227
|
+
|
|
228
|
+
폴더 안의 구조는 아래처럼 나뉩니다.
|
|
229
|
+
|
|
230
|
+
- `data`: 앱 설정과 API 토큰 secret 파일
|
|
231
|
+
- `outputs`: 생성된 이미지
|
|
211
232
|
|
|
212
233
|
API 토큰은 일반 설정 파일에 저장하지 않고 별도 secret store에 저장합니다. 앱 화면으로 다시 내려보내지도 않습니다.
|
|
213
234
|
|
package/app.py
CHANGED
|
@@ -23,12 +23,30 @@ except Exception:
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
APP_DIR = Path(__file__).resolve().parent
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_pictures_dir() -> Path:
|
|
29
|
+
if os.name != "nt":
|
|
30
|
+
user_dirs = Path.home() / ".config" / "user-dirs.dirs"
|
|
31
|
+
try:
|
|
32
|
+
for line in user_dirs.read_text(encoding="utf-8").splitlines():
|
|
33
|
+
if line.startswith("XDG_PICTURES_DIR="):
|
|
34
|
+
value = line.split("=", 1)[1].strip().strip('"')
|
|
35
|
+
value = value.replace("$HOME", str(Path.home()))
|
|
36
|
+
return Path(value).expanduser()
|
|
37
|
+
except OSError:
|
|
38
|
+
pass
|
|
39
|
+
return Path.home() / "Pictures"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_user_dir() -> Path:
|
|
43
|
+
configured = os.environ.get("NAI_ARTIST_LAB_USER_DIR", "").strip()
|
|
44
|
+
if configured:
|
|
45
|
+
return Path(configured).expanduser()
|
|
46
|
+
return default_pictures_dir() / "NAI Artist Combination Lab"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
USER_DIR = default_user_dir()
|
|
32
50
|
DATA_DIR = USER_DIR / "data"
|
|
33
51
|
OUTPUT_DIR = USER_DIR / "outputs"
|
|
34
52
|
STATE_PATH = DATA_DIR / "app_state.json"
|
|
@@ -141,7 +159,8 @@ def output_ref(path_value: str) -> str:
|
|
|
141
159
|
path = Path(raw)
|
|
142
160
|
try:
|
|
143
161
|
if path.is_absolute():
|
|
144
|
-
|
|
162
|
+
resolved = path.resolve()
|
|
163
|
+
return str(resolved.relative_to(OUTPUT_DIR.resolve())).replace("\\", "/")
|
|
145
164
|
except (OSError, ValueError):
|
|
146
165
|
return path.name
|
|
147
166
|
return raw.replace("\\", "/")
|
package/bin/nai-artist-lab.js
CHANGED
|
@@ -15,16 +15,16 @@ function userDataDir() {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (process.platform === "win32") {
|
|
18
|
-
return path.join(
|
|
18
|
+
return path.join(os.homedir(), "Pictures", "NAI Artist Combination Lab");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
if (process.platform === "darwin") {
|
|
22
|
-
return path.join(os.homedir(), "
|
|
22
|
+
return path.join(os.homedir(), "Pictures", "NAI Artist Combination Lab");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
return path.join(
|
|
26
|
-
process.env.
|
|
27
|
-
"
|
|
26
|
+
process.env.XDG_PICTURES_DIR || path.join(os.homedir(), "Pictures"),
|
|
27
|
+
"NAI Artist Combination Lab"
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
package/package.json
CHANGED
package/web/index.html
CHANGED
|
@@ -18,16 +18,18 @@
|
|
|
18
18
|
</div>
|
|
19
19
|
<nav class="nav" aria-label="Main">
|
|
20
20
|
<button class="nav-item active" data-tab="generate">⌘ 생성</button>
|
|
21
|
-
<button class="nav-item" data-tab="batting">▦ 타율 테스트</button>
|
|
22
|
-
<button class="nav-item" data-tab="artists"># 작가 태그</button>
|
|
23
|
-
<button class="nav-item" data-tab="presets">▣ 프리셋</button>
|
|
24
|
-
<button class="nav-item" data-tab="
|
|
25
|
-
<button class="nav-item" data-tab="
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
<button class="nav-item" data-tab="batting">▦ 타율 테스트</button>
|
|
22
|
+
<button class="nav-item" data-tab="artists"># 작가 태그</button>
|
|
23
|
+
<button class="nav-item" data-tab="presets">▣ 프리셋</button>
|
|
24
|
+
<button class="nav-item" data-tab="compare">≋ 가중치 비교</button>
|
|
25
|
+
<button class="nav-item" data-tab="history">◈ 히스토리</button>
|
|
26
|
+
</nav>
|
|
27
|
+
<div class="sidebar-tools">
|
|
28
|
+
<button class="nav-item settings-nav-item" data-tab="settings">⚙ API 설정</button>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="sidebar-footer">
|
|
31
|
+
<span id="saveState">준비됨</span>
|
|
32
|
+
</div>
|
|
31
33
|
</aside>
|
|
32
34
|
|
|
33
35
|
<main class="workspace">
|
package/web/main.js
CHANGED
|
@@ -655,15 +655,30 @@ function renderHistory() {
|
|
|
655
655
|
const history = state.history[historyIndex];
|
|
656
656
|
const detail = $("#historyDetail");
|
|
657
657
|
detail.innerHTML = "";
|
|
658
|
-
if (!history) {
|
|
659
|
-
detail.innerHTML = `<p>아직 생성 히스토리가 없습니다.</p>`;
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
658
|
+
if (!history) {
|
|
659
|
+
detail.innerHTML = `<p>아직 생성 히스토리가 없습니다.</p>`;
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const actions = document.createElement("div");
|
|
663
|
+
actions.className = "history-detail-actions";
|
|
664
|
+
const info = document.createElement("div");
|
|
665
|
+
const title = document.createElement("strong");
|
|
666
|
+
title.textContent = `${history.base_preset || ""} + ${history.character_preset || ""}`;
|
|
667
|
+
const meta = document.createElement("span");
|
|
668
|
+
meta.textContent = `${history.created_at || ""} · ${(history.items || []).length}장`;
|
|
669
|
+
info.append(title, meta);
|
|
670
|
+
const openButton = document.createElement("button");
|
|
671
|
+
openButton.className = "ghost-button";
|
|
672
|
+
openButton.type = "button";
|
|
673
|
+
openButton.textContent = "저장 폴더 열기";
|
|
674
|
+
openButton.onclick = () => openHistoryFolder(history);
|
|
675
|
+
actions.append(info, openButton);
|
|
676
|
+
detail.appendChild(actions);
|
|
677
|
+
const modalItems = (history.items || [])
|
|
678
|
+
.map((item, index) => enrichedImageItem(item, { history, label: `#${index + 1}` }))
|
|
679
|
+
.filter((item) => item.image_url);
|
|
680
|
+
(history.items || []).forEach((item, index) => {
|
|
681
|
+
detail.appendChild(renderImageCard(item, { history, label: `#${index + 1}`, modalItems }));
|
|
667
682
|
});
|
|
668
683
|
}
|
|
669
684
|
|
|
@@ -1171,7 +1186,7 @@ async function deleteSelectedHistory() {
|
|
|
1171
1186
|
setSaveState("히스토리 삭제됨");
|
|
1172
1187
|
}
|
|
1173
1188
|
|
|
1174
|
-
async function clearHistory() {
|
|
1189
|
+
async function clearHistory() {
|
|
1175
1190
|
if (!state.history.length) return;
|
|
1176
1191
|
const deleteFiles = $("#deleteFilesToggle").checked;
|
|
1177
1192
|
const ok = window.confirm(`전체 히스토리 ${state.history.length}개를 삭제할까요?${deleteFiles ? " 출력 파일도 함께 삭제됩니다." : ""}`);
|
|
@@ -1186,9 +1201,22 @@ async function clearHistory() {
|
|
|
1186
1201
|
renderHistory();
|
|
1187
1202
|
renderCompare();
|
|
1188
1203
|
setSaveState("히스토리 전체 삭제됨");
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
function
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function openHistoryFolder(history) {
|
|
1207
|
+
if (!history?.id) return;
|
|
1208
|
+
try {
|
|
1209
|
+
await request("/api/history/open", {
|
|
1210
|
+
method: "POST",
|
|
1211
|
+
body: JSON.stringify({ id: history.id }),
|
|
1212
|
+
});
|
|
1213
|
+
setSaveState("저장 폴더를 열었습니다");
|
|
1214
|
+
} catch {
|
|
1215
|
+
setSaveState("저장 폴더를 찾을 수 없습니다");
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function selectAllHistory() {
|
|
1192
1220
|
const allSelected = state.history.length > 0 && state.history.every((item) => selectedHistoryIds.has(item.id));
|
|
1193
1221
|
selectedHistoryIds = allSelected ? new Set() : new Set(state.history.map((item) => item.id));
|
|
1194
1222
|
renderHistory();
|
package/web/styles.css
CHANGED
|
@@ -111,14 +111,44 @@ button {
|
|
|
111
111
|
border-color: var(--line);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
.sidebar-
|
|
114
|
+
.sidebar-tools {
|
|
115
115
|
margin-top: auto;
|
|
116
|
-
|
|
116
|
+
display: grid;
|
|
117
|
+
gap: 8px;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.settings-nav-item {
|
|
121
|
+
border-color: rgba(94, 224, 184, 0.22);
|
|
122
|
+
background: rgba(94, 224, 184, 0.06);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.sidebar-footer {
|
|
126
|
+
color: #dce8e4;
|
|
117
127
|
font-size: 12px;
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
font-weight: 650;
|
|
129
|
+
padding: 12px 12px 12px 14px;
|
|
130
|
+
border: 1px solid rgba(94, 224, 184, 0.24);
|
|
131
|
+
border-left: 3px solid var(--accent);
|
|
120
132
|
border-radius: 8px;
|
|
121
|
-
background:
|
|
133
|
+
background:
|
|
134
|
+
linear-gradient(135deg, rgba(94, 224, 184, 0.14), rgba(140, 182, 255, 0.08)),
|
|
135
|
+
rgba(20, 24, 28, 0.82);
|
|
136
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#saveState {
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: 8px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#saveState::before {
|
|
146
|
+
content: "";
|
|
147
|
+
width: 7px;
|
|
148
|
+
height: 7px;
|
|
149
|
+
border-radius: 999px;
|
|
150
|
+
background: var(--accent);
|
|
151
|
+
box-shadow: 0 0 14px rgba(94, 224, 184, 0.72);
|
|
122
152
|
}
|
|
123
153
|
|
|
124
154
|
.workspace {
|
|
@@ -690,6 +720,29 @@ pre {
|
|
|
690
720
|
gap: 12px;
|
|
691
721
|
}
|
|
692
722
|
|
|
723
|
+
.history-detail-actions {
|
|
724
|
+
grid-column: 1 / -1;
|
|
725
|
+
display: flex;
|
|
726
|
+
justify-content: space-between;
|
|
727
|
+
align-items: center;
|
|
728
|
+
gap: 14px;
|
|
729
|
+
padding: 14px;
|
|
730
|
+
border: 1px solid var(--line-soft);
|
|
731
|
+
border-radius: 8px;
|
|
732
|
+
background: rgba(255, 255, 255, 0.025);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.history-detail-actions strong,
|
|
736
|
+
.history-detail-actions span {
|
|
737
|
+
display: block;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
.history-detail-actions span {
|
|
741
|
+
margin-top: 4px;
|
|
742
|
+
color: var(--muted);
|
|
743
|
+
font-size: 12px;
|
|
744
|
+
}
|
|
745
|
+
|
|
693
746
|
.live-gallery {
|
|
694
747
|
display: grid;
|
|
695
748
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
package/web_app.py
CHANGED
|
@@ -2,9 +2,11 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import mimetypes
|
|
5
|
+
import os
|
|
5
6
|
import random
|
|
6
7
|
import secrets
|
|
7
8
|
import shutil
|
|
9
|
+
import subprocess
|
|
8
10
|
import sys
|
|
9
11
|
import threading
|
|
10
12
|
import time
|
|
@@ -80,17 +82,35 @@ def state_from_dict(data: dict) -> AppState:
|
|
|
80
82
|
|
|
81
83
|
|
|
82
84
|
def media_url(path: str) -> str:
|
|
85
|
+
resolved = resolve_output_path(path)
|
|
86
|
+
if not resolved:
|
|
87
|
+
return ""
|
|
83
88
|
try:
|
|
84
|
-
|
|
85
|
-
candidate = Path(raw)
|
|
86
|
-
root = OUTPUT_DIR.resolve()
|
|
87
|
-
resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
|
|
88
|
-
rel = resolved.relative_to(root)
|
|
89
|
+
rel = resolved.relative_to(OUTPUT_DIR.resolve())
|
|
89
90
|
except (ValueError, OSError):
|
|
90
91
|
return ""
|
|
91
92
|
return "/media/" + urllib.parse.quote(str(rel).replace("\\", "/"))
|
|
92
93
|
|
|
93
94
|
|
|
95
|
+
def resolve_output_path(path: str, expect_dir: bool = False) -> Path | None:
|
|
96
|
+
raw = str(path or "").strip()
|
|
97
|
+
if not raw:
|
|
98
|
+
return None
|
|
99
|
+
candidate = Path(raw)
|
|
100
|
+
try:
|
|
101
|
+
if candidate.is_absolute():
|
|
102
|
+
resolved = candidate.resolve()
|
|
103
|
+
resolved.relative_to(OUTPUT_DIR.resolve())
|
|
104
|
+
return resolved
|
|
105
|
+
resolved = (OUTPUT_DIR / candidate).resolve()
|
|
106
|
+
resolved.relative_to(OUTPUT_DIR.resolve())
|
|
107
|
+
if resolved.exists() or not expect_dir:
|
|
108
|
+
return resolved
|
|
109
|
+
except (ValueError, OSError):
|
|
110
|
+
return None
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
94
114
|
def safe_child_path(root: Path, rel: str) -> Path | None:
|
|
95
115
|
try:
|
|
96
116
|
root_resolved = root.resolve()
|
|
@@ -101,6 +121,15 @@ def safe_child_path(root: Path, rel: str) -> Path | None:
|
|
|
101
121
|
return None
|
|
102
122
|
|
|
103
123
|
|
|
124
|
+
def open_in_file_manager(path: Path) -> None:
|
|
125
|
+
if os.name == "nt":
|
|
126
|
+
os.startfile(path) # type: ignore[attr-defined]
|
|
127
|
+
elif sys.platform == "darwin":
|
|
128
|
+
subprocess.Popen(["open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
129
|
+
else:
|
|
130
|
+
subprocess.Popen(["xdg-open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
131
|
+
|
|
132
|
+
|
|
104
133
|
def state_payload(state: AppState) -> dict:
|
|
105
134
|
data = asdict(state)
|
|
106
135
|
data.setdefault("api", {})["token"] = ""
|
|
@@ -236,10 +265,8 @@ def delete_history_entries(ids: list[str], delete_files: bool) -> AppState:
|
|
|
236
265
|
raw_dir = history.get("output_dir", "")
|
|
237
266
|
if not raw_dir:
|
|
238
267
|
continue
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
target = raw_path.resolve() if raw_path.is_absolute() else (OUTPUT_DIR / raw_path).resolve()
|
|
242
|
-
except OSError:
|
|
268
|
+
target = resolve_output_path(raw_dir, expect_dir=True)
|
|
269
|
+
if not target:
|
|
243
270
|
continue
|
|
244
271
|
if target == output_root or output_root not in target.parents:
|
|
245
272
|
continue
|
|
@@ -248,6 +275,11 @@ def delete_history_entries(ids: list[str], delete_files: bool) -> AppState:
|
|
|
248
275
|
return load_state()
|
|
249
276
|
|
|
250
277
|
|
|
278
|
+
def history_by_id(history_id: str) -> dict | None:
|
|
279
|
+
state = load_state()
|
|
280
|
+
return next((item for item in state.history if item.get("id") == history_id), None)
|
|
281
|
+
|
|
282
|
+
|
|
251
283
|
def clear_history(delete_files: bool) -> AppState:
|
|
252
284
|
with STATE_LOCK:
|
|
253
285
|
state = load_state()
|
|
@@ -561,6 +593,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
561
593
|
elif route == "/api/history/clear":
|
|
562
594
|
state = clear_history(bool(data.get("delete_files", False)))
|
|
563
595
|
self.send_json({"state": state_payload(state)})
|
|
596
|
+
elif route == "/api/history/open":
|
|
597
|
+
history = history_by_id(str(data.get("id", "")))
|
|
598
|
+
target = resolve_output_path((history or {}).get("output_dir", ""), expect_dir=True)
|
|
599
|
+
if not history or not target or not target.exists() or not target.is_dir():
|
|
600
|
+
self.send_json({"error": "저장 폴더를 찾을 수 없습니다."}, status=404)
|
|
601
|
+
return
|
|
602
|
+
open_in_file_manager(target)
|
|
603
|
+
self.send_json({"ok": True})
|
|
564
604
|
else:
|
|
565
605
|
self.send_error(404)
|
|
566
606
|
|