@agent-native/core 0.114.13 → 0.114.16

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 (163) hide show
  1. package/bin/agent-native.js +35 -14
  2. package/bin/launcher.js +34 -0
  3. package/corpus/README.md +2 -2
  4. package/corpus/core/CHANGELOG.md +21 -0
  5. package/corpus/core/bin/agent-native.js +35 -14
  6. package/corpus/core/bin/launcher.js +34 -0
  7. package/corpus/core/docs/content/deployment.mdx +1 -1
  8. package/corpus/core/docs/content/frames.mdx +1 -1
  9. package/corpus/core/docs/content/integrations.mdx +27 -27
  10. package/corpus/core/docs/content/pr-visual-recap.mdx +17 -13
  11. package/corpus/core/docs/content/workspace-management.mdx +1 -1
  12. package/corpus/core/package.json +2 -1
  13. package/corpus/core/src/agent/engine/builder-engine.ts +4 -0
  14. package/corpus/core/src/client/AgentPanel.tsx +10 -4
  15. package/corpus/core/src/client/ConnectBuilderCard.tsx +5 -1
  16. package/corpus/core/src/client/chat/markdown-renderer.tsx +3 -5
  17. package/corpus/core/src/client/error-format.ts +2 -1
  18. package/corpus/core/src/client/index.ts +1 -0
  19. package/corpus/core/src/client/integrations/IntegrationsPanel.tsx +2 -18
  20. package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +43 -32
  21. package/corpus/core/src/client/resources/mcp-integration-catalog.ts +4 -2
  22. package/corpus/core/src/client/settings/BackgroundAgentSection.tsx +5 -1
  23. package/corpus/core/src/client/settings/SettingsPanel.tsx +4 -4
  24. package/corpus/core/src/client/settings/useBuilderStatus.ts +3 -0
  25. package/corpus/core/src/localization/default-messages.ts +1 -7
  26. package/corpus/core/src/server/builder-browser.ts +4 -0
  27. package/corpus/core/src/server/builder-design-systems.ts +6 -1
  28. package/corpus/core/src/server/fusion-app.ts +5 -1
  29. package/corpus/core/src/shared/builder-link-tracking.ts +41 -0
  30. package/corpus/core/src/shared/index.ts +1 -0
  31. package/corpus/templates/analytics/AGENTS.md +10 -1
  32. package/corpus/templates/analytics/actions/save-dashboard-report-subscription.ts +10 -2
  33. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +5 -4
  34. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +20 -32
  35. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +62 -0
  36. package/corpus/templates/analytics/changelog/2026-07-21-chart-series-controls-stay-open-while-moving-from-the-legend.md +6 -0
  37. package/corpus/templates/analytics/changelog/2026-07-21-dashboard-email-captures-complete-large-dashboards-in-chunks.md +6 -0
  38. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +4 -4
  39. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +1 -8
  40. package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +39 -7
  41. package/corpus/templates/analytics/server/lib/dashboard-report.ts +341 -251
  42. package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +114 -0
  43. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +84 -6
  44. package/corpus/templates/analytics/server/plugins/db.ts +13 -0
  45. package/corpus/templates/clips/app/components/library/bulk-action-toolbar.tsx +25 -8
  46. package/corpus/templates/clips/app/components/library/library-grid.tsx +118 -9
  47. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
  48. package/corpus/templates/clips/app/components/library/recording-card.tsx +10 -8
  49. package/corpus/templates/clips/app/components/player/video-player.tsx +10 -4
  50. package/corpus/templates/clips/app/i18n/en-US.ts +6 -0
  51. package/corpus/templates/clips/app/routes/_app.trash.tsx +16 -1
  52. package/corpus/templates/clips/changelog/2026-07-21-choose-which-whisper-model-to-use-in-settings.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-07-21-fixed-organization-recording-visibility-default.md +6 -0
  54. package/corpus/templates/clips/changelog/2026-07-21-meeting-notes-stop-when-calls-end.md +6 -0
  55. package/corpus/templates/clips/desktop/src/app.tsx +90 -78
  56. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +2 -0
  57. package/corpus/templates/clips/desktop/src/hooks/useWhisperSettings.ts +172 -0
  58. package/corpus/templates/clips/desktop/src/lib/meeting-call-app.ts +41 -0
  59. package/corpus/templates/clips/desktop/src/lib/silence-events.ts +1 -0
  60. package/corpus/templates/clips/desktop/src/shared/config.ts +1 -0
  61. package/corpus/templates/clips/desktop/src/styles.css +80 -7
  62. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +32 -0
  63. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  64. package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +268 -20
  65. package/corpus/templates/clips/desktop/src-tauri/src/whisper_model.rs +285 -164
  66. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +18 -7
  67. package/corpus/templates/clips/learnings.defaults.md +1 -1
  68. package/corpus/templates/clips/server/plugins/db.ts +11 -0
  69. package/corpus/templates/clips/shared/builder-credits.ts +1 -1
  70. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +20 -38
  71. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +40 -1
  72. package/corpus/templates/design/app/components/design/FusionAppBanner.tsx +9 -1
  73. package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +5 -1
  74. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +9 -1
  75. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +9 -1
  76. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  77. package/dist/agent/engine/builder-engine.js +4 -0
  78. package/dist/agent/engine/builder-engine.js.map +1 -1
  79. package/dist/client/AgentPanel.d.ts.map +1 -1
  80. package/dist/client/AgentPanel.js +10 -3
  81. package/dist/client/AgentPanel.js.map +1 -1
  82. package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
  83. package/dist/client/ConnectBuilderCard.js +5 -1
  84. package/dist/client/ConnectBuilderCard.js.map +1 -1
  85. package/dist/client/chat/markdown-renderer.d.ts.map +1 -1
  86. package/dist/client/chat/markdown-renderer.js +3 -2
  87. package/dist/client/chat/markdown-renderer.js.map +1 -1
  88. package/dist/client/error-format.d.ts +1 -1
  89. package/dist/client/error-format.d.ts.map +1 -1
  90. package/dist/client/error-format.js +1 -1
  91. package/dist/client/error-format.js.map +1 -1
  92. package/dist/client/index.d.ts +1 -0
  93. package/dist/client/index.d.ts.map +1 -1
  94. package/dist/client/index.js +1 -0
  95. package/dist/client/index.js.map +1 -1
  96. package/dist/client/integrations/IntegrationsPanel.d.ts.map +1 -1
  97. package/dist/client/integrations/IntegrationsPanel.js +3 -4
  98. package/dist/client/integrations/IntegrationsPanel.js.map +1 -1
  99. package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
  100. package/dist/client/resources/McpIntegrationDialog.js +21 -7
  101. package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
  102. package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
  103. package/dist/client/resources/mcp-integration-catalog.js +2 -2
  104. package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
  105. package/dist/client/settings/BackgroundAgentSection.d.ts.map +1 -1
  106. package/dist/client/settings/BackgroundAgentSection.js +5 -1
  107. package/dist/client/settings/BackgroundAgentSection.js.map +1 -1
  108. package/dist/client/settings/SettingsPanel.js +1 -1
  109. package/dist/client/settings/SettingsPanel.js.map +1 -1
  110. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  111. package/dist/client/settings/useBuilderStatus.js +2 -0
  112. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  113. package/dist/collab/struct-routes.d.ts +1 -1
  114. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  115. package/dist/localization/default-messages.d.ts +0 -6
  116. package/dist/localization/default-messages.d.ts.map +1 -1
  117. package/dist/localization/default-messages.js +1 -7
  118. package/dist/localization/default-messages.js.map +1 -1
  119. package/dist/observability/routes.d.ts +5 -5
  120. package/dist/resources/handlers.d.ts +1 -1
  121. package/dist/secrets/routes.d.ts +9 -9
  122. package/dist/server/builder-browser.d.ts.map +1 -1
  123. package/dist/server/builder-browser.js +4 -0
  124. package/dist/server/builder-browser.js.map +1 -1
  125. package/dist/server/builder-design-systems.d.ts.map +1 -1
  126. package/dist/server/builder-design-systems.js +6 -1
  127. package/dist/server/builder-design-systems.js.map +1 -1
  128. package/dist/server/fusion-app.d.ts.map +1 -1
  129. package/dist/server/fusion-app.js +2 -1
  130. package/dist/server/fusion-app.js.map +1 -1
  131. package/dist/server/transcribe-voice.d.ts +1 -1
  132. package/dist/shared/builder-link-tracking.d.ts +14 -0
  133. package/dist/shared/builder-link-tracking.d.ts.map +1 -0
  134. package/dist/shared/builder-link-tracking.js +26 -0
  135. package/dist/shared/builder-link-tracking.js.map +1 -0
  136. package/dist/shared/index.d.ts +1 -0
  137. package/dist/shared/index.d.ts.map +1 -1
  138. package/dist/shared/index.js +1 -0
  139. package/dist/shared/index.js.map +1 -1
  140. package/docs/content/deployment.mdx +1 -1
  141. package/docs/content/frames.mdx +1 -1
  142. package/docs/content/integrations.mdx +27 -27
  143. package/docs/content/pr-visual-recap.mdx +17 -13
  144. package/docs/content/workspace-management.mdx +1 -1
  145. package/package.json +3 -2
  146. package/src/agent/engine/builder-engine.ts +4 -0
  147. package/src/client/AgentPanel.tsx +10 -4
  148. package/src/client/ConnectBuilderCard.tsx +5 -1
  149. package/src/client/chat/markdown-renderer.tsx +3 -5
  150. package/src/client/error-format.ts +2 -1
  151. package/src/client/index.ts +1 -0
  152. package/src/client/integrations/IntegrationsPanel.tsx +2 -18
  153. package/src/client/resources/McpIntegrationDialog.tsx +43 -32
  154. package/src/client/resources/mcp-integration-catalog.ts +4 -2
  155. package/src/client/settings/BackgroundAgentSection.tsx +5 -1
  156. package/src/client/settings/SettingsPanel.tsx +4 -4
  157. package/src/client/settings/useBuilderStatus.ts +3 -0
  158. package/src/localization/default-messages.ts +1 -7
  159. package/src/server/builder-browser.ts +4 -0
  160. package/src/server/builder-design-systems.ts +6 -1
  161. package/src/server/fusion-app.ts +5 -1
  162. package/src/shared/builder-link-tracking.ts +41 -0
  163. package/src/shared/index.ts +1 -0
