Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/components/ops/kb-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e));
type LibraryView = "documents" | "graph";

export function KbPanel() {
// Gate on activation BEFORE mounting the library: KbList fetches on mount
// and a 503 from a disabled/keyless KB stacked error panels under the
// activation card (plan 106). Older gateways omit `enabled`; treat
// configured-as-enabled there.
const kbStatus = useAsync(() => api.getKnowledge(), []);
const kbEnabled = kbStatus.data
? (kbStatus.data.enabled ?? kbStatus.data.embedding_configured)
: false;
const groups = useAsync(() => api.kbGroups(), []);
const [selected, setSelected] = React.useState<KbGroup | null>(null);
const [view, setView] = React.useState<LibraryView>("documents");
Expand All @@ -85,10 +93,15 @@ export function KbPanel() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groups.data]);

if (kbStatus.loading) return null;
if (!kbEnabled) {
// Activation screen only — no Documents/Graph chrome, no doomed fetches.
return <KnowledgeSettingsCard onChanged={kbStatus.refresh} />;
}

return (
<div className="space-y-4">
{/* Self-refreshes on save via its own useAsync — no parent state needed. */}
<KnowledgeSettingsCard />
<KnowledgeSettingsCard onChanged={kbStatus.refresh} />

<Segmented
value={view}
Expand Down
5 changes: 3 additions & 2 deletions src/components/ops/knowledge-graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,9 @@ export function KnowledgeGraph({
title="No graph to show"
hint={
<>
No entities have been extracted yet. Intelligence extraction may be disabled
(<code>KB_INTELLIGENCE_ENABLED</code>) or not yet run for these documents.
No entities have been extracted for these documents yet. Check the Graph tab&apos;s
status for the cause (extraction off, or no credential under Knowledge Base
settings), or use a document&apos;s <em>Re-extract</em>.
</>
}
/>
Expand Down
236 changes: 175 additions & 61 deletions src/components/ops/knowledge-settings-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,57 +12,95 @@ import { Badge } from "@/components/ui/badge";
import { ConfirmModal } from "@/components/ui/confirm-modal";

/**
* Knowledge Base credentials card. Doubles as the "not configured" gate:
* prominent with an input when no key resolves; a compact status row when
* configured. Consumes GET/PUT /api/rc/config/knowledge. The key value is
* never returned by the backend — this card only ever shows booleans + source.
* Knowledge Base activation screen + status row (RantAIClaw plan 106).
*
* Three states, driven by `enabled` × `embedding_configured`:
* - off + no key → "Activate Knowledge Base": key inputs + Activate
* - off + key → "Knowledge Base is off": one-click Activate (key kept)
* - on + key → compact status row + Deactivate / Edit / Remove key
*
* Deactivate is NOT Clear: it sends `{enabled:false}` and keeps the key so
* re-activation is one click. The destructive Remove key stays behind its
* confirm modal. A key the provider rejects (the gateway probes it live and
* answers 400) surfaces INLINE on the input — a rejected key is a form
* error, not a toast.
*/
export function KnowledgeSettingsCard() {
export function KnowledgeSettingsCard({ onChanged }: { onChanged?: () => void }) {
const status = useAsync(() => api.getKnowledge(), []);
const [editing, setEditing] = React.useState(false);
const [embedding, setEmbedding] = React.useState("");
const [vision, setVision] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [confirmClear, setConfirmClear] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);

const configured = status.data?.embedding_configured ?? false;
// Older gateways omit `enabled`; treat configured-as-enabled there so the
// console keeps working against them (pre-v0.18.5 behaviour).
const enabled = status.data?.enabled ?? configured;
const source = status.data?.source ?? "none";
const envManaged = source === "env";

const save = async () => {
const emb = embedding.trim();
if (!emb && !configured) return;
const refreshAll = () => {
status.refresh();
onChanged?.();
};

const put = async (
body: { enabled?: boolean; embedding_api_key?: string; vision_api_key?: string },
okMessage: string,
): Promise<boolean> => {
setBusy(true);
setFormError(null);
try {
const body: { embedding_api_key?: string; vision_api_key?: string } = {};
if (emb) body.embedding_api_key = emb;
if (vision.trim()) body.vision_api_key = vision.trim();
await api.setKnowledge(body);
toast.success("Knowledge Base configured");
setEmbedding("");
setVision("");
setEditing(false);
status.refresh();
toast.success(okMessage);
return true;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
const msg = e instanceof Error ? e.message : String(e);
// The gateway probes the key before persisting; its 400 belongs on
// the form, not (only) in a toast.
if (body.embedding_api_key) setFormError(msg);
else toast.error(msg);
return false;
} finally {
setBusy(false);
}
};

const clear = async () => {
setBusy(true);
try {
await api.setKnowledge({ embedding_api_key: "", vision_api_key: "" });
toast.success("Knowledge Base keys cleared");
setConfirmClear(false);
status.refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
const saveAndActivate = async () => {
const emb = embedding.trim();
if (!emb && !configured) return;
const body: { enabled?: boolean; embedding_api_key?: string; vision_api_key?: string } = {
enabled: true,
};
if (emb) body.embedding_api_key = emb;
if (vision.trim()) body.vision_api_key = vision.trim();
if (await put(body, "Knowledge Base activated")) {
setEmbedding("");
setVision("");
setEditing(false);
refreshAll();
}
};

const setEnabled = async (next: boolean) => {
const ok = await put(
{ enabled: next },
next ? "Knowledge Base activated" : "Knowledge Base deactivated — key kept",
);
if (ok) refreshAll();
};

const clear = async () => {
const ok = await put(
{ enabled: false, embedding_api_key: "", vision_api_key: "" },
"Knowledge Base keys removed",
);
setConfirmClear(false);
if (ok) refreshAll();
};

if (status.loading) return null;

// Never silently vanish on error — that hides the only place to enter an
Expand All @@ -82,60 +120,123 @@ export function KnowledgeSettingsCard() {
);
}

if (configured && !editing) {
const envManaged = source === "env";
// State: configured but off — one-click reactivation, key retained.
if (!enabled && configured && !editing) {
return (
<>
<Card className="flex flex-wrap items-center justify-between gap-3 p-3">
<div className="flex items-center gap-2 text-sm">
<BookOpen className="size-4 text-muted-foreground" />
<span className="font-medium">Knowledge Base configured</span>
<Badge variant="outline">source: {source}</Badge>
{status.data?.vision_configured && <Badge variant="outline">OCR on</Badge>}
</div>
{envManaged ? (
<span className="text-xs text-muted-foreground">
Managed by <code>KB_EMBEDDING_API_KEY</code> — unset it to manage the key here.
</span>
) : (
<Card className="flex flex-wrap items-center justify-between gap-3 p-4">
<div className="flex items-center gap-2 text-sm">
<BookOpen className="size-4 text-muted-foreground" />
<span className="font-medium">Knowledge Base is off</span>
<Badge variant="outline">key stored</Badge>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>Edit key</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmClear(true)} disabled={busy}>Clear</Button>
<Button size="sm" onClick={() => setEnabled(true)} disabled={busy}>
{busy ? "…" : "Activate"}
</Button>
{!envManaged && (
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmClear(true)}
disabled={busy}
>
Remove key
</Button>
)}
</div>
)}
</Card>
<ConfirmModal
open={confirmClear}
onClose={() => setConfirmClear(false)}
title="Clear Knowledge Base keys?"
description="Document search will stop working until you re-enter an embedding key."
confirmLabel="Clear keys"
busy={busy}
onConfirm={clear}
/>
</Card>
<ConfirmModal
open={confirmClear}
onClose={() => setConfirmClear(false)}
title="Remove Knowledge Base keys?"
description="Permanently removes the stored keys — you will have to re-enter one to activate again. To pause the Knowledge Base without losing the key, use Deactivate instead."
confirmLabel="Remove keys"
busy={busy}
onConfirm={clear}
/>
</>
);
}

// State: on + configured — compact status row.
if (enabled && configured && !editing) {
return (
<>
<Card className="flex flex-wrap items-center justify-between gap-3 p-3">
<div className="flex items-center gap-2 text-sm">
<BookOpen className="size-4 text-muted-foreground" />
<span className="font-medium">Knowledge Base active</span>
<Badge variant="outline">source: {source}</Badge>
{status.data?.vision_configured && <Badge variant="outline">OCR on</Badge>}
</div>
{envManaged ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Key managed by <code>KB_EMBEDDING_API_KEY</code> — unset it to manage the key here.
</span>
<Button size="sm" variant="outline" onClick={() => setEnabled(false)} disabled={busy}>
Deactivate
</Button>
</div>
) : (
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setEnabled(false)} disabled={busy}>
Deactivate
</Button>
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
Edit key
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmClear(true)}
disabled={busy}
>
Remove key
</Button>
</div>
)}
</Card>
<ConfirmModal
open={confirmClear}
onClose={() => setConfirmClear(false)}
title="Remove Knowledge Base keys?"
description="Permanently removes the stored keys — you will have to re-enter one to activate again. To pause the Knowledge Base without losing the key, use Deactivate instead."
confirmLabel="Remove keys"
busy={busy}
onConfirm={clear}
/>
</>
);
}

// State: activation form — no key yet (or editing an existing one).
return (
<Card className="space-y-3 p-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-semibold">
<BookOpen className="size-4" /> Knowledge Base — add an API key
<BookOpen className="size-4" />{" "}
{editing ? "Edit Knowledge Base key" : "Activate Knowledge Base"}
</div>
<p className="text-xs text-muted-foreground">
Document search needs an embedding API key. Optionally add a separate OCR/vision key
(defaults to the embedding key). Stored encrypted; env <code>KB_EMBEDDING_API_KEY</code>{" "}
still overrides.
still overrides. The key is verified with the provider before it is saved.
</p>
</div>
<Input
type="password"
placeholder="Embedding API key (OpenRouter)"
autoComplete="off"
value={embedding}
onChange={(e) => setEmbedding(e.target.value)}
aria-invalid={formError ? true : undefined}
onChange={(e) => {
setEmbedding(e.target.value);
setFormError(null);
}}
/>
{formError && <p className="text-xs text-destructive">{formError}</p>}
<Input
type="password"
placeholder="OCR / vision key (optional — leave blank to reuse embedding key)"
Expand All @@ -144,11 +245,24 @@ export function KnowledgeSettingsCard() {
onChange={(e) => setVision(e.target.value)}
/>
<div className="flex gap-2">
<Button size="sm" onClick={save} disabled={busy || (!embedding.trim() && !configured)}>
{busy ? "Saving…" : "Save"}
<Button
size="sm"
onClick={saveAndActivate}
disabled={busy || (!embedding.trim() && !configured)}
>
{busy ? "Verifying…" : editing ? "Save" : "Activate"}
</Button>
{editing && (
<Button size="sm" variant="ghost" onClick={() => { setEditing(false); setEmbedding(""); setVision(""); }}>
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditing(false);
setEmbedding("");
setVision("");
setFormError(null);
}}
>
Cancel
</Button>
)}
Expand Down
3 changes: 2 additions & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,10 +395,11 @@ export const api = {
// ---- Knowledge Base credentials ([knowledge] config) ----
getKnowledge: () => rc<KnowledgeStatus>("config/knowledge"),
setKnowledge: (body: {
enabled?: boolean;
embedding_api_key?: string;
vision_api_key?: string;
}) =>
rc<{ embedding_configured: boolean; vision_configured: boolean }>(
rc<{ enabled?: boolean; embedding_configured: boolean; vision_configured: boolean }>(
"config/knowledge",
{
method: "PUT",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ export interface KbGroup {
}

export interface KnowledgeStatus {
/** Whether the KB is active. Optional: older gateways omit it. */
enabled?: boolean;
embedding_configured: boolean;
vision_configured: boolean;
/** Effective source of the embedding key, reported without revealing it. */
Expand Down
Loading