@brainpilot/web 0.0.8 → 0.0.10

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.
@@ -102,6 +102,25 @@ const SessionContext = createContext<SessionContextValue | null>(null);
102
102
  // CONTENT / END leaves old long sessions looking empty.
103
103
  const HISTORY_REHYDRATE_LIMIT = 0;
104
104
 
105
+ /**
106
+ * #194-B1: merge the full rehydrated history under whatever the live message
107
+ * list already holds. On refresh the SSE ring-buffer tail seeds a few recent
108
+ * messages before history arrives; we must NOT discard the (complete) history
109
+ * just because the list is non-empty. The persisted history is the base; we
110
+ * append only the messages already shown that history doesn't contain (by id) —
111
+ * in-flight optimistic sends, or events newer than the persisted file. Ordering
112
+ * matters: history first (chronological), then the live-only tail.
113
+ */
114
+ export function mergeRehydratedMessages(
115
+ existing: ChatMessage[],
116
+ history: ChatMessage[],
117
+ ): ChatMessage[] {
118
+ if (existing.length === 0) return history;
119
+ const historyIds = new Set(history.map((m) => m.id));
120
+ const extra = existing.filter((m) => !historyIds.has(m.id));
121
+ return [...history, ...extra];
122
+ }
123
+
105
124
  function foldSessionHistory(events: unknown[], sessionId: string): {
106
125
  messages: ChatMessage[];
107
126
  trace: TraceGraph | null;
@@ -293,13 +312,18 @@ export function SessionProvider({ children }: { children: ReactNode }) {
293
312
  hydratedSessionsRef.current.add(sessionId);
294
313
  if (lastUsage) setTokenUsage(lastUsage);
295
314
 
296
- // Only seed the message list if the user hasn't already started typing
297
- // / receiving live SSE for this session in the brief window before
298
- // history arrived (otherwise we'd clobber their in-flight messages).
299
- setMessagesBySession((current) => {
300
- if ((current[sessionId]?.length ?? 0) > 0) return current;
301
- return { ...current, [sessionId]: nextMessages };
302
- });
315
+ // Merge the full history under whatever SSE / optimistic messages have
316
+ // already landed do NOT bail just because the list is non-empty
317
+ // (#194-B1). On refresh the SSE ring-buffer tail arrives first and seeds
318
+ // a few recent messages; the old `length > 0 → skip` guard then dropped
319
+ // the entire rehydrated history, leaving only those few. The persisted
320
+ // history is the complete log, so use it as the base and append only the
321
+ // messages SSE already showed that the history doesn't contain (by id)
322
+ // in-flight optimistic sends, or events newer than the persisted file.
323
+ setMessagesBySession((current) => ({
324
+ ...current,
325
+ [sessionId]: mergeRehydratedMessages(current[sessionId] ?? [], nextMessages),
326
+ }));
303
327
  if (nextTrace) {
304
328
  setTraceBySession((current) =>
305
329
  current[sessionId] ? current : { ...current, [sessionId]: nextTrace! },
@@ -171,6 +171,25 @@ export function reduceMessagesForEvent(existing: ChatMessage[], event: WebSocket
171
171
  // Strip NO-RENDER wrapper used by record_trace "Message Complete" hint
172
172
  delta = delta.replace(/<!--NO-RENDER-->[\s\S]*?<!--\/NO-RENDER-->/g, "");
173
173
  if (!delta) return existing;
174
+ // Orphaned CONTENT (no matching START) — recover gracefully instead of
175
+ // dropping it. This happens when a demo bundle was exported from a
176
+ // tail-sliced history: the leading START of the earliest messages is gone,
177
+ // and a plain `.map` here would no-op, silently swallowing the opening
178
+ // replies. Synthesize the message so the content still renders.
179
+ if (!existing.some((m) => m.id === id)) {
180
+ return [
181
+ ...existing,
182
+ {
183
+ id,
184
+ role: "assistant",
185
+ content: delta,
186
+ createdAt: new Date().toISOString(),
187
+ agent,
188
+ streaming: true,
189
+ kind: "text",
190
+ },
191
+ ];
192
+ }
174
193
  return existing.map((m) =>
175
194
  m.id === id ? { ...m, content: (m.content ?? "") + delta } : m,
176
195
  );
@@ -481,7 +481,14 @@ function normalizeStringArray(value: unknown): string[] {
481
481
  }
482
482
 
483
483
  function camelizeKey(key: string): string {
484
- return key.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
484
+ // Preserve a leading-underscore prefix: `_ts` / `_seq` are AG-UI transport
485
+ // metadata whose underscore is significant. Without this guard the regex
486
+ // turns `_ts` into `Ts`, so `normalizeAgUiEvent` strips the timestamp and the
487
+ // demo replay's timeline collapses (every event lands at ms=0). Only internal
488
+ // snake_case boundaries (e.g. `agent_name` → `agentName`) are camelized.
489
+ const lead = key.match(/^_+/)?.[0] ?? "";
490
+ const rest = key.slice(lead.length);
491
+ return lead + rest.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
485
492
  }
486
493
 
487
494
  function camelizeObject(value: unknown): unknown {
@@ -23,6 +23,8 @@ export default defineMessages(
23
23
  "chat.agentsWorking": "{names} 正在工作",
24
24
  "chat.aria.stop": "停止生成",
25
25
  "chat.stop": "停止",
26
+ "chat.runningScripts.count": "{count} 个脚本正在运行",
27
+ "chat.runningScripts.pending": "参数传输中…",
26
28
  "chat.aria.newPrompt": "新的研究提问",
27
29
  "chat.srAsk": "提出一个研究问题",
28
30
  "chat.placeholder": "向 MAS 提问。输入 @ 使用插件或提及文件",
@@ -86,6 +88,8 @@ export default defineMessages(
86
88
  "chat.agentsWorking": "{names} are working",
87
89
  "chat.aria.stop": "Stop generating",
88
90
  "chat.stop": "stop",
91
+ "chat.runningScripts.count": "{count} script(s) running",
92
+ "chat.runningScripts.pending": "receiving args…",
89
93
  "chat.aria.newPrompt": "New research prompt",
90
94
  "chat.srAsk": "Ask a research question",
91
95
  "chat.placeholder": "Ask MAS. Type @ to use plugins or mention files",
@@ -17,6 +17,7 @@ export default defineMessages(
17
17
  "files.aria.tree": "文件树",
18
18
  "files.selectForDownload": "选择 {name} 以下载",
19
19
  "files.error.notRunning": "Sandbox 未运行,无法读取文件。",
20
+ "files.error.loadFailed": "加载文件列表失败",
20
21
  "files.error.downloadFailed": "下载文件失败",
21
22
  "files.error.refreshFailed": "刷新文件失败",
22
23
  "files.error.previewFailed": "无法预览文件",
@@ -56,6 +57,7 @@ export default defineMessages(
56
57
  "files.aria.tree": "File tree",
57
58
  "files.selectForDownload": "Select {name} for download",
58
59
  "files.error.notRunning": "Sandbox is not running; cannot read files.",
60
+ "files.error.loadFailed": "Failed to load file list",
59
61
  "files.error.downloadFailed": "Failed to download file",
60
62
  "files.error.refreshFailed": "Failed to refresh files",
61
63
  "files.error.previewFailed": "Unable to preview file",
@@ -12,7 +12,34 @@ export default defineMessages(
12
12
  "settings.tab.account": "账户",
13
13
  "settings.tab.providers": "服务商",
14
14
  "settings.tab.mcp": "MCP",
15
+ "settings.tab.knowledgeBase": "知识库",
15
16
  "settings.tab.preferences": "偏好",
17
+ // knowledge base
18
+ "settings.kb.title": "本地知识库",
19
+ "settings.kb.desc": "将 PDF 放入 KnowledgeBase/source/pdf/,然后构建本地 RAG 索引。嵌入模型在本机运行;仅 OCR 与元数据抽取阶段需要联网。",
20
+ "settings.kb.ocrKey": "SiliconFlow OCR API Key",
21
+ "settings.kb.metaKey": "元数据抽取 API Key",
22
+ "settings.kb.metaBaseUrl": "元数据抽取 Base URL",
23
+ "settings.kb.metaModel": "元数据抽取模型",
24
+ "settings.kb.reuseAgentKey": "复用 agent 当前的 LLM key 进行元数据抽取",
25
+ "settings.kb.stages": "要运行的阶段",
26
+ "settings.kb.start": "构建知识库",
27
+ "settings.kb.cancel": "取消构建",
28
+ "settings.kb.progress": "阶段进度",
29
+ "settings.kb.log": "实时日志",
30
+ "settings.kb.logEmpty": "尚无构建事件 — 点击「构建知识库」开始。",
31
+ "settings.kb.error.missingOcrKey": "缺少 SiliconFlow OCR API Key",
32
+ "settings.kb.error.missingMetaKey": "缺少元数据抽取 API Key(或勾选「复用 agent LLM key」)",
33
+ "settings.kb.env.title": "Python 运行环境",
34
+ "settings.kb.env.venvMissing": "尚未为 KnowledgeBase 创建 Python 虚拟环境。点下面按钮一键创建:",
35
+ "settings.kb.env.setupButton": "一键配置 Python 环境",
36
+ "settings.kb.env.setupHint": "在 KnowledgeBase/.venv 里创建 venv 并安装 requirements.txt 中的所有依赖(约 2-5 分钟,需联网下载 PyTorch / FlagEmbedding)",
37
+ "settings.kb.env.reinstallButton": "重建虚拟环境",
38
+ "settings.kb.env.reinstallHint": "删除现有 .venv 并从头重装所有依赖",
39
+ "settings.kb.env.cliFallback": "或者也可以在终端手动执行",
40
+ "settings.kb.env.venvHint": "venv 创建后无需任何额外环境变量配置 —— 构建按钮会自动发现它。如果你想用别的解释器,把绝对路径写到 BP_KB_PYTHON 环境变量里再启动 BrainPilot。",
41
+ "settings.kb.env.needSetupFirst": "请先点击「一键配置 Python 环境」",
42
+ "settings.kb.env.busy": "Python 环境配置正在进行中…",
16
43
  // account
17
44
  "settings.account.title": "账户",
18
45
  "settings.account.username": "用户名",
@@ -76,6 +103,8 @@ export default defineMessages(
76
103
  "settings.providerForm.models": "模型",
77
104
  "settings.providerForm.addModel": "添加模型",
78
105
  "settings.providerForm.removeModel": "移除模型",
106
+ "settings.providerForm.modelsHint":
107
+ "示例模型仅作占位,保存前请改成你的网关支持的模型,否则发消息时才会报模型不可用。",
79
108
  "settings.providerForm.useColor": "使用颜色 {color}",
80
109
  "settings.providerForm.color": "颜色",
81
110
  "settings.providerForm.cancel": "取消",
@@ -107,7 +136,33 @@ export default defineMessages(
107
136
  "settings.tab.account": "Account",
108
137
  "settings.tab.providers": "Providers",
109
138
  "settings.tab.mcp": "MCP",
139
+ "settings.tab.knowledgeBase": "Knowledge Base",
110
140
  "settings.tab.preferences": "Preferences",
141
+ "settings.kb.title": "Local Knowledge Base",
142
+ "settings.kb.desc": "Drop PDFs into KnowledgeBase/source/pdf/, then build a local RAG index. Embedding models run on your machine; only the OCR and metadata-extraction stages call out.",
143
+ "settings.kb.ocrKey": "SiliconFlow OCR API key",
144
+ "settings.kb.metaKey": "Metadata-extract API key",
145
+ "settings.kb.metaBaseUrl": "Metadata-extract base URL",
146
+ "settings.kb.metaModel": "Metadata-extract model",
147
+ "settings.kb.reuseAgentKey": "Reuse the agent's active LLM key for metadata extraction",
148
+ "settings.kb.stages": "Stages to run",
149
+ "settings.kb.start": "Build Knowledge Base",
150
+ "settings.kb.cancel": "Cancel build",
151
+ "settings.kb.progress": "Stage progress",
152
+ "settings.kb.log": "Live log",
153
+ "settings.kb.logEmpty": "No build events yet — press \"Build Knowledge Base\" to start.",
154
+ "settings.kb.error.missingOcrKey": "SiliconFlow OCR API key required",
155
+ "settings.kb.error.missingMetaKey": "Metadata-extract API key required (or check \"reuse agent LLM key\")",
156
+ "settings.kb.env.title": "Python environment",
157
+ "settings.kb.env.venvMissing": "No Python venv set up for KnowledgeBase yet. Click below to create one:",
158
+ "settings.kb.env.setupButton": "Set up Python environment",
159
+ "settings.kb.env.setupHint": "Creates KnowledgeBase/.venv and installs everything in requirements.txt (2-5 min; downloads PyTorch + FlagEmbedding)",
160
+ "settings.kb.env.reinstallButton": "Reinstall venv",
161
+ "settings.kb.env.reinstallHint": "Remove the existing .venv and reinstall every dependency from scratch",
162
+ "settings.kb.env.cliFallback": "Or run this in a terminal yourself",
163
+ "settings.kb.env.venvHint": "Once the venv exists no env vars are needed — the build button auto-detects it. To use a different interpreter, set BP_KB_PYTHON to its absolute path before launching BrainPilot.",
164
+ "settings.kb.env.needSetupFirst": "Click \"Set up Python environment\" first",
165
+ "settings.kb.env.busy": "Python environment setup is currently running…",
111
166
  "settings.account.title": "Account",
112
167
  "settings.account.username": "Username",
113
168
  "settings.account.userId": "User ID",
@@ -166,6 +221,8 @@ export default defineMessages(
166
221
  "settings.providerForm.models": "Models",
167
222
  "settings.providerForm.addModel": "Add model",
168
223
  "settings.providerForm.removeModel": "Remove model",
224
+ "settings.providerForm.modelsHint":
225
+ "The example model is only a placeholder — replace it with one your gateway supports before saving, or the model will be rejected when you send a message.",
169
226
  "settings.providerForm.useColor": "Use color {color}",
170
227
  "settings.providerForm.color": "Color",
171
228
  "settings.providerForm.cancel": "Cancel",
@@ -1683,7 +1683,10 @@ button {
1683
1683
  .prompt-home--active .prompt-home__inner {
1684
1684
  width: min(1040px, 100%);
1685
1685
  height: 100%;
1686
- grid-template-rows: minmax(0, 1fr) auto auto;
1686
+ /* Rows: message stream (fills) | toast | running-scripts panel | composer.
1687
+ * The two middle rows are optional and collapse to zero when their children
1688
+ * are unmounted (empty child on `auto` row occupies no space). */
1689
+ grid-template-rows: minmax(0, 1fr) auto auto auto;
1687
1690
  align-content: end;
1688
1691
  gap: 16px;
1689
1692
  justify-items: stretch;
@@ -1965,6 +1968,134 @@ button {
1965
1968
  animation: pulse 1.5s infinite;
1966
1969
  }
1967
1970
 
1971
+ /* Running-scripts panel — sits directly above the composer while any bash
1972
+ * tool call is streaming. Collapses/expands with a native <details>. Visual
1973
+ * weight matches .agent-running-toast so the two stack cleanly. */
1974
+ .running-scripts {
1975
+ justify-self: stretch;
1976
+ width: 100%;
1977
+ margin: 0 0 6px;
1978
+ border: 1px solid var(--color-border);
1979
+ border-radius: 12px;
1980
+ background: var(--color-surface-soft);
1981
+ color: var(--color-text);
1982
+ font-size: 13px;
1983
+ overflow: hidden;
1984
+ animation: fadeSlideIn var(--ease-layout) both;
1985
+ }
1986
+ .running-scripts > details > summary {
1987
+ display: flex;
1988
+ align-items: center;
1989
+ gap: 8px;
1990
+ padding: 8px 10px 8px 14px;
1991
+ cursor: pointer;
1992
+ list-style: none;
1993
+ user-select: none;
1994
+ }
1995
+ .running-scripts > details > summary::-webkit-details-marker {
1996
+ display: none;
1997
+ }
1998
+ .running-scripts__dot {
1999
+ display: inline-block;
2000
+ width: 8px;
2001
+ height: 8px;
2002
+ border-radius: 50%;
2003
+ background: var(--color-info);
2004
+ animation: pulse 1.5s infinite;
2005
+ flex: 0 0 auto;
2006
+ }
2007
+ .running-scripts__chevron {
2008
+ transition: transform var(--ease-standard);
2009
+ flex: 0 0 auto;
2010
+ color: var(--color-text-subtle);
2011
+ }
2012
+ .running-scripts > details:not([open]) .running-scripts__chevron {
2013
+ transform: rotate(-90deg);
2014
+ }
2015
+ .running-scripts__label {
2016
+ flex: 1 1 auto;
2017
+ min-width: 0;
2018
+ overflow: hidden;
2019
+ text-overflow: ellipsis;
2020
+ white-space: nowrap;
2021
+ }
2022
+ .running-scripts__stop {
2023
+ display: inline-flex;
2024
+ align-items: center;
2025
+ gap: 4px;
2026
+ flex: 0 0 auto;
2027
+ height: 22px;
2028
+ padding: 0 9px;
2029
+ border: 1px solid var(--color-border);
2030
+ border-radius: 999px;
2031
+ background: var(--color-surface);
2032
+ color: var(--color-text-muted);
2033
+ font-size: 11px;
2034
+ cursor: pointer;
2035
+ transition:
2036
+ background var(--ease-standard),
2037
+ border-color var(--ease-standard),
2038
+ color var(--ease-standard);
2039
+ }
2040
+ .running-scripts__stop:hover {
2041
+ background: var(--color-danger-soft);
2042
+ border-color: color-mix(in srgb, var(--color-danger) 40%, var(--color-border));
2043
+ color: var(--color-danger);
2044
+ }
2045
+ .running-scripts__list {
2046
+ list-style: none;
2047
+ margin: 0;
2048
+ padding: 0 10px 10px;
2049
+ display: flex;
2050
+ flex-direction: column;
2051
+ gap: 8px;
2052
+ }
2053
+ .running-scripts__item {
2054
+ border: 1px solid var(--color-border);
2055
+ border-radius: 10px;
2056
+ background: var(--color-surface);
2057
+ padding: 8px 10px;
2058
+ display: flex;
2059
+ flex-direction: column;
2060
+ gap: 6px;
2061
+ }
2062
+ .running-scripts__item-head {
2063
+ display: flex;
2064
+ align-items: center;
2065
+ gap: 8px;
2066
+ font-size: 12px;
2067
+ color: var(--color-text-subtle);
2068
+ }
2069
+ .running-scripts__item-agent {
2070
+ padding: 1px 8px;
2071
+ border-radius: 999px;
2072
+ background: var(--color-hover);
2073
+ color: var(--color-text);
2074
+ font-size: 11px;
2075
+ }
2076
+ .running-scripts__item-name {
2077
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
2078
+ color: var(--color-text);
2079
+ }
2080
+ .running-scripts__item-elapsed {
2081
+ margin-left: auto;
2082
+ font-variant-numeric: tabular-nums;
2083
+ }
2084
+ .running-scripts__cmd {
2085
+ margin: 0;
2086
+ padding: 8px 10px;
2087
+ border-radius: 8px;
2088
+ background: var(--color-surface-raised);
2089
+ color: var(--color-text);
2090
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
2091
+ font-size: 12px;
2092
+ line-height: 1.45;
2093
+ white-space: pre-wrap;
2094
+ word-break: break-word;
2095
+ overflow-x: auto;
2096
+ max-height: 180px;
2097
+ }
2098
+
1968
2099
  .command-picker {
1969
2100
  position: relative;
1970
2101
  }
@@ -4755,6 +4886,13 @@ button {
4755
4886
  gap: 8px;
4756
4887
  }
4757
4888
 
4889
+ .provider-form__models-hint {
4890
+ margin: 8px 0 0;
4891
+ color: var(--color-text-subtle);
4892
+ font-size: 11px;
4893
+ line-height: 1.45;
4894
+ }
4895
+
4758
4896
  .provider-model-row {
4759
4897
  display: grid !important;
4760
4898
  grid-template-columns: minmax(0, 1fr) 40px;
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})`;
@@ -123,6 +154,20 @@ export const api = {
123
154
  return handleJson(await apiFetch(`${API_BASE}/version`));
124
155
  },
125
156
 
157
+ // #156: real on-disk paths for the Files panel (local mode only). Hosted
158
+ // backends return `{ localMode: false }` with no host path. Best-effort:
159
+ // any failure resolves to a non-local shape so callers fall back cleanly.
160
+ async getInfo(): Promise<{ localMode: boolean; dataDir?: string; workspacesRoot?: string }> {
161
+ if (runtimeConfig.useMockBackend) {
162
+ return { localMode: false };
163
+ }
164
+ try {
165
+ return await handleJson(await apiFetch(`${API_BASE}/info`));
166
+ } catch {
167
+ return { localMode: false };
168
+ }
169
+ },
170
+
126
171
  auth: {
127
172
  async me(): Promise<User> {
128
173
  if (runtimeConfig.useMockBackend) {
@@ -505,7 +550,14 @@ export const api = {
505
550
  `${API_BASE}/sessions/${sessionId}/history${qs}`,
506
551
  { headers: authHeaders() },
507
552
  );
508
- 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
+ }
509
561
  const raw = (await res.json().catch(() => null)) as
510
562
  | { events?: unknown[]; total?: number; truncated?: boolean }
511
563
  | null;
@@ -721,4 +773,88 @@ export const api = {
721
773
  return normalizeProviderProfile(raw as Parameters<typeof normalizeProviderProfile>[0]);
722
774
  },
723
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
+ }): Promise<{ ok: boolean; startedAt?: number; error?: string }> {
794
+ const res = await apiFetch(`${API_BASE}/kb/build`, {
795
+ method: "POST",
796
+ headers: { "content-type": "application/json" },
797
+ body: JSON.stringify(opts),
798
+ });
799
+ if (!res.ok) {
800
+ const body = await res.json().catch(() => ({}));
801
+ return { ok: false, error: body.error || `kb build failed (${res.status})` };
802
+ }
803
+ return handleJson(res);
804
+ },
805
+
806
+ async status(): Promise<{
807
+ active: boolean;
808
+ startedAt: number | null;
809
+ finishedAt: number | null;
810
+ exitCode: number | null | undefined;
811
+ error?: string;
812
+ recentEvents: Array<{
813
+ ts: string;
814
+ stage: string;
815
+ event: string;
816
+ msg: string;
817
+ [k: string]: unknown;
818
+ }>;
819
+ environment: {
820
+ python: string;
821
+ pythonIsVenv: boolean;
822
+ venvExists: boolean;
823
+ expectedVenvPath: string;
824
+ scriptsPresent: boolean;
825
+ kbRoot: string;
826
+ };
827
+ }> {
828
+ return handleJson(await apiFetch(`${API_BASE}/kb/status`));
829
+ },
830
+
831
+ async cancel(): Promise<{ ok: boolean; message?: string }> {
832
+ const res = await apiFetch(`${API_BASE}/kb/cancel`, { method: "POST" });
833
+ return res.json().catch(() => ({ ok: false }));
834
+ },
835
+
836
+ // Bootstrap the Python venv (KnowledgeBase/.venv) before the first
837
+ // build can run. Streams progress on the same SSE channel as `build`,
838
+ // so the panel only needs one EventSource subscription.
839
+ async setupEnv(opts: {
840
+ python?: string;
841
+ reinstall?: boolean;
842
+ kbRoot?: string;
843
+ } = {}): Promise<{ ok: boolean; startedAt?: number; error?: string }> {
844
+ const res = await apiFetch(`${API_BASE}/kb/setup-env`, {
845
+ method: "POST",
846
+ headers: { "content-type": "application/json" },
847
+ body: JSON.stringify(opts),
848
+ });
849
+ if (!res.ok) {
850
+ const body = await res.json().catch(() => ({}));
851
+ return { ok: false, error: body.error || `setup-env failed (${res.status})` };
852
+ }
853
+ return handleJson(res);
854
+ },
855
+
856
+ eventsUrl(): string {
857
+ return `${API_BASE}/kb/events`;
858
+ },
859
+ },
724
860
  };
@@ -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
+ }