@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.
- package/dist/assets/index-D63mUJxx.js +450 -0
- package/dist/assets/index-D8J9Cnup.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/__tests__/api.test.ts +90 -1
- package/src/__tests__/demoTruncatedExport.test.ts +104 -0
- package/src/__tests__/rehydrateMerge.test.ts +40 -0
- package/src/__tests__/runningScripts.test.ts +139 -0
- package/src/__tests__/timelineBounds.test.ts +51 -0
- package/src/components/chat/MessageStream.tsx +1 -11
- package/src/components/chat/PromptComposer.tsx +118 -16
- package/src/components/chat/RunningScriptsPanel.tsx +118 -0
- package/src/components/chat/runningScripts.ts +88 -0
- package/src/components/demo/demoBundle.ts +8 -3
- package/src/components/files/FileSidebar.tsx +82 -11
- package/src/components/session/AgentNetwork.tsx +1 -0
- package/src/components/session/TimelineTab.tsx +39 -9
- package/src/components/settings/KnowledgeBasePanel.tsx +594 -0
- package/src/components/settings/SettingsDialog.tsx +12 -4
- package/src/contexts/SessionContext.tsx +31 -7
- package/src/contexts/messageReducer.ts +19 -0
- package/src/contracts/backend.ts +8 -1
- package/src/i18n/messages/chat.ts +4 -0
- package/src/i18n/messages/files.ts +2 -0
- package/src/i18n/messages/settings.ts +57 -0
- package/src/styles/global.css +139 -1
- package/src/utils/api.ts +139 -3
- package/src/utils/format.ts +9 -0
- package/dist/assets/index-162Pskp8.js +0 -438
- package/dist/assets/index-DWOsU22G.css +0 -1
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KnowledgeBase build panel — surfaced as a tab inside the Settings dialog.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Collect the two API keys the pipeline needs (SiliconFlow for OCR,
|
|
6
|
+
* OpenAI-compatible for metadata extraction). Models are downloaded
|
|
7
|
+
* and run locally — no key required there.
|
|
8
|
+
* - Let the user choose which stages to run (default: all four).
|
|
9
|
+
* - POST /api/kb/build to start a run; show a live log streamed from
|
|
10
|
+
* /api/kb/events (SSE); update a stage-by-stage progress strip.
|
|
11
|
+
* - Surface errors prominently — the user can't easily debug a Python
|
|
12
|
+
* subprocess in production, so we strive to print the actual python
|
|
13
|
+
* stderr / stage / msg.
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
16
|
+
import { Database, Loader2, Play, RefreshCw, Square, Wrench } from "lucide-react";
|
|
17
|
+
import { useT } from "../../i18n/useT";
|
|
18
|
+
import { api } from "../../utils/api";
|
|
19
|
+
|
|
20
|
+
type Stage = "ocr" | "extract" | "chunk" | "vectorize";
|
|
21
|
+
|
|
22
|
+
interface BuildEvent {
|
|
23
|
+
ts?: string;
|
|
24
|
+
stage: string;
|
|
25
|
+
event: string;
|
|
26
|
+
msg: string;
|
|
27
|
+
// Stage-specific extras (done/total/percent/...). Kept as `unknown` here;
|
|
28
|
+
// the component only reads numeric fields it knows about.
|
|
29
|
+
[k: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const STAGES: Stage[] = ["ocr", "extract", "chunk", "vectorize"];
|
|
33
|
+
const STAGE_LABELS: Record<Stage, string> = {
|
|
34
|
+
ocr: "OCR",
|
|
35
|
+
extract: "Metadata",
|
|
36
|
+
chunk: "Chunking",
|
|
37
|
+
vectorize: "Vectorize",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface StageState {
|
|
41
|
+
status: "pending" | "running" | "done" | "error";
|
|
42
|
+
percent: number;
|
|
43
|
+
msg: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const INITIAL_STAGE_STATE: Record<Stage, StageState> = {
|
|
47
|
+
ocr: { status: "pending", percent: 0, msg: "" },
|
|
48
|
+
extract: { status: "pending", percent: 0, msg: "" },
|
|
49
|
+
chunk: { status: "pending", percent: 0, msg: "" },
|
|
50
|
+
vectorize: { status: "pending", percent: 0, msg: "" },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function isKnownStage(stage: string): stage is Stage {
|
|
54
|
+
return (STAGES as string[]).includes(stage);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function KnowledgeBasePanel() {
|
|
58
|
+
const t = useT();
|
|
59
|
+
const [ocrApiKey, setOcrApiKey] = useState("");
|
|
60
|
+
const [metaApiKey, setMetaApiKey] = useState("");
|
|
61
|
+
const [metaBaseUrl, setMetaBaseUrl] = useState("");
|
|
62
|
+
const [metaModel, setMetaModel] = useState("");
|
|
63
|
+
const [reuseAgentKey, setReuseAgentKey] = useState(true);
|
|
64
|
+
const [skip, setSkip] = useState<Record<Stage, boolean>>({
|
|
65
|
+
ocr: false,
|
|
66
|
+
extract: false,
|
|
67
|
+
chunk: false,
|
|
68
|
+
vectorize: false,
|
|
69
|
+
});
|
|
70
|
+
const [events, setEvents] = useState<BuildEvent[]>([]);
|
|
71
|
+
const [stages, setStages] = useState<Record<Stage, StageState>>(INITIAL_STAGE_STATE);
|
|
72
|
+
const [active, setActive] = useState(false);
|
|
73
|
+
/** 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);
|
|
76
|
+
const [error, setError] = useState<string | null>(null);
|
|
77
|
+
const [env, setEnv] = useState<{
|
|
78
|
+
python: string;
|
|
79
|
+
pythonIsVenv: boolean;
|
|
80
|
+
venvExists: boolean;
|
|
81
|
+
expectedVenvPath: string;
|
|
82
|
+
scriptsPresent: boolean;
|
|
83
|
+
kbRoot: string;
|
|
84
|
+
} | null>(null);
|
|
85
|
+
const [envBusy, setEnvBusy] = useState(false);
|
|
86
|
+
const logRef = useRef<HTMLDivElement | null>(null);
|
|
87
|
+
const sseRef = useRef<EventSource | null>(null);
|
|
88
|
+
|
|
89
|
+
// Hydrate from server: if a build is already running (e.g. user reopened
|
|
90
|
+
// the dialog mid-build), show its current status + replay recent events.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
let cancelled = false;
|
|
93
|
+
void (async () => {
|
|
94
|
+
try {
|
|
95
|
+
const status = await api.kb.status();
|
|
96
|
+
if (cancelled) return;
|
|
97
|
+
if (status.environment) setEnv(status.environment);
|
|
98
|
+
if (status.recentEvents?.length) {
|
|
99
|
+
setEvents(status.recentEvents);
|
|
100
|
+
replayStages(status.recentEvents);
|
|
101
|
+
}
|
|
102
|
+
if (status.active) {
|
|
103
|
+
// Guess which job is running from the most recent event with a
|
|
104
|
+
// known stage. setup-env and build share the same RUN slot, so
|
|
105
|
+
// we can't ask the server directly — but the last event's
|
|
106
|
+
// `stage` is reliable enough for the UI banner.
|
|
107
|
+
const recent = status.recentEvents ?? [];
|
|
108
|
+
const last = [...recent].reverse().find(
|
|
109
|
+
(ev) => ev.stage === "setup-env" || ev.stage === "build",
|
|
110
|
+
);
|
|
111
|
+
if (last?.stage === "setup-env") {
|
|
112
|
+
setEnvBusy(true);
|
|
113
|
+
setActiveJob("setup-env");
|
|
114
|
+
} else {
|
|
115
|
+
setActive(true);
|
|
116
|
+
setActiveJob("build");
|
|
117
|
+
}
|
|
118
|
+
openSse();
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
/* status fetch is best-effort */
|
|
122
|
+
}
|
|
123
|
+
})();
|
|
124
|
+
return () => {
|
|
125
|
+
cancelled = true;
|
|
126
|
+
closeSse();
|
|
127
|
+
};
|
|
128
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
// Auto-scroll the log to the bottom on new events.
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const el = logRef.current;
|
|
134
|
+
if (!el) return;
|
|
135
|
+
el.scrollTop = el.scrollHeight;
|
|
136
|
+
}, [events.length]);
|
|
137
|
+
|
|
138
|
+
function replayStages(history: BuildEvent[]) {
|
|
139
|
+
const next: Record<Stage, StageState> = { ...INITIAL_STAGE_STATE };
|
|
140
|
+
for (const ev of history) {
|
|
141
|
+
applyEventToStages(next, ev);
|
|
142
|
+
}
|
|
143
|
+
setStages(next);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function applyEventToStages(
|
|
147
|
+
target: Record<Stage, StageState>,
|
|
148
|
+
ev: BuildEvent,
|
|
149
|
+
) {
|
|
150
|
+
if (!isKnownStage(ev.stage)) return;
|
|
151
|
+
const cur = target[ev.stage];
|
|
152
|
+
if (ev.event === "progress") {
|
|
153
|
+
const pct = typeof ev.percent === "number" ? ev.percent : cur.percent;
|
|
154
|
+
target[ev.stage] = { status: "running", percent: pct, msg: ev.msg };
|
|
155
|
+
} else if (ev.event === "info") {
|
|
156
|
+
target[ev.stage] = { ...cur, status: "running", msg: ev.msg };
|
|
157
|
+
} else if (ev.event === "done") {
|
|
158
|
+
target[ev.stage] = { status: "done", percent: 100, msg: ev.msg };
|
|
159
|
+
} else if (ev.event === "error") {
|
|
160
|
+
target[ev.stage] = { ...cur, status: "error", msg: ev.msg };
|
|
161
|
+
} else if (ev.event === "warn") {
|
|
162
|
+
target[ev.stage] = { ...cur, msg: ev.msg };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function refreshEnv() {
|
|
167
|
+
try {
|
|
168
|
+
const s = await api.kb.status();
|
|
169
|
+
if (s.environment) setEnv(s.environment);
|
|
170
|
+
} catch {
|
|
171
|
+
/* best-effort */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function pushEvent(ev: BuildEvent) {
|
|
176
|
+
setEvents((prev) => {
|
|
177
|
+
const next = prev.concat(ev);
|
|
178
|
+
// Cap log at ~2k lines so a long OCR run doesn't drag the DOM.
|
|
179
|
+
return next.length > 2000 ? next.slice(next.length - 2000) : next;
|
|
180
|
+
});
|
|
181
|
+
setStages((prev) => {
|
|
182
|
+
const next = { ...prev };
|
|
183
|
+
applyEventToStages(next, ev);
|
|
184
|
+
return next;
|
|
185
|
+
});
|
|
186
|
+
// Job-level finish signals: clear active flags.
|
|
187
|
+
if (ev.stage === "build" && (ev.event === "done" || ev.event === "error")) {
|
|
188
|
+
setActive(false);
|
|
189
|
+
setActiveJob(null);
|
|
190
|
+
}
|
|
191
|
+
if (ev.stage === "setup-env" && (ev.event === "done" || ev.event === "error")) {
|
|
192
|
+
setEnvBusy(false);
|
|
193
|
+
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
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function openSse() {
|
|
201
|
+
if (sseRef.current) return;
|
|
202
|
+
const es = new EventSource(api.kb.eventsUrl());
|
|
203
|
+
sseRef.current = es;
|
|
204
|
+
es.onmessage = (e) => {
|
|
205
|
+
try {
|
|
206
|
+
const ev = JSON.parse(e.data) as BuildEvent;
|
|
207
|
+
if (ev.event === "stream-end" || ev.event === "idle") {
|
|
208
|
+
es.close();
|
|
209
|
+
sseRef.current = null;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
pushEvent(ev);
|
|
213
|
+
} catch {
|
|
214
|
+
/* swallow malformed event */
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
es.onerror = () => {
|
|
218
|
+
// Browser auto-retries; nothing to do.
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function closeSse() {
|
|
223
|
+
if (sseRef.current) {
|
|
224
|
+
sseRef.current.close();
|
|
225
|
+
sseRef.current = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const formInvalid = useMemo(() => {
|
|
230
|
+
if (skip.ocr === false && !ocrApiKey.trim()) {
|
|
231
|
+
return t("settings.kb.error.missingOcrKey");
|
|
232
|
+
}
|
|
233
|
+
if (skip.extract === false) {
|
|
234
|
+
if (!metaApiKey.trim() && !reuseAgentKey) {
|
|
235
|
+
return t("settings.kb.error.missingMetaKey");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}, [ocrApiKey, metaApiKey, reuseAgentKey, skip.ocr, skip.extract, t]);
|
|
240
|
+
|
|
241
|
+
async function startBuild() {
|
|
242
|
+
setError(null);
|
|
243
|
+
if (formInvalid) {
|
|
244
|
+
setError(formInvalid);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// Reset for a fresh run.
|
|
248
|
+
setEvents([]);
|
|
249
|
+
setStages(INITIAL_STAGE_STATE);
|
|
250
|
+
// When "reuse agent key" is on we omit metaKey from the request and let
|
|
251
|
+
// the build_kb.py side resolve it from META_LLM_API_KEY / API_config.json.
|
|
252
|
+
// Browser code can't read the masked provider key over the API, so we
|
|
253
|
+
// intentionally don't try.
|
|
254
|
+
const metaKey: string | undefined = reuseAgentKey ? undefined : (metaApiKey.trim() || undefined);
|
|
255
|
+
const skipList = STAGES.filter((s) => skip[s]);
|
|
256
|
+
try {
|
|
257
|
+
const r = await api.kb.build({
|
|
258
|
+
ocrApiKey: ocrApiKey.trim() || undefined,
|
|
259
|
+
metaApiKey: metaKey || undefined,
|
|
260
|
+
metaBaseUrl: metaBaseUrl.trim() || undefined,
|
|
261
|
+
metaModel: metaModel.trim() || undefined,
|
|
262
|
+
skip: skipList.length ? skipList : undefined,
|
|
263
|
+
});
|
|
264
|
+
if (!r.ok) {
|
|
265
|
+
setError(r.error || "build start failed");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
setActive(true);
|
|
269
|
+
setActiveJob("build");
|
|
270
|
+
openSse();
|
|
271
|
+
} catch (err) {
|
|
272
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function cancelBuild() {
|
|
277
|
+
try {
|
|
278
|
+
await api.kb.cancel();
|
|
279
|
+
} catch (err) {
|
|
280
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function startEnvSetup(reinstall: boolean) {
|
|
285
|
+
setError(null);
|
|
286
|
+
// Keep the build log in place if the user already ran one — env setup
|
|
287
|
+
// events get prefixed with [setup-env:...] so they stay visually
|
|
288
|
+
// distinct from prior [build:...] / [ocr:...] lines.
|
|
289
|
+
setEnvBusy(true);
|
|
290
|
+
setActiveJob("setup-env");
|
|
291
|
+
try {
|
|
292
|
+
const r = await api.kb.setupEnv({ reinstall });
|
|
293
|
+
if (!r.ok) {
|
|
294
|
+
setEnvBusy(false);
|
|
295
|
+
setActiveJob(null);
|
|
296
|
+
setError(r.error || "setup-env start failed");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
openSse();
|
|
300
|
+
} catch (err) {
|
|
301
|
+
setEnvBusy(false);
|
|
302
|
+
setActiveJob(null);
|
|
303
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<section className="settings-section">
|
|
309
|
+
<div className="settings-section__header">
|
|
310
|
+
<div>
|
|
311
|
+
<h3 style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
312
|
+
<Database size={18} aria-hidden />
|
|
313
|
+
{t("settings.kb.title")}
|
|
314
|
+
</h3>
|
|
315
|
+
<p>
|
|
316
|
+
{t("settings.kb.desc")}
|
|
317
|
+
</p>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
{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" : ""}
|
|
341
|
+
</div>
|
|
342
|
+
{!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" }}>
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
className="settings-button"
|
|
349
|
+
onClick={() => void startEnvSetup(false)}
|
|
350
|
+
disabled={envBusy || activeJob !== null}
|
|
351
|
+
title={t("settings.kb.env.setupHint")}
|
|
352
|
+
>
|
|
353
|
+
<Wrench size={14} style={{ marginRight: 4 }} aria-hidden />
|
|
354
|
+
{t("settings.kb.env.setupButton")}
|
|
355
|
+
</button>
|
|
356
|
+
{envBusy ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
|
|
357
|
+
</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`}
|
|
374
|
+
</pre>
|
|
375
|
+
<div style={{ color: "#64748b", marginTop: 4 }}>
|
|
376
|
+
{t("settings.kb.env.venvHint")}
|
|
377
|
+
</div>
|
|
378
|
+
</details>
|
|
379
|
+
</div>
|
|
380
|
+
) : (
|
|
381
|
+
<div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8 }}>
|
|
382
|
+
<button
|
|
383
|
+
type="button"
|
|
384
|
+
className="settings-button"
|
|
385
|
+
onClick={() => void startEnvSetup(true)}
|
|
386
|
+
disabled={envBusy || activeJob !== null}
|
|
387
|
+
title={t("settings.kb.env.reinstallHint")}
|
|
388
|
+
style={{ background: "transparent", border: "1px solid #cbd5e1", color: "#334155" }}
|
|
389
|
+
>
|
|
390
|
+
<RefreshCw size={14} style={{ marginRight: 4 }} aria-hidden />
|
|
391
|
+
{t("settings.kb.env.reinstallButton")}
|
|
392
|
+
</button>
|
|
393
|
+
{envBusy ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
397
|
+
) : null}
|
|
398
|
+
|
|
399
|
+
<div className="settings-field-grid">
|
|
400
|
+
<label className="settings-field">
|
|
401
|
+
<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
|
+
/>
|
|
410
|
+
</label>
|
|
411
|
+
|
|
412
|
+
<label className="settings-check">
|
|
413
|
+
<input
|
|
414
|
+
type="checkbox"
|
|
415
|
+
checked={reuseAgentKey}
|
|
416
|
+
onChange={(e) => setReuseAgentKey(e.target.checked)}
|
|
417
|
+
disabled={active}
|
|
418
|
+
/>
|
|
419
|
+
<span>
|
|
420
|
+
{t("settings.kb.reuseAgentKey")}
|
|
421
|
+
</span>
|
|
422
|
+
</label>
|
|
423
|
+
|
|
424
|
+
{!reuseAgentKey ? (
|
|
425
|
+
<>
|
|
426
|
+
<label className="settings-field">
|
|
427
|
+
<span>{t("settings.kb.metaKey")}</span>
|
|
428
|
+
<input
|
|
429
|
+
type="password"
|
|
430
|
+
value={metaApiKey}
|
|
431
|
+
onChange={(e) => setMetaApiKey(e.target.value)}
|
|
432
|
+
placeholder="sk-..."
|
|
433
|
+
autoComplete="off"
|
|
434
|
+
disabled={active || skip.extract}
|
|
435
|
+
/>
|
|
436
|
+
</label>
|
|
437
|
+
<label className="settings-field">
|
|
438
|
+
<span>{t("settings.kb.metaBaseUrl")}</span>
|
|
439
|
+
<input
|
|
440
|
+
type="url"
|
|
441
|
+
value={metaBaseUrl}
|
|
442
|
+
onChange={(e) => setMetaBaseUrl(e.target.value)}
|
|
443
|
+
placeholder="https://api.example.com"
|
|
444
|
+
disabled={active || skip.extract}
|
|
445
|
+
/>
|
|
446
|
+
</label>
|
|
447
|
+
<label className="settings-field">
|
|
448
|
+
<span>{t("settings.kb.metaModel")}</span>
|
|
449
|
+
<input
|
|
450
|
+
type="text"
|
|
451
|
+
value={metaModel}
|
|
452
|
+
onChange={(e) => setMetaModel(e.target.value)}
|
|
453
|
+
placeholder="deepseek-chat"
|
|
454
|
+
disabled={active || skip.extract}
|
|
455
|
+
/>
|
|
456
|
+
</label>
|
|
457
|
+
</>
|
|
458
|
+
) : null}
|
|
459
|
+
|
|
460
|
+
<fieldset className="settings-field" style={{ border: "none", padding: 0 }}>
|
|
461
|
+
<legend>{t("settings.kb.stages")}</legend>
|
|
462
|
+
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
|
463
|
+
{STAGES.map((s) => (
|
|
464
|
+
<label key={s} className="settings-check" style={{ margin: 0 }}>
|
|
465
|
+
<input
|
|
466
|
+
type="checkbox"
|
|
467
|
+
checked={!skip[s]}
|
|
468
|
+
disabled={active}
|
|
469
|
+
onChange={(e) =>
|
|
470
|
+
setSkip((prev) => ({ ...prev, [s]: !e.target.checked }))
|
|
471
|
+
}
|
|
472
|
+
/>
|
|
473
|
+
<span>{STAGE_LABELS[s]}</span>
|
|
474
|
+
</label>
|
|
475
|
+
))}
|
|
476
|
+
</div>
|
|
477
|
+
</fieldset>
|
|
478
|
+
</div>
|
|
479
|
+
|
|
480
|
+
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
|
|
481
|
+
{!active ? (
|
|
482
|
+
<button
|
|
483
|
+
className="settings-button"
|
|
484
|
+
type="button"
|
|
485
|
+
onClick={() => void startBuild()}
|
|
486
|
+
disabled={!!formInvalid || envBusy || (env != null && !env.venvExists)}
|
|
487
|
+
title={
|
|
488
|
+
formInvalid
|
|
489
|
+
?? (env != null && !env.venvExists
|
|
490
|
+
? t("settings.kb.env.needSetupFirst")
|
|
491
|
+
: envBusy
|
|
492
|
+
? t("settings.kb.env.busy")
|
|
493
|
+
: undefined)
|
|
494
|
+
}
|
|
495
|
+
>
|
|
496
|
+
<Play size={14} style={{ marginRight: 4 }} aria-hidden />
|
|
497
|
+
{t("settings.kb.start")}
|
|
498
|
+
</button>
|
|
499
|
+
) : (
|
|
500
|
+
<button
|
|
501
|
+
className="settings-button"
|
|
502
|
+
type="button"
|
|
503
|
+
onClick={() => void cancelBuild()}
|
|
504
|
+
>
|
|
505
|
+
<Square size={14} style={{ marginRight: 4 }} aria-hidden />
|
|
506
|
+
{t("settings.kb.cancel")}
|
|
507
|
+
</button>
|
|
508
|
+
)}
|
|
509
|
+
{active ? <Loader2 size={16} className="animate-spin" aria-hidden /> : null}
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
{error ? (
|
|
513
|
+
<p style={{ color: "var(--color-danger, #d92d20)", marginTop: 8 }}>
|
|
514
|
+
{error}
|
|
515
|
+
</p>
|
|
516
|
+
) : null}
|
|
517
|
+
|
|
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" }}>
|
|
521
|
+
{STAGES.map((s) => {
|
|
522
|
+
const st = stages[s];
|
|
523
|
+
const color =
|
|
524
|
+
st.status === "done" ? "#16a34a" :
|
|
525
|
+
st.status === "error" ? "#d92d20" :
|
|
526
|
+
st.status === "running" ? "#2563eb" : "#cbd5e1";
|
|
527
|
+
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
|
+
}} />
|
|
542
|
+
</div>
|
|
543
|
+
<span style={{ fontVariantNumeric: "tabular-nums", color: "#64748b" }}>
|
|
544
|
+
{st.status === "done" ? "✓" : `${Math.round(st.percent)}%`}
|
|
545
|
+
</span>
|
|
546
|
+
{st.msg ? (
|
|
547
|
+
<div style={{ gridColumn: "2 / -1", fontSize: 12, color: "#64748b" }}>
|
|
548
|
+
{st.msg}
|
|
549
|
+
</div>
|
|
550
|
+
) : null}
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
})}
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
|
|
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
|
+
>
|
|
574
|
+
{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
|
+
})}
|
|
590
|
+
</div>
|
|
591
|
+
</div>
|
|
592
|
+
</section>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { FormEvent, useEffect, useState } from "react";
|
|
2
|
-
import { Check, Eye, EyeOff, Loader2, Plug, Plus, Settings, SlidersHorizontal, Trash2, UserRound, X } from "lucide-react";
|
|
2
|
+
import { Check, Database, Eye, EyeOff, Loader2, Plug, Plus, Settings, SlidersHorizontal, Trash2, UserRound, X } from "lucide-react";
|
|
3
3
|
import type { LucideIcon } from "lucide-react";
|
|
4
4
|
import type { McpServerEntry, ProviderProfile, ProviderApi } from "../../contracts/backend";
|
|
5
5
|
import { useAuth } from "../../contexts/AuthContext";
|
|
6
6
|
import { usePreferences } from "../../contexts/PreferencesContext";
|
|
7
7
|
import { useT } from "../../i18n/useT";
|
|
8
8
|
import { api } from "../../utils/api";
|
|
9
|
+
import { EXAMPLE_MODEL } from "@brainpilot/protocol";
|
|
9
10
|
import { CustomSelect } from "../primitives/CustomSelect";
|
|
10
11
|
import { IconButton } from "../primitives/IconButton";
|
|
12
|
+
import { KnowledgeBasePanel } from "./KnowledgeBasePanel";
|
|
11
13
|
|
|
12
|
-
type SettingsTab = "account" | "providers" | "mcp" | "preferences";
|
|
14
|
+
type SettingsTab = "account" | "providers" | "mcp" | "knowledgeBase" | "preferences";
|
|
13
15
|
|
|
14
16
|
type SettingsDialogProps = {
|
|
15
17
|
isOpen: boolean;
|
|
@@ -20,6 +22,7 @@ const tabs: Array<{ id: SettingsTab; labelKey: string; icon: LucideIcon }> = [
|
|
|
20
22
|
{ id: "account", labelKey: "settings.tab.account", icon: UserRound },
|
|
21
23
|
{ id: "providers", labelKey: "settings.tab.providers", icon: SlidersHorizontal },
|
|
22
24
|
{ id: "mcp", labelKey: "settings.tab.mcp", icon: Plug },
|
|
25
|
+
{ id: "knowledgeBase", labelKey: "settings.tab.knowledgeBase", icon: Database },
|
|
23
26
|
{ id: "preferences", labelKey: "settings.tab.preferences", icon: Settings },
|
|
24
27
|
];
|
|
25
28
|
|
|
@@ -29,7 +32,7 @@ const DEFAULT_PROVIDER_FORM = {
|
|
|
29
32
|
api: "anthropic-messages" as ProviderApi,
|
|
30
33
|
apiKey: "",
|
|
31
34
|
apiKeyMasked: "",
|
|
32
|
-
models: [
|
|
35
|
+
models: [EXAMPLE_MODEL],
|
|
33
36
|
iconColor: "#111111",
|
|
34
37
|
notes: "",
|
|
35
38
|
};
|
|
@@ -493,6 +496,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|
|
493
496
|
</section>
|
|
494
497
|
) : null}
|
|
495
498
|
|
|
499
|
+
{activeTab === "knowledgeBase" ? <KnowledgeBasePanel /> : null}
|
|
500
|
+
|
|
496
501
|
{activeTab === "preferences" ? (
|
|
497
502
|
<section className="settings-section">
|
|
498
503
|
<h3>{t("settings.prefs.title")}</h3>
|
|
@@ -611,7 +616,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|
|
611
616
|
{providerForm.models.map((model, index) => (
|
|
612
617
|
<label className="provider-model-row" key={`${index}-${providerForm.models.length}`}>
|
|
613
618
|
<input
|
|
614
|
-
placeholder=
|
|
619
|
+
placeholder={EXAMPLE_MODEL}
|
|
615
620
|
value={model}
|
|
616
621
|
onChange={(event) => updateProviderModel(index, event.target.value)}
|
|
617
622
|
/>
|
|
@@ -626,6 +631,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|
|
626
631
|
</label>
|
|
627
632
|
))}
|
|
628
633
|
</div>
|
|
634
|
+
<p className="provider-form__models-hint">
|
|
635
|
+
{t("settings.providerForm.modelsHint")}
|
|
636
|
+
</p>
|
|
629
637
|
</div>
|
|
630
638
|
|
|
631
639
|
<div className="provider-form__appearance">
|