@bhooai/nexus-cli 0.1.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.
- package/PLAN.md +141 -0
- package/README.md +34 -0
- package/package.json +25 -0
- package/src/commands/cluster.ts +133 -0
- package/src/commands/dev.ts +133 -0
- package/src/commands/doctor.ts +199 -0
- package/src/commands/init.ts +960 -0
- package/src/commands/node.ts +101 -0
- package/src/commands/pysetup.ts +136 -0
- package/src/commands/sync.ts +116 -0
- package/src/commands/uninstall.ts +287 -0
- package/src/config-sync.ts +384 -0
- package/src/dotenv.ts +39 -0
- package/src/index.ts +94 -0
- package/src/supervisor.ts +384 -0
- package/src/util.ts +123 -0
- package/src/wizard.ts +149 -0
- package/templates/Dockerfile +60 -0
- package/templates/README.md +69 -0
- package/templates/apps/admin/index.html +12 -0
- package/templates/apps/admin/package.json +24 -0
- package/templates/apps/admin/postcss.config.js +6 -0
- package/templates/apps/admin/src/main.tsx +10 -0
- package/templates/apps/admin/src/vite-env.d.ts +18 -0
- package/templates/apps/admin/tailwind.config.js +9 -0
- package/templates/apps/admin/tsconfig.json +17 -0
- package/templates/apps/admin/vite.config.ts +64 -0
- package/templates/apps/ai-server/main.py +43 -0
- package/templates/apps/ai-server/providers/__init__.py +3 -0
- package/templates/apps/ai-server/providers/base.py +111 -0
- package/templates/apps/ai-server/requirements.txt +3 -0
- package/templates/apps/ai-server/routers/__init__.py +3 -0
- package/templates/apps/ai-server/routers/chat.py +47 -0
- package/templates/apps/ai-server/routers/embeddings.py +30 -0
- package/templates/apps/ai-server/routers/lint.py +167 -0
- package/templates/apps/ai-server/routers/models.py +23 -0
- package/templates/apps/ai-server/routers/preflight.py +169 -0
- package/templates/apps/ai-server/settings.py +48 -0
- package/templates/apps/backend/package.json +33 -0
- package/templates/apps/backend/src/main.ts +375 -0
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
- package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
- package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
- package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
- package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
- package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
- package/templates/apps/backend/tsconfig.json +14 -0
- package/templates/apps/frontend/index.html +12 -0
- package/templates/apps/frontend/package.json +19 -0
- package/templates/apps/frontend/src/main.tsx +64 -0
- package/templates/apps/frontend/vite.config.ts +63 -0
- package/templates/bin/nexus.js +35 -0
- package/templates/bin/serve-all.mjs +45 -0
- package/templates/dockerignore +15 -0
- package/templates/gitignore +12 -0
- package/templates/nexus.config.ts +69 -0
- package/templates/package.json +47 -0
- package/templates/tsconfig.json +17 -0
- package/templates/uploads/.gitkeep +0 -0
- package/tests/cli.test.ts +45 -0
- package/tests/config-sync.test.ts +201 -0
- package/tests/dotenv.test.ts +51 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +9 -0
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI-enabled schema generation for MongoDB + AI provider management.
|
|
3
|
+
*
|
|
4
|
+
* Routes:
|
|
5
|
+
* GET /admin/ai/status — probe the Python AI server / OpenAI / Ollama
|
|
6
|
+
* POST /admin/schemas/generate — generate a MongoDB $jsonSchema from text
|
|
7
|
+
* GET /admin/ai/providers — list configured providers (keys masked)
|
|
8
|
+
* POST /admin/ai/providers — add a custom provider
|
|
9
|
+
* PUT /admin/ai/providers/:id — update a provider (key, enabled, model…)
|
|
10
|
+
* DELETE /admin/ai/providers/:id — remove a provider + its .env key
|
|
11
|
+
* POST /admin/ai/providers/:id/test — probe a single provider's connectivity
|
|
12
|
+
*
|
|
13
|
+
* Provider data is persisted in three places:
|
|
14
|
+
* - in-memory aiConfig.providers array (live, so the AI proxy sees changes
|
|
15
|
+
* without a restart),
|
|
16
|
+
* - nexus.runtime.json `ai.providers` override (survives restarts; secrets
|
|
17
|
+
* are NOT written here — only id/label/baseUrl/enabled/defaultModel),
|
|
18
|
+
* - nexus_projects.settings.aiProviders (MongoDB, for cross-project visibility),
|
|
19
|
+
* - API keys → .env as NEXUS_AI_<ID>_API_KEY (never in the config file).
|
|
20
|
+
*/
|
|
21
|
+
import type { Router, Middleware } from '@bhooai/nexus-core';
|
|
22
|
+
import type { AiProviderConfig } from '@bhooai/nexus-core';
|
|
23
|
+
import { AiClient } from '@bhooai/nexus-ai-client';
|
|
24
|
+
import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
import { resolve, join } from 'node:path';
|
|
27
|
+
import { getProjectInfoCollection } from '@bhooai/nexus-data';
|
|
28
|
+
|
|
29
|
+
export interface SchemaAIConfig {
|
|
30
|
+
serverUrl: string;
|
|
31
|
+
timeoutMs: number;
|
|
32
|
+
/** Model to use for generation (config: `ai.schemaModel`). */
|
|
33
|
+
model?: string;
|
|
34
|
+
/** Pre-built client (tests inject a stub). */
|
|
35
|
+
client?: Pick<AiClient, 'chat' | 'listModels'>;
|
|
36
|
+
/** AI providers from config (mutable — the admin endpoints update this in
|
|
37
|
+
* place so the AI proxy sees new keys/enabled state without a restart). */
|
|
38
|
+
providers?: AiProviderConfig[];
|
|
39
|
+
/** Project root — used to read/write the .env file + nexus.runtime.json. */
|
|
40
|
+
root?: string;
|
|
41
|
+
/** Canonical project name — used to persist settings to nexus_projects. */
|
|
42
|
+
projectName?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_SCHEMA_MODEL = 'gpt-4o-mini';
|
|
46
|
+
|
|
47
|
+
/** Per-check result for the AI status probe. */
|
|
48
|
+
export interface AiProbeResult<T = unknown> {
|
|
49
|
+
ok: boolean;
|
|
50
|
+
error?: string;
|
|
51
|
+
detail?: T;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AiStatus {
|
|
55
|
+
checkedAt: string;
|
|
56
|
+
/** Python AI server reachable? */
|
|
57
|
+
aiServer: AiProbeResult<{ status?: string; providers?: string[] }>;
|
|
58
|
+
openai: AiProbeResult<{ provider?: string; modelCount?: number; models?: string[] }>;
|
|
59
|
+
ollama: AiProbeResult<{ provider?: string; modelCount?: number; models?: string[] }>;
|
|
60
|
+
/** Which provider 'auto' currently resolves to on the AI server. */
|
|
61
|
+
autoResolvesTo: 'openai' | 'ollama';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SYSTEM_PROMPT = `You are a MongoDB schema designer. Given the user's description of the data they want to store, produce ONLY a valid JSON object — no markdown fences, no commentary — shaped exactly like this:
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
"collection": "<plural, snake_case collection name>",
|
|
68
|
+
"fields": [
|
|
69
|
+
{ "name": "<field>", "type": "String|Number|Boolean|Date|ObjectId|Array|Mixed", "required": false, "unique": false, "enum": ["a","b"], "description": "<short note>" }
|
|
70
|
+
],
|
|
71
|
+
"jsonSchema": { "$jsonSchema": { "bsonType": "object", "required": ["..."], "properties": { "<field>": { "bsonType": "..." } } } }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
Rules:
|
|
75
|
+
- Every listed field must appear in properties with a matching bsonType (string, int/double/long, bool, date, objectId, array, object).
|
|
76
|
+
- required lists only fields marked required: true. unique fields must be marked with a unique index note in their description.
|
|
77
|
+
- Give the collection a sensible plural snake_case name.`;
|
|
78
|
+
|
|
79
|
+
/** Normalized result returned to the admin UI. */
|
|
80
|
+
export interface GeneratedSchema {
|
|
81
|
+
collection: string;
|
|
82
|
+
fields: Array<{ name: string; type: string; required?: boolean; unique?: boolean; enum?: string[]; description?: string }>;
|
|
83
|
+
jsonSchema: { $jsonSchema: { bsonType: string; required?: string[]; properties: Record<string, unknown> } };
|
|
84
|
+
model: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Env var name for a provider's API key (e.g. "together" → "NEXUS_AI_TOGETHER_API_KEY"). */
|
|
88
|
+
function providerEnvKey(providerId: string): string {
|
|
89
|
+
return `NEXUS_AI_${providerId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Write a single key=value line into the .env file (creates or updates it). */
|
|
93
|
+
async function writeEnvKey(root: string, key: string, value: string): Promise<void> {
|
|
94
|
+
const envPath = resolve(root, '.env');
|
|
95
|
+
const content = existsSync(envPath) ? await readFile(envPath, 'utf8') : '';
|
|
96
|
+
const lines = content.split(/\r?\n/);
|
|
97
|
+
const regex = new RegExp(`^(\\s*export\\s+)?${key}\\s*=`);
|
|
98
|
+
let found = false;
|
|
99
|
+
const output = lines.map((line) => {
|
|
100
|
+
if (regex.test(line)) { found = true; return `${key}=${value}`; }
|
|
101
|
+
return line;
|
|
102
|
+
});
|
|
103
|
+
if (!found) {
|
|
104
|
+
if (output.length > 0 && output[output.length - 1] === '') output[output.length - 1] = `${key}=${value}`;
|
|
105
|
+
else output.push(`${key}=${value}`);
|
|
106
|
+
}
|
|
107
|
+
const tempPath = `${envPath}.tmp-${process.pid}-${Date.now()}`;
|
|
108
|
+
await writeFile(tempPath, `${output.join('\n')}\n`, 'utf8');
|
|
109
|
+
try { await rename(tempPath, envPath); }
|
|
110
|
+
catch (err) {
|
|
111
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST' && (err as NodeJS.ErrnoException).code !== 'EPERM') throw err;
|
|
112
|
+
await unlink(envPath).catch(() => undefined);
|
|
113
|
+
await rename(tempPath, envPath);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Delete a key from the .env file. */
|
|
118
|
+
async function deleteEnvKey(root: string, key: string): Promise<void> {
|
|
119
|
+
const envPath = resolve(root, '.env');
|
|
120
|
+
if (!existsSync(envPath)) return;
|
|
121
|
+
const content = await readFile(envPath, 'utf8');
|
|
122
|
+
const regex = new RegExp(`^(\\s*export\\s+)?${key}\\s*=`);
|
|
123
|
+
const output = content.split(/\r?\n/).filter((line) => !regex.test(line));
|
|
124
|
+
if (output.length === content.split(/\r?\n/).length) return;
|
|
125
|
+
const tempPath = `${envPath}.tmp-${process.pid}-${Date.now()}`;
|
|
126
|
+
await writeFile(tempPath, `${output.join('\n')}\n`, 'utf8');
|
|
127
|
+
try { await rename(tempPath, envPath); }
|
|
128
|
+
catch (err) {
|
|
129
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST' && (err as NodeJS.ErrnoException).code !== 'EPERM') throw err;
|
|
130
|
+
await unlink(envPath).catch(() => undefined);
|
|
131
|
+
await rename(tempPath, envPath);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Persist the provider list (sans API keys) to nexus.runtime.json so the
|
|
136
|
+
* change survives restarts. The runtime file is a JSON object of DeepPartial
|
|
137
|
+
* config overrides; we merge `ai.providers` into it. */
|
|
138
|
+
async function persistRuntimeProviders(root: string, providers: AiProviderConfig[]): Promise<void> {
|
|
139
|
+
const runtimePath = join(root, 'nexus.runtime.json');
|
|
140
|
+
let overrides: Record<string, any> = {};
|
|
141
|
+
if (existsSync(runtimePath)) {
|
|
142
|
+
try { overrides = JSON.parse(await readFile(runtimePath, 'utf8')) as Record<string, any>; }
|
|
143
|
+
catch { /* corrupt file — start fresh */ }
|
|
144
|
+
}
|
|
145
|
+
const ai = (overrides.ai ?? {}) as Record<string, any>;
|
|
146
|
+
ai.providers = providers.map((p) => ({
|
|
147
|
+
id: p.id, label: p.label, baseUrl: p.baseUrl,
|
|
148
|
+
enabled: p.enabled,
|
|
149
|
+
...(p.defaultModel ? { defaultModel: p.defaultModel } : {}),
|
|
150
|
+
}));
|
|
151
|
+
overrides.ai = ai;
|
|
152
|
+
const tempPath = `${runtimePath}.tmp-${process.pid}-${Date.now()}`;
|
|
153
|
+
await writeFile(tempPath, JSON.stringify(overrides, null, 2) + '\n', 'utf8');
|
|
154
|
+
try { await rename(tempPath, runtimePath); }
|
|
155
|
+
catch (err) {
|
|
156
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST' && (err as NodeJS.ErrnoException).code !== 'EPERM') throw err;
|
|
157
|
+
await unlink(runtimePath).catch(() => undefined);
|
|
158
|
+
await rename(tempPath, runtimePath);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Persist the provider list (sans API keys) to the nexus_projects.settings.aiProviders
|
|
163
|
+
* field in MongoDB, so other admin views / projects can see the configured providers. */
|
|
164
|
+
async function persistDbProviders(projectName: string | undefined, providers: AiProviderConfig[]): Promise<void> {
|
|
165
|
+
if (!projectName) throw new Error('project name is unavailable for database persistence');
|
|
166
|
+
const coll = await getProjectInfoCollection();
|
|
167
|
+
const result = await coll.updateOne(
|
|
168
|
+
{ name: projectName },
|
|
169
|
+
{ $set: { 'settings.aiProviders': providers.map((p) => ({
|
|
170
|
+
id: p.id, label: p.label, baseUrl: p.baseUrl,
|
|
171
|
+
enabled: p.enabled,
|
|
172
|
+
...(p.defaultModel ? { defaultModel: p.defaultModel } : {}),
|
|
173
|
+
})) } },
|
|
174
|
+
{ upsert: false },
|
|
175
|
+
);
|
|
176
|
+
if (result.matchedCount === 0) throw new Error(`project "${projectName}" was not found in nexus_projects`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function registerSchemaRoutes(router: Router, aiConfig: SchemaAIConfig, guard: Middleware[]): void {
|
|
180
|
+
const model = aiConfig.model ?? DEFAULT_SCHEMA_MODEL;
|
|
181
|
+
|
|
182
|
+
// On boot, load any persisted API keys from .env into the in-memory providers.
|
|
183
|
+
if (aiConfig.providers) {
|
|
184
|
+
for (const p of aiConfig.providers) {
|
|
185
|
+
const envKey = providerEnvKey(p.id);
|
|
186
|
+
const simpleKey = `${p.id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
|
|
187
|
+
const envVal = process.env[envKey] ?? process.env[simpleKey];
|
|
188
|
+
if (envVal) p.apiKey = envVal;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Persist providers and report each durable store independently. */
|
|
193
|
+
const persistAll = async (): Promise<{ runtime: boolean; database: boolean }> => {
|
|
194
|
+
const providers = aiConfig.providers ?? [];
|
|
195
|
+
let runtime = false;
|
|
196
|
+
let database = false;
|
|
197
|
+
try {
|
|
198
|
+
if (aiConfig.root) {
|
|
199
|
+
await persistRuntimeProviders(aiConfig.root, providers);
|
|
200
|
+
runtime = true;
|
|
201
|
+
}
|
|
202
|
+
} catch { /* surfaced in the response */ }
|
|
203
|
+
try {
|
|
204
|
+
await persistDbProviders(aiConfig.projectName, providers);
|
|
205
|
+
database = true;
|
|
206
|
+
} catch { /* surfaced in the response */ }
|
|
207
|
+
return { runtime, database };
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
router.get('/admin/ai/status', async (ctx) => {
|
|
211
|
+
const ai = aiConfig.client ?? new AiClient({ serverUrl: aiConfig.serverUrl, timeoutMs: 10_000 });
|
|
212
|
+
const probe = async <T>(fn: () => Promise<T>): Promise<AiProbeResult<T>> => {
|
|
213
|
+
try {
|
|
214
|
+
return { ok: true, detail: await fn() };
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return { ok: false, error: (e as Error).message || 'unknown error' };
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const aiServer = await probe(async () => {
|
|
221
|
+
const controller = new AbortController();
|
|
222
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
223
|
+
try {
|
|
224
|
+
const res = await fetch(`${aiConfig.serverUrl.replace(/\/+$/, '')}/health`, { signal: controller.signal });
|
|
225
|
+
if (!res.ok) throw new Error(`AI server responded with HTTP ${res.status}`);
|
|
226
|
+
return (await res.json()) as { status?: string; providers?: string[] };
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const listModels = async (provider: 'openai' | 'ollama') => {
|
|
233
|
+
const res = await ai.listModels(provider);
|
|
234
|
+
const models = (res.data ?? []).map((m) => String(m.id ?? m)).slice(0, 10);
|
|
235
|
+
return { provider: res.provider, modelCount: (res.data ?? []).length, models };
|
|
236
|
+
};
|
|
237
|
+
const openai = await probe(() => listModels('openai'));
|
|
238
|
+
const ollama = await probe(() => listModels('ollama'));
|
|
239
|
+
|
|
240
|
+
const autoResolvesTo =
|
|
241
|
+
aiServer.detail && Array.isArray(aiServer.detail.providers)
|
|
242
|
+
? (aiServer.detail.providers.includes('openai') ? 'openai' : 'ollama')
|
|
243
|
+
: 'ollama';
|
|
244
|
+
|
|
245
|
+
ctx.json({
|
|
246
|
+
checkedAt: new Date().toISOString(),
|
|
247
|
+
aiServer,
|
|
248
|
+
openai,
|
|
249
|
+
ollama,
|
|
250
|
+
autoResolvesTo,
|
|
251
|
+
} satisfies AiStatus);
|
|
252
|
+
}, guard);
|
|
253
|
+
|
|
254
|
+
router.post('/admin/schemas/generate', async (ctx) => {
|
|
255
|
+
const body = (ctx.body ?? {}) as { prompt?: unknown; model?: unknown; provider?: unknown };
|
|
256
|
+
const prompt = typeof body.prompt === 'string' && body.prompt.trim() ? body.prompt.trim() : '';
|
|
257
|
+
if (!prompt) {
|
|
258
|
+
ctx.json({ error: 'prompt (string) is required — describe the data you want to store' }, 400);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const usedModel = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : model;
|
|
262
|
+
|
|
263
|
+
const ai = aiConfig.client ?? new AiClient({ serverUrl: aiConfig.serverUrl, timeoutMs: aiConfig.timeoutMs });
|
|
264
|
+
let text: string;
|
|
265
|
+
try {
|
|
266
|
+
const reply = await ai.chat({
|
|
267
|
+
model: usedModel,
|
|
268
|
+
temperature: 0,
|
|
269
|
+
provider: body.provider === 'auto' || body.provider === undefined ? undefined : (body.provider as 'openai' | 'ollama'),
|
|
270
|
+
messages: [
|
|
271
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
272
|
+
{ role: 'user', content: prompt },
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
text = reply.choices?.[0]?.message?.content ?? '';
|
|
276
|
+
} catch (e) {
|
|
277
|
+
ctx.status(502);
|
|
278
|
+
ctx.json({ error: `AI server error: ${(e as Error).message}` });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let parsed: GeneratedSchema;
|
|
283
|
+
try {
|
|
284
|
+
parsed = extractSchema(text);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
ctx.json({ error: `AI response was not a usable schema: ${(e as Error).message}` }, 502);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
ctx.json({ ...parsed, model: usedModel });
|
|
290
|
+
}, guard);
|
|
291
|
+
|
|
292
|
+
// ── AI provider management ───────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/** GET /admin/ai/providers — list all configured providers (API keys masked). */
|
|
295
|
+
router.get('/admin/ai/providers', async (ctx) => {
|
|
296
|
+
const providers = aiConfig.providers ?? [];
|
|
297
|
+
ctx.json({
|
|
298
|
+
providers: providers.map((p) => ({
|
|
299
|
+
...p,
|
|
300
|
+
apiKey: p.apiKey ? '••••••••' : '',
|
|
301
|
+
hasApiKey: !!p.apiKey,
|
|
302
|
+
})),
|
|
303
|
+
});
|
|
304
|
+
}, guard);
|
|
305
|
+
|
|
306
|
+
/** PUT /admin/ai/providers/:id — update a provider. API keys are persisted
|
|
307
|
+
* to .env as NEXUS_AI_<ID>_API_KEY so they survive restarts. */
|
|
308
|
+
router.put('/admin/ai/providers/:id', async (ctx) => {
|
|
309
|
+
const id = ctx.params.id;
|
|
310
|
+
const body = (ctx.body ?? {}) as { apiKey?: string; enabled?: boolean; defaultModel?: string; label?: string; baseUrl?: string };
|
|
311
|
+
const providers = aiConfig.providers ?? [];
|
|
312
|
+
const provider = providers.find((p) => p.id === id);
|
|
313
|
+
if (!provider) { ctx.json({ error: `provider "${id}" not found` }, 404); return; }
|
|
314
|
+
if (typeof body.enabled === 'boolean') provider.enabled = body.enabled;
|
|
315
|
+
if (typeof body.defaultModel === 'string' && body.defaultModel.trim()) provider.defaultModel = body.defaultModel.trim();
|
|
316
|
+
if (typeof body.label === 'string' && body.label.trim()) provider.label = body.label.trim();
|
|
317
|
+
if (typeof body.baseUrl === 'string' && body.baseUrl.trim()) provider.baseUrl = body.baseUrl.trim();
|
|
318
|
+
if (typeof body.apiKey === 'string' && body.apiKey && body.apiKey !== '••••••••') {
|
|
319
|
+
provider.apiKey = body.apiKey;
|
|
320
|
+
if (aiConfig.root) {
|
|
321
|
+
try {
|
|
322
|
+
await writeEnvKey(aiConfig.root, providerEnvKey(id), body.apiKey);
|
|
323
|
+
process.env[providerEnvKey(id)] = body.apiKey;
|
|
324
|
+
} catch { /* non-fatal — in-memory key still works */ }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const persistence = await persistAll();
|
|
328
|
+
ctx.json({ ok: true, persistence, provider: { ...provider, apiKey: provider.apiKey ? '••••••••' : '', hasApiKey: !!provider.apiKey } });
|
|
329
|
+
}, guard);
|
|
330
|
+
|
|
331
|
+
/** POST /admin/ai/providers — add a custom provider.
|
|
332
|
+
* API key (if provided) is persisted to .env as NEXUS_AI_<ID>_API_KEY. */
|
|
333
|
+
router.post('/admin/ai/providers', async (ctx) => {
|
|
334
|
+
const body = (ctx.body ?? {}) as { id?: string; label?: string; baseUrl?: string; apiKey?: string; defaultModel?: string; enabled?: boolean };
|
|
335
|
+
const id = typeof body.id === 'string' ? body.id.trim().toLowerCase() : '';
|
|
336
|
+
const label = typeof body.label === 'string' ? body.label.trim() : '';
|
|
337
|
+
const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : '';
|
|
338
|
+
if (!id || !label || !baseUrl) { ctx.json({ error: 'id, label and baseUrl are required' }, 400); return; }
|
|
339
|
+
const providers = aiConfig.providers ?? [];
|
|
340
|
+
if (providers.find((p) => p.id === id)) { ctx.json({ error: `provider "${id}" already exists` }, 409); return; }
|
|
341
|
+
const apiKey = typeof body.apiKey === 'string' && body.apiKey ? body.apiKey : undefined;
|
|
342
|
+
const provider: AiProviderConfig = {
|
|
343
|
+
id, label, baseUrl,
|
|
344
|
+
enabled: body.enabled ?? true,
|
|
345
|
+
apiKey,
|
|
346
|
+
defaultModel: typeof body.defaultModel === 'string' && body.defaultModel.trim() ? body.defaultModel.trim() : undefined,
|
|
347
|
+
};
|
|
348
|
+
providers.push(provider);
|
|
349
|
+
aiConfig.providers = providers;
|
|
350
|
+
if (apiKey && aiConfig.root) {
|
|
351
|
+
try {
|
|
352
|
+
await writeEnvKey(aiConfig.root, providerEnvKey(id), apiKey);
|
|
353
|
+
process.env[providerEnvKey(id)] = apiKey;
|
|
354
|
+
} catch { /* non-fatal */ }
|
|
355
|
+
}
|
|
356
|
+
const persistence = await persistAll();
|
|
357
|
+
ctx.json({ ok: true, persistence, provider: { ...provider, apiKey: provider.apiKey ? '••••••••' : '', hasApiKey: !!provider.apiKey } });
|
|
358
|
+
}, guard);
|
|
359
|
+
|
|
360
|
+
/** DELETE /admin/ai/providers/:id — remove a provider + its .env key. */
|
|
361
|
+
router.delete('/admin/ai/providers/:id', async (ctx) => {
|
|
362
|
+
const id = ctx.params.id;
|
|
363
|
+
const providers = aiConfig.providers ?? [];
|
|
364
|
+
const idx = providers.findIndex((p) => p.id === id);
|
|
365
|
+
if (idx === -1) { ctx.json({ error: `provider "${id}" not found` }, 404); return; }
|
|
366
|
+
providers.splice(idx, 1);
|
|
367
|
+
aiConfig.providers = providers;
|
|
368
|
+
if (aiConfig.root) {
|
|
369
|
+
try { await deleteEnvKey(aiConfig.root, providerEnvKey(id)); } catch { /* non-fatal */ }
|
|
370
|
+
}
|
|
371
|
+
const persistence = await persistAll();
|
|
372
|
+
ctx.json({ ok: true, persistence });
|
|
373
|
+
}, guard);
|
|
374
|
+
|
|
375
|
+
/** POST /admin/ai/providers/:id/test — probe a single provider's connectivity. */
|
|
376
|
+
router.post('/admin/ai/providers/:id/test', async (ctx) => {
|
|
377
|
+
const id = ctx.params.id;
|
|
378
|
+
const providers = aiConfig.providers ?? [];
|
|
379
|
+
const provider = providers.find((p) => p.id === id);
|
|
380
|
+
if (!provider) { ctx.json({ error: `provider "${id}" not found` }, 404); return; }
|
|
381
|
+
const ai = aiConfig.client ?? new AiClient({ serverUrl: aiConfig.serverUrl, timeoutMs: 8_000 });
|
|
382
|
+
try {
|
|
383
|
+
const res = await ai.listModels(id);
|
|
384
|
+
const models = (res.data ?? []).map((m) => String(m.id ?? m)).slice(0, 20);
|
|
385
|
+
ctx.json({
|
|
386
|
+
ok: true,
|
|
387
|
+
provider: id,
|
|
388
|
+
modelCount: (res.data ?? []).length,
|
|
389
|
+
models,
|
|
390
|
+
checkedAt: new Date().toISOString(),
|
|
391
|
+
});
|
|
392
|
+
} catch (e) {
|
|
393
|
+
ctx.json({
|
|
394
|
+
ok: false,
|
|
395
|
+
provider: id,
|
|
396
|
+
error: (e as Error).message || 'unknown error',
|
|
397
|
+
checkedAt: new Date().toISOString(),
|
|
398
|
+
}, 200);
|
|
399
|
+
}
|
|
400
|
+
}, guard);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Parse the AI reply (tolerates markdown fences and stray prose around the JSON). */
|
|
404
|
+
function extractSchema(text: string): GeneratedSchema {
|
|
405
|
+
const cleaned = stripFences(text).trim();
|
|
406
|
+
const start = cleaned.indexOf('{');
|
|
407
|
+
const end = cleaned.lastIndexOf('}');
|
|
408
|
+
if (start === -1 || end <= start) throw new Error('no JSON object found in the response');
|
|
409
|
+
const parsed = JSON.parse(cleaned.slice(start, end + 1)) as {
|
|
410
|
+
collection?: unknown;
|
|
411
|
+
fields?: unknown;
|
|
412
|
+
jsonSchema?: unknown;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
if (typeof parsed.collection !== 'string' || !/^[A-Za-z][A-Za-z0-9_]*$/.test(parsed.collection)) {
|
|
416
|
+
throw new Error(`invalid collection name from AI: ${String(parsed.collection)}`);
|
|
417
|
+
}
|
|
418
|
+
if (!Array.isArray(parsed.fields)) throw new Error('"fields" must be an array');
|
|
419
|
+
const schema = parsed.jsonSchema as { $jsonSchema?: unknown } | undefined;
|
|
420
|
+
const jsonSchema = schema?.$jsonSchema as Record<string, unknown> | undefined;
|
|
421
|
+
if (!jsonSchema || jsonSchema.bsonType !== 'object' || typeof jsonSchema.properties !== 'object' || jsonSchema.properties === null) {
|
|
422
|
+
throw new Error('"jsonSchema" must contain $jsonSchema with bsonType "object" and properties');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const fields = parsed.fields.map((f) => {
|
|
426
|
+
const raw = (f ?? {}) as Record<string, unknown>;
|
|
427
|
+
return {
|
|
428
|
+
name: String(raw.name ?? ''),
|
|
429
|
+
type: String(raw.type ?? 'Mixed'),
|
|
430
|
+
required: !!raw.required,
|
|
431
|
+
unique: !!raw.unique,
|
|
432
|
+
enum: Array.isArray(raw.enum) ? (raw.enum as string[]).map(String) : undefined,
|
|
433
|
+
description: typeof raw.description === 'string' ? raw.description : undefined,
|
|
434
|
+
};
|
|
435
|
+
});
|
|
436
|
+
if (!fields.length || fields.some((f) => !f.name)) throw new Error('fields must have unique names');
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
collection: parsed.collection,
|
|
440
|
+
fields,
|
|
441
|
+
jsonSchema: { $jsonSchema: jsonSchema as GeneratedSchema['jsonSchema']['$jsonSchema'] },
|
|
442
|
+
model: '',
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function stripFences(text: string): string {
|
|
447
|
+
const m = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
448
|
+
return m ? m[1]! : text;
|
|
449
|
+
}
|