Compare commits
4 commits
a4cb799978
...
f268cde348
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f268cde348 | ||
|
|
4c96e9ba2b | ||
|
|
d5a131da0c | ||
|
|
16bf681dc6 |
|
|
@ -19,7 +19,7 @@ Open the localhost URL, enter a Gemini API key, choose a target language, and cl
|
||||||
- Chrome or Edge is recommended for tab/system audio sharing
|
- Chrome or Edge is recommended for tab/system audio sharing
|
||||||
- Output-device selection appears only when `HTMLMediaElement.setSinkId()` is supported
|
- Output-device selection appears only when `HTMLMediaElement.setSinkId()` is supported
|
||||||
|
|
||||||
Use headphones or a separate output device to prevent translated speech from being captured again. Relay also requests `selfBrowserSurface: "exclude"` where the browser supports it.
|
Prefer sharing the individual tab that is playing the source audio. Full system-audio capture may also include Relay's translated output in the digital mix. Relay requests `selfBrowserSurface: "exclude"` and `restrictOwnAudio: true` where supported; a separate output device can also help when the operating system isolates its audio from the captured source.
|
||||||
|
|
||||||
## Privacy and key handling
|
## Privacy and key handling
|
||||||
|
|
||||||
|
|
|
||||||
80
src/App.tsx
80
src/App.tsx
|
|
@ -2,15 +2,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
|
BookOpen,
|
||||||
Captions,
|
Captions,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
CircleStop,
|
CircleStop,
|
||||||
Clock3,
|
Clock3,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Gauge,
|
Gauge,
|
||||||
Headphones,
|
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Laptop,
|
Laptop,
|
||||||
Languages as LanguagesIcon,
|
Languages as LanguagesIcon,
|
||||||
|
|
@ -25,6 +26,7 @@ import {
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
Upload,
|
Upload,
|
||||||
Volume2,
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { APP_LANGUAGES, APP_LANGUAGE_STORAGE, translate, type AppLanguage } from './i18n'
|
import { APP_LANGUAGES, APP_LANGUAGE_STORAGE, translate, type AppLanguage } from './i18n'
|
||||||
|
|
@ -35,7 +37,7 @@ import {
|
||||||
supportsOutputPicker,
|
supportsOutputPicker,
|
||||||
supportsOutputSelection,
|
supportsOutputSelection,
|
||||||
} from './lib/audio'
|
} from './lib/audio'
|
||||||
import { GeminiLiveSession, type Transcript } from './lib/geminiLive'
|
import { GeminiLiveSession, SessionClosedError, type Transcript } from './lib/geminiLive'
|
||||||
|
|
||||||
const API_KEY_STORAGE = 'relay-gemini-api-key'
|
const API_KEY_STORAGE = 'relay-gemini-api-key'
|
||||||
const TARGET_LANGUAGE_STORAGE = 'relay-target-language'
|
const TARGET_LANGUAGE_STORAGE = 'relay-target-language'
|
||||||
|
|
@ -99,6 +101,7 @@ function friendlyError(error: unknown, t: T): string {
|
||||||
if (error.name === 'NotAllowedError') return t('errorShareCancelled')
|
if (error.name === 'NotAllowedError') return t('errorShareCancelled')
|
||||||
if (error.name === 'NotFoundError') return t('errorNoSource')
|
if (error.name === 'NotFoundError') return t('errorNoSource')
|
||||||
if (error.name === 'NotReadableError') return t('errorSourceUnreadable')
|
if (error.name === 'NotReadableError') return t('errorSourceUnreadable')
|
||||||
|
if (error.name === 'AbortError') return t('errorStart')
|
||||||
}
|
}
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
const localizedMessages: Record<string, Parameters<typeof translate>[1]> = {
|
const localizedMessages: Record<string, Parameters<typeof translate>[1]> = {
|
||||||
|
|
@ -145,6 +148,7 @@ function App() {
|
||||||
const [outputDevices, setOutputDevices] = useState<MediaDeviceInfo[]>([])
|
const [outputDevices, setOutputDevices] = useState<MediaDeviceInfo[]>([])
|
||||||
const [outputDeviceId, setOutputDeviceId] = useState('')
|
const [outputDeviceId, setOutputDeviceId] = useState('')
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
const [diagnostics, setDiagnostics] = useState<SessionDiagnostics>(INITIAL_DIAGNOSTICS)
|
const [diagnostics, setDiagnostics] = useState<SessionDiagnostics>(INITIAL_DIAGNOSTICS)
|
||||||
|
|
||||||
const [capture] = useState(() => new DisplayAudioCapture())
|
const [capture] = useState(() => new DisplayAudioCapture())
|
||||||
|
|
@ -256,8 +260,12 @@ function App() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus('picking')
|
setStatus('picking')
|
||||||
await player.initialize()
|
const captureRequest = capture.request(() => void stop(t('sourceEnded')))
|
||||||
const label = await capture.request(() => void stop(t('sourceEnded')))
|
const playerInitialization = player.initialize()
|
||||||
|
const [captureResult, playerResult] = await Promise.allSettled([captureRequest, playerInitialization])
|
||||||
|
if (captureResult.status === 'rejected') throw captureResult.reason
|
||||||
|
if (playerResult.status === 'rejected') throw playerResult.reason
|
||||||
|
const label = captureResult.value
|
||||||
setSourceLabel(label)
|
setSourceLabel(label)
|
||||||
sessionStartedAtRef.current = Date.now()
|
sessionStartedAtRef.current = Date.now()
|
||||||
await refreshOutputDevices()
|
await refreshOutputDevices()
|
||||||
|
|
@ -307,9 +315,9 @@ function App() {
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
captureStartedRef.current = true
|
captureStartedRef.current = true
|
||||||
setStatus('listening')
|
setStatus(session.isReady() ? 'listening' : 'reconnecting')
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
if (caught instanceof DOMException && caught.name === 'AbortError') {
|
if (caught instanceof SessionClosedError) {
|
||||||
setStatus('idle')
|
setStatus('idle')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -396,10 +404,60 @@ function App() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="top">
|
<main id="top">
|
||||||
<section className="hero">
|
<section className="guide-section" aria-labelledby="guide-button-title">
|
||||||
<div className="eyebrow"><span className="live-dot" /> {t('liveInterpreter')}</div>
|
<button
|
||||||
<h1>{t('heroLineOne')}<br /><span>{t('heroLineTwo')}</span></h1>
|
className="guide-launch"
|
||||||
<p className="hero-copy">{t('heroCopy')}</p>
|
onClick={() => setGuideOpen((open) => !open)}
|
||||||
|
aria-expanded={guideOpen}
|
||||||
|
aria-controls="usage-guide"
|
||||||
|
>
|
||||||
|
<span className="guide-launch-icon"><BookOpen size={25} /></span>
|
||||||
|
<span className="guide-launch-copy">
|
||||||
|
<strong id="guide-button-title">{t('guideButtonTitle')}</strong>
|
||||||
|
<small>{t('guideButtonCopy')}</small>
|
||||||
|
</span>
|
||||||
|
{guideOpen ? <ChevronUp size={22} /> : <ChevronDown size={22} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{guideOpen && (
|
||||||
|
<div className="usage-guide" id="usage-guide">
|
||||||
|
<div className="guide-heading">
|
||||||
|
<span>{t('guideEyebrow')}</span>
|
||||||
|
<h2>{t('guideTitle')}</h2>
|
||||||
|
<p>{t('guideIntro')}</p>
|
||||||
|
</div>
|
||||||
|
<ol className="guide-steps">
|
||||||
|
<li>
|
||||||
|
<span>1</span>
|
||||||
|
<div><strong>{t('guideKeyTitle')}</strong><p>{t('guideKeyCopy')}</p></div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>2</span>
|
||||||
|
<div><strong>{t('guideLanguageTitle')}</strong><p>{t('guideLanguageCopy')}</p></div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>3</span>
|
||||||
|
<div><strong>{t('guideOutputTitle')}</strong><p>{t('guideOutputCopy')}</p></div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>4</span>
|
||||||
|
<div><strong>{t('guideShareTitle')}</strong><p>{t('guideShareCopy')}</p></div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>5</span>
|
||||||
|
<div><strong>{t('guidePickerTitle')}</strong><p>{t('guidePickerCopy')}</p></div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>6</span>
|
||||||
|
<div><strong>{t('guideListenTitle')}</strong><p>{t('guideListenCopy')}</p></div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<div className="guide-tip">
|
||||||
|
<VolumeX size={20} />
|
||||||
|
<div><strong>{t('guideTipTitle')}</strong><p>{t('guideTipCopy')}</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="workspace" aria-label={t('translatorControls')}>
|
<section className="workspace" aria-label={t('translatorControls')}>
|
||||||
|
|
@ -583,7 +641,7 @@ function App() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="loop-card">
|
<section className="loop-card">
|
||||||
<div className="loop-icon"><Headphones size={23} /></div>
|
<div className="loop-icon"><VolumeX size={23} /></div>
|
||||||
<div>
|
<div>
|
||||||
<h2>{t('loopTitle')}</h2>
|
<h2>{t('loopTitle')}</h2>
|
||||||
<p>{t('loopCopy')}</p>
|
<p>{t('loopCopy')}</p>
|
||||||
|
|
|
||||||
54
src/i18n.ts
54
src/i18n.ts
|
|
@ -12,10 +12,25 @@ const english = {
|
||||||
pageTitle: 'Relay — Live audio translation',
|
pageTitle: 'Relay — Live audio translation',
|
||||||
relayHome: 'Relay home',
|
relayHome: 'Relay home',
|
||||||
translatorControls: 'Live translator controls',
|
translatorControls: 'Live translator controls',
|
||||||
liveInterpreter: 'Live audio interpreter',
|
guideButtonTitle: 'How do I use Relay?',
|
||||||
heroLineOne: 'Hear anything.',
|
guideButtonCopy: 'Open the detailed step-by-step guide',
|
||||||
heroLineTwo: 'Understand everything.',
|
guideEyebrow: 'Getting started',
|
||||||
heroCopy: 'Translate audio playing on your computer in real time. Direct from your browser to Gemini—nothing passes through our servers.',
|
guideTitle: 'Translate computer audio in six steps',
|
||||||
|
guideIntro: 'Relay runs entirely in your browser. Have your Gemini API key ready, then follow these steps.',
|
||||||
|
guideKeyTitle: 'Enter your Gemini API key',
|
||||||
|
guideKeyCopy: 'Paste your own API key into the key field. It is saved only in this browser’s local storage and sent directly to Google when a session starts.',
|
||||||
|
guideLanguageTitle: 'Choose the translation language',
|
||||||
|
guideLanguageCopy: 'Select the language you want to hear. Turn on live transcripts only if you also want original and translated text; transcripts add billable text tokens.',
|
||||||
|
guideOutputTitle: 'Prefer sharing a single tab',
|
||||||
|
guideOutputCopy: 'Sharing only the tab playing the source audio normally keeps Relay’s translated output out of the capture. Full system-audio sharing can also capture Relay.',
|
||||||
|
guideShareTitle: 'Start the translation',
|
||||||
|
guideShareCopy: 'Click “Share audio & translate.” Your browser will open its standard screen-sharing picker.',
|
||||||
|
guidePickerTitle: 'Select a source and enable audio',
|
||||||
|
guidePickerCopy: 'Choose the tab, window, or screen that is playing the audio. Explicitly enable “Share audio” before confirming. Relay will reject sources that do not provide an audio track.',
|
||||||
|
guideListenTitle: 'Listen and monitor the session',
|
||||||
|
guideListenCopy: 'The translated voice begins automatically. The live panel shows elapsed time, sent or dropped packets, playback delay, and reconnect attempts. Click “Stop translation” when finished.',
|
||||||
|
guideTipTitle: 'Keep Relay out of the captured mix',
|
||||||
|
guideTipCopy: 'Relay does not use your microphone. If you share full system audio, its translated output may still be captured digitally. Prefer a single source tab, or use a separate output device where that isolates the audio.',
|
||||||
setupSession: 'Set up your session',
|
setupSession: 'Set up your session',
|
||||||
clientOnly: 'Client-side only',
|
clientOnly: 'Client-side only',
|
||||||
geminiApiKey: 'Gemini API key',
|
geminiApiKey: 'Gemini API key',
|
||||||
|
|
@ -67,8 +82,8 @@ const english = {
|
||||||
emptyCopy: 'Set your language, share a source with audio, and Relay will begin speaking the translation.',
|
emptyCopy: 'Set your language, share a source with audio, and Relay will begin speaking the translation.',
|
||||||
voiceTranslation: 'Live voice translation',
|
voiceTranslation: 'Live voice translation',
|
||||||
transcripts: 'Original + translated transcripts',
|
transcripts: 'Original + translated transcripts',
|
||||||
loopTitle: 'Keep the translation from hearing itself',
|
loopTitle: 'Keep Relay’s output out of the shared audio',
|
||||||
loopCopy: 'Headphones are the safest option. You can also send translated speech to a separate output device, or share a different tab—Relay asks supported browsers to exclude this tab.',
|
loopCopy: 'Prefer sharing only the tab playing the source audio. Full system-audio sharing may capture Relay’s output digitally; supported browsers are asked to exclude or suppress this app’s own audio.',
|
||||||
chooseOutput: 'Choose output',
|
chooseOutput: 'Choose output',
|
||||||
privacyFooter: 'Your key and audio stay between this browser and Google’s Gemini API.',
|
privacyFooter: 'Your key and audio stay between this browser and Google’s Gemini API.',
|
||||||
appLanguage: 'App language',
|
appLanguage: 'App language',
|
||||||
|
|
@ -92,10 +107,25 @@ const slovene: Record<TranslationKey, string> = {
|
||||||
pageTitle: 'Relay — Prevajanje zvoka v živo',
|
pageTitle: 'Relay — Prevajanje zvoka v živo',
|
||||||
relayHome: 'Domača stran Relay',
|
relayHome: 'Domača stran Relay',
|
||||||
translatorControls: 'Kontrolniki prevajalnika v živo',
|
translatorControls: 'Kontrolniki prevajalnika v živo',
|
||||||
liveInterpreter: 'Tolmač zvoka v živo',
|
guideButtonTitle: 'Kako uporabljam Relay?',
|
||||||
heroLineOne: 'Poslušajte kar koli.',
|
guideButtonCopy: 'Odprite podrobna navodila po korakih',
|
||||||
heroLineTwo: 'Razumite vse.',
|
guideEyebrow: 'Prvi koraki',
|
||||||
heroCopy: 'V živo prevajajte zvok, ki se predvaja na vašem računalniku. Zvok gre neposredno iz brskalnika v Gemini—nič ne potuje prek naših strežnikov.',
|
guideTitle: 'Prevedite zvok računalnika v šestih korakih',
|
||||||
|
guideIntro: 'Relay deluje v celoti v vašem brskalniku. Pripravite ključ API Gemini in sledite tem korakom.',
|
||||||
|
guideKeyTitle: 'Vnesite ključ API Gemini',
|
||||||
|
guideKeyCopy: 'Prilepite svoj ključ API v polje za ključ. Shranjen je samo v lokalni shrambi tega brskalnika in se ob začetku seje pošlje neposredno Googlu.',
|
||||||
|
guideLanguageTitle: 'Izberite jezik prevoda',
|
||||||
|
guideLanguageCopy: 'Izberite jezik, v katerem želite poslušati prevod. Prepise v živo vključite le, če želite tudi izvirno in prevedeno besedilo; prepisi ustvarijo dodatne plačljive besedilne žetone.',
|
||||||
|
guideOutputTitle: 'Raje delite posamezen zavihek',
|
||||||
|
guideOutputCopy: 'Če delite samo zavihek, ki predvaja izvorni zvok, prevedeni izhod aplikacije Relay praviloma ni zajet. Skupna raba celotnega sistemskega zvoka lahko zajame tudi Relay.',
|
||||||
|
guideShareTitle: 'Začnite prevajanje',
|
||||||
|
guideShareCopy: 'Kliknite »Deli zvok in prevajaj«. Brskalnik bo odprl standardni izbirnik za skupno rabo zaslona.',
|
||||||
|
guidePickerTitle: 'Izberite vir in vključite zvok',
|
||||||
|
guidePickerCopy: 'Izberite zavihek, okno ali zaslon, ki predvaja zvok. Pred potrditvijo izrecno vključite možnost »Deli zvok«. Relay zavrne vire, ki ne zagotovijo zvočnega posnetka.',
|
||||||
|
guideListenTitle: 'Poslušajte in spremljajte sejo',
|
||||||
|
guideListenCopy: 'Prevedeni glas se začne predvajati samodejno. Plošča v živo prikazuje trajanje, poslane ali izpuščene pakete, zakasnitev predvajanja in poskuse ponovne povezave. Ko končate, kliknite »Ustavi prevajanje«.',
|
||||||
|
guideTipTitle: 'Izločite Relay iz zajetega zvoka',
|
||||||
|
guideTipCopy: 'Relay ne uporablja mikrofona. Če delite celotni sistemski zvok, se lahko prevedeni izhod vseeno zajame digitalno. Raje delite posamezen izvorni zavihek ali uporabite ločeno izhodno napravo, kadar ta loči zvok.',
|
||||||
setupSession: 'Nastavite sejo',
|
setupSession: 'Nastavite sejo',
|
||||||
clientOnly: 'Samo v brskalniku',
|
clientOnly: 'Samo v brskalniku',
|
||||||
geminiApiKey: 'Ključ API Gemini',
|
geminiApiKey: 'Ključ API Gemini',
|
||||||
|
|
@ -147,8 +177,8 @@ const slovene: Record<TranslationKey, string> = {
|
||||||
emptyCopy: 'Izberite jezik in delite vir z zvokom. Relay bo začel predvajati prevod.',
|
emptyCopy: 'Izberite jezik in delite vir z zvokom. Relay bo začel predvajati prevod.',
|
||||||
voiceTranslation: 'Glasovno prevajanje v živo',
|
voiceTranslation: 'Glasovno prevajanje v živo',
|
||||||
transcripts: 'Izvirni in prevedeni prepis',
|
transcripts: 'Izvirni in prevedeni prepis',
|
||||||
loopTitle: 'Preprečite, da bi prevod poslušal samega sebe',
|
loopTitle: 'Izločite izhod Relay iz zvoka v skupni rabi',
|
||||||
loopCopy: 'Najvarnejša izbira so slušalke. Prevedeni govor lahko pošljete tudi na drugo izhodno napravo ali delite drug zavihek—Relay podprte brskalnike zaprosi, naj ta zavihek izključijo.',
|
loopCopy: 'Raje delite samo zavihek, ki predvaja izvorni zvok. Skupna raba celotnega sistemskega zvoka lahko digitalno zajame izhod Relay; podprte brskalnike zaprosimo, naj zvok te aplikacije izključijo ali zadušijo.',
|
||||||
chooseOutput: 'Izberi izhod',
|
chooseOutput: 'Izberi izhod',
|
||||||
privacyFooter: 'Vaš ključ in zvok ostaneta med tem brskalnikom in Googlovim API-jem Gemini.',
|
privacyFooter: 'Vaš ključ in zvok ostaneta med tem brskalnikom in Googlovim API-jem Gemini.',
|
||||||
appLanguage: 'Jezik aplikacije',
|
appLanguage: 'Jezik aplikacije',
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@ export function supportsOutputSelection(): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function supportsOutputPicker(): boolean {
|
export function supportsOutputPicker(): boolean {
|
||||||
return typeof (navigator.mediaDevices as SelectableMediaDevices).selectAudioOutput === 'function'
|
const devices = navigator.mediaDevices as SelectableMediaDevices | undefined
|
||||||
|
return typeof devices?.selectAudioOutput === 'function'
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function chooseOutputDevice(): Promise<MediaDeviceInfo | null> {
|
export async function chooseOutputDevice(): Promise<MediaDeviceInfo | null> {
|
||||||
const devices = navigator.mediaDevices as SelectableMediaDevices
|
const devices = navigator.mediaDevices as SelectableMediaDevices | undefined
|
||||||
if (!devices.selectAudioOutput) return null
|
if (!devices?.selectAudioOutput) return null
|
||||||
return devices.selectAudioOutput()
|
return devices.selectAudioOutput()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +137,7 @@ export class DisplayAudioCapture {
|
||||||
noiseSuppression: false,
|
noiseSuppression: false,
|
||||||
autoGainControl: false,
|
autoGainControl: false,
|
||||||
suppressLocalAudioPlayback: false,
|
suppressLocalAudioPlayback: false,
|
||||||
|
restrictOwnAudio: true,
|
||||||
},
|
},
|
||||||
selfBrowserSurface: 'exclude',
|
selfBrowserSurface: 'exclude',
|
||||||
surfaceSwitching: 'include',
|
surfaceSwitching: 'include',
|
||||||
|
|
@ -232,9 +234,9 @@ export class PcmPlayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
async setOutputDevice(deviceId: string): Promise<void> {
|
async setOutputDevice(deviceId: string): Promise<void> {
|
||||||
this.sinkId = deviceId
|
|
||||||
if (!this.audio.setSinkId) throw new Error('This browser does not support selecting an output device.')
|
if (!this.audio.setSinkId) throw new Error('This browser does not support selecting an output device.')
|
||||||
await this.audio.setSinkId(deviceId)
|
await this.audio.setSinkId(deviceId)
|
||||||
|
this.sinkId = deviceId
|
||||||
}
|
}
|
||||||
|
|
||||||
enqueue(base64: string): PlaybackQueueResult {
|
enqueue(base64: string): PlaybackQueueResult {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,32 @@ async function decodeMessageData(data: unknown): Promise<string> {
|
||||||
throw new Error(`Unsupported WebSocket frame type: ${Object.prototype.toString.call(data)}`)
|
throw new Error(`Unsupported WebSocket frame type: ${Object.prototype.toString.call(data)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateWebSocketCloseReason(reason: string): string {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
let truncated = ''
|
||||||
|
let byteLength = 0
|
||||||
|
|
||||||
|
for (const character of reason) {
|
||||||
|
const characterBytes = encoder.encode(character).byteLength
|
||||||
|
if (byteLength + characterBytes > 123) break
|
||||||
|
truncated += character
|
||||||
|
byteLength += characterBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return truncated
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableCloseCode(code: number): boolean {
|
||||||
|
return ![1002, 1003, 1007, 1008, 1009, 1010].includes(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionClosedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Session closed')
|
||||||
|
this.name = 'SessionClosedError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class GeminiLiveSession {
|
export class GeminiLiveSession {
|
||||||
private socket: WebSocket | null = null
|
private socket: WebSocket | null = null
|
||||||
private closing = false
|
private closing = false
|
||||||
|
|
@ -173,6 +199,10 @@ export class GeminiLiveSession {
|
||||||
this.clearSetupTimer()
|
this.clearSetupTimer()
|
||||||
if (this.closing) return
|
if (this.closing) return
|
||||||
const reason = event.reason || `Connection closed (code ${event.code})`
|
const reason = event.reason || `Connection closed (code ${event.code})`
|
||||||
|
if (!isRetryableCloseCode(event.code)) {
|
||||||
|
this.fail(reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
this.scheduleReconnect(reason)
|
this.scheduleReconnect(reason)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +232,9 @@ export class GeminiLiveSession {
|
||||||
this.clearTimers()
|
this.clearTimers()
|
||||||
const socket = this.socket
|
const socket = this.socket
|
||||||
this.socket = null
|
this.socket = null
|
||||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1008, reason.slice(0, 120))
|
if (socket && socket.readyState < WebSocket.CLOSING) {
|
||||||
|
socket.close(1008, truncateWebSocketCloseReason(reason))
|
||||||
|
}
|
||||||
this.callbacks.onError(reason)
|
this.callbacks.onError(reason)
|
||||||
|
|
||||||
if (!this.initialSettled) {
|
if (!this.initialSettled) {
|
||||||
|
|
@ -238,6 +270,10 @@ export class GeminiLiveSession {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isReady(): boolean {
|
||||||
|
return this.ready && this.socket?.readyState === WebSocket.OPEN
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
if (this.closing) return
|
if (this.closing) return
|
||||||
this.closing = true
|
this.closing = true
|
||||||
|
|
@ -245,7 +281,7 @@ export class GeminiLiveSession {
|
||||||
this.clearTimers()
|
this.clearTimers()
|
||||||
if (!this.initialSettled) {
|
if (!this.initialSettled) {
|
||||||
this.initialSettled = true
|
this.initialSettled = true
|
||||||
this.rejectInitial?.(new DOMException('Session closed', 'AbortError'))
|
this.rejectInitial?.(new SessionClosedError())
|
||||||
this.resolveInitial = null
|
this.resolveInitial = null
|
||||||
this.rejectInitial = null
|
this.rejectInitial = null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,14 +58,31 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible
|
||||||
.icon-button:hover { background: white; }
|
.icon-button:hover { background: white; }
|
||||||
|
|
||||||
main { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding-bottom: 64px; }
|
main { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding-bottom: 64px; }
|
||||||
.hero { padding: 78px 0 52px; text-align: center; max-width: 820px; margin: 0 auto; }
|
|
||||||
.eyebrow { display: inline-flex; align-items: center; gap: 8px; text-transform: uppercase; letter-spacing: normal; font-size: 11px; font-weight: 700; color: var(--green); }
|
|
||||||
.live-dot { width: 7px; height: 7px; background: #2e9b77; border-radius: 50%; box-shadow: 0 0 0 5px rgba(46,155,119,.13); }
|
|
||||||
.hero h1 { margin: 20px 0 18px; font: 800 clamp(42px, 6vw, 70px)/1.02 Arial, sans-serif; letter-spacing: -.055em; }
|
|
||||||
.hero h1 span { color: var(--green); }
|
|
||||||
.hero-copy { max-width: 640px; margin: auto; font-size: 17px; line-height: 1.65; color: var(--muted); }
|
|
||||||
|
|
||||||
.workspace { display: grid; grid-template-columns: 1fr 1fr; min-height: 610px; border: 1px solid #d8d6cf; background: var(--paper); border-radius: 24px; overflow: hidden; box-shadow: 0 24px 70px rgba(32, 40, 36, .08); }
|
.guide-section { margin-top: 40px; }
|
||||||
|
.guide-launch { width: 100%; min-height: 88px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 16px; padding: 17px 22px; color: var(--ink); text-align: left; border: 1px solid #cfd9d4; border-radius: 17px; background: linear-gradient(135deg, #eef7f3, #faf8f3); cursor: pointer; box-shadow: 0 10px 28px rgba(32, 40, 36, .05); transition: transform .18s, border-color .18s, box-shadow .18s; }
|
||||||
|
.guide-launch:hover { transform: translateY(-1px); border-color: #9ebdaf; box-shadow: 0 14px 32px rgba(32, 40, 36, .08); }
|
||||||
|
.guide-launch-icon { width: 50px; height: 50px; display: grid; place-items: center; color: white; background: var(--green); border-radius: 14px 14px 14px 5px; }
|
||||||
|
.guide-launch-copy { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.guide-launch-copy strong { font-size: 17px; }
|
||||||
|
.guide-launch-copy small { color: var(--muted); font-size: 12px; }
|
||||||
|
.guide-launch > svg { color: var(--green); }
|
||||||
|
.usage-guide { margin-top: 12px; padding: 30px; border: 1px solid #d8d8d1; border-radius: 18px; background: rgba(251,250,247,.96); box-shadow: 0 18px 45px rgba(32,40,36,.06); }
|
||||||
|
.guide-heading { max-width: 720px; margin-bottom: 25px; }
|
||||||
|
.guide-heading > span { display: block; margin-bottom: 7px; color: var(--green); font-size: 10px; font-weight: 700; text-transform: uppercase; }
|
||||||
|
.guide-heading h2 { margin-bottom: 7px; font-size: 21px; }
|
||||||
|
.guide-heading p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
|
.guide-steps { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.guide-steps li { display: grid; grid-template-columns: auto 1fr; align-items: start; gap: 12px; padding: 16px; border: 1px solid #e0dfd9; border-radius: 12px; background: white; }
|
||||||
|
.guide-steps li > span { width: 28px; height: 28px; display: grid; place-items: center; color: var(--green); background: #e3f1eb; border-radius: 8px; font-size: 11px; font-weight: 800; }
|
||||||
|
.guide-steps strong { display: block; margin: 1px 0 5px; font-size: 12px; }
|
||||||
|
.guide-steps p { margin: 0; color: #747c78; font-size: 10px; line-height: 1.55; }
|
||||||
|
.guide-tip { display: flex; align-items: flex-start; gap: 12px; margin-top: 13px; padding: 15px 17px; color: #845134; border: 1px solid #ead2c2; border-radius: 12px; background: #fff3eb; }
|
||||||
|
.guide-tip > svg { flex: 0 0 auto; margin-top: 1px; }
|
||||||
|
.guide-tip strong { display: block; margin-bottom: 3px; font-size: 11px; }
|
||||||
|
.guide-tip p { margin: 0; color: #876957; font-size: 10px; line-height: 1.5; }
|
||||||
|
|
||||||
|
.workspace { display: grid; grid-template-columns: 1fr 1fr; min-height: 610px; margin-top: 18px; border: 1px solid #d8d6cf; background: var(--paper); border-radius: 24px; overflow: hidden; box-shadow: 0 24px 70px rgba(32, 40, 36, .08); }
|
||||||
.config-panel, .session-panel { padding: 38px 40px; }
|
.config-panel, .session-panel { padding: 38px 40px; }
|
||||||
.config-panel { border-right: 1px solid var(--line); background: rgba(251,250,247,.96); }
|
.config-panel { border-right: 1px solid var(--line); background: rgba(251,250,247,.96); }
|
||||||
.session-panel { background: #f7f6f2; display: flex; flex-direction: column; }
|
.session-panel { background: #f7f6f2; display: flex; flex-direction: column; }
|
||||||
|
|
@ -182,18 +199,22 @@ footer p { color: #888e8b; font-size: 10px; }
|
||||||
|
|
||||||
@media (max-width: 850px) {
|
@media (max-width: 850px) {
|
||||||
.workspace { grid-template-columns: 1fr; }
|
.workspace { grid-template-columns: 1fr; }
|
||||||
|
.guide-steps { grid-template-columns: 1fr; }
|
||||||
.config-panel { border-right: 0; border-bottom: 1px solid var(--line); }
|
.config-panel { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
.session-panel { min-height: 550px; }
|
.session-panel { min-height: 550px; }
|
||||||
.hero { padding-top: 55px; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.topbar { padding: 0 16px; grid-template-columns: 1fr auto; }
|
.topbar { padding: 0 16px; grid-template-columns: 1fr auto; }
|
||||||
.model-pill { display: none; }
|
.model-pill { display: none; }
|
||||||
main { width: min(100% - 24px, 1180px); }
|
main { width: min(100% - 24px, 1180px); }
|
||||||
.hero { padding: 44px 8px 35px; }
|
.guide-section { margin-top: 24px; }
|
||||||
.hero-copy { font-size: 14px; }
|
.guide-launch { min-height: 78px; padding: 14px 16px; }
|
||||||
.workspace { border-radius: 17px; }
|
.guide-launch-icon { width: 44px; height: 44px; }
|
||||||
|
.guide-launch-copy strong { font-size: 14px; }
|
||||||
|
.guide-launch-copy small { font-size: 10px; }
|
||||||
|
.usage-guide { padding: 21px; }
|
||||||
|
.workspace { margin-top: 14px; border-radius: 17px; }
|
||||||
.config-panel, .session-panel { padding: 27px 21px; }
|
.config-panel, .session-panel { padding: 27px 21px; }
|
||||||
.privacy-label { display: none; }
|
.privacy-label { display: none; }
|
||||||
.language-flow { grid-template-columns: 1fr 25px 1fr; }
|
.language-flow { grid-template-columns: 1fr 25px 1fr; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue