@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/dist/assets/index-DkoqxJfs.css +1 -0
- package/dist/assets/index-DtLW483q.js +451 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/__tests__/api.test.ts +49 -1
- package/src/__tests__/messageGroups.test.ts +150 -0
- package/src/__tests__/newUiEvents.test.ts +32 -0
- package/src/__tests__/runningScripts.test.ts +139 -0
- package/src/components/chat/MessageStream.tsx +103 -43
- package/src/components/chat/PromptComposer.tsx +28 -10
- package/src/components/chat/RunningScriptsPanel.tsx +118 -0
- package/src/components/chat/runningScripts.ts +88 -0
- package/src/components/demo/DemoView.tsx +1 -1
- package/src/components/session/AgentTraceViews.tsx +5 -9
- package/src/components/settings/KnowledgeBasePanel.tsx +758 -0
- package/src/components/settings/SettingsDialog.tsx +127 -61
- package/src/components/shell/SandboxStatus.tsx +128 -84
- package/src/contexts/messageGroups.ts +110 -4
- package/src/contexts/messageReducer.ts +11 -1
- package/src/i18n/messages/chat.ts +14 -0
- package/src/i18n/messages/sandbox.ts +3 -0
- package/src/i18n/messages/settings.ts +93 -0
- package/src/i18n/messages/trace.ts +0 -2
- package/src/styles/global.css +970 -80
- package/src/utils/api.ts +188 -3
- package/src/utils/format.ts +9 -0
- package/dist/assets/index-CJNvdeGz.js +0 -445
- package/dist/assets/index-DWOsU22G.css +0 -1
|
@@ -0,0 +1,758 @@
|
|
|
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
|
+
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
|
+
|
|
92
|
+
export function KnowledgeBasePanel() {
|
|
93
|
+
const t = useT();
|
|
94
|
+
const [ocrApiKey, setOcrApiKey] = useState("");
|
|
95
|
+
const [metaApiKey, setMetaApiKey] = useState("");
|
|
96
|
+
const [metaBaseUrl, setMetaBaseUrl] = useState("");
|
|
97
|
+
const [metaModel, setMetaModel] = useState("");
|
|
98
|
+
const [reuseAgentKey, setReuseAgentKey] = useState(true);
|
|
99
|
+
const [useHfMirror, setUseHfMirror] = useState(false);
|
|
100
|
+
const [skip, setSkip] = useState<Record<Stage, boolean>>({
|
|
101
|
+
ocr: false,
|
|
102
|
+
extract: false,
|
|
103
|
+
chunk: false,
|
|
104
|
+
vectorize: false,
|
|
105
|
+
});
|
|
106
|
+
const [events, setEvents] = useState<BuildEvent[]>([]);
|
|
107
|
+
const [stages, setStages] = useState<Record<Stage, StageState>>(INITIAL_STAGE_STATE);
|
|
108
|
+
const [active, setActive] = useState(false);
|
|
109
|
+
/** Distinguishes "the build is running" from "env setup is running" so the
|
|
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);
|
|
114
|
+
const [error, setError] = useState<string | null>(null);
|
|
115
|
+
const [env, setEnv] = useState<{
|
|
116
|
+
python: string;
|
|
117
|
+
pythonIsVenv: boolean;
|
|
118
|
+
venvExists: boolean;
|
|
119
|
+
expectedVenvPath: string;
|
|
120
|
+
scriptsPresent: boolean;
|
|
121
|
+
kbRoot: string;
|
|
122
|
+
} | null>(null);
|
|
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);
|
|
139
|
+
const logRef = useRef<HTMLDivElement | null>(null);
|
|
140
|
+
const sseRef = useRef<EventSource | null>(null);
|
|
141
|
+
|
|
142
|
+
// Hydrate from server: if a build is already running (e.g. user reopened
|
|
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".
|
|
145
|
+
useEffect(() => {
|
|
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
|
+
})();
|
|
157
|
+
void (async () => {
|
|
158
|
+
try {
|
|
159
|
+
const status = await api.kb.status();
|
|
160
|
+
if (cancelled) return;
|
|
161
|
+
if (status.environment) setEnv(status.environment);
|
|
162
|
+
if (status.recentEvents?.length) {
|
|
163
|
+
setEvents(status.recentEvents);
|
|
164
|
+
replayStages(status.recentEvents);
|
|
165
|
+
replaySetupProgress(status.recentEvents);
|
|
166
|
+
}
|
|
167
|
+
if (status.active) {
|
|
168
|
+
// Guess which job is running from the most recent event with a
|
|
169
|
+
// known stage. setup-env and build share the same RUN slot, so
|
|
170
|
+
// we can't ask the server directly — but the last event's
|
|
171
|
+
// `stage` is reliable enough for the UI banner.
|
|
172
|
+
const recent = status.recentEvents ?? [];
|
|
173
|
+
const last = [...recent].reverse().find(
|
|
174
|
+
(ev) => ev.stage === "setup-env" || ev.stage === "build",
|
|
175
|
+
);
|
|
176
|
+
if (last?.stage === "setup-env") {
|
|
177
|
+
setEnvBusy(true);
|
|
178
|
+
setActiveJob("setup-env");
|
|
179
|
+
} else {
|
|
180
|
+
setActive(true);
|
|
181
|
+
setActiveJob("build");
|
|
182
|
+
}
|
|
183
|
+
openSse();
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
/* status fetch is best-effort */
|
|
187
|
+
}
|
|
188
|
+
})();
|
|
189
|
+
return () => {
|
|
190
|
+
cancelled = true;
|
|
191
|
+
closeSse();
|
|
192
|
+
};
|
|
193
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
// Auto-scroll the log to the bottom on new events.
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const el = logRef.current;
|
|
199
|
+
if (!el) return;
|
|
200
|
+
el.scrollTop = el.scrollHeight;
|
|
201
|
+
}, [events.length]);
|
|
202
|
+
|
|
203
|
+
function replayStages(history: BuildEvent[]) {
|
|
204
|
+
const next: Record<Stage, StageState> = { ...INITIAL_STAGE_STATE };
|
|
205
|
+
for (const ev of history) {
|
|
206
|
+
applyEventToStages(next, ev);
|
|
207
|
+
}
|
|
208
|
+
setStages(next);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function applyEventToStages(
|
|
212
|
+
target: Record<Stage, StageState>,
|
|
213
|
+
ev: BuildEvent,
|
|
214
|
+
) {
|
|
215
|
+
if (!isKnownStage(ev.stage)) return;
|
|
216
|
+
const cur = target[ev.stage];
|
|
217
|
+
if (ev.event === "progress") {
|
|
218
|
+
const pct = typeof ev.percent === "number" ? ev.percent : cur.percent;
|
|
219
|
+
target[ev.stage] = { status: "running", percent: pct, msg: ev.msg };
|
|
220
|
+
} else if (ev.event === "info") {
|
|
221
|
+
target[ev.stage] = { ...cur, status: "running", msg: ev.msg };
|
|
222
|
+
} else if (ev.event === "done") {
|
|
223
|
+
target[ev.stage] = { status: "done", percent: 100, msg: ev.msg };
|
|
224
|
+
} else if (ev.event === "error") {
|
|
225
|
+
target[ev.stage] = { ...cur, status: "error", msg: ev.msg };
|
|
226
|
+
} else if (ev.event === "warn") {
|
|
227
|
+
target[ev.stage] = { ...cur, msg: ev.msg };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
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
|
+
|
|
265
|
+
async function refreshEnv() {
|
|
266
|
+
try {
|
|
267
|
+
const s = await api.kb.status();
|
|
268
|
+
if (s.environment) setEnv(s.environment);
|
|
269
|
+
} catch {
|
|
270
|
+
/* best-effort */
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function pushEvent(ev: BuildEvent) {
|
|
275
|
+
setEvents((prev) => {
|
|
276
|
+
const next = prev.concat(ev);
|
|
277
|
+
// Cap log at ~2k lines so a long OCR run doesn't drag the DOM.
|
|
278
|
+
return next.length > 2000 ? next.slice(next.length - 2000) : next;
|
|
279
|
+
});
|
|
280
|
+
setStages((prev) => {
|
|
281
|
+
const next = { ...prev };
|
|
282
|
+
applyEventToStages(next, ev);
|
|
283
|
+
return next;
|
|
284
|
+
});
|
|
285
|
+
// Job-level finish signals: clear active flags.
|
|
286
|
+
if (ev.stage === "build" && (ev.event === "done" || ev.event === "error")) {
|
|
287
|
+
setActive(false);
|
|
288
|
+
setActiveJob(null);
|
|
289
|
+
}
|
|
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")) {
|
|
310
|
+
setActiveJob(null);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function openSse() {
|
|
315
|
+
if (sseRef.current) return;
|
|
316
|
+
const es = new EventSource(api.kb.eventsUrl());
|
|
317
|
+
sseRef.current = es;
|
|
318
|
+
es.onmessage = (e) => {
|
|
319
|
+
try {
|
|
320
|
+
const ev = JSON.parse(e.data) as BuildEvent;
|
|
321
|
+
if (ev.event === "stream-end" || ev.event === "idle") {
|
|
322
|
+
es.close();
|
|
323
|
+
sseRef.current = null;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
pushEvent(ev);
|
|
327
|
+
} catch {
|
|
328
|
+
/* swallow malformed event */
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
es.onerror = () => {
|
|
332
|
+
// Browser auto-retries; nothing to do.
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function closeSse() {
|
|
337
|
+
if (sseRef.current) {
|
|
338
|
+
sseRef.current.close();
|
|
339
|
+
sseRef.current = null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const formInvalid = useMemo(() => {
|
|
344
|
+
if (skip.ocr === false && !ocrApiKey.trim() && !ocrKeySaved) {
|
|
345
|
+
return t("settings.kb.error.missingOcrKey");
|
|
346
|
+
}
|
|
347
|
+
if (skip.extract === false) {
|
|
348
|
+
if (!metaApiKey.trim() && !reuseAgentKey) {
|
|
349
|
+
return t("settings.kb.error.missingMetaKey");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}, [ocrApiKey, ocrKeySaved, metaApiKey, reuseAgentKey, skip.ocr, skip.extract, t]);
|
|
354
|
+
|
|
355
|
+
async function startBuild() {
|
|
356
|
+
setError(null);
|
|
357
|
+
if (formInvalid) {
|
|
358
|
+
setError(formInvalid);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Reset for a fresh run.
|
|
362
|
+
setEvents([]);
|
|
363
|
+
setStages(INITIAL_STAGE_STATE);
|
|
364
|
+
// When "reuse agent key" is on we omit metaKey from the request and let
|
|
365
|
+
// the build_kb.py side resolve it from META_LLM_API_KEY / API_config.json.
|
|
366
|
+
// Browser code can't read the masked provider key over the API, so we
|
|
367
|
+
// intentionally don't try.
|
|
368
|
+
const metaKey: string | undefined = reuseAgentKey ? undefined : (metaApiKey.trim() || undefined);
|
|
369
|
+
const skipList = STAGES.filter((s) => skip[s]);
|
|
370
|
+
try {
|
|
371
|
+
const r = await api.kb.build({
|
|
372
|
+
ocrApiKey: ocrApiKey.trim() || undefined,
|
|
373
|
+
metaApiKey: metaKey || undefined,
|
|
374
|
+
metaBaseUrl: metaBaseUrl.trim() || undefined,
|
|
375
|
+
metaModel: metaModel.trim() || undefined,
|
|
376
|
+
skip: skipList.length ? skipList : undefined,
|
|
377
|
+
hfMirror: useHfMirror ? "https://hf-mirror.com" : undefined,
|
|
378
|
+
});
|
|
379
|
+
if (!r.ok) {
|
|
380
|
+
setError(r.error || "build start failed");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
setActive(true);
|
|
384
|
+
setActiveJob("build");
|
|
385
|
+
openSse();
|
|
386
|
+
} catch (err) {
|
|
387
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function cancelBuild() {
|
|
392
|
+
try {
|
|
393
|
+
await api.kb.cancel();
|
|
394
|
+
} catch (err) {
|
|
395
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function startEnvSetup(reinstall: boolean) {
|
|
400
|
+
setError(null);
|
|
401
|
+
// Keep the build log in place if the user already ran one — env setup
|
|
402
|
+
// events get prefixed with [setup-env:...] so they stay visually
|
|
403
|
+
// distinct from prior [build:...] / [ocr:...] lines.
|
|
404
|
+
setEnvBusy(true);
|
|
405
|
+
setActiveJob("setup-env");
|
|
406
|
+
setEnvProgress({ percent: 0, msg: "", status: "running" });
|
|
407
|
+
try {
|
|
408
|
+
const r = await api.kb.setupEnv({ reinstall });
|
|
409
|
+
if (!r.ok) {
|
|
410
|
+
setEnvBusy(false);
|
|
411
|
+
setActiveJob(null);
|
|
412
|
+
setEnvProgress({ percent: 0, msg: r.error || "start failed", status: "error" });
|
|
413
|
+
setError(r.error || "setup-env start failed");
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
openSse();
|
|
417
|
+
} catch (err) {
|
|
418
|
+
setEnvBusy(false);
|
|
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) {
|
|
476
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<section className="settings-section">
|
|
482
|
+
<div className="settings-section__header">
|
|
483
|
+
<div>
|
|
484
|
+
<h3 className="kb-panel__title">
|
|
485
|
+
<Database size={18} aria-hidden />
|
|
486
|
+
{t("settings.kb.title")}
|
|
487
|
+
</h3>
|
|
488
|
+
<p>
|
|
489
|
+
{t("settings.kb.desc")}
|
|
490
|
+
</p>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
{env ? (
|
|
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>
|
|
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>
|
|
513
|
+
{!env.venvExists ? (
|
|
514
|
+
<div className="kb-env__action">
|
|
515
|
+
<p className="kb-env__note">{t("settings.kb.env.venvMissing")}</p>
|
|
516
|
+
<div className="kb-env__buttons">
|
|
517
|
+
<button
|
|
518
|
+
type="button"
|
|
519
|
+
className="settings-button"
|
|
520
|
+
onClick={() => void startFullSetup()}
|
|
521
|
+
disabled={envBusy || modelBusy || activeJob !== null}
|
|
522
|
+
title={t("settings.kb.env.setupFullHint")}
|
|
523
|
+
>
|
|
524
|
+
<Wrench size={14} aria-hidden />
|
|
525
|
+
{t("settings.kb.env.setupFullButton")}
|
|
526
|
+
</button>
|
|
527
|
+
{envBusy || modelBusy ? <Loader2 size={14} className="spin" aria-hidden /> : null}
|
|
528
|
+
</div>
|
|
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`}
|
|
534
|
+
</pre>
|
|
535
|
+
<p className="kb-env__note">{t("settings.kb.env.venvHint")}</p>
|
|
536
|
+
</details>
|
|
537
|
+
</div>
|
|
538
|
+
) : (
|
|
539
|
+
<div className="kb-env__buttons">
|
|
540
|
+
<button
|
|
541
|
+
type="button"
|
|
542
|
+
className="settings-button settings-button--ghost"
|
|
543
|
+
onClick={() => void startEnvSetup(true)}
|
|
544
|
+
disabled={envBusy || modelBusy || activeJob !== null}
|
|
545
|
+
title={t("settings.kb.env.reinstallHint")}
|
|
546
|
+
>
|
|
547
|
+
<RefreshCw size={14} aria-hidden />
|
|
548
|
+
{t("settings.kb.env.reinstallButton")}
|
|
549
|
+
</button>
|
|
550
|
+
{envBusy ? <Loader2 size={14} className="spin" aria-hidden /> : null}
|
|
551
|
+
</div>
|
|
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}
|
|
567
|
+
</div>
|
|
568
|
+
) : null}
|
|
569
|
+
|
|
570
|
+
<div className="kb-fields">
|
|
571
|
+
<label className="settings-field">
|
|
572
|
+
<span>{t("settings.kb.ocrKey")}</span>
|
|
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
|
+
)}
|
|
602
|
+
</label>
|
|
603
|
+
|
|
604
|
+
<label className="settings-check">
|
|
605
|
+
<input
|
|
606
|
+
type="checkbox"
|
|
607
|
+
checked={reuseAgentKey}
|
|
608
|
+
onChange={(e) => setReuseAgentKey(e.target.checked)}
|
|
609
|
+
disabled={active}
|
|
610
|
+
/>
|
|
611
|
+
<span>
|
|
612
|
+
{t("settings.kb.reuseAgentKey")}
|
|
613
|
+
</span>
|
|
614
|
+
</label>
|
|
615
|
+
|
|
616
|
+
{!reuseAgentKey ? (
|
|
617
|
+
<>
|
|
618
|
+
<label className="settings-field">
|
|
619
|
+
<span>{t("settings.kb.metaKey")}</span>
|
|
620
|
+
<input
|
|
621
|
+
type="password"
|
|
622
|
+
value={metaApiKey}
|
|
623
|
+
onChange={(e) => setMetaApiKey(e.target.value)}
|
|
624
|
+
placeholder="sk-..."
|
|
625
|
+
autoComplete="off"
|
|
626
|
+
disabled={active || skip.extract}
|
|
627
|
+
/>
|
|
628
|
+
</label>
|
|
629
|
+
<label className="settings-field">
|
|
630
|
+
<span>{t("settings.kb.metaBaseUrl")}</span>
|
|
631
|
+
<input
|
|
632
|
+
type="url"
|
|
633
|
+
value={metaBaseUrl}
|
|
634
|
+
onChange={(e) => setMetaBaseUrl(e.target.value)}
|
|
635
|
+
placeholder="https://api.example.com"
|
|
636
|
+
disabled={active || skip.extract}
|
|
637
|
+
/>
|
|
638
|
+
</label>
|
|
639
|
+
<label className="settings-field">
|
|
640
|
+
<span>{t("settings.kb.metaModel")}</span>
|
|
641
|
+
<input
|
|
642
|
+
type="text"
|
|
643
|
+
value={metaModel}
|
|
644
|
+
onChange={(e) => setMetaModel(e.target.value)}
|
|
645
|
+
placeholder="deepseek-chat"
|
|
646
|
+
disabled={active || skip.extract}
|
|
647
|
+
/>
|
|
648
|
+
</label>
|
|
649
|
+
</>
|
|
650
|
+
) : null}
|
|
651
|
+
|
|
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">
|
|
666
|
+
<legend>{t("settings.kb.stages")}</legend>
|
|
667
|
+
<div className="kb-stages__row">
|
|
668
|
+
{STAGES.map((s) => (
|
|
669
|
+
<label key={s} className="settings-check kb-stages__item">
|
|
670
|
+
<input
|
|
671
|
+
type="checkbox"
|
|
672
|
+
checked={!skip[s]}
|
|
673
|
+
disabled={active}
|
|
674
|
+
onChange={(e) =>
|
|
675
|
+
setSkip((prev) => ({ ...prev, [s]: !e.target.checked }))
|
|
676
|
+
}
|
|
677
|
+
/>
|
|
678
|
+
<span>{STAGE_LABELS[s]}</span>
|
|
679
|
+
</label>
|
|
680
|
+
))}
|
|
681
|
+
</div>
|
|
682
|
+
</fieldset>
|
|
683
|
+
</div>
|
|
684
|
+
|
|
685
|
+
<div className="kb-actions">
|
|
686
|
+
{!active ? (
|
|
687
|
+
<button
|
|
688
|
+
className="settings-button"
|
|
689
|
+
type="button"
|
|
690
|
+
onClick={() => void startBuild()}
|
|
691
|
+
disabled={!!formInvalid || envBusy || (env != null && !env.venvExists)}
|
|
692
|
+
title={
|
|
693
|
+
formInvalid
|
|
694
|
+
?? (env != null && !env.venvExists
|
|
695
|
+
? t("settings.kb.env.needSetupFirst")
|
|
696
|
+
: envBusy
|
|
697
|
+
? t("settings.kb.env.busy")
|
|
698
|
+
: undefined)
|
|
699
|
+
}
|
|
700
|
+
>
|
|
701
|
+
<Play size={14} aria-hidden />
|
|
702
|
+
{t("settings.kb.start")}
|
|
703
|
+
</button>
|
|
704
|
+
) : (
|
|
705
|
+
<button
|
|
706
|
+
className="settings-button"
|
|
707
|
+
type="button"
|
|
708
|
+
onClick={() => void cancelBuild()}
|
|
709
|
+
>
|
|
710
|
+
<Square size={14} aria-hidden />
|
|
711
|
+
{t("settings.kb.cancel")}
|
|
712
|
+
</button>
|
|
713
|
+
)}
|
|
714
|
+
{active ? <Loader2 size={16} className="spin" aria-hidden /> : null}
|
|
715
|
+
</div>
|
|
716
|
+
|
|
717
|
+
{error ? <p className="settings-note settings-note--error kb-error">{error}</p> : null}
|
|
718
|
+
|
|
719
|
+
<div className="kb-block">
|
|
720
|
+
<h4 className="kb-block__title">{t("settings.kb.progress")}</h4>
|
|
721
|
+
<div className="kb-progress">
|
|
722
|
+
{STAGES.map((s) => {
|
|
723
|
+
const st = stages[s];
|
|
724
|
+
const pct = Math.min(100, Math.max(0, st.percent));
|
|
725
|
+
return (
|
|
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
|
+
/>
|
|
733
|
+
</div>
|
|
734
|
+
<span className="kb-progress__pct">
|
|
735
|
+
{st.status === "done" ? "✓" : `${Math.round(st.percent)}%`}
|
|
736
|
+
</span>
|
|
737
|
+
{st.msg ? <div className="kb-progress__msg">{st.msg}</div> : null}
|
|
738
|
+
</div>
|
|
739
|
+
);
|
|
740
|
+
})}
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
|
|
744
|
+
<div className="kb-block">
|
|
745
|
+
<h4 className="kb-block__title">{t("settings.kb.log")}</h4>
|
|
746
|
+
<div ref={logRef} className="kb-log">
|
|
747
|
+
{events.length === 0 ? (
|
|
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
|
+
))}
|
|
754
|
+
</div>
|
|
755
|
+
</div>
|
|
756
|
+
</section>
|
|
757
|
+
);
|
|
758
|
+
}
|