ltcai 4.7.2 → 5.0.0

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.
@@ -6,6 +6,8 @@ import { Badge } from "@/components/ui/badge";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Input } from "@/components/ui/input";
8
8
  import { cn, asArray } from "@/lib/utils";
9
+ import { LANGUAGE_LABELS, t, type Language } from "@/i18n";
10
+ import { useAppStore } from "@/store/appStore";
9
11
 
10
12
  const FLOW_COMPLETE_KEY = "lattice.productFlow.complete";
11
13
  const FLOW_USER_KEY = "lattice.productFlow.user";
@@ -52,6 +54,7 @@ function readSavedFlowUser(): { email?: string; name?: string } | null {
52
54
  }
53
55
 
54
56
  export function ProductFlow({ onComplete }: { onComplete: () => void }) {
57
+ const language = useAppStore((state) => state.language);
55
58
  const [step, setStep] = React.useState<FlowStep>("login");
56
59
  const [analysis, setAnalysis] = React.useState<FlowAnalysis | null>(null);
57
60
  const [analysisError, setAnalysisError] = React.useState<string | null>(null);
@@ -78,16 +81,17 @@ export function ProductFlow({ onComplete }: { onComplete: () => void }) {
78
81
  sysinfo: sysinfo.ok ? sysinfo.data as ApiData : null,
79
82
  });
80
83
  if (!setup.ok && !recommendationsResult.ok && !models.ok) {
81
- setAnalysisError("Lattice could not finish reading this computer. You can still continue with a safe default.");
84
+ setAnalysisError(t(language, "flow.analysis.error"));
82
85
  }
83
86
  }
84
87
  void runAnalysis();
85
88
  return () => { cancelled = true; };
86
- }, [analysis, step]);
89
+ }, [analysis, language, step]);
87
90
 
88
91
  return (
89
- <div className="ritual-shell" aria-label="Awaken your Brain">
92
+ <div className="ritual-shell" aria-label={t(language, "flow.shell")}>
90
93
  <div className="ritual-container">
94
+ <LanguageChooser />
91
95
  {/* The living presence participates in the ritual at every step */}
92
96
  <div className="ritual-brain">
93
97
  <LivingBrain
@@ -141,7 +145,29 @@ export function ProductFlow({ onComplete }: { onComplete: () => void }) {
141
145
  );
142
146
  }
143
147
 
148
+ function LanguageChooser() {
149
+ const language = useAppStore((state) => state.language);
150
+ const setLanguage = useAppStore((state) => state.setLanguage);
151
+
152
+ return (
153
+ <div className="language-switcher ritual-language" aria-label={t(language, "language.label")}>
154
+ {(["ko", "en"] as Language[]).map((item) => (
155
+ <button
156
+ key={item}
157
+ type="button"
158
+ className={language === item ? "is-active" : ""}
159
+ onClick={() => setLanguage(item)}
160
+ aria-pressed={language === item}
161
+ >
162
+ {LANGUAGE_LABELS[item]}
163
+ </button>
164
+ ))}
165
+ </div>
166
+ );
167
+ }
168
+
144
169
  function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
170
+ const language = useAppStore((state) => state.language);
145
171
  const [email, setEmail] = React.useState(() => {
146
172
  return readSavedFlowUser()?.email || "you@local";
147
173
  });
@@ -156,7 +182,7 @@ function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
156
182
  const cleanPassword = password.trim();
157
183
  const cleanName = name.trim() || cleanEmail.split("@")[0] || "You";
158
184
  if (!cleanEmail || !cleanPassword) {
159
- setError("이름과 이메일, 비밀번호를 입력하면 기존 Brain을 안전하게 확인합니다.");
185
+ setError(t(language, "flow.login.missing"));
160
186
  return;
161
187
  }
162
188
  setBusy(true);
@@ -173,12 +199,12 @@ function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
173
199
  }
