tempest-react-sdk 0.38.2 → 0.38.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/design/collect.mjs +19 -0
- package/bin/lib/design/functions.mjs +5 -0
- package/bin/lib/design/index.mjs +8 -3
- package/bin/lib/design/index.test.mjs +31 -0
- package/bin/lib/design/scan.mjs +34 -2
- package/bin/lib/design/scan.test.mjs +78 -0
- package/bin/lib/doctor/doctor.e2e.test.mjs +85 -0
- package/bin/tempest.mjs +90 -6
- package/dist/audio/level-meter.cjs.map +1 -1
- package/dist/audio/level-meter.js.map +1 -1
- package/dist/audio/use-audio-recorder.cjs.map +1 -1
- package/dist/audio/use-audio-recorder.js.map +1 -1
- package/dist/auth/passkey.cjs.map +1 -1
- package/dist/auth/passkey.js.map +1 -1
- package/dist/auth/use-passkey.cjs.map +1 -1
- package/dist/auth/use-passkey.js.map +1 -1
- package/dist/br/BrazilMap.cjs.map +1 -1
- package/dist/br/BrazilMap.js.map +1 -1
- package/dist/br/BrazilStateCitySelect.cjs.map +1 -1
- package/dist/br/BrazilStateCitySelect.js.map +1 -1
- package/dist/br/BrazilStateMap.cjs.map +1 -1
- package/dist/br/BrazilStateMap.js.map +1 -1
- package/dist/br/PixQRCode.cjs.map +1 -1
- package/dist/br/PixQRCode.js.map +1 -1
- package/dist/br/boleto.cjs.map +1 -1
- package/dist/br/boleto.js.map +1 -1
- package/dist/br/pix.cjs.map +1 -1
- package/dist/br/pix.js.map +1 -1
- package/dist/br.d.ts +8 -0
- package/dist/capture/media-recorder.cjs.map +1 -1
- package/dist/capture/media-recorder.js.map +1 -1
- package/dist/capture/use-barcode-scanner.cjs.map +1 -1
- package/dist/capture/use-barcode-scanner.js.map +1 -1
- package/dist/capture/use-screen-capture.cjs.map +1 -1
- package/dist/capture/use-screen-capture.js.map +1 -1
- package/dist/capture/use-speech-recognition.cjs.map +1 -1
- package/dist/capture/use-speech-recognition.js.map +1 -1
- package/dist/capture/use-video-recorder.cjs.map +1 -1
- package/dist/capture/use-video-recorder.js.map +1 -1
- package/dist/charts/types.cjs.map +1 -1
- package/dist/charts/types.js.map +1 -1
- package/dist/charts.d.ts +8 -0
- package/dist/components/AIChat/AIChat.cjs.map +1 -1
- package/dist/components/AIChat/AIChat.js.map +1 -1
- package/dist/components/AIChat/AIChatComposer.cjs.map +1 -1
- package/dist/components/AIChat/AIChatComposer.js.map +1 -1
- package/dist/components/AIChat/AIChatTurn.cjs.map +1 -1
- package/dist/components/AIChat/AIChatTurn.js.map +1 -1
- package/dist/components/AppBar/AppBar.cjs.map +1 -1
- package/dist/components/AppBar/AppBar.js.map +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.cjs.map +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.js.map +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.cjs.map +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.js.map +1 -1
- package/dist/components/BarcodeScanner/BarcodeScanner.cjs.map +1 -1
- package/dist/components/BarcodeScanner/BarcodeScanner.js.map +1 -1
- package/dist/components/Button/Button.cjs.map +1 -1
- package/dist/components/Button/Button.js.map +1 -1
- package/dist/components/Calendar/Calendar.cjs.map +1 -1
- package/dist/components/Calendar/Calendar.js.map +1 -1
- package/dist/components/Carousel/Carousel.cjs.map +1 -1
- package/dist/components/Carousel/Carousel.js.map +1 -1
- package/dist/components/Chat/Chat.cjs.map +1 -1
- package/dist/components/Chat/Chat.js.map +1 -1
- package/dist/components/ChipInput/ChipInput.cjs.map +1 -1
- package/dist/components/ChipInput/ChipInput.js.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.js.map +1 -1
- package/dist/components/Combobox/Combobox.cjs.map +1 -1
- package/dist/components/Combobox/Combobox.js.map +1 -1
- package/dist/components/Command/Command.cjs.map +1 -1
- package/dist/components/Command/Command.js.map +1 -1
- package/dist/components/ConfirmDialog/ConfirmDialog.cjs.map +1 -1
- package/dist/components/ConfirmDialog/ConfirmDialog.js.map +1 -1
- package/dist/components/ContextMenu/ContextMenu.cjs.map +1 -1
- package/dist/components/ContextMenu/ContextMenu.js.map +1 -1
- package/dist/components/CopyButton/CopyButton.cjs.map +1 -1
- package/dist/components/CopyButton/CopyButton.js.map +1 -1
- package/dist/components/DataTable/DataTable.cjs.map +1 -1
- package/dist/components/DataTable/DataTable.js.map +1 -1
- package/dist/components/DataTable/DataTable.module.cjs.map +1 -1
- package/dist/components/DataTable/DataTable.module.js.map +1 -1
- package/dist/components/DataTable/EditableCell.cjs.map +1 -1
- package/dist/components/DataTable/EditableCell.js.map +1 -1
- package/dist/components/DatePicker/DatePicker.cjs.map +1 -1
- package/dist/components/DatePicker/DatePicker.js.map +1 -1
- package/dist/components/DateRangePicker/DateRangePicker.cjs.map +1 -1
- package/dist/components/DateRangePicker/DateRangePicker.js.map +1 -1
- package/dist/components/Drawer/Drawer.cjs.map +1 -1
- package/dist/components/Drawer/Drawer.js.map +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.cjs.map +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.js.map +1 -1
- package/dist/components/Dropzone/Dropzone.cjs.map +1 -1
- package/dist/components/Dropzone/Dropzone.js.map +1 -1
- package/dist/components/FileUpload/FileUpload.cjs.map +1 -1
- package/dist/components/FileUpload/FileUpload.js.map +1 -1
- package/dist/components/FilterBar/FilterBar.cjs.map +1 -1
- package/dist/components/FilterBar/FilterBar.js.map +1 -1
- package/dist/components/ImageCropper/ImageCropper.cjs.map +1 -1
- package/dist/components/ImageCropper/ImageCropper.js.map +1 -1
- package/dist/components/InstallBanner/InstallBanner.cjs.map +1 -1
- package/dist/components/InstallBanner/InstallBanner.js.map +1 -1
- package/dist/components/Kanban/Kanban.cjs.map +1 -1
- package/dist/components/Kanban/Kanban.js.map +1 -1
- package/dist/components/Lightbox/Lightbox.cjs.map +1 -1
- package/dist/components/Lightbox/Lightbox.js.map +1 -1
- package/dist/components/ListTile/ListTile.cjs.map +1 -1
- package/dist/components/ListTile/ListTile.js.map +1 -1
- package/dist/components/Markdown/Markdown.cjs.map +1 -1
- package/dist/components/Markdown/Markdown.js.map +1 -1
- package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
- package/dist/components/Markdown/markdown-parse.js.map +1 -1
- package/dist/components/Menubar/Menubar.cjs.map +1 -1
- package/dist/components/Menubar/Menubar.js.map +1 -1
- package/dist/components/Modal/Modal.cjs.map +1 -1
- package/dist/components/Modal/Modal.js.map +1 -1
- package/dist/components/ModalsManager/ModalsManager.cjs.map +1 -1
- package/dist/components/ModalsManager/ModalsManager.js.map +1 -1
- package/dist/components/MultiSelect/MultiSelect.cjs.map +1 -1
- package/dist/components/MultiSelect/MultiSelect.js.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
- package/dist/components/NotificationCenter/NotificationCenter.cjs.map +1 -1
- package/dist/components/NotificationCenter/NotificationCenter.js.map +1 -1
- package/dist/components/Page/Page.cjs.map +1 -1
- package/dist/components/Page/Page.js.map +1 -1
- package/dist/components/Pagination/Pagination.cjs.map +1 -1
- package/dist/components/Pagination/Pagination.js.map +1 -1
- package/dist/components/PasswordInput/PasswordInput.cjs.map +1 -1
- package/dist/components/PasswordInput/PasswordInput.js.map +1 -1
- package/dist/components/PinInput/PinInput.cjs.map +1 -1
- package/dist/components/PinInput/PinInput.js.map +1 -1
- package/dist/components/Popover/Popover.cjs.map +1 -1
- package/dist/components/Popover/Popover.js.map +1 -1
- package/dist/components/Progress/Progress.cjs.map +1 -1
- package/dist/components/Progress/Progress.js.map +1 -1
- package/dist/components/QRCode/qr-encode.cjs.map +1 -1
- package/dist/components/QRCode/qr-encode.js.map +1 -1
- package/dist/components/Radio/Radio.cjs.map +1 -1
- package/dist/components/Radio/Radio.js.map +1 -1
- package/dist/components/RangeSlider/RangeSlider.cjs.map +1 -1
- package/dist/components/RangeSlider/RangeSlider.js.map +1 -1
- package/dist/components/RatingStars/RatingStars.cjs.map +1 -1
- package/dist/components/RatingStars/RatingStars.js.map +1 -1
- package/dist/components/RefreshIndicator/RefreshIndicator.cjs.map +1 -1
- package/dist/components/RefreshIndicator/RefreshIndicator.js.map +1 -1
- package/dist/components/Resizable/Resizable.cjs.map +1 -1
- package/dist/components/Resizable/Resizable.js.map +1 -1
- package/dist/components/Scheduler/Scheduler.cjs.map +1 -1
- package/dist/components/Scheduler/Scheduler.js.map +1 -1
- package/dist/components/Sidebar/Sidebar.cjs.map +1 -1
- package/dist/components/Sidebar/Sidebar.js.map +1 -1
- package/dist/components/SignaturePad/SignaturePad.cjs.map +1 -1
- package/dist/components/SignaturePad/SignaturePad.js.map +1 -1
- package/dist/components/Slider/Slider.cjs.map +1 -1
- package/dist/components/Slider/Slider.js.map +1 -1
- package/dist/components/Sparkline/Sparkline.cjs.map +1 -1
- package/dist/components/Sparkline/Sparkline.js.map +1 -1
- package/dist/components/StepperInput/StepperInput.cjs.map +1 -1
- package/dist/components/StepperInput/StepperInput.js.map +1 -1
- package/dist/components/Table/Table.cjs.map +1 -1
- package/dist/components/Table/Table.js.map +1 -1
- package/dist/components/TimePicker/TimePicker.cjs.map +1 -1
- package/dist/components/TimePicker/TimePicker.js.map +1 -1
- package/dist/components/Toast/ToastProvider.cjs.map +1 -1
- package/dist/components/Toast/ToastProvider.js.map +1 -1
- package/dist/components/Tour/Tour.cjs.map +1 -1
- package/dist/components/Tour/Tour.js.map +1 -1
- package/dist/components/Transfer/Transfer.cjs.map +1 -1
- package/dist/components/Transfer/Transfer.js.map +1 -1
- package/dist/components/TreeView/TreeView.cjs.map +1 -1
- package/dist/components/TreeView/TreeView.js.map +1 -1
- package/dist/components/VirtualList/VirtualList.cjs.map +1 -1
- package/dist/components/VirtualList/VirtualList.js.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.cjs.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.js.map +1 -1
- package/dist/components/Wizard/Wizard.cjs.map +1 -1
- package/dist/components/Wizard/Wizard.js.map +1 -1
- package/dist/editor/RichTextEditor.cjs.map +1 -1
- package/dist/editor/RichTextEditor.js.map +1 -1
- package/dist/forms/FormField.cjs.map +1 -1
- package/dist/forms/FormField.js.map +1 -1
- package/dist/geo/TrajectoryMap.cjs.map +1 -1
- package/dist/geo/TrajectoryMap.js.map +1 -1
- package/dist/geo/estimate.cjs.map +1 -1
- package/dist/geo/estimate.js.map +1 -1
- package/dist/geo/projection.cjs.map +1 -1
- package/dist/geo/projection.js.map +1 -1
- package/dist/hooks/use-event-listener.cjs.map +1 -1
- package/dist/hooks/use-event-listener.js.map +1 -1
- package/dist/hooks/use-local-storage.cjs.map +1 -1
- package/dist/hooks/use-local-storage.js.map +1 -1
- package/dist/hooks/use-sortable.cjs.map +1 -1
- package/dist/hooks/use-sortable.js.map +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/errors.cjs.map +1 -1
- package/dist/http/errors.js.map +1 -1
- package/dist/http/resumable-upload.cjs.map +1 -1
- package/dist/http/resumable-upload.js.map +1 -1
- package/dist/http/upload-with-progress.cjs.map +1 -1
- package/dist/http/upload-with-progress.js.map +1 -1
- package/dist/i18n/I18nProvider.cjs.map +1 -1
- package/dist/i18n/I18nProvider.js.map +1 -1
- package/dist/imaging/canvas.cjs.map +1 -1
- package/dist/imaging/canvas.js.map +1 -1
- package/dist/imaging.d.ts +5 -0
- package/dist/logger/logger.cjs.map +1 -1
- package/dist/logger/logger.js.map +1 -1
- package/dist/oauth/GoogleSignIn.cjs.map +1 -1
- package/dist/oauth/GoogleSignIn.js.map +1 -1
- package/dist/offline/create-offline-sync.cjs.map +1 -1
- package/dist/offline/create-offline-sync.js.map +1 -1
- package/dist/perf/cache-size.cjs +1 -1
- package/dist/perf/cache-size.cjs.map +1 -1
- package/dist/perf/cache-size.js +27 -6
- package/dist/perf/cache-size.js.map +1 -1
- package/dist/sse/create-event-stream.cjs.map +1 -1
- package/dist/sse/create-event-stream.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/cache.cjs.map +1 -1
- package/dist/sw/cache.js.map +1 -1
- package/dist/sw.d.ts +12 -0
- package/dist/tabular/compact.cjs.map +1 -1
- package/dist/tabular/compact.js.map +1 -1
- package/dist/tabular/predictor.cjs.map +1 -1
- package/dist/tabular/predictor.js.map +1 -1
- package/dist/tabular.d.ts +7 -2
- package/dist/tempest-react-sdk.d.ts +133 -6
- package/dist/theme/ThemeProvider.cjs.map +1 -1
- package/dist/theme/ThemeProvider.js.map +1 -1
- package/dist/theme/color.cjs.map +1 -1
- package/dist/theme/color.js.map +1 -1
- package/dist/theme/create-theme.cjs.map +1 -1
- package/dist/theme/create-theme.js.map +1 -1
- package/dist/theme/data-viz-ramps.cjs +1 -1
- package/dist/theme/data-viz-ramps.cjs.map +1 -1
- package/dist/theme/data-viz-ramps.js +1 -1
- package/dist/theme/data-viz-ramps.js.map +1 -1
- package/dist/utils/storage.cjs.map +1 -1
- package/dist/utils/storage.js.map +1 -1
- package/dist/vision/core/canvas.cjs.map +1 -1
- package/dist/vision/core/canvas.js.map +1 -1
- package/dist/vision/core/exceptions.cjs.map +1 -1
- package/dist/vision/core/exceptions.js.map +1 -1
- package/dist/vision/core/graph.cjs.map +1 -1
- package/dist/vision/core/graph.js.map +1 -1
- package/dist/vision/core/metadata.cjs.map +1 -1
- package/dist/vision/core/metadata.js.map +1 -1
- package/dist/vision/core/providers.cjs.map +1 -1
- package/dist/vision/core/providers.js.map +1 -1
- package/dist/vision/core/session.cjs.map +1 -1
- package/dist/vision/core/session.js.map +1 -1
- package/dist/vision/core/timing.cjs.map +1 -1
- package/dist/vision/core/timing.js.map +1 -1
- package/dist/vision/io/image.cjs.map +1 -1
- package/dist/vision/io/image.js.map +1 -1
- package/dist/vision/labels.cjs.map +1 -1
- package/dist/vision/labels.js.map +1 -1
- package/dist/vision/luminance.cjs.map +1 -1
- package/dist/vision/luminance.js.map +1 -1
- package/dist/vision/postprocess/classification.cjs.map +1 -1
- package/dist/vision/postprocess/classification.js.map +1 -1
- package/dist/vision/postprocess/detection.cjs.map +1 -1
- package/dist/vision/postprocess/detection.js.map +1 -1
- package/dist/vision/postprocess/segmentation.cjs.map +1 -1
- package/dist/vision/postprocess/segmentation.js.map +1 -1
- package/dist/vision/preprocess/image.cjs.map +1 -1
- package/dist/vision/preprocess/image.js.map +1 -1
- package/dist/vision/results.cjs.map +1 -1
- package/dist/vision/results.js.map +1 -1
- package/dist/vision/tasks/base.cjs.map +1 -1
- package/dist/vision/tasks/base.js.map +1 -1
- package/dist/vision/tasks/classifier.cjs.map +1 -1
- package/dist/vision/tasks/classifier.js.map +1 -1
- package/dist/vision/tasks/detector.cjs.map +1 -1
- package/dist/vision/tasks/detector.js.map +1 -1
- package/dist/vision/tasks/segmenter.cjs.map +1 -1
- package/dist/vision/tasks/segmenter.js.map +1 -1
- package/dist/vision/types.cjs.map +1 -1
- package/dist/vision/types.js.map +1 -1
- package/dist/vision.d.ts +6 -0
- package/dist/vite/tempest-icons.cjs.map +1 -1
- package/dist/vite/tempest-icons.js.map +1 -1
- package/dist/vite/tempest-pwa-icons.cjs.map +1 -1
- package/dist/vite/tempest-pwa-icons.js.map +1 -1
- package/dist/ws/create-web-socket.cjs.map +1 -1
- package/dist/ws/create-web-socket.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-speech-recognition.cjs","names":[],"sources":["../../src/capture/use-speech-recognition.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\n\n/** One reading of what was heard. */\nexport interface SpeechAlternativeLike {\n transcript: string;\n confidence?: number;\n}\n\n/** One recognised phrase, settled (`isFinal`) or still being revised. */\nexport interface SpeechResultLike {\n isFinal: boolean;\n length: number;\n [index: number]: SpeechAlternativeLike | undefined;\n}\n\n/** The growing list of phrases in a session. */\nexport interface SpeechResultListLike {\n length: number;\n [index: number]: SpeechResultLike | undefined;\n}\n\n/** The `result` event, reduced to what this hook reads. */\nexport interface SpeechRecognitionEventLike {\n /** Index of the first result that changed — everything before it is settled. */\n resultIndex: number;\n results: SpeechResultListLike;\n}\n\n/** The `error` event, reduced to what this hook reads. */\nexport interface SpeechRecognitionErrorEventLike {\n error: string;\n message?: string;\n}\n\n/**\n * The slice of the Web Speech `SpeechRecognition` object this SDK uses.\n *\n * Declared here rather than imported: TypeScript's DOM lib ships the *event* types but\n * not the constructor, because the API is still prefixed in Chromium and absent in\n * Firefox. Exported so a test — or a consumer wrapping a different engine — can hand in\n * something else.\n */\nexport interface SpeechRecognitionLike {\n lang: string;\n continuous: boolean;\n interimResults: boolean;\n maxAlternatives: number;\n start: () => void;\n stop: () => void;\n abort: () => void;\n onresult: ((event: SpeechRecognitionEventLike) => void) | null;\n onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;\n onend: (() => void) | null;\n onstart: (() => void) | null;\n}\n\n/** Classified reason recognition stopped or never started. */\nexport type SpeechErrorKind =\n | \"unsupported\"\n | \"not-allowed\"\n | \"no-speech\"\n | \"audio-capture\"\n | \"network\"\n | \"aborted\"\n | \"language-not-supported\"\n | \"unknown\";\n\n/** A classified speech error with a human-readable, English message. */\nexport interface SpeechError {\n kind: SpeechErrorKind;\n message: string;\n}\n\n/** Options for {@link useSpeechRecognition}. */\nexport interface UseSpeechRecognitionOptions {\n /** BCP-47 tag. Default `\"pt-BR\"`. */\n lang?: string;\n /**\n * Keep listening after the first phrase settles. Default `false`.\n *\n * Even with this on, the engine ends the session by itself after a stretch of\n * silence — that is a server-side timeout, not a bug — so watch `listening` rather\n * than assuming the microphone stays open.\n */\n continuous?: boolean;\n /** Publish the running guess as it changes. Default `true`. */\n interimResults?: boolean;\n /** How many readings per phrase to ask for. Default 1. */\n maxAlternatives?: number;\n /** Every update, settled or not. */\n onResult?: (result: { transcript: string; isFinal: boolean }) => void;\n /** Only the settled text of a phrase. The one to wire dictation to. */\n onFinal?: (transcript: string) => void;\n /** Classified failure. `no-speech` and `aborted` arrive here too — they are routine. */\n onError?: (error: SpeechError) => void;\n /** The session ended, for any reason. */\n onEnd?: () => void;\n /** Build the recogniser yourself — another engine, or a stub in a test. */\n factory?: () => SpeechRecognitionLike;\n}\n\n/** Value returned by {@link useSpeechRecognition}. */\nexport interface UseSpeechRecognitionResult {\n /** `false` in Firefox and in every browser that is not Chromium-based. */\n supported: boolean;\n /** Whether a session is open right now. */\n listening: boolean;\n /** Everything settled so far in this session. Cleared by `reset()`. */\n transcript: string;\n /** The running guess. Replaced on every event, empty once the phrase settles. */\n interim: string;\n /** Classified error, or `null`. */\n error: SpeechError | null;\n /** Open a session. No-op while already listening. */\n start: () => void;\n /** Close the session, keeping what was recognised. */\n stop: () => void;\n /** Close the session and throw the pending phrase away. */\n abort: () => void;\n /** Clear `transcript`, `interim` and `error`. Does not stop a session. */\n reset: () => void;\n}\n\n/** English messages for the `error` codes the spec defines. */\nconst MESSAGES: Record<SpeechErrorKind, string> = {\n unsupported: \"Speech recognition is not supported in this browser.\",\n \"not-allowed\": \"Microphone permission denied. Enable access in your browser settings.\",\n \"no-speech\": \"No speech was detected.\",\n \"audio-capture\": \"No microphone available on this device.\",\n network: \"The recognition service could not be reached.\",\n aborted: \"Recognition was cancelled.\",\n \"language-not-supported\": \"The recognition service does not support this language.\",\n unknown: \"Unexpected error during speech recognition.\",\n};\n\n/**\n * Map a spec `error` code to a kind an app can branch on.\n *\n * `service-not-allowed` collapses into `not-allowed` because the fix is the same from\n * the user's side, and `bad-grammar` into `unknown` because this hook never sets a\n * grammar, so seeing it means something outside our control went wrong.\n *\n * @param code - The `error` property of the event.\n * @returns The classified kind.\n */\nfunction classifySpeechError(code: string): SpeechErrorKind {\n switch (code) {\n case \"not-allowed\":\n case \"service-not-allowed\":\n return \"not-allowed\";\n case \"no-speech\":\n return \"no-speech\";\n case \"audio-capture\":\n return \"audio-capture\";\n case \"network\":\n return \"network\";\n case \"aborted\":\n return \"aborted\";\n case \"language-not-supported\":\n return \"language-not-supported\";\n default:\n return \"unknown\";\n }\n}\n\n/** The constructor, prefixed or not, or `null` where the API does not exist. */\nfunction speechRecognitionConstructor(): (new () => SpeechRecognitionLike) | null {\n const scope = globalThis as {\n SpeechRecognition?: unknown;\n webkitSpeechRecognition?: unknown;\n };\n const candidate = scope.SpeechRecognition ?? scope.webkitSpeechRecognition;\n return typeof candidate === \"function\" ? (candidate as new () => SpeechRecognitionLike) : null;\n}\n\n/** Whether this browser exposes the Web Speech recognition API at all. */\nexport function isSpeechRecognitionSupported(): boolean {\n return speechRecognitionConstructor() !== null;\n}\n\n/**\n * Dictate into your app with the Web Speech API — no dependency, no API key.\n *\n * ## Recognition is not local\n *\n * **Chromium streams the captured audio to a Google server to transcribe it.** Nothing\n * about the API says so, there is no setting that changes it, and it happens on every\n * `start()`. Anything the user says while a session is open leaves the device. Do not\n * put this on a field that takes clinical notes, credentials, or a client's financial\n * detail without telling them first — and if the data cannot leave your infrastructure,\n * this API is the wrong tool and a self-hosted model is the right one.\n *\n * ## What the states mean\n *\n * `transcript` accumulates the phrases the engine has **settled** on; `interim` is the\n * guess it is still revising and is replaced wholesale on every event, so rendering\n * `transcript + interim` gives the live caption effect and rendering `transcript` alone\n * gives the committed text. `no-speech` and `aborted` come through `onError` but are\n * routine — a user who pressed the button and said nothing is not a failure to report.\n *\n * There is deliberately **no auto-restart** when the engine ends a session on silence.\n * A restart loop is how an app ends up holding the microphone indefinitely — and, in\n * Chromium, streaming audio to a third party indefinitely. Show that listening stopped\n * and let the user press again.\n *\n * @param options - See {@link UseSpeechRecognitionOptions}.\n * @returns Session state, the transcript and the controls.\n *\n * @example\n * const speech = useSpeechRecognition({ onFinal: (text) => setPrompt(text) });\n * <button onClick={speech.listening ? speech.stop : speech.start}>\n * {speech.listening ? \"Parar\" : \"Ditar\"}\n * </button>\n * <p>{speech.transcript}{speech.interim}</p>\n */\nexport function useSpeechRecognition(\n options: UseSpeechRecognitionOptions = {},\n): UseSpeechRecognitionResult {\n const {\n lang = \"pt-BR\",\n continuous = false,\n interimResults = true,\n maxAlternatives = 1,\n onResult,\n onFinal,\n onError,\n onEnd,\n factory,\n } = options;\n\n const [supported] = useState(() => factory !== undefined || isSpeechRecognitionSupported());\n const [listening, setListening] = useState(false);\n const [transcript, setTranscript] = useState(\"\");\n const [interim, setInterim] = useState(\"\");\n const [error, setError] = useState<SpeechError | null>(null);\n\n const sessionRef = useRef<SpeechRecognitionLike | null>(null);\n\n const emitResult = useStableCallback((result: { transcript: string; isFinal: boolean }) =>\n onResult?.(result),\n );\n const emitFinal = useStableCallback((text: string) => onFinal?.(text));\n const emitEnd = useStableCallback(() => onEnd?.());\n const emitError = useStableCallback((failure: SpeechError) => {\n setError(failure);\n onError?.(failure);\n });\n\n const teardown = useCallback((): void => {\n const session = sessionRef.current;\n if (!session) return;\n session.onresult = null;\n session.onerror = null;\n session.onend = null;\n session.onstart = null;\n sessionRef.current = null;\n }, []);\n\n const start = useCallback((): void => {\n if (sessionRef.current) return;\n const build =\n factory ??\n (() => {\n const constructor = speechRecognitionConstructor();\n return constructor ? new constructor() : null;\n });\n const session = build();\n if (!session) {\n emitError({ kind: \"unsupported\", message: MESSAGES.unsupported });\n return;\n }\n\n session.lang = lang;\n session.continuous = continuous;\n session.interimResults = interimResults;\n session.maxAlternatives = maxAlternatives;\n\n /**\n * Fold one event into the two published strings.\n *\n * Only the results from `resultIndex` on are new; re-reading the whole list\n * would append phrases that are already in `transcript`. Settled text is\n * accumulated, unsettled text replaces the previous guess entirely — the engine\n * revises a phrase in place, so appending it would stutter the caption.\n */\n session.onresult = (event: SpeechRecognitionEventLike): void => {\n let settled = \"\";\n let pending = \"\";\n for (let index = event.resultIndex; index < event.results.length; index += 1) {\n const result = event.results[index];\n if (!result) continue;\n const text = result[0]?.transcript ?? \"\";\n if (result.isFinal) settled += text;\n else pending += text;\n }\n setInterim(pending);\n if (settled !== \"\") {\n setTranscript((previous) => previous + settled);\n emitFinal(settled);\n }\n emitResult({ transcript: settled !== \"\" ? settled : pending, isFinal: settled !== \"\" });\n };\n\n session.onerror = (event: SpeechRecognitionErrorEventLike): void => {\n const kind = classifySpeechError(event.error);\n emitError({ kind, message: event.message || MESSAGES[kind] });\n };\n\n session.onend = (): void => {\n setListening(false);\n setInterim(\"\");\n teardown();\n emitEnd();\n };\n\n session.onstart = (): void => setListening(true);\n\n setError(null);\n try {\n session.start();\n } catch (failure) {\n // Chromium throws `InvalidStateError` when a session is already running in\n // another component. Reporting it beats leaving a dead handle behind.\n emitError({\n kind: \"unknown\",\n message: failure instanceof Error ? failure.message : MESSAGES.unknown,\n });\n return;\n }\n sessionRef.current = session;\n setListening(true);\n }, [\n lang,\n continuous,\n interimResults,\n maxAlternatives,\n factory,\n emitResult,\n emitFinal,\n emitEnd,\n emitError,\n teardown,\n ]);\n\n const stop = useCallback((): void => {\n sessionRef.current?.stop();\n }, []);\n\n const abort = useCallback((): void => {\n sessionRef.current?.abort();\n }, []);\n\n const reset = useCallback((): void => {\n setTranscript(\"\");\n setInterim(\"\");\n setError(null);\n }, []);\n\n /**\n * Never leave a session open past unmount.\n *\n * `abort()` and not `stop()`: a component that is gone has nowhere to put a final\n * result, and `stop()` would keep the microphone — and the upstream connection —\n * alive while the engine finishes deciding what the last word was.\n */\n useEffect(\n () => () => {\n sessionRef.current?.abort();\n teardown();\n },\n [teardown],\n );\n\n return {\n supported,\n listening,\n transcript,\n interim,\n error,\n start,\n stop,\n abort,\n reset,\n };\n}\n"],"mappings":"2EA8HA,IAAM,EAA4C,CAC9C,YAAa,uDACb,cAAe,wEACf,YAAa,0BACb,gBAAiB,0CACjB,QAAS,gDACT,QAAS,6BACT,yBAA0B,0DAC1B,QAAS,6CACb,EAYA,SAAS,EAAoB,EAA+B,CACxD,OAAQ,EAAR,CACI,IAAK,cACL,IAAK,sBACD,MAAO,cACX,IAAK,YACD,MAAO,YACX,IAAK,gBACD,MAAO,gBACX,IAAK,UACD,MAAO,UACX,IAAK,UACD,MAAO,UACX,IAAK,yBACD,MAAO,yBACX,QACI,MAAO,SACf,CACJ,CAGA,SAAS,GAAyE,CAC9E,IAAM,EAAQ,WAIR,EAAY,EAAM,mBAAqB,EAAM,wBACnD,OAAO,OAAO,GAAc,WAAc,EAAgD,IAC9F,CAGA,SAAgB,GAAwC,CACpD,OAAO,EAA6B,IAAM,IAC9C,CAqCA,SAAgB,EACZ,EAAuC,CAAC,EACd,CAC1B,GAAM,CACF,OAAO,QACP,aAAa,GACb,iBAAiB,GACjB,kBAAkB,EAClB,WACA,UACA,UACA,QACA,WACA,EAEE,CAAC,IAAA,EAAA,EAAA,SAAA,KAA4B,IAAY,IAAA,IAAa,EAA6B,CAAC,EACpF,CAAC,EAAW,IAAA,EAAA,EAAA,SAAA,CAAyB,EAAK,EAC1C,CAAC,EAAY,IAAA,EAAA,EAAA,SAAA,CAA0B,EAAE,EACzC,CAAC,EAAS,IAAA,EAAA,EAAA,SAAA,CAAuB,EAAE,EACnC,CAAC,EAAO,IAAA,EAAA,EAAA,SAAA,CAAyC,IAAI,EAErD,GAAA,EAAA,EAAA,OAAA,CAAkD,IAAI,EAEtD,EAAa,EAAA,kBAAmB,GAClC,IAAW,CAAM,CACrB,EACM,EAAY,EAAA,kBAAmB,GAAiB,IAAU,CAAI,CAAC,EAC/D,EAAU,EAAA,sBAAwB,IAAQ,CAAC,EAC3C,EAAY,EAAA,kBAAmB,GAAyB,CAC1D,EAAS,CAAO,EAChB,IAAU,CAAO,CACrB,CAAC,EAEK,GAAA,EAAA,EAAA,YAAA,KAAmC,CACrC,IAAM,EAAU,EAAW,QACtB,IACL,EAAQ,SAAW,KACnB,EAAQ,QAAU,KAClB,EAAQ,MAAQ,KAChB,EAAQ,QAAU,KAClB,EAAW,QAAU,KACzB,EAAG,CAAC,CAAC,EAEC,GAAA,EAAA,EAAA,YAAA,KAAgC,CAClC,GAAI,EAAW,QAAS,OAOxB,IAAM,GALF,QACO,CACH,IAAM,EAAc,EAA6B,EACjD,OAAO,EAAc,IAAI,EAAgB,IAC7C,GAAA,CACkB,EACtB,GAAI,CAAC,EAAS,CACV,EAAU,CAAE,KAAM,cAAe,QAAS,EAAS,WAAY,CAAC,EAChE,MACJ,CAEA,EAAQ,KAAO,EACf,EAAQ,WAAa,EACrB,EAAQ,eAAiB,EACzB,EAAQ,gBAAkB,EAU1B,EAAQ,SAAY,GAA4C,CAC5D,IAAI,EAAU,GACV,EAAU,GACd,IAAK,IAAI,EAAQ,EAAM,YAAa,EAAQ,EAAM,QAAQ,OAAQ,GAAS,EAAG,CAC1E,IAAM,EAAS,EAAM,QAAQ,GAC7B,GAAI,CAAC,EAAQ,SACb,IAAM,EAAO,EAAO,EAAE,EAAE,YAAc,GAClC,EAAO,QAAS,GAAW,EAC1B,GAAW,CACpB,CACA,EAAW,CAAO,EACd,IAAY,KACZ,EAAe,GAAa,EAAW,CAAO,EAC9C,EAAU,CAAO,GAErB,EAAW,CAAE,WAAY,IAAY,GAAe,EAAV,EAAmB,QAAS,IAAY,EAAG,CAAC,CAC1F,EAEA,EAAQ,QAAW,GAAiD,CAChE,IAAM,EAAO,EAAoB,EAAM,KAAK,EAC5C,EAAU,CAAE,OAAM,QAAS,EAAM,SAAW,EAAS,EAAM,CAAC,CAChE,EAEA,EAAQ,UAAoB,CACxB,EAAa,EAAK,EAClB,EAAW,EAAE,EACb,EAAS,EACT,EAAQ,CACZ,EAEA,EAAQ,YAAsB,EAAa,EAAI,EAE/C,EAAS,IAAI,EACb,GAAI,CACA,EAAQ,MAAM,CAClB,OAAS,EAAS,CAGd,EAAU,CACN,KAAM,UACN,QAAS,aAAmB,MAAQ,EAAQ,QAAU,EAAS,OACnE,CAAC,EACD,MACJ,CACA,EAAW,QAAU,EACrB,EAAa,EAAI,CACrB,EAAG,CACC,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,CACJ,CAAC,EAEK,GAAA,EAAA,EAAA,YAAA,KAA+B,CACjC,EAAW,SAAS,KAAK,CAC7B,EAAG,CAAC,CAAC,EAEC,GAAA,EAAA,EAAA,YAAA,KAAgC,CAClC,EAAW,SAAS,MAAM,CAC9B,EAAG,CAAC,CAAC,EAEC,GAAA,EAAA,EAAA,YAAA,KAAgC,CAClC,EAAc,EAAE,EAChB,EAAW,EAAE,EACb,EAAS,IAAI,CACjB,EAAG,CAAC,CAAC,EAiBL,OARA,EAAA,EAAA,UAAA,SACgB,CACR,EAAW,SAAS,MAAM,EAC1B,EAAS,CACb,EACA,CAAC,CAAQ,CACb,EAEO,CACH,YACA,YACA,aACA,UACA,QACA,QACA,OACA,QACA,OACJ,CACJ"}
|
|
1
|
+
{"version":3,"file":"use-speech-recognition.cjs","names":[],"sources":["../../src/capture/use-speech-recognition.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, hook-lines — the Web Speech API restarts itself on\n * silence, reports interim and final results on the same event, and reports `no-\n * speech` as an error that is not one. The hook is that reconciliation plus the\n * continuous-mode restart loop, and its length is mostly the vendor quirks the\n * docstring warns about.\n */\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\n\n/** One reading of what was heard. */\nexport interface SpeechAlternativeLike {\n transcript: string;\n confidence?: number;\n}\n\n/** One recognised phrase, settled (`isFinal`) or still being revised. */\nexport interface SpeechResultLike {\n isFinal: boolean;\n length: number;\n [index: number]: SpeechAlternativeLike | undefined;\n}\n\n/** The growing list of phrases in a session. */\nexport interface SpeechResultListLike {\n length: number;\n [index: number]: SpeechResultLike | undefined;\n}\n\n/** The `result` event, reduced to what this hook reads. */\nexport interface SpeechRecognitionEventLike {\n /** Index of the first result that changed — everything before it is settled. */\n resultIndex: number;\n results: SpeechResultListLike;\n}\n\n/** The `error` event, reduced to what this hook reads. */\nexport interface SpeechRecognitionErrorEventLike {\n error: string;\n message?: string;\n}\n\n/**\n * The slice of the Web Speech `SpeechRecognition` object this SDK uses.\n *\n * Declared here rather than imported: TypeScript's DOM lib ships the *event* types but\n * not the constructor, because the API is still prefixed in Chromium and absent in\n * Firefox. Exported so a test — or a consumer wrapping a different engine — can hand in\n * something else.\n */\nexport interface SpeechRecognitionLike {\n lang: string;\n continuous: boolean;\n interimResults: boolean;\n maxAlternatives: number;\n start: () => void;\n stop: () => void;\n abort: () => void;\n onresult: ((event: SpeechRecognitionEventLike) => void) | null;\n onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;\n onend: (() => void) | null;\n onstart: (() => void) | null;\n}\n\n/** Classified reason recognition stopped or never started. */\nexport type SpeechErrorKind =\n | \"unsupported\"\n | \"not-allowed\"\n | \"no-speech\"\n | \"audio-capture\"\n | \"network\"\n | \"aborted\"\n | \"language-not-supported\"\n | \"unknown\";\n\n/** A classified speech error with a human-readable, English message. */\nexport interface SpeechError {\n kind: SpeechErrorKind;\n message: string;\n}\n\n/** Options for {@link useSpeechRecognition}. */\nexport interface UseSpeechRecognitionOptions {\n /** BCP-47 tag. Default `\"pt-BR\"`. */\n lang?: string;\n /**\n * Keep listening after the first phrase settles. Default `false`.\n *\n * Even with this on, the engine ends the session by itself after a stretch of\n * silence — that is a server-side timeout, not a bug — so watch `listening` rather\n * than assuming the microphone stays open.\n */\n continuous?: boolean;\n /** Publish the running guess as it changes. Default `true`. */\n interimResults?: boolean;\n /** How many readings per phrase to ask for. Default 1. */\n maxAlternatives?: number;\n /** Every update, settled or not. */\n onResult?: (result: { transcript: string; isFinal: boolean }) => void;\n /** Only the settled text of a phrase. The one to wire dictation to. */\n onFinal?: (transcript: string) => void;\n /** Classified failure. `no-speech` and `aborted` arrive here too — they are routine. */\n onError?: (error: SpeechError) => void;\n /** The session ended, for any reason. */\n onEnd?: () => void;\n /** Build the recogniser yourself — another engine, or a stub in a test. */\n factory?: () => SpeechRecognitionLike;\n}\n\n/** Value returned by {@link useSpeechRecognition}. */\nexport interface UseSpeechRecognitionResult {\n /** `false` in Firefox and in every browser that is not Chromium-based. */\n supported: boolean;\n /** Whether a session is open right now. */\n listening: boolean;\n /** Everything settled so far in this session. Cleared by `reset()`. */\n transcript: string;\n /** The running guess. Replaced on every event, empty once the phrase settles. */\n interim: string;\n /** Classified error, or `null`. */\n error: SpeechError | null;\n /** Open a session. No-op while already listening. */\n start: () => void;\n /** Close the session, keeping what was recognised. */\n stop: () => void;\n /** Close the session and throw the pending phrase away. */\n abort: () => void;\n /** Clear `transcript`, `interim` and `error`. Does not stop a session. */\n reset: () => void;\n}\n\n/** English messages for the `error` codes the spec defines. */\nconst MESSAGES: Record<SpeechErrorKind, string> = {\n unsupported: \"Speech recognition is not supported in this browser.\",\n \"not-allowed\": \"Microphone permission denied. Enable access in your browser settings.\",\n \"no-speech\": \"No speech was detected.\",\n \"audio-capture\": \"No microphone available on this device.\",\n network: \"The recognition service could not be reached.\",\n aborted: \"Recognition was cancelled.\",\n \"language-not-supported\": \"The recognition service does not support this language.\",\n unknown: \"Unexpected error during speech recognition.\",\n};\n\n/**\n * Map a spec `error` code to a kind an app can branch on.\n *\n * `service-not-allowed` collapses into `not-allowed` because the fix is the same from\n * the user's side, and `bad-grammar` into `unknown` because this hook never sets a\n * grammar, so seeing it means something outside our control went wrong.\n *\n * @param code - The `error` property of the event.\n * @returns The classified kind.\n */\nfunction classifySpeechError(code: string): SpeechErrorKind {\n switch (code) {\n case \"not-allowed\":\n case \"service-not-allowed\":\n return \"not-allowed\";\n case \"no-speech\":\n return \"no-speech\";\n case \"audio-capture\":\n return \"audio-capture\";\n case \"network\":\n return \"network\";\n case \"aborted\":\n return \"aborted\";\n case \"language-not-supported\":\n return \"language-not-supported\";\n default:\n return \"unknown\";\n }\n}\n\n/** The constructor, prefixed or not, or `null` where the API does not exist. */\nfunction speechRecognitionConstructor(): (new () => SpeechRecognitionLike) | null {\n const scope = globalThis as {\n SpeechRecognition?: unknown;\n webkitSpeechRecognition?: unknown;\n };\n const candidate = scope.SpeechRecognition ?? scope.webkitSpeechRecognition;\n return typeof candidate === \"function\" ? (candidate as new () => SpeechRecognitionLike) : null;\n}\n\n/** Whether this browser exposes the Web Speech recognition API at all. */\nexport function isSpeechRecognitionSupported(): boolean {\n return speechRecognitionConstructor() !== null;\n}\n\n/**\n * Dictate into your app with the Web Speech API — no dependency, no API key.\n *\n * ## Recognition is not local\n *\n * **Chromium streams the captured audio to a Google server to transcribe it.** Nothing\n * about the API says so, there is no setting that changes it, and it happens on every\n * `start()`. Anything the user says while a session is open leaves the device. Do not\n * put this on a field that takes clinical notes, credentials, or a client's financial\n * detail without telling them first — and if the data cannot leave your infrastructure,\n * this API is the wrong tool and a self-hosted model is the right one.\n *\n * ## What the states mean\n *\n * `transcript` accumulates the phrases the engine has **settled** on; `interim` is the\n * guess it is still revising and is replaced wholesale on every event, so rendering\n * `transcript + interim` gives the live caption effect and rendering `transcript` alone\n * gives the committed text. `no-speech` and `aborted` come through `onError` but are\n * routine — a user who pressed the button and said nothing is not a failure to report.\n *\n * There is deliberately **no auto-restart** when the engine ends a session on silence.\n * A restart loop is how an app ends up holding the microphone indefinitely — and, in\n * Chromium, streaming audio to a third party indefinitely. Show that listening stopped\n * and let the user press again.\n *\n * @param options - See {@link UseSpeechRecognitionOptions}.\n * @returns Session state, the transcript and the controls.\n *\n * @example\n * const speech = useSpeechRecognition({ onFinal: (text) => setPrompt(text) });\n * <button onClick={speech.listening ? speech.stop : speech.start}>\n * {speech.listening ? \"Parar\" : \"Ditar\"}\n * </button>\n * <p>{speech.transcript}{speech.interim}</p>\n */\nexport function useSpeechRecognition(\n options: UseSpeechRecognitionOptions = {},\n): UseSpeechRecognitionResult {\n const {\n lang = \"pt-BR\",\n continuous = false,\n interimResults = true,\n maxAlternatives = 1,\n onResult,\n onFinal,\n onError,\n onEnd,\n factory,\n } = options;\n\n const [supported] = useState(() => factory !== undefined || isSpeechRecognitionSupported());\n const [listening, setListening] = useState(false);\n const [transcript, setTranscript] = useState(\"\");\n const [interim, setInterim] = useState(\"\");\n const [error, setError] = useState<SpeechError | null>(null);\n\n const sessionRef = useRef<SpeechRecognitionLike | null>(null);\n\n const emitResult = useStableCallback((result: { transcript: string; isFinal: boolean }) =>\n onResult?.(result),\n );\n const emitFinal = useStableCallback((text: string) => onFinal?.(text));\n const emitEnd = useStableCallback(() => onEnd?.());\n const emitError = useStableCallback((failure: SpeechError) => {\n setError(failure);\n onError?.(failure);\n });\n\n const teardown = useCallback((): void => {\n const session = sessionRef.current;\n if (!session) return;\n session.onresult = null;\n session.onerror = null;\n session.onend = null;\n session.onstart = null;\n sessionRef.current = null;\n }, []);\n\n const start = useCallback((): void => {\n if (sessionRef.current) return;\n const build =\n factory ??\n (() => {\n const constructor = speechRecognitionConstructor();\n return constructor ? new constructor() : null;\n });\n const session = build();\n if (!session) {\n emitError({ kind: \"unsupported\", message: MESSAGES.unsupported });\n return;\n }\n\n session.lang = lang;\n session.continuous = continuous;\n session.interimResults = interimResults;\n session.maxAlternatives = maxAlternatives;\n\n /**\n * Fold one event into the two published strings.\n *\n * Only the results from `resultIndex` on are new; re-reading the whole list\n * would append phrases that are already in `transcript`. Settled text is\n * accumulated, unsettled text replaces the previous guess entirely — the engine\n * revises a phrase in place, so appending it would stutter the caption.\n */\n session.onresult = (event: SpeechRecognitionEventLike): void => {\n let settled = \"\";\n let pending = \"\";\n for (let index = event.resultIndex; index < event.results.length; index += 1) {\n const result = event.results[index];\n if (!result) continue;\n const text = result[0]?.transcript ?? \"\";\n if (result.isFinal) settled += text;\n else pending += text;\n }\n setInterim(pending);\n if (settled !== \"\") {\n setTranscript((previous) => previous + settled);\n emitFinal(settled);\n }\n emitResult({ transcript: settled !== \"\" ? settled : pending, isFinal: settled !== \"\" });\n };\n\n session.onerror = (event: SpeechRecognitionErrorEventLike): void => {\n const kind = classifySpeechError(event.error);\n emitError({ kind, message: event.message || MESSAGES[kind] });\n };\n\n session.onend = (): void => {\n setListening(false);\n setInterim(\"\");\n teardown();\n emitEnd();\n };\n\n session.onstart = (): void => setListening(true);\n\n setError(null);\n try {\n session.start();\n } catch (failure) {\n // Chromium throws `InvalidStateError` when a session is already running in\n // another component. Reporting it beats leaving a dead handle behind.\n emitError({\n kind: \"unknown\",\n message: failure instanceof Error ? failure.message : MESSAGES.unknown,\n });\n return;\n }\n sessionRef.current = session;\n setListening(true);\n }, [\n lang,\n continuous,\n interimResults,\n maxAlternatives,\n factory,\n emitResult,\n emitFinal,\n emitEnd,\n emitError,\n teardown,\n ]);\n\n const stop = useCallback((): void => {\n sessionRef.current?.stop();\n }, []);\n\n const abort = useCallback((): void => {\n sessionRef.current?.abort();\n }, []);\n\n const reset = useCallback((): void => {\n setTranscript(\"\");\n setInterim(\"\");\n setError(null);\n }, []);\n\n /**\n * Never leave a session open past unmount.\n *\n * `abort()` and not `stop()`: a component that is gone has nowhere to put a final\n * result, and `stop()` would keep the microphone — and the upstream connection —\n * alive while the engine finishes deciding what the last word was.\n */\n useEffect(\n () => () => {\n sessionRef.current?.abort();\n teardown();\n },\n [teardown],\n );\n\n return {\n supported,\n listening,\n transcript,\n interim,\n error,\n start,\n stop,\n abort,\n reset,\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-speech-recognition.js","names":[],"sources":["../../src/capture/use-speech-recognition.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\n\n/** One reading of what was heard. */\nexport interface SpeechAlternativeLike {\n transcript: string;\n confidence?: number;\n}\n\n/** One recognised phrase, settled (`isFinal`) or still being revised. */\nexport interface SpeechResultLike {\n isFinal: boolean;\n length: number;\n [index: number]: SpeechAlternativeLike | undefined;\n}\n\n/** The growing list of phrases in a session. */\nexport interface SpeechResultListLike {\n length: number;\n [index: number]: SpeechResultLike | undefined;\n}\n\n/** The `result` event, reduced to what this hook reads. */\nexport interface SpeechRecognitionEventLike {\n /** Index of the first result that changed — everything before it is settled. */\n resultIndex: number;\n results: SpeechResultListLike;\n}\n\n/** The `error` event, reduced to what this hook reads. */\nexport interface SpeechRecognitionErrorEventLike {\n error: string;\n message?: string;\n}\n\n/**\n * The slice of the Web Speech `SpeechRecognition` object this SDK uses.\n *\n * Declared here rather than imported: TypeScript's DOM lib ships the *event* types but\n * not the constructor, because the API is still prefixed in Chromium and absent in\n * Firefox. Exported so a test — or a consumer wrapping a different engine — can hand in\n * something else.\n */\nexport interface SpeechRecognitionLike {\n lang: string;\n continuous: boolean;\n interimResults: boolean;\n maxAlternatives: number;\n start: () => void;\n stop: () => void;\n abort: () => void;\n onresult: ((event: SpeechRecognitionEventLike) => void) | null;\n onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;\n onend: (() => void) | null;\n onstart: (() => void) | null;\n}\n\n/** Classified reason recognition stopped or never started. */\nexport type SpeechErrorKind =\n | \"unsupported\"\n | \"not-allowed\"\n | \"no-speech\"\n | \"audio-capture\"\n | \"network\"\n | \"aborted\"\n | \"language-not-supported\"\n | \"unknown\";\n\n/** A classified speech error with a human-readable, English message. */\nexport interface SpeechError {\n kind: SpeechErrorKind;\n message: string;\n}\n\n/** Options for {@link useSpeechRecognition}. */\nexport interface UseSpeechRecognitionOptions {\n /** BCP-47 tag. Default `\"pt-BR\"`. */\n lang?: string;\n /**\n * Keep listening after the first phrase settles. Default `false`.\n *\n * Even with this on, the engine ends the session by itself after a stretch of\n * silence — that is a server-side timeout, not a bug — so watch `listening` rather\n * than assuming the microphone stays open.\n */\n continuous?: boolean;\n /** Publish the running guess as it changes. Default `true`. */\n interimResults?: boolean;\n /** How many readings per phrase to ask for. Default 1. */\n maxAlternatives?: number;\n /** Every update, settled or not. */\n onResult?: (result: { transcript: string; isFinal: boolean }) => void;\n /** Only the settled text of a phrase. The one to wire dictation to. */\n onFinal?: (transcript: string) => void;\n /** Classified failure. `no-speech` and `aborted` arrive here too — they are routine. */\n onError?: (error: SpeechError) => void;\n /** The session ended, for any reason. */\n onEnd?: () => void;\n /** Build the recogniser yourself — another engine, or a stub in a test. */\n factory?: () => SpeechRecognitionLike;\n}\n\n/** Value returned by {@link useSpeechRecognition}. */\nexport interface UseSpeechRecognitionResult {\n /** `false` in Firefox and in every browser that is not Chromium-based. */\n supported: boolean;\n /** Whether a session is open right now. */\n listening: boolean;\n /** Everything settled so far in this session. Cleared by `reset()`. */\n transcript: string;\n /** The running guess. Replaced on every event, empty once the phrase settles. */\n interim: string;\n /** Classified error, or `null`. */\n error: SpeechError | null;\n /** Open a session. No-op while already listening. */\n start: () => void;\n /** Close the session, keeping what was recognised. */\n stop: () => void;\n /** Close the session and throw the pending phrase away. */\n abort: () => void;\n /** Clear `transcript`, `interim` and `error`. Does not stop a session. */\n reset: () => void;\n}\n\n/** English messages for the `error` codes the spec defines. */\nconst MESSAGES: Record<SpeechErrorKind, string> = {\n unsupported: \"Speech recognition is not supported in this browser.\",\n \"not-allowed\": \"Microphone permission denied. Enable access in your browser settings.\",\n \"no-speech\": \"No speech was detected.\",\n \"audio-capture\": \"No microphone available on this device.\",\n network: \"The recognition service could not be reached.\",\n aborted: \"Recognition was cancelled.\",\n \"language-not-supported\": \"The recognition service does not support this language.\",\n unknown: \"Unexpected error during speech recognition.\",\n};\n\n/**\n * Map a spec `error` code to a kind an app can branch on.\n *\n * `service-not-allowed` collapses into `not-allowed` because the fix is the same from\n * the user's side, and `bad-grammar` into `unknown` because this hook never sets a\n * grammar, so seeing it means something outside our control went wrong.\n *\n * @param code - The `error` property of the event.\n * @returns The classified kind.\n */\nfunction classifySpeechError(code: string): SpeechErrorKind {\n switch (code) {\n case \"not-allowed\":\n case \"service-not-allowed\":\n return \"not-allowed\";\n case \"no-speech\":\n return \"no-speech\";\n case \"audio-capture\":\n return \"audio-capture\";\n case \"network\":\n return \"network\";\n case \"aborted\":\n return \"aborted\";\n case \"language-not-supported\":\n return \"language-not-supported\";\n default:\n return \"unknown\";\n }\n}\n\n/** The constructor, prefixed or not, or `null` where the API does not exist. */\nfunction speechRecognitionConstructor(): (new () => SpeechRecognitionLike) | null {\n const scope = globalThis as {\n SpeechRecognition?: unknown;\n webkitSpeechRecognition?: unknown;\n };\n const candidate = scope.SpeechRecognition ?? scope.webkitSpeechRecognition;\n return typeof candidate === \"function\" ? (candidate as new () => SpeechRecognitionLike) : null;\n}\n\n/** Whether this browser exposes the Web Speech recognition API at all. */\nexport function isSpeechRecognitionSupported(): boolean {\n return speechRecognitionConstructor() !== null;\n}\n\n/**\n * Dictate into your app with the Web Speech API — no dependency, no API key.\n *\n * ## Recognition is not local\n *\n * **Chromium streams the captured audio to a Google server to transcribe it.** Nothing\n * about the API says so, there is no setting that changes it, and it happens on every\n * `start()`. Anything the user says while a session is open leaves the device. Do not\n * put this on a field that takes clinical notes, credentials, or a client's financial\n * detail without telling them first — and if the data cannot leave your infrastructure,\n * this API is the wrong tool and a self-hosted model is the right one.\n *\n * ## What the states mean\n *\n * `transcript` accumulates the phrases the engine has **settled** on; `interim` is the\n * guess it is still revising and is replaced wholesale on every event, so rendering\n * `transcript + interim` gives the live caption effect and rendering `transcript` alone\n * gives the committed text. `no-speech` and `aborted` come through `onError` but are\n * routine — a user who pressed the button and said nothing is not a failure to report.\n *\n * There is deliberately **no auto-restart** when the engine ends a session on silence.\n * A restart loop is how an app ends up holding the microphone indefinitely — and, in\n * Chromium, streaming audio to a third party indefinitely. Show that listening stopped\n * and let the user press again.\n *\n * @param options - See {@link UseSpeechRecognitionOptions}.\n * @returns Session state, the transcript and the controls.\n *\n * @example\n * const speech = useSpeechRecognition({ onFinal: (text) => setPrompt(text) });\n * <button onClick={speech.listening ? speech.stop : speech.start}>\n * {speech.listening ? \"Parar\" : \"Ditar\"}\n * </button>\n * <p>{speech.transcript}{speech.interim}</p>\n */\nexport function useSpeechRecognition(\n options: UseSpeechRecognitionOptions = {},\n): UseSpeechRecognitionResult {\n const {\n lang = \"pt-BR\",\n continuous = false,\n interimResults = true,\n maxAlternatives = 1,\n onResult,\n onFinal,\n onError,\n onEnd,\n factory,\n } = options;\n\n const [supported] = useState(() => factory !== undefined || isSpeechRecognitionSupported());\n const [listening, setListening] = useState(false);\n const [transcript, setTranscript] = useState(\"\");\n const [interim, setInterim] = useState(\"\");\n const [error, setError] = useState<SpeechError | null>(null);\n\n const sessionRef = useRef<SpeechRecognitionLike | null>(null);\n\n const emitResult = useStableCallback((result: { transcript: string; isFinal: boolean }) =>\n onResult?.(result),\n );\n const emitFinal = useStableCallback((text: string) => onFinal?.(text));\n const emitEnd = useStableCallback(() => onEnd?.());\n const emitError = useStableCallback((failure: SpeechError) => {\n setError(failure);\n onError?.(failure);\n });\n\n const teardown = useCallback((): void => {\n const session = sessionRef.current;\n if (!session) return;\n session.onresult = null;\n session.onerror = null;\n session.onend = null;\n session.onstart = null;\n sessionRef.current = null;\n }, []);\n\n const start = useCallback((): void => {\n if (sessionRef.current) return;\n const build =\n factory ??\n (() => {\n const constructor = speechRecognitionConstructor();\n return constructor ? new constructor() : null;\n });\n const session = build();\n if (!session) {\n emitError({ kind: \"unsupported\", message: MESSAGES.unsupported });\n return;\n }\n\n session.lang = lang;\n session.continuous = continuous;\n session.interimResults = interimResults;\n session.maxAlternatives = maxAlternatives;\n\n /**\n * Fold one event into the two published strings.\n *\n * Only the results from `resultIndex` on are new; re-reading the whole list\n * would append phrases that are already in `transcript`. Settled text is\n * accumulated, unsettled text replaces the previous guess entirely — the engine\n * revises a phrase in place, so appending it would stutter the caption.\n */\n session.onresult = (event: SpeechRecognitionEventLike): void => {\n let settled = \"\";\n let pending = \"\";\n for (let index = event.resultIndex; index < event.results.length; index += 1) {\n const result = event.results[index];\n if (!result) continue;\n const text = result[0]?.transcript ?? \"\";\n if (result.isFinal) settled += text;\n else pending += text;\n }\n setInterim(pending);\n if (settled !== \"\") {\n setTranscript((previous) => previous + settled);\n emitFinal(settled);\n }\n emitResult({ transcript: settled !== \"\" ? settled : pending, isFinal: settled !== \"\" });\n };\n\n session.onerror = (event: SpeechRecognitionErrorEventLike): void => {\n const kind = classifySpeechError(event.error);\n emitError({ kind, message: event.message || MESSAGES[kind] });\n };\n\n session.onend = (): void => {\n setListening(false);\n setInterim(\"\");\n teardown();\n emitEnd();\n };\n\n session.onstart = (): void => setListening(true);\n\n setError(null);\n try {\n session.start();\n } catch (failure) {\n // Chromium throws `InvalidStateError` when a session is already running in\n // another component. Reporting it beats leaving a dead handle behind.\n emitError({\n kind: \"unknown\",\n message: failure instanceof Error ? failure.message : MESSAGES.unknown,\n });\n return;\n }\n sessionRef.current = session;\n setListening(true);\n }, [\n lang,\n continuous,\n interimResults,\n maxAlternatives,\n factory,\n emitResult,\n emitFinal,\n emitEnd,\n emitError,\n teardown,\n ]);\n\n const stop = useCallback((): void => {\n sessionRef.current?.stop();\n }, []);\n\n const abort = useCallback((): void => {\n sessionRef.current?.abort();\n }, []);\n\n const reset = useCallback((): void => {\n setTranscript(\"\");\n setInterim(\"\");\n setError(null);\n }, []);\n\n /**\n * Never leave a session open past unmount.\n *\n * `abort()` and not `stop()`: a component that is gone has nowhere to put a final\n * result, and `stop()` would keep the microphone — and the upstream connection —\n * alive while the engine finishes deciding what the last word was.\n */\n useEffect(\n () => () => {\n sessionRef.current?.abort();\n teardown();\n },\n [teardown],\n );\n\n return {\n supported,\n listening,\n transcript,\n interim,\n error,\n start,\n stop,\n abort,\n reset,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-speech-recognition.js","names":[],"sources":["../../src/capture/use-speech-recognition.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, hook-lines — the Web Speech API restarts itself on\n * silence, reports interim and final results on the same event, and reports `no-\n * speech` as an error that is not one. The hook is that reconciliation plus the\n * continuous-mode restart loop, and its length is mostly the vendor quirks the\n * docstring warns about.\n */\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\n\n/** One reading of what was heard. */\nexport interface SpeechAlternativeLike {\n transcript: string;\n confidence?: number;\n}\n\n/** One recognised phrase, settled (`isFinal`) or still being revised. */\nexport interface SpeechResultLike {\n isFinal: boolean;\n length: number;\n [index: number]: SpeechAlternativeLike | undefined;\n}\n\n/** The growing list of phrases in a session. */\nexport interface SpeechResultListLike {\n length: number;\n [index: number]: SpeechResultLike | undefined;\n}\n\n/** The `result` event, reduced to what this hook reads. */\nexport interface SpeechRecognitionEventLike {\n /** Index of the first result that changed — everything before it is settled. */\n resultIndex: number;\n results: SpeechResultListLike;\n}\n\n/** The `error` event, reduced to what this hook reads. */\nexport interface SpeechRecognitionErrorEventLike {\n error: string;\n message?: string;\n}\n\n/**\n * The slice of the Web Speech `SpeechRecognition` object this SDK uses.\n *\n * Declared here rather than imported: TypeScript's DOM lib ships the *event* types but\n * not the constructor, because the API is still prefixed in Chromium and absent in\n * Firefox. Exported so a test — or a consumer wrapping a different engine — can hand in\n * something else.\n */\nexport interface SpeechRecognitionLike {\n lang: string;\n continuous: boolean;\n interimResults: boolean;\n maxAlternatives: number;\n start: () => void;\n stop: () => void;\n abort: () => void;\n onresult: ((event: SpeechRecognitionEventLike) => void) | null;\n onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;\n onend: (() => void) | null;\n onstart: (() => void) | null;\n}\n\n/** Classified reason recognition stopped or never started. */\nexport type SpeechErrorKind =\n | \"unsupported\"\n | \"not-allowed\"\n | \"no-speech\"\n | \"audio-capture\"\n | \"network\"\n | \"aborted\"\n | \"language-not-supported\"\n | \"unknown\";\n\n/** A classified speech error with a human-readable, English message. */\nexport interface SpeechError {\n kind: SpeechErrorKind;\n message: string;\n}\n\n/** Options for {@link useSpeechRecognition}. */\nexport interface UseSpeechRecognitionOptions {\n /** BCP-47 tag. Default `\"pt-BR\"`. */\n lang?: string;\n /**\n * Keep listening after the first phrase settles. Default `false`.\n *\n * Even with this on, the engine ends the session by itself after a stretch of\n * silence — that is a server-side timeout, not a bug — so watch `listening` rather\n * than assuming the microphone stays open.\n */\n continuous?: boolean;\n /** Publish the running guess as it changes. Default `true`. */\n interimResults?: boolean;\n /** How many readings per phrase to ask for. Default 1. */\n maxAlternatives?: number;\n /** Every update, settled or not. */\n onResult?: (result: { transcript: string; isFinal: boolean }) => void;\n /** Only the settled text of a phrase. The one to wire dictation to. */\n onFinal?: (transcript: string) => void;\n /** Classified failure. `no-speech` and `aborted` arrive here too — they are routine. */\n onError?: (error: SpeechError) => void;\n /** The session ended, for any reason. */\n onEnd?: () => void;\n /** Build the recogniser yourself — another engine, or a stub in a test. */\n factory?: () => SpeechRecognitionLike;\n}\n\n/** Value returned by {@link useSpeechRecognition}. */\nexport interface UseSpeechRecognitionResult {\n /** `false` in Firefox and in every browser that is not Chromium-based. */\n supported: boolean;\n /** Whether a session is open right now. */\n listening: boolean;\n /** Everything settled so far in this session. Cleared by `reset()`. */\n transcript: string;\n /** The running guess. Replaced on every event, empty once the phrase settles. */\n interim: string;\n /** Classified error, or `null`. */\n error: SpeechError | null;\n /** Open a session. No-op while already listening. */\n start: () => void;\n /** Close the session, keeping what was recognised. */\n stop: () => void;\n /** Close the session and throw the pending phrase away. */\n abort: () => void;\n /** Clear `transcript`, `interim` and `error`. Does not stop a session. */\n reset: () => void;\n}\n\n/** English messages for the `error` codes the spec defines. */\nconst MESSAGES: Record<SpeechErrorKind, string> = {\n unsupported: \"Speech recognition is not supported in this browser.\",\n \"not-allowed\": \"Microphone permission denied. Enable access in your browser settings.\",\n \"no-speech\": \"No speech was detected.\",\n \"audio-capture\": \"No microphone available on this device.\",\n network: \"The recognition service could not be reached.\",\n aborted: \"Recognition was cancelled.\",\n \"language-not-supported\": \"The recognition service does not support this language.\",\n unknown: \"Unexpected error during speech recognition.\",\n};\n\n/**\n * Map a spec `error` code to a kind an app can branch on.\n *\n * `service-not-allowed` collapses into `not-allowed` because the fix is the same from\n * the user's side, and `bad-grammar` into `unknown` because this hook never sets a\n * grammar, so seeing it means something outside our control went wrong.\n *\n * @param code - The `error` property of the event.\n * @returns The classified kind.\n */\nfunction classifySpeechError(code: string): SpeechErrorKind {\n switch (code) {\n case \"not-allowed\":\n case \"service-not-allowed\":\n return \"not-allowed\";\n case \"no-speech\":\n return \"no-speech\";\n case \"audio-capture\":\n return \"audio-capture\";\n case \"network\":\n return \"network\";\n case \"aborted\":\n return \"aborted\";\n case \"language-not-supported\":\n return \"language-not-supported\";\n default:\n return \"unknown\";\n }\n}\n\n/** The constructor, prefixed or not, or `null` where the API does not exist. */\nfunction speechRecognitionConstructor(): (new () => SpeechRecognitionLike) | null {\n const scope = globalThis as {\n SpeechRecognition?: unknown;\n webkitSpeechRecognition?: unknown;\n };\n const candidate = scope.SpeechRecognition ?? scope.webkitSpeechRecognition;\n return typeof candidate === \"function\" ? (candidate as new () => SpeechRecognitionLike) : null;\n}\n\n/** Whether this browser exposes the Web Speech recognition API at all. */\nexport function isSpeechRecognitionSupported(): boolean {\n return speechRecognitionConstructor() !== null;\n}\n\n/**\n * Dictate into your app with the Web Speech API — no dependency, no API key.\n *\n * ## Recognition is not local\n *\n * **Chromium streams the captured audio to a Google server to transcribe it.** Nothing\n * about the API says so, there is no setting that changes it, and it happens on every\n * `start()`. Anything the user says while a session is open leaves the device. Do not\n * put this on a field that takes clinical notes, credentials, or a client's financial\n * detail without telling them first — and if the data cannot leave your infrastructure,\n * this API is the wrong tool and a self-hosted model is the right one.\n *\n * ## What the states mean\n *\n * `transcript` accumulates the phrases the engine has **settled** on; `interim` is the\n * guess it is still revising and is replaced wholesale on every event, so rendering\n * `transcript + interim` gives the live caption effect and rendering `transcript` alone\n * gives the committed text. `no-speech` and `aborted` come through `onError` but are\n * routine — a user who pressed the button and said nothing is not a failure to report.\n *\n * There is deliberately **no auto-restart** when the engine ends a session on silence.\n * A restart loop is how an app ends up holding the microphone indefinitely — and, in\n * Chromium, streaming audio to a third party indefinitely. Show that listening stopped\n * and let the user press again.\n *\n * @param options - See {@link UseSpeechRecognitionOptions}.\n * @returns Session state, the transcript and the controls.\n *\n * @example\n * const speech = useSpeechRecognition({ onFinal: (text) => setPrompt(text) });\n * <button onClick={speech.listening ? speech.stop : speech.start}>\n * {speech.listening ? \"Parar\" : \"Ditar\"}\n * </button>\n * <p>{speech.transcript}{speech.interim}</p>\n */\nexport function useSpeechRecognition(\n options: UseSpeechRecognitionOptions = {},\n): UseSpeechRecognitionResult {\n const {\n lang = \"pt-BR\",\n continuous = false,\n interimResults = true,\n maxAlternatives = 1,\n onResult,\n onFinal,\n onError,\n onEnd,\n factory,\n } = options;\n\n const [supported] = useState(() => factory !== undefined || isSpeechRecognitionSupported());\n const [listening, setListening] = useState(false);\n const [transcript, setTranscript] = useState(\"\");\n const [interim, setInterim] = useState(\"\");\n const [error, setError] = useState<SpeechError | null>(null);\n\n const sessionRef = useRef<SpeechRecognitionLike | null>(null);\n\n const emitResult = useStableCallback((result: { transcript: string; isFinal: boolean }) =>\n onResult?.(result),\n );\n const emitFinal = useStableCallback((text: string) => onFinal?.(text));\n const emitEnd = useStableCallback(() => onEnd?.());\n const emitError = useStableCallback((failure: SpeechError) => {\n setError(failure);\n onError?.(failure);\n });\n\n const teardown = useCallback((): void => {\n const session = sessionRef.current;\n if (!session) return;\n session.onresult = null;\n session.onerror = null;\n session.onend = null;\n session.onstart = null;\n sessionRef.current = null;\n }, []);\n\n const start = useCallback((): void => {\n if (sessionRef.current) return;\n const build =\n factory ??\n (() => {\n const constructor = speechRecognitionConstructor();\n return constructor ? new constructor() : null;\n });\n const session = build();\n if (!session) {\n emitError({ kind: \"unsupported\", message: MESSAGES.unsupported });\n return;\n }\n\n session.lang = lang;\n session.continuous = continuous;\n session.interimResults = interimResults;\n session.maxAlternatives = maxAlternatives;\n\n /**\n * Fold one event into the two published strings.\n *\n * Only the results from `resultIndex` on are new; re-reading the whole list\n * would append phrases that are already in `transcript`. Settled text is\n * accumulated, unsettled text replaces the previous guess entirely — the engine\n * revises a phrase in place, so appending it would stutter the caption.\n */\n session.onresult = (event: SpeechRecognitionEventLike): void => {\n let settled = \"\";\n let pending = \"\";\n for (let index = event.resultIndex; index < event.results.length; index += 1) {\n const result = event.results[index];\n if (!result) continue;\n const text = result[0]?.transcript ?? \"\";\n if (result.isFinal) settled += text;\n else pending += text;\n }\n setInterim(pending);\n if (settled !== \"\") {\n setTranscript((previous) => previous + settled);\n emitFinal(settled);\n }\n emitResult({ transcript: settled !== \"\" ? settled : pending, isFinal: settled !== \"\" });\n };\n\n session.onerror = (event: SpeechRecognitionErrorEventLike): void => {\n const kind = classifySpeechError(event.error);\n emitError({ kind, message: event.message || MESSAGES[kind] });\n };\n\n session.onend = (): void => {\n setListening(false);\n setInterim(\"\");\n teardown();\n emitEnd();\n };\n\n session.onstart = (): void => setListening(true);\n\n setError(null);\n try {\n session.start();\n } catch (failure) {\n // Chromium throws `InvalidStateError` when a session is already running in\n // another component. Reporting it beats leaving a dead handle behind.\n emitError({\n kind: \"unknown\",\n message: failure instanceof Error ? failure.message : MESSAGES.unknown,\n });\n return;\n }\n sessionRef.current = session;\n setListening(true);\n }, [\n lang,\n continuous,\n interimResults,\n maxAlternatives,\n factory,\n emitResult,\n emitFinal,\n emitEnd,\n emitError,\n teardown,\n ]);\n\n const stop = useCallback((): void => {\n sessionRef.current?.stop();\n }, []);\n\n const abort = useCallback((): void => {\n sessionRef.current?.abort();\n }, []);\n\n const reset = useCallback((): void => {\n setTranscript(\"\");\n setInterim(\"\");\n setError(null);\n }, []);\n\n /**\n * Never leave a session open past unmount.\n *\n * `abort()` and not `stop()`: a component that is gone has nowhere to put a final\n * result, and `stop()` would keep the microphone — and the upstream connection —\n * alive while the engine finishes deciding what the last word was.\n */\n useEffect(\n () => () => {\n sessionRef.current?.abort();\n teardown();\n },\n [teardown],\n );\n\n return {\n supported,\n listening,\n transcript,\n interim,\n error,\n start,\n stop,\n abort,\n reset,\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-video-recorder.cjs","names":[],"sources":["../../src/capture/use-video-recorder.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\n\nimport type { MediaRecorderHandle, MediaRecorderStatus } from \"./media-recorder\";\nimport {\n createVideoRecorder,\n type VideoRecorderOptions,\n type VideoRecording,\n} from \"./video-recorder\";\n\n/** Options for {@link useVideoRecorder}. */\nexport interface UseVideoRecorderOptions extends VideoRecorderOptions {\n /**\n * Stop automatically after this many milliseconds.\n *\n * Worth setting on anything user-facing, and more so than for audio: a minute of\n * 1080p at 2.5 Mbps is roughly 19 MB, so a recording left running by accident\n * fills memory an order of magnitude faster than a voice note does.\n */\n maxDurationMs?: number;\n /** Called once the recording is assembled, whether by `stop()` or `maxDurationMs`. */\n onRecorded?: (recording: VideoRecording) => void;\n /** How often the clock is published, in ms. Default 250. */\n tickMs?: number;\n}\n\n/** Value returned by {@link useVideoRecorder}. */\nexport interface UseVideoRecorderResult {\n status: MediaRecorderStatus;\n /** Recorded length so far, excluding paused time. */\n durationMs: number;\n /** The finished recording, or `null` before the first `stop()`. */\n recording: VideoRecording | null;\n /** Recorder-level error (encoder failure, share revoked mid-recording). */\n error: unknown;\n /** `false` when there is no stream yet, or the browser cannot record video. */\n ready: boolean;\n start: () => void;\n pause: () => void;\n resume: () => void;\n /** Stop and resolve with the recording. Also published on `recording`. */\n stop: () => Promise<VideoRecording | null>;\n /** Stop and throw the video away. */\n cancel: () => void;\n}\n\n/**\n * Record a video stream — from {@link useScreenCapture}, from a camera, or from a\n * canvas.\n *\n * Pass the stream in; the hook stays `ready: false` until there is one, so a page can\n * render the whole recorder UI before the user has picked a screen and simply have it\n * disabled.\n *\n * There is no level meter here, which is the one real difference from\n * `useAudioRecorder`: metering a screen share means opening an `AudioContext` on a\n * stream that usually has no audio track at all, and browsers cap the number of live\n * contexts. If you are recording a camera **and** want a level, run\n * `createLevelMeter` on the same stream yourself.\n *\n * The clock is published every `tickMs` (default 250 ms) rather than per frame: a\n * video recording UI shows `0:07`, and a hook that re-rendered its parent 60 times a\n * second to move a one-second counter would be the most expensive thing on the page.\n *\n * @param stream - The stream to record, or `null` while the picker is open.\n * @param options - See {@link UseVideoRecorderOptions}.\n *\n * @example\n * const screen = useScreenCapture();\n * const rec = useVideoRecorder(screen.stream, { maxDurationMs: 120_000 });\n * <button disabled={!rec.ready} onClick={rec.start}>Gravar tela</button>\n */\nexport function useVideoRecorder(\n stream: MediaStream | null,\n options: UseVideoRecorderOptions = {},\n): UseVideoRecorderResult {\n const {\n maxDurationMs,\n onRecorded,\n onChunk,\n onError,\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n tickMs = 250,\n } = options;\n\n const [status, setStatus] = useState<MediaRecorderStatus>(\"idle\");\n const [durationMs, setDurationMs] = useState(0);\n const [recording, setRecording] = useState<VideoRecording | null>(null);\n const [error, setError] = useState<unknown>(null);\n const [ready, setReady] = useState(false);\n\n const recorderRef = useRef<MediaRecorderHandle | null>(null);\n\n const emitRecorded = useStableCallback((result: VideoRecording) => onRecorded?.(result));\n const emitChunk = useStableCallback((chunk: Blob) => onChunk?.(chunk));\n const emitError = useStableCallback((err: unknown) => {\n setError(err);\n onError?.(err);\n });\n\n /**\n * Build the recorder for a stream, and tear it down when the stream changes.\n *\n * Rebuilt per stream because `MediaRecorder` is bound to the stream it was\n * constructed with: a user who stops sharing and picks a different window hands us\n * a new `MediaStream`, and reusing the old recorder would keep recording a surface\n * that no longer exists. Same reason for a container or bitrate change — both are\n * constructor arguments.\n */\n useEffect(() => {\n if (!stream) {\n setReady(false);\n return;\n }\n let recorder: MediaRecorderHandle;\n try {\n recorder = createVideoRecorder(stream, {\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n onChunk: emitChunk,\n onError: emitError,\n });\n } catch (err) {\n setError(err);\n setReady(false);\n return;\n }\n recorderRef.current = recorder;\n setReady(true);\n setStatus(\"idle\");\n\n return () => {\n if (recorder.status() === \"recording\" || recorder.status() === \"paused\") {\n recorder.cancel();\n }\n recorderRef.current = null;\n setReady(false);\n };\n }, [\n stream,\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n emitChunk,\n emitError,\n ]);\n\n const stop = useCallback(async (): Promise<VideoRecording | null> => {\n const recorder = recorderRef.current;\n if (!recorder) return null;\n const current = recorder.status();\n if (current !== \"recording\" && current !== \"paused\") return null;\n const result = await recorder.stop();\n setStatus(\"stopped\");\n setDurationMs(result.durationMs);\n setRecording(result);\n emitRecorded(result);\n return result;\n }, [emitRecorded]);\n\n /**\n * Publish the clock while something is running, and enforce `maxDurationMs`.\n *\n * The cap is checked on the same tick rather than with a `setTimeout` armed at\n * `start()`, because a paused recording must not keep counting toward it — a\n * timeout would fire while the user is paused on a slide.\n */\n useEffect(() => {\n if (status !== \"recording\" && status !== \"paused\") return;\n const id = setInterval(() => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n const elapsed = recorder.durationMs();\n setDurationMs(elapsed);\n if (maxDurationMs !== undefined && elapsed >= maxDurationMs) void stop();\n }, tickMs);\n return () => clearInterval(id);\n }, [status, tickMs, maxDurationMs, stop]);\n\n const start = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n setRecording(null);\n setError(null);\n setDurationMs(0);\n recorder.start();\n setStatus(recorder.status());\n }, []);\n\n const pause = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.pause();\n setDurationMs(recorder.durationMs());\n setStatus(recorder.status());\n }, []);\n\n const resume = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.resume();\n setStatus(recorder.status());\n }, []);\n\n const cancel = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.cancel();\n setStatus(\"idle\");\n setDurationMs(0);\n setRecording(null);\n }, []);\n\n return { status, durationMs, recording, error, ready, start, pause, resume, stop, cancel };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-video-recorder.cjs","names":[],"sources":["../../src/capture/use-video-recorder.ts"],"sourcesContent":["/**\n * @tempest-limits hook-lines — the same lifecycle as the audio recorder —\n * permission, recorder, clock, auto-stop — over a stream that also carries video,\n * with the preview element to attach and release.\n */\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\n\nimport type { MediaRecorderHandle, MediaRecorderStatus } from \"./media-recorder\";\nimport {\n createVideoRecorder,\n type VideoRecorderOptions,\n type VideoRecording,\n} from \"./video-recorder\";\n\n/** Options for {@link useVideoRecorder}. */\nexport interface UseVideoRecorderOptions extends VideoRecorderOptions {\n /**\n * Stop automatically after this many milliseconds.\n *\n * Worth setting on anything user-facing, and more so than for audio: a minute of\n * 1080p at 2.5 Mbps is roughly 19 MB, so a recording left running by accident\n * fills memory an order of magnitude faster than a voice note does.\n */\n maxDurationMs?: number;\n /** Called once the recording is assembled, whether by `stop()` or `maxDurationMs`. */\n onRecorded?: (recording: VideoRecording) => void;\n /** How often the clock is published, in ms. Default 250. */\n tickMs?: number;\n}\n\n/** Value returned by {@link useVideoRecorder}. */\nexport interface UseVideoRecorderResult {\n status: MediaRecorderStatus;\n /** Recorded length so far, excluding paused time. */\n durationMs: number;\n /** The finished recording, or `null` before the first `stop()`. */\n recording: VideoRecording | null;\n /** Recorder-level error (encoder failure, share revoked mid-recording). */\n error: unknown;\n /** `false` when there is no stream yet, or the browser cannot record video. */\n ready: boolean;\n start: () => void;\n pause: () => void;\n resume: () => void;\n /** Stop and resolve with the recording. Also published on `recording`. */\n stop: () => Promise<VideoRecording | null>;\n /** Stop and throw the video away. */\n cancel: () => void;\n}\n\n/**\n * Record a video stream — from {@link useScreenCapture}, from a camera, or from a\n * canvas.\n *\n * Pass the stream in; the hook stays `ready: false` until there is one, so a page can\n * render the whole recorder UI before the user has picked a screen and simply have it\n * disabled.\n *\n * There is no level meter here, which is the one real difference from\n * `useAudioRecorder`: metering a screen share means opening an `AudioContext` on a\n * stream that usually has no audio track at all, and browsers cap the number of live\n * contexts. If you are recording a camera **and** want a level, run\n * `createLevelMeter` on the same stream yourself.\n *\n * The clock is published every `tickMs` (default 250 ms) rather than per frame: a\n * video recording UI shows `0:07`, and a hook that re-rendered its parent 60 times a\n * second to move a one-second counter would be the most expensive thing on the page.\n *\n * @param stream - The stream to record, or `null` while the picker is open.\n * @param options - See {@link UseVideoRecorderOptions}.\n *\n * @example\n * const screen = useScreenCapture();\n * const rec = useVideoRecorder(screen.stream, { maxDurationMs: 120_000 });\n * <button disabled={!rec.ready} onClick={rec.start}>Gravar tela</button>\n */\nexport function useVideoRecorder(\n stream: MediaStream | null,\n options: UseVideoRecorderOptions = {},\n): UseVideoRecorderResult {\n const {\n maxDurationMs,\n onRecorded,\n onChunk,\n onError,\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n tickMs = 250,\n } = options;\n\n const [status, setStatus] = useState<MediaRecorderStatus>(\"idle\");\n const [durationMs, setDurationMs] = useState(0);\n const [recording, setRecording] = useState<VideoRecording | null>(null);\n const [error, setError] = useState<unknown>(null);\n const [ready, setReady] = useState(false);\n\n const recorderRef = useRef<MediaRecorderHandle | null>(null);\n\n const emitRecorded = useStableCallback((result: VideoRecording) => onRecorded?.(result));\n const emitChunk = useStableCallback((chunk: Blob) => onChunk?.(chunk));\n const emitError = useStableCallback((err: unknown) => {\n setError(err);\n onError?.(err);\n });\n\n /**\n * Build the recorder for a stream, and tear it down when the stream changes.\n *\n * Rebuilt per stream because `MediaRecorder` is bound to the stream it was\n * constructed with: a user who stops sharing and picks a different window hands us\n * a new `MediaStream`, and reusing the old recorder would keep recording a surface\n * that no longer exists. Same reason for a container or bitrate change — both are\n * constructor arguments.\n */\n useEffect(() => {\n if (!stream) {\n setReady(false);\n return;\n }\n let recorder: MediaRecorderHandle;\n try {\n recorder = createVideoRecorder(stream, {\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n onChunk: emitChunk,\n onError: emitError,\n });\n } catch (err) {\n setError(err);\n setReady(false);\n return;\n }\n recorderRef.current = recorder;\n setReady(true);\n setStatus(\"idle\");\n\n return () => {\n if (recorder.status() === \"recording\" || recorder.status() === \"paused\") {\n recorder.cancel();\n }\n recorderRef.current = null;\n setReady(false);\n };\n }, [\n stream,\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n emitChunk,\n emitError,\n ]);\n\n const stop = useCallback(async (): Promise<VideoRecording | null> => {\n const recorder = recorderRef.current;\n if (!recorder) return null;\n const current = recorder.status();\n if (current !== \"recording\" && current !== \"paused\") return null;\n const result = await recorder.stop();\n setStatus(\"stopped\");\n setDurationMs(result.durationMs);\n setRecording(result);\n emitRecorded(result);\n return result;\n }, [emitRecorded]);\n\n /**\n * Publish the clock while something is running, and enforce `maxDurationMs`.\n *\n * The cap is checked on the same tick rather than with a `setTimeout` armed at\n * `start()`, because a paused recording must not keep counting toward it — a\n * timeout would fire while the user is paused on a slide.\n */\n useEffect(() => {\n if (status !== \"recording\" && status !== \"paused\") return;\n const id = setInterval(() => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n const elapsed = recorder.durationMs();\n setDurationMs(elapsed);\n if (maxDurationMs !== undefined && elapsed >= maxDurationMs) void stop();\n }, tickMs);\n return () => clearInterval(id);\n }, [status, tickMs, maxDurationMs, stop]);\n\n const start = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n setRecording(null);\n setError(null);\n setDurationMs(0);\n recorder.start();\n setStatus(recorder.status());\n }, []);\n\n const pause = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.pause();\n setDurationMs(recorder.durationMs());\n setStatus(recorder.status());\n }, []);\n\n const resume = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.resume();\n setStatus(recorder.status());\n }, []);\n\n const cancel = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.cancel();\n setStatus(\"idle\");\n setDurationMs(0);\n setRecording(null);\n }, []);\n\n return { status, durationMs, recording, error, ready, start, pause, resume, stop, cancel };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-video-recorder.js","names":[],"sources":["../../src/capture/use-video-recorder.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\n\nimport type { MediaRecorderHandle, MediaRecorderStatus } from \"./media-recorder\";\nimport {\n createVideoRecorder,\n type VideoRecorderOptions,\n type VideoRecording,\n} from \"./video-recorder\";\n\n/** Options for {@link useVideoRecorder}. */\nexport interface UseVideoRecorderOptions extends VideoRecorderOptions {\n /**\n * Stop automatically after this many milliseconds.\n *\n * Worth setting on anything user-facing, and more so than for audio: a minute of\n * 1080p at 2.5 Mbps is roughly 19 MB, so a recording left running by accident\n * fills memory an order of magnitude faster than a voice note does.\n */\n maxDurationMs?: number;\n /** Called once the recording is assembled, whether by `stop()` or `maxDurationMs`. */\n onRecorded?: (recording: VideoRecording) => void;\n /** How often the clock is published, in ms. Default 250. */\n tickMs?: number;\n}\n\n/** Value returned by {@link useVideoRecorder}. */\nexport interface UseVideoRecorderResult {\n status: MediaRecorderStatus;\n /** Recorded length so far, excluding paused time. */\n durationMs: number;\n /** The finished recording, or `null` before the first `stop()`. */\n recording: VideoRecording | null;\n /** Recorder-level error (encoder failure, share revoked mid-recording). */\n error: unknown;\n /** `false` when there is no stream yet, or the browser cannot record video. */\n ready: boolean;\n start: () => void;\n pause: () => void;\n resume: () => void;\n /** Stop and resolve with the recording. Also published on `recording`. */\n stop: () => Promise<VideoRecording | null>;\n /** Stop and throw the video away. */\n cancel: () => void;\n}\n\n/**\n * Record a video stream — from {@link useScreenCapture}, from a camera, or from a\n * canvas.\n *\n * Pass the stream in; the hook stays `ready: false` until there is one, so a page can\n * render the whole recorder UI before the user has picked a screen and simply have it\n * disabled.\n *\n * There is no level meter here, which is the one real difference from\n * `useAudioRecorder`: metering a screen share means opening an `AudioContext` on a\n * stream that usually has no audio track at all, and browsers cap the number of live\n * contexts. If you are recording a camera **and** want a level, run\n * `createLevelMeter` on the same stream yourself.\n *\n * The clock is published every `tickMs` (default 250 ms) rather than per frame: a\n * video recording UI shows `0:07`, and a hook that re-rendered its parent 60 times a\n * second to move a one-second counter would be the most expensive thing on the page.\n *\n * @param stream - The stream to record, or `null` while the picker is open.\n * @param options - See {@link UseVideoRecorderOptions}.\n *\n * @example\n * const screen = useScreenCapture();\n * const rec = useVideoRecorder(screen.stream, { maxDurationMs: 120_000 });\n * <button disabled={!rec.ready} onClick={rec.start}>Gravar tela</button>\n */\nexport function useVideoRecorder(\n stream: MediaStream | null,\n options: UseVideoRecorderOptions = {},\n): UseVideoRecorderResult {\n const {\n maxDurationMs,\n onRecorded,\n onChunk,\n onError,\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n tickMs = 250,\n } = options;\n\n const [status, setStatus] = useState<MediaRecorderStatus>(\"idle\");\n const [durationMs, setDurationMs] = useState(0);\n const [recording, setRecording] = useState<VideoRecording | null>(null);\n const [error, setError] = useState<unknown>(null);\n const [ready, setReady] = useState(false);\n\n const recorderRef = useRef<MediaRecorderHandle | null>(null);\n\n const emitRecorded = useStableCallback((result: VideoRecording) => onRecorded?.(result));\n const emitChunk = useStableCallback((chunk: Blob) => onChunk?.(chunk));\n const emitError = useStableCallback((err: unknown) => {\n setError(err);\n onError?.(err);\n });\n\n /**\n * Build the recorder for a stream, and tear it down when the stream changes.\n *\n * Rebuilt per stream because `MediaRecorder` is bound to the stream it was\n * constructed with: a user who stops sharing and picks a different window hands us\n * a new `MediaStream`, and reusing the old recorder would keep recording a surface\n * that no longer exists. Same reason for a container or bitrate change — both are\n * constructor arguments.\n */\n useEffect(() => {\n if (!stream) {\n setReady(false);\n return;\n }\n let recorder: MediaRecorderHandle;\n try {\n recorder = createVideoRecorder(stream, {\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n onChunk: emitChunk,\n onError: emitError,\n });\n } catch (err) {\n setError(err);\n setReady(false);\n return;\n }\n recorderRef.current = recorder;\n setReady(true);\n setStatus(\"idle\");\n\n return () => {\n if (recorder.status() === \"recording\" || recorder.status() === \"paused\") {\n recorder.cancel();\n }\n recorderRef.current = null;\n setReady(false);\n };\n }, [\n stream,\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n emitChunk,\n emitError,\n ]);\n\n const stop = useCallback(async (): Promise<VideoRecording | null> => {\n const recorder = recorderRef.current;\n if (!recorder) return null;\n const current = recorder.status();\n if (current !== \"recording\" && current !== \"paused\") return null;\n const result = await recorder.stop();\n setStatus(\"stopped\");\n setDurationMs(result.durationMs);\n setRecording(result);\n emitRecorded(result);\n return result;\n }, [emitRecorded]);\n\n /**\n * Publish the clock while something is running, and enforce `maxDurationMs`.\n *\n * The cap is checked on the same tick rather than with a `setTimeout` armed at\n * `start()`, because a paused recording must not keep counting toward it — a\n * timeout would fire while the user is paused on a slide.\n */\n useEffect(() => {\n if (status !== \"recording\" && status !== \"paused\") return;\n const id = setInterval(() => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n const elapsed = recorder.durationMs();\n setDurationMs(elapsed);\n if (maxDurationMs !== undefined && elapsed >= maxDurationMs) void stop();\n }, tickMs);\n return () => clearInterval(id);\n }, [status, tickMs, maxDurationMs, stop]);\n\n const start = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n setRecording(null);\n setError(null);\n setDurationMs(0);\n recorder.start();\n setStatus(recorder.status());\n }, []);\n\n const pause = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.pause();\n setDurationMs(recorder.durationMs());\n setStatus(recorder.status());\n }, []);\n\n const resume = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.resume();\n setStatus(recorder.status());\n }, []);\n\n const cancel = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.cancel();\n setStatus(\"idle\");\n setDurationMs(0);\n setRecording(null);\n }, []);\n\n return { status, durationMs, recording, error, ready, start, pause, resume, stop, cancel };\n}\n"],"mappings":";;;;AAyEA,SAAgB,EACZ,GACA,IAAmC,CAAC,GACd;CACtB,IAAM,EACF,kBACA,eACA,YACA,YACA,aACA,uBACA,uBACA,gBACA,YAAS,QACT,GAEE,CAAC,GAAQ,KAAa,EAA8B,MAAM,GAC1D,CAAC,GAAY,KAAiB,EAAS,CAAC,GACxC,CAAC,GAAW,KAAgB,EAAgC,IAAI,GAChE,CAAC,GAAO,KAAY,EAAkB,IAAI,GAC1C,CAAC,GAAO,KAAY,EAAS,EAAK,GAElC,IAAc,EAAmC,IAAI,GAErD,IAAe,GAAmB,MAA2B,IAAa,CAAM,CAAC,GACjF,IAAY,GAAmB,MAAgB,IAAU,CAAK,CAAC,GAC/D,IAAY,GAAmB,MAAiB;EAElD,AADA,EAAS,CAAG,GACZ,IAAU,CAAG;CACjB,CAAC;CAWD,QAAgB;EACZ,IAAI,CAAC,GAAQ;GACT,EAAS,EAAK;GACd;EACJ;EACA,IAAI;EACJ,IAAI;GACA,IAAW,EAAoB,GAAQ;IACnC;IACA;IACA;IACA;IACA,SAAS;IACT,SAAS;GACb,CAAC;EACL,SAAS,GAAK;GAEV,AADA,EAAS,CAAG,GACZ,EAAS,EAAK;GACd;EACJ;EAKA,OAJA,EAAY,UAAU,GACtB,EAAS,EAAI,GACb,EAAU,MAAM,SAEH;GAKT,CAJI,EAAS,OAAO,MAAM,eAAe,EAAS,OAAO,MAAM,aAC3D,EAAS,OAAO,GAEpB,EAAY,UAAU,MACtB,EAAS,EAAK;EAClB;CACJ,GAAG;EACC;EACA;EACA;EACA;EACA;EACA;EACA;CACJ,CAAC;CAED,IAAM,IAAO,EAAY,YAA4C;EACjE,IAAM,IAAW,EAAY;EAC7B,IAAI,CAAC,GAAU,OAAO;EACtB,IAAM,IAAU,EAAS,OAAO;EAChC,IAAI,MAAY,eAAe,MAAY,UAAU,OAAO;EAC5D,IAAM,IAAS,MAAM,EAAS,KAAK;EAKnC,OAJA,EAAU,SAAS,GACnB,EAAc,EAAO,UAAU,GAC/B,EAAa,CAAM,GACnB,EAAa,CAAM,GACZ;CACX,GAAG,CAAC,CAAY,CAAC;CAuDjB,OA9CA,QAAgB;EACZ,IAAI,MAAW,eAAe,MAAW,UAAU;EACnD,IAAM,IAAK,kBAAkB;GACzB,IAAM,IAAW,EAAY;GAC7B,IAAI,CAAC,GAAU;GACf,IAAM,IAAU,EAAS,WAAW;GAEpC,AADA,EAAc,CAAO,GACjB,MAAkB,KAAA,KAAa,KAAW,KAAe,EAAU;EAC3E,GAAG,CAAM;EACT,aAAa,cAAc,CAAE;CACjC,GAAG;EAAC;EAAQ;EAAQ;EAAe;CAAI,CAAC,GAoCjC;EAAE;EAAQ;EAAY;EAAW;EAAO;EAAO,OAlCxC,QAAwB;GAClC,IAAM,IAAW,EAAY;GACxB,MACL,EAAa,IAAI,GACjB,EAAS,IAAI,GACb,EAAc,CAAC,GACf,EAAS,MAAM,GACf,EAAU,EAAS,OAAO,CAAC;EAC/B,GAAG,CAAC,CA0BkD;EAAO,OAxB/C,QAAwB;GAClC,IAAM,IAAW,EAAY;GACxB,MACL,EAAS,MAAM,GACf,EAAc,EAAS,WAAW,CAAC,GACnC,EAAU,EAAS,OAAO,CAAC;EAC/B,GAAG,CAAC,CAkByD;EAAO,QAhBrD,QAAwB;GACnC,IAAM,IAAW,EAAY;GACxB,MACL,EAAS,OAAO,GAChB,EAAU,EAAS,OAAO,CAAC;EAC/B,GAAG,CAAC,CAWgE;EAAQ;EAAM,QATnE,QAAwB;GACnC,IAAM,IAAW,EAAY;GACxB,MACL,EAAS,OAAO,GAChB,EAAU,MAAM,GAChB,EAAc,CAAC,GACf,EAAa,IAAI;EACrB,GAAG,CAAC,CAE8E;CAAO;AAC7F"}
|
|
1
|
+
{"version":3,"file":"use-video-recorder.js","names":[],"sources":["../../src/capture/use-video-recorder.ts"],"sourcesContent":["/**\n * @tempest-limits hook-lines — the same lifecycle as the audio recorder —\n * permission, recorder, clock, auto-stop — over a stream that also carries video,\n * with the preview element to attach and release.\n */\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\n\nimport type { MediaRecorderHandle, MediaRecorderStatus } from \"./media-recorder\";\nimport {\n createVideoRecorder,\n type VideoRecorderOptions,\n type VideoRecording,\n} from \"./video-recorder\";\n\n/** Options for {@link useVideoRecorder}. */\nexport interface UseVideoRecorderOptions extends VideoRecorderOptions {\n /**\n * Stop automatically after this many milliseconds.\n *\n * Worth setting on anything user-facing, and more so than for audio: a minute of\n * 1080p at 2.5 Mbps is roughly 19 MB, so a recording left running by accident\n * fills memory an order of magnitude faster than a voice note does.\n */\n maxDurationMs?: number;\n /** Called once the recording is assembled, whether by `stop()` or `maxDurationMs`. */\n onRecorded?: (recording: VideoRecording) => void;\n /** How often the clock is published, in ms. Default 250. */\n tickMs?: number;\n}\n\n/** Value returned by {@link useVideoRecorder}. */\nexport interface UseVideoRecorderResult {\n status: MediaRecorderStatus;\n /** Recorded length so far, excluding paused time. */\n durationMs: number;\n /** The finished recording, or `null` before the first `stop()`. */\n recording: VideoRecording | null;\n /** Recorder-level error (encoder failure, share revoked mid-recording). */\n error: unknown;\n /** `false` when there is no stream yet, or the browser cannot record video. */\n ready: boolean;\n start: () => void;\n pause: () => void;\n resume: () => void;\n /** Stop and resolve with the recording. Also published on `recording`. */\n stop: () => Promise<VideoRecording | null>;\n /** Stop and throw the video away. */\n cancel: () => void;\n}\n\n/**\n * Record a video stream — from {@link useScreenCapture}, from a camera, or from a\n * canvas.\n *\n * Pass the stream in; the hook stays `ready: false` until there is one, so a page can\n * render the whole recorder UI before the user has picked a screen and simply have it\n * disabled.\n *\n * There is no level meter here, which is the one real difference from\n * `useAudioRecorder`: metering a screen share means opening an `AudioContext` on a\n * stream that usually has no audio track at all, and browsers cap the number of live\n * contexts. If you are recording a camera **and** want a level, run\n * `createLevelMeter` on the same stream yourself.\n *\n * The clock is published every `tickMs` (default 250 ms) rather than per frame: a\n * video recording UI shows `0:07`, and a hook that re-rendered its parent 60 times a\n * second to move a one-second counter would be the most expensive thing on the page.\n *\n * @param stream - The stream to record, or `null` while the picker is open.\n * @param options - See {@link UseVideoRecorderOptions}.\n *\n * @example\n * const screen = useScreenCapture();\n * const rec = useVideoRecorder(screen.stream, { maxDurationMs: 120_000 });\n * <button disabled={!rec.ready} onClick={rec.start}>Gravar tela</button>\n */\nexport function useVideoRecorder(\n stream: MediaStream | null,\n options: UseVideoRecorderOptions = {},\n): UseVideoRecorderResult {\n const {\n maxDurationMs,\n onRecorded,\n onChunk,\n onError,\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n tickMs = 250,\n } = options;\n\n const [status, setStatus] = useState<MediaRecorderStatus>(\"idle\");\n const [durationMs, setDurationMs] = useState(0);\n const [recording, setRecording] = useState<VideoRecording | null>(null);\n const [error, setError] = useState<unknown>(null);\n const [ready, setReady] = useState(false);\n\n const recorderRef = useRef<MediaRecorderHandle | null>(null);\n\n const emitRecorded = useStableCallback((result: VideoRecording) => onRecorded?.(result));\n const emitChunk = useStableCallback((chunk: Blob) => onChunk?.(chunk));\n const emitError = useStableCallback((err: unknown) => {\n setError(err);\n onError?.(err);\n });\n\n /**\n * Build the recorder for a stream, and tear it down when the stream changes.\n *\n * Rebuilt per stream because `MediaRecorder` is bound to the stream it was\n * constructed with: a user who stops sharing and picks a different window hands us\n * a new `MediaStream`, and reusing the old recorder would keep recording a surface\n * that no longer exists. Same reason for a container or bitrate change — both are\n * constructor arguments.\n */\n useEffect(() => {\n if (!stream) {\n setReady(false);\n return;\n }\n let recorder: MediaRecorderHandle;\n try {\n recorder = createVideoRecorder(stream, {\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n onChunk: emitChunk,\n onError: emitError,\n });\n } catch (err) {\n setError(err);\n setReady(false);\n return;\n }\n recorderRef.current = recorder;\n setReady(true);\n setStatus(\"idle\");\n\n return () => {\n if (recorder.status() === \"recording\" || recorder.status() === \"paused\") {\n recorder.cancel();\n }\n recorderRef.current = null;\n setReady(false);\n };\n }, [\n stream,\n mimeType,\n videoBitsPerSecond,\n audioBitsPerSecond,\n timesliceMs,\n emitChunk,\n emitError,\n ]);\n\n const stop = useCallback(async (): Promise<VideoRecording | null> => {\n const recorder = recorderRef.current;\n if (!recorder) return null;\n const current = recorder.status();\n if (current !== \"recording\" && current !== \"paused\") return null;\n const result = await recorder.stop();\n setStatus(\"stopped\");\n setDurationMs(result.durationMs);\n setRecording(result);\n emitRecorded(result);\n return result;\n }, [emitRecorded]);\n\n /**\n * Publish the clock while something is running, and enforce `maxDurationMs`.\n *\n * The cap is checked on the same tick rather than with a `setTimeout` armed at\n * `start()`, because a paused recording must not keep counting toward it — a\n * timeout would fire while the user is paused on a slide.\n */\n useEffect(() => {\n if (status !== \"recording\" && status !== \"paused\") return;\n const id = setInterval(() => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n const elapsed = recorder.durationMs();\n setDurationMs(elapsed);\n if (maxDurationMs !== undefined && elapsed >= maxDurationMs) void stop();\n }, tickMs);\n return () => clearInterval(id);\n }, [status, tickMs, maxDurationMs, stop]);\n\n const start = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n setRecording(null);\n setError(null);\n setDurationMs(0);\n recorder.start();\n setStatus(recorder.status());\n }, []);\n\n const pause = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.pause();\n setDurationMs(recorder.durationMs());\n setStatus(recorder.status());\n }, []);\n\n const resume = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.resume();\n setStatus(recorder.status());\n }, []);\n\n const cancel = useCallback((): void => {\n const recorder = recorderRef.current;\n if (!recorder) return;\n recorder.cancel();\n setStatus(\"idle\");\n setDurationMs(0);\n setRecording(null);\n }, []);\n\n return { status, durationMs, recording, error, ready, start, pause, resume, stop, cancel };\n}\n"],"mappings":";;;;AA8EA,SAAgB,EACZ,GACA,IAAmC,CAAC,GACd;CACtB,IAAM,EACF,kBACA,eACA,YACA,YACA,aACA,uBACA,uBACA,gBACA,YAAS,QACT,GAEE,CAAC,GAAQ,KAAa,EAA8B,MAAM,GAC1D,CAAC,GAAY,KAAiB,EAAS,CAAC,GACxC,CAAC,GAAW,KAAgB,EAAgC,IAAI,GAChE,CAAC,GAAO,KAAY,EAAkB,IAAI,GAC1C,CAAC,GAAO,KAAY,EAAS,EAAK,GAElC,IAAc,EAAmC,IAAI,GAErD,IAAe,GAAmB,MAA2B,IAAa,CAAM,CAAC,GACjF,IAAY,GAAmB,MAAgB,IAAU,CAAK,CAAC,GAC/D,IAAY,GAAmB,MAAiB;EAElD,AADA,EAAS,CAAG,GACZ,IAAU,CAAG;CACjB,CAAC;CAWD,QAAgB;EACZ,IAAI,CAAC,GAAQ;GACT,EAAS,EAAK;GACd;EACJ;EACA,IAAI;EACJ,IAAI;GACA,IAAW,EAAoB,GAAQ;IACnC;IACA;IACA;IACA;IACA,SAAS;IACT,SAAS;GACb,CAAC;EACL,SAAS,GAAK;GAEV,AADA,EAAS,CAAG,GACZ,EAAS,EAAK;GACd;EACJ;EAKA,OAJA,EAAY,UAAU,GACtB,EAAS,EAAI,GACb,EAAU,MAAM,SAEH;GAKT,CAJI,EAAS,OAAO,MAAM,eAAe,EAAS,OAAO,MAAM,aAC3D,EAAS,OAAO,GAEpB,EAAY,UAAU,MACtB,EAAS,EAAK;EAClB;CACJ,GAAG;EACC;EACA;EACA;EACA;EACA;EACA;EACA;CACJ,CAAC;CAED,IAAM,IAAO,EAAY,YAA4C;EACjE,IAAM,IAAW,EAAY;EAC7B,IAAI,CAAC,GAAU,OAAO;EACtB,IAAM,IAAU,EAAS,OAAO;EAChC,IAAI,MAAY,eAAe,MAAY,UAAU,OAAO;EAC5D,IAAM,IAAS,MAAM,EAAS,KAAK;EAKnC,OAJA,EAAU,SAAS,GACnB,EAAc,EAAO,UAAU,GAC/B,EAAa,CAAM,GACnB,EAAa,CAAM,GACZ;CACX,GAAG,CAAC,CAAY,CAAC;CAuDjB,OA9CA,QAAgB;EACZ,IAAI,MAAW,eAAe,MAAW,UAAU;EACnD,IAAM,IAAK,kBAAkB;GACzB,IAAM,IAAW,EAAY;GAC7B,IAAI,CAAC,GAAU;GACf,IAAM,IAAU,EAAS,WAAW;GAEpC,AADA,EAAc,CAAO,GACjB,MAAkB,KAAA,KAAa,KAAW,KAAe,EAAU;EAC3E,GAAG,CAAM;EACT,aAAa,cAAc,CAAE;CACjC,GAAG;EAAC;EAAQ;EAAQ;EAAe;CAAI,CAAC,GAoCjC;EAAE;EAAQ;EAAY;EAAW;EAAO;EAAO,OAlCxC,QAAwB;GAClC,IAAM,IAAW,EAAY;GACxB,MACL,EAAa,IAAI,GACjB,EAAS,IAAI,GACb,EAAc,CAAC,GACf,EAAS,MAAM,GACf,EAAU,EAAS,OAAO,CAAC;EAC/B,GAAG,CAAC,CA0BkD;EAAO,OAxB/C,QAAwB;GAClC,IAAM,IAAW,EAAY;GACxB,MACL,EAAS,MAAM,GACf,EAAc,EAAS,WAAW,CAAC,GACnC,EAAU,EAAS,OAAO,CAAC;EAC/B,GAAG,CAAC,CAkByD;EAAO,QAhBrD,QAAwB;GACnC,IAAM,IAAW,EAAY;GACxB,MACL,EAAS,OAAO,GAChB,EAAU,EAAS,OAAO,CAAC;EAC/B,GAAG,CAAC,CAWgE;EAAQ;EAAM,QATnE,QAAwB;GACnC,IAAM,IAAW,EAAY;GACxB,MACL,EAAS,OAAO,GAChB,EAAU,MAAM,GAChB,EAAc,CAAC,GACf,EAAa,IAAI;EACrB,GAAG,CAAC,CAE8E;CAAO;AAC7F"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.cjs","names":[],"sources":["../../src/charts/types.ts"],"sourcesContent":["/**\n * Tabular data consumed by every chart: an array of rows, where each row maps a\n * column key to a string (label) or number (value).\n */\nexport type ChartData = Array<Record<string, string | number>>;\n\nimport type {\n Formatter,\n NameType,\n ValueType,\n} from \"recharts/types/component/DefaultTooltipContent\";\n\n/**\n * Recharts tooltip formatters receive a loosely-typed value. This adapts a\n * friendly `(value: number) => string` formatter into a recharts `Formatter`,\n * coercing the incoming value to a number first.\n *\n * @param valueFormatter - The user-supplied numeric formatter, if any.\n * @returns A recharts-compatible formatter, or undefined when none was given.\n */\nexport function toTooltipFormatter(\n valueFormatter?: (value: number) => string,\n): Formatter<ValueType, NameType> | undefined {\n if (!valueFormatter) return undefined;\n return (value: ValueType | undefined): string => valueFormatter(Number(value));\n}\n\n/**\n * Shared props for the cartesian chart family (Area, Bar, Line) and Radar.\n *\n * Each chart plots one series per entry in `categories`, reading values from the\n * matching key on every row, and uses `index` for the category axis.\n */\nexport interface CartesianChartProps {\n /** Rows of data to plot. */\n data: ChartData;\n /** Row key used for the x-axis (cartesian) or angle axis (radar). */\n index: string;\n /** Row keys to plot, one series each. */\n categories: string[];\n /** Series colors, cycled per category. Defaults to {@link DEFAULT_CHART_COLORS}. */\n colors?: string[];\n /** Chart height in pixels. Defaults to 300. */\n height?: number;\n /**\n * Fixed chart width in pixels. When set, the chart renders at this explicit\n * width WITHOUT a ResponsiveContainer (useful for tests/SSR). When omitted,\n * the chart fills its parent via a ResponsiveContainer.\n */\n width?: number;\n /** Stack all series on a shared stackId instead of grouping them. */\n stack?: boolean;\n /** Render the legend. Defaults to true. */\n showLegend?: boolean;\n /** Render the cartesian grid. Defaults to true. */\n showGrid?: boolean;\n /** Render the tooltip. Defaults to true. */\n showTooltip?: boolean;\n /** Format numeric values for tooltip/axis display. */\n valueFormatter?: (value: number) => string;\n /** Extra class name applied to the chart wrapper. */\n className?: string;\n}\n\n/**\n * Props for the {@link PieChart} component.\n */\nexport interface PieChartProps {\n /** Rows of data to plot, one slice each. */\n data: ChartData;\n /** Row key holding the numeric slice value. */\n category: string;\n /** Row key holding the slice name/label. */\n index: string;\n /** Slice colors, cycled per slice. Defaults to {@link DEFAULT_CHART_COLORS}. */\n colors?: string[];\n /** Chart height in pixels. Defaults to 300. */\n height?: number;\n /**\n * Fixed chart width in pixels. When set, the chart renders at this explicit\n * width WITHOUT a ResponsiveContainer (useful for tests/SSR).\n */\n width?: number;\n /** Render as a donut (non-zero inner radius) instead of a full pie. */\n donut?: boolean;\n /** Render the legend. Defaults to true. */\n showLegend?: boolean;\n /** Render the tooltip. Defaults to true. */\n showTooltip?: boolean;\n /** Format numeric values for tooltip display. */\n valueFormatter?: (value: number) => string;\n /** Extra class name applied to the chart wrapper. */\n className?: string;\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.cjs","names":[],"sources":["../../src/charts/types.ts"],"sourcesContent":["/**\n * @tempest-limits props-count — these are the recharts wrapper's surface, and each\n * prop maps to one recharts element the wrapper renders: data/index/categories to\n * the series, colors to Cell, showLegend/showGrid/showTooltip to\n * Legend/CartesianGrid/Tooltip, valueFormatter to the tick and tooltip formatters.\n * Cutting the list means the caller drops to raw recharts for whatever was cut,\n * which is the thing the wrapper exists to avoid.\n */\n/**\n * Tabular data consumed by every chart: an array of rows, where each row maps a\n * column key to a string (label) or number (value).\n */\nexport type ChartData = Array<Record<string, string | number>>;\n\nimport type {\n Formatter,\n NameType,\n ValueType,\n} from \"recharts/types/component/DefaultTooltipContent\";\n\n/**\n * Recharts tooltip formatters receive a loosely-typed value. This adapts a\n * friendly `(value: number) => string` formatter into a recharts `Formatter`,\n * coercing the incoming value to a number first.\n *\n * @param valueFormatter - The user-supplied numeric formatter, if any.\n * @returns A recharts-compatible formatter, or undefined when none was given.\n */\nexport function toTooltipFormatter(\n valueFormatter?: (value: number) => string,\n): Formatter<ValueType, NameType> | undefined {\n if (!valueFormatter) return undefined;\n return (value: ValueType | undefined): string => valueFormatter(Number(value));\n}\n\n/**\n * Shared props for the cartesian chart family (Area, Bar, Line) and Radar.\n *\n * Each chart plots one series per entry in `categories`, reading values from the\n * matching key on every row, and uses `index` for the category axis.\n */\nexport interface CartesianChartProps {\n /** Rows of data to plot. */\n data: ChartData;\n /** Row key used for the x-axis (cartesian) or angle axis (radar). */\n index: string;\n /** Row keys to plot, one series each. */\n categories: string[];\n /** Series colors, cycled per category. Defaults to {@link DEFAULT_CHART_COLORS}. */\n colors?: string[];\n /** Chart height in pixels. Defaults to 300. */\n height?: number;\n /**\n * Fixed chart width in pixels. When set, the chart renders at this explicit\n * width WITHOUT a ResponsiveContainer (useful for tests/SSR). When omitted,\n * the chart fills its parent via a ResponsiveContainer.\n */\n width?: number;\n /** Stack all series on a shared stackId instead of grouping them. */\n stack?: boolean;\n /** Render the legend. Defaults to true. */\n showLegend?: boolean;\n /** Render the cartesian grid. Defaults to true. */\n showGrid?: boolean;\n /** Render the tooltip. Defaults to true. */\n showTooltip?: boolean;\n /** Format numeric values for tooltip/axis display. */\n valueFormatter?: (value: number) => string;\n /** Extra class name applied to the chart wrapper. */\n className?: string;\n}\n\n/**\n * Props for the {@link PieChart} component.\n */\nexport interface PieChartProps {\n /** Rows of data to plot, one slice each. */\n data: ChartData;\n /** Row key holding the numeric slice value. */\n category: string;\n /** Row key holding the slice name/label. */\n index: string;\n /** Slice colors, cycled per slice. Defaults to {@link DEFAULT_CHART_COLORS}. */\n colors?: string[];\n /** Chart height in pixels. Defaults to 300. */\n height?: number;\n /**\n * Fixed chart width in pixels. When set, the chart renders at this explicit\n * width WITHOUT a ResponsiveContainer (useful for tests/SSR).\n */\n width?: number;\n /** Render as a donut (non-zero inner radius) instead of a full pie. */\n donut?: boolean;\n /** Render the legend. Defaults to true. */\n showLegend?: boolean;\n /** Render the tooltip. Defaults to true. */\n showTooltip?: boolean;\n /** Format numeric values for tooltip display. */\n valueFormatter?: (value: number) => string;\n /** Extra class name applied to the chart wrapper. */\n className?: string;\n}\n"],"mappings":"AA4BA,SAAgB,EACZ,EAC0C,CACrC,KACL,MAAQ,IAAyC,EAAe,OAAO,CAAK,CAAC,CACjF"}
|
package/dist/charts/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","names":[],"sources":["../../src/charts/types.ts"],"sourcesContent":["/**\n * Tabular data consumed by every chart: an array of rows, where each row maps a\n * column key to a string (label) or number (value).\n */\nexport type ChartData = Array<Record<string, string | number>>;\n\nimport type {\n Formatter,\n NameType,\n ValueType,\n} from \"recharts/types/component/DefaultTooltipContent\";\n\n/**\n * Recharts tooltip formatters receive a loosely-typed value. This adapts a\n * friendly `(value: number) => string` formatter into a recharts `Formatter`,\n * coercing the incoming value to a number first.\n *\n * @param valueFormatter - The user-supplied numeric formatter, if any.\n * @returns A recharts-compatible formatter, or undefined when none was given.\n */\nexport function toTooltipFormatter(\n valueFormatter?: (value: number) => string,\n): Formatter<ValueType, NameType> | undefined {\n if (!valueFormatter) return undefined;\n return (value: ValueType | undefined): string => valueFormatter(Number(value));\n}\n\n/**\n * Shared props for the cartesian chart family (Area, Bar, Line) and Radar.\n *\n * Each chart plots one series per entry in `categories`, reading values from the\n * matching key on every row, and uses `index` for the category axis.\n */\nexport interface CartesianChartProps {\n /** Rows of data to plot. */\n data: ChartData;\n /** Row key used for the x-axis (cartesian) or angle axis (radar). */\n index: string;\n /** Row keys to plot, one series each. */\n categories: string[];\n /** Series colors, cycled per category. Defaults to {@link DEFAULT_CHART_COLORS}. */\n colors?: string[];\n /** Chart height in pixels. Defaults to 300. */\n height?: number;\n /**\n * Fixed chart width in pixels. When set, the chart renders at this explicit\n * width WITHOUT a ResponsiveContainer (useful for tests/SSR). When omitted,\n * the chart fills its parent via a ResponsiveContainer.\n */\n width?: number;\n /** Stack all series on a shared stackId instead of grouping them. */\n stack?: boolean;\n /** Render the legend. Defaults to true. */\n showLegend?: boolean;\n /** Render the cartesian grid. Defaults to true. */\n showGrid?: boolean;\n /** Render the tooltip. Defaults to true. */\n showTooltip?: boolean;\n /** Format numeric values for tooltip/axis display. */\n valueFormatter?: (value: number) => string;\n /** Extra class name applied to the chart wrapper. */\n className?: string;\n}\n\n/**\n * Props for the {@link PieChart} component.\n */\nexport interface PieChartProps {\n /** Rows of data to plot, one slice each. */\n data: ChartData;\n /** Row key holding the numeric slice value. */\n category: string;\n /** Row key holding the slice name/label. */\n index: string;\n /** Slice colors, cycled per slice. Defaults to {@link DEFAULT_CHART_COLORS}. */\n colors?: string[];\n /** Chart height in pixels. Defaults to 300. */\n height?: number;\n /**\n * Fixed chart width in pixels. When set, the chart renders at this explicit\n * width WITHOUT a ResponsiveContainer (useful for tests/SSR).\n */\n width?: number;\n /** Render as a donut (non-zero inner radius) instead of a full pie. */\n donut?: boolean;\n /** Render the legend. Defaults to true. */\n showLegend?: boolean;\n /** Render the tooltip. Defaults to true. */\n showTooltip?: boolean;\n /** Format numeric values for tooltip display. */\n valueFormatter?: (value: number) => string;\n /** Extra class name applied to the chart wrapper. */\n className?: string;\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"file":"types.js","names":[],"sources":["../../src/charts/types.ts"],"sourcesContent":["/**\n * @tempest-limits props-count — these are the recharts wrapper's surface, and each\n * prop maps to one recharts element the wrapper renders: data/index/categories to\n * the series, colors to Cell, showLegend/showGrid/showTooltip to\n * Legend/CartesianGrid/Tooltip, valueFormatter to the tick and tooltip formatters.\n * Cutting the list means the caller drops to raw recharts for whatever was cut,\n * which is the thing the wrapper exists to avoid.\n */\n/**\n * Tabular data consumed by every chart: an array of rows, where each row maps a\n * column key to a string (label) or number (value).\n */\nexport type ChartData = Array<Record<string, string | number>>;\n\nimport type {\n Formatter,\n NameType,\n ValueType,\n} from \"recharts/types/component/DefaultTooltipContent\";\n\n/**\n * Recharts tooltip formatters receive a loosely-typed value. This adapts a\n * friendly `(value: number) => string` formatter into a recharts `Formatter`,\n * coercing the incoming value to a number first.\n *\n * @param valueFormatter - The user-supplied numeric formatter, if any.\n * @returns A recharts-compatible formatter, or undefined when none was given.\n */\nexport function toTooltipFormatter(\n valueFormatter?: (value: number) => string,\n): Formatter<ValueType, NameType> | undefined {\n if (!valueFormatter) return undefined;\n return (value: ValueType | undefined): string => valueFormatter(Number(value));\n}\n\n/**\n * Shared props for the cartesian chart family (Area, Bar, Line) and Radar.\n *\n * Each chart plots one series per entry in `categories`, reading values from the\n * matching key on every row, and uses `index` for the category axis.\n */\nexport interface CartesianChartProps {\n /** Rows of data to plot. */\n data: ChartData;\n /** Row key used for the x-axis (cartesian) or angle axis (radar). */\n index: string;\n /** Row keys to plot, one series each. */\n categories: string[];\n /** Series colors, cycled per category. Defaults to {@link DEFAULT_CHART_COLORS}. */\n colors?: string[];\n /** Chart height in pixels. Defaults to 300. */\n height?: number;\n /**\n * Fixed chart width in pixels. When set, the chart renders at this explicit\n * width WITHOUT a ResponsiveContainer (useful for tests/SSR). When omitted,\n * the chart fills its parent via a ResponsiveContainer.\n */\n width?: number;\n /** Stack all series on a shared stackId instead of grouping them. */\n stack?: boolean;\n /** Render the legend. Defaults to true. */\n showLegend?: boolean;\n /** Render the cartesian grid. Defaults to true. */\n showGrid?: boolean;\n /** Render the tooltip. Defaults to true. */\n showTooltip?: boolean;\n /** Format numeric values for tooltip/axis display. */\n valueFormatter?: (value: number) => string;\n /** Extra class name applied to the chart wrapper. */\n className?: string;\n}\n\n/**\n * Props for the {@link PieChart} component.\n */\nexport interface PieChartProps {\n /** Rows of data to plot, one slice each. */\n data: ChartData;\n /** Row key holding the numeric slice value. */\n category: string;\n /** Row key holding the slice name/label. */\n index: string;\n /** Slice colors, cycled per slice. Defaults to {@link DEFAULT_CHART_COLORS}. */\n colors?: string[];\n /** Chart height in pixels. Defaults to 300. */\n height?: number;\n /**\n * Fixed chart width in pixels. When set, the chart renders at this explicit\n * width WITHOUT a ResponsiveContainer (useful for tests/SSR).\n */\n width?: number;\n /** Render as a donut (non-zero inner radius) instead of a full pie. */\n donut?: boolean;\n /** Render the legend. Defaults to true. */\n showLegend?: boolean;\n /** Render the tooltip. Defaults to true. */\n showTooltip?: boolean;\n /** Format numeric values for tooltip display. */\n valueFormatter?: (value: number) => string;\n /** Extra class name applied to the chart wrapper. */\n className?: string;\n}\n"],"mappings":";AA4BA,SAAgB,EACZ,GAC0C;CACrC,OACL,QAAQ,MAAyC,EAAe,OAAO,CAAK,CAAC;AACjF"}
|
package/dist/charts.d.ts
CHANGED
|
@@ -63,6 +63,14 @@ export declare const CHART_COLOR_TOKEN_COUNT = 8;
|
|
|
63
63
|
/** A `var(--tempest-chart-…)` reference, so the value follows the active theme. */
|
|
64
64
|
export declare type ChartColorToken = string;
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* @tempest-limits props-count — these are the recharts wrapper's surface, and each
|
|
68
|
+
* prop maps to one recharts element the wrapper renders: data/index/categories to
|
|
69
|
+
* the series, colors to Cell, showLegend/showGrid/showTooltip to
|
|
70
|
+
* Legend/CartesianGrid/Tooltip, valueFormatter to the tick and tooltip formatters.
|
|
71
|
+
* Cutting the list means the caller drops to raw recharts for whatever was cut,
|
|
72
|
+
* which is the thing the wrapper exists to avoid.
|
|
73
|
+
*/
|
|
66
74
|
/**
|
|
67
75
|
* Tabular data consumed by every chart: an array of rows, where each row maps a
|
|
68
76
|
* column key to a string (label) or number (value).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AIChat.cjs","names":[],"sources":["../../../src/components/AIChat/AIChat.tsx"],"sourcesContent":["import { ArrowDown } from \"lucide-react\";\nimport {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ReactNode,\n type Ref,\n} from \"react\";\n\nimport { useAnnounce } from \"@/hooks/use-announce\";\nimport { cn } from \"@/utils/cn\";\n\nimport { EmptyState } from \"../EmptyState\";\nimport {\n aiChatStrings,\n isGenerating,\n lastAssistantId,\n tailSignature,\n visibleTurns,\n type AIChatMessage,\n type AIChatVote,\n} from \"./ai-chat-turns\";\nimport { AIChatComposer, type AIChatComposerHandle } from \"./AIChatComposer\";\nimport { AIChatTurn } from \"./AIChatTurn\";\nimport styles from \"./AIChat.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\" | \"onSubmit\";\n\nexport interface AIChatProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /** The transcript, **oldest first**. Never reordered by the component. */\n messages: readonly AIChatMessage[];\n /** Renders the composer when given. Receives the trimmed prompt. */\n onSend?: (text: string) => void | Promise<void>;\n /** Abort the turn in flight. Shows the stop button while generating. */\n onStop?: () => void;\n /** Ask again for the newest assistant turn. */\n onRegenerate?: (message: AIChatMessage) => void;\n /** Re-submit an edited user turn. The app decides what to drop after it. */\n onEditSubmit?: (message: AIChatMessage, text: string) => void | Promise<void>;\n /** Rating on an assistant turn. */\n onFeedback?: (message: AIChatMessage, vote: AIChatVote) => void;\n /** Retry a turn that carries an `error`. */\n onRetry?: (message: AIChatMessage) => void;\n /**\n * The request is out and nothing has arrived yet.\n *\n * Distinct from a turn with `streaming: true`: apps that only push a message\n * once the first token lands need somewhere to say \"we asked\", and without it the\n * screen is frozen for however long the model takes to start.\n */\n pending?: boolean;\n /** Prompts offered on an empty transcript. Clicking one sends it. */\n suggestions?: readonly string[];\n /** Avatar for a turn — an `<Avatar>`, an `<Icon>`, a logo. */\n renderAvatar?: (message: AIChatMessage) => ReactNode;\n /** Render a body yourself — a tool-call card, a chart, a citation list. */\n renderContent?: (message: AIChatMessage) => ReactNode;\n /** Ratings to show as pressed, by message id. Omit to keep them local. */\n votes?: Readonly<Record<string, AIChatVote>>;\n /** Rendered above the transcript, inside the panel. */\n header?: ReactNode;\n /** Shown when there are no turns and no suggestions. */\n emptyState?: ReactNode;\n /** Show `\"system\"` turns. Default `false`. */\n showSystem?: boolean;\n /** Reasoning blocks start expanded. Default `false`. */\n defaultReasoningOpen?: boolean;\n /** Show line numbers in fenced code. Default `false`. */\n showLineNumbers?: boolean;\n /** Locale for labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Placeholder for the composer. */\n placeholder?: string;\n /** Extra controls inside the composer, before the send button. */\n composerActions?: ReactNode;\n /**\n * Reach the composer imperatively — `focus()`, `getValue()`, `setValue()`.\n *\n * What makes dictation (or a slash-command menu, or \"edit and resend\") possible\n * without this component knowing anything about them: pair it with\n * `composerActions` and the button you put in the composer can write into the\n * field. Speech recognition is **not** wired in here on purpose — it would make\n * every consumer of `AIChat` pay for an API that streams audio to a third party.\n */\n composerRef?: Ref<AIChatComposerHandle>;\n /** Under the composer field — token count, model name, a disclaimer. */\n composerFooter?: ReactNode;\n /** Disable the composer — no credits, conversation archived, offline. */\n composerDisabled?: boolean;\n /** Largest height the composer grows to, in lines. Default 8. */\n maxRows?: number;\n /**\n * Called when `onSend` **or** `onEditSubmit` rejects. The draft stays in the field\n * either way.\n */\n onSendError?: (error: unknown) => void;\n}\n\n/** How close to the bottom still counts as \"reading the newest turn\", in pixels. */\nconst BOTTOM_SLACK = 48;\n\n/**\n * A conversation with a model: role-based turns, Markdown answers, a reasoning\n * block, a streaming caret, per-turn actions and a composer that turns into a stop\n * button while a turn is generating.\n *\n * This is the shape ChatGPT, Claude and DeepSeek converged on, and it is a different\n * component from {@link Chat}, not a variant of it. A human thread is addressed by\n * author and cares about delivery state; a model transcript is addressed by role,\n * has no delivery state at all, and needs three things a human thread never does —\n * partial output, reasoning separate from the answer, and re-asking.\n *\n * Presentational and controlled, like the rest of the SDK: it takes a list and emits\n * intent (`onSend`, `onStop`, `onRegenerate`, `onEditSubmit`, `onFeedback`). The\n * transport stays with the app, because \"how do I stream from my backend\" has a\n * different answer per provider — the SDK's `createEventStream` covers SSE, `fetch`\n * with a `ReadableStream` covers the rest, and either way the app owns the\n * `AbortController` it hands to `onStop`.\n *\n * @example\n * <AIChat\n * messages={turns}\n * pending={pending}\n * onSend={(text) => ask(text)}\n * onStop={() => controller.current?.abort()}\n * onRegenerate={(turn) => reask(turn)}\n * onFeedback={(turn, vote) => track(\"answer_rated\", { id: turn.id, vote })}\n * suggestions={[\"Resuma o último relatório\", \"Quais pedidos atrasaram?\"]}\n * />\n */\nexport function AIChat({\n messages,\n onSend,\n onStop,\n onRegenerate,\n onEditSubmit,\n onFeedback,\n onRetry,\n pending = false,\n suggestions = [],\n renderAvatar,\n renderContent,\n votes,\n header,\n emptyState,\n showSystem = false,\n defaultReasoningOpen = false,\n showLineNumbers = false,\n locale = \"pt-BR\",\n placeholder,\n composerActions,\n composerRef,\n composerFooter,\n composerDisabled,\n maxRows,\n onSendError,\n className,\n ...rest\n}: AIChatProps) {\n const strings = aiChatStrings(locale);\n const turns = visibleTurns({ messages, showSystem });\n const generating = pending || isGenerating(messages);\n const newestAssistant = lastAssistantId(messages);\n const signature = tailSignature(messages);\n\n const composer = useRef<AIChatComposerHandle | null>(null);\n const thread = useRef<HTMLDivElement | null>(null);\n const stuckToBottom = useRef(true);\n const wasGenerating = useRef(false);\n const [atBottom, setAtBottom] = useState(true);\n const announce = useAnnounce();\n\n /**\n * Remember whether the reader is at the bottom, before the next tokens land.\n *\n * A transcript that always scrolls to the newest text yanks somebody out of the\n * answer they were re-reading — and with a streaming answer that would happen\n * dozens of times per second. So the jump only happens when they were already at\n * the bottom. The slack covers a partially visible last line.\n */\n const trackPosition = (): void => {\n const node = thread.current;\n if (!node) return;\n const next = node.scrollHeight - node.scrollTop - node.clientHeight < BOTTOM_SLACK;\n stuckToBottom.current = next;\n setAtBottom(next);\n };\n\n const jumpToLatest = (): void => {\n const node = thread.current;\n if (!node) return;\n stuckToBottom.current = true;\n setAtBottom(true);\n node.scrollTop = node.scrollHeight;\n };\n\n useLayoutEffect(() => {\n const node = thread.current;\n if (!node || !stuckToBottom.current) return;\n node.scrollTop = node.scrollHeight;\n }, [signature, pending]);\n\n useEffect(() => {\n const node = thread.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n // Mount lands on the newest turn; from then on `trackPosition` decides.\n }, []);\n\n /**\n * Announce the start and the end of a generation, and nothing in between.\n *\n * The transcript is a `role=\"log\"` **without** `aria-live`: a live region over\n * streaming text makes a screen reader read the answer again on every token,\n * which is unusable. The two moments that matter are announced here instead —\n * through the shared `useAnnounce` region rather than a private one, so a page\n * holding a chat plus a table plus toasts still has exactly one live region per\n * politeness — and the finished answer is read from the log at the reader's own\n * pace.\n */\n useEffect(() => {\n if (generating) {\n announce(strings.generating);\n wasGenerating.current = true;\n return;\n }\n if (wasGenerating.current) {\n announce(strings.done);\n wasGenerating.current = false;\n }\n }, [generating, strings.generating, strings.done, announce]);\n\n const showSuggestions = turns.length === 0 && suggestions.length > 0 && onSend !== undefined;\n const detached = !atBottom && turns.length > 0;\n\n return (\n <div className={cn(styles.panel, className)} {...rest}>\n {header && <header className={styles.header}>{header}</header>}\n\n <div className={styles.threadWrapper}>\n <div\n ref={thread}\n className={styles.thread}\n onScroll={trackPosition}\n role=\"log\"\n tabIndex={0}\n aria-label={strings.thread}\n >\n {turns.length === 0 && !showSuggestions\n ? (emptyState ?? (\n <EmptyState title={strings.empty} description={strings.emptyHint} />\n ))\n : turns.map((message) => (\n <AIChatTurn\n key={message.id}\n message={message}\n locale={locale}\n canRegenerate={\n message.id === newestAssistant && message.streaming !== true\n }\n onRegenerate={onRegenerate}\n onFeedback={onFeedback}\n onEditSubmit={onEditSubmit}\n onEditError={onSendError}\n onRetry={onRetry}\n renderAvatar={renderAvatar}\n renderContent={renderContent}\n vote={votes?.[message.id]}\n defaultReasoningOpen={defaultReasoningOpen}\n showLineNumbers={showLineNumbers}\n />\n ))}\n\n {showSuggestions && (\n <div className={styles.suggestions}>\n <p className={styles.suggestionsTitle}>{strings.emptyHint}</p>\n {suggestions.map((suggestion) => (\n <button\n key={suggestion}\n type=\"button\"\n className={styles.suggestion}\n onClick={() => void onSend?.(suggestion)}\n >\n {suggestion}\n </button>\n ))}\n </div>\n )}\n\n {pending && !isGenerating(messages) && (\n <p className={styles.thinking}>\n <span className={styles.dots} aria-hidden=\"true\">\n <span />\n <span />\n <span />\n </span>\n {strings.thinking}\n </p>\n )}\n </div>\n\n {detached && (\n <button\n type=\"button\"\n className={styles.jump}\n onClick={jumpToLatest}\n aria-label={strings.jumpToLatest}\n title={strings.jumpToLatest}\n >\n <ArrowDown size={16} aria-hidden />\n </button>\n )}\n </div>\n\n {onSend && (\n <AIChatComposer\n ref={composerRef ?? composer}\n onSend={onSend}\n onStop={onStop}\n generating={generating}\n locale={locale}\n placeholder={placeholder}\n actions={composerActions}\n footer={composerFooter}\n disabled={composerDisabled}\n maxRows={maxRows}\n onError={onSendError}\n />\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AIChat.cjs","names":[],"sources":["../../../src/components/AIChat/AIChat.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — the widest surface in\n * the SDK on purpose: it is a whole chat page, and its 25 props are four groups —\n * the transcript (messages, votes, pending, showSystem), the turn actions an app has\n * to own (onSend, onStop, onRegenerate, onEditSubmit, onFeedback, onRetry,\n * onSendError), render slots (renderAvatar, renderContent, header, emptyState,\n * suggestions, composerActions, composerFooter) and composer settings (composerRef,\n * composerDisabled, maxRows, placeholder, locale, defaultReasoningOpen,\n * showLineNumbers). The parts already are separate components — AIChatTurn and\n * AIChatComposer are exported — and this is the assembly whose entire job is to\n * accept the union of their configuration.\n */\nimport { ArrowDown } from \"lucide-react\";\nimport {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ReactNode,\n type Ref,\n} from \"react\";\n\nimport { useAnnounce } from \"@/hooks/use-announce\";\nimport { cn } from \"@/utils/cn\";\n\nimport { EmptyState } from \"../EmptyState\";\nimport {\n aiChatStrings,\n isGenerating,\n lastAssistantId,\n tailSignature,\n visibleTurns,\n type AIChatMessage,\n type AIChatVote,\n} from \"./ai-chat-turns\";\nimport { AIChatComposer, type AIChatComposerHandle } from \"./AIChatComposer\";\nimport { AIChatTurn } from \"./AIChatTurn\";\nimport styles from \"./AIChat.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\" | \"onSubmit\";\n\nexport interface AIChatProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /** The transcript, **oldest first**. Never reordered by the component. */\n messages: readonly AIChatMessage[];\n /** Renders the composer when given. Receives the trimmed prompt. */\n onSend?: (text: string) => void | Promise<void>;\n /** Abort the turn in flight. Shows the stop button while generating. */\n onStop?: () => void;\n /** Ask again for the newest assistant turn. */\n onRegenerate?: (message: AIChatMessage) => void;\n /** Re-submit an edited user turn. The app decides what to drop after it. */\n onEditSubmit?: (message: AIChatMessage, text: string) => void | Promise<void>;\n /** Rating on an assistant turn. */\n onFeedback?: (message: AIChatMessage, vote: AIChatVote) => void;\n /** Retry a turn that carries an `error`. */\n onRetry?: (message: AIChatMessage) => void;\n /**\n * The request is out and nothing has arrived yet.\n *\n * Distinct from a turn with `streaming: true`: apps that only push a message\n * once the first token lands need somewhere to say \"we asked\", and without it the\n * screen is frozen for however long the model takes to start.\n */\n pending?: boolean;\n /** Prompts offered on an empty transcript. Clicking one sends it. */\n suggestions?: readonly string[];\n /** Avatar for a turn — an `<Avatar>`, an `<Icon>`, a logo. */\n renderAvatar?: (message: AIChatMessage) => ReactNode;\n /** Render a body yourself — a tool-call card, a chart, a citation list. */\n renderContent?: (message: AIChatMessage) => ReactNode;\n /** Ratings to show as pressed, by message id. Omit to keep them local. */\n votes?: Readonly<Record<string, AIChatVote>>;\n /** Rendered above the transcript, inside the panel. */\n header?: ReactNode;\n /** Shown when there are no turns and no suggestions. */\n emptyState?: ReactNode;\n /** Show `\"system\"` turns. Default `false`. */\n showSystem?: boolean;\n /** Reasoning blocks start expanded. Default `false`. */\n defaultReasoningOpen?: boolean;\n /** Show line numbers in fenced code. Default `false`. */\n showLineNumbers?: boolean;\n /** Locale for labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Placeholder for the composer. */\n placeholder?: string;\n /** Extra controls inside the composer, before the send button. */\n composerActions?: ReactNode;\n /**\n * Reach the composer imperatively — `focus()`, `getValue()`, `setValue()`.\n *\n * What makes dictation (or a slash-command menu, or \"edit and resend\") possible\n * without this component knowing anything about them: pair it with\n * `composerActions` and the button you put in the composer can write into the\n * field. Speech recognition is **not** wired in here on purpose — it would make\n * every consumer of `AIChat` pay for an API that streams audio to a third party.\n */\n composerRef?: Ref<AIChatComposerHandle>;\n /** Under the composer field — token count, model name, a disclaimer. */\n composerFooter?: ReactNode;\n /** Disable the composer — no credits, conversation archived, offline. */\n composerDisabled?: boolean;\n /** Largest height the composer grows to, in lines. Default 8. */\n maxRows?: number;\n /**\n * Called when `onSend` **or** `onEditSubmit` rejects. The draft stays in the field\n * either way.\n */\n onSendError?: (error: unknown) => void;\n}\n\n/** How close to the bottom still counts as \"reading the newest turn\", in pixels. */\nconst BOTTOM_SLACK = 48;\n\n/**\n * A conversation with a model: role-based turns, Markdown answers, a reasoning\n * block, a streaming caret, per-turn actions and a composer that turns into a stop\n * button while a turn is generating.\n *\n * This is the shape ChatGPT, Claude and DeepSeek converged on, and it is a different\n * component from {@link Chat}, not a variant of it. A human thread is addressed by\n * author and cares about delivery state; a model transcript is addressed by role,\n * has no delivery state at all, and needs three things a human thread never does —\n * partial output, reasoning separate from the answer, and re-asking.\n *\n * Presentational and controlled, like the rest of the SDK: it takes a list and emits\n * intent (`onSend`, `onStop`, `onRegenerate`, `onEditSubmit`, `onFeedback`). The\n * transport stays with the app, because \"how do I stream from my backend\" has a\n * different answer per provider — the SDK's `createEventStream` covers SSE, `fetch`\n * with a `ReadableStream` covers the rest, and either way the app owns the\n * `AbortController` it hands to `onStop`.\n *\n * @example\n * <AIChat\n * messages={turns}\n * pending={pending}\n * onSend={(text) => ask(text)}\n * onStop={() => controller.current?.abort()}\n * onRegenerate={(turn) => reask(turn)}\n * onFeedback={(turn, vote) => track(\"answer_rated\", { id: turn.id, vote })}\n * suggestions={[\"Resuma o último relatório\", \"Quais pedidos atrasaram?\"]}\n * />\n */\nexport function AIChat({\n messages,\n onSend,\n onStop,\n onRegenerate,\n onEditSubmit,\n onFeedback,\n onRetry,\n pending = false,\n suggestions = [],\n renderAvatar,\n renderContent,\n votes,\n header,\n emptyState,\n showSystem = false,\n defaultReasoningOpen = false,\n showLineNumbers = false,\n locale = \"pt-BR\",\n placeholder,\n composerActions,\n composerRef,\n composerFooter,\n composerDisabled,\n maxRows,\n onSendError,\n className,\n ...rest\n}: AIChatProps) {\n const strings = aiChatStrings(locale);\n const turns = visibleTurns({ messages, showSystem });\n const generating = pending || isGenerating(messages);\n const newestAssistant = lastAssistantId(messages);\n const signature = tailSignature(messages);\n\n const composer = useRef<AIChatComposerHandle | null>(null);\n const thread = useRef<HTMLDivElement | null>(null);\n const stuckToBottom = useRef(true);\n const wasGenerating = useRef(false);\n const [atBottom, setAtBottom] = useState(true);\n const announce = useAnnounce();\n\n /**\n * Remember whether the reader is at the bottom, before the next tokens land.\n *\n * A transcript that always scrolls to the newest text yanks somebody out of the\n * answer they were re-reading — and with a streaming answer that would happen\n * dozens of times per second. So the jump only happens when they were already at\n * the bottom. The slack covers a partially visible last line.\n */\n const trackPosition = (): void => {\n const node = thread.current;\n if (!node) return;\n const next = node.scrollHeight - node.scrollTop - node.clientHeight < BOTTOM_SLACK;\n stuckToBottom.current = next;\n setAtBottom(next);\n };\n\n const jumpToLatest = (): void => {\n const node = thread.current;\n if (!node) return;\n stuckToBottom.current = true;\n setAtBottom(true);\n node.scrollTop = node.scrollHeight;\n };\n\n useLayoutEffect(() => {\n const node = thread.current;\n if (!node || !stuckToBottom.current) return;\n node.scrollTop = node.scrollHeight;\n }, [signature, pending]);\n\n useEffect(() => {\n const node = thread.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n // Mount lands on the newest turn; from then on `trackPosition` decides.\n }, []);\n\n /**\n * Announce the start and the end of a generation, and nothing in between.\n *\n * The transcript is a `role=\"log\"` **without** `aria-live`: a live region over\n * streaming text makes a screen reader read the answer again on every token,\n * which is unusable. The two moments that matter are announced here instead —\n * through the shared `useAnnounce` region rather than a private one, so a page\n * holding a chat plus a table plus toasts still has exactly one live region per\n * politeness — and the finished answer is read from the log at the reader's own\n * pace.\n */\n useEffect(() => {\n if (generating) {\n announce(strings.generating);\n wasGenerating.current = true;\n return;\n }\n if (wasGenerating.current) {\n announce(strings.done);\n wasGenerating.current = false;\n }\n }, [generating, strings.generating, strings.done, announce]);\n\n const showSuggestions = turns.length === 0 && suggestions.length > 0 && onSend !== undefined;\n const detached = !atBottom && turns.length > 0;\n\n return (\n <div className={cn(styles.panel, className)} {...rest}>\n {header && <header className={styles.header}>{header}</header>}\n\n <div className={styles.threadWrapper}>\n <div\n ref={thread}\n className={styles.thread}\n onScroll={trackPosition}\n role=\"log\"\n tabIndex={0}\n aria-label={strings.thread}\n >\n {turns.length === 0 && !showSuggestions\n ? (emptyState ?? (\n <EmptyState title={strings.empty} description={strings.emptyHint} />\n ))\n : turns.map((message) => (\n <AIChatTurn\n key={message.id}\n message={message}\n locale={locale}\n canRegenerate={\n message.id === newestAssistant && message.streaming !== true\n }\n onRegenerate={onRegenerate}\n onFeedback={onFeedback}\n onEditSubmit={onEditSubmit}\n onEditError={onSendError}\n onRetry={onRetry}\n renderAvatar={renderAvatar}\n renderContent={renderContent}\n vote={votes?.[message.id]}\n defaultReasoningOpen={defaultReasoningOpen}\n showLineNumbers={showLineNumbers}\n />\n ))}\n\n {showSuggestions && (\n <div className={styles.suggestions}>\n <p className={styles.suggestionsTitle}>{strings.emptyHint}</p>\n {suggestions.map((suggestion) => (\n <button\n key={suggestion}\n type=\"button\"\n className={styles.suggestion}\n onClick={() => void onSend?.(suggestion)}\n >\n {suggestion}\n </button>\n ))}\n </div>\n )}\n\n {pending && !isGenerating(messages) && (\n <p className={styles.thinking}>\n <span className={styles.dots} aria-hidden=\"true\">\n <span />\n <span />\n <span />\n </span>\n {strings.thinking}\n </p>\n )}\n </div>\n\n {detached && (\n <button\n type=\"button\"\n className={styles.jump}\n onClick={jumpToLatest}\n aria-label={strings.jumpToLatest}\n title={strings.jumpToLatest}\n >\n <ArrowDown size={16} aria-hidden />\n </button>\n )}\n </div>\n\n {onSend && (\n <AIChatComposer\n ref={composerRef ?? composer}\n onSend={onSend}\n onStop={onStop}\n generating={generating}\n locale={locale}\n placeholder={placeholder}\n actions={composerActions}\n footer={composerFooter}\n disabled={composerDisabled}\n maxRows={maxRows}\n onError={onSendError}\n />\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AIChat.js","names":[],"sources":["../../../src/components/AIChat/AIChat.tsx"],"sourcesContent":["import { ArrowDown } from \"lucide-react\";\nimport {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ReactNode,\n type Ref,\n} from \"react\";\n\nimport { useAnnounce } from \"@/hooks/use-announce\";\nimport { cn } from \"@/utils/cn\";\n\nimport { EmptyState } from \"../EmptyState\";\nimport {\n aiChatStrings,\n isGenerating,\n lastAssistantId,\n tailSignature,\n visibleTurns,\n type AIChatMessage,\n type AIChatVote,\n} from \"./ai-chat-turns\";\nimport { AIChatComposer, type AIChatComposerHandle } from \"./AIChatComposer\";\nimport { AIChatTurn } from \"./AIChatTurn\";\nimport styles from \"./AIChat.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\" | \"onSubmit\";\n\nexport interface AIChatProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /** The transcript, **oldest first**. Never reordered by the component. */\n messages: readonly AIChatMessage[];\n /** Renders the composer when given. Receives the trimmed prompt. */\n onSend?: (text: string) => void | Promise<void>;\n /** Abort the turn in flight. Shows the stop button while generating. */\n onStop?: () => void;\n /** Ask again for the newest assistant turn. */\n onRegenerate?: (message: AIChatMessage) => void;\n /** Re-submit an edited user turn. The app decides what to drop after it. */\n onEditSubmit?: (message: AIChatMessage, text: string) => void | Promise<void>;\n /** Rating on an assistant turn. */\n onFeedback?: (message: AIChatMessage, vote: AIChatVote) => void;\n /** Retry a turn that carries an `error`. */\n onRetry?: (message: AIChatMessage) => void;\n /**\n * The request is out and nothing has arrived yet.\n *\n * Distinct from a turn with `streaming: true`: apps that only push a message\n * once the first token lands need somewhere to say \"we asked\", and without it the\n * screen is frozen for however long the model takes to start.\n */\n pending?: boolean;\n /** Prompts offered on an empty transcript. Clicking one sends it. */\n suggestions?: readonly string[];\n /** Avatar for a turn — an `<Avatar>`, an `<Icon>`, a logo. */\n renderAvatar?: (message: AIChatMessage) => ReactNode;\n /** Render a body yourself — a tool-call card, a chart, a citation list. */\n renderContent?: (message: AIChatMessage) => ReactNode;\n /** Ratings to show as pressed, by message id. Omit to keep them local. */\n votes?: Readonly<Record<string, AIChatVote>>;\n /** Rendered above the transcript, inside the panel. */\n header?: ReactNode;\n /** Shown when there are no turns and no suggestions. */\n emptyState?: ReactNode;\n /** Show `\"system\"` turns. Default `false`. */\n showSystem?: boolean;\n /** Reasoning blocks start expanded. Default `false`. */\n defaultReasoningOpen?: boolean;\n /** Show line numbers in fenced code. Default `false`. */\n showLineNumbers?: boolean;\n /** Locale for labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Placeholder for the composer. */\n placeholder?: string;\n /** Extra controls inside the composer, before the send button. */\n composerActions?: ReactNode;\n /**\n * Reach the composer imperatively — `focus()`, `getValue()`, `setValue()`.\n *\n * What makes dictation (or a slash-command menu, or \"edit and resend\") possible\n * without this component knowing anything about them: pair it with\n * `composerActions` and the button you put in the composer can write into the\n * field. Speech recognition is **not** wired in here on purpose — it would make\n * every consumer of `AIChat` pay for an API that streams audio to a third party.\n */\n composerRef?: Ref<AIChatComposerHandle>;\n /** Under the composer field — token count, model name, a disclaimer. */\n composerFooter?: ReactNode;\n /** Disable the composer — no credits, conversation archived, offline. */\n composerDisabled?: boolean;\n /** Largest height the composer grows to, in lines. Default 8. */\n maxRows?: number;\n /**\n * Called when `onSend` **or** `onEditSubmit` rejects. The draft stays in the field\n * either way.\n */\n onSendError?: (error: unknown) => void;\n}\n\n/** How close to the bottom still counts as \"reading the newest turn\", in pixels. */\nconst BOTTOM_SLACK = 48;\n\n/**\n * A conversation with a model: role-based turns, Markdown answers, a reasoning\n * block, a streaming caret, per-turn actions and a composer that turns into a stop\n * button while a turn is generating.\n *\n * This is the shape ChatGPT, Claude and DeepSeek converged on, and it is a different\n * component from {@link Chat}, not a variant of it. A human thread is addressed by\n * author and cares about delivery state; a model transcript is addressed by role,\n * has no delivery state at all, and needs three things a human thread never does —\n * partial output, reasoning separate from the answer, and re-asking.\n *\n * Presentational and controlled, like the rest of the SDK: it takes a list and emits\n * intent (`onSend`, `onStop`, `onRegenerate`, `onEditSubmit`, `onFeedback`). The\n * transport stays with the app, because \"how do I stream from my backend\" has a\n * different answer per provider — the SDK's `createEventStream` covers SSE, `fetch`\n * with a `ReadableStream` covers the rest, and either way the app owns the\n * `AbortController` it hands to `onStop`.\n *\n * @example\n * <AIChat\n * messages={turns}\n * pending={pending}\n * onSend={(text) => ask(text)}\n * onStop={() => controller.current?.abort()}\n * onRegenerate={(turn) => reask(turn)}\n * onFeedback={(turn, vote) => track(\"answer_rated\", { id: turn.id, vote })}\n * suggestions={[\"Resuma o último relatório\", \"Quais pedidos atrasaram?\"]}\n * />\n */\nexport function AIChat({\n messages,\n onSend,\n onStop,\n onRegenerate,\n onEditSubmit,\n onFeedback,\n onRetry,\n pending = false,\n suggestions = [],\n renderAvatar,\n renderContent,\n votes,\n header,\n emptyState,\n showSystem = false,\n defaultReasoningOpen = false,\n showLineNumbers = false,\n locale = \"pt-BR\",\n placeholder,\n composerActions,\n composerRef,\n composerFooter,\n composerDisabled,\n maxRows,\n onSendError,\n className,\n ...rest\n}: AIChatProps) {\n const strings = aiChatStrings(locale);\n const turns = visibleTurns({ messages, showSystem });\n const generating = pending || isGenerating(messages);\n const newestAssistant = lastAssistantId(messages);\n const signature = tailSignature(messages);\n\n const composer = useRef<AIChatComposerHandle | null>(null);\n const thread = useRef<HTMLDivElement | null>(null);\n const stuckToBottom = useRef(true);\n const wasGenerating = useRef(false);\n const [atBottom, setAtBottom] = useState(true);\n const announce = useAnnounce();\n\n /**\n * Remember whether the reader is at the bottom, before the next tokens land.\n *\n * A transcript that always scrolls to the newest text yanks somebody out of the\n * answer they were re-reading — and with a streaming answer that would happen\n * dozens of times per second. So the jump only happens when they were already at\n * the bottom. The slack covers a partially visible last line.\n */\n const trackPosition = (): void => {\n const node = thread.current;\n if (!node) return;\n const next = node.scrollHeight - node.scrollTop - node.clientHeight < BOTTOM_SLACK;\n stuckToBottom.current = next;\n setAtBottom(next);\n };\n\n const jumpToLatest = (): void => {\n const node = thread.current;\n if (!node) return;\n stuckToBottom.current = true;\n setAtBottom(true);\n node.scrollTop = node.scrollHeight;\n };\n\n useLayoutEffect(() => {\n const node = thread.current;\n if (!node || !stuckToBottom.current) return;\n node.scrollTop = node.scrollHeight;\n }, [signature, pending]);\n\n useEffect(() => {\n const node = thread.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n // Mount lands on the newest turn; from then on `trackPosition` decides.\n }, []);\n\n /**\n * Announce the start and the end of a generation, and nothing in between.\n *\n * The transcript is a `role=\"log\"` **without** `aria-live`: a live region over\n * streaming text makes a screen reader read the answer again on every token,\n * which is unusable. The two moments that matter are announced here instead —\n * through the shared `useAnnounce` region rather than a private one, so a page\n * holding a chat plus a table plus toasts still has exactly one live region per\n * politeness — and the finished answer is read from the log at the reader's own\n * pace.\n */\n useEffect(() => {\n if (generating) {\n announce(strings.generating);\n wasGenerating.current = true;\n return;\n }\n if (wasGenerating.current) {\n announce(strings.done);\n wasGenerating.current = false;\n }\n }, [generating, strings.generating, strings.done, announce]);\n\n const showSuggestions = turns.length === 0 && suggestions.length > 0 && onSend !== undefined;\n const detached = !atBottom && turns.length > 0;\n\n return (\n <div className={cn(styles.panel, className)} {...rest}>\n {header && <header className={styles.header}>{header}</header>}\n\n <div className={styles.threadWrapper}>\n <div\n ref={thread}\n className={styles.thread}\n onScroll={trackPosition}\n role=\"log\"\n tabIndex={0}\n aria-label={strings.thread}\n >\n {turns.length === 0 && !showSuggestions\n ? (emptyState ?? (\n <EmptyState title={strings.empty} description={strings.emptyHint} />\n ))\n : turns.map((message) => (\n <AIChatTurn\n key={message.id}\n message={message}\n locale={locale}\n canRegenerate={\n message.id === newestAssistant && message.streaming !== true\n }\n onRegenerate={onRegenerate}\n onFeedback={onFeedback}\n onEditSubmit={onEditSubmit}\n onEditError={onSendError}\n onRetry={onRetry}\n renderAvatar={renderAvatar}\n renderContent={renderContent}\n vote={votes?.[message.id]}\n defaultReasoningOpen={defaultReasoningOpen}\n showLineNumbers={showLineNumbers}\n />\n ))}\n\n {showSuggestions && (\n <div className={styles.suggestions}>\n <p className={styles.suggestionsTitle}>{strings.emptyHint}</p>\n {suggestions.map((suggestion) => (\n <button\n key={suggestion}\n type=\"button\"\n className={styles.suggestion}\n onClick={() => void onSend?.(suggestion)}\n >\n {suggestion}\n </button>\n ))}\n </div>\n )}\n\n {pending && !isGenerating(messages) && (\n <p className={styles.thinking}>\n <span className={styles.dots} aria-hidden=\"true\">\n <span />\n <span />\n <span />\n </span>\n {strings.thinking}\n </p>\n )}\n </div>\n\n {detached && (\n <button\n type=\"button\"\n className={styles.jump}\n onClick={jumpToLatest}\n aria-label={strings.jumpToLatest}\n title={strings.jumpToLatest}\n >\n <ArrowDown size={16} aria-hidden />\n </button>\n )}\n </div>\n\n {onSend && (\n <AIChatComposer\n ref={composerRef ?? composer}\n onSend={onSend}\n onStop={onStop}\n generating={generating}\n locale={locale}\n placeholder={placeholder}\n actions={composerActions}\n footer={composerFooter}\n disabled={composerDisabled}\n maxRows={maxRows}\n onError={onSendError}\n />\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AIChat.js","names":[],"sources":["../../../src/components/AIChat/AIChat.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — the widest surface in\n * the SDK on purpose: it is a whole chat page, and its 25 props are four groups —\n * the transcript (messages, votes, pending, showSystem), the turn actions an app has\n * to own (onSend, onStop, onRegenerate, onEditSubmit, onFeedback, onRetry,\n * onSendError), render slots (renderAvatar, renderContent, header, emptyState,\n * suggestions, composerActions, composerFooter) and composer settings (composerRef,\n * composerDisabled, maxRows, placeholder, locale, defaultReasoningOpen,\n * showLineNumbers). The parts already are separate components — AIChatTurn and\n * AIChatComposer are exported — and this is the assembly whose entire job is to\n * accept the union of their configuration.\n */\nimport { ArrowDown } from \"lucide-react\";\nimport {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ReactNode,\n type Ref,\n} from \"react\";\n\nimport { useAnnounce } from \"@/hooks/use-announce\";\nimport { cn } from \"@/utils/cn\";\n\nimport { EmptyState } from \"../EmptyState\";\nimport {\n aiChatStrings,\n isGenerating,\n lastAssistantId,\n tailSignature,\n visibleTurns,\n type AIChatMessage,\n type AIChatVote,\n} from \"./ai-chat-turns\";\nimport { AIChatComposer, type AIChatComposerHandle } from \"./AIChatComposer\";\nimport { AIChatTurn } from \"./AIChatTurn\";\nimport styles from \"./AIChat.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\" | \"onSubmit\";\n\nexport interface AIChatProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /** The transcript, **oldest first**. Never reordered by the component. */\n messages: readonly AIChatMessage[];\n /** Renders the composer when given. Receives the trimmed prompt. */\n onSend?: (text: string) => void | Promise<void>;\n /** Abort the turn in flight. Shows the stop button while generating. */\n onStop?: () => void;\n /** Ask again for the newest assistant turn. */\n onRegenerate?: (message: AIChatMessage) => void;\n /** Re-submit an edited user turn. The app decides what to drop after it. */\n onEditSubmit?: (message: AIChatMessage, text: string) => void | Promise<void>;\n /** Rating on an assistant turn. */\n onFeedback?: (message: AIChatMessage, vote: AIChatVote) => void;\n /** Retry a turn that carries an `error`. */\n onRetry?: (message: AIChatMessage) => void;\n /**\n * The request is out and nothing has arrived yet.\n *\n * Distinct from a turn with `streaming: true`: apps that only push a message\n * once the first token lands need somewhere to say \"we asked\", and without it the\n * screen is frozen for however long the model takes to start.\n */\n pending?: boolean;\n /** Prompts offered on an empty transcript. Clicking one sends it. */\n suggestions?: readonly string[];\n /** Avatar for a turn — an `<Avatar>`, an `<Icon>`, a logo. */\n renderAvatar?: (message: AIChatMessage) => ReactNode;\n /** Render a body yourself — a tool-call card, a chart, a citation list. */\n renderContent?: (message: AIChatMessage) => ReactNode;\n /** Ratings to show as pressed, by message id. Omit to keep them local. */\n votes?: Readonly<Record<string, AIChatVote>>;\n /** Rendered above the transcript, inside the panel. */\n header?: ReactNode;\n /** Shown when there are no turns and no suggestions. */\n emptyState?: ReactNode;\n /** Show `\"system\"` turns. Default `false`. */\n showSystem?: boolean;\n /** Reasoning blocks start expanded. Default `false`. */\n defaultReasoningOpen?: boolean;\n /** Show line numbers in fenced code. Default `false`. */\n showLineNumbers?: boolean;\n /** Locale for labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Placeholder for the composer. */\n placeholder?: string;\n /** Extra controls inside the composer, before the send button. */\n composerActions?: ReactNode;\n /**\n * Reach the composer imperatively — `focus()`, `getValue()`, `setValue()`.\n *\n * What makes dictation (or a slash-command menu, or \"edit and resend\") possible\n * without this component knowing anything about them: pair it with\n * `composerActions` and the button you put in the composer can write into the\n * field. Speech recognition is **not** wired in here on purpose — it would make\n * every consumer of `AIChat` pay for an API that streams audio to a third party.\n */\n composerRef?: Ref<AIChatComposerHandle>;\n /** Under the composer field — token count, model name, a disclaimer. */\n composerFooter?: ReactNode;\n /** Disable the composer — no credits, conversation archived, offline. */\n composerDisabled?: boolean;\n /** Largest height the composer grows to, in lines. Default 8. */\n maxRows?: number;\n /**\n * Called when `onSend` **or** `onEditSubmit` rejects. The draft stays in the field\n * either way.\n */\n onSendError?: (error: unknown) => void;\n}\n\n/** How close to the bottom still counts as \"reading the newest turn\", in pixels. */\nconst BOTTOM_SLACK = 48;\n\n/**\n * A conversation with a model: role-based turns, Markdown answers, a reasoning\n * block, a streaming caret, per-turn actions and a composer that turns into a stop\n * button while a turn is generating.\n *\n * This is the shape ChatGPT, Claude and DeepSeek converged on, and it is a different\n * component from {@link Chat}, not a variant of it. A human thread is addressed by\n * author and cares about delivery state; a model transcript is addressed by role,\n * has no delivery state at all, and needs three things a human thread never does —\n * partial output, reasoning separate from the answer, and re-asking.\n *\n * Presentational and controlled, like the rest of the SDK: it takes a list and emits\n * intent (`onSend`, `onStop`, `onRegenerate`, `onEditSubmit`, `onFeedback`). The\n * transport stays with the app, because \"how do I stream from my backend\" has a\n * different answer per provider — the SDK's `createEventStream` covers SSE, `fetch`\n * with a `ReadableStream` covers the rest, and either way the app owns the\n * `AbortController` it hands to `onStop`.\n *\n * @example\n * <AIChat\n * messages={turns}\n * pending={pending}\n * onSend={(text) => ask(text)}\n * onStop={() => controller.current?.abort()}\n * onRegenerate={(turn) => reask(turn)}\n * onFeedback={(turn, vote) => track(\"answer_rated\", { id: turn.id, vote })}\n * suggestions={[\"Resuma o último relatório\", \"Quais pedidos atrasaram?\"]}\n * />\n */\nexport function AIChat({\n messages,\n onSend,\n onStop,\n onRegenerate,\n onEditSubmit,\n onFeedback,\n onRetry,\n pending = false,\n suggestions = [],\n renderAvatar,\n renderContent,\n votes,\n header,\n emptyState,\n showSystem = false,\n defaultReasoningOpen = false,\n showLineNumbers = false,\n locale = \"pt-BR\",\n placeholder,\n composerActions,\n composerRef,\n composerFooter,\n composerDisabled,\n maxRows,\n onSendError,\n className,\n ...rest\n}: AIChatProps) {\n const strings = aiChatStrings(locale);\n const turns = visibleTurns({ messages, showSystem });\n const generating = pending || isGenerating(messages);\n const newestAssistant = lastAssistantId(messages);\n const signature = tailSignature(messages);\n\n const composer = useRef<AIChatComposerHandle | null>(null);\n const thread = useRef<HTMLDivElement | null>(null);\n const stuckToBottom = useRef(true);\n const wasGenerating = useRef(false);\n const [atBottom, setAtBottom] = useState(true);\n const announce = useAnnounce();\n\n /**\n * Remember whether the reader is at the bottom, before the next tokens land.\n *\n * A transcript that always scrolls to the newest text yanks somebody out of the\n * answer they were re-reading — and with a streaming answer that would happen\n * dozens of times per second. So the jump only happens when they were already at\n * the bottom. The slack covers a partially visible last line.\n */\n const trackPosition = (): void => {\n const node = thread.current;\n if (!node) return;\n const next = node.scrollHeight - node.scrollTop - node.clientHeight < BOTTOM_SLACK;\n stuckToBottom.current = next;\n setAtBottom(next);\n };\n\n const jumpToLatest = (): void => {\n const node = thread.current;\n if (!node) return;\n stuckToBottom.current = true;\n setAtBottom(true);\n node.scrollTop = node.scrollHeight;\n };\n\n useLayoutEffect(() => {\n const node = thread.current;\n if (!node || !stuckToBottom.current) return;\n node.scrollTop = node.scrollHeight;\n }, [signature, pending]);\n\n useEffect(() => {\n const node = thread.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n // Mount lands on the newest turn; from then on `trackPosition` decides.\n }, []);\n\n /**\n * Announce the start and the end of a generation, and nothing in between.\n *\n * The transcript is a `role=\"log\"` **without** `aria-live`: a live region over\n * streaming text makes a screen reader read the answer again on every token,\n * which is unusable. The two moments that matter are announced here instead —\n * through the shared `useAnnounce` region rather than a private one, so a page\n * holding a chat plus a table plus toasts still has exactly one live region per\n * politeness — and the finished answer is read from the log at the reader's own\n * pace.\n */\n useEffect(() => {\n if (generating) {\n announce(strings.generating);\n wasGenerating.current = true;\n return;\n }\n if (wasGenerating.current) {\n announce(strings.done);\n wasGenerating.current = false;\n }\n }, [generating, strings.generating, strings.done, announce]);\n\n const showSuggestions = turns.length === 0 && suggestions.length > 0 && onSend !== undefined;\n const detached = !atBottom && turns.length > 0;\n\n return (\n <div className={cn(styles.panel, className)} {...rest}>\n {header && <header className={styles.header}>{header}</header>}\n\n <div className={styles.threadWrapper}>\n <div\n ref={thread}\n className={styles.thread}\n onScroll={trackPosition}\n role=\"log\"\n tabIndex={0}\n aria-label={strings.thread}\n >\n {turns.length === 0 && !showSuggestions\n ? (emptyState ?? (\n <EmptyState title={strings.empty} description={strings.emptyHint} />\n ))\n : turns.map((message) => (\n <AIChatTurn\n key={message.id}\n message={message}\n locale={locale}\n canRegenerate={\n message.id === newestAssistant && message.streaming !== true\n }\n onRegenerate={onRegenerate}\n onFeedback={onFeedback}\n onEditSubmit={onEditSubmit}\n onEditError={onSendError}\n onRetry={onRetry}\n renderAvatar={renderAvatar}\n renderContent={renderContent}\n vote={votes?.[message.id]}\n defaultReasoningOpen={defaultReasoningOpen}\n showLineNumbers={showLineNumbers}\n />\n ))}\n\n {showSuggestions && (\n <div className={styles.suggestions}>\n <p className={styles.suggestionsTitle}>{strings.emptyHint}</p>\n {suggestions.map((suggestion) => (\n <button\n key={suggestion}\n type=\"button\"\n className={styles.suggestion}\n onClick={() => void onSend?.(suggestion)}\n >\n {suggestion}\n </button>\n ))}\n </div>\n )}\n\n {pending && !isGenerating(messages) && (\n <p className={styles.thinking}>\n <span className={styles.dots} aria-hidden=\"true\">\n <span />\n <span />\n <span />\n </span>\n {strings.thinking}\n </p>\n )}\n </div>\n\n {detached && (\n <button\n type=\"button\"\n className={styles.jump}\n onClick={jumpToLatest}\n aria-label={strings.jumpToLatest}\n title={strings.jumpToLatest}\n >\n <ArrowDown size={16} aria-hidden />\n </button>\n )}\n </div>\n\n {onSend && (\n <AIChatComposer\n ref={composerRef ?? composer}\n onSend={onSend}\n onStop={onStop}\n generating={generating}\n locale={locale}\n placeholder={placeholder}\n actions={composerActions}\n footer={composerFooter}\n disabled={composerDisabled}\n maxRows={maxRows}\n onError={onSendError}\n />\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AIChatComposer.cjs","names":[],"sources":["../../../src/components/AIChat/AIChatComposer.tsx"],"sourcesContent":["import {\n forwardRef,\n useImperativeHandle,\n useLayoutEffect,\n useRef,\n useState,\n type FormEvent,\n type KeyboardEvent,\n type ReactNode,\n type TextareaHTMLAttributes,\n} from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport { aiChatStrings } from \"./ai-chat-turns\";\nimport styles from \"./AIChat.module.css\";\n\n/** DOM attributes the composer redefines. */\ntype OverriddenDomProps = \"onSubmit\" | \"value\" | \"defaultValue\" | \"rows\";\n\nexport interface AIChatComposerProps extends Omit<\n TextareaHTMLAttributes<HTMLTextAreaElement>,\n OverriddenDomProps\n> {\n /** Called with the trimmed prompt. The field clears only when this does not throw. */\n onSend: (text: string) => void | Promise<void>;\n /**\n * Abort the turn in flight.\n *\n * When given together with `generating`, the send button becomes a stop button\n * and `Escape` aborts too.\n */\n onStop?: () => void;\n /** A turn is being generated. Replaces send with stop and refuses to send. */\n generating?: boolean;\n /** Locale for the placeholder and the button labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Left of the send button — an attach control, a model picker, a tool toggle. */\n actions?: ReactNode;\n /** Under the field — a token count, the model name, a disclaimer. */\n footer?: ReactNode;\n /** Largest height the field grows to, in lines. Default 8. */\n maxRows?: number;\n /**\n * Called when `onSend` rejects. The draft is kept either way.\n *\n * Without it the rejection is swallowed after the draft is preserved: re-throwing\n * out of a DOM event handler surfaces as an unhandled promise rejection, which is\n * console noise for the developer and nothing the user can act on. The visible\n * signal is the prompt still sitting in the field; wire this to a toast to say why.\n */\n onError?: (error: unknown) => void;\n}\n\n/** Imperative handle, so a thread can focus, read or refill the field. */\nexport interface AIChatComposerHandle {\n focus: () => void;\n /** Replace the draft — used to put a prompt back in the field. */\n setValue: (text: string) => void;\n /**\n * The current draft.\n *\n * The counterpart `setValue` needs to be usable for anything **additive**. The\n * field is uncontrolled, so without this the only way to append to a draft — a\n * dictated phrase, a picked slash-command, a pasted citation — is to shadow the\n * whole value in app state through `onChange` and hope the two never drift.\n */\n getValue: () => string;\n}\n\n/**\n * The prompt field of a conversation with a model: a textarea that grows with its\n * content, sends on `Enter`, keeps `Shift+Enter` for a newline, and turns into a\n * stop button while a turn is streaming.\n *\n * Uncontrolled on purpose. A draft changes on every keystroke, and lifting that into\n * app state re-renders the whole transcript per character — with a streaming answer\n * above, that is the one place where \"controlled by default\" costs something\n * visible. Apps that need the draft (a persisted composer, a slash-command menu)\n * read it from `onChange` or drive it through the ref.\n *\n * @example\n * <AIChatComposer\n * generating={generating}\n * onSend={(text) => ask(text)}\n * onStop={() => controller.abort()}\n * footer={<small>Claude Opus 5 · pode errar</small>}\n * />\n */\nexport const AIChatComposer = forwardRef<AIChatComposerHandle, AIChatComposerProps>(\n function AIChatComposer(\n {\n onSend,\n onStop,\n generating = false,\n locale = \"pt-BR\",\n actions,\n footer,\n maxRows = 8,\n onError,\n className,\n disabled,\n placeholder,\n onKeyDown,\n onChange,\n ...rest\n },\n ref,\n ) {\n const strings = aiChatStrings(locale);\n const textarea = useRef<HTMLTextAreaElement | null>(null);\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n\n useImperativeHandle(ref, () => ({\n focus: () => textarea.current?.focus(),\n setValue: (text: string) => {\n setValue(text);\n textarea.current?.focus();\n },\n getValue: () => value,\n }));\n\n /**\n * Grow the field to fit its content, up to `maxRows`.\n *\n * Measured from `scrollHeight` after resetting the height, because\n * `scrollHeight` on an element that is already tall enough reports the current\n * height and the field would never shrink back.\n */\n useLayoutEffect(() => {\n const node = textarea.current;\n if (!node) return;\n node.style.height = \"auto\";\n const lineHeight = Number.parseFloat(getComputedStyle(node).lineHeight) || 20;\n const max = lineHeight * maxRows;\n node.style.height = `${Math.min(node.scrollHeight, max)}px`;\n node.style.overflowY = node.scrollHeight > max ? \"auto\" : \"hidden\";\n }, [value, maxRows]);\n\n const submit = async (): Promise<void> => {\n const text = value.trim();\n if (!text || busy || disabled || generating) return;\n setBusy(true);\n try {\n await onSend(text);\n setValue(\"\");\n } catch (error) {\n onError?.(error);\n } finally {\n setBusy(false);\n }\n };\n\n const handleSubmit = (event: FormEvent): void => {\n event.preventDefault();\n void submit();\n };\n\n /**\n * `Enter` sends, `Shift+Enter` breaks the line, `Escape` aborts a turn in\n * flight.\n *\n * The IME check is not optional: while composing Japanese or Korean, `Enter`\n * confirms the candidate word and `keyCode === 229` marks that keystroke.\n * Sending there would post half a word and eat the confirmation.\n */\n const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {\n onKeyDown?.(event);\n if (event.defaultPrevented) return;\n if (event.key === \"Escape\" && generating && onStop) {\n event.preventDefault();\n onStop();\n return;\n }\n if (event.key !== \"Enter\" || event.shiftKey) return;\n if (event.nativeEvent.isComposing || event.keyCode === 229) return;\n event.preventDefault();\n void submit();\n };\n\n const showStop = generating && onStop !== undefined;\n\n return (\n <form className={cn(styles.composer, className)} onSubmit={handleSubmit}>\n <div className={styles.composerBox}>\n <textarea\n {...rest}\n ref={textarea}\n className={styles.field}\n rows={1}\n value={value}\n disabled={disabled}\n placeholder={placeholder ?? strings.placeholder}\n onChange={(event) => {\n setValue(event.target.value);\n onChange?.(event);\n }}\n onKeyDown={handleKeyDown}\n />\n <div className={styles.composerActions}>\n {actions}\n {showStop ? (\n <button type=\"button\" className={styles.stop} onClick={onStop}>\n <span aria-hidden=\"true\" className={styles.stopGlyph} />\n {strings.stop}\n </button>\n ) : (\n <button\n type=\"submit\"\n className={styles.send}\n disabled={disabled || busy || generating || value.trim() === \"\"}\n >\n {strings.send}\n </button>\n )}\n </div>\n </div>\n {footer && <div className={styles.composerFooter}>{footer}</div>}\n </form>\n );\n },\n);\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AIChatComposer.cjs","names":[],"sources":["../../../src/components/AIChat/AIChatComposer.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count, function-lines — a textarea that grows, submits on\n * Enter, offers a stop button while a turn is generating and clears only when onSend\n * resolves. The eight props are that contract (onSend, onStop, generating, onError)\n * plus its slots (actions, footer) and shape (maxRows, locale); the body is the\n * autosize measurement and the key handling, which both need the same ref and cannot\n * be lifted out of it.\n */\nimport {\n forwardRef,\n useImperativeHandle,\n useLayoutEffect,\n useRef,\n useState,\n type FormEvent,\n type KeyboardEvent,\n type ReactNode,\n type TextareaHTMLAttributes,\n} from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport { aiChatStrings } from \"./ai-chat-turns\";\nimport styles from \"./AIChat.module.css\";\n\n/** DOM attributes the composer redefines. */\ntype OverriddenDomProps = \"onSubmit\" | \"value\" | \"defaultValue\" | \"rows\";\n\nexport interface AIChatComposerProps extends Omit<\n TextareaHTMLAttributes<HTMLTextAreaElement>,\n OverriddenDomProps\n> {\n /** Called with the trimmed prompt. The field clears only when this does not throw. */\n onSend: (text: string) => void | Promise<void>;\n /**\n * Abort the turn in flight.\n *\n * When given together with `generating`, the send button becomes a stop button\n * and `Escape` aborts too.\n */\n onStop?: () => void;\n /** A turn is being generated. Replaces send with stop and refuses to send. */\n generating?: boolean;\n /** Locale for the placeholder and the button labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Left of the send button — an attach control, a model picker, a tool toggle. */\n actions?: ReactNode;\n /** Under the field — a token count, the model name, a disclaimer. */\n footer?: ReactNode;\n /** Largest height the field grows to, in lines. Default 8. */\n maxRows?: number;\n /**\n * Called when `onSend` rejects. The draft is kept either way.\n *\n * Without it the rejection is swallowed after the draft is preserved: re-throwing\n * out of a DOM event handler surfaces as an unhandled promise rejection, which is\n * console noise for the developer and nothing the user can act on. The visible\n * signal is the prompt still sitting in the field; wire this to a toast to say why.\n */\n onError?: (error: unknown) => void;\n}\n\n/** Imperative handle, so a thread can focus, read or refill the field. */\nexport interface AIChatComposerHandle {\n focus: () => void;\n /** Replace the draft — used to put a prompt back in the field. */\n setValue: (text: string) => void;\n /**\n * The current draft.\n *\n * The counterpart `setValue` needs to be usable for anything **additive**. The\n * field is uncontrolled, so without this the only way to append to a draft — a\n * dictated phrase, a picked slash-command, a pasted citation — is to shadow the\n * whole value in app state through `onChange` and hope the two never drift.\n */\n getValue: () => string;\n}\n\n/**\n * The prompt field of a conversation with a model: a textarea that grows with its\n * content, sends on `Enter`, keeps `Shift+Enter` for a newline, and turns into a\n * stop button while a turn is streaming.\n *\n * Uncontrolled on purpose. A draft changes on every keystroke, and lifting that into\n * app state re-renders the whole transcript per character — with a streaming answer\n * above, that is the one place where \"controlled by default\" costs something\n * visible. Apps that need the draft (a persisted composer, a slash-command menu)\n * read it from `onChange` or drive it through the ref.\n *\n * @example\n * <AIChatComposer\n * generating={generating}\n * onSend={(text) => ask(text)}\n * onStop={() => controller.abort()}\n * footer={<small>Claude Opus 5 · pode errar</small>}\n * />\n */\nexport const AIChatComposer = forwardRef<AIChatComposerHandle, AIChatComposerProps>(\n function AIChatComposer(\n {\n onSend,\n onStop,\n generating = false,\n locale = \"pt-BR\",\n actions,\n footer,\n maxRows = 8,\n onError,\n className,\n disabled,\n placeholder,\n onKeyDown,\n onChange,\n ...rest\n },\n ref,\n ) {\n const strings = aiChatStrings(locale);\n const textarea = useRef<HTMLTextAreaElement | null>(null);\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n\n useImperativeHandle(ref, () => ({\n focus: () => textarea.current?.focus(),\n setValue: (text: string) => {\n setValue(text);\n textarea.current?.focus();\n },\n getValue: () => value,\n }));\n\n /**\n * Grow the field to fit its content, up to `maxRows`.\n *\n * Measured from `scrollHeight` after resetting the height, because\n * `scrollHeight` on an element that is already tall enough reports the current\n * height and the field would never shrink back.\n */\n useLayoutEffect(() => {\n const node = textarea.current;\n if (!node) return;\n node.style.height = \"auto\";\n const lineHeight = Number.parseFloat(getComputedStyle(node).lineHeight) || 20;\n const max = lineHeight * maxRows;\n node.style.height = `${Math.min(node.scrollHeight, max)}px`;\n node.style.overflowY = node.scrollHeight > max ? \"auto\" : \"hidden\";\n }, [value, maxRows]);\n\n const submit = async (): Promise<void> => {\n const text = value.trim();\n if (!text || busy || disabled || generating) return;\n setBusy(true);\n try {\n await onSend(text);\n setValue(\"\");\n } catch (error) {\n onError?.(error);\n } finally {\n setBusy(false);\n }\n };\n\n const handleSubmit = (event: FormEvent): void => {\n event.preventDefault();\n void submit();\n };\n\n /**\n * `Enter` sends, `Shift+Enter` breaks the line, `Escape` aborts a turn in\n * flight.\n *\n * The IME check is not optional: while composing Japanese or Korean, `Enter`\n * confirms the candidate word and `keyCode === 229` marks that keystroke.\n * Sending there would post half a word and eat the confirmation.\n */\n const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {\n onKeyDown?.(event);\n if (event.defaultPrevented) return;\n if (event.key === \"Escape\" && generating && onStop) {\n event.preventDefault();\n onStop();\n return;\n }\n if (event.key !== \"Enter\" || event.shiftKey) return;\n if (event.nativeEvent.isComposing || event.keyCode === 229) return;\n event.preventDefault();\n void submit();\n };\n\n const showStop = generating && onStop !== undefined;\n\n return (\n <form className={cn(styles.composer, className)} onSubmit={handleSubmit}>\n <div className={styles.composerBox}>\n <textarea\n {...rest}\n ref={textarea}\n className={styles.field}\n rows={1}\n value={value}\n disabled={disabled}\n placeholder={placeholder ?? strings.placeholder}\n onChange={(event) => {\n setValue(event.target.value);\n onChange?.(event);\n }}\n onKeyDown={handleKeyDown}\n />\n <div className={styles.composerActions}>\n {actions}\n {showStop ? (\n <button type=\"button\" className={styles.stop} onClick={onStop}>\n <span aria-hidden=\"true\" className={styles.stopGlyph} />\n {strings.stop}\n </button>\n ) : (\n <button\n type=\"submit\"\n className={styles.send}\n disabled={disabled || busy || generating || value.trim() === \"\"}\n >\n {strings.send}\n </button>\n )}\n </div>\n </div>\n {footer && <div className={styles.composerFooter}>{footer}</div>}\n </form>\n );\n },\n);\n"],"mappings":"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"}
|