mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
feat: add config v2 to simplify billing (#428)
* feat: add model config v2 * chore: centralize user org selection * chore: move preferences to platform settings * fix: decouple org preference and ai model preferences
This commit is contained in:
parent
49e68b49d5
commit
cdbd06c8d9
42 changed files with 5135 additions and 264 deletions
419
ui/src/components/AIModelConfigurationV2Editor.tsx
Normal file
419
ui/src/components/AIModelConfigurationV2Editor.tsx
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound, Save } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen";
|
||||
import {
|
||||
type ProviderSchema,
|
||||
type ServiceConfigurationDefaults,
|
||||
ServiceConfigurationForm,
|
||||
type ServiceSegment,
|
||||
} from "@/components/ServiceConfigurationForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
|
||||
|
||||
type ModelMode = "dograh" | "byok";
|
||||
|
||||
interface DograhDefaults {
|
||||
voices: string[];
|
||||
speeds: number[];
|
||||
languages: string[];
|
||||
defaults: {
|
||||
voice: string;
|
||||
speed: number;
|
||||
language: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelConfigurationDefaultsV2 {
|
||||
dograh: DograhDefaults;
|
||||
byok: {
|
||||
pipeline: ServiceConfigurationDefaults;
|
||||
realtime: {
|
||||
realtime: Record<string, ProviderSchema>;
|
||||
llm: Record<string, ProviderSchema>;
|
||||
embeddings: Record<string, ProviderSchema>;
|
||||
default_providers: ServiceConfigurationDefaults["default_providers"];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface DograhFormState {
|
||||
api_key: string;
|
||||
voice: string;
|
||||
speed: number;
|
||||
language: string;
|
||||
}
|
||||
|
||||
interface AIModelConfigurationV2EditorProps {
|
||||
defaults: ModelConfigurationDefaultsV2;
|
||||
configuration?: OrganizationAiModelConfigurationV2 | Record<string, unknown> | null;
|
||||
effectiveConfiguration?: Record<string, unknown> | null;
|
||||
onSave: (configuration: OrganizationAiModelConfigurationV2) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
function firstApiKey(value: unknown): string {
|
||||
if (Array.isArray(value)) return String(value[0] || "");
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function isDograhEffectiveConfig(config: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!config || config.is_realtime) return false;
|
||||
const llm = asRecord(config.llm);
|
||||
const tts = asRecord(config.tts);
|
||||
const stt = asRecord(config.stt);
|
||||
return llm?.provider === "dograh" && tts?.provider === "dograh" && stt?.provider === "dograh";
|
||||
}
|
||||
|
||||
function byokDefaults(defaults: ModelConfigurationDefaultsV2): ServiceConfigurationDefaults {
|
||||
return {
|
||||
llm: defaults.byok.pipeline.llm,
|
||||
tts: defaults.byok.pipeline.tts,
|
||||
stt: defaults.byok.pipeline.stt,
|
||||
embeddings: defaults.byok.pipeline.embeddings,
|
||||
realtime: defaults.byok.realtime.realtime,
|
||||
default_providers: defaults.byok.pipeline.default_providers,
|
||||
};
|
||||
}
|
||||
|
||||
function byokConfigToLegacyShape(config: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (!config || config.mode !== "byok") return null;
|
||||
const byok = asRecord(config.byok);
|
||||
if (!byok) return null;
|
||||
|
||||
if (byok.mode === "realtime") {
|
||||
const realtime = asRecord(byok.realtime);
|
||||
return {
|
||||
is_realtime: true,
|
||||
realtime: realtime?.realtime,
|
||||
llm: realtime?.llm,
|
||||
embeddings: realtime?.embeddings,
|
||||
};
|
||||
}
|
||||
|
||||
const pipeline = asRecord(byok.pipeline);
|
||||
return {
|
||||
is_realtime: false,
|
||||
llm: pipeline?.llm,
|
||||
tts: pipeline?.tts,
|
||||
stt: pipeline?.stt,
|
||||
embeddings: pipeline?.embeddings,
|
||||
};
|
||||
}
|
||||
|
||||
function effectiveConfigToLegacyShape(config: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (!config) return null;
|
||||
return {
|
||||
is_realtime: Boolean(config.is_realtime),
|
||||
llm: config.llm,
|
||||
tts: config.tts,
|
||||
stt: config.stt,
|
||||
realtime: config.realtime,
|
||||
embeddings: config.embeddings,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyByokInitialConfig(): Record<string, unknown> {
|
||||
return {
|
||||
is_realtime: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getByokInitialConfig(
|
||||
configuration: Record<string, unknown> | null,
|
||||
effectiveConfiguration: Record<string, unknown> | null,
|
||||
): Record<string, unknown> {
|
||||
const byokConfiguration = byokConfigToLegacyShape(configuration);
|
||||
if (byokConfiguration) return byokConfiguration;
|
||||
|
||||
if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) {
|
||||
return emptyByokInitialConfig();
|
||||
}
|
||||
|
||||
return effectiveConfigToLegacyShape(effectiveConfiguration) || emptyByokInitialConfig();
|
||||
}
|
||||
|
||||
function buildDograhState(
|
||||
defaults: ModelConfigurationDefaultsV2,
|
||||
configuration: Record<string, unknown> | null,
|
||||
effectiveConfiguration: Record<string, unknown> | null,
|
||||
): DograhFormState {
|
||||
const fallback = defaults.dograh.defaults;
|
||||
const configuredDograh = configuration?.mode === "dograh" ? asRecord(configuration.dograh) : null;
|
||||
if (configuredDograh) {
|
||||
return {
|
||||
api_key: String(configuredDograh.api_key || ""),
|
||||
voice: String(configuredDograh.voice || fallback.voice),
|
||||
speed: Number(configuredDograh.speed || fallback.speed),
|
||||
language: String(configuredDograh.language || fallback.language),
|
||||
};
|
||||
}
|
||||
|
||||
if (isDograhEffectiveConfig(effectiveConfiguration)) {
|
||||
const llm = asRecord(effectiveConfiguration?.llm);
|
||||
const tts = asRecord(effectiveConfiguration?.tts);
|
||||
const stt = asRecord(effectiveConfiguration?.stt);
|
||||
return {
|
||||
api_key: firstApiKey(llm?.api_key || tts?.api_key || stt?.api_key),
|
||||
voice: String(tts?.voice || fallback.voice),
|
||||
speed: Number(tts?.speed || fallback.speed),
|
||||
language: String(stt?.language || fallback.language),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
api_key: "",
|
||||
voice: fallback.voice,
|
||||
speed: fallback.speed,
|
||||
language: fallback.language,
|
||||
};
|
||||
}
|
||||
|
||||
function preferredMode(
|
||||
configuration: Record<string, unknown> | null,
|
||||
effectiveConfiguration: Record<string, unknown> | null,
|
||||
): ModelMode {
|
||||
if (configuration?.mode === "dograh" || configuration?.mode === "byok") {
|
||||
return configuration.mode;
|
||||
}
|
||||
return isDograhEffectiveConfig(effectiveConfiguration) ? "dograh" : "byok";
|
||||
}
|
||||
|
||||
function hasRequiredApiKey(
|
||||
service: ServiceSegment,
|
||||
serviceConfiguration: Record<string, unknown>,
|
||||
defaults: ServiceConfigurationDefaults,
|
||||
): boolean {
|
||||
const provider = serviceConfiguration.provider as string | undefined;
|
||||
if (!provider) return false;
|
||||
const providerSchema = service === "realtime"
|
||||
? defaults.realtime?.[provider]
|
||||
: defaults[service as "llm" | "tts" | "stt" | "embeddings"]?.[provider];
|
||||
const requiresApiKey = providerSchema?.required?.includes("api_key") ?? false;
|
||||
if (!requiresApiKey) return true;
|
||||
|
||||
const apiKey = serviceConfiguration.api_key;
|
||||
if (Array.isArray(apiKey)) {
|
||||
return apiKey.some((key) => typeof key === "string" && key.trim().length > 0);
|
||||
}
|
||||
return typeof apiKey === "string" && apiKey.trim().length > 0;
|
||||
}
|
||||
|
||||
function requireByokService(
|
||||
config: Record<string, unknown>,
|
||||
service: ServiceSegment,
|
||||
defaults: ServiceConfigurationDefaults,
|
||||
): Record<string, unknown> {
|
||||
const serviceConfiguration = asRecord(config[service]);
|
||||
if (
|
||||
!serviceConfiguration
|
||||
|| !serviceConfiguration.provider
|
||||
|| serviceConfiguration.provider === "dograh"
|
||||
|| !hasRequiredApiKey(service, serviceConfiguration, defaults)
|
||||
) {
|
||||
throw new Error(`${service} configuration is required`);
|
||||
}
|
||||
return serviceConfiguration;
|
||||
}
|
||||
|
||||
function optionalByokService(config: Record<string, unknown>, service: ServiceSegment): Record<string, unknown> | undefined {
|
||||
const serviceConfiguration = asRecord(config[service]);
|
||||
if (!serviceConfiguration?.provider || serviceConfiguration.provider === "dograh") return undefined;
|
||||
return serviceConfiguration;
|
||||
}
|
||||
|
||||
export function AIModelConfigurationV2Editor({
|
||||
defaults,
|
||||
configuration,
|
||||
effectiveConfiguration,
|
||||
onSave,
|
||||
submitLabel = "Save Configuration",
|
||||
}: AIModelConfigurationV2EditorProps) {
|
||||
const defaultsForByok = useMemo(() => byokDefaults(defaults), [defaults]);
|
||||
const [mode, setMode] = useState<ModelMode>("dograh");
|
||||
const [dograh, setDograh] = useState<DograhFormState>(() => ({
|
||||
api_key: "",
|
||||
voice: defaults.dograh.defaults.voice,
|
||||
speed: defaults.dograh.defaults.speed,
|
||||
language: defaults.dograh.defaults.language,
|
||||
}));
|
||||
const [byokInitialConfig, setByokInitialConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [isSavingDograh, setIsSavingDograh] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const rawConfiguration = asRecord(configuration);
|
||||
const rawEffectiveConfiguration = asRecord(effectiveConfiguration);
|
||||
setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration));
|
||||
setDograh(buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration));
|
||||
setByokInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration));
|
||||
}, [configuration, defaults, effectiveConfiguration]);
|
||||
|
||||
const saveDograhConfiguration = async () => {
|
||||
setIsSavingDograh(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({
|
||||
version: 2,
|
||||
mode: "dograh",
|
||||
dograh: {
|
||||
api_key: dograh.api_key.trim(),
|
||||
voice: dograh.voice,
|
||||
speed: dograh.speed,
|
||||
language: dograh.language,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save configuration");
|
||||
} finally {
|
||||
setIsSavingDograh(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveByokConfiguration = async (config: Record<string, unknown>) => {
|
||||
setError(null);
|
||||
const isRealtime = Boolean(config.is_realtime);
|
||||
const llm = requireByokService(config, "llm", defaultsForByok);
|
||||
const embeddings = optionalByokService(config, "embeddings");
|
||||
const body: OrganizationAiModelConfigurationV2 = {
|
||||
version: 2,
|
||||
mode: "byok",
|
||||
byok: isRealtime
|
||||
? {
|
||||
mode: "realtime",
|
||||
realtime: {
|
||||
realtime: requireByokService(config, "realtime", defaultsForByok) as never,
|
||||
llm: llm as never,
|
||||
...(embeddings ? { embeddings: embeddings as never } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
mode: "pipeline",
|
||||
pipeline: {
|
||||
llm: llm as never,
|
||||
tts: requireByokService(config, "tts", defaultsForByok) as never,
|
||||
stt: requireByokService(config, "stt", defaultsForByok) as never,
|
||||
...(embeddings ? { embeddings: embeddings as never } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await onSave(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs value={mode} onValueChange={(value) => setMode(value as ModelMode)} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="dograh">Dograh</TabsTrigger>
|
||||
<TabsTrigger value="byok">BYOK</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dograh" className="mt-0">
|
||||
<div className="rounded-lg border p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Voice</Label>
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.voices.map((voice) => (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
{voice}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Speed</Label>
|
||||
<Select
|
||||
value={String(dograh.speed)}
|
||||
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select speed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.speeds.map((speed) => (
|
||||
<SelectItem key={speed} value={String(speed)}>
|
||||
{speed}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.languages.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{LANGUAGE_DISPLAY_NAMES[language] || language}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSavingDograh ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="byok" className="mt-0">
|
||||
<ServiceConfigurationForm
|
||||
key={JSON.stringify(byokInitialConfig)}
|
||||
mode="global"
|
||||
configurationDefaults={defaultsForByok}
|
||||
initialConfig={byokInitialConfig}
|
||||
submitLabel={submitLabel}
|
||||
onSave={saveByokConfiguration}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
ui/src/components/ModelConfigurationV2.tsx
Normal file
274
ui/src/components/ModelConfigurationV2.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get,
|
||||
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet,
|
||||
migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost,
|
||||
saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put,
|
||||
} from "@/client/sdk.gen";
|
||||
import type {
|
||||
OrganizationAiModelConfigurationResponse,
|
||||
OrganizationAiModelConfigurationV2,
|
||||
} from "@/client/types.gen";
|
||||
import { AIModelConfigurationV2Editor, type ModelConfigurationDefaultsV2 } from "@/components/AIModelConfigurationV2Editor";
|
||||
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export default function ModelConfigurationV2({
|
||||
docsUrl,
|
||||
initialAction,
|
||||
}: {
|
||||
docsUrl?: string;
|
||||
initialAction?: string;
|
||||
}) {
|
||||
const auth = useAuth();
|
||||
const { refreshConfig, saveUserConfig } = useUserConfig();
|
||||
const hasFetched = useRef(false);
|
||||
const hasAppliedInitialMigrationAction = useRef(false);
|
||||
|
||||
const [defaults, setDefaults] = useState<ModelConfigurationDefaultsV2 | null>(null);
|
||||
const [response, setResponse] = useState<OrganizationAiModelConfigurationResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const applyResponse = (nextResponse: OrganizationAiModelConfigurationResponse) => {
|
||||
setResponse(nextResponse);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.loading || !auth.user || hasFetched.current) return;
|
||||
hasFetched.current = true;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [defaultsResult, configResult] = await Promise.all([
|
||||
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(),
|
||||
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(),
|
||||
]);
|
||||
|
||||
if (defaultsResult.error) {
|
||||
setError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (configResult.error) {
|
||||
setError(detailFromError(configResult.error, "Failed to load model configuration"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDefaults = defaultsResult.data as ModelConfigurationDefaultsV2;
|
||||
if (!nextDefaults || !configResult.data) {
|
||||
setError("Failed to load model configuration");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setDefaults(nextDefaults);
|
||||
applyResponse(configResult.data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
}, [auth.loading, auth.user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAppliedInitialMigrationAction.current) return;
|
||||
if (initialAction !== "migrate_to_v2") return;
|
||||
if (loading || response?.source !== "legacy_user_v1") return;
|
||||
hasAppliedInitialMigrationAction.current = true;
|
||||
setMigrationDialogOpen(true);
|
||||
}, [initialAction, loading, response?.source]);
|
||||
|
||||
const saveConfiguration = async (configuration: OrganizationAiModelConfigurationV2) => {
|
||||
if (!defaults) return;
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
|
||||
const result = await saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put({
|
||||
body: configuration,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(detailFromError(result.error, "Failed to save model configuration"));
|
||||
}
|
||||
if (!result.data) {
|
||||
throw new Error("Failed to save model configuration");
|
||||
}
|
||||
|
||||
applyResponse(result.data);
|
||||
await refreshConfig();
|
||||
setNotice("Model configuration saved");
|
||||
};
|
||||
|
||||
const migrateConfiguration = async () => {
|
||||
if (!defaults) return;
|
||||
setMigrating(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
|
||||
const result = await migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost();
|
||||
if (result.error) {
|
||||
setError(detailFromError(result.error, "Failed to migrate model configuration"));
|
||||
} else if (!result.data) {
|
||||
setError("Failed to migrate model configuration");
|
||||
} else {
|
||||
applyResponse(result.data);
|
||||
await refreshConfig();
|
||||
setNotice("Configuration migrated to v2");
|
||||
setMigrationDialogOpen(false);
|
||||
}
|
||||
setMigrating(false);
|
||||
};
|
||||
|
||||
const migrationWarningDialog = (
|
||||
<AlertDialog open={migrationDialogOpen} onOpenChange={setMigrationDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Migrate model configuration to v2?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Your configurations will be migrated to v2. After migration, check your global configuration and workflow model overrides, then run a test call to make sure everything is working.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={migrating}>Cancel</AlertDialogCancel>
|
||||
<Button type="button" onClick={migrateConfiguration} disabled={migrating}>
|
||||
{migrating ? "Migrating..." : "Migrate to v2"}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto space-y-6">
|
||||
<Skeleton className="h-10 w-80" />
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const source = response?.source || "empty";
|
||||
|
||||
if (source !== "organization_v2") {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold">AI Models Configuration</h1>
|
||||
<Badge variant="outline">
|
||||
{source === "legacy_user_v1" ? "legacy" : "v1"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Configure your AI model, voice, and transcription services.{" "}
|
||||
{docsUrl && (
|
||||
<a href={docsUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">
|
||||
Learn more <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{source === "legacy_user_v1" && (
|
||||
<Button type="button" variant="outline" onClick={() => setMigrationDialogOpen(true)} disabled={migrating}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{migrating ? "Migrating..." : "Migrate to v2"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="rounded-md border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-300">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ServiceConfigurationForm
|
||||
mode="global"
|
||||
onSave={async (config) => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
await saveUserConfig(config as Parameters<typeof saveUserConfig>[0]);
|
||||
await refreshConfig();
|
||||
if (defaults) {
|
||||
const configResult = await getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get();
|
||||
if (configResult.data) {
|
||||
applyResponse(configResult.data);
|
||||
}
|
||||
}
|
||||
setNotice("Configuration saved");
|
||||
}}
|
||||
/>
|
||||
{migrationWarningDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">AI Models Configuration</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Organization-scoped model settings.{" "}
|
||||
{docsUrl && (
|
||||
<a href={docsUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">
|
||||
Learn more <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="rounded-md border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-300">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{defaults && response && (
|
||||
<AIModelConfigurationV2Editor
|
||||
defaults={defaults}
|
||||
configuration={response.configuration}
|
||||
effectiveConfiguration={response.effective_configuration}
|
||||
onSave={saveConfiguration}
|
||||
/>
|
||||
)}
|
||||
{migrationWarningDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
ui/src/components/OrganizationPreferencesSection.tsx
Normal file
221
ui/src/components/OrganizationPreferencesSection.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"use client";
|
||||
|
||||
import { Save } from "lucide-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import TimezoneSelect, { type ITimezoneOption } from "react-timezone-select";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getPreferencesApiV1OrganizationsPreferencesGet,
|
||||
savePreferencesApiV1OrganizationsPreferencesPut,
|
||||
} from "@/client/sdk.gen";
|
||||
import type { OrganizationPreferences } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
const emptyPreferences: OrganizationPreferences = {
|
||||
test_phone_number: "",
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
};
|
||||
|
||||
const timezoneSelectStyles = {
|
||||
control: (base: Record<string, unknown>, state: { isFocused: boolean }) => ({
|
||||
...base,
|
||||
minHeight: "36px",
|
||||
fontSize: "14px",
|
||||
backgroundColor: "var(--background)",
|
||||
borderColor: state.isFocused ? "var(--ring)" : "var(--border)",
|
||||
boxShadow: state.isFocused
|
||||
? "0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent)"
|
||||
: "none",
|
||||
"&:hover": { borderColor: "var(--border)" },
|
||||
}),
|
||||
menu: (base: Record<string, unknown>) => ({
|
||||
...base,
|
||||
zIndex: 9999,
|
||||
backgroundColor: "var(--popover)",
|
||||
border: "1px solid var(--border)",
|
||||
boxShadow:
|
||||
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
|
||||
}),
|
||||
menuList: (base: Record<string, unknown>) => ({
|
||||
...base,
|
||||
backgroundColor: "var(--popover)",
|
||||
padding: 0,
|
||||
}),
|
||||
option: (
|
||||
base: Record<string, unknown>,
|
||||
state: { isFocused: boolean; isSelected: boolean },
|
||||
) => ({
|
||||
...base,
|
||||
backgroundColor: state.isSelected
|
||||
? "var(--accent)"
|
||||
: state.isFocused
|
||||
? "var(--accent)"
|
||||
: "var(--popover)",
|
||||
color: "var(--foreground)",
|
||||
cursor: "pointer",
|
||||
"&:active": { backgroundColor: "var(--accent)" },
|
||||
}),
|
||||
singleValue: (base: Record<string, unknown>) => ({
|
||||
...base,
|
||||
color: "var(--foreground)",
|
||||
}),
|
||||
input: (base: Record<string, unknown>) => ({
|
||||
...base,
|
||||
color: "var(--foreground)",
|
||||
}),
|
||||
placeholder: (base: Record<string, unknown>) => ({
|
||||
...base,
|
||||
color: "var(--muted-foreground)",
|
||||
}),
|
||||
indicatorSeparator: (base: Record<string, unknown>) => ({
|
||||
...base,
|
||||
backgroundColor: "var(--border)",
|
||||
}),
|
||||
dropdownIndicator: (base: Record<string, unknown>) => ({
|
||||
...base,
|
||||
color: "var(--muted-foreground)",
|
||||
"&:hover": { color: "var(--foreground)" },
|
||||
}),
|
||||
};
|
||||
|
||||
function getTimezoneValue(tz: ITimezoneOption | string): string {
|
||||
return typeof tz === "string" ? tz : tz.value;
|
||||
}
|
||||
|
||||
export function OrganizationPreferencesSection() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { refreshConfig } = useUserConfig();
|
||||
const timezoneSelectId = useId();
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
const [preferences, setPreferences] =
|
||||
useState<OrganizationPreferences>(emptyPreferences);
|
||||
const [timezone, setTimezone] = useState<ITimezoneOption | string>(
|
||||
emptyPreferences.timezone || "UTC",
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || !user || hasFetched.current) {
|
||||
return;
|
||||
}
|
||||
hasFetched.current = true;
|
||||
void fetchPreferences();
|
||||
}, [authLoading, user]);
|
||||
|
||||
async function fetchPreferences() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result =
|
||||
await getPreferencesApiV1OrganizationsPreferencesGet();
|
||||
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
detailFromError(
|
||||
result.error,
|
||||
"Failed to load organization preferences",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPreferences = result.data || emptyPreferences;
|
||||
setPreferences({
|
||||
test_phone_number: nextPreferences.test_phone_number || "",
|
||||
timezone: nextPreferences.timezone || emptyPreferences.timezone,
|
||||
});
|
||||
setTimezone(
|
||||
nextPreferences.timezone || emptyPreferences.timezone || "UTC",
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to load organization preferences");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result =
|
||||
await savePreferencesApiV1OrganizationsPreferencesPut(
|
||||
{
|
||||
body: {
|
||||
test_phone_number: preferences.test_phone_number || null,
|
||||
timezone: getTimezoneValue(timezone),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(detailFromError(result.error, "Failed to save preferences"));
|
||||
return;
|
||||
}
|
||||
if (!result.data) {
|
||||
toast.error("Failed to save preferences");
|
||||
return;
|
||||
}
|
||||
|
||||
setPreferences({
|
||||
test_phone_number: result.data.test_phone_number || "",
|
||||
timezone: result.data.timezone || emptyPreferences.timezone,
|
||||
});
|
||||
setTimezone(result.data.timezone || emptyPreferences.timezone || "UTC");
|
||||
await refreshConfig();
|
||||
toast.success("Preferences saved");
|
||||
} catch {
|
||||
toast.error("Failed to save preferences");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Set organization-wide defaults used by testing and scheduling flows.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-test-phone-number">Test Phone Number</Label>
|
||||
<Input
|
||||
id="settings-test-phone-number"
|
||||
value={preferences.test_phone_number || ""}
|
||||
onChange={(event) =>
|
||||
setPreferences({
|
||||
...preferences,
|
||||
test_phone_number: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="+15551234567"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Timezone</Label>
|
||||
<TimezoneSelect
|
||||
instanceId={timezoneSelectId}
|
||||
value={timezone}
|
||||
onChange={setTimezone}
|
||||
styles={timezoneSelectStyles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
|
|||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import type { ModelOverrides } from "@/types/workflow-configurations";
|
||||
|
||||
type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
|
||||
export type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
|
||||
|
||||
interface SchemaProperty {
|
||||
type?: string;
|
||||
|
|
@ -35,7 +35,7 @@ interface SchemaProperty {
|
|||
docs_url?: string;
|
||||
}
|
||||
|
||||
interface ProviderSchema {
|
||||
export interface ProviderSchema {
|
||||
title?: string;
|
||||
description?: string;
|
||||
provider_docs_url?: string;
|
||||
|
|
@ -49,6 +49,15 @@ interface FormValues {
|
|||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
export interface ServiceConfigurationDefaults {
|
||||
llm: Record<string, ProviderSchema>;
|
||||
tts: Record<string, ProviderSchema>;
|
||||
stt: Record<string, ProviderSchema>;
|
||||
embeddings: Record<string, ProviderSchema>;
|
||||
realtime?: Record<string, ProviderSchema>;
|
||||
default_providers: Partial<Record<ServiceSegment, string>>;
|
||||
}
|
||||
|
||||
const STANDARD_TABS: { key: ServiceSegment; label: string }[] = [
|
||||
{ key: "llm", label: "LLM" },
|
||||
{ key: "tts", label: "Voice" },
|
||||
|
|
@ -90,6 +99,8 @@ export interface ServiceConfigurationFormProps {
|
|||
onSave: (config: Record<string, unknown>) => Promise<void>;
|
||||
/** Text for the submit button. Defaults to "Save Configuration". */
|
||||
submitLabel?: string;
|
||||
configurationDefaults?: ServiceConfigurationDefaults | null;
|
||||
initialConfig?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function getProviderDisplayName(
|
||||
|
|
@ -117,6 +128,8 @@ export function ServiceConfigurationForm({
|
|||
currentOverrides,
|
||||
onSave,
|
||||
submitLabel,
|
||||
configurationDefaults,
|
||||
initialConfig,
|
||||
}: ServiceConfigurationFormProps) {
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
|
@ -165,15 +178,16 @@ export function ServiceConfigurationForm({
|
|||
|
||||
// Build effective config source: overlay overrides onto global config
|
||||
const configSource = useMemo(() => {
|
||||
if (mode === 'global' || !currentOverrides) return userConfig;
|
||||
const baseConfig = initialConfig ?? userConfig;
|
||||
if (mode === 'global' || !currentOverrides) return baseConfig;
|
||||
// Merge overrides onto global config for form initialization
|
||||
const merged = { ...userConfig } as Record<string, unknown>;
|
||||
const merged = { ...baseConfig } as Record<string, unknown>;
|
||||
const overrideServices: (keyof ModelOverrides)[] = ["llm", "tts", "stt", "realtime"];
|
||||
for (const svc of overrideServices) {
|
||||
if (svc === "is_realtime") continue;
|
||||
const overrideVal = currentOverrides[svc];
|
||||
if (overrideVal && typeof overrideVal === "object") {
|
||||
const globalVal = (userConfig as Record<string, unknown> | null)?.[svc] as Record<string, unknown> | undefined;
|
||||
const globalVal = (baseConfig as Record<string, unknown> | null)?.[svc] as Record<string, unknown> | undefined;
|
||||
merged[svc] = { ...globalVal, ...overrideVal };
|
||||
}
|
||||
}
|
||||
|
|
@ -181,24 +195,35 @@ export function ServiceConfigurationForm({
|
|||
merged.is_realtime = currentOverrides.is_realtime;
|
||||
}
|
||||
return merged as typeof userConfig;
|
||||
}, [mode, userConfig, currentOverrides]);
|
||||
}, [mode, userConfig, currentOverrides, initialConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConfigurations = async () => {
|
||||
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
|
||||
if (!response.data) {
|
||||
console.error("Failed to fetch configurations");
|
||||
return;
|
||||
let defaultsData = configurationDefaults;
|
||||
if (!defaultsData) {
|
||||
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
|
||||
if (!response.data) {
|
||||
console.error("Failed to fetch configurations");
|
||||
return;
|
||||
}
|
||||
defaultsData = response.data as ServiceConfigurationDefaults;
|
||||
}
|
||||
|
||||
const data = response.data as Record<string, unknown>;
|
||||
const realtimeSchemas = (data.realtime || {}) as Record<string, ProviderSchema>;
|
||||
const realtimeSchemas = (defaultsData.realtime || {}) as Record<string, ProviderSchema>;
|
||||
const pickDefaultProvider = (
|
||||
service: ServiceSegment,
|
||||
schemaMap: Record<string, ProviderSchema>,
|
||||
) => {
|
||||
const preferred = defaultsData.default_providers?.[service];
|
||||
if (preferred && schemaMap[preferred]) return preferred;
|
||||
return Object.keys(schemaMap)[0] || "";
|
||||
};
|
||||
|
||||
setSchemas({
|
||||
llm: response.data.llm as Record<string, ProviderSchema>,
|
||||
tts: response.data.tts as Record<string, ProviderSchema>,
|
||||
stt: response.data.stt as Record<string, ProviderSchema>,
|
||||
embeddings: response.data.embeddings as Record<string, ProviderSchema>,
|
||||
llm: defaultsData.llm,
|
||||
tts: defaultsData.tts,
|
||||
stt: defaultsData.stt,
|
||||
embeddings: defaultsData.embeddings,
|
||||
realtime: realtimeSchemas,
|
||||
});
|
||||
|
||||
|
|
@ -210,10 +235,10 @@ export function ServiceConfigurationForm({
|
|||
|
||||
const defaultValues: Record<string, string | number | boolean> = {};
|
||||
const selectedProviders: Record<ServiceSegment, string> = {
|
||||
llm: response.data.default_providers.llm,
|
||||
tts: response.data.default_providers.tts,
|
||||
stt: response.data.default_providers.stt,
|
||||
embeddings: response.data.default_providers.embeddings,
|
||||
llm: pickDefaultProvider("llm", defaultsData.llm),
|
||||
tts: pickDefaultProvider("tts", defaultsData.tts),
|
||||
stt: pickDefaultProvider("stt", defaultsData.stt),
|
||||
embeddings: pickDefaultProvider("embeddings", defaultsData.embeddings),
|
||||
realtime: "",
|
||||
};
|
||||
|
||||
|
|
@ -237,7 +262,7 @@ export function ServiceConfigurationForm({
|
|||
|
||||
const schemaSource = service === "realtime"
|
||||
? realtimeSchemas
|
||||
: response.data![service as "llm" | "tts" | "stt" | "embeddings"] as Record<string, ProviderSchema> | undefined;
|
||||
: defaultsData[service as "llm" | "tts" | "stt" | "embeddings"] as Record<string, ProviderSchema> | undefined;
|
||||
|
||||
if (src?.provider) {
|
||||
Object.entries(src).forEach(([field, value]) => {
|
||||
|
|
@ -296,7 +321,7 @@ export function ServiceConfigurationForm({
|
|||
|
||||
// Detect custom inputs
|
||||
const detectedCustomInput: Record<string, boolean> = {};
|
||||
const allSchemas = { ...response.data, realtime: realtimeSchemas } as unknown as Record<string, Record<string, ProviderSchema>>;
|
||||
const allSchemas = { ...defaultsData, realtime: realtimeSchemas } as unknown as Record<string, Record<string, ProviderSchema>>;
|
||||
(["llm", "tts", "stt", "embeddings", "realtime"] as ServiceSegment[]).forEach(service => {
|
||||
const provider = selectedProviders[service];
|
||||
const providerSchema = allSchemas[service]?.[provider];
|
||||
|
|
@ -337,7 +362,7 @@ export function ServiceConfigurationForm({
|
|||
};
|
||||
fetchConfigurations();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reset, configSource]);
|
||||
}, [reset, configSource, configurationDefaults]);
|
||||
|
||||
// Reset voice when TTS model changes if the provider has model-dependent voice options
|
||||
const ttsModel = watch("tts_model");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue