Secrets must never be baked into the static build. Stop reading VITE_AI_API_KEY from env — key comes only from localStorage via the ⚙️ config dialog (flow-state:llm). Gateway URL / model may still come from env as non-secret defaults. - src/llm/config.js: DEFAULTS model → meta/muse-spark-1.3-contributor (cheap, per user request), readEnv no longer touches apiKey, getLlmConfig resolves key only from stored.apiKey. - .env.example: drop VITE_AI_API_KEY=, document browser-only key flow. - src/main.js: dialog copy now says key is browser-only, never baked. Build + lint:scenes + tests still green. No VITE_AI_API_KEY string remains in source. Co-Authored-By: Claude <noreply@anthropic.com>
89 lines
2.9 KiB
JavaScript
89 lines
2.9 KiB
JavaScript
// LLM gateway config — localStorage for the secret, env only for non-secrets.
|
|
//
|
|
// The app is a static Vite site with no server. The API key is a secret and
|
|
// must never be baked into the build — it lives only in the browser's
|
|
// localStorage (→ ⚙️ dialog). Gateway URL and model are non-secret and may
|
|
// come from VITE env as defaults with a localStorage override; the key never
|
|
// does.
|
|
|
|
const STORAGE_KEY = 'flow-state:llm';
|
|
|
|
const DEFAULTS = {
|
|
gatewayUrl: 'https://ai-gateway.vercel.sh/v1',
|
|
model: 'meta/muse-spark-1.3-contributor',
|
|
apiKey: '',
|
|
};
|
|
|
|
function readEnv() {
|
|
try {
|
|
const env = import.meta.env || {};
|
|
return {
|
|
gatewayUrl: env.VITE_AI_GATEWAY_URL || null,
|
|
model: env.VITE_AI_MODEL || null,
|
|
// apiKey intentionally NOT read from env — see module comment.
|
|
};
|
|
} catch {
|
|
return { gatewayUrl: null, model: null };
|
|
}
|
|
}
|
|
|
|
function readStorage() {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return {};
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Resolved config — key from localStorage only (never env); URL/model from localStorage > env > defaults. */
|
|
export function getLlmConfig() {
|
|
const env = readEnv();
|
|
const stored = readStorage();
|
|
const gatewayUrl = stored.gatewayUrl || env.gatewayUrl || DEFAULTS.gatewayUrl;
|
|
const model = stored.model || env.model || DEFAULTS.model;
|
|
const key = typeof stored.apiKey === 'string' ? stored.apiKey.trim() : '';
|
|
return {
|
|
gatewayUrl: String(gatewayUrl || DEFAULTS.gatewayUrl).trim() || DEFAULTS.gatewayUrl,
|
|
model: String(model || DEFAULTS.model).trim() || DEFAULTS.model,
|
|
apiKey: key,
|
|
isConfigured: key.length > 0,
|
|
defaults: { ...DEFAULTS },
|
|
env: { ...env },
|
|
stored: { ...stored },
|
|
};
|
|
}
|
|
|
|
/** Merge patch into localStorage. Pass null/'' to clear a field. */
|
|
export function setLlmConfig(patch) {
|
|
const current = readStorage();
|
|
const next = { ...current };
|
|
for (const k of ['gatewayUrl', 'model', 'apiKey']) {
|
|
if (patch && Object.prototype.hasOwnProperty.call(patch, k)) {
|
|
const v = patch[k];
|
|
if (v == null || v === '') {
|
|
delete next[k];
|
|
} else {
|
|
next[k] = String(v).trim();
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
if (!Object.keys(next).length) localStorage.removeItem(STORAGE_KEY);
|
|
else localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
} catch {
|
|
// storage full / blocked — ignore, caller sees isConfigured false
|
|
}
|
|
return getLlmConfig();
|
|
}
|
|
|
|
export function clearLlmConfig() {
|
|
try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ }
|
|
return getLlmConfig();
|
|
}
|
|
|
|
export const LLM_STORAGE_KEY = STORAGE_KEY;
|
|
export const LLM_DEFAULTS = DEFAULTS;
|