@brainpilot/web 0.0.9 → 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.
package/src/utils/api.ts CHANGED
@@ -66,13 +66,44 @@ function authHeaders(json = true): Record<string, string> {
66
66
  return json ? { "Content-Type": "application/json" } : {};
67
67
  }
68
68
 
69
+ /**
70
+ * #206: build a readable message from a Zod issue list. The backend returns
71
+ * `details: parsed.error.issues` — each issue has a `path` (field) and a
72
+ * `message`. We render `field: message` per issue so a validation 400 tells the
73
+ * user *which* field is wrong (empty name, invalid url, …) instead of degrading
74
+ * to a generic "Request failed (400)".
75
+ */
76
+ function formatIssues(details: unknown): string | null {
77
+ if (!Array.isArray(details) || details.length === 0) return null;
78
+ const parts: string[] = [];
79
+ for (const issue of details) {
80
+ if (!issue || typeof issue !== "object") continue;
81
+ const { path, message } = issue as { path?: unknown; message?: unknown };
82
+ if (typeof message !== "string" || message.length === 0) continue;
83
+ const field = Array.isArray(path) ? path.filter((p) => p !== "" && p != null).join(".") : "";
84
+ parts.push(field ? `${field}: ${message}` : message);
85
+ }
86
+ return parts.length > 0 ? parts.join("; ") : null;
87
+ }
88
+
69
89
  async function parseError(res: Response): Promise<string> {
70
90
  const contentType = res.headers.get("content-type") || "";
71
91
  if (contentType.includes("application/json")) {
72
- const body = (await res.json().catch(() => null)) as { detail?: unknown } | null;
73
- if (typeof body?.detail === "string") {
92
+ // #206: the backend uses two shapes `{ detail }` (single string) and the
93
+ // Zod validation shape `{ error, details }`. parseError previously read only
94
+ // `detail`, so every `{ error, details }` 400/409 fell through to the generic
95
+ // text fallback. Read all three: detail → error(+formatted details) → error.
96
+ const body = (await res.json().catch(() => null)) as
97
+ | { detail?: unknown; error?: unknown; details?: unknown }
98
+ | null;
99
+ if (typeof body?.detail === "string" && body.detail.length > 0) {
74
100
  return body.detail;
75
101
  }
102
+ const issues = formatIssues(body?.details);
103
+ if (typeof body?.error === "string" && body.error.length > 0) {
104
+ return issues ? `${body.error} (${issues})` : body.error;
105
+ }
106
+ if (issues) return issues;
76
107
  }
77
108
  const text = await res.text().catch(() => "");
78
109
  return text || `Request failed (${res.status})`;
@@ -519,7 +550,14 @@ export const api = {
519
550
  `${API_BASE}/sessions/${sessionId}/history${qs}`,
520
551
  { headers: authHeaders() },
521
552
  );
522
- if (!res.ok) return { events: [], total: 0, truncated: false };
553
+ // A 404 means the session has no transcript on disk (genuinely empty) —
554
+ // return an empty history. Any OTHER non-OK status is a real failure
555
+ // (routing / storage / auth); surface it instead of silently rendering an
556
+ // empty transcript, which historically masked broken rehydrates (#223).
557
+ if (res.status === 404) return { events: [], total: 0, truncated: false };
558
+ if (!res.ok) {
559
+ throw new Error(`history fetch failed: ${res.status} ${res.statusText}`);
560
+ }
523
561
  const raw = (await res.json().catch(() => null)) as
524
562
  | { events?: unknown[]; total?: number; truncated?: boolean }
525
563
  | null;
@@ -735,4 +773,151 @@ export const api = {
735
773
  return normalizeProviderProfile(raw as Parameters<typeof normalizeProviderProfile>[0]);
736
774
  },
737
775
  },
776
+
777
+ // Knowledge Base — local pipeline build orchestration.
778
+ // Mirrors the backend /api/kb/* routes. The SSE event stream is consumed
779
+ // directly via `new EventSource()` in the panel component (so it can stay
780
+ // attached for the lifetime of the dialog), so we don't expose a helper
781
+ // here for it.
782
+ kb: {
783
+ async build(opts: {
784
+ ocrApiKey?: string;
785
+ metaApiKey?: string;
786
+ metaBaseUrl?: string;
787
+ metaModel?: string;
788
+ kbRoot?: string;
789
+ ocrConcurrency?: number;
790
+ ocrLimit?: number;
791
+ skip?: Array<"ocr" | "extract" | "chunk" | "vectorize">;
792
+ only?: Array<"ocr" | "extract" | "chunk" | "vectorize">;
793
+ hfMirror?: string;
794
+ }): Promise<{ ok: boolean; startedAt?: number; error?: string }> {
795
+ const res = await apiFetch(`${API_BASE}/kb/build`, {
796
+ method: "POST",
797
+ headers: { "content-type": "application/json" },
798
+ body: JSON.stringify(opts),
799
+ });
800
+ if (!res.ok) {
801
+ const body = await res.json().catch(() => ({}));
802
+ return { ok: false, error: body.error || `kb build failed (${res.status})` };
803
+ }
804
+ return handleJson(res);
805
+ },
806
+
807
+ async status(): Promise<{
808
+ active: boolean;
809
+ startedAt: number | null;
810
+ finishedAt: number | null;
811
+ exitCode: number | null | undefined;
812
+ error?: string;
813
+ recentEvents: Array<{
814
+ ts: string;
815
+ stage: string;
816
+ event: string;
817
+ msg: string;
818
+ [k: string]: unknown;
819
+ }>;
820
+ environment: {
821
+ python: string;
822
+ pythonIsVenv: boolean;
823
+ venvExists: boolean;
824
+ expectedVenvPath: string;
825
+ scriptsPresent: boolean;
826
+ kbRoot: string;
827
+ };
828
+ }> {
829
+ return handleJson(await apiFetch(`${API_BASE}/kb/status`));
830
+ },
831
+
832
+ async cancel(): Promise<{ ok: boolean; message?: string }> {
833
+ const res = await apiFetch(`${API_BASE}/kb/cancel`, { method: "POST" });
834
+ return res.json().catch(() => ({ ok: false }));
835
+ },
836
+
837
+ // Bootstrap the Python venv (KnowledgeBase/.venv) before the first
838
+ // build can run. Streams progress on the same SSE channel as `build`,
839
+ // so the panel only needs one EventSource subscription.
840
+ async setupEnv(opts: {
841
+ python?: string;
842
+ reinstall?: boolean;
843
+ kbRoot?: string;
844
+ } = {}): Promise<{ ok: boolean; startedAt?: number; error?: string }> {
845
+ const res = await apiFetch(`${API_BASE}/kb/setup-env`, {
846
+ method: "POST",
847
+ headers: { "content-type": "application/json" },
848
+ body: JSON.stringify(opts),
849
+ });
850
+ if (!res.ok) {
851
+ const body = await res.json().catch(() => ({}));
852
+ return { ok: false, error: body.error || `setup-env failed (${res.status})` };
853
+ }
854
+ return handleJson(res);
855
+ },
856
+
857
+ // Download bge-m3 + bge-reranker-v2-m3 model weights (~2.5 GB) via
858
+ // `scripts/setup_models.py`. Independent slot from setupEnv — the two
859
+ // can run concurrently, and setupFull() chains them.
860
+ async setupModels(opts: {
861
+ hfMirror?: string;
862
+ kbRoot?: string;
863
+ } = {}): Promise<{ ok: boolean; startedAt?: number; error?: string }> {
864
+ const res = await apiFetch(`${API_BASE}/kb/setup-models`, {
865
+ method: "POST",
866
+ headers: { "content-type": "application/json" },
867
+ body: JSON.stringify(opts),
868
+ });
869
+ if (!res.ok) {
870
+ const body = await res.json().catch(() => ({}));
871
+ return { ok: false, error: body.error || `setup-models failed (${res.status})` };
872
+ }
873
+ return handleJson(res);
874
+ },
875
+
876
+ // One-click orchestration: create venv, then download models when venv
877
+ // exits 0. Preferred entry point from the KB panel — one button, two
878
+ // progress rows in the UI.
879
+ async setupFull(opts: {
880
+ python?: string;
881
+ reinstall?: boolean;
882
+ hfMirror?: string;
883
+ kbRoot?: string;
884
+ } = {}): Promise<{ ok: boolean; startedAt?: number; error?: string }> {
885
+ const res = await apiFetch(`${API_BASE}/kb/setup-full`, {
886
+ method: "POST",
887
+ headers: { "content-type": "application/json" },
888
+ body: JSON.stringify(opts),
889
+ });
890
+ if (!res.ok) {
891
+ const body = await res.json().catch(() => ({}));
892
+ return { ok: false, error: body.error || `setup-full failed (${res.status})` };
893
+ }
894
+ return handleJson(res);
895
+ },
896
+
897
+ // Persisted KB API config (SiliconFlow OCR key today). Backend never
898
+ // returns the plaintext — only a masked preview + boolean — so the
899
+ // browser can indicate "already saved" without ever holding the secret.
900
+ async getApiConfig(): Promise<{ hasOcrApiKey: boolean; ocrApiKeyPreview: string }> {
901
+ const res = await apiFetch(`${API_BASE}/kb/api-config`);
902
+ if (!res.ok) return { hasOcrApiKey: false, ocrApiKeyPreview: "" };
903
+ return handleJson(res);
904
+ },
905
+
906
+ async saveApiConfig(patch: { ocrApiKey?: string }): Promise<{ ok: boolean; error?: string }> {
907
+ const res = await apiFetch(`${API_BASE}/kb/api-config`, {
908
+ method: "PUT",
909
+ headers: { "content-type": "application/json" },
910
+ body: JSON.stringify(patch),
911
+ });
912
+ if (!res.ok) {
913
+ const body = await res.json().catch(() => ({}));
914
+ return { ok: false, error: body.error || `save failed (${res.status})` };
915
+ }
916
+ return handleJson(res);
917
+ },
918
+
919
+ eventsUrl(): string {
920
+ return `${API_BASE}/kb/events`;
921
+ },
922
+ },
738
923
  };
@@ -5,3 +5,12 @@ export function formatBytes(bytes: number) {
5
5
  const value = bytes / 1024 ** index;
6
6
  return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
7
7
  }
8
+
9
+ /** Compact elapsed formatter: "3.2s" under a minute, "1m 05s" above. */
10
+ export function formatElapsed(ms: number): string {
11
+ const totalSeconds = Math.max(0, ms) / 1000;
12
+ if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
13
+ const minutes = Math.floor(totalSeconds / 60);
14
+ const seconds = Math.floor(totalSeconds % 60);
15
+ return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
16
+ }