feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like the detail chip's trash affordance in the same file — an overflow-hidden rounded wrapper holding two raw <button>s divided by border-s — rather than two shared Buttons, which each hardcode their own rounding and would fight a segmented control. border-s puts the mic at the logical end: visually right in en/nl, visually left in fa, on the same side as the chip's trash in both directions. The two halves share a wrapper and nothing else. Add keeps its exact behaviour. The control never changes size while recording; the timer and level meter live in a bar between the header row and the chip strip, because the header is sm:justify-between and growing the button would shove the row on every start and stop. The meter exists to prove the microphone is actually hearing something — silence and a dead mic look identical otherwise. Voice reaches the editor as one optional `voice` prop, so its absence *is* the unavailable state and the two cannot disagree. Fixes from review of this commit: - mountedRef was set false on unmount and never re-armed, so under StrictMode the hook was permanently "unmounted" in dev and recording silently never started. - onStart guarded only on `phase`, which does not change until getUserMedia resolves; a second click during the permission prompt orphaned the first MediaStream, leaving the mic indicator lit. - Week start is now per locale. "Next Thursday" is week-relative, and hardcoding Saturday put an en/nl clinician's deadline a week out. - A missing `which` on a weekday intent is read as "this" rather than failing — a bare weekday carries no qualifier, and rejecting it discarded a real deadline. - durationMs is client-reported and so is a claim, not enforcement; the cap is now also checked against the vendor's own usage.seconds. - Blob type falls back to the recorder's actual mimeType before webm, so old Safari's mp4/aac clips are not mislabelled. Two review findings were rejected as incorrect, both re-verified against live sources: google/gemini-3.7-flash does exist on OpenRouter (1M context, $0.375/$1.875 per M), and base64 JSON input_audio is the documented primary path for /audio/transcriptions, with multipart as the OpenAI-compatible alternative. The spec's stale "unverified" note is corrected, and the provider now has unit tests covering the request shape, usage parsing, and that a vendor error body never reaches the thrown message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
|
||||
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||
import { weekStartForLocale } from './due-date.resolver';
|
||||
import {
|
||||
resolveVoiceIntent,
|
||||
type ResolvedExtraction,
|
||||
@@ -92,6 +93,7 @@ export class VoiceService {
|
||||
// Stage 1 — audio never touches disk and is not retained beyond this call.
|
||||
let transcript: string;
|
||||
let asrCost: number | null = null;
|
||||
let asrSeconds: number | null = null;
|
||||
try {
|
||||
const result = await asr.transcribe(
|
||||
{ data: dto.audio, format: dto.format },
|
||||
@@ -100,10 +102,19 @@ export class VoiceService {
|
||||
);
|
||||
transcript = result.text;
|
||||
asrCost = result.usage.costUsd;
|
||||
asrSeconds = result.usage.seconds;
|
||||
} catch (error) {
|
||||
throw this.toAppException(error, 'asr');
|
||||
}
|
||||
|
||||
// durationMs is client-reported and therefore not enforcement. usage.seconds is the
|
||||
// vendor's own measurement of the audio it decoded, so a client under-reporting length
|
||||
// to slip past the cap is caught here — after the ASR spend, but before the extraction
|
||||
// call, and visibly in telemetry.
|
||||
if (asrSeconds != null) {
|
||||
this.assertWithinCap(asrSeconds * 1000);
|
||||
}
|
||||
|
||||
if (!transcript.trim()) {
|
||||
throw new AppException(
|
||||
ErrorCode.VOICE_NOTHING_RECOGNIZED,
|
||||
@@ -126,6 +137,7 @@ export class VoiceService {
|
||||
llmCost = result.costUsd;
|
||||
resolved = resolveVoiceIntent(result.intent, {
|
||||
todayIso,
|
||||
weekStartJs: weekStartForLocale(catalogLocale),
|
||||
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
|
||||
prosthesisTypeCodes: new Set(
|
||||
catalog.prosthesisTypes.map((t) => t.code),
|
||||
|
||||
Reference in New Issue
Block a user