@brainpilot/web 0.0.10 → 0.0.11

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.
@@ -54,6 +54,41 @@ function isKnownStage(stage: string): stage is Stage {
54
54
  return (STAGES as string[]).includes(stage);
55
55
  }
56
56
 
57
+ interface SetupState {
58
+ percent: number;
59
+ msg: string;
60
+ status: "pending" | "running" | "done" | "error";
61
+ }
62
+
63
+ // Small progress bar used for the venv + model download rows in the env
64
+ // card. Kept local to this file so we don't grow a shared "ProgressBar"
65
+ // component just for two sites — one file, one style.
66
+ function SetupProgressRow({
67
+ label,
68
+ state,
69
+ }: {
70
+ label: string;
71
+ state: SetupState;
72
+ }) {
73
+ const pct = Math.max(0, Math.min(100, state.percent));
74
+ return (
75
+ <div className="kb-setup-row">
76
+ <div className="kb-setup-row__head">
77
+ <span className="kb-setup-row__label">{label}</span>
78
+ <span className="kb-setup-row__pct">{state.status === "done" ? "✓ 100%" : `${pct}%`}</span>
79
+ </div>
80
+ <div className="kb-setup-row__track" aria-hidden="true">
81
+ <span className={`kb-setup-row__fill kb-setup-row__fill--${state.status}`} style={{ width: `${pct}%` }} />
82
+ </div>
83
+ {state.msg ? (
84
+ <div className={`kb-setup-row__msg ${state.status === "error" ? "kb-setup-row__msg--error" : ""}`}>
85
+ {state.msg}
86
+ </div>
87
+ ) : null}
88
+ </div>
89
+ );
90
+ }
91
+
57
92
  export function KnowledgeBasePanel() {
58
93
  const t = useT();
59
94
  const [ocrApiKey, setOcrApiKey] = useState("");
@@ -61,6 +96,7 @@ export function KnowledgeBasePanel() {
61
96
  const [metaBaseUrl, setMetaBaseUrl] = useState("");
62
97
  const [metaModel, setMetaModel] = useState("");
63
98
  const [reuseAgentKey, setReuseAgentKey] = useState(true);
99
+ const [useHfMirror, setUseHfMirror] = useState(false);
64
100
  const [skip, setSkip] = useState<Record<Stage, boolean>>({
65
101
  ocr: false,
66
102
  extract: false,
@@ -71,8 +107,10 @@ export function KnowledgeBasePanel() {
71
107
  const [stages, setStages] = useState<Record<Stage, StageState>>(INITIAL_STAGE_STATE);
72
108
  const [active, setActive] = useState(false);
73
109
  /** Distinguishes "the build is running" from "env setup is running" so the
74
- * UI can show the right spinner / disable the right buttons. */
75
- const [activeJob, setActiveJob] = useState<"build" | "setup-env" | null>(null);
110
+ * UI can show the right spinner / disable the right buttons.
111
+ * "setup-full" covers the one-click orchestration that runs venv + model
112
+ * download back-to-back. */
113
+ const [activeJob, setActiveJob] = useState<"build" | "setup-env" | "setup-full" | null>(null);
76
114
  const [error, setError] = useState<string | null>(null);
77
115
  const [env, setEnv] = useState<{
78
116
  python: string;
@@ -83,13 +121,39 @@ export function KnowledgeBasePanel() {
83
121
  kbRoot: string;
84
122
  } | null>(null);
85
123
  const [envBusy, setEnvBusy] = useState(false);
124
+ // Model download runs in parallel with env setup; it needs its own
125
+ // progress row and busy flag so the UI can show both in flight at once.
126
+ const [modelBusy, setModelBusy] = useState(false);
127
+ const [envProgress, setEnvProgress] = useState<SetupState>(
128
+ { percent: 0, msg: "", status: "pending" },
129
+ );
130
+ const [modelProgress, setModelProgress] = useState<SetupState>(
131
+ { percent: 0, msg: "", status: "pending" },
132
+ );
133
+ // Persisted OCR key state: true iff the backend confirms one is on disk.
134
+ // When true, the input shows a masked preview + "Change" button so the
135
+ // user doesn't have to re-type it on every page reload.
136
+ const [ocrKeySaved, setOcrKeySaved] = useState(false);
137
+ const [ocrKeyPreview, setOcrKeyPreview] = useState("");
138
+ const [ocrKeyEditing, setOcrKeyEditing] = useState(false);
86
139
  const logRef = useRef<HTMLDivElement | null>(null);
87
140
  const sseRef = useRef<EventSource | null>(null);
88
141
 
89
142
  // Hydrate from server: if a build is already running (e.g. user reopened
90
143
  // the dialog mid-build), show its current status + replay recent events.
144
+ // Also fetch the persisted OCR key state so the input can show "saved".
91
145
  useEffect(() => {
92
146
  let cancelled = false;
147
+ void (async () => {
148
+ try {
149
+ const cfg = await api.kb.getApiConfig();
150
+ if (cancelled) return;
151
+ setOcrKeySaved(cfg.hasOcrApiKey);
152
+ setOcrKeyPreview(cfg.ocrApiKeyPreview);
153
+ } catch {
154
+ /* api-config fetch is best-effort */
155
+ }
156
+ })();
93
157
  void (async () => {
94
158
  try {
95
159
  const status = await api.kb.status();
@@ -98,6 +162,7 @@ export function KnowledgeBasePanel() {
98
162
  if (status.recentEvents?.length) {
99
163
  setEvents(status.recentEvents);
100
164
  replayStages(status.recentEvents);
165
+ replaySetupProgress(status.recentEvents);
101
166
  }
102
167
  if (status.active) {
103
168
  // Guess which job is running from the most recent event with a
@@ -163,6 +228,40 @@ export function KnowledgeBasePanel() {
163
228
  }
164
229
  }
165
230
 
231
+ // Replay setup-env / setup-models progress from a fresh snapshot (used on
232
+ // page reload when there might be a job already in flight — the SSE stream
233
+ // gives us subsequent events, this fills in whatever happened before).
234
+ function replaySetupProgress(history: BuildEvent[]) {
235
+ let envP: SetupState = { percent: 0, msg: "", status: "pending" };
236
+ let modelP: SetupState = { percent: 0, msg: "", status: "pending" };
237
+ for (const ev of history) {
238
+ if (ev.stage === "setup-env") {
239
+ envP = deriveSetupState(envP, ev);
240
+ } else if (ev.stage === "setup-models") {
241
+ modelP = deriveSetupState(modelP, ev);
242
+ }
243
+ }
244
+ setEnvProgress(envP);
245
+ setModelProgress(modelP);
246
+ }
247
+
248
+ function deriveSetupState(prev: SetupState, ev: BuildEvent): SetupState {
249
+ if (ev.event === "progress") {
250
+ const pct = typeof ev.percent === "number" ? ev.percent : prev.percent;
251
+ return { status: "running", percent: pct, msg: ev.msg };
252
+ }
253
+ if (ev.event === "info") {
254
+ return { ...prev, status: "running", msg: ev.msg };
255
+ }
256
+ if (ev.event === "done") {
257
+ return { status: "done", percent: 100, msg: ev.msg };
258
+ }
259
+ if (ev.event === "error") {
260
+ return { ...prev, status: "error", msg: ev.msg };
261
+ }
262
+ return prev;
263
+ }
264
+
166
265
  async function refreshEnv() {
167
266
  try {
168
267
  const s = await api.kb.status();
@@ -188,12 +287,27 @@ export function KnowledgeBasePanel() {
188
287
  setActive(false);
189
288
  setActiveJob(null);
190
289
  }
191
- if (ev.stage === "setup-env" && (ev.event === "done" || ev.event === "error")) {
192
- setEnvBusy(false);
290
+ if (ev.stage === "setup-env") {
291
+ setEnvProgress((prev) => deriveSetupState(prev, ev));
292
+ if (ev.event === "done" || ev.event === "error") {
293
+ setEnvBusy(false);
294
+ // Re-fetch environment so the banner flips from yellow to green
295
+ // (or stays yellow with the right error).
296
+ void refreshEnv();
297
+ }
298
+ }
299
+ if (ev.stage === "setup-models") {
300
+ setModelProgress((prev) => deriveSetupState(prev, ev));
301
+ if (ev.event === "info" && !modelBusy) setModelBusy(true);
302
+ if (ev.event === "done" || ev.event === "error") {
303
+ setModelBusy(false);
304
+ }
305
+ }
306
+ // The whole "setup-full" umbrella job clears activeJob only when both
307
+ // constituent jobs are done (or one failed). setup-full emits its own
308
+ // synthetic done/error event that we key off here.
309
+ if (ev.stage === "setup-full" && (ev.event === "done" || ev.event === "error")) {
193
310
  setActiveJob(null);
194
- // Re-fetch environment so the banner flips from yellow to green
195
- // (or stays yellow with the right error).
196
- void refreshEnv();
197
311
  }
198
312
  }
199
313
 
@@ -227,7 +341,7 @@ export function KnowledgeBasePanel() {
227
341
  }
228
342
 
229
343
  const formInvalid = useMemo(() => {
230
- if (skip.ocr === false && !ocrApiKey.trim()) {
344
+ if (skip.ocr === false && !ocrApiKey.trim() && !ocrKeySaved) {
231
345
  return t("settings.kb.error.missingOcrKey");
232
346
  }
233
347
  if (skip.extract === false) {
@@ -236,7 +350,7 @@ export function KnowledgeBasePanel() {
236
350
  }
237
351
  }
238
352
  return null;
239
- }, [ocrApiKey, metaApiKey, reuseAgentKey, skip.ocr, skip.extract, t]);
353
+ }, [ocrApiKey, ocrKeySaved, metaApiKey, reuseAgentKey, skip.ocr, skip.extract, t]);
240
354
 
241
355
  async function startBuild() {
242
356
  setError(null);
@@ -260,6 +374,7 @@ export function KnowledgeBasePanel() {
260
374
  metaBaseUrl: metaBaseUrl.trim() || undefined,
261
375
  metaModel: metaModel.trim() || undefined,
262
376
  skip: skipList.length ? skipList : undefined,
377
+ hfMirror: useHfMirror ? "https://hf-mirror.com" : undefined,
263
378
  });
264
379
  if (!r.ok) {
265
380
  setError(r.error || "build start failed");
@@ -288,11 +403,13 @@ export function KnowledgeBasePanel() {
288
403
  // distinct from prior [build:...] / [ocr:...] lines.
289
404
  setEnvBusy(true);
290
405
  setActiveJob("setup-env");
406
+ setEnvProgress({ percent: 0, msg: "", status: "running" });
291
407
  try {
292
408
  const r = await api.kb.setupEnv({ reinstall });
293
409
  if (!r.ok) {
294
410
  setEnvBusy(false);
295
411
  setActiveJob(null);
412
+ setEnvProgress({ percent: 0, msg: r.error || "start failed", status: "error" });
296
413
  setError(r.error || "setup-env start failed");
297
414
  return;
298
415
  }
@@ -300,6 +417,62 @@ export function KnowledgeBasePanel() {
300
417
  } catch (err) {
301
418
  setEnvBusy(false);
302
419
  setActiveJob(null);
420
+ setEnvProgress({ percent: 0, msg: String(err), status: "error" });
421
+ setError(err instanceof Error ? err.message : String(err));
422
+ }
423
+ }
424
+
425
+ /** One-click: create venv, then download bge models. The backend chains
426
+ * the two jobs — venv completes first, models kick off automatically. */
427
+ async function startFullSetup() {
428
+ setError(null);
429
+ setEnvBusy(true);
430
+ setModelBusy(true);
431
+ setActiveJob("setup-full");
432
+ setEnvProgress({ percent: 0, msg: "", status: "running" });
433
+ setModelProgress({ percent: 0, msg: "waiting for venv…", status: "pending" });
434
+ try {
435
+ const r = await api.kb.setupFull({
436
+ hfMirror: useHfMirror ? "https://hf-mirror.com" : undefined,
437
+ });
438
+ if (!r.ok) {
439
+ setEnvBusy(false);
440
+ setModelBusy(false);
441
+ setActiveJob(null);
442
+ setEnvProgress({ percent: 0, msg: r.error || "start failed", status: "error" });
443
+ setError(r.error || "setup-full start failed");
444
+ return;
445
+ }
446
+ openSse();
447
+ } catch (err) {
448
+ setEnvBusy(false);
449
+ setModelBusy(false);
450
+ setActiveJob(null);
451
+ setEnvProgress({ percent: 0, msg: String(err), status: "error" });
452
+ setError(err instanceof Error ? err.message : String(err));
453
+ }
454
+ }
455
+
456
+ /** Save the OCR API key to disk (backend → API_config.json). Called on
457
+ * blur when the user typed something new. */
458
+ async function saveOcrKey(value: string) {
459
+ if (!value.trim()) return;
460
+ try {
461
+ const r = await api.kb.saveApiConfig({ ocrApiKey: value.trim() });
462
+ if (!r.ok) {
463
+ setError(r.error || "failed to save OCR key");
464
+ return;
465
+ }
466
+ setOcrKeySaved(true);
467
+ // The backend gives us the masked preview on GET; refresh so the UI
468
+ // shows "...abcd" matching what's actually on disk.
469
+ const cfg = await api.kb.getApiConfig();
470
+ setOcrKeyPreview(cfg.ocrApiKeyPreview);
471
+ setOcrKeyEditing(false);
472
+ // Clear the input value now that it's persisted — subsequent builds
473
+ // pick it up from the backend's saved copy.
474
+ setOcrApiKey("");
475
+ } catch (err) {
303
476
  setError(err instanceof Error ? err.message : String(err));
304
477
  }
305
478
  }
@@ -308,7 +481,7 @@ export function KnowledgeBasePanel() {
308
481
  <section className="settings-section">
309
482
  <div className="settings-section__header">
310
483
  <div>
311
- <h3 style={{ display: "flex", alignItems: "center", gap: 8 }}>
484
+ <h3 className="kb-panel__title">
312
485
  <Database size={18} aria-hidden />
313
486
  {t("settings.kb.title")}
314
487
  </h3>
@@ -319,94 +492,113 @@ export function KnowledgeBasePanel() {
319
492
  </div>
320
493
 
321
494
  {env ? (
322
- <div
323
- style={{
324
- marginBottom: 12,
325
- padding: 10,
326
- border: `1px solid ${env.venvExists ? "#bbf7d0" : "#fde68a"}`,
327
- background: env.venvExists ? "#f0fdf4" : "#fffbeb",
328
- borderRadius: 6,
329
- fontSize: 12,
330
- color: "#334155",
331
- }}
332
- >
333
- <div style={{ marginBottom: 4 }}>
334
- <strong>{t("settings.kb.env.title")}</strong>
335
- </div>
336
- <div style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}>
337
- KB_ROOT: {env.kbRoot}
338
- <br />
339
- Python: {env.python}
340
- {env.pythonIsVenv ? " ✓ venv" : ""}
495
+ <div className={`kb-env kb-env--${env.venvExists ? "ready" : "missing"}`}>
496
+ <div className="kb-env__head">
497
+ <span className={`sandbox-chip sandbox-chip--${env.venvExists ? "ok" : "off"}`}>
498
+ <Wrench size={13} />
499
+ {t("settings.kb.env.title")}
500
+ <i className="sandbox-chip__dot" aria-hidden="true" />
501
+ </span>
341
502
  </div>
503
+ <dl className="kb-env__facts">
504
+ <div>
505
+ <dt>KB_ROOT</dt>
506
+ <dd>{env.kbRoot}</dd>
507
+ </div>
508
+ <div>
509
+ <dt>Python</dt>
510
+ <dd>{env.python}{env.pythonIsVenv ? " · venv" : ""}</dd>
511
+ </div>
512
+ </dl>
342
513
  {!env.venvExists ? (
343
- <div style={{ marginTop: 8 }}>
344
- <div style={{ marginBottom: 6 }}>{t("settings.kb.env.venvMissing")}</div>
345
- <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
514
+ <div className="kb-env__action">
515
+ <p className="kb-env__note">{t("settings.kb.env.venvMissing")}</p>
516
+ <div className="kb-env__buttons">
346
517
  <button
347
518
  type="button"
348
519
  className="settings-button"
349
- onClick={() => void startEnvSetup(false)}
350
- disabled={envBusy || activeJob !== null}
351
- title={t("settings.kb.env.setupHint")}
520
+ onClick={() => void startFullSetup()}
521
+ disabled={envBusy || modelBusy || activeJob !== null}
522
+ title={t("settings.kb.env.setupFullHint")}
352
523
  >
353
- <Wrench size={14} style={{ marginRight: 4 }} aria-hidden />
354
- {t("settings.kb.env.setupButton")}
524
+ <Wrench size={14} aria-hidden />
525
+ {t("settings.kb.env.setupFullButton")}
355
526
  </button>
356
- {envBusy ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
527
+ {envBusy || modelBusy ? <Loader2 size={14} className="spin" aria-hidden /> : null}
357
528
  </div>
358
- <details style={{ marginTop: 6 }}>
359
- <summary style={{ cursor: "pointer", color: "#64748b" }}>
360
- {t("settings.kb.env.cliFallback")}
361
- </summary>
362
- <pre
363
- style={{
364
- marginTop: 4,
365
- padding: 8,
366
- background: "#0f172a",
367
- color: "#e2e8f0",
368
- borderRadius: 4,
369
- overflowX: "auto",
370
- fontSize: 12,
371
- }}
372
- >
373
- {`bash ${env.kbRoot}/scripts/setup_env.sh`}
529
+ <details className="kb-env__fallback">
530
+ <summary>{t("settings.kb.env.cliFallback")}</summary>
531
+ <pre className="kb-code">
532
+ {`bash ${env.kbRoot}/scripts/setup_env.sh
533
+ ${env.kbRoot}/.venv/bin/python ${env.kbRoot}/scripts/setup_models.py`}
374
534
  </pre>
375
- <div style={{ color: "#64748b", marginTop: 4 }}>
376
- {t("settings.kb.env.venvHint")}
377
- </div>
535
+ <p className="kb-env__note">{t("settings.kb.env.venvHint")}</p>
378
536
  </details>
379
537
  </div>
380
538
  ) : (
381
- <div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8 }}>
539
+ <div className="kb-env__buttons">
382
540
  <button
383
541
  type="button"
384
- className="settings-button"
542
+ className="settings-button settings-button--ghost"
385
543
  onClick={() => void startEnvSetup(true)}
386
- disabled={envBusy || activeJob !== null}
544
+ disabled={envBusy || modelBusy || activeJob !== null}
387
545
  title={t("settings.kb.env.reinstallHint")}
388
- style={{ background: "transparent", border: "1px solid #cbd5e1", color: "#334155" }}
389
546
  >
390
- <RefreshCw size={14} style={{ marginRight: 4 }} aria-hidden />
547
+ <RefreshCw size={14} aria-hidden />
391
548
  {t("settings.kb.env.reinstallButton")}
392
549
  </button>
393
- {envBusy ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
550
+ {envBusy ? <Loader2 size={14} className="spin" aria-hidden /> : null}
394
551
  </div>
395
552
  )}
553
+
554
+ {/* Setup progress rows: shown any time either job is/was active. */}
555
+ {(envProgress.status !== "pending" || modelProgress.status !== "pending") ? (
556
+ <div className="kb-setup-rows">
557
+ <SetupProgressRow
558
+ label={t("settings.kb.env.venvProgressLabel")}
559
+ state={envProgress}
560
+ />
561
+ <SetupProgressRow
562
+ label={t("settings.kb.env.modelProgressLabel")}
563
+ state={modelProgress}
564
+ />
565
+ </div>
566
+ ) : null}
396
567
  </div>
397
568
  ) : null}
398
569
 
399
- <div className="settings-field-grid">
570
+ <div className="kb-fields">
400
571
  <label className="settings-field">
401
572
  <span>{t("settings.kb.ocrKey")}</span>
402
- <input
403
- type="password"
404
- value={ocrApiKey}
405
- onChange={(e) => setOcrApiKey(e.target.value)}
406
- placeholder="sk-..."
407
- autoComplete="off"
408
- disabled={active || skip.ocr}
409
- />
573
+ {ocrKeySaved && !ocrKeyEditing ? (
574
+ <div className="kb-key-saved-row">
575
+ <div className="kb-key-saved">
576
+ {t("settings.kb.ocrKeySaved")} {ocrKeyPreview}
577
+ </div>
578
+ <button
579
+ type="button"
580
+ className="settings-button settings-button--ghost"
581
+ onClick={() => setOcrKeyEditing(true)}
582
+ disabled={active || skip.ocr}
583
+ >
584
+ {t("settings.kb.ocrKeyChange")}
585
+ </button>
586
+ </div>
587
+ ) : (
588
+ <input
589
+ type="password"
590
+ value={ocrApiKey}
591
+ onChange={(e) => setOcrApiKey(e.target.value)}
592
+ onBlur={(e) => {
593
+ // Persist on blur so the user doesn't have to remember to
594
+ // save. Empty input (they cleared it) → treat as no-op.
595
+ if (e.target.value.trim()) void saveOcrKey(e.target.value);
596
+ }}
597
+ placeholder="sk-..."
598
+ autoComplete="off"
599
+ disabled={active || skip.ocr}
600
+ />
601
+ )}
410
602
  </label>
411
603
 
412
604
  <label className="settings-check">
@@ -457,11 +649,24 @@ export function KnowledgeBasePanel() {
457
649
  </>
458
650
  ) : null}
459
651
 
460
- <fieldset className="settings-field" style={{ border: "none", padding: 0 }}>
652
+ <label className="settings-check">
653
+ <input
654
+ type="checkbox"
655
+ checked={useHfMirror}
656
+ onChange={(e) => setUseHfMirror(e.target.checked)}
657
+ disabled={active}
658
+ />
659
+ <span>{t("settings.kb.useHfMirror")}</span>
660
+ </label>
661
+ <p className="kb-field-hint">
662
+ {t("settings.kb.useHfMirrorHint")}
663
+ </p>
664
+
665
+ <fieldset className="kb-stages">
461
666
  <legend>{t("settings.kb.stages")}</legend>
462
- <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
667
+ <div className="kb-stages__row">
463
668
  {STAGES.map((s) => (
464
- <label key={s} className="settings-check" style={{ margin: 0 }}>
669
+ <label key={s} className="settings-check kb-stages__item">
465
670
  <input
466
671
  type="checkbox"
467
672
  checked={!skip[s]}
@@ -477,7 +682,7 @@ export function KnowledgeBasePanel() {
477
682
  </fieldset>
478
683
  </div>
479
684
 
480
- <div style={{ display: "flex", gap: 8, marginTop: 12 }}>
685
+ <div className="kb-actions">
481
686
  {!active ? (
482
687
  <button
483
688
  className="settings-button"
@@ -493,7 +698,7 @@ export function KnowledgeBasePanel() {
493
698
  : undefined)
494
699
  }
495
700
  >
496
- <Play size={14} style={{ marginRight: 4 }} aria-hidden />
701
+ <Play size={14} aria-hidden />
497
702
  {t("settings.kb.start")}
498
703
  </button>
499
704
  ) : (
@@ -502,91 +707,50 @@ export function KnowledgeBasePanel() {
502
707
  type="button"
503
708
  onClick={() => void cancelBuild()}
504
709
  >
505
- <Square size={14} style={{ marginRight: 4 }} aria-hidden />
710
+ <Square size={14} aria-hidden />
506
711
  {t("settings.kb.cancel")}
507
712
  </button>
508
713
  )}
509
- {active ? <Loader2 size={16} className="animate-spin" aria-hidden /> : null}
714
+ {active ? <Loader2 size={16} className="spin" aria-hidden /> : null}
510
715
  </div>
511
716
 
512
- {error ? (
513
- <p style={{ color: "var(--color-danger, #d92d20)", marginTop: 8 }}>
514
- {error}
515
- </p>
516
- ) : null}
717
+ {error ? <p className="settings-note settings-note--error kb-error">{error}</p> : null}
517
718
 
518
- <div style={{ marginTop: 16 }}>
519
- <h4 style={{ margin: "0 0 8px" }}>{t("settings.kb.progress")}</h4>
520
- <div style={{ display: "grid", gridTemplateColumns: "100px 1fr 60px", rowGap: 6, columnGap: 8, alignItems: "center" }}>
719
+ <div className="kb-block">
720
+ <h4 className="kb-block__title">{t("settings.kb.progress")}</h4>
721
+ <div className="kb-progress">
521
722
  {STAGES.map((s) => {
522
723
  const st = stages[s];
523
- const color =
524
- st.status === "done" ? "#16a34a" :
525
- st.status === "error" ? "#d92d20" :
526
- st.status === "running" ? "#2563eb" : "#cbd5e1";
724
+ const pct = Math.min(100, Math.max(0, st.percent));
527
725
  return (
528
- <div key={s} style={{ display: "contents" }}>
529
- <strong>{STAGE_LABELS[s]}</strong>
530
- <div style={{
531
- background: "#f1f5f9",
532
- borderRadius: 4,
533
- overflow: "hidden",
534
- height: 8,
535
- }}>
536
- <div style={{
537
- width: `${Math.min(100, Math.max(0, st.percent))}%`,
538
- background: color,
539
- height: "100%",
540
- transition: "width 200ms linear",
541
- }} />
726
+ <div key={s} className="kb-progress__row">
727
+ <strong className="kb-progress__label">{STAGE_LABELS[s]}</strong>
728
+ <div className="kb-progress__track" aria-hidden="true">
729
+ <span
730
+ className={`kb-progress__fill kb-progress__fill--${st.status}`}
731
+ style={{ width: `${pct}%` }}
732
+ />
542
733
  </div>
543
- <span style={{ fontVariantNumeric: "tabular-nums", color: "#64748b" }}>
734
+ <span className="kb-progress__pct">
544
735
  {st.status === "done" ? "✓" : `${Math.round(st.percent)}%`}
545
736
  </span>
546
- {st.msg ? (
547
- <div style={{ gridColumn: "2 / -1", fontSize: 12, color: "#64748b" }}>
548
- {st.msg}
549
- </div>
550
- ) : null}
737
+ {st.msg ? <div className="kb-progress__msg">{st.msg}</div> : null}
551
738
  </div>
552
739
  );
553
740
  })}
554
741
  </div>
555
742
  </div>
556
743
 
557
- <div style={{ marginTop: 16 }}>
558
- <h4 style={{ margin: "0 0 8px" }}>{t("settings.kb.log")}</h4>
559
- <div
560
- ref={logRef}
561
- style={{
562
- background: "#0f172a",
563
- color: "#e2e8f0",
564
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
565
- fontSize: 12,
566
- padding: 8,
567
- borderRadius: 4,
568
- maxHeight: 220,
569
- overflowY: "auto",
570
- whiteSpace: "pre-wrap",
571
- wordBreak: "break-word",
572
- }}
573
- >
744
+ <div className="kb-block">
745
+ <h4 className="kb-block__title">{t("settings.kb.log")}</h4>
746
+ <div ref={logRef} className="kb-log">
574
747
  {events.length === 0 ? (
575
- <span style={{ color: "#64748b" }}>
576
- {t("settings.kb.logEmpty")}
577
- </span>
578
- ) : events.map((ev, i) => {
579
- const color =
580
- ev.event === "error" ? "#fca5a5" :
581
- ev.event === "warn" ? "#fbbf24" :
582
- ev.event === "done" ? "#86efac" :
583
- ev.event === "progress" ? "#93c5fd" : "#e2e8f0";
584
- return (
585
- <div key={i} style={{ color }}>
586
- [{ev.stage}:{ev.event}] {ev.msg}
587
- </div>
588
- );
589
- })}
748
+ <span className="kb-log__empty">{t("settings.kb.logEmpty")}</span>
749
+ ) : events.map((ev, i) => (
750
+ <div key={i} className={`kb-log__line kb-log__line--${ev.event}`}>
751
+ [{ev.stage}:{ev.event}] {ev.msg}
752
+ </div>
753
+ ))}
590
754
  </div>
591
755
  </div>
592
756
  </section>