373d10595b
Captures roughly forty version bumps (v0.2.6 → v0.2.47) of work that
accumulated without commits.
- Pluggable provider system under server/providers/: gemini, anthropic,
openai, openai-compatible, ollama, whisper-compatible, relay. Mix and
match transcription + analysis per request via the picker UI.
- Relay backend integration. Hardcoded relay URL in server/relay-default.js
(operator-controlled at build time, not user-configurable). New
/api/relay/{status,policy} endpoints proxy to the relay; balance pings
populate a cached credit display.
- Per-install identity in server/install-id.js for relay credit accounting.
Sent to the relay as X-Recap-Install-Id; persists across upgrades, lost
on a full uninstall + reinstall. Not surfaced in the UI.
- Admin login gate (server/admin-auth.js + setAdminPassword action). Scrypt
password hash + HMAC-signed session cookie.
- Entitlement scheme rename: pro / max (each paired with subscriptions and
relay_pro / relay_max), replacing the misleading "core" entitlement
that conflicted with the user-facing "Core" tier name.
- Activation screen: dynamic credit count pulled from /api/relay/policy,
"Skip — use free mode" button, accurate paid-feature list.
- Top toolbar: inline credit-balance pill (or "BYO configured" fallback),
Upgrade + "I have a key" buttons.
- Picker UI: per-provider sections with Save/Test/Delete buttons, sections
collapsible by chevron, default-collapsed unless currently selected,
"Use comped credits (reset to relay)" link when the user has strayed,
green hint under inputs whose values are server-configured.
- Activity log: chevron-collapsible groups per video, refresh-survival via
localStorage + a 500-entry server-side buffer, explicit Clear button.
- YouTube captions fast-path with user toggle (skips audio download + AI
transcription when captions are available — uncheck for speaker labels).
- Cancel button: AbortController plumbed through every provider SDK call;
retryAPI short-circuits on AbortError; cancellation events surface in
the activity log instead of silent retries.
- Long-video analysis: auto-coalesce transcript entries before building the
analysis prompt so local-model context windows (32k-ish) don't overflow.
Original entries preserved for transcript display via an index map; the
analyzer sees a coarser view but click-to-seek timestamps stay precise.
- StartOS action grouping (Setup / AI Providers) so the actions list is
navigable.
- Manifest description rewritten to reflect multi-provider support and
free-tier relay credits.
- Smaller fixes: summarize-button enablement no longer requires a Gemini
key when other providers are configured; analysis fallback chain handles
context-length and 503 capacity errors; single-segment expansion for
providers that don't return per-segment timestamps (Parakeet et al.);
many other UX polish items.
136 lines
5.6 KiB
JavaScript
136 lines
5.6 KiB
JavaScript
// Server-side configuration: API key resolution + live reload of the
|
|
// StartOS config file. Module owns its own state — `serverApiKey` is
|
|
// exported as a `let` binding so importers see the current value
|
|
// (ESM live bindings).
|
|
//
|
|
// Resolution priority:
|
|
// 1. process.env.GEMINI_API_KEY (pins; never re-read after boot)
|
|
// 2. /data/config/startos-config.json's gemini_api_key
|
|
// 3. /data/.env's GEMINI_API_KEY
|
|
//
|
|
// Whenever the StartOS config file changes (via the "Set Gemini API
|
|
// Key" action), the new value is picked up within CONFIG_POLL_MS — no
|
|
// service restart required.
|
|
|
|
import fs from "fs/promises";
|
|
import path from "path";
|
|
|
|
// ── Module state ────────────────────────────────────────────────────────────
|
|
// Initialized by initConfig(). serverApiKey is exported as a live binding;
|
|
// importers see the current value, not a snapshot.
|
|
let envApiKey = "";
|
|
let envPath = null;
|
|
let configDir = null;
|
|
let startosConfigPath = null;
|
|
|
|
export let serverApiKey = "";
|
|
|
|
// ── Init ────────────────────────────────────────────────────────────────────
|
|
// Call once at boot. Sets up paths, reads the initial value, kicks off the
|
|
// poll loop. Idempotent if you really want to call it twice (the interval
|
|
// would just stack — don't).
|
|
export async function initConfig({ dataDir }) {
|
|
envPath = path.join(dataDir, ".env");
|
|
configDir = path.join(dataDir, "config");
|
|
startosConfigPath = path.join(configDir, "startos-config.json");
|
|
envApiKey = process.env.GEMINI_API_KEY || "";
|
|
serverApiKey = envApiKey;
|
|
|
|
await fs.mkdir(configDir, { recursive: true }).catch(() => {});
|
|
await refreshServerApiKey("startup");
|
|
|
|
const pollMs = parseInt(process.env.RECAP_CONFIG_POLL_MS || "3000", 10);
|
|
setInterval(() => {
|
|
refreshServerApiKey("config poll").catch(() => {});
|
|
}, pollMs);
|
|
}
|
|
|
|
// ── Internals ───────────────────────────────────────────────────────────────
|
|
async function readApiKeyFromConfig() {
|
|
try {
|
|
const content = await fs.readFile(startosConfigPath, "utf-8");
|
|
const config = JSON.parse(content);
|
|
return config.gemini_api_key || "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
async function readApiKeyFromEnvFile() {
|
|
try {
|
|
const envContent = await fs.readFile(envPath, "utf-8");
|
|
const match = envContent.match(/^GEMINI_API_KEY=(.+)$/m);
|
|
if (match) return match[1].trim().replace(/^["']|["']$/g, "");
|
|
} catch {}
|
|
return "";
|
|
}
|
|
|
|
async function refreshServerApiKey(reason) {
|
|
if (envApiKey) return; // env var pins the value
|
|
const fromConfig = await readApiKeyFromConfig();
|
|
const next = fromConfig || (await readApiKeyFromEnvFile()) || "";
|
|
if (next !== serverApiKey) {
|
|
serverApiKey = next;
|
|
console.log(`[config] server API key ${next ? "loaded" : "cleared"} (${reason})`);
|
|
}
|
|
}
|
|
|
|
// ── Public helpers ──────────────────────────────────────────────────────────
|
|
// Resolves the per-request key — either the client's own (BYO) or the
|
|
// server's stored key (when the client signals USE_SERVER_KEY or sends
|
|
// nothing).
|
|
export function resolveApiKey(clientKey) {
|
|
if (!clientKey || clientKey === "USE_SERVER_KEY") return serverApiKey;
|
|
return clientKey;
|
|
}
|
|
|
|
// Where the .env file lives (some other modules read it for legacy
|
|
// settings like YT_COOKIES_FROM).
|
|
export function getEnvPath() {
|
|
return envPath;
|
|
}
|
|
|
|
// Snapshot of the full StartOS config blob — keys for every provider
|
|
// (gemini, anthropic, openai, openai-compatible, ollama) plus the
|
|
// admin-auth fields. Each request reads it once and passes it into
|
|
// resolveProviderOpts() per provider. Returns {} if the file doesn't
|
|
// exist or is unreadable.
|
|
export async function getConfigSnapshot() {
|
|
try {
|
|
const content = await fs.readFile(startosConfigPath, "utf-8");
|
|
return JSON.parse(content) || {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
// Patch the StartOS config file in place. Reads current, merges in the
|
|
// given fields, writes atomically (tmp + rename). Used by the picker
|
|
// UI's Delete button to clear server-side credentials for a provider.
|
|
// The next config poll picks up the changes within CONFIG_POLL_MS;
|
|
// resolveProviderOpts already reads getConfigSnapshot per-request, so
|
|
// effectively the change is immediate.
|
|
//
|
|
// `patch` is a plain object of { config_field: value } pairs.
|
|
// Pass empty strings to clear a field rather than deleting the key —
|
|
// the StartOS schema declares every field with a default of '', so
|
|
// empty string is the canonical "unset" representation.
|
|
export async function mergeConfig(patch) {
|
|
if (!patch || typeof patch !== "object") return;
|
|
let current = {};
|
|
try {
|
|
const content = await fs.readFile(startosConfigPath, "utf-8");
|
|
current = JSON.parse(content) || {};
|
|
} catch {}
|
|
const merged = { ...current, ...patch };
|
|
const tmp = startosConfigPath + ".tmp";
|
|
await fs.mkdir(path.dirname(startosConfigPath), { recursive: true });
|
|
await fs.writeFile(tmp, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
await fs.rename(tmp, startosConfigPath);
|
|
// Re-run the gemini-key refresher so serverApiKey reflects the
|
|
// patch immediately (otherwise it'd lag until the poll tick).
|
|
if (Object.prototype.hasOwnProperty.call(patch, "gemini_api_key")) {
|
|
await refreshServerApiKey("merge config");
|
|
}
|
|
}
|