@@ -1,116 +1,205 @@
1
- //! Whisper model resolution + download for the local meeting transcription
2
- //! engine (`whisper_speech.rs`).
3
- //!
4
- //! Resolves where the `ggml-base.bin` model lives, downloads it from
5
- //! HuggingFace on first use, and verifies the download against a pinned
6
- //! SHA-256 + byte size so a corrupted, truncated, or tampered file is rejected
7
- //! rather than loaded
8
-
9
- use std::path::PathBuf;
1
+ //! Local Whisper model catalog, download, and integrity verification.
2
+
3
+ use std::path::{Path, PathBuf};
10
4
  use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
11
5
 
12
6
  use serde::Serialize;
13
7
  use tauri::{AppHandle, Emitter, Manager};
14
8
 
15
- const MODEL_URL: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin";
16
- const MODEL_FILENAME: &str = "ggml-base.bin";
17
- const MODEL_SHA256: &str = "60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe";
18
- const MODEL_SIZE: u64 = 147_951_465;
19
- const MODEL_SIZE_MB: u64 = MODEL_SIZE / (1024 * 1024);
9
+ #[derive(Debug, Clone, Copy, Serialize)]
10
+ #[serde(rename_all = "camelCase")]
11
+ pub struct WhisperModel {
12
+ pub id: &'static str,
13
+ pub title: &'static str,
14
+ pub description: &'static str,
15
+ pub url: &'static str,
16
+ pub filename: &'static str,
17
+ pub sha256: &'static str,
18
+ pub size_bytes: u64,
19
+ pub size_mb: u64,
20
+ }
21
+
22
+ const TINY_MODEL: WhisperModel = WhisperModel {
23
+ id: "tiny",
24
+ title: "Tiny",
25
+ description: "Transcribes instantly and uses very little space. May miss some words or struggle with accents, but great if speed is all that matters.",
26
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin",
27
+ filename: "ggml-tiny.bin",
28
+ sha256: "be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21",
29
+ size_bytes: 77_691_713,
30
+ size_mb: 74,
31
+ };
32
+
33
+ const BASE_MODEL: WhisperModel = WhisperModel {
34
+ id: "base",
35
+ title: "Base",
36
+ description: "A great everyday choice. Transcribes quickly and gets most things right for dictation and meetings.",
37
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin",
38
+ filename: "ggml-base.bin",
39
+ sha256: "60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe",
40
+ size_bytes: 147_951_465,
41
+ size_mb: 141,
42
+ };
43
+
44
+ const SMALL_MODEL: WhisperModel = WhisperModel {
45
+ id: "small",
46
+ title: "Small",
47
+ description: "More accurate than Base, especially with accents or background noise. Takes a bit longer and uses more space.",
48
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin",
49
+ filename: "ggml-small.bin",
50
+ sha256: "1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b",
51
+ size_bytes: 487_601_967,
52
+ size_mb: 488,
53
+ };
54
+
55
+ const LARGE_V3_TURBO_MODEL: WhisperModel = WhisperModel {
56
+ id: "large-v3-turbo",
57
+ title: "Large v3 Turbo",
58
+ description: "The most accurate option. Catches subtle speech and complex vocabulary well. Best on newer Macs; requires a larger one-time download.",
59
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin",
60
+ filename: "ggml-large-v3-turbo.bin",
61
+ sha256: "1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69",
62
+ size_bytes: 1_624_555_275,
63
+ size_mb: 1549,
64
+ };
65
+
66
+ const SUPPORTED_MODELS: &[WhisperModel] =
67
+ &[TINY_MODEL, BASE_MODEL, SMALL_MODEL, LARGE_V3_TURBO_MODEL];
20
68
 
