chore: add cloud support for formatting

This commit is contained in:
nchopra 2026-01-15 00:03:16 +05:30
parent cf5a113534
commit 9aba2f780b
12 changed files with 543 additions and 136 deletions

View file

@ -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({
<CommandEmpty>No option found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
setOpen(false);
onChange(currentValue === value ? "" : currentValue);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
{option.label}
</CommandItem>
<div key={option.value}>
<CommandItem
value={option.value}
disabled={option.disabled}
onSelect={(currentValue) => {
if (option.disabled) {
return;
}
setOpen(false);
onChange(currentValue === value ? "" : currentValue);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
{option.label}
</CommandItem>
{option.disabled && option.disabledReason && (
<p className="text-[10px] text-muted-foreground px-2 pb-1 -mt-0.5">
{option.disabledReason}
</p>
)}
</div>
))}
</CommandGroup>
</CommandList>

View file

@ -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<AppSettingsData, "shortcuts"> {
@ -68,6 +68,29 @@ const migrations: Record<number, MigrationFn> = {
: 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;
},
};
/**

View file

@ -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";

View file

@ -14,6 +14,7 @@ export interface TranscribeContext {
previousChunk?: string;
aggregatedTranscription?: string;
language?: string;
formattingEnabled?: boolean;
}
// Transcription input parameters

View file

@ -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;

View file

@ -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() {
</Avatar>
<span>{model.provider}</span>
</div>
{isCloudModel && (
<div className="mt-1">
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0"
>
Formatting available
</Badge>
</TooltipTrigger>
<TooltipContent>
Cloud formatting is available when
this model is selected.
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</div>
</TableCell>

View file

@ -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 (
<div className="">
<div className="flex items-center justify-between mb-2">
@ -62,7 +42,7 @@ export function FormattingSettings() {
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
Enable context based transcription formatting.
Apply punctuation and structure to your transcriptions.
</p>
</div>
<Tooltip delayDuration={100}>
@ -71,13 +51,14 @@ export function FormattingSettings() {
<Switch
checked={formattingEnabled}
onCheckedChange={handleFormattingEnabledChange}
disabled={!hasModels}
disabled={disableFormattingToggle}
/>
</div>
</TooltipTrigger>
{!hasModels && (
{disableFormattingToggle && (
<TooltipContent className="max-w-sm text-center">
Please sync AI models first to enable formatting functionality.
Sync a language model or select Amical Cloud transcription to
enable formatting.
</TooltipContent>
)}
</Tooltip>
@ -99,30 +80,62 @@ export function FormattingSettings() {
<div className="space-y-4">
<div>
<Label className="text-sm font-medium text-foreground mb-2 block">
Formatting Model
Formatting model
</Label>
<p className="text-xs text-muted-foreground mb-4">
Select the language model to use for formatting transcriptions.
Choose the model used to format your transcription.
</p>
</div>
{!hasModels ? (
<div className="flex flex-col items-center gap-2">
<span className="text-destructive text-sm">
No models available. Please sync models first.
</span>
<Link to="/settings/ai-models" search={{ tab: "language" }}>
<Button variant="outline" size={"sm"}>
<Plus className="w-4 h-4 mr-1" />
Sync models
</Button>
</Link>
</div>
) : (
<DefaultModelCombobox
modelType="language"
title="Default Language Model"
<div className="space-y-3">
<Combobox
options={formattingOptions}
value={selectedModelId}
onChange={handleFormattingModelChange}
placeholder="Select a model..."
disabled={!hasFormattingOptions}
/>
)}
{showCloudRequiresSpeech && (
<div className="flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span>Requires Amical Cloud transcription.</span>
<Link to="/settings/ai-models" search={{ tab: "speech" }}>
<Button variant="outline" size="sm">
Switch speech model
</Button>
</Link>
</div>
)}
{showCloudRequiresAuth && (
<div className="flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span>Sign in to use Amical Cloud formatting.</span>
<Button
variant="outline"
size="sm"
onClick={handleCloudLogin}
disabled={isLoginPending}
>
Sign in
</Button>
</div>
)}
{showCloudReady && (
<p className="text-xs text-muted-foreground">
Using Amical Cloud formatting.
</p>
)}
{showNoLanguageModels && (
<div className="flex items-center justify-between rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-xs text-muted-foreground">
<span>
Formatting won't run no language model available.
</span>
<Link to="/settings/ai-models" search={{ tab: "language" }}>
<Button variant="outline" size="sm">
<Plus className="w-4 h-4 mr-1" />
Sync language models
</Button>
</Link>
</div>
)}
</div>
</div>
</div>
)}

View file

@ -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<void>;
// 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<ComboboxOption[]>(() => {
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,
};
}

View file

@ -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<void> {
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<void> {
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<void> {
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,
);
}
}

View file

@ -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",

View file

@ -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)

View file

@ -1,3 +1,5 @@
export interface FormatterConfig {
enabled: boolean;
modelId?: string;
fallbackModelId?: string;
}