diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index eaa24749..31cd0dd2 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -570,9 +570,13 @@ function App() { // Check if voice is available on mount useEffect(() => { - window.ipc.invoke('voice:getConfig', null).then(config => { + Promise.all([ + window.ipc.invoke('voice:getConfig', null), + window.ipc.invoke('oauth:getState', null), + ]).then(([config, oauthState]) => { + const rowboatConnected = oauthState.config?.rowboat?.connected ?? false setVoiceAvailable(!!config.deepgram) - setTtsAvailable(!!config.elevenlabs) + setTtsAvailable(!!config.elevenlabs || rowboatConnected) }).catch(() => { setVoiceAvailable(false) setTtsAvailable(false) diff --git a/apps/x/packages/core/src/auth/tokens.ts b/apps/x/packages/core/src/auth/tokens.ts new file mode 100644 index 00000000..e89c911d --- /dev/null +++ b/apps/x/packages/core/src/auth/tokens.ts @@ -0,0 +1,46 @@ +import container from '../di/container.js'; +import { IOAuthRepo } from './repo.js'; +import { IClientRegistrationRepo } from './client-repo.js'; +import { getProviderConfig } from './providers.js'; +import * as oauthClient from './oauth-client.js'; + +export async function getAccessToken(): Promise { + const oauthRepo = container.resolve('oauthRepo'); + const { tokens } = await oauthRepo.read('rowboat'); + if (!tokens) { + throw new Error('Not signed into Rowboat'); + } + + if (!oauthClient.isTokenExpired(tokens)) { + return tokens.access_token; + } + + if (!tokens.refresh_token) { + throw new Error('Rowboat token expired and no refresh token available. Please sign in again.'); + } + + const providerConfig = getProviderConfig('rowboat'); + if (providerConfig.discovery.mode !== 'issuer') { + throw new Error('Rowboat provider requires issuer discovery mode'); + } + + const clientRepo = container.resolve('clientRegistrationRepo'); + const registration = await clientRepo.getClientRegistration('rowboat'); + if (!registration) { + throw new Error('Rowboat client not registered. Please sign in again.'); + } + + const config = await oauthClient.discoverConfiguration( + providerConfig.discovery.issuer, + registration.client_id, + ); + + const refreshed = await oauthClient.refreshTokens( + config, + tokens.refresh_token, + tokens.scopes, + ); + await oauthRepo.upsert('rowboat', { tokens: refreshed }); + + return refreshed.access_token; +} diff --git a/apps/x/packages/core/src/models/gateway.ts b/apps/x/packages/core/src/models/gateway.ts index a297d56f..a18a37f5 100644 --- a/apps/x/packages/core/src/models/gateway.ts +++ b/apps/x/packages/core/src/models/gateway.ts @@ -1,53 +1,8 @@ import { ProviderV2 } from '@ai-sdk/provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; -import container from '../di/container.js'; -import { IOAuthRepo } from '../auth/repo.js'; -import { IClientRegistrationRepo } from '../auth/client-repo.js'; -import { getProviderConfig } from '../auth/providers.js'; -import * as oauthClient from '../auth/oauth-client.js'; +import { getAccessToken } from '../auth/tokens.js'; import { API_URL } from '../config/env.js'; -async function getAccessToken(): Promise { - const oauthRepo = container.resolve('oauthRepo'); - const { tokens } = await oauthRepo.read('rowboat'); - if (!tokens) { - throw new Error('Not signed into Rowboat'); - } - - if (!oauthClient.isTokenExpired(tokens)) { - return tokens.access_token; - } - - if (!tokens.refresh_token) { - throw new Error('Rowboat token expired and no refresh token available. Please sign in again.'); - } - - const providerConfig = getProviderConfig('rowboat'); - if (providerConfig.discovery.mode !== 'issuer') { - throw new Error('Rowboat provider requires issuer discovery mode'); - } - - const clientRepo = container.resolve('clientRegistrationRepo'); - const registration = await clientRepo.getClientRegistration('rowboat'); - if (!registration) { - throw new Error('Rowboat client not registered. Please sign in again.'); - } - - const config = await oauthClient.discoverConfiguration( - providerConfig.discovery.issuer, - registration.client_id, - ); - - const refreshed = await oauthClient.refreshTokens( - config, - tokens.refresh_token, - tokens.scopes, - ); - await oauthRepo.upsert('rowboat', { tokens: refreshed }); - - return refreshed.access_token; -} - export async function getGatewayProvider(): Promise { const accessToken = await getAccessToken(); return createOpenRouter({ diff --git a/apps/x/packages/core/src/voice/voice.ts b/apps/x/packages/core/src/voice/voice.ts index 9d46c1fc..895c81b9 100644 --- a/apps/x/packages/core/src/voice/voice.ts +++ b/apps/x/packages/core/src/voice/voice.ts @@ -1,5 +1,8 @@ import * as fs from 'fs/promises'; import * as path from 'path'; +import { isSignedIn } from '../account/account.js'; +import { getAccessToken } from '../auth/tokens.js'; +import { API_URL } from '../config/env.js'; const homedir = process.env.HOME || process.env.USERPROFILE || ''; @@ -32,21 +35,36 @@ export async function getVoiceConfig(): Promise { export async function synthesizeSpeech(text: string): Promise<{ audioBase64: string; mimeType: string }> { const config = await getVoiceConfig(); - if (!config.elevenlabs) { - throw new Error('ElevenLabs not configured. Create ~/.rowboat/config/elevenlabs.json with { "apiKey": "" }'); + const signedIn = await isSignedIn(); + + let url: string; + let headers: Record; + + if (signedIn) { + const voiceId = config.elevenlabs?.voiceId || 'UgBBYS2sOqTuMpoF3BR0'; + const accessToken = await getAccessToken(); + url = `${API_URL}/v1/voice/text-to-speech/${voiceId}`; + headers = { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }; + console.log('[voice] synthesizing speech via Rowboat proxy, text length:', text.length, 'voiceId:', voiceId); + } else { + if (!config.elevenlabs) { + throw new Error('ElevenLabs not configured. Create ~/.rowboat/config/elevenlabs.json with { "apiKey": "" }'); + } + const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0'; + url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`; + headers = { + 'xi-api-key': config.elevenlabs.apiKey, + 'Content-Type': 'application/json', + }; + console.log('[voice] synthesizing speech via ElevenLabs, text length:', text.length, 'voiceId:', voiceId); } - const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0'; - const url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`; - - console.log('[voice] synthesizing speech, text length:', text.length, 'voiceId:', voiceId); - const response = await fetch(url, { method: 'POST', - headers: { - 'xi-api-key': config.elevenlabs.apiKey, - 'Content-Type': 'application/json', - }, + headers, body: JSON.stringify({ text, model_id: 'eleven_flash_v2_5', @@ -59,8 +77,8 @@ export async function synthesizeSpeech(text: string): Promise<{ audioBase64: str if (!response.ok) { const errText = await response.text().catch(() => 'Unknown error'); - console.error('[voice] ElevenLabs API error:', response.status, errText); - throw new Error(`ElevenLabs API error ${response.status}: ${errText}`); + console.error('[voice] TTS API error:', response.status, errText); + throw new Error(`TTS API error ${response.status}: ${errText}`); } const arrayBuffer = await response.arrayBuffer();