@schlessera/brain-ui-server 0.24.0 → 0.26.0

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.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Web-search configuration for the pi backend's `pi-web-access` extension —
3
+ * the Settings UI's way to pick a search provider and store its API key
4
+ * without a shell on the host.
5
+ *
6
+ * The extension reads `web-search.json` from the pi config dir; these routes
7
+ * read-modify-write that same file, touching ONLY the managed fields (the
8
+ * `provider` selector and the per-provider `<name>ApiKey` entries) so any
9
+ * hand-edited config beside them survives.
10
+ *
11
+ * Defaults are deliberate: no file (or `provider: "auto"`) means the
12
+ * extension's automatic chain, which starts with Exa's keyless MCP endpoint —
13
+ * free, throttled. Adding an `exaApiKey` lifts the throttle; picking another
14
+ * provider routes searches there instead.
15
+ *
16
+ * Key VALUES never leave the server: GET reports only which providers have a
17
+ * key configured. Mount BEHIND the /api auth guard.
18
+ */
19
+
20
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
21
+ import { dirname, join } from "path";
22
+ import { Hono } from "hono";
23
+ import type { AgentConfig } from "../config/env.js";
24
+ import { resolvePiConfigDir } from "../config/env.js";
25
+ import { loadBackendModule } from "../agent/backend.js";
26
+
27
+ /** One selectable provider, as the Settings UI renders it. */
28
+ export interface WebSearchProviderView {
29
+ id: string;
30
+ label: string;
31
+ /** Whether this provider accepts an API key ("auto" does not). */
32
+ hasKeyField: boolean;
33
+ /** True when a key for it is present in the config file. */
34
+ keyConfigured: boolean;
35
+ /** True when the provider works without any key (Exa's free MCP tier). */
36
+ keyless: boolean;
37
+ }
38
+
39
+ export interface WebSearchConfigView {
40
+ /** False when the pi backend is not configured — the client hides the card. */
41
+ configured: boolean;
42
+ /** The active provider selection ("auto" when unset). */
43
+ provider: string;
44
+ providers: WebSearchProviderView[];
45
+ }
46
+
47
+ /**
48
+ * The providers this surface manages. A curated subset of pi-web-access's
49
+ * catalog: the ones configurable by a single `<id>ApiKey` field. The
50
+ * extension itself accepts more (SearXNG endpoints, Ollama, …) — those stay
51
+ * hand-edited config, deliberately outside this UI.
52
+ */
53
+ const MANAGED_PROVIDERS: ReadonlyArray<{
54
+ id: string;
55
+ label: string;
56
+ keyField: string | null;
57
+ keyless: boolean;
58
+ }> = [
59
+ { id: "auto", label: "Auto — Exa (free) with fallbacks", keyField: null, keyless: true },
60
+ { id: "exa", label: "Exa", keyField: "exaApiKey", keyless: true },
61
+ { id: "openai", label: "OpenAI", keyField: "openaiApiKey", keyless: false },
62
+ { id: "brave", label: "Brave", keyField: "braveApiKey", keyless: false },
63
+ { id: "tavily", label: "Tavily", keyField: "tavilyApiKey", keyless: false },
64
+ { id: "perplexity", label: "Perplexity", keyField: "perplexityApiKey", keyless: false },
65
+ { id: "firecrawl", label: "Firecrawl", keyField: "firecrawlApiKey", keyless: false },
66
+ { id: "jina", label: "Jina", keyField: "jinaApiKey", keyless: false },
67
+ { id: "kagi", label: "Kagi", keyField: "kagiApiKey", keyless: false },
68
+ { id: "gemini", label: "Gemini", keyField: "geminiApiKey", keyless: false },
69
+ ];
70
+
71
+ const MANAGED_IDS = new Set(MANAGED_PROVIDERS.map((p) => p.id));
72
+
73
+ export interface WebSearchRoutesDeps {
74
+ agent: AgentConfig;
75
+ /** Test seam — overrides the config file location. */
76
+ configPath?: string;
77
+ /** Test seam — forwarded to loadBackendModule. */
78
+ importer?: (specifier: string) => Promise<unknown>;
79
+ }
80
+
81
+ type JsonRecord = Record<string, unknown>;
82
+
83
+ function readConfig(path: string): JsonRecord {
84
+ if (!existsSync(path)) return {};
85
+ try {
86
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
87
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
88
+ ? (parsed as JsonRecord)
89
+ : {};
90
+ } catch {
91
+ // Unreadable config: treat as empty for GET; PUT refuses instead of
92
+ // silently clobbering whatever the user had there.
93
+ return {};
94
+ }
95
+ }
96
+
97
+ function writeConfig(path: string, config: JsonRecord): void {
98
+ mkdirSync(dirname(path), { recursive: true });
99
+ // Atomic-enough: temp file + rename, so the extension never reads a torn write.
100
+ const tmp = `${path}.${process.pid}.tmp`;
101
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
102
+ renameSync(tmp, path);
103
+ }
104
+
105
+ export function createWebSearchRoutes(deps: WebSearchRoutesDeps): Hono {
106
+ const { agent } = deps;
107
+ const configPath = () => deps.configPath ?? join(resolvePiConfigDir(), "web-search.json");
108
+
109
+ const piConfigured = () =>
110
+ Boolean(agent.piProfilesJson) || (agent.backend || "claude") === "pi";
111
+
112
+ function view(): WebSearchConfigView {
113
+ const config = readConfig(configPath());
114
+ const provider =
115
+ typeof config.provider === "string" && MANAGED_IDS.has(config.provider)
116
+ ? config.provider
117
+ : "auto";
118
+ return {
119
+ configured: true,
120
+ provider,
121
+ providers: MANAGED_PROVIDERS.map((p) => ({
122
+ id: p.id,
123
+ label: p.label,
124
+ hasKeyField: p.keyField !== null,
125
+ keyConfigured:
126
+ p.keyField !== null &&
127
+ typeof config[p.keyField] === "string" &&
128
+ (config[p.keyField] as string).trim() !== "",
129
+ keyless: p.keyless,
130
+ })),
131
+ };
132
+ }
133
+
134
+ return new Hono()
135
+ .get("/web-search", (c) => {
136
+ // Not-configured is a normal state, not an error: the client hides the
137
+ // whole card (same convention as /pi-auth/providers).
138
+ if (!piConfigured()) {
139
+ return c.json({ configured: false, provider: "auto", providers: [] });
140
+ }
141
+ return c.json(view());
142
+ })
143
+ .put("/web-search", async (c) => {
144
+ if (!piConfigured()) {
145
+ return c.json({ error: "The pi backend is not configured." }, 409);
146
+ }
147
+ const body = (await c.req.json().catch(() => null)) as {
148
+ provider?: unknown;
149
+ apiKeys?: unknown;
150
+ } | null;
151
+ if (!body || typeof body !== "object") {
152
+ return c.json({ error: "Invalid body." }, 400);
153
+ }
154
+
155
+ const path = configPath();
156
+ if (existsSync(path)) {
157
+ // A config that exists but does not parse must not be clobbered by a
158
+ // settings save — that would eat hand-written provider config.
159
+ try {
160
+ JSON.parse(readFileSync(path, "utf-8"));
161
+ } catch {
162
+ return c.json(
163
+ { error: `${path} exists but is not valid JSON — fix or remove it first.` },
164
+ 409
165
+ );
166
+ }
167
+ }
168
+ const config = readConfig(path);
169
+
170
+ if (body.provider !== undefined) {
171
+ if (typeof body.provider !== "string" || !MANAGED_IDS.has(body.provider)) {
172
+ return c.json({ error: "Unknown provider." }, 400);
173
+ }
174
+ // "auto" is the extension's default — store it as absence, so an
175
+ // untouched deployment and a reset-to-default one look identical.
176
+ if (body.provider === "auto") delete config.provider;
177
+ else config.provider = body.provider;
178
+ }
179
+
180
+ if (body.apiKeys !== undefined) {
181
+ if (!body.apiKeys || typeof body.apiKeys !== "object" || Array.isArray(body.apiKeys)) {
182
+ return c.json({ error: "apiKeys must be an object." }, 400);
183
+ }
184
+ for (const [id, value] of Object.entries(body.apiKeys as JsonRecord)) {
185
+ const spec = MANAGED_PROVIDERS.find((p) => p.id === id);
186
+ if (!spec || spec.keyField === null) {
187
+ return c.json({ error: `Provider "${id}" does not take an API key here.` }, 400);
188
+ }
189
+ if (value === null || value === "") {
190
+ delete config[spec.keyField];
191
+ } else if (typeof value === "string") {
192
+ const trimmed = value.trim();
193
+ if (!trimmed || trimmed.length > 512 || /[\r\n]/.test(trimmed)) {
194
+ return c.json({ error: `Invalid API key for "${id}".` }, 400);
195
+ }
196
+ config[spec.keyField] = trimmed;
197
+ } else {
198
+ return c.json({ error: `Invalid API key for "${id}".` }, 400);
199
+ }
200
+ }
201
+ }
202
+
203
+ writeConfig(path, config);
204
+
205
+ // Extension modules cache their config per process; clearing pi's
206
+ // extension cache makes NEW sessions re-read web-search.json. Best
207
+ // effort: on failure the change still lands, it just needs a restart
208
+ // (or a naturally recycled session) to apply.
209
+ try {
210
+ const mod = (await loadBackendModule("pi", deps.importer)) as {
211
+ invalidateExtensionCache?: () => Promise<boolean>;
212
+ };
213
+ await mod.invalidateExtensionCache?.();
214
+ } catch {
215
+ /* config written; cache clearing is an optimization */
216
+ }
217
+
218
+ return c.json(view());
219
+ });
220
+ }