174
200
  if (savedUser?.email && savedUser.email !== cleanEmail) {
175
201
  setBusy(false);
176
- setError("이 컴퓨터의 기존 Brain과 다른 이메일입니다. 오타인지 확인해 주세요.");
202
+ setError(t(language, "flow.login.otherEmail"));
177
203
  return;
178
204
  }
179
205
  if (savedUser?.email === cleanEmail) {
180
206
  setBusy(false);
181
- setError("기존 Brain 이메일은 맞지만 비밀번호가 다릅니다. 비밀번호를 다시 확인해 주세요.");
207
+ setError(t(language, "flow.login.wrongPassword"));
182
208
  return;
183
209
  }
184
210
  const registered = await latticeApi.register({
@@ -191,7 +217,7 @@ function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
191
217
  }
192
218
  if (!result.ok) {
193
219
  setBusy(false);
194
- setError("로컬 프로필을 열 수 없습니다. 이메일과 비밀번호를 확인해 주세요.");
220
+ setError(t(language, "flow.login.unavailable"));
195
221
  return;
196
222
  }
197
223
  try { localStorage.setItem(FLOW_USER_KEY, JSON.stringify({ email: cleanEmail, name: cleanName })); } catch {}
@@ -201,36 +227,34 @@ function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
201
227
 
202
228
  return (
203
229
  <div>
204
- <div className="ritual-title">내 Brain을 시작합니다.</div>
205
- <div className="ritual-subtitle">
206
- 모델은 바뀔 수 있지만, 내 문서와 대화, 결정, 기억은 사라지면 안 됩니다. Lattice는 이 지식을 내가 소유하는 개인 Brain으로 모읍니다.
207
- </div>
230
+ <div className="ritual-title">{t(language, "flow.login.title")}</div>
231
+ <div className="ritual-subtitle">{t(language, "flow.login.body")}</div>
208
232
 
209
233
  <ProductPromise />
210
234
 
211
235
  <form onSubmit={submit} className="ritual-card" style={{ maxWidth: 420, margin: "0 auto" }}>
212
236
  <div style={{ display: "grid", gap: "0.85rem" }}>
213
237
  <div>
214
- <div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>이름</div>
238
+ <div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>{t(language, "flow.name")}</div>
215
239
  <Input value={name} onChange={(e) => setName(e.target.value)} placeholder="You" />
216
240
  </div>
217
241
  <div>
218
- <div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>이메일</div>
242
+ <div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>{t(language, "flow.email")}</div>
219
243
  <Input value={email} onChange={(e) => setEmail(e.target.value)} type="email" placeholder="you@local" />
220
244
  </div>
221
245
  <div>
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 비밀번호" />
246
+ <div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>{t(language, "flow.password")}</div>
247
+ <Input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder={t(language, "flow.password.placeholder")} />
224
248
  </div>
225
249
  </div>
226
250
 
227
251
  {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>}
228
252
 
229
253
  <Button type="submit" disabled={busy || !email.trim() || !password.trim()} style={{ width: "100%", marginTop: "1rem" }}>
230
- {busy ? "Brain 여는 중..." : "내 Brain 시작하기"}
254
+ {busy ? t(language, "flow.login.busy") : t(language, "flow.login.submit")}
231
255
  </Button>
232
256
  <div style={{ fontSize: "0.75rem", color: "hsl(var(--fg-muted))", marginTop: "0.6rem" }}>
233
- 기존 Brain과 다른 이메일이면 새로 만들지 않고 먼저 확인합니다.
257
+ {t(language, "flow.login.note")}
234
258
  </div>
235
259
  </form>
236
260
  </div>
@@ -238,19 +262,20 @@ function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
238
262
  }
239
263
 
240
264
  function ProductPromise() {
265
+ const language = useAppStore((state) => state.language);
241
266
  return (
242
267
  <div className="ritual-promise" aria-label="Lattice AI product promise">
243
268
  <div>
244
- <span>오래 남는 지식</span>
245
- <strong>내 일이 장기 기억이 됩니다.</strong>
269
+ <span>{t(language, "flow.promise.memory.k")}</span>
270
+ <strong>{t(language, "flow.promise.memory.v")}</strong>
246
271
  </div>
247
272
  <div>
248
- <span>교체 가능한 모델</span>
249
- <strong>모델은 목소리이고, 자산은 Brain입니다.</strong>
273
+ <span>{t(language, "flow.promise.model.k")}</span>
274
+ <strong>{t(language, "flow.promise.model.v")}</strong>
250
275
  </div>
251
276
  <div>
252
- <span>사용자 소유</span>
253
- <strong>백업, 복원, 이동이 가능합니다.</strong>
277
+ <span>{t(language, "flow.promise.ownership.k")}</span>
278
+ <strong>{t(language, "flow.promise.ownership.v")}</strong>
254
279
  </div>
255
280
  </div>
256
281
  );
@@ -265,13 +290,12 @@ function AnalysisScreen({
265
290
  error: string | null;
266
291
  onContinue: () => void;
267
292
  }) {
293
+ const language = useAppStore((state) => state.language);
268
294
  const detected = buildDetectedFacts(analysis);
269
295
  return (
270
296
  <div>
271
- <div className="ritual-title">이 컴퓨터를 확인합니다.</div>
272
- <div className="ritual-subtitle">
273
- Brain이 클라우드에 의존하지 않고 이 컴퓨터에서 편하게 돌아갈 수 있는지 확인합니다.
274
- </div>
297
+ <div className="ritual-title">{t(language, "flow.analysis.title")}</div>
298
+ <div className="ritual-subtitle">{t(language, "flow.analysis.body")}</div>
275
299
 
276
300
  <div className="ritual-fact-grid">
277
301
  {detected.map((item, idx) => (
@@ -287,9 +311,9 @@ function AnalysisScreen({
287
311
  <div style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
288
312
  <Sparkles style={{ color: "hsl(var(--brain-core))" }} />
289
313
  <div>
290
- <div style={{ fontWeight: 620 }}>{analysis ? recommendedSummary(analysis) : "가장 편한 설정을 찾는 중..."}</div>
314
+ <div style={{ fontWeight: 620 }}>{analysis ? recommendedSummary(analysis, language) : t(language, "flow.analysis.finding")}</div>
291
315
  <div style={{ fontSize: "0.9rem", color: "hsl(var(--fg-muted))" }}>
292
- {analysis ? "추천 모델을 바로 시작할 수 있게 준비했습니다." : "잠시만 기다리면 자동으로 정리됩니다."}
316
+ {analysis ? t(language, "flow.analysis.ready") : t(language, "flow.analysis.wait")}
293
317
  </div>
294
318
  </div>
295
319
  </div>
@@ -299,7 +323,7 @@ function AnalysisScreen({
299
323
 
300
324
  <div style={{ marginTop: "1.25rem" }}>
301
325
  <Button onClick={onContinue} disabled={!analysis && !error} style={{ minWidth: 260 }}>
302
- 추천 모델 보기
326
+ {t(language, "flow.analysis.continue")}
303
327
  </Button>
304
328
  </div>
305
329
  </div>
@@ -315,18 +339,17 @@ function RecommendationScreen({
315
339
  onBack: () => void;
316
340
  onSelect: (model: RecommendedModel) => void;
317
341
  }) {
342
+ const language = useAppStore((state) => state.language);
318
343
  const items = recommendations.length ? recommendations : [fallbackModel()];
319
344
  return (
320
345
  <div>
321
- <div className="ritual-title">추천 모델로 시작하세요.</div>
322
- <div className="ritual-subtitle">
323
- 모델은 Brain의 현재 목소리입니다. 나중에 바꿔도 기억과 지식은 그대로 남습니다.
324
- </div>
346
+ <div className="ritual-title">{t(language, "flow.recommend.title")}</div>
347
+ <div className="ritual-subtitle">{t(language, "flow.recommend.body")}</div>
325
348
 
326
349
  <div style={{ maxWidth: 560, margin: "0 auto" }}>
327
350
  {items[0]?.supported ? (
328
351
  <Button onClick={() => onSelect(items[0])} style={{ width: "100%", marginBottom: "0.85rem" }}>
329
- 추천대로 시작하기
352
+ {t(language, "flow.recommend.primary")}
330
353
  </Button>
331
354
  ) : null}
332
355
  {items.slice(0, 3).map((model, index) => (
@@ -337,17 +360,17 @@ function RecommendationScreen({
337
360
  disabled={!model.supported}
338
361
  style={{ width: "100%" }}
339
362
  >
340
- <div className="role">{rankLabel(model.role, index)}</div>
363
+ <div className="role">{rankLabel(model.role, index, language)}</div>
341
364
  <div className="name">{model.shortName}</div>
342
365
  <div className="reason">{model.reason} · {model.size || "ready"}</div>
343
- {!model.supported && <div style={{ color: "hsl(var(--destructive))", marginTop: 6, fontSize: "0.85rem" }}>이 컴퓨터에서 추가 확인이 필요합니다</div>}
366
+ {!model.supported && <div style={{ color: "hsl(var(--destructive))", marginTop: 6, fontSize: "0.85rem" }}>{t(language, "flow.recommend.unsupported")}</div>}
344
367
  </button>
345
368
  ))}
346
369
  </div>
347
370
 
348
371
  <div style={{ marginTop: "1.1rem", display: "flex", justifyContent: "center", gap: "1rem", alignItems: "center" }}>
349
- <Button variant="ghost" onClick={onBack}>뒤로</Button>
350
- <div style={{ fontSize: "0.82rem", color: "hsl(var(--fg-muted))" }}>잘 모르겠다면 추천대로 시작하면 됩니다.</div>
372
+ <Button variant="ghost" onClick={onBack}>{t(language, "flow.recommend.back")}</Button>
373
+ <div style={{ fontSize: "0.82rem", color: "hsl(var(--fg-muted))" }}>{t(language, "flow.recommend.hint")}</div>
351
374
  </div>
352
375
  </div>
353
376
  );
@@ -362,10 +385,11 @@ function InstallScreen({
362
385
  onBack: () => void;
363
386
  onComplete: () => void;
364
387
  }) {
388
+ const language = useAppStore((state) => state.language);
365
389
  const [busy, setBusy] = React.useState(false);
366
390
  const [stage, setStage] = React.useState<InstallStage>("idle");
367
391
  const [percent, setPercent] = React.useState(0);
368
- const [message, setMessage] = React.useState("Brain이 사용할 모델을 기다리고 있습니다.");
392
+ const [message, setMessage] = React.useState(t(language, "flow.install.wait"));
369
393
  const [error, setError] = React.useState<string | null>(null);
370
394
 
371
395
  async function start() {
@@ -373,7 +397,7 @@ function InstallScreen({
373
397
  setError(null);
374
398
  setStage("install");
375
399
  setPercent(8);
376
- setMessage("Brain 준비 중입니다.");
400
+ setMessage(t(language, "flow.install.prepare"));
377
401
  const result = await latticeApi.streamModelPrepare(
378
402
  { model: model.loadId, engine: model.engine || "local_mlx", allow_download: true },
379
403
  {
@@ -381,12 +405,12 @@ function InstallScreen({
381
405
  const nextStage = friendlyInstallStage(String(event.stage || ""));
382
406
  setStage(nextStage);
383
407
  setPercent(Number(event.percent || percentForStage(nextStage)));
384
- setMessage(friendlyInstallMessage(event, nextStage));
408
+ setMessage(friendlyInstallMessage(event, nextStage, language));
385
409
  },
386
410
  onDone: () => {
387
411
  setStage("done");
388
412
  setPercent(100);
389
- setMessage("Brain이 준비되었습니다.");
413
+ setMessage(t(language, "flow.install.done"));
390
414
  },
391
415
  onError: (event) => {
392
416
  setStage("error");
@@ -398,7 +422,7 @@ function InstallScreen({
398
422
  if (result.ok) {
399
423
  setStage("done");
400
424
  setPercent(100);
401
- setMessage("Brain이 준비되었습니다.");
425
+ setMessage(t(language, "flow.install.done"));
402
426
  window.setTimeout(onComplete, 700);
403
427
  } else {
404
428
  setStage("error");
@@ -414,10 +438,10 @@ function InstallScreen({
414
438
 
415
439
  return (
416
440
  <div>
417
- <div className="ritual-title">모델을 설치하고 시작합니다.</div>
441
+ <div className="ritual-title">{t(language, "flow.install.title")}</div>
418
442
  <div className="ritual-subtitle">
419
443
  <strong>{model.shortName}</strong> — {model.reason}.<br />
420
- 모델이 Brain의 로컬 목소리가 됩니다. 다운로드, 확인, 로드는 사용자가 누른 뒤에만 진행됩니다.
444
+ {t(language, "flow.install.body")}
421
445
  </div>
422
446
 
423
447
  {/* Living Brain reacts to the ceremony of installation */}
@@ -434,7 +458,7 @@ function InstallScreen({
434
458
  {(["install", "download", "validate", "load"] as const).map((item) => (
435
459
  <div key={item} className={`ritual-stage ${installStepState(stage, item)}`}>
436
460
  <CheckCircle2 style={{ width: 15, height: 15 }} />
437
- <span>{installLabel(item)}</span>
461
+ <span>{installLabel(item, language)}</span>
438
462
  </div>
439
463
  ))}
440
464
  </div>
@@ -446,33 +470,33 @@ function InstallScreen({
446
470
 
447
471
  <div className="ritual-status">{message}</div>
448
472
  <div className="ritual-card" style={{ margin: "0.8rem auto 0", maxWidth: 540, fontSize: "0.86rem", color: "hsl(var(--fg-muted))" }}>
449
- 모델은 다운로드에 몇 분 이상 걸릴 수 있습니다. 진행률은 모델 런타임이 알려주는 만큼 표시하고, 멈춘 것처럼 보여도 현재 단계가 유지됩니다.
473
+ {t(language, "flow.install.note")}
450
474
  </div>
451
475
 
452
476
  {error && (
453
477
  <div className="ritual-card" style={{ borderColor: "hsl(var(--destructive)/0.45)", background: "hsl(var(--destructive)/0.07)", marginBottom: "1rem" }}>
454
478
  {error}
455
- <div style={{ marginTop: "0.5rem", fontSize: "0.85rem" }}>다른 모델을 고르거나 다시 시도할 수 있습니다.</div>
479
+ <div style={{ marginTop: "0.5rem", fontSize: "0.85rem" }}>{t(language, "flow.install.retry")}</div>
456
480
  </div>
457
481
  )}
458
482
 
459
483
  <div style={{ display: "flex", gap: "0.75rem", justifyContent: "center", marginTop: "1rem" }}>
460
- <Button variant="ghost" onClick={onBack} disabled={busy}>다른 모델 고르기</Button>
484
+ <Button variant="ghost" onClick={onBack} disabled={busy}>{t(language, "flow.install.back")}</Button>
461
485
 
462
486
  {stage !== "done" ? (
463
487
  <Button
464
488
  onClick={start}
465
489
  disabled={busy || !model.supported}
466
490
  >
467
- {busy ? "모델 준비 중..." : "다운로드하고 시작하기"}
491
+ {busy ? t(language, "flow.install.busy") : t(language, "flow.install.start")}
468
492
  </Button>
469
493
  ) : (
470
- <Button onClick={onComplete}>Brain으로 들어가기</Button>
494
+ <Button onClick={onComplete}>{t(language, "flow.install.enter")}</Button>
471
495
  )}
472
496
  </div>
473
497
 
474
498
  <div style={{ fontSize: "0.72rem", color: "hsl(var(--fg-muted))", marginTop: "0.9rem" }}>
475
- 모든 작업은 이 컴퓨터에서 진행됩니다.
499
+ {t(language, "flow.install.local")}
476
500
  </div>
477
501
  </div>
478
502
  );
@@ -604,18 +628,21 @@ function fallbackModel(): RecommendedModel {
604
628
  };
605
629
  }
606
630
 
607
- function rankLabel(role: RecommendedModel["role"], index: number) {
608
- if (role === "best") return "Best Experience";
609
- if (role === "faster") return "Faster";
610
- if (role === "advanced") return "Advanced";
631
+ function rankLabel(role: RecommendedModel["role"], index: number, language: Language) {
632
+ if (role === "best") return language === "ko" ? "추천" : "Best Experience";
633
+ if (role === "faster") return language === "ko" ? "빠른 선택" : "Faster";
634
+ if (role === "advanced") return language === "ko" ? "고급 선택" : "Advanced";
611
635
  return `Choice ${index + 1}`;
612
636
  }
613
637
 
614
- function recommendedSummary(analysis: FlowAnalysis) {
638
+ function recommendedSummary(analysis: FlowAnalysis, language: Language) {
615
639
  const recs = asRecord(analysis.recommendations?.recommendations);
616
640
  const topPick = asRecord(recs.top_pick);
617
- if (topPick.name || topPick.id) return `${friendlyModelName(String(topPick.name || topPick.id))} looks like the best fit.`;
618
- return "A private local Brain is recommended for this computer.";
641
+ if (topPick.name || topPick.id) {
642
+ const model = friendlyModelName(String(topPick.name || topPick.id));
643
+ return language === "ko" ? `${model}이 이 컴퓨터에 가장 잘 맞습니다.` : `${model} looks like the best fit.`;
644
+ }
645
+ return language === "ko" ? "이 컴퓨터에는 개인 로컬 Brain을 추천합니다." : "A private local Brain is recommended for this computer.";
619
646
  }
620
647
 
621
648
  function friendlyModelName(value: string) {
@@ -657,20 +684,26 @@ function percentForStage(stage: InstallStage) {
657
684
  return 8;
658
685
  }
659
686
 
660
- function friendlyInstallMessage(event: ApiData, stage: InstallStage) {
687
+ function friendlyInstallMessage(event: ApiData, stage: InstallStage, language: Language) {
661
688
  const fallback = {
662
- install: "Preparing the Brain.",
663
- download: "Getting the model files.",
664
- validate: "Checking that the Brain can answer.",
665
- load: "Loading the Brain.",
666
- done: "Your Brain is ready.",
667
- idle: "Ready when you are.",
668
- error: "Something needs attention.",
689
+ install: t(language, "flow.install.prepare"),
690
+ download: language === "ko" ? "모델 파일을 받는 중입니다." : "Getting the model files.",
691
+ validate: language === "ko" ? "Brain이 응답할 수 있는지 확인 중입니다." : "Checking that the Brain can answer.",
692
+ load: language === "ko" ? "Brain을 불러오는 중입니다." : "Loading the Brain.",
693
+ done: t(language, "flow.install.done"),
694
+ idle: t(language, "flow.install.wait"),
695
+ error: language === "ko" ? "확인이 필요한 일이 있습니다." : "Something needs attention.",
669
696
  }[stage];
670
697
  return cleanConsumerText(String(event.user_message || event.message || fallback));
671
698
  }
672
699
 
673
- function installLabel(stage: "install" | "download" | "validate" | "load") {
700
+ function installLabel(stage: "install" | "download" | "validate" | "load", language: Language) {
701
+ if (language === "ko") {
702
+ if (stage === "install") return "준비";
703
+ if (stage === "download") return "다운로드";
704
+ if (stage === "validate") return "확인";
705
+ return "로드";
706
+ }
674
707
  if (stage === "install") return "Install";
675
708
  if (stage === "download") return "Download";
676
709
  if (stage === "validate") return "Validate";