@agent-native/core 0.76.11 → 0.76.13

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.
Files changed (58) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +19 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +7 -4
  5. package/corpus/core/src/client/FeedbackButton.tsx +162 -18
  6. package/corpus/core/src/client/blocks/library/AnnotatedCodeBlock.tsx +11 -2
  7. package/corpus/core/src/client/blocks/library/ApiEndpointBlock.tsx +46 -19
  8. package/corpus/core/src/client/blocks/library/MermaidBlock.tsx +7 -5
  9. package/corpus/core/src/client/blocks/library/block-copy.ts +367 -0
  10. package/corpus/core/src/client/blocks/library/diagram.tsx +8 -6
  11. package/corpus/core/src/client/blocks/library/wireframe.tsx +7 -2
  12. package/corpus/core/src/client/i18n.tsx +4 -0
  13. package/corpus/templates/clips/app/components/recorder/camera-visualizer.tsx +88 -10
  14. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +64 -0
  15. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +94 -9
  16. package/corpus/templates/clips/app/lib/camera-blur.ts +341 -0
  17. package/corpus/templates/clips/app/lib/camera-composite.ts +6 -1
  18. package/corpus/templates/clips/app/lib/recorder-preferences.ts +15 -0
  19. package/corpus/templates/clips/app/routes/record.tsx +13 -0
  20. package/corpus/templates/clips/desktop/src/app.tsx +101 -69
  21. package/corpus/templates/clips/desktop/src/lib/voice-dictation.ts +109 -11
  22. package/corpus/templates/clips/desktop/src/styles.css +19 -4
  23. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +1 -1
  24. package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +143 -86
  25. package/corpus/templates/clips/package.json +1 -0
  26. package/corpus/templates/clips/vite.config.ts +38 -1
  27. package/corpus/templates/mail/app/root.tsx +90 -6
  28. package/dist/agent/production-agent.d.ts +7 -4
  29. package/dist/agent/production-agent.d.ts.map +1 -1
  30. package/dist/agent/production-agent.js +7 -4
  31. package/dist/agent/production-agent.js.map +1 -1
  32. package/dist/client/FeedbackButton.d.ts.map +1 -1
  33. package/dist/client/FeedbackButton.js +142 -14
  34. package/dist/client/FeedbackButton.js.map +1 -1
  35. package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
  36. package/dist/client/blocks/library/AnnotatedCodeBlock.js +6 -2
  37. package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
  38. package/dist/client/blocks/library/ApiEndpointBlock.d.ts.map +1 -1
  39. package/dist/client/blocks/library/ApiEndpointBlock.js +14 -6
  40. package/dist/client/blocks/library/ApiEndpointBlock.js.map +1 -1
  41. package/dist/client/blocks/library/MermaidBlock.d.ts.map +1 -1
  42. package/dist/client/blocks/library/MermaidBlock.js +6 -4
  43. package/dist/client/blocks/library/MermaidBlock.js.map +1 -1
  44. package/dist/client/blocks/library/block-copy.d.ts +33 -0
  45. package/dist/client/blocks/library/block-copy.d.ts.map +1 -0
  46. package/dist/client/blocks/library/block-copy.js +329 -0
  47. package/dist/client/blocks/library/block-copy.js.map +1 -0
  48. package/dist/client/blocks/library/diagram.d.ts.map +1 -1
  49. package/dist/client/blocks/library/diagram.js +7 -5
  50. package/dist/client/blocks/library/diagram.js.map +1 -1
  51. package/dist/client/blocks/library/wireframe.d.ts.map +1 -1
  52. package/dist/client/blocks/library/wireframe.js +4 -2
  53. package/dist/client/blocks/library/wireframe.js.map +1 -1
  54. package/dist/client/i18n.d.ts +1 -0
  55. package/dist/client/i18n.d.ts.map +1 -1
  56. package/dist/client/i18n.js +3 -0
  57. package/dist/client/i18n.js.map +1 -1
  58. package/package.json +1 -1
@@ -3,6 +3,7 @@ import type {
3
3
  DisplaySurface,
4
4
  RecordingMode,
5
5
  } from "@/components/recorder/recorder-engine";
6
+ import { MAX_BLUR_PX, MIN_BLUR_PX } from "@/lib/camera-blur";
6
7
 
