Update public/index.html

#1
Files changed (1) hide show
  1. public/index.html +370 -1
public/index.html CHANGED
@@ -168,4 +168,373 @@
168
  }
169
  .api-key-row input[type="password"],
170
  .api-key-row input[type="text"] {
171
- flex: 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  }
169
  .api-key-row input[type="password"],
170
  .api-key-row input[type="text"] {
171
+ flex: 1;
172
+ background: #0f0f13;
173
+ border: 1px solid #2a2a35;
174
+ border-radius: 8px;
175
+ color: #e8e8f0;
176
+ padding: 0.5rem 0.75rem;
177
+ font-size: 0.88rem;
178
+ font-family: monospace;
179
+ }
180
+ .api-key-row input:focus { outline: 2px solid #5b6af0; }
181
+ .api-key-row input.valid { border-color: #3ecf8e44; }
182
+ .toggle-vis {
183
+ background: none;
184
+ border: 1px solid #2a2a35;
185
+ border-radius: 8px;
186
+ color: #888;
187
+ padding: 0.5rem 0.6rem;
188
+ cursor: pointer;
189
+ font-size: 0.85rem;
190
+ flex-shrink: 0;
191
+ }
192
+ .toggle-vis:hover { color: #e8e8f0; border-color: #444; }
193
+ .key-status {
194
+ font-size: 0.75rem;
195
+ color: #555;
196
+ margin-top: -0.75rem;
197
+ margin-bottom: 1rem;
198
+ }
199
+ .key-status.ok { color: #3ecf8e; }
200
+
201
+ /* Koszt */
202
+ .cost-note {
203
+ font-size: 0.75rem;
204
+ color: #444;
205
+ text-align: center;
206
+ margin-top: 0.5rem;
207
+ }
208
+
209
+ /* Błąd */
210
+ #errorBox {
211
+ display: none;
212
+ background: #2a1515;
213
+ border: 1px solid #5a2020;
214
+ border-radius: 10px;
215
+ padding: 0.75rem 1rem;
216
+ font-size: 0.85rem;
217
+ color: #ff8080;
218
+ width: 100%;
219
+ max-width: 560px;
220
+ margin-bottom: 1rem;
221
+ }
222
+ </style>
223
+ </head>
224
+ <body>
225
+
226
+ <h1>Realtime Translator</h1>
227
+ <p class="subtitle">gpt-realtime-translate · WebRTC · mikrofon → tłumaczony głos + napisy</p>
228
+
229
+ <div id="errorBox"></div>
230
+
231
+ <div class="card">
232
+ <!-- Klucz API -->
233
+ <div class="transcript-label" style="margin-bottom:0.3rem">Klucz API OpenAI</div>
234
+ <div class="api-key-row">
235
+ <input type="password" id="apiKeyInput" placeholder="sk-..." autocomplete="off" spellcheck="false" />
236
+ <button class="toggle-vis" id="toggleVis" title="Pokaż/ukryj">👁</button>
237
+ </div>
238
+ <div class="key-status" id="keyStatus">Klucz nie jest zapisany</div>
239
+
240
+ <!-- Wybór języków -->
241
+ <div class="lang-row">
242
+ <div>
243
+ <div class="transcript-label" style="margin-bottom:0.3rem">Język wejściowy</div>
244
+ <select id="inputLang">
245
+ <option value="">Auto-detect</option>
246
+ <option value="pl">Polski</option>
247
+ <option value="en">English</option>
248
+ <option value="de">Deutsch</option>
249
+ <option value="fr">Français</option>
250
+ <option value="es">Español</option>
251
+ <option value="it">Italiano</option>
252
+ <option value="ja">日本語</option>
253
+ <option value="zh">中文</option>
254
+ <option value="uk">Українська</option>
255
+ <option value="ru">Русский</option>
256
+ </select>
257
+ </div>
258
+ <div class="arrow">→</div>
259
+ <div>
260
+ <div class="transcript-label" style="margin-bottom:0.3rem">Język docelowy</div>
261
+ <select id="outputLang">
262
+ <option value="en">English</option>
263
+ <option value="pl">Polski</option>
264
+ <option value="de">Deutsch</option>
265
+ <option value="fr">Français</option>
266
+ <option value="es">Español</option>
267
+ <option value="it">Italiano</option>
268
+ <option value="ja">日本語</option>
269
+ <option value="zh">中文</option>
270
+ <option value="uk">Українська</option>
271
+ <option value="ru">Русский</option>
272
+ </select>
273
+ </div>
274
+ </div>
275
+
276
+ <!-- Przycisk mikrofonu -->
277
+ <div class="mic-wrap">
278
+ <button id="micBtn" aria-label="Start / Stop nagrywania">
279
+ <!-- ikona mikrofonu -->
280
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
281
+ <path d="M12 1a4 4 0 0 1 4 4v7a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v7a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-7 9a7 7 0 0 0 14 0h2a9 9 0 0 1-8 8.94V23h-2v-2.06A9 9 0 0 1 3 12H5z"/>
282
+ </svg>
283
+ </button>
284
+ <span id="statusLabel">Kliknij mikrofon, aby rozpocząć</span>
285
+ </div>
286
+
287
+ <!-- Transkrypcja -->
288
+ <div class="transcript-wrap">
289
+ <div class="transcript-label">
290
+ Transkrypcja tłumaczenia
291
+ <button class="clear-btn" id="clearBtn" title="Wyczyść">✕ wyczyść</button>
292
+ </div>
293
+ <div id="transcript"></div>
294
+ </div>
295
+
296
+ <!-- Pasek połączenia -->
297
+ <div class="conn-bar">
298
+ <div class="dot" id="connDot"></div>
299
+ <span id="connLabel">Rozłączono</span>
300
+ </div>
301
+ </div>
302
+
303
+ <p class="cost-note">Koszt: ~$0.034 / minutę audio · rozliczane przez OpenAI</p>
304
+
305
+ <!-- Ukryty element audio do odtwarzania przetłumaczonego głosu -->
306
+ <audio id="remoteAudio" autoplay playsinline></audio>
307
+
308
+ <script>
309
+ const apiKeyInput = document.getElementById("apiKeyInput");
310
+ const toggleVis = document.getElementById("toggleVis");
311
+ const keyStatus = document.getElementById("keyStatus");
312
+ const micBtn = document.getElementById("micBtn");
313
+ const statusLabel = document.getElementById("statusLabel");
314
+ const transcript = document.getElementById("transcript");
315
+ const connDot = document.getElementById("connDot");
316
+ const connLabel = document.getElementById("connLabel");
317
+ const errorBox = document.getElementById("errorBox");
318
+ const clearBtn = document.getElementById("clearBtn");
319
+ const remoteAudio = document.getElementById("remoteAudio");
320
+ const inputLang = document.getElementById("inputLang");
321
+ const outputLang = document.getElementById("outputLang");
322
+
323
+ let pc = null; // RTCPeerConnection
324
+ let localStream = null;
325
+ let active = false;
326
+
327
+ // --- Klucz API: localStorage ---
328
+ const STORAGE_KEY = "oai_api_key";
329
+
330
+ function loadSavedKey() {
331
+ const saved = localStorage.getItem(STORAGE_KEY) ?? "";
332
+ if (saved) {
333
+ apiKeyInput.value = saved;
334
+ updateKeyStatus(saved);
335
+ }
336
+ }
337
+
338
+ function updateKeyStatus(val) {
339
+ if (val.startsWith("sk-") && val.length > 20) {
340
+ keyStatus.textContent = "Klucz zapisany lokalnie (localStorage)";
341
+ keyStatus.className = "key-status ok";
342
+ apiKeyInput.classList.add("valid");
343
+ } else if (val.length > 0) {
344
+ keyStatus.textContent = "Nieprawidłowy format klucza (powinien zaczynać się od sk-)";
345
+ keyStatus.className = "key-status";
346
+ apiKeyInput.classList.remove("valid");
347
+ } else {
348
+ keyStatus.textContent = "Klucz nie jest zapisany";
349
+ keyStatus.className = "key-status";
350
+ apiKeyInput.classList.remove("valid");
351
+ }
352
+ }
353
+
354
+ apiKeyInput.addEventListener("input", () => {
355
+ const val = apiKeyInput.value.trim();
356
+ localStorage.setItem(STORAGE_KEY, val);
357
+ updateKeyStatus(val);
358
+ });
359
+
360
+ toggleVis.addEventListener("click", () => {
361
+ apiKeyInput.type = apiKeyInput.type === "password" ? "text" : "password";
362
+ });
363
+
364
+ loadSavedKey();
365
+
366
+ clearBtn.addEventListener("click", () => { transcript.textContent = ""; });
367
+
368
+ function setConn(state) {
369
+ connDot.className = "dot";
370
+ if (state === "connected") { connDot.classList.add("green"); connLabel.textContent = "Połączono z OpenAI"; }
371
+ else if (state === "error") { connDot.classList.add("red"); connLabel.textContent = "Błąd połączenia"; }
372
+ else if (state === "loading") { connDot.classList.add("yellow"); connLabel.textContent = "Łączenie…"; }
373
+ else { connLabel.textContent = "Rozłączono"; }
374
+ }
375
+
376
+ function showError(msg) {
377
+ errorBox.style.display = "block";
378
+ errorBox.textContent = msg;
379
+ }
380
+ function clearError() {
381
+ errorBox.style.display = "none";
382
+ errorBox.textContent = "";
383
+ }
384
+
385
+ async function startSession() {
386
+ clearError();
387
+
388
+ const apiKey = apiKeyInput.value.trim();
389
+ if (!apiKey.startsWith("sk-")) {
390
+ showError("Wpisz prawidłowy klucz API OpenAI (zaczyna się od sk-).");
391
+ micBtn.classList.remove("active");
392
+ active = false;
393
+ return;
394
+ }
395
+
396
+ setConn("loading");
397
+ statusLabel.textContent = "Pobieranie tokenu sesji…";
398
+
399
+ // 1. Pobierz client secret z własnego backendu
400
+ let secretData;
401
+ try {
402
+ const res = await fetch("/session", {
403
+ method: "POST",
404
+ headers: { "Content-Type": "application/json" },
405
+ body: JSON.stringify({
406
+ targetLanguage: outputLang.value,
407
+ inputLanguage: inputLang.value || undefined,
408
+ apiKey,
409
+ }),
410
+ });
411
+ secretData = await res.json();
412
+ if (!res.ok) throw new Error(secretData.error ?? `HTTP ${res.status}`);
413
+ } catch (err) {
414
+ showError("Błąd sesji: " + err.message);
415
+ setConn("error");
416
+ statusLabel.textContent = "Kliknij mikrofon, aby spróbować ponownie";
417
+ micBtn.classList.remove("active");
418
+ active = false;
419
+ return;
420
+ }
421
+
422
+ const clientSecret = secretData?.value ?? secretData?.client_secret?.value;
423
+ if (!clientSecret) {
424
+ showError("Brak client_secret w odpowiedzi serwera. Sprawdź logi backendu.");
425
+ setConn("error");
426
+ micBtn.classList.remove("active");
427
+ active = false;
428
+ return;
429
+ }
430
+
431
+ // 2. Pobierz mikrofon
432
+ statusLabel.textContent = "Prośba o dostęp do mikrofonu…";
433
+ try {
434
+ localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
435
+ } catch (err) {
436
+ showError("Brak dostępu do mikrofonu: " + err.message);
437
+ setConn("error");
438
+ micBtn.classList.remove("active");
439
+ active = false;
440
+ return;
441
+ }
442
+
443
+ // 3. Utwórz RTCPeerConnection
444
+ pc = new RTCPeerConnection();
445
+
446
+ // Dodaj ścieżkę audio z mikrofonu
447
+ pc.addTrack(localStream.getAudioTracks()[0], localStream);
448
+
449
+ // Odtwórz przetłumaczony głos z serwera OpenAI
450
+ pc.ontrack = ({ streams }) => {
451
+ remoteAudio.srcObject = streams[0];
452
+ };
453
+
454
+ // Kanał zdarzeń — transkrypcja
455
+ const events = pc.createDataChannel("oai-events");
456
+ events.onmessage = ({ data }) => {
457
+ try {
458
+ const event = JSON.parse(data);
459
+
460
+ // Tłumaczona transkrypcja (output)
461
+ if (event.type === "session.output_transcript.delta" && event.delta) {
462
+ transcript.textContent += event.delta;
463
+ transcript.scrollTop = transcript.scrollHeight;
464
+ }
465
+
466
+ // Opcjonalnie: loguj inne zdarzenia
467
+ if (event.type === "error") {
468
+ console.error("OpenAI event error:", event);
469
+ showError("Błąd modelu: " + (event.error?.message ?? JSON.stringify(event.error)));
470
+ }
471
+ } catch { /* ignoruj nieparsowalny payload */ }
472
+ };
473
+
474
+ pc.onconnectionstatechange = () => {
475
+ const s = pc.connectionState;
476
+ if (s === "connected") { setConn("connected"); statusLabel.textContent = "Tłumaczę… mów do mikrofonu"; }
477
+ else if (s === "failed" || s === "disconnected") {
478
+ setConn("error");
479
+ statusLabel.textContent = "Połączenie utracone";
480
+ cleanup();
481
+ }
482
+ };
483
+
484
+ // 4. SDP offer → OpenAI → SDP answer
485
+ statusLabel.textContent = "Nawiązywanie połączenia WebRTC…";
486
+ const offer = await pc.createOffer();
487
+ await pc.setLocalDescription(offer);
488
+
489
+ let sdpAnswer;
490
+ try {
491
+ const sdpRes = await fetch(
492
+ "https://api.openai.com/v1/realtime/translations/calls",
493
+ {
494
+ method: "POST",
495
+ headers: {
496
+ Authorization: `Bearer ${clientSecret}`,
497
+ "Content-Type": "application/sdp",
498
+ },
499
+ body: offer.sdp,
500
+ }
501
+ );
502
+ if (!sdpRes.ok) {
503
+ const txt = await sdpRes.text();
504
+ throw new Error(`OpenAI SDP ${sdpRes.status}: ${txt}`);
505
+ }
506
+ sdpAnswer = await sdpRes.text();
507
+ } catch (err) {
508
+ showError("Błąd WebRTC SDP: " + err.message);
509
+ setConn("error");
510
+ cleanup();
511
+ return;
512
+ }
513
+
514
+ await pc.setRemoteDescription({ type: "answer", sdp: sdpAnswer });
515
+ // connectionstatechange obsłuży resztę
516
+ }
517
+
518
+ function cleanup() {
519
+ if (localStream) { localStream.getTracks().forEach(t => t.stop()); localStream = null; }
520
+ if (pc) { pc.close(); pc = null; }
521
+ remoteAudio.srcObject = null;
522
+ micBtn.classList.remove("active");
523
+ active = false;
524
+ setConn("disconnected");
525
+ statusLabel.textContent = "Kliknij mikrofon, aby rozpocząć";
526
+ }
527
+
528
+ micBtn.addEventListener("click", () => {
529
+ if (!active) {
530
+ active = true;
531
+ micBtn.classList.add("active");
532
+ statusLabel.textContent = "Uruchamianie…";
533
+ startSession();
534
+ } else {
535
+ cleanup();
536
+ }
537
+ });
538
+ </script>
539
+ </body>
540
+ </html>