Spaces:
Sleeping
fix(ux+voice): KI-274 — landing page enterprise redesign + Sarvam 429 friendly error
Browse filesTwo parallel bundles committed together:
A — Landing page enterprise 3-section redesign (frontend/src/app/page.tsx):
- Removed: 4 example suggestion chips ("nonsense"), yellow standalone callout
"Tell me the truth", "You can type below" narrative, "166 policies"
standalone line, header EN/HI lang toggle (already removed in KI-257).
- Section 1 "How this works": 3-step grid with numbered teal circles —
(1) ask Qs → profile, (2) match 150+ policies, (3) customised premium
estimate. Lightbulb icon header.
- Section 2 "Using the bot": 4-mode card grid — Type / Push-to-talk / Hold
SPACE / Live (BETA). Mic icon header. Note about Sarvam TTS + Hindi.
- Section 3 "Two things before we start": 2 callouts side-by-side —
Left (amber/teal): "Tell me the truth, even on hard things" (absorbed
yellow callout). Right (muted): "Speaking to the bot" — speak slowly,
English OR Hindi, the bot mirrors. Shield icon header.
- Visual: rounded cards with subtle borders + shadows, hero gradient
band, all driven by existing CSS variables (no new deps). Mobile
collapses to single column under sm breakpoint.
B — Sarvam STT 429 friendly error (backend/main.py + sarvam_stt.py +
frontend api.ts + page.tsx PTT path):
- Bug from screenshot: raw httpx error dumped to chat
"Sorry — transcribe error: transcribe failed: 500 {detail: STT failed:
HTTPStatusError: Client error '429 Too Many Requests'..."
- backend/providers/sarvam_stt.py: STT_ERROR_* enum, STT_ERROR_USER_MESSAGES
dict, classify_stt_exception() helper. Maps 429/401/403/5xx/timeout/
network to clean codes.
- backend/main.py /api/transcribe:
* Single retry with 2s backoff for 429 only (mirrors KI-242 pattern).
* Returns HTTP 200 (not 500) with shape {text:"", error_code:"rate_limit",
user_message:"Voice is busy right now..."}.
* Server-side logging.warning keeps diagnostics intact.
- frontend/src/lib/api.ts: TranscribeResponse type with error_code +
user_message optional fields.
- frontend/src/app/page.tsx PTT recorder.onstop:
* Checks error_code first; falls back to browser-SR transcript if present.
* Else pushAssistant(user_message) — never raw httpx text.
* Never calls send() on empty text.
Error classification table:
- 429 (after 1 retry) → "Voice is busy right now — please try again in a
moment, or type your question."
- 5xx → "Voice service is temporarily unavailable..."
- 401/403 → "Voice service is unavailable right now — please type your question."
- Timeout/network → "Network hiccup while transcribing..."
- Unknown → "Couldn't transcribe that — please try again..."
Verification:
- py_compile clean on backend/main.py + backend/providers/sarvam_stt.py
- npx tsc --noEmit clean
- next build succeeds (4/4 static pages)
- All 5 error categories + timeout + network classify correctly
- Grep confirms no raw httpx error path remains in page.tsx PTT branch
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/main.py +80 -8
- backend/providers/sarvam_stt.py +63 -0
- frontend/src/app/page.tsx +343 -51
- frontend/src/lib/api.ts +13 -1
|
@@ -96,6 +96,13 @@ class TranscribeResponse(BaseModel):
|
|
| 96 |
language_code: Optional[str] = None
|
| 97 |
confidence: Optional[float] = None
|
| 98 |
latency_ms: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
class CitationOut(BaseModel):
|
|
@@ -554,21 +561,86 @@ async def transcribe(
|
|
| 554 |
file: UploadFile = File(...),
|
| 555 |
language_code: Optional[str] = Form(None),
|
| 556 |
):
|
| 557 |
-
"""Speech-to-text. Accepts an audio file upload (WAV/MP3/etc.).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
t0 = time.time()
|
| 559 |
audio_bytes = await file.read()
|
| 560 |
ext = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower()
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
|
|
|
|
|
|
|
|
|
| 565 |
audio_bytes=audio_bytes,
|
| 566 |
-
audio_format=
|
| 567 |
language_code=language_code,
|
| 568 |
)
|
| 569 |
-
|
| 570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
latency = int((time.time() - t0) * 1000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
return TranscribeResponse(
|
| 573 |
text=result.text,
|
| 574 |
language_code=result.language_code,
|
|
|
|
| 96 |
language_code: Optional[str] = None
|
| 97 |
confidence: Optional[float] = None
|
| 98 |
latency_ms: int
|
| 99 |
+
# KI-242 — When Sarvam STT fails, the endpoint returns HTTP 200 with
|
| 100 |
+
# `text=""` plus these two fields set so the frontend can render a
|
| 101 |
+
# friendly message instead of parsing raw httpx error strings.
|
| 102 |
+
# error_code is a closed enum: rate_limit | service_unavailable |
|
| 103 |
+
# network | auth | unknown. Absent on the success path.
|
| 104 |
+
error_code: Optional[str] = None
|
| 105 |
+
user_message: Optional[str] = None
|
| 106 |
|
| 107 |
|
| 108 |
class CitationOut(BaseModel):
|
|
|
|
| 561 |
file: UploadFile = File(...),
|
| 562 |
language_code: Optional[str] = Form(None),
|
| 563 |
):
|
| 564 |
+
"""Speech-to-text. Accepts an audio file upload (WAV/MP3/etc.).
|
| 565 |
+
|
| 566 |
+
KI-242 — Sarvam errors are classified into a closed `error_code` enum
|
| 567 |
+
and the endpoint always returns HTTP 200 with a friendly `user_message`
|
| 568 |
+
on failure. The frontend never parses raw httpx text. 429 (rate limit)
|
| 569 |
+
is retried ONCE with a 2 s backoff before being surfaced as
|
| 570 |
+
`error_code: "rate_limit"`.
|
| 571 |
+
"""
|
| 572 |
+
import httpx as _httpx
|
| 573 |
+
from backend.providers.sarvam_stt import (
|
| 574 |
+
classify_stt_exception,
|
| 575 |
+
STT_ERROR_USER_MESSAGES,
|
| 576 |
+
STT_ERROR_RATE_LIMIT,
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
t0 = time.time()
|
| 580 |
audio_bytes = await file.read()
|
| 581 |
ext = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower()
|
| 582 |
+
audio_format = (
|
| 583 |
+
ext if ext in ("wav", "mp3", "flac", "ogg", "m4a", "webm", "opus", "mp4")
|
| 584 |
+
else "wav"
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
async def _try_once():
|
| 588 |
+
return await get_stt().transcribe(
|
| 589 |
audio_bytes=audio_bytes,
|
| 590 |
+
audio_format=audio_format,
|
| 591 |
language_code=language_code,
|
| 592 |
)
|
| 593 |
+
|
| 594 |
+
last_exc: Optional[BaseException] = None
|
| 595 |
+
try:
|
| 596 |
+
result = await _try_once()
|
| 597 |
+
except Exception as e: # noqa: BLE001 — classifier narrows
|
| 598 |
+
last_exc = e
|
| 599 |
+
# 429-only single retry with 2s backoff, mirroring KI-242. Only retry
|
| 600 |
+
# on a positively identified rate-limit; other failures surface fast.
|
| 601 |
+
is_rate_limited = (
|
| 602 |
+
isinstance(e, _httpx.HTTPStatusError)
|
| 603 |
+
and e.response is not None
|
| 604 |
+
and e.response.status_code == 429
|
| 605 |
+
)
|
| 606 |
+
if is_rate_limited:
|
| 607 |
+
await asyncio.sleep(2.0)
|
| 608 |
+
try:
|
| 609 |
+
result = await _try_once()
|
| 610 |
+
last_exc = None
|
| 611 |
+
except Exception as e2: # noqa: BLE001
|
| 612 |
+
last_exc = e2
|
| 613 |
+
|
| 614 |
latency = int((time.time() - t0) * 1000)
|
| 615 |
+
|
| 616 |
+
if last_exc is not None:
|
| 617 |
+
code = classify_stt_exception(last_exc)
|
| 618 |
+
# Log the underlying error server-side so we keep diagnostics, but
|
| 619 |
+
# never leak the raw httpx string to the user-facing response.
|
| 620 |
+
logging.warning(
|
| 621 |
+
"STT failed: error_code=%s exc=%s: %s",
|
| 622 |
+
code,
|
| 623 |
+
type(last_exc).__name__,
|
| 624 |
+
last_exc,
|
| 625 |
+
)
|
| 626 |
+
# Force rate_limit code when the retry-arm exhausted on 429 too.
|
| 627 |
+
if (
|
| 628 |
+
isinstance(last_exc, _httpx.HTTPStatusError)
|
| 629 |
+
and last_exc.response is not None
|
| 630 |
+
and last_exc.response.status_code == 429
|
| 631 |
+
):
|
| 632 |
+
code = STT_ERROR_RATE_LIMIT
|
| 633 |
+
return TranscribeResponse(
|
| 634 |
+
text="",
|
| 635 |
+
language_code=language_code,
|
| 636 |
+
confidence=0.0,
|
| 637 |
+
latency_ms=latency,
|
| 638 |
+
error_code=code,
|
| 639 |
+
user_message=STT_ERROR_USER_MESSAGES.get(
|
| 640 |
+
code, STT_ERROR_USER_MESSAGES["unknown"]
|
| 641 |
+
),
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
return TranscribeResponse(
|
| 645 |
text=result.text,
|
| 646 |
language_code=result.language_code,
|
|
@@ -17,6 +17,69 @@ from backend.config import settings
|
|
| 17 |
from backend.providers.base import STTProvider, STTResult
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
class SarvamSTT(STTProvider):
|
| 21 |
name = "sarvam-saarika"
|
| 22 |
|
|
|
|
| 17 |
from backend.providers.base import STTProvider, STTResult
|
| 18 |
|
| 19 |
|
| 20 |
+
# Public error-code vocabulary returned by /api/transcribe when Sarvam fails.
|
| 21 |
+
# Frontends (PTT + live voice) consume these as a closed enum so they can map
|
| 22 |
+
# each cause to a user-friendly reply without ever parsing httpx error text.
|
| 23 |
+
STT_ERROR_RATE_LIMIT = "rate_limit"
|
| 24 |
+
STT_ERROR_SERVICE = "service_unavailable"
|
| 25 |
+
STT_ERROR_NETWORK = "network"
|
| 26 |
+
STT_ERROR_AUTH = "auth"
|
| 27 |
+
STT_ERROR_UNKNOWN = "unknown"
|
| 28 |
+
|
| 29 |
+
# Human-readable reply per error_code. Kept here so the message lives next to
|
| 30 |
+
# the classifier — single source of truth for both backend response shaping
|
| 31 |
+
# and any place a tool wants to render the same string.
|
| 32 |
+
STT_ERROR_USER_MESSAGES = {
|
| 33 |
+
STT_ERROR_RATE_LIMIT: (
|
| 34 |
+
"Voice is busy right now — please try again in a moment, "
|
| 35 |
+
"or type your question."
|
| 36 |
+
),
|
| 37 |
+
STT_ERROR_SERVICE: (
|
| 38 |
+
"Voice service is temporarily unavailable — please type your "
|
| 39 |
+
"question or try voice again shortly."
|
| 40 |
+
),
|
| 41 |
+
STT_ERROR_NETWORK: (
|
| 42 |
+
"Network hiccup while transcribing — please try again, or "
|
| 43 |
+
"type your question."
|
| 44 |
+
),
|
| 45 |
+
STT_ERROR_AUTH: (
|
| 46 |
+
"Voice service is unavailable right now — please type your question."
|
| 47 |
+
),
|
| 48 |
+
STT_ERROR_UNKNOWN: (
|
| 49 |
+
"Couldn't transcribe that — please try again or type your question."
|
| 50 |
+
),
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def classify_stt_exception(exc: BaseException) -> str:
|
| 55 |
+
"""Map an httpx / network exception to a STT_ERROR_* code.
|
| 56 |
+
|
| 57 |
+
KI-242 pattern — the frontend never reads raw httpx text. Backend
|
| 58 |
+
classifies once at the boundary so PTT + live voice + any future
|
| 59 |
+
caller share one closed vocabulary.
|
| 60 |
+
"""
|
| 61 |
+
# httpx raises HTTPStatusError on resp.raise_for_status() with the
|
| 62 |
+
# original Response attached. Status code is the most reliable signal.
|
| 63 |
+
if isinstance(exc, httpx.HTTPStatusError):
|
| 64 |
+
status = exc.response.status_code if exc.response is not None else 0
|
| 65 |
+
if status == 429:
|
| 66 |
+
return STT_ERROR_RATE_LIMIT
|
| 67 |
+
if status in (401, 403):
|
| 68 |
+
return STT_ERROR_AUTH
|
| 69 |
+
if 500 <= status < 600:
|
| 70 |
+
return STT_ERROR_SERVICE
|
| 71 |
+
return STT_ERROR_UNKNOWN
|
| 72 |
+
# TimeoutException covers connect/read/write/pool timeouts.
|
| 73 |
+
if isinstance(exc, httpx.TimeoutException):
|
| 74 |
+
return STT_ERROR_NETWORK
|
| 75 |
+
# NetworkError covers ConnectError, ReadError, RemoteProtocolError, etc.
|
| 76 |
+
if isinstance(exc, httpx.NetworkError):
|
| 77 |
+
return STT_ERROR_NETWORK
|
| 78 |
+
if isinstance(exc, httpx.HTTPError):
|
| 79 |
+
return STT_ERROR_UNKNOWN
|
| 80 |
+
return STT_ERROR_UNKNOWN
|
| 81 |
+
|
| 82 |
+
|
| 83 |
class SarvamSTT(STTProvider):
|
| 84 |
name = "sarvam-saarika"
|
| 85 |
|
|
@@ -50,13 +50,6 @@ type DisplayMessage = ChatMessage & {
|
|
| 50 |
blocked?: boolean;
|
| 51 |
};
|
| 52 |
|
| 53 |
-
const SUGGESTED_QUESTIONS = [
|
| 54 |
-
"I'm looking for a new health insurance policy.",
|
| 55 |
-
"What is the waiting period for pre-existing diseases?",
|
| 56 |
-
"Does HDFC ERGO Optima Secure cover AYUSH?",
|
| 57 |
-
"What's the room rent cap on Care Supreme?",
|
| 58 |
-
];
|
| 59 |
-
|
| 60 |
export default function Page() {
|
| 61 |
const [messages, setMessages] = useState<DisplayMessage[]>([]);
|
| 62 |
const [input, setInput] = useState("");
|
|
@@ -1039,15 +1032,31 @@ export default function Page() {
|
|
| 1039 |
// placeholder while Sarvam runs. send() will clear it when (and only
|
| 1040 |
// when) we actually submit, so the user sees their words throughout.
|
| 1041 |
try {
|
| 1042 |
-
const
|
| 1043 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1044 |
// KI-213 — replace the interim SR transcript with Sarvam's
|
| 1045 |
// authoritative version, then submit. send() clears the input
|
| 1046 |
// itself so the brief flash here is intentional UX feedback.
|
| 1047 |
// V4 FIX 4 — transcript-sourced.
|
| 1048 |
-
setInputFromTranscript(text);
|
| 1049 |
// send() flips voicePhase to "thinking" itself; no need to set here
|
| 1050 |
-
await send(text);
|
| 1051 |
} else if (srFallback) {
|
| 1052 |
// KI-213 — Sarvam returned empty but the browser caught
|
| 1053 |
// something. Better than telling the user "couldn't hear that
|
|
@@ -1059,14 +1068,20 @@ export default function Page() {
|
|
| 1059 |
pushAssistant("Sorry, I couldn't hear that clearly. Please try again.");
|
| 1060 |
}
|
| 1061 |
} catch (e: unknown) {
|
| 1062 |
-
// KI-
|
| 1063 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1064 |
if (srFallback) {
|
| 1065 |
setInputFromTranscript(srFallback);
|
| 1066 |
try { await send(srFallback); } catch { /* send handles its own errors */ }
|
| 1067 |
} else {
|
| 1068 |
setInput("");
|
| 1069 |
-
pushAssistant(
|
|
|
|
|
|
|
| 1070 |
}
|
| 1071 |
} finally {
|
| 1072 |
setBusy(false);
|
|
@@ -1482,7 +1497,7 @@ export default function Page() {
|
|
| 1482 |
}`}>
|
| 1483 |
{messages.length === 0 ? (
|
| 1484 |
<>
|
| 1485 |
-
<EmptyState
|
| 1486 |
{/* KI-038 — dots visible even on the very first turn (no messages
|
| 1487 |
yet but the bot is hearing you / thinking) */}
|
| 1488 |
{(busy || voicePhase) && (
|
|
@@ -2418,46 +2433,323 @@ function HealthBadge({ health }: { health: { status: string; missing: string[] }
|
|
| 2418 |
);
|
| 2419 |
}
|
| 2420 |
|
| 2421 |
-
|
| 2422 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2423 |
return (
|
| 2424 |
-
<div className="flex-1 flex flex-col items-center
|
| 2425 |
-
<div className="w-
|
| 2426 |
-
|
| 2427 |
-
|
| 2428 |
-
|
| 2429 |
-
|
| 2430 |
-
|
| 2431 |
-
|
| 2432 |
-
|
| 2433 |
-
|
| 2434 |
-
|
| 2435 |
-
|
| 2436 |
-
|
| 2437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2438 |
</div>
|
| 2439 |
-
|
| 2440 |
-
|
| 2441 |
-
|
| 2442 |
-
|
| 2443 |
-
|
| 2444 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2445 |
</div>
|
| 2446 |
-
<div className="
|
| 2447 |
-
{
|
| 2448 |
-
const q = t(key);
|
| 2449 |
-
return (
|
| 2450 |
-
<button
|
| 2451 |
-
key={i}
|
| 2452 |
-
onClick={() => onSuggest(q)}
|
| 2453 |
-
className="text-left text-sm px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--card)] hover:border-[var(--primary)] transition"
|
| 2454 |
-
>
|
| 2455 |
-
<span className="opacity-50 text-xs">→</span> {q}
|
| 2456 |
-
</button>
|
| 2457 |
-
);
|
| 2458 |
-
})}
|
| 2459 |
</div>
|
| 2460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2461 |
);
|
| 2462 |
}
|
| 2463 |
|
|
|
|
| 50 |
blocked?: boolean;
|
| 51 |
};
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
export default function Page() {
|
| 54 |
const [messages, setMessages] = useState<DisplayMessage[]>([]);
|
| 55 |
const [input, setInput] = useState("");
|
|
|
|
| 1032 |
// placeholder while Sarvam runs. send() will clear it when (and only
|
| 1033 |
// when) we actually submit, so the user sees their words throughout.
|
| 1034 |
try {
|
| 1035 |
+
const tr = await postTranscribe(blob, ttsLang);
|
| 1036 |
+
// KI-242 — Backend now returns HTTP 200 + error_code on Sarvam
|
| 1037 |
+
// failures (rate_limit / service_unavailable / network / auth /
|
| 1038 |
+
// unknown). Prefer the SR fallback transcript when present so the
|
| 1039 |
+
// turn still goes through; otherwise surface the friendly
|
| 1040 |
+
// user_message and DO NOT call send() with empty text.
|
| 1041 |
+
if (tr.error_code) {
|
| 1042 |
+
if (srFallback) {
|
| 1043 |
+
setInputFromTranscript(srFallback);
|
| 1044 |
+
try { await send(srFallback); } catch { /* send handles its own errors */ }
|
| 1045 |
+
} else {
|
| 1046 |
+
setInput("");
|
| 1047 |
+
pushAssistant(
|
| 1048 |
+
tr.user_message ||
|
| 1049 |
+
"Couldn't transcribe that — please try again or type your question.",
|
| 1050 |
+
);
|
| 1051 |
+
}
|
| 1052 |
+
} else if (tr.text && tr.text.trim()) {
|
| 1053 |
// KI-213 — replace the interim SR transcript with Sarvam's
|
| 1054 |
// authoritative version, then submit. send() clears the input
|
| 1055 |
// itself so the brief flash here is intentional UX feedback.
|
| 1056 |
// V4 FIX 4 — transcript-sourced.
|
| 1057 |
+
setInputFromTranscript(tr.text);
|
| 1058 |
// send() flips voicePhase to "thinking" itself; no need to set here
|
| 1059 |
+
await send(tr.text);
|
| 1060 |
} else if (srFallback) {
|
| 1061 |
// KI-213 — Sarvam returned empty but the browser caught
|
| 1062 |
// something. Better than telling the user "couldn't hear that
|
|
|
|
| 1068 |
pushAssistant("Sorry, I couldn't hear that clearly. Please try again.");
|
| 1069 |
}
|
| 1070 |
} catch (e: unknown) {
|
| 1071 |
+
// KI-242 — postTranscribe only throws on transport-level failures
|
| 1072 |
+
// now (5xx from a different middlebox, abort, etc.). Sarvam errors
|
| 1073 |
+
// arrive as HTTP 200 + error_code above and never reach this
|
| 1074 |
+
// branch. Fall back to SR if we have one; else show a friendly
|
| 1075 |
+
// generic message — NEVER leak raw httpx text to the user.
|
| 1076 |
+
void e;
|
| 1077 |
if (srFallback) {
|
| 1078 |
setInputFromTranscript(srFallback);
|
| 1079 |
try { await send(srFallback); } catch { /* send handles its own errors */ }
|
| 1080 |
} else {
|
| 1081 |
setInput("");
|
| 1082 |
+
pushAssistant(
|
| 1083 |
+
"Voice service is temporarily unavailable — please type your question or try voice again shortly.",
|
| 1084 |
+
);
|
| 1085 |
}
|
| 1086 |
} finally {
|
| 1087 |
setBusy(false);
|
|
|
|
| 1497 |
}`}>
|
| 1498 |
{messages.length === 0 ? (
|
| 1499 |
<>
|
| 1500 |
+
<EmptyState coverage={coverage} t={t} uiLang={uiLang} />
|
| 1501 |
{/* KI-038 — dots visible even on the very first turn (no messages
|
| 1502 |
yet but the bot is hearing you / thinking) */}
|
| 1503 |
{(busy || voicePhase) && (
|
|
|
|
| 2433 |
);
|
| 2434 |
}
|
| 2435 |
|
| 2436 |
+
// EmptyState — landing page shown before the user's first message.
|
| 2437 |
+
// Redesigned 2026-05-15: removed the four hard-coded example chips
|
| 2438 |
+
// (unused conversational starters) and the standalone yellow "tell me the
|
| 2439 |
+
// truth" callout. Replaced with three structured cards: (1) how the bot
|
| 2440 |
+
// works, (2) the four input modes, (3) two "before we begin" callouts
|
| 2441 |
+
// (honesty + voice tips). Localized headings switch to Hindi when the UI
|
| 2442 |
+
// language toggle is on; long body copy reuses the existing welcome.*
|
| 2443 |
+
// translation keys (already bilingual) and supplements them with English
|
| 2444 |
+
// step / mode copy that mirrors the chat composer the user is about to use.
|
| 2445 |
+
function EmptyState({
|
| 2446 |
+
coverage,
|
| 2447 |
+
t,
|
| 2448 |
+
uiLang,
|
| 2449 |
+
}: {
|
| 2450 |
+
coverage: CoverageResponse | null;
|
| 2451 |
+
t: (k: StringKey, v?: Record<string, string | number>) => string;
|
| 2452 |
+
uiLang: UILang;
|
| 2453 |
+
}) {
|
| 2454 |
+
const isHi = uiLang === "hi";
|
| 2455 |
+
const sectionHeading = (en: string, hi: string) => (isHi ? hi : en);
|
| 2456 |
+
|
| 2457 |
return (
|
| 2458 |
+
<div className="flex-1 flex flex-col items-center px-4 py-6 sm:py-8">
|
| 2459 |
+
<div className="w-full max-w-3xl flex flex-col gap-8 sm:gap-10">
|
| 2460 |
+
{/* Hero band — subtle teal-to-white gradient, IA logo, headline. */}
|
| 2461 |
+
<section
|
| 2462 |
+
className="relative overflow-hidden rounded-2xl border border-[var(--border)] px-5 sm:px-7 py-6 sm:py-8 text-center"
|
| 2463 |
+
style={{
|
| 2464 |
+
background:
|
| 2465 |
+
"linear-gradient(180deg, color-mix(in srgb, var(--primary) 8%, var(--card)) 0%, var(--card) 100%)",
|
| 2466 |
+
}}
|
| 2467 |
+
>
|
| 2468 |
+
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl bg-[var(--primary)] text-[var(--primary-foreground)] flex items-center justify-center text-xl sm:text-2xl font-bold mb-4 mx-auto shadow-sm">
|
| 2469 |
+
IA
|
| 2470 |
+
</div>
|
| 2471 |
+
<h1 className="text-2xl sm:text-[2.5rem] sm:leading-tight font-semibold tracking-tight mb-3">
|
| 2472 |
+
{t("welcome.heading_a")}
|
| 2473 |
+
<em className="not-italic text-[var(--primary)]">{t("welcome.heading_b")}</em>
|
| 2474 |
+
{t("welcome.heading_c")}
|
| 2475 |
+
</h1>
|
| 2476 |
+
<p className="text-[15px] sm:text-base text-[var(--muted-foreground)] max-w-xl mx-auto leading-relaxed">
|
| 2477 |
+
{t("welcome.subtitle")}{" "}
|
| 2478 |
+
<strong className="text-[var(--foreground)]">{t("welcome.no_commissions")}</strong>{" "}
|
| 2479 |
+
{t("welcome.source_link")}
|
| 2480 |
+
</p>
|
| 2481 |
+
</section>
|
| 2482 |
+
|
| 2483 |
+
{/* Section 1 — How this works. Three numbered step cards. The
|
| 2484 |
+
coverage line ("X policies across Y insurers") is folded into
|
| 2485 |
+
step 2 here so it doesn't float as a standalone caption. */}
|
| 2486 |
+
<section className="rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-sm px-5 sm:px-6 py-5 sm:py-6">
|
| 2487 |
+
<div className="flex items-center gap-2 mb-4">
|
| 2488 |
+
<SectionIcon kind="lightbulb" />
|
| 2489 |
+
<h2 className="text-lg sm:text-xl font-semibold text-[var(--foreground)]">
|
| 2490 |
+
{sectionHeading("How this works", "यह कैसे काम करता है")}
|
| 2491 |
+
</h2>
|
| 2492 |
+
</div>
|
| 2493 |
+
<ol className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
|
| 2494 |
+
<StepCard
|
| 2495 |
+
n={1}
|
| 2496 |
+
title={sectionHeading(
|
| 2497 |
+
"I'll ask you a few questions",
|
| 2498 |
+
"मैं आपसे कुछ सवाल पूछूंगा"
|
| 2499 |
+
)}
|
| 2500 |
+
body={sectionHeading(
|
| 2501 |
+
"We'll build your profile — age, family, location, budget, conditions — one short answer at a time.",
|
| 2502 |
+
"हम आपकी प्रोफ़ाइल बनाएंगे — उम्र, परिवार, location, budget, conditions — एक-एक करके।"
|
| 2503 |
+
)}
|
| 2504 |
+
/>
|
| 2505 |
+
<StepCard
|
| 2506 |
+
n={2}
|
| 2507 |
+
title={sectionHeading(
|
| 2508 |
+
"I'll match you with the right policies",
|
| 2509 |
+
"सही policies match करूंगा"
|
| 2510 |
+
)}
|
| 2511 |
+
body={
|
| 2512 |
+
coverage
|
| 2513 |
+
? sectionHeading(
|
| 2514 |
+
`Ranked by fit-to-you, not by commission. ${coverage.total_policies} policies across ${coverage.total_insurers} Indian insurers are indexed — you can also upload your own policy PDF.`,
|
| 2515 |
+
`कमीशन से नहीं, आपके लिए fit के हिसाब से। ${coverage.total_policies} policies, ${coverage.total_insurers} बीमाकर्ताओं से indexed हैं — अपनी policy PDF भी upload कर सकते हैं।`
|
| 2516 |
+
)
|
| 2517 |
+
: sectionHeading(
|
| 2518 |
+
"Ranked by fit-to-you, not by commission. 150+ Indian health policies are indexed — you can also upload your own policy PDF.",
|
| 2519 |
+
"कमीशन से नहीं, आपके लिए fit के हिसाब से। 150+ Indian health policies indexed हैं — अपनी policy PDF भी upload कर सकते हैं।"
|
| 2520 |
+
)
|
| 2521 |
+
}
|
| 2522 |
+
/>
|
| 2523 |
+
<StepCard
|
| 2524 |
+
n={3}
|
| 2525 |
+
title={sectionHeading(
|
| 2526 |
+
"I'll give you a premium estimate",
|
| 2527 |
+
"Premium अनुमान दूंगा"
|
| 2528 |
+
)}
|
| 2529 |
+
body={sectionHeading(
|
| 2530 |
+
"An illustrative annual premium band, tuned to your profile — so you know what you're walking into before you talk to any insurer.",
|
| 2531 |
+
"आपकी profile पर आधारित illustrative वार्षिक premium band — ताकि किसी insurer से बात करने से पहले आपको पता हो क्या उम्मीद रखें।"
|
| 2532 |
+
)}
|
| 2533 |
+
/>
|
| 2534 |
+
</ol>
|
| 2535 |
+
</section>
|
| 2536 |
+
|
| 2537 |
+
{/* Section 2 — Using the bot. Four input-mode rows with mini icons.
|
| 2538 |
+
Mirrors the composer pill below (mic / spacebar / Live BETA). */}
|
| 2539 |
+
<section className="rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-sm px-5 sm:px-6 py-5 sm:py-6">
|
| 2540 |
+
<div className="flex items-center gap-2 mb-4">
|
| 2541 |
+
<SectionIcon kind="mic" />
|
| 2542 |
+
<h2 className="text-lg sm:text-xl font-semibold text-[var(--foreground)]">
|
| 2543 |
+
{sectionHeading("Using the bot", "Bot का इस्तेमाल कैसे करें")}
|
| 2544 |
+
</h2>
|
| 2545 |
+
</div>
|
| 2546 |
+
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
| 2547 |
+
<ModeRow
|
| 2548 |
+
icon="keyboard"
|
| 2549 |
+
title={sectionHeading("Type your message", "Message type करें")}
|
| 2550 |
+
body={sectionHeading(
|
| 2551 |
+
"Write in the chat box below and press Enter to send.",
|
| 2552 |
+
"नीचे के chat box में लिखें और Enter दबाएं।"
|
| 2553 |
+
)}
|
| 2554 |
+
/>
|
| 2555 |
+
<ModeRow
|
| 2556 |
+
icon="mic"
|
| 2557 |
+
title={sectionHeading("Push-to-talk", "Push-to-talk")}
|
| 2558 |
+
body={sectionHeading(
|
| 2559 |
+
"Click the green mic button to dictate one voice turn.",
|
| 2560 |
+
"हरे mic बटन पर click करके एक voice turn बोलें।"
|
| 2561 |
+
)}
|
| 2562 |
+
/>
|
| 2563 |
+
<ModeRow
|
| 2564 |
+
icon="space"
|
| 2565 |
+
title={sectionHeading("Hold SPACE to talk", "SPACE दबाकर बोलें")}
|
| 2566 |
+
body={sectionHeading(
|
| 2567 |
+
"Hold the space bar to talk hands-free; release to submit.",
|
| 2568 |
+
"बिना हाथ लगाए बोलने के लिए spacebar दबाए रखें; छोड़ने पर submit हो जाएगा।"
|
| 2569 |
+
)}
|
| 2570 |
+
/>
|
| 2571 |
+
<ModeRow
|
| 2572 |
+
icon="wave"
|
| 2573 |
+
title={sectionHeading("Live (BETA)", "Live (BETA)")}
|
| 2574 |
+
body={sectionHeading(
|
| 2575 |
+
"Toggle on for always-on listening with barge-in. Experimental — may hear background noise.",
|
| 2576 |
+
"हमेशा सुनने और बीच में रोकने (barge-in) के लिए toggle on करें। Experimental — background noise सुन सकता है।"
|
| 2577 |
+
)}
|
| 2578 |
+
/>
|
| 2579 |
+
</ul>
|
| 2580 |
+
<p className="mt-4 text-xs text-[var(--muted-foreground)] leading-relaxed">
|
| 2581 |
+
{sectionHeading(
|
| 2582 |
+
"The bot speaks back automatically (Sarvam TTS, Hindi-capable).",
|
| 2583 |
+
"Bot आवाज़ में जवाब देता है (Sarvam TTS, हिन्दी में भी)।"
|
| 2584 |
+
)}
|
| 2585 |
+
</p>
|
| 2586 |
+
</section>
|
| 2587 |
+
|
| 2588 |
+
{/* Section 3 — Before we begin. Two side-by-side callouts:
|
| 2589 |
+
(a) the honesty callout that previously lived as a standalone
|
| 2590 |
+
yellow box; (b) voice-tips so the user knows how to speak. */}
|
| 2591 |
+
<section className="rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-sm px-5 sm:px-6 py-5 sm:py-6">
|
| 2592 |
+
<div className="flex items-center gap-2 mb-4">
|
| 2593 |
+
<SectionIcon kind="shield" />
|
| 2594 |
+
<h2 className="text-lg sm:text-xl font-semibold text-[var(--foreground)]">
|
| 2595 |
+
{sectionHeading("Two things before we start", "शुरू करने से पहले दो बातें")}
|
| 2596 |
+
</h2>
|
| 2597 |
+
</div>
|
| 2598 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
| 2599 |
+
{/* Left — honesty callout. Uses the existing welcome.trust_* keys
|
| 2600 |
+
so the Hindi copy comes through untouched. */}
|
| 2601 |
+
<div className="rounded-xl border border-[var(--primary)] bg-[var(--accent)] px-4 py-4 text-left">
|
| 2602 |
+
<div className="text-sm font-semibold text-[var(--primary)] mb-1.5">
|
| 2603 |
+
{t("welcome.trust_title")}
|
| 2604 |
+
</div>
|
| 2605 |
+
<p className="text-[13px] text-[var(--muted-foreground)] leading-relaxed">
|
| 2606 |
+
{t("welcome.trust_body")}
|
| 2607 |
+
</p>
|
| 2608 |
+
</div>
|
| 2609 |
+
{/* Right — voice tips. */}
|
| 2610 |
+
<div className="rounded-xl border border-[var(--border)] bg-[var(--muted)] px-4 py-4 text-left">
|
| 2611 |
+
<div className="text-sm font-semibold text-[var(--foreground)] mb-1.5">
|
| 2612 |
+
{sectionHeading("Speaking to the bot", "Bot से बात करना")}
|
| 2613 |
+
</div>
|
| 2614 |
+
<p className="text-[13px] text-[var(--muted-foreground)] leading-relaxed">
|
| 2615 |
+
{sectionHeading(
|
| 2616 |
+
"Please speak slowly and clearly so the voice AI can understand you. You can speak in English OR Hindi — I'll respond in whichever language you used. If you say something unclear, just repeat or rephrase — no penalties.",
|
| 2617 |
+
"धीरे और साफ़ बोलिए ताकि voice AI समझ सके। आप English या हिन्दी, किसी भी भाषा में बोल सकते हैं — मैं उसी भाषा में जवाब दूंगा। अगर कुछ अस्पष्ट हो, तो दोबारा या अलग शब्दों में बोलिए — कोई penalty नहीं।"
|
| 2618 |
+
)}
|
| 2619 |
+
</p>
|
| 2620 |
+
</div>
|
| 2621 |
+
</div>
|
| 2622 |
+
</section>
|
| 2623 |
</div>
|
| 2624 |
+
</div>
|
| 2625 |
+
);
|
| 2626 |
+
}
|
| 2627 |
+
|
| 2628 |
+
// StepCard — one of the three numbered cards in Section 1.
|
| 2629 |
+
function StepCard({ n, title, body }: { n: number; title: string; body: string }) {
|
| 2630 |
+
return (
|
| 2631 |
+
<li className="rounded-xl border border-[var(--border)] bg-[var(--background)] px-4 py-4 flex flex-col">
|
| 2632 |
+
<div className="w-7 h-7 rounded-full bg-[var(--primary)] text-[var(--primary-foreground)] text-sm font-semibold flex items-center justify-center mb-3">
|
| 2633 |
+
{n}
|
| 2634 |
</div>
|
| 2635 |
+
<div className="text-sm font-semibold text-[var(--foreground)] mb-1.5 leading-snug">
|
| 2636 |
+
{title}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2637 |
</div>
|
| 2638 |
+
<p className="text-[13px] text-[var(--muted-foreground)] leading-relaxed">{body}</p>
|
| 2639 |
+
</li>
|
| 2640 |
+
);
|
| 2641 |
+
}
|
| 2642 |
+
|
| 2643 |
+
// ModeRow — one of the four input-mode rows in Section 2.
|
| 2644 |
+
function ModeRow({
|
| 2645 |
+
icon,
|
| 2646 |
+
title,
|
| 2647 |
+
body,
|
| 2648 |
+
}: {
|
| 2649 |
+
icon: "keyboard" | "mic" | "space" | "wave";
|
| 2650 |
+
title: string;
|
| 2651 |
+
body: string;
|
| 2652 |
+
}) {
|
| 2653 |
+
return (
|
| 2654 |
+
<li className="flex items-start gap-3 rounded-xl border border-[var(--border)] bg-[var(--background)] px-3.5 py-3">
|
| 2655 |
+
<div className="shrink-0 w-9 h-9 rounded-lg bg-[var(--muted)] text-[var(--primary)] flex items-center justify-center">
|
| 2656 |
+
<ModeIcon kind={icon} />
|
| 2657 |
+
</div>
|
| 2658 |
+
<div className="min-w-0">
|
| 2659 |
+
<div className="text-sm font-semibold text-[var(--foreground)] leading-snug">{title}</div>
|
| 2660 |
+
<p className="text-[13px] text-[var(--muted-foreground)] leading-relaxed mt-0.5">{body}</p>
|
| 2661 |
+
</div>
|
| 2662 |
+
</li>
|
| 2663 |
+
);
|
| 2664 |
+
}
|
| 2665 |
+
|
| 2666 |
+
// SectionIcon — leading icon for each section heading.
|
| 2667 |
+
function SectionIcon({ kind }: { kind: "lightbulb" | "mic" | "shield" }) {
|
| 2668 |
+
const common = {
|
| 2669 |
+
width: 20,
|
| 2670 |
+
height: 20,
|
| 2671 |
+
viewBox: "0 0 24 24",
|
| 2672 |
+
fill: "none",
|
| 2673 |
+
stroke: "currentColor",
|
| 2674 |
+
strokeWidth: 2,
|
| 2675 |
+
strokeLinecap: "round" as const,
|
| 2676 |
+
strokeLinejoin: "round" as const,
|
| 2677 |
+
className: "text-[var(--primary)]",
|
| 2678 |
+
};
|
| 2679 |
+
if (kind === "lightbulb") {
|
| 2680 |
+
return (
|
| 2681 |
+
<svg {...common}>
|
| 2682 |
+
<path d="M9 18h6" />
|
| 2683 |
+
<path d="M10 22h4" />
|
| 2684 |
+
<path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.2 1 2V17h6v-.3c0-.8.4-1.5 1-2A7 7 0 0 0 12 2Z" />
|
| 2685 |
+
</svg>
|
| 2686 |
+
);
|
| 2687 |
+
}
|
| 2688 |
+
if (kind === "mic") {
|
| 2689 |
+
return (
|
| 2690 |
+
<svg {...common}>
|
| 2691 |
+
<rect x="9" y="2" width="6" height="12" rx="3" />
|
| 2692 |
+
<path d="M5 10a7 7 0 0 0 14 0" />
|
| 2693 |
+
<path d="M12 17v4" />
|
| 2694 |
+
</svg>
|
| 2695 |
+
);
|
| 2696 |
+
}
|
| 2697 |
+
// shield
|
| 2698 |
+
return (
|
| 2699 |
+
<svg {...common}>
|
| 2700 |
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
|
| 2701 |
+
<path d="m9 12 2 2 4-4" />
|
| 2702 |
+
</svg>
|
| 2703 |
+
);
|
| 2704 |
+
}
|
| 2705 |
+
|
| 2706 |
+
// ModeIcon — small icons next to each input mode.
|
| 2707 |
+
function ModeIcon({ kind }: { kind: "keyboard" | "mic" | "space" | "wave" }) {
|
| 2708 |
+
const common = {
|
| 2709 |
+
width: 18,
|
| 2710 |
+
height: 18,
|
| 2711 |
+
viewBox: "0 0 24 24",
|
| 2712 |
+
fill: "none",
|
| 2713 |
+
stroke: "currentColor",
|
| 2714 |
+
strokeWidth: 2,
|
| 2715 |
+
strokeLinecap: "round" as const,
|
| 2716 |
+
strokeLinejoin: "round" as const,
|
| 2717 |
+
};
|
| 2718 |
+
if (kind === "keyboard") {
|
| 2719 |
+
return (
|
| 2720 |
+
<svg {...common}>
|
| 2721 |
+
<rect x="2" y="6" width="20" height="12" rx="2" />
|
| 2722 |
+
<path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" />
|
| 2723 |
+
</svg>
|
| 2724 |
+
);
|
| 2725 |
+
}
|
| 2726 |
+
if (kind === "mic") {
|
| 2727 |
+
return (
|
| 2728 |
+
<svg {...common}>
|
| 2729 |
+
<rect x="9" y="2" width="6" height="12" rx="3" />
|
| 2730 |
+
<path d="M5 10a7 7 0 0 0 14 0" />
|
| 2731 |
+
<path d="M12 17v4" />
|
| 2732 |
+
</svg>
|
| 2733 |
+
);
|
| 2734 |
+
}
|
| 2735 |
+
if (kind === "space") {
|
| 2736 |
+
return (
|
| 2737 |
+
<svg {...common}>
|
| 2738 |
+
<rect x="3" y="8" width="18" height="9" rx="2" />
|
| 2739 |
+
<path d="M7 13h10" />
|
| 2740 |
+
</svg>
|
| 2741 |
+
);
|
| 2742 |
+
}
|
| 2743 |
+
// wave
|
| 2744 |
+
return (
|
| 2745 |
+
<svg {...common}>
|
| 2746 |
+
<path d="M3 12h2" />
|
| 2747 |
+
<path d="M7 8v8" />
|
| 2748 |
+
<path d="M11 5v14" />
|
| 2749 |
+
<path d="M15 8v8" />
|
| 2750 |
+
<path d="M19 11v2" />
|
| 2751 |
+
<path d="M21 12h.01" />
|
| 2752 |
+
</svg>
|
| 2753 |
);
|
| 2754 |
}
|
| 2755 |
|
|
@@ -141,11 +141,23 @@ export async function postChat(args: {
|
|
| 141 |
return resp.json();
|
| 142 |
}
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
export async function postTranscribe(
|
| 145 |
blob: Blob,
|
| 146 |
language_code?: string,
|
| 147 |
signal?: AbortSignal,
|
| 148 |
-
): Promise<
|
| 149 |
const fd = new FormData();
|
| 150 |
// Use blob's mime to derive extension; default to wav
|
| 151 |
const mime = blob.type || "audio/wav";
|
|
|
|
| 141 |
return resp.json();
|
| 142 |
}
|
| 143 |
|
| 144 |
+
// KI-242 — Backend now returns a clean error_code + user_message on STT
|
| 145 |
+
// failures (HTTP 200 with empty text). Frontend consumes these directly
|
| 146 |
+
// instead of parsing raw httpx text. error_code is a closed enum:
|
| 147 |
+
// rate_limit | service_unavailable | network | auth | unknown.
|
| 148 |
+
export type TranscribeResponse = {
|
| 149 |
+
text: string;
|
| 150 |
+
language_code?: string;
|
| 151 |
+
latency_ms: number;
|
| 152 |
+
error_code?: "rate_limit" | "service_unavailable" | "network" | "auth" | "unknown";
|
| 153 |
+
user_message?: string;
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
export async function postTranscribe(
|
| 157 |
blob: Blob,
|
| 158 |
language_code?: string,
|
| 159 |
signal?: AbortSignal,
|
| 160 |
+
): Promise<TranscribeResponse> {
|
| 161 |
const fd = new FormData();
|
| 162 |
// Use blob's mime to derive extension; default to wav
|
| 163 |
const mime = blob.type || "audio/wav";
|