7
8
  /**
8
9
  * Last-used recorder selections, remembered across `/record` visits via
@@ -21,6 +22,8 @@ export interface RecorderPreferences {
21
22
  micId: string;
22
23
  cameraId: string;
23
24
  cameraSize: CameraBubbleSize;
25
+ cameraBlur: boolean;
26
+ cameraBlurRadius: number;
24
27
  }
25
28
 
26
29
  const STORAGE_KEY = "clips:recorder-preferences";
@@ -47,6 +50,18 @@ export function loadRecorderPreferences(): Partial<RecorderPreferences> {
47
50
  if (VALID_SIZES.includes(parsed.cameraSize as CameraBubbleSize)) {
48
51
  prefs.cameraSize = parsed.cameraSize as CameraBubbleSize;
49
52
  }
53
+ if (typeof parsed.cameraBlur === "boolean") {
54
+ prefs.cameraBlur = parsed.cameraBlur;
55
+ }
56
+ if (
57
+ typeof parsed.cameraBlurRadius === "number" &&
58
+ Number.isFinite(parsed.cameraBlurRadius)
59
+ ) {
60
+ prefs.cameraBlurRadius = Math.min(
61
+ MAX_BLUR_PX,
62
+ Math.max(MIN_BLUR_PX, parsed.cameraBlurRadius),
63
+ );
64
+ }
50
65
  return prefs;
51
66
  } catch {
52
67
  return {};
@@ -874,6 +874,8 @@ export default function RecordRoute() {
874
874
  displaySurface: DisplaySurface;
875
875
  micDeviceId: string | null;
876
876
  cameraDeviceId: string | null;
877
+ cameraBlur: boolean;
878
+ cameraBlurRadius: number;
877
879
  } | null>(null);
878
880
  const tickRef = useRef<number | null>(null);
879
881
  const previewVideoRef = useRef<HTMLVideoElement>(null);
@@ -949,6 +951,8 @@ export default function RecordRoute() {
949
951
  displaySurface: DisplaySurface;
950
952
  micDeviceId: string | null;
951
953
  cameraDeviceId: string | null;
954
+ cameraBlur: boolean;
955
+ cameraBlurRadius: number;
952
956
  }) => {
953
957
  const blockedFeature = isEmbeddedWindow()
954
958
  ? getPolicyBlockedCaptureLabel({
@@ -994,6 +998,8 @@ export default function RecordRoute() {
994
998
  micDeviceId: opts.micDeviceId,
995
999
  cameraDeviceId: opts.cameraDeviceId,
996
1000
  cameraBubbleSize: cameraSize,
1001
+ cameraBlur: opts.cameraBlur,
1002
+ cameraBlurRadius: opts.cameraBlurRadius,
997
1003
  uploadUrl: "",
998
1004
  abortUrl: "",
999
1005
  onError: (err) => {
@@ -1007,6 +1013,13 @@ export default function RecordRoute() {
1007
1013
  onWarning: (message) => {
1008
1014
  toast.warning(message);
1009
1015
  },
1016
+ // Camera ended (unplugged / revoked): drop the on-page bubble, and in
1017
+ // camera-only mode clear the fullscreen preview too (it renders from
1018
+ // previewStream) so neither freezes on the last frame.
1019
+ onCameraEnded: () => {
1020
+ setCameraStream(null);
1021
+ if (opts.mode === "camera") setPreviewStream(null);
1022
+ },
1010
1023
  // Track the surface the user actually chose (and any mid-recording
1011
1024
  // switch) so the live camera bubble is hidden only when the full
1012
1025
  // screen — including this tab's overlay — is being captured.
@@ -295,7 +295,7 @@ function installAuthFetchInterceptor(): void {
295
295
  }
296
296
 
297
297
  type ByokVoiceProvider = Extract<VoiceProvider, "gemini" | "groq">;
298
- type VoiceProviderMode = "native" | "builder" | "byok";
298
+ type VoiceProviderMode = "native" | "whisper" | "builder" | "byok";
299
299
  type MacosPrivacyPane =
300
300
  | "camera"
301
301
  | "microphone"
@@ -368,6 +368,7 @@ function isByokVoiceProvider(value: VoiceProvider): value is ByokVoiceProvider {
368
368
  function voiceProviderMode(value: VoiceProvider): VoiceProviderMode {
369
369
  if (isByokVoiceProvider(value)) return "byok";
370
370
  if (value === "builder" || value === "builder-gemini") return "builder";
371
+ if (value === "whisper") return "whisper";
371
372
  return "native";
372
373
  }
373
374
 
@@ -378,6 +379,7 @@ function normalizeVoiceProvider(value: string): VoiceProvider {
378
379
  if (value === "macos-native" && !isMacPlatform()) return "browser";
379
380
  return value === "browser" ||
380
381
  value === "macos-native" ||
382
+ value === "whisper" ||
381
383
  value === "builder-gemini" ||
382
384
  value === "gemini" ||
383
385
  value === "groq"
@@ -2987,7 +2989,11 @@ function Setup({
2987
2989
  }).catch((err) =>
2988
2990
  console.error("[settings] set_feature_config failed", err),
2989
2991
  );
2990
- if (enabled) triggerWhisperDownload();
2992
+ if (enabled) {
2993
+ triggerWhisperDownload();
2994
+ } else if (voiceProvider === "whisper") {
2995
+ onVoiceProviderChange(nativeVoiceProvider());
2996
+ }
2991
2997
  }
2992
2998
 
2993
2999
  function setLaunchAtLoginEnabled(enabled: boolean) {
@@ -3223,6 +3229,7 @@ function Setup({
3223
3229
  native: isMacPlatform()
3224
3230
  ? "Uses macOS on-device speech recognition for the fastest free dictation."
3225
3231
  : "Uses the browser's built-in speech recognition when available.",
3232
+ whisper: "Uses the local Whisper model for offline AI transcription.",
3226
3233
  builder:
3227
3234
  "Uses Builder.io for fast cleanup. No separate provider key needed.",
3228
3235
  byok: "Use your own provider key for cleanup.",
@@ -3245,6 +3252,9 @@ function Setup({
3245
3252
  setApiKeyMessage(null);
3246
3253
  if (mode === "native") {
3247
3254
  onVoiceProviderChange(nativeVoiceProvider());
3255
+ } else if (mode === "whisper") {
3256
+ onVoiceProviderChange("whisper");
3257
+ if (!whisperModelEnabled) setWhisperModelEnabled(true);
3248
3258
  } else if (mode === "builder") {
3249
3259
  onVoiceProviderChange("builder-gemini");
3250
3260
  } else {
@@ -3333,6 +3343,7 @@ function Setup({
3333
3343
  const providerWarning: string | null = (() => {
3334
3344
  if (providerStatusLoading || !providerStatus) return null;
3335
3345
  if (selectedMode === "native") return null;
3346
+ if (selectedMode === "whisper") return null;
3336
3347
  if (selectedMode === "builder") {
3337
3348
  return providerStatus.builder
3338
3349
  ? null
@@ -3358,6 +3369,8 @@ function Setup({
3358
3369
  <h2>Settings</h2>
3359
3370
  </div>
3360
3371
 
3372
+ <div className="setup-section-heading">General</div>
3373
+
3361
3374
  <div className="setup-section">
3362
3375
  <SettingLabel
3363
3376
  label="Clips server URL"
@@ -3419,6 +3432,23 @@ function Setup({
3419
3432
  </div>
3420
3433
  </div>
3421
3434
 
3435
+ <div className="setup-section-heading">Permissions</div>
3436
+
3437
+ <div className="setup-section">
3438
+ <ReadinessPanel
3439
+ mode="screen-camera"
3440
+ cameraOn={true}
3441
+ micOn={true}
3442
+ includeVoicePaste={voiceEnabled}
3443
+ includeFnMonitoring={fnShortcutSelected}
3444
+ open={readinessOpen}
3445
+ onOpenChange={setReadinessOpen}
3446
+ onOpenPermission={openPrivacySettings}
3447
+ />
3448
+ </div>
3449
+
3450
+ <div className="setup-section-heading">Recording</div>
3451
+
3422
3452
  <details className="setup-advanced">
3423
3453
  <summary className="setup-advanced-summary">Advanced recording</summary>
3424
3454
  <div className="setup-advanced-body">
@@ -3502,19 +3532,26 @@ function Setup({
3502
3532
  </details>
3503
3533
 
3504
3534
  <div className="setup-section">
3505
- <div className="setup-toggle-row">
3506
- <SettingLabel
3507
- label="Voice dictation"
3508
- hint="Speak to type anywhere on your Mac. Turn off to disable globally and remove the keyboard shortcuts."
3509
- />
3510
- <Switch
3511
- on={voiceEnabled}
3512
- onChange={setVoiceEnabled}
3513
- label="Enable voice dictation"
3514
- />
3515
- </div>
3535
+ <SettingLabel
3536
+ label="Open Clips shortcut"
3537
+ hint="Optional extra global shortcut for opening the tray popover. Cmd+Shift+L remains available."
3538
+ />
3539
+ <ShortcutRecorder
3540
+ value={popoverCustomShortcut}
3541
+ placeholder="Record shortcut"
3542
+ onChange={onPopoverCustomShortcutChange}
3543
+ />
3544
+ <p className="setup-hint">
3545
+ Use a modifier combination like Cmd+Shift+K. Leave empty to use only
3546
+ Cmd+Shift+L.
3547
+ </p>
3548
+ {shortcutRegistrationError ? (
3549
+ <p className="setup-warning">{shortcutRegistrationError}</p>
3550
+ ) : null}
3516
3551
  </div>
3517
3552
 
3553
+ <div className="setup-section-heading">Meetings</div>
3554
+
3518
3555
  <div className="setup-section">
3519
3556
  <div className="setup-toggle-row">
3520
3557
  <SettingLabel
@@ -3557,25 +3594,6 @@ function Setup({
3557
3594
  </p>
3558
3595
  </div>
3559
3596
 
3560
- <div className="setup-section">
3561
- <div className="setup-toggle-row">
3562
- <SettingLabel
3563
- label="Whisper model"
3564
- hint="Local AI model for offline meeting transcription. Captures both your mic and other speakers — no API key required."
3565
- />
3566
- <Switch
3567
- on={whisperModelEnabled}
3568
- onChange={setWhisperModelEnabled}
3569
- label="Enable Whisper model"
3570
- />
3571
- </div>
3572
- <WhisperModelStatusRow
3573
- status={whisperStatus}
3574
- enabled={whisperModelEnabled}
3575
- onDownload={triggerWhisperDownload}
3576
- />
3577
- </div>
3578
-
3579
3597
  <div className="setup-section">
3580
3598
  <div className="setup-toggle-row">
3581
3599
  <SettingLabel
@@ -3592,40 +3610,41 @@ function Setup({
3592
3610
  </>
3593
3611
  ) : null}
3594
3612
 
3613
+ <div className="setup-section-heading">Whisper</div>
3614
+
3595
3615
  <div className="setup-section">
3596
- <SettingLabel
3597
- label="Open Clips shortcut"
3598
- hint="Optional extra global shortcut for opening the tray popover. Cmd+Shift+L remains available."
3599
- />
3600
- <ShortcutRecorder
3601
- value={popoverCustomShortcut}
3602
- placeholder="Record shortcut"
3603
- onChange={onPopoverCustomShortcutChange}
3616
+ <div className="setup-toggle-row">
3617
+ <SettingLabel
3618
+ label="Whisper model"
3619
+ hint="Local AI model for offline transcription (dictation and meetings). No API key required."
3620
+ />
3621
+ <Switch
3622
+ on={whisperModelEnabled}
3623
+ onChange={setWhisperModelEnabled}
3624
+ label="Enable Whisper model"
3625
+ />
3626
+ </div>
3627
+ <WhisperModelStatusRow
3628
+ status={whisperStatus}
3629
+ enabled={whisperModelEnabled}
3630
+ onDownload={triggerWhisperDownload}
3604
3631
  />
3605
- <p className="setup-hint">
3606
- Use a modifier combination like Cmd+Shift+K. Leave empty to use only
3607
- Cmd+Shift+L.
3608
- </p>
3609
- {shortcutRegistrationError ? (
3610
- <p className="setup-warning">{shortcutRegistrationError}</p>
3611
- ) : null}
3612
3632
  </div>
3613
3633
 
3634
+ <div className="setup-section-heading">Dictation</div>
3635
+
3614
3636
  <div className="setup-section">
3615
- <SettingLabel
3616
- label="Privacy permissions"
3617
- hint="Open the exact Privacy & Security pane for each permission Clips can need."
3618
- />
3619
- <ReadinessPanel
3620
- mode="screen-camera"
3621
- cameraOn={true}
3622
- micOn={true}
3623
- includeVoicePaste={voiceEnabled}
3624
- includeFnMonitoring={fnShortcutSelected}
3625
- open={readinessOpen}
3626
- onOpenChange={setReadinessOpen}
3627
- onOpenPermission={openPrivacySettings}
3628
- />
3637
+ <div className="setup-toggle-row">
3638
+ <SettingLabel
3639
+ label="Voice dictation"
3640
+ hint="Speak to type anywhere on your Mac. Turn off to disable globally and remove the keyboard shortcuts."
3641
+ />
3642
+ <Switch
3643
+ on={voiceEnabled}
3644
+ onChange={setVoiceEnabled}
3645
+ label="Enable voice dictation"
3646
+ />
3647
+ </div>
3629
3648
  </div>
3630
3649
 
3631
3650
  {voiceEnabled ? (
@@ -3645,10 +3664,21 @@ function Setup({
3645
3664
  }
3646
3665
  >
3647
3666
  <option value="native">On-device (free, fast)</option>
3667
+ <option value="whisper" disabled={!whisperModelEnabled}>
3668
+ {whisperModelEnabled
3669
+ ? "Local Whisper (offline AI)"
3670
+ : "Local Whisper — enable Whisper model first"}
3671
+ </option>
3648
3672
  <option value="builder">Builder.io</option>
3649
3673
  <option value="byok">Add your own key</option>
3650
3674
  </select>
3651
3675
  <p className="setup-hint">{providerHint[selectedMode]}</p>
3676
+ {selectedMode === "whisper" && !whisperModelEnabled ? (
3677
+ <p className="setup-warning">
3678
+ Whisper model is disabled. Enable it in the Whisper section
3679
+ above.
3680
+ </p>
3681
+ ) : null}
3652
3682
  {providerWarning ? (
3653
3683
  <p className="setup-warning">{providerWarning}</p>
3654
3684
  ) : null}
@@ -3697,9 +3727,10 @@ function Setup({
3697
3727
  }}
3698
3728
  placeholder={
3699
3729
  providerStatus?.[byokProvider]
3700
- ? "Paste a new key to rotate"
3701
- : `Paste ${keyForByokProvider(byokProvider)}`
3730
+ ? "Key is saved paste to rotate"
3731
+ : `Paste ${keyForByokProvider(byokProvider)} here`
3702
3732
  }
3733
+ className="setup-key-input"
3703
3734
  />
3704
3735
  <button
3705
3736
  type="button"
@@ -3733,7 +3764,7 @@ function Setup({
3733
3764
  </div>
3734
3765
  ) : null}
3735
3766
 
3736
- {selectedMode !== "native" ? (
3767
+ {selectedMode !== "native" && selectedMode !== "whisper" ? (
3737
3768
  <div className="setup-section">
3738
3769
  <SettingLabel
3739
3770
  label="Custom instructions"
@@ -3819,7 +3850,10 @@ function Setup({
3819
3850
  </div>
3820
3851
  </>
3821
3852
  ) : null}
3822
- <div className="setup-account">
3853
+
3854
+ <div className="setup-section-heading">Debug</div>
3855
+
3856
+ <div className="setup-account setup-account--no-border">
3823
3857
  <button
3824
3858
  type="button"
3825
3859
  className="link-button"
@@ -3906,10 +3940,8 @@ function WhisperModelStatusRow({
3906
3940
  return (
3907
3941
  <div className="whisper-status whisper-status-ready">
3908
3942
  <IconCircleCheck size={13} className="whisper-status-icon" />
3909
- <span>
3910
- Ready · {status.totalMb} MB
3911
- <span className="whisper-status-path">{status.path}</span>
3912
- </span>
3943
+ <span>Ready · {status.totalMb} MB</span>
3944
+ <span className="whisper-status-path">{status.path}</span>
3913
3945
  </div>
3914
3946
  );
3915
3947
  }
@@ -32,6 +32,7 @@ export type VoiceProvider =
32
32
  | "auto"
33
33
  | "browser"
34
34
  | "macos-native"
35
+ | "whisper"
35
36
  | "builder-gemini"
36
37
  | "builder"
37
38
  | "gemini"
@@ -82,8 +83,10 @@ interface VoiceSession {
82
83
  // webkitSpeechRecognition (works in Safari and Chromium WebViews,
83
84
  // broken in Tauri WKWebView). "native" sessions drive Apple's
84
85
  // SFSpeechRecognizer + AVAudioEngine through Tauri commands —
85
- // on-device, real-time partials, free, macOS-only.
86
- kind: "server" | "browser" | "native";
86
+ // on-device, real-time partials, free, macOS-only. "whisper" sessions
87
+ // drive the local whisper.cpp engine via audio_transcription_* commands,
88
+ // mic-only (captureSystem: false), no API key required.
89
+ kind: "server" | "browser" | "native" | "whisper";
87
90
  // server-only fields
88
91
  stream: MediaStream | null;
89
92
  recorder: MediaRecorder | null;
@@ -496,6 +499,7 @@ export function installDesktopVoiceDictation(
496
499
  const resolveProvider = async (): Promise<
497
500
  | { kind: "browser"; cleanupProvider?: ServerVoiceProvider }
498
501
  | { kind: "native"; cleanupProvider?: ServerVoiceProvider }
502
+ | { kind: "whisper"; cleanupProvider?: ServerVoiceProvider }
499
503
  | {
500
504
  kind: "server";
501
505
  providerPref: ServerVoiceProvider;
@@ -515,6 +519,7 @@ export function installDesktopVoiceDictation(
515
519
  return { kind: "browser" };
516
520
  }
517
521
  if (provider === "macos-native") return { kind: "native" };
522
+ if (provider === "whisper") return { kind: "whisper" };
518
523
  if (provider !== "auto") {
519
524
  const cleanupProvider =
520
525
  provider === "builder" ? "builder-gemini" : provider;
@@ -605,6 +610,8 @@ export function installDesktopVoiceDictation(
605
610
  await startBrowser(resolved.cleanupProvider);
606
611
  } else if (resolved.kind === "native") {
607
612
  await startNative(resolved.cleanupProvider);
613
+ } else if (resolved.kind === "whisper") {
614
+ await startWhisper(resolved.cleanupProvider);
608
615
  } else {
609
616
  await startServer(resolved.providerPref);
610
617
  }
@@ -943,6 +950,80 @@ export function installDesktopVoiceDictation(
943
950
  }
944
951
  };
945
952
 
953
+ /**
954
+ * Whisper path: local whisper.cpp engine via audio_transcription_* Tauri
955
+ * commands. Mic-only (captureSystem: false) — same linger/finalize flow
956
+ * as the native path, same voice:*-transcript events from Rust.
957
+ */
958
+ const startWhisper = async (cleanupProvider?: ServerVoiceProvider) => {
959
+ console.log(
960
+ "[voice-dictation] startWhisper: invoke audio_transcription_start",
961
+ );
962
+ try {
963
+ // Open the mic BEFORE showing the bar so audio is capturing by the time
964
+ // the user sees the recording state and starts speaking. The inverse order
965
+ // (bar first, then start) causes the mic to open ~100-300ms late and the
966
+ // first spoken words are lost inside audio_transcription_start's
967
+ // ensure_model + create_state + start_raw_mic_capture sequence.
968
+ await invoke("audio_transcription_start", {
969
+ meetingId: null,
970
+ locale: navigator.language || "en-US",
971
+ micDeviceId: concreteMediaDeviceId(micDeviceId) || null,
972
+ micDeviceLabel: micDeviceLabel || null,
973
+ captureSystem: false,
974
+ });
975
+ console.log("[voice-dictation] audio_transcription_start ok");
976
+ if (disposed || stopRequestedBeforeReady) {
977
+ invoke("audio_transcription_stop").catch(() => {});
978
+ abortPendingStart();
979
+ return;
980
+ }
981
+ await invoke("show_flow_bar");
982
+ if (disposed || stopRequestedBeforeReady) {
983
+ invoke("audio_transcription_stop").catch(() => {});
984
+ abortPendingStart();
985
+ return;
986
+ }
987
+ setFlowState("recording");
988
+ emit("voice:partial-transcript", { text: "" }).catch(() => {});
989
+ const next: VoiceSession = {
990
+ kind: "whisper",
991
+ stream: null,
992
+ recorder: null,
993
+ chunks: [],
994
+ audioContext: null,
995
+ analyser: null,
996
+ raf: null,
997
+ mimeType: "",
998
+ recognition: null,
999
+ browserTranscript: "",
1000
+ lastResultAt: 0,
1001
+ startedAt: Date.now(),
1002
+ stopping: false,
1003
+ transcribeAbort: null,
1004
+ cancelled: false,
1005
+ cleanupProvider: cleanupProvider ?? null,
1006
+ };
1007
+ session = next;
1008
+ startInFlight = false;
1009
+ startSyntheticMeter(next);
1010
+ if (stopRequestedBeforeReady) {
1011
+ stop();
1012
+ }
1013
+ } catch (err) {
1014
+ console.error("[voice-dictation] startWhisper failed", err);
1015
+ startInFlight = false;
1016
+ stopRequestedBeforeReady = false;
1017
+ session = null;
1018
+ setFlowState("error");
1019
+ window.setTimeout(() => {
1020
+ if (disposed || session) return;
1021
+ setFlowState("idle");
1022
+ invoke("hide_flow_bar").catch(() => {});
1023
+ }, 800);
1024
+ }
1025
+ };
1026
+
946
1027
  /**
947
1028
  * Browser-path: real-time on-device transcription via WKWebView's
948
1029
  * webkitSpeechRecognition. No server round-trip — text is ready the
@@ -1172,6 +1253,13 @@ export function installDesktopVoiceDictation(
1172
1253
  invoke("native_speech_cancel").catch((err) => {
1173
1254
  console.warn("[voice-dictation] native_speech_cancel failed:", err);
1174
1255
  });
1256
+ } else if (current.kind === "whisper") {
1257
+ invoke("audio_transcription_stop").catch((err) => {
1258
+ console.warn(
1259
+ "[voice-dictation] audio_transcription_stop (cancel) failed:",
1260
+ err,
1261
+ );
1262
+ });
1175
1263
  } else {
1176
1264
  try {
1177
1265
  current.recognition?.abort();
@@ -1225,6 +1313,8 @@ export function installDesktopVoiceDictation(
1225
1313
  }
1226
1314
  } else if (current.kind === "native") {
1227
1315
  invoke("native_speech_cancel").catch(() => {});
1316
+ } else if (current.kind === "whisper") {
1317
+ invoke("audio_transcription_stop").catch(() => {});
1228
1318
  }
1229
1319
  cleanup(current);
1230
1320
  return;
@@ -1232,15 +1322,19 @@ export function installDesktopVoiceDictation(
1232
1322
  try {
1233
1323
  if (current.kind === "server") {
1234
1324
  current.recorder?.stop();
1235
- } else if (current.kind === "native") {
1236
- // NATIVE PATH: dismiss the pill *immediately* (snappy UX) but
1237
- // leave the transcript chip lingering. Tell Rust to `endAudio()`
1238
- // so SFSpeechRecognizer can deliver its final hypothesis. When
1325
+ } else if (current.kind === "native" || current.kind === "whisper") {
1326
+ // NATIVE / WHISPER PATH: dismiss the pill *immediately* (snappy UX)
1327
+ // but leave the transcript chip lingering. Tell Rust to end the
1328
+ // engine so it can deliver its final hypothesis. When
1239
1329
  // `voice:final-transcript` lands (or after a safety timeout),
1240
1330
  // paste the text and let the chip sit for ~1s with the final
1241
1331
  // word visible — like a notification fading — then dismiss.
1242
- invoke("native_speech_stop").catch((err) => {
1243
- console.warn("[voice-dictation] native_speech_stop failed:", err);
1332
+ const stopCmd =
1333
+ current.kind === "whisper"
1334
+ ? "audio_transcription_stop"
1335
+ : "native_speech_stop";
1336
+ invoke(stopCmd).catch((err) => {
1337
+ console.warn(`[voice-dictation] ${stopCmd} failed:`, err);
1244
1338
  });
1245
1339
  // Pill goes RIGHT NOW. The flow-bar window stays open (we'll
1246
1340
  // hide it after the linger) but renders only the transcript
@@ -1576,7 +1670,8 @@ export function installDesktopVoiceDictation(
1576
1670
  // don't re-emit it here.
1577
1671
  onPartialTranscript(({ text }) => {
1578
1672
  const current = session;
1579
- if (!current || current.kind !== "native") return;
1673
+ if (!current || (current.kind !== "native" && current.kind !== "whisper"))
1674
+ return;
1580
1675
  if (current.cancelled || current.stopping) return;
1581
1676
  current.browserTranscript = text.trim();
1582
1677
  })
@@ -1590,7 +1685,9 @@ export function installDesktopVoiceDictation(
1590
1685
  // late-arriving final from the previous session would otherwise
1591
1686
  // overwrite the new session's transcript with stale text.
1592
1687
  const current =
1593
- lingeringSession && lingeringSession.kind === "native"
1688
+ lingeringSession &&
1689
+ (lingeringSession.kind === "native" ||
1690
+ lingeringSession.kind === "whisper")
1594
1691
  ? lingeringSession
1595
1692
  : null;
1596
1693
  if (!current) return;
@@ -1607,7 +1704,8 @@ export function installDesktopVoiceDictation(
1607
1704
  onSpeechError(({ error }) => {
1608
1705
  const current = session;
1609
1706
  console.error("[voice-dictation] native speech error:", error);
1610
- if (!current || current.kind !== "native") return;
1707
+ if (!current || (current.kind !== "native" && current.kind !== "whisper"))
1708
+ return;
1611
1709
  setFlowState("error");
1612
1710
  window.setTimeout(() => {
1613
1711
  if (!disposed && session === current) cleanup(current);
@@ -1314,6 +1314,17 @@ body[data-clips-route="recording-pill"] #root {
1314
1314
  gap: 6px;
1315
1315
  }
1316
1316
 
1317
+ .setup-section-heading {
1318
+ font-size: 10px;
1319
+ font-weight: 700;
1320
+ letter-spacing: 0.07em;
1321
+ text-transform: uppercase;
1322
+ color: var(--fg-muted);
1323
+ padding-top: 6px;
1324
+ border-top: 1px solid var(--border);
1325
+ margin-top: 4px;
1326
+ }
1327
+
1317
1328
  .setup-advanced {
1318
1329
  border: 1px solid var(--border);
1319
1330
  border-radius: var(--radius-sm);
@@ -1638,6 +1649,11 @@ body[data-clips-route="recording-pill"] #root {
1638
1649
  font-size: 12px;
1639
1650
  }
1640
1651
 
1652
+ .setup-account--no-border {
1653
+ border-top: none;
1654
+ padding-top: 0;
1655
+ }
1656
+
1641
1657
  .setup-account-email {
1642
1658
  color: var(--fg-muted);
1643
1659
  overflow: hidden;
@@ -4200,9 +4216,9 @@ body[data-clips-route="recording-pill"] #root {
4200
4216
 
4201
4217
  .whisper-status {
4202
4218
  display: flex;
4203
- align-items: flex-start;
4219
+ align-items: center;
4204
4220
  flex-wrap: wrap;
4205
- gap: 6px;
4221
+ gap: 4px 6px;
4206
4222
  margin-top: 6px;
4207
4223
  font-size: 11px;
4208
4224
  line-height: 1.4;
@@ -4237,8 +4253,7 @@ body[data-clips-route="recording-pill"] #root {
4237
4253
  }
4238
4254
 
4239
4255
  .whisper-status-path {
4240
- display: block;
4241
- margin-top: 2px;
4256
+ flex-basis: 100%;
4242
4257
  font-size: 10px;
4243
4258
  color: var(--fg-subtle);
4244
4259
  word-break: break-all;
@@ -69,7 +69,7 @@ libc = "0.2"
69
69
  [target."cfg(target_os = \"macos\")".dependencies]
70
70
  core-foundation = "0.10"
71
71
  core-graphics = "0.25"
72
- objc2 = "0.6"
72
+ objc2 = { version = "0.6", features = ["exception"] }
73
73
  # Native on-device dictation via SFSpeechRecognizer + AVAudioEngine.
74
74
  # Powers the "browser" voice-dictation provider (free, instant, no API
75
75
  # key, no server round-trip — same engine WisprFlow uses for offline).