diff --git a/apps/desktop/src/components/ui/combobox.tsx b/apps/desktop/src/components/ui/combobox.tsx index 70f1a5c..c8d5252 100644 --- a/apps/desktop/src/components/ui/combobox.tsx +++ b/apps/desktop/src/components/ui/combobox.tsx @@ -19,6 +19,13 @@ import { PopoverTrigger, } from "@/components/ui/popover"; +export interface ComboboxOption { + value: string; + label: string; + disabled?: boolean; + disabledReason?: string; +} + export function Combobox({ options, value, @@ -26,7 +33,7 @@ export function Combobox({ disabled, placeholder = "Select option...", }: { - options: { value: string; label: string }[]; + options: ComboboxOption[]; value: string; onChange: (value: string) => void; disabled?: boolean; @@ -56,22 +63,32 @@ export function Combobox({ No option found. {options.map((option) => ( - { - setOpen(false); - onChange(currentValue === value ? "" : currentValue); - }} - > - - {option.label} - +
+ { + if (option.disabled) { + return; + } + setOpen(false); + onChange(currentValue === value ? "" : currentValue); + }} + > + + {option.label} + + {option.disabled && option.disabledReason && ( +

+ {option.disabledReason} +

+ )} +
))}
diff --git a/apps/desktop/src/db/app-settings.ts b/apps/desktop/src/db/app-settings.ts index 6fd3ebb..17bea0a 100644 --- a/apps/desktop/src/db/app-settings.ts +++ b/apps/desktop/src/db/app-settings.ts @@ -27,7 +27,7 @@ import { import { isMacOS } from "../utils/platform"; // Current settings schema version - increment when making breaking changes -const CURRENT_SETTINGS_VERSION = 2; +const CURRENT_SETTINGS_VERSION = 3; // Type for v1 settings (before shortcuts array migration) interface AppSettingsDataV1 extends Omit { @@ -68,6 +68,29 @@ const migrations: Record = { : undefined, } as AppSettingsData; }, + + // v2 -> v3: Auto-set formatting model to amical-cloud for users already on cloud transcription + 3: (data: unknown): AppSettingsData => { + const oldData = data as AppSettingsData; + const isCloudSpeech = + oldData.modelProvidersConfig?.defaultSpeechModel === "amical-cloud"; + const hasNoFormattingModel = !oldData.formatterConfig?.modelId; + + // If user is on Amical Cloud transcription and hasn't set a formatting model, + // auto-set formatting to use Amical Cloud + if (isCloudSpeech && hasNoFormattingModel) { + return { + ...oldData, + formatterConfig: { + ...oldData.formatterConfig, + enabled: oldData.formatterConfig?.enabled ?? false, + modelId: "amical-cloud", + }, + }; + } + + return oldData; + }, }; /** diff --git a/apps/desktop/src/db/schema.ts b/apps/desktop/src/db/schema.ts index d1493b6..6f97e20 100644 --- a/apps/desktop/src/db/schema.ts +++ b/apps/desktop/src/db/schema.ts @@ -111,6 +111,8 @@ export const models = sqliteTable( export interface AppSettingsData { formatterConfig?: { enabled: boolean; + modelId?: string; // Formatting model selection (language model ID or "amical-cloud") + fallbackModelId?: string; // Last non-cloud formatting model for auto-restore }; ui?: { theme: "light" | "dark" | "system"; diff --git a/apps/desktop/src/pipeline/core/pipeline-types.ts b/apps/desktop/src/pipeline/core/pipeline-types.ts index afeaaf2..26d99a2 100644 --- a/apps/desktop/src/pipeline/core/pipeline-types.ts +++ b/apps/desktop/src/pipeline/core/pipeline-types.ts @@ -14,6 +14,7 @@ export interface TranscribeContext { previousChunk?: string; aggregatedTranscription?: string; language?: string; + formattingEnabled?: boolean; } // Transcription input parameters diff --git a/apps/desktop/src/pipeline/providers/transcription/amical-cloud-provider.ts b/apps/desktop/src/pipeline/providers/transcription/amical-cloud-provider.ts index 1137d8b..3f926aa 100644 --- a/apps/desktop/src/pipeline/providers/transcription/amical-cloud-provider.ts +++ b/apps/desktop/src/pipeline/providers/transcription/amical-cloud-provider.ts @@ -116,7 +116,8 @@ export class AmicalCloudProvider implements TranscriptionProvider { throw new Error("Authentication required for cloud transcription"); } - return this.doTranscription(true); + const enableFormatting = context.formattingEnabled ?? false; + return this.doTranscription(enableFormatting); } catch (error) { logger.transcription.error("Cloud transcription error:", error); throw error; diff --git a/apps/desktop/src/renderer/main/pages/settings/ai-models/tabs/SpeechTab.tsx b/apps/desktop/src/renderer/main/pages/settings/ai-models/tabs/SpeechTab.tsx index bda6210..099a196 100644 --- a/apps/desktop/src/renderer/main/pages/settings/ai-models/tabs/SpeechTab.tsx +++ b/apps/desktop/src/renderer/main/pages/settings/ai-models/tabs/SpeechTab.tsx @@ -2,6 +2,7 @@ import { ComponentProps, useEffect, useState } from "react"; import { Card, CardContent } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; import DefaultModelCombobox from "../components/default-model-combobox"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { @@ -172,8 +173,11 @@ export default function SpeechTab() { }); const setSelectedModelMutation = api.models.setSelectedModel.useMutation({ - onSuccess: () => { + onSuccess: (_data, variables) => { utils.models.getSelectedModel.invalidate(); + if (variables.modelId === "amical-cloud") { + toast.success("Amical Cloud selected. Cloud formatting enabled."); + } }, onError: (error) => { console.error("Failed to select model:", error); @@ -464,6 +468,24 @@ export default function SpeechTab() { {model.provider} + {isCloudModel && ( +
+ + + + Formatting available + + + + Cloud formatting is available when + this model is selected. + + +
+ )} diff --git a/apps/desktop/src/renderer/main/pages/settings/dictation/components/FormattingSettings.tsx b/apps/desktop/src/renderer/main/pages/settings/dictation/components/FormattingSettings.tsx index 8affd62..6fb1c49 100644 --- a/apps/desktop/src/renderer/main/pages/settings/dictation/components/FormattingSettings.tsx +++ b/apps/desktop/src/renderer/main/pages/settings/dictation/components/FormattingSettings.tsx @@ -1,4 +1,3 @@ -import { useState, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -10,45 +9,26 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { api } from "@/trpc/react"; -import { toast } from "sonner"; -import DefaultModelCombobox from "@/renderer/main/pages/settings/ai-models/components/default-model-combobox"; +import { Combobox } from "@/components/ui/combobox"; +import { useFormattingSettings } from "../hooks/use-formatting-settings"; export function FormattingSettings() { - const [formattingEnabled, setFormattingEnabled] = useState(false); + const { + formattingEnabled, + selectedModelId, + formattingOptions, + disableFormattingToggle, + hasFormattingOptions, + showCloudRequiresSpeech, + showCloudRequiresAuth, + showCloudReady, + showNoLanguageModels, + handleFormattingEnabledChange, + handleFormattingModelChange, + handleCloudLogin, + isLoginPending, + } = useFormattingSettings(); - // tRPC queries and mutations - const formatterConfigQuery = api.settings.getFormatterConfig.useQuery(); - const modelsQuery = api.models.getModels.useQuery({ - type: "language", - }); - const utils = api.useUtils(); - - const setFormatterConfigMutation = - api.settings.setFormatterConfig.useMutation({ - onSuccess: () => { - utils.settings.getFormatterConfig.invalidate(); - }, - onError: (error) => { - console.error("Failed to save formatting settings:", error); - toast.error("Failed to save formatting settings. Please try again."); - }, - }); - - // Load formatter config from database - useEffect(() => { - if (formatterConfigQuery.data) { - const config = formatterConfigQuery.data; - setFormattingEnabled(config.enabled); - } - }, [formatterConfigQuery.data]); - - const handleFormattingEnabledChange = (enabled: boolean) => { - setFormattingEnabled(enabled); - setFormatterConfigMutation.mutate({ enabled }); - }; - - const hasModels = (modelsQuery.data?.length ?? 0) > 0; return (
@@ -62,7 +42,7 @@ export function FormattingSettings() {

- Enable context based transcription formatting. + Apply punctuation and structure to your transcriptions.

@@ -71,13 +51,14 @@ export function FormattingSettings() { - {!hasModels && ( + {disableFormattingToggle && ( - Please sync AI models first to enable formatting functionality. + Sync a language model or select Amical Cloud transcription to + enable formatting. )} @@ -99,30 +80,62 @@ export function FormattingSettings() {

- Select the language model to use for formatting transcriptions. + Choose the model used to format your transcription.

- {!hasModels ? ( -
- - No models available. Please sync models first. - - - - -
- ) : ( - + - )} + {showCloudRequiresSpeech && ( +
+ Requires Amical Cloud transcription. + + + +
+ )} + {showCloudRequiresAuth && ( +
+ Sign in to use Amical Cloud formatting. + +
+ )} + {showCloudReady && ( +

+ Using Amical Cloud formatting. +

+ )} + {showNoLanguageModels && ( +
+ + Formatting won't run — no language model available. + + + + +
+ )} +
)} diff --git a/apps/desktop/src/renderer/main/pages/settings/dictation/hooks/use-formatting-settings.ts b/apps/desktop/src/renderer/main/pages/settings/dictation/hooks/use-formatting-settings.ts new file mode 100644 index 0000000..3c3ead2 --- /dev/null +++ b/apps/desktop/src/renderer/main/pages/settings/dictation/hooks/use-formatting-settings.ts @@ -0,0 +1,265 @@ +import { useMemo, useCallback } from "react"; +import { api } from "@/trpc/react"; +import { toast } from "sonner"; +import type { FormatterConfig } from "@/types/formatter"; + +import type { ComboboxOption } from "@/components/ui/combobox"; + +interface UseFormattingSettingsReturn { + // State + formattingEnabled: boolean; + selectedModelId: string; + formattingOptions: ComboboxOption[]; + + // Derived booleans + disableFormattingToggle: boolean; + hasFormattingOptions: boolean; + showCloudRequiresSpeech: boolean; + showCloudRequiresAuth: boolean; + showCloudReady: boolean; + showNoLanguageModels: boolean; + + // Handlers + handleFormattingEnabledChange: (enabled: boolean) => void; + handleFormattingModelChange: (modelId: string) => void; + handleCloudLogin: () => Promise; + + // Loading state + isLoginPending: boolean; +} + +export function useFormattingSettings(): UseFormattingSettingsReturn { + // tRPC queries + const formatterConfigQuery = api.settings.getFormatterConfig.useQuery(); + const languageModelsQuery = api.models.getModels.useQuery({ + type: "language", + }); + const speechModelQuery = api.models.getDefaultModel.useQuery({ + type: "speech", + }); + const defaultLanguageModelQuery = api.models.getDefaultModel.useQuery({ + type: "language", + }); + const isAuthenticatedQuery = api.auth.isAuthenticated.useQuery(); + const utils = api.useUtils(); + + // Use query data directly + const formatterConfig = formatterConfigQuery.data ?? null; + + // Mutations with optimistic updates + const setFormatterConfigMutation = + api.settings.setFormatterConfig.useMutation({ + onMutate: async (newConfig) => { + // Cancel outgoing refetches + await utils.settings.getFormatterConfig.cancel(); + + // Snapshot previous value + const previousConfig = utils.settings.getFormatterConfig.getData(); + + // Optimistically update + utils.settings.getFormatterConfig.setData(undefined, newConfig); + + return { previousConfig }; + }, + onError: (error, _newConfig, context) => { + // Rollback on error + if (context?.previousConfig) { + utils.settings.getFormatterConfig.setData( + undefined, + context.previousConfig, + ); + } + console.error("Failed to save formatting settings:", error); + toast.error("Failed to save formatting settings. Please try again."); + }, + onSettled: () => { + // Refetch to ensure consistency + utils.settings.getFormatterConfig.invalidate(); + }, + }); + + const loginMutation = api.auth.login.useMutation({ + onSuccess: () => { + toast.info("Complete login in your browser"); + }, + onError: (error) => { + console.error("Failed to initiate login:", error); + toast.error("Failed to start login process"); + }, + }); + + // Subscriptions + api.models.onSelectionChanged.useSubscription(undefined, { + onData: ({ modelType }) => { + if (modelType === "speech") { + utils.settings.getFormatterConfig.invalidate(); + utils.models.getDefaultModel.invalidate({ type: "speech" }); + } + }, + onError: (error) => { + console.error("Selection changed subscription error:", error); + }, + }); + + api.auth.onAuthStateChange.useSubscription(undefined, { + onData: () => { + utils.auth.isAuthenticated.invalidate(); + }, + onError: (error) => { + console.error("Auth state subscription error:", error); + }, + }); + + // Derived values + const languageModels = languageModelsQuery.data || []; + const hasLanguageModels = languageModels.length > 0; + const isCloudSpeechSelected = speechModelQuery.data === "amical-cloud"; + const isAuthenticated = isAuthenticatedQuery.data || false; + const canUseCloudFormatting = isCloudSpeechSelected && isAuthenticated; + const hasFormattingOptions = hasLanguageModels || canUseCloudFormatting; + const formattingEnabled = formatterConfig?.enabled ?? false; + const disableFormattingToggle = !hasFormattingOptions; + + const formattingOptions = useMemo(() => { + const getCloudDisabledReason = () => { + if (!isCloudSpeechSelected && !isAuthenticated) { + return "Requires Amical Cloud transcription and sign in"; + } + if (!isCloudSpeechSelected) { + return "Requires Amical Cloud transcription"; + } + if (!isAuthenticated) { + return "Requires sign in"; + } + return undefined; + }; + + const options: ComboboxOption[] = [ + { + value: "amical-cloud", + label: "Amical Cloud (Amical)", + disabled: !canUseCloudFormatting, + disabledReason: getCloudDisabledReason(), + }, + ]; + + const languageOptions = languageModels.map((model) => ({ + value: model.id, + label: `${model.name} (${model.provider})`, + })); + + return [...options, ...languageOptions]; + }, [ + canUseCloudFormatting, + isCloudSpeechSelected, + isAuthenticated, + languageModels, + ]); + + const optionValues = useMemo(() => { + return new Set(formattingOptions.map((option) => option.value)); + }, [formattingOptions]); + + const selectedModelId = useMemo(() => { + const preferredModelId = + formatterConfig?.modelId || defaultLanguageModelQuery.data || ""; + + return optionValues.has(preferredModelId) ? preferredModelId : ""; + }, [defaultLanguageModelQuery.data, formatterConfig?.modelId, optionValues]); + + // Inline state conditions + const showCloudRequiresSpeech = + selectedModelId === "amical-cloud" && !isCloudSpeechSelected; + const showCloudRequiresAuth = + selectedModelId === "amical-cloud" && + isCloudSpeechSelected && + !isAuthenticated; + const showCloudReady = + selectedModelId === "amical-cloud" && canUseCloudFormatting; + const showNoLanguageModels = + !hasLanguageModels && + !canUseCloudFormatting && + selectedModelId !== "amical-cloud"; + + // Handlers + const handleFormattingEnabledChange = useCallback( + (enabled: boolean) => { + const nextConfig: FormatterConfig = { + enabled, + modelId: formatterConfig?.modelId, + fallbackModelId: formatterConfig?.fallbackModelId, + }; + setFormatterConfigMutation.mutate(nextConfig); + }, + [formatterConfig, setFormatterConfigMutation], + ); + + const handleFormattingModelChange = useCallback( + (modelId: string) => { + if (!modelId) { + return; + } + + const currentModelId = + formatterConfig?.modelId || defaultLanguageModelQuery.data || ""; + + if (modelId === currentModelId) { + return; + } + + const nextConfig: FormatterConfig = { + enabled: formatterConfig?.enabled ?? false, + modelId, + fallbackModelId: formatterConfig?.fallbackModelId, + }; + + if (modelId !== "amical-cloud") { + nextConfig.fallbackModelId = modelId; + } else if ( + !nextConfig.fallbackModelId && + currentModelId && + currentModelId !== "amical-cloud" + ) { + nextConfig.fallbackModelId = currentModelId; + } + + setFormatterConfigMutation.mutate(nextConfig); + }, + [ + formatterConfig, + defaultLanguageModelQuery.data, + setFormatterConfigMutation, + ], + ); + + const handleCloudLogin = useCallback(async () => { + try { + await loginMutation.mutateAsync(); + } catch { + // Errors already handled in mutation callbacks + } + }, [loginMutation]); + + return { + // State + formattingEnabled, + selectedModelId, + formattingOptions, + + // Derived booleans + disableFormattingToggle, + hasFormattingOptions, + showCloudRequiresSpeech, + showCloudRequiresAuth, + showCloudReady, + showNoLanguageModels, + + // Handlers + handleFormattingEnabledChange, + handleFormattingModelChange, + handleCloudLogin, + + // Loading state + isLoginPending: loginMutation.isPending, + }; +} diff --git a/apps/desktop/src/services/model-service.ts b/apps/desktop/src/services/model-service.ts index 8708f74..06f8844 100644 --- a/apps/desktop/src/services/model-service.ts +++ b/apps/desktop/src/services/model-service.ts @@ -165,13 +165,10 @@ class ModelService extends EventEmitter { } } - await this.settingsService.setDefaultSpeechModel(newModelId); - this.emit( - "selection-changed", - savedSelection, + await this.applySpeechModelSelection( newModelId, "manual", - "speech", + savedSelection, ); logger.main.info( @@ -183,7 +180,11 @@ class ModelService extends EventEmitter { ); } else { // No local models available - await this.settingsService.setDefaultSpeechModel(undefined); + await this.applySpeechModelSelection( + null, + "cleared", + savedSelection, + ); logger.main.warn( "Cleared cloud model selection on startup - not authenticated and no local models available", ); @@ -208,13 +209,10 @@ class ModelService extends EventEmitter { for (const candidateId of preferredOrder) { if (downloadedModels[candidateId]) { - await this.settingsService.setDefaultSpeechModel(candidateId); - this.emit( - "selection-changed", - null, + await this.applySpeechModelSelection( candidateId, "auto-first-download", - "speech", + null, ); logger.main.info("Auto-selected speech model on initialization", { modelId: candidateId, @@ -276,13 +274,10 @@ class ModelService extends EventEmitter { } } - await this.settingsService.setDefaultSpeechModel(newModelId); - this.emit( - "selection-changed", - selectedModelId, + await this.applySpeechModelSelection( newModelId, "manual", - "speech", + selectedModelId, ); logger.main.info( @@ -294,13 +289,10 @@ class ModelService extends EventEmitter { ); } else { // No local models available, clear selection - await this.settingsService.setDefaultSpeechModel(undefined); - this.emit( - "selection-changed", - selectedModelId, + await this.applySpeechModelSelection( null, "cleared", - "speech", + selectedModelId, ); logger.main.warn( @@ -522,13 +514,10 @@ class ModelService extends EventEmitter { await this.settingsService.getDefaultSpeechModel(); if (downloadedModelCount === 1 && !currentSelection) { - await this.settingsService.setDefaultSpeechModel(modelId); - this.emit( - "selection-changed", - null, + await this.applySpeechModelSelection( modelId, "auto-first-download", - "speech", + null, ); logger.main.info("Auto-selected first downloaded model", { modelId }); } @@ -603,9 +592,6 @@ class ModelService extends EventEmitter { // Handle selection update if needed if (wasSelected) { - // Clear selection first - await this.settingsService.setDefaultSpeechModel(undefined); - // Try to auto-select next best model const remainingModels = await this.getValidDownloadedModels(); const preferredOrder = [ @@ -620,13 +606,10 @@ class ModelService extends EventEmitter { let autoSelected = false; for (const candidateId of preferredOrder) { if (remainingModels[candidateId]) { - await this.settingsService.setDefaultSpeechModel(candidateId); - this.emit( - "selection-changed", - modelId, + await this.applySpeechModelSelection( candidateId, "auto-after-deletion", - "speech", + modelId, ); logger.main.info("Auto-selected new model after deletion", { oldModel: modelId, @@ -639,7 +622,7 @@ class ModelService extends EventEmitter { if (!autoSelected) { // No models left, selection cleared - this.emit("selection-changed", modelId, null, "cleared", "speech"); + await this.applySpeechModelSelection(null, "cleared", modelId); logger.main.info( "No models available for auto-selection after deletion", ); @@ -686,6 +669,80 @@ class ModelService extends EventEmitter { return (await this.settingsService.getDefaultSpeechModel()) || null; } + private async syncFormatterConfigForSpeechChange( + oldModelId: string | null, + newModelId: string | null, + ): Promise { + if (oldModelId === newModelId) { + return; + } + + const formatterConfig = + (await this.settingsService.getFormatterConfig()) || { enabled: false }; + const currentModelId = formatterConfig.modelId; + const fallbackModelId = formatterConfig.fallbackModelId; + const movedToCloud = newModelId === "amical-cloud"; + const movedFromCloud = oldModelId === "amical-cloud"; + const usingCloudFormatting = currentModelId === "amical-cloud"; + + let nextConfig = { ...formatterConfig }; + let updated = false; + + if (movedToCloud && !usingCloudFormatting) { + if (currentModelId && currentModelId !== "amical-cloud") { + nextConfig.fallbackModelId = currentModelId; + } else if (!fallbackModelId) { + const defaultLanguageModel = + await this.settingsService.getDefaultLanguageModel(); + if (defaultLanguageModel) { + nextConfig.fallbackModelId = defaultLanguageModel; + } + } + + nextConfig.modelId = "amical-cloud"; + nextConfig.enabled = true; + updated = true; + } else if (movedFromCloud && usingCloudFormatting) { + const fallback = + fallbackModelId || + (await this.settingsService.getDefaultLanguageModel()); + + nextConfig.modelId = + fallback && fallback !== "amical-cloud" ? fallback : undefined; + updated = true; + } + + if (updated) { + await this.settingsService.setFormatterConfig(nextConfig); + } + } + + private async applySpeechModelSelection( + modelId: string | null, + reason: + | "manual" + | "auto-first-download" + | "auto-after-deletion" + | "cleared", + oldModelId?: string | null, + ): Promise { + const previousModelId = oldModelId ?? (await this.getSelectedModel()); + + if (previousModelId === modelId) { + return; + } + + await this.settingsService.setDefaultSpeechModel(modelId || undefined); + await this.syncFormatterConfigForSpeechChange(previousModelId, modelId); + + this.emit("selection-changed", previousModelId, modelId, reason, "speech"); + logger.main.info("Model selection changed", { + from: previousModelId, + to: modelId, + reason, + }); + } + // Set selected model for transcription async setSelectedModel(modelId: string | null): Promise { const oldModelId = await this.getSelectedModel(); @@ -714,18 +771,7 @@ class ModelService extends EventEmitter { } } - // Update selection in settings - await this.settingsService.setDefaultSpeechModel(modelId || undefined); - - // Emit change event if selection actually changed - if (oldModelId !== modelId) { - this.emit("selection-changed", oldModelId, modelId, "manual", "speech"); - logger.main.info("Model selection changed", { - from: oldModelId, - to: modelId, - reason: "manual", - }); - } + await this.applySpeechModelSelection(modelId, "manual", oldModelId); } // Get best available model path for transcription (used by WhisperProvider) @@ -1092,13 +1138,10 @@ class ModelService extends EventEmitter { logger.main.info("Clearing invalid default speech model", { modelId: defaultSpeechModel, }); - await this.settingsService.setDefaultSpeechModel(undefined); - this.emit( - "selection-changed", - defaultSpeechModel, + await this.applySpeechModelSelection( null, "auto-after-deletion", - "speech", + defaultSpeechModel, ); } } diff --git a/apps/desktop/src/services/transcription-service.ts b/apps/desktop/src/services/transcription-service.ts index acad703..1c54324 100644 --- a/apps/desktop/src/services/transcription-service.ts +++ b/apps/desktop/src/services/transcription-service.ts @@ -380,6 +380,11 @@ export class TranscriptionService { session.recordingStartedAt = recordingStartedAt; } + const formatterConfig = await this.settingsService.getFormatterConfig(); + const shouldUseCloudFormatting = + formatterConfig?.enabled && formatterConfig.modelId === "amical-cloud"; + let usedCloudProvider = false; + // Flush provider to get any remaining buffered audio await this.transcriptionMutex.acquire(); try { @@ -394,12 +399,14 @@ export class TranscriptionService { .trim(); const provider = await this.selectProvider(); + usedCloudProvider = provider.name === "amical-cloud"; const finalTranscription = await provider.flush({ vocabulary: session.context.sharedData.vocabulary, accessibilityContext: session.context.sharedData.accessibilityContext, previousChunk, aggregatedTranscription: aggregatedTranscription || undefined, language: session.context.sharedData.userPreferences?.language, + formattingEnabled: shouldUseCloudFormatting && usedCloudProvider, }); if (finalTranscription.trim()) { @@ -427,15 +434,24 @@ export class TranscriptionService { let formattingUsed = false; let formattingModel: string | undefined; - const formatterConfig = await this.settingsService.getFormatterConfig(); - - if (!formatterConfig?.enabled) { + if (!formatterConfig || !formatterConfig.enabled) { logger.transcription.debug("Formatting skipped: disabled in config"); } else if (!completeTranscription.trim().length) { logger.transcription.debug("Formatting skipped: empty transcription"); + } else if (formatterConfig.modelId === "amical-cloud") { + if (!usedCloudProvider) { + logger.transcription.warn( + "Formatting skipped: Amical Cloud formatting requires cloud transcription", + ); + } else { + formattingUsed = true; + formattingModel = "amical-cloud"; + } } else { // Get default language model and look up provider - const modelId = await this.settingsService.getDefaultLanguageModel(); + const modelId = + formatterConfig.modelId || + (await this.settingsService.getDefaultLanguageModel()); if (!modelId) { logger.transcription.debug( "Formatting skipped: no default language model", diff --git a/apps/desktop/src/trpc/routers/settings.ts b/apps/desktop/src/trpc/routers/settings.ts index f612075..ae7472d 100644 --- a/apps/desktop/src/trpc/routers/settings.ts +++ b/apps/desktop/src/trpc/routers/settings.ts @@ -10,6 +10,8 @@ import * as fs from "fs/promises"; // FormatterConfig schema const FormatterConfigSchema = z.object({ enabled: z.boolean(), + modelId: z.string().optional(), + fallbackModelId: z.string().optional(), }); // Shortcut schema (array of key names) diff --git a/apps/desktop/src/types/formatter.ts b/apps/desktop/src/types/formatter.ts index e38f0bf..4108aca 100644 --- a/apps/desktop/src/types/formatter.ts +++ b/apps/desktop/src/types/formatter.ts @@ -1,3 +1,5 @@ export interface FormatterConfig { enabled: boolean; + modelId?: string; + fallbackModelId?: string; }