ltcai 4.6.1 → 4.7.2
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 +74 -40
- package/docs/CHANGELOG.md +141 -0
- package/docs/PRODUCT_DIRECTION_REVIEW.md +88 -0
- package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +42 -0
- package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +49 -0
- package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +62 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +22 -19
- package/frontend/src/App.tsx +627 -8
- package/frontend/src/api/client.ts +11 -1
- package/frontend/src/components/ProductFlow.tsx +106 -51
- package/frontend/src/pages/System.tsx +1 -1
- package/frontend/src/styles.css +905 -81
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +86 -13
- package/lattice_brain/portability.py +82 -14
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +141 -6
- package/latticeai/api/chat.py +35 -13
- package/latticeai/app_factory.py +8 -4
- package/latticeai/core/audit.py +3 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/package.json +2 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/index-DdAB4yfa.js +16 -0
- package/static/app/assets/index-DdAB4yfa.js.map +1 -0
- package/static/app/assets/{index-7U86v70r.css → index-KlQ04wVv.css} +1 -1
- package/static/app/index.html +2 -2
- package/static/app/assets/index-D1jAPQws.js +0 -16
- package/static/app/assets/index-D1jAPQws.js.map +0 -1
|
@@ -12,6 +12,13 @@ export type ApiResult<T = unknown> = {
|
|
|
12
12
|
|
|
13
13
|
type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
|
|
14
14
|
type Query = Record<string, string | number | boolean | null | undefined>;
|
|
15
|
+
export type AdminAuditFilters = {
|
|
16
|
+
q?: string;
|
|
17
|
+
actor?: string;
|
|
18
|
+
action?: string;
|
|
19
|
+
severity?: string;
|
|
20
|
+
limit?: number;
|
|
21
|
+
};
|
|
15
22
|
|
|
16
23
|
const TIMEOUT_MS = 10_000;
|
|
17
24
|
const clients = new Map<string, ReturnType<typeof createClient<paths>>>();
|
|
@@ -417,12 +424,15 @@ export const latticeApi = {
|
|
|
417
424
|
computerMemory: () => get("/workspace/computer-memory", {}),
|
|
418
425
|
setComputerMemory: (enabled: boolean) => post("/workspace/computer-memory", { enabled, consent: { approved: enabled } }, {}),
|
|
419
426
|
adminSummary: () => get("/admin/summary", {}),
|
|
427
|
+
adminStats: () => get("/admin/stats", {}),
|
|
420
428
|
adminUsers: () => get("/admin/users", []),
|
|
421
|
-
adminAudit: () => get("/admin/audit", { recent_events: [] }),
|
|
429
|
+
adminAudit: (filters?: AdminAuditFilters) => get("/admin/audit", { recent_events: [], filters: {} }, filters),
|
|
422
430
|
adminRoles: () => get("/admin/roles", { roles: [] }),
|
|
423
431
|
adminPolicies: () => get("/admin/policies", { policies: [] }),
|
|
432
|
+
adminLogRetention: () => get("/admin/log-retention", {}),
|
|
424
433
|
adminProductHardening: () => get("/admin/product-hardening", {}),
|
|
425
434
|
adminSecurity: () => get("/admin/security/overview", {}),
|
|
435
|
+
adminSecurityEvents: (limit = 50) => get("/admin/security/events", { events: [] }, { limit }),
|
|
426
436
|
vpcStatus: () => get("/vpc/status", {}),
|
|
427
437
|
toolPermissions: () => get("/tools/permissions", { permissions: [] }),
|
|
428
438
|
};
|
|
@@ -43,6 +43,14 @@ export function readProductFlowComplete() {
|
|
|
43
43
|
return false;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function readSavedFlowUser(): { email?: string; name?: string } | null {
|
|
47
|
+
try {
|
|
48
|
+
const raw = localStorage.getItem(FLOW_USER_KEY);
|
|
49
|
+
return raw ? JSON.parse(raw) : null;
|
|
50
|
+
} catch {}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
export function ProductFlow({ onComplete }: { onComplete: () => void }) {
|
|
47
55
|
const [step, setStep] = React.useState<FlowStep>("login");
|
|
48
56
|
const [analysis, setAnalysis] = React.useState<FlowAnalysis | null>(null);
|
|
@@ -135,80 +143,119 @@ export function ProductFlow({ onComplete }: { onComplete: () => void }) {
|
|
|
135
143
|
|
|
136
144
|
function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
|
|
137
145
|
const [email, setEmail] = React.useState(() => {
|
|
138
|
-
|
|
139
|
-
const saved = localStorage.getItem(FLOW_USER_KEY);
|
|
140
|
-
return saved ? JSON.parse(saved).email || "you@local" : "you@local";
|
|
141
|
-
} catch {
|
|
142
|
-
return "you@local";
|
|
143
|
-
}
|
|
146
|
+
return readSavedFlowUser()?.email || "you@local";
|
|
144
147
|
});
|
|
145
148
|
const [password, setPassword] = React.useState("");
|
|
146
|
-
const [name, setName] = React.useState("You");
|
|
149
|
+
const [name, setName] = React.useState(() => readSavedFlowUser()?.name || "You");
|
|
147
150
|
const [busy, setBusy] = React.useState(false);
|
|
148
151
|
const [error, setError] = React.useState<string | null>(null);
|
|
149
152
|
|
|
150
153
|
async function submit(event: React.FormEvent) {
|
|
151
154
|
event.preventDefault();
|
|
155
|
+
const cleanEmail = email.trim();
|
|
156
|
+
const cleanPassword = password.trim();
|
|
157
|
+
const cleanName = name.trim() || cleanEmail.split("@")[0] || "You";
|
|
158
|
+
if (!cleanEmail || !cleanPassword) {
|
|
159
|
+
setError("이름과 이메일, 비밀번호를 입력하면 기존 Brain을 안전하게 확인합니다.");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
152
162
|
setBusy(true);
|
|
153
163
|
setError(null);
|
|
154
|
-
const
|
|
155
|
-
let result = await latticeApi.login(
|
|
156
|
-
if (!result.ok) {
|
|
157
|
-
const registered = await latticeApi.register({
|
|
158
|
-
email,
|
|
159
|
-
password: safePassword,
|
|
160
|
-
name: name || email.split("@")[0] || "You",
|
|
161
|
-
nickname: name || "You",
|
|
162
|
-
});
|
|
163
|
-
if (registered.ok) result = await latticeApi.login(email, safePassword);
|
|
164
|
-
}
|
|
164
|
+
const savedUser = readSavedFlowUser();
|
|
165
|
+
let result = await latticeApi.login(cleanEmail, cleanPassword);
|
|
165
166
|
if (!result.ok) {
|
|
166
167
|
const profile = await latticeApi.profile();
|
|
167
|
-
if (
|
|
168
|
+
if (profile.ok && (!savedUser?.email || savedUser.email === cleanEmail)) {
|
|
169
|
+
try { localStorage.setItem(FLOW_USER_KEY, JSON.stringify({ email: cleanEmail, name: cleanName })); } catch {}
|
|
170
|
+
setBusy(false);
|
|
171
|
+
onSuccess();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (savedUser?.email && savedUser.email !== cleanEmail) {
|
|
168
175
|
setBusy(false);
|
|
169
|
-
setError("
|
|
176
|
+
setError("이 컴퓨터의 기존 Brain과 다른 이메일입니다. 오타인지 확인해 주세요.");
|
|
170
177
|
return;
|
|
171
178
|
}
|
|
179
|
+
if (savedUser?.email === cleanEmail) {
|
|
180
|
+
setBusy(false);
|
|
181
|
+
setError("기존 Brain 이메일은 맞지만 비밀번호가 다릅니다. 비밀번호를 다시 확인해 주세요.");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const registered = await latticeApi.register({
|
|
185
|
+
email: cleanEmail,
|
|
186
|
+
password: cleanPassword,
|
|
187
|
+
name: cleanName,
|
|
188
|
+
nickname: cleanName,
|
|
189
|
+
});
|
|
190
|
+
if (registered.ok) result = await latticeApi.login(cleanEmail, cleanPassword);
|
|
191
|
+
}
|
|
192
|
+
if (!result.ok) {
|
|
193
|
+
setBusy(false);
|
|
194
|
+
setError("로컬 프로필을 열 수 없습니다. 이메일과 비밀번호를 확인해 주세요.");
|
|
195
|
+
return;
|
|
172
196
|
}
|
|
173
|
-
try { localStorage.setItem(FLOW_USER_KEY, JSON.stringify({ email, name })); } catch {}
|
|
197
|
+
try { localStorage.setItem(FLOW_USER_KEY, JSON.stringify({ email: cleanEmail, name: cleanName })); } catch {}
|
|
174
198
|
setBusy(false);
|
|
175
199
|
onSuccess();
|
|
176
200
|
}
|
|
177
201
|
|
|
178
202
|
return (
|
|
179
203
|
<div>
|
|
180
|
-
<div className="ritual-title"
|
|
181
|
-
<div className="ritual-subtitle">
|
|
204
|
+
<div className="ritual-title">내 Brain을 시작합니다.</div>
|
|
205
|
+
<div className="ritual-subtitle">
|
|
206
|
+
모델은 바뀔 수 있지만, 내 문서와 대화, 결정, 기억은 사라지면 안 됩니다. Lattice는 이 지식을 내가 소유하는 개인 Brain으로 모읍니다.
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<ProductPromise />
|
|
182
210
|
|
|
183
211
|
<form onSubmit={submit} className="ritual-card" style={{ maxWidth: 420, margin: "0 auto" }}>
|
|
184
212
|
<div style={{ display: "grid", gap: "0.85rem" }}>
|
|
185
213
|
<div>
|
|
186
|
-
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}
|
|
214
|
+
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>이름</div>
|
|
187
215
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="You" />
|
|
188
216
|
</div>
|
|
189
217
|
<div>
|
|
190
|
-
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}
|
|
218
|
+
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>이메일</div>
|
|
191
219
|
<Input value={email} onChange={(e) => setEmail(e.target.value)} type="email" placeholder="you@local" />
|
|
192
220
|
</div>
|
|
193
221
|
<div>
|
|
194
|
-
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}
|
|
195
|
-
<Input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder="
|
|
222
|
+
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>비밀번호</div>
|
|
223
|
+
<Input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder="로컬 Brain 비밀번호" />
|
|
196
224
|
</div>
|
|
197
225
|
</div>
|
|
198
226
|
|
|
199
227
|
{error && <div style={{ marginTop: "0.85rem", padding: "0.6rem 0.85rem", background: "hsl(var(--destructive)/0.12)", border: "1px solid hsl(var(--destructive)/0.4)", borderRadius: 10, fontSize: "0.9rem" }}>{error}</div>}
|
|
200
228
|
|
|
201
|
-
<Button type="submit" disabled={busy || !email.trim()} style={{ width: "100%", marginTop: "1rem" }}>
|
|
202
|
-
{busy ? "
|
|
229
|
+
<Button type="submit" disabled={busy || !email.trim() || !password.trim()} style={{ width: "100%", marginTop: "1rem" }}>
|
|
230
|
+
{busy ? "Brain 여는 중..." : "내 Brain 시작하기"}
|
|
203
231
|
</Button>
|
|
204
232
|
<div style={{ fontSize: "0.75rem", color: "hsl(var(--fg-muted))", marginTop: "0.6rem" }}>
|
|
205
|
-
|
|
233
|
+
기존 Brain과 다른 이메일이면 새로 만들지 않고 먼저 확인합니다.
|
|
206
234
|
</div>
|
|
207
235
|
</form>
|
|
208
236
|
</div>
|
|
209
237
|
);
|
|
210
238
|
}
|
|
211
239
|
|
|
240
|
+
function ProductPromise() {
|
|
241
|
+
return (
|
|
242
|
+
<div className="ritual-promise" aria-label="Lattice AI product promise">
|
|
243
|
+
<div>
|
|
244
|
+
<span>오래 남는 지식</span>
|
|
245
|
+
<strong>내 일이 장기 기억이 됩니다.</strong>
|
|
246
|
+
</div>
|
|
247
|
+
<div>
|
|
248
|
+
<span>교체 가능한 모델</span>
|
|
249
|
+
<strong>모델은 목소리이고, 자산은 Brain입니다.</strong>
|
|
250
|
+
</div>
|
|
251
|
+
<div>
|
|
252
|
+
<span>사용자 소유</span>
|
|
253
|
+
<strong>백업, 복원, 이동이 가능합니다.</strong>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
212
259
|
function AnalysisScreen({
|
|
213
260
|
analysis,
|
|
214
261
|
error,
|
|
@@ -221,9 +268,9 @@ function AnalysisScreen({
|
|
|
221
268
|
const detected = buildDetectedFacts(analysis);
|
|
222
269
|
return (
|
|
223
270
|
<div>
|
|
224
|
-
<div className="ritual-title"
|
|
271
|
+
<div className="ritual-title">이 컴퓨터를 확인합니다.</div>
|
|
225
272
|
<div className="ritual-subtitle">
|
|
226
|
-
|
|
273
|
+
Brain이 클라우드에 의존하지 않고 이 컴퓨터에서 편하게 돌아갈 수 있는지 확인합니다.
|
|
227
274
|
</div>
|
|
228
275
|
|
|
229
276
|
<div className="ritual-fact-grid">
|
|
@@ -240,9 +287,9 @@ function AnalysisScreen({
|
|
|
240
287
|
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
|
|
241
288
|
<Sparkles style={{ color: "hsl(var(--brain-core))" }} />
|
|
242
289
|
<div>
|
|
243
|
-
<div style={{ fontWeight: 620 }}>{analysis ? recommendedSummary(analysis) : "
|
|
290
|
+
<div style={{ fontWeight: 620 }}>{analysis ? recommendedSummary(analysis) : "가장 편한 설정을 찾는 중..."}</div>
|
|
244
291
|
<div style={{ fontSize: "0.9rem", color: "hsl(var(--fg-muted))" }}>
|
|
245
|
-
{analysis ? "
|
|
292
|
+
{analysis ? "추천 모델을 바로 시작할 수 있게 준비했습니다." : "잠시만 기다리면 자동으로 정리됩니다."}
|
|
246
293
|
</div>
|
|
247
294
|
</div>
|
|
248
295
|
</div>
|
|
@@ -252,7 +299,7 @@ function AnalysisScreen({
|
|
|
252
299
|
|
|
253
300
|
<div style={{ marginTop: "1.25rem" }}>
|
|
254
301
|
<Button onClick={onContinue} disabled={!analysis && !error} style={{ minWidth: 260 }}>
|
|
255
|
-
|
|
302
|
+
추천 모델 보기
|
|
256
303
|
</Button>
|
|
257
304
|
</div>
|
|
258
305
|
</div>
|
|
@@ -271,12 +318,17 @@ function RecommendationScreen({
|
|
|
271
318
|
const items = recommendations.length ? recommendations : [fallbackModel()];
|
|
272
319
|
return (
|
|
273
320
|
<div>
|
|
274
|
-
<div className="ritual-title"
|
|
321
|
+
<div className="ritual-title">추천 모델로 시작하세요.</div>
|
|
275
322
|
<div className="ritual-subtitle">
|
|
276
|
-
|
|
323
|
+
모델은 Brain의 현재 목소리입니다. 나중에 바꿔도 기억과 지식은 그대로 남습니다.
|
|
277
324
|
</div>
|
|
278
325
|
|
|
279
326
|
<div style={{ maxWidth: 560, margin: "0 auto" }}>
|
|
327
|
+
{items[0]?.supported ? (
|
|
328
|
+
<Button onClick={() => onSelect(items[0])} style={{ width: "100%", marginBottom: "0.85rem" }}>
|
|
329
|
+
추천대로 시작하기
|
|
330
|
+
</Button>
|
|
331
|
+
) : null}
|
|
280
332
|
{items.slice(0, 3).map((model, index) => (
|
|
281
333
|
<button
|
|
282
334
|
key={`${model.role}-${model.id}`}
|
|
@@ -288,14 +340,14 @@ function RecommendationScreen({
|
|
|
288
340
|
<div className="role">{rankLabel(model.role, index)}</div>
|
|
289
341
|
<div className="name">{model.shortName}</div>
|
|
290
342
|
<div className="reason">{model.reason} · {model.size || "ready"}</div>
|
|
291
|
-
{!model.supported && <div style={{ color: "hsl(var(--destructive))", marginTop: 6, fontSize: "0.85rem" }}
|
|
343
|
+
{!model.supported && <div style={{ color: "hsl(var(--destructive))", marginTop: 6, fontSize: "0.85rem" }}>이 컴퓨터에서 추가 확인이 필요합니다</div>}
|
|
292
344
|
</button>
|
|
293
345
|
))}
|
|
294
346
|
</div>
|
|
295
347
|
|
|
296
348
|
<div style={{ marginTop: "1.1rem", display: "flex", justifyContent: "center", gap: "1rem", alignItems: "center" }}>
|
|
297
|
-
<Button variant="ghost" onClick={onBack}
|
|
298
|
-
<div style={{ fontSize: "0.82rem", color: "hsl(var(--fg-muted))" }}
|
|
349
|
+
<Button variant="ghost" onClick={onBack}>뒤로</Button>
|
|
350
|
+
<div style={{ fontSize: "0.82rem", color: "hsl(var(--fg-muted))" }}>잘 모르겠다면 추천대로 시작하면 됩니다.</div>
|
|
299
351
|
</div>
|
|
300
352
|
</div>
|
|
301
353
|
);
|
|
@@ -313,7 +365,7 @@ function InstallScreen({
|
|
|
313
365
|
const [busy, setBusy] = React.useState(false);
|
|
314
366
|
const [stage, setStage] = React.useState<InstallStage>("idle");
|
|
315
367
|
const [percent, setPercent] = React.useState(0);
|
|
316
|
-
const [message, setMessage] = React.useState("
|
|
368
|
+
const [message, setMessage] = React.useState("Brain이 사용할 모델을 기다리고 있습니다.");
|
|
317
369
|
const [error, setError] = React.useState<string | null>(null);
|
|
318
370
|
|
|
319
371
|
async function start() {
|
|
@@ -321,7 +373,7 @@ function InstallScreen({
|
|
|
321
373
|
setError(null);
|
|
322
374
|
setStage("install");
|
|
323
375
|
setPercent(8);
|
|
324
|
-
setMessage("
|
|
376
|
+
setMessage("Brain 준비 중입니다.");
|
|
325
377
|
const result = await latticeApi.streamModelPrepare(
|
|
326
378
|
{ model: model.loadId, engine: model.engine || "local_mlx", allow_download: true },
|
|
327
379
|
{
|
|
@@ -334,7 +386,7 @@ function InstallScreen({
|
|
|
334
386
|
onDone: () => {
|
|
335
387
|
setStage("done");
|
|
336
388
|
setPercent(100);
|
|
337
|
-
setMessage("
|
|
389
|
+
setMessage("Brain이 준비되었습니다.");
|
|
338
390
|
},
|
|
339
391
|
onError: (event) => {
|
|
340
392
|
setStage("error");
|
|
@@ -346,7 +398,7 @@ function InstallScreen({
|
|
|
346
398
|
if (result.ok) {
|
|
347
399
|
setStage("done");
|
|
348
400
|
setPercent(100);
|
|
349
|
-
setMessage("
|
|
401
|
+
setMessage("Brain이 준비되었습니다.");
|
|
350
402
|
window.setTimeout(onComplete, 700);
|
|
351
403
|
} else {
|
|
352
404
|
setStage("error");
|
|
@@ -362,10 +414,10 @@ function InstallScreen({
|
|
|
362
414
|
|
|
363
415
|
return (
|
|
364
416
|
<div>
|
|
365
|
-
<div className="ritual-title"
|
|
417
|
+
<div className="ritual-title">모델을 설치하고 시작합니다.</div>
|
|
366
418
|
<div className="ritual-subtitle">
|
|
367
419
|
<strong>{model.shortName}</strong> — {model.reason}.<br />
|
|
368
|
-
|
|
420
|
+
이 모델이 Brain의 로컬 목소리가 됩니다. 다운로드, 확인, 로드는 사용자가 누른 뒤에만 진행됩니다.
|
|
369
421
|
</div>
|
|
370
422
|
|
|
371
423
|
{/* Living Brain reacts to the ceremony of installation */}
|
|
@@ -393,31 +445,34 @@ function InstallScreen({
|
|
|
393
445
|
</div>
|
|
394
446
|
|
|
395
447
|
<div className="ritual-status">{message}</div>
|
|
448
|
+
<div className="ritual-card" style={{ margin: "0.8rem auto 0", maxWidth: 540, fontSize: "0.86rem", color: "hsl(var(--fg-muted))" }}>
|
|
449
|
+
큰 모델은 다운로드에 몇 분 이상 걸릴 수 있습니다. 진행률은 모델 런타임이 알려주는 만큼 표시하고, 멈춘 것처럼 보여도 현재 단계가 유지됩니다.
|
|
450
|
+
</div>
|
|
396
451
|
|
|
397
452
|
{error && (
|
|
398
453
|
<div className="ritual-card" style={{ borderColor: "hsl(var(--destructive)/0.45)", background: "hsl(var(--destructive)/0.07)", marginBottom: "1rem" }}>
|
|
399
454
|
{error}
|
|
400
|
-
<div style={{ marginTop: "0.5rem", fontSize: "0.85rem" }}
|
|
455
|
+
<div style={{ marginTop: "0.5rem", fontSize: "0.85rem" }}>다른 모델을 고르거나 다시 시도할 수 있습니다.</div>
|
|
401
456
|
</div>
|
|
402
457
|
)}
|
|
403
458
|
|
|
404
459
|
<div style={{ display: "flex", gap: "0.75rem", justifyContent: "center", marginTop: "1rem" }}>
|
|
405
|
-
<Button variant="ghost" onClick={onBack} disabled={busy}
|
|
460
|
+
<Button variant="ghost" onClick={onBack} disabled={busy}>다른 모델 고르기</Button>
|
|
406
461
|
|
|
407
462
|
{stage !== "done" ? (
|
|
408
463
|
<Button
|
|
409
464
|
onClick={start}
|
|
410
465
|
disabled={busy || !model.supported}
|
|
411
466
|
>
|
|
412
|
-
{busy ? "
|
|
467
|
+
{busy ? "모델 준비 중..." : "다운로드하고 시작하기"}
|
|
413
468
|
</Button>
|
|
414
469
|
) : (
|
|
415
|
-
<Button onClick={onComplete}>
|
|
470
|
+
<Button onClick={onComplete}>Brain으로 들어가기</Button>
|
|
416
471
|
)}
|
|
417
472
|
</div>
|
|
418
473
|
|
|
419
474
|
<div style={{ fontSize: "0.72rem", color: "hsl(var(--fg-muted))", marginTop: "0.9rem" }}>
|
|
420
|
-
|
|
475
|
+
모든 작업은 이 컴퓨터에서 진행됩니다.
|
|
421
476
|
</div>
|
|
422
477
|
</div>
|
|
423
478
|
);
|
|
@@ -590,7 +590,7 @@ function AdminPanel() {
|
|
|
590
590
|
const mode = useAppStore((state) => state.mode);
|
|
591
591
|
const summary = useQuery({ queryKey: ["adminSummary"], queryFn: latticeApi.adminSummary });
|
|
592
592
|
const users = useQuery({ queryKey: ["adminUsers"], queryFn: latticeApi.adminUsers });
|
|
593
|
-
const audit = useQuery({ queryKey: ["adminAudit"], queryFn: latticeApi.adminAudit });
|
|
593
|
+
const audit = useQuery({ queryKey: ["adminAudit"], queryFn: () => latticeApi.adminAudit() });
|
|
594
594
|
const roles = useQuery({ queryKey: ["adminRoles"], queryFn: latticeApi.adminRoles });
|
|
595
595
|
const policies = useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies });
|
|
596
596
|
const hardening = useQuery({ queryKey: ["adminProductHardening"], queryFn: latticeApi.adminProductHardening });
|