21
- // Global download-in-flight state so the status command and concurrent callers
22
- // can inspect without re-checking the filesystem.
23
69
  static DOWNLOADING: AtomicBool = AtomicBool::new(false);
24
70
  static DOWNLOADED_BYTES: AtomicU64 = AtomicU64::new(0);
25
71
 
26
- /// Whether the model path is overridden via `CLIPS_WHISPER_MODEL`. A custom
27
- /// model is exempt from checksum verification (it may legitimately be a
28
- /// different model, e.g. multilingual).
72
+ // ---------------------------------------------------------------------------
73
+ // Internal helpers
74
+ // ---------------------------------------------------------------------------
75
+
29
76
  pub(crate) fn custom_model_override() -> bool {
30
77
  std::env::var("CLIPS_WHISPER_MODEL")
31
78
  .map(|v| !v.trim().is_empty())
32
79
  .unwrap_or(false)
33
80
  }
34
81
 
35
- /// Resolve the model path. Honors `CLIPS_WHISPER_MODEL`, otherwise
36
- /// `<app_data_dir>/models/ggml-base.bin` (creating the dir).
37
- pub fn model_file(app: &AppHandle) -> Result<PathBuf, String> {
38
- if let Ok(path) = std::env::var("CLIPS_WHISPER_MODEL") {
39
- if !path.trim().is_empty() {
40
- return Ok(PathBuf::from(path));
41
- }
42
- }
82
+ pub(crate) fn is_supported_model_id(id: &str) -> bool {
83
+ SUPPORTED_MODELS.iter().any(|m| m.id == id)
84
+ }
85
+
86
+ fn find_model(id: &str) -> Result<&'static WhisperModel, String> {
87
+ SUPPORTED_MODELS
88
+ .iter()
89
+ .find(|m| m.id == id)
90
+ .ok_or_else(|| format!("unsupported Whisper model: {id}"))
91
+ }
92
+
93
+ /// Returns the directory where model files are stored, creating it if needed.
94
+ fn models_dir(app: &AppHandle) -> Result<PathBuf, String> {
43
95
  let dir = app
44
96
  .path()
45
97
  .app_data_dir()
46
98
  .map_err(|e| format!("no app_data_dir: {e}"))?
47
99
  .join("models");
48
100
  std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir models: {e}"))?;
49
- Ok(dir.join(MODEL_FILENAME))
101
+ Ok(dir)
102
+ }
103
+
104
+ /// Resolve the full path for a catalog model on disk.
105
+ fn model_path(dir: &Path, model: &WhisperModel) -> PathBuf {
106
+ dir.join(model.filename)
107
+ }
108
+
109
+ fn is_complete_on_disk(path: &Path, model: &WhisperModel) -> bool {
110
+ std::fs::metadata(path)
111
+ .map(|m| m.len() == model.size_bytes)
112
+ .unwrap_or(false)
113
+ }
114
+
115
+ fn downloaded_mb(bytes: u64, model: &WhisperModel) -> u64 {
116
+ bytes.saturating_mul(model.size_mb) / model.size_bytes
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Public: resolve model file path (honors CLIPS_WHISPER_MODEL env override)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ pub fn model_file(app: &AppHandle) -> Result<PathBuf, String> {
124
+ if let Ok(path) = std::env::var("CLIPS_WHISPER_MODEL") {
125
+ if !path.trim().is_empty() {
126
+ return Ok(PathBuf::from(path));
127
+ }
128
+ }
129
+ let config = crate::config::feature_config(app);
130
+ let model = find_model(&config.whisper_model_id)?;
131
+ Ok(model_path(&models_dir(app)?, model))
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Tauri commands
136
+ // ---------------------------------------------------------------------------
137
+
138
+ #[tauri::command]
139
+ pub async fn whisper_models() -> Vec<WhisperModel> {
140
+ SUPPORTED_MODELS.to_vec()
50
141
  }
51
142
 
52
- /// Model status returned to the frontend settings UI.
53
143
  #[derive(Debug, Clone, Serialize)]
54
144
  #[serde(rename_all = "camelCase")]
55
145
  pub struct ModelStatus {
56
- /// One of: "disabled" | "missing" | "downloading" | "ready"
57
146
  pub state: String,
58
- /// Absolute path where the model file lives (or will live).
59
147
  pub path: String,
60
- /// How many MB have been downloaded so far (only meaningful during "downloading").
61
148
  pub downloaded_mb: u64,
62
- /// Total model size in MB.
63
149
  pub total_mb: u64,
64
150
  }
65
151
 
66
- /// Return the current model state without triggering a download.
67
152
  #[tauri::command]
68
153
  pub async fn whisper_model_status(app: AppHandle) -> Result<ModelStatus, String> {
69
- let path = model_file(&app)?;
70
- let path_str = path.to_string_lossy().to_string();
154
+ if custom_model_override() {
155
+ let path = model_file(&app)?;
156
+ let ready = path.exists();
157
+ return Ok(ModelStatus {
158
+ state: if ready { "ready" } else { "missing" }.into(),
159
+ path: path.to_string_lossy().into(),
160
+ downloaded_mb: 0,
161
+ total_mb: 0,
162
+ });
163
+ }
164
+
71
165
  let config = crate::config::feature_config(&app);
166
+ let model = find_model(&config.whisper_model_id)?;
167
+ let dir = models_dir(&app)?;
168
+ let path = model_path(&dir, model);
169
+ let path_str = path.to_string_lossy().into_owned();
72
170
 
73
171
  if !config.whisper_model_enabled {
74
172
  return Ok(ModelStatus {
75
173
  state: "disabled".into(),
76
174
  path: path_str,
77
175
  downloaded_mb: 0,
78
- total_mb: MODEL_SIZE_MB,
176
+ total_mb: model.size_mb,
79
177
  });
80
178
  }
81
179
  if DOWNLOADING.load(Ordering::Relaxed) {
82
- let downloaded_mb = DOWNLOADED_BYTES.load(Ordering::Relaxed) / (1024 * 1024);
83
180
  return Ok(ModelStatus {
84
181
  state: "downloading".into(),
85
182
  path: path_str,
86
- downloaded_mb,
87
- total_mb: MODEL_SIZE_MB,
183
+ downloaded_mb: downloaded_mb(DOWNLOADED_BYTES.load(Ordering::Relaxed), model),
184
+ total_mb: model.size_mb,
88
185
  });
89
186
  }
90
- let state = match std::fs::metadata(&path) {
91
- Ok(m) if m.len() == MODEL_SIZE || custom_model_override() => "ready",
92
- _ => "missing",
93
- };
187
+ let ready = is_complete_on_disk(&path, model);
94
188
  Ok(ModelStatus {
95
- state: state.into(),
189
+ state: if ready { "ready" } else { "missing" }.into(),
96
190
  path: path_str,
97
- downloaded_mb: if state == "ready" { MODEL_SIZE_MB } else { 0 },
98
- total_mb: MODEL_SIZE_MB,
191
+ downloaded_mb: if ready { model.size_mb } else { 0 },
192
+ total_mb: model.size_mb,
99
193
  })
100
194
  }
101
195
 
102
- /// Spawn a background download. Idempotent — no-ops if already downloading or
103
- /// already present. Emits `whisper:model-progress`, `whisper:model-ready`, or
104
- /// `whisper:model-error` as the download progresses.
105
196
  #[tauri::command]
106
197
  pub async fn whisper_model_download(app: AppHandle) -> Result<(), String> {
107
- if DOWNLOADING.load(Ordering::Acquire) {
108
- return Ok(());
109
- }
110
- // Quick check: if model is already present, just emit ready and return.
111
- if let Ok(path) = model_file(&app) {
112
- if let Ok(m) = std::fs::metadata(&path) {
113
- if m.len() == MODEL_SIZE || custom_model_override() {
198
+ let path = model_file(&app)?;
199
+ if let Ok(meta) = std::fs::metadata(&path) {
200
+ let config = crate::config::feature_config(&app);
201
+ if let Ok(model) = find_model(&config.whisper_model_id) {
202
+ if meta.len() == model.size_bytes || custom_model_override() {
114
203
  let _ = app.emit("whisper:model-ready", ());
115
204
  return Ok(());
116
205
  }
@@ -130,164 +219,196 @@ pub async fn whisper_model_download(app: AppHandle) -> Result<(), String> {
130
219
  Ok(())
131
220
  }
132
221
 
133
- /// Ensure the model file exists, downloading it on first use. ~142 MB, so the
134
- /// first meeting after install pays a one-time download cost.
135
- ///
136
- /// The default `ggml-base.bin` download is verified against `MODEL_SHA256` /
137
- /// `MODEL_SIZE`. A custom model supplied via `CLIPS_WHISPER_MODEL` is exempt
138
- /// (it may legitimately be a different model) — we only require it to exist.
139
- ///
140
- /// Emits `whisper:model-progress { downloadedMb, totalMb }` every ~16 MB.
141
- pub async fn ensure_model(app: &AppHandle) -> Result<PathBuf, String> {
222
+ #[tauri::command]
223
+ pub async fn whisper_downloaded_models(app: AppHandle) -> Vec<String> {
224
+ let Ok(dir) = models_dir(&app) else {
225
+ return vec![];
226
+ };
227
+ SUPPORTED_MODELS
228
+ .iter()
229
+ .filter(|m| is_complete_on_disk(&model_path(&dir, m), m))
230
+ .map(|m| m.id.to_string())
231
+ .collect()
232
+ }
233
+
234
+ #[tauri::command]
235
+ pub async fn whisper_model_delete(app: AppHandle, model_id: String) -> Result<(), String> {
236
+ if custom_model_override() {
237
+ return Err("Cannot manage models while CLIPS_WHISPER_MODEL is set.".to_string());
238
+ }
239
+ if crate::config::feature_config(&app).whisper_model_id == model_id {
240
+ return Err("Cannot delete the model that is currently selected.".to_string());
241
+ }
242
+ let model = find_model(&model_id)?;
243
+ let path = model_path(&models_dir(&app)?, model);
244
+ if path.exists() {
245
+ std::fs::remove_file(&path).map_err(|e| format!("delete model: {e}"))?;
246
+ }
247
+ let _ = app.emit(
248
+ "whisper:model-deleted",
249
+ serde_json::json!({ "modelId": model_id }),
250
+ );
251
+ Ok(())
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Download logic (called by whisper_model_download and lib.rs startup prewarm)
256
+ // ---------------------------------------------------------------------------
257
+
258
+ pub(crate) async fn ensure_model(app: &AppHandle) -> Result<PathBuf, String> {
142
259
  let path = model_file(app)?;
143
260
  let custom = custom_model_override();
144
261
 
145
- if custom && !path.exists() {
262
+ if custom {
263
+ if path.exists() {
264
+ eprintln!("[whisper] using custom model at {}", path.display());
265
+ return Ok(path);
266
+ }
146
267
  return Err(format!(
147
- "CLIPS_WHISPER_MODEL is set to '{}' but the file does not exist.",
268
+ "CLIPS_WHISPER_MODEL points to '{}' but the file does not exist.",
148
269
  path.display()
149
270
  ));
150
271
  }
151
272
 
273
+ let config = crate::config::feature_config(app);
274
+ let model = find_model(&config.whisper_model_id)?;
275
+
276
+ if is_complete_on_disk(&path, model) {
277
+ eprintln!("[whisper] model found at {}", path.display());
278
+ return Ok(path);
279
+ }
152
280
  if path.exists() {
153
- if custom {
154
- eprintln!("[whisper] using custom model at {}", path.display());
155
- return Ok(path);
156
- }
157
- match std::fs::metadata(&path) {
158
- Ok(m) if m.len() == MODEL_SIZE => {
159
- eprintln!("[whisper] model found at {}", path.display());
160
- return Ok(path);
161
- }
162
- Ok(m) => {
163
- eprintln!(
164
- "[whisper] cached model size {} != expected {} — re-downloading",
165
- m.len(),
166
- MODEL_SIZE
167
- );
168
- }
169
- Err(e) => return Err(format!("stat model: {e}")),
170
- }
281
+ eprintln!(
282
+ "[whisper] cached model has wrong size — re-downloading {}",
283
+ path.display()
284
+ );
171
285
  }
172
286
 
173
- // If a download is already in progress, wait for it rather than failing —
174
- // the caller (meeting start) should succeed once the model lands.
175
- if DOWNLOADING.load(Ordering::SeqCst) {
176
- eprintln!("[whisper] waiting for in-progress model download…");
177
- loop {
287
+ loop {
288
+ if DOWNLOADING
289
+ .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
290
+ .is_ok()
291
+ {
292
+ break;
293
+ }
294
+
295
+ eprintln!("[whisper] waiting for in-progress download…");
296
+ while DOWNLOADING.load(Ordering::SeqCst) {
178
297
  tokio::time::sleep(std::time::Duration::from_millis(100)).await;
179
- if !DOWNLOADING.load(Ordering::SeqCst) {
180
- break;
181
- }
182
298
  }
183
- // Re-check: the download that just finished may have placed the model.
184
- if path.exists() {
185
- if custom {
186
- return Ok(path);
187
- }
188
- if let Ok(m) = std::fs::metadata(&path) {
189
- if m.len() == MODEL_SIZE {
190
- return Ok(path);
191
- }
192
- }
299
+ if is_complete_on_disk(&path, model) {
300
+ return Ok(path);
193
301
  }
194
302
  }
195
- // Guard against concurrent downloads.
196
- if DOWNLOADING
197
- .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
198
- .is_err()
199
- {
200
- return Err("Whisper model download already in progress — please wait.".to_string());
201
- }
202
303
  DOWNLOADED_BYTES.store(0, Ordering::Relaxed);
203
304
 
204
- let result = do_download(app, &path, custom).await;
305
+ let result = do_download(app, &path, model).await;
205
306
  DOWNLOADING.store(false, Ordering::SeqCst);
206
307
  result
207
308
  }
208
309
 
209
- async fn do_download(app: &AppHandle, path: &PathBuf, custom: bool) -> Result<PathBuf, String> {
310
+ async fn do_download(
311
+ app: &AppHandle,
312
+ path: &Path,
313
+ model: &WhisperModel,
314
+ ) -> Result<PathBuf, String> {
210
315
  eprintln!(
211
- "[whisper] model not found at {} — downloading {} (~142 MB, one time)",
212
- path.display(),
213
- MODEL_URL
316
+ "[whisper] downloading {} (~{} MB)",
317
+ model.url, model.size_mb
214
318
  );
215
- let mut resp = reqwest::get(MODEL_URL).await.map_err(|e| {
216
- let msg = format!("model download request failed: {e}");
217
- eprintln!("[whisper] {msg}");
218
- msg
219
- })?;
220
- if !resp.status().is_success() {
221
- let msg = format!("model download HTTP {}", resp.status());
222
- eprintln!("[whisper] {msg}");
223
- return Err(msg);
319
+ let mut response = reqwest::get(model.url)
320
+ .await
321
+ .map_err(|e| format!("model download failed: {e}"))?;
322
+ if !response.status().is_success() {
323
+ return Err(format!("model download HTTP {}", response.status()));
224
324
  }
225
325
 
226
- // Stream body to a temp file, hashing as we go. Keeps memory flat
227
- // (no 142 MB heap spike) and lets us verify before the rename.
228
326
  use sha2::{Digest, Sha256};
229
327
  use std::io::Write as _;
230
328
 
231
329
  let tmp = path.with_extension("bin.tmp");
232
- let mut file = std::fs::File::create(&tmp).map_err(|e| format!("create model tmp: {e}"))?;
330
+ let mut file = std::fs::File::create(&tmp).map_err(|e| format!("create tmp: {e}"))?;
233
331
  let mut hasher = Sha256::new();
234
- let mut total: u64 = 0;
235
- let mut last_progress: u64 = 0;
332
+ let mut total = 0_u64;
333
+ let mut last_progress = 0_u64;
236
334
 
237
- while let Some(chunk) = resp
335
+ while let Some(chunk) = response
238
336
  .chunk()
239
337
  .await
240
- .map_err(|e| format!("model download body failed: {e}"))?
338
+ .map_err(|e| format!("download body: {e}"))?
241
339
  {
242
- if !custom {
243
- hasher.update(&chunk);
244
- }
340
+ hasher.update(&chunk);
245
341
  total += chunk.len() as u64;
246
342
  DOWNLOADED_BYTES.store(total, Ordering::Relaxed);
247
343
 
248
344
  if let Err(e) = file.write_all(&chunk) {
249
345
  let _ = std::fs::remove_file(&tmp);
250
- let msg = format!("write model tmp: {e}");
251
- eprintln!("[whisper] {msg}");
252
- return Err(msg);
346
+ return Err(format!("write tmp: {e}"));
253
347
  }
254
348
 
255
- // Emit progress + log every ~16 MB.
256
349
  if total - last_progress >= 16 * 1024 * 1024 {
257
350
  last_progress = total;
258
- let downloaded_mb = total / (1024 * 1024);
259
- eprintln!("[whisper] downloading model… {downloaded_mb} / {MODEL_SIZE_MB} MB");
351
+ let mb = downloaded_mb(total, model);
352
+ eprintln!("[whisper] {mb} / {} MB", model.size_mb);
260
353
  let _ = app.emit(
261
354
  "whisper:model-progress",
262
- serde_json::json!({ "downloadedMb": downloaded_mb, "totalMb": MODEL_SIZE_MB }),
355
+ serde_json::json!({ "downloadedMb": mb, "totalMb": model.size_mb }),
263
356
  );
264
357
  }
265
358
  }
266
- file.flush().map_err(|e| format!("flush model tmp: {e}"))?;
359
+ file.flush().map_err(|e| format!("flush tmp: {e}"))?;
267
360
  drop(file);
268
361
 
269
- if !custom {
270
- if total != MODEL_SIZE {
271
- let _ = std::fs::remove_file(&tmp);
272
- let msg = format!("model size mismatch: got {total} bytes, expected {MODEL_SIZE}");
273
- eprintln!("[whisper] {msg}");
274
- return Err(msg);
275
- }
276
- let digest: String = hasher
277
- .finalize()
278
- .iter()
279
- .map(|b| format!("{b:02x}"))
280
- .collect();
281
- if digest != MODEL_SHA256 {
282
- let _ = std::fs::remove_file(&tmp);
283
- let msg = format!("model checksum mismatch: got {digest}, expected {MODEL_SHA256}");
284
- eprintln!("[whisper] {msg}");
285
- return Err(msg);
286
- }
287
- eprintln!("[whisper] model checksum verified (sha256 {MODEL_SHA256})");
362
+ if total != model.size_bytes {
363
+ let _ = std::fs::remove_file(&tmp);
364
+ return Err(format!(
365
+ "size mismatch: got {total} bytes, expected {}",
366
+ model.size_bytes
367
+ ));
368
+ }
369
+ let digest: String = hasher
370
+ .finalize()
371
+ .iter()
372
+ .map(|b| format!("{b:02x}"))
373
+ .collect();
374
+ if digest != model.sha256 {
375
+ let _ = std::fs::remove_file(&tmp);
376
+ return Err(format!(
377
+ "checksum mismatch: got {digest}, expected {}",
378
+ model.sha256
379
+ ));
288
380
  }
289
381
 
290
382
  std::fs::rename(&tmp, path).map_err(|e| format!("rename model: {e}"))?;
291
- eprintln!("[whisper] model saved → {}", path.display());
292
- Ok(path.clone())
383
+ eprintln!("[whisper] saved → {}", path.display());
384
+ Ok(path.to_path_buf())
385
+ }
386
+
387
+ #[cfg(test)]
388
+ mod tests {
389
+ use super::*;
390
+
391
+ #[test]
392
+ fn supported_models_have_unique_ids_and_filenames() {
393
+ for (i, model) in SUPPORTED_MODELS.iter().enumerate() {
394
+ assert!(model.size_bytes > 0);
395
+ assert_eq!(model.sha256.len(), 64, "bad sha256 for {}", model.id);
396
+ assert!(!model.title.is_empty());
397
+ assert!(!model.description.is_empty());
398
+ for other in SUPPORTED_MODELS.iter().skip(i + 1) {
399
+ assert_ne!(model.id, other.id);
400
+ assert_ne!(model.filename, other.filename);
401
+ }
402
+ }
403
+ }
404
+
405
+ #[test]
406
+ fn base_model_is_default() {
407
+ assert!(is_supported_model_id("base"));
408
+ }
409
+
410
+ #[test]
411
+ fn unknown_model_id_is_rejected() {
412
+ assert!(find_model("does-not-exist").is_err());
413
+ }
293
414
  }
@@ -134,6 +134,7 @@ pub async fn whisper_transcription_reset_timeline() -> Result<(), String> {
134
134
 
135
135
  #[cfg(target_os = "macos")]
136
136
  mod macos {
137
+ use std::path::PathBuf;
137
138
  use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
138
139
  use std::sync::{Arc, Mutex, OnceLock};
139
140
  use std::time::{Duration, Instant};
@@ -189,13 +190,20 @@ mod macos {
189
190
  // the first call takes effect.
190
191
  whisper_rs::install_logging_hooks();
191
192
 
192
- static CTX: OnceLock<Mutex<Option<Arc<WhisperContext>>>> = OnceLock::new();
193
+ struct CachedContext {
194
+ model_path: PathBuf,
195
+ context: Arc<WhisperContext>,
196
+ }
197
+
198
+ static CTX: OnceLock<Mutex<Option<CachedContext>>> = OnceLock::new();
193
199
  let slot = CTX.get_or_init(|| Mutex::new(None));
194
200
  let mut guard = slot.lock().map_err(|e| e.to_string())?;
195
- if let Some(ctx) = guard.as_ref() {
196
- return Ok(ctx.clone());
197
- }
198
201
  let path = model_file(app)?;
202
+ if let Some(cached) = guard.as_ref() {
203
+ if cached.model_path == path {
204
+ return Ok(cached.context.clone());
205
+ }
206
+ }
199
207
  let path_str = path
200
208
  .to_str()
201
209
  .ok_or_else(|| "model path is not valid UTF-8".to_string())?;
@@ -208,9 +216,12 @@ mod macos {
208
216
  params.use_gpu(cfg!(target_arch = "aarch64"));
209
217
  let ctx = WhisperContext::new_with_params(path_str, params)
210
218
  .map_err(|e| format!("whisper model load failed: {e}"))?;
211
- let ctx = Arc::new(ctx);
212
- *guard = Some(ctx.clone());
213
- Ok(ctx)
219
+ let context = Arc::new(ctx);
220
+ *guard = Some(CachedContext {
221
+ model_path: path,
222
+ context: context.clone(),
223
+ });
224
+ Ok(context)
214
225
  }
215
226
 
216
227
  pub fn prewarm(app: &AppHandle) -> Result<(), String> {
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## Recording defaults
8
8
 
9
- - New recordings default to **private** visibility unless the user asks otherwise.
9
+ - New recordings default to **public** visibility so anyone with the link can watch them. Organization admins can choose a different default.
10
10
  - Default playback speed for viewers is **1.2x** (creator can override per video).
11
11
  - Animated GIF thumbnails are enabled by default on new recordings — the first few seconds play as a hover preview.
12
12
 
@@ -839,6 +839,17 @@ const migrations = runMigrations(
839
839
  name: "clips-meetings-share-transcript",
840
840
  sql: `ALTER TABLE clips_meetings ADD COLUMN IF NOT EXISTS share_transcript INTEGER NOT NULL DEFAULT 0`,
841
841
  },
842
+ {
843
+ version: 50,
844
+ name: "clips-public-organization-default",
845
+ // Earlier releases persisted the old private default into org rows.
846
+ // Normalize that state once; the org setting remains an explicit override.
847
+ // guard:allow-unscoped — startup migration normalizes legacy defaults across organizations.
848
+ sql: [
849
+ `UPDATE workspaces SET default_visibility = 'public' WHERE default_visibility = 'private' AND updated_at = created_at`,
850
+ `UPDATE organization_settings SET default_visibility = 'public' WHERE default_visibility = 'private' AND updated_at = created_at`,
851
+ ].join("; "),
852
+ },
842
853
  ],
843
854
  { table: "clips_migrations" },
844
855
  );
@@ -1,7 +1,7 @@
1
1
  export const CLIPS_BUILDER_CREDITS_STATE_KEY = "clips-builder-credits";
2
2
 
3
3
  export const BUILDER_CREDITS_UPGRADE_URL =
4
- "https://builder.io/account/subscription?signupSource=agent-native&agentNativeConnectSource=clips_builder_credits&agentNativeFlow=connect_llm&framework=agent-native";
4
+ "https://builder.io/account/subscription?signupSource=agent-native&agentNativeConnectSource=clips_builder_credits&agentNativeFlow=connect_llm&framework=agent-native&utm_source=agent-native&utm_medium=product&utm_campaign=onboarding&utm_content=clips_builder_credits";
5
5
 
6
6
  export const BUILDER_CREDIT_FEATURES = [
7
7
  "backup-transcription",