chore: add cloud support for formatting
This commit is contained in:
parent
cf5a113534
commit
9aba2f780b
12 changed files with 543 additions and 136 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface TranscribeContext {
|
|||
previousChunk?: string;
|
||||
aggregatedTranscription?: string;
|
||||
language?: string;
|
||||
formattingEnabled?: boolean;
|
||||
}
|
||||
|
||||
// Transcription input parameters
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export interface FormatterConfig {
|
||||
enabled: boolean;
|
||||
modelId?: string;
|
||||
fallbackModelId?: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue