Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion packages/core/src/elevenlabs/env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";

Expand Down Expand Up @@ -34,6 +34,13 @@ function readEnvFile(path: string): string | null {
}
}

export type ElevenLabsKeySource = "process" | "project-env" | "global-env" | "none";

export interface ElevenLabsKeyStatus {
hasKey: boolean;
source: ElevenLabsKeySource;
}

/**
* Look up the ElevenLabs API key. Resolution order:
* 1. process.env.ELEVENLABS_API_KEY
Expand All @@ -52,4 +59,63 @@ export function loadElevenLabsKey(projectDir?: string): string | null {
return readEnvFile(join(homedir(), ".hyperframes", ".env"));
}

/**
* Report whether a key is set and which layer it came from. The actual key
* value is intentionally not returned — callers only need presence + source.
*/
export function getElevenLabsKeyStatus(projectDir?: string): ElevenLabsKeyStatus {
if (process.env[KEY_NAME]) return { hasKey: true, source: "process" };
if (projectDir && readEnvFile(join(projectDir, ".env"))) {
return { hasKey: true, source: "project-env" };
}
if (readEnvFile(join(homedir(), ".hyperframes", ".env"))) {
return { hasKey: true, source: "global-env" };
}
return { hasKey: false, source: "none" };
}

/**
* Write or replace ELEVENLABS_API_KEY in the given .env file, preserving
* surrounding lines (other vars, comments, blank lines). Creates the file
* if missing. Pass null to remove the entry.
*/
export function writeElevenLabsKeyToEnvFile(envPath: string, value: string | null): void {
const existing = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
const lines = existing.length > 0 ? existing.split(/\r?\n/) : [];

let replaced = false;
const next: string[] = [];
for (const line of lines) {
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
if (match && match[1] === KEY_NAME) {
if (value != null) {
next.push(`${KEY_NAME}=${quoteIfNeeded(value)}`);
replaced = true;
}
// null → drop the line
continue;
}
next.push(line);
}

if (value != null && !replaced) {
if (next.length > 0 && next[next.length - 1] !== "") next.push("");
next.push(`${KEY_NAME}=${quoteIfNeeded(value)}`);
}

// Preserve trailing newline.
let out = next.join("\n");
if (!out.endsWith("\n")) out += "\n";
writeFileSync(envPath, out, { mode: 0o600 });
}

function quoteIfNeeded(value: string): string {
// Quote values containing whitespace, quotes, or special shell chars to
// keep the file safe when sourced.
if (/[\s"'`$\\]/.test(value)) {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
return value;
}

export const ELEVENLABS_KEY_NAME = KEY_NAME;
8 changes: 7 additions & 1 deletion packages/core/src/elevenlabs/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export { loadElevenLabsKey, ELEVENLABS_KEY_NAME } from "./env.js";
export {
loadElevenLabsKey,
getElevenLabsKeyStatus,
writeElevenLabsKeyToEnvFile,
ELEVENLABS_KEY_NAME,
} from "./env.js";
export type { ElevenLabsKeySource, ElevenLabsKeyStatus } from "./env.js";
export {
listVoices,
fetchVoicePreview,
Expand Down
137 changes: 135 additions & 2 deletions packages/core/src/studio-api/routes/elevenlabs.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { Hono } from "hono";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve, dirname, join } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { isSafePath } from "../helpers/safePath.js";
import {
loadElevenLabsKey,
getElevenLabsKeyStatus,
writeElevenLabsKeyToEnvFile,
listVoices,
fetchVoicePreview,
synthesize,
Expand Down Expand Up @@ -170,6 +172,137 @@ export function registerElevenLabsRoutes(api: Hono, adapter: StudioApiAdapter):
return elevenLabsError(err);
}
});

// Report whether a key is set, and which layer it came from. Never returns the value.
api.get("/projects/:id/elevenlabs/key", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
return c.json(getElevenLabsKeyStatus(project.dir));
});

// Persist a key into <project>/.env. Use null/empty to remove it.
api.put("/projects/:id/elevenlabs/key", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);

let body: { value?: string | null };
try {
body = (await c.req.json()) as { value?: string | null };
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}

const raw = typeof body.value === "string" ? body.value.trim() : null;
const value = raw && raw.length > 0 ? raw : null;
try {
writeElevenLabsKeyToEnvFile(join(project.dir, ".env"), value);
ensureGitignoreCovers(project.dir, ".env");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return c.json({ error: message }, 500);
}
return c.json(getElevenLabsKeyStatus(project.dir));
});

