projects
This commit is contained in:
@@ -9,6 +9,17 @@ import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||
import { readSttConfig } from '@@/api/server-settings/stt';
|
||||
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
||||
|
||||
async function getUserTtsVoice(email: string): Promise<string | null> {
|
||||
try {
|
||||
const settingsFile = Bun.file(getUserSettingsFile(email));
|
||||
if (await settingsFile.exists()) {
|
||||
const settings = (await settingsFile.json()) as { tts?: { voice?: string | null } };
|
||||
return settings.tts?.voice ?? null;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Video', 'Pictures', 'Onboarding'];
|
||||
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
|
||||
const ONBOARDING_SEED = join(DATA_PATH, 'Onboarding');
|
||||
@@ -373,18 +384,22 @@ router.post('/tts', async (ctx) => {
|
||||
const s = await stat(absPath);
|
||||
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read aloud a directory');
|
||||
|
||||
const ttsConfig = await readTtsConfig();
|
||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||
|
||||
const userVoice = await getUserTtsVoice(user.email);
|
||||
const voice = userVoice ?? ttsConfig.voice;
|
||||
|
||||
const userDataDir = getUserDataDir(user.email);
|
||||
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
|
||||
const cacheRel = dir ? `cache/tts/${dir}/${name}.mp3` : `cache/tts/${name}.mp3`;
|
||||
const voicePrefix = `${voice}/`;
|
||||
const cacheRel = dir ? `cache/tts/${voicePrefix}${dir}/${name}.mp3` : `cache/tts/${voicePrefix}${name}.mp3`;
|
||||
const cacheAbs = resolve(userDataDir, cacheRel);
|
||||
|
||||
if (existsSync(cacheAbs)) {
|
||||
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||
}
|
||||
|
||||
const ttsConfig = await readTtsConfig();
|
||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||
|
||||
const content = await readFile(absPath, 'utf-8');
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
@@ -392,7 +407,7 @@ router.post('/tts', async (ctx) => {
|
||||
|
||||
if (ttsConfig.provider === 'elevenlabs') {
|
||||
if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
|
||||
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(ttsConfig.voice)}`, {
|
||||
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
|
||||
body: JSON.stringify({ text: content, model_id: ttsConfig.model }),
|
||||
@@ -402,7 +417,7 @@ router.post('/tts', async (ctx) => {
|
||||
res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ model: ttsConfig.model, input: content, voice: ttsConfig.voice, response_format: 'mp3' }),
|
||||
body: JSON.stringify({ model: ttsConfig.model, input: content, voice, response_format: 'mp3' }),
|
||||
});
|
||||
}
|
||||
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
|
||||
@@ -421,23 +436,26 @@ router.post('/tts-text', async (ctx) => {
|
||||
if (!text) throw errors.BAD_REQUEST('text is required');
|
||||
if (!id) throw errors.BAD_REQUEST('id is required');
|
||||
|
||||
const ttsConfig = await readTtsConfig();
|
||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||
|
||||
const userVoice = await getUserTtsVoice(user.email);
|
||||
const voice = userVoice ?? ttsConfig.voice;
|
||||
|
||||
const userDataDir = getUserDataDir(user.email);
|
||||
const cacheRel = `cache/tts/chat/${id}.mp3`;
|
||||
const cacheRel = `cache/tts/chat/${voice}/${id}.mp3`;
|
||||
const cacheAbs = resolve(userDataDir, cacheRel);
|
||||
|
||||
if (existsSync(cacheAbs)) {
|
||||
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||
}
|
||||
|
||||
const ttsConfig = await readTtsConfig();
|
||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
let res: Response;
|
||||
|
||||
if (ttsConfig.provider === 'elevenlabs') {
|
||||
if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
|
||||
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(ttsConfig.voice)}`, {
|
||||
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
|
||||
body: JSON.stringify({ text, model_id: ttsConfig.model }),
|
||||
@@ -447,7 +465,7 @@ router.post('/tts-text', async (ctx) => {
|
||||
res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ model: ttsConfig.model, input: text, voice: ttsConfig.voice, response_format: 'mp3' }),
|
||||
body: JSON.stringify({ model: ttsConfig.model, input: text, voice, response_format: 'mp3' }),
|
||||
});
|
||||
}
|
||||
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
|
||||
|
||||
@@ -414,12 +414,18 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
|
||||
}
|
||||
|
||||
case 'agent_end': {
|
||||
// Pi doesn't provide cost info in agent_end, use zeros
|
||||
const cost: MessageCost = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalUSD: 0,
|
||||
};
|
||||
const cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||
const messages = event.messages as Array<Record<string, unknown>> | undefined;
|
||||
if (messages) {
|
||||
for (const msg of messages) {
|
||||
const usage = msg.usage as Record<string, unknown> | undefined;
|
||||
if (!usage) continue;
|
||||
cost.inputTokens += (usage.input as number) ?? 0;
|
||||
cost.outputTokens += (usage.output as number) ?? 0;
|
||||
const usageCost = usage.cost as Record<string, unknown> | undefined;
|
||||
if (usageCost) cost.totalUSD += (usageCost.total as number) ?? 0;
|
||||
}
|
||||
}
|
||||
return { type: 'result', cost };
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ ttsRouter.put('/', async (ctx) => {
|
||||
});
|
||||
|
||||
ttsRouter.post('/voices', async (ctx) => {
|
||||
const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string }>();
|
||||
const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string; model?: string }>();
|
||||
|
||||
try {
|
||||
if (body.provider === 'elevenlabs') {
|
||||
@@ -67,20 +67,88 @@ ttsRouter.post('/voices', async (ctx) => {
|
||||
return ctx.json({ voices: json.voices.map((v) => v.voice_id) });
|
||||
}
|
||||
|
||||
// OpenAI-compatible
|
||||
// OpenAI-compatible: try local server first, fallback to HuggingFace
|
||||
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
|
||||
const headers: Record<string, string> = {};
|
||||
if (body.apiKey) headers['Authorization'] = `Bearer ${body.apiKey}`;
|
||||
const res = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/voices`, { headers });
|
||||
if (!res.ok) return ctx.json({ error: `Voices fetch failed: ${res.status}` }, 500);
|
||||
const json = (await res.json()) as { voices: string[] };
|
||||
return ctx.json({ voices: json.voices });
|
||||
|
||||
// Try /v1/audio/voices on the local server
|
||||
const localRes = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/voices`, { headers }).catch(() => null);
|
||||
if (localRes?.ok) {
|
||||
const json = (await localRes.json()) as { voices: string[] };
|
||||
return ctx.json({ voices: json.voices });
|
||||
}
|
||||
|
||||
// Fallback: get model repo from /v1/models, then list voices from HuggingFace
|
||||
const modelsRes = await fetch(`${body.url.replace(/\/+$/, '')}/v1/models`, { headers }).catch(() => null);
|
||||
if (modelsRes?.ok) {
|
||||
const modelsJson = (await modelsRes.json()) as { data?: { id: string }[] };
|
||||
const repoId = modelsJson.data?.[0]?.id;
|
||||
if (repoId) {
|
||||
const result = await fetchHuggingFaceVoices(repoId);
|
||||
if (result.flat.length > 0) return ctx.json({ voices: result.flat, groups: result.groups });
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: try model field as HuggingFace repo ID directly
|
||||
if (body.model && body.model.includes('/')) {
|
||||
const result = await fetchHuggingFaceVoices(body.model);
|
||||
if (result.flat.length > 0) return ctx.json({ voices: result.flat, groups: result.groups });
|
||||
}
|
||||
|
||||
return ctx.json({ error: 'Could not fetch voices from server or HuggingFace' }, 500);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return ctx.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
const VOICE_GROUP_LABELS: Record<string, string> = {
|
||||
af: 'American Female',
|
||||
am: 'American Male',
|
||||
bf: 'British Female',
|
||||
bm: 'British Male',
|
||||
ef: 'Spanish Female',
|
||||
em: 'Spanish Male',
|
||||
ff: 'French Female',
|
||||
hf: 'Hindi Female',
|
||||
hm: 'Hindi Male',
|
||||
if: 'Italian Female',
|
||||
im: 'Italian Male',
|
||||
jf: 'Japanese Female',
|
||||
jm: 'Japanese Male',
|
||||
pf: 'Brazilian Portuguese Female',
|
||||
pm: 'Brazilian Portuguese Male',
|
||||
zf: 'Mandarin Chinese Female',
|
||||
zm: 'Mandarin Chinese Male',
|
||||
};
|
||||
|
||||
type VoiceGroup = { label: string; voices: string[] };
|
||||
|
||||
async function fetchHuggingFaceVoices(repoId: string): Promise<{ flat: string[]; groups: VoiceGroup[] }> {
|
||||
const res = await fetch(`https://huggingface.co/api/models/${repoId}/tree/main/voices`);
|
||||
if (!res.ok) return { flat: [], groups: [] };
|
||||
const files = (await res.json()) as { path: string; type: string }[];
|
||||
const names = new Set<string>();
|
||||
for (const f of files) {
|
||||
if (f.type !== 'file') continue;
|
||||
const name = f.path.replace('voices/', '').replace(/\.(pt|safetensors)$/, '');
|
||||
names.add(name);
|
||||
}
|
||||
const sorted = [...names].sort();
|
||||
const groupMap = new Map<string, string[]>();
|
||||
for (const name of sorted) {
|
||||
const prefix = name.slice(0, 2);
|
||||
if (!groupMap.has(prefix)) groupMap.set(prefix, []);
|
||||
groupMap.get(prefix)!.push(name);
|
||||
}
|
||||
const groups: VoiceGroup[] = [];
|
||||
for (const [prefix, voices] of groupMap) {
|
||||
groups.push({ label: VOICE_GROUP_LABELS[prefix] ?? prefix, voices });
|
||||
}
|
||||
return { flat: sorted, groups };
|
||||
}
|
||||
|
||||
ttsRouter.post('/test', async (ctx) => {
|
||||
const body = await ctx.req.json<TtsConfig>();
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { mkdir, readdir, rm, cp } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { getDirs, migrateFromState, migrateHomepageToScreens, readAllWorkspacesState, resolveKey, writeJsonFile } from './utils';
|
||||
|
||||
const TEMPLATES_DIR = resolve(import.meta.dir, '../../../../seed/project-templates');
|
||||
|
||||
export const workspacesRouter = createRouter();
|
||||
|
||||
// GET /workspaces
|
||||
@@ -48,8 +51,25 @@ workspacesRouter.patch('/', async (ctx) => {
|
||||
await writeJsonFile(metaFile, value);
|
||||
|
||||
if (isNew) {
|
||||
const proc = Bun.spawn(['git', 'init', projectDir]);
|
||||
await proc.exited;
|
||||
const meta = value as Record<string, unknown>;
|
||||
if (meta.projectType === 'app') {
|
||||
const templateDir = join(TEMPLATES_DIR, 'simple-app-template');
|
||||
const entries = readdirSync(templateDir);
|
||||
for (const entry of entries) {
|
||||
if (entry === '.git' || entry === '.officerdev') continue;
|
||||
await cp(join(templateDir, entry), join(projectDir, entry), { recursive: true });
|
||||
}
|
||||
const pkgPath = join(projectDir, 'package.json');
|
||||
const pkg = await Bun.file(pkgPath).json().catch(() => null);
|
||||
if (pkg) {
|
||||
pkg.name = slug;
|
||||
await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
}
|
||||
const install = Bun.spawn(['bun', 'install'], { cwd: projectDir, stdout: 'ignore', stderr: 'ignore' });
|
||||
await install.exited;
|
||||
}
|
||||
const gitInit = Bun.spawn(['git', 'init', projectDir], { stdout: 'ignore', stderr: 'ignore' });
|
||||
await gitInit.exited;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user