diff --git a/CHANGELOG.md b/CHANGELOG.md index 226e3a67ee..6d2f0295ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **Enterprise workspaces can manage local models from first-run setup.** Company admins can publish ordered, approved Dictation and AI/Notes model lists. Employees download one approved model per required category before Permissions, may continue while downloads finish, and complete onboarding after Notes without seeing pricing or provider selection. Managed choices remain locked to the active workspace and automatically move to the first compatible fallback when an admin removes a selection. - **Uploaded and URL-ingested notes remember their speaker detection.** A note created through Upload ran speaker detection but stored none of it — the note now records that diarization ran, the speaker count you chose, and the audio duration, so it behaves like a meeting note when you record into it or resolve participants. An upload with speaker detection off writes nothing, preserving your global speaker setting. Present since upload speaker detection shipped in 1.7.6. (#1610) ## [1.8.3] - 2026-08-12 diff --git a/main.js b/main.js index 9dd131e929..006251beaa 100644 --- a/main.js +++ b/main.js @@ -534,6 +534,11 @@ function initializeCoreManagers() { } function registerSidecars() { + if (ipcHandlers) { + sidecarRegistry.register("corti-privacy-cleanup", () => + ipcHandlers.retryCortiPrivacyCleanup() + ); + } if (whisperManager) sidecarRegistry.register("whisper", () => whisperManager.stopServer()); if (parakeetManager) sidecarRegistry.register("parakeet", () => parakeetManager.stopServer()); if (diarizationManager) { @@ -961,6 +966,9 @@ async function startApp() { // Phase 1: Core managers + IPC handlers before windows initializeCoreManagers(); await environmentManager.init(); + void ipcHandlers.retryCortiPrivacyCleanup().catch((error) => { + debugLogger.error("Corti privacy cleanup retry failed", { error: error?.message }); + }); registerSidecars(); startAuthBridgeServer(); diff --git a/package-lock.json b/package-lock.json index e1d0b0111e..629f5f1d53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-whispr", - "version": "1.8.3", + "version": "1.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-whispr", - "version": "1.8.3", + "version": "1.8.4", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0927fb1aa7..f5d94f4dff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-whispr", - "version": "1.8.3", + "version": "1.8.4", "description": "A desktop dictation application using whisper.cpp for speech-to-text transcription", "main": "main.js", "private": true, diff --git a/preload.js b/preload.js index bf266965d3..c7d85862e9 100644 --- a/preload.js +++ b/preload.js @@ -60,6 +60,11 @@ const registerListener = (channel, handlerFactory) => { }; }; +const invokeWithRuntimeContext = (channel, args, managedRuntimeContext) => + managedRuntimeContext === undefined + ? ipcRenderer.invoke(channel, ...args) + : ipcRenderer.invoke(channel, ...args, managedRuntimeContext); + contextBridge.exposeInMainWorld("electronAPI", { setOnboardingWindowMode: (mode) => ipcRenderer.invoke("onboarding-set-window-mode", mode), setOnboardingActive: (active) => ipcRenderer.invoke("onboarding-set-active", active), @@ -112,7 +117,14 @@ contextBridge.exposeInMainWorld("electronAPI", { getAudioStorageUsage: () => ipcRenderer.invoke("get-audio-storage-usage"), deleteAllAudio: () => ipcRenderer.invoke("delete-all-audio"), syncRetentionSettings: (settings) => ipcRenderer.send("retention-settings-changed", settings), - retryTranscription: (id, settings) => ipcRenderer.invoke("retry-transcription", id, settings), + retryTranscription: (id, settings, requestId, managedRuntimeContext) => + invokeWithRuntimeContext( + "retry-transcription", + [id, settings, requestId], + managedRuntimeContext + ), + commitRetryTranscription: (id, requestId, text, rawText) => + ipcRenderer.invoke("commit-retry-transcription", id, requestId, text, rawText), updateTranscriptionText: (id, text, rawText) => ipcRenderer.invoke("update-transcription-text", id, text, rawText), getTranscriptionById: (id) => ipcRenderer.invoke("get-transcription-by-id", id), @@ -158,6 +170,9 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("db-get-notes", noteType, limit, folderId, spaceId), getSpaceNotes: (spaceId, limit) => ipcRenderer.invoke("db-get-space-notes", spaceId, limit), updateNote: (id, updates) => ipcRenderer.invoke("db-update-note", id, updates), + beginActionNoteCommit: (noteId, context) => + ipcRenderer.invoke("begin-action-note-commit", noteId, context), + commitActionNote: (payload) => ipcRenderer.invoke("commit-action-note", payload), deleteNote: (id) => ipcRenderer.invoke("db-delete-note", id), exportNote: (noteId, format) => ipcRenderer.invoke("export-note", noteId, format), exportTranscript: (noteId, format) => ipcRenderer.invoke("export-transcript", noteId, format), @@ -221,8 +236,8 @@ contextBridge.exposeInMainWorld("electronAPI", { // Audio file operations selectAudioFile: (options) => ipcRenderer.invoke("select-audio-file", options), getFileSize: (filePath) => ipcRenderer.invoke("get-file-size", filePath), - transcribeAudioFile: (filePath, options) => - ipcRenderer.invoke("transcribe-audio-file", filePath, options), + transcribeAudioFile: (filePath, options, managedRuntimeContext) => + invokeWithRuntimeContext("transcribe-audio-file", [filePath, options], managedRuntimeContext), getPathForFile: (file) => { const filePath = webUtils.getPathForFile(file); // Register real dropped-file paths so the main-process audio allowlist accepts them. @@ -329,8 +344,12 @@ contextBridge.exposeInMainWorld("electronAPI", { deleteTempAudio: (tempPath) => ipcRenderer.invoke("delete-temp-audio", tempPath), // Local Whisper functions (whisper.cpp) - transcribeLocalWhisper: (audioBlob, options) => - ipcRenderer.invoke("transcribe-local-whisper", audioBlob, options), + transcribeLocalWhisper: (audioBlob, options, managedRuntimeContext) => + invokeWithRuntimeContext( + "transcribe-local-whisper", + [audioBlob, options], + managedRuntimeContext + ), checkWhisperInstallation: () => ipcRenderer.invoke("check-whisper-installation"), downloadWhisperModel: (modelName) => ipcRenderer.invoke("download-whisper-model", modelName), onWhisperDownloadProgress: registerListener("whisper-download-progress"), @@ -385,8 +404,12 @@ contextBridge.exposeInMainWorld("electronAPI", { dismissGpuPackMigrationNotice: () => ipcRenderer.invoke("dismiss-gpu-pack-migration-notice"), // Local Parakeet (NVIDIA) functions - transcribeLocalParakeet: (audioBlob, options) => - ipcRenderer.invoke("transcribe-local-parakeet", audioBlob, options), + transcribeLocalParakeet: (audioBlob, options, managedRuntimeContext) => + invokeWithRuntimeContext( + "transcribe-local-parakeet", + [audioBlob, options], + managedRuntimeContext + ), checkParakeetInstallation: () => ipcRenderer.invoke("check-parakeet-installation"), downloadParakeetModel: (modelName) => ipcRenderer.invoke("download-parakeet-model", modelName), onParakeetDownloadProgress: registerListener("parakeet-download-progress"), @@ -408,8 +431,8 @@ contextBridge.exposeInMainWorld("electronAPI", { getDiarizationModelStatus: () => ipcRenderer.invoke("get-diarization-model-status"), deleteDiarizationModels: () => ipcRenderer.invoke("delete-diarization-models"), cancelDiarizationDownload: () => ipcRenderer.invoke("cancel-diarization-download"), - diarizeAudioFile: (filePath, options) => - ipcRenderer.invoke("diarize-audio-file", filePath, options), + diarizeAudioFile: (filePath, options, managedRuntimeContext) => + invokeWithRuntimeContext("diarize-audio-file", [filePath, options], managedRuntimeContext), mergeSpeakerText: (segments, text, duration) => ipcRenderer.invoke("merge-speaker-text", { segments, text, duration }), onDiarizationDownloadProgress: registerListener( @@ -515,17 +538,21 @@ contextBridge.exposeInMainWorld("electronAPI", { setUiLanguage: (language) => ipcRenderer.invoke("set-ui-language", language), // xAI / Mistral transcription proxies (keys handled by the manifest bridge) - proxyXaiTranscription: (data) => ipcRenderer.invoke("proxy-xai-transcription", data), - proxyMistralTranscription: (data) => ipcRenderer.invoke("proxy-mistral-transcription", data), + proxyXaiTranscription: (data, managedRuntimeContext) => + invokeWithRuntimeContext("proxy-xai-transcription", [data], managedRuntimeContext), + proxyMistralTranscription: (data, managedRuntimeContext) => + invokeWithRuntimeContext("proxy-mistral-transcription", [data], managedRuntimeContext), // Corti API getCortiClientId: () => ipcRenderer.invoke("get-corti-client-id"), saveCortiClientId: (key) => ipcRenderer.invoke("save-corti-client-id", key), getCortiClientSecret: () => ipcRenderer.invoke("get-corti-client-secret"), saveCortiClientSecret: (key) => ipcRenderer.invoke("save-corti-client-secret", key), - proxyCortiTranscription: (data) => ipcRenderer.invoke("proxy-corti-transcription", data), + proxyCortiTranscription: (data, managedRuntimeContext) => + invokeWithRuntimeContext("proxy-corti-transcription", [data], managedRuntimeContext), getTinfoilChatModels: () => ipcRenderer.invoke("get-tinfoil-chat-models"), - proxyTinfoilTranscription: (data) => ipcRenderer.invoke("proxy-tinfoil-transcription", data), + proxyTinfoilTranscription: (data, managedRuntimeContext) => + invokeWithRuntimeContext("proxy-tinfoil-transcription", [data], managedRuntimeContext), // Custom endpoint API keys getCustomTranscriptionKey: () => ipcRenderer.invoke("get-custom-transcription-key"), @@ -676,7 +703,8 @@ contextBridge.exposeInMainWorld("electronAPI", { // OpenWhispr Cloud API cloudHealthCheck: () => ipcRenderer.invoke("cloud-health-check"), - cloudTranscribe: (audioBuffer, opts) => ipcRenderer.invoke("cloud-transcribe", audioBuffer, opts), + cloudTranscribe: (audioBuffer, opts, managedRuntimeContext) => + invokeWithRuntimeContext("cloud-transcribe", [audioBuffer, opts], managedRuntimeContext), cancelCloudTranscription: () => ipcRenderer.send("cloud-transcribe-cancel"), cloudReason: (text, opts) => ipcRenderer.invoke("cloud-reason", text, opts), cancelCloudReason: () => ipcRenderer.send("cloud-reason-cancel"), @@ -699,11 +727,16 @@ contextBridge.exposeInMainWorld("electronAPI", { getNoteRecordingConfig: () => ipcRenderer.invoke("get-note-recording-config"), // Cloud audio file transcription - transcribeAudioFileCloud: (filePath, options) => - ipcRenderer.invoke("transcribe-audio-file-cloud", filePath, options), + transcribeAudioFileCloud: (filePath, options, managedRuntimeContext) => + invokeWithRuntimeContext( + "transcribe-audio-file-cloud", + [filePath, options], + managedRuntimeContext + ), cancelUploadTranscription: (requestId) => ipcRenderer.invoke("cancel-upload-transcription", requestId), - transcribeAudioFileByok: (options) => ipcRenderer.invoke("transcribe-audio-file-byok", options), + transcribeAudioFileByok: (options, managedRuntimeContext) => + invokeWithRuntimeContext("transcribe-audio-file-byok", [options], managedRuntimeContext), onUploadTranscriptionProgress: registerListener( "upload-transcription-progress", (callback) => (_event, data) => callback(data) @@ -715,13 +748,16 @@ contextBridge.exposeInMainWorld("electronAPI", { getReferralInvites: () => ipcRenderer.invoke("get-referral-invites"), // Assembly AI Streaming - assemblyAiStreamingWarmup: (options) => - ipcRenderer.invoke("assemblyai-streaming-warmup", options), - assemblyAiStreamingStart: (options) => ipcRenderer.invoke("assemblyai-streaming-start", options), - assemblyAiStreamingSend: (audioBuffer) => - ipcRenderer.send("assemblyai-streaming-send", audioBuffer), - assemblyAiStreamingForceEndpoint: () => ipcRenderer.send("assemblyai-streaming-force-endpoint"), - assemblyAiStreamingStop: () => ipcRenderer.invoke("assemblyai-streaming-stop"), + assemblyAiStreamingWarmup: (options, managedRuntimeContext) => + invokeWithRuntimeContext("assemblyai-streaming-warmup", [options], managedRuntimeContext), + assemblyAiStreamingStart: (options, managedRuntimeContext) => + invokeWithRuntimeContext("assemblyai-streaming-start", [options], managedRuntimeContext), + assemblyAiStreamingSend: (transportId, audioBuffer) => + ipcRenderer.send("assemblyai-streaming-send", transportId, audioBuffer), + assemblyAiStreamingForceEndpoint: (transportId) => + ipcRenderer.send("assemblyai-streaming-force-endpoint", transportId), + assemblyAiStreamingStop: (transportId) => + ipcRenderer.invoke("assemblyai-streaming-stop", transportId), assemblyAiStreamingStatus: () => ipcRenderer.invoke("assemblyai-streaming-status"), onAssemblyAiPartialTranscript: registerListener( "assemblyai-partial-transcript", @@ -741,11 +777,16 @@ contextBridge.exposeInMainWorld("electronAPI", { ), // Deepgram Streaming - deepgramStreamingWarmup: (options) => ipcRenderer.invoke("deepgram-streaming-warmup", options), - deepgramStreamingStart: (options) => ipcRenderer.invoke("deepgram-streaming-start", options), - deepgramStreamingSend: (audioBuffer) => ipcRenderer.send("deepgram-streaming-send", audioBuffer), - deepgramStreamingFinalize: () => ipcRenderer.send("deepgram-streaming-finalize"), - deepgramStreamingStop: () => ipcRenderer.invoke("deepgram-streaming-stop"), + deepgramStreamingWarmup: (options, managedRuntimeContext) => + invokeWithRuntimeContext("deepgram-streaming-warmup", [options], managedRuntimeContext), + deepgramStreamingStart: (options, managedRuntimeContext) => + invokeWithRuntimeContext("deepgram-streaming-start", [options], managedRuntimeContext), + deepgramStreamingSend: (transportId, audioBuffer) => + ipcRenderer.send("deepgram-streaming-send", transportId, audioBuffer), + deepgramStreamingFinalize: (transportId) => + ipcRenderer.send("deepgram-streaming-finalize", transportId), + deepgramStreamingStop: (transportId) => + ipcRenderer.invoke("deepgram-streaming-stop", transportId), deepgramStreamingStatus: () => ipcRenderer.invoke("deepgram-streaming-status"), onDeepgramPartialTranscript: registerListener( "deepgram-partial-transcript", @@ -765,11 +806,15 @@ contextBridge.exposeInMainWorld("electronAPI", { ), // Corti streaming (BYOK) - cortiStreamingWarmup: (options) => ipcRenderer.invoke("corti-streaming-warmup", options), - cortiStreamingStart: (options) => ipcRenderer.invoke("corti-streaming-start", options), - cortiStreamingSend: (audioBuffer) => ipcRenderer.send("corti-streaming-send", audioBuffer), - cortiStreamingFinalize: () => ipcRenderer.send("corti-streaming-finalize"), - cortiStreamingStop: () => ipcRenderer.invoke("corti-streaming-stop"), + cortiStreamingWarmup: (options, managedRuntimeContext) => + invokeWithRuntimeContext("corti-streaming-warmup", [options], managedRuntimeContext), + cortiStreamingStart: (options, managedRuntimeContext) => + invokeWithRuntimeContext("corti-streaming-start", [options], managedRuntimeContext), + cortiStreamingSend: (transportId, audioBuffer) => + ipcRenderer.send("corti-streaming-send", transportId, audioBuffer), + cortiStreamingFinalize: (transportId) => + ipcRenderer.send("corti-streaming-finalize", transportId), + cortiStreamingStop: (transportId) => ipcRenderer.invoke("corti-streaming-stop", transportId), cortiStreamingStatus: () => ipcRenderer.invoke("corti-streaming-status"), onCortiPartialTranscript: registerListener( "corti-partial-transcript", @@ -786,17 +831,21 @@ contextBridge.exposeInMainWorld("electronAPI", { ), // Meeting transcription (streaming, dual-channel) - meetingTranscriptionPrepare: (options) => - ipcRenderer.invoke("meeting-transcription-prepare", options), - meetingTranscriptionStart: (options) => - ipcRenderer.invoke("meeting-transcription-start", options), - meetingTranscriptionSend: (buffer, source) => - ipcRenderer.send("meeting-transcription-send", buffer, source), + meetingTranscriptionPrepare: (options, managedRuntimeContext) => + invokeWithRuntimeContext("meeting-transcription-prepare", [options], managedRuntimeContext), + meetingTranscriptionStart: (options, managedRuntimeContext) => + invokeWithRuntimeContext("meeting-transcription-start", [options], managedRuntimeContext), + meetingTranscriptionSend: (sessionId, buffer, source) => + ipcRenderer.send("meeting-transcription-send", sessionId, buffer, source), meetingTranscriptionSetSystemAudioAvailable: (sessionId, available) => ipcRenderer.invoke("meeting-transcription-set-system-audio-available", sessionId, available), meetingTranscriptionStop: (expectedSessionId) => ipcRenderer.invoke("meeting-transcription-stop", expectedSessionId), - meetingTranscriptionCancel: () => ipcRenderer.invoke("meeting-transcription-cancel"), + meetingTranscriptionAbort: (expectedSessionId) => + ipcRenderer.invoke("meeting-transcription-abort", expectedSessionId), + commitMeetingTranscript: (payload) => ipcRenderer.invoke("meeting-transcription-commit", payload), + meetingTranscriptionCancel: (expectedTransportId) => + ipcRenderer.invoke("meeting-transcription-cancel", expectedTransportId), onMeetingTranscriptionSegment: registerListener( "meeting-transcription-segment", (callback) => (_event, data) => callback(data) @@ -823,10 +872,16 @@ contextBridge.exposeInMainWorld("electronAPI", { ), // Dictation realtime streaming - dictationRealtimeWarmup: (options) => ipcRenderer.invoke("dictation-realtime-warmup", options), - dictationRealtimeStart: (options) => ipcRenderer.invoke("dictation-realtime-start", options), - dictationRealtimeSend: (buffer) => ipcRenderer.send("dictation-realtime-send", buffer), - dictationRealtimeStop: () => ipcRenderer.invoke("dictation-realtime-stop"), + dictationRealtimeWarmup: (options, managedRuntimeContext) => + invokeWithRuntimeContext("dictation-realtime-warmup", [options], managedRuntimeContext), + dictationRealtimeStart: (options, managedRuntimeContext) => + invokeWithRuntimeContext("dictation-realtime-start", [options], managedRuntimeContext), + dictationRealtimeSend: (transportId, buffer) => + ipcRenderer.send("dictation-realtime-send", transportId, buffer), + dictationRealtimeStop: (transportId) => + ipcRenderer.invoke("dictation-realtime-stop", transportId), + dictationStreamingAbort: (transportId) => + ipcRenderer.invoke("dictation-streaming-abort", transportId), onDictationRealtimePartial: registerListener( "dictation-realtime-partial", (callback) => (_event, data) => callback(data) @@ -948,13 +1003,15 @@ contextBridge.exposeInMainWorld("electronAPI", { (callback) => (_event, payload) => callback(payload) ), onPreviewHide: registerListener("preview-hide", (callback) => () => callback()), - startDictationPreview: (opts) => ipcRenderer.invoke("start-dictation-preview", opts), + startDictationPreview: (opts, managedRuntimeContext) => + invokeWithRuntimeContext("start-dictation-preview", [opts], managedRuntimeContext), stopDictationPreview: (opts) => ipcRenderer.invoke("stop-dictation-preview", opts), dismissDictationPreview: () => ipcRenderer.invoke("dismiss-dictation-preview"), updateDictationPreview: (text) => ipcRenderer.invoke("update-dictation-preview", text), completeDictationPreview: (payload) => ipcRenderer.invoke("complete-dictation-preview", payload), hideDictationPreview: () => ipcRenderer.invoke("hide-dictation-preview"), - sendDictationPreviewAudio: (data) => ipcRenderer.send("dictation-preview-audio", data), + sendDictationPreviewAudio: (transportId, data) => + ipcRenderer.send("dictation-preview-audio", transportId, data), acquireRecordingLock: (pipeline) => ipcRenderer.invoke("acquire-recording-lock", pipeline), releaseRecordingLock: (pipeline) => ipcRenderer.invoke("release-recording-lock", pipeline), diff --git a/src/AppRouter.jsx b/src/AppRouter.jsx index cad94f05b4..bb3b985611 100644 --- a/src/AppRouter.jsx +++ b/src/AppRouter.jsx @@ -6,11 +6,14 @@ import MeetingNotificationOverlay from "./components/MeetingNotificationOverlay. import UpdateNotificationOverlay from "./components/UpdateNotificationOverlay.tsx"; import WindowControls from "./components/WindowControls.tsx"; import BackgroundModelDownloadTray from "./components/onboarding/BackgroundModelDownloadTray.tsx"; +import ManagedEnterpriseModelCoordinator from "./components/onboarding/ManagedEnterpriseModelCoordinator.tsx"; import { Card, CardContent } from "./components/ui/card.tsx"; import { LEGACY_ONBOARDING_STEP_KEY, ONBOARDING_SESSION_KEY } from "./components/onboarding/flow"; import { useAuth } from "./hooks/useAuth"; import { useTheme } from "./hooks/useTheme"; import { usePolicyStore } from "./stores/policyStore"; +import { useEnterpriseIdentityStore } from "./stores/enterpriseIdentityStore"; +import { isEnterpriseInferenceReady } from "./helpers/enterpriseInferenceReadiness"; import { isControlPanelWindow } from "./utils/windowContext.ts"; // Either marker means the flow is mid-way: the legacy step key is kept for @@ -40,12 +43,21 @@ export default function AppRouter() { function MainApp() { const { isSignedIn, isGracePeriodOnly, isLoaded: authLoaded } = useAuth(); const policyStatus = usePolicyStore((state) => state.status); + const enterpriseStatus = useEnterpriseIdentityStore((state) => state.status); + const enterpriseFailClosed = useEnterpriseIdentityStore((state) => state.failClosed); const policyResolved = !isSignedIn || policyStatus === "managed" || policyStatus === "unmanaged" || policyStatus === "error"; - const isWaitingForPolicyStart = isSignedIn && !policyResolved; + const enterpriseInferenceReady = isEnterpriseInferenceReady({ + authLoaded, + policyResolved, + isSignedIn, + enterpriseStatus, + enterpriseFailClosed, + }); + const isWaitingForPolicyStart = !enterpriseInferenceReady; const autoSyncReady = authLoaded && policyResolved; const [showOnboarding, setShowOnboarding] = useState(false); @@ -132,7 +144,10 @@ function MainApp() { }, [isControlPanel, isLoading, isWaitingForPolicyStart, showOnboarding]); useEffect(() => { - if (isLoading || isWaitingForPolicyStart) return; + if (isLoading || isWaitingForPolicyStart) { + void window.electronAPI?.setOnboardingActive?.(true); + return; + } const onboardingCompleted = localStorage.getItem("onboardingCompleted") === "true"; const normalAppVisible = onboardingCompleted && (!isControlPanel || !showOnboarding); @@ -208,9 +223,13 @@ function MainApp() { }> + ) : ( - + <> + + + ); } diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx index 5876e5a0de..aa7e9fc000 100644 --- a/src/components/ControlPanel.tsx +++ b/src/components/ControlPanel.tsx @@ -23,6 +23,7 @@ import { useSettings } from "../hooks/useSettings"; import { useAuth } from "../hooks/useAuth"; import { useJoinableWorkspaces } from "../hooks/useJoinableWorkspaces"; import { useUsage } from "../hooks/useUsage"; +import { useManagedLocalModelLock } from "../hooks/useManagedLocalModelLock"; import { decideUpsell } from "../lib/upsell"; import { useCollapsibleSidebar } from "../hooks/useCollapsibleSidebar"; import { @@ -44,7 +45,6 @@ import { isAgentAllowed, isControlPanelViewAllowed, isPolicyActionAllowed, - isTranscriptionContextAllowed, isUpdateRequiredByOrg, } from "../stores/policyRules"; import { @@ -85,6 +85,17 @@ import { consumePendingInvitationToken, clearPendingInvitationToken, } from "../utils/pendingInvitationToken"; +import { useEnterpriseIdentityStore } from "../stores/enterpriseIdentityStore"; +import { useWorkspaceStore } from "../stores/workspaceStore"; +import { canApplyPendingCloudMigration } from "./onboarding/managedLocalModels"; +import { + captureManagedRuntimeAuthorizationContext, + isManagedLocalTranscriptionRuntimeAllowed, + resolveManagedLocalTranscriptionRuntime, +} from "../helpers/managedLocalTranscriptionRuntime"; +import { captureRuntimeAuthorizationLease } from "../helpers/runtimeAuthorizationBoundary"; +import { resolveTranscriptionRoute } from "../helpers/transcriptionRoute"; +import { getTranscriptionProviders } from "../models/ModelRegistry"; const platform = getCachedPlatform(); @@ -113,6 +124,13 @@ interface ControlPanelProps { export default function ControlPanel({ initialSettingsSection }: ControlPanelProps = {}) { const { t } = useTranslation(); + const managedTranscriptionLock = useManagedLocalModelLock("transcription"); + const enterpriseIdentityStatus = useEnterpriseIdentityStore((state) => state.status); + const enterpriseIdentityFailClosed = useEnterpriseIdentityStore((state) => state.failClosed); + const workspaceIdentityResolved = useWorkspaceStore( + (state) => state.loaded && !state.loading && !state.error + ); + const enterpriseIdentityExpected = useWorkspaceStore((state) => state.activeWorkspaceId !== null); const history = useTranscriptions(); const [isLoading, setIsLoading] = useState(true); const [showSettings, setShowSettings] = useState(!!initialSettingsSection); @@ -398,13 +416,34 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro const isPending = localStorage.getItem("pendingCloudMigration") === "true"; const alreadyShown = localStorage.getItem("cloudMigrationShown") === "true"; if (!isPending || alreadyShown) return; + if ( + !canApplyPendingCloudMigration( + enterpriseIdentityStatus, + enterpriseIdentityFailClosed, + managedTranscriptionLock.managed, + workspaceIdentityResolved, + enterpriseIdentityExpected + ) + ) { + return; + } cloudMigrationProcessed.current = true; setUseLocalWhisper(false); setCloudTranscriptionMode("openwhispr"); localStorage.removeItem("pendingCloudMigration"); setShowCloudMigrationBanner(true); - }, [authLoaded, isSignedIn, setUseLocalWhisper, setCloudTranscriptionMode]); + }, [ + authLoaded, + enterpriseIdentityExpected, + enterpriseIdentityFailClosed, + enterpriseIdentityStatus, + isSignedIn, + managedTranscriptionLock.managed, + setUseLocalWhisper, + setCloudTranscriptionMode, + workspaceIdentityResolved, + ]); useEffect(() => { if (platform === "darwin" || gpuBannerDismissed) return; @@ -626,30 +665,85 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro const retryTranscription = useCallback( async (id: number, options?: { isRecover?: boolean }) => { + const requestId = crypto.randomUUID(); + let pendingCommit = false; + const authorization = captureRuntimeAuthorizationLease(["reasoning", "transcription"], () => { + void window.electronAPI.cancelUploadTranscription?.(requestId); + }); try { + authorization.assertCurrent(); const s = getSettings(); - if (!isTranscriptionContextAllowed(usePolicyStore.getState(), s, "dictation")) { + const runtime = resolveManagedLocalTranscriptionRuntime(s); + if (!isManagedLocalTranscriptionRuntimeAllowed(runtime, usePolicyStore.getState())) { toast({ title: t("common.managedByOrg"), variant: "default" }); return; } - const result = await window.electronAPI.retryTranscription(id, { - useLocalWhisper: s.useLocalWhisper, - localTranscriptionProvider: s.localTranscriptionProvider, - cloudTranscriptionMode: s.cloudTranscriptionMode, - cloudTranscriptionProvider: s.cloudTranscriptionProvider, - cloudTranscriptionModel: s.cloudTranscriptionModel, - cloudTranscriptionBaseUrl: s.cloudTranscriptionBaseUrl, - cortiEnvironment: s.cortiEnvironment, - cortiTenant: s.cortiTenant, - parakeetModel: s.parakeetModel, - whisperModel: s.whisperModel, - preferredLanguage: s.preferredLanguage, - transcriptionMode: s.transcriptionMode, - remoteTranscriptionType: s.remoteTranscriptionType, - remoteTranscriptionUrl: s.remoteTranscriptionUrl, - remoteTranscriptionModel: s.remoteTranscriptionModel, + if (runtime.kind === "error") return; + const effectiveSettings = runtime.settings; + const route = resolveTranscriptionRoute({ + settings: effectiveSettings, + providers: getTranscriptionProviders(), + request: { + effectiveLanguage: + effectiveSettings.preferredLanguage === "auto" + ? undefined + : effectiveSettings.preferredLanguage, + }, }); - if (result.success && result.transcription) { + const contextRoute = effectiveSettings.useLocalWhisper + ? { + transcriptionMode: "local" as const, + provider: effectiveSettings.localTranscriptionProvider, + model: + effectiveSettings.localTranscriptionProvider === "nvidia" + ? effectiveSettings.parakeetModel + : effectiveSettings.whisperModel, + } + : effectiveSettings.cloudTranscriptionMode === "openwhispr" + ? { transcriptionMode: "openwhispr" as const, provider: "openwhispr", model: null } + : route.transport === "proxied" || route.transport === "http-batch" + ? { + transcriptionMode: + route.provider === "self-hosted" + ? ("self-hosted" as const) + : ("providers" as const), + provider: route.provider, + model: route.model, + } + : { + transcriptionMode: effectiveSettings.transcriptionMode, + provider: effectiveSettings.cloudTranscriptionProvider || "openai", + model: effectiveSettings.cloudTranscriptionModel || null, + }; + authorization.assertCurrent(); + const result = await window.electronAPI.retryTranscription( + id, + { + useLocalWhisper: effectiveSettings.useLocalWhisper, + localTranscriptionProvider: effectiveSettings.localTranscriptionProvider, + cloudTranscriptionMode: effectiveSettings.cloudTranscriptionMode, + cloudTranscriptionProvider: effectiveSettings.cloudTranscriptionProvider, + cloudTranscriptionModel: effectiveSettings.cloudTranscriptionModel, + cloudTranscriptionBaseUrl: effectiveSettings.cloudTranscriptionBaseUrl, + cortiEnvironment: effectiveSettings.cortiEnvironment, + cortiTenant: effectiveSettings.cortiTenant, + parakeetModel: effectiveSettings.parakeetModel, + whisperModel: effectiveSettings.whisperModel, + preferredLanguage: effectiveSettings.preferredLanguage, + transcriptionMode: effectiveSettings.transcriptionMode, + remoteTranscriptionType: effectiveSettings.remoteTranscriptionType, + remoteTranscriptionUrl: effectiveSettings.remoteTranscriptionUrl, + remoteTranscriptionModel: effectiveSettings.remoteTranscriptionModel, + }, + requestId, + captureManagedRuntimeAuthorizationContext({ + managed: runtime.managed, + ...contextRoute, + }) + ); + pendingCommit = result.success && result.pendingCommit === true; + authorization.assertCurrent(); + if (result.success && pendingCommit && result.transcription) { const rawText = result.transcription.text; let finalTranscription = result.transcription; @@ -668,6 +762,7 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro import("../helpers/audioManager"), import("../stores/settingsStore"), ]); + authorization.assertCurrent(); const settings = getEffectiveSettings(); const agentName = localStorage.getItem("agentName") || null; const route = resolveReasoningRoute(rawText, settings, agentName, false, true); @@ -675,15 +770,24 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro const { text, translated } = await executeTranslationChain({ text: rawText, cleanupReachable: route.cleanupReachable, - runCleanup: (currentText: string) => - ReasoningService.processText( + runCleanup: (currentText: string) => { + authorization.assertCurrent(); + return ReasoningService.processText( currentText, getEffectiveCleanupModel(), agentName, route.cleanupConfig - ), - runTranslate: (currentText: string) => - ReasoningService.processText(currentText, route.model, agentName, route.config), + ); + }, + runTranslate: (currentText: string) => { + authorization.assertCurrent(); + return ReasoningService.processText( + currentText, + route.model, + agentName, + route.config + ); + }, shouldTranslate: shouldRunTranslateStep( settings.translationSourceLanguage, settings.translationTargetLanguage @@ -707,22 +811,20 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro "transcription" ), }); + authorization.assertCurrent(); translationApplied = translated; if (text !== rawText) { - const updated = await window.electronAPI.updateTranscriptionText( - id, - text, - rawText - ); - if (updated.success && updated.transcription) { - finalTranscription = updated.transcription; - } + authorization.assertCurrent(); + finalTranscription = { ...finalTranscription, text }; } } else { // Translation disabled/unreachable since recording — fall through to cleanup. handledTranslation = false; } - } catch { + } catch (error) { + if ((error as { code?: string }).code === "AUTHORIZATION_BOUNDARY_CHANGED") { + throw error; + } // Reasoning failed — keep the raw STT result } } @@ -737,25 +839,25 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro import("../services/ReasoningService"), import("../stores/settingsStore"), ]); + authorization.assertCurrent(); const model = getEffectiveCleanupModel(); const isCloud = isCloudCleanupMode(); if (model || isCloud) { const agentName = localStorage.getItem("agentName") || null; + authorization.assertCurrent(); const reasonedText = await ReasoningService.processText(rawText, model, agentName, { disableThinking: getSettings().cleanupDisableThinking, }); + authorization.assertCurrent(); if (hasTextContent(reasonedText) && reasonedText !== rawText) { - const updated = await window.electronAPI.updateTranscriptionText( - id, - reasonedText, - rawText - ); - if (updated.success && updated.transcription) { - finalTranscription = updated.transcription; - } + authorization.assertCurrent(); + finalTranscription = { ...finalTranscription, text: reasonedText }; } } - } catch { + } catch (error) { + if ((error as { code?: string }).code === "AUTHORIZATION_BOUNDARY_CHANGED") { + throw error; + } // Reasoning failed — keep the raw STT result } } @@ -766,6 +868,7 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro // translate step moves the text into the target language, so anything else // still has to be scripted as the language that was dictated. try { + authorization.assertCurrent(); const outputLanguage = result.transcription.route_kind === "translation" ? (translationApplied @@ -780,21 +883,36 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro finalTranscription.text ) ); + authorization.assertCurrent(); if (scripted !== finalTranscription.text) { - const updated = await window.electronAPI.updateTranscriptionText( - id, - scripted, - rawText - ); - if (updated.success && updated.transcription) { - finalTranscription = updated.transcription; - } + authorization.assertCurrent(); + finalTranscription = { ...finalTranscription, text: scripted }; + } + } catch (error) { + if ((error as { code?: string }).code === "AUTHORIZATION_BOUNDARY_CHANGED") { + throw error; } - } catch { // Conversion failed — keep the text as transcribed } - updateInStore(finalTranscription); + authorization.assertCurrent(); + const committed = await window.electronAPI.commitRetryTranscription( + id, + requestId, + finalTranscription.text, + rawText + ); + pendingCommit = false; + authorization.assertCurrent(); + if (!committed.success || !committed.transcription) { + toast({ + title: t("controlPanel.history.retryError"), + description: committed.error, + variant: "destructive", + }); + return; + } + updateInStore(committed.transcription); toast({ title: t( options?.isRecover @@ -809,11 +927,17 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro variant: "destructive", }); } - } catch { + } catch (error) { + if ((error as { code?: string }).code === "AUTHORIZATION_BOUNDARY_CHANGED") return; toast({ title: t("controlPanel.history.retryError"), variant: "destructive", }); + } finally { + authorization.dispose(); + if (pendingCommit) { + void window.electronAPI.cancelUploadTranscription?.(requestId); + } } }, [toast, t, useCleanupModel] diff --git a/src/components/OnboardingFlow.tsx b/src/components/OnboardingFlow.tsx index 8b440a5ffb..d38bb5d0ff 100644 --- a/src/components/OnboardingFlow.tsx +++ b/src/components/OnboardingFlow.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, Loader2 } from "lucide-react"; import AuthenticationStep from "./AuthenticationStep"; import EmailVerificationStep from "./EmailVerificationStep"; import UseCaseStep from "./onboarding/UseCaseStep"; @@ -15,6 +15,7 @@ import CalendarConnectionsStep from "./onboarding/CalendarConnectionsStep"; import SetupChoiceStep from "./onboarding/SetupChoiceStep"; import { ByokProviderStep, LocalModelSetupStep } from "./onboarding/ProviderSetupStep"; import { AlertDialog } from "./ui/dialog"; +import { Button } from "./ui/button"; import { useAuth } from "../hooks/useAuth"; import { signOut } from "../lib/auth"; import { usePermissions } from "../hooks/usePermissions"; @@ -51,6 +52,21 @@ import { useOnboardingSession } from "./onboarding/useOnboardingSession"; import { clearPendingLocalModels, hasPendingLocalModels } from "./onboarding/pendingLocalModels"; import { ActivationModeSelector } from "./ui/ActivationModeSelector"; import LinuxPttSetupInfo from "./ui/LinuxPttSetupInfo"; +import EnterpriseModelSetupStep from "./onboarding/EnterpriseModelSetupStep"; +import { + EnterpriseConfigErrorActions, + ManagedSetupSignOutButton, +} from "./onboarding/ManagedSetupBlockedActions"; +import { + selectEffectiveManagedLocalModels, + useEnterpriseIdentityStore, +} from "../stores/enterpriseIdentityStore"; +import { + areManagedLocalModelBindingsReady, + readManagedLocalModelBinding, + requiresManagedLocalModels, + translateManagedLocalModelError, +} from "./onboarding/managedLocalModels"; interface OnboardingFlowProps { onComplete: (options?: { openSettings?: boolean }) => void; @@ -143,6 +159,26 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { setupMode: session.setupMode, activeWorkspace: enterpriseWorkspace, }); + const enterpriseIdentity = useEnterpriseIdentityStore(); + const enterpriseLocalModels = + enterpriseIdentity.workspaceId === enterpriseWorkspace?.id + ? selectEffectiveManagedLocalModels(enterpriseIdentity) + : null; + const requiresEnterpriseModels = requiresManagedLocalModels(enterpriseLocalModels); + const enterpriseConfigPending = Boolean( + enterpriseWorkspace && + (enterpriseIdentity.workspaceId !== enterpriseWorkspace.id || + enterpriseIdentity.status === "idle" || + enterpriseIdentity.status === "loading") + ); + const enterpriseConfigError = + enterpriseWorkspace && + enterpriseIdentity.workspaceId === enterpriseWorkspace.id && + enterpriseIdentity.status === "error" + ? enterpriseIdentity.error + ? translateManagedLocalModelError(enterpriseIdentity.error, t) + : t("managedLocalModels.onboarding.configUnavailableFallback") + : null; useEffect(() => { if ( @@ -165,7 +201,8 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { isSignedIn && session.authPath === "account" && (!workspacesLoaded || - (!activeWorkspace && skipSetupChoiceForEnterprise && Boolean(enterpriseWorkspace))); + (!activeWorkspace && skipSetupChoiceForEnterprise && Boolean(enterpriseWorkspace)) || + enterpriseConfigPending); const route = useMemo( () => @@ -174,8 +211,15 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { setupMode: session.setupMode, agentAllowed, skipSetupChoice: skipSetupChoiceForEnterprise, + requiresEnterpriseModels, }), - [agentAllowed, session.authPath, session.setupMode, skipSetupChoiceForEnterprise] + [ + agentAllowed, + requiresEnterpriseModels, + session.authPath, + session.setupMode, + skipSetupChoiceForEnterprise, + ] ); const currentStepId = reconcileStepWithRoute(session.currentStepId, route); const compact = COMPACT_STEPS.has(currentStepId); @@ -186,6 +230,84 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { } }, [currentStepId, session.currentStepId, setSession]); + // Authentication is not complete for onboarding until both workspace + // membership and the active Enterprise configuration have resolved. This + // keeps a slow or failed configuration request from silently skipping the + // managed-model step. + useEffect(() => { + if ( + !isSignedIn || + session.authPath !== "account" || + session.currentStepId !== "auth" || + !workspacesLoaded + ) { + return; + } + if (enterpriseWorkspace && activeWorkspace?.id !== enterpriseWorkspace.id) { + setActiveWorkspace(enterpriseWorkspace.id); + return; + } + if (enterpriseWorkspace && (enterpriseConfigPending || enterpriseConfigError)) return; + goTo( + enterpriseWorkspace + ? requiresEnterpriseModels + ? "enterprise-models" + : "permissions" + : session.setupMode === "cloud" + ? "setup-choice" + : "permissions" + ); + }, [ + activeWorkspace?.id, + enterpriseConfigError, + enterpriseConfigPending, + enterpriseWorkspace, + goTo, + isSignedIn, + requiresEnterpriseModels, + session.authPath, + session.currentStepId, + session.setupMode, + setActiveWorkspace, + workspacesLoaded, + ]); + + const [, setManagedBindingRevision] = useState(0); + useEffect(() => { + const refresh = () => setManagedBindingRevision((revision) => revision + 1); + window.addEventListener("openwhispr-managed-local-model-binding", refresh); + return () => window.removeEventListener("openwhispr-managed-local-model-binding", refresh); + }, []); + const managedBinding = + enterpriseIdentity.accountId && enterpriseWorkspace + ? readManagedLocalModelBinding(enterpriseIdentity.accountId, enterpriseWorkspace.id) + : null; + const requiredManagedBindingsReady = areManagedLocalModelBindingsReady( + enterpriseLocalModels, + managedBinding + ); + + useEffect(() => { + if ( + !requiresEnterpriseModels || + requiredManagedBindingsReady || + currentStepId === "auth" || + currentStepId === "enterprise-models" || + enterpriseConfigPending || + enterpriseConfigError + ) { + return; + } + goTo("enterprise-models"); + }, [ + currentStepId, + enterpriseConfigError, + enterpriseConfigPending, + goTo, + requiredManagedBindingsReady, + requiresEnterpriseModels, + ]); + // AppRouter releases this only after it has committed the normal app. Keeping // the gate active across this component's unmount prevents a one-frame flash // of the dictation pill or another normal-app overlay at completion/error. @@ -334,7 +456,8 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { // Only preserve a pending download when the completed route still uses // local models. A user who walks Back and finishes on Cloud/BYOK must not // be switched back to a stale local selection when it completes later. - const routeKeepsLocalModels = mode === "local"; + const routeKeepsLocalModels = + mode === "local" || (mode === "managed" && requiresEnterpriseModels); if (routeKeepsLocalModels && (options.localPending || hasPendingLocalModels())) { localStorage.setItem("localSetupPending", "true"); } else { @@ -364,6 +487,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { session.authPath, t, withExtraDictationHotkeys, + requiresEnterpriseModels, ] ); @@ -371,11 +495,23 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { // Enterprise workspace is confirmed. Finish them without writing provider or // model settings, just as if Notes had been their final step originally. useEffect(() => { - if (!skipSetupChoiceForEnterprise || session.currentStepId !== "setup-choice" || isFinishing) { + if (!skipSetupChoiceForEnterprise || session.currentStepId !== "setup-choice") { return; } + if (requiresEnterpriseModels) { + goTo("enterprise-models"); + return; + } + if (isFinishing) return; void finalizeOnboarding("managed"); - }, [finalizeOnboarding, isFinishing, session.currentStepId, skipSetupChoiceForEnterprise]); + }, [ + finalizeOnboarding, + goTo, + isFinishing, + requiresEnterpriseModels, + session.currentStepId, + skipSetupChoiceForEnterprise, + ]); const applyReasoningSelectionToAllScopes = useCallback( (mode: "byok" | "local") => { @@ -442,7 +578,15 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { const continueFromCurrentStep = useCallback(async () => { // A banner from an earlier failed attempt must not outlive the retry. setFatalError(null); - if (currentStepId === "notes" && workspaceResolutionPending) return; + if ( + currentStepId === "notes" && + (workspaceResolutionPending || !requiredManagedBindingsReady) + ) { + setFatalError( + !requiredManagedBindingsReady ? t("managedLocalModels.onboarding.restartDownload") : null + ); + return; + } if (currentStepId === "permissions") { if (getPlatform() === "darwin" && !permissions.accessibilityPermissionGranted) { setAccessibilitySkipped(true); @@ -526,6 +670,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { withExtraDictationHotkeys, workspaceResolutionPending, skipSetupChoiceForEnterprise, + requiredManagedBindingsReady, ]); const skipLocalSetup = useCallback(async () => { @@ -555,7 +700,9 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { case "assistant-demo": return assistantDemoSuccess; case "notes": - return !workspaceResolutionPending; + return !workspaceResolutionPending && requiredManagedBindingsReady; + case "enterprise-models": + return stageReady; case "byok-dictation": case "byok-assistant": case "local-dictation": @@ -569,6 +716,47 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { const renderStep = () => { switch (currentStepId) { case "auth": + if (isSignedIn && session.authPath === "account") { + return ( +
+ {enterpriseConfigError ? ( +
+ +
+

+ {t("managedLocalModels.onboarding.unavailableTitle")} +

+

{enterpriseConfigError}

+

+ {t("managedLocalModels.onboarding.unavailableInstructions")} +

+
+ { + if ( + enterpriseIdentity.accountId && + enterpriseIdentity.workspaceId && + enterpriseIdentity.authGeneration != null + ) { + void enterpriseIdentity.refresh( + enterpriseIdentity.accountId, + enterpriseIdentity.workspaceId, + enterpriseIdentity.authGeneration, + true + ); + } + }} + /> +
+ ) : ( +
+ + {t("managedLocalModels.onboarding.checking")} +
+ )} +
+ ); + } return (
{pendingVerificationEmail ? ( @@ -577,7 +765,6 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { onVerified={() => { setPendingVerificationEmail(null); setAuthPath("account"); - goTo(session.setupMode === "cloud" ? "setup-choice" : "permissions"); }} onBack={() => { // Abandoning verification leaves a live session for the @@ -597,7 +784,6 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { }} onAuthComplete={() => { setAuthPath("account"); - goTo(session.setupMode === "cloud" ? "setup-choice" : "permissions"); }} onNeedsVerification={setPendingVerificationEmail} /> @@ -605,6 +791,36 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
); + case "enterprise-models": + if ( + !enterpriseLocalModels || + !enterpriseIdentity.accountId || + !enterpriseIdentity.workspaceId || + enterpriseIdentity.authGeneration == null + ) { + return null; + } + return ( +
+ + +
+ ); + case "permissions": return ( : undefined} // History is the only Back gate. This preserves the branch's provider // escape path and also lets users return from setup choice/languages. onBack={hasShellNavigation && session.history.length > 0 ? goBack : undefined} diff --git a/src/components/ReasoningModelSelector.tsx b/src/components/ReasoningModelSelector.tsx index 6cd9a57166..19b6d3e94f 100644 --- a/src/components/ReasoningModelSelector.tsx +++ b/src/components/ReasoningModelSelector.tsx @@ -33,6 +33,8 @@ import { reconcileProviderSelection, } from "../stores/policyRules"; import { usePolicySnapshot } from "../hooks/usePolicy"; +import { useManagedLocalModelLock } from "../hooks/useManagedLocalModelLock"; +import { ManagedLocalModelNotice } from "./settings/ManagedLocalModelNotice"; type CloudModelOption = { value: string; @@ -322,7 +324,7 @@ function GpuStatusBadge() { return null; } -export default function ReasoningModelSelector({ +function ReasoningModelSelectorContent({ reasoningModel, setReasoningModel, localReasoningProvider, @@ -723,3 +725,9 @@ export default function ReasoningModelSelector({ ); } + +export default function ReasoningModelSelector(props: ReasoningModelSelectorProps) { + const lock = useManagedLocalModelLock("reasoning"); + if (lock.managed) return ; + return ; +} diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index 2a24ab161f..6181eaf5e8 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -131,6 +131,11 @@ import { } from "../stores/policyRules"; import { usePolicyModeOptions, usePolicySnapshot } from "../hooks/usePolicy"; import { usePolicyStore } from "../stores/policyStore"; +import { useManagedLocalModelLock } from "../hooks/useManagedLocalModelLock"; +import { + canSelectManagedLocalMode, + constrainManagedLocalModeOptions, +} from "./onboarding/managedLocalModels"; import { canManageSystemAudioInApp } from "../utils/systemAudioAccess"; import WorkspaceSection from "./settings/WorkspaceSection"; import WorkspaceBillingOverview from "./settings/WorkspaceBillingOverview"; @@ -294,6 +299,7 @@ function TranscriptionSection({ toast, }: TranscriptionSectionProps) { const { t } = useTranslation(); + const managedLocalLock = useManagedLocalModelLock("transcription"); const { modes: transcriptionModes, effectiveMode: effectiveTranscriptionMode, @@ -331,7 +337,12 @@ function TranscriptionSection({ transcriptionMode, { byokProviders: TRANSCRIPTION_POLICY_PROVIDER_IDS } ); + const selectableTranscriptionModes = constrainManagedLocalModeOptions( + transcriptionModes, + managedLocalLock.managed + ); const handleTranscriptionModeSelect = (mode: InferenceMode) => { + if (!canSelectManagedLocalMode(managedLocalLock.managed, mode)) return; if (!isModeAllowed(mode)) return; if (mode === "openwhispr" && !isSignedIn) { startOnboarding(); @@ -421,7 +432,7 @@ function TranscriptionSection({ return (
@@ -916,6 +927,9 @@ export default function SettingsPage({ const setTranslationKey = useSettingsStore((s) => s.setTranslationKey); const settingsPolicyState = usePolicySnapshot(); + const managedTranscriptionLock = useManagedLocalModelLock("transcription"); + const managedReasoningLock = useManagedLocalModelLock("reasoning"); + const hasManagedLocalModels = managedTranscriptionLock.managed || managedReasoningLock.managed; const agentAllowedByPolicy = isAgentAllowed(settingsPolicyState); const historyLockedByPolicy = lockedLocalHistoryValue(settingsPolicyState) !== null; const effectiveDataRetentionEnabled = effectiveLocalHistoryEnabled( @@ -1378,6 +1392,13 @@ export default function SettingsPage({ const handleRemoveModels = useCallback(() => { if (isRemovingModels) return; + if (hasManagedLocalModels) { + showAlertDialog({ + title: t("managedLocalModels.settings.title"), + description: t("managedLocalModels.settings.removalBlocked"), + }); + return; + } showConfirmDialog({ title: t("settingsPage.developer.removeModels.title"), @@ -1422,7 +1443,14 @@ export default function SettingsPage({ } }, }); - }, [isRemovingModels, cachePathHint, showConfirmDialog, showAlertDialog, t]); + }, [ + cachePathHint, + hasManagedLocalModels, + isRemovingModels, + showAlertDialog, + showConfirmDialog, + t, + ]); const { isSignedIn, isLoaded, user, refetch } = useAuth(); // Signed out there is nothing to load and the plan grid is purely @@ -4121,7 +4149,13 @@ EOF`,
); } + +export default function TranscriptionModelPicker(props: TranscriptionModelPickerProps) { + const lock = useManagedLocalModelLock("transcription"); + if (lock.managed) return ; + return ; +} diff --git a/src/components/UpgradePrompt.tsx b/src/components/UpgradePrompt.tsx index a41ca88417..826abdc43e 100644 --- a/src/components/UpgradePrompt.tsx +++ b/src/components/UpgradePrompt.tsx @@ -4,6 +4,11 @@ import { useTranslation } from "react-i18next"; import { useUsage } from "../hooks/useUsage"; import { useBillingPortal } from "../hooks/useBillingPortal"; import { useSettingsStore } from "../stores/settingsStore"; +import { useManagedLocalModelLock } from "../hooks/useManagedLocalModelLock"; +import { + applyManagedLocalModeChange, + canSelectManagedLocalMode, +} from "./onboarding/managedLocalModels"; interface UpgradePromptProps { open: boolean; @@ -22,6 +27,7 @@ export default function UpgradePrompt({ const usage = useUsage(); const { openBillingPortal } = useBillingPortal(usage); const isPastDue = usage?.isPastDue ?? false; + const managedLocalLock = useManagedLocalModelLock("transcription"); return ( @@ -68,16 +74,20 @@ export default function UpgradePrompt({ disabled={usage?.checkoutLoading} /> )} - { - const s = useSettingsStore.getState(); - s.setTranscriptionMode("providers"); - s.setCloudTranscriptionMode("byok"); - onOpenChange(false); - }} - /> + {canSelectManagedLocalMode(managedLocalLock.managed, "providers") && ( + { + applyManagedLocalModeChange(managedLocalLock.managed, "providers", () => { + const s = useSettingsStore.getState(); + s.setTranscriptionMode("providers"); + s.setCloudTranscriptionMode("byok"); + onOpenChange(false); + }); + }} + /> + )} { const sendGeneration = ++sendGenerationRef.current; const cancelled = () => sendGeneration !== sendGenerationRef.current; - clearToolActivity(); - let responseAnnounced = false; - const announceResponse = () => { - if (responseAnnounced) return; - responseAnnounced = true; - onResponseContent?.(); + let assistantId: string | null = null; + let authorizationInvalidated = false; + const discardAssistant = () => { + if (!assistantId) return; + setMessages((previousMessages) => + previousMessages.filter((message) => message.id !== assistantId) + ); }; - const settings = getSettings(); - const chatConfig = selectResolvedLLMConfig(settings, "chatIntelligence"); - const chatAgentMode = chatConfig.mode || "openwhispr"; - const policyState = usePolicyStore.getState(); - const policyProvider = - chatAgentMode === "openwhispr" - ? "openwhispr" - : chatAgentMode === "local" - ? "local" - : chatConfig.provider; - if ( - !isAgentAllowed(policyState) || - !isLlmSelectionAllowed(policyState, { mode: chatAgentMode, provider: policyProvider }) - ) { - // The user message is already appended; answer it instead of dead-ending silently. - const restriction = !isAgentAllowed(policyState) - ? t("common.policyAgentRestricted") - : t("common.policyAiProcessingRestricted"); - announceResponse(); - setMessages((prev) => [ - ...prev, - { id: crypto.randomUUID(), role: "assistant", content: restriction, isStreaming: false }, - ]); - return; - } - - setAgentState("thinking"); - const isCloudAgent = chatAgentMode === "openwhispr" && settings.isSignedIn; - const isLanAgent = chatAgentMode === "self-hosted" && !!chatConfig.remoteUrl; - const isCustomAgent = chatAgentMode === "providers" && chatConfig.provider === "custom"; - const isLocalProvider = - !isEnterpriseProvider(chatConfig.provider) && - ![ - "openai", - "groq", - "custom", - "anthropic", - "gemini", - "tinfoil", - "openrouter", - "corti", - ].includes(chatConfig.provider); - const localModelCanUseTool = - isLocalProvider && estimateModelSizeB(chatConfig.model) >= LOCAL_TOOL_MIN_PARAMS_B; - const supportsTools = isCloudAgent || !isLocalProvider || localModelCanUseTool; - - const scope = searchScopeRef.current; - let registry: ToolRegistry | null = null; - if (supportsTools) { - const scopeKey = scope ? `${scope.spaceId}:${scope.folderId ?? ""}` : ""; - // The calendar tool reads the shared provider-deduped events table, - // so any connected provider enables it. - const calendarConnected = - settings.gcalConnected || settings.mcalConnected || settings.appleCalendarConnected; - const webSearchEnabled = isWebSearchAllowed(usePolicyStore.getState()); - const cacheKey = `${settings.isSignedIn}-${calendarConnected}-${settings.cloudBackupEnabled}-${scopeKey}-${webSearchEnabled}`; - if (toolRegistryRef.current?.key === cacheKey) { - registry = toolRegistryRef.current.registry; - } else { - registry = createToolRegistry({ - isSignedIn: settings.isSignedIn, - calendarConnected, - cloudBackupEnabled: settings.cloudBackupEnabled, - searchScope: scope, - webSearchEnabled, - }); - toolRegistryRef.current = { key: cacheKey, registry }; + const handleAuthorizationInvalidation = () => { + if (authorizationInvalidated) return; + authorizationInvalidated = true; + ReasoningService.cancelActiveStream(); + discardAssistant(); + if (sendGeneration === sendGenerationRef.current) { + setAgentState("idle"); + clearToolActivity(); } - } - - const ragContext = await buildRAGContext(userText, scope); - if (cancelled() || !mountedRef.current) return; - const combinedContext = [noteContextRef.current, ragContext].filter(Boolean).join("\n\n"); - // The user's dictionary rides on every conversation so replies use their - // jargon — same suffix the dictation prompts carry. - let systemPrompt = appendDictionarySuffix( - getAgentSystemPrompt( - registry?.getAll().map((t) => t.name), - combinedContext || undefined - ), - getDictionaryHintWords(settings), - settings.uiLanguage + }; + const authorization = captureRuntimeAuthorizationLease( + "reasoning", + handleAuthorizationInvalidation ); + const toolExecutionContext: ToolExecutionContext = { + assertAuthorized: () => authorization.assertCurrent(), + }; + const authorizationCancelled = () => { + if (!authorization.isCurrent()) handleAuthorizationInvalidation(); + return authorizationInvalidated; + }; + + try { + clearToolActivity(); + let responseAnnounced = false; + const announceResponse = () => { + if (responseAnnounced || authorizationCancelled()) return; + responseAnnounced = true; + onResponseContent?.(); + }; + const settings = getSettings(); + const chatConfig = selectResolvedLLMConfig(settings, "chatIntelligence"); + const chatAgentMode = chatConfig.mode || "openwhispr"; + const policyState = usePolicyStore.getState(); + const policyProvider = + chatAgentMode === "openwhispr" + ? "openwhispr" + : chatAgentMode === "local" + ? "local" + : chatConfig.provider; + if ( + !isAgentAllowed(policyState) || + !isLlmSelectionAllowed(policyState, { mode: chatAgentMode, provider: policyProvider }) + ) { + // The user message is already appended; answer it instead of dead-ending silently. + const restriction = !isAgentAllowed(policyState) + ? t("common.policyAgentRestricted") + : t("common.policyAiProcessingRestricted"); + announceResponse(); + if (authorizationCancelled()) return; + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + role: "assistant", + content: restriction, + isStreaming: false, + }, + ]); + return; + } - const history: HistoryMessage[] = allMessages - .slice(-20) - .map((m) => ({ role: m.role, content: m.content })); + setAgentState("thinking"); + const isCloudAgent = chatAgentMode === "openwhispr" && settings.isSignedIn; + const isLanAgent = chatAgentMode === "self-hosted" && !!chatConfig.remoteUrl; + const isCustomAgent = chatAgentMode === "providers" && chatConfig.provider === "custom"; + const isLocalProvider = + !isEnterpriseProvider(chatConfig.provider) && + ![ + "openai", + "groq", + "custom", + "anthropic", + "gemini", + "tinfoil", + "openrouter", + "corti", + ].includes(chatConfig.provider); + const localModelCanUseTool = + isLocalProvider && estimateModelSizeB(chatConfig.model) >= LOCAL_TOOL_MIN_PARAMS_B; + const supportsTools = isCloudAgent || !isLocalProvider || localModelCanUseTool; + + const scope = searchScopeRef.current; + let registry: ToolRegistry | null = null; + if (supportsTools) { + const scopeKey = scope ? `${scope.spaceId}:${scope.folderId ?? ""}` : ""; + // The calendar tool reads the shared provider-deduped events table, + // so any connected provider enables it. + const calendarConnected = + settings.gcalConnected || settings.mcalConnected || settings.appleCalendarConnected; + const webSearchEnabled = isWebSearchAllowed(usePolicyStore.getState()); + const cacheKey = `${settings.isSignedIn}-${calendarConnected}-${settings.cloudBackupEnabled}-${scopeKey}-${webSearchEnabled}`; + if (toolRegistryRef.current?.key === cacheKey) { + registry = toolRegistryRef.current.registry; + } else { + registry = createToolRegistry({ + isSignedIn: settings.isSignedIn, + calendarConnected, + cloudBackupEnabled: settings.cloudBackupEnabled, + searchScope: scope, + webSearchEnabled, + }); + toolRegistryRef.current = { key: cacheKey, registry }; + } + } - const selectedContext = options?.selectedContext; - if (selectedContext) { - transformLastUserMessage(history, (message) => - typeof message.content === "string" - ? { ...message, content: buildAgentRequestText(message.content, selectedContext) } - : null + const ragContext = await buildRAGContext(userText, scope); + if (authorizationCancelled() || cancelled() || !mountedRef.current) return; + const combinedContext = [noteContextRef.current, ragContext].filter(Boolean).join("\n\n"); + // The user's dictionary rides on every conversation so replies use their + // jargon — same suffix the dictation prompts carry. + let systemPrompt = appendDictionarySuffix( + getAgentSystemPrompt( + registry?.getAll().map((t) => t.name), + combinedContext || undefined + ), + getDictionaryHintWords(settings), + settings.uiLanguage ); - } - // Attach the screenshot to the command it came with, but only where a - // model can actually see it; otherwise drop it silently — an image - // problem must never cost the user their command. BYOK models get it as - // an image part when the registry says they have vision; the cloud - // agent gets it as a dedicated field the server vision-routes (older - // servers strip the unknown field, which degrades to a plain command). - const attachment = - options?.attachment && - !isCloudAgent && - !isLanAgent && - !isLocalProvider && - providerSupportsStreamImages(chatConfig.provider) && - getCloudModel(chatConfig.model)?.supportsVision - ? options.attachment - : null; - const cloudScreenContext = - options?.attachment && isCloudAgent - ? { data: options.attachment.image, mediaType: options.attachment.mediaType } - : null; - if (attachment) { - // The screenshot needs its grounding instruction, exactly like the - // dictation path pairs the suffix with an attached image. Restore it - // for cloud context once openwhispr-api#157 vision-routes that field. - systemPrompt = appendScreenContextSuffix(systemPrompt, settings.uiLanguage); - } - if (attachment) { - transformLastUserMessage(history, (message) => ({ - role: "user", - content: [ - { type: "text", text: message.content as string }, - { type: "image", image: attachment.image, mediaType: attachment.mediaType }, - ], - })); - } + const history: HistoryMessage[] = allMessages + .slice(-20) + .map((m) => ({ role: m.role, content: m.content })); + + const selectedContext = options?.selectedContext; + if (selectedContext) { + transformLastUserMessage(history, (message) => + typeof message.content === "string" + ? { ...message, content: buildAgentRequestText(message.content, selectedContext) } + : null + ); + } - const llmMessages = [{ role: "system", content: systemPrompt }, ...history]; + // Attach the screenshot to the command it came with, but only where a + // model can actually see it; otherwise drop it silently — an image + // problem must never cost the user their command. BYOK models get it as + // an image part when the registry says they have vision; the cloud + // agent gets it as a dedicated field the server vision-routes (older + // servers strip the unknown field, which degrades to a plain command). + const attachment = + options?.attachment && + !isCloudAgent && + !isLanAgent && + !isLocalProvider && + providerSupportsStreamImages(chatConfig.provider) && + getCloudModel(chatConfig.model)?.supportsVision + ? options.attachment + : null; + const cloudScreenContext = + options?.attachment && isCloudAgent + ? { data: options.attachment.image, mediaType: options.attachment.mediaType } + : null; + if (attachment) { + // The screenshot needs its grounding instruction, exactly like the + // dictation path pairs the suffix with an attached image. Restore it + // for cloud context once openwhispr-api#157 vision-routes that field. + systemPrompt = appendScreenContextSuffix(systemPrompt, settings.uiLanguage); + } + if (attachment) { + transformLastUserMessage(history, (message) => ({ + role: "user", + content: [ + { type: "text", text: message.content as string }, + { type: "image", image: attachment.image, mediaType: attachment.mediaType }, + ], + })); + } - const assistantId = crypto.randomUUID(); - setMessages((prev) => [ - ...prev, - { id: assistantId, role: "assistant", content: "", isStreaming: true }, - ]); - setAgentState("streaming"); + const llmMessages = [{ role: "system", content: systemPrompt }, ...history]; - try { - let fullContent = ""; - let stream: AsyncGenerator; - - if (isCloudAgent) { - const executeToolCall = registry - ? async (name: string, argsJson: string) => { - const tool = registry.get(name); - if (!tool) - return { - data: `Unknown tool: ${name}`, - displayText: t("agentMode.tools.unknownTool", { name }), - }; - let args: Record; - try { - args = JSON.parse(argsJson); - } catch { - return { - data: `Invalid tool arguments for ${name}`, - displayText: t("agentMode.tools.invalidArgs", { name }), - }; + const createdAssistantId = crypto.randomUUID(); + assistantId = createdAssistantId; + setMessages((prev) => [ + ...prev, + { id: createdAssistantId, role: "assistant", content: "", isStreaming: true }, + ]); + setAgentState("streaming"); + + try { + let fullContent = ""; + let stream: AsyncGenerator; + + if (isCloudAgent) { + const executeToolCall = registry + ? async (name: string, argsJson: string) => { + authorization.assertCurrent(); + const tool = registry.get(name); + if (!tool) + return { + data: `Unknown tool: ${name}`, + displayText: t("agentMode.tools.unknownTool", { name }), + }; + let args: Record; + try { + args = JSON.parse(argsJson); + } catch { + return { + data: `Invalid tool arguments for ${name}`, + displayText: t("agentMode.tools.invalidArgs", { name }), + }; + } + const result = await tool.execute(args, toolExecutionContext); + authorization.assertCurrent(); + const data = result.success + ? typeof result.data === "string" + ? result.data + : JSON.stringify(result.data) + : result.displayText; + const metadata = + result.success && result.data && typeof result.data === "object" + ? (result.data as Record | Array>) + : undefined; + return { data, displayText: result.displayText, metadata }; } - const result = await tool.execute(args); - const data = result.success - ? typeof result.data === "string" - ? result.data - : JSON.stringify(result.data) - : result.displayText; - const metadata = - result.success && result.data && typeof result.data === "object" - ? (result.data as Record | Array>) - : undefined; - return { data, displayText: result.displayText, metadata }; - } - : undefined; - - stream = ReasoningService.processTextStreamingCloud(llmMessages, { - systemPrompt, - tools: registry?.getAll().map((t) => ({ - name: t.name, - description: t.description, - parameters: t.parameters, - })), - executeToolCall, - ...(cloudScreenContext ? { screenContext: cloudScreenContext } : {}), - }); - } else { - const aiTools = registry?.toAISDKFormat(); - stream = ReasoningService.processTextStreamingAI( - llmMessages, - chatConfig.model, - chatConfig.provider, - { - systemPrompt, - inferenceScope: "chatIntelligence", - lanUrl: isLanAgent ? chatConfig.remoteUrl : undefined, - baseUrl: isCustomAgent ? chatConfig.cloudBaseUrl || undefined : undefined, - customApiKey: - isCustomAgent || isLanAgent ? chatConfig.customApiKey || undefined : undefined, - disableThinking: chatConfig.disableThinking, - }, - aiTools - ); - } + : undefined; - for await (const chunk of stream) { - if (!mountedRef.current) { - ReasoningService.cancelActiveStream(); - break; - } - if (chunk.type === "content") { - if (chunk.text) announceResponse(); - fullContent += chunk.text; - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + stream = ReasoningService.processTextStreamingCloud(llmMessages, { + systemPrompt, + tools: registry?.getAll().map((t) => ({ + name: t.name, + description: t.description, + parameters: t.parameters, + })), + executeToolCall, + ...(cloudScreenContext ? { screenContext: cloudScreenContext } : {}), + }); + } else { + const aiTools = registry?.toAISDKFormat(toolExecutionContext); + stream = ReasoningService.processTextStreamingAI( + llmMessages, + chatConfig.model, + chatConfig.provider, + { + systemPrompt, + inferenceScope: "chatIntelligence", + lanUrl: isLanAgent ? chatConfig.remoteUrl : undefined, + baseUrl: isCustomAgent ? chatConfig.cloudBaseUrl || undefined : undefined, + customApiKey: + isCustomAgent || isLanAgent ? chatConfig.customApiKey || undefined : undefined, + disableThinking: chatConfig.disableThinking, + }, + aiTools ); - } else if (chunk.type === "tool_calls") { - if (chunk.calls.length > 0) announceResponse(); - for (const call of chunk.calls) { - setAgentState("tool-executing"); - beginToolActivity( - call.name, - t(`agentMode.tools.${call.name}Status`, { defaultValue: `Using ${call.name}...` }) + } + + for await (const chunk of stream) { + if (authorizationCancelled()) return; + if (!mountedRef.current) { + ReasoningService.cancelActiveStream(); + break; + } + if (chunk.type === "content") { + if (chunk.text) announceResponse(); + fullContent += chunk.text; + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) ); + } else if (chunk.type === "tool_calls") { + if (chunk.calls.length > 0) announceResponse(); + for (const call of chunk.calls) { + setAgentState("tool-executing"); + beginToolActivity( + call.name, + t(`agentMode.tools.${call.name}Status`, { defaultValue: `Using ${call.name}...` }) + ); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + toolCalls: [ + ...(m.toolCalls || []), + { + id: call.id, + name: call.name, + arguments: call.arguments, + status: "executing" as const, + }, + ], + } + : m + ) + ); + } + } else if (chunk.type === "tool_result") { setMessages((prev) => prev.map((m) => - m.id === assistantId + m.id === assistantId && m.toolCalls ? { ...m, - toolCalls: [ - ...(m.toolCalls || []), - { - id: call.id, - name: call.name, - arguments: call.arguments, - status: "executing" as const, - }, - ], + toolCalls: m.toolCalls.map((tc) => + tc.id === chunk.callId + ? { + ...tc, + status: "completed" as const, + result: chunk.displayText, + ...(chunk.metadata ? { metadata: chunk.metadata } : {}), + } + : tc + ), } : m ) ); + setAgentState("streaming"); + completeToolActivity(); } - } else if (chunk.type === "tool_result") { + } + + if (authorizationCancelled()) return; + + if (!responseAnnounced && !cancelled()) { + // The stream ended without a visible token or tool call (think-only + // local model, empty completion). Show that as a reply so every + // listener — the assistant panel's thinking state included — sees a + // terminal outcome. + fullContent = t("agentMode.chat.emptyResponse"); + announceResponse(); + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + ); + } + + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m)) + ); + + if (authorizationCancelled()) return; + const finalMsg = messagesRef.current.find((m) => m.id === assistantId); + onStreamComplete?.(assistantId, fullContent, finalMsg?.toolCalls); + } catch (error) { + if (authorizationCancelled()) { + discardAssistant(); + return; + } + if (cancelled()) { + setMessages((prev) => + prev.map((message) => + message.id === assistantId ? { ...message, isStreaming: false } : message + ) + ); + } else { + announceResponse(); setMessages((prev) => prev.map((m) => - m.id === assistantId && m.toolCalls + m.id === assistantId ? { ...m, - toolCalls: m.toolCalls.map((tc) => - tc.id === chunk.callId - ? { - ...tc, - status: "completed" as const, - result: chunk.displayText, - ...(chunk.metadata ? { metadata: chunk.metadata } : {}), - } - : tc - ), + content: `${t("agentMode.chat.errorPrefix")}: ${(error as Error).message}`, + isStreaming: false, } : m ) ); - setAgentState("streaming"); - completeToolActivity(); } } - if (!responseAnnounced && !cancelled()) { - // The stream ended without a visible token or tool call (think-only - // local model, empty completion). Show that as a reply so every - // listener — the assistant panel's thinking state included — sees a - // terminal outcome. - fullContent = t("agentMode.chat.emptyResponse"); - announceResponse(); - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) - ); - } - - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m)) - ); - - const finalMsg = messagesRef.current.find((m) => m.id === assistantId); - onStreamComplete?.(assistantId, fullContent, finalMsg?.toolCalls); - } catch (error) { - if (cancelled()) { - setMessages((prev) => - prev.map((message) => - message.id === assistantId ? { ...message, isStreaming: false } : message - ) - ); - } else { - announceResponse(); - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { - ...m, - content: `${t("agentMode.chat.errorPrefix")}: ${(error as Error).message}`, - isStreaming: false, - } - : m - ) - ); - } + setAgentState("idle"); + completeToolActivity(); + } finally { + authorization.dispose(); } - - setAgentState("idle"); - completeToolActivity(); }, [ t, diff --git a/src/components/chat/useVoiceDraft.ts b/src/components/chat/useVoiceDraft.ts index 5b2c8a73fa..8c64ecaa34 100644 --- a/src/components/chat/useVoiceDraft.ts +++ b/src/components/chat/useVoiceDraft.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "../../hooks/useAuth"; import { useSettings } from "../../hooks/useSettings"; -import { useSettingsStore } from "../../stores/settingsStore"; +import { getSettings } from "../../stores/settingsStore"; import { getBaseLanguageCode } from "../../utils/languageSupport"; import { transcribeFile, @@ -9,6 +9,12 @@ import { type FileTranscriptionConfig, } from "../../services/fileTranscription"; import { analyserRms } from "../../utils/audioLevel"; +import { + isManagedLocalTranscriptionRuntimeAllowed, + resolveManagedLocalTranscriptionRuntime, +} from "../../helpers/managedLocalTranscriptionRuntime"; +import { captureRuntimeAuthorizationLease } from "../../helpers/runtimeAuthorizationBoundary"; +import { usePolicyStore } from "../../stores/policyStore"; export type VoiceDraftStatus = "idle" | "recording" | "transcribing"; @@ -24,23 +30,7 @@ interface UseVoiceDraftOptions { */ export function useVoiceDraft({ onTranscript, onError }: UseVoiceDraftOptions) { const { isSignedIn } = useAuth(); - const settings = useSettings(); - const { - useLocalWhisper, - whisperModel, - localTranscriptionProvider, - parakeetModel, - cloudTranscriptionMode, - cloudTranscriptionProvider, - cloudTranscriptionBaseUrl, - cloudTranscriptionModel, - preferredLanguage, - transcriptionMode, - remoteTranscriptionUrl, - remoteTranscriptionModel, - } = settings; - const cortiEnvironment = useSettingsStore((s) => s.cortiEnvironment); - const cortiTenant = useSettingsStore((s) => s.cortiTenant); + useSettings(); const [status, setStatus] = useState("idle"); const [elapsed, setElapsed] = useState(0); @@ -52,25 +42,34 @@ export function useVoiceDraft({ onTranscript, onError }: UseVoiceDraftOptions) { const levelBufRef = useRef | null>(null); const chunksRef = useRef([]); const discardRef = useRef(false); + const operationGenerationRef = useRef(0); + const activeRequestIdRef = useRef(null); + const authorizationLeaseRef = useRef | null>( + null + ); - const buildConfig = (): FileTranscriptionConfig => ({ - useLocalWhisper, - localTranscriptionProvider: localTranscriptionProvider as string, - whisperModel, - parakeetModel, - isOpenWhisprCloud: isSignedIn && cloudTranscriptionMode === "openwhispr" && !useLocalWhisper, - getApiKey: () => getTranscriptionApiKey(cloudTranscriptionProvider as string, settings), - cloudTranscriptionProvider: cloudTranscriptionProvider as string, - cloudTranscriptionBaseUrl: cloudTranscriptionBaseUrl || "", - cloudTranscriptionModel, - // Empty = auto-detect; the resolver supplies a default where one is required. - language: getBaseLanguageCode(preferredLanguage) || "", - cortiEnvironment, - cortiTenant, - transcriptionMode, - remoteTranscriptionUrl, - remoteTranscriptionModel, - }); + const buildConfig = (): FileTranscriptionConfig => { + const settings = getSettings(); + return { + useLocalWhisper: settings.useLocalWhisper, + localTranscriptionProvider: settings.localTranscriptionProvider, + whisperModel: settings.whisperModel, + parakeetModel: settings.parakeetModel, + isOpenWhisprCloud: + isSignedIn && settings.cloudTranscriptionMode === "openwhispr" && !settings.useLocalWhisper, + getApiKey: () => getTranscriptionApiKey(settings.cloudTranscriptionProvider, settings), + cloudTranscriptionProvider: settings.cloudTranscriptionProvider, + cloudTranscriptionBaseUrl: settings.cloudTranscriptionBaseUrl || "", + cloudTranscriptionModel: settings.cloudTranscriptionModel, + // Empty = auto-detect; the resolver supplies a default where one is required. + language: getBaseLanguageCode(settings.preferredLanguage) || "", + cortiEnvironment: settings.cortiEnvironment, + cortiTenant: settings.cortiTenant, + transcriptionMode: settings.transcriptionMode, + remoteTranscriptionUrl: settings.remoteTranscriptionUrl, + remoteTranscriptionModel: settings.remoteTranscriptionModel, + }; + }; // Latest-value refs so the recorder's onstop (bound at start time) uses // current settings and callbacks. @@ -104,22 +103,40 @@ export function useVoiceDraft({ onTranscript, onError }: UseVoiceDraftOptions) { }, []); const finishRecording = useCallback(async () => { + const operationGeneration = operationGenerationRef.current; + const authorization = authorizationLeaseRef.current; const blob = new Blob(chunksRef.current, { type: "audio/webm" }); chunksRef.current = []; teardownCapture(); - if (discardRef.current || blob.size === 0) { + if (discardRef.current || blob.size === 0 || !authorization?.isCurrent()) { + authorization?.dispose(); + if (authorizationLeaseRef.current === authorization) authorizationLeaseRef.current = null; setStatus("idle"); return; } setStatus("transcribing"); let tempPath: string | null = null; + let requestId: string | null = null; try { + authorization.assertCurrent(); const buffer = await blob.arrayBuffer(); + authorization.assertCurrent(); const saved = await window.electronAPI.saveTempAudio(buffer); tempPath = saved.path; - const result = await transcribeFile(tempPath, buildConfigRef.current(), false); + authorization.assertCurrent(); + const runtime = resolveManagedLocalTranscriptionRuntime(buildConfigRef.current()); + if (!isManagedLocalTranscriptionRuntimeAllowed(runtime, usePolicyStore.getState())) { + throw new Error("Transcription is restricted by your organization."); + } + if (runtime.kind === "error") throw Object.assign(new Error(runtime.message), runtime); + requestId = crypto.randomUUID(); + activeRequestIdRef.current = requestId; + authorization.assertCurrent(); + const result = await transcribeFile(tempPath, runtime.settings, false, { requestId }); + authorization.assertCurrent(); + if (operationGeneration !== operationGenerationRef.current || discardRef.current) return; const text = result.text?.trim(); if (!result.success || !text) { onErrorRef.current(result.error || ""); @@ -127,17 +144,47 @@ export function useVoiceDraft({ onTranscript, onError }: UseVoiceDraftOptions) { onTranscriptRef.current(text); } } catch (error) { + if ( + operationGeneration !== operationGenerationRef.current || + discardRef.current || + (error as { code?: string }).code === "AUTHORIZATION_BOUNDARY_CHANGED" + ) { + return; + } onErrorRef.current(error instanceof Error ? error.message : String(error)); } finally { + if (activeRequestIdRef.current === requestId) activeRequestIdRef.current = null; if (tempPath) void window.electronAPI.deleteTempAudio(tempPath); - setStatus("idle"); + authorization.dispose(); + if (authorizationLeaseRef.current === authorization) authorizationLeaseRef.current = null; + if (operationGeneration === operationGenerationRef.current) setStatus("idle"); } }, [teardownCapture]); const start = useCallback(async () => { if (recorderRef.current) return; + const operationGeneration = ++operationGenerationRef.current; + const authorization = captureRuntimeAuthorizationLease("transcription", () => { + if (operationGeneration !== operationGenerationRef.current) return; + operationGenerationRef.current += 1; + discardRef.current = true; + const requestId = activeRequestIdRef.current; + activeRequestIdRef.current = null; + if (requestId) void window.electronAPI.cancelUploadTranscription?.(requestId); + recorderRef.current?.stop(); + setStatus("idle"); + }); + authorizationLeaseRef.current?.dispose(); + authorizationLeaseRef.current = authorization; + let stream: MediaStream | null = null; try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const runtime = resolveManagedLocalTranscriptionRuntime(buildConfigRef.current()); + if (!isManagedLocalTranscriptionRuntimeAllowed(runtime, usePolicyStore.getState())) { + throw new Error("Transcription is restricted by your organization."); + } + authorization.assertCurrent(); + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + authorization.assertCurrent(); const context = new AudioContext(); const source = context.createMediaStreamSource(stream); const analyser = context.createAnalyser(); @@ -165,7 +212,16 @@ export function useVoiceDraft({ onTranscript, onError }: UseVoiceDraftOptions) { analyserRef.current = analyser; setStatus("recording"); } catch (error) { + stream?.getTracks().forEach((track) => track.stop()); teardownCapture(); + authorization.dispose(); + if (authorizationLeaseRef.current === authorization) authorizationLeaseRef.current = null; + if ( + operationGeneration !== operationGenerationRef.current || + (error as { code?: string }).code === "AUTHORIZATION_BOUNDARY_CHANGED" + ) { + return; + } onErrorRef.current(error instanceof Error ? error.message : String(error)); } }, [finishRecording, teardownCapture]); @@ -176,13 +232,24 @@ export function useVoiceDraft({ onTranscript, onError }: UseVoiceDraftOptions) { const cancel = useCallback(() => { discardRef.current = true; + operationGenerationRef.current += 1; + const requestId = activeRequestIdRef.current; + activeRequestIdRef.current = null; + if (requestId) void window.electronAPI.cancelUploadTranscription?.(requestId); recorderRef.current?.stop(); + setStatus("idle"); }, []); // Discard silently if the surface unmounts mid-take. useEffect( () => () => { discardRef.current = true; + operationGenerationRef.current += 1; + const requestId = activeRequestIdRef.current; + activeRequestIdRef.current = null; + if (requestId) void window.electronAPI.cancelUploadTranscription?.(requestId); + authorizationLeaseRef.current?.dispose(); + authorizationLeaseRef.current = null; recorderRef.current?.stop(); }, [] diff --git a/src/components/notes/UploadAudioView.tsx b/src/components/notes/UploadAudioView.tsx index 46e723e2b3..348a116531 100644 --- a/src/components/notes/UploadAudioView.tsx +++ b/src/components/notes/UploadAudioView.tsx @@ -36,6 +36,7 @@ import { useAuth } from "../../hooks/useAuth"; import { useUsage } from "../../hooks/useUsage"; import { useSettings } from "../../hooks/useSettings"; import { useStartOnboarding } from "../../hooks/useStartOnboarding"; +import { useManagedLocalModelLock } from "../../hooks/useManagedLocalModelLock"; import { getAllReasoningModels, getBatchTranscriptionModel, @@ -64,11 +65,19 @@ import { MAX_SPEAKER_COUNT } from "../../constants/speakerDetection.json"; import BatchQueueView from "./BatchQueueView"; import { generateNoteTitle } from "../../utils/generateTitle"; import { getBaseLanguageCode } from "../../utils/languageSupport"; -import { isTranscriptionContextAllowed } from "../../stores/policyRules"; import { usePolicyStore } from "../../stores/policyStore"; import { usePolicySnapshot, useTranscriptionContextAllowed } from "../../hooks/usePolicy"; import { resolveTranscriptionRoute } from "../../helpers/transcriptionRoute"; import { saveUploadNote, uploadTitleFallback } from "../../services/uploadNotes"; +import { + isManagedLocalTranscriptionRuntimeAllowed, + resolveManagedLocalTranscriptionRuntime, +} from "../../helpers/managedLocalTranscriptionRuntime"; +import { + applyManagedLocalModeChange, + canSelectManagedLocalMode, +} from "../onboarding/managedLocalModels"; +import { captureRuntimeAuthorizationLease } from "../../helpers/runtimeAuthorizationBoundary"; type UploadState = "idle" | "selected" | "downloading" | "transcribing" | "complete" | "error"; @@ -242,6 +251,7 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa const [providerReady, setProviderReady] = useState(null); const { isSignedIn } = useAuth(); + const managedLocalLock = useManagedLocalModelLock("transcription"); const usage = useUsage(); // The server enforces the free-tier size limit regardless, so an unresolved // entitlement should not block a payer's upload. @@ -344,6 +354,11 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa mountedRef.current = true; return () => { mountedRef.current = false; + runIdRef.current += 1; + if (activeRequestIdRef.current) { + void window.electronAPI.cancelUploadTranscription?.(activeRequestIdRef.current); + activeRequestIdRef.current = null; + } if (urlDownloadActiveRef.current) { window.electronAPI.cancelUrlDownload(); } @@ -601,7 +616,8 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa }; const handleTranscribe = async () => { if (!file || batch.isProcessing) return; - if (!isTranscriptionContextAllowed(usePolicyStore.getState(), getSettings(), "upload")) { + const runtime = resolveManagedLocalTranscriptionRuntime(buildTranscriptionConfig()); + if (!isManagedLocalTranscriptionRuntimeAllowed(runtime, usePolicyStore.getState())) { setError(t("common.managedByOrg")); return; } @@ -610,6 +626,9 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa const runId = ++runIdRef.current; const requestId = crypto.randomUUID(); activeRequestIdRef.current = requestId; + const authorization = captureRuntimeAuthorizationLease("transcription", () => { + if (runId === runIdRef.current) cancelTranscription(); + }); setState("transcribing"); setError(null); setProgress(0); @@ -641,6 +660,7 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa } try { + authorization.assertCurrent(); const diarization: DiarizationSettings = { enabled: diarizationEnabled, localModelsReady: !!diarizationModelsReady, @@ -648,7 +668,7 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa }; const res: FileTranscriptionResult = await transcribeFileWithSpeakers( currentFile.path, - buildTranscriptionConfig(), + runtime.kind === "ready" ? runtime.settings : buildTranscriptionConfig(), diarization, currentFile.durationSeconds, { requestId, timestamps: true } @@ -657,6 +677,7 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa }); if (runId !== runIdRef.current) return; + authorization.assertCurrent(); if (progressRef.current) clearInterval(progressRef.current); if (progressCleanupRef.current) progressCleanupRef.current(); @@ -675,11 +696,14 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa if (currentFile.fromUrl) { title = currentFile.name; } else { + authorization.assertCurrent(); const aiTitle = await generateTitle(res.text); if (runId !== runIdRef.current) return; + authorization.assertCurrent(); title = aiTitle || uploadTitleFallback(res.text, currentFile.name); } + authorization.assertCurrent(); const noteRes = await saveUploadNote({ title, text: res.text, @@ -690,6 +714,7 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa segments: res.segments, }); if (runId !== runIdRef.current) return; + authorization.assertCurrent(); if (noteRes.success && noteRes.note) setNoteId(noteRes.note.id); if (currentTempPath) { window.electronAPI.deleteTempFile(currentTempPath); @@ -719,6 +744,8 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa setError(err instanceof Error ? err.message : t("notes.upload.errorOccurred")); } setState("error"); + } finally { + authorization.dispose(); } }; @@ -839,7 +866,8 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa const startBatchProcessing = () => { if (state === "downloading" || state === "transcribing") return; - if (!isTranscriptionContextAllowed(usePolicyStore.getState(), getSettings(), "upload")) { + const runtime = resolveManagedLocalTranscriptionRuntime(buildTranscriptionConfig()); + if (!isManagedLocalTranscriptionRuntimeAllowed(runtime, usePolicyStore.getState())) { setBatchUrlNotice(t("common.managedByOrg")); return; } @@ -891,9 +919,11 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa const handleCreateAccount = useStartOnboarding(); const switchToCloud = () => { - setUploadTranscriptionMode("openwhispr"); - setUploadCloudTranscriptionMode("openwhispr"); - setUploadUseLocalWhisper(false); + applyManagedLocalModeChange(managedLocalLock.managed, "openwhispr", () => { + setUploadTranscriptionMode("openwhispr"); + setUploadCloudTranscriptionMode("openwhispr"); + setUploadUseLocalWhisper(false); + }); }; const getTranscribingLabel = (): string => { @@ -1102,6 +1132,7 @@ export default function UploadAudioView({ onNoteCreated, onOpenSettings }: Uploa byokTooLarge={byokTooLarge} requiresAccount={requiresAccount} isProUser={!!isProUser} + canSwitchToCloud={canSelectManagedLocalMode(managedLocalLock.managed, "openwhispr")} onUpgrade={() => usage?.openCheckout()} onCreateAccount={handleCreateAccount} onSwitchToCloud={switchToCloud} @@ -1530,6 +1561,7 @@ interface SelectedViewProps { byokTooLarge: boolean; requiresAccount: boolean; isProUser: boolean; + canSwitchToCloud: boolean; onUpgrade: () => void; onCreateAccount: () => void; onSwitchToCloud: () => void; @@ -1549,6 +1581,7 @@ function SelectedView({ byokTooLarge, requiresAccount, isProUser, + canSwitchToCloud, onUpgrade, onCreateAccount, onSwitchToCloud, @@ -1634,7 +1667,7 @@ function SelectedView({ )} {/* BYOK too large — signed in, Pro: Switch to Cloud */} - {byokTooLarge && !requiresAccount && isProUser && ( + {byokTooLarge && !requiresAccount && isProUser && canSwitchToCloud && ( +
+ )} + + ); +} + +function ModelCategory({ + title, + description, + models, + selected, + isInstalled, + isDownloading, + policyAllows, + onChoose, +}: { + title: string; + description: string; + models: DisplayModel[]; + selected: ManagedEnterpriseLocalModelSelection | null; + isInstalled: (model: ManagedEnterpriseLocalModelSelection) => boolean; + isDownloading: (model: ManagedEnterpriseLocalModelSelection) => boolean; + policyAllows: boolean; + onChoose: (model: DisplayModel) => void; +}): JSX.Element { + const { t } = useTranslation(); + return ( +
+

{title}

+

{description}

+
+ {models.map((model) => { + const installed = isInstalled(model); + const downloading = isDownloading(model); + const active = sameModel(selected, model); + return ( +
+ +
+

+ {model.name} +

+

+ {model.size} + {model.compatibilityMessage ? ` · ${model.compatibilityMessage}` : ""} +

+
+ {downloading ? ( + + + {t("onboarding.rehaul.local.downloadingShort")} + + ) : active && installed ? ( + + {t("onboarding.rehaul.local.selected")} + + ) : ( + + )} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/onboarding/ManagedEnterpriseModelCoordinator.tsx b/src/components/onboarding/ManagedEnterpriseModelCoordinator.tsx new file mode 100644 index 0000000000..7775f2ffc1 --- /dev/null +++ b/src/components/onboarding/ManagedEnterpriseModelCoordinator.tsx @@ -0,0 +1,818 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertCircle } from "lucide-react"; +import { useModelDownload } from "../../hooks/useModelDownload"; +import { modelRegistry } from "../../models/ModelRegistry"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog"; +import { isAgentAllowed, isModeAllowedByPolicy } from "../../stores/policyRules"; +import { usePolicyStore } from "../../stores/policyStore"; +import { usePolicySnapshot } from "../../hooks/usePolicy"; +import { + selectEffectiveManagedLocalModels, + useEnterpriseIdentityStore, +} from "../../stores/enterpriseIdentityStore"; +import type { ManagedEnterpriseLocalModelSelection } from "../../types/enterpriseIdentity"; +import EnterpriseModelSetupStep from "./EnterpriseModelSetupStep"; +import { + EnterpriseConfigErrorActions, + ManagedSetupFooterActions, +} from "./ManagedSetupBlockedActions"; +import { + applyManagedLocalModelSelectionWhenAllowed, + beginManagedLocalModelReplacement, + canAutomaticallyStartManagedLocalModelReplacement, + clearManagedLocalModelCategoryError, + createResolvedManagedLocalModelBinding, + createManagedLocalModelRetryBinding, + finishManagedLocalModelReplacement, + getManagedLocalModelInventoryRetryDelay, + getManagedLocalModelBindingError, + isManagedLocalModelDownloadActive, + isManagedLocalModelBindingSelectionCurrent, + MANAGED_LOCAL_MODEL_ERROR_CODES, + MANAGED_LOCAL_MODEL_BINDINGS_KEY, + recordManagedLocalModelDownloadError, + readManagedLocalModelBinding, + requiresManagedLocalModels, + recordPendingManagedLocalModelError, + resolveManagedLocalModelSelection, + resolveManagedLocalModelInventorySnapshot, + resolveManagedLocalSetupReadiness, + runWithManagedLocalModelReconciliationLock, + setManagedLocalModelCategoryError, + setManagedLocalModelBindingError, + shouldRecoverManagedLocalModelFromInventory, + updateManagedLocalSetupReadiness, + translateManagedLocalModelError, + writeManagedLocalModelBinding, + type ManagedLocalModelBinding, +} from "./managedLocalModels"; +import { forgetPendingLocalModel, rememberPendingLocalModel } from "./pendingLocalModels"; +import { + enforceManagedLocalModelSettings, + reconcileManagedLocalModelSettings, +} from "./managedLocalModelSettings"; + +interface InstalledModels { + whisper: Set; + parakeet: Set; + reasoning: Set; + downloadingWhisper: Set; + downloadingParakeet: Set; + downloadingReasoning: Set; + whisperKnown: boolean; + parakeetKnown: boolean; + reasoningKnown: boolean; +} + +function emptyInstalledModels(): InstalledModels { + return { + whisper: new Set(), + parakeet: new Set(), + reasoning: new Set(), + downloadingWhisper: new Set(), + downloadingParakeet: new Set(), + downloadingReasoning: new Set(), + whisperKnown: false, + parakeetKnown: false, + reasoningKnown: false, + }; +} + +function keepManagedSetupDialogOpen(_open: boolean): void {} + +function preventManagedSetupDialogDismissal(event: Event): void { + event.preventDefault(); +} + +export default function ManagedEnterpriseModelCoordinator({ + showUi = true, +}: { + showUi?: boolean; +}): JSX.Element | null { + const { t } = useTranslation(); + const accountId = useEnterpriseIdentityStore((state) => state.accountId); + const workspaceId = useEnterpriseIdentityStore((state) => state.workspaceId); + const authGeneration = useEnterpriseIdentityStore((state) => state.authGeneration); + const config = useEnterpriseIdentityStore(selectEffectiveManagedLocalModels); + const enterpriseStatus = useEnterpriseIdentityStore((state) => state.status); + const enterpriseFailClosed = useEnterpriseIdentityStore((state) => state.failClosed); + const authoritativeLocalModels = useEnterpriseIdentityStore( + (state) => state.config?.localModels ?? null + ); + const policy = usePolicySnapshot(); + const [installed, setInstalled] = useState(emptyInstalledModels); + const [inventoryReady, setInventoryReady] = useState(false); + const [inventoryRefreshAttempts, setInventoryRefreshAttempts] = useState(0); + const [resolvedParakeetCapability, setResolvedParakeetCapability] = useState<{ + identityKey: string; + supported: boolean | null; + } | null>(null); + const [focusedReadiness, setFocusedReadiness] = useState({ + identityKey: "", + ready: false, + }); + const [dismissedIdentity, setDismissedIdentity] = useState(null); + const [, setBindingRevision] = useState(0); + const [ownsReconciliation, setOwnsReconciliation] = useState(false); + const ownsReconciliationRef = useRef(false); + const startedReplacements = useRef(new Set()); + const seenRetryGeneration = useRef(null); + const appliedSelectionKeys = useRef<{ + transcription: string | null; + reasoning: string | null; + }>({ transcription: null, reasoning: null }); + const completed = localStorage.getItem("onboardingCompleted") === "true"; + const capabilityIdentityKey = `${accountId ?? ""}:${workspaceId ?? ""}:${authGeneration ?? ""}`; + const parakeetSupported = + resolvedParakeetCapability?.identityKey === capabilityIdentityKey + ? resolvedParakeetCapability.supported + : null; + + const refreshInstalled = useCallback(async (): Promise => { + const [whisper, parakeet, reasoning] = await Promise.all([ + window.electronAPI?.listWhisperModels?.().catch(() => undefined), + window.electronAPI?.listParakeetModels?.().catch(() => undefined), + window.electronAPI?.modelGetAll?.().catch(() => undefined), + ]); + setInstalled((current) => { + const whisperInstalled = resolveManagedLocalModelInventorySnapshot( + current.whisper, + whisper === undefined + ? undefined + : new Set(whisper.models.filter((model) => model.downloaded).map((model) => model.model)) + ); + const parakeetInstalled = resolveManagedLocalModelInventorySnapshot( + current.parakeet, + parakeet === undefined + ? undefined + : new Set(parakeet.models.filter((model) => model.downloaded).map((model) => model.model)) + ); + const reasoningInstalled = resolveManagedLocalModelInventorySnapshot( + current.reasoning, + reasoning === undefined + ? undefined + : new Set(reasoning.filter((model) => model.isDownloaded).map((model) => model.id)) + ); + return { + whisper: whisperInstalled.value, + parakeet: parakeetInstalled.value, + reasoning: reasoningInstalled.value, + downloadingWhisper: + whisper === undefined + ? current.downloadingWhisper + : new Set( + whisper.models.filter((model) => model.isDownloading).map((model) => model.model) + ), + downloadingParakeet: + parakeet === undefined + ? current.downloadingParakeet + : new Set( + parakeet.models.filter((model) => model.isDownloading).map((model) => model.model) + ), + downloadingReasoning: + reasoning === undefined + ? current.downloadingReasoning + : new Set(reasoning.filter((model) => model.isDownloading).map((model) => model.id)), + whisperKnown: whisperInstalled.known, + parakeetKnown: parakeetInstalled.known, + reasoningKnown: reasoningInstalled.known, + }; + }); + setInventoryReady(true); + setInventoryRefreshAttempts((attempts) => attempts + 1); + }, []); + const whisperDownload = useModelDownload({ + modelType: "whisper", + onDownloadComplete: refreshInstalled, + onTerminalError: (modelId, error) => { + if (ownsReconciliationRef.current) { + recordPendingManagedLocalModelError("dictation", modelId, error); + } + }, + }); + const parakeetDownload = useModelDownload({ + modelType: "parakeet", + onDownloadComplete: refreshInstalled, + onTerminalError: (modelId, error) => { + if (ownsReconciliationRef.current) { + recordPendingManagedLocalModelError("dictation", modelId, error); + } + }, + }); + const reasoningDownload = useModelDownload({ + modelType: "llm", + onDownloadComplete: refreshInstalled, + onTerminalError: (modelId, error) => { + if (ownsReconciliationRef.current) { + recordPendingManagedLocalModelError("assistant", modelId, error); + } + }, + }); + + useEffect(() => { + let mounted = true; + let release: (() => void) | null = null; + void runWithManagedLocalModelReconciliationLock(async () => { + if (!mounted) return; + ownsReconciliationRef.current = true; + setOwnsReconciliation(true); + await new Promise((resolve) => { + release = resolve; + }); + }).finally(() => { + ownsReconciliationRef.current = false; + if (mounted) setOwnsReconciliation(false); + }); + return () => { + mounted = false; + ownsReconciliationRef.current = false; + release?.(); + }; + }, []); + + useEffect(() => { + reconcileManagedLocalModelSettings({ + ownsReconciliation, + status: enterpriseStatus, + failClosed: enterpriseFailClosed, + localModels: authoritativeLocalModels, + }); + }, [authoritativeLocalModels, enterpriseFailClosed, enterpriseStatus, ownsReconciliation]); + + useEffect(() => { + setInventoryReady(false); + setInventoryRefreshAttempts(0); + void refreshInstalled(); + }, [accountId, completed, refreshInstalled, workspaceId]); + + useEffect(() => { + if (!inventoryReady || !config) return; + const transcriptionUnknown = config.transcription.some((selection) => + selection.provider === "whisper" ? !installed.whisperKnown : !installed.parakeetKnown + ); + const reasoningUnknown = config.reasoning.length > 0 && !installed.reasoningKnown; + if (!transcriptionUnknown && !reasoningUnknown) { + if (inventoryRefreshAttempts !== 0) setInventoryRefreshAttempts(0); + return; + } + const delay = getManagedLocalModelInventoryRetryDelay(inventoryRefreshAttempts); + if (delay === null) return; + const timer = window.setTimeout(() => void refreshInstalled(), delay); + return () => window.clearTimeout(timer); + }, [config, installed, inventoryReady, inventoryRefreshAttempts, refreshInstalled]); + + useEffect(() => { + let cancelled = false; + setResolvedParakeetCapability(null); + void window.electronAPI + ?.checkParakeetInstallation?.() + .then((result) => { + if (!cancelled) { + setResolvedParakeetCapability({ + identityKey: capabilityIdentityKey, + supported: result.supported !== false, + }); + } + }) + .catch(() => { + if (!cancelled) { + setResolvedParakeetCapability({ + identityKey: capabilityIdentityKey, + supported: null, + }); + } + }); + return () => { + cancelled = true; + }; + }, [capabilityIdentityKey]); + + useEffect(() => { + const refresh = (): void => setBindingRevision((revision) => revision + 1); + const refreshFromStorage = (event: StorageEvent): void => { + if (event.key === MANAGED_LOCAL_MODEL_BINDINGS_KEY || event.key === "onboardingCompleted") { + refresh(); + } + }; + window.addEventListener("openwhispr-managed-local-model-binding", refresh); + window.addEventListener("storage", refreshFromStorage); + return () => { + window.removeEventListener("openwhispr-managed-local-model-binding", refresh); + window.removeEventListener("storage", refreshFromStorage); + }; + }, []); + + const binding = + accountId && workspaceId ? readManagedLocalModelBinding(accountId, workspaceId) : null; + const bindingError = getManagedLocalModelBindingError(binding); + const identityKey = `${accountId ?? ""}:${workspaceId ?? ""}:${authGeneration ?? ""}:${config?.version ?? 0}`; + const focusedReady = resolveManagedLocalSetupReadiness(focusedReadiness, identityKey); + const handleFocusedReadinessChange = useCallback( + (ready: boolean): void => { + setFocusedReadiness((current) => + updateManagedLocalSetupReadiness(current, identityKey, ready) + ); + }, + [identityKey] + ); + const needsFocusedSetup = Boolean( + showUi && + completed && + accountId && + workspaceId && + authGeneration != null && + requiresManagedLocalModels(config) && + (!binding || + (config?.transcription.length && !binding.transcription) || + (config?.reasoning.length && !binding.reasoning)) && + dismissedIdentity !== identityKey + ); + + useEffect(() => { + const retryGeneration = binding?.retryGeneration ?? 0; + if (seenRetryGeneration.current === null) { + seenRetryGeneration.current = retryGeneration; + return; + } + if (seenRetryGeneration.current === retryGeneration) return; + seenRetryGeneration.current = retryGeneration; + startedReplacements.current.clear(); + }, [binding?.retryGeneration]); + + const isInstalled = useCallback( + (selection: ManagedEnterpriseLocalModelSelection): boolean => { + if (selection.provider === "whisper") return installed.whisper.has(selection.modelId); + if (selection.provider === "nvidia") return installed.parakeet.has(selection.modelId); + return installed.reasoning.has(selection.modelId); + }, + [installed] + ); + const isDownloading = useCallback( + (selection: ManagedEnterpriseLocalModelSelection): boolean => { + if (selection.provider === "whisper") { + return isManagedLocalModelDownloadActive( + whisperDownload.isDownloadingModel(selection.modelId), + installed.downloadingWhisper.has(selection.modelId) + ); + } + if (selection.provider === "nvidia") { + return isManagedLocalModelDownloadActive( + parakeetDownload.isDownloadingModel(selection.modelId), + installed.downloadingParakeet.has(selection.modelId) + ); + } + return isManagedLocalModelDownloadActive( + reasoningDownload.isDownloadingModel(selection.modelId), + installed.downloadingReasoning.has(selection.modelId) + ); + }, + [installed, parakeetDownload, reasoningDownload, whisperDownload] + ); + const isInventoryKnown = useCallback( + (selection: ManagedEnterpriseLocalModelSelection): boolean => { + if (selection.provider === "whisper") return installed.whisperKnown; + if (selection.provider === "nvidia") return installed.parakeetKnown; + return installed.reasoningKnown; + }, + [installed.parakeetKnown, installed.reasoningKnown, installed.whisperKnown] + ); + const isDownloadEngineBusy = useCallback( + (selection: ManagedEnterpriseLocalModelSelection): boolean => { + if (selection.provider === "whisper") { + return whisperDownload.isDownloading || installed.downloadingWhisper.size > 0; + } + if (selection.provider === "nvidia") { + return parakeetDownload.isDownloading || installed.downloadingParakeet.size > 0; + } + return reasoningDownload.isDownloading || installed.downloadingReasoning.size > 0; + }, + [ + installed, + parakeetDownload.isDownloading, + reasoningDownload.isDownloading, + whisperDownload.isDownloading, + ] + ); + + const applySelection = useCallback( + ( + category: "transcription" | "reasoning", + selection: ManagedEnterpriseLocalModelSelection, + complete = true + ): boolean => { + const enterprise = useEnterpriseIdentityStore.getState(); + const currentConfig = selectEffectiveManagedLocalModels(enterprise); + const currentBinding = + accountId && workspaceId ? readManagedLocalModelBinding(accountId, workspaceId) : null; + const approved = + category === "transcription" ? currentConfig?.transcription : currentConfig?.reasoning; + if ( + enterprise.accountId !== accountId || + enterprise.workspaceId !== workspaceId || + enterprise.authGeneration !== authGeneration || + currentConfig?.version !== config?.version || + !isManagedLocalModelBindingSelectionCurrent( + currentBinding, + currentConfig?.version ?? -1, + category, + selection + ) || + !approved || + !approved.some( + (model) => model.provider === selection.provider && model.modelId === selection.modelId + ) + ) { + return false; + } + const policyScope = category === "transcription" ? "transcription" : "llm"; + return applyManagedLocalModelSelectionWhenAllowed( + isModeAllowedByPolicy(usePolicyStore.getState(), policyScope, "local"), + () => { + enforceManagedLocalModelSettings( + category, + selection, + isAgentAllowed(usePolicyStore.getState()) + ); + if (!complete) return; + setManagedLocalModelCategoryError( + accountId, + workspaceId, + currentConfig.version, + category, + null + ); + forgetPendingLocalModel( + category === "reasoning" ? "assistant" : "dictation", + selection.modelId + ); + finishManagedLocalModelReplacement( + startedReplacements.current, + `${identityKey}:${category}:${selection.provider}:${selection.modelId}` + ); + }, + () => { + setManagedLocalModelBindingError( + accountId, + workspaceId, + currentConfig.version, + category === "transcription" + ? MANAGED_LOCAL_MODEL_ERROR_CODES.policyTranscription + : MANAGED_LOCAL_MODEL_ERROR_CODES.policyReasoning + ); + } + ); + }, + [accountId, authGeneration, config?.version, identityKey, workspaceId] + ); + + const compatibleTranscription = useMemo( + () => + config?.transcription.filter( + (model) => model.provider !== "nvidia" || parakeetSupported === true + ) ?? [], + [config?.transcription, parakeetSupported] + ); + const compatibleReasoning = useMemo( + () => + config?.reasoning.filter( + (selection) => modelRegistry.getModel(selection.modelId)?.provider.id === selection.provider + ) ?? [], + [config?.reasoning] + ); + const localPolicyError = useMemo((): string | null => { + if (config?.transcription.length && !isModeAllowedByPolicy(policy, "transcription", "local")) { + return MANAGED_LOCAL_MODEL_ERROR_CODES.policyTranscription; + } + if (config?.reasoning.length && !isModeAllowedByPolicy(policy, "llm", "local")) { + return MANAGED_LOCAL_MODEL_ERROR_CODES.policyReasoning; + } + return null; + }, [config?.reasoning.length, config?.transcription.length, policy]); + + const startReplacement = useCallback( + ( + category: "transcription" | "reasoning", + selection: ManagedEnterpriseLocalModelSelection, + nextBinding: ManagedLocalModelBinding + ): void => { + if (!accountId || !workspaceId || authGeneration == null || !config) return; + const replacementKey = `${identityKey}:${category}:${selection.provider}:${selection.modelId}`; + if (!beginManagedLocalModelReplacement(startedReplacements.current, replacementKey)) return; + writeManagedLocalModelBinding( + accountId, + workspaceId, + clearManagedLocalModelCategoryError(nextBinding, category) + ); + if (isInstalled(selection)) { + applySelection(category, selection); + return; + } + if (!applySelection(category, selection, false)) { + finishManagedLocalModelReplacement(startedReplacements.current, replacementKey); + return; + } + const identity = { + accountId, + workspaceId, + authGeneration, + configVersion: config.version, + }; + rememberPendingLocalModel( + category === "reasoning" ? "assistant" : "dictation", + selection, + identity + ); + localStorage.setItem("localSetupPending", "true"); + const download = + selection.provider === "whisper" + ? whisperDownload + : selection.provider === "nvidia" + ? parakeetDownload + : reasoningDownload; + const attempt = { identity, category, selection }; + void download + .downloadModel( + selection.modelId, + () => { + if (ownsReconciliationRef.current) applySelection(category, selection); + }, + (error) => { + if (ownsReconciliationRef.current) { + recordManagedLocalModelDownloadError(attempt, error); + } + } + ) + .then((outcome) => { + if (outcome !== "busy-other" || !ownsReconciliationRef.current) return; + forgetPendingLocalModel( + category === "reasoning" ? "assistant" : "dictation", + selection.modelId + ); + finishManagedLocalModelReplacement(startedReplacements.current, replacementKey); + }); + }, + [ + accountId, + applySelection, + authGeneration, + config, + identityKey, + isInstalled, + parakeetDownload, + reasoningDownload, + whisperDownload, + workspaceId, + ] + ); + + useEffect(() => { + if ( + !ownsReconciliation || + !completed || + !inventoryReady || + !whisperDownload.hasHydratedDownloads || + !parakeetDownload.hasHydratedDownloads || + !reasoningDownload.hasHydratedDownloads || + !accountId || + !workspaceId || + authGeneration == null || + !config || + !binding + ) { + return; + } + if ( + parakeetSupported === null && + config.transcription.some((selection) => selection.provider === "nvidia") + ) { + return; + } + if (localPolicyError) { + if (binding.configVersion !== config.version || binding.error !== localPolicyError) { + writeManagedLocalModelBinding(accountId, workspaceId, { + ...binding, + configVersion: config.version, + error: localPolicyError, + }); + } + return; + } + const transcription = resolveManagedLocalModelSelection( + compatibleTranscription, + binding.transcription + ); + const reasoning = resolveManagedLocalModelSelection(compatibleReasoning, binding.reasoning); + if (config.transcription.length > 0 && !transcription) { + const error = MANAGED_LOCAL_MODEL_ERROR_CODES.incompatibleTranscription; + if ( + binding.configVersion !== config.version || + binding.transcription !== null || + binding.reasoning?.provider !== reasoning?.provider || + binding.reasoning?.modelId !== reasoning?.modelId || + binding.error !== error + ) { + writeManagedLocalModelBinding(accountId, workspaceId, { + configVersion: config.version, + transcription: null, + reasoning, + error, + }); + } + return; + } + if (config.reasoning.length > 0 && !reasoning) { + const error = MANAGED_LOCAL_MODEL_ERROR_CODES.incompatibleReasoning; + if ( + binding.configVersion !== config.version || + binding.transcription?.provider !== transcription?.provider || + binding.transcription?.modelId !== transcription?.modelId || + binding.reasoning !== null || + binding.error !== error + ) { + writeManagedLocalModelBinding(accountId, workspaceId, { + configVersion: config.version, + transcription, + reasoning: null, + error, + }); + } + return; + } + const next = createResolvedManagedLocalModelBinding( + binding, + config.version, + transcription, + reasoning + ); + const transcriptionChanged = + transcription?.provider !== binding.transcription?.provider || + transcription?.modelId !== binding.transcription?.modelId; + const reasoningChanged = + reasoning?.provider !== binding.reasoning?.provider || + reasoning?.modelId !== binding.reasoning?.modelId; + if (transcription && !transcriptionChanged) { + applySelection("transcription", transcription, false); + } + if (reasoning && !reasoningChanged) { + applySelection("reasoning", reasoning, false); + } + if (transcription && !transcriptionChanged && isInstalled(transcription)) { + const selectionKey = `${identityKey}:transcription:${transcription.provider}:${transcription.modelId}`; + if (appliedSelectionKeys.current.transcription !== selectionKey) { + if (applySelection("transcription", transcription)) { + appliedSelectionKeys.current.transcription = selectionKey; + } + } + } + if (reasoning && !reasoningChanged && isInstalled(reasoning)) { + const selectionKey = `${identityKey}:reasoning:${reasoning.provider}:${reasoning.modelId}`; + if (appliedSelectionKeys.current.reasoning !== selectionKey) { + if (applySelection("reasoning", reasoning)) { + appliedSelectionKeys.current.reasoning = selectionKey; + } + } + } + const transcriptionInventoryKnown = Boolean(transcription && isInventoryKnown(transcription)); + const reasoningInventoryKnown = Boolean(reasoning && isInventoryKnown(reasoning)); + const transcriptionNeedsRecovery = shouldRecoverManagedLocalModelFromInventory( + transcription, + transcriptionInventoryKnown, + Boolean(transcription && isInstalled(transcription)), + Boolean(transcription && isDownloading(transcription)) + ); + const reasoningNeedsRecovery = shouldRecoverManagedLocalModelFromInventory( + reasoning, + reasoningInventoryKnown, + Boolean(reasoning && isInstalled(reasoning)), + Boolean(reasoning && isDownloading(reasoning)) + ); + const transcriptionIsActive = Boolean(transcription && isDownloading(transcription)); + const reasoningIsActive = Boolean(reasoning && isDownloading(reasoning)); + const transcriptionBusyOther = Boolean( + transcription && isDownloadEngineBusy(transcription) && !transcriptionIsActive + ); + const reasoningBusyOther = Boolean( + reasoning && isDownloadEngineBusy(reasoning) && !reasoningIsActive + ); + if ( + transcription && + transcriptionInventoryKnown && + !transcriptionBusyOther && + canAutomaticallyStartManagedLocalModelReplacement( + transcriptionChanged || transcriptionNeedsRecovery || transcriptionIsActive, + next.categoryErrors?.transcription + ) + ) { + startReplacement("transcription", transcription, next); + } + if ( + reasoning && + reasoningInventoryKnown && + !reasoningBusyOther && + canAutomaticallyStartManagedLocalModelReplacement( + reasoningChanged || reasoningNeedsRecovery || reasoningIsActive, + next.categoryErrors?.reasoning + ) + ) { + startReplacement("reasoning", reasoning, next); + } + if ( + ((transcriptionChanged || transcriptionNeedsRecovery) && transcription) || + ((reasoningChanged || reasoningNeedsRecovery) && reasoning) + ) { + return; + } + if (binding.configVersion !== config.version || binding.error) { + writeManagedLocalModelBinding(accountId, workspaceId, next); + } + }, [ + accountId, + applySelection, + authGeneration, + binding, + compatibleReasoning, + compatibleTranscription, + completed, + config, + identityKey, + inventoryReady, + isDownloading, + isDownloadEngineBusy, + isInstalled, + isInventoryKnown, + localPolicyError, + ownsReconciliation, + parakeetSupported, + parakeetDownload, + reasoningDownload, + startReplacement, + whisperDownload, + workspaceId, + ]); + + if (!showUi || !completed || !config || !accountId || !workspaceId || authGeneration == null) { + return null; + } + + if (bindingError && !needsFocusedSetup) { + return ( + + ); + } + + if (!needsFocusedSetup) return null; + return ( + + +
+ + {t("managedLocalModels.workspaceSetup.title")} + + + {t("managedLocalModels.workspaceSetup.description")} + +
+ + setDismissedIdentity(identityKey)} + /> +
+
+ ); +} diff --git a/src/components/onboarding/ManagedSetupBlockedActions.tsx b/src/components/onboarding/ManagedSetupBlockedActions.tsx new file mode 100644 index 0000000000..cb2e93c67b --- /dev/null +++ b/src/components/onboarding/ManagedSetupBlockedActions.tsx @@ -0,0 +1,51 @@ +import type { JSX } from "react"; +import { useTranslation } from "react-i18next"; +import { Loader2, RotateCcw } from "lucide-react"; +import { signOut } from "../../lib/auth"; +import { Button } from "../ui/button"; + +export function ManagedSetupSignOutButton(): JSX.Element { + const { t } = useTranslation(); + return ( + + ); +} + +export function EnterpriseConfigErrorActions({ onRetry }: { onRetry: () => void }): JSX.Element { + const { t } = useTranslation(); + return ( +
+ + +
+ ); +} + +export function ManagedSetupFooterActions({ + ready, + onContinue, +}: { + ready: boolean; + onContinue: () => void; +}): JSX.Element { + const { t } = useTranslation(); + return ( +
+ + +
+ ); +} diff --git a/src/components/onboarding/OnboardingShell.tsx b/src/components/onboarding/OnboardingShell.tsx index 57ee457abf..eb162e9e61 100644 --- a/src/components/onboarding/OnboardingShell.tsx +++ b/src/components/onboarding/OnboardingShell.tsx @@ -14,6 +14,7 @@ import onboardingBackgroundDark from "@/assets/onboarding-bg-dark.svg"; interface OnboardingShellProps { compact?: boolean; children: ReactNode; + footerLeadingAction?: ReactNode; onBack?: () => void; onContinue?: () => void; onSkip?: () => void; @@ -218,6 +219,7 @@ export function OnboardingStepHeader({ export default function OnboardingShell({ compact = false, children, + footerLeadingAction, onBack, onContinue, onSkip, @@ -230,7 +232,7 @@ export default function OnboardingShell({ stepKey, }: OnboardingShellProps) { const { t } = useTranslation(); - const hasFooter = onBack || onContinue || onSkip || progress; + const hasFooter = footerLeadingAction || onBack || onContinue || onSkip || progress; return (
+ {footerLeadingAction} {onBack && (
)} - + {effectiveMode === "providers" && renderModelSelector("cloud")} {effectiveMode === "local" && renderModelSelector("local")} diff --git a/src/components/settings/ManagedLocalModelNotice.tsx b/src/components/settings/ManagedLocalModelNotice.tsx new file mode 100644 index 0000000000..254dd32936 --- /dev/null +++ b/src/components/settings/ManagedLocalModelNotice.tsx @@ -0,0 +1,56 @@ +import { Lock } from "lucide-react"; +import type { JSX } from "react"; +import { useTranslation } from "react-i18next"; +import { + getParakeetModelInfo, + getWhisperModelInfo, + modelRegistry, +} from "../../models/ModelRegistry"; +import { ProviderIcon } from "../ui/ProviderIcon"; +import type { ManagedEnterpriseLocalModelSelection } from "../../types/enterpriseIdentity"; + +function modelName( + selection: ManagedEnterpriseLocalModelSelection | null, + waitingLabel: string +): string { + if (!selection) return waitingLabel; + if (selection.provider === "whisper") { + return getWhisperModelInfo(selection.modelId)?.name ?? selection.modelId; + } + if (selection.provider === "nvidia") { + return getParakeetModelInfo(selection.modelId)?.name ?? selection.modelId; + } + return modelRegistry.getModel(selection.modelId)?.model.name ?? selection.modelId; +} + +export function ManagedLocalModelNotice({ + selection, +}: { + selection: ManagedEnterpriseLocalModelSelection | null; +}): JSX.Element { + const { t } = useTranslation(); + return ( +
+
+ + {selection ? ( + + ) : ( + + )} + +
+

+ {modelName(selection, t("managedLocalModels.notice.waiting"))} +

+

+ {t("managedLocalModels.notice.managedDescription")} +

+
+
+
+ ); +} diff --git a/src/components/settings/MeetingSettings.tsx b/src/components/settings/MeetingSettings.tsx index 97e83fecd4..e1f2a08f21 100644 --- a/src/components/settings/MeetingSettings.tsx +++ b/src/components/settings/MeetingSettings.tsx @@ -10,6 +10,11 @@ import TranscriptionModelPicker from "../TranscriptionModelPicker"; import type { InferenceMode } from "../../types/electron"; import { useStartOnboarding } from "../../hooks/useStartOnboarding"; import { getStreamingTranscriptionProviders } from "../../models/ModelRegistry"; +import { useManagedLocalModelLock } from "../../hooks/useManagedLocalModelLock"; +import { + canSelectManagedLocalMode, + constrainManagedLocalModeOptions, +} from "../onboarding/managedLocalModels"; const MEETING_BYOK_PROVIDER_IDS = getStreamingTranscriptionProviders().map( (provider) => provider.id @@ -35,6 +40,7 @@ const noop = () => {}; export function MeetingTranscriptionPanel() { const { t } = useTranslation(); const startOnboarding = useStartOnboarding(); + const managedLocalLock = useManagedLocalModelLock("transcription"); const { isSignedIn, @@ -94,7 +100,12 @@ export function MeetingTranscriptionPanel() { meetingTranscriptionMode, { byokProviders: MEETING_BYOK_PROVIDER_IDS } ); + const selectableTranscriptionModes = constrainManagedLocalModeOptions( + transcriptionModes, + managedLocalLock.managed + ); const handleTranscriptionModeSelect = (mode: InferenceMode) => { + if (!canSelectManagedLocalMode(managedLocalLock.managed, mode)) return; if (!isModeAllowed(mode)) return; if (mode === "self-hosted") return; if (mode === "openwhispr" && !isSignedIn) { @@ -147,7 +158,7 @@ export function MeetingTranscriptionPanel() { return (
diff --git a/src/components/settings/UploadSettings.tsx b/src/components/settings/UploadSettings.tsx index c82ac46d7a..c19958826e 100644 --- a/src/components/settings/UploadSettings.tsx +++ b/src/components/settings/UploadSettings.tsx @@ -9,10 +9,16 @@ import TranscriptionModelPicker from "../TranscriptionModelPicker"; import SelfHostedPanel from "../SelfHostedPanel"; import type { InferenceMode } from "../../types/electron"; import { useStartOnboarding } from "../../hooks/useStartOnboarding"; +import { useManagedLocalModelLock } from "../../hooks/useManagedLocalModelLock"; +import { + canSelectManagedLocalMode, + constrainManagedLocalModeOptions, +} from "../onboarding/managedLocalModels"; export function UploadTranscriptionPanel() { const { t } = useTranslation(); const startOnboarding = useStartOnboarding(); + const managedLocalLock = useManagedLocalModelLock("transcription"); const { isSignedIn, @@ -74,7 +80,12 @@ export function UploadTranscriptionPanel() { uploadTranscriptionMode, { byokProviders: TRANSCRIPTION_POLICY_PROVIDER_IDS } ); + const selectableTranscriptionModes = constrainManagedLocalModeOptions( + transcriptionModes, + managedLocalLock.managed + ); const handleTranscriptionModeSelect = (mode: InferenceMode) => { + if (!canSelectManagedLocalMode(managedLocalLock.managed, mode)) return; if (!isModeAllowed(mode)) return; if (mode === "openwhispr" && !isSignedIn) { startOnboarding(); @@ -125,7 +136,7 @@ export function UploadTranscriptionPanel() { return (
diff --git a/src/components/ui/SettingsSection.tsx b/src/components/ui/SettingsSection.tsx index 6eb89d4729..cf2c80c1a6 100644 --- a/src/components/ui/SettingsSection.tsx +++ b/src/components/ui/SettingsSection.tsx @@ -162,6 +162,8 @@ export function InferenceModeSelector({ }`} >