// Read project's TTS settings (default voice id, etc.) from hyperframes.json.
api.get("/projects/:id/elevenlabs/settings", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
return c.json(readTtsSettings(project.dir));
});

// Persist a partial TTS settings update into hyperframes.json.
api.patch("/projects/:id/elevenlabs/settings", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);

let body: { defaultVoiceId?: string | null };
try {
body = (await c.req.json()) as { defaultVoiceId?: string | null };
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}

const next = writeTtsSettings(project.dir, body);
return c.json(next);
});
}

interface TtsSettings {
defaultVoiceId: string | null;
}

function readTtsSettings(projectDir: string): TtsSettings {
const path = join(projectDir, "hyperframes.json");
if (!existsSync(path)) return { defaultVoiceId: null };
try {
const raw = JSON.parse(readFileSync(path, "utf-8")) as {
tts?: { defaultVoiceId?: unknown };
};
const id = raw.tts?.defaultVoiceId;
return { defaultVoiceId: typeof id === "string" && id.length > 0 ? id : null };
} catch {
return { defaultVoiceId: null };
}
}

function writeTtsSettings(
projectDir: string,
patch: { defaultVoiceId?: string | null },
): TtsSettings {
const path = join(projectDir, "hyperframes.json");
let json: Record<string, unknown> = {};
if (existsSync(path)) {
try {
json = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
} catch {
json = {};
}
}
const tts =
typeof json.tts === "object" && json.tts !== null
? ({ ...(json.tts as Record<string, unknown>) } as Record<string, unknown>)
: {};

if (Object.prototype.hasOwnProperty.call(patch, "defaultVoiceId")) {
if (patch.defaultVoiceId == null || patch.defaultVoiceId === "") {
delete tts.defaultVoiceId;
} else {
tts.defaultVoiceId = patch.defaultVoiceId;
}
}

json.tts = tts;
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
return readTtsSettings(projectDir);
}

/**
* Make sure .env is excluded by the project's .gitignore — otherwise a
* convenience UI could lead someone to commit a key. Idempotent; touches
* .gitignore only when missing the entry. Best-effort: we do nothing if
* the project isn't a git repo or the file isn't writable.
*/
function ensureGitignoreCovers(projectDir: string, entry: string): void {
const gitignorePath = join(projectDir, ".gitignore");
let content = "";
try {
if (existsSync(gitignorePath)) {
content = readFileSync(gitignorePath, "utf-8");
}
} catch {
return;
}
const lines = content.split(/\r?\n/);
const already = lines.some((line) => line.trim() === entry || line.trim() === `/${entry}`);
if (already) return;
const trailingNl = content.length === 0 || content.endsWith("\n");
const next = (trailingNl ? content : content + "\n") + `${entry}\n`;
try {
writeFileSync(gitignorePath, next);
} catch {
/* ignore — best effort */
}
}

function sanitizeFilename(value: string | undefined): string | null {
Expand Down
20 changes: 19 additions & 1 deletion packages/studio/src/components/sidebar/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import { memo, useState, useCallback, type ReactNode } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { CompositionsTab } from "./CompositionsTab";
import { AssetsTab } from "./AssetsTab";
import { VoicesTab } from "./VoicesTab";
import { FileTree } from "../editor/FileTree";

type SidebarTab = "compositions" | "assets" | "code";
type SidebarTab = "compositions" | "assets" | "code" | "voices";

const STORAGE_KEY = "hf-studio-sidebar-tab";

function getPersistedTab(): SidebarTab {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "assets") return "assets";
if (stored === "code") return "code";
if (stored === "voices") return "voices";
return "compositions";
}

Expand Down Expand Up @@ -77,6 +79,10 @@ export const LeftSidebar = memo(function LeftSidebar({
e.preventDefault();
selectTab("assets");
}
if (e.key === "3") {
e.preventDefault();
selectTab("voices");
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
Expand Down Expand Up @@ -122,6 +128,17 @@ export const LeftSidebar = memo(function LeftSidebar({
>
Assets
</button>
<button
type="button"
onClick={() => selectTab("voices")}
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
tab === "voices"
? "text-neutral-200 border-b-2 border-studio-accent"
: "text-neutral-500 hover:text-neutral-400"
}`}
>
Voices
</button>
</div>

{/* Tab content */}
Expand All @@ -142,6 +159,7 @@ export const LeftSidebar = memo(function LeftSidebar({
onRename={onRenameFile}
/>
)}
{tab === "voices" && <VoicesTab projectId={projectId} />}
{tab === "code" && (
<div className="flex flex-1 min-h-0">
{(fileProp?.length ?? 0) > 0 && (
Expand Down
Loading