From cdad9d3931bb4e84a9f0227f10aa8dfaca791d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Sat, 2 May 2026 11:49:06 +1000 Subject: [PATCH] Align OpenAI key settings with local model profiles --- app/api/settings/openai-key/route.ts | 24 +++++ src/components/onboarding/FirstRunModal.tsx | 2 +- src/components/settings/ApiKeysViewer.tsx | 108 +++++++++++++++++++- 3 files changed, 130 insertions(+), 4 deletions(-) diff --git a/app/api/settings/openai-key/route.ts b/app/api/settings/openai-key/route.ts index 386b3a8..2c9b71c 100644 --- a/app/api/settings/openai-key/route.ts +++ b/app/api/settings/openai-key/route.ts @@ -11,10 +11,23 @@ export const runtime = 'nodejs'; function buildResponse(key: string | null) { const configured = isValidOpenAiKey(key); + const llmProfile = process.env.LLM_PROFILE || 'openai'; + const embeddingProfile = process.env.EMBEDDING_PROFILE || 'openai'; + const openAiKeyWritable = llmProfile === 'openai' || embeddingProfile === 'openai'; return { configured, maskedKey: configured ? maskOpenAiKey(key) : null, envPath: getEnvLocalPath(), + openAiKeyWritable, + activeProfile: { + llmProfile, + llmModel: process.env.LLM_MODEL || (llmProfile === 'openai' ? 'gpt-4o-mini' : null), + llmBaseUrl: process.env.LLM_BASE_URL || null, + embeddingProfile, + embeddingModel: process.env.EMBEDDING_MODEL || (embeddingProfile === 'openai' ? 'text-embedding-3-small' : null), + embeddingBaseUrl: process.env.EMBEDDING_BASE_URL || process.env.LLM_BASE_URL || null, + embeddingDimensions: process.env.EMBEDDING_DIMENSIONS || (embeddingProfile === 'openai' ? '1536' : '1024'), + }, }; } @@ -37,6 +50,17 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); const key = typeof body?.key === 'string' ? body.key.trim() : ''; + const currentState = buildResponse(await readStoredOpenAiKey()); + + if (!currentState.openAiKeyWritable) { + return NextResponse.json( + { + error: 'OpenAI key entry is disabled because this install is using a local model profile. Change .env.local and rebuild embeddings to switch providers.', + ...currentState, + }, + { status: 409 } + ); + } if (!isValidOpenAiKey(key)) { return NextResponse.json( diff --git a/src/components/onboarding/FirstRunModal.tsx b/src/components/onboarding/FirstRunModal.tsx index cd07643..4e2f656 100644 --- a/src/components/onboarding/FirstRunModal.tsx +++ b/src/components/onboarding/FirstRunModal.tsx @@ -21,7 +21,7 @@ export default function FirstRunModal() { if (cancelled) return; const dismissed = window.localStorage.getItem(FIRST_RUN_DISMISSED_KEY) === 'true'; - setIsOpen(!data.configured && !dismissed); + setIsOpen(data.openAiKeyWritable !== false && !data.configured && !dismissed); } catch { if (!cancelled) { const dismissed = window.localStorage.getItem(FIRST_RUN_DISMISSED_KEY) === 'true'; diff --git a/src/components/settings/ApiKeysViewer.tsx b/src/components/settings/ApiKeysViewer.tsx index e4ee448..107e4e1 100644 --- a/src/components/settings/ApiKeysViewer.tsx +++ b/src/components/settings/ApiKeysViewer.tsx @@ -3,11 +3,23 @@ import { useState, useEffect, type CSSProperties } from 'react'; import { openExternalUrl } from '@/utils/openExternalUrl'; +type ActiveProfile = { + llmProfile: string; + llmModel: string | null; + llmBaseUrl: string | null; + embeddingProfile: string; + embeddingModel: string | null; + embeddingBaseUrl: string | null; + embeddingDimensions: string; +}; + export default function ApiKeysViewer() { const [status, setStatus] = useState<'checking' | 'configured' | 'not-set'>('checking'); const [keyInput, setKeyInput] = useState(''); const [maskedKey, setMaskedKey] = useState(null); const [envPath, setEnvPath] = useState('.env.local'); + const [openAiKeyWritable, setOpenAiKeyWritable] = useState(true); + const [activeProfile, setActiveProfile] = useState(null); const [message, setMessage] = useState(null); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); @@ -19,6 +31,8 @@ export default function ApiKeysViewer() { setStatus(data.configured ? 'configured' : 'not-set'); setMaskedKey(data.maskedKey ?? null); setEnvPath(data.envPath ?? '.env.local'); + setOpenAiKeyWritable(data.openAiKeyWritable !== false); + setActiveProfile(data.activeProfile ?? null); }) .catch(() => setStatus('not-set')); }, []); @@ -44,6 +58,8 @@ export default function ApiKeysViewer() { setStatus('configured'); setMaskedKey(payload.maskedKey ?? null); setEnvPath(payload.envPath ?? envPath); + setOpenAiKeyWritable(payload.openAiKeyWritable !== false); + setActiveProfile(payload.activeProfile ?? activeProfile); setKeyInput(''); setMessage('Saved to .env.local and updated the running app.'); } catch (err) { @@ -71,6 +87,8 @@ export default function ApiKeysViewer() { setMaskedKey(null); setKeyInput(''); setEnvPath(payload.envPath ?? envPath); + setOpenAiKeyWritable(payload.openAiKeyWritable !== false); + setActiveProfile(payload.activeProfile ?? activeProfile); setMessage('Removed from .env.local and cleared the running app key.'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to remove API key'); @@ -81,8 +99,38 @@ export default function ApiKeysViewer() { return (
+ {activeProfile && ( +
+
+ {openAiKeyWritable ? 'OpenAI profile active' : 'Local model profile active'} +
+
+
+
Utility LLM
+ {activeProfile.llmProfile} + {activeProfile.llmModel && {activeProfile.llmModel}} +
+
+
Embeddings
+ {activeProfile.embeddingProfile} + {activeProfile.embeddingModel && {activeProfile.embeddingModel}} +
+
+
Embedding width
+ {activeProfile.embeddingDimensions} +
+
+ {!openAiKeyWritable && ( +

+ This install is already configured for local models. OpenAI key entry is disabled here so the app does not drift away from the setup profile. To switch providers, edit {envPath} and run npm run rebuild:embeddings. +

+ )} +
+ )} + {/* Features explanation */} -
+ {openAiKeyWritable && ( +
OpenAI API Key enables:
  • Auto-generated descriptions for new nodes
  • @@ -93,9 +141,11 @@ export default function ApiKeysViewer() { Without a key, you can still create and organise nodes manually.
+ )} {/* Status */} -
+ {openAiKeyWritable ? ( +
OpenAI API Key
+ ) : ( +
+
+ OpenAI API Key + Disabled +
+
+

+ This workspace is using the local model profile selected during setup. The app will call the configured OpenAI-compatible local endpoints for descriptions and embeddings instead of using an OpenAI API key. +

+ {activeProfile?.llmBaseUrl && ( +

+ Local endpoint: {activeProfile.llmBaseUrl} +

+ )} +
+
+ )} {/* Get key link */} -
+ {openAiKeyWritable && ( +
+ )}
); } @@ -212,6 +282,38 @@ const featuresBoxStyle: CSSProperties = { marginBottom: 20, }; +const profileBoxStyle: CSSProperties = { + background: 'var(--settings-card-bg)', + border: '1px solid var(--settings-border)', + borderRadius: 8, + padding: 16, + marginBottom: 20, +}; + +const profileGridStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', + gap: 12, +}; + +const profileLabelStyle: CSSProperties = { + fontSize: 11, + color: 'var(--settings-muted)', + marginBottom: 6, +}; + +const profileValueStyle: CSSProperties = { + fontSize: 12, + color: 'var(--settings-subtext)', +}; + +const profileNoteStyle: CSSProperties = { + margin: '12px 0 0', + fontSize: 12, + color: 'var(--settings-muted)', + lineHeight: 1.5, +}; + const featuresHeaderStyle: CSSProperties = { fontSize: 13, fontWeight: 500,